Adding UAC Diameter Range MM to augmented parts
This commit is contained in:
1 parent
597cf68157
commit
d23f5b9e82
7 files changed
+263
-2
No files matched your search
@@ -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")
|
||||
@@ -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,
|
||||
*,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user