-
-
Sim: {{ resultado.numero_votos.votos_sim }}
- Não: {{ resultado.numero_votos.votos_nao }}
- Abstenções: {{ resultado.numero_votos.abstencoes }}
- Presentes: {{ resultado.numero_votos.num_presentes }}
- Total votos: {{ resultado.numero_votos.total_votos }}
-
-
{{ resultado.resultado_votacao }}
+
+
+
+
+
+ Presentes
+ {{ numPresentes }}
+
+
+ Sim
+ {{ votosSim }}
+
+
+ Não
+ {{ votosNao }}
+
+
+ Abstenções
+ {{ votosAbstencao }}
+
+
+ Total votos
+ {{ totalVotos }}
+
+
+
@@ -28,18 +35,6 @@ export default {
name: 'PainelResultado',
data() {
return {
- /*
- resultado: {
- numero_votos: {
- votos_sim: 0,
- votos_nao: 0,
- abstencoes: 0,
- total_votos: 0,
- num_presentes: 0,
- },
- resultado_votacao: '',
- }
- */
};
},
mounted() {
@@ -50,20 +45,64 @@ export default {
canRender () {
return this.sessao_aberta && this.painel_aberto;
},
- ...mapState(["painel_aberto", "sessao_aberta", "resultado"])
- },
- methods: {
- changeFontSize(value) {
- const el = this.$refs.votacao;
- if (!el) return;
- let fontSize = window.getComputedStyle(el).fontSize;
- fontSize = parseFloat(fontSize); // safely convert "16px" → 16
- el.style.fontSize = (fontSize + value) + 'px';
+ ...mapState(["painel_aberto", "sessao_aberta", "resultado", "parlamentares"]),
+ numPresentes() {
+ if (this.resultado && this.resultado.numero_votos && typeof this.resultado.numero_votos.num_presentes !== 'undefined' && this.resultado.numero_votos.num_presentes !== null) {
+ return this.resultado.numero_votos.num_presentes;
+ }
+ return this.parlamentares ? this.parlamentares.length : 0;
+ },
+ votosSim() {
+ if (this.resultado && this.resultado.numero_votos && typeof this.resultado.numero_votos.votos_sim !== 'undefined' && this.resultado.numero_votos.votos_sim !== null && this.resultado.numero_votos.votos_sim > 0) {
+ return this.resultado.numero_votos.votos_sim;
+ }
+ return this.parlamentares ? this.parlamentares.filter(p => p.voto === 'Sim').length : 0;
},
- }
+ votosNao() {
+ if (this.resultado && this.resultado.numero_votos && typeof this.resultado.numero_votos.votos_nao !== 'undefined' && this.resultado.numero_votos.votos_nao !== null && this.resultado.numero_votos.votos_nao > 0) {
+ return this.resultado.numero_votos.votos_nao;
+ }
+ return this.parlamentares ? this.parlamentares.filter(p => p.voto === 'Não').length : 0;
+ },
+ votosAbstencao() {
+ if (this.resultado && this.resultado.numero_votos && typeof this.resultado.numero_votos.abstencoes !== 'undefined' && this.resultado.numero_votos.abstencoes !== null && this.resultado.numero_votos.abstencoes > 0) {
+ return this.resultado.numero_votos.abstencoes;
+ }
+ return this.parlamentares ? this.parlamentares.filter(p => p.voto === 'Abstenção').length : 0;
+ },
+ totalVotos() {
+ if (this.resultado && this.resultado.numero_votos && typeof this.resultado.numero_votos.total_votos !== 'undefined' && this.resultado.numero_votos.total_votos !== null && this.resultado.numero_votos.total_votos > 0) {
+ return this.resultado.numero_votos.total_votos;
+ }
+ return this.votosSim + this.votosNao + this.votosAbstencao;
+ }
+ },
};
\ No newline at end of file
diff --git a/frontend/src/components/votacao/VotacaoMateria.vue b/frontend/src/components/votacao/VotacaoMateria.vue
new file mode 100644
index 000000000..e8f47b57d
--- /dev/null
+++ b/frontend/src/components/votacao/VotacaoMateria.vue
@@ -0,0 +1,25 @@
+
+
+
+ Matéria: {{ materia.texto }}
+
+ Ementa: {{ materia.ementa }}
+
+
+
+ Nenhuma matéria aberta para votação.
+
+
+
+
diff --git a/frontend/src/components/votacao/VotacaoNominal.vue b/frontend/src/components/votacao/VotacaoNominal.vue
index dd569d78f..128a57ed9 100644
--- a/frontend/src/components/votacao/VotacaoNominal.vue
+++ b/frontend/src/components/votacao/VotacaoNominal.vue
@@ -1,11 +1,73 @@
-
+
+ Votação Nominal
+
+
+ {{ error_message }}
+
+
+
+
+
+
+
+
+
+
+
+
+import { mapState } from 'vuex';
+export default {
+ name: 'VotacaoNominal',
+ props: {
+ isOpen: {
+ type: Boolean,
+ default: false
+ },
+ errorMessage: {
+ type: String,
+ default: ''
+ }
+ },
+ data() {
+ return {
+ edit_votes: true,
+ disable_resultado: false,
+ resultado_selected: '',
+ observacoes: '',
+ }
+ },
+ computed: {
+ ...mapState(['parlamentares', 'materia']),
+ error_message() {
+ return this.errorMessage;
+ }
+ },
+ methods: {
+ onCastVote({ parlamentar_id, voto }) {
+ this.$emit('cast-vote', { parlamentar_id, voto });
+ },
+ onCancelar() {
+ this.$emit('cancelar');
+ },
+ onFechar() {
+ this.$emit('fechar', {
+ resultado_selected: this.resultado_selected,
+ observacoes: this.observacoes
+ });
+ }
+ },
+ mounted() {
+ console.log('VotacaoNominal mounted');
+ }
+};
+
\ No newline at end of file
diff --git a/frontend/src/components/votacao/VotacaoObservacoes.vue b/frontend/src/components/votacao/VotacaoObservacoes.vue
new file mode 100644
index 000000000..0b6d1b369
--- /dev/null
+++ b/frontend/src/components/votacao/VotacaoObservacoes.vue
@@ -0,0 +1,84 @@
+
+
+
+
+ Resultado da Votação
+
+ ---------
+
+ {{ tipo.nome }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/votacao/VotacaoResultado.vue b/frontend/src/components/votacao/VotacaoResultado.vue
new file mode 100644
index 000000000..ee6befd5a
--- /dev/null
+++ b/frontend/src/components/votacao/VotacaoResultado.vue
@@ -0,0 +1,49 @@
+
+
+
Situação da Votação:
+
+
+
+
+
Abstenções: {{ votosAbstencao }}
+
+
+
Ainda não votaram: {{ naoVotou }}
+
+
+
+
+
+
diff --git a/frontend/src/components/votacao/VotacaoVotos.vue b/frontend/src/components/votacao/VotacaoVotos.vue
new file mode 100644
index 000000000..734647cdc
--- /dev/null
+++ b/frontend/src/components/votacao/VotacaoVotos.vue
@@ -0,0 +1,44 @@
+
+
+ Votos
+
+
+
+ {{ p.nome_parlamentar }}
+
+
+
+ Não Votou
+ Sim
+ Não
+ Abstenção
+
+
+
+
+
+
+
Não existe nenhum parlamentar presente para que a votação ocorra.
+
+
+
+
diff --git a/sapl/painel/consumers.py b/sapl/painel/consumers.py
index 0e6f06b97..fef75e2a3 100644
--- a/sapl/painel/consumers.py
+++ b/sapl/painel/consumers.py
@@ -12,16 +12,28 @@ from sapl.sessao.models import SessaoPlenaria, SessaoPresencasView, \
def get_materia_votacao(votacao):
+ if not votacao:
+ return {
+ "materia_id": "",
+ "texto": "",
+ "ementa": "",
+ "resultado": {
+ "resultado_votacao": "",
+ "resultado": "",
+ "numero_votos": {},
+ "votos_parlamentares": [],
+ },
+ }
+ materia = votacao.materia
return {
- # TOO UGLY! FIX THIS if-else
- "materia_id": votacao.materia.id if votacao and votacao.materia else "",
- "texto": str(votacao.materia) if votacao and votacao.materia else "",
- "ementa": votacao.materia.ementa if votacao and votacao.materia and votacao.materia.ementa else "",
+ "materia_id": materia.id if materia else "",
+ "texto": str(materia) if materia else "",
+ "ementa": materia.ementa if materia and materia.ementa else "",
"resultado": {
- "resultado_votacao": votacao.resultado_votacao if votacao and votacao.resultado_votacao else "",
- "resultado": votacao.resultado if votacao and votacao.resultado else "",
- "numero_votos": votacao.numero_votos if votacao and votacao.numero_votos else {},
- "votos_parlamentares": votacao.votos_parlamentares if votacao and votacao.votos_parlamentares else [],
+ "resultado_votacao": votacao.resultado_votacao or "",
+ "resultado": votacao.resultado or "",
+ "numero_votos": votacao.numero_votos or {},
+ "votos_parlamentares": votacao.votos_parlamentares or [],
},
}
@@ -65,6 +77,29 @@ def get_dados_painel(sessao_plenaria_id: int) -> dict:
'nome_parlamentar',
'filiacao', )
parlamentares = [dict(zip(['parlamentar_id', 'nome_parlamentar', 'filiacao'], p)) for p in presentes]
+
+ # Adicionar fotografia dos parlamentares
+ from sapl.parlamentares.models import Parlamentar
+ from image_cropping.utils import get_backend
+ parlamentar_ids = [p['parlamentar_id'] for p in parlamentares]
+ parlamentares_db = {p.id: p for p in Parlamentar.objects.filter(id__in=parlamentar_ids)}
+ for p in parlamentares:
+ par = parlamentares_db.get(p['parlamentar_id'])
+ if par and par.fotografia:
+ try:
+ p['fotografia'] = get_backend().get_thumbnail_url(
+ par.fotografia,
+ {
+ 'size': (128, 128),
+ 'box': par.cropping,
+ 'crop': True,
+ 'detail': True,
+ }
+ )
+ except Exception:
+ p['fotografia'] = None
+ else:
+ p['fotografia'] = None
if materia_votacao and materia_votacao.numero_votos:
materia_votacao.numero_votos.update({"num_presentes": len(parlamentares)})
@@ -84,6 +119,12 @@ def get_dados_painel(sessao_plenaria_id: int) -> dict:
"elapsed_ms": 5320
}
+ # Tipos de Resultado de Votação (for the resultado dropdown)
+ from sapl.sessao.models import TipoResultadoVotacao
+ tipos_resultado = list(
+ TipoResultadoVotacao.objects.all().values('id', 'nome')
+ )
+
dados_sessao = {
"type": "data",
"sessao_aberta": sessao.iniciada and not sessao.finalizada,
@@ -103,6 +144,7 @@ def get_dados_painel(sessao_plenaria_id: int) -> dict:
"parlamentares": parlamentares,
"oradores": oradores,
"materia": get_materia_votacao(materia_votacao),
+ "tipos_resultado": tipos_resultado,
"stopwatch": [stopwatch], # TODO: array of stopwatches
}
@@ -170,11 +212,15 @@ class PainelConsumer(AsyncJsonWebsocketConsumer):
return
await self.send_json({"type": "error", "message": "Misformed message"})
return
-
async def stopwatch_update(self, event):
await self.send_json(event)
return
-
+ async def vote_update(self, event):
+ """Broadcast vote updates to all connected clients (painel + votacao).
+ Called automatically by Channels when group_send is invoked with type='vote.update'.
+ """
+ await self.send_json(event)
+ return
@database_sync_to_async
def user_can_view(self, user_id, controller_id) -> bool:
# Replace with your ACL check (ORM must be in a sync wrapper)
diff --git a/sapl/painel/urls.py b/sapl/painel/urls.py
index 915e811f6..163f9740d 100644
--- a/sapl/painel/urls.py
+++ b/sapl/painel/urls.py
@@ -3,8 +3,9 @@ from django.conf.urls import url
from .apps import AppConfig
from .views import (cronometro_painel, get_dados_painel, painel_mensagem_view,
painel_parlamentar_view, painel_view, painel_votacao_view,
- switch_painel, verifica_painel, votante_view, websocket_view, stopwatch_controller)
-
+ switch_painel, verifica_painel, votante_view, websocket_view,
+ stopwatch_controller, vote_controller, cancel_voting,
+ close_voting)
from django.urls import path
app_name = AppConfig.name
@@ -30,4 +31,10 @@ urlpatterns = [
path("v2/painel/
", websocket_view, name='painel_websocket'),
path("v2/painel/controller//stopwatch",
stopwatch_controller, name='painel_controller'),
+ path("v2/painel/controller//vote",
+ vote_controller, name='vote_controller'),
+ path("v2/painel/controller//cancel",
+ cancel_voting, name='cancel_voting'),
+ path("v2/painel/controller//close",
+ close_voting, name='close_voting'),
]
diff --git a/sapl/painel/views.py b/sapl/painel/views.py
index 53abaa1de..c02eb70f5 100644
--- a/sapl/painel/views.py
+++ b/sapl/painel/views.py
@@ -13,6 +13,7 @@ from django.shortcuts import render
from django.utils import timezone
from django.utils.translation import ugettext_lazy as _
+from sapl import settings
from sapl.base.models import AppConfig as ConfiguracoesAplicacao
from sapl.base.models import CasaLegislativa
from sapl.crud.base import Crud
@@ -23,6 +24,7 @@ from sapl.sessao.models import (ExpedienteMateria, OradorExpediente, OrdemDia,
SessaoPlenaria, SessaoPlenariaPresenca,
VotoParlamentar, RegistroLeitura)
from sapl.utils import filiacao_data, get_client_ip, sort_lista_chave
+from image_cropping.utils import get_backend
from .models import Cronometro
@@ -402,10 +404,24 @@ def get_presentes(pk, response, materia):
else:
partido = filiacao
+
+ if p.parlamentar.fotografia:
+ thumbnail_url = get_backend().get_thumbnail_url(
+ p.parlamentar.fotografia,
+ {
+ 'size': (128, 128),
+ 'box': p.parlamentar.cropping,
+ 'crop': True,
+ 'detail': True,
+ }
+ )
+ else:
+ thumbnail_url = False
presentes_list.append(
{'id': p.id,
'parlamentar_id': p.parlamentar.id,
'nome': p.parlamentar.nome_parlamentar,
+ 'fotografia':thumbnail_url,
'partido': partido,
'voto': ''
})
@@ -691,6 +707,279 @@ def stopwatch_controller(request, controller_id):
async_to_sync(layer.group_send)(group, notification)
return JsonResponse(notification)
+VALID_VOTE_VALUES = ["Sim", "Não", "Abstenção", "Não Votou"]
+@user_passes_test(check_permission)
+def vote_controller(request, controller_id):
+ """
+ HTTP endpoint to cast/update a vote and broadcast to all connected WebSocket clients.
+ Follows the same pattern as stopwatch_controller.
+ POST /v2/painel/controller//vote
+ Body (form-encoded or JSON):
+ parlamentar_id: int
+ voto: str ("Sim", "Não", "Abstenção", "Não Votou")
+ """
+ from asgiref.sync import async_to_sync
+ from channels.layers import get_channel_layer
+ logger = logging.getLogger(__name__)
+ if request.method != 'POST':
+ return JsonResponse({"type": "error", "message": "Only POST allowed"}, status=405)
+ # Parse body: support both form-encoded and JSON
+ if request.content_type and 'json' in request.content_type:
+ try:
+ body = json.loads(request.body)
+ except json.JSONDecodeError:
+ return JsonResponse({"type": "error", "message": "Invalid JSON"}, status=400)
+ parlamentar_id = body.get("parlamentar_id")
+ voto = body.get("voto")
+ else:
+ parlamentar_id = request.POST.get("parlamentar_id")
+ voto = request.POST.get("voto")
+ # Validate
+ if not parlamentar_id:
+ return JsonResponse({"type": "error", "message": "parlamentar_id is required"}, status=400)
+ try:
+ parlamentar_id = int(parlamentar_id)
+ except (ValueError, TypeError):
+ return JsonResponse({"type": "error", "message": "parlamentar_id must be an integer"}, status=400)
+ if voto not in VALID_VOTE_VALUES:
+ return JsonResponse({
+ "type": "error",
+ "message": f"Invalid vote value: {voto}. Must be one of: {VALID_VOTE_VALUES}"
+ }, status=400)
+ # Verify session exists
+ try:
+ sessao = SessaoPlenaria.objects.get(id=controller_id)
+ except SessaoPlenaria.DoesNotExist:
+ return JsonResponse({"type": "error", "message": "Session not found"}, status=404)
+ # Find open materia (OrdemDia or ExpedienteMateria)
+ ordem_dia = get_materia_aberta(controller_id)
+ expediente = get_materia_expediente_aberta(controller_id)
+ materia_aberta = ordem_dia or expediente
+ if not materia_aberta:
+ return JsonResponse({"type": "error", "message": "No open materia for voting"}, status=400)
+ if materia_aberta.tipo_votacao != VOTACAO_NOMINAL:
+ return JsonResponse({"type": "error", "message": "Materia is not nominal voting type"}, status=400)
+ # Verify parlamentar exists
+ try:
+ parlamentar = Parlamentar.objects.get(id=parlamentar_id)
+ except Parlamentar.DoesNotExist:
+ return JsonResponse({"type": "error", "message": f"Parlamentar {parlamentar_id} not found"}, status=404)
+ # Save/update vote
+ vote_kwargs = {
+ 'parlamentar': parlamentar,
+ 'voto': voto,
+ 'user': request.user,
+ 'ip': get_client_ip(request),
+ }
+ if ordem_dia:
+ vote_kwargs['ordem'] = ordem_dia
+ voto_obj, created = VotoParlamentar.objects.update_or_create(
+ parlamentar=parlamentar,
+ ordem=ordem_dia,
+ defaults={'voto': voto, 'user': request.user, 'ip': get_client_ip(request)}
+ )
+ else:
+ vote_kwargs['expediente'] = expediente
+ voto_obj, created = VotoParlamentar.objects.update_or_create(
+ parlamentar=parlamentar,
+ expediente=expediente,
+ defaults={'voto': voto, 'user': request.user, 'ip': get_client_ip(request)}
+ )
+ logger.info(f"Vote {'created' if created else 'updated'}: parlamentar={parlamentar_id}, voto={voto}, sessao={controller_id}")
+ # Broadcast vote to all connected WebSocket clients
+ layer = get_channel_layer()
+ group = f"controller_{controller_id}"
+ notification = {
+ "type": "vote.update",
+ "parlamentar_id": parlamentar_id,
+ "voto": voto,
+ "created": created,
+ }
+ async_to_sync(layer.group_send)(group, notification)
+ return JsonResponse({"ok": True, **notification})
+
+
+@user_passes_test(check_permission)
+def cancel_voting(request, controller_id):
+ """
+ HTTP endpoint to cancel the current open voting and discard all votes.
+ POST /v2/painel/controller//cancel
+ """
+ from asgiref.sync import async_to_sync
+ from channels.layers import get_channel_layer
+ from sapl.sessao.views import fechar_votacao_materia
+
+ logger = logging.getLogger(__name__)
+
+ if request.method != 'POST':
+ return JsonResponse({"type": "error", "message": "Only POST allowed"}, status=405)
+
+ # Find the open materia
+ ordem_dia = get_materia_aberta(controller_id)
+ expediente = get_materia_expediente_aberta(controller_id)
+ materia_aberta = ordem_dia or expediente
+
+ if not materia_aberta:
+ return JsonResponse({"type": "error", "message": "No open materia for voting"}, status=400)
+
+ # Build redirect URL before closing (materia_aberta fields change after close)
+ materia_id = materia_aberta.materia_id
+ if ordem_dia:
+ redirect_url = reverse('sapl.sessao:ordemdia_list',
+ kwargs={'pk': controller_id}) + f'#id{materia_id}'
+ else:
+ redirect_url = reverse('sapl.sessao:expedientemateria_list',
+ kwargs={'pk': controller_id}) + f'#id{materia_id}'
+
+ # Cancel: delete votes + RegistroVotacao, close materia
+ fechar_votacao_materia(materia_aberta)
+ logger.info(f"Voting cancelled for sessao={controller_id}, materia={materia_aberta.id}")
+
+ # Broadcast updated state to all WebSocket clients
+ layer = get_channel_layer()
+ group = f"controller_{controller_id}"
+ from sapl.painel.consumers import get_dados_painel
+ updated_data = get_dados_painel(controller_id)
+ async_to_sync(layer.group_send)(group, updated_data)
+
+ return JsonResponse({"ok": True, "message": "Votação cancelada com sucesso.", "redirect_url": redirect_url})
+
+
+@user_passes_test(check_permission)
+def close_voting(request, controller_id):
+ """
+ HTTP endpoint to close the current voting and save the result.
+ POST /v2/painel/controller//close
+ Body (JSON):
+ resultado_id: int (TipoResultadoVotacao id)
+ observacoes: str (optional)
+ """
+ from asgiref.sync import async_to_sync
+ from channels.layers import get_channel_layer
+ from sapl.sessao.models import TipoResultadoVotacao
+
+ logger = logging.getLogger(__name__)
+
+ if request.method != 'POST':
+ return JsonResponse({"type": "error", "message": "Only POST allowed"}, status=405)
+
+ # Parse body
+ if request.content_type and 'json' in request.content_type:
+ try:
+ body = json.loads(request.body)
+ except json.JSONDecodeError:
+ return JsonResponse({"type": "error", "message": "Invalid JSON"}, status=400)
+ else:
+ body = request.POST
+
+ resultado_id = body.get("resultado_id")
+ observacoes = body.get("observacoes", "")
+
+ if not resultado_id:
+ return JsonResponse({
+ "type": "error",
+ "message": "Não é possível finalizar a votação sem nenhum resultado da votação"
+ }, status=400)
+
+ # Validate resultado
+ try:
+ tipo_resultado = TipoResultadoVotacao.objects.get(id=resultado_id)
+ except TipoResultadoVotacao.DoesNotExist:
+ return JsonResponse({"type": "error", "message": "Tipo de resultado não encontrado"}, status=404)
+
+ # Find the open materia
+ ordem_dia = get_materia_aberta(controller_id)
+ expediente = get_materia_expediente_aberta(controller_id)
+ materia_aberta = ordem_dia or expediente
+
+ if not materia_aberta:
+ return JsonResponse({"type": "error", "message": "No open materia for voting"}, status=400)
+
+ # Count votes from VotoParlamentar
+ if ordem_dia:
+ votos = VotoParlamentar.objects.filter(ordem=ordem_dia)
+ else:
+ votos = VotoParlamentar.objects.filter(expediente=expediente)
+
+ votos_sim = votos.filter(voto='Sim').count()
+ votos_nao = votos.filter(voto='Não').count()
+ abstencoes = votos.filter(voto='Abstenção').count()
+ nao_votou = votos.filter(voto='Não Votou').count()
+
+ # All votes must not be "Não Votou"
+ total_votados = votos_sim + votos_nao + abstencoes
+ if total_votados == 0:
+ return JsonResponse({
+ "type": "error",
+ "message": "Não é possível finalizar a votação sem nenhum voto"
+ }, status=400)
+
+ # Remove old RegistroVotacao if exists
+ if ordem_dia:
+ RegistroVotacao.objects.filter(ordem=ordem_dia).delete()
+ else:
+ RegistroVotacao.objects.filter(expediente=expediente).delete()
+
+ # Create RegistroVotacao
+ registro = RegistroVotacao()
+ registro.numero_votos_sim = votos_sim
+ registro.numero_votos_nao = votos_nao
+ registro.numero_abstencoes = abstencoes
+ registro.observacao = observacoes
+ registro.user = request.user
+ registro.ip = get_client_ip(request)
+ registro.materia = materia_aberta.materia
+ registro.tipo_resultado_votacao = tipo_resultado
+
+ if ordem_dia:
+ registro.ordem = ordem_dia
+ else:
+ registro.expediente = expediente
+
+ registro.save()
+
+ # Link VotoParlamentar records to RegistroVotacao and update user/ip
+ for voto_obj in votos:
+ voto_obj.votacao = registro
+ voto_obj.user = request.user
+ voto_obj.ip = get_client_ip(request)
+ voto_obj.save()
+
+ # Build redirect URL before closing
+ materia_id = materia_aberta.materia_id
+ if ordem_dia:
+ redirect_url = reverse('sapl.sessao:ordemdia_list',
+ kwargs={'pk': controller_id}) + f'#id{materia_id}'
+ else:
+ redirect_url = reverse('sapl.sessao:expedientemateria_list',
+ kwargs={'pk': controller_id}) + f'#id{materia_id}'
+
+ # Close materia with resultado
+ materia_aberta.resultado = tipo_resultado.nome
+ materia_aberta.votacao_aberta = False
+ materia_aberta.save()
+
+ # Clean up orphan VotoParlamentar (without votacao)
+ if ordem_dia:
+ VotoParlamentar.objects.filter(ordem=ordem_dia, votacao__isnull=True).delete()
+ else:
+ VotoParlamentar.objects.filter(expediente=expediente, votacao__isnull=True).delete()
+
+ logger.info(
+ f"Voting closed for sessao={controller_id}: "
+ f"sim={votos_sim}, nao={votos_nao}, abstencoes={abstencoes}, "
+ f"resultado={tipo_resultado.nome}"
+ )
+
+ # Broadcast updated state to all WebSocket clients
+ layer = get_channel_layer()
+ group = f"controller_{controller_id}"
+ from sapl.painel.consumers import get_dados_painel
+ updated_data = get_dados_painel(controller_id)
+ async_to_sync(layer.group_send)(group, updated_data)
+
+ return JsonResponse({"ok": True, "message": "Votação finalizada com sucesso.", "redirect_url": redirect_url})
+
# def push_to_me(request):
# from asgiref.sync import async_to_sync
diff --git a/sapl/sessao/migrations/0070_views_sessao_plenaria.py b/sapl/sessao/migrations/0070_views_sessao_plenaria.py
index 74c0be100..4e8791ac5 100644
--- a/sapl/sessao/migrations/0070_views_sessao_plenaria.py
+++ b/sapl/sessao/migrations/0070_views_sessao_plenaria.py
@@ -186,14 +186,14 @@ class Migration(migrations.Migration):
LEFT JOIN LATERAL (
SELECT em.sessao_plenaria_id,
em.numero_ordem,
- jsonb_agg(jsonb_build_object(
+ jsonb_object_agg(
vp.parlamentar_id,
jsonb_build_object(
'materia_id', em.materia_id,
'parlamentar_id', vp.parlamentar_id,
'parlamentar_nome', p.nome_parlamentar,
'voto', vp.voto
- )) ORDER BY p.nome_parlamentar) as votos_parlamentares
+ )) as votos_parlamentares
FROM sessao_votoparlamentar vp
JOIN parlamentares_parlamentar p ON (vp.parlamentar_id = p.id)
WHERE vp.expediente_id = em.id AND em.tipo_votacao != 4
@@ -227,7 +227,7 @@ class Migration(migrations.Migration):
FROM sessao_ordemdia od
JOIN materia_materialegislativa ml ON (od.materia_id = ml.id)
JOIN materia_tipomaterialegislativa tm ON (ml.tipo_id = tm.id)
- LEFT JOIN sessao_registroleitura rl on (od.id = rl.expediente_id)
+ LEFT JOIN sessao_registroleitura rl on (od.id = rl.ordem_id)
LEFT JOIN LATERAL (
SELECT jsonb_build_object(
'votos_sim', coalesce(rv.numero_votos_sim, 0),
@@ -238,21 +238,21 @@ class Migration(migrations.Migration):
trv.nome resultado_votacao
FROM sessao_registrovotacao rv
JOIN sessao_tiporesultadovotacao trv on (rv.tipo_resultado_votacao_id = trv.id)
- WHERE rv.expediente_id = od.id AND tipo_votacao != 4) rv ON TRUE
+ WHERE rv.ordem_id = od.id AND tipo_votacao != 4) rv ON TRUE
LEFT JOIN LATERAL (
SELECT od.sessao_plenaria_id,
od.numero_ordem,
- jsonb_agg(jsonb_build_object(
+ jsonb_object_agg(
vp.parlamentar_id,
jsonb_build_object(
'materia_id', od.materia_id,
'parlamentar_id', vp.parlamentar_id,
'parlamentar_nome', p.nome_parlamentar,
'voto', vp.voto
- )) ORDER BY p.nome_parlamentar) as votos_parlamentares
+ )) as votos_parlamentares
FROM sessao_votoparlamentar vp
JOIN parlamentares_parlamentar p ON (vp.parlamentar_id = p.id)
- WHERE vp.expediente_id = od.id AND od.tipo_votacao != 4
+ WHERE vp.ordem_id = od.id AND od.tipo_votacao != 4
GROUP BY od.sessao_plenaria_id, od.numero_ordem
ORDER BY od.sessao_plenaria_id, od.numero_ordem
) vp ON TRUE
diff --git a/sapl/templates/painel/painel_v2.html b/sapl/templates/painel/painel_v2.html
index e8ceeb76e..ee5360311 100644
--- a/sapl/templates/painel/painel_v2.html
+++ b/sapl/templates/painel/painel_v2.html
@@ -5,86 +5,57 @@
{% load webpack_static from webpack_loader %}
-
-
-
-
{% block head_title %}{% trans 'SAPL - Sistema de Apoio ao Processo Legislativo' %}{% endblock %}
-
+
+
{% block webpack_loader_css %}
{% render_chunk_vendors 'css' %}
{% render_bundle 'global' 'css' %}
{% render_bundle 'painel' 'css' %}
{% endblock webpack_loader_css %}
-
-
-
-
+
{% block vue_content %}
-
+
+
-
-
-
+
-
-
+
+
-
-
-
-
Cronômetros
-
-
-
- A-
-
-
- A+
-
-
-
-
-
-
-
-
+
-
-
-
+
Tema da Sessão Solene
-
+ [[ sessao.tema_solene ]]
+
+
+
-
{% endblock %}
{% block webpack_loader_js %}
diff --git a/sapl/templates/sessao/votacao/votacao_v2.html b/sapl/templates/sessao/votacao/votacao_v2.html
index f988f1aba..e3da10e04 100644
--- a/sapl/templates/sessao/votacao/votacao_v2.html
+++ b/sapl/templates/sessao/votacao/votacao_v2.html
@@ -7,62 +7,15 @@
{% block vue_content %}
-
-
- [[ tipo_votacao_descricao ]]
- [[ error_message ]]
-
-
-
- Matéria: [[ materia ]]
-
- Ementa: [[ ementa ]]
-
-
-
-
-
- Votos
-
-
[[ p.nome_parlamentar ]]
-
[[ p.filiacao ]]
-
-
-
- [[ o.text ]]
-
-
-
-
- [[ votos_parlamentares[p.parlamentar_id].voto ]]
-
-
-
-
-
- Total de votos:
-
-
[[ voto.tipo ]]: [[ voto.total ]]
-
-
-
-
- Resultado da Votação:
-
- [[ t.nome ]]
-
-
-
-
-
-
Não existe nenhum parlamentar presente para que a votação ocorra.
-
-
+
+
+
{% endblock vue_content %}