Fix fanout gaps: statistics JSON 500, schema drops, undocumented admin routes

- Fix StatisticsOut to match the real admin_statistics page context
  (active_tab/tabs/window_hours/initial), which was raising a
  ValidationError and 500ing GET /admin/statistics with
  Accept: application/json.
- Add missing *Out fields dropped from JSON responses: IsslopSourceOut
  (source_lines, marked_lines, focus_line, report_url), IsslopReportOut
  (report_url, badge_url, events_url, topic), IssuesOut
  (viewer_is_admin), GameStateOut/GameFarmViewOut (game_error),
  QuizAttemptPageOut (answer_max_chars, quiz_error), QuizBuilderOut
  (quiz_error).
- Convert GET /admin/issues/planning to respond() with a new
  AdminIssuesPlanningOut schema so it serves JSON like every sibling
  admin dashboard, and document it in docs_api.
- Document previously-undocumented admin endpoint families in
  docs_api: services page routes, gateway provider/model CRUD, admin
  workspaces (11 routes), and trash list/restore/purge.
- Correct stale references to services/devii/actions/catalog.py as a
  single file; it is the actions/catalog/ package.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VL9Xn57W5UR3HZbbuuzxdK
This commit is contained in:
2026-09-03 08:47:57 +02:00
co-authored by Claude Sonnet 5
parent 572e022584
commit 9d7b3db314
13 changed files with 359 additions and 10 deletions
+286
View File
@@ -265,6 +265,29 @@ four ways to sign requests.
),
],
),
endpoint(
id="admin-issues-planning",
method="GET",
path="/admin/issues/planning",
title="Ticket planning report",
summary=(
"Admin page listing every open Gitea ticket so an admin can pick a subset "
"and generate a grouped, ordered planning document for a coding agent."
),
auth="admin",
interactive=False,
sample_response={
"configured": True,
"tickets": [
{"number": 42, "title": "Fix login redirect", "labels": ["bug"]},
],
"tickets_error": False,
},
notes=[
"`configured` is false when the issue tracker (Gitea) has not been set up in Services yet - `tickets` is then empty.",
"`tickets_error` is true when the tracker is configured but the live fetch failed - retry shortly.",
],
),
endpoint(
id="admin-ai-usage",
method="GET",
@@ -613,6 +636,84 @@ four ways to sign requests.
auth="admin",
destructive=True,
),
endpoint(
id="admin-gateway-page",
method="GET",
path="/admin/gateway",
title="Gateway routing dashboard",
summary="Admin HTML page for managing AI gateway providers and per-model routing.",
auth="admin",
interactive=False,
),
endpoint(
id="admin-gateway-providers",
method="GET",
path="/admin/gateway/providers",
title="List AI gateway providers",
summary="List every configured upstream provider plus the default provider summary.",
auth="admin",
interactive=True,
),
endpoint(
id="admin-gateway-provider-set",
method="POST",
path="/admin/gateway/providers",
title="Create or update an AI gateway provider",
summary="Save an upstream provider (base URL, model, and API key) by name. Pass name to update an existing provider.",
auth="admin",
params=[
field("name", "json", "string", True, "openrouter", "Provider name; existing name updates in place."),
field("base_url", "json", "string", True, "https://openrouter.ai/api/v1", "Upstream chat completions base URL."),
field("model", "json", "string", True, "x-ai/grok-4.3", "Default model for this provider."),
field("api_key", "json", "string", False, "", "Upstream API key. Blank keeps the current key."),
],
),
endpoint(
id="admin-gateway-provider-delete",
method="DELETE",
path="/admin/gateway/providers/{name}",
title="Delete an AI gateway provider",
summary="Delete a configured upstream provider by name.",
auth="admin",
destructive=True,
params=[
field("name", "path", "string", True, "openrouter", "Provider name."),
],
),
endpoint(
id="admin-gateway-models",
method="GET",
path="/admin/gateway/models",
title="List AI gateway model routes",
summary="List every source-to-target model route plus the configured provider names.",
auth="admin",
interactive=True,
),
endpoint(
id="admin-gateway-model-set",
method="POST",
path="/admin/gateway/models",
title="Create or update an AI gateway model route",
summary="Route a source model name to a target model, optionally on a specific provider. Pass source_model to update an existing route.",
auth="admin",
params=[
field("source_model", "json", "string", True, "gpt-4", "Model name callers request."),
field("target_model", "json", "string", True, "x-ai/grok-4.3", "Model actually sent upstream."),
field("provider", "json", "string", False, "openrouter", "Provider name to route through. Blank = the default provider."),
],
),
endpoint(
id="admin-gateway-model-delete",
method="DELETE",
path="/admin/gateway/models/{source_model}",
title="Delete an AI gateway model route",
summary="Delete a source-to-target model route by source model name.",
auth="admin",
destructive=True,
params=[
field("source_model", "path", "string", True, "gpt-4", "Source model name."),
],
),
endpoint(
id="admin-gateway-quota-rules",
method="GET",
@@ -975,5 +1076,190 @@ four ways to sign requests.
destructive=True,
sample_response={"ok": True, "redirect": "/admin/game"},
),
endpoint(
id="admin-workspaces-page",
method="GET",
path="/admin/workspaces",
title="Workspaces dashboard",
summary="Admin HTML page listing every dev workspace across all projects, with owner, project, and moderation flags.",
auth="admin",
interactive=False,
),
endpoint(
id="admin-workspaces-data",
method="GET",
path="/admin/workspaces/data",
title="List workspaces",
summary="Every dev workspace across all projects plus open moderation flags, as JSON.",
auth="admin",
interactive=True,
sample_response={"workspaces": [], "flags": []},
),
endpoint(
id="admin-workspaces-suspend",
method="POST",
path="/admin/workspaces/{uid}/suspend",
title="Suspend a workspace",
summary="Suspend a workspace with a reason shown to its owner. The owner is notified.",
auth="admin",
destructive=True,
params=[
field("uid", "path", "string", True, "INSTANCE_UID", "Workspace instance uid."),
field("reason", "form", "string", True, "Excessive resource usage", "Shown to the workspace owner."),
],
),
endpoint(
id="admin-workspaces-unsuspend",
method="POST",
path="/admin/workspaces/{uid}/unsuspend",
title="Unsuspend a workspace",
summary="Lift a suspension. The owner is notified the workspace is available again.",
auth="admin",
params=[
field("uid", "path", "string", True, "INSTANCE_UID", "Workspace instance uid."),
],
),
endpoint(
id="admin-workspaces-stop",
method="POST",
path="/admin/workspaces/{uid}/stop",
title="Stop a workspace",
summary="Stop the workspace container. Files and tunnels are kept.",
auth="admin",
destructive=True,
params=[
field("uid", "path", "string", True, "INSTANCE_UID", "Workspace instance uid."),
],
),
endpoint(
id="admin-workspaces-start",
method="POST",
path="/admin/workspaces/{uid}/start",
title="Start a workspace",
summary="Resume a stopped workspace container.",
auth="admin",
params=[
field("uid", "path", "string", True, "INSTANCE_UID", "Workspace instance uid."),
],
),
endpoint(
id="admin-workspaces-delete",
method="POST",
path="/admin/workspaces/{uid}/delete",
title="Delete a workspace",
summary="Remove a workspace and its tunnels.",
auth="admin",
destructive=True,
params=[
field("uid", "path", "string", True, "INSTANCE_UID", "Workspace instance uid."),
],
),
endpoint(
id="admin-workspaces-flag",
method="POST",
path="/admin/workspaces/{uid}/flag",
title="Flag a workspace",
summary="Raise a moderation flag on a workspace. The owner is notified.",
auth="admin",
params=[
field("uid", "path", "string", True, "INSTANCE_UID", "Workspace instance uid."),
field("kind", "form", "string", False, "manual", "Flag kind."),
field("severity", "form", "string", False, "warn", "Flag severity."),
field("detail", "form", "string", False, "", "Detail shown to the owner."),
],
),
endpoint(
id="admin-workspaces-flag-resolve",
method="POST",
path="/admin/workspaces/flags/{flag_uid}/resolve",
title="Resolve or dismiss a workspace flag",
summary="Set a moderation flag's status.",
auth="admin",
params=[
field("flag_uid", "path", "string", True, "FLAG_UID", "Flag uid."),
field("status", "query", "string", False, "resolved", "resolved or dismissed."),
],
),
endpoint(
id="admin-workspaces-editor",
method="POST",
path="/admin/workspaces/{uid}/editor",
title="Set a workspace owner's editor preferences",
summary="Change the workspace owner's editor preferences on their behalf, or reset them. Subject to admin seniority.",
auth="admin",
params=[
field("uid", "path", "string", True, "INSTANCE_UID", "Workspace instance uid."),
field("theme", "form", "string", False, "devplace-dark", "devplace-dark, devplace-light or system."),
field("layout", "form", "string", False, "standard", "standard, terminal-focus or zen."),
field("panel_preset", "form", "string", False, "tall", "short, normal, tall or maximized."),
field("font_size", "form", "integer", False, "14", "Editor font size in pixels."),
field("terminal_font_size", "form", "integer", False, "13", "Terminal font size in pixels."),
field("zoom_level", "form", "integer", False, "0", "Window zoom, -5 to 5."),
field("boot_agent", "form", "string", False, "dpc", "dpc or none."),
field("boot_shell", "form", "integer", False, "1", "1 opens a shell on boot, 0 skips it."),
field("window_mode", "form", "string", False, "tab", "tab, window or fullscreen."),
field("window_width", "form", "integer", False, "1600", "Editor window width in pixels."),
field("window_height", "form", "integer", False, "1000", "Editor window height in pixels."),
field("reset", "form", "boolean", False, "false", "Drop every preference for this owner."),
],
),
endpoint(
id="admin-workspaces-quota",
method="POST",
path="/admin/workspaces/quota",
title="Set a user's workspace quota override",
summary="Set or update a per-user override of the workspace count/disk/egress/idle/retention/CPU/memory limits.",
auth="admin",
params=[
field("owner_id", "form", "string", True, "USER_UID", "Target user uid."),
field("label", "form", "string", False, "", "Optional admin-facing note."),
field("max_workspaces", "form", "int", False, "0", "0 = use the site default."),
field("max_tunnels", "form", "int", False, "0", "0 = use the site default."),
field("disk_quota_mb", "form", "int", False, "0", "0 = use the site default."),
field("egress_quota_mb", "form", "int", False, "0", "0 = use the site default."),
field("idle_stop_minutes", "form", "int", False, "0", "0 = use the site default."),
field("retention_days", "form", "int", False, "0", "0 = use the site default."),
field("cpu_millicores", "form", "int", False, "0", "0 = use the site default."),
field("memory_mb", "form", "int", False, "0", "0 = use the site default."),
],
),
endpoint(
id="admin-trash-list",
method="GET",
path="/admin/trash",
title="Trash",
summary="Admin HTML page listing soft-deleted rows for one table, with restore/purge controls per row.",
auth="admin",
interactive=True,
params=[
field("table", "query", "string", False, "posts", "Trash table key (posts, comments, gists, projects, news, awards, quizzes, project_files, attachments)."),
field("page", "query", "int", False, "1", "Page number."),
],
),
endpoint(
id="admin-trash-restore",
method="POST",
path="/admin/trash/{table}/{uid}/restore",
title="Restore a soft-deleted row",
summary="Restore every row soft-deleted under the same event timestamp as the given row (a whole delete cascade at once).",
auth="admin",
params=[
field("table", "path", "string", True, "posts", "Trash table key."),
field("uid", "path", "string", True, "ROW_UID", "Row uid."),
],
),
endpoint(
id="admin-trash-purge",
method="POST",
path="/admin/trash/{table}/{uid}/purge",
title="Purge a soft-deleted row",
summary="Permanently delete every row soft-deleted under the same event timestamp as the given row, unlinking any attachment/project-file blobs.",
auth="admin",
destructive=True,
params=[
field("table", "path", "string", True, "posts", "Trash table key."),
field("uid", "path", "string", True, "ROW_UID", "Row uid."),
],
),
],
}
+19
View File
@@ -117,6 +117,25 @@ def build_services_group(services, base):
)
)
control_endpoints = [
endpoint(
id="services-page",
method="GET",
path="/admin/services",
title="Services dashboard",
summary="Admin HTML index of every registered background service, its status, and controls.",
auth="admin",
interactive=False,
),
endpoint(
id="services-detail-page",
method="GET",
path="/admin/services/{name}",
title="Service detail page",
summary="Admin HTML detail page for a single service: overview, configuration form, and logs.",
auth="admin",
interactive=False,
params=[field("name", "path", "string", True, "news", "Service name.")],
),
endpoint(
id="services-data",
method="GET",
+3 -2
View File
@@ -5,11 +5,12 @@ import logging
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from devplacepy.responses import respond
from devplacepy.schemas import AdminIssuesPlanningOut
from devplacepy.seo import base_seo_context
from devplacepy.services.gitea import runtime
from devplacepy.services.gitea.config import gitea_config
from devplacepy.services.gitea.planning import collect_open_issues
from devplacepy.templating import templates
from devplacepy.utils import require_admin
logger = logging.getLogger(__name__)
@@ -65,4 +66,4 @@ async def admin_issues_planning(request: Request):
"tickets": tickets,
"tickets_error": tickets_error,
}
return templates.TemplateResponse(request, "admin_issues_planning.html", context)
return respond(request, "admin_issues_planning.html", context, model=AdminIssuesPlanningOut)
+1
View File
@@ -62,6 +62,7 @@ from devplacepy.schemas.profile import (
TelegramPairOut,
)
from devplacepy.schemas.issues import (
AdminIssuesPlanningOut,
IssueAttachmentsOut,
IssueCommentOut,
IssueDetailOut,
+4
View File
@@ -2,6 +2,8 @@
from __future__ import annotations
from typing import Optional
from devplacepy.schemas.base import _Out
@@ -186,6 +188,7 @@ class GameFarmOut(_Out):
class GameStateOut(_Out):
ok: bool = True
farm: GameFarmOut
game_error: Optional[str] = None
class GameFarmViewOut(_Out):
@@ -193,6 +196,7 @@ class GameFarmViewOut(_Out):
page_title: str = ""
meta_description: str = ""
stole_coins: int = 0
game_error: Optional[str] = None
class GameLeaderboardEntryOut(_Out):
+13
View File
@@ -67,3 +67,16 @@ class IssuesOut(_Out):
state: str = "open"
configured: bool = True
error_message: Optional[str] = None
viewer_is_admin: bool = False
class AdminPlanningTicketOut(_Out):
number: int = 0
title: str = ""
labels: list[str] = []
class AdminIssuesPlanningOut(_Out):
configured: bool = True
tickets: list[AdminPlanningTicketOut] = []
tickets_error: bool = False
+8
View File
@@ -204,6 +204,10 @@ class IsslopReportOut(_Out):
detected_builder: Optional[str] = None
dom_slop_score: Optional[float] = None
error: Optional[str] = None
report_url: Optional[str] = None
badge_url: Optional[str] = None
events_url: Optional[str] = None
topic: Optional[str] = None
content_hash: Optional[str] = None
markdown: str = ""
generator_model: str = ""
@@ -226,3 +230,7 @@ class IsslopSourceOut(_Out):
source: str = ""
truncated: bool = False
signals: list = []
source_lines: list = []
marked_lines: dict = {}
focus_line: int = 0
report_url: Optional[str] = None
+3
View File
@@ -184,6 +184,8 @@ class QuizAttemptOut(_Out):
class QuizAttemptPageOut(_Out):
quiz: QuizOut = QuizOut()
attempt: QuizAttemptOut = QuizAttemptOut()
answer_max_chars: int = 0
quiz_error: Optional[str] = None
class QuizResultOut(_Out):
@@ -225,6 +227,7 @@ class QuizBuilderOut(_Out):
questions: list[QuizQuestionOut] = []
kinds: list[Any] = []
validation_errors: list[str] = []
quiz_error: Optional[str] = None
class QuizFormPageOut(_Out):
+16 -2
View File
@@ -39,7 +39,7 @@ class StatisticsHighlightOut(BaseModel):
value: Any
class StatisticsOut(BaseModel):
class StatisticsPayloadOut(BaseModel):
tab: str
window_hours: int
granularity: str
@@ -49,4 +49,18 @@ class StatisticsOut(BaseModel):
series: list[StatisticsSeriesOut] = []
tables: list[StatisticsTableOut] = []
highlights: list[StatisticsHighlightOut] = []
notes: dict[str, Any] = {}
notes: dict[str, Any] = {}
class StatisticsTabOut(BaseModel):
key: str
label: str
icon: str
active: bool
class StatisticsOut(BaseModel):
active_tab: str
window_hours: int
tabs: list[StatisticsTabOut] = []
initial: StatisticsPayloadOut
+1 -1
View File
@@ -43,7 +43,7 @@ The dispatcher's **authorization guard** (before `_run`) mirrors this with `_aud
### Devii access (read-only)
Admins query the same two routes conversationally via the admin-only Devii tools `audit_log` (GET `/admin/audit-log`) and `audit_event` (GET `/admin/audit-log/{uid}`) (`services/devii/actions/catalog.py`, `handler="http"`, `requires_admin=True`) - no new endpoint, since the `respond(...)` routes already serve JSON to Devii's `Accept: application/json` client (like `admin_list_users`). `audit_log` forwards every filter as a query param (`page`, `event_key`, `category`, `actor_role`, `actor_uid`, `origin`, `result`, `q`, `date_from`, `date_to`) and returns `AuditLogOut`, whose `options` object lists the valid values for each filter so the agent can discover them in one call. `audit_event` returns `AuditEventOut` (the row + related links). A system-prompt steer in `agent.py` (the AGGREGATES block) routes audit/history/"who did X" questions to these tools.
Admins query the same two routes conversationally via the admin-only Devii tools `audit_log` (GET `/admin/audit-log`) and `audit_event` (GET `/admin/audit-log/{uid}`) (`services/devii/actions/catalog/admin.py`, `handler="http"`, `requires_admin=True`) - no new endpoint, since the `respond(...)` routes already serve JSON to Devii's `Accept: application/json` client (like `admin_list_users`). `audit_log` forwards every filter as a query param (`page`, `event_key`, `category`, `actor_role`, `actor_uid`, `origin`, `result`, `q`, `date_from`, `date_to`) and returns `AuditLogOut`, whose `options` object lists the valid values for each filter so the agent can discover them in one call. `audit_event` returns `AuditEventOut` (the row + related links). A system-prompt steer in `agent.py` (the AGGREGATES block) routes audit/history/"who did X" questions to these tools.
### Deferred persistence
+1 -1
View File
@@ -34,7 +34,7 @@ It exposes per-table reads, a validated raw `query()`, a natural-language-to-SQL
## Devii (primary-admin gated)
**Read-only** tools `db_list_tables`, `db_table_schema`, `db_list_rows`, `db_get_row`, `db_query` (SELECT-only; surface `suspicious`), and `db_design_query` (NL->SQL), all flagged `requires_primary_admin=True` (alongside `requires_admin=True`) in `actions/catalog.py`. The `Action.requires_primary_admin` flag is filtered by `Catalog.tool_schemas_for(authenticated, is_admin, is_primary_admin)` and enforced again in `Dispatcher.dispatch`, so these tools are **added to the LLM tool list only for the primary administrator** - every other administrator's (and member's/guest's) Devii never receives the schemas and is unaware the database API exists. `is_primary_admin` is threaded WS/Telegram/CLI -> `hub.get_or_create(is_primary_admin=)` -> `DeviiSession` -> `Dispatcher`, mirroring how `is_admin` flows (`routers/devii.py` `_resolve_ws_owner`, `services/telegram/bridge.py`, `services/devii/cli.py` which runs both flags `True` as the trusted local operator, and the headless scheduler in `service.py`). There are **no write tools** - the former `db_insert_row`/`db_update_row`/`db_delete_row` were removed along with the HTTP write routes, so Devii cannot change data through the database API.
**Read-only** tools `db_list_tables`, `db_table_schema`, `db_list_rows`, `db_get_row`, `db_query` (SELECT-only; surface `suspicious`), and `db_design_query` (NL->SQL), all flagged `requires_primary_admin=True` (alongside `requires_admin=True`) in `actions/catalog/dbapi.py`. The `Action.requires_primary_admin` flag is filtered by `Catalog.tool_schemas_for(authenticated, is_admin, is_primary_admin)` and enforced again in `Dispatcher.dispatch`, so these tools are **added to the LLM tool list only for the primary administrator** - every other administrator's (and member's/guest's) Devii never receives the schemas and is unaware the database API exists. `is_primary_admin` is threaded WS/Telegram/CLI -> `hub.get_or_create(is_primary_admin=)` -> `DeviiSession` -> `Dispatcher`, mirroring how `is_admin` flows (`routers/devii.py` `_resolve_ws_owner`, `services/telegram/bridge.py`, `services/devii/cli.py` which runs both flags `True` as the trusted local operator, and the headless scheduler in `service.py`). There are **no write tools** - the former `db_insert_row`/`db_update_row`/`db_delete_row` were removed along with the HTTP write routes, so Devii cannot change data through the database API.
## nginx
+1 -1
View File
@@ -28,7 +28,7 @@ After any mutation the handler `await`s `_shared.notify_farm(username)` which pu
## Fan-out
Devii plays via the `game_*` `http` actions in `actions/catalog.py` (state/leaderboard/view public, the rest `requires_auth`); the API reference has a **Code Farm** group in `docs_api.py`; pages are `noindex,follow` (interactive, user-specific) so they are intentionally not in the sitemap; badges live in `utils.BADGE_CATALOG` under the **Code Farm** group with `harvest`/`water`/`harvest_stolen`/`got_stolen_from` `ACHIEVEMENTS` (the steal pair awards **Cat Burglar** to the thief and **Robbed** to the victim, both threshold 1).
Devii plays via the `game_*` `http` actions in `actions/catalog/game.py` (state/leaderboard/view public, the rest `requires_auth`); the API reference has a **Code Farm** group in `docs_api.py`; pages are `noindex,follow` (interactive, user-specific) so they are intentionally not in the sitemap; badges live in `utils.BADGE_CATALOG` under the **Code Farm** group with `harvest`/`water`/`harvest_stolen`/`got_stolen_from` `ACHIEVEMENTS` (the steal pair awards **Cat Burglar** to the thief and **Robbed** to the victim, both threshold 1).
## Stealing (competitive loop, backwards compatible)