Adding sync with imported parts
This commit is contained in:
1 parent
00fb43c8ed
commit
c320595423
11 files changed
+300
-4
No files matched your search
@@ -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 ###
|
||||||
@@ -9,8 +9,10 @@ from app.models.user import User
|
|||||||
from app.schemas.augmented_part import (
|
from app.schemas.augmented_part import (
|
||||||
AugmentedPartCreate,
|
AugmentedPartCreate,
|
||||||
AugmentedPartRead,
|
AugmentedPartRead,
|
||||||
|
AugmentedPartSyncResult,
|
||||||
AugmentedPartUpdate,
|
AugmentedPartUpdate,
|
||||||
)
|
)
|
||||||
|
from app.services import augmented_part_sync
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -49,6 +51,23 @@ async def create_augmented_part(
|
|||||||
return ap
|
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)
|
@router.get("/{augmented_part_id}", response_model=AugmentedPartRead)
|
||||||
async def get_augmented_part(
|
async def get_augmented_part(
|
||||||
augmented_part_id: int,
|
augmented_part_id: int,
|
||||||
|
|||||||
@@ -8,6 +8,13 @@ async def get(db: AsyncSession, manufacturer_id: int) -> Manufacturer | None:
|
|||||||
return await db.get(Manufacturer, manufacturer_id)
|
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(
|
async def list_all(
|
||||||
db: AsyncSession, skip: int = 0, limit: int = 100
|
db: AsyncSession, skip: int = 0, limit: int = 100
|
||||||
) -> list[Manufacturer]:
|
) -> list[Manufacturer]:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Import all models so they register with ``Base.metadata``."""
|
"""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.audit_log import AuditLog
|
||||||
from app.models.augmented_part import AugmentedPart
|
from app.models.augmented_part import AugmentedPart
|
||||||
from app.models.imported_part import ImportedPart
|
from app.models.imported_part import ImportedPart
|
||||||
@@ -35,6 +35,7 @@ __all__ = [
|
|||||||
"Role",
|
"Role",
|
||||||
"UncThread",
|
"UncThread",
|
||||||
"User",
|
"User",
|
||||||
|
"augmented_part_imported_parts",
|
||||||
"role_permissions",
|
"role_permissions",
|
||||||
"user_roles",
|
"user_roles",
|
||||||
]
|
]
|
||||||
@@ -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
|
from app.core.database import Base
|
||||||
|
|
||||||
@@ -21,3 +21,19 @@ role_permissions = Table(
|
|||||||
primary_key=True,
|
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,
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -2,6 +2,8 @@ from sqlalchemy import ForeignKey, String
|
|||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from app.core.database import Base
|
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.manufacturer import Manufacturer
|
||||||
from app.models.part_colour import PartColour
|
from app.models.part_colour import PartColour
|
||||||
from app.models.part_type import PartType
|
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
|
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")
|
colour: Mapped[PartColour] = relationship(lazy="selectin")
|
||||||
manufacturer: Mapped[Manufacturer] = relationship(lazy="selectin")
|
manufacturer: Mapped[Manufacturer] = relationship(lazy="selectin")
|
||||||
scope_model: Mapped[ScopeModel | None] = relationship(lazy="selectin")
|
scope_model: Mapped[ScopeModel | None] = relationship(lazy="selectin")
|
||||||
|
|||||||
@@ -31,6 +31,12 @@ class AugmentedPartUpdate(BaseModel):
|
|||||||
sub_part_type_id: int | None = None
|
sub_part_type_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AugmentedPartSyncResult(BaseModel):
|
||||||
|
created: int
|
||||||
|
deleted: int
|
||||||
|
skipped: int
|
||||||
|
|
||||||
|
|
||||||
class AugmentedPartRead(AugmentedPartBase):
|
class AugmentedPartRead(AugmentedPartBase):
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
<table class="table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -22,3 +29,4 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
@@ -54,6 +54,14 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</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" %}
|
{% include "admin/_augmented_parts_table.html" %}
|
||||||
</main>
|
</main>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -22,6 +22,7 @@ from app.crud import user as user_crud
|
|||||||
from app.deps import get_current_web_user
|
from app.deps import get_current_web_user
|
||||||
from app.models.unc_thread import UncSeries
|
from app.models.unc_thread import UncSeries
|
||||||
from app.models.user import User
|
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.services import odata as odata_service
|
||||||
from app.web.templates import templates
|
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)
|
@router.get("/augmented-parts/{augmented_part_id}/edit", response_class=HTMLResponse)
|
||||||
async def edit_augmented_part_row(
|
async def edit_augmented_part_row(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
|||||||
Reference in new issue
Block a user