Allowing unauthenticated users to see scopes
This commit is contained in:
1 parent
2ce4f91e4f
commit
3253d8e858
8 files changed
+136
-27
No files matched your search
+1
-1
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user