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