Browse Source

Addressing review comments

feat/sessao-relatorio-perf
Edward Ribeiro 1 week ago
parent
commit
e8fcccac99
  1. 145
      CLAUDE.md
  2. 70
      frontend/src/__apps/compilacao/js/old/compilacao.js
  3. 4
      frontend/src/__apps/compilacao/js/old/compilacao_edit.js
  4. 9
      frontend/src/__apps/compilacao/scss/compilacao.scss
  5. 25
      frontend/src/__global/js/tinymce/index.js
  6. 20
      sapl/audiencia/forms.py
  7. 59
      sapl/audiencia/migrations/0021_auto_20260623_1938.py
  8. 18
      sapl/audiencia/migrations/0022_audienciapublica_data_ultima_atualizacao.py
  9. 11
      sapl/audiencia/models.py
  10. 25
      sapl/comissoes/forms.py
  11. 18
      sapl/comissoes/migrations/0031_comissao_data_ultima_atualizacao.py
  12. 3
      sapl/comissoes/models.py
  13. 11
      sapl/comissoes/views.py
  14. 5
      sapl/crud/base.py
  15. 21
      sapl/materia/migrations/0088_fix_view_materiaemtramitacao.py
  16. 18
      sapl/parlamentares/migrations/0046_parlamentar_data_ultima_atualizacao.py
  17. 4
      sapl/parlamentares/models.py
  18. 28
      sapl/protocoloadm/migrations/0046_auto_20260626_1220.py
  19. 3
      sapl/protocoloadm/models.py
  20. 28
      sapl/relatorios/views.py
  21. 11
      sapl/sessao/models.py
  22. 54
      sapl/sessao/tests/test_sessao_view.py
  23. 147
      sapl/sessao/views.py
  24. 4
      sapl/templates/audiencia/layouts.yaml
  25. 7
      sapl/templates/base.html
  26. 2
      sapl/templates/compilacao/dispositivo_form_alteracao.html
  27. 2
      sapl/templates/compilacao/dispositivo_form_edicao_basica.html
  28. 6
      sapl/templates/compilacao/text_edit.html
  29. 2
      sapl/templates/relatorios/relatorio_materia_tramitacao.html
  30. 7
      sapl/templates/sessao/sessaoplenaria_filter.html
  31. 9
      sapl/utils.py

145
CLAUDE.md

@ -1,145 +0,0 @@
# 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 |

70
frontend/src/__apps/compilacao/js/old/compilacao.js

