From b4dcb68bea17d7029a0288afec818b38adf02017 Mon Sep 17 00:00:00 2001 From: Ian Roddis <31021769+iroddis@users.noreply.github.com> Date: Tue, 18 Aug 2026 16:57:28 -0300 Subject: [PATCH] Adding Manual connection point to allow undeclared fits --- app/api/attachment_points.py | 10 +- app/models/attachment_point.py | 5 +- app/services/fit_matching.py | 7 +- app/services/scope_import.py | 65 +++++++++-- .../_attachment_point_connection_select.html | 2 + .../admin/_attachment_point_row.html | 2 +- .../admin/_attachment_point_row_edit.html | 3 +- .../admin/_augmented_part_ap_row.html | 2 +- .../admin/_augmented_part_ap_row_edit.html | 3 +- .../_augmented_part_attachment_points.html | 1 + .../admin/_augmented_part_fit_row.html | 2 +- app/templates/admin/attachment_points.html | 1 + app/web/admin.py | 6 +- tests/test_attachment_points_api.py | 106 ++++++++++++++++++ tests/test_scope_import.py | 35 ++++++ 15 files changed, 227 insertions(+), 23 deletions(-) create mode 100644 tests/test_attachment_points_api.py diff --git a/app/api/attachment_points.py b/app/api/attachment_points.py index 2a44283..483a62c 100644 --- a/app/api/attachment_points.py +++ b/app/api/attachment_points.py @@ -27,7 +27,7 @@ def _connection_label(ap) -> str: return f"{cc.min_in}–{cc.max_in} in" if ap.static_connection: return f"{ap.static_connection.od_mm} mm OD" - return "—" + return "Manual" @router.get("", response_model=list[AttachmentPointRead]) @@ -54,10 +54,14 @@ async def create_attachment_point( payload.clamp_connection_id, payload.static_connection_id, ] - if sum(v is not None for v in conn_ids) != 1: + if sum(v is not None for v in conn_ids) > 1: raise HTTPException( status_code=400, - detail="Exactly one connection (iso_thread, unc_thread, clamp_connection, or static_connection) must be provided.", + detail=( + "At most one connection (iso_thread, unc_thread, clamp_connection, " + "or static_connection) may be provided. Leave all unset for a " + "manual attachment point." + ), ) ap = await attachment_point_crud.create(db, **payload.model_dump()) await audit_crud.log( diff --git a/app/models/attachment_point.py b/app/models/attachment_point.py index 9accc15..956791f 100644 --- a/app/models/attachment_point.py +++ b/app/models/attachment_point.py @@ -21,7 +21,10 @@ class AttachmentPoint(Base): ForeignKey("attachment_point_names.id", ondelete="RESTRICT"), nullable=False ) - # Exactly one of the four below must be non-null (enforced in application layer) + # At most one of the four below is non-null (enforced in application layer). + # All four null means a "Manual" attachment point: no connection details, + # used to let a user assert a Fit by hand without the fit-matching algorithm + # (app.services.fit_matching) ever auto-matching it. iso_thread_id: Mapped[int | None] = mapped_column( ForeignKey("iso_threads.id", ondelete="RESTRICT"), nullable=True, default=None ) diff --git a/app/services/fit_matching.py b/app/services/fit_matching.py index d1a1cdf..8f31e24 100644 --- a/app/services/fit_matching.py +++ b/app/services/fit_matching.py @@ -76,7 +76,12 @@ def _static_clamp_match(ap_a: AttachmentPoint, ap_b: AttachmentPoint) -> bool: def attachment_points_match(ap_a: AttachmentPoint, ap_b: AttachmentPoint) -> bool: - """True if these two attachment points satisfy the fit-matching algorithm.""" + """True if these two attachment points satisfy the fit-matching algorithm. + + A "Manual" attachment point (all four connections null) always returns + False here — it never participates in algorithmic matching, only in + fits a user asserts by hand. + """ # Disabled for now, the attachment_point_names should not be considered for the purposes of matching # if ap_a.attachment_point_name_id == ap_b.attachment_point_name_id: # return False diff --git a/app/services/scope_import.py b/app/services/scope_import.py index fe998ea..106fbcf 100644 --- a/app/services/scope_import.py +++ b/app/services/scope_import.py @@ -27,9 +27,13 @@ Fits are created with label POSSIBLE. The referenced part is looked up by ``product_number`` and linked to its first attachment point (chosen answer: "sole/first AP"). If the referenced part has no attachment points at all, one is created for it that mirrors the scope's attachment point's connection, so -the fit has something valid to attach to. Both directions are resolved in a -second pass so a fit between two scopes in the same spreadsheet resolves -regardless of row order. +the fit has something valid to attach to. If the scope itself has no Ocular/ +Objective attachment point (no ocular_outer_diameter_mm/objective_thread, or +an unparseable one), a Manual attachment point — no connection details, never +picked up by the fit-matching algorithm — is created on the scope under the +appropriate label so the fit still has somewhere to attach. Both directions +are resolved in a second pass so a fit between two scopes in the same +spreadsheet resolves regardless of row order. Bad rows (missing key, unknown fit target, unparseable thread) are skipped and reported per-row; good rows are committed as they go. @@ -207,6 +211,36 @@ async def _mirrored_ap(db: AsyncSession, *, augmented_part_id: int, scope_ap): ) +async def _ensure_manual_scope_ap( + db: AsyncSession, + *, + scope_ap, + augmented_part_id: int, + label: str, + messages: list[str], +): + """Return ``scope_ap``, creating a Manual attachment point named ``label`` + on the scope if it's None. + + Used so an ocular_fit/objective_fit reference still has something to link + from even when the scope has no ocular_outer_diameter_mm/objective_thread + of its own. + """ + if scope_ap is not None: + return scope_ap + apn = await apn_crud.get_by_name(db, label) + if apn is None: + apn = await apn_crud.create(db, name=label) + messages.append( + f"scope has no {label.lower()} attachment point; created a manual one to record the fit" + ) + return await attachment_point_crud.create( + db, + augmented_part_id=augmented_part_id, + attachment_point_name_id=apn.id, + ) + + async def _link_fit( db: AsyncSession, *, @@ -218,14 +252,11 @@ async def _link_fit( ) -> None: """Create a POSSIBLE fit from the scope's AP to the referenced part's first AP. - If the referenced part has no attachment points at all, one is created - mirroring the scope's attachment point's connection. + ``scope_ap`` must already exist — callers ensure this via + ``_ensure_manual_scope_ap`` before calling. If the referenced part has no + attachment points at all, one is created mirroring the scope's attachment + point's connection. """ - if scope_ap is None: - messages.append( - f"{scope_role}_fit '{target_sku}' skipped: scope has no {scope_role} attachment point" - ) - return target = await augmented_part_crud.get_by_sku(db, target_sku) if target is None: messages.append(f"{scope_role}_fit references unknown product '{target_sku}'") @@ -406,6 +437,13 @@ async def import_scopes(db: AsyncSession, rows: list[dict]) -> ScopeImportResult try: ocular_fit = _text(row.get("ocular_fit")) if ocular_fit: + ocular_ap = await _ensure_manual_scope_ap( + db, + scope_ap=ocular_ap, + augmented_part_id=part_id, + label=OCULAR_LABEL, + messages=result.messages, + ) fits = ocular_fit if "/" in fits: fits = [x.strip() for x in fits.split("/")] @@ -422,6 +460,13 @@ async def import_scopes(db: AsyncSession, rows: list[dict]) -> ScopeImportResult ) objective_fit = _text(row.get("objective_fit")) if objective_fit: + objective_ap = await _ensure_manual_scope_ap( + db, + scope_ap=objective_ap, + augmented_part_id=part_id, + label=OBJECTIVE_LABEL, + messages=result.messages, + ) fits = objective_fit if "/" in fits: fits = [x.strip() for x in fits.split("/")] diff --git a/app/templates/admin/_attachment_point_connection_select.html b/app/templates/admin/_attachment_point_connection_select.html index f4b5365..2237c03 100644 --- a/app/templates/admin/_attachment_point_connection_select.html +++ b/app/templates/admin/_attachment_point_connection_select.html @@ -48,4 +48,6 @@ {% endfor %} +{% elif connection_type == "manual" %} +
No connection details — fits for this attachment point must be asserted manually.
{% endif %} diff --git a/app/templates/admin/_attachment_point_row.html b/app/templates/admin/_attachment_point_row.html index c661fe8..e184087 100644 --- a/app/templates/admin/_attachment_point_row.html +++ b/app/templates/admin/_attachment_point_row.html @@ -15,7 +15,7 @@ {% set conn_type = "Static Connection" %} {% set conn_label = ap.static_connection.od_mm ~ " mm OD" %} {% else %} - {% set conn_type = "—" %} + {% set conn_type = "Manual" %} {% set conn_label = "—" %} {% endif %}