diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a4d1d6..5d538f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Added +- **Affiliate product system** — "Wirecutter for padel" editorial affiliate cards embedded in articles via `[product:slug]` and `[product-group:category]` markers, baked at build time into static HTML. `/go/` click-tracking redirect (302, GDPR-compliant daily-rotated IP hash). Admin CRUD (`/admin/affiliate`) with live preview, inline status toggle, HTMX search/filter. Click stats dashboard (pure CSS bar chart, top products/articles/retailers). 10 German equipment review article scaffolds seeded. + - `web/src/padelnomics/migrations/versions/0026_affiliate_products.py`: `affiliate_products` + `affiliate_clicks` tables; `UNIQUE(slug, language)` constraint mirrors articles schema + - `web/src/padelnomics/affiliate.py`: `get_product()`, `get_products_by_category()`, `get_all_products()`, `log_click()`, `hash_ip()`, `get_click_stats()`, `get_click_counts()`, `get_distinct_retailers()` + - `web/src/padelnomics/content/routes.py`: `PRODUCT_RE`, `PRODUCT_GROUP_RE`, `bake_product_cards()` — chained after `bake_scenario_cards()` in `generate_articles()` and `preview_article()` + - `web/src/padelnomics/app.py`: `/go/` route with rate limiting (60/min per IP) and referer-based article/language extraction + - `web/src/padelnomics/admin/routes.py`: affiliate CRUD routes + `bake_product_cards()` chained in article rebuild flows + - New templates: `partials/product_card.html`, `partials/product_group.html`, `admin/affiliate_products.html`, `admin/affiliate_form.html`, `admin/affiliate_dashboard.html`, `admin/partials/affiliate_results.html`, `admin/partials/affiliate_row.html` + - `locales/en.json` + `locales/de.json`: 6 new affiliate i18n keys + - `data/content/articles/`: 10 new German equipment review scaffolds (rackets, balls, shoes, accessories, gifts) + - 26 tests in `web/tests/test_affiliate.py` + ### Added - **Three-tier proxy system** for extraction pipeline: free (Webshare auto-fetched) → datacenter (`PROXY_URLS_DATACENTER`) → residential (`PROXY_URLS_RESIDENTIAL`). Webshare free proxies are now auto-fetched from their download API on each run — no more manually copying stale proxy lists. - `proxy.py`: added `fetch_webshare_proxies()` (stdlib urllib, bounded read + timeout), `load_proxy_tiers()` (assembles N tiers from env), generalised `make_tiered_cycler()` to accept `list[list[str]]` with N-level escalation. Exposes `is_exhausted()`, `active_tier_index()`, `tier_count()`. diff --git a/PROJECT.md b/PROJECT.md index 5eeaa01..9b1a226 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -1,7 +1,7 @@ # Padelnomics — Project Tracker > Move tasks across columns as you work. Add new tasks at the top of the relevant column. -> Last updated: 2026-02-27 (Phase 2b — EU NUTS-2 spatial join + US state income). +> Last updated: 2026-02-28 (Affiliate product system — editorial gear cards + click tracking). --- @@ -132,6 +132,7 @@ - [x] **pSEO article noindex** — `noindex` column on articles (migration 0025), `NOINDEX_THRESHOLDS` per-template lambdas in `content/__init__.py`, robots meta tag in `article_detail.html`, sitemap exclusion, pSEO dashboard count card + article row badge; 20 tests - [x] **group_key static article grouping** — migration 0020 adds `group_key TEXT` column; `_sync_static_articles()` auto-upserts `data/content/articles/*.md` on admin page load; `_get_article_list_grouped()` groups by `COALESCE(group_key, url_path)` so EN/DE static cornerstones pair into one row - [x] **Email-gated report PDF** — `reports/` blueprint with email capture gate + PDF download; premium WeasyPrint PDF (full-bleed navy cover, Padelnomics wordmark watermark, gold/teal accents); `make report-pdf` target; EN + DE i18n (26 keys, native German); state-of-padel report moved to `data/content/reports/` +- [x] **Affiliate product system** — "Wirecutter for padel" editorial gear cards embedded in articles via `[product:slug]` / `[product-group:category]` markers, baked at build time; `/go/` click-tracking redirect (302, GDPR daily-rotated IP hash, rate-limited); admin CRUD with live preview, HTMX filter/search, status toggle; click stats dashboard (pure CSS charts); 10 German equipment review article scaffolds; 26 tests ### SEO & Legal - [x] Sitemap (both language variants, `` on all entries) @@ -243,7 +244,6 @@ ### Marketing & Content - [ ] LinkedIn presence (ongoing — founder posts, thought leadership) -- [ ] "Wirecutter for padel" affiliate site (racket reviews, gear guides) - [ ] "The Padel Business Report" newsletter - [ ] Equipment supplier affiliate partnerships (€500–1,000/lead or 5%) - [ ] Padel podcasts (guest appearances) diff --git a/web/src/padelnomics/admin/routes.py b/web/src/padelnomics/admin/routes.py index 5a927b8..2afb389 100644 --- a/web/src/padelnomics/admin/routes.py +++ b/web/src/padelnomics/admin/routes.py @@ -2499,7 +2499,12 @@ async def article_results(): @csrf_protect async def article_new(): """Create a manual article.""" - from ..content.routes import BUILD_DIR, bake_product_cards, bake_scenario_cards, is_reserved_path + from ..content.routes import ( + BUILD_DIR, + bake_product_cards, + bake_scenario_cards, + is_reserved_path, + ) if request.method == "POST": form = await request.form @@ -2562,7 +2567,12 @@ async def article_new(): @csrf_protect async def article_edit(article_id: int): """Edit a manual article.""" - from ..content.routes import BUILD_DIR, bake_product_cards, bake_scenario_cards, is_reserved_path + from ..content.routes import ( + BUILD_DIR, + bake_product_cards, + bake_scenario_cards, + is_reserved_path, + ) article = await fetch_one("SELECT * FROM articles WHERE id = ?", (article_id,)) if not article: @@ -3266,8 +3276,8 @@ def _form_to_product(form) -> dict: pros_raw = form.get("pros", "").strip() cons_raw = form.get("cons", "").strip() - pros = json.dumps([l.strip() for l in pros_raw.splitlines() if l.strip()]) - cons = json.dumps([l.strip() for l in cons_raw.splitlines() if l.strip()]) + pros = json.dumps([line.strip() for line in pros_raw.splitlines() if line.strip()]) + cons = json.dumps([line.strip() for line in cons_raw.splitlines() if line.strip()]) return { "slug": form.get("slug", "").strip(), diff --git a/web/tests/test_affiliate.py b/web/tests/test_affiliate.py new file mode 100644 index 0000000..b1e98a6 --- /dev/null +++ b/web/tests/test_affiliate.py @@ -0,0 +1,332 @@ +""" +Tests for the affiliate product system. + +Covers: hash_ip determinism, product CRUD, bake_product_cards marker replacement, +click redirect (302 + logged), rate limiting, inactive product 404, multi-retailer. +""" +import json +from datetime import date +from unittest.mock import patch + +import pytest +from padelnomics.affiliate import ( + get_all_products, + get_click_counts, + get_click_stats, + get_product, + get_products_by_category, + hash_ip, + log_click, +) +from padelnomics.content.routes import PRODUCT_GROUP_RE, PRODUCT_RE, bake_product_cards +from padelnomics.core import execute, fetch_all + +# ── Helpers ──────────────────────────────────────────────────────────────────── + +async def _insert_product( + slug="test-racket-amazon", + name="Test Racket", + brand="TestBrand", + category="racket", + retailer="Amazon", + affiliate_url="https://amazon.de/dp/TEST?tag=test-21", + status="active", + language="de", + price_cents=14999, + pros=None, + cons=None, + sort_order=0, +) -> int: + """Insert an affiliate product, return its id.""" + return await execute( + """INSERT INTO affiliate_products + (slug, name, brand, category, retailer, affiliate_url, + price_cents, currency, status, language, pros, cons, sort_order) + VALUES (?, ?, ?, ?, ?, ?, ?, 'EUR', ?, ?, ?, ?, ?)""", + ( + slug, name, brand, category, retailer, affiliate_url, + price_cents, status, language, + json.dumps(pros or ["Gut"]), + json.dumps(cons or ["Teuer"]), + sort_order, + ), + ) + + +# ── hash_ip ──────────────────────────────────────────────────────────────────── + +def test_hash_ip_deterministic(): + """Same IP + same day → same hash.""" + h1 = hash_ip("1.2.3.4") + h2 = hash_ip("1.2.3.4") + assert h1 == h2 + assert len(h1) == 64 # SHA256 hex digest + + +def test_hash_ip_different_ips_differ(): + """Different IPs → different hashes.""" + assert hash_ip("1.2.3.4") != hash_ip("5.6.7.8") + + +def test_hash_ip_rotates_daily(): + """Different days → different hashes for same IP (GDPR daily rotation).""" + with patch("padelnomics.affiliate.date") as mock_date: + mock_date.today.return_value = date(2026, 2, 1) + h1 = hash_ip("1.2.3.4") + mock_date.today.return_value = date(2026, 2, 2) + h2 = hash_ip("1.2.3.4") + assert h1 != h2 + + +# ── get_product ──────────────────────────────────────────────────────────────── + +@pytest.mark.usefixtures("db") +async def test_get_product_active_by_lang(db): + """get_product returns active product for correct language.""" + await _insert_product(slug="vertex-amazon", language="de", status="active") + product = await get_product("vertex-amazon", "de") + assert product is not None + assert product["slug"] == "vertex-amazon" + assert isinstance(product["pros"], list) + + +@pytest.mark.usefixtures("db") +async def test_get_product_draft_returns_none(db): + """Draft products are not returned.""" + await _insert_product(slug="vertex-draft", status="draft") + product = await get_product("vertex-draft", "de") + assert product is None + + +@pytest.mark.usefixtures("db") +async def test_get_product_lang_fallback(db): + """Falls back to any language when no match for requested lang.""" + await _insert_product(slug="vertex-de-only", language="de", status="active") + # Request EN but only DE exists — should fall back + product = await get_product("vertex-de-only", "en") + assert product is not None + assert product["language"] == "de" + + +@pytest.mark.usefixtures("db") +async def test_get_product_not_found(db): + """Returns None for unknown slug.""" + product = await get_product("nonexistent-slug", "de") + assert product is None + + +# ── get_products_by_category ─────────────────────────────────────────────────── + +@pytest.mark.usefixtures("db") +async def test_get_products_by_category_sorted(db): + """Returns products sorted by sort_order.""" + await _insert_product(slug="racket-b", name="Racket B", sort_order=2) + await _insert_product(slug="racket-a", name="Racket A", sort_order=1) + products = await get_products_by_category("racket", "de") + assert len(products) == 2 + assert products[0]["sort_order"] == 1 + assert products[1]["sort_order"] == 2 + + +@pytest.mark.usefixtures("db") +async def test_get_products_by_category_inactive_excluded(db): + """Draft and archived products are excluded.""" + await _insert_product(slug="racket-draft", status="draft") + await _insert_product(slug="racket-archived", status="archived") + products = await get_products_by_category("racket", "de") + assert products == [] + + +# ── get_all_products ─────────────────────────────────────────────────────────── + +@pytest.mark.usefixtures("db") +async def test_get_all_products_no_filter(db): + """Returns all products regardless of status.""" + await _insert_product(slug="p1", status="active") + await _insert_product(slug="p2", status="draft") + products = await get_all_products() + assert len(products) == 2 + + +@pytest.mark.usefixtures("db") +async def test_get_all_products_status_filter(db): + """Status filter returns only matching rows.""" + await _insert_product(slug="p-active", status="active") + await _insert_product(slug="p-draft", status="draft") + active = await get_all_products(status="active") + assert len(active) == 1 + assert active[0]["slug"] == "p-active" + + +# ── log_click + get_click_counts ────────────────────────────────────────────── + +@pytest.mark.usefixtures("db") +async def test_log_click_inserts_row(db): + """log_click inserts a row into affiliate_clicks.""" + product_id = await _insert_product(slug="clickable") + await log_click(product_id, "1.2.3.4", "beste-padelschlaeger", "https://example.com/de/blog/test") + rows = await fetch_all("SELECT * FROM affiliate_clicks WHERE product_id = ?", (product_id,)) + assert len(rows) == 1 + assert rows[0]["article_slug"] == "beste-padelschlaeger" + # IP hash must not be the raw IP + assert rows[0]["ip_hash"] != "1.2.3.4" + + +@pytest.mark.usefixtures("db") +async def test_get_click_counts(db): + """get_click_counts returns dict of product_id → count.""" + pid = await _insert_product(slug="tracked-product") + await log_click(pid, "1.2.3.4", None, None) + await log_click(pid, "5.6.7.8", None, None) + counts = await get_click_counts() + assert counts.get(pid) == 2 + + +# ── get_click_stats ──────────────────────────────────────────────────────────── + +@pytest.mark.usefixtures("db") +async def test_get_click_stats_structure(db): + """get_click_stats returns expected keys.""" + stats = await get_click_stats(days_count=30) + assert "total_clicks" in stats + assert "top_products" in stats + assert "daily_bars" in stats + assert "by_retailer" in stats + + +# ── bake_product_cards ──────────────────────────────────────────────────────── + +@pytest.mark.usefixtures("db") +async def test_bake_product_cards_replaces_marker(db): + """[product:slug] marker is replaced with rendered HTML.""" + await _insert_product(slug="vertex-04-amazon", name="Bullpadel Vertex 04", status="active") + html = "

