Browse Source

Painel de votação em tempo real: Django Channels/WebSockets + Vue 2/Pinia

Substitui o polling HTTP do painel legislativo por push via WebSockets
(um PainelConsumer por sessão, broadcast de snapshot completo a partir
de build_dados_painel()) e cobre com páginas Vue 2 + Pinia as telas de
exibição pública do painel, votação nominal, voto individual, votação
simbólica e leitura de matéria — todas consumindo o mesmo broadcast via
normalizePainelData()/painelStore, sem componentes Vuex remanescentes.

- Backend: PainelConsumer/ASGI (Channels + Redis), views Postgres
  (SessaoPresencasView/SessaoMateriasVotacoesView) para eliminar N+1,
  cronômetro com estado compartilhado em cache, migrations 0070-0073
  como cadeia única.
- Máscara de voto preservada: o broadcast público continua sempre
  mascarado por mostrar_voto; estado por-operador e por-eleitor vem de
  endpoints HTTP dedicados (votante_status, _status_json), nunca do
  broadcast compartilhado.
- Infra: docker-compose e nginx atualizados para /ws/, requirements
  (channels/daphne/channels-redis) e vue.config.js com as novas entries
  de webpack.
- Testes: test_broadcasts.py, test_consumers.py, test_votacao_nominal.py
  e test_sessao_view.py cobrindo o novo backend.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H85beqPY6uKTmSdCEovbPa

Painel: remove polling fallback, WS-only with connection error UI

Documents local-dev setup requirements in sapl/sessao/README.md and
finishes the polling->push migration by removing the HTTP polling that
was still running as a fallback everywhere: the three legacy templates
(painel/index.html, voto_individual.html, sessao/votacao/nominal.html)
and voto-individual/main.js. Every painel/votação screen — legacy and
Vue — is now WebSocket-only.

- Reconnect: exponential backoff with jitter (was flat 500ms in Vue),
  and connections now distinguish PainelConsumer's permanent close codes
  (4401/4403/4404 — auth/permission/no-such-sessão) from transient drops
  instead of retrying forever. New frontend/src/__apps/painel/ws/
  painelSocket.js + WsStatusBanner.vue share this across the five Vue
  apps; the three legacy templates get an equivalent vanilla-JS banner.
- Broadcast coverage: without polling to mask it, every write path that
  changes the painel payload now has to call broadcast_dados_painel()
  itself. Added it to PresencaView/PresencaOrdemDiaView, the three
  Orador*Crud create/update/delete views (via a new
  BroadcastPainelOnSaveMixin — covered by a new end-to-end test, since
  self.kwargs['pk'] means the parent sessão in CreateView but the
  Orador's own pk in Update/DeleteView), LeituraEmBloco, and
  VotacaoEmBlocoSimbolicaView/VotacaoEmBlocoNominalView.
- voto_individual.html now opens its WebSocket whenever a sessão is
  open (not just when a matéria is), gated on sessao_id being present
  rather than on materia — otherwise a page with no votação aberta at
  all had no sessao_id to build a WS URL from.
- Dead code removed: painel_mensagem/painel_parlamentar/painel_votacao
  (views, urls, templates) had no references anywhere and predate this
  work. dados_painel/get_dados_painel (the HTTP polling endpoint) and
  its ETag helper are also removed now that nothing polls them, along
  with the tests pinning their HTTP-specific behavior — build_dados_painel()
  itself is untouched and still backs every WS broadcast.
- scripts/verify_painel_websockets.sh now runs a single Daphne process
  instead of Daphne+runserver on separate ports: WS-only clients need
  HTTP and WS on the same origin, which the old split-port setup never
  actually provided (it only appeared to work via the polling fallback
  that's now gone).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H85beqPY6uKTmSdCEovbPa
feat/painel-votacao-v2
Edward Ribeiro 4 weeks ago
parent
commit
705d6e16eb
  1. 3
      RUNBOOK.txt
  2. 19
      docker/config/nginx/sapl.conf
  3. 9
      docker/docker-compose-dev.yml
  4. 14
      docker/docker-compose.yaml
  5. 2
      docker/startup_scripts/start.sh
  6. 121
      frontend/src/__apps/leitura-materia/main.js
  7. 1
      frontend/src/__apps/leitura-materia/scss/leitura-materia.scss
  8. 38
      frontend/src/__apps/painel-controle/main.js
  9. 43
      frontend/src/__apps/painel-controle/scss/painel.scss
  10. 175
      frontend/src/__apps/painel/main.js
  11. 55
      frontend/src/__apps/painel/scss/painel.scss
  12. 86
      frontend/src/__apps/painel/store/painelStore.js
  13. 125
      frontend/src/__apps/painel/ws/normalizePainel.js
  14. 42
      frontend/src/__apps/painel/ws/painelSocket.js
  15. 129
      frontend/src/__apps/votacao-simbolica/main.js
  16. 1
      frontend/src/__apps/votacao-simbolica/scss/votacao-simbolica.scss
  17. 287
      frontend/src/__apps/votacao/main.js
  18. 0
      frontend/src/__apps/votacao/scss/votacao.scss
  19. 115
      frontend/src/__apps/voto-individual/main.js
  20. 18
      frontend/src/__apps/voto-individual/scss/voto-individual.scss
  21. 90
      frontend/src/components/leitura-materia/LeituraMateria.vue
  22. 103
      frontend/src/components/painel/Cronometro.vue
  23. 136
      frontend/src/components/painel/CronometroList.vue
  24. 64
      frontend/src/components/painel/PainelFooter.vue
  25. 36
      frontend/src/components/painel/PainelHeader.vue
  26. 46
      frontend/src/components/painel/PainelMateria.vue
  27. 41
      frontend/src/components/painel/PainelOradores.vue
  28. 171
      frontend/src/components/painel/PainelParlamentares.vue
  29. 106
      frontend/src/components/painel/PainelResultado.vue
  30. 41
      frontend/src/components/painel/WsStatusBanner.vue
  31. 128
      frontend/src/components/votacao-simbolica/VotacaoSimbolica.vue
  32. 26
      frontend/src/components/votacao/VotacaoMateria.vue
  33. 78
      frontend/src/components/votacao/VotacaoNominal.vue
  34. 85
      frontend/src/components/votacao/VotacaoObservacoes.vue
  35. 49
      frontend/src/components/votacao/VotacaoResultado.vue
  36. 53
      frontend/src/components/votacao/VotacaoVotos.vue
  37. 83
      frontend/src/components/voto-individual/VotoIndividual.vue
  38. 14525
      package-lock.json
  39. 4
      package.json
  40. 4
      requirements/requirements.txt
  41. 1
      requirements/test-requirements.txt
  42. 23
      sapl/api/serializers.py
  43. 20
      sapl/asgi.py
  44. 54
      sapl/painel/README.md
  45. 101
      sapl/painel/consumers.py
  46. 253
      sapl/painel/tests/test_broadcasts.py
  47. 157
      sapl/painel/tests/test_consumers.py
  48. 270
      sapl/painel/tests/tests.py
  49. 27
      sapl/painel/urls.py
  50. 765
      sapl/painel/views.py
  51. 283
      sapl/sessao/README.md
  52. 31
      sapl/sessao/admin.py
  53. 4
      sapl/sessao/forms.py
  54. 21
      sapl/sessao/migrations/0070_votoparlamentar_unique_constraint.py
  55. 39
      sapl/sessao/migrations/0071_votacao_aberta_unique_constraint.py
  56. 225
      sapl/sessao/migrations/0072_sessao_presencas_e_materias_votacoes_views.py
  57. 53
      sapl/sessao/migrations/0073_sessaomateriasvotacoesview_sessaopresencasview.py
  58. 88
      sapl/sessao/models.py
  59. 383
      sapl/sessao/tests/test_votacao_nominal.py
  60. 64
      sapl/sessao/urls.py
  61. 792
      sapl/sessao/views.py
  62. 18
      sapl/settings.py
  63. 113
      sapl/templates/painel/index.html
  64. 120
      sapl/templates/painel/mensagem.html
  65. 70
      sapl/templates/painel/painel_v2.html
  66. 128
      sapl/templates/painel/parlamentares.html
  67. 123
      sapl/templates/painel/votacao.html
  68. 139
      sapl/templates/painel/voto_individual.html
  69. 41
      sapl/templates/painel/voto_individual_v2.html
  70. 106
      sapl/templates/sessao/painel_v2.html
  71. 39
      sapl/templates/sessao/votacao/leitura_v2.html
  72. 167
      sapl/templates/sessao/votacao/nominal.html
  73. 38
      sapl/templates/sessao/votacao/votacao_simbolica_v2.html
  74. 34
      sapl/templates/sessao/votacao/votacao_v2.html
  75. 138
      scripts/verify_painel_websockets.sh
  76. 6
      scripts/websockets.sh
  77. 37
      vue.config.js
  78. 27
      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

19
docker/config/nginx/sapl.conf

@ -4,13 +4,18 @@ upstream sapl_server {
} }
upstream sapl_ws {
server unix:/var/interlegis/sapl/run/daphne.sock fail_timeout=0;
}
# Se o cliente já manda X-Request-ID, reaproveita; senão, usa $request_id (nginx) # Se o cliente já manda X-Request-ID, reaproveita; senão, usa $request_id (nginx)
map $http_x_request_id $req_id { map $http_x_request_id $req_id {
default $http_x_request_id; default $http_x_request_id;
"" $request_id; "" $request_id;
} }
server { server {
listen 80; listen 80;
@ -53,6 +58,18 @@ server {
alias /var/interlegis/sapl/media/; alias /var/interlegis/sapl/media/;
} }
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_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 3600;
proxy_send_timeout 3600;
proxy_pass http://sapl_ws;
}
location / { location / {
proxy_set_header X-Request-ID $req_id; proxy_set_header X-Request-ID $req_id;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

9
docker/docker-compose-dev.yml

@ -1,6 +1,12 @@
version: '3.7' version: '3.7'
services: services:
sapl-redis:
image: redis:7-alpine
container_name: sapl-redis-dev
ports:
- "6379:6379"
sapl-dev: sapl-dev:
container_name: sapl-dev container_name: sapl-dev
image: sapl:dev image: sapl:dev
@ -12,8 +18,11 @@ services:
- ..:/sapl-dev - ..:/sapl-dev
ports: ports:
- "8000:8000" - "8000:8000"
depends_on:
- sapl-redis
environment: environment:
SECRET_KEY: '$dkhxm-$zvxdox$g2-&w^1i!_z1juq0xwox6e3#gy6w_88!3t^' SECRET_KEY: '$dkhxm-$zvxdox$g2-&w^1i!_z1juq0xwox6e3#gy6w_88!3t^'
DEBUG: 'True' DEBUG: 'True'
DATABASE_URL: postgresql://sapl:sapl@host.docker.internal:5432/sapl DATABASE_URL: postgresql://sapl:sapl@host.docker.internal:5432/sapl
REDIS_URL: redis://sapl-redis:6379/0
TZ: America/Sao_Paulo TZ: America/Sao_Paulo

14
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,11 +32,14 @@ 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.165-RC3 # image: interlegis/sapl:3.1.165-RC3
# build: build:
# context: ../ context: ../
# dockerfile: ./docker/Dockerfile dockerfile: ./docker/Dockerfile
container_name: sapl container_name: sapl
labels: labels:
NAME: "sapl" NAME: "sapl"
@ -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)..."
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;"
} }

121
frontend/src/__apps/leitura-materia/main.js

@ -0,0 +1,121 @@
import './scss/leitura-materia.scss'
import Vue from 'vue'
import VueCompositionApi from '@vue/composition-api'
import { PiniaVuePlugin, createPinia } from 'pinia'
import axios from 'axios'
import { usePainelStore } from '../painel/store/painelStore'
import { isPermanentCloseCode, permanentCloseMessage, nextBackoffDelay, INITIAL_BACKOFF_MS, DISCONNECTED_MESSAGE } from '../painel/ws/painelSocket'
import LeituraMateria from '../../components/leitura-materia/LeituraMateria.vue'
import WsStatusBanner from '../../components/painel/WsStatusBanner.vue'
Vue.component('leitura-materia', LeituraMateria)
Vue.component('ws-status-banner', WsStatusBanner)
axios.defaults.xsrfCookieName = 'csrftoken'
axios.defaults.xsrfHeaderName = 'X-CSRFToken'
Vue.use(VueCompositionApi)
Vue.use(PiniaVuePlugin)
const pinia = createPinia()
new Vue({ // eslint-disable-line
pinia,
delimiters: ['[[', ']]'],
el: '#leitura-materia',
data () {
return {
sessaoId: null,
oid: null,
mid: null,
initialObservacao: '',
initialMateriaTexto: '',
initialMateriaEmenta: '',
cancelUrl: '#',
ws: null,
reconnectTimer: null,
reconnectDelay: INITIAL_BACKOFF_MS,
wsStatus: 'connecting',
wsErrorMessage: 'Conectando ao servidor de tempo real…',
error_message: ''
}
},
mounted () {
const el = this.$el
this.sessaoId = el.dataset.sessaoId
this.oid = el.dataset.oid
this.mid = el.dataset.mid
this.initialObservacao = el.dataset.observacao || ''
this.initialMateriaTexto = el.dataset.materiaTexto || ''
this.initialMateriaEmenta = el.dataset.materiaEmenta || ''
// Mesma URL da tela legada (AbstractLeituraView.cancel_url) —
// iso=1 identifica o fluxo de OrdemDia.
this.cancelUrl = `/sessao/${this.sessaoId}/1/${this.oid}/retirar-leitura`
if (this.sessaoId) {
this.connectWS()
}
},
methods: {
painelStore () {
return usePainelStore()
},
actionUrl () {
return `/sessao/${this.sessaoId}/materia/ordemdia/leitura/v2/${this.oid}/${this.mid}/salvar`
},
wsURL () {
const proto = location.protocol === 'https:' ? 'wss' : 'ws'
return `${proto}://${location.host}/ws/painel/${this.sessaoId}/`
},
connectWS () {
this.ws = new WebSocket(this.wsURL())
this.ws.addEventListener('open', () => {
this.wsStatus = 'open'
this.wsErrorMessage = ''
this.reconnectDelay = INITIAL_BACKOFF_MS
})
this.ws.addEventListener('message', (message) => {
try {
const data = JSON.parse(message.data)
if (data.type === 'data') {
this.painelStore().applyData(data)
}
} catch (e) {
console.error('Leitura materia WS parse error:', e)
}
})
this.ws.addEventListener('close', (e) => {
if (isPermanentCloseCode(e.code)) {
this.wsStatus = 'error'
this.wsErrorMessage = permanentCloseMessage(e.code)
return
}
this.wsStatus = 'reconnecting'
this.wsErrorMessage = DISCONNECTED_MESSAGE
this.reconnectTimer = setTimeout(() => this.connectWS(), this.reconnectDelay)
this.reconnectDelay = nextBackoffDelay(this.reconnectDelay)
})
},
onSalvar (data) {
axios.post(this.actionUrl(), data, {
headers: { 'Content-Type': 'application/json' }
})
.then(response => {
if (response.data.redirect_url) {
window.location.href = response.data.redirect_url
}
})
.catch(error => {
const respData = error.response && error.response.data
this.error_message = (respData && respData.message) || 'Erro ao registrar a leitura. Tente novamente.'
})
},
onCancelar () {
// O próprio <a :href> do componente já navega — nada a fazer aqui.
},
beforeDestroy () {
if (this.reconnectTimer) clearTimeout(this.reconnectTimer)
if (this.ws) this.ws.close()
}
}
})

1
frontend/src/__apps/leitura-materia/scss/leitura-materia.scss

@ -0,0 +1 @@
/* estilos específicos da tela de leitura de matéria, se necessário */

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

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

@ -1 +1,176 @@
import './scss/painel.scss' import './scss/painel.scss'
import Vue from 'vue'
import VueCompositionApi from '@vue/composition-api'
import { PiniaVuePlugin, createPinia, mapState } from 'pinia'
import { usePainelStore } from './store/painelStore'
import { isPermanentCloseCode, permanentCloseMessage, nextBackoffDelay, INITIAL_BACKOFF_MS, DISCONNECTED_MESSAGE } from './ws/painelSocket'
import Cronometro from '../../components/painel/Cronometro.vue'
import CronometroList from '../../components/painel/CronometroList.vue'
import PainelHeader from '../../components/painel/PainelHeader.vue'
import PainelParlamentares from '../../components/painel/PainelParlamentares.vue'
import PainelOradores from '../../components/painel/PainelOradores.vue'
import PainelMateria from '../../components/painel/PainelMateria.vue'
import PainelResultado from '../../components/painel/PainelResultado.vue'
import PainelFooter from '../../components/painel/PainelFooter.vue'
import WsStatusBanner from '../../components/painel/WsStatusBanner.vue'
// register components
Vue.component('painel-cronometro', Cronometro)
Vue.component('painel-cronometro-list', CronometroList)
Vue.component('painel-header', PainelHeader)
Vue.component('painel-parlamentares', PainelParlamentares)
Vue.component('painel-oradores', PainelOradores)
Vue.component('painel-materia', PainelMateria)
Vue.component('painel-resultado', PainelResultado)
Vue.component('painel-footer', PainelFooter)
Vue.component('ws-status-banner', WsStatusBanner)
// Pinia (no Vue 2 o plugin @vue/composition-api é obrigatório)
Vue.use(VueCompositionApi)
Vue.use(PiniaVuePlugin)
const pinia = createPinia()
const PING_INTERVAL = 30000 // 30s
new Vue({
pinia,
el: '#painel',
delimiters: ['[[', ']]'],
data() {
return {
controllerId: null,
ws: null,
isOpen: false,
error: null,
pingTimer: null,
reconnectTimer: null,
reconnectDelay: INITIAL_BACKOFF_MS,
wsStatus: 'connecting',
wsErrorMessage: 'Conectando ao servidor de tempo real…',
}
},
mounted() {
console.log('Painel principal mounted!')
// $el is guaranteed here
const el = this.$el
// prefer data-attr; fallback to global if you set it
this.controllerId = el.dataset.controllerId || window.controllerId
console.log(`ControllerId: ${this.controllerId}`)
this.connectWS()
},
computed: {
...mapState(usePainelStore, ['painel_aberto', 'sessao_aberta', 'sessao', 'canRender']),
},
methods: {
painelStore() {
return usePainelStore()
},
wsURL() {
const proto = location.protocol === 'https:' ? 'wss' : 'ws'
return `${proto}://${location.host}/ws/painel/${this.controllerId}/`
},
updateState(data) {
try {
// Toda a lógica de estado (flags, sessão, parlamentares, matéria,
// resultado e votos) vive no store Pinia. O header/footer leem a
// sessão do store — não há mais escrita via $refs.
this.painelStore().applyData(data)
} catch (e) {
console.error('Error applying painel data', e)
}
},
connectWS() {
const url = this.wsURL()
this.ws = new WebSocket(url)
this.ws.addEventListener('open', () => {
this.isOpen = true;
this.error = null
this.wsStatus = 'open'
this.wsErrorMessage = ''
this.reconnectDelay = INITIAL_BACKOFF_MS
console.log(`✅ WebSocket connected to ${url}`);
// ping keep-alive timer (servidor responde com 'pong')
this.pingTimer = setInterval(() => {
const ping = JSON.stringify({ type: "ping", ts: Date.now()});
this.ws.send(ping);
}, PING_INTERVAL);
})
this.ws.addEventListener('message', (message) => {
try {
const data = JSON.parse(message.data)
if (data.type === 'data') {
this.updateState(data);
} else if (data.type === 'pong') {
console.debug('Received pong from server');
}
} catch (e) {
console.error('WS parse error:', e);
}
})
this.ws.addEventListener('close', (e) => {
console.log("❌ WebSocket closed:", e);
this.isOpen = false;
if (this.pingTimer) {
clearInterval(this.pingTimer)
this.pingTimer = null
}
if (isPermanentCloseCode(e.code)) {
this.wsStatus = 'error'
this.wsErrorMessage = permanentCloseMessage(e.code)
return
}
this.wsStatus = 'reconnecting'
this.wsErrorMessage = DISCONNECTED_MESSAGE
this.reconnectTimer = setTimeout(() => this.connectWS(), this.reconnectDelay);
this.reconnectDelay = nextBackoffDelay(this.reconnectDelay);
})
this.ws.addEventListener('error', (e) => {
this.error = e
console.error('❌ WebSocket error:', e)
})
},
closeWS() {
try {
console.log(`⚠️ Closing Websocket connection: ${this.wsURL}`);
this.ws && this.ws.close()
} catch (_) {
console.log("Error closing WS connection");
}
},
beforeDestroy() {
// Clear the interval before the component is destroyed
if (this.pingTimer) {
clearInterval(this.pingTimer);
console.log('pingTimer Interval cleared');
}
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
console.log('reconnectTimer cleared');
}
this.closeWS();
},
changeFontSize(value) {
for (var name in this.$refs){
if (name.startsWith("sw")) {
const cronometro = this.$refs[name]
cronometro.changeFontSize(value)
}
}
},
startStopwatch() { if (this.isOpen) this.ws.send(JSON.stringify({ type:'notify', stopwatch:'start' })) },
}
})

55
frontend/src/__apps/painel/scss/painel.scss

@ -1,43 +1,24 @@
:root {
.painel-principal { --bg-dark: #121212;
background: #1c1b1b; --bg-panel: #1e1e1e;
font-family: Verdana; --text-main: #e0e0e0;
font-size: x-large; --text-highlight: #4fa64d;
.text-title {
color: #4fa64d;
margin: 0.5rem;
font-weight: bold;
}
.text-subtitle {
color: #459170;
font-weight: bold;
}
.data-hora {
font-size: 180%;
} }
.text-value { body {
color: white; background-color: var(--bg-dark);
color: var(--text-main);
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
margin-bottom: 0;
} }
.logo-painel { .separador-vertical {
max-width: 100%; border-right: 1px solid #333;
} min-height: 100vh;
.painels { margin: 0;
flex-wrap: wrap;
}
.painel{
margin-top: 1rem;
table {
width: 100%;
}
h2 {
margin-bottom: 0.5rem;
}
#votacao, #oradores_list {
text-align: left;
display: inline-block;
margin-bottom: 1rem;
}
} }
.text-subtitle {
color: #81c784;
font-weight: 600;
} }

86
frontend/src/__apps/painel/store/painelStore.js

@ -0,0 +1,86 @@
// frontend/src/__apps/painel/store/painelStore.js
//
// Store Pinia do painel. Substitui o store Vuex inline: um único ponto de
// entrada (`applyData`), alimentado pelo payload normalizado a partir do
// full-snapshot broadcast do backend (sapl/painel/views.py::
// build_dados_painel()) — não há mais eventos incrementais
// (`vote.update`/`stopwatch.update`); qualquer mudança de estado é
// republicada como um novo snapshot completo.
import { defineStore } from 'pinia'
import { DEFAULT_STATE, normalizePainelData } from '../ws/normalizePainel'
function deepClone (x) {
return JSON.parse(JSON.stringify(x))
}
export const usePainelStore = defineStore('painel', {
state: () => deepClone(DEFAULT_STATE),
getters: {
canRender: (s) => !!(s.sessao_aberta && s.painel_aberto),
// Getters usados pelo app `votacao` (apuração ao vivo a partir dos votos).
totalVotos: (s) => {
const totals = [
{ tipo: 'Sim', total: 0 },
{ tipo: 'Não', total: 0 },
{ tipo: 'Abstenção', total: 0 },
{ tipo: 'Não Votou', total: 0 }
]
s.parlamentares.forEach((p) => {
if (p.voto) {
const entry = totals.find((t) => t.tipo === p.voto)
if (entry) entry.total++
}
})
return totals
},
numPresentes: (s) => s.parlamentares.length,
totalVotados: (s) =>
s.parlamentares.filter((p) => p.voto && p.voto !== 'Não Votou').length
},
actions: {
// Aplica o payload completo (`type: "data"`) do WebSocket.
applyData (raw) {
const next = normalizePainelData(raw)
// flags simples
this.sessao_aberta = next.sessao_aberta
this.painel_aberto = next.painel_aberto
this.mostrar_voto = next.mostrar_voto
this.message = next.message
// objeto aninhado: patch (preserva reatividade dos campos)
Object.assign(this.sessao, next.sessao)
// cronômetros: só start/stop/reset explícitos (ver Cronometro.vue/
// CronometroList.vue) reagem a mudança de valor — nunca a um toggle
// por broadcast, que dispara em qualquer voto/matéria aberta, não só
// em ações de cronômetro.
this.cronometro_discurso = next.cronometro_discurso
this.cronometro_aparte = next.cronometro_aparte
this.cronometro_ordem = next.cronometro_ordem
this.cronometro_consideracoes = next.cronometro_consideracoes
// arrays: trocar referência (voto já vem aplicado por parlamentar
// dentro de next.parlamentares — get_votos() no backend já inlina o
// voto de cada presente, não há mais um mapa separado a reaplicar)
this.parlamentares = next.parlamentares
this.oradores = next.oradores
// objetos grandes: substituir
this.materia = next.materia
this.resultado = next.resultado
// tipos de resultado (app votacao): nunca vem no broadcast do
// WebSocket (ver GAP 3 do plano de merge) — só sobrescreve quando o
// payload realmente traz a lista, preservando o valor populado uma
// vez a partir do contexto inicial renderizado pelo Django.
if (next.tipos_resultado && next.tipos_resultado.length) {
this.tipos_resultado = next.tipos_resultado
}
}
}
})

125
frontend/src/__apps/painel/ws/normalizePainel.js

@ -0,0 +1,125 @@
// frontend/src/__apps/painel/ws/normalizePainel.js
//
// Normaliza o payload `type: "data"` recebido via WebSocket para um shape
// estável e tolerante a payload parcial. A partir da adoção do backend de
// feat/painel-websockets, o payload é o dict flat produzido por
// sapl/painel/views.py::build_dados_painel() — o mesmo usado pelo polling
// HTTP legado e pinado pelos testes sapl/painel/tests/test_broadcasts.py e
// test_consumers.py, então essa função nunca deve mudar de formato; toda
// adaptação de shape vive aqui, não em build_dados_painel().
export const DEFAULT_STATE = Object.freeze({
sessao_aberta: false,
painel_aberto: false,
mostrar_voto: false,
message: '',
sessao: {
sessao_plenaria: '',
sessao_plenaria_data: '',
sessao_plenaria_hora_inicio: '',
sessao_solene: false,
tema_solene: '',
brasao: ''
},
cronometro_discurso: '',
cronometro_aparte: '',
cronometro_ordem: '',
cronometro_consideracoes: '',
parlamentares: [],
oradores: [],
materia: {},
resultado: {
numero_votos: {}
},
// Populado uma vez, a partir do contexto inicial renderizado pelo Django
// (não vem no payload do WebSocket) — ver GAP 3 do plano de merge.
// applyData() preserva o valor atual quando o payload não traz a lista.
tipos_resultado: []
})
function asObject (v) {
return v && typeof v === 'object' ? v : {}
}
function asArray (v) {
return Array.isArray(v) ? v : []
}
function asString (v, fallback) {
return v != null ? v : fallback
}
export function normalizePainelData (raw) {
const d = asObject(raw)
const parlamentares = asArray(d.presentes)
.filter((p) => p && typeof p === 'object')
.map((p) => ({
id: p.id,
parlamentar_id: p.parlamentar_id,
nome_parlamentar: asString(p.nome, ''),
filiacao: asString(p.partido, ''),
fotografia: p.fotografia != null ? p.fotografia : false,
voto: p.voto != null ? p.voto : ''
}))
const oradores = asArray(d.oradores)
.filter((o) => o && typeof o === 'object')
.map((o) => ({
ordem_pronunciamento: o.numero,
nome_parlamentar: asString(o.nome, '')
}))
return {
// sessao_iniciada vem de sessao.iniciada is not False (True ou None
// contam como "iniciada" — sessões legadas ficaram com None, mesma
// convenção de restringe_sessoes_visiveis() no backend).
sessao_aberta: !!d.sessao_iniciada && !d.sessao_finalizada,
painel_aberto: d.status_painel != null ? !!d.status_painel : DEFAULT_STATE.painel_aberto,
mostrar_voto: d.mostrar_voto != null ? !!d.mostrar_voto : DEFAULT_STATE.mostrar_voto,
message: asString(d.msg_painel, DEFAULT_STATE.message),
sessao: {
sessao_plenaria: asString(d.sessao_plenaria, DEFAULT_STATE.sessao.sessao_plenaria),
sessao_plenaria_data: asString(d.sessao_plenaria_data, DEFAULT_STATE.sessao.sessao_plenaria_data),
sessao_plenaria_hora_inicio: asString(d.sessao_plenaria_hora_inicio, DEFAULT_STATE.sessao.sessao_plenaria_hora_inicio),
sessao_solene: d.sessao_solene != null ? !!d.sessao_solene : DEFAULT_STATE.sessao.sessao_solene,
tema_solene: asString(d.tema_solene, DEFAULT_STATE.sessao.tema_solene),
brasao: asString(d.brasao, DEFAULT_STATE.sessao.brasao)
},
cronometro_discurso: asString(d.cronometro_discurso, DEFAULT_STATE.cronometro_discurso),
cronometro_aparte: asString(d.cronometro_aparte, DEFAULT_STATE.cronometro_aparte),
cronometro_ordem: asString(d.cronometro_ordem, DEFAULT_STATE.cronometro_ordem),
cronometro_consideracoes: asString(d.cronometro_consideracoes, DEFAULT_STATE.cronometro_consideracoes),
parlamentares,
oradores,
materia: {
texto: asString(d.materia_legislativa_texto, ''),
ementa: asString(d.materia_legislativa_ementa, ''),
observacao: asString(d.observacao_materia, ''),
resultado_votacao: asString(d.tipo_resultado, '')
},
resultado: {
numero_votos: {
num_presentes: d.num_presentes != null ? d.num_presentes : parlamentares.length,
votos_sim: d.numero_votos_sim != null ? d.numero_votos_sim : 0,
votos_nao: d.numero_votos_nao != null ? d.numero_votos_nao : 0,
abstencoes: d.numero_abstencoes != null ? d.numero_abstencoes : 0,
total_votos: d.total_votos != null ? d.total_votos : 0
}
},
// Nunca vem no payload do WebSocket — applyData() só sobrescreve o
// valor atual do store quando esta lista não está vazia.
tipos_resultado: asArray(d.tipos_resultado)
}
}

42
frontend/src/__apps/painel/ws/painelSocket.js

@ -0,0 +1,42 @@
// Reconexão do WebSocket do painel (/ws/painel/<sessao_id>/), compartilhada
// pelos cinco apps Vue que se conectam nele (painel, votacao, voto-individual,
// votacao-simbolica, leitura-materia). Não há mais fallback de polling HTTP —
// isto é a única fonte de atualização, então quem usa isto precisa também
// expor o status ao usuário (ver WsStatusBanner.vue), não só logar no console.
const MIN_BACKOFF_MS = 1000
const MAX_BACKOFF_MS = 30000
// Códigos fechados explicitamente por PainelConsumer.connect()
// (sapl/painel/consumers.py) para condições que uma nova tentativa não
// resolve sozinha — não faz sentido continuar reconectando com backoff.
const PERMANENT_CLOSE_MESSAGES = {
4401: 'Sessão expirada ou usuário não autenticado. Faça login novamente e recarregue a página.',
4403: 'Seu usuário não tem permissão para acessar esta tela.',
4404: 'Sessão plenária não encontrada.'
}
export function isPermanentCloseCode (code) {
return Object.prototype.hasOwnProperty.call(PERMANENT_CLOSE_MESSAGES, code)
}
export function permanentCloseMessage (code) {
return PERMANENT_CLOSE_MESSAGES[code]
}
export const INITIAL_BACKOFF_MS = MIN_BACKOFF_MS
// Redis fora do ar (com o Daphne no ar) tende a fechar a conexão logo após
// aceitá-la, não a recusar a abertura — então o "erro de conexão" real é
// "fechou de novo pouco depois de eu tentar", não só "nunca abriu".
export function nextBackoffDelay (currentDelayMs) {
const base = Math.min(currentDelayMs * 2, MAX_BACKOFF_MS)
// +/-30% de jitter — evita que várias telas conectadas na mesma sessão
// reconectem todas no mesmo instante depois de uma queda do Daphne,
// o que bateria em build_dados_painel() ao mesmo tempo para todas.
return Math.round(base * (0.7 + Math.random() * 0.6))
}
export const DISCONNECTED_MESSAGE =
'Conexão em tempo real perdida. Verifique se o Redis e o Daphne (ou ' +
'"./manage.py runserver") estão em execução. Tentando reconectar…'

129
frontend/src/__apps/votacao-simbolica/main.js

