diff --git a/web/src/padelnomics/affiliate.py b/web/src/padelnomics/affiliate.py index 24e8cde..f957bbf 100644 --- a/web/src/padelnomics/affiliate.py +++ b/web/src/padelnomics/affiliate.py @@ -21,6 +21,7 @@ logger = logging.getLogger(__name__) VALID_CATEGORIES = ("racket", "ball", "shoe", "bag", "grip", "eyewear", "accessory") VALID_STATUSES = ("draft", "active", "archived") +VALID_PROGRAM_STATUSES = ("active", "inactive") def hash_ip(ip_address: str) -> str: @@ -32,20 +33,86 @@ def hash_ip(ip_address: str) -> str: 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.""" +async def get_all_programs(status: str | None = None) -> list[dict]: + """Return all affiliate programs, optionally filtered by status.""" + if status: + assert status in VALID_PROGRAM_STATUSES, f"unknown program status: {status}" + rows = await fetch_all( + "SELECT ap.*, (" + " SELECT COUNT(*) FROM affiliate_products WHERE program_id = ap.id" + ") AS product_count" + " FROM affiliate_programs ap WHERE ap.status = ?" + " ORDER BY ap.name ASC", + (status,), + ) + else: + rows = await fetch_all( + "SELECT ap.*, (" + " SELECT COUNT(*) FROM affiliate_products WHERE program_id = ap.id" + ") AS product_count" + " FROM affiliate_programs ap ORDER BY ap.name ASC" + ) + return [dict(r) for r in rows] + + +async def get_program(program_id: int) -> dict | None: + """Return a single affiliate program by id.""" + assert program_id > 0, "program_id must be positive" + row = await fetch_one( + "SELECT * FROM affiliate_programs WHERE id = ?", (program_id,) + ) + return dict(row) if row else None + + +async def get_program_by_slug(slug: str) -> dict | None: + """Return a single affiliate program by slug.""" assert slug, "slug must not be empty" row = await fetch_one( - "SELECT * FROM affiliate_products" - " WHERE slug = ? AND language = ? AND status = 'active'", + "SELECT * FROM affiliate_programs WHERE slug = ?", (slug,) + ) + return dict(row) if row else None + + +def build_affiliate_url(product: dict, program: dict | None = None) -> str: + """Assemble the final affiliate URL from program template + product identifier. + + Falls back to the baked product["affiliate_url"] when no program is set, + preserving backward compatibility with products created before programs existed. + """ + if not product.get("program_id") or not program: + return product["affiliate_url"] + return program["url_template"].format( + product_id=product["product_identifier"], + tag=program["tracking_tag"], + ) + + +async def get_product(slug: str, language: str = "de") -> dict | None: + """Return active product by slug+language, falling back to any language. + + JOINs affiliate_programs so the returned dict includes program fields + (prefixed with _program_*) for use in build_affiliate_url(). + """ + assert slug, "slug must not be empty" + row = await fetch_one( + "SELECT p.*, pg.url_template AS _program_url_template," + " pg.tracking_tag AS _program_tracking_tag," + " pg.name AS _program_name" + " FROM affiliate_products p" + " LEFT JOIN affiliate_programs pg ON pg.id = p.program_id" + " WHERE p.slug = ? AND p.language = ? AND p.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", + "SELECT p.*, pg.url_template AS _program_url_template," + " pg.tracking_tag AS _program_tracking_tag," + " pg.name AS _program_name" + " FROM affiliate_products p" + " LEFT JOIN affiliate_programs pg ON pg.id = p.program_id" + " WHERE p.slug = ? AND p.status = 'active' LIMIT 1", (slug,), ) return _parse_product(row) if row else None @@ -217,8 +284,24 @@ async def get_distinct_retailers() -> list[str]: def _parse_product(row) -> dict: - """Convert aiosqlite Row to plain dict, parsing JSON pros/cons arrays.""" + """Convert aiosqlite Row to plain dict, parsing JSON pros/cons arrays. + + If the row includes _program_* columns (from a JOIN), extracts them into + a nested "_program" dict so build_affiliate_url() can use them directly. + """ d = dict(row) d["pros"] = json.loads(d.get("pros") or "[]") d["cons"] = json.loads(d.get("cons") or "[]") + # Extract program fields added by get_product()'s JOIN + if "_program_url_template" in d: + if d.get("program_id") and d["_program_url_template"]: + d["_program"] = { + "url_template": d.pop("_program_url_template"), + "tracking_tag": d.pop("_program_tracking_tag", ""), + "name": d.pop("_program_name", ""), + } + else: + d.pop("_program_url_template", None) + d.pop("_program_tracking_tag", None) + d.pop("_program_name", None) return d