feat(admin): bulk actions for articles and leads

Add bulk selection checkboxes and action bars to the articles and leads
admin pages, replicating the existing supplier bulk pattern.

Articles: publish, unpublish, toggle noindex, rebuild, delete (with
confirmation dialog). Leads: set status, set heat. Both re-render the
results partial after action via HTMX, preserving current filters.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Deeman
2026-03-04 09:40:26 +01:00
parent 477f635bc5
commit 81487d6f01
7 changed files with 353 additions and 0 deletions

View File

@@ -532,6 +532,71 @@ async def lead_results():
) )
@bp.route("/leads/bulk", methods=["POST"])
@role_required("admin")
@csrf_protect
async def leads_bulk():
"""Bulk actions on leads: set_status, set_heat."""
form = await request.form
ids_raw = form.get("lead_ids", "").strip()
action = form.get("action", "").strip()
if action not in ("set_status", "set_heat") or not ids_raw:
return "", 400
lead_ids = [int(i) for i in ids_raw.split(",") if i.strip().isdigit()]
assert len(lead_ids) <= 500, "too many lead IDs in bulk action"
if not lead_ids:
return "", 400
placeholders = ",".join("?" for _ in lead_ids)
if action == "set_status":
target = form.get("target_status", "").strip()
if target not in LEAD_STATUSES:
return "", 400
await execute(
f"UPDATE lead_requests SET status = ? WHERE id IN ({placeholders})",
(target, *lead_ids),
)
elif action == "set_heat":
target = form.get("target_heat", "").strip()
if target not in HEAT_OPTIONS:
return "", 400
await execute(
f"UPDATE lead_requests SET heat_score = ? WHERE id IN ({placeholders})",
(target, *lead_ids),
)
# Re-render results partial with current filters
search = form.get("search", "").strip()
status_filter = form.get("status", "")
heat_filter = form.get("heat", "")
country_filter = form.get("country", "")
days_str = form.get("days", "")
days = int(days_str) if days_str.isdigit() else None
per_page = 50
lead_list, total = await get_leads(
status=status_filter or None, heat=heat_filter or None,
country=country_filter or None, search=search or None,
days=days, page=1, per_page=per_page,
)
return await render_template(
"admin/partials/lead_results.html",
leads=lead_list,
page=1,
per_page=per_page,
total=total,
current_status=status_filter,
current_heat=heat_filter,
current_country=country_filter,
current_search=search,
current_days=days_str,
)
@bp.route("/leads/<int:lead_id>") @bp.route("/leads/<int:lead_id>")
@role_required("admin") @role_required("admin")
async def lead_detail(lead_id: int): async def lead_detail(lead_id: int):
@@ -2430,6 +2495,101 @@ async def article_results():
) )
@bp.route("/articles/bulk", methods=["POST"])
@role_required("admin")
@csrf_protect
async def articles_bulk():
"""Bulk actions on articles: publish, unpublish, toggle_noindex, rebuild, delete."""
form = await request.form
ids_raw = form.get("article_ids", "").strip()
action = form.get("action", "").strip()
valid_actions = ("publish", "unpublish", "toggle_noindex", "rebuild", "delete")
if action not in valid_actions or not ids_raw:
return "", 400
article_ids = [int(i) for i in ids_raw.split(",") if i.strip().isdigit()]
assert len(article_ids) <= 500, "too many article IDs in bulk action"
if not article_ids:
return "", 400
placeholders = ",".join("?" for _ in article_ids)
now = utcnow_iso()
if action == "publish":
await execute(
f"UPDATE articles SET status = 'published', updated_at = ? WHERE id IN ({placeholders})",
(now, *article_ids),
)
from ..sitemap import invalidate_sitemap_cache
invalidate_sitemap_cache()
elif action == "unpublish":
await execute(
f"UPDATE articles SET status = 'draft', updated_at = ? WHERE id IN ({placeholders})",
(now, *article_ids),
)
from ..sitemap import invalidate_sitemap_cache
invalidate_sitemap_cache()
elif action == "toggle_noindex":
await execute(
f"UPDATE articles SET noindex = CASE WHEN noindex = 1 THEN 0 ELSE 1 END, updated_at = ? WHERE id IN ({placeholders})",
(now, *article_ids),
)
elif action == "rebuild":
for aid in article_ids:
await _rebuild_article(aid)
elif action == "delete":
from ..content.routes import BUILD_DIR
articles = await fetch_all(
f"SELECT id, slug FROM articles WHERE id IN ({placeholders})",
tuple(article_ids),
)
for a in articles:
build_path = BUILD_DIR / f"{a['slug']}.html"
if build_path.exists():
build_path.unlink()
md_path = Path("data/content/articles") / f"{a['slug']}.md"
if md_path.exists():
md_path.unlink()
await execute(
f"DELETE FROM articles WHERE id IN ({placeholders})",
tuple(article_ids),
)
from ..sitemap import invalidate_sitemap_cache
invalidate_sitemap_cache()
# Re-render results partial with current filters
search = form.get("search", "").strip()
status_filter = form.get("status", "")
template_filter = form.get("template", "")
language_filter = form.get("language", "")
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,
)
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,
)
return await render_template(
"admin/partials/article_results.html",
articles=article_list,
grouped=grouped,
page=1,
is_generating=await _is_generating(),
)
@bp.route("/articles/new", methods=["GET", "POST"]) @bp.route("/articles/new", methods=["GET", "POST"])
@role_required("admin") @role_required("admin")
@csrf_protect @csrf_protect

