Moving post-import reconciliation to a separate page
This commit is contained in:
1 parent
87019ff2af
commit
0168b023af
5 files changed
+170
-23
No files matched your search
@@ -32,6 +32,9 @@
|
|||||||
{% if user.is_superuser or "augmented_parts:read" in user.permissions %}
|
{% if user.is_superuser or "augmented_parts:read" in user.permissions %}
|
||||||
<a href="/admin/augmented-parts">Augmented Parts</a>
|
<a href="/admin/augmented-parts">Augmented Parts</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if user.is_superuser or "augmented_parts:write" in user.permissions %}
|
||||||
|
<a href="/admin/augmented-parts/skipped">Skipped Parts</a>
|
||||||
|
{% endif %}
|
||||||
{% if user.is_superuser or "part_colours:read" in user.permissions %}
|
{% if user.is_superuser or "part_colours:read" in user.permissions %}
|
||||||
<a href="/admin/part-colours">Part Colours</a>
|
<a href="/admin/part-colours">Part Colours</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
<div id="skipped-parts-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 %}
|
||||||
|
|
||||||
|
{% if unlinked_parts %}
|
||||||
|
<form hx-post="/admin/augmented-parts/skipped"
|
||||||
|
hx-target="#skipped-parts-list"
|
||||||
|
hx-swap="outerHTML">
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="form-grid">
|
||||||
|
<label>Type
|
||||||
|
<select name="part_type_id" required>
|
||||||
|
<option value="">— select type —</option>
|
||||||
|
{% for pt in part_types %}
|
||||||
|
<option value="{{ pt.id }}">{{ pt.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>Sub Type
|
||||||
|
<select name="sub_part_type_id">
|
||||||
|
<option value="">— none —</option>
|
||||||
|
{% for spt in sub_part_types %}
|
||||||
|
<option value="{{ spt.id }}">{{ spt.name }} ({{ spt.part_type.name }})</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button id="sync-selected-btn" type="submit" class="btn btn-primary">Sync selected</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" style="overflow-x:auto">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th><input type="checkbox" id="select-all" title="Select all"></th>
|
||||||
|
<th>Inventory ID</th>
|
||||||
|
<th>Description</th>
|
||||||
|
<th>Colour</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for ip in unlinked_parts %}
|
||||||
|
<tr>
|
||||||
|
<td><input type="checkbox" name="inventory_ids" value="{{ ip.InventoryID }}"></td>
|
||||||
|
<td><code>{{ ip.InventoryID }}</code></td>
|
||||||
|
<td>{{ ip.Description | truncate(80) if ip.Description else '—' }}</td>
|
||||||
|
<td>{{ ip.TBXColour or '—' }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<div class="card">
|
||||||
|
<p class="muted">No skipped parts — all imported parts have been synchronised.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
@@ -57,11 +57,14 @@
|
|||||||
<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-parts-sections" hx-swap="outerHTML">
|
<div class="row-actions">
|
||||||
|
<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>
|
<button type="submit" class="btn btn-primary">Sync now</button>
|
||||||
</form>
|
</form>
|
||||||
|
<a href="/admin/augmented-parts/skipped" class="btn btn-ghost">View skipped parts →</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% include "admin/_augmented_parts_sections.html" %}
|
{% include "admin/_augmented_parts_table.html" %}
|
||||||
</main>
|
</main>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Skipped Parts · {{ app_name }}{% endblock %}
|
||||||
|
{% block body %}
|
||||||
|
{% include "_nav.html" %}
|
||||||
|
<main class="container">
|
||||||
|
<h1>Skipped Imported Parts</h1>
|
||||||
|
<p class="muted">
|
||||||
|
These parts were skipped during sync because no Part Type could be inferred
|
||||||
|
from their description. Select one or more rows, choose a Type (and optionally
|
||||||
|
a Sub Type), then click <strong>Sync selected</strong> to create their
|
||||||
|
Augmented Part entries.
|
||||||
|
</p>
|
||||||
|
<p><a href="/admin/augmented-parts" class="btn btn-ghost">← Augmented Parts</a></p>
|
||||||
|
|
||||||
|
{% include "admin/_skipped_parts_table.html" %}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
function updateButton() {
|
||||||
|
var count = document.querySelectorAll('input[name="inventory_ids"]:checked').length;
|
||||||
|
var btn = document.getElementById('sync-selected-btn');
|
||||||
|
if (btn) {
|
||||||
|
btn.textContent = count > 0 ? 'Sync selected (' + count + ')' : 'Sync selected';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('change', function (e) {
|
||||||
|
if (!e.target) return;
|
||||||
|
|
||||||
|
if (e.target.id === 'select-all') {
|
||||||
|
document.querySelectorAll('input[name="inventory_ids"]').forEach(function (cb) {
|
||||||
|
cb.checked = e.target.checked;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.target.id === 'select-all' || e.target.name === 'inventory_ids') {
|
||||||
|
updateButton();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Re-run after every HTMX swap so the button state reflects the new content.
|
||||||
|
document.addEventListener('htmx:afterSwap', updateButton);
|
||||||
|
}());
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
+55
-21
@@ -1143,7 +1143,6 @@ 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)
|
||||||
|
|
||||||
|
|
||||||
@@ -1209,56 +1208,91 @@ 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_sections.html",
|
"admin/_augmented_parts_table.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)
|
@router.get("/augmented-parts/skipped", response_class=HTMLResponse)
|
||||||
async def sync_unlinked_part(
|
async def skipped_parts_page(
|
||||||
|
request: Request,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(web_require("augmented_parts:read")),
|
||||||
|
):
|
||||||
|
form_ctx = await _augmented_part_form_ctx(db)
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request,
|
||||||
|
"admin/augmented_parts_skipped.html",
|
||||||
|
{
|
||||||
|
"user": user,
|
||||||
|
**form_ctx,
|
||||||
|
"unlinked_parts": await augmented_part_crud.get_unlinked_imported_parts(db),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/augmented-parts/skipped", response_class=HTMLResponse)
|
||||||
|
async def sync_skipped_parts(
|
||||||
request: Request,
|
request: Request,
|
||||||
inventory_id: str = Form(...),
|
|
||||||
part_type_id: int = Form(...),
|
|
||||||
sub_part_type_id: str = Form(""),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
actor: User = Depends(web_require("augmented_parts:write")),
|
actor: User = Depends(web_require("augmented_parts:write")),
|
||||||
):
|
):
|
||||||
|
form_data = await request.form()
|
||||||
|
inventory_ids = [str(v) for v in form_data.getlist("inventory_ids")]
|
||||||
|
part_type_id_str = str(form_data.get("part_type_id", ""))
|
||||||
|
sub_part_type_id_str = str(form_data.get("sub_part_type_id", ""))
|
||||||
|
|
||||||
|
part_type_id = int(part_type_id_str) if part_type_id_str else None
|
||||||
|
sub_part_type_id = int(sub_part_type_id_str) if sub_part_type_id_str else None
|
||||||
|
|
||||||
|
sync_result = None
|
||||||
|
sync_error = None
|
||||||
|
|
||||||
|
if not inventory_ids:
|
||||||
|
sync_error = "No parts selected."
|
||||||
|
elif part_type_id is None:
|
||||||
|
sync_error = "No type selected."
|
||||||
|
else:
|
||||||
|
synced = 0
|
||||||
|
errors: list[str] = []
|
||||||
|
for inv_id in inventory_ids:
|
||||||
try:
|
try:
|
||||||
ap = await augmented_part_sync_service.sync_one(
|
ap = await augmented_part_sync_service.sync_one(
|
||||||
db,
|
db,
|
||||||
inventory_id=inventory_id,
|
inventory_id=inv_id,
|
||||||
part_type_id=part_type_id,
|
part_type_id=part_type_id,
|
||||||
sub_part_type_id=int(sub_part_type_id) if sub_part_type_id else None,
|
sub_part_type_id=sub_part_type_id,
|
||||||
)
|
)
|
||||||
await audit_crud.log(
|
await audit_crud.log(
|
||||||
db, actor=actor, action="create", resource_type="AugmentedPart",
|
db, actor=actor, action="create", resource_type="AugmentedPart",
|
||||||
resource_id=ap.id,
|
resource_id=ap.id,
|
||||||
description=audit_crud.describe_create("AugmentedPart", ap.id, ap.description),
|
description=audit_crud.describe_create(
|
||||||
|
"AugmentedPart", ap.id, ap.description
|
||||||
|
),
|
||||||
)
|
)
|
||||||
unlinked_sync_result = f"Created Augmented Part for '{ap.description}'."
|
synced += 1
|
||||||
unlinked_sync_error = None
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
unlinked_sync_result = None
|
errors.append(f"{inv_id}: {exc}")
|
||||||
unlinked_sync_error = f"Sync failed: {exc}"
|
if errors:
|
||||||
|
sync_error = "Some parts failed: " + "; ".join(errors)
|
||||||
|
if synced:
|
||||||
|
sync_result = f"{synced} part(s) synced successfully."
|
||||||
|
|
||||||
form_ctx = await _augmented_part_form_ctx(db)
|
form_ctx = await _augmented_part_form_ctx(db)
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
request,
|
request,
|
||||||
"admin/_augmented_parts_sections.html",
|
"admin/_skipped_parts_table.html",
|
||||||
{
|
{
|
||||||
**form_ctx,
|
**form_ctx,
|
||||||
"augmented_parts": await augmented_part_crud.list_all(db),
|
|
||||||
"unlinked_parts": await augmented_part_crud.get_unlinked_imported_parts(db),
|
"unlinked_parts": await augmented_part_crud.get_unlinked_imported_parts(db),
|
||||||
"unlinked_sync_result": unlinked_sync_result,
|
"sync_result": sync_result,
|
||||||
"unlinked_sync_error": unlinked_sync_error,
|
"sync_error": sync_error,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user