From 12e6984c63d6cb251fdc8a85f5089eb8c0b0fe35 Mon Sep 17 00:00:00 2001 From: Ian Roddis <31021769+iroddis@users.noreply.github.com> Date: Tue, 4 Aug 2026 16:48:00 -0300 Subject: [PATCH] Making objective_lens a thing --- CLAUDE.md | 2 +- .../versions/1b99be8a9b1e_add_fits_tables.py | 15 ++++-- app/api/fits.py | 4 +- app/core/database.py | 1 + app/crud/role.py | 4 +- app/crud/scope_info.py | 6 +-- app/models/scope_info.py | 5 ++ app/schemas/scope_info.py | 1 + app/services/fit_matching.py | 8 ++-- app/services/scope_guide.py | 35 +++++--------- app/services/scope_import.py | 28 ++++------- app/templates/_scopes_table_card.html | 6 +-- .../admin/augmented_part_detail.html | 6 +++ tests/test_fit_matching.py | 42 +++++++++++++---- tests/test_ldap_login.py | 4 +- tests/test_scope_guide.py | 47 +++++-------------- tests/test_scope_import.py | 20 ++++---- 17 files changed, 115 insertions(+), 119 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index aa36866..3abb87a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -87,7 +87,7 @@ The business domain, independent of the auth/RBAC skeleton: - **`app/services/scope_import.py`** is a separate, manually-triggered bulk importer: reads an uploaded `.xlsx` of third-party scopes, upserting by `product_number` (→ `AugmentedPart.sku`) and creating `ScopeModel`, - `ScopeInfo`, up to three `AttachmentPoint`s, and up to two `Fit`s per row + `ScopeInfo`, up to two `AttachmentPoint`s, and up to two `Fit`s per row (see the module docstring for the full column mapping). - **`app/services/scope_guide.py`** assembles the `/scopes` page: one row per Scope-type `AugmentedPart`, one column per other `PartType`, each cell diff --git a/alembic/versions/1b99be8a9b1e_add_fits_tables.py b/alembic/versions/1b99be8a9b1e_add_fits_tables.py index 6005c88..b65e432 100644 --- a/alembic/versions/1b99be8a9b1e_add_fits_tables.py +++ b/alembic/versions/1b99be8a9b1e_add_fits_tables.py @@ -31,10 +31,16 @@ def upgrade() -> None: nullable=False, ), sa.Column( - "created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False + "created_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, ), sa.Column( - "updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, ), sa.ForeignKeyConstraint( ["attachment_point_a_id"], ["attachment_points.id"], ondelete="CASCADE" @@ -59,7 +65,10 @@ def upgrade() -> None: sa.Column("username", sa.String(length=255), nullable=False), sa.Column("body", sa.Text(), nullable=False), sa.Column( - "created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False + "created_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, ), sa.ForeignKeyConstraint(["fit_id"], ["fits.id"], ondelete="CASCADE"), sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="SET NULL"), diff --git a/app/api/fits.py b/app/api/fits.py index 545f19f..6d30885 100644 --- a/app/api/fits.py +++ b/app/api/fits.py @@ -67,9 +67,7 @@ async def create_fit( if ap_a is None or ap_b is None: raise HTTPException(status_code=404, detail="Attachment point not found") if ap_a.augmented_part_id == ap_b.augmented_part_id: - raise HTTPException( - status_code=422, detail="a part cannot fit itself" - ) + raise HTTPException(status_code=422, detail="a part cannot fit itself") existing = await fit_crud.get_by_attachment_points(db, ap_a, ap_b) if existing is not None: raise HTTPException(status_code=409, detail="Fit already exists") diff --git a/app/core/database.py b/app/core/database.py index 95397fd..5b86be1 100644 --- a/app/core/database.py +++ b/app/core/database.py @@ -35,6 +35,7 @@ if engine.sync_engine.dialect.name == "sqlite": cursor.execute("PRAGMA busy_timeout=5000") cursor.close() + AsyncSessionLocal = async_sessionmaker( engine, class_=AsyncSession, diff --git a/app/crud/role.py b/app/crud/role.py index e07fc51..258f013 100644 --- a/app/crud/role.py +++ b/app/crud/role.py @@ -23,9 +23,7 @@ async def get_default(db: AsyncSession) -> Role | None: async def set_default(db: AsyncSession, role: Role) -> Role: """Make ``role`` the sole default, clearing the flag on every other role.""" - await db.execute( - sa_update(Role).where(Role.id != role.id).values(is_default=False) - ) + await db.execute(sa_update(Role).where(Role.id != role.id).values(is_default=False)) role.is_default = True await db.commit() await db.refresh(role) diff --git a/app/crud/scope_info.py b/app/crud/scope_info.py index 54cd901..59b8112 100644 --- a/app/crud/scope_info.py +++ b/app/crud/scope_info.py @@ -24,9 +24,7 @@ async def list_by_augmented_parts( if not augmented_part_ids: return [] result = await db.execute( - select(ScopeInfo).where( - ScopeInfo.augmented_part_id.in_(augmented_part_ids) - ) + select(ScopeInfo).where(ScopeInfo.augmented_part_id.in_(augmented_part_ids)) ) return list(result.scalars().all()) @@ -38,6 +36,7 @@ async def create( magnification_min: float | None = None, magnification_max: float | None = None, model_suffix: str | None = None, + objective_lens_diameter_mm: float | None = None, objective_thread_pitch: str | None = None, ) -> ScopeInfo: info = ScopeInfo( @@ -45,6 +44,7 @@ async def create( magnification_min=magnification_min, magnification_max=magnification_max, model_suffix=model_suffix, + objective_lens_diameter_mm=objective_lens_diameter_mm, objective_thread_pitch=objective_thread_pitch, ) db.add(info) diff --git a/app/models/scope_info.py b/app/models/scope_info.py index abc9d29..7167766 100644 --- a/app/models/scope_info.py +++ b/app/models/scope_info.py @@ -27,6 +27,11 @@ class ScopeInfo(Base): model_suffix: Mapped[str | None] = mapped_column( String, nullable=True, default=None ) + # The lens diameter is a descriptive attribute (the "50" in "3-18x50"), not a + # connection dimension — it has no AttachmentPoint. + objective_lens_diameter_mm: Mapped[float | None] = mapped_column( + Float, nullable=True, default=None + ) # Kept as the raw string from the sheet (e.g. "0.75", "1/16") so no precision # or notation is lost; the parsed thread lives on its own AttachmentPoint. objective_thread_pitch: Mapped[str | None] = mapped_column( diff --git a/app/schemas/scope_info.py b/app/schemas/scope_info.py index 3b6cfa2..ffa359f 100644 --- a/app/schemas/scope_info.py +++ b/app/schemas/scope_info.py @@ -5,6 +5,7 @@ class ScopeInfoBase(BaseModel): magnification_min: float | None = None magnification_max: float | None = None model_suffix: str | None = None + objective_lens_diameter_mm: float | None = None objective_thread_pitch: str | None = None diff --git a/app/services/fit_matching.py b/app/services/fit_matching.py index 7d56775..da79916 100644 --- a/app/services/fit_matching.py +++ b/app/services/fit_matching.py @@ -142,9 +142,7 @@ async def _reconcile( for own_ap in own_points: for other_ap in points_by_part.get(other_id, []): if attachment_points_match(own_ap, other_ap): - candidate_pairs.add( - _canonical_pair(own_ap.id, other_ap.id) - ) + candidate_pairs.add(_canonical_pair(own_ap.id, other_ap.id)) result = RecomputeResult() @@ -171,7 +169,9 @@ async def _reconcile( return result -async def recompute_for_part(db: AsyncSession, augmented_part_id: int) -> RecomputeResult: +async def recompute_for_part( + db: AsyncSession, augmented_part_id: int +) -> RecomputeResult: """Reconcile Possible fits involving this part against the current algorithm. Creates Possible fits for newly-matching attachment point pairs, and removes diff --git a/app/services/scope_guide.py b/app/services/scope_guide.py index e720423..893b2bd 100644 --- a/app/services/scope_guide.py +++ b/app/services/scope_guide.py @@ -7,7 +7,6 @@ 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 @@ -15,7 +14,6 @@ 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} @@ -45,7 +43,6 @@ async def build( { "scope": AugmentedPart, "info": ScopeInfo | None, - "objective_diameter_mm": float | None, "cells": {part_type_id: [{"part": AugmentedPart, "label": str}]}, }, ..., @@ -54,7 +51,9 @@ 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] + 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": []} @@ -72,7 +71,9 @@ 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() @@ -80,28 +81,15 @@ 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} @@ -121,7 +109,9 @@ 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: @@ -130,7 +120,6 @@ async def build( { "scope": scope, "info": infos.get(scope.id), - "objective_diameter_mm": objective_diameters.get(scope.id), "cells": cells, } ) diff --git a/app/services/scope_import.py b/app/services/scope_import.py index 26a0e33..3ccd897 100644 --- a/app/services/scope_import.py +++ b/app/services/scope_import.py @@ -2,7 +2,7 @@ The spreadsheet describes parts of PartType "Scope". Each row maps onto an AugmentedPart (keyed on ``product_number`` → ``sku``, so re-importing upserts), -a ScopeModel, a ScopeInfo record for the scope-specific fields, up to three +a ScopeModel, a ScopeInfo record for the scope-specific fields, up to two attachment points, and up to two Fits against other parts. Column → destination @@ -14,9 +14,9 @@ model_suffix → ScopeInfo.model_suffix (+ description) sub_part_type → SubPartType (get-or-create under "Scope") product_number → AugmentedPart.sku (also the upsert / fit-lookup key) magnification_min / _max → ScopeInfo +objective_lens_diameter_mm → ScopeInfo (descriptive only, no attachment point) objective_thread_pitch → ScopeInfo ocular_outer_diameter_mm → StaticConnection + AttachmentPoint "Ocular" -objective_lens_outside_diameter_mm → StaticConnection + AttachmentPoint "Objective" objective_thread → ISO/UNC thread + AttachmentPoint "Objective" ocular_fit → Fit: scope's Ocular AP ↔ referenced part's first AP objective_fit → Fit: scope's Objective AP ↔ referenced part's first AP @@ -68,7 +68,7 @@ EXPECTED_COLUMNS = [ "model", "magnification_min", "magnification_max", - "objective_lens_outside_diameter_mm", + "objective_lens_diameter_mm", "model_suffix", "sub_part_type", "product_number", @@ -330,6 +330,7 @@ async def import_scopes(db: AsyncSession, rows: list[dict]) -> ScopeImportResult magnification_min=_num(row.get("magnification_min")), magnification_max=_num(row.get("magnification_max")), model_suffix=model_suffix, + objective_lens_diameter_mm=_num(row.get("objective_lens_diameter_mm")), objective_thread_pitch=_text(row.get("objective_thread_pitch")), ) @@ -350,34 +351,23 @@ async def import_scopes(db: AsyncSession, rows: list[dict]) -> ScopeImportResult od_mm=ocular_od, ) - objective_static_ap = None - objective_od = _num(row.get("objective_lens_outside_diameter_mm")) - if objective_od is not None: - objective_static_ap = await _static_ap( - db, - augmented_part_id=part.id, - label=OBJECTIVE_LABEL, - od_mm=objective_od, - ) - + # The objective attachment point comes from the thread alone — the + # lens diameter is a descriptive attribute, not a connection. objective_thread_spec = _text(row.get("objective_thread")) - objective_thread_ap = None + objective_ap = None if objective_thread_spec: - objective_thread_ap = await _thread_ap( + objective_ap = await _thread_ap( db, augmented_part_id=part.id, label=OBJECTIVE_LABEL, spec=objective_thread_spec, ) - if objective_thread_ap is None: + if objective_ap is None: result.messages.append( f"could not parse objective_thread " f"'{objective_thread_spec}'" ) - # For objective_fit prefer the lens-OD point, falling back to the thread. - objective_ap = objective_static_ap or objective_thread_ap - staged.append((row, ocular_ap, objective_ap, part.id, result)) row_results.append(result) except Exception as exc: # noqa: BLE001 - report, don't abort the batch diff --git a/app/templates/_scopes_table_card.html b/app/templates/_scopes_table_card.html index 8245507..5f97cdc 100644 --- a/app/templates/_scopes_table_card.html +++ b/app/templates/_scopes_table_card.html @@ -8,10 +8,10 @@ {{ mag(info.magnification_max) }} {%- endif -%} {%- endmacro -%} -{%- macro magnification(info, objective_diameter_mm) -%} +{%- macro magnification(info) -%} {%- set range = magnification_range(info) -%} {%- if range -%} - {{ range }}x{% if objective_diameter_mm is not none %}{{ mag(objective_diameter_mm) }}{% endif %} + {{ range }}x{% if info.objective_lens_diameter_mm is not none %}{{ mag(info.objective_lens_diameter_mm) }}{% endif %} {%- else -%} — {%- endif -%} @@ -43,7 +43,7 @@