mirror of https://github.com/interlegis/sapl.git
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
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})
|
|
|