Trial da campanha travado com usuários cadastrados — Plano de implementação

TLDR: Três tasks TDD — validação em Campaign.clean(), travamento com aviso no CampaignAdmin e a regra R-020 na documentação.

Spec: .project/docs/specs/20260826093829_campaign_trial_locked_with_user_trials.md Branch: feature/campaign-trial-lock (já criada)

Arquitetura: A regra vive no model: Campaign.clean() compara o trial_id persistido com o valor em memória e recusa a mudança quando existe UserTrial ativo na campanha. O admin consome duas properties do model (has_user_trials, user_trials_count) para tornar trial readonly e injetar a descrição de aviso no fieldset — sem duplicar a decisão.

Stack: Django 5.2, pytest-django. Sem migration (nenhum campo novo).

Restrições globais

  • A contagem usa o related manager default (campaign.user_trials), que já filtra deleted_at=None — soft delete não trava
  • None → trial continua permitido: a primeira definição do trial não é uma troca
  • get_fieldsets não pode mutar o fieldsets de classe (dict compartilhado entre requests) — sempre criar novo dict
  • Sem migration: apenas clean() e properties, nada de schema

Task 1: Validação da troca de trial no model

Files: - Modify: apps/campaigns/models/campaign.py - Test: tests/campaigns/test_campaign_model.py

Interfaces: - Consumes: UserTrial.campaign (related_name="user_trials"), fixtures make_trial e make_user_trial de tests/conftest.py - Produces: Campaign.has_user_trials (bool), Campaign.user_trials_count (int), Campaign.TRIAL_LOCKED_ERROR (str template com {count})

  • [x] Step 1: Write the failing tests

Acrescentar ao final de tests/campaigns/test_campaign_model.py:

```python @pytest.mark.django_db def test_clean_blocks_trial_swap_when_campaign_has_user_trials(make_trial, make_user_trial): # arrange current_trial = make_trial(name=”Trial atual”) other_trial = make_trial(name=”Trial novo”) campaign = Campaign.objects.create(name=”Black Friday”, trial=current_trial) make_user_trial(trial=current_trial, campaign=campaign) campaign.trial = other_trial

# act / assert
with pytest.raises(ValidationError) as error:
    campaign.full_clean()
assert "trial" in error.value.message_dict

@pytest.mark.django_db def test_clean_blocks_clearing_trial_when_campaign_has_user_trials(make_trial, make_user_trial): # arrange trial = make_trial() campaign = Campaign.objects.create(name=”Black Friday”, trial=trial) make_user_trial(trial=trial, campaign=campaign) campaign.trial = None

# act / assert
with pytest.raises(ValidationError) as error:
    campaign.full_clean()
assert "trial" in error.value.message_dict

@pytest.mark.django_db def test_clean_allows_first_trial_assignment_even_with_user_trials(make_trial, make_user_trial): # arrange campaign = Campaign.objects.create(name=”Black Friday”) make_user_trial(campaign=campaign) campaign.trial = make_trial(name=”Trial novo”)

# act
campaign.full_clean()

# assert
assert campaign.trial is not None

@pytest.mark.django_db def test_clean_allows_trial_swap_when_campaign_has_no_user_trials(make_trial): # arrange campaign = Campaign.objects.create(name=”Black Friday”, trial=make_trial(name=”Trial atual”)) other_trial = make_trial(name=”Trial novo”) campaign.trial = other_trial

# act
campaign.full_clean()

# assert
assert campaign.trial == other_trial

@pytest.mark.django_db def test_clean_allows_trial_swap_when_user_trial_is_soft_deleted(make_trial, make_user_trial): # arrange current_trial = make_trial(name=”Trial atual”) campaign = Campaign.objects.create(name=”Black Friday”, trial=current_trial) make_user_trial(trial=current_trial, campaign=campaign).delete() other_trial = make_trial(name=”Trial novo”) campaign.trial = other_trial

# act
campaign.full_clean()

# assert
assert campaign.trial == other_trial

@pytest.mark.django_db def test_clean_allows_editing_other_fields_when_trial_is_locked(make_trial, make_user_trial): # arrange trial = make_trial() campaign = Campaign.objects.create(name=”Black Friday”, trial=trial) make_user_trial(trial=trial, campaign=campaign) campaign.name = “Black Friday 2026”

# act
campaign.full_clean()

# assert
assert campaign.name == "Black Friday 2026"

@pytest.mark.django_db def test_has_user_trials_is_false_for_unsaved_campaign(make_trial): # arrange campaign = Campaign(name=”Black Friday”, trial=make_trial())

# act
result = campaign.has_user_trials

# assert
assert result is False ```
  • [x] Step 2: Run to verify it fails

