feat(affiliate): affiliate programs management + frontmatter bugfix

Centralises retailer config in affiliate_programs table (URL template,
tracking tag, commission %). Products now use program dropdown + product
identifier instead of manual URL baking. URL assembled at redirect time
via build_affiliate_url() — changing a tag propagates to all products
instantly. Backward compatible: legacy baked-URL products fall through
unchanged. Amazon OneLink (configured in Associates dashboard) handles
geo-redirect to local marketplaces with no additional programs needed.

Also fixes _rebuild_article() frontmatter rendering bug.

Commits: fix frontmatter, migration 0027, program CRUD functions,
redirect update, admin CRUD + templates, product form update, tests.
41 tests, all passing. Ruff clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Deeman
2026-02-28 22:54:28 +01:00
12 changed files with 986 additions and 40 deletions

View File

@@ -6,6 +6,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased]
### Added
- **Affiliate programs management** — centralised retailer config (`affiliate_programs` table) with URL template + tracking tag + commission %. Products now use a program dropdown + product identifier (e.g. ASIN) instead of manually baking full URLs. URL is assembled at redirect time via `build_affiliate_url()`, so changing a tag propagates instantly to all products. Legacy products (baked `affiliate_url`) continue to work via fallback. Amazon OneLink configured in the Associates dashboard handles geo-redirect to local marketplaces — no per-country programs needed.
- `web/src/padelnomics/migrations/versions/0027_affiliate_programs.py`: `affiliate_programs` table, nullable `program_id` + `product_identifier` columns on `affiliate_products`, seeds "Amazon" program, backfills ASINs from existing URLs
- `web/src/padelnomics/affiliate.py`: `get_all_programs()`, `get_program()`, `get_program_by_slug()`, `build_affiliate_url()`; `get_product()` JOINs program for redirect assembly; `_parse_product()` extracts `_program` sub-dict
- `web/src/padelnomics/app.py`: `/go/<slug>` uses `build_affiliate_url()` — program-based products get URLs assembled at redirect time
- `web/src/padelnomics/admin/routes.py`: program CRUD routes (list, new, edit, delete — delete blocked if products reference the program); product form updated to program dropdown + identifier; `retailer` auto-populated from program name
- New templates: `admin/affiliate_programs.html`, `admin/affiliate_program_form.html`, `admin/partials/affiliate_program_results.html`
- Updated templates: `admin/affiliate_form.html` (program dropdown + JS toggle), `admin/base_admin.html` (Programs subnav tab)
- 15 new tests in `web/tests/test_affiliate.py` (41 total)
### Fixed
- **Article preview frontmatter bug** — `_rebuild_article()` in `admin/routes.py` now strips YAML frontmatter before passing markdown to `mistune.html()`, preventing raw `title:`, `slug:` etc. from appearing as visible text in article previews.
### Added
- **Affiliate product system** — "Wirecutter for padel" editorial affiliate cards embedded in articles via `[product:slug]` and `[product-group:category]` markers, baked at build time into static HTML. `/go/<slug>` click-tracking redirect (302, GDPR-compliant daily-rotated IP hash). Admin CRUD (`/admin/affiliate`) with live preview, inline status toggle, HTMX search/filter. Click stats dashboard (pure CSS bar chart, top products/articles/retailers). 10 German equipment review article scaffolds seeded.
- `web/src/padelnomics/migrations/versions/0026_affiliate_products.py`: `affiliate_products` + `affiliate_clicks` tables; `UNIQUE(slug, language)` constraint mirrors articles schema

View File

@@ -1,7 +1,7 @@
# Padelnomics — Project Tracker
> Move tasks across columns as you work. Add new tasks at the top of the relevant column.
> Last updated: 2026-02-28 (Affiliate product system — editorial gear cards + click tracking).
> Last updated: 2026-02-28 (Affiliate programs management — centralised retailer config + URL template assembly).
---
@@ -133,6 +133,7 @@
- [x] **group_key static article grouping** — migration 0020 adds `group_key TEXT` column; `_sync_static_articles()` auto-upserts `data/content/articles/*.md` on admin page load; `_get_article_list_grouped()` groups by `COALESCE(group_key, url_path)` so EN/DE static cornerstones pair into one row
- [x] **Email-gated report PDF**`reports/` blueprint with email capture gate + PDF download; premium WeasyPrint PDF (full-bleed navy cover, Padelnomics wordmark watermark, gold/teal accents); `make report-pdf` target; EN + DE i18n (26 keys, native German); state-of-padel report moved to `data/content/reports/`
- [x] **Affiliate product system** — "Wirecutter for padel" editorial gear cards embedded in articles via `[product:slug]` / `[product-group:category]` markers, baked at build time; `/go/<slug>` click-tracking redirect (302, GDPR daily-rotated IP hash, rate-limited); admin CRUD with live preview, HTMX filter/search, status toggle; click stats dashboard (pure CSS charts); 10 German equipment review article scaffolds; 26 tests
- [x] **Affiliate programs management**`affiliate_programs` table centralises retailer configs (URL template, tracking tag, commission %); product form uses program dropdown + product identifier (ASIN etc.); `build_affiliate_url()` assembles at redirect time; legacy baked-URL products still work; admin CRUD (delete blocked if products reference program); Amazon OneLink for multi-marketplace; article frontmatter preview bug fixed; 41 tests
### SEO & Legal
- [x] Sitemap (both language variants, `<lastmod>` on all entries)

View File

