9 Commits

Author SHA1 Message Date
Deeman
e0c3f38c0a fix(analytics): directory bind mount + inode-based auto-reopen
All checks were successful
CI / test (push) Successful in 51s
CI / tag (push) Successful in 3s
- docker-compose.prod.yml: replace file bind mount for analytics.duckdb
  with directory bind mount (/opt/padelnomics/data:/app/data/pipeline:ro)
  so os.rename() on the host is visible inside the container
- Override SERVING_DUCKDB_PATH to /app/data/pipeline/analytics.duckdb in
  all 6 blue/green services (removes dependency on .env value)
- analytics.py: track file inode; call _check_and_reopen() at start of
  each query — transparently picks up new analytics.duckdb without restart
  when export_serving.py atomically replaces it after each pipeline run

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 21:48:20 +01:00
Deeman
d1a10ff243 merge: fix affiliate form grid layout 2026-02-28 21:40:21 +01:00
Deeman
34065fa2ac fix(affiliate): move HTMX preview trigger outside grid container
The invisible trigger div was inside the CSS grid, occupying the first cell
(1fr) and pushing the form into the 380px column and the preview below it.
Moved it before the grid with display:none so it has no layout impact.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 21:40:21 +01:00
Deeman
b7e44ac5b3 merge: affiliate preview fires on page load 2026-02-28 21:37:39 +01:00
Deeman
c2dfefcc1e fix(affiliate): fire preview on page load so edit form shows card immediately
hx-trigger="load, input from:..." fires the preview POST as soon as the page
opens, so editing an existing product shows its card without needing to
touch any field first.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 21:37:35 +01:00
Deeman
4b5c237bee merge: affiliate live preview fix 2026-02-28 21:34:14 +01:00
Deeman
8c4a4078f9 fix(affiliate): live preview uses dedicated /affiliate/preview endpoint
The form was posting to the save route on every input change (which would
save the product on every keystroke). Added a dedicated POST
/admin/affiliate/preview route that renders the product_card.html partial
from form data without touching the database.

Form now keeps action pointing to the save route; an invisible hx-div
triggers preview-only POSTs via hx-include="#affiliate-form".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 21:34:07 +01:00
Deeman
4ac17af503 merge: affiliate sidebar/nav fixes + dev seed data 2026-02-28 21:32:28 +01:00
Deeman
0984657e72 fix(affiliate): sidebar active state, subnav order, dev seed data
- base_admin.html: add 'affiliate_dashboard' to _section_map so Dashboard
  page stays under the Affiliate section (was falling through to 'overview')
- base_admin.html: sidebar Affiliate link now points to dashboard (first tab)
- base_admin.html: subnav order Dashboard | Products (was Products | Dashboard)
- seed_dev_data.py: add 10 affiliate products (4 rackets, 2 shoes, 1 ball,
  1 grip, 1 bag) + 236 click events spread over 30 days for dashboard charts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 21:32:20 +01:00
6 changed files with 361 additions and 26 deletions

View File

