From 53117094eec44e9398608a165b55901887ca39a7 Mon Sep 17 00:00:00 2001 From: Deeman Date: Sat, 28 Feb 2026 22:32:45 +0100 Subject: [PATCH] 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 --- web/src/padelnomics/admin/routes.py | 204 ++++++++++++++++++ .../admin/affiliate_program_form.html | 134 ++++++++++++ .../templates/admin/affiliate_programs.html | 30 +++ .../admin/templates/admin/base_admin.html | 3 +- .../partials/affiliate_program_results.html | 36 ++++ 5 files changed, 406 insertions(+), 1 deletion(-) create mode 100644 web/src/padelnomics/admin/templates/admin/affiliate_program_form.html create mode 100644 web/src/padelnomics/admin/templates/admin/affiliate_programs.html create mode 100644 web/src/padelnomics/admin/templates/admin/partials/affiliate_program_results.html diff --git a/web/src/padelnomics/admin/routes.py b/web/src/padelnomics/admin/routes.py index 6c02b3d..5a90230 100644 --- a/web/src/padelnomics/admin/routes.py +++ b/web/src/padelnomics/admin/routes.py @@ -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//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//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: diff --git a/web/src/padelnomics/admin/templates/admin/affiliate_program_form.html b/web/src/padelnomics/admin/templates/admin/affiliate_program_form.html new file mode 100644 index 0000000..9c2949d --- /dev/null +++ b/web/src/padelnomics/admin/templates/admin/affiliate_program_form.html @@ -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 %} + +{% endblock %} + +{% block admin_content %} +
+
+ ← Programs +

{% if editing %}Edit Program{% else %}New Program{% endif %}

+
+
+ +
+
+ + +
+ + {# Name #} +
+ + +
+ + {# Slug #} +
+ + +

Lowercase letters, numbers, hyphens only.

+
+ + {# URL Template #} +
+ + +

+ Use {product_id} for the ASIN/product path and {tag} for the tracking tag.
+ Example: https://www.amazon.de/dp/{product_id}?tag={tag} +

+
+ + {# Tracking Tag + Commission row #} +
+
+ + +
+
+ + +

Used for revenue estimates (e.g. 3 = 3%).

+
+
+ + {# Homepage URL #} +
+ + +

Shown as a link in the programs list.

+
+ + {# Status #} +
+ + +

Inactive programs are hidden from the product form dropdown.

+
+ + {# Notes #} +
+ + +
+ + {# Actions #} +
+
+ + Cancel +
+ {% if editing %} + + + + + {% endif %} +
+ +
+ +
+{% endblock %} diff --git a/web/src/padelnomics/admin/templates/admin/affiliate_programs.html b/web/src/padelnomics/admin/templates/admin/affiliate_programs.html new file mode 100644 index 0000000..6b91d7e --- /dev/null +++ b/web/src/padelnomics/admin/templates/admin/affiliate_programs.html @@ -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 %} +
+

Affiliate Programs

+ + New Program +
+ +
+ + + + + + + + + + + + + + {% include "admin/partials/affiliate_program_results.html" %} + +
NameSlugTracking TagCommissionProductsStatusActions
+
+{% endblock %} diff --git a/web/src/padelnomics/admin/templates/admin/base_admin.html b/web/src/padelnomics/admin/templates/admin/base_admin.html index 39d3a5f..8f8189c 100644 --- a/web/src/padelnomics/admin/templates/admin/base_admin.html +++ b/web/src/padelnomics/admin/templates/admin/base_admin.html @@ -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 @@ {% elif active_section == 'system' %}