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.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()
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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">
|
||||
<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">
|
||||
<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>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% include "admin/_augmented_parts_table.html" %}
|
||||
{% include "admin/_augmented_parts_sections.html" %}
|
||||
</main>
|
||||
{% endblock %}
|
||||
+45
-1
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user