mirror of https://github.com/interlegis/sapl.git
committed by
GitHub
233 changed files with 8201 additions and 17708 deletions
@ -0,0 +1 @@ |
|||
3.12 |
|||
@ -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,125 @@ |
|||
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, |
|||
fase: 'ordem', |
|||
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.fase = el.dataset.fase || 'ordem' |
|||
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, iso=0 Expediente.
|
|||
const iso = this.fase === 'expediente' ? 0 : 1 |
|||
this.cancelUrl = `/sessao/${this.sessaoId}/${iso}/${this.oid}/retirar-leitura` |
|||
|
|||
if (this.sessaoId) { |
|||
this.connectWS() |
|||
} |
|||
}, |
|||
methods: { |
|||
painelStore () { |
|||
return usePainelStore() |
|||
}, |
|||
actionUrl () { |
|||
const etapa = this.fase === 'expediente' ? 'expediente' : 'ordemdia' |
|||
return `/sessao/${this.sessaoId}/materia/${etapa}/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.payload) |
|||
} |
|||
} 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,177 @@ |
|||
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.payload); |
|||
} 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 { |
|||
// console.log(`Data received from ws: ${JSON.stringify(message.data)}`);
|
|||
const data = JSON.parse(message.data); |
|||
console.log(JSON.stringify(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 { |
|||
--bg-dark: #121212; |
|||
--bg-panel: #1e1e1e; |
|||
--text-main: #e0e0e0; |
|||
--text-highlight: #4fa64d; |
|||
} |
|||
|
|||
.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%; |
|||
} |
|||
body { |
|||
background-color: var(--bg-dark); |
|||
color: var(--text-main); |
|||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; |
|||
margin-bottom: 0; |
|||
} |
|||
|
|||
.text-value { |
|||
color: white; |
|||
} |
|||
.separador-vertical { |
|||
border-right: 1px solid #333; |
|||
min-height: 100vh; |
|||
margin: 0; |
|||
} |
|||
|
|||
.logo-painel { |
|||
max-width: 100%; |
|||
} |
|||
.painels { |
|||
flex-wrap: wrap; |
|||
} |
|||
.painel{ |
|||
margin-top: 1rem; |
|||
table { |
|||
width: 100%; |
|||
} |
|||
h2 { |
|||
margin-bottom: 0.5rem; |
|||
} |
|||
#votacao, #oradores_list { |
|||
text-align: left; |
|||
display: inline-block; |
|||
margin-bottom: 1rem; |
|||
} |
|||
} |
|||
.text-subtitle { |
|||
color: #81c784; |
|||
font-weight: 600; |
|||
} |
|||
@ -0,0 +1,88 @@ |
|||
// 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 |
|||
this.cronometro_ativo = next.cronometro_ativo |
|||
|
|||
// 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 |
|||
this.registro_aberto = next.registro_aberto |
|||
|
|||
// 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,182 @@ |
|||
// 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: null, |
|||
cronometro_aparte: null, |
|||
cronometro_ordem: null, |
|||
cronometro_consideracoes: null, |
|||
cronometro_ativo: 'discurso', |
|||
|
|||
// Só significa algo enquanto há matéria nominal aberta pra registro —
|
|||
// ver PainelConsumer (type: "registro_toggle") / VotacaoNominal.vue.
|
|||
registro_aberto: false, |
|||
|
|||
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 |
|||
} |
|||
|
|||
/** |
|||
* Normaliza o valor do cronômetro recebido do backend. |
|||
* |
|||
* O backend novo envia um dict: |
|||
* { action, duration, start_ts?, remaining_at_stop? } |
|||
* |
|||
* O backend antigo (cache legado) pode enviar uma string ('start'|'stop'|'reset'). |
|||
* O valor null/undefined significa "sem estado" (nunca foi configurado). |
|||
* |
|||
* Retorna sempre um objeto normalizado ou null. |
|||
*/ |
|||
function normalizeCronometro (v) { |
|||
if (v == null) return null |
|||
// Formato legado: string simples
|
|||
if (typeof v === 'string') { |
|||
return { |
|||
action: v, |
|||
duration: 300, |
|||
start_ts: null, |
|||
remaining_at_start: 300, |
|||
remaining_at_stop: null, |
|||
updated_at: null |
|||
} |
|||
} |
|||
if (typeof v === 'object') { |
|||
const duration = v.duration != null ? v.duration : 300 |
|||
const remaining_at_start = v.remaining_at_start != null ? v.remaining_at_start : duration |
|||
const remaining_at_stop = v.remaining_at_stop != null ? v.remaining_at_stop : null |
|||
return { |
|||
action: v.action || '', |
|||
duration, |
|||
start_ts: v.start_ts != null ? v.start_ts : null, |
|||
remaining_at_start, |
|||
remaining_at_stop, |
|||
updated_at: v.updated_at != null ? v.updated_at : null |
|||
} |
|||
} |
|||
return null |
|||
} |
|||
|
|||
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 : '', |
|||
// Só true quando o próprio parlamentar votou (voto individual/
|
|||
// tablet) — nunca quando o voto veio do operador pelo mesmo
|
|||
// <select> que lê isto, mesmo que o valor pareça igual.
|
|||
voto_por_tablet: !!p.voto_por_tablet |
|||
})) |
|||
|
|||
const oradores = asArray(d.oradores) |
|||
.filter((o) => o && typeof o === 'object') |
|||
.map((o) => ({ |
|||
ordem_pronunciamento: o.numero, |
|||
nome_parlamentar: asString(o.nome, '') |
|||
})) |
|||
|
|||
console.log(d.sessao_iniciada) |
|||
|
|||
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) |
|||
}, |
|||
|
|||
// Cronômetros: agora objetos {action, duration, start_ts?, remaining_at_stop?}
|
|||
// ou null quando nunca configurados. O store repassa para CronometroList.vue
|
|||
// que aplica a ação e configura o tempo inicial de cada Cronometro.vue.
|
|||
cronometro_discurso: normalizeCronometro(d.cronometro_discurso), |
|||
cronometro_aparte: normalizeCronometro(d.cronometro_aparte), |
|||
cronometro_ordem: normalizeCronometro(d.cronometro_ordem), |
|||
cronometro_consideracoes: normalizeCronometro(d.cronometro_consideracoes), |
|||
cronometro_ativo: asString(d.cronometro_ativo, DEFAULT_STATE.cronometro_ativo), |
|||
|
|||
registro_aberto: d.registro_aberto != null ? !!d.registro_aberto : DEFAULT_STATE.registro_aberto, |
|||
|
|||
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,41 @@ |
|||
// 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. Tentando reconectar…' |
|||
@ -0,0 +1,28 @@ |
|||
import './scss/votacao-nominal-bloco.scss' |
|||
import Vue from 'vue' |
|||
|
|||
import VotacaoNominalBloco from '../../components/votacao-nominal-bloco/VotacaoNominalBloco.vue' |
|||
|
|||
Vue.component('votacao-nominal-bloco', VotacaoNominalBloco) |
|||
|
|||
// Sem Pinia/WebSocket aqui de propósito: este formulário é preenchido e
|
|||
// submetido de uma vez (POST nativo pra VotacaoEmBlocoNominalView, que
|
|||
// continua sem mudanças), não uma tela de estado ao vivo — a única coisa
|
|||
// que precisava parar de usar polling era a soma dos votos, que já vira
|
|||
// reatividade normal do Vue dentro de VotacaoNominalBloco.vue.
|
|||
const parlamentaresEl = document.getElementById('parlamentares-data') |
|||
let parlamentares = [] |
|||
if (parlamentaresEl) { |
|||
try { |
|||
parlamentares = JSON.parse(parlamentaresEl.textContent) |
|||
} catch (e) { |
|||
console.error('Erro ao ler parlamentares-data:', e) |
|||
} |
|||
} |
|||
|
|||
new Vue({ // eslint-disable-line
|
|||
el: '#app-votacao-nominal-bloco', |
|||
data () { |
|||
return { parlamentares } |
|||
} |
|||
}) |
|||
@ -0,0 +1 @@ |
|||
/* estilos específicos da votação nominal em bloco, se necessário */ |
|||
@ -0,0 +1,137 @@ |
|||
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, |
|||
fase: 'ordem', |
|||
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.fase = el.dataset.fase || 'ordem' |
|||
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/VotacaoExpedienteView) —
|
|||
// sessao/urls.py: continua sendo quem grava o voto (só o shell/GET
|
|||
// mudou pra v2); fase decide ordem do dia ou expediente, mesmo
|
|||
// discriminador usado pelo shell (votacao_simbolica_v2_view).
|
|||
const etapa = this.fase === 'expediente' ? 'expediente' : 'ordemdia' |
|||
return `/sessao/${this.sessaoId}/materia/${etapa}/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') { |
|||
// applyData() espera o payload cru, não o envelope {type,
|
|||
// payload} (mesmo ajuste de painel/votacao/voto-individual
|
|||
// main.js).
|
|||
this.painelStore().applyData(data.payload) |
|||
} |
|||
} 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,307 @@ |
|||
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: '', |
|||
} |
|||
}, |
|||
|
|||
computed: { |
|||
...mapState(usePainelStore, [ |
|||
'sessao_aberta', 'painel_aberto', 'sessao', |
|||
'parlamentares', 'materia', 'resultado', 'message', 'mostrar_voto', |
|||
'registro_aberto' |
|||
]), |
|||
// Estado real (não mascarado) de quem já votou — a conexão WS desta
|
|||
// tela é reconhecida como operador/Mesa pelo backend
|
|||
// (PainelConsumer.is_operator), então parlamentares[].voto já vem sem
|
|||
// máscara aqui, ao contrário da conexão do telão público. Substitui o
|
|||
// fetch a /votos-status (removido — ver PainelConsumer.painel_refresh).
|
|||
votosStatus () { |
|||
const map = {} |
|||
this.parlamentares.forEach((p) => { |
|||
if (p.voto) map[p.parlamentar_id] = p.voto |
|||
}) |
|||
return map |
|||
}, |
|||
// Quais linhas devem ficar travadas pro operador — só quando o voto
|
|||
// veio do próprio parlamentar (tablet), nunca quando veio do próprio
|
|||
// operador (por este <select> ou pelo formulário legado em lote):
|
|||
// votosStatus sozinho não distingue isso, ver voto_por_tablet.
|
|||
votosTravados () { |
|||
const map = {} |
|||
this.parlamentares.forEach((p) => { |
|||
if (p.voto_por_tablet) map[p.parlamentar_id] = true |
|||
}) |
|||
return map |
|||
}, |
|||
}, |
|||
|
|||
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.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}/` |
|||
}, |
|||
|
|||
// Voto vai pelo WebSocket já aberto (PainelConsumer.receive_json,
|
|||
// type: "vote") em vez de um POST HTTP separado — o consumer grava e
|
|||
// já dispara o refresh pro grupo (ver vote_ack/vote_error no
|
|||
// listener de mensagens). vote_controller (HTTP) continua existindo
|
|||
// por compatibilidade, mas esta tela não o chama mais.
|
|||
castVote ({ parlamentar_id, voto }) { |
|||
console.log(`Casting vote: parlamentar=${parlamentar_id}, voto=${voto}`) |
|||
if (!this.isOpen) { |
|||
this.error_message = 'Sem conexão em tempo real. Aguarde reconectar e tente de novo.' |
|||
return |
|||
} |
|||
this.ws.send(JSON.stringify({ type: 'vote', parlamentar_id, voto })) |
|||
}, |
|||
|
|||
// Bloqueia/reabre o registro de novos votos — equivalente ao
|
|||
// bloquear-registro-votacao/reabrir-votacao de nominal.html (tela
|
|||
// legada), agora pelo WS. aberto=true BLOQUEIA (nome do campo
|
|||
// registro_aberto é herdado do modelo, mesma semântica de sempre).
|
|||
toggleRegistro (aberto) { |
|||
if (!this.isOpen) { |
|||
this.error_message = 'Sem conexão em tempo real. Aguarde reconectar e tente de novo.' |
|||
return |
|||
} |
|||
this.ws.send(JSON.stringify({ type: 'registro_toggle', aberto })) |
|||
}, |
|||
|
|||
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.
|
|||
// applyData() espera o payload cru (normalizePainelData() lê
|
|||
// d.sessao_iniciada etc. diretamente) — não o envelope {type,
|
|||
// payload} inteiro da mensagem WS, senão sessao_aberta/
|
|||
// painel_aberto nunca saem de DEFAULT_STATE e a tela não renderiza
|
|||
// nada. Mesmo ajuste já feito em painel/main.js e voto-individual/
|
|||
// main.js.
|
|||
this.painelStore().applyData(data.payload) |
|||
} catch (e) { |
|||
console.error('Error updating state:', e) |
|||
} |
|||
}, |
|||
|
|||
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') |
|||
} else if (data.type === 'vote_ack') { |
|||
console.log('Voto confirmado pelo servidor:', data) |
|||
this.error_message = '' |
|||
} else if (data.type === 'vote_error') { |
|||
console.error('Erro ao registrar voto:', data.message) |
|||
this.error_message = data.message || 'Erro ao registrar voto. Tente novamente.' |
|||
} else if (data.type === 'registro_toggle_ack') { |
|||
console.log('Registro bloqueado/reaberto:', data) |
|||
this.error_message = '' |
|||
} else if (data.type === 'registro_toggle_error') { |
|||
console.error('Erro ao bloquear/reabrir registro:', data.message) |
|||
this.error_message = data.message || 'Erro ao bloquear/reabrir registro. Tente novamente.' |
|||
} |
|||
} 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,116 @@ |
|||
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)
|
|||
console.log(`${JSON.stringify(data.payload)}`); |
|||
this.painelStore().applyData(data.payload) |
|||
// 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,168 @@ |
|||
<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: 'PainelCronometro', |
|||
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' |
|||
}, |
|||
|
|||
/** |
|||
* Aplica o estado completo recebido do backend. |
|||
* |
|||
* @param {Object} state - { action, duration, start_ts?, remaining_at_stop? } |
|||
* |
|||
* Lógica: |
|||
* - reset / sem estado → para, define time = duration |
|||
* - stop → para, define time = remaining_at_stop (tempo no momento do stop) |
|||
* - start → calcula tempo restante a partir de start_ts + duration - agora |
|||
* e inicia a contagem (idempotente: se já estiver rodando com |
|||
* o mesmo start_ts, não reinicia) |
|||
*/ |
|||
applyState (state) { |
|||
if (!state || !state.action) { |
|||
// Sem estado: apenas configura a duração e para |
|||
this.stop() |
|||
this.time = this.initialTime |
|||
return |
|||
} |
|||
|
|||
const { action, duration, start_ts, remaining_at_start, remaining_at_stop } = state |
|||
|
|||
// Atualiza a duração configurada |
|||
if (duration != null) { |
|||
this.initialTime = duration |
|||
} |
|||
|
|||
if (action === 'reset') { |
|||
this.stop() |
|||
this.time = this.initialTime |
|||
} else if (action === 'stop') { |
|||
this.stop() |
|||
// Mostra o tempo exato em que o operador parou o cronômetro |
|||
if (remaining_at_stop != null) { |
|||
this.time = Math.max(0, Math.round(remaining_at_stop)) |
|||
} |
|||
} else if (action === 'start') { |
|||
// Calcula o tempo restante a partir de remaining_at_start e start_ts |
|||
const nowSec = Date.now() / 1000 |
|||
const elapsed = start_ts != null ? Math.max(0, nowSec - start_ts) : 0 |
|||
const baseRem = remaining_at_start != null |
|||
? remaining_at_start |
|||
: (remaining_at_stop != null |
|||
? remaining_at_stop |
|||
: (this.time > 0 && this.time < this.initialTime ? this.time : this.initialTime)) |
|||
const remaining = Math.max(0, baseRem - elapsed) |
|||
|
|||
// Idempotente: se já estiver rodando e a contagem estiver em sincronia |
|||
// (diferença <= 2s), mantém o intervalo nativo para não ter saltos |
|||
if (!this.isRunning || Math.abs(this.time - remaining) > 2) { |
|||
this.stop() |
|||
this.time = Math.round(remaining) |
|||
if (remaining > 0) { |
|||
this._startInterval() |
|||
} |
|||
} |
|||
} |
|||
|
|||
this.$emit('state-changed', { id: this.id, isRunning: this.isRunning }) |
|||
}, |
|||
|
|||
_startInterval () { |
|||
this.isRunning = true |
|||
this.intervalId = setInterval(() => { |
|||
if (this.time > 0) { |
|||
this.time-- |
|||
if (this.time === 30) { |
|||
this.playSound() |
|||
} |
|||
} else { |
|||
this.isRunning = false |
|||
clearInterval(this.intervalId) |
|||
this.playSound() |
|||
this.$emit('state-changed', { id: this.id, isRunning: false }) |
|||
} |
|||
}, 1000) |
|||
}, |
|||
|
|||
// start/stop/reset legados mantidos para compatibilidade com qualquer |
|||
// chamador externo, mas internamente agora delegam para applyState(). |
|||
start () { |
|||
if (this.isRunning) return |
|||
this._startInterval() |
|||
}, |
|||
|
|||
stop () { |
|||
this.isRunning = false |
|||
clearInterval(this.intervalId) |
|||
this.intervalId = null |
|||
}, |
|||
|
|||
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,213 @@ |
|||
<template> |
|||
<!-- |
|||
v-if="canRender": cronômetros só aparecem quando o painel estiver aberto. |
|||
Enquanto o painel está fechado, os componentes não são montados e portanto |
|||
nenhum timer roda em background. |
|||
--> |
|||
<div class="painel d-flex flex-column" v-if="canRender"> |
|||
<div 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" |
|||
@state-changed="handleStateChanged" |
|||
/> |
|||
<Cronometro |
|||
ref="childRef_1" |
|||
id="aparte" |
|||
title="Aparte" |
|||
:visible="visibleCronometro === 'aparte'" |
|||
color-class="text-warning" |
|||
@child-mounted="handleChildMounted" |
|||
@state-changed="handleStateChanged" |
|||
/> |
|||
<Cronometro |
|||
ref="childRef_2" |
|||
id="ordem" |
|||
title="Questão de Ordem" |
|||
:visible="visibleCronometro === 'ordem'" |
|||
color-class="text-info" |
|||
@child-mounted="handleChildMounted" |
|||
@state-changed="handleStateChanged" |
|||
/> |
|||
<Cronometro |
|||
ref="childRef_3" |
|||
id="consideracoes" |
|||
title="Consid. Finais" |
|||
:visible="visibleCronometro === 'consideracoes'" |
|||
color-class="" |
|||
@child-mounted="handleChildMounted" |
|||
@state-changed="handleStateChanged" |
|||
/> |
|||
</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 { |
|||
// Qual cronômetro está visível no momento. |
|||
// Regra de prioridade ao rodar: ordem > aparte > consideracoes > discurso. |
|||
// Quando nenhum está rodando: mantém o último visível (para mostrar |
|||
// o tempo "parado" do cronômetro que acabou de ser parado). |
|||
visibleCronometro: 'discurso', |
|||
childrenMounted: 0, |
|||
allChildrenMounted: false |
|||
} |
|||
}, |
|||
mounted () { |
|||
console.log('CronometroList mounted') |
|||
// Os filhos podem não ter montado ainda — aplica via nextTick após |
|||
// todos os filhos emitirem 'child-mounted' (ver handleChildMounted). |
|||
}, |
|||
computed: { |
|||
...mapState(usePainelStore, [ |
|||
'canRender', |
|||
'cronometro_ativo', |
|||
'cronometro_discurso', 'cronometro_aparte', |
|||
'cronometro_ordem', 'cronometro_consideracoes' |
|||
]) |
|||
}, |
|||
watch: { |
|||
// Quando o painel abre (canRender muda para true), os filhos |
|||
// acabam de montar — aplica o estado inicial de todos. |
|||
canRender (newVal) { |
|||
if (newVal) { |
|||
this.$nextTick(() => this.applyAllStates()) |
|||
} |
|||
}, |
|||
|
|||
cronometro_ativo (newVal) { |
|||
if (newVal) { |
|||
this.updateVisibility() |
|||
} |
|||
}, |
|||
|
|||
// Watchers profundos para cada cronômetro: disparam quando o objeto |
|||
// mudar (nova action, duration, etc.) mas NÃO quando o broadcast |
|||
// chega com o mesmo valor (deep watch compara referência, mas o |
|||
// store substitui o objeto inteiro — a comparação de ação é feita |
|||
// dentro de applyState do filho para ser idempotente). |
|||
cronometro_discurso (newVal) { |
|||
this.applyOneState('childRef_0', newVal) |
|||
}, |
|||
cronometro_aparte (newVal) { |
|||
this.applyOneState('childRef_1', newVal) |
|||
}, |
|||
cronometro_ordem (newVal) { |
|||
this.applyOneState('childRef_2', newVal) |
|||
}, |
|||
cronometro_consideracoes (newVal) { |
|||
this.applyOneState('childRef_3', newVal) |
|||
} |
|||
}, |
|||
methods: { |
|||
/** |
|||
* Aplica o estado de todos os cronômetros de uma vez. |
|||
* Usado na montagem e quando o painel é aberto. |
|||
*/ |
|||
applyAllStates () { |
|||
this.applyOneState('childRef_0', this.cronometro_discurso) |
|||
this.applyOneState('childRef_1', this.cronometro_aparte) |
|||
this.applyOneState('childRef_2', this.cronometro_ordem) |
|||
this.applyOneState('childRef_3', this.cronometro_consideracoes) |
|||
this.updateVisibility() |
|||
}, |
|||
|
|||
/** |
|||
* Aplica o estado de um único cronômetro chamando applyState() no filho. |
|||
* @param {string} refName - nome do ref ('childRef_0' etc.) |
|||
* @param {Object|null} state - { action, duration, start_ts?, remaining_at_stop? } |
|||
*/ |
|||
applyOneState (refName, state) { |
|||
const comp = this.$refs[refName] |
|||
if (!comp) return |
|||
comp.applyState(state) |
|||
this.updateVisibility() |
|||
}, |
|||
|
|||
/** |
|||
* Chamado quando um filho monta. Quando todos os 4 filhos |
|||
* montaram, aplica o estado inicial. |
|||
*/ |
|||
handleChildMounted () { |
|||
this.childrenMounted++ |
|||
console.log(`Cronometro child mounted (${this.childrenMounted}/4)`) |
|||
if (this.childrenMounted >= 4) { |
|||
this.allChildrenMounted = true |
|||
this.applyAllStates() |
|||
} |
|||
}, |
|||
|
|||
/** |
|||
* Chamado quando um filho muda de estado (start/stop). |
|||
* Atualiza a visibilidade. |
|||
*/ |
|||
handleStateChanged () { |
|||
this.updateVisibility() |
|||
}, |
|||
|
|||
/** |
|||
* Decide qual cronômetro mostrar. |
|||
* |
|||
* Prioridade ao rodar: ordem > aparte > consideracoes > discurso. |
|||
* Se nenhum estiver rodando, mantém o último visível para exibir |
|||
* o tempo no momento em que foi parado (comportamento igual ao discurso). |
|||
*/ |
|||
updateVisibility () { |
|||
// Prioridade 1: se algum cronômetro estiver rodando, mostra o que está rodando |
|||
const priority = [ |
|||
{ key: 'ordem', ref: 'childRef_2' }, |
|||
{ key: 'aparte', ref: 'childRef_1' }, |
|||
{ key: 'consideracoes', ref: 'childRef_3' }, |
|||
{ key: 'discurso', ref: 'childRef_0' } |
|||
] |
|||
for (const item of priority) { |
|||
const comp = this.$refs[item.ref] |
|||
if (comp && comp.isRunning) { |
|||
this.visibleCronometro = item.key |
|||
return |
|||
} |
|||
} |
|||
|
|||
// Prioridade 2: nenhum rodando. Mostra o último cronômetro operado pelo operador |
|||
if (this.cronometro_ativo) { |
|||
this.visibleCronometro = this.cronometro_ativo |
|||
return |
|||
} |
|||
|
|||
// Fallback padrão |
|||
this.visibleCronometro = 'discurso' |
|||
} |
|||
} |
|||
} |
|||
</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,74 @@ |
|||
<template> |
|||
<div> |
|||
<fieldset class="form-group"> |
|||
<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"> |
|||
<input v-if="p.voto" type="hidden" name="voto_parlamentar" |
|||
:value="p.voto + ':' + p.parlamentar_id"> |
|||
<select class="form-control" |
|||
:name="p.voto ? null : 'voto_parlamentar'" |
|||
:disabled="!!p.voto" |
|||
v-model="votos[p.parlamentar_id]"> |
|||
<option :value="'Não Votou:' + p.parlamentar_id">Não Votou</option> |
|||
<option :value="'Sim:' + p.parlamentar_id">Sim</option> |
|||
<option :value="'Não:' + p.parlamentar_id">Não</option> |
|||
<option :value="'Abstenção:' + p.parlamentar_id">Abstenção</option> |
|||
</select> |
|||
</div> |
|||
</template> |
|||
</div> |
|||
<br> |
|||
<legend>Situação da Votação:</legend> |
|||
<div id="soma_votos"> |
|||
<div class="row"><div class="col-md-12">Sim: {{ tally.sim }}</div></div> |
|||
<div class="row"><div class="col-md-12">Não: {{ tally.nao }}</div></div> |
|||
<div class="row"><div class="col-md-12">Abstenções: {{ tally.abstencao }}</div></div> |
|||
<div class="row"><div class="col-md-12">Ainda não votaram: {{ tally.naoVotou }}</div></div> |
|||
</div> |
|||
<input type="hidden" name="votos_sim" :value="tally.sim"> |
|||
<input type="hidden" name="votos_nao" :value="tally.nao"> |
|||
<input type="hidden" name="abstencoes" :value="tally.abstencao"> |
|||
<input type="hidden" name="nao_votou" :value="tally.naoVotou"> |
|||
</fieldset> |
|||
</div> |
|||
</template> |
|||
|
|||
<script> |
|||
export default { |
|||
name: 'VotacaoNominalBloco', |
|||
props: { |
|||
parlamentares: { |
|||
type: Array, |
|||
default: () => [] |
|||
} |
|||
}, |
|||
data () { |
|||
const votos = {} |
|||
this.parlamentares.forEach((p) => { |
|||
votos[p.parlamentar_id] = (p.voto || 'Não Votou') + ':' + p.parlamentar_id |
|||
}) |
|||
return { votos } |
|||
}, |
|||
computed: { |
|||
// Substitui o antigo setTimeout(conta_votos, 500), que recontava o |
|||
// DOM inteiro a cada 500ms — a reatividade do Vue já refaz esta conta |
|||
// sozinha quando qualquer <select> muda, sem timer nenhum. |
|||
tally () { |
|||
const t = { sim: 0, nao: 0, abstencao: 0, naoVotou: 0 } |
|||
this.parlamentares.forEach((p) => { |
|||
const valor = (this.votos[p.parlamentar_id] || '').split(':')[0] |
|||
if (valor === 'Sim') t.sim++ |
|||
else if (valor === 'Não') t.nao++ |
|||
else if (valor === 'Abstenção') t.abstencao++ |
|||
else t.naoVotou++ |
|||
}) |
|||
return t |
|||
} |
|||
} |
|||
} |
|||
</script> |
|||
@ -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,101 @@ |
|||
<template> |
|||
<fieldset> |
|||
<legend>Votação Nominal</legend> |
|||
|
|||
<div v-if="error_message" class="alert alert-danger"> |
|||
{{ error_message }} |
|||
</div> |
|||
|
|||
<div class="alert" :class="registroAberto ? 'alert-warning' : 'alert-info'"> |
|||
<strong>Status da votação:</strong> em aberto · |
|||
<span v-if="registroAberto">novos votos <strong>bloqueados</strong> — os vereadores que ainda não votaram não conseguem mais votar até a Mesa reabrir</span> |
|||
<span v-else>novos votos <strong>permitidos</strong> — os vereadores ainda podem votar pelo tablet enquanto a Mesa registra</span> |
|||
</div> |
|||
|
|||
<button type="button" class="btn btn-sm mb-3" |
|||
:class="registroAberto ? 'btn-secondary' : 'btn-info'" |
|||
@click="onToggleRegistro"> |
|||
{{ registroAberto ? 'Reabrir Votação para Novos Votos' : 'Bloquear Novos Votos' }} |
|||
</button> |
|||
|
|||
<votacao-materia></votacao-materia> |
|||
<br /> |
|||
|
|||
<votacao-votos :votos-status="votosStatus" :votos-travados="votosTravados" @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: () => ({}) |
|||
}, |
|||
votosTravados: { |
|||
type: Object, |
|||
default: () => ({}) |
|||
}, |
|||
registroAberto: { |
|||
type: Boolean, |
|||
default: false |
|||
} |
|||
}, |
|||
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 |
|||
}); |
|||
}, |
|||
onToggleRegistro() { |
|||
this.$emit('toggle-registro', !this.registroAberto); |
|||
} |
|||
}, |
|||
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,64 @@ |
|||
<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'" |
|||
:disabled="!!votosTravados[p.parlamentar_id]" |
|||
@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> |
|||
<span v-if="votosTravados[p.parlamentar_id]" class="badge badge-info">Já votado (tablet)</span> |
|||
</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: () => ({}) |
|||
}, |
|||
// Quais parlamentar_id devem ficar travados pro operador — só quando |
|||
// votado_pelo_parlamentar é true no payload (voto pelo próprio |
|||
// tablet). Distinto de votosStatus: um voto lançado pelo operador |
|||
// neste mesmo <select> também aparece em votosStatus (tem valor), mas |
|||
// não deve se autotravar. |
|||
votosTravados: { |
|||
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
File diff suppressed because it is too large
@ -0,0 +1,19 @@ |
|||
import pytest |
|||
from model_bakery import baker |
|||
|
|||
from sapl.api.serializers import SessaoPlenariaECidadaniaSerializer |
|||
from sapl.base.models import CasaLegislativa |
|||
from sapl.sessao.models import SessaoPlenaria |
|||
|
|||
|
|||
@pytest.mark.django_db(transaction=False) |
|||
def test_sessao_ecidadania_serializa_dados_da_casa(): |
|||
casa = baker.make(CasaLegislativa, nome='Câmara Municipal', sigla='CM', |
|||
endereco='Praça Central') |
|||
sessao = baker.make(SessaoPlenaria) |
|||
|
|||
data = SessaoPlenariaECidadaniaSerializer(sessao).data |
|||
|
|||
assert data['txtNomeOrgao'] == casa.nome |
|||
assert data['txtSiglaOrgao'] == casa.sigla |
|||
assert data['txtLocal'] == casa.endereco |
|||
@ -0,0 +1,23 @@ |
|||
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 channels.security.websocket import AllowedHostsOriginValidator # noqa: E402 |
|||
from django.urls import path # noqa: E402 |
|||
|
|||
from sapl.painel.consumers import PainelConsumer # noqa: E402 |
|||
|
|||
application = ProtocolTypeRouter({ |
|||
"http": AsgiHandler(), |
|||
"websocket": AllowedHostsOriginValidator( |
|||
AuthMiddlewareStack(URLRouter([ |
|||
path("ws/painel/<int:sessao_id>/", PainelConsumer.as_asgi()), |
|||
])) |
|||
), |
|||
}) |
|||
@ -0,0 +1,18 @@ |
|||
# Generated by Django 2.2.28 on 2026-09-16 20:09 |
|||
|
|||
from django.db import migrations, models |
|||
|
|||
|
|||
class Migration(migrations.Migration): |
|||
|
|||
dependencies = [ |
|||
('audiencia', '0022_audienciapublica_data_ultima_atualizacao'), |
|||
] |
|||
|
|||
operations = [ |
|||
migrations.AlterField( |
|||
model_name='audienciapublica', |
|||
name='ano', |
|||
field=models.PositiveSmallIntegerField(choices=[(2027, 2027), (2026, 2026), (2025, 2025), (2024, 2024), (2023, 2023), (2022, 2022), (2021, 2021), (2020, 2020), (2019, 2019), (2018, 2018), (2017, 2017), (2016, 2016), (2015, 2015), (2014, 2014), (2013, 2013), (2012, 2012), (2011, 2011), (2010, 2010), (2009, 2009), (2008, 2008), (2007, 2007), (2006, 2006), (2005, 2005), (2004, 2004), (2003, 2003), (2002, 2002), (2001, 2001), (2000, 2000), (1999, 1999), (1998, 1998), (1997, 1997), (1996, 1996), (1995, 1995), (1994, 1994), (1993, 1993), (1992, 1992), (1991, 1991), (1990, 1990), (1989, 1989), (1988, 1988), (1987, 1987), (1986, 1986), (1985, 1985), (1984, 1984), (1983, 1983), (1982, 1982), (1981, 1981), (1980, 1980), (1979, 1979), (1978, 1978), (1977, 1977), (1976, 1976), (1975, 1975), (1974, 1974), (1973, 1973), (1972, 1972), (1971, 1971), (1970, 1970), (1969, 1969), (1968, 1968), (1967, 1967), (1966, 1966), (1965, 1965), (1964, 1964), (1963, 1963), (1962, 1962), (1961, 1961), (1960, 1960), (1959, 1959), (1958, 1958), (1957, 1957), (1956, 1956), (1955, 1955), (1954, 1954), (1953, 1953), (1952, 1952), (1951, 1951), (1950, 1950), (1949, 1949), (1948, 1948), (1947, 1947), (1946, 1946), (1945, 1945), (1944, 1944), (1943, 1943), (1942, 1942), (1941, 1941), (1940, 1940), (1939, 1939), (1938, 1938), (1937, 1937), (1936, 1936), (1935, 1935), (1934, 1934), (1933, 1933), (1932, 1932), (1931, 1931), (1930, 1930), (1929, 1929), (1928, 1928), (1927, 1927), (1926, 1926), (1925, 1925), (1924, 1924), (1923, 1923), (1922, 1922), (1921, 1921), (1920, 1920), (1919, 1919), (1918, 1918), (1917, 1917), (1916, 1916), (1915, 1915), (1914, 1914), (1913, 1913), (1912, 1912), (1911, 1911), (1910, 1910), (1909, 1909), (1908, 1908), (1907, 1907), (1906, 1906), (1905, 1905), (1904, 1904), (1903, 1903), (1902, 1902), (1901, 1901), (1900, 1900), (1899, 1899), (1898, 1898), (1897, 1897), (1896, 1896), (1895, 1895), (1894, 1894), (1893, 1893), (1892, 1892), (1891, 1891), (1890, 1890)], verbose_name='Ano'), |
|||
), |
|||
] |
|||
@ -0,0 +1,33 @@ |
|||
# Generated by Django 2.2.28 on 2026-09-16 20:09 |
|||
|
|||
from django.db import migrations, models |
|||
|
|||
|
|||
class Migration(migrations.Migration): |
|||
|
|||
dependencies = [ |
|||
('materia', '0087_update_viewdb_materiaemtramitacao'), |
|||
] |
|||
|
|||
operations = [ |
|||
migrations.AlterField( |
|||
model_name='materialegislativa', |
|||
name='ano', |
|||
field=models.PositiveSmallIntegerField(choices=[(2027, 2027), (2026, 2026), (2025, 2025), (2024, 2024), (2023, 2023), (2022, 2022), (2021, 2021), (2020, 2020), (2019, 2019), (2018, 2018), (2017, 2017), (2016, 2016), (2015, 2015), (2014, 2014), (2013, 2013), (2012, 2012), (2011, 2011), (2010, 2010), (2009, 2009), (2008, 2008), (2007, 2007), (2006, 2006), (2005, 2005), (2004, 2004), (2003, 2003), (2002, 2002), (2001, 2001), (2000, 2000), (1999, 1999), (1998, 1998), (1997, 1997), (1996, 1996), (1995, 1995), (1994, 1994), (1993, 1993), (1992, 1992), (1991, 1991), (1990, 1990), (1989, 1989), (1988, 1988), (1987, 1987), (1986, 1986), (1985, 1985), (1984, 1984), (1983, 1983), (1982, 1982), (1981, 1981), (1980, 1980), (1979, 1979), (1978, 1978), (1977, 1977), (1976, 1976), (1975, 1975), (1974, 1974), (1973, 1973), (1972, 1972), (1971, 1971), (1970, 1970), (1969, 1969), (1968, 1968), (1967, 1967), (1966, 1966), (1965, 1965), (1964, 1964), (1963, 1963), (1962, 1962), (1961, 1961), (1960, 1960), (1959, 1959), (1958, 1958), (1957, 1957), (1956, 1956), (1955, 1955), (1954, 1954), (1953, 1953), (1952, 1952), (1951, 1951), (1950, 1950), (1949, 1949), (1948, 1948), (1947, 1947), (1946, 1946), (1945, 1945), (1944, 1944), (1943, 1943), (1942, 1942), (1941, 1941), (1940, 1940), (1939, 1939), (1938, 1938), (1937, 1937), (1936, 1936), (1935, 1935), (1934, 1934), (1933, 1933), (1932, 1932), (1931, 1931), (1930, 1930), (1929, 1929), (1928, 1928), (1927, 1927), (1926, 1926), (1925, 1925), (1924, 1924), (1923, 1923), (1922, 1922), (1921, 1921), (1920, 1920), (1919, 1919), (1918, 1918), (1917, 1917), (1916, 1916), (1915, 1915), (1914, 1914), (1913, 1913), (1912, 1912), (1911, 1911), (1910, 1910), (1909, 1909), (1908, 1908), (1907, 1907), (1906, 1906), (1905, 1905), (1904, 1904), (1903, 1903), (1902, 1902), (1901, 1901), (1900, 1900), (1899, 1899), (1898, 1898), (1897, 1897), (1896, 1896), (1895, 1895), (1894, 1894), (1893, 1893), (1892, 1892), (1891, 1891), (1890, 1890)], verbose_name='Ano'), |
|||
), |
|||
migrations.AlterField( |
|||
model_name='materialegislativa', |
|||
name='ano_origem_externa', |
|||
field=models.PositiveSmallIntegerField(blank=True, choices=[(2027, 2027), (2026, 2026), (2025, 2025), (2024, 2024), (2023, 2023), (2022, 2022), (2021, 2021), (2020, 2020), (2019, 2019), (2018, 2018), (2017, 2017), (2016, 2016), (2015, 2015), (2014, 2014), (2013, 2013), (2012, 2012), (2011, 2011), (2010, 2010), (2009, 2009), (2008, 2008), (2007, 2007), (2006, 2006), (2005, 2005), (2004, 2004), (2003, 2003), (2002, 2002), (2001, 2001), (2000, 2000), (1999, 1999), (1998, 1998), (1997, 1997), (1996, 1996), (1995, 1995), (1994, 1994), (1993, 1993), (1992, 1992), (1991, 1991), (1990, 1990), (1989, 1989), (1988, 1988), (1987, 1987), (1986, 1986), (1985, 1985), (1984, 1984), (1983, 1983), (1982, 1982), (1981, 1981), (1980, 1980), (1979, 1979), (1978, 1978), (1977, 1977), (1976, 1976), (1975, 1975), (1974, 1974), (1973, 1973), (1972, 1972), (1971, 1971), (1970, 1970), (1969, 1969), (1968, 1968), (1967, 1967), (1966, 1966), (1965, 1965), (1964, 1964), (1963, 1963), (1962, 1962), (1961, 1961), (1960, 1960), (1959, 1959), (1958, 1958), (1957, 1957), (1956, 1956), (1955, 1955), (1954, 1954), (1953, 1953), (1952, 1952), (1951, 1951), (1950, 1950), (1949, 1949), (1948, 1948), (1947, 1947), (1946, 1946), (1945, 1945), (1944, 1944), (1943, 1943), (1942, 1942), (1941, 1941), (1940, 1940), (1939, 1939), (1938, 1938), (1937, 1937), (1936, 1936), (1935, 1935), (1934, 1934), (1933, 1933), (1932, 1932), (1931, 1931), (1930, 1930), (1929, 1929), (1928, 1928), (1927, 1927), (1926, 1926), (1925, 1925), (1924, 1924), (1923, 1923), (1922, 1922), (1921, 1921), (1920, 1920), (1919, 1919), (1918, 1918), (1917, 1917), (1916, 1916), (1915, 1915), (1914, 1914), (1913, 1913), (1912, 1912), (1911, 1911), (1910, 1910), (1909, 1909), (1908, 1908), (1907, 1907), (1906, 1906), (1905, 1905), (1904, 1904), (1903, 1903), (1902, 1902), (1901, 1901), (1900, 1900), (1899, 1899), (1898, 1898), (1897, 1897), (1896, 1896), (1895, 1895), (1894, 1894), (1893, 1893), (1892, 1892), (1891, 1891), (1890, 1890)], null=True, verbose_name='Ano'), |
|||
), |
|||
migrations.AlterField( |
|||
model_name='numeracao', |
|||
name='ano_materia', |
|||
field=models.PositiveSmallIntegerField(choices=[(2027, 2027), (2026, 2026), (2025, 2025), (2024, 2024), (2023, 2023), (2022, 2022), (2021, 2021), (2020, 2020), (2019, 2019), (2018, 2018), (2017, 2017), (2016, 2016), (2015, 2015), (2014, 2014), (2013, 2013), (2012, 2012), (2011, 2011), (2010, 2010), (2009, 2009), (2008, 2008), (2007, 2007), (2006, 2006), (2005, 2005), (2004, 2004), (2003, 2003), (2002, 2002), (2001, 2001), (2000, 2000), (1999, 1999), (1998, 1998), (1997, 1997), (1996, 1996), (1995, 1995), (1994, 1994), (1993, 1993), (1992, 1992), (1991, 1991), (1990, 1990), (1989, 1989), (1988, 1988), (1987, 1987), (1986, 1986), (1985, 1985), (1984, 1984), (1983, 1983), (1982, 1982), (1981, 1981), (1980, 1980), (1979, 1979), (1978, 1978), (1977, 1977), (1976, 1976), (1975, 1975), (1974, 1974), (1973, 1973), (1972, 1972), (1971, 1971), (1970, 1970), (1969, 1969), (1968, 1968), (1967, 1967), (1966, 1966), (1965, 1965), (1964, 1964), (1963, 1963), (1962, 1962), (1961, 1961), (1960, 1960), (1959, 1959), (1958, 1958), (1957, 1957), (1956, 1956), (1955, 1955), (1954, 1954), (1953, 1953), (1952, 1952), (1951, 1951), (1950, 1950), (1949, 1949), (1948, 1948), (1947, 1947), (1946, 1946), (1945, 1945), (1944, 1944), (1943, 1943), (1942, 1942), (1941, 1941), (1940, 1940), (1939, 1939), (1938, 1938), (1937, 1937), (1936, 1936), (1935, 1935), (1934, 1934), (1933, 1933), (1932, 1932), (1931, 1931), (1930, 1930), (1929, 1929), (1928, 1928), (1927, 1927), (1926, 1926), (1925, 1925), (1924, 1924), (1923, 1923), (1922, 1922), (1921, 1921), (1920, 1920), (1919, 1919), (1918, 1918), (1917, 1917), (1916, 1916), (1915, 1915), (1914, 1914), (1913, 1913), (1912, 1912), (1911, 1911), (1910, 1910), (1909, 1909), (1908, 1908), (1907, 1907), (1906, 1906), (1905, 1905), (1904, 1904), (1903, 1903), (1902, 1902), (1901, 1901), (1900, 1900), (1899, 1899), (1898, 1898), (1897, 1897), (1896, 1896), (1895, 1895), (1894, 1894), (1893, 1893), (1892, 1892), (1891, 1891), (1890, 1890)], verbose_name='Ano'), |
|||
), |
|||
migrations.AlterField( |
|||
model_name='proposicao', |
|||
name='ano', |
|||
field=models.PositiveSmallIntegerField(blank=True, choices=[(2027, 2027), (2026, 2026), (2025, 2025), (2024, 2024), (2023, 2023), (2022, 2022), (2021, 2021), (2020, 2020), (2019, 2019), (2018, 2018), (2017, 2017), (2016, 2016), (2015, 2015), (2014, 2014), (2013, 2013), (2012, 2012), (2011, 2011), (2010, 2010), (2009, 2009), (2008, 2008), (2007, 2007), (2006, 2006), (2005, 2005), (2004, 2004), (2003, 2003), (2002, 2002), (2001, 2001), (2000, 2000), (1999, 1999), (1998, 1998), (1997, 1997), (1996, 1996), (1995, 1995), (1994, 1994), (1993, 1993), (1992, 1992), (1991, 1991), (1990, 1990), (1989, 1989), (1988, 1988), (1987, 1987), (1986, 1986), (1985, 1985), (1984, 1984), (1983, 1983), (1982, 1982), (1981, 1981), (1980, 1980), (1979, 1979), (1978, 1978), (1977, 1977), (1976, 1976), (1975, 1975), (1974, 1974), (1973, 1973), (1972, 1972), (1971, 1971), (1970, 1970), (1969, 1969), (1968, 1968), (1967, 1967), (1966, 1966), (1965, 1965), (1964, 1964), (1963, 1963), (1962, 1962), (1961, 1961), (1960, 1960), (1959, 1959), (1958, 1958), (1957, 1957), (1956, 1956), (1955, 1955), (1954, 1954), (1953, 1953), (1952, 1952), (1951, 1951), (1950, 1950), (1949, 1949), (1948, 1948), (1947, 1947), (1946, 1946), (1945, 1945), (1944, 1944), (1943, 1943), (1942, 1942), (1941, 1941), (1940, 1940), (1939, 1939), (1938, 1938), (1937, 1937), (1936, 1936), (1935, 1935), (1934, 1934), (1933, 1933), (1932, 1932), (1931, 1931), (1930, 1930), (1929, 1929), (1928, 1928), (1927, 1927), (1926, 1926), (1925, 1925), (1924, 1924), (1923, 1923), (1922, 1922), (1921, 1921), (1920, 1920), (1919, 1919), (1918, 1918), (1917, 1917), (1916, 1916), (1915, 1915), (1914, 1914), (1913, 1913), (1912, 1912), (1911, 1911), (1910, 1910), (1909, 1909), (1908, 1908), (1907, 1907), (1906, 1906), (1905, 1905), (1904, 1904), (1903, 1903), (1902, 1902), (1901, 1901), (1900, 1900), (1899, 1899), (1898, 1898), (1897, 1897), (1896, 1896), (1895, 1895), (1894, 1894), (1893, 1893), (1892, 1892), (1891, 1891), (1890, 1890)], default=None, null=True, verbose_name='Ano'), |
|||
), |
|||
] |
|||
@ -0,0 +1,29 @@ |
|||
# Generated by Django 2.2.28 on 2026-09-16 20:09 |
|||
|
|||
from django.db import migrations, models |
|||
import sapl.norma.models |
|||
|
|||
|
|||
class Migration(migrations.Migration): |
|||
|
|||
dependencies = [ |
|||
('norma', '0045_auto_20240711_1405'), |
|||
] |
|||
|
|||
operations = [ |
|||
migrations.AlterField( |
|||
model_name='anexonormajuridica', |
|||
name='ano', |
|||
field=models.PositiveSmallIntegerField(choices=[(2027, 2027), (2026, 2026), (2025, 2025), (2024, 2024), (2023, 2023), (2022, 2022), (2021, 2021), (2020, 2020), (2019, 2019), (2018, 2018), (2017, 2017), (2016, 2016), (2015, 2015), (2014, 2014), (2013, 2013), (2012, 2012), (2011, 2011), (2010, 2010), (2009, 2009), (2008, 2008), (2007, 2007), (2006, 2006), (2005, 2005), (2004, 2004), (2003, 2003), (2002, 2002), (2001, 2001), (2000, 2000), (1999, 1999), (1998, 1998), (1997, 1997), (1996, 1996), (1995, 1995), (1994, 1994), (1993, 1993), (1992, 1992), (1991, 1991), (1990, 1990), (1989, 1989), (1988, 1988), (1987, 1987), (1986, 1986), (1985, 1985), (1984, 1984), (1983, 1983), (1982, 1982), (1981, 1981), (1980, 1980), (1979, 1979), (1978, 1978), (1977, 1977), (1976, 1976), (1975, 1975), (1974, 1974), (1973, 1973), (1972, 1972), (1971, 1971), (1970, 1970), (1969, 1969), (1968, 1968), (1967, 1967), (1966, 1966), (1965, 1965), (1964, 1964), (1963, 1963), (1962, 1962), (1961, 1961), (1960, 1960), (1959, 1959), (1958, 1958), (1957, 1957), (1956, 1956), (1955, 1955), (1954, 1954), (1953, 1953), (1952, 1952), (1951, 1951), (1950, 1950), (1949, 1949), (1948, 1948), (1947, 1947), (1946, 1946), (1945, 1945), (1944, 1944), (1943, 1943), (1942, 1942), (1941, 1941), (1940, 1940), (1939, 1939), (1938, 1938), (1937, 1937), (1936, 1936), (1935, 1935), (1934, 1934), (1933, 1933), (1932, 1932), (1931, 1931), (1930, 1930), (1929, 1929), (1928, 1928), (1927, 1927), (1926, 1926), (1925, 1925), (1924, 1924), (1923, 1923), (1922, 1922), (1921, 1921), (1920, 1920), (1919, 1919), (1918, 1918), (1917, 1917), (1916, 1916), (1915, 1915), (1914, 1914), (1913, 1913), (1912, 1912), (1911, 1911), (1910, 1910), (1909, 1909), (1908, 1908), (1907, 1907), (1906, 1906), (1905, 1905), (1904, 1904), (1903, 1903), (1902, 1902), (1901, 1901), (1900, 1900), (1899, 1899), (1898, 1898), (1897, 1897), (1896, 1896), (1895, 1895), (1894, 1894), (1893, 1893), (1892, 1892), (1891, 1891), (1890, 1890)], verbose_name='Ano'), |
|||
), |
|||
migrations.AlterField( |
|||
model_name='normaestatisticas', |
|||
name='ano', |
|||
field=models.PositiveSmallIntegerField(choices=[(2027, 2027), (2026, 2026), (2025, 2025), (2024, 2024), (2023, 2023), (2022, 2022), (2021, 2021), (2020, 2020), (2019, 2019), (2018, 2018), (2017, 2017), (2016, 2016), (2015, 2015), (2014, 2014), (2013, 2013), (2012, 2012), (2011, 2011), (2010, 2010), (2009, 2009), (2008, 2008), (2007, 2007), (2006, 2006), (2005, 2005), (2004, 2004), (2003, 2003), (2002, 2002), (2001, 2001), (2000, 2000), (1999, 1999), (1998, 1998), (1997, 1997), (1996, 1996), (1995, 1995), (1994, 1994), (1993, 1993), (1992, 1992), (1991, 1991), (1990, 1990), (1989, 1989), (1988, 1988), (1987, 1987), (1986, 1986), (1985, 1985), (1984, 1984), (1983, 1983), (1982, 1982), (1981, 1981), (1980, 1980), (1979, 1979), (1978, 1978), (1977, 1977), (1976, 1976), (1975, 1975), (1974, 1974), (1973, 1973), (1972, 1972), (1971, 1971), (1970, 1970), (1969, 1969), (1968, 1968), (1967, 1967), (1966, 1966), (1965, 1965), (1964, 1964), (1963, 1963), (1962, 1962), (1961, 1961), (1960, 1960), (1959, 1959), (1958, 1958), (1957, 1957), (1956, 1956), (1955, 1955), (1954, 1954), (1953, 1953), (1952, 1952), (1951, 1951), (1950, 1950), (1949, 1949), (1948, 1948), (1947, 1947), (1946, 1946), (1945, 1945), (1944, 1944), (1943, 1943), (1942, 1942), (1941, 1941), (1940, 1940), (1939, 1939), (1938, 1938), (1937, 1937), (1936, 1936), (1935, 1935), (1934, 1934), (1933, 1933), (1932, 1932), (1931, 1931), (1930, 1930), (1929, 1929), (1928, 1928), (1927, 1927), (1926, 1926), (1925, 1925), (1924, 1924), (1923, 1923), (1922, 1922), (1921, 1921), (1920, 1920), (1919, 1919), (1918, 1918), (1917, 1917), (1916, 1916), (1915, 1915), (1914, 1914), (1913, 1913), (1912, 1912), (1911, 1911), (1910, 1910), (1909, 1909), (1908, 1908), (1907, 1907), (1906, 1906), (1905, 1905), (1904, 1904), (1903, 1903), (1902, 1902), (1901, 1901), (1900, 1900), (1899, 1899), (1898, 1898), (1897, 1897), (1896, 1896), (1895, 1895), (1894, 1894), (1893, 1893), (1892, 1892), (1891, 1891), (1890, 1890)], default=sapl.norma.models.get_ano_atual, verbose_name='Ano'), |
|||
), |
|||
migrations.AlterField( |
|||
model_name='normajuridica', |
|||
name='ano', |
|||
field=models.PositiveSmallIntegerField(choices=[(2027, 2027), (2026, 2026), (2025, 2025), (2024, 2024), (2023, 2023), (2022, 2022), (2021, 2021), (2020, 2020), (2019, 2019), (2018, 2018), (2017, 2017), (2016, 2016), (2015, 2015), (2014, 2014), (2013, 2013), (2012, 2012), (2011, 2011), (2010, 2010), (2009, 2009), (2008, 2008), (2007, 2007), (2006, 2006), (2005, 2005), (2004, 2004), (2003, 2003), (2002, 2002), (2001, 2001), (2000, 2000), (1999, 1999), (1998, 1998), (1997, 1997), (1996, 1996), (1995, 1995), (1994, 1994), (1993, 1993), (1992, 1992), (1991, 1991), (1990, 1990), (1989, 1989), (1988, 1988), (1987, 1987), (1986, 1986), (1985, 1985), (1984, 1984), (1983, 1983), (1982, 1982), (1981, 1981), (1980, 1980), (1979, 1979), (1978, 1978), (1977, 1977), (1976, 1976), (1975, 1975), (1974, 1974), (1973, 1973), (1972, 1972), (1971, 1971), (1970, 1970), (1969, 1969), (1968, 1968), (1967, 1967), (1966, 1966), (1965, 1965), (1964, 1964), (1963, 1963), (1962, 1962), (1961, 1961), (1960, 1960), (1959, 1959), (1958, 1958), (1957, 1957), (1956, 1956), (1955, 1955), (1954, 1954), (1953, 1953), (1952, 1952), (1951, 1951), (1950, 1950), (1949, 1949), (1948, 1948), (1947, 1947), (1946, 1946), (1945, 1945), (1944, 1944), (1943, 1943), (1942, 1942), (1941, 1941), (1940, 1940), (1939, 1939), (1938, 1938), (1937, 1937), (1936, 1936), (1935, 1935), (1934, 1934), (1933, 1933), (1932, 1932), (1931, 1931), (1930, 1930), (1929, 1929), (1928, 1928), (1927, 1927), (1926, 1926), (1925, 1925), (1924, 1924), (1923, 1923), (1922, 1922), (1921, 1921), (1920, 1920), (1919, 1919), (1918, 1918), (1917, 1917), (1916, 1916), (1915, 1915), (1914, 1914), (1913, 1913), (1912, 1912), (1911, 1911), (1910, 1910), (1909, 1909), (1908, 1908), (1907, 1907), (1906, 1906), (1905, 1905), (1904, 1904), (1903, 1903), (1902, 1902), (1901, 1901), (1900, 1900), (1899, 1899), (1898, 1898), (1897, 1897), (1896, 1896), (1895, 1895), (1894, 1894), (1893, 1893), (1892, 1892), (1891, 1891), (1890, 1890)], verbose_name='Ano'), |
|||
), |
|||
] |
|||
@ -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,215 @@ |
|||
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. O snapshot é sempre completo (não deltas — ver Parte 3 do plano |
|||
de migração polling->push), mas não é mais idêntico para todo mundo: |
|||
cada conexão pede o seu próprio a cada aviso de mudança (ver |
|||
painel_refresh()), com o voto mascarado ou não conforme o papel de |
|||
quem está conectado (self.is_operator) — o grupo em si nunca carrega |
|||
o voto real, só um sinal de "algo mudou". |
|||
""" |
|||
|
|||
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 |
|||
|
|||
# Papéis calculados uma vez, na conexão — reaproveitados em todo |
|||
# refresh subsequente sem reconsultar as permissões do usuário. |
|||
self.is_operator = await self._is_operator(user) |
|||
self.can_cast_vote = await self._can_cast_vote(user) |
|||
|
|||
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): |
|||
msg_type = content.get('type') |
|||
if msg_type == 'ping': |
|||
await self.send_json({'type': 'pong'}) |
|||
elif msg_type == 'vote': |
|||
await self._handle_vote(content) |
|||
elif msg_type == 'registro_toggle': |
|||
await self._handle_registro_toggle(content) |
|||
|
|||
async def data(self, event): |
|||
# Ainda aceito por compatibilidade (não é mais como |
|||
# broadcast_dados_painel() manda algo pro grupo, mas nada impede |
|||
# outro caller de enviar um group_send neste formato). |
|||
await self.send_json({'type': 'data', 'payload': event['payload']}) |
|||
|
|||
async def painel_refresh(self, event): |
|||
""" |
|||
Handler do sinal leve que broadcast_dados_painel() manda pro grupo |
|||
(`{"type": "painel.refresh"}` — Channels troca o "." por "_" no |
|||
nome do método). Cada conexão reconsulta o snapshot por conta |
|||
própria e manda só pra si mesma: é aqui, não no group_send, que o |
|||
voto real fica restrito a quem tem self.is_operator. |
|||
""" |
|||
payload = await self._build_dados_painel(self.sessao_id) |
|||
await self.send_json({'type': 'data', 'payload': payload}) |
|||
|
|||
async def _handle_vote(self, content): |
|||
if not getattr(self, 'can_cast_vote', False): |
|||
await self.send_json({ |
|||
'type': 'vote_error', |
|||
'message': 'Seu usuário não tem permissão para registrar votos.'}) |
|||
return |
|||
|
|||
parlamentar_id = content.get('parlamentar_id') |
|||
voto = content.get('voto') |
|||
try: |
|||
result = await self._cast_vote(parlamentar_id, voto) |
|||
except Exception as e: |
|||
await self.send_json({'type': 'vote_error', 'message': str(e)}) |
|||
return |
|||
|
|||
await self.send_json({'type': 'vote_ack', **result}) |
|||
# Sinal pro grupo inteiro (inclusive esta conexão) reconsultar — |
|||
# não manda o payload aqui: quem recebe decide seu próprio |
|||
# mostrar_voto em painel_refresh(). |
|||
await self.channel_layer.group_send(self.group_name, {'type': 'painel.refresh'}) |
|||
|
|||
async def _handle_registro_toggle(self, content): |
|||
""" |
|||
Equivalente ao bloquear-registro-votacao/reabrir-votacao de |
|||
nominal.html — mesmo gate de permissão do voto (can_cast_vote), |
|||
já que é a mesma Mesa/operador que faz as duas coisas. |
|||
""" |
|||
if not getattr(self, 'can_cast_vote', False): |
|||
await self.send_json({ |
|||
'type': 'registro_toggle_error', |
|||
'message': 'Seu usuário não tem permissão para esta ação.'}) |
|||
return |
|||
|
|||
aberto = bool(content.get('aberto')) |
|||
try: |
|||
result = await self._toggle_registro(aberto) |
|||
except Exception as e: |
|||
await self.send_json({'type': 'registro_toggle_error', 'message': str(e)}) |
|||
return |
|||
|
|||
await self.send_json({'type': 'registro_toggle_ack', **result}) |
|||
await self.channel_layer.group_send(self.group_name, {'type': 'painel.refresh'}) |
|||
|
|||
@database_sync_to_async |
|||
def _toggle_registro(self, aberto): |
|||
# import local — mesmo motivo do import em _build_dados_painel. |
|||
from sapl.painel.views import VoteError, _toggle_registro |
|||
try: |
|||
return _toggle_registro(self.scope['user'], self.sessao_id, aberto) |
|||
except VoteError as e: |
|||
raise Exception(e.message) from None |
|||
|
|||
@database_sync_to_async |
|||
def _cast_vote(self, parlamentar_id, voto): |
|||
# import local — mesmo motivo do import em _build_dados_painel. |
|||
from sapl.painel.views import VoteError, _cast_vote |
|||
client = self.scope.get('client') or (None, None) |
|||
try: |
|||
return _cast_vote( |
|||
self.scope['user'], self.sessao_id, parlamentar_id, voto, |
|||
ip=client[0]) |
|||
except VoteError as e: |
|||
raise Exception(e.message) from None |
|||
|
|||
@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, |
|||
force_mostrar_voto=getattr(self, 'is_operator', False)) |
|||
|
|||
@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 _is_operator(self, user): |
|||
""" |
|||
Papel "operador/Mesa" pra fins de mascaramento de voto (ver |
|||
build_dados_painel(force_mostrar_voto=...)) — mais amplo que |
|||
can_cast_vote de propósito: cobre tanto quem registra o voto |
|||
nominal quanto quem só opera o painel (abre/fecha, cronômetro), |
|||
que também não deveria ficar sujeito à máscara pensada pro |
|||
público. |
|||
""" |
|||
return user.has_module_perms(PainelAppConfig.label) or \ |
|||
user.has_module_perms('sessao') |
|||
|
|||
@database_sync_to_async |
|||
def _can_cast_vote(self, user): |
|||
# Mesmo gate de vote_controller (check_permission em |
|||
# sapl/painel/views.py) — import local, mesmo motivo de sempre. |
|||
from sapl.painel.views import check_permission |
|||
return check_permission(user) |
|||
|
|||
@database_sync_to_async |
|||
def _sessao_existe(self, sessao_id): |
|||
return SessaoPlenaria.objects.filter(id=sessao_id).exists() |
|||
@ -0,0 +1,265 @@ |
|||
""" |
|||
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, |
|||
_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(): |
|||
""" |
|||
Registro individual de votação nominal (o "salvar-votacao" em lote da |
|||
tela legada nominal.html não existe mais — ver plano de unificação |
|||
Expediente/OrdemDia) agora é: um voto por chamada a vote_controller |
|||
(o mesmo caminho HTTP que PainelConsumer usa por baixo pro type: |
|||
"vote" — vote_controller continua existindo por compatibilidade), |
|||
seguido de close_voting pra fechar e apurar o resultado. |
|||
""" |
|||
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)( |
|||
reverse('sapl.painel:vote_controller', kwargs={'controller_id': sessao.pk}), |
|||
{'parlamentar_id': parlamentar.pk, 'voto': 'Sim'}) |
|||
# Descarta o refresh disparado pelo voto — este teste verifica o |
|||
# broadcast do close_voting especificamente. |
|||
await communicator.receive_json_from() |
|||
|
|||
await sync_to_async(admin_client.post)( |
|||
reverse('sapl.painel:close_voting', kwargs={'controller_id': sessao.pk}), |
|||
{'resultado_id': tipo_resultado.pk, 'observacoes': ''}) |
|||
|
|||
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']['action'] == '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,324 @@ |
|||
# 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 (ExpedienteMateria, 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 |
|||
|
|||
|
|||
@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) |
|||
|
|||
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'] |
|||
|
|||
|
|||
@pytest.mark.django_db(transaction=False) |
|||
def test_votante_status_com_duas_votacoes_abertas_nao_acumula_mensagens(): |
|||
""" |
|||
Uma OrdemDia e uma ExpedienteMateria abertas ao mesmo tempo (as |
|||
constraints são por tabela): o poll do tablet devolve o erro em |
|||
error_message, em texto puro, sem empilhar mensagens na sessão. |
|||
""" |
|||
sessao, ordem = _ordem_nominal_aberta() |
|||
vereador, votante_client = _votante_com_client(sessao) |
|||
outra_sessao = _sessao_plenaria() |
|||
baker.make(ExpedienteMateria, sessao_plenaria=outra_sessao, |
|||
materia=_materia(), tipo_votacao=NOMINAL, votacao_aberta=True) |
|||
|
|||
status_url = reverse('sapl.painel:voto_individual_status') |
|||
for _ in range(2): |
|||
data = votante_client.get(status_url).json() |
|||
|
|||
assert 'mais de uma' in data['error_message'] |
|||
assert '<' not in data['error_message'] |
|||
assert '_messages' not in votante_client.session |
|||
|
|||
|
|||
@pytest.mark.django_db(transaction=False) |
|||
@pytest.mark.parametrize('voto, registro_aberto', [ |
|||
('Não Votou', False), # não é um voto de fato |
|||
('Sim', True), # Mesa bloqueou novos votos |
|||
]) |
|||
def test_tablet_rejeita_voto_sem_erro_500(voto, registro_aberto): |
|||
sessao, ordem = _ordem_nominal_aberta(registro_aberto=registro_aberto) |
|||
vereador, votante_client = _votante_com_client(sessao) |
|||
|
|||
response = votante_client.post( |
|||
reverse('sapl.painel:voto_individual'), {'voto': voto}) |
|||
|
|||
assert response.status_code == 302 |
|||
assert not VotoParlamentar.objects.filter( |
|||
ordem=ordem, parlamentar=vereador).exists() |
|||
|
|||
|
|||
@pytest.mark.django_db(transaction=False) |
|||
def test_tablet_revalida_presenca_no_momento_do_voto(): |
|||
""" |
|||
A presença é conferida de novo sob o lock da matéria, não só quando o |
|||
contexto do tablet foi resolvido. |
|||
""" |
|||
from sapl.painel.views import (VoteError, _resolve_votante_context, |
|||
_save_voto_individual) |
|||
sessao, ordem = _ordem_nominal_aberta() |
|||
vereador, votante_client = _votante_com_client(sessao) |
|||
request = votante_client.get(reverse('sapl.painel:voto_individual')).wsgi_request |
|||
request.user = Votante.objects.get(parlamentar=vereador).user |
|||
_, context_vars = _resolve_votante_context(request) |
|||
PresencaOrdemDia.objects.filter(sessao_plenaria=sessao, parlamentar=vereador).delete() |
|||
|
|||
with pytest.raises(VoteError): |
|||
_save_voto_individual(request, context_vars, voto_valor='Sim', ip='127.0.0.1') |
|||
|
|||
assert not VotoParlamentar.objects.filter(ordem=ordem, parlamentar=vereador).exists() |
|||
|
|||
@ -1,27 +1,30 @@ |
|||
from django.conf.urls import url |
|||
from django.urls import path |
|||
|
|||
from .apps import AppConfig |
|||
from .views import (cronometro_painel, get_dados_painel, painel_mensagem_view, |
|||
painel_parlamentar_view, painel_view, painel_votacao_view, |
|||
switch_painel, verifica_painel, votante_view) |
|||
from .views import (cronometro_painel, painel_view, |
|||
switch_painel, verifica_painel, votante_status, votante_view, |
|||
vote_controller, cancel_voting, close_voting, votos_status) |
|||
|
|||
app_name = AppConfig.name |
|||
|
|||
urlpatterns = [ |
|||
url(r'^painel-principal/(?P<pk>\d+)$', painel_view, |
|||
name="painel_principal"), |
|||
url(r'^painel/(?P<pk>\d+)/dados$', get_dados_painel, name='dados_painel'), |
|||
url(r'^painel/mensagem$', painel_mensagem_view, name="painel_mensagem"), |
|||
url(r'^painel/parlamentar$', painel_parlamentar_view, |
|||
name='painel_parlamentar'), |
|||
url(r'^painel/switch-painel$', switch_painel, |
|||
name="switch_painel"), |
|||
url(r'^painel/votacao$', painel_votacao_view, name='painel_votacao'), |
|||
url(r'^painel/verifica-painel$', verifica_painel, |
|||
name="verifica_painel"), |
|||
url(r'^painel/cronometro$', cronometro_painel, name='cronometro_painel'), |
|||
# url(r'^painel/cronometro$', include(CronometroPainelCrud.get_urls())), |
|||
|
|||
url(r'^voto-individual/$', votante_view, |
|||
name='voto_individual'), |
|||
## Votacao - FRONTEND (Django/VueJS) |
|||
path("painel/<int:sessao_id>", painel_view, name='painel_principal'), |
|||
path("painel/voto-individual", votante_view, name='voto_individual'), |
|||
|
|||
## Votacao - API |
|||
path("voto-individual/status", votante_status, name='voto_individual_status'), |
|||
path("painel/switch-painel", switch_painel, name="switch_painel"), |
|||
path("painel/verifica-painel", verifica_painel, name="verifica_painel"), |
|||
path("painel/cronometro", cronometro_painel, name='cronometro_painel'), |
|||
path("painel/controller/<int:controller_id>/vote", |
|||
vote_controller, name='vote_controller'), |
|||
path("painel/controller/<int:controller_id>/cancel", |
|||
cancel_voting, name='cancel_voting'), |
|||
path("painel/controller/<int:controller_id>/close", |
|||
close_voting, name='close_voting'), |
|||
path("painel/controller/<int:controller_id>/votos-status", |
|||
votos_status, name='votos_status'), |
|||
] |
|||
|
|||
File diff suppressed because it is too large
@ -0,0 +1,23 @@ |
|||
# Generated by Django 2.2.28 on 2026-09-16 20:09 |
|||
|
|||
from django.db import migrations, models |
|||
|
|||
|
|||
class Migration(migrations.Migration): |
|||
|
|||
dependencies = [ |
|||
('protocoloadm', '0046_auto_20260626_1220'), |
|||
] |
|||
|
|||
operations = [ |
|||
migrations.AlterField( |
|||
model_name='documentoadministrativo', |
|||
name='ano', |
|||
field=models.PositiveSmallIntegerField(choices=[(2027, 2027), (2026, 2026), (2025, 2025), (2024, 2024), (2023, 2023), (2022, 2022), (2021, 2021), (2020, 2020), (2019, 2019), (2018, 2018), (2017, 2017), (2016, 2016), (2015, 2015), (2014, 2014), (2013, 2013), (2012, 2012), (2011, 2011), (2010, 2010), (2009, 2009), (2008, 2008), (2007, 2007), (2006, 2006), (2005, 2005), (2004, 2004), (2003, 2003), (2002, 2002), (2001, 2001), (2000, 2000), (1999, 1999), (1998, 1998), (1997, 1997), (1996, 1996), (1995, 1995), (1994, 1994), (1993, 1993), (1992, 1992), (1991, 1991), (1990, 1990), (1989, 1989), (1988, 1988), (1987, 1987), (1986, 1986), (1985, 1985), (1984, 1984), (1983, 1983), (1982, 1982), (1981, 1981), (1980, 1980), (1979, 1979), (1978, 1978), (1977, 1977), (1976, 1976), (1975, 1975), (1974, 1974), (1973, 1973), (1972, 1972), (1971, 1971), (1970, 1970), (1969, 1969), (1968, 1968), (1967, 1967), (1966, 1966), (1965, 1965), (1964, 1964), (1963, 1963), (1962, 1962), (1961, 1961), (1960, 1960), (1959, 1959), (1958, 1958), (1957, 1957), (1956, 1956), (1955, 1955), (1954, 1954), (1953, 1953), (1952, 1952), (1951, 1951), (1950, 1950), (1949, 1949), (1948, 1948), (1947, 1947), (1946, 1946), (1945, 1945), (1944, 1944), (1943, 1943), (1942, 1942), (1941, 1941), (1940, 1940), (1939, 1939), (1938, 1938), (1937, 1937), (1936, 1936), (1935, 1935), (1934, 1934), (1933, 1933), (1932, 1932), (1931, 1931), (1930, 1930), (1929, 1929), (1928, 1928), (1927, 1927), (1926, 1926), (1925, 1925), (1924, 1924), (1923, 1923), (1922, 1922), (1921, 1921), (1920, 1920), (1919, 1919), (1918, 1918), (1917, 1917), (1916, 1916), (1915, 1915), (1914, 1914), (1913, 1913), (1912, 1912), (1911, 1911), (1910, 1910), (1909, 1909), (1908, 1908), (1907, 1907), (1906, 1906), (1905, 1905), (1904, 1904), (1903, 1903), (1902, 1902), (1901, 1901), (1900, 1900), (1899, 1899), (1898, 1898), (1897, 1897), (1896, 1896), (1895, 1895), (1894, 1894), (1893, 1893), (1892, 1892), (1891, 1891), (1890, 1890)], verbose_name='Ano'), |
|||
), |
|||
migrations.AlterField( |
|||
model_name='protocolo', |
|||
name='ano', |
|||
field=models.PositiveSmallIntegerField(choices=[(2027, 2027), (2026, 2026), (2025, 2025), (2024, 2024), (2023, 2023), (2022, 2022), (2021, 2021), (2020, 2020), (2019, 2019), (2018, 2018), (2017, 2017), (2016, 2016), (2015, 2015), (2014, 2014), (2013, 2013), (2012, 2012), (2011, 2011), (2010, 2010), (2009, 2009), (2008, 2008), (2007, 2007), (2006, 2006), (2005, 2005), (2004, 2004), (2003, 2003), (2002, 2002), (2001, 2001), (2000, 2000), (1999, 1999), (1998, 1998), (1997, 1997), (1996, 1996), (1995, 1995), (1994, 1994), (1993, 1993), (1992, 1992), (1991, 1991), (1990, 1990), (1989, 1989), (1988, 1988), (1987, 1987), (1986, 1986), (1985, 1985), (1984, 1984), (1983, 1983), (1982, 1982), (1981, 1981), (1980, 1980), (1979, 1979), (1978, 1978), (1977, 1977), (1976, 1976), (1975, 1975), (1974, 1974), (1973, 1973), (1972, 1972), (1971, 1971), (1970, 1970), (1969, 1969), (1968, 1968), (1967, 1967), (1966, 1966), (1965, 1965), (1964, 1964), (1963, 1963), (1962, 1962), (1961, 1961), (1960, 1960), (1959, 1959), (1958, 1958), (1957, 1957), (1956, 1956), (1955, 1955), (1954, 1954), (1953, 1953), (1952, 1952), (1951, 1951), (1950, 1950), (1949, 1949), (1948, 1948), (1947, 1947), (1946, 1946), (1945, 1945), (1944, 1944), (1943, 1943), (1942, 1942), (1941, 1941), (1940, 1940), (1939, 1939), (1938, 1938), (1937, 1937), (1936, 1936), (1935, 1935), (1934, 1934), (1933, 1933), (1932, 1932), (1931, 1931), (1930, 1930), (1929, 1929), (1928, 1928), (1927, 1927), (1926, 1926), (1925, 1925), (1924, 1924), (1923, 1923), (1922, 1922), (1921, 1921), (1920, 1920), (1919, 1919), (1918, 1918), (1917, 1917), (1916, 1916), (1915, 1915), (1914, 1914), (1913, 1913), (1912, 1912), (1911, 1911), (1910, 1910), (1909, 1909), (1908, 1908), (1907, 1907), (1906, 1906), (1905, 1905), (1904, 1904), (1903, 1903), (1902, 1902), (1901, 1901), (1900, 1900), (1899, 1899), (1898, 1898), (1897, 1897), (1896, 1896), (1895, 1895), (1894, 1894), (1893, 1893), (1892, 1892), (1891, 1891), (1890, 1890)], verbose_name='Ano do Protocolo'), |
|||
), |
|||
] |
|||
@ -0,0 +1,283 @@ |
|||
# Painel backed by WebSockets — manual E2E verification |
|||
|
|||
This is a note about the **`feat/painel-websockets` branch** (a timeboxed |
|||
spike, forked from `fix/painel-registro-votacao-trava`), which adds live |
|||
server push (WebSockets over Redis) to the plenary-session "painel" |
|||
feature, additively on top of the existing HTTP-polling implementation. |
|||
It lives in this app's directory because the broadcast trigger points |
|||
(`abrir_votacao`, `VotacaoNominalAbstract.post`) are here in |
|||
`sapl/sessao/views.py` — the WebSocket infrastructure itself (consumer, |
|||
ASGI routing, the shared payload builder) lives in `sapl/painel/`. |
|||
|
|||
If you're reading this outside that branch, most of what's described |
|||
below doesn't exist yet. |
|||
|
|||
## Requirements to run this branch (local dev) |
|||
|
|||
Four things need to be running at once. Order matters a little (Redis |
|||
before the Django/ASGI process; `yarn serve` before you load a page, or |
|||
its bundles 404). |
|||
|
|||
1. **Redis, with persistence off** (channel layer only — nothing here |
|||
needs to survive a restart): |
|||
``` |
|||
redis-server --save "" --appendonly no |
|||
``` |
|||
or, if you'd rather not install Redis locally: |
|||
``` |
|||
docker run --rm -p 6379:6379 redis:7-alpine redis-server --save "" --appendonly no |
|||
``` |
|||
Make sure `REDIS_URL` in `.env` actually resolves to it — it must be a |
|||
full URL (`redis://127.0.0.1:6379/0`, matching `sapl/settings.py`'s |
|||
default). The repo's current `.env` has `REDIS_URL=localhost:6380`, |
|||
which is **not** in that form — if WebSocket updates silently never |
|||
arrive, check this first before suspecting the consumer/broadcast code. |
|||
|
|||
2. **Python deps** (adds `channels`/`daphne`/`channels-redis` on top of |
|||
the usual requirements — skip if already installed): |
|||
``` |
|||
pip install -r requirements/dev-requirements.txt |
|||
``` |
|||
|
|||
3. **Frontend dev bundle** (the Vue pages — painel, votação nominal, voto |
|||
individual, votação simbólica, leitura de matéria — are served via |
|||
`{% render_bundle %}`, which in `DEBUG` mode reads |
|||
`frontend/dev-webpack-stats.json` and points `<script>` tags at the |
|||
dev server; without this running you'll get a template error, not a |
|||
blank page): |
|||
``` |
|||
yarn install # once |
|||
yarn serve |
|||
``` |
|||
|
|||
4. **The Django/ASGI process itself — pick one, not both:** |
|||
``` |
|||
daphne -b 127.0.0.1 -p 8000 sapl.asgi:application |
|||
``` |
|||
or |
|||
``` |
|||
./manage.py runserver |
|||
``` |
|||
`sapl.asgi.application` is a single ASGI app that routes **both** |
|||
regular HTTP and `/ws/painel/<sessao_id>/` — and every WS client here |
|||
(Vue's `main.js` files and the legacy vanilla-JS templates alike) |
|||
opens its socket at `window.location.host`, i.e. *the same host:port |
|||
the page was loaded from*. So this has to be one process on one port, |
|||
not Daphne-on-one-port-plus-runserver-on-another. There's no HTTP |
|||
polling fallback anywhere in this branch anymore — every screen here |
|||
is WS-only (see "Architecture" below) — so a two-port setup wouldn't |
|||
just be slower, it would leave every page permanently stuck showing a |
|||
"conexão perdida" banner. `scripts/verify_painel_websockets.sh` runs a |
|||
single Daphne process for exactly this reason. |
|||
|
|||
Between the two: **use Daphne when you're actively debugging the |
|||
WebSocket itself** — its logs/errors for a failed or dropped `/ws/` |
|||
connection are far more legible, and this is where you'll spend time |
|||
if something in the merged backend misbehaves. Daphne does **not** |
|||
autoreload on code changes, though, so for everyday work (editing |
|||
views/templates, not the consumer/broadcast path) run `runserver` |
|||
instead — Channels patches it to serve the same combined HTTP+WS app, |
|||
just with autoreload, and you restart it far less often. |
|||
|
|||
## Architecture in one paragraph |
|||
|
|||
Every client screen — the three legacy vanilla-JS templates (the public |
|||
painel `sapl/templates/painel/index.html`, the tablet voting screen |
|||
`sapl/templates/painel/voto_individual.html`, the operator's |
|||
vote-registration screen `sapl/templates/sessao/votacao/nominal.html`) and |
|||
the five Vue v2 pages (painel, votação nominal, voto individual, votação |
|||
simbólica, leitura de matéria) — open a WebSocket to |
|||
`/ws/painel/<sessao_id>/` (one Channels group per `SessaoPlenaria`, |
|||
`sapl/painel/consumers.py::PainelConsumer`) and get a full state snapshot |
|||
immediately on connect (`PainelConsumer.connect()` sends one right after |
|||
`accept()` — this is what makes a page reload or a reconnect self-heal |
|||
with no gap, instead of showing stale data until the next event). |
|||
Whenever a view mutates presença, orador, vote, or matéria state, it calls |
|||
`broadcast_dados_painel(request, sessao_id)` (`sapl/painel/views.py`), |
|||
which re-runs `build_dados_painel()` and publishes the full payload to |
|||
that group over Redis (`channels_redis`). Every connected client gets the |
|||
*same* full-state payload; there is no separate delta format. |
|||
|
|||
**There is no HTTP polling fallback anywhere in this branch.** Every |
|||
screen listed above is WS-only: if the socket drops, nothing updates |
|||
until it reconnects. Each screen's JS shows a visible banner |
|||
(`#ws-status-banner` in the legacy templates, `<ws-status-banner>` in the |
|||
Vue pages) whenever the connection isn't open, and reconnects with an |
|||
exponential backoff (1s → 30s cap, ±30% jitter to avoid every connected |
|||
tablet retrying in lockstep). The consumer closes with a specific code for |
|||
conditions a retry can't fix — 4401 (not authenticated), 4403 (not |
|||
authorized), 4404 (no such sessão) — and the client shows a permanent |
|||
error for those instead of retrying forever. Because polling is gone, |
|||
every write path that changes anything in this payload has to call |
|||
`broadcast_dados_painel()` itself — `PresencaView`/`PresencaOrdemDiaView`, |
|||
the `Orador*Crud` create/update/delete views (via |
|||
`BroadcastPainelOnSaveMixin` in `sapl/sessao/views.py`), |
|||
`LeituraEmBloco`, and `VotacaoEmBlocoSimbolicaView`/ |
|||
`VotacaoEmBlocoNominalView` were all missing this until it was added |
|||
alongside the polling removal — before that, polling was quietly masking |
|||
the gap (500ms–3s of staleness, not "never updates"). |
|||
|
|||
## Running it (for humans) |
|||
|
|||
``` |
|||
scripts/verify_painel_websockets.sh |
|||
``` |
|||
|
|||
This starts Redis and a single Daphne process (HTTP pages + WebSocket, one |
|||
port, one process — see the "two things need to be running" note above) |
|||
together, waits for both to actually be listening, and tails their logs |
|||
in one terminal. Ctrl+C stops and cleans up both. See the script's header |
|||
comment for port-override env vars if 6379/8002 collide with something |
|||
else on your machine. |
|||
|
|||
Once it's up: |
|||
|
|||
1. **Seed test data** (there's no fixture for this yet — a `manage.py |
|||
shell` one-liner is the fastest path; see the Python snippet in the |
|||
next section, "For LLMs / scripted setup", which works equally well |
|||
pasted into a human's `shell` session). |
|||
2. Open the **public painel** at `http://127.0.0.1:8002/painel-principal/<sessao_id>` |
|||
in one tab. |
|||
3. Log in as the seeded voter and open |
|||
`http://127.0.0.1:8002/voto-individual/` in another tab (or a private |
|||
window, since it's a different session). |
|||
4. Cast or change the vote on the voter tab. **The painel tab should |
|||
update without a page reload**, and there should be no delay beyond |
|||
normal network latency — that's the only path there is now. Watch the |
|||
daphne log (tailed in the same terminal) for the connection and any |
|||
errors. |
|||
5. To confirm the error banner actually shows up when it should (not just |
|||
that push works): kill daphne (its pid is printed by the script) while |
|||
the painel tab stays open. Within a few seconds every open tab should |
|||
show the red/amber "conexão perdida" banner |
|||
(`#ws-status-banner`/`<ws-status-banner>` — see "Architecture" above) |
|||
instead of silently going stale. |
|||
6. To confirm reconnect: restart the script (or just daphne) while the |
|||
painel tab stays open. Within a few seconds (backoff-dependent) the |
|||
tab should reconnect, the banner should disappear, and the tab should |
|||
resume live updates — check the browser console for the `WebSocket` |
|||
connect/close log lines. |
|||
|
|||
The operator's registration screen |
|||
(`sapl/sessao:votacaonominal`, URL `/sessao/<pk>/matordemdia/votnom/<oid>/<mid>`) |
|||
follows the same pattern — open it as a third tab, logged in as a user |
|||
with `sessao` module permissions, and confirm the same instant-update |
|||
behavior when a vote is registered from either the operator or the |
|||
tablet side. |
|||
|
|||
## For LLMs / scripted setup |
|||
|
|||
Minimal data needed for a Nominal vote to be votable end-to-end (run via |
|||
`python manage.py shell`, from the repo root, with `DJANGO_SETTINGS_MODULE=sapl.settings`): |
|||
|
|||
```python |
|||
from model_bakery import baker |
|||
from django.contrib.auth import get_user_model |
|||
from django.contrib.auth.models import Permission |
|||
from sapl.base.models import AppConfig as ConfiguracoesAplicacao |
|||
from sapl.parlamentares.models import Legislatura, SessaoLegislativa, Mandato, Parlamentar, Votante |
|||
from sapl.sessao.models import SessaoPlenaria, TipoSessaoPlenaria, OrdemDia, PresencaOrdemDia |
|||
from sapl.materia.models import MateriaLegislativa, TipoMateriaLegislativa |
|||
|
|||
User = get_user_model() |
|||
|
|||
# ConfiguracoesAplicacao.mostrar_voto controls whether the painel shows |
|||
# real vote values or masks them as "Voto Informado" before the matéria |
|||
# closes — set True to see real values live. |
|||
cfg, _ = ConfiguracoesAplicacao.objects.get_or_create(pk=1) |
|||
cfg.mostrar_voto = True |
|||
cfg.save() |
|||
|
|||
legislatura = baker.make(Legislatura) |
|||
sessao_legislativa = baker.make(SessaoLegislativa) |
|||
tipo = baker.make(TipoSessaoPlenaria) |
|||
sessao = baker.make(SessaoPlenaria, legislatura=legislatura, sessao_legislativa=sessao_legislativa, |
|||
tipo=tipo, numero=1, iniciada=True, finalizada=False, painel_aberto=True) |
|||
|
|||
materia = baker.make(MateriaLegislativa, tipo=baker.make(TipoMateriaLegislativa)) |
|||
ordem = baker.make(OrdemDia, sessao_plenaria=sessao, materia=materia, |
|||
tipo_votacao=2, votacao_aberta=True, registro_aberto=False) # 2 = Nominal |
|||
|
|||
parlamentar = baker.make(Parlamentar, ativo=True) |
|||
baker.make(PresencaOrdemDia, sessao_plenaria=sessao, parlamentar=parlamentar) |
|||
# get_presentes() (sapl/painel/views.py, backed by sessao_presencas_view) |
|||
# only includes parlamentares with a Mandato for the session's legislatura |
|||
# — without this row the parlamentar silently never shows as "presente". |
|||
baker.make(Mandato, parlamentar=parlamentar, legislatura=legislatura, |
|||
data_inicio_mandato=sessao.data_inicio) |
|||
|
|||
# A strong password is required — SAPL's CheckWeakPasswordMiddleware |
|||
# force-redirects any login with a weak one (e.g. "x", "test") to |
|||
# /sistema/alterar-senha/ before it ever reaches the view you're testing. |
|||
# This silently breaks any requests-based scripted login otherwise. |
|||
votante_user = User.objects.create_user(username='verify-votante', password='Xk9#mQ2vLp8zR4wT!') |
|||
votante_user.user_permissions.add(Permission.objects.get(codename='can_vote')) |
|||
baker.make(Votante, parlamentar=parlamentar, user=votante_user) |
|||
|
|||
print('sessao_id =', sessao.pk, '| ordem_id =', ordem.pk, '| materia_id =', materia.pk) |
|||
``` |
|||
|
|||
Permission model for `/ws/painel/<sessao_id>/` (`PainelConsumer.connect()`, |
|||
`sapl/painel/consumers.py`) — the connection is accepted if the |
|||
authenticated user satisfies **any** of the three permission gates the |
|||
three HTTP screens already use individually (there's no single unified |
|||
permission for this endpoint because the three source screens never had |
|||
one either): |
|||
- `user.has_module_perms('painel')` — same as `check_permission()`'s gate. |
|||
- `user.has_perm('parlamentares.can_vote')` — same as `votante_view`'s gate. |
|||
- `user.has_module_perms('sessao')` — same as the operator screen's |
|||
`SessaoPermissionMixin` gate. |
|||
|
|||
Unauthenticated connections close with code `4401`; authenticated but |
|||
unauthorized close with `4403`; a nonexistent `sessao_id` closes with |
|||
`4404` (all handled explicitly — no uncaught exceptions on bad input, |
|||
unlike the `feat/painel-votacao-v2` reference this was ported from). |
|||
|
|||
To verify the broadcast path without a browser at all (what |
|||
`sapl/painel/tests/test_broadcasts.py` does, and what was used to |
|||
smoke-test this against real Redis+daphne+runserver during development): |
|||
connect with the `websockets` Python package |
|||
(`pip install websockets`, not in project requirements — it's a |
|||
verification tool, not a runtime dependency) against |
|||
`ws://127.0.0.1:8002/ws/painel/<sessao_id>/`, sending the Django |
|||
`sessionid`/`csrftoken` cookies from a `requests.Session()` that logged in |
|||
via `/login/` — then POST a vote via `requests` and confirm the socket |
|||
receives a `{"type": "data", "payload": {...}}` message with the updated |
|||
`presentes[].voto`. |
|||
|
|||
## Known caveats |
|||
|
|||
- **`cronometro_painel`'s "which session is this for" resolution is |
|||
fragile.** It broadcasts to whichever `SessaoPlenaria` currently has |
|||
`painel_aberto=True`, via `.first()` — correct under the intended |
|||
single-open-session workflow, but nothing enforces "at most one" for |
|||
`painel_aberto` the way `votacao_aberta` already has a DB |
|||
`UniqueConstraint`. If more than one session has that flag set (stale |
|||
dev data, or a real multi-tenant edge case), the broadcast goes to an |
|||
arbitrary one of them. Not hit in practice during this spike's testing, |
|||
but worth hardening before this goes further. |
|||
- **This is a spike, not a finished feature.** Test coverage exists |
|||
(`sapl/painel/tests/test_consumers.py`, `test_broadcasts.py`, plus the |
|||
Postgres-view equivalence tests in `test_votacao_nominal.py`/`tests.py`) |
|||
and the vote/close/open/cronômetro broadcast paths were each verified |
|||
against real infrastructure (not just the in-memory test layer) during |
|||
development — but things like production replica count / Redis HA, |
|||
and a nginx-level rate-limit exemption for the `/ws/` path if |
|||
`feat/rate-limiter-2026`'s Lua layer ever merges, haven't been |
|||
addressed here. |
|||
- **Requirements**: `channels==3.0.3`, `daphne==3.0.2`, |
|||
`channels-redis==3.4.1`, `asgiref==3.7.2` are pinned in |
|||
`requirements/requirements.txt` — this specific combination is the |
|||
newest one compatible with this project's Django pin (`2.2.28`; |
|||
Channels 4.x needs Django ≥ 4.2, out of scope for this branch). |
|||
- **Removed as dead code when polling was removed**: |
|||
`painel_mensagem`/`painel_parlamentar`/`painel_votacao` (views, URLs, |
|||
and their templates `painel/mensagem.html`/`parlamentares.html`/ |
|||
`votacao.html`) were an older, unrelated multi-page painel UI with no |
|||
references anywhere else in the codebase — deleted outright rather than |
|||
converted. `sapl.painel:dados_painel` (`get_dados_painel`) and its ETag |
|||
helper (`_dados_painel_etag`) were also removed once nothing polled |
|||
them anymore, along with the tests that pinned their HTTP-specific |
|||
behavior (ETag/304, payload shape, masking) in |
|||
`sapl/painel/tests/tests.py` — `build_dados_painel()` itself (what both |
|||
used internally) is untouched and still backs every WS broadcast. |
|||
@ -1,3 +1,34 @@ |
|||
from django.contrib import admin |
|||
|
|||
from sapl.sessao.models import ExpedienteMateria, OrdemDia |
|||
from sapl.utils import register_all_models_in_admin |
|||
|
|||
|
|||
class NoAdminAccessModelAdmin(admin.ModelAdmin): |
|||
""" |
|||
SAPL não usa as páginas de admin e desencoraja seu uso — e votacao_aberta/ |
|||
registro_aberto só podem ser alterados com segurança através do fluxo de |
|||
abrir_votacao()/VotacaoNominalAbstract (sapl/sessao/views.py), que |
|||
garante a invariante de no máximo uma matéria aberta por vez. Em vez de |
|||
apenas tornar os campos somente-leitura, o acesso ao admin é desabilitado |
|||
por completo para estes dois modelos. |
|||
""" |
|||
|
|||
def has_view_permission(self, request, obj=None): |
|||
return False |
|||
|
|||
def has_add_permission(self, request): |
|||
return False |
|||
|
|||
def has_change_permission(self, request, obj=None): |
|||
return False |
|||
|
|||
def has_delete_permission(self, request, obj=None): |
|||
return False |
|||
|
|||
|
|||
admin.site.register(OrdemDia, NoAdminAccessModelAdmin) |
|||
admin.site.register(ExpedienteMateria, NoAdminAccessModelAdmin) |
|||
|
|||
# register_all_models_in_admin já pula modelos já registrados acima |
|||
register_all_models_in_admin(__name__) |
|||
|
|||
@ -0,0 +1,54 @@ |
|||
# Generated by Django 2.2.28 on 2026-08-16 17:36 |
|||
|
|||
from django.db import migrations, models |
|||
from django.db.models import Count |
|||
|
|||
|
|||
def remove_votos_duplicados(apps, schema_editor): |
|||
"""Preserva o voto canônico antes de criar os índices únicos.""" |
|||
VotoParlamentar = apps.get_model('sessao', 'VotoParlamentar') |
|||
|
|||
for materia_field in ('ordem_id', 'expediente_id'): |
|||
# order_by() limpa o Meta.ordering ('id'), que no Django 2.2 entraria |
|||
# no GROUP BY e deixaria todo grupo com n=1. |
|||
duplicadas = (VotoParlamentar.objects |
|||
.exclude(**{materia_field: None}) |
|||
.order_by() |
|||
.values('parlamentar_id', materia_field) |
|||
.annotate(n=Count('id')) |
|||
.filter(n__gt=1)) |
|||
for chave in duplicadas: |
|||
votos_duplicados = list(VotoParlamentar.objects.filter( |
|||
parlamentar_id=chave['parlamentar_id'], |
|||
**{materia_field: chave[materia_field]})) |
|||
voto_preservado = max( |
|||
votos_duplicados, |
|||
key=lambda voto: ( |
|||
voto.votacao_id is not None, |
|||
voto.data_hora is not None, |
|||
voto.data_hora, |
|||
voto.id, |
|||
)) |
|||
ids_removidos = [voto.id for voto in votos_duplicados |
|||
if voto.id != voto_preservado.id] |
|||
VotoParlamentar.objects.filter(id__in=ids_removidos).delete() |
|||
|
|||
|
|||
class Migration(migrations.Migration): |
|||
|
|||
dependencies = [ |
|||
('sessao', '0070_presenca_unica_por_sessao'), |
|||
] |
|||
|
|||
operations = [ |
|||
migrations.RunPython( |
|||
remove_votos_duplicados, migrations.RunPython.noop), |
|||
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,45 @@ |
|||
# Generated by Django 2.2.28 on 2026-08-16 18:58 |
|||
|
|||
from django.db import migrations, models |
|||
|
|||
|
|||
def fecha_materias_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 criada por último (maior |
|||
id) de cada tabela e fecha as demais. |
|||
|
|||
Também zera registro_aberto, que mudou de significado ("a Mesa abriu a |
|||
tela de registro" -> "novos votos bloqueados"): o fluxo antigo marcava |
|||
True ao abrir a tela e não voltava para False ao encerrar, o que faria |
|||
uma matéria reaberta voltar com os tablets bloqueados. |
|||
""" |
|||
for model_name in ('OrdemDia', 'ExpedienteMateria'): |
|||
model = apps.get_model('sessao', model_name) |
|||
model.objects.filter(registro_aberto=True).update(registro_aberto=False) |
|||
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', '0071_votoparlamentar_unique_constraint'), |
|||
] |
|||
|
|||
operations = [ |
|||
migrations.RunPython( |
|||
fecha_materias_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,237 @@ |
|||
from django.db import migrations, models |
|||
|
|||
|
|||
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', '0072_votacao_aberta_unique_constraint'), |
|||
] |
|||
|
|||
operations = [ |
|||
migrations.AddField( |
|||
model_name='votoparlamentar', |
|||
name='votado_pelo_parlamentar', |
|||
field=models.BooleanField( |
|||
default=False, |
|||
help_text='Marca se este voto foi lançado pelo próprio parlamentar ' |
|||
'(voto individual/tablet) — distinto de um registro feito ' |
|||
'pela Mesa/operador em nome dele.', |
|||
verbose_name='Votado pelo parlamentar'), |
|||
), |
|||
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, |
|||
'votado_pelo_parlamentar', vp.votado_pelo_parlamentar |
|||
)) 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, |
|||
'votado_pelo_parlamentar', vp.votado_pelo_parlamentar |
|||
)) 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', '0073_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,516 @@ |
|||
from unittest import mock |
|||
|
|||
import pytest |
|||
from django.contrib.auth import get_user_model |
|||
from django.db import IntegrityError |
|||
from django.urls import reverse |
|||
from model_bakery import baker |
|||
|
|||
from sapl.materia.models import MateriaLegislativa, TipoMateriaLegislativa |
|||
from sapl.painel.views import VoteError, _cast_vote, _toggle_registro |
|||
from sapl.parlamentares.models import (Legislatura, Parlamentar, |
|||
SessaoLegislativa) |
|||
from sapl.sessao.models import (OrdemDia, PresencaOrdemDia, RegistroVotacao, |
|||
SessaoPlenaria, TipoResultadoVotacao, |
|||
TipoSessaoPlenaria, VotoParlamentar) |
|||
|
|||
NOMINAL = 2 |
|||
|
|||
|
|||
@pytest.fixture(autouse=True) |
|||
def in_memory_channel_layer(settings): |
|||
settings.CHANNEL_LAYERS = { |
|||
'default': {'BACKEND': 'channels.layers.InMemoryChannelLayer'}} |
|||
|
|||
|
|||
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 |
|||
|
|||
|
|||
# Registro individual de votação nominal (Ordem do Dia e Expediente) é |
|||
# feito hoje pela tela v2 (Vue) — votacaonominal_v2, sem oid/mid na URL, a |
|||
# matéria aberta é resolvida via WebSocket (get_materia_aberta/ |
|||
# get_materia_expediente_aberta) — não mais pela tela legada nominal.html/ |
|||
# VotacaoNominalAbstract/VotacaoNominalView/VotacaoNominalExpedienteView, |
|||
# removida nesta unificação. bloquear-registro-votacao/reabrir-votacao, |
|||
# que só existiam lá, viraram _toggle_registro() (sapl/painel/views.py), |
|||
# testado abaixo. votacaonominaledit/votacaonominalexpedit (edição de um |
|||
# registro já fechado) usam uma base diferente (VotacaoNominalEditAbstract) |
|||
# e continuam intocados. |
|||
|
|||
|
|||
@pytest.mark.django_db(transaction=False) |
|||
def test_toggle_registro_bloqueia_e_reabre_sem_mexer_em_votos(): |
|||
""" |
|||
Regressão do que test_bloquear_e_reabrir_votacao_nao_mexe_em_votos_ |
|||
existentes (tela legada) cobria: bloquear/reabrir o registro não pode |
|||
alterar nenhum voto já registrado, só a flag que trava votos novos. |
|||
""" |
|||
sessao, ordem = _ordem_nominal_aberta() |
|||
votante = _presente(sessao) |
|||
baker.make(VotoParlamentar, ordem=ordem, parlamentar=votante, voto='Sim') |
|||
user = baker.make(get_user_model()) |
|||
|
|||
result = _toggle_registro(user, sessao.pk, True) |
|||
ordem.refresh_from_db() |
|||
assert ordem.registro_aberto is True |
|||
assert result == {"registro_aberto": True} |
|||
|
|||
result2 = _toggle_registro(user, sessao.pk, False) |
|||
ordem.refresh_from_db() |
|||
assert ordem.registro_aberto is False |
|||
assert result2 == {"registro_aberto": False} |
|||
|
|||
voto = VotoParlamentar.objects.get(ordem=ordem, parlamentar=votante) |
|||
assert voto.voto == 'Sim' |
|||
|
|||
|
|||
@pytest.mark.django_db(transaction=False) |
|||
def test_toggle_registro_falha_sem_materia_nominal_aberta(): |
|||
sessao = _sessao_plenaria() |
|||
user = baker.make(get_user_model()) |
|||
with pytest.raises(VoteError): |
|||
_toggle_registro(user, sessao.pk, True) |
|||
|
|||
|
|||
@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) |
|||
|
|||
|
|||
def _abrir_votacao_url(sessao, ordem): |
|||
return reverse('sapl.sessao:abrir_votacao', |
|||
kwargs={'pk': ordem.pk, 'spk': sessao.pk}) + '?tipo_materia=ordem' |
|||
|
|||
|
|||
@pytest.mark.django_db(transaction=False) |
|||
def test_abrir_outra_votacao_zera_bloqueio_da_anterior(admin_client): |
|||
""" |
|||
Abrir Y sem encerrar X fecha X; X não pode voltar com os tablets |
|||
bloqueados (registro_aberto=True) quando for reaberta. |
|||
""" |
|||
sessao, x = _ordem_nominal_aberta() |
|||
sessao.iniciada = True |
|||
sessao.finalizada = False |
|||
sessao.save() |
|||
_presente(sessao) |
|||
x.registro_aberto = True |
|||
x.save() |
|||
y = baker.make(OrdemDia, sessao_plenaria=sessao, materia=_materia(), |
|||
tipo_votacao=NOMINAL, votacao_aberta=False) |
|||
|
|||
admin_client.get(_abrir_votacao_url(sessao, y)) |
|||
|
|||
x.refresh_from_db() |
|||
y.refresh_from_db() |
|||
assert y.votacao_aberta is True |
|||
assert x.votacao_aberta is False |
|||
assert x.registro_aberto is False |
|||
|
|||
# Legado: matéria fechada que ficou com registro_aberto=True. |
|||
OrdemDia.objects.filter(pk=x.pk).update(registro_aberto=True) |
|||
admin_client.get(_abrir_votacao_url(sessao, x)) |
|||
|
|||
x.refresh_from_db() |
|||
assert x.votacao_aberta is True |
|||
assert x.registro_aberto is False |
|||
|
|||
|
|||
@pytest.mark.django_db(transaction=False) |
|||
def test_abrir_votacao_concorrente_mostra_erro_em_vez_de_500(admin_client): |
|||
""" |
|||
O lock de abrir_votacao() é por sessão, mas a unicidade de |
|||
votacao_aberta é global: uma abertura simultânea em outra sessão faz o |
|||
save cair no índice parcial. O fechamento das outras votações é |
|||
desfeito junto, então a mensagem "foram fechadas" não pode aparecer. |
|||
""" |
|||
sessao, ordem = _ordem_nominal_aberta() |
|||
ordem.votacao_aberta = False |
|||
ordem.save() |
|||
_, outra = _ordem_nominal_aberta() |
|||
sessao.iniciada = True |
|||
sessao.finalizada = False |
|||
sessao.save() |
|||
_presente(sessao) |
|||
|
|||
with mock.patch.object(OrdemDia, 'save', side_effect=IntegrityError): |
|||
response = admin_client.get(_abrir_votacao_url(sessao, ordem), follow=True) |
|||
|
|||
assert response.status_code == 200 |
|||
ordem.refresh_from_db() |
|||
outra.refresh_from_db() |
|||
assert ordem.votacao_aberta is False |
|||
assert outra.votacao_aberta is True |
|||
mensagens = [str(m) for m in response.context['messages']] |
|||
assert any('aberta simultaneamente' in m for m in mensagens) |
|||
assert not any('foram fechadas' in m for m in mensagens) |
|||
|
|||
|
|||
@pytest.mark.django_db(transaction=False) |
|||
def test_abrir_votacao_avisa_que_fechou_as_outras(admin_client): |
|||
sessao, ordem = _ordem_nominal_aberta() |
|||
ordem.votacao_aberta = False |
|||
ordem.save() |
|||
_, outra = _ordem_nominal_aberta() |
|||
sessao.iniciada = True |
|||
sessao.finalizada = False |
|||
sessao.save() |
|||
_presente(sessao) |
|||
|
|||
response = admin_client.get(_abrir_votacao_url(sessao, ordem), follow=True) |
|||
|
|||
outra.refresh_from_db() |
|||
assert outra.votacao_aberta is False |
|||
mensagens = [str(m) for m in response.context['messages']] |
|||
assert 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_remove_votos_duplicados_antes_da_constraint(): |
|||
import importlib |
|||
|
|||
from django.apps import apps as real_apps |
|||
from django.db import connection |
|||
|
|||
migracao = importlib.import_module( |
|||
'sapl.sessao.migrations.0071_votoparlamentar_unique_constraint') |
|||
|
|||
with connection.cursor() as cursor: |
|||
cursor.execute( |
|||
'DROP INDEX sessao_votoparlamentar_unique_parlamentar_ordem') |
|||
|
|||
sessao, ordem = _ordem_nominal_aberta() |
|||
parlamentar = _presente(sessao) |
|||
antigo = baker.make(VotoParlamentar, ordem=ordem, |
|||
parlamentar=parlamentar, voto='Não') |
|||
recente = baker.make(VotoParlamentar, ordem=ordem, |
|||
parlamentar=parlamentar, voto='Sim') |
|||
|
|||
migracao.remove_votos_duplicados(real_apps, None) |
|||
|
|||
votos = VotoParlamentar.objects.filter( |
|||
ordem=ordem, parlamentar=parlamentar) |
|||
assert list(votos.values_list('id', flat=True)) == [recente.id] |
|||
assert not votos.filter(id=antigo.id).exists() |
|||
|
|||
|
|||
@pytest.mark.django_db(transaction=False) |
|||
def test_migracao_0072_fecha_duplicatas_antes_da_constraint(): |
|||
""" |
|||
A função de dados da migration 0072 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.0072_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, registro_aberto=True) |
|||
assert mais_recente.pk > mais_antiga.pk |
|||
# Legado: o GET antigo da tela de registro deixava registro_aberto=True |
|||
# em matérias já encerradas. |
|||
encerrada = baker.make(OrdemDia, sessao_plenaria=sessao, |
|||
materia=mais_antiga.materia, tipo_votacao=NOMINAL, |
|||
votacao_aberta=False, registro_aberto=True) |
|||
|
|||
migracao.fecha_materias_abertas_duplicadas(real_apps, None) |
|||
|
|||
mais_antiga.refresh_from_db() |
|||
mais_recente.refresh_from_db() |
|||
encerrada.refresh_from_db() |
|||
assert mais_antiga.votacao_aberta is False |
|||
assert mais_recente.votacao_aberta is True |
|||
assert mais_recente.registro_aberto is False |
|||
assert encerrada.registro_aberto is False |
|||
|
|||
|
|||
@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 |
|||
|
|||
|
|||
# Regras do #3855 aplicadas ao fluxo v2 (close_voting / _cast_vote / |
|||
# votante_view), que substituiu VotacaoNominalAbstract. |
|||
|
|||
def _close_voting(client, sessao, tipo_resultado): |
|||
return client.post( |
|||
reverse('sapl.painel:close_voting', kwargs={'controller_id': sessao.pk}), |
|||
{'resultado_id': tipo_resultado.pk, 'observacoes': ''}) |
|||
|
|||
|
|||
@pytest.mark.django_db(transaction=False) |
|||
def test_close_voting_conta_so_presentes_e_apaga_voto_de_quem_saiu(admin_client): |
|||
sessao, ordem = _ordem_nominal_aberta() |
|||
presente = _presente(sessao) |
|||
saiu = _presente(sessao) |
|||
baker.make(VotoParlamentar, ordem=ordem, parlamentar=presente, voto='Não') |
|||
baker.make(VotoParlamentar, ordem=ordem, parlamentar=saiu, voto='Sim') |
|||
PresencaOrdemDia.objects.filter(sessao_plenaria=sessao, parlamentar=saiu).delete() |
|||
tipo_resultado = baker.make(TipoResultadoVotacao, nome='Rejeitada', natureza='R') |
|||
|
|||
response = _close_voting(admin_client, sessao, tipo_resultado) |
|||
|
|||
assert response.status_code == 200 |
|||
registro = RegistroVotacao.objects.get(ordem=ordem) |
|||
assert registro.numero_votos_sim == 0 |
|||
assert registro.numero_votos_nao == 1 |
|||
assert not VotoParlamentar.objects.filter(ordem=ordem, parlamentar=saiu).exists() |
|||
|
|||
|
|||
@pytest.mark.django_db(transaction=False) |
|||
def test_close_voting_registra_nao_votou_para_presentes_sem_voto(admin_client): |
|||
sessao, ordem = _ordem_nominal_aberta() |
|||
votou = _presente(sessao) |
|||
nao_votou = _presente(sessao) |
|||
baker.make(VotoParlamentar, ordem=ordem, parlamentar=votou, voto='Sim') |
|||
tipo_resultado = baker.make(TipoResultadoVotacao, nome='Aprovada', natureza='A') |
|||
|
|||
response = _close_voting(admin_client, sessao, tipo_resultado) |
|||
|
|||
assert response.status_code == 200 |
|||
registro = RegistroVotacao.objects.get(ordem=ordem) |
|||
voto = VotoParlamentar.objects.get(ordem=ordem, parlamentar=nao_votou) |
|||
assert voto.voto == 'Não Votou' |
|||
assert voto.votacao_id == registro.id |
|||
|
|||
|
|||
@pytest.mark.django_db(transaction=False) |
|||
def test_close_voting_sem_votos_nao_grava_nao_votou(admin_client): |
|||
"""O operador precisa poder corrigir e tentar de novo.""" |
|||
sessao, ordem = _ordem_nominal_aberta() |
|||
parlamentar = _presente(sessao) |
|||
tipo_resultado = baker.make(TipoResultadoVotacao, nome='Aprovada', natureza='A') |
|||
|
|||
response = _close_voting(admin_client, sessao, tipo_resultado) |
|||
|
|||
assert response.status_code == 400 |
|||
assert not VotoParlamentar.objects.filter(ordem=ordem, parlamentar=parlamentar).exists() |
|||
ordem.refresh_from_db() |
|||
assert ordem.votacao_aberta is True |
|||
|
|||
|
|||
@pytest.mark.django_db(transaction=False) |
|||
def test_close_voting_preserva_autoria_e_zera_bloqueio(admin_client): |
|||
sessao, ordem = _ordem_nominal_aberta() |
|||
ordem.registro_aberto = True |
|||
ordem.save() |
|||
parlamentar = _presente(sessao) |
|||
autor = baker.make(get_user_model()) |
|||
baker.make(VotoParlamentar, ordem=ordem, parlamentar=parlamentar, voto='Sim', |
|||
user=autor, ip='10.0.0.1', votado_pelo_parlamentar=True) |
|||
tipo_resultado = baker.make(TipoResultadoVotacao, nome='Aprovada', natureza='A') |
|||
|
|||
response = _close_voting(admin_client, sessao, tipo_resultado) |
|||
|
|||
assert response.status_code == 200 |
|||
voto = VotoParlamentar.objects.get(ordem=ordem, parlamentar=parlamentar) |
|||
assert voto.user_id == autor.pk |
|||
assert voto.ip == '10.0.0.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_operador_nao_sobrescreve_voto_do_parlamentar(): |
|||
"""O voto registrado pelo próprio parlamentar (tablet) prevalece.""" |
|||
sessao, ordem = _ordem_nominal_aberta() |
|||
parlamentar = _presente(sessao) |
|||
baker.make(VotoParlamentar, ordem=ordem, parlamentar=parlamentar, voto='Sim', |
|||
votado_pelo_parlamentar=True) |
|||
operador = baker.make(get_user_model()) |
|||
|
|||
with pytest.raises(VoteError): |
|||
_cast_vote(operador, sessao.pk, parlamentar.pk, 'Não', ip='127.0.0.1') |
|||
|
|||
voto = VotoParlamentar.objects.get(ordem=ordem, parlamentar=parlamentar) |
|||
assert voto.voto == 'Sim' |
|||
assert voto.votado_pelo_parlamentar is True |
|||
|
|||
|
|||
@pytest.mark.django_db(transaction=False) |
|||
def test_operador_pode_corrigir_voto_que_ele_mesmo_registrou(): |
|||
sessao, ordem = _ordem_nominal_aberta() |
|||
parlamentar = _presente(sessao) |
|||
operador = baker.make(get_user_model()) |
|||
|
|||
_cast_vote(operador, sessao.pk, parlamentar.pk, 'Sim', ip='127.0.0.1') |
|||
_cast_vote(operador, sessao.pk, parlamentar.pk, 'Não', ip='127.0.0.1') |
|||
|
|||
voto = VotoParlamentar.objects.get(ordem=ordem, parlamentar=parlamentar) |
|||
assert voto.voto == 'Não' |
|||
assert voto.votado_pelo_parlamentar is False |
|||
|
|||
|
|||
@pytest.mark.django_db(transaction=False) |
|||
@pytest.mark.parametrize('cenario', ['ausente', 'bloqueado']) |
|||
def test_operador_nao_registra_voto_invalido(cenario): |
|||
sessao, ordem = _ordem_nominal_aberta() |
|||
parlamentar = _presente(sessao) |
|||
if cenario == 'ausente': |
|||
PresencaOrdemDia.objects.filter(sessao_plenaria=sessao).delete() |
|||
else: |
|||
ordem.registro_aberto = True |
|||
ordem.save() |
|||
operador = baker.make(get_user_model()) |
|||
|
|||
with pytest.raises(VoteError): |
|||
_cast_vote(operador, sessao.pk, parlamentar.pk, 'Sim', ip='127.0.0.1') |
|||
|
|||
assert not VotoParlamentar.objects.filter(ordem=ordem, parlamentar=parlamentar).exists() |
|||
|
|||
|
|||
@pytest.mark.django_db(transaction=False) |
|||
def test_operador_nao_votou_desfaz_o_proprio_lancamento_sem_gravar_linha(): |
|||
""" |
|||
'Não Votou' no <select> da Mesa desfaz o lançamento do operador; a |
|||
linha 'Não Votou' só é criada no encerramento. |
|||
""" |
|||
sessao, ordem = _ordem_nominal_aberta() |
|||
parlamentar = _presente(sessao) |
|||
operador = baker.make(get_user_model()) |
|||
|
|||
_cast_vote(operador, sessao.pk, parlamentar.pk, 'Não Votou', ip='127.0.0.1') |
|||
assert not VotoParlamentar.objects.filter(ordem=ordem, parlamentar=parlamentar).exists() |
|||
|
|||
_cast_vote(operador, sessao.pk, parlamentar.pk, 'Sim', ip='127.0.0.1') |
|||
_cast_vote(operador, sessao.pk, parlamentar.pk, 'Não Votou', ip='127.0.0.1') |
|||
assert not VotoParlamentar.objects.filter(ordem=ordem, parlamentar=parlamentar).exists() |
|||
|
|||
|
|||
@pytest.mark.django_db(transaction=False) |
|||
def test_operador_nao_desfaz_voto_do_parlamentar(): |
|||
sessao, ordem = _ordem_nominal_aberta() |
|||
parlamentar = _presente(sessao) |
|||
baker.make(VotoParlamentar, ordem=ordem, parlamentar=parlamentar, voto='Sim', |
|||
votado_pelo_parlamentar=True) |
|||
operador = baker.make(get_user_model()) |
|||
|
|||
with pytest.raises(VoteError): |
|||
_cast_vote(operador, sessao.pk, parlamentar.pk, 'Não Votou', ip='127.0.0.1') |
|||
|
|||
assert VotoParlamentar.objects.get(ordem=ordem, parlamentar=parlamentar).voto == 'Sim' |
|||
|
|||
|
|||
@pytest.mark.django_db(transaction=False) |
|||
def test_toggle_registro_nao_regrava_outros_campos(): |
|||
sessao, ordem = _ordem_nominal_aberta() |
|||
user = baker.make(get_user_model()) |
|||
# Outra operação altera a matéria depois que _toggle_registro a leu. |
|||
original = _toggle_registro.__globals__['get_materia_aberta'] |
|||
|
|||
def get_materia_aberta_obsoleta(pk): |
|||
materia = original(pk) |
|||
OrdemDia.objects.filter(pk=materia.pk).update(resultado='Aprovada') |
|||
return materia |
|||
|
|||
with mock.patch.dict(_toggle_registro.__globals__, |
|||
{'get_materia_aberta': get_materia_aberta_obsoleta}): |
|||
_toggle_registro(user, sessao.pk, True) |
|||
|
|||
ordem.refresh_from_db() |
|||
assert ordem.registro_aberto is True |
|||
assert ordem.resultado == 'Aprovada' |
|||
File diff suppressed because it is too large
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
@ -0,0 +1 @@ |
|||
.ws-status-banner[data-v-5850affd]{position:fixed;top:0;left:0;right:0;z-index:2000;padding:10px 16px;text-align:center;font-weight:700;color:#fff}.ws-status-banner--warning[data-v-5850affd]{background:#b8860b}.ws-status-banner--error[data-v-5850affd]{background:#b00020} |
|||
Binary file not shown.
@ -0,0 +1 @@ |
|||
:root{--bg-dark:#121212;--bg-panel:#1e1e1e;--text-main:#e0e0e0;--text-highlight:#4fa64d}body{background-color:var(--bg-dark);color:var(--text-main);font-family:Segoe UI,Tahoma,Geneva,Verdana,sans-serif;margin-bottom:0}.separador-vertical{border-right:1px solid #333;min-height:100vh;margin:0}.text-subtitle{color:#81c784;font-weight:600}.table-custom[data-v-a3308700]{color:#ddd}[data-v-a3308700] .table-custom tbody td{padding:8px;font-size:1.1rem}.text-title[data-v-2415194a]{color:var(--text-highlight);letter-spacing:1px}.parlamentares-grid[data-v-e3616bac]{display:grid;grid-template-columns:repeat(auto-fill,190px);width:100%}.parlamentar-card[data-v-e3616bac]{width:150px;border-radius:3px;overflow:hidden;position:relative}.foto-container[data-v-e3616bac]{border-radius:3px;overflow:hidden;height:150px}.parlamentar-card img[data-v-e3616bac]{width:100%;height:150px;display:block;-o-object-fit:fill;object-fit:fill}.parlamentar-card .nome[data-v-e3616bac]{font-weight:700;font-size:.95rem;color:#fff;line-height:1.1;text-shadow:1px 1px 2px rgba(0,0,0,.8);margin-bottom:2px;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.parlamentar-card .partido[data-v-e3616bac]{font-size:.8rem;color:#ddd;font-weight:600;text-transform:uppercase;text-shadow:1px 1px 2px rgba(0,0,0,.8)}.voto-faixa[data-v-e3616bac]{position:absolute;width:90px;height:90px;top:-45px;right:-45px;-webkit-transform:rotate(45deg);transform:rotate(45deg);display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-align:end;-ms-flex-align:end;align-items:flex-end;-webkit-box-pack:center;-ms-flex-pack:center;justify-content:center;color:#fff;font-weight:700;-webkit-box-shadow:-2px 2px 5px rgba(0,0,0,.2);box-shadow:-2px 2px 5px rgba(0,0,0,.2);z-index:10;line-height:1}.voto-faixa p[data-v-e3616bac]{-webkit-transform:rotate(-45deg);transform:rotate(-45deg);font-size:2rem;text-transform:uppercase}.voto-sim .voto-faixa[data-v-e3616bac]{background-color:#27ae60}.voto-nao .voto-faixa[data-v-e3616bac]{background-color:#c0392b}.voto-abstencao .voto-faixa[data-v-e3616bac]{background-color:#f39c12}.voto-informado .voto-faixa[data-v-e3616bac]{background-color:#d35400}.voto-ausente .voto-faixa[data-v-e3616bac]{background-color:#7f8c8d}#oradores_list tr td[data-v-090c1b6f]{font-size:1.2rem;padding:5px;text-align:start}.text-title[data-v-4b0ccace]{color:var(--text-highlight);letter-spacing:1px}.table-custom[data-v-2acf11e3]{color:#ddd}.table-custom tbody td[data-v-2acf11e3]{padding:8px;font-size:1.1rem}.table-sim[data-v-2acf11e3]{color:#27ae60}.table-nao[data-v-2acf11e3]{color:#c0392b}.table-abstencao[data-v-2acf11e3]{color:#f39c12}.table-total[data-v-2acf11e3]{color:#194bfa}.logo-painel[data-v-5367ad58]{max-height:150px;width:auto}.hora-brasao[data-v-5367ad58]{border-top:1px solid #333}.ws-status-banner[data-v-5850affd]{position:fixed;top:0;left:0;right:0;z-index:2000;padding:10px 16px;text-align:center;font-weight:700;color:#fff}.ws-status-banner--warning[data-v-5850affd]{background:#b8860b}.ws-status-banner--error[data-v-5850affd]{background:#b00020} |
|||
Binary file not shown.
@ -1 +0,0 @@ |
|||
.painel-principal{background:#1c1b1b;font-family:Verdana;font-size:x-large}.painel-principal .text-title{color:#4fa64d;margin:.5rem;font-weight:700}.painel-principal .text-subtitle{color:#459170;font-weight:700}.painel-principal .data-hora{font-size:180%}.painel-principal .text-value{color:#fff}.painel-principal .logo-painel{max-width:100%}.painel-principal .painels{-ms-flex-wrap:wrap;flex-wrap:wrap}.painel-principal .painel{margin-top:1rem}.painel-principal .painel table{width:100%}.painel-principal .painel h2{margin-bottom:.5rem}.painel-principal .painel #oradores_list,.painel-principal .painel #votacao{text-align:left;display:inline-block;margin-bottom:1rem} |
|||
Binary file not shown.
@ -1 +0,0 @@ |
|||
.painel-principal{background:#1c1b1b;font-family:Verdana;font-size:x-large}.painel-principal .text-title{color:#4fa64d;margin:.5rem;font-weight:700}.painel-principal .text-subtitle{color:#459170;font-weight:700}.painel-principal .data-hora{font-size:180%}.painel-principal .text-value{color:#fff}.painel-principal .logo-painel{max-width:100%}.painel-principal .painels{-ms-flex-wrap:wrap;flex-wrap:wrap}.painel-principal .painel{margin-top:1rem}.painel-principal .painel table{width:100%}.painel-principal .painel h2{margin-bottom:.5rem}.painel-principal .painel #oradores_list,.painel-principal .painel #votacao{text-align:left;display:inline-block;margin-bottom:1rem} |
|||
Binary file not shown.
@ -0,0 +1 @@ |
|||
.ws-status-banner[data-v-5850affd]{position:fixed;top:0;left:0;right:0;z-index:2000;padding:10px 16px;text-align:center;font-weight:700;color:#fff}.ws-status-banner--warning[data-v-5850affd]{background:#b8860b}.ws-status-banner--error[data-v-5850affd]{background:#b00020} |
|||
Binary file not shown.
@ -0,0 +1 @@ |
|||
.ws-status-banner[data-v-5850affd]{position:fixed;top:0;left:0;right:0;z-index:2000;padding:10px 16px;text-align:center;font-weight:700;color:#fff}.ws-status-banner--warning[data-v-5850affd]{background:#b8860b}.ws-status-banner--error[data-v-5850affd]{background:#b00020} |
|||
Binary file not shown.
@ -0,0 +1 @@ |
|||
: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}.ws-status-banner[data-v-5850affd]{position:fixed;top:0;left:0;right:0;z-index:2000;padding:10px 16px;text-align:center;font-weight:700;color:#fff}.ws-status-banner--warning[data-v-5850affd]{background:#b8860b}.ws-status-banner--error[data-v-5850affd]{background:#b00020} |
|||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 59 KiB |
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue