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 noCampaignAdmine a regra R-020 na documentação.
Spec:
.project/docs/specs/20260826093829_campaign_trial_locked_with_user_trials.mdBranch: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á filtradeleted_at=None— soft delete não trava None→ trial continua permitido: a primeira definição do trial não é uma trocaget_fieldsetsnão pode mutar ofieldsetsde 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
UserTrialativo vinculado à campanha,Campaign.trialnã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
UserTrialgravatrialecampaigncomo colunas independentes:UserTrialService.create_user_trialcopia o trial vigente e deriva a janela detrial.duration_days. Trocar o trial da campanha depois não propaga nada- Propagar também não seria correto:
started_at/expires_atdoUserTrialsã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
Trialapagado:Campaign.trialusaon_delete=SET_NULLe esse caminho não passa porclean()
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.