Browse Source

Merge 3fea087be3 into c46a5a0c7c

pull/3866/merge
Samuel Zwinglio 2 weeks ago
committed by GitHub
parent
commit
33528b056f
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 207
      docs/api-sessao-lote.md
  2. 78
      docs/api-sessao-painel.md
  3. 6
      sapl/api/serializers.py
  4. 440
      sapl/api/sessao_lote.py
  5. 193
      sapl/api/sessao_painel.py
  6. 39
      sapl/api/views_sessao.py
  7. 350
      sapl/sessao/tests/test_sessao_lote.py
  8. 127
      sapl/sessao/tests/test_sessao_painel_api.py

207
docs/api-sessao-lote.md

@ -0,0 +1,207 @@
# API: gravação em lote da Sessão Plenária
```
POST /api/sessao/sessaoplenaria/{id}/lote/
Authorization: Token <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 |

78
docs/api-sessao-painel.md

@ -0,0 +1,78 @@
# API: dados da Sessão Plenária para painel
```
GET /api/sessao/sessaoplenaria/{id}/painel/
```
Devolve numa única requisição tudo o que um painel eletrônico ou integração
precisa para montar uma sessão plenária. É o par de leitura do
[lote de gravação](api-sessao-lote.md): a integração baixa a sessão no início
com este endpoint, trabalha localmente e envia tudo no fim com o lote.
## Requisições por sessão
Para montar o painel pelos endpoints de listagem, uma integração precisa
buscar sessão, tipo, casa, parlamentares, mandatos, presenças, pauta,
expediente, matérias, autorias, autores, registros de votação, votos,
leituras e cadastros auxiliares, paginando de 100 em 100. Numa Casa com 21
parlamentares e 20 matérias votadas nominalmente, só os votos (420) ocupam 5
páginas.
| | Endpoints de listagem | Este endpoint |
|---|---|---|
| Montar o painel (início da sessão) | ~20, usando `expand` e filtros `__in`; bem mais sem eles | **1** |
| Sincronizar a sessão (fim) | 522 (ver [lote](api-sessao-lote.md)) | **1** com o lote |
## Permissões
Mesmas regras dos endpoints de listagem: todos os models devolvidos são de
leitura pública no SAPL, então o endpoint dispensa autenticação. Se alguma
dessas regras deixar de ser pública, o endpoint passa a exigir a permissão de
listagem de cada model e responde `403` indicando quais faltam.
A visibilidade da sessão segue a consulta pública: sessões futuras
cadastradas com antecedência (não iniciadas e sem pauta publicada) respondem
`404` para usuários não autenticados.
## Resposta
Cada bloco usa o mesmo serializer da listagem do model correspondente, então
cada registro tem exatamente o formato de `/api/<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.

6
sapl/api/serializers.py