@ -0,0 +1,129 @@
import './scss/votacao-simbolica.scss'
import Vue from 'vue'
import VueCompositionApi from '@vue/composition-api'
import { PiniaVuePlugin, createPinia } from 'pinia'
import axios from 'axios'
import { usePainelStore } from '../painel/store/painelStore'
import { isPermanentCloseCode, permanentCloseMessage, nextBackoffDelay, INITIAL_BACKOFF_MS, DISCONNECTED_MESSAGE } from '../painel/ws/painelSocket'
import VotacaoSimbolica from '../../components/votacao-simbolica/VotacaoSimbolica.vue'
import WsStatusBanner from '../../components/painel/WsStatusBanner.vue'
Vue.component('votacao-simbolica', VotacaoSimbolica)
Vue.component('ws-status-banner', WsStatusBanner)
axios.defaults.xsrfCookieName = 'csrftoken'
axios.defaults.xsrfHeaderName = 'X-CSRFToken'
Vue.use(VueCompositionApi)
Vue.use(PiniaVuePlugin)
const pinia = createPinia()
new Vue({ // eslint-disable-line
pinia,
delimiters: ['[[', ']]'],
el: '#votacao-simbolica',
data () {
return {
sessaoId: null,
oid: null,
mid: null,
totalPresentes: 0,
totalVotantes: 0,
ws: null,
reconnectTimer: null,
reconnectDelay: INITIAL_BACKOFF_MS,
wsStatus: 'connecting',
wsErrorMessage: 'Conectando ao servidor de tempo real…',
error_message: ''
}
},
mounted () {
const el = this.$el
this.sessaoId = el.dataset.sessaoId
this.oid = el.dataset.oid
this.mid = el.dataset.mid
this.totalPresentes = el.dataset.totalPresentes
this.totalVotantes = el.dataset.totalVotantes
const tiposResultadoEl = document.getElementById('tipos-resultado')
if (tiposResultadoEl) {
try {
this.painelStore().tipos_resultado = JSON.parse(tiposResultadoEl.textContent)
} catch (e) {
console.error('Erro ao ler tipos-resultado:', e)
}
}
if (this.sessaoId) {
this.connectWS()
}
},
methods: {
painelStore () {
return usePainelStore()
},
actionUrl () {
// Mesma URL da tela legada (VotacaoView) — sessao/urls.py:
// sessao/<pk>/materia/ordemdia/votacao/simbolica/<oid>/<mid>
return `/sessao/${this.sessaoId}/materia/ordemdia/votacao/simbolica/${this.oid}/${this.mid}`
},
wsURL () {
const proto = location.protocol === 'https:' ? 'wss' : 'ws'
return `${proto}://${location.host}/ws/painel/${this.sessaoId}/`
},
connectWS () {
this.ws = new WebSocket(this.wsURL())
this.ws.addEventListener('open', () => {
this.wsStatus = 'open'
this.wsErrorMessage = ''
this.reconnectDelay = INITIAL_BACKOFF_MS
})
this.ws.addEventListener('message', (message) => {
try {
const data = JSON.parse(message.data)
if (data.type === 'data') {
this.painelStore().applyData(data)
}
} catch (e) {
console.error('Votacao simbolica WS parse error:', e)
}
})
this.ws.addEventListener('close', (e) => {
if (isPermanentCloseCode(e.code)) {
this.wsStatus = 'error'
this.wsErrorMessage = permanentCloseMessage(e.code)
return
}
this.wsStatus = 'reconnecting'
this.wsErrorMessage = DISCONNECTED_MESSAGE
this.reconnectTimer = setTimeout(() => this.connectWS(), this.reconnectDelay)
this.reconnectDelay = nextBackoffDelay(this.reconnectDelay)
})
},
postAction (payload) {
axios.post(this.actionUrl(), payload, {
headers: { 'Content-Type': 'application/json' }
})
.then(response => {
if (response.data.redirect_url) {
window.location.href = response.data.redirect_url
}
})
.catch(error => {
const data = error.response && error.response.data
this.error_message = (data && data.message) || 'Erro ao processar a votação. Tente novamente.'
})
},
onCancelar () {
this.postAction({ 'cancelar-votacao': '1' })
},
onSalvar (data) {
this.postAction(data)
},
beforeDestroy () {
if (this.reconnectTimer) clearTimeout(this.reconnectTimer)
if (this.ws) this.ws.close()
}
}
})

1
frontend/src/__apps/votacao-simbolica/scss/votacao-simbolica.scss

@ -0,0 +1 @@
/* estilos específicos da tela de votação simbólica, se necessário */

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

@ -0,0 +1,287 @@
import './scss/votacao.scss'
import Vue from 'vue'
import VueCompositionApi from '@vue/composition-api'
import { PiniaVuePlugin, createPinia, mapState } from 'pinia'
import axios from 'axios'
// Store Pinia compartilhado com o app painel
import { usePainelStore } from '../painel/store/painelStore'
import { isPermanentCloseCode, permanentCloseMessage, nextBackoffDelay, INITIAL_BACKOFF_MS, DISCONNECTED_MESSAGE } from '../painel/ws/painelSocket'
// Import components
import VotacaoNominal from '../../components/votacao/VotacaoNominal.vue'
import VotacaoMateria from '../../components/votacao/VotacaoMateria.vue'
import VotacaoVotos from '../../components/votacao/VotacaoVotos.vue'
import VotacaoResultado from '../../components/votacao/VotacaoResultado.vue'
import VotacaoObservacoes from '../../components/votacao/VotacaoObservacoes.vue'
import WsStatusBanner from '../../components/painel/WsStatusBanner.vue'
// Register components globally
Vue.component('votacao-nominal', VotacaoNominal)
Vue.component('votacao-materia', VotacaoMateria)
Vue.component('votacao-votos', VotacaoVotos)
Vue.component('votacao-resultado', VotacaoResultado)
Vue.component('votacao-observacoes', VotacaoObservacoes)
Vue.component('ws-status-banner', WsStatusBanner)
axios.defaults.xsrfCookieName = 'csrftoken'
axios.defaults.xsrfHeaderName = 'X-CSRFToken'
// Pinia (no Vue 2 o plugin @vue/composition-api é obrigatório)
Vue.use(VueCompositionApi)
Vue.use(PiniaVuePlugin)
const pinia = createPinia()
console.log('votacao main.js carregado')
const PING_INTERVAL = 30000
const v = new Vue({ // eslint-disable-line
pinia,
delimiters: ['[[', ']]'],
el: '#votacao',
data () {
return {
controllerId: null,
ws: null,
isOpen: false,
error: null,
pingTimer: null,
reconnectTimer: null,
reconnectDelay: INITIAL_BACKOFF_MS,
wsStatus: 'connecting',
wsErrorMessage: 'Conectando ao servidor de tempo real…',
error_message: '',
// Estado real (não mascarado por mostrar_voto) de quem já votou —
// nunca vem do broadcast do painel (que é a visão pública/mascarada);
// vem de /votos-status, buscado no mount e a cada broadcast recebido.
votosStatus: {},
}
},
computed: {
...mapState(usePainelStore, [
'sessao_aberta', 'painel_aberto', 'sessao',
'parlamentares', 'materia', 'resultado', 'message', 'mostrar_voto'
]),
},
mounted () {
console.log('Votacao app mounted!')
const el = this.$el
this.controllerId = el.dataset.controllerId || window.controllerId
console.log(`Votacao ControllerId: ${this.controllerId}`)
// tipos_resultado nunca vem do broadcast do painel — populado uma vez
// a partir do contexto inicial renderizado pelo Django (ver GAP 3 do
// plano de merge). applyData() preserva esse valor depois (só
// sobrescreve quando o payload realmente traz uma lista não vazia).
const tiposResultadoEl = document.getElementById('tipos-resultado')
if (tiposResultadoEl) {
try {
this.painelStore().tipos_resultado = JSON.parse(tiposResultadoEl.textContent)
} catch (e) {
console.error('Erro ao ler tipos-resultado:', e)
}
}
if (this.controllerId) {
this.fetchVotosStatus()
this.connectWS()
} else {
this.error_message = 'Erro: controller_id não definido. Não é possível conectar ao WebSocket.'
console.error('No controllerId found — WebSocket will not connect.')
}
},
methods: {
painelStore () {
return usePainelStore()
},
wsURL () {
const proto = location.protocol === 'https:' ? 'wss' : 'ws'
return `${proto}://${location.host}/ws/painel/${this.controllerId}/`
},
voteURL () {
return `/v2/painel/controller/${this.controllerId}/vote`
},
votosStatusURL () {
return `/v2/painel/controller/${this.controllerId}/votos-status`
},
// Estado real (não mascarado) de quem já votou — nunca lido do store
// (que reflete o broadcast público, mascarado quando mostrar_voto é
// False). A Mesa precisa do valor real para não sobrescrever por
// engano um voto que já chegou por tablet.
fetchVotosStatus () {
if (!this.controllerId) return
axios.get(this.votosStatusURL())
.then(response => {
this.votosStatus = response.data.votos || {}
})
.catch(error => {
console.error('Erro ao buscar votos-status:', error)
})
},
castVote ({ parlamentar_id, voto }) {
console.log(`Casting vote: parlamentar=${parlamentar_id}, voto=${voto}`)
axios.post(this.voteURL(), {
parlamentar_id: parlamentar_id,
voto: voto
}, {
headers: { 'Content-Type': 'application/json' }
})
.then(response => {
console.log('Vote cast successfully:', response.data)
})
.catch(error => {
console.error('Error casting vote:', error.response ? error.response.data : error)
this.error_message = 'Erro ao registrar voto. Tente novamente.'
})
},
cancelURL () {
return `/v2/painel/controller/${this.controllerId}/cancel`
},
closeURL () {
return `/v2/painel/controller/${this.controllerId}/close`
},
onCancelar () {
console.log('Votação cancelada')
axios.post(this.cancelURL(), {}, {
headers: { 'Content-Type': 'application/json' }
})
.then(response => {
console.log('Votação cancelada com sucesso:', response.data)
if (response.data.redirect_url) {
window.location.href = response.data.redirect_url
}
})
.catch(error => {
console.error('Erro ao cancelar votação:', error.response ? error.response.data : error)
const msg = error.response && error.response.data && error.response.data.message
? error.response.data.message
: 'Erro ao cancelar votação. Tente novamente.'
this.error_message = msg
})
},
onFechar (data) {
console.log('Fechar votação:', data)
if (!data.resultado_selected) {
this.error_message = 'Não é possível finalizar a votação sem nenhum resultado da votação.'
return
}
axios.post(this.closeURL(), {
resultado_id: data.resultado_selected,
observacoes: data.observacoes || ''
}, {
headers: { 'Content-Type': 'application/json' }
})
.then(response => {
console.log('Votação finalizada com sucesso:', response.data)
this.error_message = ''
if (response.data.redirect_url) {
window.location.href = response.data.redirect_url
}
})
.catch(error => {
console.error('Erro ao fechar votação:', error.response ? error.response.data : error)
const msg = error.response && error.response.data && error.response.data.message
? error.response.data.message
: 'Erro ao fechar votação. Tente novamente.'
this.error_message = msg
})
},
updateState (data) {
try {
// Toda a normalização/aplicação de estado vive no store Pinia.
this.painelStore().applyData(data)
} catch (e) {
console.error('Error updating state:', e)
}
// O broadcast é a visão pública (mascarada); o estado real de quem
// já votou vem sempre de votos-status, refeito a cada snapshot.
this.fetchVotosStatus()
},
connectWS () {
const url = this.wsURL()
this.ws = new WebSocket(url)
this.ws.addEventListener('open', () => {
this.isOpen = true
this.error = null
this.error_message = ''
this.wsStatus = 'open'
this.wsErrorMessage = ''
this.reconnectDelay = INITIAL_BACKOFF_MS
console.log(`✅ Votação WebSocket connected to ${url}`)
this.pingTimer = setInterval(() => {
this.ws.send(JSON.stringify({ type: 'ping', ts: Date.now() }))
}, PING_INTERVAL)
})
this.ws.addEventListener('message', (message) => {
try {
const data = JSON.parse(message.data)
if (data.type === 'data') {
this.updateState(data)
} else if (data.type === 'pong') {
console.debug('Votacao: pong recebido')
}
} catch (e) {
console.error('Votacao WS parse error:', e)
}
})
this.ws.addEventListener('close', (e) => {
console.log('❌ Votação WebSocket closed:', e)
this.isOpen = false
if (this.pingTimer) {
clearInterval(this.pingTimer)
this.pingTimer = null
}
if (isPermanentCloseCode(e.code)) {
this.wsStatus = 'error'
this.wsErrorMessage = permanentCloseMessage(e.code)
return
}
this.wsStatus = 'reconnecting'
this.wsErrorMessage = DISCONNECTED_MESSAGE
this.reconnectTimer = setTimeout(() => this.connectWS(), this.reconnectDelay)
this.reconnectDelay = nextBackoffDelay(this.reconnectDelay)
})
this.ws.addEventListener('error', (e) => {
this.error = e
console.error('❌ Votação WebSocket error:', e)
})
},
closeWS () {
try {
this.ws && this.ws.close()
} catch (_) {
console.log('Error closing WS')
}
},
beforeDestroy () {
if (this.pingTimer) {
clearInterval(this.pingTimer)
}
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer)
}
this.closeWS()
}
}
})

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

115
frontend/src/__apps/voto-individual/main.js

@ -0,0 +1,115 @@
import './scss/voto-individual.scss'
import Vue from 'vue'
import VueCompositionApi from '@vue/composition-api'
import { PiniaVuePlugin, createPinia } from 'pinia'
import { usePainelStore } from '../painel/store/painelStore'
import { isPermanentCloseCode, permanentCloseMessage, nextBackoffDelay, INITIAL_BACKOFF_MS, DISCONNECTED_MESSAGE } from '../painel/ws/painelSocket'
import VotoIndividual from '../../components/voto-individual/VotoIndividual.vue'
import WsStatusBanner from '../../components/painel/WsStatusBanner.vue'
Vue.component('voto-individual', VotoIndividual)
Vue.component('ws-status-banner', WsStatusBanner)
Vue.use(VueCompositionApi)
Vue.use(PiniaVuePlugin)
const pinia = createPinia()
new Vue({
pinia,
el: '#voto-individual-app',
delimiters: ['[[', ']]'],
data () {
return {
sessaoId: null,
ws: null,
reconnectTimer: null,
reconnectDelay: INITIAL_BACKOFF_MS,
wsStatus: 'connecting',
wsErrorMessage: 'Conectando ao servidor de tempo real…'
}
},
mounted () {
this.sessaoId = this.$el.dataset.sessaoId || null
// Estado pessoal (não vem do broadcast, ver nota em fetchStatus) —
// busca inicial aqui; daí em diante só é refeito quando o WS avisar
// que algo mudou (sem poll periódico — ver painelSocket.js).
this.fetchStatus()
if (this.sessaoId) {
this.connectWS()
}
},
methods: {
painelStore () {
return usePainelStore()
},
statusURL () {
return window.VOTO_INDIVIDUAL_STATUS_URL
},
wsURL () {
const proto = location.protocol === 'https:' ? 'wss' : 'ws'
return `${proto}://${location.host}/ws/painel/${this.sessaoId}/`
},
// Mesmo padrão do template legado (voto_individual.html): estado
// pessoal (voto_parlamentar/status_message/error_message) nunca vem
// do broadcast do painel (que é o snapshot público compartilhado por
// todo mundo conectado) — sempre de um fetch dedicado, autenticado
// como este Votante. O componente compara o resultado e só
// re-renderiza quando algo muda.
fetchStatus () {
fetch(this.statusURL(), { credentials: 'same-origin' })
.then(resp => resp.json())
.then(data => {
const comp = this.$refs.votoIndividual
if (!comp) return
comp.materiaId = data.materia_id ? String(data.materia_id) : ''
comp.errorMessage = data.error_message || ''
comp.statusMessage = data.status_message || ''
comp.votoParlamentar = data.voto_parlamentar || ''
})
.catch(() => { /* tenta de novo no próximo broadcast do WS */ })
},
connectWS () {
const url = this.wsURL()
this.ws = new WebSocket(url)
this.ws.addEventListener('open', () => {
this.wsStatus = 'open'
this.wsErrorMessage = ''
this.reconnectDelay = INITIAL_BACKOFF_MS
})
this.ws.addEventListener('message', (message) => {
try {
const data = JSON.parse(message.data)
if (data.type === 'data') {
// Atualiza o display compartilhado da matéria (sessao/materia)
this.painelStore().applyData(data)
// E dispara a checagem do estado pessoal — sem poll periódico,
// isto é a única forma de saber que algo mudou.
this.fetchStatus()
}
} catch (e) {
console.error('Voto individual WS parse error:', e)
}
})
this.ws.addEventListener('close', (e) => {
if (isPermanentCloseCode(e.code)) {
this.wsStatus = 'error'
this.wsErrorMessage = permanentCloseMessage(e.code)
return
}
this.wsStatus = 'reconnecting'
this.wsErrorMessage = DISCONNECTED_MESSAGE
this.reconnectTimer = setTimeout(() => this.connectWS(), this.reconnectDelay)
this.reconnectDelay = nextBackoffDelay(this.reconnectDelay)
})
},
beforeDestroy () {
if (this.reconnectTimer) clearTimeout(this.reconnectTimer)
if (this.ws) this.ws.close()
}
}
})

18
frontend/src/__apps/voto-individual/scss/voto-individual.scss

@ -0,0 +1,18 @@
:root {
--bg-dark: #1c1b1b;
--text-main: #e0e0e0;
}
body {
background-color: var(--bg-dark);
color: var(--text-main);
font-family: Verdana, 'Segoe UI', Tahoma, Geneva, sans-serif;
max-width: 100%;
overflow-x: hidden;
}
.btn-voto {
min-width: 9rem;
min-height: 4rem;
font-size: 1.5rem;
}

90
frontend/src/components/leitura-materia/LeituraMateria.vue

@ -0,0 +1,90 @@
<template>
<fieldset class="form-group">
<legend>Leitura de Matéria</legend>
<div v-if="error_message" class="alert alert-danger">
[[ error_message ]]
</div>
<div>
<b>Matéria:</b> [[ materia.texto ]]
<br />
<b>Ementa:</b> [[ materia.ementa ]]
</div>
<br />
<div class="row">
<div class="col-md-12">
Observações
<textarea v-model="observacao" rows="7" class="form-control"></textarea>
</div>
</div>
<br />
<div class="row">
<div class="col-md-12">
<div class="form-group row justify-content-between">
<a :href="cancelUrl" class="btn btn-warning">Cancelar Leitura</a>
<input type="button" value="Salvar" class="btn btn-primary" @click="onSalvar" />
</div>
</div>
</div>
</fieldset>
</template>
<script>
import { mapState } from 'pinia'
import { usePainelStore } from '@/__apps/painel/store/painelStore'
export default {
name: 'LeituraMateria',
props: {
errorMessage: {
type: String,
default: ''
},
cancelUrl: {
type: String,
default: '#'
},
initialObservacao: {
type: String,
default: ''
},
initialMateriaTexto: {
type: String,
default: ''
},
initialMateriaEmenta: {
type: String,
default: ''
}
},
data () {
return {
observacao: this.initialObservacao
}
},
computed: {
...mapState(usePainelStore, { storeMateria: 'materia' }),
error_message () {
return this.errorMessage
},
// O broadcast do painel só reflete a matéria em votação/leitura DEPOIS
// que a ação abre a matéria; o texto/ementa iniciais vêm sempre do
// contexto Django (a matéria já é conhecida pela URL, oid/mid).
materia () {
if (this.storeMateria && this.storeMateria.texto) return this.storeMateria
return { texto: this.initialMateriaTexto, ementa: this.initialMateriaEmenta }
}
},
methods: {
onSalvar () {
this.$emit('salvar', { observacao: this.observacao })
}
},
mounted () {
console.log('LeituraMateria mounted')
}
}
</script>

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

