From dc93586baebe765ed30013821d2f2508d2fb72fc Mon Sep 17 00:00:00 2001 From: Edward Oliveira Date: Thu, 24 Sep 2026 13:11:52 -0300 Subject: [PATCH] feat: fixed more issues --- frontend/src/__apps/leitura-materia/main.js | 13 +- .../src/__apps/painel/store/painelStore.js | 1 + .../src/__apps/painel/ws/normalizePainel.js | 12 +- .../src/__apps/votacao-nominal-bloco/main.js | 28 + .../scss/votacao-nominal-bloco.scss | 1 + frontend/src/__apps/votacao-simbolica/main.js | 11 +- frontend/src/__apps/votacao/main.js | 104 ++-- .../VotacaoNominalBloco.vue | 74 +++ .../src/components/votacao/VotacaoNominal.vue | 25 +- .../src/components/votacao/VotacaoVotos.vue | 11 + sapl/asgi.py | 9 +- sapl/painel/consumers.py | 122 +++- sapl/painel/tests/test_broadcasts.py | 26 +- sapl/painel/tests/tests.py | 5 - sapl/painel/views.py | 250 ++++++-- ...sao_presencas_e_materias_votacoes_views.py | 18 +- sapl/sessao/models.py | 6 + sapl/sessao/tests/test_votacao_nominal.py | 235 +------ sapl/sessao/urls.py | 16 +- sapl/sessao/views.py | 587 +++++------------- sapl/templates/sessao/votacao/leitura_v2.html | 1 + sapl/templates/sessao/votacao/nominal.html | 282 --------- .../sessao/votacao/votacao_nominal_bloco.html | 97 +-- .../sessao/votacao/votacao_simbolica_v2.html | 1 + sapl/templates/sessao/votacao/votacao_v2.html | 3 + vue.config.js | 5 + 26 files changed, 789 insertions(+), 1154 deletions(-) create mode 100644 frontend/src/__apps/votacao-nominal-bloco/main.js create mode 100644 frontend/src/__apps/votacao-nominal-bloco/scss/votacao-nominal-bloco.scss create mode 100644 frontend/src/components/votacao-nominal-bloco/VotacaoNominalBloco.vue delete mode 100644 sapl/templates/sessao/votacao/nominal.html diff --git a/frontend/src/__apps/leitura-materia/main.js b/frontend/src/__apps/leitura-materia/main.js index d6474507a..c7b8170db 100644 --- a/frontend/src/__apps/leitura-materia/main.js +++ b/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) { diff --git a/frontend/src/__apps/painel/store/painelStore.js b/frontend/src/__apps/painel/store/painelStore.js index 2cc1fd810..7cf629e29 100644 --- a/frontend/src/__apps/painel/store/painelStore.js +++ b/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 diff --git a/frontend/src/__apps/painel/ws/normalizePainel.js b/frontend/src/__apps/painel/ws/normalizePainel.js index 633dd7eaa..116618960 100644 --- a/frontend/src/__apps/painel/ws/normalizePainel.js +++ b/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 + // 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) diff --git a/frontend/src/components/votacao-nominal-bloco/VotacaoNominalBloco.vue b/frontend/src/components/votacao-nominal-bloco/VotacaoNominalBloco.vue new file mode 100644 index 000000000..fb6def873 --- /dev/null +++ b/frontend/src/components/votacao-nominal-bloco/VotacaoNominalBloco.vue @@ -0,0 +1,74 @@ + + + diff --git a/frontend/src/components/votacao/VotacaoNominal.vue b/frontend/src/components/votacao/VotacaoNominal.vue index 76ced709c..08d33d8ef 100644 --- a/frontend/src/components/votacao/VotacaoNominal.vue +++ b/frontend/src/components/votacao/VotacaoNominal.vue @@ -6,10 +6,22 @@ {{ error_message }} +
+ Status da votação: em aberto · + novos votos bloqueados — os vereadores que ainda não votaram não conseguem mais votar até a Mesa reabrir + novos votos permitidos — os vereadores ainda podem votar pelo tablet enquanto a Mesa registra +
+ + +
- + @@ -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() { diff --git a/frontend/src/components/votacao/VotacaoVotos.vue b/frontend/src/components/votacao/VotacaoVotos.vue index e8e2bb7fc..1c149a905 100644 --- a/frontend/src/components/votacao/VotacaoVotos.vue +++ b/frontend/src/components/votacao/VotacaoVotos.vue @@ -10,12 +10,14 @@ + Já votado (tablet) @@ -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 do operador (só quando o próprio parlamentar + # votou, não quando foi o próprio operador via este + # mesmo ). 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 ' - % get_token(request)) btn_registrar = '''
%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( - ' ', ' ', 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 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 = '''''' - - 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}), }) diff --git a/sapl/templates/sessao/votacao/leitura_v2.html b/sapl/templates/sessao/votacao/leitura_v2.html index b1675d18b..4759331ba 100644 --- a/sapl/templates/sessao/votacao/leitura_v2.html +++ b/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 }}"> diff --git a/sapl/templates/sessao/votacao/nominal.html b/sapl/templates/sessao/votacao/nominal.html deleted file mode 100644 index 1aabe803b..000000000 --- a/sapl/templates/sessao/votacao/nominal.html +++ /dev/null @@ -1,282 +0,0 @@ -{% extends "crud/detail.html" %} -{% load i18n crispy_forms_tags %} - -{% block detail_content %} - - - {% csrf_token %} - -
- Votação Nominal -
- Matéria: {{materia.materia}} -
- Ementa: {{materia.ementa|safe}} -
-
- - {% if total == 0 %} - - Voltar - {% else %} -
- - Votos -
- {% for parlamentar in parlamentares %} -
{{parlamentar.0.nome_parlamentar}}
-
- {% if parlamentar.1 %} {% endif %} - - Já votado (tablet) -
- {% endfor %} -
- - Situação da Votação: -
- -
- -
-
- {{ form.resultado_votacao|as_crispy_field }} -
-
- -
-
-
- Observações
- -
-
- -

