From 9d3687981bacbe385dfeee2c7457d9898c9b59f6 Mon Sep 17 00:00:00 2001 From: LeandroJatai Date: Mon, 16 Mar 2026 08:32:21 -0300 Subject: [PATCH] =?UTF-8?q?refact:=20aplica=20solicita=C3=A7=C3=B5es=20de?= =?UTF-8?q?=20reviewer=20e=20cria=20testes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- sapl/api/views_materia.py | 62 ++- sapl/materia/models.py | 28 +- .../tests/test_materia_proximo_numero.py | 385 ++++++++++++++++++ 3 files changed, 451 insertions(+), 24 deletions(-) create mode 100644 sapl/materia/tests/test_materia_proximo_numero.py diff --git a/sapl/api/views_materia.py b/sapl/api/views_materia.py index 805453ed7..c8bc24422 100644 --- a/sapl/api/views_materia.py +++ b/sapl/api/views_materia.py @@ -1,10 +1,9 @@ -from copy import deepcopy from django.apps.registry import apps -from django.db import transaction +from django.db import IntegrityError, transaction from django.db.models import Q from rest_framework.decorators import action -from rest_framework.status import HTTP_201_CREATED +from rest_framework.status import HTTP_201_CREATED, HTTP_409_CONFLICT from rest_framework.response import Response from drfautoapi.drfautoapi import ApiViewSetConstrutor, \ @@ -93,31 +92,62 @@ class _MateriaLegislativaViewSet: class Meta: ordering = ['-ano', 'tipo', 'numero'] + _MAX_RETRIES_NUMERO = 3 + @transaction.atomic def create(self, request, *args, **kwargs): - data = deepcopy(request.data) + data = dict(request.data) tipo = data.get('tipo', None) numero = data.get('numero', None) ano = data.get('ano', None) - if tipo: - numero, ano = MateriaLegislativa.get_proximo_numero( - tipo=tipo, - ano=ano, - numero_preferido=numero - ) - data['numero'] = numero - data['ano'] = ano - + if tipo and not numero: + # Número não fornecido pelo cliente: auto-gerar próximo disponível. + # select_for_update() em get_proximo_numero previne race conditions. + # Retry como camada extra de segurança contra IntegrityError residual. + for tentativa in range(self._MAX_RETRIES_NUMERO): + try: + with transaction.atomic(): + numero_gerado, ano_gerado = MateriaLegislativa.get_proximo_numero( + tipo=tipo, ano=ano + ) + data['numero'] = numero_gerado + data['ano'] = ano_gerado + + serializer = self.get_serializer(data=data) + serializer.is_valid(raise_exception=True) + self.perform_create(serializer) + headers = self.get_success_headers(serializer.data) + return Response(serializer.data, status=HTTP_201_CREATED, headers=headers) + except IntegrityError: + if tentativa == self._MAX_RETRIES_NUMERO - 1: + return Response( + {'detail': 'Não foi possível gerar um número único após ' + '%d tentativas. Tente novamente.' % self._MAX_RETRIES_NUMERO}, + status=HTTP_409_CONFLICT + ) + continue + + # Número fornecido pelo cliente (ou tipo ausente): + # respeitar os dados enviados. Se houver conflito de unicidade, + # retornar erro explícito em vez de sobrescrever silenciosamente. serializer = self.get_serializer(data=data) - serializer.is_valid(raise_exception=True) - self.perform_create(serializer) + try: + with transaction.atomic(): + self.perform_create(serializer) + except IntegrityError: + return Response( + {'numero': [ + 'O número %s já está em uso para este tipo/ano. ' + 'Remova o campo "numero" para auto-gerar o próximo disponível.' % numero + ]}, + status=HTTP_409_CONFLICT + ) headers = self.get_success_headers(serializer.data) return Response(serializer.data, status=HTTP_201_CREATED, headers=headers) - @action(detail=True, methods=['GET']) def ultima_tramitacao(self, request, *args, **kwargs): diff --git a/sapl/materia/models.py b/sapl/materia/models.py index efbe1a186..641a8e259 100644 --- a/sapl/materia/models.py +++ b/sapl/materia/models.py @@ -5,14 +5,17 @@ from django.contrib.contenttypes.fields import GenericRelation from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from django.db import models +from django.db.models import Max from django.db.models.functions import Concat from django.template import defaultfilters from django.utils import formats, timezone from django.utils.translation import ugettext_lazy as _ from model_utils import Choices -from sapl.base.models import SEQUENCIA_NUMERACAO_PROTOCOLO, Autor + +from sapl.base.models import SEQUENCIA_NUMERACAO_PROTOCOLO, Autor, AppConfig as BaseAppConfig from sapl.comissoes.models import Comissao, Reuniao +from sapl.parlamentares.models import Legislatura from sapl.compilacao.models import (PerfilEstruturalTextoArticulado, TextoArticulado) from sapl.parlamentares.models import Parlamentar @@ -389,17 +392,21 @@ class MateriaLegislativa(models.Model): Retorna o próximo número disponível para uma MateriaLegislativa baseado no tipo e nas configurações de numeração. + IMPORTANTE: Este método utiliza select_for_update() e DEVE ser + chamado dentro de uma transação (transaction.atomic) para garantir + proteção contra race conditions em acessos concorrentes. + Args: - tipo: TipoMateriaLegislativa - o tipo da matéria + tipo: TipoMateriaLegislativa ou int/str - o tipo da matéria ano: int - o ano da matéria (default: ano atual) - numero_preferido: int - número preferido/desejado (opcional) + numero_preferido: int - número preferido/desejado (opcional). + Se fornecido e disponível, será retornado. Caso contrário, + retorna o próximo sequencial. Returns: tuple[int, int]: Uma tupla contendo (numero, ano) da matéria. """ - from django.db.models import Max - from sapl.parlamentares.models import Legislatura - import sapl.base.models + if ano is None: ano = timezone.now().year @@ -407,7 +414,7 @@ class MateriaLegislativa(models.Model): # Obtém a configuração de numeração numeracao = None try: - numeracao = sapl.base.models.AppConfig.objects.last( + numeracao = BaseAppConfig.objects.last( ).sequencia_numeracao_protocolo except AttributeError: pass @@ -427,7 +434,12 @@ class MateriaLegislativa(models.Model): raise TipoMateriaLegislativa.DoesNotExist( _("TipoMateriaLegislativa with pk '%s' does not exist.") % tipo_id ) - + + # Lock na linha do TipoMateriaLegislativa para serializar + # gerações concorrentes de número do mesmo tipo. + # Requer que o chamador esteja dentro de transaction.atomic(). + TipoMateriaLegislativa.objects.select_for_update().get(pk=tipo.pk) + # O tipo pode sobrescrever a configuração global if tipo.sequencia_numeracao: numeracao = tipo.sequencia_numeracao diff --git a/sapl/materia/tests/test_materia_proximo_numero.py b/sapl/materia/tests/test_materia_proximo_numero.py new file mode 100644 index 000000000..d1d08f0a1 --- /dev/null +++ b/sapl/materia/tests/test_materia_proximo_numero.py @@ -0,0 +1,385 @@ +from datetime import date + +import pytest +from django.db import IntegrityError, transaction +from model_bakery import baker +from rest_framework.test import APIClient + +from sapl.base.models import AppConfig as BaseAppConfig +from sapl.materia.models import (MateriaLegislativa, RegimeTramitacao, + TipoMateriaLegislativa) +from sapl.parlamentares.models import Legislatura + + +# --------------------------------------------------------------------------- +# Helpers / fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture +def tipo_materia(db): + """TipoMateriaLegislativa sem sequencia_numeracao própria (usa global).""" + return baker.make(TipoMateriaLegislativa, descricao='Projeto de Lei', + sigla='PL', sequencia_numeracao='') + + +@pytest.fixture +def tipo_materia_anual(db): + return baker.make(TipoMateriaLegislativa, descricao='Requerimento', + sigla='REQ', sequencia_numeracao='A') + + +@pytest.fixture +def tipo_materia_unico(db): + return baker.make(TipoMateriaLegislativa, descricao='Indicação', + sigla='IND', sequencia_numeracao='U') + + +@pytest.fixture +def tipo_materia_legislatura(db): + return baker.make(TipoMateriaLegislativa, descricao='PEC', + sigla='PEC', sequencia_numeracao='L') + + +@pytest.fixture +def regime(db): + return baker.make(RegimeTramitacao, descricao='Normal') + + +@pytest.fixture +def app_config(db): + return BaseAppConfig.objects.create(sequencia_numeracao_protocolo='A') + + +def _criar_materia(tipo, numero, ano, regime, **kwargs): + """Atalho para criar MateriaLegislativa com campos obrigatórios.""" + defaults = dict( + tipo=tipo, + numero=numero, + ano=ano, + data_apresentacao=date(ano, 1, 1), + regime_tramitacao=regime, + ementa='Ementa de teste', + ) + defaults.update(kwargs) + return MateriaLegislativa.objects.create(**defaults) + + +# =========================================================================== +# Testes de get_proximo_numero – numeração por ano (padrão) +# =========================================================================== + +@pytest.mark.django_db(transaction=False) +def test_proximo_numero_primeiro_do_ano(tipo_materia_anual, regime, app_config): + """Sem matérias existentes, o primeiro número gerado deve ser 1.""" + with transaction.atomic(): + numero, ano = MateriaLegislativa.get_proximo_numero( + tipo=tipo_materia_anual, ano=2024) + assert numero == 1 + assert ano == 2024 + + +@pytest.mark.django_db(transaction=False) +def test_proximo_numero_sequencial(tipo_materia_anual, regime, app_config): + """Com matérias existentes 1, 2, 3 → próximo deve ser 4.""" + for n in (1, 2, 3): + _criar_materia(tipo_materia_anual, n, 2024, regime) + + with transaction.atomic(): + numero, ano = MateriaLegislativa.get_proximo_numero( + tipo=tipo_materia_anual, ano=2024) + assert numero == 4 + assert ano == 2024 + + +@pytest.mark.django_db(transaction=False) +def test_proximo_numero_ignora_outro_ano(tipo_materia_anual, regime, app_config): + """Matérias em anos diferentes não influenciam a sequência.""" + _criar_materia(tipo_materia_anual, 10, 2023, regime, + data_apresentacao=date(2023, 6, 1)) + _criar_materia(tipo_materia_anual, 1, 2024, regime) + + with transaction.atomic(): + numero, ano = MateriaLegislativa.get_proximo_numero( + tipo=tipo_materia_anual, ano=2024) + assert numero == 2 + + +@pytest.mark.django_db(transaction=False) +def test_proximo_numero_ignora_outro_tipo(tipo_materia_anual, regime, app_config): + """Matérias de outro tipo não influenciam a sequência.""" + outro_tipo = baker.make(TipoMateriaLegislativa, descricao='Moção', + sigla='MOC', sequencia_numeracao='A') + _criar_materia(outro_tipo, 50, 2024, regime) + _criar_materia(tipo_materia_anual, 3, 2024, regime) + + with transaction.atomic(): + numero, _ = MateriaLegislativa.get_proximo_numero( + tipo=tipo_materia_anual, ano=2024) + assert numero == 4 + + +# =========================================================================== +# Testes de get_proximo_numero – numeração única +# =========================================================================== + +@pytest.mark.django_db(transaction=False) +def test_proximo_numero_unico(tipo_materia_unico, regime, app_config): + """Numeração 'U' ignora o ano – sequência é global por tipo.""" + _criar_materia(tipo_materia_unico, 1, 2022, regime, + data_apresentacao=date(2022, 1, 1)) + _criar_materia(tipo_materia_unico, 2, 2023, regime, + data_apresentacao=date(2023, 1, 1)) + + with transaction.atomic(): + numero, _ = MateriaLegislativa.get_proximo_numero( + tipo=tipo_materia_unico, ano=2024) + assert numero == 3 + + +# =========================================================================== +# Testes de get_proximo_numero – numeração por legislatura +# =========================================================================== + +@pytest.mark.django_db(transaction=False) +def test_proximo_numero_por_legislatura(tipo_materia_legislatura, regime, + app_config): + """Numeração 'L' conta apenas matérias dentro da legislatura vigente.""" + baker.make(Legislatura, numero=1, + data_inicio=date(2021, 1, 1), + data_fim=date(2024, 12, 31), + data_eleicao=date(2020, 11, 15)) + + _criar_materia(tipo_materia_legislatura, 1, 2022, regime, + data_apresentacao=date(2022, 3, 1)) + _criar_materia(tipo_materia_legislatura, 2, 2023, regime, + data_apresentacao=date(2023, 5, 1)) + + with transaction.atomic(): + numero, _ = MateriaLegislativa.get_proximo_numero( + tipo=tipo_materia_legislatura, ano=2024) + assert numero == 3 + + +@pytest.mark.django_db(transaction=False) +def test_proximo_numero_legislatura_inexistente(tipo_materia_legislatura, + regime, app_config): + """Sem legislatura vigente, número inicia em 1.""" + with transaction.atomic(): + numero, _ = MateriaLegislativa.get_proximo_numero( + tipo=tipo_materia_legislatura, ano=2050) + assert numero == 1 + + +# =========================================================================== +# Testes de get_proximo_numero – numero_preferido +# =========================================================================== + +@pytest.mark.django_db(transaction=False) +def test_numero_preferido_disponivel(tipo_materia_anual, regime, app_config): + """Se o número preferido está disponível, deve ser retornado.""" + _criar_materia(tipo_materia_anual, 1, 2024, regime) + + with transaction.atomic(): + numero, _ = MateriaLegislativa.get_proximo_numero( + tipo=tipo_materia_anual, ano=2024, numero_preferido=5) + assert numero == 5 + + +@pytest.mark.django_db(transaction=False) +def test_numero_preferido_em_uso_retorna_sequencial(tipo_materia_anual, + regime, app_config): + """Se o número preferido já existe, retorna o sequencial.""" + _criar_materia(tipo_materia_anual, 5, 2024, regime) + + with transaction.atomic(): + numero, _ = MateriaLegislativa.get_proximo_numero( + tipo=tipo_materia_anual, ano=2024, numero_preferido=5) + assert numero == 6 + + +# =========================================================================== +# Testes de get_proximo_numero – resolução de tipo por pk (int/str) +# =========================================================================== + +@pytest.mark.django_db(transaction=False) +def test_tipo_passado_como_int(tipo_materia_anual, app_config): + """O tipo pode ser passado como int (pk).""" + with transaction.atomic(): + numero, _ = MateriaLegislativa.get_proximo_numero( + tipo=tipo_materia_anual.pk, ano=2024) + assert numero == 1 + + +@pytest.mark.django_db(transaction=False) +def test_tipo_passado_como_str(tipo_materia_anual, app_config): + """O tipo pode ser passado como str representando o pk.""" + with transaction.atomic(): + numero, _ = MateriaLegislativa.get_proximo_numero( + tipo=str(tipo_materia_anual.pk), ano=2024) + assert numero == 1 + + +@pytest.mark.django_db(transaction=False) +def test_tipo_inexistente_levanta_excecao(app_config): + """Pk inexistente deve levantar DoesNotExist.""" + with pytest.raises(TipoMateriaLegislativa.DoesNotExist): + with transaction.atomic(): + MateriaLegislativa.get_proximo_numero(tipo=99999, ano=2024) + + +@pytest.mark.django_db(transaction=False) +def test_tipo_invalido_levanta_validation_error(app_config): + """Tipo não conversível para int deve levantar ValidationError.""" + from django.core.exceptions import ValidationError + with pytest.raises(ValidationError): + with transaction.atomic(): + MateriaLegislativa.get_proximo_numero(tipo='abc', ano=2024) + + +# =========================================================================== +# Testes de get_proximo_numero – ano padrão +# =========================================================================== + +@pytest.mark.django_db(transaction=False) +def test_ano_none_usa_ano_atual(tipo_materia_anual, app_config): + """Se ano=None, deve usar o ano corrente.""" + from django.utils import timezone + with transaction.atomic(): + _, ano = MateriaLegislativa.get_proximo_numero( + tipo=tipo_materia_anual, ano=None) + assert ano == timezone.now().year + + +# =========================================================================== +# Testes de get_proximo_numero – select_for_update (proteção concorrência) +# =========================================================================== + +@pytest.mark.django_db(transaction=False) +def test_unique_together_protege_duplicata(tipo_materia_anual, regime, + app_config): + """O unique_together (tipo, numero, ano) impede duplicatas no banco.""" + _criar_materia(tipo_materia_anual, 1, 2024, regime) + with pytest.raises(IntegrityError): + with transaction.atomic(): + _criar_materia(tipo_materia_anual, 1, 2024, regime) + + +# =========================================================================== +# Testes de global config – tipo sem sequencia_numeracao usa global +# =========================================================================== + +@pytest.mark.django_db(transaction=False) +def test_tipo_sem_sequencia_usa_config_global(tipo_materia, regime, app_config): + """Tipo com sequencia_numeracao vazio deve usar a config global ('A').""" + _criar_materia(tipo_materia, 1, 2024, regime) + _criar_materia(tipo_materia, 2, 2024, regime) + _criar_materia(tipo_materia, 10, 2023, regime, + data_apresentacao=date(2023, 1, 1)) + + with transaction.atomic(): + numero, _ = MateriaLegislativa.get_proximo_numero( + tipo=tipo_materia, ano=2024) + # global default 'A' → sequencial por ano → MAX(2024) = 2 → próximo = 3 + assert numero == 3 + + +# =========================================================================== +# Testes do endpoint API create (MateriaLegislativaViewSet) +# =========================================================================== + +@pytest.fixture +def api_client_autenticado(db): + """APIClient autenticado como superusuário (tem todas as permissões).""" + from django.contrib.auth import get_user_model + User = get_user_model() + user = User.objects.create_superuser( + username='admin_api', password='secret123', email='a@b.com') + client = APIClient() + client.force_authenticate(user=user) + return client + + +@pytest.mark.django_db(transaction=False) +def test_api_create_sem_numero_auto_gera(api_client_autenticado, + tipo_materia_anual, regime, + app_config): + """POST sem 'numero' deve auto-gerar próximo sequencial.""" + _criar_materia(tipo_materia_anual, 1, 2024, regime) + + response = api_client_autenticado.post( + '/api/materia/materialegislativa/', + { + 'tipo': tipo_materia_anual.pk, + 'ano': 2024, + 'data_apresentacao': '2024-06-01', + 'regime_tramitacao': regime.pk, + 'ementa': 'Teste auto-gerar número', + }, + format='json', + ) + assert response.status_code == 201 + assert response.data['numero'] == 2 + + +@pytest.mark.django_db(transaction=False) +def test_api_create_com_numero_respeita_dado(api_client_autenticado, + tipo_materia_anual, regime, + app_config): + """POST com 'numero' explícito deve criar com o número informado.""" + response = api_client_autenticado.post( + '/api/materia/materialegislativa/', + { + 'tipo': tipo_materia_anual.pk, + 'numero': 42, + 'ano': 2024, + 'data_apresentacao': '2024-06-01', + 'regime_tramitacao': regime.pk, + 'ementa': 'Número exato', + }, + format='json', + ) + assert response.status_code == 201 + assert response.data['numero'] == 42 + + +@pytest.mark.django_db(transaction=False) +def test_api_create_numero_duplicado_retorna_erro(api_client_autenticado, + tipo_materia_anual, regime, + app_config): + """POST com número já existente retorna erro. + + O DRF detecta a violação de unique_together via UniqueTogetherValidator + no serializer e retorna 400 antes de chegar ao banco. + """ + _criar_materia(tipo_materia_anual, 42, 2024, regime) + + response = api_client_autenticado.post( + '/api/materia/materialegislativa/', + { + 'tipo': tipo_materia_anual.pk, + 'numero': 42, + 'ano': 2024, + 'data_apresentacao': '2024-06-01', + 'regime_tramitacao': regime.pk, + 'ementa': 'Duplicata', + }, + format='json', + ) + assert response.status_code == 400 + + +@pytest.mark.django_db(transaction=False) +def test_api_create_sem_tipo_valida_serializer(api_client_autenticado, regime): + """POST sem 'tipo' deve falhar na validação do serializer (400).""" + response = api_client_autenticado.post( + '/api/materia/materialegislativa/', + { + 'numero': 1, + 'ano': 2024, + 'data_apresentacao': '2024-06-01', + 'regime_tramitacao': regime.pk, + 'ementa': 'Sem tipo', + }, + format='json', + ) + assert response.status_code == 400