Merge branch 'worktree-cms-admin-improvement'

This commit is contained in:
Deeman
2026-02-24 01:22:55 +01:00
10 changed files with 371 additions and 98 deletions

View File

@@ -6,6 +6,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased] ## [Unreleased]
### Added
- **CMS admin improvement** — articles list now has HTMX filter bar (search,
status, template, language), pagination (50/page), and stats strip
(total/live/scheduled/draft counts). Article actions (publish/unpublish,
delete) are inline HTMX operations — no full page reload. "View" link opens
live articles on the public site. Article generation and rebuild-all now
enqueue to the background worker instead of blocking the HTTP request.
Markdown source is written to disk during generation so the edit form shows
content. Sitemap cache is invalidated when articles are published, deleted,
or created. Fixed broken "Scheduled"/"Published" status display (was always
showing "Scheduled") and stale `template_data_id` column reference.
### Changed ### Changed
- **Visual test overhaul** — consolidated 3 separate Playwright server processes - **Visual test overhaul** — consolidated 3 separate Playwright server processes
(ports 5111/5112/5113) into 1 session-scoped fixture in `conftest.py`; 77 tests (ports 5111/5112/5113) into 1 session-scoped fixture in `conftest.py`; 77 tests

View File

@@ -79,6 +79,7 @@
- [x] City coverage: DE (8), US (6), UK (4), ES (5), FR (3), IT (2), NL, AT, CH, SE, PT (2), BE, AE, AU (2), IE - [x] City coverage: DE (8), US (6), UK (4), ES (5), FR (3), IT (2), NL, AT, CH, SE, PT (2), BE, AE, AU (2), IE
- [x] Per-city financial model overrides (rates, rent, utilities, permits, court config) - [x] Per-city financial model overrides (rates, rent, utilities, permits, court config)
- [x] Admin CMS (template CRUD, data row management, bulk CSV upload, bulk generate, publish toggle, rebuild) - [x] Admin CMS (template CRUD, data row management, bulk CSV upload, bulk generate, publish toggle, rebuild)
- [x] Admin CMS v2: HTMX filter/search/pagination, background generation, inline actions, sitemap invalidation, markdown editing
- [x] Markets hub (`/<lang>/markets`) — article listing with FTS + country/region filters - [x] Markets hub (`/<lang>/markets`) — article listing with FTS + country/region filters
- [x] DuckDB refresh script (`refresh_from_daas.py`) - [x] DuckDB refresh script (`refresh_from_daas.py`)

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))
@@ -1581,14 +1591,112 @@ async def scenario_pdf(scenario_id: int):
# Article Management # Article Management
# ============================================================================= # =============================================================================
async def _get_article_list(
status: str = None,
template_slug: str = None,
language: str = None,
search: str = None,
page: int = 1,
per_page: int = 50,
) -> list[dict]:
"""Get articles with optional filters and pagination."""
wheres = ["1=1"]
params: list = []
if status == "live":
wheres.append("status = 'published' AND published_at <= datetime('now')")
elif status == "scheduled":
wheres.append("status = 'published' AND published_at > datetime('now')")
elif status == "draft":
wheres.append("status = 'draft'")
if template_slug:
wheres.append("template_slug = ?")
params.append(template_slug)
if language:
wheres.append("language = ?")
params.append(language)
if search:
wheres.append("title LIKE ?")
params.append(f"%{search}%")
where = " AND ".join(wheres)
offset = (page - 1) * per_page
params.extend([per_page, offset])
return await fetch_all(
f"""SELECT *,
CASE WHEN status = 'published' AND published_at > datetime('now')
THEN 'scheduled'
WHEN status = 'published' THEN 'live'
ELSE status END AS display_status
FROM articles WHERE {where}
ORDER BY created_at DESC LIMIT ? OFFSET ?""",
tuple(params),
)
async def _get_article_stats() -> dict:
"""Get aggregate article stats for the admin list header."""
row = await fetch_one(
"""SELECT
COUNT(*) AS total,
SUM(CASE WHEN status='published' AND published_at <= datetime('now') THEN 1 ELSE 0 END) AS live,
SUM(CASE WHEN status='published' AND published_at > datetime('now') THEN 1 ELSE 0 END) AS scheduled,
SUM(CASE WHEN status='draft' THEN 1 ELSE 0 END) AS draft
FROM articles"""
)
return dict(row) if row else {"total": 0, "live": 0, "scheduled": 0, "draft": 0}
@bp.route("/articles") @bp.route("/articles")
@role_required("admin") @role_required("admin")
async def articles(): async def articles():
"""List all articles.""" """List all articles with filters."""
article_list = await fetch_all( search = request.args.get("search", "").strip()
"SELECT * FROM articles ORDER BY created_at DESC" status_filter = request.args.get("status", "")
template_filter = request.args.get("template", "")
language_filter = request.args.get("language", "")
page = max(1, int(request.args.get("page", "1") or "1"))
article_list = await _get_article_list(
status=status_filter or None, template_slug=template_filter or None,
language=language_filter or None, search=search or None, page=page,
)
stats = await _get_article_stats()
templates = await fetch_all(
"SELECT DISTINCT template_slug FROM articles WHERE template_slug IS NOT NULL ORDER BY template_slug"
)
return await render_template(
"admin/articles.html",
articles=article_list,
stats=stats,
template_slugs=[t["template_slug"] for t in templates],
current_search=search,
current_status=status_filter,
current_template=template_filter,
current_language=language_filter,
page=page,
)
@bp.route("/articles/results")
@role_required("admin")
async def article_results():
"""HTMX partial for filtered article results."""
search = request.args.get("search", "").strip()
status_filter = request.args.get("status", "")
template_filter = request.args.get("template", "")
language_filter = request.args.get("language", "")
page = max(1, int(request.args.get("page", "1") or "1"))
article_list = await _get_article_list(
status=status_filter or None, template_slug=template_filter or None,
language=language_filter or None, search=search or None, page=page,
)
return await render_template(
"admin/partials/article_results.html", articles=article_list, page=page,
) )
return await render_template("admin/articles.html", articles=article_list)
@bp.route("/articles/new", methods=["GET", "POST"]) @bp.route("/articles/new", methods=["GET", "POST"])
@@ -1642,6 +1750,9 @@ async def article_new():
(url_path, article_slug, title, meta_description, og_image_url, (url_path, article_slug, title, meta_description, og_image_url,
country, region, status, pub_dt), country, region, status, pub_dt),
) )
from ..sitemap import invalidate_sitemap_cache
invalidate_sitemap_cache()
await flash(f"Article '{title}' created.", "success") await flash(f"Article '{title}' created.", "success")
return redirect(url_for("admin.articles")) return redirect(url_for("admin.articles"))
@@ -1703,8 +1814,12 @@ async def article_edit(article_id: int):
await flash("Article updated.", "success") await flash("Article updated.", "success")
return redirect(url_for("admin.articles")) return redirect(url_for("admin.articles"))
# Load markdown source if available # Load markdown source if available (manual or generated)
from ..content.routes import BUILD_DIR as CONTENT_BUILD_DIR
md_path = Path("data/content/articles") / f"{article['slug']}.md" md_path = Path("data/content/articles") / f"{article['slug']}.md"
if not md_path.exists():
lang = article["language"] or "en"
md_path = CONTENT_BUILD_DIR / lang / "md" / f"{article['slug']}.md"
body = md_path.read_text() if md_path.exists() else "" body = md_path.read_text() if md_path.exists() else ""
data = {**dict(article), "body": body} data = {**dict(article), "body": body}
@@ -1730,6 +1845,13 @@ async def article_delete(article_id: int):
md_path.unlink() md_path.unlink()
await execute("DELETE FROM articles WHERE id = ?", (article_id,)) await execute("DELETE FROM articles WHERE id = ?", (article_id,))
from ..sitemap import invalidate_sitemap_cache
invalidate_sitemap_cache()
if request.headers.get("HX-Request"):
return "" # row removed via hx-swap="outerHTML"
await flash("Article deleted.", "success") await flash("Article deleted.", "success")
return redirect(url_for("admin.articles")) return redirect(url_for("admin.articles"))
@@ -1750,6 +1872,22 @@ async def article_publish(article_id: int):
"UPDATE articles SET status = ?, updated_at = ? WHERE id = ?", "UPDATE articles SET status = ?, updated_at = ? WHERE id = ?",
(new_status, now, article_id), (new_status, now, article_id),
) )
from ..sitemap import invalidate_sitemap_cache
invalidate_sitemap_cache()
if request.headers.get("HX-Request"):
updated = await fetch_one(
"""SELECT *,
CASE WHEN status = 'published' AND published_at > datetime('now')
THEN 'scheduled'
WHEN status = 'published' THEN 'live'
ELSE status END AS display_status
FROM articles WHERE id = ?""",
(article_id,),
)
return await render_template("admin/partials/article_row.html", a=updated)
await flash(f"Article status changed to {new_status}.", "success") await flash(f"Article status changed to {new_status}.", "success")
return redirect(url_for("admin.articles")) return redirect(url_for("admin.articles"))
@@ -1768,13 +1906,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

