From 3253d8e858b8e7c359babd37c4181e45b47452ec Mon Sep 17 00:00:00 2001 From: Ian Roddis <31021769+iroddis@users.noreply.github.com> Date: Tue, 18 Aug 2026 13:44:55 -0300 Subject: [PATCH] Allowing unauthenticated users to see scopes --- app/main.py | 2 +- app/services/scope_guide.py | 7 +++++ app/templates/_nav.html | 11 ++++++-- app/templates/scopes.html | 3 +- app/web/pages.py | 8 ++++-- app/web/scopes.py | 55 +++++++++++++++++++++++++++++------- tests/test_login_redirect.py | 24 ++++++++++++---- tests/test_scope_guide.py | 53 +++++++++++++++++++++++++++++++--- 8 files changed, 136 insertions(+), 27 deletions(-) diff --git a/app/main.py b/app/main.py index e7f0ed3..584d24c 100644 --- a/app/main.py +++ b/app/main.py @@ -49,7 +49,7 @@ def create_app() -> FastAPI: # Preserve the originally requested URL so we can return there after # login. Only meaningful for GET navigations (the ones users deep-link). login_url = "/login" - if request.method == "GET" and request.url.path not in ("/", "/login"): + if request.method == "GET" and request.url.path != "/login": target = request.url.path if request.url.query: target += "?" + request.url.query diff --git a/app/services/scope_guide.py b/app/services/scope_guide.py index 363f1c2..5561921 100644 --- a/app/services/scope_guide.py +++ b/app/services/scope_guide.py @@ -29,6 +29,7 @@ async def build( scope_model_id: int | None = None, sub_part_type_id: int | None = None, fits_part_type_id: int | None = None, + only_exact: bool = False, ) -> dict: """Return the context for ``scopes.html``. @@ -40,6 +41,10 @@ async def build( themselves — this always returns the full filtered set, since ``fits_part_type_id`` is a post-filter that can't be pushed into the DB query. + ``only_exact`` drops ``possible`` fits entirely, keeping only user-confirmed + ``exact`` ones — used for anonymous visitors, who shouldn't see unconfirmed + fit candidates. + Shape:: { @@ -80,6 +85,8 @@ async def build( fits = await fit_crud.list_for_parts( db, list(scope_ids), exclude_label=FitLabel.NONE ) + if only_exact: + fits = [f for f in fits if f.label == FitLabel.EXACT] # Resolve the "other" (non-scope) part on each fit in a single batched query. other_ids: set[int] = set() diff --git a/app/templates/_nav.html b/app/templates/_nav.html index 5245a66..f0d10d8 100644 --- a/app/templates/_nav.html +++ b/app/templates/_nav.html @@ -1,11 +1,12 @@ -{% set su = user.is_superuser %} -{% set p = user.permissions %} +{% set su = user.is_superuser if user else False %} +{% set p = user.permissions if user else [] %} {% set can_admin = su or "users:read" in p or "roles:read" in p or "audit_log:read" in p %} {% set can_connections = su or "clamp_connections:read" in p or "iso_threads:read" in p or "static_connections:read" in p or "unc_threads:read" in p %} {% set can_labels = su or "attachment_point_names:read" in p or "manufacturers:read" in p or "part_colours:read" in p or "part_types:read" in p or "scope_models:read" in p or "sub_part_types:read" in p %} {% set can_database = su or "imported_parts:read" in p or "augmented_parts:read" in p or "attachment_points:read" in p %} {% set can_parts = can_connections or can_labels or can_database %} -{% set can_scopes = su or ("augmented_parts:read" in p and "fits:read" in p) %} +{# /scopes is public, so anonymous visitors see the link too; logged-in users still need the read perms. #} +{% set can_scopes = (not user) or su or ("augmented_parts:read" in p and "fits:read" in p) %} {# ── SVG icon sprite ───────────────────────────────────────────────────────── #} @@ -232,8 +233,12 @@ {{ icon("book-open") }} API docs + {% if user %}
{{ user.email }} + {% else %} + Sign in + {% endif %} diff --git a/app/templates/scopes.html b/app/templates/scopes.html index 4f6e6f5..a79ec86 100644 --- a/app/templates/scopes.html +++ b/app/templates/scopes.html @@ -7,7 +7,8 @@

Every scope on record, with the accessories that fit it. Each part-type column lists matching parts; Exact - is a confirmed fit, Possible a candidate. + is a confirmed fit{% if not only_exact %}, Possible a candidate{% endif %}. + {% if only_exact %}Sign in to also see unconfirmed candidate fits.{% endif %}

