Sync with imported parts now properly updates augmented parts and creates any new attachment points

This commit is contained in:
Ian Roddis committed 2026-09-21 13:50:06 -03:00
1 parent d23f5b9e82
commit 13b64a2d17
6 files changed
+272 -8

No files matched your search

+2 -2
View File
@@ -86,8 +86,8 @@ async def sync_augmented_parts(
resource_type="AugmentedPart",
resource_id=None,
description=(
f"Sync: {result.created} created, {result.archived} archived, "
f"{result.skipped} skipped (no type match)"
f"Sync: {result.created} created, {result.updated} updated, "
f"{result.archived} archived, {result.skipped} skipped (no type match)"
),
)
return result
+1
View File
@@ -58,6 +58,7 @@ class AugmentedPartUpdate(BaseModel):
class AugmentedPartSyncResult(BaseModel):
created: int
updated: int
archived: int
skipped: int
+96 -4
View File
@@ -9,7 +9,24 @@ Phase 1 – archive stale entries
remaining associations (i.e. it was created purely from now-deleted
ImportedParts), preserving its fit/attachment-point history.
Phase 2 – create entries for new ImportedParts
Phase 2 – refresh entries for already-linked ImportedParts
• For each ImportedPart already linked to an AugmentedPart (from a prior
sync run), refresh the fields sync itself owns — description, colour,
and uac_diameter_range_mm (see field mapping below) — to match the
ImportedPart's current values.
• Never touches "extra"/admin-owned AugmentedPart fields: part_type_id,
sub_part_type_id, scope_model_id, manufacturer_id, sku, archived. Those
are only ever set once, at creation (phase 3) or via the admin API/UI.
• Adds an "Ocular" and/or "Objective" attachment point if the part doesn't
already have one of that name and the current ImportedPart data now
supports deriving one. Never deletes or modifies an existing attachment
point, even if the source data that produced it has since changed.
• If an AugmentedPart has more than one backing ImportedPart, the one with
the lexicographically smallest InventoryID is used as the source (this
doesn't happen via any current creation path, but the data model and
phase 1's archiving logic both allow for it).
Phase 3 – create entries for new ImportedParts
• Any ImportedPart not yet linked to an AugmentedPart gets one created.
• Description ← ImportedPart.Description (fallback: InventoryID)
• Colour ← ImportedPart.TBXColour (fallback: "Unknown"; created if needed)
@@ -40,6 +57,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.crud import attachment_point as attachment_point_crud
from app.crud import attachment_point_name as attachment_point_name_crud
from app.crud import augmented_part as augmented_part_crud
from app.crud import clamp_connection as clamp_connection_crud
from app.crud import iso_thread as iso_thread_crud
from app.crud import manufacturer as manufacturer_crud
@@ -408,6 +426,52 @@ async def delete_imported_part_and_archive_orphans(
return archived
async def _refresh_augmented_part(
db: AsyncSession, ap: AugmentedPart, ip: ImportedPart
) -> bool:
"""Refresh the AugmentedPart fields sync owns (description, colour, UAC
diameter range) from the current state of `ip`, and add an "Ocular"
and/or "Objective" attachment point if the part doesn't already have one
and the current data now supports deriving one.
Never touches "extra" AugmentedPart fields (part_type_id, sub_part_type_id,
scope_model_id, manufacturer_id, sku, archived) and never deletes or
replaces an attachment point that already exists. Returns True if
anything changed.
"""
description = (ip.Description or "").strip() or ip.InventoryID
colour_name = (ip.TBXColour or "").strip() or "Unknown"
colour = await part_colour_crud.get_by_name(db, colour_name)
if colour is None:
colour = await part_colour_crud.create(db, name=colour_name)
uac_range = _parse_uac_diameter_range(ip.UACDiameterRangemm)
uac_value = uac_range[0] if uac_range else None
changes = {}
if ap.description != description:
changes["description"] = description
if ap.colour_id != colour.id:
changes["colour_id"] = colour.id
if ap.uac_diameter_range_mm != uac_value:
changes["uac_diameter_range_mm"] = uac_value
if changes:
await augmented_part_crud.update(db, ap, changes)
existing_points = await attachment_point_crud.list_for_augmented_part(db, ap.id)
existing_names = {p.attachment_point_name.name for p in existing_points}
points_before = len(existing_points)
if ATTACHMENT_POINT_NAME not in existing_names:
await _add_attachment_point(db, ap, ip)
if UAC_ATTACHMENT_POINT_NAME not in existing_names:
await _add_uac_clamp_attachment_point(db, ap, ip)
points_after = await attachment_point_crud.list_for_augmented_part(db, ap.id)
return bool(changes) or len(points_after) > points_before
async def sync(db: AsyncSession) -> AugmentedPartSyncResult:
# ------------------------------------------------------------------ phase 1
@@ -430,19 +494,45 @@ async def sync(db: AsyncSession) -> AugmentedPartSyncResult:
# ------------------------------------------------------------------ phase 2
# Re-query linked IDs after phase 1 cleanup
# Re-query linked IDs after phase 1 cleanup — these are the ImportedParts
# already linked to an AugmentedPart from a prior run (not created below).
result = await db.execute(
select(augmented_part_imported_parts.c.imported_part_id).distinct()
)
now_linked_ids: set[str] = {row[0] for row in result.all()}
unlinked_ids = current_ids - now_linked_ids
updated = 0
if now_linked_ids:
result = await db.execute(
select(
augmented_part_imported_parts.c.augmented_part_id,
augmented_part_imported_parts.c.imported_part_id,
).where(
augmented_part_imported_parts.c.imported_part_id.in_(now_linked_ids)
)
)
ip_ids_by_ap_id: dict[int, list[str]] = {}
for ap_id, ip_id in result.all():
ip_ids_by_ap_id.setdefault(ap_id, []).append(ip_id)
for ap_id, ip_ids in ip_ids_by_ap_id.items():
ap = await db.get(AugmentedPart, ap_id)
ip = await db.get(ImportedPart, min(ip_ids))
if ap is None or ip is None:
continue
if await _refresh_augmented_part(db, ap, ip):
updated += 1
# ------------------------------------------------------------------ phase 3
created = 0
skipped = 0
if not unlinked_ids:
return AugmentedPartSyncResult(
created=created, archived=archived, skipped=skipped
created=created, updated=updated, archived=archived, skipped=skipped
)
result = await db.execute(
@@ -497,4 +587,6 @@ async def sync(db: AsyncSession) -> AugmentedPartSyncResult:
await db.commit()
return AugmentedPartSyncResult(created=created, archived=archived, skipped=skipped)
return AugmentedPartSyncResult(
created=created, updated=updated, archived=archived, skipped=skipped
)
+3 -1
View File
@@ -1789,7 +1789,8 @@ async def sync_augmented_parts(
resource_type="AugmentedPart",
resource_id=None,
description=(
f"Sync: {result.created} created, {result.archived} archived, "
f"Sync: {result.created} created, {result.updated} updated, "
f"{result.archived} archived, "
f"{result.skipped} skipped (no type match)"
),
)
@@ -1797,6 +1798,7 @@ async def sync_augmented_parts(
job_id,
message=(
f"Sync complete: {result.created} created, "
f"{result.updated} updated, "
f"{result.archived} archived"
+ (
f", {result.skipped} skipped (type not recognised)"
+3 -1
View File
@@ -234,7 +234,8 @@ async def dashboard_sync_augmented_parts(
resource_type="AugmentedPart",
resource_id=None,
description=(
f"Sync: {result.created} created, {result.archived} archived, "
f"Sync: {result.created} created, {result.updated} updated, "
f"{result.archived} archived, "
f"{result.skipped} skipped (no type match)"
),
)
@@ -242,6 +243,7 @@ async def dashboard_sync_augmented_parts(
job_id,
message=(
f"Sync complete: {result.created} created, "
f"{result.updated} updated, "
f"{result.archived} archived"
+ (
f", {result.skipped} skipped (type not recognised)"