diff --git a/docs/api-sessao-lote.md b/docs/api-sessao-lote.md index 2099b8ad3..dc7d857a5 100644 --- a/docs/api-sessao-lote.md +++ b/docs/api-sessao-lote.md @@ -14,12 +14,28 @@ sistema de votação etc.) precisa registrar numa sessão plenária: | `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 | +| `leituras` | `RegistroLeitura` | `POST`/`PATCH`/`DELETE /api/sessao/registroleitura/` | | `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. +## Requisições por sessão + +Considerando uma Casa com 21 parlamentares, uma sessão com 20 matérias +votadas nominalmente e 10 matérias lidas no expediente: + +| O que é gravado | Endpoints individuais | Lote | +|---|---|---| +| Presenças da sessão | 21 | | +| Presenças da ordem do dia | 21 | | +| Votações: 1 registro + 21 votos + 1 `PATCH` na ordem do dia, por matéria | 20 × 23 = 460 | | +| Leituras: 1 registro + 1 `PATCH` no expediente, por matéria | 10 × 2 = 20 | | +| **Total** | **522** | **1** | + +De modo geral, pelos endpoints individuais são `2 × P + M × (P + 2) + 2 × L` +requisições (P parlamentares, M matérias votadas nominalmente, L matérias +lidas). Com este endpoint, a integração pode registrar a sessão localmente e +fazer **uma única requisição ao final da sessão, apenas para +sincronização** com o SAPL. Os endpoints individuais continuam funcionando sem alteração. @@ -43,7 +59,7 @@ Os endpoints individuais continuam funcionando sem alteração. - **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. + votações, leituras, matérias da ordem do dia, matérias do expediente. - Máximo de 500 itens por bloco. ## Payload @@ -75,6 +91,10 @@ Os endpoints individuais continuam funcionando sem alteração. "numero_abstencoes": 0 } ], + "leituras": [ + {"expediente": 5, "observacao": ""}, + {"expediente": 6, "lida": false} + ], "ordens_dia": [ {"id": 10, "resultado": "Aprovado", "votacao_aberta": false} ], @@ -118,6 +138,21 @@ 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. +### Leituras + +Cada leitura informa **exatamente um** entre `ordem` e `expediente`; a +matéria é obtida do item da pauta. Há no máximo uma leitura por item da +pauta. `lida` é opcional (padrão `true`): + +- `true`: cria a leitura (`criado`) ou atualiza a `observacao` da existente + (`atualizado`/`inalterado`). Leituras duplicadas do mesmo item são + removidas. +- `false`: remove a leitura, como "retirar leitura" na tela (`removido`); se + não existir, `inalterado`. + +Assim como a votação, a leitura não altera a matéria da pauta: para gravar +`resultado: "Matéria lida"`, use `ordens_dia`/`expedientes_materia`. + ### 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 @@ -144,13 +179,17 @@ Confira `resumo.erro`. {"expediente": 4, "status": "erro", "erros": {"tipo_resultado_votacao": ["Este campo não pode ser nulo."]}} ], + "leituras": [ + {"expediente": 5, "lida": true, "status": "criado", "id": 80}, + {"expediente": 6, "lida": false, "status": "removido"} + ], "ordens_dia": [ {"id": 10, "status": "atualizado"} ], "expedientes_materia": [ {"id": 4, "status": "atualizado"} ], - "resumo": {"criado": 2, "atualizado": 2, "removido": 0, + "resumo": {"criado": 3, "atualizado": 2, "removido": 1, "inalterado": 2, "erro": 1} } ``` diff --git a/sapl/api/sessao_lote.py b/sapl/api/sessao_lote.py index 91c65c9f6..04962be42 100644 --- a/sapl/api/sessao_lote.py +++ b/sapl/api/sessao_lote.py @@ -2,8 +2,9 @@ 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. +dia, votações (registro + votos nominais), registros de leitura 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 é @@ -24,8 +25,9 @@ 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) + PresencaOrdemDia, RegistroLeitura, + RegistroVotacao, SessaoPlenariaPresenca, + VotoParlamentar) from sapl.utils import get_client_ip @@ -49,6 +51,7 @@ BLOCOS = ( 'presencas_sessao', 'presencas_ordem_dia', 'votacoes', + 'leituras', 'ordens_dia', 'expedientes_materia', ) @@ -115,9 +118,9 @@ class SessaoLote: 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. + # Presenças antes das votações e leituras, e estas 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( @@ -125,6 +128,7 @@ class SessaoLote: ('presencas_ordem_dia', lambda i: self.presenca( PresencaOrdemDia, i)), ('votacoes', self.votacao), + ('leituras', self.leitura), ('ordens_dia', lambda i: self.materia(OrdemDia, i)), ('expedientes_materia', lambda i: self.materia( ExpedienteMateria, i)), @@ -213,9 +217,10 @@ class SessaoLote: 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).""" + def alvo(self, item): + """Votação e leitura são de uma matéria da ordem do dia ou do + expediente, nunca das duas (mesma regra de RegistroVotacao.clean e + RegistroLeitura.clean).""" tem_ordem = item.get('ordem') is not None tem_expediente = item.get('expediente') is not None if tem_ordem == tem_expediente: @@ -269,7 +274,7 @@ class SessaoLote: "votos" (simbólica/secreta), informe numero_votos_sim, numero_votos_nao e numero_abstencoes. """ - campo, alvo = self.alvo_votacao(item) + campo, alvo = self.alvo(item) r = {campo: alvo.pk} votos = self.votos_do_item(item) if 'votos' in item else None @@ -356,6 +361,55 @@ class SessaoLote: alterado = True return alterado + def leitura(self, item): + """ + {"ordem"|"expediente": , "observacao": "...", + "lida": true|false} + + Uma leitura por matéria da pauta: "lida" (padrão true) cria ou + atualiza o registro; false remove, como "retirar leitura" na tela. + """ + campo, alvo = self.alvo(item) + lida = item.get('lida', True) + if not isinstance(lida, bool): + raise ItemInvalido({'lida': 'Deve ser true ou false.'}) + + r = {campo: alvo.pk, 'lida': lida} + existentes = RegistroLeitura.objects.filter(**{campo: alvo}) + + if not lida: + if not existentes.exists(): + return dict(r, status=INALTERADO) + self.exigir_perms(perm(RegistroLeitura, 'delete')) + existentes.delete() + return dict(r, status=REMOVIDO) + + registro = existentes.first() + self.exigir_perms(perm(RegistroLeitura, + 'change' if registro else 'add')) + duplicados = existentes.exclude(pk=registro.pk) if registro \ + else existentes.none() + limpou = duplicados.exists() + if limpou: + self.exigir_perms(perm(RegistroLeitura, 'delete')) + duplicados.delete() + + serializer = serializer_class_for(RegistroLeitura)( + registro, data={campo: alvo.pk, + 'materia': alvo.materia_id, + 'observacao': item.get('observacao', '')}, + context={'request': self.request}) + serializer.is_valid(raise_exception=True) + + if registro and not any( + getattr(registro, f) != v + for f, v in serializer.validated_data.items()): + status = ATUALIZADO if limpou else INALTERADO + return dict(r, status=status, id=registro.pk) + status = ATUALIZADO if registro else CRIADO + registro = serializer.save(user=self.user, ip=self.ip) + return dict(r, status=status, id=registro.pk) + def materia(self, model, item): """{"id": , }""" pk = item.get('id') diff --git a/sapl/sessao/tests/test_sessao_lote.py b/sapl/sessao/tests/test_sessao_lote.py index 3f923ea03..b262c70e0 100644 --- a/sapl/sessao/tests/test_sessao_lote.py +++ b/sapl/sessao/tests/test_sessao_lote.py @@ -6,7 +6,8 @@ from rest_framework.test import APIClient from sapl.parlamentares.models import Parlamentar from sapl.sessao.models import (ExpedienteMateria, OrdemDia, - PresencaOrdemDia, RegistroVotacao, + PresencaOrdemDia, RegistroLeitura, + RegistroVotacao, SessaoPlenaria, SessaoPlenariaPresenca, TipoResultadoVotacao, VotoParlamentar) @@ -235,6 +236,59 @@ def test_lote_votacao_alvo_invalido(client, sessao, ordem, resultado): assert not RegistroVotacao.objects.exists() +@pytest.mark.django_db(transaction=False) +def test_lote_leitura_expediente(client, sessao): + expediente = baker.make(ExpedienteMateria, sessao_plenaria=sessao) + data = { + 'leituras': [{'expediente': expediente.pk, 'observacao': 'Lida'}], + 'expedientes_materia': [{'id': expediente.pk, + 'resultado': 'Matéria lida', + 'votacao_aberta': False}], + } + + r = post(client, sessao, data).json() + assert r['leituras'][0]['status'] == 'criado' + assert r['expedientes_materia'][0]['status'] == 'atualizado' + leitura = RegistroLeitura.objects.get(expediente=expediente) + assert r['leituras'][0]['id'] == leitura.pk + assert leitura.materia_id == expediente.materia_id + assert leitura.ordem is None + assert leitura.observacao == 'Lida' + + # Reenvio não duplica. + r = post(client, sessao, data).json() + assert r['leituras'][0]['status'] == 'inalterado' + assert r['resumo']['erro'] == 0 + assert RegistroLeitura.objects.filter(expediente=expediente).count() == 1 + + data['leituras'][0]['observacao'] = 'Lida em plenário' + r = post(client, sessao, {'leituras': data['leituras']}).json() + assert r['leituras'][0]['status'] == 'atualizado' + leitura.refresh_from_db() + assert leitura.observacao == 'Lida em plenário' + + +@pytest.mark.django_db(transaction=False) +def test_lote_retira_leitura_e_remove_duplicadas(client, sessao, ordem): + baker.make(RegistroLeitura, ordem=ordem, materia=ordem.materia, + _quantity=2) + + r = post(client, sessao, {'leituras': [{'ordem': ordem.pk}]}).json() + assert r['leituras'][0]['status'] == 'atualizado' + assert RegistroLeitura.objects.filter(ordem=ordem).count() == 1 + + r = post(client, sessao, {'leituras': [ + {'ordem': ordem.pk, 'lida': False}]}).json() + assert r['leituras'][0]['status'] == 'removido' + assert not RegistroLeitura.objects.exists() + + r = post(client, sessao, {'leituras': [ + {'ordem': ordem.pk, 'lida': False}, + {'ordem': baker.make(OrdemDia).pk}, + ]}).json() + assert [i['status'] for i in r['leituras']] == ['inalterado', 'erro'] + + @pytest.mark.django_db(transaction=False) def test_lote_atualiza_ordem_dia(client, sessao, ordem, resultado, parlamentares):