Compare commits

...

11 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
Deeman
8d86669360 merge: article bulk select-all matching + cornerstone filter
All checks were successful
CI / test (push) Successful in 59s
CI / tag (push) Successful in 3s
2026-03-06 23:48:26 +01:00
Deeman
7d523250f7 feat(admin): article bulk select-all matching + cornerstone filter
- Extract _build_article_where() helper, eliminating duplicated WHERE
  logic from _get_article_list() and _get_article_list_grouped()
- Add template_slug='__manual__' sentinel → filters template_slug IS NULL
  (cornerstone / hand-written articles without a pSEO template)
- Add GET /articles/matching-count endpoint returning count of articles
  matching current filter params (for the Gmail-style select-all banner)
- Extend POST /articles/bulk with apply_to_all=true mode: builds WHERE
  from filter params instead of explicit IDs; rebuild capped at 2,000,
  delete at 5,000
- Add "Manual" option to Template filter dropdown
- Add Gmail-style "select all matching" banner: appears when select-all
  checkbox is checked, fetches total count, lets user switch to
  apply_to_all mode with confirmation dialog
- Sync filter hidden inputs into bulk form on filter change; changing
  filters resets apply-to-all state and clears selection

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 22:47:10 +01:00
11 changed files with 545 additions and 132 deletions

View File

