From a617b935d3de6ea5f01d6438f67b34d16282b222 Mon Sep 17 00:00:00 2001 From: Ian Roddis <31021769+iroddis@users.noreply.github.com> Date: Tue, 4 Aug 2026 09:02:38 -0300 Subject: [PATCH] Fixing formatting of magnification --- app/crud/attachment_point.py | 19 ++++++++++ app/services/scope_guide.py | 49 +++++++++++++++---------- app/templates/_scopes_table_card.html | 16 ++++++--- tests/test_scope_guide.py | 52 ++++++++++++++++++++++++++- 4 files changed, 111 insertions(+), 25 deletions(-) diff --git a/app/crud/attachment_point.py b/app/crud/attachment_point.py index f58594d..b582bd5 100644 --- a/app/crud/attachment_point.py +++ b/app/crud/attachment_point.py @@ -2,6 +2,7 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.models.attachment_point import AttachmentPoint +from app.models.attachment_point_name import AttachmentPointName _RELATIONSHIPS = [ "attachment_point_name", @@ -27,6 +28,24 @@ async def list_for_augmented_part( return list(result.scalars().all()) +async def list_by_augmented_parts_and_name( + db: AsyncSession, augmented_part_ids: list[int], name: str +) -> list[AttachmentPoint]: + """AttachmentPoints named ``name`` for the given augmented parts, fetched in + one query.""" + if not augmented_part_ids: + return [] + result = await db.execute( + select(AttachmentPoint) + .join(AttachmentPoint.attachment_point_name) + .where( + AttachmentPoint.augmented_part_id.in_(augmented_part_ids), + AttachmentPointName.name == name, + ) + ) + return list(result.scalars().all()) + + async def list_all( db: AsyncSession, skip: int = 0, limit: int = 100 ) -> list[AttachmentPoint]: diff --git a/app/services/scope_guide.py b/app/services/scope_guide.py index a3f7d87..e720423 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_diameter_mm": float | None, "cells": {part_type_id: [{"part": AugmentedPart, "label": str}]}, }, ..., @@ -50,12 +53,8 @@ async def build( } """ part_types = await part_type_crud.list_all(db, limit=1000) - scope_type = next( - (pt for pt in part_types if pt.name == SCOPE_TYPE_NAME), None - ) - column_types = [ - pt for pt in part_types if scope_type is None or pt.id != scope_type.id - ] + scope_type = next((pt for pt in part_types if pt.name == SCOPE_TYPE_NAME), None) + column_types = [pt for pt in part_types if scope_type is None or pt.id != scope_type.id] if scope_type is None: return {"column_types": column_types, "rows": []} @@ -73,9 +72,7 @@ async def build( if not scope_ids: return {"column_types": column_types, "rows": []} - fits = await fit_crud.list_for_parts( - db, list(scope_ids), exclude_label=FitLabel.NONE - ) + fits = await fit_crud.list_for_parts(db, list(scope_ids), exclude_label=FitLabel.NONE) # Resolve the "other" (non-scope) part on each fit in a single batched query. other_ids: set[int] = set() @@ -83,20 +80,31 @@ async def build( scope_id, other_id = _scope_and_other(fit, scope_ids) if scope_id is not None and other_id not in scope_ids: other_ids.add(other_id) - other_parts = { - p.id: p for p in await augmented_part_crud.get_many(db, list(other_ids)) - } + other_parts = {p.id: p for p in await augmented_part_crud.get_many(db, list(other_ids))} infos = { info.augmented_part_id: info for info in await scope_info_crud.list_by_augmented_parts(db, list(scope_ids)) } + # The lens outside diameter lives on the "Objective" attachment point's + # StaticConnection, not on ScopeInfo (which only carries the objective + # *thread* spec) — see app/services/scope_import.py's column-mapping doc. + objective_diameters: dict[int, float] = {} + objective_aps = await attachment_point_crud.list_by_augmented_parts_and_name( + db, list(scope_ids), OBJECTIVE_ATTACHMENT_POINT_NAME + ) + for ap in objective_aps: + if ap.static_connection is not None: + objective_diameters[ap.augmented_part_id] = ap.static_connection.od_mm + elif ap.iso_thread is not None: + objective_diameters[ap.augmented_part_id] = ap.iso_thread.diameter_mm + elif ap.unc_thread is not None: + objective_diameters[ap.augmented_part_id] = float(ap.unc_thread.diameter_fraction) + # cell_map[scope_id][type_id][other_part_id] -> {"part", "label"}, dedup'd so a # part appears once per cell, keeping its strongest fit label. - cell_map: dict[int, dict[int, dict[int, dict]]] = { - sid: {} for sid in scope_ids - } + cell_map: dict[int, dict[int, dict[int, dict]]] = {sid: {} for sid in scope_ids} for fit in fits: scope_id, other_id = _scope_and_other(fit, scope_ids) if scope_id is None: @@ -113,15 +121,18 @@ async def build( rows = [] for scope in scopes: cells = { - type_id: sorted( - entries.values(), key=lambda e: e["part"].description.lower() - ) + type_id: sorted(entries.values(), key=lambda e: e["part"].description.lower()) for type_id, entries in cell_map[scope.id].items() } if fits_part_type_id is not None and fits_part_type_id not in cells: continue # scope has no fit for the required accessory type rows.append( - {"scope": scope, "info": infos.get(scope.id), "cells": cells} + { + "scope": scope, + "info": infos.get(scope.id), + "objective_diameter_mm": objective_diameters.get(scope.id), + "cells": cells, + } ) return {"column_types": column_types, "rows": rows} diff --git a/app/templates/_scopes_table_card.html b/app/templates/_scopes_table_card.html index dc10170..8245507 100644 --- a/app/templates/_scopes_table_card.html +++ b/app/templates/_scopes_table_card.html @@ -1,11 +1,17 @@ {%- macro mag(v) -%}{{ v | int if v == v | int else v | round(1) }}{%- endmacro -%} -{%- macro magnification(info) -%} +{%- macro magnification_range(info) -%} {%- if info and info.magnification_min is not none and info.magnification_max is not none -%} - {{ mag(info.magnification_min) }}–{{ mag(info.magnification_max) }}× + {{ mag(info.magnification_min) }}-{{ mag(info.magnification_max) }} {%- elif info and info.magnification_min is not none -%} - {{ mag(info.magnification_min) }}× + {{ mag(info.magnification_min) }} {%- elif info and info.magnification_max is not none -%} - {{ mag(info.magnification_max) }}× + {{ mag(info.magnification_max) }} + {%- endif -%} +{%- endmacro -%} +{%- macro magnification(info, objective_diameter_mm) -%} + {%- set range = magnification_range(info) -%} + {%- if range -%} + {{ range }}x{% if objective_diameter_mm is not none %}{{ mag(objective_diameter_mm) }}{% endif %} {%- else -%} — {%- endif -%} @@ -37,7 +43,7 @@ {{ scope.manufacturer.name if scope.manufacturer else "—" }} {{ scope.scope_model.name if scope.scope_model else "—" }} {{ scope.sub_part_type.name if scope.sub_part_type else "—" }} - {{ magnification(info) }} + {{ magnification(info, row.objective_diameter_mm) }} {% if info and info.objective_thread_pitch %} {{ info.objective_thread_pitch }} diff --git a/tests/test_scope_guide.py b/tests/test_scope_guide.py index 88d8361..d9f43c4 100644 --- a/tests/test_scope_guide.py +++ b/tests/test_scope_guide.py @@ -1,9 +1,12 @@ 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.part_type import PartType from app.models.scope_info import ScopeInfo +from app.models.static_connection import StaticConnection from app.models.sub_part_type import SubPartType from app.services import scope_guide @@ -199,7 +202,54 @@ async def test_scopes_page_renders_rows_and_fits( assert "Flip cover 50mm" in body # a matching part in a fit column assert "fit-badge-exact" in body # the exact fit badge assert "M52x0.75" in body # scope info: objective thread - assert "3–18×" in body # magnification range, integral values un-padded + assert "3-18" in body # magnification range, integral values un-padded + + +@pytest.mark.asyncio +async def test_build_includes_objective_diameter(db_sessionmaker): + async with db_sessionmaker() as db: + ids = await _seed_guide_data(db) + apn = AttachmentPointName(name="Objective") + db.add(apn) + await db.flush() + db.add( + AttachmentPoint( + augmented_part_id=ids["s1"].id, + attachment_point_name_id=apn.id, + static_connection=StaticConnection(od_mm=50, od_in=1.9685), + ) + ) + await db.commit() + + guide = await scope_guide.build(db) + + rows = {r["scope"].description: r for r in guide["rows"]} + assert rows["Alpha 3-18x50"]["objective_diameter_mm"] == 50 + assert rows["Zeta 1-6x24"]["objective_diameter_mm"] is None + + +@pytest.mark.asyncio +async def test_scopes_page_renders_objective_diameter_in_magnification( + 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() + db.add( + AttachmentPoint( + augmented_part_id=ids["s1"].id, + attachment_point_name_id=apn.id, + static_connection=StaticConnection(od_mm=50, od_in=1.9685), + ) + ) + await db.commit() + await make_user("reader4@example.com", role_ids=[seeded["roles"]["user"].id]) + await _web_login(client, "reader4@example.com") + resp = await client.get("/scopes") + assert resp.status_code == 200 + assert "3-18x50" in resp.text @pytest.mark.asyncio