Adding a bulk delete option to the scopes page
This commit is contained in:
1 parent
a9c4c16d4f
commit
7a15d9f7c0
5 files changed
+137
-40
No files matched your search
@@ -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;
|
||||||
|
|||||||
@@ -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,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
@@ -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)
|
|
||||||
@@ -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
|
||||||
Reference in new issue
Block a user