@ -0,0 +1,103 @@
<template>
<tr :id="'row_' + id" v-show="visible">
<td :class="['fs-3', titleColorClass]">{{ title }}</td>
<td class="text-end">
<audio ref="player" :src="audioSrc" preload="auto"></audio>
<span :id="'cronometro_' + id"
:class="['fw-bold', 'font-monospace', 'fs-1', titleColorClass]"
ref="time">{{ formatTime(time) }}</span>
</td>
</tr>
</template>
<script>
export default {
name: 'Cronometro',
props: ['id', 'title', 'visible', 'colorClass'],
data() {
return {
time: 300,
isRunning: false,
initialTime: 300,
intervalId: null,
audioSrc: require('@/assets/audio/ring.mp3'),
}
},
computed: {
titleColorClass() {
return this.colorClass || '';
}
},
mounted() {
console.log('Cronometro mounted');
this.$emit('child-mounted');
},
methods: {
changeFontSize(value) {
const el = this.$refs.time;
if (!el) return;
let fontSize = window.getComputedStyle(el).fontSize;
fontSize = parseFloat(fontSize);
el.style.fontSize = (fontSize + value) + 'px';
},
// start/stop/reset são idempotentes de propósito: o valor cacheado no
// servidor (sapl.painel.views.cronometro_painel) é reenviado em todo
// broadcast do painel, não só quando o cronômetro muda — chamar
// start() de novo com o cronômetro já rodando não deve reiniciar a
// contagem, e stop()/reset() repetidos não devem quebrar nada.
start() {
if (this.isRunning) return;
this.isRunning = true;
this.intervalId = setInterval(() => {
if (this.time > 0) {
this.time--;
if (this.time == 30) {
this.playSound();
}
} else {
this.isRunning = false;
clearInterval(this.intervalId);
this.playSound();
}
}, 1000);
},
stop() {
this.isRunning = false;
clearInterval(this.intervalId);
},
reset() {
this.stop();
this.time = this.initialTime;
},
playSound() {
const audio = this.$refs.player
if (!audio) return
audio.play()
},
formatTime(seconds) {
const hrs = Math.floor(seconds / 3600);
const mins = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
return `${hrs.toString().padStart(2, '0')}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
},
watch: {
initialTime(newVal) {
if (!this.isRunning) {
this.time = newVal;
}
}
},
beforeDestroy() {
clearInterval(this.intervalId);
}
}
</script>
<style scoped>
/* Add your own styles here */
</style>

136
frontend/src/components/painel/CronometroList.vue

@ -0,0 +1,136 @@
<template>
<div class="painel d-flex flex-column" v-if="canRender">
<div id="box_cronometros" class="w-100">
<h2 class="text-center text-subtitle mb-3">Cronômetro</h2>
<div class="d-flex align-items-center justify-content-center" style="height: 80px">
<table class="table-custom w-100 mb-0">
<tbody>
<Cronometro
ref="childRef_0"
id="discurso"
title="Discurso"
:visible="visibleCronometro === 'discurso'"
color-class=""
@child-mounted="handleChildMounted"
/>
<Cronometro
ref="childRef_1"
id="aparte"
title="Aparte"
:visible="visibleCronometro === 'aparte'"
color-class="text-warning"
@child-mounted="handleChildMounted"
/>
<Cronometro
ref="childRef_2"
id="ordem"
title="Questão de Ordem"
:visible="visibleCronometro === 'ordem'"
color-class="text-info"
@child-mounted="handleChildMounted"
/>
<Cronometro
ref="childRef_3"
id="consideracoes"
title="Consid. Finais"
:visible="visibleCronometro === 'consideracoes'"
color-class=""
@child-mounted="handleChildMounted"
/>
</tbody>
</table>
</div>
</div>
</div>
</template>
<script>
import { mapState } from 'pinia';
import { usePainelStore } from '@/__apps/painel/store/painelStore';
import Cronometro from './Cronometro.vue';
export default {
name: 'CronometroList',
components: {
Cronometro,
},
data() {
return {
visibleCronometro: 'discurso',
}
},
mounted() {
console.log('CronometroList mounted');
// Sincroniza o estado inicial a partir do snapshot já presente
// no store (não dá pra confiar num watcher `immediate` aqui:
// ele dispararia em `created`, antes dos refs dos filhos
// existirem).
this.applyCronometroAction('childRef_0', this.cronometro_discurso);
this.applyCronometroAction('childRef_1', this.cronometro_aparte);
this.applyCronometroAction('childRef_2', this.cronometro_ordem);
this.applyCronometroAction('childRef_3', this.cronometro_consideracoes);
},
computed: {
...mapState(usePainelStore, [
"canRender",
"cronometro_discurso", "cronometro_aparte",
"cronometro_ordem", "cronometro_consideracoes"
])
},
watch: {
// Só dispara em mudança de valor — o broadcast do painel chega a
// cada voto/matéria aberta, não só em ações de cronômetro; um
// toggle-em-toda-mensagem reiniciaria o cronômetro à toa.
cronometro_discurso(newVal) { this.applyCronometroAction('childRef_0', newVal); },
cronometro_aparte(newVal) { this.applyCronometroAction('childRef_1', newVal); },
cronometro_ordem(newVal) { this.applyCronometroAction('childRef_2', newVal); },
cronometro_consideracoes(newVal) { this.applyCronometroAction('childRef_3', newVal); },
},
methods: {
applyCronometroAction(ref, action) {
const comp = this.$refs[ref];
if (!comp) return;
if (action === 'start') {
comp.start();
} else if (action === 'stop') {
comp.stop();
} else if (action === 'reset') {
comp.reset();
}
this.updateVisibility();
},
handleChildMounted() {
console.log('Cronometro child mounted');
},
updateVisibility() {
// Show priority: ordem > aparte > consideracoes > discurso
const refs = [
{ key: 'ordem', ref: 'childRef_2' },
{ key: 'aparte', ref: 'childRef_1' },
{ key: 'consideracoes', ref: 'childRef_3' },
{ key: 'discurso', ref: 'childRef_0' },
];
for (const item of refs) {
const comp = this.$refs[item.ref];
if (comp && comp.isRunning) {
this.visibleCronometro = item.key;
return;
}
}
// Default to discurso if none running
this.visibleCronometro = 'discurso';
}
},
};
</script>
<style scoped>
.table-custom {
color: #ddd;
}
::v-deep .table-custom tbody td {
padding: 8px;
font-size: 1.1rem;
}
</style>

64
frontend/src/components/painel/PainelFooter.vue

@ -0,0 +1,64 @@
<template>
<div class="painel position-relative bottom-0 hora-brasao">
<div class="row justify-content-center align-items-center">
<div class="col-4 text-center" id="logo-container" v-if="brasao">
<img :src="brasao" id="logo-painel" class="logo-painel" alt="Brasão" />
</div>
<div :class="['text-center', brasao ? 'col-4' : 'col-12 mt-5']" id="relogio-container">
<span class="fs-1 fw-bold" id="relogio">{{ relogio }}</span>
</div>
</div>
</div>
</template>
<script>
import { mapState } from 'pinia';
import { usePainelStore } from '@/__apps/painel/store/painelStore';
export default {
name: 'PainelFooter',
props: {
},
data() {
return {
data_atual: "",
relogio: "",
currentDateTimeId: null,
}
},
methods: {
startCurrentDateTime() {
this.data_atual = moment().utcOffset(-3).format("DD/MM/YY");
this.currentDateTimeId = setInterval(() => {
this.relogio = moment.utc().utcOffset(-3).format("HH:mm:ss");
}, 500);
},
},
beforeDestroy() {
if (this.currentDateTimeId) {
clearInterval(this.currentDateTimeId);
console.log('currentDateTimeId Interval cleared.');
}
},
computed: {
...mapState(usePainelStore, {
brasao: (store) => (store.sessao && store.sessao.brasao) || '',
})
},
mounted() {
console.log('PainelFooter component mounted');
this.startCurrentDateTime();
}
}
</script>
<style scoped>
.logo-painel {
max-height: 150px;
width: auto;
}
.hora-brasao {
border-top: 1px solid #333;
}
</style>

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

@ -0,0 +1,36 @@
<template>
<div>
<h1 id="sessao_plenaria" class="text-title fw-bold text-uppercase">{{ sessao_plenaria }}</h1>
<h2 class="text-danger" v-if="message"><span id="message">{{ message }}</span></h2>
</div>
</template>
<script>
import { mapState } from 'pinia';
import { usePainelStore } from '@/__apps/painel/store/painelStore';
export default {
name: 'PainelHeader',
props: {
},
methods: {
},
computed: {
...mapState(usePainelStore, {
sessao_aberta: 'sessao_aberta',
message: 'message',
sessao_plenaria: (store) => (store.sessao && store.sessao.sessao_plenaria) || '',
})
},
mounted() {
console.log('PainelHeader component mounted');
}
}
</script>
<style scoped>
.text-title {
color: var(--text-highlight);
letter-spacing: 1px;
}
</style>

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

@ -0,0 +1,46 @@
<template>
<div class="painel p-3 d-flex flex-column" id="obs_materia_div" v-if="canRender">
<div class="flex-grow-1">
<h2 id="mat_em_votacao" class="text-subtitle text-center mb-3 pb-1">
Matéria em Votação
</h2>
<span id="materia_legislativa_texto"
class="fs-4 text-white d-block mb-1">{{ materia.texto }}</span>
<span id="materia_legislativa_ementa"
class="fs-6 fst-italic opacity-75 text-white">{{ materia.ementa }}</span>
<div class="mt-2 text-warning" style="font-size: 0.9rem">
<span id="observacao_materia">{{ materia.observacao }}</span>
</div>
<div id="resultado_votacao"
class="text-title mt-auto text-center d-block fs-2">{{ materia.resultado_votacao }}</div>
</div>
</div>
</template>
<script>
import { mapState } from 'pinia';
import { usePainelStore } from '@/__apps/painel/store/painelStore';
export default {
name: 'PainelMateria',
data() {
return {
};
},
mounted() {
console.log('PainelMateria mounted');
},
beforeDestroy() {},
computed: {
...mapState(usePainelStore, ["canRender", "materia"])
}
};
</script>
<style scoped>
.text-title {
color: var(--text-highlight);
letter-spacing: 1px;
}
</style>

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

@ -0,0 +1,41 @@
<template>
<div class="text-center painel" id="aparecer_oradores" v-if="canRender && oradores.length > 0">
<h2 class="text-subtitle">Oradores</h2>
<div id="orador">
<table id="oradores_list">
<tr v-for="o in oradores" :key="o.ordem_pronunciamento">
<td style="padding-right:20px; color:white">
{{ o.ordem_pronunciamento }}º - {{ o.nome_parlamentar }}
</td>
</tr>
</table>
</div>
</div>
</template>
<script>
import { mapState } from 'pinia';
import { usePainelStore } from '@/__apps/painel/store/painelStore';
export default {
name: 'PainelOradores',
data() {
return {
};
},
mounted() {
console.log('PainelOradores mounted');
},
beforeDestroy() {},
computed: {
...mapState(usePainelStore, ["canRender", "oradores"])
}
};
</script>
<style scoped>
#oradores_list tr td {
font-size: 1.2rem;
padding: 5px;
text-align: start;
}
</style>

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

@ -0,0 +1,171 @@
<template>
<div id="parlamentares">
<div class="container-fluid" id="parlamentares_container" v-if="canRender">
<div class="parlamentares-grid" id="parlamentares_row">
<div v-for="p in parlamentares"
:key="p.parlamentar_id"
:class="['parlamentar-card', 'mt-3', votoClass(p)]">
<div class="voto-faixa" v-if="votoText(p)">
<p>{{ votoText(p) }}</p>
</div>
<div class="foto-container">
<img :src="p.fotografia || defaultAvatar" :alt="p.nome_parlamentar">
</div>
<div class="mt-2 text-center">
<div class="nome">{{ p.nome_parlamentar }}</div>
<div class="partido">{{ p.filiacao }}</div>
</div>
</div>
</div>
</div>
<span class="text-white" v-else>
<center>A listagem de parlamentares só aparecerá quando o painel estiver aberto.</center>
</span>
</div>
</template>
<script>
import { mapState } from 'pinia';
import { usePainelStore } from '@/__apps/painel/store/painelStore';
import defaultAvatarImg from '@/assets/img/avatar.png';
export default {
name: 'PainelParlamentares',
data() {
return {
defaultAvatar: defaultAvatarImg,
};
},
mounted() {
console.log('PainelParlamentares mounted');
},
beforeDestroy() {},
computed: {
...mapState(usePainelStore, ["canRender", "parlamentares", "mostrar_voto"])
},
methods: {
votoClass(parlamentar) {
const voto = parlamentar.voto;
if (!voto) return '';
if (this.mostrar_voto) {
if (voto === 'Sim') return 'voto-sim';
if (voto === 'Não') return 'voto-nao';
if (voto === 'Abstenção') return 'voto-abstencao';
} else {
if (voto === 'Sim' || voto === 'Não' || voto === 'Abstenção' || voto === 'Voto Informado') {
return 'voto-informado';
}
}
return '';
},
votoText(parlamentar) {
const voto = parlamentar.voto;
if (!voto) return '';
if (this.mostrar_voto) {
if (voto === 'Sim') return 'S';
if (voto === 'Não') return 'N';
if (voto === 'Abstenção') return 'A';
} else {
if (voto === 'Sim' || voto === 'Não' || voto === 'Abstenção' || voto === 'Voto Informado') {
return 'I';
}
}
return '';
},
},
};
</script>
<style scoped>
.parlamentares-grid {
display: grid;
grid-template-columns: repeat(auto-fill, 190px);
width: 100%;
}
.parlamentar-card {
width: 150px;
border-radius: 3px;
overflow: hidden;
position: relative;
}
.foto-container {
border-radius: 3px;
overflow: hidden;
height: 150px;
}
.parlamentar-card img {
width: 100%;
height: 150px;
display: block;
object-fit: fill;
}
.parlamentar-card .nome {
font-weight: bold;
font-size: 0.95rem;
color: #fff;
line-height: 1.1;
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.8);
margin-bottom: 2px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.parlamentar-card .partido {
font-size: 0.8rem;
color: #ddd;
font-weight: 600;
text-transform: uppercase;
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.8);
}
.voto-faixa {
position: absolute;
width: 90px;
height: 90px;
top: -45px;
right: -45px;
transform: rotate(45deg);
display: flex;
align-items: flex-end;
justify-content: center;
color: #fff;
font-weight: 700;
box-shadow: -2px 2px 5px rgba(0, 0, 0, 0.2);
z-index: 10;
line-height: 1;
}
.voto-faixa p {
transform: rotate(-45deg);
font-size: 2rem;
text-transform: uppercase;
}
.voto-sim .voto-faixa {
background-color: #27ae60;
}
.voto-nao .voto-faixa {
background-color: #c0392b;
}
.voto-abstencao .voto-faixa {
background-color: #f39c12;
}
.voto-informado .voto-faixa {
background-color: #d35400;
}
.voto-ausente .voto-faixa {
background-color: #7f8c8d;
}
</style>

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

@ -0,0 +1,106 @@
<template>
<div class="painel p-3 d-flex flex-column" id="resultado_votacao_div" v-if="canRender">
<div id="box_votacao" class="w-100">
<table class="table-custom w-100 mb-0" id="tabela_resultados">
<tbody id="votacao">
<tr>
<td>Presentes</td>
<td>{{ numPresentes }}</td>
</tr>
<tr>
<td>Sim</td>
<td class="table-sim">{{ votosSim }}</td>
</tr>
<tr>
<td>Não</td>
<td class="table-nao">{{ votosNao }}</td>
</tr>
<tr>
<td>Abstenções</td>
<td class="table-abstencao">{{ votosAbstencao }}</td>
</tr>
<tr>
<td><strong>Total votos</strong></td>
<td class="table-total"><strong>{{ totalVotos }}</strong></td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<script>
import { mapState } from 'pinia';
import { usePainelStore } from '@/__apps/painel/store/painelStore';
export default {
name: 'PainelResultado',
data() {
return {
};
},
mounted() {
console.log('PainelResultado mounted');
},
beforeDestroy() {},
computed: {
...mapState(usePainelStore, ["canRender", "resultado", "parlamentares"]),
numPresentes() {
if (this.resultado && this.resultado.numero_votos && typeof this.resultado.numero_votos.num_presentes !== 'undefined' && this.resultado.numero_votos.num_presentes !== null) {
return this.resultado.numero_votos.num_presentes;
}
return this.parlamentares ? this.parlamentares.length : 0;
},
votosSim() {
if (this.resultado && this.resultado.numero_votos && typeof this.resultado.numero_votos.votos_sim !== 'undefined' && this.resultado.numero_votos.votos_sim !== null && this.resultado.numero_votos.votos_sim > 0) {
return this.resultado.numero_votos.votos_sim;
}
return this.parlamentares ? this.parlamentares.filter(p => p.voto === 'Sim').length : 0;
},
votosNao() {
if (this.resultado && this.resultado.numero_votos && typeof this.resultado.numero_votos.votos_nao !== 'undefined' && this.resultado.numero_votos.votos_nao !== null && this.resultado.numero_votos.votos_nao > 0) {
return this.resultado.numero_votos.votos_nao;
}
return this.parlamentares ? this.parlamentares.filter(p => p.voto === 'Não').length : 0;
},
votosAbstencao() {
if (this.resultado && this.resultado.numero_votos && typeof this.resultado.numero_votos.abstencoes !== 'undefined' && this.resultado.numero_votos.abstencoes !== null && this.resultado.numero_votos.abstencoes > 0) {
return this.resultado.numero_votos.abstencoes;
}
return this.parlamentares ? this.parlamentares.filter(p => p.voto === 'Abstenção').length : 0;
},
totalVotos() {
if (this.resultado && this.resultado.numero_votos && typeof this.resultado.numero_votos.total_votos !== 'undefined' && this.resultado.numero_votos.total_votos !== null && this.resultado.numero_votos.total_votos > 0) {
return this.resultado.numero_votos.total_votos;
}
return this.votosSim + this.votosNao + this.votosAbstencao;
}
},
};
</script>
<style scoped>
.table-custom {
color: #ddd;
}
.table-custom tbody td {
padding: 8px;
font-size: 1.1rem;
}
.table-sim {
color: #27ae60;
}
.table-nao {
color: #c0392b;
}
.table-abstencao {
color: #f39c12;
}
.table-total {
color: #194BFA;
}
</style>

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

@ -0,0 +1,41 @@
<template>
<div v-if="status !== 'open'" class="ws-status-banner" :class="bannerClass">
{{ message }}
</div>
</template>
<script>
export default {
name: 'WsStatusBanner',
props: {
// 'connecting' | 'open' | 'reconnecting' | 'error'
status: { type: String, default: 'connecting' },
message: { type: String, default: 'Conectando ao servidor de tempo real…' }
},
computed: {
bannerClass () {
return this.status === 'error' ? 'ws-status-banner--error' : 'ws-status-banner--warning'
}
}
}
</script>
<style scoped>
.ws-status-banner {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 2000;
padding: 10px 16px;
text-align: center;
font-weight: bold;
color: #fff;
}
.ws-status-banner--warning {
background: #b8860b;
}
.ws-status-banner--error {
background: #b00020;
}
</style>

128
frontend/src/components/votacao-simbolica/VotacaoSimbolica.vue

@ -0,0 +1,128 @@
<template>
<fieldset class="form-group">
<legend>Votação Simbólica</legend>
<div v-if="error_message || localError" class="alert alert-danger">
[[ error_message || localError ]]
</div>
<div>
<b>Matéria:</b> [[ materia.texto ]]
<br />
<b>Ementa:</b> [[ materia.ementa ]]
<br /><br />
<b>Total presentes:</b> [[ totalPresentes ]] (com presidente)
<br />
<b>Total votantes:</b> [[ totalVotantes ]] (com presidente)
</div>
<br />
<div class="row">
<div class="col-md-4">Sim*:
<input type="number" min="0" v-model.number="votosSim" class="form-control" />
</div>
<div class="col-md-4">Não*:
<input type="number" min="0" v-model.number="votosNao" class="form-control" />
</div>
<div class="col-md-4">Abstenções*:
<input type="number" min="0" v-model.number="abstencoes" class="form-control" />
</div>
</div>
<div class="row mt-3">
<div class="col-md-6">
A totalização inclui o voto do Presidente?*
<select v-model="votoPresidente" class="form-control">
<option :value="1">Sim</option>
<option :value="0">Não</option>
</select>
</div>
<div class="col-md-6">
Resultado da Votação*
<select v-model="resultadoSelected" class="form-control">
<option value="">---------</option>
<option v-for="tipo in tipos_resultado" :key="tipo.id" :value="tipo.id">
[[ tipo.nome ]]
</option>
</select>
</div>
</div>
<div class="row mt-3">
<div class="col-md-12">
Observações
<textarea v-model="observacao" rows="7" class="form-control"></textarea>
</div>
</div>
<br />
<div class="row">
<div class="col-md-12">
<div class="form-group row justify-content-between">
<input type="button" value="Cancelar Votação" class="btn btn-warning" @click="$emit('cancelar')" />
<input type="button" value="Salvar" class="btn btn-primary" @click="onSalvar" />
</div>
</div>
</div>
</fieldset>
</template>
<script>
import { mapState } from 'pinia'
import { usePainelStore } from '@/__apps/painel/store/painelStore'
export default {
name: 'VotacaoSimbolica',
props: {
errorMessage: {
type: String,
default: ''
},
totalPresentes: {
type: [String, Number],
default: 0
},
totalVotantes: {
type: [String, Number],
default: 0
}
},
data () {
return {
votosSim: 0,
votosNao: 0,
abstencoes: 0,
votoPresidente: 0,
resultadoSelected: '',
observacao: '',
localError: ''
}
},
computed: {
...mapState(usePainelStore, ['materia', 'tipos_resultado']),
error_message () {
return this.errorMessage
}
},
methods: {
onSalvar () {
if (!this.resultadoSelected) {
this.localError = 'Selecione o resultado da votação antes de salvar.'
return
}
this.localError = ''
this.$emit('salvar', {
votos_sim: this.votosSim,
votos_nao: this.votosNao,
abstencoes: this.abstencoes,
voto_presidente: this.votoPresidente,
resultado_votacao: this.resultadoSelected,
observacao: this.observacao
})
}
},
mounted () {
console.log('VotacaoSimbolica mounted')
}
}
</script>

26
frontend/src/components/votacao/VotacaoMateria.vue

@ -0,0 +1,26 @@
<template>
<div v-if="materia && materia.texto">
<div>
Matéria: {{ materia.texto }}
<br />
Ementa: {{ materia.ementa }}
</div>
</div>
<div v-else class="alert alert-info">
Nenhuma matéria aberta para votação.
</div>
</template>
<script>
import { mapState } from 'pinia';
import { usePainelStore } from '@/__apps/painel/store/painelStore';
export default {
name: 'VotacaoMateria',
computed: {
...mapState(usePainelStore, ['materia'])
},
mounted() {
console.log('VotacaoMateria mounted');
}
};
</script>

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

@ -0,0 +1,78 @@
<template>
<fieldset>
<legend>Votação Nominal</legend>
<div v-if="error_message" class="alert alert-danger">
{{ error_message }}
</div>
<votacao-materia></votacao-materia>
<br />
<votacao-votos :votos-status="votosStatus" @cast-vote="onCastVote" ref="votos"></votacao-votos>
<votacao-resultado ref="resultado"></votacao-resultado>
<votacao-observacoes
:observacoes.sync="observacoes"
:resultado-selected.sync="resultado_selected"
:disabled="disable_resultado"
@cancelar="onCancelar"
@fechar="onFechar"
ref="observacoesComp">
</votacao-observacoes>
</fieldset>
</template>
<script>
import { mapState } from 'pinia';
import { usePainelStore } from '@/__apps/painel/store/painelStore';
export default {
name: 'VotacaoNominal',
props: {
isOpen: {
type: Boolean,
default: false
},
errorMessage: {
type: String,
default: ''
},
votosStatus: {
type: Object,
default: () => ({})
}
},
data() {
return {
edit_votes: true,
disable_resultado: false,
resultado_selected: '',
observacoes: '',
}
},
computed: {
...mapState(usePainelStore, ['parlamentares', 'materia']),
error_message() {
return this.errorMessage;
}
},
methods: {
onCastVote({ parlamentar_id, voto }) {
this.$emit('cast-vote', { parlamentar_id, voto });
},
onCancelar() {
this.$emit('cancelar');
},
onFechar() {
this.$emit('fechar', {
resultado_selected: this.resultado_selected,
observacoes: this.observacoes
});
}
},
mounted() {
console.log('VotacaoNominal mounted');
}
};
</script>

85
frontend/src/components/votacao/VotacaoObservacoes.vue

@ -0,0 +1,85 @@
<template>
<div v-if="parlamentares && parlamentares.length > 0">
<div class="row">
<div class="col-md-12">
<label for="resultado_votacao"><strong>Resultado da Votação</strong></label>
<select v-model="resultadoSelecionado" id="resultado_votacao" class="select form-control" :disabled="disabled">
<option value="">---------</option>
<option v-for="tipo in tipos_resultado"
:key="tipo.id"
:value="tipo.id">
{{ tipo.nome }}
</option>
</select>
</div>
</div>
<br />
<div class="row">
<div class="col-md-12">
Observações<br/>
<textarea id="observacao" v-model="textoObservacoes" style="width:100%;" rows="7"></textarea>
</div>
</div>
<br /><br />
<div class="row">
<div class="col-md-12">
<div class="form-group row justify-content-between">
<input type="button" id="cancelar-votacao" value="Cancelar Votação"
class="btn btn-warning" @click="$emit('cancelar')" />
<input type="button" id="salvar-votacao" value="Fechar Votação"
class="btn btn-primary" @click="$emit('fechar')" />
</div>
</div>
</div>
</div>
</template>
<script>
import { mapState } from 'pinia';
import { usePainelStore } from '@/__apps/painel/store/painelStore';
export default {
name: 'VotacaoObservacoes',
props: {
disabled: {
type: Boolean,
default: false
},
observacoes: {
type: String,
default: ''
},
resultadoSelected: {
type: [String, Number],
default: ''
}
},
data() {
return {
textoObservacoes: this.observacoes,
resultadoSelecionado: this.resultadoSelected,
}
},
watch: {
observacoes(val) {
this.textoObservacoes = val;
},
textoObservacoes(val) {
this.$emit('update:observacoes', val);
},
resultadoSelected(val) {
this.resultadoSelecionado = val;
},
resultadoSelecionado(val) {
this.$emit('update:resultadoSelected', val);
}
},
computed: {
...mapState(usePainelStore, ['parlamentares', 'tipos_resultado'])
},
mounted() {
console.log('VotacaoObservacoes mounted');
}
};
</script>

49
frontend/src/components/votacao/VotacaoResultado.vue

@ -0,0 +1,49 @@
<template>
<div v-if="parlamentares && parlamentares.length > 0">
<legend>Situação da Votação:</legend>
<div id="soma_votos">
<div class="row">
<div class="col-md-12">Sim: {{ votosSim }}</div>
</div>
<div class="row">
<div class="col-md-12">Não: {{ votosNao }}</div>
</div>
<div class="row">
<div class="col-md-12">Abstenções: {{ votosAbstencao }}</div>
</div>
<div class="row">
<div class="col-md-12">Ainda não votaram: {{ naoVotou }}</div>
</div>
</div>
</div>
</template>
<script>
import { mapState } from 'pinia';
import { usePainelStore } from '@/__apps/painel/store/painelStore';
export default {
name: 'VotacaoResultado',
computed: {
...mapState(usePainelStore, ['parlamentares', 'totalVotos']),
votosSim() {
const entry = this.totalVotos.find(t => t.tipo === 'Sim');
return entry ? entry.total : 0;
},
votosNao() {
const entry = this.totalVotos.find(t => t.tipo === 'Não');
return entry ? entry.total : 0;
},
votosAbstencao() {
const entry = this.totalVotos.find(t => t.tipo === 'Abstenção');
return entry ? entry.total : 0;
},
naoVotou() {
const entry = this.totalVotos.find(t => t.tipo === 'Não Votou');
return entry ? entry.total : 0;
}
},
mounted() {
console.log('VotacaoResultado mounted');
}
};
</script>

53
frontend/src/components/votacao/VotacaoVotos.vue

@ -0,0 +1,53 @@
<template>
<fieldset class="form-group" v-if="parlamentares && parlamentares.length > 0">
<legend>Votos</legend>
<div class="row">
<template v-for="p in parlamentares">
<div class="col-md-4" id="styleparlamentar" :key="'nome_' + p.parlamentar_id">
{{ p.nome_parlamentar }}
</div>
<div class="col-md-5" :key="'voto_' + p.parlamentar_id">
<select class="form-control"
:name="'voto_parlamentar_' + p.parlamentar_id"
:value="votosStatus[p.parlamentar_id] || 'Não Votou'"
@change="onVoteChange(p.parlamentar_id, $event.target.value)">
<option value="Não Votou">Não Votou</option>
<option value="Sim">Sim</option>
<option value="Não">Não</option>
<option value="Abstenção">Abstenção</option>
</select>
</div>
</template>
</div>
</fieldset>
<div class="alert alert-info alert-dismissible" role="alert" v-else>
<div>Não existe nenhum parlamentar presente para que a votação ocorra.</div>
</div>
</template>
<script>
import { mapState } from 'pinia';
import { usePainelStore } from '@/__apps/painel/store/painelStore';
export default {
name: 'VotacaoVotos',
props: {
// Estado real (não mascarado por mostrar_voto) de quem já votou, por
// parlamentar_id — nunca vem do store (visão pública do painel).
votosStatus: {
type: Object,
default: () => ({})
}
},
computed: {
...mapState(usePainelStore, ['parlamentares'])
},
methods: {
onVoteChange(parlamentar_id, voto) {
this.$emit('cast-vote', { parlamentar_id, voto });
}
},
mounted() {
console.log('VotacaoVotos mounted');
}
};
</script>

83
frontend/src/components/voto-individual/VotoIndividual.vue

@ -0,0 +1,83 @@
<template>
<div class="container-fluid text-center py-4">
<template v-if="!errorMessage">
<h1 class="mb-3">[[ sessaoPlenaria ]]</h1>
<h2 class="mb-3">
Voto:
<span :class="votoColorClass">[[ votoParlamentar ]]</span>
</h2>
<p v-if="statusMessage" class="text-info">[[ statusMessage ]]</p>
<div class="my-4">
<h3 class="mb-2">Matéria em Votação</h3>
<div class="fs-4">[[ materia.texto ]]</div>
<div class="fs-6 fst-italic">[[ materia.ementa ]]</div>
</div>
<form method="POST" :action="voteActionUrl">
<input type="hidden" name="csrfmiddlewaretoken" :value="csrfToken">
<div class="d-flex justify-content-center gap-3 flex-wrap">
<button type="submit" name="voto" value="Sim" class="btn btn-lg btn-success btn-voto">Sim</button>
<button type="submit" name="voto" value="Não" class="btn btn-lg btn-danger btn-voto">Não</button>
<button type="submit" name="voto" value="Abstenção" class="btn btn-lg btn-secondary btn-voto">Abstenção</button>
</div>
</form>
</template>
<template v-else>
<h1 class="my-5">⏸</h1>
<h2 class="text-warning">[[ errorMessage ]]</h2>
</template>
<div class="mt-5">
<button type="button" class="btn btn-lg btn-primary me-2" @click="reload">Atualizar</button>
<button type="button" class="btn btn-lg btn-secondary" @click="closeWindow">Sair</button>
</div>
</div>
</template>
<script>
import { mapState } from 'pinia'
import { usePainelStore } from '@/__apps/painel/store/painelStore'
export default {
name: 'VotoIndividual',
data () {
return {
materiaId: '',
errorMessage: '',
statusMessage: '',
votoParlamentar: '',
csrfToken: ''
}
},
computed: {
...mapState(usePainelStore, ['materia', 'sessao']),
sessaoPlenaria () {
return this.sessao && this.sessao.sessao_plenaria
},
voteActionUrl () {
return window.VOTO_INDIVIDUAL_URL
},
votoColorClass () {
if (this.votoParlamentar === 'Sim') return 'text-success'
if (this.votoParlamentar === 'Não') return 'text-danger'
if (this.votoParlamentar === 'Abstenção') return 'text-white'
return ''
}
},
mounted () {
const el = document.getElementById('csrf-token')
this.csrfToken = el ? el.value : ''
},
methods: {
reload () {
document.location.reload()
},
closeWindow () {
window.close()
}
}
}
</script>

14525
package-lock.json

File diff suppressed because it is too large

4
package.json

@ -9,6 +9,7 @@
}, },
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-free": "^6.1.2", "@fortawesome/fontawesome-free": "^6.1.2",
"@vue/composition-api": "^1.7.2",
"axios": "^1.7.2", "axios": "^1.7.2",
"bootstrap": "^4.6.2", "bootstrap": "^4.6.2",
"bootstrap-vue": "^2.22.0", "bootstrap-vue": "^2.22.0",
@ -20,9 +21,10 @@
"lodash": "^4.17.21", "lodash": "^4.17.21",
"moment": "^2.29.4", "moment": "^2.29.4",
"moment-locales-webpack-plugin": "^1.2.0", "moment-locales-webpack-plugin": "^1.2.0",
"pinia": "^2.1.7",
"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"
}, },
"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

1
requirements/test-requirements.txt

@ -6,5 +6,6 @@ isort==5.13.2
model-bakery==1.5.0 model-bakery==1.5.0
pycodestyle==2.12.1 pycodestyle==2.12.1
pytest==8.3.3 pytest==8.3.3
pytest-asyncio==0.24.0
pytest-cov==5.0.0 pytest-cov==5.0.0
WebTest==3.0.6 WebTest==3.0.6

23
sapl/api/serializers.py

@ -12,7 +12,7 @@ from rest_framework.fields import SerializerMethodField
from sapl.base.models import Autor, CasaLegislativa, Metadata from sapl.base.models import Autor, CasaLegislativa, Metadata
from sapl.parlamentares.models import Parlamentar, Mandato, Legislatura from sapl.parlamentares.models import Parlamentar, Mandato, Legislatura
from sapl.sessao.models import OrdemDia, SessaoPlenaria from sapl.sessao.models import ExpedienteMateria, OrdemDia, SessaoPlenaria
class SaplSerializerMixin(DrfAutoApiSerializerMixin): class SaplSerializerMixin(DrfAutoApiSerializerMixin):
@ -313,6 +313,27 @@ class SessaoPlenariaECidadaniaSerializer(serializers.ModelSerializer):
def get_sigla_orgao(self, obj): def get_sigla_orgao(self, obj):
return self.casa().sigla return self.casa().sigla
class OrdemDiaSerializer(SaplSerializerMixin):
"""
votacao_aberta/registro_aberto só podem ser alterados através do fluxo
de abrir_votacao()/VotacaoNominalAbstract (sapl/sessao/views.py), que
garante a invariante de no máximo uma matéria aberta por vez. Editáveis
aqui, a API contornaria essa garantia por completo.
"""
class Meta:
model = OrdemDia
fields = '__all__'
read_only_fields = ('votacao_aberta', 'registro_aberto')
class ExpedienteMateriaSerializer(SaplSerializerMixin):
"""Ver OrdemDiaSerializer — mesmo motivo para os mesmos dois campos."""
class Meta:
model = ExpedienteMateria
fields = '__all__'
read_only_fields = ('votacao_aberta', 'registro_aberto')
def get_nome_orgao(self, obj): def get_nome_orgao(self, obj):
return self.casa().nome return self.casa().nome

20
sapl/asgi.py

@ -0,0 +1,20 @@
import os
import django
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "sapl.settings")
django.setup()
from channels.auth import AuthMiddlewareStack # noqa: E402
from channels.http import AsgiHandler # noqa: E402
from channels.routing import ProtocolTypeRouter, URLRouter # noqa: E402
from django.urls import path # noqa: E402
from sapl.painel.consumers import PainelConsumer # noqa: E402
application = ProtocolTypeRouter({
"http": AsgiHandler(),
"websocket": AuthMiddlewareStack(URLRouter([
path("ws/painel/<int:sessao_id>/", PainelConsumer.as_asgi()),
])),
})

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;

101
sapl/painel/consumers.py

@ -0,0 +1,101 @@
import logging
from channels.db import database_sync_to_async
from channels.generic.websocket import AsyncJsonWebsocketConsumer
from sapl.painel.apps import AppConfig as PainelAppConfig
from sapl.sessao.models import SessaoPlenaria
logger = logging.getLogger(__name__)
class _ScopeRequest:
"""
Adaptador mínimo para reaproveitar build_dados_painel(request, pk) —
que lê request.user/request.session para o cronômetro — a partir do
scope de uma conexão WebSocket, que não é um HttpRequest de verdade.
AuthMiddlewareStack (usado em sapl/asgi.py) já popula scope['user'] e
scope['session'] a partir do cookie de sessão, então isso é só leitura,
igual ao que get_cronometro_status já fazia.
"""
def __init__(self, scope):
self.user = scope['user']
self.session = scope['session']
class PainelConsumer(AsyncJsonWebsocketConsumer):
"""
Um grupo por SessaoPlenaria (`sessao_<id>`) — painel público, tela de
voto individual (tablet) e tela de registro do operador conectam todos
aqui e recebem o mesmo payload completo (broadcast de estado inteiro,
não deltas — ver Parte 3 do plano de migração polling->push).
"""
async def connect(self):
self.sessao_id = self.scope['url_route']['kwargs']['sessao_id']
user = self.scope['user']
if not user.is_authenticated:
await self.close(code=4401)
return
if not await self._user_pode_conectar(user):
await self.close(code=4403)
return
if not await self._sessao_existe(self.sessao_id):
await self.close(code=4404)
return
self.group_name = 'sessao_{}'.format(self.sessao_id)
await self.channel_layer.group_add(self.group_name, self.channel_name)
await self.accept()
payload = await self._build_dados_painel(self.sessao_id)
await self.send_json({'type': 'data', 'payload': payload})
async def disconnect(self, close_code):
if hasattr(self, 'group_name'):
await self.channel_layer.group_discard(self.group_name, self.channel_name)
async def receive_json(self, content, **kwargs):
if content.get('type') == 'ping':
await self.send_json({'type': 'pong'})
async def data(self, event):
await self.send_json({'type': 'data', 'payload': event['payload']})
@database_sync_to_async
def _build_dados_painel(self, sessao_id):
# import local para evitar ciclo de import no carregamento do ASGI
# (sapl.painel.views importa vários modelos/decorators de request
# HTTP que não precisam existir antes do Django estar totalmente
# pronto, o que já é garantido aqui por database_sync_to_async
# rodar depois de django.setup() em sapl/asgi.py).
from sapl.painel.views import build_dados_painel
return build_dados_painel(_ScopeRequest(self.scope), sessao_id)
@database_sync_to_async
def _user_pode_conectar(self, user):
"""
Aceita qualquer usuário que já teria acesso a alguma das três telas
que este consumer substitui — não há um único modelo de permissão,
já que painel público, vereador votando e operador registrando têm
gates diferentes hoje: check_permission do módulo painel (usado por
get_dados_painel), parlamentares.can_vote (usado por votante_view),
e permissão do módulo sessao (usado por SessaoPermissionMixin na
tela de registro do operador). Substitui o stub `return True` da
referência em feat/painel-votacao-v2.
"""
if user.has_module_perms(PainelAppConfig.label):
return True
if user.has_perm('parlamentares.can_vote'):
return True
if user.has_module_perms('sessao'):
return True
return False
@database_sync_to_async
def _sessao_existe(self, sessao_id):
return SessaoPlenaria.objects.filter(id=sessao_id).exists()

253
sapl/painel/tests/test_broadcasts.py

@ -0,0 +1,253 @@
"""
Prova que as views que alteram voto/matéria (Parte 3 do plano) realmente
disparam um broadcast que chega no grupo WebSocket certo — complementa
test_consumers.py (que testa o consumer isolado) e os testes em
sapl/painel/tests/tests.py que já confirmam a chamada não quebra a view
quando o layer não está disponível (Redis fora do ar).
"""
from datetime import timedelta
import pytest
from asgiref.sync import sync_to_async
from channels.testing import WebsocketCommunicator
from django.contrib.auth import get_user_model
from django.test import Client
from django.urls import reverse
from model_bakery import baker
from sapl.base.models import AppConfig as ConfiguracoesAplicacao
from sapl.painel.consumers import PainelConsumer
from sapl.painel.tests.tests import (NOMINAL, _materia, _ordem_nominal_aberta,
_presente_com_mandato, _registrar_url,
_votante_com_client)
from sapl.parlamentares.models import Mandato, Parlamentar
from sapl.sessao.models import Orador, OrdemDia, PresencaOrdemDia, TipoResultadoVotacao
IN_MEMORY_LAYER = {
'default': {'BACKEND': 'channels.layers.InMemoryChannelLayer'},
}
@pytest.fixture(autouse=True)
def in_memory_channel_layer(settings):
settings.CHANNEL_LAYERS = IN_MEMORY_LAYER
@pytest.fixture(autouse=True)
def locmem_cache(settings):
# cronômetro (último trigger point) vive no cache padrão do projeto,
# que é file-based e persiste em disco entre runs — isola aqui do
# mesmo jeito que o layer acima isola o Redis real.
settings.CACHES = {
'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'},
}
@sync_to_async
def _setup_ordem_aberta():
baker.make(ConfiguracoesAplicacao, mostrar_voto=True, mostrar_brasao_painel=False)
return _ordem_nominal_aberta()
@sync_to_async
def _votante(sessao):
parlamentar, client = _votante_com_client(sessao)
# get_presentes() (Parte 1) só inclui quem tem Mandato pra legislatura
# da sessão — _votante_com_client() não cria um, então sem isso o
# parlamentar nunca apareceria em `presentes`, com ou sem WebSockets.
baker.make(Mandato, parlamentar=parlamentar, legislatura=sessao.legislatura,
data_inicio_mandato=sessao.data_inicio)
return parlamentar, client
@sync_to_async
def _admin_client_login():
User = get_user_model()
user = User.objects.filter(is_superuser=True).first()
if not user:
user = User.objects.create_superuser('admin-ws', 'admin@x.com', 'x')
client = Client()
client.force_login(user)
return client, user
async def _painel_communicator(sessao_id, admin_user):
communicator = WebsocketCommunicator(
PainelConsumer.as_asgi(), '/ws/painel/{}/'.format(sessao_id))
communicator.scope['user'] = admin_user
communicator.scope['session'] = {}
communicator.scope['url_route'] = {'kwargs': {'sessao_id': sessao_id}}
connected, _sub = await communicator.connect()
assert connected
await communicator.receive_json_from() # payload inicial, descarta
return communicator
@pytest.mark.django_db(transaction=False)
@pytest.mark.asyncio
async def test_voto_do_tablet_dispara_broadcast_com_voto_atualizado():
# Limpeza explícita no final, em vez de confiar no rollback atômico do
# pytest-django: channels.db.database_sync_to_async despacha pro
# worker thread "thread sensitive" do asgiref, que pega sua própria
# conexão nova com o banco (autocommit) — fora da savepoint que o
# fixture `django_db` abriu na thread principal. Sem isso, cada teste
# aqui commitaria de verdade uma OrdemDia com votacao_aberta=True que
# nunca seria desfeita, quebrando a constraint única pro próximo teste
# (confirmado: aconteceu, a fileira ficava só na base de testes,
# invisível numa conexão nova, mas viva pro resto da run do pytest).
sessao, ordem = await _setup_ordem_aberta()
try:
admin_client, admin_user = await _admin_client_login()
parlamentar, votante_client = await _votante(sessao)
communicator = await _painel_communicator(sessao.pk, admin_user)
await sync_to_async(votante_client.post)(
reverse('sapl.painel:voto_individual'), {'voto': 'Sim'})
mensagem = await communicator.receive_json_from()
assert mensagem['type'] == 'data'
votos = {p['parlamentar_id']: p['voto'] for p in mensagem['payload']['presentes']}
assert votos[parlamentar.pk] == 'Sim'
await communicator.disconnect()
finally:
await sync_to_async(sessao.delete)()
@pytest.mark.django_db(transaction=False)
@pytest.mark.asyncio
async def test_encerrar_votacao_dispara_broadcast():
sessao, ordem = await _setup_ordem_aberta()
try:
admin_client, admin_user = await _admin_client_login()
parlamentar, votante_client = await _votante(sessao)
tipo_resultado = await sync_to_async(baker.make)(
TipoResultadoVotacao, nome='Aprovada', natureza='A')
communicator = await _painel_communicator(sessao.pk, admin_user)
await sync_to_async(admin_client.post)(_registrar_url(sessao, ordem), {
'salvar-votacao': '1',
'resultado_votacao': str(tipo_resultado.pk),
'observacao': '',
'voto_parlamentar': ['Sim:{}'.format(parlamentar.pk)],
})
mensagem = await communicator.receive_json_from()
assert mensagem['type'] == 'data'
assert mensagem['payload']['numero_votos_sim'] == 1
assert mensagem['payload']['registro'] is True
await communicator.disconnect()
finally:
await sync_to_async(sessao.delete)()
@pytest.mark.django_db(transaction=False)
@pytest.mark.asyncio
async def test_abrir_votacao_dispara_broadcast():
sessao, ordem_antiga = await _setup_ordem_aberta()
try:
admin_client, admin_user = await _admin_client_login()
def _prepara():
ordem_antiga.votacao_aberta = False
ordem_antiga.save()
sessao.iniciada = True
sessao.finalizada = False
sessao.save()
baker.make(PresencaOrdemDia, sessao_plenaria=sessao,
parlamentar=baker.make(Parlamentar, ativo=True))
return baker.make(OrdemDia, sessao_plenaria=sessao, materia=_materia(),
tipo_votacao=NOMINAL, votacao_aberta=False)
nova_ordem = await sync_to_async(_prepara)()
communicator = await _painel_communicator(sessao.pk, admin_user)
url = reverse('sapl.sessao:abrir_votacao', kwargs={'pk': nova_ordem.pk, 'spk': sessao.pk})
url += '?tipo_materia=ordem'
await sync_to_async(admin_client.get)(url)
mensagem = await communicator.receive_json_from()
assert mensagem['type'] == 'data'
await communicator.disconnect()
finally:
await sync_to_async(sessao.delete)()
@pytest.mark.django_db(transaction=False)
@pytest.mark.asyncio
async def test_cronometro_dispara_broadcast_com_valor_atualizado():
sessao, ordem = await _setup_ordem_aberta()
try:
def _abre_painel():
sessao.painel_aberto = True
sessao.save()
await sync_to_async(_abre_painel)()
admin_client, admin_user = await _admin_client_login()
communicator = await _painel_communicator(sessao.pk, admin_user)
await sync_to_async(admin_client.get)(
reverse('sapl.painel:cronometro_painel'),
{'tipo': 'discurso', 'action': 'start'})
mensagem = await communicator.receive_json_from()
assert mensagem['type'] == 'data'
assert mensagem['payload']['cronometro_discurso'] == 'start'
await communicator.disconnect()
finally:
await sync_to_async(sessao.delete)()
@pytest.mark.django_db(transaction=False)
@pytest.mark.asyncio
async def test_criar_e_apagar_orador_disparam_broadcast():
"""
BroadcastPainelOnSaveMixin (sapl/sessao/views.py) — sem isso, oradores
só chegavam ao painel no próximo poll (agora removido). Cobre tanto
form_valid() (create) quanto delete(), incluindo o caso em que
self.kwargs['pk'] em DeleteView é o pk do próprio Orador, não o da
SessaoPlenaria (por isso o mixin lê self.object.sessao_plenaria_id em
vez de kwargs — ver a docstring do mixin).
"""
sessao, ordem = await _setup_ordem_aberta()
try:
admin_client, admin_user = await _admin_client_login()
def _prepara():
return _presente_com_mandato(
sessao,
data_inicio_mandato=sessao.data_inicio - timedelta(days=365),
data_fim_mandato=sessao.data_inicio + timedelta(days=365))
parlamentar = await sync_to_async(_prepara)()
communicator = await _painel_communicator(sessao.pk, admin_user)
create_url = reverse('sapl.sessao:orador_create', kwargs={'pk': sessao.pk})
response = await sync_to_async(admin_client.post)(create_url, {
'parlamentar': str(parlamentar.pk),
'numero_ordem': '1',
})
assert response.status_code == 302
mensagem = await communicator.receive_json_from()
assert mensagem['type'] == 'data'
orador = await sync_to_async(
lambda: Orador.objects.get(sessao_plenaria=sessao, parlamentar=parlamentar)
)()
delete_url = reverse('sapl.sessao:orador_delete', kwargs={'pk': orador.pk})
response = await sync_to_async(admin_client.post)(delete_url)
assert response.status_code == 302
mensagem = await communicator.receive_json_from()
assert mensagem['type'] == 'data'
await communicator.disconnect()
finally:
await sync_to_async(sessao.delete)()

157
sapl/painel/tests/test_consumers.py

@ -0,0 +1,157 @@
import uuid
import pytest
from asgiref.sync import sync_to_async
from channels.layers import get_channel_layer
from channels.testing import WebsocketCommunicator
from django.contrib.auth import get_user_model
from django.contrib.auth.models import AnonymousUser, Permission
from model_bakery import baker
from sapl.base.models import AppConfig as ConfiguracoesAplicacao
from sapl.painel.consumers import PainelConsumer
from sapl.parlamentares.models import Legislatura, SessaoLegislativa
from sapl.sessao.models import SessaoPlenaria, TipoSessaoPlenaria
# Layer em memória para os testes — group_send/group_add não precisam de um
# Redis de verdade rodando; isso é o padrão recomendado pelo próprio
# Channels para testes (a validação end-to-end com Redis de verdade fica
# para a verificação manual em navegador, ver Parte 3 do plano).
#
# Usado via a fixture `settings` do pytest-django (autouse abaixo) em vez
# de @override_settings como decorator: neste Django 2.2 (sem suporte
# nativo a testes async), override_settings como decorator envolve a
# função com um wrapper síncrono que faz pytest-asyncio deixar de
# reconhecer a test function como uma coroutine — os testes eram
# silenciosamente pulados (PytestUnhandledCoroutineWarning) em vez de
# rodar.
IN_MEMORY_LAYER = {
'default': {'BACKEND': 'channels.layers.InMemoryChannelLayer'},
}
@pytest.fixture(autouse=True)
def in_memory_channel_layer(settings):
settings.CHANNEL_LAYERS = IN_MEMORY_LAYER
@sync_to_async
def _make_sessao():
baker.make(ConfiguracoesAplicacao, mostrar_voto=True, mostrar_brasao_painel=False)
legislatura = baker.make(Legislatura)
sessao_legislativa = baker.make(SessaoLegislativa)
tipo = baker.make(TipoSessaoPlenaria)
return baker.make(SessaoPlenaria, legislatura=legislatura,
sessao_legislativa=sessao_legislativa, tipo=tipo, numero=1)
@sync_to_async
def _make_user(module_perms=None):
user = get_user_model().objects.create_user(
username='ws-user-{}'.format(uuid.uuid4().hex[:12]), password='x')
if module_perms:
user.user_permissions.add(
*Permission.objects.filter(content_type__app_label=module_perms))
return user
async def _communicator(sessao_id, user, session=None):
communicator = WebsocketCommunicator(
PainelConsumer.as_asgi(), '/ws/painel/{}/'.format(sessao_id))
communicator.scope['user'] = user
communicator.scope['session'] = session if session is not None else {}
communicator.scope['url_route'] = {'kwargs': {'sessao_id': sessao_id}}
return communicator
@pytest.mark.django_db(transaction=False)
@pytest.mark.asyncio
async def test_connect_autenticado_e_permitido_recebe_payload_inicial():
sessao = await _make_sessao()
user = await _make_user(module_perms='painel')
communicator = await _communicator(sessao.pk, user)
connected, _subprotocol = await communicator.connect()
assert connected
primeira_mensagem = await communicator.receive_json_from()
assert primeira_mensagem['type'] == 'data'
assert 'sessao_plenaria' in primeira_mensagem['payload']
await communicator.disconnect()
@pytest.mark.django_db(transaction=False)
@pytest.mark.asyncio
async def test_connect_nao_autenticado_e_rejeitado():
sessao = await _make_sessao()
communicator = await _communicator(sessao.pk, AnonymousUser())
connected, close_code = await communicator.connect()
assert not connected
assert close_code == 4401
@pytest.mark.django_db(transaction=False)
@pytest.mark.asyncio
async def test_connect_autenticado_sem_nenhuma_permissao_e_rejeitado():
sessao = await _make_sessao()
user = await _make_user(module_perms=None)
communicator = await _communicator(sessao.pk, user)
connected, close_code = await communicator.connect()
assert not connected
assert close_code == 4403
@pytest.mark.django_db(transaction=False)
@pytest.mark.asyncio
async def test_connect_sessao_inexistente_e_rejeitado_sem_excecao_nao_tratada():
user = await _make_user(module_perms='painel')
communicator = await _communicator(999999, user)
connected, close_code = await communicator.connect()
assert not connected
assert close_code == 4404
@pytest.mark.django_db(transaction=False)
@pytest.mark.asyncio
async def test_broadcast_no_grupo_da_sessao_e_recebido_pelo_cliente_conectado():
sessao = await _make_sessao()
user = await _make_user(module_perms='painel')
communicator = await _communicator(sessao.pk, user)
connected, _subprotocol = await communicator.connect()
assert connected
await communicator.receive_json_from() # payload inicial, descarta
layer = get_channel_layer()
await layer.group_send('sessao_{}'.format(sessao.pk),
{'type': 'data', 'payload': {'foo': 'bar'}})
mensagem = await communicator.receive_json_from()
assert mensagem == {'type': 'data', 'payload': {'foo': 'bar'}}
await communicator.disconnect()
@pytest.mark.django_db(transaction=False)
@pytest.mark.asyncio
async def test_ping_recebe_pong():
sessao = await _make_sessao()
user = await _make_user(module_perms='painel')
communicator = await _communicator(sessao.pk, user)
connected, _subprotocol = await communicator.connect()
assert connected
await communicator.receive_json_from() # payload inicial, descarta
await communicator.send_json_to({'type': 'ping'})
resposta = await communicator.receive_json_from()
assert resposta == {'type': 'pong'}
await communicator.disconnect()

270
sapl/painel/tests/tests.py

@ -1,3 +1,269 @@
# from django.test import TestCase import datetime
# Create your tests here. import pytest
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Permission
from django.test import Client
from django.urls import reverse
from model_bakery import baker
from sapl.base.models import AppConfig as ConfiguracoesAplicacao
from sapl.materia.models import MateriaLegislativa, TipoMateriaLegislativa
from sapl.painel.views import build_dados_painel
from sapl.parlamentares.models import (Filiacao, Legislatura, Mandato,
Parlamentar, Partido,
SessaoLegislativa, Votante)
from sapl.sessao.models import (OrdemDia, PresencaOrdemDia, SessaoPlenaria,
TipoResultadoVotacao, TipoSessaoPlenaria,
VotoParlamentar)
NOMINAL = 2
def _sessao_plenaria(data_inicio=None):
legislatura = baker.make(Legislatura)
sessao_legislativa = baker.make(SessaoLegislativa)
tipo = baker.make(TipoSessaoPlenaria)
kwargs = {}
if data_inicio is not None:
kwargs['data_inicio'] = data_inicio
return baker.make(SessaoPlenaria, legislatura=legislatura,
sessao_legislativa=sessao_legislativa, tipo=tipo, numero=1,
**kwargs)
def _presente_com_mandato(sessao, data_inicio_mandato, data_fim_mandato=None,
partido_sigla=None, filiacao_data=None):
"""
Como _presente() (que ainda não existia neste arquivo antes desta
regressão), mas cria também um Mandato — nenhuma fixture deste arquivo
fazia isso antes, então nenhum teste aqui de fato exercia o ramo de
get_presentes() que confere `mandato_set.filter(legislatura=...)`. É
exatamente esse ramo que Milestone 1 troca por uma view SQL, então
precisa de cobertura própria antes de qualquer refatoração.
"""
parlamentar = baker.make(Parlamentar, ativo=True)
baker.make(PresencaOrdemDia, sessao_plenaria=sessao, parlamentar=parlamentar)
baker.make(Mandato, parlamentar=parlamentar, legislatura=sessao.legislatura,
data_inicio_mandato=data_inicio_mandato,
data_fim_mandato=data_fim_mandato)
if partido_sigla:
partido = baker.make(Partido, sigla=partido_sigla)
baker.make(Filiacao, parlamentar=parlamentar, partido=partido,
data=filiacao_data or data_inicio_mandato)
return parlamentar
def _materia():
tipo_materia = baker.make(TipoMateriaLegislativa)
return baker.make(MateriaLegislativa, tipo=tipo_materia)
def _ordem_nominal_aberta(registro_aberto=False):
sessao = _sessao_plenaria()
materia = _materia()
ordem = baker.make(OrdemDia, sessao_plenaria=sessao, materia=materia,
tipo_votacao=NOMINAL, votacao_aberta=True,
registro_aberto=registro_aberto)
return sessao, ordem
def _votante(sessao, admin_user):
parlamentar = baker.make(Parlamentar, ativo=True)
baker.make(PresencaOrdemDia, sessao_plenaria=sessao, parlamentar=parlamentar)
baker.make(Votante, parlamentar=parlamentar, user=admin_user)
return parlamentar
def _votante_com_client(sessao):
"""
Como _votante(), mas cria seu próprio usuário (não-superuser, só com a
permissão parlamentares.can_vote) e devolve um Client logado separado —
necessário para testar a corrida entre o operador e o vereador, que
precisam ser duas sessões/usuários distintos.
"""
parlamentar = baker.make(Parlamentar, ativo=True)
baker.make(PresencaOrdemDia, sessao_plenaria=sessao, parlamentar=parlamentar)
user = get_user_model().objects.create_user(
username='votante-{}'.format(parlamentar.pk), password='x')
user.user_permissions.add(Permission.objects.get(codename='can_vote'))
baker.make(Votante, parlamentar=parlamentar, user=user)
client = Client()
client.force_login(user)
return parlamentar, client
def _registrar_url(sessao, ordem):
return reverse('sapl.sessao:votacaonominal',
kwargs={'pk': sessao.pk, 'oid': ordem.pk, 'mid': ordem.materia_id})
@pytest.mark.django_db(transaction=False)
def test_votante_view_envia_headers_never_cache(admin_client, admin_user):
sessao, ordem = _ordem_nominal_aberta()
_votante(sessao, admin_user)
response = admin_client.get(reverse('sapl.painel:voto_individual'))
assert response.status_code == 200
assert 'no-store' in response['Cache-Control']
@pytest.mark.django_db(transaction=False)
def test_votante_view_mostra_materia_quando_registro_fechado(admin_client, admin_user):
sessao, ordem = _ordem_nominal_aberta(registro_aberto=False)
_votante(sessao, admin_user)
response = admin_client.get(reverse('sapl.painel:voto_individual'))
assert response.status_code == 200
assert 'error_message' not in response.context
assert response.context['materia'] == ordem.materia
@pytest.mark.django_db(transaction=False)
def test_votante_view_mostra_erro_explicito_quando_registro_bloqueado(admin_client, admin_user):
"""
Regressão da causa raiz #1: quando a Mesa bloqueia novos votos
(registro_aberto=True), o vereador que ainda não votou precisa ver uma
mensagem explícita — não uma tela em branco sem explicação.
"""
sessao, ordem = _ordem_nominal_aberta(registro_aberto=True)
_votante(sessao, admin_user)
response = admin_client.get(reverse('sapl.painel:voto_individual'))
assert response.status_code == 200
assert 'Mesa encerrou o recebimento de novos votos' in response.context['error_message']
@pytest.mark.django_db(transaction=False)
def test_voto_do_vereador_prevalece_sobre_lote_do_operador(admin_client):
"""
Invariante: o voto do próprio vereador sempre prevalece sobre qualquer
valor provisório já existente para ele (ex.: de uma tentativa anterior
do operador, ou de qualquer outra origem). Complementa
test_salvar_votacao_nao_sobrescreve_voto_ja_registrado (que cobre a
ordem inversa: o operador não pode sobrescrever um voto real já
registrado) — aqui é o vereador votando por cima de um valor existente
através da view de verdade, não apenas o estado inicial simulado.
"""
sessao, ordem = _ordem_nominal_aberta()
vereador, votante_client = _votante_com_client(sessao)
# 1) Já existe um valor provisório "Não Votou" para o vereador (ex.: o
# <select> nunca foi tocado pelo operador). Esse valor nunca deveria
# impedir o vereador de votar de verdade.
baker.make(VotoParlamentar, ordem=ordem, parlamentar=vereador,
voto='Não Votou')
# 2) O vereador vota pelo tablet — seu voto real prevalece sobre o
# valor provisório.
response = votante_client.post(
reverse('sapl.painel:voto_individual'), {'voto': 'Sim'})
assert response.status_code == 302
voto = VotoParlamentar.objects.get(ordem=ordem, parlamentar=vereador)
assert voto.voto == 'Sim'
@pytest.mark.django_db(transaction=False)
def test_propria_tela_nao_mostra_voto_de_outra_materia(admin_client):
"""
Regressão: votacao() buscava o voto do próprio vereador com
Q(ordem=ordem_dia) | Q(expediente=expediente) — quando a matéria atual
é uma OrdemDia, expediente é None, e Q(expediente=None) vira
"expediente_id IS NULL" no SQL, que é verdadeiro para QUALQUER voto de
ordem do dia daquele vereador, não só o desta matéria (idem para
ExpedienteMateria, cujas votações também têm ordem=None). Com .first()
sem ordenação, o vereador podia ver o voto de uma matéria antiga em vez
do voto (ou ausência de voto) da matéria atual — inclusive depois de
trocar o próprio voto, já que a query buscava a linha errada.
"""
sessao, ordem_antiga = _ordem_nominal_aberta()
vereador, votante_client = _votante_com_client(sessao)
baker.make(VotoParlamentar, ordem=ordem_antiga, parlamentar=vereador, voto='Sim')
ordem_antiga.votacao_aberta = False
ordem_antiga.save()
baker.make(OrdemDia, sessao_plenaria=sessao, materia=_materia(),
tipo_votacao=NOMINAL, votacao_aberta=True, registro_aberto=False)
baker.make(PresencaOrdemDia, sessao_plenaria=sessao, parlamentar=vereador)
status_url = reverse('sapl.painel:voto_individual_status')
# Ainda não votou na matéria atual — não pode herdar o 'Sim' da antiga.
assert votante_client.get(status_url).json()['voto_parlamentar'] is None
votante_client.post(reverse('sapl.painel:voto_individual'), {'voto': 'Não'})
assert votante_client.get(status_url).json()['voto_parlamentar'] == 'Não'
assert VotoParlamentar.objects.get(
ordem=ordem_antiga, parlamentar=vereador).voto == 'Sim'
@pytest.fixture
def locmem_cache(settings):
"""
O cache padrão do projeto é file-based (persiste em disco entre runs de
teste) — cronômetro agora vive nesse cache, então sem isolar aqui os
testes abaixo vazariam estado real pro cache de dev compartilhado.
"""
settings.CACHES = {
'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'},
}
@pytest.mark.django_db(transaction=False)
def test_cronometro_e_compartilhado_entre_sessoes_diferentes(locmem_cache, admin_client):
"""
Regressão: get_cronometro_status() lia de request.session — o
Presidente apertando "iniciar" no cronômetro só mudava o que ELE via;
o telão público (outra sessão/browser) nunca via a mudança. Agora o
estado fica em cache compartilhado, então uma segunda sessão/cliente
completamente separada precisa ver o mesmo valor.
Lê o resultado via build_dados_painel() diretamente (não mais pelo
endpoint HTTP get_dados_painel/dados_painel, removido com o polling —
ninguém mais o chama por HTTP; o mesmo build_dados_painel() é o que os
broadcasts via WebSocket usam, então continua sendo o ponto de
verdade certo pra este teste). request=None é seguro aqui:
build_dados_painel() nunca lê nada de request, só repassa adiante para
get_cronometro_status(), que também o ignora e lê do cache direto.
"""
sessao = _sessao_plenaria()
baker.make(ConfiguracoesAplicacao, mostrar_voto=True, mostrar_brasao_painel=False)
admin_client.get(reverse('sapl.painel:cronometro_painel'),
{'tipo': 'discurso', 'action': 'start'})
data = build_dados_painel(None, sessao.pk)
assert data['cronometro_discurso'] == 'start'
@pytest.mark.django_db(transaction=False)
def test_votante_status_reflete_estado_e_nao_exige_permissao_do_painel():
"""
votante_status precisa ser alcançável por uma conta só-Votante (sem
nenhuma permissão do app painel) — é por isso que tem sua própria
checagem de permissão (parlamentares.can_vote) em vez de reaproveitar
check_permission (permissão de módulo do app painel).
"""
sessao, ordem = _ordem_nominal_aberta()
vereador, votante_client = _votante_com_client(sessao)
status_url = reverse('sapl.painel:voto_individual_status')
resposta = votante_client.get(status_url)
assert resposta.status_code == 200
data = resposta.json()
assert data['materia_id'] == ordem.materia_id
assert data['status_message'] == 'Aguardando seu voto.'
assert data['voto_parlamentar'] is None
votante_client.post(reverse('sapl.painel:voto_individual'), {'voto': 'Não'})
resposta2 = votante_client.get(status_url)
data2 = resposta2.json()
assert data2['voto_parlamentar'] == 'Não'
assert 'encerramento da votação' in data2['status_message']

27
sapl/painel/urls.py

@ -1,22 +1,19 @@
from django.conf.urls import url from django.conf.urls import url
from django.urls import path
from .apps import AppConfig from .apps import AppConfig
from .views import (cronometro_painel, get_dados_painel, painel_mensagem_view, from .views import (cronometro_painel, painel_view,
painel_parlamentar_view, painel_view, painel_votacao_view, switch_painel, verifica_painel, votante_status, votante_view,
switch_painel, verifica_painel, votante_view) votante_view_v2, websocket_view, vote_controller,
cancel_voting, close_voting, votos_status)
app_name = AppConfig.name app_name = AppConfig.name
urlpatterns = [ urlpatterns = [
url(r'^painel-principal/(?P<pk>\d+)$', painel_view, url(r'^painel-principal/(?P<pk>\d+)$', painel_view,
name="painel_principal"), name="painel_principal"),
url(r'^painel/(?P<pk>\d+)/dados$', get_dados_painel, name='dados_painel'),
url(r'^painel/mensagem$', painel_mensagem_view, name="painel_mensagem"),
url(r'^painel/parlamentar$', painel_parlamentar_view,
name='painel_parlamentar'),
url(r'^painel/switch-painel$', switch_painel, url(r'^painel/switch-painel$', switch_painel,
name="switch_painel"), name="switch_painel"),
url(r'^painel/votacao$', painel_votacao_view, name='painel_votacao'),
url(r'^painel/verifica-painel$', verifica_painel, url(r'^painel/verifica-painel$', verifica_painel,
name="verifica_painel"), name="verifica_painel"),
url(r'^painel/cronometro$', cronometro_painel, name='cronometro_painel'), url(r'^painel/cronometro$', cronometro_painel, name='cronometro_painel'),
@ -24,4 +21,18 @@ urlpatterns = [
url(r'^voto-individual/$', votante_view, url(r'^voto-individual/$', votante_view,
name='voto_individual'), name='voto_individual'),
url(r'^voto-individual/status$', votante_status,
name='voto_individual_status'),
url(r'^voto-individual/v2$', votante_view_v2,
name='voto_individual_v2'),
path("v2/painel/<int:controller_id>", websocket_view, name='painel_websocket'),
path("v2/painel/controller/<int:controller_id>/vote",
vote_controller, name='vote_controller'),
path("v2/painel/controller/<int:controller_id>/cancel",
cancel_voting, name='cancel_voting'),
path("v2/painel/controller/<int:controller_id>/close",
close_voting, name='close_voting'),
path("v2/painel/controller/<int:controller_id>/votos-status",
votos_status, name='votos_status'),
] ]

765
sapl/painel/views.py

@ -5,7 +5,9 @@ import logging
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.decorators import (login_required, permission_required, from django.contrib.auth.decorators import (login_required, permission_required,
user_passes_test) user_passes_test)
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied from django.core.cache import cache
from django.core.exceptions import PermissionDenied
from django.db import IntegrityError, transaction
from django.urls import reverse from django.urls import reverse
from django.db.models import Q from django.db.models import Q
from django.http import HttpResponse, JsonResponse from django.http import HttpResponse, JsonResponse
@ -22,9 +24,11 @@ from sapl.painel.apps import AppConfig
from sapl.parlamentares.models import Legislatura, Parlamentar, Votante from sapl.parlamentares.models import Legislatura, Parlamentar, Votante
from sapl.sessao.models import (ExpedienteMateria, OradorExpediente, OrdemDia, from sapl.sessao.models import (ExpedienteMateria, OradorExpediente, OrdemDia,
PresencaOrdemDia, RegistroVotacao, PresencaOrdemDia, RegistroVotacao,
SessaoPlenaria, SessaoPlenariaPresenca, SessaoMateriasVotacoesView, SessaoPlenaria,
SessaoPlenariaPresenca, SessaoPresencasView,
VotoParlamentar, RegistroLeitura) VotoParlamentar, RegistroLeitura)
from sapl.utils import filiacao_data, get_client_ip, sort_lista_chave from sapl.utils import get_client_ip, sort_lista_chave
from image_cropping.utils import get_backend
from .models import Cronometro from .models import Cronometro
@ -98,41 +102,35 @@ def votacao_aberta(request):
def votacao(context, context_vars): def votacao(context, context_vars):
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
parlamentar = context_vars['votante'].parlamentar parlamentar = context_vars['votante'].parlamentar
parlamentar_presente = False
if parlamentar.id in context_vars['presentes']: if parlamentar.id not in context_vars['presentes']:
parlamentar_presente = True logger.error("Parlamentar com id={} não está presente na "
context_vars.update({'parlamentar': parlamentar}) "Ordem do Dia/Expediente em votação.".format(parlamentar.id))
else:
context.update({'error_message': context.update({'error_message':
'Não há presentes na Sessão com a ' 'Você não está presente na '
'matéria em votação.'}) 'Ordem do Dia/Expediente em votação.'})
return context, context_vars
context_vars.update({'parlamentar': parlamentar})
if parlamentar_presente:
voto = []
if context_vars['ordem_dia']: if context_vars['ordem_dia']:
voto = VotoParlamentar.objects.filter( voto = VotoParlamentar.objects.filter(
ordem=context_vars['ordem_dia']) ordem=context_vars['ordem_dia'], parlamentar=parlamentar).first()
elif context_vars['expediente']: elif context_vars['expediente']:
voto = VotoParlamentar.objects.filter( voto = VotoParlamentar.objects.filter(
expediente=context_vars['expediente']) expediente=context_vars['expediente'], parlamentar=parlamentar).first()
else:
voto = None
if voto: if voto:
try: context.update({
logger.debug( 'voto_parlamentar': voto.voto,
"Tentando obter objeto VotoParlamentar com parlamentar={}.".format(context_vars['parlamentar'])) 'status_message': 'Voto registrado. Aguardando o encerramento '
voto = voto.get(parlamentar=context_vars['parlamentar']) 'da votação pela Mesa.',
context.update({'voto_parlamentar': voto.voto}) })
except ObjectDoesNotExist:
logger.error("Voto do parlamentar {} não computado.".format(context_vars['parlamentar']))
context.update(
{'voto_parlamentar': 'Voto não '
'computado.'})
else: else:
logger.error("Parlamentar com id={} não está presente na " context.update({'status_message': 'Aguardando seu voto.'})
"Ordem do Dia/Expediente em votação.".format(parlamentar.id))
context.update({'error_message':
'Você não está presente na '
'Ordem do Dia/Expediente em votação.'})
return context, context_vars return context, context_vars
@ -171,8 +169,11 @@ def sessao_votacao(context, context_vars):
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 Mesa encerrou o recebimento de novos votos '
'tipo': 'A matéria aberta não é do tipo votação nominal.'} 'para apurar o resultado desta matéria. '
'Aguarde a próxima matéria.',
'tipo': 'Esta matéria não é votada individualmente pelos '
'tablets — a Mesa registra o resultado diretamente.'}
materia_aberta = None materia_aberta = None
if ordem_dia: if ordem_dia:
@ -202,7 +203,8 @@ def sessao_votacao(context, context_vars):
if not erro: if not erro:
context.update({'materia': materia_aberta.materia, context.update({'materia': materia_aberta.materia,
'ementa': materia_aberta.materia.ementa}) 'ementa': materia_aberta.materia.ementa,
'sessao_id': materia_aberta.sessao_plenaria_id})
context, context_vars = votacao(context, context_vars) context, context_vars = votacao(context, context_vars)
else: else:
context.update({'error_message': errors_msgs[erro]}) context.update({'error_message': errors_msgs[erro]})
@ -218,93 +220,130 @@ def can_vote(context, context_vars, 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 msg:
return HttpResponseRedirect('/') # Mais de uma votação aberta ao mesmo tempo (não deveria acontecer
# mais, dado o invariante garantido em abrir_votacao(), mas se
# acontecer é preferível mostrar isso explicitamente ao vereador do
# que redirecioná-lo silenciosamente para "/".
context.update({'error_message': msg})
else: else:
context.update( context.update(
{'error_message': 'Não há nenhuma sessão com matéria aberta.'}) {'error_message': 'Não há nenhuma sessão com matéria aberta.'})
return context, context_vars return context, context_vars
@login_required def _resolve_votante_context(request):
@permission_required('parlamentares.can_vote', raise_exception=True) """
def votante_view(request): Resolve o estado atual de votação para o Votante autenticado — usado
logger = logging.getLogger(__name__) tanto por votante_view (renderização completa) quanto por
votante_status (endpoint leve de polling), para as duas views
compartilharem a mesma lógica de can_vote() em vez de duplicá-la.
"""
username = request.user.username username = request.user.username
if not Votante.objects.filter(user=request.user).exists(): if not Votante.objects.filter(user=request.user).exists():
logger.warning( logging.getLogger(__name__).warning(
f'user={username} sem cadastro de Votante tentou acessar /voto-individual/.' f'user={username} sem cadastro de Votante tentou acessar /voto-individual/.'
) )
raise PermissionDenied raise PermissionDenied
template_name = 'painel/voto_individual.html'
context = {'head_title': str(_('Votação Individual'))} context = {'head_title': str(_('Votação Individual'))}
context_vars = {'votante': Votante.objects.get(user=request.user)} context_vars = {'votante': Votante.objects.get(user=request.user)}
return can_vote(context, context_vars, request)
context, context_vars = can_vote(context, context_vars, request)
# Salva o voto @never_cache
if request.method == 'POST': @login_required
@permission_required('parlamentares.can_vote', raise_exception=True)
def votante_status(request):
"""
Endpoint leve para o tablet (voto_individual.html/voto-individual v2)
— devolve só o suficiente pra decidir se o estado pessoal do Votante
mudou, sem o custo de renderizar a página inteira. Tem checagem de
permissão própria (parlamentares.can_vote) em vez de check_permission
(permissão do módulo painel), que uma conta só-Votante não
necessariamente tem.
"""
context, context_vars = _resolve_votante_context(request)
materia = context.get('materia')
return JsonResponse({
'materia_id': materia.id if materia else None,
'error_message': context.get('error_message'),
'status_message': context.get('status_message'),
'voto_parlamentar': context.get('voto_parlamentar'),
})
def _save_voto_individual(request, context_vars):
"""
Salva o voto do Votante autenticado — compartilhado por votante_view
(tela legada) e votante_view_v2 (Vue), cada uma redirecionando de
volta para a própria URL depois (por isso o redirect não mora aqui).
"""
logger = logging.getLogger(__name__)
username = request.user.username
if context_vars['ordem_dia']: if context_vars['ordem_dia']:
try: fase_sessao = {'ordem': context_vars['ordem_dia']}
logger.info("user=" + username + ". Tentando obter objeto VotoParlamentar para parlamentar={} e " elif context_vars['expediente']:
"ordem={}. " fase_sessao = {'expediente': context_vars['expediente']}
.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. "
.format(context_vars['parlamentar'], context_vars['ordem_dia']))
voto = VotoParlamentar.objects.create(
parlamentar=context_vars['parlamentar'],
voto=request.POST['voto'],
user=request.user,
ip=get_client_ip(request),
ordem=context_vars['ordem_dia'])
else: else:
logger.info("user=" + username + ". VotoParlamentar para parlamentar={} e ordem={} obtido com sucesso." fase_sessao = None
.format(context_vars['parlamentar'], context_vars['ordem_dia']))
voto.voto = request.POST['voto']
voto.ip = get_client_ip(request)
voto.user = request.user
voto.save()
elif context_vars['expediente']: if fase_sessao is None:
return
# select_for_update+atomic evita corrida com uma escrita concorrente
# na mesma linha (ex.: o operador registrando este mesmo parlamentar
# em lote na tela "Registrar Votação" ao mesmo tempo). Diferente do
# formulário em lote do operador, aqui é sempre seguro aplicar o
# valor enviado: é o próprio parlamentar atualizando o próprio voto.
try: try:
logger.info( with transaction.atomic():
"user=" + username + ". Tentando obter objeto VotoParlamentar para parlamentar={} e expediente={}." voto, created = VotoParlamentar.objects.select_for_update().get_or_create(
.format(context_vars['parlamentar'], context_vars['expediente'])) parlamentar=context_vars['parlamentar'], **fase_sessao)
voto = VotoParlamentar.objects.get( except IntegrityError:
parlamentar=context_vars['parlamentar'], voto = VotoParlamentar.objects.select_for_update().get(
expediente=context_vars['expediente']) parlamentar=context_vars['parlamentar'], **fase_sessao)
except ObjectDoesNotExist:
logger.error( logger.info("user=" + username + ". VotoParlamentar para parlamentar={} obtido com sucesso."
"user=" + username + ". Erro ao obter VotoParlamentar para parlamentar={} e expediente={}. Criando objeto." .format(context_vars['parlamentar']))
.format(context_vars['parlamentar'], context_vars['expediente']))
voto = VotoParlamentar.objects.create(
parlamentar=context_vars['parlamentar'],
voto=request.POST['voto'],
user=request.user,
ip=get_client_ip(request),
expediente=context_vars['expediente'])
else:
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.voto = request.POST['voto']
voto.ip = get_client_ip(request) voto.ip = get_client_ip(request)
voto.user = request.user voto.user = request.user
voto.save() voto.save()
broadcast_dados_painel(request, context_vars['sessao'].id)
@never_cache
@login_required
@permission_required('parlamentares.can_vote', raise_exception=True)
def votante_view(request):
template_name = 'painel/voto_individual.html'
context, context_vars = _resolve_votante_context(request)
if request.method == 'POST':
_save_voto_individual(request, context_vars)
return HttpResponseRedirect( return HttpResponseRedirect(
reverse('sapl.painel:voto_individual')) reverse('sapl.painel:voto_individual'))
return render(request, template_name, context) return render(request, template_name, context)
@never_cache
@login_required
@permission_required('parlamentares.can_vote', raise_exception=True)
def votante_view_v2(request):
context, context_vars = _resolve_votante_context(request)
if request.method == 'POST':
_save_voto_individual(request, context_vars)
return HttpResponseRedirect(
reverse('sapl.painel:voto_individual_v2'))
return render(request, 'painel/voto_individual_v2.html', context)
@user_passes_test(check_permission) @user_passes_test(check_permission)
def switch_painel(request): def switch_painel(request):
sessao = SessaoPlenaria.objects.get(id=request.POST['pk_sessao']) sessao = SessaoPlenaria.objects.get(id=request.POST['pk_sessao'])
@ -327,37 +366,35 @@ def verifica_painel(request):
return resposta return resposta
@user_passes_test(check_permission) CRONOMETRO_CACHE_KEY = 'cronometro:{}'
def painel_mensagem_view(request): CRONOMETRO_CACHE_TIMEOUT = None # não expira sozinho — só quando outro action chega
return render(request, 'painel/mensagem.html')
@user_passes_test(check_permission)
def painel_parlamentar_view(request):
return render(request, 'painel/parlamentares.html')
@user_passes_test(check_permission)
def painel_votacao_view(request):
return render(request, 'painel/votacao.html')
@user_passes_test(check_permission) @user_passes_test(check_permission)
def cronometro_painel(request): def cronometro_painel(request):
request.session[request.GET['tipo']] = request.GET['action'] """
Estado do cronômetro (start/stop/reset de cada tipo) era guardado em
request.session — visível só para quem clicou, nunca para os outros
clientes olhando o mesmo painel (o telão público, outros operadores).
Agora fica em cache compartilhado, e dispara um broadcast pro grupo da
sessão atualmente com o painel aberto, para os clientes conectados via
WebSocket sincronizarem o cronômetro deles quase na hora — sem isso,
dependeriam do próximo poll (até alguns segundos de atraso) para saber
que o Presidente apertou start/stop/reset.
"""
tipo = request.GET['tipo']
action = request.GET['action']
cache.set(CRONOMETRO_CACHE_KEY.format(tipo), action, CRONOMETRO_CACHE_TIMEOUT)
sessao = SessaoPlenaria.objects.filter(painel_aberto=True).first()
if sessao:
broadcast_dados_painel(request, sessao.id)
return HttpResponse({}) return HttpResponse({})
def get_cronometro_status(request, name): def get_cronometro_status(request, name):
logger = logging.getLogger(__name__) return cache.get(CRONOMETRO_CACHE_KEY.format(name)) or ''
username = request.user.username
try:
logger.debug("user=" + username + ". Tentando obter cronometro.")
cronometro = request.session[name]
except KeyError as e:
logger.error("user=" + username + ". Erro ao obter cronometro. Retornado como vazio. " + str(e))
cronometro = ''
return cronometro
def get_materia_aberta(pk): def get_materia_aberta(pk):
@ -366,16 +403,10 @@ def get_materia_aberta(pk):
def get_presentes(pk, response, materia): def get_presentes(pk, response, materia):
if type(materia) == OrdemDia: etapa_sessao = 'ordemdia' if type(materia) == OrdemDia else 'expediente'
presentes = PresencaOrdemDia.objects.filter( presencas = SessaoPresencasView.objects.filter(
sessao_plenaria_id=pk) sessao_plenaria_id=pk, etapa_sessao=etapa_sessao)
else:
presentes = SessaoPlenariaPresenca.objects.filter(
sessao_plenaria_id=pk)
sessao = SessaoPlenaria.objects.get(id=pk)
num_presentes = len(presentes)
data_sessao = sessao.data_inicio
oradores = OradorExpediente.objects.filter( oradores = OradorExpediente.objects.filter(
sessao_plenaria_id=pk).order_by('numero_ordem') sessao_plenaria_id=pk).order_by('numero_ordem')
@ -388,28 +419,43 @@ def get_presentes(pk, response, materia):
}) })
presentes_list = [] presentes_list = []
for p in presentes: for p in presencas:
legislatura = sessao.legislatura
# Recupera os mandatos daquele parlamentar
mandatos = p.parlamentar.mandato_set.filter(legislatura=legislatura)
if p.parlamentar.ativo and mandatos:
filiacao = filiacao_data(p.parlamentar, data_sessao, data_sessao)
if not filiacao:
partido = 'Sem Registro'
else:
partido = filiacao
presentes_list.append( presentes_list.append(
{'id': p.id, {'id': p.id,
'parlamentar_id': p.parlamentar.id, 'parlamentar_id': p.parlamentar_id,
'nome': p.parlamentar.nome_parlamentar, 'nome': p.nome_parlamentar,
'partido': partido, 'partido': p.filiacao,
'voto': '' 'voto': ''
}) })
elif not p.parlamentar.ativo or not mandatos: # Fotos/crop dos parlamentares presentes — consulta em bloco, à parte do
num_presentes += -1 # loop acima, porque SessaoPresencasView é uma view flat (sem FK para
# Parlamentar) criada para eliminar o N+1 por presente.
fotos_by_id = {
row['id']: row
for row in Parlamentar.objects.filter(
id__in=[p['parlamentar_id'] for p in presentes_list]
).values('id', 'fotografia', 'cropping')
}
for p in presentes_list:
foto = fotos_by_id.get(p['parlamentar_id'])
thumbnail_url = False
if foto and foto['fotografia']:
try:
thumbnail_url = get_backend().get_thumbnail_url(
foto['fotografia'],
{
'size': (128, 128),
'box': foto['cropping'],
'crop': True,
'detail': True,
}
)
except Exception:
logging.getLogger(__name__).exception(
'Falha ao gerar thumbnail do parlamentar id=%s.',
p['parlamentar_id'])
p['fotografia'] = thumbnail_url
if materia: if materia:
if materia.tipo_votacao == 1: if materia.tipo_votacao == 1:
@ -433,7 +479,7 @@ def get_presentes(pk, response, materia):
response.update({ response.update({
'presentes': presentes_list, 'presentes': presentes_list,
'num_presentes': num_presentes, 'num_presentes': len(presentes_list),
'oradores': oradores_list, 'oradores': oradores_list,
'msg_painel': str(_('Votação aberta!')), 'msg_painel': str(_('Votação aberta!')),
}) })
@ -446,36 +492,20 @@ def get_materia_expediente_aberta(pk):
sessao_plenaria_id=pk, votacao_aberta=True).last() sessao_plenaria_id=pk, votacao_aberta=True).last()
def response_nenhuma_materia(response):
response.update({
'msg_painel': str(_('Nenhuma matéria disponivel para votação.'))})
return JsonResponse(response)
def get_votos(response, materia, mostrar_voto): def get_votos(response, materia, mostrar_voto):
logger = logging.getLogger(__name__) etapa_sessao = 'ordemdia' if type(materia) == OrdemDia else 'expediente'
if type(materia) == OrdemDia: lookup = {'ordem': materia} if type(materia) == OrdemDia else {'expediente': materia}
if materia.tipo_votacao != 4:
registro = RegistroVotacao.objects.filter(
ordem=materia, materia=materia.materia).order_by('data_hora').last()
leitura = None
else:
leitura = RegistroLeitura.objects.filter(
ordem=materia, materia=materia.materia).order_by('data_hora').last()
registro = None
tipo = 'ordem'
elif type(materia) == ExpedienteMateria:
if materia.tipo_votacao != 4: if materia.tipo_votacao != 4:
registro = RegistroVotacao.objects.filter( view_row = SessaoMateriasVotacoesView.objects.filter(
expediente=materia, materia=materia.materia).order_by('data_hora').last() id=materia.id, etapa_sessao=etapa_sessao).first()
leitura = None leitura = None
else: else:
view_row = None
leitura = RegistroLeitura.objects.filter( leitura = RegistroLeitura.objects.filter(
expediente=materia, materia=materia.materia).order_by('data_hora').last() materia=materia.materia, **lookup).order_by('data_hora').last()
registro = None
tipo = 'expediente'
if not registro and not leitura: if (view_row is None or view_row.numero_votos is None) and not leitura:
response.update({ response.update({
'numero_votos_sim': 0, 'numero_votos_sim': 0,
'numero_votos_nao': 0, 'numero_votos_nao': 0,
@ -486,29 +516,15 @@ def get_votos(response, materia, mostrar_voto):
}) })
if materia.tipo_votacao == 2: if materia.tipo_votacao == 2:
if tipo == 'ordem': votos_parlamentares = (view_row.votos_parlamentares if view_row else None) or {}
votos_parlamentares = VotoParlamentar.objects.filter(
ordem_id=materia.id).order_by(
'parlamentar__nome_parlamentar')
else:
votos_parlamentares = VotoParlamentar.objects.filter(
expediente_id=materia.id).order_by(
'parlamentar__nome_parlamentar')
for i, p in enumerate(response['presentes']): for i, p in enumerate(response['presentes']):
try: voto_entry = votos_parlamentares.get(str(p['parlamentar_id']))
logger.info("Tentando obter votos do parlamentar (id={}).".format(p['parlamentar_id'])) voto = voto_entry['voto'] if voto_entry else None
voto = votos_parlamentares.get(parlamentar_id=p['parlamentar_id']).voto
if voto: if voto:
if mostrar_voto: if mostrar_voto:
response['presentes'][i]['voto'] = voto response['presentes'][i]['voto'] = voto
else: else:
response['presentes'][i]['voto'] = 'Voto Informado' response['presentes'][i]['voto'] = 'Voto Informado'
except ObjectDoesNotExist:
# logger.error("Votos do parlamentar (id={}) não encontrados. Retornado vazio."
# .format(p['parlamentar_id']))
response['presentes'][i]['voto'] = ''
elif leitura: elif leitura:
response.update({ response.update({
'numero_votos_sim': 0, 'numero_votos_sim': 0,
@ -519,39 +535,33 @@ def get_votos(response, materia, mostrar_voto):
'tipo_resultado': 'Matéria lida.', 'tipo_resultado': 'Matéria lida.',
}) })
else: else:
total = (registro.numero_votos_sim + numero_votos = view_row.numero_votos
registro.numero_votos_nao + votos_parlamentares = view_row.votos_parlamentares or {}
registro.numero_abstencoes)
if materia.tipo_votacao == 2: if materia.tipo_votacao == 2:
votos_parlamentares = VotoParlamentar.objects.filter(
votacao_id=registro.id).order_by(
'parlamentar__nome_parlamentar')
for i, p in enumerate(response['presentes']): for i, p in enumerate(response['presentes']):
try: voto_entry = votos_parlamentares.get(str(p['parlamentar_id']))
logger.debug("Tentando obter votos do parlamentar (id={}).".format(p['parlamentar_id'])) response['presentes'][i]['voto'] = voto_entry['voto'] if voto_entry else None
response['presentes'][i]['voto'] = votos_parlamentares.get(
parlamentar_id=p['parlamentar_id']).voto
except ObjectDoesNotExist:
logger.error(
"Votos do parlamentar (id={}) não encontrados. Retornado None.".format(p['parlamentar_id']))
response['presentes'][i]['voto'] = None
response.update({ response.update({
'numero_votos_sim': registro.numero_votos_sim, 'numero_votos_sim': numero_votos['votos_sim'],
'numero_votos_nao': registro.numero_votos_nao, 'numero_votos_nao': numero_votos['votos_nao'],
'numero_abstencoes': registro.numero_abstencoes, 'numero_abstencoes': numero_votos['abstencoes'],
'registro': True, 'registro': True,
'total_votos': total, 'total_votos': numero_votos['total_votos'],
'tipo_resultado': registro.tipo_resultado_votacao.nome, 'tipo_resultado': view_row.resultado_votacao,
}) })
return response return response
@user_passes_test(check_permission) def build_dados_painel(request, pk):
def get_dados_painel(request, pk): """
Monta o dict completo consumido pelos broadcasts via WebSocket
(broadcast_dados_painel() abaixo) — era também servido por um endpoint
HTTP de polling (get_dados_painel), removido quando o polling foi
eliminado (nenhuma tela restante lê build_dados_painel() por HTTP).
"""
sessao = SessaoPlenaria.objects.get(id=pk) sessao = SessaoPlenaria.objects.get(id=pk)
casa = CasaLegislativa.objects.first() casa = CasaLegislativa.objects.first()
@ -569,6 +579,11 @@ def get_dados_painel(request, pk):
'sessao_plenaria_hora_inicio': sessao.hora_inicio, 'sessao_plenaria_hora_inicio': sessao.hora_inicio,
'sessao_solene': sessao.tipo.nome == "Solene", 'sessao_solene': sessao.tipo.nome == "Solene",
'sessao_finalizada': sessao.finalizada, 'sessao_finalizada': sessao.finalizada,
# Sessões anteriores à migração que introduziu este campo ficaram
# com iniciada=None — tratado como "iniciada" (True) por
# restringe_sessoes_visiveis() em sapl/sessao/models.py, mesma
# convenção seguida aqui.
'sessao_iniciada': sessao.iniciada is not False,
'tema_solene': sessao.tema_solene, 'tema_solene': sessao.tema_solene,
'cronometro_aparte': get_cronometro_status(request, 'aparte'), 'cronometro_aparte': get_cronometro_status(request, 'aparte'),
'cronometro_discurso': get_cronometro_status(request, 'discurso'), 'cronometro_discurso': get_cronometro_status(request, 'discurso'),
@ -585,13 +600,13 @@ def get_dados_painel(request, pk):
# Caso tenha alguma matéria com votação aberta, ela é mostrada no painel # Caso tenha alguma matéria com votação aberta, ela é mostrada no painel
# com prioridade para Ordem do Dia. # com prioridade para Ordem do Dia.
if ordem_dia: if ordem_dia:
return JsonResponse(get_votos( return get_votos(
get_presentes(pk, response, ordem_dia), get_presentes(pk, response, ordem_dia),
ordem_dia, app_config.mostrar_voto)) ordem_dia, app_config.mostrar_voto)
elif expediente: elif expediente:
return JsonResponse(get_votos( return get_votos(
get_presentes(pk, response, expediente), get_presentes(pk, response, expediente),
expediente, app_config.mostrar_voto)) expediente, app_config.mostrar_voto)
# Caso não tenha nenhuma aberta, # Caso não tenha nenhuma aberta,
# a matéria a ser mostrada no Painel deve ser a última votada # a matéria a ser mostrada no Painel deve ser a última votada
@ -625,9 +640,343 @@ def get_dados_painel(request, pk):
ultimo_timestamp = last_expediente_leitura.data_hora ultimo_timestamp = last_expediente_leitura.data_hora
if ordem_expediente: if ordem_expediente:
return JsonResponse(get_votos( return get_votos(
get_presentes(pk, response, ordem_expediente), get_presentes(pk, response, ordem_expediente),
ordem_expediente, app_config.mostrar_voto)) ordem_expediente, app_config.mostrar_voto)
# 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)) response.update({
'msg_painel': str(_('Nenhuma matéria disponivel para votação.'))})
return get_presentes(pk, response, None)
def broadcast_dados_painel(request, sessao_id):
"""
Envia o estado atual da sessão para o grupo WebSocket correspondente
(sapl/painel/consumers.py::PainelConsumer) — chamado pelas views que
alteram voto/matéria/registro, sempre depois da escrita já ter sido
commitada no banco. `request` é reaproveitado só para
get_cronometro_status() dentro de build_dados_painel() (ver o item do
cronômetro no plano — ainda lê de request.session, não de storage
compartilhado; isso é resolvido por último, não aqui).
Best-effort: um Redis fora do ar não pode derrubar a ação que disparou
o broadcast (registrar voto, abrir/fechar matéria) — é exatamente por
isso que o polling continua existindo como fallback (ver plano). Falha
aqui só fica no log; quem está na tela sem WebSocket simplesmente
continua vendo a atualização pelo poll de sempre.
"""
from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer
try:
layer = get_channel_layer()
if layer is None:
return
async_to_sync(layer.group_send)(
'sessao_{}'.format(sessao_id),
{'type': 'data', 'payload': build_dados_painel(request, sessao_id)})
except Exception:
logging.getLogger(__name__).exception(
'Falha ao fazer broadcast do painel para sessao_id={}.'.format(sessao_id))
# --- Vue/Pinia (v2) painel + votação nominal operator screens ---------------
#
# `controller_id` here is the sessao_plenaria id (see websocket_view's
# docstring) — kept as the parameter/URL-kwarg name to match the existing
# frontend (main.js/vue.config.js) call sites unchanged. Originally these
# four views were authored against a separate, incompatible Channels
# backend (controller_<id> groups, vote.update/stopwatch.update messages,
# a since-removed sapl.painel.consumers.get_dados_painel()); they're kept
# here with their validation/DB-write logic intact, but every broadcast now
# goes through broadcast_dados_painel() — the same full-snapshot rebroadcast
# every other painel/sessao view already uses — instead of a bespoke
# group_send. The stopwatch_controller view from that original branch is
# dropped entirely: cronometro_painel() above already does that job, keyed
# by the same discurso/aparte/ordem/consideracoes ids CronometroList.vue uses.
@never_cache
@user_passes_test(check_permission)
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_VOTE_VALUES = ["Sim", "Não", "Abstenção", "Não Votou"]
@user_passes_test(check_permission)
def vote_controller(request, controller_id):
"""
HTTP endpoint to cast/update a vote and broadcast the updated painel
snapshot to all connected WebSocket clients.
POST /v2/painel/controller/<sessao_id>/vote
Body (form-encoded or JSON):
parlamentar_id: int
voto: str ("Sim", "Não", "Abstenção", "Não Votou")
"""
logger = logging.getLogger(__name__)
if request.method != 'POST':
return JsonResponse({"type": "error", "message": "Only POST allowed"}, status=405)
# Parse body: support both form-encoded and JSON
if request.content_type and 'json' in request.content_type:
try:
body = json.loads(request.body)
except json.JSONDecodeError:
return JsonResponse({"type": "error", "message": "Invalid JSON"}, status=400)
parlamentar_id = body.get("parlamentar_id")
voto = body.get("voto")
else:
parlamentar_id = request.POST.get("parlamentar_id")
voto = request.POST.get("voto")
# Validate
if not parlamentar_id:
return JsonResponse({"type": "error", "message": "parlamentar_id is required"}, status=400)
try:
parlamentar_id = int(parlamentar_id)
except (ValueError, TypeError):
return JsonResponse({"type": "error", "message": "parlamentar_id must be an integer"}, status=400)
if voto not in VALID_VOTE_VALUES:
return JsonResponse({
"type": "error",
"message": f"Invalid vote value: {voto}. Must be one of: {VALID_VOTE_VALUES}"
}, status=400)
# Verify session exists
try:
SessaoPlenaria.objects.get(id=controller_id)
except SessaoPlenaria.DoesNotExist:
return JsonResponse({"type": "error", "message": "Session not found"}, status=404)
# Find open materia (OrdemDia or ExpedienteMateria)
ordem_dia = get_materia_aberta(controller_id)
expediente = get_materia_expediente_aberta(controller_id)
materia_aberta = ordem_dia or expediente
if not materia_aberta:
return JsonResponse({"type": "error", "message": "No open materia for voting"}, status=400)
if materia_aberta.tipo_votacao != VOTACAO_NOMINAL:
return JsonResponse({"type": "error", "message": "Materia is not nominal voting type"}, status=400)
# Verify parlamentar exists
try:
parlamentar = Parlamentar.objects.get(id=parlamentar_id)
except Parlamentar.DoesNotExist:
return JsonResponse({"type": "error", "message": f"Parlamentar {parlamentar_id} not found"}, status=404)
# Save/update vote
if ordem_dia:
voto_obj, created = VotoParlamentar.objects.update_or_create(
parlamentar=parlamentar,
ordem=ordem_dia,
defaults={'voto': voto, 'user': request.user, 'ip': get_client_ip(request)}
)
else:
voto_obj, created = VotoParlamentar.objects.update_or_create(
parlamentar=parlamentar,
expediente=expediente,
defaults={'voto': voto, 'user': request.user, 'ip': get_client_ip(request)}
)
logger.info(f"Vote {'created' if created else 'updated'}: parlamentar={parlamentar_id}, voto={voto}, sessao={controller_id}")
broadcast_dados_painel(request, controller_id)
return JsonResponse({"ok": True, "parlamentar_id": parlamentar_id, "voto": voto, "created": created})
@user_passes_test(check_permission)
def votos_status(request, controller_id):
"""
Estado real (não mascarado por mostrar_voto) dos votos da matéria em
votação nominal aberta na sessão — usado pela tela de operação
(VotacaoVotos.vue) para saber quem já votou (ex.: via tablet) sem
correr o risco de sobrescrever um voto, sem depender do broadcast
público do painel (build_dados_painel()/get_votos()), que mascara o
voto individual quando app_config.mostrar_voto é False (ver
sapl.sessao.views.VotacaoNominalAbstract._status_json, que resolve o
mesmo problema para a tela legada — mas indexado por oid/mid, que a
tela v2 não tem na URL; aqui o mesmo cálculo é indexado por sessao_id).
"""
ordem_dia = get_materia_aberta(controller_id)
expediente = get_materia_expediente_aberta(controller_id)
materia_aberta = ordem_dia or expediente
if not materia_aberta:
return JsonResponse({
"votacao_aberta": False, "registro_aberto": False,
"ja_registrada": False, "votos": {},
})
lookup = {'ordem': materia_aberta} if ordem_dia else {'expediente': materia_aberta}
votos = VotoParlamentar.objects.filter(**lookup).values_list('parlamentar_id', 'voto')
return JsonResponse({
"votacao_aberta": materia_aberta.votacao_aberta,
"registro_aberto": materia_aberta.registro_aberto,
"ja_registrada": RegistroVotacao.objects.filter(**lookup).exists(),
"votos": {str(pid): voto for pid, voto in votos},
})
@user_passes_test(check_permission)
def cancel_voting(request, controller_id):
"""
HTTP endpoint to cancel the current open voting and discard all votes.
POST /v2/painel/controller/<sessao_id>/cancel
"""
from sapl.sessao.views import fechar_votacao_materia
logger = logging.getLogger(__name__)
if request.method != 'POST':
return JsonResponse({"type": "error", "message": "Only POST allowed"}, status=405)
# Find the open materia
ordem_dia = get_materia_aberta(controller_id)
expediente = get_materia_expediente_aberta(controller_id)
materia_aberta = ordem_dia or expediente
if not materia_aberta:
return JsonResponse({"type": "error", "message": "No open materia for voting"}, status=400)
# Build redirect URL before closing (materia_aberta fields change after close)
materia_id = materia_aberta.materia_id
if ordem_dia:
redirect_url = reverse('sapl.sessao:ordemdia_list',
kwargs={'pk': controller_id}) + f'#id{materia_id}'
else:
redirect_url = reverse('sapl.sessao:expedientemateria_list',
kwargs={'pk': controller_id}) + f'#id{materia_id}'
# Cancel: delete votes + RegistroVotacao, close materia
fechar_votacao_materia(materia_aberta)
logger.info(f"Voting cancelled for sessao={controller_id}, materia={materia_aberta.id}")
broadcast_dados_painel(request, controller_id)
return JsonResponse({"ok": True, "message": "Votação cancelada com sucesso.", "redirect_url": redirect_url})
@user_passes_test(check_permission)
def close_voting(request, controller_id):
"""
HTTP endpoint to close the current voting and save the result.
POST /v2/painel/controller/<sessao_id>/close
Body (JSON):
resultado_id: int (TipoResultadoVotacao id)
observacoes: str (optional)
"""
from sapl.sessao.models import TipoResultadoVotacao
logger = logging.getLogger(__name__)
if request.method != 'POST':
return JsonResponse({"type": "error", "message": "Only POST allowed"}, status=405)
# Parse body
if request.content_type and 'json' in request.content_type:
try:
body = json.loads(request.body)
except json.JSONDecodeError:
return JsonResponse({"type": "error", "message": "Invalid JSON"}, status=400)
else:
body = request.POST
resultado_id = body.get("resultado_id")
observacoes = body.get("observacoes", "")
if not resultado_id:
return JsonResponse({
"type": "error",
"message": "Não é possível finalizar a votação sem nenhum resultado da votação"
}, status=400)
# Validate resultado
try:
tipo_resultado = TipoResultadoVotacao.objects.get(id=resultado_id)
except TipoResultadoVotacao.DoesNotExist:
return JsonResponse({"type": "error", "message": "Tipo de resultado não encontrado"}, status=404)
# Find the open materia
ordem_dia = get_materia_aberta(controller_id)
expediente = get_materia_expediente_aberta(controller_id)
materia_aberta = ordem_dia or expediente
if not materia_aberta:
return JsonResponse({"type": "error", "message": "No open materia for voting"}, status=400)
# Count votes from VotoParlamentar
if ordem_dia:
votos = VotoParlamentar.objects.filter(ordem=ordem_dia)
else:
votos = VotoParlamentar.objects.filter(expediente=expediente)
votos_sim = votos.filter(voto='Sim').count()
votos_nao = votos.filter(voto='Não').count()
abstencoes = votos.filter(voto='Abstenção').count()
# All votes must not be "Não Votou"
total_votados = votos_sim + votos_nao + abstencoes
if total_votados == 0:
return JsonResponse({
"type": "error",
"message": "Não é possível finalizar a votação sem nenhum voto"
}, status=400)
# Remove old RegistroVotacao if exists
if ordem_dia:
RegistroVotacao.objects.filter(ordem=ordem_dia).delete()
else:
RegistroVotacao.objects.filter(expediente=expediente).delete()
# Create RegistroVotacao
registro = RegistroVotacao()
registro.numero_votos_sim = votos_sim
registro.numero_votos_nao = votos_nao
registro.numero_abstencoes = abstencoes
registro.observacao = observacoes
registro.user = request.user
registro.ip = get_client_ip(request)
registro.materia = materia_aberta.materia
registro.tipo_resultado_votacao = tipo_resultado
if ordem_dia:
registro.ordem = ordem_dia
else:
registro.expediente = expediente
registro.save()
# Link VotoParlamentar records to RegistroVotacao and update user/ip
for voto_obj in votos:
voto_obj.votacao = registro
voto_obj.user = request.user
voto_obj.ip = get_client_ip(request)
voto_obj.save()
# Build redirect URL before closing
materia_id = materia_aberta.materia_id
if ordem_dia:
redirect_url = reverse('sapl.sessao:ordemdia_list',
kwargs={'pk': controller_id}) + f'#id{materia_id}'
else:
redirect_url = reverse('sapl.sessao:expedientemateria_list',
kwargs={'pk': controller_id}) + f'#id{materia_id}'
# Close materia with resultado
materia_aberta.resultado = tipo_resultado.nome
materia_aberta.votacao_aberta = False
materia_aberta.save()
# Clean up orphan VotoParlamentar (without votacao)
if ordem_dia:
VotoParlamentar.objects.filter(ordem=ordem_dia, votacao__isnull=True).delete()
else:
VotoParlamentar.objects.filter(expediente=expediente, votacao__isnull=True).delete()
logger.info(
f"Voting closed for sessao={controller_id}: "
f"sim={votos_sim}, nao={votos_nao}, abstencoes={abstencoes}, "
f"resultado={tipo_resultado.nome}"
)
broadcast_dados_painel(request, controller_id)
return JsonResponse({"ok": True, "message": "Votação finalizada com sucesso.", "redirect_url": redirect_url})

283
sapl/sessao/README.md

@ -0,0 +1,283 @@
# Painel backed by WebSockets — manual E2E verification
This is a note about the **`feat/painel-websockets` branch** (a timeboxed
spike, forked from `fix/painel-registro-votacao-trava`), which adds live
server push (WebSockets over Redis) to the plenary-session "painel"
feature, additively on top of the existing HTTP-polling implementation.
It lives in this app's directory because the broadcast trigger points
(`abrir_votacao`, `VotacaoNominalAbstract.post`) are here in
`sapl/sessao/views.py` — the WebSocket infrastructure itself (consumer,
ASGI routing, the shared payload builder) lives in `sapl/painel/`.
If you're reading this outside that branch, most of what's described
below doesn't exist yet.
## Requirements to run this branch (local dev)
Four things need to be running at once. Order matters a little (Redis
before the Django/ASGI process; `yarn serve` before you load a page, or
its bundles 404).
1. **Redis, with persistence off** (channel layer only — nothing here
needs to survive a restart):
```
redis-server --save "" --appendonly no
```
or, if you'd rather not install Redis locally:
```
docker run --rm -p 6379:6379 redis:7-alpine redis-server --save "" --appendonly no
```
Make sure `REDIS_URL` in `.env` actually resolves to it — it must be a
full URL (`redis://127.0.0.1:6379/0`, matching `sapl/settings.py`'s
default). The repo's current `.env` has `REDIS_URL=localhost:6380`,
which is **not** in that form — if WebSocket updates silently never
arrive, check this first before suspecting the consumer/broadcast code.
2. **Python deps** (adds `channels`/`daphne`/`channels-redis` on top of
the usual requirements — skip if already installed):
```
pip install -r requirements/dev-requirements.txt
```
3. **Frontend dev bundle** (the Vue pages — painel, votação nominal, voto
individual, votação simbólica, leitura de matéria — are served via
`{% render_bundle %}`, which in `DEBUG` mode reads
`frontend/dev-webpack-stats.json` and points `<script>` tags at the
dev server; without this running you'll get a template error, not a
blank page):
```
yarn install # once
yarn serve
```
4. **The Django/ASGI process itself — pick one, not both:**
```
daphne -b 127.0.0.1 -p 8000 sapl.asgi:application
```
or
```
./manage.py runserver
```
`sapl.asgi.application` is a single ASGI app that routes **both**
regular HTTP and `/ws/painel/<sessao_id>/` — and every WS client here
(Vue's `main.js` files and the legacy vanilla-JS templates alike)
opens its socket at `window.location.host`, i.e. *the same host:port
the page was loaded from*. So this has to be one process on one port,
not Daphne-on-one-port-plus-runserver-on-another. There's no HTTP
polling fallback anywhere in this branch anymore — every screen here
is WS-only (see "Architecture" below) — so a two-port setup wouldn't
just be slower, it would leave every page permanently stuck showing a
"conexão perdida" banner. `scripts/verify_painel_websockets.sh` runs a
single Daphne process for exactly this reason.
Between the two: **use Daphne when you're actively debugging the
WebSocket itself** — its logs/errors for a failed or dropped `/ws/`
connection are far more legible, and this is where you'll spend time
if something in the merged backend misbehaves. Daphne does **not**
autoreload on code changes, though, so for everyday work (editing
views/templates, not the consumer/broadcast path) run `runserver`
instead — Channels patches it to serve the same combined HTTP+WS app,
just with autoreload, and you restart it far less often.
## Architecture in one paragraph
Every client screen — the three legacy vanilla-JS templates (the public
painel `sapl/templates/painel/index.html`, the tablet voting screen
`sapl/templates/painel/voto_individual.html`, the operator's
vote-registration screen `sapl/templates/sessao/votacao/nominal.html`) and
the five Vue v2 pages (painel, votação nominal, voto individual, votação
simbólica, leitura de matéria) — open a WebSocket to
`/ws/painel/<sessao_id>/` (one Channels group per `SessaoPlenaria`,
`sapl/painel/consumers.py::PainelConsumer`) and get a full state snapshot
immediately on connect (`PainelConsumer.connect()` sends one right after
`accept()` — this is what makes a page reload or a reconnect self-heal
with no gap, instead of showing stale data until the next event).
Whenever a view mutates presença, orador, vote, or matéria state, it calls
`broadcast_dados_painel(request, sessao_id)` (`sapl/painel/views.py`),
which re-runs `build_dados_painel()` and publishes the full payload to
that group over Redis (`channels_redis`). Every connected client gets the
*same* full-state payload; there is no separate delta format.
**There is no HTTP polling fallback anywhere in this branch.** Every
screen listed above is WS-only: if the socket drops, nothing updates
until it reconnects. Each screen's JS shows a visible banner
(`#ws-status-banner` in the legacy templates, `<ws-status-banner>` in the
Vue pages) whenever the connection isn't open, and reconnects with an
exponential backoff (1s → 30s cap, ±30% jitter to avoid every connected
tablet retrying in lockstep). The consumer closes with a specific code for
conditions a retry can't fix — 4401 (not authenticated), 4403 (not
authorized), 4404 (no such sessão) — and the client shows a permanent
error for those instead of retrying forever. Because polling is gone,
every write path that changes anything in this payload has to call
`broadcast_dados_painel()` itself — `PresencaView`/`PresencaOrdemDiaView`,
the `Orador*Crud` create/update/delete views (via
`BroadcastPainelOnSaveMixin` in `sapl/sessao/views.py`),
`LeituraEmBloco`, and `VotacaoEmBlocoSimbolicaView`/
`VotacaoEmBlocoNominalView` were all missing this until it was added
alongside the polling removal — before that, polling was quietly masking
the gap (500ms–3s of staleness, not "never updates").
## Running it (for humans)
```
scripts/verify_painel_websockets.sh
```
This starts Redis and a single Daphne process (HTTP pages + WebSocket, one
port, one process — see the "two things need to be running" note above)
together, waits for both to actually be listening, and tails their logs
in one terminal. Ctrl+C stops and cleans up both. See the script's header
comment for port-override env vars if 6379/8002 collide with something
else on your machine.
Once it's up:
1. **Seed test data** (there's no fixture for this yet — a `manage.py
shell` one-liner is the fastest path; see the Python snippet in the
next section, "For LLMs / scripted setup", which works equally well
pasted into a human's `shell` session).
2. Open the **public painel** at `http://127.0.0.1:8002/painel-principal/<sessao_id>`
in one tab.
3. Log in as the seeded voter and open
`http://127.0.0.1:8002/voto-individual/` in another tab (or a private
window, since it's a different session).
4. Cast or change the vote on the voter tab. **The painel tab should
update without a page reload**, and there should be no delay beyond
normal network latency — that's the only path there is now. Watch the
daphne log (tailed in the same terminal) for the connection and any
errors.
5. To confirm the error banner actually shows up when it should (not just
that push works): kill daphne (its pid is printed by the script) while
the painel tab stays open. Within a few seconds every open tab should
show the red/amber "conexão perdida" banner
(`#ws-status-banner`/`<ws-status-banner>` — see "Architecture" above)
instead of silently going stale.
6. To confirm reconnect: restart the script (or just daphne) while the
painel tab stays open. Within a few seconds (backoff-dependent) the
tab should reconnect, the banner should disappear, and the tab should
resume live updates — check the browser console for the `WebSocket`
connect/close log lines.
The operator's registration screen
(`sapl/sessao:votacaonominal`, URL `/sessao/<pk>/matordemdia/votnom/<oid>/<mid>`)
follows the same pattern — open it as a third tab, logged in as a user
with `sessao` module permissions, and confirm the same instant-update
behavior when a vote is registered from either the operator or the
tablet side.
## For LLMs / scripted setup
Minimal data needed for a Nominal vote to be votable end-to-end (run via
`python manage.py shell`, from the repo root, with `DJANGO_SETTINGS_MODULE=sapl.settings`):
```python
from model_bakery import baker
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Permission
from sapl.base.models import AppConfig as ConfiguracoesAplicacao
from sapl.parlamentares.models import Legislatura, SessaoLegislativa, Mandato, Parlamentar, Votante
from sapl.sessao.models import SessaoPlenaria, TipoSessaoPlenaria, OrdemDia, PresencaOrdemDia
from sapl.materia.models import MateriaLegislativa, TipoMateriaLegislativa
User = get_user_model()
# ConfiguracoesAplicacao.mostrar_voto controls whether the painel shows
# real vote values or masks them as "Voto Informado" before the matéria
# closes — set True to see real values live.
cfg, _ = ConfiguracoesAplicacao.objects.get_or_create(pk=1)
cfg.mostrar_voto = True
cfg.save()
legislatura = baker.make(Legislatura)
sessao_legislativa = baker.make(SessaoLegislativa)
tipo = baker.make(TipoSessaoPlenaria)
sessao = baker.make(SessaoPlenaria, legislatura=legislatura, sessao_legislativa=sessao_legislativa,
tipo=tipo, numero=1, iniciada=True, finalizada=False, painel_aberto=True)
materia = baker.make(MateriaLegislativa, tipo=baker.make(TipoMateriaLegislativa))
ordem = baker.make(OrdemDia, sessao_plenaria=sessao, materia=materia,
tipo_votacao=2, votacao_aberta=True, registro_aberto=False) # 2 = Nominal
parlamentar = baker.make(Parlamentar, ativo=True)
baker.make(PresencaOrdemDia, sessao_plenaria=sessao, parlamentar=parlamentar)
# get_presentes() (sapl/painel/views.py, backed by sessao_presencas_view)
# only includes parlamentares with a Mandato for the session's legislatura
# — without this row the parlamentar silently never shows as "presente".
baker.make(Mandato, parlamentar=parlamentar, legislatura=legislatura,
data_inicio_mandato=sessao.data_inicio)
# A strong password is required — SAPL's CheckWeakPasswordMiddleware
# force-redirects any login with a weak one (e.g. "x", "test") to
# /sistema/alterar-senha/ before it ever reaches the view you're testing.
# This silently breaks any requests-based scripted login otherwise.
votante_user = User.objects.create_user(username='verify-votante', password='Xk9#mQ2vLp8zR4wT!')
votante_user.user_permissions.add(Permission.objects.get(codename='can_vote'))
baker.make(Votante, parlamentar=parlamentar, user=votante_user)
print('sessao_id =', sessao.pk, '| ordem_id =', ordem.pk, '| materia_id =', materia.pk)
```
Permission model for `/ws/painel/<sessao_id>/` (`PainelConsumer.connect()`,
`sapl/painel/consumers.py`) — the connection is accepted if the
authenticated user satisfies **any** of the three permission gates the
three HTTP screens already use individually (there's no single unified
permission for this endpoint because the three source screens never had
one either):
- `user.has_module_perms('painel')` — same as `check_permission()`'s gate.
- `user.has_perm('parlamentares.can_vote')` — same as `votante_view`'s gate.
- `user.has_module_perms('sessao')` — same as the operator screen's
`SessaoPermissionMixin` gate.
Unauthenticated connections close with code `4401`; authenticated but
unauthorized close with `4403`; a nonexistent `sessao_id` closes with
`4404` (all handled explicitly — no uncaught exceptions on bad input,
unlike the `feat/painel-votacao-v2` reference this was ported from).
To verify the broadcast path without a browser at all (what
`sapl/painel/tests/test_broadcasts.py` does, and what was used to
smoke-test this against real Redis+daphne+runserver during development):
connect with the `websockets` Python package
(`pip install websockets`, not in project requirements — it's a
verification tool, not a runtime dependency) against
`ws://127.0.0.1:8002/ws/painel/<sessao_id>/`, sending the Django
`sessionid`/`csrftoken` cookies from a `requests.Session()` that logged in
via `/login/` — then POST a vote via `requests` and confirm the socket
receives a `{"type": "data", "payload": {...}}` message with the updated
`presentes[].voto`.
## Known caveats
- **`cronometro_painel`'s "which session is this for" resolution is
fragile.** It broadcasts to whichever `SessaoPlenaria` currently has
`painel_aberto=True`, via `.first()` — correct under the intended
single-open-session workflow, but nothing enforces "at most one" for
`painel_aberto` the way `votacao_aberta` already has a DB
`UniqueConstraint`. If more than one session has that flag set (stale
dev data, or a real multi-tenant edge case), the broadcast goes to an
arbitrary one of them. Not hit in practice during this spike's testing,
but worth hardening before this goes further.
- **This is a spike, not a finished feature.** Test coverage exists
(`sapl/painel/tests/test_consumers.py`, `test_broadcasts.py`, plus the
Postgres-view equivalence tests in `test_votacao_nominal.py`/`tests.py`)
and the vote/close/open/cronômetro broadcast paths were each verified
against real infrastructure (not just the in-memory test layer) during
development — but things like production replica count / Redis HA,
and a nginx-level rate-limit exemption for the `/ws/` path if
`feat/rate-limiter-2026`'s Lua layer ever merges, haven't been
addressed here.
- **Requirements**: `channels==3.0.3`, `daphne==3.0.2`,
`channels-redis==3.4.1`, `asgiref==3.7.2` are pinned in
`requirements/requirements.txt` — this specific combination is the
newest one compatible with this project's Django pin (`2.2.28`;
Channels 4.x needs Django ≥ 4.2, out of scope for this branch).
- **Removed as dead code when polling was removed**:
`painel_mensagem`/`painel_parlamentar`/`painel_votacao` (views, URLs,
and their templates `painel/mensagem.html`/`parlamentares.html`/
`votacao.html`) were an older, unrelated multi-page painel UI with no
references anywhere else in the codebase — deleted outright rather than
converted. `sapl.painel:dados_painel` (`get_dados_painel`) and its ETag
helper (`_dados_painel_etag`) were also removed once nothing polled
them anymore, along with the tests that pinned their HTTP-specific
behavior (ETag/304, payload shape, masking) in
`sapl/painel/tests/tests.py` — `build_dados_painel()` itself (what both
used internally) is untouched and still backs every WS broadcast.

31
sapl/sessao/admin.py

@ -1,3 +1,34 @@
from django.contrib import admin
from sapl.sessao.models import ExpedienteMateria, OrdemDia
from sapl.utils import register_all_models_in_admin from sapl.utils import register_all_models_in_admin
class NoAdminAccessModelAdmin(admin.ModelAdmin):
"""
SAPL não usa as páginas de admin e desencoraja seu uso — e votacao_aberta/
registro_aberto só podem ser alterados com segurança através do fluxo de
abrir_votacao()/VotacaoNominalAbstract (sapl/sessao/views.py), que
garante a invariante de no máximo uma matéria aberta por vez. Em vez de
apenas tornar os campos somente-leitura, o acesso ao admin é desabilitado
por completo para estes dois modelos.
"""
def has_view_permission(self, request, obj=None):
return False
def has_add_permission(self, request):
return False
def has_change_permission(self, request, obj=None):
return False
def has_delete_permission(self, request, obj=None):
return False
admin.site.register(OrdemDia, NoAdminAccessModelAdmin)
admin.site.register(ExpedienteMateria, NoAdminAccessModelAdmin)
# register_all_models_in_admin já pula modelos já registrados acima
register_all_models_in_admin(__name__) register_all_models_in_admin(__name__)

4
sapl/sessao/forms.py

@ -449,10 +449,6 @@ class PresencaForm(forms.Form):
parlamentar = forms.CharField(required=False, max_length=20) parlamentar = forms.CharField(required=False, max_length=20)
class ListMateriaForm(forms.Form):
error_message = forms.CharField(required=False, label='votacao_aberta')
class MesaForm(forms.Form): class MesaForm(forms.Form):
parlamentar = forms.IntegerField(required=True) parlamentar = forms.IntegerField(required=True)
cargo = forms.IntegerField(required=True) cargo = forms.IntegerField(required=True)

21
sapl/sessao/migrations/0070_votoparlamentar_unique_constraint.py

@ -0,0 +1,21 @@
# Generated by Django 2.2.28 on 2026-08-16 17:36
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('sessao', '0069_auto_20220919_1705'),
]
operations = [
migrations.AddConstraint(
model_name='votoparlamentar',
constraint=models.UniqueConstraint(condition=models.Q(ordem__isnull=False), fields=('parlamentar', 'ordem'), name='sessao_votoparlamentar_unique_parlamentar_ordem'),
),
migrations.AddConstraint(
model_name='votoparlamentar',
constraint=models.UniqueConstraint(condition=models.Q(expediente__isnull=False), fields=('parlamentar', 'expediente'), name='sessao_votoparlamentar_unique_parlamentar_expediente'),
),
]

39
sapl/sessao/migrations/0071_votacao_aberta_unique_constraint.py

@ -0,0 +1,39 @@
# Generated by Django 2.2.28 on 2026-08-16 18:58
from django.db import migrations, models
def fecha_matérias_abertas_duplicadas(apps, schema_editor):
"""
Pré-requisito para o AddConstraint abaixo: se já existir mais de uma
OrdemDia/ExpedienteMateria com votacao_aberta=True (dado deixado por
versões anteriores, que não garantiam essa invariante), a constraint
falharia ao ser criada. Mantém aberta apenas a mais recentemente
modificada de cada tabela e fecha as demais.
"""
for model_name in ('OrdemDia', 'ExpedienteMateria'):
model = apps.get_model('sessao', model_name)
abertas = model.objects.filter(votacao_aberta=True).order_by('-id')
for materia in abertas[1:]:
materia.votacao_aberta = False
materia.save(update_fields=['votacao_aberta'])
class Migration(migrations.Migration):
dependencies = [
('sessao', '0070_votoparlamentar_unique_constraint'),
]
operations = [
migrations.RunPython(
fecha_matérias_abertas_duplicadas, migrations.RunPython.noop),
migrations.AddConstraint(
model_name='expedientemateria',
constraint=models.UniqueConstraint(condition=models.Q(votacao_aberta=True), fields=('votacao_aberta',), name='sessao_expedientemateria_unique_votacao_aberta'),
),
migrations.AddConstraint(
model_name='ordemdia',
constraint=models.UniqueConstraint(condition=models.Q(votacao_aberta=True), fields=('votacao_aberta',), name='sessao_ordemdia_unique_votacao_aberta'),
),
]

225
sapl/sessao/migrations/0072_sessao_presencas_e_materias_votacoes_views.py

@ -0,0 +1,225 @@
from django.db import migrations
class Migration(migrations.Migration):
"""
Views Postgres portadas de feat/painel-votacao-v2 (migration
0070_views_sessao_plenaria.py naquela branch — o número colide com
0070/0071 desta branch por coincidência de ponto de fork comum,
daí o 0072 aqui), com correções para preservar exatamente o
comportamento atual de sapl/painel/views.py:
- COALESCE usa 'Sem Registro' (texto atual), não 'SEM PARTIDO'.
- id da view é o da própria linha de presença, não o do parlamentar.
- Sem checagem de intervalo de datas do Mandato (código atual não
checa; a view original checava, o que é mais estrito e mudaria
quem aparece como presente).
- reverse_sql explícito (a migration original não tinha nenhum,
deixando-a irreversível).
- sessao_oradores_view não é portada (bug real no ON da LATERAL,
e não é o alvo do N+1 que esta mudança resolve).
"""
dependencies = [
('sessao', '0071_votacao_aberta_unique_constraint'),
]
operations = [
migrations.RunSQL(
sql="""
DROP VIEW IF EXISTS sessao_presencas_view;
CREATE VIEW sessao_presencas_view AS
--
-- PRESENCAS DE PARLAMENTARES COM FILIACAO (SE HOUVER)
-- PARLAMENTAR DEVE ESTAR ATIVO E TER MANDATO NA LEGISLATURA
-- DA SESSAO (sem checagem de intervalo de datas do
-- mandato — igual ao comportamento Python atual)
--
-- EXPEDIENTE
SELECT
presenca.id,
presenca.sessao_plenaria_id,
'expediente' AS etapa_sessao,
p.id as parlamentar_id,
p.nome_parlamentar as nome_parlamentar,
COALESCE(af.sigla, 'Sem Registro') AS filiacao,
p.ativo
FROM sessao_sessaoplenariapresenca AS presenca
JOIN sessao_sessaoplenaria AS sp
ON presenca.sessao_plenaria_id = sp.id
JOIN parlamentares_parlamentar AS p
ON presenca.parlamentar_id = p.id
JOIN parlamentares_mandato AS m
ON p.id = m.parlamentar_id
AND m.legislatura_id = sp.legislatura_id
LEFT JOIN LATERAL (
SELECT pa.sigla
FROM parlamentares_filiacao f
JOIN parlamentares_partido pa ON pa.id = f.partido_id
WHERE f.parlamentar_id = p.id
AND f.data <= sp.data_inicio
AND (f.data_desfiliacao IS NULL OR f.data_desfiliacao >= sp.data_inicio)
ORDER BY f.data DESC
LIMIT 1
) AS af ON TRUE
WHERE p.ativo = TRUE
UNION ALL
-- ORDEM DO DIA
SELECT
presenca.id,
presenca.sessao_plenaria_id,
'ordemdia' AS etapa_sessao,
p.id as parlamentar_id,
p.nome_parlamentar as nome_parlamentar,
COALESCE(af.sigla, 'Sem Registro') AS filiacao,
p.ativo
FROM sessao_presencaordemdia AS presenca
JOIN sessao_sessaoplenaria AS sp
ON presenca.sessao_plenaria_id = sp.id
JOIN parlamentares_parlamentar AS p
ON presenca.parlamentar_id = p.id
JOIN parlamentares_mandato AS m
ON p.id = m.parlamentar_id
AND m.legislatura_id = sp.legislatura_id
LEFT JOIN LATERAL (
SELECT pa.sigla
FROM parlamentares_filiacao f
JOIN parlamentares_partido pa ON pa.id = f.partido_id
WHERE f.parlamentar_id = p.id
AND f.data <= sp.data_inicio
AND (f.data_desfiliacao IS NULL OR f.data_desfiliacao >= sp.data_inicio)
ORDER BY f.data DESC
LIMIT 1
) AS af ON TRUE
WHERE p.ativo = TRUE
ORDER BY sessao_plenaria_id, etapa_sessao, nome_parlamentar
""",
reverse_sql="DROP VIEW IF EXISTS sessao_presencas_view;",
),
migrations.RunSQL(
sql="""
DROP VIEW IF EXISTS sessao_materias_votacoes_view;
CREATE VIEW sessao_materias_votacoes_view AS
--
-- Votacao de Materias (Simbolica/Nominal/Secreta) — Leitura
-- (tipo_votacao=4) fica de fora, ela não usa este caminho.
--
-- EXPEDIENTE
WITH votacao_materias AS (
SELECT em.sessao_plenaria_id,
em.id id,
'expediente' etapa_sessao,
em.numero_ordem,
em.materia_id,
tm.descricao||' nº '||ml.numero||' de '||ml.ano as materia_texto,
ml.ementa materia_ementa,
tipo_votacao,
CASE tipo_votacao
WHEN 1 THEN 'Simbólica'
WHEN 2 THEN 'Nominal'
WHEN 3 THEN 'Secreta'
WHEN 4 THEN 'Leitura'
ELSE ''
END as tipo_votacao_descricao,
resultado_votacao,
em.resultado,
numero_votos,
votos_parlamentares,
votacao_aberta
FROM sessao_expedientemateria em
JOIN materia_materialegislativa ml ON (em.materia_id = ml.id)
JOIN materia_tipomaterialegislativa tm ON (ml.tipo_id = tm.id)
LEFT JOIN LATERAL (
SELECT jsonb_build_object(
'votos_sim', coalesce(rv.numero_votos_sim, 0),
'votos_nao', coalesce(rv.numero_votos_nao, 0),
'abstencoes', coalesce(rv.numero_abstencoes, 0),
'total_votos', coalesce(rv.numero_votos_sim, 0) +
coalesce(rv.numero_votos_nao, 0) +
coalesce(rv.numero_abstencoes, 0)
) as numero_votos,
trv.nome resultado_votacao
FROM sessao_registrovotacao rv
JOIN sessao_tiporesultadovotacao trv on (rv.tipo_resultado_votacao_id = trv.id)
WHERE rv.expediente_id = em.id AND tipo_votacao != 4) rv ON TRUE
LEFT JOIN LATERAL (
SELECT em.sessao_plenaria_id,
em.numero_ordem,
jsonb_object_agg(
vp.parlamentar_id,
jsonb_build_object(
'materia_id', em.materia_id,
'parlamentar_id', vp.parlamentar_id,
'parlamentar_nome', p.nome_parlamentar,
'voto', vp.voto
)) as votos_parlamentares
FROM sessao_votoparlamentar vp
JOIN parlamentares_parlamentar p ON (vp.parlamentar_id = p.id)
WHERE vp.expediente_id = em.id AND em.tipo_votacao != 4
GROUP BY em.sessao_plenaria_id, em.numero_ordem
) vp ON TRUE
UNION ALL
-- ORDEM DIA
SELECT od.sessao_plenaria_id,
od.id id,
'ordemdia' etapa_sessao,
od.numero_ordem,
od.materia_id,
tm.descricao||' nº '||ml.numero||' de '||ml.ano as materia_texto,
ml.ementa,
tipo_votacao,
CASE tipo_votacao
WHEN 1 THEN 'Simbólica'
WHEN 2 THEN 'Nominal'
WHEN 3 THEN 'Secreta'
WHEN 4 THEN 'Leitura'
ELSE ''
END as tipo_votacao_descricao,
resultado_votacao,
od.resultado,
numero_votos,
votos_parlamentares,
votacao_aberta
FROM sessao_ordemdia od
JOIN materia_materialegislativa ml ON (od.materia_id = ml.id)
JOIN materia_tipomaterialegislativa tm ON (ml.tipo_id = tm.id)
LEFT JOIN LATERAL (
SELECT jsonb_build_object(
'votos_sim', coalesce(rv.numero_votos_sim, 0),
'votos_nao', coalesce(rv.numero_votos_nao, 0),
'abstencoes', coalesce(rv.numero_abstencoes, 0),
'total_votos', coalesce(rv.numero_votos_sim, 0) +
coalesce(rv.numero_votos_nao, 0) +
coalesce(rv.numero_abstencoes, 0)
) as numero_votos,
trv.nome resultado_votacao
FROM sessao_registrovotacao rv
JOIN sessao_tiporesultadovotacao trv on (rv.tipo_resultado_votacao_id = trv.id)
WHERE rv.ordem_id = od.id AND tipo_votacao != 4) rv ON TRUE
LEFT JOIN LATERAL (
SELECT od.sessao_plenaria_id,
od.numero_ordem,
jsonb_object_agg(
vp.parlamentar_id,
jsonb_build_object(
'materia_id', od.materia_id,
'parlamentar_id', vp.parlamentar_id,
'parlamentar_nome', p.nome_parlamentar,
'voto', vp.voto
)) as votos_parlamentares
FROM sessao_votoparlamentar vp
JOIN parlamentares_parlamentar p ON (vp.parlamentar_id = p.id)
WHERE vp.ordem_id = od.id AND od.tipo_votacao != 4
GROUP BY od.sessao_plenaria_id, od.numero_ordem
) vp ON TRUE
)
SELECT *
FROM votacao_materias
ORDER BY sessao_plenaria_id, etapa_sessao, numero_ordem
""",
reverse_sql="DROP VIEW IF EXISTS sessao_materias_votacoes_view;",
),
]

53
sapl/sessao/migrations/0073_sessaomateriasvotacoesview_sessaopresencasview.py

@ -0,0 +1,53 @@
# Generated by Django 2.2.28 on 2026-08-17 19:32
import django.contrib.postgres.fields.jsonb
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('sessao', '0072_sessao_presencas_e_materias_votacoes_views'),
]
operations = [
migrations.CreateModel(
name='SessaoMateriasVotacoesView',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('sessao_plenaria_id', models.IntegerField()),
('etapa_sessao', models.CharField(max_length=20)),
('numero_ordem', models.IntegerField()),
('materia_id', models.IntegerField()),
('materia_texto', models.TextField()),
('materia_ementa', models.TextField()),
('tipo_votacao', models.IntegerField()),
('tipo_votacao_descricao', models.CharField(max_length=20)),
('resultado_votacao', models.TextField(blank=True, null=True)),
('resultado', models.TextField(blank=True, null=True)),
('numero_votos', django.contrib.postgres.fields.jsonb.JSONField(blank=True, null=True)),
('votos_parlamentares', django.contrib.postgres.fields.jsonb.JSONField(blank=True, null=True)),
('votacao_aberta', models.NullBooleanField()),
],
options={
'db_table': 'sessao_materias_votacoes_view',
'managed': False,
},
),
migrations.CreateModel(
name='SessaoPresencasView',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('sessao_plenaria_id', models.IntegerField()),
('etapa_sessao', models.CharField(max_length=20)),
('parlamentar_id', models.IntegerField()),
('nome_parlamentar', models.TextField()),
('filiacao', models.TextField()),
('ativo', models.BooleanField()),
],
options={
'db_table': 'sessao_presencas_view',
'managed': False,
},
),
]

