Adding delete option for scopes

This commit is contained in:
Ian Roddis committed 2026-08-10 12:04:56 -03:00
1 parent f4771e33d4
commit 14aa2ac5d6
4 files changed
+118 -7

No files matched your search

+3 -1
View File
@@ -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)"
]
}
}
+14 -2
View File
@@ -30,13 +30,16 @@
{% for ct in column_types %}
<th data-sort-off>{{ ct.name }}</th>
{% endfor %}
{% if can_write_scopes %}
<th data-sort-off></th>
{% endif %}
</tr>
</thead>
<tbody>
{% for row in rows %}
{% set scope = row.scope %}
{% set info = row.info %}
<tr>
<tr id="scope-row-{{ scope.id }}">
<td>
<strong><a href="/admin/augmented-parts/{{ scope.id }}">{{ scope.description }}</a></strong>
{% if scope.sku %}<br><span class="muted">{{ scope.sku }}</span>{% endif %}
@@ -67,10 +70,19 @@
{% endif %}
</td>
{% 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>
{% else %}
<tr>
<td colspan="{{ 6 + column_types | length }}" class="muted">
<td colspan="{{ 6 + column_types | length + (1 if can_write_scopes else 0) }}" class="muted">
No scopes match your search.
</td>
</tr>
+44 -4
View File
@@ -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)
+57
View File
@@ -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