@@ -4,73 +4,72 @@
{% block title %}Articles - Admin - {{ config.APP_NAME }}{% endblock %} {% block title %}Articles - Admin - {{ config.APP_NAME }}{% endblock %}
{% block admin_content %} {% block admin_content %}
<header class="flex justify-between items-center mb-8"> <header class="flex justify-between items-center mb-6">
<div> <div>
<h1 class="text-2xl">Articles</h1> <h1 class="text-2xl">Articles</h1>
<p class="text-slate text-sm">{{ articles | length }} article{{ 's' if articles | length != 1 }}</p> <p class="text-sm text-slate mt-1">
{{ stats.total }} total
&middot; {{ stats.live }} live
&middot; {{ stats.scheduled }} scheduled
&middot; {{ stats.draft }} draft
</p>
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<a href="{{ url_for('admin.article_new') }}" class="btn">New Article</a> <a href="{{ url_for('admin.article_new') }}" class="btn btn-sm">New Article</a>
<form method="post" action="{{ url_for('admin.rebuild_all') }}" class="m-0" style="display: inline;"> <form method="post" action="{{ url_for('admin.rebuild_all') }}" class="m-0" style="display:inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn-outline" onclick="return confirm('Rebuild all articles?')">Rebuild All</button> <button type="submit" class="btn-outline btn-sm" onclick="return confirm('Rebuild all articles?')">Rebuild All</button>
</form> </form>
<a href="{{ url_for('admin.index') }}" class="btn-outline">Back</a>
</div> </div>
</header> </header>
<div class="card"> {# Filters #}
{% if articles %} <div class="card mb-6" style="padding:1rem 1.25rem">
<table class="table"> <form class="flex flex-wrap gap-3 items-end"
<thead> hx-get="{{ url_for('admin.article_results') }}"
<tr> hx-target="#article-results"
<th>Title</th> hx-trigger="change, input delay:300ms from:find input">
<th>URL</th> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<th>Status</th>
<th>Published</th> <div>
<th>Source</th> <label class="text-xs font-semibold text-slate block mb-1">Search</label>
<th></th> <input type="text" name="search" value="{{ current_search }}" placeholder="Title..."
</tr> class="form-input" style="min-width:180px">
</thead> </div>
<tbody>
{% for a in articles %} <div>
<tr> <label class="text-xs font-semibold text-slate block mb-1">Status</label>
<td>{{ a.title }}</td> <select name="status" class="form-input" style="min-width:120px">
<td class="mono text-sm">{{ a.url_path }}</td> <option value="">All</option>
<td> <option value="live" {% if current_status == 'live' %}selected{% endif %}>Live</option>
{% if a.status == 'published' %} <option value="scheduled" {% if current_status == 'scheduled' %}selected{% endif %}>Scheduled</option>
{% if a.published_at and a.published_at > now.isoformat() %} <option value="draft" {% if current_status == 'draft' %}selected{% endif %}>Draft</option>
<span class="badge-warning">Scheduled</span> </select>
{% else %} </div>
<span class="badge-success">Published</span>
{% endif %} <div>
{% else %} <label class="text-xs font-semibold text-slate block mb-1">Template</label>
<span class="badge">Draft</span> <select name="template" class="form-input" style="min-width:140px">
{% endif %} <option value="">All</option>
</td> {% for t in template_slugs %}
<td class="mono text-sm">{{ a.published_at[:10] if a.published_at else '-' }}</td> <option value="{{ t }}" {% if t == current_template %}selected{% endif %}>{{ t }}</option>
<td class="text-sm">{% if a.template_data_id %}Generated{% else %}Manual{% endif %}</td> {% endfor %}
<td class="text-right" style="white-space: nowrap;"> </select>
<form method="post" action="{{ url_for('admin.article_publish', article_id=a.id) }}" class="m-0" style="display: inline;"> </div>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn-outline btn-sm">{% if a.status == 'published' %}Unpublish{% else %}Publish{% endif %}</button> <div>
</form> <label class="text-xs font-semibold text-slate block mb-1">Language</label>
<a href="{{ url_for('admin.article_edit', article_id=a.id) }}" class="btn-outline btn-sm">Edit</a> <select name="language" class="form-input" style="min-width:80px">
<form method="post" action="{{ url_for('admin.article_rebuild', article_id=a.id) }}" class="m-0" style="display: inline;"> <option value="">All</option>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <option value="en" {% if current_language == 'en' %}selected{% endif %}>EN</option>
<button type="submit" class="btn-outline btn-sm">Rebuild</button> <option value="de" {% if current_language == 'de' %}selected{% endif %}>DE</option>
</form> </select>
<form method="post" action="{{ url_for('admin.article_delete', article_id=a.id) }}" class="m-0" style="display: inline;"> </div>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> </form>
<button type="submit" class="btn-outline btn-sm" onclick="return confirm('Delete this article?')">Delete</button> </div>
</form>
</td> {# Results #}
</tr> <div id="article-results">
{% endfor %} {% include "admin/partials/article_results.html" %}
</tbody>
</table>
{% else %}
<p class="text-slate text-sm">No articles yet. Create one or generate from a template.</p>
{% endif %}
</div> </div>
{% endblock %} {% endblock %}

View File

@@ -0,0 +1,42 @@
{% if articles %}
<div class="card">
<table class="table text-sm">
<thead>
<tr>
<th>Title</th>
<th>Status</th>
<th>Published</th>
<th>Lang</th>
<th>Template</th>
<th></th>
</tr>
</thead>
<tbody>
{% for a in articles %}
{% include "admin/partials/article_row.html" %}
{% endfor %}
</tbody>
</table>
{% if articles | length >= 50 %}
<div class="flex justify-between items-center" style="padding:0.75rem 1rem; border-top:1px solid #E2E8F0">
{% if page > 1 %}
<button class="btn-outline btn-sm"
hx-get="{{ url_for('admin.article_results') }}?page={{ page - 1 }}"
hx-target="#article-results"
hx-include="closest form">Prev</button>
{% else %}
<span></span>
{% endif %}
<span class="text-xs text-slate">Page {{ page }}</span>
<button class="btn-outline btn-sm"
hx-get="{{ url_for('admin.article_results') }}?page={{ page + 1 }}"
hx-target="#article-results"
hx-include="closest form">Next</button>
</div>
{% endif %}
</div>
{% else %}
<div class="card text-center" style="padding:2rem">
<p class="text-slate">No articles match the current filters.</p>
</div>
{% endif %}

View File

@@ -0,0 +1,33 @@
<tr id="article-{{ a.id }}">
<td style="max-width:280px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap"
title="{{ a.url_path }}">{{ a.title }}</td>
<td>
{% if a.display_status == 'live' %}
<span class="badge-success">Live</span>
{% elif a.display_status == 'scheduled' %}
<span class="badge-warning">Scheduled</span>
{% else %}
<span class="badge">Draft</span>
{% endif %}
</td>
<td class="mono">{{ a.published_at[:10] if a.published_at else '-' }}</td>
<td>{{ a.language | upper if a.language else '-' }}</td>
<td class="text-slate">{{ a.template_slug or 'Manual' }}</td>
<td class="text-right" style="white-space:nowrap">
{% if a.display_status == 'live' %}
<a href="/{{ a.language or 'en' }}{{ a.url_path }}" target="_blank" class="btn-outline btn-sm">View</a>
{% endif %}
<a href="{{ url_for('admin.article_edit', article_id=a.id) }}" class="btn-outline btn-sm">Edit</a>
<button hx-post="{{ url_for('admin.article_publish', article_id=a.id) }}"
hx-target="#article-{{ a.id }}" hx-swap="outerHTML"
hx-headers='{"X-CSRF-Token": "{{ csrf_token() }}"}'
class="btn-outline btn-sm">
{% if a.display_status != 'draft' %}Unpublish{% else %}Publish{% endif %}
</button>
<button hx-post="{{ url_for('admin.article_delete', article_id=a.id) }}"
hx-target="#article-{{ a.id }}" hx-swap="outerHTML swap:200ms"
hx-confirm="Delete this article?"
hx-headers='{"X-CSRF-Token": "{{ csrf_token() }}"}'
class="btn-outline btn-sm">Delete</button>
</td>
</tr>

View File

@@ -429,6 +429,11 @@ async def generate_articles(
build_dir.mkdir(parents=True, exist_ok=True) build_dir.mkdir(parents=True, exist_ok=True)
(build_dir / f"{article_slug}.html").write_text(body_html) (build_dir / f"{article_slug}.html").write_text(body_html)
# Write markdown source to disk (for admin editing)
md_dir = BUILD_DIR / lang / "md"
md_dir.mkdir(parents=True, exist_ok=True)
(md_dir / f"{article_slug}.md").write_text(body_md)
# Upsert article in SQLite # Upsert article in SQLite
existing_article = await fetch_one( existing_article = await fetch_one(
"SELECT id FROM articles WHERE url_path = ?", (url_path,), "SELECT id FROM articles WHERE url_path = ?", (url_path,),

View File

@@ -103,6 +103,13 @@ async def _generate_sitemap_xml(base_url: str) -> str:
return xml return xml
def invalidate_sitemap_cache() -> None:
"""Force sitemap regeneration on next request."""
global _cache_xml, _cache_timestamp # noqa: PLW0603
_cache_xml = ""
_cache_timestamp = 0.0
async def sitemap_response(base_url: str) -> Response: async def sitemap_response(base_url: str) -> Response:
"""Return cached sitemap XML, regenerating if stale (1-hour TTL).""" """Return cached sitemap XML, regenerating if stale (1-hour TTL)."""
global _cache_xml, _cache_timestamp # noqa: PLW0603 global _cache_xml, _cache_timestamp # noqa: PLW0603

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: