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 quart import Quart, Response, abort, g, redirect, request, session, url_for
|
||||||
|
|
||||||
from .analytics import close_analytics_db, open_analytics_db
|
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()
|
setup_logging()
|
||||||
from .i18n import LANG_BLUEPRINTS, SUPPORTED_LANGS, get_country_name, get_translations
|
from .i18n import LANG_BLUEPRINTS, SUPPORTED_LANGS, get_country_name, get_translations
|
||||||
@@ -303,6 +311,7 @@ def create_app() -> Quart:
|
|||||||
# Blueprint registration
|
# Blueprint registration
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
from .admin.pseo_routes import bp as pseo_bp
|
||||||
from .admin.routes import bp as admin_bp
|
from .admin.routes import bp as admin_bp
|
||||||
from .auth.routes import bp as auth_bp
|
from .auth.routes import bp as auth_bp
|
||||||
from .billing.routes import bp as billing_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(dashboard_bp)
|
||||||
app.register_blueprint(billing_bp)
|
app.register_blueprint(billing_bp)
|
||||||
app.register_blueprint(admin_bp)
|
app.register_blueprint(admin_bp)
|
||||||
|
app.register_blueprint(pseo_bp)
|
||||||
app.register_blueprint(webhooks_bp)
|
app.register_blueprint(webhooks_bp)
|
||||||
|
|
||||||
# Content catch-all LAST — lives under /<lang> too
|
# Content catch-all LAST — lives under /<lang> too
|
||||||
|
|||||||
Reference in New Issue
Block a user