From 6531f70df3165be24ad6f22926b5285905f4cb24 Mon Sep 17 00:00:00 2001 From: Samuel Zwinglio Date: Tue, 13 Oct 2026 20:48:48 -0300 Subject: [PATCH] =?UTF-8?q?Adiciona=20endpoint=20de=20grava=C3=A7=C3=A3o?= =?UTF-8?q?=20em=20lote=20da=20sess=C3=A3o=20plen=C3=A1ria?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /api/sessao/sessaoplenaria/{id}/lote/ grava numa única requisição presenças da sessão, presenças da ordem do dia, votações (registro e votos nominais) e atualizações de matérias da ordem do dia e do expediente. Cada item é gravado em sua própria transação e devolve um status (criado, atualizado, removido, inalterado ou erro). O reenvio do mesmo lote é idempotente. Validações e permissões são as mesmas dos endpoints individuais, que seguem sem alteração. --- docs/api-sessao-lote.md | 168 +++++++++++ sapl/api/sessao_lote.py | 386 ++++++++++++++++++++++++++ sapl/api/views_sessao.py | 16 ++ sapl/sessao/tests/test_sessao_lote.py | 296 ++++++++++++++++++++ 4 files changed, 866 insertions(+) create mode 100644 docs/api-sessao-lote.md create mode 100644 sapl/api/sessao_lote.py create mode 100644 sapl/sessao/tests/test_sessao_lote.py diff --git a/docs/api-sessao-lote.md b/docs/api-sessao-lote.md new file mode 100644 index 000000000..2099b8ad3 --- /dev/null +++ b/docs/api-sessao-lote.md @@ -0,0 +1,168 @@ +# API: gravação em lote da Sessão Plenária + +``` +POST /api/sessao/sessaoplenaria/{id}/lote/ +Authorization: Token +Content-Type: application/json +``` + +Grava numa única requisição tudo o que uma integração (painel eletrônico, +sistema de votação etc.) precisa registrar numa sessão plenária: + +| Bloco | Model | Equivale a | +|---|---|---| +| `presencas_sessao` | `SessaoPlenariaPresenca` | `POST`/`DELETE /api/sessao/sessaoplenariapresenca/` | +| `presencas_ordem_dia` | `PresencaOrdemDia` | `POST`/`DELETE /api/sessao/presencaordemdia/` | +| `votacoes` | `RegistroVotacao` + `VotoParlamentar` | `POST /api/sessao/registrovotacao/` + um `POST /api/sessao/votoparlamentar/` por voto | +| `ordens_dia` | `OrdemDia` | `PATCH /api/sessao/ordemdia/{id}/` | +| `expedientes_materia` | `ExpedienteMateria` | `PATCH /api/sessao/expedientemateria/{id}/` | + +Numa Casa com 21 parlamentares, uma sessão com 20 matérias votadas +nominalmente gera mais de 460 requisições pelos endpoints individuais +(420 só de votos). Com este endpoint, a mesma sessão pode ser enviada em uma. + +Os endpoints individuais continuam funcionando sem alteração. + +## Regras + +- **Qualquer combinação de blocos.** Blocos não enviados não são alterados. + Ao menos um bloco deve ser informado. +- **Itens independentes.** Cada item é gravado em sua própria transação: a + falha de um não impede a gravação dos demais. Uma votação (registro + + votos) é um único item: ou é gravada inteira, ou nada é gravado. +- **Relatório por item.** A resposta traz o status de cada item, na mesma + ordem em que foi enviado, para que apenas os que falharam sejam + reenviados. +- **Idempotente.** Reenviar o mesmo lote não duplica presenças, registros de + votação nem votos; os itens já gravados voltam como `inalterado`. +- **Mesmas validações e permissões.** Os dados são validados pelos mesmos + serializers dos endpoints individuais e cada item exige as mesmas + permissões (`sessao.add_*`, `sessao.change_*`, `sessao.delete_*`). Sem + permissão para um bloco, os itens daquele bloco voltam com erro e os + demais são gravados. +- **Escopo da sessão.** Matérias da ordem do dia e do expediente precisam + pertencer à sessão da URL. +- **Ordem de processamento:** presenças da sessão, presenças da ordem do dia, + votações, matérias da ordem do dia, matérias do expediente. +- Máximo de 500 itens por bloco. + +## Payload + +```json +{ + "presencas_sessao": [ + {"parlamentar": 1, "presente": true}, + {"parlamentar": 2, "presente": false} + ], + "presencas_ordem_dia": [ + {"parlamentar": 1} + ], + "votacoes": [ + { + "ordem": 10, + "tipo_resultado_votacao": 1, + "observacao": "", + "votos": [ + {"parlamentar": 1, "voto": "Sim"}, + {"parlamentar": 3, "voto": "Não"} + ] + }, + { + "expediente": 4, + "tipo_resultado_votacao": 1, + "numero_votos_sim": 15, + "numero_votos_nao": 2, + "numero_abstencoes": 0 + } + ], + "ordens_dia": [ + {"id": 10, "resultado": "Aprovado", "votacao_aberta": false} + ], + "expedientes_materia": [ + {"id": 4, "resultado": "Aprovado", "votacao_aberta": false} + ] +} +``` + +### Presenças + +`presente` é opcional (padrão `true`): + +- `true`: registra a presença se ela ainda não existir (`criado`); se já + existir, `inalterado`. +- `false`: remove a presença se existir (`removido`); se não existir, + `inalterado`. + +Na presença da sessão, `data_sessao` é preenchida com a data de início da +sessão. + +### Votações + +Cada votação informa **exatamente um** entre `ordem` (id de `OrdemDia`) e +`expediente` (id de `ExpedienteMateria`). A matéria é obtida do item da +pauta. + +- **Nominal**: envie `votos`. Valores aceitos: `Sim`, `Não`, `Abstenção`, + `Não Votou`. Os totais do registro são calculados a partir dos votos; se + `numero_votos_sim`, `numero_votos_nao` ou `numero_abstencoes` também + forem enviados, precisam bater com o cálculo. A lista é o conjunto + completo de votos da matéria: votos de parlamentares fora da lista são + removidos, como acontece ao finalizar a votação nominal pela tela. +- **Simbólica / secreta**: omita `votos` e informe `numero_votos_sim`, + `numero_votos_nao` e `numero_abstencoes`. + +Se a matéria ainda não tem registro de votação, ele é criado; se já tem, é +atualizado. Usuário e IP da requisição são gravados no registro e nos votos. + +A votação não altera a matéria da pauta. Para gravar `resultado`, fechar a +votação etc., use os blocos `ordens_dia`/`expedientes_materia`, que são +processados depois das votações. + +### Matérias da ordem do dia e do expediente + +Cada item informa o `id` e os campos a alterar, com a mesma semântica de um +`PATCH` no endpoint individual. Não é permitido mover a matéria para outra +sessão. + +## Resposta + +`200 OK` sempre que o lote for processado, mesmo que algum item falhe. +Confira `resumo.erro`. + +```json +{ + "sessao_plenaria": 7, + "presencas_sessao": [ + {"parlamentar": 1, "presente": true, "status": "criado", "id": 101}, + {"parlamentar": 2, "presente": false, "status": "inalterado"} + ], + "presencas_ordem_dia": [ + {"parlamentar": 1, "presente": true, "status": "inalterado", "id": 55} + ], + "votacoes": [ + {"ordem": 10, "status": "criado", "id": 300}, + {"expediente": 4, "status": "erro", + "erros": {"tipo_resultado_votacao": ["Este campo não pode ser nulo."]}} + ], + "ordens_dia": [ + {"id": 10, "status": "atualizado"} + ], + "expedientes_materia": [ + {"id": 4, "status": "atualizado"} + ], + "resumo": {"criado": 2, "atualizado": 2, "removido": 0, + "inalterado": 2, "erro": 1} +} +``` + +Status por item: `criado`, `atualizado`, `removido`, `inalterado`, `erro`. +Itens com `erro` trazem `erros`, no mesmo formato dos erros de validação dos +endpoints individuais. + +Erros que recusam o lote inteiro: + +| Código | Quando | +|---|---| +| `400` | corpo não é um objeto, bloco desconhecido, nenhum bloco, bloco que não é lista, item que não é objeto ou mais de 500 itens num bloco | +| `401`/`403` | requisição sem autenticação | +| `404` | sessão plenária inexistente | diff --git a/sapl/api/sessao_lote.py b/sapl/api/sessao_lote.py new file mode 100644 index 000000000..91c65c9f6 --- /dev/null +++ b/sapl/api/sessao_lote.py @@ -0,0 +1,386 @@ +""" +Gravação em lote dos dados de uma Sessão Plenária. + +Recebe, numa única requisição, presenças da sessão, presenças da ordem do +dia, votações (registro + votos nominais) e atualizações de matérias da +ordem do dia/expediente. Ver docs/api-sessao-lote.md. + +Regras: + - qualquer combinação de blocos é aceita; o que não for enviado não é + alterado; + - cada item é gravado em sua própria transação: a falha de um não + impede a gravação dos demais; + - cada item devolve um status (criado, atualizado, removido, inalterado + ou erro) para que o cliente reenvie apenas o que falhou; + - reenviar o mesmo lote não duplica presenças nem votos; + - validações vêm dos mesmos serializers e as permissões são as mesmas + exigidas pelos endpoints individuais de cada model. +""" +import logging + +from django.db import transaction +from rest_framework import serializers + +from drfautoapi.drfautoapi import ApiViewSetConstrutor +from sapl.parlamentares.models import Parlamentar +from sapl.sessao.models import (ExpedienteMateria, OrdemDia, + PresencaOrdemDia, RegistroVotacao, + SessaoPlenariaPresenca, VotoParlamentar) +from sapl.utils import get_client_ip + + +logger = logging.getLogger(__name__) + +MAX_ITENS_POR_BLOCO = 500 + +CRIADO = 'criado' +ATUALIZADO = 'atualizado' +REMOVIDO = 'removido' +INALTERADO = 'inalterado' +ERRO = 'erro' + +VOTO_SIM = 'Sim' +VOTO_NAO = 'Não' +VOTO_ABSTENCAO = 'Abstenção' +VOTO_NAO_VOTOU = 'Não Votou' +VOTOS_VALIDOS = (VOTO_SIM, VOTO_NAO, VOTO_ABSTENCAO, VOTO_NAO_VOTOU) + +BLOCOS = ( + 'presencas_sessao', + 'presencas_ordem_dia', + 'votacoes', + 'ordens_dia', + 'expedientes_materia', +) + + +class ItemInvalido(Exception): + + def __init__(self, erros): + super().__init__(erros) + self.erros = erros + + +def perm(model, acao): + return '{}.{}_{}'.format( + model._meta.app_label, acao, model._meta.model_name) + + +def serializer_class_for(model): + return ApiViewSetConstrutor.get_viewset_for_model(model).serializer_class + + +class SessaoLote: + + def __init__(self, sessao, request): + self.sessao = sessao + self.request = request + self.user = request.user + self.ip = get_client_ip(request) + + @classmethod + def validar_payload(cls, data): + """Valida apenas a estrutura do lote. Erros aqui recusam o lote + inteiro (HTTP 400); erros de item são reportados por item.""" + if not isinstance(data, dict): + raise serializers.ValidationError( + 'O corpo da requisição deve ser um objeto JSON.') + + desconhecidos = sorted(set(data) - set(BLOCOS)) + if desconhecidos: + raise serializers.ValidationError({ + k: 'Bloco desconhecido. Blocos aceitos: {}.'.format( + ', '.join(BLOCOS)) for k in desconhecidos}) + + if not any(b in data for b in BLOCOS): + raise serializers.ValidationError( + 'Informe ao menos um dos blocos: {}.'.format( + ', '.join(BLOCOS))) + + erros = {} + for bloco in BLOCOS: + if bloco not in data: + continue + itens = data[bloco] + if not isinstance(itens, list): + erros[bloco] = 'Deve ser uma lista.' + elif len(itens) > MAX_ITENS_POR_BLOCO: + erros[bloco] = 'Máximo de {} itens por bloco.'.format( + MAX_ITENS_POR_BLOCO) + elif not all(isinstance(i, dict) for i in itens): + erros[bloco] = 'Todos os itens devem ser objetos JSON.' + if erros: + raise serializers.ValidationError(erros) + + def processar(self, data): + self.validar_payload(data) + + # Presenças antes das votações e votações antes das atualizações de + # matéria, na mesma ordem em que as integrações fariam com os + # endpoints individuais. + relatorio = {'sessao_plenaria': self.sessao.pk} + processadores = ( + ('presencas_sessao', lambda i: self.presenca( + SessaoPlenariaPresenca, i)), + ('presencas_ordem_dia', lambda i: self.presenca( + PresencaOrdemDia, i)), + ('votacoes', self.votacao), + ('ordens_dia', lambda i: self.materia(OrdemDia, i)), + ('expedientes_materia', lambda i: self.materia( + ExpedienteMateria, i)), + ) + for bloco, processador in processadores: + if bloco in data: + relatorio[bloco] = [ + self.processar_item(processador, item) + for item in data[bloco]] + + resumo = dict.fromkeys( + (CRIADO, ATUALIZADO, REMOVIDO, INALTERADO, ERRO), 0) + for bloco in BLOCOS: + for item in relatorio.get(bloco, []): + resumo[item['status']] += 1 + relatorio['resumo'] = resumo + + logger.info('user={}. Lote da sessão plenária (pk={}) processado: ' + '{}'.format(self.user.username, self.sessao.pk, resumo)) + return relatorio + + def processar_item(self, processador, item): + try: + with transaction.atomic(): + return processador(item) + except ItemInvalido as e: + return self.item_erro(item, e.erros) + except serializers.ValidationError as e: + return self.item_erro(item, e.detail) + except Exception as e: + logger.error('user={}. Erro ao processar item do lote da sessão ' + 'plenária (pk={}): {}. {}'.format( + self.user.username, self.sessao.pk, item, e)) + return self.item_erro(item, str(e)) + + def item_erro(self, item, erros): + chaves = ('parlamentar', 'ordem', 'expediente', 'id') + r = {k: item[k] for k in chaves if k in item} + r.update(status=ERRO, erros=erros) + return r + + def exigir_perms(self, *perms): + faltando = [p for p in perms if not self.user.has_perm(p)] + if faltando: + raise ItemInvalido( + 'Usuário sem permissão: {}.'.format(', '.join(faltando))) + + def parlamentar_id(self, valor, campo='parlamentar'): + if isinstance(valor, bool) or not isinstance(valor, int): + raise ItemInvalido({campo: 'Informe o id do parlamentar.'}) + if not Parlamentar.objects.filter(pk=valor).exists(): + raise ItemInvalido( + {campo: 'Parlamentar (pk={}) não existe.'.format(valor)}) + return valor + + def presenca(self, model, item): + """{"parlamentar": , "presente": true|false}""" + parlamentar = self.parlamentar_id(item.get('parlamentar')) + presente = item.get('presente', True) + if not isinstance(presente, bool): + raise ItemInvalido({'presente': 'Deve ser true ou false.'}) + + r = {'parlamentar': parlamentar, 'presente': presente} + existentes = model.objects.filter( + sessao_plenaria=self.sessao, parlamentar_id=parlamentar) + + if not presente: + if not existentes.exists(): + return dict(r, status=INALTERADO) + self.exigir_perms(perm(model, 'delete')) + existentes.delete() + return dict(r, status=REMOVIDO) + + atual = existentes.first() + if atual: + return dict(r, status=INALTERADO, id=atual.pk) + + self.exigir_perms(perm(model, 'add')) + dados = {'sessao_plenaria': self.sessao.pk, + 'parlamentar': parlamentar} + if model is SessaoPlenariaPresenca: + dados['data_sessao'] = self.sessao.data_inicio + serializer = serializer_class_for(model)( + data=dados, context={'request': self.request}) + serializer.is_valid(raise_exception=True) + obj = serializer.save() + return dict(r, status=CRIADO, id=obj.pk) + + def alvo_votacao(self, item): + """Votação é de uma matéria da ordem do dia ou do expediente, + nunca das duas (mesma regra de RegistroVotacao.clean).""" + tem_ordem = item.get('ordem') is not None + tem_expediente = item.get('expediente') is not None + if tem_ordem == tem_expediente: + raise ItemInvalido('Informe exatamente um dos campos ' + '"ordem" ou "expediente".') + campo, model = (('ordem', OrdemDia) if tem_ordem + else ('expediente', ExpedienteMateria)) + alvo = model.objects.filter( + pk=item[campo], sessao_plenaria=self.sessao).first() + if not alvo: + raise ItemInvalido({campo: '{} (pk={}) não existe nesta sessão ' + 'plenária.'.format( + model._meta.verbose_name, item[campo])}) + return campo, alvo + + def votos_do_item(self, item): + votos = item['votos'] + if not isinstance(votos, list): + raise ItemInvalido({'votos': 'Deve ser uma lista.'}) + + erros = {} + resultado = {} + for i, v in enumerate(votos): + if not isinstance(v, dict): + erros[i] = 'Deve ser um objeto JSON.' + continue + try: + p = self.parlamentar_id(v.get('parlamentar')) + except ItemInvalido as e: + erros[i] = e.erros + continue + if p in resultado: + erros[i] = {'parlamentar': 'Parlamentar repetido.'} + elif v.get('voto') not in VOTOS_VALIDOS: + erros[i] = {'voto': 'Valores aceitos: {}.'.format( + ', '.join(VOTOS_VALIDOS))} + else: + resultado[p] = v['voto'] + if erros: + raise ItemInvalido({'votos': erros}) + return resultado + + def votacao(self, item): + """ + {"ordem"|"expediente": , "tipo_resultado_votacao": , + "observacao": "...", + "votos": [{"parlamentar": , "voto": "Sim"}, ...]} + + Com "votos" (votação nominal), os totais são calculados a partir + deles e a lista substitui os votos já gravados para a matéria. Sem + "votos" (simbólica/secreta), informe numero_votos_sim, + numero_votos_nao e numero_abstencoes. + """ + campo, alvo = self.alvo_votacao(item) + r = {campo: alvo.pk} + + votos = self.votos_do_item(item) if 'votos' in item else None + + dados = { + campo: alvo.pk, + 'materia': alvo.materia_id, + 'tipo_resultado_votacao': item.get('tipo_resultado_votacao'), + 'observacao': item.get('observacao', ''), + } + totais = ('numero_votos_sim', 'numero_votos_nao', 'numero_abstencoes') + if votos is not None: + contagem = list(votos.values()) + calculados = dict(zip(totais, ( + contagem.count(VOTO_SIM), + contagem.count(VOTO_NAO), + contagem.count(VOTO_ABSTENCAO)))) + divergentes = {k: 'Diverge do total calculado a partir dos ' + 'votos ({}).'.format(calculados[k]) + for k in totais + if k in item and item[k] != calculados[k]} + if divergentes: + raise ItemInvalido(divergentes) + dados.update(calculados) + else: + dados.update({k: item.get(k) for k in totais}) + + registro = RegistroVotacao.objects.filter(**{campo: alvo}).first() + if registro: + self.exigir_perms(perm(RegistroVotacao, 'change')) + else: + self.exigir_perms(perm(RegistroVotacao, 'add')) + if votos is not None: + self.exigir_perms(*(perm(VotoParlamentar, a) + for a in ('add', 'change', 'delete'))) + + serializer = serializer_class_for(RegistroVotacao)( + registro, data=dados, context={'request': self.request}) + serializer.is_valid(raise_exception=True) + + criado = registro is None + alterado = criado or any( + getattr(registro, f) != v + for f, v in serializer.validated_data.items()) + if alterado: + registro = serializer.save(user=self.user, ip=self.ip) + + if votos is not None and self.gravar_votos(campo, alvo, registro, + votos): + alterado = True + + if criado: + status = CRIADO + else: + status = ATUALIZADO if alterado else INALTERADO + return dict(r, status=status, id=registro.pk) + + def gravar_votos(self, campo, alvo, registro, votos): + """Upsert por (matéria, parlamentar). Remove votos de parlamentares + fora da lista e votos sem registro, como a votação nominal da tela + faz ao finalizar.""" + alterado = False + existentes = {} + for v in VotoParlamentar.objects.filter(**{campo: alvo}): + if v.parlamentar_id in existentes or \ + v.parlamentar_id not in votos: + v.delete() + alterado = True + else: + existentes[v.parlamentar_id] = v + + for parlamentar, voto in votos.items(): + v = existentes.get(parlamentar) + if v and v.voto == voto and v.votacao_id == registro.pk: + continue + if not v: + v = VotoParlamentar(parlamentar_id=parlamentar, + **{campo: alvo}) + v.voto = voto + v.votacao = registro + v.user = self.user + v.ip = self.ip + v.save() + alterado = True + return alterado + + def materia(self, model, item): + """{"id": , }""" + pk = item.get('id') + obj = model.objects.filter( + pk=pk, sessao_plenaria=self.sessao).first() \ + if isinstance(pk, int) and not isinstance(pk, bool) else None + if not obj: + raise ItemInvalido({'id': '{} (pk={}) não existe nesta sessão ' + 'plenária.'.format( + model._meta.verbose_name, pk)}) + + dados = {k: v for k, v in item.items() if k != 'id'} + if 'sessao_plenaria' in dados and \ + dados['sessao_plenaria'] != self.sessao.pk: + raise ItemInvalido({'sessao_plenaria': 'Não é possível mover a ' + 'matéria para outra sessão pelo lote.'}) + self.exigir_perms(perm(model, 'change')) + + serializer = serializer_class_for(model)( + obj, data=dados, partial=True, context={'request': self.request}) + serializer.is_valid(raise_exception=True) + + r = {'id': obj.pk} + if not any(getattr(obj, f) != v + for f, v in serializer.validated_data.items()): + return dict(r, status=INALTERADO) + serializer.save() + return dict(r, status=ATUALIZADO) diff --git a/sapl/api/views_sessao.py b/sapl/api/views_sessao.py index 29c31bf29..c3bf4653e 100644 --- a/sapl/api/views_sessao.py +++ b/sapl/api/views_sessao.py @@ -1,12 +1,15 @@ from django.apps.registry import apps +from django.shortcuts import get_object_or_404 from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from drfautoapi.drfautoapi import ApiViewSetConstrutor, \ customize, wrapper_queryset_response_for_drf_action from sapl.api.serializers import ChoiceSerializer,\ SessaoPlenariaECidadaniaSerializer +from sapl.api.sessao_lote import SessaoLote from sapl.sessao.models import SessaoPlenaria, ExpedienteSessao,\ restringe_sessoes_visiveis from sapl.utils import choice_anos_com_sessaoplenaria @@ -55,6 +58,19 @@ class _SessaoPlenariaViewSet: self.serializer_class = SessaoPlenariaECidadaniaSerializer return self.list(request, *args, **kwargs) + # As permissões são verificadas por item em SessaoLote, com as mesmas + # regras dos endpoints individuais de cada model. + @action(detail=True, methods=['post'], + permission_classes=[IsAuthenticated]) + def lote(self, request, *args, **kwargs): + """ + Grava em uma única requisição presenças da sessão, presenças da + ordem do dia, votações e atualizações de matérias da sessão. + Detalhes em docs/api-sessao-lote.md. + """ + sessao = get_object_or_404(SessaoPlenaria, pk=kwargs['pk']) + return Response(SessaoLote(sessao, request).processar(request.data)) + class _RestringeSessoesVisiveisMixin: diff --git a/sapl/sessao/tests/test_sessao_lote.py b/sapl/sessao/tests/test_sessao_lote.py new file mode 100644 index 000000000..3f923ea03 --- /dev/null +++ b/sapl/sessao/tests/test_sessao_lote.py @@ -0,0 +1,296 @@ +import pytest +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Permission +from model_bakery import baker +from rest_framework.test import APIClient + +from sapl.parlamentares.models import Parlamentar +from sapl.sessao.models import (ExpedienteMateria, OrdemDia, + PresencaOrdemDia, RegistroVotacao, + SessaoPlenaria, SessaoPlenariaPresenca, + TipoResultadoVotacao, VotoParlamentar) + + +def url(sessao): + return '/api/sessao/sessaoplenaria/{}/lote/'.format(sessao.pk) + + +@pytest.fixture +def client(admin_user): + c = APIClient() + c.force_authenticate(admin_user) + return c + + +@pytest.fixture +def sessao(): + return baker.make(SessaoPlenaria) + + +@pytest.fixture +def parlamentares(): + return baker.make(Parlamentar, ativo=True, _quantity=3) + + +@pytest.fixture +def ordem(sessao): + return baker.make(OrdemDia, sessao_plenaria=sessao, votacao_aberta=True) + + +@pytest.fixture +def resultado(): + return baker.make(TipoResultadoVotacao, nome='Aprovado', natureza='A') + + +def post(client, sessao, data): + return client.post(url(sessao), data, format='json') + + +def votacao_nominal(ordem, resultado, parlamentares, votos): + return { + 'ordem': ordem.pk, + 'tipo_resultado_votacao': resultado.pk, + 'votos': [{'parlamentar': p.pk, 'voto': v} + for p, v in zip(parlamentares, votos)], + } + + +@pytest.mark.django_db(transaction=False) +def test_lote_exige_autenticacao(sessao): + response = APIClient().post(url(sessao), {}, format='json') + assert response.status_code in (401, 403) + + +@pytest.mark.django_db(transaction=False) +def test_lote_sessao_inexistente(client): + response = client.post('/api/sessao/sessaoplenaria/999999/lote/', + {'presencas_sessao': []}, format='json') + assert response.status_code == 404 + + +@pytest.mark.django_db(transaction=False) +def test_lote_estrutura_invalida(client, sessao): + assert post(client, sessao, {}).status_code == 400 + assert post(client, sessao, {'votos': []}).status_code == 400 + assert post(client, sessao, {'presencas_sessao': {}}).status_code == 400 + assert post(client, sessao, {'presencas_sessao': [1]}).status_code == 400 + + +@pytest.mark.django_db(transaction=False) +def test_lote_presencas_idempotente(client, sessao, parlamentares): + data = { + 'presencas_sessao': [{'parlamentar': p.pk} for p in parlamentares], + 'presencas_ordem_dia': [{'parlamentar': parlamentares[0].pk, + 'presente': True}], + } + + response = post(client, sessao, data) + assert response.status_code == 200 + r = response.json() + assert [i['status'] for i in r['presencas_sessao']] == ['criado'] * 3 + assert r['presencas_ordem_dia'][0]['status'] == 'criado' + assert r['resumo']['criado'] == 4 + assert SessaoPlenariaPresenca.objects.filter( + sessao_plenaria=sessao).count() == 3 + assert SessaoPlenariaPresenca.objects.filter( + sessao_plenaria=sessao, data_sessao=sessao.data_inicio).count() == 3 + + r = post(client, sessao, data).json() + assert r['resumo']['inalterado'] == 4 + assert r['resumo']['criado'] == 0 + assert SessaoPlenariaPresenca.objects.filter( + sessao_plenaria=sessao).count() == 3 + assert PresencaOrdemDia.objects.filter( + sessao_plenaria=sessao).count() == 1 + + +@pytest.mark.django_db(transaction=False) +def test_lote_remove_presenca(client, sessao, parlamentares): + baker.make(SessaoPlenariaPresenca, sessao_plenaria=sessao, + parlamentar=parlamentares[0]) + + r = post(client, sessao, {'presencas_sessao': [ + {'parlamentar': parlamentares[0].pk, 'presente': False}, + {'parlamentar': parlamentares[1].pk, 'presente': False}, + ]}).json() + + assert [i['status'] for i in r['presencas_sessao']] == [ + 'removido', 'inalterado'] + assert not SessaoPlenariaPresenca.objects.filter( + sessao_plenaria=sessao).exists() + + +@pytest.mark.django_db(transaction=False) +def test_lote_falha_de_item_nao_impede_os_demais(client, sessao, + parlamentares): + r = post(client, sessao, {'presencas_sessao': [ + {'parlamentar': parlamentares[0].pk}, + {'parlamentar': 999999}, + {'parlamentar': 'abc'}, + {'parlamentar': parlamentares[1].pk}, + ]}).json() + + status = [i['status'] for i in r['presencas_sessao']] + assert status == ['criado', 'erro', 'erro', 'criado'] + assert 'parlamentar' in r['presencas_sessao'][1]['erros'] + assert r['resumo']['erro'] == 2 + assert SessaoPlenariaPresenca.objects.filter( + sessao_plenaria=sessao).count() == 2 + + +@pytest.mark.django_db(transaction=False) +def test_lote_votacao_nominal(client, sessao, ordem, resultado, + parlamentares): + data = {'votacoes': [votacao_nominal( + ordem, resultado, parlamentares, ['Sim', 'Sim', 'Não'])]} + + r = post(client, sessao, data).json() + assert r['votacoes'][0]['status'] == 'criado' + + registro = RegistroVotacao.objects.get(ordem=ordem) + assert r['votacoes'][0]['id'] == registro.pk + assert registro.materia_id == ordem.materia_id + assert (registro.numero_votos_sim, registro.numero_votos_nao, + registro.numero_abstencoes) == (2, 1, 0) + assert VotoParlamentar.objects.filter( + ordem=ordem, votacao=registro).count() == 3 + + # Reenvio do mesmo lote não duplica registro nem votos. + r = post(client, sessao, data).json() + assert r['votacoes'][0]['status'] == 'inalterado' + assert RegistroVotacao.objects.filter(ordem=ordem).count() == 1 + assert VotoParlamentar.objects.filter(ordem=ordem).count() == 3 + + +@pytest.mark.django_db(transaction=False) +def test_lote_votacao_nominal_atualiza_votos(client, sessao, ordem, + resultado, parlamentares): + post(client, sessao, {'votacoes': [votacao_nominal( + ordem, resultado, parlamentares, ['Sim', 'Sim', 'Não'])]}) + + # Último parlamentar deixa a lista; segundo muda o voto. + r = post(client, sessao, {'votacoes': [votacao_nominal( + ordem, resultado, parlamentares[:2], ['Sim', 'Abstenção'])]}).json() + assert r['votacoes'][0]['status'] == 'atualizado' + + registro = RegistroVotacao.objects.get(ordem=ordem) + assert (registro.numero_votos_sim, registro.numero_votos_nao, + registro.numero_abstencoes) == (1, 0, 1) + votos = dict(VotoParlamentar.objects.filter( + ordem=ordem).values_list('parlamentar_id', 'voto')) + assert votos == {parlamentares[0].pk: 'Sim', + parlamentares[1].pk: 'Abstenção'} + + +@pytest.mark.django_db(transaction=False) +def test_lote_votacao_invalida_nao_grava_nada(client, sessao, ordem, + resultado, parlamentares): + item = votacao_nominal(ordem, resultado, parlamentares, + ['Sim', 'Talvez', 'Não']) + r = post(client, sessao, {'votacoes': [item]}).json() + assert r['votacoes'][0]['status'] == 'erro' + assert '1' in r['votacoes'][0]['erros']['votos'] + assert not RegistroVotacao.objects.exists() + assert not VotoParlamentar.objects.exists() + + item = votacao_nominal(ordem, resultado, parlamentares, + ['Sim', 'Sim', 'Não']) + item['numero_votos_sim'] = 3 + r = post(client, sessao, {'votacoes': [item]}).json() + assert 'numero_votos_sim' in r['votacoes'][0]['erros'] + assert not RegistroVotacao.objects.exists() + + +@pytest.mark.django_db(transaction=False) +def test_lote_votacao_simbolica_expediente(client, sessao, resultado): + expediente = baker.make(ExpedienteMateria, sessao_plenaria=sessao) + item = {'expediente': expediente.pk, + 'tipo_resultado_votacao': resultado.pk, + 'numero_votos_sim': 10, 'numero_votos_nao': 1, + 'numero_abstencoes': 0} + + r = post(client, sessao, {'votacoes': [item]}).json() + assert r['votacoes'][0]['status'] == 'criado' + registro = RegistroVotacao.objects.get(expediente=expediente) + assert registro.ordem is None + assert registro.numero_votos_sim == 10 + + del item['numero_votos_nao'] + r = post(client, sessao, {'votacoes': [item]}).json() + assert 'numero_votos_nao' in r['votacoes'][0]['erros'] + + +@pytest.mark.django_db(transaction=False) +def test_lote_votacao_alvo_invalido(client, sessao, ordem, resultado): + outra = baker.make(OrdemDia) + expediente = baker.make(ExpedienteMateria, sessao_plenaria=sessao) + r = post(client, sessao, {'votacoes': [ + {'ordem': outra.pk, 'tipo_resultado_votacao': resultado.pk, + 'votos': []}, + {'ordem': ordem.pk, 'expediente': expediente.pk, + 'tipo_resultado_votacao': resultado.pk, 'votos': []}, + {'tipo_resultado_votacao': resultado.pk, 'votos': []}, + ]}).json() + assert [i['status'] for i in r['votacoes']] == ['erro'] * 3 + assert not RegistroVotacao.objects.exists() + + +@pytest.mark.django_db(transaction=False) +def test_lote_atualiza_ordem_dia(client, sessao, ordem, resultado, + parlamentares): + data = { + 'votacoes': [votacao_nominal( + ordem, resultado, parlamentares, ['Sim', 'Sim', 'Sim'])], + 'ordens_dia': [{'id': ordem.pk, 'resultado': resultado.nome, + 'votacao_aberta': False}], + } + r = post(client, sessao, data).json() + assert r['ordens_dia'][0]['status'] == 'atualizado' + ordem.refresh_from_db() + assert ordem.resultado == 'Aprovado' + assert ordem.votacao_aberta is False + + r = post(client, sessao, data).json() + assert r['ordens_dia'][0]['status'] == 'inalterado' + assert r['resumo']['erro'] == 0 + + +@pytest.mark.django_db(transaction=False) +def test_lote_ordem_dia_de_outra_sessao(client, sessao): + outra = baker.make(OrdemDia) + ordem = baker.make(OrdemDia, sessao_plenaria=sessao) + r = post(client, sessao, {'ordens_dia': [ + {'id': outra.pk, 'resultado': 'x'}, + {'id': ordem.pk, 'sessao_plenaria': outra.sessao_plenaria_id}, + ]}).json() + assert [i['status'] for i in r['ordens_dia']] == ['erro', 'erro'] + outra.refresh_from_db() + ordem.refresh_from_db() + assert outra.resultado != 'x' + assert ordem.sessao_plenaria == sessao + + +@pytest.mark.django_db(transaction=False) +def test_lote_respeita_permissoes_por_bloco(sessao, ordem, resultado, + parlamentares): + user = get_user_model().objects.create_user( + username='operador', password='operador') + user.user_permissions.add(Permission.objects.get( + codename='add_sessaoplenariapresenca')) + c = APIClient() + c.force_authenticate(user) + + r = post(c, sessao, { + 'presencas_sessao': [{'parlamentar': parlamentares[0].pk}], + 'presencas_ordem_dia': [{'parlamentar': parlamentares[0].pk}], + 'votacoes': [votacao_nominal( + ordem, resultado, parlamentares, ['Sim', 'Sim', 'Sim'])], + }).json() + + assert r['presencas_sessao'][0]['status'] == 'criado' + assert r['presencas_ordem_dia'][0]['status'] == 'erro' + assert 'sessao.add_presencaordemdia' in r['presencas_ordem_dia'][0][ + 'erros'] + assert r['votacoes'][0]['status'] == 'erro' + assert not PresencaOrdemDia.objects.exists() + assert not RegistroVotacao.objects.exists()