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. 10
      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 () { function InitViewTAs () {
setTimeout(function () { setTimeout(function () {
var href = location.href.split('#') var href = location.href.split('#')
@ -297,11 +360,12 @@ function InitViewTAs () {
} }
}, 100) }, 100)
aplicarEscalaFonte(lerEscalaFonte())
$('#btn_font_menos').click(function () { $('#btn_font_menos').click(function () {
$('.dpt').css('font-size', '-=1') salvarEscalaFonte(lerEscalaFonte() - EscalaFonte.PASSO)
}) })
$('#btn_font_mais').click(function () { $('#btn_font_mais').click(function () {
$('.dpt').css('font-size', '+=1') salvarEscalaFonte(lerEscalaFonte() + EscalaFonte.PASSO)
}) })
$('.dpt.bloco_alteracao .dpt').each(function () { $('.dpt.bloco_alteracao .dpt').each(function () {
@ -327,5 +391,7 @@ export default {
ReadCookie, ReadCookie,
insertWaitAjax, insertWaitAjax,
InitViewTAs, InitViewTAs,
lerEscalaFonte,
aplicarEscalaFonte,
DispositivoSearch DispositivoSearch
} }

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

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

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

@ -164,12 +164,21 @@ a:link:after, a:visited:after {
} }
.cp { .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 { .vigencia-active {
margin-top: 30px; margin-top: 30px;
display: block; display: block;
} }
.cp-linha-vigencias { .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; display: flex;
flex-direction: row; flex-direction: row;
list-style: none; list-style: none;

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

@ -14,7 +14,23 @@ import 'tinymce/plugins/table'
import './langs/pt_BR.js' import './langs/pt_BR.js'
window.tinymce = tinymce 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 = { const configTinymce = {
selector: elements === null || elements === undefined ? 'textarea' : elements, selector: elements === null || elements === undefined ? 'textarea' : elements,
language: 'pt_BR', language: 'pt_BR',
@ -22,14 +38,17 @@ window.initTextRichEditor = function (elements, readonly = false, paste_as_text
forced_root_block: 'p', forced_root_block: 'p',
paste_as_text, paste_as_text,
plugins: 'table lists advlist link code', 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', menubar: 'file edit view insert format table',
license_key: 'gpl' license_key: 'gpl'
} }
if (paste_titulos_como_paragrafo) {
configTinymce.paste_postprocess = colarTitulosComoParagrafo
}
if (readonly) { if (readonly) {
configTinymce.readonly = 1 configTinymce.readonly = 1
configTinymce.menubar = false configTinymce.menubar = false
configTinymce.toolbar = 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 crispy_forms.layout import Button, Column, Fieldset, HTML, Layout
from sapl.audiencia.models import AudienciaPublica, TipoAudienciaPublica, AnexoAudienciaPublica 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.crispy_layout_mixin import form_actions, SaplFormHelper, SaplFormLayout, to_row
from sapl.materia.models import MateriaLegislativa, TipoMateriaLegislativa from sapl.materia.models import MateriaLegislativa, TipoMateriaLegislativa
from sapl.parlamentares.models import Parlamentar
from sapl.utils import timezone, FileFieldCheckMixin, validar_arquivo from sapl.utils import timezone, FileFieldCheckMixin, validar_arquivo
@ -44,10 +44,10 @@ class AudienciaForm(FileFieldCheckMixin, forms.ModelForm):
widget=forms.HiddenInput(), widget=forms.HiddenInput(),
queryset=MateriaLegislativa.objects.all()) queryset=MateriaLegislativa.objects.all())
parlamentar_autor = forms.ModelChoiceField( autor = forms.ModelChoiceField(
label=_("Parlamentar Autor"), label=_("Autor"),
required=False, required=False,
queryset=Parlamentar.objects.all()) queryset=Autor.objects.all().order_by('nome'))
requerimento = forms.ModelChoiceField( requerimento = forms.ModelChoiceField(
label=_("Requerimento"), label=_("Requerimento"),
@ -58,7 +58,7 @@ class AudienciaForm(FileFieldCheckMixin, forms.ModelForm):
model = AudienciaPublica model = AudienciaPublica
fields = ['tipo', 'numero', 'ano', 'nome', fields = ['tipo', 'numero', 'ano', 'nome',
'tema', 'data', 'hora_inicio', 'hora_fim', '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', 'url_video', 'upload_pauta', 'upload_ata',
'upload_anexo', 'tipo_materia', 'numero_materia', 'upload_anexo', 'tipo_materia', 'numero_materia',
'ano_materia', 'materia'] 'ano_materia', 'materia']
@ -85,7 +85,7 @@ class AudienciaForm(FileFieldCheckMixin, forms.ModelForm):
materia = cleaned_data['numero_materia'] materia = cleaned_data['numero_materia']
ano_materia = cleaned_data['ano_materia'] ano_materia = cleaned_data['ano_materia']
tipo_materia = cleaned_data['tipo_materia'] tipo_materia = cleaned_data['tipo_materia']
parlamentar_autor = cleaned_data["parlamentar_autor"] autor = cleaned_data["autor"]
requerimento = cleaned_data["requerimento"] requerimento = cleaned_data["requerimento"]
if cleaned_data["ano"] != cleaned_data["data"].year: if cleaned_data["ano"] != cleaned_data["data"].year:
@ -157,12 +157,12 @@ class AudienciaForm(FileFieldCheckMixin, forms.ModelForm):
raise ValidationError(msg) raise ValidationError(msg)
# requerimento é optativo # requerimento é optativo
if parlamentar_autor and requerimento: if autor and requerimento:
if parlamentar_autor.autor.first() not in requerimento.autores.all(): if autor not in requerimento.autores.all():
raise ValidationError("Parlamentar Autor selecionado não faz" raise ValidationError("Autor selecionado não faz"
" parte da autoria do Requerimento " " parte da autoria do Requerimento "
"selecionado.") "selecionado.")
elif parlamentar_autor: elif autor:
raise ValidationError("Para informar um autor deve-se informar um requerimento.") raise ValidationError("Para informar um autor deve-se informar um requerimento.")
elif requerimento: elif requerimento:
raise ValidationError("Para informar um requerimento deve-se informar um autor.") 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 django.utils.translation import ugettext_lazy as _
from model_utils import Choices from model_utils import Choices
from sapl.materia.models import MateriaLegislativa 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, from sapl.utils import (RANGE_ANOS, YES_NO_CHOICES, SaplGenericRelation,
restringe_tipos_de_arquivo_txt, texto_upload_path, restringe_tipos_de_arquivo_txt, texto_upload_path,
@ -79,12 +79,15 @@ class AudienciaPublica(models.Model):
default=False, default=False,
choices=YES_NO_CHOICES, choices=YES_NO_CHOICES,
verbose_name=_('Audiência Cancelada?')) verbose_name=_('Audiência Cancelada?'))
parlamentar_autor = models.ForeignKey( autor = models.ForeignKey(
Parlamentar, 'base.Autor',
on_delete=models.PROTECT, on_delete=models.PROTECT,
null=True, null=True,
blank=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( requerimento = models.ForeignKey(
MateriaLegislativa, MateriaLegislativa,
null=True, 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}') # raise ValidationError(f'Favor anexar arquivo em {nome_arquivo}')
return self.cleaned_data 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, tipo = models.ForeignKey(TipoComissao,
on_delete=models.PROTECT, on_delete=models.PROTECT,
verbose_name=_('Tipo')) 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')) nome = models.CharField(max_length=100, verbose_name=_('Nome'))
sigla = models.CharField(max_length=10, verbose_name=_('Sigla')) sigla = models.CharField(max_length=10, verbose_name=_('Sigla'))
data_criacao = models.DateField(verbose_name=_('Data de Criação')) 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, ParticipacaoCreateForm,
ParticipacaoEditForm, ParticipacaoEditForm,
PautaReuniaoFilterSet, PautaReuniaoForm, PautaReuniaoFilterSet, PautaReuniaoForm,
PeriodoForm, ReuniaoForm) PeriodoForm, ReuniaoFilterSet, ReuniaoForm)
from sapl.crud.base import (Crud, CrudAux, MasterDetailCrud, from sapl.crud.base import (Crud, CrudAux, MasterDetailCrud,
PermissionRequiredForAppCrudMixin, RP_DETAIL, PermissionRequiredForAppCrudMixin, RP_DETAIL,
RP_LIST) RP_LIST)
@ -216,7 +216,7 @@ class ReuniaoCrud(MasterDetailCrud):
public = [RP_LIST, RP_DETAIL, ] public = [RP_LIST, RP_DETAIL, ]
class BaseMixin(MasterDetailCrud.BaseMixin): 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): class DetailView(MasterDetailCrud.DetailView):
template_name = "comissoes/reuniao_detail.html" template_name = "comissoes/reuniao_detail.html"
@ -248,6 +248,11 @@ class ReuniaoCrud(MasterDetailCrud):
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
paginate_by = 10 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): def take_reuniao_pk(self):
username = self.request.user.username username = self.request.user.username
@ -276,6 +281,8 @@ class ReuniaoCrud(MasterDetailCrud):
context['documentoacessorio_set'] = DocumentoAcessorio.objects.filter( context['documentoacessorio_set'] = DocumentoAcessorio.objects.filter(
reuniao__pk=context['reuniao_pk'] reuniao__pk=context['reuniao_pk']
).order_by('id') ).order_by('id')
context['form'] = self.filterset.form
return context return context
class UpdateView(MasterDetailCrud.UpdateView): class UpdateView(MasterDetailCrud.UpdateView):

5
sapl/crud/base.py

@ -863,6 +863,11 @@ class CrudDetailView(PermissionRequiredContainerCrudMixin,
context[context_object_name] = self.object context[context_object_name] = self.object
context.update(kwargs) 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 return context
@property @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.RunSQL(sql=_NEW_VIEW, reverse_sql=_OLD_VIEW),
migrations.SeparateDatabaseAndState( migrations.SeparateDatabaseAndState(
database_operations=[ 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( migrations.RunSQL(
sql=""" sql="""
CREATE INDEX CONCURRENTLY IF NOT EXISTS 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')), SEXO_CHOICE = ((FEMININO, _('Feminino')),
(MASCULINO, _('Masculino'))) (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( nivel_instrucao = models.ForeignKey(
NivelInstrucao, NivelInstrucao,
blank=True, 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( tipo = models.ForeignKey(
TipoDocumentoAdministrativo, on_delete=models.PROTECT, TipoDocumentoAdministrativo, on_delete=models.PROTECT,
verbose_name=_('Tipo Documento')) 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')) numero = models.PositiveIntegerField(verbose_name=_('Número'))
complemento = models.CharField(max_length=10, blank=True, 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_ordemdia,
get_oradores_explicacoes_pessoais, get_consideracoes_finais, get_oradores_explicacoes_pessoais, get_consideracoes_finais,
get_ocorrencias_da_sessao, get_assinaturas, get_ocorrencias_da_sessao, get_assinaturas,
get_correspondencias, get_conteudo_multimidia, get_correspondencias)
get_votos_nominais)
from sapl.settings import MEDIA_URL, RATE_LIMITER_RATE from sapl.settings import MEDIA_URL, RATE_LIMITER_RATE
from sapl.settings import STATIC_ROOT from sapl.settings import STATIC_ROOT
from sapl.utils import LISTA_DE_UFS, TrocaTag, filiacao_data, create_barcode, show_results_filter_set, \ 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'): .order_by('cargo_id'):
# Antes usava Filiacao...first() (filiação mais recente), enquanto a # Antes usava Filiacao...first() (filiação mais recente), enquanto a
# tela usava .last() (a mais antiga) — e as listas de presença deste # 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 # mesmo PDF já usavam filiacao_data. Unifica no critério das listas
# partido vigente na data da sessão. # de presença e do Resumo: o partido vigente em data_inicio.
sigla = filiacao_data(composicao.parlamentar, data_sessao) sigla = filiacao_data(composicao.parlamentar, sessao.data_inicio)
lst_mesa.append({ lst_mesa.append({
'nom_parlamentar': composicao.parlamentar.nome_parlamentar, 'nom_parlamentar': composicao.parlamentar.nome_parlamentar,
'sgl_partido': sigla, 'sgl_partido': sigla,
@ -719,7 +718,7 @@ def get_sessao_plenaria(sessao, casa, user):
lst_expediente_materia_vot_nom = [] lst_expediente_materia_vot_nom = []
materias_expediente_votacao_nominal = ExpedienteMateria.objects.filter(sessao_plenaria=sessao, tipo_votacao=2) \ 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: for mevn in materias_expediente_votacao_nominal:
votos_materia = [] votos_materia = []
@ -744,7 +743,7 @@ def get_sessao_plenaria(sessao, casa, user):
"num_ordem": orador_expediente.numero_ordem, "num_ordem": orador_expediente.numero_ordem,
"nom_parlamentar": parlamentar.nome_parlamentar, "nom_parlamentar": parlamentar.nome_parlamentar,
"observacao": orador_expediente.observacao, "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 # Lista presença na ordem do dia
@ -830,7 +829,7 @@ def get_sessao_plenaria(sessao, casa, user):
lst_votacao_vot_nom = [] lst_votacao_vot_nom = []
materias_ordem_dia_votacao_nominal = OrdemDia.objects.filter(sessao_plenaria=sessao, tipo_votacao=2) \ 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: for modvn in materias_ordem_dia_votacao_nominal:
votos_materia_od = [] votos_materia_od = []
@ -860,7 +859,7 @@ def get_sessao_plenaria(sessao, casa, user):
"num_ordem": orador_ordemdia.numero_ordem, "num_ordem": orador_ordemdia.numero_ordem,
"nome_parlamentar": parlamentar_orador.nome_parlamentar, "nome_parlamentar": parlamentar_orador.nome_parlamentar,
"observacao": orador_ordemdia.observacao, "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 # Lista dos oradores nas Explicações Pessoais
@ -871,7 +870,7 @@ def get_sessao_plenaria(sessao, casa, user):
"num_ordem": orador.numero_ordem, "num_ordem": orador.numero_ordem,
"nom_parlamentar": parlamentar.nome_parlamentar, "nom_parlamentar": parlamentar.nome_parlamentar,
"observacao": orador.observacao, "observacao": orador.observacao,
"sgl_partido": filiacao_data(parlamentar, data_sessao) "sgl_partido": filiacao_data(parlamentar, sessao.data_inicio)
}) })
# Ocorrências da Sessão # Ocorrências da Sessão
@ -1447,9 +1446,6 @@ def resumo_ata_pdf(request, pk):
context = {} context = {}
context.update(get_identificacao_basica(sessao_plenaria)) 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_mesa_diretora(sessao_plenaria))
context.update(get_presenca_sessao(sessao_plenaria)) context.update(get_presenca_sessao(sessao_plenaria))
context.update(get_correspondencias(sessao_plenaria, request.user)) context.update(get_correspondencias(sessao_plenaria, request.user))
@ -1466,10 +1462,6 @@ def resumo_ata_pdf(request, pk):
sessao_plenaria, sessao_plenaria,
mesa=context['mesa'], mesa=context['mesa'],
presenca_ordem=context['presenca_ordem'])) 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({'object': sessao_plenaria})
context.update({'data': dt.today().strftime('%d/%m/%Y')}) context.update({'data': dt.today().strftime('%d/%m/%Y')})
context.update({'rodape': rodape}) context.update({'rodape': rodape})
@ -1842,7 +1834,7 @@ def relatorio_materia_tramitacao(request, pk):
'materia': materia_legislativa, 'materia': materia_legislativa,
'ano': materia_legislativa.ano, 'ano': materia_legislativa.ano,
'numero': materia_legislativa.numero, 'numero': materia_legislativa.numero,
'autor': materia_legislativa.autores.first(), 'autores': materia_legislativa.autores.all(),
'tipo': materia_legislativa.tipo.descricao, 'tipo': materia_legislativa.tipo.descricao,
'rodape': rodape, 'rodape': rodape,
'data': dt.today().strftime('%d/%m/%Y'), '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. """Restringe o queryset às sessões visíveis na consulta pública.
Usuário autenticado enxerga todas. Para o público, ficam ocultas as 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 sessões cadastradas com antecedência, ou seja, as de data futura
iniciadas e sem pauta publicada. 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 `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 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( return qs.filter(
Q(publicar_pauta=True) | Q(publicar_pauta=True) |
Q(iniciada=True) | Q(iniciada=True) |
Q(iniciada__isnull=True)) Q(iniciada__isnull=True) |
Q(data_inicio__lte=timezone.localdate()))
class SessaoPlenaria(models.Model): class SessaoPlenaria(models.Model):

54
sapl/sessao/tests/test_sessao_view.py

@ -1,9 +1,10 @@
import pytest import pytest
from datetime import date from datetime import timedelta
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.contrib.auth.models import AnonymousUser from django.contrib.auth.models import AnonymousUser
from django.db import IntegrityError, transaction from django.db import IntegrityError, transaction
from django.urls import reverse from django.urls import reverse
from django.utils import timezone
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from model_bakery import baker from model_bakery import baker
@ -155,13 +156,22 @@ class TestResumoView():
assert resultado_get_ocorrencia['ocorrencias_da_sessao'][0] == ocorrencia 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) @pytest.mark.django_db(transaction=False)
def test_visiveis_para_oculta_do_anonimo_apenas_a_sessao_previa(): def test_visiveis_para_oculta_do_anonimo_apenas_a_sessao_previa():
previa = baker.make(SessaoPlenaria, iniciada=False, publicar_pauta=False) previa = baker.make(SessaoPlenaria, iniciada=False, publicar_pauta=False,
com_pauta = baker.make(SessaoPlenaria, iniciada=False, publicar_pauta=True) data_inicio=data_futura())
iniciada = baker.make(SessaoPlenaria, iniciada=True, publicar_pauta=False) 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. # 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( visiveis = restringe_sessoes_visiveis(
SessaoPlenaria.objects.all(), AnonymousUser()) SessaoPlenaria.objects.all(), AnonymousUser())
@ -172,9 +182,25 @@ def test_visiveis_para_oculta_do_anonimo_apenas_a_sessao_previa():
assert legada in visiveis 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) @pytest.mark.django_db(transaction=False)
def test_visiveis_para_nao_oculta_nada_de_usuario_autenticado(): 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()) 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) @pytest.mark.django_db(transaction=False)
def test_pesquisar_sessao_nao_lista_sessao_previa_para_anonimo(client): def test_pesquisar_sessao_nao_lista_sessao_previa_para_anonimo(client):
previa = baker.make(SessaoPlenaria, iniciada=False, publicar_pauta=False, 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, 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'), response = client.get(reverse('sapl.sessao:pesquisar_sessao'),
{'data_inicio__year': '2025'}) {'data_inicio__year': data_futura().year})
assert response.status_code == 200 assert response.status_code == 200
assert previa not in response.context['object_list'] 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) @pytest.mark.django_db(transaction=False)
def test_pesquisar_sessao_lista_sessao_previa_para_autenticado(admin_client): def test_pesquisar_sessao_lista_sessao_previa_para_autenticado(admin_client):
previa = baker.make(SessaoPlenaria, iniciada=False, publicar_pauta=False, 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'), 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 response.status_code == 200
assert previa in response.context['object_list'] 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) @pytest.mark.django_db(transaction=False)
def test_detail_sessao_previa_indisponivel_para_anonimo(client): 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', response = client.get(reverse('sapl.sessao:sessaoplenaria_detail',
kwargs={'pk': previa.pk})) kwargs={'pk': previa.pk}))
@ -221,7 +248,8 @@ def test_detail_sessao_previa_indisponivel_para_anonimo(client):
@pytest.mark.django_db(transaction=False) @pytest.mark.django_db(transaction=False)
def test_resumo_de_sessao_previa_indisponivel_para_anonimo(client): 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', response = client.get(reverse('sapl.sessao:resumo',
kwargs={'pk': previa.pk})) 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] turno = t[1]
break break
materia_em_tramitacao = materia._met_prefetch[0] if materia._met_prefetch else None
# idUnica para cada materia # idUnica para cada materia
idAutor = "autor" + str(i) idAutor = "autor" + str(i)
idAutores = "autores" + 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 # url em toda a string de title_materia
context['rows'][i][1] = (title_materia, None) context['rows'][i][1] = (title_materia, None)
exist_resultado = bool(obj._votacao_prefetch) # A matéria do item pode ter sido trocada depois de votada: só contam
exist_retirada = bool(obj._retirada_prefetch) # os registros da matéria atual.
exist_leitura = bool(obj._leitura_prefetch) 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 \ if (obj.tipo_votacao != LEITURA and not exist_resultado and not exist_retirada) or \
(obj.tipo_votacao == LEITURA and not exist_leitura): (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''' resultado = '''Não há resultado'''
elif exist_retirada: elif exist_retirada:
retirada = obj._retirada_prefetch[-1] retirada = retiradas[-1]
retirada_descricao = retirada.tipo_de_retirada.descricao retirada_descricao = retirada.tipo_de_retirada.descricao
retirada_observacao = retirada.observacao retirada_observacao = retirada.observacao
url = reverse('sapl.sessao:retiradapauta_detail', url = reverse('sapl.sessao:retiradapauta_detail',
@ -423,11 +429,11 @@ def customize_link_materia(context, pk, has_permission, is_expediente):
else: else:
if obj.tipo_votacao == LEITURA: if obj.tipo_votacao == LEITURA:
resultado = obj._leitura_prefetch[-1] resultado = leituras[-1]
resultado_descricao = "Matéria lida" resultado_descricao = "Matéria lida"
resultado_observacao = resultado.observacao resultado_observacao = resultado.observacao
else: else:
resultado = obj._votacao_prefetch[-1] resultado = votacoes[-1]
resultado_descricao = resultado.tipo_resultado_votacao.nome resultado_descricao = resultado.tipo_resultado_votacao.nome
resultado_observacao = resultado.observacao resultado_observacao = resultado.observacao
@ -833,57 +839,19 @@ class MateriaOrdemDiaCrud(MasterDetailCrud):
layout_key = 'OrdemDiaDetail' layout_key = 'OrdemDiaDetail'
class ListView(MasterDetailCrud.ListView): class ListView(MasterDetailCrud.ListView):
paginate_by = 100 paginate_by = None
ordering = ['numero_ordem', 'materia', 'resultado'] ordering = ['numero_ordem', 'materia', 'resultado']
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
self.paginate_by = 50 if self.object_list.count() > 500 else None
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
has_permition = self.request.user.has_module_perms(AppConfig.label) has_permition = self.request.user.has_module_perms(AppConfig.label)
return customize_link_materia(context, self.kwargs['pk'], has_permition, False) return customize_link_materia(context, self.kwargs['pk'], has_permition, False)
def get_queryset(self): def get_queryset(self):
return super().get_queryset().select_related( return prefetch_materias_sessao(
'materia', 'materia__tipo', 'sessao_plenaria', super().get_queryset().select_related('sessao_plenaria'),
).prefetch_related( com_situacao=False)
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): def recuperar_materia(request):
@ -943,10 +911,11 @@ class ExpedienteMateriaCrud(MasterDetailCrud):
'resultado'] 'resultado']
class ListView(MasterDetailCrud.ListView): class ListView(MasterDetailCrud.ListView):
paginate_by = 100 paginate_by = None
ordering = ['numero_ordem', 'materia', 'resultado'] ordering = ['numero_ordem', 'materia', 'resultado']
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
self.paginate_by = 50 if self.object_list.count() > 500 else None
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
if self.request.GET.get('page'): if self.request.GET.get('page'):
context['page'] = 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) return customize_link_materia(context, self.kwargs['pk'], has_permition, True)
def get_queryset(self): def get_queryset(self):
return super().get_queryset().select_related( return prefetch_materias_sessao(
'materia', 'materia__tipo', 'sessao_plenaria', super().get_queryset().select_related('sessao_plenaria'),
).prefetch_related( com_situacao=False)
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): class CreateView(MasterDetailCrud.CreateView):
form_class = ExpedienteMateriaForm form_class = ExpedienteMateriaForm
@ -2147,25 +2077,24 @@ def get_expedientes(sessao_plenaria):
return ({'expedientes': expedientes}) return ({'expedientes': expedientes})
def prefetch_materias_sessao(qs): def prefetch_materias_sessao(qs, com_situacao=True):
"""Prefetches compartilhados por `get_materias_expediente` e """Prefetches compartilhados por `get_materias_expediente`,
`get_materias_ordem_do_dia`. `get_materias_ordem_do_dia` e pelas ListViews de OrdemDia e
ExpedienteMateria.
Os querysets dos `Prefetch` não recebem `order_by` explícito justamente Os querysets dos `Prefetch` não recebem `order_by` explícito justamente
para herdarem o `Meta.ordering` de cada model — é dele que dependem os para herdarem o `Meta.ordering` de cada model — é dele que dependem os
`.first()` / `.last()` que este código substitui por indexação de lista. `.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( Prefetch(
'materia__tramitacao_set', 'materia__tramitacao_set',
queryset=Tramitacao.objects.exclude(turno='').order_by( queryset=Tramitacao.objects.exclude(turno='').order_by(
'-data_tramitacao', '-id'), '-data_tramitacao', '-id'),
to_attr='_tramitacao_prefetch'), 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__numeracao_set', to_attr='_numeracao_prefetch'),
Prefetch( Prefetch(
'materia__autoria_set', 'materia__autoria_set',
@ -2181,7 +2110,15 @@ def prefetch_materias_sessao(qs):
queryset=RetiradaPauta.objects.select_related('tipo_de_retirada'), queryset=RetiradaPauta.objects.select_related('tipo_de_retirada'),
to_attr='_retirada_prefetch'), to_attr='_retirada_prefetch'),
Prefetch('registroleitura_set', to_attr='_leitura_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): 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( materias = list(model.objects.filter(
sessao_plenaria_id=sessao_plenaria_id, tipo_votacao=2 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) votos_por_materia = defaultdict(list)
if materias: if materias:

4
sapl/templates/audiencia/layouts.yaml

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

7
sapl/templates/base.html

@ -40,6 +40,13 @@
<body> <body>
<div class="page fadein"> <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 sapl_as_sapn or user.is_authenticated%}
{% if not request|has_iframe %} {% if not request|has_iframe %}
{% block navigation %} {% block navigation %}

2
sapl/templates/compilacao/dispositivo_form_alteracao.html

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

2
sapl/templates/compilacao/dispositivo_form_edicao_basica.html

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

10
sapl/templates/compilacao/text_edit.html

@ -17,8 +17,14 @@
<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> <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 %} {% endif %}
</div> </div>
<div class="actions btn-group dropleft" role="group"> <div class="d-flex">
{% include 'compilacao/textoarticulado_menu_config.html' %} <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>
</div> </div>
{% if object.dispositivos_set.count == 1 %} {% if object.dispositivos_set.count == 1 %}

2
sapl/templates/relatorios/relatorio_materia_tramitacao.html

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

7
sapl/templates/sessao/sessaoplenaria_filter.html

@ -53,6 +53,13 @@
{% if s.upload_ata %} {% if s.upload_ata %}
<strong><a href="{{s.upload_ata.url}}">Ata da Sessão</strong></a></br> <strong><a href="{{s.upload_ata.url}}">Ata da Sessão</strong></a></br>
{% endif %} {% 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> </td>
</tr> </tr>
{% endfor %} {% endfor %}

9
sapl/utils.py

@ -623,6 +623,15 @@ TIPOS_TEXTO_PERMITIDOS = (
'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/xml', 'application/xml',
'text/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 = ( TIPOS_IMG_PERMITIDOS = (

Loading…
Cancel
Save