From 87019ff2afba795d18ab3d00a3ac8be99816c472 Mon Sep 17 00:00:00 2001 From: Ian Roddis <31021769+iroddis@users.noreply.github.com> Date: Thu, 2 Jul 2026 13:12:09 -0300 Subject: [PATCH] Adding post-import adjustments --- app/crud/augmented_part.py | 16 +++++ app/services/augmented_part_sync.py | 45 ++++++++++++++ .../admin/_augmented_parts_sections.html | 4 ++ .../admin/_unlinked_parts_table.html | 58 +++++++++++++++++++ app/templates/admin/augmented_parts.html | 4 +- app/web/admin.py | 46 ++++++++++++++- 6 files changed, 170 insertions(+), 3 deletions(-) create mode 100644 app/templates/admin/_augmented_parts_sections.html create mode 100644 app/templates/admin/_unlinked_parts_table.html diff --git a/app/crud/augmented_part.py b/app/crud/augmented_part.py index 456f2c6..3257876 100644 --- a/app/crud/augmented_part.py +++ b/app/crud/augmented_part.py @@ -1,7 +1,9 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from app.models.associations import augmented_part_imported_parts from app.models.augmented_part import AugmentedPart +from app.models.imported_part import ImportedPart _RELATIONSHIPS = ["colour", "manufacturer", "scope_model", "part_type", "sub_part_type"] @@ -58,6 +60,20 @@ async def update( return ap +async def get_unlinked_imported_parts(db: AsyncSession) -> list[ImportedPart]: + """ImportedParts that have no corresponding AugmentedPart link.""" + result = await db.execute( + select(ImportedPart) + .where( + ImportedPart.InventoryID.not_in( + select(augmented_part_imported_parts.c.imported_part_id) + ) + ) + .order_by(ImportedPart.InventoryID) + ) + return list(result.scalars().all()) + + async def delete(db: AsyncSession, ap: AugmentedPart) -> None: await db.delete(ap) await db.commit() diff --git a/app/services/augmented_part_sync.py b/app/services/augmented_part_sync.py index e51e0d1..b7a6b43 100644 --- a/app/services/augmented_part_sync.py +++ b/app/services/augmented_part_sync.py @@ -32,6 +32,51 @@ from app.models.part_type import PartType from app.schemas.augmented_part import AugmentedPartSyncResult +async def sync_one( + db: AsyncSession, + *, + inventory_id: str, + part_type_id: int, + sub_part_type_id: int | None, +) -> AugmentedPart: + """Create an AugmentedPart for a single ImportedPart with manually supplied type.""" + ip = await db.get(ImportedPart, inventory_id) + if ip is None: + raise ValueError(f"ImportedPart {inventory_id!r} not found") + + 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) + + manufacturer = await manufacturer_crud.get_by_name(db, "Armament") + if manufacturer is None: + manufacturer = await manufacturer_crud.create(db, name="Armament", url="") + + 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=sub_part_type_id, + ) + 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, + ) + ) + await db.commit() + return ap + + def _infer_part_type(description: str, part_types: list[PartType]) -> PartType | None: """Return the best-matching PartType or None. diff --git a/app/templates/admin/_augmented_parts_sections.html b/app/templates/admin/_augmented_parts_sections.html new file mode 100644 index 0000000..0ee9d83 --- /dev/null +++ b/app/templates/admin/_augmented_parts_sections.html @@ -0,0 +1,4 @@ +
+ {% include "admin/_augmented_parts_table.html" %} + {% include "admin/_unlinked_parts_table.html" %} +
diff --git a/app/templates/admin/_unlinked_parts_table.html b/app/templates/admin/_unlinked_parts_table.html new file mode 100644 index 0000000..f63bc1c --- /dev/null +++ b/app/templates/admin/_unlinked_parts_table.html @@ -0,0 +1,58 @@ +
+{% if unlinked_parts %} +
+

Skipped Imported Parts

+

These parts were skipped during sync because no Part Type could be inferred from their description. Assign a type and click Sync to create the Augmented Part entry.

+ {% if unlinked_sync_result %}

{{ unlinked_sync_result }}

{% endif %} + {% if unlinked_sync_error %}

{{ unlinked_sync_error }}

{% endif %} +
+ + + + + + + + + + + + + {% for ip in unlinked_parts %} + + + + + + + + + {% endfor %} + +
Inventory IDDescriptionColourTypeSub Type
{{ ip.InventoryID }}{{ ip.Description | truncate(60) if ip.Description else '—' }}{{ ip.TBXColour or '—' }} + + + + +
+ + +
+
+
+
+{% endif %} +
diff --git a/app/templates/admin/augmented_parts.html b/app/templates/admin/augmented_parts.html index 8d8daad..1fcc735 100644 --- a/app/templates/admin/augmented_parts.html +++ b/app/templates/admin/augmented_parts.html @@ -57,11 +57,11 @@

Sync with Imported Parts

Creates Augmented Part entries for new Imported Parts, and removes entries for deleted Imported Parts.

-
+
- {% include "admin/_augmented_parts_table.html" %} + {% include "admin/_augmented_parts_sections.html" %} {% endblock %} diff --git a/app/web/admin.py b/app/web/admin.py index 7c2d9c5..034ba19 100644 --- a/app/web/admin.py +++ b/app/web/admin.py @@ -1143,6 +1143,7 @@ async def augmented_parts_page( ctx = await _augmented_part_form_ctx(db) ctx["user"] = user ctx["augmented_parts"] = await augmented_part_crud.list_all(db) + ctx["unlinked_parts"] = await augmented_part_crud.get_unlinked_imported_parts(db) return templates.TemplateResponse(request, "admin/augmented_parts.html", ctx) @@ -1208,17 +1209,60 @@ async def sync_augmented_parts( except Exception as exc: sync_result = None sync_error = f"Sync failed: {exc}" + form_ctx = await _augmented_part_form_ctx(db) return templates.TemplateResponse( request, - "admin/_augmented_parts_table.html", + "admin/_augmented_parts_sections.html", { + **form_ctx, "augmented_parts": await augmented_part_crud.list_all(db), + "unlinked_parts": await augmented_part_crud.get_unlinked_imported_parts(db), "sync_result": sync_result, "sync_error": sync_error, }, ) +@router.post("/augmented-parts/unlinked", response_class=HTMLResponse) +async def sync_unlinked_part( + request: Request, + inventory_id: str = Form(...), + part_type_id: int = Form(...), + sub_part_type_id: str = Form(""), + db: AsyncSession = Depends(get_db), + actor: User = Depends(web_require("augmented_parts:write")), +): + try: + ap = await augmented_part_sync_service.sync_one( + db, + inventory_id=inventory_id, + part_type_id=part_type_id, + sub_part_type_id=int(sub_part_type_id) if sub_part_type_id else None, + ) + await audit_crud.log( + db, actor=actor, action="create", resource_type="AugmentedPart", + resource_id=ap.id, + description=audit_crud.describe_create("AugmentedPart", ap.id, ap.description), + ) + unlinked_sync_result = f"Created Augmented Part for '{ap.description}'." + unlinked_sync_error = None + except Exception as exc: + unlinked_sync_result = None + unlinked_sync_error = f"Sync failed: {exc}" + form_ctx = await _augmented_part_form_ctx(db) + return templates.TemplateResponse( + request, + "admin/_augmented_parts_sections.html", + { + **form_ctx, + "augmented_parts": await augmented_part_crud.list_all(db), + "unlinked_parts": await augmented_part_crud.get_unlinked_imported_parts(db), + "unlinked_sync_result": unlinked_sync_result, + "unlinked_sync_error": unlinked_sync_error, + }, + ) + + @router.get("/augmented-parts/{augmented_part_id}/edit", response_class=HTMLResponse) async def edit_augmented_part_row( request: Request,