Sistema de Apoio ao Processo Legislativo
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

1244 lines
51 KiB

import html
import json
import logging
import time as _time
from django.contrib.auth.decorators import (login_required, permission_required,
user_passes_test)
from django.core.cache import cache
from django.core.exceptions import PermissionDenied
from django.db import transaction
from django.urls import reverse
from django.db.models import Q
from django.http import HttpResponse, JsonResponse
from django.http.response import Http404, HttpResponseRedirect
from django.shortcuts import render
from django.utils import timezone
from django.utils.translation import ugettext_lazy as _
from django.views.decorators.cache import never_cache
from sapl.base.models import AppConfig as ConfiguracoesAplicacao
from sapl.base.models import CasaLegislativa
from sapl.crud.base import Crud
from sapl.painel.apps import AppConfig
from sapl.parlamentares.models import Legislatura, Parlamentar, Votante
from sapl.sessao.models import (ExpedienteMateria, OradorExpediente,
OradorOrdemDia, OrdemDia,
PresencaOrdemDia, RegistroVotacao,
SessaoMateriasVotacoesView, SessaoPlenaria,
SessaoPlenariaPresenca, SessaoPresencasView,
VotoParlamentar, RegistroLeitura)
from sapl.utils import get_client_ip, sort_lista_chave
from image_cropping.utils import get_backend
from .models import Cronometro
VOTACAO_NOMINAL = 2
CronometroPainelCrud = Crud.build(Cronometro, '')
# FIXME mudar lógica
def check_permission(user):
return user.has_module_perms(AppConfig.label)
def votacao_aberta(request):
'''
Função que verifica se há somente 1 uma matéria aberta ou
nenhuma. É utilizada como uma função auxiliar para a view
votante_view.
A mensagem de erro volta como texto puro e é exibida pelo chamador em
error_message (inclusive no poll JSON do tablet), então não é
registrada em messages — senão cada poll empilharia uma cópia na sessão.
'''
logger = logging.getLogger(__name__)
username = request.user.username
votacoes_abertas = SessaoPlenaria.objects.filter(
Q(ordemdia__votacao_aberta=True) |
Q(expedientemateria__votacao_aberta=True)).distinct()
if len(votacoes_abertas) > 1:
sessoes = ', '.join(str(v) for v in votacoes_abertas)
logger.info('user=' + username + '. Existe mais de uma votações aberta. Elas se encontram '
'nas seguintes Sessões: ' + sessoes + '. '
'Para votar, peça para que o Operador feche-as.')
msg = _('Existe mais de uma votações aberta. Elas se encontram '
'nas seguintes Sessões: ' + sessoes + '. '
'Para votar, peça para que o Operador feche-as.')
return None, msg
elif len(votacoes_abertas) == 1:
ordens = OrdemDia.objects.filter(
sessao_plenaria=votacoes_abertas.first(),
votacao_aberta=True)
expedientes = ExpedienteMateria.objects.filter(
sessao_plenaria=votacoes_abertas.first(),
votacao_aberta=True)
numero_materias_abertas = len(ordens) + len(expedientes)
if numero_materias_abertas > 1:
sessao = str(votacoes_abertas.first())
logger.info('user=' + username + '. Existe mais de uma votação aberta na Sessão: ' +
sessao)
msg = _('Existe mais de uma votação aberta na Sessão: ' + sessao +
'. Para votar, peça para que o Operador as feche.')
return None, msg
return votacoes_abertas.first(), None
def votacao(context, context_vars):
logger = logging.getLogger(__name__)
parlamentar = context_vars['votante'].parlamentar
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':
'Você não está presente na '
'Ordem do Dia/Expediente em votação.'})
return context, context_vars
context_vars.update({'parlamentar': parlamentar})
if context_vars['ordem_dia']:
voto = VotoParlamentar.objects.filter(
ordem=context_vars['ordem_dia'], parlamentar=parlamentar).first()
elif context_vars['expediente']:
voto = VotoParlamentar.objects.filter(
expediente=context_vars['expediente'], parlamentar=parlamentar).first()
else:
voto = None
if voto:
context.update({
'voto_parlamentar': voto.voto,
'status_message': 'Voto registrado. Aguardando o encerramento '
'da votação pela Mesa.',
})
else:
context.update({'status_message': 'Aguardando seu voto.'})
return context, context_vars
def sessao_votacao(context, context_vars):
pk = context_vars['sessao'].pk
context.update({'sessao_id': pk})
context.update({'sessao': context_vars['sessao'],
'data': context_vars['sessao'].data_inicio,
'hora': context_vars['sessao'].hora_inicio})
# Inicializa presentes
presentes = []
ordem_dia = get_materia_aberta(pk)
expediente = get_materia_expediente_aberta(pk)
errors_msgs = {'materia': 'Não há nenhuma matéria aberta.',
'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:
materia_aberta = ordem_dia
presentes = PresencaOrdemDia.objects.filter(
sessao_plenaria_id=pk).values_list(
'parlamentar_id', flat=True).distinct()
elif expediente:
materia_aberta = expediente
presentes = SessaoPlenariaPresenca.objects.filter(
sessao_plenaria_id=pk).values_list(
'parlamentar_id', flat=True).distinct()
context_vars.update({'ordem_dia': ordem_dia,
'expediente': expediente,
'presentes': presentes})
# Verifica votação aberta
# Se aberta, verifica se é nominal. ID nominal == 2
erro = None
if not materia_aberta:
erro = 'materia'
elif materia_aberta.registro_aberto:
erro = 'registro'
elif materia_aberta.tipo_votacao != VOTACAO_NOMINAL:
erro = 'tipo'
if not erro:
context.update({'materia': materia_aberta.materia,
'ementa': materia_aberta.materia.ementa,
'sessao_id': materia_aberta.sessao_plenaria_id})
context, context_vars = votacao(context, context_vars)
else:
context.update({'error_message': errors_msgs[erro]})
return context, context_vars
def can_vote(context, context_vars, request):
context.update({'permissao': True})
# Pega sessão
sessao, msg = votacao_aberta(request)
context_vars.update({'sessao': sessao})
if sessao and not msg:
context, context_vars = sessao_votacao(context, context_vars)
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
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():
logging.getLogger(__name__).warning(
f'user={username} sem cadastro de Votante tentou acessar /voto-individual/.'
)
raise PermissionDenied
context = {'head_title': str(_('Votação Individual'))}
context_vars = {'votante': Votante.objects.get(user=request.user)}
return 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 tablet (voto_individual.html/voto-individual v2)
— devolve só o suficiente pra decidir se o estado pessoal do Votante
mudou, sem o custo de renderizar a página inteira. Tem checagem de
permissão própria (parlamentares.can_vote) em vez de check_permission
(permissão do módulo painel), que uma conta só-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'),
})
def _trava_materia_para_voto(materia, parlamentar):
"""
Trava a linha da matéria (select_for_update) e confere, sob o lock, que
ela ainda aceita voto deste parlamentar: presente, votação aberta, não
bloqueada pela Mesa, nominal e ainda sem RegistroVotacao. Deve ser
chamada dentro de transaction.atomic(). Devolve o filtro da matéria
para VotoParlamentar ({'ordem': ...} ou {'expediente': ...}).
"""
if isinstance(materia, OrdemDia):
materia = OrdemDia.objects.select_for_update().get(pk=materia.pk)
fase_sessao = {'ordem': materia}
presenca_model = PresencaOrdemDia
else:
materia = ExpedienteMateria.objects.select_for_update().get(pk=materia.pk)
fase_sessao = {'expediente': materia}
presenca_model = SessaoPlenariaPresenca
esta_presente = presenca_model.objects.filter(
sessao_plenaria_id=materia.sessao_plenaria_id,
parlamentar=parlamentar).exists()
if not esta_presente:
raise VoteError(
"O parlamentar não está presente na sessão.", status=409)
if (not materia.votacao_aberta or materia.registro_aberto or
materia.tipo_votacao != VOTACAO_NOMINAL or
RegistroVotacao.objects.filter(**fase_sessao).exists()):
raise VoteError(
"A votação não está mais disponível para novos votos.", status=409)
return fase_sessao
def _save_voto_individual(request, context_vars, voto_valor=None, ip=None):
"""
Salva o voto do Votante autenticado — compartilhado por votante_view
(tela legada), votante_view_v2 (Vue, form HTTP) e
PainelConsumer.receive_json (type: "vote_self", WS — ver
_handle_vote_self). `voto_valor`/`ip` deixam o caminho WS passar o
voto/IP explicitamente em vez de ler de request.POST/request.META,
que uma conexão WebSocket não tem; o caminho HTTP não passa nada e
mantém o comportamento de sempre.
O voto do próprio parlamentar sempre prevalece sobre um valor lançado
pela Mesa. Levanta VoteError quando a votação não aceita mais votos.
"""
logger = logging.getLogger(__name__)
username = request.user.username
materia = context_vars.get('ordem_dia') or context_vars.get('expediente')
parlamentar = context_vars.get('parlamentar')
if not materia or not parlamentar:
raise VoteError("A votação não está disponível para novos votos.", status=409)
voto_valor = voto_valor if voto_valor is not None else request.POST.get('voto')
if voto_valor not in VOTOS_REAIS:
raise VoteError(
f"Invalid vote value: {voto_valor}. Must be one of: {list(VOTOS_REAIS)}")
with transaction.atomic():
fase_sessao = _trava_materia_para_voto(materia, parlamentar)
voto, _created = VotoParlamentar.objects.select_for_update().get_or_create(
parlamentar=parlamentar, **fase_sessao)
voto.voto = voto_valor
voto.ip = ip if ip is not None else get_client_ip(request)
voto.user = request.user
# Único caminho onde é o próprio parlamentar votando — vote_controller/
# _cast_vote (operador) sempre grava False.
voto.votado_pelo_parlamentar = True
voto.save()
logger.info("user=" + username + ". VotoParlamentar para parlamentar={} salvo com sucesso."
.format(parlamentar))
broadcast_dados_painel(request, context_vars['sessao'].id)
return voto_valor
@never_cache
@login_required
@permission_required('parlamentares.can_vote', raise_exception=True)
def votante_view(request):
context, context_vars = _resolve_votante_context(request)
if request.method == 'POST':
try:
_save_voto_individual(request, context_vars)
except VoteError as e:
# Recarregar a tela já mostra o estado atual (bloqueada,
# encerrada, ausente) em error_message.
logging.getLogger(__name__).info(
"user=%s. Voto recusado: %s", request.user.username, e.message)
return HttpResponseRedirect(
reverse('sapl.painel:voto_individual'))
return render(request, 'painel/voto_individual_v2.html', context)
@never_cache
@login_required
@user_passes_test(check_permission)
def switch_painel(request):
sessao = SessaoPlenaria.objects.get(id=request.POST['pk_sessao'])
switch = json.loads(request.POST['aberto'])
if switch:
sessao.painel_aberto = True
else:
sessao.painel_aberto = False
sessao.save()
# Se o painel for fechado, pausa qualquer cronômetro que esteja em execução
if not sessao.painel_aberto:
now_ts = _time.time()
for t in ['discurso', 'aparte', 'ordem', 'consideracoes']:
val = cache.get(CRONOMETRO_CACHE_KEY.format(t))
if isinstance(val, dict) and val.get('action') == 'start':
elapsed = now_ts - val.get('start_ts', now_ts)
base_rem = val.get('remaining_at_start', val.get('duration', 300))
remaining = max(0.0, base_rem - elapsed)
entry = {
'action': 'stop',
'duration': val.get('duration', 300),
'remaining_at_stop': remaining,
'remaining_at_start': remaining,
'updated_at': now_ts,
}
cache.set(CRONOMETRO_CACHE_KEY.format(t), entry, CRONOMETRO_CACHE_TIMEOUT)
# Notifica todos os clientes WebSocket da mudança de status do painel
# (antes o switch_painel não disparava broadcast, por isso o painel não
# abria/fechava em tempo real sem um F5)
broadcast_dados_painel(request, sessao.id)
return JsonResponse({})
@never_cache
@login_required
@user_passes_test(check_permission)
def verifica_painel(request):
sessao = SessaoPlenaria.objects.get(id=request.GET['pk_sessao'])
status = sessao.painel_aberto
resposta = JsonResponse(dict(status=status))
return resposta
CRONOMETRO_CACHE_KEY = 'cronometro:{}'
CRONOMETRO_CACHE_TIMEOUT = None # não expira sozinho — só quando outro action chega
CRONOMETRO_ULTIMO_ATIVO_KEY = 'cronometro:ultimo_ativo'
class CronometroStatus(dict):
"""
Subclasse de dict para compatibilidade com asserções em testes
(ex: assert data['cronometro_discurso'] == 'start') e serialização JSON.
"""
def __eq__(self, other):
if isinstance(other, str):
return self.get('action') == other
return super().__eq__(other)
@user_passes_test(check_permission)
def cronometro_painel(request):
"""
Estado do cronômetro (start/stop/reset de cada tipo) era guardado em
request.session — visível só para quem clicou, nunca para os outros
clientes olhando o mesmo painel (o telão público, outros operadores).
Agora fica em cache compartilhado, e dispara um broadcast pro grupo da
sessão atualmente com o painel aberto, para os clientes conectados via
WebSocket sincronizarem o cronômetro deles quase na hora — sem isso,
dependeriam do próximo poll (até alguns segundos de atraso) para saber
que o Presidente apertou start/stop/reset.
O cache guarda um dict com:
action — 'start' | 'stop' | 'reset'
start_ts — epoch (float) do momento em que o start foi dado
duration — duração total configurada em segundos
remaining_at_start — segundos restantes no momento do start/resumo
remaining_at_stop — segundos restantes no momento do stop/pausa
updated_at — epoch (float) da última modificação
Isso permite que qualquer cliente que conecte (ou dê F5) recalcule o
tempo restante correto a partir de (start_ts + remaining_at_start - now()).
"""
tipo = request.GET['tipo']
action = request.GET['action']
remaining_param = request.GET.get('remaining')
# Registra o tipo como o último cronômetro operado pelo operador
cache.set(CRONOMETRO_ULTIMO_ATIVO_KEY, tipo, CRONOMETRO_CACHE_TIMEOUT)
# Lê a duração configurada no AppConfig
app_config = ConfiguracoesAplicacao.objects.first()
duracao_campo = {
'discurso': 'cronometro_discurso',
'aparte': 'cronometro_aparte',
'ordem': 'cronometro_ordem',
'consideracoes': 'cronometro_consideracoes',
}.get(tipo)
duration_td = getattr(app_config, duracao_campo, None) if (app_config and duracao_campo) else None
duration_secs = int(duration_td.total_seconds()) if duration_td else 300 # default 5min
# Recupera estado anterior
prev = cache.get(CRONOMETRO_CACHE_KEY.format(tipo)) or {}
now_ts = _time.time()
if action == 'start':
if remaining_param is not None:
try:
remaining_at_start = max(0.0, float(remaining_param))
except ValueError:
remaining_at_start = float(duration_secs)
elif isinstance(prev, dict) and prev.get('action') == 'stop' and prev.get('remaining_at_stop') is not None:
remaining_at_start = float(prev['remaining_at_stop'])
else:
remaining_at_start = float(duration_secs)
entry = {
'action': 'start',
'start_ts': now_ts,
'duration': duration_secs,
'remaining_at_start': remaining_at_start,
'remaining_at_stop': remaining_at_start,
'updated_at': now_ts,
}
elif action == 'stop':
if remaining_param is not None:
try:
remaining = max(0.0, float(remaining_param))
except ValueError:
remaining = float(duration_secs)
elif isinstance(prev, dict) and prev.get('action') == 'start':
elapsed = now_ts - prev.get('start_ts', now_ts)
base_rem = prev.get('remaining_at_start', prev.get('duration', duration_secs))
remaining = max(0.0, base_rem - elapsed)
elif isinstance(prev, dict) and prev.get('remaining_at_stop') is not None:
remaining = float(prev['remaining_at_stop'])
else:
remaining = float(duration_secs)
entry = {
'action': 'stop',
'duration': duration_secs,
'remaining_at_stop': remaining,
'remaining_at_start': remaining,
'updated_at': now_ts,
}
else: # reset
entry = {
'action': 'reset',
'duration': duration_secs,
'remaining_at_start': float(duration_secs),
'remaining_at_stop': float(duration_secs),
'updated_at': now_ts,
}
cache.set(CRONOMETRO_CACHE_KEY.format(tipo), entry, CRONOMETRO_CACHE_TIMEOUT)
sessao = SessaoPlenaria.objects.filter(painel_aberto=True).first()
if sessao:
broadcast_dados_painel(request, sessao.id)
return HttpResponse({})
def get_cronometro_status(request, name):
"""
Retorna o estado serializado do cronômetro para incluir no payload do
painel. O dict retornado é normalizado pelo frontend (normalizePainel.js)
e processado por CronometroList.vue para sincronizar o estado local.
Formato retornado:
{ action, duration, start_ts?, remaining_at_start?, remaining_at_stop?, updated_at? }
Compatibilidade: se ainda houver uma string no cache (formato antigo),
retorna a string diretamente para não quebrar instâncias em migração.
"""
val = cache.get(CRONOMETRO_CACHE_KEY.format(name))
if val is None:
return None
if isinstance(val, dict):
return CronometroStatus(val)
return val
def get_materia_aberta(pk):
return OrdemDia.objects.filter(
sessao_plenaria_id=pk, votacao_aberta=True).last()
def get_presentes(pk, response, materia):
etapa_sessao = 'ordemdia' if type(materia) == OrdemDia else 'expediente'
presencas = SessaoPresencasView.objects.filter(
sessao_plenaria_id=pk, etapa_sessao=etapa_sessao)
# Mesmo discriminador de etapa_sessao usado acima pra SessaoPresencasView
# — sem isso, o payload sempre consultava OradorExpediente, nunca
# OradorOrdemDia, então um orador de Ordem do Dia nunca aparecia no
# painel/telão mesmo com o broadcast disparando corretamente.
oradores_model = OradorOrdemDia if etapa_sessao == 'ordemdia' else OradorExpediente
oradores = oradores_model.objects.filter(
sessao_plenaria_id=pk).order_by('numero_ordem')
oradores_list = []
for o in oradores:
oradores_list.append(
{
'nome': o.parlamentar.nome_parlamentar,
'numero': o.numero_ordem
})
presentes_list = []
for p in presencas:
presentes_list.append(
{'id': p.id,
'parlamentar_id': p.parlamentar_id,
'nome': p.nome_parlamentar,
'partido': p.filiacao,
'voto': ''
})
# Fotos/crop dos parlamentares presentes — consulta em bloco, à parte do
# loop acima, porque SessaoPresencasView é uma view flat (sem FK para
# Parlamentar) criada para eliminar o N+1 por presente.
fotos_by_id = {
row['id']: row
for row in Parlamentar.objects.filter(
id__in=[p['parlamentar_id'] for p in presentes_list]
).values('id', 'fotografia', 'cropping')
}
for p in presentes_list:
foto = fotos_by_id.get(p['parlamentar_id'])
thumbnail_url = False
if foto and foto['fotografia']:
try:
thumbnail_url = get_backend().get_thumbnail_url(
foto['fotografia'],
{
'size': (128, 128),
'box': foto['cropping'],
'crop': True,
'detail': True,
}
)
except Exception:
logging.getLogger(__name__).exception(
'Falha ao gerar thumbnail do parlamentar id=%s.',
p['parlamentar_id'])
p['fotografia'] = thumbnail_url
if materia:
if materia.tipo_votacao == 1:
tipo_votacao = 'Simbólica'
elif materia.tipo_votacao == 2:
tipo_votacao = 'Nominal'
elif materia.tipo_votacao == 3:
tipo_votacao = 'Secreta'
elif materia.tipo_votacao == 4:
tipo_votacao = 'Leitura'
response.update({
'tipo_resultado': materia.resultado,
'observacao_materia': html.unescape(materia.observacao),
'tipo_votacao': tipo_votacao,
'materia_legislativa_texto': str(materia.materia),
'materia_legislativa_ementa': str(materia.materia.ementa)
})
presentes_list = sort_lista_chave(presentes_list, 'nome')
response.update({
'presentes': presentes_list,
'num_presentes': len(presentes_list),
'oradores': oradores_list,
'msg_painel': str(_('Votação aberta!')),
})
return response
def get_materia_expediente_aberta(pk):
return ExpedienteMateria.objects.filter(
sessao_plenaria_id=pk, votacao_aberta=True).last()
def get_votos(response, materia, mostrar_voto):
etapa_sessao = 'ordemdia' if type(materia) == OrdemDia else 'expediente'
lookup = {'ordem': materia} if type(materia) == OrdemDia else {'expediente': materia}
if materia.tipo_votacao != 4:
view_row = SessaoMateriasVotacoesView.objects.filter(
id=materia.id, etapa_sessao=etapa_sessao).first()
leitura = None
else:
view_row = None
leitura = RegistroLeitura.objects.filter(
materia=materia.materia, **lookup).order_by('data_hora').last()
if (view_row is None or view_row.numero_votos is None) and not leitura:
response.update({
'numero_votos_sim': 0,
'numero_votos_nao': 0,
'numero_abstencoes': 0,
'registro': None,
'total_votos': 0,
'tipo_resultado': 'Ainda não foi votada.',
})
if materia.tipo_votacao == 2:
votos_parlamentares = (view_row.votos_parlamentares if view_row else None) or {}
for i, p in enumerate(response['presentes']):
voto_entry = votos_parlamentares.get(str(p['parlamentar_id']))
voto = voto_entry['voto'] if voto_entry else None
if voto:
if mostrar_voto:
response['presentes'][i]['voto'] = voto
else:
response['presentes'][i]['voto'] = 'Voto Informado'
# Sempre desmascarado (não depende de mostrar_voto): diz
# quem travou a linha, não o que a pessoa votou — é o
# que VotacaoVotos.vue usa pra saber se deve desabilitar
# o <select> do operador (só quando o próprio parlamentar
# votou, não quando foi o próprio operador via este
# mesmo <select> ou o formulário legado em lote).
response['presentes'][i]['voto_por_tablet'] = bool(
voto_entry.get('votado_pelo_parlamentar'))
elif leitura:
response.update({
'numero_votos_sim': 0,
'numero_votos_nao': 0,
'numero_abstencoes': 0,
'registro': True,
'total_votos': 0,
'tipo_resultado': 'Matéria lida.',
})
else:
numero_votos = view_row.numero_votos
votos_parlamentares = view_row.votos_parlamentares or {}
if materia.tipo_votacao == 2:
for i, p in enumerate(response['presentes']):
voto_entry = votos_parlamentares.get(str(p['parlamentar_id']))
response['presentes'][i]['voto'] = voto_entry['voto'] if voto_entry else None
response['presentes'][i]['voto_por_tablet'] = bool(
voto_entry and voto_entry.get('votado_pelo_parlamentar'))
response.update({
'numero_votos_sim': numero_votos['votos_sim'],
'numero_votos_nao': numero_votos['votos_nao'],
'numero_abstencoes': numero_votos['abstencoes'],
'registro': True,
'total_votos': numero_votos['total_votos'],
'tipo_resultado': view_row.resultado_votacao,
})
return response
def build_dados_painel(request, pk, force_mostrar_voto=False):
"""
Monta o dict completo consumido pelos broadcasts via WebSocket
(broadcast_dados_painel() abaixo) — era também servido por um endpoint
HTTP de polling (get_dados_painel), removido quando o polling foi
eliminado (nenhuma tela restante lê build_dados_painel() por HTTP).
`force_mostrar_voto`: usado por PainelConsumer para dar à conexão do
operador/Mesa (painel/sessao module perms) o voto real de cada
parlamentar, mesmo quando a Casa configura mostrar_voto=False para o
público. Cada conexão WebSocket pede seu próprio payload (ver
PainelConsumer.painel_refresh) em vez de um único payload comum
broadcastado a todo o grupo — assim o voto real nunca trafega para a
conexão do telão público/tablets, só para quem tem permissão de
operação. Sem isso, dar visibilidade real ao operador exigiria colocar
o voto real no payload do grupo inteiro (legível no DevTools do telão
público mesmo que a UI não o renderize) ou deixar a Mesa cega para
votos já registrados por tablet.
"""
sessao = SessaoPlenaria.objects.get(id=pk)
casa = CasaLegislativa.objects.first()
app_config = ConfiguracoesAplicacao.objects.first()
efetivo_mostrar_voto = bool(app_config.mostrar_voto) or force_mostrar_voto
brasao = None
if casa and app_config and (bool(casa.logotipo)):
brasao = casa.logotipo.url \
if app_config.mostrar_brasao_painel else None
response = {
'sessao_plenaria': str(sessao),
'sessao_plenaria_data': sessao.data_inicio.strftime('%d/%m/%Y'),
'sessao_plenaria_hora_inicio': sessao.hora_inicio,
'sessao_solene': sessao.tipo.nome == "Solene",
'sessao_finalizada': sessao.finalizada,
# Sessões anteriores à migração que introduziu este campo ficaram
# com iniciada=None — tratado como "iniciada" (True) por
# restringe_sessoes_visiveis() em sapl/sessao/models.py, mesma
# convenção seguida aqui.
'sessao_iniciada': sessao.iniciada is not False,
'tema_solene': sessao.tema_solene,
'cronometro_aparte': get_cronometro_status(request, 'aparte'),
'cronometro_discurso': get_cronometro_status(request, 'discurso'),
'cronometro_ordem': get_cronometro_status(request, 'ordem'),
'cronometro_consideracoes': get_cronometro_status(request, 'consideracoes'),
'cronometro_ativo': cache.get(CRONOMETRO_ULTIMO_ATIVO_KEY) or 'discurso',
'status_painel': sessao.painel_aberto,
'brasao': brasao,
'mostrar_voto': efetivo_mostrar_voto
}
ordem_dia = get_materia_aberta(pk)
expediente = get_materia_expediente_aberta(pk)
# Caso tenha alguma matéria com votação aberta, ela é mostrada no painel
# com prioridade para Ordem do Dia. registro_aberto só faz sentido
# enquanto há uma matéria aberta pra registro — usado pela tela de
# operação (VotacaoNominal.vue) pra saber se deve mostrar "Bloquear" ou
# "Reabrir Votação" e o aviso de status, equivalente ao que
# nominal.html já fazia lendo o context direto.
if ordem_dia:
response['registro_aberto'] = ordem_dia.registro_aberto
return get_votos(
get_presentes(pk, response, ordem_dia),
ordem_dia, efetivo_mostrar_voto)
elif expediente:
response['registro_aberto'] = expediente.registro_aberto
return get_votos(
get_presentes(pk, response, expediente),
expediente, efetivo_mostrar_voto)
# Caso não tenha nenhuma aberta,
# a matéria a ser mostrada no Painel deve ser a última votada
last_ordem_voto = RegistroVotacao.objects.filter(
ordem__sessao_plenaria=sessao).order_by('data_hora').last()
last_expediente_voto = RegistroVotacao.objects.filter(
expediente__sessao_plenaria=sessao).order_by('data_hora').last()
last_ordem_leitura = RegistroLeitura.objects.filter(
ordem__sessao_plenaria=sessao).order_by('data_hora').last()
last_expediente_leitura = RegistroLeitura.objects.filter(
expediente__sessao_plenaria=sessao).order_by('data_hora').last()
# Obtém última matéria que foi votada, através do timestamp mais recente
ordem_expediente = None
ultimo_timestamp = None
if last_ordem_voto:
ordem_expediente = last_ordem_voto.ordem
ultimo_timestamp = last_ordem_voto.data_hora
if (last_expediente_voto and ultimo_timestamp and last_expediente_voto.data_hora > ultimo_timestamp) or \
(not ultimo_timestamp and last_expediente_voto):
ordem_expediente = last_expediente_voto.expediente
ultimo_timestamp = last_expediente_voto.data_hora
if (last_ordem_leitura and ultimo_timestamp and last_ordem_leitura.data_hora > ultimo_timestamp) or \
(not ultimo_timestamp and last_ordem_leitura):
ordem_expediente = last_ordem_leitura.ordem
ultimo_timestamp = last_ordem_leitura.data_hora
if (last_expediente_leitura and ultimo_timestamp and last_expediente_leitura.data_hora > ultimo_timestamp) or \
(not ultimo_timestamp and last_expediente_leitura):
ordem_expediente = last_expediente_leitura.expediente
ultimo_timestamp = last_expediente_leitura.data_hora
if ordem_expediente:
return get_votos(
get_presentes(pk, response, ordem_expediente),
ordem_expediente, efetivo_mostrar_voto)
# Retorna que não há nenhuma matéria já votada ou aberta
response.update({
'msg_painel': str(_('Nenhuma matéria disponivel para votação.'))})
return get_presentes(pk, response, None)
def broadcast_dados_painel(request, sessao_id):
"""
Avisa o grupo WebSocket correspondente (sapl/painel/consumers.py::
PainelConsumer) de que algo mudou — chamado pelas views que alteram
voto/matéria/registro, sempre depois da escrita já ter sido commitada
no banco. `request` não é mais usado aqui (mantido só para não obrigar
a mudar todo call site); build_dados_painel() é chamado depois, uma vez
por conexão, não aqui.
Não carrega mais o payload: manda um sinal leve ("algo mudou nesta
sessão") e cada conexão reconsulta build_dados_painel() por conta
própria (PainelConsumer.painel_refresh), com o mostrar_voto certo para
o seu papel (força unmask para operador/Mesa, nunca para o telão
público/tablets — ver o docstring de build_dados_painel()). Um único
payload comum não daria pra fazer isso sem vazar o voto real da Mesa
para a conexão do telão público (legível via DevTools mesmo sem
renderizar), ou sem deixar a Mesa sem visibilidade do voto real.
Best-effort: um Redis fora do ar não pode derrubar a ação que disparou
o broadcast (registrar voto, abrir/fechar matéria). Falha aqui só fica
no log — não há mais fallback de polling (removido nesta branch); quem
está sem WebSocket fica sem atualização em tempo real até reconectar
ou recarregar a tela.
"""
from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer
try:
layer = get_channel_layer()
if layer is None:
return
async_to_sync(layer.group_send)(
'sessao_{}'.format(sessao_id),
{'type': 'painel.refresh'})
except Exception:
logging.getLogger(__name__).exception(
'Falha ao fazer broadcast do painel para sessao_id={}.'.format(sessao_id))
# --- Vue/Pinia (v2) painel + votação nominal operator screens ---------------
#
# `controller_id` here is the sessao_plenaria id (see websocket_view's
# docstring) — kept as the parameter/URL-kwarg name to match the existing
# frontend (main.js/vue.config.js) call sites unchanged. Originally these
# four views were authored against a separate, incompatible Channels
# backend (controller_<id> groups, vote.update/stopwatch.update messages,
# a since-removed sapl.painel.consumers.get_dados_painel()); they're kept
# here with their validation/DB-write logic intact, but every broadcast now
# goes through broadcast_dados_painel() — the same full-snapshot rebroadcast
# every other painel/sessao view already uses — instead of a bespoke
# group_send. The stopwatch_controller view from that original branch is
# dropped entirely: cronometro_painel() above already does that job, keyed
# by the same discurso/aparte/ordem/consideracoes ids CronometroList.vue uses.
@never_cache
@user_passes_test(check_permission)
def painel_view(request, sessao_id):
## O controller da sessao WS é o ID da sessao
now = timezone.localtime(timezone.now())
utc_offset = now.utcoffset().total_seconds() / 60
context = {'head_title': str(_('Painel Plenário')),
'utc_offset': utc_offset,
'enable_live_ws': True,
'controller_id': sessao_id, # aka, sessao_plenaria_id
}
return render(request, "painel/painel_v2.html", context)
VALID_VOTE_VALUES = ["Sim", "Não", "Abstenção", "Não Votou"]
# Valores que contam como voto; "Não Votou" só é gravado no encerramento.
VOTOS_REAIS = ("Sim", "Não", "Abstenção")
class VoteError(Exception):
"""
Erro de validação ao registrar um voto via _cast_vote() — mensagem já
pronta pra virar resposta pro chamador (HTTP ou WebSocket), com o
status HTTP equivalente em `status` (ignorado pelo caminho WS).
"""
def __init__(self, message, status=400):
super().__init__(message)
self.message = message
self.status = status
def _cast_vote(user, controller_id, parlamentar_id, voto, ip=None):
"""
Valida e grava um voto nominal (OrdemDia ou ExpedienteMateria aberta).
Compartilhado por vote_controller (POST HTTP, mantido por
compatibilidade) e PainelConsumer.receive_json (type: "vote", o
caminho que o app votacao usa agora) — mesma validação, mesma escrita;
só quem chama broadcast_dados_painel()/group_send depois é diferente
(request síncrono vs. group_send assíncrono direto do consumer).
"""
logger = logging.getLogger(__name__)
if not parlamentar_id:
raise VoteError("parlamentar_id is required")
try:
parlamentar_id = int(parlamentar_id)
except (ValueError, TypeError):
raise VoteError("parlamentar_id must be an integer")
if voto not in VALID_VOTE_VALUES:
raise VoteError(
f"Invalid vote value: {voto}. Must be one of: {VALID_VOTE_VALUES}")
try:
SessaoPlenaria.objects.get(id=controller_id)
except SessaoPlenaria.DoesNotExist:
raise VoteError("Session not found", status=404)
ordem_dia = get_materia_aberta(controller_id)
expediente = get_materia_expediente_aberta(controller_id)
materia_aberta = ordem_dia or expediente
if not materia_aberta:
raise VoteError("No open materia for voting")
if materia_aberta.tipo_votacao != VOTACAO_NOMINAL:
raise VoteError("Materia is not nominal voting type")
try:
parlamentar = Parlamentar.objects.get(id=parlamentar_id)
except Parlamentar.DoesNotExist:
raise VoteError(f"Parlamentar {parlamentar_id} not found", status=404)
with transaction.atomic():
fase_sessao = _trava_materia_para_voto(materia_aberta, parlamentar)
voto_obj = VotoParlamentar.objects.select_for_update().filter(
parlamentar=parlamentar, **fase_sessao).first()
# O voto registrado pelo próprio parlamentar (tablet) prevalece: a
# Mesa só lança ou corrige votos que ela mesma registrou.
if voto_obj and voto_obj.votado_pelo_parlamentar:
raise VoteError(
f"O voto de {parlamentar.nome_parlamentar} já foi registrado "
f"pelo próprio parlamentar e não pode ser alterado pela Mesa.",
status=409)
created = voto_obj is None
if voto == 'Não Votou':
# Desfaz o lançamento da Mesa; a linha 'Não Votou' só é
# gravada no encerramento, para os presentes sem voto.
if voto_obj:
voto_obj.delete()
else:
if created:
voto_obj = VotoParlamentar(parlamentar=parlamentar, **fase_sessao)
voto_obj.voto = voto
voto_obj.user = user
voto_obj.ip = ip
voto_obj.votado_pelo_parlamentar = False
voto_obj.save()
logger.info(
f"Vote {'created' if created else 'updated'}: "
f"parlamentar={parlamentar_id}, voto={voto}, sessao={controller_id}")
return {"parlamentar_id": parlamentar_id, "voto": voto, "created": created}
@user_passes_test(check_permission)
def vote_controller(request, controller_id):
"""
HTTP endpoint to cast/update a vote and broadcast the updated painel
snapshot to all connected WebSocket clients.
POST /v2/painel/controller/<sessao_id>/vote
Body (form-encoded or JSON):
parlamentar_id: int
voto: str ("Sim", "Não", "Abstenção", "Não Votou")
Mantido por compatibilidade; o app votacao (VotacaoNominal.vue) manda
o voto pelo WebSocket já aberto (PainelConsumer, type: "vote") em vez
de chamar isto — ver castVote() em frontend/src/__apps/votacao/main.js.
"""
if request.method != 'POST':
return JsonResponse({"type": "error", "message": "Only POST allowed"}, status=405)
# Parse body: support both form-encoded and JSON
if request.content_type and 'json' in request.content_type:
try:
body = json.loads(request.body)
except json.JSONDecodeError:
return JsonResponse({"type": "error", "message": "Invalid JSON"}, status=400)
parlamentar_id = body.get("parlamentar_id")
voto = body.get("voto")
else:
parlamentar_id = request.POST.get("parlamentar_id")
voto = request.POST.get("voto")
try:
result = _cast_vote(
request.user, controller_id, parlamentar_id, voto,
ip=get_client_ip(request))
except VoteError as e:
return JsonResponse({"type": "error", "message": e.message}, status=e.status)
broadcast_dados_painel(request, controller_id)
return JsonResponse({"ok": True, **result})
def _toggle_registro(user, controller_id, aberto):
"""
Bloqueia/reabre o registro de novos votos numa votação nominal aberta
— equivalente a bloquear-registro-votacao/reabrir-votacao de
VotacaoNominalAbstract.post() (nominal.html/VotacaoNominalView, tela
legada), agora também disponível para a tela v2 (Vue)/WS via
PainelConsumer.receive_json (type: "registro_toggle"). Não mexe em
nenhum voto já registrado — só na flag que trava votos novos, mesma
semântica de lá: aberto=True bloqueia (nome contraintuitivo herdado do
campo `registro_aberto`, mantido igual para não confundir quem já
conhece o comportamento legado).
"""
ordem_dia = get_materia_aberta(controller_id)
expediente = get_materia_expediente_aberta(controller_id)
materia_aberta = ordem_dia or expediente
if not materia_aberta:
raise VoteError("No open materia for voting")
if materia_aberta.tipo_votacao != VOTACAO_NOMINAL:
raise VoteError("Materia is not nominal voting type")
# update() só toca registro_aberto: um save() completo regravaria
# resultado/votacao_aberta lidos antes de um encerramento concorrente.
type(materia_aberta).objects.filter(pk=materia_aberta.pk).update(
registro_aberto=aberto)
logging.getLogger(__name__).info(
f"registro_aberto={aberto} para materia id={materia_aberta.pk} "
f"(sessao={controller_id}, user={user.username})")
return {"registro_aberto": aberto}
@user_passes_test(check_permission)
def votos_status(request, controller_id):
"""
Estado real (não mascarado por mostrar_voto) dos votos da matéria em
votação nominal aberta na sessão — usado pela tela de operação
(VotacaoVotos.vue) para saber quem já votou (ex.: via tablet) sem
correr o risco de sobrescrever um voto, sem depender do broadcast
público do painel (build_dados_painel()/get_votos()), que mascara o
voto individual quando app_config.mostrar_voto é False (ver
sapl.sessao.views.VotacaoNominalAbstract._status_json, que resolve o
mesmo problema para a tela legada — mas indexado por oid/mid, que a
tela v2 não tem na URL; aqui o mesmo cálculo é indexado por sessao_id).
"""
ordem_dia = get_materia_aberta(controller_id)
expediente = get_materia_expediente_aberta(controller_id)
materia_aberta = ordem_dia or expediente
if not materia_aberta:
return JsonResponse({
"votacao_aberta": False, "registro_aberto": False,
"ja_registrada": False, "votos": {},
})
lookup = {'ordem': materia_aberta} if ordem_dia else {'expediente': materia_aberta}
votos = VotoParlamentar.objects.filter(**lookup).values_list('parlamentar_id', 'voto')
return JsonResponse({
"votacao_aberta": materia_aberta.votacao_aberta,
"registro_aberto": materia_aberta.registro_aberto,
"ja_registrada": RegistroVotacao.objects.filter(**lookup).exists(),
"votos": {str(pid): voto for pid, voto in votos},
})
@user_passes_test(check_permission)
def cancel_voting(request, controller_id):
"""
HTTP endpoint to cancel the current open voting and discard all votes.
POST /v2/painel/controller/<sessao_id>/cancel
"""
from sapl.sessao.views import fechar_votacao_materia
logger = logging.getLogger(__name__)
if request.method != 'POST':
return JsonResponse({"type": "error", "message": "Only POST allowed"}, status=405)
# Find the open materia
ordem_dia = get_materia_aberta(controller_id)
expediente = get_materia_expediente_aberta(controller_id)
materia_aberta = ordem_dia or expediente
if not materia_aberta:
return JsonResponse({"type": "error", "message": "No open materia for voting"}, status=400)
# Build redirect URL before closing (materia_aberta fields change after close)
materia_id = materia_aberta.materia_id
if ordem_dia:
redirect_url = reverse('sapl.sessao:ordemdia_list',
kwargs={'pk': controller_id}) + f'#id{materia_id}'
else:
redirect_url = reverse('sapl.sessao:expedientemateria_list',
kwargs={'pk': controller_id}) + f'#id{materia_id}'
# Cancel: delete votes + RegistroVotacao, close materia
fechar_votacao_materia(materia_aberta)
logger.info(f"Voting cancelled for sessao={controller_id}, materia={materia_aberta.id}")
broadcast_dados_painel(request, controller_id)
return JsonResponse({"ok": True, "message": "Votação cancelada com sucesso.", "redirect_url": redirect_url})
@user_passes_test(check_permission)
def close_voting(request, controller_id):
"""
HTTP endpoint to close the current voting and save the result.
POST /v2/painel/controller/<sessao_id>/close
Body (JSON):
resultado_id: int (TipoResultadoVotacao id)
observacoes: str (optional)
"""
from sapl.sessao.models import TipoResultadoVotacao
logger = logging.getLogger(__name__)
if request.method != 'POST':
return JsonResponse({"type": "error", "message": "Only POST allowed"}, status=405)
# Parse body
if request.content_type and 'json' in request.content_type:
try:
body = json.loads(request.body)
except json.JSONDecodeError:
return JsonResponse({"type": "error", "message": "Invalid JSON"}, status=400)
else:
body = request.POST
resultado_id = body.get("resultado_id")
observacoes = body.get("observacoes", "")
if not resultado_id:
return JsonResponse({
"type": "error",
"message": "Não é possível finalizar a votação sem nenhum resultado da votação"
}, status=400)
# Validate resultado
try:
tipo_resultado = TipoResultadoVotacao.objects.get(id=resultado_id)
except TipoResultadoVotacao.DoesNotExist:
return JsonResponse({"type": "error", "message": "Tipo de resultado não encontrado"}, status=404)
# Find the open materia
ordem_dia = get_materia_aberta(controller_id)
expediente = get_materia_expediente_aberta(controller_id)
materia_aberta = ordem_dia or expediente
if not materia_aberta:
return JsonResponse({"type": "error", "message": "No open materia for voting"}, status=400)
with transaction.atomic():
# Trava a matéria: votos do tablet/da Mesa esperam o encerramento
# terminar, e um segundo "Encerrar" (duplo clique) vê a votação já
# registrada.
materia = type(materia_aberta).objects.select_for_update().get(
pk=materia_aberta.pk)
if ordem_dia:
fase_sessao = {'ordem': materia}
presenca_model = PresencaOrdemDia
else:
fase_sessao = {'expediente': materia}
presenca_model = SessaoPlenariaPresenca
if (not materia.votacao_aberta or
RegistroVotacao.objects.filter(**fase_sessao).exists()):
return JsonResponse({
"type": "error",
"message": "A votação já foi encerrada por outra operação."
}, status=409)
# Só contam os presentes: quem votou e teve a presença removida
# antes do encerramento fica de fora (e o voto é apagado abaixo).
presentes = presenca_model.objects.filter(
sessao_plenaria_id=materia.sessao_plenaria_id)
votos = VotoParlamentar.objects.filter(
**fase_sessao,
parlamentar_id__in=presentes.values('parlamentar_id'))
votos_sim = votos.filter(voto='Sim').count()
votos_nao = votos.filter(voto='Não').count()
abstencoes = votos.filter(voto='Abstenção').count()
if votos_sim + votos_nao + abstencoes == 0:
return JsonResponse({
"type": "error",
"message": "Não é possível finalizar a votação sem nenhum voto"
}, status=400)
registro = RegistroVotacao(
numero_votos_sim=votos_sim,
numero_votos_nao=votos_nao,
numero_abstencoes=abstencoes,
observacao=observacoes,
user=request.user,
ip=get_client_ip(request),
materia=materia.materia,
tipo_resultado_votacao=tipo_resultado,
**fase_sessao)
registro.save()
# user/ip de cada voto continuam sendo de quem votou.
votos.update(votacao=registro)
VotoParlamentar.objects.bulk_create([
VotoParlamentar(
parlamentar_id=presenca.parlamentar_id,
voto='Não Votou',
votacao=registro,
user=request.user,
ip=get_client_ip(request),
**fase_sessao)
for presenca in presentes.exclude(
parlamentar_id__in=votos.values('parlamentar_id'))])
materia.resultado = tipo_resultado.nome
materia.votacao_aberta = False
materia.registro_aberto = False
materia.save()
VotoParlamentar.objects.filter(
**fase_sessao, votacao__isnull=True).delete()
# Build redirect URL
materia_id = materia.materia_id
if ordem_dia:
redirect_url = reverse('sapl.sessao:ordemdia_list',
kwargs={'pk': controller_id}) + f'#id{materia_id}'
else:
redirect_url = reverse('sapl.sessao:expedientemateria_list',
kwargs={'pk': controller_id}) + f'#id{materia_id}'
logger.info(
f"Voting closed for sessao={controller_id}: "
f"sim={votos_sim}, nao={votos_nao}, abstencoes={abstencoes}, "
f"resultado={tipo_resultado.nome}"
)
broadcast_dados_painel(request, controller_id)
return JsonResponse({"ok": True, "message": "Votação finalizada com sucesso.", "redirect_url": redirect_url})