feat(admin): article type tabs (cornerstone / editorial / generated)

- Migration 0029: ADD COLUMN article_type + backfill + index
- Tab bar on /admin/articles with per-type counts
- _build_article_where, _get_article_list, _get_article_list_grouped, and
  all routes now accept and thread article_type filter
- Template dropdown only shown on Generated tab
- Bulk form and matching-count endpoint carry article_type
- Delete guard uses article_type == 'generated' (not template_slug check)
- _sync_static_articles derives article_type from cornerstone frontmatter field
- generate_articles() upserts with article_type = 'generated'
- article_new / article_edit: Type dropdown (Editorial / Cornerstone)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Deeman
2026-03-07 12:21:07 +01:00
parent a27da79705
commit 39225d6cfd
8 changed files with 148 additions and 44 deletions

View File

@@ -2255,13 +2255,14 @@ async def _sync_static_articles() -> None:
meta_description = fm.get("meta_description", "")
template_slug = fm.get("template_slug") or None
group_key = fm.get("cornerstone") or None
article_type = "cornerstone" if fm.get("cornerstone") else "editorial"
now_iso = utcnow_iso()
await execute(
"""INSERT INTO articles
(slug, title, url_path, language, meta_description,
status, template_slug, group_key, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, 'draft', ?, ?, ?, ?)
status, template_slug, group_key, article_type, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, 'draft', ?, ?, ?, ?, ?)
ON CONFLICT(slug) DO UPDATE SET
title = excluded.title,
url_path = excluded.url_path,
@@ -2269,9 +2270,10 @@ async def _sync_static_articles() -> None:
meta_description = excluded.meta_description,
template_slug = excluded.template_slug,
group_key = excluded.group_key,
article_type = excluded.article_type,
updated_at = excluded.updated_at""",
(slug, title, url_path, language, meta_description,
template_slug, group_key, now_iso, now_iso),
template_slug, group_key, article_type, now_iso, now_iso),
)
# Build HTML so the article is immediately servable (cornerstones have no template)
@@ -2292,12 +2294,9 @@ def _build_article_where(
template_slug: str = None,
language: str = None,
search: str = None,
article_type: str = None,
) -> tuple[list[str], list]:
"""Build WHERE clauses and params for article queries.
template_slug='__manual__' filters for articles with template_slug IS NULL
(cornerstone / manually written articles, no pSEO template).
"""
"""Build WHERE clauses and params for article queries."""
wheres = ["1=1"]
params: list = []
@@ -2307,9 +2306,7 @@ def _build_article_where(
wheres.append("status = 'published' AND published_at > datetime('now')")
elif status == "draft":
wheres.append("status = 'draft'")
if template_slug == "__manual__":
wheres.append("template_slug IS NULL")
elif template_slug:
if template_slug:
wheres.append("template_slug = ?")
params.append(template_slug)
if language:
@@ -2318,6 +2315,9 @@ def _build_article_where(
if search:
wheres.append("title LIKE ?")
params.append(f"%{search}%")
if article_type:
wheres.append("article_type = ?")
params.append(article_type)
return wheres, params
@@ -2327,12 +2327,14 @@ async def _get_article_list(
template_slug: str = None,
language: str = None,
search: str = None,
article_type: str = None,
page: int = 1,
per_page: int = 50,
) -> list[dict]:
"""Get articles with optional filters and pagination."""
wheres, params = _build_article_where(status=status, template_slug=template_slug,
language=language, search=search)
language=language, search=search,
article_type=article_type)
where = " AND ".join(wheres)
offset = (page - 1) * per_page
params.extend([per_page, offset])
@@ -2353,6 +2355,7 @@ async def _get_article_list_grouped(
status: str = None,
template_slug: str = None,
search: str = None,
article_type: str = None,
page: int = 1,
per_page: int = 50,
) -> list[dict]:
@@ -2363,7 +2366,7 @@ async def _get_article_list_grouped(
Each returned item has a 'variants' list (one dict per language variant).
"""
wheres, params = _build_article_where(status=status, template_slug=template_slug,
search=search)
search=search, article_type=article_type)
where = " AND ".join(wheres)
offset = (page - 1) * per_page
@@ -2418,19 +2421,32 @@ async def _get_article_list_grouped(
return groups
async def _get_article_stats() -> dict:
async def _get_article_stats(article_type: str = None) -> dict:
"""Get aggregate article stats for the admin list header."""
where = f"WHERE article_type = '{article_type}'" if article_type else ""
row = await fetch_one(
"""SELECT
f"""SELECT
COUNT(*) AS total,
COALESCE(SUM(CASE WHEN status='published' AND published_at <= datetime('now') THEN 1 ELSE 0 END), 0) AS live,
COALESCE(SUM(CASE WHEN status='published' AND published_at > datetime('now') THEN 1 ELSE 0 END), 0) AS scheduled,
COALESCE(SUM(CASE WHEN status='draft' THEN 1 ELSE 0 END), 0) AS draft
FROM articles"""
FROM articles {where}"""
)
return dict(row) if row else {"total": 0, "live": 0, "scheduled": 0, "draft": 0}
async def _get_article_type_counts() -> dict[str, int]:
"""Return per-type article counts for the tab bar."""
rows = await fetch_all(
"SELECT article_type, COUNT(*) AS cnt FROM articles GROUP BY article_type"
)
counts: dict[str, int] = {"cornerstone": 0, "editorial": 0, "generated": 0}
for r in rows:
if r["article_type"] in counts:
counts[r["article_type"]] = r["cnt"]
return counts
async def _is_generating() -> bool:
"""Return True if a generate_articles task is currently pending."""
row = await fetch_one(
@@ -2448,34 +2464,43 @@ async def articles():
status_filter = request.args.get("status", "")
template_filter = request.args.get("template", "")
language_filter = request.args.get("language", "")
article_type = request.args.get("article_type", "cornerstone")
page = max(1, int(request.args.get("page", "1") or "1"))
grouped = not language_filter
if grouped:
article_list = await _get_article_list_grouped(
status=status_filter or None, template_slug=template_filter or None,
search=search or None, page=page,
search=search or None, article_type=article_type or None, page=page,
)
else:
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,
language=language_filter or None, search=search or None,
article_type=article_type 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"
)
stats = await _get_article_stats(article_type=article_type or None)
type_counts = await _get_article_type_counts()
template_slugs: list[str] = []
if article_type == "generated":
templates = await fetch_all(
"SELECT DISTINCT template_slug FROM articles WHERE template_slug IS NOT NULL ORDER BY template_slug"
)
template_slugs = [t["template_slug"] for t in templates]
return await render_template(
"admin/articles.html",
articles=article_list,
grouped=grouped,
stats=stats,
template_slugs=[t["template_slug"] for t in templates],
template_slugs=template_slugs,
current_search=search,
current_status=status_filter,
current_template=template_filter,
current_language=language_filter,
current_article_type=article_type,
type_counts=type_counts,
page=page,
is_generating=await _is_generating(),
)
@@ -2489,23 +2514,26 @@ async def article_results():
status_filter = request.args.get("status", "")
template_filter = request.args.get("template", "")
language_filter = request.args.get("language", "")
article_type = request.args.get("article_type", "cornerstone")
page = max(1, int(request.args.get("page", "1") or "1"))
grouped = not language_filter
if grouped:
article_list = await _get_article_list_grouped(
status=status_filter or None, template_slug=template_filter or None,
search=search or None, page=page,
search=search or None, article_type=article_type or None, page=page,
)
else:
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,
language=language_filter or None, search=search or None,
article_type=article_type or None, page=page,
)
return await render_template(
"admin/partials/article_results.html",
articles=article_list,
grouped=grouped,
current_article_type=article_type,
page=page,
is_generating=await _is_generating(),
)
@@ -2518,6 +2546,7 @@ async def articles_matching_count():
status_filter = request.args.get("status", "")
template_filter = request.args.get("template", "")
language_filter = request.args.get("language", "")
article_type = request.args.get("article_type", "cornerstone")
search = request.args.get("search", "").strip()
wheres, params = _build_article_where(
@@ -2525,6 +2554,7 @@ async def articles_matching_count():
template_slug=template_filter or None,
language=language_filter or None,
search=search or None,
article_type=article_type or None,
)
where = " AND ".join(wheres)
row = await fetch_one(f"SELECT COUNT(*) AS cnt FROM articles WHERE {where}", tuple(params))
@@ -2551,6 +2581,7 @@ async def articles_bulk():
status_filter = form.get("status", "")
template_filter = form.get("template", "")
language_filter = form.get("language", "")
article_type = form.get("article_type", "cornerstone")
valid_actions = ("publish", "unpublish", "toggle_noindex", "rebuild", "delete")
if action not in valid_actions:
@@ -2564,6 +2595,7 @@ async def articles_bulk():
template_slug=template_filter or None,
language=language_filter or None,
search=search or None,
article_type=article_type or None,
)
where = " AND ".join(wheres)
@@ -2613,15 +2645,15 @@ async def articles_bulk():
from ..content.routes import BUILD_DIR
rows = await fetch_all(
f"SELECT id, slug, template_slug FROM articles WHERE {where} LIMIT 5000",
f"SELECT id, slug, article_type FROM articles WHERE {where} LIMIT 5000",
tuple(where_params),
)
for a in rows:
build_path = BUILD_DIR / f"{a['slug']}.html"
if build_path.exists():
build_path.unlink()
# Only remove source .md for generated articles; cornerstones have no template
if a["template_slug"] is not None:
# Only remove source .md for generated articles
if a["article_type"] == "generated":
md_path = Path("data/content/articles") / f"{a['slug']}.md"
if md_path.exists():
md_path.unlink()
@@ -2670,15 +2702,15 @@ async def articles_bulk():
from ..content.routes import BUILD_DIR
articles_rows = await fetch_all(
f"SELECT id, slug, template_slug FROM articles WHERE id IN ({placeholders})",
f"SELECT id, slug, article_type FROM articles WHERE id IN ({placeholders})",
tuple(article_ids),
)
for a in articles_rows:
build_path = BUILD_DIR / f"{a['slug']}.html"
if build_path.exists():
build_path.unlink()
# Only remove source .md for generated articles; cornerstones have no template
if a["template_slug"] is not None:
# Only remove source .md for generated articles
if a["article_type"] == "generated":
md_path = Path("data/content/articles") / f"{a['slug']}.md"
if md_path.exists():
md_path.unlink()
@@ -2694,17 +2726,19 @@ async def articles_bulk():
if grouped:
article_list = await _get_article_list_grouped(
status=status_filter or None, template_slug=template_filter or None,
search=search or None,
search=search or None, article_type=article_type or None,
)
else:
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,
article_type=article_type or None,
)
return await render_template(
"admin/partials/article_results.html",
articles=article_list,
grouped=grouped,
current_article_type=article_type,
page=1,
is_generating=await _is_generating(),
)
@@ -2735,6 +2769,8 @@ async def article_new():
language = form.get("language", "en").strip() or "en"
status = form.get("status", "draft")
published_at = form.get("published_at", "").strip()
article_type = form.get("article_type", "editorial")
assert article_type in ("editorial", "cornerstone"), f"invalid article_type: {article_type}"
if not title or not body:
await flash("Title and body are required.", "error")
@@ -2764,10 +2800,10 @@ async def article_new():
await execute(
"""INSERT INTO articles
(url_path, slug, title, meta_description, og_image_url,
country, region, language, status, published_at, seo_head)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
country, region, language, status, published_at, seo_head, article_type)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(url_path, article_slug, title, meta_description, og_image_url,
country, region, language, status, pub_dt, seo_head),
country, region, language, status, pub_dt, seo_head, article_type),
)
from ..sitemap import invalidate_sitemap_cache
invalidate_sitemap_cache()
@@ -2807,6 +2843,8 @@ async def article_edit(article_id: int):
language = form.get("language", article.get("language", "en")).strip() or "en"
status = form.get("status", article["status"])
published_at = form.get("published_at", "").strip()
article_type = form.get("article_type", article.get("article_type", "editorial"))
assert article_type in ("editorial", "cornerstone"), f"invalid article_type: {article_type}"
if is_reserved_path(url_path):
await flash(f"URL path '{url_path}' conflicts with a reserved route.", "error")
@@ -2835,10 +2873,10 @@ async def article_edit(article_id: int):
"""UPDATE articles
SET title = ?, url_path = ?, meta_description = ?, og_image_url = ?,
country = ?, region = ?, language = ?, status = ?, published_at = ?,
seo_head = ?, updated_at = ?
seo_head = ?, article_type = ?, updated_at = ?
WHERE id = ?""",
(title, url_path, meta_description, og_image_url,
country, region, language, status, pub_dt, seo_head, now, article_id),
country, region, language, status, pub_dt, seo_head, article_type, now, article_id),
)
await flash("Article updated.", "success")
return redirect(url_for("admin.articles"))

