From 1b52ad04bc9f082b9f9d0c340d335b718871b696 Mon Sep 17 00:00:00 2001 From: Ian Roddis <31021769+iroddis@users.noreply.github.com> Date: Mon, 27 Jul 2026 15:11:58 -0300 Subject: [PATCH] Adding part details page --- app/templates/_scopes_table_card.html | 4 +- .../admin/_augmented_part_fit_row.html | 4 + app/templates/admin/_augmented_part_fits.html | 2 + app/templates/admin/_augmented_part_row.html | 2 +- .../admin/augmented_part_detail.html | 85 ++++++++++ app/web/admin.py | 65 +++++++- tests/test_augmented_part_detail.py | 152 ++++++++++++++++++ 7 files changed, 305 insertions(+), 9 deletions(-) create mode 100644 app/templates/admin/augmented_part_detail.html create mode 100644 tests/test_augmented_part_detail.py 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 %}
{{ e.label | capitalize }} - {{ e.part.description | truncate(48) }} + {{ e.part.description | truncate(48) }}
{% 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" %}
+ {% 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 %}
@@ -46,4 +47,5 @@
+ {% endif %}
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