diff --git a/.claude/settings.local.json b/.claude/settings.local.json
index d27782b..52a0ebc 100644
--- a/.claude/settings.local.json
+++ b/.claude/settings.local.json
@@ -36,7 +36,8 @@
"Bash(sqlite3 app.db \"SELECT id, name FROM scope_models WHERE name='Viper';\")",
"Bash(sqlite3 app.db \"SELECT COUNT\\(*\\) FROM augmented_parts WHERE scope_model_id=\\(SELECT id FROM scope_models WHERE name='Viper'\\);\")",
"Bash(sqlite3 app.db \"PRAGMA wal_checkpoint\\(FULL\\);\")",
- "Bash(curl -s -o /dev/null -w \"%{http_code}\\\\n\" http://127.0.0.1:8000/login)"
+ "Bash(curl -s -o /dev/null -w \"%{http_code}\\\\n\" http://127.0.0.1:8000/login)",
+ "Bash(curl -s -o /dev/null -w \"%{http_code}\\\\n\" http://127.0.0.1:8000/admin/imported-parts)"
]
}
}
diff --git a/app/templates/admin/imported_parts.html b/app/templates/admin/imported_parts.html
index c966694..912942c 100644
--- a/app/templates/admin/imported_parts.html
+++ b/app/templates/admin/imported_parts.html
@@ -29,11 +29,9 @@
- Loading…
{% include "admin/_imported_parts_table.html" %}
diff --git a/app/web/admin.py b/app/web/admin.py
index 68624cb..3c99771 100644
--- a/app/web/admin.py
+++ b/app/web/admin.py
@@ -1366,6 +1366,19 @@ async def save_imported_part_config(
return HTMLResponse("Configuration saved.", status_code=200)
+_IMPORTED_PARTS_REFRESH_TARGET_ID = "imported-parts-section"
+
+
+async def _imported_parts_table_ctx(
+ db: AsyncSession, *, sync_result: str | None = None, sync_error: str | None = None
+) -> dict:
+ return {
+ **await _default_imported_parts_table_ctx(db),
+ "sync_result": sync_result,
+ "sync_error": sync_error,
+ }
+
+
@router.post("/imported-parts/refresh", response_class=HTMLResponse)
async def refresh_imported_parts(
request: Request,
@@ -1373,46 +1386,95 @@ async def refresh_imported_parts(
actor: User = Depends(web_require("imported_parts:write")),
):
config = await imported_part_config_crud.get(db)
-
- async def _imported_table_ctx(sync_result=None, sync_error=None):
- return {
- **await _default_imported_parts_table_ctx(db),
- "sync_result": sync_result,
- "sync_error": sync_error,
- }
-
if not config or not config.odata_url:
return templates.TemplateResponse(
request,
"admin/_imported_parts_table.html",
- await _imported_table_ctx(sync_error="OData URL is not configured."),
- )
- try:
- records = await odata_service.fetch(
- config.odata_url, config.username, config.password
- )
- created, updated = await imported_part_crud.upsert_bulk(db, records)
- await audit_crud.log(
- db,
- actor=actor,
- action="update",
- resource_type="ImportedPart",
- resource_id=None,
- description=f"OData sync: {created} created, {updated} updated ({len(records)} total records)",
- )
- return templates.TemplateResponse(
- request,
- "admin/_imported_parts_table.html",
- await _imported_table_ctx(
- sync_result=f"Sync complete: {created} created, {updated} updated."
+ await _imported_parts_table_ctx(
+ db, sync_error="OData URL is not configured."
),
)
- except Exception as exc:
+
+ actor_id = actor.id
+
+ async def _run(job_id: str) -> None:
+ async with AsyncSessionLocal() as db:
+ config = await imported_part_config_crud.get(db)
+ if not config or not config.odata_url:
+ await jobs.mark_error(job_id, "OData URL is not configured.")
+ return
+ try:
+ records = await odata_service.fetch(
+ config.odata_url, config.username, config.password
+ )
+ created, updated = await imported_part_crud.upsert_bulk(db, records)
+ except Exception as exc:
+ await jobs.mark_error(job_id, f"Sync failed: {exc}")
+ return
+ actor_row = await user_crud.get(db, actor_id)
+ await audit_crud.log(
+ db,
+ actor=actor_row,
+ action="update",
+ resource_type="ImportedPart",
+ resource_id=None,
+ description=(
+ f"OData sync: {created} created, {updated} updated "
+ f"({len(records)} total records)"
+ ),
+ )
+ await jobs.mark_done(
+ job_id,
+ message=f"Sync complete: {created} created, {updated} updated.",
+ )
+
+ job = await jobs.start("imported_parts_refresh", _run)
+ return templates.TemplateResponse(
+ request,
+ "admin/_job_progress.html",
+ {
+ "container_id": _IMPORTED_PARTS_REFRESH_TARGET_ID,
+ "poll_url": f"/admin/imported-parts/refresh/jobs/{job.id}",
+ "job": job,
+ },
+ )
+
+
+@router.get("/imported-parts/refresh/jobs/{job_id}", response_class=HTMLResponse)
+async def imported_parts_refresh_job_status(
+ request: Request,
+ job_id: str,
+ db: AsyncSession = Depends(get_db),
+ _: User = Depends(web_require("imported_parts:write")),
+):
+ job = await jobs.get(db, job_id)
+ if job is None:
return templates.TemplateResponse(
request,
"admin/_imported_parts_table.html",
- await _imported_table_ctx(sync_error=f"Sync failed: {exc}"),
+ await _imported_parts_table_ctx(
+ db, sync_error="Refresh job not found — it may have expired."
+ ),
)
+ if job.status == "running":
+ return templates.TemplateResponse(
+ request,
+ "admin/_job_progress.html",
+ {
+ "container_id": _IMPORTED_PARTS_REFRESH_TARGET_ID,
+ "poll_url": f"/admin/imported-parts/refresh/jobs/{job_id}",
+ "job": job,
+ },
+ )
+ return templates.TemplateResponse(
+ request,
+ "admin/_imported_parts_table.html",
+ await _imported_parts_table_ctx(
+ db,
+ sync_result=job.message if job.status == "done" else None,
+ sync_error=job.message if job.status == "error" else None,
+ ),
+ )
@router.get("/imported-parts/filter", response_class=HTMLResponse)
diff --git a/tests/test_admin_background_jobs.py b/tests/test_admin_background_jobs.py
index 563b717..aafb08d 100644
--- a/tests/test_admin_background_jobs.py
+++ b/tests/test_admin_background_jobs.py
@@ -6,6 +6,7 @@ import pytest_asyncio
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.core.database import Base
+from app.crud import imported_part_config as imported_part_config_crud
from app.crud import role as role_crud
from app.models.attachment_point import AttachmentPoint
from app.models.attachment_point_name import AttachmentPointName
@@ -180,3 +181,64 @@ async def test_fit_recompute_all_runs_in_background(
final_body = await _poll_until_done(client, match.group(1))
assert "Recompute complete" in final_body
+
+
+@pytest.mark.asyncio
+async def test_imported_parts_refresh_runs_in_background(
+ client, seeded, make_user, db_sessionmaker, monkeypatch
+):
+ monkeypatch.setattr("app.web.admin.AsyncSessionLocal", db_sessionmaker)
+ monkeypatch.setattr("app.services.jobs.AsyncSessionLocal", db_sessionmaker)
+
+ async def _fake_fetch(url, username, password):
+ return [{"InventoryID": "INV-1", "Description": "Widget"}]
+
+ monkeypatch.setattr("app.web.admin.odata_service.fetch", _fake_fetch)
+
+ async with db_sessionmaker() as db:
+ await imported_part_config_crud.upsert(
+ db, odata_url="https://example.test/odata", username=None, password=None
+ )
+ perms = seeded["perms"]
+ editor = await role_crud.create(
+ db,
+ name="imported-parts-editor",
+ permission_ids=[perms["imported_parts:write"].id],
+ )
+ editor_id = editor.id
+ await make_user("refresher@example.com", role_ids=[editor_id])
+ await _web_login(client, "refresher@example.com")
+
+ resp = await client.post("/admin/imported-parts/refresh")
+ assert resp.status_code == 200
+ assert 'id="imported-parts-section"' in resp.text
+ match = _JOB_URL_RE.search(resp.text)
+ assert match, resp.text
+
+ final_body = await _poll_until_done(client, match.group(1))
+ assert "Sync complete: 1 created, 0 updated." in final_body
+ assert "INV-1" in final_body
+
+
+@pytest.mark.asyncio
+async def test_imported_parts_refresh_without_config_skips_job(
+ client, seeded, make_user, db_sessionmaker, monkeypatch
+):
+ monkeypatch.setattr("app.web.admin.AsyncSessionLocal", db_sessionmaker)
+ monkeypatch.setattr("app.services.jobs.AsyncSessionLocal", db_sessionmaker)
+ async with db_sessionmaker() as db:
+ perms = seeded["perms"]
+ editor = await role_crud.create(
+ db,
+ name="imported-parts-editor-noconfig",
+ permission_ids=[perms["imported_parts:write"].id],
+ )
+ editor_id = editor.id
+ await make_user("refresher2@example.com", role_ids=[editor_id])
+ await _web_login(client, "refresher2@example.com")
+
+ resp = await client.post("/admin/imported-parts/refresh")
+ assert resp.status_code == 200
+ assert "OData URL is not configured." in resp.text
+ # No background job started for this synchronous validation failure.
+ assert _JOB_URL_RE.search(resp.text) is None