@ -23,6 +23,12 @@ class SaplSerializerMixin(DrfAutoApiSerializerMixin):
fields = '__all__'
def get_metadata(self, obj) -> dict:
# Quem serializa muitos registros pode carregar o metadata de todos
# numa consulta só e passá-lo no contexto (ver api/sessao_painel.py).
precarregado = self.context.get('metadata')
chave = (obj._meta.label, obj.pk)
if precarregado is not None and chave in precarregado:
return precarregado[chave]
try:
metadata = Metadata.objects.get(
content_type=ContentType.objects.get_for_model(

440
sapl/api/sessao_lote.py

@ -0,0 +1,440 @@
"""
Gravação em lote dos dados de uma Sessão Plenária.
Recebe, numa única requisição, presenças da sessão, presenças da ordem do
dia, votações (registro + votos nominais), registros de leitura e
atualizações de matérias da ordem do dia/expediente.
Ver docs/api-sessao-lote.md.
Regras:
- qualquer combinação de blocos é aceita; o que não for enviado não é
alterado;
- cada item é gravado em sua própria transação: a falha de um não
impede a gravação dos demais;
- cada item devolve um status (criado, atualizado, removido, inalterado
ou erro) para que o cliente reenvie apenas o que falhou;
- reenviar o mesmo lote não duplica presenças nem votos;
- validações vêm dos mesmos serializers e as permissões são as mesmas
exigidas pelos endpoints individuais de cada model.
"""
import logging
from django.db import transaction
from rest_framework import serializers
from drfautoapi.drfautoapi import ApiViewSetConstrutor
from sapl.parlamentares.models import Parlamentar
from sapl.sessao.models import (ExpedienteMateria, OrdemDia,
PresencaOrdemDia, RegistroLeitura,
RegistroVotacao, SessaoPlenariaPresenca,
VotoParlamentar)
from sapl.utils import get_client_ip
logger = logging.getLogger(__name__)
MAX_ITENS_POR_BLOCO = 500
CRIADO = 'criado'
ATUALIZADO = 'atualizado'
REMOVIDO = 'removido'
INALTERADO = 'inalterado'
ERRO = 'erro'
VOTO_SIM = 'Sim'
VOTO_NAO = 'Não'
VOTO_ABSTENCAO = 'Abstenção'
VOTO_NAO_VOTOU = 'Não Votou'
VOTOS_VALIDOS = (VOTO_SIM, VOTO_NAO, VOTO_ABSTENCAO, VOTO_NAO_VOTOU)
BLOCOS = (
'presencas_sessao',
'presencas_ordem_dia',
'votacoes',
'leituras',
'ordens_dia',
'expedientes_materia',
)
class ItemInvalido(Exception):
def __init__(self, erros):
super().__init__(erros)
self.erros = erros
def perm(model, acao):
return '{}.{}_{}'.format(
model._meta.app_label, acao, model._meta.model_name)
def serializer_class_for(model):
return ApiViewSetConstrutor.get_viewset_for_model(model).serializer_class
class SessaoLote:
def __init__(self, sessao, request):
self.sessao = sessao
self.request = request
self.user = request.user
self.ip = get_client_ip(request)
@classmethod
def validar_payload(cls, data):
"""Valida apenas a estrutura do lote. Erros aqui recusam o lote
inteiro (HTTP 400); erros de item são reportados por item."""
if not isinstance(data, dict):
raise serializers.ValidationError(
'O corpo da requisição deve ser um objeto JSON.')
desconhecidos = sorted(set(data) - set(BLOCOS))
if desconhecidos:
raise serializers.ValidationError({
k: 'Bloco desconhecido. Blocos aceitos: {}.'.format(
', '.join(BLOCOS)) for k in desconhecidos})
if not any(b in data for b in BLOCOS):
raise serializers.ValidationError(
'Informe ao menos um dos blocos: {}.'.format(
', '.join(BLOCOS)))
erros = {}
for bloco in BLOCOS:
if bloco not in data:
continue
itens = data[bloco]
if not isinstance(itens, list):
erros[bloco] = 'Deve ser uma lista.'
elif len(itens) > MAX_ITENS_POR_BLOCO:
erros[bloco] = 'Máximo de {} itens por bloco.'.format(
MAX_ITENS_POR_BLOCO)
elif not all(isinstance(i, dict) for i in itens):
erros[bloco] = 'Todos os itens devem ser objetos JSON.'
if erros:
raise serializers.ValidationError(erros)
def processar(self, data):
self.validar_payload(data)
# Presenças antes das votações e leituras, e estas antes das
# atualizações de matéria, na mesma ordem em que as integrações
# fariam com os endpoints individuais.
relatorio = {'sessao_plenaria': self.sessao.pk}
processadores = (
('presencas_sessao', lambda i: self.presenca(
SessaoPlenariaPresenca, i)),
('presencas_ordem_dia', lambda i: self.presenca(
PresencaOrdemDia, i)),
('votacoes', self.votacao),
('leituras', self.leitura),
('ordens_dia', lambda i: self.materia(OrdemDia, i)),
('expedientes_materia', lambda i: self.materia(
ExpedienteMateria, i)),
)
for bloco, processador in processadores:
if bloco in data:
relatorio[bloco] = [
self.processar_item(processador, item)
for item in data[bloco]]
resumo = dict.fromkeys(
(CRIADO, ATUALIZADO, REMOVIDO, INALTERADO, ERRO), 0)
for bloco in BLOCOS:
for item in relatorio.get(bloco, []):
resumo[item['status']] += 1
relatorio['resumo'] = resumo
logger.info('user={}. Lote da sessão plenária (pk={}) processado: '
'{}'.format(self.user.username, self.sessao.pk, resumo))
return relatorio
def processar_item(self, processador, item):
try:
with transaction.atomic():
return processador(item)
except ItemInvalido as e:
return self.item_erro(item, e.erros)
except serializers.ValidationError as e:
return self.item_erro(item, e.detail)
except Exception as e:
logger.error('user={}. Erro ao processar item do lote da sessão '
'plenária (pk={}): {}. {}'.format(
self.user.username, self.sessao.pk, item, e))
return self.item_erro(item, str(e))
def item_erro(self, item, erros):
chaves = ('parlamentar', 'ordem', 'expediente', 'id')
r = {k: item[k] for k in chaves if k in item}
r.update(status=ERRO, erros=erros)
return r
def exigir_perms(self, *perms):
faltando = [p for p in perms if not self.user.has_perm(p)]
if faltando:
raise ItemInvalido(
'Usuário sem permissão: {}.'.format(', '.join(faltando)))
def parlamentar_id(self, valor, campo='parlamentar'):
if isinstance(valor, bool) or not isinstance(valor, int):
raise ItemInvalido({campo: 'Informe o id do parlamentar.'})
if not Parlamentar.objects.filter(pk=valor).exists():
raise ItemInvalido(
{campo: 'Parlamentar (pk={}) não existe.'.format(valor)})
return valor
def presenca(self, model, item):
"""{"parlamentar": <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)

193
sapl/api/sessao_painel.py

@ -0,0 +1,193 @@
"""
Leitura, em uma única requisição, de tudo o que um painel eletrônico ou
integração precisa para montar uma Sessão Plenária: sessão, mesa,
parlamentares, presenças, expediente, ordem do dia, matérias, autorias,
votações, votos, leituras e oradores. Ver docs/api-sessao-painel.md.
Cada bloco usa o mesmo serializer do endpoint de listagem do model, então
os registros têm o mesmo formato de /api/<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]),
}

