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