30 Commits

Author SHA1 Message Date
Deeman
8f2ffd432b fix(admin): correct docker volume mount + pipeline_routes repo root
All checks were successful
CI / test (push) Successful in 50s
CI / tag (push) Successful in 2s
- docker-compose.prod.yml: fix volume mount for all 6 web containers
  from /opt/padelnomics/data (stale) → /data/padelnomics (live supervisor output);
  add LANDING_DIR=/app/data/pipeline/landing so extraction/landing stats work
- pipeline_routes.py: fix _REPO_ROOT parents[5] → parents[4] so workflows.toml
  is found in dev and pipeline overview shows workflow schedules

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 11:41:29 +01:00
Deeman
c9dec066f7 fix(admin): mobile UX fixes — contrast, scroll, responsive grids
- CSS: `.nav-mobile a` → `.nav-mobile a:not(.nav-auth-btn)` to fix Sign
  Out button showing slate text instead of white on mobile
- base_admin.html: add `overflow-y: hidden` + `scrollbar-width: none` to
  `.admin-subnav` to eliminate ghost 1px scrollbar on Content tab row
- routes.py: pass `outreach_email=EMAIL_ADDRESSES["outreach"]` to outreach
  template so sending domain is no longer hardcoded
- outreach.html: display dynamic `outreach_email`; replace inline
  `repeat(6,1fr)` grid with responsive `.pipeline-status-grid` (2→3→6 cols)
- index.html: replace inline `repeat(5,1fr)` Lead/Supplier Funnel grids
  with responsive `.funnel-grid` class (2 cols mobile, 5 cols md+)
- pipeline.html: replace inline `repeat(4,1fr)` stat grid with responsive
  `.pipeline-stat-grid` (2 cols mobile, 4 cols md+)
- 4 partials (lead/email/supplier/outreach results): wrap `<table>` in
  `<div style="overflow-x:auto">` so tables scroll on narrow screens

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 11:20:46 +01:00
Deeman
fea4f85da3 perf(transform): optimize dim_locations spatial joins via IEJoin + country filters
All checks were successful
CI / test (push) Successful in 51s
CI / tag (push) Successful in 2s
Replace ABS() bbox predicates with BETWEEN in all three spatial CTEs
(nearest_padel, padel_local, tennis_nearby). BETWEEN enables DuckDB's
IEJoin (interval join) which is O((N+M) log M) vs the previous O(N×M)
nested-loop cross-join.

Add country pre-filters to restrict the left side from ~140K global
locations to ~20K rows for padel/tennis CTEs (~8 countries each).

Expected: ~50-200x speedup on the spatial CTE portion of the model.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 02:57:05 +01:00
Deeman
2590020014 update sops
All checks were successful
CI / test (push) Successful in 51s
CI / tag (push) Successful in 3s
2026-03-01 01:27:01 +01:00
Deeman
a72f7721bb fix(supervisor): add geonames to workflows.toml
All checks were successful
CI / test (push) Successful in 53s
CI / tag (push) Successful in 3s
stg_population_geonames → dim_locations → location_opportunity_profile
were all 0 rows in prod because the GeoNames extractor was never
scheduled. First run will backfill cities1000 to landing zone.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 01:25:38 +01:00
Deeman
849dc8359c feat(affiliate): affiliate programs management + frontmatter bugfix
Centralises retailer config in affiliate_programs table (URL template,
tracking tag, commission %). Products now use program dropdown + product
identifier instead of manual URL baking. URL assembled at redirect time
via build_affiliate_url() — changing a tag propagates to all products
instantly. Backward compatible: legacy baked-URL products fall through
unchanged. Amazon OneLink (configured in Associates dashboard) handles
geo-redirect to local marketplaces with no additional programs needed.

Also fixes _rebuild_article() frontmatter rendering bug.

Commits: fix frontmatter, migration 0027, program CRUD functions,
redirect update, admin CRUD + templates, product form update, tests.
41 tests, all passing. Ruff clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 22:54:28 +01:00
Deeman
ec839478c3 feat(affiliate): add program + URL assembly tests; update CHANGELOG + PROJECT.md
41 tests total (+15). New coverage: get_all_programs(), get_program(),
get_program_by_slug(), build_affiliate_url() (program path, legacy fallback,
no program_id, no program dict), program-based redirect, legacy redirect,
migration seed assertion, ASIN backfill assertion. All ruff checks pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 22:51:26 +01:00
Deeman
47acf4d3df feat(affiliate): product form uses program dropdown + product identifier
Replaces the manual affiliate URL field with a program selector and
product identifier input. JS toggles visibility between program mode and
manual (custom URL) mode. retailer field is auto-populated from the
program name on save. INSERT/UPDATE statements include new program_id
and product_identifier columns. Validation accepts program+ID or manual
URL as the URL source.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 22:43:10 +01:00
Deeman
53117094ee feat(affiliate): admin CRUD for affiliate programs
Adds program list, create, edit, delete routes with appropriate guards
(delete blocked if products reference the program). Adds "Programs" tab
to the affiliate subnav. New templates: affiliate_programs.html,
affiliate_program_form.html, partials/affiliate_program_results.html.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 22:32:45 +01:00
Deeman
6076a0b30f feat(affiliate): use build_affiliate_url() in /go/<slug> redirect
Program-based products now get URLs assembled from the template at
redirect time. Changing a program's tracking_tag propagates instantly
to all its products without rebuilding. Legacy products (no program_id)
still use their baked affiliate_url via fallback.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 22:27:57 +01:00
Deeman
8dbbd0df05 feat(affiliate): add program CRUD functions + build_affiliate_url()
Adds get_all_programs(), get_program(), get_program_by_slug() for admin
CRUD. Adds build_affiliate_url() that assembles URLs from program template
+ product identifier, with fallback to baked affiliate_url for legacy
products. Updates get_product() to JOIN affiliate_programs so _program
dict is available at redirect time. _parse_product() extracts program
fields into nested _program key.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 22:23:53 +01:00
Deeman
b1eeb0a0ac feat(affiliate): add affiliate_programs table + migration 0027
Creates affiliate_programs for centralised retailer config (URL template,
tracking tag, commission %). Adds nullable program_id + product_identifier
to affiliate_products for backward compat. Seeds "Amazon" program with
oneLink template. Backfills existing products by extracting ASINs from
baked affiliate_url values.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 22:23:00 +01:00
Deeman
6aae92fc58 fix(admin): strip YAML frontmatter before mistune in _rebuild_article()
Fixes a bug where manual article previews rendered raw frontmatter
(title:, slug:, etc.) as visible text. Now strips the --- block using
the existing _FRONTMATTER_RE before passing the body to mistune.html().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 22:17:44 +01:00
Deeman
86be044116 fix(supervisor): stop infinite deploy loop in web_code_changed()
All checks were successful
CI / test (push) Successful in 51s
CI / tag (push) Successful in 3s
HEAD~1..HEAD always shows the same diff after os.execv reloads the
process — every tick triggers deploy.sh if the last commit touched web/.

Fix: track the last-seen HEAD in a module-level variable. On first call
(fresh process after os.execv), fall back to HEAD~1 so the newly-deployed
commit is evaluated once. Recording HEAD before returning means the same
commit never fires twice, regardless of how many ticks pass.

Also remove two unused imports (json, urllib.request) caught by ruff.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 22:17:41 +01:00
Deeman
5de0676f44 merge: editorial review pass — all 12 articles + 3 pSEO templates 2026-02-28 21:55:32 +01:00
Deeman
81ec8733c7 fix(admin): DE/EN chips in article list link to live article, not edit
Live chips now open the article in a new tab. Draft/scheduled chips are
non-clickable spans (informational only). The Edit button is the sole
path to the edit page, removing the redundant double-link.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 21:52:23 +01:00
Deeman
8a921ee18a merge: fix article list DE/EN chip links 2026-02-28 21:52:23 +01:00
Deeman
07d8ea1c0e editorial: review + improve country-overview.md.jinja pSEO template
EN: replace cliché phrase "pointing to pockets of underserved demand"
→ "leaving genuine supply gaps even in established markets" (more precise)

DE version already had a cleaner equivalent — no change needed there.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 21:52:19 +01:00
Deeman
370fc1f70b editorial: review + improve city-cost-de.md.jinja pSEO template
EN prose: tighten intro paragraph — "The question investors actually need
answered is:" → "The question that matters:" (DE version already had the
cleaner formulation; now aligned)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 21:51:17 +01:00
Deeman
f9faa02683 editorial: review + improve padel-hall-location-guide-en (C5)
- "Anyone evaluating" → "Any investor evaluating" in scoring matrix intro
  (audience precision; article otherwise in excellent shape — highest-quality
  article in the set, minimal intervention required)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 21:48:16 +01:00
