Browse Source

fix: corrige fluxo de votação nominal no painel

fix/painel-registro-votacao-trava
Edward Ribeiro 4 days ago
parent
commit
05a6a4108b
  1. 26
      sapl/api/serializers.py
  2. 19
      sapl/api/tests/test_serializers_sessao.py
  3. 290
      sapl/painel/tests/tests.py
  4. 5
      sapl/painel/urls.py
  5. 208
      sapl/painel/views.py
  6. 31
      sapl/sessao/admin.py
  7. 4
      sapl/sessao/forms.py
  8. 50
      sapl/sessao/migrations/0070_votoparlamentar_unique_constraint.py
  9. 39
      sapl/sessao/migrations/0071_votacao_aberta_unique_constraint.py
  10. 32
      sapl/sessao/models.py
  11. 412
      sapl/sessao/tests/test_votacao_nominal.py
  12. 547
      sapl/sessao/views.py
  13. 5
      sapl/templates/painel/index.html
  14. 52
      sapl/templates/painel/voto_individual.html
  15. 78
      sapl/templates/sessao/votacao/nominal.html

26
sapl/api/serializers.py

@ -12,7 +12,7 @@ from rest_framework.fields import SerializerMethodField
from sapl.base.models import Autor, CasaLegislativa, Metadata
from sapl.parlamentares.models import Parlamentar, Mandato, Legislatura
from sapl.sessao.models import OrdemDia, SessaoPlenaria
from sapl.sessao.models import ExpedienteMateria, OrdemDia, SessaoPlenaria
class SaplSerializerMixin(DrfAutoApiSerializerMixin):
@ -317,5 +317,25 @@ class SessaoPlenariaECidadaniaSerializer(serializers.ModelSerializer):
return self.casa().nome
def casa(self):
casa = CasaLegislativa.objects.first()
return casa
return CasaLegislativa.objects.first()
class OrdemDiaSerializer(SaplSerializerMixin):
"""
votacao_aberta/registro_aberto podem ser alterados através do fluxo
de abrir_votacao()/VotacaoNominalAbstract (sapl/sessao/views.py), que
garante a invariante de no máximo uma matéria aberta por vez. Editáveis
aqui, a API contornaria essa garantia por completo.
"""
class Meta:
model = OrdemDia
fields = '__all__'
read_only_fields = ('votacao_aberta', 'registro_aberto')
class ExpedienteMateriaSerializer(SaplSerializerMixin):
"""Ver OrdemDiaSerializer — mesmo motivo para os mesmos dois campos."""
class Meta:
model = ExpedienteMateria
fields = '__all__'
read_only_fields = ('votacao_aberta', 'registro_aberto')

19
sapl/api/tests/test_serializers_sessao.py

@ -0,0 +1,19 @@
import pytest
from model_bakery import baker
from sapl.api.serializers import SessaoPlenariaECidadaniaSerializer
from sapl.base.models import CasaLegislativa
from sapl.sessao.models import SessaoPlenaria
@pytest.mark.django_db(transaction=False)
def test_sessao_ecidadania_serializa_dados_da_casa():
casa = baker.make(CasaLegislativa, nome='Câmara Municipal', sigla='CM',
endereco='Praça Central')
sessao = baker.make(SessaoPlenaria)
data = SessaoPlenariaECidadaniaSerializer(sessao).data
assert data['txtNomeOrgao'] == casa.nome
assert data['txtSiglaOrgao'] == casa.sigla
assert data['txtLocal'] == casa.endereco

290
sapl/painel/tests/tests.py