88
sapl/sessao/models.py

@ -1,6 +1,10 @@
import datetime
from enum import Enum
from operator import xor from operator import xor
from django.contrib.postgres.fields.jsonb import JSONField
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
@ -433,6 +437,15 @@ class ExpedienteMateria(AbstractOrdemDia):
verbose_name = _('Matéria do Expediente') verbose_name = _('Matéria do Expediente')
verbose_name_plural = _('Matérias do Expediente') verbose_name_plural = _('Matérias do Expediente')
ordering = ['numero_ordem'] ordering = ['numero_ordem']
constraints = [
# No máximo uma matéria de expediente aberta para votação por
# vez (mesma invariante que abrir_votacao() já garante na
# aplicação — isto é o backstop no nível do banco).
models.UniqueConstraint(
fields=['votacao_aberta'],
condition=models.Q(votacao_aberta=True),
name='sessao_expedientemateria_unique_votacao_aberta'),
]
class TipoExpediente(models.Model): class TipoExpediente(models.Model):
@ -583,6 +596,15 @@ class OrdemDia(AbstractOrdemDia):
verbose_name = _('Matéria da Ordem do Dia') verbose_name = _('Matéria da Ordem do Dia')
verbose_name_plural = _('Matérias da Ordem do Dia') verbose_name_plural = _('Matérias da Ordem do Dia')
ordering = ['numero_ordem'] ordering = ['numero_ordem']
constraints = [
# No máximo uma matéria de ordem do dia aberta para votação por
# vez (mesma invariante que abrir_votacao() já garante na
# aplicação — isto é o backstop no nível do banco).
models.UniqueConstraint(
fields=['votacao_aberta'],
condition=models.Q(votacao_aberta=True),
name='sessao_ordemdia_unique_votacao_aberta'),
]
class PresencaOrdemDia(models.Model): # OrdemDiaPresenca class PresencaOrdemDia(models.Model): # OrdemDiaPresenca
@ -720,6 +742,20 @@ class VotoParlamentar(models.Model): # RegistroVotacaoParlamentar
verbose_name = _('Registro de Votação de Parlamentar') verbose_name = _('Registro de Votação de Parlamentar')
verbose_name_plural = _('Registros de Votações de Parlamentares') verbose_name_plural = _('Registros de Votações de Parlamentares')
ordering = ('id',) ordering = ('id',)
constraints = [
# Garante, no nível do banco, no máximo um voto por parlamentar
# por matéria — get_or_create() sozinho não protege contra duas
# inserções concorrentes (ex.: o tablet do parlamentar e o
# formulário em lote do operador chegando ao mesmo tempo).
models.UniqueConstraint(
fields=['parlamentar', 'ordem'],
condition=models.Q(ordem__isnull=False),
name='sessao_votoparlamentar_unique_parlamentar_ordem'),
models.UniqueConstraint(
fields=['parlamentar', 'expediente'],
condition=models.Q(expediente__isnull=False),
name='sessao_votoparlamentar_unique_parlamentar_expediente'),
]
def __str__(self): def __str__(self):
return _('Votação: %(votacao)s - Parlamentar: %(parlamentar)s') % { return _('Votação: %(votacao)s - Parlamentar: %(parlamentar)s') % {
@ -1070,3 +1106,55 @@ class Correspondencia(models.Model):
def __str__(self): def __str__(self):
return _('Correspondência: {}').format(self.documento) return _('Correspondência: {}').format(self.documento)
class SessaoPresencasView(models.Model):
'''
Backed by the `sessao_presencas_view` Postgres view (migration 0072) —
pré-agrega presença + mandato ativo + filiação partidária, evitando o
N+1 que sapl/painel/views.py::get_presentes() fazia consultando
mandato_set/filiacao_data() por parlamentar presente. `id` não é
globalmente único entre as duas etapas (expediente/ordemdia usam
tabelas de presença diferentes, cujas PKs podem colidir numericamente)
— sempre filtrar também por etapa_sessao, nunca só por id.
'''
sessao_plenaria_id = models.IntegerField()
etapa_sessao = models.CharField(max_length=20)
parlamentar_id = models.IntegerField()
nome_parlamentar = models.TextField()
filiacao = models.TextField()
ativo = models.BooleanField()
class Meta:
managed = False
db_table = 'sessao_presencas_view'
class SessaoMateriasVotacoesView(models.Model):
'''
Backed by the `sessao_materias_votacoes_view` Postgres view
(migration 0072) — pré-agrega o resultado da votação e os votos de
cada parlamentar (`votos_parlamentares`, JSONB, chaves string) numa
única linha, evitando o N+1 que
sapl/painel/views.py::get_votos() fazia consultando VotoParlamentar
por parlamentar presente. `id` não é globalmente único entre as duas
etapas (ExpedienteMateria/OrdemDia têm PKs independentes) — sempre
filtrar também por etapa_sessao.
'''
sessao_plenaria_id = models.IntegerField()
etapa_sessao = models.CharField(max_length=20)
numero_ordem = models.IntegerField()
materia_id = models.IntegerField()
materia_texto = models.TextField()
materia_ementa = models.TextField()
tipo_votacao = models.IntegerField()
tipo_votacao_descricao = models.CharField(max_length=20)
resultado_votacao = models.TextField(null=True, blank=True)
resultado = models.TextField(null=True, blank=True)
numero_votos = JSONField(null=True, blank=True)
votos_parlamentares = JSONField(null=True, blank=True)
votacao_aberta = models.NullBooleanField()
class Meta:
managed = False
db_table = 'sessao_materias_votacoes_view'

383
sapl/sessao/tests/test_votacao_nominal.py

@ -0,0 +1,383 @@
import pytest
from django.db import IntegrityError
from django.urls import reverse
from model_bakery import baker
from sapl.materia.models import MateriaLegislativa, TipoMateriaLegislativa
from sapl.parlamentares.models import (Legislatura, Parlamentar,
SessaoLegislativa)
from sapl.sessao.models import (OrdemDia, PresencaOrdemDia, RegistroVotacao,
SessaoPlenaria, TipoResultadoVotacao,
TipoSessaoPlenaria, VotoParlamentar)
NOMINAL = 2
def _sessao_plenaria():
legislatura = baker.make(Legislatura)
sessao_legislativa = baker.make(SessaoLegislativa)
tipo = baker.make(TipoSessaoPlenaria)
return baker.make(SessaoPlenaria, legislatura=legislatura,
sessao_legislativa=sessao_legislativa, tipo=tipo, numero=1)
def _materia():
tipo_materia = baker.make(TipoMateriaLegislativa)
return baker.make(MateriaLegislativa, tipo=tipo_materia)
def _ordem_nominal_aberta():
sessao = _sessao_plenaria()
materia = _materia()
ordem = baker.make(OrdemDia, sessao_plenaria=sessao, materia=materia,
tipo_votacao=NOMINAL, votacao_aberta=True,
registro_aberto=False)
return sessao, ordem
def _presente(sessao):
parlamentar = baker.make(Parlamentar, ativo=True)
baker.make(PresencaOrdemDia, sessao_plenaria=sessao, parlamentar=parlamentar)
return parlamentar
def _registrar_url(sessao, ordem):
return reverse('sapl.sessao:votacaonominal',
kwargs={'pk': sessao.pk, 'oid': ordem.pk, 'mid': ordem.materia_id})
@pytest.mark.django_db(transaction=False)
def test_get_registrar_votacao_nao_bloqueia_novos_votos(admin_client):
"""
Regressão da causa raiz #1: abrir a tela "Registrar Votação" não pode,
sozinho, impedir que vereadores que ainda não votaram continuem votando.
"""
sessao, ordem = _ordem_nominal_aberta()
_presente(sessao)
response = admin_client.get(_registrar_url(sessao, ordem))
assert response.status_code == 200
ordem.refresh_from_db()
assert ordem.registro_aberto is False
@pytest.mark.django_db(transaction=False)
def test_post_sem_acao_reconhecida_apenas_renderiza(admin_client):
"""
O botão "Registrar Votação" agora faz POST (para evitar cache/replay de
GET), mas sem nenhuma chave de ação reconhecida isso deve continuar
sendo pura navegação, sem nenhum efeito colateral.
"""
sessao, ordem = _ordem_nominal_aberta()
_presente(sessao)
response = admin_client.post(_registrar_url(sessao, ordem), {})
assert response.status_code == 200
ordem.refresh_from_db()
assert ordem.registro_aberto is False
@pytest.mark.django_db(transaction=False)
def test_bloquear_e_reabrir_votacao_nao_mexe_em_votos_existentes(admin_client):
sessao, ordem = _ordem_nominal_aberta()
votante = _presente(sessao)
baker.make(VotoParlamentar, ordem=ordem, parlamentar=votante, voto='Sim')
url = _registrar_url(sessao, ordem)
response = admin_client.post(url, {'bloquear-registro-votacao': '1'})
assert response.status_code == 302
ordem.refresh_from_db()
assert ordem.registro_aberto is True
response = admin_client.post(url, {'reabrir-votacao': '1'})
assert response.status_code == 302
ordem.refresh_from_db()
assert ordem.registro_aberto is False
voto = VotoParlamentar.objects.get(ordem=ordem, parlamentar=votante)
assert voto.voto == 'Sim'
@pytest.mark.django_db(transaction=False)
def test_salvar_votacao_nao_sobrescreve_voto_ja_registrado(admin_client):
"""
Regressão do modelo de concorrência (3b): um formulário de "Fechar
Votação" com um valor obsoleto para quem já votou pelo tablet não pode
sobrescrever esse voto — só preenche quem ainda não tem voto registrado.
"""
sessao, ordem = _ordem_nominal_aberta()
ja_votou = _presente(sessao)
ainda_nao_votou = _presente(sessao)
baker.make(VotoParlamentar, ordem=ordem, parlamentar=ja_votou, voto='Sim')
tipo_resultado = baker.make(TipoResultadoVotacao, nome='Aprovada', natureza='A')
url = _registrar_url(sessao, ordem)
payload = {
'salvar-votacao': '1',
'resultado_votacao': str(tipo_resultado.pk),
'observacao': '',
'voto_parlamentar': [
# valor obsoleto: a tela do operador ainda não sabia que este
# parlamentar já havia votado "Sim" pelo tablet
'Não Votou:{}'.format(ja_votou.pk),
'Não:{}'.format(ainda_nao_votou.pk),
],
}
response = admin_client.post(url, payload)
assert response.status_code == 302
voto_ja_votou = VotoParlamentar.objects.get(ordem=ordem, parlamentar=ja_votou)
assert voto_ja_votou.voto == 'Sim'
voto_novo = VotoParlamentar.objects.get(ordem=ordem, parlamentar=ainda_nao_votou)
assert voto_novo.voto == 'Não'
registro = RegistroVotacao.objects.get(ordem=ordem)
assert registro.numero_votos_sim == 1
assert registro.numero_votos_nao == 1
ordem.refresh_from_db()
assert ordem.votacao_aberta is False
assert ordem.registro_aberto is False
@pytest.mark.django_db(transaction=False)
def test_salvar_votacao_sem_votos_nao_trava_selects_para_nova_tentativa(admin_client):
"""
Regressão: fechar a votação sem nenhum voto real corretamente mostra um
erro, mas antes disso o laço de salvamento em lote criava um
VotoParlamentar com voto='Não Votou' para cada parlamentar cujo select
não foi tocado (o valor padrão do <select>). Esses registros persistiam
mesmo com o fechamento falhando (o bloco atomic não é revertido, já que
form_invalid retorna normalmente em vez de lançar), e nominal.html
desabilita o <select> de qualquer parlamentar com um VotoParlamentar
existente — travando o operador para sempre sem conseguir registrar
nenhum voto para essa matéria.
"""
sessao, ordem = _ordem_nominal_aberta()
parlamentar = _presente(sessao)
tipo_resultado = baker.make(TipoResultadoVotacao, nome='Aprovada', natureza='A')
url = _registrar_url(sessao, ordem)
payload = {
'salvar-votacao': '1',
'resultado_votacao': str(tipo_resultado.pk),
'observacao': '',
'voto_parlamentar': ['Não Votou:{}'.format(parlamentar.pk)],
}
response = admin_client.post(url, payload)
assert response.status_code == 302
assert not VotoParlamentar.objects.filter(
ordem=ordem, parlamentar=parlamentar).exists()
ordem.refresh_from_db()
assert ordem.votacao_aberta is True
# O operador consegue tentar de novo, agora com um voto real.
response2 = admin_client.post(url, {
'salvar-votacao': '1',
'resultado_votacao': str(tipo_resultado.pk),
'observacao': '',
'voto_parlamentar': ['Sim:{}'.format(parlamentar.pk)],
})
assert response2.status_code == 302
ordem.refresh_from_db()
assert ordem.votacao_aberta is False
assert VotoParlamentar.objects.get(ordem=ordem, parlamentar=parlamentar).voto == 'Sim'
@pytest.mark.django_db(transaction=False)
def test_status_da_votacao_reflete_troca_de_voto_do_parlamentar(admin_client):
"""
Regressão: a tela de registro (nominal.html) não atualizava a linha de
um parlamentar que trocou o voto durante a janela de votação — o poll
antigo só marcava "já votou" uma vez e nunca revisitava o valor. O poll
(?status=1) precisa sempre devolver o voto atual, não só se existe.
"""
sessao, ordem = _ordem_nominal_aberta()
parlamentar = _presente(sessao)
voto = baker.make(VotoParlamentar, ordem=ordem, parlamentar=parlamentar,
voto='Sim')
url = _registrar_url(sessao, ordem) + '?status=1'
response = admin_client.get(url)
assert response.status_code == 200
data = response.json()
assert data['votos'] == {str(parlamentar.pk): 'Sim'}
assert data['votacao_aberta'] is True
assert data['registro_aberto'] is False
assert data['ja_registrada'] is False
voto.voto = 'Não'
voto.save()
response2 = admin_client.get(url)
assert response2.json()['votos'] == {str(parlamentar.pk): 'Não'}
@pytest.mark.django_db(transaction=False)
def test_status_da_votacao_nao_depende_de_mostrar_voto(admin_client):
"""
O poll da tela de registro é só para a Mesa, não para o público — ao
contrário de sapl.painel:dados_painel, ele não pode mascarar o valor
real do voto por trás de "Voto Informado" mesmo quando a Casa configura
mostrar_voto=False (essa config controla o telão público, não a tela de
registro da própria Mesa). Como o endpoint nem consulta essa
configuração, isso é garantido por construção — este teste só
documenta a expectativa.
"""
sessao, ordem = _ordem_nominal_aberta()
parlamentar = _presente(sessao)
baker.make(VotoParlamentar, ordem=ordem, parlamentar=parlamentar,
voto='Abstenção')
url = _registrar_url(sessao, ordem) + '?status=1'
data = admin_client.get(url).json()
assert data['votos'][str(parlamentar.pk)] == 'Abstenção'
@pytest.mark.django_db(transaction=False)
def test_status_da_votacao_nao_redireciona_apos_encerrar_votacao(
admin_client):
"""
_get_materia_votacao (usado pelo GET normal) redireciona com uma
mensagem quando a matéria já foi votada — comportamento certo para
navegação, errado para um poll em background. O branch ?status=1 não
pode herdar esse redirect.
"""
sessao, ordem = _ordem_nominal_aberta()
parlamentar = _presente(sessao)
tipo_resultado = baker.make(TipoResultadoVotacao, nome='Aprovada',
natureza='A')
url = _registrar_url(sessao, ordem)
admin_client.post(url, {
'salvar-votacao': '1',
'resultado_votacao': str(tipo_resultado.pk),
'observacao': '',
'voto_parlamentar': ['Sim:{}'.format(parlamentar.pk)],
})
response = admin_client.get(url + '?status=1')
assert response.status_code == 200
data = response.json()
assert data['ja_registrada'] is True
assert data['votacao_aberta'] is False
@pytest.mark.django_db(transaction=False)
def test_abrir_votacao_ja_aberta_e_idempotente(admin_client):
"""
Regressão: clicar em "Abrir Votação" para uma matéria que já está aberta
(ex.: duplo clique, ou a lista ainda não recarregou para trocar o botão
por "Registrar Votação") caía em verifica_votacoes_abertas(), que trata
a própria matéria como uma "outra" votação conflitante — mostra a
mensagem "existem votações abertas... foram fechadas" e fecha a matéria
(para reabri-la em seguida, já que o código sempre prossegue). O
resultado final até ficava certo (votacao_aberta=True), mas a mensagem
era enganosa. Reabrir a própria matéria já aberta precisa ser
silenciosamente idempotente.
"""
sessao, ordem = _ordem_nominal_aberta()
sessao.iniciada = True
sessao.finalizada = False
sessao.save()
_presente(sessao)
url = reverse('sapl.sessao:abrir_votacao', kwargs={'pk': ordem.pk, 'spk': sessao.pk})
url += '?tipo_materia=ordem'
response = admin_client.get(url, follow=True)
assert response.status_code == 200
ordem.refresh_from_db()
assert ordem.votacao_aberta is True
mensagens = [str(m) for m in response.context['messages']]
assert not any('foram fechadas' in m for m in mensagens)
@pytest.mark.django_db(transaction=False)
def test_unique_constraint_impede_voto_duplicado(admin_client):
sessao, ordem = _ordem_nominal_aberta()
parlamentar = _presente(sessao)
baker.make(VotoParlamentar, ordem=ordem, parlamentar=parlamentar, voto='Sim')
with pytest.raises(IntegrityError):
VotoParlamentar.objects.create(
ordem=ordem, parlamentar=parlamentar, voto='Não')
@pytest.mark.django_db(transaction=False)
def test_unique_constraint_impede_duas_ordens_abertas():
sessao, ordem = _ordem_nominal_aberta()
outra = baker.make(OrdemDia, sessao_plenaria=sessao, materia=ordem.materia,
tipo_votacao=NOMINAL, votacao_aberta=False)
with pytest.raises(IntegrityError):
outra.votacao_aberta = True
outra.save()
@pytest.mark.django_db(transaction=False)
def test_migracao_0071_fecha_duplicatas_antes_da_constraint():
"""
A função de dados da migration 0071 precisa fechar duplicatas
pré-existentes antes do AddConstraint — senão a migration falharia ao
ser aplicada num banco com dado antigo (de antes desta invariante
existir). Testa a função isoladamente: como o teste roda dentro de uma
transação que é desfeita no final, é seguro derrubar o índice aqui
(DDL é transacional no Postgres).
"""
import importlib
from django.apps import apps as real_apps
from django.db import connection
migracao = importlib.import_module(
'sapl.sessao.migrations.0071_votacao_aberta_unique_constraint')
with connection.cursor() as cursor:
cursor.execute('DROP INDEX sessao_ordemdia_unique_votacao_aberta')
sessao, mais_antiga = _ordem_nominal_aberta()
mais_recente = baker.make(OrdemDia, sessao_plenaria=sessao,
materia=mais_antiga.materia, tipo_votacao=NOMINAL,
votacao_aberta=True)
assert mais_recente.pk > mais_antiga.pk
migracao.fecha_matérias_abertas_duplicadas(real_apps, None)
mais_antiga.refresh_from_db()
mais_recente.refresh_from_db()
assert mais_antiga.votacao_aberta is False
assert mais_recente.votacao_aberta is True
@pytest.mark.django_db(transaction=False)
def test_api_nao_permite_abrir_votacao_via_patch(admin_client):
"""
Regressão do bypass encontrado na auditoria: a API auto-gerada
(drfautoapi) não pode mais aceitar votacao_aberta/registro_aberto —
senão qualquer "Operador de Sessão Plenária" conseguiria abrir uma
matéria via PATCH direto, pulando verifica_votacoes_abertas() e o
lock em abrir_votacao().
"""
sessao, ordem = _ordem_nominal_aberta()
ordem.votacao_aberta = False
ordem.save()
url = '/api/sessao/ordemdia/{}/'.format(ordem.pk)
response = admin_client.patch(
url, data={'votacao_aberta': True}, content_type='application/json')
assert response.status_code in (200, 202)
ordem.refresh_from_db()
assert ordem.votacao_aberta is False

64
sapl/sessao/urls.py

@ -39,9 +39,13 @@ from sapl.sessao.views import (AdicionarVariasMateriasExpediente,
TransferenciaMateriasExpediente, TransferenciaMateriasOrdemDia, TransferenciaMateriasExpediente, TransferenciaMateriasOrdemDia,
filtra_materias_copia_sessao_ajax, verifica_materia_sessao_plenaria_ajax, filtra_materias_copia_sessao_ajax, verifica_materia_sessao_plenaria_ajax,
recuperar_tramitacao, CorrespondenciaEmLoteView, recuperar_tramitacao, CorrespondenciaEmLoteView,
CorrespondenciaCrud, recuperar_documento) CorrespondenciaCrud, recuperar_documento,
votacao_nominal_v2_view, votacao_simbolica_v2_view,
leitura_v2_view, leitura_v2_action)
from django.views.generic import TemplateView
from .apps import AppConfig from .apps import AppConfig
app_name = AppConfig.name app_name = AppConfig.name
@ -149,12 +153,13 @@ 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'),
url(r'^sessao/(?P<pk>\d+)/votacao_bloco/votnom$', url(r'^sessao/(?P<pk>\d+)/votacao_bloco/votacao/nominal$',
VotacaoEmBlocoNominalView.as_view(), name='votacaobloconom'), VotacaoEmBlocoNominalView.as_view(), name='votacaobloconom'),
url(r'^sessao/(?P<pk>\d+)/votacao_bloco/votsimb$', url(r'^sessao/(?P<pk>\d+)/votacao_bloco/votacao/simbolica$',
VotacaoEmBlocoSimbolicaView.as_view(), name='votacaoblocosimb'), VotacaoEmBlocoSimbolicaView.as_view(), name='votacaoblocosimb'),
url(r'^sessao/(?P<pk>\d+)/votacao_bloco_expediente$', url(r'^sessao/(?P<pk>\d+)/votacao_bloco_expediente$',
VotacaoEmBlocoExpediente.as_view(), VotacaoEmBlocoExpediente.as_view(),
@ -169,39 +174,58 @@ 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'),
url(r'^sessao/(?P<pk>\d+)/matordemdia/votnom/(?P<oid>\d+)/(?P<mid>\d+)$',
# VOTACAO
url(r'^sessao/(?P<pk>\d+)/v2/votacao$',
votacao_nominal_v2_view,
name='votacaonominal_v2'),
url(r'^sessao/(?P<pk>\d+)/materia/ordemdia/votacao/simbolica/v2/(?P<oid>\d+)/(?P<mid>\d+)$',
votacao_simbolica_v2_view,
name='votacaosimbolica_v2'),
url(r'^sessao/(?P<pk>\d+)/materia/ordemdia/leitura/v2/(?P<oid>\d+)/(?P<mid>\d+)$',
leitura_v2_view,
name='leituraod_v2'),
url(r'^sessao/(?P<pk>\d+)/materia/ordemdia/leitura/v2/(?P<oid>\d+)/(?P<mid>\d+)/salvar$',
leitura_v2_action,
name='leituraod_v2_action'),
url(r'^sessao/(?P<pk>\d+)/v2/painel$',
TemplateView.as_view(template_name='sessao/painel_v2.html')),
url(r'^sessao/(?P<pk>\d+)/materia/ordemdia/votacao/nominal/(?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+)/materia/ordemdia/votacao/nominal/edit/(?P<oid>\d+)/(?P<mid>\d+)$',
VotacaoNominalEditView.as_view(), name='votacaonominaledit'), VotacaoNominalEditView.as_view(), name='votacaonominaledit'),
url(r'^sessao/(?P<pk>\d+)/matordemdia/votsec/(?P<oid>\d+)/(?P<mid>\d+)$', url(r'^sessao/(?P<pk>\d+)/materia/ordemdia/votacao/secreta/(?P<oid>\d+)/(?P<mid>\d+)$',
VotacaoView.as_view(), name='votacaosecreta'), VotacaoView.as_view(), name='votacaosecreta'),
url(r'^sessao/(?P<pk>\d+)/matordemdia/votsec/view/(?P<oid>\d+)/(?P<mid>\d+)$', url(r'^sessao/(?P<pk>\d+)/materia/ordemdia/votacao/secreta/view/(?P<oid>\d+)/(?P<mid>\d+)$',
VotacaoEditView.as_view(), name='votacaosecretaedit'), VotacaoEditView.as_view(), name='votacaosecretaedit'),
url(r'^sessao/(?P<pk>\d+)/matordemdia/votsimb/(?P<oid>\d+)/(?P<mid>\d+)$', url(r'^sessao/(?P<pk>\d+)/materia/ordemdia/votacao/simbolica/(?P<oid>\d+)/(?P<mid>\d+)$',
VotacaoView.as_view(), name='votacaosimbolica'), VotacaoView.as_view(), name='votacaosimbolica'),
url(r'^sessao/(?P<pk>\d+)/matordemdia/votsimbbloco/$', url(r'^sessao/(?P<pk>\d+)/materia/ordemdia/votacao/simbolico/bloco/$',
VotacaoView.as_view(), name='votacaosimbolicabloco'), VotacaoView.as_view(), name='votacaosimbolicabloco'),
url(r'^sessao/(?P<pk>\d+)/matordemdia/votsimb/view/(?P<oid>\d+)/(?P<mid>\d+)$', url(r'^sessao/(?P<pk>\d+)/materia/ordemdia/votacao/simbolica/view/(?P<oid>\d+)/(?P<mid>\d+)$',
VotacaoEditView.as_view(), name='votacaosimbolicaedit'), VotacaoEditView.as_view(), name='votacaosimbolicaedit'),
url(r'^sessao/(?P<pk>\d+)/matexp/votnom/(?P<oid>\d+)/(?P<mid>\d+)$', url(r'^sessao/(?P<pk>\d+)/materia/expediente/votacao/nominal/(?P<oid>\d+)/(?P<mid>\d+)$',
VotacaoNominalExpedienteView.as_view(), name='votacaonominalexp'), VotacaoNominalExpedienteView.as_view(), name='votacaonominalexp'),
url(r'^sessao/(?P<pk>\d+)/matexp/votnom/edit/(?P<oid>\d+)/(?P<mid>\d+)$', url(r'^sessao/(?P<pk>\d+)/materia/expediente/votacao/nominal/edit/(?P<oid>\d+)/(?P<mid>\d+)$',
VotacaoNominalExpedienteEditView.as_view(), VotacaoNominalExpedienteEditView.as_view(),
name='votacaonominalexpedit'), name='votacaonominalexpedit'),
url(r'^sessao/(?P<pk>\d+)/matexp/votnom/detail/(?P<oid>\d+)/(?P<mid>\d+)$', url(r'^sessao/(?P<pk>\d+)/materia/expediente/votacao/nominal/detail/(?P<oid>\d+)/(?P<mid>\d+)$',
VotacaoNominalExpedienteDetailView.as_view(), VotacaoNominalExpedienteDetailView.as_view(),
name='votacaonominalexpdetail'), name='votacaonominalexpdetail'),
url(r'^sessao/(?P<pk>\d+)/matexp/votsimb/(?P<oid>\d+)/(?P<mid>\d+)$', url(r'^sessao/(?P<pk>\d+)/materia/expediente/votacao/simbolica/(?P<oid>\d+)/(?P<mid>\d+)$',
VotacaoExpedienteView.as_view(), name='votacaosimbolicaexp'), VotacaoExpedienteView.as_view(), name='votacaosimbolicaexp'),
url(r'^sessao/(?P<pk>\d+)/matexp/votsimb/view/(?P<oid>\d+)/(?P<mid>\d+)$', url(r'^sessao/(?P<pk>\d+)/materia/expediente/votacao/simbolica/view/(?P<oid>\d+)/(?P<mid>\d+)$',
VotacaoExpedienteEditView.as_view(), name='votacaosimbolicaexpedit'), VotacaoExpedienteEditView.as_view(), name='votacaosimbolicaexpedit'),
url(r'^sessao/(?P<pk>\d+)/matexp/votsec/(?P<oid>\d+)/(?P<mid>\d+)$', url(r'^sessao/(?P<pk>\d+)/materia/expediente/votacao/secreta/(?P<oid>\d+)/(?P<mid>\d+)$',
VotacaoExpedienteView.as_view(), name='votacaosecretaexp'), VotacaoExpedienteView.as_view(), name='votacaosecretaexp'),
url(r'^sessao/(?P<pk>\d+)/matexp/votsec/view/(?P<oid>\d+)/(?P<mid>\d+)$', url(r'^sessao/(?P<pk>\d+)/materia/expediente/votacao/secreta/view/(?P<oid>\d+)/(?P<mid>\d+)$',
VotacaoExpedienteEditView.as_view(), name='votacaosecretaexpedit'), VotacaoExpedienteEditView.as_view(), name='votacaosecretaexpedit'),
url(r'^sessao/(?P<pk>\d+)/votacao-nominal-transparencia/(?P<oid>\d+)/(?P<mid>\d+)$', url(r'^sessao/(?P<pk>\d+)/votacao-nominal-transparencia/(?P<oid>\d+)/(?P<mid>\d+)$',
VotacaoNominalTransparenciaDetailView.as_view(), VotacaoNominalTransparenciaDetailView.as_view(),
@ -213,15 +237,15 @@ urlpatterns = [
mudar_ordem_materia_sessao, mudar_ordem_materia_sessao,
name='mudar_ordem_materia_sessao'), name='mudar_ordem_materia_sessao'),
url(r'^sessao/(?P<pk>\d+)/matexp/leitura/(?P<oid>\d+)/(?P<mid>\d+)$', url(r'^sessao/(?P<pk>\d+)/materia/expediente/leitura/(?P<oid>\d+)/(?P<mid>\d+)$',
ExpedienteLeituraView.as_view(), name='leituraexp'), ExpedienteLeituraView.as_view(), name='leituraexp'),
url(r'^sessao/(?P<pk>\d+)/matordemdia/leitura/(?P<oid>\d+)/(?P<mid>\d+)$', url(r'^sessao/(?P<pk>\d+)/materia/ordemdia/leitura/(?P<oid>\d+)/(?P<mid>\d+)$',
OrdemDiaLeituraView.as_view(), name='leituraod'), OrdemDiaLeituraView.as_view(), name='leituraod'),
url(r'^sessao/(?P<pk>\d+)/(?P<iso>\d+)/(?P<oid>\d+)/retirar-leitura$', url(r'^sessao/(?P<pk>\d+)/(?P<iso>\d+)/(?P<oid>\d+)/retirar-leitura$',
retirar_leitura, name='retirar_leitura'), retirar_leitura, name='retirar_leitura'),
url(r'^sessao/(?P<pk>\d+)/transf-mat-exp$', url(r'^sessao/(?P<pk>\d+)/transferencia-materia-expediente$',
TransferenciaMateriasExpediente.as_view(), TransferenciaMateriasExpediente.as_view(),
name="transf_mat_exp"), name="transf_mat_exp"),
url(r'^sessao/(?P<pk>\d+)/transf-mat-ordemdia$', url(r'^sessao/(?P<pk>\d+)/transf-mat-ordemdia$',

792
sapl/sessao/views.py

File diff suppressed because it is too large

18
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
@ -100,8 +101,25 @@ INSTALLED_APPS = (
'django_prometheus', 'django_prometheus',
'channels',
) + SAPL_APPS ) + SAPL_APPS
# WebSockets (painel/votação em tempo real — ver plano de migração
# polling->push em docs internas). channels==3.0.3 é a versão mais recente
# compatível com Django 2.2 (channels 4.x exige Django >= 4.2).
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')],
'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

113
sapl/templates/painel/index.html

@ -35,6 +35,7 @@
</style> </style>
</head> </head>
<body class="painel-principal"> <body class="painel-principal">
<div id="ws-status-banner" style="display:none; position:fixed; top:0; left:0; right:0; z-index:2000; padding:10px 16px; text-align:center; font-weight:bold; color:#fff;"></div>
<audio type="hidden" id="audio" src="{% webpack_static 'audio/ring.mp3' %}"></audio> <audio type="hidden" id="audio" src="{% webpack_static 'audio/ring.mp3' %}"></audio>
<div class="d-flex justify-content-center"> <div class="d-flex justify-content-center">
@ -266,20 +267,13 @@
var aparte_previous; var aparte_previous;
var consideracoes_previous; var consideracoes_previous;
const POLL_INTERVAL_MS = // Renderiza o payload completo que o WebSocket manda em cada broadcast
window.location.hostname === "localhost" ? 2000 : 500; // (build_dados_painel() — antes também servido por um endpoint HTTP de
// polling, removido junto com o fallback).
var counter = 1; function renderDadosPainel(data) {
(function poll() { if (!data) {
$.ajax({ return;
url: "{% url 'sapl.painel:dados_painel' sessao_id %}", }
type: "GET",
dataType: "json",
complete: function() {
setTimeout(poll, POLL_INTERVAL_MS);
},
timeout: 20000, // TODO: decrease
success: function(data) {
$("#sessao_plenaria").text(data["sessao_plenaria"]) $("#sessao_plenaria").text(data["sessao_plenaria"])
$("#sessao_plenaria_data").text("Data Início: " + data["sessao_plenaria_data"]) $("#sessao_plenaria_data").text("Data Início: " + data["sessao_plenaria_data"])
$("#sessao_plenaria_hora_inicio").text("Hora Início: " + data["sessao_plenaria_hora_inicio"]) $("#sessao_plenaria_hora_inicio").text("Hora Início: " + data["sessao_plenaria_hora_inicio"])
@ -496,11 +490,94 @@
$("#mat_em_votacao").text("Matéria em Leitura"); $("#mat_em_votacao").text("Matéria em Leitura");
} }
} }
},
error: function(err) {
console.error(err);
} }
})
// Sem fallback de polling: esta página vive inteiramente do
// WebSocket. PainelConsumer.connect() (sapl/painel/consumers.py)
// manda um snapshot completo assim que a conexão é aceita — então
// tanto a carga inicial quanto toda atualização depois vêm daqui.
// Se a conexão não sobe (Redis/Daphne fora do ar), showWsBanner()
// deixa isso visível na página em vez de deixá-la muda em silêncio.
(function() {
var banner = document.getElementById('ws-status-banner');
function showWsBanner(message, isError) {
if (!banner) return;
banner.textContent = message;
banner.style.display = 'block';
banner.style.background = isError ? '#b00020' : '#b8860b';
}
function hideWsBanner() {
if (!banner) return;
banner.style.display = 'none';
}
// Fechados explicitamente por PainelConsumer.connect() para
// condições que uma nova tentativa não resolve sozinha.
var PERMANENT_CLOSE_MESSAGES = {
4401: 'Sessão expirada ou usuário não autenticado. Faça login novamente e recarregue a página.',
4403: 'Seu usuário não tem permissão para acessar esta tela.',
4404: 'Sessão plenária não encontrada.'
};
var wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
var wsUrl = wsProtocol + '//' + window.location.host + '/ws/painel/{{ sessao_id }}/';
var MIN_RECONNECT_DELAY_MS = 1000;
var MAX_RECONNECT_DELAY_MS = 30000;
var reconnectDelayMs = MIN_RECONNECT_DELAY_MS;
var socket = null;
function nextDelay() {
var base = Math.min(reconnectDelayMs * 2, MAX_RECONNECT_DELAY_MS);
// +/-30% de jitter — evita que várias telas conectadas na mesma
// sessão reconectem todas no mesmo instante após uma queda do
// Daphne, batendo em build_dados_painel() ao mesmo tempo.
return Math.round(base * (0.7 + Math.random() * 0.6));
}
function connect() {
socket = new WebSocket(wsUrl);
socket.onopen = function() {
reconnectDelayMs = MIN_RECONNECT_DELAY_MS;
hideWsBanner();
};
socket.onmessage = function(event) {
var message;
try {
message = JSON.parse(event.data);
} catch (e) {
return;
}
if (message && message.type === 'data') {
renderDadosPainel(message.payload);
}
};
socket.onclose = function(event) {
var permanentMessage = PERMANENT_CLOSE_MESSAGES[event.code];
if (permanentMessage) {
showWsBanner(permanentMessage, true);
return;
}
showWsBanner(
'Conexão em tempo real perdida. Verifique se o Redis e o ' +
'Daphne (ou "./manage.py runserver") estão em execução. ' +
'Tentando reconectar…',
false
);
setTimeout(connect, reconnectDelayMs);
reconnectDelayMs = nextDelay();
};
socket.onerror = function() {
socket.close();
};
}
connect();
})(); })();
}); });

120
sapl/templates/painel/mensagem.html

@ -1,120 +0,0 @@
{% load i18n %}
{% load common_tags %}
{% load render_bundle from webpack_loader %}
{% load webpack_static from webpack_loader %}
<!DOCTYPE HTML>
<!--[if IE 8]> <html class="no-js lt-ie9" lang="pt-br"> <![endif]-->
<!--[if gt IE 8]><!-->
<html lang="pt-br">
<!--<![endif]-->
<head>
<meta charset="UTF-8">
<!-- TODO: does it need this head_title here? -->
<title>{% block head_title %}{% trans 'SAPL - Sistema de Apoio ao Processo Legislativo' %}{% endblock %}</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
{% render_chunk_vendors 'css' %}
{% render_bundle 'global' 'css' %}
{% render_bundle 'painel' 'css' %}
<STYLE type="text/css">
@media screen {
body {font-size: medium; color: white; line-height: 1em; background: black;}
}
</STYLE>
</head>
<body>
<h1>{{ context.title }}</h1>
<input id="json_url" type="hidden" value="{% url 'sapl.painel:dados_painel' %}">
<h2>Ajax refresh counter: <span id="counter"></span></h2>
<h3>
<span id="sessao_plenaria"></span><br/><br/>
<span id="sessao_plenaria_data"></span><br/><br/>
<span id="sessao_plenaria_hora_inicio"></span></br><br/>
<h2><span id="relogio"></span></h2></br><br/><br/>
<span id="materia_legislativa_texto"></span><br/>
<span id="observacao_materia"></span>
</h3>
</body>
{% render_chunk_vendors 'js' %}
{% render_bundle 'global' 'js' %}
{% render_bundle 'painel' 'js' %}
<script type="text/javascript">
$(document).ready(function() {
//TODO: replace by a fancy jQuery clock
function checkTime(i) {
if (i<10) {i = "0" + i}; // add zero in front of numbers < 10
return i;
}
function startTime() {
var today=new Date();
var h=today.getHours();
var m=today.getMinutes();
var s=today.getSeconds();
m = checkTime(m);
s = checkTime(s);
$("#relogio").text(h+":"+m+":"+s)
var t = setTimeout(function(){
startTime()
},500);
}
startTime();
var counter = 1;
(function poll() {
$.ajax({
url: $("#json_url").val(),
type: "GET",
success: function(data) {
//TODO: json spitted out is very complex, have to simplify/flat it
//TODO: probably building it by hand on REST side
console.debug(data)
var presentes = $("#parlamentares");
presentes.children().remove();
presentes_ordem_dia = data.presentes_ordem_dia
$.each(presentes_ordem_dia, function(index, parlamentar) {
$('<li />', {text: parlamentar.nome + '/' + parlamentar.partido + ' ' + parlamentar.voto }).appendTo(presentes);
});
var votacao = $("#votacao")
votacao.children().remove()
votacao.append("<li>Sim: " + data["numero_votos_sim"] + "</li>")
votacao.append("<li>Não: " + data["numero_votos_nao"] + "</li>")
votacao.append("<li>Abstenções: " + data["numero_abstencoes"] + "</li>")
votacao.append("<li>Presentes: " + data["presentes"] + "</li>")
votacao.append("<li>Total votos: " + data["total_votos"] + "</li>")
$("#sessao_plenaria").text(data["sessao_plenaria"])
$("#sessao_plenaria_data").text("Data Início: " + data["sessao_plenaria_data"])
$("#sessao_plenaria_hora_inicio").text("Hora Início: " + data["sessao_plenaria_hora_inicio"])
$("#materia_legislativa_texto").text(data["materia_legislativa_texto"])
$("#observacao_materia").text(data["observacao_materia"])
$("#resultado_votacao").text(data["tipo_resultado"])
$("#counter").text(counter);
counter++;
},
error: function(err) {
console.error(err);
},
dataType: "json",
//complete: setTimeout(function() {poll()}, 5000),
timeout: 20000 // TODO: decrease
})
})();
});
</script>
</html>