@@ -59,10 +59,10 @@ services:
env_file: ./.env
environment:
- DATABASE_PATH=/app/data/app.db
- SERVING_DUCKDB_PATH=/app/data/analytics.duckdb
- SERVING_DUCKDB_PATH=/app/data/pipeline/analytics.duckdb
volumes:
- app-data:/app/data
- /data/padelnomics/analytics.duckdb:/app/data/analytics.duckdb:ro
- /opt/padelnomics/data:/app/data/pipeline:ro
networks:
- net
healthcheck:
@@ -81,10 +81,10 @@ services:
env_file: ./.env
environment:
- DATABASE_PATH=/app/data/app.db
- SERVING_DUCKDB_PATH=/app/data/analytics.duckdb
- SERVING_DUCKDB_PATH=/app/data/pipeline/analytics.duckdb
volumes:
- app-data:/app/data
- /data/padelnomics/analytics.duckdb:/app/data/analytics.duckdb:ro
- /opt/padelnomics/data:/app/data/pipeline:ro
networks:
- net
@@ -97,10 +97,10 @@ services:
env_file: ./.env
environment:
- DATABASE_PATH=/app/data/app.db
- SERVING_DUCKDB_PATH=/app/data/analytics.duckdb
- SERVING_DUCKDB_PATH=/app/data/pipeline/analytics.duckdb
volumes:
- app-data:/app/data
- /data/padelnomics/analytics.duckdb:/app/data/analytics.duckdb:ro
- /opt/padelnomics/data:/app/data/pipeline:ro
networks:
- net
@@ -114,10 +114,10 @@ services:
env_file: ./.env
environment:
- DATABASE_PATH=/app/data/app.db
- SERVING_DUCKDB_PATH=/app/data/analytics.duckdb
- SERVING_DUCKDB_PATH=/app/data/pipeline/analytics.duckdb
volumes:
- app-data:/app/data
- /data/padelnomics/analytics.duckdb:/app/data/analytics.duckdb:ro
- /opt/padelnomics/data:/app/data/pipeline:ro
networks:
- net
healthcheck:
@@ -136,10 +136,10 @@ services:
env_file: ./.env
environment:
- DATABASE_PATH=/app/data/app.db
- SERVING_DUCKDB_PATH=/app/data/analytics.duckdb
- SERVING_DUCKDB_PATH=/app/data/pipeline/analytics.duckdb
volumes:
- app-data:/app/data
- /data/padelnomics/analytics.duckdb:/app/data/analytics.duckdb:ro
- /opt/padelnomics/data:/app/data/pipeline:ro
networks:
- net
@@ -152,10 +152,10 @@ services:
env_file: ./.env
environment:
- DATABASE_PATH=/app/data/app.db
- SERVING_DUCKDB_PATH=/app/data/analytics.duckdb
- SERVING_DUCKDB_PATH=/app/data/pipeline/analytics.duckdb
volumes:
- app-data:/app/data
- /data/padelnomics/analytics.duckdb:/app/data/analytics.duckdb:ro
- /opt/padelnomics/data:/app/data/pipeline:ro
networks:
- net

View File

@@ -3373,6 +3373,31 @@ async def affiliate_results():
)
@bp.route("/affiliate/preview", methods=["POST"])
@role_required("admin")
@csrf_protect
async def affiliate_preview():
"""Render a product card fragment from form data — used by live preview HTMX."""
from ..content.routes import _bake_env
from ..i18n import get_translations
form = await request.form
data = _form_to_product(form)
lang = data["language"] or "de"
# Convert JSON-string pros/cons to lists for the template
product = dict(data)
product["pros"] = json.loads(product["pros"]) if product["pros"] else []
product["cons"] = json.loads(product["cons"]) if product["cons"] else []
if not product["name"]:
return "<p style='color:#94A3B8;font-size:.875rem;padding:.5rem 0'>Fill in the form to see a preview.</p>"
tmpl = _bake_env.get_template("partials/product_card.html")
html = tmpl.render(product=product, t=get_translations(lang), lang=lang)
return html
@bp.route("/affiliate/new", methods=["GET", "POST"])
@role_required("admin")
@csrf_protect

View File

