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.materia.models import MateriaLegislativa, TipoMateriaLegislativa
from sapl.parlamentares.models import (Legislatura, Mandato, Parlamentar, from sapl.parlamentares.models import (Legislatura, Mandato, Parlamentar,
SessaoLegislativa, Votante) SessaoLegislativa, Votante)
from sapl.sessao.models import (OrdemDia, PresencaOrdemDia, SessaoPlenaria, from sapl.sessao.models import (ExpedienteMateria, OrdemDia,
PresencaOrdemDia, SessaoPlenaria,
TipoResultadoVotacao, TipoSessaoPlenaria, TipoResultadoVotacao, TipoSessaoPlenaria,
VotoParlamentar) VotoParlamentar)
@ -158,6 +159,11 @@ def test_post_de_voto_bloqueado_nao_persiste(admin_client, admin_user):
assert not VotoParlamentar.objects.filter( assert not VotoParlamentar.objects.filter(
ordem=ordem, parlamentar=parlamentar).exists() 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) @pytest.mark.django_db(transaction=False)
def test_post_rejeita_valor_de_voto_invalido(admin_client, admin_user): 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( assert not VotoParlamentar.objects.filter(
ordem=ordem, parlamentar=parlamentar).exists() 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) @pytest.mark.django_db(transaction=False)
def test_propria_tela_nao_mostra_voto_de_outra_materia(admin_client): 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() ordem_antiga.save()
baker.make(OrdemDia, sessao_plenaria=sessao, materia=_materia(), baker.make(OrdemDia, sessao_plenaria=sessao, materia=_materia(),
tipo_votacao=NOMINAL, votacao_aberta=True, registro_aberto=False) 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') 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) @pytest.mark.django_db(transaction=False)
def test_painel_exibe_nao_votou_para_parlamentar_sem_voto(admin_client): def test_painel_exibe_nao_votou_para_parlamentar_sem_voto(admin_client):
""" """
Regressão: depois que o fechamento deixou de persistir o valor Quem estava presente e não votou chega ao painel como 'Não Votou' depois
provisório "Não Votou", parlamentares sem VotoParlamentar passaram a do encerramento. Para dado legado sem VotoParlamentar (voto null), o
chegar ao painel como null. O JavaScript não pode renderizar esse null JavaScript mostra "Não votou" só com a votação registrada — com ela
literalmente no telão. aberta, voto vazio significa apenas "ainda não votou".
""" """
baker.make(ConfiguracoesAplicacao, mostrar_voto=True, baker.make(ConfiguracoesAplicacao, mostrar_voto=True,
mostrar_brasao_painel=False) 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() 'sapl.painel:dados_painel', kwargs={'pk': sessao.pk})).json()
parlamentar_sem_voto = next( parlamentar_sem_voto = next(
p for p in dados['presentes'] if p['parlamentar_id'] == nao_votou.pk) 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( painel = admin_client.get(reverse(
'sapl.painel:painel_principal', kwargs={'pk': sessao.pk})) 'sapl.painel:painel_principal', kwargs={'pk': sessao.pk}))
assert painel.status_code == 200 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 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() data2 = resposta2.json()
assert data2['voto_parlamentar'] == 'Não' assert data2['voto_parlamentar'] == 'Não'
assert 'encerramento da votação' in data2['status_message'] 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 Função que verifica se há somente 1 uma matéria aberta ou
nenhuma. É utilizada como uma função auxiliar para a view nenhuma. É utilizada como uma função auxiliar para a view
votante_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__) logger = logging.getLogger(__name__)
username = request.user.username username = request.user.username
@ -54,19 +58,13 @@ def votacao_aberta(request):
Q(expedientemateria__votacao_aberta=True)).distinct() Q(expedientemateria__votacao_aberta=True)).distinct()
if len(votacoes_abertas) > 1: if len(votacoes_abertas) > 1:
msg_abertas = [] sessoes = ', '.join(str(v) for v in votacoes_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__()))
logger.info('user=' + username + '. Existe mais de uma votações aberta. Elas se encontram ' logger.info('user=' + username + '. Existe mais de uma votações aberta. Elas se encontram '
'nas seguintes Sessões: ' + ', '.join(msg_abertas) + '. ' 'nas seguintes Sessões: ' + sessoes + '. '
'Para votar, peça para que o Operador feche-as.') 'Para votar, peça para que o Operador feche-as.')
msg = _('Existe mais de uma votações aberta. Elas se encontram ' msg = _('Existe mais de uma votações aberta. Elas se encontram '
'nas seguintes Sessões: ' + ', '.join(msg_abertas) + '. ' 'nas seguintes Sessões: ' + sessoes + '. '
'Para votar, peça para que o Operador feche-as.') 'Para votar, peça para que o Operador feche-as.')
messages.add_message(request, messages.INFO, msg)
return None, msg return None, msg
elif len(votacoes_abertas) == 1: elif len(votacoes_abertas) == 1:
@ -79,18 +77,11 @@ def votacao_aberta(request):
numero_materias_abertas = len(ordens) + len(expedientes) numero_materias_abertas = len(ordens) + len(expedientes)
if numero_materias_abertas > 1: if numero_materias_abertas > 1:
sessao = str(votacoes_abertas.first())
logger.info('user=' + username + '. Existe mais de uma votação aberta na Sessão: ' + logger.info('user=' + username + '. Existe mais de uma votação aberta na Sessão: ' +
('''<li><a href="%s">%s</a></li>''' % ( sessao)
reverse('sapl.sessao:sessaoplenaria_detail', msg = _('Existe mais de uma votação aberta na Sessão: ' + sessao +
kwargs={'pk': votacoes_abertas.first().id}), '. Para votar, peça para que o Operador as feche.')
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)
return None, msg return None, msg
return votacoes_abertas.first(), None return votacoes_abertas.first(), None
@ -314,16 +305,13 @@ def votante_view(request):
request, request,
_('A votação não está mais disponível para novos votos.')) _('A votação não está mais disponível para novos votos.'))
else: else:
try: # get_or_create já cria num savepoint e, se der
with transaction.atomic(): # IntegrityError, refaz o get() no mesmo queryset
voto, created = (VotoParlamentar.objects # (com select_for_update).
.select_for_update().get_or_create( voto, _created = (VotoParlamentar.objects
parlamentar=parlamentar, .select_for_update().get_or_create(
**fase_sessao)) parlamentar=parlamentar,
except IntegrityError: **fase_sessao))
voto = VotoParlamentar.objects.select_for_update().get(
parlamentar=parlamentar, **fase_sessao)
voto.voto = voto_submetido voto.voto = voto_submetido
voto.ip = get_client_ip(request) voto.ip = get_client_ip(request)
voto.user = request.user 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 # Generated by Django 2.2.28 on 2026-08-16 17:36
from django.db import migrations, models from django.db import migrations, models
from django.db.models import Count
def remove_votos_duplicados(apps, schema_editor): def remove_votos_duplicados(apps, schema_editor):
@ -8,15 +9,18 @@ def remove_votos_duplicados(apps, schema_editor):
VotoParlamentar = apps.get_model('sessao', 'VotoParlamentar') VotoParlamentar = apps.get_model('sessao', 'VotoParlamentar')
for materia_field in ('ordem_id', 'expediente_id'): for materia_field in ('ordem_id', 'expediente_id'):
grupos = {} # order_by() limpa o Meta.ordering ('id'), que no Django 2.2 entraria
votos = VotoParlamentar.objects.exclude(**{materia_field: None}) # no GROUP BY e deixaria todo grupo com n=1.
for voto in votos.iterator(): duplicadas = (VotoParlamentar.objects
chave = (voto.parlamentar_id, getattr(voto, materia_field)) .exclude(**{materia_field: None})
grupos.setdefault(chave, []).append(voto) .order_by()
.values('parlamentar_id', materia_field)
for votos_duplicados in grupos.values(): .annotate(n=Count('id'))
if len(votos_duplicados) < 2: .filter(n__gt=1))
continue for chave in duplicadas:
votos_duplicados = list(VotoParlamentar.objects.filter(
parlamentar_id=chave['parlamentar_id'],
**{materia_field: chave[materia_field]}))
voto_preservado = max( voto_preservado = max(
votos_duplicados, votos_duplicados,
key=lambda voto: ( key=lambda voto: (
@ -33,7 +37,7 @@ def remove_votos_duplicados(apps, schema_editor):
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('sessao', '0069_auto_20220919_1705'), ('sessao', '0070_presenca_unica_por_sessao'),
] ]
operations = [ 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 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 Pré-requisito para o AddConstraint abaixo: se já existir mais de uma
OrdemDia/ExpedienteMateria com votacao_aberta=True (dado deixado por OrdemDia/ExpedienteMateria com votacao_aberta=True (dado deixado por
versões anteriores, que não garantiam essa invariante), a constraint versões anteriores, que não garantiam essa invariante), a constraint
falharia ao ser criada. Mantém aberta apenas a mais recentemente falharia ao ser criada. Mantém aberta apenas a criada por último (maior
modificada de cada tabela e fecha as demais. 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'): for model_name in ('OrdemDia', 'ExpedienteMateria'):
model = apps.get_model('sessao', model_name) 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') abertas = model.objects.filter(votacao_aberta=True).order_by('-id')
for materia in abertas[1:]: for materia in abertas[1:]:
materia.votacao_aberta = False materia.votacao_aberta = False
@ -22,12 +28,12 @@ def fecha_matérias_abertas_duplicadas(apps, schema_editor):
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('sessao', '0070_votoparlamentar_unique_constraint'), ('sessao', '0071_votoparlamentar_unique_constraint'),
] ]
operations = [ operations = [
migrations.RunPython( migrations.RunPython(
fecha_matérias_abertas_duplicadas, migrations.RunPython.noop), fecha_materias_abertas_duplicadas, migrations.RunPython.noop),
migrations.AddConstraint( migrations.AddConstraint(
model_name='expedientemateria', model_name='expedientemateria',
constraint=models.UniqueConstraint(condition=models.Q(votacao_aberta=True), fields=('votacao_aberta',), name='sessao_expedientemateria_unique_votacao_aberta'), 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 import pytest
from django.contrib.messages import get_messages
from django.db import IntegrityError from django.db import IntegrityError
from django.urls import reverse from django.urls import reverse
from model_bakery import baker 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) @pytest.mark.django_db(transaction=False)
def test_post_sem_acao_reconhecida_apenas_renderiza(admin_client): def test_post_sem_acao_reconhecida_redireciona_para_registro(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.
"""
sessao, ordem = _ordem_nominal_aberta() sessao, ordem = _ordem_nominal_aberta()
_presente(sessao) _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() ordem.refresh_from_db()
assert ordem.registro_aberto is False 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) @pytest.mark.django_db(transaction=False)
def test_bloquear_e_reabrir_votacao_nao_mexe_em_votos_existentes(admin_client): def test_bloquear_e_reabrir_votacao_nao_mexe_em_votos_existentes(admin_client):
sessao, ordem = _ordem_nominal_aberta() 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.votacao_aberta is False
assert ordem.registro_aberto 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) @pytest.mark.django_db(transaction=False)
def test_salvar_votacao_sem_votos_nao_trava_selects_para_nova_tentativa(admin_client): 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) 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) @pytest.mark.django_db(transaction=False)
def test_unique_constraint_impede_voto_duplicado(admin_client): def test_unique_constraint_impede_voto_duplicado(admin_client):
sessao, ordem = _ordem_nominal_aberta() sessao, ordem = _ordem_nominal_aberta()
@ -327,14 +515,14 @@ def test_unique_constraint_impede_duas_ordens_abertas():
@pytest.mark.django_db(transaction=False) @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 import importlib
from django.apps import apps as real_apps from django.apps import apps as real_apps
from django.db import connection from django.db import connection
migracao = importlib.import_module( migracao = importlib.import_module(
'sapl.sessao.migrations.0070_votoparlamentar_unique_constraint') 'sapl.sessao.migrations.0071_votoparlamentar_unique_constraint')
with connection.cursor() as cursor: with connection.cursor() as cursor:
cursor.execute( cursor.execute(
@ -356,9 +544,9 @@ def test_migracao_0070_remove_votos_duplicados_antes_da_constraint():
@pytest.mark.django_db(transaction=False) @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 pré-existentes antes do AddConstraint — senão a migration falharia ao
ser aplicada num banco com dado antigo (de antes desta invariante ser aplicada num banco com dado antigo (de antes desta invariante
existir). Testa a função isoladamente: como o teste roda dentro de uma 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 from django.db import connection
migracao = importlib.import_module( 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: with connection.cursor() as cursor:
cursor.execute('DROP INDEX sessao_ordemdia_unique_votacao_aberta') 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() sessao, mais_antiga = _ordem_nominal_aberta()
mais_recente = baker.make(OrdemDia, sessao_plenaria=sessao, mais_recente = baker.make(OrdemDia, sessao_plenaria=sessao,
materia=mais_antiga.materia, tipo_votacao=NOMINAL, materia=mais_antiga.materia, tipo_votacao=NOMINAL,
votacao_aberta=True) votacao_aberta=True, registro_aberto=True)
assert mais_recente.pk > mais_antiga.pk 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_antiga.refresh_from_db()
mais_recente.refresh_from_db() mais_recente.refresh_from_db()
encerrada.refresh_from_db()
assert mais_antiga.votacao_aberta is False assert mais_antiga.votacao_aberta is False
assert mais_recente.votacao_aberta is True 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) @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.db.models import Max, Q
from django.http import JsonResponse from django.http import JsonResponse
from django.http.response import Http404, HttpResponseRedirect from django.http.response import Http404, HttpResponseRedirect
from django.middleware.csrf import get_token
from django.urls import reverse from django.urls import reverse
from django.urls.base import reverse_lazy from django.urls.base import reverse_lazy
from django.utils import timezone from django.utils import timezone
@ -130,7 +129,12 @@ def verifica_presenca(request, model, spk, is_leitura=False):
return True 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( votacoes_abertas = SessaoPlenaria.objects.filter(
Q(ordemdia__votacao_aberta=True) | Q(ordemdia__votacao_aberta=True) |
Q(expedientemateria__votacao_aberta=True)).distinct() 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.') ', '.join(msg_abertas) + '. Estas votações ou leituras foram fechadas.')
msg = _('Já existem votações ou leituras abertas nas seguintes Sessões: ' + msg = _('Já existem votações ou leituras abertas nas seguintes Sessões: ' +
', '.join(msg_abertas) + '. Estas votações ou leituras foram fechadas.') ', '.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: # update() em vez de save(): só toca estes dois campos e reavalia o
ordens = sessao.ordemdia_set.filter(votacao_aberta=True) # WHERE sob o lock da linha, sem regravar um resultado que um
expediente = sessao.expedientemateria_set.filter( # "Encerrar Votação" concorrente tenha acabado de salvar.
votacao_aberta=True) OrdemDia.objects.filter(votacao_aberta=True).update(
for o in ordens: votacao_aberta=False, registro_aberto=False)
o.votacao_aberta = False ExpedienteMateria.objects.filter(votacao_aberta=True).update(
o.save() votacao_aberta=False, registro_aberto=False)
for e in expediente:
e.votacao_aberta = False
e.save()
return True return True
@ -205,36 +206,49 @@ def abrir_votacao(request, pk, spk):
is_leitura = materia_votacao.tipo_votacao == 4 is_leitura = materia_votacao.tipo_votacao == 4
is_expediente = model is ExpedienteMateria is_expediente = model is ExpedienteMateria
opened = False opened = False
mensagens = []
with transaction.atomic(): try:
# select_for_update trava a linha da SessaoPlenaria durante toda a with transaction.atomic():
# checagem+fechamento+abertura, para que dois "abrir votação" # select_for_update trava a linha da SessaoPlenaria durante toda a
# concorrentes (duplo clique, ou uma requisição lenta seguida de # checagem+fechamento+abertura, para que dois "abrir votação"
# nova tentativa) não deixem duas matérias com votacao_aberta=True # concorrentes (duplo clique, ou uma requisição lenta seguida de
# ao mesmo tempo — sapl/painel/views.py::votacao_aberta() trata # nova tentativa) não deixem duas matérias com votacao_aberta=True
# esse caso redirecionando todos os tablets sem nenhuma mensagem # ao mesmo tempo — sapl/painel/views.py::votacao_aberta() trata
# clara de erro. # esse caso redirecionando todos os tablets sem nenhuma mensagem
SessaoPlenaria.objects.select_for_update().get(id=spk) # clara de erro.
# Reflete o estado mais atual sob o lock: outra requisição SessaoPlenaria.objects.select_for_update().get(id=spk)
# concorrente pode ter mudado votacao_aberta entre o SELECT inicial # Reflete o estado mais atual sob o lock: outra requisição
# (antes do lock) e aqui. # concorrente pode ter mudado votacao_aberta entre o SELECT inicial
materia_votacao.refresh_from_db() # (antes do lock) e aqui.
ja_aberta = materia_votacao.votacao_aberta 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 # Reabrir a própria matéria que já está aberta precisa ser
# OUTRAS matérias concorrentes, e sua mensagem ("já existem # idempotente: verifica_votacoes_abertas() existe para fechar
# votações abertas... foram fechadas") não faz sentido quando a # OUTRAS matérias concorrentes, e sua mensagem ("já existem
# única "conflitante" é ela mesma. # votações abertas... foram fechadas") não faz sentido quando a
if (verifica_presenca(request, presenca_model, spk, is_leitura) and # única "conflitante" é ela mesma.
(ja_aberta or verifica_votacoes_abertas(request)) and if (verifica_presenca(request, presenca_model, spk, is_leitura) and
verifica_sessao_iniciada(request, spk, is_leitura)): (ja_aberta or verifica_votacoes_abertas(request, mensagens)) and
materia_votacao.votacao_aberta = True verifica_sessao_iniciada(request, spk, is_leitura)):
sessao = SessaoPlenaria.objects.get(id=spk) materia_votacao.votacao_aberta = True
sessao.painel_aberto = True if not ja_aberta:
sessao.save() materia_votacao.registro_aberto = False
materia_votacao.save() sessao = SessaoPlenaria.objects.get(id=spk)
opened = True 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: if opened:
# Leva direto para a tela de registro do tipo de votação recém # 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) 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']): for i, row in enumerate(context['rows']):
materia = context['object_list'][i].materia materia = context['object_list'][i].materia
obj = context['object_list'][i] 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 has_permission:
if obj.tipo_votacao != LEITURA: 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 = ''' btn_registrar = '''
<form action="%s"%s> <form action="%s">
%s
<input type="submit" class="btn btn-primary" <input type="submit" class="btn btn-primary"
value="Registrar Votação" /> value="Registrar Votação" />
%s %s
</form>''' % ( </form>''' % (
url, metodo, csrf_input, page_number) url, page_number)
else: else:
btn_registrar = ''' btn_registrar = '''
<form action="%s"> <form action="%s">
@ -904,8 +905,7 @@ class MateriaOrdemDiaCrud(MasterDetailCrud):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
has_permition = self.request.user.has_module_perms(AppConfig.label) has_permition = self.request.user.has_module_perms(AppConfig.label)
return customize_link_materia(context, self.kwargs['pk'], has_permition, False, return customize_link_materia(context, self.kwargs['pk'], has_permition, False)
request=self.request)
def recuperar_materia(request): def recuperar_materia(request):
@ -981,8 +981,7 @@ class ExpedienteMateriaCrud(MasterDetailCrud):
context['page'] = self.request.GET.get('page') context['page'] = self.request.GET.get('page')
has_permition = self.request.user.has_module_perms(AppConfig.label) has_permition = self.request.user.has_module_perms(AppConfig.label)
return customize_link_materia(context, self.kwargs['pk'], has_permition, True, return customize_link_materia(context, self.kwargs['pk'], has_permition, True)
request=self.request)
class CreateView(MasterDetailCrud.CreateView): class CreateView(MasterDetailCrud.CreateView):
form_class = ExpedienteMateriaForm form_class = ExpedienteMateriaForm
@ -3075,22 +3074,6 @@ class VotacaoNominalAbstract(SessaoPermissionMixin):
'votos': votos, '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): def _lock_materia_votacao(self, materia_votacao):
model = OrdemDia if self.ordem else ExpedienteMateria model = OrdemDia if self.ordem else ExpedienteMateria
return model.objects.select_for_update().get(pk=materia_votacao.pk) return model.objects.select_for_update().get(pk=materia_votacao.pk)
@ -3168,20 +3151,20 @@ class VotacaoNominalAbstract(SessaoPermissionMixin):
return self._redirect_lista(kwargs, page) return self._redirect_lista(kwargs, page)
for voto_submetido, parlamentar_id in votos_submetidos: for voto_submetido, parlamentar_id in votos_submetidos:
voto_parlamentar, created = self._get_or_create_voto_parlamentar( if voto_submetido == 'Não Votou':
fase_sessao_field, fase_sessao_value, parlamentar_id) # "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 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.voto = voto_submetido
voto_parlamentar.user = request.user voto_parlamentar.user = request.user
voto_parlamentar.ip = get_client_ip(request) voto_parlamentar.ip = get_client_ip(request)
@ -3195,7 +3178,8 @@ class VotacaoNominalAbstract(SessaoPermissionMixin):
skipped_parlamentares.append(voto_parlamentar.parlamentar) skipped_parlamentares.append(voto_parlamentar.parlamentar)
votos_atuais = VotoParlamentar.objects.filter( 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_sim = votos_atuais.filter(voto='Sim').count()
votos_nao = votos_atuais.filter(voto='Não').count() votos_nao = votos_atuais.filter(voto='Não').count()
abstencoes = votos_atuais.filter(voto='Abstenção').count() abstencoes = votos_atuais.filter(voto='Abstenção').count()
@ -3207,10 +3191,6 @@ class VotacaoNominalAbstract(SessaoPermissionMixin):
'nenhum voto')) 'nenhum voto'))
return self.form_invalid(form) 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( votacao = RegistroVotacao(
numero_votos_sim=votos_sim, numero_votos_sim=votos_sim,
numero_votos_nao=votos_nao, numero_votos_nao=votos_nao,
@ -3224,6 +3204,16 @@ class VotacaoNominalAbstract(SessaoPermissionMixin):
votacao.save() votacao.save()
votos_atuais.update(votacao_id=votacao.id) 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.resultado = form.cleaned_data['resultado_votacao'].nome
materia_votacao.votacao_aberta = False materia_votacao.votacao_aberta = False
@ -3246,10 +3236,8 @@ class VotacaoNominalAbstract(SessaoPermissionMixin):
return self.form_valid(form) return self.form_valid(form)
# Nenhuma chave de ação reconhecida: navegação simples para a tela # Nenhuma ação reconhecida: volta para a tela de registro (GET).
# de registro (botão "Registrar Votação"), sem efeito colateral. return self._redirect_same_registro(kwargs, page)
context = self._build_registro_context(materia_votacao, presentes, total)
return self.render_to_response(context)
def form_invalid(self, form): def form_invalid(self, form):
errors_tuple = [(form[e].label, form.errors[e]) errors_tuple = [(form[e].label, form.errors[e])

7
sapl/templates/painel/index.html

@ -331,7 +331,7 @@
+ '</td></tr>') + '</td></tr>')
} }
else{ 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' 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"; parlamentar.voto = "Não votou";
} }
if (parlamentar.voto == "Sim"){ if (parlamentar.voto == "Sim"){

25
sapl/templates/painel/voto_individual.html

@ -46,6 +46,10 @@
</head> </head>
<body> <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 %} {% if not error_message %}
<div id="blocoprincipal" style="display:block"> <div id="blocoprincipal" style="display:block">
<h1><b><font color="#4FA64D"><p align="center">{{sessao}}</p></font></b></h1> <h1><b><font color="#4FA64D"><p align="center">{{sessao}}</p></font></b></h1>
@ -213,7 +217,17 @@
function verificaEstado() { function verificaEstado() {
fetch("{% url 'sapl.painel:voto_individual_status' %}", {credentials: 'same-origin'}) 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) { .then(function(data) {
var novo = { var novo = {
materiaId: data.materia_id ? String(data.materia_id) : "", materiaId: data.materia_id ? String(data.materia_id) : "",
@ -228,9 +242,14 @@
document.location.reload(); 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> </script>

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

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

Loading…
Cancel
Save