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

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_ordem = next.cronometro_ordem
this.cronometro_consideracoes = next.cronometro_consideracoes
this.registro_aberto = next.registro_aberto
// arrays: trocar referência (voto já vem aplicado por parlamentar
// dentro de next.parlamentares — get_votos() no backend já inlina o

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

@ -28,6 +28,10 @@ export const DEFAULT_STATE = Object.freeze({
cronometro_ordem: '',
cronometro_consideracoes: '',
// Só significa algo enquanto há matéria nominal aberta pra registro —
// ver PainelConsumer (type: "registro_toggle") / VotacaoNominal.vue.
registro_aberto: false,
parlamentares: [],
oradores: [],
@ -65,7 +69,11 @@ export function normalizePainelData (raw) {
nome_parlamentar: asString(p.nome, ''),
filiacao: asString(p.partido, ''),
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)
@ -100,6 +108,8 @@ export function normalizePainelData (raw) {
cronometro_ordem: asString(d.cronometro_ordem, DEFAULT_STATE.cronometro_ordem),
cronometro_consideracoes: asString(d.cronometro_consideracoes, DEFAULT_STATE.cronometro_consideracoes),
registro_aberto: d.registro_aberto != null ? !!d.registro_aberto : DEFAULT_STATE.registro_aberto,
parlamentares,
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,
oid: null,
mid: null,
fase: 'ordem',
totalPresentes: 0,
totalVotantes: 0,
ws: null,
@ -43,6 +44,7 @@ new Vue({ // eslint-disable-line
this.sessaoId = el.dataset.sessaoId
this.oid = el.dataset.oid
this.mid = el.dataset.mid
this.fase = el.dataset.fase || 'ordem'
this.totalPresentes = el.dataset.totalPresentes
this.totalVotantes = el.dataset.totalVotantes
@ -64,9 +66,12 @@ new Vue({ // eslint-disable-line
return usePainelStore()
},
actionUrl () {
// Mesma URL da tela legada (VotacaoView) — sessao/urls.py:
// sessao/<pk>/materia/ordemdia/votacao/simbolica/<oid>/<mid>
return `/sessao/${this.sessaoId}/materia/ordemdia/votacao/simbolica/${this.oid}/${this.mid}`
// Mesma URL da tela legada (VotacaoView/VotacaoExpedienteView) —
// sessao/urls.py: continua sendo quem grava o voto (só o shell/GET
// mudou pra v2); fase decide ordem do dia ou expediente, mesmo
// discriminador usado pelo shell (votacao_simbolica_v2_view).
const etapa = this.fase === 'expediente' ? 'expediente' : 'ordemdia'
return `/sessao/${this.sessaoId}/materia/${etapa}/votacao/simbolica/${this.oid}/${this.mid}`
},
wsURL () {
const proto = location.protocol === 'https:' ? 'wss' : 'ws'

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

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

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 }}
</div>
<div class="alert" :class="registroAberto ? 'alert-warning' : 'alert-info'">
<strong>Status da votação:</strong> em aberto ·
<span v-if="registroAberto">novos votos <strong>bloqueados</strong> — os vereadores que ainda não votaram não conseguem mais votar até a Mesa reabrir</span>
<span v-else>novos votos <strong>permitidos</strong> — os vereadores ainda podem votar pelo tablet enquanto a Mesa registra</span>
</div>
<button type="button" class="btn btn-sm mb-3"
:class="registroAberto ? 'btn-secondary' : 'btn-info'"
@click="onToggleRegistro">
{{ registroAberto ? 'Reabrir Votação para Novos Votos' : 'Bloquear Novos Votos' }}
</button>
<votacao-materia></votacao-materia>
<br />
<votacao-votos :votos-status="votosStatus" @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>
@ -41,6 +53,14 @@ export default {
votosStatus: {
type: Object,
default: () => ({})
},
votosTravados: {
type: Object,
default: () => ({})
},
registroAberto: {
type: Boolean,
default: false
}
},
data() {
@ -69,6 +89,9 @@ export default {
resultado_selected: this.resultado_selected,
observacoes: this.observacoes
});
},
onToggleRegistro() {
this.$emit('toggle-registro', !this.registroAberto);
}
},
mounted() {

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

@ -10,12 +10,14 @@
<select class="form-control"
:name="'voto_parlamentar_' + p.parlamentar_id"
:value="votosStatus[p.parlamentar_id] || 'Não Votou'"
:disabled="!!votosTravados[p.parlamentar_id]"
@change="onVoteChange(p.parlamentar_id, $event.target.value)">
<option value="Não Votou">Não Votou</option>
<option value="Sim">Sim</option>
<option value="Não">Não</option>
<option value="Abstenção">Abstenção</option>
</select>
<span v-if="votosTravados[p.parlamentar_id]" class="badge badge-info">Já votado (tablet)</span>
</div>
</template>
</div>
@ -36,6 +38,15 @@ export default {
votosStatus: {
type: Object,
default: () => ({})
},
// Quais parlamentar_id devem ficar travados pro operador — só quando
// votado_pelo_parlamentar é true no payload (voto pelo próprio
// tablet). Distinto de votosStatus: um voto lançado pelo operador
// neste mesmo <select> também aparece em votosStatus (tem valor), mas
// não deve se autotravar.
votosTravados: {
type: Object,
default: () => ({})
}
},
computed: {

7
sapl/asgi.py

@ -8,13 +8,16 @@ django.setup()
from channels.auth import AuthMiddlewareStack # noqa: E402
from channels.http import AsgiHandler # noqa: E402
from channels.routing import ProtocolTypeRouter, URLRouter # noqa: E402
from channels.security.websocket import AllowedHostsOriginValidator # noqa: E402
from django.urls import path # noqa: E402
from sapl.painel.consumers import PainelConsumer # noqa: E402
application = ProtocolTypeRouter({
"http": AsgiHandler(),
"websocket": AuthMiddlewareStack(URLRouter([
"websocket": AllowedHostsOriginValidator(
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
voto individual (tablet) e tela de registro do operador conectam todos
aqui e recebem o mesmo payload completo (broadcast de estado inteiro,
não deltas — ver Parte 3 do plano de migração polling->push).
aqui. O snapshot é sempre completo (não deltas — ver Parte 3 do plano
de migração polling->push), mas não é mais idêntico para todo mundo:
cada conexão pede o seu próprio a cada aviso de mudança (ver
painel_refresh()), com o voto mascarado ou não conforme o papel de
quem está conectado (self.is_operator) — o grupo em si nunca carrega
o voto real, só um sinal de "algo mudou".
"""
async def connect(self):
@ -48,6 +52,11 @@ class PainelConsumer(AsyncJsonWebsocketConsumer):
await self.close(code=4404)
return
# Papéis calculados uma vez, na conexão — reaproveitados em todo
# refresh subsequente sem reconsultar as permissões do usuário.
self.is_operator = await self._is_operator(user)
self.can_cast_vote = await self._can_cast_vote(user)
self.group_name = 'sessao_{}'.format(self.sessao_id)
await self.channel_layer.group_add(self.group_name, self.channel_name)
await self.accept()
@ -60,12 +69,95 @@ class PainelConsumer(AsyncJsonWebsocketConsumer):
await self.channel_layer.group_discard(self.group_name, self.channel_name)
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'})
elif msg_type == 'vote':
await self._handle_vote(content)
elif msg_type == 'registro_toggle':
await self._handle_registro_toggle(content)
async def data(self, event):
# Ainda aceito por compatibilidade (não é mais como
# broadcast_dados_painel() manda algo pro grupo, mas nada impede
# outro caller de enviar um group_send neste formato).
await self.send_json({'type': 'data', 'payload': event['payload']})
async def painel_refresh(self, event):
"""
Handler do sinal leve que broadcast_dados_painel() manda pro grupo
(`{"type": "painel.refresh"}` — Channels troca o "." por "_" no
nome do método). Cada conexão reconsulta o snapshot por conta
própria e manda só pra si mesma: é aqui, não no group_send, que o
voto real fica restrito a quem tem self.is_operator.
"""
payload = await self._build_dados_painel(self.sessao_id)
await self.send_json({'type': 'data', 'payload': payload})
async def _handle_vote(self, content):
if not getattr(self, 'can_cast_vote', False):
await self.send_json({
'type': 'vote_error',
'message': 'Seu usuário não tem permissão para registrar votos.'})
return
parlamentar_id = content.get('parlamentar_id')
voto = content.get('voto')
try:
result = await self._cast_vote(parlamentar_id, voto)
except Exception as e:
await self.send_json({'type': 'vote_error', 'message': str(e)})
return
await self.send_json({'type': 'vote_ack', **result})
# Sinal pro grupo inteiro (inclusive esta conexão) reconsultar —
# não manda o payload aqui: quem recebe decide seu próprio
# mostrar_voto em painel_refresh().
await self.channel_layer.group_send(self.group_name, {'type': 'painel.refresh'})
async def _handle_registro_toggle(self, content):
"""
Equivalente ao bloquear-registro-votacao/reabrir-votacao de
nominal.html — mesmo gate de permissão do voto (can_cast_vote),
já que é a mesma Mesa/operador que faz as duas coisas.
"""
if not getattr(self, 'can_cast_vote', False):
await self.send_json({
'type': 'registro_toggle_error',
'message': 'Seu usuário não tem permissão para esta ação.'})
return
aberto = bool(content.get('aberto'))
try:
result = await self._toggle_registro(aberto)
except Exception as e:
await self.send_json({'type': 'registro_toggle_error', 'message': str(e)})
return
await self.send_json({'type': 'registro_toggle_ack', **result})
await self.channel_layer.group_send(self.group_name, {'type': 'painel.refresh'})
@database_sync_to_async
def _toggle_registro(self, aberto):
# import local — mesmo motivo do import em _build_dados_painel.
from sapl.painel.views import VoteError, _toggle_registro
try:
return _toggle_registro(self.scope['user'], self.sessao_id, aberto)
except VoteError as e:
raise Exception(e.message) from None
@database_sync_to_async
def _cast_vote(self, parlamentar_id, voto):
# import local — mesmo motivo do import em _build_dados_painel.
from sapl.painel.views import VoteError, _cast_vote
client = self.scope.get('client') or (None, None)
try:
return _cast_vote(
self.scope['user'], self.sessao_id, parlamentar_id, voto,
ip=client[0])
except VoteError as e:
raise Exception(e.message) from None
@database_sync_to_async
def _build_dados_painel(self, sessao_id):
# import local para evitar ciclo de import no carregamento do ASGI
@ -74,7 +166,9 @@ class PainelConsumer(AsyncJsonWebsocketConsumer):
# pronto, o que já é garantido aqui por database_sync_to_async
# rodar depois de django.setup() em sapl/asgi.py).
from sapl.painel.views import build_dados_painel
return build_dados_painel(_ScopeRequest(self.scope), sessao_id)
return build_dados_painel(
_ScopeRequest(self.scope), sessao_id,
force_mostrar_voto=getattr(self, 'is_operator', False))
@database_sync_to_async
def _user_pode_conectar(self, user):
@ -96,6 +190,26 @@ class PainelConsumer(AsyncJsonWebsocketConsumer):
return True
return False
@database_sync_to_async
def _is_operator(self, user):
"""
Papel "operador/Mesa" pra fins de mascaramento de voto (ver
build_dados_painel(force_mostrar_voto=...)) — mais amplo que
can_cast_vote de propósito: cobre tanto quem registra o voto
nominal quanto quem só opera o painel (abre/fecha, cronômetro),
que também não deveria ficar sujeito à máscara pensada pro
público.
"""
return user.has_module_perms(PainelAppConfig.label) or \
user.has_module_perms('sessao')
@database_sync_to_async
def _can_cast_vote(self, user):
# Mesmo gate de vote_controller (check_permission em
# sapl/painel/views.py) — import local, mesmo motivo de sempre.
from sapl.painel.views import check_permission
return check_permission(user)
@database_sync_to_async
def _sessao_existe(self, sessao_id):
return SessaoPlenaria.objects.filter(id=sessao_id).exists()

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.painel.consumers import PainelConsumer
from sapl.painel.tests.tests import (NOMINAL, _materia, _ordem_nominal_aberta,
_presente_com_mandato, _registrar_url,
_presente_com_mandato,
_votante_com_client)
from sapl.parlamentares.models import Mandato, Parlamentar
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.asyncio
async def test_encerrar_votacao_dispara_broadcast():
"""
Registro individual de votação nominal (o "salvar-votacao" em lote da
tela legada nominal.html não existe mais — ver plano de unificação
Expediente/OrdemDia) agora é: um voto por chamada a vote_controller
(o mesmo caminho HTTP que PainelConsumer usa por baixo pro type:
"vote" — vote_controller continua existindo por compatibilidade),
seguido de close_voting pra fechar e apurar o resultado.
"""
sessao, ordem = await _setup_ordem_aberta()
try:
admin_client, admin_user = await _admin_client_login()
@ -127,12 +135,16 @@ async def test_encerrar_votacao_dispara_broadcast():
communicator = await _painel_communicator(sessao.pk, admin_user)
await sync_to_async(admin_client.post)(_registrar_url(sessao, ordem), {
'salvar-votacao': '1',
'resultado_votacao': str(tipo_resultado.pk),
'observacao': '',
'voto_parlamentar': ['Sim:{}'.format(parlamentar.pk)],
})
await sync_to_async(admin_client.post)(
reverse('sapl.painel:vote_controller', kwargs={'controller_id': sessao.pk}),
{'parlamentar_id': parlamentar.pk, 'voto': 'Sim'})
# Descarta o refresh disparado pelo voto — este teste verifica o
# broadcast do close_voting especificamente.
await communicator.receive_json_from()
await sync_to_async(admin_client.post)(
reverse('sapl.painel:close_voting', kwargs={'controller_id': sessao.pk}),
{'resultado_id': tipo_resultado.pk, 'observacoes': ''})
mensagem = await communicator.receive_json_from()
assert mensagem['type'] == 'data'

5
sapl/painel/tests/tests.py

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

250
sapl/painel/views.py

@ -22,7 +22,8 @@ from sapl.base.models import CasaLegislativa
from sapl.crud.base import Crud
from sapl.painel.apps import AppConfig
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,
SessaoMateriasVotacoesView, SessaoPlenaria,
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
(tela legada) e votante_view_v2 (Vue), cada uma redirecionando de
volta para a própria URL depois (por isso o redirect não mora aqui).
(tela legada), votante_view_v2 (Vue, form HTTP) e
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__)
username = request.user.username
@ -268,6 +273,11 @@ def _save_voto_individual(request, context_vars):
if fase_sessao is None:
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
# na mesma linha (ex.: o operador registrando este mesmo parlamentar
# 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."
.format(context_vars['parlamentar']))
voto.voto = request.POST['voto']
voto.ip = get_client_ip(request)
voto.voto = voto_valor
voto.ip = ip if ip is not None else get_client_ip(request)
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()
broadcast_dados_painel(request, context_vars['sessao'].id)
return voto_valor
@never_cache
@login_required
@ -371,7 +385,12 @@ def get_presentes(pk, response, materia):
presencas = SessaoPresencasView.objects.filter(
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')
oradores_list = []
@ -489,6 +508,14 @@ def get_votos(response, materia, mostrar_voto):
response['presentes'][i]['voto'] = voto
else:
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:
response.update({
'numero_votos_sim': 0,
@ -506,6 +533,8 @@ def get_votos(response, materia, mostrar_voto):
for i, p in enumerate(response['presentes']):
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_por_tablet'] = bool(
voto_entry and voto_entry.get('votado_pelo_parlamentar'))
response.update({
'numero_votos_sim': numero_votos['votos_sim'],
@ -519,12 +548,24 @@ def get_votos(response, materia, mostrar_voto):
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
(broadcast_dados_painel() abaixo) — era também servido por um endpoint
HTTP de polling (get_dados_painel), removido quando o polling foi
eliminado (nenhuma tela restante lê build_dados_painel() por HTTP).
`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)
@ -532,6 +573,8 @@ def build_dados_painel(request, pk):
app_config = ConfiguracoesAplicacao.objects.first()
efetivo_mostrar_voto = bool(app_config.mostrar_voto) or force_mostrar_voto
brasao = None
if casa and app_config and (bool(casa.logotipo)):
brasao = casa.logotipo.url \
@ -555,22 +598,28 @@ def build_dados_painel(request, pk):
'cronometro_consideracoes': get_cronometro_status(request, 'consideracoes'),
'status_painel': sessao.painel_aberto,
'brasao': brasao,
'mostrar_voto': app_config.mostrar_voto
'mostrar_voto': efetivo_mostrar_voto
}
ordem_dia = get_materia_aberta(pk)
expediente = get_materia_expediente_aberta(pk)
# 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:
response['registro_aberto'] = ordem_dia.registro_aberto
return get_votos(
get_presentes(pk, response, ordem_dia),
ordem_dia, app_config.mostrar_voto)
ordem_dia, efetivo_mostrar_voto)
elif expediente:
response['registro_aberto'] = expediente.registro_aberto
return get_votos(
get_presentes(pk, response, expediente),
expediente, app_config.mostrar_voto)
expediente, efetivo_mostrar_voto)
# Caso não tenha nenhuma aberta,
# 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:
return get_votos(
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
response.update({
@ -616,19 +665,27 @@ def build_dados_painel(request, pk):
def broadcast_dados_painel(request, sessao_id):
"""
Envia o estado atual da sessão para o grupo WebSocket correspondente
(sapl/painel/consumers.py::PainelConsumer) — chamado pelas views que
alteram voto/matéria/registro, sempre depois da escrita já ter sido
commitada no banco. `request` é reaproveitado só para
get_cronometro_status() dentro de build_dados_painel() (ver o item do
cronômetro no plano — ainda lê de request.session, não de storage
compartilhado; isso é resolvido por último, não aqui).
Avisa o grupo WebSocket correspondente (sapl/painel/consumers.py::
PainelConsumer) de que algo mudou — chamado pelas views que alteram
voto/matéria/registro, sempre depois da escrita já ter sido commitada
no banco. `request` não é mais usado aqui (mantido só para não obrigar
a mudar todo call site); build_dados_painel() é chamado depois, uma vez
por conexão, 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
o broadcast (registrar voto, abrir/fechar matéria) — é exatamente por
isso que o polling continua existindo como fallback (ver plano). Falha
aqui só fica no log; quem está na tela sem WebSocket simplesmente
continua vendo a atualização pelo poll de sempre.
o broadcast (registrar voto, abrir/fechar matéria). Falha aqui só fica
no log — não há mais fallback de polling (removido nesta branch); quem
está sem WebSocket fica sem atualização em tempo real até reconectar
ou recarregar a tela.
"""
from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer
@ -639,7 +696,7 @@ def broadcast_dados_painel(request, sessao_id):
return
async_to_sync(layer.group_send)(
'sessao_{}'.format(sessao_id),
{'type': 'data', 'payload': build_dados_painel(request, sessao_id)})
{'type': 'painel.refresh'})
except Exception:
logging.getLogger(__name__).exception(
'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"]
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)
def vote_controller(request, controller_id):
"""
@ -686,8 +809,11 @@ def vote_controller(request, controller_id):
Body (form-encoded or JSON):
parlamentar_id: int
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':
return JsonResponse({"type": "error", "message": "Only POST allowed"}, status=405)
# Parse body: support both form-encoded and JSON
@ -701,52 +827,44 @@ def vote_controller(request, controller_id):
else:
parlamentar_id = request.POST.get("parlamentar_id")
voto = request.POST.get("voto")
# Validate
if not parlamentar_id:
return JsonResponse({"type": "error", "message": "parlamentar_id is required"}, status=400)
try:
parlamentar_id = int(parlamentar_id)
except (ValueError, TypeError):
return JsonResponse({"type": "error", "message": "parlamentar_id must be an integer"}, status=400)
if voto not in VALID_VOTE_VALUES:
return JsonResponse({
"type": "error",
"message": f"Invalid vote value: {voto}. Must be one of: {VALID_VOTE_VALUES}"
}, status=400)
# Verify session exists
try:
SessaoPlenaria.objects.get(id=controller_id)
except SessaoPlenaria.DoesNotExist:
return JsonResponse({"type": "error", "message": "Session not found"}, status=404)
# Find open materia (OrdemDia or ExpedienteMateria)
result = _cast_vote(
request.user, controller_id, parlamentar_id, voto,
ip=get_client_ip(request))
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)
expediente = get_materia_expediente_aberta(controller_id)
materia_aberta = ordem_dia or expediente
if not materia_aberta:
return JsonResponse({"type": "error", "message": "No open materia for voting"}, status=400)
raise VoteError("No open materia for voting")
if materia_aberta.tipo_votacao != VOTACAO_NOMINAL:
return JsonResponse({"type": "error", "message": "Materia is not nominal voting type"}, status=400)
# Verify parlamentar exists
try:
parlamentar = Parlamentar.objects.get(id=parlamentar_id)
except Parlamentar.DoesNotExist:
return JsonResponse({"type": "error", "message": f"Parlamentar {parlamentar_id} not found"}, status=404)
# Save/update vote
if ordem_dia:
voto_obj, created = VotoParlamentar.objects.update_or_create(
parlamentar=parlamentar,
ordem=ordem_dia,
defaults={'voto': voto, 'user': request.user, 'ip': get_client_ip(request)}
)
else:
voto_obj, created = VotoParlamentar.objects.update_or_create(
parlamentar=parlamentar,
expediente=expediente,
defaults={'voto': voto, 'user': request.user, 'ip': get_client_ip(request)}
)
logger.info(f"Vote {'created' if created else 'updated'}: parlamentar={parlamentar_id}, voto={voto}, sessao={controller_id}")
broadcast_dados_painel(request, controller_id)
return JsonResponse({"ok": True, "parlamentar_id": parlamentar_id, "voto": voto, "created": created})
raise VoteError("Materia is not nominal voting type")
materia_aberta.registro_aberto = aberto
materia_aberta.save()
logging.getLogger(__name__).info(
f"registro_aberto={aberto} para materia id={materia_aberta.pk} "
f"(sessao={controller_id}, user={user.username})")
return {"registro_aberto": aberto}
@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):
@ -24,6 +24,16 @@ class Migration(migrations.Migration):
]
operations = [
migrations.AddField(
model_name='votoparlamentar',
name='votado_pelo_parlamentar',
field=models.BooleanField(
default=False,
help_text='Marca se este voto foi lançado pelo próprio parlamentar '
'(voto individual/tablet) — distinto de um registro feito '
'pela Mesa/operador em nome dele.',
verbose_name='Votado pelo parlamentar'),
),
migrations.RunSQL(
sql="""
DROP VIEW IF EXISTS sessao_presencas_view;
@ -152,7 +162,8 @@ class Migration(migrations.Migration):
'materia_id', em.materia_id,
'parlamentar_id', vp.parlamentar_id,
'parlamentar_nome', p.nome_parlamentar,
'voto', vp.voto
'voto', vp.voto,
'votado_pelo_parlamentar', vp.votado_pelo_parlamentar
)) as votos_parlamentares
FROM sessao_votoparlamentar vp
JOIN parlamentares_parlamentar p ON (vp.parlamentar_id = p.id)
@ -208,7 +219,8 @@ class Migration(migrations.Migration):
'materia_id', od.materia_id,
'parlamentar_id', vp.parlamentar_id,
'parlamentar_nome', p.nome_parlamentar,
'voto', vp.voto
'voto', vp.voto,
'votado_pelo_parlamentar', vp.votado_pelo_parlamentar
)) as votos_parlamentares
FROM sessao_votoparlamentar vp
JOIN parlamentares_parlamentar p ON (vp.parlamentar_id = p.id)

6
sapl/sessao/models.py

@ -725,6 +725,12 @@ class VotoParlamentar(models.Model): # RegistroVotacaoParlamentar
max_length=60,
blank=True,
default='')
votado_pelo_parlamentar = models.BooleanField(
verbose_name=_('Votado pelo parlamentar'),
default=False,
help_text=_('Marca se este voto foi lançado pelo próprio parlamentar '
'(voto individual/tablet) — distinto de um registro feito '
'pela Mesa/operador em nome dele.'))
data_hora = models.DateTimeField(
verbose_name=_('Data/Hora'),
auto_now=True,

235
sapl/sessao/tests/test_votacao_nominal.py

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

16
sapl/sessao/urls.py

@ -19,10 +19,9 @@ from sapl.sessao.views import (AdicionarVariasMateriasExpediente,
VotacaoExpedienteView, VotacaoNominalEditView,
VotacaoNominalExpedienteDetailView,
VotacaoNominalExpedienteEditView,
VotacaoNominalExpedienteView,
VotacaoNominalTransparenciaDetailView,
VotacaoSimbolicaTransparenciaDetailView,
VotacaoNominalView, VotacaoView, abrir_votacao,
VotacaoView, abrir_votacao,
atualizar_mesa, insere_parlamentar_composicao,
mudar_ordem_materia_sessao, recuperar_materia,
recuperar_numero_sessao_view,
@ -186,17 +185,24 @@ urlpatterns = [
url(r'^sessao/(?P<pk>\d+)/materia/ordemdia/votacao/simbolica/v2/(?P<oid>\d+)/(?P<mid>\d+)$',
votacao_simbolica_v2_view,
name='votacaosimbolica_v2'),
url(r'^sessao/(?P<pk>\d+)/materia/expediente/votacao/simbolica/v2/(?P<oid>\d+)/(?P<mid>\d+)$',
lambda request, **kw: votacao_simbolica_v2_view(request, fase='expediente', **kw),
name='votacaosimbolicaexp_v2'),
url(r'^sessao/(?P<pk>\d+)/materia/ordemdia/leitura/v2/(?P<oid>\d+)/(?P<mid>\d+)$',
leitura_v2_view,
name='leituraod_v2'),
url(r'^sessao/(?P<pk>\d+)/materia/ordemdia/leitura/v2/(?P<oid>\d+)/(?P<mid>\d+)/salvar$',
leitura_v2_action,
name='leituraod_v2_action'),
url(r'^sessao/(?P<pk>\d+)/materia/expediente/leitura/v2/(?P<oid>\d+)/(?P<mid>\d+)$',
lambda request, **kw: leitura_v2_view(request, fase='expediente', **kw),
name='leituraexp_v2'),
url(r'^sessao/(?P<pk>\d+)/materia/expediente/leitura/v2/(?P<oid>\d+)/(?P<mid>\d+)/salvar$',
lambda request, **kw: leitura_v2_action(request, fase='expediente', **kw),
name='leituraexp_v2_action'),
url(r'^sessao/(?P<pk>\d+)/v2/painel$',
TemplateView.as_view(template_name='sessao/painel_v2.html')),
url(r'^sessao/(?P<pk>\d+)/materia/ordemdia/votacao/nominal/(?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+)$',
VotacaoNominalEditView.as_view(), name='votacaonominaledit'),
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+)$',
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+)$',
VotacaoNominalExpedienteEditView.as_view(),
name='votacaonominalexpedit'),

587
sapl/sessao/views.py

@ -250,31 +250,35 @@ def abrir_votacao(request, pk, spk):
registro_view_names_ordem = {
# Simbólica e Leitura (Ordem do Dia) vão para as telas de
# registro v2 (Vue) — mesmo raciocínio do NOMINAL abaixo.
# Secreta fica na legada: não tem tela v2 (VotacaoView/
# votacao.html atende Simbólica e Secreta hoje, mas só
# Simbólica ganhou uma tela v2 dedicada). NOMINAL aqui não é
# usado — tratado à parte logo abaixo (única URL v2 sem
# oid/mid).
# Secreta fica na legada: não tem tela v2, por decisão de
# design (VotacaoView/votacao.html atende Simbólica e Secreta
# hoje; só Simbólica ganhou uma tela v2 dedicada, Secreta
# nunca vai ganhar). NOMINAL aqui não é usado — tratado à
# parte logo abaixo (única URL v2 sem oid/mid, unificada para
# Ordem e Expediente).
TipoVotacao.SIMBOLICA: 'votacaosimbolica_v2', TipoVotacao.NOMINAL: 'votacaonominal',
TipoVotacao.SECRETA: 'votacaosecreta', TipoVotacao.LEITURA: 'leituraod_v2',
}
registro_view_names_expediente = {
TipoVotacao.SIMBOLICA: 'votacaosimbolicaexp', TipoVotacao.NOMINAL: 'votacaonominalexp',
TipoVotacao.SECRETA: 'votacaosecretaexp', TipoVotacao.LEITURA: 'leituraexp',
# Simbólica e Leitura de Expediente também vão para telas v2
# (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
else registro_view_names_ordem)
if not is_expediente and materia_votacao.tipo_votacao == TipoVotacao.NOMINAL:
# Votação Nominal de Ordem do Dia vai para a tela de registro
# v2 (Vue) em vez da legada — é a única com submissão por
# parlamentar (VotacaoVotos.vue -> vote_controller, salva e
# transmite a cada troca de voto, sem esperar o "Encerrar
# Votação" em lote). Sem equivalente v2 ainda para
# Nominal/Expediente nem para os outros tipos de votação, que
# continuam na tela legada abaixo. votacaonominal_v2 não tem
if materia_votacao.tipo_votacao == TipoVotacao.NOMINAL:
# Votação Nominal (Ordem do Dia OU Expediente) vai para a tela
# de registro v2 (Vue) em vez da legada — é a única com
# submissão por parlamentar (VotacaoVotos.vue -> WS "vote",
# salva e transmite a cada troca de voto, sem esperar o
# "Encerrar Votação" em lote). votacaonominal_v2 não tem
# 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})
else:
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 = ''
if is_expediente:
if obj.tipo_votacao == TipoVotacao.SIMBOLICA:
url = reverse('sapl.sessao:votacaosimbolicaexp',
url = reverse('sapl.sessao:votacaosimbolicaexp_v2',
kwargs={
'pk': obj.sessao_plenaria_id,
'oid': obj.pk,
'mid': obj.materia_id})
elif obj.tipo_votacao == TipoVotacao.NOMINAL:
url = reverse('sapl.sessao:votacaonominalexp',
kwargs={
'pk': obj.sessao_plenaria_id,
'oid': obj.pk,
'mid': obj.materia_id})
# Nominal (Ordem do Dia e Expediente) unificado na
# mesma tela v2 sem oid/mid — mesmo raciocínio do
# branch "else" (Ordem do Dia) logo abaixo.
url = reverse('sapl.sessao:votacaonominal_v2',
kwargs={'pk': obj.sessao_plenaria_id})
elif obj.tipo_votacao == TipoVotacao.SECRETA:
url = reverse('sapl.sessao:votacaosecretaexp',
kwargs={
@ -383,7 +387,7 @@ def customize_link_materia(context, pk, has_permission, is_expediente, request=N
'oid': obj.pk,
'mid': obj.materia_id})
elif obj.tipo_votacao == TipoVotacao.LEITURA:
url = reverse('sapl.sessao:leituraexp',
url = reverse('sapl.sessao:leituraexp_v2',
kwargs={
'pk': obj.sessao_plenaria_id,
'oid': obj.pk,
@ -425,20 +429,15 @@ def customize_link_materia(context, pk, has_permission, is_expediente, request=N
if has_permission:
if obj.tipo_votacao != TipoVotacao.LEITURA:
# Votação Nominal de Expediente é a única que ainda
# aponta para VotacaoNominalAbstract (sem tela v2) —
# é a única cuja tela de registro sabe tratar POST
# sem efeito colateral, por isso só ela é convertida
# para POST aqui; Simbólica/Secreta continuam GET, e
# Nominal de Ordem do Dia também (aponta para
# votacaonominal_v2, que só faz GET).
# Nominal (Ordem do Dia e Expediente, ambos agora)
# aponta para votacaonominal_v2, que só faz GET —
# mesmo tratamento de Simbólica/Secreta. O POST
# especial que existia aqui só para Nominal de
# Expediente era necessário quando ela ainda
# apontava para VotacaoNominalAbstract/nominal.html
# (retirado desta unificação — ver plano).
metodo = ''
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 = '''
<form action="%s"%s>
%s
@ -2853,18 +2852,22 @@ def votacao_nominal_v2_view(request, pk):
@never_cache
@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
votacao_nominal_v2_view acima. total_presentes/total_votantes seguem
o mesmo cálculo de VotacaoView.get() (contagem de presentes
ativos/inativos) — não vêm de build_dados_painel(), cujo
num_presentes reflete a etapa de ordem-do-dia como um todo, não
necessariamente a mesma contagem "com/sem presidente" que esta tela
usa.
votacao_nominal_v2_view acima. Uma só view/template pra Ordem do Dia e
Expediente (mesma unificação do Nominal); `fase` só afeta qual tabela
de presença conta ('ordem' -> PresencaOrdemDia, 'expediente' ->
SessaoPlenariaPresenca — mesmo par usado em sessao_votacao() e
get_presentes()). total_presentes/total_votantes seguem o mesmo
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 = [
presente.parlamentar_id for presente in PresencaOrdemDia.objects.filter(
presente.parlamentar_id for presente in presenca_model.objects.filter(
sessao_plenaria_id=pk)
]
total_presentes = len(presentes_id)
@ -2875,6 +2878,7 @@ def votacao_simbolica_v2_view(request, pk, oid, mid):
'pk': pk,
'oid': oid,
'mid': mid,
'fase': fase,
'total_presentes': total_presentes,
'total_votantes': total_votantes,
'tipos_resultado': list(TipoResultadoVotacao.objects.all().values('id', 'nome')),
@ -3082,370 +3086,6 @@ def fechar_votacao_materia(materia):
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):
template_name = 'sessao/votacao/nominal_edit.html'
@ -3580,16 +3220,6 @@ class VotacaoNominalEditAbstract(SessaoPermissionMixin):
) + page + "#id{}".format(self.kwargs['mid'])
class VotacaoNominalView(VotacaoNominalAbstract):
ordem = True
expediente = False
class VotacaoNominalExpedienteView(VotacaoNominalAbstract):
expediente = True
ordem = False
class VotacaoNominalEditView(VotacaoNominalEditAbstract):
ordem = True
expediente = False
@ -3775,7 +3405,23 @@ class VotacaoExpedienteView(SessaoPermissionMixin):
def post(self, request, *args, **kwargs):
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)
url = request.get_full_path()
@ -3811,56 +3457,73 @@ class VotacaoExpedienteView(SessaoPermissionMixin):
context.update({'form': form})
# ====================================================
if 'cancelar-votacao' in request.POST:
if 'cancelar-votacao' in post_data:
expediente.votacao_aberta = False
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)
if form.is_valid():
materia_id = kwargs['mid']
expediente_id = kwargs['oid']
qtde_votos = (int(request.POST['votos_sim']) +
int(request.POST['votos_nao']) +
int(request.POST['abstencoes']))
qtde_votos = (int(post_data['votos_sim']) +
int(post_data['votos_nao']) +
int(post_data['abstencoes']))
if (int(request.POST['voto_presidente']) == 0):
if (int(post_data['voto_presidente']) == 0):
qtde_ativos -= 1
if qtde_votos != qtde_ativos:
msg = _(
'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)
return self.render_to_response(context)
else:
try:
votacao = RegistroVotacao()
votacao.numero_votos_sim = int(request.POST['votos_sim'])
votacao.numero_votos_nao = int(request.POST['votos_nao'])
votacao.numero_abstencoes = int(request.POST['abstencoes'])
votacao.observacao = request.POST['observacao']
votacao.numero_votos_sim = int(post_data['votos_sim'])
votacao.numero_votos_nao = int(post_data['votos_nao'])
votacao.numero_abstencoes = int(post_data['abstencoes'])
votacao.observacao = post_data['observacao']
votacao.materia_id = materia_id
votacao.expediente_id = expediente_id
votacao.tipo_resultado_votacao_id = int(
request.POST['resultado_votacao'])
post_data['resultado_votacao'])
votacao.user = request.user
votacao.ip = get_client_ip(request)
votacao.save()
except Exception as e:
username = request.user.username
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)
else:
expediente = ExpedienteMateria.objects.get(
id=expediente_id)
resultado = TipoResultadoVotacao.objects.get(
id=request.POST['resultado_votacao'])
id=post_data['resultado_votacao'])
expediente.resultado = resultado.nome
expediente.votacao_aberta = False
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)
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)
def get_tipos_votacao(self):
@ -5058,7 +4721,9 @@ class VotacaoEmBlocoNominalView(PermissionRequiredForAppCrudMixin, TemplateView)
sessao_plenaria_id=kwargs['pk'])
context.update({'expedientes': expedientes})
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})
if 'cancelar-votacao' in request.POST:
@ -5135,6 +4800,10 @@ class VotacaoEmBlocoNominalView(PermissionRequiredForAppCrudMixin, TemplateView)
voto_parlamentar.votacao_id = votacao.id
voto_parlamentar.user = request.user
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()
ordem.resultado = form.cleaned_data['resultado_votacao'].nome
@ -5181,6 +4850,10 @@ class VotacaoEmBlocoNominalView(PermissionRequiredForAppCrudMixin, TemplateView)
voto_parlamentar.votacao_id = votacao.id
voto_parlamentar.user = request.user
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()
expediente.resultado = form.cleaned_data['resultado_votacao'].nome
@ -5282,7 +4955,9 @@ class VotacaoEmBlocoNominalView(PermissionRequiredForAppCrudMixin, TemplateView)
context.update({'expedientes': expedientes})
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,
'resultado_votacao': TipoResultadoVotacao.objects.all(),
'form': form,
@ -5290,6 +4965,19 @@ class VotacaoEmBlocoNominalView(PermissionRequiredForAppCrudMixin, TemplateView)
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):
model = RetiradaPauta
@ -5330,15 +5018,26 @@ class RetiradaPautaCrud(MasterDetailCrud):
@never_cache
@user_passes_test(_check_sessao_permission)
def leitura_v2_view(request, pk, oid, mid):
"""Shell da página Vue de leitura de matéria (só o fluxo de OrdemDia)."""
def leitura_v2_view(request, pk, oid, mid, fase='ordem'):
"""
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)
ordem = OrdemDia.objects.get(id=oid)
registro = RegistroLeitura.objects.filter(materia=materia, ordem=ordem).first()
model = OrdemDia if fase == 'ordem' else ExpedienteMateria
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 = {
'pk': pk,
'oid': oid,
'mid': mid,
'fase': fase,
'materia_texto': str(materia),
'materia_ementa': materia.ementa,
'observacao': registro.observacao if registro else '',
@ -5347,7 +5046,7 @@ def leitura_v2_view(request, pk, oid, mid):
@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
(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
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(
materia=materia, ordem=ordem,
materia=materia, **{lookup_field: materia_votacao},
defaults={
'observacao': body.get('observacao', ''),
'user': request.user,
'ip': get_client_ip(request),
})
ordem.resultado = "Matéria lida"
ordem.votacao_aberta = False
ordem.save()
materia_votacao.resultado = "Matéria lida"
materia_votacao.votacao_aberta = False
materia_votacao.save()
# Mesmo gap encontrado em VotacaoView.post(): registrar a leitura
# também muda o estado da matéria no painel e nunca notificava via
# WebSocket, só no próximo poll.
broadcast_dados_painel(request, pk)
lista_url = 'sapl.sessao:ordemdia_list' if fase == 'ordem' \
else 'sapl.sessao:expedientemateria_list'
return JsonResponse({
"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-oid="{{ oid }}"
data-mid="{{ mid }}"
data-fase="{{ fase }}"
data-materia-texto="{{ materia_texto }}"
data-materia-ementa="{{ materia_ementa }}"
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%}

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

@ -1,7 +1,10 @@
{% extends "crud/detail.html" %}
{% load i18n crispy_forms_tags%}
{% load common_tags %}
{% load render_bundle from webpack_loader %}
{% block detail_content %}
<style>[v-cloak]{display:none}</style>
<form method="post">
{% csrf_token %}
@ -39,35 +42,20 @@
<br />
{% endfor %}
{% endif %}
<fieldset class="form-group">
{% if parlamentares %}
<legend>Votos:</legend>
<div class="row">
{% for parlamentar in parlamentares %}
<div class="col-md-4" id="styleparlamentar">{{parlamentar.0.nome_parlamentar}}</div>
<div class="col-md-5">
{% if parlamentar.1 %} <input type="hidden" name="voto_parlamentar" value="{{parlamentar.1}}:{{parlamentar.0.id}}" /> {% endif %}
<select id="voto_parlamentar" name="voto_parlamentar" class="form-control" {% if parlamentar.1 %} disabled {% endif %}>
<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>
</div>
{% endfor %}
{{ parlamentares_json|json_script:"parlamentares-data" }}
<div id="app-votacao-nominal-bloco" v-cloak>
<votacao-nominal-bloco :parlamentares="parlamentares"></votacao-nominal-bloco>
</div>
{% endif %}
<br>
{% else %}
<fieldset class="form-group">
<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="">
<input type="hidden" name="votos_sim" value="0">
<input type="hidden" name="votos_nao" value="0">
<input type="hidden" name="abstencoes" value="0">
<input type="hidden" name="nao_votou" value="0">
</fieldset>
{% endif %}
<div class="row">
<div class="col-md-12">
@ -114,50 +102,17 @@
window.history.back();
}
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;
};
};
});
$("#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();
// A soma de votos (antes um setTimeout(conta_votos, 500) sondando o
// DOM pra sempre) agora é reatividade normal do Vue, dentro de
// VotacaoNominalBloco.vue — nada aqui recalcula mais nada.
$(window).on('beforeunload', function () {
$("input[type=submit], input[type=button]").prop("disabled", "disabled");
});
</script>
{% 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-oid="{{ oid }}"
data-mid="{{ mid }}"
data-fase="{{ fase }}"
data-total-presentes="{{ total_presentes }}"
data-total-votantes="{{ total_votantes }}">
<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"
:error-message="error_message"
:votos-status="votosStatus"
:votos-travados="votosTravados"
:registro-aberto="registro_aberto"
@cast-vote="castVote"
@cancelar="onCancelar"
@fechar="onFechar"
@toggle-registro="toggleRegistro"
ref="votacaoNominal">
</votacao-nominal>
</div>

5
vue.config.js

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

Loading…
Cancel
Save