@ -1,3 +1,289 @@
# from django.test import TestCase
import pytest
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Permission
from django.test import Client
from django.urls import reverse
from model_bakery import baker
# Create your tests here.
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,
TipoResultadoVotacao, TipoSessaoPlenaria,
VotoParlamentar)
NOMINAL = 2
def _sessao_plenaria():
legislatura = baker.make(Legislatura)
sessao_legislativa = baker.make(SessaoLegislativa)
tipo = baker.make(TipoSessaoPlenaria)
return baker.make(SessaoPlenaria, legislatura=legislatura,
sessao_legislativa=sessao_legislativa, tipo=tipo, numero=1)
def _materia():
tipo_materia = baker.make(TipoMateriaLegislativa)
return baker.make(MateriaLegislativa, tipo=tipo_materia)
def _ordem_nominal_aberta(registro_aberto=False):
sessao = _sessao_plenaria()
materia = _materia()
ordem = baker.make(OrdemDia, sessao_plenaria=sessao, materia=materia,
tipo_votacao=NOMINAL, votacao_aberta=True,
registro_aberto=registro_aberto)
return sessao, ordem
def _votante(sessao, admin_user):
parlamentar = baker.make(Parlamentar, ativo=True)
baker.make(PresencaOrdemDia, sessao_plenaria=sessao, parlamentar=parlamentar)
baker.make(Votante, parlamentar=parlamentar, user=admin_user)
return parlamentar
def _votante_com_client(sessao):
"""
Como _votante(), mas cria seu próprio usuário (não-superuser, com a
permissão parlamentares.can_vote) e devolve um Client logado separado
necessário para testar a corrida entre o operador e o vereador, que
precisam ser duas sessões/usuários distintos.
"""
parlamentar = baker.make(Parlamentar, ativo=True)
baker.make(PresencaOrdemDia, sessao_plenaria=sessao, parlamentar=parlamentar)
user = get_user_model().objects.create_user(
username='votante-{}'.format(parlamentar.pk), password='x')
user.user_permissions.add(Permission.objects.get(codename='can_vote'))
baker.make(Votante, parlamentar=parlamentar, user=user)
client = Client()
client.force_login(user)
return parlamentar, client
def _registrar_url(sessao, ordem):
return reverse('sapl.sessao:votacaonominal',
kwargs={'pk': sessao.pk, 'oid': ordem.pk, 'mid': ordem.materia_id})
@pytest.mark.django_db(transaction=False)
def test_votante_view_envia_headers_never_cache(admin_client, admin_user):
sessao, ordem = _ordem_nominal_aberta()
_votante(sessao, admin_user)
response = admin_client.get(reverse('sapl.painel:voto_individual'))
assert response.status_code == 200
assert 'no-store' in response['Cache-Control']
@pytest.mark.django_db(transaction=False)
def test_votante_view_mostra_materia_quando_registro_fechado(admin_client, admin_user):
sessao, ordem = _ordem_nominal_aberta(registro_aberto=False)
_votante(sessao, admin_user)
response = admin_client.get(reverse('sapl.painel:voto_individual'))
assert response.status_code == 200
assert 'error_message' not in response.context
assert response.context['materia'] == ordem.materia
@pytest.mark.django_db(transaction=False)
def test_votante_view_mostra_erro_explicito_quando_registro_bloqueado(admin_client, admin_user):
"""
Regressão da causa raiz #1: quando a Mesa bloqueia novos votos
(registro_aberto=True), o vereador que ainda não votou precisa ver uma
mensagem explícita não uma tela em branco sem explicação.
"""
sessao, ordem = _ordem_nominal_aberta(registro_aberto=True)
_votante(sessao, admin_user)
response = admin_client.get(reverse('sapl.painel:voto_individual'))
assert response.status_code == 200
assert 'Mesa encerrou o recebimento de novos votos' in response.context['error_message']
@pytest.mark.django_db(transaction=False)
def test_voto_do_vereador_prevalece_sobre_lote_do_operador(admin_client):
"""
Invariante: o voto do próprio vereador sempre prevalece sobre qualquer
valor provisório existente para ele (ex.: de uma tentativa anterior
do operador, ou de qualquer outra origem). Complementa
test_salvar_votacao_nao_sobrescreve_voto_ja_registrado (que cobre a
ordem inversa: o operador não pode sobrescrever um voto real
registrado) aqui é o vereador votando por cima de um valor existente
através da view de verdade, não apenas o estado inicial simulado.
"""
sessao, ordem = _ordem_nominal_aberta()
vereador, votante_client = _votante_com_client(sessao)
# 1) Já existe um valor provisório "Não Votou" para o vereador (ex.: o
# <select> nunca foi tocado pelo operador). Esse valor nunca deveria
# impedir o vereador de votar de verdade.
baker.make(VotoParlamentar, ordem=ordem, parlamentar=vereador,
voto='Não Votou')
# 2) O vereador vota pelo tablet — seu voto real prevalece sobre o
# valor provisório.
response = votante_client.post(
reverse('sapl.painel:voto_individual'), {'voto': 'Sim'})
assert response.status_code == 302
voto = VotoParlamentar.objects.get(ordem=ordem, parlamentar=vereador)
assert voto.voto == 'Sim'
@pytest.mark.django_db(transaction=False)
def test_post_de_voto_bloqueado_nao_persiste(admin_client, admin_user):
sessao, ordem = _ordem_nominal_aberta(registro_aberto=True)
parlamentar = _votante(sessao, admin_user)
response = admin_client.post(
reverse('sapl.painel:voto_individual'), {'voto': 'Sim'})
assert response.status_code == 302
assert not VotoParlamentar.objects.filter(
ordem=ordem, parlamentar=parlamentar).exists()
@pytest.mark.django_db(transaction=False)
def test_post_rejeita_valor_de_voto_invalido(admin_client, admin_user):
sessao, ordem = _ordem_nominal_aberta()
parlamentar = _votante(sessao, admin_user)
response = admin_client.post(
reverse('sapl.painel:voto_individual'), {'voto': 'Outro'})
assert response.status_code == 302
assert not VotoParlamentar.objects.filter(
ordem=ordem, parlamentar=parlamentar).exists()
@pytest.mark.django_db(transaction=False)
def test_propria_tela_nao_mostra_voto_de_outra_materia(admin_client):
"""
Regressão: votacao() buscava o voto do próprio vereador com
Q(ordem=ordem_dia) | Q(expediente=expediente) quando a matéria atual
é uma OrdemDia, expediente é None, e Q(expediente=None) vira
"expediente_id IS NULL" no SQL, que é verdadeiro para QUALQUER voto de
ordem do dia daquele vereador, não o desta matéria (idem para
ExpedienteMateria, cujas votações também têm ordem=None). Com .first()
sem ordenação, o vereador podia ver o voto de uma matéria antiga em vez
do voto (ou ausência de voto) da matéria atual inclusive depois de
trocar o próprio voto, que a query buscava a linha errada.
"""
sessao, ordem_antiga = _ordem_nominal_aberta()
vereador, votante_client = _votante_com_client(sessao)
baker.make(VotoParlamentar, ordem=ordem_antiga, parlamentar=vereador, voto='Sim')
ordem_antiga.votacao_aberta = False
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')
# Ainda não votou na matéria atual — não pode herdar o 'Sim' da antiga.
assert votante_client.get(status_url).json()['voto_parlamentar'] is None
votante_client.post(reverse('sapl.painel:voto_individual'), {'voto': 'Não'})
assert votante_client.get(status_url).json()['voto_parlamentar'] == 'Não'
assert VotoParlamentar.objects.get(
ordem=ordem_antiga, parlamentar=vereador).voto == 'Sim'
@pytest.mark.django_db(transaction=False)
def test_get_dados_painel_nao_usa_etag_incompleto(admin_client):
baker.make(ConfiguracoesAplicacao, mostrar_voto=True, mostrar_brasao_painel=False)
sessao, ordem = _ordem_nominal_aberta()
url = reverse('sapl.painel:dados_painel', kwargs={'pk': sessao.pk})
primeira = admin_client.get(url)
assert primeira.status_code == 200
assert 'ETag' not in primeira
# Mesmo que um cliente antigo ainda envie o último valor conhecido, a
# resposta deve ser sempre recalculada para não ocultar mudanças em
# presenças, oradores ou configuração do painel.
segunda = admin_client.get(url, HTTP_IF_NONE_MATCH='"valor-antigo"')
assert segunda.status_code == 200
assert 'ETag' not in segunda
@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.
"""
baker.make(ConfiguracoesAplicacao, mostrar_voto=True,
mostrar_brasao_painel=False)
sessao, ordem = _ordem_nominal_aberta()
tipo_resultado = baker.make(TipoResultadoVotacao, nome='Aprovada',
natureza='A')
votou = baker.make(Parlamentar, ativo=True)
nao_votou = baker.make(Parlamentar, ativo=True)
for parlamentar in (votou, nao_votou):
baker.make(PresencaOrdemDia, sessao_plenaria=sessao,
parlamentar=parlamentar)
baker.make(Mandato, parlamentar=parlamentar,
legislatura=sessao.legislatura)
resposta_registro = 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 resposta_registro.status_code == 302
dados = admin_client.get(reverse(
'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
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 'Não votou'.encode() in painel.content
@pytest.mark.django_db(transaction=False)
def test_votante_status_reflete_estado_e_nao_exige_permissao_do_painel():
"""
votante_status precisa ser alcançável por uma conta -Votante (sem
nenhuma permissão do app painel) é por isso que não reaproveita
get_dados_painel, que exige check_permission (permissão de módulo do
app painel).
"""
sessao, ordem = _ordem_nominal_aberta()
vereador, votante_client = _votante_com_client(sessao)
status_url = reverse('sapl.painel:voto_individual_status')
resposta = votante_client.get(status_url)
assert resposta.status_code == 200
data = resposta.json()
assert data['materia_id'] == ordem.materia_id
assert data['status_message'] == 'Aguardando seu voto.'
assert data['voto_parlamentar'] is None
votante_client.post(reverse('sapl.painel:voto_individual'), {'voto': 'Não'})
resposta2 = votante_client.get(status_url)
data2 = resposta2.json()
assert data2['voto_parlamentar'] == 'Não'
assert 'encerramento da votação' in data2['status_message']

5
sapl/painel/urls.py

@ -3,7 +3,8 @@ from django.conf.urls import url
from .apps import AppConfig
from .views import (cronometro_painel, get_dados_painel, painel_mensagem_view,
painel_parlamentar_view, painel_view, painel_votacao_view,
switch_painel, verifica_painel, votante_view)
switch_painel, verifica_painel, votante_status,
votante_view)
app_name = AppConfig.name
@ -24,4 +25,6 @@ urlpatterns = [
url(r'^voto-individual/$', votante_view,
name='voto_individual'),
url(r'^voto-individual/status$', votante_status,
name='voto_individual_status'),
]

208
sapl/painel/views.py

@ -6,6 +6,7 @@ from django.contrib import messages
from django.contrib.auth.decorators import (login_required, permission_required,
user_passes_test)
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
from django.db import IntegrityError, transaction
from django.urls import reverse
from django.db.models import Q
from django.http import HttpResponse, JsonResponse
@ -98,41 +99,35 @@ def votacao_aberta(request):
def votacao(context, context_vars):
logger = logging.getLogger(__name__)
parlamentar = context_vars['votante'].parlamentar
parlamentar_presente = False
if parlamentar.id in context_vars['presentes']:
parlamentar_presente = True
context_vars.update({'parlamentar': parlamentar})
else:
if parlamentar.id not in context_vars['presentes']:
logger.error("Parlamentar com id={} não está presente na "
"Ordem do Dia/Expediente em votação.".format(parlamentar.id))
context.update({'error_message':
'Não há presentes na Sessão com a '
'matéria em votação.'})
'Você não está presente na '
'Ordem do Dia/Expediente em votação.'})
return context, context_vars
context_vars.update({'parlamentar': parlamentar})
if parlamentar_presente:
voto = []
if context_vars['ordem_dia']:
voto = VotoParlamentar.objects.filter(
ordem=context_vars['ordem_dia'])
ordem=context_vars['ordem_dia'], parlamentar=parlamentar).first()
elif context_vars['expediente']:
voto = VotoParlamentar.objects.filter(
expediente=context_vars['expediente'])
expediente=context_vars['expediente'], parlamentar=parlamentar).first()
else:
voto = None
if voto:
try:
logger.debug(
"Tentando obter objeto VotoParlamentar com parlamentar={}.".format(context_vars['parlamentar']))
voto = voto.get(parlamentar=context_vars['parlamentar'])
context.update({'voto_parlamentar': voto.voto})
except ObjectDoesNotExist:
logger.error("Voto do parlamentar {} não computado.".format(context_vars['parlamentar']))
context.update(
{'voto_parlamentar': 'Voto não '
'computado.'})
context.update({
'voto_parlamentar': voto.voto,
'status_message': 'Voto registrado. Aguardando o encerramento '
'da votação pela Mesa.',
})
else:
logger.error("Parlamentar com id={} não está presente na "
"Ordem do Dia/Expediente em votação.".format(parlamentar.id))
context.update({'error_message':
'Você não está presente na '
'Ordem do Dia/Expediente em votação.'})
context.update({'status_message': 'Aguardando seu voto.'})
return context, context_vars
@ -171,8 +166,11 @@ def sessao_votacao(context, context_vars):
ordem_dia = get_materia_aberta(pk)
expediente = get_materia_expediente_aberta(pk)
errors_msgs = {'materia': 'Não há nenhuma matéria aberta.',
'registro': 'A votação para esta matéria já encerrou.',
'tipo': 'A matéria aberta não é do tipo votação nominal.'}
'registro': 'A Mesa encerrou o recebimento de novos votos '
'para apurar o resultado desta matéria. '
'Aguarde a próxima matéria.',
'tipo': 'Esta matéria não é votada individualmente pelos '
'tablets — a Mesa registra o resultado diretamente.'}
materia_aberta = None
if ordem_dia:
@ -218,86 +216,129 @@ def can_vote(context, context_vars, request):
context_vars.update({'sessao': sessao})
if sessao and not msg:
context, context_vars = sessao_votacao(context, context_vars)
elif not sessao and msg:
return HttpResponseRedirect('/')
elif msg:
# Mais de uma votação aberta ao mesmo tempo (não deveria acontecer
# mais, dado o invariante garantido em abrir_votacao(), mas se
# acontecer é preferível mostrar isso explicitamente ao vereador do
# que redirecioná-lo silenciosamente para "/".
context.update({'error_message': msg})
else:
context.update(
{'error_message': 'Não há nenhuma sessão com matéria aberta.'})
return context, context_vars
@login_required
@permission_required('parlamentares.can_vote', raise_exception=True)
def votante_view(request):
logger = logging.getLogger(__name__)
def _resolve_votante_context(request):
"""
Resolve o estado atual de votação para o Votante autenticado usado
tanto por votante_view (renderização completa) quanto por
votante_status (endpoint leve de polling), para as duas views
compartilharem a mesma lógica de can_vote() em vez de duplicá-la.
"""
username = request.user.username
if not Votante.objects.filter(user=request.user).exists():
logger.warning(
logging.getLogger(__name__).warning(
f'user={username} sem cadastro de Votante tentou acessar /voto-individual/.'
)
raise PermissionDenied
template_name = 'painel/voto_individual.html'
context = {'head_title': str(_('Votação Individual'))}
context_vars = {'votante': Votante.objects.get(user=request.user)}
return can_vote(context, context_vars, request)
context, context_vars = can_vote(context, context_vars, request)
@never_cache
@login_required
@permission_required('parlamentares.can_vote', raise_exception=True)
def votante_status(request):
"""
Endpoint leve para o polling automático do tablet (voto_individual.html)
devolve o suficiente pra decidir se algo mudou desde o último
carregamento da página, sem o custo de renderizar a página inteira a
cada poll. Não reaproveita get_dados_painel: aquele endpoint exige a
permissão do módulo painel (check_permission), que uma conta -Votante
não necessariamente tem.
"""
context, context_vars = _resolve_votante_context(request)
materia = context.get('materia')
return JsonResponse({
'materia_id': materia.id if materia else None,
'error_message': context.get('error_message'),
'status_message': context.get('status_message'),
'voto_parlamentar': context.get('voto_parlamentar'),
})
@never_cache
@login_required
@permission_required('parlamentares.can_vote', raise_exception=True)
def votante_view(request):
logger = logging.getLogger(__name__)
username = request.user.username
template_name = 'painel/voto_individual.html'
context, context_vars = _resolve_votante_context(request)
# Salva o voto
if request.method == 'POST':
if context_vars['ordem_dia']:
voto_submetido = request.POST.get('voto')
votos_validos = ('Sim', 'Não', 'Abstenção')
ordem = context_vars.get('ordem_dia')
expediente = context_vars.get('expediente')
parlamentar = context_vars.get('parlamentar')
if voto_submetido not in votos_validos:
messages.error(request, _('Voto inválido.'))
elif parlamentar and (ordem or expediente):
try:
logger.info("user=" + username + ". Tentando obter objeto VotoParlamentar para parlamentar={} e "
"ordem={}. "
.format(context_vars['parlamentar'], context_vars['ordem_dia']))
voto = VotoParlamentar.objects.get(
parlamentar=context_vars['parlamentar'],
ordem=context_vars['ordem_dia'])
except ObjectDoesNotExist:
logger.error("user=" + username + ". Erro ao obter VotoParlamentar para parlamentar={} e ordem={}. "
"Criando objeto. "
.format(context_vars['parlamentar'], context_vars['ordem_dia']))
voto = VotoParlamentar.objects.create(
parlamentar=context_vars['parlamentar'],
voto=request.POST['voto'],
user=request.user,
ip=get_client_ip(request),
ordem=context_vars['ordem_dia'])
with transaction.atomic():
if ordem:
materia = OrdemDia.objects.select_for_update().get(pk=ordem.pk)
fase_sessao = {'ordem': materia}
else:
logger.info("user=" + username + ". VotoParlamentar para parlamentar={} e ordem={} obtido com sucesso."
.format(context_vars['parlamentar'], context_vars['ordem_dia']))
voto.voto = request.POST['voto']
materia = ExpedienteMateria.objects.select_for_update().get(
pk=expediente.pk)
fase_sessao = {'expediente': materia}
presenca_model = (PresencaOrdemDia if ordem
else SessaoPlenariaPresenca)
esta_presente = presenca_model.objects.filter(
sessao_plenaria_id=materia.sessao_plenaria_id,
parlamentar=parlamentar).exists()
if (not esta_presente or not materia.votacao_aberta or
materia.registro_aberto or
materia.tipo_votacao != VOTACAO_NOMINAL or
RegistroVotacao.objects.filter(**fase_sessao).exists()):
messages.error(
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)
voto.voto = voto_submetido
voto.ip = get_client_ip(request)
voto.user = request.user
voto.save()
elif context_vars['expediente']:
try:
logger.info(
"user=" + username + ". Tentando obter objeto VotoParlamentar para parlamentar={} e expediente={}."
.format(context_vars['parlamentar'], context_vars['expediente']))
voto = VotoParlamentar.objects.get(
parlamentar=context_vars['parlamentar'],
expediente=context_vars['expediente'])
except ObjectDoesNotExist:
logger.error(
"user=" + username + ". Erro ao obter VotoParlamentar para parlamentar={} e expediente={}. Criando objeto."
.format(context_vars['parlamentar'], context_vars['expediente']))
voto = VotoParlamentar.objects.create(
parlamentar=context_vars['parlamentar'],
voto=request.POST['voto'],
user=request.user,
ip=get_client_ip(request),
expediente=context_vars['expediente'])
"user=%s. VotoParlamentar para parlamentar=%s "
"salvo com sucesso.", username, parlamentar)
except IntegrityError:
logger.exception(
"user=%s. Falha de integridade ao salvar voto de parlamentar=%s.",
username, parlamentar)
messages.error(request, _('Não foi possível registrar o voto. Tente novamente.'))
else:
logger.info(
"user=" + username + ". VotoParlamentar para parlamentar={} e expediente={} obtido com sucesso."
.format(context_vars['parlamentar'], context_vars['expediente']))
voto.voto = request.POST['voto']
voto.ip = get_client_ip(request)
voto.user = request.user
voto.save()
messages.error(
request, _('A votação não está disponível para novos votos.'))
return HttpResponseRedirect(
reverse('sapl.painel:voto_individual'))
@ -550,6 +591,7 @@ def get_votos(response, materia, mostrar_voto):
return response
@never_cache
@user_passes_test(check_permission)
def get_dados_painel(request, pk):
sessao = SessaoPlenaria.objects.get(id=pk)

31
sapl/sessao/admin.py

@ -1,3 +1,34 @@
from django.contrib import admin
from sapl.sessao.models import ExpedienteMateria, OrdemDia
from sapl.utils import register_all_models_in_admin
class NoAdminAccessModelAdmin(admin.ModelAdmin):
"""
SAPL não usa as páginas de admin e desencoraja seu uso e votacao_aberta/
registro_aberto podem ser alterados com segurança através do fluxo de
abrir_votacao()/VotacaoNominalAbstract (sapl/sessao/views.py), que
garante a invariante de no máximo uma matéria aberta por vez. Em vez de
apenas tornar os campos somente-leitura, o acesso ao admin é desabilitado
por completo para estes dois modelos.
"""
def has_view_permission(self, request, obj=None):
return False
def has_add_permission(self, request):
return False
def has_change_permission(self, request, obj=None):
return False
def has_delete_permission(self, request, obj=None):
return False
admin.site.register(OrdemDia, NoAdminAccessModelAdmin)
admin.site.register(ExpedienteMateria, NoAdminAccessModelAdmin)
# register_all_models_in_admin já pula modelos já registrados acima
register_all_models_in_admin(__name__)

4
sapl/sessao/forms.py

@ -449,10 +449,6 @@ class PresencaForm(forms.Form):
parlamentar = forms.CharField(required=False, max_length=20)
class ListMateriaForm(forms.Form):
error_message = forms.CharField(required=False, label='votacao_aberta')
class MesaForm(forms.Form):
parlamentar = forms.IntegerField(required=True)
cargo = forms.IntegerField(required=True)

50
sapl/sessao/migrations/0070_votoparlamentar_unique_constraint.py

@ -0,0 +1,50 @@
# Generated by Django 2.2.28 on 2026-08-16 17:36
from django.db import migrations, models
def remove_votos_duplicados(apps, schema_editor):
"""Preserva o voto canônico antes de criar os índices únicos."""
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
voto_preservado = max(
votos_duplicados,
key=lambda voto: (
voto.votacao_id is not None,
voto.data_hora is not None,
voto.data_hora,
voto.id,
))
ids_removidos = [voto.id for voto in votos_duplicados
if voto.id != voto_preservado.id]
VotoParlamentar.objects.filter(id__in=ids_removidos).delete()
class Migration(migrations.Migration):
dependencies = [
('sessao', '0069_auto_20220919_1705'),
]
operations = [
migrations.RunPython(
remove_votos_duplicados, migrations.RunPython.noop),
migrations.AddConstraint(
model_name='votoparlamentar',
constraint=models.UniqueConstraint(condition=models.Q(ordem__isnull=False), fields=('parlamentar', 'ordem'), name='sessao_votoparlamentar_unique_parlamentar_ordem'),
),
migrations.AddConstraint(
model_name='votoparlamentar',
constraint=models.UniqueConstraint(condition=models.Q(expediente__isnull=False), fields=('parlamentar', 'expediente'), name='sessao_votoparlamentar_unique_parlamentar_expediente'),
),
]

39
sapl/sessao/migrations/0071_votacao_aberta_unique_constraint.py

@ -0,0 +1,39 @@
# Generated by Django 2.2.28 on 2026-08-16 18:58
from django.db import migrations, models
def fecha_matérias_abertas_duplicadas(apps, schema_editor):
"""
Pré-requisito para o AddConstraint abaixo: se 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.
"""
for model_name in ('OrdemDia', 'ExpedienteMateria'):
model = apps.get_model('sessao', model_name)
abertas = model.objects.filter(votacao_aberta=True).order_by('-id')
for materia in abertas[1:]:
materia.votacao_aberta = False
materia.save(update_fields=['votacao_aberta'])
class Migration(migrations.Migration):
dependencies = [
('sessao', '0070_votoparlamentar_unique_constraint'),
]
operations = [
migrations.RunPython(
fecha_matérias_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'),
),
migrations.AddConstraint(
model_name='ordemdia',
constraint=models.UniqueConstraint(condition=models.Q(votacao_aberta=True), fields=('votacao_aberta',), name='sessao_ordemdia_unique_votacao_aberta'),
),
]

32
sapl/sessao/models.py

@ -433,6 +433,15 @@ class ExpedienteMateria(AbstractOrdemDia):
verbose_name = _('Matéria do Expediente')
verbose_name_plural = _('Matérias do Expediente')
ordering = ['numero_ordem']
constraints = [
# No máximo uma matéria de expediente aberta para votação por
# vez (mesma invariante que abrir_votacao() já garante na
# aplicação — isto é o backstop no nível do banco).
models.UniqueConstraint(
fields=['votacao_aberta'],
condition=models.Q(votacao_aberta=True),
name='sessao_expedientemateria_unique_votacao_aberta'),
]
class TipoExpediente(models.Model):
@ -583,6 +592,15 @@ class OrdemDia(AbstractOrdemDia):
verbose_name = _('Matéria da Ordem do Dia')
verbose_name_plural = _('Matérias da Ordem do Dia')
ordering = ['numero_ordem']
constraints = [
# No máximo uma matéria de ordem do dia aberta para votação por
# vez (mesma invariante que abrir_votacao() já garante na
# aplicação — isto é o backstop no nível do banco).
models.UniqueConstraint(
fields=['votacao_aberta'],
condition=models.Q(votacao_aberta=True),
name='sessao_ordemdia_unique_votacao_aberta'),
]
class PresencaOrdemDia(models.Model): # OrdemDiaPresenca
@ -720,6 +738,20 @@ class VotoParlamentar(models.Model): # RegistroVotacaoParlamentar
verbose_name = _('Registro de Votação de Parlamentar')
verbose_name_plural = _('Registros de Votações de Parlamentares')
ordering = ('id',)
constraints = [
# Garante, no nível do banco, no máximo um voto por parlamentar
# por matéria — get_or_create() sozinho não protege contra duas
# inserções concorrentes (ex.: o tablet do parlamentar e o
# formulário em lote do operador chegando ao mesmo tempo).
models.UniqueConstraint(
fields=['parlamentar', 'ordem'],
condition=models.Q(ordem__isnull=False),
name='sessao_votoparlamentar_unique_parlamentar_ordem'),
models.UniqueConstraint(
fields=['parlamentar', 'expediente'],
condition=models.Q(expediente__isnull=False),
name='sessao_votoparlamentar_unique_parlamentar_expediente'),
]
def __str__(self):
return _('Votação: %(votacao)s - Parlamentar: %(parlamentar)s') % {

412
sapl/sessao/tests/test_votacao_nominal.py

@ -0,0 +1,412 @@
import pytest
from django.db import IntegrityError
from django.urls import reverse
from model_bakery import baker
from sapl.materia.models import MateriaLegislativa, TipoMateriaLegislativa
from sapl.parlamentares.models import (Legislatura, Parlamentar,
SessaoLegislativa)
from sapl.sessao.models import (OrdemDia, PresencaOrdemDia, RegistroVotacao,
SessaoPlenaria, TipoResultadoVotacao,
TipoSessaoPlenaria, VotoParlamentar)
NOMINAL = 2
def _sessao_plenaria():
legislatura = baker.make(Legislatura)
sessao_legislativa = baker.make(SessaoLegislativa)
tipo = baker.make(TipoSessaoPlenaria)
return baker.make(SessaoPlenaria, legislatura=legislatura,
sessao_legislativa=sessao_legislativa, tipo=tipo, numero=1)
def _materia():
tipo_materia = baker.make(TipoMateriaLegislativa)
return baker.make(MateriaLegislativa, tipo=tipo_materia)
def _ordem_nominal_aberta():
sessao = _sessao_plenaria()
materia = _materia()
ordem = baker.make(OrdemDia, sessao_plenaria=sessao, materia=materia,
tipo_votacao=NOMINAL, votacao_aberta=True,
registro_aberto=False)
return sessao, ordem
def _presente(sessao):
parlamentar = baker.make(Parlamentar, ativo=True)
baker.make(PresencaOrdemDia, sessao_plenaria=sessao, parlamentar=parlamentar)
return parlamentar
def _registrar_url(sessao, ordem):
return reverse('sapl.sessao:votacaonominal',
kwargs={'pk': sessao.pk, 'oid': ordem.pk, 'mid': ordem.materia_id})
@pytest.mark.django_db(transaction=False)
def test_get_registrar_votacao_nao_bloqueia_novos_votos(admin_client):
"""
Regressão da causa raiz #1: abrir a tela "Registrar Votação" não pode,
sozinho, impedir que vereadores que ainda não votaram continuem votando.
"""
sessao, ordem = _ordem_nominal_aberta()
_presente(sessao)
response = admin_client.get(_registrar_url(sessao, ordem))
assert response.status_code == 200
ordem.refresh_from_db()
assert ordem.registro_aberto is False
@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.
"""
sessao, ordem = _ordem_nominal_aberta()
_presente(sessao)
response = admin_client.post(_registrar_url(sessao, ordem), {})
assert response.status_code == 200
ordem.refresh_from_db()
assert ordem.registro_aberto is False
@pytest.mark.django_db(transaction=False)
def test_bloquear_e_reabrir_votacao_nao_mexe_em_votos_existentes(admin_client):
sessao, ordem = _ordem_nominal_aberta()
votante = _presente(sessao)
baker.make(VotoParlamentar, ordem=ordem, parlamentar=votante, voto='Sim')
url = _registrar_url(sessao, ordem)
response = admin_client.post(url, {'bloquear-registro-votacao': '1'})
assert response.status_code == 302
ordem.refresh_from_db()
assert ordem.registro_aberto is True
response = admin_client.post(url, {'reabrir-votacao': '1'})
assert response.status_code == 302
ordem.refresh_from_db()
assert ordem.registro_aberto is False
voto = VotoParlamentar.objects.get(ordem=ordem, parlamentar=votante)
assert voto.voto == 'Sim'
@pytest.mark.django_db(transaction=False)
def test_salvar_votacao_nao_sobrescreve_voto_ja_registrado(admin_client):
"""
Regressão do modelo de concorrência (3b): um formulário de "Fechar
Votação" com um valor obsoleto para quem já votou pelo tablet não pode
sobrescrever esse voto preenche quem ainda não tem voto registrado.
"""
sessao, ordem = _ordem_nominal_aberta()
ja_votou = _presente(sessao)
ainda_nao_votou = _presente(sessao)
baker.make(VotoParlamentar, ordem=ordem, parlamentar=ja_votou, voto='Sim')
tipo_resultado = baker.make(TipoResultadoVotacao, nome='Aprovada', natureza='A')
url = _registrar_url(sessao, ordem)
payload = {
'salvar-votacao': '1',
'resultado_votacao': str(tipo_resultado.pk),
'observacao': '',
'voto_parlamentar': [
# valor obsoleto: a tela do operador ainda não sabia que este
# parlamentar já havia votado "Sim" pelo tablet
'Não Votou:{}'.format(ja_votou.pk),
'Não:{}'.format(ainda_nao_votou.pk),
],
}
response = admin_client.post(url, payload)
assert response.status_code == 302
voto_ja_votou = VotoParlamentar.objects.get(ordem=ordem, parlamentar=ja_votou)
assert voto_ja_votou.voto == 'Sim'
voto_novo = VotoParlamentar.objects.get(ordem=ordem, parlamentar=ainda_nao_votou)
assert voto_novo.voto == 'Não'
registro = RegistroVotacao.objects.get(ordem=ordem)
assert registro.numero_votos_sim == 1
assert registro.numero_votos_nao == 1
ordem.refresh_from_db()
assert ordem.votacao_aberta is False
assert ordem.registro_aberto is False
@pytest.mark.django_db(transaction=False)
def test_salvar_votacao_sem_votos_nao_trava_selects_para_nova_tentativa(admin_client):
"""
Regressão: fechar a votação sem nenhum voto real corretamente mostra um
erro, mas antes disso o laço de salvamento em lote criava um
VotoParlamentar com voto='Não Votou' para cada parlamentar cujo select
não foi tocado (o valor padrão do <select>). Esses registros persistiam
mesmo com o fechamento falhando (o bloco atomic não é revertido, que
form_invalid retorna normalmente em vez de lançar), e nominal.html
desabilita o <select> de qualquer parlamentar com um VotoParlamentar
existente travando o operador para sempre sem conseguir registrar
nenhum voto para essa matéria.
"""
sessao, ordem = _ordem_nominal_aberta()
parlamentar = _presente(sessao)
tipo_resultado = baker.make(TipoResultadoVotacao, nome='Aprovada', natureza='A')
url = _registrar_url(sessao, ordem)
payload = {
'salvar-votacao': '1',
'resultado_votacao': str(tipo_resultado.pk),
'observacao': '',
'voto_parlamentar': ['Não Votou:{}'.format(parlamentar.pk)],
}
response = admin_client.post(url, payload)
assert response.status_code == 302
assert not VotoParlamentar.objects.filter(
ordem=ordem, parlamentar=parlamentar).exists()
ordem.refresh_from_db()
assert ordem.votacao_aberta is True
# O operador consegue tentar de novo, agora com um voto real.
response2 = admin_client.post(url, {
'salvar-votacao': '1',
'resultado_votacao': str(tipo_resultado.pk),
'observacao': '',
'voto_parlamentar': ['Sim:{}'.format(parlamentar.pk)],
})
assert response2.status_code == 302
ordem.refresh_from_db()
assert ordem.votacao_aberta is False
assert VotoParlamentar.objects.get(ordem=ordem, parlamentar=parlamentar).voto == 'Sim'
@pytest.mark.django_db(transaction=False)
def test_status_da_votacao_reflete_troca_de_voto_do_parlamentar(admin_client):
"""
Regressão: a tela de registro (nominal.html) não atualizava a linha de
um parlamentar que trocou o voto durante a janela de votação o poll
antigo marcava "já votou" uma vez e nunca revisitava o valor. O poll
(?status=1) precisa sempre devolver o voto atual, não se existe.
"""
sessao, ordem = _ordem_nominal_aberta()
parlamentar = _presente(sessao)
voto = baker.make(VotoParlamentar, ordem=ordem, parlamentar=parlamentar,
voto='Sim')
url = _registrar_url(sessao, ordem) + '?status=1'
response = admin_client.get(url)
assert response.status_code == 200
data = response.json()
assert data['votos'] == {str(parlamentar.pk): 'Sim'}
assert data['votacao_aberta'] is True
assert data['registro_aberto'] is False
assert data['ja_registrada'] is False
voto.voto = 'Não'
voto.save()
response2 = admin_client.get(url)
assert response2.json()['votos'] == {str(parlamentar.pk): 'Não'}
@pytest.mark.django_db(transaction=False)
def test_status_da_votacao_nao_depende_de_mostrar_voto(admin_client):
"""
O poll da tela de registro é para a Mesa, não para o público ao
contrário de sapl.painel:dados_painel, ele não pode mascarar o valor
real do voto por trás de "Voto Informado" mesmo quando a Casa configura
mostrar_voto=False (essa config controla o telão público, não a tela de
registro da própria Mesa). Como o endpoint nem consulta essa
configuração, isso é garantido por construção este teste
documenta a expectativa.
"""
sessao, ordem = _ordem_nominal_aberta()
parlamentar = _presente(sessao)
baker.make(VotoParlamentar, ordem=ordem, parlamentar=parlamentar,
voto='Abstenção')
url = _registrar_url(sessao, ordem) + '?status=1'
data = admin_client.get(url).json()
assert data['votos'][str(parlamentar.pk)] == 'Abstenção'
@pytest.mark.django_db(transaction=False)
def test_status_da_votacao_nao_redireciona_apos_encerrar_votacao(
admin_client):
"""
_get_materia_votacao (usado pelo GET normal) redireciona com uma
mensagem quando a matéria foi votada comportamento certo para
navegação, errado para um poll em background. O branch ?status=1 não
pode herdar esse redirect.
"""
sessao, ordem = _ordem_nominal_aberta()
parlamentar = _presente(sessao)
tipo_resultado = baker.make(TipoResultadoVotacao, nome='Aprovada',
natureza='A')
url = _registrar_url(sessao, ordem)
admin_client.post(url, {
'salvar-votacao': '1',
'resultado_votacao': str(tipo_resultado.pk),
'observacao': '',
'voto_parlamentar': ['Sim:{}'.format(parlamentar.pk)],
})
response = admin_client.get(url + '?status=1')
assert response.status_code == 200
data = response.json()
assert data['ja_registrada'] is True
assert data['votacao_aberta'] is False
@pytest.mark.django_db(transaction=False)
def test_abrir_votacao_ja_aberta_e_idempotente(admin_client):
"""
Regressão: clicar em "Abrir Votação" para uma matéria que está aberta
(ex.: duplo clique, ou a lista ainda não recarregou para trocar o botão
por "Registrar Votação") caía em verifica_votacoes_abertas(), que trata
a própria matéria como uma "outra" votação conflitante mostra a
mensagem "existem votações abertas... foram fechadas" e fecha a matéria
(para reabri-la em seguida, que o código sempre prossegue). O
resultado final até ficava certo (votacao_aberta=True), mas a mensagem
era enganosa. Reabrir a própria matéria aberta precisa ser
silenciosamente idempotente.
"""
sessao, ordem = _ordem_nominal_aberta()
sessao.iniciada = True
sessao.finalizada = False
sessao.save()
_presente(sessao)
url = reverse('sapl.sessao:abrir_votacao', kwargs={'pk': ordem.pk, 'spk': sessao.pk})
url += '?tipo_materia=ordem'
response = admin_client.get(url, follow=True)
assert response.status_code == 200
ordem.refresh_from_db()
assert ordem.votacao_aberta is True
mensagens = [str(m) for m in response.context['messages']]
assert not 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()
parlamentar = _presente(sessao)
baker.make(VotoParlamentar, ordem=ordem, parlamentar=parlamentar, voto='Sim')
with pytest.raises(IntegrityError):
VotoParlamentar.objects.create(
ordem=ordem, parlamentar=parlamentar, voto='Não')
@pytest.mark.django_db(transaction=False)
def test_unique_constraint_impede_duas_ordens_abertas():
sessao, ordem = _ordem_nominal_aberta()
outra = baker.make(OrdemDia, sessao_plenaria=sessao, materia=ordem.materia,
tipo_votacao=NOMINAL, votacao_aberta=False)
with pytest.raises(IntegrityError):
outra.votacao_aberta = True
outra.save()
@pytest.mark.django_db(transaction=False)
def test_migracao_0070_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')
with connection.cursor() as cursor:
cursor.execute(
'DROP INDEX sessao_votoparlamentar_unique_parlamentar_ordem')
sessao, ordem = _ordem_nominal_aberta()
parlamentar = _presente(sessao)
antigo = baker.make(VotoParlamentar, ordem=ordem,
parlamentar=parlamentar, voto='Não')
recente = baker.make(VotoParlamentar, ordem=ordem,
parlamentar=parlamentar, voto='Sim')
migracao.remove_votos_duplicados(real_apps, None)
votos = VotoParlamentar.objects.filter(
ordem=ordem, parlamentar=parlamentar)
assert list(votos.values_list('id', flat=True)) == [recente.id]
assert not votos.filter(id=antigo.id).exists()
@pytest.mark.django_db(transaction=False)
def test_migracao_0071_fecha_duplicatas_antes_da_constraint():
"""
A função de dados da migration 0071 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
transação que é desfeita no final, é seguro derrubar o índice aqui
(DDL é transacional no Postgres).
"""
import importlib
from django.apps import apps as real_apps
from django.db import connection
migracao = importlib.import_module(
'sapl.sessao.migrations.0071_votacao_aberta_unique_constraint')
with connection.cursor() as cursor:
cursor.execute('DROP INDEX sessao_ordemdia_unique_votacao_aberta')
sessao, mais_antiga = _ordem_nominal_aberta()
mais_recente = baker.make(OrdemDia, sessao_plenaria=sessao,
materia=mais_antiga.materia, tipo_votacao=NOMINAL,
votacao_aberta=True)
assert mais_recente.pk > mais_antiga.pk
migracao.fecha_matérias_abertas_duplicadas(real_apps, None)
mais_antiga.refresh_from_db()
mais_recente.refresh_from_db()
assert mais_antiga.votacao_aberta is False
assert mais_recente.votacao_aberta is True
@pytest.mark.django_db(transaction=False)
def test_api_nao_permite_abrir_votacao_via_patch(admin_client):
"""
Regressão do bypass encontrado na auditoria: a API auto-gerada
(drfautoapi) não pode mais aceitar votacao_aberta/registro_aberto
senão qualquer "Operador de Sessão Plenária" conseguiria abrir uma
matéria via PATCH direto, pulando verifica_votacoes_abertas() e o
lock em abrir_votacao().
"""
sessao, ordem = _ordem_nominal_aberta()
ordem.votacao_aberta = False
ordem.save()
url = '/api/sessao/ordemdia/{}/'.format(ordem.pk)
response = admin_client.patch(
url, data={'votacao_aberta': True}, content_type='application/json')
assert response.status_code in (200, 202)
ordem.refresh_from_db()
assert ordem.votacao_aberta is False

