Adding archived flag to augmented parts, fixing migrations so that creation from scratch is successful

This commit is contained in:
Ian Roddis committed 2026-08-17 22:16:53 -03:00
1 parent 740dfc97c7
commit 413e9e8381
27 files changed
+695 -126

No files matched your search

+94 -40
View File
@@ -2,11 +2,12 @@
Algorithm
---------
Phase 1 – prune stale entries
Phase 1 – archive stale entries
• Find association rows whose imported_part_id no longer exists.
• Delete those rows.
• Delete any AugmentedPart that ends up with zero remaining associations
(i.e. it was created purely from now-deleted ImportedParts).
• Archive (rather than delete) any AugmentedPart that ends up with zero
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
• Any ImportedPart not yet linked to an AugmentedPart gets one created.
@@ -33,12 +34,12 @@ 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 clamp_connection as clamp_connection_crud
from app.crud import iso_thread as iso_thread_crud
from app.crud import manufacturer as manufacturer_crud
from app.crud import part_colour as part_colour_crud
from app.crud import part_type as part_type_crud
from app.crud import static_connection as static_connection_crud
from app.crud import clamp_connection as clamp_connection_crud
from app.crud import unc_thread as unc_thread_crud
from app.models.associations import augmented_part_imported_parts
from app.models.augmented_part import AugmentedPart
@@ -268,6 +269,91 @@ def _infer_part_type(description: str, part_types: list[PartType]) -> PartType |
return None
async def _archive_orphans(db: AsyncSession, candidate_ap_ids: set[int]) -> int:
"""Archive any of `candidate_ap_ids` left with zero linked ImportedParts.
Idempotent: already-archived parts and parts that still have a link are
left untouched. Does not commit — the caller commits as part of its own
transaction.
"""
archived = 0
for ap_id in candidate_ap_ids:
result = await db.execute(
select(func.count())
.select_from(augmented_part_imported_parts)
.where(augmented_part_imported_parts.c.augmented_part_id == ap_id)
)
if result.scalar() == 0:
ap = await db.get(AugmentedPart, ap_id)
if ap is not None and not ap.archived:
ap.archived = True
archived += 1
return archived
async def _detach_and_archive_orphans(
db: AsyncSession, imported_part_ids: set[str]
) -> int:
"""Detach `imported_part_ids` from any AugmentedPart association and archive
AugmentedParts left with zero remaining ImportedPart links as a result.
Deletes the association rows directly rather than relying on the
ImportedPart row's ON DELETE CASCADE, since SQLite (this app's dev/test
database) doesn't enforce FK actions by default. Safe to call whether or
not the ImportedPart row(s) still exist. Does not commit — the caller
commits as part of its own transaction.
"""
if not imported_part_ids:
return 0
result = await db.execute(
select(augmented_part_imported_parts.c.augmented_part_id).where(
augmented_part_imported_parts.c.imported_part_id.in_(imported_part_ids)
)
)
candidate_ap_ids: set[int] = {row[0] for row in result.all()}
await db.execute(
augmented_part_imported_parts.delete().where(
augmented_part_imported_parts.c.imported_part_id.in_(imported_part_ids)
)
)
await db.flush()
return await _archive_orphans(db, candidate_ap_ids)
async def delete_imported_part_and_archive_orphans(
db: AsyncSession, imported_part: ImportedPart
) -> int:
"""Delete `imported_part` and archive any AugmentedPart left with no
remaining ImportedPart links as a result.
Captures affected AugmentedPart ids before deleting so this is correct
regardless of whether the DB enforces the association table's ON DELETE
CASCADE. Returns the number of AugmentedParts newly archived.
"""
inventory_id = imported_part.InventoryID
result = await db.execute(
select(augmented_part_imported_parts.c.augmented_part_id).where(
augmented_part_imported_parts.c.imported_part_id == inventory_id
)
)
candidate_ap_ids: set[int] = {row[0] for row in result.all()}
await db.delete(imported_part)
await db.execute(
augmented_part_imported_parts.delete().where(
augmented_part_imported_parts.c.imported_part_id == inventory_id
)
)
await db.flush()
archived = await _archive_orphans(db, candidate_ap_ids)
await db.commit()
return archived
async def sync(db: AsyncSession) -> AugmentedPartSyncResult:
# ------------------------------------------------------------------ phase 1
@@ -282,42 +368,10 @@ async def sync(db: AsyncSession) -> AugmentedPartSyncResult:
current_ids: set[str] = {row[0] for row in result.all()}
orphaned_import_ids = all_linked_ids - current_ids
deleted = 0
archived = 0
if orphaned_import_ids:
# Which AugmentedParts are affected?
result = await db.execute(
select(augmented_part_imported_parts.c.augmented_part_id).where(
augmented_part_imported_parts.c.imported_part_id.in_(
orphaned_import_ids
)
)
)
orphaned_ap_ids: set[int] = {row[0] for row in result.all()}
# Remove the stale association rows
await db.execute(
augmented_part_imported_parts.delete().where(
augmented_part_imported_parts.c.imported_part_id.in_(
orphaned_import_ids
)
)
)
await db.flush()
# Delete AugmentedParts that now have no remaining associations
for ap_id in orphaned_ap_ids:
result = await db.execute(
select(func.count())
.select_from(augmented_part_imported_parts)
.where(augmented_part_imported_parts.c.augmented_part_id == ap_id)
)
if result.scalar() == 0:
ap = await db.get(AugmentedPart, ap_id)
if ap is not None:
await db.delete(ap)
deleted += 1
archived = await _detach_and_archive_orphans(db, orphaned_import_ids)
await db.commit()
# ------------------------------------------------------------------ phase 2
@@ -334,7 +388,7 @@ async def sync(db: AsyncSession) -> AugmentedPartSyncResult:
if not unlinked_ids:
return AugmentedPartSyncResult(
created=created, deleted=deleted, skipped=skipped
created=created, archived=archived, skipped=skipped
)
result = await db.execute(
@@ -385,4 +439,4 @@ async def sync(db: AsyncSession) -> AugmentedPartSyncResult:
await db.commit()
return AugmentedPartSyncResult(created=created, deleted=deleted, skipped=skipped)
return AugmentedPartSyncResult(created=created, archived=archived, skipped=skipped)
+2
View File
@@ -113,6 +113,8 @@ async def build(
other = other_parts.get(other_id)
if other is None or other.part_type_id == scope_type.id:
continue # scope-to-scope fits have no column
if other.archived:
continue # archived parts don't show up as compatible accessories
by_type = cell_map[scope_id].setdefault(other.part_type_id, {})
existing = by_type.get(other.id)
label = fit.label.value