Browse Source

fix: aplica ajustes da revisão do #3855 na votação nominal

- renumera as migrações para 0071/0072, após 0070_presenca_unica_por_sessao
- encerramento conta só os presentes e grava 'Não Votou' para quem não votou
- fechar outras votações usa update() e zera registro_aberto
- abertura simultânea mostra erro em vez de 500; "foram fechadas" só após commit
- poll do tablet não acumula mensagens; voto_individual.html exibe messages
- poll do tablet recarrega em resposta não-JSON e não sobrepõe requisições
- remove timers duplicados de conta_votos e o hidden obsoleto em nominal.html
- painel só mostra "Não votou" após o registro da votação
- "Registrar Votação" volta a ser GET
- 0071 carrega só os grupos duplicados; 0072 zera registro_aberto legado
- usa get_or_create diretamente e remove código morto

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fix/painel-registro-votacao-trava
Edward Ribeiro 1 week ago
parent
commit
eacfd6ae82
  1. 46
      sapl/painel/tests/tests.py
  2. 52
      sapl/painel/views.py
  3. 24
      sapl/sessao/migrations/0071_votoparlamentar_unique_constraint.py
  4. 16
      sapl/sessao/migrations/0072_votacao_aberta_unique_constraint.py
  5. 226
      sapl/sessao/tests/test_votacao_nominal.py
  6. 188
      sapl/sessao/views.py
  7. 7
      sapl/templates/painel/index.html
  8. 25
      sapl/templates/painel/voto_individual.html
  9. 8
      sapl/templates/sessao/votacao/nominal.html

46
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

