Compare commits

...

16 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
Deeman
fee0d6913b fix(pipeline): use sqlmesh plan --auto-apply instead of run
All checks were successful
CI / test (push) Successful in 56s
CI / tag (push) Successful in 3s
2026-03-06 22:34:58 +01:00
Deeman
71e08a5fa6 fix(pipeline): also update supervisor.py to use plan --auto-apply
Missed the Python supervisor module — same fix as supervisor.sh and
worker.py.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 22:33:59 +01:00
Deeman
27e86db6a1 fix(pipeline): use sqlmesh plan --auto-apply instead of sqlmesh run
`sqlmesh run` only re-evaluates intervals for already-planned models —
it does not detect new, modified, or deleted models. Switching to
`plan prod --auto-apply` ensures schema changes (like the new
location_profiles model) are picked up automatically.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 22:33:17 +01:00
Deeman
90754b8d9f chore: move ci.py to ~/.claude/scripts (uv inline script, no project dep)
All checks were successful
CI / test (push) Successful in 53s
CI / tag (push) Successful in 2s
Script now lives globally as a uv inline-dependency script.
Removes per-project scripts/ci.py and the msgspec dev dependency.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 15:51:36 +01:00
Deeman
277c92e507 chore: add scripts/ci.py for Gitea CI pipeline status
Copies ci.py from beanflows (same script, shared across projects).
Adds msgspec dev dependency required by the script.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 15:38:42 +01:00
15 changed files with 558 additions and 140 deletions

View File

@@ -33,10 +33,10 @@ do
DUCKDB_PATH="${DUCKDB_PATH:-/data/padelnomics/lakehouse.duckdb}" \ DUCKDB_PATH="${DUCKDB_PATH:-/data/padelnomics/lakehouse.duckdb}" \
uv run --package padelnomics_extract extract uv run --package padelnomics_extract extract
# Transform — run evaluates missing daily intervals for incremental models. # Transform — plan detects new/modified/deleted models and applies changes.
LANDING_DIR="${LANDING_DIR:-/data/padelnomics/landing}" \ LANDING_DIR="${LANDING_DIR:-/data/padelnomics/landing}" \
DUCKDB_PATH="${DUCKDB_PATH:-/data/padelnomics/lakehouse.duckdb}" \ DUCKDB_PATH="${DUCKDB_PATH:-/data/padelnomics/lakehouse.duckdb}" \
uv run sqlmesh -p transform/sqlmesh_padelnomics run prod uv run sqlmesh -p transform/sqlmesh_padelnomics plan prod --auto-apply
# Export serving tables to analytics.duckdb (atomic swap). # Export serving tables to analytics.duckdb (atomic swap).
# The web app detects the inode change on next query — no restart needed. # The web app detects the inode change on next query — no restart needed.

View File

@@ -247,10 +247,10 @@ def run_shell(cmd: str, timeout_seconds: int = SUBPROCESS_TIMEOUT_SECONDS) -> tu
def run_transform() -> None: def run_transform() -> None:
"""Run SQLMesh — evaluates missing daily intervals.""" """Run SQLMesh — detects new/modified/deleted models and applies changes."""
logger.info("Running SQLMesh transform") logger.info("Running SQLMesh transform")
ok, err = run_shell( ok, err = run_shell(
"uv run sqlmesh -p transform/sqlmesh_padelnomics run prod", "uv run sqlmesh -p transform/sqlmesh_padelnomics plan prod --auto-apply",
) )
if not ok: if not ok:
send_alert(f"[transform] {err}") send_alert(f"[transform] {err}")

5
uv.lock generated
View File

