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_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(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
</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 %}
|
||||||
|
|||||||
Reference in New Issue
Block a user