Intro

\n[product:vertex-04-amazon]\n

Outro

" + result = await bake_product_cards(html, lang="de") + assert "[product:vertex-04-amazon]" not in result + assert "Bullpadel Vertex 04" in result + assert "/go/vertex-04-amazon" in result + assert "sponsored" in result + + +@pytest.mark.usefixtures("db") +async def test_bake_product_cards_missing_slug_passthrough(db): + """Unknown slugs pass through unchanged — no product card rendered.""" + html = "

Text

\n[product:nonexistent-slug]\n

End

" + result = await bake_product_cards(html, lang="de") + # Surrounding content is intact; no product HTML injected + assert "

Text

" in result + assert "

End

" in result + assert " redirects to affiliate_url with 302.""" + await _insert_product(slug="redirect-test", affiliate_url="https://amazon.de/dp/XYZ?tag=test-21") + async with app.test_client() as client: + response = await client.get("/go/redirect-test") + assert response.status_code == 302 + assert "amazon.de" in response.headers.get("Location", "") + + +@pytest.mark.usefixtures("db") +async def test_affiliate_redirect_logs_click(app, db): + """Successful redirect logs a click in affiliate_clicks.""" + pid = await _insert_product(slug="logged-test", affiliate_url="https://amazon.de/dp/LOG?tag=test-21") + async with app.test_client() as client: + await client.get( + "/go/logged-test", + headers={"Referer": "https://padelnomics.io/de/beste-padelschlaeger-2026"}, + ) + rows = await fetch_all("SELECT * FROM affiliate_clicks WHERE product_id = ?", (pid,)) + assert len(rows) == 1 + assert rows[0]["article_slug"] == "beste-padelschlaeger-2026" + + +@pytest.mark.usefixtures("db") +async def test_affiliate_redirect_inactive_404(app, db): + """Draft products return 404 on /go/.""" + await _insert_product(slug="inactive-test", status="draft") + async with app.test_client() as client: + response = await client.get("/go/inactive-test") + assert response.status_code == 404 + + +@pytest.mark.usefixtures("db") +async def test_affiliate_redirect_unknown_404(app, db): + """Unknown slug returns 404.""" + async with app.test_client() as client: + response = await client.get("/go/totally-unknown-xyz") + assert response.status_code == 404