From 7a15d9f7c0b9caad1ddea6a3eba4151a96cecb83 Mon Sep 17 00:00:00 2001
From: Ian Roddis <31021769+iroddis@users.noreply.github.com>
Date: Mon, 10 Aug 2026 12:15:09 -0300
Subject: [PATCH] Adding a bulk delete option to the scopes page
---
app/static/css/style.css | 5 +++
app/templates/_scopes_table_card.html | 18 +++------
app/templates/scopes.html | 44 +++++++++++++++++++++
app/web/scopes.py | 57 ++++++++++++++++++---------
tests/test_scope_guide.py | 53 ++++++++++++++++++++-----
5 files changed, 137 insertions(+), 40 deletions(-)
diff --git a/app/static/css/style.css b/app/static/css/style.css
index c9a65b9..790b405 100644
--- a/app/static/css/style.css
+++ b/app/static/css/style.css
@@ -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;
diff --git a/app/templates/_scopes_table_card.html b/app/templates/_scopes_table_card.html
index eedf202..8c87345 100644
--- a/app/templates/_scopes_table_card.html
+++ b/app/templates/_scopes_table_card.html
@@ -21,6 +21,9 @@
+ {% if can_write_scopes %}
+ |
+ {% endif %}
Scope |
Manufacturer |
Model |
@@ -30,9 +33,6 @@
{% for ct in column_types %}
{{ ct.name }} |
{% endfor %}
- {% if can_write_scopes %}
- |
- {% endif %}
@@ -40,6 +40,9 @@
{% set scope = row.scope %}
{% set info = row.info %}
+ {% if can_write_scopes %}
+ |
+ {% endif %}
{{ scope.description }}
{% if scope.sku %} {{ scope.sku }}{% endif %}
@@ -70,15 +73,6 @@
{% endif %}
|
{% endfor %}
- {% if can_write_scopes %}
-
-
- |
- {% endif %}
{% else %}
diff --git a/app/templates/scopes.html b/app/templates/scopes.html
index 58ab65c..05609c4 100644
--- a/app/templates/scopes.html
+++ b/app/templates/scopes.html
@@ -44,6 +44,50 @@
+ {% if can_write_scopes %}
+
+
+
+ {% endif %}
+
{% include "_scopes_table_card.html" %}
+
+{% if can_write_scopes %}
+
+{% endif %}
{% endblock %}
diff --git a/app/web/scopes.py b/app/web/scopes.py
index 65ed7ac..ee76f73 100644
--- a/app/web/scopes.py
+++ b/app/web/scopes.py
@@ -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)
diff --git a/tests/test_scope_guide.py b/tests/test_scope_guide.py
index c58d8e8..7e28590 100644
--- a/tests/test_scope_guide.py
+++ b/tests/test_scope_guide.py
@@ -333,20 +333,21 @@ async def test_scopes_page_redirects_when_anonymous(client):
@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
):
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 _web_login(client, "reader6@example.com")
resp = await client.get("/scopes")
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
-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
):
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")
resp = await client.get("/scopes")
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
-async def test_delete_scope_removes_augmented_part(
+async def test_bulk_delete_scopes_removes_selected_augmented_parts(
client, seeded, make_user, db_sessionmaker
):
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 _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
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 None
@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
):
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 _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
+
+ 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