feat: Python supervisor + feature flags
Supervisor (replaces supervisor.sh): - supervisor.py — cron-based pipeline orchestration, reads workflows.toml on every tick, runs due extractors in topological waves with parallel execution, then SQLMesh transform + serving export - workflows.toml — workflow registry: overpass (monthly), eurostat (monthly), playtomic_tenants (weekly), playtomic_availability (daily), playtomic_recheck (hourly 6–23) - padelnomics-supervisor.service — updated ExecStart to Python supervisor Extraction enhancements: - proxy.py — optional round-robin/sticky proxy rotation via PROXY_URLS env - playtomic_availability.py — parallel fetch (EXTRACT_WORKERS), recheck mode (main_recheck) re-queries imminent slots for accurate occupancy measurement - _shared.py — realistic browser User-Agent on all extractor sessions - stg_playtomic_availability.sql — reads morning + recheck snapshots, tags each - fct_daily_availability.sql — prefers recheck over morning for same slot Feature flags (replaces WAITLIST_MODE env var): - migration 0019 — feature_flags table, 5 initial flags: markets (on), payments/planner_export/supplier_signup/lead_unlock (off) - core.py — is_flag_enabled() + feature_gate() decorator - routes — payments, markets, planner_export, supplier_signup, lead_unlock gated - admin flags UI — /admin/flags toggle page + nav link - app.py — flag() injected as Jinja2 global Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -18,6 +18,7 @@ dependencies = [
|
||||
"duckdb>=1.0.0",
|
||||
"pyarrow>=23.0.1",
|
||||
"pyyaml>=6.0",
|
||||
"croniter>=6.0.0",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
|
||||
@@ -807,6 +807,46 @@ async def supplier_tier(supplier_id: int):
|
||||
return redirect(url_for("admin.supplier_detail", supplier_id=supplier_id))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Feature Flags
|
||||
# =============================================================================
|
||||
|
||||
@bp.route("/flags")
|
||||
@role_required("admin")
|
||||
async def flags():
|
||||
"""Feature flags management."""
|
||||
flag_list = await fetch_all("SELECT * FROM feature_flags ORDER BY name")
|
||||
return await render_template("admin/flags.html", flags=flag_list, admin_page="flags")
|
||||
|
||||
|
||||
@bp.route("/flags/toggle", methods=["POST"])
|
||||
@role_required("admin")
|
||||
@csrf_protect
|
||||
async def flag_toggle():
|
||||
"""Toggle a feature flag on/off."""
|
||||
form = await request.form
|
||||
flag_name = form.get("name", "").strip()
|
||||
if not flag_name:
|
||||
await flash("Flag name required.", "error")
|
||||
return redirect(url_for("admin.flags"))
|
||||
|
||||
# Get current state and flip it
|
||||
row = await fetch_one("SELECT enabled FROM feature_flags WHERE name = ?", (flag_name,))
|
||||
if not row:
|
||||
await flash(f"Flag '{flag_name}' not found.", "error")
|
||||
return redirect(url_for("admin.flags"))
|
||||
|
||||
new_enabled = 0 if row["enabled"] else 1
|
||||
now = datetime.utcnow().isoformat()
|
||||
await execute(
|
||||
"UPDATE feature_flags SET enabled = ?, updated_at = ? WHERE name = ?",
|
||||
(new_enabled, now, flag_name),
|
||||
)
|
||||
state = "enabled" if new_enabled else "disabled"
|
||||
await flash(f"Flag '{flag_name}' {state}.", "success")
|
||||
return redirect(url_for("admin.flags"))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Feedback Management
|
||||
# =============================================================================
|
||||
|
||||
@@ -87,6 +87,10 @@
|
||||
</a>
|
||||
|
||||
<div class="admin-sidebar__section">System</div>
|
||||
<a href="{{ url_for('admin.flags') }}" class="{% if admin_page == 'flags' %}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="M3 3v1.5M3 21v-6m0 0 2.77-.693a9 9 0 0 1 6.208.682l.108.054a9 9 0 0 0 6.086.71l3.114-.732a48.524 48.524 0 0 1-.005-10.499l-3.11.732a9 9 0 0 1-6.085-.711l-.108-.054a9 9 0 0 0-6.208-.682L3 4.5M3 15V4.5"/></svg>
|
||||
Flags
|
||||
</a>
|
||||
<a href="{{ url_for('admin.tasks') }}" class="{% if admin_page == 'tasks' %}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="M10.5 6h9.75M10.5 6a1.5 1.5 0 1 1-3 0m3 0a1.5 1.5 0 1 0-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-9.75 0h9.75"/></svg>
|
||||
Tasks
|
||||
|
||||
72
web/src/padelnomics/admin/templates/admin/flags.html
Normal file
72
web/src/padelnomics/admin/templates/admin/flags.html
Normal file
@@ -0,0 +1,72 @@
|
||||
{% extends "admin/base_admin.html" %}
|
||||
|
||||
{% block admin_head %}
|
||||
<style>
|
||||
.flags-table { width: 100%; border-collapse: collapse; }
|
||||
.flags-table th, .flags-table td { padding: 12px 16px; text-align: left; border-bottom: 1px solid #E2E8F0; }
|
||||
.flags-table th { font-size: 0.75rem; font-weight: 600; color: #64748B; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.flag-badge { display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px; border-radius: 9999px; font-size: 0.75rem; font-weight: 600; }
|
||||
.flag-badge--on { background: #DCFCE7; color: #166534; }
|
||||
.flag-badge--off { background: #FEE2E2; color: #991B1B; }
|
||||
.flag-dot { width: 8px; height: 8px; border-radius: 50%; }
|
||||
.flag-dot--on { background: #22C55E; }
|
||||
.flag-dot--off { background: #EF4444; }
|
||||
.flag-toggle-btn {
|
||||
padding: 6px 14px; border-radius: 6px; font-size: 0.8125rem; font-weight: 500;
|
||||
border: 1px solid #E2E8F0; cursor: pointer; transition: all 0.15s;
|
||||
}
|
||||
.flag-toggle-btn--enable { background: #F0FDF4; color: #166534; border-color: #BBF7D0; }
|
||||
.flag-toggle-btn--enable:hover { background: #DCFCE7; }
|
||||
.flag-toggle-btn--disable { background: #FEF2F2; color: #991B1B; border-color: #FECACA; }
|
||||
.flag-toggle-btn--disable:hover { background: #FEE2E2; }
|
||||
.flag-name { font-weight: 600; color: #0F172A; }
|
||||
.flag-desc { font-size: 0.8125rem; color: #64748B; }
|
||||
.flag-updated { font-size: 0.75rem; color: #94A3B8; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block admin_content %}
|
||||
<h1 style="font-size:1.5rem; font-weight:700; margin-bottom:0.5rem;">Feature Flags</h1>
|
||||
<p style="color:#64748B; margin-bottom:1.5rem; font-size:0.875rem;">
|
||||
Toggle features on/off without redeployment. Changes take effect immediately.
|
||||
</p>
|
||||
|
||||
<table class="flags-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Flag</th>
|
||||
<th>Description</th>
|
||||
<th>Status</th>
|
||||
<th>Last Updated</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for f in flags %}
|
||||
<tr>
|
||||
<td><span class="flag-name">{{ f.name }}</span></td>
|
||||
<td><span class="flag-desc">{{ f.description or '—' }}</span></td>
|
||||
<td>
|
||||
{% if f.enabled %}
|
||||
<span class="flag-badge flag-badge--on"><span class="flag-dot flag-dot--on"></span> Enabled</span>
|
||||
{% else %}
|
||||
<span class="flag-badge flag-badge--off"><span class="flag-dot flag-dot--off"></span> Disabled</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td><span class="flag-updated">{{ f.updated_at or '—' }}</span></td>
|
||||
<td>
|
||||
<form method="POST" action="{{ url_for('admin.flag_toggle') }}" style="display:inline;">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="name" value="{{ f.name }}">
|
||||
{% if f.enabled %}
|
||||
<button type="submit" class="flag-toggle-btn flag-toggle-btn--disable">Disable</button>
|
||||
{% else %}
|
||||
<button type="submit" class="flag-toggle-btn flag-toggle-btn--enable">Enable</button>
|
||||
{% endif %}
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endblock %}
|
||||
@@ -7,7 +7,7 @@ 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 .core import close_db, config, get_csrf_token, init_db, is_flag_enabled, setup_request_id
|
||||
from .i18n import LANG_BLUEPRINTS, SUPPORTED_LANGS, get_translations
|
||||
|
||||
_ASSET_VERSION = str(int(time.time()))
|
||||
@@ -224,6 +224,7 @@ def create_app() -> Quart:
|
||||
"lang": effective_lang,
|
||||
"t": get_translations(effective_lang),
|
||||
"v": _ASSET_VERSION,
|
||||
"flag": is_flag_enabled,
|
||||
}
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
@@ -14,9 +14,10 @@ from ..core import (
|
||||
config,
|
||||
csrf_protect,
|
||||
execute,
|
||||
feature_gate,
|
||||
fetch_one,
|
||||
is_disposable_email,
|
||||
waitlist_gate,
|
||||
is_flag_enabled,
|
||||
)
|
||||
from ..i18n import SUPPORTED_LANGS, get_translations
|
||||
|
||||
@@ -248,14 +249,14 @@ async def login():
|
||||
|
||||
@bp.route("/signup", methods=["GET", "POST"])
|
||||
@csrf_protect
|
||||
@waitlist_gate("waitlist.html", plan=lambda: request.args.get("plan", "free"))
|
||||
@feature_gate("payments", "waitlist.html", plan=lambda: request.args.get("plan", "free"))
|
||||
async def signup():
|
||||
"""Signup page - same as login but with different messaging."""
|
||||
if g.get("user"):
|
||||
return redirect(url_for("dashboard.index"))
|
||||
|
||||
# Waitlist POST handling
|
||||
if config.WAITLIST_MODE and request.method == "POST":
|
||||
# Waitlist POST handling (when payments flag is disabled)
|
||||
if not await is_flag_enabled("payments") and request.method == "POST":
|
||||
_t = get_translations(g.lang)
|
||||
form = await request.form
|
||||
email = form.get("email", "").strip().lower()
|
||||
|
||||
@@ -9,7 +9,7 @@ from jinja2 import Environment, FileSystemLoader
|
||||
from markupsafe import Markup
|
||||
from quart import Blueprint, abort, render_template, request
|
||||
|
||||
from ..core import capture_waitlist_email, config, csrf_protect, fetch_all, fetch_one, waitlist_gate
|
||||
from ..core import capture_waitlist_email, csrf_protect, feature_gate, fetch_all, fetch_one
|
||||
from ..i18n import get_translations
|
||||
|
||||
bp = Blueprint(
|
||||
@@ -106,10 +106,11 @@ async def bake_scenario_cards(html: str, lang: str = "en") -> str:
|
||||
|
||||
@bp.route("/markets", methods=["GET", "POST"])
|
||||
@csrf_protect
|
||||
@waitlist_gate("markets_waitlist.html")
|
||||
@feature_gate("markets", "markets_waitlist.html")
|
||||
async def markets():
|
||||
"""Hub page: search + country/region filter for articles."""
|
||||
if config.WAITLIST_MODE and request.method == "POST":
|
||||
from ..core import is_flag_enabled
|
||||
if not await is_flag_enabled("markets") and request.method == "POST":
|
||||
form = await request.form
|
||||
email = form.get("email", "").strip().lower()
|
||||
if email and "@" in email:
|
||||
@@ -147,7 +148,7 @@ async def markets():
|
||||
|
||||
|
||||
@bp.route("/markets/results")
|
||||
@waitlist_gate("markets_waitlist.html")
|
||||
@feature_gate("markets", "markets_waitlist.html")
|
||||
async def market_results():
|
||||
"""HTMX partial: filtered article cards."""
|
||||
q = request.args.get("q", "").strip()
|
||||
|
||||
@@ -698,27 +698,61 @@ def ab_test(experiment: str, variants: tuple = ("control", "treatment")):
|
||||
return decorator
|
||||
|
||||
|
||||
def waitlist_gate(template: str, **extra_context):
|
||||
"""Parameterized decorator that intercepts GET requests when WAITLIST_MODE is enabled.
|
||||
async def is_flag_enabled(name: str, default: bool = False) -> bool:
|
||||
"""Check if a feature flag is enabled. Falls back to default if flag doesn't exist.
|
||||
|
||||
If WAITLIST_MODE is true and the request is a GET, renders the given template
|
||||
instead of calling the wrapped function. POST requests and non-waitlist mode
|
||||
always pass through.
|
||||
Reads from the feature_flags table. Flags are toggled via the admin UI
|
||||
and take effect immediately — no restart needed.
|
||||
"""
|
||||
db = await get_db()
|
||||
row = await db.execute_fetchone(
|
||||
"SELECT enabled FROM feature_flags WHERE name = ?", (name,)
|
||||
)
|
||||
if row is None:
|
||||
return default
|
||||
return bool(row[0])
|
||||
|
||||
|
||||
def feature_gate(flag_name: str, waitlist_template: str, **extra_context):
|
||||
"""Gate a route behind a feature flag. Shows waitlist template if flag is disabled.
|
||||
|
||||
Replaces the old waitlist_gate() which used a global WAITLIST_MODE env var.
|
||||
This checks per-feature flags from the database instead.
|
||||
|
||||
Args:
|
||||
template: Template path to render in waitlist mode (e.g., "waitlist.html")
|
||||
**extra_context: Additional context variables to pass to template.
|
||||
Values can be callables (evaluated at request time) or static.
|
||||
flag_name: Name of the feature flag (e.g., "payments", "supplier_signup")
|
||||
waitlist_template: Template to render when the flag is OFF and method is GET
|
||||
**extra_context: Additional context. Values can be callables (evaluated at request time).
|
||||
|
||||
Usage:
|
||||
@bp.route("/signup", methods=["GET", "POST"])
|
||||
@csrf_protect
|
||||
@waitlist_gate("waitlist.html", plan=lambda: request.args.get("plan", "free"))
|
||||
@feature_gate("payments", "waitlist.html", plan=lambda: request.args.get("plan", "free"))
|
||||
async def signup():
|
||||
# POST handling and normal signup code here
|
||||
...
|
||||
"""
|
||||
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
async def decorated(*args, **kwargs):
|
||||
if not await is_flag_enabled(flag_name) and request.method == "GET":
|
||||
ctx = {}
|
||||
for key, val in extra_context.items():
|
||||
ctx[key] = val() if callable(val) else val
|
||||
return await render_template(waitlist_template, **ctx)
|
||||
return await f(*args, **kwargs)
|
||||
|
||||
return decorated
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def waitlist_gate(template: str, **extra_context):
|
||||
"""DEPRECATED: Use feature_gate() instead. Kept for backwards compatibility.
|
||||
|
||||
Intercepts GET requests when WAITLIST_MODE is enabled.
|
||||
"""
|
||||
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
async def decorated(*args, **kwargs):
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Add feature_flags table for per-feature gating.
|
||||
|
||||
Replaces the global WAITLIST_MODE env var with granular per-feature flags
|
||||
that can be toggled at runtime via the admin UI.
|
||||
"""
|
||||
|
||||
|
||||
def up(conn):
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS feature_flags (
|
||||
name TEXT PRIMARY KEY,
|
||||
enabled INTEGER NOT NULL DEFAULT 0,
|
||||
description TEXT,
|
||||
updated_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
)
|
||||
""")
|
||||
|
||||
# Seed initial flags — markets live, everything else behind waitlist
|
||||
conn.executemany(
|
||||
"INSERT OR IGNORE INTO feature_flags (name, enabled, description) VALUES (?, ?, ?)",
|
||||
[
|
||||
("markets", 1, "Market/SEO content pages"),
|
||||
("payments", 0, "Paddle billing & checkout"),
|
||||
("planner_export", 0, "Business plan PDF export"),
|
||||
("supplier_signup", 0, "Supplier onboarding wizard"),
|
||||
("lead_unlock", 0, "Lead credit purchase & unlock"),
|
||||
],
|
||||
)
|
||||
@@ -14,10 +14,10 @@ from ..core import (
|
||||
config,
|
||||
csrf_protect,
|
||||
execute,
|
||||
feature_gate,
|
||||
fetch_all,
|
||||
fetch_one,
|
||||
get_paddle_price,
|
||||
waitlist_gate,
|
||||
)
|
||||
from ..i18n import get_translations
|
||||
from .calculator import COUNTRY_CURRENCY, CURRENCY_DEFAULT, calc, validate_state
|
||||
@@ -596,7 +596,7 @@ async def set_default(scenario_id: int):
|
||||
|
||||
@bp.route("/export")
|
||||
@login_required
|
||||
@waitlist_gate("export_waitlist.html")
|
||||
@feature_gate("planner_export", "export_waitlist.html")
|
||||
async def export():
|
||||
"""Export options page — language, scenario picker, pricing."""
|
||||
scenarios = await get_scenarios(g.user["id"])
|
||||
|
||||
@@ -15,8 +15,9 @@ from ..core import (
|
||||
execute,
|
||||
fetch_all,
|
||||
fetch_one,
|
||||
feature_gate,
|
||||
get_paddle_price,
|
||||
waitlist_gate,
|
||||
is_flag_enabled,
|
||||
)
|
||||
from ..i18n import get_translations
|
||||
|
||||
@@ -243,8 +244,8 @@ def _lead_tier_required(f):
|
||||
|
||||
|
||||
@bp.route("/signup")
|
||||
@waitlist_gate(
|
||||
"suppliers/waitlist.html",
|
||||
@feature_gate(
|
||||
"supplier_signup", "suppliers/waitlist.html",
|
||||
plan=lambda: request.args.get("plan", "supplier_growth"),
|
||||
plans=lambda: PLAN_FEATURES,
|
||||
)
|
||||
@@ -574,6 +575,9 @@ async def lead_feed():
|
||||
@csrf_protect
|
||||
async def unlock_lead(token: str):
|
||||
"""Spend credits to unlock a lead. Returns full-details card via HTMX."""
|
||||
if not await is_flag_enabled("lead_unlock"):
|
||||
return jsonify({"error": "Lead unlock is not available yet."}), 403
|
||||
|
||||
from ..credits import InsufficientCredits
|
||||
from ..credits import unlock_lead as do_unlock
|
||||
|
||||
|
||||
Reference in New Issue
Block a user