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