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:
@@ -1434,6 +1434,42 @@ async def scenarios():
|
||||
current_search=search,
|
||||
current_country=country_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}
|
||||
|
||||
|
||||
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")
|
||||
@role_required("admin")
|
||||
async def articles():
|
||||
@@ -1712,6 +1756,7 @@ async def articles():
|
||||
current_template=template_filter,
|
||||
current_language=language_filter,
|
||||
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,
|
||||
)
|
||||
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(),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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 %}
|
||||
<div class="card">
|
||||
<table class="table text-sm">
|
||||
|
||||
@@ -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 %}
|
||||
@@ -51,42 +51,8 @@
|
||||
</form>
|
||||
|
||||
<div class="card">
|
||||
{% 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 %}
|
||||
<div id="scenario-results">
|
||||
{% include "admin/partials/scenario_results.html" %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user