Deeman
109da23902 editorial: propagate EN changes to padel-business-plan-bank-de (C3)
- Align contingency figure: 10% → 10–20% range (consistent with C7/C8)
- Add native German bridge before KfW section
- Add "Das spüren Banken." to close personal guarantees section

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 21:42:41 +01:00
Deeman
5f48449d25 editorial: review + improve padel-business-plan-bank-requirements-en (C3)
- Fix gendered pronoun: "he'll" → "they'll"
- Align contingency figure: 10% → 10–20% (consistent with C7/C8 guidance)
- "despite the fact that" → "even though"
- Add bridge sentence before KfW section connecting to section 9 of plan framework
- Sharpen personal guarantees closer: "That comes across in a bank conversation"
  → "Banks can tell."

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 21:40:02 +01:00
Deeman
e9d1b74618 editorial: propagate EN changes to padel-halle-finanzierung-de (C6)
- Add native German bridge sentence before Bürgschaften section,
  matching the EN improvement: abrupt transition now contextualised

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 21:36:13 +01:00
Deeman
5f756a2ba5 editorial: review + improve padel-hall-financing-germany-en (C6)
- Add bridge sentence before Personal Guarantee section — this key topic
  was abrupt without introduction; now connects cleanly from the debt
  structure discussion above

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 21:34:06 +01:00
Deeman
73547ec876 editorial: propagate C7 improvements to padel-halle-risiken-de
- Tightened competitive risk advice opener (Rechnen Sie das durch.)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 21:31:15 +01:00
Deeman
129ca26143 editorial: review + improve padel-hall-investment-risks-en (C7)
- Fixed Even so: colon to em dash (punctuation)
- Tightened Risk 5 advice opener (Model this explicitly.)
- Removed double pronoun in F&B note (before committing)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 21:29:55 +01:00
Deeman
9ea4ff55fa editorial: propagate C8 improvements to padel-halle-bauen-de
- Lender reference: made active sentence
- Fixed grammar: Ihr persoenlicher Track Record (nominative)
- Added closing thought before Was-erfolgreiche-Bauprojekte section

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 21:27:11 +01:00
Deeman
8a91fc752b editorial: review + improve padel-hall-build-guide-en (C8)
- Tightened Phase 1 intro (removed embedded clause, sharper)
- Nail the concept: simplified phrase
- Lender requirements: passive link sentence made active
- Added two-sentence conclusion to final section (solved problem framing)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 21:25:19 +01:00
Deeman
4783067c6e editorial: propagate C2 improvements to padel-halle-kosten-de
- Tightened opening sentence (native German equivalent)
- Added Munich/Leipzig rent gap qualifier (vergleichbare Marktsegmente)
- Added bridging transition before Hallenmiete section
- Improved court hire rates opener (Ertragspotenzial folgt Standortlogik)
- Extended OPEX rent note: adjust for Munich/Berlin
- Sharpened lease signal sentence (planbarer Cashflow im Kreditbescheid)
- Expanded lender section intro with insider framing
- Tightened Fazit opening (Richtig aufgesetzt...)
- Updated CTA (Die Zahlen in diesem Artikel sind Ihr Ausgangspunkt)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 21:22:55 +01:00
Deeman
ecd1cdd27a editorial: review + improve padel-hall-cost-guide-en (C2)
- Tightened opening sentence and intro paragraph
- Added Munich/Leipzig rent gap qualifier (across comparable market tiers)
- Added bridging transition before Commercial Rent section
- Improved Court Hire Rates section opener for better flow
- Added OPEX note: rent line is mid-tier city calibrated; adjust for Munich/Berlin
- Expanded lender section intro with insider framing
- Sharpened lease signal sentence (converts uncertain future revenue...)
- Fixed cashflow to cash flow
- Strengthened Bottom Line and CTA

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 21:15:20 +01:00
41 changed files with 1262 additions and 187 deletions

View File

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

View File

@@ -6,6 +6,21 @@ 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
- **Data Platform admin view showing stale/zero row counts** — Docker web containers were mounting `/opt/padelnomics/data` (stale copy) instead of `/data/padelnomics` (live supervisor output). Fixed volume mount in all 6 containers (blue/green × app/worker/scheduler) and added `LANDING_DIR=/app/data/pipeline/landing` so extraction stats and landing zone file stats are visible to the web app.
- **`workflows.toml` never found in dev** — `_REPO_ROOT` in `pipeline_routes.py` used `parents[5]` (one level too far up) instead of `parents[4]`. Workflow schedules now display correctly on the pipeline overview tab in dev.
- **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

View File

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

View File

