feat(affiliate): admin CRUD for affiliate programs

Adds program list, create, edit, delete routes with appropriate guards
(delete blocked if products reference the program). Adds "Programs" tab
to the affiliate subnav. New templates: affiliate_programs.html,
affiliate_program_form.html, partials/affiliate_program_results.html.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Deeman
2026-02-28 22:32:45 +01:00
parent 6076a0b30f
commit 53117094ee
5 changed files with 406 additions and 1 deletions

View File

@@ -3257,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:

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 %}