173 lines
5.2 KiB
Python
173 lines
5.2 KiB
Python
"""FastAPI routes for scope resources.
|
|
|
|
A scope is a named grouping of sessions that provides a visibility boundary
|
|
within a peer. Internally a scope is a peer named ``scope.<name>`` that
|
|
observes its member sessions and never speaks; these routes are the facade
|
|
that keeps the observer/observed mechanics hidden.
|
|
|
|
All scopes routes require a workspace-level (or admin) key: scopes are an
|
|
app-level admin surface, so peer- and session-scoped keys are rejected.
|
|
|
|
Note: scope membership only affects messages ingested *after* the membership
|
|
change. Conclusions already derived are neither backfilled on add nor
|
|
reconciled on removal.
|
|
"""
|
|
|
|
import logging
|
|
|
|
from fastapi import APIRouter, Body, Depends, Path, Query, Response
|
|
from fastapi_pagination import Page
|
|
from fastapi_pagination.ext.sqlalchemy import apaginate
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from src import crud, schemas
|
|
from src.dependencies import db, read_db
|
|
from src.security import require_auth
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(
|
|
prefix="/workspaces/{workspace_id}/scopes",
|
|
tags=["scopes"],
|
|
)
|
|
|
|
|
|
@router.post(
|
|
"",
|
|
response_model=schemas.Scope,
|
|
dependencies=[Depends(require_auth(workspace_name="workspace_id"))],
|
|
)
|
|
async def get_or_create_scope(
|
|
response: Response,
|
|
workspace_id: str = Path(...),
|
|
scope: schemas.ScopeCreate = Body(..., description="Scope creation parameters"),
|
|
db: AsyncSession = db,
|
|
):
|
|
"""
|
|
Get a Scope by ID or create a new Scope with the given ID.
|
|
|
|
Returns 201 when the scope is created and 200 when it already exists.
|
|
A pre-existing peer occupying the scope's reserved internal name is never
|
|
adopted; that conflict returns 409.
|
|
"""
|
|
result = await crud.get_or_create_scopes(db, workspace_id, [scope])
|
|
await db.commit()
|
|
await result.post_commit()
|
|
response.status_code = 201 if result.created else 200
|
|
return result.resource[0]
|
|
|
|
|
|
@router.post(
|
|
"/list",
|
|
response_model=Page[schemas.Scope],
|
|
dependencies=[Depends(require_auth(workspace_name="workspace_id"))],
|
|
)
|
|
async def get_scopes(
|
|
workspace_id: str = Path(...),
|
|
reverse: bool = Query(False, description="Whether to reverse the order of results"),
|
|
db: AsyncSession = read_db,
|
|
):
|
|
"""Get all Scopes for a Workspace. Results are paginated."""
|
|
return await apaginate(
|
|
db,
|
|
await crud.get_scopes(workspace_name=workspace_id, reverse=reverse),
|
|
)
|
|
|
|
|
|
@router.get(
|
|
"/{scope_id}",
|
|
response_model=schemas.Scope,
|
|
dependencies=[Depends(require_auth(workspace_name="workspace_id"))],
|
|
)
|
|
async def get_scope(
|
|
workspace_id: str = Path(...),
|
|
scope_id: str = Path(...),
|
|
db: AsyncSession = read_db,
|
|
):
|
|
"""Get a single Scope by ID."""
|
|
return await crud.get_scope_or_raise(db, workspace_id, scope_id)
|
|
|
|
|
|
@router.post(
|
|
"/{scope_id}/sessions",
|
|
status_code=204,
|
|
response_model=None,
|
|
dependencies=[Depends(require_auth(workspace_name="workspace_id"))],
|
|
)
|
|
async def add_sessions_to_scope(
|
|
workspace_id: str = Path(...),
|
|
scope_id: str = Path(...),
|
|
body: schemas.ScopeSessionsAdd = Body(
|
|
..., description="IDs of the sessions to add to the scope"
|
|
),
|
|
db: AsyncSession = db,
|
|
):
|
|
"""
|
|
Add Sessions to a Scope.
|
|
|
|
All named sessions must already exist (404 otherwise). Adding a session that
|
|
is already a member is a no-op. List the resulting membership with
|
|
`POST /scopes/{scope_id}/sessions/list`.
|
|
|
|
Note: membership applies only to messages ingested after this call;
|
|
conclusions already derived are not backfilled.
|
|
"""
|
|
await crud.add_sessions_to_scope(
|
|
db,
|
|
workspace_name=workspace_id,
|
|
scope_name=scope_id,
|
|
session_names=body.session_ids,
|
|
)
|
|
|
|
|
|
@router.delete(
|
|
"/{scope_id}/sessions/{session_id}",
|
|
status_code=204,
|
|
response_model=None,
|
|
dependencies=[Depends(require_auth(workspace_name="workspace_id"))],
|
|
)
|
|
async def remove_session_from_scope(
|
|
workspace_id: str = Path(...),
|
|
scope_id: str = Path(...),
|
|
session_id: str = Path(...),
|
|
db: AsyncSession = db,
|
|
):
|
|
"""
|
|
Remove a Session from a Scope.
|
|
|
|
Note: conclusions already derived while the session was a member are left in
|
|
place.
|
|
"""
|
|
await crud.remove_session_from_scope(
|
|
db,
|
|
workspace_name=workspace_id,
|
|
scope_name=scope_id,
|
|
session_name=session_id,
|
|
)
|
|
|
|
|
|
@router.post(
|
|
"/{scope_id}/sessions/list",
|
|
response_model=Page[schemas.Session],
|
|
dependencies=[Depends(require_auth(workspace_name="workspace_id"))],
|
|
)
|
|
async def get_scope_sessions(
|
|
workspace_id: str = Path(...),
|
|
scope_id: str = Path(...),
|
|
reverse: bool = Query(False, description="Whether to reverse the order of results"),
|
|
db: AsyncSession = read_db,
|
|
):
|
|
"""Get the Sessions that are members of a Scope, paginated.
|
|
|
|
Ordered by how long each session has been a member, oldest first.
|
|
"""
|
|
# Distinguishes an empty scope from one that does not exist; the query itself
|
|
# returns an empty page either way.
|
|
await crud.get_scope_or_raise(db, workspace_id, scope_id)
|
|
return await apaginate(
|
|
db,
|
|
await crud.get_scope_sessions(
|
|
workspace_name=workspace_id, scope_name=scope_id, reverse=reverse
|
|
),
|
|
)
|