@@ -91,6 +91,8 @@ Die Bilanz am ersten Betriebstag: Aktiva (Anlagevermögen nach CAPEX, Anfangsliq
## KfW-Förderprogramme für Padelhallen ## KfW-Förderprogramme für Padelhallen
Abschnitt 9 des Gliederungsrahmens verlangt: Welche Förderprogramme wurden geprüft? Hier ist die Antwort, die Ihr Businessplan liefern muss.
Die KfW bietet mehrere Programme, die für Padelhallen-Projekte relevant sein können. Wichtig: KfW-Kredite werden nicht direkt bei der KfW beantragt, sondern über die Hausbank. Die Hausbank leitet den Antrag weiter und trägt einen Teil des Ausfallrisikos mit — was erklärt, warum sie ein starkes Eigeninteresse an der Qualität des Businessplans hat. Die KfW bietet mehrere Programme, die für Padelhallen-Projekte relevant sein können. Wichtig: KfW-Kredite werden nicht direkt bei der KfW beantragt, sondern über die Hausbank. Die Hausbank leitet den Antrag weiter und trägt einen Teil des Ausfallrisikos mit — was erklärt, warum sie ein starkes Eigeninteresse an der Qualität des Businessplans hat.
**KfW Unternehmerkredit (037/047)** **KfW Unternehmerkredit (037/047)**
@@ -129,7 +131,7 @@ Was passiert, wenn die Auslastung 10 Prozentpunkte unter Plan liegt? Wenn die Ba
### 4. Unvollständiger CAPEX ### 4. Unvollständiger CAPEX
Häufig unterschätzt: Nebenkosten des Baus (Architektenhonorar, Baunebenkosten, Baugenehmigungsgebühren), Working Capital für die Anlaufphase (36 Monate Betriebskosten als Puffer), Kosten der Betriebsaufnahme (Marketing, Erstausstattung, Versicherungen vor Eröffnung), Unvorhergesehenes (Bankstandard: 10 Prozent Contingency auf den Rohbau). Wer diese Positionen vergisst, finanziert sich zu knapp — und die Bank bemerkt es. Häufig unterschätzt: Nebenkosten des Baus (Architektenhonorar, Baunebenkosten, Baugenehmigungsgebühren), Working Capital für die Anlaufphase (36 Monate Betriebskosten als Puffer), Kosten der Betriebsaufnahme (Marketing, Erstausstattung, Versicherungen vor Eröffnung), Unvorhergesehenes (Mindestpuffer: 10 Prozent auf den Rohbau — bei Sportstättenumbauten realistisch eher 1520 Prozent). Wer diese Positionen vergisst, finanziert sich zu knapp — und die Bank bemerkt es.
### 5. KfW nicht adressiert ### 5. KfW nicht adressiert
@@ -148,7 +150,7 @@ Fragen, die Sie sich vor der Bürgschaftsübernahme stellen sollten:
- Gibt es Vermögenswerte, die ich herauslösen kann (z.B. durch Schenkung an Ehepartner vor Gründung — hier unbedingt Rechtsberatung einholen, da Anfechtungsrisiken bestehen)? - Gibt es Vermögenswerte, die ich herauslösen kann (z.B. durch Schenkung an Ehepartner vor Gründung — hier unbedingt Rechtsberatung einholen, da Anfechtungsrisiken bestehen)?
- Wie viele Monate Verlustbetrieb kann ich aus eigenen Mitteln abfedern? - Wie viele Monate Verlustbetrieb kann ich aus eigenen Mitteln abfedern?
Wer diese Fragen beantwortet hat, hat das Projekt ernst genommen. Wer diese Fragen beantwortet hat, hat das Projekt ernst genommen. Das spüren Banken.
--- ---

View File

@@ -23,7 +23,7 @@ The formula:
DSCR = operating cash flow ÷ annual debt service (interest + principal) DSCR = operating cash flow ÷ annual debt service (interest + principal)
``` ```
The standard in German SME lending: **1.2 to 1.5x**. For every €1 of debt service, the project needs to generate €1.201.50 of cash flow. Below 1.2x, you'll either face rejection or be asked to inject more equity. A plan that doesn't make the DSCR calculation transparent forces the loan officer to do the math himself — and he'll be more conservative than you. The standard in German SME lending: **1.2 to 1.5x**. For every €1 of debt service, the project needs to generate €1.201.50 of cash flow. Below 1.2x, you'll either face rejection or be asked to inject more equity. A plan that doesn't make the DSCR calculation transparent forces the loan officer to do the math himself — and they'll be more conservative than you.
The other hard constraint is **equity contribution** (*Eigenkapitalquote*): banks typically expect the founder to put in 2030% of total investment. KfW subsidy programs can partly substitute for equity (more on that below), but they never replace it entirely. Coming to the table with 10% equity rarely works. The other hard constraint is **equity contribution** (*Eigenkapitalquote*): banks typically expect the founder to put in 2030% of total investment. KfW subsidy programs can partly substitute for equity (more on that below), but they never replace it entirely. Coming to the table with 10% equity rarely works.
@@ -89,6 +89,8 @@ The balance sheet on Day 1: assets (fixed assets after CAPEX, opening cash) vers
## KfW Subsidy Programs for Padel Hall Projects ## KfW Subsidy Programs for Padel Hall Projects
Section 9 of the business plan framework above asks which financing programs have been evaluated. Here's the answer your plan needs to provide.
KfW (Germany's state development bank) offers several programs relevant to padel hall construction and launch. One crucial operational detail: KfW loans are not applied for directly at KfW. They're applied for through your *Hausbank* (house bank), which passes the application to KfW and shares a portion of the default risk. This is precisely why your Hausbank cares so much about the quality of your business plan — they're on the hook too. KfW (Germany's state development bank) offers several programs relevant to padel hall construction and launch. One crucial operational detail: KfW loans are not applied for directly at KfW. They're applied for through your *Hausbank* (house bank), which passes the application to KfW and shares a portion of the default risk. This is precisely why your Hausbank cares so much about the quality of your business plan — they're on the hook too.
**KfW Unternehmerkredit (programs 037/047)** **KfW Unternehmerkredit (programs 037/047)**
@@ -109,7 +111,7 @@ Each German state (*Bundesland*) runs its own SME and startup lending programs t
- Hamburg: IFB Hamburg - Hamburg: IFB Hamburg
- Saxony: Sächsische Aufbaubank (SAB) - Saxony: Sächsische Aufbaubank (SAB)
These programs are overlooked in the majority of business plans we've reviewed — despite the fact that combining them with KfW can meaningfully reduce the equity burden. These programs are overlooked in the majority of business plans we've reviewed — even though combining them with KfW can meaningfully reduce the equity burden.
--- ---
@@ -129,7 +131,7 @@ What happens if utilization comes in 10 percentage points below plan? If constru
### 4. Incomplete CAPEX ### 4. Incomplete CAPEX
Frequently underestimated items: architect and engineering fees, permitting fees and costs of the *Baugenehmigung* (building permit), working capital for the ramp-up period (36 months of operating costs), pre-opening expenses (marketing, initial inventory, pre-opening insurance), and contingency (the industry standard is 10% of raw construction costs). Forget these, and you're underfunded from Day 1. Frequently underestimated items: architect and engineering fees, permitting fees and costs of the *Baugenehmigung* (building permit), working capital for the ramp-up period (36 months of operating costs), pre-opening expenses (marketing, initial inventory, pre-opening insurance), and contingency (minimum 10% of raw construction costs — 1520% is more realistic for sports hall conversions). Forget these, and you're underfunded from Day 1.
### 5. No mention of KfW or subsidy programs ### 5. No mention of KfW or subsidy programs
@@ -148,7 +150,7 @@ Questions worth answering before you proceed:
- Are there assets that could be structured outside the exposure (specialist legal advice is essential here, as pre-signing asset transfers can be challenged under German insolvency law)? - Are there assets that could be structured outside the exposure (specialist legal advice is essential here, as pre-signing asset transfers can be challenged under German insolvency law)?
- How many months of operating losses could I absorb from personal resources? - How many months of operating losses could I absorb from personal resources?
A founder who has worked through these questions has taken the project seriously. That comes across in a bank conversation. A founder who has worked through these questions has taken the project seriously. Banks can tell.
--- ---

View File

@@ -31,7 +31,7 @@ Steps 15 Steps 611 Steps 1216 Steps 1720 Step
## Phase 1: Feasibility and Concept (Months 13) ## Phase 1: Feasibility and Concept (Months 13)
This is the most important phase and the one where projects most often go wrong in one of two directions: either stopping too early because the first obstacle looks daunting, or moving too fast because enthusiasm outpaces analysis. Rigorous work here prevents expensive corrections later. This is the most important phase and where projects most often go wrong in one of two directions: stopping too early because the first obstacle looks daunting, or moving too fast because enthusiasm outpaces analysis. Rigorous work here prevents expensive corrections later.
### Step 1: Market Research ### Step 1: Market Research
@@ -49,7 +49,7 @@ Good market research won't guarantee success, but it will protect you from the m
Your market research should drive your concept. How many courts? Which customer segments — competitive recreational players, club training, corporate wellness, broad community use? What service level — a pure booking facility or a full-concept venue with lounge, bar, pro shop, and coaching program? Your market research should drive your concept. How many courts? Which customer segments — competitive recreational players, club training, corporate wellness, broad community use? What service level — a pure booking facility or a full-concept venue with lounge, bar, pro shop, and coaching program?
Every decision here cascades into investment requirements, operating costs, and revenue potential. Nail this down before moving to site selection. Every decision here cascades into investment requirements, operating costs, and revenue potential. Nail the concept before moving to site selection.
### Step 3: Location Scouting ### Step 3: Location Scouting
@@ -125,7 +125,7 @@ Approach lenders with your full business plan. Typical capital structure for pad
- 5070% debt (bank loan) - 5070% debt (bank loan)
- 3050% equity (own funds, silent partners, shareholder loans) - 3050% equity (own funds, silent partners, shareholder loans)
What lenders will require: a credible financial model, collateral, your track record, and — almost universally for single-asset leisure facilities — personal guarantees from principal shareholders. See the companion article on investment risks for a full treatment of personal guarantee exposure. What lenders will require: a credible financial model, collateral, your track record, and — almost universally for single-asset leisure facilities — personal guarantees from principal shareholders. The companion article on investment risks covers personal guarantee exposure in full.
Investigate public funding programs: development bank loans, regional sports infrastructure grants, and municipal co-investment schemes can reduce either equity requirements or interest burden. This research is worth several hours of your time. Investigate public funding programs: development bank loans, regional sports infrastructure grants, and municipal co-investment schemes can reduce either equity requirements or interest burden. This research is worth several hours of your time.
@@ -256,6 +256,8 @@ Patterns emerge when you observe padel hall projects across a market over time.
**Projects that succeed long-term** treat all three phases — planning, build, and opening — with equal rigor, and invest early and consistently in community and repeat customers. **Projects that succeed long-term** treat all three phases — planning, build, and opening — with equal rigor, and invest early and consistently in community and repeat customers.
Building a padel hall is complex, but it is a solved problem. The failures are nearly always the same failures. So are the successes.
--- ---
## Find Builders and Suppliers Through Padelnomics ## Find Builders and Suppliers Through Padelnomics

View File

@@ -9,11 +9,11 @@ cornerstone: C2
# How Much Does It Cost to Open a Padel Hall in Germany? Complete 2026 CAPEX Breakdown # How Much Does It Cost to Open a Padel Hall in Germany? Complete 2026 CAPEX Breakdown
Anyone who has started researching padel hall investment in Germany has encountered the same frustrating non-answer: "it depends." And it genuinely does — total project costs for a six-court indoor facility range from **€930,000 to €1.9 million**, a span wide enough to make planning feel impossible. Anyone researching padel hall investment in Germany hits the same frustrating non-answer: "it depends." And it genuinely does — total project costs for a six-court indoor facility range from **€930,000 to €1.9 million**, a span wide enough to make planning feel impossible.
But that range is not noise. It reflects specific, quantifiable decisions: whether you're fitting out an existing warehouse or building from scratch, whether you're in Munich or Leipzig, whether you want panorama glass courts or standard construction. Once you understand where the variance lives, the numbers become plannable. But that range is not noise. It reflects specific, quantifiable decisions: whether you're fitting out an existing warehouse or building from scratch, whether you're in Munich or Leipzig, whether you want panorama glass courts or standard construction. Once you understand where the variance lives, the numbers become plannable.
This article gives you the complete picture: itemized CAPEX, city-by-city rent and booking rates, a full operating cost breakdown, a three-year P&L projection, and the key metrics your bank will want to see. All figures are based on real German market data from 20252026. By the end, you should be able to build a credible first-pass financial model for your specific scenario — and walk into a lender conversation with confidence. This article gives you the complete picture: itemized CAPEX, city-by-city rent and booking rates, a full operating cost breakdown, a three-year P&L projection, and the key metrics your bank will want to see. All figures are based on real German market data from 20252026. By the end, you'll have everything you need to build a credible first-pass financial model for your specific scenario — and walk into a lender conversation with confidence.
--- ---
@@ -21,7 +21,7 @@ This article gives you the complete picture: itemized CAPEX, city-by-city rent a
The single largest driver of CAPEX variance is construction. Converting a suitable existing warehouse — one that already has the necessary ceiling height (89 m clear) and adequate structural load — costs vastly less than a ground-up build or a complete gut-renovation. This line item alone accounts for €400,000 to €800,000 of the total budget. The single largest driver of CAPEX variance is construction. Converting a suitable existing warehouse — one that already has the necessary ceiling height (89 m clear) and adequate structural load — costs vastly less than a ground-up build or a complete gut-renovation. This line item alone accounts for €400,000 to €800,000 of the total budget.
Location adds another layer of variance. The same 2,000 sqm hall costs 4060% more to rent in Munich than in Leipzig. That gap shows up not just in annual OPEX but in the lease deposit and the working capital reserve you need to fund the ramp-up — both of which are part of your initial CAPEX. Location adds another layer of variance. The same 2,000 sqm hall costs 4060% more to rent in Munich than in Leipzig across comparable market tiers — at the extremes, the gap is considerably wider. That difference runs through every budget line: not just annual rent, but the lease deposit and working capital reserve needed at launch, both part of your initial CAPEX.
For a **six-court indoor facility** with solid but not extravagant fit-out, the realistic planning figure is **€1.21.5 million all-in**. Projects that come in below that typically either benefited from an exceptional real estate deal or — more often — undercounted one of the three most expensive items: construction, HVAC, and the operating reserve. For a **six-court indoor facility** with solid but not extravagant fit-out, the realistic planning figure is **€1.21.5 million all-in**. Projects that come in below that typically either benefited from an exceptional real estate deal or — more often — undercounted one of the three most expensive items: construction, HVAC, and the operating reserve.
@@ -56,6 +56,8 @@ For a **six-court indoor facility** with solid but not extravagant fit-out, the
## Commercial Rent by German City ## Commercial Rent by German City
Construction and courts consume most of your initial budget. What determines long-term viability is what you pay every month: rent.
A six-court facility with changing rooms, a reception area, and a lounge requires **1,5002,500 sqm** of floor space. Current industrial/warehouse lease rates across major German cities: A six-court facility with changing rooms, a reception area, and a lounge requires **1,5002,500 sqm** of floor space. Current industrial/warehouse lease rates across major German cities:
| City | Rent €/sqm/month | Typical monthly cost (2,000 sqm) | | City | Rent €/sqm/month | Typical monthly cost (2,000 sqm) |
@@ -77,7 +79,7 @@ One structural note: German commercial landlords typically require lease terms o
## Court Hire Rates: What the Market Will Bear ## Court Hire Rates: What the Market Will Bear
Booking rates vary significantly by city and time slot. The following figures are drawn from platform data and direct market surveys: Revenue potential tracks location almost as closely as rent does. The following booking rates are drawn from platform data and direct market surveys:
| City | Off-Peak (€/hr) | Peak (€/hr) | Confidence | | City | Off-Peak (€/hr) | Peak (€/hr) | Confidence |
|---|---|---|---| |---|---|---|---|
@@ -113,6 +115,8 @@ Operating cost projections are where business plans most often diverge from real
| Admin, accounting, legal | €20,000 | €22,000 | €24,000 | | Admin, accounting, legal | €20,000 | €22,000 | €24,000 |
| **Total OPEX** | **€490,000** | **€530,000** | **€566,000** | | **Total OPEX** | **€490,000** | **€530,000** | **€566,000** |
Note: the rent line reflects a well-positioned facility in a mid-tier city. For Munich or Berlin, adjust upward using the city rent table above — and recalibrate your revenue assumptions accordingly.
**Staffing** is the line that most first-time operators get wrong. Five FTEs is a genuine minimum for professional operations — reception, court management, a coach, administration. In Germany, employer social security contributions add roughly 20% on top of gross wages. €200k in Year 1 for a five-person team is lean, not generous. **Staffing** is the line that most first-time operators get wrong. Five FTEs is a genuine minimum for professional operations — reception, court management, a coach, administration. In Germany, employer social security contributions add roughly 20% on top of gross wages. €200k in Year 1 for a five-person team is lean, not generous.
**Energy** depends heavily on the building envelope. An older warehouse with poor insulation and an oversized, inefficient HVAC installation can run 3050% higher than the figures shown here. Commissioning a quick energy audit before signing the lease is cheap insurance. **Energy** depends heavily on the building envelope. An older warehouse with poor insulation and an oversized, inefficient HVAC installation can run 3050% higher than the figures shown here. Commissioning a quick energy audit before signing the lease is cheap insurance.
@@ -167,11 +171,11 @@ On an €800k loan at 5% over 10 years, annual debt service is approximately €
## What Lenders Actually Look For ## What Lenders Actually Look For
A padel hall is an unusual asset class for most bank credit officers. What moves a credit committee is not enthusiasm for the sport — it is the rigor of the financial documentation. A padel hall is an unfamiliar asset class for most bank credit officers. They have no mental model for court utilization rates or booking yield — and that is actually an opportunity. What moves a credit committee is not enthusiasm for the sport. It is the rigor of the financial documentation. Arrive with clean numbers and you stand out from the start.
**DSCR of 1.21.5x minimum.** Lenders want operating cash flow to cover debt service with a 2050% buffer. The base case in this model clears that bar easily; your job is to show it holds under stress scenarios too. **DSCR of 1.21.5x minimum.** Lenders want operating cash flow to cover debt service with a 2050% buffer. The base case in this model clears that bar easily; your job is to show it holds under stress scenarios too.
**Signed lease agreement.** Without a lease in place, the credit assessment stays hypothetical. A long-term lease with indexed escalation is a positive signal to lenders — it translates future revenue into something closer to contracted income. **Signed lease agreement.** Without a lease in place, the credit assessment stays hypothetical. A long-term lease with indexed escalation is a positive signal — it converts uncertain future revenue into something closer to contracted income on the credit committee's worksheet.
**Monthly cash flow model for Year 1.** Lenders do not expect monthly forecasts to be accurate. They use them to assess whether you have thought through the ramp-up — the timing of fit-out completion, the month of first bookings, the staffing build-out. A monthly model signals operational seriousness. **Monthly cash flow model for Year 1.** Lenders do not expect monthly forecasts to be accurate. They use them to assess whether you have thought through the ramp-up — the timing of fit-out completion, the month of first bookings, the staffing build-out. A monthly model signals operational seriousness.
@@ -183,8 +187,8 @@ A dedicated article on structuring a padel hall business plan and navigating Ger
## Bottom Line ## Bottom Line
Opening a padel hall in Germany in 2026 is a real capital commitment: €930k on the low end, €1.9M at the top, with €1.21.5M as the honest planning figure for a solid six-court operation. The economics, modelled carefully, are genuinely attractive — payback in 35 years, 60%+ cash-on-cash return at maturity, and a market that continues to grow. Opening a padel hall in Germany in 2026 is a real capital commitment: €930k on the low end, €1.9M at the top, with €1.21.5M as the honest planning figure for a solid six-court operation. The economics, done right, are genuinely attractive — payback in 35 years, 60%+ cash-on-cash return at maturity, and a market that continues to grow.
The investors who succeed in this space are not the ones who found a cheaper build. They are the ones who understood the numbers precisely enough to make the right location and concept decisions early — and to structure their financing before the costs escalated. The investors who succeed here are not the ones who found a cheaper build. They are the ones who understood the numbers precisely enough to make the right location and concept decisions early — and to structure their financing before the costs escalated.
**Next step:** Use the Padelnomics Financial Planner to model your specific scenario — your city, your financing mix, your pricing assumptions. The model above is the starting point. Your hall deserves a projection built around your actual numbers. **Next step:** Use the Padelnomics Financial Planner to model your specific scenario — your city, your financing mix, your pricing assumptions. The figures in this article are your starting point; your hall deserves a projection built around your actual numbers.

View File

@@ -121,6 +121,8 @@ Every state has a development bank: Investitionsbank Schleswig-Holstein, Thürin
## Personal Guarantee Reality: Don't Avoid This Conversation ## Personal Guarantee Reality: Don't Avoid This Conversation
Once the debt structure is in place, there is one more item that belongs in every financing conversation — and that is too often skipped until the term sheet arrives.
German banks financing a padel hall through a standalone project company will almost always require **persönliche Bürgschaft** (personal guarantee) from the founders. This means your personal assets — home, savings, existing investments — are at risk if the business fails. German banks financing a padel hall through a standalone project company will almost always require **persönliche Bürgschaft** (personal guarantee) from the founders. This means your personal assets — home, savings, existing investments — are at risk if the business fails.
Three ways to limit this exposure: Three ways to limit this exposure:

View File

@@ -50,7 +50,7 @@ Squash followed a strikingly similar pattern in the 1980s: grassroots boom, infr
The counterargument has real merit: padel requires permanent, fixed courts. That infrastructure creates genuine stickiness that squash never had — players build habits, drive to a venue, become regulars. Padel is also demonstrably more accessible and social than squash, which supports long-term participation. German player numbers show no plateau effect yet. The counterargument has real merit: padel requires permanent, fixed courts. That infrastructure creates genuine stickiness that squash never had — players build habits, drive to a venue, become regulars. Padel is also demonstrably more accessible and social than squash, which supports long-term participation. German player numbers show no plateau effect yet.
Even so: if utilization falls from 65% to 35% in year five because hype fades, your model breaks. That scenario is largely unhedgeable — but it can be modeled. What does your P&L look like at 40% utilization sustained for two years? Can your financing structure survive it? If you haven't answered that question, you're not done with your business plan. Even so if utilization falls from 65% to 35% in year five because hype fades, your model breaks. That scenario is largely unhedgeable — but it can be modeled. What does your P&L look like at 40% utilization sustained for two years? Can your financing structure survive it? If you haven't answered that question, you're not done with your business plan.
--- ---
@@ -91,7 +91,7 @@ When a new competitor opens ten minutes away in year three, you feel it in utili
Padel has no real moat. No patents, no network effects, no meaningful switching costs. What you have is location, the community you've built, and service quality — genuine advantages, but ones that require continuous investment to maintain. Padel has no real moat. No patents, no network effects, no meaningful switching costs. What you have is location, the community you've built, and service quality — genuine advantages, but ones that require continuous investment to maintain.
**The right move is to model this explicitly.** What does your P&L look like when a competitor opens in year three and takes 20% of your demand? What operational responses are available — pricing, loyalty programs, corporate contracts, additional programming? Having thought through the competitive response in advance means you won't be improvising when it happens. **Model this explicitly.** What does your P&L look like when a competitor opens in year three and takes 20% of your demand? What operational responses are available — pricing, loyalty programs, corporate contracts, additional programming? Thinking through the competitive response in advance means you won't be improvising when it happens.
--- ---
@@ -111,7 +111,7 @@ Good facility managers, coaches who combine technical skill with genuine hospita
Courts need replacing. Artificial turf has a lifespan of five to eight years. Glass panels and framework require regular inspection and periodic replacement. If this isn't in your long-term financial model, you're looking at a significant unplanned capital call in year six or seven. Budget a per-court annual refurbishment reserve — and set it conservatively above zero. Courts need replacing. Artificial turf has a lifespan of five to eight years. Glass panels and framework require regular inspection and periodic replacement. If this isn't in your long-term financial model, you're looking at a significant unplanned capital call in year six or seven. Budget a per-court annual refurbishment reserve — and set it conservatively above zero.
**A note on F&B:** Running a café or bar inside your facility is an entirely different business — different skills, thin margins, and separate regulatory requirements. If food and beverage is part of your concept, outsourcing to a dedicated operator deserves serious consideration before you commit to running it in-house. **A note on F&B:** Running a café or bar inside your facility is an entirely different business — different skills, thin margins, and separate regulatory requirements. If food and beverage is part of your concept, outsourcing to a dedicated operator deserves serious consideration before committing to running it in-house.
--- ---

View File

@@ -111,7 +111,7 @@ Key checks before committing to a site:
## The Site Scoring Framework: From 8 Criteria to a Decision ## The Site Scoring Framework: From 8 Criteria to a Decision
Anyone evaluating multiple sites in parallel needs a comparison tool. A weighted scoring matrix works well: each criterion is rated 15 and multiplied by a weighting factor. Any investor evaluating multiple sites in parallel needs a comparison tool. A weighted scoring matrix works well: each criterion is rated 15 and multiplied by a weighting factor.
A suggested weighting: A suggested weighting:

View File

@@ -122,7 +122,7 @@ Mit dem detaillierten Businessplan gehen Sie zu Banken und ggf. Fördermittelgeb
- 5070 Prozent Fremdkapital (Bankdarlehen) - 5070 Prozent Fremdkapital (Bankdarlehen)
- 3050 Prozent Eigenkapital (eigene Mittel, stille Beteiligungen, Gesellschafterdarlehen) - 3050 Prozent Eigenkapital (eigene Mittel, stille Beteiligungen, Gesellschafterdarlehen)
Was Banken sehen wollen: belastbares Finanzmodell, Sicherheiten, Ihr persönliches Track Record, und — fast immer — eine persönliche Bürgschaft. (Mehr dazu im separaten Artikel zu Investitionsrisiken.) Was Banken sehen wollen: belastbares Finanzmodell, Sicherheiten, Ihr persönlicher Track Record, und — fast immer — eine persönliche Bürgschaft. Der separate Artikel zu Investitionsrisiken behandelt das Thema Bürgschaftsexposition ausführlich.
Klären Sie Förderprogramme: KfW-Mittel, Landesförderbanken und kommunale Sportförderprogramme können den Eigenkapitalbedarf oder die Zinsbelastung reduzieren. Diese Recherche lohnt sich. Klären Sie Förderprogramme: KfW-Mittel, Landesförderbanken und kommunale Sportförderprogramme können den Eigenkapitalbedarf oder die Zinsbelastung reduzieren. Diese Recherche lohnt sich.
@@ -251,6 +251,8 @@ Wer Dutzende Padelhallenprojekte in Europa beobachtet, sieht Muster auf beiden S
**Die Projekte, die langfristig erfolgreich sind**, haben alle drei Phasen — Planung, Bau, Eröffnung — mit derselben Sorgfalt behandelt und früh in Community und Stammkundschaft investiert. **Die Projekte, die langfristig erfolgreich sind**, haben alle drei Phasen — Planung, Bau, Eröffnung — mit derselben Sorgfalt behandelt und früh in Community und Stammkundschaft investiert.
Eine Padelhalle zu bauen ist komplex — aber kein ungelöstes Problem. Die Fehler, die Projekte scheitern lassen, sind fast immer dieselben. Genauso wie die Entscheidungen, die sie gelingen lassen.
--- ---
## Planer und Lieferanten finden ## Planer und Lieferanten finden

View File

@@ -159,6 +159,8 @@ Der Kapitaldienstdeckungsgrad (DSCR) auf den Bankkredit (€700k, 5 %, 10 Jahre
## Das persönliche Risiko: Bürgschaften offen ansprechen ## Das persönliche Risiko: Bürgschaften offen ansprechen
Steht die Fremdkapitalstruktur, bleibt eine Frage, die in fast jedem Finanzierungsgespräch zu spät gestellt wird — und die zu oft erst auf dem Konditionenblatt der Bank auftaucht.
Banken werden für eine Padelhalle, die eine eigenständige Projektgesellschaft ist, fast immer eine **persönliche Bürgschaft** des Gründers fordern. Das bedeutet: Ihre privaten Vermögenswerte — Eigenheim, Ersparnisse, Beteiligungen — haften im Zweifelsfall. Banken werden für eine Padelhalle, die eine eigenständige Projektgesellschaft ist, fast immer eine **persönliche Bürgschaft** des Gründers fordern. Das bedeutet: Ihre privaten Vermögenswerte — Eigenheim, Ersparnisse, Beteiligungen — haften im Zweifelsfall.
Es gibt drei Wege, dieses Risiko zu begrenzen: Es gibt drei Wege, dieses Risiko zu begrenzen:

View File

@@ -9,7 +9,7 @@ cornerstone: C2
# Padel Halle Kosten 2026: Die komplette CAPEX-Aufstellung # Padel Halle Kosten 2026: Die komplette CAPEX-Aufstellung
Wer ernsthaft über eine Padelhalle nachdenkt, bekommt auf die Frage nach den Kosten zunächst eine frustrierende Antwort: "Das kommt drauf an." Und ja — die Spanne ist tatsächlich enorm. Je nach Standort, Konzept und Bausubstanz liegen die Gesamtinvestitionskosten für eine sechsstellige Anlage zwischen **€930.000 und €1,9 Millionen**. Diese Streuung ist kein Zufall, sondern Ausdruck ganz konkreter Entscheidungen, die Sie als Investor treffen werden. Wer eine Padelhalle plant, bekommt auf die Kostenfrage zunächst eine frustrierende Antwort: Das kommt drauf an." Und ja — die Spanne ist tatsächlich enorm. Je nach Standort, Konzept und Bausubstanz liegen die Gesamtinvestitionskosten für eine sechsstellige Anlage zwischen **€930.000 und €1,9 Millionen**. Diese Streuung ist kein Zufall, sondern Ausdruck ganz konkreter Entscheidungen, die Sie als Investor treffen werden.
Dieser Artikel schlüsselt die vollständige Investition auf — von der Bausubstanz über Platztechnik und Ausstattung bis hin zu Betriebskosten, Standortmieten und einer belastbaren 3-Jahres-Ergebnisprognose. Alle Zahlen basieren auf realen deutschen Marktdaten aus 2025/2026. Das Ziel: Sie sollen nach der Lektüre in der Lage sein, eine erste realistische Wirtschaftlichkeitsrechnung für Ihre konkrete Situation aufzustellen — und wissen, welche Fragen Sie Ihrer Bank stellen müssen. Dieser Artikel schlüsselt die vollständige Investition auf — von der Bausubstanz über Platztechnik und Ausstattung bis hin zu Betriebskosten, Standortmieten und einer belastbaren 3-Jahres-Ergebnisprognose. Alle Zahlen basieren auf realen deutschen Marktdaten aus 2025/2026. Das Ziel: Sie sollen nach der Lektüre in der Lage sein, eine erste realistische Wirtschaftlichkeitsrechnung für Ihre konkrete Situation aufzustellen — und wissen, welche Fragen Sie Ihrer Bank stellen müssen.
@@ -19,7 +19,7 @@ Dieser Artikel schlüsselt die vollständige Investition auf — von der Bausubs
Warum liegen €930.000 und €1,9 Millionen so weit auseinander? Der größte Einzeltreiber ist der bauliche Aufwand. Wer eine bestehende Gewerbehalle — etwa einen ehemaligen Produktions- oder Logistikbau — kostengünstig anmieten und mit minimalem Umbau bespielen kann, landet am unteren Ende der Spanne. Wer dagegen auf grüner Wiese baut oder ein Gebäude von Grund auf saniert, zahlt entsprechend mehr. Warum liegen €930.000 und €1,9 Millionen so weit auseinander? Der größte Einzeltreiber ist der bauliche Aufwand. Wer eine bestehende Gewerbehalle — etwa einen ehemaligen Produktions- oder Logistikbau — kostengünstig anmieten und mit minimalem Umbau bespielen kann, landet am unteren Ende der Spanne. Wer dagegen auf grüner Wiese baut oder ein Gebäude von Grund auf saniert, zahlt entsprechend mehr.
Dazu kommt der Standortfaktor. In München oder Berlin kostet dasselbe Objekt in der Miete 4060 % mehr als in Leipzig oder Kassel. Das drückt sich nicht nur in der laufenden OPEX aus, sondern auch in der Kaution und dem nötigen Working-Capital-Puffer — beides Teil der initialen CAPEX. Dazu kommt der Standortfaktor. In München oder Berlin kostet dasselbe Objekt in vergleichbaren Marktsegmenten 4060 % mehr als in Leipzig oder Kassel — an den Extremen fällt der Abstand erheblich größer aus. Das schlägt sich nicht nur in der laufenden OPEX nieder, sondern auch in der Kaution und dem nötigen Working-Capital-Puffer — beides Teil der initialen CAPEX.
Realistischer Planungsansatz für eine **6-Court-Innenhalle** mit solider Ausstattung: **€1,21,5 Millionen Gesamtinvestition**. Wer mit deutlich weniger kalkuliert, unterschätzt in der Regel einen der drei teuersten Posten: Bau/Umbau, Lüftungstechnik oder den Kapitalpuffer für den Anlauf. Realistischer Planungsansatz für eine **6-Court-Innenhalle** mit solider Ausstattung: **€1,21,5 Millionen Gesamtinvestition**. Wer mit deutlich weniger kalkuliert, unterschätzt in der Regel einen der drei teuersten Posten: Bau/Umbau, Lüftungstechnik oder den Kapitalpuffer für den Anlauf.
@@ -56,6 +56,8 @@ Die folgende Tabelle zeigt die typischen Bandbreiten für eine sechsstellige Inn
## Hallenmiete in Deutschland: Was Sie nach Standort zahlen ## Hallenmiete in Deutschland: Was Sie nach Standort zahlen
Bau und Courts binden den größten Teil des Startkapitals. Was über die langfristige Wirtschaftlichkeit entscheidet, zahlen Sie monatlich: die Miete.
Eine 6-Court-Halle benötigt je nach Konzept (Nebenräume, Lounge, Pro Shop) eine Fläche von **1.500 bis 2.500 qm**. Auf Basis aktueller Gewerberaummieten für Industrie- und Hallenflächen in deutschen Städten ergibt sich folgende Einschätzung: Eine 6-Court-Halle benötigt je nach Konzept (Nebenräume, Lounge, Pro Shop) eine Fläche von **1.500 bis 2.500 qm**. Auf Basis aktueller Gewerberaummieten für Industrie- und Hallenflächen in deutschen Städten ergibt sich folgende Einschätzung:
| Stadt | Miete €/qm/Monat | Typische Monatsmiete (2.000 qm) | | Stadt | Miete €/qm/Monat | Typische Monatsmiete (2.000 qm) |
@@ -69,15 +71,15 @@ Eine 6-Court-Halle benötigt je nach Konzept (Nebenräume, Lounge, Pro Shop) ein
| Stuttgart | €710 | €14.000€20.000 | | Stuttgart | €710 | €14.000€20.000 |
| Leipzig | €47 | €8.000€14.000 | | Leipzig | €47 | €8.000€14.000 |
In Hochpreislagen Berlins (Mitte, Prenzlauer Berg) oder Münchens (Schwabing, Maxvorstadt) liegen die Preise auch für Gewerbehallen teils noch darüber. Die in der OPEX-Tabelle verwendete Jahresmiete von €120.000 entspricht einer Monatsmiete von €10.000 — das ist ein realistischer Wert für eine mittelgroße deutsche Stadt mit einem Standort leicht außerhalb der Innenstadt. In Hochpreislagen Berlins (Mitte, Prenzlauer Berg) oder Münchens (Schwabing, Maxvorstadt) liegen die Preise auch für Gewerbehallen teils noch darüber. Die in der OPEX-Tabelle verwendete Jahresmiete von €120.000 entspricht einer Monatsmiete von €10.000 — das ist ein realistischer Wert für eine mittelgroße deutsche Stadt mit einem Standort leicht außerhalb der Innenstadt. Für München oder Berlin kalkulieren Sie mit den Werten aus der Stadtübersicht oben — und passen Sie die Erlösannahme entsprechend an.
Ein Hinweis zur Mietstruktur: Viele Vermieter verlangen bei Hallenflächen eine Laufzeit von mindestens 510 Jahren, oft mit Verlängerungsoptionen. Das bindet Sie, schafft aber auch Planungssicherheit für die Finanzierung. Banken bewerten einen langen Mietvertrag mit festen Konditionen positiv. Ein Hinweis zur Mietstruktur: Viele Vermieter verlangen bei Hallenflächen eine Laufzeit von mindestens 510 Jahren, oft mit Verlängerungsoptionen. Das bindet Sie, schafft aber auch Planungssicherheit für die Finanzierung. Ein langfristiger Mietvertrag mit indexierter Staffelung ist für die Bank ein echtes Positivsignal — er macht aus unsicheren künftigen Einnahmen etwas, das im Kreditbescheid wie planbarer Cashflow aussieht.
--- ---
## Platzbuchungspreise: Was der Markt trägt ## Platzbuchungspreise: Was der Markt trägt
Die Mietpreise sind das Fundament Ihrer Ertragsrechnung. Hier die aktuellen Marktpreise nach Stadt, basierend auf Plattformdaten und direkten Hallenerhebungen: Das Ertragspotenzial folgt der Standortlogik ähnlich eng wie die Mietkosten. Hier die aktuellen Marktpreise nach Stadt, basierend auf Plattformdaten und direkten Hallenerhebungen:
| Stadt | Nebenzeiten (€/Std.) | Hauptzeiten (€/Std.) | Datenbasis | | Stadt | Nebenzeiten (€/Std.) | Hauptzeiten (€/Std.) | Datenbasis |
|---|---|---|---| |---|---|---|---|
@@ -167,7 +169,7 @@ Bei einem Darlehen von €800.000 (z. B. KfW oder Hausbank), 5 % Zinsen und 10 J
## Was Banken wirklich wollen ## Was Banken wirklich wollen
Eine Padelhalle ist für die meisten Bankberater ein ungewohntes Investitionsobjekt. Was zählt, ist nicht die Begeisterung für Padel — sondern die Qualität Ihrer Zahlengrundlage. Eine Padelhalle ist für die meisten Bankberater unbekanntes Terrain. Auslastungsquoten und Erlöse pro Court sind keine Größen, mit denen Kreditausschüsse täglich arbeiten — das ist Ihr Vorteil. Wer mit sauberen Zahlen und strukturierter Dokumentation ins Gespräch geht, fällt sofort positiv auf. Was den Kreditausschuss bewegt, ist nicht die Begeisterung für den Sport, sondern die Belastbarkeit der Unterlagen.
**Debt Service Coverage Ratio (DSCR) 1,21,5x:** Die Bank will sehen, dass Ihr operativer Cashflow den Schuldendienst mit einem Puffer von 2050 % abdeckt. Mit einem EBITDA von €310.000 im ersten Jahr und einem Schuldendienst von €102.000 liegt der DSCR bei 3,0 — auf dem Papier sehr solide. Aber: Banken werden nachfragen, wie empfindlich dieses Ergebnis auf niedrigere Auslastung reagiert. **Debt Service Coverage Ratio (DSCR) 1,21,5x:** Die Bank will sehen, dass Ihr operativer Cashflow den Schuldendienst mit einem Puffer von 2050 % abdeckt. Mit einem EBITDA von €310.000 im ersten Jahr und einem Schuldendienst von €102.000 liegt der DSCR bei 3,0 — auf dem Papier sehr solide. Aber: Banken werden nachfragen, wie empfindlich dieses Ergebnis auf niedrigere Auslastung reagiert.
@@ -185,6 +187,6 @@ Wie Sie einen vollständigen Businessplan strukturieren und welche Unterlagen Ba
Die Kosten für eine Padelhalle sind real und erheblich — €930.000 bis €1,9 Millionen, realistischer Mittelpunkt €1,21,5 Millionen. Wer diese Zahlen kennt und versteht, wo die Hebel sitzen, kann daraus ein belastbares Investitionsmodell bauen. Wer mit Schätzungen aus zweiter Hand ins Bankgespräch geht, verliert Zeit und Glaubwürdigkeit. Die Kosten für eine Padelhalle sind real und erheblich — €930.000 bis €1,9 Millionen, realistischer Mittelpunkt €1,21,5 Millionen. Wer diese Zahlen kennt und versteht, wo die Hebel sitzen, kann daraus ein belastbares Investitionsmodell bauen. Wer mit Schätzungen aus zweiter Hand ins Bankgespräch geht, verliert Zeit und Glaubwürdigkeit.
Die Wirtschaftlichkeit stimmt: Bei konservativen Annahmen und solider Betriebsführung ist die Amortisation in 35 Jahren realistisch. Der deutsche Padel-Markt wächst weiter — aber mit wachsendem Angebot steigen auch die Erwartungen der Spieler und die Anforderungen an Konzept, Lage und Service. Richtig aufgesetzt, stimmt die Wirtschaftlichkeit: Bei konservativen Annahmen und solider Betriebsführung ist die Amortisation in 35 Jahren realistisch. Der deutsche Padel-Markt wächst weiter — aber mit wachsendem Angebot steigen auch die Erwartungen der Spieler und die Anforderungen an Konzept, Lage und Service.
**Nächster Schritt:** Nutzen Sie den Padelnomics Financial Planner, um Ihre spezifische Konstellation durchzurechnen — mit Ihrem Standort, Ihrer Finanzierungsstruktur und Ihren Preisannahmen. Das Modell oben ist der Einstieg. Ihre Halle verdient eine maßgeschneiderte Kalkulation. **Nächster Schritt:** Nutzen Sie den Padelnomics Financial Planner, um Ihre spezifische Konstellation durchzurechnen — mit Ihrem Standort, Ihrer Finanzierungsstruktur und Ihren Preisannahmen. Die Zahlen in diesem Artikel sind Ihr Ausgangspunkt — Ihre Halle verdient eine Kalkulation, die auf Ihren tatsächlichen Rahmenbedingungen aufbaut.

View File

@@ -89,7 +89,7 @@ Wenn in Jahr drei ein neuer Wettbewerber 10 Fahrminuten entfernt aufmacht, ist I
Einen echten Burggraben gibt es im Padel-Geschäft kaum. Keine Patente, keine Netzwerkeffekte, keine Wechselkosten. Was bleibt, ist: Standort, Gemeinschaft, Servicequalität und die Beziehung zu Stammkunden. Das sind reale Vorteile — aber sie müssen aktiv aufgebaut und gepflegt werden. Einen echten Burggraben gibt es im Padel-Geschäft kaum. Keine Patente, keine Netzwerkeffekte, keine Wechselkosten. Was bleibt, ist: Standort, Gemeinschaft, Servicequalität und die Beziehung zu Stammkunden. Das sind reale Vorteile — aber sie müssen aktiv aufgebaut und gepflegt werden.
**Was Sie jetzt schon tun können:** Modellieren Sie im Businessplan explizit das Szenario "neuer Wettbewerber in Jahr drei". Was ändert sich? Wie reagieren Sie? Welche Maßnahmen senken die Auslastungsschwelle für Profitabilität? **Rechnen Sie das durch.** Modellieren Sie im Businessplan explizit das Szenario neuer Wettbewerber in Jahr drei". Was ändert sich? Wie reagieren Sie? Welche Maßnahmen senken die Auslastungsschwelle für Profitabilität?
--- ---

View File

@@ -60,9 +60,10 @@ services:
environment: environment:
- DATABASE_PATH=/app/data/app.db - DATABASE_PATH=/app/data/app.db
- SERVING_DUCKDB_PATH=/app/data/pipeline/analytics.duckdb - SERVING_DUCKDB_PATH=/app/data/pipeline/analytics.duckdb
- LANDING_DIR=/app/data/pipeline/landing
volumes: volumes:
- app-data:/app/data - app-data:/app/data
- /opt/padelnomics/data:/app/data/pipeline:ro - /data/padelnomics:/app/data/pipeline:ro
networks: networks:
- net - net
healthcheck: healthcheck:
@@ -82,9 +83,10 @@ services:
environment: environment:
- DATABASE_PATH=/app/data/app.db - DATABASE_PATH=/app/data/app.db
- SERVING_DUCKDB_PATH=/app/data/pipeline/analytics.duckdb - SERVING_DUCKDB_PATH=/app/data/pipeline/analytics.duckdb
- LANDING_DIR=/app/data/pipeline/landing
volumes: volumes:
- app-data:/app/data - app-data:/app/data
- /opt/padelnomics/data:/app/data/pipeline:ro - /data/padelnomics:/app/data/pipeline:ro
networks: networks:
- net - net
@@ -98,9 +100,10 @@ services:
environment: environment:
- DATABASE_PATH=/app/data/app.db - DATABASE_PATH=/app/data/app.db
- SERVING_DUCKDB_PATH=/app/data/pipeline/analytics.duckdb - SERVING_DUCKDB_PATH=/app/data/pipeline/analytics.duckdb
- LANDING_DIR=/app/data/pipeline/landing
volumes: volumes:
- app-data:/app/data - app-data:/app/data
- /opt/padelnomics/data:/app/data/pipeline:ro - /data/padelnomics:/app/data/pipeline:ro
networks: networks:
- net - net
@@ -115,9 +118,10 @@ services:
environment: environment:
- DATABASE_PATH=/app/data/app.db - DATABASE_PATH=/app/data/app.db
- SERVING_DUCKDB_PATH=/app/data/pipeline/analytics.duckdb - SERVING_DUCKDB_PATH=/app/data/pipeline/analytics.duckdb
- LANDING_DIR=/app/data/pipeline/landing
volumes: volumes:
- app-data:/app/data - app-data:/app/data
- /opt/padelnomics/data:/app/data/pipeline:ro - /data/padelnomics:/app/data/pipeline:ro
networks: networks:
- net - net
healthcheck: healthcheck:
@@ -137,9 +141,10 @@ services:
environment: environment:
- DATABASE_PATH=/app/data/app.db - DATABASE_PATH=/app/data/app.db
- SERVING_DUCKDB_PATH=/app/data/pipeline/analytics.duckdb - SERVING_DUCKDB_PATH=/app/data/pipeline/analytics.duckdb
- LANDING_DIR=/app/data/pipeline/landing
volumes: volumes:
- app-data:/app/data - app-data:/app/data
- /opt/padelnomics/data:/app/data/pipeline:ro - /data/padelnomics:/app/data/pipeline:ro
networks: networks:
- net - net
@@ -153,9 +158,10 @@ services:
environment: environment:
- DATABASE_PATH=/app/data/app.db - DATABASE_PATH=/app/data/app.db
- SERVING_DUCKDB_PATH=/app/data/pipeline/analytics.duckdb - SERVING_DUCKDB_PATH=/app/data/pipeline/analytics.duckdb
- LANDING_DIR=/app/data/pipeline/landing
volumes: volumes:
- app-data:/app/data - app-data:/app/data
- /opt/padelnomics/data:/app/data/pipeline:ro - /data/padelnomics:/app/data/pipeline:ro
networks: networks:
- net - net

View File

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

View File

@@ -17,14 +17,12 @@ Usage:
""" """
import importlib import importlib
import json
import logging import logging
import os import os
import subprocess import subprocess
import sys import sys
import time import time
import tomllib import tomllib
import urllib.request
from collections import defaultdict from collections import defaultdict
from concurrent.futures import ThreadPoolExecutor, as_completed from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import UTC, datetime from datetime import UTC, datetime
@@ -269,14 +267,46 @@ def run_export() -> None:
send_alert(f"[export] {err}") send_alert(f"[export] {err}")
_last_seen_head: str | None = None
def web_code_changed() -> bool: def web_code_changed() -> bool:
"""Check if web app code or secrets changed since last deploy (after git pull).""" """True on the first tick after a commit that changed web app code or secrets.
Compares the current HEAD to the HEAD from the previous tick. On first call
after process start (e.g. after os.execv reloads new code), falls back to
HEAD~1 so the just-deployed commit is evaluated exactly once.
Records HEAD before returning so the same commit never triggers twice.
"""
global _last_seen_head
result = subprocess.run( result = subprocess.run(
["git", "diff", "--name-only", "HEAD~1", "HEAD", "--", ["git", "rev-parse", "HEAD"], capture_output=True, text=True, timeout=10,
)
if result.returncode != 0:
return False
current_head = result.stdout.strip()
if _last_seen_head is None:
# Fresh process — use HEAD~1 as base (evaluates the newly deployed tag).
base_result = subprocess.run(
["git", "rev-parse", "HEAD~1"], capture_output=True, text=True, timeout=10,
)
base = base_result.stdout.strip() if base_result.returncode == 0 else current_head
else:
base = _last_seen_head
_last_seen_head = current_head # advance now — won't fire again for this HEAD
if base == current_head:
return False
diff = subprocess.run(
["git", "diff", "--name-only", base, current_head, "--",
"web/", "Dockerfile", ".env.prod.sops"], "web/", "Dockerfile", ".env.prod.sops"],
capture_output=True, text=True, timeout=30, capture_output=True, text=True, timeout=30,
) )
return bool(result.stdout.strip()) return bool(diff.stdout.strip())
def current_deployed_tag() -> str | None: def current_deployed_tag() -> str | None:

View File

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

View File

@@ -49,7 +49,7 @@ _LANDING_DIR = os.environ.get("LANDING_DIR", "data/landing")
_SERVING_DUCKDB_PATH = os.environ.get("SERVING_DUCKDB_PATH", "data/analytics.duckdb") _SERVING_DUCKDB_PATH = os.environ.get("SERVING_DUCKDB_PATH", "data/analytics.duckdb")
# Repo root: web/src/padelnomics/admin/ → up 4 levels # Repo root: web/src/padelnomics/admin/ → up 4 levels
_REPO_ROOT = Path(__file__).resolve().parents[5] _REPO_ROOT = Path(__file__).resolve().parents[4]
_WORKFLOWS_TOML = _REPO_ROOT / "infra" / "supervisor" / "workflows.toml" _WORKFLOWS_TOML = _REPO_ROOT / "infra" / "supervisor" / "workflows.toml"
# A "running" row older than this is considered stale/crashed. # A "running" row older than this is considered stale/crashed.

View File

@@ -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)
@@ -3034,6 +3037,7 @@ async def outreach():
current_search=search, current_search=search,
current_follow_up=follow_up, current_follow_up=follow_up,
page=page, page=page,
outreach_email=EMAIL_ADDRESSES["outreach"],
) )
@@ -3254,6 +3258,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 +3487,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 +3624,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 +3641,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 +3658,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 +3695,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 +3704,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 +3715,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 +3727,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 +3747,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 +3801,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"),
) )

