Compare commits
9 Commits
v202602282
...
v202602282
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0c3f38c0a | ||
|
|
d1a10ff243 | ||
|
|
34065fa2ac | ||
|
|
b7e44ac5b3 | ||
|
|
c2dfefcc1e | ||
|
|
4b5c237bee | ||
|
|
8c4a4078f9 | ||
|
|
4ac17af503 | ||
|
|
0984657e72 |
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 4–5 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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user