fix(cms): change articles unique constraint to (url_path, language)
url_path UNIQUE prevented multilingual generation — the second language (e.g. EN after DE) always failed with UNIQUE constraint, leaving tasks in a retry loop and only the first 1-2 articles visible. Migration 0020 recreates the articles table with UNIQUE(url_path, language) and adds a composite index. Adds idx_articles_url_lang for the new lookup pattern used by article_page and generate_articles upsert. Also adds search/country/venue_type filters to the admin Scenarios tab and clarifies what "Published Scenarios" means in the subtitle. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1395,11 +1395,46 @@ SCENARIO_FORM_FIELDS = [
|
|||||||
@bp.route("/scenarios")
|
@bp.route("/scenarios")
|
||||||
@role_required("admin")
|
@role_required("admin")
|
||||||
async def scenarios():
|
async def scenarios():
|
||||||
"""List published scenarios."""
|
"""List published scenarios with optional filters."""
|
||||||
|
search = request.args.get("search", "").strip()
|
||||||
|
country_filter = request.args.get("country", "")
|
||||||
|
venue_filter = request.args.get("venue_type", "")
|
||||||
|
|
||||||
|
wheres = ["1=1"]
|
||||||
|
params: list = []
|
||||||
|
if search:
|
||||||
|
wheres.append("(title LIKE ? OR location LIKE ? OR slug LIKE ?)")
|
||||||
|
params.extend([f"%{search}%", f"%{search}%", f"%{search}%"])
|
||||||
|
if country_filter:
|
||||||
|
wheres.append("country = ?")
|
||||||
|
params.append(country_filter)
|
||||||
|
if venue_filter:
|
||||||
|
wheres.append("venue_type = ?")
|
||||||
|
params.append(venue_filter)
|
||||||
|
|
||||||
|
where = " AND ".join(wheres)
|
||||||
scenario_list = await fetch_all(
|
scenario_list = await fetch_all(
|
||||||
"SELECT * FROM published_scenarios ORDER BY created_at DESC"
|
f"SELECT * FROM published_scenarios WHERE {where} ORDER BY created_at DESC",
|
||||||
|
tuple(params),
|
||||||
|
)
|
||||||
|
countries = await fetch_all(
|
||||||
|
"SELECT DISTINCT country FROM published_scenarios WHERE country != '' ORDER BY country"
|
||||||
|
)
|
||||||
|
venue_types = await fetch_all(
|
||||||
|
"SELECT DISTINCT venue_type FROM published_scenarios WHERE venue_type != '' ORDER BY venue_type"
|
||||||
|
)
|
||||||
|
total = await fetch_one("SELECT COUNT(*) as cnt FROM published_scenarios")
|
||||||
|
|
||||||
|
return await render_template(
|
||||||
|
"admin/scenarios.html",
|
||||||
|
scenarios=scenario_list,
|
||||||
|
countries=[r["country"] for r in countries],
|
||||||
|
venue_types=[r["venue_type"] for r in venue_types],
|
||||||
|
total=total["cnt"] if total else 0,
|
||||||
|
current_search=search,
|
||||||
|
current_country=country_filter,
|
||||||
|
current_venue_type=venue_filter,
|
||||||
)
|
)
|
||||||
return await render_template("admin/scenarios.html", scenarios=scenario_list)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/scenarios/new", methods=["GET", "POST"])
|
@bp.route("/scenarios/new", methods=["GET", "POST"])
|
||||||
|
|||||||
@@ -1,20 +1,55 @@
|
|||||||
{% extends "admin/base_admin.html" %}
|
{% extends "admin/base_admin.html" %}
|
||||||
{% set admin_page = "scenarios" %}
|
{% set admin_page = "scenarios" %}
|
||||||
|
|
||||||
{% block title %}Published Scenarios - Admin - {{ config.APP_NAME }}{% endblock %}
|
{% block title %}Scenarios - Admin - {{ config.APP_NAME }}{% endblock %}
|
||||||
|
|
||||||
{% block admin_content %}
|
{% block admin_content %}
|
||||||
<header class="flex justify-between items-center mb-8">
|
<header class="flex justify-between items-center mb-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl">Published Scenarios</h1>
|
<h1 class="text-2xl">Scenarios</h1>
|
||||||
<p class="text-slate text-sm">{{ scenarios | length }} scenario{{ 's' if scenarios | length != 1 }}</p>
|
<p class="text-slate text-sm">
|
||||||
|
Pre-computed calculator outputs — embedded as cards in articles and PDFs.
|
||||||
|
Showing {{ scenarios | length }} of {{ total }}.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<a href="{{ url_for('admin.scenario_new') }}" class="btn">New Scenario</a>
|
<a href="{{ url_for('admin.scenario_new') }}" class="btn">New Scenario</a>
|
||||||
<a href="{{ url_for('admin.index') }}" class="btn-outline">Back</a>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<form method="get" class="card mb-4 flex flex-wrap gap-3 items-end">
|
||||||
|
<div class="flex-1 min-w-48">
|
||||||
|
<label class="block text-sm text-slate mb-1">Search</label>
|
||||||
|
<input type="text" name="search" value="{{ current_search }}"
|
||||||
|
placeholder="Title, location, slug…"
|
||||||
|
class="input w-full">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm text-slate mb-1">Country</label>
|
||||||
|
<select name="country" class="input">
|
||||||
|
<option value="">All countries</option>
|
||||||
|
{% for c in countries %}
|
||||||
|
<option value="{{ c }}" {% if c == current_country %}selected{% endif %}>{{ c }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm text-slate mb-1">Venue type</label>
|
||||||
|
<select name="venue_type" class="input">
|
||||||
|
<option value="">All types</option>
|
||||||
|
{% for v in venue_types %}
|
||||||
|
<option value="{{ v }}" {% if v == current_venue_type %}selected{% endif %}>{{ v | capitalize }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button type="submit" class="btn">Filter</button>
|
||||||
|
{% if current_search or current_country or current_venue_type %}
|
||||||
|
<a href="{{ url_for('admin.scenarios') }}" class="btn-outline">Clear</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
{% if scenarios %}
|
{% if scenarios %}
|
||||||
<table class="table">
|
<table class="table">
|
||||||
@@ -51,7 +86,7 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="text-slate text-sm">No published scenarios yet.</p>
|
<p class="text-slate text-sm">No scenarios match the current filters.</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
"""Change articles unique constraint from url_path alone to (url_path, language).
|
||||||
|
|
||||||
|
Previously url_path was declared UNIQUE, which prevented multiple languages
|
||||||
|
from sharing the same url_path (e.g. /markets/germany/berlin for both de and en).
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def up(conn) -> None:
|
||||||
|
# ── 1. Drop FTS triggers + virtual table ──────────────────────────────────
|
||||||
|
conn.execute("DROP TRIGGER IF EXISTS articles_ai")
|
||||||
|
conn.execute("DROP TRIGGER IF EXISTS articles_ad")
|
||||||
|
conn.execute("DROP TRIGGER IF EXISTS articles_au")
|
||||||
|
conn.execute("DROP TABLE IF EXISTS articles_fts")
|
||||||
|
|
||||||
|
# ── 2. Recreate articles with UNIQUE(url_path, language) ──────────────────
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE articles_new (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
url_path TEXT NOT NULL,
|
||||||
|
slug TEXT UNIQUE NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
meta_description TEXT,
|
||||||
|
country TEXT,
|
||||||
|
region TEXT,
|
||||||
|
og_image_url TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'draft',
|
||||||
|
published_at TEXT,
|
||||||
|
template_slug TEXT,
|
||||||
|
language TEXT NOT NULL DEFAULT 'en',
|
||||||
|
date_modified TEXT,
|
||||||
|
seo_head TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT,
|
||||||
|
UNIQUE(url_path, language)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.execute("""
|
||||||
|
INSERT INTO articles_new
|
||||||
|
(id, url_path, slug, title, meta_description, country, region,
|
||||||
|
og_image_url, status, published_at, template_slug, language,
|
||||||
|
date_modified, seo_head, created_at, updated_at)
|
||||||
|
SELECT id, url_path, slug, title, meta_description, country, region,
|
||||||
|
og_image_url, status, published_at, template_slug, language,
|
||||||
|
date_modified, seo_head, created_at, updated_at
|
||||||
|
FROM articles
|
||||||
|
""")
|
||||||
|
conn.execute("DROP TABLE articles")
|
||||||
|
conn.execute("ALTER TABLE articles_new RENAME TO articles")
|
||||||
|
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_articles_url_path ON articles(url_path)")
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_articles_url_lang ON articles(url_path, language)")
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_articles_slug ON articles(slug)")
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_articles_status ON articles(status, published_at)")
|
||||||
|
|
||||||
|
# ── 3. Recreate FTS + triggers ─────────────────────────────────────────────
|
||||||
|
conn.execute("""
|
||||||
|
CREATE VIRTUAL TABLE IF NOT EXISTS articles_fts USING fts5(
|
||||||
|
title, meta_description, country, region,
|
||||||
|
content='articles', content_rowid='id'
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TRIGGER IF NOT EXISTS articles_ai AFTER INSERT ON articles BEGIN
|
||||||
|
INSERT INTO articles_fts(rowid, title, meta_description, country, region)
|
||||||
|
VALUES (new.id, new.title, new.meta_description, new.country, new.region);
|
||||||
|
END
|
||||||
|
""")
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TRIGGER IF NOT EXISTS articles_ad AFTER DELETE ON articles BEGIN
|
||||||
|
INSERT INTO articles_fts(articles_fts, rowid, title, meta_description, country, region)
|
||||||
|
VALUES ('delete', old.id, old.title, old.meta_description, old.country, old.region);
|
||||||
|
END
|
||||||
|
""")
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TRIGGER IF NOT EXISTS articles_au AFTER UPDATE ON articles BEGIN
|
||||||
|
INSERT INTO articles_fts(articles_fts, rowid, title, meta_description, country, region)
|
||||||
|
VALUES ('delete', old.id, old.title, old.meta_description, old.country, old.region);
|
||||||
|
INSERT INTO articles_fts(rowid, title, meta_description, country, region)
|
||||||
|
VALUES (new.id, new.title, new.meta_description, new.country, new.region);
|
||||||
|
END
|
||||||
|
""")
|
||||||
Reference in New Issue
Block a user