View File

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

View File

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

View File

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

View File

@@ -40,8 +40,10 @@
.admin-subnav { .admin-subnav {
display: flex; align-items: stretch; padding: 0 2rem; display: flex; align-items: stretch; padding: 0 2rem;
background: #fff; border-bottom: 1px solid #E2E8F0; background: #fff; border-bottom: 1px solid #E2E8F0;
flex-shrink: 0; overflow-x: auto; gap: 0; flex-shrink: 0; overflow-x: auto; overflow-y: hidden; gap: 0;
scrollbar-width: none;
} }
.admin-subnav::-webkit-scrollbar { display: none; }
.admin-subnav a { .admin-subnav a {
display: flex; align-items: center; gap: 5px; display: flex; align-items: center; gap: 5px;
padding: 0 1px; margin: 0 13px 0 0; height: 42px; padding: 0 1px; margin: 0 13px 0 0; height: 42px;
@@ -99,7 +101,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 +208,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">

View File

@@ -3,6 +3,19 @@
{% block title %}Admin Dashboard - {{ config.APP_NAME }}{% endblock %} {% block title %}Admin Dashboard - {{ config.APP_NAME }}{% endblock %}
{% block admin_head %}
<style>
.funnel-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.75rem;
}
@media (min-width: 768px) {
.funnel-grid { grid-template-columns: repeat(5, 1fr); }
}
</style>
{% endblock %}
{% block admin_content %} {% block admin_content %}
<header class="flex justify-between items-center mb-8"> <header class="flex justify-between items-center mb-8">
<div> <div>
@@ -47,7 +60,7 @@
<!-- Lead Funnel --> <!-- Lead Funnel -->
<p class="text-xs font-semibold text-slate uppercase tracking-wider mb-2">Lead Funnel</p> <p class="text-xs font-semibold text-slate uppercase tracking-wider mb-2">Lead Funnel</p>
<div style="display:grid;grid-template-columns:repeat(5,1fr);gap:0.75rem" class="mb-8"> <div class="funnel-grid mb-8">
<div class="card text-center border-l-4 border-l-electric" style="padding:0.75rem"> <div class="card text-center border-l-4 border-l-electric" style="padding:0.75rem">
<p class="text-xs text-slate">Planner Users</p> <p class="text-xs text-slate">Planner Users</p>
<p class="text-xl font-bold text-navy">{{ stats.planner_users }}</p> <p class="text-xl font-bold text-navy">{{ stats.planner_users }}</p>
@@ -72,7 +85,7 @@
<!-- Supplier Stats --> <!-- Supplier Stats -->
<p class="text-xs font-semibold text-slate uppercase tracking-wider mb-2">Supplier Funnel</p> <p class="text-xs font-semibold text-slate uppercase tracking-wider mb-2">Supplier Funnel</p>
<div style="display:grid;grid-template-columns:repeat(5,1fr);gap:0.75rem" class="mb-8"> <div class="funnel-grid mb-8">
<div class="card text-center border-l-4 border-l-accent" style="padding:0.75rem"> <div class="card text-center border-l-4 border-l-accent" style="padding:0.75rem">
<p class="text-xs text-slate">Claimed Suppliers</p> <p class="text-xs text-slate">Claimed Suppliers</p>
<p class="text-xl font-bold text-navy">{{ stats.suppliers_claimed }}</p> <p class="text-xl font-bold text-navy">{{ stats.suppliers_claimed }}</p>

View File

@@ -2,13 +2,30 @@
{% set admin_page = "outreach" %} {% set admin_page = "outreach" %}
{% block title %}Outreach Pipeline - Admin - {{ config.APP_NAME }}{% endblock %} {% block title %}Outreach Pipeline - Admin - {{ config.APP_NAME }}{% endblock %}
{% block admin_head %}
<style>
.pipeline-status-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.75rem;
margin-bottom: 1.5rem;
}
@media (min-width: 640px) {
.pipeline-status-grid { grid-template-columns: repeat(3, 1fr); }
}
@media (min-width: 1024px) {
.pipeline-status-grid { grid-template-columns: repeat(6, 1fr); }
}
</style>
{% endblock %}
{% block admin_content %} {% block admin_content %}
<header class="flex justify-between items-center mb-6"> <header class="flex justify-between items-center mb-6">
<div> <div>
<h1 class="text-2xl">Outreach</h1> <h1 class="text-2xl">Outreach</h1>
<p class="text-sm text-slate mt-1"> <p class="text-sm text-slate mt-1">
{{ pipeline.total }} supplier{{ 's' if pipeline.total != 1 else '' }} in pipeline {{ pipeline.total }} supplier{{ 's' if pipeline.total != 1 else '' }} in pipeline
&middot; Sending domain: <span class="mono text-xs">hello.padelnomics.io</span> &middot; Sending from: <span class="mono text-xs">{{ outreach_email }}</span>
</p> </p>
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
@@ -18,7 +35,7 @@
</header> </header>
<!-- Pipeline cards --> <!-- Pipeline cards -->
<div style="display:grid;grid-template-columns:repeat(6,1fr);gap:0.75rem;margin-bottom:1.5rem"> <div class="pipeline-status-grid">
{% set status_colors = { {% set status_colors = {
'prospect': '#E2E8F0', 'prospect': '#E2E8F0',
'contacted': '#DBEAFE', 'contacted': '#DBEAFE',

View File

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

View File

@@ -6,15 +6,19 @@
<td> <td>
{% for v in g.variants %} {% for v in g.variants %}
<div class="variant-row"> <div class="variant-row">
<a href="{{ url_for('admin.article_edit', article_id=v.id) }}" {% if v.display_status == 'live' %}
<a href="/{{ v.language or 'en' }}{{ v.url_path }}" target="_blank"
class="lang-chip lang-chip-{{ v.display_status }}" class="lang-chip lang-chip-{{ v.display_status }}"
title="Edit {{ v.language|upper }} variant"> title="View live {{ v.language|upper }} article">
<span class="dot"></span>{{ v.language | upper }} <span class="dot"></span>{{ v.language | upper }}
{% if v.noindex %}<span class="noindex-tag">noindex</span>{% endif %} {% if v.noindex %}<span class="noindex-tag">noindex</span>{% endif %}
</a> </a>
{% if v.display_status == 'live' %} {% else %}
<a href="/{{ v.language or 'en' }}{{ v.url_path }}" target="_blank" <span class="lang-chip lang-chip-{{ v.display_status }}"
class="btn-outline btn-sm view-lang-btn" title="View live article">View ↗</a> title="{{ v.display_status | capitalize }}">
<span class="dot"></span>{{ v.language | upper }}
{% if v.noindex %}<span class="noindex-tag">noindex</span>{% endif %}
</span>
{% endif %} {% endif %}
<a href="{{ url_for('admin.article_edit', article_id=v.id) }}" <a href="{{ url_for('admin.article_edit', article_id=v.id) }}"
class="btn-outline btn-sm view-lang-btn">Edit</a> class="btn-outline btn-sm view-lang-btn">Edit</a>

View File

@@ -1,5 +1,6 @@
{% if emails %} {% if emails %}
<div class="card"> <div class="card">
<div style="overflow-x:auto">
<table class="table"> <table class="table">
<thead> <thead>
<tr> <tr>
@@ -39,6 +40,7 @@
</tbody> </tbody>
</table> </table>
</div> </div>
</div>
{% else %} {% else %}
<div class="card text-center" style="padding:2rem"> <div class="card text-center" style="padding:2rem">
<p class="text-slate">No emails match the current filters.</p> <p class="text-slate">No emails match the current filters.</p>

View File

@@ -25,6 +25,7 @@
{% if leads %} {% if leads %}
<div class="card"> <div class="card">
<div style="overflow-x:auto">
<table class="table"> <table class="table">
<thead> <thead>
<tr> <tr>
@@ -59,6 +60,7 @@
</tbody> </tbody>
</table> </table>
</div> </div>
</div>
<!-- Pagination --> <!-- Pagination -->
{% if total > per_page %} {% if total > per_page %}

View File

@@ -1,5 +1,6 @@
{% if suppliers %} {% if suppliers %}
<div class="card"> <div class="card">
<div style="overflow-x:auto">
<table class="table"> <table class="table">
<thead> <thead>
<tr> <tr>
@@ -20,6 +21,7 @@
</tbody> </tbody>
</table> </table>
</div> </div>
</div>
{% else %} {% else %}
<div class="card text-center" style="padding:2rem"> <div class="card text-center" style="padding:2rem">
<p class="text-slate">No suppliers match the current filters.</p> <p class="text-slate">No suppliers match the current filters.</p>

View File

@@ -1,5 +1,6 @@
{% if suppliers %} {% if suppliers %}
<div class="card"> <div class="card">
<div style="overflow-x:auto">
<table class="table"> <table class="table">
<thead> <thead>
<tr> <tr>
@@ -48,6 +49,7 @@
</tbody> </tbody>
</table> </table>
</div> </div>
</div>
{% else %} {% else %}
<div class="card text-center" style="padding:2rem"> <div class="card text-center" style="padding:2rem">
<p class="text-slate">No suppliers match the current filters.</p> <p class="text-slate">No suppliers match the current filters.</p>

View File

@@ -4,6 +4,15 @@
{% block admin_head %} {% block admin_head %}
<style> <style>
.pipeline-stat-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.75rem;
}
@media (min-width: 768px) {
.pipeline-stat-grid { grid-template-columns: repeat(4, 1fr); }
}
.pipeline-tabs { .pipeline-tabs {
display: flex; gap: 0; border-bottom: 2px solid #E2E8F0; margin-bottom: 1.5rem; display: flex; gap: 0; border-bottom: 2px solid #E2E8F0; margin-bottom: 1.5rem;
} }
@@ -46,7 +55,7 @@
</header> </header>
<!-- Health stat cards --> <!-- Health stat cards -->
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:0.75rem" class="mb-6"> <div class="pipeline-stat-grid mb-6">
<div class="card text-center" style="padding:0.875rem"> <div class="card text-center" style="padding:0.875rem">
<p class="text-xs text-slate">Total Runs</p> <p class="text-xs text-slate">Total Runs</p>
<p class="text-2xl font-bold text-navy metric">{{ summary.total | default(0) }}</p> <p class="text-2xl font-bold text-navy metric">{{ summary.total | default(0) }}</p>

View File

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

View File

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

View File

@@ -181,7 +181,7 @@ Der **Market Score ({{ market_score | round(1) }}/100)** misst die *Marktreife*:
{{ city_name }} has a **<a href="/{{ language }}/market-score" style="text-decoration:none"><span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Market Score</a> of {{ market_score | round(1) }}/100** — placing it{% if market_score >= 55 %} among the strongest padel markets in {{ country_name_en }}{% elif market_score >= 35 %} in the mid-tier of {{ country_name_en }}'s padel markets{% else %} in an early-stage padel market with room for growth{% endif %}. The city currently has **{{ padel_venue_count }} padel venues** serving a population of {% if population >= 1000000 %}{{ (population / 1000000) | round(1) }}M{% else %}{{ (population / 1000) | round(0) | int }}K{% endif %} residents — a density of {{ venues_per_100k | round(1) }} venues per 100,000 people.{% if opportunity_score %} The **<a href="/{{ language }}/market-score" style="text-decoration:none"><span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Opportunity Score</a> of {{ opportunity_score | round(1) }}/100** scores investment potential — supply gaps, catchment reach, and sports culture as a demand proxy:{% if opportunity_score >= 65 and market_score < 40 %} limited competition meets strong location fundamentals{% elif opportunity_score >= 65 %} strong potential despite an already active market{% elif opportunity_score >= 40 %} solid potential as the market starts to fill in{% else %} the area is comparatively well-served; differentiation is the key lever{% endif %}.{% endif %} {{ city_name }} has a **<a href="/{{ language }}/market-score" style="text-decoration:none"><span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Market Score</a> of {{ market_score | round(1) }}/100** — placing it{% if market_score >= 55 %} among the strongest padel markets in {{ country_name_en }}{% elif market_score >= 35 %} in the mid-tier of {{ country_name_en }}'s padel markets{% else %} in an early-stage padel market with room for growth{% endif %}. The city currently has **{{ padel_venue_count }} padel venues** serving a population of {% if population >= 1000000 %}{{ (population / 1000000) | round(1) }}M{% else %}{{ (population / 1000) | round(0) | int }}K{% endif %} residents — a density of {{ venues_per_100k | round(1) }} venues per 100,000 people.{% if opportunity_score %} The **<a href="/{{ language }}/market-score" style="text-decoration:none"><span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Opportunity Score</a> of {{ opportunity_score | round(1) }}/100** scores investment potential — supply gaps, catchment reach, and sports culture as a demand proxy:{% if opportunity_score >= 65 and market_score < 40 %} limited competition meets strong location fundamentals{% elif opportunity_score >= 65 %} strong potential despite an already active market{% elif opportunity_score >= 40 %} solid potential as the market starts to fill in{% else %} the area is comparatively well-served; differentiation is the key lever{% endif %}.{% endif %}
The question investors actually need answered is: given current pricing, occupancy, and build costs, what does the return look like? The financial model below uses real {{ city_name }} market data to give you that answer. The question that matters: given current pricing, occupancy, and build costs, what does a padel investment in {{ city_name }} actually return? The financial model below works with real local market data.
## What Does a Padel Investment Cost in {{ city_name }}? ## What Does a Padel Investment Cost in {{ city_name }}?

View File

@@ -178,7 +178,7 @@ Der **Market Score (Ø {{ avg_market_score }}/100)** bewertet die Marktreife: Be
Padel is growing rapidly across {{ country_name_en }}. Our data tracks {{ total_venues }} venues — a figure that likely understates the true count given independent clubs not listed on booking platforms. The average <span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Market Score of {{ avg_market_score }}/100 across {{ city_count }} cities reflects both market maturity and data availability. Padel is growing rapidly across {{ country_name_en }}. Our data tracks {{ total_venues }} venues — a figure that likely understates the true count given independent clubs not listed on booking platforms. The average <span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Market Score of {{ avg_market_score }}/100 across {{ city_count }} cities reflects both market maturity and data availability.
{% if avg_market_score >= 55 %}Markets scoring above 55 typically show an established player base, reliable pricing data, and predictable demand patterns — all critical for sound financial planning. Yet even in mature markets, venue density per 100,000 residents varies significantly between cities, pointing to pockets of underserved demand.{% elif avg_market_score >= 35 %}A mid-range score signals a growth phase: demand is proven, venue infrastructure is building, and pricing hasn't fully settled to competitive levels. This creates opportunities for well-positioned new entrants who can secure good locations before the market matures.{% else %}Emerging markets offer first-mover advantages — less direct competition, potentially better lease terms, and the opportunity to build a loyal player base before the market fills out. The trade-off is less pricing data and more uncertainty in demand projections.{% endif %} {% if avg_market_score >= 55 %}Markets scoring above 55 typically show an established player base, reliable pricing data, and predictable demand patterns — all critical for sound financial planning. Yet even in mature markets, venue density per 100,000 residents varies significantly between cities, leaving genuine supply gaps even in established markets.{% elif avg_market_score >= 35 %}A mid-range score signals a growth phase: demand is proven, venue infrastructure is building, and pricing hasn't fully settled to competitive levels. This creates opportunities for well-positioned new entrants who can secure good locations before the market matures.{% else %}Emerging markets offer first-mover advantages — less direct competition, potentially better lease terms, and the opportunity to build a loyal player base before the market fills out. The trade-off is less pricing data and more uncertainty in demand projections.{% endif %}
{% if avg_opportunity_score %}The average **<a href="/{{ language }}/market-score" style="text-decoration:none"><span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Opportunity Score</a> of {{ avg_opportunity_score }}/100** shows how much investment potential remains untapped in {{ country_name_en }}. {% if avg_opportunity_score >= 60 and avg_market_score < 40 %}The combination of a high Opportunity Score and a moderate Market Score makes {{ country_name_en }} particularly attractive for new entrants: demand potential and sports culture are there, infrastructure is still building — first-mover conditions for well-chosen locations.{% elif avg_opportunity_score >= 60 %}Despite an already active market, locations with significant potential remain — particularly in mid-size cities and at the periphery of major metro areas.{% else %}Many locations in {{ country_name_en }} are already well-served. New projects need careful site selection and a clear differentiation strategy to compete.{% endif %}{% endif %} {% if avg_opportunity_score %}The average **<a href="/{{ language }}/market-score" style="text-decoration:none"><span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Opportunity Score</a> of {{ avg_opportunity_score }}/100** shows how much investment potential remains untapped in {{ country_name_en }}. {% if avg_opportunity_score >= 60 and avg_market_score < 40 %}The combination of a high Opportunity Score and a moderate Market Score makes {{ country_name_en }} particularly attractive for new entrants: demand potential and sports culture are there, infrastructure is still building — first-mover conditions for well-chosen locations.{% elif avg_opportunity_score >= 60 %}Despite an already active market, locations with significant potential remain — particularly in mid-size cities and at the periphery of major metro areas.{% else %}Many locations in {{ country_name_en }} are already well-served. New projects need careful site selection and a clear differentiation strategy to compete.{% endif %}{% endif %}

View File

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

View File

@@ -218,9 +218,7 @@
.nav-bar[data-navopen="true"] .nav-mobile { .nav-bar[data-navopen="true"] .nav-mobile {
display: flex; display: flex;
} }
.nav-mobile a, .nav-mobile a:not(.nav-auth-btn) {
.nav-mobile button.nav-auth-btn,
.nav-mobile a.nav-auth-btn {
display: block; display: block;
padding: 0.625rem 0; padding: 0.625rem 0;
border-bottom: 1px solid #F1F5F9; border-bottom: 1px solid #F1F5F9;
@@ -230,15 +228,18 @@
text-decoration: none; text-decoration: none;
transition: color 0.15s; transition: color 0.15s;
} }
.nav-mobile a:last-child { border-bottom: none; } .nav-mobile a:not(.nav-auth-btn):last-child { border-bottom: none; }
.nav-mobile a:hover { color: #1D4ED8; } .nav-mobile a:not(.nav-auth-btn):hover { color: #1D4ED8; }
/* nav-auth-btn in mobile menu: override block style, keep button colours */
.nav-mobile a.nav-auth-btn, .nav-mobile a.nav-auth-btn,
.nav-mobile button.nav-auth-btn { .nav-mobile button.nav-auth-btn {
display: inline-flex; display: inline-flex;
margin-top: 0.5rem; margin-top: 0.5rem;
padding: 6px 16px;
border-bottom: none; border-bottom: none;
width: auto; width: auto;
align-self: flex-start; align-self: flex-start;
color: #fff;
} }
.nav-mobile .nav-mobile-section { .nav-mobile .nav-mobile-section {
font-size: 0.6875rem; font-size: 0.6875rem;

View File

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