@@ -2769,7 +2769,10 @@ async def _rebuild_article(article_id: int):
md_path = Path("data/content/articles") / f"{article['slug']}.md"
if not md_path.exists():
return
body_html = mistune.html(md_path.read_text())
raw = md_path.read_text()
m = _FRONTMATTER_RE.match(raw)
body = raw[m.end():] if m else raw
body_html = mistune.html(body)
lang = article.get("language", "en") if hasattr(article, "get") else "en"
body_html = await bake_scenario_cards(body_html, lang=lang)
body_html = await bake_product_cards(body_html, lang=lang)
@@ -3254,6 +3257,210 @@ async def outreach_import():
AFFILIATE_CATEGORIES = ("racket", "ball", "shoe", "bag", "grip", "eyewear", "accessory")
AFFILIATE_STATUSES = ("draft", "active", "archived")
AFFILIATE_PROGRAM_STATUSES = ("active", "inactive")
# ── Affiliate Programs ────────────────────────────────────────────────────────
def _form_to_program(form) -> dict:
"""Parse affiliate program form values into a data dict."""
commission_str = form.get("commission_pct", "").strip()
commission_pct = 0.0
if commission_str:
try:
commission_pct = float(commission_str.replace(",", "."))
except ValueError:
commission_pct = 0.0
return {
"name": form.get("name", "").strip(),
"slug": form.get("slug", "").strip(),
"url_template": form.get("url_template", "").strip(),
"tracking_tag": form.get("tracking_tag", "").strip(),
"commission_pct": commission_pct,
"homepage_url": form.get("homepage_url", "").strip(),
"status": form.get("status", "active").strip(),
"notes": form.get("notes", "").strip(),
}
@bp.route("/affiliate/programs")
@role_required("admin")
async def affiliate_programs():
"""Affiliate programs list — full page."""
from ..affiliate import get_all_programs
programs = await get_all_programs()
return await render_template(
"admin/affiliate_programs.html",
admin_page="affiliate_programs",
programs=programs,
)
@bp.route("/affiliate/programs/results")
@role_required("admin")
async def affiliate_program_results():
"""HTMX partial: program rows."""
from ..affiliate import get_all_programs
programs = await get_all_programs()
return await render_template(
"admin/partials/affiliate_program_results.html",
programs=programs,
)
@bp.route("/affiliate/programs/new", methods=["GET", "POST"])
@role_required("admin")
@csrf_protect
async def affiliate_program_new():
"""Create an affiliate program."""
if request.method == "POST":
form = await request.form
data = _form_to_program(form)
if not data["name"] or not data["slug"] or not data["url_template"]:
await flash("Name, slug, and URL template are required.", "error")
return await render_template(
"admin/affiliate_program_form.html",
admin_page="affiliate_programs",
data=data,
editing=False,
program_statuses=AFFILIATE_PROGRAM_STATUSES,
)
existing = await fetch_one(
"SELECT id FROM affiliate_programs WHERE slug = ?", (data["slug"],)
)
if existing:
await flash(f"Slug '{data['slug']}' already exists.", "error")
return await render_template(
"admin/affiliate_program_form.html",
admin_page="affiliate_programs",
data=data,
editing=False,
program_statuses=AFFILIATE_PROGRAM_STATUSES,
)
await execute(
"""INSERT INTO affiliate_programs
(name, slug, url_template, tracking_tag, commission_pct,
homepage_url, status, notes)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
(
data["name"], data["slug"], data["url_template"],
data["tracking_tag"], data["commission_pct"],
data["homepage_url"], data["status"], data["notes"],
),
)
await flash(f"Program '{data['name']}' created.", "success")
return redirect(url_for("admin.affiliate_programs"))
return await render_template(
"admin/affiliate_program_form.html",
admin_page="affiliate_programs",
data={},
editing=False,
program_statuses=AFFILIATE_PROGRAM_STATUSES,
)
@bp.route("/affiliate/programs/<int:program_id>/edit", methods=["GET", "POST"])
@role_required("admin")
@csrf_protect
async def affiliate_program_edit(program_id: int):
"""Edit an affiliate program."""
program = await fetch_one(
"SELECT * FROM affiliate_programs WHERE id = ?", (program_id,)
)
if not program:
await flash("Program not found.", "error")
return redirect(url_for("admin.affiliate_programs"))
if request.method == "POST":
form = await request.form
data = _form_to_program(form)
if not data["name"] or not data["slug"] or not data["url_template"]:
await flash("Name, slug, and URL template are required.", "error")
return await render_template(
"admin/affiliate_program_form.html",
admin_page="affiliate_programs",
data={**dict(program), **data},
editing=True,
program_id=program_id,
program_statuses=AFFILIATE_PROGRAM_STATUSES,
)
if data["slug"] != program["slug"]:
collision = await fetch_one(
"SELECT id FROM affiliate_programs WHERE slug = ? AND id != ?",
(data["slug"], program_id),
)
if collision:
await flash(f"Slug '{data['slug']}' already exists.", "error")
return await render_template(
"admin/affiliate_program_form.html",
admin_page="affiliate_programs",
data={**dict(program), **data},
editing=True,
program_id=program_id,
program_statuses=AFFILIATE_PROGRAM_STATUSES,
)
await execute(
"""UPDATE affiliate_programs
SET name=?, slug=?, url_template=?, tracking_tag=?, commission_pct=?,
homepage_url=?, status=?, notes=?, updated_at=datetime('now')
WHERE id=?""",
(
data["name"], data["slug"], data["url_template"],
data["tracking_tag"], data["commission_pct"],
data["homepage_url"], data["status"], data["notes"],
program_id,
),
)
await flash(f"Program '{data['name']}' updated.", "success")
return redirect(url_for("admin.affiliate_programs"))
return await render_template(
"admin/affiliate_program_form.html",
admin_page="affiliate_programs",
data=dict(program),
editing=True,
program_id=program_id,
program_statuses=AFFILIATE_PROGRAM_STATUSES,
)
@bp.route("/affiliate/programs/<int:program_id>/delete", methods=["POST"])
@role_required("admin")
@csrf_protect
async def affiliate_program_delete(program_id: int):
"""Delete an affiliate program — blocked if products reference it."""
program = await fetch_one(
"SELECT name FROM affiliate_programs WHERE id = ?", (program_id,)
)
if not program:
return redirect(url_for("admin.affiliate_programs"))
product_count = await fetch_one(
"SELECT COUNT(*) AS cnt FROM affiliate_products WHERE program_id = ?",
(program_id,),
)
count = product_count["cnt"] if product_count else 0
if count > 0:
await flash(
f"Cannot delete '{program['name']}'{count} product(s) reference it. "
"Reassign or remove those products first.",
"error",
)
return redirect(url_for("admin.affiliate_programs"))
await execute("DELETE FROM affiliate_programs WHERE id = ?", (program_id,))
await flash(f"Program '{program['name']}' deleted.", "success")
return redirect(url_for("admin.affiliate_programs"))
def _form_to_product(form) -> dict:
@@ -3279,13 +3486,26 @@ def _form_to_product(form) -> dict:
pros = json.dumps([line.strip() for line in pros_raw.splitlines() if line.strip()])
cons = json.dumps([line.strip() for line in cons_raw.splitlines() if line.strip()])
# Program-based URL vs manual URL.
# When a program is selected, product_identifier holds the ASIN/path;
# affiliate_url is cleared. Manual mode is the reverse.
program_id_str = form.get("program_id", "").strip()
program_id = int(program_id_str) if program_id_str and program_id_str != "0" else None
product_identifier = form.get("product_identifier", "").strip()
affiliate_url = form.get("affiliate_url", "").strip()
# retailer is auto-populated from program name on save (kept for display/filter)
retailer = form.get("retailer", "").strip()
return {
"slug": form.get("slug", "").strip(),
"name": form.get("name", "").strip(),
"brand": form.get("brand", "").strip(),
"category": form.get("category", "accessory").strip(),
"retailer": form.get("retailer", "").strip(),
"affiliate_url": form.get("affiliate_url", "").strip(),
"retailer": retailer,
"program_id": program_id,
"product_identifier": product_identifier,
"affiliate_url": affiliate_url,
"image_url": form.get("image_url", "").strip(),
"price_cents": price_cents,
"currency": "EUR",
@@ -3403,14 +3623,15 @@ async def affiliate_preview():
@csrf_protect
async def affiliate_new():
"""Create an affiliate product."""
from ..affiliate import get_distinct_retailers
from ..affiliate import get_all_programs, get_distinct_retailers
if request.method == "POST":
form = await request.form
data = _form_to_product(form)
if not data["slug"] or not data["name"] or not data["affiliate_url"]:
await flash("Slug, name, and affiliate URL are required.", "error")
has_url = bool(data["program_id"] and data["product_identifier"]) or bool(data["affiliate_url"])
if not data["slug"] or not data["name"] or not has_url:
await flash("Slug, name, and either a program+product ID or manual URL are required.", "error")
return await render_template(
"admin/affiliate_form.html",
admin_page="affiliate",
@@ -3419,6 +3640,7 @@ async def affiliate_new():
categories=AFFILIATE_CATEGORIES,
statuses=AFFILIATE_STATUSES,
retailers=await get_distinct_retailers(),
programs=await get_all_programs(status="active"),
)
existing = await fetch_one(
@@ -3435,17 +3657,27 @@ async def affiliate_new():
categories=AFFILIATE_CATEGORIES,
statuses=AFFILIATE_STATUSES,
retailers=await get_distinct_retailers(),
programs=await get_all_programs(status="active"),
)
# Auto-populate retailer from program name if not manually set
if data["program_id"] and not data["retailer"]:
prog = await fetch_one(
"SELECT name FROM affiliate_programs WHERE id = ?", (data["program_id"],)
)
if prog:
data["retailer"] = prog["name"]
await execute(
"""INSERT INTO affiliate_products
(slug, name, brand, category, retailer, affiliate_url, image_url,
price_cents, currency, rating, pros, cons, description, cta_label,
status, language, sort_order)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(slug, name, brand, category, retailer, program_id, product_identifier,
affiliate_url, image_url, price_cents, currency, rating, pros, cons,
description, cta_label, status, language, sort_order)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
data["slug"], data["name"], data["brand"], data["category"],
data["retailer"], data["affiliate_url"], data["image_url"],
data["retailer"], data["program_id"], data["product_identifier"],
data["affiliate_url"], data["image_url"],
data["price_cents"], data["currency"], data["rating"],
data["pros"], data["cons"], data["description"], data["cta_label"],
data["status"], data["language"], data["sort_order"],
@@ -3462,6 +3694,7 @@ async def affiliate_new():
categories=AFFILIATE_CATEGORIES,
statuses=AFFILIATE_STATUSES,
retailers=await get_distinct_retailers(),
programs=await get_all_programs(status="active"),
)
@@ -3470,7 +3703,7 @@ async def affiliate_new():
@csrf_protect
async def affiliate_edit(product_id: int):
"""Edit an affiliate product."""
from ..affiliate import get_distinct_retailers
from ..affiliate import get_all_programs, get_distinct_retailers
product = await fetch_one("SELECT * FROM affiliate_products WHERE id = ?", (product_id,))
if not product:
@@ -3481,8 +3714,9 @@ async def affiliate_edit(product_id: int):
form = await request.form
data = _form_to_product(form)
if not data["slug"] or not data["name"] or not data["affiliate_url"]:
await flash("Slug, name, and affiliate URL are required.", "error")
has_url = bool(data["program_id"] and data["product_identifier"]) or bool(data["affiliate_url"])
if not data["slug"] or not data["name"] or not has_url:
await flash("Slug, name, and either a program+product ID or manual URL are required.", "error")
return await render_template(
"admin/affiliate_form.html",
admin_page="affiliate",
@@ -3492,6 +3726,7 @@ async def affiliate_edit(product_id: int):
categories=AFFILIATE_CATEGORIES,
statuses=AFFILIATE_STATUSES,
retailers=await get_distinct_retailers(),
programs=await get_all_programs(status="active"),
)
# Check slug collision only if slug or language changed
@@ -3511,18 +3746,29 @@ async def affiliate_edit(product_id: int):
categories=AFFILIATE_CATEGORIES,
statuses=AFFILIATE_STATUSES,
retailers=await get_distinct_retailers(),
programs=await get_all_programs(status="active"),
)
# Auto-populate retailer from program name if not manually set
if data["program_id"] and not data["retailer"]:
prog = await fetch_one(
"SELECT name FROM affiliate_programs WHERE id = ?", (data["program_id"],)
)
if prog:
data["retailer"] = prog["name"]
await execute(
"""UPDATE affiliate_products
SET slug=?, name=?, brand=?, category=?, retailer=?, affiliate_url=?,
image_url=?, price_cents=?, currency=?, rating=?, pros=?, cons=?,
SET slug=?, name=?, brand=?, category=?, retailer=?, program_id=?,
product_identifier=?, affiliate_url=?, image_url=?,
price_cents=?, currency=?, rating=?, pros=?, cons=?,
description=?, cta_label=?, status=?, language=?, sort_order=?,
updated_at=datetime('now')
WHERE id=?""",
(
data["slug"], data["name"], data["brand"], data["category"],
data["retailer"], data["affiliate_url"], data["image_url"],
data["retailer"], data["program_id"], data["product_identifier"],
data["affiliate_url"], data["image_url"],
data["price_cents"], data["currency"], data["rating"],
data["pros"], data["cons"], data["description"], data["cta_label"],
data["status"], data["language"], data["sort_order"],
@@ -3554,6 +3800,7 @@ async def affiliate_edit(product_id: int):
categories=AFFILIATE_CATEGORIES,
statuses=AFFILIATE_STATUSES,
retailers=await get_distinct_retailers(),
programs=await get_all_programs(status="active"),
)

View File

@@ -24,6 +24,20 @@ document.addEventListener('DOMContentLoaded', function() {
slugInput.dataset.manual = '1';
});
}
// Toggle program-based vs manual URL fields
function toggleProgramFields() {
var sel = document.getElementById('f-program');
if (!sel) return;
var isManual = sel.value === '0' || sel.value === '';
document.getElementById('f-product-id-row').style.display = isManual ? 'none' : '';
document.getElementById('f-manual-url-row').style.display = isManual ? '' : 'none';
}
var programSel = document.getElementById('f-program');
if (programSel) {
programSel.addEventListener('change', toggleProgramFields);
toggleProgramFields();
}
});
</script>
{% endblock %}
@@ -87,9 +101,38 @@ document.addEventListener('DOMContentLoaded', function() {
</div>
</div>
{# Retailer #}
{# Program dropdown #}
<div>
<label class="form-label" for="f-retailer">Retailer</label>
<label class="form-label" for="f-program">Affiliate Program</label>
<select id="f-program" name="program_id" class="form-input">
<option value="0" {% if not data.get('program_id') %}selected{% endif %}>Manual (custom URL)</option>
{% for prog in programs %}
<option value="{{ prog.id }}" {% if data.get('program_id') == prog.id %}selected{% endif %}>{{ prog.name }}</option>
{% endfor %}
</select>
<p class="form-hint">Select a program to auto-build the URL, or choose Manual for a custom link.</p>
</div>
{# Product Identifier (shown when program selected) #}
<div id="f-product-id-row">
<label class="form-label" for="f-product-id">Product ID *</label>
<input id="f-product-id" type="text" name="product_identifier"
value="{{ data.get('product_identifier','') }}"
class="form-input" placeholder="e.g. B0XXXXXXXXX (ASIN for Amazon)">
<p class="form-hint">ASIN, product path, or other program-specific identifier. URL is assembled at redirect time.</p>
</div>
{# Manual URL (shown when Manual selected) #}
<div id="f-manual-url-row">
<label class="form-label" for="f-url">Affiliate URL</label>
<input id="f-url" type="url" name="affiliate_url" value="{{ data.get('affiliate_url','') }}"
class="form-input" placeholder="https://www.amazon.de/dp/B0XXXXX?tag=padelnomics-21">
<p class="form-hint">Full URL with tracking params already baked in. Used as fallback if no program is set.</p>
</div>
{# Retailer (auto-populated from program; editable for manual products) #}
<div>
<label class="form-label" for="f-retailer">Retailer <span class="form-hint" style="font-weight:normal">(auto-filled from program)</span></label>
<input id="f-retailer" type="text" name="retailer" value="{{ data.get('retailer','') }}"
class="form-input" placeholder="e.g. Amazon, Padel Nuestro"
list="retailers-list">
@@ -100,14 +143,6 @@ document.addEventListener('DOMContentLoaded', function() {
</datalist>
</div>
{# Affiliate URL #}
<div>
<label class="form-label" for="f-url">Affiliate URL *</label>
<input id="f-url" type="url" name="affiliate_url" value="{{ data.get('affiliate_url','') }}"
class="form-input" placeholder="https://www.amazon.de/dp/B0XXXXX?tag=padelnomics-21" required>
<p class="form-hint">Full URL with tracking params already baked in.</p>
</div>
{# Image URL #}
<div>
<label class="form-label" for="f-image">Image URL</label>

View File

@@ -0,0 +1,134 @@
{% extends "admin/base_admin.html" %}
{% set admin_page = "affiliate_programs" %}
{% block title %}{% if editing %}Edit Program{% else %}New Program{% endif %} - Admin - {{ config.APP_NAME }}{% endblock %}
{% block admin_head %}
<script>
function slugify(text) {
return text.toLowerCase()
.replace(/[äöü]/g, c => ({'ä':'ae','ö':'oe','ü':'ue'}[c]))
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
document.addEventListener('DOMContentLoaded', function() {
var nameInput = document.getElementById('f-name');
var slugInput = document.getElementById('f-slug');
if (nameInput && slugInput && !slugInput.value) {
nameInput.addEventListener('input', function() {
if (!slugInput.dataset.manual) {
slugInput.value = slugify(nameInput.value);
}
});
slugInput.addEventListener('input', function() {
slugInput.dataset.manual = '1';
});
}
});
</script>
{% endblock %}
{% block admin_content %}
<header class="flex justify-between items-center mb-6">
<div>
<a href="{{ url_for('admin.affiliate_programs') }}" class="text-slate text-sm" style="text-decoration:none">← Programs</a>
<h1 class="text-2xl mt-1">{% if editing %}Edit Program{% else %}New Program{% endif %}</h1>
</div>
</header>
<div style="max-width:600px">
<form method="post" id="program-form"
action="{% if editing %}{{ url_for('admin.affiliate_program_edit', program_id=program_id) }}{% else %}{{ url_for('admin.affiliate_program_new') }}{% endif %}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="card" style="padding:1.5rem;display:flex;flex-direction:column;gap:1.25rem;">
{# Name #}
<div>
<label class="form-label" for="f-name">Name *</label>
<input id="f-name" type="text" name="name" value="{{ data.get('name','') }}"
class="form-input" placeholder="e.g. Amazon, Padel Nuestro" required>
</div>
{# Slug #}
<div>
<label class="form-label" for="f-slug">Slug *</label>
<input id="f-slug" type="text" name="slug" value="{{ data.get('slug','') }}"
class="form-input" placeholder="e.g. amazon, padel-nuestro" required
pattern="[a-z0-9][a-z0-9\-]*">
<p class="form-hint">Lowercase letters, numbers, hyphens only.</p>
</div>
{# URL Template #}
<div>
<label class="form-label" for="f-template">URL Template *</label>
<input id="f-template" type="text" name="url_template" value="{{ data.get('url_template','') }}"
class="form-input" placeholder="https://www.amazon.de/dp/{product_id}?tag={tag}" required>
<p class="form-hint">
Use <code>{product_id}</code> for the ASIN/product path and <code>{tag}</code> for the tracking tag.<br>
Example: <code>https://www.amazon.de/dp/{product_id}?tag={tag}</code>
</p>
</div>
{# Tracking Tag + Commission row #}
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;">
<div>
<label class="form-label" for="f-tag">Tracking Tag</label>
<input id="f-tag" type="text" name="tracking_tag" value="{{ data.get('tracking_tag','') }}"
class="form-input" placeholder="e.g. padelnomics-21">
</div>
<div>
<label class="form-label" for="f-commission">Commission %</label>
<input id="f-commission" type="number" name="commission_pct" value="{{ data.get('commission_pct', 0) }}"
class="form-input" placeholder="3" step="0.1" min="0" max="100">
<p class="form-hint">Used for revenue estimates (e.g. 3 = 3%).</p>
</div>
</div>
{# Homepage URL #}
<div>
<label class="form-label" for="f-homepage">Homepage URL</label>
<input id="f-homepage" type="url" name="homepage_url" value="{{ data.get('homepage_url','') }}"
class="form-input" placeholder="https://www.amazon.de">
<p class="form-hint">Shown as a link in the programs list.</p>
</div>
{# Status #}
<div>
<label class="form-label" for="f-status">Status</label>
<select id="f-status" name="status" class="form-input">
{% for s in program_statuses %}
<option value="{{ s }}" {% if data.get('status','active') == s %}selected{% endif %}>{{ s | capitalize }}</option>
{% endfor %}
</select>
<p class="form-hint">Inactive programs are hidden from the product form dropdown.</p>
</div>
{# Notes #}
<div>
<label class="form-label" for="f-notes">Notes <span class="form-hint" style="font-weight:normal">(internal)</span></label>
<textarea id="f-notes" name="notes" rows="3"
class="form-input" placeholder="Login URL, account ID, affiliate dashboard link...">{{ data.get('notes','') }}</textarea>
</div>
{# Actions #}
<div class="flex gap-3 justify-between" style="margin-top:.5rem">
<div class="flex gap-2">
<button type="submit" class="btn">
{% if editing %}Save Changes{% else %}Create Program{% endif %}
</button>
<a href="{{ url_for('admin.affiliate_programs') }}" class="btn-outline">Cancel</a>
</div>
{% if editing %}
<form method="post" action="{{ url_for('admin.affiliate_program_delete', program_id=program_id) }}" style="margin:0">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn-outline"
onclick="return confirm('Delete this program? Blocked if products reference it.')">Delete</button>
</form>
{% endif %}
</div>
</div>
</form>
</div>
{% endblock %}

View File

@@ -0,0 +1,30 @@
{% extends "admin/base_admin.html" %}
{% set admin_page = "affiliate_programs" %}
{% block title %}Affiliate Programs - Admin - {{ config.APP_NAME }}{% endblock %}
{% block admin_content %}
<header class="flex justify-between items-center mb-6">
<h1 class="text-2xl">Affiliate Programs</h1>
<a href="{{ url_for('admin.affiliate_program_new') }}" class="btn btn-sm">+ New Program</a>
</header>
<div id="prog-results">
<table class="table">
<thead>
<tr>
<th>Name</th>
<th>Slug</th>
<th>Tracking Tag</th>
<th class="text-right">Commission</th>
<th class="text-right">Products</th>
<th>Status</th>
<th class="text-right">Actions</th>
</tr>
</thead>
<tbody>
{% include "admin/partials/affiliate_program_results.html" %}
</tbody>
</table>
</div>
{% endblock %}

View File

@@ -99,7 +99,7 @@
'suppliers': 'suppliers',
'articles': 'content', 'scenarios': 'content', 'templates': 'content', 'pseo': 'content',
'emails': 'email', 'inbox': 'email', 'compose': 'email', 'gallery': 'email', 'audiences': 'email', 'outreach': 'email',
'affiliate': 'affiliate', 'affiliate_dashboard': 'affiliate',
'affiliate': 'affiliate', 'affiliate_dashboard': 'affiliate', 'affiliate_programs': 'affiliate',
'billing': 'billing',
'seo': 'analytics',
'pipeline': 'pipeline',
@@ -206,6 +206,7 @@
<nav class="admin-subnav">
<a href="{{ url_for('admin.affiliate_dashboard') }}" class="{% if admin_page == 'affiliate_dashboard' %}active{% endif %}">Dashboard</a>
<a href="{{ url_for('admin.affiliate_products') }}" class="{% if admin_page == 'affiliate' %}active{% endif %}">Products</a>
<a href="{{ url_for('admin.affiliate_programs') }}" class="{% if admin_page == 'affiliate_programs' %}active{% endif %}">Programs</a>
</nav>
{% elif active_section == 'system' %}
<nav class="admin-subnav">

View File

@@ -0,0 +1,36 @@
{% if programs %}
{% for prog in programs %}
<tr id="prog-{{ prog.id }}">
<td style="font-weight:500">
{% if prog.homepage_url %}
<a href="{{ prog.homepage_url }}" target="_blank" rel="noopener" style="color:#0F172A;text-decoration:none">{{ prog.name }}</a>
{% else %}
{{ prog.name }}
{% endif %}
</td>
<td class="mono text-slate">{{ prog.slug }}</td>
<td class="mono text-slate">{{ prog.tracking_tag or '—' }}</td>
<td class="mono text-right">
{% if prog.commission_pct %}{{ "%.0f" | format(prog.commission_pct) }}%{% else %}—{% endif %}
</td>
<td class="mono text-right">{{ prog.product_count }}</td>
<td>
<span class="badge {% if prog.status == 'active' %}badge-success{% else %}badge{% endif %}">
{{ prog.status }}
</span>
</td>
<td class="text-right" style="white-space:nowrap">
<a href="{{ url_for('admin.affiliate_program_edit', program_id=prog.id) }}" class="btn-outline btn-sm">Edit</a>
<form method="post" action="{{ url_for('admin.affiliate_program_delete', program_id=prog.id) }}" style="display:inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn-outline btn-sm"
onclick="return confirm('Delete {{ prog.name }}? This is blocked if products reference it.')">Delete</button>
</form>
</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td colspan="7" class="text-slate" style="text-align:center;padding:2rem;">No programs found.</td>
</tr>
{% endif %}

View File

@@ -21,6 +21,7 @@ logger = logging.getLogger(__name__)
VALID_CATEGORIES = ("racket", "ball", "shoe", "bag", "grip", "eyewear", "accessory")
VALID_STATUSES = ("draft", "active", "archived")
VALID_PROGRAM_STATUSES = ("active", "inactive")
def hash_ip(ip_address: str) -> str:
@@ -32,20 +33,86 @@ def hash_ip(ip_address: str) -> str:
return hashlib.sha256(raw.encode()).hexdigest()
async def get_product(slug: str, language: str = "de") -> dict | None:
"""Return active product by slug+language, falling back to any language."""
async def get_all_programs(status: str | None = None) -> list[dict]:
"""Return all affiliate programs, optionally filtered by status."""
if status:
assert status in VALID_PROGRAM_STATUSES, f"unknown program status: {status}"
rows = await fetch_all(
"SELECT ap.*, ("
" SELECT COUNT(*) FROM affiliate_products WHERE program_id = ap.id"
") AS product_count"
" FROM affiliate_programs ap WHERE ap.status = ?"
" ORDER BY ap.name ASC",
(status,),
)
else:
rows = await fetch_all(
"SELECT ap.*, ("
" SELECT COUNT(*) FROM affiliate_products WHERE program_id = ap.id"
") AS product_count"
" FROM affiliate_programs ap ORDER BY ap.name ASC"
)
return [dict(r) for r in rows]
async def get_program(program_id: int) -> dict | None:
"""Return a single affiliate program by id."""
assert program_id > 0, "program_id must be positive"
row = await fetch_one(
"SELECT * FROM affiliate_programs WHERE id = ?", (program_id,)
)
return dict(row) if row else None
async def get_program_by_slug(slug: str) -> dict | None:
"""Return a single affiliate program by slug."""
assert slug, "slug must not be empty"
row = await fetch_one(
"SELECT * FROM affiliate_products"
" WHERE slug = ? AND language = ? AND status = 'active'",
"SELECT * FROM affiliate_programs WHERE slug = ?", (slug,)
)
return dict(row) if row else None
def build_affiliate_url(product: dict, program: dict | None = None) -> str:
"""Assemble the final affiliate URL from program template + product identifier.
Falls back to the baked product["affiliate_url"] when no program is set,
preserving backward compatibility with products created before programs existed.
"""
if not product.get("program_id") or not program:
return product["affiliate_url"]
return program["url_template"].format(
product_id=product["product_identifier"],
tag=program["tracking_tag"],
)
async def get_product(slug: str, language: str = "de") -> dict | None:
"""Return active product by slug+language, falling back to any language.
JOINs affiliate_programs so the returned dict includes program fields
(prefixed with _program_*) for use in build_affiliate_url().
"""
assert slug, "slug must not be empty"
row = await fetch_one(
"SELECT p.*, pg.url_template AS _program_url_template,"
" pg.tracking_tag AS _program_tracking_tag,"
" pg.name AS _program_name"
" FROM affiliate_products p"
" LEFT JOIN affiliate_programs pg ON pg.id = p.program_id"
" WHERE p.slug = ? AND p.language = ? AND p.status = 'active'",
(slug, language),
)
if row:
return _parse_product(row)
# Graceful fallback: show any language rather than nothing
row = await fetch_one(
"SELECT * FROM affiliate_products"
" WHERE slug = ? AND status = 'active' LIMIT 1",
"SELECT p.*, pg.url_template AS _program_url_template,"
" pg.tracking_tag AS _program_tracking_tag,"
" pg.name AS _program_name"
" FROM affiliate_products p"
" LEFT JOIN affiliate_programs pg ON pg.id = p.program_id"
" WHERE p.slug = ? AND p.status = 'active' LIMIT 1",
(slug,),
)
return _parse_product(row) if row else None
@@ -217,8 +284,24 @@ async def get_distinct_retailers() -> list[str]:
def _parse_product(row) -> dict:
"""Convert aiosqlite Row to plain dict, parsing JSON pros/cons arrays."""
"""Convert aiosqlite Row to plain dict, parsing JSON pros/cons arrays.
If the row includes _program_* columns (from a JOIN), extracts them into
a nested "_program" dict so build_affiliate_url() can use them directly.
"""
d = dict(row)
d["pros"] = json.loads(d.get("pros") or "[]")
d["cons"] = json.loads(d.get("cons") or "[]")
# Extract program fields added by get_product()'s JOIN
if "_program_url_template" in d:
if d.get("program_id") and d["_program_url_template"]:
d["_program"] = {
"url_template": d.pop("_program_url_template"),
"tracking_tag": d.pop("_program_tracking_tag", ""),
"name": d.pop("_program_name", ""),
}
else:
d.pop("_program_url_template", None)
d.pop("_program_tracking_tag", None)
d.pop("_program_name", None)
return d

View File

@@ -291,7 +291,7 @@ def create_app() -> Quart:
Uses 302 (not 301) so every hit is tracked — browsers don't cache 302s.
Extracts article_slug and lang from Referer header best-effort.
"""
from .affiliate import get_product, log_click
from .affiliate import build_affiliate_url, get_product, log_click
from .core import check_rate_limit
# Extract lang from Referer path (e.g. /de/blog/... → "de"), default de
@@ -314,14 +314,17 @@ def create_app() -> Quart:
if not product:
abort(404)
# Assemble URL from program template; falls back to baked affiliate_url
url = build_affiliate_url(product, product.get("_program"))
ip = request.remote_addr or "unknown"
allowed, _info = await check_rate_limit(f"aff:{ip}", limit=60, window=60)
if not allowed:
# Still redirect even if rate-limited; just don't log the click
return redirect(product["affiliate_url"], 302)
return redirect(url, 302)
await log_click(product["id"], ip, article_slug, referer or None)
return redirect(product["affiliate_url"], 302)
return redirect(url, 302)
# Legacy 301 redirects — bookmarked/cached URLs before lang prefixes existed
@app.route("/terms")

View File

@@ -0,0 +1,79 @@
"""Migration 0027: Affiliate programs table + program FK on products.
affiliate_programs: centralises retailer configs (URL template + tag + commission).
- url_template uses {product_id} and {tag} placeholders, assembled at redirect time.
- tracking_tag: e.g. "padelnomics-21" — changing it propagates to all products instantly.
- commission_pct: stored as a decimal (0.03 = 3%) for revenue estimates.
- status: active/inactive — only active programs appear in the product form dropdown.
- notes: internal field for login URLs, account IDs, etc.
affiliate_products changes:
- program_id (nullable FK): new products use a program; existing products keep their
baked affiliate_url (backward compat via build_affiliate_url() fallback).
- product_identifier: ASIN, product path, or other program-specific ID (e.g. B0XXXXX).
Amazon OneLink note: we use a single "Amazon" program pointing to amazon.de.
Amazon OneLink (configured in the Associates dashboard, no code changes needed)
auto-redirects visitors to their local marketplace (UK→amazon.co.uk, ES→amazon.es)
with the correct regional tag. One program covers all Amazon marketplaces.
"""
import re
def up(conn) -> None:
conn.execute("""
CREATE TABLE affiliate_programs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
url_template TEXT NOT NULL,
tracking_tag TEXT NOT NULL DEFAULT '',
commission_pct REAL NOT NULL DEFAULT 0,
homepage_url TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'active',
notes TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT
)
""")
# Seed the default Amazon program.
# OneLink handles geo-redirect to local marketplaces — no per-country programs needed.
conn.execute("""
INSERT INTO affiliate_programs (name, slug, url_template, tracking_tag, commission_pct, homepage_url)
VALUES ('Amazon', 'amazon', 'https://www.amazon.de/dp/{product_id}?tag={tag}', 'padelnomics-21', 3.0, 'https://www.amazon.de')
""")
# Add program FK + product identifier to products table.
# program_id is nullable — existing rows keep their baked affiliate_url.
conn.execute("""
ALTER TABLE affiliate_products
ADD COLUMN program_id INTEGER REFERENCES affiliate_programs(id)
""")
conn.execute("""
ALTER TABLE affiliate_products
ADD COLUMN product_identifier TEXT NOT NULL DEFAULT ''
""")
# Backfill: extract ASIN from existing Amazon affiliate URLs.
# Pattern: /dp/<ASIN> where ASIN is 10 uppercase alphanumeric chars.
amazon_program = conn.execute(
"SELECT id FROM affiliate_programs WHERE slug = 'amazon'"
).fetchone()
assert amazon_program is not None, "Amazon program must exist after seed"
amazon_id = amazon_program[0]
rows = conn.execute(
"SELECT id, affiliate_url FROM affiliate_products"
).fetchall()
asin_re = re.compile(r"/dp/([A-Z0-9]{10})")
for product_id, url in rows:
if not url:
continue
m = asin_re.search(url)
if m:
asin = m.group(1)
conn.execute(
"UPDATE affiliate_products SET program_id=?, product_identifier=? WHERE id=?",
(amazon_id, asin, product_id),
)

View File

@@ -2,7 +2,8 @@
Tests for the affiliate product system.
Covers: hash_ip determinism, product CRUD, bake_product_cards marker replacement,
click redirect (302 + logged), rate limiting, inactive product 404, multi-retailer.
click redirect (302 + logged), rate limiting, inactive product 404, multi-retailer,
program CRUD, build_affiliate_url(), program-based redirect.
"""
import json
from datetime import date
@@ -10,11 +11,15 @@ from unittest.mock import patch
import pytest
from padelnomics.affiliate import (
build_affiliate_url,
get_all_products,
get_all_programs,
get_click_counts,
get_click_stats,
get_product,
get_products_by_category,
get_program,
get_program_by_slug,
hash_ip,
log_click,
)
@@ -330,3 +335,282 @@ async def test_affiliate_redirect_unknown_404(app, db):
async with app.test_client() as client:
response = await client.get("/go/totally-unknown-xyz")
assert response.status_code == 404
# ── affiliate_programs ────────────────────────────────────────────────────────
async def _insert_program(
name="Test Shop",
slug="test-shop",
url_template="https://testshop.example.com/p/{product_id}?ref={tag}",
tracking_tag="testref",
commission_pct=5.0,
homepage_url="https://testshop.example.com",
status="active",
) -> int:
"""Insert an affiliate program, return its id."""
return await execute(
"""INSERT INTO affiliate_programs
(name, slug, url_template, tracking_tag, commission_pct, homepage_url, status)
VALUES (?, ?, ?, ?, ?, ?, ?)""",
(name, slug, url_template, tracking_tag, commission_pct, homepage_url, status),
)
@pytest.mark.usefixtures("db")
async def test_get_all_programs_returns_all(db):
"""get_all_programs returns inserted programs sorted by name."""
await _insert_program(slug="zebra-shop", name="Zebra Shop")
await _insert_program(slug="alpha-shop", name="Alpha Shop")
programs = await get_all_programs()
names = [p["name"] for p in programs]
assert "Alpha Shop" in names
assert "Zebra Shop" in names
# Sorted by name ascending
assert names.index("Alpha Shop") < names.index("Zebra Shop")
@pytest.mark.usefixtures("db")
async def test_get_all_programs_status_filter(db):
"""get_all_programs(status='active') excludes inactive programs."""
await _insert_program(slug="inactive-prog", status="inactive")
await _insert_program(slug="active-prog", name="Active Shop")
active = await get_all_programs(status="active")
statuses = [p["status"] for p in active]
assert all(s == "active" for s in statuses)
slugs = [p["slug"] for p in active]
assert "inactive-prog" not in slugs
assert "active-prog" in slugs
@pytest.mark.usefixtures("db")
async def test_get_program_by_id(db):
"""get_program returns a program by id."""
prog_id = await _insert_program()
prog = await get_program(prog_id)
assert prog is not None
assert prog["slug"] == "test-shop"
@pytest.mark.usefixtures("db")
async def test_get_program_not_found(db):
"""get_program returns None for unknown id."""
prog = await get_program(99999)
assert prog is None
@pytest.mark.usefixtures("db")
async def test_get_program_by_slug(db):
"""get_program_by_slug returns the program for a known slug."""
await _insert_program(slug="find-by-slug")
prog = await get_program_by_slug("find-by-slug")
assert prog is not None
assert prog["name"] == "Test Shop"
@pytest.mark.usefixtures("db")
async def test_get_program_by_slug_not_found(db):
"""get_program_by_slug returns None for unknown slug."""
prog = await get_program_by_slug("nonexistent-slug-xyz")
assert prog is None
@pytest.mark.usefixtures("db")
async def test_get_all_programs_product_count(db):
"""get_all_programs includes product_count for each program."""
prog_id = await _insert_program(slug="counted-prog")
await _insert_product(slug="p-for-count", program_id=prog_id)
programs = await get_all_programs()
prog = next(p for p in programs if p["slug"] == "counted-prog")
assert prog["product_count"] == 1
# ── build_affiliate_url ───────────────────────────────────────────────────────
def test_build_affiliate_url_with_program():
"""build_affiliate_url assembles URL from program template."""
product = {"program_id": 1, "product_identifier": "B0TESTTEST", "affiliate_url": ""}
program = {"url_template": "https://amazon.de/dp/{product_id}?tag={tag}", "tracking_tag": "mysite-21"}
url = build_affiliate_url(product, program)
assert url == "https://amazon.de/dp/B0TESTTEST?tag=mysite-21"
def test_build_affiliate_url_legacy_fallback():
"""build_affiliate_url falls back to baked affiliate_url when no program."""
product = {"program_id": None, "product_identifier": "", "affiliate_url": "https://baked.example.com/p?tag=x"}
url = build_affiliate_url(product, None)
assert url == "https://baked.example.com/p?tag=x"
def test_build_affiliate_url_no_program_id():
"""build_affiliate_url uses fallback when program_id is 0/falsy."""
product = {"program_id": 0, "product_identifier": "B0IGNORED", "affiliate_url": "https://fallback.example.com"}
program = {"url_template": "https://shop.example.com/{product_id}?ref={tag}", "tracking_tag": "tag123"}
url = build_affiliate_url(product, program)
# program_id is falsy → fallback
assert url == "https://fallback.example.com"
def test_build_affiliate_url_no_program_dict():
"""build_affiliate_url uses fallback when program dict is None."""
product = {"program_id": 5, "product_identifier": "ASIN123", "affiliate_url": "https://fallback.example.com"}
url = build_affiliate_url(product, None)
assert url == "https://fallback.example.com"
# ── program-based redirect ────────────────────────────────────────────────────
async def _insert_product( # noqa: F811 — redefined to add program_id support
slug="test-racket-amazon",
name="Test Racket",
brand="TestBrand",
category="racket",
retailer="Amazon",
affiliate_url="https://amazon.de/dp/TEST?tag=test-21",
status="active",
language="de",
price_cents=14999,
pros=None,
cons=None,
sort_order=0,
program_id=None,
product_identifier="",
) -> int:
"""Insert an affiliate product with optional program_id, return its id."""
return await execute(
"""INSERT INTO affiliate_products
(slug, name, brand, category, retailer, affiliate_url,
price_cents, currency, status, language, pros, cons, sort_order,
program_id, product_identifier)
VALUES (?, ?, ?, ?, ?, ?, ?, 'EUR', ?, ?, ?, ?, ?, ?, ?)""",
(
slug, name, brand, category, retailer, affiliate_url,
price_cents, status, language,
json.dumps(pros or ["Gut"]),
json.dumps(cons or ["Teuer"]),
sort_order,
program_id,
product_identifier,
),
)
@pytest.mark.usefixtures("db")
async def test_affiliate_redirect_uses_program_url(app, db):
"""Redirect assembles URL from program template when product has program_id."""
prog_id = await _insert_program(
slug="amzn-test",
url_template="https://www.amazon.de/dp/{product_id}?tag={tag}",
tracking_tag="testsite-21",
)
await _insert_product(
slug="program-redirect-test",
affiliate_url="",
program_id=prog_id,
product_identifier="B0PROGRAM01",
)
async with app.test_client() as client:
response = await client.get("/go/program-redirect-test")
assert response.status_code == 302
location = response.headers.get("Location", "")
assert "B0PROGRAM01" in location
assert "testsite-21" in location
@pytest.mark.usefixtures("db")
async def test_affiliate_redirect_legacy_url_still_works(app, db):
"""Legacy products with baked affiliate_url still redirect correctly."""
await _insert_product(
slug="legacy-redirect-test",
affiliate_url="https://amazon.de/dp/LEGACY?tag=old-21",
program_id=None,
product_identifier="",
)
async with app.test_client() as client:
response = await client.get("/go/legacy-redirect-test")
assert response.status_code == 302
assert "LEGACY" in response.headers.get("Location", "")
# ── migration backfill ────────────────────────────────────────────────────────
def _load_migration_0027():
"""Import migration 0027 via importlib (filename starts with a digit)."""
import importlib
from pathlib import Path
versions_dir = Path(__file__).parent.parent / "src/padelnomics/migrations/versions"
spec = importlib.util.spec_from_file_location(
"migration_0027", versions_dir / "0027_affiliate_programs.py"
)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
def _make_pre_migration_db():
"""Create a minimal sqlite3 DB simulating state just before migration 0027.
Provides the affiliate_products table (migration ALTERs it), but not
affiliate_programs (migration CREATEs it).
"""
import sqlite3
conn = sqlite3.connect(":memory:")
conn.execute("""
CREATE TABLE affiliate_products (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT NOT NULL,
name TEXT NOT NULL DEFAULT '',
affiliate_url TEXT NOT NULL DEFAULT '',
UNIQUE(slug)
)
""")
conn.commit()
return conn
def test_migration_seeds_amazon_program():
"""Migration 0027 up() seeds the Amazon program with expected fields.
Tests the migration function directly against a real sqlite3 DB
(the conftest only replays CREATE TABLE DDL, not INSERT seeds).
"""
migration = _load_migration_0027()
conn = _make_pre_migration_db()
migration.up(conn)
conn.commit()
row = conn.execute(
"SELECT * FROM affiliate_programs WHERE slug = 'amazon'"
).fetchone()
assert row is not None
cols = [d[0] for d in conn.execute("SELECT * FROM affiliate_programs WHERE slug = 'amazon'").description]
prog = dict(zip(cols, row))
assert prog["name"] == "Amazon"
assert "padelnomics-21" in prog["tracking_tag"]
assert "{product_id}" in prog["url_template"]
assert "{tag}" in prog["url_template"]
assert prog["commission_pct"] == 3.0
conn.close()
def test_migration_backfills_asin_from_url():
"""Migration 0027 up() extracts ASINs from existing affiliate_url values."""
migration = _load_migration_0027()
conn = _make_pre_migration_db()
conn.execute(
"INSERT INTO affiliate_products (slug, affiliate_url) VALUES (?, ?)",
("test-racket", "https://www.amazon.de/dp/B0ASIN1234?tag=test-21"),
)
conn.commit()
migration.up(conn)
conn.commit()
row = conn.execute(
"SELECT program_id, product_identifier FROM affiliate_products WHERE slug = 'test-racket'"
).fetchone()
assert row is not None
assert row[0] is not None # program_id set
assert row[1] == "B0ASIN1234" # ASIN extracted correctly
conn.close()