@@ -2142,7 +2142,7 @@ async def scenario_preview(scenario_id: int):
async def scenario_pdf(scenario_id: int):
"""Generate and immediately download a business plan PDF for a published scenario."""
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,))
if not scenario:
@@ -2153,7 +2153,7 @@ async def scenario_pdf(scenario_id: int):
lang = "en"
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["scenario_name"] = scenario["title"]
sections["location"] = scenario.get("location", "")
@@ -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,21 +2270,33 @@ 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)
if template_slug is None:
from ..content.routes import BUILD_DIR, bake_product_cards, bake_scenario_cards
async def _get_article_list(
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(
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."""
article_type: str = None,
) -> tuple[list[str], list]:
"""Build WHERE clauses and params for article queries."""
wheres = ["1=1"]
params: list = []
@@ -2302,7 +2315,26 @@ async def _get_article_list(
if search:
wheres.append("title LIKE ?")
params.append(f"%{search}%")
if article_type:
wheres.append("article_type = ?")
params.append(article_type)
return wheres, params
async def _get_article_list(
status: str = None,
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,
article_type=article_type)
where = " AND ".join(wheres)
offset = (page - 1) * per_page
params.extend([per_page, offset])
@@ -2323,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]:
@@ -2332,22 +2365,8 @@ async def _get_article_list_grouped(
Static cornerstones (group_key e.g. 'C2') group by cornerstone key regardless of url_path.
Each returned item has a 'variants' list (one dict per language variant).
"""
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 search:
wheres.append("title LIKE ?")
params.append(f"%{search}%")
wheres, params = _build_article_where(status=status, template_slug=template_slug,
search=search, article_type=article_type)
where = " AND ".join(wheres)
offset = (page - 1) * per_page
@@ -2402,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(
@@ -2432,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(),
)
@@ -2485,118 +2526,221 @@ 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(),
)
@bp.route("/articles/matching-count")
@role_required("admin")
async def articles_matching_count():
"""Return count of articles matching current filters (for bulk select-all banner)."""
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(
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,
)
where = " AND ".join(wheres)
row = await fetch_one(f"SELECT COUNT(*) AS cnt FROM articles WHERE {where}", tuple(params))
count = row["cnt"] if row else 0
return f"{count:,}"
@bp.route("/articles/bulk", methods=["POST"])
@role_required("admin")
@csrf_protect
async def articles_bulk():
"""Bulk actions on articles: publish, unpublish, toggle_noindex, rebuild, delete."""
"""Bulk actions on articles: publish, unpublish, toggle_noindex, rebuild, delete.
Supports two modes:
- Explicit IDs: article_ids=1,2,3 (max 500)
- Apply to all matching: apply_to_all=true + filter params (rebuild capped at 2000, delete at 5000)
"""
form = await request.form
ids_raw = form.get("article_ids", "").strip()
action = form.get("action", "").strip()
apply_to_all = form.get("apply_to_all", "").strip() == "true"
valid_actions = ("publish", "unpublish", "toggle_noindex", "rebuild", "delete")
if action not in valid_actions or not ids_raw:
return "", 400
article_ids = [int(i) for i in ids_raw.split(",") if i.strip().isdigit()]
assert len(article_ids) <= 500, "too many article IDs in bulk action"
if not article_ids:
return "", 400
placeholders = ",".join("?" for _ in article_ids)
now = utcnow_iso()
if action == "publish":
await execute(
f"UPDATE articles SET status = 'published', updated_at = ? WHERE id IN ({placeholders})",
(now, *article_ids),
)
from ..sitemap import invalidate_sitemap_cache
invalidate_sitemap_cache()
elif action == "unpublish":
await execute(
f"UPDATE articles SET status = 'draft', updated_at = ? WHERE id IN ({placeholders})",
(now, *article_ids),
)
from ..sitemap import invalidate_sitemap_cache
invalidate_sitemap_cache()
elif action == "toggle_noindex":
await execute(
f"UPDATE articles SET noindex = CASE WHEN noindex = 1 THEN 0 ELSE 1 END, updated_at = ? WHERE id IN ({placeholders})",
(now, *article_ids),
)
elif action == "rebuild":
for aid in article_ids:
await _rebuild_article(aid)
elif action == "delete":
from ..content.routes import BUILD_DIR
articles = await fetch_all(
f"SELECT id, slug FROM articles WHERE id IN ({placeholders})",
tuple(article_ids),
)
for a in articles:
build_path = BUILD_DIR / f"{a['slug']}.html"
if build_path.exists():
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 id IN ({placeholders})",
tuple(article_ids),
)
from ..sitemap import invalidate_sitemap_cache
invalidate_sitemap_cache()
# Re-render results partial with current filters
# Common filter params (used for action scope and re-render)
search = form.get("search", "").strip()
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:
return "", 400
now = utcnow_iso()
if apply_to_all:
wheres, where_params = _build_article_where(
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,
)
where = " AND ".join(wheres)
if action == "rebuild":
count_row = await fetch_one(
f"SELECT COUNT(*) AS cnt FROM articles WHERE {where}", tuple(where_params)
)
count = count_row["cnt"] if count_row else 0
if count > 2000:
return (
f"<p class='text-red-600 p-4'>Too many articles ({count:,}) for bulk rebuild"
f" — max 2,000. Narrow your filters first.</p>",
400,
)
if action == "publish":
await execute(
f"UPDATE articles SET status = 'published', updated_at = ? WHERE {where}",
(now, *where_params),
)
from ..sitemap import invalidate_sitemap_cache
invalidate_sitemap_cache()
elif action == "unpublish":
await execute(
f"UPDATE articles SET status = 'draft', updated_at = ? WHERE {where}",
(now, *where_params),
)
from ..sitemap import invalidate_sitemap_cache
invalidate_sitemap_cache()
elif action == "toggle_noindex":
await execute(
f"UPDATE articles SET noindex = CASE WHEN noindex = 1 THEN 0 ELSE 1 END,"
f" updated_at = ? WHERE {where}",
(now, *where_params),
)
elif action == "rebuild":
rows = await fetch_all(
f"SELECT id FROM articles WHERE {where} LIMIT 2000", tuple(where_params)
)
for r in rows:
await _rebuild_article(r["id"])
elif action == "delete":
from ..content.routes import BUILD_DIR
rows = await fetch_all(
f"SELECT id, slug 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()
await execute(f"DELETE FROM articles WHERE {where}", tuple(where_params))
from ..sitemap import invalidate_sitemap_cache
invalidate_sitemap_cache()
else:
ids_raw = form.get("article_ids", "").strip()
if not ids_raw:
return "", 400
article_ids = [int(i) for i in ids_raw.split(",") if i.strip().isdigit()]
assert len(article_ids) <= 500, "too many article IDs in bulk action"
if not article_ids:
return "", 400
placeholders = ",".join("?" for _ in article_ids)
if action == "publish":
await execute(
f"UPDATE articles SET status = 'published', updated_at = ? WHERE id IN ({placeholders})",
(now, *article_ids),
)
from ..sitemap import invalidate_sitemap_cache
invalidate_sitemap_cache()
elif action == "unpublish":
await execute(
f"UPDATE articles SET status = 'draft', updated_at = ? WHERE id IN ({placeholders})",
(now, *article_ids),
)
from ..sitemap import invalidate_sitemap_cache
invalidate_sitemap_cache()
elif action == "toggle_noindex":
await execute(
f"UPDATE articles SET noindex = CASE WHEN noindex = 1 THEN 0 ELSE 1 END, updated_at = ? WHERE id IN ({placeholders})",
(now, *article_ids),
)
elif action == "rebuild":
for aid in article_ids:
await _rebuild_article(aid)
elif action == "delete":
from ..content.routes import BUILD_DIR
articles_rows = await fetch_all(
f"SELECT id, slug 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()
await execute(
f"DELETE FROM articles WHERE id IN ({placeholders})",
tuple(article_ids),
)
from ..sitemap import invalidate_sitemap_cache
invalidate_sitemap_cache()
# Re-render results partial with current filters
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,
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(),
)
@@ -2627,6 +2771,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")
@@ -2656,10 +2802,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()
@@ -2699,6 +2845,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")
@@ -2727,10 +2875,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"))
@@ -2793,14 +2941,10 @@ async def article_delete(article_id: int):
"""Delete an article."""
article = await fetch_one("SELECT slug FROM articles WHERE id = ?", (article_id,))
if article:
# Clean up files
from ..content.routes import BUILD_DIR
build_path = BUILD_DIR / f"{article['slug']}.html"
if build_path.exists():
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,))

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,6 +72,7 @@
</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">
@@ -53,6 +82,7 @@
{% endfor %}
</select>
</div>
{% endif %}
<div>
<label class="text-xs font-semibold text-slate block mb-1">Language</label>
@@ -75,12 +105,14 @@
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="article_ids" id="article-bulk-ids" value="">
<input type="hidden" name="action" id="article-bulk-action" value="">
<input type="hidden" name="search" value="{{ current_search }}">
<input type="hidden" name="status" value="{{ current_status }}">
<input type="hidden" name="template" value="{{ current_template }}">
<input type="hidden" name="language" value="{{ current_language }}">
<input type="hidden" name="apply_to_all" id="article-bulk-apply-to-all" value="false">
<input type="hidden" name="search" id="article-bulk-search" value="{{ current_search }}">
<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;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>
<select id="article-bulk-action-select" class="form-input" style="min-width:140px;padding:0.25rem 0.5rem;font-size:0.8125rem">
<option value="">Action…</option>
@@ -92,6 +124,20 @@
</select>
<button type="button" class="btn btn-sm" onclick="submitArticleBulk()">Apply</button>
<button type="button" class="btn-outline btn-sm" onclick="clearArticleSelection()">Clear</button>
<span id="article-select-all-banner" style="display:none;font-size:0.8125rem;color:#1E40AF;margin-left:0.5rem">
All <strong id="article-page-count"></strong> on this page selected.
<button type="button" onclick="enableApplyToAll()"
style="background:none;border:none;color:#1D4ED8;font-weight:600;cursor:pointer;text-decoration:underline;padding:0;font-size:inherit">
Select all <span id="article-matching-count"></span> matching instead?
</button>
</span>
<span id="article-apply-to-all-banner" style="display:none;font-size:0.8125rem;color:#991B1B;font-weight:600;margin-left:0.5rem">
All matching articles selected (<span id="article-matching-count-confirm"></span> total).
<button type="button" onclick="disableApplyToAll()"
style="background:none;border:none;color:#1D4ED8;font-weight:400;cursor:pointer;text-decoration:underline;padding:0;font-size:inherit">
Undo
</button>
</span>
</div>
{# Results #}
@@ -101,10 +147,13 @@
<script>
const articleSelectedIds = new Set();
let articleApplyToAll = false;
let articleMatchingCount = 0;
function toggleArticleSelect(id, checked) {
if (checked) articleSelectedIds.add(id);
else articleSelectedIds.delete(id);
disableApplyToAll();
updateArticleBulkBar();
}
@@ -114,30 +163,92 @@ function toggleArticleGroupSelect(checkbox) {
if (checkbox.checked) articleSelectedIds.add(id);
else articleSelectedIds.delete(id);
});
disableApplyToAll();
updateArticleBulkBar();
}
function clearArticleSelection() {
articleSelectedIds.clear();
articleApplyToAll = false;
document.querySelectorAll('.article-checkbox').forEach(function(cb) { cb.checked = false; });
var selectAll = document.getElementById('article-select-all');
if (selectAll) selectAll.checked = false;
updateArticleBulkBar();
}
function enableApplyToAll() {
articleApplyToAll = true;
document.getElementById('article-bulk-apply-to-all').value = 'true';
document.getElementById('article-select-all-banner').style.display = 'none';
document.getElementById('article-apply-to-all-banner').style.display = 'inline';
var confirmEl = document.getElementById('article-matching-count-confirm');
if (confirmEl) confirmEl.textContent = articleMatchingCount.toLocaleString();
document.getElementById('article-bulk-count').textContent = 'All matching selected';
}
function disableApplyToAll() {
articleApplyToAll = false;
document.getElementById('article-bulk-apply-to-all').value = 'false';
document.getElementById('article-select-all-banner').style.display = 'none';
document.getElementById('article-apply-to-all-banner').style.display = 'none';
}
function updateArticleBulkBar() {
var bar = document.getElementById('article-bulk-bar');
var count = document.getElementById('article-bulk-count');
var countEl = document.getElementById('article-bulk-count');
var ids = document.getElementById('article-bulk-ids');
bar.style.display = articleSelectedIds.size > 0 ? 'flex' : 'none';
count.textContent = articleSelectedIds.size + ' selected';
ids.value = Array.from(articleSelectedIds).join(',');
if (articleSelectedIds.size === 0 && !articleApplyToAll) {
bar.style.display = 'none';
return;
}
bar.style.display = 'flex';
if (!articleApplyToAll) {
countEl.textContent = articleSelectedIds.size + ' selected';
ids.value = Array.from(articleSelectedIds).join(',');
}
// Check if select-all is checked → show "select all matching" banner
var selectAll = document.getElementById('article-select-all');
var allOnPage = document.querySelectorAll('.article-checkbox');
var pageCount = 0;
allOnPage.forEach(function(cb) {
if (cb.dataset.ids) {
pageCount += (cb.dataset.ids || '').split(',').filter(Boolean).length;
} else {
pageCount += 1;
}
});
var selectAllBanner = document.getElementById('article-select-all-banner');
if (!articleApplyToAll && selectAll && selectAll.checked && pageCount > 0) {
document.getElementById('article-page-count').textContent = pageCount;
selectAllBanner.style.display = 'inline';
// Fetch count of matching articles
var params = new URLSearchParams({
search: document.getElementById('article-bulk-search').value,
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(); })
.then(function(text) {
articleMatchingCount = parseInt(text.replace(/,/g, ''), 10) || 0;
var el = document.getElementById('article-matching-count');
if (el) el.textContent = text;
});
} else if (!articleApplyToAll) {
selectAllBanner.style.display = 'none';
}
}
function submitArticleBulk() {
var action = document.getElementById('article-bulk-action-select').value;
if (!action) return;
if (articleSelectedIds.size === 0) return;
if (!articleApplyToAll && articleSelectedIds.size === 0) return;
function doSubmit() {
document.getElementById('article-bulk-action').value = action;
@@ -150,7 +261,13 @@ function submitArticleBulk() {
}
if (action === 'delete') {
showConfirm('Delete ' + articleSelectedIds.size + ' articles? This cannot be undone.').then(function(ok) {
var subject = articleApplyToAll
? 'Delete all ' + articleMatchingCount.toLocaleString() + ' matching articles? This cannot be undone.'
: 'Delete ' + articleSelectedIds.size + ' articles? This cannot be undone.';
showConfirm(subject).then(function(ok) { if (ok) doSubmit(); });
} else if (articleApplyToAll) {
var verb = action.charAt(0).toUpperCase() + action.slice(1);
showConfirm(verb + ' all ' + articleMatchingCount.toLocaleString() + ' matching articles?').then(function(ok) {
if (ok) doSubmit();
});
} else {
@@ -158,6 +275,32 @@ function submitArticleBulk() {
}
}
// Sync filter values into bulk form hidden inputs when filters change
document.addEventListener('DOMContentLoaded', function() {
var filterForm = document.querySelector('form[hx-get*="article_results"]');
if (!filterForm) return;
filterForm.addEventListener('change', syncBulkFilters);
filterForm.addEventListener('input', syncBulkFilters);
});
function syncBulkFilters() {
var filterForm = document.querySelector('form[hx-get*="article_results"]');
if (!filterForm) return;
var fd = new FormData(filterForm);
var searchEl = document.getElementById('article-bulk-search');
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();
}
document.body.addEventListener('htmx:afterSwap', function(evt) {
if (evt.detail.target.id === 'article-results') {
document.querySelectorAll('.article-checkbox').forEach(function(cb) {

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">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn-outline btn-sm"
onclick="return confirm('Delete this program?')">Delete</button>
</form>
</td>
</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">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn-outline btn-sm"
onclick="return confirm('Delete this product?')">Delete</button>
</form>
</td>
</tr>

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)
""")

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",
status="published", published_at=None):
status="published", published_at=None,
article_type="editorial"):
"""Insert an article row, return its id."""
pub = published_at or utcnow_iso()
return await execute(
"""INSERT INTO articles
(url_path, slug, title, meta_description, country, region,
status, published_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
status, published_at, article_type)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(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 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"