bash make run.test path=tests/campaigns/test_campaign_model.py

Expected: FAIL — AttributeError: 'Campaign' object has no attribute 'has_user_trials' nos testes novos e DID NOT RAISE ValidationError nos dois primeiros.

  • [x] Step 3: Write minimal implementation

Em apps/campaigns/models/campaign.py, dentro da classe Campaign:

```python TRIAL_LOCKED_ERROR = ( “Não é possível alterar o trial: {count} usuário(s) já se cadastraram por esta campanha. “ “Os trials já concedidos não são atualizados quando o trial da campanha muda.” )

@property
def user_trials_count(self) -> int:
    if not self.pk:
        return 0
    return self.user_trials.count()

@property
def has_user_trials(self) -> bool:
    return self.user_trials_count > 0 ```

e trocar clean() por:

```python def clean(self): super().clean() if self.start_date and self.end_date and self.end_date <= self.start_date: raise ValidationError({“end_date”: “A data de fim deve ser posterior à data de início.”}) self._validate_trial_is_not_locked()

def _validate_trial_is_not_locked(self):
    if not self.pk:
        return

    stored_trial_id = (
        self.__class__.all_objects.filter(pk=self.pk).values_list("trial_id", flat=True).first()
    )
    if stored_trial_id in (None, self.trial_id):
        return

    count = self.user_trials_count
    if count:
        raise ValidationError({"trial": self.TRIAL_LOCKED_ERROR.format(count=count)}) ```

stored_trial_id in (None, self.trial_id) cobre os dois casos liberados de uma vez: campanha que ainda não tinha trial (None → trial) e campanha em que o trial não mudou.

  • [x] Step 4: Run to verify it passes

bash make run.test path=tests/campaigns/test_campaign_model.py

Expected: PASS

  • [x] Step 5: Commit

bash git add apps/campaigns/models/campaign.py tests/campaigns/test_campaign_model.py git commit -m "feat: lock campaign trial when user trials exist"


Task 2: Trial readonly e aviso no admin

Files: - Modify: apps/campaigns/admin.py - Test: tests/campaigns/test_admin.py (criar)

Interfaces: - Consumes: Campaign.has_user_trials, Campaign.user_trials_count (Task 1) - Produces: CampaignAdmin.TRIAL_LOCKED_WARNING, CampaignAdmin.get_readonly_fields, CampaignAdmin.get_fieldsets

  • [x] Step 1: Write the failing test

Criar tests/campaigns/test_admin.py:

