Browse Source

Merge f37c5748fb into 4e58d8ae97

pull/3849/merge
Edward 21 hours ago
committed by GitHub
parent
commit
5f33d86c17
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 145
      CLAUDE.md
  2. 62
      sapl/materia/migrations/0088_fix_view_materiaemtramitacao.py
  3. 3
      sapl/materia/models.py
  4. 7
      sapl/painel/urls.py
  5. 15
      sapl/painel/views.py
  6. 4
      sapl/relatorios/forms.py
  7. 32
      sapl/relatorios/templates/pdf_sessao_plenaria_gerar.py
  8. 118
      sapl/relatorios/views.py
  9. 491
      sapl/sessao/views.py
  10. 120
      sapl/templates/painel/mensagem.html
  11. 128
      sapl/templates/painel/parlamentares.html
  12. 123
      sapl/templates/painel/votacao.html
  13. 58
      sapl/utils.py

145
CLAUDE.md

@ -0,0 +1,145 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
SAPL (Sistema de Apoio ao Processo Legislativo) is a Django-based legislative management system used by Brazilian municipal and state legislative houses. It manages bills, parliamentary sessions, committees, norms, protocols, and related legislative workflows.
## Commands
### Development
```bash
# Run dev server
python manage.py runserver
# Docker (dev, without bundled DB)
docker-compose -f docker/docker-compose-dev.yml up
# Docker (dev, with PostgreSQL container)
docker-compose -f docker/docker-compose-dev-db.yml up
```
### Database Setup (local PostgreSQL)
```bash
sudo -u postgres psql -c "CREATE ROLE sapl LOGIN ENCRYPTED PASSWORD 'sapl' NOSUPERUSER INHERIT CREATEDB NOCREATEROLE NOREPLICATION;"
sudo -u postgres psql -c "CREATE DATABASE sapl WITH OWNER=sapl ENCODING='UTF8' LC_COLLATE='pt_BR.UTF-8' LC_CTYPE='pt_BR.UTF-8' CONNECTION LIMIT=-1 TEMPLATE template0;"
python manage.py migrate
```
### Testing
```bash
# All tests (reuses DB by default for speed)
pytest
# Single test file or test function
pytest sapl/materia/tests/test_materia.py
pytest sapl/materia/tests/test_materia.py::test_function_name
# Force DB recreation
pytest --create-db
# With coverage
pytest --cov=sapl
```
Tests require `DJANGO_SETTINGS_MODULE=sapl.settings` (set in `pytest.ini`). All tests must be marked with `@pytest.mark.django_db`. The `conftest.py` root fixture provides an `app` fixture (WebTest `DjangoTestApp`).
### Linting / Formatting
```bash
flake8 .
isort .
autopep8 --in-place <file.py>
```
### Restore Database from Backup
```bash
./scripts/restore_db.sh -f /path/to/dump
./scripts/restore_db.sh -f /path/to/dump -p 5433 # Docker port
```
## Architecture
### Django Apps
Apps are under `sapl/` and follow domain boundaries:
| App | Domain |
|-----|--------|
| `base` | `CasaLegislativa` (legislative house config), `AppConfig`, `Autor` (authorship) |
| `parliamentary` | `Parlamentar`, `Legislatura`, `SessaoLegislativa`, `Coligacao` |
| `materia` | Bills (`MateriaLegislativa`), types, tracking, annexes |
| `norma` | Laws/norms (`NormaJuridica`) and hierarchies |
| `sessao` | Plenary sessions, agenda, attendance, voting |
| `comissoes` | Committees (`Comissao`) and meetings (`Reuniao`) |
| `protocoloadm` | Administrative protocols and document intake |
| `compilacao` | Structured/articulated texts (LexML-like tree structure) |
| `lexml` | LexML XML standard integration |
| `audiencia` | Public hearings |
| `painel` | Real-time session display panel |
| `relatorios` | PDF report generation |
| `api` | REST API entry point (auto-generated ViewSets) |
| `crud` | Generic CRUD base views |
| `rules` | Business rules and permission definitions |
### REST API
The API uses a custom `drfautoapi` package (`drfautoapi/drfautoapi.py`) that auto-generates DRF ViewSets, Serializers, and FilterSets from Django models. Authentication is Token + Session. Permissions use a custom `SaplModelPermissions` class that maps HTTP methods to Django model permissions.
OpenAPI 3.0 docs are generated by drf-spectacular.
### Caching
- **Default:** File-based (`/var/tmp/django_cache`)
- **Production:** Redis via django-redis; configured at startup by `configure_redis_cache()` in `sapl/settings.py`
- **Cache key prefix:** `cache:{POD_NAMESPACE}:` (namespace-isolated for multi-tenant k8s)
- **Rate limiter state** is shared via Redis keys
### Feature Flags
django-waffle is used for feature flags. Switches (global on/off) can be toggled via:
```bash
python manage.py waffle_switch <switch_name> on|off
```
### Key Environment Variables
| Variable | Purpose |
|----------|---------|
| `DATABASE_URL` | PostgreSQL connection string |
| `SECRET_KEY` | Django secret key |
| `DEBUG` | Debug mode |
| `REDIS_URL` | Redis host:port |
| `CACHE_BACKEND` | `file` or `redis` |
| `POD_NAMESPACE` | K8s namespace (used in cache key prefix) |
| `USE_SOLR` | Enable Haystack/Solr full-text search |
| `SOLR_URL` / `SOLR_COLLECTION` | Solr connection |
### Docker Build
The production build requires a MaxMind GeoLite2-ASN license key (for nginx ASN-based bot blocking):
```bash
docker build --secret id=maxmind_key,src=.env -f docker/Dockerfile -t sapl:local .
```
Optional build args: `WITH_NGINX`, `WITH_GRAPHVIZ`, `WITH_POPPLER`, `WITH_PSQL_CLIENT`.
### Key File Locations
| File | Purpose |
|------|---------|
| `sapl/settings.py` | All Django settings, including cache/rate-limit setup |
| `pytest.ini` | Test configuration (DJANGO_SETTINGS_MODULE, addopts) |
| `conftest.py` | Root pytest fixtures |
| `drfautoapi/drfautoapi.py` | Auto-API generation logic |
| `docker/startup_scripts/start.sh` | Container entrypoint (migrations, waffle, gunicorn) |
| `requirements/requirements.txt` | Production deps |
| `requirements/test-requirements.txt` | Test deps |
| `requirements/dev-requirements.txt` | Dev/lint deps |

62
sapl/materia/migrations/0088_fix_view_materiaemtramitacao.py

@ -0,0 +1,62 @@
from django.db import migrations, models
_OLD_VIEW = """
create or replace view materia_materiaemtramitacao as
select m.id as id,
m.id as materia_id,
t.id as tramitacao_id,
t.unidade_tramitacao_destino_id as unidade_tramitacao_atual_id
from materia_materialegislativa m
inner join materia_tramitacao t on (m.id = t.materia_id)
where t.id = (select max(id) from materia_tramitacao where materia_id = m.id)
order by m.id DESC
"""
_NEW_VIEW = """
create or replace view materia_materiaemtramitacao as
select distinct on (m.id)
m.id as id,
m.id as materia_id,
t.id as tramitacao_id,
t.unidade_tramitacao_destino_id as unidade_tramitacao_atual_id
from materia_materialegislativa m
inner join materia_tramitacao t on t.materia_id = m.id
order by m.id desc, t.id desc
"""
class Migration(migrations.Migration):
# CREATE INDEX CONCURRENTLY cannot run inside a transaction.
atomic = False
dependencies = [
('materia', '0087_update_viewdb_materiaemtramitacao'),
]
operations = [
migrations.RunSQL(sql=_NEW_VIEW, reverse_sql=_OLD_VIEW),
migrations.SeparateDatabaseAndState(
database_operations=[
migrations.RunSQL(
sql="""
CREATE INDEX CONCURRENTLY IF NOT EXISTS
tram_materia_id_desc
ON materia_tramitacao (materia_id, id DESC)
""",
reverse_sql="""
DROP INDEX CONCURRENTLY IF EXISTS
tram_materia_id_desc
""",
),
],
state_operations=[
migrations.AddIndex(
model_name='tramitacao',
index=models.Index(
fields=['materia', '-id'],
name='tram_materia_id_desc',
),
),
],
),
]

3
sapl/materia/models.py

