Compare commits
16 Commits
v202603061
...
v202603071
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
055cc23482 | ||
|
|
9f8afdbda7 | ||
|
|
66353b3da1 | ||
|
|
15378b1804 | ||
|
|
03fdec7297 | ||
|
|
608f0356a5 | ||
|
|
39225d6cfd | ||
|
|
e537bfd9d3 | ||
|
|
a27da79705 | ||
|
|
8d86669360 | ||
|
|
7d523250f7 | ||
|
|
fee0d6913b | ||
|
|
71e08a5fa6 | ||
|
|
27e86db6a1 | ||
|
|
90754b8d9f | ||
|
|
277c92e507 |
@@ -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.
|
||||||
|
|||||||
@@ -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
5
uv.lock
generated
@@ -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" },
|
||||||
|
|||||||
@@ -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,))
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
25
web/src/padelnomics/migrations/versions/0029_article_type.py
Normal file
25
web/src/padelnomics/migrations/versions/0029_article_type.py
Normal 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)
|
||||||
|
""")
|
||||||
@@ -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,
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user