From a258c0a232013e6663865a22578fbef454086a17 Mon Sep 17 00:00:00 2001
From: Edward Oliveira
Date: Mon, 3 Aug 2026 15:48:02 -0300
Subject: [PATCH 01/10] hot-fix: Adiciona MIME Media Types opcionais para CSV.
---
sapl/utils.py | 3 +++
1 file changed, 3 insertions(+)
diff --git a/sapl/utils.py b/sapl/utils.py
index 42d4b2c6e..9bd6231dc 100644
--- a/sapl/utils.py
+++ b/sapl/utils.py
@@ -611,6 +611,9 @@ TIPOS_TEXTO_PERMITIDOS = (
'text/pdf',
'text/x-pdf',
'text/plain',
+ 'text/csv',
+ 'application/vnd.ms-excel',
+ 'application/csv',
'application/txt',
'browser/internal',
'text/anytext',
From 85079b492d691ed6ae256c7d383a996be02ca3e2 Mon Sep 17 00:00:00 2001
From: joaohortsenado <100957576+joaohortsenado@users.noreply.github.com>
Date: Tue, 4 Aug 2026 15:04:19 -0300
Subject: [PATCH 02/10] =?UTF-8?q?Oculta=20sess=C3=B5es=20plen=C3=A1rias=20?=
=?UTF-8?q?n=C3=A3o=20iniciadas=20da=20consulta=20p=C3=BAblica=20(#3845)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* Oculta sessões plenárias não iniciadas da consulta pública
Sessões cadastradas com antecedência ficavam visíveis no portal mesmo com
"Publicar pauta?", "Sessão iniciada?" e "Sessão finalizada?" em Não: nenhum
desses campos filtrava queryset público. Só publicar_pauta filtrava, e apenas
nos dois pontos de entrada da pauta.
Passa a esconder do usuário anônimo as sessões marcadas explicitamente como
não iniciadas e sem pauta publicada. Usuário autenticado continua vendo todas.
A regra fica centralizada em SessaoPlenaria.objects.visiveis_para(user) e é
aplicada na pesquisa de sessões, no detalhe, no resumo e no resumo/ata.
iniciada nulo conta como visível: o campo nasceu sem default e o default=True
só chegou na migração 0027, que não preencheu as linhas existentes, então
bases anteriores a out/2018 têm sessões realizadas com o campo em NULL.
Também restringe PautaSessaoDetailView, que não filtrava publicar_pauta e
expunha por URL a pauta de sessões não publicadas.
* Troca custom manager por função utilitária de visibilidade
Atende à revisão do PR: substitui a subclasse SessaoPlenariaQuerySet e o
método .visiveis_para() por uma função restringe_sessoes_visiveis(qs, user),
chamada como wrapper nos pontos de acesso público. Mantém a mesma regra de
filtragem e o comportamento de fundir as cadeias de filter em um único SQL.
---------
Co-authored-by: Edward <9326037+edwardoliveira@users.noreply.github.com>
---
sapl/sessao/models.py | 20 ++++++
sapl/sessao/tests/test_sessao_view.py | 92 ++++++++++++++++++++++++++-
sapl/sessao/views.py | 21 +++++-
3 files changed, 130 insertions(+), 3 deletions(-)
diff --git a/sapl/sessao/models.py b/sapl/sessao/models.py
index 613068afe..ad71be072 100644
--- a/sapl/sessao/models.py
+++ b/sapl/sessao/models.py
@@ -153,6 +153,26 @@ def anexo_upload_path(instance, filename):
# return get_sessao_media_path(instance, 'anexo', filename)
+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.
+
+ `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
+ antigas têm sessões realizadas com o campo em NULL.
+ """
+ if user.is_authenticated:
+ return qs
+
+ return qs.filter(
+ Q(publicar_pauta=True) |
+ Q(iniciada=True) |
+ Q(iniciada__isnull=True))
+
+
class SessaoPlenaria(models.Model):
# TODO trash??? Seems to have been a FK in the past. Would be:
# andamento_sessao = models.ForeignKey(
diff --git a/sapl/sessao/tests/test_sessao_view.py b/sapl/sessao/tests/test_sessao_view.py
index 00284465b..70bb920a9 100644
--- a/sapl/sessao/tests/test_sessao_view.py
+++ b/sapl/sessao/tests/test_sessao_view.py
@@ -1,4 +1,7 @@
import pytest
+from datetime import date
+from django.contrib.auth import get_user_model
+from django.contrib.auth.models import AnonymousUser
from django.urls import reverse
from django.utils.translation import ugettext_lazy as _
from model_bakery import baker
@@ -8,7 +11,8 @@ from sapl.sessao.models import (SessaoPlenaria, TipoSessaoPlenaria,
IntegranteMesa, SessaoPlenariaPresenca,
JustificativaAusencia, ExpedienteSessao,
TipoExpediente, ExpedienteMateria,
- Orador, OcorrenciaSessao)
+ Orador, OcorrenciaSessao,
+ restringe_sessoes_visiveis)
from sapl.parlamentares.models import Parlamentar, CargoMesa, Filiacao
@@ -146,4 +150,88 @@ class TestResumoView():
ocorrencia = baker.make(OcorrenciaSessao, sessao_plenaria=self.sessao_plenaria)
resultado_get_ocorrencia = get_ocorrencias_da_sessao(self.sessao_plenaria)
- assert resultado_get_ocorrencia['ocorrencias_da_sessao'][0] == ocorrencia
\ No newline at end of file
+ assert resultado_get_ocorrencia['ocorrencias_da_sessao'][0] == ocorrencia
+
+
+@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)
+ # Sessões anteriores à migração 0027 ficaram com `iniciada` em NULL.
+ legada = baker.make(SessaoPlenaria, iniciada=None, publicar_pauta=False)
+
+ visiveis = restringe_sessoes_visiveis(
+ SessaoPlenaria.objects.all(), AnonymousUser())
+
+ assert previa not in visiveis
+ assert com_pauta in visiveis
+ assert iniciada in visiveis
+ assert legada 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)
+
+ operador = baker.make(get_user_model())
+
+ assert previa in restringe_sessoes_visiveis(
+ SessaoPlenaria.objects.all(), operador)
+
+
+@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))
+ iniciada = baker.make(SessaoPlenaria, iniciada=True, publicar_pauta=False,
+ data_inicio=date(2025, 11, 5))
+
+ response = client.get(reverse('sapl.sessao:pesquisar_sessao'),
+ {'data_inicio__year': '2025'})
+
+ assert response.status_code == 200
+ assert previa not in response.context['object_list']
+ assert iniciada in response.context['object_list']
+
+
+@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))
+
+ response = admin_client.get(reverse('sapl.sessao:pesquisar_sessao'),
+ {'data_inicio__year': '2025'})
+
+ assert response.status_code == 200
+ assert previa in response.context['object_list']
+
+
+@pytest.mark.django_db(transaction=False)
+def test_detail_sessao_previa_indisponivel_para_anonimo(client):
+ previa = baker.make(SessaoPlenaria, iniciada=False, publicar_pauta=False)
+
+ response = client.get(reverse('sapl.sessao:sessaoplenaria_detail',
+ kwargs={'pk': previa.pk}))
+
+ assert response.status_code == 404
+
+
+@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)
+
+ response = client.get(reverse('sapl.sessao:resumo',
+ kwargs={'pk': previa.pk}))
+
+ assert response.status_code == 404
+
+
+@pytest.mark.django_db(transaction=False)
+def test_pauta_nao_publicada_indisponivel_para_anonimo(client):
+ sem_pauta = baker.make(SessaoPlenaria, iniciada=True, publicar_pauta=False)
+
+ response = client.get(reverse('sapl.sessao:pauta_sessao_detail',
+ kwargs={'pk': sem_pauta.pk}))
+
+ assert response.status_code == 404
diff --git a/sapl/sessao/views.py b/sapl/sessao/views.py
index ab30c2c6a..64f7ce7c1 100755
--- a/sapl/sessao/views.py
+++ b/sapl/sessao/views.py
@@ -64,7 +64,7 @@ from .models import (Bancada, CargoBancada, CargoMesa,
SessaoPlenaria, SessaoPlenariaPresenca, TipoExpediente,
TipoResultadoVotacao, TipoSessaoPlenaria, VotoParlamentar, TipoRetiradaPauta,
RetiradaPauta, TipoJustificativa, JustificativaAusencia, OradorOrdemDia,
- ORDENACAO_RESUMO, RegistroLeitura)
+ ORDENACAO_RESUMO, RegistroLeitura, restringe_sessoes_visiveis)
TipoSessaoCrud = CrudAux.build(TipoSessaoPlenaria, 'tipo_sessao_plenaria')
TipoJustificativaCrud = CrudAux.build(TipoJustificativa, 'tipo_justificativa')
@@ -1348,6 +1348,13 @@ class SessaoCrud(Crud):
class DetailView(Crud.DetailView):
+ def get(self, request, *args, **kwargs):
+ if not restringe_sessoes_visiveis(
+ SessaoPlenaria.objects.filter(pk=kwargs.get('pk')),
+ request.user).exists():
+ raise Http404()
+ return super().get(request, *args, **kwargs)
+
@property
def layout_key(self):
sessao = self.object
@@ -2327,6 +2334,10 @@ class ResumoView(DetailView):
model = SessaoPlenaria
logger = logging.getLogger(__name__)
+ def get_queryset(self):
+ return restringe_sessoes_visiveis(
+ SessaoPlenaria.objects.all(), self.request.user)
+
def get_context(self, *args, **kwargs):
self.object = self.get_object()
context = self.get_context_data(object=self.object)
@@ -3833,6 +3844,12 @@ class PautaSessaoDetailView(PautaMultiFormatOutputMixin, DetailView):
('situacao', 'Situação')
)
+ def get_queryset(self):
+ qs = SessaoPlenaria.objects.all()
+ if not self.request.user.is_authenticated:
+ qs = qs.filter(publicar_pauta=True)
+ return qs
+
def hook_autor(self, obj):
return ','.join(obj['autor'])
@@ -4028,6 +4045,8 @@ class PesquisarSessaoPlenariaView(MultiFormatOutputMixin, FilterView):
qs = self.get_queryset().select_related(
'tipo', 'sessao_legislativa', 'legislatura')
+ qs = restringe_sessoes_visiveis(qs, self.request.user)
+
qs = qs.distinct().order_by(
'-legislatura__numero', '-data_inicio', '-hora_inicio')
From 4e58d8ae9719cea4f203bdac49afcf56a3989f82 Mon Sep 17 00:00:00 2001
From: joaohortsenado <100957576+joaohortsenado@users.noreply.github.com>
Date: Wed, 5 Aug 2026 17:33:41 -0300
Subject: [PATCH 03/10] =?UTF-8?q?Remove=20limite=20de=20caracteres=20do=20?=
=?UTF-8?q?campo=20Informa=C3=A7=C3=A3o=20Geral=20da=20Casa=20(#3848)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
O campo `CasaLegislativa.informacao_geral` é editado por editor de texto
rico: `CasaLegislativaForm` aplica `attrs={'id': 'texto-rico'}` no widget
e `frontend/src/__global/main.js` instancia o TinyMCE sobre esse id. O
valor submetido, portanto, é HTML, e não texto puro.
Com `max_length=100` no model, o MaxLengthValidator derivado pelo Django
contava os caracteres do HTML, tags inclusive. Um endereço de três linhas
com 87 caracteres visíveis vira 106 caracteres depois dos ``, e o
salvamento era recusado com "no máximo 100 caracteres (ele possui 106)"
sem que o usuário tivesse digitado 100 caracteres. Quanto mais formatação
o texto tem, mais cedo o erro aparece — e não há como o operador
descobrir sozinho que o limite está sendo consumido por tags que ele não
vê.
O limite nunca teve efeito no banco: `TextField` gera coluna `text` no
PostgreSQL, sem restrição de tamanho. A migration é um no-op em SQL e
serve apenas para manter o state do Django coerente com o model.
Adiciona teste que trava o comportamento e comentário no model
registrando por que o campo não deve receber `max_length` de volta.
OSTicket #824090
---
.../0061_casalegislativa_informacao_geral.py | 19 +++++++++++++
sapl/base/models.py | 4 ++-
sapl/base/tests/test_form.py | 27 +++++++++++++++++++
3 files changed, 49 insertions(+), 1 deletion(-)
create mode 100644 sapl/base/migrations/0061_casalegislativa_informacao_geral.py
diff --git a/sapl/base/migrations/0061_casalegislativa_informacao_geral.py b/sapl/base/migrations/0061_casalegislativa_informacao_geral.py
new file mode 100644
index 000000000..5fec68acd
--- /dev/null
+++ b/sapl/base/migrations/0061_casalegislativa_informacao_geral.py
@@ -0,0 +1,19 @@
+# Generated by Django 2.2.28 on 2026-08-05 12:00
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('base', '0060_auto_20240812_1628'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='casalegislativa',
+ name='informacao_geral',
+ field=models.TextField(blank=True,
+ verbose_name='Informação Geral'),
+ ),
+ ]
diff --git a/sapl/base/models.py b/sapl/base/models.py
index 692c55138..b31c532ab 100644
--- a/sapl/base/models.py
+++ b/sapl/base/models.py
@@ -73,8 +73,10 @@ class CasaLegislativa(models.Model):
max_length=100, blank=True, verbose_name=_('HomePage'))
email = models.EmailField(
max_length=100, blank=True, verbose_name=_('E-mail'))
+ # Campo editado via editor de texto rico (TinyMCE), portanto o valor
+ # armazenado é HTML. Não deve ter max_length: as tags consomem o limite
+ # e impedem o salvamento de textos curtos.
informacao_geral = models.TextField(
- max_length=100,
blank=True,
verbose_name=_('Informação Geral'))
diff --git a/sapl/base/tests/test_form.py b/sapl/base/tests/test_form.py
index 35f1c82bf..9464fe39e 100644
--- a/sapl/base/tests/test_form.py
+++ b/sapl/base/tests/test_form.py
@@ -39,3 +39,30 @@ def test_casa_legislativa_form_invalido():
})
assert not form.is_valid()
+
+
+@pytest.mark.django_db(transaction=False)
+def test_casa_legislativa_form_informacao_geral_sem_limite():
+ # O campo é editado por editor de texto rico, então o valor submetido é
+ # HTML: as tags não podem consumir um limite de caracteres.
+ html = ''.join('%s
' % linha for linha in
+ ['Câmara Municipal', 'Rua Osvaldo Cruz, 555 - Centro',
+ 'Expediente das 8h às 12h e das 13h30 às 17h30'])
+ assert len(html) > 100
+
+ form = CasaLegislativaForm(data={'codigo': 'codigo',
+ 'nome': 'nome',
+ 'sigla': 'sg',
+ 'endereco': 'endereco',
+ 'cep': '70000-000',
+ 'municipio': 'municipio',
+ 'uf': 'DF',
+ 'telefone': '33333333',
+ 'fax': '33333333',
+ 'endereco_web': '',
+ 'email': '',
+ 'informacao_geral': html
+ })
+
+ assert form.is_valid(), form.errors
+ assert form.cleaned_data['informacao_geral'] == html
From c82d626540e58b3613e8a06c9a08867a0290af48 Mon Sep 17 00:00:00 2001
From: Edward <9326037+edwardoliveira@users.noreply.github.com>
Date: Tue, 1 Sep 2026 18:34:56 -0300
Subject: [PATCH 04/10] Corrige SQL injection em
DispositivoSearchFragmentFormView (#3856)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Os parâmetros texto, rotulo, num_ta e ano_ta da busca de dispositivos
eram interpolados diretamente na SQL bruta executada via
Dispositivo.objects.raw(), sem nenhum escape (ano_ta nem sequer era
citado entre aspas). Troca as interpolações por placeholders %s com
params, deixando o driver do banco responsável pela escapagem.
Além disso:
- Renomeia a lista de palavras pesquisadas para texto_valores e monta
os fragmentos '%s' repetidos via ['d.texto ~* %s'] * len(texto_valores),
em vez de reatribuir a mesma variável texto ou usar uma comprehension
com variável de loop não utilizada.
- Adiciona sapl.compilacao.utils.valid_int e aplica a tipo_model,
tipo_ta e ano_ta (colunas/PKs inteiras), rejeitando valores não
numéricos com um erro claro em vez de deixar o filtro inválido
seguir adiante. num_ta não é validado dessa forma porque
ta.numero é CharField pesquisado via regex (~*), então aceitar
valores não numéricos ali é esperado.
Claude-Session: https://claude.ai/code/session_01RZAufy4JQKWAaDgfcQByyt
Co-authored-by: Claude Sonnet 5
---
sapl/compilacao/utils.py | 13 ++++++++++++
sapl/compilacao/views.py | 44 ++++++++++++++++++++++++----------------
2 files changed, 39 insertions(+), 18 deletions(-)
diff --git a/sapl/compilacao/utils.py b/sapl/compilacao/utils.py
index aa1793484..c56f618b8 100644
--- a/sapl/compilacao/utils.py
+++ b/sapl/compilacao/utils.py
@@ -1,5 +1,7 @@
import sys
+from django.utils.translation import ugettext_lazy as _
+
DISPOSITIVO_SELECT_RELATED = (
'tipo_dispositivo',
'ta_publicado',
@@ -66,3 +68,14 @@ def get_integrations_view_names():
if 'IntegracaoTaView' in str(base):
result.append(v)
return result
+
+
+def valid_int(value):
+ if value:
+ try:
+ int(value)
+ except (TypeError, ValueError):
+ raise ValueError(
+ _('Valor "%(value)s" não é um número inteiro válido.') % {
+ 'value': value})
+ return value
diff --git a/sapl/compilacao/views.py b/sapl/compilacao/views.py
index 9afbe3201..93a066b3f 100644
--- a/sapl/compilacao/views.py
+++ b/sapl/compilacao/views.py
@@ -49,7 +49,7 @@ from sapl.compilacao.models import (STATUS_TA_EDITION, STATUS_TA_PRIVATE,
VeiculoPublicacao, Vide)
from sapl.compilacao.utils import (DISPOSITIVO_SELECT_RELATED,
DISPOSITIVO_SELECT_RELATED_EDIT,
- get_integrations_view_names)
+ get_integrations_view_names, valid_int)
from sapl.crud.base import RP_DETAIL, RP_LIST, Crud, CrudAux, CrudListView,\
make_pagination
from sapl.settings import BASE_DIR
@@ -3177,14 +3177,14 @@ class DispositivoSearchFragmentFormView(ListView):
result = []
try:
- tipo_model = self.request.GET.get('tipo_model', '')
+ tipo_model = valid_int(self.request.GET.get('tipo_model', ''))
limit = int(self.request.GET.get('max_results', 100))
- tipo_ta = self.request.GET.get('tipo_ta', '')
+ tipo_ta = valid_int(self.request.GET.get('tipo_ta', ''))
num_ta = self.request.GET.get('num_ta', '')
- ano_ta = self.request.GET.get('ano_ta', '')
+ ano_ta = valid_int(self.request.GET.get('ano_ta', ''))
rotulo = self.request.GET.get('rotulo', '')
str_texto = self.request.GET.get('texto', '')
- texto = str_texto.split(' ')
+ texto_valores = str_texto.split(' ')
tipo_resultado = self.request.GET.get('tipo_resultado', '')
tipo_resultado = '' if tipo_resultado == 'False' else tipo_resultado
@@ -3228,20 +3228,24 @@ class DispositivoSearchFragmentFormView(ListView):
AND_CONTROLS = '''AND td.dispositivo_de_alteracao = true
AND td.dispositivo_de_articulacao = true'''
- texto = list(map("d.texto ~* '{}'".format, texto))
+ texto = ['d.texto ~* %s'] * len(texto_valores)
AND_TEXTO_ROTULO = ''
+ texto_rotulo_params = []
if str_texto and rotulo:
- AND_TEXTO_ROTULO = '''AND ( ({BUSCA_TEXTO} AND d.rotulo ~* '{BUSCA_ROTULO}') OR
- ({BUSCA_TEXTO} AND d.rotulo = '' AND dp.rotulo ~* '{BUSCA_ROTULO}')
+ busca_texto = ' AND '.join(texto)
+ AND_TEXTO_ROTULO = '''AND ( ({BUSCA_TEXTO} AND d.rotulo ~* %s) OR
+ ({BUSCA_TEXTO} AND d.rotulo = '' AND dp.rotulo ~* %s)
)'''.format(
- BUSCA_TEXTO=' AND '.join(texto),
- BUSCA_ROTULO=rotulo
+ BUSCA_TEXTO=busca_texto,
)
+ texto_rotulo_params = (
+ texto_valores + [rotulo] + texto_valores + [rotulo])
elif str_texto:
- AND_TEXTO_ROTULO = ' AND %s' % ' AND '.join(texto)
+ AND_TEXTO_ROTULO = ' AND ' + ' AND '.join(texto)
+ texto_rotulo_params = texto_valores
elif rotulo:
- AND_TEXTO_ROTULO = "AND d.rotulo ~* '{BUSCA_ROTULO}'".format(
- BUSCA_ROTULO=rotulo)
+ AND_TEXTO_ROTULO = "AND d.rotulo ~* %s"
+ texto_rotulo_params = [rotulo]
else:
AND_TEXTO_ROTULO = ''
@@ -3288,17 +3292,21 @@ class DispositivoSearchFragmentFormView(ListView):
AND3_TIPO_TA="AND ta.tipo_ta_id = {}".format(
tipo_ta.id) if tipo_ta else '',
- AND2_ANO="AND ta.ano = {}".format(
- ano_ta) if ano_ta else '',
+ AND2_ANO="AND ta.ano = %s" if ano_ta else '',
- AND1_NUMERO="AND ta.numero ~* '{}'".format(
- num_ta) if num_ta else '',
+ AND1_NUMERO="AND ta.numero ~* %s" if num_ta else '',
AND_TEXTO_ROTULO=AND_TEXTO_ROTULO if AND_TEXTO_ROTULO else '',
AND_CONTROLS=AND_CONTROLS if AND_CONTROLS else ''
)
- result = Dispositivo.objects.raw(sql)
+ params = texto_rotulo_params.copy()
+ if num_ta:
+ params.append(num_ta)
+ if ano_ta:
+ params.append(ano_ta)
+
+ result = Dispositivo.objects.raw(sql, params)
r = []
ids = set()
From 55f02aa6397bc613ad5b471b799519e02e53dde5 Mon Sep 17 00:00:00 2001
From: Edward Oliveira
Date: Tue, 1 Sep 2026 18:43:34 -0300
Subject: [PATCH 05/10] Release: 3.1.165-RC3
---
CHANGES.md | 24 ++++++++++++++++++++++++
docker/docker-compose.yaml | 2 +-
sapl/settings.py | 2 +-
sapl/templates/base.html | 2 +-
4 files changed, 27 insertions(+), 3 deletions(-)
diff --git a/CHANGES.md b/CHANGES.md
index e7b78bbd7..efac508cd 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -1,4 +1,28 @@
+3.1.165-RC3 / 2026-09-01
+========================
+
+ * Corrige SQL injection em DispositivoSearchFragmentFormView (#3856)
+ * Remove limite de caracteres do campo Informação Geral da Casa (#3848)
+ * Oculta sessões plenárias não iniciadas da consulta pública (#3845)
+ * hot-fix: Adiciona MIME Media Types opcionais para CSV.
+ * hot-fix: Remove alerta errado
+ * Corrige ordem dos expedientes no PDF do Resumo da Sessão (#3844)
+ * hot-fix: imprime somente contéudo da ocorrência
+ * Fix duplicate normas/matérias in author reports and prevent unfiltered queries
+ * Fix ValueError in audiencia form and AttributeError on HEAD requests
+ * Fix panel clock source and prevent overlapping polling
+ * hot-fix: recover epoch from server always
+ * Corrige polling excessivo e bloqueia não-votantes em /voto-individual/ (#3831)
+ * Hot-fix: corrige cópia de request.data
+ * Release: 3.1.165-RC2
+ * #3821 centralizar geração próximo número para create de matérias legislativas (#3822)
+
+3.1.166-RC0 / 2026-09-01
+========================
+
+
+
3.1.165-RC2 / 2026-03-26
========================
diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml
index dc8559812..46b5bd627 100644
--- a/docker/docker-compose.yaml
+++ b/docker/docker-compose.yaml
@@ -33,7 +33,7 @@ services:
networks:
- sapl-net
sapl:
- image: interlegis/sapl:3.1.165-RC2
+ image: interlegis/sapl:3.1.165-RC3
# build:
# context: ../
# dockerfile: ./docker/Dockerfile
diff --git a/sapl/settings.py b/sapl/settings.py
index 4be58ab0c..9c9aa99c7 100644
--- a/sapl/settings.py
+++ b/sapl/settings.py
@@ -43,7 +43,7 @@ ALLOWED_HOSTS = ['*']
LOGIN_REDIRECT_URL = '/'
LOGIN_URL = '/login/?next='
-SAPL_VERSION = '3.1.165-RC2'
+SAPL_VERSION = '3.1.165-RC3'
if DEBUG:
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
diff --git a/sapl/templates/base.html b/sapl/templates/base.html
index 79b285a3a..a11841704 100644
--- a/sapl/templates/base.html
+++ b/sapl/templates/base.html
@@ -200,7 +200,7 @@
Desenvolvido pelo Interlegis em software livre e aberto.
- Release: 3.1.165-RC2
+ Release: 3.1.165-RC3
From 2e6acfa24d11c6346daa172041fe4f38c5cf3156 Mon Sep 17 00:00:00 2001
From: joaohortsenado <100957576+joaohortsenado@users.noreply.github.com>
Date: Thu, 24 Sep 2026 16:14:58 -0300
Subject: [PATCH 06/10] =?UTF-8?q?Impede=20presen=C3=A7as=20duplicadas=20de?=
=?UTF-8?q?=20parlamentar=20na=20mesma=20sess=C3=A3o=20(#3850)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* Impede presenças duplicadas de parlamentar na mesma sessão
O relatório de presença exibia percentuais acima de 100% — em Capanema/PR,
um vereador aparecia com 46 sessões em um período de 45. A contagem não
estava errada: existiam mesmo duas linhas de presença dele na sessão nº 22
de 30/06/2025, e o relatório contava linhas com Count('id').
A origem é PresencaView/PresencaOrdemDiaView. `presentes_banco` vem do banco
como inteiros e `marcados` vem do POST como strings, então
`set(presentes_banco) - set(marcados)` nunca casava e resultava em apagar
todas as presenças da sessão a cada salvamento, recriando-as em seguida.
Sequencialmente o resultado é correto, mas duas submissões concorrentes do
formulário — um duplo clique em Salvar — passam ambas pela janela entre o
apagar e o recriar e gravam duas linhas por parlamentar. Os ids gravados em
Capanema confirmam: pares adjacentes intercalados na ordem alfabética da
tela, assinatura de dois laços concorrentes.
Nada no banco impedia isso. Não é um caso isolado: Capanema tinha 625 linhas
excedentes em 313 sessões, e a base de Agudo/RS, 9 linhas em 1 sessão.
Corrige em quatro frentes:
- compara os ids com o mesmo tipo e passa a criar apenas quem ainda não tem
presença, sem apagar e recriar quem permanece marcado;
- usa bulk_create(ignore_conflicts=True), para que a submissão concorrente
descarte a inserção repetida em vez de estourar IntegrityError;
- adiciona unique_together (sessao_plenaria, parlamentar) nos dois modelos,
que é a proteção efetiva contra concorrência, com migração que remove as
duplicatas existentes antes de criar a restrição;
- conta sessões distintas no relatório, em vez de linhas, para que bases
ainda não migradas não exibam percentuais impossíveis.
OSTicket #212222
* Remove presenças duplicadas com RunSQL na migração 0070
Substitui o RunPython por um DELETE ... USING por tabela, conforme
sugestão da revisão: mantém a presença de menor id de cada par
(sessão, parlamentar), agora também em sessao_presencaordemdia.
A preferência por data_sessao preenchida foi abandonada, já que o
campo não é lido nem escrito em nenhum ponto do código.
---
sapl/relatorios/views.py | 11 +-
.../0070_presenca_unica_por_sessao.py | 40 +++++++
sapl/sessao/models.py | 6 ++
sapl/sessao/tests/test_sessao_view.py | 102 ++++++++++++++++++
sapl/sessao/views.py | 56 +++++-----
5 files changed, 188 insertions(+), 27 deletions(-)
create mode 100644 sapl/sessao/migrations/0070_presenca_unica_por_sessao.py
diff --git a/sapl/relatorios/views.py b/sapl/relatorios/views.py
index 8b89342fd..073fa8ba6 100755
--- a/sapl/relatorios/views.py
+++ b/sapl/relatorios/views.py
@@ -2080,17 +2080,22 @@ class RelatorioPresencaSessaoView(RelatorioMixin, FilterView):
parlamentares_id = parlamentares_qs.values_list('id', flat=True)
# Presenças de cada Parlamentar em Sessões
+ # Conta sessões distintas, e não linhas de presença: bases com
+ # presenças repetidas para o mesmo parlamentar na mesma sessão
+ # produziam percentuais acima de 100%.
presenca_sessao = SessaoPlenariaPresenca.objects.filter(
- **param0).values_list('parlamentar_id').annotate(sessao_count=Count('id'))
+ **param0).values_list('parlamentar_id').annotate(
+ sessao_count=Count('sessao_plenaria_id', distinct=True))
# Presenças de cada Ordem do Dia
presenca_ordem = PresencaOrdemDia.objects.filter(
- **param0).values_list('parlamentar_id').annotate(sessao_count=Count('id'))
+ **param0).values_list('parlamentar_id').annotate(
+ sessao_count=Count('sessao_plenaria_id', distinct=True))
# Ausencias justificadas
ausencia_justificadas = JustificativaAusencia.objects.filter(
**param0, ausencia=2).values_list('parlamentar_id')\
- .annotate(sessao_count=Count('id'))
+ .annotate(sessao_count=Count('sessao_plenaria_id', distinct=True))
total_ordemdia = PresencaOrdemDia.objects.filter(
**param0).distinct('sessao_plenaria__id').order_by('sessao_plenaria__id').count()
diff --git a/sapl/sessao/migrations/0070_presenca_unica_por_sessao.py b/sapl/sessao/migrations/0070_presenca_unica_por_sessao.py
new file mode 100644
index 000000000..ff71601b1
--- /dev/null
+++ b/sapl/sessao/migrations/0070_presenca_unica_por_sessao.py
@@ -0,0 +1,40 @@
+# Generated by Django 2.2.28 on 2026-08-05 16:00
+
+from django.db import migrations
+
+
+# Mantém uma única presença por (sessão, parlamentar). Presença é um sim/não,
+# então as linhas repetidas não carregam informação adicional: preserva-se a
+# de menor id e descartam-se as demais.
+REMOVE_DUPLICADAS_SQL = """
+ DELETE FROM {tabela} a
+ USING {tabela} b
+ WHERE a.sessao_plenaria_id = b.sessao_plenaria_id
+ AND a.parlamentar_id = b.parlamentar_id
+ AND a.id > b.id;
+"""
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('sessao', '0069_auto_20220919_1705'),
+ ]
+
+ operations = [
+ migrations.RunSQL(
+ REMOVE_DUPLICADAS_SQL.format(
+ tabela='sessao_sessaoplenariapresenca'),
+ migrations.RunSQL.noop),
+ migrations.RunSQL(
+ REMOVE_DUPLICADAS_SQL.format(tabela='sessao_presencaordemdia'),
+ migrations.RunSQL.noop),
+ migrations.AlterUniqueTogether(
+ name='sessaoplenariapresenca',
+ unique_together={('sessao_plenaria', 'parlamentar')},
+ ),
+ migrations.AlterUniqueTogether(
+ name='presencaordemdia',
+ unique_together={('sessao_plenaria', 'parlamentar')},
+ ),
+ ]
diff --git a/sapl/sessao/models.py b/sapl/sessao/models.py
index ad71be072..fc418b08f 100644
--- a/sapl/sessao/models.py
+++ b/sapl/sessao/models.py
@@ -594,6 +594,9 @@ class PresencaOrdemDia(models.Model): # OrdemDiaPresenca
verbose_name = _('Presença da Ordem do Dia')
verbose_name_plural = _('Presenças da Ordem do Dia')
ordering = ['parlamentar__nome_parlamentar']
+ # Presença é um sim/não: mais de uma linha para o mesmo parlamentar
+ # na mesma sessão infla a contagem dos relatórios.
+ unique_together = ('sessao_plenaria', 'parlamentar')
def __str__(self):
# FIXME ambigous
@@ -736,6 +739,9 @@ class SessaoPlenariaPresenca(models.Model):
verbose_name = _('Presença em Sessão Plenária')
verbose_name_plural = _('Presenças em Sessões Plenárias')
ordering = ['parlamentar__nome_parlamentar']
+ # Presença é um sim/não: mais de uma linha para o mesmo parlamentar
+ # na mesma sessão infla a contagem dos relatórios.
+ unique_together = ('sessao_plenaria', 'parlamentar')
ORDENACAO_RESUMO = [
diff --git a/sapl/sessao/tests/test_sessao_view.py b/sapl/sessao/tests/test_sessao_view.py
index 70bb920a9..aef74c2ae 100644
--- a/sapl/sessao/tests/test_sessao_view.py
+++ b/sapl/sessao/tests/test_sessao_view.py
@@ -2,6 +2,7 @@ import pytest
from datetime import date
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.translation import ugettext_lazy as _
from model_bakery import baker
@@ -9,6 +10,7 @@ from model_bakery import baker
from sapl.parlamentares.models import Legislatura, SessaoLegislativa
from sapl.sessao.models import (SessaoPlenaria, TipoSessaoPlenaria,
IntegranteMesa, SessaoPlenariaPresenca,
+ PresencaOrdemDia,
JustificativaAusencia, ExpedienteSessao,
TipoExpediente, ExpedienteMateria,
Orador, OcorrenciaSessao,
@@ -235,3 +237,103 @@ def test_pauta_nao_publicada_indisponivel_para_anonimo(client):
kwargs={'pk': sem_pauta.pk}))
assert response.status_code == 404
+
+
+def cria_sessao_com_parlamentares():
+ legislatura = baker.make(Legislatura)
+ sessao_legislativa = baker.make(SessaoLegislativa)
+ tipo = baker.make(TipoSessaoPlenaria)
+ sessao = baker.make(SessaoPlenaria,
+ legislatura=legislatura,
+ sessao_legislativa=sessao_legislativa,
+ tipo=tipo)
+ parlamentares = [baker.make(Parlamentar, ativo=True) for _i in range(3)]
+ return sessao, parlamentares
+
+
+@pytest.mark.django_db(transaction=False)
+def test_presenca_sessao_preserva_registros_ao_salvar_novamente(admin_client):
+ # Cada salvamento apagava todas as presenças da sessão e as recriava,
+ # trocando os ids. Além do desperdício, é essa janela entre apagar e
+ # recriar que permite a duplicação quando o formulário é submetido duas
+ # vezes em paralelo. Salvar de novo deve ser inócuo.
+ sessao, parlamentares = cria_sessao_com_parlamentares()
+ url = reverse('sapl.sessao:presenca', kwargs={'pk': sessao.pk})
+ dados = {'presenca_ativos': [str(p.pk) for p in parlamentares]}
+
+ admin_client.post(url, dados, follow=True)
+ presencas = SessaoPlenariaPresenca.objects.filter(sessao_plenaria=sessao)
+ ids_iniciais = sorted(presencas.values_list('id', flat=True))
+
+ admin_client.post(url, dados, follow=True)
+ admin_client.post(url, dados, follow=True)
+
+ assert presencas.count() == len(parlamentares)
+ assert sorted(presencas.values_list('parlamentar_id', flat=True)) == \
+ sorted(p.pk for p in parlamentares)
+ assert sorted(presencas.values_list('id', flat=True)) == ids_iniciais
+
+
+@pytest.mark.django_db(transaction=False)
+def test_presenca_sessao_ignora_registro_ja_existente(admin_client):
+ # Reproduz o efeito da submissão concorrente: a linha já existe quando a
+ # view vai gravar. Deve terminar com uma única presença, sem erro.
+ sessao, parlamentares = cria_sessao_com_parlamentares()
+ parlamentar = parlamentares[0]
+ SessaoPlenariaPresenca.objects.create(sessao_plenaria=sessao,
+ parlamentar=parlamentar)
+
+ url = reverse('sapl.sessao:presenca', kwargs={'pk': sessao.pk})
+ response = admin_client.post(
+ url, {'presenca_ativos': [str(parlamentar.pk)]}, follow=True)
+
+ assert response.status_code == 200
+ assert SessaoPlenariaPresenca.objects.filter(
+ sessao_plenaria=sessao, parlamentar=parlamentar).count() == 1
+
+
+@pytest.mark.django_db(transaction=False)
+def test_presenca_sessao_remove_desmarcados(admin_client):
+ sessao, parlamentares = cria_sessao_com_parlamentares()
+ url = reverse('sapl.sessao:presenca', kwargs={'pk': sessao.pk})
+
+ admin_client.post(url, {'presenca_ativos': [str(p.pk)
+ for p in parlamentares]},
+ follow=True)
+ admin_client.post(url, {'presenca_ativos': [str(parlamentares[0].pk)]},
+ follow=True)
+
+ presencas = SessaoPlenariaPresenca.objects.filter(sessao_plenaria=sessao)
+ assert list(presencas.values_list('parlamentar_id', flat=True)) == \
+ [parlamentares[0].pk]
+
+
+@pytest.mark.django_db(transaction=False)
+def test_presenca_ordem_dia_preserva_registros_ao_resalvar(admin_client):
+ sessao, parlamentares = cria_sessao_com_parlamentares()
+ url = reverse('sapl.sessao:presencaordemdia', kwargs={'pk': sessao.pk})
+ dados = {'presenca_ativos': [str(p.pk) for p in parlamentares]}
+
+ admin_client.post(url, dados, follow=True)
+ presencas = PresencaOrdemDia.objects.filter(sessao_plenaria=sessao)
+ ids_iniciais = sorted(presencas.values_list('id', flat=True))
+
+ admin_client.post(url, dados, follow=True)
+
+ assert presencas.count() == len(parlamentares)
+ assert sorted(presencas.values_list('id', flat=True)) == ids_iniciais
+
+
+@pytest.mark.django_db(transaction=False)
+def test_presenca_unica_por_sessao_e_parlamentar():
+ # A restrição de unicidade é a proteção contra submissões concorrentes,
+ # que a lógica da view sozinha não cobre.
+ sessao, parlamentares = cria_sessao_com_parlamentares()
+ parlamentar = parlamentares[0]
+
+ for model in (SessaoPlenariaPresenca, PresencaOrdemDia):
+ model.objects.create(sessao_plenaria=sessao, parlamentar=parlamentar)
+ with pytest.raises(IntegrityError):
+ with transaction.atomic():
+ model.objects.create(sessao_plenaria=sessao,
+ parlamentar=parlamentar)
diff --git a/sapl/sessao/views.py b/sapl/sessao/views.py
index 64f7ce7c1..4298f8f44 100755
--- a/sapl/sessao/views.py
+++ b/sapl/sessao/views.py
@@ -1420,26 +1420,31 @@ class PresencaView(FormMixin, PresencaMixin, DetailView):
if form.is_valid():
# Pegar os presentes salvos no banco
- presentes_banco = SessaoPlenariaPresenca.objects.filter(
+ presentes_banco = set(SessaoPlenariaPresenca.objects.filter(
sessao_plenaria_id=self.object.id).values_list(
- 'parlamentar_id', flat=True).distinct()
+ 'parlamentar_id', flat=True))
# Id dos parlamentares presentes
- marcados = request.POST.getlist('presenca_ativos') \
- + request.POST.getlist('presenca_inativos')
+ marcados = set(int(p) for p in
+ request.POST.getlist('presenca_ativos')
+ + request.POST.getlist('presenca_inativos'))
# Deletar os que foram desmarcados
- deletar = set(presentes_banco) - set(marcados)
SessaoPlenariaPresenca.objects.filter(
- parlamentar_id__in=deletar,
+ parlamentar_id__in=presentes_banco - marcados,
sessao_plenaria_id=self.object.id).delete()
- for p in marcados:
- sessao = SessaoPlenariaPresenca()
- sessao.sessao_plenaria = self.object
- sessao.parlamentar = Parlamentar.objects.get(id=p)
- sessao.save()
- username = request.user.username
+ # Criar apenas quem ainda não tem presença registrada. O
+ # ignore_conflicts descarta a inserção duplicada quando o
+ # formulário é submetido duas vezes em paralelo, em vez de
+ # gravar uma segunda linha para o mesmo parlamentar.
+ username = request.user.username
+ novos = marcados - presentes_banco
+ SessaoPlenariaPresenca.objects.bulk_create(
+ [SessaoPlenariaPresenca(sessao_plenaria=self.object,
+ parlamentar_id=p) for p in novos],
+ ignore_conflicts=True)
+ for p in novos:
self.logger.info(
"user=" + username + ". SessaoPlenariaPresenca salva com sucesso (parlamentar_id={})!".format(p))
msg = _('Presença em Sessão salva com sucesso!')
@@ -1535,26 +1540,29 @@ class PresencaOrdemDiaView(FormMixin, PresencaMixin, DetailView):
if form.is_valid():
# Pegar os presentes salvos no banco
- presentes_banco = PresencaOrdemDia.objects.filter(
+ presentes_banco = set(PresencaOrdemDia.objects.filter(
sessao_plenaria_id=self.object.id).values_list(
- 'parlamentar_id', flat=True).distinct()
+ 'parlamentar_id', flat=True))
# Id dos parlamentares presentes
- marcados = request.POST.getlist('presenca_ativos') \
- + request.POST.getlist('presenca_inativos')
+ marcados = set(int(p) for p in
+ request.POST.getlist('presenca_ativos')
+ + request.POST.getlist('presenca_inativos'))
# Deletar os que foram desmarcados
- deletar = set(presentes_banco) - set(marcados)
PresencaOrdemDia.objects.filter(
- parlamentar_id__in=deletar,
+ parlamentar_id__in=presentes_banco - marcados,
sessao_plenaria_id=self.object.id).delete()
- for p in marcados:
- ordem = PresencaOrdemDia()
- ordem.sessao_plenaria = self.object
- ordem.parlamentar = Parlamentar.objects.get(id=p)
- ordem.save()
- username = request.user.username
+ # Criar apenas quem ainda não tem presença registrada. Ver
+ # comentário equivalente em PresencaView.post.
+ username = request.user.username
+ novos = marcados - presentes_banco
+ PresencaOrdemDia.objects.bulk_create(
+ [PresencaOrdemDia(sessao_plenaria=self.object,
+ parlamentar_id=p) for p in novos],
+ ignore_conflicts=True)
+ for p in novos:
self.logger.info(
'user=' + username + '. PresencaOrdemDia (parlamentar com id={}) salva com sucesso!'.format(p))
From 2e7c3aa56d23cc615835d704cc0f15ffb6ae6250 Mon Sep 17 00:00:00 2001
From: joaohortsenado <100957576+joaohortsenado@users.noreply.github.com>
Date: Thu, 24 Sep 2026 16:28:30 -0300
Subject: [PATCH 07/10] =?UTF-8?q?Melhorias=20de=20transpar=C3=AAncia=20(PN?=
=?UTF-8?q?TP):=20corre=C3=A7=C3=B5es=20de=20relat=C3=B3rios=20e=20novas?=
=?UTF-8?q?=20funcionalidades=20(#3842)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* fix(sessao): ordena votações nominais do resumo por numero_ordem
O resumo da Sessão Plenária (HTML e PDF) ordenava as votações nominais por
order_by('-materia'), divergindo da ordem das Matérias da Ordem do Dia, que
seguem numero_ordem. Passa a ordenar por numero_ordem nas listas de
Expediente e de Ordem do Dia, tanto no resumo HTML quanto no gerador de PDF.
* fix(relatorios): lista todos os autores no relatório de tramitação
O relatório "Imprimir Tramitação" exibia apenas o primeiro autor
(autores.first()). Passa a exibir todos os autores/co-autores da matéria,
ajustando a view (autores.all()) e o template (rótulo Autor/Autores e
junção dos nomes).
* feat(uploads): aceita planilhas e CSV em documentos de texto
Amplia TIPOS_TEXTO_PERMITIDOS (validador restringe_tipos_de_arquivo_txt),
usado nos campos de Pauta/Ata/Anexo, para aceitar formatos editáveis de
planilha e CSV: .csv, .xls, .xlsx e .ods (e variantes de MIME). Não requer
migração, pois o validador é referenciado nas migrations apenas pelo nome.
* feat(comissoes): filtro de pesquisa e coluna de pauta na lista de reuniões
Adiciona ReuniaoFilterSet (número, nome, tema e período de data) à listagem
de Reuniões de Comissão, permitindo localizar reuniões sem navegar página a
página. Inclui a coluna 'Pauta da Reunião' ao lado de 'Ata da Reunião'.
* feat(sessao): renomeia anexo para 'Votações Nominais' e exibe anexos na busca
Renomeia o verbose_name do campo upload_anexo de SessaoPlenaria de 'Anexo da
Sessão' para 'Votações Nominais' (com migração) e passa a exibir os links de
Pauta da Sessão e Votações Nominais ao lado da Ata na pesquisa pública de
sessões plenárias.
* feat(base): exibe data de atualização no topo das páginas
Adiciona uma faixa no topo do layout com 'Informações atualizadas em
DD/MM/AAAA' (data corrente, sem horário), oculta na impressão e em iframes.
* feat(audiencia): permite autor de qualquer tipo na audiência pública
Substitui o campo parlamentar_autor (FK direta a Parlamentar) pelo Autor
polimórfico do SAPL em AudienciaPublica, permitindo Parlamentar, Comissão,
Bancada e demais tipos. Atualiza o formulário, o layout e a validação de
autoria do requerimento. A migração preserva os autores já cadastrados.
* feat(base): faixa de atualidade usa a data de última atualização da entidade
Em telas de detalhe (CrudDetailView), a faixa do topo passa a exibir a data
de última atualização do registro (campo data_ultima_atualizacao); nas demais
telas (pesquisa/listagem) exibe a data atual. Adiciona data_ultima_atualizacao
(auto_now) a Parlamentar, Comissao, DocumentoAdministrativo e AudienciaPublica.
* fix(sessao): reverte rótulo do anexo e adiciona link de Votações Nominais
Desfaz o rename de upload_anexo (volta a 'Anexo da Sessão' e remove a migração
de verbose_name) e adiciona, na pesquisa pública de sessões, um link
'Votações Nominais' que aponta para o resumo da sessão.
---------
Co-authored-by: Edward <9326037+edwardoliveira@users.noreply.github.com>
---
sapl/audiencia/forms.py | 20 +++----
.../migrations/0021_auto_20260623_1938.py | 59 +++++++++++++++++++
...udienciapublica_data_ultima_atualizacao.py | 18 ++++++
sapl/audiencia/models.py | 11 ++--
sapl/comissoes/forms.py | 25 ++++++++
.../0031_comissao_data_ultima_atualizacao.py | 18 ++++++
sapl/comissoes/models.py | 3 +
sapl/comissoes/views.py | 11 +++-
sapl/crud/base.py | 5 ++
...046_parlamentar_data_ultima_atualizacao.py | 18 ++++++
sapl/parlamentares/models.py | 4 ++
.../migrations/0046_auto_20260626_1220.py | 28 +++++++++
sapl/protocoloadm/models.py | 3 +
sapl/relatorios/views.py | 6 +-
sapl/sessao/views.py | 4 +-
sapl/templates/audiencia/layouts.yaml | 4 +-
sapl/templates/base.html | 7 +++
.../relatorio_materia_tramitacao.html | 2 +-
.../sessao/sessaoplenaria_filter.html | 7 +++
sapl/utils.py | 9 +++
20 files changed, 238 insertions(+), 24 deletions(-)
create mode 100644 sapl/audiencia/migrations/0021_auto_20260623_1938.py
create mode 100644 sapl/audiencia/migrations/0022_audienciapublica_data_ultima_atualizacao.py
create mode 100644 sapl/comissoes/migrations/0031_comissao_data_ultima_atualizacao.py
create mode 100644 sapl/parlamentares/migrations/0046_parlamentar_data_ultima_atualizacao.py
create mode 100644 sapl/protocoloadm/migrations/0046_auto_20260626_1220.py
diff --git a/sapl/audiencia/forms.py b/sapl/audiencia/forms.py
index f1978032f..d2c7dd45b 100755
--- a/sapl/audiencia/forms.py
+++ b/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.")
diff --git a/sapl/audiencia/migrations/0021_auto_20260623_1938.py b/sapl/audiencia/migrations/0021_auto_20260623_1938.py
new file mode 100644
index 000000000..a2e101c64
--- /dev/null
+++ b/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'),
+ ),
+ ]
diff --git a/sapl/audiencia/migrations/0022_audienciapublica_data_ultima_atualizacao.py b/sapl/audiencia/migrations/0022_audienciapublica_data_ultima_atualizacao.py
new file mode 100644
index 000000000..15f49d3fa
--- /dev/null
+++ b/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'),
+ ),
+ ]
diff --git a/sapl/audiencia/models.py b/sapl/audiencia/models.py
index 6b74cffd0..8ba14f29f 100755
--- a/sapl/audiencia/models.py
+++ b/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,
diff --git a/sapl/comissoes/forms.py b/sapl/comissoes/forms.py
index 76f511ed2..599b9a5b3 100644
--- a/sapl/comissoes/forms.py
+++ b/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'))))
diff --git a/sapl/comissoes/migrations/0031_comissao_data_ultima_atualizacao.py b/sapl/comissoes/migrations/0031_comissao_data_ultima_atualizacao.py
new file mode 100644
index 000000000..e35f98e0a
--- /dev/null
+++ b/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'),
+ ),
+ ]
diff --git a/sapl/comissoes/models.py b/sapl/comissoes/models.py
index 2d9a323d3..694fdceaf 100644
--- a/sapl/comissoes/models.py
+++ b/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'))
diff --git a/sapl/comissoes/views.py b/sapl/comissoes/views.py
index 6017ae2b3..92cde447f 100644
--- a/sapl/comissoes/views.py
+++ b/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):
diff --git a/sapl/crud/base.py b/sapl/crud/base.py
index fd091ec52..0966c4bc4 100644
--- a/sapl/crud/base.py
+++ b/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
diff --git a/sapl/parlamentares/migrations/0046_parlamentar_data_ultima_atualizacao.py b/sapl/parlamentares/migrations/0046_parlamentar_data_ultima_atualizacao.py
new file mode 100644
index 000000000..c4ae53dee
--- /dev/null
+++ b/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'),
+ ),
+ ]
diff --git a/sapl/parlamentares/models.py b/sapl/parlamentares/models.py
index eecc2f314..6098835c4 100644
--- a/sapl/parlamentares/models.py
+++ b/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,
diff --git a/sapl/protocoloadm/migrations/0046_auto_20260626_1220.py b/sapl/protocoloadm/migrations/0046_auto_20260626_1220.py
new file mode 100644
index 000000000..313bcd98a
--- /dev/null
+++ b/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'),
+ ),
+ ]
diff --git a/sapl/protocoloadm/models.py b/sapl/protocoloadm/models.py
index 3cdf32735..1a169733a 100644
--- a/sapl/protocoloadm/models.py
+++ b/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,
diff --git a/sapl/relatorios/views.py b/sapl/relatorios/views.py
index 073fa8ba6..1c1d2299f 100755
--- a/sapl/relatorios/views.py
+++ b/sapl/relatorios/views.py
@@ -711,7 +711,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 = []
@@ -824,7 +824,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 = []
@@ -1792,7 +1792,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'),
diff --git a/sapl/sessao/views.py b/sapl/sessao/views.py
index 4298f8f44..2c1df1442 100755
--- a/sapl/sessao/views.py
+++ b/sapl/sessao/views.py
@@ -2353,7 +2353,7 @@ class ResumoView(DetailView):
# Votos de Votação Nominal de Matérias Expediente
votacoes = []
for mevn in ExpedienteMateria.objects.filter(sessao_plenaria_id=self.object.id, tipo_votacao=2) \
- .order_by('-materia'):
+ .order_by('numero_ordem'):
votos_materia = []
titulo_materia = mevn.materia
registro = RegistroVotacao.objects.filter(expediente=mevn)
@@ -2402,7 +2402,7 @@ class ResumoView(DetailView):
# Matérias Ordem do Dia
# Votos de Votação Nominal de Matérias Ordem do Dia
votacoes_od = []
- for modvn in OrdemDia.objects.filter(sessao_plenaria_id=self.object.id, tipo_votacao=2).order_by('-materia'):
+ for modvn in OrdemDia.objects.filter(sessao_plenaria_id=self.object.id, tipo_votacao=2).order_by('numero_ordem'):
votos_materia_od = []
t_materia = modvn.materia
registro_od = RegistroVotacao.objects.filter(ordem=modvn)
diff --git a/sapl/templates/audiencia/layouts.yaml b/sapl/templates/audiencia/layouts.yaml
index 916bfa287..18bc46e46 100644
--- a/sapl/templates/audiencia/layouts.yaml
+++ b/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
diff --git a/sapl/templates/base.html b/sapl/templates/base.html
index a11841704..e3582e2ef 100644
--- a/sapl/templates/base.html
+++ b/sapl/templates/base.html
@@ -40,6 +40,13 @@
+ {% if not request|has_iframe %}
+
+ {% trans 'Informações foram atualizadas em' %}
+ {% if data_atualizacao_conteudo %}{{ data_atualizacao_conteudo|date:"d/m/Y" }}{% else %}{% now "d/m/Y" %}{% endif %}
+
+ {% endif %}
+
{% if not sapl_as_sapn or user.is_authenticated%}
{% if not request|has_iframe %}
{% block navigation %}
diff --git a/sapl/templates/relatorios/relatorio_materia_tramitacao.html b/sapl/templates/relatorios/relatorio_materia_tramitacao.html
index ddfe6e38b..a75a7344b 100644
--- a/sapl/templates/relatorios/relatorio_materia_tramitacao.html
+++ b/sapl/templates/relatorios/relatorio_materia_tramitacao.html
@@ -9,7 +9,7 @@
Histórico de Tramitações da Matéria: {{ numero }}/{{ ano }}
Tipo de matéria: {{ tipo }}
- Autor: {{ autor }}
+ {% if autores|length > 1 %}Autores: {% else %}Autor: {% endif %}{{ autores|join:", " }}
{% for tramitacao in object%}
diff --git a/sapl/templates/sessao/sessaoplenaria_filter.html b/sapl/templates/sessao/sessaoplenaria_filter.html
index 51c2fdb17..7ea3f8b65 100644
--- a/sapl/templates/sessao/sessaoplenaria_filter.html
+++ b/sapl/templates/sessao/sessaoplenaria_filter.html
@@ -53,6 +53,13 @@
{% if s.upload_ata %}
Ata da Sessão
{% endif %}
+ {% if s.upload_pauta %}
+ Pauta da Sessão
+ {% endif %}
+ {% if s.upload_anexo %}
+ Anexo da Sessão
+ {% endif %}
+ Votações Nominais
{% endfor %}
diff --git a/sapl/utils.py b/sapl/utils.py
index 9bd6231dc..588494bd5 100644
--- a/sapl/utils.py
+++ b/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 = (
From c1d542ae349bfe6580a3b1e4ebdd295bdafb3d7c Mon Sep 17 00:00:00 2001
From: joaohortsenado <100957576+joaohortsenado@users.noreply.github.com>
Date: Thu, 24 Sep 2026 18:02:14 -0300
Subject: [PATCH 08/10] =?UTF-8?q?Mant=C3=A9m=20vis=C3=ADveis=20ao=20p?=
=?UTF-8?q?=C3=BAblico=20as=20sess=C3=B5es=20de=20hoje=20e=20de=20datas=20?=
=?UTF-8?q?passadas=20(#3864)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A regra do #3845 escondia do usuário anônimo toda sessão com "Sessão
iniciada?" e "Publicar pauta?" em Não. Há Casas que não marcam o campo
nas sessões já realizadas, e essas sessões, com presença e áudio, passaram
a dar 404 para o público.
O que caracteriza o cadastro antecipado é a data futura. Passa a ocultar
só as sessões não iniciadas, sem pauta publicada e com data posterior a
hoje; as de hoje e de datas passadas ficam sempre visíveis.
---
sapl/sessao/models.py | 11 ++++--
sapl/sessao/tests/test_sessao_view.py | 54 ++++++++++++++++++++-------
2 files changed, 49 insertions(+), 16 deletions(-)
diff --git a/sapl/sessao/models.py b/sapl/sessao/models.py
index fc418b08f..f86f2f414 100644
--- a/sapl/sessao/models.py
+++ b/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):
diff --git a/sapl/sessao/tests/test_sessao_view.py b/sapl/sessao/tests/test_sessao_view.py
index aef74c2ae..9e68953a6 100644
--- a/sapl/sessao/tests/test_sessao_view.py
+++ b/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}))
From 0e031959d9bdf369cbf5aa1dc816d32a3e746b0b Mon Sep 17 00:00:00 2001
From: joaohortsenado <100957576+joaohortsenado@users.noreply.github.com>
Date: Thu, 24 Sep 2026 18:12:24 -0300
Subject: [PATCH 09/10] Permite ajustar e persistir o tamanho da letra no
editor de textos articulados (#3862)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* Permite ajustar e persistir o tamanho da letra no editor de textos articulados
Os botões a/A da leitura alteravam o font-size de cada dispositivo via
jQuery, sem persistir, e não existiam na tela de edição. Com isso, a
única forma de aumentar a letra era formatar o texto no TinyMCE, o que
gravava o tamanho no conteúdo do dispositivo e valia apenas para ele.
- Escala de leitura aplicada via variável CSS (--cp-font-scale) no
container .cp, salva no localStorage (80% a 250%, passo de 10%)
- Botões a/A também na tela de edição (text_edit)
- Textarea do editor simples e corpo do TinyMCE acompanham a escala
- Linha de vigências não é afetada pela escala
- initTextRichEditor passa a retornar a promise do tinymce.init
* Ajusta a escala de fonte conforme a revisão do PR
- Agrupa as constantes no objeto congelado EscalaFonte, com o valor
padrão em EscalaFonte.PADRAO.
- Extrai o helper clamp() para limitar a escala ao intervalo permitido.
- Separa, no log, a falha de acesso ao localStorage do valor salvo
inválido, e registra também a falha ao salvar.
- Renomeia aplicaEscalaFonte/salvaEscalaFonte para o infinitivo.
---------
Co-authored-by: Edward <9326037+edwardoliveira@users.noreply.github.com>
---
.../__apps/compilacao/js/old/compilacao.js | 70 ++++++++++++++++++-
.../compilacao/js/old/compilacao_edit.js | 4 +-
.../__apps/compilacao/scss/compilacao.scss | 9 +++
frontend/src/__global/js/tinymce/index.js | 2 +-
sapl/templates/compilacao/text_edit.html | 10 ++-
5 files changed, 89 insertions(+), 6 deletions(-)
diff --git a/frontend/src/__apps/compilacao/js/old/compilacao.js b/frontend/src/__apps/compilacao/js/old/compilacao.js
index f7176c473..8853d6425 100644
--- a/frontend/src/__apps/compilacao/js/old/compilacao.js
+++ b/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
}
diff --git a/frontend/src/__apps/compilacao/js/old/compilacao_edit.js b/frontend/src/__apps/compilacao/js/old/compilacao_edit.js
index ba651188b..27060135b 100644
--- a/frontend/src/__apps/compilacao/js/old/compilacao_edit.js
+++ b/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()
}
diff --git a/frontend/src/__apps/compilacao/scss/compilacao.scss b/frontend/src/__apps/compilacao/scss/compilacao.scss
index 82c52f7f6..842436b24 100644
--- a/frontend/src/__apps/compilacao/scss/compilacao.scss
+++ b/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;
diff --git a/frontend/src/__global/js/tinymce/index.js b/frontend/src/__global/js/tinymce/index.js
index 51eed1efa..3f3ca312e 100644
--- a/frontend/src/__global/js/tinymce/index.js
+++ b/frontend/src/__global/js/tinymce/index.js
@@ -31,5 +31,5 @@ window.initTextRichEditor = function (elements, readonly = false, paste_as_text
configTinymce.menubar = false
configTinymce.toolbar = false
}
- window.tinymce.init(configTinymce)
+ return window.tinymce.init(configTinymce)
}
diff --git a/sapl/templates/compilacao/text_edit.html b/sapl/templates/compilacao/text_edit.html
index ada437934..ed7055e62 100644
--- a/sapl/templates/compilacao/text_edit.html
+++ b/sapl/templates/compilacao/text_edit.html
@@ -17,8 +17,14 @@
{% if object.editing_locked %}{% trans 'Desbloquear Edição' %}{%else%}{% trans 'Publicar Texto' %}{% endif %}
{% endif %}
-
- {% include 'compilacao/textoarticulado_menu_config.html' %}
+
+
+
+ {% include 'compilacao/textoarticulado_menu_config.html' %}
+
{% if object.dispositivos_set.count == 1 %}
From ecfd65a8b353be5368bce5ff624d19f4b95d3491 Mon Sep 17 00:00:00 2001
From: joaohortsenado <100957576+joaohortsenado@users.noreply.github.com>
Date: Thu, 24 Sep 2026 18:15:50 -0300
Subject: [PATCH 10/10] =?UTF-8?q?Converte=20t=C3=ADtulos=20colados=20em=20?=
=?UTF-8?q?par=C3=A1grafos=20no=20editor=20de=20dispositivos=20(#3863)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Texto colado de estilos de título do Word ou de páginas web chega ao
TinyMCE como h1-h6: fica em negrito e não pode ser desfeito, porque o
botão de negrito apenas acrescenta um strong e "remover formatação"
mantém o título. A barra também não oferecia como trocar o bloco, pois
"styleselect" não existe mais no TinyMCE 7 e era ignorado.
- initTextRichEditor ganha o parâmetro paste_titulos_como_paragrafo,
que converte h1-h6 colados em p, mantendo o alinhamento
- Parâmetro ativado nos editores de dispositivos da compilação
- "styleselect" substituído por "blocks" na barra de ferramentas
Co-authored-by: Edward <9326037+edwardoliveira@users.noreply.github.com>
---
frontend/src/__global/js/tinymce/index.js | 23 +++++++++++++++++--
.../dispositivo_form_alteracao.html | 2 +-
.../dispositivo_form_edicao_basica.html | 2 +-
3 files changed, 23 insertions(+), 4 deletions(-)
diff --git a/frontend/src/__global/js/tinymce/index.js b/frontend/src/__global/js/tinymce/index.js
index 3f3ca312e..ee20a7089 100644
--- a/frontend/src/__global/js/tinymce/index.js
+++ b/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,10 +38,13 @@ 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
diff --git a/sapl/templates/compilacao/dispositivo_form_alteracao.html b/sapl/templates/compilacao/dispositivo_form_alteracao.html
index 711472d01..c7d8562d8 100644
--- a/sapl/templates/compilacao/dispositivo_form_alteracao.html
+++ b/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);
{% endblock %}
diff --git a/sapl/templates/compilacao/dispositivo_form_edicao_basica.html b/sapl/templates/compilacao/dispositivo_form_edicao_basica.html
index 5eca7a09d..c4c5d8e2b 100644
--- a/sapl/templates/compilacao/dispositivo_form_edicao_basica.html
+++ b/sapl/templates/compilacao/dispositivo_form_edicao_basica.html
@@ -31,6 +31,6 @@
});
}
- initTextRichEditor();
+ initTextRichEditor(null, false, false, true);
{% endblock %}