Adding post-import adjustments
This commit is contained in:
1 parent
c320595423
commit
87019ff2af
6 files changed
+170
-3
No files matched your search
@@ -1,7 +1,9 @@
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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.augmented_part import AugmentedPart
|
||||||
|
from app.models.imported_part import ImportedPart
|
||||||
|
|
||||||
_RELATIONSHIPS = ["colour", "manufacturer", "scope_model", "part_type", "sub_part_type"]
|
_RELATIONSHIPS = ["colour", "manufacturer", "scope_model", "part_type", "sub_part_type"]
|
||||||
|
|
||||||
@@ -58,6 +60,20 @@ async def update(
|
|||||||
return ap
|
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:
|
async def delete(db: AsyncSession, ap: AugmentedPart) -> None:
|
||||||
await db.delete(ap)
|
await db.delete(ap)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
@@ -32,6 +32,51 @@ from app.models.part_type import PartType
|
|||||||
from app.schemas.augmented_part import AugmentedPartSyncResult
|
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:
|
def _infer_part_type(description: str, part_types: list[PartType]) -> PartType | None:
|
||||||
"""Return the best-matching PartType or None.
|
"""Return the best-matching PartType or None.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<div id="augmented-parts-sections">
|
||||||
|
{% include "admin/_augmented_parts_table.html" %}
|
||||||
|
{% include "admin/_unlinked_parts_table.html" %}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<div id="unlinked-parts-list">
|
||||||
|
{% if unlinked_parts %}
|
||||||
|
<div class="card">
|
||||||
|
<h2>Skipped Imported Parts</h2>
|
||||||
|
<p class="muted">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.</p>
|
||||||
|
{% if unlinked_sync_result %}<p class="alert alert-success">{{ unlinked_sync_result }}</p>{% endif %}
|
||||||
|
{% if unlinked_sync_error %}<p class="alert alert-error">{{ unlinked_sync_error }}</p>{% endif %}
|
||||||
|
<div style="overflow-x:auto">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Inventory ID</th>
|
||||||
|
<th>Description</th>
|
||||||
|
<th>Colour</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Sub Type</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for ip in unlinked_parts %}
|
||||||
|
<tr>
|
||||||
|
<td><code>{{ ip.InventoryID }}</code></td>
|
||||||
|
<td>{{ ip.Description | truncate(60) if ip.Description else '—' }}</td>
|
||||||
|
<td>{{ ip.TBXColour or '—' }}</td>
|
||||||
|
<td>
|
||||||
|
<select name="part_type_id" form="unlinked-sync-{{ loop.index }}" required>
|
||||||
|
<option value="">— select —</option>
|
||||||
|
{% for pt in part_types %}
|
||||||
|
<option value="{{ pt.id }}">{{ pt.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<select name="sub_part_type_id" form="unlinked-sync-{{ loop.index }}">
|
||||||
|
<option value="">— none —</option>
|
||||||
|
{% for spt in sub_part_types %}
|
||||||
|
<option value="{{ spt.id }}">{{ spt.name }} ({{ spt.part_type.name }})</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<form id="unlinked-sync-{{ loop.index }}"
|
||||||
|
hx-post="/admin/augmented-parts/unlinked"
|
||||||
|
hx-target="#augmented-parts-sections"
|
||||||
|
hx-swap="outerHTML">
|
||||||
|
<input type="hidden" name="inventory_id" value="{{ ip.InventoryID }}">
|
||||||
|
<button type="submit" class="btn btn-primary">Sync</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
@@ -57,11 +57,11 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Sync with Imported Parts</h2>
|
<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>
|
<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">
|
<form hx-post="/admin/augmented-parts/sync" hx-target="#augmented-parts-sections" hx-swap="outerHTML">
|
||||||
<button type="submit" class="btn btn-primary">Sync now</button>
|
<button type="submit" class="btn btn-primary">Sync now</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% include "admin/_augmented_parts_table.html" %}
|
{% include "admin/_augmented_parts_sections.html" %}
|
||||||
</main>
|
</main>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
+45
-1
@@ -1143,6 +1143,7 @@ async def augmented_parts_page(
|
|||||||
ctx = await _augmented_part_form_ctx(db)
|
ctx = await _augmented_part_form_ctx(db)
|
||||||
ctx["user"] = user
|
ctx["user"] = user
|
||||||
ctx["augmented_parts"] = await augmented_part_crud.list_all(db)
|
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)
|
return templates.TemplateResponse(request, "admin/augmented_parts.html", ctx)
|
||||||
|
|
||||||
|
|
||||||
@@ -1208,17 +1209,60 @@ async def sync_augmented_parts(
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
sync_result = None
|
sync_result = None
|
||||||
sync_error = f"Sync failed: {exc}"
|
sync_error = f"Sync failed: {exc}"
|
||||||
|
form_ctx = await _augmented_part_form_ctx(db)
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
request,
|
request,
|
||||||
"admin/_augmented_parts_table.html",
|
"admin/_augmented_parts_sections.html",
|
||||||
{
|
{
|
||||||
|
**form_ctx,
|
||||||
"augmented_parts": await augmented_part_crud.list_all(db),
|
"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_result": sync_result,
|
||||||
"sync_error": sync_error,
|
"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)
|
@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