A filed packet's sealed data book, documents and a manifest are copied to the archive folder by the worker. A failed copy never blocks filing: it is retried with backoff and raises the archive_failed condition. Co-Authored-By: Claude Opus 5.5 <[email protected]>
142 lines
4.8 KiB
Python
142 lines
4.8 KiB
Python
"""Admin: File locations (Gitea issue 7). The archive folder under ARCHIVE_ROOT, its check, and the archive copies.
|
|
|
|
Every route needs the admin role and a password session. Writes answer 409 `archive_off` while ARCHIVE_ROOT is unset.
|
|
The check and the save are audited (`archive.checked`, `admin.file_locations_updated`); "Retry now" is audited as
|
|
`admin.archive_retry` through the job request.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
from fastapi import APIRouter, Depends, Request
|
|
from fastapi.encoders import jsonable_encoder
|
|
from pydantic import BaseModel, ConfigDict, Field
|
|
from starlette.concurrency import run_in_threadpool
|
|
from starlette.responses import JSONResponse
|
|
|
|
from ..domain import archive
|
|
from .deps import Db, Principal, admin_principal, get_db, settings_of
|
|
from .errors import ApiError
|
|
from .jobs import request_run
|
|
from .packets import WRITE
|
|
|
|
router = APIRouter(prefix="/api/admin/file-locations", tags=["admin"])
|
|
|
|
|
|
class CheckBody(BaseModel):
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
archive_dir: str = Field(max_length=archive.DIR_MAX + 100)
|
|
|
|
|
|
class PutBody(BaseModel):
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
archive_dir: str = Field(max_length=archive.DIR_MAX + 100)
|
|
version: int = Field(ge=0)
|
|
|
|
|
|
def _out(conn: Any, request: Request) -> dict[str, Any]:
|
|
root = archive.root_of(settings_of(request))
|
|
archive_dir, version, _, _ = archive.load(conn)
|
|
resolved = None
|
|
if root is not None and archive_dir is not None:
|
|
try:
|
|
resolved = str(archive.resolve_dir(root, archive_dir)[1])
|
|
except archive.PathRefused:
|
|
resolved = None
|
|
last = archive.last_check(conn)
|
|
return {
|
|
"enabled": root is not None,
|
|
"root": str(root) if root is not None else None,
|
|
"archive_dir": archive_dir,
|
|
"version": version,
|
|
"resolved": resolved,
|
|
"last_check": {k: last[k] for k in ("ok", "at", "by", "message")} if last else None,
|
|
"copies": archive.copies_status(conn),
|
|
}
|
|
|
|
|
|
def _root_or_409(request: Request) -> Any:
|
|
root = archive.root_of(settings_of(request))
|
|
if root is None:
|
|
raise ApiError(409, "archive_off", archive.OFF_MESSAGE)
|
|
return root
|
|
|
|
|
|
@router.get("")
|
|
def file_locations(
|
|
request: Request,
|
|
p: Principal = Depends(admin_principal), # noqa: B008
|
|
d: Db = Depends(get_db), # noqa: B008
|
|
) -> dict[str, Any]:
|
|
return _out(d.conn, request)
|
|
|
|
|
|
@router.post("/check", dependencies=WRITE)
|
|
async def file_locations_check(
|
|
body: CheckBody,
|
|
request: Request,
|
|
p: Principal = Depends(admin_principal), # noqa: B008
|
|
d: Db = Depends(get_db), # noqa: B008
|
|
) -> dict[str, Any]:
|
|
root = _root_or_409(request)
|
|
rel, result = await run_in_threadpool(archive.check, root, body.archive_dir)
|
|
await run_in_threadpool(
|
|
archive.audit_check, d.conn, result, archive_dir=rel or body.archive_dir, source="admin", who=p
|
|
)
|
|
if rel is None:
|
|
# The path rules refused it (not a failed probe): 422 with the reason, still audited above.
|
|
raise ApiError(422, "bad_path", result.message)
|
|
return result.as_dict()
|
|
|
|
|
|
@router.put("", dependencies=WRITE)
|
|
async def file_locations_put(
|
|
body: PutBody,
|
|
request: Request,
|
|
p: Principal = Depends(admin_principal), # noqa: B008
|
|
d: Db = Depends(get_db), # noqa: B008
|
|
) -> Any:
|
|
root = _root_or_409(request)
|
|
conn = d.conn
|
|
rel, result = await run_in_threadpool(archive.check, root, body.archive_dir)
|
|
await run_in_threadpool(
|
|
archive.audit_check, conn, result, archive_dir=rel or body.archive_dir, source="save", who=p
|
|
)
|
|
if rel is None:
|
|
raise ApiError(422, "bad_path", result.message)
|
|
if not result.ok:
|
|
raise ApiError(422, "check_failed", result.message, extra={"check": result.as_dict()})
|
|
|
|
def save() -> dict[str, Any]:
|
|
try:
|
|
archive.save(conn, who=p, archive_dir=rel, version=body.version)
|
|
except archive.VersionConflict:
|
|
raise ApiError(
|
|
409,
|
|
"version_conflict",
|
|
"Someone else changed the file locations. Reload and try again.",
|
|
extra={"current": jsonable_encoder(_out(conn, request))},
|
|
) from None
|
|
with conn.transaction():
|
|
archive.request_copy(conn, p.user_id, "archive folder saved")
|
|
return _out(conn, request)
|
|
|
|
return await run_in_threadpool(save)
|
|
|
|
|
|
@router.post("/retry", dependencies=WRITE)
|
|
def file_locations_retry(
|
|
request: Request,
|
|
p: Principal = Depends(admin_principal), # noqa: B008
|
|
d: Db = Depends(get_db), # noqa: B008
|
|
) -> JSONResponse:
|
|
_root_or_409(request)
|
|
conn = d.conn
|
|
with conn.transaction():
|
|
reset = archive.reset_backoff(conn)
|
|
out = request_run(conn, p, archive.JOB, note="Retry now (File locations)", action="admin.archive_retry")
|
|
return JSONResponse(status_code=202, content={**out, "reset": reset})
|