```python import pytest from django.contrib.admin.sites import AdminSite from django.test import RequestFactory

from apps.campaigns.admin import CampaignAdmin from apps.campaigns.models import Campaign

pytestmark = pytest.mark.django_db

def _description(fieldsets, section): return dict(fieldsets)[section].get(“description”)

def test_trial_is_readonly_when_campaign_has_user_trials(rf: RequestFactory, user, make_trial, make_user_trial): # arrange trial = make_trial() campaign = Campaign.objects.create(name=”Black Friday”, trial=trial) make_user_trial(trial=trial, campaign=campaign) request = rf.get(“/admin/campaigns/campaign/”) request.user = user

# act
readonly_fields = CampaignAdmin(Campaign, AdminSite()).get_readonly_fields(request, campaign)

# assert
assert "trial" in readonly_fields

def test_trial_is_editable_when_campaign_has_no_user_trials(rf: RequestFactory, user, make_trial): # arrange campaign = Campaign.objects.create(name=”Black Friday”, trial=make_trial()) request = rf.get(“/admin/campaigns/campaign/”) request.user = user

# act
readonly_fields = CampaignAdmin(Campaign, AdminSite()).get_readonly_fields(request, campaign)

# assert
assert "trial" not in readonly_fields

def test_trial_is_editable_on_add_form(rf: RequestFactory, user): # arrange request = rf.get(“/admin/campaigns/campaign/add/”) request.user = user

# act
readonly_fields = CampaignAdmin(Campaign, AdminSite()).get_readonly_fields(request, None)

# assert
assert "trial" not in readonly_fields

def test_fieldset_warns_with_user_trials_count(rf: RequestFactory, user, make_trial, make_user_trial): # arrange trial = make_trial() campaign = Campaign.objects.create(name=”Black Friday”, trial=trial) make_user_trial(trial=trial, campaign=campaign) request = rf.get(“/admin/campaigns/campaign/”) request.user = user

# act
fieldsets = CampaignAdmin(Campaign, AdminSite()).get_fieldsets(request, campaign)

# assert
assert CampaignAdmin.TRIAL_LOCKED_WARNING.format(count=1) in _description(fieldsets, "Informações gerais")

def test_fieldset_has_no_warning_without_user_trials(rf: RequestFactory, user, make_trial): # arrange campaign = Campaign.objects.create(name=”Black Friday”, trial=make_trial()) request = rf.get(“/admin/campaigns/campaign/”) request.user = user

# act
fieldsets = CampaignAdmin(Campaign, AdminSite()).get_fieldsets(request, campaign)

# assert
assert _description(fieldsets, "Informações gerais") is None

def test_class_fieldsets_are_not_mutated(rf: RequestFactory, user, make_trial, make_user_trial): # arrange trial = make_trial() campaign = Campaign.objects.create(name=”Black Friday”, trial=trial) make_user_trial(trial=trial, campaign=campaign) request = rf.get(“/admin/campaigns/campaign/”) request.user = user admin_instance = CampaignAdmin(Campaign, AdminSite())

# act
admin_instance.get_fieldsets(request, campaign)

# assert
assert _description(CampaignAdmin.fieldsets, "Informações gerais") is None ```
  • [x] Step 2: Run to verify it fails

bash make run.test path=tests/campaigns/test_admin.py

Expected: FAIL — AttributeError: type object 'CampaignAdmin' has no attribute 'TRIAL_LOCKED_WARNING'.

  • [x] Step 3: Write minimal implementation

Em apps/campaigns/admin.py, importar format_html (from django.utils.html import format_html) e, dentro de CampaignAdmin:

```python GENERAL_FIELDSET = “Informações gerais” TRIAL_LOCKED_WARNING = ( “A troca do trial está travada: {count} usuário(s) já se cadastraram por esta campanha. “ “Trocar o trial não atualiza os trials já concedidos — crie uma nova campanha “ “para oferecer um trial diferente.” )

def get_readonly_fields(self, request, obj=None):
    readonly_fields = list(super().get_readonly_fields(request, obj))
    if obj and obj.has_user_trials:
        readonly_fields.append("trial")
    return readonly_fields

def get_fieldsets(self, request, obj=None):
    fieldsets = super().get_fieldsets(request, obj)
    if not (obj and obj.has_user_trials):
        return fieldsets

    warning = format_html(
        '<div style="font-size: 13px; color: #7c4a03; background: #fff8e1; '
        'border-left: 4px solid #b45309; border-radius: 4px; padding: 8px 12px;">{}</div>',
        self.TRIAL_LOCKED_WARNING.format(count=obj.user_trials_count),
    )
    return [
        (name, {**options, "description": warning} if name == self.GENERAL_FIELDSET else options)
        for name, options in fieldsets
    ] ```

A description do fieldset renderiza no topo da seção (comportamento fixo do Django admin), então o aviso aparece acima dos campos, como caixa destacada. Por isso o teste usa in em vez de ==: a description carrega o HTML do <div> ao redor do texto.

  • [x] Step 4: Run to verify it passes

bash make run.test path=tests/campaigns

Expected: PASS

  • [x] Step 5: Commit

bash git add apps/campaigns/admin.py tests/campaigns/test_admin.py git commit -m "feat: warn and lock trial field in campaign admin"

O ajuste visual do aviso (caixa destacada, fonte 13px, texto “A troca do trial está travada”) veio depois, em commit separado sobre os mesmos arquivos:

bash git commit -m "style: highlight campaign trial lock warning"


