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