git mv all tracked files from the nested padelnomics/ workspace directory to the git repo root. Merged .gitignore files. No code changes — pure path rename. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
387 lines
14 KiB
Python
387 lines
14 KiB
Python
"""
|
|
Padelnomics - Application factory and entry point.
|
|
"""
|
|
from pathlib import Path
|
|
|
|
from quart import Quart, Response, abort, g, redirect, request, session, url_for
|
|
|
|
from .analytics import close_analytics_db, open_analytics_db
|
|
from .core import close_db, config, get_csrf_token, init_db, setup_request_id
|
|
from .i18n import LANG_BLUEPRINTS, SUPPORTED_LANGS, get_translations
|
|
|
|
|
|
def _detect_lang() -> str:
|
|
"""Detect preferred language from cookie then Accept-Language header."""
|
|
cookie_lang = request.cookies.get("lang", "")
|
|
if cookie_lang in SUPPORTED_LANGS:
|
|
return cookie_lang
|
|
accept = request.headers.get("Accept-Language", "")
|
|
for part in accept.split(","):
|
|
tag = part.split(";")[0].strip()[:2].lower()
|
|
if tag in SUPPORTED_LANGS:
|
|
return tag
|
|
return "en"
|
|
|
|
|
|
def _fmt_currency(n) -> str:
|
|
"""Format currency using request-context symbol and locale style."""
|
|
sym = getattr(g, "currency_sym", "\u20ac")
|
|
eu_style = getattr(g, "currency_eu_style", True)
|
|
n = round(float(n))
|
|
s = f"{abs(n):,}"
|
|
if eu_style:
|
|
s = s.replace(",", ".")
|
|
return f"-{sym}{s}" if n < 0 else f"{sym}{s}"
|
|
|
|
|
|
def _fmt_k(n) -> str:
|
|
"""Short currency: €50K, €1.2M, or full _fmt_currency."""
|
|
sym = getattr(g, "currency_sym", "\u20ac")
|
|
n = float(n)
|
|
if abs(n) >= 1_000_000:
|
|
return f"{sym}{n/1_000_000:.1f}M"
|
|
if abs(n) >= 1_000:
|
|
return f"{sym}{n/1_000:.0f}K"
|
|
return _fmt_currency(n)
|
|
|
|
|
|
def _fmt_pct(n) -> str:
|
|
"""Format fraction as percentage: 0.152 → '15.2%'."""
|
|
return f"{float(n) * 100:.1f}%"
|
|
|
|
|
|
def _fmt_x(n) -> str:
|
|
"""Format as MOIC multiple: 2.30x."""
|
|
return f"{float(n):.2f}x"
|
|
|
|
|
|
def _fmt_n(n) -> str:
|
|
"""Format integer with locale-aware thousands separator: 1.234 or 1,234."""
|
|
eu_style = getattr(g, "currency_eu_style", True)
|
|
s = f"{round(float(n)):,}"
|
|
return s.replace(",", ".") if eu_style else s
|
|
|
|
|
|
def _tformat(s: str, **kwargs) -> str:
|
|
"""Format a translation string with named placeholders.
|
|
|
|
Usage: {{ t.some_key | tformat(count=total, name=supplier.name) }}
|
|
JSON value: "Browse {count}+ suppliers from {name}"
|
|
"""
|
|
return s.format_map(kwargs)
|
|
|
|
|
|
def create_app() -> Quart:
|
|
"""Create and configure the Quart application."""
|
|
|
|
pkg_dir = Path(__file__).parent
|
|
|
|
app = Quart(
|
|
__name__,
|
|
template_folder=str(pkg_dir / "templates"),
|
|
static_folder=str(pkg_dir / "static"),
|
|
)
|
|
|
|
app.secret_key = config.SECRET_KEY
|
|
|
|
# Jinja2 filters
|
|
app.jinja_env.filters["fmt_currency"] = _fmt_currency
|
|
app.jinja_env.filters["fmt_k"] = _fmt_k
|
|
app.jinja_env.filters["fmt_pct"] = _fmt_pct
|
|
app.jinja_env.filters["fmt_x"] = _fmt_x
|
|
app.jinja_env.filters["fmt_n"] = _fmt_n
|
|
app.jinja_env.filters["tformat"] = _tformat # translate with placeholders: {{ t.key | tformat(count=n) }}
|
|
|
|
# Session config
|
|
app.config["SESSION_COOKIE_SECURE"] = not config.DEBUG
|
|
app.config["SESSION_COOKIE_HTTPONLY"] = True
|
|
app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
|
|
app.config["PERMANENT_SESSION_LIFETIME"] = 60 * 60 * 24 * config.SESSION_LIFETIME_DAYS
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Language URL routing
|
|
# -------------------------------------------------------------------------
|
|
|
|
@app.url_value_preprocessor
|
|
def pull_lang(endpoint, values):
|
|
"""Pop <lang> from URL values and stash in g.lang."""
|
|
if values and "lang" in values:
|
|
g.lang = values.pop("lang")
|
|
|
|
@app.url_defaults
|
|
def inject_lang(endpoint, values):
|
|
"""Auto-inject g.lang into url_for() calls on lang-prefixed blueprints."""
|
|
if endpoint and endpoint.partition(".")[0] in LANG_BLUEPRINTS:
|
|
values.setdefault("lang", g.get("lang", "en"))
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Database lifecycle
|
|
# -------------------------------------------------------------------------
|
|
|
|
@app.before_serving
|
|
async def startup():
|
|
await init_db()
|
|
open_analytics_db()
|
|
|
|
@app.after_serving
|
|
async def shutdown():
|
|
await close_db()
|
|
close_analytics_db()
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Per-request hooks
|
|
# -------------------------------------------------------------------------
|
|
|
|
@app.before_request
|
|
async def validate_lang():
|
|
"""404 unsupported language prefixes (e.g. /fr/terms)."""
|
|
lang = g.get("lang")
|
|
if lang is not None and lang not in SUPPORTED_LANGS:
|
|
abort(404)
|
|
|
|
@app.before_request
|
|
async def load_user():
|
|
"""Load current user + subscription + roles before each request."""
|
|
g.user = None
|
|
g.subscription = None
|
|
user_id = session.get("user_id")
|
|
if user_id:
|
|
from .core import fetch_one as _fetch_one
|
|
row = await _fetch_one(
|
|
"""SELECT u.*,
|
|
bc.provider_customer_id,
|
|
(SELECT GROUP_CONCAT(role) FROM user_roles WHERE user_id = u.id) AS roles_csv,
|
|
s.id AS sub_id, s.plan, s.status AS sub_status,
|
|
s.provider_subscription_id, s.current_period_end
|
|
FROM users u
|
|
LEFT JOIN billing_customers bc ON bc.user_id = u.id
|
|
LEFT JOIN subscriptions s ON s.id = (
|
|
SELECT id FROM subscriptions
|
|
WHERE user_id = u.id
|
|
ORDER BY created_at DESC LIMIT 1
|
|
)
|
|
WHERE u.id = ? AND u.deleted_at IS NULL""",
|
|
(user_id,),
|
|
)
|
|
if row:
|
|
g.user = dict(row)
|
|
g.user["roles"] = row["roles_csv"].split(",") if row["roles_csv"] else []
|
|
if row["sub_id"]:
|
|
g.subscription = {
|
|
"id": row["sub_id"], "plan": row["plan"],
|
|
"status": row["sub_status"],
|
|
"provider_subscription_id": row["provider_subscription_id"],
|
|
"current_period_end": row["current_period_end"],
|
|
}
|
|
|
|
@app.after_request
|
|
async def add_security_headers(response):
|
|
response.headers["X-Content-Type-Options"] = "nosniff"
|
|
response.headers["X-Frame-Options"] = "DENY"
|
|
response.headers["X-XSS-Protection"] = "1; mode=block"
|
|
if not config.DEBUG:
|
|
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
|
|
return response
|
|
|
|
@app.after_request
|
|
async def set_lang_cookie(response):
|
|
"""Persist detected/current language in a long-lived cookie."""
|
|
lang = g.get("lang")
|
|
if lang and lang in SUPPORTED_LANGS:
|
|
current_cookie = request.cookies.get("lang", "")
|
|
if current_cookie != lang:
|
|
response.set_cookie(
|
|
"lang", lang,
|
|
max_age=60 * 60 * 24 * 365, # 1 year
|
|
samesite="Lax",
|
|
secure=not config.DEBUG,
|
|
httponly=False, # JS may read for analytics
|
|
)
|
|
return response
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Template context globals
|
|
# -------------------------------------------------------------------------
|
|
|
|
@app.context_processor
|
|
def inject_globals():
|
|
from datetime import datetime
|
|
lang = g.get("lang") or _detect_lang()
|
|
g.lang = lang # ensure g.lang is always set (e.g. for dashboard/billing routes)
|
|
effective_lang = lang if lang in SUPPORTED_LANGS else "en"
|
|
return {
|
|
"config": config,
|
|
"user": g.get("user"),
|
|
"subscription": g.get("subscription"),
|
|
"is_admin": "admin" in (g.get("user") or {}).get("roles", []),
|
|
"now": datetime.utcnow(),
|
|
"csrf_token": get_csrf_token,
|
|
"ab_variant": getattr(g, "ab_variant", None),
|
|
"ab_tag": getattr(g, "ab_tag", None),
|
|
"lang": effective_lang,
|
|
"t": get_translations(effective_lang),
|
|
}
|
|
|
|
# -------------------------------------------------------------------------
|
|
# App-level routes (no lang prefix)
|
|
# -------------------------------------------------------------------------
|
|
|
|
# Root: detect language and redirect
|
|
@app.route("/")
|
|
async def root():
|
|
lang = _detect_lang()
|
|
return redirect(url_for("public.landing", lang=lang), 301)
|
|
|
|
# robots.txt must live at root (not under /<lang>)
|
|
@app.route("/robots.txt")
|
|
async def robots_txt():
|
|
base = config.BASE_URL.rstrip("/")
|
|
body = (
|
|
"User-agent: *\n"
|
|
"Disallow: /admin/\n"
|
|
"Disallow: /auth/\n"
|
|
"Disallow: /dashboard/\n"
|
|
"Disallow: /billing/\n"
|
|
"Disallow: /directory/results\n"
|
|
f"Sitemap: {base}/sitemap.xml\n"
|
|
)
|
|
return Response(body, content_type="text/plain")
|
|
|
|
# sitemap.xml must live at root
|
|
@app.route("/sitemap.xml")
|
|
async def sitemap():
|
|
from datetime import UTC, datetime
|
|
|
|
from .core import fetch_all
|
|
base = config.BASE_URL.rstrip("/")
|
|
today = datetime.now(UTC).strftime("%Y-%m-%d")
|
|
|
|
# Both language variants of all SEO pages
|
|
static_paths = [
|
|
"", # landing
|
|
"/features",
|
|
"/about",
|
|
"/terms",
|
|
"/privacy",
|
|
"/imprint",
|
|
"/suppliers",
|
|
"/markets",
|
|
]
|
|
entries: list[tuple[str, str]] = []
|
|
for path in static_paths:
|
|
for lang in ("en", "de"):
|
|
entries.append((f"{base}/{lang}{path}", today))
|
|
|
|
# Planner + directory lang variants, billing (no lang)
|
|
for lang in ("en", "de"):
|
|
entries.append((f"{base}/{lang}/planner/", today))
|
|
entries.append((f"{base}/{lang}/directory/", today))
|
|
entries.append((f"{base}/billing/pricing", today))
|
|
|
|
# Published articles — both lang variants
|
|
articles = await fetch_all(
|
|
"""SELECT url_path, COALESCE(updated_at, published_at) as lastmod
|
|
FROM articles
|
|
WHERE status = 'published' AND published_at <= datetime('now')
|
|
ORDER BY published_at DESC"""
|
|
)
|
|
for article in articles:
|
|
lastmod = article["lastmod"][:10] if article["lastmod"] else today
|
|
for lang in ("en", "de"):
|
|
entries.append((f"{base}/{lang}{article['url_path']}", lastmod))
|
|
|
|
# Supplier detail pages (English only — canonical)
|
|
suppliers = await fetch_all(
|
|
"SELECT slug, created_at FROM suppliers ORDER BY name LIMIT 5000"
|
|
)
|
|
for supplier in suppliers:
|
|
lastmod = supplier["created_at"][:10] if supplier["created_at"] else today
|
|
entries.append((f"{base}/en/directory/{supplier['slug']}", lastmod))
|
|
|
|
xml = '<?xml version="1.0" encoding="UTF-8"?>\n'
|
|
xml += '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
|
|
for loc, lastmod in entries:
|
|
xml += f" <url><loc>{loc}</loc><lastmod>{lastmod}</lastmod></url>\n"
|
|
xml += "</urlset>"
|
|
return Response(xml, content_type="application/xml")
|
|
|
|
# Health check
|
|
@app.route("/health")
|
|
async def health():
|
|
from .core import fetch_one
|
|
try:
|
|
await fetch_one("SELECT 1")
|
|
return {"status": "healthy", "db": "ok"}
|
|
except Exception as e:
|
|
return {"status": "unhealthy", "db": str(e)}, 500
|
|
|
|
# Legacy 301 redirects — bookmarked/cached URLs before lang prefixes existed
|
|
@app.route("/terms")
|
|
async def legacy_terms():
|
|
return redirect("/en/terms", 301)
|
|
|
|
@app.route("/privacy")
|
|
async def legacy_privacy():
|
|
return redirect("/en/privacy", 301)
|
|
|
|
@app.route("/imprint")
|
|
async def legacy_imprint():
|
|
return redirect("/en/imprint", 301)
|
|
|
|
@app.route("/about")
|
|
async def legacy_about():
|
|
return redirect("/en/about", 301)
|
|
|
|
@app.route("/features")
|
|
async def legacy_features():
|
|
return redirect("/en/features", 301)
|
|
|
|
@app.route("/suppliers")
|
|
async def legacy_suppliers():
|
|
return redirect("/en/suppliers", 301)
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Blueprint registration
|
|
# -------------------------------------------------------------------------
|
|
|
|
from .admin.routes import bp as admin_bp
|
|
from .auth.routes import bp as auth_bp
|
|
from .billing.routes import bp as billing_bp
|
|
from .content.routes import bp as content_bp
|
|
from .dashboard.routes import bp as dashboard_bp
|
|
from .directory.routes import bp as directory_bp
|
|
from .leads.routes import bp as leads_bp
|
|
from .planner.routes import bp as planner_bp
|
|
from .public.routes import bp as public_bp
|
|
from .suppliers.routes import bp as suppliers_bp
|
|
|
|
# Lang-prefixed blueprints (SEO-relevant, public-facing)
|
|
app.register_blueprint(public_bp, url_prefix="/<lang>")
|
|
app.register_blueprint(planner_bp, url_prefix="/<lang>/planner")
|
|
app.register_blueprint(directory_bp, url_prefix="/<lang>/directory")
|
|
app.register_blueprint(leads_bp, url_prefix="/<lang>/leads")
|
|
app.register_blueprint(suppliers_bp, url_prefix="/<lang>/suppliers")
|
|
|
|
# Non-prefixed blueprints (internal / behind auth)
|
|
app.register_blueprint(auth_bp)
|
|
app.register_blueprint(dashboard_bp)
|
|
app.register_blueprint(billing_bp)
|
|
app.register_blueprint(admin_bp)
|
|
|
|
# Content catch-all LAST — lives under /<lang> too
|
|
app.register_blueprint(content_bp, url_prefix="/<lang>")
|
|
|
|
# Request ID tracking
|
|
setup_request_id(app)
|
|
|
|
return app
|
|
|
|
|
|
app = create_app()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
import os
|
|
port = int(os.environ.get("PORT", 5000))
|
|
app.run(debug=config.DEBUG, port=port)
|