Skip to content

Commit c1916b6

Browse files
Fix N+1 queries in sessão/relatórios and remove unused painel views
Performance-only slice extracted from feat/rate-limiter-2026 (commit 2f2a976), which bundled query-optimization work in with unrelated rate-limiter/caching changes. - customize_link_materia() and the Ordem do Dia / Expediente ListViews now use prefetched querysets instead of per-row queries. - get_etiqueta_protocolos() batch-fetches MateriaLegislativa and DocumentoAdministrativo instead of querying per protocolo in a loop. - New migration redefines the materia_materiaemtramitacao view and adds a concurrent index (tram_materia_id_desc) backing the tramitacao prefetch. - Drops a redundant .distinct() + order_by on a related field in RelatorioMateriasTramitacaoFilterSet.qs. - Removes the unused painel_mensagem/parlamentar/votacao views and templates (also bundled into 2f2a976 by mistake). - Adds CLAUDE.md project documentation. Note: feat/painel-votacao-v2 still imports painel_mensagem_view, painel_parlamentar_view and painel_votacao_view. That branch will need reconciling with this removal when it's rebased onto 3.1.x. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 85079b4 commit c1916b6

14 files changed

Lines changed: 722 additions & 600 deletions

File tree

CLAUDE.md

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
# CLAUDE.md
2+
3+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4+
5+
## Project Overview
6+
7+
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.
8+
9+
## Commands
10+
11+
### Development
12+
13+
```bash
14+
# Run dev server
15+
python manage.py runserver
16+
17+
# Docker (dev, without bundled DB)
18+
docker-compose -f docker/docker-compose-dev.yml up
19+
20+
# Docker (dev, with PostgreSQL container)
21+
docker-compose -f docker/docker-compose-dev-db.yml up
22+
```
23+
24+
### Database Setup (local PostgreSQL)
25+
26+
```bash
27+
sudo -u postgres psql -c "CREATE ROLE sapl LOGIN ENCRYPTED PASSWORD 'sapl' NOSUPERUSER INHERIT CREATEDB NOCREATEROLE NOREPLICATION;"
28+
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;"
29+
python manage.py migrate
30+
```
31+
32+
### Testing
33+
34+
```bash
35+
# All tests (reuses DB by default for speed)
36+
pytest
37+
38+
# Single test file or test function
39+
pytest sapl/materia/tests/test_materia.py
40+
pytest sapl/materia/tests/test_materia.py::test_function_name
41+
42+
# Force DB recreation
43+
pytest --create-db
44+
45+
# With coverage
46+
pytest --cov=sapl
47+
```
48+
49+
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`).
50+
51+
### Linting / Formatting
52+
53+
```bash
54+
flake8 .
55+
isort .
56+
autopep8 --in-place <file.py>
57+
```
58+
59+
### Restore Database from Backup
60+
61+
```bash
62+
./scripts/restore_db.sh -f /path/to/dump
63+
./scripts/restore_db.sh -f /path/to/dump -p 5433 # Docker port
64+
```
65+
66+
## Architecture
67+
68+
### Django Apps
69+
70+
Apps are under `sapl/` and follow domain boundaries:
71+
72+
| App | Domain |
73+
|-----|--------|
74+
| `base` | `CasaLegislativa` (legislative house config), `AppConfig`, `Autor` (authorship) |
75+
| `parliamentary` | `Parlamentar`, `Legislatura`, `SessaoLegislativa`, `Coligacao` |
76+
| `materia` | Bills (`MateriaLegislativa`), types, tracking, annexes |
77+
| `norma` | Laws/norms (`NormaJuridica`) and hierarchies |
78+
| `sessao` | Plenary sessions, agenda, attendance, voting |
79+
| `comissoes` | Committees (`Comissao`) and meetings (`Reuniao`) |
80+
| `protocoloadm` | Administrative protocols and document intake |
81+
| `compilacao` | Structured/articulated texts (LexML-like tree structure) |
82+
| `lexml` | LexML XML standard integration |
83+
| `audiencia` | Public hearings |
84+
| `painel` | Real-time session display panel |
85+
| `relatorios` | PDF report generation |
86+
| `api` | REST API entry point (auto-generated ViewSets) |
87+
| `crud` | Generic CRUD base views |
88+
| `rules` | Business rules and permission definitions |
89+
90+
### REST API
91+
92+
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.
93+
94+
OpenAPI 3.0 docs are generated by drf-spectacular.
95+
96+
### Caching
97+
98+
- **Default:** File-based (`/var/tmp/django_cache`)
99+
- **Production:** Redis via django-redis; configured at startup by `configure_redis_cache()` in `sapl/settings.py`
100+
- **Cache key prefix:** `cache:{POD_NAMESPACE}:` (namespace-isolated for multi-tenant k8s)
101+
- **Rate limiter state** is shared via Redis keys
102+
103+
### Feature Flags
104+
105+
django-waffle is used for feature flags. Switches (global on/off) can be toggled via:
106+
107+
```bash
108+
python manage.py waffle_switch <switch_name> on|off
109+
```
110+
111+
### Key Environment Variables
112+
113+
| Variable | Purpose |
114+
|----------|---------|
115+
| `DATABASE_URL` | PostgreSQL connection string |
116+
| `SECRET_KEY` | Django secret key |
117+
| `DEBUG` | Debug mode |
118+
| `REDIS_URL` | Redis host:port |
119+
| `CACHE_BACKEND` | `file` or `redis` |
120+
| `POD_NAMESPACE` | K8s namespace (used in cache key prefix) |
121+
| `USE_SOLR` | Enable Haystack/Solr full-text search |
122+
| `SOLR_URL` / `SOLR_COLLECTION` | Solr connection |
123+
124+
### Docker Build
125+
126+
The production build requires a MaxMind GeoLite2-ASN license key (for nginx ASN-based bot blocking):
127+
128+
```bash
129+
docker build --secret id=maxmind_key,src=.env -f docker/Dockerfile -t sapl:local .
130+
```
131+
132+
Optional build args: `WITH_NGINX`, `WITH_GRAPHVIZ`, `WITH_POPPLER`, `WITH_PSQL_CLIENT`.
133+
134+
### Key File Locations
135+
136+
| File | Purpose |
137+
|------|---------|
138+
| `sapl/settings.py` | All Django settings, including cache/rate-limit setup |
139+
| `pytest.ini` | Test configuration (DJANGO_SETTINGS_MODULE, addopts) |
140+
| `conftest.py` | Root pytest fixtures |
141+
| `drfautoapi/drfautoapi.py` | Auto-API generation logic |
142+
| `docker/startup_scripts/start.sh` | Container entrypoint (migrations, waffle, gunicorn) |
143+
| `requirements/requirements.txt` | Production deps |
144+
| `requirements/test-requirements.txt` | Test deps |
145+
| `requirements/dev-requirements.txt` | Dev/lint deps |
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
from django.db import migrations, models
2+
3+
_OLD_VIEW = """
4+
create or replace view materia_materiaemtramitacao as
5+
select m.id as id,
6+
m.id as materia_id,
7+
t.id as tramitacao_id,
8+
t.unidade_tramitacao_destino_id as unidade_tramitacao_atual_id
9+
from materia_materialegislativa m
10+
inner join materia_tramitacao t on (m.id = t.materia_id)
11+
where t.id = (select max(id) from materia_tramitacao where materia_id = m.id)
12+
order by m.id DESC
13+
"""
14+
15+
_NEW_VIEW = """
16+
CREATE OR REPLACE VIEW materia_materiaemtramitacao AS
17+
SELECT
18+
m.id,
19+
m.id AS materia_id,
20+
t.id AS tramitacao_id,
21+
t.unidade_tramitacao_destino_id AS unidade_tramitacao_atual_id
22+
FROM materia_materialegislativa m
23+
JOIN LATERAL (
24+
SELECT
25+
t.id,
26+
t.unidade_tramitacao_destino_id
27+
FROM materia_tramitacao t
28+
WHERE t.materia_id = m.id
29+
ORDER BY t.id DESC
30+
LIMIT 1
31+
) t ON true;
32+
"""
33+
34+
35+
class Migration(migrations.Migration):
36+
# CREATE INDEX CONCURRENTLY cannot run inside a transaction.
37+
atomic = False
38+
39+
dependencies = [
40+
('materia', '0087_update_viewdb_materiaemtramitacao'),
41+
]
42+
43+
operations = [
44+
migrations.RunSQL(sql=_NEW_VIEW, reverse_sql=_OLD_VIEW),
45+
migrations.SeparateDatabaseAndState(
46+
database_operations=[
47+
migrations.RunSQL(
48+
sql="""
49+
CREATE INDEX CONCURRENTLY IF NOT EXISTS
50+
tram_materia_id_desc
51+
ON materia_tramitacao (materia_id, id DESC)
52+
""",
53+
reverse_sql="""
54+
DROP INDEX CONCURRENTLY IF EXISTS
55+
tram_materia_id_desc
56+
""",
57+
),
58+
],
59+
state_operations=[
60+
migrations.AddIndex(
61+
model_name='tramitacao',
62+
index=models.Index(
63+
fields=['materia', '-id'],
64+
name='tram_materia_id_desc',
65+
),
66+
),
67+
],
68+
),
69+
]

sapl/materia/models.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1350,6 +1350,9 @@ class Meta:
13501350
verbose_name = _('Tramitação')
13511351
verbose_name_plural = _('Tramitações')
13521352
ordering = ('-data_tramitacao', '-id')
1353+
indexes = [
1354+
models.Index(fields=['materia', '-id'], name='tram_materia_id_desc'),
1355+
]
13531356

13541357
def __str__(self):
13551358
return _('%(materia)s | %(status)s | %(data)s') % {

sapl/painel/urls.py

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
from django.conf.urls import url
22

33
from .apps import AppConfig
4-
from .views import (cronometro_painel, get_dados_painel, painel_mensagem_view,
5-
painel_parlamentar_view, painel_view, painel_votacao_view,
4+
from .views import (cronometro_painel, get_dados_painel, painel_view,
65
switch_painel, verifica_painel, votante_view)
76

87
app_name = AppConfig.name
@@ -11,12 +10,8 @@
1110
url(r'^painel-principal/(?P<pk>\d+)$', painel_view,
1211
name="painel_principal"),
1312
url(r'^painel/(?P<pk>\d+)/dados$', get_dados_painel, name='dados_painel'),
14-
url(r'^painel/mensagem$', painel_mensagem_view, name="painel_mensagem"),
15-
url(r'^painel/parlamentar$', painel_parlamentar_view,
16-
name='painel_parlamentar'),
1713
url(r'^painel/switch-painel$', switch_painel,
1814
name="switch_painel"),
19-
url(r'^painel/votacao$', painel_votacao_view, name='painel_votacao'),
2015
url(r'^painel/verifica-painel$', verifica_painel,
2116
name="verifica_painel"),
2217
url(r'^painel/cronometro$', cronometro_painel, name='cronometro_painel'),

sapl/painel/views.py

Lines changed: 0 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -327,21 +327,6 @@ def verifica_painel(request):
327327
return resposta
328328

329329

330-
@user_passes_test(check_permission)
331-
def painel_mensagem_view(request):
332-
return render(request, 'painel/mensagem.html')
333-
334-
335-
@user_passes_test(check_permission)
336-
def painel_parlamentar_view(request):
337-
return render(request, 'painel/parlamentares.html')
338-
339-
340-
@user_passes_test(check_permission)
341-
def painel_votacao_view(request):
342-
return render(request, 'painel/votacao.html')
343-
344-
345330
@user_passes_test(check_permission)
346331
def cronometro_painel(request):
347332
request.session[request.GET['tipo']] = request.GET['action']

sapl/relatorios/forms.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -543,9 +543,7 @@ class RelatorioMateriasTramitacaoFilterSet(django_filters.FilterSet):
543543
@property
544544
def qs(self):
545545
parent = super(RelatorioMateriasTramitacaoFilterSet, self).qs
546-
return parent.distinct().order_by(
547-
'-materia__ano', 'materia__tipo', '-materia__numero'
548-
)
546+
return parent.order_by('-materia__ano', 'materia__tipo', '-materia__numero')
549547

550548
class Meta:
551549
model = MateriaEmTramitacao

sapl/relatorios/templates/pdf_sessao_plenaria_gerar.py

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
import os
88
import time
99
import logging
10+
from xml.sax.saxutils import escape
11+
1012
from django.template.defaultfilters import safe
1113
from django.utils.html import strip_tags
1214
from trml2pdf import parseString
@@ -198,6 +200,33 @@ def presenca(lst_presenca_sessao, lst_ausencia_sessao):
198200
return tmp
199201

200202

203+
def correspondencias(lst_correspondencias):
204+
tmp = ''
205+
if lst_correspondencias:
206+
tmp += '\t\t<para style="P1">Correspondências</para>\n'
207+
tmp += '\t\t<para style="P2">\n'
208+
tmp += '\t\t\t<font color="white"> <br/></font>\n'
209+
tmp += '\t\t</para>\n'
210+
tmp += '<blockTable style="repeater" repeatRows="1" colWidths="3cm,4cm,3.5cm,6.5cm">\n'
211+
tmp += '<tr><td>Tipo</td><td>Documento</td><td>Interessado</td><td>Assunto</td></tr>\n'
212+
for c in lst_correspondencias:
213+
tmp += '<tr>'
214+
tmp += '<td><para style="P4">' + \
215+
escape(str(c['tipo'])) + '</para></td>\n'
216+
tmp += '<td><para style="P4">' + escape(str(c['epigrafe'])) + \
217+
' - ' + escape(str(c['data'])) + '</para></td>\n'
218+
tmp += '<td><para style="P4">' + \
219+
escape(str(c['interessado'] or '')) + '</para></td>\n'
220+
tmp += '<td><para style="P4">' + \
221+
escape(str(c['assunto'] or '')) + '</para></td>\n'
222+
tmp += '</tr>\n'
223+
tmp += '</blockTable>\n'
224+
tmp += '\t\t<para style="P2">\n'
225+
tmp += '\t\t\t<font color="white"> <br/></font>\n'
226+
tmp += '\t\t</para>\n'
227+
return tmp
228+
229+
201230
def expedientes(lst_expedientes):
202231
tmp = ''
203232
if lst_expedientes:
@@ -415,7 +444,7 @@ def consideracoes(lst_consideracoes):
415444
return tmp
416445

417446

418-
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):
447+
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):
419448
"""
420449
"""
421450
arquivoPdf = str(int(time.time() * 100)) + ".pdf"
@@ -440,6 +469,7 @@ def principal(rodape_dic, imagem, inf_basicas_dic, cont_mult_dic, lst_mesa, lst_
440469
ordenacao = ResumoOrdenacao.objects.first()
441470
dict_ord_template = {
442471
'cont_mult': multimidia(cont_mult_dic),
472+
'correspondencia': correspondencias(lst_correspondencias),
443473
'exp': expedientes(lst_expedientes),
444474
'id_basica': inf_basicas(inf_basicas_dic),
445475
'lista_p': presenca(lst_presenca_sessao, lst_ausencia_sessao),
@@ -473,13 +503,15 @@ def principal(rodape_dic, imagem, inf_basicas_dic, cont_mult_dic, lst_mesa, lst_
473503
tmp += dict_ord_template[ordenacao.decimo_terceiro]
474504
tmp += dict_ord_template[ordenacao.decimo_quarto]
475505
tmp += dict_ord_template[ordenacao.decimo_quinto]
506+
tmp += dict_ord_template[ordenacao.decimo_sexto]
476507
except KeyError as e:
477508
logger.error("KeyError: " + str(e) + ". Erro ao tentar utilizar "
478509
"configuração de ordenação. Utilizando ordenação padrão.")
479510
tmp += inf_basicas(inf_basicas_dic)
480511
tmp += multimidia(cont_mult_dic)
481512
tmp += mesa(lst_mesa)
482513
tmp += presenca(lst_presenca_sessao, lst_ausencia_sessao)
514+
tmp += correspondencias(lst_correspondencias)
483515
tmp += expedientes(lst_expedientes)
484516
tmp += expediente_materia(lst_expediente_materia)
485517
tmp += expediente_materia_vot_nom(lst_expediente_materia_vot_nom)
@@ -497,6 +529,7 @@ def principal(rodape_dic, imagem, inf_basicas_dic, cont_mult_dic, lst_mesa, lst_
497529
tmp += multimidia(cont_mult_dic)
498530
tmp += mesa(lst_mesa)
499531
tmp += presenca(lst_presenca_sessao, lst_ausencia_sessao)
532+
tmp += correspondencias(lst_correspondencias)
500533
tmp += expedientes(lst_expedientes)
501534
tmp += expediente_materia(lst_expediente_materia)
502535
tmp += expediente_materia_vot_nom(lst_expediente_materia_vot_nom)

0 commit comments

Comments
 (0)