feat: move article generation to background worker queue

- Add generate_articles task handler to worker.py
- template_generate and template_regenerate now enqueue tasks instead
  of running inline (was blocking HTTP request for seconds with 1k articles)
- rebuild_all enqueues per-template + inline rebuilds manual articles
- Update tests to check task enqueue instead of immediate article creation

Subtask 4 of CMS admin improvement.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Deeman
2026-02-24 01:17:59 +01:00
parent 81b361859d
commit f0f6e7542f
3 changed files with 80 additions and 34 deletions

View File

@@ -1314,7 +1314,7 @@ async def template_preview(slug: str, row_key: str):
@csrf_protect @csrf_protect
async def template_generate(slug: str): async def template_generate(slug: str):
"""Generate articles from template + DuckDB data.""" """Generate articles from template + DuckDB data."""
from ..content import fetch_template_data, generate_articles, load_template from ..content import fetch_template_data, load_template
try: try:
config = load_template(slug) config = load_template(slug)
@@ -1330,15 +1330,20 @@ async def template_generate(slug: str):
start_date_str = form.get("start_date", "") start_date_str = form.get("start_date", "")
articles_per_day = int(form.get("articles_per_day", 3) or 3) articles_per_day = int(form.get("articles_per_day", 3) or 3)
if not start_date_str: start_date = date.fromisoformat(start_date_str) if start_date_str else date.today()
start_date = date.today()
else:
start_date = date.fromisoformat(start_date_str)
generated = await generate_articles( from ..worker import enqueue
slug, start_date, articles_per_day, limit=500, await enqueue("generate_articles", {
"template_slug": slug,
"start_date": start_date.isoformat(),
"articles_per_day": articles_per_day,
"limit": 500,
})
await flash(
f"Article generation queued for '{config['name']}'. "
"The worker will process it in the background.",
"success",
) )
await flash(f"Generated {generated} articles with staggered publish dates.", "success")
return redirect(url_for("admin.articles")) return redirect(url_for("admin.articles"))
return await render_template( return await render_template(
@@ -1354,7 +1359,7 @@ async def template_generate(slug: str):
@csrf_protect @csrf_protect
async def template_regenerate(slug: str): async def template_regenerate(slug: str):
"""Re-generate all articles for a template with fresh DuckDB data.""" """Re-generate all articles for a template with fresh DuckDB data."""
from ..content import generate_articles, load_template from ..content import load_template
try: try:
load_template(slug) load_template(slug)
@@ -1362,9 +1367,14 @@ async def template_regenerate(slug: str):
await flash("Template not found.", "error") await flash("Template not found.", "error")
return redirect(url_for("admin.templates")) return redirect(url_for("admin.templates"))
# Use today as start date, keep existing publish dates via upsert from ..worker import enqueue
generated = await generate_articles(slug, date.today(), articles_per_day=500) await enqueue("generate_articles", {
await flash(f"Regenerated {generated} articles from fresh data.", "success") "template_slug": slug,
"start_date": date.today().isoformat(),
"articles_per_day": 500,
"limit": 500,
})
await flash("Regeneration queued. The worker will process it in the background.", "success")
return redirect(url_for("admin.template_detail", slug=slug)) return redirect(url_for("admin.template_detail", slug=slug))
@@ -1877,13 +1887,29 @@ async def article_rebuild(article_id: int):
@role_required("admin") @role_required("admin")
@csrf_protect @csrf_protect
async def rebuild_all(): async def rebuild_all():
"""Re-render all articles.""" """Re-render all articles via background worker."""
articles = await fetch_all("SELECT id FROM articles") from ..content import discover_templates
count = 0 from ..worker import enqueue
for a in articles:
templates = discover_templates()
for t in templates:
await enqueue("generate_articles", {
"template_slug": t["slug"],
"start_date": date.today().isoformat(),
"articles_per_day": 500,
"limit": 500,
})
# Manual articles still need inline rebuild
manual = await fetch_all("SELECT id FROM articles WHERE template_slug IS NULL")
for a in manual:
await _rebuild_article(a["id"]) await _rebuild_article(a["id"])
count += 1
await flash(f"Rebuilt {count} articles.", "success") await flash(
f"Queued rebuild for {len(templates)} templates"
f" + rebuilt {len(manual)} manual articles.",
"success",
)
return redirect(url_for("admin.articles")) return redirect(url_for("admin.articles"))

View File

@@ -709,6 +709,22 @@ async def handle_cleanup_seo_metrics(payload: dict) -> None:
print(f"[WORKER] Cleaned up {deleted} old SEO metric rows") print(f"[WORKER] Cleaned up {deleted} old SEO metric rows")
@task("generate_articles")
async def handle_generate_articles(payload: dict) -> None:
"""Generate articles from a template in the background."""
from datetime import date as date_cls
from .content import generate_articles
slug = payload["template_slug"]
start_date = date_cls.fromisoformat(payload["start_date"])
articles_per_day = payload.get("articles_per_day", 3)
limit = payload.get("limit", 500)
count = await generate_articles(slug, start_date, articles_per_day, limit=limit)
print(f"[WORKER] Generated {count} articles for template '{slug}'")
# ============================================================================= # =============================================================================
# Worker Loop # Worker Loop
# ============================================================================= # =============================================================================

View File

@@ -1019,7 +1019,7 @@ class TestAdminTemplateGenerate:
assert "3" in html # 3 rows available assert "3" in html # 3 rows available
assert "Generate" in html assert "Generate" in html
async def test_generate_creates_articles(self, admin_client, db, pseo_env): async def test_generate_enqueues_task(self, admin_client, db, pseo_env):
async with admin_client.session_transaction() as sess: async with admin_client.session_transaction() as sess:
sess["csrf_token"] = "test" sess["csrf_token"] = "test"
@@ -1030,11 +1030,16 @@ class TestAdminTemplateGenerate:
}) })
assert resp.status_code == 302 assert resp.status_code == 302
articles = await fetch_all("SELECT * FROM articles") # Generation is now queued, not inline
assert len(articles) == 3 tasks = await fetch_all(
"SELECT * FROM tasks WHERE task_name = 'generate_articles'"
scenarios = await fetch_all("SELECT * FROM published_scenarios") )
assert len(scenarios) == 3 assert len(tasks) == 1
import json
payload = json.loads(tasks[0]["payload"])
assert payload["template_slug"] == "test-city"
assert payload["start_date"] == "2026-04-01"
assert payload["articles_per_day"] == 2
async def test_generate_unknown_template_redirects(self, admin_client, db, pseo_env): async def test_generate_unknown_template_redirects(self, admin_client, db, pseo_env):
resp = await admin_client.get("/admin/templates/nonexistent/generate") resp = await admin_client.get("/admin/templates/nonexistent/generate")
@@ -1042,13 +1047,7 @@ class TestAdminTemplateGenerate:
class TestAdminTemplateRegenerate: class TestAdminTemplateRegenerate:
async def test_regenerate_updates_articles(self, admin_client, db, pseo_env): async def test_regenerate_enqueues_task(self, admin_client, db, pseo_env):
from padelnomics.content import generate_articles
# First generate
await generate_articles("test-city", date(2026, 3, 1), 10)
initial = await fetch_all("SELECT * FROM articles")
assert len(initial) == 3
async with admin_client.session_transaction() as sess: async with admin_client.session_transaction() as sess:
sess["csrf_token"] = "test" sess["csrf_token"] = "test"
@@ -1057,9 +1056,14 @@ class TestAdminTemplateRegenerate:
}) })
assert resp.status_code == 302 assert resp.status_code == 302
# Same count — upserted, not duplicated # Regeneration is now queued, not inline
articles = await fetch_all("SELECT * FROM articles") tasks = await fetch_all(
assert len(articles) == 3 "SELECT * FROM tasks WHERE task_name = 'generate_articles'"
)
assert len(tasks) == 1
import json
payload = json.loads(tasks[0]["payload"])
assert payload["template_slug"] == "test-city"
async def test_regenerate_unknown_template_redirects(self, admin_client, db, pseo_env): async def test_regenerate_unknown_template_redirects(self, admin_client, db, pseo_env):
async with admin_client.session_transaction() as sess: async with admin_client.session_transaction() as sess: