Adding delete option for scopes
This commit is contained in:
1 parent
f4771e33d4
commit
14aa2ac5d6
4 files changed
+118
-7
No files matched your search
@@ -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 \"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(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/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)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -30,13 +30,16 @@
|
|||||||
{% for ct in column_types %}
|
{% for ct in column_types %}
|
||||||
<th data-sort-off>{{ ct.name }}</th>
|
<th data-sort-off>{{ ct.name }}</th>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
{% if can_write_scopes %}
|
||||||
|
<th data-sort-off></th>
|
||||||
|
{% endif %}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for row in rows %}
|
{% for row in rows %}
|
||||||
{% set scope = row.scope %}
|
{% set scope = row.scope %}
|
||||||
{% set info = row.info %}
|
{% set info = row.info %}
|
||||||
<tr>
|
<tr id="scope-row-{{ scope.id }}">
|
||||||
<td>
|
<td>
|
||||||
<strong><a href="/admin/augmented-parts/{{ scope.id }}">{{ scope.description }}</a></strong>
|
<strong><a href="/admin/augmented-parts/{{ scope.id }}">{{ scope.description }}</a></strong>
|
||||||
{% if scope.sku %}<br><span class="muted">{{ scope.sku }}</span>{% endif %}
|
{% if scope.sku %}<br><span class="muted">{{ scope.sku }}</span>{% endif %}
|
||||||
@@ -67,10 +70,19 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
{% endfor %}
|
{% 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>
|
</tr>
|
||||||
{% else %}
|
{% else %}
|
||||||
<tr>
|
<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.
|
No scopes match your search.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
+44
-4
@@ -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.
|
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 fastapi.responses import HTMLResponse
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.database import get_db
|
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 manufacturer as manufacturer_crud
|
||||||
from app.crud import part_type as part_type_crud
|
from app.crud import part_type as part_type_crud
|
||||||
from app.crud import scope_model as scope_model_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
|
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:
|
async def _build(db: AsyncSession, filters: dict) -> dict:
|
||||||
"""Run the guide query for the given raw (string) filter values."""
|
"""Run the guide query for the given raw (string) filter values."""
|
||||||
return await scope_guide_service.build(
|
return await scope_guide_service.build(
|
||||||
@@ -77,7 +83,13 @@ async def scopes_page(
|
|||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
request,
|
request,
|
||||||
"scopes.html",
|
"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 = "",
|
sub_part_type_id: str = "",
|
||||||
fits_part_type_id: str = "",
|
fits_part_type_id: str = "",
|
||||||
db: AsyncSession = Depends(get_db),
|
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 = {
|
filters = {
|
||||||
"description": description,
|
"description": description,
|
||||||
@@ -100,4 +112,32 @@ async def scopes_filter(
|
|||||||
"fits_part_type_id": fits_part_type_id,
|
"fits_part_type_id": fits_part_type_id,
|
||||||
}
|
}
|
||||||
guide = await _build(db, filters)
|
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)
|
||||||
@@ -330,3 +330,60 @@ async def test_scopes_page_redirects_when_anonymous(client):
|
|||||||
assert resp.status_code == 302
|
assert resp.status_code == 302
|
||||||
# Deep link is preserved so the user lands back here after logging in.
|
# Deep link is preserved so the user lands back here after logging in.
|
||||||
assert resp.headers["location"] == "/login?next=%2Fscopes"
|
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
|
||||||
Reference in new issue
Block a user