-
-
-
- - {% if registro_aberto %} - - {% else %} - - {% endif %} - -
-
-
- {% endif %} -
-
-{% endblock detail_content %} - -{% block extra_js %} - -{% endblock extra_js%} diff --git a/sapl/templates/sessao/votacao/votacao_nominal_bloco.html b/sapl/templates/sessao/votacao/votacao_nominal_bloco.html index fe558fcc7..1ce1cee6d 100644 --- a/sapl/templates/sessao/votacao/votacao_nominal_bloco.html +++ b/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 %} +
{% csrf_token %} @@ -39,35 +42,20 @@
{% endfor %} {% endif %} -
- - {% if parlamentares %} - Votos: -
- {% for parlamentar in parlamentares %} -
{{parlamentar.0.nome_parlamentar}}
-
- {% if parlamentar.1 %} {% endif %} - -
- {% endfor %} -
- {% endif %} -
- Situação da Votação: - -
- - - - - -
+ {% if parlamentares %} + {{ parlamentares_json|json_script:"parlamentares-data" }} +
+ +
+ {% else %} +
+ Situação da Votação: + + + + +
+ {% endif %}
@@ -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("
Sim: " + votos_sim + "
"); - $("#soma_votos").append("
Não: " + votos_nao + "
"); - $("#soma_votos").append("
Abstenções: " + votos_abstencao + "
"); - $("#soma_votos").append("
Ainda não votaram: " + nao_votou + "
"); - 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"); }); - {% endblock extra_js%} + +{% block webpack_loader_js %} + {% render_chunk_vendors 'js' %} + {% render_bundle 'global' 'js' %} + {% render_bundle 'votacao-nominal-bloco' 'js' %} +{% endblock %} diff --git a/sapl/templates/sessao/votacao/votacao_simbolica_v2.html b/sapl/templates/sessao/votacao/votacao_simbolica_v2.html index d9e4a41c5..c877d8ab2 100644 --- a/sapl/templates/sessao/votacao/votacao_simbolica_v2.html +++ b/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 }}"> diff --git a/sapl/templates/sessao/votacao/votacao_v2.html b/sapl/templates/sessao/votacao/votacao_v2.html index 5d511f931..fb1e35230 100644 --- a/sapl/templates/sessao/votacao/votacao_v2.html +++ b/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">
diff --git a/vue.config.js b/vue.config.js index 8451ccaf6..afef8d354 100644 --- a/vue.config.js +++ b/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')