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:
@@ -1314,7 +1314,7 @@ async def template_preview(slug: str, row_key: str):
|
||||
@csrf_protect
|
||||
async def template_generate(slug: str):
|
||||
"""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:
|
||||
config = load_template(slug)
|
||||
@@ -1330,15 +1330,20 @@ async def template_generate(slug: str):
|
||||
start_date_str = form.get("start_date", "")
|
||||
articles_per_day = int(form.get("articles_per_day", 3) or 3)
|
||||
|
||||
if not start_date_str:
|
||||
start_date = date.today()
|
||||
else:
|
||||
start_date = date.fromisoformat(start_date_str)
|
||||
start_date = date.fromisoformat(start_date_str) if start_date_str else date.today()
|
||||
|
||||
generated = await generate_articles(
|
||||
slug, start_date, articles_per_day, limit=500,
|
||||
from ..worker import enqueue
|
||||
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 await render_template(
|
||||
@@ -1354,7 +1359,7 @@ async def template_generate(slug: str):
|
||||
@csrf_protect
|
||||
async def template_regenerate(slug: str):
|
||||
"""Re-generate all articles for a template with fresh DuckDB data."""
|
||||
from ..content import generate_articles, load_template
|
||||
from ..content import load_template
|
||||
|
||||
try:
|
||||
load_template(slug)
|
||||
@@ -1362,9 +1367,14 @@ async def template_regenerate(slug: str):
|
||||
await flash("Template not found.", "error")
|
||||
return redirect(url_for("admin.templates"))
|
||||
|
||||
# Use today as start date, keep existing publish dates via upsert
|
||||
generated = await generate_articles(slug, date.today(), articles_per_day=500)
|
||||
await flash(f"Regenerated {generated} articles from fresh data.", "success")
|
||||
from ..worker import enqueue
|
||||
await enqueue("generate_articles", {
|
||||
"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))
|
||||
|
||||
|
||||
@@ -1877,13 +1887,29 @@ async def article_rebuild(article_id: int):
|
||||
@role_required("admin")
|
||||
@csrf_protect
|
||||
async def rebuild_all():
|
||||
"""Re-render all articles."""
|
||||
articles = await fetch_all("SELECT id FROM articles")
|
||||
count = 0
|
||||
for a in articles:
|
||||
"""Re-render all articles via background worker."""
|
||||
from ..content import discover_templates
|
||||
from ..worker import enqueue
|
||||
|
||||
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"])
|
||||
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"))
|
||||
|
||||
|
||||
|
||||
@@ -709,6 +709,22 @@ async def handle_cleanup_seo_metrics(payload: dict) -> None:
|
||||
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
|
||||
# =============================================================================
|
||||
|
||||
@@ -1019,7 +1019,7 @@ class TestAdminTemplateGenerate:
|
||||
assert "3" in html # 3 rows available
|
||||
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:
|
||||
sess["csrf_token"] = "test"
|
||||
|
||||
@@ -1030,11 +1030,16 @@ class TestAdminTemplateGenerate:
|
||||
})
|
||||
assert resp.status_code == 302
|
||||
|
||||
articles = await fetch_all("SELECT * FROM articles")
|
||||
assert len(articles) == 3
|
||||
|
||||
scenarios = await fetch_all("SELECT * FROM published_scenarios")
|
||||
assert len(scenarios) == 3
|
||||
# Generation is now queued, not inline
|
||||
tasks = await fetch_all(
|
||||
"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"
|
||||
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):
|
||||
resp = await admin_client.get("/admin/templates/nonexistent/generate")
|
||||
@@ -1042,13 +1047,7 @@ class TestAdminTemplateGenerate:
|
||||
|
||||
|
||||
class TestAdminTemplateRegenerate:
|
||||
async def test_regenerate_updates_articles(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 def test_regenerate_enqueues_task(self, admin_client, db, pseo_env):
|
||||
async with admin_client.session_transaction() as sess:
|
||||
sess["csrf_token"] = "test"
|
||||
|
||||
@@ -1057,9 +1056,14 @@ class TestAdminTemplateRegenerate:
|
||||
})
|
||||
assert resp.status_code == 302
|
||||
|
||||
# Same count — upserted, not duplicated
|
||||
articles = await fetch_all("SELECT * FROM articles")
|
||||
assert len(articles) == 3
|
||||
# Regeneration is now queued, not inline
|
||||
tasks = await fetch_all(
|
||||
"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 with admin_client.session_transaction() as sess:
|
||||
|
||||
Reference in New Issue
Block a user