From 2e149fc1db4a100a98ed4dff198f1c9fc7a9ba36 Mon Sep 17 00:00:00 2001 From: Deeman Date: Sat, 28 Feb 2026 18:35:27 +0100 Subject: [PATCH 1/9] =?UTF-8?q?feat(affiliate):=20migration=200026=20?= =?UTF-8?q?=E2=80=94=20affiliate=5Fproducts=20+=20affiliate=5Fclicks=20tab?= =?UTF-8?q?les?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds affiliate product catalog and click tracking tables. UNIQUE(slug, language) mirrors articles schema for multi-language support. Co-Authored-By: Claude Sonnet 4.6 --- .../versions/0026_affiliate_products.py | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 web/src/padelnomics/migrations/versions/0026_affiliate_products.py diff --git a/web/src/padelnomics/migrations/versions/0026_affiliate_products.py b/web/src/padelnomics/migrations/versions/0026_affiliate_products.py new file mode 100644 index 0000000..4d74599 --- /dev/null +++ b/web/src/padelnomics/migrations/versions/0026_affiliate_products.py @@ -0,0 +1,65 @@ +"""Migration 0026: Affiliate product catalog + click tracking tables. + +affiliate_products: admin-managed product catalog for editorial affiliate cards. + - slug+language uniqueness mirrors articles (same slug can exist in DE + EN + with different affiliate URLs, copy, and pros/cons). + - retailer: display name (Amazon, Padel Nuestro, etc.) — stored in full URL + with tracking params already baked into affiliate_url. + - cta_label: per-product override; empty → use i18n default "Zum Angebot". + - status: draft/active/archived — only active products are baked into articles. + +affiliate_clicks: one row per /go/ redirect hit. + - ip_hash: SHA256(ip + YYYY-MM-DD + SECRET_KEY[:16]), daily rotation for GDPR. + - article_slug: best-effort extraction from Referer header. +""" + + +def up(conn) -> None: + conn.execute(""" + CREATE TABLE affiliate_products ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + slug TEXT NOT NULL, + name TEXT NOT NULL, + brand TEXT NOT NULL DEFAULT '', + category TEXT NOT NULL DEFAULT 'accessory', + retailer TEXT NOT NULL DEFAULT '', + affiliate_url TEXT NOT NULL, + image_url TEXT NOT NULL DEFAULT '', + price_cents INTEGER, + currency TEXT NOT NULL DEFAULT 'EUR', + rating REAL, + pros TEXT NOT NULL DEFAULT '[]', + cons TEXT NOT NULL DEFAULT '[]', + description TEXT NOT NULL DEFAULT '', + cta_label TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'draft', + language TEXT NOT NULL DEFAULT 'de', + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT, + UNIQUE(slug, language) + ) + """) + conn.execute(""" + CREATE TABLE affiliate_clicks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + product_id INTEGER NOT NULL REFERENCES affiliate_products(id), + article_slug TEXT, + referrer TEXT, + ip_hash TEXT NOT NULL, + clicked_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + """) + # Queries: products by category+status, clicks by product and time + conn.execute( + "CREATE INDEX idx_affiliate_products_category_status" + " ON affiliate_products(category, status)" + ) + conn.execute( + "CREATE INDEX idx_affiliate_clicks_product_id" + " ON affiliate_clicks(product_id)" + ) + conn.execute( + "CREATE INDEX idx_affiliate_clicks_clicked_at" + " ON affiliate_clicks(clicked_at)" + ) From b5db9d16b990ac1a5b3b9bb76f1e17c95889c36c Mon Sep 17 00:00:00 2001 From: Deeman Date: Sat, 28 Feb 2026 18:36:31 +0100 Subject: [PATCH 2/9] =?UTF-8?q?feat(affiliate):=20core=20affiliate=20modul?= =?UTF-8?q?e=20=E2=80=94=20product=20lookup,=20click=20logging,=20stats?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure async functions: get_product(), get_products_by_category(), log_click(), hash_ip() with daily-rotating GDPR salt, get_click_stats() with SQL aggregation. Co-Authored-By: Claude Sonnet 4.6 --- web/src/padelnomics/affiliate.py | 224 +++++++++++++++++++++++++++++++ 1 file changed, 224 insertions(+) create mode 100644 web/src/padelnomics/affiliate.py diff --git a/web/src/padelnomics/affiliate.py b/web/src/padelnomics/affiliate.py new file mode 100644 index 0000000..24e8cde --- /dev/null +++ b/web/src/padelnomics/affiliate.py @@ -0,0 +1,224 @@ +""" +Affiliate product catalog: product lookup, click logging, and stats queries. + +All functions are plain async procedures — no classes, no state. + +Design decisions: +- IP hashing uses a daily salt (date + SECRET_KEY[:16]) for GDPR compliance. + Rotating salt prevents re-identification across days without storing PII. +- Products are fetched by (slug, language) with a graceful fallback to any + language, so DE cards appear in EN articles rather than nothing. +- Stats are computed entirely in SQL — no Python aggregation. +""" +import hashlib +import json +import logging +from datetime import date + +from .core import config, execute, fetch_all, fetch_one + +logger = logging.getLogger(__name__) + +VALID_CATEGORIES = ("racket", "ball", "shoe", "bag", "grip", "eyewear", "accessory") +VALID_STATUSES = ("draft", "active", "archived") + + +def hash_ip(ip_address: str) -> str: + """SHA256(ip + YYYY-MM-DD + SECRET_KEY[:16]) with daily salt rotation.""" + assert ip_address, "ip_address must not be empty" + today = date.today().isoformat() + salt = config.SECRET_KEY[:16] + raw = f"{ip_address}:{today}:{salt}" + 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.""" + assert slug, "slug must not be empty" + row = await fetch_one( + "SELECT * FROM affiliate_products" + " WHERE slug = ? AND language = ? AND 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", + (slug,), + ) + return _parse_product(row) if row else None + + +async def get_products_by_category(category: str, language: str = "de") -> list[dict]: + """Return active products in category sorted by sort_order, with fallback.""" + assert category in VALID_CATEGORIES, f"unknown category: {category}" + rows = await fetch_all( + "SELECT * FROM affiliate_products" + " WHERE category = ? AND language = ? AND status = 'active'" + " ORDER BY sort_order ASC, id ASC", + (category, language), + ) + if rows: + return [_parse_product(r) for r in rows] + # Fallback: any language for this category + rows = await fetch_all( + "SELECT * FROM affiliate_products" + " WHERE category = ? AND status = 'active'" + " ORDER BY sort_order ASC, id ASC", + (category,), + ) + return [_parse_product(r) for r in rows] + + +async def get_all_products( + status: str | None = None, + retailer: str | None = None, +) -> list[dict]: + """Admin listing — all products, optionally filtered by status and/or retailer.""" + conditions = [] + params: list = [] + if status: + assert status in VALID_STATUSES, f"unknown status: {status}" + conditions.append("status = ?") + params.append(status) + if retailer: + conditions.append("retailer = ?") + params.append(retailer) + + where = ("WHERE " + " AND ".join(conditions)) if conditions else "" + rows = await fetch_all( + f"SELECT * FROM affiliate_products {where} ORDER BY sort_order ASC, id ASC", + tuple(params), + ) + return [_parse_product(r) for r in rows] + + +async def get_click_counts() -> dict[int, int]: + """Return {product_id: click_count} for all products (used in admin list).""" + rows = await fetch_all( + "SELECT product_id, COUNT(*) AS cnt FROM affiliate_clicks GROUP BY product_id" + ) + return {r["product_id"]: r["cnt"] for r in rows} + + +async def log_click( + product_id: int, + ip_address: str, + article_slug: str | None, + referrer: str | None, +) -> None: + """Insert a click event. Hashes IP for GDPR compliance.""" + assert product_id > 0, "product_id must be positive" + assert ip_address, "ip_address must not be empty" + ip = hash_ip(ip_address) + await execute( + "INSERT INTO affiliate_clicks (product_id, article_slug, referrer, ip_hash)" + " VALUES (?, ?, ?, ?)", + (product_id, article_slug, referrer, ip), + ) + + +async def get_click_stats(days_count: int = 30) -> dict: + """Compute click statistics over the last N days, entirely in SQL.""" + assert 1 <= days_count <= 365, f"days must be 1-365, got {days_count}" + + # Total clicks in window + total_row = await fetch_one( + "SELECT COUNT(*) AS cnt FROM affiliate_clicks" + " WHERE clicked_at >= datetime('now', ?)", + (f"-{days_count} days",), + ) + total = total_row["cnt"] if total_row else 0 + + # Active product count + product_counts = await fetch_one( + "SELECT" + " SUM(CASE WHEN status='active' THEN 1 ELSE 0 END) AS active_count," + " SUM(CASE WHEN status='draft' THEN 1 ELSE 0 END) AS draft_count" + " FROM affiliate_products" + ) + + # Top products by clicks + top_products = await fetch_all( + "SELECT p.id, p.name, p.slug, p.retailer, COUNT(c.id) AS click_count" + " FROM affiliate_products p" + " LEFT JOIN affiliate_clicks c" + " ON c.product_id = p.id" + " AND c.clicked_at >= datetime('now', ?)" + " GROUP BY p.id" + " ORDER BY click_count DESC" + " LIMIT 10", + (f"-{days_count} days",), + ) + + # Top articles by clicks + top_articles = await fetch_all( + "SELECT article_slug, COUNT(*) AS click_count" + " FROM affiliate_clicks" + " WHERE clicked_at >= datetime('now', ?)" + " AND article_slug IS NOT NULL" + " GROUP BY article_slug" + " ORDER BY click_count DESC" + " LIMIT 10", + (f"-{days_count} days",), + ) + + # Clicks by retailer + by_retailer = await fetch_all( + "SELECT p.retailer, COUNT(c.id) AS click_count" + " FROM affiliate_products p" + " LEFT JOIN affiliate_clicks c" + " ON c.product_id = p.id" + " AND c.clicked_at >= datetime('now', ?)" + " GROUP BY p.retailer" + " ORDER BY click_count DESC", + (f"-{days_count} days",), + ) + + # Daily click counts for bar chart + daily = await fetch_all( + "SELECT date(clicked_at) AS day, COUNT(*) AS click_count" + " FROM affiliate_clicks" + " WHERE clicked_at >= datetime('now', ?)" + " GROUP BY day" + " ORDER BY day ASC", + (f"-{days_count} days",), + ) + + # Normalize daily to percentage heights for CSS bar chart + max_daily = max((r["click_count"] for r in daily), default=1) + daily_bars = [ + {"day": r["day"], "click_count": r["click_count"], + "pct": round(r["click_count"] / max_daily * 100)} + for r in daily + ] + + return { + "total_clicks": total, + "active_products": product_counts["active_count"] if product_counts else 0, + "draft_products": product_counts["draft_count"] if product_counts else 0, + "top_products": [dict(r) for r in top_products], + "top_articles": [dict(r) for r in top_articles], + "by_retailer": [dict(r) for r in by_retailer], + "daily_bars": daily_bars, + "days": days_count, + } + + +async def get_distinct_retailers() -> list[str]: + """Return sorted list of distinct retailer names for form datalist.""" + rows = await fetch_all( + "SELECT DISTINCT retailer FROM affiliate_products" + " WHERE retailer != '' ORDER BY retailer" + ) + return [r["retailer"] for r in rows] + + +def _parse_product(row) -> dict: + """Convert aiosqlite Row to plain dict, parsing JSON pros/cons arrays.""" + d = dict(row) + d["pros"] = json.loads(d.get("pros") or "[]") + d["cons"] = json.loads(d.get("cons") or "[]") + return d From 4d45b99cd88506b26d5ee96b371f323308e4c565 Mon Sep 17 00:00:00 2001 From: Deeman Date: Sat, 28 Feb 2026 18:40:27 +0100 Subject: [PATCH 3/9] =?UTF-8?q?feat(affiliate):=20product=20card=20baking?= =?UTF-8?q?=20=E2=80=94=20PRODUCT=5FRE,=20bake=5Fproduct=5Fcards(),=20temp?= =?UTF-8?q?lates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds [product:slug] and [product-group:category] marker replacement. Templates: product_card.html (horizontal editorial callout) and product_group.html (responsive comparison grid). Chained after bake_scenario_cards() in generate_articles(), preview_article(), article_new(), article_edit(), and _rebuild_article(). Co-Authored-By: Claude Sonnet 4.6 --- web/src/padelnomics/admin/routes.py | 11 ++- web/src/padelnomics/content/__init__.py | 6 +- web/src/padelnomics/content/routes.py | 49 ++++++++++ .../templates/partials/product_card.html | 89 +++++++++++++++++++ .../templates/partials/product_group.html | 68 ++++++++++++++ 5 files changed, 217 insertions(+), 6 deletions(-) create mode 100644 web/src/padelnomics/content/templates/partials/product_card.html create mode 100644 web/src/padelnomics/content/templates/partials/product_group.html diff --git a/web/src/padelnomics/admin/routes.py b/web/src/padelnomics/admin/routes.py index e29eb8b..6f276a0 100644 --- a/web/src/padelnomics/admin/routes.py +++ b/web/src/padelnomics/admin/routes.py @@ -2499,7 +2499,7 @@ async def article_results(): @csrf_protect async def article_new(): """Create a manual article.""" - from ..content.routes import BUILD_DIR, bake_scenario_cards, is_reserved_path + from ..content.routes import BUILD_DIR, bake_product_cards, bake_scenario_cards, is_reserved_path if request.method == "POST": form = await request.form @@ -2523,9 +2523,10 @@ async def article_new(): await flash(f"URL path '{url_path}' conflicts with a reserved route.", "error") return await render_template("admin/article_form.html", data=dict(form), editing=False) - # Render markdown → HTML with scenario cards baked in + # Render markdown → HTML with scenario + product cards baked in body_html = mistune.html(body) body_html = await bake_scenario_cards(body_html) + body_html = await bake_product_cards(body_html, lang=language) build_dir = BUILD_DIR / language build_dir.mkdir(parents=True, exist_ok=True) @@ -2561,7 +2562,7 @@ async def article_new(): @csrf_protect async def article_edit(article_id: int): """Edit a manual article.""" - from ..content.routes import BUILD_DIR, bake_scenario_cards, is_reserved_path + from ..content.routes import BUILD_DIR, bake_product_cards, bake_scenario_cards, is_reserved_path article = await fetch_one("SELECT * FROM articles WHERE id = ?", (article_id,)) if not article: @@ -2591,6 +2592,7 @@ async def article_edit(article_id: int): if body: body_html = mistune.html(body) body_html = await bake_scenario_cards(body_html) + body_html = await bake_product_cards(body_html, lang=language) build_dir = BUILD_DIR / language build_dir.mkdir(parents=True, exist_ok=True) (build_dir / f"{article['slug']}.html").write_text(body_html) @@ -2735,7 +2737,7 @@ async def rebuild_all(): async def _rebuild_article(article_id: int): """Re-render a single article from its source.""" - from ..content.routes import BUILD_DIR, bake_scenario_cards + from ..content.routes import BUILD_DIR, bake_product_cards, bake_scenario_cards article = await fetch_one("SELECT * FROM articles WHERE id = ?", (article_id,)) if not article: @@ -2760,6 +2762,7 @@ async def _rebuild_article(article_id: int): body_html = mistune.html(md_path.read_text()) 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) BUILD_DIR.mkdir(parents=True, exist_ok=True) (BUILD_DIR / f"{article['slug']}.html").write_text(body_html) diff --git a/web/src/padelnomics/content/__init__.py b/web/src/padelnomics/content/__init__.py index faadcae..2c16f8c 100644 --- a/web/src/padelnomics/content/__init__.py +++ b/web/src/padelnomics/content/__init__.py @@ -315,7 +315,7 @@ async def generate_articles( """ from ..core import execute as db_execute from ..planner.calculator import DEFAULTS, calc, validate_state - from .routes import bake_scenario_cards, is_reserved_path + from .routes import bake_product_cards, bake_scenario_cards, is_reserved_path assert articles_per_day > 0, "articles_per_day must be positive" @@ -443,6 +443,7 @@ async def generate_articles( body_html = await bake_scenario_cards( body_html, lang=lang, scenario_overrides=scenario_overrides ) + body_html = await bake_product_cards(body_html, lang=lang) t_bake += time.perf_counter() - t0 # Extract FAQ pairs for structured data @@ -584,7 +585,7 @@ async def preview_article( No disk write, no DB insert. Returns {title, url_path, html, meta_description}. """ from ..planner.calculator import DEFAULTS, calc, validate_state - from .routes import bake_scenario_cards + from .routes import bake_product_cards, bake_scenario_cards config = load_template(slug) @@ -641,6 +642,7 @@ async def preview_article( body_html = await bake_scenario_cards( body_html, lang=lang, scenario_overrides=scenario_overrides, ) + body_html = await bake_product_cards(body_html, lang=lang) return { "title": title, diff --git a/web/src/padelnomics/content/routes.py b/web/src/padelnomics/content/routes.py index 5b84376..48bb02b 100644 --- a/web/src/padelnomics/content/routes.py +++ b/web/src/padelnomics/content/routes.py @@ -27,6 +27,8 @@ RESERVED_PREFIXES = ( ) SCENARIO_RE = re.compile(r'\[scenario:([a-z0-9_-]+)(?::([a-z]+))?\]') +PRODUCT_RE = re.compile(r'\[product:([a-z0-9_-]+)\]') +PRODUCT_GROUP_RE = re.compile(r'\[product-group:([a-z0-9_-]+)\]') SECTION_TEMPLATES = { None: "partials/scenario_summary.html", @@ -112,6 +114,53 @@ async def bake_scenario_cards( return html +async def bake_product_cards(html: str, lang: str = "de") -> str: + """Replace [product:slug] and [product-group:category] markers with rendered HTML. + + Processes markers in two passes (product first, then groups) to keep logic + clear. Reverse iteration preserves string offsets when splicing. + """ + from ..affiliate import get_product, get_products_by_category + + t = get_translations(lang) + + # ── Pass 1: [product:slug] ──────────────────────────────────────────────── + product_matches = list(PRODUCT_RE.finditer(html)) + if product_matches: + slugs = list({m.group(1) for m in product_matches}) + products: dict[str, dict | None] = {} + for slug in slugs: + products[slug] = await get_product(slug, lang) + + tmpl = _bake_env.get_template("partials/product_card.html") + for match in reversed(product_matches): + slug = match.group(1) + product = products.get(slug) + if not product: + continue + card_html = tmpl.render(product=product, lang=lang, t=t) + html = html[:match.start()] + card_html + html[match.end():] + + # ── Pass 2: [product-group:category] ───────────────────────────────────── + group_matches = list(PRODUCT_GROUP_RE.finditer(html)) + if group_matches: + categories = list({m.group(1) for m in group_matches}) + groups: dict[str, list] = {} + for cat in categories: + groups[cat] = await get_products_by_category(cat, lang) + + tmpl = _bake_env.get_template("partials/product_group.html") + for match in reversed(group_matches): + cat = match.group(1) + group_products = groups.get(cat, []) + if not group_products: + continue + grid_html = tmpl.render(products=group_products, category=cat, lang=lang, t=t) + html = html[:match.start()] + grid_html + html[match.end():] + + return html + + # ============================================================================= # Markets Hub # ============================================================================= diff --git a/web/src/padelnomics/content/templates/partials/product_card.html b/web/src/padelnomics/content/templates/partials/product_card.html new file mode 100644 index 0000000..7ad0c67 --- /dev/null +++ b/web/src/padelnomics/content/templates/partials/product_card.html @@ -0,0 +1,89 @@ +{# Affiliate product card — editorial recommendation style. + Variables: product (dict with parsed pros/cons lists), t (translations), lang. + Rendered bake-time by bake_product_cards(); no request context available. #} +{%- set price_eur = (product.price_cents / 100) if product.price_cents else none -%} +{%- set cta = product.cta_label if product.cta_label else t.affiliate_cta_buy -%} +
+
+ + {# ── Image ── #} +
+ {% if product.image_url %} + {{ product.name }} + {% else %} + + {% endif %} +
+ + {# ── Content ── #} +
+ + {# Brand + retailer #} +
+ {% if product.brand %} + {{ product.brand }} + {% endif %} + {% if product.retailer %} + {{ t.affiliate_at_retailer | tformat(retailer=product.retailer) }} + {% endif %} +
+ + {# Name #} +

{{ product.name }}

+ + {# Rating #} + {% if product.rating %} + {%- set stars_full = product.rating | int -%} + {%- set has_half = (product.rating - stars_full) >= 0.5 -%} +
+ + {%- for i in range(stars_full) %}★{% endfor -%} + {%- if has_half %}★{% endif -%} + {%- for i in range(5 - stars_full - (1 if has_half else 0)) %}{% endfor -%} + + {{ "%.1f" | format(product.rating) }} +
+ {% endif %} + + {# Price #} + {% if price_eur %} +
{{ "%.2f" | format(price_eur) | replace('.', ',') }} €
+ {% endif %} + + {# Description #} + {% if product.description %} +

{{ product.description }}

+ {% endif %} + + {# Pros #} + {% if product.pros %} +
    + {% for pro in product.pros %} +
  • {{ pro }}
  • + {% endfor %} +
+ {% endif %} + + {# Cons #} + {% if product.cons %} +
    + {% for con in product.cons %} +
  • {{ con }}
  • + {% endfor %} +
+ {% endif %} + + {# CTA #} + + {{ cta }} → + + + {# Disclosure #} +

{{ t.affiliate_disclosure }}

+ +
+
+
diff --git a/web/src/padelnomics/content/templates/partials/product_group.html b/web/src/padelnomics/content/templates/partials/product_group.html new file mode 100644 index 0000000..85ea210 --- /dev/null +++ b/web/src/padelnomics/content/templates/partials/product_group.html @@ -0,0 +1,68 @@ +{# Affiliate product comparison grid — editorial picks layout. + Variables: products (list of dicts), category (str), t (translations), lang. + Rendered bake-time by bake_product_cards(). #} +{% if products %} +
+ + {# Section header #} +
+ {{ t.affiliate_our_picks }} · {{ category | capitalize }} +
+ + {# Responsive grid of compact cards #} +
+ {% for product in products %} + {%- set price_eur = (product.price_cents / 100) if product.price_cents else none -%} + {%- set cta = product.cta_label if product.cta_label else t.affiliate_cta_buy -%} +
+ + {# Image #} +
+ {% if product.image_url %} + {{ product.name }} + {% else %} + + {% endif %} +
+ + {# Brand #} + {% if product.brand %} + {{ product.brand }} + {% endif %} + + {# Name #} +

{{ product.name }}

+ + {# Rating + pros/cons counts #} +
+ {% if product.rating %} + + {{ "%.1f" | format(product.rating) }} + {% endif %} + {% if product.pros %} + {{ product.pros | length }} {{ t.affiliate_pros_label }} + {% endif %} +
+ + {# Price #} + {% if price_eur %} +
{{ "%.2f" | format(price_eur) | replace('.', ',') }} €
+ {% endif %} + + {# CTA — pushed to bottom via margin-top:auto #} + + {{ cta }} → + + +
+ {% endfor %} +
+ + {# Shared disclosure #} +

{{ t.affiliate_disclosure }}

+ +
+{% endif %} From ef85d3bb36cb7a6ebb4f1bc0fb32e457d2fcbbda Mon Sep 17 00:00:00 2001 From: Deeman Date: Sat, 28 Feb 2026 18:41:04 +0100 Subject: [PATCH 4/9] feat(affiliate): /go/ click redirect with rate limiting + click logging 302 redirect (not 301) so every click is tracked. Extracts lang/article_slug from Referer header best-effort. Rate-limited to 60/min per IP; clicks above limit still redirect but are not logged to prevent amplification. Co-Authored-By: Claude Sonnet 4.6 --- web/src/padelnomics/app.py | 43 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/web/src/padelnomics/app.py b/web/src/padelnomics/app.py index f7ea5bd..82ba62a 100644 --- a/web/src/padelnomics/app.py +++ b/web/src/padelnomics/app.py @@ -280,6 +280,49 @@ def create_app() -> Quart: except Exception as e: return {"status": "unhealthy", "db": str(e)}, 500 + # ------------------------------------------------------------------------- + # Affiliate click redirect — language-agnostic, no blueprint prefix + # ------------------------------------------------------------------------- + + @app.route("/go/") + async def affiliate_redirect(slug: str): + """302 redirect to affiliate URL, logging the click. + + 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 .core import check_rate_limit + + # Extract lang from Referer path (e.g. /de/blog/... → "de"), default de + referer = request.headers.get("Referer", "") + lang = "de" + article_slug = None + if referer: + try: + from urllib.parse import urlparse + ref_path = urlparse(referer).path + parts = ref_path.strip("/").split("/") + if parts and len(parts[0]) == 2: + lang = parts[0] + if len(parts) > 1: + article_slug = parts[-1] or None + except Exception: + pass + + product = await get_product(slug, lang) + if not product: + abort(404) + + 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) + + await log_click(product["id"], ip, article_slug, referer or None) + return redirect(product["affiliate_url"], 302) + # Legacy 301 redirects — bookmarked/cached URLs before lang prefixes existed @app.route("/terms") async def legacy_terms(): From bc7e40b531e541d62580f4bd5d645ade89df76a6 Mon Sep 17 00:00:00 2001 From: Deeman Date: Sat, 28 Feb 2026 18:50:25 +0100 Subject: [PATCH 5/9] =?UTF-8?q?feat(affiliate):=20admin=20CRUD=20=E2=80=94?= =?UTF-8?q?=20routes,=20list/form=20templates,=20sidebar=20entry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Routes: GET/POST affiliate, affiliate/results (HTMX), affiliate/new, affiliate//edit, affiliate//delete, affiliate//toggle. Templates: affiliate_products.html (filterable list), affiliate_form.html (two-column with live preview slot), partials/affiliate_row.html, partials/affiliate_results.html. Affiliate added to base_admin.html sidebar and subnav (Products | Dashboard). Co-Authored-By: Claude Sonnet 4.6 --- web/src/padelnomics/admin/routes.py | 327 ++++++++++++++++++ .../admin/templates/admin/affiliate_form.html | 216 ++++++++++++ .../templates/admin/affiliate_products.html | 83 +++++ .../admin/templates/admin/base_admin.html | 11 + .../admin/partials/affiliate_results.html | 9 + .../admin/partials/affiliate_row.html | 29 ++ 6 files changed, 675 insertions(+) create mode 100644 web/src/padelnomics/admin/templates/admin/affiliate_form.html create mode 100644 web/src/padelnomics/admin/templates/admin/affiliate_products.html create mode 100644 web/src/padelnomics/admin/templates/admin/partials/affiliate_results.html create mode 100644 web/src/padelnomics/admin/templates/admin/partials/affiliate_row.html diff --git a/web/src/padelnomics/admin/routes.py b/web/src/padelnomics/admin/routes.py index 6f276a0..fc35e7d 100644 --- a/web/src/padelnomics/admin/routes.py +++ b/web/src/padelnomics/admin/routes.py @@ -3236,3 +3236,330 @@ async def outreach_import(): await flash(f"Imported {imported} suppliers. Skipped {skipped} (duplicates or missing data).", "success") return redirect(url_for("admin.outreach")) + + +# ============================================================================= +# Affiliate Product Catalog +# ============================================================================= + +AFFILIATE_CATEGORIES = ("racket", "ball", "shoe", "bag", "grip", "eyewear", "accessory") +AFFILIATE_STATUSES = ("draft", "active", "archived") + + +def _form_to_product(form) -> dict: + """Parse affiliate product form values into a data dict.""" + price_str = form.get("price_eur", "").strip() + price_cents = None + if price_str: + try: + price_cents = round(float(price_str.replace(",", ".")) * 100) + except ValueError: + price_cents = None + + rating_str = form.get("rating", "").strip() + rating = None + if rating_str: + try: + rating = float(rating_str.replace(",", ".")) + except ValueError: + rating = None + + pros_raw = form.get("pros", "").strip() + cons_raw = form.get("cons", "").strip() + pros = json.dumps([l.strip() for l in pros_raw.splitlines() if l.strip()]) + cons = json.dumps([l.strip() for l in cons_raw.splitlines() if l.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(), + "image_url": form.get("image_url", "").strip(), + "price_cents": price_cents, + "currency": "EUR", + "rating": rating, + "pros": pros, + "cons": cons, + "description": form.get("description", "").strip(), + "cta_label": form.get("cta_label", "").strip(), + "status": form.get("status", "draft").strip(), + "language": form.get("language", "de").strip() or "de", + "sort_order": int(form.get("sort_order", "0") or "0"), + } + + +@bp.route("/affiliate") +@role_required("admin") +async def affiliate_products(): + """Affiliate product list — full page.""" + from ..affiliate import get_all_products, get_click_counts, get_distinct_retailers + + q = request.args.get("q", "").strip() + category = request.args.get("category", "").strip() + retailer_filter = request.args.get("retailer", "").strip() + status_filter = request.args.get("status", "").strip() + + products = await get_all_products( + status=status_filter or None, + retailer=retailer_filter or None, + ) + if q: + q_lower = q.lower() + products = [p for p in products if q_lower in p["name"].lower() or q_lower in p["brand"].lower()] + if category: + products = [p for p in products if p["category"] == category] + + click_counts = await get_click_counts() + for p in products: + p["click_count"] = click_counts.get(p["id"], 0) + + retailers = await get_distinct_retailers() + + return await render_template( + "admin/affiliate_products.html", + admin_page="affiliate", + products=products, + click_counts=click_counts, + retailers=retailers, + categories=AFFILIATE_CATEGORIES, + statuses=AFFILIATE_STATUSES, + q=q, + category=category, + retailer_filter=retailer_filter, + status_filter=status_filter, + ) + + +@bp.route("/affiliate/results") +@role_required("admin") +async def affiliate_results(): + """HTMX partial: filtered product rows.""" + from ..affiliate import get_all_products, get_click_counts + + q = request.args.get("q", "").strip() + category = request.args.get("category", "").strip() + retailer_filter = request.args.get("retailer", "").strip() + status_filter = request.args.get("status", "").strip() + + products = await get_all_products( + status=status_filter or None, + retailer=retailer_filter or None, + ) + if q: + q_lower = q.lower() + products = [p for p in products if q_lower in p["name"].lower() or q_lower in p["brand"].lower()] + if category: + products = [p for p in products if p["category"] == category] + + click_counts = await get_click_counts() + for p in products: + p["click_count"] = click_counts.get(p["id"], 0) + + return await render_template( + "admin/partials/affiliate_results.html", + products=products, + ) + + +@bp.route("/affiliate/new", methods=["GET", "POST"]) +@role_required("admin") +@csrf_protect +async def affiliate_new(): + """Create an affiliate product.""" + from ..affiliate import 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") + return await render_template( + "admin/affiliate_form.html", + admin_page="affiliate", + data=data, + editing=False, + categories=AFFILIATE_CATEGORIES, + statuses=AFFILIATE_STATUSES, + retailers=await get_distinct_retailers(), + ) + + existing = await fetch_one( + "SELECT id FROM affiliate_products WHERE slug = ? AND language = ?", + (data["slug"], data["language"]), + ) + if existing: + await flash(f"Slug '{data['slug']}' already exists for language '{data['language']}'.", "error") + return await render_template( + "admin/affiliate_form.html", + admin_page="affiliate", + data=data, + editing=False, + categories=AFFILIATE_CATEGORIES, + statuses=AFFILIATE_STATUSES, + retailers=await get_distinct_retailers(), + ) + + 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + ( + data["slug"], data["name"], data["brand"], data["category"], + data["retailer"], 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"], + ), + ) + await flash(f"Product '{data['name']}' created.", "success") + return redirect(url_for("admin.affiliate_products")) + + return await render_template( + "admin/affiliate_form.html", + admin_page="affiliate", + data={}, + editing=False, + categories=AFFILIATE_CATEGORIES, + statuses=AFFILIATE_STATUSES, + retailers=await get_distinct_retailers(), + ) + + +@bp.route("/affiliate//edit", methods=["GET", "POST"]) +@role_required("admin") +@csrf_protect +async def affiliate_edit(product_id: int): + """Edit an affiliate product.""" + from ..affiliate import get_distinct_retailers + + product = await fetch_one("SELECT * FROM affiliate_products WHERE id = ?", (product_id,)) + if not product: + await flash("Product not found.", "error") + return redirect(url_for("admin.affiliate_products")) + + 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") + return await render_template( + "admin/affiliate_form.html", + admin_page="affiliate", + data={**dict(product), **data}, + editing=True, + product_id=product_id, + categories=AFFILIATE_CATEGORIES, + statuses=AFFILIATE_STATUSES, + retailers=await get_distinct_retailers(), + ) + + # Check slug collision only if slug or language changed + if data["slug"] != product["slug"] or data["language"] != product["language"]: + collision = await fetch_one( + "SELECT id FROM affiliate_products WHERE slug = ? AND language = ? AND id != ?", + (data["slug"], data["language"], product_id), + ) + if collision: + await flash(f"Slug '{data['slug']}' already exists for language '{data['language']}'.", "error") + return await render_template( + "admin/affiliate_form.html", + admin_page="affiliate", + data={**dict(product), **data}, + editing=True, + product_id=product_id, + categories=AFFILIATE_CATEGORIES, + statuses=AFFILIATE_STATUSES, + retailers=await get_distinct_retailers(), + ) + + await execute( + """UPDATE affiliate_products + SET slug=?, name=?, brand=?, category=?, retailer=?, 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["price_cents"], data["currency"], data["rating"], + data["pros"], data["cons"], data["description"], data["cta_label"], + data["status"], data["language"], data["sort_order"], + product_id, + ), + ) + await flash(f"Product '{data['name']}' updated.", "success") + return redirect(url_for("admin.affiliate_products")) + + # Render pros/cons JSON arrays as newline-separated text for the form + product_dict = dict(product) + try: + product_dict["pros_text"] = "\n".join(json.loads(product["pros"] or "[]")) + product_dict["cons_text"] = "\n".join(json.loads(product["cons"] or "[]")) + except (json.JSONDecodeError, TypeError): + product_dict["pros_text"] = "" + product_dict["cons_text"] = "" + if product["price_cents"]: + product_dict["price_eur"] = f"{product['price_cents'] / 100:.2f}" + else: + product_dict["price_eur"] = "" + + return await render_template( + "admin/affiliate_form.html", + admin_page="affiliate", + data=product_dict, + editing=True, + product_id=product_id, + categories=AFFILIATE_CATEGORIES, + statuses=AFFILIATE_STATUSES, + retailers=await get_distinct_retailers(), + ) + + +@bp.route("/affiliate//delete", methods=["POST"]) +@role_required("admin") +@csrf_protect +async def affiliate_delete(product_id: int): + """Delete an affiliate product.""" + product = await fetch_one("SELECT name FROM affiliate_products WHERE id = ?", (product_id,)) + if product: + await execute("DELETE FROM affiliate_products WHERE id = ?", (product_id,)) + await flash(f"Product '{product['name']}' deleted.", "success") + return redirect(url_for("admin.affiliate_products")) + + +@bp.route("/affiliate//toggle", methods=["POST"]) +@role_required("admin") +async def affiliate_toggle(product_id: int): + """Toggle product status: draft → active → archived → draft.""" + product = await fetch_one( + "SELECT id, name, status FROM affiliate_products WHERE id = ?", (product_id,) + ) + if not product: + return "", 404 + + cycle = {"draft": "active", "active": "archived", "archived": "draft"} + new_status = cycle.get(product["status"], "draft") + await execute( + "UPDATE affiliate_products SET status=?, updated_at=datetime('now') WHERE id=?", + (new_status, product_id), + ) + + product_updated = await fetch_one( + "SELECT * FROM affiliate_products WHERE id = ?", (product_id,) + ) + from ..affiliate import get_click_counts + click_counts = await get_click_counts() + product_dict = dict(product_updated) + product_dict["click_count"] = click_counts.get(product_id, 0) + + return await render_template( + "admin/partials/affiliate_row.html", + product=product_dict, + ) diff --git a/web/src/padelnomics/admin/templates/admin/affiliate_form.html b/web/src/padelnomics/admin/templates/admin/affiliate_form.html new file mode 100644 index 0000000..35302c6 --- /dev/null +++ b/web/src/padelnomics/admin/templates/admin/affiliate_form.html @@ -0,0 +1,216 @@ +{% extends "admin/base_admin.html" %} +{% set admin_page = "affiliate" %} + +{% block title %}{% if editing %}Edit Product{% else %}New Product{% endif %} - Admin - {{ config.APP_NAME }}{% endblock %} + +{% block admin_head %} + +{% endblock %} + +{% block admin_content %} +
+
+ ← Products +

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

+
+
+ +
+ + {# ── Left: form ── #} +
+ + +
+ + {# Name #} +
+ + +
+ + {# Slug #} +
+ + +

Lowercase letters, numbers, hyphens only. Include retailer to disambiguate (e.g. -amazon, -padelnuestro).

+
+ + {# Brand + Category row #} +
+
+ + +
+
+ + +
+
+ + {# Retailer #} +
+ + + + {% for r in retailers %} + +
+ + {# Affiliate URL #} +
+ + +

Full URL with tracking params already baked in.

+
+ + {# Image URL #} +
+ + +

Local path (recommended) or external URL.

+
+ + {# Price + Rating row #} +
+
+ + +
+
+ + +
+
+ + {# Description #} +
+ + +
+ + {# Pros #} +
+ + +
+ + {# Cons #} +
+ + +
+ + {# CTA Label #} +
+ + +
+ + {# Status + Language + Sort #} +
+
+ + +
+
+ + +
+
+ + +
+
+ + {# Actions #} +
+
+ + Cancel +
+ {% if editing %} + + + + + {% endif %} +
+ +
+ + + {# ── Right: live preview ── #} +
+
Preview
+
+

+ Fill in the form to see a live preview. +

+
+
+ +
+ + +{% endblock %} diff --git a/web/src/padelnomics/admin/templates/admin/affiliate_products.html b/web/src/padelnomics/admin/templates/admin/affiliate_products.html new file mode 100644 index 0000000..925390f --- /dev/null +++ b/web/src/padelnomics/admin/templates/admin/affiliate_products.html @@ -0,0 +1,83 @@ +{% extends "admin/base_admin.html" %} +{% set admin_page = "affiliate" %} + +{% block title %}Affiliate Products - Admin - {{ config.APP_NAME }}{% endblock %} + +{% block admin_content %} +
+

Affiliate Products

+ + New Product +
+ + {# Filters #} +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+
+ + {# Results #} +
+ + + + + + + + + + + + + + + {% include "admin/partials/affiliate_results.html" %} + +
NameBrandRetailerCategoryPriceStatusClicksActions
+
+{% endblock %} diff --git a/web/src/padelnomics/admin/templates/admin/base_admin.html b/web/src/padelnomics/admin/templates/admin/base_admin.html index def2822..fde5947 100644 --- a/web/src/padelnomics/admin/templates/admin/base_admin.html +++ b/web/src/padelnomics/admin/templates/admin/base_admin.html @@ -99,6 +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', 'billing': 'billing', 'seo': 'analytics', 'pipeline': 'pipeline', @@ -149,6 +150,11 @@ Billing + + + Affiliate + + Analytics @@ -196,6 +202,11 @@ Audiences Outreach + {% elif active_section == 'affiliate' %} + {% elif active_section == 'system' %}