feat: admin articles grouped view, live stats, + bug fixes

Admin articles list:
- Group EN/DE language variants into a single row (grouped by url_path)
- Language chips (● EN/● DE) coloured by status: green=live, amber=scheduled, blue=draft
- Inline View ↗ (live only) and Edit buttons per variant — one-click access
- Filter by language switches back to flat single-row view
- Live HTMX polling of article counts while generation runs (every 3s, self-terminates)
- Table overflow fix: card gets overflow:hidden, table wrapped in overflow-x:auto scroll div

Bug fixes:
- X-Forwarded-Proto: pass $http_x_forwarded_proto through Nginx so Quart sees https
- pipeline_routes.py: fix relative import for analytics module (from .analytics → from ..analytics)
- Scheduled articles: redirect to parent path instead of 404 when not yet published
- city-cost-de: change priority_column from population to padel_venue_count
- Quote wizard step 4: make location_status required
- Article generation: use COUNT(*) instead of 501-sentinel hack for row counts
- Makefile: pin Tailwind v4.1.18, add dev/help targets, uv run python, .PHONY

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Deeman
2026-02-26 20:17:28 +01:00
parent ee488b6aca
commit 0fa2bf7c30
17 changed files with 270 additions and 335 deletions

View File

@@ -372,7 +372,7 @@ def _is_stale(run: dict) -> bool:
@role_required("admin")
async def pipeline_dashboard():
"""Main page: health stat cards + tab container."""
from .analytics import fetch_analytics # noqa: PLC0415
from ..analytics import fetch_analytics # noqa: PLC0415
summary, serving_meta = await asyncio.gather(
asyncio.to_thread(_fetch_extraction_summary_sync),
@@ -442,7 +442,7 @@ async def pipeline_overview():
]
last_export = serving_meta.get("exported_at_utc", "")[:19].replace("T", " ") or None
else:
from .analytics import fetch_analytics # noqa: PLC0415
from ..analytics import fetch_analytics # noqa: PLC0415
schema_rows = await fetch_analytics(
"SELECT table_name FROM information_schema.tables "
"WHERE table_schema = 'serving' ORDER BY table_name"

View File

@@ -2244,6 +2244,79 @@ async def _get_article_list(
)
async def _get_article_list_grouped(
status: str = None,
template_slug: str = None,
search: str = None,
page: int = 1,
per_page: int = 50,
) -> list[dict]:
"""Get articles grouped by slug; each item has a 'variants' list (one per language)."""
wheres = ["1=1"]
params: list = []
if status == "live":
wheres.append("status = 'published' AND published_at <= datetime('now')")
elif status == "scheduled":
wheres.append("status = 'published' AND published_at > datetime('now')")
elif status == "draft":
wheres.append("status = 'draft'")
if template_slug:
wheres.append("template_slug = ?")
params.append(template_slug)
if search:
wheres.append("title LIKE ?")
params.append(f"%{search}%")
where = " AND ".join(wheres)
offset = (page - 1) * per_page
# Group by url_path — language variants share the same url_path (no lang prefix stored)
path_rows = await fetch_all(
f"""SELECT url_path, MAX(created_at) AS latest_created
FROM articles WHERE {where}
GROUP BY url_path
ORDER BY latest_created DESC
LIMIT ? OFFSET ?""",
tuple(params + [per_page, offset]),
)
if not path_rows:
return []
url_paths = [r["url_path"] for r in path_rows]
placeholders = ",".join("?" * len(url_paths))
variants = await fetch_all(
f"""SELECT *,
CASE WHEN status = 'published' AND published_at > datetime('now')
THEN 'scheduled'
WHEN status = 'published' THEN 'live'
ELSE status END AS display_status
FROM articles WHERE url_path IN ({placeholders})
ORDER BY url_path, language""",
tuple(url_paths),
)
by_path: dict[str, list] = {}
for v in variants:
by_path.setdefault(v["url_path"], []).append(dict(v))
groups = []
for url_path in url_paths:
variant_list = by_path.get(url_path, [])
if not variant_list:
continue
primary = next((v for v in variant_list if v["language"] == "en"), variant_list[0])
groups.append({
"url_path": url_path,
"title": primary["title"],
"published_at": primary["published_at"],
"template_slug": primary["template_slug"],
"variants": variant_list,
})
return groups
async def _get_article_stats() -> dict:
"""Get aggregate article stats for the admin list header."""
row = await fetch_one(
@@ -2275,10 +2348,17 @@ async def articles():
language_filter = request.args.get("language", "")
page = max(1, int(request.args.get("page", "1") or "1"))
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,
)
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,
)
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,
)
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"
@@ -2287,6 +2367,7 @@ async def articles():
return await render_template(
"admin/articles.html",
articles=article_list,
grouped=grouped,
stats=stats,
template_slugs=[t["template_slug"] for t in templates],
current_search=search,
@@ -2308,13 +2389,21 @@ async def article_results():
language_filter = request.args.get("language", "")
page = max(1, int(request.args.get("page", "1") or "1"))
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,
)
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,
)
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,
)
return await render_template(
"admin/partials/article_results.html",
articles=article_list,
grouped=grouped,
page=page,
is_generating=await _is_generating(),
)