70
sapl/templates/painel/painel_v2.html

@ -0,0 +1,70 @@
{% load i18n %}
{% load common_tags %}
{% load render_bundle from webpack_loader %}
{% load webpack_static from webpack_loader %}
<!DOCTYPE HTML>
<html lang="pt-br">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block head_title %}{% trans 'SAPL - Sistema de Apoio ao Processo Legislativo' %}{% endblock %}</title>
<link
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css"
rel="stylesheet"
/>
{% block webpack_loader_css %}
{% render_chunk_vendors 'css' %}
{% render_bundle 'global' 'css' %}
{% render_bundle 'painel' 'css' %}
{% endblock webpack_loader_css %}
</head>
<body class="min-vh-100 d-flex flex-column p-2">
{% block vue_content %}
<style>[v-cloak]{display:none}</style>
<div id="painel" v-cloak class="container-fluid" data-controller-id="{{ controller_id }}">
<ws-status-banner :status="wsStatus" :message="wsErrorMessage"></ws-status-banner>
<div class="row g-3">
<div class="col-md-9 separador-vertical pe-4">
<div class="painel p-3 h-100 d-flex flex-column">
<painel-header ref="painelHeader"></painel-header>
<painel-parlamentares ref="parlamentares"></painel-parlamentares>
</div>
</div>
<div class="col-md-3 d-flex flex-column gap-3">
<painel-materia ref="materia"></painel-materia>
<painel-oradores ref="oradores"></painel-oradores>
<painel-cronometro-list ref="cronometro_list"></painel-cronometro-list>
<painel-resultado ref="resultado"></painel-resultado>
<div class="painel p-3 d-flex flex-column" id="tema_solene_div" v-if="sessao && sessao.sessao_solene">
<h2 class="text-subtitle">Tema da Sessão Solene</h2>
<span id="sessao_solene_tema" class="text-value">[[ sessao.tema_solene ]]</span>
</div>
<painel-footer ref="painelFooter"></painel-footer>
</div>
</div>
</div>
{% endblock %}
</body>
{% block webpack_loader_js %}
{% render_chunk_vendors 'js' %}
{% render_bundle 'global' 'js' %}
{% render_bundle 'painel' 'js' %}
{% endblock webpack_loader_js %}
{% block webpack_loader_chunks_js %}
{% endblock webpack_loader_chunks_js %}
</html>

