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'
[ ] 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
@@ -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())
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,
*,
+5
View File
@@ -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.
+1
View File
@@ -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
+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
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()
+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