feat(pseo): add pseo_routes.py blueprint + register in app
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/<slug> → HTMX partial: content gaps - POST /admin/pseo/gaps/<slug>/generate → enqueue gap-fill job - GET /admin/pseo/jobs → full jobs list - GET /admin/pseo/jobs/<id>/status → HTMX polled progress bar Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
209
web/src/padelnomics/admin/pseo_routes.py
Normal file
209
web/src/padelnomics/admin/pseo_routes.py
Normal file
@@ -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/<slug> → HTMX partial: content gaps for one template
|
||||
/admin/pseo/gaps/<slug>/generate → POST: enqueue gap-fill job
|
||||
/admin/pseo/jobs → recent generation jobs
|
||||
/admin/pseo/jobs/<id>/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/<slug>")
|
||||
@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/<slug>/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/<int:job_id>/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)
|
||||
@@ -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 /<lang> too
|
||||
|
||||
Reference in New Issue
Block a user