Browse Source

Merge branch '3.1.x' into fix/painel-registro-votacao-trava

fix/painel-registro-votacao-trava
Edward 2 weeks ago
committed by GitHub
parent
commit
282b6d563a
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 24
      CHANGES.md
  2. 2
      docker/docker-compose.yaml
  3. 13
      sapl/compilacao/utils.py
  4. 44
      sapl/compilacao/views.py
  5. 11
      sapl/relatorios/views.py
  6. 40
      sapl/sessao/migrations/0070_presenca_unica_por_sessao.py
  7. 6
      sapl/sessao/models.py
  8. 102
      sapl/sessao/tests/test_sessao_view.py
  9. 52
      sapl/sessao/views.py
  10. 2
      sapl/settings.py
  11. 2
      sapl/templates/base.html

24
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
========================

2
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

13
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

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

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

40
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')},
),
]

6
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 = [

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

52
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()
# 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()
# 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))

2
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'

2
sapl/templates/base.html

@ -200,7 +200,7 @@
<small>
Desenvolvido pelo <a href="http://www.interlegis.leg.br/">Interlegis</a> em software livre e aberto.
</small>
<span>Release: 3.1.165-RC2</span>
<span>Release: 3.1.165-RC3</span>
</p>
</div>

Loading…
Cancel
Save