diff --git a/docs/api-sessao-painel.md b/docs/api-sessao-painel.md new file mode 100644 index 000000000..89eb37773 --- /dev/null +++ b/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///`. + +| 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. diff --git a/sapl/api/serializers.py b/sapl/api/serializers.py index f4a82db35..fb25058f2 100644 --- a/sapl/api/serializers.py +++ b/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( diff --git a/sapl/api/sessao_painel.py b/sapl/api/sessao_painel.py new file mode 100644 index 000000000..914d87497 --- /dev/null +++ b/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///. +""" +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]), + } diff --git a/sapl/api/views_sessao.py b/sapl/api/views_sessao.py index c3bf4653e..e37429168 100644 --- a/sapl/api/views_sessao.py +++ b/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: diff --git a/sapl/sessao/tests/test_sessao_painel_api.py b/sapl/sessao/tests/test_sessao_painel_api.py new file mode 100644 index 000000000..24ab23973 --- /dev/null +++ b/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