diff --git a/app/web/pages.py b/app/web/pages.py index 694d27d..ea591b9 100644 --- a/app/web/pages.py +++ b/app/web/pages.py @@ -7,7 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.core.config import settings from app.core.database import get_db from app.crud import user as user_crud -from app.deps import get_current_web_user +from app.deps import get_optional_web_user from app.models.user import User from app.services import ldap as ldap_service from app.services.auth import issue_token_pair @@ -46,8 +46,12 @@ def _set_auth_cookie(response: Response, access_token: str) -> None: @router.get("/", response_class=HTMLResponse) async def dashboard( request: Request, - user: User = Depends(get_current_web_user), + 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}) diff --git a/app/web/scopes.py b/app/web/scopes.py index 73e2be7..6e5c6c5 100644 --- a/app/web/scopes.py +++ b/app/web/scopes.py @@ -5,7 +5,7 @@ non-Scope part type listing the accessories that fit each scope. Supports HTMX search/filter over scope attributes and required accessory fits. """ -from fastapi import APIRouter, Depends, Form, Request +from fastapi import APIRouter, Depends, Form, HTTPException, Request, status from fastapi.responses import HTMLResponse from sqlalchemy.ext.asyncio import AsyncSession @@ -16,6 +16,7 @@ from app.crud import manufacturer as manufacturer_crud from app.crud import part_type as part_type_crud from app.crud import scope_model as scope_model_crud from app.crud import sub_part_type as sub_part_type_crud +from app.deps import get_optional_web_user from app.models.user import User from app.services import scope_guide as scope_guide_service from app.web.admin import web_require @@ -27,17 +28,39 @@ router = APIRouter(include_in_schema=False) _PAGE_SIZE = 50 _TABLE_TARGET = "#scopes-table-card" _FILTER_PATH = "/scopes/filter" +_REQUIRED_PERMS = {"augmented_parts:read", "fits:read"} def _to_int(value: str) -> int | None: return int(value) if value.strip() else None -def _can_write_scopes(user: User) -> bool: - return user.is_superuser or "augmented_parts:write" in user.permissions +def _can_write_scopes(user: User | None) -> bool: + return user is not None and ( + user.is_superuser or "augmented_parts:write" in user.permissions + ) -async def _build(db: AsyncSession, filters: dict) -> dict: +async def _optional_scopes_user( + user: User | None = Depends(get_optional_web_user), +) -> User | None: + """Anyone may view the scopes page; logged-in users still need the read perms. + + Anonymous visitors are allowed through (and get an exact-fits-only view — + see ``only_exact`` in ``scope_guide.build``); a logged-in user missing the + permissions gets the usual 403, matching ``web_require``'s behaviour. + """ + if user is not None and not ( + user.is_superuser or _REQUIRED_PERMS.issubset(user.permissions) + ): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You do not have access to this page.", + ) + return user + + +async def _build(db: AsyncSession, filters: dict, only_exact: bool = False) -> dict: """Run the guide query for the given raw (string) filter values. Always returns the full matching row set — pagination is applied by the @@ -50,17 +73,22 @@ async def _build(db: AsyncSession, filters: dict) -> dict: scope_model_id=_to_int(filters["scope_model_id"]), sub_part_type_id=_to_int(filters["sub_part_type_id"]), fits_part_type_id=_to_int(filters["fits_part_type_id"]), + only_exact=only_exact, ) async def _paginated_guide( - db: AsyncSession, filters: dict, page: int, page_size: int + db: AsyncSession, + filters: dict, + page: int, + page_size: int, + only_exact: bool = False, ) -> tuple[dict, dict]: """Run the guide query, then slice its rows to the requested page. Returns ``(guide, pagination)`` where ``guide["rows"]`` is already sliced. """ - guide = await _build(db, filters) + guide = await _build(db, filters, only_exact=only_exact) all_rows = guide["rows"] pagination = paginate( _FILTER_PATH, filters, page, page_size, len(all_rows), _TABLE_TARGET @@ -95,7 +123,7 @@ async def scopes_page( sub_part_type_id: str = "", fits_part_type_id: str = "", db: AsyncSession = Depends(get_db), - user: User = Depends(web_require("augmented_parts:read", "fits:read")), + user: User | None = Depends(_optional_scopes_user), ): filters = { "description": description, @@ -104,7 +132,10 @@ async def scopes_page( "sub_part_type_id": sub_part_type_id, "fits_part_type_id": fits_part_type_id, } - guide, pagination = await _paginated_guide(db, filters, 1, _PAGE_SIZE) + only_exact = user is None + guide, pagination = await _paginated_guide( + db, filters, 1, _PAGE_SIZE, only_exact=only_exact + ) return templates.TemplateResponse( request, "scopes.html", @@ -112,6 +143,7 @@ async def scopes_page( "user": user, "filters": filters, "can_write_scopes": _can_write_scopes(user), + "only_exact": only_exact, "pagination": pagination, **guide, **(await _filter_options(db)), @@ -130,7 +162,7 @@ async def scopes_filter( page: int = 1, page_size: int = _PAGE_SIZE, db: AsyncSession = Depends(get_db), - user: User = Depends(web_require("augmented_parts:read", "fits:read")), + user: User | None = Depends(_optional_scopes_user), ): filters = { "description": description, @@ -139,7 +171,10 @@ async def scopes_filter( "sub_part_type_id": sub_part_type_id, "fits_part_type_id": fits_part_type_id, } - guide, pagination = await _paginated_guide(db, filters, page, page_size) + only_exact = user is None + guide, pagination = await _paginated_guide( + db, filters, page, page_size, only_exact=only_exact + ) return templates.TemplateResponse( request, "_scopes_table_card.html", diff --git a/tests/test_login_redirect.py b/tests/test_login_redirect.py index 4fffade..1c0531b 100644 --- a/tests/test_login_redirect.py +++ b/tests/test_login_redirect.py @@ -3,23 +3,35 @@ import pytest @pytest.mark.asyncio async def test_deep_link_redirects_to_login_with_next(client): - resp = await client.get("/scopes") + resp = await client.get("/admin/users") assert resp.status_code == 302 - assert resp.headers["location"] == "/login?next=%2Fscopes" + assert resp.headers["location"] == "/login?next=%2Fadmin%2Fusers" @pytest.mark.asyncio async def test_deep_link_preserves_query_string(client): - resp = await client.get("/scopes?description=alpha") + resp = await client.get("/admin/users?sort=email") assert resp.status_code == 302 - assert resp.headers["location"] == "/login?next=%2Fscopes%3Fdescription%3Dalpha" + assert resp.headers["location"] == "/login?next=%2Fadmin%2Fusers%3Fsort%3Demail" @pytest.mark.asyncio -async def test_root_redirect_has_no_next(client): +async def test_root_redirects_anonymous_to_scopes(client): resp = await client.get("/") assert resp.status_code == 302 - assert resp.headers["location"] == "/login" + assert resp.headers["location"] == "/scopes" + + +@pytest.mark.asyncio +async def test_root_renders_dashboard_when_logged_in(client, make_user): + await make_user("dash@example.com") + resp = await client.post( + "/login", data={"email": "dash@example.com", "password": "secret123"} + ) + assert resp.status_code == 303 + resp = await client.get("/") + assert resp.status_code == 200 + assert "dash@example.com" in resp.text @pytest.mark.asyncio diff --git a/tests/test_scope_guide.py b/tests/test_scope_guide.py index a31af15..206c5ee 100644 --- a/tests/test_scope_guide.py +++ b/tests/test_scope_guide.py @@ -341,11 +341,56 @@ async def test_scopes_page_forbidden_without_permission(client, seeded, make_use @pytest.mark.asyncio -async def test_scopes_page_redirects_when_anonymous(client): +async def test_scopes_page_renders_for_anonymous(client, db_sessionmaker): + async with db_sessionmaker() as db: + await _seed_guide_data(db) resp = await client.get("/scopes") - assert resp.status_code == 302 - # Deep link is preserved so the user lands back here after logging in. - assert resp.headers["location"] == "/login?next=%2Fscopes" + assert resp.status_code == 200 + body = resp.text + assert "Alpha 3-18x50" in body + assert "fit-badge-exact" in body + # Possible fits (e.g. the ARD) are hidden entirely from anonymous visitors. + assert "ARD 50mm" not in body + assert "fit-badge-possible" not in body + + +@pytest.mark.asyncio +async def test_scopes_page_hides_write_controls_for_anonymous(client, db_sessionmaker): + async with db_sessionmaker() as db: + await _seed_guide_data(db) + resp = await client.get("/scopes") + assert resp.status_code == 200 + assert 'name="scope_ids"' not in resp.text + assert "delete-selected-scopes-btn" not in resp.text + + +@pytest.mark.asyncio +async def test_scopes_filter_endpoint_works_for_anonymous(client, db_sessionmaker): + async with db_sessionmaker() as db: + await _seed_guide_data(db) + resp = await client.get("/scopes/filter", params={"description": "Alpha"}) + assert resp.status_code == 200 + assert "Alpha 3-18x50" in resp.text + assert "Zeta 1-6x24" not in resp.text + + +@pytest.mark.asyncio +async def test_build_only_exact_drops_possible_fits(db_sessionmaker): + async with db_sessionmaker() as db: + ids = await _seed_guide_data(db) + guide = await scope_guide.build(db, only_exact=True) + + cover_id, ard_id = ids["cover"].id, ids["ard"].id + rows = {r["scope"].description: r for r in guide["rows"]} + + s1 = rows["Alpha 3-18x50"] + assert [(e["part"].description, e["label"]) for e in s1["cells"][cover_id]] == [ + ("Flip cover 50mm", "exact") + ] + assert ard_id not in s1["cells"] # the ARD fit was only "possible" + + s2 = rows["Zeta 1-6x24"] + assert cover_id not in s2["cells"] # S2's flip-cover fit was only "possible" @pytest.mark.asyncio