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 './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 @@ |
|||||
|
:root { |
||||
.painel-principal { |
--bg-dark: #121212; |
||||
background: #1c1b1b; |
--bg-panel: #1e1e1e; |
||||
font-family: Verdana; |
--text-main: #e0e0e0; |
||||
font-size: x-large; |
--text-highlight: #4fa64d; |
||||
.text-title { |
|
||||
color: #4fa64d; |
|
||||
margin: 0.5rem; |
|
||||
font-weight: bold; |
|
||||
} |
|
||||
.text-subtitle { |
|
||||
color: #459170; |
|
||||
font-weight: bold; |
|
||||
} |
|
||||
.data-hora { |
|
||||
font-size: 180%; |
|
||||
} |
} |
||||
|
|
||||
.text-value { |
body { |
||||
color: white; |
background-color: var(--bg-dark); |
||||
|
color: var(--text-main); |
||||
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; |
||||
|
margin-bottom: 0; |
||||
} |
} |
||||
|
|
||||
.logo-painel { |
.separador-vertical { |
||||
max-width: 100%; |
border-right: 1px solid #333; |
||||
} |
min-height: 100vh; |
||||
.painels { |
margin: 0; |
||||
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; |
|
||||
} |
|
||||
} |
} |
||||
|
|
||||
|
.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 |
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__) |
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