diff --git a/docs/api-sessao-lote.md b/docs/api-sessao-lote.md new file mode 100644 index 000000000..dc7d857a5 --- /dev/null +++ b/docs/api-sessao-lote.md @@ -0,0 +1,207 @@ +# 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 | +| `leituras` | `RegistroLeitura` | `POST`/`PATCH`/`DELETE /api/sessao/registroleitura/` | +| `ordens_dia` | `OrdemDia` | `PATCH /api/sessao/ordemdia/{id}/` | +| `expedientes_materia` | `ExpedienteMateria` | `PATCH /api/sessao/expedientemateria/{id}/` | + +## 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. + +## 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, leituras, 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 + } + ], + "leituras": [ + {"expediente": 5, "observacao": ""}, + {"expediente": 6, "lida": false} + ], + "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. + +### 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 +`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."]}} + ], + "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": 3, "atualizado": 2, "removido": 1, + "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/docs/api-sessao-painel.md b/docs/api-sessao-painel.md new file mode 100644 index 000000000..89eb37773 --- /dev/null +++ b/docs/api-sessao-painel.md @@ -0,0 +1,78 @@ +# API: dados da Sessão Plenária para painel + +``` +GET /api/sessao/sessaoplenaria/{id}/painel/ +``` + +Devolve numa única requisição tudo o que um painel eletrônico ou integração +precisa para montar uma sessão plenária. É o par de leitura do +[lote de gravação](api-sessao-lote.md): a integração baixa a sessão no início +com este endpoint, trabalha localmente e envia tudo no fim com o lote. + +## Requisições por sessão + +Para montar o painel pelos endpoints de listagem, uma integração precisa +buscar sessão, tipo, casa, parlamentares, mandatos, presenças, pauta, +expediente, matérias, autorias, autores, registros de votação, votos, +leituras e cadastros auxiliares, paginando de 100 em 100. Numa Casa com 21 +parlamentares e 20 matérias votadas nominalmente, só os votos (420) ocupam 5 +páginas. + +| | Endpoints de listagem | Este endpoint | +|---|---|---| +| Montar o painel (início da sessão) | ~20, usando `expand` e filtros `__in`; bem mais sem eles | **1** | +| Sincronizar a sessão (fim) | 522 (ver [lote](api-sessao-lote.md)) | **1** com o lote | + +## Permissões + +Mesmas regras dos endpoints de listagem: todos os models devolvidos são de +leitura pública no SAPL, então o endpoint dispensa autenticação. Se alguma +dessas regras deixar de ser pública, o endpoint passa a exigir a permissão de +listagem de cada model e responde `403` indicando quais faltam. + +A visibilidade da sessão segue a consulta pública: sessões futuras +cadastradas com antecedência (não iniciadas e sem pauta publicada) respondem +`404` para usuários não autenticados. + +## Resposta + +Cada bloco usa o mesmo serializer da listagem do model correspondente, então +cada registro tem exatamente o formato de `/api///`. + +| Chave | Model | Conteúdo | +|---|---|---| +| `sessao_plenaria` | `SessaoPlenaria` | objeto da sessão | +| `tipo_sessao` | `TipoSessaoPlenaria` | objeto do tipo da sessão | +| `casa_legislativa` | `CasaLegislativa` | lista | +| `parlamentares` | `Parlamentar` | com mandato na legislatura da sessão ou citados nela (presença, mesa, voto, orador) | +| `mandatos` | `Mandato` | da legislatura da sessão | +| `filiacoes` | `Filiacao` | dos parlamentares acima | +| `partidos` | `Partido` | das filiações acima | +| `cargos_mesa` | `CargoMesa` | todos | +| `integrantes_mesa` | `IntegranteMesa` | mesa da sessão | +| `presencas_sessao` | `SessaoPlenariaPresenca` | da sessão | +| `presencas_ordem_dia` | `PresencaOrdemDia` | da sessão | +| `tipos_expediente` | `TipoExpediente` | todos | +| `expedientes` | `ExpedienteSessao` | da sessão | +| `expedientes_materia` | `ExpedienteMateria` | da sessão | +| `ordens_dia` | `OrdemDia` | da sessão | +| `materias` | `MateriaLegislativa` | da ordem do dia e do expediente | +| `autorias` | `Autoria` | dessas matérias | +| `autores` | `Autor` | dessas autorias | +| `tipos_autor` | `TipoAutor` | todos | +| `tipos_resultado_votacao` | `TipoResultadoVotacao` | todos | +| `registros_votacao` | `RegistroVotacao` | da ordem do dia e do expediente | +| `votos` | `VotoParlamentar` | da ordem do dia e do expediente | +| `registros_leitura` | `RegistroLeitura` | da ordem do dia e do expediente | +| `oradores` | `Orador` | explicações pessoais | +| `oradores_expediente` | `OradorExpediente` | da sessão | +| `oradores_ordem_dia` | `OradorOrdemDia` | da sessão | + +Os blocos não são paginados. + +## Desempenho + +O número de consultas ao banco não cresce com o tamanho da sessão: +relacionamentos usados no `__str__`, campos many-to-many e `metadata` são +carregados de uma vez por bloco. Numa sessão com cerca de 600 registros, a +resposta faz menos de 100 consultas. diff --git a/sapl/api/serializers.py b/sapl/api/serializers.py index f4a82db35..fb25058f2 100644 --- a/sapl/api/serializers.py +++ b/sapl/api/serializers.py @@ -23,6 +23,12 @@ class SaplSerializerMixin(DrfAutoApiSerializerMixin): fields = '__all__' def get_metadata(self, obj) -> dict: + # Quem serializa muitos registros pode carregar o metadata de todos + # numa consulta só e passá-lo no contexto (ver api/sessao_painel.py). + precarregado = self.context.get('metadata') + chave = (obj._meta.label, obj.pk) + if precarregado is not None and chave in precarregado: + return precarregado[chave] try: metadata = Metadata.objects.get( content_type=ContentType.objects.get_for_model( diff --git a/sapl/api/sessao_lote.py b/sapl/api/sessao_lote.py new file mode 100644 index 000000000..04962be42 --- /dev/null +++ b/sapl/api/sessao_lote.py @@ -0,0 +1,440 @@ +""" +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), 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 é + 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, RegistroLeitura, + 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', + 'leituras', + '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 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( + SessaoPlenariaPresenca, i)), + ('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)), + ) + 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(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: + 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(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 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') + 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/sessao_painel.py b/sapl/api/sessao_painel.py new file mode 100644 index 000000000..914d87497 --- /dev/null +++ b/sapl/api/sessao_painel.py @@ -0,0 +1,193 @@ +""" +Leitura, em uma única requisição, de tudo o que um painel eletrônico ou +integração precisa para montar uma Sessão Plenária: sessão, mesa, +parlamentares, presenças, expediente, ordem do dia, matérias, autorias, +votações, votos, leituras e oradores. Ver docs/api-sessao-painel.md. + +Cada bloco usa o mesmo serializer do endpoint de listagem do model, então +os registros têm o mesmo formato de /api///. +""" +from django.contrib.contenttypes.models import ContentType +from django.db.models import Q, prefetch_related_objects + +from drfautoapi.drfautoapi import ApiViewSetConstrutor +from sapl.api.permissions import SaplModelPermissions +from sapl.base.models import Autor, CasaLegislativa, Metadata, TipoAutor +from sapl.materia.models import Autoria, MateriaLegislativa +from sapl.parlamentares.models import (CargoMesa, Filiacao, Mandato, + Parlamentar, Partido) +from sapl.sessao.models import (ExpedienteMateria, ExpedienteSessao, + IntegranteMesa, Orador, OradorExpediente, + OradorOrdemDia, OrdemDia, PresencaOrdemDia, + RegistroLeitura, RegistroVotacao, + SessaoPlenariaPresenca, TipoExpediente, + TipoResultadoVotacao, TipoSessaoPlenaria, + VotoParlamentar) + + +def serializar(request, model, objs, many=True): + """Serializa com o serializer da listagem do model. Metadata e campos + many-to-many de todos os registros são lidos de uma vez, em vez de uma + consulta por registro.""" + serializer_class = ApiViewSetConstrutor.get_viewset_for_model( + model).serializer_class + lista = list(objs) if many else [objs] + prefetch_related_objects( + lista, *(f.name for f in model._meta.many_to_many)) + content_type = ContentType.objects.get_for_model(model) + metadata = dict.fromkeys(((model._meta.label, o.pk) for o in lista), {}) + metadata.update({ + (model._meta.label, m.object_id): m.metadata + for m in Metadata.objects.filter( + content_type=content_type, + object_id__in=[o.pk for o in lista])}) + return serializer_class( + lista if many else objs, many=many, + context={'request': request, 'metadata': metadata}).data + + +def por_id(objs): + return {o.pk: o for o in objs} + + +class SessaoPainel: + + # Models lidos, para conferir a permissão de leitura de cada um com as + # mesmas regras dos endpoints de listagem. + MODELS = ( + TipoSessaoPlenaria, CasaLegislativa, Parlamentar, Mandato, Filiacao, + Partido, CargoMesa, IntegranteMesa, SessaoPlenariaPresenca, + PresencaOrdemDia, TipoExpediente, ExpedienteSessao, + ExpedienteMateria, OrdemDia, MateriaLegislativa, Autoria, Autor, + TipoAutor, TipoResultadoVotacao, RegistroVotacao, VotoParlamentar, + RegistroLeitura, Orador, OradorExpediente, OradorOrdemDia, + ) + + def __init__(self, sessao, request): + self.sessao = sessao + self.request = request + + def models_sem_permissao(self): + permissao = SaplModelPermissions() + sem = [] + for model in self.MODELS: + # SaplModelPermissions confere a permissão pelo queryset da view. + view = type('View', (), {'queryset': model.objects.none()})() + if not permissao.has_permission(self.request, view): + sem.append('{}.{}'.format(model._meta.app_label, + model._meta.model_name)) + return sem + + def dados(self): + """ + Os registros da sessão recebem a própria sessão já carregada, e + votações, votos e leituras recebem os itens da pauta já carregados: + o __str__ de cada um sobe por essas relações e, sem isso, faria + consultas por registro. + """ + s = self.sessao + r = self.request + + def da_sessao(model, *relacionados): + objs = list(model.objects.filter( + sessao_plenaria=s).select_related(*relacionados)) + for o in objs: + o.sessao_plenaria = s + return objs + + ordens = da_sessao(OrdemDia, 'materia__tipo') + expedientes_materia = da_sessao(ExpedienteMateria, 'materia__tipo') + ordens_por_id = por_id(ordens) + expedientes_por_id = por_id(expedientes_materia) + da_pauta = Q(ordem_id__in=ordens_por_id) | \ + Q(expediente_id__in=expedientes_por_id) + + def na_pauta(objs): + for o in objs: + if o.ordem_id: + o.ordem = ordens_por_id[o.ordem_id] + if o.expediente_id: + o.expediente = expedientes_por_id[o.expediente_id] + return objs + + registros_votacao = na_pauta(list(RegistroVotacao.objects.filter( + da_pauta).select_related('tipo_resultado_votacao'))) + registros_por_id = por_id(registros_votacao) + votos = na_pauta(list(VotoParlamentar.objects.filter( + da_pauta).select_related('parlamentar'))) + for v in votos: + if v.votacao_id in registros_por_id: + v.votacao = registros_por_id[v.votacao_id] + registros_leitura = na_pauta(list(RegistroLeitura.objects.filter( + da_pauta).select_related('materia__tipo'))) + + materias_ids = {o.materia_id for o in ordens + expedientes_materia} + materias = list(MateriaLegislativa.objects.filter( + id__in=materias_ids).select_related('tipo')) + autorias = list(Autoria.objects.filter( + materia_id__in=materias_ids).select_related( + 'autor__tipo', 'materia__tipo').prefetch_related( + 'autor__autor_related')) + autores = {a.autor_id: a.autor for a in autorias} + + presencas = da_sessao(SessaoPlenariaPresenca, 'parlamentar') + presencas_ordem = da_sessao(PresencaOrdemDia, 'parlamentar') + mesa = da_sessao(IntegranteMesa, 'parlamentar', 'cargo') + expedientes = da_sessao(ExpedienteSessao, 'tipo') + oradores = {model: da_sessao(model, 'parlamentar') + for model in (Orador, OradorExpediente, OradorOrdemDia)} + + # Parlamentares com mandato na legislatura da sessão e qualquer + # outro citado nela (presença, mesa, voto ou orador). + mandatos = list(Mandato.objects.filter( + legislatura_id=s.legislatura_id).select_related( + 'parlamentar', 'legislatura')) + parlamentares_ids = {o.parlamentar_id for o in ( + mandatos + presencas + presencas_ordem + mesa + votos + + [o for lista in oradores.values() for o in lista])} + filiacoes = list(Filiacao.objects.filter( + parlamentar_id__in=parlamentares_ids).select_related( + 'parlamentar', 'partido')) + + return { + 'sessao_plenaria': serializar(r, type(s), s, many=False), + 'tipo_sessao': serializar( + r, TipoSessaoPlenaria, s.tipo, many=False), + 'casa_legislativa': serializar( + r, CasaLegislativa, CasaLegislativa.objects.all()), + 'parlamentares': serializar( + r, Parlamentar, Parlamentar.objects.filter( + id__in=parlamentares_ids)), + 'mandatos': serializar(r, Mandato, mandatos), + 'filiacoes': serializar(r, Filiacao, filiacoes), + 'partidos': serializar(r, Partido, Partido.objects.filter( + id__in={f.partido_id for f in filiacoes})), + 'cargos_mesa': serializar(r, CargoMesa, CargoMesa.objects.all()), + 'integrantes_mesa': serializar(r, IntegranteMesa, mesa), + 'presencas_sessao': serializar( + r, SessaoPlenariaPresenca, presencas), + 'presencas_ordem_dia': serializar( + r, PresencaOrdemDia, presencas_ordem), + 'tipos_expediente': serializar( + r, TipoExpediente, TipoExpediente.objects.all()), + 'expedientes': serializar(r, ExpedienteSessao, expedientes), + 'expedientes_materia': serializar( + r, ExpedienteMateria, expedientes_materia), + 'ordens_dia': serializar(r, OrdemDia, ordens), + 'materias': serializar(r, MateriaLegislativa, materias), + 'autorias': serializar(r, Autoria, autorias), + 'autores': serializar(r, Autor, autores.values()), + 'tipos_autor': serializar(r, TipoAutor, TipoAutor.objects.all()), + 'tipos_resultado_votacao': serializar( + r, TipoResultadoVotacao, TipoResultadoVotacao.objects.all()), + 'registros_votacao': serializar( + r, RegistroVotacao, registros_votacao), + 'votos': serializar(r, VotoParlamentar, votos), + 'registros_leitura': serializar( + r, RegistroLeitura, registros_leitura), + 'oradores': serializar(r, Orador, oradores[Orador]), + 'oradores_expediente': serializar( + r, OradorExpediente, oradores[OradorExpediente]), + 'oradores_ordem_dia': serializar( + r, OradorOrdemDia, oradores[OradorOrdemDia]), + } diff --git a/sapl/api/views_sessao.py b/sapl/api/views_sessao.py index 29c31bf29..e37429168 100644 --- a/sapl/api/views_sessao.py +++ b/sapl/api/views_sessao.py @@ -1,13 +1,18 @@ from django.apps.registry import apps +from django.shortcuts import get_object_or_404 from rest_framework.decorators import action +from rest_framework.exceptions import PermissionDenied +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.sessao.models import SessaoPlenaria, ExpedienteSessao,\ +from sapl.api.sessao_lote import SessaoLote +from sapl.api.sessao_painel import SessaoPainel +from sapl.sessao.models import SessaoPlenaria, ExpedienteSessao, \ restringe_sessoes_visiveis from sapl.utils import choice_anos_com_sessaoplenaria @@ -55,6 +60,38 @@ 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, leituras 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)) + + @action(detail=True) + def painel(self, request, *args, **kwargs): + """ + Devolve em uma única requisição tudo o que um painel eletrônico + precisa para montar a sessão. Detalhes em docs/api-sessao-painel.md. + """ + sessao = get_object_or_404( + restringe_sessoes_visiveis( + SessaoPlenaria.objects.select_related( + 'tipo', 'sessao_legislativa', 'legislatura'), request.user), + pk=kwargs['pk']) + painel = SessaoPainel(sessao, request) + sem_permissao = painel.models_sem_permissao() + if sem_permissao: + raise PermissionDenied('Sem permissão de leitura em: {}.'.format( + ', '.join(sem_permissao))) + return Response(painel.dados()) + 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..b262c70e0 --- /dev/null +++ b/sapl/sessao/tests/test_sessao_lote.py @@ -0,0 +1,350 @@ +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, RegistroLeitura, + 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_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): + 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() diff --git a/sapl/sessao/tests/test_sessao_painel_api.py b/sapl/sessao/tests/test_sessao_painel_api.py new file mode 100644 index 000000000..24ab23973 --- /dev/null +++ b/sapl/sessao/tests/test_sessao_painel_api.py @@ -0,0 +1,127 @@ +from datetime import timedelta + +import pytest +from django.db import connection +from django.test.utils import CaptureQueriesContext +from django.utils import timezone +from model_bakery import baker +from rest_framework.test import APIClient + +from sapl.base.models import Autor +from sapl.materia.models import Autoria +from sapl.parlamentares.models import Mandato, Parlamentar +from sapl.sessao.models import (ExpedienteMateria, IntegranteMesa, OrdemDia, + OradorExpediente, PresencaOrdemDia, + RegistroLeitura, RegistroVotacao, + SessaoPlenaria, SessaoPlenariaPresenca, + VotoParlamentar) + + +def url(sessao): + return '/api/sessao/sessaoplenaria/{}/painel/'.format(sessao.pk) + + +@pytest.fixture +def sessao(): + return baker.make(SessaoPlenaria, data_inicio=timezone.localdate(), + iniciada=True) + + +@pytest.fixture +def sessao_completa(sessao): + """Casa com 21 parlamentares e 20 matérias votadas nominalmente.""" + parlamentares = baker.make(Parlamentar, ativo=True, _quantity=21) + for p in parlamentares: + baker.make(Mandato, parlamentar=p, legislatura=sessao.legislatura) + baker.make(SessaoPlenariaPresenca, sessao_plenaria=sessao, + parlamentar=p) + baker.make(PresencaOrdemDia, sessao_plenaria=sessao, parlamentar=p) + baker.make(IntegranteMesa, sessao_plenaria=sessao, + parlamentar=parlamentares[0]) + for _ in range(20): + ordem = baker.make(OrdemDia, sessao_plenaria=sessao) + baker.make(Autoria, materia=ordem.materia, autor=baker.make(Autor)) + registro = baker.make(RegistroVotacao, ordem=ordem, + materia=ordem.materia) + for p in parlamentares: + baker.make(VotoParlamentar, ordem=ordem, votacao=registro, + parlamentar=p, voto='Sim') + for _ in range(5): + expediente = baker.make(ExpedienteMateria, sessao_plenaria=sessao) + baker.make(RegistroLeitura, expediente=expediente, + materia=expediente.materia) + baker.make(OradorExpediente, sessao_plenaria=sessao, + parlamentar=parlamentares[1]) + return sessao + + +@pytest.mark.django_db(transaction=False) +def test_painel_publico_traz_toda_a_sessao(sessao_completa): + response = APIClient().get(url(sessao_completa)) + assert response.status_code == 200 + r = response.json() + + assert r['sessao_plenaria']['id'] == sessao_completa.pk + assert r['tipo_sessao']['id'] == sessao_completa.tipo_id + assert len(r['parlamentares']) == 21 + assert len(r['mandatos']) == 21 + assert len(r['presencas_sessao']) == 21 + assert len(r['presencas_ordem_dia']) == 21 + assert len(r['integrantes_mesa']) == 1 + assert len(r['ordens_dia']) == 20 + assert len(r['expedientes_materia']) == 5 + assert len(r['materias']) == 25 + assert len(r['autorias']) == 20 + assert len(r['autores']) == 20 + assert len(r['registros_votacao']) == 20 + assert len(r['votos']) == 420 + assert len(r['registros_leitura']) == 5 + assert len(r['oradores_expediente']) == 1 + + +@pytest.mark.django_db(transaction=False) +def test_painel_mesmo_formato_da_listagem(sessao_completa): + c = APIClient() + painel = c.get(url(sessao_completa)).json() + lista = c.get('/api/sessao/ordemdia/', { + 'sessao_plenaria': sessao_completa.pk, 'page_size': 100}).json() + assert sorted(painel['ordens_dia'], key=lambda o: o['id']) == \ + sorted(lista['results'], key=lambda o: o['id']) + + +@pytest.mark.django_db(transaction=False) +def test_painel_nao_mistura_outras_sessoes(sessao_completa): + outra = baker.make(SessaoPlenaria, data_inicio=timezone.localdate()) + baker.make(OrdemDia, sessao_plenaria=outra) + baker.make(SessaoPlenariaPresenca, sessao_plenaria=outra) + + r = APIClient().get(url(outra)).json() + assert len(r['ordens_dia']) == 1 + assert len(r['presencas_sessao']) == 1 + assert r['votos'] == [] + + +@pytest.mark.django_db(transaction=False) +def test_painel_numero_de_consultas_limitado(sessao_completa): + with CaptureQueriesContext(connection) as consultas: + assert APIClient().get(url(sessao_completa)).status_code == 200 + # ~620 registros serializados: o número de consultas não pode crescer + # com o volume (sem N+1 em __str__, metadata e many-to-many). + assert len(consultas) < 100 + + +@pytest.mark.django_db(transaction=False) +def test_painel_sessao_futura_oculta_ao_publico(admin_user): + futura = baker.make(SessaoPlenaria, iniciada=False, publicar_pauta=False, + data_inicio=timezone.localdate() + timedelta(days=3)) + assert APIClient().get(url(futura)).status_code == 404 + + c = APIClient() + c.force_authenticate(admin_user) + assert c.get(url(futura)).status_code == 200 + + +@pytest.mark.django_db(transaction=False) +def test_painel_sessao_inexistente(): + response = APIClient().get('/api/sessao/sessaoplenaria/999999/painel/') + assert response.status_code == 404