Adding a bulk delete option to the scopes page

This commit is contained in:
Ian Roddis committed 2026-08-10 12:15:09 -03:00
1 parent a9c4c16d4f
commit 7a15d9f7c0
5 files changed
+137 -40

No files matched your search

+5
View File
@@ -567,6 +567,11 @@ textarea:focus {
margin-bottom: 0.75rem;
}
/* Bulk actions (e.g. delete-selected above a checkbox table) */
.bulk-actions {
margin-bottom: 0.75rem;
}
.filter-bar form {
display: flex;
flex-wrap: wrap;
+6 -12
View File
@@ -21,6 +21,9 @@
<table class="table sortable">
<thead>
<tr>
{% if can_write_scopes %}
<th data-sort-off><input type="checkbox" id="scopes-select-all" title="Select all"></th>
{% endif %}
<th>Scope</th>
<th>Manufacturer</th>
<th>Model</th>
@@ -30,9 +33,6 @@
{% for ct in column_types %}
<th data-sort-off>{{ ct.name }}</th>
{% endfor %}
{% if can_write_scopes %}
<th data-sort-off></th>
{% endif %}
</tr>
</thead>
<tbody>
@@ -40,6 +40,9 @@
{% set scope = row.scope %}
{% set info = row.info %}
<tr id="scope-row-{{ scope.id }}">
{% if can_write_scopes %}
<td><input type="checkbox" name="scope_ids" value="{{ scope.id }}"></td>
{% endif %}
<td>
<strong><a href="/admin/augmented-parts/{{ scope.id }}">{{ scope.description }}</a></strong>
{% if scope.sku %}<br><span class="muted">{{ scope.sku }}</span>{% endif %}
@@ -70,15 +73,6 @@
{% endif %}
</td>
{% endfor %}
{% if can_write_scopes %}
<td class="row-actions">
<button class="btn btn-danger"
hx-delete="/scopes/{{ scope.id }}"
hx-target="#scope-row-{{ scope.id }}"
hx-swap="outerHTML"
hx-confirm="Delete {{ scope.sku or scope.description }}?">Delete</button>
</td>
{% endif %}
</tr>
{% else %}
<tr>
+44
View File
@@ -44,6 +44,50 @@
</form>
</div>
{% if can_write_scopes %}
<div class="bulk-actions">
<button id="delete-selected-scopes-btn" type="button" class="btn btn-danger" disabled
hx-post="/scopes/bulk-delete"
hx-include="[name='scope_ids']:checked, [name='description'], [name='manufacturer_id'], [name='scope_model_id'], [name='sub_part_type_id'], [name='fits_part_type_id']"
hx-target="#scopes-table-card"
hx-swap="outerHTML"
hx-confirm="Delete selected scopes?">Delete selected</button>
</div>
{% endif %}
{% include "_scopes_table_card.html" %}
</main>
{% if can_write_scopes %}
<script>
(function () {
function updateDeleteButton() {
var count = document.querySelectorAll('input[name="scope_ids"]:checked').length;
var btn = document.getElementById('delete-selected-scopes-btn');
if (!btn) return;
btn.textContent = count > 0 ? 'Delete selected (' + count + ')' : 'Delete selected';
btn.disabled = count === 0;
btn.setAttribute('hx-confirm', 'Delete ' + count + ' selected scope' + (count === 1 ? '' : 's') + '?');
}
document.addEventListener('change', function (e) {
if (!e.target) return;
if (e.target.id === 'scopes-select-all') {
document.querySelectorAll('input[name="scope_ids"]').forEach(function (cb) {
cb.checked = e.target.checked;
});
}
if (e.target.id === 'scopes-select-all' || e.target.name === 'scope_ids') {
updateDeleteButton();
}
});
// Re-run after every HTMX swap (filter results, post-delete) so the button
// state reflects the freshly rendered checkboxes, which start unchecked.
document.addEventListener('htmx:afterSwap', updateDeleteButton);
}());
</script>
{% endif %}
{% endblock %}
+38 -19
View File
@@ -5,7 +5,7 @@ non-Scope part type listing the accessories that fit each scope. Supports HTMX
search/filter over scope attributes and required accessory fits.
"""
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from fastapi import APIRouter, Depends, Form, Request
from fastapi.responses import HTMLResponse
from sqlalchemy.ext.asyncio import AsyncSession
@@ -119,25 +119,44 @@ async def scopes_filter(
)
@router.delete("/scopes/{augmented_part_id}")
async def delete_scope(
augmented_part_id: int,
@router.post("/scopes/bulk-delete", response_class=HTMLResponse)
async def bulk_delete_scopes(
request: Request,
scope_ids: list[int] = Form([]),
description: str = Form(""),
manufacturer_id: str = Form(""),
scope_model_id: str = Form(""),
sub_part_type_id: str = Form(""),
fits_part_type_id: str = Form(""),
db: AsyncSession = Depends(get_db),
actor: User = Depends(web_require("augmented_parts:write")),
):
ap = await augmented_part_crud.get(db, augmented_part_id)
if ap is None:
raise HTTPException(status_code=404, detail="Scope not found")
label = ap.sku or ap.description
await augmented_part_crud.delete(db, ap)
await audit_crud.log(
db,
actor=actor,
action="delete",
resource_type="AugmentedPart",
resource_id=augmented_part_id,
description=audit_crud.describe_delete(
"AugmentedPart", augmented_part_id, label
),
for augmented_part_id in scope_ids:
ap = await augmented_part_crud.get(db, augmented_part_id)
if ap is None:
continue
label = ap.sku or ap.description
await augmented_part_crud.delete(db, ap)
await audit_crud.log(
db,
actor=actor,
action="delete",
resource_type="AugmentedPart",
resource_id=augmented_part_id,
description=audit_crud.describe_delete(
"AugmentedPart", augmented_part_id, label
),
)
filters = {
"description": description,
"manufacturer_id": manufacturer_id,
"scope_model_id": scope_model_id,
"sub_part_type_id": sub_part_type_id,
"fits_part_type_id": fits_part_type_id,
}
guide = await _build(db, filters)
return templates.TemplateResponse(
request,
"_scopes_table_card.html",
{**guide, "can_write_scopes": _can_write_scopes(actor)},
)
return Response(status_code=status.HTTP_200_OK)