From 9a9ebab7c8a82b81d0324e483d765f954ba97c08 Mon Sep 17 00:00:00 2001 From: Ian Roddis <31021769+iroddis@users.noreply.github.com> Date: Tue, 18 Aug 2026 14:07:18 -0300 Subject: [PATCH] Making dashboard a bit more useful --- app/static/css/style.css | 7 + app/templates/_dashboard_sync_card.html | 32 +++ app/templates/dashboard.html | 2 + app/web/pages.py | 340 +++++++++++++++++++++++- tests/test_dashboard.py | 241 +++++++++++++++++ 5 files changed, 620 insertions(+), 2 deletions(-) create mode 100644 app/templates/_dashboard_sync_card.html create mode 100644 tests/test_dashboard.py diff --git a/app/static/css/style.css b/app/static/css/style.css index 987887e..29c5e6f 100644 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -637,6 +637,13 @@ textarea:focus { margin-bottom: 0.75rem; } +.dashboard-sync-actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 0.75rem; +} + .filter-bar form { display: flex; flex-wrap: wrap; diff --git a/app/templates/_dashboard_sync_card.html b/app/templates/_dashboard_sync_card.html new file mode 100644 index 0000000..60ef9ce --- /dev/null +++ b/app/templates/_dashboard_sync_card.html @@ -0,0 +1,32 @@ +
+

Data sync

+
+
Scopes
+
{{ scope_count }}
+
Imported parts skipped
+
{% if can_sync_augmented_parts %}{{ skipped_count }}{% else %}{{ skipped_count }}{% endif %}
+
+ + {% if status_message %}

✅ {{ status_message }}

{% endif %} + {% if status_error %}

⚠️ {{ status_error }}

{% endif %} + + {% if can_refresh_odata or can_sync_augmented_parts or can_recompute_fits %} +
+ {% if can_refresh_odata %} +
+ +
+ {% endif %} + {% if can_sync_augmented_parts %} +
+ +
+ {% endif %} + {% if can_recompute_fits %} +
+ +
+ {% endif %} +
+ {% endif %} +
diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html index dff7299..f236ace 100644 --- a/app/templates/dashboard.html +++ b/app/templates/dashboard.html @@ -35,6 +35,8 @@
  • API reference (ReDoc)
  • + + {% include "_dashboard_sync_card.html" %} {% endblock %} diff --git a/app/web/pages.py b/app/web/pages.py index ea591b9..a00d00a 100644 --- a/app/web/pages.py +++ b/app/web/pages.py @@ -5,16 +5,29 @@ from fastapi.responses import HTMLResponse, RedirectResponse, Response from sqlalchemy.ext.asyncio import AsyncSession from app.core.config import settings -from app.core.database import get_db +from app.core.database import AsyncSessionLocal, get_db +from app.crud import audit as audit_crud +from app.crud import augmented_part as augmented_part_crud +from app.crud import imported_part as imported_part_crud +from app.crud import imported_part_config as imported_part_config_crud +from app.crud import part_type as part_type_crud from app.crud import user as user_crud from app.deps import get_optional_web_user from app.models.user import User +from app.services import augmented_part_sync as augmented_part_sync_service +from app.services import fit_matching as fit_matching_service +from app.services import jobs from app.services import ldap as ldap_service +from app.services import odata as odata_service +from app.services import scope_guide as scope_guide_service from app.services.auth import issue_token_pair +from app.web.admin import web_require from app.web.templates import templates router = APIRouter(include_in_schema=False) +_SYNC_CARD_ID = "dashboard-sync-card" + def _safe_next(next_url: str) -> str: """Return next_url only if it's a safe same-site path, else "/". @@ -43,16 +56,339 @@ def _set_auth_cookie(response: Response, access_token: str) -> None: ) +async def _dashboard_stats(db: AsyncSession) -> dict: + scope_type = await part_type_crud.get_by_name( + db, scope_guide_service.SCOPE_TYPE_NAME + ) + scope_count = ( + await augmented_part_crud.count_filtered(db, part_type_id=scope_type.id) + if scope_type is not None + else 0 + ) + return { + "scope_count": scope_count, + "skipped_count": await augmented_part_crud.count_unlinked_filtered(db), + } + + +def _dashboard_permissions(user: User) -> dict: + return { + "can_refresh_odata": user.is_superuser + or "imported_parts:write" in user.permissions, + "can_sync_augmented_parts": user.is_superuser + or "augmented_parts:write" in user.permissions, + "can_recompute_fits": user.is_superuser or "fits:write" in user.permissions, + } + + +async def _sync_card_ctx( + db: AsyncSession, + user: User, + status_message: str | None = None, + status_error: str | None = None, +) -> dict: + return { + **(await _dashboard_stats(db)), + **_dashboard_permissions(user), + "status_message": status_message, + "status_error": status_error, + } + + @router.get("/", response_class=HTMLResponse) async def dashboard( request: Request, + db: AsyncSession = Depends(get_db), user: User | None = Depends(get_optional_web_user), ): # Anonymous visitors land on the public Scopes page instead of being # bounced to login; the dashboard itself requires an account. if user is None: return RedirectResponse(url="/scopes", status_code=status.HTTP_302_FOUND) - return templates.TemplateResponse(request, "dashboard.html", {"user": user}) + return templates.TemplateResponse( + request, + "dashboard.html", + {"user": user, **(await _sync_card_ctx(db, user))}, + ) + + +@router.post("/dashboard/refresh-odata", response_class=HTMLResponse) +async def dashboard_refresh_odata( + request: Request, + db: AsyncSession = Depends(get_db), + actor: User = Depends(web_require("imported_parts:write")), +): + config = await imported_part_config_crud.get(db) + if not config or not config.odata_url: + return templates.TemplateResponse( + request, + "_dashboard_sync_card.html", + await _sync_card_ctx( + db, actor, status_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: + 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": _SYNC_CARD_ID, + "poll_url": f"/dashboard/refresh-odata/jobs/{job.id}", + "job": job, + }, + ) + + +@router.get("/dashboard/refresh-odata/jobs/{job_id}", response_class=HTMLResponse) +async def dashboard_refresh_odata_job_status( + request: Request, + job_id: str, + db: AsyncSession = Depends(get_db), + actor: User = Depends(web_require("imported_parts:write")), +): + job = await jobs.get(db, job_id) + if job is None: + return templates.TemplateResponse( + request, + "_dashboard_sync_card.html", + await _sync_card_ctx( + db, actor, status_error="Refresh job not found — it may have expired." + ), + ) + if job.status == "running": + return templates.TemplateResponse( + request, + "admin/_job_progress.html", + { + "container_id": _SYNC_CARD_ID, + "poll_url": f"/dashboard/refresh-odata/jobs/{job_id}", + "job": job, + }, + ) + return templates.TemplateResponse( + request, + "_dashboard_sync_card.html", + await _sync_card_ctx( + db, + actor, + status_message=job.message if job.status == "done" else None, + status_error=job.message if job.status == "error" else None, + ), + ) + + +@router.post("/dashboard/sync-augmented-parts", response_class=HTMLResponse) +async def dashboard_sync_augmented_parts( + request: Request, + db: AsyncSession = Depends(get_db), + actor: User = Depends(web_require("augmented_parts:write")), +): + actor_id = actor.id + + async def _run(job_id: str) -> None: + async with AsyncSessionLocal() as db: + try: + result = await augmented_part_sync_service.sync(db) + 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="AugmentedPart", + resource_id=None, + description=( + f"Sync: {result.created} created, {result.archived} archived, " + f"{result.skipped} skipped (no type match)" + ), + ) + await jobs.mark_done( + job_id, + message=( + f"Sync complete: {result.created} created, " + f"{result.archived} archived" + + ( + f", {result.skipped} skipped (type not recognised)" + if result.skipped + else "" + ) + + "." + ), + ) + + job = await jobs.start("augmented_part_sync", _run) + return templates.TemplateResponse( + request, + "admin/_job_progress.html", + { + "container_id": _SYNC_CARD_ID, + "poll_url": f"/dashboard/sync-augmented-parts/jobs/{job.id}", + "job": job, + }, + ) + + +@router.get( + "/dashboard/sync-augmented-parts/jobs/{job_id}", response_class=HTMLResponse +) +async def dashboard_sync_augmented_parts_job_status( + request: Request, + job_id: str, + db: AsyncSession = Depends(get_db), + actor: User = Depends(web_require("augmented_parts:write")), +): + job = await jobs.get(db, job_id) + if job is None: + return templates.TemplateResponse( + request, + "_dashboard_sync_card.html", + await _sync_card_ctx( + db, actor, status_error="Sync job not found — it may have expired." + ), + ) + if job.status == "running": + return templates.TemplateResponse( + request, + "admin/_job_progress.html", + { + "container_id": _SYNC_CARD_ID, + "poll_url": f"/dashboard/sync-augmented-parts/jobs/{job_id}", + "job": job, + }, + ) + return templates.TemplateResponse( + request, + "_dashboard_sync_card.html", + await _sync_card_ctx( + db, + actor, + status_message=job.message if job.status == "done" else None, + status_error=job.message if job.status == "error" else None, + ), + ) + + +@router.post("/dashboard/recompute-fits", response_class=HTMLResponse) +async def dashboard_recompute_fits( + request: Request, + db: AsyncSession = Depends(get_db), + actor: User = Depends(web_require("fits:write")), +): + actor_id = actor.id + + async def _run(job_id: str) -> None: + async with AsyncSessionLocal() as db: + try: + result = await fit_matching_service.recompute_all(db) + except Exception as exc: + await jobs.mark_error(job_id, f"Recompute failed: {exc}") + return + actor_row = await user_crud.get(db, actor_id) + await audit_crud.log( + db, + actor=actor_row, + action="update", + resource_type="Fit", + resource_id=None, + description=( + f"Recomputed fits for all parts: {result.created} created, " + f"{result.removed} removed" + ), + ) + await jobs.mark_done( + job_id, + message=( + f"Recompute complete: {result.created} created, " + f"{result.removed} removed." + ), + ) + + job = await jobs.start("fit_recompute_all", _run) + return templates.TemplateResponse( + request, + "admin/_job_progress.html", + { + "container_id": _SYNC_CARD_ID, + "poll_url": f"/dashboard/recompute-fits/jobs/{job.id}", + "job": job, + }, + ) + + +@router.get("/dashboard/recompute-fits/jobs/{job_id}", response_class=HTMLResponse) +async def dashboard_recompute_fits_job_status( + request: Request, + job_id: str, + db: AsyncSession = Depends(get_db), + actor: User = Depends(web_require("fits:write")), +): + job = await jobs.get(db, job_id) + if job is None: + return templates.TemplateResponse( + request, + "_dashboard_sync_card.html", + await _sync_card_ctx( + db, + actor, + status_error="Recompute job not found — it may have expired.", + ), + ) + if job.status == "running": + return templates.TemplateResponse( + request, + "admin/_job_progress.html", + { + "container_id": _SYNC_CARD_ID, + "poll_url": f"/dashboard/recompute-fits/jobs/{job_id}", + "job": job, + }, + ) + return templates.TemplateResponse( + request, + "_dashboard_sync_card.html", + await _sync_card_ctx( + db, + actor, + status_message=job.message if job.status == "done" else None, + status_error=job.message if job.status == "error" else None, + ), + ) @router.get("/login", response_class=HTMLResponse) diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py new file mode 100644 index 0000000..87c7015 --- /dev/null +++ b/tests/test_dashboard.py @@ -0,0 +1,241 @@ +import asyncio +import re + +import pytest +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.augmented_part import AugmentedPart +from app.models.part_type import PartType + +_JOB_URL_RE = re.compile(r'hx-get="([^"]+/jobs/[^"]+)"') + + +@pytest_asyncio.fixture +async def db_sessionmaker(tmp_path): + """Override conftest's in-memory+StaticPool fixture for this module. + + The dashboard's sync/recompute actions run a real background asyncio task + concurrently with polling requests, both hitting the DB at once. A real + file-backed DB (its own connection pool) supports that, unlike StaticPool's + single shared connection (see tests/test_admin_background_jobs.py). + """ + db_path = tmp_path / "dashboard_test.db" + engine = create_async_engine(f"sqlite+aiosqlite:///{db_path}") + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + maker = async_sessionmaker(engine, expire_on_commit=False) + yield maker + await engine.dispose() + + +async def _web_login(client, email, password="secret123"): + resp = await client.post("/login", data={"email": email, "password": password}) + assert resp.status_code == 303 + + +async def _poll_until_done(client, url, max_tries=50): + """Follow the same htmx polling loop a browser would, until no further + ``hx-get`` job-status link is present in the response.""" + body = "" + for _ in range(max_tries): + resp = await client.get(url) + assert resp.status_code == 200 + body = resp.text + match = _JOB_URL_RE.search(body) + if not match: + return body + url = match.group(1) + await asyncio.sleep(0.02) + raise AssertionError(f"job did not finish after {max_tries} polls: {body!r}") + + +@pytest.mark.asyncio +async def test_dashboard_shows_scope_and_skipped_counts( + client, seeded, make_user, db_sessionmaker +): + async with db_sessionmaker() as db: + scope_t = PartType(name="Scope") + db.add(scope_t) + await db.flush() + db.add_all( + [ + AugmentedPart( + description="Alpha 3-18x50", + colour_id=1, + manufacturer_id=1, + part_type_id=scope_t.id, + ), + AugmentedPart( + description="Zeta 1-6x24", + colour_id=1, + manufacturer_id=1, + part_type_id=scope_t.id, + ), + ] + ) + await db.commit() + await make_user("reader@example.com", role_ids=[seeded["roles"]["user"].id]) + await _web_login(client, "reader@example.com") + + resp = await client.get("/") + assert resp.status_code == 200 + assert "Data sync" in resp.text + assert "
    2
    " in resp.text # scope count + assert "
    0
    " in resp.text # nothing imported/skipped yet + + +@pytest.mark.asyncio +async def test_dashboard_hides_action_buttons_without_permissions( + client, seeded, make_user +): + await make_user("plain@example.com", role_ids=[seeded["roles"]["user"].id]) + await _web_login(client, "plain@example.com") + + resp = await client.get("/") + assert resp.status_code == 200 + assert "Refresh from OData" not in resp.text + assert "Sync augmented parts" not in resp.text + assert "Recompute possible fits" not in resp.text + + +@pytest.mark.asyncio +async def test_dashboard_shows_action_buttons_for_superuser(client, make_user): + await make_user("boss@example.com", is_superuser=True) + await _web_login(client, "boss@example.com") + + resp = await client.get("/") + assert resp.status_code == 200 + assert "Refresh from OData" in resp.text + assert "Sync augmented parts" in resp.text + assert "Recompute possible fits" in resp.text + + +@pytest.mark.asyncio +async def test_dashboard_shows_only_permitted_button( + client, seeded, make_user, db_sessionmaker +): + async with db_sessionmaker() as db: + perms = seeded["perms"] + editor = await role_crud.create( + db, name="fits-only-editor", permission_ids=[perms["fits:write"].id] + ) + editor_id = editor.id + await make_user("fitsonly@example.com", role_ids=[editor_id]) + await _web_login(client, "fitsonly@example.com") + + resp = await client.get("/") + assert resp.status_code == 200 + assert "Recompute possible fits" in resp.text + assert "Refresh from OData" not in resp.text + assert "Sync augmented parts" not in resp.text + + +@pytest.mark.asyncio +async def test_dashboard_refresh_odata_without_config_skips_job( + client, seeded, make_user, db_sessionmaker +): + async with db_sessionmaker() as db: + perms = seeded["perms"] + editor = await role_crud.create( + db, + name="dash-refresh-noconfig-editor", + permission_ids=[perms["imported_parts:write"].id], + ) + editor_id = editor.id + await make_user("dashrefresher2@example.com", role_ids=[editor_id]) + await _web_login(client, "dashrefresher2@example.com") + + resp = await client.post("/dashboard/refresh-odata") + assert resp.status_code == 200 + assert "OData URL is not configured." in resp.text + assert _JOB_URL_RE.search(resp.text) is None + + +@pytest.mark.asyncio +async def test_dashboard_sync_augmented_parts_runs_in_background( + client, seeded, make_user, db_sessionmaker, monkeypatch +): + monkeypatch.setattr("app.web.pages.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="dash-sync-editor", + permission_ids=[perms["augmented_parts:write"].id], + ) + editor_id = editor.id + await make_user("dashsyncer@example.com", role_ids=[editor_id]) + await _web_login(client, "dashsyncer@example.com") + + resp = await client.post("/dashboard/sync-augmented-parts") + assert resp.status_code == 200 + match = _JOB_URL_RE.search(resp.text) + assert match, resp.text + + final_body = await _poll_until_done(client, match.group(1)) + assert "Sync complete" in final_body + assert "dashboard-sync-card" in final_body + + +@pytest.mark.asyncio +async def test_dashboard_recompute_fits_runs_in_background( + client, seeded, make_user, db_sessionmaker, monkeypatch +): + monkeypatch.setattr("app.web.pages.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="dash-recompute-editor", permission_ids=[perms["fits:write"].id] + ) + editor_id = editor.id + await make_user("dashrecomputer@example.com", role_ids=[editor_id]) + await _web_login(client, "dashrecomputer@example.com") + + resp = await client.post("/dashboard/recompute-fits") + assert resp.status_code == 200 + match = _JOB_URL_RE.search(resp.text) + assert match, resp.text + + final_body = await _poll_until_done(client, match.group(1)) + assert "Recompute complete" in final_body + + +@pytest.mark.asyncio +async def test_dashboard_refresh_odata_runs_in_background( + client, seeded, make_user, db_sessionmaker, monkeypatch +): + monkeypatch.setattr("app.web.pages.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.pages.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="dash-refresh-editor", + permission_ids=[perms["imported_parts:write"].id], + ) + editor_id = editor.id + await make_user("dashrefresher@example.com", role_ids=[editor_id]) + await _web_login(client, "dashrefresher@example.com") + + resp = await client.post("/dashboard/refresh-odata") + assert resp.status_code == 200 + 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