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:
Deeman
2026-02-24 19:30:18 +01:00
parent 9cc853d38e
commit 04f59e9015
2 changed files with 220 additions and 1 deletions

View 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)

View File

@@ -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