diff --git a/app/services/scope_guide.py b/app/services/scope_guide.py index 893b2bd..3df7864 100644 --- a/app/services/scope_guide.py +++ b/app/services/scope_guide.py @@ -7,6 +7,7 @@ the parts of that type that fit the row's scope. from sqlalchemy.ext.asyncio import AsyncSession +from app.crud import attachment_point as attachment_point_crud from app.crud import augmented_part as augmented_part_crud from app.crud import fit as fit_crud from app.crud import part_type as part_type_crud @@ -14,6 +15,7 @@ from app.crud import scope_info as scope_info_crud from app.models.fit import FitLabel SCOPE_TYPE_NAME = "Scope" +OBJECTIVE_ATTACHMENT_POINT_NAME = "Objective" # "exact" outranks "possible" when the same part fits a scope via several fits. _LABEL_RANK = {FitLabel.EXACT.value: 2, FitLabel.POSSIBLE.value: 1} @@ -43,6 +45,7 @@ async def build( { "scope": AugmentedPart, "info": ScopeInfo | None, + "objective_attachment_point": AttachmentPoint | None, "cells": {part_type_id: [{"part": AugmentedPart, "label": str}]}, }, ..., @@ -85,6 +88,13 @@ async def build( p.id: p for p in await augmented_part_crud.get_many(db, list(other_ids)) } + objective_aps = { + ap.augmented_part_id: ap + for ap in await attachment_point_crud.list_by_augmented_parts_and_name( + db, list(scope_ids), OBJECTIVE_ATTACHMENT_POINT_NAME + ) + } + infos = { info.augmented_part_id: info for info in await scope_info_crud.list_by_augmented_parts(db, list(scope_ids)) @@ -120,6 +130,7 @@ async def build( { "scope": scope, "info": infos.get(scope.id), + "objective_attachment_point": objective_aps.get(scope.id), "cells": cells, } ) diff --git a/app/templates/_connection_macros.html b/app/templates/_connection_macros.html new file mode 100644 index 0000000..6c98cdf --- /dev/null +++ b/app/templates/_connection_macros.html @@ -0,0 +1,17 @@ +{%- macro connection_spec(ap) -%} + {%- if ap.iso_thread -%} + M{{ ap.iso_thread.diameter_mm }}×{{ ap.iso_thread.pitch_mm }} + {%- elif ap.unc_thread -%} + {{ ap.unc_thread.diameter_fraction }}-{{ ap.unc_thread.tpi }} {{ ap.unc_thread.series.value }} + {%- elif ap.clamp_connection -%} + {%- if ap.clamp_connection.min_mm is not none -%} + {{ ap.clamp_connection.min_mm }}–{{ ap.clamp_connection.max_mm }} mm + {%- else -%} + {{ ap.clamp_connection.min_in }}–{{ ap.clamp_connection.max_in }} in + {%- endif -%} + {%- elif ap.static_connection -%} + {{ ap.static_connection.od_mm }} mm OD + {%- else -%} + — + {%- endif -%} +{%- endmacro -%} diff --git a/app/templates/_scopes_table_card.html b/app/templates/_scopes_table_card.html index 5f97cdc..a2d2cbc 100644 --- a/app/templates/_scopes_table_card.html +++ b/app/templates/_scopes_table_card.html @@ -1,3 +1,4 @@ +{% from "_connection_macros.html" import connection_spec %} {%- macro mag(v) -%}{{ v | int if v == v | int else v | round(1) }}{%- endmacro -%} {%- macro magnification_range(info) -%} {%- if info and info.magnification_min is not none and info.magnification_max is not none -%} @@ -45,7 +46,9 @@ {{ scope.sub_part_type.name if scope.sub_part_type else "—" }} {{ magnification(info) }} - {% if info and info.objective_thread_pitch %} + {% if row.objective_attachment_point %} + {{ connection_spec(row.objective_attachment_point) }} + {% elif info and info.objective_thread_pitch %} {{ info.objective_thread_pitch }} {% else %}—{% endif %} diff --git a/tests/test_scope_guide.py b/tests/test_scope_guide.py index 8dc6401..e073c6b 100644 --- a/tests/test_scope_guide.py +++ b/tests/test_scope_guide.py @@ -1,7 +1,10 @@ import pytest +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.iso_thread import IsoThread from app.models.part_type import PartType from app.models.scope_info import ScopeInfo from app.models.sub_part_type import SubPartType @@ -214,6 +217,68 @@ async def test_build_includes_objective_lens_diameter(db_sessionmaker): assert rows["Zeta 1-6x24"]["info"] is None +@pytest.mark.asyncio +async def test_build_prefers_objective_attachment_point_over_scope_info( + db_sessionmaker, +): + async with db_sessionmaker() as db: + ids = await _seed_guide_data(db) + apn = AttachmentPointName(name="Objective") + db.add(apn) + await db.flush() + iso = IsoThread(diameter_mm=44, pitch_mm=0.5) + db.add(iso) + await db.flush() + db.add( + AttachmentPoint( + augmented_part_id=ids["s1"].id, + attachment_point_name_id=apn.id, + iso_thread_id=iso.id, + ) + ) + await db.commit() + + guide = await scope_guide.build(db) + + rows = {r["scope"].description: r for r in guide["rows"]} + s1_ap = rows["Alpha 3-18x50"]["objective_attachment_point"] + assert s1_ap is not None + assert s1_ap.iso_thread.diameter_mm == 44 + # No "Objective"-named attachment point for s2 -> falls back to None. + assert rows["Zeta 1-6x24"]["objective_attachment_point"] is None + + +@pytest.mark.asyncio +async def test_scopes_page_renders_objective_attachment_point_thread( + client, seeded, make_user, db_sessionmaker +): + async with db_sessionmaker() as db: + ids = await _seed_guide_data(db) + apn = AttachmentPointName(name="Objective") + db.add(apn) + await db.flush() + iso = IsoThread(diameter_mm=44, pitch_mm=0.5) + db.add(iso) + await db.flush() + db.add( + AttachmentPoint( + augmented_part_id=ids["s1"].id, + attachment_point_name_id=apn.id, + iso_thread_id=iso.id, + ) + ) + await db.commit() + + await make_user("reader5@example.com", role_ids=[seeded["roles"]["user"].id]) + await _web_login(client, "reader5@example.com") + resp = await client.get("/scopes") + assert resp.status_code == 200 + body = resp.text + # The attachment point's thread spec wins over ScopeInfo.objective_thread_pitch. + assert "M44×0.5" in body + assert "M52x0.75" not in body + + @pytest.mark.asyncio async def test_scopes_page_renders_objective_lens_diameter_in_magnification( client, seeded, make_user, db_sessionmaker