Browse Source

Merge 6e53b72e95 into ecfd65a8b3

pull/3861/merge
Edward 6 days ago
committed by GitHub
parent
commit
a3257be456
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 1
      .python-version
  2. 3
      RUNBOOK.txt
  3. 19
      docker/config/nginx/sapl.conf
  4. 9
      docker/docker-compose-dev.yml
  5. 14
      docker/docker-compose.yaml
  6. 2
      docker/startup_scripts/start.sh
  7. 125
      frontend/src/__apps/leitura-materia/main.js
  8. 1
      frontend/src/__apps/leitura-materia/scss/leitura-materia.scss
  9. 38
      frontend/src/__apps/painel-controle/main.js
  10. 43
      frontend/src/__apps/painel-controle/scss/painel.scss
  11. 176
      frontend/src/__apps/painel/main.js
  12. 59
      frontend/src/__apps/painel/scss/painel.scss
  13. 88
      frontend/src/__apps/painel/store/painelStore.js
  14. 182
      frontend/src/__apps/painel/ws/normalizePainel.js
  15. 41
      frontend/src/__apps/painel/ws/painelSocket.js
  16. 28
      frontend/src/__apps/votacao-nominal-bloco/main.js
  17. 1
      frontend/src/__apps/votacao-nominal-bloco/scss/votacao-nominal-bloco.scss
  18. 137
      frontend/src/__apps/votacao-simbolica/main.js
  19. 1
      frontend/src/__apps/votacao-simbolica/scss/votacao-simbolica.scss
  20. 307
      frontend/src/__apps/votacao/main.js
  21. 0
      frontend/src/__apps/votacao/scss/votacao.scss
  22. 116
      frontend/src/__apps/voto-individual/main.js
  23. 18
      frontend/src/__apps/voto-individual/scss/voto-individual.scss
  24. 90
      frontend/src/components/leitura-materia/LeituraMateria.vue
  25. 168
      frontend/src/components/painel/Cronometro.vue
  26. 213
      frontend/src/components/painel/CronometroList.vue
  27. 64
      frontend/src/components/painel/PainelFooter.vue
  28. 36
      frontend/src/components/painel/PainelHeader.vue
  29. 46
      frontend/src/components/painel/PainelMateria.vue
  30. 41
      frontend/src/components/painel/PainelOradores.vue
  31. 171
      frontend/src/components/painel/PainelParlamentares.vue
  32. 106
      frontend/src/components/painel/PainelResultado.vue
  33. 41
      frontend/src/components/painel/WsStatusBanner.vue
  34. 74
      frontend/src/components/votacao-nominal-bloco/VotacaoNominalBloco.vue
  35. 128
      frontend/src/components/votacao-simbolica/VotacaoSimbolica.vue
  36. 26
      frontend/src/components/votacao/VotacaoMateria.vue
  37. 101
      frontend/src/components/votacao/VotacaoNominal.vue
  38. 85
      frontend/src/components/votacao/VotacaoObservacoes.vue
  39. 49
      frontend/src/components/votacao/VotacaoResultado.vue
  40. 64
      frontend/src/components/votacao/VotacaoVotos.vue
  41. 83
      frontend/src/components/voto-individual/VotoIndividual.vue
  42. 705
      frontend/webpack-stats.json
  43. 14525
      package-lock.json
  44. 7
      package.json
  45. 4
      requirements/requirements.txt
  46. 1
      requirements/test-requirements.txt
  47. 26
      sapl/api/serializers.py
  48. 19
      sapl/api/tests/test_serializers_sessao.py
  49. 23
      sapl/asgi.py
  50. 18
      sapl/audiencia/migrations/0023_auto_20260916_1709.py
  51. 4
      sapl/compilacao/utils.py
  52. 33
      sapl/materia/migrations/0088_auto_20260916_1709.py
  53. 29
      sapl/norma/migrations/0046_auto_20260916_1709.py
  54. 54
      sapl/painel/README.md
  55. 215
      sapl/painel/consumers.py
  56. 265
      sapl/painel/tests/test_broadcasts.py
  57. 157
      sapl/painel/tests/test_consumers.py
  58. 325
      sapl/painel/tests/tests.py
  59. 39
      sapl/painel/urls.py
  60. 1129
      sapl/painel/views.py
  61. 23
      sapl/protocoloadm/migrations/0047_auto_20260916_1709.py
  62. 283
      sapl/sessao/README.md
  63. 31
      sapl/sessao/admin.py
  64. 4
      sapl/sessao/forms.py
  65. 54
      sapl/sessao/migrations/0071_votoparlamentar_unique_constraint.py
  66. 45
      sapl/sessao/migrations/0072_votacao_aberta_unique_constraint.py
  67. 237
      sapl/sessao/migrations/0073_sessao_presencas_e_materias_votacoes_views.py
  68. 53
      sapl/sessao/migrations/0074_sessaomateriasvotacoesview_sessaopresencasview.py
  69. 94
      sapl/sessao/models.py
  70. 516
      sapl/sessao/tests/test_votacao_nominal.py
  71. 76
      sapl/sessao/urls.py
  72. 1066
      sapl/sessao/views.py
  73. 18
      sapl/settings.py
  74. BIN
      sapl/static/sapl/frontend/.DS_Store.gz
  75. BIN
      sapl/static/sapl/frontend/css/chunk-vendors.045ec640.css.gz
  76. 1
      sapl/static/sapl/frontend/css/compilacao.0baf3580.css
  77. BIN
      sapl/static/sapl/frontend/css/compilacao.0baf3580.css.gz
  78. 1
      sapl/static/sapl/frontend/css/compilacao.84d2df71.css
  79. BIN
      sapl/static/sapl/frontend/css/compilacao.84d2df71.css.gz
  80. BIN
      sapl/static/sapl/frontend/css/global.042f6737.css.gz
  81. 1
      sapl/static/sapl/frontend/css/leitura-materia.fc544ce7.css
  82. BIN
      sapl/static/sapl/frontend/css/leitura-materia.fc544ce7.css.gz
  83. 1
      sapl/static/sapl/frontend/css/painel.780f8a6d.css
  84. BIN
      sapl/static/sapl/frontend/css/painel.780f8a6d.css.gz
  85. 1
      sapl/static/sapl/frontend/css/painel.c215b6be.css
  86. BIN
      sapl/static/sapl/frontend/css/painel.c215b6be.css.gz
  87. 1
      sapl/static/sapl/frontend/css/painel.e2b9504e.css
  88. BIN
      sapl/static/sapl/frontend/css/painel.e2b9504e.css.gz
  89. 0
      sapl/static/sapl/frontend/css/votacao-nominal-bloco.cd5dc5a8.css
  90. 1
      sapl/static/sapl/frontend/css/votacao-simbolica.fc544ce7.css
  91. BIN
      sapl/static/sapl/frontend/css/votacao-simbolica.fc544ce7.css.gz
  92. 1
      sapl/static/sapl/frontend/css/votacao.fc544ce7.css
  93. BIN
      sapl/static/sapl/frontend/css/votacao.fc544ce7.css.gz
  94. 1
      sapl/static/sapl/frontend/css/voto-individual.e3e45ce9.css
  95. BIN
      sapl/static/sapl/frontend/css/voto-individual.e3e45ce9.css.gz
  96. BIN
      sapl/static/sapl/frontend/fonts/fa-brands-400.5d18d427.ttf.gz
  97. BIN
      sapl/static/sapl/frontend/fonts/fa-regular-400.81482cd4.ttf.gz
  98. BIN
      sapl/static/sapl/frontend/fonts/fa-solid-900.69d3141a.ttf.gz
  99. BIN
      sapl/static/sapl/frontend/fonts/fa-v4compatibility.2c070fd2.ttf.gz
  100. BIN
      sapl/static/sapl/frontend/img/avatar.f8e1e628.png

1
.python-version

