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_CATEGORIES = ("racket", "ball", "shoe", "bag", "grip", "eyewear", "accessory")
|
||||||
VALID_STATUSES = ("draft", "active", "archived")
|
VALID_STATUSES = ("draft", "active", "archived")
|
||||||
|
VALID_PROGRAM_STATUSES = ("active", "inactive")
|
||||||
|
|
||||||
|
|
||||||
def hash_ip(ip_address: str) -> str:
|
def hash_ip(ip_address: str) -> str:
|
||||||
@@ -32,20 +33,86 @@ def hash_ip(ip_address: str) -> str:
|
|||||||
return hashlib.sha256(raw.encode()).hexdigest()
|
return hashlib.sha256(raw.encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
async def get_product(slug: str, language: str = "de") -> dict | None:
|
async def get_all_programs(status: str | None = None) -> list[dict]:
|
||||||
"""Return active product by slug+language, falling back to any language."""
|
"""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"
|
assert slug, "slug must not be empty"
|
||||||
row = await fetch_one(
|
row = await fetch_one(
|
||||||
"SELECT * FROM affiliate_products"
|
"SELECT * FROM affiliate_programs WHERE slug = ?", (slug,)
|
||||||
" WHERE slug = ? AND language = ? AND status = 'active'",
|
)
|
||||||
|
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),
|
(slug, language),
|
||||||
)
|
)
|
||||||
if row:
|
if row:
|
||||||
return _parse_product(row)
|
return _parse_product(row)
|
||||||
# Graceful fallback: show any language rather than nothing
|
# Graceful fallback: show any language rather than nothing
|
||||||
row = await fetch_one(
|
row = await fetch_one(
|
||||||
"SELECT * FROM affiliate_products"
|
"SELECT p.*, pg.url_template AS _program_url_template,"
|
||||||
" WHERE slug = ? AND status = 'active' LIMIT 1",
|
" 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,),
|
(slug,),
|
||||||
)
|
)
|
||||||
return _parse_product(row) if row else None
|
return _parse_product(row) if row else None
|
||||||
@@ -217,8 +284,24 @@ async def get_distinct_retailers() -> list[str]:
|
|||||||
|
|
||||||
|
|
||||||
def _parse_product(row) -> dict:
|
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 = dict(row)
|
||||||
d["pros"] = json.loads(d.get("pros") or "[]")
|
d["pros"] = json.loads(d.get("pros") or "[]")
|
||||||
d["cons"] = json.loads(d.get("cons") 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
|
return d
|
||||||
|
|||||||
Reference in New Issue
Block a user