Browse Source

WIP

websockets-2025
Edward Ribeiro 2 weeks ago
parent
commit
444a9c60cb
  1. 260
      frontend/src/__apps/painel/main.js
  2. 92
      frontend/src/components/Cronometro.vue
  3. 51
      frontend/src/components/CronometroList.vue
  4. 12
      frontend/src/components/PainelHeader.vue
  5. 29
      frontend/src/components/PainelMateria.vue
  6. 29
      frontend/src/components/PainelOradores.vue
  7. 15
      frontend/src/components/PainelParlamentares.vue
  8. 23
      frontend/src/components/PainelResultado.vue
  9. 117
      frontend/src/components/StopWatch.vue
  10. 14525
      package-lock.json
  11. 3
      package.json
  12. 48
      sapl/painel/consumers.py
  13. 6
      sapl/painel/urls.py
  14. 51
      sapl/painel/views.py
  15. 21
      sapl/templates/painel/painel_v2.html
  16. 9
      yarn.lock

260
frontend/src/__apps/painel/main.js

@ -2,22 +2,84 @@ import './scss/painel.scss'
// main.js (Vue 2)
import Vue from 'vue'
import StopWatch from '../../components/StopWatch.vue'
import Vuex from 'vuex'
import { mapState } from 'vuex';
import { mapMutations } from 'vuex'
import Cronometro from '../../components/Cronometro.vue'
import CronometroList from '../../components/CronometroList.vue'
import PainelHeader from '../../components/PainelHeader.vue'
import PainelParlamentares from '../../components/PainelParlamentares.vue'
import PainelOradores from '../../components/PainelOradores.vue'
import PainelMateria from '../../components/PainelMateria.vue'
import PainelResultado from '../../components/PainelResultado.vue'
import alarm from '../../assets/audio/ring.mp3'
// register components
Vue.component('cronometro', StopWatch)
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)
// global store
Vue.use(Vuex)
const store = new Vuex.Store({
state: {
sessao_aberta: false,
painel_aberto: false,
mostrar_voto: false,
sessao: {},
parlamentares: [],
oradores: [],
materia: {},
resultado: {},
message: '',
},
mutations: {
sessaoStatus(state, value) {
state.sessao_aberta = value;
},
painelStatus(state, value) {
state.painel_aberto = value;
},
setParlamentares(state, parlamentares) {
state.parlamentares = parlamentares;
},
updateParlamentares(state, votos_parlamentares) {
state.parlamentares.forEach((p)=>{
if (p.parlamentar_id in votos_parlamentares) {
p.voto = votos_parlamentares[p.parlamentar_id].voto
}
});
},
setOradores(state, oradores) {
state.oradores = oradores;
},
setMateria(state, materia) {
state.materia = materia;
},
setResultado(state, resultado) {
state.resultado = resultado;
},
setMessage(state, message) {
state.message = message;
},
setMostrarVoto(state, mostrar_voto) {
state.mostrar_voto = mostrar_voto;
}
},
actions: {},
getters: {}
})
const BACKOFF = 500
const PING_INTERVAL = 30000 // 30s
new Vue({
store,
el: '#painel',
delimiters: ['[[', ']]'],
data() {
@ -37,6 +99,8 @@ new Vue({
ws: null,
isOpen: false,
error: null,
pingTimer: null,
reconnectTimer: null,
}
},
mounted() {
@ -46,66 +110,186 @@ new Vue({
this.controllerId = el.dataset.controllerId || window.controllerId
console.log(`ControllerId: ${this.controllerId}`)
this.connectWS()
},
beforeDestroy() { this.closeWS() },
computed: {
...mapState(["painel_aberto", "sessao_aberta"]),
canRender () {
return this.sessao_aberta && this.painel_aberto;
},
},
methods: {
wsUrl() {
...mapMutations(['sessaoStatus', 'painelStatus','setParlamentares',
'updateParlamentares', 'setOradores', 'setMateria',
'setResultado', 'setMessage', 'setMostrarVoto']),
wsURL() {
const proto = location.protocol === 'https:' ? 'wss' : 'ws'
return `${proto}://${location.host}/ws/painel/${this.controllerId}/`
},
connectWS() {
const url = this.wsUrl()
this.ws = new WebSocket(url)
handleStopWatchEvent(data) {
console.log("Received a stopwatch update event");
//TODO: check if action is valid
//TODO: check if stopwatch ID is valid
this.ws.addEventListener('open', () => { this.isOpen = true; this.error = null })
this.ws.addEventListener('message', (evt) => {
try {
const msg = JSON.parse(evt.data)
// SETUP STATE
console.log(`FROM VUEJS: ${evt.data}`) // DEBUG
const stopwatch = this.$refs[data.id]
//TODO: stopwatch has to buzz at 00:30 AND 00:00
if (data.action == "start" || data.action == "stop") {
console.log(`Stopwatch Event: ${data}`)
stopwatch.handleStartStop();
} else if (data.action == "reset") {
stopwatch.handleReset();
} else if (data.action == "set") {
//TODO: check if time is passed and valid
stopwatch.initialTime = stopwatch.time
stopwatch.time = stopwatch.time
} else {
console.log(`Invalid stopwatch action ${data.action}`);
return
}
// TODO: check if WebSocket is open and handle submission errors
// TODO: save stopwatch state on localStorage?
// Return updated stopwatch status
// "stopwatch": {
// "type": "stopwatch.state",
// "id": "sw:main",
// "status": "running",
// "started_at_ms": 1699990000123,
// "elapsed_ms": 5320
// }
// TODO: change started_at and elapsed to milliseconds
this.ws.send(JSON.stringify({type:'stopwatch.state',
id: stopwatch.id,
status: stopwatch.isRunning ? "running" : "stopped",
started_at_s: stopwatch.initialTime,
elapsed_s: stopwatch.initialTime - stopwatch.time,
}))
},
updateState(data) {
try {
// CONFIG GERAIS
this.sessaoStatus(data.sessao_aberta);
this.painelStatus(data.painel_aberto);
this.setMostrarVoto(data.mostrar_voto);
// PARLAMENTARES
const parlamentaresInstance = this.$refs.parlamentares
parlamentaresInstance.parlamentares = msg.parlamentares
if (data.parlamentares) {
// pre-popula para Vuex capturar mudanca de estado de 'voto'
data.parlamentares.forEach(p => p.voto = '');
this.setParlamentares(data.parlamentares);
}
// HEADER DO PAINEL
// SESSAO_PLENARIA
//TODO: group in a single SessaoPlenaria object
const headerInstance = this.$refs.painelHeader;
// SESSAO_PLENARIA
//TODO: setup as child's props?
headerInstance.sessao_plenaria = msg.sessao.sessao_plenaria
headerInstance.sessao_plenaria_data = msg.sessao.sessao_plenaria_data
headerInstance.sessao_plenaria_hora_inicio = msg.sessao.sessao_plenaria_hora_inicio
headerInstance.brasao = msg.sessao.brasao
headerInstance.sessao_plenaria = data.sessao.sessao_plenaria
headerInstance.sessao_plenaria_data = data.sessao.sessao_plenaria_data
headerInstance.sessao_plenaria_hora_inicio = data.sessao.sessao_plenaria_hora_inicio
headerInstance.brasao = data.sessao.brasao
if (data.message) {
this.setMessage(data.message);
}
// ORADORES
const oradoresInstance = this.$refs.oradores
oradoresInstance.oradores = msg.oradores
if (data.oradores) {
this.setOradores(data.oradores);
}
// MATERIA
const materiaInstance = this.$refs.materia
materiaInstance.materia = msg.materia
if (data.materia) {
this.setMateria(data.materia);
}
// RESULTADO
const resultadoInstance = this.$refs.resultado
resultadoInstance.resultado = msg.resultado
if (msg.votos_parlamentares) { // && mostrar voto
parlamentaresInstance.parlamentares.forEach((p)=>{
if (p.parlamentar_id in msg.votos_parlamentares) {
p.voto = msg.votos_parlamentares[p.parlamentar_id].voto
}
});
if (data.resultado) {
this.setResultado(data.resultado);
}
if (data.votos_parlamentares) {
this.updateParlamentares(data.votos_parlamentares);
}
} catch (e) {
console.error('Error', e);
}
},
connectWS() {
const url = this.wsURL()
this.ws = new WebSocket(url)
this.ws.addEventListener('open', () => {
this.isOpen = true;
this.error = null
console.log(`✅ WebSocket connected: ${url}`);
// send an initial message to the server
this.ws.send(JSON.stringify({ type: "echo", message: "Client connected" }));
if (msg.type === 'state') this.state = msg.payload || {}
} catch (e) { console.warn('WS parse error:', e) }
// Ping keep-alive
this.pingTimer = setInterval(() => {
const ping = JSON.stringify({ type: "ping", ts: Date.now()});
console.log(`Sending ping ${ping}`)
this.ws.send(ping);
}, PING_INTERVAL);
})
this.ws.addEventListener('close', () => { this.isOpen = false; setTimeout(() => this.connectWS(), 800) })
this.ws.addEventListener('error', (e) => { this.error = e })
this.ws.addEventListener('message', (message) => {
try {
const data = JSON.parse(message.data)
console.debug(`${JSON.stringify(data)}`)
if (data.type === 'data') {
this.updateState(data);
} else if (data.type == 'echo') {
console.log(`Received ack from server: ${JSON.stringify(data)}`);
} else if (data.type == 'ping') {
console.log(`Received ping from server: ${JSON.stringify(data)}`);
} else if (data.type == 'stopwatch.update') {
this.handleStopWatchEvent(data);
}
} catch (e) {
console.error('WS parse error:', e);
}
})
this.ws.addEventListener('close', (e) => {
console.log("❌ WebSocket closed:", e);
this.isOpen = false;
this.reconnectTimer = setTimeout(() => this.connectWS(), BACKOFF);
})
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.pingItmer);
console.log('pingTimer Interval cleared');
}
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
console.log('reconnectTimer cleared');
}
this.closeWS();
},
closeWS() { try { this.ws && this.ws.close() } catch (_) {} },
startStopwatch() { if (this.isOpen) this.ws.send(JSON.stringify({ type:'notify', stopwatch:'start' })) },
}
})

92
frontend/src/components/Cronometro.vue

@ -0,0 +1,92 @@
<template>
<div>
<audio
ref="player"
:src="audioSrc"
preload="auto"
></audio>
{{ title }}: <span>{{ formatTime(time) }}</span><br/>
</div>
</template>
<script>
export default {
name: 'Cronometro',
props: ['id', 'title'],
data() {
return {
time: 300,
isRunning: false,
initialTime: 300,
intervalId: null,
audioSrc: require('@/assets/audio/ring.mp3'),
}
},
mounted() {
console.log('Cronometro mounted');
console.log(this.audioSrc);
this.$emit('child-mounted'); // Emit a custom event
},
methods: {
handleStartStop() {
this.isRunning = !this.isRunning;
if (this.isRunning) {
this.intervalId = setInterval(() => {
if (this.time > 0) {
this.time--;
// play buzz at 00:00:30
if (this.time == 30000) {
this.playSound();
}
} else {
this.isRunning = false;
clearInterval(this.intervalId);
this.playSound();
}
}, 1000);
} else {
clearInterval(this.intervalId);
}
},
handleReset() {
this.isRunning = false;
clearInterval(this.intervalId);
this.time = this.initialTime;
},
playSound() {
const audio = this.$refs.player
if (!audio) return
const playPromise = audio.play()
},
formatTime(seconds) {
const hrs = Math.floor(seconds / 3600);
const mins = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
if (hrs > 0) {
return `${hrs.toString().padStart(2, '0')}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
return `${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>

51
frontend/src/components/CronometroList.vue

@ -0,0 +1,51 @@
.<template>
<div class="col-md-6 text-left painel" v-if="canRender">
<div class="d-flex align-items-left justify-content-left mb-2">
<h2 class="text-subtitle mb-0">Cronômetros</h2>
</div>
<div class="text-value" id="box_cronometros">
<Cronometro v-for="(title, idx) in titles" :key="idx" :title="title" :ref="'childRef_' + idx" @child-mounted="handleChildMounted"/>
</div>
</div>
</template>
<script>
import { ref, onMounted } from 'vue';
import { mapState } from 'vuex';
import Cronometro from './Cronometro.vue';
export default {
name: 'CronometroList',
components: {
Cronometro,
},
data() {
return {
titles: ["Discurso", "Aparte", "Questão de Ordem", "Considerações Finais"],
itemRefs: ref([]), // An array to store the refs
}
},
mounted() {
console.log('CronometroList mounted');
},
computed: {
canRender () {
return this.sessao_aberta && this.painel_aberto;
},
...mapState(["painel_aberto", "sessao_aberta"])
},
methods: {
handleStartStop() {
console.log("start/stop stopwatch");
//console.log(this.$refs.itemRefs);
},
handleChildMounted() {
console.log('ChildComponent has finished mounting in the parent!');
// Perform actions in the parent that depend on the child being fully mounted
const childId = 0;
const childComponent = this.$refs['childRef_' + childId];
childComponent[0].handleStartStop();
},
},
};
</script>

12
frontend/src/components/PainelHeader.vue

@ -1,4 +1,4 @@
<template>
<template v-if="sessao_aberta">
<div>
<div class="d-flex justify-content-center">
<h1 id="sessao_plenaria" class="title text-title">{{ sessao_plenaria }} </h1>
@ -16,7 +16,7 @@
<img v-bind:src="brasao" id="logo-painel" class="logo-painel" alt=""/>
</div>
</div>
<div class="row justify-content-center">
<div class="row justify-content-center" v-if="!painel_aberto">
<h2 class="text-danger"><span id="message">{{ message }}</span></h2>
</div>
@ -28,9 +28,9 @@
</template>
<script>
import { mapState } from 'vuex';
export default {
name: 'PainelHeader',
props: {
},
@ -40,7 +40,7 @@ export default {
sessao_plenaria_data: "22/10/2025",
sessao_plenaria_hora_inicio: "13:30",
brasao: "",
message: "painel fechado",
message: "",
data_atual: "",
relogio: "",
currentDateTimeId: null, // stores the id returned by setInterval()
@ -62,7 +62,9 @@ export default {
}
},
},
computed: {
...mapState(["sessao_aberta", "painel_aberto", "message", "mostrar_voto"])
},
mounted() {
console.log('PainelHeader component mounted');
this.startCurrentDateTime();

29
frontend/src/components/PainelMateria.vue

@ -1,32 +1,39 @@
<template>
<div class="col-md-6 text-center painel" id="obs_materia_div">
<h2 class="text-subtitle" id="mat_em_votacao">Matéria em Votação</h2>
<span id="materia_legislativa_texto" class="text-value">{{ materia.texto }}</span>
<br>
<span id="materia_legislativa_ementa" class="text-value">{{ materia.ementa }} </span>
<br>
<span id="observacao_materia" class="text-value">{{ materia.observacao }}</span>
</div>
<div class="col-md-6 text-center painel" id="obs_materia_div" v-if="canRender">
<h2 class="text-subtitle" id="mat_em_votacao">Matéria em Votação</h2>
<span id="materia_legislativa_texto" class="text-value">{{ materia.texto }}</span>
<br>
<span id="materia_legislativa_ementa" class="text-value">{{ materia.ementa }} </span>
<br>
<span id="observacao_materia" class="text-value">{{ materia.observacao }}</span>
</div>
</template>
<script>
import { mapState } from 'vuex';
export default {
name: 'PainelMateria',
data() {
return {
/*
materia: {
texto: '',
ementa: '',
observacao: '',
}
*/
};
},
mounted() {
console.log('PainelMateria mounted');
},
beforeDestroy() {
},
beforeDestroy() {},
computed: {
canRender () {
return this.sessao_aberta && this.painel_aberto;
},
...mapState(["painel_aberto", "sessao_aberta", "materia"])
}
};
</script>

29
frontend/src/components/PainelOradores.vue

@ -1,28 +1,33 @@
<template>
<div class="col-md-6 text-center painel" id="aparecer_oradores">
<h2 class="text-subtitle">Oradores</h2>
<table id="oradores_list">
<tr v-for="o in oradores" :key="o.ordem_pronunciamento"><td style="padding-right:20px; color:white">
{{ o.ordem_pronunciamento }}º &nbsp {{ o.nome_parlamentar }}</td>
</tr>
</table>
</div>
<div class="col-md-6 text-center painel" id="aparecer_oradores" v-if="canRender">
<h2 class="text-subtitle">Oradores</h2>
<table id="oradores_list">
<tr v-for="o in oradores" :key="o.ordem_pronunciamento"><td style="padding-right:20px; color:white">
{{ o.ordem_pronunciamento }}º &nbsp {{ o.nome_parlamentar }}</td>
</tr>
</table>
</div>
</template>
<script>
import { mapState } from 'vuex';
export default {
name: 'PainelOradores',
data() {
return {
oradores: [],
// oradores: [],
};
},
mounted() {
console.log('PainelOradores mounted');
},
beforeDestroy() {
},
beforeDestroy() {},
computed: {
canRender () {
return this.sessao_aberta && this.painel_aberto;
},
...mapState(["painel_aberto", "sessao_aberta", "oradores"])
}
};
</script>

15
frontend/src/components/PainelParlamentares.vue

@ -1,5 +1,5 @@
<template>
<div class="col-md-4">
<div class="col-md-4" v-if="canRender">
<div class="text-center painel">
<h2 class="text-subtitle">Parlamentares</h2>
<span id="parlamentares" class="text-value text-center"></span>
@ -7,7 +7,7 @@
<tr v-for="p in parlamentares" :key="p.parlamentar_id" class="text-value text-center">
<td style="padding-right:20px; color:yellow" > {{ p.nome_parlamentar }}</td>
<td style="padding-right:20px; color:yellow"> {{ p.filiacao }}</td>
<td style="padding-right:20px; color:yellow"> {{ p.voto }} </td>
<td style="padding-right:20px; color:yellow" v-if="mostrar_voto"> {{ p.voto }} </td>
</tr>
</table>
</div>
@ -15,18 +15,23 @@
</template>
<script>
import { mapState } from 'vuex';
export default {
name: 'PainelParlamentares',
data() {
return {
parlamentares: [],
//parlamentares: [],
};
},
mounted() {
console.log('PainelParlamentares mounted');
},
beforeDestroy() {
beforeDestroy() {},
computed: {
canRender () {
return this.sessao_aberta && this.painel_aberto;
},
...mapState(["painel_aberto", "sessao_aberta", "parlamentares", "mostrar_voto"])
},
};
</script>

23
frontend/src/components/PainelResultado.vue

@ -1,7 +1,13 @@
<template>
<div class="col-md-6 text-left painel" id="resultado_votacao_div">
<div class="col-md-6 text-left painel" id="resultado_votacao_div" v-if="canRender">
<div class="d-flex align-items-left justify-content-left mb-2">
<h2 class="text-subtitle mb-0">Resultado</h2>
<h2 class="text-subtitle mb-0">Resultado</h2>
<button class="btn btn-sm btn-secondary ms-2" onclick="changeFontSize('box_votacao', -1)">
A-
</button>
<button class="btn btn-sm btn-secondary ms-2" onclick="changeFontSize('box_votacao', 1)">
A+
</button>
</div>
<div id="box_votacao">
<div id="votacao" class="text-value">
@ -17,10 +23,12 @@
</template>
<script>
import { mapState } from 'vuex';
export default {
name: 'PainelResultado',
data() {
return {
/*
resultado: {
numero_votos: {
votos_sim: 0,
@ -31,14 +39,19 @@ export default {
},
resultado_votacao: '',
}
*/
};
},
mounted() {
console.log('PainelResultado mounted');
},
beforeDestroy() {
},
beforeDestroy() {},
computed: {
canRender () {
return this.sessao_aberta && this.painel_aberto;
},
...mapState(["painel_aberto", "sessao_aberta", "resultado"])
}
};
</script>

117
frontend/src/components/StopWatch.vue

@ -1,117 +0,0 @@
<template>
<div class="stopwatch-container">
<div class="stopwatch-card">
<h1>Stopwatch Timer</h1>
<div class="time-input">
<label>Set Time (seconds)</label>
<input
type="number"
v-model.number="initialTime"
:disabled="isRunning"
min="0"
/>
</div>
<div class="time-display" :class="{ 'time-up': time === 0 }">
{{ formatTime(time) }}
</div>
<div class="controls">
<button @click="handleStartStop" :class="isRunning ? 'pause-btn' : 'start-btn'">
{{ isRunning ? 'Pause' : 'Start' }}
</button>
<button @click="handleReset" class="reset-btn">
Reset
</button>
</div>
<div v-if="time === 0" class="alert">
<p>Time's up!</p>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'Stopwatch',
data() {
return {
time: 300,
isRunning: false,
initialTime: 300,
intervalId: null
}
},
methods: {
handleStartStop() {
this.isRunning = !this.isRunning;
if (this.isRunning) {
this.intervalId = setInterval(() => {
if (this.time > 0) {
this.time--;
} else {
this.isRunning = false;
clearInterval(this.intervalId);
this.playSound();
}
}, 1000);
} else {
clearInterval(this.intervalId);
}
},
handleReset() {
this.isRunning = false;
clearInterval(this.intervalId);
this.time = this.initialTime;
},
playSound() {
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
const oscillator = audioContext.createOscillator();
const gainNode = audioContext.createGain();
oscillator.connect(gainNode);
gainNode.connect(audioContext.destination);
oscillator.frequency.value = 800;
oscillator.type = 'sine';
gainNode.gain.setValueAtTime(0.3, audioContext.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.5);
oscillator.start(audioContext.currentTime);
oscillator.stop(audioContext.currentTime + 0.5);
},
formatTime(seconds) {
const hrs = Math.floor(seconds / 3600);
const mins = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
if (hrs > 0) {
return `${hrs.toString().padStart(2, '0')}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
return `${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>

14525
package-lock.json

File diff suppressed because it is too large

3
package.json

@ -22,7 +22,8 @@
"moment-locales-webpack-plugin": "^1.2.0",
"popper.js": "^1.16.1",
"tinymce": "^7.2.0",
"vue": "^2.7.9"
"vue": "^2.7.16",
"vuex": "3"
},
"devDependencies": {
"@babel/core": "^7.18.13",

48
sapl/painel/consumers.py

@ -13,7 +13,7 @@ from sapl.sessao.models import SessaoPlenaria, OrdemDia, ExpedienteMateria, Regi
PresencaOrdemDia, SessaoPlenariaPresenca, OradorExpediente, VotoParlamentar, AbstractOrdemDia, SessaoPresencaView, \
SessaoOradorView, SessaoMateriaVotacaoView
log = logging.getLogger(__name__)
logger = logging.getLogger(__name__)
def get_dados_painel(pk: int) -> dict:
@ -44,10 +44,14 @@ def get_dados_painel(pk: int) -> dict:
votacao = SessaoMateriaVotacaoView.objects.get(sessao_plenaria_id=pk,
etapa_sessao='expediente',
materia_id=31919)
materia_id=31910)
# TODO: check if votacao has numero_votos!
votacao.numero_votos.update({"num_presentes": len(parlamentares)})
votos_parlamentares = {}
if votacao:
if votacao.votos_parlamentares:
votos_parlamentares = {p["parlamentar_id"]: p for p in votacao.votos_parlamentares}
if votacao.numero_votos:
votacao.numero_votos.update({"num_presentes": len(parlamentares)})
# TODO: recover stopwatch state from DB/Cache
stopwatch = {
@ -60,18 +64,19 @@ def get_dados_painel(pk: int) -> dict:
dados_sessao = {
"type": "data",
"sessao_aberta": sessao.iniciada and not sessao.finalizada,
"painel_aberto": sessao.painel_aberto,
"mostrar_voto": app_config.mostrar_voto,
"message": "PAINEL ENCONTRA-SE FECHADO" if not sessao.painel_aberto else "",
"sessao": {
"sessao_plenaria_id": sessao.id,
"status_painel": sessao.painel_aberto,
"brasao": brasao,
"mostrar_voto": app_config.mostrar_voto,
"sessao_plenaria": str(sessao),
"sessao_plenaria_data": sessao.data_inicio.strftime("%d/%m/%Y"),
"sessao_plenaria_hora_inicio": sessao.hora_inicio,
"sessao_solene": sessao.tipo.nome == "Solene",
"sessao_finalizada": sessao.finalizada,
"tema_solene": sessao.tema_solene,
"status_painel": False, # TODO: recover from DB **and** move status to other place.
},
"parlamentares": parlamentares,
"oradores": oradores,
@ -80,7 +85,7 @@ def get_dados_painel(pk: int) -> dict:
"resultado": votacao.resultado,
"numero_votos": votacao.numero_votos,
},
"votos_parlamentares": {p["parlamentar_id"]: p for p in votacao.votos_parlamentares},
"votos_parlamentares": votos_parlamentares,
"materia": {
"materia_id": votacao.materia.id,
"texto": str(votacao.materia),
@ -122,7 +127,7 @@ class PainelConsumer(AsyncJsonWebsocketConsumer):
# await self.send_json({"type": "data",
# "text": "Connection established!"})
print("SENDING DATA DO CONSUMER ")
print("SENDING bootstrap DATA DO CONSUMER ")
print(get_dados_painel(controller_id))
await self.send_json(get_dados_painel(controller_id))
@ -131,19 +136,30 @@ class PainelConsumer(AsyncJsonWebsocketConsumer):
await self.channel_layer.group_discard(self.group, self.channel_name)
# Called by server via channel_layer.group_send
async def notify(self, event):
# event: {"type": "notify", "data": {...}}
await self.send_json(event)
# async def notify(self, event):
# # event: {"type": "notify", "data": {...}}
# await self.send_json(event)
async def receive(self, text_data=None, bytes_data=None):
data = json.loads(text_data or "{}")
print("Received from client:", data) # TODO: turn into log messages
if data.get("type") == "ping":
msg_type = data.get("type")
if msg_type == "ping":
print("PING")
await self.send_json({"type": "pong", "ts": time.time()})
await self.send_json({"type": "ping", "ts": time.time()})
return
elif msg_type == "echo":
await self.send_json(data)
return
elif msg_type == "stopwatch.state":
print(data)
return
await self.send_json({"type": "echo",
"text": f"Echo: {data}"})
await self.send_json({"type": "error", "message": "Misformed message"})
return
async def stopwatch_update(self, event):
await self.send_json(event)
return
@database_sync_to_async
def user_can_view(self, user_id, controller_id) -> bool:

6
sapl/painel/urls.py

@ -3,7 +3,7 @@ from django.conf.urls import url
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, websocket_view, painel_controller_view)
switch_painel, verifica_painel, votante_view, websocket_view, stopwatch_controller)
from django.urls import path
@ -27,7 +27,7 @@ urlpatterns = [
url(r'^voto-individual/$', votante_view,
name='voto_individual'),
# url(r'^painel', websocket_view, name='painel_websocket'),
path("painel/v2", websocket_view, name='painel_websocket'),
path("painel/controller", painel_controller_view, name='painel_controller'),
path("painel/v2/controller/<int:controller_id>/stopwatch",
stopwatch_controller, name='painel_controller'),
]

51
sapl/painel/views.py

@ -647,28 +647,51 @@ def websocket_view(request):
return render(request, "painel/painel_v2.html", context)
def painel_controller_view(request):
VALID_SW_IDS = ["discurso", "aparte", "questao", "consideracao"] # recover from BD?
VALID_SW_ACTIONS = ["start", "stop", "reset", "set"]
@user_passes_test(check_permission)
def stopwatch_controller(request, controller_id):
"""
http://localhost:8000/painel/v2/controller/<sessao_id>/stopwatch?id=discurso&action=start
http://localhost:8000/painel/v2/controller/<sessao_id>/stopwatch?id=discurso&action=stop
http://localhost:8000/painel/v2/controller/<sessao_id>/stopwatch?id=discurso&action=reset
http://localhost:8000/painel/v2/controller/<sessao_id>/stopwatch?id=discurso&action=set&time=30
"""
from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer
print("called") # LOG!
logger = logging.getLogger(__name__)
command = request.GET["stopwatch"]
print("command", command)
print(f"stopwatch for {controller_id}")
stopwatch_id = request.GET.get("id")
if stopwatch_id not in VALID_SW_IDS:
return JsonResponse({"type": "error",
f"message": f"Invalid stopwatch id: {stopwatch_id}"})
if command:
layer = get_channel_layer()
controller_id = 2393 # TODO: sessaoplenaria_id recover from template call
group = f"controller_{controller_id}"
stopwatch_action = request.GET.get("action")
if stopwatch_action not in VALID_SW_ACTIONS:
return JsonResponse({"type": "error",
f"message": f"Invalid stopwatch action: {stopwatch_action}"})
print(group)
stopwatch_time = request.GET.get("time", 300)
async_to_sync(layer.group_send)(
group, {"type": "notify", "stopwatch": command}
)
# await self.channel_layer.group_add(self.group, self.channel_name)
# TODO: check stopwatch state transition
layer = get_channel_layer()
group = f"controller_{controller_id}"
print(group)
notification = {
"type": "stopwatch.update",
"id": f"sw:{stopwatch_id}",
"action": stopwatch_action,
"time": stopwatch_time,
}
return JsonResponse({"ok": True})
async_to_sync(layer.group_send)(group, notification)
return JsonResponse(notification)
# def push_to_me(request):

21
sapl/templates/painel/painel_v2.html

@ -44,27 +44,34 @@
<style>[v-cloak]{display:none}</style>
<div id="painel" v-cloak class="col text-center" data-controller-id="{{ controller_id }}">
<audio type="hidden" id="alarm" src="{% webpack_static 'audio/ring.mp3' %}"></audio>
<painel-header ref="painelHeader"></painel-header>
<div class="row justify-content-center">
<div class="d-flex justify-content-start">
<painel-parlamentares ref="parlamentares"></painel-parlamentares>
<div class="d-flex col-md-8 painels">
<painel-oradores ref="oradores"></painel-oradores>
<div class="col-md-6 text-left painel">
<div class="col-md-6 text-left painel" v-if="canRender">
<div class="d-flex align-items-left justify-content-left mb-2">
<h2 class="text-subtitle mb-0">Cronômetros</h2>
</div>
<div class="text-value" id="box_cronometros">
Discurso: <span id="cronometro_discurso"></span><br>
Aparte: <span id="cronometro_aparte"></span><br>
Questão de Ordem: <span id="cronometro_ordem"></span><br>
Considerações Finais: <span id="cronometro_consideracoes"></span>
<painel-cronometro ref="sw:discurso" id="sw:discurso" title="Discurso"></painel-cronometro>
<painel-cronometro ref="sw:aparte" id="sw:aparte" title="Aparte"></painel-cronometro>
<painel-cronometro ref="sw:questao" id="sw:questao" title="Questão de Ordem"></painel-cronometro>
<painel-cronometro ref="sw:consideracoes" id="sw:consideracoes" title="Considerações Finais"></painel-cronometro>
<button class="btn btn-sm btn-secondary ms-2" onclick="changeFontSize('box_cronometros', -1)">
A-
</button>
<button class="btn btn-sm btn-secondary ms-2" onclick="changeFontSize('box_cronometros', 1)">
A+
</button>
</div>
</div>
<!-- <painel-cronometro-list ref="cronometro_list"></painel-cronometro-list>-->
<painel-resultado ref="resultado"></painel-resultado>

9
yarn.lock

@ -3758,7 +3758,7 @@ fs.realpath@^1.0.0:
fsevents@~2.3.2:
version "2.3.3"
resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6"
resolved "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz"
integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==
function-bind@^1.1.2:
@ -6783,7 +6783,7 @@ vue-template-es2015-compiler@^1.9.0:
resolved "https://registry.npmjs.org/vue-template-es2015-compiler/-/vue-template-es2015-compiler-1.9.1.tgz"
integrity sha512-4gDntzrifFnCEvyoO8PqyJDmguXgVPxKiIxrBKjIowvL9l+N66196+72XVYR8BBf1Uv1Fgt3bGevJ+sEmxfZzw==
vue@^2.7.9:
vue@^2.7.16:
version "2.7.16"
resolved "https://registry.npmjs.org/vue/-/vue-2.7.16.tgz"
integrity sha512-4gCtFXaAA3zYZdTp5s4Hl2sozuySsgz4jy1EnpBHNfpMa9dK1ZCG7viqBPCwXtmgc8nHqUsAu3G4gtmXkkY3Sw==
@ -6791,6 +6791,11 @@ vue@^2.7.9:
"@vue/compiler-sfc" "2.7.16"
csstype "^3.1.0"
vuex@3:
version "3.6.2"
resolved "https://registry.yarnpkg.com/vuex/-/vuex-3.6.2.tgz#236bc086a870c3ae79946f107f16de59d5895e71"
integrity sha512-ETW44IqCgBpVomy520DT5jf8n0zoCac+sxWnn+hMe/CzaSejb/eVw2YToiXYX+Ex/AuHHia28vWTq4goAexFbw==
watchpack@^2.4.0, watchpack@^2.4.1:
version "2.4.1"
resolved "https://registry.npmjs.org/watchpack/-/watchpack-2.4.1.tgz"

Loading…
Cancel
Save