@ -0,0 +1 @@
3.12

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)
map $http_x_request_id $req_id {
default $http_x_request_id;
"" $request_id;
}
server {
listen 80;
@ -53,6 +58,18 @@ server {
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 / {
proxy_set_header X-Request-ID $req_id;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

9
docker/docker-compose-dev.yml

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

14
docker/docker-compose.yaml

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

2
docker/startup_scripts/start.sh

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

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

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

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

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

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

59
frontend/src/__apps/painel/scss/painel.scss

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

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

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

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

@ -0,0 +1,182 @@
// frontend/src/__apps/painel/ws/normalizePainel.js
//
// Normaliza o payload `type: "data"` recebido via WebSocket para um shape
// estável e tolerante a payload parcial. A partir da adoção do backend de
// feat/painel-websockets, o payload é o dict flat produzido por
// sapl/painel/views.py::build_dados_painel() — o mesmo usado pelo polling
// HTTP legado e pinado pelos testes sapl/painel/tests/test_broadcasts.py e
// test_consumers.py, então essa função nunca deve mudar de formato; toda
// adaptação de shape vive aqui, não em build_dados_painel().
export const DEFAULT_STATE = Object.freeze({
sessao_aberta: false,
painel_aberto: false,
mostrar_voto: false,
message: '',
sessao: {
sessao_plenaria: '',
sessao_plenaria_data: '',
sessao_plenaria_hora_inicio: '',
sessao_solene: false,
tema_solene: '',
brasao: ''
},
cronometro_discurso: null,
cronometro_aparte: null,
cronometro_ordem: null,
cronometro_consideracoes: null,
cronometro_ativo: 'discurso',
// Só significa algo enquanto há matéria nominal aberta pra registro —
// ver PainelConsumer (type: "registro_toggle") / VotacaoNominal.vue.
registro_aberto: false,
parlamentares: [],
oradores: [],
materia: {},
resultado: {
numero_votos: {}
},
// Populado uma vez, a partir do contexto inicial renderizado pelo Django
// (não vem no payload do WebSocket) — ver GAP 3 do plano de merge.
// applyData() preserva o valor atual quando o payload não traz a lista.
tipos_resultado: []
})
function asObject (v) {
return v && typeof v === 'object' ? v : {}
}
function asArray (v) {
return Array.isArray(v) ? v : []
}
function asString (v, fallback) {
return v != null ? v : fallback
}
/**
* Normaliza o valor do cronômetro recebido do backend.
*
* O backend novo envia um dict:
* { action, duration, start_ts?, remaining_at_stop? }
*
* O backend antigo (cache legado) pode enviar uma string ('start'|'stop'|'reset').
* O valor null/undefined significa "sem estado" (nunca foi configurado).
*
* Retorna sempre um objeto normalizado ou null.
*/
function normalizeCronometro (v) {
if (v == null) return null
// Formato legado: string simples
if (typeof v === 'string') {
return {
action: v,
duration: 300,
start_ts: null,
remaining_at_start: 300,
remaining_at_stop: null,
updated_at: null
}
}
if (typeof v === 'object') {
const duration = v.duration != null ? v.duration : 300
const remaining_at_start = v.remaining_at_start != null ? v.remaining_at_start : duration
const remaining_at_stop = v.remaining_at_stop != null ? v.remaining_at_stop : null
return {
action: v.action || '',
duration,
start_ts: v.start_ts != null ? v.start_ts : null,
remaining_at_start,
remaining_at_stop,
updated_at: v.updated_at != null ? v.updated_at : null
}
}
return null
}
export function normalizePainelData (raw) {
const d = asObject(raw)
const parlamentares = asArray(d.presentes)
.filter((p) => p && typeof p === 'object')
.map((p) => ({
id: p.id,
parlamentar_id: p.parlamentar_id,
nome_parlamentar: asString(p.nome, ''),
filiacao: asString(p.partido, ''),
fotografia: p.fotografia != null ? p.fotografia : false,
voto: p.voto != null ? p.voto : '',
// Só true quando o próprio parlamentar votou (voto individual/
// tablet) — nunca quando o voto veio do operador pelo mesmo
// <select> que lê isto, mesmo que o valor pareça igual.
voto_por_tablet: !!p.voto_por_tablet
}))
const oradores = asArray(d.oradores)
.filter((o) => o && typeof o === 'object')
.map((o) => ({
ordem_pronunciamento: o.numero,
nome_parlamentar: asString(o.nome, '')
}))
console.log(d.sessao_iniciada)
return {
// sessao_iniciada vem de sessao.iniciada is not False (True ou None
// contam como "iniciada" — sessões legadas ficaram com None, mesma
// convenção de restringe_sessoes_visiveis() no backend).
sessao_aberta: !!d.sessao_iniciada && !d.sessao_finalizada,
painel_aberto: d.status_painel != null ? !!d.status_painel : DEFAULT_STATE.painel_aberto,
mostrar_voto: d.mostrar_voto != null ? !!d.mostrar_voto : DEFAULT_STATE.mostrar_voto,
message: asString(d.msg_painel, DEFAULT_STATE.message),
sessao: {
sessao_plenaria: asString(d.sessao_plenaria, DEFAULT_STATE.sessao.sessao_plenaria),
sessao_plenaria_data: asString(d.sessao_plenaria_data, DEFAULT_STATE.sessao.sessao_plenaria_data),
sessao_plenaria_hora_inicio: asString(d.sessao_plenaria_hora_inicio, DEFAULT_STATE.sessao.sessao_plenaria_hora_inicio),
sessao_solene: d.sessao_solene != null ? !!d.sessao_solene : DEFAULT_STATE.sessao.sessao_solene,
tema_solene: asString(d.tema_solene, DEFAULT_STATE.sessao.tema_solene),
brasao: asString(d.brasao, DEFAULT_STATE.sessao.brasao)
},
// Cronômetros: agora objetos {action, duration, start_ts?, remaining_at_stop?}
// ou null quando nunca configurados. O store repassa para CronometroList.vue
// que aplica a ação e configura o tempo inicial de cada Cronometro.vue.
cronometro_discurso: normalizeCronometro(d.cronometro_discurso),
cronometro_aparte: normalizeCronometro(d.cronometro_aparte),
cronometro_ordem: normalizeCronometro(d.cronometro_ordem),
cronometro_consideracoes: normalizeCronometro(d.cronometro_consideracoes),
cronometro_ativo: asString(d.cronometro_ativo, DEFAULT_STATE.cronometro_ativo),
registro_aberto: d.registro_aberto != null ? !!d.registro_aberto : DEFAULT_STATE.registro_aberto,
parlamentares,
oradores,
materia: {
texto: asString(d.materia_legislativa_texto, ''),
ementa: asString(d.materia_legislativa_ementa, ''),
observacao: asString(d.observacao_materia, ''),
resultado_votacao: asString(d.tipo_resultado, '')
},
resultado: {
numero_votos: {
num_presentes: d.num_presentes != null ? d.num_presentes : parlamentares.length,
votos_sim: d.numero_votos_sim != null ? d.numero_votos_sim : 0,
votos_nao: d.numero_votos_nao != null ? d.numero_votos_nao : 0,
abstencoes: d.numero_abstencoes != null ? d.numero_abstencoes : 0,
total_votos: d.total_votos != null ? d.total_votos : 0
}
},
// Nunca vem no payload do WebSocket — applyData() só sobrescreve o
// valor atual do store quando esta lista não está vazia.
tipos_resultado: asArray(d.tipos_resultado)
}
}

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

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

28
frontend/src/__apps/votacao-nominal-bloco/main.js

@ -0,0 +1,28 @@
import './scss/votacao-nominal-bloco.scss'
import Vue from 'vue'
import VotacaoNominalBloco from '../../components/votacao-nominal-bloco/VotacaoNominalBloco.vue'
Vue.component('votacao-nominal-bloco', VotacaoNominalBloco)
// Sem Pinia/WebSocket aqui de propósito: este formulário é preenchido e
// submetido de uma vez (POST nativo pra VotacaoEmBlocoNominalView, que
// continua sem mudanças), não uma tela de estado ao vivo — a única coisa
// que precisava parar de usar polling era a soma dos votos, que já vira
// reatividade normal do Vue dentro de VotacaoNominalBloco.vue.
const parlamentaresEl = document.getElementById('parlamentares-data')
let parlamentares = []
if (parlamentaresEl) {
try {
parlamentares = JSON.parse(parlamentaresEl.textContent)
} catch (e) {
console.error('Erro ao ler parlamentares-data:', e)
}
}
new Vue({ // eslint-disable-line
el: '#app-votacao-nominal-bloco',
data () {
return { parlamentares }
}
})

1
frontend/src/__apps/votacao-nominal-bloco/scss/votacao-nominal-bloco.scss

@ -0,0 +1 @@
/* estilos específicos da votação nominal em bloco, se necessário */

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

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

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 */

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

@ -0,0 +1,307 @@
import './scss/votacao.scss'
import Vue from 'vue'
import VueCompositionApi from '@vue/composition-api'
import { PiniaVuePlugin, createPinia, mapState } from 'pinia'
import axios from 'axios'
// Store Pinia compartilhado com o app painel
import { usePainelStore } from '../painel/store/painelStore'
import { isPermanentCloseCode, permanentCloseMessage, nextBackoffDelay, INITIAL_BACKOFF_MS, DISCONNECTED_MESSAGE } from '../painel/ws/painelSocket'
// Import components
import VotacaoNominal from '../../components/votacao/VotacaoNominal.vue'
import VotacaoMateria from '../../components/votacao/VotacaoMateria.vue'
import VotacaoVotos from '../../components/votacao/VotacaoVotos.vue'
import VotacaoResultado from '../../components/votacao/VotacaoResultado.vue'
import VotacaoObservacoes from '../../components/votacao/VotacaoObservacoes.vue'
import WsStatusBanner from '../../components/painel/WsStatusBanner.vue'
// Register components globally
Vue.component('votacao-nominal', VotacaoNominal)
Vue.component('votacao-materia', VotacaoMateria)
Vue.component('votacao-votos', VotacaoVotos)
Vue.component('votacao-resultado', VotacaoResultado)
Vue.component('votacao-observacoes', VotacaoObservacoes)
Vue.component('ws-status-banner', WsStatusBanner)
axios.defaults.xsrfCookieName = 'csrftoken'
axios.defaults.xsrfHeaderName = 'X-CSRFToken'
// Pinia (no Vue 2 o plugin @vue/composition-api é obrigatório)
Vue.use(VueCompositionApi)
Vue.use(PiniaVuePlugin)
const pinia = createPinia()
console.log('votacao main.js carregado')
const PING_INTERVAL = 30000
const v = new Vue({ // eslint-disable-line
pinia,
delimiters: ['[[', ']]'],
el: '#votacao',
data () {
return {
controllerId: null,
ws: null,
isOpen: false,
error: null,
pingTimer: null,
reconnectTimer: null,
reconnectDelay: INITIAL_BACKOFF_MS,
wsStatus: 'connecting',
wsErrorMessage: 'Conectando ao servidor de tempo real…',
error_message: '',
}
},
computed: {
...mapState(usePainelStore, [
'sessao_aberta', 'painel_aberto', 'sessao',
'parlamentares', 'materia', 'resultado', 'message', 'mostrar_voto',
'registro_aberto'
]),
// Estado real (não mascarado) de quem já votou — a conexão WS desta
// tela é reconhecida como operador/Mesa pelo backend
// (PainelConsumer.is_operator), então parlamentares[].voto já vem sem
// máscara aqui, ao contrário da conexão do telão público. Substitui o
// fetch a /votos-status (removido — ver PainelConsumer.painel_refresh).
votosStatus () {
const map = {}
this.parlamentares.forEach((p) => {
if (p.voto) map[p.parlamentar_id] = p.voto
})
return map
},
// Quais linhas devem ficar travadas pro operador — só quando o voto
// veio do próprio parlamentar (tablet), nunca quando veio do próprio
// operador (por este <select> ou pelo formulário legado em lote):
// votosStatus sozinho não distingue isso, ver voto_por_tablet.
votosTravados () {
const map = {}
this.parlamentares.forEach((p) => {
if (p.voto_por_tablet) map[p.parlamentar_id] = true
})
return map
},
},
mounted () {
console.log('Votacao app mounted!')
const el = this.$el
this.controllerId = el.dataset.controllerId || window.controllerId
console.log(`Votacao ControllerId: ${this.controllerId}`)
// tipos_resultado nunca vem do broadcast do painel — populado uma vez
// a partir do contexto inicial renderizado pelo Django (ver GAP 3 do
// plano de merge). applyData() preserva esse valor depois (só
// sobrescreve quando o payload realmente traz uma lista não vazia).
const tiposResultadoEl = document.getElementById('tipos-resultado')
if (tiposResultadoEl) {
try {
this.painelStore().tipos_resultado = JSON.parse(tiposResultadoEl.textContent)
} catch (e) {
console.error('Erro ao ler tipos-resultado:', e)
}
}
if (this.controllerId) {
this.connectWS()
} else {
this.error_message = 'Erro: controller_id não definido. Não é possível conectar ao WebSocket.'
console.error('No controllerId found — WebSocket will not connect.')
}
},
methods: {
painelStore () {
return usePainelStore()
},
wsURL () {
const proto = location.protocol === 'https:' ? 'wss' : 'ws'
return `${proto}://${location.host}/ws/painel/${this.controllerId}/`
},
// Voto vai pelo WebSocket já aberto (PainelConsumer.receive_json,
// type: "vote") em vez de um POST HTTP separado — o consumer grava e
// já dispara o refresh pro grupo (ver vote_ack/vote_error no
// listener de mensagens). vote_controller (HTTP) continua existindo
// por compatibilidade, mas esta tela não o chama mais.
castVote ({ parlamentar_id, voto }) {
console.log(`Casting vote: parlamentar=${parlamentar_id}, voto=${voto}`)
if (!this.isOpen) {
this.error_message = 'Sem conexão em tempo real. Aguarde reconectar e tente de novo.'
return
}
this.ws.send(JSON.stringify({ type: 'vote', parlamentar_id, voto }))
},
// Bloqueia/reabre o registro de novos votos — equivalente ao
// bloquear-registro-votacao/reabrir-votacao de nominal.html (tela
// legada), agora pelo WS. aberto=true BLOQUEIA (nome do campo
// registro_aberto é herdado do modelo, mesma semântica de sempre).
toggleRegistro (aberto) {
if (!this.isOpen) {
this.error_message = 'Sem conexão em tempo real. Aguarde reconectar e tente de novo.'
return
}
this.ws.send(JSON.stringify({ type: 'registro_toggle', aberto }))
},
cancelURL () {
return `/v2/painel/controller/${this.controllerId}/cancel`
},
closeURL () {
return `/v2/painel/controller/${this.controllerId}/close`
},
onCancelar () {
console.log('Votação cancelada')
axios.post(this.cancelURL(), {}, {
headers: { 'Content-Type': 'application/json' }
})
.then(response => {
console.log('Votação cancelada com sucesso:', response.data)
if (response.data.redirect_url) {
window.location.href = response.data.redirect_url
}
})
.catch(error => {
console.error('Erro ao cancelar votação:', error.response ? error.response.data : error)
const msg = error.response && error.response.data && error.response.data.message
? error.response.data.message
: 'Erro ao cancelar votação. Tente novamente.'
this.error_message = msg
})
},
onFechar (data) {
console.log('Fechar votação:', data)
if (!data.resultado_selected) {
this.error_message = 'Não é possível finalizar a votação sem nenhum resultado da votação.'
return
}
axios.post(this.closeURL(), {
resultado_id: data.resultado_selected,
observacoes: data.observacoes || ''
}, {
headers: { 'Content-Type': 'application/json' }
})
.then(response => {
console.log('Votação finalizada com sucesso:', response.data)
this.error_message = ''
if (response.data.redirect_url) {
window.location.href = response.data.redirect_url
}
})
.catch(error => {
console.error('Erro ao fechar votação:', error.response ? error.response.data : error)
const msg = error.response && error.response.data && error.response.data.message
? error.response.data.message
: 'Erro ao fechar votação. Tente novamente.'
this.error_message = msg
})
},
updateState (data) {
try {
// Toda a normalização/aplicação de estado vive no store Pinia.
// applyData() espera o payload cru (normalizePainelData() lê
// d.sessao_iniciada etc. diretamente) — não o envelope {type,
// payload} inteiro da mensagem WS, senão sessao_aberta/
// painel_aberto nunca saem de DEFAULT_STATE e a tela não renderiza
// nada. Mesmo ajuste já feito em painel/main.js e voto-individual/
// main.js.
this.painelStore().applyData(data.payload)
} catch (e) {
console.error('Error updating state:', e)
}
},
connectWS () {
const url = this.wsURL()
this.ws = new WebSocket(url)
this.ws.addEventListener('open', () => {
this.isOpen = true
this.error = null
this.error_message = ''
this.wsStatus = 'open'
this.wsErrorMessage = ''
this.reconnectDelay = INITIAL_BACKOFF_MS
console.log(`✅ Votação WebSocket connected to ${url}`)
this.pingTimer = setInterval(() => {
this.ws.send(JSON.stringify({ type: 'ping', ts: Date.now() }))
}, PING_INTERVAL)
})
this.ws.addEventListener('message', (message) => {
try {
const data = JSON.parse(message.data)
if (data.type === 'data') {
this.updateState(data)
} else if (data.type === 'pong') {
console.debug('Votacao: pong recebido')
} else if (data.type === 'vote_ack') {
console.log('Voto confirmado pelo servidor:', data)
this.error_message = ''
} else if (data.type === 'vote_error') {
console.error('Erro ao registrar voto:', data.message)
this.error_message = data.message || 'Erro ao registrar voto. Tente novamente.'
} else if (data.type === 'registro_toggle_ack') {
console.log('Registro bloqueado/reaberto:', data)
this.error_message = ''
} else if (data.type === 'registro_toggle_error') {
console.error('Erro ao bloquear/reabrir registro:', data.message)
this.error_message = data.message || 'Erro ao bloquear/reabrir registro. Tente novamente.'
}
} catch (e) {
console.error('Votacao WS parse error:', e)
}
})
this.ws.addEventListener('close', (e) => {
console.log('❌ Votação WebSocket closed:', e)
this.isOpen = false
if (this.pingTimer) {
clearInterval(this.pingTimer)
this.pingTimer = null
}
if (isPermanentCloseCode(e.code)) {
this.wsStatus = 'error'
this.wsErrorMessage = permanentCloseMessage(e.code)
return
}
this.wsStatus = 'reconnecting'
this.wsErrorMessage = DISCONNECTED_MESSAGE
this.reconnectTimer = setTimeout(() => this.connectWS(), this.reconnectDelay)
this.reconnectDelay = nextBackoffDelay(this.reconnectDelay)
})
this.ws.addEventListener('error', (e) => {
this.error = e
console.error('❌ Votação WebSocket error:', e)
})
},
closeWS () {
try {
this.ws && this.ws.close()
} catch (_) {
console.log('Error closing WS')
}
},
beforeDestroy () {
if (this.pingTimer) {
clearInterval(this.pingTimer)
}
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer)
}
this.closeWS()
}
}
})

0
sapl/static/sapl/frontend/css/votacao.cd5dc5a8.css → frontend/src/__apps/votacao/scss/votacao.scss

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

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

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>

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

@ -0,0 +1,168 @@
<template>
<tr :id="'row_' + id" v-show="visible">
<td :class="['fs-3', titleColorClass]">{{ title }}</td>
<td class="text-end">
<audio ref="player" :src="audioSrc" preload="auto"></audio>
<span :id="'cronometro_' + id"
:class="['fw-bold', 'font-monospace', 'fs-1', titleColorClass]"
ref="time">{{ formatTime(time) }}</span>
</td>
</tr>
</template>
<script>
export default {
name: 'PainelCronometro',
props: ['id', 'title', 'visible', 'colorClass'],
data () {
return {
time: 300,
isRunning: false,
initialTime: 300,
intervalId: null,
audioSrc: require('@/assets/audio/ring.mp3')
}
},
computed: {
titleColorClass () {
return this.colorClass || ''
}
},
mounted () {
console.log('Cronometro mounted')
this.$emit('child-mounted')
},
methods: {
changeFontSize (value) {
const el = this.$refs.time
if (!el) return
let fontSize = window.getComputedStyle(el).fontSize
fontSize = parseFloat(fontSize)
el.style.fontSize = (fontSize + value) + 'px'
},
/**
* Aplica o estado completo recebido do backend.
*
* @param {Object} state - { action, duration, start_ts?, remaining_at_stop? }
*
* Lógica:
* - reset / sem estado → para, define time = duration
* - stop → para, define time = remaining_at_stop (tempo no momento do stop)
* - start → calcula tempo restante a partir de start_ts + duration - agora
* e inicia a contagem (idempotente: se já estiver rodando com
* o mesmo start_ts, não reinicia)
*/
applyState (state) {
if (!state || !state.action) {
// Sem estado: apenas configura a duração e para
this.stop()
this.time = this.initialTime
return
}
const { action, duration, start_ts, remaining_at_start, remaining_at_stop } = state
// Atualiza a duração configurada
if (duration != null) {
this.initialTime = duration
}
if (action === 'reset') {
this.stop()
this.time = this.initialTime
} else if (action === 'stop') {
this.stop()
// Mostra o tempo exato em que o operador parou o cronômetro
if (remaining_at_stop != null) {
this.time = Math.max(0, Math.round(remaining_at_stop))
}
} else if (action === 'start') {
// Calcula o tempo restante a partir de remaining_at_start e start_ts
const nowSec = Date.now() / 1000
const elapsed = start_ts != null ? Math.max(0, nowSec - start_ts) : 0
const baseRem = remaining_at_start != null
? remaining_at_start
: (remaining_at_stop != null
? remaining_at_stop
: (this.time > 0 && this.time < this.initialTime ? this.time : this.initialTime))
const remaining = Math.max(0, baseRem - elapsed)
// Idempotente: se já estiver rodando e a contagem estiver em sincronia
// (diferença <= 2s), mantém o intervalo nativo para não ter saltos
if (!this.isRunning || Math.abs(this.time - remaining) > 2) {
this.stop()
this.time = Math.round(remaining)
if (remaining > 0) {
this._startInterval()
}
}
}
this.$emit('state-changed', { id: this.id, isRunning: this.isRunning })
},
_startInterval () {
this.isRunning = true
this.intervalId = setInterval(() => {
if (this.time > 0) {
this.time--
if (this.time === 30) {
this.playSound()
}
} else {
this.isRunning = false
clearInterval(this.intervalId)
this.playSound()
this.$emit('state-changed', { id: this.id, isRunning: false })
}
}, 1000)
},
// start/stop/reset legados mantidos para compatibilidade com qualquer
// chamador externo, mas internamente agora delegam para applyState().
start () {
if (this.isRunning) return
this._startInterval()
},
stop () {
this.isRunning = false
clearInterval(this.intervalId)
this.intervalId = null
},
reset () {
this.stop()
this.time = this.initialTime
},
playSound () {
const audio = this.$refs.player
if (!audio) return
audio.play()
},
formatTime (seconds) {
const hrs = Math.floor(seconds / 3600)
const mins = Math.floor((seconds % 3600) / 60)
const secs = seconds % 60
return `${hrs.toString().padStart(2, '0')}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
}
},
watch: {
initialTime (newVal) {
if (!this.isRunning) {
this.time = newVal
}
}
},
beforeDestroy () {
clearInterval(this.intervalId)
}
}
</script>
<style scoped>
/* Add your own styles here */
</style>

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

@ -0,0 +1,213 @@
<template>
<!--
v-if="canRender": cronômetros só aparecem quando o painel estiver aberto.
Enquanto o painel está fechado, os componentes não são montados e portanto
nenhum timer roda em background.
-->
<div class="painel d-flex flex-column" v-if="canRender">
<div id="box_cronometros" class="w-100">
<h2 class="text-center text-subtitle mb-3">Cronômetro</h2>
<div class="d-flex align-items-center justify-content-center" style="height: 80px">
<table class="table-custom w-100 mb-0">
<tbody>
<Cronometro
ref="childRef_0"
id="discurso"
title="Discurso"
:visible="visibleCronometro === 'discurso'"
color-class=""
@child-mounted="handleChildMounted"
@state-changed="handleStateChanged"
/>
<Cronometro
ref="childRef_1"
id="aparte"
title="Aparte"
:visible="visibleCronometro === 'aparte'"
color-class="text-warning"
@child-mounted="handleChildMounted"
@state-changed="handleStateChanged"
/>
<Cronometro
ref="childRef_2"
id="ordem"
title="Questão de Ordem"
:visible="visibleCronometro === 'ordem'"
color-class="text-info"
@child-mounted="handleChildMounted"
@state-changed="handleStateChanged"
/>
<Cronometro
ref="childRef_3"
id="consideracoes"
title="Consid. Finais"
:visible="visibleCronometro === 'consideracoes'"
color-class=""
@child-mounted="handleChildMounted"
@state-changed="handleStateChanged"
/>
</tbody>
</table>
</div>
</div>
</div>
</template>
<script>
import { mapState } from 'pinia'
import { usePainelStore } from '@/__apps/painel/store/painelStore'
import Cronometro from './Cronometro.vue'
export default {
name: 'CronometroList',
components: {
Cronometro
},
data () {
return {
// Qual cronômetro está visível no momento.
// Regra de prioridade ao rodar: ordem > aparte > consideracoes > discurso.
// Quando nenhum está rodando: mantém o último visível (para mostrar
// o tempo "parado" do cronômetro que acabou de ser parado).
visibleCronometro: 'discurso',
childrenMounted: 0,
allChildrenMounted: false
}
},
mounted () {
console.log('CronometroList mounted')
// Os filhos podem não ter montado ainda — aplica via nextTick após
// todos os filhos emitirem 'child-mounted' (ver handleChildMounted).
},
computed: {
...mapState(usePainelStore, [
'canRender',
'cronometro_ativo',
'cronometro_discurso', 'cronometro_aparte',
'cronometro_ordem', 'cronometro_consideracoes'
])
},
watch: {
// Quando o painel abre (canRender muda para true), os filhos
// acabam de montar — aplica o estado inicial de todos.
canRender (newVal) {
if (newVal) {
this.$nextTick(() => this.applyAllStates())
}
},
cronometro_ativo (newVal) {
if (newVal) {
this.updateVisibility()
}
},
// Watchers profundos para cada cronômetro: disparam quando o objeto
// mudar (nova action, duration, etc.) mas NÃO quando o broadcast
// chega com o mesmo valor (deep watch compara referência, mas o
// store substitui o objeto inteiro — a comparação de ação é feita
// dentro de applyState do filho para ser idempotente).
cronometro_discurso (newVal) {
this.applyOneState('childRef_0', newVal)
},
cronometro_aparte (newVal) {
this.applyOneState('childRef_1', newVal)
},
cronometro_ordem (newVal) {
this.applyOneState('childRef_2', newVal)
},
cronometro_consideracoes (newVal) {
this.applyOneState('childRef_3', newVal)
}
},
methods: {
/**
* Aplica o estado de todos os cronômetros de uma vez.
* Usado na montagem e quando o painel é aberto.
*/
applyAllStates () {
this.applyOneState('childRef_0', this.cronometro_discurso)
this.applyOneState('childRef_1', this.cronometro_aparte)
this.applyOneState('childRef_2', this.cronometro_ordem)
this.applyOneState('childRef_3', this.cronometro_consideracoes)
this.updateVisibility()
},
/**
* Aplica o estado de um único cronômetro chamando applyState() no filho.
* @param {string} refName - nome do ref ('childRef_0' etc.)
* @param {Object|null} state - { action, duration, start_ts?, remaining_at_stop? }
*/
applyOneState (refName, state) {
const comp = this.$refs[refName]
if (!comp) return
comp.applyState(state)
this.updateVisibility()
},
/**
* Chamado quando um filho monta. Quando todos os 4 filhos
* montaram, aplica o estado inicial.
*/
handleChildMounted () {
this.childrenMounted++
console.log(`Cronometro child mounted (${this.childrenMounted}/4)`)
if (this.childrenMounted >= 4) {
this.allChildrenMounted = true
this.applyAllStates()
}
},
/**
* Chamado quando um filho muda de estado (start/stop).
* Atualiza a visibilidade.
*/
handleStateChanged () {
this.updateVisibility()
},
/**
* Decide qual cronômetro mostrar.
*
* Prioridade ao rodar: ordem > aparte > consideracoes > discurso.
* Se nenhum estiver rodando, mantém o último visível para exibir
* o tempo no momento em que foi parado (comportamento igual ao discurso).
*/
updateVisibility () {
// Prioridade 1: se algum cronômetro estiver rodando, mostra o que está rodando
const priority = [
{ key: 'ordem', ref: 'childRef_2' },
{ key: 'aparte', ref: 'childRef_1' },
{ key: 'consideracoes', ref: 'childRef_3' },
{ key: 'discurso', ref: 'childRef_0' }
]
for (const item of priority) {
const comp = this.$refs[item.ref]
if (comp && comp.isRunning) {
this.visibleCronometro = item.key
return
}
}
// Prioridade 2: nenhum rodando. Mostra o último cronômetro operado pelo operador
if (this.cronometro_ativo) {
this.visibleCronometro = this.cronometro_ativo
return
}
// Fallback padrão
this.visibleCronometro = 'discurso'
}
}
}
</script>
<style scoped>
.table-custom {
color: #ddd;
}
::v-deep .table-custom tbody td {
padding: 8px;
font-size: 1.1rem;
}
</style>

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>

74
frontend/src/components/votacao-nominal-bloco/VotacaoNominalBloco.vue

@ -0,0 +1,74 @@
<template>
<div>
<fieldset class="form-group">
<legend>Votos:</legend>
<div class="row">
<template v-for="p in parlamentares">
<div class="col-md-4" id="styleparlamentar" :key="'nome_' + p.parlamentar_id">
{{ p.nome_parlamentar }}
</div>
<div class="col-md-5" :key="'voto_' + p.parlamentar_id">
<input v-if="p.voto" type="hidden" name="voto_parlamentar"
:value="p.voto + ':' + p.parlamentar_id">
<select class="form-control"
:name="p.voto ? null : 'voto_parlamentar'"
:disabled="!!p.voto"
v-model="votos[p.parlamentar_id]">
<option :value="'Não Votou:' + p.parlamentar_id">Não Votou</option>
<option :value="'Sim:' + p.parlamentar_id">Sim</option>
<option :value="'Não:' + p.parlamentar_id">Não</option>
<option :value="'Abstenção:' + p.parlamentar_id">Abstenção</option>
</select>
</div>
</template>
</div>
<br>
<legend>Situação da Votação:</legend>
<div id="soma_votos">
<div class="row"><div class="col-md-12">Sim: {{ tally.sim }}</div></div>
<div class="row"><div class="col-md-12">Não: {{ tally.nao }}</div></div>
<div class="row"><div class="col-md-12">Abstenções: {{ tally.abstencao }}</div></div>
<div class="row"><div class="col-md-12">Ainda não votaram: {{ tally.naoVotou }}</div></div>
</div>
<input type="hidden" name="votos_sim" :value="tally.sim">
<input type="hidden" name="votos_nao" :value="tally.nao">
<input type="hidden" name="abstencoes" :value="tally.abstencao">
<input type="hidden" name="nao_votou" :value="tally.naoVotou">
</fieldset>
</div>
</template>
<script>
export default {
name: 'VotacaoNominalBloco',
props: {
parlamentares: {
type: Array,
default: () => []
}
},
data () {
const votos = {}
this.parlamentares.forEach((p) => {
votos[p.parlamentar_id] = (p.voto || 'Não Votou') + ':' + p.parlamentar_id
})
return { votos }
},
computed: {
// Substitui o antigo setTimeout(conta_votos, 500), que recontava o
// DOM inteiro a cada 500ms — a reatividade do Vue já refaz esta conta
// sozinha quando qualquer <select> muda, sem timer nenhum.
tally () {
const t = { sim: 0, nao: 0, abstencao: 0, naoVotou: 0 }
this.parlamentares.forEach((p) => {
const valor = (this.votos[p.parlamentar_id] || '').split(':')[0]
if (valor === 'Sim') t.sim++
else if (valor === 'Não') t.nao++
else if (valor === 'Abstenção') t.abstencao++
else t.naoVotou++
})
return t
}
}
}
</script>

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>

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

@ -0,0 +1,101 @@
<template>
<fieldset>
<legend>Votação Nominal</legend>
<div v-if="error_message" class="alert alert-danger">
{{ error_message }}
</div>
<div class="alert" :class="registroAberto ? 'alert-warning' : 'alert-info'">
<strong>Status da votação:</strong> em aberto ·
<span v-if="registroAberto">novos votos <strong>bloqueados</strong> — os vereadores que ainda não votaram não conseguem mais votar até a Mesa reabrir</span>
<span v-else>novos votos <strong>permitidos</strong> — os vereadores ainda podem votar pelo tablet enquanto a Mesa registra</span>
</div>
<button type="button" class="btn btn-sm mb-3"
:class="registroAberto ? 'btn-secondary' : 'btn-info'"
@click="onToggleRegistro">
{{ registroAberto ? 'Reabrir Votação para Novos Votos' : 'Bloquear Novos Votos' }}
</button>
<votacao-materia></votacao-materia>
<br />
<votacao-votos :votos-status="votosStatus" :votos-travados="votosTravados" @cast-vote="onCastVote" ref="votos"></votacao-votos>
<votacao-resultado ref="resultado"></votacao-resultado>
<votacao-observacoes
:observacoes.sync="observacoes"
:resultado-selected.sync="resultado_selected"
:disabled="disable_resultado"
@cancelar="onCancelar"
@fechar="onFechar"
ref="observacoesComp">
</votacao-observacoes>
</fieldset>
</template>
<script>
import { mapState } from 'pinia';
import { usePainelStore } from '@/__apps/painel/store/painelStore';
export default {
name: 'VotacaoNominal',
props: {
isOpen: {
type: Boolean,
default: false
},
errorMessage: {
type: String,
default: ''
},
votosStatus: {
type: Object,
default: () => ({})
},
votosTravados: {
type: Object,
default: () => ({})
},
registroAberto: {
type: Boolean,
default: false
}
},
data() {
return {
edit_votes: true,
disable_resultado: false,
resultado_selected: '',
observacoes: '',
}
},
computed: {
...mapState(usePainelStore, ['parlamentares', 'materia']),
error_message() {
return this.errorMessage;
}
},
methods: {
onCastVote({ parlamentar_id, voto }) {
this.$emit('cast-vote', { parlamentar_id, voto });
},
onCancelar() {
this.$emit('cancelar');
},
onFechar() {
this.$emit('fechar', {
resultado_selected: this.resultado_selected,
observacoes: this.observacoes
});
},
onToggleRegistro() {
this.$emit('toggle-registro', !this.registroAberto);
}
},
mounted() {
console.log('VotacaoNominal mounted');
}
};
</script>

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>

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

@ -0,0 +1,64 @@
<template>
<fieldset class="form-group" v-if="parlamentares && parlamentares.length > 0">
<legend>Votos</legend>
<div class="row">
<template v-for="p in parlamentares">
<div class="col-md-4" id="styleparlamentar" :key="'nome_' + p.parlamentar_id">
{{ p.nome_parlamentar }}
</div>
<div class="col-md-5" :key="'voto_' + p.parlamentar_id">
<select class="form-control"
:name="'voto_parlamentar_' + p.parlamentar_id"
:value="votosStatus[p.parlamentar_id] || 'Não Votou'"
:disabled="!!votosTravados[p.parlamentar_id]"
@change="onVoteChange(p.parlamentar_id, $event.target.value)">
<option value="Não Votou">Não Votou</option>
<option value="Sim">Sim</option>
<option value="Não">Não</option>
<option value="Abstenção">Abstenção</option>
</select>
<span v-if="votosTravados[p.parlamentar_id]" class="badge badge-info">Já votado (tablet)</span>
</div>
</template>
</div>
</fieldset>
<div class="alert alert-info alert-dismissible" role="alert" v-else>
<div>Não existe nenhum parlamentar presente para que a votação ocorra.</div>
</div>
</template>
<script>
import { mapState } from 'pinia';
import { usePainelStore } from '@/__apps/painel/store/painelStore';
export default {
name: 'VotacaoVotos',
props: {
// Estado real (não mascarado por mostrar_voto) de quem já votou, por
// parlamentar_id — nunca vem do store (visão pública do painel).
votosStatus: {
type: Object,
default: () => ({})
},
// Quais parlamentar_id devem ficar travados pro operador — só quando
// votado_pelo_parlamentar é true no payload (voto pelo próprio
// tablet). Distinto de votosStatus: um voto lançado pelo operador
// neste mesmo <select> também aparece em votosStatus (tem valor), mas
// não deve se autotravar.
votosTravados: {
type: Object,
default: () => ({})
}
},
computed: {
...mapState(usePainelStore, ['parlamentares'])
},
methods: {
onVoteChange(parlamentar_id, voto) {
this.$emit('cast-vote', { parlamentar_id, voto });
}
},
mounted() {
console.log('VotacaoVotos mounted');
}
};
</script>

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>

705
frontend/webpack-stats.json

File diff suppressed because it is too large

14525
package-lock.json

File diff suppressed because it is too large

7
package.json

@ -9,6 +9,7 @@
},
"dependencies": {
"@fortawesome/fontawesome-free": "^6.1.2",
"@vue/composition-api": "^1.7.2",
"axios": "^1.7.2",
"bootstrap": "^4.6.2",
"bootstrap-vue": "^2.22.0",
@ -20,9 +21,10 @@
"lodash": "^4.17.21",
"moment": "^2.29.4",
"moment-locales-webpack-plugin": "^1.2.0",
"pinia": "^2.1.7",
"popper.js": "^1.16.1",
"tinymce": "^7.2.0",
"vue": "^2.7.9"
"vue": "^2.7.16"
},
"devDependencies": {
"@babel/core": "^7.18.13",
@ -54,5 +56,8 @@
"vue-template-compiler": "^2.7.9",
"webpack": "^5.74.0",
"webpack-bundle-tracker": "^1.6.0"
},
"resolutions": {
"@achrinza/node-ipc": "9.2.10"
}
}

4
requirements/requirements.txt

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

1
requirements/test-requirements.txt

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

26
sapl/api/serializers.py

@ -12,7 +12,7 @@ from rest_framework.fields import SerializerMethodField
from sapl.base.models import Autor, CasaLegislativa, Metadata
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):
@ -317,5 +317,25 @@ class SessaoPlenariaECidadaniaSerializer(serializers.ModelSerializer):
return self.casa().nome
def casa(self):
casa = CasaLegislativa.objects.first()
return casa
return CasaLegislativa.objects.first()
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')

19
sapl/api/tests/test_serializers_sessao.py

@ -0,0 +1,19 @@
import pytest
from model_bakery import baker
from sapl.api.serializers import SessaoPlenariaECidadaniaSerializer
from sapl.base.models import CasaLegislativa
from sapl.sessao.models import SessaoPlenaria
@pytest.mark.django_db(transaction=False)
def test_sessao_ecidadania_serializa_dados_da_casa():
casa = baker.make(CasaLegislativa, nome='Câmara Municipal', sigla='CM',
endereco='Praça Central')
sessao = baker.make(SessaoPlenaria)
data = SessaoPlenariaECidadaniaSerializer(sessao).data
assert data['txtNomeOrgao'] == casa.nome
assert data['txtSiglaOrgao'] == casa.sigla
assert data['txtLocal'] == casa.endereco

23
sapl/asgi.py

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

18
sapl/audiencia/migrations/0023_auto_20260916_1709.py

@ -0,0 +1,18 @@
# Generated by Django 2.2.28 on 2026-09-16 20:09
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('audiencia', '0022_audienciapublica_data_ultima_atualizacao'),
]
operations = [
migrations.AlterField(
model_name='audienciapublica',
name='ano',
field=models.PositiveSmallIntegerField(choices=[(2027, 2027), (2026, 2026), (2025, 2025), (2024, 2024), (2023, 2023), (2022, 2022), (2021, 2021), (2020, 2020), (2019, 2019), (2018, 2018), (2017, 2017), (2016, 2016), (2015, 2015), (2014, 2014), (2013, 2013), (2012, 2012), (2011, 2011), (2010, 2010), (2009, 2009), (2008, 2008), (2007, 2007), (2006, 2006), (2005, 2005), (2004, 2004), (2003, 2003), (2002, 2002), (2001, 2001), (2000, 2000), (1999, 1999), (1998, 1998), (1997, 1997), (1996, 1996), (1995, 1995), (1994, 1994), (1993, 1993), (1992, 1992), (1991, 1991), (1990, 1990), (1989, 1989), (1988, 1988), (1987, 1987), (1986, 1986), (1985, 1985), (1984, 1984), (1983, 1983), (1982, 1982), (1981, 1981), (1980, 1980), (1979, 1979), (1978, 1978), (1977, 1977), (1976, 1976), (1975, 1975), (1974, 1974), (1973, 1973), (1972, 1972), (1971, 1971), (1970, 1970), (1969, 1969), (1968, 1968), (1967, 1967), (1966, 1966), (1965, 1965), (1964, 1964), (1963, 1963), (1962, 1962), (1961, 1961), (1960, 1960), (1959, 1959), (1958, 1958), (1957, 1957), (1956, 1956), (1955, 1955), (1954, 1954), (1953, 1953), (1952, 1952), (1951, 1951), (1950, 1950), (1949, 1949), (1948, 1948), (1947, 1947), (1946, 1946), (1945, 1945), (1944, 1944), (1943, 1943), (1942, 1942), (1941, 1941), (1940, 1940), (1939, 1939), (1938, 1938), (1937, 1937), (1936, 1936), (1935, 1935), (1934, 1934), (1933, 1933), (1932, 1932), (1931, 1931), (1930, 1930), (1929, 1929), (1928, 1928), (1927, 1927), (1926, 1926), (1925, 1925), (1924, 1924), (1923, 1923), (1922, 1922), (1921, 1921), (1920, 1920), (1919, 1919), (1918, 1918), (1917, 1917), (1916, 1916), (1915, 1915), (1914, 1914), (1913, 1913), (1912, 1912), (1911, 1911), (1910, 1910), (1909, 1909), (1908, 1908), (1907, 1907), (1906, 1906), (1905, 1905), (1904, 1904), (1903, 1903), (1902, 1902), (1901, 1901), (1900, 1900), (1899, 1899), (1898, 1898), (1897, 1897), (1896, 1896), (1895, 1895), (1894, 1894), (1893, 1893), (1892, 1892), (1891, 1891), (1890, 1890)], verbose_name='Ano'),
),
]

4
sapl/compilacao/utils.py

@ -59,8 +59,8 @@ def int_to_letter(int_value):
def get_integrations_view_names():
result = []
modules = sys.modules
for key, value in modules.items():
# list(): importar um módulo durante a varredura altera sys.modules.
for key, value in list(sys.modules.items()):
if key.endswith('.views'):
for v in value.__dict__.values():
if hasattr(v, '__bases__'):

33
sapl/materia/migrations/0088_auto_20260916_1709.py

@ -0,0 +1,33 @@
# Generated by Django 2.2.28 on 2026-09-16 20:09
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('materia', '0087_update_viewdb_materiaemtramitacao'),
]
operations = [
migrations.AlterField(
model_name='materialegislativa',
name='ano',
field=models.PositiveSmallIntegerField(choices=[(2027, 2027), (2026, 2026), (2025, 2025), (2024, 2024), (2023, 2023), (2022, 2022), (2021, 2021), (2020, 2020), (2019, 2019), (2018, 2018), (2017, 2017), (2016, 2016), (2015, 2015), (2014, 2014), (2013, 2013), (2012, 2012), (2011, 2011), (2010, 2010), (2009, 2009), (2008, 2008), (2007, 2007), (2006, 2006), (2005, 2005), (2004, 2004), (2003, 2003), (2002, 2002), (2001, 2001), (2000, 2000), (1999, 1999), (1998, 1998), (1997, 1997), (1996, 1996), (1995, 1995), (1994, 1994), (1993, 1993), (1992, 1992), (1991, 1991), (1990, 1990), (1989, 1989), (1988, 1988), (1987, 1987), (1986, 1986), (1985, 1985), (1984, 1984), (1983, 1983), (1982, 1982), (1981, 1981), (1980, 1980), (1979, 1979), (1978, 1978), (1977, 1977), (1976, 1976), (1975, 1975), (1974, 1974), (1973, 1973), (1972, 1972), (1971, 1971), (1970, 1970), (1969, 1969), (1968, 1968), (1967, 1967), (1966, 1966), (1965, 1965), (1964, 1964), (1963, 1963), (1962, 1962), (1961, 1961), (1960, 1960), (1959, 1959), (1958, 1958), (1957, 1957), (1956, 1956), (1955, 1955), (1954, 1954), (1953, 1953), (1952, 1952), (1951, 1951), (1950, 1950), (1949, 1949), (1948, 1948), (1947, 1947), (1946, 1946), (1945, 1945), (1944, 1944), (1943, 1943), (1942, 1942), (1941, 1941), (1940, 1940), (1939, 1939), (1938, 1938), (1937, 1937), (1936, 1936), (1935, 1935), (1934, 1934), (1933, 1933), (1932, 1932), (1931, 1931), (1930, 1930), (1929, 1929), (1928, 1928), (1927, 1927), (1926, 1926), (1925, 1925), (1924, 1924), (1923, 1923), (1922, 1922), (1921, 1921), (1920, 1920), (1919, 1919), (1918, 1918), (1917, 1917), (1916, 1916), (1915, 1915), (1914, 1914), (1913, 1913), (1912, 1912), (1911, 1911), (1910, 1910), (1909, 1909), (1908, 1908), (1907, 1907), (1906, 1906), (1905, 1905), (1904, 1904), (1903, 1903), (1902, 1902), (1901, 1901), (1900, 1900), (1899, 1899), (1898, 1898), (1897, 1897), (1896, 1896), (1895, 1895), (1894, 1894), (1893, 1893), (1892, 1892), (1891, 1891), (1890, 1890)], verbose_name='Ano'),
),
migrations.AlterField(
model_name='materialegislativa',
name='ano_origem_externa',
field=models.PositiveSmallIntegerField(blank=True, choices=[(2027, 2027), (2026, 2026), (2025, 2025), (2024, 2024), (2023, 2023), (2022, 2022), (2021, 2021), (2020, 2020), (2019, 2019), (2018, 2018), (2017, 2017), (2016, 2016), (2015, 2015), (2014, 2014), (2013, 2013), (2012, 2012), (2011, 2011), (2010, 2010), (2009, 2009), (2008, 2008), (2007, 2007), (2006, 2006), (2005, 2005), (2004, 2004), (2003, 2003), (2002, 2002), (2001, 2001), (2000, 2000), (1999, 1999), (1998, 1998), (1997, 1997), (1996, 1996), (1995, 1995), (1994, 1994), (1993, 1993), (1992, 1992), (1991, 1991), (1990, 1990), (1989, 1989), (1988, 1988), (1987, 1987), (1986, 1986), (1985, 1985), (1984, 1984), (1983, 1983), (1982, 1982), (1981, 1981), (1980, 1980), (1979, 1979), (1978, 1978), (1977, 1977), (1976, 1976), (1975, 1975), (1974, 1974), (1973, 1973), (1972, 1972), (1971, 1971), (1970, 1970), (1969, 1969), (1968, 1968), (1967, 1967), (1966, 1966), (1965, 1965), (1964, 1964), (1963, 1963), (1962, 1962), (1961, 1961), (1960, 1960), (1959, 1959), (1958, 1958), (1957, 1957), (1956, 1956), (1955, 1955), (1954, 1954), (1953, 1953), (1952, 1952), (1951, 1951), (1950, 1950), (1949, 1949), (1948, 1948), (1947, 1947), (1946, 1946), (1945, 1945), (1944, 1944), (1943, 1943), (1942, 1942), (1941, 1941), (1940, 1940), (1939, 1939), (1938, 1938), (1937, 1937), (1936, 1936), (1935, 1935), (1934, 1934), (1933, 1933), (1932, 1932), (1931, 1931), (1930, 1930), (1929, 1929), (1928, 1928), (1927, 1927), (1926, 1926), (1925, 1925), (1924, 1924), (1923, 1923), (1922, 1922), (1921, 1921), (1920, 1920), (1919, 1919), (1918, 1918), (1917, 1917), (1916, 1916), (1915, 1915), (1914, 1914), (1913, 1913), (1912, 1912), (1911, 1911), (1910, 1910), (1909, 1909), (1908, 1908), (1907, 1907), (1906, 1906), (1905, 1905), (1904, 1904), (1903, 1903), (1902, 1902), (1901, 1901), (1900, 1900), (1899, 1899), (1898, 1898), (1897, 1897), (1896, 1896), (1895, 1895), (1894, 1894), (1893, 1893), (1892, 1892), (1891, 1891), (1890, 1890)], null=True, verbose_name='Ano'),
),
migrations.AlterField(
model_name='numeracao',
name='ano_materia',
field=models.PositiveSmallIntegerField(choices=[(2027, 2027), (2026, 2026), (2025, 2025), (2024, 2024), (2023, 2023), (2022, 2022), (2021, 2021), (2020, 2020), (2019, 2019), (2018, 2018), (2017, 2017), (2016, 2016), (2015, 2015), (2014, 2014), (2013, 2013), (2012, 2012), (2011, 2011), (2010, 2010), (2009, 2009), (2008, 2008), (2007, 2007), (2006, 2006), (2005, 2005), (2004, 2004), (2003, 2003), (2002, 2002), (2001, 2001), (2000, 2000), (1999, 1999), (1998, 1998), (1997, 1997), (1996, 1996), (1995, 1995), (1994, 1994), (1993, 1993), (1992, 1992), (1991, 1991), (1990, 1990), (1989, 1989), (1988, 1988), (1987, 1987), (1986, 1986), (1985, 1985), (1984, 1984), (1983, 1983), (1982, 1982), (1981, 1981), (1980, 1980), (1979, 1979), (1978, 1978), (1977, 1977), (1976, 1976), (1975, 1975), (1974, 1974), (1973, 1973), (1972, 1972), (1971, 1971), (1970, 1970), (1969, 1969), (1968, 1968), (1967, 1967), (1966, 1966), (1965, 1965), (1964, 1964), (1963, 1963), (1962, 1962), (1961, 1961), (1960, 1960), (1959, 1959), (1958, 1958), (1957, 1957), (1956, 1956), (1955, 1955), (1954, 1954), (1953, 1953), (1952, 1952), (1951, 1951), (1950, 1950), (1949, 1949), (1948, 1948), (1947, 1947), (1946, 1946), (1945, 1945), (1944, 1944), (1943, 1943), (1942, 1942), (1941, 1941), (1940, 1940), (1939, 1939), (1938, 1938), (1937, 1937), (1936, 1936), (1935, 1935), (1934, 1934), (1933, 1933), (1932, 1932), (1931, 1931), (1930, 1930), (1929, 1929), (1928, 1928), (1927, 1927), (1926, 1926), (1925, 1925), (1924, 1924), (1923, 1923), (1922, 1922), (1921, 1921), (1920, 1920), (1919, 1919), (1918, 1918), (1917, 1917), (1916, 1916), (1915, 1915), (1914, 1914), (1913, 1913), (1912, 1912), (1911, 1911), (1910, 1910), (1909, 1909), (1908, 1908), (1907, 1907), (1906, 1906), (1905, 1905), (1904, 1904), (1903, 1903), (1902, 1902), (1901, 1901), (1900, 1900), (1899, 1899), (1898, 1898), (1897, 1897), (1896, 1896), (1895, 1895), (1894, 1894), (1893, 1893), (1892, 1892), (1891, 1891), (1890, 1890)], verbose_name='Ano'),
),
migrations.AlterField(
model_name='proposicao',
name='ano',
field=models.PositiveSmallIntegerField(blank=True, choices=[(2027, 2027), (2026, 2026), (2025, 2025), (2024, 2024), (2023, 2023), (2022, 2022), (2021, 2021), (2020, 2020), (2019, 2019), (2018, 2018), (2017, 2017), (2016, 2016), (2015, 2015), (2014, 2014), (2013, 2013), (2012, 2012), (2011, 2011), (2010, 2010), (2009, 2009), (2008, 2008), (2007, 2007), (2006, 2006), (2005, 2005), (2004, 2004), (2003, 2003), (2002, 2002), (2001, 2001), (2000, 2000), (1999, 1999), (1998, 1998), (1997, 1997), (1996, 1996), (1995, 1995), (1994, 1994), (1993, 1993), (1992, 1992), (1991, 1991), (1990, 1990), (1989, 1989), (1988, 1988), (1987, 1987), (1986, 1986), (1985, 1985), (1984, 1984), (1983, 1983), (1982, 1982), (1981, 1981), (1980, 1980), (1979, 1979), (1978, 1978), (1977, 1977), (1976, 1976), (1975, 1975), (1974, 1974), (1973, 1973), (1972, 1972), (1971, 1971), (1970, 1970), (1969, 1969), (1968, 1968), (1967, 1967), (1966, 1966), (1965, 1965), (1964, 1964), (1963, 1963), (1962, 1962), (1961, 1961), (1960, 1960), (1959, 1959), (1958, 1958), (1957, 1957), (1956, 1956), (1955, 1955), (1954, 1954), (1953, 1953), (1952, 1952), (1951, 1951), (1950, 1950), (1949, 1949), (1948, 1948), (1947, 1947), (1946, 1946), (1945, 1945), (1944, 1944), (1943, 1943), (1942, 1942), (1941, 1941), (1940, 1940), (1939, 1939), (1938, 1938), (1937, 1937), (1936, 1936), (1935, 1935), (1934, 1934), (1933, 1933), (1932, 1932), (1931, 1931), (1930, 1930), (1929, 1929), (1928, 1928), (1927, 1927), (1926, 1926), (1925, 1925), (1924, 1924), (1923, 1923), (1922, 1922), (1921, 1921), (1920, 1920), (1919, 1919), (1918, 1918), (1917, 1917), (1916, 1916), (1915, 1915), (1914, 1914), (1913, 1913), (1912, 1912), (1911, 1911), (1910, 1910), (1909, 1909), (1908, 1908), (1907, 1907), (1906, 1906), (1905, 1905), (1904, 1904), (1903, 1903), (1902, 1902), (1901, 1901), (1900, 1900), (1899, 1899), (1898, 1898), (1897, 1897), (1896, 1896), (1895, 1895), (1894, 1894), (1893, 1893), (1892, 1892), (1891, 1891), (1890, 1890)], default=None, null=True, verbose_name='Ano'),
),
]

29
sapl/norma/migrations/0046_auto_20260916_1709.py

@ -0,0 +1,29 @@
# Generated by Django 2.2.28 on 2026-09-16 20:09
from django.db import migrations, models
import sapl.norma.models
class Migration(migrations.Migration):
dependencies = [
('norma', '0045_auto_20240711_1405'),
]
operations = [
migrations.AlterField(
model_name='anexonormajuridica',
name='ano',
field=models.PositiveSmallIntegerField(choices=[(2027, 2027), (2026, 2026), (2025, 2025), (2024, 2024), (2023, 2023), (2022, 2022), (2021, 2021), (2020, 2020), (2019, 2019), (2018, 2018), (2017, 2017), (2016, 2016), (2015, 2015), (2014, 2014), (2013, 2013), (2012, 2012), (2011, 2011), (2010, 2010), (2009, 2009), (2008, 2008), (2007, 2007), (2006, 2006), (2005, 2005), (2004, 2004), (2003, 2003), (2002, 2002), (2001, 2001), (2000, 2000), (1999, 1999), (1998, 1998), (1997, 1997), (1996, 1996), (1995, 1995), (1994, 1994), (1993, 1993), (1992, 1992), (1991, 1991), (1990, 1990), (1989, 1989), (1988, 1988), (1987, 1987), (1986, 1986), (1985, 1985), (1984, 1984), (1983, 1983), (1982, 1982), (1981, 1981), (1980, 1980), (1979, 1979), (1978, 1978), (1977, 1977), (1976, 1976), (1975, 1975), (1974, 1974), (1973, 1973), (1972, 1972), (1971, 1971), (1970, 1970), (1969, 1969), (1968, 1968), (1967, 1967), (1966, 1966), (1965, 1965), (1964, 1964), (1963, 1963), (1962, 1962), (1961, 1961), (1960, 1960), (1959, 1959), (1958, 1958), (1957, 1957), (1956, 1956), (1955, 1955), (1954, 1954), (1953, 1953), (1952, 1952), (1951, 1951), (1950, 1950), (1949, 1949), (1948, 1948), (1947, 1947), (1946, 1946), (1945, 1945), (1944, 1944), (1943, 1943), (1942, 1942), (1941, 1941), (1940, 1940), (1939, 1939), (1938, 1938), (1937, 1937), (1936, 1936), (1935, 1935), (1934, 1934), (1933, 1933), (1932, 1932), (1931, 1931), (1930, 1930), (1929, 1929), (1928, 1928), (1927, 1927), (1926, 1926), (1925, 1925), (1924, 1924), (1923, 1923), (1922, 1922), (1921, 1921), (1920, 1920), (1919, 1919), (1918, 1918), (1917, 1917), (1916, 1916), (1915, 1915), (1914, 1914), (1913, 1913), (1912, 1912), (1911, 1911), (1910, 1910), (1909, 1909), (1908, 1908), (1907, 1907), (1906, 1906), (1905, 1905), (1904, 1904), (1903, 1903), (1902, 1902), (1901, 1901), (1900, 1900), (1899, 1899), (1898, 1898), (1897, 1897), (1896, 1896), (1895, 1895), (1894, 1894), (1893, 1893), (1892, 1892), (1891, 1891), (1890, 1890)], verbose_name='Ano'),
),
migrations.AlterField(
model_name='normaestatisticas',
name='ano',
field=models.PositiveSmallIntegerField(choices=[(2027, 2027), (2026, 2026), (2025, 2025), (2024, 2024), (2023, 2023), (2022, 2022), (2021, 2021), (2020, 2020), (2019, 2019), (2018, 2018), (2017, 2017), (2016, 2016), (2015, 2015), (2014, 2014), (2013, 2013), (2012, 2012), (2011, 2011), (2010, 2010), (2009, 2009), (2008, 2008), (2007, 2007), (2006, 2006), (2005, 2005), (2004, 2004), (2003, 2003), (2002, 2002), (2001, 2001), (2000, 2000), (1999, 1999), (1998, 1998), (1997, 1997), (1996, 1996), (1995, 1995), (1994, 1994), (1993, 1993), (1992, 1992), (1991, 1991), (1990, 1990), (1989, 1989), (1988, 1988), (1987, 1987), (1986, 1986), (1985, 1985), (1984, 1984), (1983, 1983), (1982, 1982), (1981, 1981), (1980, 1980), (1979, 1979), (1978, 1978), (1977, 1977), (1976, 1976), (1975, 1975), (1974, 1974), (1973, 1973), (1972, 1972), (1971, 1971), (1970, 1970), (1969, 1969), (1968, 1968), (1967, 1967), (1966, 1966), (1965, 1965), (1964, 1964), (1963, 1963), (1962, 1962), (1961, 1961), (1960, 1960), (1959, 1959), (1958, 1958), (1957, 1957), (1956, 1956), (1955, 1955), (1954, 1954), (1953, 1953), (1952, 1952), (1951, 1951), (1950, 1950), (1949, 1949), (1948, 1948), (1947, 1947), (1946, 1946), (1945, 1945), (1944, 1944), (1943, 1943), (1942, 1942), (1941, 1941), (1940, 1940), (1939, 1939), (1938, 1938), (1937, 1937), (1936, 1936), (1935, 1935), (1934, 1934), (1933, 1933), (1932, 1932), (1931, 1931), (1930, 1930), (1929, 1929), (1928, 1928), (1927, 1927), (1926, 1926), (1925, 1925), (1924, 1924), (1923, 1923), (1922, 1922), (1921, 1921), (1920, 1920), (1919, 1919), (1918, 1918), (1917, 1917), (1916, 1916), (1915, 1915), (1914, 1914), (1913, 1913), (1912, 1912), (1911, 1911), (1910, 1910), (1909, 1909), (1908, 1908), (1907, 1907), (1906, 1906), (1905, 1905), (1904, 1904), (1903, 1903), (1902, 1902), (1901, 1901), (1900, 1900), (1899, 1899), (1898, 1898), (1897, 1897), (1896, 1896), (1895, 1895), (1894, 1894), (1893, 1893), (1892, 1892), (1891, 1891), (1890, 1890)], default=sapl.norma.models.get_ano_atual, verbose_name='Ano'),
),
migrations.AlterField(
model_name='normajuridica',
name='ano',
field=models.PositiveSmallIntegerField(choices=[(2027, 2027), (2026, 2026), (2025, 2025), (2024, 2024), (2023, 2023), (2022, 2022), (2021, 2021), (2020, 2020), (2019, 2019), (2018, 2018), (2017, 2017), (2016, 2016), (2015, 2015), (2014, 2014), (2013, 2013), (2012, 2012), (2011, 2011), (2010, 2010), (2009, 2009), (2008, 2008), (2007, 2007), (2006, 2006), (2005, 2005), (2004, 2004), (2003, 2003), (2002, 2002), (2001, 2001), (2000, 2000), (1999, 1999), (1998, 1998), (1997, 1997), (1996, 1996), (1995, 1995), (1994, 1994), (1993, 1993), (1992, 1992), (1991, 1991), (1990, 1990), (1989, 1989), (1988, 1988), (1987, 1987), (1986, 1986), (1985, 1985), (1984, 1984), (1983, 1983), (1982, 1982), (1981, 1981), (1980, 1980), (1979, 1979), (1978, 1978), (1977, 1977), (1976, 1976), (1975, 1975), (1974, 1974), (1973, 1973), (1972, 1972), (1971, 1971), (1970, 1970), (1969, 1969), (1968, 1968), (1967, 1967), (1966, 1966), (1965, 1965), (1964, 1964), (1963, 1963), (1962, 1962), (1961, 1961), (1960, 1960), (1959, 1959), (1958, 1958), (1957, 1957), (1956, 1956), (1955, 1955), (1954, 1954), (1953, 1953), (1952, 1952), (1951, 1951), (1950, 1950), (1949, 1949), (1948, 1948), (1947, 1947), (1946, 1946), (1945, 1945), (1944, 1944), (1943, 1943), (1942, 1942), (1941, 1941), (1940, 1940), (1939, 1939), (1938, 1938), (1937, 1937), (1936, 1936), (1935, 1935), (1934, 1934), (1933, 1933), (1932, 1932), (1931, 1931), (1930, 1930), (1929, 1929), (1928, 1928), (1927, 1927), (1926, 1926), (1925, 1925), (1924, 1924), (1923, 1923), (1922, 1922), (1921, 1921), (1920, 1920), (1919, 1919), (1918, 1918), (1917, 1917), (1916, 1916), (1915, 1915), (1914, 1914), (1913, 1913), (1912, 1912), (1911, 1911), (1910, 1910), (1909, 1909), (1908, 1908), (1907, 1907), (1906, 1906), (1905, 1905), (1904, 1904), (1903, 1903), (1902, 1902), (1901, 1901), (1900, 1900), (1899, 1899), (1898, 1898), (1897, 1897), (1896, 1896), (1895, 1895), (1894, 1894), (1893, 1893), (1892, 1892), (1891, 1891), (1890, 1890)], verbose_name='Ano'),
),
]

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;

215
sapl/painel/consumers.py

@ -0,0 +1,215 @@
import logging
from channels.db import database_sync_to_async
from channels.generic.websocket import AsyncJsonWebsocketConsumer
from sapl.painel.apps import AppConfig as PainelAppConfig
from sapl.sessao.models import SessaoPlenaria
logger = logging.getLogger(__name__)
class _ScopeRequest:
"""
Adaptador mínimo para reaproveitar build_dados_painel(request, pk) —
que lê request.user/request.session para o cronômetro — a partir do
scope de uma conexão WebSocket, que não é um HttpRequest de verdade.
AuthMiddlewareStack (usado em sapl/asgi.py) já popula scope['user'] e
scope['session'] a partir do cookie de sessão, então isso é só leitura,
igual ao que get_cronometro_status já fazia.
"""
def __init__(self, scope):
self.user = scope['user']
self.session = scope['session']
class PainelConsumer(AsyncJsonWebsocketConsumer):
"""
Um grupo por SessaoPlenaria (`sessao_<id>`) — painel público, tela de
voto individual (tablet) e tela de registro do operador conectam todos
aqui. O snapshot é sempre completo (não deltas — ver Parte 3 do plano
de migração polling->push), mas não é mais idêntico para todo mundo:
cada conexão pede o seu próprio a cada aviso de mudança (ver
painel_refresh()), com o voto mascarado ou não conforme o papel de
quem está conectado (self.is_operator) — o grupo em si nunca carrega
o voto real, só um sinal de "algo mudou".
"""
async def connect(self):
self.sessao_id = self.scope['url_route']['kwargs']['sessao_id']
user = self.scope['user']
if not user.is_authenticated:
await self.close(code=4401)
return
if not await self._user_pode_conectar(user):
await self.close(code=4403)
return
if not await self._sessao_existe(self.sessao_id):
await self.close(code=4404)
return
# Papéis calculados uma vez, na conexão — reaproveitados em todo
# refresh subsequente sem reconsultar as permissões do usuário.
self.is_operator = await self._is_operator(user)
self.can_cast_vote = await self._can_cast_vote(user)
self.group_name = 'sessao_{}'.format(self.sessao_id)
await self.channel_layer.group_add(self.group_name, self.channel_name)
await self.accept()
payload = await self._build_dados_painel(self.sessao_id)
await self.send_json({'type': 'data', 'payload': payload})
async def disconnect(self, close_code):
if hasattr(self, 'group_name'):
await self.channel_layer.group_discard(self.group_name, self.channel_name)
async def receive_json(self, content, **kwargs):
msg_type = content.get('type')
if msg_type == 'ping':
await self.send_json({'type': 'pong'})
elif msg_type == 'vote':
await self._handle_vote(content)
elif msg_type == 'registro_toggle':
await self._handle_registro_toggle(content)
async def data(self, event):
# Ainda aceito por compatibilidade (não é mais como
# broadcast_dados_painel() manda algo pro grupo, mas nada impede
# outro caller de enviar um group_send neste formato).
await self.send_json({'type': 'data', 'payload': event['payload']})
async def painel_refresh(self, event):
"""
Handler do sinal leve que broadcast_dados_painel() manda pro grupo
(`{"type": "painel.refresh"}` — Channels troca o "." por "_" no
nome do método). Cada conexão reconsulta o snapshot por conta
própria e manda só pra si mesma: é aqui, não no group_send, que o
voto real fica restrito a quem tem self.is_operator.
"""
payload = await self._build_dados_painel(self.sessao_id)
await self.send_json({'type': 'data', 'payload': payload})
async def _handle_vote(self, content):
if not getattr(self, 'can_cast_vote', False):
await self.send_json({
'type': 'vote_error',
'message': 'Seu usuário não tem permissão para registrar votos.'})
return
parlamentar_id = content.get('parlamentar_id')
voto = content.get('voto')
try:
result = await self._cast_vote(parlamentar_id, voto)
except Exception as e:
await self.send_json({'type': 'vote_error', 'message': str(e)})
return
await self.send_json({'type': 'vote_ack', **result})
# Sinal pro grupo inteiro (inclusive esta conexão) reconsultar —
# não manda o payload aqui: quem recebe decide seu próprio
# mostrar_voto em painel_refresh().
await self.channel_layer.group_send(self.group_name, {'type': 'painel.refresh'})
async def _handle_registro_toggle(self, content):
"""
Equivalente ao bloquear-registro-votacao/reabrir-votacao de
nominal.html — mesmo gate de permissão do voto (can_cast_vote),
já que é a mesma Mesa/operador que faz as duas coisas.
"""
if not getattr(self, 'can_cast_vote', False):
await self.send_json({
'type': 'registro_toggle_error',
'message': 'Seu usuário não tem permissão para esta ação.'})
return
aberto = bool(content.get('aberto'))
try:
result = await self._toggle_registro(aberto)
except Exception as e:
await self.send_json({'type': 'registro_toggle_error', 'message': str(e)})
return
await self.send_json({'type': 'registro_toggle_ack', **result})
await self.channel_layer.group_send(self.group_name, {'type': 'painel.refresh'})
@database_sync_to_async
def _toggle_registro(self, aberto):
# import local — mesmo motivo do import em _build_dados_painel.
from sapl.painel.views import VoteError, _toggle_registro
try:
return _toggle_registro(self.scope['user'], self.sessao_id, aberto)
except VoteError as e:
raise Exception(e.message) from None
@database_sync_to_async
def _cast_vote(self, parlamentar_id, voto):
# import local — mesmo motivo do import em _build_dados_painel.
from sapl.painel.views import VoteError, _cast_vote
client = self.scope.get('client') or (None, None)
try:
return _cast_vote(
self.scope['user'], self.sessao_id, parlamentar_id, voto,
ip=client[0])
except VoteError as e:
raise Exception(e.message) from None
@database_sync_to_async
def _build_dados_painel(self, sessao_id):
# import local para evitar ciclo de import no carregamento do ASGI
# (sapl.painel.views importa vários modelos/decorators de request
# HTTP que não precisam existir antes do Django estar totalmente
# pronto, o que já é garantido aqui por database_sync_to_async
# rodar depois de django.setup() em sapl/asgi.py).
from sapl.painel.views import build_dados_painel
return build_dados_painel(
_ScopeRequest(self.scope), sessao_id,
force_mostrar_voto=getattr(self, 'is_operator', False))
@database_sync_to_async
def _user_pode_conectar(self, user):
"""
Aceita qualquer usuário que já teria acesso a alguma das três telas
que este consumer substitui — não há um único modelo de permissão,
já que painel público, vereador votando e operador registrando têm
gates diferentes hoje: check_permission do módulo painel (usado por
get_dados_painel), parlamentares.can_vote (usado por votante_view),
e permissão do módulo sessao (usado por SessaoPermissionMixin na
tela de registro do operador). Substitui o stub `return True` da
referência em feat/painel-votacao-v2.
"""
if user.has_module_perms(PainelAppConfig.label):
return True
if user.has_perm('parlamentares.can_vote'):
return True
if user.has_module_perms('sessao'):
return True
return False
@database_sync_to_async
def _is_operator(self, user):
"""
Papel "operador/Mesa" pra fins de mascaramento de voto (ver
build_dados_painel(force_mostrar_voto=...)) — mais amplo que
can_cast_vote de propósito: cobre tanto quem registra o voto
nominal quanto quem só opera o painel (abre/fecha, cronômetro),
que também não deveria ficar sujeito à máscara pensada pro
público.
"""
return user.has_module_perms(PainelAppConfig.label) or \
user.has_module_perms('sessao')
@database_sync_to_async
def _can_cast_vote(self, user):
# Mesmo gate de vote_controller (check_permission em
# sapl/painel/views.py) — import local, mesmo motivo de sempre.
from sapl.painel.views import check_permission
return check_permission(user)
@database_sync_to_async
def _sessao_existe(self, sessao_id):
return SessaoPlenaria.objects.filter(id=sessao_id).exists()

265
sapl/painel/tests/test_broadcasts.py

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

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()

325
sapl/painel/tests/tests.py

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

39
sapl/painel/urls.py

@ -1,27 +1,30 @@
from django.conf.urls import url
from django.urls import path
from .apps import AppConfig
from .views import (cronometro_painel, get_dados_painel, painel_mensagem_view,
painel_parlamentar_view, painel_view, painel_votacao_view,
switch_painel, verifica_painel, votante_view)
from .views import (cronometro_painel, painel_view,
switch_painel, verifica_painel, votante_status, votante_view,
vote_controller, cancel_voting, close_voting, votos_status)
app_name = AppConfig.name
urlpatterns = [
url(r'^painel-principal/(?P<pk>\d+)$', painel_view,
name="painel_principal"),
url(r'^painel/(?P<pk>\d+)/dados$', get_dados_painel, name='dados_painel'),
url(r'^painel/mensagem$', painel_mensagem_view, name="painel_mensagem"),
url(r'^painel/parlamentar$', painel_parlamentar_view,
name='painel_parlamentar'),
url(r'^painel/switch-painel$', switch_painel,
name="switch_painel"),
url(r'^painel/votacao$', painel_votacao_view, name='painel_votacao'),
url(r'^painel/verifica-painel$', verifica_painel,
name="verifica_painel"),
url(r'^painel/cronometro$', cronometro_painel, name='cronometro_painel'),
# url(r'^painel/cronometro$', include(CronometroPainelCrud.get_urls())),
url(r'^voto-individual/$', votante_view,
name='voto_individual'),
## Votacao - FRONTEND (Django/VueJS)
path("painel/<int:sessao_id>", painel_view, name='painel_principal'),
path("painel/voto-individual", votante_view, name='voto_individual'),
## Votacao - API
path("voto-individual/status", votante_status, name='voto_individual_status'),
path("painel/switch-painel", switch_painel, name="switch_painel"),
path("painel/verifica-painel", verifica_painel, name="verifica_painel"),
path("painel/cronometro", cronometro_painel, name='cronometro_painel'),
path("painel/controller/<int:controller_id>/vote",
vote_controller, name='vote_controller'),
path("painel/controller/<int:controller_id>/cancel",
cancel_voting, name='cancel_voting'),
path("painel/controller/<int:controller_id>/close",
close_voting, name='close_voting'),
path("painel/controller/<int:controller_id>/votos-status",
votos_status, name='votos_status'),
]

1129
sapl/painel/views.py

File diff suppressed because it is too large

23
sapl/protocoloadm/migrations/0047_auto_20260916_1709.py

@ -0,0 +1,23 @@
# Generated by Django 2.2.28 on 2026-09-16 20:09
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('protocoloadm', '0046_auto_20260626_1220'),
]
operations = [
migrations.AlterField(
model_name='documentoadministrativo',
name='ano',
field=models.PositiveSmallIntegerField(choices=[(2027, 2027), (2026, 2026), (2025, 2025), (2024, 2024), (2023, 2023), (2022, 2022), (2021, 2021), (2020, 2020), (2019, 2019), (2018, 2018), (2017, 2017), (2016, 2016), (2015, 2015), (2014, 2014), (2013, 2013), (2012, 2012), (2011, 2011), (2010, 2010), (2009, 2009), (2008, 2008), (2007, 2007), (2006, 2006), (2005, 2005), (2004, 2004), (2003, 2003), (2002, 2002), (2001, 2001), (2000, 2000), (1999, 1999), (1998, 1998), (1997, 1997), (1996, 1996), (1995, 1995), (1994, 1994), (1993, 1993), (1992, 1992), (1991, 1991), (1990, 1990), (1989, 1989), (1988, 1988), (1987, 1987), (1986, 1986), (1985, 1985), (1984, 1984), (1983, 1983), (1982, 1982), (1981, 1981), (1980, 1980), (1979, 1979), (1978, 1978), (1977, 1977), (1976, 1976), (1975, 1975), (1974, 1974), (1973, 1973), (1972, 1972), (1971, 1971), (1970, 1970), (1969, 1969), (1968, 1968), (1967, 1967), (1966, 1966), (1965, 1965), (1964, 1964), (1963, 1963), (1962, 1962), (1961, 1961), (1960, 1960), (1959, 1959), (1958, 1958), (1957, 1957), (1956, 1956), (1955, 1955), (1954, 1954), (1953, 1953), (1952, 1952), (1951, 1951), (1950, 1950), (1949, 1949), (1948, 1948), (1947, 1947), (1946, 1946), (1945, 1945), (1944, 1944), (1943, 1943), (1942, 1942), (1941, 1941), (1940, 1940), (1939, 1939), (1938, 1938), (1937, 1937), (1936, 1936), (1935, 1935), (1934, 1934), (1933, 1933), (1932, 1932), (1931, 1931), (1930, 1930), (1929, 1929), (1928, 1928), (1927, 1927), (1926, 1926), (1925, 1925), (1924, 1924), (1923, 1923), (1922, 1922), (1921, 1921), (1920, 1920), (1919, 1919), (1918, 1918), (1917, 1917), (1916, 1916), (1915, 1915), (1914, 1914), (1913, 1913), (1912, 1912), (1911, 1911), (1910, 1910), (1909, 1909), (1908, 1908), (1907, 1907), (1906, 1906), (1905, 1905), (1904, 1904), (1903, 1903), (1902, 1902), (1901, 1901), (1900, 1900), (1899, 1899), (1898, 1898), (1897, 1897), (1896, 1896), (1895, 1895), (1894, 1894), (1893, 1893), (1892, 1892), (1891, 1891), (1890, 1890)], verbose_name='Ano'),
),
migrations.AlterField(
model_name='protocolo',
name='ano',
field=models.PositiveSmallIntegerField(choices=[(2027, 2027), (2026, 2026), (2025, 2025), (2024, 2024), (2023, 2023), (2022, 2022), (2021, 2021), (2020, 2020), (2019, 2019), (2018, 2018), (2017, 2017), (2016, 2016), (2015, 2015), (2014, 2014), (2013, 2013), (2012, 2012), (2011, 2011), (2010, 2010), (2009, 2009), (2008, 2008), (2007, 2007), (2006, 2006), (2005, 2005), (2004, 2004), (2003, 2003), (2002, 2002), (2001, 2001), (2000, 2000), (1999, 1999), (1998, 1998), (1997, 1997), (1996, 1996), (1995, 1995), (1994, 1994), (1993, 1993), (1992, 1992), (1991, 1991), (1990, 1990), (1989, 1989), (1988, 1988), (1987, 1987), (1986, 1986), (1985, 1985), (1984, 1984), (1983, 1983), (1982, 1982), (1981, 1981), (1980, 1980), (1979, 1979), (1978, 1978), (1977, 1977), (1976, 1976), (1975, 1975), (1974, 1974), (1973, 1973), (1972, 1972), (1971, 1971), (1970, 1970), (1969, 1969), (1968, 1968), (1967, 1967), (1966, 1966), (1965, 1965), (1964, 1964), (1963, 1963), (1962, 1962), (1961, 1961), (1960, 1960), (1959, 1959), (1958, 1958), (1957, 1957), (1956, 1956), (1955, 1955), (1954, 1954), (1953, 1953), (1952, 1952), (1951, 1951), (1950, 1950), (1949, 1949), (1948, 1948), (1947, 1947), (1946, 1946), (1945, 1945), (1944, 1944), (1943, 1943), (1942, 1942), (1941, 1941), (1940, 1940), (1939, 1939), (1938, 1938), (1937, 1937), (1936, 1936), (1935, 1935), (1934, 1934), (1933, 1933), (1932, 1932), (1931, 1931), (1930, 1930), (1929, 1929), (1928, 1928), (1927, 1927), (1926, 1926), (1925, 1925), (1924, 1924), (1923, 1923), (1922, 1922), (1921, 1921), (1920, 1920), (1919, 1919), (1918, 1918), (1917, 1917), (1916, 1916), (1915, 1915), (1914, 1914), (1913, 1913), (1912, 1912), (1911, 1911), (1910, 1910), (1909, 1909), (1908, 1908), (1907, 1907), (1906, 1906), (1905, 1905), (1904, 1904), (1903, 1903), (1902, 1902), (1901, 1901), (1900, 1900), (1899, 1899), (1898, 1898), (1897, 1897), (1896, 1896), (1895, 1895), (1894, 1894), (1893, 1893), (1892, 1892), (1891, 1891), (1890, 1890)], verbose_name='Ano do Protocolo'),
),
]

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
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__)

4
sapl/sessao/forms.py

@ -449,10 +449,6 @@ class PresencaForm(forms.Form):
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):
parlamentar = forms.IntegerField(required=True)
cargo = forms.IntegerField(required=True)

54
sapl/sessao/migrations/0071_votoparlamentar_unique_constraint.py

@ -0,0 +1,54 @@
# Generated by Django 2.2.28 on 2026-08-16 17:36
from django.db import migrations, models
from django.db.models import Count
def remove_votos_duplicados(apps, schema_editor):
"""Preserva o voto canônico antes de criar os índices únicos."""
VotoParlamentar = apps.get_model('sessao', 'VotoParlamentar')
for materia_field in ('ordem_id', 'expediente_id'):
# order_by() limpa o Meta.ordering ('id'), que no Django 2.2 entraria
# no GROUP BY e deixaria todo grupo com n=1.
duplicadas = (VotoParlamentar.objects
.exclude(**{materia_field: None})
.order_by()
.values('parlamentar_id', materia_field)
.annotate(n=Count('id'))
.filter(n__gt=1))
for chave in duplicadas:
votos_duplicados = list(VotoParlamentar.objects.filter(
parlamentar_id=chave['parlamentar_id'],
**{materia_field: chave[materia_field]}))
voto_preservado = max(
votos_duplicados,
key=lambda voto: (
voto.votacao_id is not None,
voto.data_hora is not None,
voto.data_hora,
voto.id,
))
ids_removidos = [voto.id for voto in votos_duplicados
if voto.id != voto_preservado.id]
VotoParlamentar.objects.filter(id__in=ids_removidos).delete()
class Migration(migrations.Migration):
dependencies = [
('sessao', '0070_presenca_unica_por_sessao'),
]
operations = [
migrations.RunPython(
remove_votos_duplicados, migrations.RunPython.noop),
migrations.AddConstraint(
model_name='votoparlamentar',
constraint=models.UniqueConstraint(condition=models.Q(ordem__isnull=False), fields=('parlamentar', 'ordem'), name='sessao_votoparlamentar_unique_parlamentar_ordem'),
),
migrations.AddConstraint(
model_name='votoparlamentar',
constraint=models.UniqueConstraint(condition=models.Q(expediente__isnull=False), fields=('parlamentar', 'expediente'), name='sessao_votoparlamentar_unique_parlamentar_expediente'),
),
]

45
sapl/sessao/migrations/0072_votacao_aberta_unique_constraint.py

@ -0,0 +1,45 @@
# Generated by Django 2.2.28 on 2026-08-16 18:58
from django.db import migrations, models
def fecha_materias_abertas_duplicadas(apps, schema_editor):
"""
Pré-requisito para o AddConstraint abaixo: se já existir mais de uma
OrdemDia/ExpedienteMateria com votacao_aberta=True (dado deixado por
versões anteriores, que não garantiam essa invariante), a constraint
falharia ao ser criada. Mantém aberta apenas a criada por último (maior
id) de cada tabela e fecha as demais.
Também zera registro_aberto, que mudou de significado ("a Mesa abriu a
tela de registro" -> "novos votos bloqueados"): o fluxo antigo marcava
True ao abrir a tela e não voltava para False ao encerrar, o que faria
uma matéria reaberta voltar com os tablets bloqueados.
"""
for model_name in ('OrdemDia', 'ExpedienteMateria'):
model = apps.get_model('sessao', model_name)
model.objects.filter(registro_aberto=True).update(registro_aberto=False)
abertas = model.objects.filter(votacao_aberta=True).order_by('-id')
for materia in abertas[1:]:
materia.votacao_aberta = False
materia.save(update_fields=['votacao_aberta'])
class Migration(migrations.Migration):
dependencies = [
('sessao', '0071_votoparlamentar_unique_constraint'),
]
operations = [
migrations.RunPython(
fecha_materias_abertas_duplicadas, migrations.RunPython.noop),
migrations.AddConstraint(
model_name='expedientemateria',
constraint=models.UniqueConstraint(condition=models.Q(votacao_aberta=True), fields=('votacao_aberta',), name='sessao_expedientemateria_unique_votacao_aberta'),
),
migrations.AddConstraint(
model_name='ordemdia',
constraint=models.UniqueConstraint(condition=models.Q(votacao_aberta=True), fields=('votacao_aberta',), name='sessao_ordemdia_unique_votacao_aberta'),
),
]

237
sapl/sessao/migrations/0073_sessao_presencas_e_materias_votacoes_views.py

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

53
sapl/sessao/migrations/0074_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', '0073_sessao_presencas_e_materias_votacoes_views'),
]
operations = [
migrations.CreateModel(
name='SessaoMateriasVotacoesView',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('sessao_plenaria_id', models.IntegerField()),
('etapa_sessao', models.CharField(max_length=20)),
('numero_ordem', models.IntegerField()),
('materia_id', models.IntegerField()),
('materia_texto', models.TextField()),
('materia_ementa', models.TextField()),
('tipo_votacao', models.IntegerField()),
('tipo_votacao_descricao', models.CharField(max_length=20)),
('resultado_votacao', models.TextField(blank=True, null=True)),
('resultado', models.TextField(blank=True, null=True)),
('numero_votos', django.contrib.postgres.fields.jsonb.JSONField(blank=True, null=True)),
('votos_parlamentares', django.contrib.postgres.fields.jsonb.JSONField(blank=True, null=True)),
('votacao_aberta', models.NullBooleanField()),
],
options={
'db_table': 'sessao_materias_votacoes_view',
'managed': False,
},
),
migrations.CreateModel(
name='SessaoPresencasView',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('sessao_plenaria_id', models.IntegerField()),
('etapa_sessao', models.CharField(max_length=20)),
('parlamentar_id', models.IntegerField()),
('nome_parlamentar', models.TextField()),
('filiacao', models.TextField()),
('ativo', models.BooleanField()),
],
options={
'db_table': 'sessao_presencas_view',
'managed': False,
},
),
]

94
sapl/sessao/models.py

@ -1,6 +1,10 @@
import datetime
from enum import Enum
from operator import xor
from django.contrib.postgres.fields.jsonb import JSONField
from django.core.exceptions import ValidationError
from django.contrib.postgres.fields.jsonb import JSONField
from django.db import models
from django.db.models import Q, F
from django.utils import timezone, formats
@ -438,6 +442,15 @@ class ExpedienteMateria(AbstractOrdemDia):
verbose_name = _('Matéria do Expediente')
verbose_name_plural = _('Matérias do Expediente')
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):
@ -588,6 +601,15 @@ class OrdemDia(AbstractOrdemDia):
verbose_name = _('Matéria da Ordem do Dia')
verbose_name_plural = _('Matérias da Ordem do Dia')
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
@ -711,6 +733,12 @@ class VotoParlamentar(models.Model): # RegistroVotacaoParlamentar
max_length=60,
blank=True,
default='')
votado_pelo_parlamentar = models.BooleanField(
verbose_name=_('Votado pelo parlamentar'),
default=False,
help_text=_('Marca se este voto foi lançado pelo próprio parlamentar '
'(voto individual/tablet) — distinto de um registro feito '
'pela Mesa/operador em nome dele.'))
data_hora = models.DateTimeField(
verbose_name=_('Data/Hora'),
auto_now=True,
@ -728,6 +756,20 @@ class VotoParlamentar(models.Model): # RegistroVotacaoParlamentar
verbose_name = _('Registro de Votação de Parlamentar')
verbose_name_plural = _('Registros de Votações de Parlamentares')
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):
return _('Votação: %(votacao)s - Parlamentar: %(parlamentar)s') % {
@ -1081,3 +1123,55 @@ class Correspondencia(models.Model):
def __str__(self):
return _('Correspondência: {}').format(self.documento)
class SessaoPresencasView(models.Model):
'''
Backed by the `sessao_presencas_view` Postgres view (migration 0073) —
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 0073) — 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'

516
sapl/sessao/tests/test_votacao_nominal.py

@ -0,0 +1,516 @@
from unittest import mock
import pytest
from django.contrib.auth import get_user_model
from django.db import IntegrityError
from django.urls import reverse
from model_bakery import baker
from sapl.materia.models import MateriaLegislativa, TipoMateriaLegislativa
from sapl.painel.views import VoteError, _cast_vote, _toggle_registro
from sapl.parlamentares.models import (Legislatura, Parlamentar,
SessaoLegislativa)
from sapl.sessao.models import (OrdemDia, PresencaOrdemDia, RegistroVotacao,
SessaoPlenaria, TipoResultadoVotacao,
TipoSessaoPlenaria, VotoParlamentar)
NOMINAL = 2
@pytest.fixture(autouse=True)
def in_memory_channel_layer(settings):
settings.CHANNEL_LAYERS = {
'default': {'BACKEND': 'channels.layers.InMemoryChannelLayer'}}
def _sessao_plenaria():
legislatura = baker.make(Legislatura)
sessao_legislativa = baker.make(SessaoLegislativa)
tipo = baker.make(TipoSessaoPlenaria)
return baker.make(SessaoPlenaria, legislatura=legislatura,
sessao_legislativa=sessao_legislativa, tipo=tipo, numero=1)
def _materia():
tipo_materia = baker.make(TipoMateriaLegislativa)
return baker.make(MateriaLegislativa, tipo=tipo_materia)
def _ordem_nominal_aberta():
sessao = _sessao_plenaria()
materia = _materia()
ordem = baker.make(OrdemDia, sessao_plenaria=sessao, materia=materia,
tipo_votacao=NOMINAL, votacao_aberta=True,
registro_aberto=False)
return sessao, ordem
def _presente(sessao):
parlamentar = baker.make(Parlamentar, ativo=True)
baker.make(PresencaOrdemDia, sessao_plenaria=sessao, parlamentar=parlamentar)
return parlamentar
# Registro individual de votação nominal (Ordem do Dia e Expediente) é
# feito hoje pela tela v2 (Vue) — votacaonominal_v2, sem oid/mid na URL, a
# matéria aberta é resolvida via WebSocket (get_materia_aberta/
# get_materia_expediente_aberta) — não mais pela tela legada nominal.html/
# VotacaoNominalAbstract/VotacaoNominalView/VotacaoNominalExpedienteView,
# removida nesta unificação. bloquear-registro-votacao/reabrir-votacao,
# que só existiam lá, viraram _toggle_registro() (sapl/painel/views.py),
# testado abaixo. votacaonominaledit/votacaonominalexpedit (edição de um
# registro já fechado) usam uma base diferente (VotacaoNominalEditAbstract)
# e continuam intocados.
@pytest.mark.django_db(transaction=False)
def test_toggle_registro_bloqueia_e_reabre_sem_mexer_em_votos():
"""
Regressão do que test_bloquear_e_reabrir_votacao_nao_mexe_em_votos_
existentes (tela legada) cobria: bloquear/reabrir o registro não pode
alterar nenhum voto já registrado, só a flag que trava votos novos.
"""
sessao, ordem = _ordem_nominal_aberta()
votante = _presente(sessao)
baker.make(VotoParlamentar, ordem=ordem, parlamentar=votante, voto='Sim')
user = baker.make(get_user_model())
result = _toggle_registro(user, sessao.pk, True)
ordem.refresh_from_db()
assert ordem.registro_aberto is True
assert result == {"registro_aberto": True}
result2 = _toggle_registro(user, sessao.pk, False)
ordem.refresh_from_db()
assert ordem.registro_aberto is False
assert result2 == {"registro_aberto": False}
voto = VotoParlamentar.objects.get(ordem=ordem, parlamentar=votante)
assert voto.voto == 'Sim'
@pytest.mark.django_db(transaction=False)
def test_toggle_registro_falha_sem_materia_nominal_aberta():
sessao = _sessao_plenaria()
user = baker.make(get_user_model())
with pytest.raises(VoteError):
_toggle_registro(user, sessao.pk, True)
@pytest.mark.django_db(transaction=False)
def test_abrir_votacao_ja_aberta_e_idempotente(admin_client):
"""
Regressão: clicar em "Abrir Votação" para uma matéria que já está aberta
(ex.: duplo clique, ou a lista ainda não recarregou para trocar o botão
por "Registrar Votação") caía em verifica_votacoes_abertas(), que trata
a própria matéria como uma "outra" votação conflitante — mostra a
mensagem "existem votações abertas... foram fechadas" e fecha a matéria
(para reabri-la em seguida, já que o código sempre prossegue). O
resultado final até ficava certo (votacao_aberta=True), mas a mensagem
era enganosa. Reabrir a própria matéria já aberta precisa ser
silenciosamente idempotente.
"""
sessao, ordem = _ordem_nominal_aberta()
sessao.iniciada = True
sessao.finalizada = False
sessao.save()
_presente(sessao)
url = reverse('sapl.sessao:abrir_votacao', kwargs={'pk': ordem.pk, 'spk': sessao.pk})
url += '?tipo_materia=ordem'
response = admin_client.get(url, follow=True)
assert response.status_code == 200
ordem.refresh_from_db()
assert ordem.votacao_aberta is True
mensagens = [str(m) for m in response.context['messages']]
assert not any('foram fechadas' in m for m in mensagens)
def _abrir_votacao_url(sessao, ordem):
return reverse('sapl.sessao:abrir_votacao',
kwargs={'pk': ordem.pk, 'spk': sessao.pk}) + '?tipo_materia=ordem'
@pytest.mark.django_db(transaction=False)
def test_abrir_outra_votacao_zera_bloqueio_da_anterior(admin_client):
"""
Abrir Y sem encerrar X fecha X; X não pode voltar com os tablets
bloqueados (registro_aberto=True) quando for reaberta.
"""
sessao, x = _ordem_nominal_aberta()
sessao.iniciada = True
sessao.finalizada = False
sessao.save()
_presente(sessao)
x.registro_aberto = True
x.save()
y = baker.make(OrdemDia, sessao_plenaria=sessao, materia=_materia(),
tipo_votacao=NOMINAL, votacao_aberta=False)
admin_client.get(_abrir_votacao_url(sessao, y))
x.refresh_from_db()
y.refresh_from_db()
assert y.votacao_aberta is True
assert x.votacao_aberta is False
assert x.registro_aberto is False
# Legado: matéria fechada que ficou com registro_aberto=True.
OrdemDia.objects.filter(pk=x.pk).update(registro_aberto=True)
admin_client.get(_abrir_votacao_url(sessao, x))
x.refresh_from_db()
assert x.votacao_aberta is True
assert x.registro_aberto is False
@pytest.mark.django_db(transaction=False)
def test_abrir_votacao_concorrente_mostra_erro_em_vez_de_500(admin_client):
"""
O lock de abrir_votacao() é por sessão, mas a unicidade de
votacao_aberta é global: uma abertura simultânea em outra sessão faz o
save cair no índice parcial. O fechamento das outras votações é
desfeito junto, então a mensagem "foram fechadas" não pode aparecer.
"""
sessao, ordem = _ordem_nominal_aberta()
ordem.votacao_aberta = False
ordem.save()
_, outra = _ordem_nominal_aberta()
sessao.iniciada = True
sessao.finalizada = False
sessao.save()
_presente(sessao)
with mock.patch.object(OrdemDia, 'save', side_effect=IntegrityError):
response = admin_client.get(_abrir_votacao_url(sessao, ordem), follow=True)
assert response.status_code == 200
ordem.refresh_from_db()
outra.refresh_from_db()
assert ordem.votacao_aberta is False
assert outra.votacao_aberta is True
mensagens = [str(m) for m in response.context['messages']]
assert any('aberta simultaneamente' in m for m in mensagens)
assert not any('foram fechadas' in m for m in mensagens)
@pytest.mark.django_db(transaction=False)
def test_abrir_votacao_avisa_que_fechou_as_outras(admin_client):
sessao, ordem = _ordem_nominal_aberta()
ordem.votacao_aberta = False
ordem.save()
_, outra = _ordem_nominal_aberta()
sessao.iniciada = True
sessao.finalizada = False
sessao.save()
_presente(sessao)
response = admin_client.get(_abrir_votacao_url(sessao, ordem), follow=True)
outra.refresh_from_db()
assert outra.votacao_aberta is False
mensagens = [str(m) for m in response.context['messages']]
assert any('foram fechadas' in m for m in mensagens)
@pytest.mark.django_db(transaction=False)
def test_unique_constraint_impede_voto_duplicado(admin_client):
sessao, ordem = _ordem_nominal_aberta()
parlamentar = _presente(sessao)
baker.make(VotoParlamentar, ordem=ordem, parlamentar=parlamentar, voto='Sim')
with pytest.raises(IntegrityError):
VotoParlamentar.objects.create(
ordem=ordem, parlamentar=parlamentar, voto='Não')
@pytest.mark.django_db(transaction=False)
def test_unique_constraint_impede_duas_ordens_abertas():
sessao, ordem = _ordem_nominal_aberta()
outra = baker.make(OrdemDia, sessao_plenaria=sessao, materia=ordem.materia,
tipo_votacao=NOMINAL, votacao_aberta=False)
with pytest.raises(IntegrityError):
outra.votacao_aberta = True
outra.save()
@pytest.mark.django_db(transaction=False)
def test_migracao_0071_remove_votos_duplicados_antes_da_constraint():
import importlib
from django.apps import apps as real_apps
from django.db import connection
migracao = importlib.import_module(
'sapl.sessao.migrations.0071_votoparlamentar_unique_constraint')
with connection.cursor() as cursor:
cursor.execute(
'DROP INDEX sessao_votoparlamentar_unique_parlamentar_ordem')
sessao, ordem = _ordem_nominal_aberta()
parlamentar = _presente(sessao)
antigo = baker.make(VotoParlamentar, ordem=ordem,
parlamentar=parlamentar, voto='Não')
recente = baker.make(VotoParlamentar, ordem=ordem,
parlamentar=parlamentar, voto='Sim')
migracao.remove_votos_duplicados(real_apps, None)
votos = VotoParlamentar.objects.filter(
ordem=ordem, parlamentar=parlamentar)
assert list(votos.values_list('id', flat=True)) == [recente.id]
assert not votos.filter(id=antigo.id).exists()
@pytest.mark.django_db(transaction=False)
def test_migracao_0072_fecha_duplicatas_antes_da_constraint():
"""
A função de dados da migration 0072 precisa fechar duplicatas
pré-existentes antes do AddConstraint — senão a migration falharia ao
ser aplicada num banco com dado antigo (de antes desta invariante
existir). Testa a função isoladamente: como o teste roda dentro de uma
transação que é desfeita no final, é seguro derrubar o índice aqui
(DDL é transacional no Postgres).
"""
import importlib
from django.apps import apps as real_apps
from django.db import connection
migracao = importlib.import_module(
'sapl.sessao.migrations.0072_votacao_aberta_unique_constraint')
with connection.cursor() as cursor:
cursor.execute('DROP INDEX sessao_ordemdia_unique_votacao_aberta')
sessao, mais_antiga = _ordem_nominal_aberta()
mais_recente = baker.make(OrdemDia, sessao_plenaria=sessao,
materia=mais_antiga.materia, tipo_votacao=NOMINAL,
votacao_aberta=True, registro_aberto=True)
assert mais_recente.pk > mais_antiga.pk
# Legado: o GET antigo da tela de registro deixava registro_aberto=True
# em matérias já encerradas.
encerrada = baker.make(OrdemDia, sessao_plenaria=sessao,
materia=mais_antiga.materia, tipo_votacao=NOMINAL,
votacao_aberta=False, registro_aberto=True)
migracao.fecha_materias_abertas_duplicadas(real_apps, None)
mais_antiga.refresh_from_db()
mais_recente.refresh_from_db()
encerrada.refresh_from_db()
assert mais_antiga.votacao_aberta is False
assert mais_recente.votacao_aberta is True
assert mais_recente.registro_aberto is False
assert encerrada.registro_aberto is False
@pytest.mark.django_db(transaction=False)
def test_api_nao_permite_abrir_votacao_via_patch(admin_client):
"""
Regressão do bypass encontrado na auditoria: a API auto-gerada
(drfautoapi) não pode mais aceitar votacao_aberta/registro_aberto —
senão qualquer "Operador de Sessão Plenária" conseguiria abrir uma
matéria via PATCH direto, pulando verifica_votacoes_abertas() e o
lock em abrir_votacao().
"""
sessao, ordem = _ordem_nominal_aberta()
ordem.votacao_aberta = False
ordem.save()
url = '/api/sessao/ordemdia/{}/'.format(ordem.pk)
response = admin_client.patch(
url, data={'votacao_aberta': True}, content_type='application/json')
assert response.status_code in (200, 202)
ordem.refresh_from_db()
assert ordem.votacao_aberta is False
# Regras do #3855 aplicadas ao fluxo v2 (close_voting / _cast_vote /
# votante_view), que substituiu VotacaoNominalAbstract.
def _close_voting(client, sessao, tipo_resultado):
return client.post(
reverse('sapl.painel:close_voting', kwargs={'controller_id': sessao.pk}),
{'resultado_id': tipo_resultado.pk, 'observacoes': ''})
@pytest.mark.django_db(transaction=False)
def test_close_voting_conta_so_presentes_e_apaga_voto_de_quem_saiu(admin_client):
sessao, ordem = _ordem_nominal_aberta()
presente = _presente(sessao)
saiu = _presente(sessao)
baker.make(VotoParlamentar, ordem=ordem, parlamentar=presente, voto='Não')
baker.make(VotoParlamentar, ordem=ordem, parlamentar=saiu, voto='Sim')
PresencaOrdemDia.objects.filter(sessao_plenaria=sessao, parlamentar=saiu).delete()
tipo_resultado = baker.make(TipoResultadoVotacao, nome='Rejeitada', natureza='R')
response = _close_voting(admin_client, sessao, tipo_resultado)
assert response.status_code == 200
registro = RegistroVotacao.objects.get(ordem=ordem)
assert registro.numero_votos_sim == 0
assert registro.numero_votos_nao == 1
assert not VotoParlamentar.objects.filter(ordem=ordem, parlamentar=saiu).exists()
@pytest.mark.django_db(transaction=False)
def test_close_voting_registra_nao_votou_para_presentes_sem_voto(admin_client):
sessao, ordem = _ordem_nominal_aberta()
votou = _presente(sessao)
nao_votou = _presente(sessao)
baker.make(VotoParlamentar, ordem=ordem, parlamentar=votou, voto='Sim')
tipo_resultado = baker.make(TipoResultadoVotacao, nome='Aprovada', natureza='A')
response = _close_voting(admin_client, sessao, tipo_resultado)
assert response.status_code == 200
registro = RegistroVotacao.objects.get(ordem=ordem)
voto = VotoParlamentar.objects.get(ordem=ordem, parlamentar=nao_votou)
assert voto.voto == 'Não Votou'
assert voto.votacao_id == registro.id
@pytest.mark.django_db(transaction=False)
def test_close_voting_sem_votos_nao_grava_nao_votou(admin_client):
"""O operador precisa poder corrigir e tentar de novo."""
sessao, ordem = _ordem_nominal_aberta()
parlamentar = _presente(sessao)
tipo_resultado = baker.make(TipoResultadoVotacao, nome='Aprovada', natureza='A')
response = _close_voting(admin_client, sessao, tipo_resultado)
assert response.status_code == 400
assert not VotoParlamentar.objects.filter(ordem=ordem, parlamentar=parlamentar).exists()
ordem.refresh_from_db()
assert ordem.votacao_aberta is True
@pytest.mark.django_db(transaction=False)
def test_close_voting_preserva_autoria_e_zera_bloqueio(admin_client):
sessao, ordem = _ordem_nominal_aberta()
ordem.registro_aberto = True
ordem.save()
parlamentar = _presente(sessao)
autor = baker.make(get_user_model())
baker.make(VotoParlamentar, ordem=ordem, parlamentar=parlamentar, voto='Sim',
user=autor, ip='10.0.0.1', votado_pelo_parlamentar=True)
tipo_resultado = baker.make(TipoResultadoVotacao, nome='Aprovada', natureza='A')
response = _close_voting(admin_client, sessao, tipo_resultado)
assert response.status_code == 200
voto = VotoParlamentar.objects.get(ordem=ordem, parlamentar=parlamentar)
assert voto.user_id == autor.pk
assert voto.ip == '10.0.0.1'
ordem.refresh_from_db()
assert ordem.votacao_aberta is False
assert ordem.registro_aberto is False
@pytest.mark.django_db(transaction=False)
def test_operador_nao_sobrescreve_voto_do_parlamentar():
"""O voto registrado pelo próprio parlamentar (tablet) prevalece."""
sessao, ordem = _ordem_nominal_aberta()
parlamentar = _presente(sessao)
baker.make(VotoParlamentar, ordem=ordem, parlamentar=parlamentar, voto='Sim',
votado_pelo_parlamentar=True)
operador = baker.make(get_user_model())
with pytest.raises(VoteError):
_cast_vote(operador, sessao.pk, parlamentar.pk, 'Não', ip='127.0.0.1')
voto = VotoParlamentar.objects.get(ordem=ordem, parlamentar=parlamentar)
assert voto.voto == 'Sim'
assert voto.votado_pelo_parlamentar is True
@pytest.mark.django_db(transaction=False)
def test_operador_pode_corrigir_voto_que_ele_mesmo_registrou():
sessao, ordem = _ordem_nominal_aberta()
parlamentar = _presente(sessao)
operador = baker.make(get_user_model())
_cast_vote(operador, sessao.pk, parlamentar.pk, 'Sim', ip='127.0.0.1')
_cast_vote(operador, sessao.pk, parlamentar.pk, 'Não', ip='127.0.0.1')
voto = VotoParlamentar.objects.get(ordem=ordem, parlamentar=parlamentar)
assert voto.voto == 'Não'
assert voto.votado_pelo_parlamentar is False
@pytest.mark.django_db(transaction=False)
@pytest.mark.parametrize('cenario', ['ausente', 'bloqueado'])
def test_operador_nao_registra_voto_invalido(cenario):
sessao, ordem = _ordem_nominal_aberta()
parlamentar = _presente(sessao)
if cenario == 'ausente':
PresencaOrdemDia.objects.filter(sessao_plenaria=sessao).delete()
else:
ordem.registro_aberto = True
ordem.save()
operador = baker.make(get_user_model())
with pytest.raises(VoteError):
_cast_vote(operador, sessao.pk, parlamentar.pk, 'Sim', ip='127.0.0.1')
assert not VotoParlamentar.objects.filter(ordem=ordem, parlamentar=parlamentar).exists()
@pytest.mark.django_db(transaction=False)
def test_operador_nao_votou_desfaz_o_proprio_lancamento_sem_gravar_linha():
"""
'Não Votou' no <select> da Mesa desfaz o lançamento do operador; a
linha 'Não Votou' só é criada no encerramento.
"""
sessao, ordem = _ordem_nominal_aberta()
parlamentar = _presente(sessao)
operador = baker.make(get_user_model())
_cast_vote(operador, sessao.pk, parlamentar.pk, 'Não Votou', ip='127.0.0.1')
assert not VotoParlamentar.objects.filter(ordem=ordem, parlamentar=parlamentar).exists()
_cast_vote(operador, sessao.pk, parlamentar.pk, 'Sim', ip='127.0.0.1')
_cast_vote(operador, sessao.pk, parlamentar.pk, 'Não Votou', ip='127.0.0.1')
assert not VotoParlamentar.objects.filter(ordem=ordem, parlamentar=parlamentar).exists()
@pytest.mark.django_db(transaction=False)
def test_operador_nao_desfaz_voto_do_parlamentar():
sessao, ordem = _ordem_nominal_aberta()
parlamentar = _presente(sessao)
baker.make(VotoParlamentar, ordem=ordem, parlamentar=parlamentar, voto='Sim',
votado_pelo_parlamentar=True)
operador = baker.make(get_user_model())
with pytest.raises(VoteError):
_cast_vote(operador, sessao.pk, parlamentar.pk, 'Não Votou', ip='127.0.0.1')
assert VotoParlamentar.objects.get(ordem=ordem, parlamentar=parlamentar).voto == 'Sim'
@pytest.mark.django_db(transaction=False)
def test_toggle_registro_nao_regrava_outros_campos():
sessao, ordem = _ordem_nominal_aberta()
user = baker.make(get_user_model())
# Outra operação altera a matéria depois que _toggle_registro a leu.
original = _toggle_registro.__globals__['get_materia_aberta']
def get_materia_aberta_obsoleta(pk):
materia = original(pk)
OrdemDia.objects.filter(pk=materia.pk).update(resultado='Aprovada')
return materia
with mock.patch.dict(_toggle_registro.__globals__,
{'get_materia_aberta': get_materia_aberta_obsoleta}):
_toggle_registro(user, sessao.pk, True)
ordem.refresh_from_db()
assert ordem.registro_aberto is True
assert ordem.resultado == 'Aprovada'

76
sapl/sessao/urls.py

@ -19,10 +19,9 @@ from sapl.sessao.views import (AdicionarVariasMateriasExpediente,
VotacaoExpedienteView, VotacaoNominalEditView,
VotacaoNominalExpedienteDetailView,
VotacaoNominalExpedienteEditView,
VotacaoNominalExpedienteView,
VotacaoNominalTransparenciaDetailView,
VotacaoSimbolicaTransparenciaDetailView,
VotacaoNominalView, VotacaoView, abrir_votacao,
VotacaoView, abrir_votacao,
atualizar_mesa, insere_parlamentar_composicao,
mudar_ordem_materia_sessao, recuperar_materia,
recuperar_numero_sessao_view,
@ -39,9 +38,13 @@ from sapl.sessao.views import (AdicionarVariasMateriasExpediente,
TransferenciaMateriasExpediente, TransferenciaMateriasOrdemDia,
filtra_materias_copia_sessao_ajax, verifica_materia_sessao_plenaria_ajax,
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
app_name = AppConfig.name
@ -149,12 +152,13 @@ urlpatterns = [
url(r'^sessao/(?P<pk>\d+)/presencaordemdia$',
PresencaOrdemDiaView.as_view(),
name='presencaordemdia'),
# VOTACAO - LEITURA
url(r'^sessao/(?P<pk>\d+)/votacao_bloco_ordemdia$',
VotacaoEmBlocoOrdemDia.as_view(),
name='votacao_bloco_ordemdia'),
url(r'^sessao/(?P<pk>\d+)/votacao_bloco/votnom$',
url(r'^sessao/(?P<pk>\d+)/votacao_bloco/votacao/nominal$',
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'),
url(r'^sessao/(?P<pk>\d+)/votacao_bloco_expediente$',
VotacaoEmBlocoExpediente.as_view(),
@ -169,39 +173,63 @@ urlpatterns = [
ResumoView.as_view(), name='resumo'),
url(r'^sessao/(?P<pk>\d+)/resumo_ata$',
ResumoAtaView.as_view(), name='resumo_ata'),
##
url(r'^sessao/pesquisar-sessao$',
PesquisarSessaoPlenariaView.as_view(), name='pesquisar_sessao'),
url(r'^sessao/(?P<pk>\d+)/matordemdia/votnom/(?P<oid>\d+)/(?P<mid>\d+)$',
VotacaoNominalView.as_view(), name='votacaonominal'),
url(r'^sessao/(?P<pk>\d+)/matordemdia/votnom/edit/(?P<oid>\d+)/(?P<mid>\d+)$',
# 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/expediente/votacao/simbolica/v2/(?P<oid>\d+)/(?P<mid>\d+)$',
lambda request, **kw: votacao_simbolica_v2_view(request, fase='expediente', **kw),
name='votacaosimbolicaexp_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+)/materia/expediente/leitura/v2/(?P<oid>\d+)/(?P<mid>\d+)$',
lambda request, **kw: leitura_v2_view(request, fase='expediente', **kw),
name='leituraexp_v2'),
url(r'^sessao/(?P<pk>\d+)/materia/expediente/leitura/v2/(?P<oid>\d+)/(?P<mid>\d+)/salvar$',
lambda request, **kw: leitura_v2_action(request, fase='expediente', **kw),
name='leituraexp_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/edit/(?P<oid>\d+)/(?P<mid>\d+)$',
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'),
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'),
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'),
url(r'^sessao/(?P<pk>\d+)/matordemdia/votsimbbloco/$',
url(r'^sessao/(?P<pk>\d+)/materia/ordemdia/votacao/simbolico/bloco/$',
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'),
url(r'^sessao/(?P<pk>\d+)/matexp/votnom/(?P<oid>\d+)/(?P<mid>\d+)$',
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(),
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(),
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'),
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'),
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'),
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'),
url(r'^sessao/(?P<pk>\d+)/votacao-nominal-transparencia/(?P<oid>\d+)/(?P<mid>\d+)$',
VotacaoNominalTransparenciaDetailView.as_view(),
@ -213,15 +241,15 @@ urlpatterns = [
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'),
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'),
url(r'^sessao/(?P<pk>\d+)/(?P<iso>\d+)/(?P<oid>\d+)/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(),
name="transf_mat_exp"),
url(r'^sessao/(?P<pk>\d+)/transf-mat-ordemdia$',

1066
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 os
import socket
import sys
@ -100,8 +101,25 @@ INSTALLED_APPS = (
'django_prometheus',
'channels',
) + 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
# Desabilita a indexação textual até encontramos uma solução para a issue
# https://github.com/interlegis/sapl/issues/2055

BIN
sapl/static/sapl/frontend/.DS_Store.gz

Binary file not shown.

BIN
sapl/static/sapl/frontend/css/chunk-vendors.045ec640.css.gz

Binary file not shown.

1
sapl/static/sapl/frontend/css/compilacao.0baf3580.css

File diff suppressed because one or more lines are too long

BIN
sapl/static/sapl/frontend/css/compilacao.0baf3580.css.gz

Binary file not shown.

1
sapl/static/sapl/frontend/css/compilacao.84d2df71.css

File diff suppressed because one or more lines are too long

BIN
sapl/static/sapl/frontend/css/compilacao.84d2df71.css.gz

Binary file not shown.

BIN
sapl/static/sapl/frontend/css/global.042f6737.css.gz

Binary file not shown.

1
sapl/static/sapl/frontend/css/leitura-materia.fc544ce7.css

@ -0,0 +1 @@
.ws-status-banner[data-v-5850affd]{position:fixed;top:0;left:0;right:0;z-index:2000;padding:10px 16px;text-align:center;font-weight:700;color:#fff}.ws-status-banner--warning[data-v-5850affd]{background:#b8860b}.ws-status-banner--error[data-v-5850affd]{background:#b00020}

BIN
sapl/static/sapl/frontend/css/leitura-materia.fc544ce7.css.gz

Binary file not shown.

1
sapl/static/sapl/frontend/css/painel.780f8a6d.css

@ -0,0 +1 @@
:root{--bg-dark:#121212;--bg-panel:#1e1e1e;--text-main:#e0e0e0;--text-highlight:#4fa64d}body{background-color:var(--bg-dark);color:var(--text-main);font-family:Segoe UI,Tahoma,Geneva,Verdana,sans-serif;margin-bottom:0}.separador-vertical{border-right:1px solid #333;min-height:100vh;margin:0}.text-subtitle{color:#81c784;font-weight:600}.table-custom[data-v-a3308700]{color:#ddd}[data-v-a3308700] .table-custom tbody td{padding:8px;font-size:1.1rem}.text-title[data-v-2415194a]{color:var(--text-highlight);letter-spacing:1px}.parlamentares-grid[data-v-e3616bac]{display:grid;grid-template-columns:repeat(auto-fill,190px);width:100%}.parlamentar-card[data-v-e3616bac]{width:150px;border-radius:3px;overflow:hidden;position:relative}.foto-container[data-v-e3616bac]{border-radius:3px;overflow:hidden;height:150px}.parlamentar-card img[data-v-e3616bac]{width:100%;height:150px;display:block;-o-object-fit:fill;object-fit:fill}.parlamentar-card .nome[data-v-e3616bac]{font-weight:700;font-size:.95rem;color:#fff;line-height:1.1;text-shadow:1px 1px 2px rgba(0,0,0,.8);margin-bottom:2px;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.parlamentar-card .partido[data-v-e3616bac]{font-size:.8rem;color:#ddd;font-weight:600;text-transform:uppercase;text-shadow:1px 1px 2px rgba(0,0,0,.8)}.voto-faixa[data-v-e3616bac]{position:absolute;width:90px;height:90px;top:-45px;right:-45px;-webkit-transform:rotate(45deg);transform:rotate(45deg);display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-align:end;-ms-flex-align:end;align-items:flex-end;-webkit-box-pack:center;-ms-flex-pack:center;justify-content:center;color:#fff;font-weight:700;-webkit-box-shadow:-2px 2px 5px rgba(0,0,0,.2);box-shadow:-2px 2px 5px rgba(0,0,0,.2);z-index:10;line-height:1}.voto-faixa p[data-v-e3616bac]{-webkit-transform:rotate(-45deg);transform:rotate(-45deg);font-size:2rem;text-transform:uppercase}.voto-sim .voto-faixa[data-v-e3616bac]{background-color:#27ae60}.voto-nao .voto-faixa[data-v-e3616bac]{background-color:#c0392b}.voto-abstencao .voto-faixa[data-v-e3616bac]{background-color:#f39c12}.voto-informado .voto-faixa[data-v-e3616bac]{background-color:#d35400}.voto-ausente .voto-faixa[data-v-e3616bac]{background-color:#7f8c8d}#oradores_list tr td[data-v-090c1b6f]{font-size:1.2rem;padding:5px;text-align:start}.text-title[data-v-4b0ccace]{color:var(--text-highlight);letter-spacing:1px}.table-custom[data-v-2acf11e3]{color:#ddd}.table-custom tbody td[data-v-2acf11e3]{padding:8px;font-size:1.1rem}.table-sim[data-v-2acf11e3]{color:#27ae60}.table-nao[data-v-2acf11e3]{color:#c0392b}.table-abstencao[data-v-2acf11e3]{color:#f39c12}.table-total[data-v-2acf11e3]{color:#194bfa}.logo-painel[data-v-5367ad58]{max-height:150px;width:auto}.hora-brasao[data-v-5367ad58]{border-top:1px solid #333}.ws-status-banner[data-v-5850affd]{position:fixed;top:0;left:0;right:0;z-index:2000;padding:10px 16px;text-align:center;font-weight:700;color:#fff}.ws-status-banner--warning[data-v-5850affd]{background:#b8860b}.ws-status-banner--error[data-v-5850affd]{background:#b00020}

BIN
sapl/static/sapl/frontend/css/painel.780f8a6d.css.gz

Binary file not shown.

1
sapl/static/sapl/frontend/css/painel.c215b6be.css

@ -1 +0,0 @@
.painel-principal{background:#1c1b1b;font-family:Verdana;font-size:x-large}.painel-principal .text-title{color:#4fa64d;margin:.5rem;font-weight:700}.painel-principal .text-subtitle{color:#459170;font-weight:700}.painel-principal .data-hora{font-size:180%}.painel-principal .text-value{color:#fff}.painel-principal .logo-painel{max-width:100%}.painel-principal .painels{-ms-flex-wrap:wrap;flex-wrap:wrap}.painel-principal .painel{margin-top:1rem}.painel-principal .painel table{width:100%}.painel-principal .painel h2{margin-bottom:.5rem}.painel-principal .painel #oradores_list,.painel-principal .painel #votacao{text-align:left;display:inline-block;margin-bottom:1rem}

BIN
sapl/static/sapl/frontend/css/painel.c215b6be.css.gz

Binary file not shown.

1
sapl/static/sapl/frontend/css/painel.e2b9504e.css

@ -1 +0,0 @@
.painel-principal{background:#1c1b1b;font-family:Verdana;font-size:x-large}.painel-principal .text-title{color:#4fa64d;margin:.5rem;font-weight:700}.painel-principal .text-subtitle{color:#459170;font-weight:700}.painel-principal .data-hora{font-size:180%}.painel-principal .text-value{color:#fff}.painel-principal .logo-painel{max-width:100%}.painel-principal .painels{-ms-flex-wrap:wrap;flex-wrap:wrap}.painel-principal .painel{margin-top:1rem}.painel-principal .painel table{width:100%}.painel-principal .painel h2{margin-bottom:.5rem}.painel-principal .painel #oradores_list,.painel-principal .painel #votacao{text-align:left;display:inline-block;margin-bottom:1rem}

BIN
sapl/static/sapl/frontend/css/painel.e2b9504e.css.gz

Binary file not shown.

0
sapl/static/sapl/frontend/css/votacao-nominal-bloco.cd5dc5a8.css

1
sapl/static/sapl/frontend/css/votacao-simbolica.fc544ce7.css

@ -0,0 +1 @@
.ws-status-banner[data-v-5850affd]{position:fixed;top:0;left:0;right:0;z-index:2000;padding:10px 16px;text-align:center;font-weight:700;color:#fff}.ws-status-banner--warning[data-v-5850affd]{background:#b8860b}.ws-status-banner--error[data-v-5850affd]{background:#b00020}

BIN
sapl/static/sapl/frontend/css/votacao-simbolica.fc544ce7.css.gz

Binary file not shown.

1
sapl/static/sapl/frontend/css/votacao.fc544ce7.css

@ -0,0 +1 @@
.ws-status-banner[data-v-5850affd]{position:fixed;top:0;left:0;right:0;z-index:2000;padding:10px 16px;text-align:center;font-weight:700;color:#fff}.ws-status-banner--warning[data-v-5850affd]{background:#b8860b}.ws-status-banner--error[data-v-5850affd]{background:#b00020}

BIN
sapl/static/sapl/frontend/css/votacao.fc544ce7.css.gz

Binary file not shown.

1
sapl/static/sapl/frontend/css/voto-individual.e3e45ce9.css

@ -0,0 +1 @@
:root{--bg-dark:#1c1b1b;--text-main:#e0e0e0}body{background-color:var(--bg-dark);color:var(--text-main);font-family:Verdana,Segoe UI,Tahoma,Geneva,sans-serif;max-width:100%;overflow-x:hidden}.btn-voto{min-width:9rem;min-height:4rem;font-size:1.5rem}.ws-status-banner[data-v-5850affd]{position:fixed;top:0;left:0;right:0;z-index:2000;padding:10px 16px;text-align:center;font-weight:700;color:#fff}.ws-status-banner--warning[data-v-5850affd]{background:#b8860b}.ws-status-banner--error[data-v-5850affd]{background:#b00020}

BIN
sapl/static/sapl/frontend/css/voto-individual.e3e45ce9.css.gz

Binary file not shown.

BIN
sapl/static/sapl/frontend/fonts/fa-brands-400.5d18d427.ttf.gz

Binary file not shown.

BIN
sapl/static/sapl/frontend/fonts/fa-regular-400.81482cd4.ttf.gz

Binary file not shown.

BIN
sapl/static/sapl/frontend/fonts/fa-solid-900.69d3141a.ttf.gz

Binary file not shown.

BIN
sapl/static/sapl/frontend/fonts/fa-v4compatibility.2c070fd2.ttf.gz

Binary file not shown.

BIN
sapl/static/sapl/frontend/img/avatar.f8e1e628.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save