128
sapl/templates/painel/parlamentares.html

@ -1,128 +0,0 @@
{% load i18n %}
{% load common_tags %}
{% load render_bundle from webpack_loader %}
{% load webpack_static from webpack_loader %}
<!DOCTYPE HTML>
<!--[if IE 8]> <html class="no-js lt-ie9" lang="pt-br"> <![endif]-->
<!--[if gt IE 8]><!-->
<html lang="pt-br">
<!--<![endif]-->
<head>
<meta charset="UTF-8">
<!-- TODO: does it need this head_title here? -->
<title>{% block head_title %}{% trans 'SAPL - Sistema de Apoio ao Processo Legislativo' %}{% endblock %}</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
{% render_chunk_vendors 'css' %}
{% render_bundle 'global' 'css' %}
{% render_bundle 'painel' 'css' %}
<STYLE type="text/css">
@media screen {
body {font-size: medium; color: white; line-height: 1em; background: black;}
}
</STYLE>
</head>
<body>
<h1>{{ context.title }}</h1>
<input id="json_url" type="hidden" value="{% url 'sapl.painel:dados_painel' %}">
<h3>
<span id="sessao_plenaria"></span><br/><br/>
<span id="sessao_plenaria_data"></span><br/><br/>
<span id="sessao_plenaria_hora_inicio"></span></br><br/>
<h2><span id="relogio"></span></h2>
<table>
<tr>
<td>
<ul id="parlamentares">
</ul>
</td>
</tr>
</table>
</h3>
</body>
{% render_chunk_vendors 'js' %}
{% render_bundle 'global' 'js' %}
{% render_bundle 'painel' 'js' %}
<script type="text/javascript">
$(document).ready(function() {
//TODO: replace by a fancy jQuery clock
function checkTime(i) {
if (i<10) {i = "0" + i}; // add zero in front of numbers < 10
return i;
}
function startTime() {
var today=new Date();
var h=today.getHours();
var m=today.getMinutes();
var s=today.getSeconds();
m = checkTime(m);
s = checkTime(s);
$("#relogio").text(h+":"+m+":"+s)
var t = setTimeout(function(){
startTime()
},500);
}
startTime();
var counter = 1;
(function poll() {
$.ajax({
url: $("#json_url").val(),
type: "GET",
success: function(data) {
//TODO: json spitted out is very complex, have to simplify/flat it
//TODO: probably building it by hand on REST side
console.debug(data)
var presentes = $("#parlamentares");
presentes.children().remove();
presentes_ordem_dia = data.presentes_ordem_dia
$.each(presentes_ordem_dia, function(index, parlamentar) {
$('<li />', {text: parlamentar.nome + '/' + parlamentar.partido }).appendTo(presentes);
/*$('<li />', {text: parlamentar.nome + '/' + parlamentar.partido + ' ' + parlamentar.voto }).appendTo(presentes);*/
});
var votacao = $("#votacao")
votacao.children().remove()
votacao.append("<li>Sim: " + data["numero_votos_sim"] + "</li>")
votacao.append("<li>Não: " + data["numero_votos_nao"] + "</li>")
votacao.append("<li>Abstenções: " + data["numero_abstencoes"] + "</li>")
votacao.append("<li>Presentes: " + data["presentes"] + "</li>")
votacao.append("<li>Total votos: " + data["total_votos"] + "</li>")
$("#sessao_plenaria").text(data["sessao_plenaria"])
$("#sessao_plenaria_data").text("Data Início: " + data["sessao_plenaria_data"])
$("#sessao_plenaria_hora_inicio").text("Hora Início: " + data["sessao_plenaria_hora_inicio"])
$("#materia_legislativa_texto").text(data["materia_legislativa_texto"])
$("#observacao_materia").text(data["observacao_materia"])
$("#resultado_votacao").text(data["tipo_resultado"])
$("#counter").text(counter);
counter++;
},
error: function(err) {
console.error(err);
},
dataType: "json",
//complete: setTimeout(function() {poll()}, 5000),
timeout: 20000 // TODO: decrease
})
})();
});
</script>
</html>

123
sapl/templates/painel/votacao.html

