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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user