@ -279,6 +279,69 @@ function DispositivoSearch (opts) {
})
}
// Escala de leitura do texto articulado, preferência de cada navegador.
// Aplicada via variável CSS (--cp-font-scale) para não alterar o conteúdo
// dos dispositivos e para valer também nos que são recarregados via ajax.
const EscalaFonte = Object.freeze({
CHAVE: 'compilacao_escala_fonte',
PADRAO: 1,
PASSO: 0.1,
MIN: 0.8,
MAX: 2.5
})
let escalaFonteAtual = null
// Limita num ao intervalo [lower, upper].
function clamp (num, lower, upper) {
return Math.max(lower, Math.min(num, upper))
}
function lerEscalaFonte () {
if (escalaFonteAtual !== null) {
return escalaFonteAtual
}
let valor = null
try {
valor = window.localStorage.getItem(EscalaFonte.CHAVE)
} catch (e) {
// localStorage indisponível (ex.: navegação privada bloqueada)
console.warn('Escala de fonte: não foi possível ler o localStorage.', e)
return EscalaFonte.PADRAO
}
if (valor === null) {
return EscalaFonte.PADRAO
}
const escala = parseFloat(valor)
if (!Number.isFinite(escala)) {
console.warn('Escala de fonte: valor inválido no localStorage, usando o padrão.', valor)
return EscalaFonte.PADRAO
}
return clamp(escala, EscalaFonte.MIN, EscalaFonte.MAX)
}
function aplicarEscalaFonte (escala) {
document.documentElement.style.setProperty('--cp-font-scale', escala)
if (window.tinymce) {
window.tinymce.get().forEach(function (editor) {
if (editor.getBody()) {
editor.getBody().style.fontSize = (escala * 100) + '%'
}
})
}
}
function salvarEscalaFonte (escala) {
escala = Math.round(clamp(escala, EscalaFonte.MIN, EscalaFonte.MAX) * 10) / 10
try {
window.localStorage.setItem(EscalaFonte.CHAVE, escala)
} catch (e) {
// localStorage indisponível: a escala vale apenas até recarregar a página
console.warn('Escala de fonte: não foi possível salvar no localStorage.', escala, e)
}
escalaFonteAtual = escala
aplicarEscalaFonte(escala)
}
function InitViewTAs () {
setTimeout(function () {
var href = location.href.split('#')
@ -297,11 +360,12 @@ function InitViewTAs () {
}
}, 100)
aplicarEscalaFonte(lerEscalaFonte())
$('#btn_font_menos').click(function () {
$('.dpt').css('font-size', '-=1')
salvarEscalaFonte(lerEscalaFonte() - EscalaFonte.PASSO)
})
$('#btn_font_mais').click(function () {
$('.dpt').css('font-size', '+=1')
salvarEscalaFonte(lerEscalaFonte() + EscalaFonte.PASSO)
})
$('.dpt.bloco_alteracao .dpt').each(function () {
@ -327,5 +391,7 @@ export default {
ReadCookie,
insertWaitAjax,
InitViewTAs,
lerEscalaFonte,
aplicarEscalaFonte,
DispositivoSearch
}

4
frontend/src/__apps/compilacao/js/old/compilacao_edit.js

@ -244,7 +244,9 @@ window.DispositivoEdit = function () {
if (editortype !== 'construct') {
dpt_form.html(data)
if (editortype === 'tinymce') {
window.initTextRichEditor(null, false, false)
window.initTextRichEditor(null, false, false).then(function () {
window.aplicarEscalaFonte(window.lerEscalaFonte())
})
}
// OptionalCustomFrontEnd().init()
}

9
frontend/src/__apps/compilacao/scss/compilacao.scss

@ -164,12 +164,21 @@ a:link:after, a:visited:after {
}
.cp {
/* --cp-font-scale: escala de leitura escolhida pelo usuário (a/A) */
font-size: calc(100% * var(--cp-font-scale, 1));
.dpt-form textarea.form-control {
font-size: calc(1rem * var(--cp-font-scale, 1));
}
.vigencia-active {
margin-top: 30px;
display: block;
}
.cp-linha-vigencias {
/* navegação entre vigências não acompanha a escala de leitura */
font-size: calc(100% / var(--cp-font-scale, 1));
display: flex;
flex-direction: row;
list-style: none;

25
frontend/src/__global/js/tinymce/index.js

@ -14,7 +14,23 @@ import 'tinymce/plugins/table'
import './langs/pt_BR.js'
window.tinymce = tinymce
window.initTextRichEditor = function (elements, readonly = false, paste_as_text = false) {
// Títulos (h1-h6) vindos de estilos de título do Word ou de páginas web
// chegam em negrito e não podem ser desfeitos pelo botão de negrito nem
// por "remover formatação". Converte-os em parágrafos, mantendo o alinhamento.
function colarTitulosComoParagrafo (editor, args) {
args.node.querySelectorAll('h1, h2, h3, h4, h5, h6').forEach(function (titulo) {
const paragrafo = document.createElement('p')
if (titulo.style.textAlign) {
paragrafo.style.textAlign = titulo.style.textAlign
}
while (titulo.firstChild) {
paragrafo.appendChild(titulo.firstChild)
}
titulo.replaceWith(paragrafo)
})
}
window.initTextRichEditor = function (elements, readonly = false, paste_as_text = false, paste_titulos_como_paragrafo = false) {
const configTinymce = {
selector: elements === null || elements === undefined ? 'textarea' : elements,
language: 'pt_BR',
@ -22,14 +38,17 @@ window.initTextRichEditor = function (elements, readonly = false, paste_as_text
forced_root_block: 'p',
paste_as_text,
plugins: 'table lists advlist link code',
toolbar: 'undo redo | styleselect | bold italic | alignleft aligncenter alignright alignjustify | bullist numlist outdent indent | link | code | removeformat ',
toolbar: 'undo redo | blocks | bold italic | alignleft aligncenter alignright alignjustify | bullist numlist outdent indent | link | code | removeformat ',
menubar: 'file edit view insert format table',
license_key: 'gpl'
}
if (paste_titulos_como_paragrafo) {
configTinymce.paste_postprocess = colarTitulosComoParagrafo
}
if (readonly) {
configTinymce.readonly = 1
configTinymce.menubar = false
configTinymce.toolbar = false
}
window.tinymce.init(configTinymce)
return window.tinymce.init(configTinymce)
}

20
sapl/audiencia/forms.py

@ -10,9 +10,9 @@ from django.utils.translation import ugettext_lazy as _
from crispy_forms.layout import Button, Column, Fieldset, HTML, Layout
from sapl.audiencia.models import AudienciaPublica, TipoAudienciaPublica, AnexoAudienciaPublica
from sapl.base.models import Autor
from sapl.crispy_layout_mixin import form_actions, SaplFormHelper, SaplFormLayout, to_row
from sapl.materia.models import MateriaLegislativa, TipoMateriaLegislativa
from sapl.parlamentares.models import Parlamentar
from sapl.utils import timezone, FileFieldCheckMixin, validar_arquivo
@ -44,10 +44,10 @@ class AudienciaForm(FileFieldCheckMixin, forms.ModelForm):
widget=forms.HiddenInput(),
queryset=MateriaLegislativa.objects.all())
parlamentar_autor = forms.ModelChoiceField(
label=_("Parlamentar Autor"),
autor = forms.ModelChoiceField(
label=_("Autor"),
required=False,
queryset=Parlamentar.objects.all())
queryset=Autor.objects.all().order_by('nome'))
requerimento = forms.ModelChoiceField(
label=_("Requerimento"),
@ -58,7 +58,7 @@ class AudienciaForm(FileFieldCheckMixin, forms.ModelForm):
model = AudienciaPublica
fields = ['tipo', 'numero', 'ano', 'nome',
'tema', 'data', 'hora_inicio', 'hora_fim',
'observacao', 'audiencia_cancelada', 'parlamentar_autor', 'requerimento', 'url_audio',
'observacao', 'audiencia_cancelada', 'autor', 'requerimento', 'url_audio',
'url_video', 'upload_pauta', 'upload_ata',
'upload_anexo', 'tipo_materia', 'numero_materia',
'ano_materia', 'materia']
@ -85,7 +85,7 @@ class AudienciaForm(FileFieldCheckMixin, forms.ModelForm):
materia = cleaned_data['numero_materia']
ano_materia = cleaned_data['ano_materia']
tipo_materia = cleaned_data['tipo_materia']
parlamentar_autor = cleaned_data["parlamentar_autor"]
autor = cleaned_data["autor"]
requerimento = cleaned_data["requerimento"]
if cleaned_data["ano"] != cleaned_data["data"].year:
@ -157,12 +157,12 @@ class AudienciaForm(FileFieldCheckMixin, forms.ModelForm):
raise ValidationError(msg)
# requerimento é optativo
if parlamentar_autor and requerimento:
if parlamentar_autor.autor.first() not in requerimento.autores.all():
raise ValidationError("Parlamentar Autor selecionado não faz"
if autor and requerimento:
if autor not in requerimento.autores.all():
raise ValidationError("Autor selecionado não faz"
" parte da autoria do Requerimento "
"selecionado.")
elif parlamentar_autor:
elif autor:
raise ValidationError("Para informar um autor deve-se informar um requerimento.")
elif requerimento:
raise ValidationError("Para informar um requerimento deve-se informar um autor.")

59
sapl/audiencia/migrations/0021_auto_20260623_1938.py

@ -0,0 +1,59 @@
# Generated by Django 2.2.28 on 2026-06-23 22:38
from django.db import migrations, models
import django.db.models.deletion
def copia_parlamentar_para_autor(apps, schema_editor):
"""Converte o antigo parlamentar_autor no Autor genérico correspondente."""
AudienciaPublica = apps.get_model('audiencia', 'AudienciaPublica')
Autor = apps.get_model('base', 'Autor')
ContentType = apps.get_model('contenttypes', 'ContentType')
try:
ct = ContentType.objects.get(
app_label='parlamentares', model='parlamentar')
except ContentType.DoesNotExist:
return
for ap in AudienciaPublica.objects.filter(parlamentar_autor__isnull=False):
autor = Autor.objects.filter(
content_type=ct, object_id=ap.parlamentar_autor_id).first()
if autor:
ap.autor = autor
ap.save(update_fields=['autor'])
class Migration(migrations.Migration):
dependencies = [
('base', '0060_auto_20240812_1628'),
('audiencia', '0020_auto_20251201_1450'),
]
operations = [
migrations.AddField(
model_name='audienciapublica',
name='autor',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='base.Autor', verbose_name='Autor'),
),
migrations.RunPython(
copia_parlamentar_para_autor, migrations.RunPython.noop),
# Mantém a coluna parlamentar_autor no banco (removida apenas do estado
# do Django). Assim uma casa pode dar rollback da imagem sem perder o
# vínculo, e o código novo simplesmente ignora a coluna. O DROP
# definitivo fica para uma migração posterior, quando toda a frota
# estiver estável nesta versão.
migrations.SeparateDatabaseAndState(
state_operations=[
migrations.RemoveField(
model_name='audienciapublica',
name='parlamentar_autor',
),
],
database_operations=[],
),
migrations.AlterField(
model_name='audienciapublica',
name='ano',
field=models.PositiveSmallIntegerField(choices=[(2027, 2027), (2026, 2026), (2025, 2025), (2024, 2024), (2023, 2023), (2022, 2022), (2021, 2021), (2020, 2020), (2019, 2019), (2018, 2018), (2017, 2017), (2016, 2016), (2015, 2015), (2014, 2014), (2013, 2013), (2012, 2012), (2011, 2011), (2010, 2010), (2009, 2009), (2008, 2008), (2007, 2007), (2006, 2006), (2005, 2005), (2004, 2004), (2003, 2003), (2002, 2002), (2001, 2001), (2000, 2000), (1999, 1999), (1998, 1998), (1997, 1997), (1996, 1996), (1995, 1995), (1994, 1994), (1993, 1993), (1992, 1992), (1991, 1991), (1990, 1990), (1989, 1989), (1988, 1988), (1987, 1987), (1986, 1986), (1985, 1985), (1984, 1984), (1983, 1983), (1982, 1982), (1981, 1981), (1980, 1980), (1979, 1979), (1978, 1978), (1977, 1977), (1976, 1976), (1975, 1975), (1974, 1974), (1973, 1973), (1972, 1972), (1971, 1971), (1970, 1970), (1969, 1969), (1968, 1968), (1967, 1967), (1966, 1966), (1965, 1965), (1964, 1964), (1963, 1963), (1962, 1962), (1961, 1961), (1960, 1960), (1959, 1959), (1958, 1958), (1957, 1957), (1956, 1956), (1955, 1955), (1954, 1954), (1953, 1953), (1952, 1952), (1951, 1951), (1950, 1950), (1949, 1949), (1948, 1948), (1947, 1947), (1946, 1946), (1945, 1945), (1944, 1944), (1943, 1943), (1942, 1942), (1941, 1941), (1940, 1940), (1939, 1939), (1938, 1938), (1937, 1937), (1936, 1936), (1935, 1935), (1934, 1934), (1933, 1933), (1932, 1932), (1931, 1931), (1930, 1930), (1929, 1929), (1928, 1928), (1927, 1927), (1926, 1926), (1925, 1925), (1924, 1924), (1923, 1923), (1922, 1922), (1921, 1921), (1920, 1920), (1919, 1919), (1918, 1918), (1917, 1917), (1916, 1916), (1915, 1915), (1914, 1914), (1913, 1913), (1912, 1912), (1911, 1911), (1910, 1910), (1909, 1909), (1908, 1908), (1907, 1907), (1906, 1906), (1905, 1905), (1904, 1904), (1903, 1903), (1902, 1902), (1901, 1901), (1900, 1900), (1899, 1899), (1898, 1898), (1897, 1897), (1896, 1896), (1895, 1895), (1894, 1894), (1893, 1893), (1892, 1892), (1891, 1891), (1890, 1890)], verbose_name='Ano'),
),
]

18
sapl/audiencia/migrations/0022_audienciapublica_data_ultima_atualizacao.py

@ -0,0 +1,18 @@
# Generated by Django 2.2.28 on 2026-06-26 15:20
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('audiencia', '0021_auto_20260623_1938'),
]
operations = [
migrations.AddField(
model_name='audienciapublica',
name='data_ultima_atualizacao',
field=models.DateTimeField(auto_now=True, null=True, verbose_name='Data da Última Atualização'),
),
]

11
sapl/audiencia/models.py

@ -3,7 +3,7 @@ from django.utils import timezone
from django.utils.translation import ugettext_lazy as _
from model_utils import Choices
from sapl.materia.models import MateriaLegislativa
from sapl.parlamentares.models import (CargoMesa, Parlamentar)
from sapl.parlamentares.models import CargoMesa
from sapl.utils import (RANGE_ANOS, YES_NO_CHOICES, SaplGenericRelation,
restringe_tipos_de_arquivo_txt, texto_upload_path,
@ -79,12 +79,15 @@ class AudienciaPublica(models.Model):
default=False,
choices=YES_NO_CHOICES,
verbose_name=_('Audiência Cancelada?'))
parlamentar_autor = models.ForeignKey(
Parlamentar,
autor = models.ForeignKey(
'base.Autor',
on_delete=models.PROTECT,
null=True,
blank=True,
verbose_name=_('Parlamentar Autor'))
verbose_name=_('Autor'))
data_ultima_atualizacao = models.DateTimeField(
blank=True, null=True, auto_now=True,
verbose_name=_('Data da Última Atualização'))
requerimento = models.ForeignKey(
MateriaLegislativa,
null=True,

25
sapl/comissoes/forms.py

@ -558,3 +558,28 @@ class DocumentoAcessorioEditForm(FileFieldCheckMixin, forms.ModelForm):
# raise ValidationError(f'Favor anexar arquivo em {nome_arquivo}')
return self.cleaned_data
class ReuniaoFilterSet(django_filters.FilterSet):
nome = django_filters.CharFilter(
lookup_expr='icontains', label=_('Nome da Reunião'))
tema = django_filters.CharFilter(
lookup_expr='icontains', label=_('Tema da Reunião'))
class Meta(FilterOverridesMetaMixin):
model = Reuniao
fields = ['numero', 'nome', 'tema', 'data']
def __init__(self, *args, **kwargs):
super(ReuniaoFilterSet, self).__init__(*args, **kwargs)
row1 = to_row([('numero', 3), ('nome', 5), ('tema', 4)])
row2 = to_row([('data', 6)])
self.form.helper = SaplFormHelper()
self.form.helper.form_method = 'GET'
self.form.helper.layout = Layout(
Fieldset(_('Pesquisar Reunião'),
row1, row2,
form_actions(label=_('Pesquisar'))))

18
sapl/comissoes/migrations/0031_comissao_data_ultima_atualizacao.py

@ -0,0 +1,18 @@
# Generated by Django 2.2.28 on 2026-06-26 15:20
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('comissoes', '0030_auto_20231007_2149'),
]
operations = [
migrations.AddField(
model_name='comissao',
name='data_ultima_atualizacao',
field=models.DateTimeField(auto_now=True, null=True, verbose_name='Data da Última Atualização'),
),
]

3
sapl/comissoes/models.py

@ -34,6 +34,9 @@ class Comissao(models.Model):
tipo = models.ForeignKey(TipoComissao,
on_delete=models.PROTECT,
verbose_name=_('Tipo'))
data_ultima_atualizacao = models.DateTimeField(
blank=True, null=True, auto_now=True,
verbose_name=_('Data da Última Atualização'))
nome = models.CharField(max_length=100, verbose_name=_('Nome'))
sigla = models.CharField(max_length=10, verbose_name=_('Sigla'))
data_criacao = models.DateField(verbose_name=_('Data de Criação'))

11
sapl/comissoes/views.py

@ -22,7 +22,7 @@ from sapl.comissoes.forms import (ComissaoForm, ComposicaoForm,
ParticipacaoCreateForm,
ParticipacaoEditForm,
PautaReuniaoFilterSet, PautaReuniaoForm,
PeriodoForm, ReuniaoForm)
PeriodoForm, ReuniaoFilterSet, ReuniaoForm)
from sapl.crud.base import (Crud, CrudAux, MasterDetailCrud,
PermissionRequiredForAppCrudMixin, RP_DETAIL,
RP_LIST)
@ -216,7 +216,7 @@ class ReuniaoCrud(MasterDetailCrud):
public = [RP_LIST, RP_DETAIL, ]
class BaseMixin(MasterDetailCrud.BaseMixin):
list_field_names = ['data', 'nome', 'tema', 'upload_ata']
list_field_names = ['data', 'nome', 'tema', 'upload_pauta', 'upload_ata']
class DetailView(MasterDetailCrud.DetailView):
template_name = "comissoes/reuniao_detail.html"
@ -248,6 +248,11 @@ class ReuniaoCrud(MasterDetailCrud):
logger = logging.getLogger(__name__)
paginate_by = 10
def get_queryset(self):
qs = super().get_queryset()
self.filterset = ReuniaoFilterSet(self.request.GET, queryset=qs)
return self.filterset.qs
def take_reuniao_pk(self):
username = self.request.user.username
@ -276,6 +281,8 @@ class ReuniaoCrud(MasterDetailCrud):
context['documentoacessorio_set'] = DocumentoAcessorio.objects.filter(
reuniao__pk=context['reuniao_pk']
).order_by('id')
context['form'] = self.filterset.form
return context
class UpdateView(MasterDetailCrud.UpdateView):

5
sapl/crud/base.py

@ -863,6 +863,11 @@ class CrudDetailView(PermissionRequiredContainerCrudMixin,
context[context_object_name] = self.object
context.update(kwargs)
# Mensagem de atualidade (PNTP): data de última atualização da entidade,
# quando o modelo possuir o campo. Consumido pelo banner em base.html.
context['data_atualizacao_conteudo'] = getattr(
self.object, 'data_ultima_atualizacao', None)
return context
@property

21
sapl/materia/migrations/0088_fix_view_materiaemtramitacao.py

@ -44,6 +44,27 @@ class Migration(migrations.Migration):
migrations.RunSQL(sql=_NEW_VIEW, reverse_sql=_OLD_VIEW),
migrations.SeparateDatabaseAndState(
database_operations=[
# Um CREATE INDEX CONCURRENTLY interrompido deixa o índice
# criado porém inválido, e o IF NOT EXISTS abaixo o pularia
# na próxima execução. DROP INDEX sem CONCURRENTLY porque
# este não roda dentro de um bloco DO.
migrations.RunSQL(
sql="""
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM pg_index i
JOIN pg_class c ON c.oid = i.indexrelid
WHERE c.relname = 'tram_materia_id_desc'
AND NOT i.indisvalid
) THEN
DROP INDEX tram_materia_id_desc;
END IF;
END $$;
""",
reverse_sql=migrations.RunSQL.noop,
),
migrations.RunSQL(
sql="""
CREATE INDEX CONCURRENTLY IF NOT EXISTS

18
sapl/parlamentares/migrations/0046_parlamentar_data_ultima_atualizacao.py

@ -0,0 +1,18 @@
# Generated by Django 2.2.28 on 2026-06-26 15:20
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('parlamentares', '0045_auto_20251201_1531'),
]
operations = [
migrations.AddField(
model_name='parlamentar',
name='data_ultima_atualizacao',
field=models.DateTimeField(auto_now=True, null=True, verbose_name='Data da Última Atualização'),
),
]

4
sapl/parlamentares/models.py

@ -193,6 +193,10 @@ class Parlamentar(models.Model):
SEXO_CHOICE = ((FEMININO, _('Feminino')),
(MASCULINO, _('Masculino')))
data_ultima_atualizacao = models.DateTimeField(
blank=True, null=True, auto_now=True,
verbose_name=_('Data da Última Atualização'))
nivel_instrucao = models.ForeignKey(
NivelInstrucao,
blank=True,

28
sapl/protocoloadm/migrations/0046_auto_20260626_1220.py

@ -0,0 +1,28 @@
# Generated by Django 2.2.28 on 2026-06-26 15:20
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('protocoloadm', '0045_auto_20240711_1405'),
]
operations = [
migrations.AddField(
model_name='documentoadministrativo',
name='data_ultima_atualizacao',
field=models.DateTimeField(auto_now=True, null=True, verbose_name='Data da Última Atualização'),
),
migrations.AlterField(
model_name='documentoadministrativo',
name='ano',
field=models.PositiveSmallIntegerField(choices=[(2027, 2027), (2026, 2026), (2025, 2025), (2024, 2024), (2023, 2023), (2022, 2022), (2021, 2021), (2020, 2020), (2019, 2019), (2018, 2018), (2017, 2017), (2016, 2016), (2015, 2015), (2014, 2014), (2013, 2013), (2012, 2012), (2011, 2011), (2010, 2010), (2009, 2009), (2008, 2008), (2007, 2007), (2006, 2006), (2005, 2005), (2004, 2004), (2003, 2003), (2002, 2002), (2001, 2001), (2000, 2000), (1999, 1999), (1998, 1998), (1997, 1997), (1996, 1996), (1995, 1995), (1994, 1994), (1993, 1993), (1992, 1992), (1991, 1991), (1990, 1990), (1989, 1989), (1988, 1988), (1987, 1987), (1986, 1986), (1985, 1985), (1984, 1984), (1983, 1983), (1982, 1982), (1981, 1981), (1980, 1980), (1979, 1979), (1978, 1978), (1977, 1977), (1976, 1976), (1975, 1975), (1974, 1974), (1973, 1973), (1972, 1972), (1971, 1971), (1970, 1970), (1969, 1969), (1968, 1968), (1967, 1967), (1966, 1966), (1965, 1965), (1964, 1964), (1963, 1963), (1962, 1962), (1961, 1961), (1960, 1960), (1959, 1959), (1958, 1958), (1957, 1957), (1956, 1956), (1955, 1955), (1954, 1954), (1953, 1953), (1952, 1952), (1951, 1951), (1950, 1950), (1949, 1949), (1948, 1948), (1947, 1947), (1946, 1946), (1945, 1945), (1944, 1944), (1943, 1943), (1942, 1942), (1941, 1941), (1940, 1940), (1939, 1939), (1938, 1938), (1937, 1937), (1936, 1936), (1935, 1935), (1934, 1934), (1933, 1933), (1932, 1932), (1931, 1931), (1930, 1930), (1929, 1929), (1928, 1928), (1927, 1927), (1926, 1926), (1925, 1925), (1924, 1924), (1923, 1923), (1922, 1922), (1921, 1921), (1920, 1920), (1919, 1919), (1918, 1918), (1917, 1917), (1916, 1916), (1915, 1915), (1914, 1914), (1913, 1913), (1912, 1912), (1911, 1911), (1910, 1910), (1909, 1909), (1908, 1908), (1907, 1907), (1906, 1906), (1905, 1905), (1904, 1904), (1903, 1903), (1902, 1902), (1901, 1901), (1900, 1900), (1899, 1899), (1898, 1898), (1897, 1897), (1896, 1896), (1895, 1895), (1894, 1894), (1893, 1893), (1892, 1892), (1891, 1891), (1890, 1890)], verbose_name='Ano'),
),
migrations.AlterField(
model_name='protocolo',
name='ano',
field=models.PositiveSmallIntegerField(choices=[(2027, 2027), (2026, 2026), (2025, 2025), (2024, 2024), (2023, 2023), (2022, 2022), (2021, 2021), (2020, 2020), (2019, 2019), (2018, 2018), (2017, 2017), (2016, 2016), (2015, 2015), (2014, 2014), (2013, 2013), (2012, 2012), (2011, 2011), (2010, 2010), (2009, 2009), (2008, 2008), (2007, 2007), (2006, 2006), (2005, 2005), (2004, 2004), (2003, 2003), (2002, 2002), (2001, 2001), (2000, 2000), (1999, 1999), (1998, 1998), (1997, 1997), (1996, 1996), (1995, 1995), (1994, 1994), (1993, 1993), (1992, 1992), (1991, 1991), (1990, 1990), (1989, 1989), (1988, 1988), (1987, 1987), (1986, 1986), (1985, 1985), (1984, 1984), (1983, 1983), (1982, 1982), (1981, 1981), (1980, 1980), (1979, 1979), (1978, 1978), (1977, 1977), (1976, 1976), (1975, 1975), (1974, 1974), (1973, 1973), (1972, 1972), (1971, 1971), (1970, 1970), (1969, 1969), (1968, 1968), (1967, 1967), (1966, 1966), (1965, 1965), (1964, 1964), (1963, 1963), (1962, 1962), (1961, 1961), (1960, 1960), (1959, 1959), (1958, 1958), (1957, 1957), (1956, 1956), (1955, 1955), (1954, 1954), (1953, 1953), (1952, 1952), (1951, 1951), (1950, 1950), (1949, 1949), (1948, 1948), (1947, 1947), (1946, 1946), (1945, 1945), (1944, 1944), (1943, 1943), (1942, 1942), (1941, 1941), (1940, 1940), (1939, 1939), (1938, 1938), (1937, 1937), (1936, 1936), (1935, 1935), (1934, 1934), (1933, 1933), (1932, 1932), (1931, 1931), (1930, 1930), (1929, 1929), (1928, 1928), (1927, 1927), (1926, 1926), (1925, 1925), (1924, 1924), (1923, 1923), (1922, 1922), (1921, 1921), (1920, 1920), (1919, 1919), (1918, 1918), (1917, 1917), (1916, 1916), (1915, 1915), (1914, 1914), (1913, 1913), (1912, 1912), (1911, 1911), (1910, 1910), (1909, 1909), (1908, 1908), (1907, 1907), (1906, 1906), (1905, 1905), (1904, 1904), (1903, 1903), (1902, 1902), (1901, 1901), (1900, 1900), (1899, 1899), (1898, 1898), (1897, 1897), (1896, 1896), (1895, 1895), (1894, 1894), (1893, 1893), (1892, 1892), (1891, 1891), (1890, 1890)], verbose_name='Ano do Protocolo'),
),
]

3
sapl/protocoloadm/models.py

@ -158,6 +158,9 @@ class DocumentoAdministrativo(models.Model):
tipo = models.ForeignKey(
TipoDocumentoAdministrativo, on_delete=models.PROTECT,
verbose_name=_('Tipo Documento'))
data_ultima_atualizacao = models.DateTimeField(
blank=True, null=True, auto_now=True,
verbose_name=_('Data da Última Atualização'))
numero = models.PositiveIntegerField(verbose_name=_('Número'))
complemento = models.CharField(max_length=10, blank=True,

28
sapl/relatorios/views.py

@ -49,8 +49,7 @@ 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_conteudo_multimidia,
get_votos_nominais)
get_correspondencias)
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, \
@ -561,9 +560,9 @@ def get_sessao_plenaria(sessao, casa, user):
.order_by('cargo_id'):
# 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)
# mesmo PDF já usavam filiacao_data. Unifica no critério das listas
# de presença e do Resumo: o partido vigente em data_inicio.
sigla = filiacao_data(composicao.parlamentar, sessao.data_inicio)
lst_mesa.append({
'nom_parlamentar': composicao.parlamentar.nome_parlamentar,
'sgl_partido': sigla,
@ -719,7 +718,7 @@ def get_sessao_plenaria(sessao, casa, user):
lst_expediente_materia_vot_nom = []
materias_expediente_votacao_nominal = ExpedienteMateria.objects.filter(sessao_plenaria=sessao, tipo_votacao=2) \
.order_by('-materia')
.order_by('numero_ordem')
for mevn in materias_expediente_votacao_nominal:
votos_materia = []
@ -744,7 +743,7 @@ def get_sessao_plenaria(sessao, casa, user):
"num_ordem": orador_expediente.numero_ordem,
"nom_parlamentar": parlamentar.nome_parlamentar,
"observacao": orador_expediente.observacao,
"sgl_partido": filiacao_data(parlamentar, data_sessao)
"sgl_partido": filiacao_data(parlamentar, sessao.data_inicio)
})
# Lista presença na ordem do dia
@ -830,7 +829,7 @@ def get_sessao_plenaria(sessao, casa, user):
lst_votacao_vot_nom = []
materias_ordem_dia_votacao_nominal = OrdemDia.objects.filter(sessao_plenaria=sessao, tipo_votacao=2) \
.order_by('-materia')
.order_by('numero_ordem')
for modvn in materias_ordem_dia_votacao_nominal:
votos_materia_od = []
@ -860,7 +859,7 @@ def get_sessao_plenaria(sessao, casa, user):
"num_ordem": orador_ordemdia.numero_ordem,
"nome_parlamentar": parlamentar_orador.nome_parlamentar,
"observacao": orador_ordemdia.observacao,
"sigla": filiacao_data(parlamentar_orador, data_sessao)
"sigla": filiacao_data(parlamentar_orador, sessao.data_inicio)
})
# Lista dos oradores nas Explicações Pessoais
@ -871,7 +870,7 @@ def get_sessao_plenaria(sessao, casa, user):
"num_ordem": orador.numero_ordem,
"nom_parlamentar": parlamentar.nome_parlamentar,
"observacao": orador.observacao,
"sgl_partido": filiacao_data(parlamentar, data_sessao)
"sgl_partido": filiacao_data(parlamentar, sessao.data_inicio)
})
# Ocorrências da Sessão
@ -1447,9 +1446,6 @@ 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))
@ -1466,10 +1462,6 @@ def resumo_ata_pdf(request, pk):
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})
@ -1842,7 +1834,7 @@ def relatorio_materia_tramitacao(request, pk):
'materia': materia_legislativa,
'ano': materia_legislativa.ano,
'numero': materia_legislativa.numero,
'autor': materia_legislativa.autores.first(),
'autores': materia_legislativa.autores.all(),
'tipo': materia_legislativa.tipo.descricao,
'rodape': rodape,
'data': dt.today().strftime('%d/%m/%Y'),

11
sapl/sessao/models.py

@ -157,8 +157,12 @@ def restringe_sessoes_visiveis(qs, user):
"""Restringe o queryset às sessões visíveis na consulta pública.
Usuário autenticado enxerga todas. Para o público, ficam ocultas as
sessões cadastradas com antecedência, ou seja, as marcadas como não
iniciadas e sem pauta publicada.
sessões cadastradas com antecedência, ou seja, as de data futura
marcadas como não iniciadas e sem pauta publicada.
A data é o que distingue o cadastro antecipado: sessões de hoje ou de
datas passadas ficam sempre visíveis, mesmo com `iniciada` em Não, porque
há Casas que não marcam o campo nas sessões já realizadas.
`iniciada` nulo conta como visível: o default do campo só surgiu na
migração 0027, que não preencheu as linhas já existentes, então bases
@ -170,7 +174,8 @@ def restringe_sessoes_visiveis(qs, user):
return qs.filter(
Q(publicar_pauta=True) |
Q(iniciada=True) |
Q(iniciada__isnull=True))
Q(iniciada__isnull=True) |
Q(data_inicio__lte=timezone.localdate()))
class SessaoPlenaria(models.Model):

54
sapl/sessao/tests/test_sessao_view.py

@ -1,9 +1,10 @@
import pytest
from datetime import date
from datetime import timedelta
from django.contrib.auth import get_user_model
from django.contrib.auth.models import AnonymousUser
from django.db import IntegrityError, transaction
from django.urls import reverse
from django.utils import timezone
from django.utils.translation import ugettext_lazy as _
from model_bakery import baker
@ -155,13 +156,22 @@ class TestResumoView():
assert resultado_get_ocorrencia['ocorrencias_da_sessao'][0] == ocorrencia
# Sessão cadastrada com antecedência: data futura, sem pauta e não iniciada.
def data_futura():
return timezone.localdate() + timedelta(days=30)
@pytest.mark.django_db(transaction=False)
def test_visiveis_para_oculta_do_anonimo_apenas_a_sessao_previa():
previa = baker.make(SessaoPlenaria, iniciada=False, publicar_pauta=False)
com_pauta = baker.make(SessaoPlenaria, iniciada=False, publicar_pauta=True)
iniciada = baker.make(SessaoPlenaria, iniciada=True, publicar_pauta=False)
previa = baker.make(SessaoPlenaria, iniciada=False, publicar_pauta=False,
data_inicio=data_futura())
com_pauta = baker.make(SessaoPlenaria, iniciada=False, publicar_pauta=True,
data_inicio=data_futura())
iniciada = baker.make(SessaoPlenaria, iniciada=True, publicar_pauta=False,
data_inicio=data_futura())
# Sessões anteriores à migração 0027 ficaram com `iniciada` em NULL.
legada = baker.make(SessaoPlenaria, iniciada=None, publicar_pauta=False)
legada = baker.make(SessaoPlenaria, iniciada=None, publicar_pauta=False,
data_inicio=data_futura())
visiveis = restringe_sessoes_visiveis(
SessaoPlenaria.objects.all(), AnonymousUser())
@ -172,9 +182,25 @@ def test_visiveis_para_oculta_do_anonimo_apenas_a_sessao_previa():
assert legada in visiveis
@pytest.mark.django_db(transaction=False)
def test_visiveis_para_mostra_ao_anonimo_sessao_realizada_nao_marcada():
# Casas que não marcam "Sessão iniciada?" nas sessões já realizadas.
passada = baker.make(SessaoPlenaria, iniciada=False, publicar_pauta=False,
data_inicio=timezone.localdate() - timedelta(days=1))
de_hoje = baker.make(SessaoPlenaria, iniciada=False, publicar_pauta=False,
data_inicio=timezone.localdate())
visiveis = restringe_sessoes_visiveis(
SessaoPlenaria.objects.all(), AnonymousUser())
assert passada in visiveis
assert de_hoje in visiveis
@pytest.mark.django_db(transaction=False)
def test_visiveis_para_nao_oculta_nada_de_usuario_autenticado():
previa = baker.make(SessaoPlenaria, iniciada=False, publicar_pauta=False)
previa = baker.make(SessaoPlenaria, iniciada=False, publicar_pauta=False,
data_inicio=data_futura())
operador = baker.make(get_user_model())
@ -185,12 +211,12 @@ def test_visiveis_para_nao_oculta_nada_de_usuario_autenticado():
@pytest.mark.django_db(transaction=False)
def test_pesquisar_sessao_nao_lista_sessao_previa_para_anonimo(client):
previa = baker.make(SessaoPlenaria, iniciada=False, publicar_pauta=False,
data_inicio=date(2025, 11, 5))
data_inicio=data_futura())
iniciada = baker.make(SessaoPlenaria, iniciada=True, publicar_pauta=False,
data_inicio=date(2025, 11, 5))
data_inicio=data_futura())
response = client.get(reverse('sapl.sessao:pesquisar_sessao'),
{'data_inicio__year': '2025'})
{'data_inicio__year': data_futura().year})
assert response.status_code == 200
assert previa not in response.context['object_list']
@ -200,10 +226,10 @@ def test_pesquisar_sessao_nao_lista_sessao_previa_para_anonimo(client):
@pytest.mark.django_db(transaction=False)
def test_pesquisar_sessao_lista_sessao_previa_para_autenticado(admin_client):
previa = baker.make(SessaoPlenaria, iniciada=False, publicar_pauta=False,
data_inicio=date(2025, 11, 5))
data_inicio=data_futura())
response = admin_client.get(reverse('sapl.sessao:pesquisar_sessao'),
{'data_inicio__year': '2025'})
{'data_inicio__year': data_futura().year})
assert response.status_code == 200
assert previa in response.context['object_list']
@ -211,7 +237,8 @@ def test_pesquisar_sessao_lista_sessao_previa_para_autenticado(admin_client):
@pytest.mark.django_db(transaction=False)
def test_detail_sessao_previa_indisponivel_para_anonimo(client):
previa = baker.make(SessaoPlenaria, iniciada=False, publicar_pauta=False)
previa = baker.make(SessaoPlenaria, iniciada=False, publicar_pauta=False,
data_inicio=data_futura())
response = client.get(reverse('sapl.sessao:sessaoplenaria_detail',
kwargs={'pk': previa.pk}))
@ -221,7 +248,8 @@ def test_detail_sessao_previa_indisponivel_para_anonimo(client):
@pytest.mark.django_db(transaction=False)
def test_resumo_de_sessao_previa_indisponivel_para_anonimo(client):
previa = baker.make(SessaoPlenaria, iniciada=False, publicar_pauta=False)
previa = baker.make(SessaoPlenaria, iniciada=False, publicar_pauta=False,
data_inicio=data_futura())
response = client.get(reverse('sapl.sessao:resumo',
kwargs={'pk': previa.pk}))

147
sapl/sessao/views.py

@ -260,8 +260,6 @@ def customize_link_materia(context, pk, has_permission, is_expediente):
turno = t[1]
break
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)
@ -287,9 +285,17 @@ 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 = bool(obj._votacao_prefetch)
exist_retirada = bool(obj._retirada_prefetch)
exist_leitura = bool(obj._leitura_prefetch)
# A matéria do item pode ter sido trocada depois de votada: só contam
# os registros da matéria atual.
votacoes = [r for r in obj._votacao_prefetch
if r.materia_id == obj.materia_id]
retiradas = [r for r in obj._retirada_prefetch
if r.materia_id == obj.materia_id]
leituras = [r for r in obj._leitura_prefetch
if r.materia_id == obj.materia_id]
exist_resultado = bool(votacoes)
exist_retirada = bool(retiradas)
exist_leitura = bool(leituras)
if (obj.tipo_votacao != LEITURA and not exist_resultado and not exist_retirada) or \
(obj.tipo_votacao == LEITURA and not exist_leitura):
@ -411,7 +417,7 @@ def customize_link_materia(context, pk, has_permission, is_expediente):
resultado = '''Não há resultado'''
elif exist_retirada:
retirada = obj._retirada_prefetch[-1]
retirada = retiradas[-1]
retirada_descricao = retirada.tipo_de_retirada.descricao
retirada_observacao = retirada.observacao
url = reverse('sapl.sessao:retiradapauta_detail',
@ -423,11 +429,11 @@ def customize_link_materia(context, pk, has_permission, is_expediente):
else:
if obj.tipo_votacao == LEITURA:
resultado = obj._leitura_prefetch[-1]
resultado = leituras[-1]
resultado_descricao = "Matéria lida"
resultado_observacao = resultado.observacao
else:
resultado = obj._votacao_prefetch[-1]
resultado = votacoes[-1]
resultado_descricao = resultado.tipo_resultado_votacao.nome
resultado_observacao = resultado.observacao
@ -833,57 +839,19 @@ class MateriaOrdemDiaCrud(MasterDetailCrud):
layout_key = 'OrdemDiaDetail'
class ListView(MasterDetailCrud.ListView):
paginate_by = 100
paginate_by = None
ordering = ['numero_ordem', 'materia', 'resultado']
def get_context_data(self, **kwargs):
self.paginate_by = 50 if self.object_list.count() > 500 else 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',
),
)
return prefetch_materias_sessao(
super().get_queryset().select_related('sessao_plenaria'),
com_situacao=False)
def recuperar_materia(request):
@ -943,10 +911,11 @@ class ExpedienteMateriaCrud(MasterDetailCrud):
'resultado']
class ListView(MasterDetailCrud.ListView):
paginate_by = 100
paginate_by = None
ordering = ['numero_ordem', 'materia', 'resultado']
def get_context_data(self, **kwargs):
self.paginate_by = 50 if self.object_list.count() > 500 else None
context = super().get_context_data(**kwargs)
if self.request.GET.get('page'):
context['page'] = self.request.GET.get('page')
@ -954,48 +923,9 @@ class ExpedienteMateriaCrud(MasterDetailCrud):
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',
),
)
return prefetch_materias_sessao(
super().get_queryset().select_related('sessao_plenaria'),
com_situacao=False)
class CreateView(MasterDetailCrud.CreateView):
form_class = ExpedienteMateriaForm
@ -2147,25 +2077,24 @@ def get_expedientes(sessao_plenaria):
return ({'expedientes': expedientes})
def prefetch_materias_sessao(qs):
"""Prefetches compartilhados por `get_materias_expediente` e
`get_materias_ordem_do_dia`.
def prefetch_materias_sessao(qs, com_situacao=True):
"""Prefetches compartilhados por `get_materias_expediente`,
`get_materias_ordem_do_dia` e pelas ListViews de OrdemDia e
ExpedienteMateria.
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.
`com_situacao=False` dispensa o prefetch de `MateriaEmTramitacao`, que
as ListViews não usam.
"""
return qs.select_related('materia', 'materia__tipo').prefetch_related(
prefetches = [
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',
@ -2181,7 +2110,15 @@ def prefetch_materias_sessao(qs):
queryset=RetiradaPauta.objects.select_related('tipo_de_retirada'),
to_attr='_retirada_prefetch'),
Prefetch('registroleitura_set', to_attr='_leitura_prefetch'),
)
]
if com_situacao:
prefetches.append(Prefetch(
'materia__materiaemtramitacao_set',
queryset=MateriaEmTramitacao.objects.select_related(
'tramitacao', 'tramitacao__status'),
to_attr='_met_prefetch'))
return qs.select_related('materia', 'materia__tipo').prefetch_related(
*prefetches)
def agrupa_votos_por_materia(campo, ids):
@ -2521,7 +2458,7 @@ def get_votos_nominais(sessao_plenaria_id, model, campo):
"""
materias = list(model.objects.filter(
sessao_plenaria_id=sessao_plenaria_id, tipo_votacao=2
).select_related('materia').order_by('-materia'))
).select_related('materia').order_by('numero_ordem'))
votos_por_materia = defaultdict(list)
if materias:

4
sapl/templates/audiencia/layouts.yaml

@ -6,7 +6,7 @@ AudienciaPublica:
{% trans 'Dados' %}:
- tipo_materia numero_materia ano_materia
- tipo:4 data:2 hora_inicio:3 hora_fim:3
- parlamentar_autor requerimento
- autor requerimento
- url_audio url_video
- upload_pauta upload_ata upload_anexo
- observacao
@ -19,7 +19,7 @@ AudienciaPublicaDetail:
{% trans 'Dados' %}:
- materia tipo
- data hora_inicio hora_fim
- parlamentar_autor requerimento
- autor requerimento
- url_audio url_video
- upload_pauta upload_ata upload_anexo
- observacao

7
sapl/templates/base.html

@ -40,6 +40,13 @@
<body>
<div class="page fadein">
{% if not request|has_iframe %}
<div class="info-atualizacao hidden-print" style="background-color:#0d2b45;color:#fff;text-align:center;padding:6px 12px;font-size:14px;font-weight:600;">
{% trans 'Informações foram atualizadas em' %}
{% if data_atualizacao_conteudo %}{{ data_atualizacao_conteudo|date:"d/m/Y" }}{% else %}{% now "d/m/Y" %}{% endif %}
</div>
{% endif %}
{% if not sapl_as_sapn or user.is_authenticated%}
{% if not request|has_iframe %}
{% block navigation %}

