Compare commits
11 Commits
v202602282
...
v202603010
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fea4f85da3 | ||
|
|
2590020014 | ||
|
|
a72f7721bb | ||
|
|
849dc8359c | ||
|
|
ec839478c3 | ||
|
|
47acf4d3df | ||
|
|
53117094ee | ||
|
|
6076a0b30f | ||
|
|
8dbbd0df05 | ||
|
|
b1eeb0a0ac | ||
|
|
6aae92fc58 |
@@ -32,10 +32,6 @@ LITESTREAM_R2_BUCKET=ENC[AES256_GCM,data:pAqSkoJzsw==,iv:5J1Js7JPH/j1oTmEBdNXjwd
|
|||||||
LITESTREAM_R2_ACCESS_KEY_ID=ENC[AES256_GCM,data:e89yGzousImmdO7WVqmRWLJNejDFH5eTaw7G74CyZSw=,iv:bR1jgqSzJlxPA8LMMg2Mc1Lnp01iZgaqa9dgAoV0RpY=,tag:m92xzCP0qaP2onK7ChwA1Q==,type:str]
|
LITESTREAM_R2_ACCESS_KEY_ID=ENC[AES256_GCM,data:e89yGzousImmdO7WVqmRWLJNejDFH5eTaw7G74CyZSw=,iv:bR1jgqSzJlxPA8LMMg2Mc1Lnp01iZgaqa9dgAoV0RpY=,tag:m92xzCP0qaP2onK7ChwA1Q==,type:str]
|
||||||
LITESTREAM_R2_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:yzXeb8c/Y0d+EluY7g6buo4BnFvBDEVblOi7doNgOp3siLvfMmPkjdRLqZzA14ET6CW5vef9i51yijPYwuhnbw==,iv:IYQRZ8SsquUQpsHH3X/iovz2wFskR4iHyvr0arY7Ag4=,tag:9G5lpHloacjQbEhSk9T2pw==,type:str]
|
LITESTREAM_R2_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:yzXeb8c/Y0d+EluY7g6buo4BnFvBDEVblOi7doNgOp3siLvfMmPkjdRLqZzA14ET6CW5vef9i51yijPYwuhnbw==,iv:IYQRZ8SsquUQpsHH3X/iovz2wFskR4iHyvr0arY7Ag4=,tag:9G5lpHloacjQbEhSk9T2pw==,type:str]
|
||||||
LITESTREAM_R2_ENDPOINT=ENC[AES256_GCM,data:qqDLfsPeiWOfwtgpZeItypnYNmIOD07fV0IPlZfphhUFeY0Z/BRpkVXA7nfqQ2M6PmcYKVIlBiBY,iv:hsEBxxv1+fvUY4v3nhBP8puKlu216eAGZDUNBAjibas=,tag:MvnsJ8W3oSrv4ZrWW/p+dg==,type:str]
|
LITESTREAM_R2_ENDPOINT=ENC[AES256_GCM,data:qqDLfsPeiWOfwtgpZeItypnYNmIOD07fV0IPlZfphhUFeY0Z/BRpkVXA7nfqQ2M6PmcYKVIlBiBY,iv:hsEBxxv1+fvUY4v3nhBP8puKlu216eAGZDUNBAjibas=,tag:MvnsJ8W3oSrv4ZrWW/p+dg==,type:str]
|
||||||
#ENC[AES256_GCM,data:YGV2exKdGOUkblNZZos=,iv:NuabFM/gNHIzYmDMRZ2tglFYdMPVFuHFGd+AAWvvu6Q=,tag:gZRoNNEmjL9v3nC8j9YkHw==,type:comment]
|
|
||||||
DUCKDB_PATH=ENC[AES256_GCM,data:GgOEQ5B1KeQrVavhoMU/JGXcVu3H,iv:XY8JiaosxaUDv5PwizrZFWuNKMSOeuE3cfVyp51r++8=,tag:RnoDE5+7WQolFLejfRZ//w==,type:str]
|
|
||||||
SERVING_DUCKDB_PATH=ENC[AES256_GCM,data:U2X9KmlgnWXM9uCfhHCJ03HMGCLm,iv:KHHdBTq+ct4AG7Jt4zLog/5jbDC7LvHA6KzWNTDS/Yw=,tag:m5uIG/bS4vaBooSYoYa6SA==,type:str]
|
|
||||||
LANDING_DIR=ENC[AES256_GCM,data:NkEmV8LOwEiN9Sal,iv:mQHBVT6lNoEEEVbl7a5bNN5qoF/LvTyWXQvvkv/z/B0=,tag:IgA5A1nfF91fOBdYxEN71g==,type:str]
|
|
||||||
#ENC[AES256_GCM,data:jvZYm7ceM4jtNRg=,iv:nuv65SDTZiaVukVZ40seBZevpqP8uiKCgJyQcIrY524=,tag:cq6gB3vmJzJWIXCLHaIc9g==,type:comment]
|
#ENC[AES256_GCM,data:jvZYm7ceM4jtNRg=,iv:nuv65SDTZiaVukVZ40seBZevpqP8uiKCgJyQcIrY524=,tag:cq6gB3vmJzJWIXCLHaIc9g==,type:comment]
|
||||||
REPO_DIR=ENC[AES256_GCM,data:ae8i6PpGFaiYFA/gGIhczg==,iv:nmsIRMPJYocIO6Z2Gz4OIzAOvSpdgDYmUaIr2hInFo0=,tag:EmAYG5NujnHg8lPaO/uAnQ==,type:str]
|
REPO_DIR=ENC[AES256_GCM,data:ae8i6PpGFaiYFA/gGIhczg==,iv:nmsIRMPJYocIO6Z2Gz4OIzAOvSpdgDYmUaIr2hInFo0=,tag:EmAYG5NujnHg8lPaO/uAnQ==,type:str]
|
||||||
WORKFLOWS_PATH=ENC[AES256_GCM,data:sGU4l68Pbb1thsPyG104mWXWD+zJGTIcR/TqVbPmew==,iv:+xhGkX+ep4kFEAU65ELdDrfjrl/WyuaOi35JI3OB/zM=,tag:brauZhFq8twPXmvhZKjhDQ==,type:str]
|
WORKFLOWS_PATH=ENC[AES256_GCM,data:sGU4l68Pbb1thsPyG104mWXWD+zJGTIcR/TqVbPmew==,iv:+xhGkX+ep4kFEAU65ELdDrfjrl/WyuaOi35JI3OB/zM=,tag:brauZhFq8twPXmvhZKjhDQ==,type:str]
|
||||||
@@ -62,7 +58,7 @@ sops_age__list_1__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb2
|
|||||||
sops_age__list_1__map_recipient=age1wjepykv3glvsrtegu25tevg7vyn3ngpl607u3yjc9ucay04s045s796msw
|
sops_age__list_1__map_recipient=age1wjepykv3glvsrtegu25tevg7vyn3ngpl607u3yjc9ucay04s045s796msw
|
||||||
sops_age__list_2__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBFeHhaOURNZnRVMEwxNThu\nUjF4Q0kwUXhTUE1QSzZJbmpubnh3RnpQTmdvCjRmWWxpNkxFUmVGb3NRbnlydW5O\nWEg3ZXJQTU4vcndzS2pUQXY3Q0ttYjAKLS0tIE9IRFJ1c2ZxbGVHa2xTL0swbGN1\nTzgwMThPUDRFTWhuZHJjZUYxOTZrU00KY62qrNBCUQYxwcLMXFEnLkwncxq3BPJB\nKm4NzeHBU87XmPWVrgrKuf+PH1mxJlBsl7Hev8xBTy7l6feiZjLIvQ==\n-----END AGE ENCRYPTED FILE-----\n
|
sops_age__list_2__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBFeHhaOURNZnRVMEwxNThu\nUjF4Q0kwUXhTUE1QSzZJbmpubnh3RnpQTmdvCjRmWWxpNkxFUmVGb3NRbnlydW5O\nWEg3ZXJQTU4vcndzS2pUQXY3Q0ttYjAKLS0tIE9IRFJ1c2ZxbGVHa2xTL0swbGN1\nTzgwMThPUDRFTWhuZHJjZUYxOTZrU00KY62qrNBCUQYxwcLMXFEnLkwncxq3BPJB\nKm4NzeHBU87XmPWVrgrKuf+PH1mxJlBsl7Hev8xBTy7l6feiZjLIvQ==\n-----END AGE ENCRYPTED FILE-----\n
|
||||||
sops_age__list_2__map_recipient=age1c783ym2q5x9tv7py5d28uc4k44aguudjn03g97l9nzs00dd9tsrqum8h4d
|
sops_age__list_2__map_recipient=age1c783ym2q5x9tv7py5d28uc4k44aguudjn03g97l9nzs00dd9tsrqum8h4d
|
||||||
sops_lastmodified=2026-02-28T17:03:44Z
|
sops_lastmodified=2026-03-01T00:26:54Z
|
||||||
sops_mac=ENC[AES256_GCM,data:IQ9jpRxVUssaMK+qFcM3nPdzXHkiqp6E+DhEey1TfqUu5GCBNsWeVy9m9A6p9RWhu2NtJV7aKdUeqneuMtD1q5Tnm6L96zuyot2ESnx2N2ssD9ilrDauQxoBJcrJVnGV61CgaCz9458w8BuVUZydn3MoHeRaU7bOBBzQlTI6vZk=,iv:qHqdt3av/KZRQHr/OS/9KdAJUgKlKEDgan7qI3Zzkck=,tag:fOvdO9iRTTF1Siobu2mLqg==,type:str]
|
sops_mac=ENC[AES256_GCM,data:DdcABGVm9KbAcFrF0iuZlAaugsouNs7Hon2mZISaHs15/2H/Pd9FniXW3KeQ0+/NdZFQkz/h3i3bVFampcpFS1AxuOE5+1/IgWn8sKtaqPc7E9y8g6lxMnwTkUX2z+n/Q2nR8KAcO9IyE0GNjIluMWkxPWQuLzlRYDOjRN4/1e0=,iv:rm+6lXhYu6VUmrdCIrU0BRN2/ooa21Fw1ESWxr7vATg=,tag:GZmLLZf/LQaNeNNAAEg5bA==,type:str]
|
||||||
sops_unencrypted_suffix=_unencrypted
|
sops_unencrypted_suffix=_unencrypted
|
||||||
sops_version=3.12.1
|
sops_version=3.12.1
|
||||||
|
|||||||
13
CHANGELOG.md
13
CHANGELOG.md
@@ -6,6 +6,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Affiliate programs management** — centralised retailer config (`affiliate_programs` table) with URL template + tracking tag + commission %. Products now use a program dropdown + product identifier (e.g. ASIN) instead of manually baking full URLs. URL is assembled at redirect time via `build_affiliate_url()`, so changing a tag propagates instantly to all products. Legacy products (baked `affiliate_url`) continue to work via fallback. Amazon OneLink configured in the Associates dashboard handles geo-redirect to local marketplaces — no per-country programs needed.
|
||||||
|
- `web/src/padelnomics/migrations/versions/0027_affiliate_programs.py`: `affiliate_programs` table, nullable `program_id` + `product_identifier` columns on `affiliate_products`, seeds "Amazon" program, backfills ASINs from existing URLs
|
||||||
|
- `web/src/padelnomics/affiliate.py`: `get_all_programs()`, `get_program()`, `get_program_by_slug()`, `build_affiliate_url()`; `get_product()` JOINs program for redirect assembly; `_parse_product()` extracts `_program` sub-dict
|
||||||
|
- `web/src/padelnomics/app.py`: `/go/<slug>` uses `build_affiliate_url()` — program-based products get URLs assembled at redirect time
|
||||||
|
- `web/src/padelnomics/admin/routes.py`: program CRUD routes (list, new, edit, delete — delete blocked if products reference the program); product form updated to program dropdown + identifier; `retailer` auto-populated from program name
|
||||||
|
- New templates: `admin/affiliate_programs.html`, `admin/affiliate_program_form.html`, `admin/partials/affiliate_program_results.html`
|
||||||
|
- Updated templates: `admin/affiliate_form.html` (program dropdown + JS toggle), `admin/base_admin.html` (Programs subnav tab)
|
||||||
|
- 15 new tests in `web/tests/test_affiliate.py` (41 total)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Article preview frontmatter bug** — `_rebuild_article()` in `admin/routes.py` now strips YAML frontmatter before passing markdown to `mistune.html()`, preventing raw `title:`, `slug:` etc. from appearing as visible text in article previews.
|
||||||
|
|
||||||
### Added
|
### 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/<slug>` 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.
|
- **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/<slug>` 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/migrations/versions/0026_affiliate_products.py`: `affiliate_products` + `affiliate_clicks` tables; `UNIQUE(slug, language)` constraint mirrors articles schema
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Padelnomics — Project Tracker
|
# Padelnomics — Project Tracker
|
||||||
|
|
||||||
> Move tasks across columns as you work. Add new tasks at the top of the relevant column.
|
> Move tasks across columns as you work. Add new tasks at the top of the relevant column.
|
||||||
> Last updated: 2026-02-28 (Affiliate product system — editorial gear cards + click tracking).
|
> Last updated: 2026-02-28 (Affiliate programs management — centralised retailer config + URL template assembly).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -133,6 +133,7 @@
|
|||||||
- [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] **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] **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/<slug>` 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
|
- [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/<slug>` 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
|
||||||
|
- [x] **Affiliate programs management** — `affiliate_programs` table centralises retailer configs (URL template, tracking tag, commission %); product form uses program dropdown + product identifier (ASIN etc.); `build_affiliate_url()` assembles at redirect time; legacy baked-URL products still work; admin CRUD (delete blocked if products reference program); Amazon OneLink for multi-marketplace; article frontmatter preview bug fixed; 41 tests
|
||||||
|
|
||||||
### SEO & Legal
|
### SEO & Legal
|
||||||
- [x] Sitemap (both language variants, `<lastmod>` on all entries)
|
- [x] Sitemap (both language variants, `<lastmod>` on all entries)
|
||||||
|
|||||||
@@ -1,67 +1,67 @@
|
|||||||
---
|
---
|
||||||
title: "Padelschläger für Fortgeschrittene: Die besten Modelle 2026"
|
title: "Padelschläger für Fortgeschrittene: Die besten Modelle 2026"
|
||||||
slug: padelschlaeger-fortgeschrittene-de
|
slug: padelschlaeger-fortgeschrittene-de
|
||||||
language: de
|
language: de
|
||||||
url_path: /padelschlaeger-fortgeschrittene
|
url_path: /padelschlaeger-fortgeschrittene
|
||||||
meta_description: "Die besten Padelschläger für fortgeschrittene und ambitionierte Spieler. High-End-Modelle mit Carbon, Kevlar und ausgereifter Schlagbalance für Spieler ab 3.0."
|
meta_description: "Die besten Padelschläger für fortgeschrittene und ambitionierte Spieler. High-End-Modelle mit Carbon, Kevlar und ausgereifter Schlagbalance für Spieler ab 3.0."
|
||||||
---
|
---
|
||||||
|
|
||||||
# Padelschläger für Fortgeschrittene: Die besten Modelle 2026
|
# Padelschläger für Fortgeschrittene: Die besten Modelle 2026
|
||||||
|
|
||||||
<!-- TODO: Einleitung — wann ist man bereit für einen Fortgeschrittenenschläger? -->
|
<!-- TODO: Einleitung — wann ist man bereit für einen Fortgeschrittenenschläger? -->
|
||||||
|
|
||||||
Ab einem gewissen Spielniveau lohnt sich der Griff zu einem anspruchsvolleren Schläger. Wer sauber trifft, kann von einer härteren Bespannung und einer präziseren Balance profitieren. Die Schläger in dieser Liste sind kein Selbstläufer — aber in den richtigen Händen ein echter Vorteil.
|
Ab einem gewissen Spielniveau lohnt sich der Griff zu einem anspruchsvolleren Schläger. Wer sauber trifft, kann von einer härteren Bespannung und einer präziseren Balance profitieren. Die Schläger in dieser Liste sind kein Selbstläufer — aber in den richtigen Händen ein echter Vorteil.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Top-Schläger für Fortgeschrittene im Überblick
|
## Top-Schläger für Fortgeschrittene im Überblick
|
||||||
|
|
||||||
[product-group:racket]
|
[product-group:racket]
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Carbon, Kevlar, Glasfaser: Was steckt drin?
|
## Carbon, Kevlar, Glasfaser: Was steckt drin?
|
||||||
|
|
||||||
<!-- TODO: Materialüberblick mit Vor- und Nachteilen -->
|
<!-- TODO: Materialüberblick mit Vor- und Nachteilen -->
|
||||||
|
|
||||||
### Carbon-Rahmen
|
### Carbon-Rahmen
|
||||||
|
|
||||||
<!-- TODO -->
|
<!-- TODO -->
|
||||||
|
|
||||||
### 3K vs. 12K Carbon
|
### 3K vs. 12K Carbon
|
||||||
|
|
||||||
<!-- TODO -->
|
<!-- TODO -->
|
||||||
|
|
||||||
### Kevlar-Einlagen
|
### Kevlar-Einlagen
|
||||||
|
|
||||||
<!-- TODO -->
|
<!-- TODO -->
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Testbericht: Unser Empfehlungsschläger
|
## Testbericht: Unser Empfehlungsschläger
|
||||||
|
|
||||||
[product:platzhalter-fortgeschrittene-schlaeger-amazon]
|
[product:platzhalter-fortgeschrittene-schlaeger-amazon]
|
||||||
|
|
||||||
<!-- TODO: Praxistest -->
|
<!-- TODO: Praxistest -->
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Häufige Fragen
|
## Häufige Fragen
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Ab welcher Spielstufe lohnt sich ein Fortgeschrittenenschläger?</summary>
|
<summary>Ab welcher Spielstufe lohnt sich ein Fortgeschrittenenschläger?</summary>
|
||||||
|
|
||||||
<!-- TODO -->
|
<!-- TODO -->
|
||||||
|
|
||||||
Wer regelmäßig spielt (2–3 Mal pro Woche), seit mindestens einem Jahr dabei ist und an Taktik und Technik arbeitet, kann von einem hochwertigeren Schläger profitieren. Für gelegentliche Spieler ist der Unterschied zu einem Mittelklassemodell kaum spürbar.
|
Wer regelmäßig spielt (2–3 Mal pro Woche), seit mindestens einem Jahr dabei ist und an Taktik und Technik arbeitet, kann von einem hochwertigeren Schläger profitieren. Für gelegentliche Spieler ist der Unterschied zu einem Mittelklassemodell kaum spürbar.
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Müssen Fortgeschrittenenschläger teurer sein?</summary>
|
<summary>Müssen Fortgeschrittenenschläger teurer sein?</summary>
|
||||||
|
|
||||||
<!-- TODO -->
|
<!-- TODO -->
|
||||||
|
|
||||||
Nicht zwingend. Es gibt ausgezeichnete Modelle im 150–200-Euro-Segment, die professionell verarbeitete Carbon-Elemente enthalten. Alles über 300 Euro richtet sich meist an Spieler mit Wettkampfambitionen.
|
Nicht zwingend. Es gibt ausgezeichnete Modelle im 150–200-Euro-Segment, die professionell verarbeitete Carbon-Elemente enthalten. Alles über 300 Euro richtet sich meist an Spieler mit Wettkampfambitionen.
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
@@ -21,6 +21,10 @@ schedule = "monthly"
|
|||||||
module = "padelnomics_extract.eurostat"
|
module = "padelnomics_extract.eurostat"
|
||||||
schedule = "monthly"
|
schedule = "monthly"
|
||||||
|
|
||||||
|
[geonames]
|
||||||
|
module = "padelnomics_extract.geonames"
|
||||||
|
schedule = "monthly"
|
||||||
|
|
||||||
[playtomic_tenants]
|
[playtomic_tenants]
|
||||||
module = "padelnomics_extract.playtomic_tenants"
|
module = "padelnomics_extract.playtomic_tenants"
|
||||||
schedule = "daily"
|
schedule = "daily"
|
||||||
|
|||||||
@@ -19,8 +19,10 @@
|
|||||||
-- 4. Country-level income (global fallback from stg_income / ilc_di03)
|
-- 4. Country-level income (global fallback from stg_income / ilc_di03)
|
||||||
--
|
--
|
||||||
-- Distance calculations use ST_Distance_Sphere (DuckDB spatial extension).
|
-- Distance calculations use ST_Distance_Sphere (DuckDB spatial extension).
|
||||||
-- A bounding-box pre-filter (~0.5°, ≈55km) reduces the cross-join before the
|
-- Spatial joins use BETWEEN predicates (not ABS()) to enable DuckDB's IEJoin
|
||||||
-- exact sphere distance is computed.
|
-- (interval join) optimization: O((N+M) log M) vs O(N×M) nested-loop.
|
||||||
|
-- Country pre-filters restrict the left side to ~20K rows for padel/tennis CTEs
|
||||||
|
-- (~8 countries each), down from ~140K global locations.
|
||||||
|
|
||||||
MODEL (
|
MODEL (
|
||||||
name foundation.dim_locations,
|
name foundation.dim_locations,
|
||||||
@@ -147,6 +149,8 @@ padel_courts AS (
|
|||||||
WHERE lat IS NOT NULL AND lon IS NOT NULL
|
WHERE lat IS NOT NULL AND lon IS NOT NULL
|
||||||
),
|
),
|
||||||
-- Nearest padel court distance per location (bbox pre-filter → exact sphere distance)
|
-- Nearest padel court distance per location (bbox pre-filter → exact sphere distance)
|
||||||
|
-- BETWEEN enables DuckDB IEJoin (O((N+M) log M)) vs ABS() nested-loop (O(N×M)).
|
||||||
|
-- Country pre-filter reduces left side from ~140K to ~20K rows (padel is ~8 countries).
|
||||||
nearest_padel AS (
|
nearest_padel AS (
|
||||||
SELECT
|
SELECT
|
||||||
l.geoname_id,
|
l.geoname_id,
|
||||||
@@ -158,9 +162,12 @@ nearest_padel AS (
|
|||||||
) AS nearest_padel_court_km
|
) AS nearest_padel_court_km
|
||||||
FROM locations l
|
FROM locations l
|
||||||
JOIN padel_courts p
|
JOIN padel_courts p
|
||||||
-- ~55km bounding box pre-filter to limit cross-join before sphere calc
|
-- ~55km bounding box pre-filter; BETWEEN triggers IEJoin optimization
|
||||||
ON ABS(l.lat - p.lat) < 0.5
|
ON l.lat BETWEEN p.lat - 0.5 AND p.lat + 0.5
|
||||||
AND ABS(l.lon - p.lon) < 0.5
|
AND l.lon BETWEEN p.lon - 0.5 AND p.lon + 0.5
|
||||||
|
WHERE l.country_code IN (
|
||||||
|
SELECT DISTINCT country_code FROM padel_courts WHERE country_code IS NOT NULL
|
||||||
|
)
|
||||||
GROUP BY l.geoname_id
|
GROUP BY l.geoname_id
|
||||||
),
|
),
|
||||||
-- Padel venues within 5km of each location (counts as "local padel supply")
|
-- Padel venues within 5km of each location (counts as "local padel supply")
|
||||||
@@ -170,24 +177,35 @@ padel_local AS (
|
|||||||
COUNT(*) AS padel_venue_count
|
COUNT(*) AS padel_venue_count
|
||||||
FROM locations l
|
FROM locations l
|
||||||
JOIN padel_courts p
|
JOIN padel_courts p
|
||||||
ON ABS(l.lat - p.lat) < 0.05 -- ~5km bbox pre-filter
|
-- ~5km bbox pre-filter; BETWEEN triggers IEJoin optimization
|
||||||
AND ABS(l.lon - p.lon) < 0.05
|
ON l.lat BETWEEN p.lat - 0.05 AND p.lat + 0.05
|
||||||
WHERE ST_Distance_Sphere(
|
AND l.lon BETWEEN p.lon - 0.05 AND p.lon + 0.05
|
||||||
|
WHERE l.country_code IN (
|
||||||
|
SELECT DISTINCT country_code FROM padel_courts WHERE country_code IS NOT NULL
|
||||||
|
)
|
||||||
|
AND ST_Distance_Sphere(
|
||||||
ST_Point(l.lon, l.lat),
|
ST_Point(l.lon, l.lat),
|
||||||
ST_Point(p.lon, p.lat)
|
ST_Point(p.lon, p.lat)
|
||||||
) / 1000.0 <= 5.0
|
) / 1000.0 <= 5.0
|
||||||
GROUP BY l.geoname_id
|
GROUP BY l.geoname_id
|
||||||
),
|
),
|
||||||
-- Tennis courts within 25km of each location (sports culture proxy)
|
-- Tennis courts within 25km of each location (sports culture proxy)
|
||||||
|
-- Country pre-filter reduces left side from ~140K to ~20K rows (tennis courts are European only).
|
||||||
tennis_nearby AS (
|
tennis_nearby AS (
|
||||||
SELECT
|
SELECT
|
||||||
l.geoname_id,
|
l.geoname_id,
|
||||||
COUNT(*) AS tennis_courts_within_25km
|
COUNT(*) AS tennis_courts_within_25km
|
||||||
FROM locations l
|
FROM locations l
|
||||||
JOIN staging.stg_tennis_courts t
|
JOIN staging.stg_tennis_courts t
|
||||||
ON ABS(l.lat - t.lat) < 0.23 -- ~25km bbox pre-filter
|
-- ~25km bbox pre-filter; BETWEEN triggers IEJoin optimization
|
||||||
AND ABS(l.lon - t.lon) < 0.23
|
ON l.lat BETWEEN t.lat - 0.23 AND t.lat + 0.23
|
||||||
WHERE ST_Distance_Sphere(
|
AND l.lon BETWEEN t.lon - 0.23 AND t.lon + 0.23
|
||||||
|
WHERE l.country_code IN (
|
||||||
|
SELECT DISTINCT country_code
|
||||||
|
FROM staging.stg_tennis_courts
|
||||||
|
WHERE country_code IS NOT NULL
|
||||||
|
)
|
||||||
|
AND ST_Distance_Sphere(
|
||||||
ST_Point(l.lon, l.lat),
|
ST_Point(l.lon, l.lat),
|
||||||
ST_Point(t.lon, t.lat)
|
ST_Point(t.lon, t.lat)
|
||||||
) / 1000.0 <= 25.0
|
) / 1000.0 <= 25.0
|
||||||
|
|||||||
@@ -2769,7 +2769,10 @@ async def _rebuild_article(article_id: int):
|
|||||||
md_path = Path("data/content/articles") / f"{article['slug']}.md"
|
md_path = Path("data/content/articles") / f"{article['slug']}.md"
|
||||||
if not md_path.exists():
|
if not md_path.exists():
|
||||||
return
|
return
|
||||||
body_html = mistune.html(md_path.read_text())
|
raw = md_path.read_text()
|
||||||
|
m = _FRONTMATTER_RE.match(raw)
|
||||||
|
body = raw[m.end():] if m else raw
|
||||||
|
body_html = mistune.html(body)
|
||||||
lang = article.get("language", "en") if hasattr(article, "get") else "en"
|
lang = article.get("language", "en") if hasattr(article, "get") else "en"
|
||||||
body_html = await bake_scenario_cards(body_html, lang=lang)
|
body_html = await bake_scenario_cards(body_html, lang=lang)
|
||||||
body_html = await bake_product_cards(body_html, lang=lang)
|
body_html = await bake_product_cards(body_html, lang=lang)
|
||||||
@@ -3254,6 +3257,210 @@ async def outreach_import():
|
|||||||
|
|
||||||
AFFILIATE_CATEGORIES = ("racket", "ball", "shoe", "bag", "grip", "eyewear", "accessory")
|
AFFILIATE_CATEGORIES = ("racket", "ball", "shoe", "bag", "grip", "eyewear", "accessory")
|
||||||
AFFILIATE_STATUSES = ("draft", "active", "archived")
|
AFFILIATE_STATUSES = ("draft", "active", "archived")
|
||||||
|
AFFILIATE_PROGRAM_STATUSES = ("active", "inactive")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Affiliate Programs ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _form_to_program(form) -> dict:
|
||||||
|
"""Parse affiliate program form values into a data dict."""
|
||||||
|
commission_str = form.get("commission_pct", "").strip()
|
||||||
|
commission_pct = 0.0
|
||||||
|
if commission_str:
|
||||||
|
try:
|
||||||
|
commission_pct = float(commission_str.replace(",", "."))
|
||||||
|
except ValueError:
|
||||||
|
commission_pct = 0.0
|
||||||
|
|
||||||
|
return {
|
||||||
|
"name": form.get("name", "").strip(),
|
||||||
|
"slug": form.get("slug", "").strip(),
|
||||||
|
"url_template": form.get("url_template", "").strip(),
|
||||||
|
"tracking_tag": form.get("tracking_tag", "").strip(),
|
||||||
|
"commission_pct": commission_pct,
|
||||||
|
"homepage_url": form.get("homepage_url", "").strip(),
|
||||||
|
"status": form.get("status", "active").strip(),
|
||||||
|
"notes": form.get("notes", "").strip(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/affiliate/programs")
|
||||||
|
@role_required("admin")
|
||||||
|
async def affiliate_programs():
|
||||||
|
"""Affiliate programs list — full page."""
|
||||||
|
from ..affiliate import get_all_programs
|
||||||
|
|
||||||
|
programs = await get_all_programs()
|
||||||
|
return await render_template(
|
||||||
|
"admin/affiliate_programs.html",
|
||||||
|
admin_page="affiliate_programs",
|
||||||
|
programs=programs,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/affiliate/programs/results")
|
||||||
|
@role_required("admin")
|
||||||
|
async def affiliate_program_results():
|
||||||
|
"""HTMX partial: program rows."""
|
||||||
|
from ..affiliate import get_all_programs
|
||||||
|
|
||||||
|
programs = await get_all_programs()
|
||||||
|
return await render_template(
|
||||||
|
"admin/partials/affiliate_program_results.html",
|
||||||
|
programs=programs,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/affiliate/programs/new", methods=["GET", "POST"])
|
||||||
|
@role_required("admin")
|
||||||
|
@csrf_protect
|
||||||
|
async def affiliate_program_new():
|
||||||
|
"""Create an affiliate program."""
|
||||||
|
if request.method == "POST":
|
||||||
|
form = await request.form
|
||||||
|
data = _form_to_program(form)
|
||||||
|
|
||||||
|
if not data["name"] or not data["slug"] or not data["url_template"]:
|
||||||
|
await flash("Name, slug, and URL template are required.", "error")
|
||||||
|
return await render_template(
|
||||||
|
"admin/affiliate_program_form.html",
|
||||||
|
admin_page="affiliate_programs",
|
||||||
|
data=data,
|
||||||
|
editing=False,
|
||||||
|
program_statuses=AFFILIATE_PROGRAM_STATUSES,
|
||||||
|
)
|
||||||
|
|
||||||
|
existing = await fetch_one(
|
||||||
|
"SELECT id FROM affiliate_programs WHERE slug = ?", (data["slug"],)
|
||||||
|
)
|
||||||
|
if existing:
|
||||||
|
await flash(f"Slug '{data['slug']}' already exists.", "error")
|
||||||
|
return await render_template(
|
||||||
|
"admin/affiliate_program_form.html",
|
||||||
|
admin_page="affiliate_programs",
|
||||||
|
data=data,
|
||||||
|
editing=False,
|
||||||
|
program_statuses=AFFILIATE_PROGRAM_STATUSES,
|
||||||
|
)
|
||||||
|
|
||||||
|
await execute(
|
||||||
|
"""INSERT INTO affiliate_programs
|
||||||
|
(name, slug, url_template, tracking_tag, commission_pct,
|
||||||
|
homepage_url, status, notes)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||||
|
(
|
||||||
|
data["name"], data["slug"], data["url_template"],
|
||||||
|
data["tracking_tag"], data["commission_pct"],
|
||||||
|
data["homepage_url"], data["status"], data["notes"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await flash(f"Program '{data['name']}' created.", "success")
|
||||||
|
return redirect(url_for("admin.affiliate_programs"))
|
||||||
|
|
||||||
|
return await render_template(
|
||||||
|
"admin/affiliate_program_form.html",
|
||||||
|
admin_page="affiliate_programs",
|
||||||
|
data={},
|
||||||
|
editing=False,
|
||||||
|
program_statuses=AFFILIATE_PROGRAM_STATUSES,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/affiliate/programs/<int:program_id>/edit", methods=["GET", "POST"])
|
||||||
|
@role_required("admin")
|
||||||
|
@csrf_protect
|
||||||
|
async def affiliate_program_edit(program_id: int):
|
||||||
|
"""Edit an affiliate program."""
|
||||||
|
program = await fetch_one(
|
||||||
|
"SELECT * FROM affiliate_programs WHERE id = ?", (program_id,)
|
||||||
|
)
|
||||||
|
if not program:
|
||||||
|
await flash("Program not found.", "error")
|
||||||
|
return redirect(url_for("admin.affiliate_programs"))
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
form = await request.form
|
||||||
|
data = _form_to_program(form)
|
||||||
|
|
||||||
|
if not data["name"] or not data["slug"] or not data["url_template"]:
|
||||||
|
await flash("Name, slug, and URL template are required.", "error")
|
||||||
|
return await render_template(
|
||||||
|
"admin/affiliate_program_form.html",
|
||||||
|
admin_page="affiliate_programs",
|
||||||
|
data={**dict(program), **data},
|
||||||
|
editing=True,
|
||||||
|
program_id=program_id,
|
||||||
|
program_statuses=AFFILIATE_PROGRAM_STATUSES,
|
||||||
|
)
|
||||||
|
|
||||||
|
if data["slug"] != program["slug"]:
|
||||||
|
collision = await fetch_one(
|
||||||
|
"SELECT id FROM affiliate_programs WHERE slug = ? AND id != ?",
|
||||||
|
(data["slug"], program_id),
|
||||||
|
)
|
||||||
|
if collision:
|
||||||
|
await flash(f"Slug '{data['slug']}' already exists.", "error")
|
||||||
|
return await render_template(
|
||||||
|
"admin/affiliate_program_form.html",
|
||||||
|
admin_page="affiliate_programs",
|
||||||
|
data={**dict(program), **data},
|
||||||
|
editing=True,
|
||||||
|
program_id=program_id,
|
||||||
|
program_statuses=AFFILIATE_PROGRAM_STATUSES,
|
||||||
|
)
|
||||||
|
|
||||||
|
await execute(
|
||||||
|
"""UPDATE affiliate_programs
|
||||||
|
SET name=?, slug=?, url_template=?, tracking_tag=?, commission_pct=?,
|
||||||
|
homepage_url=?, status=?, notes=?, updated_at=datetime('now')
|
||||||
|
WHERE id=?""",
|
||||||
|
(
|
||||||
|
data["name"], data["slug"], data["url_template"],
|
||||||
|
data["tracking_tag"], data["commission_pct"],
|
||||||
|
data["homepage_url"], data["status"], data["notes"],
|
||||||
|
program_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await flash(f"Program '{data['name']}' updated.", "success")
|
||||||
|
return redirect(url_for("admin.affiliate_programs"))
|
||||||
|
|
||||||
|
return await render_template(
|
||||||
|
"admin/affiliate_program_form.html",
|
||||||
|
admin_page="affiliate_programs",
|
||||||
|
data=dict(program),
|
||||||
|
editing=True,
|
||||||
|
program_id=program_id,
|
||||||
|
program_statuses=AFFILIATE_PROGRAM_STATUSES,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/affiliate/programs/<int:program_id>/delete", methods=["POST"])
|
||||||
|
@role_required("admin")
|
||||||
|
@csrf_protect
|
||||||
|
async def affiliate_program_delete(program_id: int):
|
||||||
|
"""Delete an affiliate program — blocked if products reference it."""
|
||||||
|
program = await fetch_one(
|
||||||
|
"SELECT name FROM affiliate_programs WHERE id = ?", (program_id,)
|
||||||
|
)
|
||||||
|
if not program:
|
||||||
|
return redirect(url_for("admin.affiliate_programs"))
|
||||||
|
|
||||||
|
product_count = await fetch_one(
|
||||||
|
"SELECT COUNT(*) AS cnt FROM affiliate_products WHERE program_id = ?",
|
||||||
|
(program_id,),
|
||||||
|
)
|
||||||
|
count = product_count["cnt"] if product_count else 0
|
||||||
|
if count > 0:
|
||||||
|
await flash(
|
||||||
|
f"Cannot delete '{program['name']}' — {count} product(s) reference it. "
|
||||||
|
"Reassign or remove those products first.",
|
||||||
|
"error",
|
||||||
|
)
|
||||||
|
return redirect(url_for("admin.affiliate_programs"))
|
||||||
|
|
||||||
|
await execute("DELETE FROM affiliate_programs WHERE id = ?", (program_id,))
|
||||||
|
await flash(f"Program '{program['name']}' deleted.", "success")
|
||||||
|
return redirect(url_for("admin.affiliate_programs"))
|
||||||
|
|
||||||
|
|
||||||
def _form_to_product(form) -> dict:
|
def _form_to_product(form) -> dict:
|
||||||
@@ -3279,13 +3486,26 @@ def _form_to_product(form) -> dict:
|
|||||||
pros = json.dumps([line.strip() for line in pros_raw.splitlines() if line.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()])
|
cons = json.dumps([line.strip() for line in cons_raw.splitlines() if line.strip()])
|
||||||
|
|
||||||
|
# Program-based URL vs manual URL.
|
||||||
|
# When a program is selected, product_identifier holds the ASIN/path;
|
||||||
|
# affiliate_url is cleared. Manual mode is the reverse.
|
||||||
|
program_id_str = form.get("program_id", "").strip()
|
||||||
|
program_id = int(program_id_str) if program_id_str and program_id_str != "0" else None
|
||||||
|
product_identifier = form.get("product_identifier", "").strip()
|
||||||
|
affiliate_url = form.get("affiliate_url", "").strip()
|
||||||
|
|
||||||
|
# retailer is auto-populated from program name on save (kept for display/filter)
|
||||||
|
retailer = form.get("retailer", "").strip()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"slug": form.get("slug", "").strip(),
|
"slug": form.get("slug", "").strip(),
|
||||||
"name": form.get("name", "").strip(),
|
"name": form.get("name", "").strip(),
|
||||||
"brand": form.get("brand", "").strip(),
|
"brand": form.get("brand", "").strip(),
|
||||||
"category": form.get("category", "accessory").strip(),
|
"category": form.get("category", "accessory").strip(),
|
||||||
"retailer": form.get("retailer", "").strip(),
|
"retailer": retailer,
|
||||||
"affiliate_url": form.get("affiliate_url", "").strip(),
|
"program_id": program_id,
|
||||||
|
"product_identifier": product_identifier,
|
||||||
|
"affiliate_url": affiliate_url,
|
||||||
"image_url": form.get("image_url", "").strip(),
|
"image_url": form.get("image_url", "").strip(),
|
||||||
"price_cents": price_cents,
|
"price_cents": price_cents,
|
||||||
"currency": "EUR",
|
"currency": "EUR",
|
||||||
@@ -3403,14 +3623,15 @@ async def affiliate_preview():
|
|||||||
@csrf_protect
|
@csrf_protect
|
||||||
async def affiliate_new():
|
async def affiliate_new():
|
||||||
"""Create an affiliate product."""
|
"""Create an affiliate product."""
|
||||||
from ..affiliate import get_distinct_retailers
|
from ..affiliate import get_all_programs, get_distinct_retailers
|
||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
form = await request.form
|
form = await request.form
|
||||||
data = _form_to_product(form)
|
data = _form_to_product(form)
|
||||||
|
|
||||||
if not data["slug"] or not data["name"] or not data["affiliate_url"]:
|
has_url = bool(data["program_id"] and data["product_identifier"]) or bool(data["affiliate_url"])
|
||||||
await flash("Slug, name, and affiliate URL are required.", "error")
|
if not data["slug"] or not data["name"] or not has_url:
|
||||||
|
await flash("Slug, name, and either a program+product ID or manual URL are required.", "error")
|
||||||
return await render_template(
|
return await render_template(
|
||||||
"admin/affiliate_form.html",
|
"admin/affiliate_form.html",
|
||||||
admin_page="affiliate",
|
admin_page="affiliate",
|
||||||
@@ -3419,6 +3640,7 @@ async def affiliate_new():
|
|||||||
categories=AFFILIATE_CATEGORIES,
|
categories=AFFILIATE_CATEGORIES,
|
||||||
statuses=AFFILIATE_STATUSES,
|
statuses=AFFILIATE_STATUSES,
|
||||||
retailers=await get_distinct_retailers(),
|
retailers=await get_distinct_retailers(),
|
||||||
|
programs=await get_all_programs(status="active"),
|
||||||
)
|
)
|
||||||
|
|
||||||
existing = await fetch_one(
|
existing = await fetch_one(
|
||||||
@@ -3435,17 +3657,27 @@ async def affiliate_new():
|
|||||||
categories=AFFILIATE_CATEGORIES,
|
categories=AFFILIATE_CATEGORIES,
|
||||||
statuses=AFFILIATE_STATUSES,
|
statuses=AFFILIATE_STATUSES,
|
||||||
retailers=await get_distinct_retailers(),
|
retailers=await get_distinct_retailers(),
|
||||||
|
programs=await get_all_programs(status="active"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Auto-populate retailer from program name if not manually set
|
||||||
|
if data["program_id"] and not data["retailer"]:
|
||||||
|
prog = await fetch_one(
|
||||||
|
"SELECT name FROM affiliate_programs WHERE id = ?", (data["program_id"],)
|
||||||
|
)
|
||||||
|
if prog:
|
||||||
|
data["retailer"] = prog["name"]
|
||||||
|
|
||||||
await execute(
|
await execute(
|
||||||
"""INSERT INTO affiliate_products
|
"""INSERT INTO affiliate_products
|
||||||
(slug, name, brand, category, retailer, affiliate_url, image_url,
|
(slug, name, brand, category, retailer, program_id, product_identifier,
|
||||||
price_cents, currency, rating, pros, cons, description, cta_label,
|
affiliate_url, image_url, price_cents, currency, rating, pros, cons,
|
||||||
status, language, sort_order)
|
description, cta_label, status, language, sort_order)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||||
(
|
(
|
||||||
data["slug"], data["name"], data["brand"], data["category"],
|
data["slug"], data["name"], data["brand"], data["category"],
|
||||||
data["retailer"], data["affiliate_url"], data["image_url"],
|
data["retailer"], data["program_id"], data["product_identifier"],
|
||||||
|
data["affiliate_url"], data["image_url"],
|
||||||
data["price_cents"], data["currency"], data["rating"],
|
data["price_cents"], data["currency"], data["rating"],
|
||||||
data["pros"], data["cons"], data["description"], data["cta_label"],
|
data["pros"], data["cons"], data["description"], data["cta_label"],
|
||||||
data["status"], data["language"], data["sort_order"],
|
data["status"], data["language"], data["sort_order"],
|
||||||
@@ -3462,6 +3694,7 @@ async def affiliate_new():
|
|||||||
categories=AFFILIATE_CATEGORIES,
|
categories=AFFILIATE_CATEGORIES,
|
||||||
statuses=AFFILIATE_STATUSES,
|
statuses=AFFILIATE_STATUSES,
|
||||||
retailers=await get_distinct_retailers(),
|
retailers=await get_distinct_retailers(),
|
||||||
|
programs=await get_all_programs(status="active"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -3470,7 +3703,7 @@ async def affiliate_new():
|
|||||||
@csrf_protect
|
@csrf_protect
|
||||||
async def affiliate_edit(product_id: int):
|
async def affiliate_edit(product_id: int):
|
||||||
"""Edit an affiliate product."""
|
"""Edit an affiliate product."""
|
||||||
from ..affiliate import get_distinct_retailers
|
from ..affiliate import get_all_programs, get_distinct_retailers
|
||||||
|
|
||||||
product = await fetch_one("SELECT * FROM affiliate_products WHERE id = ?", (product_id,))
|
product = await fetch_one("SELECT * FROM affiliate_products WHERE id = ?", (product_id,))
|
||||||
if not product:
|
if not product:
|
||||||
@@ -3481,8 +3714,9 @@ async def affiliate_edit(product_id: int):
|
|||||||
form = await request.form
|
form = await request.form
|
||||||
data = _form_to_product(form)
|
data = _form_to_product(form)
|
||||||
|
|
||||||
if not data["slug"] or not data["name"] or not data["affiliate_url"]:
|
has_url = bool(data["program_id"] and data["product_identifier"]) or bool(data["affiliate_url"])
|
||||||
await flash("Slug, name, and affiliate URL are required.", "error")
|
if not data["slug"] or not data["name"] or not has_url:
|
||||||
|
await flash("Slug, name, and either a program+product ID or manual URL are required.", "error")
|
||||||
return await render_template(
|
return await render_template(
|
||||||
"admin/affiliate_form.html",
|
"admin/affiliate_form.html",
|
||||||
admin_page="affiliate",
|
admin_page="affiliate",
|
||||||
@@ -3492,6 +3726,7 @@ async def affiliate_edit(product_id: int):
|
|||||||
categories=AFFILIATE_CATEGORIES,
|
categories=AFFILIATE_CATEGORIES,
|
||||||
statuses=AFFILIATE_STATUSES,
|
statuses=AFFILIATE_STATUSES,
|
||||||
retailers=await get_distinct_retailers(),
|
retailers=await get_distinct_retailers(),
|
||||||
|
programs=await get_all_programs(status="active"),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check slug collision only if slug or language changed
|
# Check slug collision only if slug or language changed
|
||||||
@@ -3511,18 +3746,29 @@ async def affiliate_edit(product_id: int):
|
|||||||
categories=AFFILIATE_CATEGORIES,
|
categories=AFFILIATE_CATEGORIES,
|
||||||
statuses=AFFILIATE_STATUSES,
|
statuses=AFFILIATE_STATUSES,
|
||||||
retailers=await get_distinct_retailers(),
|
retailers=await get_distinct_retailers(),
|
||||||
|
programs=await get_all_programs(status="active"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Auto-populate retailer from program name if not manually set
|
||||||
|
if data["program_id"] and not data["retailer"]:
|
||||||
|
prog = await fetch_one(
|
||||||
|
"SELECT name FROM affiliate_programs WHERE id = ?", (data["program_id"],)
|
||||||
|
)
|
||||||
|
if prog:
|
||||||
|
data["retailer"] = prog["name"]
|
||||||
|
|
||||||
await execute(
|
await execute(
|
||||||
"""UPDATE affiliate_products
|
"""UPDATE affiliate_products
|
||||||
SET slug=?, name=?, brand=?, category=?, retailer=?, affiliate_url=?,
|
SET slug=?, name=?, brand=?, category=?, retailer=?, program_id=?,
|
||||||
image_url=?, price_cents=?, currency=?, rating=?, pros=?, cons=?,
|
product_identifier=?, affiliate_url=?, image_url=?,
|
||||||
|
price_cents=?, currency=?, rating=?, pros=?, cons=?,
|
||||||
description=?, cta_label=?, status=?, language=?, sort_order=?,
|
description=?, cta_label=?, status=?, language=?, sort_order=?,
|
||||||
updated_at=datetime('now')
|
updated_at=datetime('now')
|
||||||
WHERE id=?""",
|
WHERE id=?""",
|
||||||
(
|
(
|
||||||
data["slug"], data["name"], data["brand"], data["category"],
|
data["slug"], data["name"], data["brand"], data["category"],
|
||||||
data["retailer"], data["affiliate_url"], data["image_url"],
|
data["retailer"], data["program_id"], data["product_identifier"],
|
||||||
|
data["affiliate_url"], data["image_url"],
|
||||||
data["price_cents"], data["currency"], data["rating"],
|
data["price_cents"], data["currency"], data["rating"],
|
||||||
data["pros"], data["cons"], data["description"], data["cta_label"],
|
data["pros"], data["cons"], data["description"], data["cta_label"],
|
||||||
data["status"], data["language"], data["sort_order"],
|
data["status"], data["language"], data["sort_order"],
|
||||||
@@ -3554,6 +3800,7 @@ async def affiliate_edit(product_id: int):
|
|||||||
categories=AFFILIATE_CATEGORIES,
|
categories=AFFILIATE_CATEGORIES,
|
||||||
statuses=AFFILIATE_STATUSES,
|
statuses=AFFILIATE_STATUSES,
|
||||||
retailers=await get_distinct_retailers(),
|
retailers=await get_distinct_retailers(),
|
||||||
|
programs=await get_all_programs(status="active"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,20 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
slugInput.dataset.manual = '1';
|
slugInput.dataset.manual = '1';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Toggle program-based vs manual URL fields
|
||||||
|
function toggleProgramFields() {
|
||||||
|
var sel = document.getElementById('f-program');
|
||||||
|
if (!sel) return;
|
||||||
|
var isManual = sel.value === '0' || sel.value === '';
|
||||||
|
document.getElementById('f-product-id-row').style.display = isManual ? 'none' : '';
|
||||||
|
document.getElementById('f-manual-url-row').style.display = isManual ? '' : 'none';
|
||||||
|
}
|
||||||
|
var programSel = document.getElementById('f-program');
|
||||||
|
if (programSel) {
|
||||||
|
programSel.addEventListener('change', toggleProgramFields);
|
||||||
|
toggleProgramFields();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -87,9 +101,38 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# Retailer #}
|
{# Program dropdown #}
|
||||||
<div>
|
<div>
|
||||||
<label class="form-label" for="f-retailer">Retailer</label>
|
<label class="form-label" for="f-program">Affiliate Program</label>
|
||||||
|
<select id="f-program" name="program_id" class="form-input">
|
||||||
|
<option value="0" {% if not data.get('program_id') %}selected{% endif %}>Manual (custom URL)</option>
|
||||||
|
{% for prog in programs %}
|
||||||
|
<option value="{{ prog.id }}" {% if data.get('program_id') == prog.id %}selected{% endif %}>{{ prog.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<p class="form-hint">Select a program to auto-build the URL, or choose Manual for a custom link.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Product Identifier (shown when program selected) #}
|
||||||
|
<div id="f-product-id-row">
|
||||||
|
<label class="form-label" for="f-product-id">Product ID *</label>
|
||||||
|
<input id="f-product-id" type="text" name="product_identifier"
|
||||||
|
value="{{ data.get('product_identifier','') }}"
|
||||||
|
class="form-input" placeholder="e.g. B0XXXXXXXXX (ASIN for Amazon)">
|
||||||
|
<p class="form-hint">ASIN, product path, or other program-specific identifier. URL is assembled at redirect time.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Manual URL (shown when Manual selected) #}
|
||||||
|
<div id="f-manual-url-row">
|
||||||
|
<label class="form-label" for="f-url">Affiliate URL</label>
|
||||||
|
<input id="f-url" type="url" name="affiliate_url" value="{{ data.get('affiliate_url','') }}"
|
||||||
|
class="form-input" placeholder="https://www.amazon.de/dp/B0XXXXX?tag=padelnomics-21">
|
||||||
|
<p class="form-hint">Full URL with tracking params already baked in. Used as fallback if no program is set.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Retailer (auto-populated from program; editable for manual products) #}
|
||||||
|
<div>
|
||||||
|
<label class="form-label" for="f-retailer">Retailer <span class="form-hint" style="font-weight:normal">(auto-filled from program)</span></label>
|
||||||
<input id="f-retailer" type="text" name="retailer" value="{{ data.get('retailer','') }}"
|
<input id="f-retailer" type="text" name="retailer" value="{{ data.get('retailer','') }}"
|
||||||
class="form-input" placeholder="e.g. Amazon, Padel Nuestro"
|
class="form-input" placeholder="e.g. Amazon, Padel Nuestro"
|
||||||
list="retailers-list">
|
list="retailers-list">
|
||||||
@@ -100,14 +143,6 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
</datalist>
|
</datalist>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# Affiliate URL #}
|
|
||||||
<div>
|
|
||||||
<label class="form-label" for="f-url">Affiliate URL *</label>
|
|
||||||
<input id="f-url" type="url" name="affiliate_url" value="{{ data.get('affiliate_url','') }}"
|
|
||||||
class="form-input" placeholder="https://www.amazon.de/dp/B0XXXXX?tag=padelnomics-21" required>
|
|
||||||
<p class="form-hint">Full URL with tracking params already baked in.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{# Image URL #}
|
{# Image URL #}
|
||||||
<div>
|
<div>
|
||||||
<label class="form-label" for="f-image">Image URL</label>
|
<label class="form-label" for="f-image">Image URL</label>
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
{% extends "admin/base_admin.html" %}
|
||||||
|
{% set admin_page = "affiliate_programs" %}
|
||||||
|
|
||||||
|
{% block title %}{% if editing %}Edit Program{% else %}New Program{% endif %} - Admin - {{ config.APP_NAME }}{% endblock %}
|
||||||
|
|
||||||
|
{% block admin_head %}
|
||||||
|
<script>
|
||||||
|
function slugify(text) {
|
||||||
|
return text.toLowerCase()
|
||||||
|
.replace(/[äöü]/g, c => ({'ä':'ae','ö':'oe','ü':'ue'}[c]))
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '');
|
||||||
|
}
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
var nameInput = document.getElementById('f-name');
|
||||||
|
var slugInput = document.getElementById('f-slug');
|
||||||
|
if (nameInput && slugInput && !slugInput.value) {
|
||||||
|
nameInput.addEventListener('input', function() {
|
||||||
|
if (!slugInput.dataset.manual) {
|
||||||
|
slugInput.value = slugify(nameInput.value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
slugInput.addEventListener('input', function() {
|
||||||
|
slugInput.dataset.manual = '1';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block admin_content %}
|
||||||
|
<header class="flex justify-between items-center mb-6">
|
||||||
|
<div>
|
||||||
|
<a href="{{ url_for('admin.affiliate_programs') }}" class="text-slate text-sm" style="text-decoration:none">← Programs</a>
|
||||||
|
<h1 class="text-2xl mt-1">{% if editing %}Edit Program{% else %}New Program{% endif %}</h1>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div style="max-width:600px">
|
||||||
|
<form method="post" id="program-form"
|
||||||
|
action="{% if editing %}{{ url_for('admin.affiliate_program_edit', program_id=program_id) }}{% else %}{{ url_for('admin.affiliate_program_new') }}{% endif %}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
|
||||||
|
<div class="card" style="padding:1.5rem;display:flex;flex-direction:column;gap:1.25rem;">
|
||||||
|
|
||||||
|
{# Name #}
|
||||||
|
<div>
|
||||||
|
<label class="form-label" for="f-name">Name *</label>
|
||||||
|
<input id="f-name" type="text" name="name" value="{{ data.get('name','') }}"
|
||||||
|
class="form-input" placeholder="e.g. Amazon, Padel Nuestro" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Slug #}
|
||||||
|
<div>
|
||||||
|
<label class="form-label" for="f-slug">Slug *</label>
|
||||||
|
<input id="f-slug" type="text" name="slug" value="{{ data.get('slug','') }}"
|
||||||
|
class="form-input" placeholder="e.g. amazon, padel-nuestro" required
|
||||||
|
pattern="[a-z0-9][a-z0-9\-]*">
|
||||||
|
<p class="form-hint">Lowercase letters, numbers, hyphens only.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# URL Template #}
|
||||||
|
<div>
|
||||||
|
<label class="form-label" for="f-template">URL Template *</label>
|
||||||
|
<input id="f-template" type="text" name="url_template" value="{{ data.get('url_template','') }}"
|
||||||
|
class="form-input" placeholder="https://www.amazon.de/dp/{product_id}?tag={tag}" required>
|
||||||
|
<p class="form-hint">
|
||||||
|
Use <code>{product_id}</code> for the ASIN/product path and <code>{tag}</code> for the tracking tag.<br>
|
||||||
|
Example: <code>https://www.amazon.de/dp/{product_id}?tag={tag}</code>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Tracking Tag + Commission row #}
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;">
|
||||||
|
<div>
|
||||||
|
<label class="form-label" for="f-tag">Tracking Tag</label>
|
||||||
|
<input id="f-tag" type="text" name="tracking_tag" value="{{ data.get('tracking_tag','') }}"
|
||||||
|
class="form-input" placeholder="e.g. padelnomics-21">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="form-label" for="f-commission">Commission %</label>
|
||||||
|
<input id="f-commission" type="number" name="commission_pct" value="{{ data.get('commission_pct', 0) }}"
|
||||||
|
class="form-input" placeholder="3" step="0.1" min="0" max="100">
|
||||||
|
<p class="form-hint">Used for revenue estimates (e.g. 3 = 3%).</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Homepage URL #}
|
||||||
|
<div>
|
||||||
|
<label class="form-label" for="f-homepage">Homepage URL</label>
|
||||||
|
<input id="f-homepage" type="url" name="homepage_url" value="{{ data.get('homepage_url','') }}"
|
||||||
|
class="form-input" placeholder="https://www.amazon.de">
|
||||||
|
<p class="form-hint">Shown as a link in the programs list.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Status #}
|
||||||
|
<div>
|
||||||
|
<label class="form-label" for="f-status">Status</label>
|
||||||
|
<select id="f-status" name="status" class="form-input">
|
||||||
|
{% for s in program_statuses %}
|
||||||
|
<option value="{{ s }}" {% if data.get('status','active') == s %}selected{% endif %}>{{ s | capitalize }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<p class="form-hint">Inactive programs are hidden from the product form dropdown.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Notes #}
|
||||||
|
<div>
|
||||||
|
<label class="form-label" for="f-notes">Notes <span class="form-hint" style="font-weight:normal">(internal)</span></label>
|
||||||
|
<textarea id="f-notes" name="notes" rows="3"
|
||||||
|
class="form-input" placeholder="Login URL, account ID, affiliate dashboard link...">{{ data.get('notes','') }}</textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Actions #}
|
||||||
|
<div class="flex gap-3 justify-between" style="margin-top:.5rem">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button type="submit" class="btn">
|
||||||
|
{% if editing %}Save Changes{% else %}Create Program{% endif %}
|
||||||
|
</button>
|
||||||
|
<a href="{{ url_for('admin.affiliate_programs') }}" class="btn-outline">Cancel</a>
|
||||||
|
</div>
|
||||||
|
{% if editing %}
|
||||||
|
<form method="post" action="{{ url_for('admin.affiliate_program_delete', program_id=program_id) }}" style="margin:0">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<button type="submit" class="btn-outline"
|
||||||
|
onclick="return confirm('Delete this program? Blocked if products reference it.')">Delete</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{% extends "admin/base_admin.html" %}
|
||||||
|
{% set admin_page = "affiliate_programs" %}
|
||||||
|
|
||||||
|
{% block title %}Affiliate Programs - Admin - {{ config.APP_NAME }}{% endblock %}
|
||||||
|
|
||||||
|
{% block admin_content %}
|
||||||
|
<header class="flex justify-between items-center mb-6">
|
||||||
|
<h1 class="text-2xl">Affiliate Programs</h1>
|
||||||
|
<a href="{{ url_for('admin.affiliate_program_new') }}" class="btn btn-sm">+ New Program</a>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div id="prog-results">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Slug</th>
|
||||||
|
<th>Tracking Tag</th>
|
||||||
|
<th class="text-right">Commission</th>
|
||||||
|
<th class="text-right">Products</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th class="text-right">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% include "admin/partials/affiliate_program_results.html" %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -99,7 +99,7 @@
|
|||||||
'suppliers': 'suppliers',
|
'suppliers': 'suppliers',
|
||||||
'articles': 'content', 'scenarios': 'content', 'templates': 'content', 'pseo': 'content',
|
'articles': 'content', 'scenarios': 'content', 'templates': 'content', 'pseo': 'content',
|
||||||
'emails': 'email', 'inbox': 'email', 'compose': 'email', 'gallery': 'email', 'audiences': 'email', 'outreach': 'email',
|
'emails': 'email', 'inbox': 'email', 'compose': 'email', 'gallery': 'email', 'audiences': 'email', 'outreach': 'email',
|
||||||
'affiliate': 'affiliate', 'affiliate_dashboard': 'affiliate',
|
'affiliate': 'affiliate', 'affiliate_dashboard': 'affiliate', 'affiliate_programs': 'affiliate',
|
||||||
'billing': 'billing',
|
'billing': 'billing',
|
||||||
'seo': 'analytics',
|
'seo': 'analytics',
|
||||||
'pipeline': 'pipeline',
|
'pipeline': 'pipeline',
|
||||||
@@ -206,6 +206,7 @@
|
|||||||
<nav class="admin-subnav">
|
<nav class="admin-subnav">
|
||||||
<a href="{{ url_for('admin.affiliate_dashboard') }}" class="{% if admin_page == 'affiliate_dashboard' %}active{% endif %}">Dashboard</a>
|
<a href="{{ url_for('admin.affiliate_dashboard') }}" class="{% if admin_page == 'affiliate_dashboard' %}active{% endif %}">Dashboard</a>
|
||||||
<a href="{{ url_for('admin.affiliate_products') }}" class="{% if admin_page == 'affiliate' %}active{% endif %}">Products</a>
|
<a href="{{ url_for('admin.affiliate_products') }}" class="{% if admin_page == 'affiliate' %}active{% endif %}">Products</a>
|
||||||
|
<a href="{{ url_for('admin.affiliate_programs') }}" class="{% if admin_page == 'affiliate_programs' %}active{% endif %}">Programs</a>
|
||||||
</nav>
|
</nav>
|
||||||
{% elif active_section == 'system' %}
|
{% elif active_section == 'system' %}
|
||||||
<nav class="admin-subnav">
|
<nav class="admin-subnav">
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
{% if programs %}
|
||||||
|
{% for prog in programs %}
|
||||||
|
<tr id="prog-{{ prog.id }}">
|
||||||
|
<td style="font-weight:500">
|
||||||
|
{% if prog.homepage_url %}
|
||||||
|
<a href="{{ prog.homepage_url }}" target="_blank" rel="noopener" style="color:#0F172A;text-decoration:none">{{ prog.name }}</a>
|
||||||
|
{% else %}
|
||||||
|
{{ prog.name }}
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="mono text-slate">{{ prog.slug }}</td>
|
||||||
|
<td class="mono text-slate">{{ prog.tracking_tag or '—' }}</td>
|
||||||
|
<td class="mono text-right">
|
||||||
|
{% if prog.commission_pct %}{{ "%.0f" | format(prog.commission_pct) }}%{% else %}—{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="mono text-right">{{ prog.product_count }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge {% if prog.status == 'active' %}badge-success{% else %}badge{% endif %}">
|
||||||
|
{{ prog.status }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="text-right" style="white-space:nowrap">
|
||||||
|
<a href="{{ url_for('admin.affiliate_program_edit', program_id=prog.id) }}" class="btn-outline btn-sm">Edit</a>
|
||||||
|
<form method="post" action="{{ url_for('admin.affiliate_program_delete', program_id=prog.id) }}" style="display:inline">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<button type="submit" class="btn-outline btn-sm"
|
||||||
|
onclick="return confirm('Delete {{ prog.name }}? This is blocked if products reference it.')">Delete</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="7" class="text-slate" style="text-align:center;padding:2rem;">No programs found.</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -291,7 +291,7 @@ def create_app() -> Quart:
|
|||||||
Uses 302 (not 301) so every hit is tracked — browsers don't cache 302s.
|
Uses 302 (not 301) so every hit is tracked — browsers don't cache 302s.
|
||||||
Extracts article_slug and lang from Referer header best-effort.
|
Extracts article_slug and lang from Referer header best-effort.
|
||||||
"""
|
"""
|
||||||
from .affiliate import get_product, log_click
|
from .affiliate import build_affiliate_url, get_product, log_click
|
||||||
from .core import check_rate_limit
|
from .core import check_rate_limit
|
||||||
|
|
||||||
# Extract lang from Referer path (e.g. /de/blog/... → "de"), default de
|
# Extract lang from Referer path (e.g. /de/blog/... → "de"), default de
|
||||||
@@ -314,14 +314,17 @@ def create_app() -> Quart:
|
|||||||
if not product:
|
if not product:
|
||||||
abort(404)
|
abort(404)
|
||||||
|
|
||||||
|
# Assemble URL from program template; falls back to baked affiliate_url
|
||||||
|
url = build_affiliate_url(product, product.get("_program"))
|
||||||
|
|
||||||
ip = request.remote_addr or "unknown"
|
ip = request.remote_addr or "unknown"
|
||||||
allowed, _info = await check_rate_limit(f"aff:{ip}", limit=60, window=60)
|
allowed, _info = await check_rate_limit(f"aff:{ip}", limit=60, window=60)
|
||||||
if not allowed:
|
if not allowed:
|
||||||
# Still redirect even if rate-limited; just don't log the click
|
# Still redirect even if rate-limited; just don't log the click
|
||||||
return redirect(product["affiliate_url"], 302)
|
return redirect(url, 302)
|
||||||
|
|
||||||
await log_click(product["id"], ip, article_slug, referer or None)
|
await log_click(product["id"], ip, article_slug, referer or None)
|
||||||
return redirect(product["affiliate_url"], 302)
|
return redirect(url, 302)
|
||||||
|
|
||||||
# Legacy 301 redirects — bookmarked/cached URLs before lang prefixes existed
|
# Legacy 301 redirects — bookmarked/cached URLs before lang prefixes existed
|
||||||
@app.route("/terms")
|
@app.route("/terms")
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""Migration 0027: Affiliate programs table + program FK on products.
|
||||||
|
|
||||||
|
affiliate_programs: centralises retailer configs (URL template + tag + commission).
|
||||||
|
- url_template uses {product_id} and {tag} placeholders, assembled at redirect time.
|
||||||
|
- tracking_tag: e.g. "padelnomics-21" — changing it propagates to all products instantly.
|
||||||
|
- commission_pct: stored as a decimal (0.03 = 3%) for revenue estimates.
|
||||||
|
- status: active/inactive — only active programs appear in the product form dropdown.
|
||||||
|
- notes: internal field for login URLs, account IDs, etc.
|
||||||
|
|
||||||
|
affiliate_products changes:
|
||||||
|
- program_id (nullable FK): new products use a program; existing products keep their
|
||||||
|
baked affiliate_url (backward compat via build_affiliate_url() fallback).
|
||||||
|
- product_identifier: ASIN, product path, or other program-specific ID (e.g. B0XXXXX).
|
||||||
|
|
||||||
|
Amazon OneLink note: we use a single "Amazon" program pointing to amazon.de.
|
||||||
|
Amazon OneLink (configured in the Associates dashboard, no code changes needed)
|
||||||
|
auto-redirects visitors to their local marketplace (UK→amazon.co.uk, ES→amazon.es)
|
||||||
|
with the correct regional tag. One program covers all Amazon marketplaces.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
|
||||||
|
|
||||||
|
def up(conn) -> None:
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE affiliate_programs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
slug TEXT NOT NULL UNIQUE,
|
||||||
|
url_template TEXT NOT NULL,
|
||||||
|
tracking_tag TEXT NOT NULL DEFAULT '',
|
||||||
|
commission_pct REAL NOT NULL DEFAULT 0,
|
||||||
|
homepage_url TEXT NOT NULL DEFAULT '',
|
||||||
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
notes TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Seed the default Amazon program.
|
||||||
|
# OneLink handles geo-redirect to local marketplaces — no per-country programs needed.
|
||||||
|
conn.execute("""
|
||||||
|
INSERT INTO affiliate_programs (name, slug, url_template, tracking_tag, commission_pct, homepage_url)
|
||||||
|
VALUES ('Amazon', 'amazon', 'https://www.amazon.de/dp/{product_id}?tag={tag}', 'padelnomics-21', 3.0, 'https://www.amazon.de')
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Add program FK + product identifier to products table.
|
||||||
|
# program_id is nullable — existing rows keep their baked affiliate_url.
|
||||||
|
conn.execute("""
|
||||||
|
ALTER TABLE affiliate_products
|
||||||
|
ADD COLUMN program_id INTEGER REFERENCES affiliate_programs(id)
|
||||||
|
""")
|
||||||
|
conn.execute("""
|
||||||
|
ALTER TABLE affiliate_products
|
||||||
|
ADD COLUMN product_identifier TEXT NOT NULL DEFAULT ''
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Backfill: extract ASIN from existing Amazon affiliate URLs.
|
||||||
|
# Pattern: /dp/<ASIN> where ASIN is 10 uppercase alphanumeric chars.
|
||||||
|
amazon_program = conn.execute(
|
||||||
|
"SELECT id FROM affiliate_programs WHERE slug = 'amazon'"
|
||||||
|
).fetchone()
|
||||||
|
assert amazon_program is not None, "Amazon program must exist after seed"
|
||||||
|
amazon_id = amazon_program[0]
|
||||||
|
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT id, affiliate_url FROM affiliate_products"
|
||||||
|
).fetchall()
|
||||||
|
asin_re = re.compile(r"/dp/([A-Z0-9]{10})")
|
||||||
|
for product_id, url in rows:
|
||||||
|
if not url:
|
||||||
|
continue
|
||||||
|
m = asin_re.search(url)
|
||||||
|
if m:
|
||||||
|
asin = m.group(1)
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE affiliate_products SET program_id=?, product_identifier=? WHERE id=?",
|
||||||
|
(amazon_id, asin, product_id),
|
||||||
|
)
|
||||||
@@ -2,7 +2,8 @@
|
|||||||
Tests for the affiliate product system.
|
Tests for the affiliate product system.
|
||||||
|
|
||||||
Covers: hash_ip determinism, product CRUD, bake_product_cards marker replacement,
|
Covers: hash_ip determinism, product CRUD, bake_product_cards marker replacement,
|
||||||
click redirect (302 + logged), rate limiting, inactive product 404, multi-retailer.
|
click redirect (302 + logged), rate limiting, inactive product 404, multi-retailer,
|
||||||
|
program CRUD, build_affiliate_url(), program-based redirect.
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
from datetime import date
|
from datetime import date
|
||||||
@@ -10,11 +11,15 @@ from unittest.mock import patch
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from padelnomics.affiliate import (
|
from padelnomics.affiliate import (
|
||||||
|
build_affiliate_url,
|
||||||
get_all_products,
|
get_all_products,
|
||||||
|
get_all_programs,
|
||||||
get_click_counts,
|
get_click_counts,
|
||||||
get_click_stats,
|
get_click_stats,
|
||||||
get_product,
|
get_product,
|
||||||
get_products_by_category,
|
get_products_by_category,
|
||||||
|
get_program,
|
||||||
|
get_program_by_slug,
|
||||||
hash_ip,
|
hash_ip,
|
||||||
log_click,
|
log_click,
|
||||||
)
|
)
|
||||||
@@ -330,3 +335,282 @@ async def test_affiliate_redirect_unknown_404(app, db):
|
|||||||
async with app.test_client() as client:
|
async with app.test_client() as client:
|
||||||
response = await client.get("/go/totally-unknown-xyz")
|
response = await client.get("/go/totally-unknown-xyz")
|
||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
# ── affiliate_programs ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _insert_program(
|
||||||
|
name="Test Shop",
|
||||||
|
slug="test-shop",
|
||||||
|
url_template="https://testshop.example.com/p/{product_id}?ref={tag}",
|
||||||
|
tracking_tag="testref",
|
||||||
|
commission_pct=5.0,
|
||||||
|
homepage_url="https://testshop.example.com",
|
||||||
|
status="active",
|
||||||
|
) -> int:
|
||||||
|
"""Insert an affiliate program, return its id."""
|
||||||
|
return await execute(
|
||||||
|
"""INSERT INTO affiliate_programs
|
||||||
|
(name, slug, url_template, tracking_tag, commission_pct, homepage_url, status)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
||||||
|
(name, slug, url_template, tracking_tag, commission_pct, homepage_url, status),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("db")
|
||||||
|
async def test_get_all_programs_returns_all(db):
|
||||||
|
"""get_all_programs returns inserted programs sorted by name."""
|
||||||
|
await _insert_program(slug="zebra-shop", name="Zebra Shop")
|
||||||
|
await _insert_program(slug="alpha-shop", name="Alpha Shop")
|
||||||
|
programs = await get_all_programs()
|
||||||
|
names = [p["name"] for p in programs]
|
||||||
|
assert "Alpha Shop" in names
|
||||||
|
assert "Zebra Shop" in names
|
||||||
|
# Sorted by name ascending
|
||||||
|
assert names.index("Alpha Shop") < names.index("Zebra Shop")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("db")
|
||||||
|
async def test_get_all_programs_status_filter(db):
|
||||||
|
"""get_all_programs(status='active') excludes inactive programs."""
|
||||||
|
await _insert_program(slug="inactive-prog", status="inactive")
|
||||||
|
await _insert_program(slug="active-prog", name="Active Shop")
|
||||||
|
active = await get_all_programs(status="active")
|
||||||
|
statuses = [p["status"] for p in active]
|
||||||
|
assert all(s == "active" for s in statuses)
|
||||||
|
slugs = [p["slug"] for p in active]
|
||||||
|
assert "inactive-prog" not in slugs
|
||||||
|
assert "active-prog" in slugs
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("db")
|
||||||
|
async def test_get_program_by_id(db):
|
||||||
|
"""get_program returns a program by id."""
|
||||||
|
prog_id = await _insert_program()
|
||||||
|
prog = await get_program(prog_id)
|
||||||
|
assert prog is not None
|
||||||
|
assert prog["slug"] == "test-shop"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("db")
|
||||||
|
async def test_get_program_not_found(db):
|
||||||
|
"""get_program returns None for unknown id."""
|
||||||
|
prog = await get_program(99999)
|
||||||
|
assert prog is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("db")
|
||||||
|
async def test_get_program_by_slug(db):
|
||||||
|
"""get_program_by_slug returns the program for a known slug."""
|
||||||
|
await _insert_program(slug="find-by-slug")
|
||||||
|
prog = await get_program_by_slug("find-by-slug")
|
||||||
|
assert prog is not None
|
||||||
|
assert prog["name"] == "Test Shop"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("db")
|
||||||
|
async def test_get_program_by_slug_not_found(db):
|
||||||
|
"""get_program_by_slug returns None for unknown slug."""
|
||||||
|
prog = await get_program_by_slug("nonexistent-slug-xyz")
|
||||||
|
assert prog is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("db")
|
||||||
|
async def test_get_all_programs_product_count(db):
|
||||||
|
"""get_all_programs includes product_count for each program."""
|
||||||
|
prog_id = await _insert_program(slug="counted-prog")
|
||||||
|
await _insert_product(slug="p-for-count", program_id=prog_id)
|
||||||
|
programs = await get_all_programs()
|
||||||
|
prog = next(p for p in programs if p["slug"] == "counted-prog")
|
||||||
|
assert prog["product_count"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ── build_affiliate_url ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_build_affiliate_url_with_program():
|
||||||
|
"""build_affiliate_url assembles URL from program template."""
|
||||||
|
product = {"program_id": 1, "product_identifier": "B0TESTTEST", "affiliate_url": ""}
|
||||||
|
program = {"url_template": "https://amazon.de/dp/{product_id}?tag={tag}", "tracking_tag": "mysite-21"}
|
||||||
|
url = build_affiliate_url(product, program)
|
||||||
|
assert url == "https://amazon.de/dp/B0TESTTEST?tag=mysite-21"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_affiliate_url_legacy_fallback():
|
||||||
|
"""build_affiliate_url falls back to baked affiliate_url when no program."""
|
||||||
|
product = {"program_id": None, "product_identifier": "", "affiliate_url": "https://baked.example.com/p?tag=x"}
|
||||||
|
url = build_affiliate_url(product, None)
|
||||||
|
assert url == "https://baked.example.com/p?tag=x"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_affiliate_url_no_program_id():
|
||||||
|
"""build_affiliate_url uses fallback when program_id is 0/falsy."""
|
||||||
|
product = {"program_id": 0, "product_identifier": "B0IGNORED", "affiliate_url": "https://fallback.example.com"}
|
||||||
|
program = {"url_template": "https://shop.example.com/{product_id}?ref={tag}", "tracking_tag": "tag123"}
|
||||||
|
url = build_affiliate_url(product, program)
|
||||||
|
# program_id is falsy → fallback
|
||||||
|
assert url == "https://fallback.example.com"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_affiliate_url_no_program_dict():
|
||||||
|
"""build_affiliate_url uses fallback when program dict is None."""
|
||||||
|
product = {"program_id": 5, "product_identifier": "ASIN123", "affiliate_url": "https://fallback.example.com"}
|
||||||
|
url = build_affiliate_url(product, None)
|
||||||
|
assert url == "https://fallback.example.com"
|
||||||
|
|
||||||
|
|
||||||
|
# ── program-based redirect ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _insert_product( # noqa: F811 — redefined to add program_id support
|
||||||
|
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,
|
||||||
|
program_id=None,
|
||||||
|
product_identifier="",
|
||||||
|
) -> int:
|
||||||
|
"""Insert an affiliate product with optional program_id, 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,
|
||||||
|
program_id, product_identifier)
|
||||||
|
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,
|
||||||
|
program_id,
|
||||||
|
product_identifier,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("db")
|
||||||
|
async def test_affiliate_redirect_uses_program_url(app, db):
|
||||||
|
"""Redirect assembles URL from program template when product has program_id."""
|
||||||
|
prog_id = await _insert_program(
|
||||||
|
slug="amzn-test",
|
||||||
|
url_template="https://www.amazon.de/dp/{product_id}?tag={tag}",
|
||||||
|
tracking_tag="testsite-21",
|
||||||
|
)
|
||||||
|
await _insert_product(
|
||||||
|
slug="program-redirect-test",
|
||||||
|
affiliate_url="",
|
||||||
|
program_id=prog_id,
|
||||||
|
product_identifier="B0PROGRAM01",
|
||||||
|
)
|
||||||
|
async with app.test_client() as client:
|
||||||
|
response = await client.get("/go/program-redirect-test")
|
||||||
|
assert response.status_code == 302
|
||||||
|
location = response.headers.get("Location", "")
|
||||||
|
assert "B0PROGRAM01" in location
|
||||||
|
assert "testsite-21" in location
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("db")
|
||||||
|
async def test_affiliate_redirect_legacy_url_still_works(app, db):
|
||||||
|
"""Legacy products with baked affiliate_url still redirect correctly."""
|
||||||
|
await _insert_product(
|
||||||
|
slug="legacy-redirect-test",
|
||||||
|
affiliate_url="https://amazon.de/dp/LEGACY?tag=old-21",
|
||||||
|
program_id=None,
|
||||||
|
product_identifier="",
|
||||||
|
)
|
||||||
|
async with app.test_client() as client:
|
||||||
|
response = await client.get("/go/legacy-redirect-test")
|
||||||
|
assert response.status_code == 302
|
||||||
|
assert "LEGACY" in response.headers.get("Location", "")
|
||||||
|
|
||||||
|
|
||||||
|
# ── migration backfill ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _load_migration_0027():
|
||||||
|
"""Import migration 0027 via importlib (filename starts with a digit)."""
|
||||||
|
import importlib
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
versions_dir = Path(__file__).parent.parent / "src/padelnomics/migrations/versions"
|
||||||
|
spec = importlib.util.spec_from_file_location(
|
||||||
|
"migration_0027", versions_dir / "0027_affiliate_programs.py"
|
||||||
|
)
|
||||||
|
mod = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(mod)
|
||||||
|
return mod
|
||||||
|
|
||||||
|
|
||||||
|
def _make_pre_migration_db():
|
||||||
|
"""Create a minimal sqlite3 DB simulating state just before migration 0027.
|
||||||
|
|
||||||
|
Provides the affiliate_products table (migration ALTERs it), but not
|
||||||
|
affiliate_programs (migration CREATEs it).
|
||||||
|
"""
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
conn = sqlite3.connect(":memory:")
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE affiliate_products (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
slug TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL DEFAULT '',
|
||||||
|
affiliate_url TEXT NOT NULL DEFAULT '',
|
||||||
|
UNIQUE(slug)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.commit()
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def test_migration_seeds_amazon_program():
|
||||||
|
"""Migration 0027 up() seeds the Amazon program with expected fields.
|
||||||
|
|
||||||
|
Tests the migration function directly against a real sqlite3 DB
|
||||||
|
(the conftest only replays CREATE TABLE DDL, not INSERT seeds).
|
||||||
|
"""
|
||||||
|
migration = _load_migration_0027()
|
||||||
|
conn = _make_pre_migration_db()
|
||||||
|
migration.up(conn)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT * FROM affiliate_programs WHERE slug = 'amazon'"
|
||||||
|
).fetchone()
|
||||||
|
assert row is not None
|
||||||
|
cols = [d[0] for d in conn.execute("SELECT * FROM affiliate_programs WHERE slug = 'amazon'").description]
|
||||||
|
prog = dict(zip(cols, row))
|
||||||
|
assert prog["name"] == "Amazon"
|
||||||
|
assert "padelnomics-21" in prog["tracking_tag"]
|
||||||
|
assert "{product_id}" in prog["url_template"]
|
||||||
|
assert "{tag}" in prog["url_template"]
|
||||||
|
assert prog["commission_pct"] == 3.0
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_migration_backfills_asin_from_url():
|
||||||
|
"""Migration 0027 up() extracts ASINs from existing affiliate_url values."""
|
||||||
|
migration = _load_migration_0027()
|
||||||
|
conn = _make_pre_migration_db()
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO affiliate_products (slug, affiliate_url) VALUES (?, ?)",
|
||||||
|
("test-racket", "https://www.amazon.de/dp/B0ASIN1234?tag=test-21"),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
migration.up(conn)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT program_id, product_identifier FROM affiliate_products WHERE slug = 'test-racket'"
|
||||||
|
).fetchone()
|
||||||
|
assert row is not None
|
||||||
|
assert row[0] is not None # program_id set
|
||||||
|
assert row[1] == "B0ASIN1234" # ASIN extracted correctly
|
||||||
|
conn.close()
|
||||||
|
|||||||
Reference in New Issue
Block a user