Browse Source

fix: corrige sincronização e exibição dos cronômetros no painel

feat/painel-votacao-v2
RogerKoala 6 days ago
committed by Edward Oliveira
parent
commit
ad00d7a6ab
  1. 1
      frontend/src/__apps/painel/store/painelStore.js
  2. 63
      frontend/src/__apps/painel/ws/normalizePainel.js
  3. 81
      frontend/src/components/painel/Cronometro.vue
  4. 151
      frontend/src/components/painel/CronometroList.vue
  5. 2
      sapl/painel/tests/test_broadcasts.py
  6. 136
      sapl/painel/views.py
  7. 13
      sapl/sessao/views.py
  8. 447
      sapl/templates/sessao/painel.html

1
frontend/src/__apps/painel/store/painelStore.js

@ -51,6 +51,7 @@ export const usePainelStore = defineStore('painel', {
this.painel_aberto = next.painel_aberto this.painel_aberto = next.painel_aberto
this.mostrar_voto = next.mostrar_voto this.mostrar_voto = next.mostrar_voto
this.message = next.message this.message = next.message
this.cronometro_ativo = next.cronometro_ativo
// objeto aninhado: patch (preserva reatividade dos campos) // objeto aninhado: patch (preserva reatividade dos campos)
Object.assign(this.sessao, next.sessao) Object.assign(this.sessao, next.sessao)

63
frontend/src/__apps/painel/ws/normalizePainel.js

@ -23,10 +23,11 @@ export const DEFAULT_STATE = Object.freeze({
brasao: '' brasao: ''
}, },
cronometro_discurso: '', cronometro_discurso: null,
cronometro_aparte: '', cronometro_aparte: null,
cronometro_ordem: '', cronometro_ordem: null,
cronometro_consideracoes: '', cronometro_consideracoes: null,
cronometro_ativo: 'discurso',
// Só significa algo enquanto há matéria nominal aberta pra registro — // Só significa algo enquanto há matéria nominal aberta pra registro —
// ver PainelConsumer (type: "registro_toggle") / VotacaoNominal.vue. // ver PainelConsumer (type: "registro_toggle") / VotacaoNominal.vue.
@ -58,6 +59,46 @@ function asString (v, fallback) {
return v != null ? v : fallback return v != null ? v : fallback
} }
/**
* Normaliza o valor do cronômetro recebido do backend.
*
* O backend novo envia um dict:
* { action, duration, start_ts?, remaining_at_stop? }
*
* O backend antigo (cache legado) pode enviar uma string ('start'|'stop'|'reset').
* O valor null/undefined significa "sem estado" (nunca foi configurado).
*
* Retorna sempre um objeto normalizado ou null.
*/
function normalizeCronometro (v) {
if (v == null) return null
// Formato legado: string simples
if (typeof v === 'string') {
return {
action: v,
duration: 300,
start_ts: null,
remaining_at_start: 300,
remaining_at_stop: null,
updated_at: null,
}
}
if (typeof v === 'object') {
const duration = v.duration != null ? v.duration : 300
const remaining_at_start = v.remaining_at_start != null ? v.remaining_at_start : duration
const remaining_at_stop = v.remaining_at_stop != null ? v.remaining_at_stop : null
return {
action: v.action || '',
duration: duration,
start_ts: v.start_ts != null ? v.start_ts : null,
remaining_at_start: remaining_at_start,
remaining_at_stop: remaining_at_stop,
updated_at: v.updated_at != null ? v.updated_at : null,
}
}
return null
}
export function normalizePainelData (raw) { export function normalizePainelData (raw) {
const d = asObject(raw) const d = asObject(raw)
@ -90,7 +131,7 @@ export function normalizePainelData (raw) {
// contam como "iniciada" — sessões legadas ficaram com None, mesma // contam como "iniciada" — sessões legadas ficaram com None, mesma
// convenção de restringe_sessoes_visiveis() no backend). // convenção de restringe_sessoes_visiveis() no backend).
sessao_aberta: !!d.sessao_iniciada && !d.sessao_finalizada, sessao_aberta: !!d.sessao_iniciada && !d.sessao_finalizada,
painel_aberto: d.status_painel != null ? !!d.status_painel : DEFAULT_STATE.painel_aberto, painel_aberto: d.status_painel != null ? !!d.status_painel : DEFAULT_STATE.painel_aberto,
mostrar_voto: d.mostrar_voto != null ? !!d.mostrar_voto : DEFAULT_STATE.mostrar_voto, mostrar_voto: d.mostrar_voto != null ? !!d.mostrar_voto : DEFAULT_STATE.mostrar_voto,
message: asString(d.msg_painel, DEFAULT_STATE.message), message: asString(d.msg_painel, DEFAULT_STATE.message),
@ -103,10 +144,14 @@ export function normalizePainelData (raw) {
brasao: asString(d.brasao, DEFAULT_STATE.sessao.brasao) brasao: asString(d.brasao, DEFAULT_STATE.sessao.brasao)
}, },
cronometro_discurso: asString(d.cronometro_discurso, DEFAULT_STATE.cronometro_discurso), // Cronômetros: agora objetos {action, duration, start_ts?, remaining_at_stop?}
cronometro_aparte: asString(d.cronometro_aparte, DEFAULT_STATE.cronometro_aparte), // ou null quando nunca configurados. O store repassa para CronometroList.vue
cronometro_ordem: asString(d.cronometro_ordem, DEFAULT_STATE.cronometro_ordem), // que aplica a ação e configura o tempo inicial de cada Cronometro.vue.
cronometro_consideracoes: asString(d.cronometro_consideracoes, DEFAULT_STATE.cronometro_consideracoes), cronometro_discurso: normalizeCronometro(d.cronometro_discurso),
cronometro_aparte: normalizeCronometro(d.cronometro_aparte),
cronometro_ordem: normalizeCronometro(d.cronometro_ordem),
cronometro_consideracoes: normalizeCronometro(d.cronometro_consideracoes),
cronometro_ativo: asString(d.cronometro_ativo, DEFAULT_STATE.cronometro_ativo),
registro_aberto: d.registro_aberto != null ? !!d.registro_aberto : DEFAULT_STATE.registro_aberto, registro_aberto: d.registro_aberto != null ? !!d.registro_aberto : DEFAULT_STATE.registro_aberto,

81
frontend/src/components/painel/Cronometro.vue

@ -40,13 +40,71 @@ export default {
fontSize = parseFloat(fontSize); fontSize = parseFloat(fontSize);
el.style.fontSize = (fontSize + value) + 'px'; el.style.fontSize = (fontSize + value) + 'px';
}, },
// start/stop/reset são idempotentes de propósito: o valor cacheado no
// servidor (sapl.painel.views.cronometro_painel) é reenviado em todo /**
// broadcast do painel, não só quando o cronômetro muda — chamar * Aplica o estado completo recebido do backend.
// start() de novo com o cronômetro já rodando não deve reiniciar a *
// contagem, e stop()/reset() repetidos não devem quebrar nada. * @param {Object} state - { action, duration, start_ts?, remaining_at_stop? }
start() { *
if (this.isRunning) return; * Lógica:
* - reset / sem estado → para, define time = duration
* - stop → para, define time = remaining_at_stop (tempo no momento do stop)
* - start → calcula tempo restante a partir de start_ts + duration - agora
* e inicia a contagem (idempotente: se já estiver rodando com
* o mesmo start_ts, não reinicia)
*/
applyState(state) {
if (!state || !state.action) {
// Sem estado: apenas configura a duração e para
this.stop();
this.time = this.initialTime;
return;
}
const { action, duration, start_ts, remaining_at_start, remaining_at_stop } = state;
// Atualiza a duração configurada
if (duration != null) {
this.initialTime = duration;
}
if (action === 'reset') {
this.stop();
this.time = this.initialTime;
} else if (action === 'stop') {
this.stop();
// Mostra o tempo exato em que o operador parou o cronômetro
if (remaining_at_stop != null) {
this.time = Math.max(0, Math.round(remaining_at_stop));
}
} else if (action === 'start') {
// Calcula o tempo restante a partir de remaining_at_start e start_ts
const nowSec = Date.now() / 1000;
const elapsed = start_ts != null ? Math.max(0, nowSec - start_ts) : 0;
const baseRem = remaining_at_start != null
? remaining_at_start
: (remaining_at_stop != null
? remaining_at_stop
: (this.time > 0 && this.time < this.initialTime ? this.time : this.initialTime));
const remaining = Math.max(0, baseRem - elapsed);
// Idempotente: se já estiver rodando e a contagem estiver em sincronia
// (diferença <= 2s), mantém o intervalo nativo para não ter saltos
if (!this.isRunning || Math.abs(this.time - remaining) > 2) {
this.stop();
this.time = Math.round(remaining);
if (remaining > 0) {
this._startInterval();
}
}
}
this.$emit('state-changed', { id: this.id, isRunning: this.isRunning });
},
_startInterval() {
this.isRunning = true; this.isRunning = true;
this.intervalId = setInterval(() => { this.intervalId = setInterval(() => {
if (this.time > 0) { if (this.time > 0) {
@ -58,13 +116,22 @@ export default {
this.isRunning = false; this.isRunning = false;
clearInterval(this.intervalId); clearInterval(this.intervalId);
this.playSound(); this.playSound();
this.$emit('state-changed', { id: this.id, isRunning: false });
} }
}, 1000); }, 1000);
}, },
// start/stop/reset legados mantidos para compatibilidade com qualquer
// chamador externo, mas internamente agora delegam para applyState().
start() {
if (this.isRunning) return;
this._startInterval();
},
stop() { stop() {
this.isRunning = false; this.isRunning = false;
clearInterval(this.intervalId); clearInterval(this.intervalId);
this.intervalId = null;
}, },
reset() { reset() {

151
frontend/src/components/painel/CronometroList.vue

@ -1,4 +1,9 @@
<template> <template>
<!--
v-if="canRender": cronômetros só aparecem quando o painel estiver aberto.
Enquanto o painel está fechado, os componentes não são montados e portanto
nenhum timer roda em background.
-->
<div class="painel d-flex flex-column" v-if="canRender"> <div class="painel d-flex flex-column" v-if="canRender">
<div id="box_cronometros" class="w-100"> <div id="box_cronometros" class="w-100">
<h2 class="text-center text-subtitle mb-3">Cronômetro</h2> <h2 class="text-center text-subtitle mb-3">Cronômetro</h2>
@ -12,6 +17,7 @@
:visible="visibleCronometro === 'discurso'" :visible="visibleCronometro === 'discurso'"
color-class="" color-class=""
@child-mounted="handleChildMounted" @child-mounted="handleChildMounted"
@state-changed="handleStateChanged"
/> />
<Cronometro <Cronometro
ref="childRef_1" ref="childRef_1"
@ -20,6 +26,7 @@
:visible="visibleCronometro === 'aparte'" :visible="visibleCronometro === 'aparte'"
color-class="text-warning" color-class="text-warning"
@child-mounted="handleChildMounted" @child-mounted="handleChildMounted"
@state-changed="handleStateChanged"
/> />
<Cronometro <Cronometro
ref="childRef_2" ref="childRef_2"
@ -28,6 +35,7 @@
:visible="visibleCronometro === 'ordem'" :visible="visibleCronometro === 'ordem'"
color-class="text-info" color-class="text-info"
@child-mounted="handleChildMounted" @child-mounted="handleChildMounted"
@state-changed="handleStateChanged"
/> />
<Cronometro <Cronometro
ref="childRef_3" ref="childRef_3"
@ -36,6 +44,7 @@
:visible="visibleCronometro === 'consideracoes'" :visible="visibleCronometro === 'consideracoes'"
color-class="" color-class=""
@child-mounted="handleChildMounted" @child-mounted="handleChildMounted"
@state-changed="handleStateChanged"
/> />
</tbody> </tbody>
</table> </table>
@ -49,6 +58,14 @@
import { usePainelStore } from '@/__apps/painel/store/painelStore'; import { usePainelStore } from '@/__apps/painel/store/painelStore';
import Cronometro from './Cronometro.vue'; import Cronometro from './Cronometro.vue';
// Mapa: key do cronômetro → ref do componente filho
const CRONOMETRO_REFS = [
{ key: 'discurso', ref: 'childRef_0' },
{ key: 'aparte', ref: 'childRef_1' },
{ key: 'ordem', ref: 'childRef_2' },
{ key: 'consideracoes', ref: 'childRef_3' },
];
export default { export default {
name: 'CronometroList', name: 'CronometroList',
components: { components: {
@ -56,68 +73,137 @@
}, },
data() { data() {
return { return {
// Qual cronômetro está visível no momento.
// Regra de prioridade ao rodar: ordem > aparte > consideracoes > discurso.
// Quando nenhum está rodando: mantém o último visível (para mostrar
// o tempo "parado" do cronômetro que acabou de ser parado).
visibleCronometro: 'discurso', visibleCronometro: 'discurso',
childrenMounted: 0,
allChildrenMounted: false,
} }
}, },
mounted() { mounted() {
console.log('CronometroList mounted'); console.log('CronometroList mounted');
// Sincroniza o estado inicial a partir do snapshot já presente // Os filhos podem não ter montado ainda — aplica via nextTick após
// no store (não dá pra confiar num watcher `immediate` aqui: // todos os filhos emitirem 'child-mounted' (ver handleChildMounted).
// ele dispararia em `created`, antes dos refs dos filhos
// existirem).
this.applyCronometroAction('childRef_0', this.cronometro_discurso);
this.applyCronometroAction('childRef_1', this.cronometro_aparte);
this.applyCronometroAction('childRef_2', this.cronometro_ordem);
this.applyCronometroAction('childRef_3', this.cronometro_consideracoes);
}, },
computed: { computed: {
...mapState(usePainelStore, [ ...mapState(usePainelStore, [
"canRender", "canRender",
"cronometro_ativo",
"cronometro_discurso", "cronometro_aparte", "cronometro_discurso", "cronometro_aparte",
"cronometro_ordem", "cronometro_consideracoes" "cronometro_ordem", "cronometro_consideracoes"
]) ])
}, },
watch: { watch: {
// Só dispara em mudança de valor — o broadcast do painel chega a // Quando o painel abre (canRender muda para true), os filhos
// cada voto/matéria aberta, não só em ações de cronômetro; um // acabam de montar — aplica o estado inicial de todos.
// toggle-em-toda-mensagem reiniciaria o cronômetro à toa. canRender(newVal) {
cronometro_discurso(newVal) { this.applyCronometroAction('childRef_0', newVal); }, if (newVal) {
cronometro_aparte(newVal) { this.applyCronometroAction('childRef_1', newVal); }, this.$nextTick(() => this.applyAllStates());
cronometro_ordem(newVal) { this.applyCronometroAction('childRef_2', newVal); }, }
cronometro_consideracoes(newVal) { this.applyCronometroAction('childRef_3', newVal); }, },
cronometro_ativo(newVal) {
if (newVal) {
this.updateVisibility();
}
},
// Watchers profundos para cada cronômetro: disparam quando o objeto
// mudar (nova action, duration, etc.) mas NÃO quando o broadcast
// chega com o mesmo valor (deep watch compara referência, mas o
// store substitui o objeto inteiro — a comparação de ação é feita
// dentro de applyState do filho para ser idempotente).
cronometro_discurso(newVal) {
this.applyOneState('childRef_0', newVal);
},
cronometro_aparte(newVal) {
this.applyOneState('childRef_1', newVal);
},
cronometro_ordem(newVal) {
this.applyOneState('childRef_2', newVal);
},
cronometro_consideracoes(newVal) {
this.applyOneState('childRef_3', newVal);
},
}, },
methods: { methods: {
applyCronometroAction(ref, action) { /**
const comp = this.$refs[ref]; * Aplica o estado de todos os cronômetros de uma vez.
* Usado na montagem e quando o painel é aberto.
*/
applyAllStates() {
this.applyOneState('childRef_0', this.cronometro_discurso);
this.applyOneState('childRef_1', this.cronometro_aparte);
this.applyOneState('childRef_2', this.cronometro_ordem);
this.applyOneState('childRef_3', this.cronometro_consideracoes);
this.updateVisibility();
},
/**
* Aplica o estado de um único cronômetro chamando applyState() no filho.
* @param {string} refName - nome do ref ('childRef_0' etc.)
* @param {Object|null} state - { action, duration, start_ts?, remaining_at_stop? }
*/
applyOneState(refName, state) {
const comp = this.$refs[refName];
if (!comp) return; if (!comp) return;
if (action === 'start') { comp.applyState(state);
comp.start();
} else if (action === 'stop') {
comp.stop();
} else if (action === 'reset') {
comp.reset();
}
this.updateVisibility(); this.updateVisibility();
}, },
/**
* Chamado quando um filho monta. Quando todos os 4 filhos
* montaram, aplica o estado inicial.
*/
handleChildMounted() { handleChildMounted() {
console.log('Cronometro child mounted'); this.childrenMounted++;
console.log(`Cronometro child mounted (${this.childrenMounted}/4)`);
if (this.childrenMounted >= 4) {
this.allChildrenMounted = true;
this.applyAllStates();
}
},
/**
* Chamado quando um filho muda de estado (start/stop).
* Atualiza a visibilidade.
*/
handleStateChanged() {
this.updateVisibility();
}, },
/**
* Decide qual cronômetro mostrar.
*
* Prioridade ao rodar: ordem > aparte > consideracoes > discurso.
* Se nenhum estiver rodando, mantém o último visível para exibir
* o tempo no momento em que foi parado (comportamento igual ao discurso).
*/
updateVisibility() { updateVisibility() {
// Show priority: ordem > aparte > consideracoes > discurso // Prioridade 1: se algum cronômetro estiver rodando, mostra o que está rodando
const refs = [ const priority = [
{ key: 'ordem', ref: 'childRef_2' }, { key: 'ordem', ref: 'childRef_2' },
{ key: 'aparte', ref: 'childRef_1' }, { key: 'aparte', ref: 'childRef_1' },
{ key: 'consideracoes', ref: 'childRef_3' }, { key: 'consideracoes', ref: 'childRef_3' },
{ key: 'discurso', ref: 'childRef_0' }, { key: 'discurso', ref: 'childRef_0' },
]; ];
for (const item of refs) { for (const item of priority) {
const comp = this.$refs[item.ref]; const comp = this.$refs[item.ref];
if (comp && comp.isRunning) { if (comp && comp.isRunning) {
this.visibleCronometro = item.key; this.visibleCronometro = item.key;
return; return;
} }
} }
// Default to discurso if none running
// Prioridade 2: nenhum rodando. Mostra o último cronômetro operado pelo operador
if (this.cronometro_ativo) {
this.visibleCronometro = this.cronometro_ativo;
return;
}
// Fallback padrão
this.visibleCronometro = 'discurso'; this.visibleCronometro = 'discurso';
} }
}, },
@ -133,4 +219,3 @@
font-size: 1.1rem; font-size: 1.1rem;
} }
</style> </style>

2
sapl/painel/tests/test_broadcasts.py

@ -208,7 +208,7 @@ async def test_cronometro_dispara_broadcast_com_valor_atualizado():
mensagem = await communicator.receive_json_from() mensagem = await communicator.receive_json_from()
assert mensagem['type'] == 'data' assert mensagem['type'] == 'data'
assert mensagem['payload']['cronometro_discurso'] == 'start' assert mensagem['payload']['cronometro_discurso']['action'] == 'start'
await communicator.disconnect() await communicator.disconnect()
finally: finally:

136
sapl/painel/views.py

@ -1,6 +1,7 @@
import html import html
import json import json
import logging import logging
import time as _time
from django.contrib.auth.decorators import (login_required, permission_required, from django.contrib.auth.decorators import (login_required, permission_required,
user_passes_test) user_passes_test)
@ -347,6 +348,29 @@ def switch_painel(request):
sessao.painel_aberto = False sessao.painel_aberto = False
sessao.save() 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({}) return JsonResponse({})
@ -362,6 +386,18 @@ def verifica_painel(request):
CRONOMETRO_CACHE_KEY = 'cronometro:{}' CRONOMETRO_CACHE_KEY = 'cronometro:{}'
CRONOMETRO_CACHE_TIMEOUT = None # não expira sozinho — só quando outro action chega 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) @user_passes_test(check_permission)
@ -375,10 +411,90 @@ def cronometro_painel(request):
WebSocket sincronizarem o cronômetro deles quase na hora — sem isso, WebSocket sincronizarem o cronômetro deles quase na hora — sem isso,
dependeriam do próximo poll (até alguns segundos de atraso) para saber dependeriam do próximo poll (até alguns segundos de atraso) para saber
que o Presidente apertou start/stop/reset. 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'] tipo = request.GET['tipo']
action = request.GET['action'] action = request.GET['action']
cache.set(CRONOMETRO_CACHE_KEY.format(tipo), action, CRONOMETRO_CACHE_TIMEOUT) 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() sessao = SessaoPlenaria.objects.filter(painel_aberto=True).first()
if sessao: if sessao:
@ -388,7 +504,22 @@ def cronometro_painel(request):
def get_cronometro_status(request, name): def get_cronometro_status(request, name):
return cache.get(CRONOMETRO_CACHE_KEY.format(name)) or '' """
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): def get_materia_aberta(pk):
@ -612,6 +743,7 @@ def build_dados_painel(request, pk, force_mostrar_voto=False):
'cronometro_discurso': get_cronometro_status(request, 'discurso'), 'cronometro_discurso': get_cronometro_status(request, 'discurso'),
'cronometro_ordem': get_cronometro_status(request, 'ordem'), 'cronometro_ordem': get_cronometro_status(request, 'ordem'),
'cronometro_consideracoes': get_cronometro_status(request, 'consideracoes'), 'cronometro_consideracoes': get_cronometro_status(request, 'consideracoes'),
'cronometro_ativo': cache.get(CRONOMETRO_ULTIMO_ATIVO_KEY) or 'discurso',
'status_painel': sessao.painel_aberto, 'status_painel': sessao.painel_aberto,
'brasao': brasao, 'brasao': brasao,
'mostrar_voto': efetivo_mostrar_voto 'mostrar_voto': efetivo_mostrar_voto

13
sapl/sessao/views.py

@ -1633,6 +1633,16 @@ class PainelView(PermissionRequiredForAppCrudMixin, TemplateView):
sessao_pk = kwargs['pk'] sessao_pk = kwargs['pk']
sessao = SessaoPlenaria.objects.get(pk=sessao_pk) sessao = SessaoPlenaria.objects.get(pk=sessao_pk)
context = TemplateView.get_context_data(self, **kwargs) context = TemplateView.get_context_data(self, **kwargs)
from django.core.cache import cache
from sapl.painel.views import CRONOMETRO_CACHE_KEY
cronometros_status = {
'discurso': cache.get(CRONOMETRO_CACHE_KEY.format('discurso')),
'aparte': cache.get(CRONOMETRO_CACHE_KEY.format('aparte')),
'ordem': cache.get(CRONOMETRO_CACHE_KEY.format('ordem')),
'consideracoes': cache.get(CRONOMETRO_CACHE_KEY.format('consideracoes')),
}
context.update({ context.update({
'head_title': str(_('Painel Plenário')), 'head_title': str(_('Painel Plenário')),
'sessao_id': sessao_pk, 'sessao_id': sessao_pk,
@ -1641,7 +1651,8 @@ class PainelView(PermissionRequiredForAppCrudMixin, TemplateView):
'cronometro_discurso': cronometro_discurso, 'cronometro_discurso': cronometro_discurso,
'cronometro_aparte': cronometro_aparte, 'cronometro_aparte': cronometro_aparte,
'cronometro_ordem': cronometro_ordem, 'cronometro_ordem': cronometro_ordem,
'cronometro_consideracoes': cronometro_consideracoes}) 'cronometro_consideracoes': cronometro_consideracoes,
'cronometros_status_json': json.dumps(cronometros_status)})
tipo_sessao = sessao.tipo tipo_sessao = sessao.tipo
if tipo_sessao.nome == "Solene": if tipo_sessao.nome == "Solene":

447
sapl/templates/sessao/painel.html

@ -17,84 +17,89 @@
<div class="row"> <div class="row">
<div class="col-md-6"><a href="" onclick="window.open('{% url 'sapl.painel:painel_principal' pk %}','Comprovante','width=800, height=800, scrollbars=yes'); return false;" class="btn btn-primary btn-sm active">Iniciar painel completo</a></div> <div class="col-md-6"><a href="" onclick="window.open('{% url 'sapl.painel:painel_principal' pk %}','Comprovante','width=800, height=800, scrollbars=yes'); return false;" class="btn btn-primary btn-sm active">Iniciar painel completo</a></div>
<div class="col-md-3"><button onclick="switch_painel(true)" id="id_abrir_painel" class="btn btn-primary btn-sm active" style="display: none">Abrir Painel</button></div> <div class="col-md-3"><button onclick="switch_painel(true)" id="id_abrir_painel" class="btn btn-primary btn-sm active" style="display: {% if sessaoplenaria.painel_aberto %}none{% else %}inline-block{% endif %};">Abrir Painel</button></div>
<div class="col-md-3"><button onclick="switch_painel(false)" id="id_fechar_painel" class="btn btn-danger btn-sm active" style="display: none;">Fechar Painel</button></div> <div class="col-md-3"><button onclick="switch_painel(false)" id="id_fechar_painel" class="btn btn-danger btn-sm active" style="display: {% if sessaoplenaria.painel_aberto %}inline-block{% else %}none{% endif %};">Fechar Painel</button></div>
</div> </div>
<br /> <br />
<h1>Operação do Painel Eletrônico</h1> <h1>Operação do Painel Eletrônico</h1>
<h2><span id="relogio"></span></h2> <h2><span id="relogio"></span></h2>
<br /> <br />
<div class="row">
<div class="col-md-12 mb-2"><h3>Cronômetro do Discurso</h3></div>
</div>
<div class="row">
<div class="col-md-2"><input size="2" id="discurso" name="discurso" value="" readyonly="true" class="form-control"></div>
</div>
<br />
<div class="row">
<div class="col-md-6"><button type="button" id="discursoStart" class="btn btn-success">Iniciar</button></div>
<div class="col-md-6"><button type="button" id="discursoReset" class="btn btn-success">Reiniciar</button></div>
</div>
<br /><br >
<div class="row">
<div class="col-md-12 mb-2"><h3>Cronômetro do Aparte</h3></div>
</div>
<div class="row">
<div class="col-md-2"><input size="2" id="aparte" name="aparte" value="" readyonly="true" class="form-control"></div>
</div>
<br />
<div class="row">
<div class="col-md-6"><button type="button" id="aparteStart" class="btn btn-success">Iniciar</button></div>
<div class="col-md-6"><button type="button" id="aparteReset" class="btn btn-success" class="btn btn-success">Reiniciar</button></div>
</div>
<br /><br >
<div class="row">
<div class="col-md-12 mb-2"><h3>Cronômetro da Questão de Ordem </h3></div>
</div>
<div class="row">
<div class="col-md-2"><input size="2" id="ordem" name="ordem" value="" readyonly="true" class="form-control"></div>
</div>
<br />
<div class="row">
<div class="col-md-6"><button type="button" id="ordemStart" class="btn btn-success">Iniciar</button></div>
<div class="col-md-6"><button type="button" id="ordemReset" class="btn btn-success">Reiniciar</button></div>
</div>
<br/>
<br/>
<div class="row">
<div class="col-md-12 mb-2"><h3>Cronômetro de Considerações Finais</h3></div>
</div>
<div class="row">
<div class="col-md-4"><input size="2" id="consideracoes" name="consideracoes" value="" readyonly="true" class="form-control"></div>
</div>
<br />
<div class="row"> <div id="bloco_cronometros" style="display: {% if sessaoplenaria.painel_aberto %}block{% else %}none{% endif %};">
<div class="col-md-6"><button type="button" id="consideracoesStart" class="btn btn-success">Iniciar</button></div> <div class="row">
<div class="col-md-6"><button type="button" id="consideracoesReset" class="btn btn-success">Reiniciar</button></div> <div class="col-md-12 mb-2"><h3>Cronômetro do Discurso</h3></div>
</div> </div>
<br /><br >
<div class="row">
<div class="row"> <div class="col-md-2"><input size="2" id="discurso" name="discurso" value="" readyonly="true" class="form-control"></div>
<div class="col-md-6"><button type="button" id="sinalSonoro" class="btn btn-success" onclick="document.getElementById('audio').play();">Sinal Sonoro</button></div> </div>
<br />
<div class="row">
<div class="col-md-6"><button type="button" id="discursoStart" class="btn btn-success">Iniciar</button></div>
<div class="col-md-6"><button type="button" id="discursoReset" class="btn btn-success">Reiniciar</button></div>
</div>
<br /><br >
<div class="row">
<div class="col-md-12 mb-2"><h3>Cronômetro do Aparte</h3></div>
</div>
<div class="row">
<div class="col-md-2"><input size="2" id="aparte" name="aparte" value="" readyonly="true" class="form-control"></div>
</div>
<br />
<div class="row">
<div class="col-md-6"><button type="button" id="aparteStart" class="btn btn-success">Iniciar</button></div>
<div class="col-md-6"><button type="button" id="aparteReset" class="btn btn-success" class="btn btn-success">Reiniciar</button></div>
</div>
<br /><br >
<div class="row">
<div class="col-md-12 mb-2"><h3>Cronômetro da Questão de Ordem </h3></div>
</div>
<div class="row">
<div class="col-md-2"><input size="2" id="ordem" name="ordem" value="" readyonly="true" class="form-control"></div>
</div>
<br />
<div class="row">
<div class="col-md-6"><button type="button" id="ordemStart" class="btn btn-success">Iniciar</button></div>
<div class="col-md-6"><button type="button" id="ordemReset" class="btn btn-success">Reiniciar</button></div>
</div>
<br/>
<br/>
<div class="row">
<div class="col-md-12 mb-2"><h3>Cronômetro de Considerações Finais</h3></div>
</div>
<div class="row">
<div class="col-md-4"><input size="2" id="consideracoes" name="consideracoes" value="" readyonly="true" class="form-control"></div>
</div>
<br />
<div class="row">
<div class="col-md-6"><button type="button" id="consideracoesStart" class="btn btn-success">Iniciar</button></div>
<div class="col-md-6"><button type="button" id="consideracoesReset" class="btn btn-success">Reiniciar</button></div>
</div>
<br /><br >
<div class="row">
<div class="col-md-6"><button type="button" id="sinalSonoro" class="btn btn-success" onclick="document.getElementById('audio').play();">Sinal Sonoro</button></div>
</div>
</div> </div>
{% endblock detail_content %} {% endblock detail_content %}
{% block extra_js %} {% block extra_js %}
<script language="JavaScript"> <script language="JavaScript">
var cronometrosStatus = {{ cronometros_status_json|default:"{}"|safe }};
$(function() { $(function() {
function checkTime(i) { function checkTime(i) {
@ -117,220 +122,104 @@ $(function() {
startTime(); startTime();
var audioAlertFinish = document.getElementById("audio"); var audioAlertFinish = document.getElementById("audio");
$('#discurso').prop('disabled', false);
$('#aparte').prop('disabled', false);
$('#ordem').prop('disabled', false);
$('#consideracoes').prop('disabled', false);
$('#discurso').runner({
autostart: false,
countdown: true,
startAt: {{cronometro_discurso}} * 1000,
stopAt: 0,
milliseconds: false,
format: function(value) {
let h = Math.floor((value/1000) / 3600);
h = checkTime(h);
let m = Math.floor((value/1000) % 3600 / 60);
m = checkTime(m);
let s = Math.floor((value/1000) % 3600 % 60);
s = checkTime(s);
return h.toString() + ":" + m.toString() + ":" + s.toString();
}
}).on('runnerFinish', function(eventObject, info){
$.get('/painel/cronometro', { tipo: 'discurso', action: 'stop' } );
audioAlertFinish.play();
$('#discursoReset').show();
$('#discurso').runner('stop');
$('#discursoStart').text('Iniciar');
});
$('#discursoStart').click(function() {
if ($('#discursoStart').text() == 'Iniciar'){
$.get('/painel/cronometro', { tipo: 'discurso', action: 'start' } );
$('#discursoReset').hide();
$('#discurso').runner('start');
$('#discursoStart').text('Parar');
} else {
$.get('/painel/cronometro', { tipo: 'discurso', action: 'stop' } );
$('#discursoReset').show();
$('#discurso').runner('stop');
$('#discursoStart').text('Iniciar');
}
});
$('#discursoReset').click(function() {
$.get('/painel/cronometro', { tipo: 'discurso', action: 'reset' } );
$('#discurso').runner('stop'); function formatTime(value) {
$('#discurso').runner('reset'); let h = Math.floor((value/1000) / 3600);
}); h = checkTime(h);
let m = Math.floor((value/1000) % 3600 / 60);
$('#aparte').runner({ m = checkTime(m);
autostart: false, let s = Math.floor((value/1000) % 3600 % 60);
countdown: true, s = checkTime(s);
startAt: {{cronometro_aparte}} * 1000, return h.toString() + ":" + m.toString() + ":" + s.toString();
stopAt: 0, }
milliseconds: false,
format: function(value) {
let h = Math.floor((value/1000) / 3600);
h = checkTime(h);
let m = Math.floor((value/1000) % 3600 / 60);
m = checkTime(m);
let s = Math.floor((value/1000) % 3600 % 60);
s = checkTime(s);
return h.toString() + ":" + m.toString() + ":" + s.toString();
}
}).on('runnerFinish', function(eventObject, info){
$.get('/painel/cronometro', { tipo: 'aparte', action: 'stop' } );
audioAlertFinish.play();
$('#aparteReset').show();
$('#aparte').runner('stop');
$('#aparteStart').text('Iniciar');
});
$('#aparteStart').click(function(){
if ($('#aparteStart').text() == 'Iniciar') {
$.get('/painel/cronometro', { tipo: 'aparte', action: 'start' } );
$('#aparteReset').hide();
$('#aparte').runner('start');
$('#aparteStart').text('Parar');
} else {
$.get('/painel/cronometro', { tipo: 'aparte', action: 'stop' } );
$('#aparteReset').show();
$('#aparte').runner('stop');
$('#aparteStart').text('Iniciar');
}
});
$('#aparteReset').click(function() {
$.get('/painel/cronometro', { tipo: 'aparte', action: 'reset' } );
$('#aparte').runner('stop');
$('#aparte').runner('reset');
});
$('#ordem').runner({
autostart: false,
countdown: true,
startAt: {{cronometro_ordem}} * 1000,
stopAt: 0,
milliseconds: false,
format: function(value) {
let h = Math.floor((value/1000) / 3600);
h = checkTime(h);
let m = Math.floor((value/1000) % 3600 / 60);
m = checkTime(m);
let s = Math.floor((value/1000) % 3600 % 60);
s = checkTime(s);
return h.toString() + ":" + m.toString() + ":" + s.toString();
}
}).on('runnerFinish', function(eventObject, info){
$.get('/painel/cronometro', { tipo: 'ordem', action: 'stop' } );
audioAlertFinish.play();
$('#ordemReset').show();
$('#ordem').runner('stop');
$('#ordemStart').text('Iniciar');
});
$('#ordemStart').click(function() {
if ($('#ordemStart').text() == 'Iniciar') {
$.get('/painel/cronometro', { tipo: 'ordem', action: 'start' } );
$('#ordemReset').hide();
$('#ordem').runner('start');
$('#ordemStart').text('Parar');
} else {
$.get('/painel/cronometro', { tipo: 'ordem', action: 'stop' } );
$('#ordemReset').show();
$('#ordem').runner('stop');
$('#ordemStart').text('Iniciar');
}
});
$('#ordemReset').click(function() {
$.get('/painel/cronometro', { tipo: 'ordem', action: 'reset' } );
$('#ordem').runner('stop');
$('#ordem').runner('reset');
});
$('#consideracoes').runner({ function setupCronometro(tipo, startAtSecs, initialStatus) {
autostart: false, var startAtMs = startAtSecs * 1000;
countdown: true, var $el = $('#' + tipo);
startAt: {{cronometro_consideracoes}} * 1000, var $btnStart = $('#' + tipo + 'Start');
stopAt: 0, var $btnReset = $('#' + tipo + 'Reset');
milliseconds: false,
format: function(value) { $el.prop('disabled', false);
let h = Math.floor((value/1000) / 3600);
h = checkTime(h); var currentStartAt = startAtMs;
let m = Math.floor((value/1000) % 3600 / 60); var initialAction = initialStatus ? initialStatus.action : null;
m = checkTime(m); var remainingAtStop = initialStatus ? initialStatus.remaining_at_stop : null;
let s = Math.floor((value/1000) % 3600 % 60); var remainingAtStart = initialStatus ? initialStatus.remaining_at_start : null;
s = checkTime(s); var startTs = initialStatus ? initialStatus.start_ts : null;
return h.toString() + ":" + m.toString() + ":" + s.toString();
if (initialAction === 'stop' && remainingAtStop != null) {
currentStartAt = Math.max(0, Math.round(remainingAtStop)) * 1000;
} else if (initialAction === 'start' && startTs != null) {
var elapsed = (Date.now() / 1000) - startTs;
var baseRem = remainingAtStart != null ? remainingAtStart : startAtSecs;
currentStartAt = Math.max(0, Math.round(baseRem - elapsed)) * 1000;
} }
}).on('runnerFinish', function(eventObject, info){
$.get('/painel/cronometro', { tipo: 'consideracoes', action: 'stop' } );
audioAlertFinish.play();
$('#consideracoesReset').show();
$('#consideracoes').runner('stop');
$('#consideracoesStart').text('Iniciar');
});
$('#consideracoesStart').click(function(){
if ($('#consideracoesStart').text() == 'Iniciar') {
$.get('/painel/cronometro', { tipo: 'consideracoes', action: 'start' } );
$('#consideracoesReset').hide();
$('#consideracoes').runner('start');
$('#consideracoesStart').text('Parar');
$el.runner({
autostart: false,
countdown: true,
startAt: currentStartAt,
stopAt: 0,
milliseconds: false,
format: formatTime
}).on('runnerFinish', function(eventObject, info){
$.get('/painel/cronometro', { tipo: tipo, action: 'stop', remaining: 0 } );
if (audioAlertFinish) {
audioAlertFinish.play();
}
$btnReset.show();
$el.runner('stop');
$btnStart.text('Iniciar');
});
if (initialAction === 'start' && currentStartAt > 0) {
$el.runner('start');
$btnStart.text('Parar');
$btnReset.hide();
} else if (initialAction === 'stop') {
$btnStart.text('Resumir');
$btnReset.show();
} else { } else {
$btnStart.text('Iniciar');
$.get('/painel/cronometro', { tipo: 'consideracoes', action: 'stop' } ); $btnReset.show();
$('#consideracoesReset').show();
$('#consideracoes').runner('stop');
$('#consideracoesStart').text('Iniciar');
} }
});
$('#consideracoesReset').click(function() {
$.get('/painel/cronometro', { tipo: 'consideracoes', action: 'reset' } ); $btnStart.click(function() {
if ($btnStart.text() === 'Parar') {
$el.runner('stop');
var remaining = Math.max(0, Math.round($el.runner('info').time / 1000));
$.get('/painel/cronometro', { tipo: tipo, action: 'stop', remaining: remaining } );
$btnReset.show();
$btnStart.text('Resumir');
} else {
$el.runner('start');
var remaining = Math.max(0, Math.round($el.runner('info').time / 1000));
$.get('/painel/cronometro', { tipo: tipo, action: 'start', remaining: remaining } );
$btnReset.hide();
$btnStart.text('Parar');
}
});
$btnReset.click(function() {
$el.runner('stop');
$el.runner({
autostart: false,
countdown: true,
startAt: startAtMs,
stopAt: 0,
milliseconds: false,
format: formatTime
});
$el.runner('reset');
$btnStart.text('Iniciar');
$.get('/painel/cronometro', { tipo: tipo, action: 'reset' } );
});
}
$('#consideracoes').runner('stop'); setupCronometro('discurso', {{cronometro_discurso}}, cronometrosStatus.discurso);
$('#consideracoes').runner('reset'); setupCronometro('aparte', {{cronometro_aparte}}, cronometrosStatus.aparte);
}); setupCronometro('ordem', {{cronometro_ordem}}, cronometrosStatus.ordem);
setupCronometro('consideracoes', {{cronometro_consideracoes}}, cronometrosStatus.consideracoes);
}); });
@ -338,7 +227,7 @@ function switch_painel(aberto) {
var pk_sessao = {{root_pk}}; var pk_sessao = {{root_pk}};
var botao_abrir = $('#id_abrir_painel'); var botao_abrir = $('#id_abrir_painel');
var botao_fechar = $('#id_fechar_painel'); var botao_fechar = $('#id_fechar_painel');
var bloco_cronometros = $('#bloco_cronometros');
$.ajax({ $.ajax({
data: {pk_sessao: pk_sessao, aberto: aberto}, data: {pk_sessao: pk_sessao, aberto: aberto},
@ -350,9 +239,20 @@ function switch_painel(aberto) {
if (aberto) { if (aberto) {
botao_abrir.hide(); botao_abrir.hide();
botao_fechar.show(); botao_fechar.show();
bloco_cronometros.show();
} else { } else {
botao_abrir.show(); botao_abrir.show();
botao_fechar.hide(); botao_fechar.hide();
bloco_cronometros.hide();
['discurso', 'aparte', 'ordem', 'consideracoes'].forEach(function(tipo) {
var $el = $('#' + tipo);
var $btn = $('#' + tipo + 'Start');
if ($btn.text() === 'Parar') {
$el.runner('stop');
$btn.text('Resumir');
$('#' + tipo + 'Reset').show();
}
});
} }
} }
@ -360,6 +260,7 @@ $(document).ready(function(){
var pk_sessao = {{root_pk}}; var pk_sessao = {{root_pk}};
var botao_abrir = $('#id_abrir_painel'); var botao_abrir = $('#id_abrir_painel');
var botao_fechar = $('#id_fechar_painel'); var botao_fechar = $('#id_fechar_painel');
var bloco_cronometros = $('#bloco_cronometros');
$.ajax({ $.ajax({
data: {pk_sessao: pk_sessao}, data: {pk_sessao: pk_sessao},
@ -374,9 +275,11 @@ $(document).ready(function(){
if (data['status']) { if (data['status']) {
botao_abrir.hide(); botao_abrir.hide();
botao_fechar.show(); botao_fechar.show();
bloco_cronometros.show();
} else { } else {
botao_abrir.show(); botao_abrir.show();
botao_fechar.hide(); botao_fechar.hide();
bloco_cronometros.hide();
} }
}, },
}); });

Loading…
Cancel
Save