Files
padelnomics/web/src/padelnomics/app.py
Deeman 4ae00b35d1 refactor: flatten padelnomics/padelnomics/ → repo root
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>
2026-02-22 00:44:40 +01:00

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)