feat(affiliate): add program CRUD functions + build_affiliate_url()

Adds get_all_programs(), get_program(), get_program_by_slug() for admin
CRUD. Adds build_affiliate_url() that assembles URLs from program template
+ product identifier, with fallback to baked affiliate_url for legacy
products. Updates get_product() to JOIN affiliate_programs so _program
dict is available at redirect time. _parse_product() extracts program
fields into nested _program key.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Deeman
2026-02-28 22:23:53 +01:00
parent b1eeb0a0ac
commit 8dbbd0df05

View File

@@ -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