View File

@@ -70,8 +70,91 @@
</form> </form>
</div> </div>
{# Bulk action bar #}
<form id="article-bulk-form" style="display:none">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="article_ids" id="article-bulk-ids" value="">
<input type="hidden" name="action" id="article-bulk-action" value="">
<input type="hidden" name="search" value="{{ current_search }}">
<input type="hidden" name="status" value="{{ current_status }}">
<input type="hidden" name="template" value="{{ current_template }}">
<input type="hidden" name="language" value="{{ current_language }}">
</form>
<div id="article-bulk-bar" class="card mb-4" style="padding:0.75rem 1.25rem;display:none;align-items:center;gap:1rem;background:#EFF6FF;border:1px solid #BFDBFE;">
<span id="article-bulk-count" class="text-sm font-semibold text-navy">0 selected</span>
<select id="article-bulk-action-select" class="form-input" style="min-width:140px;padding:0.25rem 0.5rem;font-size:0.8125rem">
<option value="">Action…</option>
<option value="publish">Publish</option>
<option value="unpublish">Unpublish</option>
<option value="toggle_noindex">Toggle noindex</option>
<option value="rebuild">Rebuild</option>
<option value="delete">Delete</option>
</select>
<button type="button" class="btn btn-sm" onclick="submitArticleBulk()">Apply</button>
<button type="button" class="btn-outline btn-sm" onclick="clearArticleSelection()">Clear</button>
</div>
{# Results #} {# Results #}
<div id="article-results"> <div id="article-results">
{% include "admin/partials/article_results.html" %} {% include "admin/partials/article_results.html" %}
</div> </div>
<script>
const articleSelectedIds = new Set();
function toggleArticleSelect(id, checked) {
if (checked) articleSelectedIds.add(id);
else articleSelectedIds.delete(id);
updateArticleBulkBar();
}
function clearArticleSelection() {
articleSelectedIds.clear();
document.querySelectorAll('.article-checkbox').forEach(function(cb) { cb.checked = false; });
var selectAll = document.getElementById('article-select-all');
if (selectAll) selectAll.checked = false;
updateArticleBulkBar();
}
function updateArticleBulkBar() {
var bar = document.getElementById('article-bulk-bar');
var count = document.getElementById('article-bulk-count');
var ids = document.getElementById('article-bulk-ids');
bar.style.display = articleSelectedIds.size > 0 ? 'flex' : 'none';
count.textContent = articleSelectedIds.size + ' selected';
ids.value = Array.from(articleSelectedIds).join(',');
}
function submitArticleBulk() {
var action = document.getElementById('article-bulk-action-select').value;
if (!action) return;
if (articleSelectedIds.size === 0) return;
function doSubmit() {
document.getElementById('article-bulk-action').value = action;
htmx.ajax('POST', '{{ url_for("admin.articles_bulk") }}', {
source: document.getElementById('article-bulk-form'),
target: '#article-results',
swap: 'innerHTML'
});
clearArticleSelection();
}
if (action === 'delete') {
showConfirm('Delete ' + articleSelectedIds.size + ' articles? This cannot be undone.').then(function(ok) {
if (ok) doSubmit();
});
} else {
doSubmit();
}
}
document.body.addEventListener('htmx:afterSwap', function(evt) {
if (evt.detail.target.id === 'article-results') {
document.querySelectorAll('.article-checkbox').forEach(function(cb) {
if (articleSelectedIds.has(Number(cb.dataset.id))) cb.checked = true;
});
}
});
</script>
{% endblock %} {% endblock %}

View File

@@ -126,8 +126,103 @@
</form> </form>
</div> </div>
<!-- Bulk action bar -->
<form id="lead-bulk-form" style="display:none">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="lead_ids" id="lead-bulk-ids" value="">
<input type="hidden" name="action" id="lead-bulk-action" value="">
<input type="hidden" name="target_status" id="lead-bulk-target-status" value="">
<input type="hidden" name="target_heat" id="lead-bulk-target-heat" value="">
<input type="hidden" name="search" value="{{ current_search }}">
<input type="hidden" name="status" value="{{ current_status }}">
<input type="hidden" name="heat" value="{{ current_heat }}">
<input type="hidden" name="country" value="{{ current_country }}">
<input type="hidden" name="days" value="{{ current_days }}">
</form>
<div id="lead-bulk-bar" class="card mb-4" style="padding:0.75rem 1.25rem;display:none;align-items:center;gap:1rem;background:#EFF6FF;border:1px solid #BFDBFE;">
<span id="lead-bulk-count" class="text-sm font-semibold text-navy">0 selected</span>
<select id="lead-bulk-action-select" class="form-input" style="min-width:120px;padding:0.25rem 0.5rem;font-size:0.8125rem" onchange="onLeadActionChange()">
<option value="">Action…</option>
<option value="set_status">Set Status</option>
<option value="set_heat">Set Heat</option>
</select>
<select id="lead-status-select" class="form-input" style="min-width:140px;padding:0.25rem 0.5rem;font-size:0.8125rem;display:none">
{% for s in statuses %}
<option value="{{ s }}">{{ s | replace('_', ' ') }}</option>
{% endfor %}
</select>
<select id="lead-heat-select" class="form-input" style="min-width:100px;padding:0.25rem 0.5rem;font-size:0.8125rem;display:none">
{% for h in heat_options %}
<option value="{{ h }}">{{ h | upper }}</option>
{% endfor %}
</select>
<button type="button" class="btn btn-sm" onclick="submitLeadBulk()">Apply</button>
<button type="button" class="btn-outline btn-sm" onclick="clearLeadSelection()">Clear</button>
</div>
<!-- Results --> <!-- Results -->
<div id="lead-results"> <div id="lead-results">
{% include "admin/partials/lead_results.html" %} {% include "admin/partials/lead_results.html" %}
</div> </div>
<script>
const leadSelectedIds = new Set();
function toggleLeadSelect(id, checked) {
if (checked) leadSelectedIds.add(id);
else leadSelectedIds.delete(id);
updateLeadBulkBar();
}
function clearLeadSelection() {
leadSelectedIds.clear();
document.querySelectorAll('.lead-checkbox').forEach(function(cb) { cb.checked = false; });
var selectAll = document.getElementById('lead-select-all');
if (selectAll) selectAll.checked = false;
updateLeadBulkBar();
}
function updateLeadBulkBar() {
var bar = document.getElementById('lead-bulk-bar');
var count = document.getElementById('lead-bulk-count');
var ids = document.getElementById('lead-bulk-ids');
bar.style.display = leadSelectedIds.size > 0 ? 'flex' : 'none';
count.textContent = leadSelectedIds.size + ' selected';
ids.value = Array.from(leadSelectedIds).join(',');
}
function onLeadActionChange() {
var action = document.getElementById('lead-bulk-action-select').value;
document.getElementById('lead-status-select').style.display = action === 'set_status' ? '' : 'none';
document.getElementById('lead-heat-select').style.display = action === 'set_heat' ? '' : 'none';
}
function submitLeadBulk() {
var action = document.getElementById('lead-bulk-action-select').value;
if (!action) return;
if (leadSelectedIds.size === 0) return;
document.getElementById('lead-bulk-action').value = action;
if (action === 'set_status') {
document.getElementById('lead-bulk-target-status').value = document.getElementById('lead-status-select').value;
} else if (action === 'set_heat') {
document.getElementById('lead-bulk-target-heat').value = document.getElementById('lead-heat-select').value;
}
htmx.ajax('POST', '{{ url_for("admin.leads_bulk") }}', {
source: document.getElementById('lead-bulk-form'),
target: '#lead-results',
swap: 'innerHTML'
});
clearLeadSelection();
}
document.body.addEventListener('htmx:afterSwap', function(evt) {
if (evt.detail.target.id === 'lead-results') {
document.querySelectorAll('.lead-checkbox').forEach(function(cb) {
if (leadSelectedIds.has(Number(cb.dataset.id))) cb.checked = true;
});
}
});
</script>
{% endblock %} {% endblock %}

View File

@@ -1,4 +1,5 @@
<tr id="article-group-{{ g.url_path | replace('/', '-') | trim('-') }}"> <tr id="article-group-{{ g.url_path | replace('/', '-') | trim('-') }}">
<td></td>
<td style="max-width:260px"> <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 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> <div class="article-subtitle">{{ g.url_path }}</div>

View File

@@ -54,6 +54,11 @@
<table class="table text-sm"> <table class="table text-sm">
<thead> <thead>
<tr> <tr>
{% if not grouped %}
<th style="width:32px"><input type="checkbox" id="article-select-all" onchange="document.querySelectorAll('.article-checkbox').forEach(cb => { cb.checked = this.checked; toggleArticleSelect(Number(cb.dataset.id), this.checked); })"></th>
{% else %}
<th style="width:32px"></th>
{% endif %}
<th>Title</th> <th>Title</th>
<th>{% if grouped %}Variants{% else %}Status{% endif %}</th> <th>{% if grouped %}Variants{% else %}Status{% endif %}</th>
<th>Published</th> <th>Published</th>

View File

@@ -1,4 +1,8 @@
<tr id="article-{{ a.id }}"> <tr id="article-{{ a.id }}">
<td onclick="event.stopPropagation()">
<input type="checkbox" class="article-checkbox" data-id="{{ a.id }}"
onchange="toggleArticleSelect({{ a.id }}, this.checked)">
</td>
<td style="max-width:280px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap" <td style="max-width:280px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap"
title="{{ a.url_path }}">{{ a.title }}</td> title="{{ a.url_path }}">{{ a.title }}</td>
<td> <td>

View File

@@ -29,6 +29,7 @@
<table class="table"> <table class="table">
<thead> <thead>
<tr> <tr>
<th style="width:32px"><input type="checkbox" id="lead-select-all" onchange="document.querySelectorAll('.lead-checkbox').forEach(cb => { cb.checked = this.checked; toggleLeadSelect(Number(cb.dataset.id), this.checked); })"></th>
<th>ID</th> <th>ID</th>
<th>Heat</th> <th>Heat</th>
<th>Contact</th> <th>Contact</th>
@@ -43,6 +44,10 @@
<tbody> <tbody>
{% for lead in leads %} {% for lead in leads %}
<tr data-href="{{ url_for('admin.lead_detail', lead_id=lead.id) }}"> <tr data-href="{{ url_for('admin.lead_detail', lead_id=lead.id) }}">
<td onclick="event.stopPropagation()">
<input type="checkbox" class="lead-checkbox" data-id="{{ lead.id }}"
onchange="toggleLeadSelect({{ lead.id }}, this.checked)">
</td>
<td><a href="{{ url_for('admin.lead_detail', lead_id=lead.id) }}">#{{ lead.id }}</a></td> <td><a href="{{ url_for('admin.lead_detail', lead_id=lead.id) }}">#{{ lead.id }}</a></td>
<td>{{ heat_badge(lead.heat_score) }}</td> <td>{{ heat_badge(lead.heat_score) }}</td>
<td> <td>