Campaign e Tracking Links — Implementation Plan
Spec:
.project/docs/specs/20260807111328_campaign_and_tracking_links.mdBranch:feat/campaign-tracking-links
Goal: criar o app campaigns com os models Campaign e TrackingLink para gerenciar campanhas de marketing e seus links de rastreamento UTM.
Architecture: novo app Django apps/campaigns/ seguindo o layout de apps/trials/ (models em pacote, apps.py, migrations/). Ambos os models herdam de BaseModel (timestamps + soft-delete). Campaign tem slug único auto-gerado a partir do name (padrão do Course); TrackingLink referencia Campaign via FK CASCADE, permitindo múltiplos links por campanha.
Tech stack: Django 6, Django REST Framework, PostgreSQL, pytest + pytest-django.
Global constraints
- Código, nomes e testes em inglês; verbose_names em português (padrão do repo).
- Models herdam de
apps.common.models.BaseModel. - Testes em
tests/campaigns/, rodados compytest(DJANGO_SETTINGS_MODULE=config.settings.test). - Sem comentários explicativos de lógica (regra do projeto).
- Commits: uma linha, máx 60 chars, sem menção a IA.
Task 1: App campaigns + model Campaign
Files:
- Create: apps/campaigns/__init__.py (vazio)
- Create: apps/campaigns/apps.py
- Create: apps/campaigns/models/__init__.py
- Create: apps/campaigns/models/campaign.py
- Create: apps/campaigns/migrations/__init__.py (vazio)
- Create: apps/campaigns/migrations/0001_initial.py (gerada via makemigrations)
- Modify: config/settings/base.py
- Test: tests/campaigns/__init__.py, tests/campaigns/test_campaign_model.py
Interfaces:
- Produces: apps.campaigns.models.Campaign com campos name, status, start_date, end_date, trial, slug, Status TextChoices, save() com slug auto e clean() validando datas.
- [ ] Step 1: Write the failing test
tests/campaigns/__init__.py (vazio) e tests/campaigns/test_campaign_model.py:
```python from datetime import date
import pytest from django.core.exceptions import ValidationError
from apps.campaigns.models import Campaign
@pytest.mark.django_db def test_status_defaults_to_inactive(): campaign = Campaign.objects.create(name=”Black Friday”) assert campaign.status == Campaign.Status.INACTIVE
@pytest.mark.django_db def test_slug_is_autogenerated_from_name(): campaign = Campaign.objects.create(name=”Black Friday”) assert campaign.slug == “black-friday”
@pytest.mark.django_db def test_duplicate_names_generate_distinct_slugs(): first = Campaign.objects.create(name=”Black Friday”) second = Campaign.objects.create(name=”Black Friday”) assert first.slug == “black-friday” assert second.slug == “black-friday-2”
@pytest.mark.django_db def test_explicit_slug_is_preserved(): campaign = Campaign.objects.create(name=”Black Friday”, slug=”bf-2026”) assert campaign.slug == “bf-2026”
@pytest.mark.django_db def test_clean_rejects_end_date_before_start_date(): campaign = Campaign( name=”Black Friday”, start_date=date(2026, 11, 20), end_date=date(2026, 11, 10), ) with pytest.raises(ValidationError): campaign.full_clean()
@pytest.mark.django_db def test_trial_is_optional(): campaign = Campaign.objects.create(name=”No Trial”) assert campaign.trial is None ```
- [ ] Step 2: Run to verify it fails
bash
uv run pytest tests/campaigns/test_campaign_model.py
Expected: FAIL — ModuleNotFoundError: No module named 'apps.campaigns'
- [ ] Step 3: Write minimal implementation
apps/campaigns/apps.py:
```python from django.apps import AppConfig
class CampaignsConfig(AppConfig): default_auto_field = ‘django.db.models.BigAutoField’ name = ‘apps.campaigns’ verbose_name = “Campaigns” ```
apps/campaigns/models/campaign.py:
```python from django.core.exceptions import ValidationError from django.utils.text import slugify
from apps.common.models import BaseModel, models
class Campaign(BaseModel): class Status(models.TextChoices): ACTIVE = “active”, “Ativa” INACTIVE = “inactive”, “Inativa” ARCHIVED = “archived”, “Arquivada”
name = models.CharField(max_length=255, verbose_name="Nome")
status = models.CharField(
max_length=20,
choices=Status.choices,
default=Status.INACTIVE,
verbose_name="Status",
)
start_date = models.DateField(null=True, blank=True, verbose_name="Início")
end_date = models.DateField(null=True, blank=True, verbose_name="Fim")
trial = models.ForeignKey(
"trials.Trial",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="campaigns",
verbose_name="Trial",
)
slug = models.SlugField(max_length=255, unique=True, verbose_name="Slug")
class Meta:
verbose_name = "Campanha"
verbose_name_plural = "Campanhas"
ordering = ["-created_at"]
def __str__(self):
return f"{self.name} ({self.get_status_display()})"
def _generate_unique_slug(self) -> str:
base_slug = slugify(self.name) or "campaign"
slug = base_slug
i = 2
qs = self.__class__.all_objects.exclude(pk=self.pk)
while qs.filter(slug=slug).exists():
slug = f"{base_slug}-{i}"
i += 1
return slug
def save(self, *args, **kwargs):
if not self.slug:
self.slug = self._generate_unique_slug()
super().save(*args, **kwargs)
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."}) ```
apps/campaigns/models/__init__.py:
python
from .campaign import Campaign
Add to INSTALLED_APPS in config/settings/base.py, next to "apps.trials.apps.TrialsConfig":
python
"apps.campaigns.apps.CampaignsConfig",
Generate the migration:
bash
uv run python manage.py makemigrations campaigns
- [ ] Step 4: Run to verify it passes
bash
uv run pytest tests/campaigns/test_campaign_model.py
Expected: PASS
- [ ] Step 5: Commit
bash
git add apps/campaigns/__init__.py apps/campaigns/apps.py apps/campaigns/models/ apps/campaigns/migrations/ config/settings/base.py tests/campaigns/__init__.py tests/campaigns/test_campaign_model.py
git commit -m "feat: create campaign model"
Task 2: Model TrackingLink
Files:
- Create: apps/campaigns/models/tracking_link.py
- Modify: apps/campaigns/models/__init__.py
- Create: apps/campaigns/migrations/0002_trackinglink.py (gerada via makemigrations)
- Test: tests/campaigns/test_tracking_link_model.py
Interfaces:
- Consumes: apps.campaigns.models.Campaign
- Produces: apps.campaigns.models.TrackingLink com FK campaign (related_name tracking_links) e campos UTM.
- [ ] Step 1: Write the failing test
tests/campaigns/test_tracking_link_model.py:
```python import pytest
from apps.campaigns.models import Campaign, TrackingLink
@pytest.fixture def campaign(db): return Campaign.objects.create(name=”Black Friday”)
@pytest.mark.django_db def test_campaign_can_have_multiple_tracking_links(campaign): TrackingLink.objects.create( campaign=campaign, utm_source=”instagram”, utm_medium=”social”, final_link=”https://onion.app/bf?utm_source=instagram”, ) TrackingLink.objects.create( campaign=campaign, utm_source=”google”, utm_medium=”cpc”, final_link=”https://onion.app/bf?utm_source=google”, ) assert campaign.tracking_links.count() == 2
@pytest.mark.django_db def test_optional_utm_fields_default_to_blank(campaign): link = TrackingLink.objects.create( campaign=campaign, utm_source=”instagram”, utm_medium=”social”, final_link=”https://onion.app/bf”, ) assert link.source_channel == “” assert link.utm_content == “” assert link.utm_term == “”
@pytest.mark.django_db def test_deleting_campaign_cascades_to_links(campaign): TrackingLink.objects.create( campaign=campaign, utm_source=”instagram”, utm_medium=”social”, final_link=”https://onion.app/bf”, ) campaign.hard_delete() assert TrackingLink.all_objects.filter(campaign_id=campaign.id).count() == 0 ```
- [ ] Step 2: Run to verify it fails
bash
uv run pytest tests/campaigns/test_tracking_link_model.py
Expected: FAIL — ImportError: cannot import name 'TrackingLink' from 'apps.campaigns.models'
- [ ] Step 3: Write minimal implementation
apps/campaigns/models/tracking_link.py:
```python from apps.campaigns.models.campaign import Campaign from apps.common.models import BaseModel, models
class TrackingLink(BaseModel): campaign = models.ForeignKey( Campaign, on_delete=models.CASCADE, related_name=”tracking_links”, verbose_name=”Campanha”, ) source_channel = models.CharField(max_length=100, blank=True, verbose_name=”Canal de origem”) utm_source = models.CharField(max_length=255, verbose_name=”UTM Source”) utm_medium = models.CharField(max_length=255, verbose_name=”UTM Medium”) utm_content = models.CharField(max_length=255, blank=True, verbose_name=”UTM Content”) utm_term = models.CharField(max_length=255, blank=True, verbose_name=”UTM Term”) final_link = models.URLField(max_length=2048, verbose_name=”Link final”)
class Meta:
verbose_name = "Link de rastreamento"
verbose_name_plural = "Links de rastreamento"
ordering = ["-created_at"]
def __str__(self):
return f"{self.campaign.name} — {self.utm_source}/{self.utm_medium}" ```
apps/campaigns/models/__init__.py:
python
from .campaign import Campaign
from .tracking_link import TrackingLink
Generate the migration:
bash
uv run python manage.py makemigrations campaigns
- [ ] Step 4: Run to verify it passes
bash
uv run pytest tests/campaigns/test_tracking_link_model.py
Expected: PASS
- [ ] Step 5: Commit
bash
git add apps/campaigns/models/ apps/campaigns/migrations/ tests/campaigns/test_tracking_link_model.py
git commit -m "feat: create tracking link model"
Task 3: Full verification
- [ ] Step 1: Run the whole campaigns suite + Django checks
bash
uv run pytest tests/campaigns/
uv run python manage.py makemigrations --check --dry-run
uv run python manage.py check
Expected: todos os testes PASS; makemigrations --check sem migrations pendentes; check sem erros.
Self-review
Spec coverage:
- campaign table (name, status enum, start/end date, trial FK opcional, slug único) → Task 1 ✓
- tracking_links table (campaign FK, source_channel, utm_source/medium obrigatórios, utm_content/term opcionais, final_link) → Task 2 ✓
- Múltiplos links por campanha → Task 2 test test_campaign_can_have_multiple_tracking_links ✓
- source_channel em tracking_links → Task 2 ✓
- Registro em INSTALLED_APPS → Task 1 ✓
- Verificação (makemigrations/migrate/check) → Task 3 ✓
Placeholder scan: nenhum TBD/TODO; todo código presente.
Type consistency: Campaign (Task 1) consumido em Task 2 com os mesmos campos; related_name="tracking_links" usado consistentemente nos testes; all_objects/hard_delete vêm de BaseModel.