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; margin-bottom: 0.75rem;
} }
/* Bulk actions (e.g. delete-selected above a checkbox table) */
.bulk-actions {
margin-bottom: 0.75rem;
}
.filter-bar form { .filter-bar form {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
+6 -12
View File
@@ -21,6 +21,9 @@
<table class="table sortable"> <table class="table sortable">
<thead> <thead>
<tr> <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>Scope</th>
<th>Manufacturer</th> <th>Manufacturer</th>
<th>Model</th> <th>Model</th>
@@ -30,9 +33,6 @@
{% for ct in column_types %} {% for ct in column_types %}
<th data-sort-off>{{ ct.name }}</th> <th data-sort-off>{{ ct.name }}</th>
{% endfor %} {% endfor %}
{% if can_write_scopes %}
<th data-sort-off></th>
{% endif %}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -40,6 +40,9 @@
{% set scope = row.scope %} {% set scope = row.scope %}
{% set info = row.info %} {% set info = row.info %}
<tr id="scope-row-{{ scope.id }}"> <tr id="scope-row-{{ scope.id }}">
{% if can_write_scopes %}
<td><input type="checkbox" name="scope_ids" value="{{ scope.id }}"></td>
{% endif %}
<td> <td>
<strong><a href="/admin/augmented-parts/{{ scope.id }}">{{ scope.description }}</a></strong> <strong><a href="/admin/augmented-parts/{{ scope.id }}">{{ scope.description }}</a></strong>
{% if scope.sku %}<br><span class="muted">{{ scope.sku }}</span>{% endif %} {% if scope.sku %}<br><span class="muted">{{ scope.sku }}</span>{% endif %}
@@ -70,15 +73,6 @@
{% endif %} {% endif %}
</td> </td>
{% endfor %} {% 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> </tr>
{% else %} {% else %}
<tr> <tr>
+44
View File
@@ -44,6 +44,50 @@
</form> </form>
</div> </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" %} {% include "_scopes_table_card.html" %}
</main> </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 %} {% 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. 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 fastapi.responses import HTMLResponse
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -119,25 +119,44 @@ async def scopes_filter(
) )
@router.delete("/scopes/{augmented_part_id}") @router.post("/scopes/bulk-delete", response_class=HTMLResponse)
async def delete_scope( async def bulk_delete_scopes(
augmented_part_id: int, 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), db: AsyncSession = Depends(get_db),
actor: User = Depends(web_require("augmented_parts:write")), actor: User = Depends(web_require("augmented_parts:write")),
): ):
ap = await augmented_part_crud.get(db, augmented_part_id) for augmented_part_id in scope_ids:
if ap is None: ap = await augmented_part_crud.get(db, augmented_part_id)
raise HTTPException(status_code=404, detail="Scope not found") if ap is None:
label = ap.sku or ap.description continue
await augmented_part_crud.delete(db, ap) label = ap.sku or ap.description
await audit_crud.log( await augmented_part_crud.delete(db, ap)
db, await audit_crud.log(
actor=actor, db,
action="delete", actor=actor,
resource_type="AugmentedPart", action="delete",
resource_id=augmented_part_id, resource_type="AugmentedPart",
description=audit_crud.describe_delete( resource_id=augmented_part_id,
"AugmentedPart", augmented_part_id, label 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)
+44 -9
View File
@@ -333,20 +333,21 @@ async def test_scopes_page_redirects_when_anonymous(client):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_scopes_page_hides_delete_for_read_only_user( async def test_scopes_page_hides_checkboxes_for_read_only_user(
client, seeded, make_user, db_sessionmaker client, seeded, make_user, db_sessionmaker
): ):
async with db_sessionmaker() as db: async with db_sessionmaker() as db:
ids = await _seed_guide_data(db) await _seed_guide_data(db)
await make_user("reader6@example.com", role_ids=[seeded["roles"]["user"].id]) await make_user("reader6@example.com", role_ids=[seeded["roles"]["user"].id])
await _web_login(client, "reader6@example.com") await _web_login(client, "reader6@example.com")
resp = await client.get("/scopes") resp = await client.get("/scopes")
assert resp.status_code == 200 assert resp.status_code == 200
assert f"/scopes/{ids['s1'].id}" not in resp.text assert 'name="scope_ids"' not in resp.text
assert "delete-selected-scopes-btn" not in resp.text
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_scopes_page_shows_delete_for_write_user( async def test_scopes_page_shows_checkboxes_for_write_user(
client, seeded, make_user, db_sessionmaker client, seeded, make_user, db_sessionmaker
): ):
async with db_sessionmaker() as db: async with db_sessionmaker() as db:
@@ -355,11 +356,13 @@ async def test_scopes_page_shows_delete_for_write_user(
await _web_login(client, "writer@example.com") await _web_login(client, "writer@example.com")
resp = await client.get("/scopes") resp = await client.get("/scopes")
assert resp.status_code == 200 assert resp.status_code == 200
assert f'hx-delete="/scopes/{ids["s1"].id}"' in resp.text assert f'name="scope_ids" value="{ids["s1"].id}"' in resp.text
assert "delete-selected-scopes-btn" in resp.text
assert 'hx-post="/scopes/bulk-delete"' in resp.text
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_delete_scope_removes_augmented_part( async def test_bulk_delete_scopes_removes_selected_augmented_parts(
client, seeded, make_user, db_sessionmaker client, seeded, make_user, db_sessionmaker
): ):
async with db_sessionmaker() as db: async with db_sessionmaker() as db:
@@ -367,17 +370,42 @@ async def test_delete_scope_removes_augmented_part(
await make_user("writer2@example.com", is_superuser=True) await make_user("writer2@example.com", is_superuser=True)
await _web_login(client, "writer2@example.com") await _web_login(client, "writer2@example.com")
resp = await client.delete(f"/scopes/{ids['s1'].id}") resp = await client.post(
"/scopes/bulk-delete",
data={"scope_ids": [str(ids["s1"].id), str(ids["s2"].id)]},
)
assert resp.status_code == 200 assert resp.status_code == 200
async with db_sessionmaker() as db: async with db_sessionmaker() as db:
from app.crud import augmented_part as augmented_part_crud from app.crud import augmented_part as augmented_part_crud
assert await augmented_part_crud.get(db, ids["s1"].id) is None assert await augmented_part_crud.get(db, ids["s1"].id) is None
assert await augmented_part_crud.get(db, ids["s2"].id) is None
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_delete_scope_forbidden_without_write_permission( async def test_bulk_delete_scopes_leaves_unselected_rows(
client, seeded, make_user, db_sessionmaker
):
async with db_sessionmaker() as db:
ids = await _seed_guide_data(db)
await make_user("writer3@example.com", is_superuser=True)
await _web_login(client, "writer3@example.com")
resp = await client.post(
"/scopes/bulk-delete", data={"scope_ids": [str(ids["s1"].id)]}
)
assert resp.status_code == 200
async with db_sessionmaker() as db:
from app.crud import augmented_part as augmented_part_crud
assert await augmented_part_crud.get(db, ids["s1"].id) is None
assert await augmented_part_crud.get(db, ids["s2"].id) is not None
@pytest.mark.asyncio
async def test_bulk_delete_scopes_forbidden_without_write_permission(
client, seeded, make_user, db_sessionmaker client, seeded, make_user, db_sessionmaker
): ):
async with db_sessionmaker() as db: async with db_sessionmaker() as db:
@@ -385,5 +413,12 @@ async def test_delete_scope_forbidden_without_write_permission(
await make_user("reader7@example.com", role_ids=[seeded["roles"]["user"].id]) await make_user("reader7@example.com", role_ids=[seeded["roles"]["user"].id])
await _web_login(client, "reader7@example.com") await _web_login(client, "reader7@example.com")
resp = await client.delete(f"/scopes/{ids['s1'].id}") resp = await client.post(
"/scopes/bulk-delete", data={"scope_ids": [str(ids["s1"].id)]}
)
assert resp.status_code == 403 assert resp.status_code == 403
async with db_sessionmaker() as db:
from app.crud import augmented_part as augmented_part_crud
assert await augmented_part_crud.get(db, ids["s1"].id) is not None