feat(admin): live-poll Articles and Scenarios tabs during generation

- Add _is_generating() helper — queries tasks table for pending generate_articles tasks
- Pass is_generating to article_results partial (both full page and HTMX route)
- article_results.html: render invisible hx-trigger="every 3s" div when generating;
  polling stops naturally once generation completes and div is absent
- Add /admin/scenarios/results HTMX partial route with same is_generating logic
- Extract scenario table into admin/partials/scenario_results.html partial
- scenarios.html: wrap table in #scenario-results div, include partial

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Deeman
2026-02-24 10:14:21 +01:00
parent 3d99b8c375
commit 4033e13e05
4 changed files with 103 additions and 38 deletions

View File

@@ -1434,6 +1434,42 @@ async def scenarios():
current_search=search, current_search=search,
current_country=country_filter, current_country=country_filter,
current_venue_type=venue_filter, current_venue_type=venue_filter,
is_generating=await _is_generating(),
)
@bp.route("/scenarios/results")
@role_required("admin")
async def scenario_results():
"""HTMX partial for scenario results (used by live polling)."""
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(
f"SELECT * FROM published_scenarios WHERE {where} ORDER BY created_at DESC LIMIT 500",
tuple(params),
)
total = await fetch_one("SELECT COUNT(*) as cnt FROM published_scenarios")
return await render_template(
"admin/partials/scenario_results.html",
scenarios=scenario_list,
total=total["cnt"] if total else 0,
is_generating=await _is_generating(),
) )
@@ -1683,6 +1719,14 @@ async def _get_article_stats() -> dict:
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 _is_generating() -> bool:
"""Return True if a generate_articles task is currently pending."""
row = await fetch_one(
"SELECT COUNT(*) AS cnt FROM tasks WHERE task_type = 'generate_articles' AND status = 'pending'"
)
return bool(row and row["cnt"] > 0)
@bp.route("/articles") @bp.route("/articles")
@role_required("admin") @role_required("admin")
async def articles(): async def articles():
@@ -1712,6 +1756,7 @@ async def articles():
current_template=template_filter, current_template=template_filter,
current_language=language_filter, current_language=language_filter,
page=page, page=page,
is_generating=await _is_generating(),
) )
@@ -1730,7 +1775,10 @@ async def article_results():
language=language_filter or None, search=search or None, page=page, language=language_filter or None, search=search or None, page=page,
) )
return await render_template( return await render_template(
"admin/partials/article_results.html", articles=article_list, page=page, "admin/partials/article_results.html",
articles=article_list,
page=page,
is_generating=await _is_generating(),
) )

View File

@@ -1,3 +1,10 @@
{% if is_generating %}
<div hx-get="{{ url_for('admin.article_results') }}"
hx-trigger="every 3s"
hx-target="#article-results"
hx-swap="innerHTML"
style="display:none" aria-hidden="true"></div>
{% endif %}
{% if articles %} {% if articles %}
<div class="card"> <div class="card">
<table class="table text-sm"> <table class="table text-sm">

View File

@@ -0,0 +1,44 @@
{% if is_generating %}
<div hx-get="{{ url_for('admin.scenario_results') }}"
hx-trigger="every 3s"
hx-target="#scenario-results"
hx-swap="innerHTML"
style="display:none" aria-hidden="true"></div>
{% endif %}
{% if scenarios %}
<table class="table">
<thead>
<tr>
<th>Title</th>
<th>Slug</th>
<th>Location</th>
<th>Config</th>
<th>Created</th>
<th></th>
</tr>
</thead>
<tbody>
{% for s in scenarios %}
<tr>
<td>{{ s.title }}</td>
<td class="mono text-sm">{{ s.slug }}</td>
<td>{{ s.location }}, {{ s.country }}</td>
<td class="text-sm">{{ s.venue_type | capitalize }} · {{ s.court_config }}</td>
<td class="mono text-sm">{{ s.created_at[:10] }}</td>
<td class="text-right">
<a href="{{ url_for('admin.scenario_preview', scenario_id=s.id) }}" class="btn-outline btn-sm">Preview</a>
<a href="{{ url_for('admin.scenario_pdf', scenario_id=s.id, lang='en') }}" class="btn-outline btn-sm">PDF EN</a>
<a href="{{ url_for('admin.scenario_pdf', scenario_id=s.id, lang='de') }}" class="btn-outline btn-sm">PDF DE</a>
<a href="{{ url_for('admin.scenario_edit', scenario_id=s.id) }}" class="btn-outline btn-sm">Edit</a>
<form method="post" action="{{ url_for('admin.scenario_delete', scenario_id=s.id) }}" class="m-0" style="display: inline;">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn-outline btn-sm" onclick="return confirm('Delete this scenario?')">Delete</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="text-slate text-sm">No scenarios match the current filters.</p>
{% endif %}

View File

@@ -51,42 +51,8 @@
</form> </form>
<div class="card"> <div class="card">
{% if scenarios %} <div id="scenario-results">
<table class="table"> {% include "admin/partials/scenario_results.html" %}
<thead> </div>
<tr>
<th>Title</th>
<th>Slug</th>
<th>Location</th>
<th>Config</th>
<th>Created</th>
<th></th>
</tr>
</thead>
<tbody>
{% for s in scenarios %}
<tr>
<td>{{ s.title }}</td>
<td class="mono text-sm">{{ s.slug }}</td>
<td>{{ s.location }}, {{ s.country }}</td>
<td class="text-sm">{{ s.venue_type | capitalize }} · {{ s.court_config }}</td>
<td class="mono text-sm">{{ s.created_at[:10] }}</td>
<td class="text-right">
<a href="{{ url_for('admin.scenario_preview', scenario_id=s.id) }}" class="btn-outline btn-sm">Preview</a>
<a href="{{ url_for('admin.scenario_pdf', scenario_id=s.id, lang='en') }}" class="btn-outline btn-sm">PDF EN</a>
<a href="{{ url_for('admin.scenario_pdf', scenario_id=s.id, lang='de') }}" class="btn-outline btn-sm">PDF DE</a>
<a href="{{ url_for('admin.scenario_edit', scenario_id=s.id) }}" class="btn-outline btn-sm">Edit</a>
<form method="post" action="{{ url_for('admin.scenario_delete', scenario_id=s.id) }}" class="m-0" style="display: inline;">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn-outline btn-sm" onclick="return confirm('Delete this scenario?')">Delete</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="text-slate text-sm">No scenarios match the current filters.</p>
{% endif %}
</div> </div>
{% endblock %} {% endblock %}