mirror of https://github.com/interlegis/sapl.git
committed by
GitHub
15 changed files with 1779 additions and 428 deletions
@ -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 |
||||
@ -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 |
||||
|
# <select> nunca foi tocado pelo operador). Esse valor nunca deveria |
||||
|
# impedir o vereador de votar de verdade. |
||||
|
baker.make(VotoParlamentar, ordem=ordem, parlamentar=vereador, |
||||
|
voto='Não Votou') |
||||
|
|
||||
|
# 2) O vereador vota pelo tablet — seu voto real prevalece sobre o |
||||
|
# valor provisório. |
||||
|
response = votante_client.post( |
||||
|
reverse('sapl.painel:voto_individual'), {'voto': 'Sim'}) |
||||
|
assert response.status_code == 302 |
||||
|
|
||||
|
voto = VotoParlamentar.objects.get(ordem=ordem, parlamentar=vereador) |
||||
|
assert voto.voto == 'Sim' |
||||
|
|
||||
|
|
||||
|
@pytest.mark.django_db(transaction=False) |
||||
|
def test_post_de_voto_bloqueado_nao_persiste(admin_client, admin_user): |
||||
|
sessao, ordem = _ordem_nominal_aberta(registro_aberto=True) |
||||
|
parlamentar = _votante(sessao, admin_user) |
||||
|
|
||||
|
response = admin_client.post( |
||||
|
reverse('sapl.painel:voto_individual'), {'voto': 'Sim'}) |
||||
|
|
||||
|
assert response.status_code == 302 |
||||
|
assert not VotoParlamentar.objects.filter( |
||||
|
ordem=ordem, parlamentar=parlamentar).exists() |
||||
|
|
||||
|
# voto_individual.html é avulso (não herda base.html): o erro precisa |
||||
|
# aparecer na própria tela do tablet. |
||||
|
pagina = admin_client.get(response.url) |
||||
|
assert 'A votação não está disponível para novos votos.' in pagina.content.decode() |
||||
|
|
||||
|
|
||||
|
@pytest.mark.django_db(transaction=False) |
||||
|
def test_post_rejeita_valor_de_voto_invalido(admin_client, admin_user): |
||||
|
sessao, ordem = _ordem_nominal_aberta() |
||||
|
parlamentar = _votante(sessao, admin_user) |
||||
|
|
||||
|
response = admin_client.post( |
||||
|
reverse('sapl.painel:voto_individual'), {'voto': 'Outro'}) |
||||
|
|
||||
|
assert response.status_code == 302 |
||||
|
assert not VotoParlamentar.objects.filter( |
||||
|
ordem=ordem, parlamentar=parlamentar).exists() |
||||
|
|
||||
|
pagina = admin_client.get(response.url) |
||||
|
assert 'Voto inválido.' in pagina.content.decode() |
||||
|
|
||||
|
|
||||
|
@pytest.mark.django_db(transaction=False) |
||||
|
def test_propria_tela_nao_mostra_voto_de_outra_materia(admin_client): |
||||
|
""" |
||||
|
Regressão: votacao() buscava o voto do próprio vereador com |
||||
|
Q(ordem=ordem_dia) | Q(expediente=expediente) — quando a matéria atual |
||||
|
é uma OrdemDia, expediente é None, e Q(expediente=None) vira |
||||
|
"expediente_id IS NULL" no SQL, que é verdadeiro para QUALQUER voto de |
||||
|
ordem do dia daquele vereador, não só o desta matéria (idem para |
||||
|
ExpedienteMateria, cujas votações também têm ordem=None). Com .first() |
||||
|
sem ordenação, o vereador podia ver o voto de uma matéria antiga em vez |
||||
|
do voto (ou ausência de voto) da matéria atual — inclusive depois de |
||||
|
trocar o próprio voto, já que a query buscava a linha errada. |
||||
|
""" |
||||
|
sessao, ordem_antiga = _ordem_nominal_aberta() |
||||
|
vereador, votante_client = _votante_com_client(sessao) |
||||
|
baker.make(VotoParlamentar, ordem=ordem_antiga, parlamentar=vereador, voto='Sim') |
||||
|
|
||||
|
ordem_antiga.votacao_aberta = False |
||||
|
ordem_antiga.save() |
||||
|
baker.make(OrdemDia, sessao_plenaria=sessao, materia=_materia(), |
||||
|
tipo_votacao=NOMINAL, votacao_aberta=True, registro_aberto=False) |
||||
|
|
||||
|
status_url = reverse('sapl.painel:voto_individual_status') |
||||
|
|
||||
|
# Ainda não votou na matéria atual — não pode herdar o 'Sim' da antiga. |
||||
|
assert votante_client.get(status_url).json()['voto_parlamentar'] is None |
||||
|
|
||||
|
votante_client.post(reverse('sapl.painel:voto_individual'), {'voto': 'Não'}) |
||||
|
|
||||
|
assert votante_client.get(status_url).json()['voto_parlamentar'] == 'Não' |
||||
|
assert VotoParlamentar.objects.get( |
||||
|
ordem=ordem_antiga, parlamentar=vereador).voto == 'Sim' |
||||
|
|
||||
|
|
||||
|
@pytest.mark.django_db(transaction=False) |
||||
|
def test_get_dados_painel_nao_usa_etag_incompleto(admin_client): |
||||
|
baker.make(ConfiguracoesAplicacao, mostrar_voto=True, mostrar_brasao_painel=False) |
||||
|
sessao, ordem = _ordem_nominal_aberta() |
||||
|
url = reverse('sapl.painel:dados_painel', kwargs={'pk': sessao.pk}) |
||||
|
|
||||
|
primeira = admin_client.get(url) |
||||
|
assert primeira.status_code == 200 |
||||
|
assert 'ETag' not in primeira |
||||
|
|
||||
|
# Mesmo que um cliente antigo ainda envie o último valor conhecido, a |
||||
|
# resposta deve ser sempre recalculada para não ocultar mudanças em |
||||
|
# presenças, oradores ou configuração do painel. |
||||
|
segunda = admin_client.get(url, HTTP_IF_NONE_MATCH='"valor-antigo"') |
||||
|
assert segunda.status_code == 200 |
||||
|
assert 'ETag' not in segunda |
||||
|
|
||||
|
|
||||
|
@pytest.mark.django_db(transaction=False) |
||||
|
def test_painel_exibe_nao_votou_para_parlamentar_sem_voto(admin_client): |
||||
|
""" |
||||
|
Quem estava presente e não votou chega ao painel como 'Não Votou' depois |
||||
|
do encerramento. Para dado legado sem VotoParlamentar (voto null), o |
||||
|
JavaScript mostra "Não votou" só com a votação registrada — com ela |
||||
|
aberta, voto vazio significa apenas "ainda não votou". |
||||
|
""" |
||||
|
baker.make(ConfiguracoesAplicacao, mostrar_voto=True, |
||||
|
mostrar_brasao_painel=False) |
||||
|
sessao, ordem = _ordem_nominal_aberta() |
||||
|
tipo_resultado = baker.make(TipoResultadoVotacao, nome='Aprovada', |
||||
|
natureza='A') |
||||
|
votou = baker.make(Parlamentar, ativo=True) |
||||
|
nao_votou = baker.make(Parlamentar, ativo=True) |
||||
|
for parlamentar in (votou, nao_votou): |
||||
|
baker.make(PresencaOrdemDia, sessao_plenaria=sessao, |
||||
|
parlamentar=parlamentar) |
||||
|
baker.make(Mandato, parlamentar=parlamentar, |
||||
|
legislatura=sessao.legislatura) |
||||
|
|
||||
|
resposta_registro = 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 resposta_registro.status_code == 302 |
||||
|
|
||||
|
dados = admin_client.get(reverse( |
||||
|
'sapl.painel:dados_painel', kwargs={'pk': sessao.pk})).json() |
||||
|
parlamentar_sem_voto = next( |
||||
|
p for p in dados['presentes'] if p['parlamentar_id'] == nao_votou.pk) |
||||
|
assert parlamentar_sem_voto['voto'] == 'Não Votou' |
||||
|
|
||||
|
painel = admin_client.get(reverse( |
||||
|
'sapl.painel:painel_principal', kwargs={'pk': sessao.pk})) |
||||
|
assert painel.status_code == 200 |
||||
|
assert b'if (!parlamentar.voto && registrada)' in painel.content |
||||
|
assert 'Não votou'.encode() in painel.content |
||||
|
|
||||
|
|
||||
|
@pytest.mark.django_db(transaction=False) |
||||
|
def test_dados_painel_leitura_nao_tem_voto_individual(admin_client, admin_user): |
||||
|
""" |
||||
|
Contrato consumido pelo JS do painel (sapl/templates/painel/index.html): |
||||
|
para uma matéria em Leitura, tipo_votacao chega como a string 'Leitura' |
||||
|
(não o código inteiro) e nenhum parlamentar presente recebe um voto — |
||||
|
é isso que permite ao JS distinguir "não há voto individual nesta |
||||
|
matéria" de "ainda não votou". |
||||
|
""" |
||||
|
baker.make(ConfiguracoesAplicacao, mostrar_voto=True, mostrar_brasao_painel=False) |
||||
|
sessao, ordem = _ordem_leitura_aberta() |
||||
|
_votante(sessao, admin_user) |
||||
|
|
||||
|
dados = admin_client.get(reverse( |
||||
|
'sapl.painel:dados_painel', kwargs={'pk': sessao.pk})).json() |
||||
|
|
||||
|
assert dados['tipo_votacao'] == 'Leitura' |
||||
|
assert all(p['voto'] == '' for p in dados['presentes']) |
||||
|
|
||||
|
|
||||
|
@pytest.mark.django_db(transaction=False) |
||||
|
def test_dados_painel_sem_materia_nao_envia_tipo_votacao(admin_client, admin_user): |
||||
|
""" |
||||
|
Sem nenhuma matéria aberta ou já votada/lida (ex.: sessão solene), a |
||||
|
chave tipo_votacao não é enviada — o JS trata isso como "sem voto |
||||
|
individual" da mesma forma que trata 'Leitura'. |
||||
|
""" |
||||
|
baker.make(ConfiguracoesAplicacao, mostrar_voto=True, mostrar_brasao_painel=False) |
||||
|
sessao = _sessao_plenaria() |
||||
|
|
||||
|
dados = admin_client.get(reverse( |
||||
|
'sapl.painel:dados_painel', kwargs={'pk': sessao.pk})).json() |
||||
|
|
||||
|
assert 'tipo_votacao' not in dados |
||||
|
|
||||
|
|
||||
|
@pytest.mark.django_db(transaction=False) |
||||
|
def test_votante_status_reflete_estado_e_nao_exige_permissao_do_painel(): |
||||
|
""" |
||||
|
votante_status precisa ser alcançável por uma conta só-Votante (sem |
||||
|
nenhuma permissão do app painel) — é por isso que não reaproveita |
||||
|
get_dados_painel, que exige check_permission (permissão de módulo do |
||||
|
app painel). |
||||
|
""" |
||||
|
sessao, ordem = _ordem_nominal_aberta() |
||||
|
vereador, votante_client = _votante_com_client(sessao) |
||||
|
|
||||
|
status_url = reverse('sapl.painel:voto_individual_status') |
||||
|
|
||||
|
resposta = votante_client.get(status_url) |
||||
|
assert resposta.status_code == 200 |
||||
|
data = resposta.json() |
||||
|
assert data['materia_id'] == ordem.materia_id |
||||
|
assert data['status_message'] == 'Aguardando seu voto.' |
||||
|
assert data['voto_parlamentar'] is None |
||||
|
|
||||
|
votante_client.post(reverse('sapl.painel:voto_individual'), {'voto': 'Não'}) |
||||
|
|
||||
|
resposta2 = votante_client.get(status_url) |
||||
|
data2 = resposta2.json() |
||||
|
assert data2['voto_parlamentar'] == 'Não' |
||||
|
assert 'encerramento da votação' in data2['status_message'] |
||||
|
|
||||
|
|
||||
|
@pytest.mark.django_db(transaction=False) |
||||
|
def test_votante_status_com_duas_votacoes_abertas_nao_acumula_mensagens(): |
||||
|
""" |
||||
|
Uma OrdemDia e uma ExpedienteMateria abertas ao mesmo tempo (as |
||||
|
constraints são por tabela): o poll do tablet devolve o erro em |
||||
|
error_message, em texto puro, sem empilhar mensagens na sessão. |
||||
|
""" |
||||
|
sessao, ordem = _ordem_nominal_aberta() |
||||
|
vereador, votante_client = _votante_com_client(sessao) |
||||
|
outra_sessao = _sessao_plenaria() |
||||
|
baker.make(ExpedienteMateria, sessao_plenaria=outra_sessao, |
||||
|
materia=_materia(), tipo_votacao=NOMINAL, votacao_aberta=True) |
||||
|
|
||||
|
status_url = reverse('sapl.painel:voto_individual_status') |
||||
|
for _ in range(2): |
||||
|
data = votante_client.get(status_url).json() |
||||
|
|
||||
|
assert 'mais de uma' in data['error_message'] |
||||
|
assert '<' not in data['error_message'] |
||||
|
assert '_messages' not in votante_client.session |
||||
|
|||||
@ -1,3 +1,34 @@ |
|||||
|
from django.contrib import admin |
||||
|
|
||||
|
from sapl.sessao.models import ExpedienteMateria, OrdemDia |
||||
from sapl.utils import register_all_models_in_admin |
from sapl.utils import register_all_models_in_admin |
||||
|
|
||||
|
|
||||
|
class NoAdminAccessModelAdmin(admin.ModelAdmin): |
||||
|
""" |
||||
|
SAPL não usa as páginas de admin e desencoraja seu uso — e votacao_aberta/ |
||||
|
registro_aberto só podem ser alterados com segurança através do fluxo de |
||||
|
abrir_votacao()/VotacaoNominalAbstract (sapl/sessao/views.py), que |
||||
|
garante a invariante de no máximo uma matéria aberta por vez. Em vez de |
||||
|
apenas tornar os campos somente-leitura, o acesso ao admin é desabilitado |
||||
|
por completo para estes dois modelos. |
||||
|
""" |
||||
|
|
||||
|
def has_view_permission(self, request, obj=None): |
||||
|
return False |
||||
|
|
||||
|
def has_add_permission(self, request): |
||||
|
return False |
||||
|
|
||||
|
def has_change_permission(self, request, obj=None): |
||||
|
return False |
||||
|
|
||||
|
def has_delete_permission(self, request, obj=None): |
||||
|
return False |
||||
|
|
||||
|
|
||||
|
admin.site.register(OrdemDia, NoAdminAccessModelAdmin) |
||||
|
admin.site.register(ExpedienteMateria, NoAdminAccessModelAdmin) |
||||
|
|
||||
|
# register_all_models_in_admin já pula modelos já registrados acima |
||||
register_all_models_in_admin(__name__) |
register_all_models_in_admin(__name__) |
||||
|
|||||
@ -0,0 +1,54 @@ |
|||||
|
# Generated by Django 2.2.28 on 2026-08-16 17:36 |
||||
|
|
||||
|
from django.db import migrations, models |
||||
|
from django.db.models import Count |
||||
|
|
||||
|
|
||||
|
def remove_votos_duplicados(apps, schema_editor): |
||||
|
"""Preserva o voto canônico antes de criar os índices únicos.""" |
||||
|
VotoParlamentar = apps.get_model('sessao', 'VotoParlamentar') |
||||
|
|
||||
|
for materia_field in ('ordem_id', 'expediente_id'): |
||||
|
# order_by() limpa o Meta.ordering ('id'), que no Django 2.2 entraria |
||||
|
# no GROUP BY e deixaria todo grupo com n=1. |
||||
|
duplicadas = (VotoParlamentar.objects |
||||
|
.exclude(**{materia_field: None}) |
||||
|
.order_by() |
||||
|
.values('parlamentar_id', materia_field) |
||||
|
.annotate(n=Count('id')) |
||||
|
.filter(n__gt=1)) |
||||
|
for chave in duplicadas: |
||||
|
votos_duplicados = list(VotoParlamentar.objects.filter( |
||||
|
parlamentar_id=chave['parlamentar_id'], |
||||
|
**{materia_field: chave[materia_field]})) |
||||
|
voto_preservado = max( |
||||
|
votos_duplicados, |
||||
|
key=lambda voto: ( |
||||
|
voto.votacao_id is not None, |
||||
|
voto.data_hora is not None, |
||||
|
voto.data_hora, |
||||
|
voto.id, |
||||
|
)) |
||||
|
ids_removidos = [voto.id for voto in votos_duplicados |
||||
|
if voto.id != voto_preservado.id] |
||||
|
VotoParlamentar.objects.filter(id__in=ids_removidos).delete() |
||||
|
|
||||
|
|
||||
|
class Migration(migrations.Migration): |
||||
|
|
||||
|
dependencies = [ |
||||
|
('sessao', '0070_presenca_unica_por_sessao'), |
||||
|
] |
||||
|
|
||||
|
operations = [ |
||||
|
migrations.RunPython( |
||||
|
remove_votos_duplicados, migrations.RunPython.noop), |
||||
|
migrations.AddConstraint( |
||||
|
model_name='votoparlamentar', |
||||
|
constraint=models.UniqueConstraint(condition=models.Q(ordem__isnull=False), fields=('parlamentar', 'ordem'), name='sessao_votoparlamentar_unique_parlamentar_ordem'), |
||||
|
), |
||||
|
migrations.AddConstraint( |
||||
|
model_name='votoparlamentar', |
||||
|
constraint=models.UniqueConstraint(condition=models.Q(expediente__isnull=False), fields=('parlamentar', 'expediente'), name='sessao_votoparlamentar_unique_parlamentar_expediente'), |
||||
|
), |
||||
|
] |
||||
@ -0,0 +1,45 @@ |
|||||
|
# Generated by Django 2.2.28 on 2026-08-16 18:58 |
||||
|
|
||||
|
from django.db import migrations, models |
||||
|
|
||||
|
|
||||
|
def fecha_materias_abertas_duplicadas(apps, schema_editor): |
||||
|
""" |
||||
|
Pré-requisito para o AddConstraint abaixo: se já existir mais de uma |
||||
|
OrdemDia/ExpedienteMateria com votacao_aberta=True (dado deixado por |
||||
|
versões anteriores, que não garantiam essa invariante), a constraint |
||||
|
falharia ao ser criada. Mantém aberta apenas a criada por último (maior |
||||
|
id) de cada tabela e fecha as demais. |
||||
|
|
||||
|
Também zera registro_aberto, que mudou de significado ("a Mesa abriu a |
||||
|
tela de registro" -> "novos votos bloqueados"): o fluxo antigo marcava |
||||
|
True ao abrir a tela e não voltava para False ao encerrar, o que faria |
||||
|
uma matéria reaberta voltar com os tablets bloqueados. |
||||
|
""" |
||||
|
for model_name in ('OrdemDia', 'ExpedienteMateria'): |
||||
|
model = apps.get_model('sessao', model_name) |
||||
|
model.objects.filter(registro_aberto=True).update(registro_aberto=False) |
||||
|
abertas = model.objects.filter(votacao_aberta=True).order_by('-id') |
||||
|
for materia in abertas[1:]: |
||||
|
materia.votacao_aberta = False |
||||
|
materia.save(update_fields=['votacao_aberta']) |
||||
|
|
||||
|
|
||||
|
class Migration(migrations.Migration): |
||||
|
|
||||
|
dependencies = [ |
||||
|
('sessao', '0071_votoparlamentar_unique_constraint'), |
||||
|
] |
||||
|
|
||||
|
operations = [ |
||||
|
migrations.RunPython( |
||||
|
fecha_materias_abertas_duplicadas, migrations.RunPython.noop), |
||||
|
migrations.AddConstraint( |
||||
|
model_name='expedientemateria', |
||||
|
constraint=models.UniqueConstraint(condition=models.Q(votacao_aberta=True), fields=('votacao_aberta',), name='sessao_expedientemateria_unique_votacao_aberta'), |
||||
|
), |
||||
|
migrations.AddConstraint( |
||||
|
model_name='ordemdia', |
||||
|
constraint=models.UniqueConstraint(condition=models.Q(votacao_aberta=True), fields=('votacao_aberta',), name='sessao_ordemdia_unique_votacao_aberta'), |
||||
|
), |
||||
|
] |
||||
@ -0,0 +1,608 @@ |
|||||
|
import re |
||||
|
from unittest import mock |
||||
|
|
||||
|
import pytest |
||||
|
from django.contrib.messages import get_messages |
||||
|
from django.db import IntegrityError |
||||
|
from django.urls import reverse |
||||
|
from model_bakery import baker |
||||
|
|
||||
|
from sapl.materia.models import MateriaLegislativa, TipoMateriaLegislativa |
||||
|
from sapl.parlamentares.models import (Legislatura, Parlamentar, |
||||
|
SessaoLegislativa) |
||||
|
from sapl.sessao.models import (OrdemDia, PresencaOrdemDia, RegistroVotacao, |
||||
|
SessaoPlenaria, TipoResultadoVotacao, |
||||
|
TipoSessaoPlenaria, VotoParlamentar) |
||||
|
|
||||
|
NOMINAL = 2 |
||||
|
|
||||
|
|
||||
|
def _sessao_plenaria(): |
||||
|
legislatura = baker.make(Legislatura) |
||||
|
sessao_legislativa = baker.make(SessaoLegislativa) |
||||
|
tipo = baker.make(TipoSessaoPlenaria) |
||||
|
return baker.make(SessaoPlenaria, legislatura=legislatura, |
||||
|
sessao_legislativa=sessao_legislativa, tipo=tipo, numero=1) |
||||
|
|
||||
|
|
||||
|
def _materia(): |
||||
|
tipo_materia = baker.make(TipoMateriaLegislativa) |
||||
|
return baker.make(MateriaLegislativa, tipo=tipo_materia) |
||||
|
|
||||
|
|
||||
|
def _ordem_nominal_aberta(): |
||||
|
sessao = _sessao_plenaria() |
||||
|
materia = _materia() |
||||
|
ordem = baker.make(OrdemDia, sessao_plenaria=sessao, materia=materia, |
||||
|
tipo_votacao=NOMINAL, votacao_aberta=True, |
||||
|
registro_aberto=False) |
||||
|
return sessao, ordem |
||||
|
|
||||
|
|
||||
|
def _presente(sessao): |
||||
|
parlamentar = baker.make(Parlamentar, ativo=True) |
||||
|
baker.make(PresencaOrdemDia, sessao_plenaria=sessao, parlamentar=parlamentar) |
||||
|
return parlamentar |
||||
|
|
||||
|
|
||||
|
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_get_registrar_votacao_nao_bloqueia_novos_votos(admin_client): |
||||
|
""" |
||||
|
Regressão da causa raiz #1: abrir a tela "Registrar Votação" não pode, |
||||
|
sozinho, impedir que vereadores que ainda não votaram continuem votando. |
||||
|
""" |
||||
|
sessao, ordem = _ordem_nominal_aberta() |
||||
|
_presente(sessao) |
||||
|
|
||||
|
response = admin_client.get(_registrar_url(sessao, ordem)) |
||||
|
|
||||
|
assert response.status_code == 200 |
||||
|
ordem.refresh_from_db() |
||||
|
assert ordem.registro_aberto is False |
||||
|
|
||||
|
|
||||
|
@pytest.mark.django_db(transaction=False) |
||||
|
def test_post_sem_acao_reconhecida_redireciona_para_registro(admin_client): |
||||
|
sessao, ordem = _ordem_nominal_aberta() |
||||
|
_presente(sessao) |
||||
|
|
||||
|
response = admin_client.post(_registrar_url(sessao, ordem) + '?page=2', {}) |
||||
|
|
||||
|
assert response.status_code == 302 |
||||
|
assert response.url == _registrar_url(sessao, ordem) + '?page=2' |
||||
|
ordem.refresh_from_db() |
||||
|
assert ordem.registro_aberto is False |
||||
|
|
||||
|
|
||||
|
@pytest.mark.django_db(transaction=False) |
||||
|
def test_botao_registrar_votacao_nominal_usa_get(admin_client): |
||||
|
""" |
||||
|
A tela de registro não tem efeito colateral no GET; com o botão em GET |
||||
|
o ?page vai na URL e é preservado até o redirect final do post(). |
||||
|
""" |
||||
|
sessao, ordem = _ordem_nominal_aberta() |
||||
|
|
||||
|
response = admin_client.get( |
||||
|
reverse('sapl.sessao:ordemdia_list', kwargs={'pk': sessao.pk})) |
||||
|
|
||||
|
conteudo = response.content.decode() |
||||
|
form = re.search( |
||||
|
r'<form action="%s"[^>]*>' % re.escape(_registrar_url(sessao, ordem)), |
||||
|
conteudo) |
||||
|
assert form |
||||
|
assert form.group(0) == '<form action="%s">' % _registrar_url(sessao, ordem) |
||||
|
|
||||
|
|
||||
|
@pytest.mark.django_db(transaction=False) |
||||
|
def test_bloquear_e_reabrir_votacao_nao_mexe_em_votos_existentes(admin_client): |
||||
|
sessao, ordem = _ordem_nominal_aberta() |
||||
|
votante = _presente(sessao) |
||||
|
baker.make(VotoParlamentar, ordem=ordem, parlamentar=votante, voto='Sim') |
||||
|
|
||||
|
url = _registrar_url(sessao, ordem) |
||||
|
|
||||
|
response = admin_client.post(url, {'bloquear-registro-votacao': '1'}) |
||||
|
assert response.status_code == 302 |
||||
|
ordem.refresh_from_db() |
||||
|
assert ordem.registro_aberto is True |
||||
|
|
||||
|
response = admin_client.post(url, {'reabrir-votacao': '1'}) |
||||
|
assert response.status_code == 302 |
||||
|
ordem.refresh_from_db() |
||||
|
assert ordem.registro_aberto is False |
||||
|
|
||||
|
voto = VotoParlamentar.objects.get(ordem=ordem, parlamentar=votante) |
||||
|
assert voto.voto == 'Sim' |
||||
|
|
||||
|
|
||||
|
@pytest.mark.django_db(transaction=False) |
||||
|
def test_salvar_votacao_nao_sobrescreve_voto_ja_registrado(admin_client): |
||||
|
""" |
||||
|
Regressão do modelo de concorrência (3b): um formulário de "Fechar |
||||
|
Votação" com um valor obsoleto para quem já votou pelo tablet não pode |
||||
|
sobrescrever esse voto — só preenche quem ainda não tem voto registrado. |
||||
|
""" |
||||
|
sessao, ordem = _ordem_nominal_aberta() |
||||
|
ja_votou = _presente(sessao) |
||||
|
ainda_nao_votou = _presente(sessao) |
||||
|
baker.make(VotoParlamentar, ordem=ordem, parlamentar=ja_votou, voto='Sim') |
||||
|
|
||||
|
tipo_resultado = baker.make(TipoResultadoVotacao, nome='Aprovada', natureza='A') |
||||
|
|
||||
|
url = _registrar_url(sessao, ordem) |
||||
|
payload = { |
||||
|
'salvar-votacao': '1', |
||||
|
'resultado_votacao': str(tipo_resultado.pk), |
||||
|
'observacao': '', |
||||
|
'voto_parlamentar': [ |
||||
|
# valor obsoleto: a tela do operador ainda não sabia que este |
||||
|
# parlamentar já havia votado "Sim" pelo tablet |
||||
|
'Não Votou:{}'.format(ja_votou.pk), |
||||
|
'Não:{}'.format(ainda_nao_votou.pk), |
||||
|
], |
||||
|
} |
||||
|
|
||||
|
response = admin_client.post(url, payload) |
||||
|
assert response.status_code == 302 |
||||
|
|
||||
|
voto_ja_votou = VotoParlamentar.objects.get(ordem=ordem, parlamentar=ja_votou) |
||||
|
assert voto_ja_votou.voto == 'Sim' |
||||
|
|
||||
|
voto_novo = VotoParlamentar.objects.get(ordem=ordem, parlamentar=ainda_nao_votou) |
||||
|
assert voto_novo.voto == 'Não' |
||||
|
|
||||
|
registro = RegistroVotacao.objects.get(ordem=ordem) |
||||
|
assert registro.numero_votos_sim == 1 |
||||
|
assert registro.numero_votos_nao == 1 |
||||
|
|
||||
|
ordem.refresh_from_db() |
||||
|
assert ordem.votacao_aberta is False |
||||
|
assert ordem.registro_aberto is False |
||||
|
|
||||
|
# O 'Não Votou' enviado para quem já votou é só o valor padrão do |
||||
|
# <select>, 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 <select>). Esses registros persistiam |
||||
|
mesmo com o fechamento falhando (o bloco atomic não é revertido, já que |
||||
|
form_invalid retorna normalmente em vez de lançar), e nominal.html |
||||
|
desabilita o <select> de qualquer parlamentar com um VotoParlamentar |
||||
|
existente — travando o operador para sempre sem conseguir registrar |
||||
|
nenhum voto para essa matéria. |
||||
|
""" |
||||
|
sessao, ordem = _ordem_nominal_aberta() |
||||
|
parlamentar = _presente(sessao) |
||||
|
tipo_resultado = baker.make(TipoResultadoVotacao, nome='Aprovada', natureza='A') |
||||
|
|
||||
|
url = _registrar_url(sessao, ordem) |
||||
|
payload = { |
||||
|
'salvar-votacao': '1', |
||||
|
'resultado_votacao': str(tipo_resultado.pk), |
||||
|
'observacao': '', |
||||
|
'voto_parlamentar': ['Não Votou:{}'.format(parlamentar.pk)], |
||||
|
} |
||||
|
|
||||
|
response = admin_client.post(url, payload) |
||||
|
assert response.status_code == 302 |
||||
|
|
||||
|
assert not VotoParlamentar.objects.filter( |
||||
|
ordem=ordem, parlamentar=parlamentar).exists() |
||||
|
|
||||
|
ordem.refresh_from_db() |
||||
|
assert ordem.votacao_aberta is True |
||||
|
|
||||
|
# O operador consegue tentar de novo, agora com um voto real. |
||||
|
response2 = admin_client.post(url, { |
||||
|
'salvar-votacao': '1', |
||||
|
'resultado_votacao': str(tipo_resultado.pk), |
||||
|
'observacao': '', |
||||
|
'voto_parlamentar': ['Sim:{}'.format(parlamentar.pk)], |
||||
|
}) |
||||
|
assert response2.status_code == 302 |
||||
|
ordem.refresh_from_db() |
||||
|
assert ordem.votacao_aberta is False |
||||
|
assert VotoParlamentar.objects.get(ordem=ordem, parlamentar=parlamentar).voto == 'Sim' |
||||
|
|
||||
|
|
||||
|
@pytest.mark.django_db(transaction=False) |
||||
|
def test_status_da_votacao_reflete_troca_de_voto_do_parlamentar(admin_client): |
||||
|
""" |
||||
|
Regressão: a tela de registro (nominal.html) não atualizava a linha de |
||||
|
um parlamentar que trocou o voto durante a janela de votação — o poll |
||||
|
antigo só marcava "já votou" uma vez e nunca revisitava o valor. O poll |
||||
|
(?status=1) precisa sempre devolver o voto atual, não só se existe. |
||||
|
""" |
||||
|
sessao, ordem = _ordem_nominal_aberta() |
||||
|
parlamentar = _presente(sessao) |
||||
|
voto = baker.make(VotoParlamentar, ordem=ordem, parlamentar=parlamentar, |
||||
|
voto='Sim') |
||||
|
|
||||
|
url = _registrar_url(sessao, ordem) + '?status=1' |
||||
|
response = admin_client.get(url) |
||||
|
assert response.status_code == 200 |
||||
|
data = response.json() |
||||
|
assert data['votos'] == {str(parlamentar.pk): 'Sim'} |
||||
|
assert data['votacao_aberta'] is True |
||||
|
assert data['registro_aberto'] is False |
||||
|
assert data['ja_registrada'] is False |
||||
|
|
||||
|
voto.voto = 'Não' |
||||
|
voto.save() |
||||
|
|
||||
|
response2 = admin_client.get(url) |
||||
|
assert response2.json()['votos'] == {str(parlamentar.pk): 'Não'} |
||||
|
|
||||
|
|
||||
|
@pytest.mark.django_db(transaction=False) |
||||
|
def test_status_da_votacao_nao_depende_de_mostrar_voto(admin_client): |
||||
|
""" |
||||
|
O poll da tela de registro é só para a Mesa, não para o público — ao |
||||
|
contrário de sapl.painel:dados_painel, ele não pode mascarar o valor |
||||
|
real do voto por trás de "Voto Informado" mesmo quando a Casa configura |
||||
|
mostrar_voto=False (essa config controla o telão público, não a tela de |
||||
|
registro da própria Mesa). Como o endpoint nem consulta essa |
||||
|
configuração, isso é garantido por construção — este teste só |
||||
|
documenta a expectativa. |
||||
|
""" |
||||
|
sessao, ordem = _ordem_nominal_aberta() |
||||
|
parlamentar = _presente(sessao) |
||||
|
baker.make(VotoParlamentar, ordem=ordem, parlamentar=parlamentar, |
||||
|
voto='Abstenção') |
||||
|
|
||||
|
url = _registrar_url(sessao, ordem) + '?status=1' |
||||
|
data = admin_client.get(url).json() |
||||
|
assert data['votos'][str(parlamentar.pk)] == 'Abstenção' |
||||
|
|
||||
|
|
||||
|
@pytest.mark.django_db(transaction=False) |
||||
|
def test_status_da_votacao_nao_redireciona_apos_encerrar_votacao( |
||||
|
admin_client): |
||||
|
""" |
||||
|
_get_materia_votacao (usado pelo GET normal) redireciona com uma |
||||
|
mensagem quando a matéria já foi votada — comportamento certo para |
||||
|
navegação, errado para um poll em background. O branch ?status=1 não |
||||
|
pode herdar esse redirect. |
||||
|
""" |
||||
|
sessao, ordem = _ordem_nominal_aberta() |
||||
|
parlamentar = _presente(sessao) |
||||
|
tipo_resultado = baker.make(TipoResultadoVotacao, nome='Aprovada', |
||||
|
natureza='A') |
||||
|
|
||||
|
url = _registrar_url(sessao, ordem) |
||||
|
admin_client.post(url, { |
||||
|
'salvar-votacao': '1', |
||||
|
'resultado_votacao': str(tipo_resultado.pk), |
||||
|
'observacao': '', |
||||
|
'voto_parlamentar': ['Sim:{}'.format(parlamentar.pk)], |
||||
|
}) |
||||
|
|
||||
|
response = admin_client.get(url + '?status=1') |
||||
|
assert response.status_code == 200 |
||||
|
data = response.json() |
||||
|
assert data['ja_registrada'] is True |
||||
|
assert data['votacao_aberta'] is False |
||||
|
|
||||
|
|
||||
|
@pytest.mark.django_db(transaction=False) |
||||
|
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 |
||||
Loading…
Reference in new issue