39
sapl/api/views_sessao.py

@ -1,13 +1,18 @@
from django.apps.registry import apps
from django.shortcuts import get_object_or_404
from rest_framework.decorators import action
from rest_framework.exceptions import PermissionDenied
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from drfautoapi.drfautoapi import ApiViewSetConstrutor, \
customize, wrapper_queryset_response_for_drf_action
from sapl.api.serializers import ChoiceSerializer,\
SessaoPlenariaECidadaniaSerializer
from sapl.sessao.models import SessaoPlenaria, ExpedienteSessao,\
from sapl.api.sessao_lote import SessaoLote
from sapl.api.sessao_painel import SessaoPainel
from sapl.sessao.models import SessaoPlenaria, ExpedienteSessao, \
restringe_sessoes_visiveis
from sapl.utils import choice_anos_com_sessaoplenaria
@ -55,6 +60,38 @@ class _SessaoPlenariaViewSet:
self.serializer_class = SessaoPlenariaECidadaniaSerializer
return self.list(request, *args, **kwargs)
# As permissões são verificadas por item em SessaoLote, com as mesmas
# regras dos endpoints individuais de cada model.
@action(detail=True, methods=['post'],
permission_classes=[IsAuthenticated])
def lote(self, request, *args, **kwargs):
"""
Grava em uma única requisição presenças da sessão, presenças da
ordem do dia, votações, leituras e atualizações de matérias da
sessão.
Detalhes em docs/api-sessao-lote.md.
"""
sessao = get_object_or_404(SessaoPlenaria, pk=kwargs['pk'])
return Response(SessaoLote(sessao, request).processar(request.data))
@action(detail=True)
def painel(self, request, *args, **kwargs):
"""
Devolve em uma única requisição tudo o que um painel eletrônico
precisa para montar a sessão. Detalhes em docs/api-sessao-painel.md.
"""
sessao = get_object_or_404(
restringe_sessoes_visiveis(
SessaoPlenaria.objects.select_related(
'tipo', 'sessao_legislativa', 'legislatura'), request.user),
pk=kwargs['pk'])
painel = SessaoPainel(sessao, request)
sem_permissao = painel.models_sem_permissao()
if sem_permissao:
raise PermissionDenied('Sem permissão de leitura em: {}.'.format(
', '.join(sem_permissao)))
return Response(painel.dados())
class _RestringeSessoesVisiveisMixin:

350
sapl/sessao/tests/test_sessao_lote.py

@ -0,0 +1,350 @@
import pytest
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Permission
from model_bakery import baker
from rest_framework.test import APIClient
from sapl.parlamentares.models import Parlamentar
from sapl.sessao.models import (ExpedienteMateria, OrdemDia,
PresencaOrdemDia, RegistroLeitura,
RegistroVotacao,
SessaoPlenaria, SessaoPlenariaPresenca,
TipoResultadoVotacao, VotoParlamentar)
def url(sessao):
return '/api/sessao/sessaoplenaria/{}/lote/'.format(sessao.pk)
@pytest.fixture
def client(admin_user):
c = APIClient()
c.force_authenticate(admin_user)
return c
@pytest.fixture
def sessao():
return baker.make(SessaoPlenaria)
@pytest.fixture
def parlamentares():
return baker.make(Parlamentar, ativo=True, _quantity=3)
@pytest.fixture
def ordem(sessao):
return baker.make(OrdemDia, sessao_plenaria=sessao, votacao_aberta=True)
@pytest.fixture
def resultado():
return baker.make(TipoResultadoVotacao, nome='Aprovado', natureza='A')
def post(client, sessao, data):
return client.post(url(sessao), data, format='json')
def votacao_nominal(ordem, resultado, parlamentares, votos):
return {
'ordem': ordem.pk,
'tipo_resultado_votacao': resultado.pk,
'votos': [{'parlamentar': p.pk, 'voto': v}
for p, v in zip(parlamentares, votos)],
}
@pytest.mark.django_db(transaction=False)
def test_lote_exige_autenticacao(sessao):
response = APIClient().post(url(sessao), {}, format='json')
assert response.status_code in (401, 403)
@pytest.mark.django_db(transaction=False)
def test_lote_sessao_inexistente(client):
response = client.post('/api/sessao/sessaoplenaria/999999/lote/',
{'presencas_sessao': []}, format='json')
assert response.status_code == 404
@pytest.mark.django_db(transaction=False)
def test_lote_estrutura_invalida(client, sessao):
assert post(client, sessao, {}).status_code == 400
assert post(client, sessao, {'votos': []}).status_code == 400
assert post(client, sessao, {'presencas_sessao': {}}).status_code == 400
assert post(client, sessao, {'presencas_sessao': [1]}).status_code == 400
@pytest.mark.django_db(transaction=False)
def test_lote_presencas_idempotente(client, sessao, parlamentares):
data = {
'presencas_sessao': [{'parlamentar': p.pk} for p in parlamentares],
'presencas_ordem_dia': [{'parlamentar': parlamentares[0].pk,
'presente': True}],
}
response = post(client, sessao, data)
assert response.status_code == 200
r = response.json()
assert [i['status'] for i in r['presencas_sessao']] == ['criado'] * 3
assert r['presencas_ordem_dia'][0]['status'] == 'criado'
assert r['resumo']['criado'] == 4
assert SessaoPlenariaPresenca.objects.filter(
sessao_plenaria=sessao).count() == 3
assert SessaoPlenariaPresenca.objects.filter(
sessao_plenaria=sessao, data_sessao=sessao.data_inicio).count() == 3
r = post(client, sessao, data).json()
assert r['resumo']['inalterado'] == 4
assert r['resumo']['criado'] == 0
assert SessaoPlenariaPresenca.objects.filter(
sessao_plenaria=sessao).count() == 3
assert PresencaOrdemDia.objects.filter(
sessao_plenaria=sessao).count() == 1
@pytest.mark.django_db(transaction=False)
def test_lote_remove_presenca(client, sessao, parlamentares):
baker.make(SessaoPlenariaPresenca, sessao_plenaria=sessao,
parlamentar=parlamentares[0])
r = post(client, sessao, {'presencas_sessao': [
{'parlamentar': parlamentares[0].pk, 'presente': False},
{'parlamentar': parlamentares[1].pk, 'presente': False},
]}).json()
assert [i['status'] for i in r['presencas_sessao']] == [
'removido', 'inalterado']
assert not SessaoPlenariaPresenca.objects.filter(
sessao_plenaria=sessao).exists()
@pytest.mark.django_db(transaction=False)
def test_lote_falha_de_item_nao_impede_os_demais(client, sessao,
parlamentares):
r = post(client, sessao, {'presencas_sessao': [
{'parlamentar': parlamentares[0].pk},
{'parlamentar': 999999},
{'parlamentar': 'abc'},
{'parlamentar': parlamentares[1].pk},
]}).json()
status = [i['status'] for i in r['presencas_sessao']]
assert status == ['criado', 'erro', 'erro', 'criado']
assert 'parlamentar' in r['presencas_sessao'][1]['erros']
assert r['resumo']['erro'] == 2
assert SessaoPlenariaPresenca.objects.filter(
sessao_plenaria=sessao).count() == 2
@pytest.mark.django_db(transaction=False)
def test_lote_votacao_nominal(client, sessao, ordem, resultado,
parlamentares):
data = {'votacoes': [votacao_nominal(
ordem, resultado, parlamentares, ['Sim', 'Sim', 'Não'])]}
r = post(client, sessao, data).json()
assert r['votacoes'][0]['status'] == 'criado'
registro = RegistroVotacao.objects.get(ordem=ordem)
assert r['votacoes'][0]['id'] == registro.pk
assert registro.materia_id == ordem.materia_id
assert (registro.numero_votos_sim, registro.numero_votos_nao,
registro.numero_abstencoes) == (2, 1, 0)
assert VotoParlamentar.objects.filter(
ordem=ordem, votacao=registro).count() == 3
# Reenvio do mesmo lote não duplica registro nem votos.
r = post(client, sessao, data).json()
assert r['votacoes'][0]['status'] == 'inalterado'
assert RegistroVotacao.objects.filter(ordem=ordem).count() == 1
assert VotoParlamentar.objects.filter(ordem=ordem).count() == 3
@pytest.mark.django_db(transaction=False)
def test_lote_votacao_nominal_atualiza_votos(client, sessao, ordem,
resultado, parlamentares):
post(client, sessao, {'votacoes': [votacao_nominal(
ordem, resultado, parlamentares, ['Sim', 'Sim', 'Não'])]})
# Último parlamentar deixa a lista; segundo muda o voto.
r = post(client, sessao, {'votacoes': [votacao_nominal(
ordem, resultado, parlamentares[:2], ['Sim', 'Abstenção'])]}).json()
assert r['votacoes'][0]['status'] == 'atualizado'
registro = RegistroVotacao.objects.get(ordem=ordem)
assert (registro.numero_votos_sim, registro.numero_votos_nao,
registro.numero_abstencoes) == (1, 0, 1)
votos = dict(VotoParlamentar.objects.filter(
ordem=ordem).values_list('parlamentar_id', 'voto'))
assert votos == {parlamentares[0].pk: 'Sim',
parlamentares[1].pk: 'Abstenção'}
@pytest.mark.django_db(transaction=False)
def test_lote_votacao_invalida_nao_grava_nada(client, sessao, ordem,
resultado, parlamentares):
item = votacao_nominal(ordem, resultado, parlamentares,
['Sim', 'Talvez', 'Não'])
r = post(client, sessao, {'votacoes': [item]}).json()
assert r['votacoes'][0]['status'] == 'erro'
assert '1' in r['votacoes'][0]['erros']['votos']
assert not RegistroVotacao.objects.exists()
assert not VotoParlamentar.objects.exists()
item = votacao_nominal(ordem, resultado, parlamentares,
['Sim', 'Sim', 'Não'])
item['numero_votos_sim'] = 3
r = post(client, sessao, {'votacoes': [item]}).json()
assert 'numero_votos_sim' in r['votacoes'][0]['erros']
assert not RegistroVotacao.objects.exists()
@pytest.mark.django_db(transaction=False)
def test_lote_votacao_simbolica_expediente(client, sessao, resultado):
expediente = baker.make(ExpedienteMateria, sessao_plenaria=sessao)
item = {'expediente': expediente.pk,
'tipo_resultado_votacao': resultado.pk,
'numero_votos_sim': 10, 'numero_votos_nao': 1,
'numero_abstencoes': 0}
r = post(client, sessao, {'votacoes': [item]}).json()
assert r['votacoes'][0]['status'] == 'criado'
registro = RegistroVotacao.objects.get(expediente=expediente)
assert registro.ordem is None
assert registro.numero_votos_sim == 10
del item['numero_votos_nao']
r = post(client, sessao, {'votacoes': [item]}).json()
assert 'numero_votos_nao' in r['votacoes'][0]['erros']
@pytest.mark.django_db(transaction=False)
def test_lote_votacao_alvo_invalido(client, sessao, ordem, resultado):
outra = baker.make(OrdemDia)
expediente = baker.make(ExpedienteMateria, sessao_plenaria=sessao)
r = post(client, sessao, {'votacoes': [
{'ordem': outra.pk, 'tipo_resultado_votacao': resultado.pk,
'votos': []},
{'ordem': ordem.pk, 'expediente': expediente.pk,
'tipo_resultado_votacao': resultado.pk, 'votos': []},
{'tipo_resultado_votacao': resultado.pk, 'votos': []},
]}).json()
assert [i['status'] for i in r['votacoes']] == ['erro'] * 3
assert not RegistroVotacao.objects.exists()
@pytest.mark.django_db(transaction=False)
def test_lote_leitura_expediente(client, sessao):
expediente = baker.make(ExpedienteMateria, sessao_plenaria=sessao)
data = {
'leituras': [{'expediente': expediente.pk, 'observacao': 'Lida'}],
'expedientes_materia': [{'id': expediente.pk,
'resultado': 'Matéria lida',
'votacao_aberta': False}],
}
r = post(client, sessao, data).json()
assert r['leituras'][0]['status'] == 'criado'
assert r['expedientes_materia'][0]['status'] == 'atualizado'
leitura = RegistroLeitura.objects.get(expediente=expediente)
assert r['leituras'][0]['id'] == leitura.pk
assert leitura.materia_id == expediente.materia_id
assert leitura.ordem is None
assert leitura.observacao == 'Lida'
# Reenvio não duplica.
r = post(client, sessao, data).json()
assert r['leituras'][0]['status'] == 'inalterado'
assert r['resumo']['erro'] == 0
assert RegistroLeitura.objects.filter(expediente=expediente).count() == 1
data['leituras'][0]['observacao'] = 'Lida em plenário'
r = post(client, sessao, {'leituras': data['leituras']}).json()
assert r['leituras'][0]['status'] == 'atualizado'
leitura.refresh_from_db()
assert leitura.observacao == 'Lida em plenário'
@pytest.mark.django_db(transaction=False)
def test_lote_retira_leitura_e_remove_duplicadas(client, sessao, ordem):
baker.make(RegistroLeitura, ordem=ordem, materia=ordem.materia,
_quantity=2)
r = post(client, sessao, {'leituras': [{'ordem': ordem.pk}]}).json()
assert r['leituras'][0]['status'] == 'atualizado'
assert RegistroLeitura.objects.filter(ordem=ordem).count() == 1
r = post(client, sessao, {'leituras': [
{'ordem': ordem.pk, 'lida': False}]}).json()
assert r['leituras'][0]['status'] == 'removido'
assert not RegistroLeitura.objects.exists()
r = post(client, sessao, {'leituras': [
{'ordem': ordem.pk, 'lida': False},
{'ordem': baker.make(OrdemDia).pk},
]}).json()
assert [i['status'] for i in r['leituras']] == ['inalterado', 'erro']
@pytest.mark.django_db(transaction=False)
def test_lote_atualiza_ordem_dia(client, sessao, ordem, resultado,
parlamentares):
data = {
'votacoes': [votacao_nominal(
ordem, resultado, parlamentares, ['Sim', 'Sim', 'Sim'])],
'ordens_dia': [{'id': ordem.pk, 'resultado': resultado.nome,
'votacao_aberta': False}],
}
r = post(client, sessao, data).json()
assert r['ordens_dia'][0]['status'] == 'atualizado'
ordem.refresh_from_db()
assert ordem.resultado == 'Aprovado'
assert ordem.votacao_aberta is False
r = post(client, sessao, data).json()
assert r['ordens_dia'][0]['status'] == 'inalterado'
assert r['resumo']['erro'] == 0
@pytest.mark.django_db(transaction=False)
def test_lote_ordem_dia_de_outra_sessao(client, sessao):
outra = baker.make(OrdemDia)
ordem = baker.make(OrdemDia, sessao_plenaria=sessao)
r = post(client, sessao, {'ordens_dia': [
{'id': outra.pk, 'resultado': 'x'},
{'id': ordem.pk, 'sessao_plenaria': outra.sessao_plenaria_id},
]}).json()
assert [i['status'] for i in r['ordens_dia']] == ['erro', 'erro']
outra.refresh_from_db()
ordem.refresh_from_db()
assert outra.resultado != 'x'
assert ordem.sessao_plenaria == sessao
@pytest.mark.django_db(transaction=False)
def test_lote_respeita_permissoes_por_bloco(sessao, ordem, resultado,
parlamentares):
user = get_user_model().objects.create_user(
username='operador', password='operador')
user.user_permissions.add(Permission.objects.get(
codename='add_sessaoplenariapresenca'))
c = APIClient()
c.force_authenticate(user)
r = post(c, sessao, {
'presencas_sessao': [{'parlamentar': parlamentares[0].pk}],
'presencas_ordem_dia': [{'parlamentar': parlamentares[0].pk}],
'votacoes': [votacao_nominal(
ordem, resultado, parlamentares, ['Sim', 'Sim', 'Sim'])],
}).json()
assert r['presencas_sessao'][0]['status'] == 'criado'
assert r['presencas_ordem_dia'][0]['status'] == 'erro'
assert 'sessao.add_presencaordemdia' in r['presencas_ordem_dia'][0][
'erros']
assert r['votacoes'][0]['status'] == 'erro'
assert not PresencaOrdemDia.objects.exists()
assert not RegistroVotacao.objects.exists()

