Trial da campanha é travado quando há usuários cadastrados

TLDR: Campaign.trial passa a ser imutável a partir do primeiro UserTrial cadastrado na campanha, porque trocar o trial não atualiza — nem deveria atualizar — os UserTrial já concedidos.

Contexto

Campaign.trial é um FK opcional e hoje é livremente editável no admin. UserTrial grava trial e campaign como colunas independentes: no cadastro, UserTrialService copia o trial vigente da campanha para o UserTrial e deriva started_at/expires_at a partir de trial.duration_days (janela imutável, R-018).

Consequência: trocar o trial de uma campanha em andamento não propaga nada. Os UserTrial existentes continuam apontando para o trial anterior, com a janela e as permissões do trial anterior, enquanto a campanha passa a exibir outro trial. E propagar também não seria correto — a janela concedida é registro histórico e não pode ser reescrita.

Como não há atualização possível nem desejável, a solução é impedir a troca e explicar o motivo na tela.

Objetivos

  • Bloquear qualquer alteração de Campaign.trial quando a campanha já tem ao menos um UserTrial ativo
  • Exibir no admin uma caixa de aviso explicando por que o campo está travado, com a contagem de usuários cadastrados
  • Manter livre a primeira definição do trial (None → trial), quando ainda não há cadastros

Fora de escopo

  • Migrar ou recalcular UserTrial existentes
  • Alterar o comportamento de on_delete=SET_NULL quando o Trial em si é apagado
  • Bloquear a edição de outros campos da campanha (name, status, datas, slug) — seguem editáveis
  • Bloquear a edição do próprio Trial (duração, features), que já tem regras próprias

Regra

Transição Sem UserTrial Com ao menos 1 UserTrial
None → trial A permitido permitido
trial A → trial B permitido bloqueado
trial A → None permitido bloqueado

A contagem usa o manager default (objects), ou seja apenas UserTrial não soft-deletados — um cadastro removido não trava a campanha.

Mudanças

apps/campaigns/models/campaign.py - clean() passa a validar a troca do trial: se self.pk existe, o trial_id gravado no banco difere do atual e self.user_trials.exists(), levanta ValidationError({"trial": ...}) com a mensagem explicando que existem usuários cadastrados - Adiciona helpers has_user_trials e user_trials_count (properties) para o admin reusar a mesma checagem

apps/campaigns/admin.py - get_readonly_fields acrescenta "trial" quando o objeto em edição já tem UserTrial - get_fieldsets injeta na seção “Informações gerais” a description com o aviso, renderizado via format_html como caixa destacada (fundo âmbar claro, borda esquerda, fonte 13px): “A troca do trial está travada: N 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.”

tests/campaigns/test_campaign_model.py - Cobertura das três transições da tabela acima, mais o caso soft-deletado e o caso “salvar sem mexer no trial continua permitido”

tests/campaigns/test_admin.py (novo) - trial está em get_readonly_fields quando há UserTrial, e não está quando não há - get_fieldsets traz a description de aviso apenas no caso travado, sem mutar o fieldsets de classe

Como verificar

  1. make run.test path=tests/campaigns
  2. No admin (dash.*/admin/campaigns/campaign/): criar campanha sem trial, salvar, definir um trial → salva normal
  3. Cadastrar um usuário pela campanha (fluxo de registro de trial), reabrir a campanha → campo Trial aparece como leitura, com a caixa de aviso e a contagem
  4. Via shell (make run.console): campaign.trial = other; campaign.full_clean() → ValidationError no campo trial

Documentação

  • Nova regra em .project/docs/rules/campaigns/campaign_trial_locked_with_user_trials.md (R-020)
  • Índice .project/docs/RULES.md atualizado com a linha R-020