From 96ee98d414d5efc60538fd80d751889aaf250308 Mon Sep 17 00:00:00 2001
From: joaohortsenado <100957576+joaohortsenado@users.noreply.github.com>
Date: Thu, 16 Jul 2026 12:58:47 -0300
Subject: [PATCH 1/8] =?UTF-8?q?Corrige=20ordem=20dos=20expedientes=20no=20?=
=?UTF-8?q?PDF=20do=20Resumo=20da=20Sess=C3=A3o=20(#3844)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
O PDF do Resumo da Sessão ordenava os expedientes alfabeticamente pelo
nome do tipo, ignorando o campo `ordenacao` de TipoExpediente. Numa Casa
que cadastre vários tipos, isso embaralha o documento: "Grande
Expediente" sai antes de "Leitura e Aprovação da Ata" e o encerramento
pode ser impresso logo após a abertura.
A tela do Resumo e o Extrato passam por sapl.sessao.views.get_expedientes,
que já ordena por `tipo__ordenacao`; apenas o relatório em PDF, que usa
get_sessao_plenaria, ficou para trás. O order_by('tipo__nome') havia sido
introduzido em 25be37eb justamente para acompanhar o Resumo, mas quando
2eb0b796 (#2920) criou o campo `ordenacao` somente get_expedientes foi
atualizado, e os dois caminhos divergiram em silêncio.
Alinha get_sessao_plenaria à mesma ordenação e adiciona testes que travam
a equivalência entre o PDF e o Resumo exibido em tela.
OSTicket #125461
---
sapl/relatorios/tests.py | 53 ++++++++++++++++++++++++++++++++++++++--
sapl/relatorios/views.py | 4 ++-
2 files changed, 54 insertions(+), 3 deletions(-)
diff --git a/sapl/relatorios/tests.py b/sapl/relatorios/tests.py
index a79ca8be5..fe46e86c4 100644
--- a/sapl/relatorios/tests.py
+++ b/sapl/relatorios/tests.py
@@ -1,3 +1,52 @@
-# from django.test import TestCase
+import pytest
+from model_bakery import baker
-# Create your tests here.
+from sapl.base.models import CasaLegislativa
+from sapl.relatorios.views import get_sessao_plenaria
+from sapl.sessao.models import ExpedienteSessao, SessaoPlenaria, TipoExpediente
+from sapl.sessao.views import get_expedientes
+
+
+def cria_sessao_com_expedientes():
+ """Cria uma sessão cujos expedientes têm `ordenacao` inversa à ordem
+ alfabética dos nomes -- o caso que expõe o OSTicket #125461."""
+ sessao = baker.make(SessaoPlenaria)
+ tipo_leitura = baker.make(
+ TipoExpediente, nome='Leitura e Aprovação da Ata', ordenacao=1)
+ tipo_grande = baker.make(
+ TipoExpediente, nome='Grande Expediente', ordenacao=2)
+
+ for tipo in (tipo_grande, tipo_leitura):
+ baker.make(ExpedienteSessao, sessao_plenaria=sessao, tipo=tipo,
+ conteudo='
Conteúdo de {}.
'.format(tipo.nome))
+
+ return sessao
+
+
+@pytest.mark.django_db(transaction=False)
+def test_relatorio_sessao_respeita_ordenacao_do_tipo_expediente():
+ """O PDF da Sessão Plenária deve seguir o campo `ordenacao` de
+ TipoExpediente, e não a ordem alfabética do nome."""
+ sessao = cria_sessao_com_expedientes()
+ casa = baker.make(CasaLegislativa)
+ user = baker.make('auth.User')
+
+ lst_expedientes = get_sessao_plenaria(sessao, casa, user)[6]
+ nomes = [e['nom_expediente'] for e in lst_expedientes]
+
+ assert nomes == ['Leitura e Aprovação da Ata', 'Grande Expediente']
+
+
+@pytest.mark.django_db(transaction=False)
+def test_relatorio_sessao_tem_mesma_ordem_de_expedientes_do_resumo():
+ """O PDF e o Resumo exibido em tela não podem divergir: foi essa
+ divergência que originou o OSTicket #125461."""
+ sessao = cria_sessao_com_expedientes()
+ casa = baker.make(CasaLegislativa)
+ user = baker.make('auth.User')
+
+ do_pdf = [e['nom_expediente']
+ for e in get_sessao_plenaria(sessao, casa, user)[6]]
+ da_tela = [e['tipo'].nome for e in get_expedientes(sessao)['expedientes']]
+
+ assert do_pdf == da_tela
diff --git a/sapl/relatorios/views.py b/sapl/relatorios/views.py
index 60ba1635e..8b89342fd 100755
--- a/sapl/relatorios/views.py
+++ b/sapl/relatorios/views.py
@@ -609,8 +609,10 @@ def get_sessao_plenaria(sessao, casa, user):
# Exibe os Expedientes
lst_expedientes = []
+ # A ordenação deve ser a mesma de sapl.sessao.views.get_expedientes, para
+ # que o PDF confira com o Resumo exibido em tela. OSTicket #125461
expedientes = ExpedienteSessao.objects.filter(
- sessao_plenaria=sessao).order_by('tipo__nome')
+ sessao_plenaria=sessao).order_by('tipo__ordenacao', 'tipo__nome')
for e in expedientes:
conteudo = e.conteudo
if not is_empty(conteudo):
From 9da03c1f083e7920530a0bc864757e7728ed3ff5 Mon Sep 17 00:00:00 2001
From: Edward Oliveira
Date: Mon, 3 Aug 2026 14:19:15 -0300
Subject: [PATCH 2/8] hot-fix: Remove alerta errado
---
sapl/materia/forms.py | 4 ----
1 file changed, 4 deletions(-)
diff --git a/sapl/materia/forms.py b/sapl/materia/forms.py
index 3615fee47..9a0d5007c 100644
--- a/sapl/materia/forms.py
+++ b/sapl/materia/forms.py
@@ -1944,10 +1944,6 @@ class ProposicaoForm(FileFieldCheckMixin, forms.ModelForm):
fields = [
to_column((Fieldset(
TipoProposicao._meta.verbose_name, Field('tipo')), 12)),
- to_column(
- (Alert('teste',
- css_class="ementa_materia hidden alert-info",
- dismiss=False), 12)),
to_column(('descricao', 12)),
to_column(('observacao', 12)),
From a258c0a232013e6663865a22578fbef454086a17 Mon Sep 17 00:00:00 2001
From: Edward Oliveira
Date: Mon, 3 Aug 2026 15:48:02 -0300
Subject: [PATCH 3/8] 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 4/8] =?UTF-8?q?Oculta=20sess=C3=B5es=20plen=C3=A1rias=20n?=
=?UTF-8?q?=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 5/8] =?UTF-8?q?Remove=20limite=20de=20caracteres=20do=20ca?=
=?UTF-8?q?mpo=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 6/8] 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 7/8] 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 8/8] =?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))