diff --git a/sapl/api/serializers.py b/sapl/api/serializers.py index b8bb06b4d..f4a82db35 100644 --- a/sapl/api/serializers.py +++ b/sapl/api/serializers.py @@ -12,7 +12,7 @@ from rest_framework.fields import SerializerMethodField from sapl.base.models import Autor, CasaLegislativa, Metadata from sapl.parlamentares.models import Parlamentar, Mandato, Legislatura -from sapl.sessao.models import OrdemDia, SessaoPlenaria +from sapl.sessao.models import ExpedienteMateria, OrdemDia, SessaoPlenaria class SaplSerializerMixin(DrfAutoApiSerializerMixin): @@ -317,5 +317,25 @@ class SessaoPlenariaECidadaniaSerializer(serializers.ModelSerializer): return self.casa().nome def casa(self): - casa = CasaLegislativa.objects.first() - return casa + return CasaLegislativa.objects.first() + + +class OrdemDiaSerializer(SaplSerializerMixin): + """ + votacao_aberta/registro_aberto só podem ser alterados através do fluxo + de abrir_votacao()/VotacaoNominalAbstract (sapl/sessao/views.py), que + garante a invariante de no máximo uma matéria aberta por vez. Editáveis + aqui, a API contornaria essa garantia por completo. + """ + class Meta: + model = OrdemDia + fields = '__all__' + read_only_fields = ('votacao_aberta', 'registro_aberto') + + +class ExpedienteMateriaSerializer(SaplSerializerMixin): + """Ver OrdemDiaSerializer — mesmo motivo para os mesmos dois campos.""" + class Meta: + model = ExpedienteMateria + fields = '__all__' + read_only_fields = ('votacao_aberta', 'registro_aberto') diff --git a/sapl/api/tests/test_serializers_sessao.py b/sapl/api/tests/test_serializers_sessao.py new file mode 100644 index 000000000..69e8d7259 --- /dev/null +++ b/sapl/api/tests/test_serializers_sessao.py @@ -0,0 +1,19 @@ +import pytest +from model_bakery import baker + +from sapl.api.serializers import SessaoPlenariaECidadaniaSerializer +from sapl.base.models import CasaLegislativa +from sapl.sessao.models import SessaoPlenaria + + +@pytest.mark.django_db(transaction=False) +def test_sessao_ecidadania_serializa_dados_da_casa(): + casa = baker.make(CasaLegislativa, nome='Câmara Municipal', sigla='CM', + endereco='Praça Central') + sessao = baker.make(SessaoPlenaria) + + data = SessaoPlenariaECidadaniaSerializer(sessao).data + + assert data['txtNomeOrgao'] == casa.nome + assert data['txtSiglaOrgao'] == casa.sigla + assert data['txtLocal'] == casa.endereco diff --git a/sapl/painel/tests/tests.py b/sapl/painel/tests/tests.py index a79ca8be5..dd7753d18 100644 --- a/sapl/painel/tests/tests.py +++ b/sapl/painel/tests/tests.py @@ -1,3 +1,364 @@ -# from django.test import TestCase +import pytest +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Permission +from django.test import Client +from django.urls import reverse +from model_bakery import baker -# Create your tests here. +from sapl.base.models import AppConfig as ConfiguracoesAplicacao +from sapl.materia.models import MateriaLegislativa, TipoMateriaLegislativa +from sapl.parlamentares.models import (Legislatura, Mandato, Parlamentar, + SessaoLegislativa, Votante) +from sapl.sessao.models import (ExpedienteMateria, OrdemDia, + PresencaOrdemDia, SessaoPlenaria, + TipoResultadoVotacao, TipoSessaoPlenaria, + VotoParlamentar) + +NOMINAL = 2 +LEITURA = 4 + + +def _sessao_plenaria(): + legislatura = baker.make(Legislatura) + sessao_legislativa = baker.make(SessaoLegislativa) + tipo = baker.make(TipoSessaoPlenaria) + return baker.make(SessaoPlenaria, legislatura=legislatura, + sessao_legislativa=sessao_legislativa, tipo=tipo, numero=1) + + +def _materia(): + tipo_materia = baker.make(TipoMateriaLegislativa) + return baker.make(MateriaLegislativa, tipo=tipo_materia) + + +def _ordem_nominal_aberta(registro_aberto=False): + sessao = _sessao_plenaria() + materia = _materia() + ordem = baker.make(OrdemDia, sessao_plenaria=sessao, materia=materia, + tipo_votacao=NOMINAL, votacao_aberta=True, + registro_aberto=registro_aberto) + return sessao, ordem + + +def _ordem_leitura_aberta(): + sessao = _sessao_plenaria() + materia = _materia() + ordem = baker.make(OrdemDia, sessao_plenaria=sessao, materia=materia, + tipo_votacao=LEITURA, votacao_aberta=True) + return sessao, ordem + + +def _votante(sessao, admin_user): + parlamentar = baker.make(Parlamentar, ativo=True) + baker.make(PresencaOrdemDia, sessao_plenaria=sessao, parlamentar=parlamentar) + baker.make(Votante, parlamentar=parlamentar, user=admin_user) + return parlamentar + + +def _votante_com_client(sessao): + """ + Como _votante(), mas cria seu próprio usuário (não-superuser, só com a + permissão parlamentares.can_vote) e devolve um Client logado separado — + necessário para testar a corrida entre o operador e o vereador, que + precisam ser duas sessões/usuários distintos. + """ + parlamentar = baker.make(Parlamentar, ativo=True) + baker.make(PresencaOrdemDia, sessao_plenaria=sessao, parlamentar=parlamentar) + user = get_user_model().objects.create_user( + username='votante-{}'.format(parlamentar.pk), password='x') + user.user_permissions.add(Permission.objects.get(codename='can_vote')) + baker.make(Votante, parlamentar=parlamentar, user=user) + client = Client() + client.force_login(user) + return parlamentar, client + + +def _registrar_url(sessao, ordem): + return reverse('sapl.sessao:votacaonominal', + kwargs={'pk': sessao.pk, 'oid': ordem.pk, 'mid': ordem.materia_id}) + + +@pytest.mark.django_db(transaction=False) +def test_votante_view_envia_headers_never_cache(admin_client, admin_user): + sessao, ordem = _ordem_nominal_aberta() + _votante(sessao, admin_user) + + response = admin_client.get(reverse('sapl.painel:voto_individual')) + + assert response.status_code == 200 + assert 'no-store' in response['Cache-Control'] + + +@pytest.mark.django_db(transaction=False) +def test_votante_view_mostra_materia_quando_registro_fechado(admin_client, admin_user): + sessao, ordem = _ordem_nominal_aberta(registro_aberto=False) + _votante(sessao, admin_user) + + response = admin_client.get(reverse('sapl.painel:voto_individual')) + + assert response.status_code == 200 + assert 'error_message' not in response.context + assert response.context['materia'] == ordem.materia + + +@pytest.mark.django_db(transaction=False) +def test_votante_view_mostra_erro_explicito_quando_registro_bloqueado(admin_client, admin_user): + """ + Regressão da causa raiz #1: quando a Mesa bloqueia novos votos + (registro_aberto=True), o vereador que ainda não votou precisa ver uma + mensagem explícita — não uma tela em branco sem explicação. + """ + sessao, ordem = _ordem_nominal_aberta(registro_aberto=True) + _votante(sessao, admin_user) + + response = admin_client.get(reverse('sapl.painel:voto_individual')) + + assert response.status_code == 200 + assert 'Mesa encerrou o recebimento de novos votos' in response.context['error_message'] + + +@pytest.mark.django_db(transaction=False) +def test_voto_do_vereador_prevalece_sobre_lote_do_operador(admin_client): + """ + Invariante: o voto do próprio vereador sempre prevalece sobre qualquer + valor provisório já existente para ele (ex.: de uma tentativa anterior + do operador, ou de qualquer outra origem). Complementa + test_salvar_votacao_nao_sobrescreve_voto_ja_registrado (que cobre a + ordem inversa: o operador não pode sobrescrever um voto real já + registrado) — aqui é o vereador votando por cima de um valor existente + através da view de verdade, não apenas o estado inicial simulado. + """ + sessao, ordem = _ordem_nominal_aberta() + vereador, votante_client = _votante_com_client(sessao) + + # 1) Já existe um valor provisório "Não Votou" para o vereador (ex.: o + # , não um conflito — não pode gerar o aviso de não sobrescrito. + avisos = [str(m) for m in get_messages(response.wsgi_request)] + assert not any('não foram sobrescritos' in a for a in avisos) + + +@pytest.mark.django_db(transaction=False) +def test_salvar_votacao_avisa_quando_voto_do_tablet_diverge_do_formulario(admin_client): + sessao, ordem = _ordem_nominal_aberta() + ja_votou = _presente(sessao) + baker.make(VotoParlamentar, ordem=ordem, parlamentar=ja_votou, voto='Sim') + tipo_resultado = baker.make(TipoResultadoVotacao, nome='Aprovada', natureza='A') + + response = admin_client.post(_registrar_url(sessao, ordem), { + 'salvar-votacao': '1', + 'resultado_votacao': str(tipo_resultado.pk), + 'observacao': '', + 'voto_parlamentar': ['Não:{}'.format(ja_votou.pk)], + }) + + assert response.status_code == 302 + assert VotoParlamentar.objects.get(ordem=ordem, parlamentar=ja_votou).voto == 'Sim' + avisos = [str(m) for m in get_messages(response.wsgi_request)] + assert any('não foram sobrescritos' in a for a in avisos) + + +@pytest.mark.django_db(transaction=False) +def test_salvar_votacao_registra_nao_votou_para_presentes_sem_voto(admin_client): + """ + Relatórios, transparência e o painel leem VotoParlamentar por votação: + quem estava presente e não votou precisa ficar registrado como + 'Não Votou', vinculado ao RegistroVotacao. + """ + sessao, ordem = _ordem_nominal_aberta() + votou = _presente(sessao) + nao_votou = _presente(sessao) + tipo_resultado = baker.make(TipoResultadoVotacao, nome='Aprovada', natureza='A') + + response = admin_client.post(_registrar_url(sessao, ordem), { + 'salvar-votacao': '1', + 'resultado_votacao': str(tipo_resultado.pk), + 'observacao': '', + 'voto_parlamentar': [ + 'Sim:{}'.format(votou.pk), + 'Não Votou:{}'.format(nao_votou.pk), + ], + }) + + assert response.status_code == 302 + registro = RegistroVotacao.objects.get(ordem=ordem) + assert registro.numero_votos_sim == 1 + voto = VotoParlamentar.objects.get(ordem=ordem, parlamentar=nao_votou) + assert voto.voto == 'Não Votou' + assert voto.votacao_id == registro.id + + +@pytest.mark.django_db(transaction=False) +def test_salvar_votacao_ignora_voto_de_quem_teve_presenca_removida(admin_client): + """ + Parlamentar vota pelo tablet, sai e tem a presença removida antes do + encerramento: o voto não entra na contagem e é apagado. + """ + sessao, ordem = _ordem_nominal_aberta() + presente = _presente(sessao) + saiu = _presente(sessao) + baker.make(VotoParlamentar, ordem=ordem, parlamentar=saiu, voto='Sim') + PresencaOrdemDia.objects.filter(sessao_plenaria=sessao, parlamentar=saiu).delete() + tipo_resultado = baker.make(TipoResultadoVotacao, nome='Rejeitada', natureza='R') + + response = admin_client.post(_registrar_url(sessao, ordem), { + 'salvar-votacao': '1', + 'resultado_votacao': str(tipo_resultado.pk), + 'observacao': '', + 'voto_parlamentar': ['Não:{}'.format(presente.pk)], + }) + + assert response.status_code == 302 + registro = RegistroVotacao.objects.get(ordem=ordem) + assert registro.numero_votos_sim == 0 + assert registro.numero_votos_nao == 1 + assert not VotoParlamentar.objects.filter(ordem=ordem, parlamentar=saiu).exists() + + +@pytest.mark.django_db(transaction=False) +def test_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 de qualquer parlamentar com um VotoParlamentar + existente — travando o operador para sempre sem conseguir registrar + nenhum voto para essa matéria. + """ + sessao, ordem = _ordem_nominal_aberta() + parlamentar = _presente(sessao) + tipo_resultado = baker.make(TipoResultadoVotacao, nome='Aprovada', natureza='A') + + url = _registrar_url(sessao, ordem) + payload = { + 'salvar-votacao': '1', + 'resultado_votacao': str(tipo_resultado.pk), + 'observacao': '', + 'voto_parlamentar': ['Não Votou:{}'.format(parlamentar.pk)], + } + + response = admin_client.post(url, payload) + assert response.status_code == 302 + + assert not VotoParlamentar.objects.filter( + ordem=ordem, parlamentar=parlamentar).exists() + + ordem.refresh_from_db() + assert ordem.votacao_aberta is True + + # O operador consegue tentar de novo, agora com um voto real. + response2 = admin_client.post(url, { + 'salvar-votacao': '1', + 'resultado_votacao': str(tipo_resultado.pk), + 'observacao': '', + 'voto_parlamentar': ['Sim:{}'.format(parlamentar.pk)], + }) + assert response2.status_code == 302 + ordem.refresh_from_db() + assert ordem.votacao_aberta is False + assert VotoParlamentar.objects.get(ordem=ordem, parlamentar=parlamentar).voto == 'Sim' + + +@pytest.mark.django_db(transaction=False) +def test_status_da_votacao_reflete_troca_de_voto_do_parlamentar(admin_client): + """ + Regressão: a tela de registro (nominal.html) não atualizava a linha de + um parlamentar que trocou o voto durante a janela de votação — o poll + antigo só marcava "já votou" uma vez e nunca revisitava o valor. O poll + (?status=1) precisa sempre devolver o voto atual, não só se existe. + """ + sessao, ordem = _ordem_nominal_aberta() + parlamentar = _presente(sessao) + voto = baker.make(VotoParlamentar, ordem=ordem, parlamentar=parlamentar, + voto='Sim') + + url = _registrar_url(sessao, ordem) + '?status=1' + response = admin_client.get(url) + assert response.status_code == 200 + data = response.json() + assert data['votos'] == {str(parlamentar.pk): 'Sim'} + assert data['votacao_aberta'] is True + assert data['registro_aberto'] is False + assert data['ja_registrada'] is False + + voto.voto = 'Não' + voto.save() + + response2 = admin_client.get(url) + assert response2.json()['votos'] == {str(parlamentar.pk): 'Não'} + + +@pytest.mark.django_db(transaction=False) +def test_status_da_votacao_nao_depende_de_mostrar_voto(admin_client): + """ + O poll da tela de registro é só para a Mesa, não para o público — ao + contrário de sapl.painel:dados_painel, ele não pode mascarar o valor + real do voto por trás de "Voto Informado" mesmo quando a Casa configura + mostrar_voto=False (essa config controla o telão público, não a tela de + registro da própria Mesa). Como o endpoint nem consulta essa + configuração, isso é garantido por construção — este teste só + documenta a expectativa. + """ + sessao, ordem = _ordem_nominal_aberta() + parlamentar = _presente(sessao) + baker.make(VotoParlamentar, ordem=ordem, parlamentar=parlamentar, + voto='Abstenção') + + url = _registrar_url(sessao, ordem) + '?status=1' + data = admin_client.get(url).json() + assert data['votos'][str(parlamentar.pk)] == 'Abstenção' + + +@pytest.mark.django_db(transaction=False) +def test_status_da_votacao_nao_redireciona_apos_encerrar_votacao( + admin_client): + """ + _get_materia_votacao (usado pelo GET normal) redireciona com uma + mensagem quando a matéria já foi votada — comportamento certo para + navegação, errado para um poll em background. O branch ?status=1 não + pode herdar esse redirect. + """ + sessao, ordem = _ordem_nominal_aberta() + parlamentar = _presente(sessao) + tipo_resultado = baker.make(TipoResultadoVotacao, nome='Aprovada', + natureza='A') + + url = _registrar_url(sessao, ordem) + admin_client.post(url, { + 'salvar-votacao': '1', + 'resultado_votacao': str(tipo_resultado.pk), + 'observacao': '', + 'voto_parlamentar': ['Sim:{}'.format(parlamentar.pk)], + }) + + response = admin_client.get(url + '?status=1') + assert response.status_code == 200 + data = response.json() + assert data['ja_registrada'] is True + assert data['votacao_aberta'] is False + + +@pytest.mark.django_db(transaction=False) +def test_abrir_votacao_ja_aberta_e_idempotente(admin_client): + """ + Regressão: clicar em "Abrir Votação" para uma matéria que já está aberta + (ex.: duplo clique, ou a lista ainda não recarregou para trocar o botão + por "Registrar Votação") caía em verifica_votacoes_abertas(), que trata + a própria matéria como uma "outra" votação conflitante — mostra a + mensagem "existem votações abertas... foram fechadas" e fecha a matéria + (para reabri-la em seguida, já que o código sempre prossegue). O + resultado final até ficava certo (votacao_aberta=True), mas a mensagem + era enganosa. Reabrir a própria matéria já aberta precisa ser + silenciosamente idempotente. + """ + sessao, ordem = _ordem_nominal_aberta() + sessao.iniciada = True + sessao.finalizada = False + sessao.save() + _presente(sessao) + + url = reverse('sapl.sessao:abrir_votacao', kwargs={'pk': ordem.pk, 'spk': sessao.pk}) + url += '?tipo_materia=ordem' + + response = admin_client.get(url, follow=True) + + assert response.status_code == 200 + ordem.refresh_from_db() + assert ordem.votacao_aberta is True + + mensagens = [str(m) for m in response.context['messages']] + assert not any('foram fechadas' in m for m in mensagens) + + +def _abrir_votacao_url(sessao, ordem): + return reverse('sapl.sessao:abrir_votacao', + kwargs={'pk': ordem.pk, 'spk': sessao.pk}) + '?tipo_materia=ordem' + + +@pytest.mark.django_db(transaction=False) +def test_abrir_outra_votacao_zera_bloqueio_da_anterior(admin_client): + """ + Abrir Y sem encerrar X fecha X; X não pode voltar com os tablets + bloqueados (registro_aberto=True) quando for reaberta. + """ + sessao, x = _ordem_nominal_aberta() + sessao.iniciada = True + sessao.finalizada = False + sessao.save() + _presente(sessao) + x.registro_aberto = True + x.save() + y = baker.make(OrdemDia, sessao_plenaria=sessao, materia=_materia(), + tipo_votacao=NOMINAL, votacao_aberta=False) + + admin_client.get(_abrir_votacao_url(sessao, y)) + + x.refresh_from_db() + y.refresh_from_db() + assert y.votacao_aberta is True + assert x.votacao_aberta is False + assert x.registro_aberto is False + + # Legado: matéria fechada que ficou com registro_aberto=True. + OrdemDia.objects.filter(pk=x.pk).update(registro_aberto=True) + admin_client.get(_abrir_votacao_url(sessao, x)) + + x.refresh_from_db() + assert x.votacao_aberta is True + assert x.registro_aberto is False + + +@pytest.mark.django_db(transaction=False) +def test_abrir_votacao_concorrente_mostra_erro_em_vez_de_500(admin_client): + """ + O lock de abrir_votacao() é por sessão, mas a unicidade de + votacao_aberta é global: uma abertura simultânea em outra sessão faz o + save cair no índice parcial. O fechamento das outras votações é + desfeito junto, então a mensagem "foram fechadas" não pode aparecer. + """ + sessao, ordem = _ordem_nominal_aberta() + ordem.votacao_aberta = False + ordem.save() + _, outra = _ordem_nominal_aberta() + sessao.iniciada = True + sessao.finalizada = False + sessao.save() + _presente(sessao) + + with mock.patch.object(OrdemDia, 'save', side_effect=IntegrityError): + response = admin_client.get(_abrir_votacao_url(sessao, ordem), follow=True) + + assert response.status_code == 200 + ordem.refresh_from_db() + outra.refresh_from_db() + assert ordem.votacao_aberta is False + assert outra.votacao_aberta is True + mensagens = [str(m) for m in response.context['messages']] + assert any('aberta simultaneamente' in m for m in mensagens) + assert not any('foram fechadas' in m for m in mensagens) + + +@pytest.mark.django_db(transaction=False) +def test_abrir_votacao_avisa_que_fechou_as_outras(admin_client): + sessao, ordem = _ordem_nominal_aberta() + ordem.votacao_aberta = False + ordem.save() + _, outra = _ordem_nominal_aberta() + sessao.iniciada = True + sessao.finalizada = False + sessao.save() + _presente(sessao) + + response = admin_client.get(_abrir_votacao_url(sessao, ordem), follow=True) + + outra.refresh_from_db() + assert outra.votacao_aberta is False + mensagens = [str(m) for m in response.context['messages']] + assert any('foram fechadas' in m for m in mensagens) + + +@pytest.mark.django_db(transaction=False) +def test_unique_constraint_impede_voto_duplicado(admin_client): + sessao, ordem = _ordem_nominal_aberta() + parlamentar = _presente(sessao) + baker.make(VotoParlamentar, ordem=ordem, parlamentar=parlamentar, voto='Sim') + + with pytest.raises(IntegrityError): + VotoParlamentar.objects.create( + ordem=ordem, parlamentar=parlamentar, voto='Não') + + +@pytest.mark.django_db(transaction=False) +def test_unique_constraint_impede_duas_ordens_abertas(): + sessao, ordem = _ordem_nominal_aberta() + outra = baker.make(OrdemDia, sessao_plenaria=sessao, materia=ordem.materia, + tipo_votacao=NOMINAL, votacao_aberta=False) + + with pytest.raises(IntegrityError): + outra.votacao_aberta = True + outra.save() + + +@pytest.mark.django_db(transaction=False) +def test_migracao_0071_remove_votos_duplicados_antes_da_constraint(): + import importlib + + from django.apps import apps as real_apps + from django.db import connection + + migracao = importlib.import_module( + 'sapl.sessao.migrations.0071_votoparlamentar_unique_constraint') + + with connection.cursor() as cursor: + cursor.execute( + 'DROP INDEX sessao_votoparlamentar_unique_parlamentar_ordem') + + sessao, ordem = _ordem_nominal_aberta() + parlamentar = _presente(sessao) + antigo = baker.make(VotoParlamentar, ordem=ordem, + parlamentar=parlamentar, voto='Não') + recente = baker.make(VotoParlamentar, ordem=ordem, + parlamentar=parlamentar, voto='Sim') + + migracao.remove_votos_duplicados(real_apps, None) + + votos = VotoParlamentar.objects.filter( + ordem=ordem, parlamentar=parlamentar) + assert list(votos.values_list('id', flat=True)) == [recente.id] + assert not votos.filter(id=antigo.id).exists() + + +@pytest.mark.django_db(transaction=False) +def test_migracao_0072_fecha_duplicatas_antes_da_constraint(): + """ + A função de dados da migration 0072 precisa fechar duplicatas + pré-existentes antes do AddConstraint — senão a migration falharia ao + ser aplicada num banco com dado antigo (de antes desta invariante + existir). Testa a função isoladamente: como o teste roda dentro de uma + transação que é desfeita no final, é seguro derrubar o índice aqui + (DDL é transacional no Postgres). + """ + import importlib + + from django.apps import apps as real_apps + from django.db import connection + + migracao = importlib.import_module( + 'sapl.sessao.migrations.0072_votacao_aberta_unique_constraint') + + with connection.cursor() as cursor: + cursor.execute('DROP INDEX sessao_ordemdia_unique_votacao_aberta') + + sessao, mais_antiga = _ordem_nominal_aberta() + mais_recente = baker.make(OrdemDia, sessao_plenaria=sessao, + materia=mais_antiga.materia, tipo_votacao=NOMINAL, + votacao_aberta=True, registro_aberto=True) + assert mais_recente.pk > mais_antiga.pk + # Legado: o GET antigo da tela de registro deixava registro_aberto=True + # em matérias já encerradas. + encerrada = baker.make(OrdemDia, sessao_plenaria=sessao, + materia=mais_antiga.materia, tipo_votacao=NOMINAL, + votacao_aberta=False, registro_aberto=True) + + migracao.fecha_materias_abertas_duplicadas(real_apps, None) + + mais_antiga.refresh_from_db() + mais_recente.refresh_from_db() + encerrada.refresh_from_db() + assert mais_antiga.votacao_aberta is False + assert mais_recente.votacao_aberta is True + assert mais_recente.registro_aberto is False + assert encerrada.registro_aberto is False + + +@pytest.mark.django_db(transaction=False) +def test_api_nao_permite_abrir_votacao_via_patch(admin_client): + """ + Regressão do bypass encontrado na auditoria: a API auto-gerada + (drfautoapi) não pode mais aceitar votacao_aberta/registro_aberto — + senão qualquer "Operador de Sessão Plenária" conseguiria abrir uma + matéria via PATCH direto, pulando verifica_votacoes_abertas() e o + lock em abrir_votacao(). + """ + sessao, ordem = _ordem_nominal_aberta() + ordem.votacao_aberta = False + ordem.save() + + url = '/api/sessao/ordemdia/{}/'.format(ordem.pk) + response = admin_client.patch( + url, data={'votacao_aberta': True}, content_type='application/json') + + assert response.status_code in (200, 202) + ordem.refresh_from_db() + assert ordem.votacao_aberta is False diff --git a/sapl/sessao/views.py b/sapl/sessao/views.py index 2c1df1442..fcae0ebed 100755 --- a/sapl/sessao/views.py +++ b/sapl/sessao/views.py @@ -9,6 +9,7 @@ from django.contrib import messages from django.contrib.auth.decorators import permission_required from django.contrib.auth.mixins import PermissionRequiredMixin from django.core.exceptions import ObjectDoesNotExist +from django.db import IntegrityError, transaction from django.db.models import Max, Q from django.http import JsonResponse from django.http.response import Http404, HttpResponseRedirect @@ -52,7 +53,7 @@ from sapl.utils import show_results_filter_set, remover_acentos, get_client_ip, MultiFormatOutputMixin, PautaMultiFormatOutputMixin, ratelimit_ip from .forms import (AdicionarVariasMateriasFilterSet, AdicionarVariasMateriasForm, BancadaForm, - ExpedienteForm, JustificativaAusenciaForm, OcorrenciaSessaoForm, ListMateriaForm, + ExpedienteForm, JustificativaAusenciaForm, OcorrenciaSessaoForm, MesaForm, OradorExpedienteForm, OradorForm, PautaSessaoFilterSet, PresencaForm, ResumoOrdenacaoForm, SessaoPlenariaFilterSet, SessaoPlenariaForm, VotacaoEditForm, VotacaoForm, @@ -128,7 +129,12 @@ def verifica_presenca(request, model, spk, is_leitura=False): return True -def verifica_votacoes_abertas(request): +def verifica_votacoes_abertas(request, mensagens): + """ + Fecha as votações abertas. A mensagem para o usuário vai para + `mensagens`, e o chamador só a registra depois do commit: se a transação + for desfeita, as votações não foram fechadas. + """ votacoes_abertas = SessaoPlenaria.objects.filter( Q(ordemdia__votacao_aberta=True) | Q(expedientemateria__votacao_aberta=True)).distinct() @@ -147,18 +153,15 @@ def verifica_votacoes_abertas(request): ', '.join(msg_abertas) + '. Estas votações ou leituras foram fechadas.') msg = _('Já existem votações ou leituras abertas nas seguintes Sessões: ' + ', '.join(msg_abertas) + '. Estas votações ou leituras foram fechadas.') - messages.add_message(request, messages.INFO, msg) + mensagens.append(msg) - for sessao in votacoes_abertas: - ordens = sessao.ordemdia_set.filter(votacao_aberta=True) - expediente = sessao.expedientemateria_set.filter( - votacao_aberta=True) - for o in ordens: - o.votacao_aberta = False - o.save() - for e in expediente: - e.votacao_aberta = False - e.save() + # update() em vez de save(): só toca estes dois campos e reavalia o + # WHERE sob o lock da linha, sem regravar um resultado que um + # "Encerrar Votação" concorrente tenha acabado de salvar. + OrdemDia.objects.filter(votacao_aberta=True).update( + votacao_aberta=False, registro_aberto=False) + ExpedienteMateria.objects.filter(votacao_aberta=True).update( + votacao_aberta=False, registro_aberto=False) return True @@ -199,26 +202,83 @@ def abrir_votacao(request, pk, spk): if not model: raise Http404() - query_params = "?" - materia_votacao = model.objects.get(id=pk) is_leitura = materia_votacao.tipo_votacao == 4 - if (verifica_presenca(request, presenca_model, spk, is_leitura) and - verifica_votacoes_abertas(request) and - verifica_sessao_iniciada(request, spk, is_leitura)): - materia_votacao.votacao_aberta = True - sessao = SessaoPlenaria.objects.get(id=spk) - sessao.painel_aberto = True - sessao.save() - materia_votacao.save() + is_expediente = model is ExpedienteMateria + opened = False + mensagens = [] + try: + with transaction.atomic(): + # select_for_update trava a linha da SessaoPlenaria durante toda a + # checagem+fechamento+abertura, para que dois "abrir votação" + # concorrentes (duplo clique, ou uma requisição lenta seguida de + # nova tentativa) não deixem duas matérias com votacao_aberta=True + # ao mesmo tempo — sapl/painel/views.py::votacao_aberta() trata + # esse caso redirecionando todos os tablets sem nenhuma mensagem + # clara de erro. + SessaoPlenaria.objects.select_for_update().get(id=spk) + # Reflete o estado mais atual sob o lock: outra requisição + # concorrente pode ter mudado votacao_aberta entre o SELECT inicial + # (antes do lock) e aqui. + materia_votacao.refresh_from_db() + ja_aberta = materia_votacao.votacao_aberta + + # Reabrir a própria matéria que já está aberta precisa ser + # idempotente: verifica_votacoes_abertas() existe para fechar + # OUTRAS matérias concorrentes, e sua mensagem ("já existem + # votações abertas... foram fechadas") não faz sentido quando a + # única "conflitante" é ela mesma. + if (verifica_presenca(request, presenca_model, spk, is_leitura) and + (ja_aberta or verifica_votacoes_abertas(request, mensagens)) and + verifica_sessao_iniciada(request, spk, is_leitura)): + materia_votacao.votacao_aberta = True + if not ja_aberta: + materia_votacao.registro_aberto = False + sessao = SessaoPlenaria.objects.get(id=spk) + sessao.painel_aberto = True + sessao.save() + materia_votacao.save() + opened = True + for msg in mensagens: + messages.add_message(request, messages.INFO, msg) + except IntegrityError: + # O lock acima é por sessão, mas a unicidade de votacao_aberta é + # global: uma abertura simultânea em outra sessão cai no índice + # parcial. + opened = False + messages.add_message(request, messages.ERROR, _( + 'Outra votação foi aberta simultaneamente. Tente novamente.')) + + if opened: + # Leva direto para a tela de registro do tipo de votação recém + # aberta, em vez de voltar para a lista — sem isso, o usuário + # precisa de um segundo clique em "Registrar Votação"/"Registrar + # Leitura" (que só aparece depois que a lista recarrega) para + # chegar aonde queria. + registro_view_names_ordem = { + SIMBOLICA: 'votacaosimbolica', NOMINAL: 'votacaonominal', + SECRETA: 'votacaosecreta', LEITURA: 'leituraod', + } + registro_view_names_expediente = { + SIMBOLICA: 'votacaosimbolicaexp', NOMINAL: 'votacaonominalexp', + SECRETA: 'votacaosecretaexp', LEITURA: 'leituraexp', + } + registro_view_names = (registro_view_names_expediente if is_expediente + else registro_view_names_ordem) + registro_view_name = registro_view_names[materia_votacao.tipo_votacao] + success_url = reverse('sapl.sessao:' + registro_view_name, kwargs={ + 'pk': spk, 'oid': materia_votacao.pk, + 'mid': materia_votacao.materia_id}) + if 'page' in request.GET: + success_url += '?page={}'.format(request.GET['page']) + else: + query_params = "?" if 'page' in request.GET: query_params += 'page={}&'.format(request.GET['page']) - query_params += "#id{}".format(materia_votacao.materia.id) - - success_url = reverse('sapl.sessao:' + redirect_url, kwargs={'pk': spk}) - success_url += query_params + success_url = reverse('sapl.sessao:' + redirect_url, + kwargs={'pk': spk}) + query_params return HttpResponseRedirect(success_url) @@ -1578,112 +1638,6 @@ class PresencaOrdemDiaView(FormMixin, PresencaMixin, DetailView): return reverse('sapl.sessao:presencaordemdia', kwargs={'pk': pk}) -class ListMateriaOrdemDiaView(FormMixin, DetailView): - template_name = 'sessao/materia_ordemdia_list.html' - form_class = ListMateriaForm - model = SessaoPlenaria - - def get(self, request, *args, **kwargs): - self.object = self.get_object() - context = self.get_context_data(object=self.object) - - pk = self.kwargs['pk'] - ordem = OrdemDia.objects.filter(sessao_plenaria_id=pk) - - materias_ordem = [] - for o in ordem: - ementa = o.materia.ementa - titulo = o.materia - numero = o.numero_ordem - - autoria = Autoria.objects.filter(materia_id=o.materia_id) - autor = [str(a.autor) for a in autoria] - - mat = {'pk': pk, - 'oid': o.id, - 'ordem_id': o.materia_id, - 'ementa': ementa, - 'titulo': titulo, - 'numero': numero, - 'resultado': o.resultado, - 'autor': autor, - 'votacao_aberta': o.votacao_aberta, - 'tipo_votacao': o.tipo_votacao - } - materias_ordem.append(mat) - - sorted(materias_ordem, key=lambda x: x['numero']) - - context.update({'materias_ordem': materias_ordem}) - - return self.render_to_response(context) - - @method_decorator(permission_required('sessao.change_ordemdia')) - def post(self, request, *args, **kwargs): - self.object = self.get_object() - context = self.get_context_data(object=self.object) - - pk = self.kwargs['pk'] - form = ListMateriaForm(request.POST) - - # TODO: Existe uma forma de atualizar em lote de acordo - # com a forma abaixo, mas como setar o primeiro para "1"? - # OrdemDia.objects.filter(sessao_plenaria_id=pk) - # .order_by('numero_ordem').update(numero_ordem=3) - - if 'materia_reorder' in request.POST: - ordens = OrdemDia.objects.filter(sessao_plenaria_id=pk) - ordem_num = 1 - for o in ordens: - o.numero_ordem = ordem_num - o.save() - ordem_num += 1 - elif 'abrir-votacao' in request.POST: - existe_votacao_aberta = OrdemDia.objects.filter( - sessao_plenaria_id=pk, votacao_aberta=True).exists() - if existe_votacao_aberta: - context = self.get_context_data(object=self.object) - - form._errors = {'error_message': 'error_message'} - context.update({'form': form}) - - pk = self.kwargs['pk'] - ordem = OrdemDia.objects.filter(sessao_plenaria_id=pk) - - materias_ordem = [] - for o in ordem: - ementa = o.materia.ementa - titulo = o.materia - numero = o.numero_ordem - - autoria = Autoria.objects.filter(materia_id=o.materia_id) - autor = [str(a.autor) for a in autoria] - - mat = {'pk': pk, - 'oid': o.id, - 'ordem_id': o.materia_id, - 'ementa': ementa, - 'titulo': titulo, - 'numero': numero, - 'resultado': o.resultado, - 'autor': autor, - 'votacao_aberta': o.votacao_aberta, - 'tipo_votacao': o.tipo_votacao - } - materias_ordem.append(mat) - - sorted(materias_ordem, key=lambda x: x['numero']) - context.update({'materias_ordem': materias_ordem}) - return self.render_to_response(context) - else: - ordem_id = request.POST['ordem_id'] - ordem = OrdemDia.objects.get(id=ordem_id) - ordem.votacao_aberta = True - ordem.registro_aberto = False - ordem.save() - return self.get(self, request, args, kwargs) - - class MesaView(FormMixin, DetailView): template_name = 'sessao/mesa.html' form_class = MesaForm @@ -2985,7 +2939,15 @@ class VotacaoNominalAbstract(SessaoPermissionMixin): logger = logging.getLogger(__name__) - def get(self, request, *args, **kwargs): + 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'] @@ -2994,33 +2956,27 @@ class VotacaoNominalAbstract(SessaoPermissionMixin): messages.add_message(request, messages.ERROR, msg) self.logger.info( 'user=' + username + '. Matéria (ordem_id={}) já votada!'.format(ordem_id)) - return HttpResponseRedirect(reverse( + return None, None, None, HttpResponseRedirect(reverse( 'sapl.sessao:ordemdia_list', kwargs={'pk': kwargs['pk']})) try: - ordem = OrdemDia.objects.get(id=ordem_id) + 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=ordem.sessao_plenaria_id) - total = presentes.count() + sessao_plenaria_id=materia_votacao.sessao_plenaria_id) - materia_votacao = ordem - - if not ordem.votacao_aberta: + 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 HttpResponseRedirect(reverse( + return None, None, None, HttpResponseRedirect(reverse( 'sapl.sessao:ordemdia_list', kwargs={'pk': kwargs['pk']})) - ordem.registro_aberto = True - ordem.save() - elif self.expediente: expediente_id = kwargs['oid'] if (RegistroVotacao.objects.filter( @@ -3029,194 +2985,259 @@ class VotacaoNominalAbstract(SessaoPermissionMixin): "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 HttpResponseRedirect(reverse( + 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)) - expediente = ExpedienteMateria.objects.get(id=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=expediente.sessao_plenaria_id) - total = presentes.count() + sessao_plenaria_id=materia_votacao.sessao_plenaria_id) - materia_votacao = expediente - - if not expediente.votacao_aberta: + 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 HttpResponseRedirect(reverse( + return None, None, None, HttpResponseRedirect(reverse( 'sapl.sessao:expedientemateria_list', kwargs={'pk': kwargs['pk']})) - expediente.registro_aberto = True - expediente.save() + 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))} - context = {'materia': materia, 'object': self.get_object(), - 'parlamentares': self.get_parlamentares(presentes), - 'form': self.get_form(), - 'total': total} + 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 _lock_materia_votacao(self, materia_votacao): + model = OrdemDia if self.ordem else ExpedienteMateria + return model.objects.select_for_update().get(pk=materia_votacao.pk) + def post(self, request, *args, **kwargs): self.object = self.get_object() - form = self.get_form() username = request.user.username page = '' if 'page' in self.request.GET: page = '?page={}'.format(self.request.GET['page']) - if self.ordem: - ordem_id = kwargs['oid'] - try: - self.logger.debug( - "user=" + username + ". Tentando obter objeto OrdemDia com id={}.".format(ordem_id)) - materia_votacao = OrdemDia.objects.get(id=ordem_id) - except ObjectDoesNotExist: - self.logger.error( - 'user=' + username + '. Objeto OrdemDia com id={} não existe.'.format(ordem_id)) - raise Http404() - elif self.expediente: - expediente_id = kwargs['oid'] - try: - self.logger.debug( - "user=" + username + ". Tentando obter 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() + materia_votacao, presentes, total, redirect = self._get_materia_votacao( + request, kwargs) + if redirect: + return redirect - if form.is_valid(): - votos_sim = 0 - votos_nao = 0 - abstencoes = 0 - nao_votou = 0 + fase_sessao_field = 'ordem_id' if self.ordem else 'expediente_id' + fase_sessao_value = kwargs['oid'] + + if 'reabrir-votacao' in request.POST: + with transaction.atomic(): + materia_votacao = self._lock_materia_votacao(materia_votacao) + materia_votacao.registro_aberto = False + materia_votacao.save(update_fields=['registro_aberto']) + self.logger.info( + 'user=' + username + '. Reabriu a matéria (id={}) para novos votos.'.format(fase_sessao_value)) + return self._redirect_same_registro(kwargs, page) + + if 'bloquear-registro-votacao' in request.POST: + with transaction.atomic(): + materia_votacao = self._lock_materia_votacao(materia_votacao) + materia_votacao.registro_aberto = True + materia_votacao.save(update_fields=['registro_aberto']) + self.logger.info( + 'user=' + username + '. Bloqueou novos votos para a matéria (id={}).'.format(fase_sessao_value)) + return self._redirect_same_registro(kwargs, page) - if 'cancelar-votacao' in request.POST: + if 'cancelar-votacao' in request.POST: + with transaction.atomic(): + materia_votacao = self._lock_materia_votacao(materia_votacao) fechar_votacao_materia(materia_votacao) - if self.ordem: - return HttpResponseRedirect( - reverse( - 'sapl.sessao:ordemdia_list', - kwargs={'pk': kwargs['pk']} - ) + page + "#id{}".format(self.kwargs['mid']) - ) - else: - return HttpResponseRedirect( - reverse( - 'sapl.sessao:expedientemateria_list', - kwargs={'pk': kwargs['pk']} - ) + page + "#id{}".format(self.kwargs['mid']) - ) - else: - if form.cleaned_data['resultado_votacao'] == None: - form.add_error(None, 'Não é possível finalizar a votação sem ' - 'nenhum resultado da votação') - return self.form_invalid(form) + return self._redirect_lista(kwargs, page) - for votos in request.POST.getlist('voto_parlamentar'): - v = votos.split(':') - voto = v[0] - parlamentar_id = v[1] - - if voto == 'Sim': - votos_sim += 1 - elif voto == 'Não': - votos_nao += 1 - elif voto == 'Abstenção': - abstencoes += 1 - elif voto == 'Não Votou': - nao_votou += 1 - - # Caso todas as opções sejam 'Não votou', fecha a votação - if nao_votou == len(request.POST.getlist('voto_parlamentar')): - 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') + if 'salvar-votacao' in request.POST: + form = self.get_form() + if not form.is_valid(): return self.form_invalid(form) - # Remove todas as votação desta matéria, caso existam - if self.ordem: - RegistroVotacao.objects.filter(ordem_id=ordem_id).delete() - elif self.expediente: - RegistroVotacao.objects.filter( - expediente_id=expediente_id).delete() - - votacao = RegistroVotacao() - votacao.numero_votos_sim = votos_sim - votacao.numero_votos_nao = votos_nao - votacao.numero_abstencoes = abstencoes - votacao.observacao = request.POST.get('observacao', None) - votacao.user = request.user - votacao.ip = get_client_ip(request) - - votacao.materia_id = materia_votacao.materia.id - if self.ordem: - votacao.ordem_id = ordem_id - elif self.expediente: - votacao.expediente_id = expediente_id - - votacao.tipo_resultado_votacao = form.cleaned_data['resultado_votacao'] - votacao.save() - - for votos in request.POST.getlist('voto_parlamentar'): - v = votos.split(':') - voto = v[0] - parlamentar_id = v[1] - - if self.ordem: - voto_parlamentar = VotoParlamentar.objects.get_or_create( - parlamentar_id=parlamentar_id, - ordem_id=ordem_id)[0] - elif self.expediente: - voto_parlamentar = VotoParlamentar.objects.get_or_create( - parlamentar_id=parlamentar_id, - expediente_id=expediente_id)[0] - voto_parlamentar.voto = voto - voto_parlamentar.parlamentar_id = parlamentar_id - voto_parlamentar.votacao_id = votacao.id - voto_parlamentar.user = request.user - voto_parlamentar.ip = get_client_ip(request) - voto_parlamentar.save() + 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) - resultado = form.cleaned_data['resultado_votacao'] + votos_submetidos = [] + for voto_codificado in request.POST.getlist('voto_parlamentar'): + try: + voto_submetido, parlamentar_id = voto_codificado.split(':', 1) + except ValueError: + form.add_error(None, _('Formato de voto inválido.')) + return self.form_invalid(form) + if voto_submetido not in ('Sim', 'Não', 'Abstenção', 'Não Votou'): + form.add_error(None, _('Voto inválido.')) + return self.form_invalid(form) + votos_submetidos.append((voto_submetido, parlamentar_id)) + + skipped_parlamentares = [] + with transaction.atomic(): + materia_votacao = self._lock_materia_votacao(materia_votacao) + if (not materia_votacao.votacao_aberta or + RegistroVotacao.objects.filter( + **{fase_sessao_field: fase_sessao_value}).exists()): + messages.error( + request, _('A votação já foi encerrada por outra operação.')) + return self._redirect_lista(kwargs, page) + + for voto_submetido, parlamentar_id in votos_submetidos: + 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. Os + # presentes que continuarem sem voto recebem + # 'Não Votou' só depois que o RegistroVotacao existe. + continue + voto_parlamentar, created = VotoParlamentar.objects.select_for_update().get_or_create( + parlamentar_id=parlamentar_id, + **{fase_sessao_field: fase_sessao_value}) + if created: + 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}, + parlamentar_id__in=presentes.values('parlamentar_id')) + 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) - materia_votacao.resultado = resultado.nome + 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) + VotoParlamentar.objects.bulk_create([ + VotoParlamentar( + parlamentar_id=presenca.parlamentar_id, + voto='Não Votou', + votacao=votacao, + user=request.user, + ip=get_client_ip(request), + **{fase_sessao_field: fase_sessao_value}) + for presenca in presentes.exclude( + parlamentar_id__in=votos_atuais.values('parlamentar_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 - if self.ordem: - VotoParlamentar.objects.filter( - ordem_id=ordem_id, - votacao__isnull=True).delete() - elif self.expediente: + # 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( - expediente_id=expediente_id, - votacao__isnull=True).delete() + **{fase_sessao_field: fase_sessao_value}, votacao__isnull=True).delete() + + 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) - else: - return self.form_invalid(form) + # Nenhuma ação reconhecida: volta para a tela de registro (GET). + return self._redirect_same_registro(kwargs, page) def form_invalid(self, form): errors_tuple = [(form[e].label, form.errors[e]) diff --git a/sapl/templates/painel/index.html b/sapl/templates/painel/index.html index d5dc8dc4c..22ef48ecb 100644 --- a/sapl/templates/painel/index.html +++ b/sapl/templates/painel/index.html @@ -313,11 +313,17 @@ if (data["status_painel"] == true) { mostrar_voto = data["mostrar_voto"]; + var tem_voto_individual = data['tipo_votacao'] == 'Nominal'; presentes.append(''); $.each(presentes_list, function (index, parlamentar) { - - - if (parlamentar.voto == 'Voto Informado' && mostrar_voto == false){ + if (!tem_voto_individual){ + $('#parlamentares_list').append('') + } + else if (parlamentar.voto == 'Voto Informado' && mostrar_voto == false){ $('#parlamentares_list').append('') } else{ - $('#parlamentares_list').append(show_voto(parlamentar)) + $('#parlamentares_list').append(show_voto(parlamentar, !!data['registro'])) } }); @@ -504,8 +510,13 @@ })(); }); - function show_voto(parlamentar) { + function show_voto(parlamentar, registrada) { color = 'white' + + // Com a votação ainda aberta, voto vazio só significa "ainda não votou". + if (!parlamentar.voto && registrada) { + parlamentar.voto = "Não votou"; + } if (parlamentar.voto == "Sim"){ color = 'green' } @@ -529,4 +540,3 @@ - diff --git a/sapl/templates/painel/voto_individual.html b/sapl/templates/painel/voto_individual.html index 1dde0990d..df71296c5 100644 --- a/sapl/templates/painel/voto_individual.html +++ b/sapl/templates/painel/voto_individual.html @@ -46,6 +46,10 @@ + {% for message in messages %} +

{{ message }}

+ {% endfor %} + {% if not error_message %}

{{sessao}}

@@ -77,6 +81,11 @@

+ {% if status_message %} +

+ {{status_message}} +

+ {% endif %}

@@ -111,7 +120,8 @@
{% else %}
-

{{error_message}}

+

⏸

+

{{error_message}}




@@ -184,11 +194,62 @@ $("#date").text(n); startTime(); - - setTimeout(function() { - document.location.reload(true); - }, 30000) }); {% endif %} + + diff --git a/sapl/templates/sessao/votacao/nominal.html b/sapl/templates/sessao/votacao/nominal.html index 540e0f3da..887803ee4 100644 --- a/sapl/templates/sessao/votacao/nominal.html +++ b/sapl/templates/sessao/votacao/nominal.html @@ -13,6 +13,15 @@ Ementa: {{materia.ementa|safe}}

+ {% if total == 0 %}
' + + parlamentar.nome + + ' ' + + parlamentar.partido + ' ' + + '
' + parlamentar.nome + ' ' + @@ -325,7 +331,7 @@ + '