Compare commits
9 Commits
v202603062
...
v202603071
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
055cc23482 | ||
|
|
9f8afdbda7 | ||
|
|
66353b3da1 | ||
|
|
15378b1804 | ||
|
|
03fdec7297 | ||
|
|
608f0356a5 | ||
|
|
39225d6cfd | ||
|
|
e537bfd9d3 | ||
|
|
a27da79705 |
@@ -2142,7 +2142,7 @@ async def scenario_preview(scenario_id: int):
|
||||
async def scenario_pdf(scenario_id: int):
|
||||
"""Generate and immediately download a business plan PDF for a published scenario."""
|
||||
from ..businessplan import get_plan_sections
|
||||
from ..planner.calculator import validate_state
|
||||
from ..planner.calculator import calc, validate_state
|
||||
|
||||
scenario = await fetch_one("SELECT * FROM published_scenarios WHERE id = ?", (scenario_id,))
|
||||
if not scenario:
|
||||
@@ -2153,7 +2153,7 @@ async def scenario_pdf(scenario_id: int):
|
||||
lang = "en"
|
||||
|
||||
state = validate_state(json.loads(scenario["state_json"]))
|
||||
d = json.loads(scenario["calc_json"])
|
||||
d = calc(state)
|
||||
sections = get_plan_sections(state, d, lang)
|
||||
sections["scenario_name"] = scenario["title"]
|
||||
sections["location"] = scenario.get("location", "")
|
||||
@@ -2255,13 +2255,14 @@ async def _sync_static_articles() -> None:
|
||||
meta_description = fm.get("meta_description", "")
|
||||
template_slug = fm.get("template_slug") or None
|
||||
group_key = fm.get("cornerstone") or None
|
||||
article_type = "cornerstone" if fm.get("cornerstone") else "editorial"
|
||||
now_iso = utcnow_iso()
|
||||
|
||||
await execute(
|
||||
"""INSERT INTO articles
|
||||
(slug, title, url_path, language, meta_description,
|
||||
status, template_slug, group_key, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, 'draft', ?, ?, ?, ?)
|
||||
status, template_slug, group_key, article_type, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, 'draft', ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(slug) DO UPDATE SET
|
||||
title = excluded.title,
|
||||
url_path = excluded.url_path,
|
||||
@@ -2269,23 +2270,33 @@ async def _sync_static_articles() -> None:
|
||||
meta_description = excluded.meta_description,
|
||||
template_slug = excluded.template_slug,
|
||||
group_key = excluded.group_key,
|
||||
article_type = excluded.article_type,
|
||||
updated_at = excluded.updated_at""",
|
||||
(slug, title, url_path, language, meta_description,
|
||||
template_slug, group_key, now_iso, now_iso),
|
||||
template_slug, group_key, article_type, now_iso, now_iso),
|
||||
)
|
||||
|
||||
# Build HTML so the article is immediately servable (cornerstones have no template)
|
||||
if template_slug is None:
|
||||
from ..content.routes import BUILD_DIR, bake_product_cards, bake_scenario_cards
|
||||
|
||||
body = raw[m.end():]
|
||||
body_html = mistune.html(body)
|
||||
body_html = await bake_scenario_cards(body_html, lang=language)
|
||||
body_html = await bake_product_cards(body_html, lang=language)
|
||||
build_dir = BUILD_DIR / language
|
||||
build_dir.mkdir(parents=True, exist_ok=True)
|
||||
(build_dir / f"{slug}.html").write_text(body_html)
|
||||
|
||||
|
||||
def _build_article_where(
|
||||
status: str = None,
|
||||
template_slug: str = None,
|
||||
language: str = None,
|
||||
search: str = None,
|
||||
article_type: str = None,
|
||||
) -> tuple[list[str], list]:
|
||||
"""Build WHERE clauses and params for article queries.
|
||||
|
||||
template_slug='__manual__' filters for articles with template_slug IS NULL
|
||||
(cornerstone / manually written articles, no pSEO template).
|
||||
"""
|
||||
"""Build WHERE clauses and params for article queries."""
|
||||
wheres = ["1=1"]
|
||||
params: list = []
|
||||
|
||||
@@ -2295,9 +2306,7 @@ def _build_article_where(
|
||||
wheres.append("status = 'published' AND published_at > datetime('now')")
|
||||
elif status == "draft":
|
||||
wheres.append("status = 'draft'")
|
||||
if template_slug == "__manual__":
|
||||
wheres.append("template_slug IS NULL")
|
||||
elif template_slug:
|
||||
if template_slug:
|
||||
wheres.append("template_slug = ?")
|
||||
params.append(template_slug)
|
||||
if language:
|
||||
@@ -2306,6 +2315,9 @@ def _build_article_where(
|
||||
if search:
|
||||
wheres.append("title LIKE ?")
|
||||
params.append(f"%{search}%")
|
||||
if article_type:
|
||||
wheres.append("article_type = ?")
|
||||
params.append(article_type)
|
||||
|
||||
return wheres, params
|
||||
|
||||
@@ -2315,12 +2327,14 @@ async def _get_article_list(
|
||||
template_slug: str = None,
|
||||
language: str = None,
|
||||
search: str = None,
|
||||
article_type: str = None,
|
||||
page: int = 1,
|
||||
per_page: int = 50,
|
||||
) -> list[dict]:
|
||||
"""Get articles with optional filters and pagination."""
|
||||
wheres, params = _build_article_where(status=status, template_slug=template_slug,
|
||||
language=language, search=search)
|
||||
language=language, search=search,
|
||||
article_type=article_type)
|
||||
where = " AND ".join(wheres)
|
||||
offset = (page - 1) * per_page
|
||||
params.extend([per_page, offset])
|
||||
@@ -2341,6 +2355,7 @@ async def _get_article_list_grouped(
|
||||
status: str = None,
|
||||
template_slug: str = None,
|
||||
search: str = None,
|
||||
article_type: str = None,
|
||||
page: int = 1,
|
||||
per_page: int = 50,
|
||||
) -> list[dict]:
|
||||
@@ -2351,7 +2366,7 @@ async def _get_article_list_grouped(
|
||||
Each returned item has a 'variants' list (one dict per language variant).
|
||||
"""
|
||||
wheres, params = _build_article_where(status=status, template_slug=template_slug,
|
||||
search=search)
|
||||
search=search, article_type=article_type)
|
||||
where = " AND ".join(wheres)
|
||||
offset = (page - 1) * per_page
|
||||
|
||||
@@ -2406,19 +2421,32 @@ async def _get_article_list_grouped(
|
||||
return groups
|
||||
|
||||
|
||||
async def _get_article_stats() -> dict:
|
||||
async def _get_article_stats(article_type: str = None) -> dict:
|
||||
"""Get aggregate article stats for the admin list header."""
|
||||
where = f"WHERE article_type = '{article_type}'" if article_type else ""
|
||||
row = await fetch_one(
|
||||
"""SELECT
|
||||
f"""SELECT
|
||||
COUNT(*) AS total,
|
||||
COALESCE(SUM(CASE WHEN status='published' AND published_at <= datetime('now') THEN 1 ELSE 0 END), 0) AS live,
|
||||
COALESCE(SUM(CASE WHEN status='published' AND published_at > datetime('now') THEN 1 ELSE 0 END), 0) AS scheduled,
|
||||
COALESCE(SUM(CASE WHEN status='draft' THEN 1 ELSE 0 END), 0) AS draft
|
||||
FROM articles"""
|
||||
FROM articles {where}"""
|
||||
)
|
||||
return dict(row) if row else {"total": 0, "live": 0, "scheduled": 0, "draft": 0}
|
||||
|
||||
|
||||
async def _get_article_type_counts() -> dict[str, int]:
|
||||
"""Return per-type article counts for the tab bar."""
|
||||
rows = await fetch_all(
|
||||
"SELECT article_type, COUNT(*) AS cnt FROM articles GROUP BY article_type"
|
||||
)
|
||||
counts: dict[str, int] = {"cornerstone": 0, "editorial": 0, "generated": 0}
|
||||
for r in rows:
|
||||
if r["article_type"] in counts:
|
||||
counts[r["article_type"]] = r["cnt"]
|
||||
return counts
|
||||
|
||||
|
||||
async def _is_generating() -> bool:
|
||||
"""Return True if a generate_articles task is currently pending."""
|
||||
row = await fetch_one(
|
||||
@@ -2436,34 +2464,43 @@ async def articles():
|
||||
status_filter = request.args.get("status", "")
|
||||
template_filter = request.args.get("template", "")
|
||||
language_filter = request.args.get("language", "")
|
||||
article_type = request.args.get("article_type", "cornerstone")
|
||||
page = max(1, int(request.args.get("page", "1") or "1"))
|
||||
|
||||
grouped = not language_filter
|
||||
if grouped:
|
||||
article_list = await _get_article_list_grouped(
|
||||
status=status_filter or None, template_slug=template_filter or None,
|
||||
search=search or None, page=page,
|
||||
search=search or None, article_type=article_type or None, page=page,
|
||||
)
|
||||
else:
|
||||
article_list = await _get_article_list(
|
||||
status=status_filter or None, template_slug=template_filter or None,
|
||||
language=language_filter or None, search=search or None, page=page,
|
||||
language=language_filter or None, search=search or None,
|
||||
article_type=article_type or None, page=page,
|
||||
)
|
||||
stats = await _get_article_stats()
|
||||
templates = await fetch_all(
|
||||
"SELECT DISTINCT template_slug FROM articles WHERE template_slug IS NOT NULL ORDER BY template_slug"
|
||||
)
|
||||
stats = await _get_article_stats(article_type=article_type or None)
|
||||
type_counts = await _get_article_type_counts()
|
||||
|
||||
template_slugs: list[str] = []
|
||||
if article_type == "generated":
|
||||
templates = await fetch_all(
|
||||
"SELECT DISTINCT template_slug FROM articles WHERE template_slug IS NOT NULL ORDER BY template_slug"
|
||||
)
|
||||
template_slugs = [t["template_slug"] for t in templates]
|
||||
|
||||
return await render_template(
|
||||
"admin/articles.html",
|
||||
articles=article_list,
|
||||
grouped=grouped,
|
||||
stats=stats,
|
||||
template_slugs=[t["template_slug"] for t in templates],
|
||||
template_slugs=template_slugs,
|
||||
current_search=search,
|
||||
current_status=status_filter,
|
||||
current_template=template_filter,
|
||||
current_language=language_filter,
|
||||
current_article_type=article_type,
|
||||
type_counts=type_counts,
|
||||
page=page,
|
||||
is_generating=await _is_generating(),
|
||||
)
|
||||
@@ -2489,23 +2526,26 @@ async def article_results():
|
||||
status_filter = request.args.get("status", "")
|
||||
template_filter = request.args.get("template", "")
|
||||
language_filter = request.args.get("language", "")
|
||||
article_type = request.args.get("article_type", "cornerstone")
|
||||
page = max(1, int(request.args.get("page", "1") or "1"))
|
||||
|
||||
grouped = not language_filter
|
||||
if grouped:
|
||||
article_list = await _get_article_list_grouped(
|
||||
status=status_filter or None, template_slug=template_filter or None,
|
||||
search=search or None, page=page,
|
||||
search=search or None, article_type=article_type or None, page=page,
|
||||
)
|
||||
else:
|
||||
article_list = await _get_article_list(
|
||||
status=status_filter or None, template_slug=template_filter or None,
|
||||
language=language_filter or None, search=search or None, page=page,
|
||||
language=language_filter or None, search=search or None,
|
||||
article_type=article_type or None, page=page,
|
||||
)
|
||||
return await render_template(
|
||||
"admin/partials/article_results.html",
|
||||
articles=article_list,
|
||||
grouped=grouped,
|
||||
current_article_type=article_type,
|
||||
page=page,
|
||||
is_generating=await _is_generating(),
|
||||
)
|
||||
@@ -2518,6 +2558,7 @@ async def articles_matching_count():
|
||||
status_filter = request.args.get("status", "")
|
||||
template_filter = request.args.get("template", "")
|
||||
language_filter = request.args.get("language", "")
|
||||
article_type = request.args.get("article_type", "cornerstone")
|
||||
search = request.args.get("search", "").strip()
|
||||
|
||||
wheres, params = _build_article_where(
|
||||
@@ -2525,6 +2566,7 @@ async def articles_matching_count():
|
||||
template_slug=template_filter or None,
|
||||
language=language_filter or None,
|
||||
search=search or None,
|
||||
article_type=article_type or None,
|
||||
)
|
||||
where = " AND ".join(wheres)
|
||||
row = await fetch_one(f"SELECT COUNT(*) AS cnt FROM articles WHERE {where}", tuple(params))
|
||||
@@ -2551,6 +2593,7 @@ async def articles_bulk():
|
||||
status_filter = form.get("status", "")
|
||||
template_filter = form.get("template", "")
|
||||
language_filter = form.get("language", "")
|
||||
article_type = form.get("article_type", "cornerstone")
|
||||
|
||||
valid_actions = ("publish", "unpublish", "toggle_noindex", "rebuild", "delete")
|
||||
if action not in valid_actions:
|
||||
@@ -2564,6 +2607,7 @@ async def articles_bulk():
|
||||
template_slug=template_filter or None,
|
||||
language=language_filter or None,
|
||||
search=search or None,
|
||||
article_type=article_type or None,
|
||||
)
|
||||
where = " AND ".join(wheres)
|
||||
|
||||
@@ -2613,15 +2657,13 @@ async def articles_bulk():
|
||||
from ..content.routes import BUILD_DIR
|
||||
|
||||
rows = await fetch_all(
|
||||
f"SELECT id, slug FROM articles WHERE {where} LIMIT 5000", tuple(where_params)
|
||||
f"SELECT id, slug FROM articles WHERE {where} LIMIT 5000",
|
||||
tuple(where_params),
|
||||
)
|
||||
for a in rows:
|
||||
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 {where}", tuple(where_params))
|
||||
from ..sitemap import invalidate_sitemap_cache
|
||||
invalidate_sitemap_cache()
|
||||
@@ -2674,9 +2716,6 @@ async def articles_bulk():
|
||||
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),
|
||||
@@ -2689,17 +2728,19 @@ async def articles_bulk():
|
||||
if grouped:
|
||||
article_list = await _get_article_list_grouped(
|
||||
status=status_filter or None, template_slug=template_filter or None,
|
||||
search=search or None,
|
||||
search=search or None, article_type=article_type or None,
|
||||
)
|
||||
else:
|
||||
article_list = await _get_article_list(
|
||||
status=status_filter or None, template_slug=template_filter or None,
|
||||
language=language_filter or None, search=search or None,
|
||||
article_type=article_type or None,
|
||||
)
|
||||
return await render_template(
|
||||
"admin/partials/article_results.html",
|
||||
articles=article_list,
|
||||
grouped=grouped,
|
||||
current_article_type=article_type,
|
||||
page=1,
|
||||
is_generating=await _is_generating(),
|
||||
)
|
||||
@@ -2730,6 +2771,8 @@ async def article_new():
|
||||
language = form.get("language", "en").strip() or "en"
|
||||
status = form.get("status", "draft")
|
||||
published_at = form.get("published_at", "").strip()
|
||||
article_type = form.get("article_type", "editorial")
|
||||
assert article_type in ("editorial", "cornerstone"), f"invalid article_type: {article_type}"
|
||||
|
||||
if not title or not body:
|
||||
await flash("Title and body are required.", "error")
|
||||
@@ -2759,10 +2802,10 @@ async def article_new():
|
||||
await execute(
|
||||
"""INSERT INTO articles
|
||||
(url_path, slug, title, meta_description, og_image_url,
|
||||
country, region, language, status, published_at, seo_head)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
country, region, language, status, published_at, seo_head, article_type)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(url_path, article_slug, title, meta_description, og_image_url,
|
||||
country, region, language, status, pub_dt, seo_head),
|
||||
country, region, language, status, pub_dt, seo_head, article_type),
|
||||
)
|
||||
from ..sitemap import invalidate_sitemap_cache
|
||||
invalidate_sitemap_cache()
|
||||
@@ -2802,6 +2845,8 @@ async def article_edit(article_id: int):
|
||||
language = form.get("language", article.get("language", "en")).strip() or "en"
|
||||
status = form.get("status", article["status"])
|
||||
published_at = form.get("published_at", "").strip()
|
||||
article_type = form.get("article_type", article.get("article_type", "editorial"))
|
||||
assert article_type in ("editorial", "cornerstone"), f"invalid article_type: {article_type}"
|
||||
|
||||
if is_reserved_path(url_path):
|
||||
await flash(f"URL path '{url_path}' conflicts with a reserved route.", "error")
|
||||
@@ -2830,10 +2875,10 @@ async def article_edit(article_id: int):
|
||||
"""UPDATE articles
|
||||
SET title = ?, url_path = ?, meta_description = ?, og_image_url = ?,
|
||||
country = ?, region = ?, language = ?, status = ?, published_at = ?,
|
||||
seo_head = ?, updated_at = ?
|
||||
seo_head = ?, article_type = ?, updated_at = ?
|
||||
WHERE id = ?""",
|
||||
(title, url_path, meta_description, og_image_url,
|
||||
country, region, language, status, pub_dt, seo_head, now, article_id),
|
||||
country, region, language, status, pub_dt, seo_head, article_type, now, article_id),
|
||||
)
|
||||
await flash("Article updated.", "success")
|
||||
return redirect(url_for("admin.articles"))
|
||||
@@ -2896,14 +2941,10 @@ async def article_delete(article_id: int):
|
||||
"""Delete an article."""
|
||||
article = await fetch_one("SELECT slug FROM articles WHERE id = ?", (article_id,))
|
||||
if article:
|
||||
# Clean up files
|
||||
from ..content.routes import BUILD_DIR
|
||||
build_path = BUILD_DIR / f"{article['slug']}.html"
|
||||
if build_path.exists():
|
||||
build_path.unlink()
|
||||
md_path = Path("data/content/articles") / f"{article['slug']}.md"
|
||||
if md_path.exists():
|
||||
md_path.unlink()
|
||||
|
||||
await execute("DELETE FROM articles WHERE id = ?", (article_id,))
|
||||
|
||||
|
||||
@@ -310,6 +310,13 @@
|
||||
<option value="de" {% if data.get('language') == 'de' %}selected{% endif %}>DE</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="ae-field ae-field--fixed120">
|
||||
<label for="article_type">Type</label>
|
||||
<select id="article_type" name="article_type">
|
||||
<option value="editorial" {% if data.get('article_type', 'editorial') == 'editorial' %}selected{% endif %}>Editorial</option>
|
||||
<option value="cornerstone" {% if data.get('article_type') == 'cornerstone' %}selected{% endif %}>Cornerstone</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="ae-field ae-field--fixed120">
|
||||
<label for="status">Status</label>
|
||||
<select id="status" name="status">
|
||||
|
||||
@@ -3,8 +3,23 @@
|
||||
|
||||
{% block title %}Articles - Admin - {{ config.APP_NAME }}{% endblock %}
|
||||
|
||||
{% block head %}{{ super() }}
|
||||
<style>
|
||||
.tab-btn { display:inline-flex; align-items:center; gap:0.4rem;
|
||||
padding:0.5rem 1rem; font-size:0.8125rem; font-weight:600;
|
||||
color:#64748B; text-decoration:none; border-bottom:2px solid transparent;
|
||||
transition: color 0.15s, border-color 0.15s; }
|
||||
.tab-btn:hover { color:#0F172A; }
|
||||
.tab-btn--active { color:#1D4ED8; border-bottom-color:#1D4ED8; }
|
||||
.tab-badge { font-size:0.6875rem; font-weight:700;
|
||||
background:#F1F5F9; color:#64748B; padding:0.1rem 0.45rem;
|
||||
border-radius:9999px; min-width:1.25rem; text-align:center; }
|
||||
.tab-btn--active .tab-badge { background:#EFF6FF; color:#1D4ED8; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block admin_content %}
|
||||
<header class="flex justify-between items-center mb-6">
|
||||
<header class="flex justify-between items-center mb-4">
|
||||
<div>
|
||||
<h1 class="text-2xl">Articles</h1>
|
||||
{% include "admin/partials/article_stats.html" %}
|
||||
@@ -19,6 +34,18 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{# Tab bar #}
|
||||
<nav class="flex gap-1 mb-4 border-b border-slate-200" role="tablist">
|
||||
{% for key, label in [('cornerstone','Cornerstone'),('editorial','Editorial'),('generated','Generated')] %}
|
||||
<a href="{{ url_for('admin.articles', article_type=key) }}"
|
||||
role="tab" class="tab-btn {% if current_article_type == key %}tab-btn--active{% endif %}"
|
||||
hx-boost="true">
|
||||
{{ label }}
|
||||
<span class="tab-badge">{{ type_counts[key] }}</span>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</nav>
|
||||
|
||||
{# Filters #}
|
||||
<div class="card mb-6" style="padding:1rem 1.25rem">
|
||||
<form class="flex flex-wrap gap-3 items-end"
|
||||
@@ -27,6 +54,7 @@
|
||||
hx-trigger="change, input delay:300ms"
|
||||
hx-indicator="#articles-loading">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="article_type" value="{{ current_article_type }}">
|
||||
|
||||
<div>
|
||||
<label class="text-xs font-semibold text-slate block mb-1">Search</label>
|
||||
@@ -44,16 +72,17 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{% if current_article_type == 'generated' %}
|
||||
<div>
|
||||
<label class="text-xs font-semibold text-slate block mb-1">Template</label>
|
||||
<select name="template" class="form-input" style="min-width:140px">
|
||||
<option value="">All</option>
|
||||
<option value="__manual__" {% if current_template == '__manual__' %}selected{% endif %}>Manual</option>
|
||||
{% for t in template_slugs %}
|
||||
<option value="{{ t }}" {% if t == current_template %}selected{% endif %}>{{ t }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div>
|
||||
<label class="text-xs font-semibold text-slate block mb-1">Language</label>
|
||||
@@ -81,6 +110,7 @@
|
||||
<input type="hidden" name="status" id="article-bulk-status" value="{{ current_status }}">
|
||||
<input type="hidden" name="template" id="article-bulk-template" value="{{ current_template }}">
|
||||
<input type="hidden" name="language" id="article-bulk-language" value="{{ current_language }}">
|
||||
<input type="hidden" name="article_type" id="article-bulk-article-type" value="{{ current_article_type }}">
|
||||
</form>
|
||||
<div id="article-bulk-bar" class="card mb-4" style="padding:0.75rem 1.25rem;display:none;align-items:center;gap:1rem;flex-wrap:wrap;background:#EFF6FF;border:1px solid #BFDBFE;">
|
||||
<span id="article-bulk-count" class="text-sm font-semibold text-navy">0 selected</span>
|
||||
@@ -201,6 +231,7 @@ function updateArticleBulkBar() {
|
||||
status: document.getElementById('article-bulk-status').value,
|
||||
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(); })
|
||||
@@ -260,10 +291,12 @@ function syncBulkFilters() {
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
<form method="post" action="{{ url_for('admin.affiliate_program_delete', program_id=prog.id) }}" style="display:inline" hx-boost="true">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn-outline btn-sm"
|
||||
onclick="return confirm('Delete this program?')">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
<form method="post" action="{{ url_for('admin.affiliate_delete', product_id=product.id) }}" style="display:inline" hx-boost="true">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn-outline btn-sm"
|
||||
onclick="return confirm('Delete this product?')">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -31,5 +31,5 @@
|
||||
{% endfor %}
|
||||
</td>
|
||||
<td class="mono">{{ g.published_at[:10] if g.published_at else '-' }}</td>
|
||||
<td class="text-slate">{{ g.template_slug or 'Manual' }}</td>
|
||||
{% if current_article_type == 'generated' %}<td class="text-slate">{{ g.template_slug or '-' }}</td>{% endif %}
|
||||
</tr>
|
||||
|
||||
@@ -63,7 +63,7 @@
|
||||
<th>{% if grouped %}Variants{% else %}Status{% endif %}</th>
|
||||
<th>Published</th>
|
||||
{% if not grouped %}<th>Lang</th>{% endif %}
|
||||
<th>Template</th>
|
||||
{% if current_article_type == 'generated' %}<th>Template</th>{% endif %}
|
||||
{% if not grouped %}<th></th>{% endif %}
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
</td>
|
||||
<td class="mono">{{ a.published_at[:10] if a.published_at else '-' }}</td>
|
||||
<td>{{ a.language | upper if a.language else '-' }}</td>
|
||||
<td class="text-slate">{{ a.template_slug or 'Manual' }}</td>
|
||||
{% if current_article_type == 'generated' %}<td class="text-slate">{{ a.template_slug or '-' }}</td>{% endif %}
|
||||
<td class="text-right" style="white-space:nowrap">
|
||||
{% if a.display_status == 'live' %}
|
||||
<a href="/{{ a.language or 'en' }}{{ a.url_path }}" target="_blank" class="btn-outline btn-sm">View</a>
|
||||
|
||||
@@ -520,8 +520,8 @@ async def generate_articles(
|
||||
"""INSERT INTO articles
|
||||
(url_path, slug, title, meta_description, country, region,
|
||||
status, published_at, template_slug, language, date_modified,
|
||||
seo_head, noindex, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 'published', ?, ?, ?, ?, ?, ?, ?)
|
||||
seo_head, noindex, article_type, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 'published', ?, ?, ?, ?, ?, ?, 'generated', ?)
|
||||
ON CONFLICT(url_path, language) DO UPDATE SET
|
||||
title = excluded.title,
|
||||
meta_description = excluded.meta_description,
|
||||
@@ -529,6 +529,7 @@ async def generate_articles(
|
||||
date_modified = excluded.date_modified,
|
||||
seo_head = excluded.seo_head,
|
||||
noindex = excluded.noindex,
|
||||
article_type = 'generated',
|
||||
updated_at = excluded.date_modified""",
|
||||
(
|
||||
url_path, article_slug, title, meta_desc,
|
||||
|
||||
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)
|
||||
""")
|
||||
@@ -68,16 +68,17 @@ async def _create_published_scenario(slug="test-scenario", city="TestCity", coun
|
||||
|
||||
|
||||
async def _create_article(slug="test-article", url_path="/test-article",
|
||||
status="published", published_at=None):
|
||||
status="published", published_at=None,
|
||||
article_type="editorial"):
|
||||
"""Insert an article row, return its id."""
|
||||
pub = published_at or utcnow_iso()
|
||||
return await execute(
|
||||
"""INSERT INTO articles
|
||||
(url_path, slug, title, meta_description, country, region,
|
||||
status, published_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
status, published_at, article_type)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(url_path, slug, f"Title {slug}", f"Desc {slug}", "US", "North America",
|
||||
status, pub),
|
||||
status, pub, article_type),
|
||||
)
|
||||
|
||||
|
||||
@@ -1228,6 +1229,96 @@ class TestAdminArticles:
|
||||
assert resp.status_code == 302
|
||||
assert await fetch_one("SELECT 1 FROM articles WHERE id = ?", (article_id,)) is None
|
||||
|
||||
async def test_delete_never_removes_md_source(self, admin_client, db, tmp_path, monkeypatch):
|
||||
"""Regression: deleting an article must NOT touch source .md files."""
|
||||
import padelnomics.content.routes as content_routes_mod
|
||||
|
||||
build_dir = tmp_path / "build"
|
||||
build_dir.mkdir()
|
||||
monkeypatch.setattr(content_routes_mod, "BUILD_DIR", build_dir)
|
||||
|
||||
(build_dir / "del-safe.html").write_text("<p>built</p>")
|
||||
md_file = tmp_path / "del-safe.md"
|
||||
md_file.write_text("# Source")
|
||||
|
||||
article_id = await _create_article(slug="del-safe", url_path="/del-safe")
|
||||
|
||||
async with admin_client.session_transaction() as sess:
|
||||
sess["csrf_token"] = "test"
|
||||
|
||||
resp = await admin_client.post(f"/admin/articles/{article_id}/delete", form={
|
||||
"csrf_token": "test",
|
||||
})
|
||||
assert resp.status_code == 302
|
||||
assert await fetch_one("SELECT 1 FROM articles WHERE id = ?", (article_id,)) is None
|
||||
assert not (build_dir / "del-safe.html").exists(), "build file should be removed"
|
||||
assert md_file.exists(), "source .md must NOT be deleted"
|
||||
|
||||
async def test_bulk_delete_by_ids_never_removes_md(self, admin_client, db, tmp_path, monkeypatch):
|
||||
"""Regression: bulk delete by explicit IDs must NOT touch source .md files."""
|
||||
import padelnomics.content.routes as content_routes_mod
|
||||
|
||||
build_dir = tmp_path / "build"
|
||||
build_dir.mkdir()
|
||||
monkeypatch.setattr(content_routes_mod, "BUILD_DIR", build_dir)
|
||||
|
||||
(build_dir / "bulk-del-1.html").write_text("<p>1</p>")
|
||||
(build_dir / "bulk-del-2.html").write_text("<p>2</p>")
|
||||
md1 = tmp_path / "bulk-del-1.md"
|
||||
md2 = tmp_path / "bulk-del-2.md"
|
||||
md1.write_text("# One")
|
||||
md2.write_text("# Two")
|
||||
|
||||
id1 = await _create_article(slug="bulk-del-1", url_path="/bulk-del-1", article_type="generated")
|
||||
id2 = await _create_article(slug="bulk-del-2", url_path="/bulk-del-2", article_type="cornerstone")
|
||||
|
||||
async with admin_client.session_transaction() as sess:
|
||||
sess["csrf_token"] = "test"
|
||||
|
||||
resp = await admin_client.post("/admin/articles/bulk", form={
|
||||
"csrf_token": "test",
|
||||
"action": "delete",
|
||||
"article_ids": f"{id1},{id2}",
|
||||
"apply_to_all": "false",
|
||||
"article_type": "generated",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
assert await fetch_one("SELECT 1 FROM articles WHERE id = ?", (id1,)) is None
|
||||
assert await fetch_one("SELECT 1 FROM articles WHERE id = ?", (id2,)) is None
|
||||
assert not (build_dir / "bulk-del-1.html").exists()
|
||||
assert not (build_dir / "bulk-del-2.html").exists()
|
||||
assert md1.exists(), "generated article .md must NOT be deleted"
|
||||
assert md2.exists(), "cornerstone article .md must NOT be deleted"
|
||||
|
||||
async def test_bulk_delete_apply_to_all_never_removes_md(self, admin_client, db, tmp_path, monkeypatch):
|
||||
"""Regression: bulk delete apply_to_all must NOT touch source .md files."""
|
||||
import padelnomics.content.routes as content_routes_mod
|
||||
|
||||
build_dir = tmp_path / "build"
|
||||
build_dir.mkdir()
|
||||
monkeypatch.setattr(content_routes_mod, "BUILD_DIR", build_dir)
|
||||
|
||||
(build_dir / "ata-del.html").write_text("<p>x</p>")
|
||||
md_file = tmp_path / "ata-del.md"
|
||||
md_file.write_text("# Source")
|
||||
|
||||
await _create_article(slug="ata-del", url_path="/ata-del", article_type="generated")
|
||||
|
||||
async with admin_client.session_transaction() as sess:
|
||||
sess["csrf_token"] = "test"
|
||||
|
||||
resp = await admin_client.post("/admin/articles/bulk", form={
|
||||
"csrf_token": "test",
|
||||
"action": "delete",
|
||||
"apply_to_all": "true",
|
||||
"article_type": "generated",
|
||||
"search": "ata-del",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
assert await fetch_one("SELECT 1 FROM articles WHERE slug = 'ata-del'") is None
|
||||
assert not (build_dir / "ata-del.html").exists()
|
||||
assert md_file.exists(), "source .md must NOT be deleted"
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user