diff --git a/alembic/versions/bfbce324cd5f_add_augmented_part_imported_parts.py b/alembic/versions/bfbce324cd5f_add_augmented_part_imported_parts.py new file mode 100644 index 0000000..85dabea --- /dev/null +++ b/alembic/versions/bfbce324cd5f_add_augmented_part_imported_parts.py @@ -0,0 +1,29 @@ +"""add augmented_part_imported_parts + +Revision ID: bfbce324cd5f +Revises: 8bc411b5814e +Create Date: 2026-07-02 12:46:27.493942 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'bfbce324cd5f' +down_revision: Union[str, None] = '8bc411b5814e' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### diff --git a/app/api/augmented_parts.py b/app/api/augmented_parts.py index 87b149f..32951bc 100644 --- a/app/api/augmented_parts.py +++ b/app/api/augmented_parts.py @@ -9,8 +9,10 @@ from app.models.user import User from app.schemas.augmented_part import ( AugmentedPartCreate, AugmentedPartRead, + AugmentedPartSyncResult, AugmentedPartUpdate, ) +from app.services import augmented_part_sync router = APIRouter() @@ -49,6 +51,23 @@ async def create_augmented_part( return ap +@router.post("/sync", response_model=AugmentedPartSyncResult) +async def sync_augmented_parts( + db: AsyncSession = Depends(get_db), + actor: User = Depends(require_permission("augmented_parts:write")), +): + result = await augmented_part_sync.sync(db) + await audit_crud.log( + db, actor=actor, action="update", resource_type="AugmentedPart", + resource_id=None, + description=( + f"Sync: {result.created} created, {result.deleted} deleted, " + f"{result.skipped} skipped (no type match)" + ), + ) + return result + + @router.get("/{augmented_part_id}", response_model=AugmentedPartRead) async def get_augmented_part( augmented_part_id: int, diff --git a/app/crud/manufacturer.py b/app/crud/manufacturer.py index d3e1579..95f9d0e 100644 --- a/app/crud/manufacturer.py +++ b/app/crud/manufacturer.py @@ -8,6 +8,13 @@ async def get(db: AsyncSession, manufacturer_id: int) -> Manufacturer | None: return await db.get(Manufacturer, manufacturer_id) +async def get_by_name(db: AsyncSession, name: str) -> Manufacturer | None: + result = await db.execute( + select(Manufacturer).where(Manufacturer.name == name) + ) + return result.scalar_one_or_none() + + async def list_all( db: AsyncSession, skip: int = 0, limit: int = 100 ) -> list[Manufacturer]: diff --git a/app/models/__init__.py b/app/models/__init__.py index 4773712..707a402 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -1,6 +1,6 @@ """Import all models so they register with ``Base.metadata``.""" -from app.models.associations import role_permissions, user_roles +from app.models.associations import augmented_part_imported_parts, role_permissions, user_roles from app.models.audit_log import AuditLog from app.models.augmented_part import AugmentedPart from app.models.imported_part import ImportedPart @@ -35,6 +35,7 @@ __all__ = [ "Role", "UncThread", "User", + "augmented_part_imported_parts", "role_permissions", "user_roles", ] diff --git a/app/models/associations.py b/app/models/associations.py index c47eb68..a76c6e2 100644 --- a/app/models/associations.py +++ b/app/models/associations.py @@ -1,6 +1,6 @@ -"""Many-to-many association tables for RBAC.""" +"""Many-to-many association tables.""" -from sqlalchemy import Column, ForeignKey, Table +from sqlalchemy import Column, ForeignKey, String, Table from app.core.database import Base @@ -21,3 +21,19 @@ role_permissions = Table( primary_key=True, ), ) + +augmented_part_imported_parts = Table( + "augmented_part_imported_parts", + Base.metadata, + Column( + "augmented_part_id", + ForeignKey("augmented_parts.id", ondelete="CASCADE"), + primary_key=True, + ), + Column( + "imported_part_id", + String, + ForeignKey("imported_parts.InventoryID", ondelete="CASCADE"), + primary_key=True, + ), +) diff --git a/app/models/augmented_part.py b/app/models/augmented_part.py index 6b86e44..d47c7b8 100644 --- a/app/models/augmented_part.py +++ b/app/models/augmented_part.py @@ -2,6 +2,8 @@ from sqlalchemy import ForeignKey, String from sqlalchemy.orm import Mapped, mapped_column, relationship from app.core.database import Base +from app.models.associations import augmented_part_imported_parts +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 @@ -31,6 +33,11 @@ class AugmentedPart(Base): ForeignKey("sub_part_types.id", ondelete="SET NULL"), nullable=True, default=None ) + # linked imported parts — not eagerly loaded; used by sync service only + imported_parts: Mapped[list[ImportedPart]] = relationship( + secondary=augmented_part_imported_parts, lazy="select" + ) + colour: Mapped[PartColour] = relationship(lazy="selectin") manufacturer: Mapped[Manufacturer] = relationship(lazy="selectin") scope_model: Mapped[ScopeModel | None] = relationship(lazy="selectin") diff --git a/app/schemas/augmented_part.py b/app/schemas/augmented_part.py index 1114661..6894bb7 100644 --- a/app/schemas/augmented_part.py +++ b/app/schemas/augmented_part.py @@ -31,6 +31,12 @@ class AugmentedPartUpdate(BaseModel): sub_part_type_id: int | None = None +class AugmentedPartSyncResult(BaseModel): + created: int + deleted: int + skipped: int + + class AugmentedPartRead(AugmentedPartBase): model_config = ConfigDict(from_attributes=True) diff --git a/app/services/augmented_part_sync.py b/app/services/augmented_part_sync.py new file mode 100644 index 0000000..e51e0d1 --- /dev/null +++ b/app/services/augmented_part_sync.py @@ -0,0 +1,158 @@ +"""Synchronise AugmentedParts against the current ImportedParts table. + +Algorithm +--------- +Phase 1 – prune 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). + +Phase 2 – 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) + • Manufacturer ← "Armament" (created if needed) + • Model ← null + • Type ← first PartType whose name is a substring of the description + (longest name wins; skipped if no match) + • SubType ← null +""" + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +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.models.associations import augmented_part_imported_parts +from app.models.augmented_part import AugmentedPart +from app.models.imported_part import ImportedPart +from app.models.part_type import PartType +from app.schemas.augmented_part import AugmentedPartSyncResult + + +def _infer_part_type(description: str, part_types: list[PartType]) -> PartType | None: + """Return the best-matching PartType or None. + + Sorted by name length descending so more specific types (e.g. "Flip Cover") + are preferred over shorter ones (e.g. "Scope"). + """ + desc_lower = description.lower() + for pt in sorted(part_types, key=lambda p: len(p.name), reverse=True): + if pt.name.lower() in desc_lower: + return pt + return None + + +async def sync(db: AsyncSession) -> AugmentedPartSyncResult: + # ------------------------------------------------------------------ phase 1 + + # IDs referenced in existing associations + result = await db.execute( + select(augmented_part_imported_parts.c.imported_part_id).distinct() + ) + all_linked_ids: set[str] = {row[0] for row in result.all()} + + # IDs that still exist in imported_parts + result = await db.execute(select(ImportedPart.InventoryID)) + current_ids: set[str] = {row[0] for row in result.all()} + + orphaned_import_ids = all_linked_ids - current_ids + deleted = 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 + + await db.commit() + + # ------------------------------------------------------------------ phase 2 + + # Re-query linked IDs after phase 1 cleanup + 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 + + created = 0 + skipped = 0 + + if not unlinked_ids: + return AugmentedPartSyncResult(created=created, deleted=deleted, skipped=skipped) + + result = await db.execute( + select(ImportedPart).where(ImportedPart.InventoryID.in_(unlinked_ids)) + ) + new_parts = list(result.scalars().all()) + + # Ensure "Armament" manufacturer exists + manufacturer = await manufacturer_crud.get_by_name(db, "Armament") + if manufacturer is None: + manufacturer = await manufacturer_crud.create(db, name="Armament", url="") + + part_types = await part_type_crud.list_all(db) + + for ip in new_parts: + description = (ip.Description or "").strip() or ip.InventoryID + + part_type = _infer_part_type(description, part_types) + if part_type is None: + skipped += 1 + continue + + 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) + + ap = AugmentedPart( + sku=None, + description=description, + colour_id=colour.id, + manufacturer_id=manufacturer.id, + scope_model_id=None, + part_type_id=part_type.id, + sub_part_type_id=None, + ) + db.add(ap) + await db.flush() + + await db.execute( + augmented_part_imported_parts.insert().values( + augmented_part_id=ap.id, + imported_part_id=ip.InventoryID, + ) + ) + created += 1 + + await db.commit() + + return AugmentedPartSyncResult(created=created, deleted=deleted, skipped=skipped) diff --git a/app/templates/admin/_augmented_parts_table.html b/app/templates/admin/_augmented_parts_table.html index 4d7eb82..1c9881c 100644 --- a/app/templates/admin/_augmented_parts_table.html +++ b/app/templates/admin/_augmented_parts_table.html @@ -1,4 +1,11 @@ -
{{ sync_result }}
+{% endif %} +{% if sync_error %} +{{ sync_error }}
+{% endif %} +Creates Augmented Part entries for new Imported Parts, and removes entries for deleted Imported Parts.
+ +