Browse Source

Adiciona endpoint com todos os dados da sessão plenária para painel

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
Samuel Zwinglio 2 weeks ago
parent
commit
a80cd3d3f2
  1. 78
      docs/api-sessao-painel.md
  2. 6
      sapl/api/serializers.py
  3. 193
      sapl/api/sessao_painel.py
  4. 25
      sapl/api/views_sessao.py
  5. 127
      sapl/sessao/tests/test_sessao_painel_api.py

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(

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]),
}

25
sapl/api/views_sessao.py

@ -2,6 +2,7 @@
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
@ -10,7 +11,8 @@ from drfautoapi.drfautoapi import ApiViewSetConstrutor, \
from sapl.api.serializers import ChoiceSerializer,\
SessaoPlenariaECidadaniaSerializer
from sapl.api.sessao_lote import SessaoLote
from sapl.sessao.models import SessaoPlenaria, ExpedienteSessao,\
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
@ -65,12 +67,31 @@ class _SessaoPlenariaViewSet:
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 e atualizações de matérias da sessão.
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:

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