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