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
|
||||
# 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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 ───────────────────────────────────────────────────────── #}
|
||||
<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>
|
||||
</nav>
|
||||
{% if user %}
|
||||
<form method="post" action="/logout" class="logout">
|
||||
<span class="muted">{{ user.email }}</span>
|
||||
<button type="submit" class="btn btn-ghost">Sign out</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<a href="/login" class="btn btn-ghost">Sign in</a>
|
||||
{% endif %}
|
||||
</header>
|
||||
@@ -7,7 +7,8 @@
|
||||
<p class="muted">
|
||||
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>
|
||||
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>
|
||||
|
||||
<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.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})
|
||||
|
||||
|
||||
|
||||
+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.
|
||||
"""
|
||||
|
||||
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",
|
||||
|
||||
Reference in new issue
Block a user