merge: affiliate product system (racket/gear editorial cards, click tracking, admin CRUD)
This commit is contained in:
@@ -2499,7 +2499,12 @@ 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 +2528,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 +2567,12 @@ 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 +2602,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 +2747,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 +2772,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)
|
||||
|
||||
@@ -3233,3 +3246,363 @@ 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([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()])
|
||||
|
||||
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/<int:product_id>/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/<int:product_id>/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/dashboard")
|
||||
@role_required("admin")
|
||||
async def affiliate_dashboard():
|
||||
"""Affiliate click statistics dashboard."""
|
||||
from ..affiliate import get_click_stats
|
||||
|
||||
days_count = int(request.args.get("days", "30") or "30")
|
||||
days_count = max(7, min(days_count, 365))
|
||||
stats = await get_click_stats(days_count)
|
||||
|
||||
# Build estimated revenue: clicks × assumed 3% CR × avg basket €80
|
||||
est_revenue = round(stats["total_clicks"] * 0.03 * 80)
|
||||
|
||||
# Article count (live articles that have been clicked)
|
||||
article_count = len(stats["top_articles"])
|
||||
|
||||
# Retailer bars: compute pct of max for width
|
||||
max_ret_clicks = max((r["click_count"] for r in stats["by_retailer"]), default=1)
|
||||
for r in stats["by_retailer"]:
|
||||
r["pct"] = round(r["click_count"] / max_ret_clicks * 100) if max_ret_clicks else 0
|
||||
total = stats["total_clicks"] or 1
|
||||
r["share_pct"] = round(r["click_count"] / total * 100)
|
||||
|
||||
return await render_template(
|
||||
"admin/affiliate_dashboard.html",
|
||||
admin_page="affiliate_dashboard",
|
||||
stats=stats,
|
||||
est_revenue=est_revenue,
|
||||
article_count=article_count,
|
||||
days_count=days_count,
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/affiliate/<int:product_id>/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,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
{% extends "admin/base_admin.html" %}
|
||||
{% set admin_page = "affiliate_dashboard" %}
|
||||
|
||||
{% block title %}Affiliate Dashboard - Admin - {{ config.APP_NAME }}{% endblock %}
|
||||
|
||||
{% block admin_content %}
|
||||
<header class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-2xl">Affiliate Dashboard</h1>
|
||||
<div class="flex gap-2">
|
||||
{% for d in [7, 30, 90] %}
|
||||
<a href="?days={{ d }}" class="btn-outline btn-sm {% if days_count == d %}active{% endif %}">{{ d }}d</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{# ── Stats strip ── #}
|
||||
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:1rem;margin-bottom:1.5rem;">
|
||||
|
||||
<div class="card" style="padding:1.25rem;">
|
||||
<div class="text-xs font-semibold text-slate" style="text-transform:uppercase;letter-spacing:.06em;margin-bottom:.375rem;">Clicks ({{ days_count }}d)</div>
|
||||
<div class="mono" style="font-size:1.75rem;font-weight:700;color:#0F172A;">{{ stats.total_clicks | int }}</div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="padding:1.25rem;">
|
||||
<div class="text-xs font-semibold text-slate" style="text-transform:uppercase;letter-spacing:.06em;margin-bottom:.375rem;">Products</div>
|
||||
<div class="mono" style="font-size:1.75rem;font-weight:700;color:#0F172A;">{{ stats.active_products or 0 }}</div>
|
||||
<div class="text-xs text-slate">{{ stats.draft_products or 0 }} draft</div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="padding:1.25rem;">
|
||||
<div class="text-xs font-semibold text-slate" style="text-transform:uppercase;letter-spacing:.06em;margin-bottom:.375rem;">Articles (clicked)</div>
|
||||
<div class="mono" style="font-size:1.75rem;font-weight:700;color:#0F172A;">{{ article_count }}</div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="padding:1.25rem;">
|
||||
<div class="text-xs font-semibold text-slate" style="text-transform:uppercase;letter-spacing:.06em;margin-bottom:.375rem;">Est. Revenue</div>
|
||||
<div class="mono" style="font-size:1.75rem;font-weight:700;color:#0F172A;">~€{{ est_revenue }}</div>
|
||||
<div class="text-xs text-slate">3% CR × €80 basket</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{# ── Daily bar chart ── #}
|
||||
{% if stats.daily_bars %}
|
||||
<div class="card mb-6" style="padding:1.5rem;">
|
||||
<div class="text-xs font-semibold text-slate mb-4" style="text-transform:uppercase;letter-spacing:.06em;">Clicks · Last {{ days_count }} Days</div>
|
||||
<div style="display:flex;align-items:flex-end;gap:2px;height:120px;overflow-x:auto;">
|
||||
{% for bar in stats.daily_bars %}
|
||||
<div title="{{ bar.day }}: {{ bar.click_count }} clicks"
|
||||
style="flex-shrink:0;width:8px;background:#1D4ED8;border-radius:3px 3px 0 0;min-height:2px;height:{{ bar.pct }}%;transition:opacity .15s;"
|
||||
onmouseover="this.style.opacity='.7'" onmouseout="this.style.opacity='1'">
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div style="display:flex;justify-content:space-between;margin-top:.375rem;">
|
||||
<span class="text-xs text-slate">{{ stats.daily_bars[0].day if stats.daily_bars else '' }}</span>
|
||||
<span class="text-xs text-slate">{{ stats.daily_bars[-1].day if stats.daily_bars else '' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1.5rem;margin-bottom:1.5rem;">
|
||||
|
||||
{# ── Top products ── #}
|
||||
<div class="card" style="padding:1.5rem;">
|
||||
<div class="text-xs font-semibold text-slate mb-4" style="text-transform:uppercase;letter-spacing:.06em;">Top Products</div>
|
||||
{% if stats.top_products %}
|
||||
{% for p in stats.top_products %}
|
||||
<div style="display:flex;align-items:center;gap:.75rem;padding:.5rem 0;{% if not loop.last %}border-bottom:1px solid #F1F5F9;{% endif %}">
|
||||
<span class="mono text-xs text-slate" style="width:1.5rem;text-align:right;">{{ loop.index }}</span>
|
||||
<span style="flex:1;font-size:.8125rem;color:#0F172A;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">
|
||||
<a href="{{ url_for('admin.affiliate_edit', product_id=p.id) }}" style="color:inherit;text-decoration:none;">{{ p.name }}</a>
|
||||
</span>
|
||||
<span class="mono" style="font-weight:600;font-size:.875rem;color:#0F172A;">{{ p.click_count }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p class="text-slate text-sm">No clicks yet.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# ── Top articles ── #}
|
||||
<div class="card" style="padding:1.5rem;">
|
||||
<div class="text-xs font-semibold text-slate mb-4" style="text-transform:uppercase;letter-spacing:.06em;">Top Articles</div>
|
||||
{% if stats.top_articles %}
|
||||
{% for a in stats.top_articles %}
|
||||
<div style="display:flex;align-items:center;gap:.75rem;padding:.5rem 0;{% if not loop.last %}border-bottom:1px solid #F1F5F9;{% endif %}">
|
||||
<span class="mono text-xs text-slate" style="width:1.5rem;text-align:right;">{{ loop.index }}</span>
|
||||
<span style="flex:1;font-size:.8125rem;color:#0F172A;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"
|
||||
title="{{ a.article_slug }}">{{ a.article_slug }}</span>
|
||||
<span class="mono" style="font-weight:600;font-size:.875rem;color:#0F172A;">{{ a.click_count }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p class="text-slate text-sm">No clicks with article source yet.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{# ── Clicks by retailer ── #}
|
||||
{% if stats.by_retailer %}
|
||||
<div class="card" style="padding:1.5rem;">
|
||||
<div class="text-xs font-semibold text-slate mb-4" style="text-transform:uppercase;letter-spacing:.06em;">Clicks by Retailer</div>
|
||||
{% for r in stats.by_retailer %}
|
||||
<div style="display:flex;align-items:center;gap:1rem;margin-bottom:.75rem;">
|
||||
<span style="width:140px;font-size:.8125rem;color:#0F172A;flex-shrink:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">
|
||||
{{ r.retailer or 'Unknown' }}
|
||||
</span>
|
||||
<div style="flex:1;background:#F1F5F9;border-radius:4px;height:24px;overflow:hidden;">
|
||||
<div style="width:{{ r.pct }}%;background:#1D4ED8;height:100%;border-radius:4px;min-width:2px;"></div>
|
||||
</div>
|
||||
<span class="mono" style="font-size:.8125rem;font-weight:600;width:60px;text-align:right;flex-shrink:0;">
|
||||
{{ r.click_count }} <span class="text-slate" style="font-weight:400;">({{ r.share_pct }}%)</span>
|
||||
</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
216
web/src/padelnomics/admin/templates/admin/affiliate_form.html
Normal file
216
web/src/padelnomics/admin/templates/admin/affiliate_form.html
Normal file
@@ -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 %}
|
||||
<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_products') }}" class="text-slate text-sm" style="text-decoration:none">← Products</a>
|
||||
<h1 class="text-2xl mt-1">{% if editing %}Edit Product{% else %}New Product{% endif %}</h1>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 380px;gap:2rem;align-items:start" class="affiliate-form-grid">
|
||||
|
||||
{# ── Left: form ── #}
|
||||
<form method="post" id="affiliate-form"
|
||||
hx-post="{% if editing %}{{ url_for('admin.affiliate_edit', product_id=product_id) }}{% else %}{{ url_for('admin.affiliate_new') }}{% endif %}"
|
||||
hx-target="#product-preview"
|
||||
hx-trigger="input delay:600ms"
|
||||
hx-push-url="false">
|
||||
<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. Bullpadel Vertex 04" 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. bullpadel-vertex-04-amazon" required
|
||||
pattern="[a-z0-9][a-z0-9\-]*">
|
||||
<p class="form-hint">Lowercase letters, numbers, hyphens only. Include retailer to disambiguate (e.g. <code>-amazon</code>, <code>-padelnuestro</code>).</p>
|
||||
</div>
|
||||
|
||||
{# Brand + Category row #}
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;">
|
||||
<div>
|
||||
<label class="form-label" for="f-brand">Brand</label>
|
||||
<input id="f-brand" type="text" name="brand" value="{{ data.get('brand','') }}"
|
||||
class="form-input" placeholder="e.g. Bullpadel">
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label" for="f-category">Category</label>
|
||||
<select id="f-category" name="category" class="form-input">
|
||||
{% for cat in categories %}
|
||||
<option value="{{ cat }}" {% if data.get('category','accessory') == cat %}selected{% endif %}>{{ cat | capitalize }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Retailer #}
|
||||
<div>
|
||||
<label class="form-label" for="f-retailer">Retailer</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">
|
||||
<datalist id="retailers-list">
|
||||
{% for r in retailers %}
|
||||
<option value="{{ r }}">
|
||||
{% endfor %}
|
||||
</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>
|
||||
<input id="f-image" type="text" name="image_url" value="{{ data.get('image_url','') }}"
|
||||
class="form-input" placeholder="/static/images/affiliate/bullpadel-vertex-04.webp">
|
||||
<p class="form-hint">Local path (recommended) or external URL.</p>
|
||||
</div>
|
||||
|
||||
{# Price + Rating row #}
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;">
|
||||
<div>
|
||||
<label class="form-label" for="f-price">Price (EUR)</label>
|
||||
<input id="f-price" type="number" name="price_eur" value="{{ data.get('price_eur','') }}"
|
||||
class="form-input" placeholder="149.99" step="0.01" min="0">
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label" for="f-rating">Rating (0–5)</label>
|
||||
<input id="f-rating" type="number" name="rating" value="{{ data.get('rating','') }}"
|
||||
class="form-input" placeholder="4.3" step="0.1" min="0" max="5">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Description #}
|
||||
<div>
|
||||
<label class="form-label" for="f-desc">Short Description</label>
|
||||
<textarea id="f-desc" name="description" rows="3"
|
||||
class="form-input" placeholder="One to two sentences describing the product...">{{ data.get('description','') }}</textarea>
|
||||
</div>
|
||||
|
||||
{# Pros #}
|
||||
<div>
|
||||
<label class="form-label" for="f-pros">Pros <span class="form-hint" style="font-weight:normal">(one per line)</span></label>
|
||||
<textarea id="f-pros" name="pros" rows="4"
|
||||
class="form-input" placeholder="Carbon frame for maximum power Diamond shape for aggressive players">{{ data.get('pros_text', data.get('pros','')) }}</textarea>
|
||||
</div>
|
||||
|
||||
{# Cons #}
|
||||
<div>
|
||||
<label class="form-label" for="f-cons">Cons <span class="form-hint" style="font-weight:normal">(one per line)</span></label>
|
||||
<textarea id="f-cons" name="cons" rows="3"
|
||||
class="form-input" placeholder="Only for advanced players">{{ data.get('cons_text', data.get('cons','')) }}</textarea>
|
||||
</div>
|
||||
|
||||
{# CTA Label #}
|
||||
<div>
|
||||
<label class="form-label" for="f-cta">CTA Label</label>
|
||||
<input id="f-cta" type="text" name="cta_label" value="{{ data.get('cta_label','') }}"
|
||||
class="form-input" placeholder='Leave empty for default "Zum Angebot"'>
|
||||
</div>
|
||||
|
||||
{# Status + Language + Sort #}
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:1rem;">
|
||||
<div>
|
||||
<label class="form-label" for="f-status">Status</label>
|
||||
<select id="f-status" name="status" class="form-input">
|
||||
{% for s in statuses %}
|
||||
<option value="{{ s }}" {% if data.get('status','draft') == s %}selected{% endif %}>{{ s | capitalize }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label" for="f-lang">Language</label>
|
||||
<select id="f-lang" name="language" class="form-input">
|
||||
<option value="de" {% if data.get('language','de') == 'de' %}selected{% endif %}>DE</option>
|
||||
<option value="en" {% if data.get('language','de') == 'en' %}selected{% endif %}>EN</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label" for="f-sort">Sort Order</label>
|
||||
<input id="f-sort" type="number" name="sort_order" value="{{ data.get('sort_order', 0) }}"
|
||||
class="form-input" min="0">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Actions #}
|
||||
<div class="flex gap-3 justify-between" style="margin-top:.5rem">
|
||||
<div class="flex gap-2">
|
||||
<button type="submit" class="btn" formaction="{% if editing %}{{ url_for('admin.affiliate_edit', product_id=product_id) }}{% else %}{{ url_for('admin.affiliate_new') }}{% endif %}">
|
||||
{% if editing %}Save Changes{% else %}Create Product{% endif %}
|
||||
</button>
|
||||
<a href="{{ url_for('admin.affiliate_products') }}" class="btn-outline">Cancel</a>
|
||||
</div>
|
||||
{% if editing %}
|
||||
<form method="post" action="{{ url_for('admin.affiliate_delete', product_id=product_id) }}" style="margin:0">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn-outline"
|
||||
onclick="return confirm('Delete this product? This cannot be undone.')">Delete</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{# ── Right: live preview ── #}
|
||||
<div style="position:sticky;top:1.5rem;">
|
||||
<div class="text-xs font-semibold text-slate mb-2" style="text-transform:uppercase;letter-spacing:.06em;">Preview</div>
|
||||
<div id="product-preview" style="border:1px solid #E2E8F0;border-radius:12px;padding:1rem;background:#F8FAFC;min-height:180px;">
|
||||
<p class="text-slate text-sm" style="text-align:center;margin-top:2rem;">
|
||||
Fill in the form to see a live preview.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@media (max-width: 900px) {
|
||||
.affiliate-form-grid { grid-template-columns: 1fr !important; }
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
@@ -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 %}
|
||||
<header class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-2xl">Affiliate Products</h1>
|
||||
<a href="{{ url_for('admin.affiliate_new') }}" class="btn btn-sm">+ New Product</a>
|
||||
</header>
|
||||
|
||||
{# Filters #}
|
||||
<div class="card mb-6" style="padding:1rem 1.25rem">
|
||||
<form class="flex flex-wrap gap-3 items-end"
|
||||
hx-get="{{ url_for('admin.affiliate_results') }}"
|
||||
hx-target="#aff-results"
|
||||
hx-trigger="change, input delay:300ms"
|
||||
hx-indicator="#aff-loading">
|
||||
|
||||
<div>
|
||||
<label class="text-xs font-semibold text-slate block mb-1">Search</label>
|
||||
<input type="text" name="q" value="{{ q }}" placeholder="Name or brand..."
|
||||
class="form-input" style="min-width:200px">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-xs font-semibold text-slate block mb-1">Category</label>
|
||||
<select name="category" class="form-input" style="min-width:120px">
|
||||
<option value="">All</option>
|
||||
{% for cat in categories %}
|
||||
<option value="{{ cat }}" {% if cat == category %}selected{% endif %}>{{ cat | capitalize }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-xs font-semibold text-slate block mb-1">Retailer</label>
|
||||
<select name="retailer" class="form-input" style="min-width:140px">
|
||||
<option value="">All</option>
|
||||
{% for r in retailers %}
|
||||
<option value="{{ r }}" {% if r == retailer_filter %}selected{% endif %}>{{ r }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-xs font-semibold text-slate block mb-1">Status</label>
|
||||
<select name="status" class="form-input" style="min-width:110px">
|
||||
<option value="">All</option>
|
||||
{% for s in statuses %}
|
||||
<option value="{{ s }}" {% if s == status_filter %}selected{% endif %}>{{ s | capitalize }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<svg id="aff-loading" class="htmx-indicator search-spinner" width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="10" stroke="#CBD5E1" stroke-width="3"/>
|
||||
<path d="M12 2a10 10 0 0 1 10 10" stroke="#0EA5E9" stroke-width="3" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{# Results #}
|
||||
<div id="aff-results">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Brand</th>
|
||||
<th>Retailer</th>
|
||||
<th>Category</th>
|
||||
<th>Price</th>
|
||||
<th>Status</th>
|
||||
<th class="text-right">Clicks</th>
|
||||
<th class="text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% include "admin/partials/affiliate_results.html" %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -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
|
||||
</a>
|
||||
|
||||
<a href="{{ url_for('admin.affiliate_products') }}" class="{% if active_section == 'affiliate' %}active{% endif %}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M13.5 21v-7.5a.75.75 0 0 1 .75-.75h3a.75.75 0 0 1 .75.75V21m-4.5 0H2.36m11.14 0H18m0 0h3.64m-1.39 0V9.349M3.75 21V9.349m0 0a3.001 3.001 0 0 0 3.75-.615A2.993 2.993 0 0 0 9.75 9.75c.896 0 1.7-.393 2.25-1.016a2.993 2.993 0 0 0 2.25 1.016 2.993 2.993 0 0 0 2.25-1.016 3.001 3.001 0 0 0 3.75.614m-16.5 0a3.004 3.004 0 0 1-.621-4.72l1.189-1.19A1.5 1.5 0 0 1 5.378 3h13.243a1.5 1.5 0 0 1 1.06.44l1.19 1.189a3 3 0 0 1-.621 4.72M6.75 18h3.75a.75.75 0 0 0 .75-.75V13.5a.75.75 0 0 0-.75-.75H6.75a.75.75 0 0 0-.75.75v3.75c0 .414.336.75.75.75Z"/></svg>
|
||||
Affiliate
|
||||
</a>
|
||||
|
||||
<a href="{{ url_for('admin.seo') }}" class="{% if active_section == 'analytics' %}active{% endif %}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 18 9 11.25l4.306 4.306a11.95 11.95 0 0 1 5.814-5.518l2.74-1.22m0 0-5.94-2.281m5.94 2.28-2.28 5.941"/></svg>
|
||||
Analytics
|
||||
@@ -196,6 +202,11 @@
|
||||
<a href="{{ url_for('admin.audiences') }}" class="{% if admin_page == 'audiences' %}active{% endif %}">Audiences</a>
|
||||
<a href="{{ url_for('admin.outreach') }}" class="{% if admin_page == 'outreach' %}active{% endif %}">Outreach</a>
|
||||
</nav>
|
||||
{% elif active_section == 'affiliate' %}
|
||||
<nav class="admin-subnav">
|
||||
<a href="{{ url_for('admin.affiliate_products') }}" class="{% if admin_page == 'affiliate' %}active{% endif %}">Products</a>
|
||||
<a href="{{ url_for('admin.affiliate_dashboard') }}" class="{% if admin_page == 'affiliate_dashboard' %}active{% endif %}">Dashboard</a>
|
||||
</nav>
|
||||
{% elif active_section == 'system' %}
|
||||
<nav class="admin-subnav">
|
||||
<a href="{{ url_for('admin.users') }}" class="{% if admin_page == 'users' %}active{% endif %}">Users</a>
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{% if products %}
|
||||
{% for product in products %}
|
||||
{% include "admin/partials/affiliate_row.html" %}
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="8" class="text-slate" style="text-align:center;padding:2rem;">No products found.</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,29 @@
|
||||
<tr id="aff-{{ product.id }}">
|
||||
<td style="max-width:240px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="{{ product.name }}">
|
||||
<a href="{{ url_for('admin.affiliate_edit', product_id=product.id) }}" style="color:#0F172A;text-decoration:none;font-weight:500;">{{ product.name }}</a>
|
||||
</td>
|
||||
<td class="text-slate">{{ product.brand or '—' }}</td>
|
||||
<td class="text-slate">{{ product.retailer or '—' }}</td>
|
||||
<td class="text-slate">{{ product.category }}</td>
|
||||
<td class="mono">
|
||||
{% if product.price_cents %}{{ "%.0f" | format(product.price_cents / 100) }}€{% else %}—{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<button hx-post="{{ url_for('admin.affiliate_toggle', product_id=product.id) }}"
|
||||
hx-target="#aff-{{ product.id }}" hx-swap="outerHTML"
|
||||
hx-headers='{"X-CSRF-Token": "{{ csrf_token() }}"}'
|
||||
class="badge {% if product.status == 'active' %}badge-success{% elif product.status == 'draft' %}badge-warning{% else %}badge{% endif %}"
|
||||
style="cursor:pointer;border:none;">
|
||||
{{ product.status }}
|
||||
</button>
|
||||
</td>
|
||||
<td class="mono text-right">{{ product.click_count or 0 }}</td>
|
||||
<td class="text-right" style="white-space:nowrap">
|
||||
<a href="{{ url_for('admin.affiliate_edit', product_id=product.id) }}" class="btn-outline btn-sm">Edit</a>
|
||||
<form method="post" action="{{ url_for('admin.affiliate_delete', product_id=product.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 {{ product.name }}?')">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
224
web/src/padelnomics/affiliate.py
Normal file
224
web/src/padelnomics/affiliate.py
Normal file
@@ -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
|
||||
@@ -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/<slug>")
|
||||
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():
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
# =============================================================================
|
||||
|
||||
@@ -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 -%}
|
||||
<div class="aff-card" style="background:#fff;border:1px solid #E2E8F0;border-radius:16px;padding:1.5rem;margin:1.5rem 0;box-shadow:0 1px 3px rgba(0,0,0,.05);transition:transform .2s,box-shadow .2s;" onmouseover="this.style.transform='translateY(-2px)';this.style.boxShadow='0 8px 24px rgba(0,0,0,.08)'" onmouseout="this.style.transform='';this.style.boxShadow='0 1px 3px rgba(0,0,0,.05)'">
|
||||
<div style="display:flex;gap:1.25rem;align-items:flex-start;flex-wrap:wrap;">
|
||||
|
||||
{# ── Image ── #}
|
||||
<div style="width:160px;flex-shrink:0;aspect-ratio:1;border-radius:12px;background:#F8FAFC;border:1px solid #E2E8F0;overflow:hidden;display:flex;align-items:center;justify-content:center;">
|
||||
{% if product.image_url %}
|
||||
<img src="{{ product.image_url }}" alt="{{ product.name }}" style="width:100%;height:100%;object-fit:contain;" loading="lazy">
|
||||
{% else %}
|
||||
<svg width="48" height="48" fill="none" stroke="#CBD5E1" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Z"/></svg>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# ── Content ── #}
|
||||
<div style="flex:1;min-width:0;">
|
||||
|
||||
{# Brand + retailer #}
|
||||
<div style="display:flex;align-items:center;gap:.5rem;margin-bottom:.375rem;">
|
||||
{% if product.brand %}
|
||||
<span style="text-transform:uppercase;font-size:.6875rem;font-weight:600;letter-spacing:.06em;color:#64748B;">{{ product.brand }}</span>
|
||||
{% endif %}
|
||||
{% if product.retailer %}
|
||||
<span style="background:#F1F5F9;border-radius:999px;padding:2px 8px;font-size:.625rem;font-weight:600;color:#64748B;letter-spacing:.04em;text-transform:uppercase;">{{ t.affiliate_at_retailer | tformat(retailer=product.retailer) }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# Name #}
|
||||
<h3 style="font-family:'Bricolage Grotesque',sans-serif;font-size:1.125rem;font-weight:700;color:#0F172A;letter-spacing:-.01em;margin:0 0 .375rem;">{{ product.name }}</h3>
|
||||
|
||||
{# Rating #}
|
||||
{% if product.rating %}
|
||||
{%- set stars_full = product.rating | int -%}
|
||||
{%- set has_half = (product.rating - stars_full) >= 0.5 -%}
|
||||
<div style="display:flex;align-items:center;gap:.25rem;margin-bottom:.375rem;">
|
||||
<span style="color:#D97706;font-size:.9375rem;">
|
||||
{%- for i in range(stars_full) %}★{% endfor -%}
|
||||
{%- if has_half %}★{% endif -%}
|
||||
{%- for i in range(5 - stars_full - (1 if has_half else 0)) %}<span style="color:#E2E8F0;">★</span>{% endfor -%}
|
||||
</span>
|
||||
<span style="font-size:.8125rem;color:#64748B;">{{ "%.1f" | format(product.rating) }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Price #}
|
||||
{% if price_eur %}
|
||||
<div style="font-family:'Commit Mono',monospace;font-size:1.25rem;font-weight:700;color:#0F172A;margin-bottom:.5rem;">{{ "%.2f" | format(price_eur) | replace('.', ',') }} €</div>
|
||||
{% endif %}
|
||||
|
||||
{# Description #}
|
||||
{% if product.description %}
|
||||
<p style="font-size:.875rem;color:#475569;line-height:1.55;margin:.625rem 0;overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;">{{ product.description }}</p>
|
||||
{% endif %}
|
||||
|
||||
{# Pros #}
|
||||
{% if product.pros %}
|
||||
<ul style="list-style:none;padding:0;margin:.625rem 0 .25rem;">
|
||||
{% for pro in product.pros %}
|
||||
<li style="font-size:.8125rem;color:#475569;line-height:1.7;"><span style="color:#16A34A;margin-right:.25rem;">✓</span>{{ pro }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
{# Cons #}
|
||||
{% if product.cons %}
|
||||
<ul style="list-style:none;padding:0;margin:.25rem 0 .75rem;">
|
||||
{% for con in product.cons %}
|
||||
<li style="font-size:.8125rem;color:#475569;line-height:1.7;"><span style="color:#EF4444;margin-right:.25rem;">✗</span>{{ con }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
{# CTA #}
|
||||
<a href="/go/{{ product.slug }}" rel="sponsored nofollow noopener" target="_blank"
|
||||
style="display:block;width:100%;background:#1D4ED8;color:#fff;border-radius:12px;padding:.625rem 1.25rem;font-weight:600;font-size:.875rem;text-align:center;text-decoration:none;box-shadow:0 2px 10px rgba(29,78,216,.25);transition:background .2s,transform .2s;margin-top:.5rem;"
|
||||
onmouseover="this.style.background='#1E40AF';this.style.transform='translateY(-1px)'"
|
||||
onmouseout="this.style.background='#1D4ED8';this.style.transform=''">
|
||||
{{ cta }} →
|
||||
</a>
|
||||
|
||||
{# Disclosure #}
|
||||
<p style="font-size:.6875rem;color:#94A3B8;font-style:italic;margin:.5rem 0 0;text-align:center;">{{ t.affiliate_disclosure }}</p>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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 %}
|
||||
<div style="margin:2rem 0;">
|
||||
|
||||
{# Section header #}
|
||||
<div style="text-transform:uppercase;font-size:.75rem;font-weight:600;color:#64748B;letter-spacing:.06em;margin-bottom:1rem;padding-bottom:.75rem;border-bottom:2px solid #E2E8F0;">
|
||||
{{ t.affiliate_our_picks }} · {{ category | capitalize }}
|
||||
</div>
|
||||
|
||||
{# Responsive grid of compact cards #}
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:1rem;">
|
||||
{% 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 -%}
|
||||
<div class="aff-card-compact" style="background:#fff;border:1px solid #E2E8F0;border-radius:16px;padding:1rem;display:flex;flex-direction:column;gap:.5rem;transition:transform .2s,box-shadow .2s;" onmouseover="this.style.transform='translateY(-2px)';this.style.boxShadow='0 8px 24px rgba(0,0,0,.08)'" onmouseout="this.style.transform='';this.style.boxShadow=''">
|
||||
|
||||
{# Image #}
|
||||
<div style="aspect-ratio:1;border-radius:10px;background:#F8FAFC;border:1px solid #E2E8F0;overflow:hidden;display:flex;align-items:center;justify-content:center;">
|
||||
{% if product.image_url %}
|
||||
<img src="{{ product.image_url }}" alt="{{ product.name }}" style="width:100%;height:100%;object-fit:contain;" loading="lazy">
|
||||
{% else %}
|
||||
<svg width="36" height="36" fill="none" stroke="#CBD5E1" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Z"/></svg>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# Brand #}
|
||||
{% if product.brand %}
|
||||
<span style="text-transform:uppercase;font-size:.625rem;font-weight:600;letter-spacing:.06em;color:#94A3B8;">{{ product.brand }}</span>
|
||||
{% endif %}
|
||||
|
||||
{# Name #}
|
||||
<h4 style="font-family:'Bricolage Grotesque',sans-serif;font-size:1rem;font-weight:700;color:#0F172A;letter-spacing:-.01em;margin:0;line-height:1.3;">{{ product.name }}</h4>
|
||||
|
||||
{# Rating + pros/cons counts #}
|
||||
<div style="display:flex;align-items:center;gap:.5rem;flex-wrap:wrap;">
|
||||
{% if product.rating %}
|
||||
<span style="color:#D97706;font-size:.8125rem;">★</span>
|
||||
<span style="font-size:.75rem;color:#64748B;">{{ "%.1f" | format(product.rating) }}</span>
|
||||
{% endif %}
|
||||
{% if product.pros %}
|
||||
<span style="font-size:.6875rem;color:#16A34A;background:#F0FDF4;border-radius:999px;padding:1px 6px;">{{ product.pros | length }} {{ t.affiliate_pros_label }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# Price #}
|
||||
{% if price_eur %}
|
||||
<div style="font-family:'Commit Mono',monospace;font-size:1.0625rem;font-weight:700;color:#0F172A;">{{ "%.2f" | format(price_eur) | replace('.', ',') }} €</div>
|
||||
{% endif %}
|
||||
|
||||
{# CTA — pushed to bottom via margin-top:auto #}
|
||||
<a href="/go/{{ product.slug }}" rel="sponsored nofollow noopener" target="_blank"
|
||||
style="display:block;background:#1D4ED8;color:#fff;border-radius:10px;padding:.5rem 1rem;font-weight:600;font-size:.8125rem;text-align:center;text-decoration:none;margin-top:auto;transition:background .2s;"
|
||||
onmouseover="this.style.background='#1E40AF'"
|
||||
onmouseout="this.style.background='#1D4ED8'">
|
||||
{{ cta }} →
|
||||
</a>
|
||||
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{# Shared disclosure #}
|
||||
<p style="font-size:.6875rem;color:#94A3B8;font-style:italic;margin:.75rem 0 0;text-align:center;">{{ t.affiliate_disclosure }}</p>
|
||||
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -1777,5 +1777,12 @@
|
||||
"report_q1_confirmed_title": "Download bereit",
|
||||
"report_q1_confirmed_body": "Unten auf den Button klicken, um das vollständige Bericht-PDF zu öffnen.",
|
||||
"report_q1_download_btn": "PDF herunterladen",
|
||||
"report_q1_download_note": "PDF öffnet im Browser. Rechtsklick zum Speichern."
|
||||
"report_q1_download_note": "PDF öffnet im Browser. Rechtsklick zum Speichern.",
|
||||
|
||||
"affiliate_cta_buy": "Zum Angebot",
|
||||
"affiliate_disclosure": "Affiliate-Link — wir erhalten eine Provision ohne Mehrkosten für dich.",
|
||||
"affiliate_pros_label": "Vorteile",
|
||||
"affiliate_cons_label": "Nachteile",
|
||||
"affiliate_at_retailer": "bei {retailer}",
|
||||
"affiliate_our_picks": "Unsere Empfehlungen"
|
||||
}
|
||||
@@ -1780,5 +1780,12 @@
|
||||
"report_q1_confirmed_title": "Your download is ready",
|
||||
"report_q1_confirmed_body": "Click below to open the full report PDF.",
|
||||
"report_q1_download_btn": "Download PDF",
|
||||
"report_q1_download_note": "PDF opens in your browser. Right-click to save."
|
||||
"report_q1_download_note": "PDF opens in your browser. Right-click to save.",
|
||||
|
||||
"affiliate_cta_buy": "View offer",
|
||||
"affiliate_disclosure": "Affiliate link — we may earn a commission at no extra cost to you.",
|
||||
"affiliate_pros_label": "Pros",
|
||||
"affiliate_cons_label": "Cons",
|
||||
"affiliate_at_retailer": "at {retailer}",
|
||||
"affiliate_our_picks": "Our picks"
|
||||
}
|
||||
@@ -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/<slug> 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)"
|
||||
)
|
||||
332
web/tests/test_affiliate.py
Normal file
332
web/tests/test_affiliate.py
Normal file
@@ -0,0 +1,332 @@
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
import json
|
||||
from datetime import date
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from padelnomics.affiliate import (
|
||||
get_all_products,
|
||||
get_click_counts,
|
||||
get_click_stats,
|
||||
get_product,
|
||||
get_products_by_category,
|
||||
hash_ip,
|
||||
log_click,
|
||||
)
|
||||
from padelnomics.content.routes import PRODUCT_GROUP_RE, PRODUCT_RE, bake_product_cards
|
||||
from padelnomics.core import execute, fetch_all
|
||||
|
||||
# ── Helpers ────────────────────────────────────────────────────────────────────
|
||||
|
||||
async def _insert_product(
|
||||
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,
|
||||
) -> int:
|
||||
"""Insert an affiliate product, 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)
|
||||
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,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# ── hash_ip ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_hash_ip_deterministic():
|
||||
"""Same IP + same day → same hash."""
|
||||
h1 = hash_ip("1.2.3.4")
|
||||
h2 = hash_ip("1.2.3.4")
|
||||
assert h1 == h2
|
||||
assert len(h1) == 64 # SHA256 hex digest
|
||||
|
||||
|
||||
def test_hash_ip_different_ips_differ():
|
||||
"""Different IPs → different hashes."""
|
||||
assert hash_ip("1.2.3.4") != hash_ip("5.6.7.8")
|
||||
|
||||
|
||||
def test_hash_ip_rotates_daily():
|
||||
"""Different days → different hashes for same IP (GDPR daily rotation)."""
|
||||
with patch("padelnomics.affiliate.date") as mock_date:
|
||||
mock_date.today.return_value = date(2026, 2, 1)
|
||||
h1 = hash_ip("1.2.3.4")
|
||||
mock_date.today.return_value = date(2026, 2, 2)
|
||||
h2 = hash_ip("1.2.3.4")
|
||||
assert h1 != h2
|
||||
|
||||
|
||||
# ── get_product ────────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.usefixtures("db")
|
||||
async def test_get_product_active_by_lang(db):
|
||||
"""get_product returns active product for correct language."""
|
||||
await _insert_product(slug="vertex-amazon", language="de", status="active")
|
||||
product = await get_product("vertex-amazon", "de")
|
||||
assert product is not None
|
||||
assert product["slug"] == "vertex-amazon"
|
||||
assert isinstance(product["pros"], list)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("db")
|
||||
async def test_get_product_draft_returns_none(db):
|
||||
"""Draft products are not returned."""
|
||||
await _insert_product(slug="vertex-draft", status="draft")
|
||||
product = await get_product("vertex-draft", "de")
|
||||
assert product is None
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("db")
|
||||
async def test_get_product_lang_fallback(db):
|
||||
"""Falls back to any language when no match for requested lang."""
|
||||
await _insert_product(slug="vertex-de-only", language="de", status="active")
|
||||
# Request EN but only DE exists — should fall back
|
||||
product = await get_product("vertex-de-only", "en")
|
||||
assert product is not None
|
||||
assert product["language"] == "de"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("db")
|
||||
async def test_get_product_not_found(db):
|
||||
"""Returns None for unknown slug."""
|
||||
product = await get_product("nonexistent-slug", "de")
|
||||
assert product is None
|
||||
|
||||
|
||||
# ── get_products_by_category ───────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.usefixtures("db")
|
||||
async def test_get_products_by_category_sorted(db):
|
||||
"""Returns products sorted by sort_order."""
|
||||
await _insert_product(slug="racket-b", name="Racket B", sort_order=2)
|
||||
await _insert_product(slug="racket-a", name="Racket A", sort_order=1)
|
||||
products = await get_products_by_category("racket", "de")
|
||||
assert len(products) == 2
|
||||
assert products[0]["sort_order"] == 1
|
||||
assert products[1]["sort_order"] == 2
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("db")
|
||||
async def test_get_products_by_category_inactive_excluded(db):
|
||||
"""Draft and archived products are excluded."""
|
||||
await _insert_product(slug="racket-draft", status="draft")
|
||||
await _insert_product(slug="racket-archived", status="archived")
|
||||
products = await get_products_by_category("racket", "de")
|
||||
assert products == []
|
||||
|
||||
|
||||
# ── get_all_products ───────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.usefixtures("db")
|
||||
async def test_get_all_products_no_filter(db):
|
||||
"""Returns all products regardless of status."""
|
||||
await _insert_product(slug="p1", status="active")
|
||||
await _insert_product(slug="p2", status="draft")
|
||||
products = await get_all_products()
|
||||
assert len(products) == 2
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("db")
|
||||
async def test_get_all_products_status_filter(db):
|
||||
"""Status filter returns only matching rows."""
|
||||
await _insert_product(slug="p-active", status="active")
|
||||
await _insert_product(slug="p-draft", status="draft")
|
||||
active = await get_all_products(status="active")
|
||||
assert len(active) == 1
|
||||
assert active[0]["slug"] == "p-active"
|
||||
|
||||
|
||||
# ── log_click + get_click_counts ──────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.usefixtures("db")
|
||||
async def test_log_click_inserts_row(db):
|
||||
"""log_click inserts a row into affiliate_clicks."""
|
||||
product_id = await _insert_product(slug="clickable")
|
||||
await log_click(product_id, "1.2.3.4", "beste-padelschlaeger", "https://example.com/de/blog/test")
|
||||
rows = await fetch_all("SELECT * FROM affiliate_clicks WHERE product_id = ?", (product_id,))
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["article_slug"] == "beste-padelschlaeger"
|
||||
# IP hash must not be the raw IP
|
||||
assert rows[0]["ip_hash"] != "1.2.3.4"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("db")
|
||||
async def test_get_click_counts(db):
|
||||
"""get_click_counts returns dict of product_id → count."""
|
||||
pid = await _insert_product(slug="tracked-product")
|
||||
await log_click(pid, "1.2.3.4", None, None)
|
||||
await log_click(pid, "5.6.7.8", None, None)
|
||||
counts = await get_click_counts()
|
||||
assert counts.get(pid) == 2
|
||||
|
||||
|
||||
# ── get_click_stats ────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.usefixtures("db")
|
||||
async def test_get_click_stats_structure(db):
|
||||
"""get_click_stats returns expected keys."""
|
||||
stats = await get_click_stats(days_count=30)
|
||||
assert "total_clicks" in stats
|
||||
assert "top_products" in stats
|
||||
assert "daily_bars" in stats
|
||||
assert "by_retailer" in stats
|
||||
|
||||
|
||||
# ── bake_product_cards ────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.usefixtures("db")
|
||||
async def test_bake_product_cards_replaces_marker(db):
|
||||
"""[product:slug] marker is replaced with rendered HTML."""
|
||||
await _insert_product(slug="vertex-04-amazon", name="Bullpadel Vertex 04", status="active")
|
||||
html = "<p>Intro</p>\n[product:vertex-04-amazon]\n<p>Outro</p>"
|
||||
result = await bake_product_cards(html, lang="de")
|
||||
assert "[product:vertex-04-amazon]" not in result
|
||||
assert "Bullpadel Vertex 04" in result
|
||||
assert "/go/vertex-04-amazon" in result
|
||||
assert "sponsored" in result
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("db")
|
||||
async def test_bake_product_cards_missing_slug_passthrough(db):
|
||||
"""Unknown slugs pass through unchanged — no product card rendered."""
|
||||
html = "<p>Text</p>\n[product:nonexistent-slug]\n<p>End</p>"
|
||||
result = await bake_product_cards(html, lang="de")
|
||||
# Surrounding content is intact; no product HTML injected
|
||||
assert "<p>Text</p>" in result
|
||||
assert "<p>End</p>" in result
|
||||
assert "<article" not in result # no product card rendered
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("db")
|
||||
async def test_bake_product_cards_group_marker(db):
|
||||
"""[product-group:category] renders a grid of products."""
|
||||
await _insert_product(slug="shoe-1-amazon", name="Test Shoe", category="shoe", status="active")
|
||||
html = "<h2>Shoes</h2>\n[product-group:shoe]\n<p>End</p>"
|
||||
result = await bake_product_cards(html, lang="de")
|
||||
assert "[product-group:shoe]" not in result
|
||||
assert "Test Shoe" in result
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("db")
|
||||
async def test_bake_product_cards_no_markers(db):
|
||||
"""HTML without markers is returned unchanged."""
|
||||
html = "<p>No markers here.</p>"
|
||||
result = await bake_product_cards(html, lang="de")
|
||||
assert result == html
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("db")
|
||||
async def test_bake_product_cards_draft_not_shown(db):
|
||||
"""Draft products are not baked into articles."""
|
||||
await _insert_product(slug="draft-product", name="Draft Product", status="draft")
|
||||
html = "[product:draft-product]"
|
||||
result = await bake_product_cards(html, lang="de")
|
||||
assert "Draft Product" not in result
|
||||
|
||||
|
||||
# ── regex patterns ─────────────────────────────────────────────────────────────
|
||||
|
||||
def test_product_re_matches():
|
||||
"""PRODUCT_RE matches valid [product:slug] markers."""
|
||||
assert PRODUCT_RE.match("[product:bullpadel-vertex-04-amazon]")
|
||||
assert PRODUCT_RE.match("[product:test-123]")
|
||||
|
||||
|
||||
def test_product_group_re_matches():
|
||||
"""PRODUCT_GROUP_RE matches valid [product-group:category] markers."""
|
||||
assert PRODUCT_GROUP_RE.match("[product-group:racket]")
|
||||
assert PRODUCT_GROUP_RE.match("[product-group:shoe]")
|
||||
|
||||
|
||||
# ── multi-retailer ────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.usefixtures("db")
|
||||
async def test_multi_retailer_same_slug_different_lang(db):
|
||||
"""Same slug can exist in DE and EN with different affiliate URLs."""
|
||||
await _insert_product(
|
||||
slug="vertex-04", language="de",
|
||||
affiliate_url="https://amazon.de/dp/TEST?tag=de-21",
|
||||
)
|
||||
await execute(
|
||||
"""INSERT INTO affiliate_products
|
||||
(slug, name, brand, category, retailer, affiliate_url,
|
||||
price_cents, currency, status, language, pros, cons, sort_order)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 'EUR', ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
"vertex-04", "Test Racket EN", "TestBrand", "racket", "Amazon UK",
|
||||
"https://amazon.co.uk/dp/TEST?tag=en-21",
|
||||
14999, "active", "en", "[]", "[]", 0,
|
||||
),
|
||||
)
|
||||
de_product = await get_product("vertex-04", "de")
|
||||
en_product = await get_product("vertex-04", "en")
|
||||
assert de_product is not None
|
||||
assert en_product is not None
|
||||
assert de_product["affiliate_url"] != en_product["affiliate_url"]
|
||||
assert "amazon.de" in de_product["affiliate_url"]
|
||||
assert "amazon.co.uk" in en_product["affiliate_url"]
|
||||
|
||||
|
||||
# ── click redirect (e2e via Quart test client) ────────────────────────────────
|
||||
|
||||
@pytest.mark.usefixtures("db")
|
||||
async def test_affiliate_redirect_302(app, db):
|
||||
"""GET /go/<slug> redirects to affiliate_url with 302."""
|
||||
await _insert_product(slug="redirect-test", affiliate_url="https://amazon.de/dp/XYZ?tag=test-21")
|
||||
async with app.test_client() as client:
|
||||
response = await client.get("/go/redirect-test")
|
||||
assert response.status_code == 302
|
||||
assert "amazon.de" in response.headers.get("Location", "")
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("db")
|
||||
async def test_affiliate_redirect_logs_click(app, db):
|
||||
"""Successful redirect logs a click in affiliate_clicks."""
|
||||
pid = await _insert_product(slug="logged-test", affiliate_url="https://amazon.de/dp/LOG?tag=test-21")
|
||||
async with app.test_client() as client:
|
||||
await client.get(
|
||||
"/go/logged-test",
|
||||
headers={"Referer": "https://padelnomics.io/de/beste-padelschlaeger-2026"},
|
||||
)
|
||||
rows = await fetch_all("SELECT * FROM affiliate_clicks WHERE product_id = ?", (pid,))
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["article_slug"] == "beste-padelschlaeger-2026"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("db")
|
||||
async def test_affiliate_redirect_inactive_404(app, db):
|
||||
"""Draft products return 404 on /go/<slug>."""
|
||||
await _insert_product(slug="inactive-test", status="draft")
|
||||
async with app.test_client() as client:
|
||||
response = await client.get("/go/inactive-test")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("db")
|
||||
async def test_affiliate_redirect_unknown_404(app, db):
|
||||
"""Unknown slug returns 404."""
|
||||
async with app.test_client() as client:
|
||||
response = await client.get("/go/totally-unknown-xyz")
|
||||
assert response.status_code == 404
|
||||
Reference in New Issue
Block a user