52
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('''<li><a href="%s">%s</a></li>''' % (
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: ' +
('''<li><a href="%s">%s</a></li>''' % (
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: ' +
('''<li><a href="%s">%s</a></li>''' % (
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

24
sapl/sessao/migrations/0070_votoparlamentar_unique_constraint.py → 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 = [

16
sapl/sessao/migrations/0071_votacao_aberta_unique_constraint.py → 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'),

226
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'<form action="%s"[^>]*>' % re.escape(_registrar_url(sessao, ordem)),
conteudo)
assert form
assert form.group(0) == '<form action="%s">' % _registrar_url(sessao, ordem)
@pytest.mark.django_db(transaction=False)
def test_bloquear_e_reabrir_votacao_nao_mexe_em_votos_existentes(admin_client):
sessao, ordem = _ordem_nominal_aberta()
@ -145,6 +164,88 @@ def test_salvar_votacao_nao_sobrescreve_voto_ja_registrado(admin_client):
assert ordem.votacao_aberta is False
assert ordem.registro_aberto is False
# O 'Não Votou' enviado para quem já votou é só o valor padrão do
# <select>, não um conflito — não pode gerar o aviso de não sobrescrito.
avisos = [str(m) for m in get_messages(response.wsgi_request)]
assert not any('não foram sobrescritos' in a for a in avisos)
@pytest.mark.django_db(transaction=False)
def test_salvar_votacao_avisa_quando_voto_do_tablet_diverge_do_formulario(admin_client):
sessao, ordem = _ordem_nominal_aberta()
ja_votou = _presente(sessao)
baker.make(VotoParlamentar, ordem=ordem, parlamentar=ja_votou, voto='Sim')
tipo_resultado = baker.make(TipoResultadoVotacao, nome='Aprovada', natureza='A')
response = admin_client.post(_registrar_url(sessao, ordem), {
'salvar-votacao': '1',
'resultado_votacao': str(tipo_resultado.pk),
'observacao': '',
'voto_parlamentar': ['Não:{}'.format(ja_votou.pk)],
})
assert response.status_code == 302
assert VotoParlamentar.objects.get(ordem=ordem, parlamentar=ja_votou).voto == 'Sim'
avisos = [str(m) for m in get_messages(response.wsgi_request)]
assert any('não foram sobrescritos' in a for a in avisos)
@pytest.mark.django_db(transaction=False)
def test_salvar_votacao_registra_nao_votou_para_presentes_sem_voto(admin_client):
"""
Relatórios, transparência e o painel leem VotoParlamentar por votação:
quem estava presente e não votou precisa ficar registrado como
'Não Votou', vinculado ao RegistroVotacao.
"""
sessao, ordem = _ordem_nominal_aberta()
votou = _presente(sessao)
nao_votou = _presente(sessao)
tipo_resultado = baker.make(TipoResultadoVotacao, nome='Aprovada', natureza='A')
response = admin_client.post(_registrar_url(sessao, ordem), {
'salvar-votacao': '1',
'resultado_votacao': str(tipo_resultado.pk),
'observacao': '',
'voto_parlamentar': [
'Sim:{}'.format(votou.pk),
'Não Votou:{}'.format(nao_votou.pk),
],
})
assert response.status_code == 302
registro = RegistroVotacao.objects.get(ordem=ordem)
assert registro.numero_votos_sim == 1
voto = VotoParlamentar.objects.get(ordem=ordem, parlamentar=nao_votou)
assert voto.voto == 'Não Votou'
assert voto.votacao_id == registro.id
@pytest.mark.django_db(transaction=False)
def test_salvar_votacao_ignora_voto_de_quem_teve_presenca_removida(admin_client):
"""
Parlamentar vota pelo tablet, sai e tem a presença removida antes do
encerramento: o voto não entra na contagem e é apagado.
"""
sessao, ordem = _ordem_nominal_aberta()
presente = _presente(sessao)
saiu = _presente(sessao)
baker.make(VotoParlamentar, ordem=ordem, parlamentar=saiu, voto='Sim')
PresencaOrdemDia.objects.filter(sessao_plenaria=sessao, parlamentar=saiu).delete()
tipo_resultado = baker.make(TipoResultadoVotacao, nome='Rejeitada', natureza='R')
response = admin_client.post(_registrar_url(sessao, ordem), {
'salvar-votacao': '1',
'resultado_votacao': str(tipo_resultado.pk),
'observacao': '',
'voto_parlamentar': ['Não:{}'.format(presente.pk)],
})
assert response.status_code == 302
registro = RegistroVotacao.objects.get(ordem=ordem)
assert registro.numero_votos_sim == 0
assert registro.numero_votos_nao == 1
assert not VotoParlamentar.objects.filter(ordem=ordem, parlamentar=saiu).exists()
@pytest.mark.django_db(transaction=False)
def test_salvar_votacao_sem_votos_nao_trava_selects_para_nova_tentativa(admin_client):
@ -304,6 +405,93 @@ def test_abrir_votacao_ja_aberta_e_idempotente(admin_client):
assert not any('foram fechadas' in m for m in mensagens)
def _abrir_votacao_url(sessao, ordem):
return reverse('sapl.sessao:abrir_votacao',
kwargs={'pk': ordem.pk, 'spk': sessao.pk}) + '?tipo_materia=ordem'
@pytest.mark.django_db(transaction=False)
def test_abrir_outra_votacao_zera_bloqueio_da_anterior(admin_client):
"""
Abrir Y sem encerrar X fecha X; X não pode voltar com os tablets
bloqueados (registro_aberto=True) quando for reaberta.
"""
sessao, x = _ordem_nominal_aberta()
sessao.iniciada = True
sessao.finalizada = False
sessao.save()
_presente(sessao)
x.registro_aberto = True
x.save()
y = baker.make(OrdemDia, sessao_plenaria=sessao, materia=_materia(),
tipo_votacao=NOMINAL, votacao_aberta=False)
admin_client.get(_abrir_votacao_url(sessao, y))
x.refresh_from_db()
y.refresh_from_db()
assert y.votacao_aberta is True
assert x.votacao_aberta is False
assert x.registro_aberto is False
# Legado: matéria fechada que ficou com registro_aberto=True.
OrdemDia.objects.filter(pk=x.pk).update(registro_aberto=True)
admin_client.get(_abrir_votacao_url(sessao, x))
x.refresh_from_db()
assert x.votacao_aberta is True
assert x.registro_aberto is False
@pytest.mark.django_db(transaction=False)
def test_abrir_votacao_concorrente_mostra_erro_em_vez_de_500(admin_client):
"""
O lock de abrir_votacao() é por sessão, mas a unicidade de
votacao_aberta é global: uma abertura simultânea em outra sessão faz o
save cair no índice parcial. O fechamento das outras votações é
desfeito junto, então a mensagem "foram fechadas" não pode aparecer.
"""
sessao, ordem = _ordem_nominal_aberta()
ordem.votacao_aberta = False
ordem.save()
_, outra = _ordem_nominal_aberta()
sessao.iniciada = True
sessao.finalizada = False
sessao.save()
_presente(sessao)
with mock.patch.object(OrdemDia, 'save', side_effect=IntegrityError):
response = admin_client.get(_abrir_votacao_url(sessao, ordem), follow=True)
assert response.status_code == 200
ordem.refresh_from_db()
outra.refresh_from_db()
assert ordem.votacao_aberta is False
assert outra.votacao_aberta is True
mensagens = [str(m) for m in response.context['messages']]
assert any('aberta simultaneamente' in m for m in mensagens)
assert not any('foram fechadas' in m for m in mensagens)
@pytest.mark.django_db(transaction=False)
def test_abrir_votacao_avisa_que_fechou_as_outras(admin_client):
sessao, ordem = _ordem_nominal_aberta()
ordem.votacao_aberta = False
ordem.save()
_, outra = _ordem_nominal_aberta()
sessao.iniciada = True
sessao.finalizada = False
sessao.save()
_presente(sessao)
response = admin_client.get(_abrir_votacao_url(sessao, ordem), follow=True)
outra.refresh_from_db()
assert outra.votacao_aberta is False
mensagens = [str(m) for m in response.context['messages']]
assert any('foram fechadas' in m for m in mensagens)
@pytest.mark.django_db(transaction=False)
def test_unique_constraint_impede_voto_duplicado(admin_client):
sessao, ordem = _ordem_nominal_aberta()
@ -327,14 +515,14 @@ def test_unique_constraint_impede_duas_ordens_abertas():
@pytest.mark.django_db(transaction=False)
def test_migracao_0070_remove_votos_duplicados_antes_da_constraint():
def test_migracao_0071_remove_votos_duplicados_antes_da_constraint():
import importlib
from django.apps import apps as real_apps
from django.db import connection
migracao = importlib.import_module(
'sapl.sessao.migrations.0070_votoparlamentar_unique_constraint')
'sapl.sessao.migrations.0071_votoparlamentar_unique_constraint')
with connection.cursor() as cursor:
cursor.execute(
@ -356,9 +544,9 @@ def test_migracao_0070_remove_votos_duplicados_antes_da_constraint():
@pytest.mark.django_db(transaction=False)
def test_migracao_0071_fecha_duplicatas_antes_da_constraint():
def test_migracao_0072_fecha_duplicatas_antes_da_constraint():
"""
A função de dados da migration 0071 precisa fechar duplicatas
A função de dados da migration 0072 precisa fechar duplicatas
pré-existentes antes do AddConstraint — senão a migration falharia ao
ser aplicada num banco com dado antigo (de antes desta invariante
existir). Testa a função isoladamente: como o teste roda dentro de uma
@ -371,7 +559,7 @@ def test_migracao_0071_fecha_duplicatas_antes_da_constraint():
from django.db import connection
migracao = importlib.import_module(
'sapl.sessao.migrations.0071_votacao_aberta_unique_constraint')
'sapl.sessao.migrations.0072_votacao_aberta_unique_constraint')
with connection.cursor() as cursor:
cursor.execute('DROP INDEX sessao_ordemdia_unique_votacao_aberta')
@ -379,15 +567,23 @@ def test_migracao_0071_fecha_duplicatas_antes_da_constraint():
sessao, mais_antiga = _ordem_nominal_aberta()
mais_recente = baker.make(OrdemDia, sessao_plenaria=sessao,
materia=mais_antiga.materia, tipo_votacao=NOMINAL,
votacao_aberta=True)
votacao_aberta=True, registro_aberto=True)
assert mais_recente.pk > mais_antiga.pk
# Legado: o GET antigo da tela de registro deixava registro_aberto=True
# em matérias já encerradas.
encerrada = baker.make(OrdemDia, sessao_plenaria=sessao,
materia=mais_antiga.materia, tipo_votacao=NOMINAL,
votacao_aberta=False, registro_aberto=True)
migracao.fecha_matérias_abertas_duplicadas(real_apps, None)
migracao.fecha_materias_abertas_duplicadas(real_apps, None)
mais_antiga.refresh_from_db()
mais_recente.refresh_from_db()
encerrada.refresh_from_db()
assert mais_antiga.votacao_aberta is False
assert mais_recente.votacao_aberta is True
assert mais_recente.registro_aberto is False
assert encerrada.registro_aberto is False
@pytest.mark.django_db(transaction=False)

188
sapl/sessao/views.py

@ -13,7 +13,6 @@ from django.db import IntegrityError, transaction
from django.db.models import Max, Q
from django.http import JsonResponse
from django.http.response import Http404, HttpResponseRedirect
from django.middleware.csrf import get_token
from django.urls import reverse
from django.urls.base import reverse_lazy
from django.utils import timezone
@ -130,7 +129,12 @@ def verifica_presenca(request, model, spk, is_leitura=False):
return True
def verifica_votacoes_abertas(request):
def verifica_votacoes_abertas(request, mensagens):
"""
Fecha as votações abertas. A mensagem para o usuário vai para
`mensagens`, e o chamador só a registra depois do commit: se a transação
for desfeita, as votações não foram fechadas.
"""
votacoes_abertas = SessaoPlenaria.objects.filter(
Q(ordemdia__votacao_aberta=True) |
Q(expedientemateria__votacao_aberta=True)).distinct()
@ -149,18 +153,15 @@ def verifica_votacoes_abertas(request):
', '.join(msg_abertas) + '. Estas votações ou leituras foram fechadas.')
msg = _('Já existem votações ou leituras abertas nas seguintes Sessões: ' +
', '.join(msg_abertas) + '. Estas votações ou leituras foram fechadas.')
messages.add_message(request, messages.INFO, msg)
mensagens.append(msg)
for sessao in votacoes_abertas:
ordens = sessao.ordemdia_set.filter(votacao_aberta=True)
expediente = sessao.expedientemateria_set.filter(
votacao_aberta=True)
for o in ordens:
o.votacao_aberta = False
o.save()
for e in expediente:
e.votacao_aberta = False
e.save()
# update() em vez de save(): só toca estes dois campos e reavalia o
# WHERE sob o lock da linha, sem regravar um resultado que um
# "Encerrar Votação" concorrente tenha acabado de salvar.
OrdemDia.objects.filter(votacao_aberta=True).update(
votacao_aberta=False, registro_aberto=False)
ExpedienteMateria.objects.filter(votacao_aberta=True).update(
votacao_aberta=False, registro_aberto=False)
return True
@ -205,36 +206,49 @@ def abrir_votacao(request, pk, spk):
is_leitura = materia_votacao.tipo_votacao == 4
is_expediente = model is ExpedienteMateria
opened = False
mensagens = []
with transaction.atomic():
# select_for_update trava a linha da SessaoPlenaria durante toda a
# checagem+fechamento+abertura, para que dois "abrir votação"
# concorrentes (duplo clique, ou uma requisição lenta seguida de
# nova tentativa) não deixem duas matérias com votacao_aberta=True
# ao mesmo tempo — sapl/painel/views.py::votacao_aberta() trata
# esse caso redirecionando todos os tablets sem nenhuma mensagem
# clara de erro.
SessaoPlenaria.objects.select_for_update().get(id=spk)
# Reflete o estado mais atual sob o lock: outra requisição
# concorrente pode ter mudado votacao_aberta entre o SELECT inicial
# (antes do lock) e aqui.
materia_votacao.refresh_from_db()
ja_aberta = materia_votacao.votacao_aberta
# Reabrir a própria matéria que já está aberta precisa ser
# idempotente: verifica_votacoes_abertas() existe para fechar
# OUTRAS matérias concorrentes, e sua mensagem ("já existem
# votações abertas... foram fechadas") não faz sentido quando a
# única "conflitante" é ela mesma.
if (verifica_presenca(request, presenca_model, spk, is_leitura) and
(ja_aberta or verifica_votacoes_abertas(request)) and
verifica_sessao_iniciada(request, spk, is_leitura)):
materia_votacao.votacao_aberta = True
sessao = SessaoPlenaria.objects.get(id=spk)
sessao.painel_aberto = True
sessao.save()
materia_votacao.save()
opened = True
try:
with transaction.atomic():
# select_for_update trava a linha da SessaoPlenaria durante toda a
# checagem+fechamento+abertura, para que dois "abrir votação"
# concorrentes (duplo clique, ou uma requisição lenta seguida de
# nova tentativa) não deixem duas matérias com votacao_aberta=True
# ao mesmo tempo — sapl/painel/views.py::votacao_aberta() trata
# esse caso redirecionando todos os tablets sem nenhuma mensagem
# clara de erro.
SessaoPlenaria.objects.select_for_update().get(id=spk)
# Reflete o estado mais atual sob o lock: outra requisição
# concorrente pode ter mudado votacao_aberta entre o SELECT inicial
# (antes do lock) e aqui.
materia_votacao.refresh_from_db()
ja_aberta = materia_votacao.votacao_aberta
# Reabrir a própria matéria que já está aberta precisa ser
# idempotente: verifica_votacoes_abertas() existe para fechar
# OUTRAS matérias concorrentes, e sua mensagem ("já existem
# votações abertas... foram fechadas") não faz sentido quando a
# única "conflitante" é ela mesma.
if (verifica_presenca(request, presenca_model, spk, is_leitura) and
(ja_aberta or verifica_votacoes_abertas(request, mensagens)) and
verifica_sessao_iniciada(request, spk, is_leitura)):
materia_votacao.votacao_aberta = True
if not ja_aberta:
materia_votacao.registro_aberto = False
sessao = SessaoPlenaria.objects.get(id=spk)
sessao.painel_aberto = True
sessao.save()
materia_votacao.save()
opened = True
for msg in mensagens:
messages.add_message(request, messages.INFO, msg)
except IntegrityError:
# O lock acima é por sessão, mas a unicidade de votacao_aberta é
# global: uma abertura simultânea em outra sessão cai no índice
# parcial.
opened = False
messages.add_message(request, messages.ERROR, _(
'Outra votação foi aberta simultaneamente. Tente novamente.'))
if opened:
# Leva direto para a tela de registro do tipo de votação recém
@ -269,7 +283,7 @@ def abrir_votacao(request, pk, spk):
return HttpResponseRedirect(success_url)
def customize_link_materia(context, pk, has_permission, is_expediente, request=None):
def customize_link_materia(context, pk, has_permission, is_expediente):
for i, row in enumerate(context['rows']):
materia = context['object_list'][i].materia
obj = context['object_list'][i]
@ -397,26 +411,13 @@ def customize_link_materia(context, pk, has_permission, is_expediente, request=N
if has_permission:
if obj.tipo_votacao != LEITURA:
# Votação Nominal é a única que envolve votos individuais
# pelos tablets (VOTACAO_NOMINAL em sapl/painel/views.py), e
# é a única cuja tela de registro (VotacaoNominalAbstract)
# sabe tratar POST sem efeito colateral — por isso só ela é
# convertida para POST aqui; Simbólica/Secreta continuam GET.
metodo = ''
csrf_input = ''
if obj.tipo_votacao == NOMINAL:
metodo = ' method="post"'
csrf_input = (
'<input type="hidden" name="csrfmiddlewaretoken" value="%s" />'
% get_token(request))
btn_registrar = '''
<form action="%s"%s>
%s
<form action="%s">
<input type="submit" class="btn btn-primary"
value="Registrar Votação" />
%s
</form>''' % (
url, metodo, csrf_input, page_number)
url, page_number)
else:
btn_registrar = '''
<form action="%s">
@ -904,8 +905,7 @@ class MateriaOrdemDiaCrud(MasterDetailCrud):
context = super().get_context_data(**kwargs)
has_permition = self.request.user.has_module_perms(AppConfig.label)
return customize_link_materia(context, self.kwargs['pk'], has_permition, False,
request=self.request)
return customize_link_materia(context, self.kwargs['pk'], has_permition, False)
def recuperar_materia(request):
@ -981,8 +981,7 @@ class ExpedienteMateriaCrud(MasterDetailCrud):
context['page'] = self.request.GET.get('page')
has_permition = self.request.user.has_module_perms(AppConfig.label)
return customize_link_materia(context, self.kwargs['pk'], has_permition, True,
request=self.request)
return customize_link_materia(context, self.kwargs['pk'], has_permition, True)
class CreateView(MasterDetailCrud.CreateView):
form_class = ExpedienteMateriaForm
@ -3075,22 +3074,6 @@ class VotacaoNominalAbstract(SessaoPermissionMixin):
'votos': votos,
})
def _get_or_create_voto_parlamentar(self, lookup_field, lookup_value, parlamentar_id):
"""
get_or_create protegido contra a corrida de duas inserções
concorrentes para o mesmo (parlamentar, matéria) — ex.: o tablet do
parlamentar e o formulário em lote do operador chegando ao mesmo
tempo. Usa um savepoint próprio para que um IntegrityError aqui não
derrube a transação inteira do 'Encerrar Votação'.
"""
try:
with transaction.atomic():
return VotoParlamentar.objects.select_for_update().get_or_create(
parlamentar_id=parlamentar_id, **{lookup_field: lookup_value})
except IntegrityError:
return VotoParlamentar.objects.select_for_update().get(
parlamentar_id=parlamentar_id, **{lookup_field: lookup_value}), False
def _lock_materia_votacao(self, materia_votacao):
model = OrdemDia if self.ordem else ExpedienteMateria
return model.objects.select_for_update().get(pk=materia_votacao.pk)
@ -3168,20 +3151,20 @@ class VotacaoNominalAbstract(SessaoPermissionMixin):
return self._redirect_lista(kwargs, page)
for voto_submetido, parlamentar_id in votos_submetidos:
voto_parlamentar, created = self._get_or_create_voto_parlamentar(
fase_sessao_field, fase_sessao_value, parlamentar_id)
if voto_submetido == 'Não Votou':
# "Não Votou" é só o valor padrão do <select> para
# quem o operador não escolheu nada — não é um voto
# de fato. Gravá-lo agora travaria a linha desse
# parlamentar (nominal.html desabilita o <select>
# sempre que existe um VotoParlamentar) mesmo quando
# o fechamento falha por falta de votos reais. Os
# presentes que continuarem sem voto recebem
# 'Não Votou' só depois que o RegistroVotacao existe.
continue
voto_parlamentar, created = VotoParlamentar.objects.select_for_update().get_or_create(
parlamentar_id=parlamentar_id,
**{fase_sessao_field: fase_sessao_value})
if created:
if voto_submetido == 'Não Votou':
# "Não Votou" é só o valor padrão do <select>
# para quem o operador não escolheu nada — não é
# um voto de fato. Persisti-lo aqui travaria a
# linha desse parlamentar (nominal.html desabilita
# o <select> sempre que existe um VotoParlamentar)
# mesmo quando o fechamento falha por falta de
# votos reais, impedindo o operador de corrigir e
# tentar de novo.
voto_parlamentar.delete()
continue
voto_parlamentar.voto = voto_submetido
voto_parlamentar.user = request.user
voto_parlamentar.ip = get_client_ip(request)
@ -3195,7 +3178,8 @@ class VotacaoNominalAbstract(SessaoPermissionMixin):
skipped_parlamentares.append(voto_parlamentar.parlamentar)
votos_atuais = VotoParlamentar.objects.filter(
**{fase_sessao_field: fase_sessao_value})
**{fase_sessao_field: fase_sessao_value},
parlamentar_id__in=presentes.values('parlamentar_id'))
votos_sim = votos_atuais.filter(voto='Sim').count()
votos_nao = votos_atuais.filter(voto='Não').count()
abstencoes = votos_atuais.filter(voto='Abstenção').count()
@ -3207,10 +3191,6 @@ class VotacaoNominalAbstract(SessaoPermissionMixin):
'nenhum voto'))
return self.form_invalid(form)
# Remove todas as votação desta matéria, caso existam
RegistroVotacao.objects.filter(
**{fase_sessao_field: fase_sessao_value}).delete()
votacao = RegistroVotacao(
numero_votos_sim=votos_sim,
numero_votos_nao=votos_nao,
@ -3224,6 +3204,16 @@ class VotacaoNominalAbstract(SessaoPermissionMixin):
votacao.save()
votos_atuais.update(votacao_id=votacao.id)
VotoParlamentar.objects.bulk_create([
VotoParlamentar(
parlamentar_id=presenca.parlamentar_id,
voto='Não Votou',
votacao=votacao,
user=request.user,
ip=get_client_ip(request),
**{fase_sessao_field: fase_sessao_value})
for presenca in presentes.exclude(
parlamentar_id__in=votos_atuais.values('parlamentar_id'))])
materia_votacao.resultado = form.cleaned_data['resultado_votacao'].nome
materia_votacao.votacao_aberta = False
@ -3246,10 +3236,8 @@ class VotacaoNominalAbstract(SessaoPermissionMixin):
return self.form_valid(form)
# Nenhuma chave de ação reconhecida: navegação simples para a tela
# de registro (botão "Registrar Votação"), sem efeito colateral.
context = self._build_registro_context(materia_votacao, presentes, total)
return self.render_to_response(context)
# Nenhuma ação reconhecida: volta para a tela de registro (GET).
return self._redirect_same_registro(kwargs, page)
def form_invalid(self, form):
errors_tuple = [(form[e].label, form.errors[e])

7
sapl/templates/painel/index.html

@ -331,7 +331,7 @@
+ '</td></tr>')
}
else{
$('#parlamentares_list').append(show_voto(parlamentar))
$('#parlamentares_list').append(show_voto(parlamentar, !!data['registro']))
}
});
@ -510,10 +510,11 @@
})();
});
function show_voto(parlamentar) {
function show_voto(parlamentar, registrada) {
color = 'white'
if (!parlamentar.voto) {
// Com a votação ainda aberta, voto vazio só significa "ainda não votou".
if (!parlamentar.voto && registrada) {
parlamentar.voto = "Não votou";
}
if (parlamentar.voto == "Sim"){

25
sapl/templates/painel/voto_individual.html

@ -46,6 +46,10 @@
</head>
<body>
{% for message in messages %}
<h3><font color="#f0ad4e"><p align="center" style="font-family:Verdana">{{ message }}</p></font></h3>
{% endfor %}
{% if not error_message %}
<div id="blocoprincipal" style="display:block">
<h1><b><font color="#4FA64D"><p align="center">{{sessao}}</p></font></b></h1>
@ -213,7 +217,17 @@
function verificaEstado() {
fetch("{% url 'sapl.painel:voto_individual_status' %}", {credentials: 'same-origin'})
.then(function(resp) { return resp.json(); })
.then(function(resp) {
// Sessão expirada (redirect para o login), 403 ou 500: recarrega
// para o tablet não ficar parado na tela antiga.
var contentType = resp.headers.get('content-type') || '';
if (!resp.ok || resp.redirected ||
contentType.indexOf('application/json') === -1) {
document.location.reload();
throw new Error('resposta inesperada');
}
return resp.json();
})
.then(function(data) {
var novo = {
materiaId: data.materia_id ? String(data.materia_id) : "",
@ -228,9 +242,14 @@
document.location.reload();
}
})
.catch(function() { /* tenta de novo no próximo poll */ });
.catch(function() { /* falha de rede: tenta de novo no próximo poll */ })
.then(function() {
// Agenda o próximo só depois deste terminar, para não sobrepor
// polls quando o servidor está lento.
setTimeout(verificaEstado, 5000);
});
}
setInterval(verificaEstado, 5000);
setTimeout(verificaEstado, 5000);
})();
</script>

8
sapl/templates/sessao/votacao/nominal.html

@ -35,7 +35,6 @@
{% for parlamentar in parlamentares %}
<div class="col-md-4" id="styleparlamentar">{{parlamentar.0.nome_parlamentar}}</div>
<div class="col-md-5" data-parlamentar-row="{{parlamentar.0.id}}">
{% if parlamentar.1 %} <input type="hidden" name="voto_parlamentar" value="{{parlamentar.1}}:{{parlamentar.0.id}}" /> {% endif %}
<select id="voto_parlamentar" name="voto_parlamentar" class="form-control" data-parlamentar-select="{{parlamentar.0.id}}" {% if parlamentar.1 %} disabled {% endif %}>
<option value="Não Votou:{{parlamentar.0.id}}">Não Votou</option>
<option value="Sim:{{parlamentar.0.id}}" {% if parlamentar.1 == 'Sim' %} selected {% endif %}>Sim</option>
@ -132,8 +131,6 @@
}
conta_votos();
window.onload = conta_votos();
$(window).on('beforeunload', function () {
$("input[type=submit], input[type=button]").prop("disabled", "disabled");
});
@ -157,7 +154,6 @@
if (!data || !data.votos) {
return;
}
var mudou = false;
Object.keys(data.votos).forEach(function(parlamentarId) {
var voto = data.votos[parlamentarId];
if (!voto) {
@ -170,16 +166,12 @@
var optionValue = voto + ':' + parlamentarId;
if (select.val() !== optionValue) {
select.val(optionValue);
mudou = true;
}
if (!select.prop('disabled')) {
select.prop('disabled', true);
}
$('[data-parlamentar-tag="' + parlamentarId + '"]').show();
});
if (mudou) {
conta_votos();
}
});
}
atualiza_votos_ao_vivo();

Loading…
Cancel
Save