From 14aa2ac5d64d930bd0a869627d46b9252b0a7a09 Mon Sep 17 00:00:00 2001 From: Ian Roddis <31021769+iroddis@users.noreply.github.com> Date: Mon, 10 Aug 2026 12:04:56 -0300 Subject: [PATCH] Adding delete option for scopes --- .claude/settings.local.json | 4 +- app/templates/_scopes_table_card.html | 16 +++++++- app/web/scopes.py | 48 ++++++++++++++++++++-- tests/test_scope_guide.py | 57 +++++++++++++++++++++++++++ 4 files changed, 118 insertions(+), 7 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 52a0ebc..bb99a88 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -37,7 +37,9 @@ "Bash(sqlite3 app.db \"SELECT COUNT\\(*\\) FROM augmented_parts WHERE scope_model_id=\\(SELECT id FROM scope_models WHERE name='Viper'\\);\")", "Bash(sqlite3 app.db \"PRAGMA wal_checkpoint\\(FULL\\);\")", "Bash(curl -s -o /dev/null -w \"%{http_code}\\\\n\" http://127.0.0.1:8000/login)", - "Bash(curl -s -o /dev/null -w \"%{http_code}\\\\n\" http://127.0.0.1:8000/admin/imported-parts)" + "Bash(curl -s -o /dev/null -w \"%{http_code}\\\\n\" http://127.0.0.1:8000/admin/imported-parts)", + "Bash(rg -n \"hx-delete|router.delete\" app/web/admin.py app/templates/admin/*.html)", + "Bash(rg -n \"is_superuser\" tests/conftest.py tests/*.py)" ] } } diff --git a/app/templates/_scopes_table_card.html b/app/templates/_scopes_table_card.html index a2d2cbc..eedf202 100644 --- a/app/templates/_scopes_table_card.html +++ b/app/templates/_scopes_table_card.html @@ -30,13 +30,16 @@ {% for ct in column_types %} {{ ct.name }} {% endfor %} + {% if can_write_scopes %} + + {% endif %} {% for row in rows %} {% set scope = row.scope %} {% set info = row.info %} - + {{ scope.description }} {% if scope.sku %}
{{ scope.sku }}{% endif %} @@ -67,10 +70,19 @@ {% endif %} {% endfor %} + {% if can_write_scopes %} + + + + {% endif %} {% else %} - + No scopes match your search. diff --git a/app/web/scopes.py b/app/web/scopes.py index e3607d6..65ed7ac 100644 --- a/app/web/scopes.py +++ b/app/web/scopes.py @@ -5,11 +5,13 @@ 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, Request +from fastapi import APIRouter, Depends, HTTPException, Request, Response, status from fastapi.responses import HTMLResponse from sqlalchemy.ext.asyncio import AsyncSession from app.core.database import get_db +from app.crud import audit as audit_crud +from app.crud import augmented_part as augmented_part_crud from app.crud import manufacturer as manufacturer_crud from app.crud import part_type as part_type_crud from app.crud import scope_model as scope_model_crud @@ -26,6 +28,10 @@ def _to_int(value: str) -> int | None: return int(value) if value.strip() else None +def _can_write_scopes(user: User) -> bool: + return user.is_superuser or "augmented_parts:write" in user.permissions + + async def _build(db: AsyncSession, filters: dict) -> dict: """Run the guide query for the given raw (string) filter values.""" return await scope_guide_service.build( @@ -77,7 +83,13 @@ async def scopes_page( return templates.TemplateResponse( request, "scopes.html", - {"user": user, "filters": filters, **guide, **(await _filter_options(db))}, + { + "user": user, + "filters": filters, + "can_write_scopes": _can_write_scopes(user), + **guide, + **(await _filter_options(db)), + }, ) @@ -90,7 +102,7 @@ async def scopes_filter( sub_part_type_id: str = "", fits_part_type_id: str = "", db: AsyncSession = Depends(get_db), - _: User = Depends(web_require("augmented_parts:read", "fits:read")), + user: User = Depends(web_require("augmented_parts:read", "fits:read")), ): filters = { "description": description, @@ -100,4 +112,32 @@ async def scopes_filter( "fits_part_type_id": fits_part_type_id, } guide = await _build(db, filters) - return templates.TemplateResponse(request, "_scopes_table_card.html", guide) + return templates.TemplateResponse( + request, + "_scopes_table_card.html", + {**guide, "can_write_scopes": _can_write_scopes(user)}, + ) + + +@router.delete("/scopes/{augmented_part_id}") +async def delete_scope( + augmented_part_id: int, + 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 + ), + ) + return Response(status_code=status.HTTP_200_OK) diff --git a/tests/test_scope_guide.py b/tests/test_scope_guide.py index 305f7b5..c58d8e8 100644 --- a/tests/test_scope_guide.py +++ b/tests/test_scope_guide.py @@ -330,3 +330,60 @@ async def test_scopes_page_redirects_when_anonymous(client): assert resp.status_code == 302 # Deep link is preserved so the user lands back here after logging in. assert resp.headers["location"] == "/login?next=%2Fscopes" + + +@pytest.mark.asyncio +async def test_scopes_page_hides_delete_for_read_only_user( + client, seeded, make_user, db_sessionmaker +): + async with db_sessionmaker() as db: + ids = 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 + + +@pytest.mark.asyncio +async def test_scopes_page_shows_delete_for_write_user( + client, seeded, make_user, db_sessionmaker +): + async with db_sessionmaker() as db: + ids = await _seed_guide_data(db) + await make_user("writer@example.com", is_superuser=True) + 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 + + +@pytest.mark.asyncio +async def test_delete_scope_removes_augmented_part( + client, seeded, make_user, db_sessionmaker +): + async with db_sessionmaker() as db: + ids = await _seed_guide_data(db) + 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}") + 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 + + +@pytest.mark.asyncio +async def test_delete_scope_forbidden_without_write_permission( + client, seeded, make_user, db_sessionmaker +): + async with db_sessionmaker() as db: + ids = await _seed_guide_data(db) + 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}") + assert resp.status_code == 403