Adding sync with imported parts

This commit is contained in:
Ian Roddis committed 2026-07-02 12:55:13 -03:00
1 parent 00fb43c8ed
commit c320595423
11 files changed
+300 -4

No files matched your search

+19
View File
@@ -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,
+7
View File
@@ -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]:
+2 -1
View File
@@ -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",
]
+18 -2
View File
@@ -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,
),
)
+7
View File
@@ -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")
+6
View File
@@ -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)
+158
View File
@@ -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)
@@ -1,4 +1,11 @@
<div id="augmented-part-list" class="card">
<div id="augmented-part-list">
{% if sync_result %}
<p class="alert alert-success">{{ sync_result }}</p>
{% endif %}
{% if sync_error %}
<p class="alert alert-error">{{ sync_error }}</p>
{% endif %}
<div class="card">
<table class="table">
<thead>
<tr>
@@ -22,3 +29,4 @@
</tbody>
</table>
</div>
</div>
+8
View File
@@ -54,6 +54,14 @@
</form>
</div>
<div class="card">
<h2>Sync with Imported Parts</h2>
<p class="muted">Creates Augmented Part entries for new Imported Parts, and removes entries for deleted Imported Parts.</p>
<form hx-post="/admin/augmented-parts/sync" hx-target="#augmented-part-list" hx-swap="outerHTML">
<button type="submit" class="btn btn-primary">Sync now</button>
</form>
</div>
{% include "admin/_augmented_parts_table.html" %}
</main>
{% endblock %}
+37
View File
@@ -22,6 +22,7 @@ from app.crud import user as user_crud
from app.deps import get_current_web_user
from app.models.unc_thread import UncSeries
from app.models.user import User
from app.services import augmented_part_sync as augmented_part_sync_service
from app.services import odata as odata_service
from app.web.templates import templates
@@ -1182,6 +1183,42 @@ async def create_augmented_part(
)
@router.post("/augmented-parts/sync", response_class=HTMLResponse)
async def sync_augmented_parts(
request: Request,
db: AsyncSession = Depends(get_db),
actor: User = Depends(web_require("augmented_parts:write")),
):
try:
result = await augmented_part_sync_service.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)"
),
)
sync_result = (
f"Sync complete: {result.created} created, {result.deleted} deleted"
+ (f", {result.skipped} skipped (type not recognised)" if result.skipped else "")
+ "."
)
sync_error = None
except Exception as exc:
sync_result = None
sync_error = f"Sync failed: {exc}"
return templates.TemplateResponse(
request,
"admin/_augmented_parts_table.html",
{
"augmented_parts": await augmented_part_crud.list_all(db),
"sync_result": sync_result,
"sync_error": sync_error,
},
)
@router.get("/augmented-parts/{augmented_part_id}/edit", response_class=HTMLResponse)
async def edit_augmented_part_row(
request: Request,