- 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>
739 lines
28 KiB
Python
739 lines
28 KiB
Python
"""
|
||
Seed realistic test data for local development.
|
||
|
||
Creates suppliers, leads, a dev user, credit ledger entries, and lead forwards.
|
||
|
||
Usage:
|
||
uv run python -m padelnomics.scripts.seed_dev_data
|
||
"""
|
||
|
||
import logging
|
||
import os
|
||
import sqlite3
|
||
import sys
|
||
from datetime import UTC, datetime, timedelta
|
||
from pathlib import Path
|
||
|
||
from dotenv import load_dotenv
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
load_dotenv()
|
||
|
||
DATABASE_PATH = os.getenv("DATABASE_PATH", "data/app.db")
|
||
|
||
|
||
def slugify(name: str) -> str:
|
||
return name.lower().replace(" ", "-").replace("&", "and").replace(",", "")
|
||
|
||
|
||
SUPPLIERS = [
|
||
{
|
||
"name": "PadelTech GmbH",
|
||
"slug": "padeltech-gmbh",
|
||
"country_code": "DE",
|
||
"city": "Munich",
|
||
"region": "Europe",
|
||
"website": "https://padeltech.example.com",
|
||
"description": "Premium padel court manufacturer with 15+ years experience in Central Europe.",
|
||
"category": "manufacturer",
|
||
"tier": "pro",
|
||
"credit_balance": 80,
|
||
"monthly_credits": 100,
|
||
"contact_name": "Hans Weber",
|
||
"contact_email": "hans@padeltech.example.com",
|
||
"years_in_business": 15,
|
||
"project_count": 120,
|
||
"service_area": "DE,AT,CH",
|
||
},
|
||
{
|
||
"name": "CourtBuild Spain",
|
||
"slug": "courtbuild-spain",
|
||
"country_code": "ES",
|
||
"city": "Madrid",
|
||
"region": "Europe",
|
||
"website": "https://courtbuild.example.com",
|
||
"description": "Turnkey padel facility builder serving Spain and Portugal.",
|
||
"category": "turnkey",
|
||
"tier": "growth",
|
||
"credit_balance": 25,
|
||
"monthly_credits": 30,
|
||
"contact_name": "Maria Garcia",
|
||
"contact_email": "maria@courtbuild.example.com",
|
||
"years_in_business": 8,
|
||
"project_count": 45,
|
||
"service_area": "ES,PT",
|
||
},
|
||
{
|
||
"name": "Nordic Padel Solutions",
|
||
"slug": "nordic-padel-solutions",
|
||
"country_code": "SE",
|
||
"city": "Stockholm",
|
||
"region": "Europe",
|
||
"website": "https://nordicpadel.example.com",
|
||
"description": "Indoor padel specialists for the Nordic climate.",
|
||
"category": "manufacturer",
|
||
"tier": "free",
|
||
"credit_balance": 0,
|
||
"monthly_credits": 0,
|
||
"contact_name": "Erik Lindqvist",
|
||
"contact_email": "erik@nordicpadel.example.com",
|
||
"years_in_business": 5,
|
||
"project_count": 20,
|
||
"service_area": "SE,DK,FI,NO",
|
||
},
|
||
{
|
||
"name": "PadelLux Consulting",
|
||
"slug": "padellux-consulting",
|
||
"country_code": "NL",
|
||
"city": "Amsterdam",
|
||
"region": "Europe",
|
||
"website": "https://padellux.example.com",
|
||
"description": "Independent padel consultants helping investors plan profitable facilities.",
|
||
"category": "consultant",
|
||
"tier": "growth",
|
||
"credit_balance": 18,
|
||
"monthly_credits": 30,
|
||
"contact_name": "Jan de Vries",
|
||
"contact_email": "jan@padellux.example.com",
|
||
"years_in_business": 3,
|
||
"project_count": 12,
|
||
"service_area": "NL,BE,DE",
|
||
},
|
||
{
|
||
"name": "Desert Padel FZE",
|
||
"slug": "desert-padel-fze",
|
||
"country_code": "AE",
|
||
"city": "Dubai",
|
||
"region": "Middle East",
|
||
"website": "https://desertpadel.example.com",
|
||
"description": "Outdoor and covered padel courts designed for extreme heat environments.",
|
||
"category": "turnkey",
|
||
"tier": "pro",
|
||
"credit_balance": 90,
|
||
"monthly_credits": 100,
|
||
"contact_name": "Ahmed Al-Rashid",
|
||
"contact_email": "ahmed@desertpadel.example.com",
|
||
"years_in_business": 6,
|
||
"project_count": 30,
|
||
"service_area": "AE,SA",
|
||
},
|
||
]
|
||
|
||
LEADS = [
|
||
{
|
||
"facility_type": "indoor",
|
||
"court_count": 6,
|
||
"country": "DE",
|
||
"city": "Berlin",
|
||
"timeline": "3-6mo",
|
||
"budget_estimate": 450000,
|
||
"heat_score": "hot",
|
||
"status": "new",
|
||
"contact_name": "Thomas Mueller",
|
||
"contact_email": "thomas@example.com",
|
||
"stakeholder_type": "entrepreneur",
|
||
"financing_status": "loan_approved",
|
||
"location_status": "lease_signed",
|
||
"decision_process": "solo",
|
||
},
|
||
{
|
||
"facility_type": "outdoor",
|
||
"court_count": 4,
|
||
"country": "ES",
|
||
"city": "Barcelona",
|
||
"timeline": "asap",
|
||
"budget_estimate": 280000,
|
||
"heat_score": "hot",
|
||
"status": "new",
|
||
"contact_name": "Carlos Ruiz",
|
||
"contact_email": "carlos@example.com",
|
||
"stakeholder_type": "tennis_club",
|
||
"financing_status": "self_funded",
|
||
"location_status": "permit_granted",
|
||
"decision_process": "partners",
|
||
},
|
||
{
|
||
"facility_type": "both",
|
||
"court_count": 8,
|
||
"country": "SE",
|
||
"city": "Gothenburg",
|
||
"timeline": "6-12mo",
|
||
"budget_estimate": 720000,
|
||
"heat_score": "warm",
|
||
"status": "new",
|
||
"contact_name": "Anna Svensson",
|
||
"contact_email": "anna@example.com",
|
||
"stakeholder_type": "developer",
|
||
"financing_status": "seeking",
|
||
"location_status": "location_found",
|
||
"decision_process": "committee",
|
||
},
|
||
{
|
||
"facility_type": "indoor",
|
||
"court_count": 4,
|
||
"country": "NL",
|
||
"city": "Rotterdam",
|
||
"timeline": "3-6mo",
|
||
"budget_estimate": 350000,
|
||
"heat_score": "warm",
|
||
"status": "new",
|
||
"contact_name": "Pieter van Dijk",
|
||
"contact_email": "pieter@example.com",
|
||
"stakeholder_type": "entrepreneur",
|
||
"financing_status": "self_funded",
|
||
"location_status": "converting_existing",
|
||
"decision_process": "solo",
|
||
},
|
||
{
|
||
"facility_type": "indoor",
|
||
"court_count": 10,
|
||
"country": "DE",
|
||
"city": "Hamburg",
|
||
"timeline": "asap",
|
||
"budget_estimate": 900000,
|
||
"heat_score": "hot",
|
||
"status": "forwarded",
|
||
"contact_name": "Lisa Braun",
|
||
"contact_email": "lisa@example.com",
|
||
"stakeholder_type": "operator",
|
||
"financing_status": "loan_approved",
|
||
"location_status": "permit_pending",
|
||
"decision_process": "partners",
|
||
},
|
||
{
|
||
"facility_type": "outdoor",
|
||
"court_count": 3,
|
||
"country": "AE",
|
||
"city": "Abu Dhabi",
|
||
"timeline": "12+mo",
|
||
"budget_estimate": 200000,
|
||
"heat_score": "cool",
|
||
"status": "new",
|
||
"contact_name": "Fatima Hassan",
|
||
"contact_email": "fatima@example.com",
|
||
"stakeholder_type": "municipality",
|
||
"financing_status": "not_started",
|
||
"location_status": "still_searching",
|
||
"decision_process": "committee",
|
||
},
|
||
{
|
||
"facility_type": "indoor",
|
||
"court_count": 6,
|
||
"country": "FR",
|
||
"city": "Lyon",
|
||
"timeline": "6-12mo",
|
||
"budget_estimate": 500000,
|
||
"heat_score": "warm",
|
||
"status": "new",
|
||
"contact_name": "Jean Dupont",
|
||
"contact_email": "jean@example.com",
|
||
"stakeholder_type": "entrepreneur",
|
||
"financing_status": "seeking",
|
||
"location_status": "location_found",
|
||
"decision_process": "solo",
|
||
},
|
||
{
|
||
"facility_type": "both",
|
||
"court_count": 12,
|
||
"country": "IT",
|
||
"city": "Milan",
|
||
"timeline": "3-6mo",
|
||
"budget_estimate": 1200000,
|
||
"heat_score": "hot",
|
||
"status": "new",
|
||
"contact_name": "Marco Rossi",
|
||
"contact_email": "marco@example.com",
|
||
"stakeholder_type": "developer",
|
||
"financing_status": "loan_approved",
|
||
"location_status": "permit_granted",
|
||
"decision_process": "partners",
|
||
},
|
||
{
|
||
"facility_type": "indoor",
|
||
"court_count": 4,
|
||
"country": "GB",
|
||
"city": "Manchester",
|
||
"timeline": "6-12mo",
|
||
"budget_estimate": 400000,
|
||
"heat_score": "warm",
|
||
"status": "pending_verification",
|
||
"contact_name": "James Wilson",
|
||
"contact_email": "james@example.com",
|
||
"stakeholder_type": "tennis_club",
|
||
"financing_status": "seeking",
|
||
"location_status": "converting_existing",
|
||
"decision_process": "committee",
|
||
},
|
||
{
|
||
"facility_type": "outdoor",
|
||
"court_count": 2,
|
||
"country": "PT",
|
||
"city": "Lisbon",
|
||
"timeline": "12+mo",
|
||
"budget_estimate": 150000,
|
||
"heat_score": "cool",
|
||
"status": "new",
|
||
"contact_name": "Sofia Costa",
|
||
"contact_email": "sofia@example.com",
|
||
"stakeholder_type": "entrepreneur",
|
||
"financing_status": "not_started",
|
||
"location_status": "still_searching",
|
||
"decision_process": "solo",
|
||
},
|
||
]
|
||
|
||
|
||
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():
|
||
logger.error("Database not found at %s. Run migrations first.", db_path)
|
||
sys.exit(1)
|
||
|
||
conn = sqlite3.connect(db_path)
|
||
conn.execute("PRAGMA journal_mode=WAL")
|
||
conn.execute("PRAGMA foreign_keys=ON")
|
||
conn.row_factory = sqlite3.Row
|
||
|
||
now = datetime.now(UTC)
|
||
|
||
# 1. Create dev user
|
||
logger.info("Creating dev user (dev@localhost)...")
|
||
existing = conn.execute("SELECT id FROM users WHERE email = 'dev@localhost'").fetchone()
|
||
if existing:
|
||
dev_user_id = existing["id"]
|
||
logger.info(" Already exists (id=%s)", dev_user_id)
|
||
else:
|
||
cursor = conn.execute(
|
||
"INSERT INTO users (email, name, created_at) VALUES (?, ?, ?)",
|
||
("dev@localhost", "Dev User", now.strftime("%Y-%m-%d %H:%M:%S")),
|
||
)
|
||
dev_user_id = cursor.lastrowid
|
||
logger.info(" Created (id=%s)", dev_user_id)
|
||
|
||
# Grant admin role to dev user
|
||
conn.execute(
|
||
"INSERT OR IGNORE INTO user_roles (user_id, role) VALUES (?, 'admin')",
|
||
(dev_user_id,),
|
||
)
|
||
logger.info(" Admin role granted")
|
||
|
||
# 2. Seed suppliers
|
||
logger.info("Seeding %s suppliers...", len(SUPPLIERS))
|
||
supplier_ids = {}
|
||
for s in SUPPLIERS:
|
||
existing = conn.execute("SELECT id FROM suppliers WHERE slug = ?", (s["slug"],)).fetchone()
|
||
if existing:
|
||
supplier_ids[s["slug"]] = existing["id"]
|
||
logger.info(" %s already exists (id=%s)", s["name"], existing["id"])
|
||
continue
|
||
|
||
cursor = conn.execute(
|
||
"""INSERT INTO suppliers
|
||
(name, slug, country_code, city, region, website, description, category,
|
||
tier, credit_balance, monthly_credits, contact_name, contact_email,
|
||
years_in_business, project_count, service_area, created_at)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||
(
|
||
s["name"], s["slug"], s["country_code"], s["city"], s["region"],
|
||
s["website"], s["description"], s["category"], s["tier"],
|
||
s["credit_balance"], s["monthly_credits"], s["contact_name"],
|
||
s["contact_email"], s["years_in_business"], s["project_count"],
|
||
s["service_area"], now.strftime("%Y-%m-%d %H:%M:%S"),
|
||
),
|
||
)
|
||
supplier_ids[s["slug"]] = cursor.lastrowid
|
||
logger.info(" %s -> id=%s", s["name"], cursor.lastrowid)
|
||
|
||
# 3. Claim paid suppliers — each gets its own owner user + subscription
|
||
logger.info("Claiming paid suppliers with owner accounts...")
|
||
claimed_suppliers = [
|
||
("padeltech-gmbh", "supplier_pro", "hans@padeltech.example.com", "Hans Weber"),
|
||
("courtbuild-spain", "supplier_growth", "maria@courtbuild.example.com", "Maria Garcia"),
|
||
("desert-padel-fze", "supplier_pro", "ahmed@desertpadel.example.com", "Ahmed Al-Rashid"),
|
||
]
|
||
period_end = (now + timedelta(days=30)).strftime("%Y-%m-%d %H:%M:%S")
|
||
for slug, plan, email, name in claimed_suppliers:
|
||
sid = supplier_ids.get(slug)
|
||
if not sid:
|
||
continue
|
||
|
||
# Create or find owner user
|
||
existing_owner = conn.execute(
|
||
"SELECT id FROM users WHERE email = ?", (email,)
|
||
).fetchone()
|
||
if existing_owner:
|
||
owner_id = existing_owner["id"]
|
||
else:
|
||
cursor = conn.execute(
|
||
"INSERT INTO users (email, name, created_at) VALUES (?, ?, ?)",
|
||
(email, name, now.strftime("%Y-%m-%d %H:%M:%S")),
|
||
)
|
||
owner_id = cursor.lastrowid
|
||
|
||
# Claim the supplier
|
||
conn.execute(
|
||
"UPDATE suppliers SET claimed_by = ?, claimed_at = ? WHERE id = ? AND claimed_by IS NULL",
|
||
(owner_id, now.strftime("%Y-%m-%d %H:%M:%S"), sid),
|
||
)
|
||
|
||
# Create billing customer record
|
||
existing_bc = conn.execute(
|
||
"SELECT id FROM billing_customers WHERE user_id = ?", (owner_id,)
|
||
).fetchone()
|
||
if not existing_bc:
|
||
conn.execute(
|
||
"""INSERT INTO billing_customers (user_id, provider_customer_id, created_at)
|
||
VALUES (?, ?, ?)""",
|
||
(owner_id, f"ctm_dev_{slug}", now.strftime("%Y-%m-%d %H:%M:%S")),
|
||
)
|
||
|
||
# Create active subscription
|
||
existing_sub = conn.execute(
|
||
"SELECT id FROM subscriptions WHERE user_id = ?", (owner_id,)
|
||
).fetchone()
|
||
if not existing_sub:
|
||
conn.execute(
|
||
"""INSERT INTO subscriptions
|
||
(user_id, plan, status, provider_subscription_id,
|
||
current_period_end, created_at)
|
||
VALUES (?, ?, 'active', ?, ?, ?)""",
|
||
(owner_id, plan, f"sub_dev_{slug}",
|
||
period_end, now.strftime("%Y-%m-%d %H:%M:%S")),
|
||
)
|
||
logger.info(" %s -> owner %s (%s)", slug, email, plan)
|
||
|
||
# 4. Seed leads
|
||
logger.info("Seeding %s leads...", len(LEADS))
|
||
lead_ids = []
|
||
for i, lead in enumerate(LEADS):
|
||
from padelnomics.credits import HEAT_CREDIT_COSTS
|
||
credit_cost = HEAT_CREDIT_COSTS.get(lead["heat_score"], 8)
|
||
verified_at = (now - timedelta(hours=i * 2)).isoformat() if lead["status"] not in ("pending_verification",) else None
|
||
|
||
cursor = conn.execute(
|
||
"""INSERT INTO lead_requests
|
||
(user_id, lead_type, facility_type, court_count, country, location, timeline,
|
||
budget_estimate, heat_score, status, contact_name, contact_email,
|
||
stakeholder_type, financing_status, location_status, decision_process,
|
||
credit_cost, verified_at, created_at)
|
||
VALUES (?, 'quote', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||
(
|
||
dev_user_id, lead["facility_type"], lead["court_count"], lead["country"],
|
||
lead["city"], lead["timeline"], lead["budget_estimate"],
|
||
lead["heat_score"], lead["status"], lead["contact_name"],
|
||
lead["contact_email"], lead["stakeholder_type"],
|
||
lead["financing_status"], lead["location_status"],
|
||
lead["decision_process"], credit_cost, verified_at,
|
||
(now - timedelta(hours=i * 3)).isoformat(),
|
||
),
|
||
)
|
||
lead_ids.append(cursor.lastrowid)
|
||
logger.info(" Lead #%s: %s (%s, %s)", cursor.lastrowid, lead["contact_name"], lead["heat_score"], lead["country"])
|
||
|
||
# 5. Add credit ledger entries for claimed suppliers
|
||
logger.info("Adding credit ledger entries...")
|
||
for slug in ("padeltech-gmbh", "courtbuild-spain", "desert-padel-fze"):
|
||
sid = supplier_ids.get(slug)
|
||
if not sid:
|
||
continue
|
||
is_pro = slug in ("padeltech-gmbh", "desert-padel-fze")
|
||
monthly = 100 if is_pro else 30
|
||
# Monthly allocation
|
||
conn.execute(
|
||
"""INSERT INTO credit_ledger (supplier_id, delta, balance_after, event_type, note, created_at)
|
||
VALUES (?, ?, ?, 'monthly_allocation', 'Monthly refill', ?)""",
|
||
(sid, monthly, monthly, (now - timedelta(days=28)).isoformat()),
|
||
)
|
||
# Admin adjustment
|
||
conn.execute(
|
||
"""INSERT INTO credit_ledger (supplier_id, delta, balance_after, event_type, note, created_at)
|
||
VALUES (?, ?, ?, 'admin_adjustment', 'Welcome bonus', ?)""",
|
||
(sid, 10, monthly + 10, (now - timedelta(days=25)).isoformat()),
|
||
)
|
||
logger.info(" %s: 2 ledger entries", slug)
|
||
|
||
# 6. Add lead forwards for testing
|
||
logger.info("Adding lead forwards...")
|
||
padeltech_id = supplier_ids.get("padeltech-gmbh")
|
||
if padeltech_id and len(lead_ids) >= 2:
|
||
for lead_id in lead_ids[:2]:
|
||
existing = conn.execute(
|
||
"SELECT 1 FROM lead_forwards WHERE lead_id = ? AND supplier_id = ?",
|
||
(lead_id, padeltech_id),
|
||
).fetchone()
|
||
if not existing:
|
||
conn.execute(
|
||
"""INSERT INTO lead_forwards (lead_id, supplier_id, credit_cost, status, created_at)
|
||
VALUES (?, ?, ?, 'sent', ?)""",
|
||
(lead_id, padeltech_id, 35, (now - timedelta(hours=6)).isoformat()),
|
||
)
|
||
conn.execute(
|
||
"UPDATE lead_requests SET unlock_count = unlock_count + 1 WHERE id = ?",
|
||
(lead_id,),
|
||
)
|
||
# Ledger entry for spend
|
||
conn.execute(
|
||
"""INSERT INTO credit_ledger (supplier_id, delta, balance_after, event_type, reference_id, note, created_at)
|
||
VALUES (?, -35, ?, 'lead_unlock', ?, ?, ?)""",
|
||
(padeltech_id, 80, lead_id, f"Unlocked lead #{lead_id}",
|
||
(now - timedelta(hours=6)).isoformat()),
|
||
)
|
||
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()
|
||
|
||
logger.info("Done! Seed data written to %s", db_path)
|
||
logger.info(" Login: /auth/dev-login?email=dev@localhost")
|
||
logger.info(" Admin: set ADMIN_EMAILS=dev@localhost in .env, then dev-login grants admin role")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
logging.basicConfig(level=logging.INFO, format="%(levelname)-8s %(message)s")
|
||
main()
|