diff --git a/sapl/painel/tests/tests.py b/sapl/painel/tests/tests.py
index 826bf90de..dd7753d18 100644
--- a/sapl/painel/tests/tests.py
+++ b/sapl/painel/tests/tests.py
@@ -9,7 +9,8 @@ from sapl.base.models import AppConfig as ConfiguracoesAplicacao
from sapl.materia.models import MateriaLegislativa, TipoMateriaLegislativa
from sapl.parlamentares.models import (Legislatura, Mandato, Parlamentar,
SessaoLegislativa, Votante)
-from sapl.sessao.models import (OrdemDia, PresencaOrdemDia, SessaoPlenaria,
+from sapl.sessao.models import (ExpedienteMateria, OrdemDia,
+ PresencaOrdemDia, SessaoPlenaria,
TipoResultadoVotacao, TipoSessaoPlenaria,
VotoParlamentar)
@@ -158,6 +159,11 @@ def test_post_de_voto_bloqueado_nao_persiste(admin_client, admin_user):
assert not VotoParlamentar.objects.filter(
ordem=ordem, parlamentar=parlamentar).exists()
+ # voto_individual.html é avulso (não herda base.html): o erro precisa
+ # aparecer na própria tela do tablet.
+ pagina = admin_client.get(response.url)
+ assert 'A votação não está disponível para novos votos.' in pagina.content.decode()
+
@pytest.mark.django_db(transaction=False)
def test_post_rejeita_valor_de_voto_invalido(admin_client, admin_user):
@@ -171,6 +177,9 @@ def test_post_rejeita_valor_de_voto_invalido(admin_client, admin_user):
assert not VotoParlamentar.objects.filter(
ordem=ordem, parlamentar=parlamentar).exists()
+ pagina = admin_client.get(response.url)
+ assert 'Voto inválido.' in pagina.content.decode()
+
@pytest.mark.django_db(transaction=False)
def test_propria_tela_nao_mostra_voto_de_outra_materia(admin_client):
@@ -193,7 +202,6 @@ def test_propria_tela_nao_mostra_voto_de_outra_materia(admin_client):
ordem_antiga.save()
baker.make(OrdemDia, sessao_plenaria=sessao, materia=_materia(),
tipo_votacao=NOMINAL, votacao_aberta=True, registro_aberto=False)
- baker.make(PresencaOrdemDia, sessao_plenaria=sessao, parlamentar=vereador)
status_url = reverse('sapl.painel:voto_individual_status')
@@ -228,10 +236,10 @@ def test_get_dados_painel_nao_usa_etag_incompleto(admin_client):
@pytest.mark.django_db(transaction=False)
def test_painel_exibe_nao_votou_para_parlamentar_sem_voto(admin_client):
"""
- Regressão: depois que o fechamento deixou de persistir o valor
- provisório "Não Votou", parlamentares sem VotoParlamentar passaram a
- chegar ao painel como null. O JavaScript não pode renderizar esse null
- literalmente no telão.
+ Quem estava presente e não votou chega ao painel como 'Não Votou' depois
+ do encerramento. Para dado legado sem VotoParlamentar (voto null), o
+ JavaScript mostra "Não votou" só com a votação registrada — com ela
+ aberta, voto vazio significa apenas "ainda não votou".
"""
baker.make(ConfiguracoesAplicacao, mostrar_voto=True,
mostrar_brasao_painel=False)
@@ -261,12 +269,12 @@ def test_painel_exibe_nao_votou_para_parlamentar_sem_voto(admin_client):
'sapl.painel:dados_painel', kwargs={'pk': sessao.pk})).json()
parlamentar_sem_voto = next(
p for p in dados['presentes'] if p['parlamentar_id'] == nao_votou.pk)
- assert parlamentar_sem_voto['voto'] is None
+ assert parlamentar_sem_voto['voto'] == 'Não Votou'
painel = admin_client.get(reverse(
'sapl.painel:painel_principal', kwargs={'pk': sessao.pk}))
assert painel.status_code == 200
- assert b'if (!parlamentar.voto)' in painel.content
+ assert b'if (!parlamentar.voto && registrada)' in painel.content
assert 'Não votou'.encode() in painel.content
@@ -332,3 +340,25 @@ def test_votante_status_reflete_estado_e_nao_exige_permissao_do_painel():
data2 = resposta2.json()
assert data2['voto_parlamentar'] == 'Não'
assert 'encerramento da votação' in data2['status_message']
+
+
+@pytest.mark.django_db(transaction=False)
+def test_votante_status_com_duas_votacoes_abertas_nao_acumula_mensagens():
+ """
+ Uma OrdemDia e uma ExpedienteMateria abertas ao mesmo tempo (as
+ constraints são por tabela): o poll do tablet devolve o erro em
+ error_message, em texto puro, sem empilhar mensagens na sessão.
+ """
+ sessao, ordem = _ordem_nominal_aberta()
+ vereador, votante_client = _votante_com_client(sessao)
+ outra_sessao = _sessao_plenaria()
+ baker.make(ExpedienteMateria, sessao_plenaria=outra_sessao,
+ materia=_materia(), tipo_votacao=NOMINAL, votacao_aberta=True)
+
+ status_url = reverse('sapl.painel:voto_individual_status')
+ for _ in range(2):
+ data = votante_client.get(status_url).json()
+
+ assert 'mais de uma' in data['error_message']
+ assert '<' not in data['error_message']
+ assert '_messages' not in votante_client.session
diff --git a/sapl/painel/views.py b/sapl/painel/views.py
index 68b798af0..67a2016ca 100644
--- a/sapl/painel/views.py
+++ b/sapl/painel/views.py
@@ -45,6 +45,10 @@ def votacao_aberta(request):
Função que verifica se há somente 1 uma matéria aberta ou
nenhuma. É utilizada como uma função auxiliar para a view
votante_view.
+
+ A mensagem de erro volta como texto puro e é exibida pelo chamador em
+ error_message (inclusive no poll JSON do tablet), então não é
+ registrada em messages — senão cada poll empilharia uma cópia na sessão.
'''
logger = logging.getLogger(__name__)
username = request.user.username
@@ -54,19 +58,13 @@ def votacao_aberta(request):
Q(expedientemateria__votacao_aberta=True)).distinct()
if len(votacoes_abertas) > 1:
- msg_abertas = []
- for v in votacoes_abertas:
- msg_abertas.append('''
%s''' % (
- reverse('sapl.sessao:sessaoplenaria_detail',
- kwargs={'pk': v.id}),
- v.__str__()))
+ sessoes = ', '.join(str(v) for v in votacoes_abertas)
logger.info('user=' + username + '. Existe mais de uma votações aberta. Elas se encontram '
- 'nas seguintes Sessões: ' + ', '.join(msg_abertas) + '. '
- 'Para votar, peça para que o Operador feche-as.')
+ 'nas seguintes Sessões: ' + sessoes + '. '
+ 'Para votar, peça para que o Operador feche-as.')
msg = _('Existe mais de uma votações aberta. Elas se encontram '
- 'nas seguintes Sessões: ' + ', '.join(msg_abertas) + '. '
- 'Para votar, peça para que o Operador feche-as.')
- messages.add_message(request, messages.INFO, msg)
+ 'nas seguintes Sessões: ' + sessoes + '. '
+ 'Para votar, peça para que o Operador feche-as.')
return None, msg
elif len(votacoes_abertas) == 1:
@@ -79,18 +77,11 @@ def votacao_aberta(request):
numero_materias_abertas = len(ordens) + len(expedientes)
if numero_materias_abertas > 1:
+ sessao = str(votacoes_abertas.first())
logger.info('user=' + username + '. Existe mais de uma votação aberta na Sessão: ' +
- ('''%s''' % (
- reverse('sapl.sessao:sessaoplenaria_detail',
- kwargs={'pk': votacoes_abertas.first().id}),
- votacoes_abertas.first().__str__())))
- msg = _('Existe mais de uma votação aberta na Sessão: ' +
- ('''%s''' % (
- reverse('sapl.sessao:sessaoplenaria_detail',
- kwargs={'pk': votacoes_abertas.first().id}),
- votacoes_abertas.first().__str__())) +
- 'Para votar, peça para que o Operador as feche.')
- messages.add_message(request, messages.INFO, msg)
+ sessao)
+ msg = _('Existe mais de uma votação aberta na Sessão: ' + sessao +
+ '. Para votar, peça para que o Operador as feche.')
return None, msg
return votacoes_abertas.first(), None
@@ -314,16 +305,13 @@ def votante_view(request):
request,
_('A votação não está mais disponível para novos votos.'))
else:
- try:
- with transaction.atomic():
- voto, created = (VotoParlamentar.objects
- .select_for_update().get_or_create(
- parlamentar=parlamentar,
- **fase_sessao))
- except IntegrityError:
- voto = VotoParlamentar.objects.select_for_update().get(
- parlamentar=parlamentar, **fase_sessao)
-
+ # get_or_create já cria num savepoint e, se der
+ # IntegrityError, refaz o get() no mesmo queryset
+ # (com select_for_update).
+ voto, _created = (VotoParlamentar.objects
+ .select_for_update().get_or_create(
+ parlamentar=parlamentar,
+ **fase_sessao))
voto.voto = voto_submetido
voto.ip = get_client_ip(request)
voto.user = request.user
diff --git a/sapl/sessao/migrations/0070_votoparlamentar_unique_constraint.py b/sapl/sessao/migrations/0071_votoparlamentar_unique_constraint.py
similarity index 68%
rename from sapl/sessao/migrations/0070_votoparlamentar_unique_constraint.py
rename to sapl/sessao/migrations/0071_votoparlamentar_unique_constraint.py
index 1d1570b2d..f23ff2cea 100644
--- a/sapl/sessao/migrations/0070_votoparlamentar_unique_constraint.py
+++ b/sapl/sessao/migrations/0071_votoparlamentar_unique_constraint.py
@@ -1,6 +1,7 @@
# Generated by Django 2.2.28 on 2026-08-16 17:36
from django.db import migrations, models
+from django.db.models import Count
def remove_votos_duplicados(apps, schema_editor):
@@ -8,15 +9,18 @@ def remove_votos_duplicados(apps, schema_editor):
VotoParlamentar = apps.get_model('sessao', 'VotoParlamentar')
for materia_field in ('ordem_id', 'expediente_id'):
- grupos = {}
- votos = VotoParlamentar.objects.exclude(**{materia_field: None})
- for voto in votos.iterator():
- chave = (voto.parlamentar_id, getattr(voto, materia_field))
- grupos.setdefault(chave, []).append(voto)
-
- for votos_duplicados in grupos.values():
- if len(votos_duplicados) < 2:
- continue
+ # order_by() limpa o Meta.ordering ('id'), que no Django 2.2 entraria
+ # no GROUP BY e deixaria todo grupo com n=1.
+ duplicadas = (VotoParlamentar.objects
+ .exclude(**{materia_field: None})
+ .order_by()
+ .values('parlamentar_id', materia_field)
+ .annotate(n=Count('id'))
+ .filter(n__gt=1))
+ for chave in duplicadas:
+ votos_duplicados = list(VotoParlamentar.objects.filter(
+ parlamentar_id=chave['parlamentar_id'],
+ **{materia_field: chave[materia_field]}))
voto_preservado = max(
votos_duplicados,
key=lambda voto: (
@@ -33,7 +37,7 @@ def remove_votos_duplicados(apps, schema_editor):
class Migration(migrations.Migration):
dependencies = [
- ('sessao', '0069_auto_20220919_1705'),
+ ('sessao', '0070_presenca_unica_por_sessao'),
]
operations = [
diff --git a/sapl/sessao/migrations/0071_votacao_aberta_unique_constraint.py b/sapl/sessao/migrations/0072_votacao_aberta_unique_constraint.py
similarity index 65%
rename from sapl/sessao/migrations/0071_votacao_aberta_unique_constraint.py
rename to sapl/sessao/migrations/0072_votacao_aberta_unique_constraint.py
index 897207077..0fc5e8d48 100644
--- a/sapl/sessao/migrations/0071_votacao_aberta_unique_constraint.py
+++ b/sapl/sessao/migrations/0072_votacao_aberta_unique_constraint.py
@@ -3,16 +3,22 @@
from django.db import migrations, models
-def fecha_matérias_abertas_duplicadas(apps, schema_editor):
+def fecha_materias_abertas_duplicadas(apps, schema_editor):
"""
Pré-requisito para o AddConstraint abaixo: se já existir mais de uma
OrdemDia/ExpedienteMateria com votacao_aberta=True (dado deixado por
versões anteriores, que não garantiam essa invariante), a constraint
- falharia ao ser criada. Mantém aberta apenas a mais recentemente
- modificada de cada tabela e fecha as demais.
+ falharia ao ser criada. Mantém aberta apenas a criada por último (maior
+ id) de cada tabela e fecha as demais.
+
+ Também zera registro_aberto, que mudou de significado ("a Mesa abriu a
+ tela de registro" -> "novos votos bloqueados"): o fluxo antigo marcava
+ True ao abrir a tela e não voltava para False ao encerrar, o que faria
+ uma matéria reaberta voltar com os tablets bloqueados.
"""
for model_name in ('OrdemDia', 'ExpedienteMateria'):
model = apps.get_model('sessao', model_name)
+ model.objects.filter(registro_aberto=True).update(registro_aberto=False)
abertas = model.objects.filter(votacao_aberta=True).order_by('-id')
for materia in abertas[1:]:
materia.votacao_aberta = False
@@ -22,12 +28,12 @@ def fecha_matérias_abertas_duplicadas(apps, schema_editor):
class Migration(migrations.Migration):
dependencies = [
- ('sessao', '0070_votoparlamentar_unique_constraint'),
+ ('sessao', '0071_votoparlamentar_unique_constraint'),
]
operations = [
migrations.RunPython(
- fecha_matérias_abertas_duplicadas, migrations.RunPython.noop),
+ fecha_materias_abertas_duplicadas, migrations.RunPython.noop),
migrations.AddConstraint(
model_name='expedientemateria',
constraint=models.UniqueConstraint(condition=models.Q(votacao_aberta=True), fields=('votacao_aberta',), name='sessao_expedientemateria_unique_votacao_aberta'),
diff --git a/sapl/sessao/tests/test_votacao_nominal.py b/sapl/sessao/tests/test_votacao_nominal.py
index 7256ac09d..6abe22a8e 100644
--- a/sapl/sessao/tests/test_votacao_nominal.py
+++ b/sapl/sessao/tests/test_votacao_nominal.py
@@ -1,4 +1,8 @@
+import re
+from unittest import mock
+
import pytest
+from django.contrib.messages import get_messages
from django.db import IntegrityError
from django.urls import reverse
from model_bakery import baker
@@ -63,22 +67,37 @@ def test_get_registrar_votacao_nao_bloqueia_novos_votos(admin_client):
@pytest.mark.django_db(transaction=False)
-def test_post_sem_acao_reconhecida_apenas_renderiza(admin_client):
- """
- O botão "Registrar Votação" agora faz POST (para evitar cache/replay de
- GET), mas sem nenhuma chave de ação reconhecida isso deve continuar
- sendo pura navegação, sem nenhum efeito colateral.
- """
+def test_post_sem_acao_reconhecida_redireciona_para_registro(admin_client):
sessao, ordem = _ordem_nominal_aberta()
_presente(sessao)
- response = admin_client.post(_registrar_url(sessao, ordem), {})
+ response = admin_client.post(_registrar_url(sessao, ordem) + '?page=2', {})
- assert response.status_code == 200
+ assert response.status_code == 302
+ assert response.url == _registrar_url(sessao, ordem) + '?page=2'
ordem.refresh_from_db()
assert ordem.registro_aberto is False
+@pytest.mark.django_db(transaction=False)
+def test_botao_registrar_votacao_nominal_usa_get(admin_client):
+ """
+ A tela de registro não tem efeito colateral no GET; com o botão em GET
+ o ?page vai na URL e é preservado até o redirect final do post().
+ """
+ sessao, ordem = _ordem_nominal_aberta()
+
+ response = admin_client.get(
+ reverse('sapl.sessao:ordemdia_list', kwargs={'pk': sessao.pk}))
+
+ conteudo = response.content.decode()
+ form = re.search(
+ r'