CI / quality (push) Canceled after 0s
Adds claim-bound online evidence records and citation verification scripts, node removal transport/adapter, retrieval benchmark and owner-review tooling, GraphCanvas/SourceDetail knowledge browser UI, and the ingest handoff helper. Bumps the agents/gewu pack to the online-verification branch.
142 lines
5.3 KiB
Python
142 lines
5.3 KiB
Python
"""Authenticated, project-scoped node removal and restore routes."""
|
|
|
|
from collections.abc import Callable
|
|
from typing import Annotated, Literal
|
|
|
|
from fastapi import Cookie, Depends, FastAPI, Header, HTTPException, Request, Response
|
|
from pydantic import BaseModel, ConfigDict
|
|
|
|
from gewu.adapters.sqlite_node_removals import NodeRemoval, RemovalConflict, SQLiteNodeRemovalStore
|
|
from gewu.application.identity import Principal
|
|
from gewu.application.reading import CanonicalNodeReader, ReadError
|
|
from gewu.domain.contracts import RecordId
|
|
|
|
|
|
class NodeRemovalRequest(BaseModel):
|
|
model_config = ConfigDict(extra="forbid", strict=True)
|
|
confirmed: Literal[True]
|
|
|
|
|
|
class NodeRemovalItem(BaseModel):
|
|
removal_id: str
|
|
project_id: str
|
|
node_id: str
|
|
title: str
|
|
requested_at: str
|
|
restore_until: str
|
|
|
|
@classmethod
|
|
def from_record(cls, record: NodeRemoval) -> "NodeRemovalItem":
|
|
return cls(
|
|
removal_id=record.removal_id,
|
|
project_id=record.project_id,
|
|
node_id=record.node_id,
|
|
title=record.title,
|
|
requested_at=record.requested_at,
|
|
restore_until=record.restore_until,
|
|
)
|
|
|
|
|
|
class NodeRemovalList(BaseModel):
|
|
can_manage: bool
|
|
removals: list[NodeRemovalItem]
|
|
|
|
|
|
def register_node_removal_routes(
|
|
app: FastAPI,
|
|
store: SQLiteNodeRemovalStore,
|
|
reader: CanonicalNodeReader,
|
|
authenticate: Callable[..., Principal],
|
|
require_same_origin: Callable[[Request], None],
|
|
) -> None:
|
|
def human(
|
|
request: Request,
|
|
response: Response,
|
|
authorization: str | None = Header(default=None),
|
|
session_cookie: str | None = Cookie(default=None, alias="gewu_session"),
|
|
csrf_token: str | None = Header(default=None, alias="X-GEWU-CSRF"),
|
|
) -> Principal:
|
|
response.headers["Cache-Control"] = "private, no-store"
|
|
write = request.method not in {"GET", "HEAD"}
|
|
if write:
|
|
require_same_origin(request)
|
|
principal = authenticate(authorization, session_cookie, csrf_token, require_csrf=write)
|
|
if "agent" in principal.roles:
|
|
raise HTTPException(403, "A human account is required")
|
|
return principal
|
|
|
|
def require_manager(principal: Principal, project_id: str) -> None:
|
|
if not principal.can_read_project(project_id):
|
|
raise HTTPException(404, "Project removal queue is unavailable")
|
|
if not ({"editor", "admin", "owner"} & principal.roles):
|
|
raise HTTPException(403, "Editor role is required to remove or restore knowledge")
|
|
|
|
@app.get(
|
|
"/v1/projects/{project_id}/node-removals",
|
|
response_model=NodeRemovalList,
|
|
tags=["knowledge"],
|
|
operation_id="node_removal_list",
|
|
)
|
|
def list_removals(
|
|
project_id: RecordId,
|
|
principal: Annotated[Principal, Depends(human)],
|
|
) -> NodeRemovalList:
|
|
if not principal.can_read_project(project_id):
|
|
raise HTTPException(404, "Project removal queue is unavailable")
|
|
can_manage = bool({"editor", "admin", "owner"} & principal.roles)
|
|
removals = store.list_restorable(project_id) if can_manage else ()
|
|
return NodeRemovalList(
|
|
can_manage=can_manage,
|
|
removals=[NodeRemovalItem.from_record(record) for record in removals],
|
|
)
|
|
|
|
@app.post(
|
|
"/v1/projects/{project_id}/nodes/{node_id}/removal",
|
|
response_model=NodeRemovalItem,
|
|
status_code=201,
|
|
tags=["knowledge"],
|
|
operation_id="node_removal_request",
|
|
)
|
|
def request_removal(
|
|
project_id: RecordId,
|
|
node_id: RecordId,
|
|
body: NodeRemovalRequest,
|
|
principal: Annotated[Principal, Depends(human)],
|
|
) -> NodeRemovalItem:
|
|
require_manager(principal, project_id)
|
|
try:
|
|
envelope = reader.get_node(project_id, node_id, principal=principal)
|
|
title = str(envelope.data[0]["title"])
|
|
return NodeRemovalItem.from_record(
|
|
store.request(project_id, node_id, title, principal.principal_id)
|
|
)
|
|
except ReadError as error:
|
|
raise HTTPException(status_code=error.status_code, detail=str(error)) from error
|
|
except RemovalConflict as error:
|
|
raise HTTPException(status_code=409, detail=str(error)) from error
|
|
except RuntimeError as error:
|
|
raise HTTPException(status_code=503, detail=str(error)) from error
|
|
|
|
@app.post(
|
|
"/v1/projects/{project_id}/nodes/{node_id}/restore-removal",
|
|
response_model=NodeRemovalItem,
|
|
tags=["knowledge"],
|
|
operation_id="node_removal_restore",
|
|
)
|
|
def restore_removal(
|
|
project_id: RecordId,
|
|
node_id: RecordId,
|
|
principal: Annotated[Principal, Depends(human)],
|
|
) -> NodeRemovalItem:
|
|
require_manager(principal, project_id)
|
|
try:
|
|
return NodeRemovalItem.from_record(
|
|
store.restore(project_id, node_id, principal.principal_id)
|
|
)
|
|
except KeyError as error:
|
|
raise HTTPException(status_code=404, detail="Removed node was not found") from error
|
|
except RemovalConflict as error:
|
|
raise HTTPException(status_code=410, detail=str(error)) from error
|
|
except RuntimeError as error:
|
|
raise HTTPException(status_code=503, detail=str(error)) from error
|