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))