@@ -150,6 +150,11 @@ dependencies = [
] ]
sdist = { url = "https://files.pythonhosted.org/packages/84/85/57c314a6b35336efbbdc13e5fc9ae13f6b60a0647cfa7c1221178ac6d8ae/brotlicffi-1.2.0.0.tar.gz", hash = "sha256:34345d8d1f9d534fcac2249e57a4c3c8801a33c9942ff9f8574f67a175e17adb", size = 476682, upload-time = "2025-11-21T18:17:57.334Z" } sdist = { url = "https://files.pythonhosted.org/packages/84/85/57c314a6b35336efbbdc13e5fc9ae13f6b60a0647cfa7c1221178ac6d8ae/brotlicffi-1.2.0.0.tar.gz", hash = "sha256:34345d8d1f9d534fcac2249e57a4c3c8801a33c9942ff9f8574f67a175e17adb", size = 476682, upload-time = "2025-11-21T18:17:57.334Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/7c/87/ba6298c3d7f8d66ce80d7a487f2a487ebae74a79c6049c7c2990178ce529/brotlicffi-1.2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b13fb476a96f02e477a506423cb5e7bc21e0e3ac4c060c20ba31c44056e38c68", size = 433038, upload-time = "2026-03-05T17:57:37.96Z" },
{ url = "https://files.pythonhosted.org/packages/00/49/16c7a77d1cae0519953ef0389a11a9c2e2e62e87d04f8e7afbae40124255/brotlicffi-1.2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:17db36fb581f7b951635cd6849553a95c6f2f53c1a707817d06eae5aeff5f6af", size = 1541124, upload-time = "2026-03-05T17:57:39.488Z" },
{ url = "https://files.pythonhosted.org/packages/e8/17/fab2c36ea820e2288f8c1bf562de1b6cd9f30e28d66f1ce2929a4baff6de/brotlicffi-1.2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:40190192790489a7b054312163d0ce82b07d1b6e706251036898ce1684ef12e9", size = 1541983, upload-time = "2026-03-05T17:57:41.061Z" },
{ url = "https://files.pythonhosted.org/packages/78/c9/849a669b3b3bb8ac96005cdef04df4db658c33443a7fc704a6d4a2f07a56/brotlicffi-1.2.0.0-cp314-cp314t-win32.whl", hash = "sha256:a8079e8ecc32ecef728036a1d9b7105991ce6a5385cf51ee8c02297c90fb08c2", size = 349046, upload-time = "2026-03-05T17:57:42.76Z" },
{ url = "https://files.pythonhosted.org/packages/a4/25/09c0fd21cfc451fa38ad538f4d18d8be566746531f7f27143f63f8c45a9f/brotlicffi-1.2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:ca90c4266704ca0a94de8f101b4ec029624273380574e4cf19301acfa46c61a0", size = 385653, upload-time = "2026-03-05T17:57:44.224Z" },
{ url = "https://files.pythonhosted.org/packages/e4/df/a72b284d8c7bef0ed5756b41c2eb7d0219a1dd6ac6762f1c7bdbc31ef3af/brotlicffi-1.2.0.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:9458d08a7ccde8e3c0afedbf2c70a8263227a68dea5ab13590593f4c0a4fd5f4", size = 432340, upload-time = "2025-11-21T18:17:42.277Z" }, { url = "https://files.pythonhosted.org/packages/e4/df/a72b284d8c7bef0ed5756b41c2eb7d0219a1dd6ac6762f1c7bdbc31ef3af/brotlicffi-1.2.0.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:9458d08a7ccde8e3c0afedbf2c70a8263227a68dea5ab13590593f4c0a4fd5f4", size = 432340, upload-time = "2025-11-21T18:17:42.277Z" },
{ url = "https://files.pythonhosted.org/packages/74/2b/cc55a2d1d6fb4f5d458fba44a3d3f91fb4320aa14145799fd3a996af0686/brotlicffi-1.2.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:84e3d0020cf1bd8b8131f4a07819edee9f283721566fe044a20ec792ca8fd8b7", size = 1534002, upload-time = "2025-11-21T18:17:43.746Z" }, { url = "https://files.pythonhosted.org/packages/74/2b/cc55a2d1d6fb4f5d458fba44a3d3f91fb4320aa14145799fd3a996af0686/brotlicffi-1.2.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:84e3d0020cf1bd8b8131f4a07819edee9f283721566fe044a20ec792ca8fd8b7", size = 1534002, upload-time = "2025-11-21T18:17:43.746Z" },
{ url = "https://files.pythonhosted.org/packages/e4/9c/d51486bf366fc7d6735f0e46b5b96ca58dc005b250263525a1eea3cd5d21/brotlicffi-1.2.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:33cfb408d0cff64cd50bef268c0fed397c46fbb53944aa37264148614a62e990", size = 1536547, upload-time = "2025-11-21T18:17:45.729Z" }, { url = "https://files.pythonhosted.org/packages/e4/9c/d51486bf366fc7d6735f0e46b5b96ca58dc005b250263525a1eea3cd5d21/brotlicffi-1.2.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:33cfb408d0cff64cd50bef268c0fed397c46fbb53944aa37264148614a62e990", size = 1536547, upload-time = "2025-11-21T18:17:45.729Z" },

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,21 +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
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, status: str = None,
template_slug: str = None, template_slug: str = None,
language: str = None, language: str = None,
search: str = None, search: str = None,
page: int = 1, article_type: str = None,
per_page: int = 50, ) -> tuple[list[str], list]:
) -> list[dict]: """Build WHERE clauses and params for article queries."""
"""Get articles with optional filters and pagination."""
wheres = ["1=1"] wheres = ["1=1"]
params: list = [] params: list = []
@@ -2302,7 +2315,26 @@ async def _get_article_list(
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
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) where = " AND ".join(wheres)
offset = (page - 1) * per_page offset = (page - 1) * per_page
params.extend([per_page, offset]) params.extend([per_page, offset])
@@ -2323,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]:
@@ -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. 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). Each returned item has a 'variants' list (one dict per language variant).
""" """
wheres = ["1=1"] wheres, params = _build_article_where(status=status, template_slug=template_slug,
params: list = [] search=search, article_type=article_type)
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}%")
where = " AND ".join(wheres) where = " AND ".join(wheres)
offset = (page - 1) * per_page offset = (page - 1) * per_page
@@ -2402,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(
@@ -2432,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(),
) )
@@ -2485,118 +2526,221 @@ 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(),
) )
@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"]) @bp.route("/articles/bulk", methods=["POST"])
@role_required("admin") @role_required("admin")
@csrf_protect @csrf_protect
async def articles_bulk(): 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 form = await request.form
ids_raw = form.get("article_ids", "").strip()
action = form.get("action", "").strip() action = form.get("action", "").strip()
apply_to_all = form.get("apply_to_all", "").strip() == "true"
valid_actions = ("publish", "unpublish", "toggle_noindex", "rebuild", "delete") # Common filter params (used for action scope and re-render)
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
search = form.get("search", "").strip() search = form.get("search", "").strip()
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")
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 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, 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(),
) )
@@ -2627,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")
@@ -2656,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()
@@ -2699,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")
@@ -2727,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"))
@@ -2793,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,6 +72,7 @@
</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">
@@ -53,6 +82,7 @@
{% 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>
@@ -75,12 +105,14 @@
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="article_ids" id="article-bulk-ids" value=""> <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="action" id="article-bulk-action" value="">
<input type="hidden" name="search" value="{{ current_search }}"> <input type="hidden" name="apply_to_all" id="article-bulk-apply-to-all" value="false">
<input type="hidden" name="status" value="{{ current_status }}"> <input type="hidden" name="search" id="article-bulk-search" value="{{ current_search }}">
<input type="hidden" name="template" value="{{ current_template }}"> <input type="hidden" name="status" id="article-bulk-status" value="{{ current_status }}">
<input type="hidden" name="language" value="{{ current_language }}"> <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> </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> <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"> <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> <option value="">Action…</option>
@@ -92,6 +124,20 @@
</select> </select>
<button type="button" class="btn btn-sm" onclick="submitArticleBulk()">Apply</button> <button type="button" class="btn btn-sm" onclick="submitArticleBulk()">Apply</button>
<button type="button" class="btn-outline btn-sm" onclick="clearArticleSelection()">Clear</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> </div>
{# Results #} {# Results #}
@@ -101,10 +147,13 @@
<script> <script>
const articleSelectedIds = new Set(); const articleSelectedIds = new Set();
let articleApplyToAll = false;
let articleMatchingCount = 0;
function toggleArticleSelect(id, checked) { function toggleArticleSelect(id, checked) {
if (checked) articleSelectedIds.add(id); if (checked) articleSelectedIds.add(id);
else articleSelectedIds.delete(id); else articleSelectedIds.delete(id);
disableApplyToAll();
updateArticleBulkBar(); updateArticleBulkBar();
} }
@@ -114,30 +163,92 @@ function toggleArticleGroupSelect(checkbox) {
if (checkbox.checked) articleSelectedIds.add(id); if (checkbox.checked) articleSelectedIds.add(id);
else articleSelectedIds.delete(id); else articleSelectedIds.delete(id);
}); });
disableApplyToAll();
updateArticleBulkBar(); updateArticleBulkBar();
} }
function clearArticleSelection() { function clearArticleSelection() {
articleSelectedIds.clear(); articleSelectedIds.clear();
articleApplyToAll = false;
document.querySelectorAll('.article-checkbox').forEach(function(cb) { cb.checked = false; }); document.querySelectorAll('.article-checkbox').forEach(function(cb) { cb.checked = false; });
var selectAll = document.getElementById('article-select-all'); var selectAll = document.getElementById('article-select-all');
if (selectAll) selectAll.checked = false; if (selectAll) selectAll.checked = false;
updateArticleBulkBar(); 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() { function updateArticleBulkBar() {
var bar = document.getElementById('article-bulk-bar'); 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'); var ids = document.getElementById('article-bulk-ids');
bar.style.display = articleSelectedIds.size > 0 ? 'flex' : 'none';
count.textContent = articleSelectedIds.size + ' selected'; if (articleSelectedIds.size === 0 && !articleApplyToAll) {
ids.value = Array.from(articleSelectedIds).join(','); 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() { function submitArticleBulk() {
var action = document.getElementById('article-bulk-action-select').value; var action = document.getElementById('article-bulk-action-select').value;
if (!action) return; if (!action) return;
if (articleSelectedIds.size === 0) return; if (!articleApplyToAll && articleSelectedIds.size === 0) return;
function doSubmit() { function doSubmit() {
document.getElementById('article-bulk-action').value = action; document.getElementById('article-bulk-action').value = action;
@@ -150,7 +261,13 @@ function submitArticleBulk() {
} }
if (action === 'delete') { 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(); if (ok) doSubmit();
}); });
} else { } 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) { document.body.addEventListener('htmx:afterSwap', function(evt) {
if (evt.detail.target.id === 'article-results') { if (evt.detail.target.id === 'article-results') {
document.querySelectorAll('.article-checkbox').forEach(function(cb) { 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"> <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

@@ -737,9 +737,9 @@ async def handle_run_extraction(payload: dict) -> None:
@task("run_transform") @task("run_transform")
async def handle_run_transform(payload: dict) -> None: async def handle_run_transform(payload: dict) -> None:
"""Run SQLMesh transform (prod run) in the background. """Run SQLMesh transform (prod plan + apply) in the background.
Shells out to `uv run sqlmesh -p transform/sqlmesh_padelnomics run prod`. Shells out to `uv run sqlmesh -p transform/sqlmesh_padelnomics plan prod --auto-apply`.
2-hour absolute timeout — same as extraction. 2-hour absolute timeout — same as extraction.
""" """
import subprocess import subprocess
@@ -748,7 +748,7 @@ async def handle_run_transform(payload: dict) -> None:
repo_root = Path(__file__).resolve().parents[4] repo_root = Path(__file__).resolve().parents[4]
result = await asyncio.to_thread( result = await asyncio.to_thread(
subprocess.run, subprocess.run,
["uv", "run", "sqlmesh", "-p", "transform/sqlmesh_padelnomics", "run", "prod"], ["uv", "run", "sqlmesh", "-p", "transform/sqlmesh_padelnomics", "plan", "prod", "--auto-apply"],
capture_output=True, capture_output=True,
text=True, text=True,
timeout=7200, timeout=7200,
@@ -803,7 +803,7 @@ async def handle_run_pipeline(payload: dict) -> None:
), ),
( (
"transform", "transform",
["uv", "run", "sqlmesh", "-p", "transform/sqlmesh_padelnomics", "run", "prod"], ["uv", "run", "sqlmesh", "-p", "transform/sqlmesh_padelnomics", "plan", "prod", "--auto-apply"],
7200, 7200,
), ),
( (

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"