Browse Source

feat: fixed more issues

feat/painel-votacao-v2
Edward Ribeiro 2 weeks ago
parent
commit
dc93586bae
  1. 13
      frontend/src/__apps/leitura-materia/main.js
  2. 1
      frontend/src/__apps/painel/store/painelStore.js
  3. 12
      frontend/src/__apps/painel/ws/normalizePainel.js
  4. 28
      frontend/src/__apps/votacao-nominal-bloco/main.js
  5. 1
      frontend/src/__apps/votacao-nominal-bloco/scss/votacao-nominal-bloco.scss
  6. 11
      frontend/src/__apps/votacao-simbolica/main.js
  7. 104
      frontend/src/__apps/votacao/main.js
  8. 74
      frontend/src/components/votacao-nominal-bloco/VotacaoNominalBloco.vue
  9. 25
      frontend/src/components/votacao/VotacaoNominal.vue
  10. 11
      frontend/src/components/votacao/VotacaoVotos.vue
  11. 9
      sapl/asgi.py
  12. 122
      sapl/painel/consumers.py
  13. 26
      sapl/painel/tests/test_broadcasts.py
  14. 5
      sapl/painel/tests/tests.py
  15. 250
      sapl/painel/views.py
  16. 18
      sapl/sessao/migrations/0072_sessao_presencas_e_materias_votacoes_views.py
  17. 6
      sapl/sessao/models.py
  18. 235
      sapl/sessao/tests/test_votacao_nominal.py
  19. 16
      sapl/sessao/urls.py
  20. 587
      sapl/sessao/views.py
  21. 1
      sapl/templates/sessao/votacao/leitura_v2.html
  22. 282
      sapl/templates/sessao/votacao/nominal.html
  23. 97
      sapl/templates/sessao/votacao/votacao_nominal_bloco.html
  24. 1
      sapl/templates/sessao/votacao/votacao_simbolica_v2.html
  25. 3
      sapl/templates/sessao/votacao/votacao_v2.html
  26. 5
      vue.config.js

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

