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/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()
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 181cb1550..d26d685f8 100644
--- a/sapl/sessao/models.py
+++ b/sapl/sessao/models.py
@@ -612,6 +612,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
@@ -768,6 +771,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 a343076f4..71de6f244 100755
--- a/sapl/sessao/views.py
+++ b/sapl/sessao/views.py
@@ -1481,26 +1481,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!')
@@ -1596,26 +1601,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))
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