From 04f59e9015821983cccc7f213704e2cd582c935d Mon Sep 17 00:00:00 2001 From: Deeman Date: Tue, 24 Feb 2026 19:30:18 +0100 Subject: [PATCH] feat(pseo): add pseo_routes.py blueprint + register in app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New blueprint at /admin/pseo with: - GET /admin/pseo/ → dashboard (stats, freshness, recent jobs) - GET /admin/pseo/health → HTMX partial: health issue lists - GET /admin/pseo/gaps/ → HTMX partial: content gaps - POST /admin/pseo/gaps//generate → enqueue gap-fill job - GET /admin/pseo/jobs → full jobs list - GET /admin/pseo/jobs//status → HTMX polled progress bar Co-Authored-By: Claude Sonnet 4.6 --- web/src/padelnomics/admin/pseo_routes.py | 209 +++++++++++++++++++++++ web/src/padelnomics/app.py | 12 +- 2 files changed, 220 insertions(+), 1 deletion(-) create mode 100644 web/src/padelnomics/admin/pseo_routes.py diff --git a/web/src/padelnomics/admin/pseo_routes.py b/web/src/padelnomics/admin/pseo_routes.py new file mode 100644 index 0000000..af7fdf0 --- /dev/null +++ b/web/src/padelnomics/admin/pseo_routes.py @@ -0,0 +1,209 @@ +""" +pSEO Engine admin blueprint. + +Operational visibility for the programmatic SEO system: + /admin/pseo/ → dashboard (template stats, freshness, recent jobs) + /admin/pseo/health → HTMX partial: health issues + /admin/pseo/gaps/ → HTMX partial: content gaps for one template + /admin/pseo/gaps//generate → POST: enqueue gap-fill job + /admin/pseo/jobs → recent generation jobs + /admin/pseo/jobs//status → HTMX polled: progress bar for one job + +Registered as a standalone blueprint so admin/routes.py (already ~2,100 lines) +stays focused on its own domain. +""" +from datetime import date +from pathlib import Path + +from quart import Blueprint, flash, redirect, render_template, url_for + +from ..auth.routes import role_required +from ..content import discover_templates, load_template +from ..content.health import ( + get_all_health_issues, + get_content_gaps, + get_template_freshness, + get_template_stats, +) +from ..core import csrf_protect, fetch_all, fetch_one + +bp = Blueprint( + "pseo", + __name__, + template_folder=str(Path(__file__).parent / "templates"), + url_prefix="/admin/pseo", +) + + +@bp.before_request +async def _inject_sidebar_data(): + """Load unread inbox count for the admin sidebar badge.""" + from quart import g + + try: + row = await fetch_one("SELECT COUNT(*) as cnt FROM inbound_emails WHERE is_read = 0") + g.admin_unread_count = row["cnt"] if row else 0 + except Exception: + g.admin_unread_count = 0 + + +@bp.context_processor +def _admin_context(): + """Expose admin-specific variables to all pSEO templates.""" + from quart import g + + return {"unread_count": getattr(g, "admin_unread_count", 0)} + + +# ── Dashboard ──────────────────────────────────────────────────────────────── + + +@bp.route("/") +@role_required("admin") +async def pseo_dashboard(): + """pSEO Engine dashboard: template stats, freshness, recent jobs.""" + templates = discover_templates() + + freshness = await get_template_freshness(templates) + freshness_by_slug = {f["slug"]: f for f in freshness} + + template_rows = [] + for t in templates: + stats = await get_template_stats(t["slug"]) + template_rows.append({ + "template": t, + "stats": stats, + "freshness": freshness_by_slug.get(t["slug"], {}), + }) + + total_articles = sum(r["stats"]["total"] for r in template_rows) + total_published = sum(r["stats"]["published"] for r in template_rows) + stale_count = sum(1 for f in freshness if f["status"] == "stale") + + # Recent generation jobs — enough for the dashboard summary. + jobs = await fetch_all( + "SELECT id, task_name, status, progress_current, progress_total," + " error, error_log, created_at, completed_at" + " FROM tasks WHERE task_name = 'generate_articles'" + " ORDER BY created_at DESC LIMIT 5", + ) + + return await render_template( + "admin/pseo_dashboard.html", + template_rows=template_rows, + total_articles=total_articles, + total_published=total_published, + total_templates=len(templates), + stale_count=stale_count, + jobs=jobs, + admin_page="pseo", + ) + + +# ── Health checks (HTMX partial) ───────────────────────────────────────────── + + +@bp.route("/health") +@role_required("admin") +async def pseo_health(): + """HTMX partial: all health issue lists.""" + templates = discover_templates() + health = await get_all_health_issues(templates) + return await render_template("admin/pseo_health.html", health=health) + + +# ── Content gaps (HTMX partial + generate action) ──────────────────────────── + + +@bp.route("/gaps/") +@role_required("admin") +async def pseo_gaps_template(slug: str): + """HTMX partial: content gaps for a specific template.""" + try: + config = load_template(slug) + except (AssertionError, FileNotFoundError): + return "Template not found", 404 + + gaps = await get_content_gaps( + template_slug=slug, + data_table=config["data_table"], + natural_key=config["natural_key"], + languages=config["languages"], + ) + return await render_template( + "admin/pseo_gaps.html", + template=config, + gaps=gaps, + ) + + +@bp.route("/gaps//generate", methods=["POST"]) +@role_required("admin") +@csrf_protect +async def pseo_generate_gaps(slug: str): + """Enqueue a generation job limited to filling gaps for this template.""" + from ..worker import enqueue + + try: + config = load_template(slug) + except (AssertionError, FileNotFoundError): + await flash("Template not found.", "error") + return redirect(url_for("pseo.pseo_dashboard")) + + gaps = await get_content_gaps( + template_slug=slug, + data_table=config["data_table"], + natural_key=config["natural_key"], + languages=config["languages"], + ) + + if not gaps: + await flash(f"No gaps found for '{config['name']}' — nothing to generate.", "info") + return redirect(url_for("pseo.pseo_dashboard")) + + await enqueue("generate_articles", { + "template_slug": slug, + "start_date": date.today().isoformat(), + "articles_per_day": 500, + "limit": 500, + }) + await flash( + f"Queued generation for {len(gaps)} missing articles in '{config['name']}'.", + "success", + ) + return redirect(url_for("pseo.pseo_dashboard")) + + +# ── Generation job monitoring ───────────────────────────────────────────────── + + +@bp.route("/jobs") +@role_required("admin") +async def pseo_jobs(): + """Full list of recent article generation jobs.""" + jobs = await fetch_all( + "SELECT id, task_name, status, progress_current, progress_total," + " error, error_log, created_at, completed_at" + " FROM tasks WHERE task_name = 'generate_articles'" + " ORDER BY created_at DESC LIMIT 20", + ) + return await render_template( + "admin/pseo_jobs.html", + jobs=jobs, + admin_page="pseo", + ) + + +@bp.route("/jobs//status") +@role_required("admin") +async def pseo_job_status(job_id: int): + """HTMX polled endpoint: progress bar for a running generation job.""" + job = await fetch_one( + "SELECT id, status, progress_current, progress_total, error, error_log," + " created_at, completed_at" + " FROM tasks WHERE id = ?", + (job_id,), + ) + if not job: + return "Job not found", 404 + return await render_template("admin/pseo_job_status.html", job=job) diff --git a/web/src/padelnomics/app.py b/web/src/padelnomics/app.py index 3906001..3413d81 100644 --- a/web/src/padelnomics/app.py +++ b/web/src/padelnomics/app.py @@ -7,7 +7,15 @@ 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, is_flag_enabled, setup_logging, setup_request_id +from .core import ( + close_db, + config, + get_csrf_token, + init_db, + is_flag_enabled, + setup_logging, + setup_request_id, +) setup_logging() from .i18n import LANG_BLUEPRINTS, SUPPORTED_LANGS, get_country_name, get_translations @@ -303,6 +311,7 @@ def create_app() -> Quart: # Blueprint registration # ------------------------------------------------------------------------- + from .admin.pseo_routes import bp as pseo_bp from .admin.routes import bp as admin_bp from .auth.routes import bp as auth_bp from .billing.routes import bp as billing_bp @@ -327,6 +336,7 @@ def create_app() -> Quart: app.register_blueprint(dashboard_bp) app.register_blueprint(billing_bp) app.register_blueprint(admin_bp) + app.register_blueprint(pseo_bp) app.register_blueprint(webhooks_bp) # Content catch-all LAST — lives under / too