From eff040b97353f0ca4e2e2df7c06114ca0182ec57 Mon Sep 17 00:00:00 2001 From: RogerKoala Date: Thu, 2 Jul 2026 09:36:24 -0300 Subject: [PATCH] =?UTF-8?q?feat(painel):=20adiciona=20endpoints=20v2=20par?= =?UTF-8?q?a=20votar,=20cancelar=20e=20fechar=20vota=C3=A7=C3=A3o?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- sapl/painel/urls.py | 11 +- sapl/painel/views.py | 273 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 282 insertions(+), 2 deletions(-) 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 fe227740f..c02eb70f5 100644 --- a/sapl/painel/views.py +++ b/sapl/painel/views.py @@ -707,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