@@ -36,14 +36,20 @@ document.addEventListener('DOMContentLoaded', function() {
</div>
</header>
{# HTMX preview trigger — outside the grid so it takes no layout space #}
<div style="display:none"
hx-post="{{ url_for('admin.affiliate_preview') }}"
hx-target="#product-preview"
hx-trigger="load, input from:#affiliate-form delay:600ms"
hx-include="#affiliate-form"
hx-push-url="false">
</div>
<div style="display:grid;grid-template-columns:1fr 380px;gap:2rem;align-items:start" class="affiliate-form-grid">
{# ── Left: form ── #}
<form method="post" id="affiliate-form"
hx-post="{% if editing %}{{ url_for('admin.affiliate_edit', product_id=product_id) }}{% else %}{{ url_for('admin.affiliate_new') }}{% endif %}"
hx-target="#product-preview"
hx-trigger="input delay:600ms"
hx-push-url="false">
action="{% if editing %}{{ url_for('admin.affiliate_edit', product_id=product_id) }}{% else %}{{ url_for('admin.affiliate_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;">
@@ -200,9 +206,7 @@ document.addEventListener('DOMContentLoaded', function() {
<div style="position:sticky;top:1.5rem;">
<div class="text-xs font-semibold text-slate mb-2" style="text-transform:uppercase;letter-spacing:.06em;">Preview</div>
<div id="product-preview" style="border:1px solid #E2E8F0;border-radius:12px;padding:1rem;background:#F8FAFC;min-height:180px;">
<p class="text-slate text-sm" style="text-align:center;margin-top:2rem;">
Fill in the form to see a live preview.
</p>
<p style="color:#94A3B8;font-size:.875rem;text-align:center;margin-top:2rem;">Loading preview…</p>
</div>
</div>

View File

@@ -99,7 +99,7 @@
'suppliers': 'suppliers',
'articles': 'content', 'scenarios': 'content', 'templates': 'content', 'pseo': 'content',
'emails': 'email', 'inbox': 'email', 'compose': 'email', 'gallery': 'email', 'audiences': 'email', 'outreach': 'email',
'affiliate': 'affiliate',
'affiliate': 'affiliate', 'affiliate_dashboard': 'affiliate',
'billing': 'billing',
'seo': 'analytics',
'pipeline': 'pipeline',
@@ -150,7 +150,7 @@
Billing
</a>
<a href="{{ url_for('admin.affiliate_products') }}" class="{% if active_section == 'affiliate' %}active{% endif %}">
<a href="{{ url_for('admin.affiliate_dashboard') }}" class="{% if active_section == 'affiliate' %}active{% endif %}">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M13.5 21v-7.5a.75.75 0 0 1 .75-.75h3a.75.75 0 0 1 .75.75V21m-4.5 0H2.36m11.14 0H18m0 0h3.64m-1.39 0V9.349M3.75 21V9.349m0 0a3.001 3.001 0 0 0 3.75-.615A2.993 2.993 0 0 0 9.75 9.75c.896 0 1.7-.393 2.25-1.016a2.993 2.993 0 0 0 2.25 1.016 2.993 2.993 0 0 0 2.25-1.016 3.001 3.001 0 0 0 3.75.614m-16.5 0a3.004 3.004 0 0 1-.621-4.72l1.189-1.19A1.5 1.5 0 0 1 5.378 3h13.243a1.5 1.5 0 0 1 1.06.44l1.19 1.189a3 3 0 0 1-.621 4.72M6.75 18h3.75a.75.75 0 0 0 .75-.75V13.5a.75.75 0 0 0-.75-.75H6.75a.75.75 0 0 0-.75.75v3.75c0 .414.336.75.75.75Z"/></svg>
Affiliate
</a>
@@ -204,8 +204,8 @@
</nav>
{% elif active_section == 'affiliate' %}
<nav class="admin-subnav">
<a href="{{ url_for('admin.affiliate_products') }}" class="{% if admin_page == 'affiliate' %}active{% endif %}">Products</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>
</nav>
{% elif active_section == 'system' %}
<nav class="admin-subnav">

View File

@@ -4,6 +4,10 @@ DuckDB read-only analytics reader.
Opens a single long-lived DuckDB connection at startup (read_only=True).
All queries run via asyncio.to_thread() to avoid blocking the event loop.
When export_serving.py atomically renames a new analytics.duckdb into place,
_check_and_reopen() detects the inode change and transparently reopens —
no app restart required.
Usage:
from .analytics import fetch_analytics, execute_user_query
@@ -14,6 +18,7 @@ Usage:
import asyncio
import logging
import os
import threading
import time
from pathlib import Path
from typing import Any
@@ -21,6 +26,8 @@ from typing import Any
logger = logging.getLogger(__name__)
_conn = None # duckdb.DuckDBPyConnection | None — lazy import
_conn_inode: int | None = None
_reopen_lock = threading.Lock()
_DUCKDB_PATH = os.environ.get("SERVING_DUCKDB_PATH", "data/analytics.duckdb")
# DuckDB queries run in the asyncio thread pool. Cap them so a slow scan
@@ -32,20 +39,67 @@ def open_analytics_db() -> None:
"""Open the DuckDB connection. Call once at app startup."""
import duckdb
global _conn
global _conn, _conn_inode
path = Path(_DUCKDB_PATH)
if not path.exists():
# Database doesn't exist yet — skip silently. Queries will return empty.
return
_conn = duckdb.connect(str(path), read_only=True)
_conn_inode = path.stat().st_ino
def close_analytics_db() -> None:
"""Close the DuckDB connection. Call at app shutdown."""
global _conn
global _conn, _conn_inode
if _conn is not None:
_conn.close()
_conn = None
_conn_inode = None
def _check_and_reopen() -> None:
"""Reopen the connection if analytics.duckdb was atomically replaced (new inode).
Called at the start of each query. Requires a directory bind mount (not a file
bind mount) so that os.stat() inside the container sees the new inode after rename.
"""
global _conn, _conn_inode
import duckdb
path = Path(_DUCKDB_PATH)
try:
current_inode = path.stat().st_ino
except OSError:
return
if current_inode == _conn_inode:
return # same file — nothing to do
with _reopen_lock:
# Double-check under lock to avoid concurrent reopens.
try:
current_inode = path.stat().st_ino
except OSError:
return
if current_inode == _conn_inode:
return
old_conn = _conn
try:
new_conn = duckdb.connect(str(path), read_only=True)
except Exception:
logger.exception("Failed to reopen analytics DB after file change")
return
_conn = new_conn
_conn_inode = current_inode
logger.info("Analytics DB reopened (inode changed to %d)", current_inode)
if old_conn is not None:
try:
old_conn.close()
except Exception:
pass
async def fetch_analytics(sql: str, params: list | None = None) -> list[dict[str, Any]]:
@@ -61,7 +115,11 @@ async def fetch_analytics(sql: str, params: list | None = None) -> list[dict[str
return []
def _run() -> list[dict]:
cur = _conn.cursor()
_check_and_reopen()
conn = _conn
if conn is None:
return []
cur = conn.cursor()
try:
rel = cur.execute(sql, params or [])
cols = [d[0] for d in rel.description]
@@ -104,8 +162,12 @@ async def execute_user_query(
return [], [], "Analytics database is not available.", 0.0
def _run() -> tuple[list[str], list[tuple], str | None, float]:
_check_and_reopen()
conn = _conn
if conn is None:
return [], [], "Analytics database is not available.", 0.0
t0 = time.monotonic()
cur = _conn.cursor()
cur = conn.cursor()
try:
rel = cur.execute(sql)
cols = [d[0] for d in rel.description]

View File

@@ -284,6 +284,184 @@ LEADS = [
]
AFFILIATE_PRODUCTS = [
# Rackets
{
"slug": "bullpadel-vertex-04-amazon",
"name": "Bullpadel Vertex 04",
"brand": "Bullpadel",
"category": "racket",
"retailer": "Amazon",
"affiliate_url": "https://www.amazon.de/dp/B0CXTEST01?tag=padelnomics-21",
"price_cents": 17999,
"rating": 4.7,
"pros": '["Carbon-Rahmen für maximale Power", "Diamant-Form für aggressive Spieler", "Sehr gute Balance"]',
"cons": '["Nur für fortgeschrittene Spieler", "Höherer Preis"]',
"description": "Der Vertex 04 ist der Flaggschiff-Schläger von Bullpadel für Power-Spieler.",
"status": "active",
"language": "de",
"sort_order": 1,
},
{
"slug": "head-delta-pro-amazon",
"name": "HEAD Delta Pro",
"brand": "HEAD",
"category": "racket",
"retailer": "Amazon",
"affiliate_url": "https://www.amazon.de/dp/B0CXTEST02?tag=padelnomics-21",
"price_cents": 14999,
"rating": 4.5,
"pros": '["Sehr kontrollorientiert", "Ideal für Defensivspieler", "Leicht"]',
"cons": '["Weniger Power als Diamant-Formen"]',
"description": "Runde Form mit perfekter Kontrolle — ideal für Einsteiger und Defensivspieler.",
"status": "active",
"language": "de",
"sort_order": 2,
},
{
"slug": "adidas-metalbone-30-amazon",
"name": "Adidas Metalbone 3.0",
"brand": "Adidas",
"category": "racket",
"retailer": "Amazon",
"affiliate_url": "https://www.amazon.de/dp/B0CXTEST03?tag=padelnomics-21",
"price_cents": 18999,
"rating": 4.8,
"pros": '["Brutale Power", "Hochwertige Verarbeitung", "Sehr beliebt auf Pro-Tour"]',
"cons": '["Teuer", "Gewöhnungsbedürftig"]',
"description": "Das Flaggschiff von Adidas Padel — getragen von den besten Profis der Welt.",
"status": "active",
"language": "de",
"sort_order": 3,
},
{
"slug": "wilson-bela-pro-v2-amazon",
"name": "Wilson Bela Pro v2",
"brand": "Wilson",
"category": "racket",
"retailer": "Amazon",
"affiliate_url": "https://www.amazon.de/dp/B0CXTEST04?tag=padelnomics-21",
"price_cents": 16999,
"rating": 4.6,
"pros": '["Bekannter Signature-Schläger", "Gute Mischung aus Power und Kontrolle"]',
"cons": '["Fortgeschrittene bevorzugt"]',
"description": "Der Schläger von Fernando Belasteguín — einer der meistgekauften Schläger weltweit.",
"status": "active",
"language": "de",
"sort_order": 4,
},
# Beginner racket — draft (tests that draft products are excluded from public)
{
"slug": "dunlop-aero-star-amazon",
"name": "Dunlop Aero Star",
"brand": "Dunlop",
"category": "racket",
"retailer": "Amazon",
"affiliate_url": "https://www.amazon.de/dp/B0CXTEST05?tag=padelnomics-21",
"price_cents": 8999,
"rating": 4.2,
"pros": '["Günstig", "Für Einsteiger ideal"]',
"cons": '["Wenig Power für Fortgeschrittene"]',
"description": "Solider Einsteigerschläger für unter 90 Euro.",
"status": "draft",
"language": "de",
"sort_order": 5,
},
# Shoes
{
"slug": "adidas-adipower-ctrl-amazon",
"name": "Adidas Adipower Ctrl",
"brand": "Adidas",
"category": "shoe",
"retailer": "Amazon",
"affiliate_url": "https://www.amazon.de/dp/B0CXTEST10?tag=padelnomics-21",
"price_cents": 9999,
"rating": 4.4,
"pros": '["Hervorragender Halt auf Sand", "Leicht und atmungsaktiv"]',
"cons": '["Größenfehler möglich — eine Größe größer bestellen"]',
"description": "Professioneller Padelschuh mit optimierter Sohle für Sand- und Kunstrasencourts.",
"status": "active",
"language": "de",
"sort_order": 1,
},
{
"slug": "babolat-jet-premura-amazon",
"name": "Babolat Jet Premura",
"brand": "Babolat",
"category": "shoe",
"retailer": "Amazon",
"affiliate_url": "https://www.amazon.de/dp/B0CXTEST11?tag=padelnomics-21",
"price_cents": 11999,
"rating": 4.6,
"pros": '["Sehr leicht", "Gute Dämpfung", "Stylisches Design"]',
"cons": '["Teurer als Mitbewerber"]',
"description": "Ultraleichter Padelschuh von Babolat — ideal für schnelle Spieler.",
"status": "active",
"language": "de",
"sort_order": 2,
},
# Balls
{
"slug": "head-padel-pro-balls-amazon",
"name": "HEAD Padel Pro Bälle (3er-Dose)",
"brand": "HEAD",
"category": "ball",
"retailer": "Amazon",
"affiliate_url": "https://www.amazon.de/dp/B0CXTEST20?tag=padelnomics-21",
"price_cents": 799,
"rating": 4.5,
"pros": '["Offizieller Turnierball", "Guter Druckerhalt", "Günstig"]',
"cons": '["Bei intensivem Spiel nach 45 Sessions platter"]',
"description": "Offizieller Turnierball von HEAD — der am häufigsten gespielte Padelball in Europa.",
"status": "active",
"language": "de",
"sort_order": 1,
},
# Grips/Accessories
{
"slug": "bullpadel-overgrip-3er-amazon",
"name": "Bullpadel Overgrip (3er-Pack)",
"brand": "Bullpadel",
"category": "grip",
"retailer": "Amazon",
"affiliate_url": "https://www.amazon.de/dp/B0CXTEST30?tag=padelnomics-21",
"price_cents": 499,
"rating": 4.3,
"pros": '["Günstig", "Guter Halt auch bei Schweiß", "Einfach zu wechseln"]',
"cons": '["Hält weniger lang als Originalgriff"]',
"description": "Günstiges Overgrip-Set — jeder Padelspieler sollte regelmäßig wechseln.",
"status": "active",
"language": "de",
"sort_order": 1,
},
{
"slug": "nox-padel-bag-amazon",
"name": "NOX ML10 Schläger-Tasche",
"brand": "NOX",
"category": "accessory",
"retailer": "Amazon",
"affiliate_url": "https://www.amazon.de/dp/B0CXTEST40?tag=padelnomics-21",
"price_cents": 5999,
"rating": 4.4,
"pros": '["Platz für 2 Schläger", "Gepolstertes Schlägerfach", "Robustes Material"]',
"cons": '["Kein Schuhfach"]',
"description": "Praktische Padelschläger-Tasche mit Platz für 2 Schläger und Zubehör.",
"status": "active",
"language": "de",
"sort_order": 1,
},
]
# Article slugs for realistic click referrers
_ARTICLE_SLUGS = [
"beste-padelschlaeger-2026",
"padelschlaeger-anfaenger",
"padelschuhe-test",
"padelbaelle-vergleich",
"padel-zubehoer",
]
def main():
db_path = DATABASE_PATH
if not Path(db_path).exists():
@@ -481,6 +659,72 @@ def main():
)
logger.info(" PadelTech unlocked lead #%s", lead_id)
# 7. Seed affiliate products
logger.info("Seeding %s affiliate products...", len(AFFILIATE_PRODUCTS))
product_ids: dict[str, int] = {}
for p in AFFILIATE_PRODUCTS:
existing = conn.execute(
"SELECT id FROM affiliate_products WHERE slug = ? AND language = ?",
(p["slug"], p["language"]),
).fetchone()
if existing:
product_ids[p["slug"]] = existing["id"]
logger.info(" %s already exists (id=%s)", p["name"], existing["id"])
continue
cursor = conn.execute(
"""INSERT INTO affiliate_products
(slug, name, brand, category, retailer, affiliate_url,
price_cents, currency, rating, pros, cons, description,
status, language, sort_order)
VALUES (?, ?, ?, ?, ?, ?, ?, 'EUR', ?, ?, ?, ?, ?, ?, ?)""",
(
p["slug"], p["name"], p["brand"], p["category"], p["retailer"],
p["affiliate_url"], p["price_cents"], p["rating"],
p["pros"], p["cons"], p["description"],
p["status"], p["language"], p["sort_order"],
),
)
product_ids[p["slug"]] = cursor.lastrowid
logger.info(" %s -> id=%s (%s)", p["name"], cursor.lastrowid, p["status"])
# 8. Seed affiliate clicks (realistic 30-day spread for dashboard charts)
logger.info("Seeding affiliate clicks...")
import random
rng = random.Random(42)
# click distribution: more on popular rackets, fewer on accessories
click_weights = [
("bullpadel-vertex-04-amazon", "beste-padelschlaeger-2026", 52),
("adidas-metalbone-30-amazon", "beste-padelschlaeger-2026", 41),
("head-delta-pro-amazon", "padelschlaeger-anfaenger", 38),
("wilson-bela-pro-v2-amazon", "padelschlaeger-anfaenger", 29),
("adidas-adipower-ctrl-amazon", "padelschuhe-test", 24),
("babolat-jet-premura-amazon", "padelschuhe-test", 18),
("head-padel-pro-balls-amazon", "padelbaelle-vergleich", 15),
("bullpadel-overgrip-3er-amazon", "padel-zubehoer", 11),
("nox-padel-bag-amazon", "padel-zubehoer", 8),
]
existing_click_count = conn.execute("SELECT COUNT(*) FROM affiliate_clicks").fetchone()[0]
if existing_click_count == 0:
for slug, article_slug, count in click_weights:
pid = product_ids.get(slug)
if not pid:
continue
for _ in range(count):
days_ago = rng.randint(0, 29)
hours_ago = rng.randint(0, 23)
clicked_at = (now - timedelta(days=days_ago, hours=hours_ago)).strftime("%Y-%m-%d %H:%M:%S")
ip_hash = f"dev_{slug}_{_:04d}" # stable fake hash (not real SHA256)
conn.execute(
"""INSERT INTO affiliate_clicks
(product_id, article_slug, referrer, ip_hash, clicked_at)
VALUES (?, ?, ?, ?, ?)""",
(pid, article_slug, f"https://padelnomics.io/de/blog/{article_slug}", ip_hash, clicked_at),
)
total_clicks = sum(c for _, _, c in click_weights)
logger.info(" Inserted %s click events across 9 products", total_clicks)
else:
logger.info(" Clicks already seeded (%s rows), skipping", existing_click_count)
conn.commit()
conn.close()