From 413e9e8381da2fbf44775c10dd59e7a218e80372 Mon Sep 17 00:00:00 2001 From: Ian Roddis <31021769+iroddis@users.noreply.github.com> Date: Mon, 17 Aug 2026 22:16:53 -0300 Subject: [PATCH] Adding archived flag to augmented parts, fixing migrations so that creation from scratch is successful --- .../versions/0c3d1a5cff1f_add_part_colours.py | 14 +- .../5b6a7817b408_add_static_connections.py | 15 +- ...70e48bda8829_add_attachment_point_names.py | 14 +- .../7be5ef599d3f_add_imported_parts_tables.py | 28 +- .../8bc411b5814e_add_augmented_parts.py | 34 ++- ...f6e0b3d_add_archived_to_augmented_parts.py | 32 +++ .../adf4a5b9493e_add_attachment_points.py | 38 ++- .../b60d0aa3ceda_add_sub_part_types_table.py | 18 +- ...4cd5f_add_augmented_part_imported_parts.py | 19 +- .../c7882cedc9ae_add_clamp_connections.py | 16 +- .../fd5379986da4_add_audit_logs_table.py | 29 +- app/api/augmented_parts.py | 7 +- app/api/imported_parts.py | 17 +- app/crud/augmented_part.py | 30 +- app/crud/imported_part.py | 5 - app/models/augmented_part.py | 6 +- app/schemas/augmented_part.py | 4 +- app/services/augmented_part_sync.py | 134 ++++++--- app/services/scope_guide.py | 2 + app/templates/admin/_augmented_part_row.html | 13 + .../admin/_augmented_part_row_edit.html | 2 +- .../admin/_augmented_parts_table.html | 1 + .../admin/_augmented_parts_table_card.html | 3 +- .../admin/augmented_part_detail.html | 1 + app/templates/admin/augmented_parts.html | 2 +- app/web/admin.py | 80 +++++- tests/test_augmented_part_archiving.py | 257 ++++++++++++++++++ 27 files changed, 695 insertions(+), 126 deletions(-) create mode 100644 alembic/versions/9a1c2f6e0b3d_add_archived_to_augmented_parts.py create mode 100644 tests/test_augmented_part_archiving.py diff --git a/alembic/versions/0c3d1a5cff1f_add_part_colours.py b/alembic/versions/0c3d1a5cff1f_add_part_colours.py index 1cd2503..2a137d3 100644 --- a/alembic/versions/0c3d1a5cff1f_add_part_colours.py +++ b/alembic/versions/0c3d1a5cff1f_add_part_colours.py @@ -18,12 +18,14 @@ depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - pass - # ### end Alembic commands ### + op.create_table( + "part_colours", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("name", sa.String(), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("name"), + ) def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - pass - # ### end Alembic commands ### + op.drop_table("part_colours") diff --git a/alembic/versions/5b6a7817b408_add_static_connections.py b/alembic/versions/5b6a7817b408_add_static_connections.py index 2ef3379..64fbaf4 100644 --- a/alembic/versions/5b6a7817b408_add_static_connections.py +++ b/alembic/versions/5b6a7817b408_add_static_connections.py @@ -18,12 +18,15 @@ depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - pass - # ### end Alembic commands ### + op.create_table( + "static_connections", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("od_mm", sa.Float(), nullable=False), + sa.Column("od_in", sa.Float(), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("od_mm"), + ) def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - pass - # ### end Alembic commands ### + op.drop_table("static_connections") diff --git a/alembic/versions/70e48bda8829_add_attachment_point_names.py b/alembic/versions/70e48bda8829_add_attachment_point_names.py index 2334a4a..fac5e12 100644 --- a/alembic/versions/70e48bda8829_add_attachment_point_names.py +++ b/alembic/versions/70e48bda8829_add_attachment_point_names.py @@ -18,12 +18,14 @@ depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - pass - # ### end Alembic commands ### + op.create_table( + "attachment_point_names", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("name", sa.String(), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("name"), + ) def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - pass - # ### end Alembic commands ### + op.drop_table("attachment_point_names") diff --git a/alembic/versions/7be5ef599d3f_add_imported_parts_tables.py b/alembic/versions/7be5ef599d3f_add_imported_parts_tables.py index 0a80d93..6b81f02 100644 --- a/alembic/versions/7be5ef599d3f_add_imported_parts_tables.py +++ b/alembic/versions/7be5ef599d3f_add_imported_parts_tables.py @@ -18,12 +18,28 @@ depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - pass - # ### end Alembic commands ### + op.create_table( + "imported_parts", + sa.Column("InventoryID", sa.String(), nullable=False), + sa.Column("Description", sa.String(), nullable=True), + sa.Column("TBXColour", sa.String(), nullable=True), + sa.Column("ProductMaxThreadDepthmm", sa.String(), nullable=True), + sa.Column("ProductMaxThreadDepthin", sa.String(), nullable=True), + sa.Column("ProductODmm", sa.String(), nullable=True), + sa.Column("ProductODin", sa.String(), nullable=True), + sa.Column("TBXThreadAttribute", sa.String(), nullable=True), + sa.PrimaryKeyConstraint("InventoryID"), + ) + op.create_table( + "imported_part_config", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("odata_url", sa.String(), nullable=True), + sa.Column("username", sa.String(), nullable=True), + sa.Column("password", sa.String(), nullable=True), + sa.PrimaryKeyConstraint("id"), + ) def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - pass - # ### end Alembic commands ### + op.drop_table("imported_part_config") + op.drop_table("imported_parts") diff --git a/alembic/versions/8bc411b5814e_add_augmented_parts.py b/alembic/versions/8bc411b5814e_add_augmented_parts.py index 91a8c4c..89f9c97 100644 --- a/alembic/versions/8bc411b5814e_add_augmented_parts.py +++ b/alembic/versions/8bc411b5814e_add_augmented_parts.py @@ -18,12 +18,34 @@ depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - pass - # ### end Alembic commands ### + op.create_table( + "augmented_parts", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("sku", sa.String(), nullable=True), + sa.Column("description", sa.String(), nullable=False), + sa.Column("colour_id", sa.Integer(), nullable=False), + sa.Column("manufacturer_id", sa.Integer(), nullable=False), + sa.Column("scope_model_id", sa.Integer(), nullable=True), + sa.Column("part_type_id", sa.Integer(), nullable=False), + sa.Column("sub_part_type_id", sa.Integer(), nullable=True), + sa.ForeignKeyConstraint( + ["colour_id"], ["part_colours.id"], ondelete="RESTRICT" + ), + sa.ForeignKeyConstraint( + ["manufacturer_id"], ["manufacturers.id"], ondelete="RESTRICT" + ), + sa.ForeignKeyConstraint( + ["scope_model_id"], ["scope_models.id"], ondelete="SET NULL" + ), + sa.ForeignKeyConstraint( + ["part_type_id"], ["part_types.id"], ondelete="RESTRICT" + ), + sa.ForeignKeyConstraint( + ["sub_part_type_id"], ["sub_part_types.id"], ondelete="SET NULL" + ), + sa.PrimaryKeyConstraint("id"), + ) def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - pass - # ### end Alembic commands ### + op.drop_table("augmented_parts") diff --git a/alembic/versions/9a1c2f6e0b3d_add_archived_to_augmented_parts.py b/alembic/versions/9a1c2f6e0b3d_add_archived_to_augmented_parts.py new file mode 100644 index 0000000..7b3ea40 --- /dev/null +++ b/alembic/versions/9a1c2f6e0b3d_add_archived_to_augmented_parts.py @@ -0,0 +1,32 @@ +"""add_archived_to_augmented_parts + +Revision ID: 9a1c2f6e0b3d +Revises: 235d823a56ee +Create Date: 2026-08-17 21:30:00.000000 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "9a1c2f6e0b3d" +down_revision: str | None = "235d823a56ee" +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( + "archived", sa.Boolean(), nullable=False, server_default=sa.false() + ) + ) + + +def downgrade() -> None: + with op.batch_alter_table("augmented_parts", schema=None) as batch_op: + batch_op.drop_column("archived") diff --git a/alembic/versions/adf4a5b9493e_add_attachment_points.py b/alembic/versions/adf4a5b9493e_add_attachment_points.py index e705b30..9680927 100644 --- a/alembic/versions/adf4a5b9493e_add_attachment_points.py +++ b/alembic/versions/adf4a5b9493e_add_attachment_points.py @@ -18,12 +18,38 @@ depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - pass - # ### end Alembic commands ### + op.create_table( + "attachment_points", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("augmented_part_id", sa.Integer(), nullable=False), + sa.Column("attachment_point_name_id", sa.Integer(), nullable=False), + sa.Column("iso_thread_id", sa.Integer(), nullable=True), + sa.Column("unc_thread_id", sa.Integer(), nullable=True), + sa.Column("clamp_connection_id", sa.Integer(), nullable=True), + sa.Column("static_connection_id", sa.Integer(), nullable=True), + sa.ForeignKeyConstraint( + ["augmented_part_id"], ["augmented_parts.id"], ondelete="CASCADE" + ), + sa.ForeignKeyConstraint( + ["attachment_point_name_id"], + ["attachment_point_names.id"], + ondelete="RESTRICT", + ), + sa.ForeignKeyConstraint( + ["iso_thread_id"], ["iso_threads.id"], ondelete="RESTRICT" + ), + sa.ForeignKeyConstraint( + ["unc_thread_id"], ["unc_threads.id"], ondelete="RESTRICT" + ), + sa.ForeignKeyConstraint( + ["clamp_connection_id"], ["clamp_connections.id"], ondelete="RESTRICT" + ), + sa.ForeignKeyConstraint( + ["static_connection_id"], ["static_connections.id"], ondelete="RESTRICT" + ), + sa.PrimaryKeyConstraint("id"), + ) def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - pass - # ### end Alembic commands ### + op.drop_table("attachment_points") diff --git a/alembic/versions/b60d0aa3ceda_add_sub_part_types_table.py b/alembic/versions/b60d0aa3ceda_add_sub_part_types_table.py index 21db083..ff8315d 100644 --- a/alembic/versions/b60d0aa3ceda_add_sub_part_types_table.py +++ b/alembic/versions/b60d0aa3ceda_add_sub_part_types_table.py @@ -18,12 +18,18 @@ depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - pass - # ### end Alembic commands ### + op.create_table( + "sub_part_types", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("name", sa.String(), nullable=False), + sa.Column("part_type_id", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint( + ["part_type_id"], ["part_types.id"], ondelete="RESTRICT" + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("name", "part_type_id"), + ) def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - pass - # ### end Alembic commands ### + op.drop_table("sub_part_types") diff --git a/alembic/versions/bfbce324cd5f_add_augmented_part_imported_parts.py b/alembic/versions/bfbce324cd5f_add_augmented_part_imported_parts.py index 84516e0..57e34ef 100644 --- a/alembic/versions/bfbce324cd5f_add_augmented_part_imported_parts.py +++ b/alembic/versions/bfbce324cd5f_add_augmented_part_imported_parts.py @@ -18,12 +18,19 @@ depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - pass - # ### end Alembic commands ### + op.create_table( + "augmented_part_imported_parts", + sa.Column("augmented_part_id", sa.Integer(), nullable=False), + sa.Column("imported_part_id", sa.String(), nullable=False), + sa.ForeignKeyConstraint( + ["augmented_part_id"], ["augmented_parts.id"], ondelete="CASCADE" + ), + sa.ForeignKeyConstraint( + ["imported_part_id"], ["imported_parts.InventoryID"], ondelete="CASCADE" + ), + sa.PrimaryKeyConstraint("augmented_part_id", "imported_part_id"), + ) def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - pass - # ### end Alembic commands ### + op.drop_table("augmented_part_imported_parts") diff --git a/alembic/versions/c7882cedc9ae_add_clamp_connections.py b/alembic/versions/c7882cedc9ae_add_clamp_connections.py index 8e542b8..54dba3e 100644 --- a/alembic/versions/c7882cedc9ae_add_clamp_connections.py +++ b/alembic/versions/c7882cedc9ae_add_clamp_connections.py @@ -18,12 +18,16 @@ depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - pass - # ### end Alembic commands ### + op.create_table( + "clamp_connections", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("min_mm", sa.Float(), nullable=True), + sa.Column("max_mm", sa.Float(), nullable=True), + sa.Column("min_in", sa.Float(), nullable=True), + sa.Column("max_in", sa.Float(), nullable=True), + sa.PrimaryKeyConstraint("id"), + ) def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - pass - # ### end Alembic commands ### + op.drop_table("clamp_connections") diff --git a/alembic/versions/fd5379986da4_add_audit_logs_table.py b/alembic/versions/fd5379986da4_add_audit_logs_table.py index 21cd6a5..39affea 100644 --- a/alembic/versions/fd5379986da4_add_audit_logs_table.py +++ b/alembic/versions/fd5379986da4_add_audit_logs_table.py @@ -18,12 +18,29 @@ depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - pass - # ### end Alembic commands ### + op.create_table( + "audit_logs", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + sa.Column("user_id", sa.Integer(), nullable=True), + sa.Column("user_email", sa.String(length=255), nullable=False), + sa.Column("action", sa.String(length=16), nullable=False), + sa.Column("resource_type", sa.String(length=64), nullable=False), + sa.Column("resource_id", sa.Integer(), nullable=True), + sa.Column("description", sa.String(), nullable=False), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="SET NULL"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_audit_logs_created_at", "audit_logs", ["created_at"]) + op.create_index("ix_audit_logs_user_id", "audit_logs", ["user_id"]) def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - pass - # ### end Alembic commands ### + op.drop_index("ix_audit_logs_user_id", table_name="audit_logs") + op.drop_index("ix_audit_logs_created_at", table_name="audit_logs") + op.drop_table("audit_logs") diff --git a/app/api/augmented_parts.py b/app/api/augmented_parts.py index c624529..17380c5 100644 --- a/app/api/augmented_parts.py +++ b/app/api/augmented_parts.py @@ -24,10 +24,13 @@ router = APIRouter() async def list_augmented_parts( skip: int = 0, limit: int = 100, + include_archived: bool = False, db: AsyncSession = Depends(get_db), _: User = Depends(require_permission("augmented_parts:read")), ): - return await augmented_part_crud.list_all(db, skip=skip, limit=limit) + return await augmented_part_crud.list_all( + db, skip=skip, limit=limit, include_archived=include_archived + ) @router.post("", response_model=AugmentedPartRead, status_code=status.HTTP_201_CREATED) @@ -72,7 +75,7 @@ async def sync_augmented_parts( resource_type="AugmentedPart", resource_id=None, description=( - f"Sync: {result.created} created, {result.deleted} deleted, " + f"Sync: {result.created} created, {result.archived} archived, " f"{result.skipped} skipped (no type match)" ), ) diff --git a/app/api/imported_parts.py b/app/api/imported_parts.py index d91ddcb..90e4788 100644 --- a/app/api/imported_parts.py +++ b/app/api/imported_parts.py @@ -13,6 +13,7 @@ from app.schemas.imported_part import ( ImportedPartRead, SyncResult, ) +from app.services import augmented_part_sync from app.services import odata as odata_service router = APIRouter() @@ -128,7 +129,9 @@ async def delete_imported_part( raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Imported part not found" ) - await imported_part_crud.delete(db, part) + archived = await augmented_part_sync.delete_imported_part_and_archive_orphans( + db, part + ) await audit_crud.log( db, actor=actor, @@ -137,3 +140,15 @@ async def delete_imported_part( resource_id=None, description=audit_crud.describe_delete("ImportedPart", 0, inventory_id), ) + if archived: + await audit_crud.log( + db, + actor=actor, + action="update", + resource_type="AugmentedPart", + resource_id=None, + description=( + f"Archived {archived} augmented part(s) left with no remaining " + f"imported parts after deleting {inventory_id!r}" + ), + ) diff --git a/app/crud/augmented_part.py b/app/crud/augmented_part.py index 0faa807..b3a4f89 100644 --- a/app/crud/augmented_part.py +++ b/app/crud/augmented_part.py @@ -58,14 +58,15 @@ async def get_by_sku(db: AsyncSession, sku: str) -> AugmentedPart | None: async def list_all( - db: AsyncSession, skip: int = 0, limit: int = 100 + db: AsyncSession, + skip: int = 0, + limit: int = 100, + include_archived: bool = False, ) -> list[AugmentedPart]: - result = await db.execute( - select(AugmentedPart) - .order_by(AugmentedPart.description) - .offset(skip) - .limit(limit) - ) + q = select(AugmentedPart).order_by(AugmentedPart.description) + if not include_archived: + q = q.where(AugmentedPart.archived.is_(False)) + result = await db.execute(q.offset(skip).limit(limit)) return list(result.scalars().all()) @@ -85,6 +86,7 @@ async def list_filtered( scope_model_id: int | None = None, part_type_id: int | None = None, sub_part_type_id: int | None = None, + include_archived: bool = False, sort: str = "description", direction: str = "asc", skip: int = 0, @@ -106,6 +108,8 @@ async def list_filtered( q = q.where(AugmentedPart.part_type_id == part_type_id) if sub_part_type_id is not None: q = q.where(AugmentedPart.sub_part_type_id == sub_part_type_id) + if not include_archived: + q = q.where(AugmentedPart.archived.is_(False)) q = _apply_sort(q, sort, direction) result = await db.execute(q.offset(skip).limit(limit)) return list(result.scalars().all()) @@ -119,6 +123,7 @@ async def count_filtered( scope_model_id: int | None = None, part_type_id: int | None = None, sub_part_type_id: int | None = None, + include_archived: bool = False, ) -> int: from sqlalchemy import func @@ -138,6 +143,8 @@ async def count_filtered( q = q.where(AugmentedPart.part_type_id == part_type_id) if sub_part_type_id is not None: q = q.where(AugmentedPart.sub_part_type_id == sub_part_type_id) + if not include_archived: + q = q.where(AugmentedPart.archived.is_(False)) result = await db.execute(q) return result.scalar_one() @@ -251,6 +258,15 @@ async def get_unlinked_imported_parts(db: AsyncSession) -> list[ImportedPart]: return list(result.scalars().all()) +async def set_archived( + db: AsyncSession, ap: AugmentedPart, archived: bool +) -> AugmentedPart: + ap.archived = archived + await db.commit() + await db.refresh(ap, attribute_names=_RELATIONSHIPS) + return ap + + async def delete(db: AsyncSession, ap: AugmentedPart) -> None: await db.delete(ap) await db.commit() diff --git a/app/crud/imported_part.py b/app/crud/imported_part.py index 58a65c6..93721e7 100644 --- a/app/crud/imported_part.py +++ b/app/crud/imported_part.py @@ -105,11 +105,6 @@ async def count_filtered(db: AsyncSession, description: str = "") -> int: return result.scalar_one() -async def delete(db: AsyncSession, part: ImportedPart) -> None: - await db.delete(part) - await db.commit() - - async def upsert_bulk(db: AsyncSession, records: list[dict]) -> tuple[int, int]: """Insert-or-update records keyed on InventoryID. Returns (created, updated).""" if not records: diff --git a/app/models/augmented_part.py b/app/models/augmented_part.py index 6425869..9eae510 100644 --- a/app/models/augmented_part.py +++ b/app/models/augmented_part.py @@ -1,6 +1,6 @@ from datetime import datetime -from sqlalchemy import DateTime, ForeignKey, String, Text, func +from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text, func from sqlalchemy.orm import Mapped, mapped_column, relationship from app.core.database import Base @@ -36,6 +36,10 @@ class AugmentedPart(Base): 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. + archived: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) # linked imported parts — not eagerly loaded; used by sync service only imported_parts: Mapped[list[ImportedPart]] = relationship( diff --git a/app/schemas/augmented_part.py b/app/schemas/augmented_part.py index 2f389ed..5596a33 100644 --- a/app/schemas/augmented_part.py +++ b/app/schemas/augmented_part.py @@ -53,11 +53,12 @@ class AugmentedPartUpdate(BaseModel): scope_model_id: int | None = None part_type_id: int | None = None sub_part_type_id: int | None = None + archived: bool | None = None class AugmentedPartSyncResult(BaseModel): created: int - deleted: int + archived: int skipped: int @@ -65,6 +66,7 @@ class AugmentedPartRead(AugmentedPartBase): model_config = ConfigDict(from_attributes=True) id: int + archived: bool colour: PartColourRead manufacturer: ManufacturerRead scope_model: ScopeModelRead | None diff --git a/app/services/augmented_part_sync.py b/app/services/augmented_part_sync.py index 5fcd9cd..0cd0a86 100644 --- a/app/services/augmented_part_sync.py +++ b/app/services/augmented_part_sync.py @@ -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) diff --git a/app/services/scope_guide.py b/app/services/scope_guide.py index 62a4154..363f1c2 100644 --- a/app/services/scope_guide.py +++ b/app/services/scope_guide.py @@ -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 diff --git a/app/templates/admin/_augmented_part_row.html b/app/templates/admin/_augmented_part_row.html index fa505ea..da689f2 100644 --- a/app/templates/admin/_augmented_part_row.html +++ b/app/templates/admin/_augmented_part_row.html @@ -7,11 +7,24 @@