Task 3: Documentar a regra R-020

Files: - Create: .project/docs/rules/campaigns/campaign_trial_locked_with_user_trials.md - Modify: .project/docs/RULES.md

Interfaces: - Consumes: comportamento entregue nas Tasks 1 e 2 - Produces: regra R-020 indexada

  • [x] Step 1: Escrever a regra

.project/docs/rules/campaigns/campaign_trial_locked_with_user_trials.md:

```markdown

id: R-020 title: Trial da campanha é travado quando há usuários cadastrados scope: campaigns certainty: high created: 2026-08-26 updated: 2026-08-26 —

R-020 — Trial da campanha é travado quando há usuários cadastrados

TLDR: A partir do primeiro UserTrial ativo vinculado à campanha, Campaign.trial não pode mais ser trocado nem esvaziado. Campaign.clean() recusa a mudança e o admin exibe o campo como leitura, com a contagem de usuários e o motivo.

Given / When / Then

Dado uma campanha com trial definido e ao menos um UserTrial ativo com campaign apontando para ela Quando alguém altera Campaign.trial para outro trial (ou para vazio) e chama full_clean() Então é levantado ValidationError no campo trial informando quantos usuários já se cadastraram

Dado a mesma campanha Quando um administrador abre dash.*/admin/campaigns/campaign/<id>/change/ Então trial aparece como texto de leitura e a seção “Informações gerais” exibe o aviso com a contagem de usuários

Dado uma campanha sem trial definido, mesmo com UserTrial vinculados Quando o trial é definido pela primeira vez Então a alteração é permitida — a primeira definição não é uma troca

Dado uma campanha cujo único UserTrial foi soft-deletado Quando o trial é trocado Então a alteração é permitida — a contagem considera apenas registros ativos

Restrições

  • UserTrial grava trial e campaign como colunas independentes: UserTrialService.create_user_trial copia o trial vigente e deriva a janela de trial.duration_days. Trocar o trial da campanha depois não propaga nada
  • Propagar também não seria correto: started_at/expires_at do UserTrial são registro histórico e imutáveis (R-018)
  • Os demais campos da campanha (name, status, start_date, end_date) seguem editáveis com usuários cadastrados
  • Para oferecer outro trial ao mesmo público, crie uma nova campanha
  • A regra não cobre Trial apagado: Campaign.trial usa on_delete=SET_NULL e esse caminho não passa por clean()

Teste vinculado

tests/campaigns/test_campaign_model.py — test_clean_blocks_trial_swap_when_campaign_has_user_trials, test_clean_blocks_clearing_trial_when_campaign_has_user_trials, test_clean_allows_first_trial_assignment_even_with_user_trials, test_clean_allows_trial_swap_when_user_trial_is_soft_deleted tests/campaigns/test_admin.py — test_trial_is_readonly_when_campaign_has_user_trials, test_fieldset_warns_with_user_trials_count ```

  • [x] Step 2: Indexar em RULES.md

Acrescentar após a linha do R-019:

markdown | R-020 | Trial da campanha é travado quando há usuários cadastrados | campaigns | high | [rules/campaigns/campaign_trial_locked_with_user_trials.md](rules/campaigns/campaign_trial_locked_with_user_trials.md) |

  • [x] Step 3: Rodar a suíte completa

bash make run.ci_local

Expected: PASS (mesmo baseline de antes da mudança)

  • [x] Step 4: Commit

bash git add .project/docs/rules/campaigns .project/docs/RULES.md git commit -m "docs: rule R-020 campaign trial lock"


Sequenciamento

Task 1 → Task 2 (o admin depende das properties) → Task 3. Não há paralelismo útil: são três arquivos de produção pequenos numa dependência linear.

Resultado

Implementado na branch feature/campaign-trial-lock — PR #161.

Commit Conteúdo
d4bf9b7 feat: lock campaign trial when user trials exist (Task 1)
edaf0c1 feat: warn and lock trial field in campaign admin (Task 2)
04fb1fc docs: rule R-020 campaign trial lock (Task 3)
79649fe style: highlight campaign trial lock warning (ajuste visual do aviso)

make run.ci_local verde (audit + lint + test); tests/campaigns com 28 testes passando.