Making OData Refresh a background process
This commit is contained in:
1 parent
2e7976e382
commit
cf8d72f73e
4 files changed
+146
-23
No files matched your search
@@ -36,7 +36,8 @@
|
|||||||
"Bash(sqlite3 app.db \"SELECT id, name FROM scope_models WHERE name='Viper';\")",
|
"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 \"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(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)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -29,11 +29,9 @@
|
|||||||
<button class="btn btn-primary"
|
<button class="btn btn-primary"
|
||||||
hx-post="/admin/imported-parts/refresh"
|
hx-post="/admin/imported-parts/refresh"
|
||||||
hx-target="#imported-parts-section"
|
hx-target="#imported-parts-section"
|
||||||
hx-swap="outerHTML"
|
hx-swap="outerHTML">
|
||||||
hx-indicator="#refresh-indicator">
|
|
||||||
Refresh from OData
|
Refresh from OData
|
||||||
</button>
|
</button>
|
||||||
<span id="refresh-indicator" class="htmx-indicator muted"> Loading…</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% include "admin/_imported_parts_table.html" %}
|
{% include "admin/_imported_parts_table.html" %}
|
||||||
|
|||||||
+81
-19
@@ -1366,6 +1366,19 @@ async def save_imported_part_config(
|
|||||||
return HTMLResponse("Configuration saved.", status_code=200)
|
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)
|
@router.post("/imported-parts/refresh", response_class=HTMLResponse)
|
||||||
async def refresh_imported_parts(
|
async def refresh_imported_parts(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -1373,45 +1386,94 @@ async def refresh_imported_parts(
|
|||||||
actor: User = Depends(web_require("imported_parts:write")),
|
actor: User = Depends(web_require("imported_parts:write")),
|
||||||
):
|
):
|
||||||
config = await imported_part_config_crud.get(db)
|
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:
|
if not config or not config.odata_url:
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
request,
|
request,
|
||||||
"admin/_imported_parts_table.html",
|
"admin/_imported_parts_table.html",
|
||||||
await _imported_table_ctx(sync_error="OData URL is not configured."),
|
await _imported_parts_table_ctx(
|
||||||
|
db, sync_error="OData URL is not configured."
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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:
|
try:
|
||||||
records = await odata_service.fetch(
|
records = await odata_service.fetch(
|
||||||
config.odata_url, config.username, config.password
|
config.odata_url, config.username, config.password
|
||||||
)
|
)
|
||||||
created, updated = await imported_part_crud.upsert_bulk(db, records)
|
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(
|
await audit_crud.log(
|
||||||
db,
|
db,
|
||||||
actor=actor,
|
actor=actor_row,
|
||||||
action="update",
|
action="update",
|
||||||
resource_type="ImportedPart",
|
resource_type="ImportedPart",
|
||||||
resource_id=None,
|
resource_id=None,
|
||||||
description=f"OData sync: {created} created, {updated} updated ({len(records)} total records)",
|
description=(
|
||||||
)
|
f"OData sync: {created} created, {updated} updated "
|
||||||
return templates.TemplateResponse(
|
f"({len(records)} total records)"
|
||||||
request,
|
|
||||||
"admin/_imported_parts_table.html",
|
|
||||||
await _imported_table_ctx(
|
|
||||||
sync_result=f"Sync complete: {created} created, {updated} updated."
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
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(
|
return templates.TemplateResponse(
|
||||||
request,
|
request,
|
||||||
"admin/_imported_parts_table.html",
|
"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,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import pytest_asyncio
|
|||||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||||
|
|
||||||
from app.core.database import Base
|
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.crud import role as role_crud
|
||||||
from app.models.attachment_point import AttachmentPoint
|
from app.models.attachment_point import AttachmentPoint
|
||||||
from app.models.attachment_point_name import AttachmentPointName
|
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))
|
final_body = await _poll_until_done(client, match.group(1))
|
||||||
assert "Recompute complete" in final_body
|
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
|
||||||
Reference in new issue
Block a user