mirror of https://github.com/interlegis/sapl.git
Browse Source
Substitui o polling HTTP do painel legislativo por push via WebSockets (um PainelConsumer por sessão, broadcast de snapshot completo a partir de build_dados_painel()) e cobre com páginas Vue 2 + Pinia as telas de exibição pública do painel, votação nominal, voto individual, votação simbólica e leitura de matéria — todas consumindo o mesmo broadcast via normalizePainelData()/painelStore, sem componentes Vuex remanescentes. - Backend: PainelConsumer/ASGI (Channels + Redis), views Postgres (SessaoPresencasView/SessaoMateriasVotacoesView) para eliminar N+1, cronômetro com estado compartilhado em cache, migrations 0070-0073 como cadeia única. - Máscara de voto preservada: o broadcast público continua sempre mascarado por mostrar_voto; estado por-operador e por-eleitor vem de endpoints HTTP dedicados (votante_status, _status_json), nunca do broadcast compartilhado. - Infra: docker-compose e nginx atualizados para /ws/, requirements (channels/daphne/channels-redis) e vue.config.js com as novas entries de webpack. - Testes: test_broadcasts.py, test_consumers.py, test_votacao_nominal.py e test_sessao_view.py cobrindo o novo backend. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H85beqPY6uKTmSdCEovbPa Painel: remove polling fallback, WS-only with connection error UI Documents local-dev setup requirements in sapl/sessao/README.md and finishes the polling->push migration by removing the HTTP polling that was still running as a fallback everywhere: the three legacy templates (painel/index.html, voto_individual.html, sessao/votacao/nominal.html) and voto-individual/main.js. Every painel/votação screen — legacy and Vue — is now WebSocket-only. - Reconnect: exponential backoff with jitter (was flat 500ms in Vue), and connections now distinguish PainelConsumer's permanent close codes (4401/4403/4404 — auth/permission/no-such-sessão) from transient drops instead of retrying forever. New frontend/src/__apps/painel/ws/ painelSocket.js + WsStatusBanner.vue share this across the five Vue apps; the three legacy templates get an equivalent vanilla-JS banner. - Broadcast coverage: without polling to mask it, every write path that changes the painel payload now has to call broadcast_dados_painel() itself. Added it to PresencaView/PresencaOrdemDiaView, the three Orador*Crud create/update/delete views (via a new BroadcastPainelOnSaveMixin — covered by a new end-to-end test, since self.kwargs['pk'] means the parent sessão in CreateView but the Orador's own pk in Update/DeleteView), LeituraEmBloco, and VotacaoEmBlocoSimbolicaView/VotacaoEmBlocoNominalView. - voto_individual.html now opens its WebSocket whenever a sessão is open (not just when a matéria is), gated on sessao_id being present rather than on materia — otherwise a page with no votação aberta at all had no sessao_id to build a WS URL from. - Dead code removed: painel_mensagem/painel_parlamentar/painel_votacao (views, urls, templates) had no references anywhere and predate this work. dados_painel/get_dados_painel (the HTTP polling endpoint) and its ETag helper are also removed now that nothing polls them, along with the tests pinning their HTTP-specific behavior — build_dados_painel() itself is untouched and still backs every WS broadcast. - scripts/verify_painel_websockets.sh now runs a single Daphne process instead of Daphne+runserver on separate ports: WS-only clients need HTTP and WS on the same origin, which the old split-port setup never actually provided (it only appeared to work via the polling fallback that's now gone). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H85beqPY6uKTmSdCEovbPafeat/painel-votacao-v2
78 changed files with 6699 additions and 15559 deletions
@ -0,0 +1,3 @@ |
|||
docker run --rm -p 6379:6379 redis:7-alpine redis-server --save "" --appendonly no |
|||
|
|||
daphne -b 127.0.0.1 -p 8000 sapl.asgi:application |
|||
@ -0,0 +1,121 @@ |
|||
import './scss/leitura-materia.scss' |
|||
import Vue from 'vue' |
|||
import VueCompositionApi from '@vue/composition-api' |
|||
import { PiniaVuePlugin, createPinia } from 'pinia' |
|||
import axios from 'axios' |
|||
|
|||
import { usePainelStore } from '../painel/store/painelStore' |
|||
import { isPermanentCloseCode, permanentCloseMessage, nextBackoffDelay, INITIAL_BACKOFF_MS, DISCONNECTED_MESSAGE } from '../painel/ws/painelSocket' |
|||
import LeituraMateria from '../../components/leitura-materia/LeituraMateria.vue' |
|||
import WsStatusBanner from '../../components/painel/WsStatusBanner.vue' |
|||
|
|||
Vue.component('leitura-materia', LeituraMateria) |
|||
Vue.component('ws-status-banner', WsStatusBanner) |
|||
|
|||
axios.defaults.xsrfCookieName = 'csrftoken' |
|||
axios.defaults.xsrfHeaderName = 'X-CSRFToken' |
|||
|
|||
Vue.use(VueCompositionApi) |
|||
Vue.use(PiniaVuePlugin) |
|||
const pinia = createPinia() |
|||
|
|||
new Vue({ // eslint-disable-line
|
|||
pinia, |
|||
delimiters: ['[[', ']]'], |
|||
el: '#leitura-materia', |
|||
data () { |
|||
return { |
|||
sessaoId: null, |
|||
oid: null, |
|||
mid: null, |
|||
initialObservacao: '', |
|||
initialMateriaTexto: '', |
|||
initialMateriaEmenta: '', |
|||
cancelUrl: '#', |
|||
ws: null, |
|||
reconnectTimer: null, |
|||
reconnectDelay: INITIAL_BACKOFF_MS, |
|||
wsStatus: 'connecting', |
|||
wsErrorMessage: 'Conectando ao servidor de tempo real…', |
|||
error_message: '' |
|||
} |
|||
}, |
|||
mounted () { |
|||
const el = this.$el |
|||
this.sessaoId = el.dataset.sessaoId |
|||
this.oid = el.dataset.oid |
|||
this.mid = el.dataset.mid |
|||
this.initialObservacao = el.dataset.observacao || '' |
|||
this.initialMateriaTexto = el.dataset.materiaTexto || '' |
|||
this.initialMateriaEmenta = el.dataset.materiaEmenta || '' |
|||
// Mesma URL da tela legada (AbstractLeituraView.cancel_url) —
|
|||
// iso=1 identifica o fluxo de OrdemDia.
|
|||
this.cancelUrl = `/sessao/${this.sessaoId}/1/${this.oid}/retirar-leitura` |
|||
|
|||
if (this.sessaoId) { |
|||
this.connectWS() |
|||
} |
|||
}, |
|||
methods: { |
|||
painelStore () { |
|||
return usePainelStore() |
|||
}, |
|||
actionUrl () { |
|||
return `/sessao/${this.sessaoId}/materia/ordemdia/leitura/v2/${this.oid}/${this.mid}/salvar` |
|||
}, |
|||
wsURL () { |
|||
const proto = location.protocol === 'https:' ? 'wss' : 'ws' |
|||
return `${proto}://${location.host}/ws/painel/${this.sessaoId}/` |
|||
}, |
|||
connectWS () { |
|||
this.ws = new WebSocket(this.wsURL()) |
|||
this.ws.addEventListener('open', () => { |
|||
this.wsStatus = 'open' |
|||
this.wsErrorMessage = '' |
|||
this.reconnectDelay = INITIAL_BACKOFF_MS |
|||
}) |
|||
this.ws.addEventListener('message', (message) => { |
|||
try { |
|||
const data = JSON.parse(message.data) |
|||
if (data.type === 'data') { |
|||
this.painelStore().applyData(data) |
|||
} |
|||
} catch (e) { |
|||
console.error('Leitura materia WS parse error:', e) |
|||
} |
|||
}) |
|||
this.ws.addEventListener('close', (e) => { |
|||
if (isPermanentCloseCode(e.code)) { |
|||
this.wsStatus = 'error' |
|||
this.wsErrorMessage = permanentCloseMessage(e.code) |
|||
return |
|||
} |
|||
this.wsStatus = 'reconnecting' |
|||
this.wsErrorMessage = DISCONNECTED_MESSAGE |
|||
this.reconnectTimer = setTimeout(() => this.connectWS(), this.reconnectDelay) |
|||
this.reconnectDelay = nextBackoffDelay(this.reconnectDelay) |
|||
}) |
|||
}, |
|||
onSalvar (data) { |
|||
axios.post(this.actionUrl(), data, { |
|||
headers: { 'Content-Type': 'application/json' } |
|||
}) |
|||
.then(response => { |
|||
if (response.data.redirect_url) { |
|||
window.location.href = response.data.redirect_url |
|||
} |
|||
}) |
|||
.catch(error => { |
|||
const respData = error.response && error.response.data |
|||
this.error_message = (respData && respData.message) || 'Erro ao registrar a leitura. Tente novamente.' |
|||
}) |
|||
}, |
|||
onCancelar () { |
|||
// O próprio <a :href> do componente já navega — nada a fazer aqui.
|
|||
}, |
|||
beforeDestroy () { |
|||
if (this.reconnectTimer) clearTimeout(this.reconnectTimer) |
|||
if (this.ws) this.ws.close() |
|||
} |
|||
} |
|||
}) |
|||
@ -0,0 +1 @@ |
|||
/* estilos específicos da tela de leitura de matéria, se necessário */ |
|||
@ -0,0 +1,38 @@ |
|||
import './scss/painel.scss' |
|||
import Vue from 'vue' |
|||
import { FormSelectPlugin } from 'bootstrap-vue' |
|||
import axios from 'axios' |
|||
|
|||
//TODO: incluir painel-controle dentro da app de painel, colocando rotas diferentes
|
|||
|
|||
axios.defaults.xsrfCookieName = 'csrftoken' |
|||
axios.defaults.xsrfHeaderName = 'X-CSRFToken' |
|||
|
|||
Vue.use(FormSelectPlugin) |
|||
|
|||
console.log('painel controle main.js carregado') |
|||
|
|||
const v = new Vue({ // eslint-disable-line
|
|||
delimiters: ['[[', ']]'], |
|||
el: '#painel-controle', |
|||
data () { |
|||
return { |
|||
sessao_plenaria: "74ª Sessão Ordinária da 1ª Sessão Legislativa da 18ª Legislatura", |
|||
message: "", |
|||
} |
|||
}, |
|||
|
|||
watch: {}, |
|||
|
|||
computed: { |
|||
|
|||
}, |
|||
|
|||
created () {}, |
|||
|
|||
methods: {}, |
|||
|
|||
mounted () { |
|||
console.log("Painel controle app mounted!") |
|||
} |
|||
}) |
|||
@ -0,0 +1,43 @@ |
|||
|
|||
.painel-principal { |
|||
background: #1c1b1b; |
|||
font-family: Verdana; |
|||
font-size: x-large; |
|||
.text-title { |
|||
color: #4fa64d; |
|||
margin: 0.5rem; |
|||
font-weight: bold; |
|||
} |
|||
.text-subtitle { |
|||
color: #459170; |
|||
font-weight: bold; |
|||
} |
|||
.data-hora { |
|||
font-size: 180%; |
|||
} |
|||
|
|||
.text-value { |
|||
color: white; |
|||
} |
|||
|
|||
.logo-painel { |
|||
max-width: 100%; |
|||
} |
|||
.painels { |
|||
flex-wrap: wrap; |
|||
} |
|||
.painel{ |
|||
margin-top: 1rem; |
|||
table { |
|||
width: 100%; |
|||
} |
|||
h2 { |
|||
margin-bottom: 0.5rem; |
|||
} |
|||
#votacao, #oradores_list { |
|||
text-align: left; |
|||
display: inline-block; |
|||
margin-bottom: 1rem; |
|||
} |
|||
} |
|||
} |
|||
@ -1 +1,176 @@ |
|||
import './scss/painel.scss' |
|||
|
|||
import Vue from 'vue' |
|||
import VueCompositionApi from '@vue/composition-api' |
|||
import { PiniaVuePlugin, createPinia, mapState } from 'pinia' |
|||
|
|||
import { usePainelStore } from './store/painelStore' |
|||
import { isPermanentCloseCode, permanentCloseMessage, nextBackoffDelay, INITIAL_BACKOFF_MS, DISCONNECTED_MESSAGE } from './ws/painelSocket' |
|||
|
|||
import Cronometro from '../../components/painel/Cronometro.vue' |
|||
import CronometroList from '../../components/painel/CronometroList.vue' |
|||
import PainelHeader from '../../components/painel/PainelHeader.vue' |
|||
import PainelParlamentares from '../../components/painel/PainelParlamentares.vue' |
|||
import PainelOradores from '../../components/painel/PainelOradores.vue' |
|||
import PainelMateria from '../../components/painel/PainelMateria.vue' |
|||
import PainelResultado from '../../components/painel/PainelResultado.vue' |
|||
import PainelFooter from '../../components/painel/PainelFooter.vue' |
|||
import WsStatusBanner from '../../components/painel/WsStatusBanner.vue' |
|||
|
|||
// register components
|
|||
Vue.component('painel-cronometro', Cronometro) |
|||
Vue.component('painel-cronometro-list', CronometroList) |
|||
Vue.component('painel-header', PainelHeader) |
|||
Vue.component('painel-parlamentares', PainelParlamentares) |
|||
Vue.component('painel-oradores', PainelOradores) |
|||
Vue.component('painel-materia', PainelMateria) |
|||
Vue.component('painel-resultado', PainelResultado) |
|||
Vue.component('painel-footer', PainelFooter) |
|||
Vue.component('ws-status-banner', WsStatusBanner) |
|||
|
|||
// Pinia (no Vue 2 o plugin @vue/composition-api é obrigatório)
|
|||
Vue.use(VueCompositionApi) |
|||
Vue.use(PiniaVuePlugin) |
|||
const pinia = createPinia() |
|||
|
|||
const PING_INTERVAL = 30000 // 30s
|
|||
|
|||
new Vue({ |
|||
pinia, |
|||
el: '#painel', |
|||
delimiters: ['[[', ']]'], |
|||
data() { |
|||
return { |
|||
controllerId: null, |
|||
ws: null, |
|||
isOpen: false, |
|||
error: null, |
|||
pingTimer: null, |
|||
reconnectTimer: null, |
|||
reconnectDelay: INITIAL_BACKOFF_MS, |
|||
wsStatus: 'connecting', |
|||
wsErrorMessage: 'Conectando ao servidor de tempo real…', |
|||
} |
|||
}, |
|||
mounted() { |
|||
console.log('Painel principal mounted!') |
|||
// $el is guaranteed here
|
|||
const el = this.$el |
|||
// prefer data-attr; fallback to global if you set it
|
|||
this.controllerId = el.dataset.controllerId || window.controllerId |
|||
console.log(`ControllerId: ${this.controllerId}`) |
|||
this.connectWS() |
|||
|
|||
}, |
|||
|
|||
computed: { |
|||
...mapState(usePainelStore, ['painel_aberto', 'sessao_aberta', 'sessao', 'canRender']), |
|||
}, |
|||
methods: { |
|||
painelStore() { |
|||
return usePainelStore() |
|||
}, |
|||
wsURL() { |
|||
const proto = location.protocol === 'https:' ? 'wss' : 'ws' |
|||
return `${proto}://${location.host}/ws/painel/${this.controllerId}/` |
|||
}, |
|||
updateState(data) { |
|||
try { |
|||
// Toda a lógica de estado (flags, sessão, parlamentares, matéria,
|
|||
// resultado e votos) vive no store Pinia. O header/footer leem a
|
|||
// sessão do store — não há mais escrita via $refs.
|
|||
this.painelStore().applyData(data) |
|||
} catch (e) { |
|||
console.error('Error applying painel data', e) |
|||
} |
|||
}, |
|||
connectWS() { |
|||
const url = this.wsURL() |
|||
this.ws = new WebSocket(url) |
|||
|
|||
this.ws.addEventListener('open', () => { |
|||
this.isOpen = true; |
|||
this.error = null |
|||
this.wsStatus = 'open' |
|||
this.wsErrorMessage = '' |
|||
this.reconnectDelay = INITIAL_BACKOFF_MS |
|||
|
|||
console.log(`✅ WebSocket connected to ${url}`); |
|||
|
|||
// ping keep-alive timer (servidor responde com 'pong')
|
|||
this.pingTimer = setInterval(() => { |
|||
const ping = JSON.stringify({ type: "ping", ts: Date.now()}); |
|||
this.ws.send(ping); |
|||
}, PING_INTERVAL); |
|||
}) |
|||
|
|||
this.ws.addEventListener('message', (message) => { |
|||
try { |
|||
const data = JSON.parse(message.data) |
|||
|
|||
if (data.type === 'data') { |
|||
this.updateState(data); |
|||
} else if (data.type === 'pong') { |
|||
console.debug('Received pong from server'); |
|||
} |
|||
} catch (e) { |
|||
console.error('WS parse error:', e); |
|||
} |
|||
}) |
|||
|
|||
this.ws.addEventListener('close', (e) => { |
|||
console.log("❌ WebSocket closed:", e); |
|||
this.isOpen = false; |
|||
if (this.pingTimer) { |
|||
clearInterval(this.pingTimer) |
|||
this.pingTimer = null |
|||
} |
|||
if (isPermanentCloseCode(e.code)) { |
|||
this.wsStatus = 'error' |
|||
this.wsErrorMessage = permanentCloseMessage(e.code) |
|||
return |
|||
} |
|||
this.wsStatus = 'reconnecting' |
|||
this.wsErrorMessage = DISCONNECTED_MESSAGE |
|||
this.reconnectTimer = setTimeout(() => this.connectWS(), this.reconnectDelay); |
|||
this.reconnectDelay = nextBackoffDelay(this.reconnectDelay); |
|||
}) |
|||
|
|||
this.ws.addEventListener('error', (e) => { |
|||
this.error = e |
|||
console.error('❌ WebSocket error:', e) |
|||
}) |
|||
}, |
|||
closeWS() { |
|||
try { |
|||
console.log(`⚠️ Closing Websocket connection: ${this.wsURL}`); |
|||
this.ws && this.ws.close() |
|||
} catch (_) { |
|||
console.log("Error closing WS connection"); |
|||
} |
|||
}, |
|||
beforeDestroy() { |
|||
// Clear the interval before the component is destroyed
|
|||
if (this.pingTimer) { |
|||
clearInterval(this.pingTimer); |
|||
console.log('pingTimer Interval cleared'); |
|||
} |
|||
|
|||
if (this.reconnectTimer) { |
|||
clearTimeout(this.reconnectTimer); |
|||
console.log('reconnectTimer cleared'); |
|||
} |
|||
|
|||
this.closeWS(); |
|||
}, |
|||
changeFontSize(value) { |
|||
for (var name in this.$refs){ |
|||
if (name.startsWith("sw")) { |
|||
const cronometro = this.$refs[name] |
|||
cronometro.changeFontSize(value) |
|||
} |
|||
} |
|||
}, |
|||
startStopwatch() { if (this.isOpen) this.ws.send(JSON.stringify({ type:'notify', stopwatch:'start' })) }, |
|||
} |
|||
}) |
|||
|
|||
@ -1,43 +1,24 @@ |
|||
|
|||
.painel-principal { |
|||
background: #1c1b1b; |
|||
font-family: Verdana; |
|||
font-size: x-large; |
|||
.text-title { |
|||
color: #4fa64d; |
|||
margin: 0.5rem; |
|||
font-weight: bold; |
|||
} |
|||
.text-subtitle { |
|||
color: #459170; |
|||
font-weight: bold; |
|||
} |
|||
.data-hora { |
|||
font-size: 180%; |
|||
:root { |
|||
--bg-dark: #121212; |
|||
--bg-panel: #1e1e1e; |
|||
--text-main: #e0e0e0; |
|||
--text-highlight: #4fa64d; |
|||
} |
|||
|
|||
.text-value { |
|||
color: white; |
|||
body { |
|||
background-color: var(--bg-dark); |
|||
color: var(--text-main); |
|||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; |
|||
margin-bottom: 0; |
|||
} |
|||
|
|||
.logo-painel { |
|||
max-width: 100%; |
|||
} |
|||
.painels { |
|||
flex-wrap: wrap; |
|||
} |
|||
.painel{ |
|||
margin-top: 1rem; |
|||
table { |
|||
width: 100%; |
|||
} |
|||
h2 { |
|||
margin-bottom: 0.5rem; |
|||
} |
|||
#votacao, #oradores_list { |
|||
text-align: left; |
|||
display: inline-block; |
|||
margin-bottom: 1rem; |
|||
} |
|||
.separador-vertical { |
|||
border-right: 1px solid #333; |
|||
min-height: 100vh; |
|||
margin: 0; |
|||
} |
|||
|
|||
.text-subtitle { |
|||
color: #81c784; |
|||
font-weight: 600; |
|||
} |
|||
@ -0,0 +1,86 @@ |
|||
// frontend/src/__apps/painel/store/painelStore.js
|
|||
//
|
|||
// Store Pinia do painel. Substitui o store Vuex inline: um único ponto de
|
|||
// entrada (`applyData`), alimentado pelo payload normalizado a partir do
|
|||
// full-snapshot broadcast do backend (sapl/painel/views.py::
|
|||
// build_dados_painel()) — não há mais eventos incrementais
|
|||
// (`vote.update`/`stopwatch.update`); qualquer mudança de estado é
|
|||
// republicada como um novo snapshot completo.
|
|||
|
|||
import { defineStore } from 'pinia' |
|||
import { DEFAULT_STATE, normalizePainelData } from '../ws/normalizePainel' |
|||
|
|||
function deepClone (x) { |
|||
return JSON.parse(JSON.stringify(x)) |
|||
} |
|||
|
|||
export const usePainelStore = defineStore('painel', { |
|||
state: () => deepClone(DEFAULT_STATE), |
|||
|
|||
getters: { |
|||
canRender: (s) => !!(s.sessao_aberta && s.painel_aberto), |
|||
|
|||
// Getters usados pelo app `votacao` (apuração ao vivo a partir dos votos).
|
|||
totalVotos: (s) => { |
|||
const totals = [ |
|||
{ tipo: 'Sim', total: 0 }, |
|||
{ tipo: 'Não', total: 0 }, |
|||
{ tipo: 'Abstenção', total: 0 }, |
|||
{ tipo: 'Não Votou', total: 0 } |
|||
] |
|||
s.parlamentares.forEach((p) => { |
|||
if (p.voto) { |
|||
const entry = totals.find((t) => t.tipo === p.voto) |
|||
if (entry) entry.total++ |
|||
} |
|||
}) |
|||
return totals |
|||
}, |
|||
numPresentes: (s) => s.parlamentares.length, |
|||
totalVotados: (s) => |
|||
s.parlamentares.filter((p) => p.voto && p.voto !== 'Não Votou').length |
|||
}, |
|||
|
|||
actions: { |
|||
// Aplica o payload completo (`type: "data"`) do WebSocket.
|
|||
applyData (raw) { |
|||
const next = normalizePainelData(raw) |
|||
|
|||
// flags simples
|
|||
this.sessao_aberta = next.sessao_aberta |
|||
this.painel_aberto = next.painel_aberto |
|||
this.mostrar_voto = next.mostrar_voto |
|||
this.message = next.message |
|||
|
|||
// objeto aninhado: patch (preserva reatividade dos campos)
|
|||
Object.assign(this.sessao, next.sessao) |
|||
|
|||
// cronômetros: só start/stop/reset explícitos (ver Cronometro.vue/
|
|||
// CronometroList.vue) reagem a mudança de valor — nunca a um toggle
|
|||
// por broadcast, que dispara em qualquer voto/matéria aberta, não só
|
|||
// em ações de cronômetro.
|
|||
this.cronometro_discurso = next.cronometro_discurso |
|||
this.cronometro_aparte = next.cronometro_aparte |
|||
this.cronometro_ordem = next.cronometro_ordem |
|||
this.cronometro_consideracoes = next.cronometro_consideracoes |
|||
|
|||
// arrays: trocar referência (voto já vem aplicado por parlamentar
|
|||
// dentro de next.parlamentares — get_votos() no backend já inlina o
|
|||
// voto de cada presente, não há mais um mapa separado a reaplicar)
|
|||
this.parlamentares = next.parlamentares |
|||
this.oradores = next.oradores |
|||
|
|||
// objetos grandes: substituir
|
|||
this.materia = next.materia |
|||
this.resultado = next.resultado |
|||
|
|||
// tipos de resultado (app votacao): nunca vem no broadcast do
|
|||
// WebSocket (ver GAP 3 do plano de merge) — só sobrescreve quando o
|
|||
// payload realmente traz a lista, preservando o valor populado uma
|
|||
// vez a partir do contexto inicial renderizado pelo Django.
|
|||
if (next.tipos_resultado && next.tipos_resultado.length) { |
|||
this.tipos_resultado = next.tipos_resultado |
|||
} |
|||
} |
|||
} |
|||
}) |
|||
@ -0,0 +1,125 @@ |
|||
// frontend/src/__apps/painel/ws/normalizePainel.js
|
|||
//
|
|||
// Normaliza o payload `type: "data"` recebido via WebSocket para um shape
|
|||
// estável e tolerante a payload parcial. A partir da adoção do backend de
|
|||
// feat/painel-websockets, o payload é o dict flat produzido por
|
|||
// sapl/painel/views.py::build_dados_painel() — o mesmo usado pelo polling
|
|||
// HTTP legado e pinado pelos testes sapl/painel/tests/test_broadcasts.py e
|
|||
// test_consumers.py, então essa função nunca deve mudar de formato; toda
|
|||
// adaptação de shape vive aqui, não em build_dados_painel().
|
|||
|
|||
export const DEFAULT_STATE = Object.freeze({ |
|||
sessao_aberta: false, |
|||
painel_aberto: false, |
|||
mostrar_voto: false, |
|||
message: '', |
|||
|
|||
sessao: { |
|||
sessao_plenaria: '', |
|||
sessao_plenaria_data: '', |
|||
sessao_plenaria_hora_inicio: '', |
|||
sessao_solene: false, |
|||
tema_solene: '', |
|||
brasao: '' |
|||
}, |
|||
|
|||
cronometro_discurso: '', |
|||
cronometro_aparte: '', |
|||
cronometro_ordem: '', |
|||
cronometro_consideracoes: '', |
|||
|
|||
parlamentares: [], |
|||
oradores: [], |
|||
|
|||
materia: {}, |
|||
resultado: { |
|||
numero_votos: {} |
|||
}, |
|||
|
|||
// Populado uma vez, a partir do contexto inicial renderizado pelo Django
|
|||
// (não vem no payload do WebSocket) — ver GAP 3 do plano de merge.
|
|||
// applyData() preserva o valor atual quando o payload não traz a lista.
|
|||
tipos_resultado: [] |
|||
}) |
|||
|
|||
function asObject (v) { |
|||
return v && typeof v === 'object' ? v : {} |
|||
} |
|||
|
|||
function asArray (v) { |
|||
return Array.isArray(v) ? v : [] |
|||
} |
|||
|
|||
function asString (v, fallback) { |
|||
return v != null ? v : fallback |
|||
} |
|||
|
|||
export function normalizePainelData (raw) { |
|||
const d = asObject(raw) |
|||
|
|||
const parlamentares = asArray(d.presentes) |
|||
.filter((p) => p && typeof p === 'object') |
|||
.map((p) => ({ |
|||
id: p.id, |
|||
parlamentar_id: p.parlamentar_id, |
|||
nome_parlamentar: asString(p.nome, ''), |
|||
filiacao: asString(p.partido, ''), |
|||
fotografia: p.fotografia != null ? p.fotografia : false, |
|||
voto: p.voto != null ? p.voto : '' |
|||
})) |
|||
|
|||
const oradores = asArray(d.oradores) |
|||
.filter((o) => o && typeof o === 'object') |
|||
.map((o) => ({ |
|||
ordem_pronunciamento: o.numero, |
|||
nome_parlamentar: asString(o.nome, '') |
|||
})) |
|||
|
|||
return { |
|||
// sessao_iniciada vem de sessao.iniciada is not False (True ou None
|
|||
// contam como "iniciada" — sessões legadas ficaram com None, mesma
|
|||
// convenção de restringe_sessoes_visiveis() no backend).
|
|||
sessao_aberta: !!d.sessao_iniciada && !d.sessao_finalizada, |
|||
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, |
|||
message: asString(d.msg_painel, DEFAULT_STATE.message), |
|||
|
|||
sessao: { |
|||
sessao_plenaria: asString(d.sessao_plenaria, DEFAULT_STATE.sessao.sessao_plenaria), |
|||
sessao_plenaria_data: asString(d.sessao_plenaria_data, DEFAULT_STATE.sessao.sessao_plenaria_data), |
|||
sessao_plenaria_hora_inicio: asString(d.sessao_plenaria_hora_inicio, DEFAULT_STATE.sessao.sessao_plenaria_hora_inicio), |
|||
sessao_solene: d.sessao_solene != null ? !!d.sessao_solene : DEFAULT_STATE.sessao.sessao_solene, |
|||
tema_solene: asString(d.tema_solene, DEFAULT_STATE.sessao.tema_solene), |
|||
brasao: asString(d.brasao, DEFAULT_STATE.sessao.brasao) |
|||
}, |
|||
|
|||
cronometro_discurso: asString(d.cronometro_discurso, DEFAULT_STATE.cronometro_discurso), |
|||
cronometro_aparte: asString(d.cronometro_aparte, DEFAULT_STATE.cronometro_aparte), |
|||
cronometro_ordem: asString(d.cronometro_ordem, DEFAULT_STATE.cronometro_ordem), |
|||
cronometro_consideracoes: asString(d.cronometro_consideracoes, DEFAULT_STATE.cronometro_consideracoes), |
|||
|
|||
parlamentares, |
|||
oradores, |
|||
|
|||
materia: { |
|||
texto: asString(d.materia_legislativa_texto, ''), |
|||
ementa: asString(d.materia_legislativa_ementa, ''), |
|||
observacao: asString(d.observacao_materia, ''), |
|||
resultado_votacao: asString(d.tipo_resultado, '') |
|||
}, |
|||
|
|||
resultado: { |
|||
numero_votos: { |
|||
num_presentes: d.num_presentes != null ? d.num_presentes : parlamentares.length, |
|||
votos_sim: d.numero_votos_sim != null ? d.numero_votos_sim : 0, |
|||
votos_nao: d.numero_votos_nao != null ? d.numero_votos_nao : 0, |
|||
abstencoes: d.numero_abstencoes != null ? d.numero_abstencoes : 0, |
|||
total_votos: d.total_votos != null ? d.total_votos : 0 |
|||
} |
|||
}, |
|||
|
|||
// Nunca vem no payload do WebSocket — applyData() só sobrescreve o
|
|||
// valor atual do store quando esta lista não está vazia.
|
|||
tipos_resultado: asArray(d.tipos_resultado) |
|||
} |
|||
} |
|||
@ -0,0 +1,42 @@ |
|||
// Reconexão do WebSocket do painel (/ws/painel/<sessao_id>/), compartilhada
|
|||
// pelos cinco apps Vue que se conectam nele (painel, votacao, voto-individual,
|
|||
// votacao-simbolica, leitura-materia). Não há mais fallback de polling HTTP —
|
|||
// isto é a única fonte de atualização, então quem usa isto precisa também
|
|||
// expor o status ao usuário (ver WsStatusBanner.vue), não só logar no console.
|
|||
|
|||
const MIN_BACKOFF_MS = 1000 |
|||
const MAX_BACKOFF_MS = 30000 |
|||
|
|||
// Códigos fechados explicitamente por PainelConsumer.connect()
|
|||
// (sapl/painel/consumers.py) para condições que uma nova tentativa não
|
|||
// resolve sozinha — não faz sentido continuar reconectando com backoff.
|
|||
const PERMANENT_CLOSE_MESSAGES = { |
|||
4401: 'Sessão expirada ou usuário não autenticado. Faça login novamente e recarregue a página.', |
|||
4403: 'Seu usuário não tem permissão para acessar esta tela.', |
|||
4404: 'Sessão plenária não encontrada.' |
|||
} |
|||
|
|||
export function isPermanentCloseCode (code) { |
|||
return Object.prototype.hasOwnProperty.call(PERMANENT_CLOSE_MESSAGES, code) |
|||
} |
|||
|
|||
export function permanentCloseMessage (code) { |
|||
return PERMANENT_CLOSE_MESSAGES[code] |
|||
} |
|||
|
|||
export const INITIAL_BACKOFF_MS = MIN_BACKOFF_MS |
|||
|
|||
// Redis fora do ar (com o Daphne no ar) tende a fechar a conexão logo após
|
|||
// aceitá-la, não a recusar a abertura — então o "erro de conexão" real é
|
|||
// "fechou de novo pouco depois de eu tentar", não só "nunca abriu".
|
|||
export function nextBackoffDelay (currentDelayMs) { |
|||
const base = Math.min(currentDelayMs * 2, MAX_BACKOFF_MS) |
|||
// +/-30% de jitter — evita que várias telas conectadas na mesma sessão
|
|||
// reconectem todas no mesmo instante depois de uma queda do Daphne,
|
|||
// o que bateria em build_dados_painel() ao mesmo tempo para todas.
|
|||
return Math.round(base * (0.7 + Math.random() * 0.6)) |
|||
} |
|||
|
|||
export const DISCONNECTED_MESSAGE = |
|||
'Conexão em tempo real perdida. Verifique se o Redis e o Daphne (ou ' + |
|||
'"./manage.py runserver") estão em execução. Tentando reconectar…' |
|||
@ -0,0 +1,129 @@ |
|||
import './scss/votacao-simbolica.scss' |
|||
import Vue from 'vue' |
|||
import VueCompositionApi from '@vue/composition-api' |
|||
import { PiniaVuePlugin, createPinia } from 'pinia' |
|||
import axios from 'axios' |
|||
|
|||
import { usePainelStore } from '../painel/store/painelStore' |
|||
import { isPermanentCloseCode, permanentCloseMessage, nextBackoffDelay, INITIAL_BACKOFF_MS, DISCONNECTED_MESSAGE } from '../painel/ws/painelSocket' |
|||
import VotacaoSimbolica from '../../components/votacao-simbolica/VotacaoSimbolica.vue' |
|||
import WsStatusBanner from '../../components/painel/WsStatusBanner.vue' |
|||
|
|||
Vue.component('votacao-simbolica', VotacaoSimbolica) |
|||
Vue.component('ws-status-banner', WsStatusBanner) |
|||
|
|||
axios.defaults.xsrfCookieName = 'csrftoken' |
|||
axios.defaults.xsrfHeaderName = 'X-CSRFToken' |
|||
|
|||
Vue.use(VueCompositionApi) |
|||
Vue.use(PiniaVuePlugin) |
|||
const pinia = createPinia() |
|||
|
|||
new Vue({ // eslint-disable-line
|
|||
pinia, |
|||
delimiters: ['[[', ']]'], |
|||
el: '#votacao-simbolica', |
|||
data () { |
|||
return { |
|||
sessaoId: null, |
|||
oid: null, |
|||
mid: null, |
|||
totalPresentes: 0, |
|||
totalVotantes: 0, |
|||
ws: null, |
|||
reconnectTimer: null, |
|||
reconnectDelay: INITIAL_BACKOFF_MS, |
|||
wsStatus: 'connecting', |
|||
wsErrorMessage: 'Conectando ao servidor de tempo real…', |
|||
error_message: '' |
|||
} |
|||
}, |
|||
mounted () { |
|||
const el = this.$el |
|||
this.sessaoId = el.dataset.sessaoId |
|||
this.oid = el.dataset.oid |
|||
this.mid = el.dataset.mid |
|||
this.totalPresentes = el.dataset.totalPresentes |
|||
this.totalVotantes = el.dataset.totalVotantes |
|||
|
|||
const tiposResultadoEl = document.getElementById('tipos-resultado') |
|||
if (tiposResultadoEl) { |
|||
try { |
|||
this.painelStore().tipos_resultado = JSON.parse(tiposResultadoEl.textContent) |
|||
} catch (e) { |
|||
console.error('Erro ao ler tipos-resultado:', e) |
|||
} |
|||
} |
|||
|
|||
if (this.sessaoId) { |
|||
this.connectWS() |
|||
} |
|||
}, |
|||
methods: { |
|||
painelStore () { |
|||
return usePainelStore() |
|||
}, |
|||
actionUrl () { |
|||
// Mesma URL da tela legada (VotacaoView) — sessao/urls.py:
|
|||
// sessao/<pk>/materia/ordemdia/votacao/simbolica/<oid>/<mid>
|
|||
return `/sessao/${this.sessaoId}/materia/ordemdia/votacao/simbolica/${this.oid}/${this.mid}` |
|||
}, |
|||
wsURL () { |
|||
const proto = location.protocol === 'https:' ? 'wss' : 'ws' |
|||
return `${proto}://${location.host}/ws/painel/${this.sessaoId}/` |
|||
}, |
|||
connectWS () { |
|||
this.ws = new WebSocket(this.wsURL()) |
|||
this.ws.addEventListener('open', () => { |
|||
this.wsStatus = 'open' |
|||
this.wsErrorMessage = '' |
|||
this.reconnectDelay = INITIAL_BACKOFF_MS |
|||
}) |
|||
this.ws.addEventListener('message', (message) => { |
|||
try { |
|||
const data = JSON.parse(message.data) |
|||
if (data.type === 'data') { |
|||
this.painelStore().applyData(data) |
|||
} |
|||
} catch (e) { |
|||
console.error('Votacao simbolica WS parse error:', e) |
|||
} |
|||
}) |
|||
this.ws.addEventListener('close', (e) => { |
|||
if (isPermanentCloseCode(e.code)) { |
|||
this.wsStatus = 'error' |
|||
this.wsErrorMessage = permanentCloseMessage(e.code) |
|||
return |
|||
} |
|||
this.wsStatus = 'reconnecting' |
|||
this.wsErrorMessage = DISCONNECTED_MESSAGE |
|||
this.reconnectTimer = setTimeout(() => this.connectWS(), this.reconnectDelay) |
|||
this.reconnectDelay = nextBackoffDelay(this.reconnectDelay) |
|||
}) |
|||
}, |
|||
postAction (payload) { |
|||
axios.post(this.actionUrl(), payload, { |
|||
headers: { 'Content-Type': 'application/json' } |
|||
}) |
|||
.then(response => { |
|||
if (response.data.redirect_url) { |
|||
window.location.href = response.data.redirect_url |
|||
} |
|||
}) |
|||
.catch(error => { |
|||
const data = error.response && error.response.data |
|||
this.error_message = (data && data.message) || 'Erro ao processar a votação. Tente novamente.' |
|||
}) |
|||
}, |
|||
onCancelar () { |
|||
this.postAction({ 'cancelar-votacao': '1' }) |
|||
}, |
|||
onSalvar (data) { |
|||
this.postAction(data) |
|||
}, |
|||
beforeDestroy () { |
|||
if (this.reconnectTimer) clearTimeout(this.reconnectTimer) |
|||
if (this.ws) this.ws.close() |
|||
} |
|||
} |
|||
}) |
|||
@ -0,0 +1 @@ |
|||
/* estilos específicos da tela de votação simbólica, se necessário */ |
|||
@ -0,0 +1,287 @@ |
|||
import './scss/votacao.scss' |
|||
import Vue from 'vue' |
|||
import VueCompositionApi from '@vue/composition-api' |
|||
import { PiniaVuePlugin, createPinia, mapState } from 'pinia' |
|||
import axios from 'axios' |
|||
|
|||
// Store Pinia compartilhado com o app painel
|
|||
import { usePainelStore } from '../painel/store/painelStore' |
|||
import { isPermanentCloseCode, permanentCloseMessage, nextBackoffDelay, INITIAL_BACKOFF_MS, DISCONNECTED_MESSAGE } from '../painel/ws/painelSocket' |
|||
|
|||
// Import components
|
|||
import VotacaoNominal from '../../components/votacao/VotacaoNominal.vue' |
|||
import VotacaoMateria from '../../components/votacao/VotacaoMateria.vue' |
|||
import VotacaoVotos from '../../components/votacao/VotacaoVotos.vue' |
|||
import VotacaoResultado from '../../components/votacao/VotacaoResultado.vue' |
|||
import VotacaoObservacoes from '../../components/votacao/VotacaoObservacoes.vue' |
|||
import WsStatusBanner from '../../components/painel/WsStatusBanner.vue' |
|||
|
|||
// Register components globally
|
|||
Vue.component('votacao-nominal', VotacaoNominal) |
|||
Vue.component('votacao-materia', VotacaoMateria) |
|||
Vue.component('votacao-votos', VotacaoVotos) |
|||
Vue.component('votacao-resultado', VotacaoResultado) |
|||
Vue.component('votacao-observacoes', VotacaoObservacoes) |
|||
Vue.component('ws-status-banner', WsStatusBanner) |
|||
|
|||
axios.defaults.xsrfCookieName = 'csrftoken' |
|||
axios.defaults.xsrfHeaderName = 'X-CSRFToken' |
|||
|
|||
// Pinia (no Vue 2 o plugin @vue/composition-api é obrigatório)
|
|||
Vue.use(VueCompositionApi) |
|||
Vue.use(PiniaVuePlugin) |
|||
const pinia = createPinia() |
|||
|
|||
console.log('votacao main.js carregado') |
|||
|
|||
const PING_INTERVAL = 30000 |
|||
|
|||
const v = new Vue({ // eslint-disable-line
|
|||
pinia, |
|||
delimiters: ['[[', ']]'], |
|||
el: '#votacao', |
|||
data () { |
|||
return { |
|||
controllerId: null, |
|||
ws: null, |
|||
isOpen: false, |
|||
error: null, |
|||
pingTimer: null, |
|||
reconnectTimer: null, |
|||
reconnectDelay: INITIAL_BACKOFF_MS, |
|||
wsStatus: 'connecting', |
|||
wsErrorMessage: 'Conectando ao servidor de tempo real…', |
|||
error_message: '', |
|||
// Estado real (não mascarado por mostrar_voto) de quem já votou —
|
|||
// nunca vem do broadcast do painel (que é a visão pública/mascarada);
|
|||
// vem de /votos-status, buscado no mount e a cada broadcast recebido.
|
|||
votosStatus: {}, |
|||
} |
|||
}, |
|||
|
|||
computed: { |
|||
...mapState(usePainelStore, [ |
|||
'sessao_aberta', 'painel_aberto', 'sessao', |
|||
'parlamentares', 'materia', 'resultado', 'message', 'mostrar_voto' |
|||
]), |
|||
}, |
|||
|
|||
mounted () { |
|||
console.log('Votacao app mounted!') |
|||
const el = this.$el |
|||
this.controllerId = el.dataset.controllerId || window.controllerId |
|||
console.log(`Votacao ControllerId: ${this.controllerId}`) |
|||
// tipos_resultado nunca vem do broadcast do painel — populado uma vez
|
|||
// a partir do contexto inicial renderizado pelo Django (ver GAP 3 do
|
|||
// plano de merge). applyData() preserva esse valor depois (só
|
|||
// sobrescreve quando o payload realmente traz uma lista não vazia).
|
|||
const tiposResultadoEl = document.getElementById('tipos-resultado') |
|||
if (tiposResultadoEl) { |
|||
try { |
|||
this.painelStore().tipos_resultado = JSON.parse(tiposResultadoEl.textContent) |
|||
} catch (e) { |
|||
console.error('Erro ao ler tipos-resultado:', e) |
|||
} |
|||
} |
|||
if (this.controllerId) { |
|||
this.fetchVotosStatus() |
|||
this.connectWS() |
|||
} else { |
|||
this.error_message = 'Erro: controller_id não definido. Não é possível conectar ao WebSocket.' |
|||
console.error('No controllerId found — WebSocket will not connect.') |
|||
} |
|||
}, |
|||
|
|||
methods: { |
|||
painelStore () { |
|||
return usePainelStore() |
|||
}, |
|||
|
|||
wsURL () { |
|||
const proto = location.protocol === 'https:' ? 'wss' : 'ws' |
|||
return `${proto}://${location.host}/ws/painel/${this.controllerId}/` |
|||
}, |
|||
|
|||
voteURL () { |
|||
return `/v2/painel/controller/${this.controllerId}/vote` |
|||
}, |
|||
|
|||
votosStatusURL () { |
|||
return `/v2/painel/controller/${this.controllerId}/votos-status` |
|||
}, |
|||
|
|||
// Estado real (não mascarado) de quem já votou — nunca lido do store
|
|||
// (que reflete o broadcast público, mascarado quando mostrar_voto é
|
|||
// False). A Mesa precisa do valor real para não sobrescrever por
|
|||
// engano um voto que já chegou por tablet.
|
|||
fetchVotosStatus () { |
|||
if (!this.controllerId) return |
|||
axios.get(this.votosStatusURL()) |
|||
.then(response => { |
|||
this.votosStatus = response.data.votos || {} |
|||
}) |
|||
.catch(error => { |
|||
console.error('Erro ao buscar votos-status:', error) |
|||
}) |
|||
}, |
|||
|
|||
castVote ({ parlamentar_id, voto }) { |
|||
console.log(`Casting vote: parlamentar=${parlamentar_id}, voto=${voto}`) |
|||
axios.post(this.voteURL(), { |
|||
parlamentar_id: parlamentar_id, |
|||
voto: voto |
|||
}, { |
|||
headers: { 'Content-Type': 'application/json' } |
|||
}) |
|||
.then(response => { |
|||
console.log('Vote cast successfully:', response.data) |
|||
}) |
|||
.catch(error => { |
|||
console.error('Error casting vote:', error.response ? error.response.data : error) |
|||
this.error_message = 'Erro ao registrar voto. Tente novamente.' |
|||
}) |
|||
}, |
|||
|
|||
cancelURL () { |
|||
return `/v2/painel/controller/${this.controllerId}/cancel` |
|||
}, |
|||
|
|||
closeURL () { |
|||
return `/v2/painel/controller/${this.controllerId}/close` |
|||
}, |
|||
|
|||
onCancelar () { |
|||
console.log('Votação cancelada') |
|||
axios.post(this.cancelURL(), {}, { |
|||
headers: { 'Content-Type': 'application/json' } |
|||
}) |
|||
.then(response => { |
|||
console.log('Votação cancelada com sucesso:', response.data) |
|||
if (response.data.redirect_url) { |
|||
window.location.href = response.data.redirect_url |
|||
} |
|||
}) |
|||
.catch(error => { |
|||
console.error('Erro ao cancelar votação:', error.response ? error.response.data : error) |
|||
const msg = error.response && error.response.data && error.response.data.message |
|||
? error.response.data.message |
|||
: 'Erro ao cancelar votação. Tente novamente.' |
|||
this.error_message = msg |
|||
}) |
|||
}, |
|||
|
|||
onFechar (data) { |
|||
console.log('Fechar votação:', data) |
|||
if (!data.resultado_selected) { |
|||
this.error_message = 'Não é possível finalizar a votação sem nenhum resultado da votação.' |
|||
return |
|||
} |
|||
axios.post(this.closeURL(), { |
|||
resultado_id: data.resultado_selected, |
|||
observacoes: data.observacoes || '' |
|||
}, { |
|||
headers: { 'Content-Type': 'application/json' } |
|||
}) |
|||
.then(response => { |
|||
console.log('Votação finalizada com sucesso:', response.data) |
|||
this.error_message = '' |
|||
if (response.data.redirect_url) { |
|||
window.location.href = response.data.redirect_url |
|||
} |
|||
}) |
|||
.catch(error => { |
|||
console.error('Erro ao fechar votação:', error.response ? error.response.data : error) |
|||
const msg = error.response && error.response.data && error.response.data.message |
|||
? error.response.data.message |
|||
: 'Erro ao fechar votação. Tente novamente.' |
|||
this.error_message = msg |
|||
}) |
|||
}, |
|||
|
|||
updateState (data) { |
|||
try { |
|||
// Toda a normalização/aplicação de estado vive no store Pinia.
|
|||
this.painelStore().applyData(data) |
|||
} catch (e) { |
|||
console.error('Error updating state:', e) |
|||
} |
|||
// O broadcast é a visão pública (mascarada); o estado real de quem
|
|||
// já votou vem sempre de votos-status, refeito a cada snapshot.
|
|||
this.fetchVotosStatus() |
|||
}, |
|||
|
|||
connectWS () { |
|||
const url = this.wsURL() |
|||
this.ws = new WebSocket(url) |
|||
|
|||
this.ws.addEventListener('open', () => { |
|||
this.isOpen = true |
|||
this.error = null |
|||
this.error_message = '' |
|||
this.wsStatus = 'open' |
|||
this.wsErrorMessage = '' |
|||
this.reconnectDelay = INITIAL_BACKOFF_MS |
|||
console.log(`✅ Votação WebSocket connected to ${url}`) |
|||
|
|||
this.pingTimer = setInterval(() => { |
|||
this.ws.send(JSON.stringify({ type: 'ping', ts: Date.now() })) |
|||
}, PING_INTERVAL) |
|||
}) |
|||
|
|||
this.ws.addEventListener('message', (message) => { |
|||
try { |
|||
const data = JSON.parse(message.data) |
|||
|
|||
if (data.type === 'data') { |
|||
this.updateState(data) |
|||
} else if (data.type === 'pong') { |
|||
console.debug('Votacao: pong recebido') |
|||
} |
|||
} catch (e) { |
|||
console.error('Votacao WS parse error:', e) |
|||
} |
|||
}) |
|||
|
|||
this.ws.addEventListener('close', (e) => { |
|||
console.log('❌ Votação WebSocket closed:', e) |
|||
this.isOpen = false |
|||
if (this.pingTimer) { |
|||
clearInterval(this.pingTimer) |
|||
this.pingTimer = null |
|||
} |
|||
if (isPermanentCloseCode(e.code)) { |
|||
this.wsStatus = 'error' |
|||
this.wsErrorMessage = permanentCloseMessage(e.code) |
|||
return |
|||
} |
|||
this.wsStatus = 'reconnecting' |
|||
this.wsErrorMessage = DISCONNECTED_MESSAGE |
|||
this.reconnectTimer = setTimeout(() => this.connectWS(), this.reconnectDelay) |
|||
this.reconnectDelay = nextBackoffDelay(this.reconnectDelay) |
|||
}) |
|||
|
|||
this.ws.addEventListener('error', (e) => { |
|||
this.error = e |
|||
console.error('❌ Votação WebSocket error:', e) |
|||
}) |
|||
}, |
|||
|
|||
closeWS () { |
|||
try { |
|||
this.ws && this.ws.close() |
|||
} catch (_) { |
|||
console.log('Error closing WS') |
|||
} |
|||
}, |
|||
|
|||
beforeDestroy () { |
|||
if (this.pingTimer) { |
|||
clearInterval(this.pingTimer) |
|||
} |
|||
if (this.reconnectTimer) { |
|||
clearTimeout(this.reconnectTimer) |
|||
} |
|||
this.closeWS() |
|||
} |
|||
} |
|||
}) |
|||
@ -0,0 +1,115 @@ |
|||
import './scss/voto-individual.scss' |
|||
|
|||
import Vue from 'vue' |
|||
import VueCompositionApi from '@vue/composition-api' |
|||
import { PiniaVuePlugin, createPinia } from 'pinia' |
|||
|
|||
import { usePainelStore } from '../painel/store/painelStore' |
|||
import { isPermanentCloseCode, permanentCloseMessage, nextBackoffDelay, INITIAL_BACKOFF_MS, DISCONNECTED_MESSAGE } from '../painel/ws/painelSocket' |
|||
import VotoIndividual from '../../components/voto-individual/VotoIndividual.vue' |
|||
import WsStatusBanner from '../../components/painel/WsStatusBanner.vue' |
|||
|
|||
Vue.component('voto-individual', VotoIndividual) |
|||
Vue.component('ws-status-banner', WsStatusBanner) |
|||
|
|||
Vue.use(VueCompositionApi) |
|||
Vue.use(PiniaVuePlugin) |
|||
const pinia = createPinia() |
|||
|
|||
new Vue({ |
|||
pinia, |
|||
el: '#voto-individual-app', |
|||
delimiters: ['[[', ']]'], |
|||
data () { |
|||
return { |
|||
sessaoId: null, |
|||
ws: null, |
|||
reconnectTimer: null, |
|||
reconnectDelay: INITIAL_BACKOFF_MS, |
|||
wsStatus: 'connecting', |
|||
wsErrorMessage: 'Conectando ao servidor de tempo real…' |
|||
} |
|||
}, |
|||
mounted () { |
|||
this.sessaoId = this.$el.dataset.sessaoId || null |
|||
// Estado pessoal (não vem do broadcast, ver nota em fetchStatus) —
|
|||
// busca inicial aqui; daí em diante só é refeito quando o WS avisar
|
|||
// que algo mudou (sem poll periódico — ver painelSocket.js).
|
|||
this.fetchStatus() |
|||
if (this.sessaoId) { |
|||
this.connectWS() |
|||
} |
|||
}, |
|||
methods: { |
|||
painelStore () { |
|||
return usePainelStore() |
|||
}, |
|||
statusURL () { |
|||
return window.VOTO_INDIVIDUAL_STATUS_URL |
|||
}, |
|||
wsURL () { |
|||
const proto = location.protocol === 'https:' ? 'wss' : 'ws' |
|||
return `${proto}://${location.host}/ws/painel/${this.sessaoId}/` |
|||
}, |
|||
// Mesmo padrão do template legado (voto_individual.html): estado
|
|||
// pessoal (voto_parlamentar/status_message/error_message) nunca vem
|
|||
// do broadcast do painel (que é o snapshot público compartilhado por
|
|||
// todo mundo conectado) — sempre de um fetch dedicado, autenticado
|
|||
// como este Votante. O componente compara o resultado e só
|
|||
// re-renderiza quando algo muda.
|
|||
fetchStatus () { |
|||
fetch(this.statusURL(), { credentials: 'same-origin' }) |
|||
.then(resp => resp.json()) |
|||
.then(data => { |
|||
const comp = this.$refs.votoIndividual |
|||
if (!comp) return |
|||
comp.materiaId = data.materia_id ? String(data.materia_id) : '' |
|||
comp.errorMessage = data.error_message || '' |
|||
comp.statusMessage = data.status_message || '' |
|||
comp.votoParlamentar = data.voto_parlamentar || '' |
|||
}) |
|||
.catch(() => { /* tenta de novo no próximo broadcast do WS */ }) |
|||
}, |
|||
connectWS () { |
|||
const url = this.wsURL() |
|||
this.ws = new WebSocket(url) |
|||
|
|||
this.ws.addEventListener('open', () => { |
|||
this.wsStatus = 'open' |
|||
this.wsErrorMessage = '' |
|||
this.reconnectDelay = INITIAL_BACKOFF_MS |
|||
}) |
|||
|
|||
this.ws.addEventListener('message', (message) => { |
|||
try { |
|||
const data = JSON.parse(message.data) |
|||
if (data.type === 'data') { |
|||
// Atualiza o display compartilhado da matéria (sessao/materia)
|
|||
this.painelStore().applyData(data) |
|||
// E dispara a checagem do estado pessoal — sem poll periódico,
|
|||
// isto é a única forma de saber que algo mudou.
|
|||
this.fetchStatus() |
|||
} |
|||
} catch (e) { |
|||
console.error('Voto individual WS parse error:', e) |
|||
} |
|||
}) |
|||
|
|||
this.ws.addEventListener('close', (e) => { |
|||
if (isPermanentCloseCode(e.code)) { |
|||
this.wsStatus = 'error' |
|||
this.wsErrorMessage = permanentCloseMessage(e.code) |
|||
return |
|||
} |
|||
this.wsStatus = 'reconnecting' |
|||
this.wsErrorMessage = DISCONNECTED_MESSAGE |
|||
this.reconnectTimer = setTimeout(() => this.connectWS(), this.reconnectDelay) |
|||
this.reconnectDelay = nextBackoffDelay(this.reconnectDelay) |
|||
}) |
|||
}, |
|||
beforeDestroy () { |
|||
if (this.reconnectTimer) clearTimeout(this.reconnectTimer) |
|||
if (this.ws) this.ws.close() |
|||
} |
|||
} |
|||
}) |
|||
@ -0,0 +1,18 @@ |
|||
:root { |
|||
--bg-dark: #1c1b1b; |
|||
--text-main: #e0e0e0; |
|||
} |
|||
|
|||
body { |
|||
background-color: var(--bg-dark); |
|||
color: var(--text-main); |
|||
font-family: Verdana, 'Segoe UI', Tahoma, Geneva, sans-serif; |
|||
max-width: 100%; |
|||
overflow-x: hidden; |
|||
} |
|||
|
|||
.btn-voto { |
|||
min-width: 9rem; |
|||
min-height: 4rem; |
|||
font-size: 1.5rem; |
|||
} |
|||
@ -0,0 +1,90 @@ |
|||
<template> |
|||
<fieldset class="form-group"> |
|||
<legend>Leitura de Matéria</legend> |
|||
|
|||
<div v-if="error_message" class="alert alert-danger"> |
|||
[[ error_message ]] |
|||
</div> |
|||
|
|||
<div> |
|||
<b>Matéria:</b> [[ materia.texto ]] |
|||
<br /> |
|||
<b>Ementa:</b> [[ materia.ementa ]] |
|||
</div> |
|||
<br /> |
|||
|
|||
<div class="row"> |
|||
<div class="col-md-12"> |
|||
Observações |
|||
<textarea v-model="observacao" rows="7" class="form-control"></textarea> |
|||
</div> |
|||
</div> |
|||
|
|||
<br /> |
|||
<div class="row"> |
|||
<div class="col-md-12"> |
|||
<div class="form-group row justify-content-between"> |
|||
<a :href="cancelUrl" class="btn btn-warning">Cancelar Leitura</a> |
|||
<input type="button" value="Salvar" class="btn btn-primary" @click="onSalvar" /> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</fieldset> |
|||
</template> |
|||
|
|||
<script> |
|||
import { mapState } from 'pinia' |
|||
import { usePainelStore } from '@/__apps/painel/store/painelStore' |
|||
|
|||
export default { |
|||
name: 'LeituraMateria', |
|||
props: { |
|||
errorMessage: { |
|||
type: String, |
|||
default: '' |
|||
}, |
|||
cancelUrl: { |
|||
type: String, |
|||
default: '#' |
|||
}, |
|||
initialObservacao: { |
|||
type: String, |
|||
default: '' |
|||
}, |
|||
initialMateriaTexto: { |
|||
type: String, |
|||
default: '' |
|||
}, |
|||
initialMateriaEmenta: { |
|||
type: String, |
|||
default: '' |
|||
} |
|||
}, |
|||
data () { |
|||
return { |
|||
observacao: this.initialObservacao |
|||
} |
|||
}, |
|||
computed: { |
|||
...mapState(usePainelStore, { storeMateria: 'materia' }), |
|||
error_message () { |
|||
return this.errorMessage |
|||
}, |
|||
// O broadcast do painel só reflete a matéria em votação/leitura DEPOIS |
|||
// que a ação abre a matéria; o texto/ementa iniciais vêm sempre do |
|||
// contexto Django (a matéria já é conhecida pela URL, oid/mid). |
|||
materia () { |
|||
if (this.storeMateria && this.storeMateria.texto) return this.storeMateria |
|||
return { texto: this.initialMateriaTexto, ementa: this.initialMateriaEmenta } |
|||
} |
|||
}, |
|||
methods: { |
|||
onSalvar () { |
|||
this.$emit('salvar', { observacao: this.observacao }) |
|||
} |
|||
}, |
|||
mounted () { |
|||
console.log('LeituraMateria mounted') |
|||
} |
|||
} |
|||
</script> |
|||
@ -0,0 +1,103 @@ |
|||
<template> |
|||
<tr :id="'row_' + id" v-show="visible"> |
|||
<td :class="['fs-3', titleColorClass]">{{ title }}</td> |
|||
<td class="text-end"> |
|||
<audio ref="player" :src="audioSrc" preload="auto"></audio> |
|||
<span :id="'cronometro_' + id" |
|||
:class="['fw-bold', 'font-monospace', 'fs-1', titleColorClass]" |
|||
ref="time">{{ formatTime(time) }}</span> |
|||
</td> |
|||
</tr> |
|||
</template> |
|||
|
|||
<script> |
|||
export default { |
|||
name: 'Cronometro', |
|||
props: ['id', 'title', 'visible', 'colorClass'], |
|||
data() { |
|||
return { |
|||
time: 300, |
|||
isRunning: false, |
|||
initialTime: 300, |
|||
intervalId: null, |
|||
audioSrc: require('@/assets/audio/ring.mp3'), |
|||
} |
|||
}, |
|||
computed: { |
|||
titleColorClass() { |
|||
return this.colorClass || ''; |
|||
} |
|||
}, |
|||
mounted() { |
|||
console.log('Cronometro mounted'); |
|||
this.$emit('child-mounted'); |
|||
}, |
|||
methods: { |
|||
changeFontSize(value) { |
|||
const el = this.$refs.time; |
|||
if (!el) return; |
|||
let fontSize = window.getComputedStyle(el).fontSize; |
|||
fontSize = parseFloat(fontSize); |
|||
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 |
|||
// start() de novo com o cronômetro já rodando não deve reiniciar a |
|||
// contagem, e stop()/reset() repetidos não devem quebrar nada. |
|||
start() { |
|||
if (this.isRunning) return; |
|||
this.isRunning = true; |
|||
this.intervalId = setInterval(() => { |
|||
if (this.time > 0) { |
|||
this.time--; |
|||
if (this.time == 30) { |
|||
this.playSound(); |
|||
} |
|||
} else { |
|||
this.isRunning = false; |
|||
clearInterval(this.intervalId); |
|||
this.playSound(); |
|||
} |
|||
}, 1000); |
|||
}, |
|||
|
|||
stop() { |
|||
this.isRunning = false; |
|||
clearInterval(this.intervalId); |
|||
}, |
|||
|
|||
reset() { |
|||
this.stop(); |
|||
this.time = this.initialTime; |
|||
}, |
|||
|
|||
playSound() { |
|||
const audio = this.$refs.player |
|||
if (!audio) return |
|||
audio.play() |
|||
}, |
|||
|
|||
formatTime(seconds) { |
|||
const hrs = Math.floor(seconds / 3600); |
|||
const mins = Math.floor((seconds % 3600) / 60); |
|||
const secs = seconds % 60; |
|||
return `${hrs.toString().padStart(2, '0')}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; |
|||
} |
|||
}, |
|||
watch: { |
|||
initialTime(newVal) { |
|||
if (!this.isRunning) { |
|||
this.time = newVal; |
|||
} |
|||
} |
|||
}, |
|||
beforeDestroy() { |
|||
clearInterval(this.intervalId); |
|||
} |
|||
} |
|||
</script> |
|||
|
|||
<style scoped> |
|||
/* Add your own styles here */ |
|||
</style> |
|||
@ -0,0 +1,136 @@ |
|||
<template> |
|||
<div class="painel d-flex flex-column" v-if="canRender"> |
|||
<div id="box_cronometros" class="w-100"> |
|||
<h2 class="text-center text-subtitle mb-3">Cronômetro</h2> |
|||
<div class="d-flex align-items-center justify-content-center" style="height: 80px"> |
|||
<table class="table-custom w-100 mb-0"> |
|||
<tbody> |
|||
<Cronometro |
|||
ref="childRef_0" |
|||
id="discurso" |
|||
title="Discurso" |
|||
:visible="visibleCronometro === 'discurso'" |
|||
color-class="" |
|||
@child-mounted="handleChildMounted" |
|||
/> |
|||
<Cronometro |
|||
ref="childRef_1" |
|||
id="aparte" |
|||
title="Aparte" |
|||
:visible="visibleCronometro === 'aparte'" |
|||
color-class="text-warning" |
|||
@child-mounted="handleChildMounted" |
|||
/> |
|||
<Cronometro |
|||
ref="childRef_2" |
|||
id="ordem" |
|||
title="Questão de Ordem" |
|||
:visible="visibleCronometro === 'ordem'" |
|||
color-class="text-info" |
|||
@child-mounted="handleChildMounted" |
|||
/> |
|||
<Cronometro |
|||
ref="childRef_3" |
|||
id="consideracoes" |
|||
title="Consid. Finais" |
|||
:visible="visibleCronometro === 'consideracoes'" |
|||
color-class="" |
|||
@child-mounted="handleChildMounted" |
|||
/> |
|||
</tbody> |
|||
</table> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</template> |
|||
|
|||
<script> |
|||
import { mapState } from 'pinia'; |
|||
import { usePainelStore } from '@/__apps/painel/store/painelStore'; |
|||
import Cronometro from './Cronometro.vue'; |
|||
|
|||
export default { |
|||
name: 'CronometroList', |
|||
components: { |
|||
Cronometro, |
|||
}, |
|||
data() { |
|||
return { |
|||
visibleCronometro: 'discurso', |
|||
} |
|||
}, |
|||
mounted() { |
|||
console.log('CronometroList mounted'); |
|||
// Sincroniza o estado inicial a partir do snapshot já presente |
|||
// no store (não dá pra confiar num watcher `immediate` aqui: |
|||
// 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: { |
|||
...mapState(usePainelStore, [ |
|||
"canRender", |
|||
"cronometro_discurso", "cronometro_aparte", |
|||
"cronometro_ordem", "cronometro_consideracoes" |
|||
]) |
|||
}, |
|||
watch: { |
|||
// Só dispara em mudança de valor — o broadcast do painel chega a |
|||
// cada voto/matéria aberta, não só em ações de cronômetro; um |
|||
// toggle-em-toda-mensagem reiniciaria o cronômetro à toa. |
|||
cronometro_discurso(newVal) { this.applyCronometroAction('childRef_0', newVal); }, |
|||
cronometro_aparte(newVal) { this.applyCronometroAction('childRef_1', newVal); }, |
|||
cronometro_ordem(newVal) { this.applyCronometroAction('childRef_2', newVal); }, |
|||
cronometro_consideracoes(newVal) { this.applyCronometroAction('childRef_3', newVal); }, |
|||
}, |
|||
methods: { |
|||
applyCronometroAction(ref, action) { |
|||
const comp = this.$refs[ref]; |
|||
if (!comp) return; |
|||
if (action === 'start') { |
|||
comp.start(); |
|||
} else if (action === 'stop') { |
|||
comp.stop(); |
|||
} else if (action === 'reset') { |
|||
comp.reset(); |
|||
} |
|||
this.updateVisibility(); |
|||
}, |
|||
handleChildMounted() { |
|||
console.log('Cronometro child mounted'); |
|||
}, |
|||
updateVisibility() { |
|||
// Show priority: ordem > aparte > consideracoes > discurso |
|||
const refs = [ |
|||
{ key: 'ordem', ref: 'childRef_2' }, |
|||
{ key: 'aparte', ref: 'childRef_1' }, |
|||
{ key: 'consideracoes', ref: 'childRef_3' }, |
|||
{ key: 'discurso', ref: 'childRef_0' }, |
|||
]; |
|||
for (const item of refs) { |
|||
const comp = this.$refs[item.ref]; |
|||
if (comp && comp.isRunning) { |
|||
this.visibleCronometro = item.key; |
|||
return; |
|||
} |
|||
} |
|||
// Default to discurso if none running |
|||
this.visibleCronometro = 'discurso'; |
|||
} |
|||
}, |
|||
}; |
|||
</script> |
|||
|
|||
<style scoped> |
|||
.table-custom { |
|||
color: #ddd; |
|||
} |
|||
::v-deep .table-custom tbody td { |
|||
padding: 8px; |
|||
font-size: 1.1rem; |
|||
} |
|||
</style> |
|||
|
|||
@ -0,0 +1,64 @@ |
|||
<template> |
|||
<div class="painel position-relative bottom-0 hora-brasao"> |
|||
<div class="row justify-content-center align-items-center"> |
|||
<div class="col-4 text-center" id="logo-container" v-if="brasao"> |
|||
<img :src="brasao" id="logo-painel" class="logo-painel" alt="Brasão" /> |
|||
</div> |
|||
<div :class="['text-center', brasao ? 'col-4' : 'col-12 mt-5']" id="relogio-container"> |
|||
<span class="fs-1 fw-bold" id="relogio">{{ relogio }}</span> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</template> |
|||
|
|||
<script> |
|||
import { mapState } from 'pinia'; |
|||
import { usePainelStore } from '@/__apps/painel/store/painelStore'; |
|||
export default { |
|||
name: 'PainelFooter', |
|||
props: { |
|||
}, |
|||
|
|||
data() { |
|||
return { |
|||
data_atual: "", |
|||
relogio: "", |
|||
currentDateTimeId: null, |
|||
} |
|||
}, |
|||
|
|||
methods: { |
|||
startCurrentDateTime() { |
|||
this.data_atual = moment().utcOffset(-3).format("DD/MM/YY"); |
|||
this.currentDateTimeId = setInterval(() => { |
|||
this.relogio = moment.utc().utcOffset(-3).format("HH:mm:ss"); |
|||
}, 500); |
|||
}, |
|||
}, |
|||
beforeDestroy() { |
|||
if (this.currentDateTimeId) { |
|||
clearInterval(this.currentDateTimeId); |
|||
console.log('currentDateTimeId Interval cleared.'); |
|||
} |
|||
}, |
|||
computed: { |
|||
...mapState(usePainelStore, { |
|||
brasao: (store) => (store.sessao && store.sessao.brasao) || '', |
|||
}) |
|||
}, |
|||
mounted() { |
|||
console.log('PainelFooter component mounted'); |
|||
this.startCurrentDateTime(); |
|||
} |
|||
} |
|||
</script> |
|||
|
|||
<style scoped> |
|||
.logo-painel { |
|||
max-height: 150px; |
|||
width: auto; |
|||
} |
|||
.hora-brasao { |
|||
border-top: 1px solid #333; |
|||
} |
|||
</style> |
|||
@ -0,0 +1,36 @@ |
|||
<template> |
|||
<div> |
|||
<h1 id="sessao_plenaria" class="text-title fw-bold text-uppercase">{{ sessao_plenaria }}</h1> |
|||
<h2 class="text-danger" v-if="message"><span id="message">{{ message }}</span></h2> |
|||
</div> |
|||
</template> |
|||
|
|||
<script> |
|||
import { mapState } from 'pinia'; |
|||
import { usePainelStore } from '@/__apps/painel/store/painelStore'; |
|||
export default { |
|||
name: 'PainelHeader', |
|||
props: { |
|||
}, |
|||
|
|||
methods: { |
|||
}, |
|||
computed: { |
|||
...mapState(usePainelStore, { |
|||
sessao_aberta: 'sessao_aberta', |
|||
message: 'message', |
|||
sessao_plenaria: (store) => (store.sessao && store.sessao.sessao_plenaria) || '', |
|||
}) |
|||
}, |
|||
mounted() { |
|||
console.log('PainelHeader component mounted'); |
|||
} |
|||
} |
|||
</script> |
|||
|
|||
<style scoped> |
|||
.text-title { |
|||
color: var(--text-highlight); |
|||
letter-spacing: 1px; |
|||
} |
|||
</style> |
|||
@ -0,0 +1,46 @@ |
|||
<template> |
|||
<div class="painel p-3 d-flex flex-column" id="obs_materia_div" v-if="canRender"> |
|||
<div class="flex-grow-1"> |
|||
<h2 id="mat_em_votacao" class="text-subtitle text-center mb-3 pb-1"> |
|||
Matéria em Votação |
|||
</h2> |
|||
<span id="materia_legislativa_texto" |
|||
class="fs-4 text-white d-block mb-1">{{ materia.texto }}</span> |
|||
<span id="materia_legislativa_ementa" |
|||
class="fs-6 fst-italic opacity-75 text-white">{{ materia.ementa }}</span> |
|||
|
|||
<div class="mt-2 text-warning" style="font-size: 0.9rem"> |
|||
<span id="observacao_materia">{{ materia.observacao }}</span> |
|||
</div> |
|||
|
|||
<div id="resultado_votacao" |
|||
class="text-title mt-auto text-center d-block fs-2">{{ materia.resultado_votacao }}</div> |
|||
</div> |
|||
</div> |
|||
</template> |
|||
|
|||
<script> |
|||
import { mapState } from 'pinia'; |
|||
import { usePainelStore } from '@/__apps/painel/store/painelStore'; |
|||
export default { |
|||
name: 'PainelMateria', |
|||
data() { |
|||
return { |
|||
}; |
|||
}, |
|||
mounted() { |
|||
console.log('PainelMateria mounted'); |
|||
}, |
|||
beforeDestroy() {}, |
|||
computed: { |
|||
...mapState(usePainelStore, ["canRender", "materia"]) |
|||
} |
|||
}; |
|||
</script> |
|||
|
|||
<style scoped> |
|||
.text-title { |
|||
color: var(--text-highlight); |
|||
letter-spacing: 1px; |
|||
} |
|||
</style> |
|||
@ -0,0 +1,41 @@ |
|||
<template> |
|||
<div class="text-center painel" id="aparecer_oradores" v-if="canRender && oradores.length > 0"> |
|||
<h2 class="text-subtitle">Oradores</h2> |
|||
<div id="orador"> |
|||
<table id="oradores_list"> |
|||
<tr v-for="o in oradores" :key="o.ordem_pronunciamento"> |
|||
<td style="padding-right:20px; color:white"> |
|||
{{ o.ordem_pronunciamento }}º - {{ o.nome_parlamentar }} |
|||
</td> |
|||
</tr> |
|||
</table> |
|||
</div> |
|||
</div> |
|||
</template> |
|||
|
|||
<script> |
|||
import { mapState } from 'pinia'; |
|||
import { usePainelStore } from '@/__apps/painel/store/painelStore'; |
|||
export default { |
|||
name: 'PainelOradores', |
|||
data() { |
|||
return { |
|||
}; |
|||
}, |
|||
mounted() { |
|||
console.log('PainelOradores mounted'); |
|||
}, |
|||
beforeDestroy() {}, |
|||
computed: { |
|||
...mapState(usePainelStore, ["canRender", "oradores"]) |
|||
} |
|||
}; |
|||
</script> |
|||
|
|||
<style scoped> |
|||
#oradores_list tr td { |
|||
font-size: 1.2rem; |
|||
padding: 5px; |
|||
text-align: start; |
|||
} |
|||
</style> |
|||
@ -0,0 +1,171 @@ |
|||
<template> |
|||
<div id="parlamentares"> |
|||
<div class="container-fluid" id="parlamentares_container" v-if="canRender"> |
|||
<div class="parlamentares-grid" id="parlamentares_row"> |
|||
<div v-for="p in parlamentares" |
|||
:key="p.parlamentar_id" |
|||
:class="['parlamentar-card', 'mt-3', votoClass(p)]"> |
|||
<div class="voto-faixa" v-if="votoText(p)"> |
|||
<p>{{ votoText(p) }}</p> |
|||
</div> |
|||
|
|||
<div class="foto-container"> |
|||
<img :src="p.fotografia || defaultAvatar" :alt="p.nome_parlamentar"> |
|||
</div> |
|||
|
|||
<div class="mt-2 text-center"> |
|||
<div class="nome">{{ p.nome_parlamentar }}</div> |
|||
<div class="partido">{{ p.filiacao }}</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
<span class="text-white" v-else> |
|||
<center>A listagem de parlamentares só aparecerá quando o painel estiver aberto.</center> |
|||
</span> |
|||
</div> |
|||
</template> |
|||
|
|||
<script> |
|||
import { mapState } from 'pinia'; |
|||
import { usePainelStore } from '@/__apps/painel/store/painelStore'; |
|||
import defaultAvatarImg from '@/assets/img/avatar.png'; |
|||
|
|||
export default { |
|||
name: 'PainelParlamentares', |
|||
data() { |
|||
return { |
|||
defaultAvatar: defaultAvatarImg, |
|||
}; |
|||
}, |
|||
mounted() { |
|||
console.log('PainelParlamentares mounted'); |
|||
}, |
|||
beforeDestroy() {}, |
|||
computed: { |
|||
...mapState(usePainelStore, ["canRender", "parlamentares", "mostrar_voto"]) |
|||
}, |
|||
methods: { |
|||
votoClass(parlamentar) { |
|||
const voto = parlamentar.voto; |
|||
if (!voto) return ''; |
|||
if (this.mostrar_voto) { |
|||
if (voto === 'Sim') return 'voto-sim'; |
|||
if (voto === 'Não') return 'voto-nao'; |
|||
if (voto === 'Abstenção') return 'voto-abstencao'; |
|||
} else { |
|||
if (voto === 'Sim' || voto === 'Não' || voto === 'Abstenção' || voto === 'Voto Informado') { |
|||
return 'voto-informado'; |
|||
} |
|||
} |
|||
return ''; |
|||
}, |
|||
votoText(parlamentar) { |
|||
const voto = parlamentar.voto; |
|||
if (!voto) return ''; |
|||
if (this.mostrar_voto) { |
|||
if (voto === 'Sim') return 'S'; |
|||
if (voto === 'Não') return 'N'; |
|||
if (voto === 'Abstenção') return 'A'; |
|||
} else { |
|||
if (voto === 'Sim' || voto === 'Não' || voto === 'Abstenção' || voto === 'Voto Informado') { |
|||
return 'I'; |
|||
} |
|||
} |
|||
return ''; |
|||
}, |
|||
}, |
|||
}; |
|||
</script> |
|||
|
|||
<style scoped> |
|||
.parlamentares-grid { |
|||
display: grid; |
|||
grid-template-columns: repeat(auto-fill, 190px); |
|||
width: 100%; |
|||
} |
|||
|
|||
.parlamentar-card { |
|||
width: 150px; |
|||
border-radius: 3px; |
|||
overflow: hidden; |
|||
position: relative; |
|||
} |
|||
|
|||
.foto-container { |
|||
border-radius: 3px; |
|||
overflow: hidden; |
|||
height: 150px; |
|||
} |
|||
|
|||
.parlamentar-card img { |
|||
width: 100%; |
|||
height: 150px; |
|||
display: block; |
|||
object-fit: fill; |
|||
} |
|||
|
|||
.parlamentar-card .nome { |
|||
font-weight: bold; |
|||
font-size: 0.95rem; |
|||
color: #fff; |
|||
line-height: 1.1; |
|||
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.8); |
|||
margin-bottom: 2px; |
|||
display: -webkit-box; |
|||
-webkit-line-clamp: 2; |
|||
-webkit-box-orient: vertical; |
|||
overflow: hidden; |
|||
} |
|||
|
|||
.parlamentar-card .partido { |
|||
font-size: 0.8rem; |
|||
color: #ddd; |
|||
font-weight: 600; |
|||
text-transform: uppercase; |
|||
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.8); |
|||
} |
|||
|
|||
.voto-faixa { |
|||
position: absolute; |
|||
width: 90px; |
|||
height: 90px; |
|||
top: -45px; |
|||
right: -45px; |
|||
transform: rotate(45deg); |
|||
display: flex; |
|||
align-items: flex-end; |
|||
justify-content: center; |
|||
color: #fff; |
|||
font-weight: 700; |
|||
box-shadow: -2px 2px 5px rgba(0, 0, 0, 0.2); |
|||
z-index: 10; |
|||
line-height: 1; |
|||
} |
|||
|
|||
.voto-faixa p { |
|||
transform: rotate(-45deg); |
|||
font-size: 2rem; |
|||
text-transform: uppercase; |
|||
} |
|||
|
|||
.voto-sim .voto-faixa { |
|||
background-color: #27ae60; |
|||
} |
|||
|
|||
.voto-nao .voto-faixa { |
|||
background-color: #c0392b; |
|||
} |
|||
|
|||
.voto-abstencao .voto-faixa { |
|||
background-color: #f39c12; |
|||
} |
|||
|
|||
.voto-informado .voto-faixa { |
|||
background-color: #d35400; |
|||
} |
|||
|
|||
.voto-ausente .voto-faixa { |
|||
background-color: #7f8c8d; |
|||
} |
|||
</style> |
|||
@ -0,0 +1,106 @@ |
|||
<template> |
|||
<div class="painel p-3 d-flex flex-column" id="resultado_votacao_div" v-if="canRender"> |
|||
<div id="box_votacao" class="w-100"> |
|||
<table class="table-custom w-100 mb-0" id="tabela_resultados"> |
|||
<tbody id="votacao"> |
|||
<tr> |
|||
<td>Presentes</td> |
|||
<td>{{ numPresentes }}</td> |
|||
</tr> |
|||
<tr> |
|||
<td>Sim</td> |
|||
<td class="table-sim">{{ votosSim }}</td> |
|||
</tr> |
|||
<tr> |
|||
<td>Não</td> |
|||
<td class="table-nao">{{ votosNao }}</td> |
|||
</tr> |
|||
<tr> |
|||
<td>Abstenções</td> |
|||
<td class="table-abstencao">{{ votosAbstencao }}</td> |
|||
</tr> |
|||
<tr> |
|||
<td><strong>Total votos</strong></td> |
|||
<td class="table-total"><strong>{{ totalVotos }}</strong></td> |
|||
</tr> |
|||
</tbody> |
|||
</table> |
|||
</div> |
|||
</div> |
|||
</template> |
|||
|
|||
<script> |
|||
import { mapState } from 'pinia'; |
|||
import { usePainelStore } from '@/__apps/painel/store/painelStore'; |
|||
export default { |
|||
name: 'PainelResultado', |
|||
data() { |
|||
return { |
|||
}; |
|||
}, |
|||
mounted() { |
|||
console.log('PainelResultado mounted'); |
|||
}, |
|||
beforeDestroy() {}, |
|||
computed: { |
|||
...mapState(usePainelStore, ["canRender", "resultado", "parlamentares"]), |
|||
numPresentes() { |
|||
if (this.resultado && this.resultado.numero_votos && typeof this.resultado.numero_votos.num_presentes !== 'undefined' && this.resultado.numero_votos.num_presentes !== null) { |
|||
return this.resultado.numero_votos.num_presentes; |
|||
} |
|||
return this.parlamentares ? this.parlamentares.length : 0; |
|||
}, |
|||
votosSim() { |
|||
if (this.resultado && this.resultado.numero_votos && typeof this.resultado.numero_votos.votos_sim !== 'undefined' && this.resultado.numero_votos.votos_sim !== null && this.resultado.numero_votos.votos_sim > 0) { |
|||
return this.resultado.numero_votos.votos_sim; |
|||
} |
|||
return this.parlamentares ? this.parlamentares.filter(p => p.voto === 'Sim').length : 0; |
|||
}, |
|||
votosNao() { |
|||
if (this.resultado && this.resultado.numero_votos && typeof this.resultado.numero_votos.votos_nao !== 'undefined' && this.resultado.numero_votos.votos_nao !== null && this.resultado.numero_votos.votos_nao > 0) { |
|||
return this.resultado.numero_votos.votos_nao; |
|||
} |
|||
return this.parlamentares ? this.parlamentares.filter(p => p.voto === 'Não').length : 0; |
|||
}, |
|||
votosAbstencao() { |
|||
if (this.resultado && this.resultado.numero_votos && typeof this.resultado.numero_votos.abstencoes !== 'undefined' && this.resultado.numero_votos.abstencoes !== null && this.resultado.numero_votos.abstencoes > 0) { |
|||
return this.resultado.numero_votos.abstencoes; |
|||
} |
|||
return this.parlamentares ? this.parlamentares.filter(p => p.voto === 'Abstenção').length : 0; |
|||
}, |
|||
totalVotos() { |
|||
if (this.resultado && this.resultado.numero_votos && typeof this.resultado.numero_votos.total_votos !== 'undefined' && this.resultado.numero_votos.total_votos !== null && this.resultado.numero_votos.total_votos > 0) { |
|||
return this.resultado.numero_votos.total_votos; |
|||
} |
|||
return this.votosSim + this.votosNao + this.votosAbstencao; |
|||
} |
|||
}, |
|||
}; |
|||
</script> |
|||
|
|||
<style scoped> |
|||
.table-custom { |
|||
color: #ddd; |
|||
} |
|||
|
|||
.table-custom tbody td { |
|||
padding: 8px; |
|||
font-size: 1.1rem; |
|||
} |
|||
|
|||
.table-sim { |
|||
color: #27ae60; |
|||
} |
|||
|
|||
.table-nao { |
|||
color: #c0392b; |
|||
} |
|||
|
|||
.table-abstencao { |
|||
color: #f39c12; |
|||
} |
|||
|
|||
.table-total { |
|||
color: #194BFA; |
|||
} |
|||
</style> |
|||
@ -0,0 +1,41 @@ |
|||
<template> |
|||
<div v-if="status !== 'open'" class="ws-status-banner" :class="bannerClass"> |
|||
{{ message }} |
|||
</div> |
|||
</template> |
|||
|
|||
<script> |
|||
export default { |
|||
name: 'WsStatusBanner', |
|||
props: { |
|||
// 'connecting' | 'open' | 'reconnecting' | 'error' |
|||
status: { type: String, default: 'connecting' }, |
|||
message: { type: String, default: 'Conectando ao servidor de tempo real…' } |
|||
}, |
|||
computed: { |
|||
bannerClass () { |
|||
return this.status === 'error' ? 'ws-status-banner--error' : 'ws-status-banner--warning' |
|||
} |
|||
} |
|||
} |
|||
</script> |
|||
|
|||
<style scoped> |
|||
.ws-status-banner { |
|||
position: fixed; |
|||
top: 0; |
|||
left: 0; |
|||
right: 0; |
|||
z-index: 2000; |
|||
padding: 10px 16px; |
|||
text-align: center; |
|||
font-weight: bold; |
|||
color: #fff; |
|||
} |
|||
.ws-status-banner--warning { |
|||
background: #b8860b; |
|||
} |
|||
.ws-status-banner--error { |
|||
background: #b00020; |
|||
} |
|||
</style> |
|||
@ -0,0 +1,128 @@ |
|||
<template> |
|||
<fieldset class="form-group"> |
|||
<legend>Votação Simbólica</legend> |
|||
|
|||
<div v-if="error_message || localError" class="alert alert-danger"> |
|||
[[ error_message || localError ]] |
|||
</div> |
|||
|
|||
<div> |
|||
<b>Matéria:</b> [[ materia.texto ]] |
|||
<br /> |
|||
<b>Ementa:</b> [[ materia.ementa ]] |
|||
<br /><br /> |
|||
<b>Total presentes:</b> [[ totalPresentes ]] (com presidente) |
|||
<br /> |
|||
<b>Total votantes:</b> [[ totalVotantes ]] (com presidente) |
|||
</div> |
|||
<br /> |
|||
|
|||
<div class="row"> |
|||
<div class="col-md-4">Sim*: |
|||
<input type="number" min="0" v-model.number="votosSim" class="form-control" /> |
|||
</div> |
|||
<div class="col-md-4">Não*: |
|||
<input type="number" min="0" v-model.number="votosNao" class="form-control" /> |
|||
</div> |
|||
<div class="col-md-4">Abstenções*: |
|||
<input type="number" min="0" v-model.number="abstencoes" class="form-control" /> |
|||
</div> |
|||
</div> |
|||
|
|||
<div class="row mt-3"> |
|||
<div class="col-md-6"> |
|||
A totalização inclui o voto do Presidente?* |
|||
<select v-model="votoPresidente" class="form-control"> |
|||
<option :value="1">Sim</option> |
|||
<option :value="0">Não</option> |
|||
</select> |
|||
</div> |
|||
<div class="col-md-6"> |
|||
Resultado da Votação* |
|||
<select v-model="resultadoSelected" class="form-control"> |
|||
<option value="">---------</option> |
|||
<option v-for="tipo in tipos_resultado" :key="tipo.id" :value="tipo.id"> |
|||
[[ tipo.nome ]] |
|||
</option> |
|||
</select> |
|||
</div> |
|||
</div> |
|||
|
|||
<div class="row mt-3"> |
|||
<div class="col-md-12"> |
|||
Observações |
|||
<textarea v-model="observacao" rows="7" class="form-control"></textarea> |
|||
</div> |
|||
</div> |
|||
|
|||
<br /> |
|||
<div class="row"> |
|||
<div class="col-md-12"> |
|||
<div class="form-group row justify-content-between"> |
|||
<input type="button" value="Cancelar Votação" class="btn btn-warning" @click="$emit('cancelar')" /> |
|||
<input type="button" value="Salvar" class="btn btn-primary" @click="onSalvar" /> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</fieldset> |
|||
</template> |
|||
|
|||
<script> |
|||
import { mapState } from 'pinia' |
|||
import { usePainelStore } from '@/__apps/painel/store/painelStore' |
|||
|
|||
export default { |
|||
name: 'VotacaoSimbolica', |
|||
props: { |
|||
errorMessage: { |
|||
type: String, |
|||
default: '' |
|||
}, |
|||
totalPresentes: { |
|||
type: [String, Number], |
|||
default: 0 |
|||
}, |
|||
totalVotantes: { |
|||
type: [String, Number], |
|||
default: 0 |
|||
} |
|||
}, |
|||
data () { |
|||
return { |
|||
votosSim: 0, |
|||
votosNao: 0, |
|||
abstencoes: 0, |
|||
votoPresidente: 0, |
|||
resultadoSelected: '', |
|||
observacao: '', |
|||
localError: '' |
|||
} |
|||
}, |
|||
computed: { |
|||
...mapState(usePainelStore, ['materia', 'tipos_resultado']), |
|||
error_message () { |
|||
return this.errorMessage |
|||
} |
|||
}, |
|||
methods: { |
|||
onSalvar () { |
|||
if (!this.resultadoSelected) { |
|||
this.localError = 'Selecione o resultado da votação antes de salvar.' |
|||
return |
|||
} |
|||
this.localError = '' |
|||
this.$emit('salvar', { |
|||
votos_sim: this.votosSim, |
|||
votos_nao: this.votosNao, |
|||
abstencoes: this.abstencoes, |
|||
voto_presidente: this.votoPresidente, |
|||
resultado_votacao: this.resultadoSelected, |
|||
observacao: this.observacao |
|||
}) |
|||
} |
|||
}, |
|||
mounted () { |
|||
console.log('VotacaoSimbolica mounted') |
|||
} |
|||
} |
|||
</script> |
|||
@ -0,0 +1,26 @@ |
|||
<template> |
|||
<div v-if="materia && materia.texto"> |
|||
<div> |
|||
Matéria: {{ materia.texto }} |
|||
<br /> |
|||
Ementa: {{ materia.ementa }} |
|||
</div> |
|||
</div> |
|||
<div v-else class="alert alert-info"> |
|||
Nenhuma matéria aberta para votação. |
|||
</div> |
|||
</template> |
|||
|
|||
<script> |
|||
import { mapState } from 'pinia'; |
|||
import { usePainelStore } from '@/__apps/painel/store/painelStore'; |
|||
export default { |
|||
name: 'VotacaoMateria', |
|||
computed: { |
|||
...mapState(usePainelStore, ['materia']) |
|||
}, |
|||
mounted() { |
|||
console.log('VotacaoMateria mounted'); |
|||
} |
|||
}; |
|||
</script> |
|||
@ -0,0 +1,78 @@ |
|||
<template> |
|||
<fieldset> |
|||
<legend>Votação Nominal</legend> |
|||
|
|||
<div v-if="error_message" class="alert alert-danger"> |
|||
{{ error_message }} |
|||
</div> |
|||
|
|||
<votacao-materia></votacao-materia> |
|||
<br /> |
|||
|
|||
<votacao-votos :votos-status="votosStatus" @cast-vote="onCastVote" ref="votos"></votacao-votos> |
|||
|
|||
<votacao-resultado ref="resultado"></votacao-resultado> |
|||
|
|||
<votacao-observacoes |
|||
:observacoes.sync="observacoes" |
|||
:resultado-selected.sync="resultado_selected" |
|||
:disabled="disable_resultado" |
|||
@cancelar="onCancelar" |
|||
@fechar="onFechar" |
|||
ref="observacoesComp"> |
|||
</votacao-observacoes> |
|||
</fieldset> |
|||
</template> |
|||
|
|||
<script> |
|||
import { mapState } from 'pinia'; |
|||
import { usePainelStore } from '@/__apps/painel/store/painelStore'; |
|||
export default { |
|||
name: 'VotacaoNominal', |
|||
props: { |
|||
isOpen: { |
|||
type: Boolean, |
|||
default: false |
|||
}, |
|||
errorMessage: { |
|||
type: String, |
|||
default: '' |
|||
}, |
|||
votosStatus: { |
|||
type: Object, |
|||
default: () => ({}) |
|||
} |
|||
}, |
|||
data() { |
|||
return { |
|||
edit_votes: true, |
|||
disable_resultado: false, |
|||
resultado_selected: '', |
|||
observacoes: '', |
|||
} |
|||
}, |
|||
computed: { |
|||
...mapState(usePainelStore, ['parlamentares', 'materia']), |
|||
error_message() { |
|||
return this.errorMessage; |
|||
} |
|||
}, |
|||
methods: { |
|||
onCastVote({ parlamentar_id, voto }) { |
|||
this.$emit('cast-vote', { parlamentar_id, voto }); |
|||
}, |
|||
onCancelar() { |
|||
this.$emit('cancelar'); |
|||
}, |
|||
onFechar() { |
|||
this.$emit('fechar', { |
|||
resultado_selected: this.resultado_selected, |
|||
observacoes: this.observacoes |
|||
}); |
|||
} |
|||
}, |
|||
mounted() { |
|||
console.log('VotacaoNominal mounted'); |
|||
} |
|||
}; |
|||
</script> |
|||
@ -0,0 +1,85 @@ |
|||
<template> |
|||
<div v-if="parlamentares && parlamentares.length > 0"> |
|||
<div class="row"> |
|||
<div class="col-md-12"> |
|||
<label for="resultado_votacao"><strong>Resultado da Votação</strong></label> |
|||
<select v-model="resultadoSelecionado" id="resultado_votacao" class="select form-control" :disabled="disabled"> |
|||
<option value="">---------</option> |
|||
<option v-for="tipo in tipos_resultado" |
|||
:key="tipo.id" |
|||
:value="tipo.id"> |
|||
{{ tipo.nome }} |
|||
</option> |
|||
</select> |
|||
</div> |
|||
</div> |
|||
|
|||
<br /> |
|||
<div class="row"> |
|||
<div class="col-md-12"> |
|||
Observações<br/> |
|||
<textarea id="observacao" v-model="textoObservacoes" style="width:100%;" rows="7"></textarea> |
|||
</div> |
|||
</div> |
|||
|
|||
<br /><br /> |
|||
<div class="row"> |
|||
<div class="col-md-12"> |
|||
<div class="form-group row justify-content-between"> |
|||
<input type="button" id="cancelar-votacao" value="Cancelar Votação" |
|||
class="btn btn-warning" @click="$emit('cancelar')" /> |
|||
<input type="button" id="salvar-votacao" value="Fechar Votação" |
|||
class="btn btn-primary" @click="$emit('fechar')" /> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</template> |
|||
|
|||
<script> |
|||
import { mapState } from 'pinia'; |
|||
import { usePainelStore } from '@/__apps/painel/store/painelStore'; |
|||
export default { |
|||
name: 'VotacaoObservacoes', |
|||
props: { |
|||
disabled: { |
|||
type: Boolean, |
|||
default: false |
|||
}, |
|||
observacoes: { |
|||
type: String, |
|||
default: '' |
|||
}, |
|||
resultadoSelected: { |
|||
type: [String, Number], |
|||
default: '' |
|||
} |
|||
}, |
|||
data() { |
|||
return { |
|||
textoObservacoes: this.observacoes, |
|||
resultadoSelecionado: this.resultadoSelected, |
|||
} |
|||
}, |
|||
watch: { |
|||
observacoes(val) { |
|||
this.textoObservacoes = val; |
|||
}, |
|||
textoObservacoes(val) { |
|||
this.$emit('update:observacoes', val); |
|||
}, |
|||
resultadoSelected(val) { |
|||
this.resultadoSelecionado = val; |
|||
}, |
|||
resultadoSelecionado(val) { |
|||
this.$emit('update:resultadoSelected', val); |
|||
} |
|||
}, |
|||
computed: { |
|||
...mapState(usePainelStore, ['parlamentares', 'tipos_resultado']) |
|||
}, |
|||
mounted() { |
|||
console.log('VotacaoObservacoes mounted'); |
|||
} |
|||
}; |
|||
</script> |
|||
@ -0,0 +1,49 @@ |
|||
<template> |
|||
<div v-if="parlamentares && parlamentares.length > 0"> |
|||
<legend>Situação da Votação:</legend> |
|||
<div id="soma_votos"> |
|||
<div class="row"> |
|||
<div class="col-md-12">Sim: {{ votosSim }}</div> |
|||
</div> |
|||
<div class="row"> |
|||
<div class="col-md-12">Não: {{ votosNao }}</div> |
|||
</div> |
|||
<div class="row"> |
|||
<div class="col-md-12">Abstenções: {{ votosAbstencao }}</div> |
|||
</div> |
|||
<div class="row"> |
|||
<div class="col-md-12">Ainda não votaram: {{ naoVotou }}</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</template> |
|||
|
|||
<script> |
|||
import { mapState } from 'pinia'; |
|||
import { usePainelStore } from '@/__apps/painel/store/painelStore'; |
|||
export default { |
|||
name: 'VotacaoResultado', |
|||
computed: { |
|||
...mapState(usePainelStore, ['parlamentares', 'totalVotos']), |
|||
votosSim() { |
|||
const entry = this.totalVotos.find(t => t.tipo === 'Sim'); |
|||
return entry ? entry.total : 0; |
|||
}, |
|||
votosNao() { |
|||
const entry = this.totalVotos.find(t => t.tipo === 'Não'); |
|||
return entry ? entry.total : 0; |
|||
}, |
|||
votosAbstencao() { |
|||
const entry = this.totalVotos.find(t => t.tipo === 'Abstenção'); |
|||
return entry ? entry.total : 0; |
|||
}, |
|||
naoVotou() { |
|||
const entry = this.totalVotos.find(t => t.tipo === 'Não Votou'); |
|||
return entry ? entry.total : 0; |
|||
} |
|||
}, |
|||
mounted() { |
|||
console.log('VotacaoResultado mounted'); |
|||
} |
|||
}; |
|||
</script> |
|||
@ -0,0 +1,53 @@ |
|||
<template> |
|||
<fieldset class="form-group" v-if="parlamentares && parlamentares.length > 0"> |
|||
<legend>Votos</legend> |
|||
<div class="row"> |
|||
<template v-for="p in parlamentares"> |
|||
<div class="col-md-4" id="styleparlamentar" :key="'nome_' + p.parlamentar_id"> |
|||
{{ p.nome_parlamentar }} |
|||
</div> |
|||
<div class="col-md-5" :key="'voto_' + p.parlamentar_id"> |
|||
<select class="form-control" |
|||
:name="'voto_parlamentar_' + p.parlamentar_id" |
|||
:value="votosStatus[p.parlamentar_id] || 'Não Votou'" |
|||
@change="onVoteChange(p.parlamentar_id, $event.target.value)"> |
|||
<option value="Não Votou">Não Votou</option> |
|||
<option value="Sim">Sim</option> |
|||
<option value="Não">Não</option> |
|||
<option value="Abstenção">Abstenção</option> |
|||
</select> |
|||
</div> |
|||
</template> |
|||
</div> |
|||
</fieldset> |
|||
<div class="alert alert-info alert-dismissible" role="alert" v-else> |
|||
<div>Não existe nenhum parlamentar presente para que a votação ocorra.</div> |
|||
</div> |
|||
</template> |
|||
|
|||
<script> |
|||
import { mapState } from 'pinia'; |
|||
import { usePainelStore } from '@/__apps/painel/store/painelStore'; |
|||
export default { |
|||
name: 'VotacaoVotos', |
|||
props: { |
|||
// Estado real (não mascarado por mostrar_voto) de quem já votou, por |
|||
// parlamentar_id — nunca vem do store (visão pública do painel). |
|||
votosStatus: { |
|||
type: Object, |
|||
default: () => ({}) |
|||
} |
|||
}, |
|||
computed: { |
|||
...mapState(usePainelStore, ['parlamentares']) |
|||
}, |
|||
methods: { |
|||
onVoteChange(parlamentar_id, voto) { |
|||
this.$emit('cast-vote', { parlamentar_id, voto }); |
|||
} |
|||
}, |
|||
mounted() { |
|||
console.log('VotacaoVotos mounted'); |
|||
} |
|||
}; |
|||
</script> |
|||
@ -0,0 +1,83 @@ |
|||
<template> |
|||
<div class="container-fluid text-center py-4"> |
|||
<template v-if="!errorMessage"> |
|||
<h1 class="mb-3">[[ sessaoPlenaria ]]</h1> |
|||
|
|||
<h2 class="mb-3"> |
|||
Voto: |
|||
<span :class="votoColorClass">[[ votoParlamentar ]]</span> |
|||
</h2> |
|||
<p v-if="statusMessage" class="text-info">[[ statusMessage ]]</p> |
|||
|
|||
<div class="my-4"> |
|||
<h3 class="mb-2">Matéria em Votação</h3> |
|||
<div class="fs-4">[[ materia.texto ]]</div> |
|||
<div class="fs-6 fst-italic">[[ materia.ementa ]]</div> |
|||
</div> |
|||
|
|||
<form method="POST" :action="voteActionUrl"> |
|||
<input type="hidden" name="csrfmiddlewaretoken" :value="csrfToken"> |
|||
<div class="d-flex justify-content-center gap-3 flex-wrap"> |
|||
<button type="submit" name="voto" value="Sim" class="btn btn-lg btn-success btn-voto">Sim</button> |
|||
<button type="submit" name="voto" value="Não" class="btn btn-lg btn-danger btn-voto">Não</button> |
|||
<button type="submit" name="voto" value="Abstenção" class="btn btn-lg btn-secondary btn-voto">Abstenção</button> |
|||
</div> |
|||
</form> |
|||
</template> |
|||
|
|||
<template v-else> |
|||
<h1 class="my-5">⏸</h1> |
|||
<h2 class="text-warning">[[ errorMessage ]]</h2> |
|||
</template> |
|||
|
|||
<div class="mt-5"> |
|||
<button type="button" class="btn btn-lg btn-primary me-2" @click="reload">Atualizar</button> |
|||
<button type="button" class="btn btn-lg btn-secondary" @click="closeWindow">Sair</button> |
|||
</div> |
|||
</div> |
|||
</template> |
|||
|
|||
<script> |
|||
import { mapState } from 'pinia' |
|||
import { usePainelStore } from '@/__apps/painel/store/painelStore' |
|||
|
|||
export default { |
|||
name: 'VotoIndividual', |
|||
data () { |
|||
return { |
|||
materiaId: '', |
|||
errorMessage: '', |
|||
statusMessage: '', |
|||
votoParlamentar: '', |
|||
csrfToken: '' |
|||
} |
|||
}, |
|||
computed: { |
|||
...mapState(usePainelStore, ['materia', 'sessao']), |
|||
sessaoPlenaria () { |
|||
return this.sessao && this.sessao.sessao_plenaria |
|||
}, |
|||
voteActionUrl () { |
|||
return window.VOTO_INDIVIDUAL_URL |
|||
}, |
|||
votoColorClass () { |
|||
if (this.votoParlamentar === 'Sim') return 'text-success' |
|||
if (this.votoParlamentar === 'Não') return 'text-danger' |
|||
if (this.votoParlamentar === 'Abstenção') return 'text-white' |
|||
return '' |
|||
} |
|||
}, |
|||
mounted () { |
|||
const el = document.getElementById('csrf-token') |
|||
this.csrfToken = el ? el.value : '' |
|||
}, |
|||
methods: { |
|||
reload () { |
|||
document.location.reload() |
|||
}, |
|||
closeWindow () { |
|||
window.close() |
|||
} |
|||
} |
|||
} |
|||
</script> |
|||
File diff suppressed because it is too large
@ -0,0 +1,20 @@ |
|||
import os |
|||
|
|||
import django |
|||
|
|||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "sapl.settings") |
|||
django.setup() |
|||
|
|||
from channels.auth import AuthMiddlewareStack # noqa: E402 |
|||
from channels.http import AsgiHandler # noqa: E402 |
|||
from channels.routing import ProtocolTypeRouter, URLRouter # noqa: E402 |
|||
from django.urls import path # noqa: E402 |
|||
|
|||
from sapl.painel.consumers import PainelConsumer # noqa: E402 |
|||
|
|||
application = ProtocolTypeRouter({ |
|||
"http": AsgiHandler(), |
|||
"websocket": AuthMiddlewareStack(URLRouter([ |
|||
path("ws/painel/<int:sessao_id>/", PainelConsumer.as_asgi()), |
|||
])), |
|||
}) |
|||
@ -0,0 +1,54 @@ |
|||
|
|||
# Websockets |
|||
|
|||
Rodar o container antes de iniciar o SAPL: |
|||
|
|||
```commandline |
|||
sudo docker run --rm -p 6379:6379 redis:7-alpine redis-server --save "" --appendonly no |
|||
``` |
|||
|
|||
Executar o SAPL |
|||
|
|||
Instalar dependências do Websockets e Redis: |
|||
|
|||
```commandline |
|||
pip install -r requirements/dev-requirements.txt |
|||
``` |
|||
|
|||
Abrir um terminal e rodar `yarn` para servir as páginas VueJS: |
|||
|
|||
```commandline |
|||
yarn serve |
|||
``` |
|||
|
|||
Executar o SAPL (duas formas): |
|||
|
|||
DAPHNE: |
|||
|
|||
Em outro terminal, no diretório raiz, execute como Daphne abaixo: |
|||
```commandline |
|||
daphne -b 127.0.0.1 -p 8000 sapl.asgi:application |
|||
``` |
|||
|
|||
Daphne é excelente para debugar a parte de WebSockets, pois contém melhores mensagens de erro e log. |
|||
O runserver geralmente só vai dar crash ou falhar ao enviar as mensagens via WebSocket. |
|||
|
|||
**MAS atenção: Daphne não faz reload automático após mudanças na página!** |
|||
|
|||
Para isso é que parar e reiniciar o Daphne ou usar `.manage.py runserver` |
|||
|
|||
|
|||
RUNSERVER: |
|||
Em outro terminal, no diretório raiz, execute como Daphne abaixo para debugar os websockets (melhores mensagens de log) |
|||
```commandline |
|||
./manage.py runserver |
|||
``` |
|||
|
|||
Logar no SAPL e acessar a página `http://localhost:8000/painel/v2` |
|||
|
|||
|
|||
Ferramentas: |
|||
|
|||
`wscat`: permite fazer chamadas a websockets via CLI, mas para acessar o WS endpoint do painel precisa estar autenticado. |
|||
|
|||
**Redis Insight:** GUI que é tipo um pgAdmin para o Redis; |
|||
@ -0,0 +1,101 @@ |
|||
import logging |
|||
|
|||
from channels.db import database_sync_to_async |
|||
from channels.generic.websocket import AsyncJsonWebsocketConsumer |
|||
|
|||
from sapl.painel.apps import AppConfig as PainelAppConfig |
|||
from sapl.sessao.models import SessaoPlenaria |
|||
|
|||
logger = logging.getLogger(__name__) |
|||
|
|||
|
|||
class _ScopeRequest: |
|||
""" |
|||
Adaptador mínimo para reaproveitar build_dados_painel(request, pk) — |
|||
que lê request.user/request.session para o cronômetro — a partir do |
|||
scope de uma conexão WebSocket, que não é um HttpRequest de verdade. |
|||
AuthMiddlewareStack (usado em sapl/asgi.py) já popula scope['user'] e |
|||
scope['session'] a partir do cookie de sessão, então isso é só leitura, |
|||
igual ao que get_cronometro_status já fazia. |
|||
""" |
|||
|
|||
def __init__(self, scope): |
|||
self.user = scope['user'] |
|||
self.session = scope['session'] |
|||
|
|||
|
|||
class PainelConsumer(AsyncJsonWebsocketConsumer): |
|||
""" |
|||
Um grupo por SessaoPlenaria (`sessao_<id>`) — painel público, tela de |
|||
voto individual (tablet) e tela de registro do operador conectam todos |
|||
aqui e recebem o mesmo payload completo (broadcast de estado inteiro, |
|||
não deltas — ver Parte 3 do plano de migração polling->push). |
|||
""" |
|||
|
|||
async def connect(self): |
|||
self.sessao_id = self.scope['url_route']['kwargs']['sessao_id'] |
|||
user = self.scope['user'] |
|||
|
|||
if not user.is_authenticated: |
|||
await self.close(code=4401) |
|||
return |
|||
|
|||
if not await self._user_pode_conectar(user): |
|||
await self.close(code=4403) |
|||
return |
|||
|
|||
if not await self._sessao_existe(self.sessao_id): |
|||
await self.close(code=4404) |
|||
return |
|||
|
|||
self.group_name = 'sessao_{}'.format(self.sessao_id) |
|||
await self.channel_layer.group_add(self.group_name, self.channel_name) |
|||
await self.accept() |
|||
|
|||
payload = await self._build_dados_painel(self.sessao_id) |
|||
await self.send_json({'type': 'data', 'payload': payload}) |
|||
|
|||
async def disconnect(self, close_code): |
|||
if hasattr(self, 'group_name'): |
|||
await self.channel_layer.group_discard(self.group_name, self.channel_name) |
|||
|
|||
async def receive_json(self, content, **kwargs): |
|||
if content.get('type') == 'ping': |
|||
await self.send_json({'type': 'pong'}) |
|||
|
|||
async def data(self, event): |
|||
await self.send_json({'type': 'data', 'payload': event['payload']}) |
|||
|
|||
@database_sync_to_async |
|||
def _build_dados_painel(self, sessao_id): |
|||
# import local para evitar ciclo de import no carregamento do ASGI |
|||
# (sapl.painel.views importa vários modelos/decorators de request |
|||
# HTTP que não precisam existir antes do Django estar totalmente |
|||
# pronto, o que já é garantido aqui por database_sync_to_async |
|||
# rodar depois de django.setup() em sapl/asgi.py). |
|||
from sapl.painel.views import build_dados_painel |
|||
return build_dados_painel(_ScopeRequest(self.scope), sessao_id) |
|||
|
|||
@database_sync_to_async |
|||
def _user_pode_conectar(self, user): |
|||
""" |
|||
Aceita qualquer usuário que já teria acesso a alguma das três telas |
|||
que este consumer substitui — não há um único modelo de permissão, |
|||
já que painel público, vereador votando e operador registrando têm |
|||
gates diferentes hoje: check_permission do módulo painel (usado por |
|||
get_dados_painel), parlamentares.can_vote (usado por votante_view), |
|||
e permissão do módulo sessao (usado por SessaoPermissionMixin na |
|||
tela de registro do operador). Substitui o stub `return True` da |
|||
referência em feat/painel-votacao-v2. |
|||
""" |
|||
if user.has_module_perms(PainelAppConfig.label): |
|||
return True |
|||
if user.has_perm('parlamentares.can_vote'): |
|||
return True |
|||
if user.has_module_perms('sessao'): |
|||
return True |
|||
return False |
|||
|
|||
@database_sync_to_async |
|||
def _sessao_existe(self, sessao_id): |
|||
return SessaoPlenaria.objects.filter(id=sessao_id).exists() |
|||
@ -0,0 +1,253 @@ |
|||
""" |
|||
Prova que as views que alteram voto/matéria (Parte 3 do plano) realmente |
|||
disparam um broadcast que chega no grupo WebSocket certo — complementa |
|||
test_consumers.py (que testa o consumer isolado) e os testes em |
|||
sapl/painel/tests/tests.py que já confirmam a chamada não quebra a view |
|||
quando o layer não está disponível (Redis fora do ar). |
|||
""" |
|||
from datetime import timedelta |
|||
|
|||
import pytest |
|||
from asgiref.sync import sync_to_async |
|||
from channels.testing import WebsocketCommunicator |
|||
from django.contrib.auth import get_user_model |
|||
from django.test import Client |
|||
from django.urls import reverse |
|||
from model_bakery import baker |
|||
|
|||
from sapl.base.models import AppConfig as ConfiguracoesAplicacao |
|||
from sapl.painel.consumers import PainelConsumer |
|||
from sapl.painel.tests.tests import (NOMINAL, _materia, _ordem_nominal_aberta, |
|||
_presente_com_mandato, _registrar_url, |
|||
_votante_com_client) |
|||
from sapl.parlamentares.models import Mandato, Parlamentar |
|||
from sapl.sessao.models import Orador, OrdemDia, PresencaOrdemDia, TipoResultadoVotacao |
|||
|
|||
IN_MEMORY_LAYER = { |
|||
'default': {'BACKEND': 'channels.layers.InMemoryChannelLayer'}, |
|||
} |
|||
|
|||
|
|||
@pytest.fixture(autouse=True) |
|||
def in_memory_channel_layer(settings): |
|||
settings.CHANNEL_LAYERS = IN_MEMORY_LAYER |
|||
|
|||
|
|||
@pytest.fixture(autouse=True) |
|||
def locmem_cache(settings): |
|||
# cronômetro (último trigger point) vive no cache padrão do projeto, |
|||
# que é file-based e persiste em disco entre runs — isola aqui do |
|||
# mesmo jeito que o layer acima isola o Redis real. |
|||
settings.CACHES = { |
|||
'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}, |
|||
} |
|||
|
|||
|
|||
@sync_to_async |
|||
def _setup_ordem_aberta(): |
|||
baker.make(ConfiguracoesAplicacao, mostrar_voto=True, mostrar_brasao_painel=False) |
|||
return _ordem_nominal_aberta() |
|||
|
|||
|
|||
@sync_to_async |
|||
def _votante(sessao): |
|||
parlamentar, client = _votante_com_client(sessao) |
|||
# get_presentes() (Parte 1) só inclui quem tem Mandato pra legislatura |
|||
# da sessão — _votante_com_client() não cria um, então sem isso o |
|||
# parlamentar nunca apareceria em `presentes`, com ou sem WebSockets. |
|||
baker.make(Mandato, parlamentar=parlamentar, legislatura=sessao.legislatura, |
|||
data_inicio_mandato=sessao.data_inicio) |
|||
return parlamentar, client |
|||
|
|||
|
|||
@sync_to_async |
|||
def _admin_client_login(): |
|||
User = get_user_model() |
|||
user = User.objects.filter(is_superuser=True).first() |
|||
if not user: |
|||
user = User.objects.create_superuser('admin-ws', 'admin@x.com', 'x') |
|||
client = Client() |
|||
client.force_login(user) |
|||
return client, user |
|||
|
|||
|
|||
async def _painel_communicator(sessao_id, admin_user): |
|||
communicator = WebsocketCommunicator( |
|||
PainelConsumer.as_asgi(), '/ws/painel/{}/'.format(sessao_id)) |
|||
communicator.scope['user'] = admin_user |
|||
communicator.scope['session'] = {} |
|||
communicator.scope['url_route'] = {'kwargs': {'sessao_id': sessao_id}} |
|||
connected, _sub = await communicator.connect() |
|||
assert connected |
|||
await communicator.receive_json_from() # payload inicial, descarta |
|||
return communicator |
|||
|
|||
|
|||
@pytest.mark.django_db(transaction=False) |
|||
@pytest.mark.asyncio |
|||
async def test_voto_do_tablet_dispara_broadcast_com_voto_atualizado(): |
|||
# Limpeza explícita no final, em vez de confiar no rollback atômico do |
|||
# pytest-django: channels.db.database_sync_to_async despacha pro |
|||
# worker thread "thread sensitive" do asgiref, que pega sua própria |
|||
# conexão nova com o banco (autocommit) — fora da savepoint que o |
|||
# fixture `django_db` abriu na thread principal. Sem isso, cada teste |
|||
# aqui commitaria de verdade uma OrdemDia com votacao_aberta=True que |
|||
# nunca seria desfeita, quebrando a constraint única pro próximo teste |
|||
# (confirmado: aconteceu, a fileira ficava só na base de testes, |
|||
# invisível numa conexão nova, mas viva pro resto da run do pytest). |
|||
sessao, ordem = await _setup_ordem_aberta() |
|||
try: |
|||
admin_client, admin_user = await _admin_client_login() |
|||
parlamentar, votante_client = await _votante(sessao) |
|||
|
|||
communicator = await _painel_communicator(sessao.pk, admin_user) |
|||
|
|||
await sync_to_async(votante_client.post)( |
|||
reverse('sapl.painel:voto_individual'), {'voto': 'Sim'}) |
|||
|
|||
mensagem = await communicator.receive_json_from() |
|||
assert mensagem['type'] == 'data' |
|||
votos = {p['parlamentar_id']: p['voto'] for p in mensagem['payload']['presentes']} |
|||
assert votos[parlamentar.pk] == 'Sim' |
|||
|
|||
await communicator.disconnect() |
|||
finally: |
|||
await sync_to_async(sessao.delete)() |
|||
|
|||
|
|||
@pytest.mark.django_db(transaction=False) |
|||
@pytest.mark.asyncio |
|||
async def test_encerrar_votacao_dispara_broadcast(): |
|||
sessao, ordem = await _setup_ordem_aberta() |
|||
try: |
|||
admin_client, admin_user = await _admin_client_login() |
|||
parlamentar, votante_client = await _votante(sessao) |
|||
tipo_resultado = await sync_to_async(baker.make)( |
|||
TipoResultadoVotacao, nome='Aprovada', natureza='A') |
|||
|
|||
communicator = await _painel_communicator(sessao.pk, admin_user) |
|||
|
|||
await sync_to_async(admin_client.post)(_registrar_url(sessao, ordem), { |
|||
'salvar-votacao': '1', |
|||
'resultado_votacao': str(tipo_resultado.pk), |
|||
'observacao': '', |
|||
'voto_parlamentar': ['Sim:{}'.format(parlamentar.pk)], |
|||
}) |
|||
|
|||
mensagem = await communicator.receive_json_from() |
|||
assert mensagem['type'] == 'data' |
|||
assert mensagem['payload']['numero_votos_sim'] == 1 |
|||
assert mensagem['payload']['registro'] is True |
|||
|
|||
await communicator.disconnect() |
|||
finally: |
|||
await sync_to_async(sessao.delete)() |
|||
|
|||
|
|||
@pytest.mark.django_db(transaction=False) |
|||
@pytest.mark.asyncio |
|||
async def test_abrir_votacao_dispara_broadcast(): |
|||
sessao, ordem_antiga = await _setup_ordem_aberta() |
|||
try: |
|||
admin_client, admin_user = await _admin_client_login() |
|||
|
|||
def _prepara(): |
|||
ordem_antiga.votacao_aberta = False |
|||
ordem_antiga.save() |
|||
sessao.iniciada = True |
|||
sessao.finalizada = False |
|||
sessao.save() |
|||
baker.make(PresencaOrdemDia, sessao_plenaria=sessao, |
|||
parlamentar=baker.make(Parlamentar, ativo=True)) |
|||
return baker.make(OrdemDia, sessao_plenaria=sessao, materia=_materia(), |
|||
tipo_votacao=NOMINAL, votacao_aberta=False) |
|||
nova_ordem = await sync_to_async(_prepara)() |
|||
|
|||
communicator = await _painel_communicator(sessao.pk, admin_user) |
|||
|
|||
url = reverse('sapl.sessao:abrir_votacao', kwargs={'pk': nova_ordem.pk, 'spk': sessao.pk}) |
|||
url += '?tipo_materia=ordem' |
|||
await sync_to_async(admin_client.get)(url) |
|||
|
|||
mensagem = await communicator.receive_json_from() |
|||
assert mensagem['type'] == 'data' |
|||
|
|||
await communicator.disconnect() |
|||
finally: |
|||
await sync_to_async(sessao.delete)() |
|||
|
|||
|
|||
@pytest.mark.django_db(transaction=False) |
|||
@pytest.mark.asyncio |
|||
async def test_cronometro_dispara_broadcast_com_valor_atualizado(): |
|||
sessao, ordem = await _setup_ordem_aberta() |
|||
try: |
|||
def _abre_painel(): |
|||
sessao.painel_aberto = True |
|||
sessao.save() |
|||
await sync_to_async(_abre_painel)() |
|||
|
|||
admin_client, admin_user = await _admin_client_login() |
|||
communicator = await _painel_communicator(sessao.pk, admin_user) |
|||
|
|||
await sync_to_async(admin_client.get)( |
|||
reverse('sapl.painel:cronometro_painel'), |
|||
{'tipo': 'discurso', 'action': 'start'}) |
|||
|
|||
mensagem = await communicator.receive_json_from() |
|||
assert mensagem['type'] == 'data' |
|||
assert mensagem['payload']['cronometro_discurso'] == 'start' |
|||
|
|||
await communicator.disconnect() |
|||
finally: |
|||
await sync_to_async(sessao.delete)() |
|||
|
|||
|
|||
@pytest.mark.django_db(transaction=False) |
|||
@pytest.mark.asyncio |
|||
async def test_criar_e_apagar_orador_disparam_broadcast(): |
|||
""" |
|||
BroadcastPainelOnSaveMixin (sapl/sessao/views.py) — sem isso, oradores |
|||
só chegavam ao painel no próximo poll (agora removido). Cobre tanto |
|||
form_valid() (create) quanto delete(), incluindo o caso em que |
|||
self.kwargs['pk'] em DeleteView é o pk do próprio Orador, não o da |
|||
SessaoPlenaria (por isso o mixin lê self.object.sessao_plenaria_id em |
|||
vez de kwargs — ver a docstring do mixin). |
|||
""" |
|||
sessao, ordem = await _setup_ordem_aberta() |
|||
try: |
|||
admin_client, admin_user = await _admin_client_login() |
|||
|
|||
def _prepara(): |
|||
return _presente_com_mandato( |
|||
sessao, |
|||
data_inicio_mandato=sessao.data_inicio - timedelta(days=365), |
|||
data_fim_mandato=sessao.data_inicio + timedelta(days=365)) |
|||
parlamentar = await sync_to_async(_prepara)() |
|||
|
|||
communicator = await _painel_communicator(sessao.pk, admin_user) |
|||
|
|||
create_url = reverse('sapl.sessao:orador_create', kwargs={'pk': sessao.pk}) |
|||
response = await sync_to_async(admin_client.post)(create_url, { |
|||
'parlamentar': str(parlamentar.pk), |
|||
'numero_ordem': '1', |
|||
}) |
|||
assert response.status_code == 302 |
|||
|
|||
mensagem = await communicator.receive_json_from() |
|||
assert mensagem['type'] == 'data' |
|||
|
|||
orador = await sync_to_async( |
|||
lambda: Orador.objects.get(sessao_plenaria=sessao, parlamentar=parlamentar) |
|||
)() |
|||
|
|||
delete_url = reverse('sapl.sessao:orador_delete', kwargs={'pk': orador.pk}) |
|||
response = await sync_to_async(admin_client.post)(delete_url) |
|||
assert response.status_code == 302 |
|||
|
|||
mensagem = await communicator.receive_json_from() |
|||
assert mensagem['type'] == 'data' |
|||
|
|||
await communicator.disconnect() |
|||
finally: |
|||
await sync_to_async(sessao.delete)() |
|||
@ -0,0 +1,157 @@ |
|||
import uuid |
|||
|
|||
import pytest |
|||
from asgiref.sync import sync_to_async |
|||
from channels.layers import get_channel_layer |
|||
from channels.testing import WebsocketCommunicator |
|||
from django.contrib.auth import get_user_model |
|||
from django.contrib.auth.models import AnonymousUser, Permission |
|||
from model_bakery import baker |
|||
|
|||
from sapl.base.models import AppConfig as ConfiguracoesAplicacao |
|||
from sapl.painel.consumers import PainelConsumer |
|||
from sapl.parlamentares.models import Legislatura, SessaoLegislativa |
|||
from sapl.sessao.models import SessaoPlenaria, TipoSessaoPlenaria |
|||
|
|||
# Layer em memória para os testes — group_send/group_add não precisam de um |
|||
# Redis de verdade rodando; isso é o padrão recomendado pelo próprio |
|||
# Channels para testes (a validação end-to-end com Redis de verdade fica |
|||
# para a verificação manual em navegador, ver Parte 3 do plano). |
|||
# |
|||
# Usado via a fixture `settings` do pytest-django (autouse abaixo) em vez |
|||
# de @override_settings como decorator: neste Django 2.2 (sem suporte |
|||
# nativo a testes async), override_settings como decorator envolve a |
|||
# função com um wrapper síncrono que faz pytest-asyncio deixar de |
|||
# reconhecer a test function como uma coroutine — os testes eram |
|||
# silenciosamente pulados (PytestUnhandledCoroutineWarning) em vez de |
|||
# rodar. |
|||
IN_MEMORY_LAYER = { |
|||
'default': {'BACKEND': 'channels.layers.InMemoryChannelLayer'}, |
|||
} |
|||
|
|||
|
|||
@pytest.fixture(autouse=True) |
|||
def in_memory_channel_layer(settings): |
|||
settings.CHANNEL_LAYERS = IN_MEMORY_LAYER |
|||
|
|||
|
|||
@sync_to_async |
|||
def _make_sessao(): |
|||
baker.make(ConfiguracoesAplicacao, mostrar_voto=True, mostrar_brasao_painel=False) |
|||
legislatura = baker.make(Legislatura) |
|||
sessao_legislativa = baker.make(SessaoLegislativa) |
|||
tipo = baker.make(TipoSessaoPlenaria) |
|||
return baker.make(SessaoPlenaria, legislatura=legislatura, |
|||
sessao_legislativa=sessao_legislativa, tipo=tipo, numero=1) |
|||
|
|||
|
|||
@sync_to_async |
|||
def _make_user(module_perms=None): |
|||
user = get_user_model().objects.create_user( |
|||
username='ws-user-{}'.format(uuid.uuid4().hex[:12]), password='x') |
|||
if module_perms: |
|||
user.user_permissions.add( |
|||
*Permission.objects.filter(content_type__app_label=module_perms)) |
|||
return user |
|||
|
|||
|
|||
async def _communicator(sessao_id, user, session=None): |
|||
communicator = WebsocketCommunicator( |
|||
PainelConsumer.as_asgi(), '/ws/painel/{}/'.format(sessao_id)) |
|||
communicator.scope['user'] = user |
|||
communicator.scope['session'] = session if session is not None else {} |
|||
communicator.scope['url_route'] = {'kwargs': {'sessao_id': sessao_id}} |
|||
return communicator |
|||
|
|||
|
|||
@pytest.mark.django_db(transaction=False) |
|||
@pytest.mark.asyncio |
|||
async def test_connect_autenticado_e_permitido_recebe_payload_inicial(): |
|||
sessao = await _make_sessao() |
|||
user = await _make_user(module_perms='painel') |
|||
|
|||
communicator = await _communicator(sessao.pk, user) |
|||
connected, _subprotocol = await communicator.connect() |
|||
assert connected |
|||
|
|||
primeira_mensagem = await communicator.receive_json_from() |
|||
assert primeira_mensagem['type'] == 'data' |
|||
assert 'sessao_plenaria' in primeira_mensagem['payload'] |
|||
|
|||
await communicator.disconnect() |
|||
|
|||
|
|||
@pytest.mark.django_db(transaction=False) |
|||
@pytest.mark.asyncio |
|||
async def test_connect_nao_autenticado_e_rejeitado(): |
|||
sessao = await _make_sessao() |
|||
|
|||
communicator = await _communicator(sessao.pk, AnonymousUser()) |
|||
connected, close_code = await communicator.connect() |
|||
|
|||
assert not connected |
|||
assert close_code == 4401 |
|||
|
|||
|
|||
@pytest.mark.django_db(transaction=False) |
|||
@pytest.mark.asyncio |
|||
async def test_connect_autenticado_sem_nenhuma_permissao_e_rejeitado(): |
|||
sessao = await _make_sessao() |
|||
user = await _make_user(module_perms=None) |
|||
|
|||
communicator = await _communicator(sessao.pk, user) |
|||
connected, close_code = await communicator.connect() |
|||
|
|||
assert not connected |
|||
assert close_code == 4403 |
|||
|
|||
|
|||
@pytest.mark.django_db(transaction=False) |
|||
@pytest.mark.asyncio |
|||
async def test_connect_sessao_inexistente_e_rejeitado_sem_excecao_nao_tratada(): |
|||
user = await _make_user(module_perms='painel') |
|||
|
|||
communicator = await _communicator(999999, user) |
|||
connected, close_code = await communicator.connect() |
|||
|
|||
assert not connected |
|||
assert close_code == 4404 |
|||
|
|||
|
|||
@pytest.mark.django_db(transaction=False) |
|||
@pytest.mark.asyncio |
|||
async def test_broadcast_no_grupo_da_sessao_e_recebido_pelo_cliente_conectado(): |
|||
sessao = await _make_sessao() |
|||
user = await _make_user(module_perms='painel') |
|||
|
|||
communicator = await _communicator(sessao.pk, user) |
|||
connected, _subprotocol = await communicator.connect() |
|||
assert connected |
|||
await communicator.receive_json_from() # payload inicial, descarta |
|||
|
|||
layer = get_channel_layer() |
|||
await layer.group_send('sessao_{}'.format(sessao.pk), |
|||
{'type': 'data', 'payload': {'foo': 'bar'}}) |
|||
|
|||
mensagem = await communicator.receive_json_from() |
|||
assert mensagem == {'type': 'data', 'payload': {'foo': 'bar'}} |
|||
|
|||
await communicator.disconnect() |
|||
|
|||
|
|||
@pytest.mark.django_db(transaction=False) |
|||
@pytest.mark.asyncio |
|||
async def test_ping_recebe_pong(): |
|||
sessao = await _make_sessao() |
|||
user = await _make_user(module_perms='painel') |
|||
|
|||
communicator = await _communicator(sessao.pk, user) |
|||
connected, _subprotocol = await communicator.connect() |
|||
assert connected |
|||
await communicator.receive_json_from() # payload inicial, descarta |
|||
|
|||
await communicator.send_json_to({'type': 'ping'}) |
|||
resposta = await communicator.receive_json_from() |
|||
assert resposta == {'type': 'pong'} |
|||
|
|||
await communicator.disconnect() |
|||
@ -1,3 +1,269 @@ |
|||
# from django.test import TestCase |
|||
import datetime |
|||
|
|||
# Create your tests here. |
|||
import pytest |
|||
from django.contrib.auth import get_user_model |
|||
from django.contrib.auth.models import Permission |
|||
from django.test import Client |
|||
from django.urls import reverse |
|||
from model_bakery import baker |
|||
|
|||
from sapl.base.models import AppConfig as ConfiguracoesAplicacao |
|||
from sapl.materia.models import MateriaLegislativa, TipoMateriaLegislativa |
|||
from sapl.painel.views import build_dados_painel |
|||
from sapl.parlamentares.models import (Filiacao, Legislatura, Mandato, |
|||
Parlamentar, Partido, |
|||
SessaoLegislativa, Votante) |
|||
from sapl.sessao.models import (OrdemDia, PresencaOrdemDia, SessaoPlenaria, |
|||
TipoResultadoVotacao, TipoSessaoPlenaria, |
|||
VotoParlamentar) |
|||
|
|||
NOMINAL = 2 |
|||
|
|||
|
|||
def _sessao_plenaria(data_inicio=None): |
|||
legislatura = baker.make(Legislatura) |
|||
sessao_legislativa = baker.make(SessaoLegislativa) |
|||
tipo = baker.make(TipoSessaoPlenaria) |
|||
kwargs = {} |
|||
if data_inicio is not None: |
|||
kwargs['data_inicio'] = data_inicio |
|||
return baker.make(SessaoPlenaria, legislatura=legislatura, |
|||
sessao_legislativa=sessao_legislativa, tipo=tipo, numero=1, |
|||
**kwargs) |
|||
|
|||
|
|||
def _presente_com_mandato(sessao, data_inicio_mandato, data_fim_mandato=None, |
|||
partido_sigla=None, filiacao_data=None): |
|||
""" |
|||
Como _presente() (que ainda não existia neste arquivo antes desta |
|||
regressão), mas cria também um Mandato — nenhuma fixture deste arquivo |
|||
fazia isso antes, então nenhum teste aqui de fato exercia o ramo de |
|||
get_presentes() que confere `mandato_set.filter(legislatura=...)`. É |
|||
exatamente esse ramo que Milestone 1 troca por uma view SQL, então |
|||
precisa de cobertura própria antes de qualquer refatoração. |
|||
""" |
|||
parlamentar = baker.make(Parlamentar, ativo=True) |
|||
baker.make(PresencaOrdemDia, sessao_plenaria=sessao, parlamentar=parlamentar) |
|||
baker.make(Mandato, parlamentar=parlamentar, legislatura=sessao.legislatura, |
|||
data_inicio_mandato=data_inicio_mandato, |
|||
data_fim_mandato=data_fim_mandato) |
|||
if partido_sigla: |
|||
partido = baker.make(Partido, sigla=partido_sigla) |
|||
baker.make(Filiacao, parlamentar=parlamentar, partido=partido, |
|||
data=filiacao_data or data_inicio_mandato) |
|||
return parlamentar |
|||
|
|||
|
|||
def _materia(): |
|||
tipo_materia = baker.make(TipoMateriaLegislativa) |
|||
return baker.make(MateriaLegislativa, tipo=tipo_materia) |
|||
|
|||
|
|||
def _ordem_nominal_aberta(registro_aberto=False): |
|||
sessao = _sessao_plenaria() |
|||
materia = _materia() |
|||
ordem = baker.make(OrdemDia, sessao_plenaria=sessao, materia=materia, |
|||
tipo_votacao=NOMINAL, votacao_aberta=True, |
|||
registro_aberto=registro_aberto) |
|||
return sessao, ordem |
|||
|
|||
|
|||
def _votante(sessao, admin_user): |
|||
parlamentar = baker.make(Parlamentar, ativo=True) |
|||
baker.make(PresencaOrdemDia, sessao_plenaria=sessao, parlamentar=parlamentar) |
|||
baker.make(Votante, parlamentar=parlamentar, user=admin_user) |
|||
return parlamentar |
|||
|
|||
|
|||
def _votante_com_client(sessao): |
|||
""" |
|||
Como _votante(), mas cria seu próprio usuário (não-superuser, só com a |
|||
permissão parlamentares.can_vote) e devolve um Client logado separado — |
|||
necessário para testar a corrida entre o operador e o vereador, que |
|||
precisam ser duas sessões/usuários distintos. |
|||
""" |
|||
parlamentar = baker.make(Parlamentar, ativo=True) |
|||
baker.make(PresencaOrdemDia, sessao_plenaria=sessao, parlamentar=parlamentar) |
|||
user = get_user_model().objects.create_user( |
|||
username='votante-{}'.format(parlamentar.pk), password='x') |
|||
user.user_permissions.add(Permission.objects.get(codename='can_vote')) |
|||
baker.make(Votante, parlamentar=parlamentar, user=user) |
|||
client = Client() |
|||
client.force_login(user) |
|||
return parlamentar, client |
|||
|
|||
|
|||
def _registrar_url(sessao, ordem): |
|||
return reverse('sapl.sessao:votacaonominal', |
|||
kwargs={'pk': sessao.pk, 'oid': ordem.pk, 'mid': ordem.materia_id}) |
|||
|
|||
|
|||
@pytest.mark.django_db(transaction=False) |
|||
def test_votante_view_envia_headers_never_cache(admin_client, admin_user): |
|||
sessao, ordem = _ordem_nominal_aberta() |
|||
_votante(sessao, admin_user) |
|||
|
|||
response = admin_client.get(reverse('sapl.painel:voto_individual')) |
|||
|
|||
assert response.status_code == 200 |
|||
assert 'no-store' in response['Cache-Control'] |
|||
|
|||
|
|||
@pytest.mark.django_db(transaction=False) |
|||
def test_votante_view_mostra_materia_quando_registro_fechado(admin_client, admin_user): |
|||
sessao, ordem = _ordem_nominal_aberta(registro_aberto=False) |
|||
_votante(sessao, admin_user) |
|||
|
|||
response = admin_client.get(reverse('sapl.painel:voto_individual')) |
|||
|
|||
assert response.status_code == 200 |
|||
assert 'error_message' not in response.context |
|||
assert response.context['materia'] == ordem.materia |
|||
|
|||
|
|||
@pytest.mark.django_db(transaction=False) |
|||
def test_votante_view_mostra_erro_explicito_quando_registro_bloqueado(admin_client, admin_user): |
|||
""" |
|||
Regressão da causa raiz #1: quando a Mesa bloqueia novos votos |
|||
(registro_aberto=True), o vereador que ainda não votou precisa ver uma |
|||
mensagem explícita — não uma tela em branco sem explicação. |
|||
""" |
|||
sessao, ordem = _ordem_nominal_aberta(registro_aberto=True) |
|||
_votante(sessao, admin_user) |
|||
|
|||
response = admin_client.get(reverse('sapl.painel:voto_individual')) |
|||
|
|||
assert response.status_code == 200 |
|||
assert 'Mesa encerrou o recebimento de novos votos' in response.context['error_message'] |
|||
|
|||
|
|||
@pytest.mark.django_db(transaction=False) |
|||
def test_voto_do_vereador_prevalece_sobre_lote_do_operador(admin_client): |
|||
""" |
|||
Invariante: o voto do próprio vereador sempre prevalece sobre qualquer |
|||
valor provisório já existente para ele (ex.: de uma tentativa anterior |
|||
do operador, ou de qualquer outra origem). Complementa |
|||
test_salvar_votacao_nao_sobrescreve_voto_ja_registrado (que cobre a |
|||
ordem inversa: o operador não pode sobrescrever um voto real já |
|||
registrado) — aqui é o vereador votando por cima de um valor existente |
|||
através da view de verdade, não apenas o estado inicial simulado. |
|||
""" |
|||
sessao, ordem = _ordem_nominal_aberta() |
|||
vereador, votante_client = _votante_com_client(sessao) |
|||
|
|||
# 1) Já existe um valor provisório "Não Votou" para o vereador (ex.: o |
|||
# <select> nunca foi tocado pelo operador). Esse valor nunca deveria |
|||
# impedir o vereador de votar de verdade. |
|||
baker.make(VotoParlamentar, ordem=ordem, parlamentar=vereador, |
|||
voto='Não Votou') |
|||
|
|||
# 2) O vereador vota pelo tablet — seu voto real prevalece sobre o |
|||
# valor provisório. |
|||
response = votante_client.post( |
|||
reverse('sapl.painel:voto_individual'), {'voto': 'Sim'}) |
|||
assert response.status_code == 302 |
|||
|
|||
voto = VotoParlamentar.objects.get(ordem=ordem, parlamentar=vereador) |
|||
assert voto.voto == 'Sim' |
|||
|
|||
|
|||
@pytest.mark.django_db(transaction=False) |
|||
def test_propria_tela_nao_mostra_voto_de_outra_materia(admin_client): |
|||
""" |
|||
Regressão: votacao() buscava o voto do próprio vereador com |
|||
Q(ordem=ordem_dia) | Q(expediente=expediente) — quando a matéria atual |
|||
é uma OrdemDia, expediente é None, e Q(expediente=None) vira |
|||
"expediente_id IS NULL" no SQL, que é verdadeiro para QUALQUER voto de |
|||
ordem do dia daquele vereador, não só o desta matéria (idem para |
|||
ExpedienteMateria, cujas votações também têm ordem=None). Com .first() |
|||
sem ordenação, o vereador podia ver o voto de uma matéria antiga em vez |
|||
do voto (ou ausência de voto) da matéria atual — inclusive depois de |
|||
trocar o próprio voto, já que a query buscava a linha errada. |
|||
""" |
|||
sessao, ordem_antiga = _ordem_nominal_aberta() |
|||
vereador, votante_client = _votante_com_client(sessao) |
|||
baker.make(VotoParlamentar, ordem=ordem_antiga, parlamentar=vereador, voto='Sim') |
|||
|
|||
ordem_antiga.votacao_aberta = False |
|||
ordem_antiga.save() |
|||
baker.make(OrdemDia, sessao_plenaria=sessao, materia=_materia(), |
|||
tipo_votacao=NOMINAL, votacao_aberta=True, registro_aberto=False) |
|||
baker.make(PresencaOrdemDia, sessao_plenaria=sessao, parlamentar=vereador) |
|||
|
|||
status_url = reverse('sapl.painel:voto_individual_status') |
|||
|
|||
# Ainda não votou na matéria atual — não pode herdar o 'Sim' da antiga. |
|||
assert votante_client.get(status_url).json()['voto_parlamentar'] is None |
|||
|
|||
votante_client.post(reverse('sapl.painel:voto_individual'), {'voto': 'Não'}) |
|||
|
|||
assert votante_client.get(status_url).json()['voto_parlamentar'] == 'Não' |
|||
assert VotoParlamentar.objects.get( |
|||
ordem=ordem_antiga, parlamentar=vereador).voto == 'Sim' |
|||
|
|||
|
|||
@pytest.fixture |
|||
def locmem_cache(settings): |
|||
""" |
|||
O cache padrão do projeto é file-based (persiste em disco entre runs de |
|||
teste) — cronômetro agora vive nesse cache, então sem isolar aqui os |
|||
testes abaixo vazariam estado real pro cache de dev compartilhado. |
|||
""" |
|||
settings.CACHES = { |
|||
'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}, |
|||
} |
|||
|
|||
|
|||
@pytest.mark.django_db(transaction=False) |
|||
def test_cronometro_e_compartilhado_entre_sessoes_diferentes(locmem_cache, admin_client): |
|||
""" |
|||
Regressão: get_cronometro_status() lia de request.session — o |
|||
Presidente apertando "iniciar" no cronômetro só mudava o que ELE via; |
|||
o telão público (outra sessão/browser) nunca via a mudança. Agora o |
|||
estado fica em cache compartilhado, então uma segunda sessão/cliente |
|||
completamente separada precisa ver o mesmo valor. |
|||
|
|||
Lê o resultado via build_dados_painel() diretamente (não mais pelo |
|||
endpoint HTTP get_dados_painel/dados_painel, removido com o polling — |
|||
ninguém mais o chama por HTTP; o mesmo build_dados_painel() é o que os |
|||
broadcasts via WebSocket usam, então continua sendo o ponto de |
|||
verdade certo pra este teste). request=None é seguro aqui: |
|||
build_dados_painel() nunca lê nada de request, só repassa adiante para |
|||
get_cronometro_status(), que também o ignora e lê do cache direto. |
|||
""" |
|||
sessao = _sessao_plenaria() |
|||
baker.make(ConfiguracoesAplicacao, mostrar_voto=True, mostrar_brasao_painel=False) |
|||
|
|||
admin_client.get(reverse('sapl.painel:cronometro_painel'), |
|||
{'tipo': 'discurso', 'action': 'start'}) |
|||
|
|||
data = build_dados_painel(None, sessao.pk) |
|||
assert data['cronometro_discurso'] == 'start' |
|||
|
|||
|
|||
@pytest.mark.django_db(transaction=False) |
|||
def test_votante_status_reflete_estado_e_nao_exige_permissao_do_painel(): |
|||
""" |
|||
votante_status precisa ser alcançável por uma conta só-Votante (sem |
|||
nenhuma permissão do app painel) — é por isso que tem sua própria |
|||
checagem de permissão (parlamentares.can_vote) em vez de reaproveitar |
|||
check_permission (permissão de módulo do app painel). |
|||
""" |
|||
sessao, ordem = _ordem_nominal_aberta() |
|||
vereador, votante_client = _votante_com_client(sessao) |
|||
|
|||
status_url = reverse('sapl.painel:voto_individual_status') |
|||
|
|||
resposta = votante_client.get(status_url) |
|||
assert resposta.status_code == 200 |
|||
data = resposta.json() |
|||
assert data['materia_id'] == ordem.materia_id |
|||
assert data['status_message'] == 'Aguardando seu voto.' |
|||
assert data['voto_parlamentar'] is None |
|||
|
|||
votante_client.post(reverse('sapl.painel:voto_individual'), {'voto': 'Não'}) |
|||
|
|||
resposta2 = votante_client.get(status_url) |
|||
data2 = resposta2.json() |
|||
assert data2['voto_parlamentar'] == 'Não' |
|||
assert 'encerramento da votação' in data2['status_message'] |
|||
|
|||
@ -0,0 +1,283 @@ |
|||
# Painel backed by WebSockets — manual E2E verification |
|||
|
|||
This is a note about the **`feat/painel-websockets` branch** (a timeboxed |
|||
spike, forked from `fix/painel-registro-votacao-trava`), which adds live |
|||
server push (WebSockets over Redis) to the plenary-session "painel" |
|||
feature, additively on top of the existing HTTP-polling implementation. |
|||
It lives in this app's directory because the broadcast trigger points |
|||
(`abrir_votacao`, `VotacaoNominalAbstract.post`) are here in |
|||
`sapl/sessao/views.py` — the WebSocket infrastructure itself (consumer, |
|||
ASGI routing, the shared payload builder) lives in `sapl/painel/`. |
|||
|
|||
If you're reading this outside that branch, most of what's described |
|||
below doesn't exist yet. |
|||
|
|||
## Requirements to run this branch (local dev) |
|||
|
|||
Four things need to be running at once. Order matters a little (Redis |
|||
before the Django/ASGI process; `yarn serve` before you load a page, or |
|||
its bundles 404). |
|||
|
|||
1. **Redis, with persistence off** (channel layer only — nothing here |
|||
needs to survive a restart): |
|||
``` |
|||
redis-server --save "" --appendonly no |
|||
``` |
|||
or, if you'd rather not install Redis locally: |
|||
``` |
|||
docker run --rm -p 6379:6379 redis:7-alpine redis-server --save "" --appendonly no |
|||
``` |
|||
Make sure `REDIS_URL` in `.env` actually resolves to it — it must be a |
|||
full URL (`redis://127.0.0.1:6379/0`, matching `sapl/settings.py`'s |
|||
default). The repo's current `.env` has `REDIS_URL=localhost:6380`, |
|||
which is **not** in that form — if WebSocket updates silently never |
|||
arrive, check this first before suspecting the consumer/broadcast code. |
|||
|
|||
2. **Python deps** (adds `channels`/`daphne`/`channels-redis` on top of |
|||
the usual requirements — skip if already installed): |
|||
``` |
|||
pip install -r requirements/dev-requirements.txt |
|||
``` |
|||
|
|||
3. **Frontend dev bundle** (the Vue pages — painel, votação nominal, voto |
|||
individual, votação simbólica, leitura de matéria — are served via |
|||
`{% render_bundle %}`, which in `DEBUG` mode reads |
|||
`frontend/dev-webpack-stats.json` and points `<script>` tags at the |
|||
dev server; without this running you'll get a template error, not a |
|||
blank page): |
|||
``` |
|||
yarn install # once |
|||
yarn serve |
|||
``` |
|||
|
|||
4. **The Django/ASGI process itself — pick one, not both:** |
|||
``` |
|||
daphne -b 127.0.0.1 -p 8000 sapl.asgi:application |
|||
``` |
|||
or |
|||
``` |
|||
./manage.py runserver |
|||
``` |
|||
`sapl.asgi.application` is a single ASGI app that routes **both** |
|||
regular HTTP and `/ws/painel/<sessao_id>/` — and every WS client here |
|||
(Vue's `main.js` files and the legacy vanilla-JS templates alike) |
|||
opens its socket at `window.location.host`, i.e. *the same host:port |
|||
the page was loaded from*. So this has to be one process on one port, |
|||
not Daphne-on-one-port-plus-runserver-on-another. There's no HTTP |
|||
polling fallback anywhere in this branch anymore — every screen here |
|||
is WS-only (see "Architecture" below) — so a two-port setup wouldn't |
|||
just be slower, it would leave every page permanently stuck showing a |
|||
"conexão perdida" banner. `scripts/verify_painel_websockets.sh` runs a |
|||
single Daphne process for exactly this reason. |
|||
|
|||
Between the two: **use Daphne when you're actively debugging the |
|||
WebSocket itself** — its logs/errors for a failed or dropped `/ws/` |
|||
connection are far more legible, and this is where you'll spend time |
|||
if something in the merged backend misbehaves. Daphne does **not** |
|||
autoreload on code changes, though, so for everyday work (editing |
|||
views/templates, not the consumer/broadcast path) run `runserver` |
|||
instead — Channels patches it to serve the same combined HTTP+WS app, |
|||
just with autoreload, and you restart it far less often. |
|||
|
|||
## Architecture in one paragraph |
|||
|
|||
Every client screen — the three legacy vanilla-JS templates (the public |
|||
painel `sapl/templates/painel/index.html`, the tablet voting screen |
|||
`sapl/templates/painel/voto_individual.html`, the operator's |
|||
vote-registration screen `sapl/templates/sessao/votacao/nominal.html`) and |
|||
the five Vue v2 pages (painel, votação nominal, voto individual, votação |
|||
simbólica, leitura de matéria) — open a WebSocket to |
|||
`/ws/painel/<sessao_id>/` (one Channels group per `SessaoPlenaria`, |
|||
`sapl/painel/consumers.py::PainelConsumer`) and get a full state snapshot |
|||
immediately on connect (`PainelConsumer.connect()` sends one right after |
|||
`accept()` — this is what makes a page reload or a reconnect self-heal |
|||
with no gap, instead of showing stale data until the next event). |
|||
Whenever a view mutates presença, orador, vote, or matéria state, it calls |
|||
`broadcast_dados_painel(request, sessao_id)` (`sapl/painel/views.py`), |
|||
which re-runs `build_dados_painel()` and publishes the full payload to |
|||
that group over Redis (`channels_redis`). Every connected client gets the |
|||
*same* full-state payload; there is no separate delta format. |
|||
|
|||
**There is no HTTP polling fallback anywhere in this branch.** Every |
|||
screen listed above is WS-only: if the socket drops, nothing updates |
|||
until it reconnects. Each screen's JS shows a visible banner |
|||
(`#ws-status-banner` in the legacy templates, `<ws-status-banner>` in the |
|||
Vue pages) whenever the connection isn't open, and reconnects with an |
|||
exponential backoff (1s → 30s cap, ±30% jitter to avoid every connected |
|||
tablet retrying in lockstep). The consumer closes with a specific code for |
|||
conditions a retry can't fix — 4401 (not authenticated), 4403 (not |
|||
authorized), 4404 (no such sessão) — and the client shows a permanent |
|||
error for those instead of retrying forever. Because polling is gone, |
|||
every write path that changes anything in this payload has to call |
|||
`broadcast_dados_painel()` itself — `PresencaView`/`PresencaOrdemDiaView`, |
|||
the `Orador*Crud` create/update/delete views (via |
|||
`BroadcastPainelOnSaveMixin` in `sapl/sessao/views.py`), |
|||
`LeituraEmBloco`, and `VotacaoEmBlocoSimbolicaView`/ |
|||
`VotacaoEmBlocoNominalView` were all missing this until it was added |
|||
alongside the polling removal — before that, polling was quietly masking |
|||
the gap (500ms–3s of staleness, not "never updates"). |
|||
|
|||
## Running it (for humans) |
|||
|
|||
``` |
|||
scripts/verify_painel_websockets.sh |
|||
``` |
|||
|
|||
This starts Redis and a single Daphne process (HTTP pages + WebSocket, one |
|||
port, one process — see the "two things need to be running" note above) |
|||
together, waits for both to actually be listening, and tails their logs |
|||
in one terminal. Ctrl+C stops and cleans up both. See the script's header |
|||
comment for port-override env vars if 6379/8002 collide with something |
|||
else on your machine. |
|||
|
|||
Once it's up: |
|||
|
|||
1. **Seed test data** (there's no fixture for this yet — a `manage.py |
|||
shell` one-liner is the fastest path; see the Python snippet in the |
|||
next section, "For LLMs / scripted setup", which works equally well |
|||
pasted into a human's `shell` session). |
|||
2. Open the **public painel** at `http://127.0.0.1:8002/painel-principal/<sessao_id>` |
|||
in one tab. |
|||
3. Log in as the seeded voter and open |
|||
`http://127.0.0.1:8002/voto-individual/` in another tab (or a private |
|||
window, since it's a different session). |
|||
4. Cast or change the vote on the voter tab. **The painel tab should |
|||
update without a page reload**, and there should be no delay beyond |
|||
normal network latency — that's the only path there is now. Watch the |
|||
daphne log (tailed in the same terminal) for the connection and any |
|||
errors. |
|||
5. To confirm the error banner actually shows up when it should (not just |
|||
that push works): kill daphne (its pid is printed by the script) while |
|||
the painel tab stays open. Within a few seconds every open tab should |
|||
show the red/amber "conexão perdida" banner |
|||
(`#ws-status-banner`/`<ws-status-banner>` — see "Architecture" above) |
|||
instead of silently going stale. |
|||
6. To confirm reconnect: restart the script (or just daphne) while the |
|||
painel tab stays open. Within a few seconds (backoff-dependent) the |
|||
tab should reconnect, the banner should disappear, and the tab should |
|||
resume live updates — check the browser console for the `WebSocket` |
|||
connect/close log lines. |
|||
|
|||
The operator's registration screen |
|||
(`sapl/sessao:votacaonominal`, URL `/sessao/<pk>/matordemdia/votnom/<oid>/<mid>`) |
|||
follows the same pattern — open it as a third tab, logged in as a user |
|||
with `sessao` module permissions, and confirm the same instant-update |
|||
behavior when a vote is registered from either the operator or the |
|||
tablet side. |
|||
|
|||
## For LLMs / scripted setup |
|||
|
|||
Minimal data needed for a Nominal vote to be votable end-to-end (run via |
|||
`python manage.py shell`, from the repo root, with `DJANGO_SETTINGS_MODULE=sapl.settings`): |
|||
|
|||
```python |
|||
from model_bakery import baker |
|||
from django.contrib.auth import get_user_model |
|||
from django.contrib.auth.models import Permission |
|||
from sapl.base.models import AppConfig as ConfiguracoesAplicacao |
|||
from sapl.parlamentares.models import Legislatura, SessaoLegislativa, Mandato, Parlamentar, Votante |
|||
from sapl.sessao.models import SessaoPlenaria, TipoSessaoPlenaria, OrdemDia, PresencaOrdemDia |
|||
from sapl.materia.models import MateriaLegislativa, TipoMateriaLegislativa |
|||
|
|||
User = get_user_model() |
|||
|
|||
# ConfiguracoesAplicacao.mostrar_voto controls whether the painel shows |
|||
# real vote values or masks them as "Voto Informado" before the matéria |
|||
# closes — set True to see real values live. |
|||
cfg, _ = ConfiguracoesAplicacao.objects.get_or_create(pk=1) |
|||
cfg.mostrar_voto = True |
|||
cfg.save() |
|||
|
|||
legislatura = baker.make(Legislatura) |
|||
sessao_legislativa = baker.make(SessaoLegislativa) |
|||
tipo = baker.make(TipoSessaoPlenaria) |
|||
sessao = baker.make(SessaoPlenaria, legislatura=legislatura, sessao_legislativa=sessao_legislativa, |
|||
tipo=tipo, numero=1, iniciada=True, finalizada=False, painel_aberto=True) |
|||
|
|||
materia = baker.make(MateriaLegislativa, tipo=baker.make(TipoMateriaLegislativa)) |
|||
ordem = baker.make(OrdemDia, sessao_plenaria=sessao, materia=materia, |
|||
tipo_votacao=2, votacao_aberta=True, registro_aberto=False) # 2 = Nominal |
|||
|
|||
parlamentar = baker.make(Parlamentar, ativo=True) |
|||
baker.make(PresencaOrdemDia, sessao_plenaria=sessao, parlamentar=parlamentar) |
|||
# get_presentes() (sapl/painel/views.py, backed by sessao_presencas_view) |
|||
# only includes parlamentares with a Mandato for the session's legislatura |
|||
# — without this row the parlamentar silently never shows as "presente". |
|||
baker.make(Mandato, parlamentar=parlamentar, legislatura=legislatura, |
|||
data_inicio_mandato=sessao.data_inicio) |
|||
|
|||
# A strong password is required — SAPL's CheckWeakPasswordMiddleware |
|||
# force-redirects any login with a weak one (e.g. "x", "test") to |
|||
# /sistema/alterar-senha/ before it ever reaches the view you're testing. |
|||
# This silently breaks any requests-based scripted login otherwise. |
|||
votante_user = User.objects.create_user(username='verify-votante', password='Xk9#mQ2vLp8zR4wT!') |
|||
votante_user.user_permissions.add(Permission.objects.get(codename='can_vote')) |
|||
baker.make(Votante, parlamentar=parlamentar, user=votante_user) |
|||
|
|||
print('sessao_id =', sessao.pk, '| ordem_id =', ordem.pk, '| materia_id =', materia.pk) |
|||
``` |
|||
|
|||
Permission model for `/ws/painel/<sessao_id>/` (`PainelConsumer.connect()`, |
|||
`sapl/painel/consumers.py`) — the connection is accepted if the |
|||
authenticated user satisfies **any** of the three permission gates the |
|||
three HTTP screens already use individually (there's no single unified |
|||
permission for this endpoint because the three source screens never had |
|||
one either): |
|||
- `user.has_module_perms('painel')` — same as `check_permission()`'s gate. |
|||
- `user.has_perm('parlamentares.can_vote')` — same as `votante_view`'s gate. |
|||
- `user.has_module_perms('sessao')` — same as the operator screen's |
|||
`SessaoPermissionMixin` gate. |
|||
|
|||
Unauthenticated connections close with code `4401`; authenticated but |
|||
unauthorized close with `4403`; a nonexistent `sessao_id` closes with |
|||
`4404` (all handled explicitly — no uncaught exceptions on bad input, |
|||
unlike the `feat/painel-votacao-v2` reference this was ported from). |
|||
|
|||
To verify the broadcast path without a browser at all (what |
|||
`sapl/painel/tests/test_broadcasts.py` does, and what was used to |
|||
smoke-test this against real Redis+daphne+runserver during development): |
|||
connect with the `websockets` Python package |
|||
(`pip install websockets`, not in project requirements — it's a |
|||
verification tool, not a runtime dependency) against |
|||
`ws://127.0.0.1:8002/ws/painel/<sessao_id>/`, sending the Django |
|||
`sessionid`/`csrftoken` cookies from a `requests.Session()` that logged in |
|||
via `/login/` — then POST a vote via `requests` and confirm the socket |
|||
receives a `{"type": "data", "payload": {...}}` message with the updated |
|||
`presentes[].voto`. |
|||
|
|||
## Known caveats |
|||
|
|||
- **`cronometro_painel`'s "which session is this for" resolution is |
|||
fragile.** It broadcasts to whichever `SessaoPlenaria` currently has |
|||
`painel_aberto=True`, via `.first()` — correct under the intended |
|||
single-open-session workflow, but nothing enforces "at most one" for |
|||
`painel_aberto` the way `votacao_aberta` already has a DB |
|||
`UniqueConstraint`. If more than one session has that flag set (stale |
|||
dev data, or a real multi-tenant edge case), the broadcast goes to an |
|||
arbitrary one of them. Not hit in practice during this spike's testing, |
|||
but worth hardening before this goes further. |
|||
- **This is a spike, not a finished feature.** Test coverage exists |
|||
(`sapl/painel/tests/test_consumers.py`, `test_broadcasts.py`, plus the |
|||
Postgres-view equivalence tests in `test_votacao_nominal.py`/`tests.py`) |
|||
and the vote/close/open/cronômetro broadcast paths were each verified |
|||
against real infrastructure (not just the in-memory test layer) during |
|||
development — but things like production replica count / Redis HA, |
|||
and a nginx-level rate-limit exemption for the `/ws/` path if |
|||
`feat/rate-limiter-2026`'s Lua layer ever merges, haven't been |
|||
addressed here. |
|||
- **Requirements**: `channels==3.0.3`, `daphne==3.0.2`, |
|||
`channels-redis==3.4.1`, `asgiref==3.7.2` are pinned in |
|||
`requirements/requirements.txt` — this specific combination is the |
|||
newest one compatible with this project's Django pin (`2.2.28`; |
|||
Channels 4.x needs Django ≥ 4.2, out of scope for this branch). |
|||
- **Removed as dead code when polling was removed**: |
|||
`painel_mensagem`/`painel_parlamentar`/`painel_votacao` (views, URLs, |
|||
and their templates `painel/mensagem.html`/`parlamentares.html`/ |
|||
`votacao.html`) were an older, unrelated multi-page painel UI with no |
|||
references anywhere else in the codebase — deleted outright rather than |
|||
converted. `sapl.painel:dados_painel` (`get_dados_painel`) and its ETag |
|||
helper (`_dados_painel_etag`) were also removed once nothing polled |
|||
them anymore, along with the tests that pinned their HTTP-specific |
|||
behavior (ETag/304, payload shape, masking) in |
|||
`sapl/painel/tests/tests.py` — `build_dados_painel()` itself (what both |
|||
used internally) is untouched and still backs every WS broadcast. |
|||
@ -1,3 +1,34 @@ |
|||
from django.contrib import admin |
|||
|
|||
from sapl.sessao.models import ExpedienteMateria, OrdemDia |
|||
from sapl.utils import register_all_models_in_admin |
|||
|
|||
|
|||
class NoAdminAccessModelAdmin(admin.ModelAdmin): |
|||
""" |
|||
SAPL não usa as páginas de admin e desencoraja seu uso — e votacao_aberta/ |
|||
registro_aberto só podem ser alterados com segurança através do fluxo de |
|||
abrir_votacao()/VotacaoNominalAbstract (sapl/sessao/views.py), que |
|||
garante a invariante de no máximo uma matéria aberta por vez. Em vez de |
|||
apenas tornar os campos somente-leitura, o acesso ao admin é desabilitado |
|||
por completo para estes dois modelos. |
|||
""" |
|||
|
|||
def has_view_permission(self, request, obj=None): |
|||
return False |
|||
|
|||
def has_add_permission(self, request): |
|||
return False |
|||
|
|||
def has_change_permission(self, request, obj=None): |
|||
return False |
|||
|
|||
def has_delete_permission(self, request, obj=None): |
|||
return False |
|||
|
|||
|
|||
admin.site.register(OrdemDia, NoAdminAccessModelAdmin) |
|||
admin.site.register(ExpedienteMateria, NoAdminAccessModelAdmin) |
|||
|
|||
# register_all_models_in_admin já pula modelos já registrados acima |
|||
register_all_models_in_admin(__name__) |
|||
|
|||
@ -0,0 +1,21 @@ |
|||
# Generated by Django 2.2.28 on 2026-08-16 17:36 |
|||
|
|||
from django.db import migrations, models |
|||
|
|||
|
|||
class Migration(migrations.Migration): |
|||
|
|||
dependencies = [ |
|||
('sessao', '0069_auto_20220919_1705'), |
|||
] |
|||
|
|||
operations = [ |
|||
migrations.AddConstraint( |
|||
model_name='votoparlamentar', |
|||
constraint=models.UniqueConstraint(condition=models.Q(ordem__isnull=False), fields=('parlamentar', 'ordem'), name='sessao_votoparlamentar_unique_parlamentar_ordem'), |
|||
), |
|||
migrations.AddConstraint( |
|||
model_name='votoparlamentar', |
|||
constraint=models.UniqueConstraint(condition=models.Q(expediente__isnull=False), fields=('parlamentar', 'expediente'), name='sessao_votoparlamentar_unique_parlamentar_expediente'), |
|||
), |
|||
] |
|||
@ -0,0 +1,39 @@ |
|||
# Generated by Django 2.2.28 on 2026-08-16 18:58 |
|||
|
|||
from django.db import migrations, models |
|||
|
|||
|
|||
def fecha_matérias_abertas_duplicadas(apps, schema_editor): |
|||
""" |
|||
Pré-requisito para o AddConstraint abaixo: se já existir mais de uma |
|||
OrdemDia/ExpedienteMateria com votacao_aberta=True (dado deixado por |
|||
versões anteriores, que não garantiam essa invariante), a constraint |
|||
falharia ao ser criada. Mantém aberta apenas a mais recentemente |
|||
modificada de cada tabela e fecha as demais. |
|||
""" |
|||
for model_name in ('OrdemDia', 'ExpedienteMateria'): |
|||
model = apps.get_model('sessao', model_name) |
|||
abertas = model.objects.filter(votacao_aberta=True).order_by('-id') |
|||
for materia in abertas[1:]: |
|||
materia.votacao_aberta = False |
|||
materia.save(update_fields=['votacao_aberta']) |
|||
|
|||
|
|||
class Migration(migrations.Migration): |
|||
|
|||
dependencies = [ |
|||
('sessao', '0070_votoparlamentar_unique_constraint'), |
|||
] |
|||
|
|||
operations = [ |
|||
migrations.RunPython( |
|||
fecha_matérias_abertas_duplicadas, migrations.RunPython.noop), |
|||
migrations.AddConstraint( |
|||
model_name='expedientemateria', |
|||
constraint=models.UniqueConstraint(condition=models.Q(votacao_aberta=True), fields=('votacao_aberta',), name='sessao_expedientemateria_unique_votacao_aberta'), |
|||
), |
|||
migrations.AddConstraint( |
|||
model_name='ordemdia', |
|||
constraint=models.UniqueConstraint(condition=models.Q(votacao_aberta=True), fields=('votacao_aberta',), name='sessao_ordemdia_unique_votacao_aberta'), |
|||
), |
|||
] |
|||
@ -0,0 +1,225 @@ |
|||
from django.db import migrations |
|||
|
|||
|
|||
class Migration(migrations.Migration): |
|||
""" |
|||
Views Postgres portadas de feat/painel-votacao-v2 (migration |
|||
0070_views_sessao_plenaria.py naquela branch — o número colide com |
|||
0070/0071 desta branch por coincidência de ponto de fork comum, |
|||
daí o 0072 aqui), com correções para preservar exatamente o |
|||
comportamento atual de sapl/painel/views.py: |
|||
- COALESCE usa 'Sem Registro' (texto atual), não 'SEM PARTIDO'. |
|||
- id da view é o da própria linha de presença, não o do parlamentar. |
|||
- Sem checagem de intervalo de datas do Mandato (código atual não |
|||
checa; a view original checava, o que é mais estrito e mudaria |
|||
quem aparece como presente). |
|||
- reverse_sql explícito (a migration original não tinha nenhum, |
|||
deixando-a irreversível). |
|||
- sessao_oradores_view não é portada (bug real no ON da LATERAL, |
|||
e não é o alvo do N+1 que esta mudança resolve). |
|||
""" |
|||
|
|||
dependencies = [ |
|||
('sessao', '0071_votacao_aberta_unique_constraint'), |
|||
] |
|||
|
|||
operations = [ |
|||
migrations.RunSQL( |
|||
sql=""" |
|||
DROP VIEW IF EXISTS sessao_presencas_view; |
|||
CREATE VIEW sessao_presencas_view AS |
|||
-- |
|||
-- PRESENCAS DE PARLAMENTARES COM FILIACAO (SE HOUVER) |
|||
-- PARLAMENTAR DEVE ESTAR ATIVO E TER MANDATO NA LEGISLATURA |
|||
-- DA SESSAO (sem checagem de intervalo de datas do |
|||
-- mandato — igual ao comportamento Python atual) |
|||
-- |
|||
-- EXPEDIENTE |
|||
SELECT |
|||
presenca.id, |
|||
presenca.sessao_plenaria_id, |
|||
'expediente' AS etapa_sessao, |
|||
p.id as parlamentar_id, |
|||
p.nome_parlamentar as nome_parlamentar, |
|||
COALESCE(af.sigla, 'Sem Registro') AS filiacao, |
|||
p.ativo |
|||
FROM sessao_sessaoplenariapresenca AS presenca |
|||
JOIN sessao_sessaoplenaria AS sp |
|||
ON presenca.sessao_plenaria_id = sp.id |
|||
JOIN parlamentares_parlamentar AS p |
|||
ON presenca.parlamentar_id = p.id |
|||
JOIN parlamentares_mandato AS m |
|||
ON p.id = m.parlamentar_id |
|||
AND m.legislatura_id = sp.legislatura_id |
|||
LEFT JOIN LATERAL ( |
|||
SELECT pa.sigla |
|||
FROM parlamentares_filiacao f |
|||
JOIN parlamentares_partido pa ON pa.id = f.partido_id |
|||
WHERE f.parlamentar_id = p.id |
|||
AND f.data <= sp.data_inicio |
|||
AND (f.data_desfiliacao IS NULL OR f.data_desfiliacao >= sp.data_inicio) |
|||
ORDER BY f.data DESC |
|||
LIMIT 1 |
|||
) AS af ON TRUE |
|||
WHERE p.ativo = TRUE |
|||
|
|||
UNION ALL |
|||
|
|||
-- ORDEM DO DIA |
|||
SELECT |
|||
presenca.id, |
|||
presenca.sessao_plenaria_id, |
|||
'ordemdia' AS etapa_sessao, |
|||
p.id as parlamentar_id, |
|||
p.nome_parlamentar as nome_parlamentar, |
|||
COALESCE(af.sigla, 'Sem Registro') AS filiacao, |
|||
p.ativo |
|||
FROM sessao_presencaordemdia AS presenca |
|||
JOIN sessao_sessaoplenaria AS sp |
|||
ON presenca.sessao_plenaria_id = sp.id |
|||
JOIN parlamentares_parlamentar AS p |
|||
ON presenca.parlamentar_id = p.id |
|||
JOIN parlamentares_mandato AS m |
|||
ON p.id = m.parlamentar_id |
|||
AND m.legislatura_id = sp.legislatura_id |
|||
LEFT JOIN LATERAL ( |
|||
SELECT pa.sigla |
|||
FROM parlamentares_filiacao f |
|||
JOIN parlamentares_partido pa ON pa.id = f.partido_id |
|||
WHERE f.parlamentar_id = p.id |
|||
AND f.data <= sp.data_inicio |
|||
AND (f.data_desfiliacao IS NULL OR f.data_desfiliacao >= sp.data_inicio) |
|||
ORDER BY f.data DESC |
|||
LIMIT 1 |
|||
) AS af ON TRUE |
|||
WHERE p.ativo = TRUE |
|||
ORDER BY sessao_plenaria_id, etapa_sessao, nome_parlamentar |
|||
""", |
|||
reverse_sql="DROP VIEW IF EXISTS sessao_presencas_view;", |
|||
), |
|||
migrations.RunSQL( |
|||
sql=""" |
|||
DROP VIEW IF EXISTS sessao_materias_votacoes_view; |
|||
CREATE VIEW sessao_materias_votacoes_view AS |
|||
-- |
|||
-- Votacao de Materias (Simbolica/Nominal/Secreta) — Leitura |
|||
-- (tipo_votacao=4) fica de fora, ela não usa este caminho. |
|||
-- |
|||
-- EXPEDIENTE |
|||
WITH votacao_materias AS ( |
|||
SELECT em.sessao_plenaria_id, |
|||
em.id id, |
|||
'expediente' etapa_sessao, |
|||
em.numero_ordem, |
|||
em.materia_id, |
|||
tm.descricao||' nº '||ml.numero||' de '||ml.ano as materia_texto, |
|||
ml.ementa materia_ementa, |
|||
tipo_votacao, |
|||
CASE tipo_votacao |
|||
WHEN 1 THEN 'Simbólica' |
|||
WHEN 2 THEN 'Nominal' |
|||
WHEN 3 THEN 'Secreta' |
|||
WHEN 4 THEN 'Leitura' |
|||
ELSE '' |
|||
END as tipo_votacao_descricao, |
|||
resultado_votacao, |
|||
em.resultado, |
|||
numero_votos, |
|||
votos_parlamentares, |
|||
votacao_aberta |
|||
FROM sessao_expedientemateria em |
|||
JOIN materia_materialegislativa ml ON (em.materia_id = ml.id) |
|||
JOIN materia_tipomaterialegislativa tm ON (ml.tipo_id = tm.id) |
|||
LEFT JOIN LATERAL ( |
|||
SELECT jsonb_build_object( |
|||
'votos_sim', coalesce(rv.numero_votos_sim, 0), |
|||
'votos_nao', coalesce(rv.numero_votos_nao, 0), |
|||
'abstencoes', coalesce(rv.numero_abstencoes, 0), |
|||
'total_votos', coalesce(rv.numero_votos_sim, 0) + |
|||
coalesce(rv.numero_votos_nao, 0) + |
|||
coalesce(rv.numero_abstencoes, 0) |
|||
) as numero_votos, |
|||
trv.nome resultado_votacao |
|||
FROM sessao_registrovotacao rv |
|||
JOIN sessao_tiporesultadovotacao trv on (rv.tipo_resultado_votacao_id = trv.id) |
|||
WHERE rv.expediente_id = em.id AND tipo_votacao != 4) rv ON TRUE |
|||
LEFT JOIN LATERAL ( |
|||
SELECT em.sessao_plenaria_id, |
|||
em.numero_ordem, |
|||
jsonb_object_agg( |
|||
vp.parlamentar_id, |
|||
jsonb_build_object( |
|||
'materia_id', em.materia_id, |
|||
'parlamentar_id', vp.parlamentar_id, |
|||
'parlamentar_nome', p.nome_parlamentar, |
|||
'voto', vp.voto |
|||
)) as votos_parlamentares |
|||
FROM sessao_votoparlamentar vp |
|||
JOIN parlamentares_parlamentar p ON (vp.parlamentar_id = p.id) |
|||
WHERE vp.expediente_id = em.id AND em.tipo_votacao != 4 |
|||
GROUP BY em.sessao_plenaria_id, em.numero_ordem |
|||
) vp ON TRUE |
|||
|
|||
UNION ALL |
|||
|
|||
-- ORDEM DIA |
|||
SELECT od.sessao_plenaria_id, |
|||
od.id id, |
|||
'ordemdia' etapa_sessao, |
|||
od.numero_ordem, |
|||
od.materia_id, |
|||
tm.descricao||' nº '||ml.numero||' de '||ml.ano as materia_texto, |
|||
ml.ementa, |
|||
tipo_votacao, |
|||
CASE tipo_votacao |
|||
WHEN 1 THEN 'Simbólica' |
|||
WHEN 2 THEN 'Nominal' |
|||
WHEN 3 THEN 'Secreta' |
|||
WHEN 4 THEN 'Leitura' |
|||
ELSE '' |
|||
END as tipo_votacao_descricao, |
|||
resultado_votacao, |
|||
od.resultado, |
|||
numero_votos, |
|||
votos_parlamentares, |
|||
votacao_aberta |
|||
FROM sessao_ordemdia od |
|||
JOIN materia_materialegislativa ml ON (od.materia_id = ml.id) |
|||
JOIN materia_tipomaterialegislativa tm ON (ml.tipo_id = tm.id) |
|||
LEFT JOIN LATERAL ( |
|||
SELECT jsonb_build_object( |
|||
'votos_sim', coalesce(rv.numero_votos_sim, 0), |
|||
'votos_nao', coalesce(rv.numero_votos_nao, 0), |
|||
'abstencoes', coalesce(rv.numero_abstencoes, 0), |
|||
'total_votos', coalesce(rv.numero_votos_sim, 0) + |
|||
coalesce(rv.numero_votos_nao, 0) + |
|||
coalesce(rv.numero_abstencoes, 0) |
|||
) as numero_votos, |
|||
trv.nome resultado_votacao |
|||
FROM sessao_registrovotacao rv |
|||
JOIN sessao_tiporesultadovotacao trv on (rv.tipo_resultado_votacao_id = trv.id) |
|||
WHERE rv.ordem_id = od.id AND tipo_votacao != 4) rv ON TRUE |
|||
LEFT JOIN LATERAL ( |
|||
SELECT od.sessao_plenaria_id, |
|||
od.numero_ordem, |
|||
jsonb_object_agg( |
|||
vp.parlamentar_id, |
|||
jsonb_build_object( |
|||
'materia_id', od.materia_id, |
|||
'parlamentar_id', vp.parlamentar_id, |
|||
'parlamentar_nome', p.nome_parlamentar, |
|||
'voto', vp.voto |
|||
)) as votos_parlamentares |
|||
FROM sessao_votoparlamentar vp |
|||
JOIN parlamentares_parlamentar p ON (vp.parlamentar_id = p.id) |
|||
WHERE vp.ordem_id = od.id AND od.tipo_votacao != 4 |
|||
GROUP BY od.sessao_plenaria_id, od.numero_ordem |
|||
) vp ON TRUE |
|||
) |
|||
SELECT * |
|||
FROM votacao_materias |
|||
ORDER BY sessao_plenaria_id, etapa_sessao, numero_ordem |
|||
""", |
|||
reverse_sql="DROP VIEW IF EXISTS sessao_materias_votacoes_view;", |
|||
), |
|||
] |
|||
@ -0,0 +1,53 @@ |
|||
# Generated by Django 2.2.28 on 2026-08-17 19:32 |
|||
|
|||
import django.contrib.postgres.fields.jsonb |
|||
from django.db import migrations, models |
|||
|
|||
|
|||
class Migration(migrations.Migration): |
|||
|
|||
dependencies = [ |
|||
('sessao', '0072_sessao_presencas_e_materias_votacoes_views'), |
|||
] |
|||
|
|||
operations = [ |
|||
migrations.CreateModel( |
|||
name='SessaoMateriasVotacoesView', |
|||
fields=[ |
|||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
|||
('sessao_plenaria_id', models.IntegerField()), |
|||
('etapa_sessao', models.CharField(max_length=20)), |
|||
('numero_ordem', models.IntegerField()), |
|||
('materia_id', models.IntegerField()), |
|||
('materia_texto', models.TextField()), |
|||
('materia_ementa', models.TextField()), |
|||
('tipo_votacao', models.IntegerField()), |
|||
('tipo_votacao_descricao', models.CharField(max_length=20)), |
|||
('resultado_votacao', models.TextField(blank=True, null=True)), |
|||
('resultado', models.TextField(blank=True, null=True)), |
|||
('numero_votos', django.contrib.postgres.fields.jsonb.JSONField(blank=True, null=True)), |
|||
('votos_parlamentares', django.contrib.postgres.fields.jsonb.JSONField(blank=True, null=True)), |
|||
('votacao_aberta', models.NullBooleanField()), |
|||
], |
|||
options={ |
|||
'db_table': 'sessao_materias_votacoes_view', |
|||
'managed': False, |
|||
}, |
|||
), |
|||
migrations.CreateModel( |
|||
name='SessaoPresencasView', |
|||
fields=[ |
|||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
|||
('sessao_plenaria_id', models.IntegerField()), |
|||
('etapa_sessao', models.CharField(max_length=20)), |
|||
('parlamentar_id', models.IntegerField()), |
|||
('nome_parlamentar', models.TextField()), |
|||
('filiacao', models.TextField()), |
|||
('ativo', models.BooleanField()), |
|||
], |
|||
options={ |
|||
'db_table': 'sessao_presencas_view', |
|||
'managed': False, |
|||
}, |
|||
), |
|||
] |
|||
@ -0,0 +1,383 @@ |
|||
import pytest |
|||
from django.db import IntegrityError |
|||
from django.urls import reverse |
|||
from model_bakery import baker |
|||
|
|||
from sapl.materia.models import MateriaLegislativa, TipoMateriaLegislativa |
|||
from sapl.parlamentares.models import (Legislatura, Parlamentar, |
|||
SessaoLegislativa) |
|||
from sapl.sessao.models import (OrdemDia, PresencaOrdemDia, RegistroVotacao, |
|||
SessaoPlenaria, TipoResultadoVotacao, |
|||
TipoSessaoPlenaria, VotoParlamentar) |
|||
|
|||
NOMINAL = 2 |
|||
|
|||
|
|||
def _sessao_plenaria(): |
|||
legislatura = baker.make(Legislatura) |
|||
sessao_legislativa = baker.make(SessaoLegislativa) |
|||
tipo = baker.make(TipoSessaoPlenaria) |
|||
return baker.make(SessaoPlenaria, legislatura=legislatura, |
|||
sessao_legislativa=sessao_legislativa, tipo=tipo, numero=1) |
|||
|
|||
|
|||
def _materia(): |
|||
tipo_materia = baker.make(TipoMateriaLegislativa) |
|||
return baker.make(MateriaLegislativa, tipo=tipo_materia) |
|||
|
|||
|
|||
def _ordem_nominal_aberta(): |
|||
sessao = _sessao_plenaria() |
|||
materia = _materia() |
|||
ordem = baker.make(OrdemDia, sessao_plenaria=sessao, materia=materia, |
|||
tipo_votacao=NOMINAL, votacao_aberta=True, |
|||
registro_aberto=False) |
|||
return sessao, ordem |
|||
|
|||
|
|||
def _presente(sessao): |
|||
parlamentar = baker.make(Parlamentar, ativo=True) |
|||
baker.make(PresencaOrdemDia, sessao_plenaria=sessao, parlamentar=parlamentar) |
|||
return parlamentar |
|||
|
|||
|
|||
def _registrar_url(sessao, ordem): |
|||
return reverse('sapl.sessao:votacaonominal', |
|||
kwargs={'pk': sessao.pk, 'oid': ordem.pk, 'mid': ordem.materia_id}) |
|||
|
|||
|
|||
@pytest.mark.django_db(transaction=False) |
|||
def test_get_registrar_votacao_nao_bloqueia_novos_votos(admin_client): |
|||
""" |
|||
Regressão da causa raiz #1: abrir a tela "Registrar Votação" não pode, |
|||
sozinho, impedir que vereadores que ainda não votaram continuem votando. |
|||
""" |
|||
sessao, ordem = _ordem_nominal_aberta() |
|||
_presente(sessao) |
|||
|
|||
response = admin_client.get(_registrar_url(sessao, ordem)) |
|||
|
|||
assert response.status_code == 200 |
|||
ordem.refresh_from_db() |
|||
assert ordem.registro_aberto is False |
|||
|
|||
|
|||
@pytest.mark.django_db(transaction=False) |
|||
def test_post_sem_acao_reconhecida_apenas_renderiza(admin_client): |
|||
""" |
|||
O botão "Registrar Votação" agora faz POST (para evitar cache/replay de |
|||
GET), mas sem nenhuma chave de ação reconhecida isso deve continuar |
|||
sendo pura navegação, sem nenhum efeito colateral. |
|||
""" |
|||
sessao, ordem = _ordem_nominal_aberta() |
|||
_presente(sessao) |
|||
|
|||
response = admin_client.post(_registrar_url(sessao, ordem), {}) |
|||
|
|||
assert response.status_code == 200 |
|||
ordem.refresh_from_db() |
|||
assert ordem.registro_aberto is False |
|||
|
|||
|
|||
@pytest.mark.django_db(transaction=False) |
|||
def test_bloquear_e_reabrir_votacao_nao_mexe_em_votos_existentes(admin_client): |
|||
sessao, ordem = _ordem_nominal_aberta() |
|||
votante = _presente(sessao) |
|||
baker.make(VotoParlamentar, ordem=ordem, parlamentar=votante, voto='Sim') |
|||
|
|||
url = _registrar_url(sessao, ordem) |
|||
|
|||
response = admin_client.post(url, {'bloquear-registro-votacao': '1'}) |
|||
assert response.status_code == 302 |
|||
ordem.refresh_from_db() |
|||
assert ordem.registro_aberto is True |
|||
|
|||
response = admin_client.post(url, {'reabrir-votacao': '1'}) |
|||
assert response.status_code == 302 |
|||
ordem.refresh_from_db() |
|||
assert ordem.registro_aberto is False |
|||
|
|||
voto = VotoParlamentar.objects.get(ordem=ordem, parlamentar=votante) |
|||
assert voto.voto == 'Sim' |
|||
|
|||
|
|||
@pytest.mark.django_db(transaction=False) |
|||
def test_salvar_votacao_nao_sobrescreve_voto_ja_registrado(admin_client): |
|||
""" |
|||
Regressão do modelo de concorrência (3b): um formulário de "Fechar |
|||
Votação" com um valor obsoleto para quem já votou pelo tablet não pode |
|||
sobrescrever esse voto — só preenche quem ainda não tem voto registrado. |
|||
""" |
|||
sessao, ordem = _ordem_nominal_aberta() |
|||
ja_votou = _presente(sessao) |
|||
ainda_nao_votou = _presente(sessao) |
|||
baker.make(VotoParlamentar, ordem=ordem, parlamentar=ja_votou, voto='Sim') |
|||
|
|||
tipo_resultado = baker.make(TipoResultadoVotacao, nome='Aprovada', natureza='A') |
|||
|
|||
url = _registrar_url(sessao, ordem) |
|||
payload = { |
|||
'salvar-votacao': '1', |
|||
'resultado_votacao': str(tipo_resultado.pk), |
|||
'observacao': '', |
|||
'voto_parlamentar': [ |
|||
# valor obsoleto: a tela do operador ainda não sabia que este |
|||
# parlamentar já havia votado "Sim" pelo tablet |
|||
'Não Votou:{}'.format(ja_votou.pk), |
|||
'Não:{}'.format(ainda_nao_votou.pk), |
|||
], |
|||
} |
|||
|
|||
response = admin_client.post(url, payload) |
|||
assert response.status_code == 302 |
|||
|
|||
voto_ja_votou = VotoParlamentar.objects.get(ordem=ordem, parlamentar=ja_votou) |
|||
assert voto_ja_votou.voto == 'Sim' |
|||
|
|||
voto_novo = VotoParlamentar.objects.get(ordem=ordem, parlamentar=ainda_nao_votou) |
|||
assert voto_novo.voto == 'Não' |
|||
|
|||
registro = RegistroVotacao.objects.get(ordem=ordem) |
|||
assert registro.numero_votos_sim == 1 |
|||
assert registro.numero_votos_nao == 1 |
|||
|
|||
ordem.refresh_from_db() |
|||
assert ordem.votacao_aberta is False |
|||
assert ordem.registro_aberto is False |
|||
|
|||
|
|||
@pytest.mark.django_db(transaction=False) |
|||
def test_salvar_votacao_sem_votos_nao_trava_selects_para_nova_tentativa(admin_client): |
|||
""" |
|||
Regressão: fechar a votação sem nenhum voto real corretamente mostra um |
|||
erro, mas antes disso o laço de salvamento em lote criava um |
|||
VotoParlamentar com voto='Não Votou' para cada parlamentar cujo select |
|||
não foi tocado (o valor padrão do <select>). Esses registros persistiam |
|||
mesmo com o fechamento falhando (o bloco atomic não é revertido, já que |
|||
form_invalid retorna normalmente em vez de lançar), e nominal.html |
|||
desabilita o <select> de qualquer parlamentar com um VotoParlamentar |
|||
existente — travando o operador para sempre sem conseguir registrar |
|||
nenhum voto para essa matéria. |
|||
""" |
|||
sessao, ordem = _ordem_nominal_aberta() |
|||
parlamentar = _presente(sessao) |
|||
tipo_resultado = baker.make(TipoResultadoVotacao, nome='Aprovada', natureza='A') |
|||
|
|||
url = _registrar_url(sessao, ordem) |
|||
payload = { |
|||
'salvar-votacao': '1', |
|||
'resultado_votacao': str(tipo_resultado.pk), |
|||
'observacao': '', |
|||
'voto_parlamentar': ['Não Votou:{}'.format(parlamentar.pk)], |
|||
} |
|||
|
|||
response = admin_client.post(url, payload) |
|||
assert response.status_code == 302 |
|||
|
|||
assert not VotoParlamentar.objects.filter( |
|||
ordem=ordem, parlamentar=parlamentar).exists() |
|||
|
|||
ordem.refresh_from_db() |
|||
assert ordem.votacao_aberta is True |
|||
|
|||
# O operador consegue tentar de novo, agora com um voto real. |
|||
response2 = admin_client.post(url, { |
|||
'salvar-votacao': '1', |
|||
'resultado_votacao': str(tipo_resultado.pk), |
|||
'observacao': '', |
|||
'voto_parlamentar': ['Sim:{}'.format(parlamentar.pk)], |
|||
}) |
|||
assert response2.status_code == 302 |
|||
ordem.refresh_from_db() |
|||
assert ordem.votacao_aberta is False |
|||
assert VotoParlamentar.objects.get(ordem=ordem, parlamentar=parlamentar).voto == 'Sim' |
|||
|
|||
|
|||
@pytest.mark.django_db(transaction=False) |
|||
def test_status_da_votacao_reflete_troca_de_voto_do_parlamentar(admin_client): |
|||
""" |
|||
Regressão: a tela de registro (nominal.html) não atualizava a linha de |
|||
um parlamentar que trocou o voto durante a janela de votação — o poll |
|||
antigo só marcava "já votou" uma vez e nunca revisitava o valor. O poll |
|||
(?status=1) precisa sempre devolver o voto atual, não só se existe. |
|||
""" |
|||
sessao, ordem = _ordem_nominal_aberta() |
|||
parlamentar = _presente(sessao) |
|||
voto = baker.make(VotoParlamentar, ordem=ordem, parlamentar=parlamentar, |
|||
voto='Sim') |
|||
|
|||
url = _registrar_url(sessao, ordem) + '?status=1' |
|||
response = admin_client.get(url) |
|||
assert response.status_code == 200 |
|||
data = response.json() |
|||
assert data['votos'] == {str(parlamentar.pk): 'Sim'} |
|||
assert data['votacao_aberta'] is True |
|||
assert data['registro_aberto'] is False |
|||
assert data['ja_registrada'] is False |
|||
|
|||
voto.voto = 'Não' |
|||
voto.save() |
|||
|
|||
response2 = admin_client.get(url) |
|||
assert response2.json()['votos'] == {str(parlamentar.pk): 'Não'} |
|||
|
|||
|
|||
@pytest.mark.django_db(transaction=False) |
|||
def test_status_da_votacao_nao_depende_de_mostrar_voto(admin_client): |
|||
""" |
|||
O poll da tela de registro é só para a Mesa, não para o público — ao |
|||
contrário de sapl.painel:dados_painel, ele não pode mascarar o valor |
|||
real do voto por trás de "Voto Informado" mesmo quando a Casa configura |
|||
mostrar_voto=False (essa config controla o telão público, não a tela de |
|||
registro da própria Mesa). Como o endpoint nem consulta essa |
|||
configuração, isso é garantido por construção — este teste só |
|||
documenta a expectativa. |
|||
""" |
|||
sessao, ordem = _ordem_nominal_aberta() |
|||
parlamentar = _presente(sessao) |
|||
baker.make(VotoParlamentar, ordem=ordem, parlamentar=parlamentar, |
|||
voto='Abstenção') |
|||
|
|||
url = _registrar_url(sessao, ordem) + '?status=1' |
|||
data = admin_client.get(url).json() |
|||
assert data['votos'][str(parlamentar.pk)] == 'Abstenção' |
|||
|
|||
|
|||
@pytest.mark.django_db(transaction=False) |
|||
def test_status_da_votacao_nao_redireciona_apos_encerrar_votacao( |
|||
admin_client): |
|||
""" |
|||
_get_materia_votacao (usado pelo GET normal) redireciona com uma |
|||
mensagem quando a matéria já foi votada — comportamento certo para |
|||
navegação, errado para um poll em background. O branch ?status=1 não |
|||
pode herdar esse redirect. |
|||
""" |
|||
sessao, ordem = _ordem_nominal_aberta() |
|||
parlamentar = _presente(sessao) |
|||
tipo_resultado = baker.make(TipoResultadoVotacao, nome='Aprovada', |
|||
natureza='A') |
|||
|
|||
url = _registrar_url(sessao, ordem) |
|||
admin_client.post(url, { |
|||
'salvar-votacao': '1', |
|||
'resultado_votacao': str(tipo_resultado.pk), |
|||
'observacao': '', |
|||
'voto_parlamentar': ['Sim:{}'.format(parlamentar.pk)], |
|||
}) |
|||
|
|||
response = admin_client.get(url + '?status=1') |
|||
assert response.status_code == 200 |
|||
data = response.json() |
|||
assert data['ja_registrada'] is True |
|||
assert data['votacao_aberta'] is False |
|||
|
|||
|
|||
@pytest.mark.django_db(transaction=False) |
|||
def test_abrir_votacao_ja_aberta_e_idempotente(admin_client): |
|||
""" |
|||
Regressão: clicar em "Abrir Votação" para uma matéria que já está aberta |
|||
(ex.: duplo clique, ou a lista ainda não recarregou para trocar o botão |
|||
por "Registrar Votação") caía em verifica_votacoes_abertas(), que trata |
|||
a própria matéria como uma "outra" votação conflitante — mostra a |
|||
mensagem "existem votações abertas... foram fechadas" e fecha a matéria |
|||
(para reabri-la em seguida, já que o código sempre prossegue). O |
|||
resultado final até ficava certo (votacao_aberta=True), mas a mensagem |
|||
era enganosa. Reabrir a própria matéria já aberta precisa ser |
|||
silenciosamente idempotente. |
|||
""" |
|||
sessao, ordem = _ordem_nominal_aberta() |
|||
sessao.iniciada = True |
|||
sessao.finalizada = False |
|||
sessao.save() |
|||
_presente(sessao) |
|||
|
|||
url = reverse('sapl.sessao:abrir_votacao', kwargs={'pk': ordem.pk, 'spk': sessao.pk}) |
|||
url += '?tipo_materia=ordem' |
|||
|
|||
response = admin_client.get(url, follow=True) |
|||
|
|||
assert response.status_code == 200 |
|||
ordem.refresh_from_db() |
|||
assert ordem.votacao_aberta is True |
|||
|
|||
mensagens = [str(m) for m in response.context['messages']] |
|||
assert not any('foram fechadas' in m for m in mensagens) |
|||
|
|||
|
|||
@pytest.mark.django_db(transaction=False) |
|||
def test_unique_constraint_impede_voto_duplicado(admin_client): |
|||
sessao, ordem = _ordem_nominal_aberta() |
|||
parlamentar = _presente(sessao) |
|||
baker.make(VotoParlamentar, ordem=ordem, parlamentar=parlamentar, voto='Sim') |
|||
|
|||
with pytest.raises(IntegrityError): |
|||
VotoParlamentar.objects.create( |
|||
ordem=ordem, parlamentar=parlamentar, voto='Não') |
|||
|
|||
|
|||
@pytest.mark.django_db(transaction=False) |
|||
def test_unique_constraint_impede_duas_ordens_abertas(): |
|||
sessao, ordem = _ordem_nominal_aberta() |
|||
outra = baker.make(OrdemDia, sessao_plenaria=sessao, materia=ordem.materia, |
|||
tipo_votacao=NOMINAL, votacao_aberta=False) |
|||
|
|||
with pytest.raises(IntegrityError): |
|||
outra.votacao_aberta = True |
|||
outra.save() |
|||
|
|||
|
|||
@pytest.mark.django_db(transaction=False) |
|||
def test_migracao_0071_fecha_duplicatas_antes_da_constraint(): |
|||
""" |
|||
A função de dados da migration 0071 precisa fechar duplicatas |
|||
pré-existentes antes do AddConstraint — senão a migration falharia ao |
|||
ser aplicada num banco com dado antigo (de antes desta invariante |
|||
existir). Testa a função isoladamente: como o teste roda dentro de uma |
|||
transação que é desfeita no final, é seguro derrubar o índice aqui |
|||
(DDL é transacional no Postgres). |
|||
""" |
|||
import importlib |
|||
|
|||
from django.apps import apps as real_apps |
|||
from django.db import connection |
|||
|
|||
migracao = importlib.import_module( |
|||
'sapl.sessao.migrations.0071_votacao_aberta_unique_constraint') |
|||
|
|||
with connection.cursor() as cursor: |
|||
cursor.execute('DROP INDEX sessao_ordemdia_unique_votacao_aberta') |
|||
|
|||
sessao, mais_antiga = _ordem_nominal_aberta() |
|||
mais_recente = baker.make(OrdemDia, sessao_plenaria=sessao, |
|||
materia=mais_antiga.materia, tipo_votacao=NOMINAL, |
|||
votacao_aberta=True) |
|||
assert mais_recente.pk > mais_antiga.pk |
|||
|
|||
migracao.fecha_matérias_abertas_duplicadas(real_apps, None) |
|||
|
|||
mais_antiga.refresh_from_db() |
|||
mais_recente.refresh_from_db() |
|||
assert mais_antiga.votacao_aberta is False |
|||
assert mais_recente.votacao_aberta is True |
|||
|
|||
|
|||
@pytest.mark.django_db(transaction=False) |
|||
def test_api_nao_permite_abrir_votacao_via_patch(admin_client): |
|||
""" |
|||
Regressão do bypass encontrado na auditoria: a API auto-gerada |
|||
(drfautoapi) não pode mais aceitar votacao_aberta/registro_aberto — |
|||
senão qualquer "Operador de Sessão Plenária" conseguiria abrir uma |
|||
matéria via PATCH direto, pulando verifica_votacoes_abertas() e o |
|||
lock em abrir_votacao(). |
|||
""" |
|||
sessao, ordem = _ordem_nominal_aberta() |
|||
ordem.votacao_aberta = False |
|||
ordem.save() |
|||
|
|||
url = '/api/sessao/ordemdia/{}/'.format(ordem.pk) |
|||
response = admin_client.patch( |
|||
url, data={'votacao_aberta': True}, content_type='application/json') |
|||
|
|||
assert response.status_code in (200, 202) |
|||
ordem.refresh_from_db() |
|||
assert ordem.votacao_aberta is False |
|||
File diff suppressed because it is too large
@ -1,120 +0,0 @@ |
|||
{% load i18n %} |
|||
{% load common_tags %} |
|||
{% load render_bundle from webpack_loader %} |
|||
{% load webpack_static from webpack_loader %} |
|||
|
|||
<!DOCTYPE HTML> |
|||
<!--[if IE 8]> <html class="no-js lt-ie9" lang="pt-br"> <![endif]--> |
|||
<!--[if gt IE 8]><!--> |
|||
<html lang="pt-br"> |
|||
<!--<![endif]--> |
|||
|
|||
<head> |
|||
<meta charset="UTF-8"> |
|||
<!-- TODO: does it need this head_title here? --> |
|||
<title>{% block head_title %}{% trans 'SAPL - Sistema de Apoio ao Processo Legislativo' %}{% endblock %}</title> |
|||
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|||
|
|||
{% render_chunk_vendors 'css' %} |
|||
{% render_bundle 'global' 'css' %} |
|||
{% render_bundle 'painel' 'css' %} |
|||
|
|||
|
|||
<STYLE type="text/css"> |
|||
@media screen { |
|||
body {font-size: medium; color: white; line-height: 1em; background: black;} |
|||
} |
|||
</STYLE> |
|||
|
|||
</head> |
|||
<body> |
|||
<h1>{{ context.title }}</h1> |
|||
<input id="json_url" type="hidden" value="{% url 'sapl.painel:dados_painel' %}"> |
|||
<h2>Ajax refresh counter: <span id="counter"></span></h2> |
|||
<h3> |
|||
<span id="sessao_plenaria"></span><br/><br/> |
|||
<span id="sessao_plenaria_data"></span><br/><br/> |
|||
<span id="sessao_plenaria_hora_inicio"></span></br><br/> |
|||
<h2><span id="relogio"></span></h2></br><br/><br/> |
|||
<span id="materia_legislativa_texto"></span><br/> |
|||
<span id="observacao_materia"></span> |
|||
</h3> |
|||
</body> |
|||
|
|||
{% render_chunk_vendors 'js' %} |
|||
{% render_bundle 'global' 'js' %} |
|||
{% render_bundle 'painel' 'js' %} |
|||
|
|||
<script type="text/javascript"> |
|||
$(document).ready(function() { |
|||
|
|||
//TODO: replace by a fancy jQuery clock |
|||
function checkTime(i) { |
|||
if (i<10) {i = "0" + i}; // add zero in front of numbers < 10 |
|||
return i; |
|||
} |
|||
function startTime() { |
|||
var today=new Date(); |
|||
var h=today.getHours(); |
|||
var m=today.getMinutes(); |
|||
var s=today.getSeconds(); |
|||
m = checkTime(m); |
|||
s = checkTime(s); |
|||
$("#relogio").text(h+":"+m+":"+s) |
|||
var t = setTimeout(function(){ |
|||
startTime() |
|||
},500); |
|||
} |
|||
|
|||
startTime(); |
|||
|
|||
var counter = 1; |
|||
(function poll() { |
|||
$.ajax({ |
|||
url: $("#json_url").val(), |
|||
type: "GET", |
|||
success: function(data) { |
|||
|
|||
//TODO: json spitted out is very complex, have to simplify/flat it |
|||
//TODO: probably building it by hand on REST side |
|||
|
|||
console.debug(data) |
|||
|
|||
var presentes = $("#parlamentares"); |
|||
presentes.children().remove(); |
|||
|
|||
presentes_ordem_dia = data.presentes_ordem_dia |
|||
$.each(presentes_ordem_dia, function(index, parlamentar) { |
|||
$('<li />', {text: parlamentar.nome + '/' + parlamentar.partido + ' ' + parlamentar.voto }).appendTo(presentes); |
|||
}); |
|||
|
|||
var votacao = $("#votacao") |
|||
votacao.children().remove() |
|||
votacao.append("<li>Sim: " + data["numero_votos_sim"] + "</li>") |
|||
votacao.append("<li>Não: " + data["numero_votos_nao"] + "</li>") |
|||
votacao.append("<li>Abstenções: " + data["numero_abstencoes"] + "</li>") |
|||
votacao.append("<li>Presentes: " + data["presentes"] + "</li>") |
|||
votacao.append("<li>Total votos: " + data["total_votos"] + "</li>") |
|||
|
|||
$("#sessao_plenaria").text(data["sessao_plenaria"]) |
|||
$("#sessao_plenaria_data").text("Data Início: " + data["sessao_plenaria_data"]) |
|||
$("#sessao_plenaria_hora_inicio").text("Hora Início: " + data["sessao_plenaria_hora_inicio"]) |
|||
|
|||
$("#materia_legislativa_texto").text(data["materia_legislativa_texto"]) |
|||
$("#observacao_materia").text(data["observacao_materia"]) |
|||
$("#resultado_votacao").text(data["tipo_resultado"]) |
|||
|
|||
$("#counter").text(counter); |
|||
counter++; |
|||
}, |
|||
error: function(err) { |
|||
console.error(err); |
|||
}, |
|||
dataType: "json", |
|||
//complete: setTimeout(function() {poll()}, 5000), |
|||
timeout: 20000 // TODO: decrease |
|||
}) |
|||
})(); |
|||
}); |
|||
</script> |
|||
</html> |
|||
@ -0,0 +1,70 @@ |
|||
{% load i18n %} |
|||
{% load common_tags %} |
|||
|
|||
{% load render_bundle from webpack_loader %} |
|||
{% load webpack_static from webpack_loader %} |
|||
|
|||
<!DOCTYPE HTML> |
|||
<html lang="pt-br"> |
|||
<head> |
|||
<meta charset="UTF-8"> |
|||
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|||
<title>{% block head_title %}{% trans 'SAPL - Sistema de Apoio ao Processo Legislativo' %}{% endblock %}</title> |
|||
|
|||
<link |
|||
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" |
|||
rel="stylesheet" |
|||
/> |
|||
|
|||
{% block webpack_loader_css %} |
|||
{% render_chunk_vendors 'css' %} |
|||
{% render_bundle 'global' 'css' %} |
|||
{% render_bundle 'painel' 'css' %} |
|||
{% endblock webpack_loader_css %} |
|||
</head> |
|||
<body class="min-vh-100 d-flex flex-column p-2"> |
|||
|
|||
{% block vue_content %} |
|||
|
|||
<style>[v-cloak]{display:none}</style> |
|||
<div id="painel" v-cloak class="container-fluid" data-controller-id="{{ controller_id }}"> |
|||
<ws-status-banner :status="wsStatus" :message="wsErrorMessage"></ws-status-banner> |
|||
<div class="row g-3"> |
|||
|
|||
<div class="col-md-9 separador-vertical pe-4"> |
|||
<div class="painel p-3 h-100 d-flex flex-column"> |
|||
<painel-header ref="painelHeader"></painel-header> |
|||
<painel-parlamentares ref="parlamentares"></painel-parlamentares> |
|||
</div> |
|||
</div> |
|||
|
|||
<div class="col-md-3 d-flex flex-column gap-3"> |
|||
<painel-materia ref="materia"></painel-materia> |
|||
|
|||
<painel-oradores ref="oradores"></painel-oradores> |
|||
|
|||
<painel-cronometro-list ref="cronometro_list"></painel-cronometro-list> |
|||
|
|||
<painel-resultado ref="resultado"></painel-resultado> |
|||
|
|||
<div class="painel p-3 d-flex flex-column" id="tema_solene_div" v-if="sessao && sessao.sessao_solene"> |
|||
<h2 class="text-subtitle">Tema da Sessão Solene</h2> |
|||
<span id="sessao_solene_tema" class="text-value">[[ sessao.tema_solene ]]</span> |
|||
</div> |
|||
|
|||
<painel-footer ref="painelFooter"></painel-footer> |
|||
</div> |
|||
|
|||
</div> |
|||
</div> |
|||
{% endblock %} |
|||
</body> |
|||
{% block webpack_loader_js %} |
|||
{% render_chunk_vendors 'js' %} |
|||
{% render_bundle 'global' 'js' %} |
|||
{% render_bundle 'painel' 'js' %} |
|||
{% endblock webpack_loader_js %} |
|||
|
|||
{% block webpack_loader_chunks_js %} |
|||
{% endblock webpack_loader_chunks_js %} |
|||
</html> |
|||
@ -1,128 +0,0 @@ |
|||
{% load i18n %} |
|||
{% load common_tags %} |
|||
|
|||
{% load render_bundle from webpack_loader %} |
|||
{% load webpack_static from webpack_loader %} |
|||
|
|||
<!DOCTYPE HTML> |
|||
<!--[if IE 8]> <html class="no-js lt-ie9" lang="pt-br"> <![endif]--> |
|||
<!--[if gt IE 8]><!--> |
|||
<html lang="pt-br"> |
|||
<!--<![endif]--> |
|||
|
|||
<head> |
|||
<meta charset="UTF-8"> |
|||
<!-- TODO: does it need this head_title here? --> |
|||
<title>{% block head_title %}{% trans 'SAPL - Sistema de Apoio ao Processo Legislativo' %}{% endblock %}</title> |
|||
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|||
|
|||
{% render_chunk_vendors 'css' %} |
|||
{% render_bundle 'global' 'css' %} |
|||
{% render_bundle 'painel' 'css' %} |
|||
|
|||
|
|||
|
|||
<STYLE type="text/css"> |
|||
@media screen { |
|||
body {font-size: medium; color: white; line-height: 1em; background: black;} |
|||
} |
|||
</STYLE> |
|||
|
|||
</head> |
|||
<body> |
|||
<h1>{{ context.title }}</h1> |
|||
<input id="json_url" type="hidden" value="{% url 'sapl.painel:dados_painel' %}"> |
|||
<h3> |
|||
<span id="sessao_plenaria"></span><br/><br/> |
|||
<span id="sessao_plenaria_data"></span><br/><br/> |
|||
<span id="sessao_plenaria_hora_inicio"></span></br><br/> |
|||
<h2><span id="relogio"></span></h2> |
|||
<table> |
|||
<tr> |
|||
<td> |
|||
<ul id="parlamentares"> |
|||
</ul> |
|||
</td> |
|||
</tr> |
|||
</table> |
|||
</h3> |
|||
</body> |
|||
|
|||
{% render_chunk_vendors 'js' %} |
|||
{% render_bundle 'global' 'js' %} |
|||
{% render_bundle 'painel' 'js' %} |
|||
|
|||
<script type="text/javascript"> |
|||
$(document).ready(function() { |
|||
|
|||
//TODO: replace by a fancy jQuery clock |
|||
function checkTime(i) { |
|||
if (i<10) {i = "0" + i}; // add zero in front of numbers < 10 |
|||
return i; |
|||
} |
|||
function startTime() { |
|||
var today=new Date(); |
|||
var h=today.getHours(); |
|||
var m=today.getMinutes(); |
|||
var s=today.getSeconds(); |
|||
m = checkTime(m); |
|||
s = checkTime(s); |
|||
$("#relogio").text(h+":"+m+":"+s) |
|||
var t = setTimeout(function(){ |
|||
startTime() |
|||
},500); |
|||
} |
|||
|
|||
startTime(); |
|||
|
|||
var counter = 1; |
|||
(function poll() { |
|||
$.ajax({ |
|||
url: $("#json_url").val(), |
|||
type: "GET", |
|||
success: function(data) { |
|||
|
|||
//TODO: json spitted out is very complex, have to simplify/flat it |
|||
//TODO: probably building it by hand on REST side |
|||
|
|||
console.debug(data) |
|||
|
|||
var presentes = $("#parlamentares"); |
|||
presentes.children().remove(); |
|||
|
|||
presentes_ordem_dia = data.presentes_ordem_dia |
|||
$.each(presentes_ordem_dia, function(index, parlamentar) { |
|||
$('<li />', {text: parlamentar.nome + '/' + parlamentar.partido }).appendTo(presentes); |
|||
/*$('<li />', {text: parlamentar.nome + '/' + parlamentar.partido + ' ' + parlamentar.voto }).appendTo(presentes);*/ |
|||
}); |
|||
|
|||
var votacao = $("#votacao") |
|||
votacao.children().remove() |
|||
votacao.append("<li>Sim: " + data["numero_votos_sim"] + "</li>") |
|||
votacao.append("<li>Não: " + data["numero_votos_nao"] + "</li>") |
|||
votacao.append("<li>Abstenções: " + data["numero_abstencoes"] + "</li>") |
|||
votacao.append("<li>Presentes: " + data["presentes"] + "</li>") |
|||
votacao.append("<li>Total votos: " + data["total_votos"] + "</li>") |
|||
|
|||
$("#sessao_plenaria").text(data["sessao_plenaria"]) |
|||
$("#sessao_plenaria_data").text("Data Início: " + data["sessao_plenaria_data"]) |
|||
$("#sessao_plenaria_hora_inicio").text("Hora Início: " + data["sessao_plenaria_hora_inicio"]) |
|||
|
|||
$("#materia_legislativa_texto").text(data["materia_legislativa_texto"]) |
|||
$("#observacao_materia").text(data["observacao_materia"]) |
|||
$("#resultado_votacao").text(data["tipo_resultado"]) |
|||
|
|||
$("#counter").text(counter); |
|||
counter++; |
|||
}, |
|||
error: function(err) { |
|||
console.error(err); |
|||
}, |
|||
dataType: "json", |
|||
//complete: setTimeout(function() {poll()}, 5000), |
|||
timeout: 20000 // TODO: decrease |
|||
}) |
|||
})(); |
|||
}); |
|||
</script> |
|||
</html> |
|||
@ -1,123 +0,0 @@ |
|||
{% load i18n %} |
|||
{% load render_bundle from webpack_loader %} |
|||
{% load webpack_static from webpack_loader %} |
|||
|
|||
<!DOCTYPE HTML> |
|||
<!--[if IE 8]> <html class="no-js lt-ie9" lang="pt-br"> <![endif]--> |
|||
<!--[if gt IE 8]><!--> |
|||
<html lang="pt-br"> |
|||
<!--<![endif]--> |
|||
|
|||
<head> |
|||
<meta charset="UTF-8"> |
|||
<!-- TODO: does it need this head_title here? --> |
|||
<title>{% block head_title %}{% trans 'SAPL - Sistema de Apoio ao Processo Legislativo' %}{% endblock %}</title> |
|||
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|||
|
|||
{% render_chunk_vendors 'css' %} |
|||
{% render_bundle 'global' 'css' %} |
|||
{% render_bundle 'painel' 'css' %} |
|||
|
|||
<STYLE type="text/css"> |
|||
@media screen { |
|||
body {font-size: medium; color: white; line-height: 1em; background: black;} |
|||
} |
|||
</STYLE> |
|||
</head> |
|||
<body> |
|||
<h1>{{ context.title }}</h1> |
|||
<input id="json_url" type="hidden" value="{% url 'sapl.painel:dados_painel' %}"> |
|||
<h3> |
|||
<span id="sessao_plenaria"></span><br/><br/> |
|||
<span id="sessao_plenaria_data"></span><br/><br/> |
|||
<span id="sessao_plenaria_hora_inicio"></span></br><br/> |
|||
<h2><span id="relogio"></span></h2> |
|||
<table> |
|||
<tr> |
|||
<td> |
|||
<ul id="votacao"> |
|||
</ul> |
|||
</td> |
|||
</tr> |
|||
</table> |
|||
<span id="resultado_votacao"></span><br/> |
|||
</h3> |
|||
</body> |
|||
|
|||
{% render_chunk_vendors 'js' %} |
|||
{% render_bundle 'global' 'js' %} |
|||
{% render_bundle 'painel' 'js' %} |
|||
|
|||
<script type="text/javascript"> |
|||
$(document).ready(function() { |
|||
|
|||
//TODO: replace by a fancy jQuery clock |
|||
function checkTime(i) { |
|||
if (i<10) {i = "0" + i}; // add zero in front of numbers < 10 |
|||
return i; |
|||
} |
|||
function startTime() { |
|||
var today=new Date(); |
|||
var h=today.getHours(); |
|||
var m=today.getMinutes(); |
|||
var s=today.getSeconds(); |
|||
m = checkTime(m); |
|||
s = checkTime(s); |
|||
$("#relogio").text(h+":"+m+":"+s) |
|||
var t = setTimeout(function(){ |
|||
startTime() |
|||
},500); |
|||
} |
|||
|
|||
startTime(); |
|||
|
|||
var counter = 1; |
|||
(function poll() { |
|||
$.ajax({ |
|||
url: $("#json_url").val(), |
|||
type: "GET", |
|||
success: function(data) { |
|||
|
|||
//TODO: json spitted out is very complex, have to simplify/flat it |
|||
//TODO: probably building it by hand on REST side |
|||
|
|||
console.debug(data) |
|||
|
|||
var presentes = $("#parlamentares"); |
|||
presentes.children().remove(); |
|||
|
|||
presentes_ordem_dia = data.presentes_ordem_dia |
|||
$.each(presentes_ordem_dia, function(index, parlamentar) { |
|||
$('<li />', {text: parlamentar.nome + '/' + parlamentar.partido + ' ' + parlamentar.voto }).appendTo(presentes); |
|||
}); |
|||
|
|||
var votacao = $("#votacao") |
|||
votacao.children().remove() |
|||
votacao.append("<li>Sim: " + data["numero_votos_sim"] + "</li>") |
|||
votacao.append("<li>Não: " + data["numero_votos_nao"] + "</li>") |
|||
votacao.append("<li>Abstenções: " + data["numero_abstencoes"] + "</li>") |
|||
votacao.append("<li>Presentes: " + data["presentes"] + "</li>") |
|||
votacao.append("<li>Total votos: " + data["total_votos"] + "</li>") |
|||
|
|||
$("#sessao_plenaria").text(data["sessao_plenaria"]) |
|||
$("#sessao_plenaria_data").text("Data Início: " + data["sessao_plenaria_data"]) |
|||
$("#sessao_plenaria_hora_inicio").text("Hora Início: " + data["sessao_plenaria_hora_inicio"]) |
|||
|
|||
$("#materia_legislativa_texto").text(data["materia_legislativa_texto"]) |
|||
$("#observacao_materia").text(data["observacao_materia"]) |
|||
$("#resultado_votacao").text(data["tipo_resultado"]) |
|||
|
|||
$("#counter").text(counter); |
|||
counter++; |
|||
}, |
|||
error: function(err) { |
|||
console.error(err); |
|||
}, |
|||
dataType: "json", |
|||
//complete: setTimeout(function() {poll()}, 5000), |
|||
timeout: 20000 // TODO: decrease |
|||
}) |
|||
})(); |
|||
}); |
|||
</script> |
|||
</html> |
|||
@ -0,0 +1,41 @@ |
|||
{% load i18n %} |
|||
{% load common_tags %} |
|||
{% load render_bundle from webpack_loader %} |
|||
{% load webpack_static from webpack_loader %} |
|||
|
|||
<!DOCTYPE HTML> |
|||
<html lang="pt-br"> |
|||
<head> |
|||
<meta charset="UTF-8"> |
|||
<title>{% block head_title %}{% trans 'SAPL - Sistema de Apoio ao Processo Legislativo' %}{% endblock %}</title> |
|||
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|||
|
|||
<link |
|||
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" |
|||
rel="stylesheet" |
|||
/> |
|||
|
|||
{% render_chunk_vendors 'css' %} |
|||
{% render_bundle 'global' 'css' %} |
|||
{% render_bundle 'voto-individual' 'css' %} |
|||
</head> |
|||
<body> |
|||
|
|||
<style>[v-cloak]{display:none}</style> |
|||
<input type="hidden" id="csrf-token" value="{{ csrf_token }}"> |
|||
|
|||
<div id="voto-individual-app" v-cloak data-sessao-id="{{ sessao_id }}"> |
|||
<ws-status-banner :status="wsStatus" :message="wsErrorMessage"></ws-status-banner> |
|||
<voto-individual ref="votoIndividual"></voto-individual> |
|||
</div> |
|||
|
|||
{% render_chunk_vendors 'js' %} |
|||
{% render_bundle 'global' 'js' %} |
|||
{% render_bundle 'voto-individual' 'js' %} |
|||
|
|||
<script type="text/javascript"> |
|||
window.VOTO_INDIVIDUAL_URL = "{% url 'sapl.painel:voto_individual_v2' %}"; |
|||
window.VOTO_INDIVIDUAL_STATUS_URL = "{% url 'sapl.painel:voto_individual_status' %}"; |
|||
</script> |
|||
</body> |
|||
</html> |
|||
@ -0,0 +1,106 @@ |
|||
{% extends "crud/detail.html" %} |
|||
{% load i18n %} |
|||
{% load staticfiles %} |
|||
{% load common_tags %} |
|||
{% load render_bundle from webpack_loader %} |
|||
{% load webpack_static from webpack_loader %} |
|||
|
|||
{% block actions %} {% endblock %} |
|||
|
|||
{% block title %} |
|||
{% endblock %} |
|||
|
|||
{% block vue_content %} |
|||
<div id="painel-controle"> |
|||
<h1 class="page-header"> |
|||
Painel Eletrônico <small>([[ sessao_plenaria ]])</small> |
|||
</h1> |
|||
[[ message ]] |
|||
<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-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(false)" id="id_fechar_painel" class="btn btn-danger btn-sm active" style="display: none;">Fechar Painel</button></div> |
|||
</div> |
|||
<br /> |
|||
|
|||
<h1>Operação do Painel Eletrônico</h1> |
|||
<h2><span id="relogio"></span></h2> |
|||
<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 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> |
|||
{% endblock vue_content %} |
|||
|
|||
{% block webpack_loader_css %} |
|||
{{ block.super }} |
|||
{% render_bundle 'votacao' 'css' %} |
|||
{% endblock %} |
|||
|
|||
{% block webpack_loader_js %} |
|||
{% render_chunk_vendors 'js' %} |
|||
{% render_bundle 'global' 'js' %} |
|||
{% render_bundle 'painel-controle' 'js' %} |
|||
{% endblock %} |
|||
@ -0,0 +1,39 @@ |
|||
{% extends "crud/detail.html" %} |
|||
{% load i18n %} |
|||
{% load common_tags %} |
|||
{% load render_bundle from webpack_loader %} |
|||
{% load webpack_static from webpack_loader %} |
|||
|
|||
{% block vue_content %} |
|||
<style>[v-cloak]{display:none}</style> |
|||
<div id="leitura-materia" v-cloak |
|||
data-sessao-id="{{ pk }}" |
|||
data-oid="{{ oid }}" |
|||
data-mid="{{ mid }}" |
|||
data-materia-texto="{{ materia_texto }}" |
|||
data-materia-ementa="{{ materia_ementa }}" |
|||
data-observacao="{{ observacao }}"> |
|||
<ws-status-banner :status="wsStatus" :message="wsErrorMessage"></ws-status-banner> |
|||
<leitura-materia |
|||
:error-message="error_message" |
|||
:cancel-url="cancelUrl" |
|||
:initial-observacao="initialObservacao" |
|||
:initial-materia-texto="initialMateriaTexto" |
|||
:initial-materia-ementa="initialMateriaEmenta" |
|||
@cancelar="onCancelar" |
|||
@salvar="onSalvar" |
|||
ref="leituraMateria"> |
|||
</leitura-materia> |
|||
</div> |
|||
{% endblock vue_content %} |
|||
|
|||
{% block webpack_loader_css %} |
|||
{{ block.super }} |
|||
{% render_bundle 'leitura-materia' 'css' %} |
|||
{% endblock %} |
|||
|
|||
{% block webpack_loader_js %} |
|||
{% render_chunk_vendors 'js' %} |
|||
{% render_bundle 'global' 'js' %} |
|||
{% render_bundle 'leitura-materia' 'js' %} |
|||
{% endblock %} |
|||
@ -0,0 +1,38 @@ |
|||
{% extends "crud/detail.html" %} |
|||
{% load i18n %} |
|||
{% load crispy_forms_tags cropping%} |
|||
{% load common_tags %} |
|||
{% load render_bundle from webpack_loader %} |
|||
{% load webpack_static from webpack_loader %} |
|||
|
|||
{% block vue_content %} |
|||
<style>[v-cloak]{display:none}</style> |
|||
{{ tipos_resultado|json_script:"tipos-resultado" }} |
|||
<div id="votacao-simbolica" v-cloak |
|||
data-sessao-id="{{ pk }}" |
|||
data-oid="{{ oid }}" |
|||
data-mid="{{ mid }}" |
|||
data-total-presentes="{{ total_presentes }}" |
|||
data-total-votantes="{{ total_votantes }}"> |
|||
<ws-status-banner :status="wsStatus" :message="wsErrorMessage"></ws-status-banner> |
|||
<votacao-simbolica |
|||
:error-message="error_message" |
|||
:total-presentes="totalPresentes" |
|||
:total-votantes="totalVotantes" |
|||
@cancelar="onCancelar" |
|||
@salvar="onSalvar" |
|||
ref="votacaoSimbolica"> |
|||
</votacao-simbolica> |
|||
</div> |
|||
{% endblock vue_content %} |
|||
|
|||
{% block webpack_loader_css %} |
|||
{{ block.super }} |
|||
{% render_bundle 'votacao-simbolica' 'css' %} |
|||
{% endblock %} |
|||
|
|||
{% block webpack_loader_js %} |
|||
{% render_chunk_vendors 'js' %} |
|||
{% render_bundle 'global' 'js' %} |
|||
{% render_bundle 'votacao-simbolica' 'js' %} |
|||
{% endblock %} |
|||
@ -0,0 +1,34 @@ |
|||
{% extends "crud/detail.html" %} |
|||
{% load i18n %} |
|||
{% load crispy_forms_tags cropping%} |
|||
{% load common_tags %} |
|||
{% load render_bundle from webpack_loader %} |
|||
{% load webpack_static from webpack_loader %} |
|||
|
|||
{% block vue_content %} |
|||
<style>[v-cloak]{display:none}</style> |
|||
{{ tipos_resultado|json_script:"tipos-resultado" }} |
|||
<div id="votacao" v-cloak data-controller-id="{{ pk }}"> |
|||
<ws-status-banner :status="wsStatus" :message="wsErrorMessage"></ws-status-banner> |
|||
<votacao-nominal |
|||
:is-open="isOpen" |
|||
:error-message="error_message" |
|||
:votos-status="votosStatus" |
|||
@cast-vote="castVote" |
|||
@cancelar="onCancelar" |
|||
@fechar="onFechar" |
|||
ref="votacaoNominal"> |
|||
</votacao-nominal> |
|||
</div> |
|||
{% endblock vue_content %} |
|||
|
|||
{% block webpack_loader_css %} |
|||
{{ block.super }} |
|||
{% render_bundle 'votacao' 'css' %} |
|||
{% endblock %} |
|||
|
|||
{% block webpack_loader_js %} |
|||
{% render_chunk_vendors 'js' %} |
|||
{% render_bundle 'global' 'js' %} |
|||
{% render_bundle 'votacao' 'js' %} |
|||
{% endblock %} |
|||
@ -0,0 +1,138 @@ |
|||
#!/usr/bin/env bash |
|||
# |
|||
# Starts the two processes needed for manual end-to-end verification of the |
|||
# painel (branch feat/painel-websockets): Redis, and a single Daphne process |
|||
# serving sapl.asgi:application — which routes both regular HTTP (page |
|||
# renders, the views that call broadcast_dados_painel()) and |
|||
# /ws/painel/<sessao_id>/ on the SAME port. |
|||
# |
|||
# This used to run Daphne and `manage.py runserver` on two separate ports. |
|||
# That doesn't work anymore: every screen this branch ships (the public |
|||
# painel, voto individual, votação nominal, and the Vue v2 pages) connects |
|||
# its WebSocket to `window.location.host` — the same host:port the page was |
|||
# loaded from — and none of them fall back to HTTP polling if that socket |
|||
# never connects. Two ports means the WS connection from a page loaded off |
|||
# the runserver port always fails, silently, with no way to notice. |
|||
# |
|||
# See sapl/sessao/README.md for what to actually click through once this is |
|||
# running, and for the daphne-vs-runserver tradeoff during regular dev. |
|||
# |
|||
# Usage: scripts/verify_painel_websockets.sh |
|||
# Stop everything with Ctrl+C. |
|||
# |
|||
# Ports are overridable via env vars if you need to avoid a clash: |
|||
# REDIS_PORT=6380 APP_PORT=8102 scripts/verify_painel_websockets.sh |
|||
|
|||
set -euo pipefail |
|||
|
|||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" |
|||
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" |
|||
|
|||
REDIS_PORT="${REDIS_PORT:-6379}" |
|||
APP_PORT="${APP_PORT:-8002}" |
|||
REDIS_URL="redis://127.0.0.1:${REDIS_PORT}/0" |
|||
|
|||
LOG_DIR="$(mktemp -d /tmp/painel-ws-verify.XXXXXX)" |
|||
REDIS_LOG="$LOG_DIR/redis.log" |
|||
APP_LOG="$LOG_DIR/daphne.log" |
|||
|
|||
REDIS_PID="" |
|||
APP_PID="" |
|||
TAIL_PID="" |
|||
|
|||
log() { echo -e "\033[36m[verify]\033[0m $*"; } |
|||
err() { echo -e "\033[31m[verify]\033[0m $*" >&2; } |
|||
|
|||
port_in_use() { |
|||
lsof -nP -iTCP:"$1" -sTCP:LISTEN >/dev/null 2>&1 |
|||
} |
|||
|
|||
require_cmd() { |
|||
if ! command -v "$1" >/dev/null 2>&1; then |
|||
err "'$1' not found on PATH. Activate the project's virtualenv first." |
|||
exit 1 |
|||
fi |
|||
} |
|||
|
|||
require_port_free() { |
|||
local port="$1" label="$2" |
|||
if port_in_use "$port"; then |
|||
err "Port $port is already in use (needed for $label)." |
|||
err "Find what's on it: lsof -nP -iTCP:$port -sTCP:LISTEN" |
|||
exit 1 |
|||
fi |
|||
} |
|||
|
|||
wait_for_tcp() { |
|||
local port="$1" label="$2" timeout="${3:-15}" waited=0 |
|||
while ! port_in_use "$port"; do |
|||
sleep 0.5 |
|||
waited=$((waited + 1)) |
|||
if (( waited >= timeout * 2 )); then |
|||
err "$label did not start listening on port $port within ${timeout}s." |
|||
err "Check the logs in $LOG_DIR" |
|||
cleanup |
|||
exit 1 |
|||
fi |
|||
done |
|||
} |
|||
|
|||
cleanup() { |
|||
trap - EXIT INT TERM |
|||
log "Stopping..." |
|||
[[ -n "$TAIL_PID" ]] && kill "$TAIL_PID" 2>/dev/null || true |
|||
[[ -n "$APP_PID" ]] && kill "$APP_PID" 2>/dev/null || true |
|||
[[ -n "$REDIS_PID" ]] && kill "$REDIS_PID" 2>/dev/null || true |
|||
wait 2>/dev/null || true |
|||
log "Stopped. Logs kept at $LOG_DIR" |
|||
} |
|||
trap cleanup EXIT INT TERM |
|||
|
|||
require_cmd lsof |
|||
require_cmd redis-server |
|||
require_cmd redis-cli |
|||
require_cmd daphne |
|||
|
|||
require_port_free "$REDIS_PORT" redis-server |
|||
require_port_free "$APP_PORT" daphne |
|||
|
|||
log "Logs: $LOG_DIR" |
|||
|
|||
log "Starting redis-server on port $REDIS_PORT..." |
|||
redis-server --port "$REDIS_PORT" --save "" --appendonly no >"$REDIS_LOG" 2>&1 & |
|||
REDIS_PID=$! |
|||
wait_for_tcp "$REDIS_PORT" redis-server |
|||
redis-cli -p "$REDIS_PORT" ping >/dev/null 2>&1 || { |
|||
err "redis-server started but is not responding to PING — check $REDIS_LOG" |
|||
exit 1 |
|||
} |
|||
log "redis-server ready (pid $REDIS_PID)." |
|||
|
|||
log "Starting daphne (HTTP + WebSocket, one process) on 127.0.0.1:$APP_PORT ..." |
|||
( |
|||
cd "$PROJECT_DIR" |
|||
REDIS_URL="$REDIS_URL" exec daphne -b 127.0.0.1 -p "$APP_PORT" sapl.asgi:application |
|||
) >"$APP_LOG" 2>&1 & |
|||
APP_PID=$! |
|||
wait_for_tcp "$APP_PORT" daphne |
|||
log "daphne ready (pid $APP_PID)." |
|||
|
|||
cat <<EOF |
|||
|
|||
$(printf '\033[32mBoth processes are up.\033[0m') |
|||
|
|||
App (HTTP pages + WebSocket, same port): http://127.0.0.1:$APP_PORT/ |
|||
Redis: 127.0.0.1:$REDIS_PORT |
|||
|
|||
See sapl/sessao/README.md for what to click through and test accounts/data. |
|||
There's no HTTP-polling fallback on any of these screens — if a page never |
|||
updates, check this terminal's daphne log for a WebSocket close/error first. |
|||
|
|||
Press Ctrl+C to stop both. |
|||
|
|||
EOF |
|||
|
|||
log "Tailing logs (Ctrl+C to stop everything)..." |
|||
tail -n +1 -f "$REDIS_LOG" "$APP_LOG" & |
|||
TAIL_PID=$! |
|||
wait "$TAIL_PID" |
|||
@ -0,0 +1,6 @@ |
|||
# DEBUG |
|||
daphne -b 127.0.0.1 -p 8000 sapl.asgi:application |
|||
|
|||
# Redis-CLI |
|||
redis-cli -h localhost -p 6379 |
|||
|
|||
Loading…
Reference in new issue