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. 16
      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. 148
      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

16
docker/config/nginx/sapl.conf

@ -1,6 +1,6 @@
upstream sapl_server { upstream sapl_server {
server unix:/var/interlegis/sapl/run/gunicorn.sock fail_timeout=0; server unix:/var/interlegis/sapl/run/gunicorn.sock fail_timeout=0;
} }
@ -10,6 +10,9 @@ map $http_x_request_id $req_id {
"" $request_id; "" $request_id;
} }
upstream sapl_ws {
server unix:/var/interlegis/sapl/run/daphne.sock fail_timeout=0;
}
server { server {
@ -62,6 +65,17 @@ server {
proxy_pass http://sapl_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; error_page 500 502 503 504 /500.html;
location = /500.html { location = /500.html {
root /var/interlegis/sapl/sapl/static/; root /var/interlegis/sapl/sapl/static/;

16
docker/docker-compose.yaml

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

2
docker/startup_scripts/start.sh

@ -259,6 +259,8 @@ setup_cache_dir() {
start_services() { start_services() {
log "Starting gunicorn..." log "Starting gunicorn..."
gunicorn -c gunicorn.conf.py & gunicorn -c gunicorn.conf.py &
log "Starting websockets..."
daphne --unix-socket "$RUN_DIR/daphne.sock" sapl.asgi:application &
log "Starting nginx..." log "Starting nginx..."
exec /usr/sbin/nginx -g "daemon off;" 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' 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", "moment-locales-webpack-plugin": "^1.2.0",
"popper.js": "^1.16.1", "popper.js": "^1.16.1",
"tinymce": "^7.2.0", "tinymce": "^7.2.0",
"vue": "^2.7.9" "vue": "^2.7.16",
"vuex": "3"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.18.13", "@babel/core": "^7.18.13",

4
requirements/requirements.txt

@ -26,6 +26,10 @@ reportlab==3.6.13
WeasyPrint==66 WeasyPrint==66
trml2pdf==0.6 trml2pdf==0.6
gunicorn==23.0.0 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 more-itertools==8.2.0
pysolr==3.6.0 pysolr==3.6.0
PyPDF4==1.27.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 .apps import AppConfig
from .views import (cronometro_painel, get_dados_painel, painel_mensagem_view, from .views import (cronometro_painel, get_dados_painel, painel_mensagem_view,
painel_parlamentar_view, painel_view, painel_votacao_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 app_name = AppConfig.name
@ -24,4 +26,8 @@ urlpatterns = [
url(r'^voto-individual/$', votante_view, url(r'^voto-individual/$', votante_view,
name='voto_individual'), 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'),
] ]

148
sapl/painel/views.py

@ -58,11 +58,11 @@ def votacao_aberta(request):
kwargs={'pk': v.id}), kwargs={'pk': v.id}),
v.__str__())) v.__str__()))
logger.info('user=' + username + '. Existe mais de uma votações aberta. Elas se encontram ' logger.info('user=' + username + '. Existe mais de uma votações aberta. Elas se encontram '
'nas seguintes Sessões: ' + ', '.join(msg_abertas) + '. ' 'nas seguintes Sessões: ' + ', '.join(msg_abertas) + '. '
'Para votar, peça para que o Operador feche-as.') 'Para votar, peça para que o Operador feche-as.')
msg = _('Existe mais de uma votações aberta. Elas se encontram ' msg = _('Existe mais de uma votações aberta. Elas se encontram '
'nas seguintes Sessões: ' + ', '.join(msg_abertas) + '. ' 'nas seguintes Sessões: ' + ', '.join(msg_abertas) + '. '
'Para votar, peça para que o Operador feche-as.') 'Para votar, peça para que o Operador feche-as.')
messages.add_message(request, messages.INFO, msg) messages.add_message(request, messages.INFO, msg)
return None, msg return None, msg
@ -78,9 +78,9 @@ def votacao_aberta(request):
if numero_materias_abertas > 1: if numero_materias_abertas > 1:
logger.info('user=' + username + '. Existe mais de uma votação aberta na Sessão: ' + logger.info('user=' + username + '. Existe mais de uma votação aberta na Sessão: ' +
('''<li><a href="%s">%s</a></li>''' % ( ('''<li><a href="%s">%s</a></li>''' % (
reverse('sapl.sessao:sessaoplenaria_detail', reverse('sapl.sessao:sessaoplenaria_detail',
kwargs={'pk': votacoes_abertas.first().id}), kwargs={'pk': votacoes_abertas.first().id}),
votacoes_abertas.first().__str__()))) votacoes_abertas.first().__str__())))
msg = _('Existe mais de uma votação aberta na Sessão: ' + msg = _('Existe mais de uma votação aberta na Sessão: ' +
('''<li><a href="%s">%s</a></li>''' % ( ('''<li><a href="%s">%s</a></li>''' % (
reverse('sapl.sessao:sessaoplenaria_detail', reverse('sapl.sessao:sessaoplenaria_detail',
@ -102,8 +102,8 @@ def votacao(context, context_vars):
context_vars.update({'parlamentar': parlamentar}) context_vars.update({'parlamentar': parlamentar})
else: else:
context.update({'error_message': context.update({'error_message':
'Não há presentes na Sessão com a ' 'Não há presentes na Sessão com a '
'matéria em votação.'}) 'matéria em votação.'})
if parlamentar_presente: if parlamentar_presente:
voto = [] voto = []
@ -123,13 +123,13 @@ def votacao(context, context_vars):
logger.error("Voto do parlamentar {} não computado.".format(context_vars['parlamentar'])) logger.error("Voto do parlamentar {} não computado.".format(context_vars['parlamentar']))
context.update( context.update(
{'voto_parlamentar': 'Voto não ' {'voto_parlamentar': 'Voto não '
'computado.'}) 'computado.'})
else: else:
logger.error("Parlamentar com id={} não está presente na " logger.error("Parlamentar com id={} não está presente na "
"Ordem do Dia/Expediente em votação.".format(parlamentar.id)) "Ordem do Dia/Expediente em votação.".format(parlamentar.id))
context.update({'error_message': context.update({'error_message':
'Você não está presente na ' 'Você não está presente na '
'Ordem do Dia/Expediente em votação.'}) 'Ordem do Dia/Expediente em votação.'})
return context, context_vars return context, context_vars
def sessao_votacao(context,context_vars): def sessao_votacao(context,context_vars):
@ -143,9 +143,9 @@ def sessao_votacao(context,context_vars):
presentes = [] presentes = []
ordem_dia = get_materia_aberta(pk) ordem_dia = get_materia_aberta(pk)
expediente = get_materia_expediente_aberta(pk) expediente = get_materia_expediente_aberta(pk)
errors_msgs = {'materia':'Não há nenhuma matéria aberta.', errors_msgs = {'materia': 'Não há nenhuma matéria aberta.',
'registro':'A votação para esta matéria já encerrou.', 'registro': 'A votação para esta matéria já encerrou.',
'tipo':'A matéria aberta não é do tipo votação nominal.'} 'tipo': 'A matéria aberta não é do tipo votação nominal.'}
materia_aberta = None materia_aberta = None
if ordem_dia: if ordem_dia:
@ -160,8 +160,8 @@ def sessao_votacao(context,context_vars):
'parlamentar_id', flat=True).distinct() 'parlamentar_id', flat=True).distinct()
context_vars.update({'ordem_dia': ordem_dia, context_vars.update({'ordem_dia': ordem_dia,
'expediente':expediente, 'expediente': expediente,
'presentes': presentes}) 'presentes': presentes})
# Verifica votação aberta # Verifica votação aberta
# Se aberta, verifica se é nominal. ID nominal == 2 # Se aberta, verifica se é nominal. ID nominal == 2
@ -188,7 +188,7 @@ def can_vote(context, context_vars, request):
# Pega sessão # Pega sessão
sessao, msg = votacao_aberta(request) sessao, msg = votacao_aberta(request)
context_vars.update({'sessao':sessao}) context_vars.update({'sessao': sessao})
if sessao and not msg: if sessao and not msg:
context, context_vars = sessao_votacao(context, context_vars) context, context_vars = sessao_votacao(context, context_vars)
elif not sessao and msg: elif not sessao and msg:
@ -202,7 +202,7 @@ def can_vote(context, context_vars, request):
def votante_view(request): def votante_view(request):
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
username = request.user.username if request.user.is_authenticated else 'AnonymousUser' username = request.user.username if request.user.is_authenticated else 'AnonymousUser'
# Pega o votante relacionado ao usuário # Pega o votante relacionado ao usuário
template_name = 'painel/voto_individual.html' template_name = 'painel/voto_individual.html'
context = {} context = {}
@ -215,9 +215,11 @@ def votante_view(request):
else: else:
raise ObjectDoesNotExist raise ObjectDoesNotExist
except ObjectDoesNotExist: except ObjectDoesNotExist:
logger.error(f"user={username}. Usuário (user={request.user}) não cadastrado como votante na tela de parlamentares. " logger.error(
"Contate a administração de sua Casa Legislativa!") f"user={username}. Usuário (user={request.user}) não cadastrado como votante na tela de parlamentares. "
msg = _("Usuário não cadastrado como votante na tela de parlamentares. Contate a administração de sua Casa Legislativa!") "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}) context.update({'error_message': msg})
return render(request, template_name, context) return render(request, template_name, context)
@ -227,7 +229,8 @@ def votante_view(request):
# Verifica se usuário possui permissão para votar # Verifica se usuário possui permissão para votar
if 'parlamentares.can_vote' in request.user.get_all_permissions(): if 'parlamentares.can_vote' in request.user.get_all_permissions():
context, context_vars = can_vote(context, context_vars, request) 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: else:
logger.error("user=" + username + ". Usuário {} sem permissão para votar.".format(request.user)) logger.error("user=" + username + ". Usuário {} sem permissão para votar.".format(request.user))
context.update({'permissao': False, context.update({'permissao': False,
@ -237,16 +240,16 @@ def votante_view(request):
if request.method == 'POST': if request.method == 'POST':
if context_vars['ordem_dia']: if context_vars['ordem_dia']:
try: try:
logger.info("user=" + username + ". Tentando obter objeto VotoParlamentar para parlamentar={} e " logger.info(
"ordem={}. " "user=" + username + ". Tentando obter objeto VotoParlamentar para parlamentar={} e ordem={}."
.format(context_vars['parlamentar'], context_vars['ordem_dia'])) .format(context_vars['parlamentar'], context_vars['ordem_dia']))
voto = VotoParlamentar.objects.get( voto = VotoParlamentar.objects.get(
parlamentar=context_vars['parlamentar'], parlamentar=context_vars['parlamentar'],
ordem=context_vars['ordem_dia']) ordem=context_vars['ordem_dia'])
except ObjectDoesNotExist: except ObjectDoesNotExist:
logger.error("user=" + username + ". Erro ao obter VotoParlamentar para parlamentar={} e ordem={}. " logger.error(
"Criando objeto. " "user=" + username + ". Erro ao obter VotoParlamentar para parlamentar={} e ordem={}. Criando objeto."
.format(context_vars['parlamentar'], context_vars['ordem_dia'])) .format(context_vars['parlamentar'], context_vars['ordem_dia']))
voto = VotoParlamentar.objects.create( voto = VotoParlamentar.objects.create(
parlamentar=context_vars['parlamentar'], parlamentar=context_vars['parlamentar'],
voto=request.POST['voto'], voto=request.POST['voto'],
@ -263,14 +266,16 @@ def votante_view(request):
elif context_vars['expediente']: elif context_vars['expediente']:
try: try:
logger.info("user=" + username + ". Tentando obter objeto VotoParlamentar para parlamentar={} e expediente={}." logger.info(
.format(context_vars['parlamentar'], context_vars['expediente'])) "user=" + username + ". Tentando obter objeto VotoParlamentar para parlamentar={} e expediente={}."
.format(context_vars['parlamentar'], context_vars['expediente']))
voto = VotoParlamentar.objects.get( voto = VotoParlamentar.objects.get(
parlamentar=context_vars['parlamentar'], parlamentar=context_vars['parlamentar'],
expediente=context_vars['expediente']) expediente=context_vars['expediente'])
except ObjectDoesNotExist: except ObjectDoesNotExist:
logger.error("user=" + username + ". Erro ao obter VotoParlamentar para parlamentar={} e expediente={}. Criando objeto." logger.error(
.format(context_vars['parlamentar'], context_vars['expediente'])) "user=" + username + ". Erro ao obter VotoParlamentar para parlamentar={} e expediente={}. Criando objeto."
.format(context_vars['parlamentar'], context_vars['expediente']))
voto = VotoParlamentar.objects.create( voto = VotoParlamentar.objects.create(
parlamentar=context_vars['parlamentar'], parlamentar=context_vars['parlamentar'],
voto=request.POST['voto'], voto=request.POST['voto'],
@ -278,8 +283,9 @@ def votante_view(request):
ip=get_client_ip(request), ip=get_client_ip(request),
expediente=context_vars['expediente']) expediente=context_vars['expediente'])
else: else:
logger.info("user=" + username + ". VotoParlamentar para parlamentar={} e expediente={} obtido com sucesso." logger.info(
.format(context_vars['parlamentar'], context_vars['expediente'])) "user=" + username + ". VotoParlamentar para parlamentar={} e expediente={} obtido com sucesso."
.format(context_vars['parlamentar'], context_vars['expediente']))
voto.voto = request.POST['voto'] voto.voto = request.POST['voto']
voto.ip = get_client_ip(request) voto.ip = get_client_ip(request)
voto.user = request.user voto.user = request.user
@ -296,7 +302,7 @@ def painel_view(request, pk):
now = timezone.localtime(timezone.now()) now = timezone.localtime(timezone.now())
utc_offset = now.utcoffset().total_seconds() / 60 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) 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 # Retorna que não há nenhuma matéria já votada ou aberta
return response_nenhuma_materia(get_presentes(pk, response, None)) 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 operator import xor
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.contrib.postgres.fields.jsonb import JSONField
from django.db import models from django.db import models
from django.db.models import Q, F from django.db.models import Q, F
from django.utils import timezone, formats from django.utils import timezone, formats
@ -1050,3 +1053,125 @@ class Correspondencia(models.Model):
def __str__(self): def __str__(self):
return _('Correspondência: {}').format(self.documento) 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) CorrespondenciaCrud, recuperar_documento)
from django.views.generic import TemplateView
from .apps import AppConfig from .apps import AppConfig
app_name = AppConfig.name app_name = AppConfig.name
@ -149,6 +151,7 @@ urlpatterns = [
url(r'^sessao/(?P<pk>\d+)/presencaordemdia$', url(r'^sessao/(?P<pk>\d+)/presencaordemdia$',
PresencaOrdemDiaView.as_view(), PresencaOrdemDiaView.as_view(),
name='presencaordemdia'), name='presencaordemdia'),
# VOTACAO - LEITURA
url(r'^sessao/(?P<pk>\d+)/votacao_bloco_ordemdia$', url(r'^sessao/(?P<pk>\d+)/votacao_bloco_ordemdia$',
VotacaoEmBlocoOrdemDia.as_view(), VotacaoEmBlocoOrdemDia.as_view(),
name='votacao_bloco_ordemdia'), name='votacao_bloco_ordemdia'),
@ -169,8 +172,19 @@ urlpatterns = [
ResumoView.as_view(), name='resumo'), ResumoView.as_view(), name='resumo'),
url(r'^sessao/(?P<pk>\d+)/resumo_ata$', url(r'^sessao/(?P<pk>\d+)/resumo_ata$',
ResumoAtaView.as_view(), name='resumo_ata'), ResumoAtaView.as_view(), name='resumo_ata'),
##
url(r'^sessao/pesquisar-sessao$', url(r'^sessao/pesquisar-sessao$',
PesquisarSessaoPlenariaView.as_view(), name='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+)$', url(r'^sessao/(?P<pk>\d+)/matordemdia/votnom/(?P<oid>\d+)/(?P<mid>\d+)$',
VotacaoNominalView.as_view(), name='votacaonominal'), VotacaoNominalView.as_view(), name='votacaonominal'),
url(r'^sessao/(?P<pk>\d+)/matordemdia/votnom/edit/(?P<oid>\d+)/(?P<mid>\d+)$', 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 logging
import os
import socket import socket
import sys import sys
@ -83,6 +84,8 @@ INSTALLED_APPS = (
'crispy_forms', 'crispy_forms',
'channels',
'waffle', 'waffle',
'drf_spectacular', 'drf_spectacular',
@ -102,6 +105,23 @@ INSTALLED_APPS = (
) + SAPL_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 # FTS = Full Text Search
# Desabilita a indexação textual até encontramos uma solução para a issue # Desabilita a indexação textual até encontramos uma solução para a issue
# https://github.com/interlegis/sapl/issues/2055 # 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 = { module.exports = {
runtimeCompiler: true, 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: publicPath:
process.env.NODE_ENV === 'production' process.env.NODE_ENV === 'production'
? '/static/sapl/frontend' ? '/static/sapl/frontend'
@ -125,11 +137,21 @@ module.exports = {
.add('./frontend/src/__apps/parlamentar/main.js') .add('./frontend/src/__apps/parlamentar/main.js')
.end() .end()
config
.entry('votacao')
.add('./frontend/src/__apps/votacao/main.js')
.end()
config config
.entry('painel') .entry('painel')
.add('./frontend/src/__apps/painel/main.js') .add('./frontend/src/__apps/painel/main.js')
.end() .end()
config
.entry('painel-controle')
.add('./frontend/src/__apps/painel-controle/main.js')
.end()
config config
.entry('compilacao') .entry('compilacao')
.add('./frontend/src/__apps/compilacao/main.js') .add('./frontend/src/__apps/compilacao/main.js')

9
yarn.lock

@ -3758,7 +3758,7 @@ fs.realpath@^1.0.0:
fsevents@~2.3.2: fsevents@~2.3.2:
version "2.3.3" 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== integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==
function-bind@^1.1.2: 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" resolved "https://registry.npmjs.org/vue-template-es2015-compiler/-/vue-template-es2015-compiler-1.9.1.tgz"
integrity sha512-4gDntzrifFnCEvyoO8PqyJDmguXgVPxKiIxrBKjIowvL9l+N66196+72XVYR8BBf1Uv1Fgt3bGevJ+sEmxfZzw== integrity sha512-4gDntzrifFnCEvyoO8PqyJDmguXgVPxKiIxrBKjIowvL9l+N66196+72XVYR8BBf1Uv1Fgt3bGevJ+sEmxfZzw==
vue@^2.7.9: vue@^2.7.16:
version "2.7.16" version "2.7.16"
resolved "https://registry.npmjs.org/vue/-/vue-2.7.16.tgz" resolved "https://registry.npmjs.org/vue/-/vue-2.7.16.tgz"
integrity sha512-4gCtFXaAA3zYZdTp5s4Hl2sozuySsgz4jy1EnpBHNfpMa9dK1ZCG7viqBPCwXtmgc8nHqUsAu3G4gtmXkkY3Sw== integrity sha512-4gCtFXaAA3zYZdTp5s4Hl2sozuySsgz4jy1EnpBHNfpMa9dK1ZCG7viqBPCwXtmgc8nHqUsAu3G4gtmXkkY3Sw==
@ -6791,6 +6791,11 @@ vue@^2.7.9:
"@vue/compiler-sfc" "2.7.16" "@vue/compiler-sfc" "2.7.16"
csstype "^3.1.0" 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: watchpack@^2.4.0, watchpack@^2.4.1:
version "2.4.1" version "2.4.1"
resolved "https://registry.npmjs.org/watchpack/-/watchpack-2.4.1.tgz" resolved "https://registry.npmjs.org/watchpack/-/watchpack-2.4.1.tgz"

Loading…
Cancel
Save