Allowing unauthenticated users to see scopes

This commit is contained in:
Ian Roddis committed 2026-08-18 13:44:55 -03:00
1 parent 2ce4f91e4f
commit 3253d8e858
8 files changed
+136 -27

No files matched your search

+1 -1
View File
@@ -49,7 +49,7 @@ def create_app() -> FastAPI:
# Preserve the originally requested URL so we can return there after # Preserve the originally requested URL so we can return there after
# login. Only meaningful for GET navigations (the ones users deep-link). # login. Only meaningful for GET navigations (the ones users deep-link).
login_url = "/login" 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 target = request.url.path
if request.url.query: if request.url.query:
target += "?" + request.url.query target += "?" + request.url.query
+7
View File
@@ -29,6 +29,7 @@ async def build(
scope_model_id: int | None = None, scope_model_id: int | None = None,
sub_part_type_id: int | None = None, sub_part_type_id: int | None = None,
fits_part_type_id: int | None = None, fits_part_type_id: int | None = None,
only_exact: bool = False,
) -> dict: ) -> dict:
"""Return the context for ``scopes.html``. """Return the context for ``scopes.html``.
@@ -40,6 +41,10 @@ async def build(
themselves — this always returns the full filtered set, since 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. ``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:: Shape::
{ {
@@ -80,6 +85,8 @@ async def build(
fits = await fit_crud.list_for_parts( fits = await fit_crud.list_for_parts(
db, list(scope_ids), exclude_label=FitLabel.NONE 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. # Resolve the "other" (non-scope) part on each fit in a single batched query.
other_ids: set[int] = set() other_ids: set[int] = set()
+8 -3
View File
@@ -1,11 +1,12 @@
{% set su = user.is_superuser %} {% set su = user.is_superuser if user else False %}
{% set p = user.permissions %} {% 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_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_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_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_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_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 ───────────────────────────────────────────────────────── #} {# ── SVG icon sprite ───────────────────────────────────────────────────────── #}
<svg xmlns="http://www.w3.org/2000/svg" style="display:none"> <svg xmlns="http://www.w3.org/2000/svg" style="display:none">
@@ -232,8 +233,12 @@
<a href="/docs" target="_blank">{{ icon("book-open") }} API docs</a> <a href="/docs" target="_blank">{{ icon("book-open") }} API docs</a>
</nav> </nav>
{% if user %}
<form method="post" action="/logout" class="logout"> <form method="post" action="/logout" class="logout">
<span class="muted">{{ user.email }}</span> <span class="muted">{{ user.email }}</span>
<button type="submit" class="btn btn-ghost">Sign out</button> <button type="submit" class="btn btn-ghost">Sign out</button>
</form> </form>
{% else %}
<a href="/login" class="btn btn-ghost">Sign in</a>
{% endif %}
</header> </header>
+2 -1
View File
@@ -7,7 +7,8 @@
<p class="muted"> <p class="muted">
Every scope on record, with the accessories that fit it. Each part-type Every scope on record, with the accessories that fit it. Each part-type
column lists matching parts; <span class="fit-badge fit-badge-exact">Exact</span> column lists matching parts; <span class="fit-badge fit-badge-exact">Exact</span>
is a confirmed fit, <span class="fit-badge fit-badge-possible">Possible</span> a candidate. is a confirmed fit{% if not only_exact %}, <span class="fit-badge fit-badge-possible">Possible</span> a candidate{% endif %}.
{% if only_exact %}<a href="/login">Sign in</a> to also see unconfirmed candidate fits.{% endif %}
</p> </p>
<div class="filter-bar"> <div class="filter-bar">
+6 -2
View File
@@ -7,7 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings from app.core.config import settings
from app.core.database import get_db from app.core.database import get_db
from app.crud import user as user_crud 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.models.user import User
from app.services import ldap as ldap_service from app.services import ldap as ldap_service
from app.services.auth import issue_token_pair 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) @router.get("/", response_class=HTMLResponse)
async def dashboard( async def dashboard(
request: Request, 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}) return templates.TemplateResponse(request, "dashboard.html", {"user": user})
+45 -10
View File
@@ -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. 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 fastapi.responses import HTMLResponse
from sqlalchemy.ext.asyncio import AsyncSession 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 part_type as part_type_crud
from app.crud import scope_model as scope_model_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.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.models.user import User
from app.services import scope_guide as scope_guide_service from app.services import scope_guide as scope_guide_service
from app.web.admin import web_require from app.web.admin import web_require
@@ -27,17 +28,39 @@ router = APIRouter(include_in_schema=False)
_PAGE_SIZE = 50 _PAGE_SIZE = 50
_TABLE_TARGET = "#scopes-table-card" _TABLE_TARGET = "#scopes-table-card"
_FILTER_PATH = "/scopes/filter" _FILTER_PATH = "/scopes/filter"
_REQUIRED_PERMS = {"augmented_parts:read", "fits:read"}
def _to_int(value: str) -> int | None: def _to_int(value: str) -> int | None:
return int(value) if value.strip() else None return int(value) if value.strip() else None
def _can_write_scopes(user: User) -> bool: def _can_write_scopes(user: User | None) -> bool:
return user.is_superuser or "augmented_parts:write" in user.permissions 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. """Run the guide query for the given raw (string) filter values.
Always returns the full matching row set — pagination is applied by the 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"]), scope_model_id=_to_int(filters["scope_model_id"]),
sub_part_type_id=_to_int(filters["sub_part_type_id"]), sub_part_type_id=_to_int(filters["sub_part_type_id"]),
fits_part_type_id=_to_int(filters["fits_part_type_id"]), fits_part_type_id=_to_int(filters["fits_part_type_id"]),
only_exact=only_exact,
) )
async def _paginated_guide( 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]: ) -> tuple[dict, dict]:
"""Run the guide query, then slice its rows to the requested page. """Run the guide query, then slice its rows to the requested page.
Returns ``(guide, pagination)`` where ``guide["rows"]`` is already sliced. 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"] all_rows = guide["rows"]
pagination = paginate( pagination = paginate(
_FILTER_PATH, filters, page, page_size, len(all_rows), _TABLE_TARGET _FILTER_PATH, filters, page, page_size, len(all_rows), _TABLE_TARGET
@@ -95,7 +123,7 @@ async def scopes_page(
sub_part_type_id: str = "", sub_part_type_id: str = "",
fits_part_type_id: str = "", fits_part_type_id: str = "",
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
user: User = Depends(web_require("augmented_parts:read", "fits:read")), user: User | None = Depends(_optional_scopes_user),
): ):
filters = { filters = {
"description": description, "description": description,
@@ -104,7 +132,10 @@ async def scopes_page(
"sub_part_type_id": sub_part_type_id, "sub_part_type_id": sub_part_type_id,
"fits_part_type_id": fits_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( return templates.TemplateResponse(
request, request,
"scopes.html", "scopes.html",
@@ -112,6 +143,7 @@ async def scopes_page(
"user": user, "user": user,
"filters": filters, "filters": filters,
"can_write_scopes": _can_write_scopes(user), "can_write_scopes": _can_write_scopes(user),
"only_exact": only_exact,
"pagination": pagination, "pagination": pagination,
**guide, **guide,
**(await _filter_options(db)), **(await _filter_options(db)),
@@ -130,7 +162,7 @@ async def scopes_filter(
page: int = 1, page: int = 1,
page_size: int = _PAGE_SIZE, page_size: int = _PAGE_SIZE,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
user: User = Depends(web_require("augmented_parts:read", "fits:read")), user: User | None = Depends(_optional_scopes_user),
): ):
filters = { filters = {
"description": description, "description": description,
@@ -139,7 +171,10 @@ async def scopes_filter(
"sub_part_type_id": sub_part_type_id, "sub_part_type_id": sub_part_type_id,
"fits_part_type_id": fits_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( return templates.TemplateResponse(
request, request,
"_scopes_table_card.html", "_scopes_table_card.html",
+18 -6
View File
@@ -3,23 +3,35 @@ import pytest
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_deep_link_redirects_to_login_with_next(client): 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.status_code == 302
assert resp.headers["location"] == "/login?next=%2Fscopes" assert resp.headers["location"] == "/login?next=%2Fadmin%2Fusers"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_deep_link_preserves_query_string(client): 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.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 @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("/") resp = await client.get("/")
assert resp.status_code == 302 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 @pytest.mark.asyncio
+49 -4
View File
@@ -341,11 +341,56 @@ async def test_scopes_page_forbidden_without_permission(client, seeded, make_use
@pytest.mark.asyncio @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") resp = await client.get("/scopes")
assert resp.status_code == 302 assert resp.status_code == 200
# Deep link is preserved so the user lands back here after logging in. body = resp.text
assert resp.headers["location"] == "/login?next=%2Fscopes" 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 @pytest.mark.asyncio