Browse Source

refact: aplica solicitações de reviewer e cria testes

pull/3822/head
LeandroJatai 2 days ago
parent
commit
9d3687981b
  1. 62
      sapl/api/views_materia.py
  2. 26
      sapl/materia/models.py
  3. 385
      sapl/materia/tests/test_materia_proximo_numero.py

62
sapl/api/views_materia.py

@ -1,10 +1,9 @@
from copy import deepcopy
from django.apps.registry import apps from django.apps.registry import apps
from django.db import transaction from django.db import IntegrityError, transaction
from django.db.models import Q from django.db.models import Q
from rest_framework.decorators import action 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 rest_framework.response import Response
from drfautoapi.drfautoapi import ApiViewSetConstrutor, \ from drfautoapi.drfautoapi import ApiViewSetConstrutor, \
@ -93,31 +92,62 @@ class _MateriaLegislativaViewSet:
class Meta: class Meta:
ordering = ['-ano', 'tipo', 'numero'] ordering = ['-ano', 'tipo', 'numero']
_MAX_RETRIES_NUMERO = 3
@transaction.atomic @transaction.atomic
def create(self, request, *args, **kwargs): def create(self, request, *args, **kwargs):
data = deepcopy(request.data) data = dict(request.data)
tipo = data.get('tipo', None) tipo = data.get('tipo', None)
numero = data.get('numero', None) numero = data.get('numero', None)
ano = data.get('ano', None) ano = data.get('ano', None)
if tipo: if tipo and not numero:
numero, ano = MateriaLegislativa.get_proximo_numero( # Número não fornecido pelo cliente: auto-gerar próximo disponível.
tipo=tipo, # select_for_update() em get_proximo_numero previne race conditions.
ano=ano, # Retry como camada extra de segurança contra IntegrityError residual.
numero_preferido=numero for tentativa in range(self._MAX_RETRIES_NUMERO):
) try:
data['numero'] = numero with transaction.atomic():
data['ano'] = ano 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 = self.get_serializer(data=data)
serializer.is_valid(raise_exception=True) 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) headers = self.get_success_headers(serializer.data)
return Response(serializer.data, status=HTTP_201_CREATED, headers=headers) return Response(serializer.data, status=HTTP_201_CREATED, headers=headers)
@action(detail=True, methods=['GET']) @action(detail=True, methods=['GET'])
def ultima_tramitacao(self, request, *args, **kwargs): def ultima_tramitacao(self, request, *args, **kwargs):

26
sapl/materia/models.py

@ -5,14 +5,17 @@ from django.contrib.contenttypes.fields import GenericRelation
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from django.db.models import Max
from django.db.models.functions import Concat from django.db.models.functions import Concat
from django.template import defaultfilters from django.template import defaultfilters
from django.utils import formats, timezone from django.utils import formats, timezone
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from model_utils import Choices 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.comissoes.models import Comissao, Reuniao
from sapl.parlamentares.models import Legislatura
from sapl.compilacao.models import (PerfilEstruturalTextoArticulado, from sapl.compilacao.models import (PerfilEstruturalTextoArticulado,
TextoArticulado) TextoArticulado)
from sapl.parlamentares.models import Parlamentar 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 Retorna o próximo número disponível para uma MateriaLegislativa
baseado no tipo e nas configurações de numeração. 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: 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) 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: Returns:
tuple[int, int]: Uma tupla contendo (numero, ano) da matéria. 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: if ano is None:
ano = timezone.now().year ano = timezone.now().year
@ -407,7 +414,7 @@ class MateriaLegislativa(models.Model):
# Obtém a configuração de numeração # Obtém a configuração de numeração
numeracao = None numeracao = None
try: try:
numeracao = sapl.base.models.AppConfig.objects.last( numeracao = BaseAppConfig.objects.last(
).sequencia_numeracao_protocolo ).sequencia_numeracao_protocolo
except AttributeError: except AttributeError:
pass pass
@ -428,6 +435,11 @@ class MateriaLegislativa(models.Model):
_("TipoMateriaLegislativa with pk '%s' does not exist.") % tipo_id _("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 # O tipo pode sobrescrever a configuração global
if tipo.sequencia_numeracao: if tipo.sequencia_numeracao:
numeracao = tipo.sequencia_numeracao numeracao = tipo.sequencia_numeracao

385
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
Loading…
Cancel
Save