Compare commits

...

9 Commits

Author SHA1 Message Date
Deeman
055cc23482 test(admin): regression tests — article delete never removes .md source
All checks were successful
CI / test (push) Successful in 1m3s
CI / tag (push) Successful in 3s
2026-03-07 14:10:41 +01:00
Deeman
9f8afdbda7 test(admin): regression tests — article delete never removes .md source
Three cases: single delete, bulk by IDs, bulk apply_to_all.
Also extends _create_article() helper with article_type param.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 14:10:41 +01:00
Deeman
66353b3da1 fix(admin): article delete only removes build file + DB row, never .md source 2026-03-07 13:52:24 +01:00
Deeman
15378b1804 fix(admin): article delete only removes build file + DB row, never .md source
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 13:52:17 +01:00
Deeman
03fdec7297 feat(admin): article type tabs + fix affiliate delete buttons
- Migration 0029: article_type column (cornerstone/editorial/generated)
- Tab bar on /admin/articles with per-type counts
- Template filter only on Generated tab; delete guard uses article_type
- Type dropdown in article_new/edit form
- Fix: affiliate program and product Delete buttons had missing text/tag
2026-03-07 13:50:44 +01:00
Deeman
608f0356a5 fix(admin): affiliate program + product delete buttons missing text/closing tag
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 13:50:35 +01:00
Deeman
39225d6cfd 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>
2026-03-07 12:21:07 +01:00
Deeman
e537bfd9d3 fix(admin): protect cornerstone .md files from bulk delete + fix PDF 500
All checks were successful
CI / test (push) Successful in 1m0s
CI / tag (push) Successful in 3s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 11:13:23 +01:00
Deeman
a27da79705 fix(admin): protect cornerstone .md files from bulk delete + fix PDF 500
- Bulk delete (both explicit-IDs and apply_to_all paths) now only unlinks
  source .md files for generated articles (template_slug IS NOT NULL).
  Manual cornerstone articles keep their .md source on disk.

- _sync_static_articles() now also renders markdown → HTML and writes to
  BUILD_DIR/<lang>/<slug>.html after upserting the DB row, so cornerstones
  are immediately servable after a sync without a separate rebuild step.

- scenario_pdf(): replace d = json.loads(scenario["calc_json"]) with
  d = calc(state) so all current calc fields (moic, dscr, cashOnCash, …)
  are present and the PDF route no longer 500s on stale stored JSON.

- Restored data/content/articles/ cornerstone .md files via git checkout.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 11:09:19 +01:00
11 changed files with 255 additions and 55 deletions

View File

