mirror of https://github.com/interlegis/sapl.git
committed by
GitHub
8 changed files with 1439 additions and 1 deletions
@ -0,0 +1,207 @@ |
|||
# API: gravação em lote da Sessão Plenária |
|||
|
|||
``` |
|||
POST /api/sessao/sessaoplenaria/{id}/lote/ |
|||
Authorization: Token <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 | |
|||
@ -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/<app>/<model>/`. |
|||
|
|||
| 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. |
|||
@ -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": <id>, "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": <id>, "tipo_resultado_votacao": <id>, |
|||
"observacao": "...", |
|||
"votos": [{"parlamentar": <id>, "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": <id>, "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": <id>, <campos do PATCH do model>}""" |
|||
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) |
|||
@ -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/<app>/<model>/. |
|||
""" |
|||
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]), |
|||
} |
|||
@ -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() |
|||
@ -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 |
|||
Loading…
Reference in new issue