Adding UAC Diameter Range MM to augmented parts

This commit is contained in:
Ian Roddis committed 2026-09-21 13:38:13 -03:00
1 parent 597cf68157
commit d23f5b9e82
7 files changed
+263 -2

No files matched your search

+2 -2
View File
@@ -1,7 +1,7 @@
[ ] Drop 'id' from UI tables, add 'date added / updated' [ ] 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 [ ] 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) [ ] Add `preferred_part` to augmented parts, used to order matches (e.g. double tab vs single tab)
[ ] Adapter Rings [ ] Adapter Rings
[ ] Should be married to a specific flip cover (a, b), not (a)x(b) cartesian product [ ] Should be married to a specific flip cover (a, b), not (a)x(b) cartesian product
@@ -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")
+11
View File
@@ -22,6 +22,17 @@ async def list_all(
return list(result.scalars().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( async def create(
db: AsyncSession, db: AsyncSession,
*, *,
+5
View File
@@ -38,6 +38,11 @@ class AugmentedPart(Base):
nullable=True, nullable=True,
default=None, 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 # Set instead of deleting the row when the last backing ImportedPart is
# removed, so fit/attachment-point history is preserved. Hidden from list # removed, so fit/attachment-point history is preserved. Hidden from list
# views by default. # views by default.
+1
View File
@@ -67,6 +67,7 @@ class AugmentedPartRead(AugmentedPartBase):
id: int id: int
archived: bool archived: bool
uac_diameter_range_mm: str | None
colour: PartColourRead colour: PartColourRead
manufacturer: ManufacturerRead manufacturer: ManufacturerRead
scope_model: ScopeModelRead | None scope_model: ScopeModelRead | None
+58
View File
@@ -25,6 +25,12 @@ Phase 2 – create entries for new ImportedParts
unit is missing is derived via mm/in conversion) if the unit is missing is derived via mm/in conversion) if the
thread attribute is absent or unparseable thread attribute is absent or unparseable
Skipped entirely if none of those fields are usable. 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 import re
@@ -49,6 +55,7 @@ from app.models.unc_thread import UncSeries
from app.schemas.augmented_part import AugmentedPartSyncResult from app.schemas.augmented_part import AugmentedPartSyncResult
ATTACHMENT_POINT_NAME = "Ocular" ATTACHMENT_POINT_NAME = "Ocular"
UAC_ATTACHMENT_POINT_NAME = "Objective"
MM_PER_INCH = 25.4 MM_PER_INCH = 25.4
# e.g. # e.g.
@@ -118,6 +125,49 @@ def _parse_thread_attribute(value: str) -> tuple[str, dict] | None:
return 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): async def _get_or_create_attachment_point_name(db: AsyncSession, name: str):
apn = await attachment_point_name_crud.get_by_name(db, name) apn = await attachment_point_name_crud.get_by_name(db, name)
if apn is None: if apn is None:
@@ -233,6 +283,8 @@ async def sync_one(
if manufacturer is None: if manufacturer is None:
manufacturer = await manufacturer_crud.create(db, name="Armament", url="") manufacturer = await manufacturer_crud.create(db, name="Armament", url="")
uac_range = _parse_uac_diameter_range(ip.UACDiameterRangemm)
ap = AugmentedPart( ap = AugmentedPart(
sku=ip.InventoryID, sku=ip.InventoryID,
description=description, description=description,
@@ -241,6 +293,7 @@ async def sync_one(
scope_model_id=None, scope_model_id=None,
part_type_id=part_type_id, part_type_id=part_type_id,
sub_part_type_id=sub_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) db.add(ap)
await db.flush() await db.flush()
@@ -252,6 +305,7 @@ async def sync_one(
) )
) )
await _add_attachment_point(db, ap, ip) await _add_attachment_point(db, ap, ip)
await _add_uac_clamp_attachment_point(db, ap, ip)
await db.commit() await db.commit()
return ap return ap
@@ -416,6 +470,8 @@ async def sync(db: AsyncSession) -> AugmentedPartSyncResult:
if colour is None: if colour is None:
colour = await part_colour_crud.create(db, name=colour_name) colour = await part_colour_crud.create(db, name=colour_name)
uac_range = _parse_uac_diameter_range(ip.UACDiameterRangemm)
ap = AugmentedPart( ap = AugmentedPart(
sku=ip.InventoryID, sku=ip.InventoryID,
description=description, description=description,
@@ -424,6 +480,7 @@ async def sync(db: AsyncSession) -> AugmentedPartSyncResult:
scope_model_id=None, scope_model_id=None,
part_type_id=part_type.id, part_type_id=part_type.id,
sub_part_type_id=None, sub_part_type_id=None,
uac_diameter_range_mm=uac_range[0] if uac_range else None,
) )
db.add(ap) db.add(ap)
await db.flush() await db.flush()
@@ -435,6 +492,7 @@ async def sync(db: AsyncSession) -> AugmentedPartSyncResult:
) )
) )
await _add_attachment_point(db, ap, ip) await _add_attachment_point(db, ap, ip)
await _add_uac_clamp_attachment_point(db, ap, ip)
created += 1 created += 1
await db.commit() await db.commit()
+156
View File
@@ -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