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:
@@ -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"
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
· {{ stats.live }} live
|
||||
· {{ stats.scheduled }} scheduled
|
||||
· {{ 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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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 }}"
|
||||
|
||||
@@ -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
|
||||
· {{ stats.live }} live
|
||||
· {{ stats.scheduled }} scheduled
|
||||
· {{ stats.draft }} draft
|
||||
{% if is_generating %}
|
||||
· <span class="text-blue-500">generating…</span>
|
||||
{% endif %}
|
||||
</p>
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}?
|
||||
|
||||
@@ -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"]},
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user