Adding Manual connection point to allow undeclared fits

This commit is contained in:
Ian Roddis committed 2026-08-18 16:57:28 -03:00
1 parent 7e246be39c
commit b4dcb68bea
15 files changed
+227 -23

No files matched your search

+7 -3
View File
@@ -27,7 +27,7 @@ def _connection_label(ap) -> str:
return f"{cc.min_in}–{cc.max_in} in" return f"{cc.min_in}–{cc.max_in} in"
if ap.static_connection: if ap.static_connection:
return f"{ap.static_connection.od_mm} mm OD" return f"{ap.static_connection.od_mm} mm OD"
return "—" return "Manual"
@router.get("", response_model=list[AttachmentPointRead]) @router.get("", response_model=list[AttachmentPointRead])
@@ -54,10 +54,14 @@ async def create_attachment_point(
payload.clamp_connection_id, payload.clamp_connection_id,
payload.static_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( raise HTTPException(
status_code=400, 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()) ap = await attachment_point_crud.create(db, **payload.model_dump())
await audit_crud.log( await audit_crud.log(
+4 -1
View File
@@ -21,7 +21,10 @@ class AttachmentPoint(Base):
ForeignKey("attachment_point_names.id", ondelete="RESTRICT"), nullable=False 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( iso_thread_id: Mapped[int | None] = mapped_column(
ForeignKey("iso_threads.id", ondelete="RESTRICT"), nullable=True, default=None ForeignKey("iso_threads.id", ondelete="RESTRICT"), nullable=True, default=None
) )
+6 -1
View File
@@ -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: 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 # 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: # if ap_a.attachment_point_name_id == ap_b.attachment_point_name_id:
# return False # return False
+55 -10
View File
@@ -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: ``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 "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 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 the fit has something valid to attach to. If the scope itself has no Ocular/
second pass so a fit between two scopes in the same spreadsheet resolves Objective attachment point (no ocular_outer_diameter_mm/objective_thread, or
regardless of row order. 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 Bad rows (missing key, unknown fit target, unparseable thread) are skipped and
reported per-row; good rows are committed as they go. 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( async def _link_fit(
db: AsyncSession, db: AsyncSession,
*, *,
@@ -218,14 +252,11 @@ async def _link_fit(
) -> None: ) -> None:
"""Create a POSSIBLE fit from the scope's AP to the referenced part's first AP. """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 ``scope_ap`` must already exist — callers ensure this via
mirroring the scope's attachment point's connection. ``_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) target = await augmented_part_crud.get_by_sku(db, target_sku)
if target is None: if target is None:
messages.append(f"{scope_role}_fit references unknown product '{target_sku}'") 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: try:
ocular_fit = _text(row.get("ocular_fit")) ocular_fit = _text(row.get("ocular_fit"))
if 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 fits = ocular_fit
if "/" in fits: if "/" in fits:
fits = [x.strip() for x in fits.split("/")] 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")) objective_fit = _text(row.get("objective_fit"))
if 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 fits = objective_fit
if "/" in fits: if "/" in fits:
fits = [x.strip() for x in fits.split("/")] fits = [x.strip() for x in fits.split("/")]
@@ -48,4 +48,6 @@
{% endfor %} {% endfor %}
</select> </select>
</label> </label>
{% elif connection_type == "manual" %}
<p>No connection details — fits for this attachment point must be asserted manually.</p>
{% endif %} {% endif %}
@@ -15,7 +15,7 @@
{% set conn_type = "Static Connection" %} {% set conn_type = "Static Connection" %}
{% set conn_label = ap.static_connection.od_mm ~ " mm OD" %} {% set conn_label = ap.static_connection.od_mm ~ " mm OD" %}
{% else %} {% else %}
{% set conn_type = "—" %} {% set conn_type = "Manual" %}
{% set conn_label = "—" %} {% set conn_label = "—" %}
{% endif %} {% endif %}
<tr id="attachment-point-{{ ap.id }}"> <tr id="attachment-point-{{ ap.id }}">
@@ -2,7 +2,7 @@
{% elif ap.unc_thread_id %}{% set current_type = "unc_thread" %} {% elif ap.unc_thread_id %}{% set current_type = "unc_thread" %}
{% elif ap.clamp_connection_id %}{% set current_type = "clamp_connection" %} {% elif ap.clamp_connection_id %}{% set current_type = "clamp_connection" %}
{% elif ap.static_connection_id %}{% set current_type = "static_connection" %} {% elif ap.static_connection_id %}{% set current_type = "static_connection" %}
{% else %}{% set current_type = "" %}{% endif %} {% else %}{% set current_type = "manual" %}{% endif %}
<tr id="attachment-point-{{ ap.id }}"> <tr id="attachment-point-{{ ap.id }}">
<td colspan="6"> <td colspan="6">
<form hx-post="/admin/attachment-points/{{ ap.id }}" <form hx-post="/admin/attachment-points/{{ ap.id }}"
@@ -41,6 +41,7 @@
<option value="unc_thread" {% if current_type == "unc_thread" %}selected{% endif %}>UNC Thread</option> <option value="unc_thread" {% if current_type == "unc_thread" %}selected{% endif %}>UNC Thread</option>
<option value="clamp_connection" {% if current_type == "clamp_connection" %}selected{% endif %}>Clamp Connection</option> <option value="clamp_connection" {% if current_type == "clamp_connection" %}selected{% endif %}>Clamp Connection</option>
<option value="static_connection" {% if current_type == "static_connection" %}selected{% endif %}>Static Connection</option> <option value="static_connection" {% if current_type == "static_connection" %}selected{% endif %}>Static Connection</option>
<option value="manual" {% if current_type == "manual" %}selected{% endif %}>Manual</option>
</select> </select>
</label> </label>
{# Render the current connection select pre-filled #} {# Render the current connection select pre-filled #}
@@ -15,7 +15,7 @@
{% set conn_type = "Static" %} {% set conn_type = "Static" %}
{% set conn_label = apoint.static_connection.od_mm ~ " mm OD" %} {% set conn_label = apoint.static_connection.od_mm ~ " mm OD" %}
{% else %} {% else %}
{% set conn_type = "—" %} {% set conn_type = "Manual" %}
{% set conn_label = "—" %} {% set conn_label = "—" %}
{% endif %} {% endif %}
<div id="augmented-part-ap-{{ apoint.id }}" class="ap-item"> <div id="augmented-part-ap-{{ apoint.id }}" class="ap-item">
@@ -2,7 +2,7 @@
{% elif apoint.unc_thread_id %}{% set current_type = "unc_thread" %} {% elif apoint.unc_thread_id %}{% set current_type = "unc_thread" %}
{% elif apoint.clamp_connection_id %}{% set current_type = "clamp_connection" %} {% elif apoint.clamp_connection_id %}{% set current_type = "clamp_connection" %}
{% elif apoint.static_connection_id %}{% set current_type = "static_connection" %} {% elif apoint.static_connection_id %}{% set current_type = "static_connection" %}
{% else %}{% set current_type = "" %}{% endif %} {% else %}{% set current_type = "manual" %}{% endif %}
{# _attachment_point_connection_select.html expects `ap` for pre-selection #} {# _attachment_point_connection_select.html expects `ap` for pre-selection #}
{% set ap = apoint %} {% set ap = apoint %}
{% set connection_type = current_type %} {% set connection_type = current_type %}
@@ -29,6 +29,7 @@
<option value="unc_thread" {{ "selected" if current_type == "unc_thread" }}>UNC Thread</option> <option value="unc_thread" {{ "selected" if current_type == "unc_thread" }}>UNC Thread</option>
<option value="clamp_connection" {{ "selected" if current_type == "clamp_connection" }}>Clamp Connection</option> <option value="clamp_connection" {{ "selected" if current_type == "clamp_connection" }}>Clamp Connection</option>
<option value="static_connection" {{ "selected" if current_type == "static_connection" }}>Static Connection</option> <option value="static_connection" {{ "selected" if current_type == "static_connection" }}>Static Connection</option>
<option value="manual" {{ "selected" if current_type == "manual" }}>Manual</option>
</select> </select>
</label> </label>
<div id="edit-ap-{{ apoint.id }}-connection-options"> <div id="edit-ap-{{ apoint.id }}-connection-options">
@@ -31,6 +31,7 @@
<option value="unc_thread">UNC Thread</option> <option value="unc_thread">UNC Thread</option>
<option value="clamp_connection">Clamp Connection</option> <option value="clamp_connection">Clamp Connection</option>
<option value="static_connection">Static Connection</option> <option value="static_connection">Static Connection</option>
<option value="manual">Manual</option>
</select> </select>
</label> </label>
<div id="ap-{{ augmented_part_id }}-add-connection-options"></div> <div id="ap-{{ augmented_part_id }}-add-connection-options"></div>
@@ -8,7 +8,7 @@
{%- elif ap.static_connection -%} {%- elif ap.static_connection -%}
Static {{ ap.static_connection.od_mm }} mm OD Static {{ ap.static_connection.od_mm }} mm OD
{%- else -%} {%- else -%}
— Manual
{%- endif -%} {%- endif -%}
{%- endmacro %} {%- endmacro %}
@@ -38,6 +38,7 @@
<option value="unc_thread">UNC Thread</option> <option value="unc_thread">UNC Thread</option>
<option value="clamp_connection">Clamp Connection</option> <option value="clamp_connection">Clamp Connection</option>
<option value="static_connection">Static Connection</option> <option value="static_connection">Static Connection</option>
<option value="manual">Manual</option>
</select> </select>
</label> </label>
<div id="add-connection-options"></div> <div id="add-connection-options"></div>
+3 -3
View File
@@ -2387,7 +2387,7 @@ async def edit_augmented_part_attachment_point(
elif apoint.static_connection_id: elif apoint.static_connection_id:
ctx["connection_type"] = "static_connection" ctx["connection_type"] = "static_connection"
else: else:
ctx["connection_type"] = "" ctx["connection_type"] = "manual"
return templates.TemplateResponse( return templates.TemplateResponse(
request, "admin/_augmented_part_ap_row_edit.html", ctx request, "admin/_augmented_part_ap_row_edit.html", ctx
) )
@@ -3242,7 +3242,7 @@ def _ap_connection_label(ap) -> str:
return f"{cc.min_in}–{cc.max_in} in" return f"{cc.min_in}–{cc.max_in} in"
if ap.static_connection: if ap.static_connection:
return f"{ap.static_connection.od_mm} mm OD" return f"{ap.static_connection.od_mm} mm OD"
return "—" return "Manual"
@router.get("/attachment-points", response_class=HTMLResponse) @router.get("/attachment-points", response_class=HTMLResponse)
@@ -3339,7 +3339,7 @@ async def edit_attachment_point_row(
elif ap.static_connection_id: elif ap.static_connection_id:
ctx["connection_type"] = "static_connection" ctx["connection_type"] = "static_connection"
else: else:
ctx["connection_type"] = "" ctx["connection_type"] = "manual"
return templates.TemplateResponse( return templates.TemplateResponse(
request, "admin/_attachment_point_row_edit.html", ctx request, "admin/_attachment_point_row_edit.html", ctx
) )
+106
View File
@@ -0,0 +1,106 @@
from app.models.attachment_point_name import AttachmentPointName
from app.models.augmented_part import AugmentedPart
from app.models.iso_thread import IsoThread
from app.models.manufacturer import Manufacturer
from app.models.part_colour import PartColour
from app.models.part_type import PartType
async def _seed_part(db):
manufacturer = Manufacturer(name="Acme", url="https://acme.test")
colour = PartColour(name="Black")
part_type = PartType(name="Flip Cover")
apn = AttachmentPointName(name="Mount")
iso = IsoThread(diameter_mm=52.0, pitch_mm=0.75)
db.add_all([manufacturer, colour, part_type, apn, iso])
await db.flush()
part = AugmentedPart(
description="Flip cover",
colour_id=colour.id,
manufacturer_id=manufacturer.id,
part_type_id=part_type.id,
)
db.add(part)
await db.flush()
await db.commit()
return part.id, apn.id, iso.id
async def _token(client, email, password="secret123"):
resp = await client.post(
"/api/auth/login", data={"username": email, "password": password}
)
assert resp.status_code == 200, resp.text
return resp.json()["access_token"]
async def test_create_manual_attachment_point_with_no_connection_succeeds(
client, seeded, make_user, db_sessionmaker
):
await make_user("root@example.com", is_superuser=True)
headers = {"Authorization": f"Bearer {await _token(client, 'root@example.com')}"}
async with db_sessionmaker() as db:
part_id, apn_id, _ = await _seed_part(db)
resp = await client.post(
"/api/attachment-points",
json={
"augmented_part_id": part_id,
"attachment_point_name_id": apn_id,
},
headers=headers,
)
assert resp.status_code == 201, resp.text
body = resp.json()
assert body["iso_thread_id"] is None
assert body["unc_thread_id"] is None
assert body["clamp_connection_id"] is None
assert body["static_connection_id"] is None
async def test_create_attachment_point_with_two_connections_rejected(
client, seeded, make_user, db_sessionmaker
):
await make_user("root2@example.com", is_superuser=True)
headers = {"Authorization": f"Bearer {await _token(client, 'root2@example.com')}"}
async with db_sessionmaker() as db:
part_id, apn_id, iso_id = await _seed_part(db)
from app.crud import static_connection as static_connection_crud
static = await static_connection_crud.create(db, od_mm=40.0, od_in=1.57)
static_id = static.id
resp = await client.post(
"/api/attachment-points",
json={
"augmented_part_id": part_id,
"attachment_point_name_id": apn_id,
"iso_thread_id": iso_id,
"static_connection_id": static_id,
},
headers=headers,
)
assert resp.status_code == 400
assert "at most one" in resp.json()["detail"].lower()
async def test_create_attachment_point_with_one_connection_still_works(
client, seeded, make_user, db_sessionmaker
):
await make_user("root3@example.com", is_superuser=True)
headers = {"Authorization": f"Bearer {await _token(client, 'root3@example.com')}"}
async with db_sessionmaker() as db:
part_id, apn_id, iso_id = await _seed_part(db)
resp = await client.post(
"/api/attachment-points",
json={
"augmented_part_id": part_id,
"attachment_point_name_id": apn_id,
"iso_thread_id": iso_id,
},
headers=headers,
)
assert resp.status_code == 201, resp.text
assert resp.json()["iso_thread_id"] == iso_id
+35
View File
@@ -284,6 +284,41 @@ async def test_fit_reference_without_attachment_points_creates_matching_one(
assert acc_aps[0].id in linked_ap_ids assert acc_aps[0].id in linked_ap_ids
async def test_scope_fit_without_scope_attachment_point_creates_manual_one(
db_sessionmaker,
):
async with db_sessionmaker() as db:
_, acc_ap = await _make_accessory(db, "ACC-1", od_mm=40)
# No ocular_outer_diameter_mm, so the scope itself has no Ocular AP,
# but an ocular_fit reference is still given.
data = _workbook(
[_scope_row(ocular_outer_diameter_mm=None, ocular_fit="ACC-1")]
)
result = await scope_import_service.import_from_bytes(db, data)
assert result.created == 1
msgs = " ".join(result.rows[0].messages)
assert "created a manual one to record the fit" in msgs
part = await augmented_part_crud.get_by_sku(db, "SCOPE-A")
scope_aps = await attachment_point_crud.list_for_augmented_part(db, part.id)
by_label = {ap.attachment_point_name.name: ap for ap in scope_aps}
assert "Ocular" in by_label
ocular_ap = by_label["Ocular"]
assert ocular_ap.iso_thread is None
assert ocular_ap.unc_thread is None
assert ocular_ap.clamp_connection is None
assert ocular_ap.static_connection is None
# Objective still gets its normal thread-based AP.
assert "Objective" in by_label
fits = await fit_crud.list_for_part(db, part.id)
assert len(fits) == 1
linked_ap_ids = {fits[0].attachment_point_a_id, fits[0].attachment_point_b_id}
assert ocular_ap.id in linked_ap_ids
assert acc_ap.id in linked_ap_ids
async def test_unc_objective_thread_creates_unc_attachment_point(db_sessionmaker): async def test_unc_objective_thread_creates_unc_attachment_point(db_sessionmaker):
async with db_sessionmaker() as db: async with db_sessionmaker() as db:
data = _workbook([_scope_row(objective_thread="1.750-32 UNS")]) data = _workbook([_scope_row(objective_thread="1.750-32 UNS")])