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 %} @@ -30,9 +33,6 @@ {% for ct in column_types %} {% endfor %} - {% if can_write_scopes %} - - {% endif %} @@ -40,6 +40,9 @@ {% set scope = row.scope %} {% set info = row.info %} + {% if can_write_scopes %} + + {% 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
Scope Manufacturer Model{{ ct.name }}
{{ scope.description }} {% if scope.sku %}
{{ scope.sku }}{% endif %} @@ -70,15 +73,6 @@ {% endif %}
- -