@ -1350,6 +1350,9 @@ class Tramitacao(models.Model):
verbose_name = _('Tramitação')
verbose_name_plural = _('Tramitações')
ordering = ('-data_tramitacao', '-id')
indexes = [
models.Index(fields=['materia', '-id'], name='tram_materia_id_desc'),
]
def __str__(self):
return _('%(materia)s | %(status)s | %(data)s') % {

7
sapl/painel/urls.py

@ -1,8 +1,7 @@
from django.conf.urls import url
from .apps import AppConfig
from .views import (cronometro_painel, get_dados_painel, painel_mensagem_view,
painel_parlamentar_view, painel_view, painel_votacao_view,
from .views import (cronometro_painel, get_dados_painel, painel_view,
switch_painel, verifica_painel, votante_view)
app_name = AppConfig.name
@ -11,12 +10,8 @@ urlpatterns = [
url(r'^painel-principal/(?P<pk>\d+)$', painel_view,
name="painel_principal"),
url(r'^painel/(?P<pk>\d+)/dados$', get_dados_painel, name='dados_painel'),
url(r'^painel/mensagem$', painel_mensagem_view, name="painel_mensagem"),
url(r'^painel/parlamentar$', painel_parlamentar_view,
name='painel_parlamentar'),
url(r'^painel/switch-painel$', switch_painel,
name="switch_painel"),
url(r'^painel/votacao$', painel_votacao_view, name='painel_votacao'),
url(r'^painel/verifica-painel$', verifica_painel,
name="verifica_painel"),
url(r'^painel/cronometro$', cronometro_painel, name='cronometro_painel'),

15
sapl/painel/views.py

@ -327,21 +327,6 @@ def verifica_painel(request):
return resposta
@user_passes_test(check_permission)
def painel_mensagem_view(request):
return render(request, 'painel/mensagem.html')
@user_passes_test(check_permission)
def painel_parlamentar_view(request):
return render(request, 'painel/parlamentares.html')
@user_passes_test(check_permission)
def painel_votacao_view(request):
return render(request, 'painel/votacao.html')
@user_passes_test(check_permission)
def cronometro_painel(request):
request.session[request.GET['tipo']] = request.GET['action']

4
sapl/relatorios/forms.py

@ -543,9 +543,7 @@ class RelatorioMateriasTramitacaoFilterSet(django_filters.FilterSet):
@property
def qs(self):
parent = super(RelatorioMateriasTramitacaoFilterSet, self).qs
return parent.distinct().order_by(
'-materia__ano', 'materia__tipo', '-materia__numero'
)
return parent.order_by('-materia__ano', 'materia__tipo', '-materia__numero')
class Meta:
model = MateriaEmTramitacao

32
sapl/relatorios/templates/pdf_sessao_plenaria_gerar.py

@ -198,6 +198,32 @@ def presenca(lst_presenca_sessao, lst_ausencia_sessao):
return tmp
def correspondencias(lst_correspondencias):
tmp = ''
if lst_correspondencias:
tmp += '\t\t<para style="P1">Correspondências</para>\n'
tmp += '\t\t<para style="P2">\n'
tmp += '\t\t\t<font color="white"> <br/></font>\n'
tmp += '\t\t</para>\n'
tmp += '<blockTable style="repeater" repeatRows="1" colWidths="3cm,4cm,3.5cm,6.5cm">>\n'
tmp += '<tr><td>Tipo</td><td>Documento</td><td>Interessado</td><td>Assunto</td></tr>\n'
for c in lst_correspondencias:
tmp += '<tr>'
tmp += '<td><para style="P4">' + str(c['tipo']) + '</para></td>\n'
tmp += '<td><para style="P4">' + str(c['epigrafe']) + \
' - ' + str(c['data']) + '</para></td>\n'
tmp += '<td><para style="P4">' + \
str(c['interessado'] or '') + '</para></td>\n'
tmp += '<td><para style="P4">' + \
str(c['assunto'] or '') + '</para></td>\n'
tmp += '</tr>\n'
tmp += '</blockTable>\n'
tmp += '\t\t<para style="P2">\n'
tmp += '\t\t\t<font color="white"> <br/></font>\n'
tmp += '\t\t</para>\n'
return tmp
def expedientes(lst_expedientes):
tmp = ''
if lst_expedientes:
@ -415,7 +441,7 @@ def consideracoes(lst_consideracoes):
return tmp
def principal(rodape_dic, imagem, inf_basicas_dic, cont_mult_dic, lst_mesa, lst_presenca_sessao, lst_ausencia_sessao, lst_expedientes, lst_expediente_materia, lst_expediente_materia_vot_nom, lst_oradores_expediente, lst_presenca_ordem_dia, lst_votacao, lst_votacao_vot_nom, lst_oradores_ordemdia, lst_oradores, lst_ocorrencias, lst_consideracoes):
def principal(rodape_dic, imagem, inf_basicas_dic, cont_mult_dic, lst_mesa, lst_presenca_sessao, lst_ausencia_sessao, lst_correspondencias, lst_expedientes, lst_expediente_materia, lst_expediente_materia_vot_nom, lst_oradores_expediente, lst_presenca_ordem_dia, lst_votacao, lst_votacao_vot_nom, lst_oradores_ordemdia, lst_oradores, lst_ocorrencias, lst_consideracoes):
"""
"""
arquivoPdf = str(int(time.time() * 100)) + ".pdf"
@ -440,6 +466,7 @@ def principal(rodape_dic, imagem, inf_basicas_dic, cont_mult_dic, lst_mesa, lst_
ordenacao = ResumoOrdenacao.objects.first()
dict_ord_template = {
'cont_mult': multimidia(cont_mult_dic),
'correspondencia': correspondencias(lst_correspondencias),
'exp': expedientes(lst_expedientes),
'id_basica': inf_basicas(inf_basicas_dic),
'lista_p': presenca(lst_presenca_sessao, lst_ausencia_sessao),
@ -473,6 +500,7 @@ def principal(rodape_dic, imagem, inf_basicas_dic, cont_mult_dic, lst_mesa, lst_
tmp += dict_ord_template[ordenacao.decimo_terceiro]
tmp += dict_ord_template[ordenacao.decimo_quarto]
tmp += dict_ord_template[ordenacao.decimo_quinto]
tmp += dict_ord_template[ordenacao.decimo_sexto]
except KeyError as e:
logger.error("KeyError: " + str(e) + ". Erro ao tentar utilizar "
"configuração de ordenação. Utilizando ordenação padrão.")
@ -480,6 +508,7 @@ def principal(rodape_dic, imagem, inf_basicas_dic, cont_mult_dic, lst_mesa, lst_
tmp += multimidia(cont_mult_dic)
tmp += mesa(lst_mesa)
tmp += presenca(lst_presenca_sessao, lst_ausencia_sessao)
tmp += correspondencias(lst_correspondencias)
tmp += expedientes(lst_expedientes)
tmp += expediente_materia(lst_expediente_materia)
tmp += expediente_materia_vot_nom(lst_expediente_materia_vot_nom)
@ -497,6 +526,7 @@ def principal(rodape_dic, imagem, inf_basicas_dic, cont_mult_dic, lst_mesa, lst_
tmp += multimidia(cont_mult_dic)
tmp += mesa(lst_mesa)
tmp += presenca(lst_presenca_sessao, lst_ausencia_sessao)
tmp += correspondencias(lst_correspondencias)
tmp += expedientes(lst_expedientes)
tmp += expediente_materia(lst_expediente_materia)
tmp += expediente_materia_vot_nom(lst_expediente_materia_vot_nom)

118
sapl/relatorios/views.py

@ -8,6 +8,7 @@ import unidecode
from django.core.exceptions import ObjectDoesNotExist
from django.db.models import Count, Q, F
from django.http import Http404, HttpResponse
from django.shortcuts import get_object_or_404
from django.template.loader import render_to_string
from django.utils import timezone
from django.utils.translation import ugettext_lazy as _
@ -23,7 +24,7 @@ from sapl.materia.models import (Autoria, MateriaLegislativa, Numeracao,
TipoMateriaLegislativa, MateriaEmTramitacao, DocumentoAcessorio, TipoDocumento,
StatusTramitacao)
from sapl.norma.models import TipoNormaJuridica, NormaJuridica
from sapl.parlamentares.models import Filiacao, Parlamentar, SessaoLegislativa, Legislatura
from sapl.parlamentares.models import Parlamentar, SessaoLegislativa, Legislatura
from sapl.protocoloadm.models import (DocumentoAdministrativo, Protocolo,
TramitacaoAdministrativo, StatusTramitacaoAdministrativo,
TipoDocumentoAdministrativo)
@ -39,7 +40,8 @@ from sapl.sessao.models import (ExpedienteMateria, ExpedienteSessao,
OrdemDia, PresencaOrdemDia, SessaoPlenaria,
SessaoPlenariaPresenca, OcorrenciaSessao,
RegistroVotacao, VotoParlamentar, OradorOrdemDia,
ConsideracoesFinais, ResumoOrdenacao, TipoSessaoPlenaria)
ConsideracoesFinais, ResumoOrdenacao, TipoSessaoPlenaria,
restringe_sessoes_visiveis)
from sapl.sessao.views import (get_identificacao_basica, get_mesa_diretora,
get_presenca_sessao, get_expedientes,
get_materias_expediente, get_oradores_expediente,
@ -47,7 +49,8 @@ from sapl.sessao.views import (get_identificacao_basica, get_mesa_diretora,
get_oradores_ordemdia,
get_oradores_explicacoes_pessoais, get_consideracoes_finais,
get_ocorrencias_da_sessao, get_assinaturas,
get_correspondencias)
get_correspondencias, get_conteudo_multimidia,
get_votos_nominais)
from sapl.settings import MEDIA_URL, RATE_LIMITER_RATE
from sapl.settings import STATIC_ROOT
from sapl.utils import LISTA_DE_UFS, TrocaTag, filiacao_data, create_barcode, show_results_filter_set, \
@ -92,7 +95,8 @@ def get_imagem(casa):
def get_rodape(casa):
if len(casa.cep) == 8:
cep = casa.cep[:4] + "-" + casa.cep[5:]
# CEP tem 8 dígitos e se formata como XXXXX-XXX.
cep = casa.cep[:5] + "-" + casa.cep[5:]
else:
cep = ""
@ -542,6 +546,8 @@ def get_sessao_plenaria(sessao, casa, user):
if sessao.tipo.nome == 'Solene':
inf_basicas_dic["tema_solene"] = sessao.tema_solene
data_sessao = sessao.data_fim if sessao.data_fim else sessao.data_inicio
# Conteudo multimidia
cont_mult_dic = {
"multimidia_audio": str(sessao.url_audio) if sessao.url_audio else "Indisponível",
@ -553,9 +559,11 @@ def get_sessao_plenaria(sessao, casa, user):
for composicao in IntegranteMesa.objects.select_related('parlamentar', 'cargo') \
.filter(sessao_plenaria=sessao) \
.order_by('cargo_id'):
partido_sigla = Filiacao.objects.filter(
parlamentar=composicao.parlamentar).first()
sigla = '' if not partido_sigla else partido_sigla.partido.sigla
# Antes usava Filiacao...first() (filiação mais recente), enquanto a
# tela usava .last() (a mais antiga) — e as listas de presença deste
# mesmo PDF já usavam filiacao_data. Unifica no critério correto: o
# partido vigente na data da sessão.
sigla = filiacao_data(composicao.parlamentar, data_sessao)
lst_mesa.append({
'nom_parlamentar': composicao.parlamentar.nome_parlamentar,
'sgl_partido': sigla,
@ -652,7 +660,7 @@ def get_sessao_plenaria(sessao, casa, user):
"num_ordem": expediente_materia.numero_ordem,
"id_materia": f"{materia.tipo.sigla} {materia.tipo.descricao} {str(materia.numero)}/{str(materia.ano)}",
"des_numeracao": ' ',
"des_turno": get_turno(materia)[0],
"des_turno": get_turno(materia, data_sessao)[0],
"situacao": materia_em_tramitacao.tramitacao.status if materia_em_tramitacao else _("Não informada"),
"txt_ementa": str(materia.ementa),
"materia_observacao": materia.observacao,
@ -732,13 +740,11 @@ def get_sessao_plenaria(sessao, casa, user):
for orador_expediente in OradorExpediente.objects.filter(sessao_plenaria=sessao).order_by('numero_ordem'):
parlamentar = Parlamentar.objects.get(
id=orador_expediente.parlamentar.id)
partido_sigla = Filiacao.objects.filter(
parlamentar=parlamentar).first()
lst_oradores_expediente.append({
"num_ordem": orador_expediente.numero_ordem,
"nom_parlamentar": parlamentar.nome_parlamentar,
"observacao": orador_expediente.observacao,
"sgl_partido": "" if not partido_sigla else partido_sigla.partido.sigla
"sgl_partido": filiacao_data(parlamentar, data_sessao)
})
# Lista presença na ordem do dia
@ -774,7 +780,7 @@ def get_sessao_plenaria(sessao, casa, user):
materia_em_tramitacao = materia.materiaemtramitacao_set.first()
dic_votacao.update({
"des_turno": get_turno(materia)[0],
"des_turno": get_turno(materia, data_sessao)[0],
# https://github.com/interlegis/sapl/issues/1009
"txt_ementa": html.unescape(materia.ementa),
"materia_observacao": materia.observacao,
@ -849,27 +855,23 @@ def get_sessao_plenaria(sessao, casa, user):
for orador_ordemdia in oradores_ordem_dia:
parlamentar_orador = Parlamentar.objects.get(
id=orador_ordemdia.parlamentar.id)
sigla_partido = Filiacao.objects.filter(
parlamentar=parlamentar_orador).first()
lst_oradores_ordemdia.append({
"num_ordem": orador_ordemdia.numero_ordem,
"nome_parlamentar": parlamentar_orador.nome_parlamentar,
"observacao": orador_ordemdia.observacao,
"sigla": "" if not sigla_partido else sigla_partido.partido.sigla
"sigla": filiacao_data(parlamentar_orador, data_sessao)
})
# Lista dos oradores nas Explicações Pessoais
lst_oradores = []
for orador in Orador.objects.select_related('parlamentar').filter(sessao_plenaria=sessao).order_by('numero_ordem'):
parlamentar = orador.parlamentar
partido_sigla = orador.parlamentar.filiacao_set.select_related(
'partido', 'parlamentar').first()
lst_oradores.append({
"num_ordem": orador.numero_ordem,
"nom_parlamentar": parlamentar.nome_parlamentar,
"observacao": orador.observacao,
"sgl_partido": "" if not partido_sigla else partido_sigla.partido.sigla
"sgl_partido": filiacao_data(parlamentar, data_sessao)
})
# Ocorrências da Sessão
@ -930,11 +932,20 @@ def get_sessao_plenaria(sessao, casa, user):
lst_consideracoes)
def get_turno(materia):
def get_turno(materia, data_sessao=None):
"""Turno e situação de uma matéria.
`data_sessao` corta as tramitações posteriores à sessão, como fazem
`get_materias_expediente` / `get_materias_ordem_do_dia` em
sapl.sessao.views. Sem esse corte, o PDF do resumo exibia um turno
registrado depois da sessão que ele documenta.
"""
descricao_turno = ''
descricao_tramitacao = ''
tramitacoes = materia.tramitacao_set.order_by(
'-data_tramitacao', '-id').all()
if data_sessao:
tramitacoes = tramitacoes.filter(data_tramitacao__lte=data_sessao)
tramitacoes_turno = tramitacoes.exclude(turno="")
if tramitacoes:
@ -969,7 +980,8 @@ def relatorio_sessao_plenaria(request, pk):
try:
logger.debug("user=" + username +
". Tentando obter SessaoPlenaria com id={}.".format(pk))
sessao = SessaoPlenaria.objects.get(id=pk)
sessao = restringe_sessoes_visiveis(
SessaoPlenaria.objects.all(), request.user).get(id=pk)
except ObjectDoesNotExist as e:
logger.error("user=" + username +
". Essa SessaoPlenaria não existe (pk={}). ".format(pk) + str(e))
@ -1143,8 +1155,29 @@ def relatorio_etiqueta_protocolo(request, nro, ano):
def get_etiqueta_protocolos(prots):
prot_list = list(prots)
if not prot_list:
return []
# Pre-fetch MateriaLegislativa for all protocols in one query.
materia_query = Q()
for p in prot_list:
materia_query |= Q(numero_protocolo=p.numero, ano=p.ano)
materias_map = {
(m.numero_protocolo, m.ano): m
for m in MateriaLegislativa.objects.filter(
materia_query).select_related('tipo')
}
# Pre-fetch DocumentoAdministrativo for all protocols in one query.
documentos_map = {
doc.protocolo_id: doc
for doc in DocumentoAdministrativo.objects.filter(
protocolo__in=prot_list).select_related('tipo')
}
protocolos = []
for p in prots:
for p in prot_list:
dic = {}
dic['titulo'] = str(p.numero) + '/' + str(p.ano)
@ -1161,11 +1194,11 @@ def get_etiqueta_protocolos(prots):
dic['nom_autor'] = str(p.autor or ' ')
dic['num_materia'] = ''
for materia in MateriaLegislativa.objects.filter(
numero_protocolo=p.numero, ano=p.ano):
dic['num_materia'] = materia.tipo.sigla + ' ' + \
str(materia.numero) + '/' + str(materia.ano)
materia = materias_map.get((p.numero, p.ano))
dic['num_materia'] = (
materia.tipo.sigla + ' ' + str(materia.numero) + '/' + str(materia.ano)
if materia else ''
)
dic['natureza'] = ''
if p.tipo_processo == 0:
@ -1173,11 +1206,11 @@ def get_etiqueta_protocolos(prots):
if p.tipo_processo == 1:
dic['natureza'] = 'Legislativo'
dic['num_documento'] = ''
for documento in DocumentoAdministrativo.objects.filter(
protocolo=p):
dic['num_documento'] = documento.tipo.sigla + ' ' + \
str(documento.numero) + '/' + str(documento.ano)
documento = documentos_map.get(p.pk)
dic['num_documento'] = (
documento.tipo.sigla + ' ' + str(documento.numero) + '/' + str(documento.ano)
if documento else ''
)
dic['ident_processo'] = dic['num_materia'] or dic['num_documento']
@ -1382,9 +1415,15 @@ def make_pdf(base_url, main_template, header_template, main_css='', header_css='
def resumo_ata_pdf(request, pk):
base_url = request.build_absolute_uri()
casa = CasaLegislativa.objects.first()
if not casa:
raise Http404
rodape = ' '.join(get_rodape(casa))
sessao_plenaria = SessaoPlenaria.objects.get(pk=pk)
# A mesma restrição de visibilidade aplicada por ResumoAtaView: sem ela,
# uma sessão oculta na consulta pública era legível por aqui.
sessao_plenaria = get_object_or_404(
restringe_sessoes_visiveis(SessaoPlenaria.objects.all(), request.user),
pk=pk)
dict_ord_template = {
'cont_mult': 'conteudo_multimidia.html',
@ -1408,6 +1447,9 @@ def resumo_ata_pdf(request, pk):
context = {}
context.update(get_identificacao_basica(sessao_plenaria))
# Sem cont_mult / votos nominais, esses blocos saíam vazios no PDF quando
# a ResumoOrdenacao os incluía, divergindo da ata exibida em tela.
context.update(get_conteudo_multimidia(sessao_plenaria))
context.update(get_mesa_diretora(sessao_plenaria))
context.update(get_presenca_sessao(sessao_plenaria))
context.update(get_correspondencias(sessao_plenaria, request.user))
@ -1420,7 +1462,14 @@ def resumo_ata_pdf(request, pk):
context.update(get_ocorrencias_da_sessao(sessao_plenaria))
context.update(get_consideracoes_finais(sessao_plenaria))
context.update(get_oradores_explicacoes_pessoais(sessao_plenaria))
context.update(get_assinaturas(sessao_plenaria))
context.update(get_assinaturas(
sessao_plenaria,
mesa=context['mesa'],
presenca_ordem=context['presenca_ordem']))
context.update({'votos_nominais_materia_expediente': get_votos_nominais(
sessao_plenaria.id, ExpedienteMateria, 'expediente')})
context.update({'votos_nominais_materia_ordem_dia': get_votos_nominais(
sessao_plenaria.id, OrdemDia, 'ordem')})
context.update({'object': sessao_plenaria})
context.update({'data': dt.today().strftime('%d/%m/%Y')})
context.update({'rodape': rodape})
@ -1594,7 +1643,8 @@ def relatorio_sessao_plenaria_pdf(request, pk):
try:
logger.debug("user=" + username +
". Tentando obter SessaoPlenaria com id={}.".format(pk))
sessao = SessaoPlenaria.objects.get(id=pk)
sessao = restringe_sessoes_visiveis(
SessaoPlenaria.objects.all(), request.user).get(id=pk)
except ObjectDoesNotExist as e:
logger.error("user=" + username +
". Essa SessaoPlenaria não existe (pk={}). ".format(pk) + str(e))

491
sapl/sessao/views.py

@ -1,4 +1,4 @@
from collections import OrderedDict
from collections import OrderedDict, defaultdict
from datetime import datetime
import json
import logging
@ -9,7 +9,7 @@ from django.contrib import messages
from django.contrib.auth.decorators import permission_required
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.core.exceptions import ObjectDoesNotExist
from django.db.models import Max, Q
from django.db.models import Max, Prefetch, Q
from django.http import JsonResponse
from django.http.response import Http404, HttpResponseRedirect
from django.urls import reverse
@ -37,7 +37,7 @@ from sapl.crud.base import (RP_DETAIL, RP_LIST, Crud, CrudAux,
PermissionRequiredForAppCrudMixin, make_pagination)
from sapl.materia.forms import filtra_tramitacao_status
from sapl.materia.models import (Autoria, TipoMateriaLegislativa,
Tramitacao, MateriaEmTramitacao, Numeracao)
Tramitacao, MateriaEmTramitacao)
from sapl.materia.views import MateriaLegislativaPesquisaView
from sapl.parlamentares.models import (Filiacao, Legislatura, Mandato,
Parlamentar, SessaoLegislativa)
@ -49,7 +49,8 @@ from sapl.sessao.forms import ExpedienteMateriaForm, OrdemDiaForm, OrdemExpedien
from sapl.sessao.models import Correspondencia
from sapl.settings import TIME_ZONE, RATE_LIMITER_RATE
from sapl.utils import show_results_filter_set, remover_acentos, get_client_ip, \
MultiFormatOutputMixin, PautaMultiFormatOutputMixin, ratelimit_ip
MultiFormatOutputMixin, PautaMultiFormatOutputMixin, ratelimit_ip, \
filiacao_data
from .forms import (AdicionarVariasMateriasFilterSet, AdicionarVariasMateriasForm, BancadaForm,
ExpedienteForm, JustificativaAusenciaForm, OcorrenciaSessaoForm, ListMateriaForm,
@ -224,38 +225,43 @@ def abrir_votacao(request, pk, spk):
def customize_link_materia(context, pk, has_permission, is_expediente):
# sessao_plenaria is the same for every row — resolve once
object_list = context['object_list']
if object_list:
sessao_plenaria = object_list[0].sessao_plenaria
else:
sessao_plenaria = SessaoPlenaria.objects.get(id=pk)
data_sessao = sessao_plenaria.data_fim or sessao_plenaria.data_inicio
for i, row in enumerate(context['rows']):
materia = context['object_list'][i].materia
obj = context['object_list'][i]
obj = object_list[i]
materia = obj.materia # already select_related
url_materia = reverse(
'sapl.materia:materialegislativa_detail', kwargs={'pk': materia.id})
numeracao = materia.numeracao_set.first() if materia.numeracao_set.first() else "-"
todos_autoria = materia.autoria_set.all()
autoria = todos_autoria.filter(primeiro_autor=True)
numeracao = materia._numeracao_prefetch[0] if materia._numeracao_prefetch else "-"
todos_autoria = materia._autoria_prefetch
autoria = [a for a in todos_autoria if a.primeiro_autor]
autor = ', '.join([str(a.autor) for a in autoria]) if autoria else "-"
todos_autores = ', '.join([str(a.autor) for a in todos_autoria]) if autoria else "-"
todos_autores = ', '.join([str(a.autor)
for a in todos_autoria]) if autoria else "-"
num_protocolo = materia.numero_protocolo or "-"
num_protocolo = materia.numero_protocolo if materia.numero_protocolo else "-"
sessao_plenaria = SessaoPlenaria.objects.get(id=pk)
data_sessao = sessao_plenaria.data_fim if sessao_plenaria.data_fim else sessao_plenaria.data_inicio
tramitacao = Tramitacao.objects \
.select_related('materia', 'status', 'materia__tipo') \
.filter(materia=materia, turno__isnull=False, data_tramitacao__lte=data_sessao) \
.exclude(turno__exact='') \
.order_by('-data_tramitacao', '-id') \
.first()
tramitacao = next(
(t for t in materia._tramitacao_prefetch if t.data_tramitacao <= data_sessao),
None,
)
turno = '-'
if tramitacao:
for t in Tramitacao.TURNO_CHOICES:
if t[0] == tramitacao.turno:
turno = t[1]
break
materia_em_tramitacao = MateriaEmTramitacao.objects \
.select_related("materia", "tramitacao") \
.filter(materia=materia) \
.first()
materia_em_tramitacao = materia._met_prefetch[0] if materia._met_prefetch else None
# idUnica para cada materia
idAutor = "autor" + str(i)
idAutores = "autores" + str(i)
@ -281,12 +287,9 @@ def customize_link_materia(context, pk, has_permission, is_expediente):
# url em toda a string de title_materia
context['rows'][i][1] = (title_materia, None)
exist_resultado = obj.registrovotacao_set.filter(
materia=obj.materia).exists()
exist_retirada = obj.retiradapauta_set.filter(
materia=obj.materia).exists()
exist_leitura = obj.registroleitura_set.filter(
materia=obj.materia).exists()
exist_resultado = bool(obj._votacao_prefetch)
exist_retirada = bool(obj._retirada_prefetch)
exist_leitura = bool(obj._leitura_prefetch)
if (obj.tipo_votacao != LEITURA and not exist_resultado and not exist_retirada) or \
(obj.tipo_votacao == LEITURA and not exist_leitura):
@ -408,8 +411,7 @@ def customize_link_materia(context, pk, has_permission, is_expediente):
resultado = '''Não há resultado'''
elif exist_retirada:
retirada = obj.retiradapauta_set.filter(
materia_id=obj.materia_id).last()
retirada = obj._retirada_prefetch[-1]
retirada_descricao = retirada.tipo_de_retirada.descricao
retirada_observacao = retirada.observacao
url = reverse('sapl.sessao:retiradapauta_detail',
@ -421,13 +423,11 @@ def customize_link_materia(context, pk, has_permission, is_expediente):
else:
if obj.tipo_votacao == LEITURA:
resultado = obj.registroleitura_set.filter(
materia_id=obj.materia_id).last()
resultado = obj._leitura_prefetch[-1]
resultado_descricao = "Matéria lida"
resultado_observacao = resultado.observacao
else:
resultado = obj.registrovotacao_set.filter(
materia_id=obj.materia_id).last()
resultado = obj._votacao_prefetch[-1]
resultado_descricao = resultado.tipo_resultado_votacao.nome
resultado_observacao = resultado.observacao
@ -833,20 +833,58 @@ class MateriaOrdemDiaCrud(MasterDetailCrud):
layout_key = 'OrdemDiaDetail'
class ListView(MasterDetailCrud.ListView):
paginate_by = None
paginate_by = 100
ordering = ['numero_ordem', 'materia', 'resultado']
def get_context_data(self, **kwargs):
if self.get_queryset().count() > 500:
self.paginate_by = 50
else:
self.paginate_by = None
context = super().get_context_data(**kwargs)
has_permition = self.request.user.has_module_perms(AppConfig.label)
return customize_link_materia(context, self.kwargs['pk'], has_permition, False)
def get_queryset(self):
return super().get_queryset().select_related(
'materia', 'materia__tipo', 'sessao_plenaria',
).prefetch_related(
Prefetch(
'materia__materiaemtramitacao_set',
to_attr='_met_prefetch',
),
Prefetch(
'materia__numeracao_set',
to_attr='_numeracao_prefetch',
),
Prefetch(
'materia__autoria_set',
queryset=Autoria.objects.select_related('autor'),
to_attr='_autoria_prefetch',
),
Prefetch(
'materia__tramitacao_set',
queryset=Tramitacao.objects.filter(
turno__isnull=False,
).exclude(turno='').order_by('-data_tramitacao', '-id'),
to_attr='_tramitacao_prefetch',
),
Prefetch(
'registrovotacao_set',
queryset=RegistroVotacao.objects.select_related(
'tipo_resultado_votacao',
),
to_attr='_votacao_prefetch',
),
Prefetch(
'retiradapauta_set',
queryset=RetiradaPauta.objects.select_related(
'tipo_de_retirada',
),
to_attr='_retirada_prefetch',
),
Prefetch(
'registroleitura_set',
to_attr='_leitura_prefetch',
),
)
def recuperar_materia(request):
tipo = TipoMateriaLegislativa.objects.get(pk=request.GET['tipo_materia'])
@ -905,24 +943,60 @@ class ExpedienteMateriaCrud(MasterDetailCrud):
'resultado']
class ListView(MasterDetailCrud.ListView):
paginate_by = None
paginate_by = 100
ordering = ['numero_ordem', 'materia', 'resultado']
def get_context_data(self, **kwargs):
if self.get_queryset().count() > 500:
self.paginate_by = 50
else:
self.paginate_by = None
context = super().get_context_data(**kwargs)
if self.request.GET.get('page'):
context['page'] = self.request.GET.get('page')
has_permition = self.request.user.has_module_perms(AppConfig.label)
return customize_link_materia(context, self.kwargs['pk'], has_permition, True)
def get_queryset(self):
return super().get_queryset().select_related(
'materia', 'materia__tipo', 'sessao_plenaria',
).prefetch_related(
Prefetch(
'materia__materiaemtramitacao_set',
to_attr='_met_prefetch',
),
Prefetch(
'materia__numeracao_set',
to_attr='_numeracao_prefetch',
),
Prefetch(
'materia__autoria_set',
queryset=Autoria.objects.select_related('autor'),
to_attr='_autoria_prefetch',
),
Prefetch(
'materia__tramitacao_set',
queryset=Tramitacao.objects.filter(
turno__isnull=False,
).exclude(turno='').order_by('-data_tramitacao', '-id'),
to_attr='_tramitacao_prefetch',
),
Prefetch(
'registrovotacao_set',
queryset=RegistroVotacao.objects.select_related(
'tipo_resultado_votacao',
),
to_attr='_votacao_prefetch',
),
Prefetch(
'retiradapauta_set',
queryset=RetiradaPauta.objects.select_related(
'tipo_de_retirada',
),
to_attr='_retirada_prefetch',
),
Prefetch(
'registroleitura_set',
to_attr='_leitura_prefetch',
),
)
class CreateView(MasterDetailCrud.CreateView):
form_class = ExpedienteMateriaForm
@ -1982,9 +2056,24 @@ def get_conteudo_multimidia(sessao_plenaria):
return context
def prefetch_filiacao():
"""Prefetch de `filiacao_set` para os Parlamentares exibidos no resumo.
Permite que `filiacao_data` (usada pelo filtro `filiacao_data_filter` nos
blocos do resumo/ata) resolva a sigla do partido em memória, sem uma query
por parlamentar. A ordenação replica `Filiacao.Meta.ordering`.
"""
return Prefetch(
'parlamentar__filiacao_set',
queryset=Filiacao.objects.select_related('partido').order_by(
'-data', '-data_desfiliacao'))
def get_mesa_diretora(sessao_plenaria):
mesa = IntegranteMesa.objects.filter(
sessao_plenaria=sessao_plenaria).order_by('cargo_id')
sessao_plenaria=sessao_plenaria).select_related(
'parlamentar', 'cargo').prefetch_related(
prefetch_filiacao()).order_by('cargo_id')
integrantes = [{'parlamentar': m.parlamentar,
'cargo': m.cargo} for m in mesa]
return {'mesa': integrantes}
@ -1993,18 +2082,21 @@ def get_mesa_diretora(sessao_plenaria):
def get_presenca_sessao(sessao_plenaria):
parlamentares_sessao = [p.parlamentar for p in SessaoPlenariaPresenca.objects.filter(
sessao_plenaria_id=sessao_plenaria.id
).order_by('parlamentar__nome_parlamentar').distinct()]
).select_related('parlamentar').prefetch_related(
prefetch_filiacao()).order_by('parlamentar__nome_parlamentar').distinct()]
ausentes_sessao = JustificativaAusencia.objects.filter(
sessao_plenaria_id=sessao_plenaria.id
).distinct().order_by('parlamentar__nome_parlamentar')
).select_related('parlamentar', 'tipo_ausencia').distinct().order_by(
'parlamentar__nome_parlamentar')
return ({'presenca_sessao': parlamentares_sessao,
'justificativa_ausencia': ausentes_sessao})
def get_correspondencias(sessao_plenaria, user):
qs = sessao_plenaria.correspondencia_set.all()
qs = sessao_plenaria.correspondencia_set.select_related(
'documento', 'documento__tipo')
is_anon = user.is_anonymous
is_ostensivo = AppsAppConfig.attr(
@ -2036,30 +2128,92 @@ def get_correspondencias(sessao_plenaria, user):
def get_expedientes(sessao_plenaria):
expediente = ExpedienteSessao.objects.filter(
sessao_plenaria_id=sessao_plenaria.id).order_by('tipo__ordenacao', 'tipo__nome')
sessao_plenaria_id=sessao_plenaria.id).select_related(
'tipo').order_by('tipo__ordenacao', 'tipo__nome')
expedientes = []
for e in expediente:
tipo = TipoExpediente.objects.get(id=e.tipo_id)
tipo = e.tipo
conteudo = e.conteudo
ex = {'tipo': tipo, 'conteudo': conteudo}
expedientes.append(ex)
return ({'expedientes': expedientes})
def prefetch_materias_sessao(qs):
"""Prefetches compartilhados por `get_materias_expediente` e
`get_materias_ordem_do_dia`.
Os querysets dos `Prefetch` não recebem `order_by` explícito justamente
para herdarem o `Meta.ordering` de cada model é dele que dependem os
`.first()` / `.last()` que este código substitui por indexação de lista.
"""
return qs.select_related('materia', 'materia__tipo').prefetch_related(
Prefetch(
'materia__tramitacao_set',
queryset=Tramitacao.objects.exclude(turno='').order_by(
'-data_tramitacao', '-id'),
to_attr='_tramitacao_prefetch'),
Prefetch(
'materia__materiaemtramitacao_set',
queryset=MateriaEmTramitacao.objects.select_related(
'tramitacao', 'tramitacao__status'),
to_attr='_met_prefetch'),
Prefetch('materia__numeracao_set', to_attr='_numeracao_prefetch'),
Prefetch(
'materia__autoria_set',
queryset=Autoria.objects.select_related('autor'),
to_attr='_autoria_prefetch'),
Prefetch(
'registrovotacao_set',
queryset=RegistroVotacao.objects.select_related(
'tipo_resultado_votacao'),
to_attr='_votacao_prefetch'),
Prefetch(
'retiradapauta_set',
queryset=RetiradaPauta.objects.select_related('tipo_de_retirada'),
to_attr='_retirada_prefetch'),
Prefetch('registroleitura_set', to_attr='_leitura_prefetch'),
)
def agrupa_votos_por_materia(campo, ids):
"""Busca todos os VotoParlamentar de uma seção em uma única query.
`campo` é 'expediente' ou 'ordem' as FKs desnormalizadas de
VotoParlamentar. O agrupamento preserva a ordem da query, que herda o
`Meta.ordering = ('id',)` do model.
"""
votos = defaultdict(list)
if not ids:
return votos
qs = VotoParlamentar.objects.filter(
**{'%s_id__in' % campo: ids}).select_related('parlamentar')
for voto in qs:
votos[getattr(voto, '%s_id' % campo)].append(voto)
return votos
def get_materias_expediente(sessao_plenaria):
materias_expediente = []
for m in ExpedienteMateria.objects.select_related("materia").filter(sessao_plenaria_id=sessao_plenaria.id):
tramitacao = ''
data_sessao = sessao_plenaria.data_fim if sessao_plenaria.data_fim else sessao_plenaria.data_inicio
for aux_tramitacao in Tramitacao.objects.filter(materia=m.materia, data_tramitacao__lte=data_sessao).order_by(
'-data_tramitacao', '-id'):
if aux_tramitacao.turno:
tramitacao = aux_tramitacao
break
rv = m.registrovotacao_set.filter(materia=m.materia).first()
rp = m.retiradapauta_set.filter(materia=m.materia).first()
rl = m.registroleitura_set.filter(materia=m.materia).first()
data_sessao = sessao_plenaria.data_fim if sessao_plenaria.data_fim else sessao_plenaria.data_inicio
expedientes = list(prefetch_materias_sessao(
ExpedienteMateria.objects.filter(
sessao_plenaria_id=sessao_plenaria.id)))
votos_por_materia = agrupa_votos_por_materia(
'expediente', [m.id for m in expedientes])
for m in expedientes:
tramitacao = next(
(t for t in m.materia._tramitacao_prefetch
if t.data_tramitacao <= data_sessao), '')
rv = next((r for r in m._votacao_prefetch
if r.materia_id == m.materia_id), None)
rp = next((r for r in m._retirada_prefetch
if r.materia_id == m.materia_id), None)
rl = next((r for r in m._leitura_prefetch
if r.materia_id == m.materia_id), None)
if rv:
resultado = rv.tipo_resultado_votacao.nome
resultado_observacao = rv.observacao
@ -2076,11 +2230,11 @@ def get_materias_expediente(sessao_plenaria):
voto_nominal = []
if m.tipo_votacao == 2:
for voto in VotoParlamentar.objects.filter(expediente=m.id):
for voto in votos_por_materia[m.id]:
voto_nominal.append(
(voto.parlamentar.nome_completo, voto.voto))
voto = RegistroVotacao.objects.filter(expediente=m.id).last()
voto = m._votacao_prefetch[-1] if m._votacao_prefetch else None
if voto:
voto_sim = voto.numero_votos_sim
voto_nao = voto.numero_votos_nao
@ -2090,7 +2244,9 @@ def get_materias_expediente(sessao_plenaria):
voto_nao = " Não Informado"
voto_abstencoes = " Não Informado"
materia_em_tramitacao = m.materia.materiaemtramitacao_set.first()
met = m.materia._met_prefetch
materia_em_tramitacao = met[0] if met else None
numeracao = m.materia._numeracao_prefetch
materias_expediente.append({
'ementa': m.materia.ementa,
'titulo': m.materia,
@ -2099,9 +2255,9 @@ def get_materias_expediente(sessao_plenaria):
'situacao': materia_em_tramitacao.tramitacao.status if materia_em_tramitacao else _("Não informada"),
'resultado': resultado,
'resultado_observacao': resultado_observacao,
'autor': [str(x.autor) for x in Autoria.objects.select_related("autor").filter(materia_id=m.materia_id)],
'autor': [str(x.autor) for x in m.materia._autoria_prefetch],
'numero_protocolo': m.materia.numero_protocolo,
'numero_processo': m.materia.numeracao_set.last(),
'numero_processo': numeracao[-1] if numeracao else None,
'tipo_votacao': m.TIPO_VOTACAO_CHOICES[m.tipo_votacao],
'voto_sim': voto_sim,
'voto_nao': voto_nao,
@ -2117,12 +2273,13 @@ def get_materias_expediente(sessao_plenaria):
def get_oradores_expediente(sessao_plenaria):
oradores = []
for orador in OradorExpediente.objects.filter(
sessao_plenaria_id=sessao_plenaria.id).order_by('numero_ordem'):
sessao_plenaria_id=sessao_plenaria.id).select_related(
'parlamentar').prefetch_related(
prefetch_filiacao()).order_by('numero_ordem'):
numero_ordem = orador.numero_ordem
url_discurso = orador.url_discurso
observacao = orador.observacao
parlamentar = Parlamentar.objects.get(
id=orador.parlamentar_id)
parlamentar = orador.parlamentar
ora = {'numero_ordem': numero_ordem,
'url_discurso': url_discurso,
'parlamentar': parlamentar,
@ -2135,21 +2292,32 @@ def get_oradores_expediente(sessao_plenaria):
def get_presenca_ordem_do_dia(sessao_plenaria):
parlamentares_ordem = [p.parlamentar for p in PresencaOrdemDia.objects.filter(
sessao_plenaria_id=sessao_plenaria.id
).distinct().order_by('parlamentar__nome_parlamentar')]
).select_related('parlamentar').prefetch_related(
prefetch_filiacao()).distinct().order_by('parlamentar__nome_parlamentar')]
return {'presenca_ordem': parlamentares_ordem}
def get_assinaturas(sessao_plenaria):
mesa_dia = get_mesa_diretora(sessao_plenaria)['mesa']
def get_assinaturas(sessao_plenaria, mesa=None, presenca_ordem=None):
"""`mesa` e `presenca_ordem` podem ser reaproveitados de
`get_mesa_diretora` / `get_presenca_ordem_do_dia` quando o chamador os
calculou, evitando repetir as duas queries. O `.distinct()` que
`get_presenca_ordem_do_dia` aplica é um no-op (a projeção inclui a PK),
então as duas listas são equivalentes.
"""
mesa_dia = get_mesa_diretora(
sessao_plenaria)['mesa'] if mesa is None else mesa
presidente_dia = [next(iter(
[m['parlamentar'] for m in mesa_dia if m['cargo'].descricao == 'Presidente']),
'')]
parlamentares_ordem = [p.parlamentar for p in PresencaOrdemDia.objects.filter(
sessao_plenaria_id=sessao_plenaria.id
).order_by('parlamentar__nome_parlamentar')]
if presenca_ordem is None:
presenca_ordem = [p.parlamentar for p in PresencaOrdemDia.objects.filter(
sessao_plenaria_id=sessao_plenaria.id
).select_related('parlamentar').prefetch_related(
prefetch_filiacao()).order_by('parlamentar__nome_parlamentar')]
parlamentares_ordem = presenca_ordem
parlamentares_mesa = [m['parlamentar'] for m in mesa_dia]
@ -2195,19 +2363,25 @@ def get_assinaturas_presidente(sessao_plenaria):
def get_materias_ordem_do_dia(sessao_plenaria):
materias_ordem = []
for o in OrdemDia.objects.filter(sessao_plenaria_id=sessao_plenaria.id):
tramitacao = ''
data_sessao = sessao_plenaria.data_fim if sessao_plenaria.data_fim else sessao_plenaria.data_inicio
for aux_tramitacao in Tramitacao.objects.filter(materia=o.materia, data_tramitacao__lte=data_sessao).order_by(
'-data_tramitacao', '-id'):
if aux_tramitacao.turno:
tramitacao = aux_tramitacao
break
data_sessao = sessao_plenaria.data_fim if sessao_plenaria.data_fim else sessao_plenaria.data_inicio
ordens = list(prefetch_materias_sessao(
OrdemDia.objects.filter(sessao_plenaria_id=sessao_plenaria.id)))
votos_por_materia = agrupa_votos_por_materia(
'ordem', [o.id for o in ordens])
for o in ordens:
tramitacao = next(
(t for t in o.materia._tramitacao_prefetch
if t.data_tramitacao <= data_sessao), '')
# Verificar resultado
rv = o.registrovotacao_set.filter(materia=o.materia).first()
rp = o.retiradapauta_set.filter(materia=o.materia).first()
rl = o.registroleitura_set.filter(materia=o.materia).first()
rv = next((r for r in o._votacao_prefetch
if r.materia_id == o.materia_id), None)
rp = next((r for r in o._retirada_prefetch
if r.materia_id == o.materia_id), None)
rl = next((r for r in o._leitura_prefetch
if r.materia_id == o.materia_id), None)
if rv:
resultado = rv.tipo_resultado_votacao.nome
resultado_observacao = rv.observacao
@ -2224,11 +2398,11 @@ def get_materias_ordem_do_dia(sessao_plenaria):
voto_nominal = []
if o.tipo_votacao == 2:
for voto in VotoParlamentar.objects.filter(ordem=o.id):
for voto in votos_por_materia[o.id]:
voto_nominal.append(
(voto.parlamentar.nome_parlamentar, voto.voto))
voto = RegistroVotacao.objects.filter(ordem=o.id).last()
voto = o._votacao_prefetch[-1] if o._votacao_prefetch else None
if voto:
voto_sim = voto.numero_votos_sim
voto_nao = voto.numero_votos_nao
@ -2238,7 +2412,9 @@ def get_materias_ordem_do_dia(sessao_plenaria):
voto_nao = " Não Informado"
voto_abstencoes = " Não Informado"
materia_em_tramitacao = o.materia.materiaemtramitacao_set.first()
met = o.materia._met_prefetch
materia_em_tramitacao = met[0] if met else None
numeracao = o.materia._numeracao_prefetch
materias_ordem.append({
'ementa': o.materia.ementa,
'ementa_observacao': o.observacao,
@ -2248,9 +2424,9 @@ def get_materias_ordem_do_dia(sessao_plenaria):
'situacao': materia_em_tramitacao.tramitacao.status if materia_em_tramitacao else _("Não informada"),
'resultado': resultado,
'resultado_observacao': resultado_observacao,
'autor': [str(x.autor) for x in Autoria.objects.select_related("autor").filter(materia_id=o.materia_id)],
'autor': [str(x.autor) for x in o.materia._autoria_prefetch],
'numero_protocolo': o.materia.numero_protocolo,
'numero_processo': o.materia.numeracao_set.last(),
'numero_processo': numeracao[-1] if numeracao else None,
'tipo_votacao': o.TIPO_VOTACAO_CHOICES[o.tipo_votacao],
'voto_sim': voto_sim,
'voto_nao': voto_nao,
@ -2267,15 +2443,14 @@ def get_oradores_ordemdia(sessao_plenaria):
oradores_ordem_dia = OradorOrdemDia.objects.filter(
sessao_plenaria_id=sessao_plenaria.id
).order_by('numero_ordem')
).select_related('parlamentar').prefetch_related(
prefetch_filiacao()).order_by('numero_ordem')
for orador in oradores_ordem_dia:
numero_ordem = orador.numero_ordem
url_discurso = orador.url_discurso
observacao = orador.observacao
parlamentar = Parlamentar.objects.get(
id=orador.parlamentar_id
)
parlamentar = orador.parlamentar
o = {
'numero_ordem': numero_ordem,
'url_discurso': url_discurso,
@ -2291,26 +2466,26 @@ def get_oradores_ordemdia(sessao_plenaria):
def get_oradores_explicacoes_pessoais(sessao_plenaria):
oradores_explicacoes = []
for orador in Orador.objects.filter(
sessao_plenaria_id=sessao_plenaria.id).order_by('numero_ordem'):
for parlamentar in Parlamentar.objects.filter(
id=orador.parlamentar.id):
partido_sigla = Filiacao.objects.filter(
parlamentar=parlamentar).last()
if not partido_sigla:
sigla = ''
else:
sigla = partido_sigla.partido.sigla
observacao = orador.observacao
url_discurso = orador.url_discurso
oradores = {
'numero_ordem': orador.numero_ordem,
'parlamentar': parlamentar,
'sgl_partido': sigla,
'observacao': observacao,
'url_discurso': url_discurso
}
oradores_explicacoes.append(oradores)
sessao_plenaria_id=sessao_plenaria.id).select_related(
'parlamentar').prefetch_related(
prefetch_filiacao()).order_by('numero_ordem'):
parlamentar = orador.parlamentar
# Antes era Filiacao...last() (a filiação mais antiga, dado o
# ordering decrescente por data), enquanto o PDF usava .first().
# Unifica no critério já usado pelas listas de presença: o partido
# vigente na data da sessão.
sigla = filiacao_data(parlamentar, sessao_plenaria.data_inicio)
observacao = orador.observacao
url_discurso = orador.url_discurso
oradores = {
'numero_ordem': orador.numero_ordem,
'parlamentar': parlamentar,
'sgl_partido': sigla,
'observacao': observacao,
'url_discurso': url_discurso
}
oradores_explicacoes.append(oradores)
context = {'oradores_explicacoes': oradores_explicacoes}
return context
@ -2329,6 +2504,34 @@ def get_consideracoes_finais(sessao_plenaria):
return context
def get_votos_nominais(sessao_plenaria_id, model, campo):
"""Blocos de votação nominal de uma seção, em 3 queries em vez de 2 por matéria.
Replica `VotoParlamentar.objects.filter(votacao__in=registro).order_by('parlamentar')`:
a query dos votos é ordenada globalmente por parlamentar e o agrupamento
preserva essa ordem dentro de cada matéria.
"""
materias = list(model.objects.filter(
sessao_plenaria_id=sessao_plenaria_id, tipo_votacao=2
).select_related('materia').order_by('-materia'))
votos_por_materia = defaultdict(list)
if materias:
registro_para_materia = {
r.id: getattr(r, '%s_id' % campo)
for r in RegistroVotacao.objects.filter(
**{'%s_id__in' % campo: [m.id for m in materias]})
}
if registro_para_materia:
for vp in VotoParlamentar.objects.filter(
votacao_id__in=registro_para_materia).select_related(
'parlamentar').order_by('parlamentar'):
votos_por_materia[registro_para_materia[vp.votacao_id]].append(vp)
return [{'titulo': m.materia, 'votos': votos_por_materia[m.id]}
for m in materias]
class ResumoView(DetailView):
template_name = 'sessao/resumo.html'
model = SessaoPlenaria
@ -2343,22 +2546,8 @@ class ResumoView(DetailView):
context = self.get_context_data(object=self.object)
# Votos de Votação Nominal de Matérias Expediente
votacoes = []
for mevn in ExpedienteMateria.objects.filter(sessao_plenaria_id=self.object.id, tipo_votacao=2) \
.order_by('-materia'):
votos_materia = []
titulo_materia = mevn.materia
registro = RegistroVotacao.objects.filter(expediente=mevn)
if registro:
for vp in VotoParlamentar.objects.filter(votacao__in=registro).order_by('parlamentar'):
votos_materia.append(vp)
votacoes.append({
'titulo': titulo_materia,
'votos': votos_materia
})
context.update({'votos_nominais_materia_expediente': votacoes})
context.update({'votos_nominais_materia_expediente': get_votos_nominais(
self.object.id, ExpedienteMateria, 'expediente')})
# =====================================================================
# Identificação Básica
@ -2389,25 +2578,15 @@ class ResumoView(DetailView):
context.update(get_presenca_ordem_do_dia(self.object))
# =====================================================================
# Assinaturas
context.update(get_assinaturas(self.object))
context.update(get_assinaturas(
self.object,
mesa=context['mesa'],
presenca_ordem=context['presenca_ordem']))
# =====================================================================
# Matérias Ordem do Dia
# Votos de Votação Nominal de Matérias Ordem do Dia
votacoes_od = []
for modvn in OrdemDia.objects.filter(sessao_plenaria_id=self.object.id, tipo_votacao=2).order_by('-materia'):
votos_materia_od = []
t_materia = modvn.materia
registro_od = RegistroVotacao.objects.filter(ordem=modvn)
if registro_od:
for vp_od in VotoParlamentar.objects.filter(votacao__in=registro_od).order_by('parlamentar'):
votos_materia_od.append(vp_od)
votacoes_od.append({
'titulo': t_materia,
'votos': votos_materia_od
})
context.update({'votos_nominais_materia_ordem_dia': votacoes_od})
context.update({'votos_nominais_materia_ordem_dia': get_votos_nominais(
self.object.id, OrdemDia, 'ordem')})
context.update(get_materias_ordem_do_dia(self.object))
# =====================================================================

120
sapl/templates/painel/mensagem.html

@ -1,120 +0,0 @@
{% load i18n %}
{% load common_tags %}
{% load render_bundle from webpack_loader %}
{% load webpack_static from webpack_loader %}
<!DOCTYPE HTML>
<!--[if IE 8]> <html class="no-js lt-ie9" lang="pt-br"> <![endif]-->
<!--[if gt IE 8]><!-->
<html lang="pt-br">
<!--<![endif]-->
<head>
<meta charset="UTF-8">
<!-- TODO: does it need this head_title here? -->
<title>{% block head_title %}{% trans 'SAPL - Sistema de Apoio ao Processo Legislativo' %}{% endblock %}</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
{% render_chunk_vendors 'css' %}
{% render_bundle 'global' 'css' %}
{% render_bundle 'painel' 'css' %}
<STYLE type="text/css">
@media screen {
body {font-size: medium; color: white; line-height: 1em; background: black;}
}
</STYLE>
</head>
<body>
<h1>{{ context.title }}</h1>
<input id="json_url" type="hidden" value="{% url 'sapl.painel:dados_painel' %}">
<h2>Ajax refresh counter: <span id="counter"></span></h2>
<h3>
<span id="sessao_plenaria"></span><br/><br/>
<span id="sessao_plenaria_data"></span><br/><br/>
<span id="sessao_plenaria_hora_inicio"></span></br><br/>
<h2><span id="relogio"></span></h2></br><br/><br/>
<span id="materia_legislativa_texto"></span><br/>
<span id="observacao_materia"></span>
</h3>
</body>
{% render_chunk_vendors 'js' %}
{% render_bundle 'global' 'js' %}
{% render_bundle 'painel' 'js' %}
<script type="text/javascript">
$(document).ready(function() {
//TODO: replace by a fancy jQuery clock
function checkTime(i) {
if (i<10) {i = "0" + i}; // add zero in front of numbers < 10
return i;
}
function startTime() {
var today=new Date();
var h=today.getHours();
var m=today.getMinutes();
var s=today.getSeconds();
m = checkTime(m);
s = checkTime(s);
$("#relogio").text(h+":"+m+":"+s)
var t = setTimeout(function(){
startTime()
},500);
}
startTime();
var counter = 1;
(function poll() {
$.ajax({
url: $("#json_url").val(),
type: "GET",
success: function(data) {
//TODO: json spitted out is very complex, have to simplify/flat it
//TODO: probably building it by hand on REST side
console.debug(data)
var presentes = $("#parlamentares");
presentes.children().remove();
presentes_ordem_dia = data.presentes_ordem_dia
$.each(presentes_ordem_dia, function(index, parlamentar) {
$('<li />', {text: parlamentar.nome + '/' + parlamentar.partido + ' ' + parlamentar.voto }).appendTo(presentes);
});
var votacao = $("#votacao")
votacao.children().remove()
votacao.append("<li>Sim: " + data["numero_votos_sim"] + "</li>")
votacao.append("<li>Não: " + data["numero_votos_nao"] + "</li>")
votacao.append("<li>Abstenções: " + data["numero_abstencoes"] + "</li>")
votacao.append("<li>Presentes: " + data["presentes"] + "</li>")
votacao.append("<li>Total votos: " + data["total_votos"] + "</li>")
$("#sessao_plenaria").text(data["sessao_plenaria"])
$("#sessao_plenaria_data").text("Data Início: " + data["sessao_plenaria_data"])
$("#sessao_plenaria_hora_inicio").text("Hora Início: " + data["sessao_plenaria_hora_inicio"])
$("#materia_legislativa_texto").text(data["materia_legislativa_texto"])
$("#observacao_materia").text(data["observacao_materia"])
$("#resultado_votacao").text(data["tipo_resultado"])
$("#counter").text(counter);
counter++;
},
error: function(err) {
console.error(err);
},
dataType: "json",
//complete: setTimeout(function() {poll()}, 5000),
timeout: 20000 // TODO: decrease
})
})();
});
</script>
</html>

128
sapl/templates/painel/parlamentares.html

@ -1,128 +0,0 @@
{% load i18n %}
{% load common_tags %}
{% load render_bundle from webpack_loader %}
{% load webpack_static from webpack_loader %}
<!DOCTYPE HTML>
<!--[if IE 8]> <html class="no-js lt-ie9" lang="pt-br"> <![endif]-->
<!--[if gt IE 8]><!-->
<html lang="pt-br">
<!--<![endif]-->
<head>
<meta charset="UTF-8">
<!-- TODO: does it need this head_title here? -->
<title>{% block head_title %}{% trans 'SAPL - Sistema de Apoio ao Processo Legislativo' %}{% endblock %}</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
{% render_chunk_vendors 'css' %}
{% render_bundle 'global' 'css' %}
{% render_bundle 'painel' 'css' %}
<STYLE type="text/css">
@media screen {
body {font-size: medium; color: white; line-height: 1em; background: black;}
}
</STYLE>
</head>
<body>
<h1>{{ context.title }}</h1>
<input id="json_url" type="hidden" value="{% url 'sapl.painel:dados_painel' %}">
<h3>
<span id="sessao_plenaria"></span><br/><br/>
<span id="sessao_plenaria_data"></span><br/><br/>
<span id="sessao_plenaria_hora_inicio"></span></br><br/>
<h2><span id="relogio"></span></h2>
<table>
<tr>
<td>
<ul id="parlamentares">
</ul>
</td>
</tr>
</table>
</h3>
</body>
{% render_chunk_vendors 'js' %}
{% render_bundle 'global' 'js' %}
{% render_bundle 'painel' 'js' %}
<script type="text/javascript">
$(document).ready(function() {
//TODO: replace by a fancy jQuery clock
function checkTime(i) {
if (i<10) {i = "0" + i}; // add zero in front of numbers < 10
return i;
}
function startTime() {
var today=new Date();
var h=today.getHours();
var m=today.getMinutes();
var s=today.getSeconds();
m = checkTime(m);
s = checkTime(s);
$("#relogio").text(h+":"+m+":"+s)
var t = setTimeout(function(){
startTime()
},500);
}
startTime();
var counter = 1;
(function poll() {
$.ajax({
url: $("#json_url").val(),
type: "GET",
success: function(data) {
//TODO: json spitted out is very complex, have to simplify/flat it
//TODO: probably building it by hand on REST side
console.debug(data)
var presentes = $("#parlamentares");
presentes.children().remove();
presentes_ordem_dia = data.presentes_ordem_dia
$.each(presentes_ordem_dia, function(index, parlamentar) {
$('<li />', {text: parlamentar.nome + '/' + parlamentar.partido }).appendTo(presentes);
/*$('<li />', {text: parlamentar.nome + '/' + parlamentar.partido + ' ' + parlamentar.voto }).appendTo(presentes);*/
});
var votacao = $("#votacao")
votacao.children().remove()
votacao.append("<li>Sim: " + data["numero_votos_sim"] + "</li>")
votacao.append("<li>Não: " + data["numero_votos_nao"] + "</li>")
votacao.append("<li>Abstenções: " + data["numero_abstencoes"] + "</li>")
votacao.append("<li>Presentes: " + data["presentes"] + "</li>")
votacao.append("<li>Total votos: " + data["total_votos"] + "</li>")
$("#sessao_plenaria").text(data["sessao_plenaria"])
$("#sessao_plenaria_data").text("Data Início: " + data["sessao_plenaria_data"])
$("#sessao_plenaria_hora_inicio").text("Hora Início: " + data["sessao_plenaria_hora_inicio"])
$("#materia_legislativa_texto").text(data["materia_legislativa_texto"])
$("#observacao_materia").text(data["observacao_materia"])
$("#resultado_votacao").text(data["tipo_resultado"])
$("#counter").text(counter);
counter++;
},
error: function(err) {
console.error(err);
},
dataType: "json",
//complete: setTimeout(function() {poll()}, 5000),
timeout: 20000 // TODO: decrease
})
})();
});
</script>
</html>

123
sapl/templates/painel/votacao.html

@ -1,123 +0,0 @@
{% load i18n %}
{% load render_bundle from webpack_loader %}
{% load webpack_static from webpack_loader %}
<!DOCTYPE HTML>
<!--[if IE 8]> <html class="no-js lt-ie9" lang="pt-br"> <![endif]-->
<!--[if gt IE 8]><!-->
<html lang="pt-br">
<!--<![endif]-->
<head>
<meta charset="UTF-8">
<!-- TODO: does it need this head_title here? -->
<title>{% block head_title %}{% trans 'SAPL - Sistema de Apoio ao Processo Legislativo' %}{% endblock %}</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
{% render_chunk_vendors 'css' %}
{% render_bundle 'global' 'css' %}
{% render_bundle 'painel' 'css' %}
<STYLE type="text/css">
@media screen {
body {font-size: medium; color: white; line-height: 1em; background: black;}
}
</STYLE>
</head>
<body>
<h1>{{ context.title }}</h1>
<input id="json_url" type="hidden" value="{% url 'sapl.painel:dados_painel' %}">
<h3>
<span id="sessao_plenaria"></span><br/><br/>
<span id="sessao_plenaria_data"></span><br/><br/>
<span id="sessao_plenaria_hora_inicio"></span></br><br/>
<h2><span id="relogio"></span></h2>
<table>
<tr>
<td>
<ul id="votacao">
</ul>
</td>
</tr>
</table>
<span id="resultado_votacao"></span><br/>
</h3>
</body>
{% render_chunk_vendors 'js' %}
{% render_bundle 'global' 'js' %}
{% render_bundle 'painel' 'js' %}
<script type="text/javascript">
$(document).ready(function() {
//TODO: replace by a fancy jQuery clock
function checkTime(i) {
if (i<10) {i = "0" + i}; // add zero in front of numbers < 10
return i;
}
function startTime() {
var today=new Date();
var h=today.getHours();
var m=today.getMinutes();
var s=today.getSeconds();
m = checkTime(m);
s = checkTime(s);
$("#relogio").text(h+":"+m+":"+s)
var t = setTimeout(function(){
startTime()
},500);
}
startTime();
var counter = 1;
(function poll() {
$.ajax({
url: $("#json_url").val(),
type: "GET",
success: function(data) {
//TODO: json spitted out is very complex, have to simplify/flat it
//TODO: probably building it by hand on REST side
console.debug(data)
var presentes = $("#parlamentares");
presentes.children().remove();
presentes_ordem_dia = data.presentes_ordem_dia
$.each(presentes_ordem_dia, function(index, parlamentar) {
$('<li />', {text: parlamentar.nome + '/' + parlamentar.partido + ' ' + parlamentar.voto }).appendTo(presentes);
});
var votacao = $("#votacao")
votacao.children().remove()
votacao.append("<li>Sim: " + data["numero_votos_sim"] + "</li>")
votacao.append("<li>Não: " + data["numero_votos_nao"] + "</li>")
votacao.append("<li>Abstenções: " + data["numero_abstencoes"] + "</li>")
votacao.append("<li>Presentes: " + data["presentes"] + "</li>")
votacao.append("<li>Total votos: " + data["total_votos"] + "</li>")
$("#sessao_plenaria").text(data["sessao_plenaria"])
$("#sessao_plenaria_data").text("Data Início: " + data["sessao_plenaria_data"])
$("#sessao_plenaria_hora_inicio").text("Hora Início: " + data["sessao_plenaria_hora_inicio"])
$("#materia_legislativa_texto").text(data["materia_legislativa_texto"])
$("#observacao_materia").text(data["observacao_materia"])
$("#resultado_votacao").text(data["tipo_resultado"])
$("#counter").text(counter);
counter++;
},
error: function(err) {
console.error(err);
},
dataType: "json",
//complete: setTimeout(function() {poll()}, 5000),
timeout: 20000 // TODO: decrease
})
})();
});
</script>
</html>

58
sapl/utils.py

@ -935,23 +935,59 @@ def qs_override_django_filter(self):
def filiacao_data(parlamentar, data_inicio, data_fim=None):
from sapl.parlamentares.models import Filiacao
filiacoes_parlamentar = Filiacao.objects.filter(
parlamentar=parlamentar)
filiacoes = _filiacoes_prefetchadas(parlamentar, data_inicio, data_fim)
filiacoes = filiacoes_parlamentar.filter(Q(
data__lte=data_inicio,
data_desfiliacao__isnull=True) | Q(
data__lte=data_inicio,
data_desfiliacao__gte=data_inicio))
if filiacoes is None:
filiacoes_parlamentar = Filiacao.objects.filter(
parlamentar=parlamentar)
if data_fim:
filiacoes = filiacoes | filiacoes_parlamentar.filter(
data__gte=data_inicio,
data__lte=data_fim)
filiacoes = filiacoes_parlamentar.filter(Q(
data__lte=data_inicio,
data_desfiliacao__isnull=True) | Q(
data__lte=data_inicio,
data_desfiliacao__gte=data_inicio))
if data_fim:
filiacoes = filiacoes | filiacoes_parlamentar.filter(
data__gte=data_inicio,
data__lte=data_fim)
return ' | '.join([f.partido.sigla for f in filiacoes])
def _filiacoes_prefetchadas(parlamentar, data_inicio, data_fim=None):
"""Aplica em memória o mesmo predicado de `filiacao_data`, quando
`filiacao_set` veio prefetchado.
O resumo/ata da sessão chama `filiacao_data_filter` duas vezes por
parlamentar exibido; sem isso cada chamada custa 1+N queries. Retorna
`None` quando não prefetch, para que o chamador use o caminho SQL.
O queryset do prefetch deve replicar `Filiacao.Meta.ordering` para que a
sequência de siglas saia idêntica à da consulta ao banco.
"""
if not data_inicio:
return None
cache = getattr(parlamentar, '_prefetched_objects_cache', None)
if not cache:
return None
cache_name = parlamentar.filiacao_set.field.remote_field.get_cache_name()
if cache_name not in cache:
return None
def selecionada(f):
if f.data <= data_inicio and (f.data_desfiliacao is None
or f.data_desfiliacao >= data_inicio):
return True
if data_fim:
return data_inicio <= f.data <= data_fim
return False
return [f for f in cache[cache_name] if selecionada(f)]
def parlamentares_ativos(data_inicio, data_fim=None):
from sapl.parlamentares.models import Mandato, Parlamentar
'''

Loading…
Cancel
Save