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 |
|||
|
|||
|
|||
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__) |
|||
|
|||
@ -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