@ -28,6 +28,7 @@ new Vue({ // eslint-disable-line
sessaoId: null, sessaoId: null,
oid: null, oid: null,
mid: null, mid: null,
fase: 'ordem',
initialObservacao: '', initialObservacao: '',
initialMateriaTexto: '', initialMateriaTexto: '',
initialMateriaEmenta: '', initialMateriaEmenta: '',
@ -45,12 +46,14 @@ new Vue({ // eslint-disable-line
this.sessaoId = el.dataset.sessaoId this.sessaoId = el.dataset.sessaoId
this.oid = el.dataset.oid this.oid = el.dataset.oid
this.mid = el.dataset.mid this.mid = el.dataset.mid
this.fase = el.dataset.fase || 'ordem'
this.initialObservacao = el.dataset.observacao || '' this.initialObservacao = el.dataset.observacao || ''
this.initialMateriaTexto = el.dataset.materiaTexto || '' this.initialMateriaTexto = el.dataset.materiaTexto || ''
this.initialMateriaEmenta = el.dataset.materiaEmenta || '' this.initialMateriaEmenta = el.dataset.materiaEmenta || ''
// Mesma URL da tela legada (AbstractLeituraView.cancel_url) — // Mesma URL da tela legada (AbstractLeituraView.cancel_url) —
// iso=1 identifica o fluxo de OrdemDia. // iso=1 identifica o fluxo de OrdemDia, iso=0 Expediente.
this.cancelUrl = `/sessao/${this.sessaoId}/1/${this.oid}/retirar-leitura` const iso = this.fase === 'expediente' ? 0 : 1
this.cancelUrl = `/sessao/${this.sessaoId}/${iso}/${this.oid}/retirar-leitura`
if (this.sessaoId) { if (this.sessaoId) {
this.connectWS() this.connectWS()
@ -61,7 +64,8 @@ new Vue({ // eslint-disable-line
return usePainelStore() return usePainelStore()
}, },
actionUrl () { actionUrl () {
return `/sessao/${this.sessaoId}/materia/ordemdia/leitura/v2/${this.oid}/${this.mid}/salvar` const etapa = this.fase === 'expediente' ? 'expediente' : 'ordemdia'
return `/sessao/${this.sessaoId}/materia/${etapa}/leitura/v2/${this.oid}/${this.mid}/salvar`
}, },
wsURL () { wsURL () {
const proto = location.protocol === 'https:' ? 'wss' : 'ws' const proto = location.protocol === 'https:' ? 'wss' : 'ws'
@ -78,9 +82,6 @@ new Vue({ // eslint-disable-line
try { try {
const data = JSON.parse(message.data) const data = JSON.parse(message.data)
if (data.type === '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) this.painelStore().applyData(data.payload)
} }
} catch (e) { } catch (e) {

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

@ -63,6 +63,7 @@ export const usePainelStore = defineStore('painel', {
this.cronometro_aparte = next.cronometro_aparte this.cronometro_aparte = next.cronometro_aparte
this.cronometro_ordem = next.cronometro_ordem this.cronometro_ordem = next.cronometro_ordem
this.cronometro_consideracoes = next.cronometro_consideracoes this.cronometro_consideracoes = next.cronometro_consideracoes
this.registro_aberto = next.registro_aberto
// arrays: trocar referência (voto já vem aplicado por parlamentar // arrays: trocar referência (voto já vem aplicado por parlamentar
// dentro de next.parlamentares — get_votos() no backend já inlina o // dentro de next.parlamentares — get_votos() no backend já inlina o

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

@ -28,6 +28,10 @@ export const DEFAULT_STATE = Object.freeze({
cronometro_ordem: '', cronometro_ordem: '',
cronometro_consideracoes: '', cronometro_consideracoes: '',
// Só significa algo enquanto há matéria nominal aberta pra registro —
// ver PainelConsumer (type: "registro_toggle") / VotacaoNominal.vue.
registro_aberto: false,
parlamentares: [], parlamentares: [],
oradores: [], oradores: [],
@ -65,7 +69,11 @@ export function normalizePainelData (raw) {
nome_parlamentar: asString(p.nome, ''), nome_parlamentar: asString(p.nome, ''),
filiacao: asString(p.partido, ''), filiacao: asString(p.partido, ''),
fotografia: p.fotografia != null ? p.fotografia : false, fotografia: p.fotografia != null ? p.fotografia : false,
voto: p.voto != null ? p.voto : '' 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) const oradores = asArray(d.oradores)
@ -100,6 +108,8 @@ export function normalizePainelData (raw) {
cronometro_ordem: asString(d.cronometro_ordem, DEFAULT_STATE.cronometro_ordem), cronometro_ordem: asString(d.cronometro_ordem, DEFAULT_STATE.cronometro_ordem),
cronometro_consideracoes: asString(d.cronometro_consideracoes, DEFAULT_STATE.cronometro_consideracoes), cronometro_consideracoes: asString(d.cronometro_consideracoes, DEFAULT_STATE.cronometro_consideracoes),
registro_aberto: d.registro_aberto != null ? !!d.registro_aberto : DEFAULT_STATE.registro_aberto,
parlamentares, parlamentares,
oradores, oradores,

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

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

@ -28,6 +28,7 @@ new Vue({ // eslint-disable-line
sessaoId: null, sessaoId: null,
oid: null, oid: null,
mid: null, mid: null,
fase: 'ordem',
totalPresentes: 0, totalPresentes: 0,
totalVotantes: 0, totalVotantes: 0,
ws: null, ws: null,
@ -43,6 +44,7 @@ new Vue({ // eslint-disable-line
this.sessaoId = el.dataset.sessaoId this.sessaoId = el.dataset.sessaoId
this.oid = el.dataset.oid this.oid = el.dataset.oid
this.mid = el.dataset.mid this.mid = el.dataset.mid
this.fase = el.dataset.fase || 'ordem'
this.totalPresentes = el.dataset.totalPresentes this.totalPresentes = el.dataset.totalPresentes
this.totalVotantes = el.dataset.totalVotantes this.totalVotantes = el.dataset.totalVotantes
@ -64,9 +66,12 @@ new Vue({ // eslint-disable-line
return usePainelStore() return usePainelStore()
}, },
actionUrl () { actionUrl () {
// Mesma URL da tela legada (VotacaoView) — sessao/urls.py: // Mesma URL da tela legada (VotacaoView/VotacaoExpedienteView) —
// sessao/<pk>/materia/ordemdia/votacao/simbolica/<oid>/<mid> // sessao/urls.py: continua sendo quem grava o voto (só o shell/GET
return `/sessao/${this.sessaoId}/materia/ordemdia/votacao/simbolica/${this.oid}/${this.mid}` // 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 () { wsURL () {
const proto = location.protocol === 'https:' ? 'wss' : 'ws' const proto = location.protocol === 'https:' ? 'wss' : 'ws'

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

@ -52,18 +52,38 @@ const v = new Vue({ // eslint-disable-line
wsStatus: 'connecting', wsStatus: 'connecting',
wsErrorMessage: 'Conectando ao servidor de tempo real…', wsErrorMessage: 'Conectando ao servidor de tempo real…',
error_message: '', error_message: '',
// Estado real (não mascarado por mostrar_voto) de quem já votou —
// nunca vem do broadcast do painel (que é a visão pública/mascarada);
// vem de /votos-status, buscado no mount e a cada broadcast recebido.
votosStatus: {},
} }
}, },
computed: { computed: {
...mapState(usePainelStore, [ ...mapState(usePainelStore, [
'sessao_aberta', 'painel_aberto', 'sessao', 'sessao_aberta', 'painel_aberto', 'sessao',
'parlamentares', 'materia', 'resultado', 'message', 'mostrar_voto' '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 () { mounted () {
@ -84,7 +104,6 @@ const v = new Vue({ // eslint-disable-line
} }
} }
if (this.controllerId) { if (this.controllerId) {
this.fetchVotosStatus()
this.connectWS() this.connectWS()
} else { } else {
this.error_message = 'Erro: controller_id não definido. Não é possível conectar ao WebSocket.' this.error_message = 'Erro: controller_id não definido. Não é possível conectar ao WebSocket.'
@ -102,44 +121,30 @@ const v = new Vue({ // eslint-disable-line
return `${proto}://${location.host}/ws/painel/${this.controllerId}/` return `${proto}://${location.host}/ws/painel/${this.controllerId}/`
}, },
voteURL () { // Voto vai pelo WebSocket já aberto (PainelConsumer.receive_json,
return `/v2/painel/controller/${this.controllerId}/vote` // 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
votosStatusURL () { // por compatibilidade, mas esta tela não o chama mais.
return `/v2/painel/controller/${this.controllerId}/votos-status`
},
// Estado real (não mascarado) de quem já votou — nunca lido do store
// (que reflete o broadcast público, mascarado quando mostrar_voto é
// False). A Mesa precisa do valor real para não sobrescrever por
// engano um voto que já chegou por tablet.
fetchVotosStatus () {
if (!this.controllerId) return
axios.get(this.votosStatusURL())
.then(response => {
this.votosStatus = response.data.votos || {}
})
.catch(error => {
console.error('Erro ao buscar votos-status:', error)
})
},
castVote ({ parlamentar_id, voto }) { castVote ({ parlamentar_id, voto }) {
console.log(`Casting vote: parlamentar=${parlamentar_id}, voto=${voto}`) console.log(`Casting vote: parlamentar=${parlamentar_id}, voto=${voto}`)
axios.post(this.voteURL(), { if (!this.isOpen) {
parlamentar_id: parlamentar_id, this.error_message = 'Sem conexão em tempo real. Aguarde reconectar e tente de novo.'
voto: voto return
}, { }
headers: { 'Content-Type': 'application/json' } this.ws.send(JSON.stringify({ type: 'vote', parlamentar_id, voto }))
}) },
.then(response => {
console.log('Vote cast successfully:', response.data) // Bloqueia/reabre o registro de novos votos — equivalente ao
}) // bloquear-registro-votacao/reabrir-votacao de nominal.html (tela
.catch(error => { // legada), agora pelo WS. aberto=true BLOQUEIA (nome do campo
console.error('Error casting vote:', error.response ? error.response.data : error) // registro_aberto é herdado do modelo, mesma semântica de sempre).
this.error_message = 'Erro ao registrar voto. Tente novamente.' 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 () { cancelURL () {
@ -211,9 +216,6 @@ const v = new Vue({ // eslint-disable-line
} catch (e) { } catch (e) {
console.error('Error updating state:', e) console.error('Error updating state:', e)
} }
// O broadcast é a visão pública (mascarada); o estado real de quem
// já votou vem sempre de votos-status, refeito a cada snapshot.
this.fetchVotosStatus()
}, },
connectWS () { connectWS () {
@ -242,6 +244,18 @@ const v = new Vue({ // eslint-disable-line
this.updateState(data) this.updateState(data)
} else if (data.type === 'pong') { } else if (data.type === 'pong') {
console.debug('Votacao: pong recebido') 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) { } catch (e) {
console.error('Votacao WS parse error:', e) console.error('Votacao WS parse error:', e)

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>

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

@ -6,10 +6,22 @@
{{ error_message }} {{ error_message }}
</div> </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> <votacao-materia></votacao-materia>
<br /> <br />
<votacao-votos :votos-status="votosStatus" @cast-vote="onCastVote" ref="votos"></votacao-votos> <votacao-votos :votos-status="votosStatus" :votos-travados="votosTravados" @cast-vote="onCastVote" ref="votos"></votacao-votos>
<votacao-resultado ref="resultado"></votacao-resultado> <votacao-resultado ref="resultado"></votacao-resultado>
@ -41,6 +53,14 @@ export default {
votosStatus: { votosStatus: {
type: Object, type: Object,
default: () => ({}) default: () => ({})
},
votosTravados: {
type: Object,
default: () => ({})
},
registroAberto: {
type: Boolean,
default: false
} }
}, },
data() { data() {
@ -69,6 +89,9 @@ export default {
resultado_selected: this.resultado_selected, resultado_selected: this.resultado_selected,
observacoes: this.observacoes observacoes: this.observacoes
}); });
},
onToggleRegistro() {
this.$emit('toggle-registro', !this.registroAberto);
} }
}, },
mounted() { mounted() {

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

@ -10,12 +10,14 @@
<select class="form-control" <select class="form-control"
:name="'voto_parlamentar_' + p.parlamentar_id" :name="'voto_parlamentar_' + p.parlamentar_id"
:value="votosStatus[p.parlamentar_id] || 'Não Votou'" :value="votosStatus[p.parlamentar_id] || 'Não Votou'"
:disabled="!!votosTravados[p.parlamentar_id]"
@change="onVoteChange(p.parlamentar_id, $event.target.value)"> @change="onVoteChange(p.parlamentar_id, $event.target.value)">
<option value="Não Votou">Não Votou</option> <option value="Não Votou">Não Votou</option>
<option value="Sim">Sim</option> <option value="Sim">Sim</option>
<option value="Não">Não</option> <option value="Não">Não</option>
<option value="Abstenção">Abstenção</option> <option value="Abstenção">Abstenção</option>
</select> </select>
<span v-if="votosTravados[p.parlamentar_id]" class="badge badge-info">Já votado (tablet)</span>
</div> </div>
</template> </template>
</div> </div>
@ -36,6 +38,15 @@ export default {
votosStatus: { votosStatus: {
type: Object, type: Object,
default: () => ({}) 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: { computed: {

9
sapl/asgi.py

@ -8,13 +8,16 @@ django.setup()
from channels.auth import AuthMiddlewareStack # noqa: E402 from channels.auth import AuthMiddlewareStack # noqa: E402
from channels.http import AsgiHandler # noqa: E402 from channels.http import AsgiHandler # noqa: E402
from channels.routing import ProtocolTypeRouter, URLRouter # 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 django.urls import path # noqa: E402
from sapl.painel.consumers import PainelConsumer # noqa: E402 from sapl.painel.consumers import PainelConsumer # noqa: E402
application = ProtocolTypeRouter({ application = ProtocolTypeRouter({
"http": AsgiHandler(), "http": AsgiHandler(),
"websocket": AuthMiddlewareStack(URLRouter([ "websocket": AllowedHostsOriginValidator(
path("ws/painel/<int:sessao_id>/", PainelConsumer.as_asgi()), AuthMiddlewareStack(URLRouter([
])), path("ws/painel/<int:sessao_id>/", PainelConsumer.as_asgi()),
]))
),
}) })

122
sapl/painel/consumers.py

@ -28,8 +28,12 @@ class PainelConsumer(AsyncJsonWebsocketConsumer):
""" """
Um grupo por SessaoPlenaria (`sessao_<id>`) — painel público, tela de Um grupo por SessaoPlenaria (`sessao_<id>`) — painel público, tela de
voto individual (tablet) e tela de registro do operador conectam todos voto individual (tablet) e tela de registro do operador conectam todos
aqui e recebem o mesmo payload completo (broadcast de estado inteiro, aqui. O snapshot é sempre completo (não deltas — ver Parte 3 do plano
não deltas — ver Parte 3 do plano de migração polling->push). 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): async def connect(self):
@ -48,6 +52,11 @@ class PainelConsumer(AsyncJsonWebsocketConsumer):
await self.close(code=4404) await self.close(code=4404)
return 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) self.group_name = 'sessao_{}'.format(self.sessao_id)
await self.channel_layer.group_add(self.group_name, self.channel_name) await self.channel_layer.group_add(self.group_name, self.channel_name)
await self.accept() await self.accept()
@ -60,12 +69,95 @@ class PainelConsumer(AsyncJsonWebsocketConsumer):
await self.channel_layer.group_discard(self.group_name, self.channel_name) await self.channel_layer.group_discard(self.group_name, self.channel_name)
async def receive_json(self, content, **kwargs): async def receive_json(self, content, **kwargs):
if content.get('type') == 'ping': msg_type = content.get('type')
if msg_type == 'ping':
await self.send_json({'type': 'pong'}) 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): 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']}) 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 @database_sync_to_async
def _build_dados_painel(self, sessao_id): def _build_dados_painel(self, sessao_id):
# import local para evitar ciclo de import no carregamento do ASGI # import local para evitar ciclo de import no carregamento do ASGI
@ -74,7 +166,9 @@ class PainelConsumer(AsyncJsonWebsocketConsumer):
# pronto, o que já é garantido aqui por database_sync_to_async # pronto, o que já é garantido aqui por database_sync_to_async
# rodar depois de django.setup() em sapl/asgi.py). # rodar depois de django.setup() em sapl/asgi.py).
from sapl.painel.views import build_dados_painel from sapl.painel.views import build_dados_painel
return build_dados_painel(_ScopeRequest(self.scope), sessao_id) return build_dados_painel(
_ScopeRequest(self.scope), sessao_id,
force_mostrar_voto=getattr(self, 'is_operator', False))
@database_sync_to_async @database_sync_to_async
def _user_pode_conectar(self, user): def _user_pode_conectar(self, user):
@ -96,6 +190,26 @@ class PainelConsumer(AsyncJsonWebsocketConsumer):
return True return True
return False 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 @database_sync_to_async
def _sessao_existe(self, sessao_id): def _sessao_existe(self, sessao_id):
return SessaoPlenaria.objects.filter(id=sessao_id).exists() return SessaoPlenaria.objects.filter(id=sessao_id).exists()

26
sapl/painel/tests/test_broadcasts.py

@ -18,7 +18,7 @@ from model_bakery import baker
from sapl.base.models import AppConfig as ConfiguracoesAplicacao from sapl.base.models import AppConfig as ConfiguracoesAplicacao
from sapl.painel.consumers import PainelConsumer from sapl.painel.consumers import PainelConsumer
from sapl.painel.tests.tests import (NOMINAL, _materia, _ordem_nominal_aberta, from sapl.painel.tests.tests import (NOMINAL, _materia, _ordem_nominal_aberta,
_presente_com_mandato, _registrar_url, _presente_com_mandato,
_votante_com_client) _votante_com_client)
from sapl.parlamentares.models import Mandato, Parlamentar from sapl.parlamentares.models import Mandato, Parlamentar
from sapl.sessao.models import Orador, OrdemDia, PresencaOrdemDia, TipoResultadoVotacao from sapl.sessao.models import Orador, OrdemDia, PresencaOrdemDia, TipoResultadoVotacao
@ -118,6 +118,14 @@ async def test_voto_do_tablet_dispara_broadcast_com_voto_atualizado():
@pytest.mark.django_db(transaction=False) @pytest.mark.django_db(transaction=False)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_encerrar_votacao_dispara_broadcast(): 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() sessao, ordem = await _setup_ordem_aberta()
try: try:
admin_client, admin_user = await _admin_client_login() admin_client, admin_user = await _admin_client_login()
@ -127,12 +135,16 @@ async def test_encerrar_votacao_dispara_broadcast():
communicator = await _painel_communicator(sessao.pk, admin_user) communicator = await _painel_communicator(sessao.pk, admin_user)
await sync_to_async(admin_client.post)(_registrar_url(sessao, ordem), { await sync_to_async(admin_client.post)(
'salvar-votacao': '1', reverse('sapl.painel:vote_controller', kwargs={'controller_id': sessao.pk}),
'resultado_votacao': str(tipo_resultado.pk), {'parlamentar_id': parlamentar.pk, 'voto': 'Sim'})
'observacao': '', # Descarta o refresh disparado pelo voto — este teste verifica o
'voto_parlamentar': ['Sim:{}'.format(parlamentar.pk)], # 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() mensagem = await communicator.receive_json_from()
assert mensagem['type'] == 'data' assert mensagem['type'] == 'data'

5
sapl/painel/tests/tests.py

@ -93,11 +93,6 @@ def _votante_com_client(sessao):
return parlamentar, client return parlamentar, client
def _registrar_url(sessao, ordem):
return reverse('sapl.sessao:votacaonominal',
kwargs={'pk': sessao.pk, 'oid': ordem.pk, 'mid': ordem.materia_id})
@pytest.mark.django_db(transaction=False) @pytest.mark.django_db(transaction=False)
def test_votante_view_envia_headers_never_cache(admin_client, admin_user): def test_votante_view_envia_headers_never_cache(admin_client, admin_user):
sessao, ordem = _ordem_nominal_aberta() sessao, ordem = _ordem_nominal_aberta()

250
sapl/painel/views.py

@ -22,7 +22,8 @@ from sapl.base.models import CasaLegislativa
from sapl.crud.base import Crud from sapl.crud.base import Crud
from sapl.painel.apps import AppConfig from sapl.painel.apps import AppConfig
from sapl.parlamentares.models import Legislatura, Parlamentar, Votante from sapl.parlamentares.models import Legislatura, Parlamentar, Votante
from sapl.sessao.models import (ExpedienteMateria, OradorExpediente, OrdemDia, from sapl.sessao.models import (ExpedienteMateria, OradorExpediente,
OradorOrdemDia, OrdemDia,
PresencaOrdemDia, RegistroVotacao, PresencaOrdemDia, RegistroVotacao,
SessaoMateriasVotacoesView, SessaoPlenaria, SessaoMateriasVotacoesView, SessaoPlenaria,
SessaoPlenariaPresenca, SessaoPresencasView, SessaoPlenariaPresenca, SessaoPresencasView,
@ -249,11 +250,15 @@ def votante_status(request):
}) })
def _save_voto_individual(request, context_vars): def _save_voto_individual(request, context_vars, voto_valor=None, ip=None):
""" """
Salva o voto do Votante autenticado — compartilhado por votante_view Salva o voto do Votante autenticado — compartilhado por votante_view
(tela legada) e votante_view_v2 (Vue), cada uma redirecionando de (tela legada), votante_view_v2 (Vue, form HTTP) e
volta para a própria URL depois (por isso o redirect não mora aqui). PainelConsumer.receive_json (type: "vote_self", WS — ver
_handle_vote_self). `voto_valor`/`ip` deixam o caminho WS passar o
voto/IP explicitamente em vez de ler de request.POST/request.META,
que uma conexão WebSocket não tem; o caminho HTTP não passa nada e
mantém o comportamento de sempre.
""" """
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
username = request.user.username username = request.user.username
@ -268,6 +273,11 @@ def _save_voto_individual(request, context_vars):
if fase_sessao is None: if fase_sessao is None:
return return
voto_valor = voto_valor if voto_valor is not None else request.POST['voto']
if voto_valor not in VALID_VOTE_VALUES:
raise VoteError(
f"Invalid vote value: {voto_valor}. Must be one of: {VALID_VOTE_VALUES}")
# select_for_update+atomic evita corrida com uma escrita concorrente # select_for_update+atomic evita corrida com uma escrita concorrente
# na mesma linha (ex.: o operador registrando este mesmo parlamentar # na mesma linha (ex.: o operador registrando este mesmo parlamentar
# em lote na tela "Registrar Votação" ao mesmo tempo). Diferente do # em lote na tela "Registrar Votação" ao mesmo tempo). Diferente do
@ -283,12 +293,16 @@ def _save_voto_individual(request, context_vars):
logger.info("user=" + username + ". VotoParlamentar para parlamentar={} obtido com sucesso." logger.info("user=" + username + ". VotoParlamentar para parlamentar={} obtido com sucesso."
.format(context_vars['parlamentar'])) .format(context_vars['parlamentar']))
voto.voto = request.POST['voto'] voto.voto = voto_valor
voto.ip = get_client_ip(request) voto.ip = ip if ip is not None else get_client_ip(request)
voto.user = request.user voto.user = request.user
# Único caminho onde é o próprio parlamentar votando — vote_controller/
# _cast_vote (operador) e o formulário em lote sempre gravam False.
voto.votado_pelo_parlamentar = True
voto.save() voto.save()
broadcast_dados_painel(request, context_vars['sessao'].id) broadcast_dados_painel(request, context_vars['sessao'].id)
return voto_valor
@never_cache @never_cache
@login_required @login_required
@ -371,7 +385,12 @@ def get_presentes(pk, response, materia):
presencas = SessaoPresencasView.objects.filter( presencas = SessaoPresencasView.objects.filter(
sessao_plenaria_id=pk, etapa_sessao=etapa_sessao) sessao_plenaria_id=pk, etapa_sessao=etapa_sessao)
oradores = OradorExpediente.objects.filter( # Mesmo discriminador de etapa_sessao usado acima pra SessaoPresencasView
# — sem isso, o payload sempre consultava OradorExpediente, nunca
# OradorOrdemDia, então um orador de Ordem do Dia nunca aparecia no
# painel/telão mesmo com o broadcast disparando corretamente.
oradores_model = OradorOrdemDia if etapa_sessao == 'ordemdia' else OradorExpediente
oradores = oradores_model.objects.filter(
sessao_plenaria_id=pk).order_by('numero_ordem') sessao_plenaria_id=pk).order_by('numero_ordem')
oradores_list = [] oradores_list = []
@ -489,6 +508,14 @@ def get_votos(response, materia, mostrar_voto):
response['presentes'][i]['voto'] = voto response['presentes'][i]['voto'] = voto
else: else:
response['presentes'][i]['voto'] = 'Voto Informado' response['presentes'][i]['voto'] = 'Voto Informado'
# Sempre desmascarado (não depende de mostrar_voto): diz
# quem travou a linha, não o que a pessoa votou — é o
# que VotacaoVotos.vue usa pra saber se deve desabilitar
# o <select> do operador (só quando o próprio parlamentar
# votou, não quando foi o próprio operador via este
# mesmo <select> ou o formulário legado em lote).
response['presentes'][i]['voto_por_tablet'] = bool(
voto_entry.get('votado_pelo_parlamentar'))
elif leitura: elif leitura:
response.update({ response.update({
'numero_votos_sim': 0, 'numero_votos_sim': 0,
@ -506,6 +533,8 @@ def get_votos(response, materia, mostrar_voto):
for i, p in enumerate(response['presentes']): for i, p in enumerate(response['presentes']):
voto_entry = votos_parlamentares.get(str(p['parlamentar_id'])) voto_entry = votos_parlamentares.get(str(p['parlamentar_id']))
response['presentes'][i]['voto'] = voto_entry['voto'] if voto_entry else None response['presentes'][i]['voto'] = voto_entry['voto'] if voto_entry else None
response['presentes'][i]['voto_por_tablet'] = bool(
voto_entry and voto_entry.get('votado_pelo_parlamentar'))
response.update({ response.update({
'numero_votos_sim': numero_votos['votos_sim'], 'numero_votos_sim': numero_votos['votos_sim'],
@ -519,12 +548,24 @@ def get_votos(response, materia, mostrar_voto):
return response return response
def build_dados_painel(request, pk): def build_dados_painel(request, pk, force_mostrar_voto=False):
""" """
Monta o dict completo consumido pelos broadcasts via WebSocket Monta o dict completo consumido pelos broadcasts via WebSocket
(broadcast_dados_painel() abaixo) — era também servido por um endpoint (broadcast_dados_painel() abaixo) — era também servido por um endpoint
HTTP de polling (get_dados_painel), removido quando o polling foi HTTP de polling (get_dados_painel), removido quando o polling foi
eliminado (nenhuma tela restante lê build_dados_painel() por HTTP). eliminado (nenhuma tela restante lê build_dados_painel() por HTTP).
`force_mostrar_voto`: usado por PainelConsumer para dar à conexão do
operador/Mesa (painel/sessao module perms) o voto real de cada
parlamentar, mesmo quando a Casa configura mostrar_voto=False para o
público. Cada conexão WebSocket pede seu próprio payload (ver
PainelConsumer.painel_refresh) em vez de um único payload comum
broadcastado a todo o grupo — assim o voto real nunca trafega para a
conexão do telão público/tablets, só para quem tem permissão de
operação. Sem isso, dar visibilidade real ao operador exigiria colocar
o voto real no payload do grupo inteiro (legível no DevTools do telão
público mesmo que a UI não o renderize) ou deixar a Mesa cega para
votos já registrados por tablet.
""" """
sessao = SessaoPlenaria.objects.get(id=pk) sessao = SessaoPlenaria.objects.get(id=pk)
@ -532,6 +573,8 @@ def build_dados_painel(request, pk):
app_config = ConfiguracoesAplicacao.objects.first() app_config = ConfiguracoesAplicacao.objects.first()
efetivo_mostrar_voto = bool(app_config.mostrar_voto) or force_mostrar_voto
brasao = None brasao = None
if casa and app_config and (bool(casa.logotipo)): if casa and app_config and (bool(casa.logotipo)):
brasao = casa.logotipo.url \ brasao = casa.logotipo.url \
@ -555,22 +598,28 @@ def build_dados_painel(request, pk):
'cronometro_consideracoes': get_cronometro_status(request, 'consideracoes'), 'cronometro_consideracoes': get_cronometro_status(request, 'consideracoes'),
'status_painel': sessao.painel_aberto, 'status_painel': sessao.painel_aberto,
'brasao': brasao, 'brasao': brasao,
'mostrar_voto': app_config.mostrar_voto 'mostrar_voto': efetivo_mostrar_voto
} }
ordem_dia = get_materia_aberta(pk) ordem_dia = get_materia_aberta(pk)
expediente = get_materia_expediente_aberta(pk) expediente = get_materia_expediente_aberta(pk)
# Caso tenha alguma matéria com votação aberta, ela é mostrada no painel # Caso tenha alguma matéria com votação aberta, ela é mostrada no painel
# com prioridade para Ordem do Dia. # com prioridade para Ordem do Dia. registro_aberto só faz sentido
# enquanto há uma matéria aberta pra registro — usado pela tela de
# operação (VotacaoNominal.vue) pra saber se deve mostrar "Bloquear" ou
# "Reabrir Votação" e o aviso de status, equivalente ao que
# nominal.html já fazia lendo o context direto.
if ordem_dia: if ordem_dia:
response['registro_aberto'] = ordem_dia.registro_aberto
return get_votos( return get_votos(
get_presentes(pk, response, ordem_dia), get_presentes(pk, response, ordem_dia),
ordem_dia, app_config.mostrar_voto) ordem_dia, efetivo_mostrar_voto)
elif expediente: elif expediente:
response['registro_aberto'] = expediente.registro_aberto
return get_votos( return get_votos(
get_presentes(pk, response, expediente), get_presentes(pk, response, expediente),
expediente, app_config.mostrar_voto) expediente, efetivo_mostrar_voto)
# Caso não tenha nenhuma aberta, # Caso não tenha nenhuma aberta,
# a matéria a ser mostrada no Painel deve ser a última votada # a matéria a ser mostrada no Painel deve ser a última votada
@ -606,7 +655,7 @@ def build_dados_painel(request, pk):
if ordem_expediente: if ordem_expediente:
return get_votos( return get_votos(
get_presentes(pk, response, ordem_expediente), get_presentes(pk, response, ordem_expediente),
ordem_expediente, app_config.mostrar_voto) ordem_expediente, efetivo_mostrar_voto)
# Retorna que não há nenhuma matéria já votada ou aberta # Retorna que não há nenhuma matéria já votada ou aberta
response.update({ response.update({
@ -616,19 +665,27 @@ def build_dados_painel(request, pk):
def broadcast_dados_painel(request, sessao_id): def broadcast_dados_painel(request, sessao_id):
""" """
Envia o estado atual da sessão para o grupo WebSocket correspondente Avisa o grupo WebSocket correspondente (sapl/painel/consumers.py::
(sapl/painel/consumers.py::PainelConsumer) — chamado pelas views que PainelConsumer) de que algo mudou — chamado pelas views que alteram
alteram voto/matéria/registro, sempre depois da escrita já ter sido voto/matéria/registro, sempre depois da escrita já ter sido commitada
commitada no banco. `request` é reaproveitado só para no banco. `request` não é mais usado aqui (mantido só para não obrigar
get_cronometro_status() dentro de build_dados_painel() (ver o item do a mudar todo call site); build_dados_painel() é chamado depois, uma vez
cronômetro no plano — ainda lê de request.session, não de storage por conexão, não aqui.
compartilhado; isso é resolvido por último, não aqui).
Não carrega mais o payload: manda um sinal leve ("algo mudou nesta
sessão") e cada conexão reconsulta build_dados_painel() por conta
própria (PainelConsumer.painel_refresh), com o mostrar_voto certo para
o seu papel (força unmask para operador/Mesa, nunca para o telão
público/tablets — ver o docstring de build_dados_painel()). Um único
payload comum não daria pra fazer isso sem vazar o voto real da Mesa
para a conexão do telão público (legível via DevTools mesmo sem
renderizar), ou sem deixar a Mesa sem visibilidade do voto real.
Best-effort: um Redis fora do ar não pode derrubar a ação que disparou Best-effort: um Redis fora do ar não pode derrubar a ação que disparou
o broadcast (registrar voto, abrir/fechar matéria) — é exatamente por o broadcast (registrar voto, abrir/fechar matéria). Falha aqui só fica
isso que o polling continua existindo como fallback (ver plano). Falha no log — não há mais fallback de polling (removido nesta branch); quem
aqui só fica no log; quem está na tela sem WebSocket simplesmente está sem WebSocket fica sem atualização em tempo real até reconectar
continua vendo a atualização pelo poll de sempre. ou recarregar a tela.
""" """
from asgiref.sync import async_to_sync from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer from channels.layers import get_channel_layer
@ -639,7 +696,7 @@ def broadcast_dados_painel(request, sessao_id):
return return
async_to_sync(layer.group_send)( async_to_sync(layer.group_send)(
'sessao_{}'.format(sessao_id), 'sessao_{}'.format(sessao_id),
{'type': 'data', 'payload': build_dados_painel(request, sessao_id)}) {'type': 'painel.refresh'})
except Exception: except Exception:
logging.getLogger(__name__).exception( logging.getLogger(__name__).exception(
'Falha ao fazer broadcast do painel para sessao_id={}.'.format(sessao_id)) 'Falha ao fazer broadcast do painel para sessao_id={}.'.format(sessao_id))
@ -677,6 +734,72 @@ def painel_view(request, sessao_id):
VALID_VOTE_VALUES = ["Sim", "Não", "Abstenção", "Não Votou"] VALID_VOTE_VALUES = ["Sim", "Não", "Abstenção", "Não Votou"]
class VoteError(Exception):
"""
Erro de validação ao registrar um voto via _cast_vote() — mensagem já
pronta pra virar resposta pro chamador (HTTP ou WebSocket), com o
status HTTP equivalente em `status` (ignorado pelo caminho WS).
"""
def __init__(self, message, status=400):
super().__init__(message)
self.message = message
self.status = status
def _cast_vote(user, controller_id, parlamentar_id, voto, ip=None):
"""
Valida e grava um voto nominal (OrdemDia ou ExpedienteMateria aberta).
Compartilhado por vote_controller (POST HTTP, mantido por
compatibilidade) e PainelConsumer.receive_json (type: "vote", o
caminho que o app votacao usa agora) — mesma validação, mesma escrita;
só quem chama broadcast_dados_painel()/group_send depois é diferente
(request síncrono vs. group_send assíncrono direto do consumer).
"""
logger = logging.getLogger(__name__)
if not parlamentar_id:
raise VoteError("parlamentar_id is required")
try:
parlamentar_id = int(parlamentar_id)
except (ValueError, TypeError):
raise VoteError("parlamentar_id must be an integer")
if voto not in VALID_VOTE_VALUES:
raise VoteError(
f"Invalid vote value: {voto}. Must be one of: {VALID_VOTE_VALUES}")
try:
SessaoPlenaria.objects.get(id=controller_id)
except SessaoPlenaria.DoesNotExist:
raise VoteError("Session not found", status=404)
ordem_dia = get_materia_aberta(controller_id)
expediente = get_materia_expediente_aberta(controller_id)
materia_aberta = ordem_dia or expediente
if not materia_aberta:
raise VoteError("No open materia for voting")
if materia_aberta.tipo_votacao != VOTACAO_NOMINAL:
raise VoteError("Materia is not nominal voting type")
try:
parlamentar = Parlamentar.objects.get(id=parlamentar_id)
except Parlamentar.DoesNotExist:
raise VoteError(f"Parlamentar {parlamentar_id} not found", status=404)
# votado_pelo_parlamentar=False explícito (não só o default do campo):
# se este parlamentar já tinha um voto marcado como do próprio (tablet)
# e o operador está sobrescrevendo agora, a flag precisa acompanhar —
# o valor atual deixou de ser o que o parlamentar escolheu.
defaults = {'voto': voto, 'user': user, 'ip': ip, 'votado_pelo_parlamentar': False}
if ordem_dia:
voto_obj, created = VotoParlamentar.objects.update_or_create(
parlamentar=parlamentar, ordem=ordem_dia, defaults=defaults)
else:
voto_obj, created = VotoParlamentar.objects.update_or_create(
parlamentar=parlamentar, expediente=expediente, defaults=defaults)
logger.info(
f"Vote {'created' if created else 'updated'}: "
f"parlamentar={parlamentar_id}, voto={voto}, sessao={controller_id}")
return {"parlamentar_id": parlamentar_id, "voto": voto, "created": created}
@user_passes_test(check_permission) @user_passes_test(check_permission)
def vote_controller(request, controller_id): def vote_controller(request, controller_id):
""" """
@ -686,8 +809,11 @@ def vote_controller(request, controller_id):
Body (form-encoded or JSON): Body (form-encoded or JSON):
parlamentar_id: int parlamentar_id: int
voto: str ("Sim", "Não", "Abstenção", "Não Votou") voto: str ("Sim", "Não", "Abstenção", "Não Votou")
Mantido por compatibilidade; o app votacao (VotacaoNominal.vue) manda
o voto pelo WebSocket já aberto (PainelConsumer, type: "vote") em vez
de chamar isto — ver castVote() em frontend/src/__apps/votacao/main.js.
""" """
logger = logging.getLogger(__name__)
if request.method != 'POST': if request.method != 'POST':
return JsonResponse({"type": "error", "message": "Only POST allowed"}, status=405) return JsonResponse({"type": "error", "message": "Only POST allowed"}, status=405)
# Parse body: support both form-encoded and JSON # Parse body: support both form-encoded and JSON
@ -701,52 +827,44 @@ def vote_controller(request, controller_id):
else: else:
parlamentar_id = request.POST.get("parlamentar_id") parlamentar_id = request.POST.get("parlamentar_id")
voto = request.POST.get("voto") voto = request.POST.get("voto")
# Validate
if not parlamentar_id:
return JsonResponse({"type": "error", "message": "parlamentar_id is required"}, status=400)
try:
parlamentar_id = int(parlamentar_id)
except (ValueError, TypeError):
return JsonResponse({"type": "error", "message": "parlamentar_id must be an integer"}, status=400)
if voto not in VALID_VOTE_VALUES:
return JsonResponse({
"type": "error",
"message": f"Invalid vote value: {voto}. Must be one of: {VALID_VOTE_VALUES}"
}, status=400)
# Verify session exists
try: try:
SessaoPlenaria.objects.get(id=controller_id) result = _cast_vote(
except SessaoPlenaria.DoesNotExist: request.user, controller_id, parlamentar_id, voto,
return JsonResponse({"type": "error", "message": "Session not found"}, status=404) ip=get_client_ip(request))
# Find open materia (OrdemDia or ExpedienteMateria) except VoteError as e:
return JsonResponse({"type": "error", "message": e.message}, status=e.status)
broadcast_dados_painel(request, controller_id)
return JsonResponse({"ok": True, **result})
def _toggle_registro(user, controller_id, aberto):
"""
Bloqueia/reabre o registro de novos votos numa votação nominal aberta
— equivalente a bloquear-registro-votacao/reabrir-votacao de
VotacaoNominalAbstract.post() (nominal.html/VotacaoNominalView, tela
legada), agora também disponível para a tela v2 (Vue)/WS via
PainelConsumer.receive_json (type: "registro_toggle"). Não mexe em
nenhum voto já registrado — só na flag que trava votos novos, mesma
semântica de lá: aberto=True bloqueia (nome contraintuitivo herdado do
campo `registro_aberto`, mantido igual para não confundir quem já
conhece o comportamento legado).
"""
ordem_dia = get_materia_aberta(controller_id) ordem_dia = get_materia_aberta(controller_id)
expediente = get_materia_expediente_aberta(controller_id) expediente = get_materia_expediente_aberta(controller_id)
materia_aberta = ordem_dia or expediente materia_aberta = ordem_dia or expediente
if not materia_aberta: if not materia_aberta:
return JsonResponse({"type": "error", "message": "No open materia for voting"}, status=400) raise VoteError("No open materia for voting")
if materia_aberta.tipo_votacao != VOTACAO_NOMINAL: if materia_aberta.tipo_votacao != VOTACAO_NOMINAL:
return JsonResponse({"type": "error", "message": "Materia is not nominal voting type"}, status=400) raise VoteError("Materia is not nominal voting type")
# Verify parlamentar exists
try: materia_aberta.registro_aberto = aberto
parlamentar = Parlamentar.objects.get(id=parlamentar_id) materia_aberta.save()
except Parlamentar.DoesNotExist: logging.getLogger(__name__).info(
return JsonResponse({"type": "error", "message": f"Parlamentar {parlamentar_id} not found"}, status=404) f"registro_aberto={aberto} para materia id={materia_aberta.pk} "
# Save/update vote f"(sessao={controller_id}, user={user.username})")
if ordem_dia: return {"registro_aberto": aberto}
voto_obj, created = VotoParlamentar.objects.update_or_create(
parlamentar=parlamentar,
ordem=ordem_dia,
defaults={'voto': voto, 'user': request.user, 'ip': get_client_ip(request)}
)
else:
voto_obj, created = VotoParlamentar.objects.update_or_create(
parlamentar=parlamentar,
expediente=expediente,
defaults={'voto': voto, 'user': request.user, 'ip': get_client_ip(request)}
)
logger.info(f"Vote {'created' if created else 'updated'}: parlamentar={parlamentar_id}, voto={voto}, sessao={controller_id}")
broadcast_dados_painel(request, controller_id)
return JsonResponse({"ok": True, "parlamentar_id": parlamentar_id, "voto": voto, "created": created})
@user_passes_test(check_permission) @user_passes_test(check_permission)

18
sapl/sessao/migrations/0072_sessao_presencas_e_materias_votacoes_views.py

@ -1,4 +1,4 @@
from django.db import migrations from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
@ -24,6 +24,16 @@ class Migration(migrations.Migration):
] ]
operations = [ 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( migrations.RunSQL(
sql=""" sql="""
DROP VIEW IF EXISTS sessao_presencas_view; DROP VIEW IF EXISTS sessao_presencas_view;
@ -152,7 +162,8 @@ class Migration(migrations.Migration):
'materia_id', em.materia_id, 'materia_id', em.materia_id,
'parlamentar_id', vp.parlamentar_id, 'parlamentar_id', vp.parlamentar_id,
'parlamentar_nome', p.nome_parlamentar, 'parlamentar_nome', p.nome_parlamentar,
'voto', vp.voto 'voto', vp.voto,
'votado_pelo_parlamentar', vp.votado_pelo_parlamentar
)) as votos_parlamentares )) as votos_parlamentares
FROM sessao_votoparlamentar vp FROM sessao_votoparlamentar vp
JOIN parlamentares_parlamentar p ON (vp.parlamentar_id = p.id) JOIN parlamentares_parlamentar p ON (vp.parlamentar_id = p.id)
@ -208,7 +219,8 @@ class Migration(migrations.Migration):
'materia_id', od.materia_id, 'materia_id', od.materia_id,
'parlamentar_id', vp.parlamentar_id, 'parlamentar_id', vp.parlamentar_id,
'parlamentar_nome', p.nome_parlamentar, 'parlamentar_nome', p.nome_parlamentar,
'voto', vp.voto 'voto', vp.voto,
'votado_pelo_parlamentar', vp.votado_pelo_parlamentar
)) as votos_parlamentares )) as votos_parlamentares
FROM sessao_votoparlamentar vp FROM sessao_votoparlamentar vp
JOIN parlamentares_parlamentar p ON (vp.parlamentar_id = p.id) JOIN parlamentares_parlamentar p ON (vp.parlamentar_id = p.id)

6
sapl/sessao/models.py

@ -725,6 +725,12 @@ class VotoParlamentar(models.Model): # RegistroVotacaoParlamentar
max_length=60, max_length=60,
blank=True, blank=True,
default='') 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( data_hora = models.DateTimeField(
verbose_name=_('Data/Hora'), verbose_name=_('Data/Hora'),
auto_now=True, auto_now=True,

235
sapl/sessao/tests/test_votacao_nominal.py

@ -1,9 +1,11 @@
import pytest import pytest
from django.contrib.auth import get_user_model
from django.db import IntegrityError from django.db import IntegrityError
from django.urls import reverse from django.urls import reverse
from model_bakery import baker from model_bakery import baker
from sapl.materia.models import MateriaLegislativa, TipoMateriaLegislativa from sapl.materia.models import MateriaLegislativa, TipoMateriaLegislativa
from sapl.painel.views import VoteError, _toggle_registro
from sapl.parlamentares.models import (Legislatura, Parlamentar, from sapl.parlamentares.models import (Legislatura, Parlamentar,
SessaoLegislativa) SessaoLegislativa)
from sapl.sessao.models import (OrdemDia, PresencaOrdemDia, RegistroVotacao, from sapl.sessao.models import (OrdemDia, PresencaOrdemDia, RegistroVotacao,
@ -41,235 +43,50 @@ def _presente(sessao):
return parlamentar return parlamentar
def _registrar_url(sessao, ordem): # Registro individual de votação nominal (Ordem do Dia e Expediente) é
return reverse('sapl.sessao:votacaonominal', # feito hoje pela tela v2 (Vue) — votacaonominal_v2, sem oid/mid na URL, a
kwargs={'pk': sessao.pk, 'oid': ordem.pk, 'mid': ordem.materia_id}) # 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) @pytest.mark.django_db(transaction=False)
def test_get_registrar_votacao_nao_bloqueia_novos_votos(admin_client): def test_toggle_registro_bloqueia_e_reabre_sem_mexer_em_votos():
""" """
Regressão da causa raiz #1: abrir a tela "Registrar Votação" não pode, Regressão do que test_bloquear_e_reabrir_votacao_nao_mexe_em_votos_
sozinho, impedir que vereadores que ainda não votaram continuem votando. 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()
_presente(sessao)
response = admin_client.get(_registrar_url(sessao, ordem))
assert response.status_code == 200
ordem.refresh_from_db()
assert ordem.registro_aberto is False
@pytest.mark.django_db(transaction=False)
def test_post_sem_acao_reconhecida_apenas_renderiza(admin_client):
"""
O botão "Registrar Votação" agora faz POST (para evitar cache/replay de
GET), mas sem nenhuma chave de ação reconhecida isso deve continuar
sendo pura navegação, sem nenhum efeito colateral.
"""
sessao, ordem = _ordem_nominal_aberta()
_presente(sessao)
response = admin_client.post(_registrar_url(sessao, ordem), {})
assert response.status_code == 200
ordem.refresh_from_db()
assert ordem.registro_aberto is False
@pytest.mark.django_db(transaction=False)
def test_bloquear_e_reabrir_votacao_nao_mexe_em_votos_existentes(admin_client):
sessao, ordem = _ordem_nominal_aberta() sessao, ordem = _ordem_nominal_aberta()
votante = _presente(sessao) votante = _presente(sessao)
baker.make(VotoParlamentar, ordem=ordem, parlamentar=votante, voto='Sim') baker.make(VotoParlamentar, ordem=ordem, parlamentar=votante, voto='Sim')
user = baker.make(get_user_model())
url = _registrar_url(sessao, ordem) result = _toggle_registro(user, sessao.pk, True)
response = admin_client.post(url, {'bloquear-registro-votacao': '1'})
assert response.status_code == 302
ordem.refresh_from_db() ordem.refresh_from_db()
assert ordem.registro_aberto is True assert ordem.registro_aberto is True
assert result == {"registro_aberto": True}
response = admin_client.post(url, {'reabrir-votacao': '1'}) result2 = _toggle_registro(user, sessao.pk, False)
assert response.status_code == 302
ordem.refresh_from_db() ordem.refresh_from_db()
assert ordem.registro_aberto is False assert ordem.registro_aberto is False
assert result2 == {"registro_aberto": False}
voto = VotoParlamentar.objects.get(ordem=ordem, parlamentar=votante) voto = VotoParlamentar.objects.get(ordem=ordem, parlamentar=votante)
assert voto.voto == 'Sim' assert voto.voto == 'Sim'
@pytest.mark.django_db(transaction=False) @pytest.mark.django_db(transaction=False)
def test_salvar_votacao_nao_sobrescreve_voto_ja_registrado(admin_client): def test_toggle_registro_falha_sem_materia_nominal_aberta():
""" sessao = _sessao_plenaria()
Regressão do modelo de concorrência (3b): um formulário de "Fechar user = baker.make(get_user_model())
Votação" com um valor obsoleto para quem já votou pelo tablet não pode with pytest.raises(VoteError):
sobrescrever esse voto — só preenche quem ainda não tem voto registrado. _toggle_registro(user, sessao.pk, True)
"""
sessao, ordem = _ordem_nominal_aberta()
ja_votou = _presente(sessao)
ainda_nao_votou = _presente(sessao)
baker.make(VotoParlamentar, ordem=ordem, parlamentar=ja_votou, voto='Sim')
tipo_resultado = baker.make(TipoResultadoVotacao, nome='Aprovada', natureza='A')
url = _registrar_url(sessao, ordem)
payload = {
'salvar-votacao': '1',
'resultado_votacao': str(tipo_resultado.pk),
'observacao': '',
'voto_parlamentar': [
# valor obsoleto: a tela do operador ainda não sabia que este
# parlamentar já havia votado "Sim" pelo tablet
'Não Votou:{}'.format(ja_votou.pk),
'Não:{}'.format(ainda_nao_votou.pk),
],
}
response = admin_client.post(url, payload)
assert response.status_code == 302
voto_ja_votou = VotoParlamentar.objects.get(ordem=ordem, parlamentar=ja_votou)
assert voto_ja_votou.voto == 'Sim'
voto_novo = VotoParlamentar.objects.get(ordem=ordem, parlamentar=ainda_nao_votou)
assert voto_novo.voto == 'Não'
registro = RegistroVotacao.objects.get(ordem=ordem)
assert registro.numero_votos_sim == 1
assert registro.numero_votos_nao == 1
ordem.refresh_from_db()
assert ordem.votacao_aberta is False
assert ordem.registro_aberto is False
@pytest.mark.django_db(transaction=False)
def test_salvar_votacao_sem_votos_nao_trava_selects_para_nova_tentativa(admin_client):
"""
Regressão: fechar a votação sem nenhum voto real corretamente mostra um
erro, mas antes disso o laço de salvamento em lote criava um
VotoParlamentar com voto='Não Votou' para cada parlamentar cujo select
não foi tocado (o valor padrão do <select>). Esses registros persistiam
mesmo com o fechamento falhando (o bloco atomic não é revertido, já que
form_invalid retorna normalmente em vez de lançar), e nominal.html
desabilita o <select> de qualquer parlamentar com um VotoParlamentar
existente — travando o operador para sempre sem conseguir registrar
nenhum voto para essa matéria.
"""
sessao, ordem = _ordem_nominal_aberta()
parlamentar = _presente(sessao)
tipo_resultado = baker.make(TipoResultadoVotacao, nome='Aprovada', natureza='A')
url = _registrar_url(sessao, ordem)
payload = {
'salvar-votacao': '1',
'resultado_votacao': str(tipo_resultado.pk),
'observacao': '',
'voto_parlamentar': ['Não Votou:{}'.format(parlamentar.pk)],
}
response = admin_client.post(url, payload)
assert response.status_code == 302
assert not VotoParlamentar.objects.filter(
ordem=ordem, parlamentar=parlamentar).exists()
ordem.refresh_from_db()
assert ordem.votacao_aberta is True
# O operador consegue tentar de novo, agora com um voto real.
response2 = admin_client.post(url, {
'salvar-votacao': '1',
'resultado_votacao': str(tipo_resultado.pk),
'observacao': '',
'voto_parlamentar': ['Sim:{}'.format(parlamentar.pk)],
})
assert response2.status_code == 302
ordem.refresh_from_db()
assert ordem.votacao_aberta is False
assert VotoParlamentar.objects.get(ordem=ordem, parlamentar=parlamentar).voto == 'Sim'
@pytest.mark.django_db(transaction=False)
def test_status_da_votacao_reflete_troca_de_voto_do_parlamentar(admin_client):
"""
Regressão: a tela de registro (nominal.html) não atualizava a linha de
um parlamentar que trocou o voto durante a janela de votação — o poll
antigo só marcava "já votou" uma vez e nunca revisitava o valor. O poll
(?status=1) precisa sempre devolver o voto atual, não só se existe.
"""
sessao, ordem = _ordem_nominal_aberta()
parlamentar = _presente(sessao)
voto = baker.make(VotoParlamentar, ordem=ordem, parlamentar=parlamentar,
voto='Sim')
url = _registrar_url(sessao, ordem) + '?status=1'
response = admin_client.get(url)
assert response.status_code == 200
data = response.json()
assert data['votos'] == {str(parlamentar.pk): 'Sim'}
assert data['votacao_aberta'] is True
assert data['registro_aberto'] is False
assert data['ja_registrada'] is False
voto.voto = 'Não'
voto.save()
response2 = admin_client.get(url)
assert response2.json()['votos'] == {str(parlamentar.pk): 'Não'}
@pytest.mark.django_db(transaction=False)
def test_status_da_votacao_nao_depende_de_mostrar_voto(admin_client):
"""
O poll da tela de registro é só para a Mesa, não para o público — ao
contrário de sapl.painel:dados_painel, ele não pode mascarar o valor
real do voto por trás de "Voto Informado" mesmo quando a Casa configura
mostrar_voto=False (essa config controla o telão público, não a tela de
registro da própria Mesa). Como o endpoint nem consulta essa
configuração, isso é garantido por construção — este teste só
documenta a expectativa.
"""
sessao, ordem = _ordem_nominal_aberta()
parlamentar = _presente(sessao)
baker.make(VotoParlamentar, ordem=ordem, parlamentar=parlamentar,
voto='Abstenção')
url = _registrar_url(sessao, ordem) + '?status=1'
data = admin_client.get(url).json()
assert data['votos'][str(parlamentar.pk)] == 'Abstenção'
@pytest.mark.django_db(transaction=False)
def test_status_da_votacao_nao_redireciona_apos_encerrar_votacao(
admin_client):
"""
_get_materia_votacao (usado pelo GET normal) redireciona com uma
mensagem quando a matéria já foi votada — comportamento certo para
navegação, errado para um poll em background. O branch ?status=1 não
pode herdar esse redirect.
"""
sessao, ordem = _ordem_nominal_aberta()
parlamentar = _presente(sessao)
tipo_resultado = baker.make(TipoResultadoVotacao, nome='Aprovada',
natureza='A')
url = _registrar_url(sessao, ordem)
admin_client.post(url, {
'salvar-votacao': '1',
'resultado_votacao': str(tipo_resultado.pk),
'observacao': '',
'voto_parlamentar': ['Sim:{}'.format(parlamentar.pk)],
})
response = admin_client.get(url + '?status=1')
assert response.status_code == 200
data = response.json()
assert data['ja_registrada'] is True
assert data['votacao_aberta'] is False
@pytest.mark.django_db(transaction=False) @pytest.mark.django_db(transaction=False)

16
sapl/sessao/urls.py

@ -19,10 +19,9 @@ from sapl.sessao.views import (AdicionarVariasMateriasExpediente,
VotacaoExpedienteView, VotacaoNominalEditView, VotacaoExpedienteView, VotacaoNominalEditView,
VotacaoNominalExpedienteDetailView, VotacaoNominalExpedienteDetailView,
VotacaoNominalExpedienteEditView, VotacaoNominalExpedienteEditView,
VotacaoNominalExpedienteView,
VotacaoNominalTransparenciaDetailView, VotacaoNominalTransparenciaDetailView,
VotacaoSimbolicaTransparenciaDetailView, VotacaoSimbolicaTransparenciaDetailView,
VotacaoNominalView, VotacaoView, abrir_votacao, VotacaoView, abrir_votacao,
atualizar_mesa, insere_parlamentar_composicao, atualizar_mesa, insere_parlamentar_composicao,
mudar_ordem_materia_sessao, recuperar_materia, mudar_ordem_materia_sessao, recuperar_materia,
recuperar_numero_sessao_view, recuperar_numero_sessao_view,
@ -186,17 +185,24 @@ urlpatterns = [
url(r'^sessao/(?P<pk>\d+)/materia/ordemdia/votacao/simbolica/v2/(?P<oid>\d+)/(?P<mid>\d+)$', url(r'^sessao/(?P<pk>\d+)/materia/ordemdia/votacao/simbolica/v2/(?P<oid>\d+)/(?P<mid>\d+)$',
votacao_simbolica_v2_view, votacao_simbolica_v2_view,
name='votacaosimbolica_v2'), 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+)$', url(r'^sessao/(?P<pk>\d+)/materia/ordemdia/leitura/v2/(?P<oid>\d+)/(?P<mid>\d+)$',
leitura_v2_view, leitura_v2_view,
name='leituraod_v2'), name='leituraod_v2'),
url(r'^sessao/(?P<pk>\d+)/materia/ordemdia/leitura/v2/(?P<oid>\d+)/(?P<mid>\d+)/salvar$', url(r'^sessao/(?P<pk>\d+)/materia/ordemdia/leitura/v2/(?P<oid>\d+)/(?P<mid>\d+)/salvar$',
leitura_v2_action, leitura_v2_action,
name='leituraod_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$', url(r'^sessao/(?P<pk>\d+)/v2/painel$',
TemplateView.as_view(template_name='sessao/painel_v2.html')), TemplateView.as_view(template_name='sessao/painel_v2.html')),
url(r'^sessao/(?P<pk>\d+)/materia/ordemdia/votacao/nominal/(?P<oid>\d+)/(?P<mid>\d+)$',
VotacaoNominalView.as_view(), name='votacaonominal'),
url(r'^sessao/(?P<pk>\d+)/materia/ordemdia/votacao/nominal/edit/(?P<oid>\d+)/(?P<mid>\d+)$', url(r'^sessao/(?P<pk>\d+)/materia/ordemdia/votacao/nominal/edit/(?P<oid>\d+)/(?P<mid>\d+)$',
VotacaoNominalEditView.as_view(), name='votacaonominaledit'), VotacaoNominalEditView.as_view(), name='votacaonominaledit'),
url(r'^sessao/(?P<pk>\d+)/materia/ordemdia/votacao/secreta/(?P<oid>\d+)/(?P<mid>\d+)$', url(r'^sessao/(?P<pk>\d+)/materia/ordemdia/votacao/secreta/(?P<oid>\d+)/(?P<mid>\d+)$',
@ -211,8 +217,6 @@ urlpatterns = [
url(r'^sessao/(?P<pk>\d+)/materia/ordemdia/votacao/simbolica/view/(?P<oid>\d+)/(?P<mid>\d+)$', url(r'^sessao/(?P<pk>\d+)/materia/ordemdia/votacao/simbolica/view/(?P<oid>\d+)/(?P<mid>\d+)$',
VotacaoEditView.as_view(), name='votacaosimbolicaedit'), VotacaoEditView.as_view(), name='votacaosimbolicaedit'),
url(r'^sessao/(?P<pk>\d+)/materia/expediente/votacao/nominal/(?P<oid>\d+)/(?P<mid>\d+)$',
VotacaoNominalExpedienteView.as_view(), name='votacaonominalexp'),
url(r'^sessao/(?P<pk>\d+)/materia/expediente/votacao/nominal/edit/(?P<oid>\d+)/(?P<mid>\d+)$', url(r'^sessao/(?P<pk>\d+)/materia/expediente/votacao/nominal/edit/(?P<oid>\d+)/(?P<mid>\d+)$',
VotacaoNominalExpedienteEditView.as_view(), VotacaoNominalExpedienteEditView.as_view(),
name='votacaonominalexpedit'), name='votacaonominalexpedit'),

587
sapl/sessao/views.py

@ -250,31 +250,35 @@ def abrir_votacao(request, pk, spk):
registro_view_names_ordem = { registro_view_names_ordem = {
# Simbólica e Leitura (Ordem do Dia) vão para as telas de # Simbólica e Leitura (Ordem do Dia) vão para as telas de
# registro v2 (Vue) — mesmo raciocínio do NOMINAL abaixo. # registro v2 (Vue) — mesmo raciocínio do NOMINAL abaixo.
# Secreta fica na legada: não tem tela v2 (VotacaoView/ # Secreta fica na legada: não tem tela v2, por decisão de
# votacao.html atende Simbólica e Secreta hoje, mas só # design (VotacaoView/votacao.html atende Simbólica e Secreta
# Simbólica ganhou uma tela v2 dedicada). NOMINAL aqui não é # hoje; só Simbólica ganhou uma tela v2 dedicada, Secreta
# usado — tratado à parte logo abaixo (única URL v2 sem # nunca vai ganhar). NOMINAL aqui não é usado — tratado à
# oid/mid). # parte logo abaixo (única URL v2 sem oid/mid, unificada para
# Ordem e Expediente).
TipoVotacao.SIMBOLICA: 'votacaosimbolica_v2', TipoVotacao.NOMINAL: 'votacaonominal', TipoVotacao.SIMBOLICA: 'votacaosimbolica_v2', TipoVotacao.NOMINAL: 'votacaonominal',
TipoVotacao.SECRETA: 'votacaosecreta', TipoVotacao.LEITURA: 'leituraod_v2', TipoVotacao.SECRETA: 'votacaosecreta', TipoVotacao.LEITURA: 'leituraod_v2',
} }
registro_view_names_expediente = { registro_view_names_expediente = {
TipoVotacao.SIMBOLICA: 'votacaosimbolicaexp', TipoVotacao.NOMINAL: 'votacaonominalexp', # Simbólica e Leitura de Expediente também vão para telas v2
TipoVotacao.SECRETA: 'votacaosecretaexp', TipoVotacao.LEITURA: 'leituraexp', # (Vue), mesma unificação do NOMINAL abaixo — só Secreta
# continua na legada, para os dois lados.
TipoVotacao.SIMBOLICA: 'votacaosimbolicaexp_v2', TipoVotacao.NOMINAL: 'votacaonominalexp',
TipoVotacao.SECRETA: 'votacaosecretaexp', TipoVotacao.LEITURA: 'leituraexp_v2',
} }
registro_view_names = (registro_view_names_expediente if is_expediente registro_view_names = (registro_view_names_expediente if is_expediente
else registro_view_names_ordem) else registro_view_names_ordem)
if not is_expediente and materia_votacao.tipo_votacao == TipoVotacao.NOMINAL: if materia_votacao.tipo_votacao == TipoVotacao.NOMINAL:
# Votação Nominal de Ordem do Dia vai para a tela de registro # Votação Nominal (Ordem do Dia OU Expediente) vai para a tela
# v2 (Vue) em vez da legada — é a única com submissão por # de registro v2 (Vue) em vez da legada — é a única com
# parlamentar (VotacaoVotos.vue -> vote_controller, salva e # submissão por parlamentar (VotacaoVotos.vue -> WS "vote",
# transmite a cada troca de voto, sem esperar o "Encerrar # salva e transmite a cada troca de voto, sem esperar o
# Votação" em lote). Sem equivalente v2 ainda para # "Encerrar Votação" em lote). votacaonominal_v2 não tem
# Nominal/Expediente nem para os outros tipos de votação, que
# continuam na tela legada abaixo. votacaonominal_v2 não tem
# oid/mid na URL — a matéria aberta é resolvida via o broadcast # oid/mid na URL — a matéria aberta é resolvida via o broadcast
# do WebSocket, não por parâmetro de rota. # do WebSocket (get_materia_aberta/get_materia_expediente_aberta,
# que já checam os dois lados), não por parâmetro de rota — daí
# a mesma URL servir os dois casos.
success_url = reverse('sapl.sessao:votacaonominal_v2', kwargs={'pk': spk}) success_url = reverse('sapl.sessao:votacaonominal_v2', kwargs={'pk': spk})
else: else:
registro_view_name = registro_view_names[materia_votacao.tipo_votacao] registro_view_name = registro_view_names[materia_votacao.tipo_votacao]
@ -365,17 +369,17 @@ def customize_link_materia(context, pk, has_permission, is_expediente, request=N
url = '' url = ''
if is_expediente: if is_expediente:
if obj.tipo_votacao == TipoVotacao.SIMBOLICA: if obj.tipo_votacao == TipoVotacao.SIMBOLICA:
url = reverse('sapl.sessao:votacaosimbolicaexp', url = reverse('sapl.sessao:votacaosimbolicaexp_v2',
kwargs={ kwargs={
'pk': obj.sessao_plenaria_id, 'pk': obj.sessao_plenaria_id,
'oid': obj.pk, 'oid': obj.pk,
'mid': obj.materia_id}) 'mid': obj.materia_id})
elif obj.tipo_votacao == TipoVotacao.NOMINAL: elif obj.tipo_votacao == TipoVotacao.NOMINAL:
url = reverse('sapl.sessao:votacaonominalexp', # Nominal (Ordem do Dia e Expediente) unificado na
kwargs={ # mesma tela v2 sem oid/mid — mesmo raciocínio do
'pk': obj.sessao_plenaria_id, # branch "else" (Ordem do Dia) logo abaixo.
'oid': obj.pk, url = reverse('sapl.sessao:votacaonominal_v2',
'mid': obj.materia_id}) kwargs={'pk': obj.sessao_plenaria_id})
elif obj.tipo_votacao == TipoVotacao.SECRETA: elif obj.tipo_votacao == TipoVotacao.SECRETA:
url = reverse('sapl.sessao:votacaosecretaexp', url = reverse('sapl.sessao:votacaosecretaexp',
kwargs={ kwargs={
@ -383,7 +387,7 @@ def customize_link_materia(context, pk, has_permission, is_expediente, request=N
'oid': obj.pk, 'oid': obj.pk,
'mid': obj.materia_id}) 'mid': obj.materia_id})
elif obj.tipo_votacao == TipoVotacao.LEITURA: elif obj.tipo_votacao == TipoVotacao.LEITURA:
url = reverse('sapl.sessao:leituraexp', url = reverse('sapl.sessao:leituraexp_v2',
kwargs={ kwargs={
'pk': obj.sessao_plenaria_id, 'pk': obj.sessao_plenaria_id,
'oid': obj.pk, 'oid': obj.pk,
@ -425,20 +429,15 @@ def customize_link_materia(context, pk, has_permission, is_expediente, request=N
if has_permission: if has_permission:
if obj.tipo_votacao != TipoVotacao.LEITURA: if obj.tipo_votacao != TipoVotacao.LEITURA:
# Votação Nominal de Expediente é a única que ainda # Nominal (Ordem do Dia e Expediente, ambos agora)
# aponta para VotacaoNominalAbstract (sem tela v2) — # aponta para votacaonominal_v2, que só faz GET —
# é a única cuja tela de registro sabe tratar POST # mesmo tratamento de Simbólica/Secreta. O POST
# sem efeito colateral, por isso só ela é convertida # especial que existia aqui só para Nominal de
# para POST aqui; Simbólica/Secreta continuam GET, e # Expediente era necessário quando ela ainda
# Nominal de Ordem do Dia também (aponta para # apontava para VotacaoNominalAbstract/nominal.html
# votacaonominal_v2, que só faz GET). # (retirado desta unificação — ver plano).
metodo = '' metodo = ''
csrf_input = '' csrf_input = ''
if is_expediente and obj.tipo_votacao == TipoVotacao.NOMINAL:
metodo = ' method="post"'
csrf_input = (
'<input type="hidden" name="csrfmiddlewaretoken" value="%s" />'
% get_token(request))
btn_registrar = ''' btn_registrar = '''
<form action="%s"%s> <form action="%s"%s>
%s %s
@ -2853,18 +2852,22 @@ def votacao_nominal_v2_view(request, pk):
@never_cache @never_cache
@user_passes_test(_check_sessao_permission) @user_passes_test(_check_sessao_permission)
def votacao_simbolica_v2_view(request, pk, oid, mid): def votacao_simbolica_v2_view(request, pk, oid, mid, fase='ordem'):
""" """
Shell da página Vue de votação simbólica — mesmo racional de Shell da página Vue de votação simbólica — mesmo racional de
votacao_nominal_v2_view acima. total_presentes/total_votantes seguem votacao_nominal_v2_view acima. Uma só view/template pra Ordem do Dia e
o mesmo cálculo de VotacaoView.get() (contagem de presentes Expediente (mesma unificação do Nominal); `fase` só afeta qual tabela
ativos/inativos) — não vêm de build_dados_painel(), cujo de presença conta ('ordem' -> PresencaOrdemDia, 'expediente' ->
num_presentes reflete a etapa de ordem-do-dia como um todo, não SessaoPlenariaPresenca — mesmo par usado em sessao_votacao() e
necessariamente a mesma contagem "com/sem presidente" que esta tela get_presentes()). total_presentes/total_votantes seguem o mesmo
usa. cálculo de VotacaoView.get()/VotacaoExpedienteView.get() (contagem de
presentes ativos/inativos) — não vêm de build_dados_painel(), cujo
num_presentes reflete a etapa como um todo, não necessariamente a
mesma contagem "com/sem presidente" que esta tela usa.
""" """
presenca_model = PresencaOrdemDia if fase == 'ordem' else SessaoPlenariaPresenca
presentes_id = [ presentes_id = [
presente.parlamentar_id for presente in PresencaOrdemDia.objects.filter( presente.parlamentar_id for presente in presenca_model.objects.filter(
sessao_plenaria_id=pk) sessao_plenaria_id=pk)
] ]
total_presentes = len(presentes_id) total_presentes = len(presentes_id)
@ -2875,6 +2878,7 @@ def votacao_simbolica_v2_view(request, pk, oid, mid):
'pk': pk, 'pk': pk,
'oid': oid, 'oid': oid,
'mid': mid, 'mid': mid,
'fase': fase,
'total_presentes': total_presentes, 'total_presentes': total_presentes,
'total_votantes': total_votantes, 'total_votantes': total_votantes,
'tipos_resultado': list(TipoResultadoVotacao.objects.all().values('id', 'nome')), 'tipos_resultado': list(TipoResultadoVotacao.objects.all().values('id', 'nome')),
@ -3082,370 +3086,6 @@ def fechar_votacao_materia(materia):
materia.save() materia.save()
class VotacaoNominalAbstract(SessaoPermissionMixin):
template_name = 'sessao/votacao/nominal.html'
ordem = None
expediente = None
form_class = VotacaoNominalForm
logger = logging.getLogger(__name__)
def _get_materia_votacao(self, request, kwargs):
"""
Resolve a OrdemDia/ExpedienteMateria sendo registrada, sem nenhum
efeito colateral (não altera registro_aberto nem qualquer outro
estado) — apenas consulta. Retorna
(materia_votacao, presentes, total, redirect); quando a matéria não
pode ser exibida (já votada ou com a votação fechada), os três
primeiros valores são None e `redirect` é a resposta a devolver.
"""
username = request.user.username
if self.ordem:
ordem_id = kwargs['oid']
if RegistroVotacao.objects.filter(ordem_id=ordem_id).exists():
msg = _('Esta matéria já foi votada!')
messages.add_message(request, messages.ERROR, msg)
self.logger.info(
'user=' + username + '. Matéria (ordem_id={}) já votada!'.format(ordem_id))
return None, None, None, HttpResponseRedirect(reverse(
'sapl.sessao:ordemdia_list', kwargs={'pk': kwargs['pk']}))
try:
materia_votacao = OrdemDia.objects.get(id=ordem_id)
except ObjectDoesNotExist:
self.logger.error(
'user=' + username + '. Objeto OrdemDia (pk={}) não existe.'.format(ordem_id))
raise Http404()
presentes = PresencaOrdemDia.objects.filter(
sessao_plenaria_id=materia_votacao.sessao_plenaria_id)
if not materia_votacao.votacao_aberta:
self.logger.error(
'user=' + username + '. A votação para esta OrdemDia (id={}) encontra-se fechada!'.format(ordem_id))
msg = _('A votação para esta matéria encontra-se fechada!')
messages.add_message(request, messages.ERROR, msg)
return None, None, None, HttpResponseRedirect(reverse(
'sapl.sessao:ordemdia_list', kwargs={'pk': kwargs['pk']}))
elif self.expediente:
expediente_id = kwargs['oid']
if (RegistroVotacao.objects.filter(
expediente_id=expediente_id).exists()):
self.logger.error(
"user=" + username + ". RegistroVotacao (expediente_id={}) já existe.".format(expediente_id))
msg = _('Esta matéria já foi votada!')
messages.add_message(request, messages.ERROR, msg)
return None, None, None, HttpResponseRedirect(reverse(
'sapl.sessao:expedientemateria_list',
kwargs={'pk': kwargs['pk']}))
try:
self.logger.debug(
"user=" + username + ". Tentando obter Objeto ExpedienteMateria com id={}.".format(expediente_id))
materia_votacao = ExpedienteMateria.objects.get(id=expediente_id)
except ObjectDoesNotExist:
self.logger.error(
'user=' + username + '. Objeto ExpedienteMateria com id={} não existe.'.format(expediente_id))
raise Http404()
presentes = SessaoPlenariaPresenca.objects.filter(
sessao_plenaria_id=materia_votacao.sessao_plenaria_id)
if not materia_votacao.votacao_aberta:
msg = _(
'A votação para este ExpedienteMateria (id={}) encontra-se fechada!'.format(expediente_id))
messages.add_message(request, messages.ERROR, msg)
return None, None, None, HttpResponseRedirect(reverse(
'sapl.sessao:expedientemateria_list',
kwargs={'pk': kwargs['pk']}))
total = presentes.count()
return materia_votacao, presentes, total, None
def _build_registro_context(self, materia_votacao, presentes, total):
materia = {'materia': materia_votacao.materia,
'ementa': sub(
'&nbsp;', ' ', strip_tags(
materia_votacao.materia.ementa))}
return {'materia': materia, 'object': self.get_object(),
'parlamentares': self.get_parlamentares(presentes),
'form': self.get_form(),
'total': total,
'registro_aberto': materia_votacao.registro_aberto}
def _redirect_same_registro(self, kwargs, page):
view = ('sapl.sessao:votacaonominal' if self.ordem
else 'sapl.sessao:votacaonominalexp')
return HttpResponseRedirect(reverse(view, kwargs={
'pk': kwargs['pk'], 'oid': kwargs['oid'], 'mid': kwargs['mid']}) + page)
def _redirect_lista(self, kwargs, page):
view = ('sapl.sessao:ordemdia_list' if self.ordem
else 'sapl.sessao:expedientemateria_list')
return HttpResponseRedirect(
reverse(view, kwargs={'pk': kwargs['pk']}) + page +
"#id{}".format(kwargs['mid']))
def get(self, request, *args, **kwargs):
if request.GET.get('status') == '1':
return self._status_json(kwargs)
materia_votacao, presentes, total, redirect = self._get_materia_votacao(
request, kwargs)
if redirect:
return redirect
context = self._build_registro_context(materia_votacao, presentes, total)
return self.render_to_response(context)
def _status_json(self, kwargs):
"""
Poll leve para a tela de registro (nominal.html) acompanhar, em tempo
real, os votos que chegam pelos tablets — sem os efeitos colaterais
de _get_materia_votacao (mensagens, redirect quando já votada) e sem
a máscara de mostrar_voto do sapl.painel:dados_painel (que é para o
telão público; aqui é a tela da própria Mesa, que precisa do valor
real para não sobrescrever por engano um voto que mudou).
"""
model = OrdemDia if self.ordem else ExpedienteMateria
lookup_field = 'ordem_id' if self.ordem else 'expediente_id'
try:
materia_votacao = model.objects.get(id=kwargs['oid'])
except ObjectDoesNotExist:
raise Http404()
votos = dict(VotoParlamentar.objects.filter(
**{lookup_field: materia_votacao.id}).values_list(
'parlamentar_id', 'voto'))
return JsonResponse({
'votacao_aberta': materia_votacao.votacao_aberta,
'registro_aberto': materia_votacao.registro_aberto,
'ja_registrada': RegistroVotacao.objects.filter(
**{lookup_field: materia_votacao.id}).exists(),
'votos': votos,
})
def _get_or_create_voto_parlamentar(self, lookup_field, lookup_value, parlamentar_id):
"""
get_or_create protegido contra a corrida de duas inserções
concorrentes para o mesmo (parlamentar, matéria) — ex.: o tablet do
parlamentar e o formulário em lote do operador chegando ao mesmo
tempo. Usa um savepoint próprio para que um IntegrityError aqui não
derrube a transação inteira do 'Encerrar Votação'.
"""
try:
with transaction.atomic():
return VotoParlamentar.objects.select_for_update().get_or_create(
parlamentar_id=parlamentar_id, **{lookup_field: lookup_value})
except IntegrityError:
return VotoParlamentar.objects.select_for_update().get(
parlamentar_id=parlamentar_id, **{lookup_field: lookup_value}), False
def post(self, request, *args, **kwargs):
self.object = self.get_object()
username = request.user.username
page = ''
if 'page' in self.request.GET:
page = '?page={}'.format(self.request.GET['page'])
materia_votacao, presentes, total, redirect = self._get_materia_votacao(
request, kwargs)
if redirect:
return redirect
fase_sessao_field = 'ordem_id' if self.ordem else 'expediente_id'
fase_sessao_value = kwargs['oid']
if 'reabrir-votacao' in request.POST:
materia_votacao.registro_aberto = False
materia_votacao.save()
self.logger.info(
'user=' + username + '. Reabriu a matéria (id={}) para novos votos.'.format(fase_sessao_value))
broadcast_dados_painel(request, kwargs['pk'])
return self._redirect_same_registro(kwargs, page)
if 'bloquear-registro-votacao' in request.POST:
materia_votacao.registro_aberto = True
materia_votacao.save()
self.logger.info(
'user=' + username + '. Bloqueou novos votos para a matéria (id={}).'.format(fase_sessao_value))
broadcast_dados_painel(request, kwargs['pk'])
return self._redirect_same_registro(kwargs, page)
if 'cancelar-votacao' in request.POST:
fechar_votacao_materia(materia_votacao)
broadcast_dados_painel(request, kwargs['pk'])
return self._redirect_lista(kwargs, page)
if 'salvar-votacao' in request.POST:
form = self.get_form()
if not form.is_valid():
return self.form_invalid(form)
if form.cleaned_data['resultado_votacao'] is None:
form.add_error(None, _('Não é possível finalizar a votação sem '
'nenhum resultado da votação'))
return self.form_invalid(form)
skipped_parlamentares = []
with transaction.atomic():
for votos in request.POST.getlist('voto_parlamentar'):
voto_submetido, parlamentar_id = votos.split(':')
voto_parlamentar, created = self._get_or_create_voto_parlamentar(
fase_sessao_field, fase_sessao_value, parlamentar_id)
if created:
if voto_submetido == 'Não Votou':
# "Não Votou" é só o valor padrão do <select>
# para quem o operador não escolheu nada — não é
# um voto de fato. Persisti-lo aqui travaria a
# linha desse parlamentar (nominal.html desabilita
# o <select> sempre que existe um VotoParlamentar)
# mesmo quando o fechamento falha por falta de
# votos reais, impedindo o operador de corrigir e
# tentar de novo.
voto_parlamentar.delete()
continue
voto_parlamentar.voto = voto_submetido
voto_parlamentar.user = request.user
voto_parlamentar.ip = get_client_ip(request)
voto_parlamentar.save()
elif voto_parlamentar.voto != voto_submetido:
# Alguém (tipicamente via tablet) já registrou um
# voto diferente do valor deste formulário desde que
# a tela de registro foi carregada. O voto já
# registrado prevalece — não sobrescrevemos com um
# valor obsoleto do formulário em lote.
skipped_parlamentares.append(voto_parlamentar.parlamentar)
votos_atuais = VotoParlamentar.objects.filter(
**{fase_sessao_field: fase_sessao_value})
votos_sim = votos_atuais.filter(voto='Sim').count()
votos_nao = votos_atuais.filter(voto='Não').count()
abstencoes = votos_atuais.filter(voto='Abstenção').count()
if votos_sim + votos_nao + abstencoes == 0:
self.logger.error('user=' + username + '. Não é possível finalizar a votação sem '
'nenhum voto')
form.add_error(None, _('Não é possível finalizar a votação sem '
'nenhum voto'))
return self.form_invalid(form)
# Remove todas as votação desta matéria, caso existam
RegistroVotacao.objects.filter(
**{fase_sessao_field: fase_sessao_value}).delete()
votacao = RegistroVotacao(
numero_votos_sim=votos_sim,
numero_votos_nao=votos_nao,
numero_abstencoes=abstencoes,
observacao=request.POST.get('observacao', None),
user=request.user,
ip=get_client_ip(request),
materia_id=materia_votacao.materia.id,
tipo_resultado_votacao=form.cleaned_data['resultado_votacao'])
setattr(votacao, fase_sessao_field, fase_sessao_value)
votacao.save()
votos_atuais.update(votacao_id=votacao.id)
materia_votacao.resultado = form.cleaned_data['resultado_votacao'].nome
materia_votacao.votacao_aberta = False
materia_votacao.registro_aberto = False
materia_votacao.save()
# Verifica se existe algum VotoParlamentar sem RegistroVotacao
# Por exemplo, se algum parlamentar votar e sua presença for
# removida da ordem do dia/expediente antes da conclusão da
# votação
VotoParlamentar.objects.filter(
**{fase_sessao_field: fase_sessao_value}, votacao__isnull=True).delete()
broadcast_dados_painel(request, kwargs['pk'])
if skipped_parlamentares:
nomes = ', '.join(p.nome_parlamentar for p in skipped_parlamentares)
messages.add_message(
request, messages.WARNING,
_('O(s) voto(s) de %(nomes)s já haviam sido registrados '
'e não foram sobrescritos.') % {'nomes': nomes})
return self.form_valid(form)
# Nenhuma chave de ação reconhecida: navegação simples para a tela
# de registro (botão "Registrar Votação"), sem efeito colateral.
context = self._build_registro_context(materia_votacao, presentes, total)
return self.render_to_response(context)
def form_invalid(self, form):
errors_tuple = [(form[e].label, form.errors[e])
for e in form.errors if e in form.fields]
error_message = '''<ul>'''
for e in errors_tuple:
error_message += '''<li><b>%s</b>: %s</li>''' % (e[0], e[1][0])
for e in form.non_field_errors():
error_message += '''<li>%s</li>''' % e
error_message += '''</ul>'''
messages.add_message(self.request, messages.ERROR, error_message)
if self.ordem:
view = 'sapl.sessao:votacaonominal'
elif self.expediente:
view = 'sapl.sessao:votacaonominalexp'
else:
view = None
return HttpResponseRedirect(reverse(
view,
kwargs={'pk': self.kwargs['pk'],
'oid': self.kwargs['oid'],
'mid': self.kwargs['mid']}))
def get_parlamentares(self, presencas):
self.object = self.get_object()
presentes = [p.parlamentar for p in presencas]
if self.ordem:
voto_parlamentar = VotoParlamentar.objects.filter(
ordem=self.kwargs['oid'])
elif self.expediente:
voto_parlamentar = VotoParlamentar.objects.filter(
expediente=self.kwargs['oid'])
for parlamentar in Parlamentar.objects.filter(ativo=True):
if parlamentar in presentes:
try:
voto = voto_parlamentar.get(
parlamentar=parlamentar)
except ObjectDoesNotExist:
username = self.request.user.username
self.logger.warning('User={}. Objeto voto_parlamentar do parlamentar de id={} não existe.'
.format(username, parlamentar.pk))
yield [parlamentar, None]
else:
yield [parlamentar, voto.voto]
def get_success_url(self):
page = ''
if 'page' in self.request.GET:
page = '?page={}'.format(self.request.GET['page'])
pk = self.kwargs['pk']
if self.ordem:
return reverse(
'sapl.sessao:ordemdia_list',
kwargs={'pk': pk}
) + page + "#id{}".format(self.kwargs['mid'])
elif self.expediente:
return reverse(
'sapl.sessao:expedientemateria_list',
kwargs={'pk': pk}
) + page + "#id{}".format(self.kwargs['mid'])
class VotacaoNominalEditAbstract(SessaoPermissionMixin): class VotacaoNominalEditAbstract(SessaoPermissionMixin):
template_name = 'sessao/votacao/nominal_edit.html' template_name = 'sessao/votacao/nominal_edit.html'
@ -3580,16 +3220,6 @@ class VotacaoNominalEditAbstract(SessaoPermissionMixin):
) + page + "#id{}".format(self.kwargs['mid']) ) + page + "#id{}".format(self.kwargs['mid'])
class VotacaoNominalView(VotacaoNominalAbstract):
ordem = True
expediente = False
class VotacaoNominalExpedienteView(VotacaoNominalAbstract):
expediente = True
ordem = False
class VotacaoNominalEditView(VotacaoNominalEditAbstract): class VotacaoNominalEditView(VotacaoNominalEditAbstract):
ordem = True ordem = True
expediente = False expediente = False
@ -3775,7 +3405,23 @@ class VotacaoExpedienteView(SessaoPermissionMixin):
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
self.object = self.get_object() self.object = self.get_object()
form = VotacaoForm(request.POST)
# Branch JSON usada pela página Vue de votação simbólica (mesma de
# VotacaoView.post, agora também para Expediente) — gated em
# content_type pra não afetar os posts HTML normais (secreta e
# simbólicabloco, que também passam por esta view).
wants_json = request.content_type == 'application/json'
if wants_json:
try:
body = json.loads(request.body)
except json.JSONDecodeError:
return JsonResponse({"ok": False, "message": "JSON inválido."}, status=400)
post_data = QueryDict('', mutable=True)
post_data.update({k: str(v) for k, v in body.items()})
else:
post_data = request.POST
form = VotacaoForm(post_data)
context = self.get_context_data(object=self.object) context = self.get_context_data(object=self.object)
url = request.get_full_path() url = request.get_full_path()
@ -3811,56 +3457,73 @@ class VotacaoExpedienteView(SessaoPermissionMixin):
context.update({'form': form}) context.update({'form': form})
# ==================================================== # ====================================================
if 'cancelar-votacao' in request.POST: if 'cancelar-votacao' in post_data:
expediente.votacao_aberta = False expediente.votacao_aberta = False
expediente.save() expediente.save()
# Mesmo gap de VotacaoView.post: cancelar não notificava o
# painel via WebSocket, só no próximo poll (removido).
broadcast_dados_painel(request, self.kwargs['pk'])
if wants_json:
return JsonResponse({"ok": True, "redirect_url": self.get_success_url()})
return self.form_valid(form) return self.form_valid(form)
if form.is_valid(): if form.is_valid():
materia_id = kwargs['mid'] materia_id = kwargs['mid']
expediente_id = kwargs['oid'] expediente_id = kwargs['oid']
qtde_votos = (int(request.POST['votos_sim']) + qtde_votos = (int(post_data['votos_sim']) +
int(request.POST['votos_nao']) + int(post_data['votos_nao']) +
int(request.POST['abstencoes'])) int(post_data['abstencoes']))
if (int(request.POST['voto_presidente']) == 0): if (int(post_data['voto_presidente']) == 0):
qtde_ativos -= 1 qtde_ativos -= 1
if qtde_votos != qtde_ativos: if qtde_votos != qtde_ativos:
msg = _( msg = _(
'O total de votos não corresponde com a quantidade de votantes!') 'O total de votos não corresponde com a quantidade de votantes!')
if wants_json:
return JsonResponse({"ok": False, "message": str(msg)}, status=400)
messages.add_message(request, messages.ERROR, msg) messages.add_message(request, messages.ERROR, msg)
return self.render_to_response(context) return self.render_to_response(context)
else: else:
try: try:
votacao = RegistroVotacao() votacao = RegistroVotacao()
votacao.numero_votos_sim = int(request.POST['votos_sim']) votacao.numero_votos_sim = int(post_data['votos_sim'])
votacao.numero_votos_nao = int(request.POST['votos_nao']) votacao.numero_votos_nao = int(post_data['votos_nao'])
votacao.numero_abstencoes = int(request.POST['abstencoes']) votacao.numero_abstencoes = int(post_data['abstencoes'])
votacao.observacao = request.POST['observacao'] votacao.observacao = post_data['observacao']
votacao.materia_id = materia_id votacao.materia_id = materia_id
votacao.expediente_id = expediente_id votacao.expediente_id = expediente_id
votacao.tipo_resultado_votacao_id = int( votacao.tipo_resultado_votacao_id = int(
request.POST['resultado_votacao']) post_data['resultado_votacao'])
votacao.user = request.user votacao.user = request.user
votacao.ip = get_client_ip(request) votacao.ip = get_client_ip(request)
votacao.save() votacao.save()
except Exception as e: except Exception as e:
username = request.user.username username = request.user.username
self.logger.error("user=" + username + ". " + str(e)) self.logger.error("user=" + username + ". " + str(e))
if wants_json:
return JsonResponse({"ok": False, "message": "Erro ao salvar o registro de votação."},
status=400)
return self.form_invalid(form) return self.form_invalid(form)
else: else:
expediente = ExpedienteMateria.objects.get( expediente = ExpedienteMateria.objects.get(
id=expediente_id) id=expediente_id)
resultado = TipoResultadoVotacao.objects.get( resultado = TipoResultadoVotacao.objects.get(
id=request.POST['resultado_votacao']) id=post_data['resultado_votacao'])
expediente.resultado = resultado.nome expediente.resultado = resultado.nome
expediente.votacao_aberta = False expediente.votacao_aberta = False
expediente.save() expediente.save()
# Idem: fechar com resultado não notificava o painel.
broadcast_dados_painel(request, self.kwargs['pk'])
if wants_json:
return JsonResponse({"ok": True, "redirect_url": self.get_success_url()})
return self.form_valid(form) return self.form_valid(form)
else: else:
if wants_json:
errors = {f: e[0] for f, e in form.errors.items()}
return JsonResponse({"ok": False, "message": "Formulário inválido.", "errors": errors}, status=400)
return self.render_to_response(context) return self.render_to_response(context)
def get_tipos_votacao(self): def get_tipos_votacao(self):
@ -5058,7 +4721,9 @@ class VotacaoEmBlocoNominalView(PermissionRequiredForAppCrudMixin, TemplateView)
sessao_plenaria_id=kwargs['pk']) sessao_plenaria_id=kwargs['pk'])
context.update({'expedientes': expedientes}) context.update({'expedientes': expedientes})
total_presentes = presentes.count() total_presentes = presentes.count()
context.update({'parlamentares': self.get_parlamentares(), parlamentares = list(self.get_parlamentares())
context.update({'parlamentares': parlamentares,
'parlamentares_json': self._parlamentares_json(parlamentares),
'total_presentes': total_presentes}) 'total_presentes': total_presentes})
if 'cancelar-votacao' in request.POST: if 'cancelar-votacao' in request.POST:
@ -5135,6 +4800,10 @@ class VotacaoEmBlocoNominalView(PermissionRequiredForAppCrudMixin, TemplateView)
voto_parlamentar.votacao_id = votacao.id voto_parlamentar.votacao_id = votacao.id
voto_parlamentar.user = request.user voto_parlamentar.user = request.user
voto_parlamentar.ip = get_client_ip(request) voto_parlamentar.ip = get_client_ip(request)
# Registro da Mesa, não do parlamentar — explícito
# (não só o default) pra sobrescrever, se preciso,
# um voto que já tinha chegado por tablet.
voto_parlamentar.votado_pelo_parlamentar = False
voto_parlamentar.save() voto_parlamentar.save()
ordem.resultado = form.cleaned_data['resultado_votacao'].nome ordem.resultado = form.cleaned_data['resultado_votacao'].nome
@ -5181,6 +4850,10 @@ class VotacaoEmBlocoNominalView(PermissionRequiredForAppCrudMixin, TemplateView)
voto_parlamentar.votacao_id = votacao.id voto_parlamentar.votacao_id = votacao.id
voto_parlamentar.user = request.user voto_parlamentar.user = request.user
voto_parlamentar.ip = get_client_ip(request) voto_parlamentar.ip = get_client_ip(request)
# Registro da Mesa, não do parlamentar — explícito
# (não só o default) pra sobrescrever, se preciso,
# um voto que já tinha chegado por tablet.
voto_parlamentar.votado_pelo_parlamentar = False
voto_parlamentar.save() voto_parlamentar.save()
expediente.resultado = form.cleaned_data['resultado_votacao'].nome expediente.resultado = form.cleaned_data['resultado_votacao'].nome
@ -5282,7 +4955,9 @@ class VotacaoEmBlocoNominalView(PermissionRequiredForAppCrudMixin, TemplateView)
context.update({'expedientes': expedientes}) context.update({'expedientes': expedientes})
total_presentes = presentes.count() total_presentes = presentes.count()
context.update({'parlamentares': self.get_parlamentares(), parlamentares = list(self.get_parlamentares())
context.update({'parlamentares': parlamentares,
'parlamentares_json': self._parlamentares_json(parlamentares),
'total_presentes': total_presentes, 'total_presentes': total_presentes,
'resultado_votacao': TipoResultadoVotacao.objects.all(), 'resultado_votacao': TipoResultadoVotacao.objects.all(),
'form': form, 'form': form,
@ -5290,6 +4965,19 @@ class VotacaoEmBlocoNominalView(PermissionRequiredForAppCrudMixin, TemplateView)
return self.render_to_response(context) return self.render_to_response(context)
@staticmethod
def _parlamentares_json(parlamentares):
# [parlamentar, voto|None] -> shape que VotacaoNominalBloco.vue
# espera (frontend/src/components/votacao-nominal-bloco/).
return [
{
'parlamentar_id': p.id,
'nome_parlamentar': p.nome_parlamentar,
'voto': voto,
}
for p, voto in parlamentares
]
class RetiradaPautaCrud(MasterDetailCrud): class RetiradaPautaCrud(MasterDetailCrud):
model = RetiradaPauta model = RetiradaPauta
@ -5330,15 +5018,26 @@ class RetiradaPautaCrud(MasterDetailCrud):
@never_cache @never_cache
@user_passes_test(_check_sessao_permission) @user_passes_test(_check_sessao_permission)
def leitura_v2_view(request, pk, oid, mid): def leitura_v2_view(request, pk, oid, mid, fase='ordem'):
"""Shell da página Vue de leitura de matéria (só o fluxo de OrdemDia).""" """
Shell da página Vue de leitura de matéria — Ordem do Dia e Expediente
(mesma unificação de votacao_simbolica_v2_view/votacaonominal_v2);
`fase` decide qual modelo é `oid` (OrdemDia vs ExpedienteMateria) e
qual FK de RegistroLeitura preencher, mesmo par usado em
leitura_v2_action logo abaixo e no resto do código (ver
votos_status()/get_presentes()).
"""
materia = MateriaLegislativa.objects.get(id=mid) materia = MateriaLegislativa.objects.get(id=mid)
ordem = OrdemDia.objects.get(id=oid) model = OrdemDia if fase == 'ordem' else ExpedienteMateria
registro = RegistroLeitura.objects.filter(materia=materia, ordem=ordem).first() lookup_field = 'ordem' if fase == 'ordem' else 'expediente'
materia_votacao = model.objects.get(id=oid)
registro = RegistroLeitura.objects.filter(
materia=materia, **{lookup_field: materia_votacao}).first()
context = { context = {
'pk': pk, 'pk': pk,
'oid': oid, 'oid': oid,
'mid': mid, 'mid': mid,
'fase': fase,
'materia_texto': str(materia), 'materia_texto': str(materia),
'materia_ementa': materia.ementa, 'materia_ementa': materia.ementa,
'observacao': registro.observacao if registro else '', 'observacao': registro.observacao if registro else '',
@ -5347,7 +5046,7 @@ def leitura_v2_view(request, pk, oid, mid):
@user_passes_test(_check_sessao_permission) @user_passes_test(_check_sessao_permission)
def leitura_v2_action(request, pk, oid, mid): def leitura_v2_action(request, pk, oid, mid, fase='ordem'):
""" """
Registra a leitura direto, sem passar por OrdemExpedienteLeituraForm Registra a leitura direto, sem passar por OrdemExpedienteLeituraForm
(cujos campos materia/ordem/user/ip são HiddenInput preenchidos via (cujos campos materia/ordem/user/ip são HiddenInput preenchidos via
@ -5366,28 +5065,32 @@ def leitura_v2_action(request, pk, oid, mid):
body = request.POST body = request.POST
materia = MateriaLegislativa.objects.get(id=mid) materia = MateriaLegislativa.objects.get(id=mid)
ordem = OrdemDia.objects.get(id=oid) model = OrdemDia if fase == 'ordem' else ExpedienteMateria
lookup_field = 'ordem' if fase == 'ordem' else 'expediente'
materia_votacao = model.objects.get(id=oid)
RegistroLeitura.objects.update_or_create( RegistroLeitura.objects.update_or_create(
materia=materia, ordem=ordem, materia=materia, **{lookup_field: materia_votacao},
defaults={ defaults={
'observacao': body.get('observacao', ''), 'observacao': body.get('observacao', ''),
'user': request.user, 'user': request.user,
'ip': get_client_ip(request), 'ip': get_client_ip(request),
}) })
ordem.resultado = "Matéria lida" materia_votacao.resultado = "Matéria lida"
ordem.votacao_aberta = False materia_votacao.votacao_aberta = False
ordem.save() materia_votacao.save()
# Mesmo gap encontrado em VotacaoView.post(): registrar a leitura # Mesmo gap encontrado em VotacaoView.post(): registrar a leitura
# também muda o estado da matéria no painel e nunca notificava via # também muda o estado da matéria no painel e nunca notificava via
# WebSocket, só no próximo poll. # WebSocket, só no próximo poll.
broadcast_dados_painel(request, pk) broadcast_dados_painel(request, pk)
lista_url = 'sapl.sessao:ordemdia_list' if fase == 'ordem' \
else 'sapl.sessao:expedientemateria_list'
return JsonResponse({ return JsonResponse({
"ok": True, "ok": True,
"redirect_url": reverse('sapl.sessao:ordemdia_list', kwargs={'pk': pk}), "redirect_url": reverse(lista_url, kwargs={'pk': pk}),
}) })

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

@ -10,6 +10,7 @@
data-sessao-id="{{ pk }}" data-sessao-id="{{ pk }}"
data-oid="{{ oid }}" data-oid="{{ oid }}"
data-mid="{{ mid }}" data-mid="{{ mid }}"
data-fase="{{ fase }}"
data-materia-texto="{{ materia_texto }}" data-materia-texto="{{ materia_texto }}"
data-materia-ementa="{{ materia_ementa }}" data-materia-ementa="{{ materia_ementa }}"
data-observacao="{{ observacao }}"> data-observacao="{{ observacao }}">

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

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

97
sapl/templates/sessao/votacao/votacao_nominal_bloco.html

@ -1,7 +1,10 @@
{% extends "crud/detail.html" %} {% extends "crud/detail.html" %}
{% load i18n crispy_forms_tags%} {% load i18n crispy_forms_tags%}
{% load common_tags %}
{% load render_bundle from webpack_loader %}
{% block detail_content %} {% block detail_content %}
<style>[v-cloak]{display:none}</style>
<form method="post"> <form method="post">
{% csrf_token %} {% csrf_token %}
@ -39,35 +42,20 @@
<br /> <br />
{% endfor %} {% endfor %}
{% endif %} {% endif %}
<fieldset class="form-group"> {% if parlamentares %}
{{ parlamentares_json|json_script:"parlamentares-data" }}
{% if parlamentares %} <div id="app-votacao-nominal-bloco" v-cloak>
<legend>Votos:</legend> <votacao-nominal-bloco :parlamentares="parlamentares"></votacao-nominal-bloco>
<div class="row"> </div>
{% for parlamentar in parlamentares %} {% else %}
<div class="col-md-4" id="styleparlamentar">{{parlamentar.0.nome_parlamentar}}</div> <fieldset class="form-group">
<div class="col-md-5"> <legend>Situação da Votação:</legend>
{% if parlamentar.1 %} <input type="hidden" name="voto_parlamentar" value="{{parlamentar.1}}:{{parlamentar.0.id}}" /> {% endif %} <input type="hidden" name="votos_sim" value="0">
<select id="voto_parlamentar" name="voto_parlamentar" class="form-control" {% if parlamentar.1 %} disabled {% endif %}> <input type="hidden" name="votos_nao" value="0">
<option value="Não Votou:{{parlamentar.0.id}}">Não Votou</option> <input type="hidden" name="abstencoes" value="0">
<option value="Sim:{{parlamentar.0.id}}" {% if parlamentar.1 == 'Sim' %} selected {% endif %}>Sim</option> <input type="hidden" name="nao_votou" value="0">
<option value="Não:{{parlamentar.0.id}}" {% if parlamentar.1 == 'Não' %} selected {% endif %}>Não</option> </fieldset>
<option value="Abstenção:{{parlamentar.0.id}}" {% if parlamentar.1 == 'Abstenção' %} selected {% endif %}>Abstenção</option> {% endif %}
</select>
</div>
{% endfor %}
</div>
{% endif %}
<br>
<legend>Situação da Votação:</legend>
<div id="soma_votos"></div>
<input type="hidden" id="votos_sim" name="votos_sim" value="">
<input type="hidden" id="votos_nao" name="votos_nao" value="">
<input type="hidden" id="abstencoes" name="abstencoes" value="">
<input type="hidden" id="nao_votou" name="nao_votou" value="">
</fieldset>
<div class="row"> <div class="row">
<div class="col-md-12"> <div class="col-md-12">
@ -114,50 +102,17 @@
window.history.back(); window.history.back();
} }
function conta_votos() { // A soma de votos (antes um setTimeout(conta_votos, 500) sondando o
var votos_sim = 0; // DOM pra sempre) agora é reatividade normal do Vue, dentro de
var votos_nao = 0; // VotacaoNominalBloco.vue — nada aqui recalcula mais nada.
var votos_abstencao = 0;
var nao_votou = 0;
$('[name=voto_parlamentar]').each(function() {
if (($(this).is(':hidden')) == false) {
switch ($(this).val().substring(0,4)) {
case "Sim:":
votos_sim = votos_sim + 1;
break;
case "Não:":
votos_nao = votos_nao + 1;
break;
case "Abst":
votos_abstencao = votos_abstencao + 1;
break;
case "Não ":
nao_votou = nao_votou + 1;
break;
};
};
});
$("#votos_sim").attr('value', votos_sim);
$("#votos_nao").attr('value', votos_nao);
$("#abstencoes").attr('value', votos_abstencao);
$("#nao_votou").attr('value', nao_votou);
$("#soma_votos").empty();
$("#soma_votos").append("<div class='row'><div class='col-md-12'>Sim: " + votos_sim + "</div></div>");
$("#soma_votos").append("<div class='row'><div class='col-md-12'>Não: " + votos_nao + "</div></div>");
$("#soma_votos").append("<div class='row'><div class='col-md-12'>Abstenções: " + votos_abstencao + "</div></div>");
$("#soma_votos").append("<div class='row'><div class='col-md-12'>Ainda não votaram: " + nao_votou + "</div></div>");
var t = setTimeout(function(){
conta_votos()
}, 500);
}
conta_votos();
window.onload = conta_votos();
$(window).on('beforeunload', function () { $(window).on('beforeunload', function () {
$("input[type=submit], input[type=button]").prop("disabled", "disabled"); $("input[type=submit], input[type=button]").prop("disabled", "disabled");
}); });
</script> </script>
{% endblock extra_js%} {% endblock extra_js%}
{% block webpack_loader_js %}
{% render_chunk_vendors 'js' %}
{% render_bundle 'global' 'js' %}
{% render_bundle 'votacao-nominal-bloco' 'js' %}
{% endblock %}

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

@ -12,6 +12,7 @@
data-sessao-id="{{ pk }}" data-sessao-id="{{ pk }}"
data-oid="{{ oid }}" data-oid="{{ oid }}"
data-mid="{{ mid }}" data-mid="{{ mid }}"
data-fase="{{ fase }}"
data-total-presentes="{{ total_presentes }}" data-total-presentes="{{ total_presentes }}"
data-total-votantes="{{ total_votantes }}"> data-total-votantes="{{ total_votantes }}">
<ws-status-banner :status="wsStatus" :message="wsErrorMessage"></ws-status-banner> <ws-status-banner :status="wsStatus" :message="wsErrorMessage"></ws-status-banner>

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

@ -14,9 +14,12 @@
:is-open="isOpen" :is-open="isOpen"
:error-message="error_message" :error-message="error_message"
:votos-status="votosStatus" :votos-status="votosStatus"
:votos-travados="votosTravados"
:registro-aberto="registro_aberto"
@cast-vote="castVote" @cast-vote="castVote"
@cancelar="onCancelar" @cancelar="onCancelar"
@fechar="onFechar" @fechar="onFechar"
@toggle-registro="toggleRegistro"
ref="votacaoNominal"> ref="votacaoNominal">
</votacao-nominal> </votacao-nominal>
</div> </div>

5
vue.config.js

@ -167,6 +167,11 @@ module.exports = {
.add('./frontend/src/__apps/leitura-materia/main.js') .add('./frontend/src/__apps/leitura-materia/main.js')
.end() .end()
config
.entry('votacao-nominal-bloco')
.add('./frontend/src/__apps/votacao-nominal-bloco/main.js')
.end()
config config
.entry('compilacao') .entry('compilacao')
.add('./frontend/src/__apps/compilacao/main.js') .add('./frontend/src/__apps/compilacao/main.js')

Loading…
Cancel
Save