diff --git a/app/templates/admin/_fit_other_points_select.html b/app/templates/admin/_fit_other_points_select.html index 352e232..6b03731 100644 --- a/app/templates/admin/_fit_other_points_select.html +++ b/app/templates/admin/_fit_other_points_select.html @@ -1,6 +1,13 @@ +{% if points %} +{% else %} + +
+ This part has no attachment points yet — one matching yours will be created automatically. +
+{% endif %} diff --git a/app/web/admin.py b/app/web/admin.py index 3091c07..a5daca5 100644 --- a/app/web/admin.py +++ b/app/web/admin.py @@ -2268,24 +2268,62 @@ async def create_augmented_part_fit( request: Request, augmented_part_id: int, own_attachment_point_id: int = Form(...), + other_augmented_part_id: str = Form(""), other_attachment_point_id: str = Form(""), label: FitLabel = Form(FitLabel.EXACT), db: AsyncSession = Depends(get_db), actor: User = Depends(web_require("fits:write")), ): error = None - if not other_attachment_point_id: - error = "Select the other part's attachment point." - else: - ap_a = await attachment_point_crud.get(db, own_attachment_point_id) + auto_created_ap = None + ap_a = await attachment_point_crud.get(db, own_attachment_point_id) + ap_b = None + if ap_a is None: + error = "Attachment point not found." + elif other_attachment_point_id: ap_b = await attachment_point_crud.get(db, int(other_attachment_point_id)) - if ap_a is None or ap_b is None: + if ap_b is None: error = "Attachment point not found." - elif ap_a.augmented_part_id == ap_b.augmented_part_id: + elif other_augmented_part_id: + other_part = await augmented_part_crud.get(db, int(other_augmented_part_id)) + if other_part is None: + error = "Part not found." + elif await attachment_point_crud.list_for_augmented_part(db, other_part.id): + error = "Select the other part's attachment point." + else: + ap_b = await attachment_point_crud.create( + db, + augmented_part_id=other_part.id, + attachment_point_name_id=ap_a.attachment_point_name_id, + iso_thread_id=ap_a.iso_thread_id, + unc_thread_id=ap_a.unc_thread_id, + clamp_connection_id=ap_a.clamp_connection_id, + static_connection_id=ap_a.static_connection_id, + ) + auto_created_ap = ap_b + else: + error = "Select the other part's attachment point." + + if error is None and ap_b is not None: + if ap_a.augmented_part_id == ap_b.augmented_part_id: error = "A part cannot fit itself." elif await fit_crud.get_by_attachment_points(db, ap_a, ap_b) is not None: error = "That fit already exists." else: + if auto_created_ap is not None: + await audit_crud.log( + db, + actor=actor, + action="create", + resource_type="AttachmentPoint", + resource_id=auto_created_ap.id, + description=audit_crud.describe_create( + "AttachmentPoint", + auto_created_ap.id, + f"auto-created on AugmentedPart #{auto_created_ap.augmented_part_id} " + f"to match attachment point {ap_a.id}", + ), + ) fit = await fit_crud.create(db, ap_a=ap_a, ap_b=ap_b, label=label) await audit_crud.log( db, diff --git a/tests/test_augmented_part_detail.py b/tests/test_augmented_part_detail.py index 773b263..f958711 100644 --- a/tests/test_augmented_part_detail.py +++ b/tests/test_augmented_part_detail.py @@ -5,6 +5,7 @@ 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 @@ -151,6 +152,90 @@ async def test_detail_write_controls_for_fit_editor( assert "Recompute possible fits" in body +@pytest.mark.asyncio +async def test_create_fit_auto_creates_matching_attachment_point( + client, seeded, make_user, db_sessionmaker +): + async with db_sessionmaker() as db: + apn = AttachmentPointName(name="Objective") + scope_t = PartType(name="Scope") + cover_t = PartType(name="Flip Cover") + db.add_all([apn, scope_t, cover_t]) + await db.flush() + + thread = IsoThread(diameter_mm=42.0, pitch_mm=0.75) + db.add(thread) + await db.flush() + + scope = AugmentedPart( + description="Alpha 3-18x50", + sku="ALP-318", + colour_id=1, + manufacturer_id=1, + part_type_id=scope_t.id, + ) + cover = AugmentedPart( + description="Flip cover 50mm", + colour_id=1, + manufacturer_id=1, + part_type_id=cover_t.id, + ) + db.add_all([scope, cover]) + await db.flush() + + own_ap = AttachmentPoint( + augmented_part_id=scope.id, + attachment_point_name_id=apn.id, + iso_thread_id=thread.id, + ) + db.add(own_ap) + await db.commit() + + scope_id, cover_id, own_ap_id = scope.id, cover.id, own_ap.id + + perms = seeded["perms"] + editor = await role_crud.create( + db, + name="fit-editor2", + permission_ids=[ + perms["augmented_parts:read"].id, + perms["fits:read"].id, + perms["fits:write"].id, + ], + ) + editor_id = editor.id + await make_user("editor2@example.com", role_ids=[editor_id]) + await _web_login(client, "editor2@example.com") + + resp = await client.post( + f"/admin/augmented-parts/{scope_id}/fits", + data={ + "own_attachment_point_id": own_ap_id, + "other_augmented_part_id": cover_id, + "other_attachment_point_id": "", + "label": "possible", + }, + ) + assert resp.status_code == 200 + body = resp.text + assert "already exists" not in body + assert "not found" not in body + + async with db_sessionmaker() as db: + cover_points = [ + ap + for ap in (await db.execute(AttachmentPoint.__table__.select())).all() + if ap.augmented_part_id == cover_id + ] + assert len(cover_points) == 1 + assert cover_points[0].iso_thread_id == thread.id + assert cover_points[0].attachment_point_name_id == apn.id + + fits = (await db.execute(Fit.__table__.select())).all() + assert len(fits) == 1 + assert fits[0].label == FitLabel.POSSIBLE + + @pytest.mark.asyncio async def test_detail_404_for_missing_part(client, seeded, make_user): await make_user("reader2@example.com", role_ids=[seeded["roles"]["user"].id])