diff --git a/TODO b/TODO index e0e4394..8066c49 100644 --- a/TODO +++ b/TODO @@ -1,7 +1,7 @@ [ ] Drop 'id' from UI tables, add 'date added / updated' -[ ] Adapter Rings should get two attachment points +[x] Adapter Rings should get two attachment points [ ] Add 'on_website' boolean to augmented part -[ ] API: allow updating parts by ID or by SKU +[x] API: allow updating parts by ID or by SKU [ ] Add `preferred_part` to augmented parts, used to order matches (e.g. double tab vs single tab) [ ] Adapter Rings [ ] Should be married to a specific flip cover (a, b), not (a)x(b) cartesian product diff --git a/alembic/versions/3c4d5e6f7a8b_augmented_parts_uac_diameter_range.py b/alembic/versions/3c4d5e6f7a8b_augmented_parts_uac_diameter_range.py new file mode 100644 index 0000000..01c03fb --- /dev/null +++ b/alembic/versions/3c4d5e6f7a8b_augmented_parts_uac_diameter_range.py @@ -0,0 +1,30 @@ +"""augmented_parts_uac_diameter_range + +Revision ID: 3c4d5e6f7a8b +Revises: 2b3c4d5e6f7a +Create Date: 2026-09-21 00:00:00.000000 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "3c4d5e6f7a8b" +down_revision: str | None = "2b3c4d5e6f7a" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + with op.batch_alter_table("augmented_parts", schema=None) as batch_op: + batch_op.add_column( + sa.Column("uac_diameter_range_mm", sa.String(), nullable=True) + ) + + +def downgrade() -> None: + with op.batch_alter_table("augmented_parts", schema=None) as batch_op: + batch_op.drop_column("uac_diameter_range_mm") diff --git a/app/crud/clamp_connection.py b/app/crud/clamp_connection.py index 5da6784..ec72fff 100644 --- a/app/crud/clamp_connection.py +++ b/app/crud/clamp_connection.py @@ -22,6 +22,17 @@ async def list_all( return list(result.scalars().all()) +async def get_by_values( + db: AsyncSession, *, min_mm: float | None, max_mm: float | None +) -> ClampConnection | None: + result = await db.execute( + select(ClampConnection).where( + ClampConnection.min_mm == min_mm, ClampConnection.max_mm == max_mm + ) + ) + return result.scalars().first() + + async def create( db: AsyncSession, *, diff --git a/app/models/augmented_part.py b/app/models/augmented_part.py index 9641a1a..d506dea 100644 --- a/app/models/augmented_part.py +++ b/app/models/augmented_part.py @@ -38,6 +38,11 @@ class AugmentedPart(Base): nullable=True, default=None, ) + # "##.##-##.##" mm, derived from ImportedPart.UACDiameterRangemm by the sync + # service. Read-only — never set outside app/services/augmented_part_sync.py. + uac_diameter_range_mm: Mapped[str | None] = mapped_column( + String, nullable=True, default=None + ) # Set instead of deleting the row when the last backing ImportedPart is # removed, so fit/attachment-point history is preserved. Hidden from list # views by default. diff --git a/app/schemas/augmented_part.py b/app/schemas/augmented_part.py index 5596a33..ab738bb 100644 --- a/app/schemas/augmented_part.py +++ b/app/schemas/augmented_part.py @@ -67,6 +67,7 @@ class AugmentedPartRead(AugmentedPartBase): id: int archived: bool + uac_diameter_range_mm: str | None colour: PartColourRead manufacturer: ManufacturerRead scope_model: ScopeModelRead | None diff --git a/app/services/augmented_part_sync.py b/app/services/augmented_part_sync.py index 0cd0a86..6979838 100644 --- a/app/services/augmented_part_sync.py +++ b/app/services/augmented_part_sync.py @@ -25,6 +25,12 @@ Phase 2 – create entries for new ImportedParts unit is missing is derived via mm/in conversion) if the thread attribute is absent or unparseable Skipped entirely if none of those fields are usable. + UAC diameter range ← ImportedPart.UACDiameterRangemm, an 8-digit string with + no separators (2 leading digits + 2 decimal digits for the + minimum, then the same for the maximum) reformatted as + AugmentedPart.uac_diameter_range_mm = "##.##-##.##". When + present, also adds a second attachment point named + "Objective" with a ClampConnection spanning that range. """ import re @@ -49,6 +55,7 @@ from app.models.unc_thread import UncSeries from app.schemas.augmented_part import AugmentedPartSyncResult ATTACHMENT_POINT_NAME = "Ocular" +UAC_ATTACHMENT_POINT_NAME = "Objective" MM_PER_INCH = 25.4 # e.g. @@ -118,6 +125,49 @@ def _parse_thread_attribute(value: str) -> tuple[str, dict] | None: return None +def _parse_uac_diameter_range(value: str | None) -> tuple[str, float, float] | None: + """Parse an 8-digit UACDiameterRangemm value into ("##.##-##.##", min, max). + + The source field is 8 digits with no separators: the first 4 are the + minimum diameter (2 whole + 2 decimal), the last 4 the maximum, e.g. + "03505500" -> ("03.50-55.00", 3.5, 55.0). + """ + if not value: + return None + digits = "".join(re.findall(r"\d", value)) + if len(digits) != 8: + return None + min_str = f"{digits[0:2]}.{digits[2:4]}" + max_str = f"{digits[4:6]}.{digits[6:8]}" + return f"{min_str}-{max_str}", float(min_str), float(max_str) + + +async def _add_uac_clamp_attachment_point( + db: AsyncSession, ap: AugmentedPart, ip: ImportedPart +) -> None: + """Add a second, "Objective"-named clamp attachment point derived from + ImportedPart.UACDiameterRangemm, if that field is populated.""" + parsed = _parse_uac_diameter_range(ip.UACDiameterRangemm) + if parsed is None: + return + _, min_mm, max_mm = parsed + min_in = min_mm / MM_PER_INCH + max_in = max_mm / MM_PER_INCH + + apn = await _get_or_create_attachment_point_name(db, UAC_ATTACHMENT_POINT_NAME) + clamp = await clamp_connection_crud.get_by_values(db, min_mm=min_mm, max_mm=max_mm) + if clamp is None: + clamp = await clamp_connection_crud.create( + db, min_mm=min_mm, max_mm=max_mm, min_in=min_in, max_in=max_in + ) + await attachment_point_crud.create( + db, + augmented_part_id=ap.id, + attachment_point_name_id=apn.id, + clamp_connection_id=clamp.id, + ) + + async def _get_or_create_attachment_point_name(db: AsyncSession, name: str): apn = await attachment_point_name_crud.get_by_name(db, name) if apn is None: @@ -233,6 +283,8 @@ async def sync_one( if manufacturer is None: manufacturer = await manufacturer_crud.create(db, name="Armament", url="") + uac_range = _parse_uac_diameter_range(ip.UACDiameterRangemm) + ap = AugmentedPart( sku=ip.InventoryID, description=description, @@ -241,6 +293,7 @@ async def sync_one( scope_model_id=None, part_type_id=part_type_id, sub_part_type_id=sub_part_type_id, + uac_diameter_range_mm=uac_range[0] if uac_range else None, ) db.add(ap) await db.flush() @@ -252,6 +305,7 @@ async def sync_one( ) ) await _add_attachment_point(db, ap, ip) + await _add_uac_clamp_attachment_point(db, ap, ip) await db.commit() return ap @@ -416,6 +470,8 @@ async def sync(db: AsyncSession) -> AugmentedPartSyncResult: if colour is None: colour = await part_colour_crud.create(db, name=colour_name) + uac_range = _parse_uac_diameter_range(ip.UACDiameterRangemm) + ap = AugmentedPart( sku=ip.InventoryID, description=description, @@ -424,6 +480,7 @@ async def sync(db: AsyncSession) -> AugmentedPartSyncResult: scope_model_id=None, part_type_id=part_type.id, sub_part_type_id=None, + uac_diameter_range_mm=uac_range[0] if uac_range else None, ) db.add(ap) await db.flush() @@ -435,6 +492,7 @@ async def sync(db: AsyncSession) -> AugmentedPartSyncResult: ) ) await _add_attachment_point(db, ap, ip) + await _add_uac_clamp_attachment_point(db, ap, ip) created += 1 await db.commit() diff --git a/tests/test_augmented_part_sync_uac.py b/tests/test_augmented_part_sync_uac.py new file mode 100644 index 0000000..4f09b07 --- /dev/null +++ b/tests/test_augmented_part_sync_uac.py @@ -0,0 +1,156 @@ +"""UACDiameterRangemm handling in the AugmentedPart sync service: the +reformatted range field on AugmentedPart, and the "Objective" clamp +attachment point it should produce.""" + +import pytest +from sqlalchemy import select + +from app.crud import attachment_point as attachment_point_crud +from app.crud import clamp_connection as clamp_connection_crud +from app.models.augmented_part import AugmentedPart +from app.models.imported_part import ImportedPart +from app.models.manufacturer import Manufacturer +from app.models.part_colour import PartColour +from app.models.part_type import PartType +from app.services import augmented_part_sync +from app.services.augmented_part_sync import _parse_uac_diameter_range + + +def test_parse_uac_diameter_range_valid_8_digits(): + assert _parse_uac_diameter_range("03505500") == ("03.50-55.00", 3.5, 55.0) + + +@pytest.mark.parametrize("value", [None, "", "1234567", "123456789", "abcdefgh"]) +def test_parse_uac_diameter_range_rejects_non_8_digit(value): + assert _parse_uac_diameter_range(value) is None + + +def test_parse_uac_diameter_range_strips_non_digit_separators(): + # Defensive: pull digits out even if the feed adds punctuation. + assert _parse_uac_diameter_range("0350-5500") == ("03.50-55.00", 3.5, 55.0) + + +async def _seed_reference_data(db): + manufacturer = Manufacturer(name="Acme", url="https://acme.test") + colour = PartColour(name="Black") + part_type = PartType(name="Scope") + db.add_all([manufacturer, colour, part_type]) + await db.flush() + return manufacturer, colour, part_type + + +@pytest.mark.asyncio +async def test_sync_populates_range_and_creates_objective_clamp_point( + db_sessionmaker, +): + async with db_sessionmaker() as db: + await _seed_reference_data(db) + db.add( + ImportedPart( + InventoryID="INV-UAC-1", + Description="Scope widget", + UACDiameterRangemm="03505500", + ) + ) + await db.commit() + + result = await augmented_part_sync.sync(db) + assert result.created == 1 + + ap = ( + await db.execute( + select(AugmentedPart).where(AugmentedPart.sku == "INV-UAC-1") + ) + ).scalar_one() + assert ap.uac_diameter_range_mm == "03.50-55.00" + + points = await attachment_point_crud.list_for_augmented_part(db, ap.id) + objective_points = [ + p for p in points if p.attachment_point_name.name == "Objective" + ] + assert len(objective_points) == 1 + clamp = await clamp_connection_crud.get( + db, objective_points[0].clamp_connection_id + ) + assert clamp.min_mm == 3.5 + assert clamp.max_mm == 55.0 + assert clamp.min_in == pytest.approx(3.5 / 25.4) + assert clamp.max_in == pytest.approx(55.0 / 25.4) + + +@pytest.mark.asyncio +async def test_sync_leaves_range_and_objective_point_unset_when_field_blank( + db_sessionmaker, +): + async with db_sessionmaker() as db: + await _seed_reference_data(db) + db.add(ImportedPart(InventoryID="INV-NO-UAC", Description="Scope widget")) + await db.commit() + + result = await augmented_part_sync.sync(db) + assert result.created == 1 + + ap = ( + await db.execute( + select(AugmentedPart).where(AugmentedPart.sku == "INV-NO-UAC") + ) + ).scalar_one() + assert ap.uac_diameter_range_mm is None + + points = await attachment_point_crud.list_for_augmented_part(db, ap.id) + assert all(p.attachment_point_name.name != "Objective" for p in points) + + +@pytest.mark.asyncio +async def test_sync_reuses_existing_clamp_connection_for_same_range(db_sessionmaker): + async with db_sessionmaker() as db: + await _seed_reference_data(db) + db.add_all( + [ + ImportedPart( + InventoryID="INV-UAC-A", + Description="Scope widget A", + UACDiameterRangemm="03505500", + ), + ImportedPart( + InventoryID="INV-UAC-B", + Description="Scope widget B", + UACDiameterRangemm="03505500", + ), + ] + ) + await db.commit() + + result = await augmented_part_sync.sync(db) + assert result.created == 2 + + clamps = await clamp_connection_crud.list_all(db) + assert len(clamps) == 1 + + +@pytest.mark.asyncio +async def test_sync_one_populates_range_and_objective_point(db_sessionmaker): + async with db_sessionmaker() as db: + manufacturer, colour, part_type = await _seed_reference_data(db) + db.add( + ImportedPart( + InventoryID="INV-UAC-MANUAL", + Description="Odd widget", + UACDiameterRangemm="10002000", + ) + ) + await db.commit() + + ap = await augmented_part_sync.sync_one( + db, + inventory_id="INV-UAC-MANUAL", + part_type_id=part_type.id, + sub_part_type_id=None, + ) + assert ap.uac_diameter_range_mm == "10.00-20.00" + + points = await attachment_point_crud.list_for_augmented_part(db, ap.id) + objective_points = [ + p for p in points if p.attachment_point_name.name == "Objective" + ] + assert len(objective_points) == 1