From 39225d6cfd8feeb978d2960fe99a98dbd46b9363 Mon Sep 17 00:00:00 2001 From: Deeman Date: Sat, 7 Mar 2026 12:21:07 +0100 Subject: [PATCH 1/2] 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 --- web/src/padelnomics/admin/routes.py | 112 ++++++++++++------ .../admin/templates/admin/article_form.html | 7 ++ .../admin/templates/admin/articles.html | 37 +++++- .../admin/partials/article_group_row.html | 2 +- .../admin/partials/article_results.html | 2 +- .../templates/admin/partials/article_row.html | 2 +- web/src/padelnomics/content/__init__.py | 5 +- .../migrations/versions/0029_article_type.py | 25 ++++ 8 files changed, 148 insertions(+), 44 deletions(-) create mode 100644 web/src/padelnomics/migrations/versions/0029_article_type.py diff --git a/web/src/padelnomics/admin/routes.py b/web/src/padelnomics/admin/routes.py index 398b022..2d8c11b 100644 --- a/web/src/padelnomics/admin/routes.py +++ b/web/src/padelnomics/admin/routes.py @@ -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")) diff --git a/web/src/padelnomics/admin/templates/admin/article_form.html b/web/src/padelnomics/admin/templates/admin/article_form.html index ae2b9f0..d45e2cf 100644 --- a/web/src/padelnomics/admin/templates/admin/article_form.html +++ b/web/src/padelnomics/admin/templates/admin/article_form.html @@ -310,6 +310,13 @@ +
+ + +
+
@@ -44,16 +72,17 @@
+ {% if current_article_type == 'generated' %}
+ {% endif %}
@@ -81,6 +110,7 @@ +