diff --git a/app/templates/_scopes_table_card.html b/app/templates/_scopes_table_card.html
index fb866d1..8b43b32 100644
--- a/app/templates/_scopes_table_card.html
+++ b/app/templates/_scopes_table_card.html
@@ -31,7 +31,7 @@
{% set info = row.info %}
- {{ scope.description }}
+ {{ scope.description }}
{% if scope.sku %}{{ scope.sku }} {% endif %}
{{ scope.manufacturer.name if scope.manufacturer else "—" }}
@@ -50,7 +50,7 @@
{% for e in entries %}
{% endfor %}
{% else %}
diff --git a/app/templates/admin/_augmented_part_fit_row.html b/app/templates/admin/_augmented_part_fit_row.html
index 15f014f..9b6d53f 100644
--- a/app/templates/admin/_augmented_part_fit_row.html
+++ b/app/templates/admin/_augmented_part_fit_row.html
@@ -19,6 +19,7 @@
↔
{{ other_part.description if other_part else "Unknown part" }} — {{ other_ap.attachment_point_name.name }} ({{ conn_desc(other_ap) }})
+ {% if can_write %}
{% if fit.label.value != "exact" %}
Delete
+ {% endif %}
{% for note in fit.notes %}
@@ -52,11 +54,13 @@
{{ note.body }}
{% endfor %}
+ {% if can_write %}
+ {% endif %}
diff --git a/app/templates/admin/_augmented_part_fits.html b/app/templates/admin/_augmented_part_fits.html
index cf24b45..7269f7b 100644
--- a/app/templates/admin/_augmented_part_fits.html
+++ b/app/templates/admin/_augmented_part_fits.html
@@ -12,6 +12,7 @@
No fits recorded yet.
{% endfor %}
+ {% if can_write %}
diff --git a/app/templates/admin/_augmented_part_row.html b/app/templates/admin/_augmented_part_row.html
index 67f8699..fa505ea 100644
--- a/app/templates/admin/_augmented_part_row.html
+++ b/app/templates/admin/_augmented_part_row.html
@@ -1,7 +1,7 @@
{{ ap.id }}
{{ ap.sku or '—' }}
- {{ ap.description | truncate(60) }}
+ {{ ap.description | truncate(60) }}
{{ ap.colour.name }}
{{ ap.manufacturer.name }}
{{ ap.scope_model.name if ap.scope_model else '—' }}
diff --git a/app/templates/admin/augmented_part_detail.html b/app/templates/admin/augmented_part_detail.html
new file mode 100644
index 0000000..61cda6f
--- /dev/null
+++ b/app/templates/admin/augmented_part_detail.html
@@ -0,0 +1,85 @@
+{% extends "base.html" %}
+{% block title %}{{ ap.description | truncate(40) }} · {{ app_name }}{% endblock %}
+{% block body %}
+{% include "_nav.html" %}
+
+{%- macro conn_desc(point) -%}
+ {%- if point.iso_thread -%}
+ ISO M{{ point.iso_thread.diameter_mm }}×{{ point.iso_thread.pitch_mm }}
+ {%- elif point.unc_thread -%}
+ UNC {{ point.unc_thread.diameter_fraction }}-{{ point.unc_thread.tpi }} {{ point.unc_thread.series.value }}
+ {%- elif point.clamp_connection -%}
+ Clamp {{ point.clamp_connection.min_mm if point.clamp_connection.min_mm is not none else point.clamp_connection.min_in }}–{{ point.clamp_connection.max_mm if point.clamp_connection.max_mm is not none else point.clamp_connection.max_in }}
+ {%- elif point.static_connection -%}
+ Static {{ point.static_connection.od_mm }} mm OD
+ {%- else -%}
+ —
+ {%- endif -%}
+{%- endmacro -%}
+
+
+ ← Augmented Parts
+
+ {{ ap.description }}
+
+ {{ ap.part_type.name }}
+ {% if ap.sub_part_type %}{{ ap.sub_part_type.name }} {% endif %}
+ {% if ap.sku %}· SKU {{ ap.sku }}{% endif %}
+
+
+
+
+
Details
+
+ SKU {{ ap.sku or "—" }}
+ Description {{ ap.description }}
+ Colour {{ ap.colour.name if ap.colour else "—" }}
+ Manufacturer {{ ap.manufacturer.name if ap.manufacturer else "—" }}
+ Type {{ ap.part_type.name }}
+ Sub type {{ ap.sub_part_type.name if ap.sub_part_type else "—" }}
+ Scope model
+
+ {% if ap.scope_model %}
+ {{ ap.scope_model.name }}
+ {% if ap.scope_model.url %} · link {% endif %}
+ {% else %}—{% endif %}
+
+
+
+
+ {% if scope_info %}
+
+
Scope information
+
+ Magnification
+
+ {% if scope_info.magnification_min is not none or scope_info.magnification_max is not none %}
+ {{ scope_info.magnification_min if scope_info.magnification_min is not none else "?" }}–{{ scope_info.magnification_max if scope_info.magnification_max is not none else "?" }}×
+ {% else %}—{% endif %}
+
+ Model suffix {{ scope_info.model_suffix or "—" }}
+ Objective thread {{ scope_info.objective_thread_pitch or "—" }}
+
+
+ {% endif %}
+
+
+
Attachment points
+ {% for point in attachment_points %}
+
+ {{ point.attachment_point_name.name }}
+ {{ conn_desc(point) }}
+
+ {% else %}
+
No attachment points recorded.
+ {% endfor %}
+
+
+
+ {% if can_read_fits %}
+
+ {% include "admin/_augmented_part_fits.html" %}
+
+ {% endif %}
+
+{% endblock %}
diff --git a/app/web/admin.py b/app/web/admin.py
index f636cc9..cec78ce 100644
--- a/app/web/admin.py
+++ b/app/web/admin.py
@@ -26,6 +26,7 @@ from app.crud import manufacturer as manufacturer_crud
from app.crud import part_colour as part_colour_crud
from app.crud import part_type as part_type_crud
from app.crud import role as role_crud
+from app.crud import scope_info as scope_info_crud
from app.crud import scope_model as scope_model_crud
from app.crud import attachment_point as attachment_point_crud
from app.crud import attachment_point_name as apn_crud
@@ -1780,6 +1781,37 @@ async def filter_skipped_parts(
)
+@router.get("/augmented-parts/{augmented_part_id}", response_class=HTMLResponse)
+async def augmented_part_detail(
+ request: Request,
+ augmented_part_id: int,
+ db: AsyncSession = Depends(get_db),
+ user: User = Depends(web_require("augmented_parts:read")),
+):
+ ap = await augmented_part_crud.get(db, augmented_part_id)
+ if ap is None:
+ raise HTTPException(status_code=404, detail="Augmented part not found")
+ can_read_fits = user.is_superuser or "fits:read" in user.permissions
+ ctx = {
+ "user": user,
+ "ap": ap,
+ "scope_info": await scope_info_crud.get_by_augmented_part(
+ db, augmented_part_id
+ ),
+ "attachment_points": await attachment_point_crud.list_for_augmented_part(
+ db, augmented_part_id
+ ),
+ "can_read_fits": can_read_fits,
+ }
+ if can_read_fits:
+ ctx.update(
+ await _augmented_part_fits_ctx(
+ db, augmented_part_id, can_write=_can_write_fits(user)
+ )
+ )
+ return templates.TemplateResponse(request, "admin/augmented_part_detail.html", ctx)
+
+
@router.get("/augmented-parts/{augmented_part_id}/edit", response_class=HTMLResponse)
async def edit_augmented_part_row(
request: Request,
@@ -2102,10 +2134,15 @@ async def _fit_view(db: AsyncSession, fit, part_id: int) -> dict:
}
+def _can_write_fits(user: User) -> bool:
+ return user.is_superuser or "fits:write" in user.permissions
+
+
async def _augmented_part_fits_ctx(
db: AsyncSession,
augmented_part_id: int,
*,
+ can_write: bool = True,
message: str | None = None,
error: str | None = None,
) -> dict:
@@ -2122,6 +2159,7 @@ async def _augmented_part_fits_ctx(
db, augmented_part_id
),
"other_parts": other_parts,
+ "can_write": can_write,
"message": message,
"error": error,
}
@@ -2132,9 +2170,11 @@ async def augmented_part_fits(
request: Request,
augmented_part_id: int,
db: AsyncSession = Depends(get_db),
- _: User = Depends(web_require("fits:read")),
+ user: User = Depends(web_require("fits:read")),
):
- ctx = await _augmented_part_fits_ctx(db, augmented_part_id)
+ ctx = await _augmented_part_fits_ctx(
+ db, augmented_part_id, can_write=_can_write_fits(user)
+ )
return templates.TemplateResponse(request, "admin/_augmented_part_fits.html", ctx)
@@ -2162,6 +2202,7 @@ async def recompute_augmented_part_fits(
ctx = await _augmented_part_fits_ctx(
db,
augmented_part_id,
+ can_write=_can_write_fits(actor),
message=f"{result.created} created, {result.removed} removed.",
)
return templates.TemplateResponse(request, "admin/_augmented_part_fits.html", ctx)
@@ -2223,7 +2264,9 @@ async def create_augmented_part_fit(
"Fit", fit.id, f"attachment points {ap_a.id}↔{ap_b.id}"
),
)
- ctx = await _augmented_part_fits_ctx(db, augmented_part_id, error=error)
+ ctx = await _augmented_part_fits_ctx(
+ db, augmented_part_id, can_write=_can_write_fits(actor), error=error
+ )
return templates.TemplateResponse(request, "admin/_augmented_part_fits.html", ctx)
@@ -2255,7 +2298,11 @@ async def update_augmented_part_fit_label(
return templates.TemplateResponse(
request,
"admin/_augmented_part_fit_row.html",
- {"augmented_part_id": augmented_part_id, **view},
+ {
+ "augmented_part_id": augmented_part_id,
+ "can_write": _can_write_fits(actor),
+ **view,
+ },
)
@@ -2281,7 +2328,11 @@ async def create_augmented_part_fit_note(
return templates.TemplateResponse(
request,
"admin/_augmented_part_fit_row.html",
- {"augmented_part_id": augmented_part_id, **view},
+ {
+ "augmented_part_id": augmented_part_id,
+ "can_write": _can_write_fits(actor),
+ **view,
+ },
)
@@ -2309,7 +2360,9 @@ async def delete_augmented_part_fit(
resource_id=fit_id,
description=audit_crud.describe_delete("Fit", fit_id, label),
)
- ctx = await _augmented_part_fits_ctx(db, augmented_part_id)
+ ctx = await _augmented_part_fits_ctx(
+ db, augmented_part_id, can_write=_can_write_fits(actor)
+ )
return templates.TemplateResponse(request, "admin/_augmented_part_fits.html", ctx)
diff --git a/tests/test_augmented_part_detail.py b/tests/test_augmented_part_detail.py
new file mode 100644
index 0000000..d6def94
--- /dev/null
+++ b/tests/test_augmented_part_detail.py
@@ -0,0 +1,152 @@
+import pytest
+
+from app.crud import role as role_crud
+from app.models.attachment_point import AttachmentPoint
+from app.models.attachment_point_name import AttachmentPointName
+from app.models.augmented_part import AugmentedPart
+from app.models.fit import Fit, FitLabel
+from app.models.part_type import PartType
+from app.models.scope_info import ScopeInfo
+
+
+async def _seed_part_with_fit(db):
+ apn = AttachmentPointName(name="Objective")
+ scope_t = PartType(name="Scope")
+ cover_t = PartType(name="Flip Cover")
+ db.add_all([apn, scope_t, cover_t])
+ await db.flush()
+
+ scope = AugmentedPart(
+ description="Alpha 3-18x50",
+ sku="ALP-318",
+ colour_id=1,
+ manufacturer_id=1,
+ part_type_id=scope_t.id,
+ )
+ cover = AugmentedPart(
+ description="Flip cover 50mm",
+ colour_id=1,
+ manufacturer_id=1,
+ part_type_id=cover_t.id,
+ )
+ db.add_all([scope, cover])
+ await db.flush()
+
+ db.add(
+ ScopeInfo(
+ augmented_part_id=scope.id,
+ magnification_min=3,
+ magnification_max=18,
+ objective_thread_pitch="M52x0.75",
+ )
+ )
+ ap_s = AttachmentPoint(
+ augmented_part_id=scope.id, attachment_point_name_id=apn.id
+ )
+ ap_c = AttachmentPoint(
+ augmented_part_id=cover.id, attachment_point_name_id=apn.id
+ )
+ db.add_all([ap_s, ap_c])
+ await db.flush()
+
+ db.add(
+ Fit(
+ attachment_point_a_id=ap_s.id,
+ attachment_point_b_id=ap_c.id,
+ augmented_part_a_id=scope.id,
+ augmented_part_b_id=cover.id,
+ label=FitLabel.POSSIBLE,
+ )
+ )
+ await db.commit()
+ return {"scope_id": scope.id, "cover_id": cover.id}
+
+
+async def _web_login(client, email, password="secret123"):
+ resp = await client.post("/login", data={"email": email, "password": password})
+ assert resp.status_code == 303
+
+
+@pytest.mark.asyncio
+async def test_detail_shows_attributes_and_fits_readonly(
+ client, seeded, make_user, db_sessionmaker
+):
+ async with db_sessionmaker() as db:
+ ids = await _seed_part_with_fit(db)
+ # The default "user" role has augmented_parts:read + fits:read, NOT fits:write.
+ await make_user("reader@example.com", role_ids=[seeded["roles"]["user"].id])
+ await _web_login(client, "reader@example.com")
+
+ resp = await client.get(f"/admin/augmented-parts/{ids['scope_id']}")
+ assert resp.status_code == 200
+ body = resp.text
+ # Attributes
+ assert "Alpha 3-18x50" in body
+ assert "ALP-318" in body
+ assert "M52x0.75" in body # scope info
+ assert "Objective" in body # attachment point
+ # Fits are shown (the other part in the fit)
+ assert "Flip cover 50mm" in body
+ # ...but write controls are hidden for a read-only user
+ assert "Add fit" not in body
+ assert "Mark Exact" not in body
+ assert "Recompute possible fits" not in body
+
+
+@pytest.mark.asyncio
+async def test_detail_write_controls_for_fit_editor(
+ client, seeded, make_user, db_sessionmaker
+):
+ async with db_sessionmaker() as db:
+ ids = await _seed_part_with_fit(db)
+ perms = seeded["perms"]
+ editor = await role_crud.create(
+ db,
+ name="fit-editor",
+ permission_ids=[
+ perms["augmented_parts:read"].id,
+ perms["fits:read"].id,
+ perms["fits:write"].id,
+ ],
+ )
+ editor_id = editor.id
+ await make_user("editor@example.com", role_ids=[editor_id])
+ await _web_login(client, "editor@example.com")
+
+ resp = await client.get(f"/admin/augmented-parts/{ids['scope_id']}")
+ assert resp.status_code == 200
+ body = resp.text
+ assert "Add fit" in body
+ assert "Mark Exact" in body
+ assert "Recompute possible fits" in body
+
+
+@pytest.mark.asyncio
+async def test_detail_404_for_missing_part(client, seeded, make_user):
+ await make_user("reader2@example.com", role_ids=[seeded["roles"]["user"].id])
+ await _web_login(client, "reader2@example.com")
+ resp = await client.get("/admin/augmented-parts/99999")
+ assert resp.status_code == 404
+
+
+@pytest.mark.asyncio
+async def test_detail_requires_read_permission(client, seeded, make_user, db_sessionmaker):
+ async with db_sessionmaker() as db:
+ ids = await _seed_part_with_fit(db)
+ await make_user("plain@example.com") # no roles, not superuser
+ await _web_login(client, "plain@example.com")
+ resp = await client.get(f"/admin/augmented-parts/{ids['scope_id']}")
+ assert resp.status_code == 403
+
+
+@pytest.mark.asyncio
+async def test_augmented_parts_list_links_to_detail(
+ client, seeded, make_user, db_sessionmaker
+):
+ async with db_sessionmaker() as db:
+ ids = await _seed_part_with_fit(db)
+ await make_user("reader3@example.com", role_ids=[seeded["roles"]["user"].id])
+ await _web_login(client, "reader3@example.com")
+ resp = await client.get("/admin/augmented-parts")
+ assert resp.status_code == 200
+ assert f'href="/admin/augmented-parts/{ids["scope_id"]}"' in resp.text