Browse Source

* Add Websockets to Painel

* Refactor UI to VueJS
* Create DB views to speed up queries
painel-votacao-v2
Edward Ribeiro 8 months ago
parent
commit
2d6eaae489
  1. 3
      RUNBOOK.txt
  2. 14
      docker/config/nginx/sapl.conf
  3. 16
      docker/docker-compose.yaml
  4. 2
      docker/startup_scripts/start.sh
  5. 38
      frontend/src/__apps/painel-controle/main.js
  6. 43
      frontend/src/__apps/painel-controle/scss/painel.scss
  7. 294
      frontend/src/__apps/painel/main.js
  8. 238
      frontend/src/__apps/votacao/main.js
  9. 0
      frontend/src/__apps/votacao/scss/votacao.scss
  10. 99
      frontend/src/components/painel/Cronometro.vue
  11. 51
      frontend/src/components/painel/CronometroList.vue
  12. 72
      frontend/src/components/painel/PainelHeader.vue
  13. 42
      frontend/src/components/painel/PainelMateria.vue
  14. 36
      frontend/src/components/painel/PainelOradores.vue
  15. 41
      frontend/src/components/painel/PainelParlamentares.vue
  16. 69
      frontend/src/components/painel/PainelResultado.vue
  17. 11
      frontend/src/components/votacao/VotacaoNominal.vue
  18. 14525
      package-lock.json
  19. 3
      package.json
  20. 4
      requirements/requirements.txt
  21. 21
      sapl/asgi.py
  22. 54
      sapl/painel/README.md
  23. 201
      sapl/painel/consumers.py
  24. 8
      sapl/painel/urls.py
  25. 108
      sapl/painel/views.py
  26. 265
      sapl/sessao/migrations/0070_views_sessao_plenaria.py
  27. 125
      sapl/sessao/models.py
  28. 14
      sapl/sessao/urls.py
  29. 20
      sapl/settings.py
  30. 98
      sapl/templates/painel/painel_v2.html
  31. 106
      sapl/templates/sessao/painel_v2.html
  32. 78
      sapl/templates/sessao/votacao/votacao_v2.html
  33. 6
      scripts/websockets.sh
  34. 22
      vue.config.js
  35. 9
      yarn.lock

3
RUNBOOK.txt

@ -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

14
docker/config/nginx/sapl.conf

@ -10,6 +10,9 @@ map $http_x_request_id $req_id {
"" $request_id;
}
upstream sapl_ws {
server unix:/var/interlegis/sapl/run/daphne.sock fail_timeout=0;
}
server {
@ -62,6 +65,17 @@ server {
proxy_pass http://sapl_server;
}
location /ws/ {
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $http_host;
proxy_read_timeout 3600;
proxy_send_timeout 3600;
proxy_pass http://sapl_ws;
}
error_page 500 502 503 504 /500.html;
location = /500.html {
root /var/interlegis/sapl/sapl/static/;

16
docker/docker-compose.yaml

@ -1,6 +1,6 @@
services:
sapldb:
image: postgres:10.5-alpine
image: postgres:16-bookworm
restart: always
container_name: postgres
labels:
@ -32,12 +32,15 @@ services:
- "8983:8983"
networks:
- sapl-net
saplredis:
image: redis:7-alpine
command: ["redis-server", "--save", "", "--appendonly", "no"]
sapl:
image: interlegis/sapl:3.1.164-RC5
# build:
# context: ../
# dockerfile: ./docker/Dockerfile
# container_name: sapl
# image: interlegis/sapl:3.1.164-RC5
build:
context: ../
dockerfile: ./docker/Dockerfile
container_name: sapl
labels:
NAME: "sapl"
restart: always
@ -56,6 +59,7 @@ services:
SOLR_URL: http://solr:solr@saplsolr:8983
IS_ZK_EMBEDDED: 'True'
ENABLE_SAPN: 'False'
REDIS_URL: 'redis://saplredis:6379/0'
TZ: America/Sao_Paulo
volumes:
- sapl_data:/var/interlegis/sapl/data

2
docker/startup_scripts/start.sh

@ -259,6 +259,8 @@ setup_cache_dir() {
start_services() {
log "Starting gunicorn..."
gunicorn -c gunicorn.conf.py &
log "Starting websockets..."
daphne --unix-socket "$RUN_DIR/daphne.sock" sapl.asgi:application &
log "Starting nginx..."
exec /usr/sbin/nginx -g "daemon off;"
}

38
frontend/src/__apps/painel-controle/main.js

@ -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!")
}
})

43
frontend/src/__apps/painel-controle/scss/painel.scss

@ -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;
}
}
}

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

@ -1 +1,295 @@
import './scss/painel.scss'
// main.js (Vue 2)
import Vue from 'vue'
import Vuex from 'vuex'
import { mapState } from 'vuex';
import { mapMutations } from 'vuex'
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 alarm from '../../assets/audio/ring.mp3'
// 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)
// 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) {
if (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() {
return {
controllerId: null,
ws: null,
isOpen: false,
error: null,
pingTimer: null,
reconnectTimer: null,
}
},
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(["painel_aberto", "sessao_aberta"]),
canRender () {
return this.sessao_aberta && this.painel_aberto;
},
},
methods: {
...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}/`
},
handleStopWatchEvent(data) {
console.log("Received a stopwatch update event");
//TODO: check if action is valid
//TODO: check if stopwatch ID is valid
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
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;
//TODO: setup as child's props?
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
if (data.oradores) {
this.setOradores(data.oradores);
}
// MATERIA
if (data.materia) {
this.setMateria(data.materia);
}
// RESULTADO
if (data.materia.resultado) {
this.setResultado(data.materia.resultado);
}
if (data.materia.resultado.votos_parlamentares) {
this.updateParlamentares(data.materia.resultado.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 to ${url}`);
// send an initial message to the server
this.ws.send(JSON.stringify({ type: "echo", message: "Client connected" }));
// ping keep-alive timer
this.pingTimer = setInterval(() => {
const ping = JSON.stringify({ type: "ping", ts: Date.now()});
console.log(`Sending ping ${ping}`)
// TODO: check if this.ws object is still usable!!!
this.ws.send(ping);
}, PING_INTERVAL);
})
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.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' })) },
}
})

238
frontend/src/__apps/votacao/main.js