View File

@@ -7,12 +7,7 @@
<header class="flex justify-between items-center mb-6">
<div>
<h1 class="text-2xl">Articles</h1>
<p class="text-sm text-slate mt-1">
{{ stats.total }} total
&middot; {{ stats.live }} live
&middot; {{ stats.scheduled }} scheduled
&middot; {{ stats.draft }} draft
</p>
{% include "admin/partials/article_stats.html" %}
</div>
<div class="flex gap-2">
<a href="{{ url_for('admin.article_new') }}" class="btn btn-sm">New Article</a>

View File

@@ -0,0 +1,26 @@
<tr id="article-group-{{ g.url_path | replace('/', '-') | trim('-') }}">
<td style="max-width:260px">
<div style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:500" title="{{ g.url_path }}">{{ g.title }}</div>
<div class="article-subtitle">{{ g.url_path }}</div>
</td>
<td>
{% for v in g.variants %}
<div class="variant-row">
<a href="{{ url_for('admin.article_edit', article_id=v.id) }}"
class="lang-chip lang-chip-{{ v.display_status }}"
title="Edit {{ v.language|upper }} variant">
<span class="dot"></span>{{ v.language | upper }}
{% if v.noindex %}<span class="noindex-tag">noindex</span>{% endif %}
</a>
{% if v.display_status == 'live' %}
<a href="/{{ v.language or 'en' }}{{ v.url_path }}" target="_blank"
class="btn-outline btn-sm view-lang-btn" title="View live article">View ↗</a>
{% endif %}
<a href="{{ url_for('admin.article_edit', article_id=v.id) }}"
class="btn-outline btn-sm view-lang-btn">Edit</a>
</div>
{% 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>
</tr>

View File

@@ -1,3 +1,40 @@
{% if grouped %}
<style>
.lang-chip {
display: inline-flex; align-items: center; gap: 0.3rem;
font-size: 0.6rem; font-weight: 700;
font-family: ui-monospace, 'SF Mono', Menlo, monospace;
letter-spacing: 0.06em; text-transform: uppercase;
padding: 0.2rem 0.5rem 0.2rem 0.35rem; border-radius: 0.3rem;
text-decoration: none;
border: 1px solid transparent;
}
.lang-chip-live { background: #DCFCE7; color: #14532D; border-color: #A7F3D0; }
.lang-chip-scheduled { background: #FEF9C3; color: #713F12; border-color: #FDE68A; }
.lang-chip-draft { background: #EFF6FF; color: #1E40AF; border-color: #BFDBFE; }
.lang-chip .dot { width: 5px; height: 5px; border-radius: 50%; flex-shrink: 0; }
.lang-chip-live .dot { background: #16A34A; }
.lang-chip-scheduled .dot { background: #D97706; }
.lang-chip-draft .dot { background: #3B82F6; }
.lang-chip .noindex-tag {
font-size: 0.5rem; font-weight: 600; opacity: 0.55;
border-left: 1px solid currentColor; padding-left: 0.3rem; margin-left: 0.1rem;
}
.article-subtitle {
font-size: 0.625rem; font-family: ui-monospace, 'SF Mono', Menlo, monospace;
color: #CBD5E1; margin-top: 0.1rem;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.variant-row {
display: flex; align-items: center; gap: 0.4rem; white-space: nowrap;
padding: 0.15rem 0;
}
.variant-row + .variant-row {
border-top: 1px solid #F1F5F9; margin-top: 0.15rem; padding-top: 0.3rem;
}
.view-lang-btn { font-size: 0.65rem !important; padding: 0.15rem 0.5rem !important; }
</style>
{% endif %}
{% if is_generating %}
<div class="generating-banner"
hx-get="{{ url_for('admin.article_results') }}"
@@ -12,26 +49,34 @@
</div>
{% endif %}
{% if articles %}
<div class="card">
<div class="card" style="padding:0; overflow:hidden">
<div style="overflow-x:auto">
<table class="table text-sm">
<thead>
<tr>
<th>Title</th>
<th>Status</th>
<th>{% if grouped %}Variants{% else %}Status{% endif %}</th>
<th>Published</th>
<th>Lang</th>
{% if not grouped %}<th>Lang</th>{% endif %}
<th>Template</th>
<th></th>
{% if not grouped %}<th></th>{% endif %}
</tr>
</thead>
<tbody>
{% for a in articles %}
{% include "admin/partials/article_row.html" %}
{% endfor %}
{% if grouped %}
{% for g in articles %}
{% include "admin/partials/article_group_row.html" %}
{% endfor %}
{% else %}
{% for a in articles %}
{% include "admin/partials/article_row.html" %}
{% endfor %}
{% endif %}
</tbody>
</table>
</div>
{% if articles | length >= 50 %}
<div class="flex justify-between items-center" style="padding:0.75rem 1rem; border-top:1px solid #E2E8F0">
<div class="flex justify-between items-center" style="padding:0.75rem 1.5rem; border-top:1px solid #E2E8F0">
{% if page > 1 %}
<button class="btn-outline btn-sm"
hx-get="{{ url_for('admin.article_results') }}?page={{ page - 1 }}"

View File

@@ -0,0 +1,15 @@
<p class="text-sm text-slate mt-1"
id="article-stats"
{% if is_generating %}
hx-get="{{ url_for('admin.article_stats') }}"
hx-trigger="every 3s"
hx-swap="outerHTML"
{% endif %}>
{{ stats.total }} total
&middot; {{ stats.live }} live
&middot; {{ stats.scheduled }} scheduled
&middot; {{ stats.draft }} draft
{% if is_generating %}
&middot; <span class="text-blue-500">generating…</span>
{% endif %}
</p>

View File

@@ -7,7 +7,7 @@ from pathlib import Path
from jinja2 import Environment, FileSystemLoader
from markupsafe import Markup
from quart import Blueprint, abort, g, render_template, request
from quart import Blueprint, abort, g, redirect, render_template, request
from ..core import capture_waitlist_email, csrf_protect, feature_gate, fetch_all, fetch_one
from ..i18n import get_translations
@@ -230,6 +230,15 @@ async def article_page(url_path: str):
(clean_path, lang),
)
if not article:
# If a scheduled (not yet live) article exists at this URL, redirect to
# the nearest parent path rather than showing a bare 404.
scheduled = await fetch_one(
"SELECT 1 FROM articles WHERE url_path = ? AND language = ?",
(clean_path, lang),
)
if scheduled:
parent = clean_path.rsplit("/", 1)[0] or f"/{lang}/markets"
return redirect(parent, 302)
abort(404)
# SSG articles: language-prefixed build path

View File

@@ -9,7 +9,7 @@ url_pattern: "/markets/{{ country_slug }}/{{ city_slug }}"
title_pattern: "{% if language == 'de' %}Padel in {{ city_name }} — Investitionskosten & Marktanalyse {{ 'now' | datetimeformat('%Y') }}{% else %}Padel in {{ city_name }} — Investment Costs & Market Analysis {{ 'now' | datetimeformat('%Y') }}{% endif %}"
meta_description_pattern: "{% if language == 'de' %}Lohnt sich eine Padelhalle in {{ city_name }}? {{ padel_venue_count }} Anlagen, <span style=\"font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em\">padelnomics</span> Market Score {{ market_score | round(1) }}/100 und ein vollständiges Finanzmodell. Stand {{ 'now' | datetimeformat('%B %Y') }}.{% else %}Is {{ city_name }} worth building a padel center in? {{ padel_venue_count }} venues, <span style=\"font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em\">padelnomics</span> Market Score {{ market_score | round(1) }}/100, and a full financial model. Updated {{ 'now' | datetimeformat('%B %Y') }}.{% endif %}"
schema_type: [Article, FAQPage]
priority_column: population
priority_column: padel_venue_count
---
{% if language == "de" %}
# Lohnt sich eine Padelhalle in {{ city_name }}?

View File

@@ -181,7 +181,7 @@ def _get_quote_steps(lang: str) -> list:
{"n": 1, "title": t["q1_heading"], "required": ["facility_type"]},
{"n": 2, "title": t["q2_heading"], "required": ["country"]},
{"n": 3, "title": t["q3_heading"], "required": []},
{"n": 4, "title": t["q4_heading"], "required": []},
{"n": 4, "title": t["q4_heading"], "required": ["location_status"]},
{"n": 5, "title": t["q5_heading"], "required": ["timeline"]},
{"n": 6, "title": t["q6_heading"], "required": ["financing_status", "decision_process"]},
{"n": 7, "title": t["q7_heading"], "required": ["stakeholder_type"]},

View File

@@ -275,7 +275,7 @@
/* Cards (replace Pico <article>) */
.card {
@apply bg-white border border-light-gray rounded-2xl p-6 mb-6 shadow-sm;
@apply bg-white border border-light-gray rounded-2xl p-6 mb-6 shadow-sm overflow-hidden;
}
.card-header {
@apply border-b border-light-gray pb-3 mb-4 text-sm text-slate font-medium;