@ -1,123 +0,0 @@
{% load i18n %}
{% load render_bundle from webpack_loader %}
{% load webpack_static from webpack_loader %}
<!DOCTYPE HTML>
<!--[if IE 8]> <html class="no-js lt-ie9" lang="pt-br"> <![endif]-->
<!--[if gt IE 8]><!-->
<html lang="pt-br">
<!--<![endif]-->
<head>
<meta charset="UTF-8">
<!-- TODO: does it need this head_title here? -->
<title>{% block head_title %}{% trans 'SAPL - Sistema de Apoio ao Processo Legislativo' %}{% endblock %}</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
{% render_chunk_vendors 'css' %}
{% render_bundle 'global' 'css' %}
{% render_bundle 'painel' 'css' %}
<STYLE type="text/css">
@media screen {
body {font-size: medium; color: white; line-height: 1em; background: black;}
}
</STYLE>
</head>
<body>
<h1>{{ context.title }}</h1>
<input id="json_url" type="hidden" value="{% url 'sapl.painel:dados_painel' %}">
<h3>
<span id="sessao_plenaria"></span><br/><br/>
<span id="sessao_plenaria_data"></span><br/><br/>
<span id="sessao_plenaria_hora_inicio"></span></br><br/>
<h2><span id="relogio"></span></h2>
<table>
<tr>
<td>
<ul id="votacao">
</ul>
</td>
</tr>
</table>
<span id="resultado_votacao"></span><br/>
</h3>
</body>
{% render_chunk_vendors 'js' %}
{% render_bundle 'global' 'js' %}
{% render_bundle 'painel' 'js' %}
<script type="text/javascript">
$(document).ready(function() {
//TODO: replace by a fancy jQuery clock
function checkTime(i) {
if (i<10) {i = "0" + i}; // add zero in front of numbers < 10
return i;
}
function startTime() {
var today=new Date();
var h=today.getHours();
var m=today.getMinutes();
var s=today.getSeconds();
m = checkTime(m);
s = checkTime(s);
$("#relogio").text(h+":"+m+":"+s)
var t = setTimeout(function(){
startTime()
},500);
}
startTime();
var counter = 1;
(function poll() {
$.ajax({
url: $("#json_url").val(),
type: "GET",
success: function(data) {
//TODO: json spitted out is very complex, have to simplify/flat it
//TODO: probably building it by hand on REST side
console.debug(data)
var presentes = $("#parlamentares");
presentes.children().remove();
presentes_ordem_dia = data.presentes_ordem_dia
$.each(presentes_ordem_dia, function(index, parlamentar) {
$('<li />', {text: parlamentar.nome + '/' + parlamentar.partido + ' ' + parlamentar.voto }).appendTo(presentes);
});
var votacao = $("#votacao")
votacao.children().remove()
votacao.append("<li>Sim: " + data["numero_votos_sim"] + "</li>")
votacao.append("<li>Não: " + data["numero_votos_nao"] + "</li>")
votacao.append("<li>Abstenções: " + data["numero_abstencoes"] + "</li>")
votacao.append("<li>Presentes: " + data["presentes"] + "</li>")
votacao.append("<li>Total votos: " + data["total_votos"] + "</li>")
$("#sessao_plenaria").text(data["sessao_plenaria"])
$("#sessao_plenaria_data").text("Data Início: " + data["sessao_plenaria_data"])
$("#sessao_plenaria_hora_inicio").text("Hora Início: " + data["sessao_plenaria_hora_inicio"])
$("#materia_legislativa_texto").text(data["materia_legislativa_texto"])
$("#observacao_materia").text(data["observacao_materia"])
$("#resultado_votacao").text(data["tipo_resultado"])
$("#counter").text(counter);
counter++;
},
error: function(err) {
console.error(err);
},
dataType: "json",
//complete: setTimeout(function() {poll()}, 5000),
timeout: 20000 // TODO: decrease
})
})();
});
</script>
</html>

139
sapl/templates/painel/voto_individual.html

@ -46,6 +46,8 @@
</head> </head>
<body> <body>
<div id="ws-status-banner" style="display:none; position:fixed; top:0; left:0; right:0; z-index:2000; padding:10px 16px; text-align:center; font-weight:bold; color:#fff;"></div>
{% if not error_message %} {% if not error_message %}
<div id="blocoprincipal" style="display:block"> <div id="blocoprincipal" style="display:block">
<h1><b><font color="#4FA64D"><p align="center">{{sessao}}</p></font></b></h1> <h1><b><font color="#4FA64D"><p align="center">{{sessao}}</p></font></b></h1>
@ -77,6 +79,11 @@
</p> </p>
</font> </font>
</h2> </h2>
{% if status_message %}
<p align="center" style="font-family:Verdana">
<font color="#8fbfe0">{{status_message}}</font>
</p>
{% endif %}
</br> </br>
</br> </br>
@ -111,7 +118,8 @@
</div> </div>
{% else %} {% else %}
<div id="blocoerro" style="display:block"> <div id="blocoerro" style="display:block">
<h2><font color="red"><p align="center" style="font-family:Verdana">{{error_message}}</p></font></h2> <h1><font color="#eaeaea"><p align="center" style="font-family:Verdana">⏸</p></font></h1>
<h2><font color="#f0ad4e"><p align="center" style="font-family:Verdana">{{error_message}}</p></font></h2>
</br> </br>
</br> </br>
</br> </br>
@ -184,11 +192,132 @@
$("#date").text(n); $("#date").text(n);
startTime(); startTime();
setTimeout(function() {
document.location.reload(true);
}, 30000)
}); });
</script> </script>
{% endif %} {% endif %}
<script type="text/javascript">
// Sem poll de segurança: esta tela vive do WebSocket. O broadcast
// (build_dados_painel) carrega o estado compartilhado do painel, não o
// status pessoal deste vereador — então cada 'data' recebido apenas
// dispara verificaEstado(), que busca o status pessoal de verdade e
// recarrega a página só se algo relevante mudou.
(function() {
var estadoAtual = {
materiaId: "{% if materia %}{{ materia.id }}{% endif %}",
errorMessage: "{{ error_message|default:''|escapejs }}",
statusMessage: "{{ status_message|default:''|escapejs }}",
votoParlamentar: "{{ voto_parlamentar|default:''|escapejs }}"
};
function verificaEstado() {
fetch("{% url 'sapl.painel:voto_individual_status' %}", {credentials: 'same-origin'})
.then(function(resp) { return resp.json(); })
.then(function(data) {
var novo = {
materiaId: data.materia_id ? String(data.materia_id) : "",
errorMessage: data.error_message || "",
statusMessage: data.status_message || "",
votoParlamentar: data.voto_parlamentar || ""
};
if (novo.materiaId !== estadoAtual.materiaId ||
novo.errorMessage !== estadoAtual.errorMessage ||
novo.statusMessage !== estadoAtual.statusMessage ||
novo.votoParlamentar !== estadoAtual.votoParlamentar) {
document.location.reload();
}
})
.catch(function() { /* sem poll — só tenta de novo no próximo broadcast */ });
}
var banner = document.getElementById('ws-status-banner');
function showWsBanner(message, isError) {
if (!banner) return;
banner.textContent = message;
banner.style.display = 'block';
banner.style.background = isError ? '#b00020' : '#b8860b';
}
function hideWsBanner() {
if (!banner) return;
banner.style.display = 'none';
}
var PERMANENT_CLOSE_MESSAGES = {
4401: 'Sessão expirada ou usuário não autenticado. Faça login novamente e recarregue a página.',
4403: 'Seu usuário não tem permissão para acessar esta tela.',
4404: 'Sessão plenária não encontrada.'
};
// Antes, a conexão WS só era aberta condicionalmente a haver matéria em
// votação: sem isso, a tela nunca abria WebSocket enquanto nenhuma
// matéria estivesse aberta, e dependia inteiramente do poll (agora
// removido) para notar quando uma matéria abrisse. Conecta sempre que
// houver uma sessão (can_vote()/sessao_votacao() em sapl/painel/views.py
// só preenchem sessao_id quando existe uma SessaoPlenaria com votação
// aberta — com ou sem matéria aberta dentro dela) — abrir_votacao já
// dispara broadcast_dados_painel(). Sem sessao_id não há URL de WS
// válida pra montar (nenhuma sessão aberta agora é um estado normal,
// não uma falha de conexão), então nem tenta.
{% if sessao_id %}
(function() {
var wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
var wsUrl = wsProtocol + '//' + window.location.host + '/ws/painel/{{ sessao_id }}/';
var MIN_RECONNECT_DELAY_MS = 1000;
var MAX_RECONNECT_DELAY_MS = 30000;
var reconnectDelayMs = MIN_RECONNECT_DELAY_MS;
function nextDelay() {
var base = Math.min(reconnectDelayMs * 2, MAX_RECONNECT_DELAY_MS);
return Math.round(base * (0.7 + Math.random() * 0.6));
}
function connect() {
var socket = new WebSocket(wsUrl);
socket.onopen = function() {
reconnectDelayMs = MIN_RECONNECT_DELAY_MS;
hideWsBanner();
verificaEstado();
};
socket.onmessage = function(event) {
var message;
try {
message = JSON.parse(event.data);
} catch (e) {
return;
}
if (message && message.type === 'data') {
verificaEstado();
}
};
socket.onclose = function(event) {
var permanentMessage = PERMANENT_CLOSE_MESSAGES[event.code];
if (permanentMessage) {
showWsBanner(permanentMessage, true);
return;
}
showWsBanner(
'Conexão em tempo real perdida. Verifique se o Redis e o ' +
'Daphne (ou "./manage.py runserver") estão em execução. ' +
'Tentando reconectar…',
false
);
setTimeout(connect, reconnectDelayMs);
reconnectDelayMs = nextDelay();
};
socket.onerror = function() {
socket.close();
};
}
connect();
})();
{% endif %}
})();
</script>

41
sapl/templates/painel/voto_individual_v2.html

@ -0,0 +1,41 @@
{% load i18n %}
{% load common_tags %}
{% load render_bundle from webpack_loader %}
{% load webpack_static from webpack_loader %}
<!DOCTYPE HTML>
<html lang="pt-br">
<head>
<meta charset="UTF-8">
<title>{% block head_title %}{% trans 'SAPL - Sistema de Apoio ao Processo Legislativo' %}{% endblock %}</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css"
rel="stylesheet"
/>
{% render_chunk_vendors 'css' %}
{% render_bundle 'global' 'css' %}
{% render_bundle 'voto-individual' 'css' %}
</head>
<body>
<style>[v-cloak]{display:none}</style>
<input type="hidden" id="csrf-token" value="{{ csrf_token }}">
<div id="voto-individual-app" v-cloak data-sessao-id="{{ sessao_id }}">
<ws-status-banner :status="wsStatus" :message="wsErrorMessage"></ws-status-banner>
<voto-individual ref="votoIndividual"></voto-individual>
</div>
{% render_chunk_vendors 'js' %}
{% render_bundle 'global' 'js' %}
{% render_bundle 'voto-individual' 'js' %}
<script type="text/javascript">
window.VOTO_INDIVIDUAL_URL = "{% url 'sapl.painel:voto_individual_v2' %}";
window.VOTO_INDIVIDUAL_STATUS_URL = "{% url 'sapl.painel:voto_individual_status' %}";
</script>
</body>
</html>

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 %}

39
sapl/templates/sessao/votacao/leitura_v2.html

@ -0,0 +1,39 @@
{% extends "crud/detail.html" %}
{% load i18n %}
{% load common_tags %}
{% load render_bundle from webpack_loader %}
{% load webpack_static from webpack_loader %}
{% block vue_content %}
<style>[v-cloak]{display:none}</style>
<div id="leitura-materia" v-cloak
data-sessao-id="{{ pk }}"
data-oid="{{ oid }}"
data-mid="{{ mid }}"
data-materia-texto="{{ materia_texto }}"
data-materia-ementa="{{ materia_ementa }}"
data-observacao="{{ observacao }}">
<ws-status-banner :status="wsStatus" :message="wsErrorMessage"></ws-status-banner>
<leitura-materia
:error-message="error_message"
:cancel-url="cancelUrl"
:initial-observacao="initialObservacao"
:initial-materia-texto="initialMateriaTexto"
:initial-materia-ementa="initialMateriaEmenta"
@cancelar="onCancelar"
@salvar="onSalvar"
ref="leituraMateria">
</leitura-materia>
</div>
{% endblock vue_content %}
{% block webpack_loader_css %}
{{ block.super }}
{% render_bundle 'leitura-materia' 'css' %}
{% endblock %}
{% block webpack_loader_js %}
{% render_chunk_vendors 'js' %}
{% render_bundle 'global' 'js' %}
{% render_bundle 'leitura-materia' 'js' %}
{% endblock %}

167
sapl/templates/sessao/votacao/nominal.html

@ -2,6 +2,7 @@
{% load i18n crispy_forms_tags %} {% load i18n crispy_forms_tags %}
{% block detail_content %} {% block detail_content %}
<div id="ws-status-banner" style="display:none; position:fixed; top:0; left:0; right:0; z-index:2000; padding:10px 16px; text-align:center; font-weight:bold; color:#fff;"></div>
<form method="post"> <form method="post">
{% csrf_token %} {% csrf_token %}
@ -13,6 +14,15 @@
Ementa: {{materia.ementa|safe}} Ementa: {{materia.ementa|safe}}
</div> </div>
<br /> <br />
<div class="alert {% if registro_aberto %}alert-warning{% else %}alert-info{% endif %}" role="alert" id="status_votacao">
<strong>Status da votação:</strong> em aberto ·
{% if registro_aberto %}
novos votos <strong>bloqueados</strong> — os vereadores que ainda não votaram não conseguem mais votar até a Mesa reabrir
{% else %}
novos votos <strong>permitidos</strong> — os vereadores ainda podem votar pelo tablet enquanto a Mesa registra
{% endif %}
· {{ total }} parlamentar(es) presente(s)
</div>
{% if total == 0 %} {% if total == 0 %}
<div class="alert alert-info alert-dismissible " role="alert"> <div class="alert alert-info alert-dismissible " role="alert">
<div>Não existe nenhum parlamentar presente para que a votação ocorra.</div> <div>Não existe nenhum parlamentar presente para que a votação ocorra.</div>
@ -22,17 +32,18 @@
<fieldset class="form-group"> <fieldset class="form-group">
<legend>Votos</legend> <legend>Votos</legend>
<div class="row"> <div class="row" id="linhas_parlamentares">
{% for parlamentar in parlamentares %} {% for parlamentar in parlamentares %}
<div class="col-md-4" id="styleparlamentar">{{parlamentar.0.nome_parlamentar}}</div> <div class="col-md-4" id="styleparlamentar">{{parlamentar.0.nome_parlamentar}}</div>
<div class="col-md-5"> <div class="col-md-5" data-parlamentar-row="{{parlamentar.0.id}}">
{% if parlamentar.1 %} <input type="hidden" name="voto_parlamentar" value="{{parlamentar.1}}:{{parlamentar.0.id}}" /> {% endif %} {% if parlamentar.1 %} <input type="hidden" name="voto_parlamentar" value="{{parlamentar.1}}:{{parlamentar.0.id}}" /> {% endif %}
<select id="voto_parlamentar" name="voto_parlamentar" class="form-control" {% if parlamentar.1 %} disabled {% endif %}> <select id="voto_parlamentar" name="voto_parlamentar" class="form-control" data-parlamentar-select="{{parlamentar.0.id}}" {% if parlamentar.1 %} disabled {% endif %}>
<option value="Não Votou:{{parlamentar.0.id}}">Não Votou</option> <option value="Não Votou:{{parlamentar.0.id}}">Não Votou</option>
<option value="Sim:{{parlamentar.0.id}}" {% if parlamentar.1 == 'Sim' %} selected {% endif %}>Sim</option> <option value="Sim:{{parlamentar.0.id}}" {% if parlamentar.1 == 'Sim' %} selected {% endif %}>Sim</option>
<option value="Não:{{parlamentar.0.id}}" {% if parlamentar.1 == 'Não' %} selected {% endif %}>Não</option> <option value="Não:{{parlamentar.0.id}}" {% if parlamentar.1 == 'Não' %} selected {% endif %}>Não</option>
<option value="Abstenção:{{parlamentar.0.id}}" {% if parlamentar.1 == 'Abstenção' %} selected {% endif %}>Abstenção</option> <option value="Abstenção:{{parlamentar.0.id}}" {% if parlamentar.1 == 'Abstenção' %} selected {% endif %}>Abstenção</option>
</select> </select>
<span data-parlamentar-tag="{{parlamentar.0.id}}" class="badge badge-info" style="{% if not parlamentar.1 %}display:none;{% endif %}">Já votado (tablet)</span>
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
@ -60,8 +71,17 @@
<div class="row"> <div class="row">
<div class="col-md-12"> <div class="col-md-12">
<div class="form-group row justify-content-between"> <div class="form-group row justify-content-between">
<input type="submit" id="cancelar-votacao" name="cancelar-votacao" value="Cancelar Votação" class="btn btn-warning" /> <input type="submit" id="cancelar-votacao" name="cancelar-votacao" value="Cancelar Votação" class="btn btn-warning"
<input type="submit" id="salvar-votacao" name="salvar-votacao" value="Fechar Votação" class="btn btn-primary" /> data-toggle="tooltip" data-placement="top" title="Apaga todos os votos já registrados desta matéria e reabre a votação do zero." />
{% if registro_aberto %}
<input type="submit" id="reabrir-votacao" name="reabrir-votacao" value="Reabrir Votação para Novos Votos" class="btn btn-secondary"
data-toggle="tooltip" data-placement="top" title="Volta a aceitar votos dos vereadores que ainda não votaram — nenhum voto já registrado é alterado." />
{% else %}
<input type="submit" id="bloquear-registro-votacao" name="bloquear-registro-votacao" value="Bloquear Novos Votos" class="btn btn-info"
data-toggle="tooltip" data-placement="top" title="Impede novos votos dos vereadores enquanto a Mesa apura o resultado. Não fecha a votação." />
{% endif %}
<input type="submit" id="salvar-votacao" name="salvar-votacao" value="Encerrar Votação" class="btn btn-primary"
data-toggle="tooltip" data-placement="top" title="Apura o resultado final e encerra definitivamente a votação desta matéria." />
</div> </div>
</div> </div>
</div> </div>
@ -76,6 +96,8 @@
window.history.back(); window.history.back();
} }
$('[data-toggle="tooltip"]').tooltip();
function conta_votos() { function conta_votos() {
var votos_sim = 0; var votos_sim = 0;
var votos_nao = 0; var votos_nao = 0;
@ -117,5 +139,140 @@
$("input[type=submit], input[type=button]").prop("disabled", "disabled"); $("input[type=submit], input[type=button]").prop("disabled", "disabled");
}); });
// Busca os votos que já chegaram pelo tablet — usa o próprio
// endpoint desta tela (?status=1), não o payload público do telão
// (build_dados_painel(), entregue via WebSocket): aquele mascara o
// valor real do voto quando a Casa configura "mostrar_voto=False"
// (regra pensada para o público, não para a Mesa) e a tela pública
// exige a permissão do módulo painel, que um Operador de Sessão
// Plenária pode não ter.
// Chamada uma vez no carregamento e de novo a cada broadcast do WS
// (não em intervalo fixo — ver mais abaixo), sempre reaplicando o
// valor (não só a primeira vez) para refletir um vereador que troca
// o voto durante a janela de votação — a linha permanece travada
// para edição pelo operador, já que o voto do vereador sempre
// prevalece (VotacaoNominalAbstract.post).
function atualiza_votos_ao_vivo() {
$.ajax({
url: window.location.pathname + '?status=1',
type: "GET",
dataType: "json",
}).done(function(data) {
if (!data || !data.votos) {
return;
}
var mudou = false;
Object.keys(data.votos).forEach(function(parlamentarId) {
var voto = data.votos[parlamentarId];
if (!voto) {
return;
}
var select = $('[data-parlamentar-select="' + parlamentarId + '"]');
if (!select.length) {
return;
}
var optionValue = voto + ':' + parlamentarId;
if (select.val() !== optionValue) {
select.val(optionValue);
mudou = true;
}
if (!select.prop('disabled')) {
select.prop('disabled', true);
}
$('[data-parlamentar-tag="' + parlamentarId + '"]').show();
});
if (mudou) {
conta_votos();
}
});
}
// Chamada única (sem poll) — o estado atual já é o que veio do
// carregamento da página; daí em diante só o WebSocket avisa
// quando reconsultar.
atualiza_votos_ao_vivo();
// O broadcast do WebSocket carrega o payload do painel público
// (build_dados_painel), não o formato de ?status=1 que
// atualiza_votos_ao_vivo() já sabe interpretar — em vez de duplicar
// essa lógica com outro formato, qualquer broadcast só dispara a
// checagem. Sem poll de segurança: se a conexão cair, o banner de
// erro abaixo avisa em vez de a tela ficar presa sem atualizar.
(function() {
var banner = document.getElementById('ws-status-banner');
function showWsBanner(message, isError) {
if (!banner) return;
banner.textContent = message;
banner.style.display = 'block';
banner.style.background = isError ? '#b00020' : '#b8860b';
}
function hideWsBanner() {
if (!banner) return;
banner.style.display = 'none';
}
var PERMANENT_CLOSE_MESSAGES = {
4401: 'Sessão expirada ou usuário não autenticado. Faça login novamente e recarregue a página.',
4403: 'Seu usuário não tem permissão para acessar esta tela.',
4404: 'Sessão plenária não encontrada.'
};
var wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
var wsUrl = wsProtocol + '//' + window.location.host + '/ws/painel/{{ object.pk }}/';
var MIN_RECONNECT_DELAY_MS = 1000;
var MAX_RECONNECT_DELAY_MS = 30000;
var reconnectDelayMs = MIN_RECONNECT_DELAY_MS;
function nextDelay() {
var base = Math.min(reconnectDelayMs * 2, MAX_RECONNECT_DELAY_MS);
return Math.round(base * (0.7 + Math.random() * 0.6));
}
function connect() {
var socket = new WebSocket(wsUrl);
socket.onopen = function() {
reconnectDelayMs = MIN_RECONNECT_DELAY_MS;
hideWsBanner();
atualiza_votos_ao_vivo();
};
socket.onmessage = function(event) {
var message;
try {
message = JSON.parse(event.data);
} catch (e) {
return;
}
if (message && message.type === 'data') {
atualiza_votos_ao_vivo();
}
};
socket.onclose = function(event) {
var permanentMessage = PERMANENT_CLOSE_MESSAGES[event.code];
if (permanentMessage) {
showWsBanner(permanentMessage, true);
return;
}
showWsBanner(
'Conexão em tempo real perdida. Verifique se o Redis e o ' +
'Daphne (ou "./manage.py runserver") estão em execução. ' +
'Tentando reconectar…',
false
);
setTimeout(connect, reconnectDelayMs);
reconnectDelayMs = nextDelay();
};
socket.onerror = function() {
socket.close();
};
}
connect();
})();
</script> </script>
{% endblock extra_js%} {% endblock extra_js%}

38
sapl/templates/sessao/votacao/votacao_simbolica_v2.html

@ -0,0 +1,38 @@
{% extends "crud/detail.html" %}
{% load i18n %}
{% load crispy_forms_tags cropping%}
{% load common_tags %}
{% load render_bundle from webpack_loader %}
{% load webpack_static from webpack_loader %}
{% block vue_content %}
<style>[v-cloak]{display:none}</style>
{{ tipos_resultado|json_script:"tipos-resultado" }}
<div id="votacao-simbolica" v-cloak
data-sessao-id="{{ pk }}"
data-oid="{{ oid }}"
data-mid="{{ mid }}"
data-total-presentes="{{ total_presentes }}"
data-total-votantes="{{ total_votantes }}">
<ws-status-banner :status="wsStatus" :message="wsErrorMessage"></ws-status-banner>
<votacao-simbolica
:error-message="error_message"
:total-presentes="totalPresentes"
:total-votantes="totalVotantes"
@cancelar="onCancelar"
@salvar="onSalvar"
ref="votacaoSimbolica">
</votacao-simbolica>
</div>
{% endblock vue_content %}
{% block webpack_loader_css %}
{{ block.super }}
{% render_bundle 'votacao-simbolica' 'css' %}
{% endblock %}
{% block webpack_loader_js %}
{% render_chunk_vendors 'js' %}
{% render_bundle 'global' 'js' %}
{% render_bundle 'votacao-simbolica' 'js' %}
{% endblock %}

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

@ -0,0 +1,34 @@
{% extends "crud/detail.html" %}
{% load i18n %}
{% load crispy_forms_tags cropping%}
{% load common_tags %}
{% load render_bundle from webpack_loader %}
{% load webpack_static from webpack_loader %}
{% block vue_content %}
<style>[v-cloak]{display:none}</style>
{{ tipos_resultado|json_script:"tipos-resultado" }}
<div id="votacao" v-cloak data-controller-id="{{ pk }}">
<ws-status-banner :status="wsStatus" :message="wsErrorMessage"></ws-status-banner>
<votacao-nominal
:is-open="isOpen"
:error-message="error_message"
:votos-status="votosStatus"
@cast-vote="castVote"
@cancelar="onCancelar"
@fechar="onFechar"
ref="votacaoNominal">
</votacao-nominal>
</div>
{% endblock vue_content %}
{% block webpack_loader_css %}
{{ block.super }}
{% render_bundle 'votacao' 'css' %}
{% endblock %}
{% block webpack_loader_js %}
{% render_chunk_vendors 'js' %}
{% render_bundle 'global' 'js' %}
{% render_bundle 'votacao' 'js' %}
{% endblock %}

138
scripts/verify_painel_websockets.sh

@ -0,0 +1,138 @@
#!/usr/bin/env bash
#
# Starts the two processes needed for manual end-to-end verification of the
# painel (branch feat/painel-websockets): Redis, and a single Daphne process
# serving sapl.asgi:application — which routes both regular HTTP (page
# renders, the views that call broadcast_dados_painel()) and
# /ws/painel/<sessao_id>/ on the SAME port.
#
# This used to run Daphne and `manage.py runserver` on two separate ports.
# That doesn't work anymore: every screen this branch ships (the public
# painel, voto individual, votação nominal, and the Vue v2 pages) connects
# its WebSocket to `window.location.host` — the same host:port the page was
# loaded from — and none of them fall back to HTTP polling if that socket
# never connects. Two ports means the WS connection from a page loaded off
# the runserver port always fails, silently, with no way to notice.
#
# See sapl/sessao/README.md for what to actually click through once this is
# running, and for the daphne-vs-runserver tradeoff during regular dev.
#
# Usage: scripts/verify_painel_websockets.sh
# Stop everything with Ctrl+C.
#
# Ports are overridable via env vars if you need to avoid a clash:
# REDIS_PORT=6380 APP_PORT=8102 scripts/verify_painel_websockets.sh
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
REDIS_PORT="${REDIS_PORT:-6379}"
APP_PORT="${APP_PORT:-8002}"
REDIS_URL="redis://127.0.0.1:${REDIS_PORT}/0"
LOG_DIR="$(mktemp -d /tmp/painel-ws-verify.XXXXXX)"
REDIS_LOG="$LOG_DIR/redis.log"
APP_LOG="$LOG_DIR/daphne.log"
REDIS_PID=""
APP_PID=""
TAIL_PID=""
log() { echo -e "\033[36m[verify]\033[0m $*"; }
err() { echo -e "\033[31m[verify]\033[0m $*" >&2; }
port_in_use() {
lsof -nP -iTCP:"$1" -sTCP:LISTEN >/dev/null 2>&1
}
require_cmd() {
if ! command -v "$1" >/dev/null 2>&1; then
err "'$1' not found on PATH. Activate the project's virtualenv first."
exit 1
fi
}
require_port_free() {
local port="$1" label="$2"
if port_in_use "$port"; then
err "Port $port is already in use (needed for $label)."
err "Find what's on it: lsof -nP -iTCP:$port -sTCP:LISTEN"
exit 1
fi
}
wait_for_tcp() {
local port="$1" label="$2" timeout="${3:-15}" waited=0
while ! port_in_use "$port"; do
sleep 0.5
waited=$((waited + 1))
if (( waited >= timeout * 2 )); then
err "$label did not start listening on port $port within ${timeout}s."
err "Check the logs in $LOG_DIR"
cleanup
exit 1
fi
done
}
cleanup() {
trap - EXIT INT TERM
log "Stopping..."
[[ -n "$TAIL_PID" ]] && kill "$TAIL_PID" 2>/dev/null || true
[[ -n "$APP_PID" ]] && kill "$APP_PID" 2>/dev/null || true
[[ -n "$REDIS_PID" ]] && kill "$REDIS_PID" 2>/dev/null || true
wait 2>/dev/null || true
log "Stopped. Logs kept at $LOG_DIR"
}
trap cleanup EXIT INT TERM
require_cmd lsof
require_cmd redis-server
require_cmd redis-cli
require_cmd daphne
require_port_free "$REDIS_PORT" redis-server
require_port_free "$APP_PORT" daphne
log "Logs: $LOG_DIR"
log "Starting redis-server on port $REDIS_PORT..."
redis-server --port "$REDIS_PORT" --save "" --appendonly no >"$REDIS_LOG" 2>&1 &
REDIS_PID=$!
wait_for_tcp "$REDIS_PORT" redis-server
redis-cli -p "$REDIS_PORT" ping >/dev/null 2>&1 || {
err "redis-server started but is not responding to PING — check $REDIS_LOG"
exit 1
}
log "redis-server ready (pid $REDIS_PID)."
log "Starting daphne (HTTP + WebSocket, one process) on 127.0.0.1:$APP_PORT ..."
(
cd "$PROJECT_DIR"
REDIS_URL="$REDIS_URL" exec daphne -b 127.0.0.1 -p "$APP_PORT" sapl.asgi:application
) >"$APP_LOG" 2>&1 &
APP_PID=$!
wait_for_tcp "$APP_PORT" daphne
log "daphne ready (pid $APP_PID)."
cat <<EOF
$(printf '\033[32mBoth processes are up.\033[0m')
App (HTTP pages + WebSocket, same port): http://127.0.0.1:$APP_PORT/
Redis: 127.0.0.1:$REDIS_PORT
See sapl/sessao/README.md for what to click through and test accounts/data.
There's no HTTP-polling fallback on any of these screens — if a page never
updates, check this terminal's daphne log for a WebSocket close/error first.
Press Ctrl+C to stop both.
EOF
log "Tailing logs (Ctrl+C to stop everything)..."
tail -n +1 -f "$REDIS_LOG" "$APP_LOG" &
TAIL_PID=$!
wait "$TAIL_PID"

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

37
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,36 @@ 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
.entry('voto-individual')
.add('./frontend/src/__apps/voto-individual/main.js')
.end()
config
.entry('votacao-simbolica')
.add('./frontend/src/__apps/votacao-simbolica/main.js')
.end()
config
.entry('leitura-materia')
.add('./frontend/src/__apps/leitura-materia/main.js')
.end()
config config
.entry('compilacao') .entry('compilacao')
.add('./frontend/src/__apps/compilacao/main.js') .add('./frontend/src/__apps/compilacao/main.js')

27
yarn.lock

@ -1725,6 +1725,16 @@
optionalDependencies: optionalDependencies:
prettier "^1.18.2 || ^2.0.0" prettier "^1.18.2 || ^2.0.0"
"@vue/composition-api@^1.7.2":
version "1.7.2"
resolved "https://registry.yarnpkg.com/@vue/composition-api/-/composition-api-1.7.2.tgz#0b656f3ec39fefc2cf40aaa8c12426bcfeae1b44"
integrity sha512-M8jm9J/laYrYT02665HkZ5l2fWTK4dcVg3BsDHm/pfz+MjDYwX+9FUaZyGwEyXEDonQYRCo0H7aLgdklcIELjw==
"@vue/devtools-api@^6.6.3":
version "6.6.4"
resolved "https://registry.yarnpkg.com/@vue/devtools-api/-/devtools-api-6.6.4.tgz#cbe97fe0162b365edc1dba80e173f90492535343"
integrity sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==
"@vue/shared@3.4.27": "@vue/shared@3.4.27":
version "3.4.27" version "3.4.27"
resolved "https://registry.npmjs.org/@vue/shared/-/shared-3.4.27.tgz" resolved "https://registry.npmjs.org/@vue/shared/-/shared-3.4.27.tgz"
@ -3758,7 +3768,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:
@ -5333,6 +5343,14 @@ picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.3.1:
resolved "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz" resolved "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz"
integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==
pinia@^2.1.7:
version "2.3.1"
resolved "https://registry.yarnpkg.com/pinia/-/pinia-2.3.1.tgz#54c476675b72f5abcfafa24a7582531ea8c23d94"
integrity sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==
dependencies:
"@vue/devtools-api" "^6.6.3"
vue-demi "^0.14.10"
pkg-dir@^4.1.0: pkg-dir@^4.1.0:
version "4.2.0" version "4.2.0"
resolved "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz" resolved "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz"
@ -6730,6 +6748,11 @@ vary@~1.1.2:
resolved "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz" resolved "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz"
integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg== integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==
vue-demi@^0.14.10:
version "0.14.10"
resolved "https://registry.yarnpkg.com/vue-demi/-/vue-demi-0.14.10.tgz#afc78de3d6f9e11bf78c55e8510ee12814522f04"
integrity sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==
vue-eslint-parser@^9.4.2: vue-eslint-parser@^9.4.2:
version "9.4.2" version "9.4.2"
resolved "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-9.4.2.tgz" resolved "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-9.4.2.tgz"
@ -6783,7 +6806,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==

Loading…
Cancel
Save