@@ -2142,7 +2142,7 @@ async def scenario_preview(scenario_id: int):
async def scenario_pdf(scenario_id: int): async def scenario_pdf(scenario_id: int):
"""Generate and immediately download a business plan PDF for a published scenario.""" """Generate and immediately download a business plan PDF for a published scenario."""
from ..businessplan import get_plan_sections from ..businessplan import get_plan_sections
from ..planner.calculator import validate_state from ..planner.calculator import calc, validate_state
scenario = await fetch_one("SELECT * FROM published_scenarios WHERE id = ?", (scenario_id,)) scenario = await fetch_one("SELECT * FROM published_scenarios WHERE id = ?", (scenario_id,))
if not scenario: if not scenario:
@@ -2153,7 +2153,7 @@ async def scenario_pdf(scenario_id: int):
lang = "en" lang = "en"
state = validate_state(json.loads(scenario["state_json"])) state = validate_state(json.loads(scenario["state_json"]))
d = json.loads(scenario["calc_json"]) d = calc(state)
sections = get_plan_sections(state, d, lang) sections = get_plan_sections(state, d, lang)
sections["scenario_name"] = scenario["title"] sections["scenario_name"] = scenario["title"]
sections["location"] = scenario.get("location", "") sections["location"] = scenario.get("location", "")
@@ -2255,13 +2255,14 @@ async def _sync_static_articles() -> None:
meta_description = fm.get("meta_description", "") meta_description = fm.get("meta_description", "")
template_slug = fm.get("template_slug") or None template_slug = fm.get("template_slug") or None
group_key = fm.get("cornerstone") or None group_key = fm.get("cornerstone") or None
article_type = "cornerstone" if fm.get("cornerstone") else "editorial"
now_iso = utcnow_iso() now_iso = utcnow_iso()
await execute( await execute(
"""INSERT INTO articles """INSERT INTO articles
(slug, title, url_path, language, meta_description, (slug, title, url_path, language, meta_description,
status, template_slug, group_key, created_at, updated_at) status, template_slug, group_key, article_type, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, 'draft', ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, 'draft', ?, ?, ?, ?, ?)
ON CONFLICT(slug) DO UPDATE SET ON CONFLICT(slug) DO UPDATE SET
title = excluded.title, title = excluded.title,
url_path = excluded.url_path, url_path = excluded.url_path,
@@ -2269,23 +2270,33 @@ async def _sync_static_articles() -> None:
meta_description = excluded.meta_description, meta_description = excluded.meta_description,
template_slug = excluded.template_slug, template_slug = excluded.template_slug,
group_key = excluded.group_key, group_key = excluded.group_key,
article_type = excluded.article_type,
updated_at = excluded.updated_at""", updated_at = excluded.updated_at""",
(slug, title, url_path, language, meta_description, (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)
if template_slug is None:
from ..content.routes import BUILD_DIR, bake_product_cards, bake_scenario_cards
body = raw[m.end():]
body_html = mistune.html(body)
body_html = await bake_scenario_cards(body_html, lang=language)
body_html = await bake_product_cards(body_html, lang=language)
build_dir = BUILD_DIR / language
build_dir.mkdir(parents=True, exist_ok=True)
(build_dir / f"{slug}.html").write_text(body_html)
def _build_article_where( def _build_article_where(
status: str = None, status: str = None,
template_slug: str = None, template_slug: str = None,
language: str = None, language: str = None,
search: str = None, search: str = None,
article_type: str = None,
) -> tuple[list[str], list]: ) -> tuple[list[str], list]:
"""Build WHERE clauses and params for article queries. """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).
"""
wheres = ["1=1"] wheres = ["1=1"]
params: list = [] params: list = []
@@ -2295,9 +2306,7 @@ def _build_article_where(
wheres.append("status = 'published' AND published_at > datetime('now')") wheres.append("status = 'published' AND published_at > datetime('now')")
elif status == "draft": elif status == "draft":
wheres.append("status = 'draft'") wheres.append("status = 'draft'")
if template_slug == "__manual__": if template_slug:
wheres.append("template_slug IS NULL")
elif template_slug:
wheres.append("template_slug = ?") wheres.append("template_slug = ?")
params.append(template_slug) params.append(template_slug)
if language: if language:
@@ -2306,6 +2315,9 @@ def _build_article_where(
if search: if search:
wheres.append("title LIKE ?") wheres.append("title LIKE ?")
params.append(f"%{search}%") params.append(f"%{search}%")
if article_type:
wheres.append("article_type = ?")
params.append(article_type)
return wheres, params return wheres, params
@@ -2315,12 +2327,14 @@ async def _get_article_list(
template_slug: str = None, template_slug: str = None,
language: str = None, language: str = None,
search: str = None, search: str = None,
article_type: str = None,
page: int = 1, page: int = 1,
per_page: int = 50, per_page: int = 50,
) -> list[dict]: ) -> list[dict]:
"""Get articles with optional filters and pagination.""" """Get articles with optional filters and pagination."""
wheres, params = _build_article_where(status=status, template_slug=template_slug, 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) where = " AND ".join(wheres)
offset = (page - 1) * per_page offset = (page - 1) * per_page
params.extend([per_page, offset]) params.extend([per_page, offset])
@@ -2341,6 +2355,7 @@ async def _get_article_list_grouped(
status: str = None, status: str = None,
template_slug: str = None, template_slug: str = None,
search: str = None, search: str = None,
article_type: str = None,
page: int = 1, page: int = 1,
per_page: int = 50, per_page: int = 50,
) -> list[dict]: ) -> list[dict]:
@@ -2351,7 +2366,7 @@ async def _get_article_list_grouped(
Each returned item has a 'variants' list (one dict per language variant). Each returned item has a 'variants' list (one dict per language variant).
""" """
wheres, params = _build_article_where(status=status, template_slug=template_slug, wheres, params = _build_article_where(status=status, template_slug=template_slug,
search=search) search=search, article_type=article_type)
where = " AND ".join(wheres) where = " AND ".join(wheres)
offset = (page - 1) * per_page offset = (page - 1) * per_page
@@ -2406,19 +2421,32 @@ async def _get_article_list_grouped(
return groups 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.""" """Get aggregate article stats for the admin list header."""
where = f"WHERE article_type = '{article_type}'" if article_type else ""
row = await fetch_one( row = await fetch_one(
"""SELECT f"""SELECT
COUNT(*) AS total, 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 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='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 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} 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: async def _is_generating() -> bool:
"""Return True if a generate_articles task is currently pending.""" """Return True if a generate_articles task is currently pending."""
row = await fetch_one( row = await fetch_one(
@@ -2436,34 +2464,43 @@ async def articles():
status_filter = request.args.get("status", "") status_filter = request.args.get("status", "")
template_filter = request.args.get("template", "") template_filter = request.args.get("template", "")
language_filter = request.args.get("language", "") 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")) page = max(1, int(request.args.get("page", "1") or "1"))
grouped = not language_filter grouped = not language_filter
if grouped: if grouped:
article_list = await _get_article_list_grouped( article_list = await _get_article_list_grouped(
status=status_filter or None, template_slug=template_filter or None, 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: else:
article_list = await _get_article_list( article_list = await _get_article_list(
status=status_filter or None, template_slug=template_filter or None, 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() stats = await _get_article_stats(article_type=article_type or None)
templates = await fetch_all( type_counts = await _get_article_type_counts()
"SELECT DISTINCT template_slug FROM articles WHERE template_slug IS NOT NULL ORDER BY template_slug"
) 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( return await render_template(
"admin/articles.html", "admin/articles.html",
articles=article_list, articles=article_list,
grouped=grouped, grouped=grouped,
stats=stats, stats=stats,
template_slugs=[t["template_slug"] for t in templates], template_slugs=template_slugs,
current_search=search, current_search=search,
current_status=status_filter, current_status=status_filter,
current_template=template_filter, current_template=template_filter,
current_language=language_filter, current_language=language_filter,
current_article_type=article_type,
type_counts=type_counts,
page=page, page=page,
is_generating=await _is_generating(), is_generating=await _is_generating(),
) )
@@ -2489,23 +2526,26 @@ async def article_results():
status_filter = request.args.get("status", "") status_filter = request.args.get("status", "")
template_filter = request.args.get("template", "") template_filter = request.args.get("template", "")
language_filter = request.args.get("language", "") 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")) page = max(1, int(request.args.get("page", "1") or "1"))
grouped = not language_filter grouped = not language_filter
if grouped: if grouped:
article_list = await _get_article_list_grouped( article_list = await _get_article_list_grouped(
status=status_filter or None, template_slug=template_filter or None, 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: else:
article_list = await _get_article_list( article_list = await _get_article_list(
status=status_filter or None, template_slug=template_filter or None, 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( return await render_template(
"admin/partials/article_results.html", "admin/partials/article_results.html",
articles=article_list, articles=article_list,
grouped=grouped, grouped=grouped,
current_article_type=article_type,
page=page, page=page,
is_generating=await _is_generating(), is_generating=await _is_generating(),
) )
@@ -2518,6 +2558,7 @@ async def articles_matching_count():
status_filter = request.args.get("status", "") status_filter = request.args.get("status", "")
template_filter = request.args.get("template", "") template_filter = request.args.get("template", "")
language_filter = request.args.get("language", "") language_filter = request.args.get("language", "")
article_type = request.args.get("article_type", "cornerstone")
search = request.args.get("search", "").strip() search = request.args.get("search", "").strip()
wheres, params = _build_article_where( wheres, params = _build_article_where(
@@ -2525,6 +2566,7 @@ async def articles_matching_count():
template_slug=template_filter or None, template_slug=template_filter or None,
language=language_filter or None, language=language_filter or None,
search=search or None, search=search or None,
article_type=article_type or None,
) )
where = " AND ".join(wheres) where = " AND ".join(wheres)
row = await fetch_one(f"SELECT COUNT(*) AS cnt FROM articles WHERE {where}", tuple(params)) row = await fetch_one(f"SELECT COUNT(*) AS cnt FROM articles WHERE {where}", tuple(params))
@@ -2551,6 +2593,7 @@ async def articles_bulk():
status_filter = form.get("status", "") status_filter = form.get("status", "")
template_filter = form.get("template", "") template_filter = form.get("template", "")
language_filter = form.get("language", "") language_filter = form.get("language", "")
article_type = form.get("article_type", "cornerstone")
valid_actions = ("publish", "unpublish", "toggle_noindex", "rebuild", "delete") valid_actions = ("publish", "unpublish", "toggle_noindex", "rebuild", "delete")
if action not in valid_actions: if action not in valid_actions:
@@ -2564,6 +2607,7 @@ async def articles_bulk():
template_slug=template_filter or None, template_slug=template_filter or None,
language=language_filter or None, language=language_filter or None,
search=search or None, search=search or None,
article_type=article_type or None,
) )
where = " AND ".join(wheres) where = " AND ".join(wheres)
@@ -2613,15 +2657,13 @@ async def articles_bulk():
from ..content.routes import BUILD_DIR from ..content.routes import BUILD_DIR
rows = await fetch_all( rows = await fetch_all(
f"SELECT id, slug FROM articles WHERE {where} LIMIT 5000", tuple(where_params) f"SELECT id, slug FROM articles WHERE {where} LIMIT 5000",
tuple(where_params),
) )
for a in rows: for a in rows:
build_path = BUILD_DIR / f"{a['slug']}.html" build_path = BUILD_DIR / f"{a['slug']}.html"
if build_path.exists(): if build_path.exists():
build_path.unlink() build_path.unlink()
md_path = Path("data/content/articles") / f"{a['slug']}.md"
if md_path.exists():
md_path.unlink()
await execute(f"DELETE FROM articles WHERE {where}", tuple(where_params)) await execute(f"DELETE FROM articles WHERE {where}", tuple(where_params))
from ..sitemap import invalidate_sitemap_cache from ..sitemap import invalidate_sitemap_cache
invalidate_sitemap_cache() invalidate_sitemap_cache()
@@ -2674,9 +2716,6 @@ async def articles_bulk():
build_path = BUILD_DIR / f"{a['slug']}.html" build_path = BUILD_DIR / f"{a['slug']}.html"
if build_path.exists(): if build_path.exists():
build_path.unlink() build_path.unlink()
md_path = Path("data/content/articles") / f"{a['slug']}.md"
if md_path.exists():
md_path.unlink()
await execute( await execute(
f"DELETE FROM articles WHERE id IN ({placeholders})", f"DELETE FROM articles WHERE id IN ({placeholders})",
tuple(article_ids), tuple(article_ids),
@@ -2689,17 +2728,19 @@ async def articles_bulk():
if grouped: if grouped:
article_list = await _get_article_list_grouped( article_list = await _get_article_list_grouped(
status=status_filter or None, template_slug=template_filter or None, 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: else:
article_list = await _get_article_list( article_list = await _get_article_list(
status=status_filter or None, template_slug=template_filter or None, status=status_filter or None, template_slug=template_filter or None,
language=language_filter or None, search=search or None, language=language_filter or None, search=search or None,
article_type=article_type or None,
) )
return await render_template( return await render_template(
"admin/partials/article_results.html", "admin/partials/article_results.html",
articles=article_list, articles=article_list,
grouped=grouped, grouped=grouped,
current_article_type=article_type,
page=1, page=1,
is_generating=await _is_generating(), is_generating=await _is_generating(),
) )
@@ -2730,6 +2771,8 @@ async def article_new():
language = form.get("language", "en").strip() or "en" language = form.get("language", "en").strip() or "en"
status = form.get("status", "draft") status = form.get("status", "draft")
published_at = form.get("published_at", "").strip() 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: if not title or not body:
await flash("Title and body are required.", "error") await flash("Title and body are required.", "error")
@@ -2759,10 +2802,10 @@ async def article_new():
await execute( await execute(
"""INSERT INTO articles """INSERT INTO articles
(url_path, slug, title, meta_description, og_image_url, (url_path, slug, title, meta_description, og_image_url,
country, region, language, status, published_at, seo_head) country, region, language, status, published_at, seo_head, article_type)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(url_path, article_slug, title, meta_description, og_image_url, (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 from ..sitemap import invalidate_sitemap_cache
invalidate_sitemap_cache() invalidate_sitemap_cache()
@@ -2802,6 +2845,8 @@ async def article_edit(article_id: int):
language = form.get("language", article.get("language", "en")).strip() or "en" language = form.get("language", article.get("language", "en")).strip() or "en"
status = form.get("status", article["status"]) status = form.get("status", article["status"])
published_at = form.get("published_at", "").strip() 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): if is_reserved_path(url_path):
await flash(f"URL path '{url_path}' conflicts with a reserved route.", "error") await flash(f"URL path '{url_path}' conflicts with a reserved route.", "error")
@@ -2830,10 +2875,10 @@ async def article_edit(article_id: int):
"""UPDATE articles """UPDATE articles
SET title = ?, url_path = ?, meta_description = ?, og_image_url = ?, SET title = ?, url_path = ?, meta_description = ?, og_image_url = ?,
country = ?, region = ?, language = ?, status = ?, published_at = ?, country = ?, region = ?, language = ?, status = ?, published_at = ?,
seo_head = ?, updated_at = ? seo_head = ?, article_type = ?, updated_at = ?
WHERE id = ?""", WHERE id = ?""",
(title, url_path, meta_description, og_image_url, (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") await flash("Article updated.", "success")
return redirect(url_for("admin.articles")) return redirect(url_for("admin.articles"))
@@ -2896,14 +2941,10 @@ async def article_delete(article_id: int):
"""Delete an article.""" """Delete an article."""
article = await fetch_one("SELECT slug FROM articles WHERE id = ?", (article_id,)) article = await fetch_one("SELECT slug FROM articles WHERE id = ?", (article_id,))
if article: if article:
# Clean up files
from ..content.routes import BUILD_DIR from ..content.routes import BUILD_DIR
build_path = BUILD_DIR / f"{article['slug']}.html" build_path = BUILD_DIR / f"{article['slug']}.html"
if build_path.exists(): if build_path.exists():
build_path.unlink() build_path.unlink()
md_path = Path("data/content/articles") / f"{article['slug']}.md"
if md_path.exists():
md_path.unlink()
await execute("DELETE FROM articles WHERE id = ?", (article_id,)) await execute("DELETE FROM articles WHERE id = ?", (article_id,))

View File

@@ -310,6 +310,13 @@
<option value="de" {% if data.get('language') == 'de' %}selected{% endif %}>DE</option> <option value="de" {% if data.get('language') == 'de' %}selected{% endif %}>DE</option>
</select> </select>
</div> </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"> <div class="ae-field ae-field--fixed120">
<label for="status">Status</label> <label for="status">Status</label>
<select id="status" name="status"> <select id="status" name="status">

View File

@@ -3,8 +3,23 @@
{% block title %}Articles - Admin - {{ config.APP_NAME }}{% endblock %} {% 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 %} {% block admin_content %}
<header class="flex justify-between items-center mb-6"> <header class="flex justify-between items-center mb-4">
<div> <div>
<h1 class="text-2xl">Articles</h1> <h1 class="text-2xl">Articles</h1>
{% include "admin/partials/article_stats.html" %} {% include "admin/partials/article_stats.html" %}
@@ -19,6 +34,18 @@
</div> </div>
</header> </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 #} {# Filters #}
<div class="card mb-6" style="padding:1rem 1.25rem"> <div class="card mb-6" style="padding:1rem 1.25rem">
<form class="flex flex-wrap gap-3 items-end" <form class="flex flex-wrap gap-3 items-end"
@@ -27,6 +54,7 @@
hx-trigger="change, input delay:300ms" hx-trigger="change, input delay:300ms"
hx-indicator="#articles-loading"> hx-indicator="#articles-loading">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="article_type" value="{{ current_article_type }}">
<div> <div>
<label class="text-xs font-semibold text-slate block mb-1">Search</label> <label class="text-xs font-semibold text-slate block mb-1">Search</label>
@@ -44,16 +72,17 @@
</select> </select>
</div> </div>
{% if current_article_type == 'generated' %}
<div> <div>
<label class="text-xs font-semibold text-slate block mb-1">Template</label> <label class="text-xs font-semibold text-slate block mb-1">Template</label>
<select name="template" class="form-input" style="min-width:140px"> <select name="template" class="form-input" style="min-width:140px">
<option value="">All</option> <option value="">All</option>
<option value="__manual__" {% if current_template == '__manual__' %}selected{% endif %}>Manual</option>
{% for t in template_slugs %} {% for t in template_slugs %}
<option value="{{ t }}" {% if t == current_template %}selected{% endif %}>{{ t }}</option> <option value="{{ t }}" {% if t == current_template %}selected{% endif %}>{{ t }}</option>
{% endfor %} {% endfor %}
</select> </select>
</div> </div>
{% endif %}
<div> <div>
<label class="text-xs font-semibold text-slate block mb-1">Language</label> <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="status" id="article-bulk-status" value="{{ current_status }}">
<input type="hidden" name="template" id="article-bulk-template" value="{{ current_template }}"> <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="language" id="article-bulk-language" value="{{ current_language }}">
<input type="hidden" name="article_type" id="article-bulk-article-type" value="{{ current_article_type }}">
</form> </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;"> <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> <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, status: document.getElementById('article-bulk-status').value,
template: document.getElementById('article-bulk-template').value, template: document.getElementById('article-bulk-template').value,
language: document.getElementById('article-bulk-language').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()) fetch('{{ url_for("admin.articles_matching_count") }}?' + params.toString())
.then(function(r) { return r.text(); }) .then(function(r) { return r.text(); })
@@ -260,10 +291,12 @@ function syncBulkFilters() {
var statusEl = document.getElementById('article-bulk-status'); var statusEl = document.getElementById('article-bulk-status');
var templateEl = document.getElementById('article-bulk-template'); var templateEl = document.getElementById('article-bulk-template');
var languageEl = document.getElementById('article-bulk-language'); var languageEl = document.getElementById('article-bulk-language');
var typeEl = document.getElementById('article-bulk-article-type');
if (searchEl) searchEl.value = fd.get('search') || ''; if (searchEl) searchEl.value = fd.get('search') || '';
if (statusEl) statusEl.value = fd.get('status') || ''; if (statusEl) statusEl.value = fd.get('status') || '';
if (templateEl) templateEl.value = fd.get('template') || ''; if (templateEl) templateEl.value = fd.get('template') || '';
if (languageEl) languageEl.value = fd.get('language') || ''; if (languageEl) languageEl.value = fd.get('language') || '';
if (typeEl) typeEl.value = fd.get('article_type') || '';
// Changing filters clears apply-to-all and resets selection // Changing filters clears apply-to-all and resets selection
clearArticleSelection(); clearArticleSelection();
} }

View File

@@ -24,6 +24,7 @@
<form method="post" action="{{ url_for('admin.affiliate_program_delete', program_id=prog.id) }}" style="display:inline" hx-boost="true"> <form method="post" action="{{ url_for('admin.affiliate_program_delete', program_id=prog.id) }}" style="display:inline" hx-boost="true">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn-outline btn-sm" <button type="submit" class="btn-outline btn-sm"
onclick="return confirm('Delete this program?')">Delete</button>
</form> </form>
</td> </td>
</tr> </tr>

View File

@@ -23,6 +23,7 @@
<form method="post" action="{{ url_for('admin.affiliate_delete', product_id=product.id) }}" style="display:inline" hx-boost="true"> <form method="post" action="{{ url_for('admin.affiliate_delete', product_id=product.id) }}" style="display:inline" hx-boost="true">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn-outline btn-sm" <button type="submit" class="btn-outline btn-sm"
onclick="return confirm('Delete this product?')">Delete</button>
</form> </form>
</td> </td>
</tr> </tr>

View File

@@ -31,5 +31,5 @@
{% endfor %} {% endfor %}
</td> </td>
<td class="mono">{{ g.published_at[:10] if g.published_at else '-' }}</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> </tr>

View File

@@ -63,7 +63,7 @@
<th>{% if grouped %}Variants{% else %}Status{% endif %}</th> <th>{% if grouped %}Variants{% else %}Status{% endif %}</th>
<th>Published</th> <th>Published</th>
{% if not grouped %}<th>Lang</th>{% endif %} {% 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 %} {% if not grouped %}<th></th>{% endif %}
</tr> </tr>
</thead> </thead>

View File

@@ -17,7 +17,7 @@
</td> </td>
<td class="mono">{{ a.published_at[:10] if a.published_at else '-' }}</td> <td class="mono">{{ a.published_at[:10] if a.published_at else '-' }}</td>
<td>{{ a.language | upper if a.language 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"> <td class="text-right" style="white-space:nowrap">
{% if a.display_status == 'live' %} {% if a.display_status == 'live' %}
<a href="/{{ a.language or 'en' }}{{ a.url_path }}" target="_blank" class="btn-outline btn-sm">View</a> <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 """INSERT INTO articles
(url_path, slug, title, meta_description, country, region, (url_path, slug, title, meta_description, country, region,
status, published_at, template_slug, language, date_modified, status, published_at, template_slug, language, date_modified,
seo_head, noindex, created_at) seo_head, noindex, article_type, created_at)
VALUES (?, ?, ?, ?, ?, ?, 'published', ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, 'published', ?, ?, ?, ?, ?, ?, 'generated', ?)
ON CONFLICT(url_path, language) DO UPDATE SET ON CONFLICT(url_path, language) DO UPDATE SET
title = excluded.title, title = excluded.title,
meta_description = excluded.meta_description, meta_description = excluded.meta_description,
@@ -529,6 +529,7 @@ async def generate_articles(
date_modified = excluded.date_modified, date_modified = excluded.date_modified,
seo_head = excluded.seo_head, seo_head = excluded.seo_head,
noindex = excluded.noindex, noindex = excluded.noindex,
article_type = 'generated',
updated_at = excluded.date_modified""", updated_at = excluded.date_modified""",
( (
url_path, article_slug, title, meta_desc, 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)
""")

View File

@@ -68,16 +68,17 @@ async def _create_published_scenario(slug="test-scenario", city="TestCity", coun
async def _create_article(slug="test-article", url_path="/test-article", async def _create_article(slug="test-article", url_path="/test-article",
status="published", published_at=None): status="published", published_at=None,
article_type="editorial"):
"""Insert an article row, return its id.""" """Insert an article row, return its id."""
pub = published_at or utcnow_iso() pub = published_at or utcnow_iso()
return await execute( return await execute(
"""INSERT INTO articles """INSERT INTO articles
(url_path, slug, title, meta_description, country, region, (url_path, slug, title, meta_description, country, region,
status, published_at) status, published_at, article_type)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(url_path, slug, f"Title {slug}", f"Desc {slug}", "US", "North America", (url_path, slug, f"Title {slug}", f"Desc {slug}", "US", "North America",
status, pub), status, pub, article_type),
) )
@@ -1228,6 +1229,96 @@ class TestAdminArticles:
assert resp.status_code == 302 assert resp.status_code == 302
assert await fetch_one("SELECT 1 FROM articles WHERE id = ?", (article_id,)) is None assert await fetch_one("SELECT 1 FROM articles WHERE id = ?", (article_id,)) is None
async def test_delete_never_removes_md_source(self, admin_client, db, tmp_path, monkeypatch):
"""Regression: deleting an article must NOT touch source .md files."""
import padelnomics.content.routes as content_routes_mod
build_dir = tmp_path / "build"
build_dir.mkdir()
monkeypatch.setattr(content_routes_mod, "BUILD_DIR", build_dir)
(build_dir / "del-safe.html").write_text("<p>built</p>")
md_file = tmp_path / "del-safe.md"
md_file.write_text("# Source")
article_id = await _create_article(slug="del-safe", url_path="/del-safe")
async with admin_client.session_transaction() as sess:
sess["csrf_token"] = "test"
resp = await admin_client.post(f"/admin/articles/{article_id}/delete", form={
"csrf_token": "test",
})
assert resp.status_code == 302
assert await fetch_one("SELECT 1 FROM articles WHERE id = ?", (article_id,)) is None
assert not (build_dir / "del-safe.html").exists(), "build file should be removed"
assert md_file.exists(), "source .md must NOT be deleted"
async def test_bulk_delete_by_ids_never_removes_md(self, admin_client, db, tmp_path, monkeypatch):
"""Regression: bulk delete by explicit IDs must NOT touch source .md files."""
import padelnomics.content.routes as content_routes_mod
build_dir = tmp_path / "build"
build_dir.mkdir()
monkeypatch.setattr(content_routes_mod, "BUILD_DIR", build_dir)
(build_dir / "bulk-del-1.html").write_text("<p>1</p>")
(build_dir / "bulk-del-2.html").write_text("<p>2</p>")
md1 = tmp_path / "bulk-del-1.md"
md2 = tmp_path / "bulk-del-2.md"
md1.write_text("# One")
md2.write_text("# Two")
id1 = await _create_article(slug="bulk-del-1", url_path="/bulk-del-1", article_type="generated")
id2 = await _create_article(slug="bulk-del-2", url_path="/bulk-del-2", article_type="cornerstone")
async with admin_client.session_transaction() as sess:
sess["csrf_token"] = "test"
resp = await admin_client.post("/admin/articles/bulk", form={
"csrf_token": "test",
"action": "delete",
"article_ids": f"{id1},{id2}",
"apply_to_all": "false",
"article_type": "generated",
})
assert resp.status_code == 200
assert await fetch_one("SELECT 1 FROM articles WHERE id = ?", (id1,)) is None
assert await fetch_one("SELECT 1 FROM articles WHERE id = ?", (id2,)) is None
assert not (build_dir / "bulk-del-1.html").exists()
assert not (build_dir / "bulk-del-2.html").exists()
assert md1.exists(), "generated article .md must NOT be deleted"
assert md2.exists(), "cornerstone article .md must NOT be deleted"
async def test_bulk_delete_apply_to_all_never_removes_md(self, admin_client, db, tmp_path, monkeypatch):
"""Regression: bulk delete apply_to_all must NOT touch source .md files."""
import padelnomics.content.routes as content_routes_mod
build_dir = tmp_path / "build"
build_dir.mkdir()
monkeypatch.setattr(content_routes_mod, "BUILD_DIR", build_dir)
(build_dir / "ata-del.html").write_text("<p>x</p>")
md_file = tmp_path / "ata-del.md"
md_file.write_text("# Source")
await _create_article(slug="ata-del", url_path="/ata-del", article_type="generated")
async with admin_client.session_transaction() as sess:
sess["csrf_token"] = "test"
resp = await admin_client.post("/admin/articles/bulk", form={
"csrf_token": "test",
"action": "delete",
"apply_to_all": "true",
"article_type": "generated",
"search": "ata-del",
})
assert resp.status_code == 200
assert await fetch_one("SELECT 1 FROM articles WHERE slug = 'ata-del'") is None
assert not (build_dir / "ata-del.html").exists()
assert md_file.exists(), "source .md must NOT be deleted"