2
sapl/templates/compilacao/dispositivo_form_alteracao.html

@ -7,6 +7,6 @@
'url_form': '{% url 'sapl.compilacao:dispositivo_search_form'%}',
'text_button': '{% trans 'Definir Dispositivo'%}'
});
initTextRichEditor();
initTextRichEditor(null, false, false, true);
</script>
{% endblock %}

2
sapl/templates/compilacao/dispositivo_form_edicao_basica.html

@ -31,6 +31,6 @@
});
}
initTextRichEditor();
initTextRichEditor(null, false, false, true);
</script>
{% endblock %}

6
sapl/templates/compilacao/text_edit.html

@ -17,10 +17,16 @@
<a href="{% url 'sapl.compilacao:ta_text_edit' object.pk %}?{% if object.editing_locked %}unlock{%else%}lock{% endif %}" class="btn {% if object.editing_locked %}btn-outline-danger{%else%}btn-outline-primary{% endif %}">{% if object.editing_locked %}{% trans 'Desbloquear Edição' %}{%else%}{% trans 'Publicar Texto' %}{% endif %}</a>
{% endif %}
</div>
<div class="d-flex">
<div class="actions btn-group mr-2" role="group">
<a class="btn btn-outline-primary" id="btn_font_menos" title="{% trans 'Diminuir tamanho da letra' %}">a</a>
<a class="btn btn-outline-primary" id="btn_font_mais" title="{% trans 'Aumentar tamanho da letra' %}">A</a>
</div>
<div class="actions btn-group dropleft" role="group">
{% include 'compilacao/textoarticulado_menu_config.html' %}
</div>
</div>
</div>
{% if object.dispositivos_set.count == 1 %}
{% with view.materia_da_norma_deste_texto_articulado as materia %}
{% if materia %}

2
sapl/templates/relatorios/relatorio_materia_tramitacao.html

@ -9,7 +9,7 @@
<h2>Histórico de Tramitações da Matéria: {{ numero }}/{{ ano }}</h2>
Tipo de matéria: {{ tipo }}<br />
Autor: {{ autor }}<br />
{% if autores|length > 1 %}Autores: {% else %}Autor: {% endif %}{{ autores|join:", " }}<br />
<table class="grayTable">
{% for tramitacao in object%}

7
sapl/templates/sessao/sessaoplenaria_filter.html

@ -53,6 +53,13 @@
{% if s.upload_ata %}
<strong><a href="{{s.upload_ata.url}}">Ata da Sessão</strong></a></br>
{% endif %}
{% if s.upload_pauta %}
<strong><a href="{{s.upload_pauta.url}}">Pauta da Sessão</strong></a></br>
{% endif %}
{% if s.upload_anexo %}
<strong><a href="{{s.upload_anexo.url}}">Anexo da Sessão</strong></a></br>
{% endif %}
<strong><a href="{% url 'sapl.sessao:resumo' s.id %}">Votações Nominais</strong></a></br>
</td>
</tr>
{% endfor %}

9
sapl/utils.py

@ -623,6 +623,15 @@ TIPOS_TEXTO_PERMITIDOS = (
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/xml',
'text/xml',
# Formatos editáveis de planilha/CSV (atende à gravação de relatórios do PNTP).
'text/csv',
'application/csv',
'text/comma-separated-values',
'application/vnd.ms-excel', # .xls
'application/x-ole-storage', # .xls (detecção OLE2 legada do libmagic)
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', # .xlsx
'application/vnd.oasis.opendocument.spreadsheet', # .ods / calc
'application/x-vnd.oasis.opendocument.spreadsheet',
)
TIPOS_IMG_PERMITIDOS = (

Loading…
Cancel
Save