127
sapl/sessao/tests/test_sessao_painel_api.py

@ -0,0 +1,127 @@
from datetime import timedelta
import pytest
from django.db import connection
from django.test.utils import CaptureQueriesContext
from django.utils import timezone
from model_bakery import baker
from rest_framework.test import APIClient
from sapl.base.models import Autor
from sapl.materia.models import Autoria
from sapl.parlamentares.models import Mandato, Parlamentar
from sapl.sessao.models import (ExpedienteMateria, IntegranteMesa, OrdemDia,
OradorExpediente, PresencaOrdemDia,
RegistroLeitura, RegistroVotacao,
SessaoPlenaria, SessaoPlenariaPresenca,
VotoParlamentar)
def url(sessao):
return '/api/sessao/sessaoplenaria/{}/painel/'.format(sessao.pk)
@pytest.fixture
def sessao():
return baker.make(SessaoPlenaria, data_inicio=timezone.localdate(),
iniciada=True)
@pytest.fixture
def sessao_completa(sessao):
"""Casa com 21 parlamentares e 20 matérias votadas nominalmente."""
parlamentares = baker.make(Parlamentar, ativo=True, _quantity=21)
for p in parlamentares:
baker.make(Mandato, parlamentar=p, legislatura=sessao.legislatura)
baker.make(SessaoPlenariaPresenca, sessao_plenaria=sessao,
parlamentar=p)
baker.make(PresencaOrdemDia, sessao_plenaria=sessao, parlamentar=p)
baker.make(IntegranteMesa, sessao_plenaria=sessao,
parlamentar=parlamentares[0])
for _ in range(20):
ordem = baker.make(OrdemDia, sessao_plenaria=sessao)
baker.make(Autoria, materia=ordem.materia, autor=baker.make(Autor))
registro = baker.make(RegistroVotacao, ordem=ordem,
materia=ordem.materia)
for p in parlamentares:
baker.make(VotoParlamentar, ordem=ordem, votacao=registro,
parlamentar=p, voto='Sim')
for _ in range(5):
expediente = baker.make(ExpedienteMateria, sessao_plenaria=sessao)
baker.make(RegistroLeitura, expediente=expediente,
materia=expediente.materia)
baker.make(OradorExpediente, sessao_plenaria=sessao,
parlamentar=parlamentares[1])
return sessao
@pytest.mark.django_db(transaction=False)
def test_painel_publico_traz_toda_a_sessao(sessao_completa):
response = APIClient().get(url(sessao_completa))
assert response.status_code == 200
r = response.json()
assert r['sessao_plenaria']['id'] == sessao_completa.pk
assert r['tipo_sessao']['id'] == sessao_completa.tipo_id
assert len(r['parlamentares']) == 21
assert len(r['mandatos']) == 21
assert len(r['presencas_sessao']) == 21
assert len(r['presencas_ordem_dia']) == 21
assert len(r['integrantes_mesa']) == 1
assert len(r['ordens_dia']) == 20
assert len(r['expedientes_materia']) == 5
assert len(r['materias']) == 25
assert len(r['autorias']) == 20
assert len(r['autores']) == 20
assert len(r['registros_votacao']) == 20
assert len(r['votos']) == 420
assert len(r['registros_leitura']) == 5
assert len(r['oradores_expediente']) == 1
@pytest.mark.django_db(transaction=False)
def test_painel_mesmo_formato_da_listagem(sessao_completa):
c = APIClient()
painel = c.get(url(sessao_completa)).json()
lista = c.get('/api/sessao/ordemdia/', {
'sessao_plenaria': sessao_completa.pk, 'page_size': 100}).json()
assert sorted(painel['ordens_dia'], key=lambda o: o['id']) == \
sorted(lista['results'], key=lambda o: o['id'])
@pytest.mark.django_db(transaction=False)
def test_painel_nao_mistura_outras_sessoes(sessao_completa):
outra = baker.make(SessaoPlenaria, data_inicio=timezone.localdate())
baker.make(OrdemDia, sessao_plenaria=outra)
baker.make(SessaoPlenariaPresenca, sessao_plenaria=outra)
r = APIClient().get(url(outra)).json()
assert len(r['ordens_dia']) == 1
assert len(r['presencas_sessao']) == 1
assert r['votos'] == []
@pytest.mark.django_db(transaction=False)
def test_painel_numero_de_consultas_limitado(sessao_completa):
with CaptureQueriesContext(connection) as consultas:
assert APIClient().get(url(sessao_completa)).status_code == 200
# ~620 registros serializados: o número de consultas não pode crescer
# com o volume (sem N+1 em __str__, metadata e many-to-many).
assert len(consultas) < 100
@pytest.mark.django_db(transaction=False)
def test_painel_sessao_futura_oculta_ao_publico(admin_user):
futura = baker.make(SessaoPlenaria, iniciada=False, publicar_pauta=False,
data_inicio=timezone.localdate() + timedelta(days=3))
assert APIClient().get(url(futura)).status_code == 404
c = APIClient()
c.force_authenticate(admin_user)
assert c.get(url(futura)).status_code == 200
@pytest.mark.django_db(transaction=False)
def test_painel_sessao_inexistente():
response = APIClient().get('/api/sessao/sessaoplenaria/999999/painel/')
assert response.status_code == 404
Loading…
Cancel
Save