@ -0,0 +1,238 @@
import './scss/votacao.scss'
import Vue from 'vue'
import { FormSelectPlugin } from 'bootstrap-vue'
import axios from 'axios'
axios.defaults.xsrfCookieName = 'csrftoken'
axios.defaults.xsrfHeaderName = 'X-CSRFToken'
Vue.use(FormSelectPlugin)
console.log('votacao main.js carregado')
const v = new Vue({ // eslint-disable-line
delimiters: ['[[', ']]'],
el: '#votacao',
data () {
return {
votacao_aberta: true,
edit_votes: true,
disable_resultado: false,
resultado_selected: "",
observacoes: "",
error_message: "",
tipo_votacao: 2,
tipo_votacao_descricao: "Votação Nominal",
materia: "Projeto de Lei Ordinária nº 3 de 2025",
ementa: "Institui no Município de Pato Branco o Projeto Chá de Fralda Social. ",
parlamentares: [
{
"parlamentar_id": 197,
"nome_parlamentar": "Alexandre Zoche",
"filiacao": "PRD"
},
{
"parlamentar_id": 196,
"nome_parlamentar": "Anne Cristine Gomes da Silva Cavali",
"filiacao": "PSD"
},
{
"parlamentar_id": 194,
"nome_parlamentar": "Diogo Domingos Grando",
"filiacao": "PRD"
},
{
"parlamentar_id": 186,
"nome_parlamentar": "Eduardo Albani Dala Costa",
"filiacao": "Republicanos"
},
{
"parlamentar_id": 3,
"nome_parlamentar": "Fabricio Preis de Mello",
"filiacao": "PL"
},
{
"parlamentar_id": 6,
"nome_parlamentar": "Joecir Bernardi",
"filiacao": "PSD"
},
{
"parlamentar_id": 187,
"nome_parlamentar": "Lindomar Rodrigo Brandão",
"filiacao": "PP"
},
{
"parlamentar_id": 195,
"nome_parlamentar": "Rafael Foss",
"filiacao": "União"
},
{
"parlamentar_id": 11,
"nome_parlamentar": "Rodrigo José Correia",
"filiacao": "União"
},
{
"parlamentar_id": 192,
"nome_parlamentar": "Thania Maria Caminski Gehlen",
"filiacao": "PP"
}
],
//TODO: check if votos_parlamentares is null
votos_parlamentares: {
186: {
"voto": "Não",
"materia_id": 31919,
"parlamentar_id": 186,
"parlamentar_nome": "Eduardo Albani Dala Costa"
},
195: {
"voto": "Não",
"materia_id": 31919,
"parlamentar_id": 195,
"parlamentar_nome": "Rafael Foss"
},
196: {
"voto": "Não",
"materia_id": 31919,
"parlamentar_id": 196,
"parlamentar_nome": "Anne Cristine Gomes da Silva Cavali"
},
3: {
"voto": "Sim",
"materia_id": 31919,
"parlamentar_id": 3,
"parlamentar_nome": "Fabricio Preis de Mello"
},
11: {
"voto": "Não",
"materia_id": 31919,
"parlamentar_id": 11,
"parlamentar_nome": "Rodrigo José Correia"
},
194: {
"voto": "Não",
"materia_id": 31919,
"parlamentar_id": 194,
"parlamentar_nome": "Diogo Domingos Grando"
},
197: {
"voto": "Não",
"materia_id": 31919,
"parlamentar_id": 197,
"parlamentar_nome": "Alexandre Zoche"
},
6: {
"voto": "Não",
"materia_id": 31919,
"parlamentar_id": 6,
"parlamentar_nome": "Joecir Bernardi"
},
187: {
"voto": "Abstenção",
"materia_id": 31919,
"parlamentar_id": 187,
"parlamentar_nome": "Lindomar Rodrigo Brandão"
},
192: {
"voto": "Sim",
"materia_id": 31919,
"parlamentar_id": 192,
"parlamentar_nome": "Thania Maria Caminski Gehlen"
}
},
options: [
{ text: 'Sim', value: 'voto_sim' },
{ text: 'Não', value: 'voto_nao' },
{ text: 'Abstenção', value: 'abstencao' },
{ text: 'Não Votou', value: 'nao_votou' },
],
tipos_resultados: [
{
"id": 13,
"nome": "Aprovada a retirada de pauta"
},
{
"id": 10,
"nome": "Aprovada por dois terços"
},
{
"id": 2,
"nome": "Aprovada por maioria absoluta"
},
{
"id": 1,
"nome": "Aprovada por maioria simples - conforme o art. 37 do RI o presidente não vota"
},
{
"id": 8,
"nome": "Aprovada por maioria simples - conforme o art. 37 do RI o presidente votou pelo desempate"
},
{
"id": 15,
"nome": "Aprovada."
},
{
"id": 7,
"nome": "Empate - conforme o art. 37 do RI o presidente vota para desempate"
},
{
"id": 16,
"nome": "IMPROCEDENTE"
},
{
"id": 12,
"nome": "Leitura em Plenário"
},
{
"id": 17,
"nome": "PROCEDENTE"
},
{
"id": 11,
"nome": "Prejudicada"
},
{
"id": 5,
"nome": "Rejeitada"
},
{
"id": 14,
"nome": "Rejeitada a retirada de pauta"
},
{
"id": 9,
"nome": "Rejeitada por maioria simples - conforme o art. 37 do RI o presidente votou pelo desempate"
}
],
}
},
watch: {},
computed: {
total_votos() {
// TODO: use number index instead of string ("sim", "não") as keys.
var groupedVotes = Map.groupBy(Object.values(this.votos_parlamentares), ({ voto }) => voto )
// initialize total_votos
total_votos = [
{"tipo": "Sim", "total": 0},
{"tipo": "Não", "total": 0},
{"tipo": "Abstenção", "total": 0},
{"tipo": "Não Votou", total: 0}
]
for (const [key, value] of groupedVotes.entries()) {
const index = total_votos.findIndex(item => item.tipo === key);
total_votos[index].total = value.length
}
return total_votos
}
},
created () {},
methods: {},
mounted () {
console.log("Votacao app mounted!")
}
})

0
frontend/src/__apps/votacao/scss/votacao.scss

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

