Campaign e Tracking Links — Implementation Plan

Spec: .project/docs/specs/20260807111328_campaign_and_tracking_links.md Branch: 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 com pytest (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"


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.