feat(affiliate): core affiliate module — product lookup, click logging, stats
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 <noreply@anthropic.com>
This commit is contained in:
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
|
||||||
Reference in New Issue
Block a user