547
sapl/sessao/views.py

@ -9,9 +9,11 @@ from django.contrib import messages
from django.contrib.auth.decorators import permission_required
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.core.exceptions import ObjectDoesNotExist
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
@ -52,7 +54,7 @@ from sapl.utils import show_results_filter_set, remover_acentos, get_client_ip,
MultiFormatOutputMixin, PautaMultiFormatOutputMixin, ratelimit_ip
from .forms import (AdicionarVariasMateriasFilterSet, AdicionarVariasMateriasForm, BancadaForm,
ExpedienteForm, JustificativaAusenciaForm, OcorrenciaSessaoForm, ListMateriaForm,
ExpedienteForm, JustificativaAusenciaForm, OcorrenciaSessaoForm,
MesaForm, OradorExpedienteForm, OradorForm, PautaSessaoFilterSet,
PresencaForm, ResumoOrdenacaoForm, SessaoPlenariaFilterSet,
SessaoPlenariaForm, VotacaoEditForm, VotacaoForm,
@ -199,31 +201,75 @@ def abrir_votacao(request, pk, spk):
if not model:
raise Http404()
query_params = "?"
materia_votacao = model.objects.get(id=pk)
is_leitura = materia_votacao.tipo_votacao == 4
is_expediente = model is ExpedienteMateria
opened = False
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
verifica_votacoes_abertas(request) 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
if opened:
# Leva direto para a tela de registro do tipo de votação recém
# aberta, em vez de voltar para a lista — sem isso, o usuário
# precisa de um segundo clique em "Registrar Votação"/"Registrar
# Leitura" (que só aparece depois que a lista recarrega) para
# chegar aonde queria.
registro_view_names_ordem = {
SIMBOLICA: 'votacaosimbolica', NOMINAL: 'votacaonominal',
SECRETA: 'votacaosecreta', LEITURA: 'leituraod',
}
registro_view_names_expediente = {
SIMBOLICA: 'votacaosimbolicaexp', NOMINAL: 'votacaonominalexp',
SECRETA: 'votacaosecretaexp', LEITURA: 'leituraexp',
}
registro_view_names = (registro_view_names_expediente if is_expediente
else registro_view_names_ordem)
registro_view_name = registro_view_names[materia_votacao.tipo_votacao]
success_url = reverse('sapl.sessao:' + registro_view_name, kwargs={
'pk': spk, 'oid': materia_votacao.pk,
'mid': materia_votacao.materia_id})
if 'page' in request.GET:
success_url += '?page={}'.format(request.GET['page'])
else:
query_params = "?"
if 'page' in request.GET:
query_params += 'page={}&'.format(request.GET['page'])
query_params += "#id{}".format(materia_votacao.materia.id)
success_url = reverse('sapl.sessao:' + redirect_url, kwargs={'pk': spk})
success_url += query_params
success_url = reverse('sapl.sessao:' + redirect_url,
kwargs={'pk': spk}) + query_params
return HttpResponseRedirect(success_url)
def customize_link_materia(context, pk, has_permission, is_expediente):
def customize_link_materia(context, pk, has_permission, is_expediente, request=None):
for i, row in enumerate(context['rows']):
materia = context['object_list'][i].materia
obj = context['object_list'][i]
@ -351,13 +397,26 @@ def customize_link_materia(context, pk, has_permission, is_expediente):
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">
<form action="%s"%s>
%s
<input type="submit" class="btn btn-primary"
value="Registrar Votação" />
%s
</form>''' % (
url, page_number)
url, metodo, csrf_input, page_number)
else:
btn_registrar = '''
<form action="%s">
@ -845,7 +904,8 @@ 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)
return customize_link_materia(context, self.kwargs['pk'], has_permition, False,
request=self.request)
def recuperar_materia(request):
@ -921,7 +981,8 @@ 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)
return customize_link_materia(context, self.kwargs['pk'], has_permition, True,
request=self.request)
class CreateView(MasterDetailCrud.CreateView):
form_class = ExpedienteMateriaForm
@ -1570,112 +1631,6 @@ class PresencaOrdemDiaView(FormMixin, PresencaMixin, DetailView):
return reverse('sapl.sessao:presencaordemdia', kwargs={'pk': pk})
class ListMateriaOrdemDiaView(FormMixin, DetailView):
template_name = 'sessao/materia_ordemdia_list.html'
form_class = ListMateriaForm
model = SessaoPlenaria
def get(self, request, *args, **kwargs):
self.object = self.get_object()
context = self.get_context_data(object=self.object)
pk = self.kwargs['pk']
ordem = OrdemDia.objects.filter(sessao_plenaria_id=pk)
materias_ordem = []
for o in ordem:
ementa = o.materia.ementa
titulo = o.materia
numero = o.numero_ordem
autoria = Autoria.objects.filter(materia_id=o.materia_id)
autor = [str(a.autor) for a in autoria]
mat = {'pk': pk,
'oid': o.id,
'ordem_id': o.materia_id,
'ementa': ementa,
'titulo': titulo,
'numero': numero,
'resultado': o.resultado,
'autor': autor,
'votacao_aberta': o.votacao_aberta,
'tipo_votacao': o.tipo_votacao
}
materias_ordem.append(mat)
sorted(materias_ordem, key=lambda x: x['numero'])
context.update({'materias_ordem': materias_ordem})
return self.render_to_response(context)
@method_decorator(permission_required('sessao.change_ordemdia'))
def post(self, request, *args, **kwargs):
self.object = self.get_object()
context = self.get_context_data(object=self.object)
pk = self.kwargs['pk']
form = ListMateriaForm(request.POST)
# TODO: Existe uma forma de atualizar em lote de acordo
# com a forma abaixo, mas como setar o primeiro para "1"?
# OrdemDia.objects.filter(sessao_plenaria_id=pk)
# .order_by('numero_ordem').update(numero_ordem=3)
if 'materia_reorder' in request.POST:
ordens = OrdemDia.objects.filter(sessao_plenaria_id=pk)
ordem_num = 1
for o in ordens:
o.numero_ordem = ordem_num
o.save()
ordem_num += 1
elif 'abrir-votacao' in request.POST:
existe_votacao_aberta = OrdemDia.objects.filter(
sessao_plenaria_id=pk, votacao_aberta=True).exists()
if existe_votacao_aberta:
context = self.get_context_data(object=self.object)
form._errors = {'error_message': 'error_message'}
context.update({'form': form})
pk = self.kwargs['pk']
ordem = OrdemDia.objects.filter(sessao_plenaria_id=pk)
materias_ordem = []
for o in ordem:
ementa = o.materia.ementa
titulo = o.materia
numero = o.numero_ordem
autoria = Autoria.objects.filter(materia_id=o.materia_id)
autor = [str(a.autor) for a in autoria]
mat = {'pk': pk,
'oid': o.id,
'ordem_id': o.materia_id,
'ementa': ementa,
'titulo': titulo,
'numero': numero,
'resultado': o.resultado,
'autor': autor,
'votacao_aberta': o.votacao_aberta,
'tipo_votacao': o.tipo_votacao
}
materias_ordem.append(mat)
sorted(materias_ordem, key=lambda x: x['numero'])
context.update({'materias_ordem': materias_ordem})
return self.render_to_response(context)
else:
ordem_id = request.POST['ordem_id']
ordem = OrdemDia.objects.get(id=ordem_id)
ordem.votacao_aberta = True
ordem.registro_aberto = False
ordem.save()
return self.get(self, request, args, kwargs)
class MesaView(FormMixin, DetailView):
template_name = 'sessao/mesa.html'
form_class = MesaForm
@ -2977,7 +2932,15 @@ class VotacaoNominalAbstract(SessaoPermissionMixin):
logger = logging.getLogger(__name__)
def get(self, request, *args, **kwargs):
def _get_materia_votacao(self, request, kwargs):
"""
Resolve a OrdemDia/ExpedienteMateria sendo registrada, sem nenhum
efeito colateral (não altera registro_aberto nem qualquer outro
estado) apenas consulta. Retorna
(materia_votacao, presentes, total, redirect); quando a matéria não
pode ser exibida ( votada ou com a votação fechada), os três
primeiros valores são None e `redirect` é a resposta a devolver.
"""
username = request.user.username
if self.ordem:
ordem_id = kwargs['oid']
@ -2986,33 +2949,27 @@ class VotacaoNominalAbstract(SessaoPermissionMixin):
messages.add_message(request, messages.ERROR, msg)
self.logger.info(
'user=' + username + '. Matéria (ordem_id={}) já votada!'.format(ordem_id))
return HttpResponseRedirect(reverse(
return None, None, None, HttpResponseRedirect(reverse(
'sapl.sessao:ordemdia_list', kwargs={'pk': kwargs['pk']}))
try:
ordem = OrdemDia.objects.get(id=ordem_id)
materia_votacao = OrdemDia.objects.get(id=ordem_id)
except ObjectDoesNotExist:
self.logger.error(
'user=' + username + '. Objeto OrdemDia (pk={}) não existe.'.format(ordem_id))
raise Http404()
presentes = PresencaOrdemDia.objects.filter(
sessao_plenaria_id=ordem.sessao_plenaria_id)
total = presentes.count()
materia_votacao = ordem
sessao_plenaria_id=materia_votacao.sessao_plenaria_id)
if not ordem.votacao_aberta:
if not materia_votacao.votacao_aberta:
self.logger.error(
'user=' + username + '. A votação para esta OrdemDia (id={}) encontra-se fechada!'.format(ordem_id))
msg = _('A votação para esta matéria encontra-se fechada!')
messages.add_message(request, messages.ERROR, msg)
return HttpResponseRedirect(reverse(
return None, None, None, HttpResponseRedirect(reverse(
'sapl.sessao:ordemdia_list', kwargs={'pk': kwargs['pk']}))
ordem.registro_aberto = True
ordem.save()
elif self.expediente:
expediente_id = kwargs['oid']
if (RegistroVotacao.objects.filter(
@ -3021,194 +2978,270 @@ class VotacaoNominalAbstract(SessaoPermissionMixin):
"user=" + username + ". RegistroVotacao (expediente_id={}) já existe.".format(expediente_id))
msg = _('Esta matéria já foi votada!')
messages.add_message(request, messages.ERROR, msg)
return HttpResponseRedirect(reverse(
return None, None, None, HttpResponseRedirect(reverse(
'sapl.sessao:expedientemateria_list',
kwargs={'pk': kwargs['pk']}))
try:
self.logger.debug(
"user=" + username + ". Tentando obter Objeto ExpedienteMateria com id={}.".format(expediente_id))
expediente = ExpedienteMateria.objects.get(id=expediente_id)
materia_votacao = ExpedienteMateria.objects.get(id=expediente_id)
except ObjectDoesNotExist:
self.logger.error(
'user=' + username + '. Objeto ExpedienteMateria com id={} não existe.'.format(expediente_id))
raise Http404()
presentes = SessaoPlenariaPresenca.objects.filter(
sessao_plenaria_id=expediente.sessao_plenaria_id)
total = presentes.count()
materia_votacao = expediente
sessao_plenaria_id=materia_votacao.sessao_plenaria_id)
if not expediente.votacao_aberta:
if not materia_votacao.votacao_aberta:
msg = _(
'A votação para este ExpedienteMateria (id={}) encontra-se fechada!'.format(expediente_id))
messages.add_message(request, messages.ERROR, msg)
return HttpResponseRedirect(reverse(
return None, None, None, HttpResponseRedirect(reverse(
'sapl.sessao:expedientemateria_list',
kwargs={'pk': kwargs['pk']}))
expediente.registro_aberto = True
expediente.save()
total = presentes.count()
return materia_votacao, presentes, total, None
def _build_registro_context(self, materia_votacao, presentes, total):
materia = {'materia': materia_votacao.materia,
'ementa': sub(
'&nbsp;', ' ', strip_tags(
materia_votacao.materia.ementa))}
context = {'materia': materia, 'object': self.get_object(),
return {'materia': materia, 'object': self.get_object(),
'parlamentares': self.get_parlamentares(presentes),
'form': self.get_form(),
'total': total}
'total': total,
'registro_aberto': materia_votacao.registro_aberto}
def _redirect_same_registro(self, kwargs, page):
view = ('sapl.sessao:votacaonominal' if self.ordem
else 'sapl.sessao:votacaonominalexp')
return HttpResponseRedirect(reverse(view, kwargs={
'pk': kwargs['pk'], 'oid': kwargs['oid'], 'mid': kwargs['mid']}) + page)
def _redirect_lista(self, kwargs, page):
view = ('sapl.sessao:ordemdia_list' if self.ordem
else 'sapl.sessao:expedientemateria_list')
return HttpResponseRedirect(
reverse(view, kwargs={'pk': kwargs['pk']}) + page +
"#id{}".format(kwargs['mid']))
def get(self, request, *args, **kwargs):
if request.GET.get('status') == '1':
return self._status_json(kwargs)
materia_votacao, presentes, total, redirect = self._get_materia_votacao(
request, kwargs)
if redirect:
return redirect
context = self._build_registro_context(materia_votacao, presentes, total)
return self.render_to_response(context)
def _status_json(self, kwargs):
"""
Poll leve para a tela de registro (nominal.html) acompanhar, em tempo
real, os votos que chegam pelos tablets sem os efeitos colaterais
de _get_materia_votacao (mensagens, redirect quando votada) e sem
a máscara de mostrar_voto do sapl.painel:dados_painel (que é para o
telão público; aqui é a tela da própria Mesa, que precisa do valor
real para não sobrescrever por engano um voto que mudou).
"""
model = OrdemDia if self.ordem else ExpedienteMateria
lookup_field = 'ordem_id' if self.ordem else 'expediente_id'
try:
materia_votacao = model.objects.get(id=kwargs['oid'])
except ObjectDoesNotExist:
raise Http404()
votos = dict(VotoParlamentar.objects.filter(
**{lookup_field: materia_votacao.id}).values_list(
'parlamentar_id', 'voto'))
return JsonResponse({
'votacao_aberta': materia_votacao.votacao_aberta,
'registro_aberto': materia_votacao.registro_aberto,
'ja_registrada': RegistroVotacao.objects.filter(
**{lookup_field: materia_votacao.id}).exists(),
'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)
def post(self, request, *args, **kwargs):
self.object = self.get_object()
form = self.get_form()
username = request.user.username
page = ''
if 'page' in self.request.GET:
page = '?page={}'.format(self.request.GET['page'])
if self.ordem:
ordem_id = kwargs['oid']
try:
self.logger.debug(
"user=" + username + ". Tentando obter objeto OrdemDia com id={}.".format(ordem_id))
materia_votacao = OrdemDia.objects.get(id=ordem_id)
except ObjectDoesNotExist:
self.logger.error(
'user=' + username + '. Objeto OrdemDia com id={} não existe.'.format(ordem_id))
raise Http404()
elif self.expediente:
expediente_id = kwargs['oid']
try:
self.logger.debug(
"user=" + username + ". Tentando obter ExpedienteMateria com id={}.".format(expediente_id))
materia_votacao = ExpedienteMateria.objects.get(
id=expediente_id)
except ObjectDoesNotExist:
self.logger.error(
'user=' + username + '. Objeto ExpedienteMateria com id={} não existe.'.format(expediente_id))
raise Http404()
materia_votacao, presentes, total, redirect = self._get_materia_votacao(
request, kwargs)
if redirect:
return redirect
if form.is_valid():
votos_sim = 0
votos_nao = 0
abstencoes = 0
nao_votou = 0
fase_sessao_field = 'ordem_id' if self.ordem else 'expediente_id'
fase_sessao_value = kwargs['oid']
if 'reabrir-votacao' in request.POST:
with transaction.atomic():
materia_votacao = self._lock_materia_votacao(materia_votacao)
materia_votacao.registro_aberto = False
materia_votacao.save(update_fields=['registro_aberto'])
self.logger.info(
'user=' + username + '. Reabriu a matéria (id={}) para novos votos.'.format(fase_sessao_value))
return self._redirect_same_registro(kwargs, page)
if 'bloquear-registro-votacao' in request.POST:
with transaction.atomic():
materia_votacao = self._lock_materia_votacao(materia_votacao)
materia_votacao.registro_aberto = True
materia_votacao.save(update_fields=['registro_aberto'])
self.logger.info(
'user=' + username + '. Bloqueou novos votos para a matéria (id={}).'.format(fase_sessao_value))
return self._redirect_same_registro(kwargs, page)
if 'cancelar-votacao' in request.POST:
with transaction.atomic():
materia_votacao = self._lock_materia_votacao(materia_votacao)
fechar_votacao_materia(materia_votacao)
if self.ordem:
return HttpResponseRedirect(
reverse(
'sapl.sessao:ordemdia_list',
kwargs={'pk': kwargs['pk']}
) + page + "#id{}".format(self.kwargs['mid'])
)
else:
return HttpResponseRedirect(
reverse(
'sapl.sessao:expedientemateria_list',
kwargs={'pk': kwargs['pk']}
) + page + "#id{}".format(self.kwargs['mid'])
)
else:
if form.cleaned_data['resultado_votacao'] == None:
form.add_error(None, 'Não é possível finalizar a votação sem '
'nenhum resultado da votação')
return self._redirect_lista(kwargs, page)
if 'salvar-votacao' in request.POST:
form = self.get_form()
if not form.is_valid():
return self.form_invalid(form)
for votos in request.POST.getlist('voto_parlamentar'):
v = votos.split(':')
voto = v[0]
parlamentar_id = v[1]
if form.cleaned_data['resultado_votacao'] is None:
form.add_error(None, _('Não é possível finalizar a votação sem '
'nenhum resultado da votação'))
return self.form_invalid(form)
if voto == 'Sim':
votos_sim += 1
elif voto == 'Não':
votos_nao += 1
elif voto == 'Abstenção':
abstencoes += 1
elif voto == 'Não Votou':
nao_votou += 1
votos_submetidos = []
for voto_codificado in request.POST.getlist('voto_parlamentar'):
try:
voto_submetido, parlamentar_id = voto_codificado.split(':', 1)
except ValueError:
form.add_error(None, _('Formato de voto inválido.'))
return self.form_invalid(form)
if voto_submetido not in ('Sim', 'Não', 'Abstenção', 'Não Votou'):
form.add_error(None, _('Voto inválido.'))
return self.form_invalid(form)
votos_submetidos.append((voto_submetido, parlamentar_id))
# Caso todas as opções sejam 'Não votou', fecha a votação
if nao_votou == len(request.POST.getlist('voto_parlamentar')):
skipped_parlamentares = []
with transaction.atomic():
materia_votacao = self._lock_materia_votacao(materia_votacao)
if (not materia_votacao.votacao_aberta or
RegistroVotacao.objects.filter(
**{fase_sessao_field: fase_sessao_value}).exists()):
messages.error(
request, _('A votação já foi encerrada por outra operação.'))
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 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)
voto_parlamentar.save()
elif voto_parlamentar.voto != voto_submetido:
# Alguém (tipicamente via tablet) já registrou um
# voto diferente do valor deste formulário desde que
# a tela de registro foi carregada. O voto já
# registrado prevalece — não sobrescrevemos com um
# valor obsoleto do formulário em lote.
skipped_parlamentares.append(voto_parlamentar.parlamentar)
votos_atuais = VotoParlamentar.objects.filter(
**{fase_sessao_field: fase_sessao_value})
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()
if votos_sim + votos_nao + abstencoes == 0:
self.logger.error('user=' + username + '. Não é possível finalizar a votação sem '
'nenhum voto')
form.add_error(None, 'Não é possível finalizar a votação sem '
'nenhum voto')
form.add_error(None, _('Não é possível finalizar a votação sem '
'nenhum voto'))
return self.form_invalid(form)
# Remove todas as votação desta matéria, caso existam
if self.ordem:
RegistroVotacao.objects.filter(ordem_id=ordem_id).delete()
elif self.expediente:
RegistroVotacao.objects.filter(
expediente_id=expediente_id).delete()
votacao = RegistroVotacao()
votacao.numero_votos_sim = votos_sim
votacao.numero_votos_nao = votos_nao
votacao.numero_abstencoes = abstencoes
votacao.observacao = request.POST.get('observacao', None)
votacao.user = request.user
votacao.ip = get_client_ip(request)
votacao.materia_id = materia_votacao.materia.id
if self.ordem:
votacao.ordem_id = ordem_id
elif self.expediente:
votacao.expediente_id = expediente_id
votacao.tipo_resultado_votacao = form.cleaned_data['resultado_votacao']
**{fase_sessao_field: fase_sessao_value}).delete()
votacao = RegistroVotacao(
numero_votos_sim=votos_sim,
numero_votos_nao=votos_nao,
numero_abstencoes=abstencoes,
observacao=request.POST.get('observacao', None),
user=request.user,
ip=get_client_ip(request),
materia_id=materia_votacao.materia.id,
tipo_resultado_votacao=form.cleaned_data['resultado_votacao'])
setattr(votacao, fase_sessao_field, fase_sessao_value)
votacao.save()
for votos in request.POST.getlist('voto_parlamentar'):
v = votos.split(':')
voto = v[0]
parlamentar_id = v[1]
votos_atuais.update(votacao_id=votacao.id)
if self.ordem:
voto_parlamentar = VotoParlamentar.objects.get_or_create(
parlamentar_id=parlamentar_id,
ordem_id=ordem_id)[0]
elif self.expediente:
voto_parlamentar = VotoParlamentar.objects.get_or_create(
parlamentar_id=parlamentar_id,
expediente_id=expediente_id)[0]
voto_parlamentar.voto = voto
voto_parlamentar.parlamentar_id = parlamentar_id
voto_parlamentar.votacao_id = votacao.id
voto_parlamentar.user = request.user
voto_parlamentar.ip = get_client_ip(request)
voto_parlamentar.save()
resultado = form.cleaned_data['resultado_votacao']
materia_votacao.resultado = resultado.nome
materia_votacao.resultado = form.cleaned_data['resultado_votacao'].nome
materia_votacao.votacao_aberta = False
materia_votacao.registro_aberto = False
materia_votacao.save()
# Verifica se existe algum VotoParlamentar sem RegistroVotacao
# Por exemplo, se algum parlamentar votar e sua presença for
# removida da ordem do dia/expediente antes da conclusão da
# votação
if self.ordem:
VotoParlamentar.objects.filter(
ordem_id=ordem_id,
votacao__isnull=True).delete()
elif self.expediente:
VotoParlamentar.objects.filter(
expediente_id=expediente_id,
votacao__isnull=True).delete()
**{fase_sessao_field: fase_sessao_value}, votacao__isnull=True).delete()
if skipped_parlamentares:
nomes = ', '.join(p.nome_parlamentar for p in skipped_parlamentares)
messages.add_message(
request, messages.WARNING,
_('O(s) voto(s) de %(nomes)s já haviam sido registrados '
'e não foram sobrescritos.') % {'nomes': nomes})
return self.form_valid(form)
else:
return self.form_invalid(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)
def form_invalid(self, form):
errors_tuple = [(form[e].label, form.errors[e])

5
sapl/templates/painel/index.html

@ -506,6 +506,10 @@
function show_voto(parlamentar) {
color = 'white'
if (!parlamentar.voto) {
parlamentar.voto = "Não votou";
}
if (parlamentar.voto == "Sim"){
color = 'green'
}
@ -529,4 +533,3 @@
</script>
</html>

52
sapl/templates/painel/voto_individual.html

@ -77,6 +77,11 @@
</p>
</font>
</h2>
{% if status_message %}
<p align="center" style="font-family:Verdana">
<font color="#8fbfe0">{{status_message}}</font>
</p>
{% endif %}
</br>
</br>
@ -111,7 +116,8 @@
</div>
{% else %}
<div id="blocoerro" style="display:block">
<h2><font color="red"><p align="center" style="font-family:Verdana">{{error_message}}</p></font></h2>
<h1><font color="#eaeaea"><p align="center" style="font-family:Verdana"></p></font></h1>
<h2><font color="#f0ad4e"><p align="center" style="font-family:Verdana">{{error_message}}</p></font></h2>
</br>
</br>
</br>
@ -184,11 +190,47 @@
$("#date").text(n);
startTime();
setTimeout(function() {
document.location.reload(true);
}, 30000)
});
</script>
{% endif %}
<script type="text/javascript">
// Substitui o antigo reload automático de página inteira a cada 30s por
// um poll leve (fetch simples, sem depender do jQuery/bundle carregado
// condicionalmente acima) que só recarrega a página quando o estado
// realmente muda — economiza o custo de renderizar a página inteira a
// cada poll em cada um dos tablets. Roda nas duas telas (voto e erro),
// já que "aguardando abertura de matéria" também precisa se recuperar
// sozinho quando o estado mudar.
(function() {
var estadoAtual = {
materiaId: "{% if materia %}{{ materia.id }}{% endif %}",
errorMessage: "{{ error_message|default:''|escapejs }}",
statusMessage: "{{ status_message|default:''|escapejs }}",
votoParlamentar: "{{ voto_parlamentar|default:''|escapejs }}"
};
function verificaEstado() {
fetch("{% url 'sapl.painel:voto_individual_status' %}", {credentials: 'same-origin'})
.then(function(resp) { return resp.json(); })
.then(function(data) {
var novo = {
materiaId: data.materia_id ? String(data.materia_id) : "",
errorMessage: data.error_message || "",
statusMessage: data.status_message || "",
votoParlamentar: data.voto_parlamentar || ""
};
if (novo.materiaId !== estadoAtual.materiaId ||
novo.errorMessage !== estadoAtual.errorMessage ||
novo.statusMessage !== estadoAtual.statusMessage ||
novo.votoParlamentar !== estadoAtual.votoParlamentar) {
document.location.reload();
}
})
.catch(function() { /* tenta de novo no próximo poll */ });
}
setInterval(verificaEstado, 5000);
})();
</script>

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

@ -13,6 +13,15 @@
Ementa: {{materia.ementa|safe}}
</div>
<br />
<div class="alert {% if registro_aberto %}alert-warning{% else %}alert-info{% endif %}" role="alert" id="status_votacao">
<strong>Status da votação:</strong> em aberto ·
{% if registro_aberto %}
novos votos <strong>bloqueados</strong> — os vereadores que ainda não votaram não conseguem mais votar até a Mesa reabrir
{% else %}
novos votos <strong>permitidos</strong> — os vereadores ainda podem votar pelo tablet enquanto a Mesa registra
{% endif %}
· {{ total }} parlamentar(es) presente(s)
</div>
{% if total == 0 %}
<div class="alert alert-info alert-dismissible " role="alert">
<div>Não existe nenhum parlamentar presente para que a votação ocorra.</div>
@ -22,17 +31,18 @@
<fieldset class="form-group">
<legend>Votos</legend>
<div class="row">
<div class="row" id="linhas_parlamentares">
{% for parlamentar in parlamentares %}
<div class="col-md-4" id="styleparlamentar">{{parlamentar.0.nome_parlamentar}}</div>
<div class="col-md-5">
<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" {% 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="Sim:{{parlamentar.0.id}}" {% if parlamentar.1 == 'Sim' %} selected {% endif %}>Sim</option>
<option value="Não:{{parlamentar.0.id}}" {% if parlamentar.1 == 'Não' %} selected {% endif %}>Não</option>
<option value="Abstenção:{{parlamentar.0.id}}" {% if parlamentar.1 == 'Abstenção' %} selected {% endif %}>Abstenção</option>
</select>
<span data-parlamentar-tag="{{parlamentar.0.id}}" class="badge badge-info" style="{% if not parlamentar.1 %}display:none;{% endif %}">Já votado (tablet)</span>
</div>
{% endfor %}
</div>
@ -60,8 +70,17 @@
<div class="row">
<div class="col-md-12">
<div class="form-group row justify-content-between">
<input type="submit" id="cancelar-votacao" name="cancelar-votacao" value="Cancelar Votação" class="btn btn-warning" />
<input type="submit" id="salvar-votacao" name="salvar-votacao" value="Fechar Votação" class="btn btn-primary" />
<input type="submit" id="cancelar-votacao" name="cancelar-votacao" value="Cancelar Votação" class="btn btn-warning"
data-toggle="tooltip" data-placement="top" title="Apaga todos os votos já registrados desta matéria e reabre a votação do zero." />
{% if registro_aberto %}
<input type="submit" id="reabrir-votacao" name="reabrir-votacao" value="Reabrir Votação para Novos Votos" class="btn btn-secondary"
data-toggle="tooltip" data-placement="top" title="Volta a aceitar votos dos vereadores que ainda não votaram — nenhum voto já registrado é alterado." />
{% else %}
<input type="submit" id="bloquear-registro-votacao" name="bloquear-registro-votacao" value="Bloquear Novos Votos" class="btn btn-info"
data-toggle="tooltip" data-placement="top" title="Impede novos votos dos vereadores enquanto a Mesa apura o resultado. Não fecha a votação." />
{% endif %}
<input type="submit" id="salvar-votacao" name="salvar-votacao" value="Encerrar Votação" class="btn btn-primary"
data-toggle="tooltip" data-placement="top" title="Apura o resultado final e encerra definitivamente a votação desta matéria." />
</div>
</div>
</div>
@ -76,6 +95,8 @@
window.history.back();
}
$('[data-toggle="tooltip"]').tooltip();
function conta_votos() {
var votos_sim = 0;
var votos_nao = 0;
@ -117,5 +138,52 @@
$("input[type=submit], input[type=button]").prop("disabled", "disabled");
});
// Atualiza, a cada poucos segundos, os votos que já chegaram pelo
// tablet — usa o próprio endpoint desta tela (?status=1), não o do
// telão (sapl.painel:dados_painel): aquele mascara o valor real do
// voto quando a Casa configura "mostrar_voto=False" (regra pensada
// para o público, não para a Mesa) e exige a permissão do módulo
// painel, que um Operador de Sessão Plenária pode não ter.
// Reaplica o valor a cada poll (não só na primeira vez) para
// refletir um vereador que troca o voto durante a janela de
// votação — a linha permanece travada para edição pelo operador,
// já que o voto do vereador sempre prevalece (VotacaoNominalAbstract.post).
function atualiza_votos_ao_vivo() {
$.ajax({
url: window.location.pathname + '?status=1',
type: "GET",
dataType: "json",
}).done(function(data) {
if (!data || !data.votos) {
return;
}
var mudou = false;
Object.keys(data.votos).forEach(function(parlamentarId) {
var voto = data.votos[parlamentarId];
if (!voto) {
return;
}
var select = $('[data-parlamentar-select="' + parlamentarId + '"]');
if (!select.length) {
return;
}
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();
setInterval(atualiza_votos_ao_vivo, 3000);
</script>
{% endblock extra_js%}

Loading…
Cancel
Save