@ -0,0 +1,99 @@
<template>
<div>
<audio
ref="player"
:src="audioSrc"
preload="auto"
></audio>
<span ref="time">{{ title }}: {{ formatTime(time) }}<br/></span>
</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: {
changeFontSize(value) {
const el = this.$refs.time;
if (!el) return;
let fontSize = window.getComputedStyle(el).fontSize;
fontSize = parseFloat(fontSize); // safely convert "16px" 16
el.style.fontSize = (fontSize + value) + 'px';
},
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/painel/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>

72
frontend/src/components/painel/PainelHeader.vue

@ -0,0 +1,72 @@
<template v-if="sessao_aberta">
<div>
<div class="d-flex justify-content-center">
<h1 id="sessao_plenaria" class="title text-title">{{ sessao_plenaria }} </h1>
</div>
<div class="row ">
<div class="col text-center">
<span id="sessao_plenaria_data" class="text-value"> Data Início: {{ sessao_plenaria_data }} </span>
</div>
<div class="col text-center">
<span id="sessao_plenaria_hora_inicio" class="text-value"> Hora Início: {{ sessao_plenaria_hora_inicio }} </span>
</div>
</div>
<div class="row justify-content-center">
<div class="col-1">
<img v-bind:src="brasao" id="logo-painel" class="logo-painel" alt=""/>
</div>
</div>
<div class="row justify-content-center">
<h2 class="text-danger"><span id="message">{{ message }}</span></h2>
</div>
<div class="row">
<div class="col text-center"><span class="text-value data-hora" id="date">{{ data_atual }}</span></div>
<div class="col text-center"><span class="text-value data-hora" id="relogio">{{ relogio }}</span></div>
</div>
</div>
</template>
<script>
import { mapState } from 'vuex';
export default {
name: 'PainelHeader',
props: {
},
data() {
return {
sessao_plenaria: "Sessao Plenaria Teste",
sessao_plenaria_data: "22/10/2025",
sessao_plenaria_hora_inicio: "13:30",
brasao: "",
data_atual: "",
relogio: "",
currentDateTimeId: null, // stores the id returned by setInterval()
}
},
methods: {
startCurrentDateTime() {
this.data_atual = moment().utcOffset(-3).format("DD/MM/YY"); // RECOVER UTC OFFSET!!!!
this.currentDateTimeId = setInterval(() => {
this.relogio = moment.utc().utcOffset(-3).format("HH:mm:ss");
}, 500);
},
beforeDestroy() {
// Clear the interval before the component is destroyed
if (this.currentDateTimeId) {
clearInterval(this.currentDateTimeId);
console.log('currentDateTimeId Interval cleared.');
}
},
},
computed: {
...mapState(["sessao_aberta", "painel_aberto", "message", "mostrar_voto"])
},
mounted() {
console.log('PainelHeader component mounted');
this.startCurrentDateTime();
}
}
</script>

42
frontend/src/components/painel/PainelMateria.vue

@ -0,0 +1,42 @@
<template>
<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() {},
computed: {
canRender () {
return this.sessao_aberta && this.painel_aberto;
},
...mapState(["painel_aberto", "sessao_aberta", "materia"])
}
};
</script>
<style scoped>
/* Optional styling */
</style>

36
frontend/src/components/painel/PainelOradores.vue

@ -0,0 +1,36 @@
<template>
<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: [],
};
},
mounted() {
console.log('PainelOradores mounted');
},
beforeDestroy() {},
computed: {
canRender () {
return this.sessao_aberta && this.painel_aberto;
},
...mapState(["painel_aberto", "sessao_aberta", "oradores"])
}
};
</script>
<style scoped>
/* Optional styling */
</style>

41
frontend/src/components/painel/PainelParlamentares.vue

@ -0,0 +1,41 @@
<template>
<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>
<table id="parlamentares_list">
<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" v-if="mostrar_voto">{{ p.voto }}</td>
</tr>
</table>
</div>
</div>
</template>
<script>
import { mapState } from 'vuex';
export default {
name: 'PainelParlamentares',
data() {
return {
//parlamentares: [],
};
},
mounted() {
console.log('PainelParlamentares mounted');
},
beforeDestroy() {},
computed: {
canRender () {
return this.sessao_aberta && this.painel_aberto;
},
...mapState(["painel_aberto", "sessao_aberta", "parlamentares", "mostrar_voto"])
},
};
</script>
<style scoped>
/* Optional styling */
</style>

69
frontend/src/components/painel/PainelResultado.vue

@ -0,0 +1,69 @@
<template>
<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>
<button class="btn btn-sm btn-secondary ms-2" v-on:click="changeFontSize(-1)">
A-
</button>
<button class="btn btn-sm btn-secondary ms-2" v-on:click="changeFontSize(1)">
A+
</button>
</div>
<div ref="votacao" id="box_votacao">
<div id="votacao" class="text-value">
<li>Sim: {{ resultado.numero_votos.votos_sim }}</li>
<li>Não: {{ resultado.numero_votos.votos_nao }}</li>
<li>Abstenções: {{ resultado.numero_votos.abstencoes }}</li>
<li>Presentes: {{ resultado.numero_votos.num_presentes }}</li>
<li>Total votos: {{ resultado.numero_votos.total_votos }}</li>
</div>
<div id="resultado_votacao" class="text-title">{{ resultado.resultado_votacao }}</div>
</div>
</div>
</template>
<script>
import { mapState } from 'vuex';
export default {
name: 'PainelResultado',
data() {
return {
/*
resultado: {
numero_votos: {
votos_sim: 0,
votos_nao: 0,
abstencoes: 0,
total_votos: 0,
num_presentes: 0,
},
resultado_votacao: '',
}
*/
};
},
mounted() {
console.log('PainelResultado mounted');
},
beforeDestroy() {},
computed: {
canRender () {
return this.sessao_aberta && this.painel_aberto;
},
...mapState(["painel_aberto", "sessao_aberta", "resultado"])
},
methods: {
changeFontSize(value) {
const el = this.$refs.votacao;
if (!el) return;
let fontSize = window.getComputedStyle(el).fontSize;
fontSize = parseFloat(fontSize); // safely convert "16px" 16
el.style.fontSize = (fontSize + value) + 'px';
},
}
};
</script>
<style scoped>
/* Optional styling */
</style>

11
frontend/src/components/votacao/VotacaoNominal.vue

@ -0,0 +1,11 @@
<template>
<div class="VotacaoNominal">
<pre v-text="$attrs"/>
</div>
</template>
<script>
export default {
name: 'Votacaonominal'
};
</script>

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",

4
requirements/requirements.txt

@ -26,6 +26,10 @@ reportlab==3.6.13
WeasyPrint==66
trml2pdf==0.6
gunicorn==23.0.0
channels==3.0.3
daphne==3.0.2
channels-redis==3.4.1
asgiref==3.7.2
more-itertools==8.2.0
pysolr==3.6.0
PyPDF4==1.27.0

21
sapl/asgi.py

@ -0,0 +1,21 @@
# sapl/asgi.py
import os
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "sapl.settings")
import django
django.setup()
from channels.routing import ProtocolTypeRouter, URLRouter
from channels.auth import AuthMiddlewareStack
from channels.http import AsgiHandler # Django 2.2 uses AsgiHandler, not django.core.asgi
from django.urls import path
from sapl.painel.consumers import PainelConsumer, HealthConsumer
application = ProtocolTypeRouter({
"http": AsgiHandler(),
"websocket": AuthMiddlewareStack(URLRouter([
# path("ws/painel/", PainelConsumer.as_asgi()),
path("ws/painel/<int:controller_id>/", PainelConsumer.as_asgi()),
])),
})

54
sapl/painel/README.md

@ -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;

201
sapl/painel/consumers.py