View File

@@ -310,6 +310,13 @@
<option value="de" {% if data.get('language') == 'de' %}selected{% endif %}>DE</option>
</select>
</div>
<div class="ae-field ae-field--fixed120">
<label for="article_type">Type</label>
<select id="article_type" name="article_type">
<option value="editorial" {% if data.get('article_type', 'editorial') == 'editorial' %}selected{% endif %}>Editorial</option>
<option value="cornerstone" {% if data.get('article_type') == 'cornerstone' %}selected{% endif %}>Cornerstone</option>
</select>
</div>
<div class="ae-field ae-field--fixed120">
<label for="status">Status</label>
<select id="status" name="status">

View File

@@ -3,8 +3,23 @@
{% block title %}Articles - Admin - {{ config.APP_NAME }}{% endblock %}
{% block head %}{{ super() }}
<style>
.tab-btn { display:inline-flex; align-items:center; gap:0.4rem;
padding:0.5rem 1rem; font-size:0.8125rem; font-weight:600;
color:#64748B; text-decoration:none; border-bottom:2px solid transparent;
transition: color 0.15s, border-color 0.15s; }
.tab-btn:hover { color:#0F172A; }
.tab-btn--active { color:#1D4ED8; border-bottom-color:#1D4ED8; }
.tab-badge { font-size:0.6875rem; font-weight:700;
background:#F1F5F9; color:#64748B; padding:0.1rem 0.45rem;
border-radius:9999px; min-width:1.25rem; text-align:center; }
.tab-btn--active .tab-badge { background:#EFF6FF; color:#1D4ED8; }
</style>
{% endblock %}
{% block admin_content %}
<header class="flex justify-between items-center mb-6">
<header class="flex justify-between items-center mb-4">
<div>
<h1 class="text-2xl">Articles</h1>
{% include "admin/partials/article_stats.html" %}
@@ -19,6 +34,18 @@
</div>
</header>
{# Tab bar #}
<nav class="flex gap-1 mb-4 border-b border-slate-200" role="tablist">
{% for key, label in [('cornerstone','Cornerstone'),('editorial','Editorial'),('generated','Generated')] %}
<a href="{{ url_for('admin.articles', article_type=key) }}"
role="tab" class="tab-btn {% if current_article_type == key %}tab-btn--active{% endif %}"
hx-boost="true">
{{ label }}
<span class="tab-badge">{{ type_counts[key] }}</span>
</a>
{% endfor %}
</nav>
{# Filters #}
<div class="card mb-6" style="padding:1rem 1.25rem">
<form class="flex flex-wrap gap-3 items-end"
@@ -27,6 +54,7 @@
hx-trigger="change, input delay:300ms"
hx-indicator="#articles-loading">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="article_type" value="{{ current_article_type }}">
<div>
<label class="text-xs font-semibold text-slate block mb-1">Search</label>
@@ -44,16 +72,17 @@
</select>
</div>
{% if current_article_type == 'generated' %}
<div>
<label class="text-xs font-semibold text-slate block mb-1">Template</label>
<select name="template" class="form-input" style="min-width:140px">
<option value="">All</option>
<option value="__manual__" {% if current_template == '__manual__' %}selected{% endif %}>Manual</option>
{% for t in template_slugs %}
<option value="{{ t }}" {% if t == current_template %}selected{% endif %}>{{ t }}</option>
{% endfor %}
</select>
</div>
{% endif %}
<div>
<label class="text-xs font-semibold text-slate block mb-1">Language</label>
@@ -81,6 +110,7 @@
<input type="hidden" name="status" id="article-bulk-status" value="{{ current_status }}">
<input type="hidden" name="template" id="article-bulk-template" value="{{ current_template }}">
<input type="hidden" name="language" id="article-bulk-language" value="{{ current_language }}">
<input type="hidden" name="article_type" id="article-bulk-article-type" value="{{ current_article_type }}">
</form>
<div id="article-bulk-bar" class="card mb-4" style="padding:0.75rem 1.25rem;display:none;align-items:center;gap:1rem;flex-wrap:wrap;background:#EFF6FF;border:1px solid #BFDBFE;">
<span id="article-bulk-count" class="text-sm font-semibold text-navy">0 selected</span>
@@ -201,6 +231,7 @@ function updateArticleBulkBar() {
status: document.getElementById('article-bulk-status').value,
template: document.getElementById('article-bulk-template').value,
language: document.getElementById('article-bulk-language').value,
article_type: document.getElementById('article-bulk-article-type').value,
});
fetch('{{ url_for("admin.articles_matching_count") }}?' + params.toString())
.then(function(r) { return r.text(); })
@@ -260,10 +291,12 @@ function syncBulkFilters() {
var statusEl = document.getElementById('article-bulk-status');
var templateEl = document.getElementById('article-bulk-template');
var languageEl = document.getElementById('article-bulk-language');
var typeEl = document.getElementById('article-bulk-article-type');
if (searchEl) searchEl.value = fd.get('search') || '';
if (statusEl) statusEl.value = fd.get('status') || '';
if (templateEl) templateEl.value = fd.get('template') || '';
if (languageEl) languageEl.value = fd.get('language') || '';
if (typeEl) typeEl.value = fd.get('article_type') || '';
// Changing filters clears apply-to-all and resets selection
clearArticleSelection();
}

View File

@@ -31,5 +31,5 @@
{% endfor %}
</td>
<td class="mono">{{ g.published_at[:10] if g.published_at else '-' }}</td>
<td class="text-slate">{{ g.template_slug or 'Manual' }}</td>
{% if current_article_type == 'generated' %}<td class="text-slate">{{ g.template_slug or '-' }}</td>{% endif %}
</tr>

View File

@@ -63,7 +63,7 @@
<th>{% if grouped %}Variants{% else %}Status{% endif %}</th>
<th>Published</th>
{% if not grouped %}<th>Lang</th>{% endif %}
<th>Template</th>
{% if current_article_type == 'generated' %}<th>Template</th>{% endif %}
{% if not grouped %}<th></th>{% endif %}
</tr>
</thead>

View File

@@ -17,7 +17,7 @@
</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>
{% if current_article_type == 'generated' %}<td class="text-slate">{{ a.template_slug or '-' }}</td>{% endif %}
<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>

View File

@@ -520,8 +520,8 @@ async def generate_articles(
"""INSERT INTO articles
(url_path, slug, title, meta_description, country, region,
status, published_at, template_slug, language, date_modified,
seo_head, noindex, created_at)
VALUES (?, ?, ?, ?, ?, ?, 'published', ?, ?, ?, ?, ?, ?, ?)
seo_head, noindex, article_type, created_at)
VALUES (?, ?, ?, ?, ?, ?, 'published', ?, ?, ?, ?, ?, ?, 'generated', ?)
ON CONFLICT(url_path, language) DO UPDATE SET
title = excluded.title,
meta_description = excluded.meta_description,
@@ -529,6 +529,7 @@ async def generate_articles(
date_modified = excluded.date_modified,
seo_head = excluded.seo_head,
noindex = excluded.noindex,
article_type = 'generated',
updated_at = excluded.date_modified""",
(
url_path, article_slug, title, meta_desc,

View File

@@ -0,0 +1,25 @@
"""Migration 0029: Add article_type column to articles table.
Values: 'cornerstone' | 'editorial' | 'generated'
Backfill from existing data:
- template_slug IS NOT NULL → generated
- template_slug IS NULL AND group_key IS NOT NULL → cornerstone
- template_slug IS NULL AND group_key IS NULL → editorial
"""
def up(conn) -> None:
conn.execute("""
ALTER TABLE articles ADD COLUMN article_type TEXT NOT NULL DEFAULT 'editorial'
""")
conn.execute("""
UPDATE articles SET article_type = 'generated'
WHERE template_slug IS NOT NULL
""")
conn.execute("""
UPDATE articles SET article_type = 'cornerstone'
WHERE template_slug IS NULL AND group_key IS NOT NULL
""")
conn.execute("""
CREATE INDEX IF NOT EXISTS idx_articles_article_type ON articles(article_type)
""")