@ -0,0 +1,201 @@
import json
import json
import logging
import time
from channels.db import database_sync_to_async
from channels.generic.websocket import AsyncWebsocketConsumer, AsyncJsonWebsocketConsumer
from sapl.base.models import CasaLegislativa, AppConfig
from sapl.sessao.models import SessaoPlenaria, SessaoPresencasView, \
SessaoOradoresView, SessaoMateriasVotacoesView
def get_materia_votacao(votacao):
return {
# TOO UGLY! FIX THIS if-else
"materia_id": votacao.materia.id if votacao and votacao.materia else "",
"texto": str(votacao.materia) if votacao and votacao.materia else "",
"ementa": votacao.materia.ementa if votacao and votacao.materia and votacao.materia.ementa else "",
"resultado": {
"resultado_votacao": votacao.resultado_votacao if votacao and votacao.resultado_votacao else "",
"resultado": votacao.resultado if votacao and votacao.resultado else "",
"numero_votos": votacao.numero_votos if votacao and votacao.numero_votos else {},
"votos_parlamentares": votacao.votos_parlamentares if votacao and votacao.votos_parlamentares else [],
},
}
def get_dados_painel(sessao_plenaria_id: int) -> dict:
app_config = AppConfig.objects.first()
sessao = SessaoPlenaria.objects.get(id=sessao_plenaria_id)
casa = CasaLegislativa.objects.first()
brasao = casa.logotipo.url \
if app_config.mostrar_brasao_painel else None
# Painel
# TODO: recuperar outra matéria quando não existir nenhuma materia_votacao aberta!
materia_votacao = SessaoMateriasVotacoesView.objects. \
filter(sessao_plenaria_id=sessao_plenaria_id, votacao_aberta=True).first()
if not materia_votacao:
return {
"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,
"brasao": brasao,
"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,
},
"message": "Nenhuma matéria aberta para votação!",
}
presentes = SessaoPresencasView.objects.filter(sessao_plenaria_id=sessao_plenaria_id,
etapa_sessao=materia_votacao.etapa_sessao,
ativo=True).values_list(
'parlamentar_id',
'nome_parlamentar',
'filiacao', )
parlamentares = [dict(zip(['parlamentar_id', 'nome_parlamentar', 'filiacao'], p)) for p in presentes]
if materia_votacao and materia_votacao.numero_votos:
materia_votacao.numero_votos.update({"num_presentes": len(parlamentares)})
oradores = SessaoOradoresView.objects.filter(sessao_plenaria_id=sessao_plenaria_id,
etapa_sessao=materia_votacao.etapa_sessao).values_list(
'ordem_pronunciamento',
'nome_parlamentar',
)
oradores = [dict(zip(['ordem_pronunciamento', 'nome_parlamentar'], o)) for o in oradores]
# TODO: recover stopwatch state from DB/Cache
stopwatch = {
"type": "stopwatch.state",
"id": "sw:main",
"status": "running", # "running" | "paused" | "stopped"
"started_at_ms": 1699990000123, # epoch ms when (re)started
"elapsed_ms": 5320
}
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,
"brasao": brasao,
"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,
},
"parlamentares": parlamentares,
"oradores": oradores,
"materia": get_materia_votacao(materia_votacao),
"stopwatch": [stopwatch], # TODO: array of stopwatches
}
print(json.dumps(dados_sessao, indent=4))
return dados_sessao
class PainelConsumer(AsyncJsonWebsocketConsumer):
logger = logging.getLogger(__name__)
# def __init__(self):
# self.group = set()
# self.controller_id = None
async def connect(self):
# TODO: transformar prints em log messages
user = self.scope.get("user")
controller_id = self.scope["url_route"]["kwargs"]["controller_id"]
print(f"user: {user}, controller_id: {controller_id}")
if not (user and user.is_authenticated):
self.logger.info(f"{user} is not authenticated user!")
await self.close(code=4401) # explicit, graceful close
return
if not await self.user_can_view(user.id, controller_id):
await self.close(code=4403)
return
self.controller_id = controller_id
self.group = f"controller_{controller_id}"
print(self.group)
await self.channel_layer.group_add(self.group, self.channel_name)
await self.accept()
# await self.send_json({"type": "data",
# "text": "Connection established!"})
print("SENDING bootstrap DATA DO CONSUMER ")
print(get_dados_painel(controller_id))
await self.send_json(get_dados_painel(controller_id))
async def disconnect(self, code):
if hasattr(self, "group"):
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 receive(self, text_data=None, bytes_data=None):
data = json.loads(text_data or "{}")
print("Received from client:", data) # TODO: turn into log messages
msg_type = data.get("type")
if msg_type == "ping":
print("PING")
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": "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:
# Replace with your ACL check (ORM must be in a sync wrapper)
# return Controller.objects.filter(id=controller_id, owners__id=user_id).exists()
return True
class HealthConsumer(AsyncWebsocketConsumer):
"""
WebSockets consumer that doesn"t require authentication to
debug with wscat (wscat -c ws://127.0.0.1:8000/ws/painel/)
"""
logger = logging.getLogger(__name__)
async def connect(self):
try:
await self.accept()
await self.send(json.dumps({"ok": True}))
except Exception as e:
self.logger.exception("connect failed: %s", e)
# Let Channels close with 1011 if we got here
async def receive(self, text_data=None, bytes_data=None):
await self.send(text_data or "")

8
sapl/painel/urls.py

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

108
sapl/painel/views.py

@ -143,9 +143,9 @@ def sessao_votacao(context,context_vars):
presentes = []
ordem_dia = get_materia_aberta(pk)
expediente = get_materia_expediente_aberta(pk)
errors_msgs = {'materia':'Não há nenhuma matéria aberta.',
'registro':'A votação para esta matéria já encerrou.',
'tipo':'A matéria aberta não é do tipo votação nominal.'}
errors_msgs = {'materia': 'Não há nenhuma matéria aberta.',
'registro': 'A votação para esta matéria já encerrou.',
'tipo': 'A matéria aberta não é do tipo votação nominal.'}
materia_aberta = None
if ordem_dia:
@ -160,7 +160,7 @@ def sessao_votacao(context,context_vars):
'parlamentar_id', flat=True).distinct()
context_vars.update({'ordem_dia': ordem_dia,
'expediente':expediente,
'expediente': expediente,
'presentes': presentes})
# Verifica votação aberta
@ -188,7 +188,7 @@ def can_vote(context, context_vars, request):
# Pega sessão
sessao, msg = votacao_aberta(request)
context_vars.update({'sessao':sessao})
context_vars.update({'sessao': sessao})
if sessao and not msg:
context, context_vars = sessao_votacao(context, context_vars)
elif not sessao and msg:
@ -215,9 +215,11 @@ def votante_view(request):
else:
raise ObjectDoesNotExist
except ObjectDoesNotExist:
logger.error(f"user={username}. Usuário (user={request.user}) não cadastrado como votante na tela de parlamentares. "
logger.error(
f"user={username}. Usuário (user={request.user}) não cadastrado como votante na tela de parlamentares. "
"Contate a administração de sua Casa Legislativa!")
msg = _("Usuário não cadastrado como votante na tela de parlamentares. Contate a administração de sua Casa Legislativa!")
msg = _(
"Usuário não cadastrado como votante na tela de parlamentares. Contate a administração de sua Casa Legislativa!")
context.update({'error_message': msg})
return render(request, template_name, context)
@ -227,7 +229,8 @@ def votante_view(request):
# Verifica se usuário possui permissão para votar
if 'parlamentares.can_vote' in request.user.get_all_permissions():
context, context_vars = can_vote(context, context_vars, request)
logger.debug("user=" + username + ". Verificando se usuário {} possui permissão para votar.".format(request.user))
logger.debug(
"user=" + username + ". Verificando se usuário {} possui permissão para votar.".format(request.user))
else:
logger.error("user=" + username + ". Usuário {} sem permissão para votar.".format(request.user))
context.update({'permissao': False,
@ -237,15 +240,15 @@ def votante_view(request):
if request.method == 'POST':
if context_vars['ordem_dia']:
try:
logger.info("user=" + username + ". Tentando obter objeto VotoParlamentar para parlamentar={} e "
"ordem={}. "
logger.info(
"user=" + username + ". Tentando obter objeto VotoParlamentar para parlamentar={} e ordem={}."
.format(context_vars['parlamentar'], context_vars['ordem_dia']))
voto = VotoParlamentar.objects.get(
parlamentar=context_vars['parlamentar'],
ordem=context_vars['ordem_dia'])
except ObjectDoesNotExist:
logger.error("user=" + username + ". Erro ao obter VotoParlamentar para parlamentar={} e ordem={}. "
"Criando objeto. "
logger.error(
"user=" + username + ". Erro ao obter VotoParlamentar para parlamentar={} e ordem={}. Criando objeto."
.format(context_vars['parlamentar'], context_vars['ordem_dia']))
voto = VotoParlamentar.objects.create(
parlamentar=context_vars['parlamentar'],
@ -263,13 +266,15 @@ def votante_view(request):
elif context_vars['expediente']:
try:
logger.info("user=" + username + ". Tentando obter objeto VotoParlamentar para parlamentar={} e expediente={}."
logger.info(
"user=" + username + ". Tentando obter objeto VotoParlamentar para parlamentar={} e expediente={}."
.format(context_vars['parlamentar'], context_vars['expediente']))
voto = VotoParlamentar.objects.get(
parlamentar=context_vars['parlamentar'],
expediente=context_vars['expediente'])
except ObjectDoesNotExist:
logger.error("user=" + username + ". Erro ao obter VotoParlamentar para parlamentar={} e expediente={}. Criando objeto."
logger.error(
"user=" + username + ". Erro ao obter VotoParlamentar para parlamentar={} e expediente={}. Criando objeto."
.format(context_vars['parlamentar'], context_vars['expediente']))
voto = VotoParlamentar.objects.create(
parlamentar=context_vars['parlamentar'],
@ -278,7 +283,8 @@ def votante_view(request):
ip=get_client_ip(request),
expediente=context_vars['expediente'])
else:
logger.info("user=" + username + ". VotoParlamentar para parlamentar={} e expediente={} obtido com sucesso."
logger.info(
"user=" + username + ". VotoParlamentar para parlamentar={} e expediente={} obtido com sucesso."
.format(context_vars['parlamentar'], context_vars['expediente']))
voto.voto = request.POST['voto']
voto.ip = get_client_ip(request)
@ -296,7 +302,7 @@ def painel_view(request, pk):
now = timezone.localtime(timezone.now())
utc_offset = now.utcoffset().total_seconds() / 60
context = {'head_title': str(_('Painel Plenário')), 'sessao_id': pk, 'utc_offset': utc_offset }
context = {'head_title': str(_('Painel Plenário')), 'sessao_id': pk, 'utc_offset': utc_offset}
return render(request, 'painel/index.html', context)
@ -626,3 +632,73 @@ def get_dados_painel(request, pk):
# Retorna que não há nenhuma matéria já votada ou aberta
return response_nenhuma_materia(get_presentes(pk, response, None))
def websocket_view(request, controller_id):
now = timezone.localtime(timezone.now())
utc_offset = now.utcoffset().total_seconds() / 60
context = {'head_title': str(_('Painel Plenário')),
'utc_offset': utc_offset,
'enable_live_ws': True,
'controller_id': controller_id, # aka, sessao_plenaria_id
}
return render(request, "painel/painel_v2.html", context)
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
logger = logging.getLogger(__name__)
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}"})
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}"})
stopwatch_time = request.GET.get("time", 300)
# 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,
}
async_to_sync(layer.group_send)(group, notification)
return JsonResponse(notification)
# def push_to_me(request):
# from asgiref.sync import async_to_sync
# from channels.layers import get_channel_layer
#
# layer = get_channel_layer()
# group = f"user_{request.user.pk}"
# async_to_sync(layer.group_send)(
# group, {"type": "notify", "data": {"text": "server says hi!"}}
# )
# return JsonResponse({"ok": True})

265
sapl/sessao/migrations/0070_views_sessao_plenaria.py

@ -0,0 +1,265 @@
# Generated by Django 2.2.28 on 2025-11-17 17:55
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('sessao', '0069_auto_20220919_1705'),
]
operations = [
migrations.RunSQL(
"""
CREATE OR REPLACE VIEW sessao_presencas_view AS
--
-- PRESENCAS DE PARLAMENTARES COM FILIACAO (SE HOUVER)
-- MANDATO DEVE ESTAR ATIVO NA LEGISLATIURA
-- PARLAMENTAR DEVE ESTAR ATIVO
--
-- EXPEDIENTE
SELECT
p.id,
presenca.sessao_plenaria_id,
'expediente' AS etapa_sessao,
p.id as parlamentar_id,
p.nome_parlamentar as nome_parlamentar,
COALESCE(af.sigla, 'SEM PARTIDO') 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
AND m.data_inicio_mandato <= sp.data_inicio
AND (m.data_fim_mandato IS NULL OR m.data_fim_mandato >= sp.data_inicio)
-- recupera uma filiacao partidaria, se existir
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
p.id,
presenca.sessao_plenaria_id,
'ordemdia' AS etapa_sessao,
p.id as parlamentar_id,
p.nome_parlamentar as nome_parlamentar,
COALESCE(af.sigla, 'SEM PARTIDO') 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
AND m.data_inicio_mandato <= sp.data_inicio
AND (m.data_fim_mandato IS NULL OR m.data_fim_mandato >= sp.data_inicio)
-- recupera uma filiacao partidaria, se existir
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
"""
),
migrations.RunSQL(
"""
--
-- ORADORES DE ORDEMDIA/EXPEDIENTE
-- * PARLAMENTARES ATIVOS
--
CREATE OR REPLACE VIEW sessao_oradores_view AS
SELECT od.id,
sessao_plenaria_id,
'ordemdia' as etapa_sessao,
numero_ordem as ordem_pronunciamento,
parlamentar_id,
nome_parlamentar,
COALESCE(af.sigla, 'SEM PARTIDO') AS filiacao
FROM sessao_oradorordemdia od
JOIN sessao_sessaoplenaria sp ON (od.sessao_plenaria_id = sp.id)
JOIN parlamentares_parlamentar p ON (od.parlamentar_id = p.id)
-- recupera uma filiacao partidaria, se existir
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
AND p.ativo = TRUE
UNION ALL
SELECT ex.id,
sessao_plenaria_id,
'expediente' as etapa_sessao,
numero_ordem as ordem_pronunciamento,
parlamentar_id,
nome_parlamentar,
COALESCE(af.sigla, 'SEM PARTIDO') AS filiacao
FROM sessao_oradorexpediente ex
JOIN sessao_sessaoplenaria sp ON (ex.sessao_plenaria_id = sp.id)
JOIN parlamentares_parlamentar p on (ex.parlamentar_id = p.id)
-- recupera uma filiacao partidaria, se existir
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
AND p.ativo = TRUE
ORDER BY sessao_plenaria_id, etapa_sessao, ordem_pronunciamento
"""
),
migrations.RunSQL(
"""
CREATE OR REPLACE VIEW sessao_materias_votacoes_view AS
--
-- Votacao/Leitura de Materias
--
-- EXPEDIENTE
WITH votacao_materias AS (
SELECT em.sessao_plenaria_id,
em.id id,
'expediente' etapa_sessao,
em.numero_ordem,
em.materia_id,
tm.descricao||''||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 sessao_registroleitura rl on (rl.expediente_id = em.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_agg(jsonb_build_object(
vp.parlamentar_id,
jsonb_build_object(
'materia_id', em.materia_id,
'parlamentar_id', vp.parlamentar_id,
'parlamentar_nome', p.nome_parlamentar,
'voto', vp.voto
)) ORDER BY p.nome_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
ORDER 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||''||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 sessao_registroleitura rl on (od.id = rl.expediente_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 = od.id AND tipo_votacao != 4) rv ON TRUE
LEFT JOIN LATERAL (
SELECT od.sessao_plenaria_id,
od.numero_ordem,
jsonb_agg(jsonb_build_object(
vp.parlamentar_id,
jsonb_build_object(
'materia_id', od.materia_id,
'parlamentar_id', vp.parlamentar_id,
'parlamentar_nome', p.nome_parlamentar,
'voto', vp.voto
)) ORDER BY p.nome_parlamentar) as votos_parlamentares
FROM sessao_votoparlamentar vp
JOIN parlamentares_parlamentar p ON (vp.parlamentar_id = p.id)
WHERE vp.expediente_id = od.id AND od.tipo_votacao != 4
GROUP BY od.sessao_plenaria_id, od.numero_ordem
ORDER BY od.sessao_plenaria_id, od.numero_ordem
) vp ON TRUE
)
SELECT *
FROM votacao_materias
ORDER BY sessao_plenaria_id, etapa_sessao, numero_ordem
"""
)
]

125
sapl/sessao/models.py

@ -1,6 +1,9 @@
import datetime
from enum import Enum
from operator import xor
from django.core.exceptions import ValidationError
from django.contrib.postgres.fields.jsonb import JSONField
from django.db import models
from django.db.models import Q, F
from django.utils import timezone, formats
@ -1050,3 +1053,125 @@ class Correspondencia(models.Model):
def __str__(self):
return _('Correspondência: {}').format(self.documento)
##
## Painel 2.0
##
class SessaoPresencasView(models.Model):
sessao_plenaria = models.ForeignKey(SessaoPlenaria,
on_delete=models.DO_NOTHING,
verbose_name=_('Sessão Plenária'))
parlamentar = models.ForeignKey(Parlamentar,
on_delete=models.DO_NOTHING,
verbose_name=_('Parlamentar'))
etapa_sessao = models.CharField(max_length=15)
nome_parlamentar = models.CharField(max_length=50,
verbose_name=_('Nome Parlamentar'))
filiacao = models.CharField(max_length=50,
verbose_name=_('Filiacao'))
ativo = models.BooleanField(verbose_name=_('Ativo na Casa?'))
class Meta:
managed = False
db_table = "sessao_presencas_view"
ordering = ('sessao_plenaria_id', 'etapa_sessao', 'nome_parlamentar')
def __str__(self):
return f"{self.sessao_plenaria} - {self.etapa_sessao} - {self.parlamentar}"
class SessaoOradoresView(models.Model):
sessao_plenaria = models.ForeignKey(SessaoPlenaria,
on_delete=models.DO_NOTHING,
verbose_name=_('Sessão Plenária'))
parlamentar = models.ForeignKey(Parlamentar,
on_delete=models.DO_NOTHING,
verbose_name=_('Parlamentar'))
etapa_sessao = models.CharField(max_length=15)
nome_parlamentar = models.CharField(max_length=50,
verbose_name=_('Nome Parlamentar'))
ordem_pronunciamento = models.PositiveIntegerField(verbose_name=_('Ordem Pronunciamento'))
filiacao = models.CharField(max_length=20, verbose_name=_('Sigla'))
class Meta:
managed = False
db_table = "sessao_oradores_view"
ordering = ('sessao_plenaria_id', 'etapa_sessao', 'ordem_pronunciamento', 'nome_parlamentar')
def __str__(self):
return f"{self.sessao_plenaria} - {self.etapa_sessao} - {self.ordem_pronunciamento} - {self.parlamentar}"
class SessaoMateriasVotacoesView(models.Model):
sessao_plenaria = models.ForeignKey(SessaoPlenaria,
on_delete=models.DO_NOTHING,
verbose_name=_('Sessão Plenária'))
materia = models.ForeignKey(MateriaLegislativa,
on_delete=models.DO_NOTHING,
verbose_name=_('Matéria Legislativa'))
etapa_sessao = models.CharField(max_length=15)
numero_ordem = models.PositiveIntegerField(verbose_name=_('Número de Ordem'))
numero_votos = JSONField(null=True, verbose_name=_('Total Votos'))
votos_parlamentares = JSONField(null=True, verbose_name=_('Votos Parlamentares'))
votacao_aberta = models.BooleanField(default=False)
tipo_votacao = models.PositiveIntegerField(verbose_name=_('Tipo Votação'))
tipo_votacao_descricao = models.CharField(max_length=15)
resultado = models.CharField(
max_length=256,
blank=True,
verbose_name=_('Resultado'))
resultado_votacao = models.CharField(
max_length=256,
blank=True,
verbose_name=_('Resultado Votação'))
class Meta:
managed = False
db_table = "sessao_materias_votacoes_view"
ordering = ('sessao_plenaria_id', 'etapa_sessao', 'numero_ordem')
def __str__(self):
return f"{self.sessao_plenaria} - {self.etapa_sessao} - " \
f"{self.tipo_votacao_descricao} - {self.votos_parlamentares}"
#
# class StatusCronometro( models.TextChoices):
# RUNNING = 'running', 'Running'
# STOPPED = 'stopped', 'Stopped'
# PAUSED = 'paused', 'Paused'
#
#
# class Cronometro(models.Model):
# titulo = models.CharField(max_length=50, verbose_name=_('Título do cronômetro'))
# status = models.CharField(max_length=10,
# choices=StatusCronometro.choices,
# default=StatusCronometro.STOPPED,
# verbose_name=_('Status do Cronômetro')) # stopped, running, paused, reset(?), resume(?)
#
# started_at_ms = models.DateTimeField(verbose_name=_('Data e Hora da Inicialização'), blank=True, null=True)
#
# class Meta:
# verbose_name = _('Cronômetro de Votação')
# verbose_name_plural = _('Cronômetros de Votação')
# ordering = ('id',)
#
# @property
# def time_elapsed_ms(self):
# return datetime.datetime.now() - started_at_ms
#
# def __str__(self):
# return self.titulo
#

14
sapl/sessao/urls.py

@ -42,6 +42,8 @@ from sapl.sessao.views import (AdicionarVariasMateriasExpediente,
CorrespondenciaCrud, recuperar_documento)
from django.views.generic import TemplateView
from .apps import AppConfig
app_name = AppConfig.name
@ -149,6 +151,7 @@ urlpatterns = [
url(r'^sessao/(?P<pk>\d+)/presencaordemdia$',
PresencaOrdemDiaView.as_view(),
name='presencaordemdia'),
# VOTACAO - LEITURA
url(r'^sessao/(?P<pk>\d+)/votacao_bloco_ordemdia$',
VotacaoEmBlocoOrdemDia.as_view(),
name='votacao_bloco_ordemdia'),
@ -169,8 +172,19 @@ urlpatterns = [
ResumoView.as_view(), name='resumo'),
url(r'^sessao/(?P<pk>\d+)/resumo_ata$',
ResumoAtaView.as_view(), name='resumo_ata'),
##
url(r'^sessao/pesquisar-sessao$',
PesquisarSessaoPlenariaView.as_view(), name='pesquisar_sessao'),
# VOTACAO
# TODO: create proper view
url(r'^sessao/(?P<pk>\d+)/v2/votacao$',
TemplateView.as_view(template_name='sessao/votacao/votacao_v2.html'),
name='votacaonominal'),
url(r'^sessao/(?P<pk>\d+)/v2/painel$',
TemplateView.as_view(template_name='sessao/painel_v2.html')),
url(r'^sessao/(?P<pk>\d+)/matordemdia/votnom/(?P<oid>\d+)/(?P<mid>\d+)$',
VotacaoNominalView.as_view(), name='votacaonominal'),
url(r'^sessao/(?P<pk>\d+)/matordemdia/votnom/edit/(?P<oid>\d+)/(?P<mid>\d+)$',

20
sapl/settings.py

@ -14,6 +14,7 @@ See https://docs.djangoproject.com/en/1.8/howto/deployment/checklist/
"""
import logging
import os
import socket
import sys
@ -83,6 +84,8 @@ INSTALLED_APPS = (
'crispy_forms',
'channels',
'waffle',
'drf_spectacular',
@ -102,6 +105,23 @@ INSTALLED_APPS = (
) + SAPL_APPS
# Web-sockets
ASGI_APPLICATION = 'sapl.asgi.application'
CHANNEL_LAYERS = {
"default": {
"BACKEND": "channels_redis.core.RedisChannelLayer",
"CONFIG": {
"hosts": [config("REDIS_URL", cast=str, default="redis://127.0.0.1:6379/0")],
"prefix": f"{host}",
"capacity": 1000,
"expiry": 60,
},
},
}
# FTS = Full Text Search
# Desabilita a indexação textual até encontramos uma solução para a issue
# https://github.com/interlegis/sapl/issues/2055

98
sapl/templates/painel/painel_v2.html

@ -0,0 +1,98 @@
{% load i18n %}
{% load common_tags %}
{% load render_bundle from webpack_loader %}
{% load webpack_static from webpack_loader %}
<!DOCTYPE HTML>
<!--[if IE 8]>
<html class="no-js lt-ie9" lang="pt-br"> <![endif]-->
<!--[if gt IE 8]><!-->
<html lang="pt-br">
<!--<![endif]-->
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- TODO: does it need this head_title here? -->
<title>{% block head_title %}{% trans 'SAPL - Sistema de Apoio ao Processo Legislativo' %}{% endblock %}</title>
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
{% block webpack_loader_css %}
{% render_chunk_vendors 'css' %}
{% render_bundle 'global' 'css' %}
{% render_bundle 'painel' 'css' %}
{% endblock webpack_loader_css %}
<style type="text/css">
html, body {
max-width: 100%;
overflow-x: hidden;
}
@media screen {
ul, li {
list-style-type: none;
}
}
</style>
</head>
<body class="painel-principal">
{% block vue_content %}
<style>[v-cloak]{display:none}</style>
<div id="painel" v-cloak class="col text-center" data-controller-id="{{ controller_id }}">
<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" 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">
<button class="btn btn-sm btn-secondary ms-2" v-on:click="changeFontSize(-1)">
A-
</button>
<button class="btn btn-sm btn-secondary ms-2" v-on:click="changeFontSize(1)">
A+
</button>
<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>
</div>
</div>
<!-- <painel-cronometro-list ref="cronometro_list"></painel-cronometro-list>-->
<painel-resultado ref="resultado"></painel-resultado>
<painel-materia ref="materia"></painel-materia>
<div class="col-md-6 text-center painel" id="tema_solene_div" style="display: none">
<h2 class="text-subtitle">Tema da Sessão Solene</h2>
<span id="sessao_solene_tema" class="text-value"></span>
</div>
</div>
</div>
</div>
{% endblock %}
</body>
{% block webpack_loader_js %}
{% render_chunk_vendors 'js' %}
{% render_bundle 'global' 'js' %}
{% render_bundle 'painel' 'js' %}
{% endblock webpack_loader_js %}
{% block webpack_loader_chunks_js %}
{% endblock webpack_loader_chunks_js %}
</html>

106
sapl/templates/sessao/painel_v2.html

@ -0,0 +1,106 @@
{% extends "crud/detail.html" %}
{% load i18n %}
{% load staticfiles %}
{% load common_tags %}
{% load render_bundle from webpack_loader %}
{% load webpack_static from webpack_loader %}
{% block actions %} {% endblock %}
{% block title %}
{% endblock %}
{% block vue_content %}
<div id="painel-controle">
<h1 class="page-header">
Painel Eletrônico <small>([[ sessao_plenaria ]])</small>
</h1>
[[ message ]]
<div class="row">
<div class="col-md-6"><a href="" onclick="window.open('{% url 'sapl.painel:painel_principal' pk %}','Comprovante','width=800, height=800, scrollbars=yes'); return false;" class="btn btn-primary btn-sm active">Iniciar painel completo</a></div>
<div class="col-md-3"><button onclick="switch_painel(true)" id="id_abrir_painel" class="btn btn-primary btn-sm active" style="display: none">Abrir Painel</button></div>
<div class="col-md-3"><button onclick="switch_painel(false)" id="id_fechar_painel" class="btn btn-danger btn-sm active" style="display: none;">Fechar Painel</button></div>
</div>
<br />
<h1>Operação do Painel Eletrônico</h1>
<h2><span id="relogio"></span></h2>
<br />
<div class="row">
<div class="col-md-12 mb-2"><h3>Cronômetro do Discurso</h3></div>
</div>
<div class="row">
<div class="col-md-2"><input size="2" id="discurso" name="discurso" value="" readyonly="true" class="form-control"></div>
</div>
<br />
<div class="row">
<div class="col-md-6"><button type="button" id="discursoStart" class="btn btn-success">Iniciar</button></div>
<div class="col-md-6"><button type="button" id="discursoReset" class="btn btn-success">Reiniciar</button></div>
</div>
<br /><br >
<div class="row">
<div class="col-md-12 mb-2"><h3>Cronômetro do Aparte</h3></div>
</div>
<div class="row">
<div class="col-md-2"><input size="2" id="aparte" name="aparte" value="" readyonly="true" class="form-control"></div>
</div>
<br />
<div class="row">
<div class="col-md-6"><button type="button" id="aparteStart" class="btn btn-success">Iniciar</button></div>
<div class="col-md-6"><button type="button" id="aparteReset" class="btn btn-success" class="btn btn-success">Reiniciar</button></div>
</div>
<br /><br >
<div class="row">
<div class="col-md-12 mb-2"><h3>Cronômetro da Questão de Ordem </h3></div>
</div>
<div class="row">
<div class="col-md-2"><input size="2" id="ordem" name="ordem" value="" readyonly="true" class="form-control"></div>
</div>
<br />
<div class="row">
<div class="col-md-6"><button type="button" id="ordemStart" class="btn btn-success">Iniciar</button></div>
<div class="col-md-6"><button type="button" id="ordemReset" class="btn btn-success">Reiniciar</button></div>
</div>
<br/>
<br/>
<div class="row">
<div class="col-md-12 mb-2"><h3>Cronômetro de Considerações Finais</h3></div>
</div>
<div class="row">
<div class="col-md-4"><input size="2" id="consideracoes" name="consideracoes" value="" readyonly="true" class="form-control"></div>
</div>
<br />
<div class="row">
<div class="col-md-6"><button type="button" id="consideracoesStart" class="btn btn-success">Iniciar</button></div>
<div class="col-md-6"><button type="button" id="consideracoesReset" class="btn btn-success">Reiniciar</button></div>
</div>
<br /><br >
<div class="row">
<div class="col-md-6"><button type="button" id="sinalSonoro" class="btn btn-success" onclick="document.getElementById('audio').play();">Sinal Sonoro</button></div>
</div>
</div>
{% endblock vue_content %}
{% block webpack_loader_css %}
{{ block.super }}
{% render_bundle 'votacao' 'css' %}
{% endblock %}
{% block webpack_loader_js %}
{% render_chunk_vendors 'js' %}
{% render_bundle 'global' 'js' %}
{% render_bundle 'painel-controle' 'js' %}
{% endblock %}

78
sapl/templates/sessao/votacao/votacao_v2.html

@ -0,0 +1,78 @@
{% extends "crud/detail.html" %}
{% load i18n %}
{% load crispy_forms_tags cropping%}
{% load common_tags %}
{% load render_bundle from webpack_loader %}
{% load webpack_static from webpack_loader %}
{% block vue_content %}
<style>[v-cloak]{display:none}</style>
<div id="votacao">
<fieldset class="form-group">
<legend>[[ tipo_votacao_descricao ]]</legend>
[[ error_message ]]
<!-- Component 1 -->
<div>
<b>Matéria:</b> [[ materia ]]
<br/>
<b>Ementa:</b> [[ ementa ]]
<br/>
</div>
<br/>
<!-- Votacao Nominal -->
<fieldset class="form-group" v-if="tipo_votacao == 1">
<legend><strong>Votos</strong></legend>
<div class="row" v-for="p in parlamentares">
<div class="col-md-4" id="nome_parlamentar">[[ p.nome_parlamentar ]]</div>
<div class="col-md-2" id="filiacao">[[ p.filiacao ]]</div>
<div class="col-md-4" v-if="edit_votes">
<span v-for="o in options" :key="o.value">
<input type="radio" :id="o.value" :value="o.text" :name="p.parlamentar_id" v-model="votos_parlamentares[p.parlamentar_id].voto"/>
<label :for="o.value"> [[ o.text ]]</label>
&nbsp;
</span>
</div>
<div v-else>
[[ votos_parlamentares[p.parlamentar_id].voto ]]
</div>
</div>
<br/>
<br/>
</fieldset>
<legend><strong>Total de votos:</strong></legend>
<div id="total_votos" v-for="voto in total_votos">
<div class='row'><div class='col-md-12'>[[ voto.tipo ]]: [[ voto.total ]]</div></div>
</div>
<br/>
<!-- component -->
<div class="col-md-6">
<b>Resultado da Votação: </b>
<select v-model="resultado_selected" class="select form-control" :disabled="disable_resultado" >
<option :key="t.id" :value="t.id" v-for="t in tipos_resultados">[[ t.nome ]]</option>
</select>
</div>
<!-- component -->
<div class="row">
<div class="col-md-12">
Observações:
<textarea v-model="observacoes" cols="10" rows="10" class="form-control"></textarea>
</div>
</div>
<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>
</fieldset>
</div>
{% endblock vue_content %}
{% block webpack_loader_css %}
{{ block.super }}
{% render_bundle 'votacao' 'css' %}
{% endblock %}
{% block webpack_loader_js %}
{% render_chunk_vendors 'js' %}
{% render_bundle 'global' 'js' %}
{% render_bundle 'votacao' 'js' %}
{% endblock %}

6
scripts/websockets.sh

@ -0,0 +1,6 @@
# DEBUG
daphne -b 127.0.0.1 -p 8000 sapl.asgi:application
# Redis-CLI
redis-cli -h localhost -p 6379

22
vue.config.js

@ -16,6 +16,18 @@ dotenv.config({
module.exports = {
runtimeCompiler: true,
configureWebpack: {
resolve: {
alias: {
// This ensures that imports of 'vue' use the build with the template compiler
'vue$': 'vue/dist/vue.esm.js'
}
}
},
// Optional but recommended if Django serves the final HTML:
// disable source maps in production builds to avoid exposing source
productionSourceMap: false,
publicPath:
process.env.NODE_ENV === 'production'
? '/static/sapl/frontend'
@ -125,11 +137,21 @@ module.exports = {
.add('./frontend/src/__apps/parlamentar/main.js')
.end()
config
.entry('votacao')
.add('./frontend/src/__apps/votacao/main.js')
.end()
config
.entry('painel')
.add('./frontend/src/__apps/painel/main.js')
.end()
config
.entry('painel-controle')
.add('./frontend/src/__apps/painel-controle/main.js')
.end()
config
.entry('compilacao')
.add('./frontend/src/__apps/compilacao/main.js')

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