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
+3 -3
View File
@@ -261,7 +261,7 @@ Users and guests inject their own CSS and JS, scoped to a page type or globally,
### Container manager, Devii assistant, AI gateway, async jobs, audit log ### Container manager, Devii assistant, AI gateway, async jobs, audit log
DevPlace ships a Docker-backed **Container manager** (admin-only, one shared `ppy:latest` image, security-hardened rootless workflow via a `sudo` superclone and `aptroot`), the **Devii** in-platform agentic assistant (WebSocket terminal, per-owner-channel sessions, scheduler/reminders, self-learning lessons, user-defined virtual tools, self-configured behavior, browser-automation client tools), the **AI gateway** (`/openai/v1/*`, single point of truth for every AI call, per-user cost attribution, provider/model routing overlay), **async job services** (`JobService` base pattern: zip, fork, SEO diagnostics, SEO metadata generation, DeepSearch, AI Usage Analyzer), and an admin-only append-only **audit log** (`record`/`record_system` entrypoints, `events.md` catalogue of 288 keys, never raises into the caller). These are among the largest subsystems in the codebase - read their dedicated nested `CLAUDE.md` files (see Subsystem map) before working in any of them; do not assume prior knowledge from this summary. DevPlace ships a Docker-backed **Container manager** (admin-only, one shared `ppy:latest` image, security-hardened rootless workflow via a `sudo` superclone and `aptroot`), the **Devii** in-platform agentic assistant (WebSocket terminal, per-owner-channel sessions, scheduler/reminders, self-learning lessons, user-defined virtual tools, self-configured behavior, browser-automation client tools), the **AI gateway** (`/openai/v1/*`, single point of truth for every AI call, per-user cost attribution, provider/model routing overlay), **async job services** (`JobService` base pattern: zip, fork, SEO diagnostics, SEO metadata generation, DeepSearch, AI Usage Analyzer), and an admin-only append-only **audit log** (`record`/`record_system` entrypoints, `events.md` catalogue of every recorded event key, never raises into the caller). These are among the largest subsystems in the codebase - read their dedicated nested `CLAUDE.md` files (see Subsystem map) before working in any of them; do not assume prior knowledge from this summary.
### Telegram bot, email, devRant compatibility API, issue tracker ### Telegram bot, email, devRant compatibility API, issue tracker
@@ -351,7 +351,7 @@ Every feature in DevPlace is **one data source fanning out into several consumer
1. **HTML** - `respond()` returns a rendered template for browsers. 1. **HTML** - `respond()` returns a rendered template for browsers.
2. **JSON** - the SAME `respond(..., model=XOut)` returns JSON when `Accept: application/json`. The `*Out` schema is the gate: a context key not declared on `*Out` is silently dropped from JSON even though the template still sees it. 2. **JSON** - the SAME `respond(..., model=XOut)` returns JSON when `Accept: application/json`. The `*Out` schema is the gate: a context key not declared on `*Out` is silently dropped from JSON even though the template still sees it.
3. **Agent tool** - `services/devii/actions/catalog.py` exposes the route as an `Action` so Devii can call it on the user's behalf. `requires_auth` mirrors the route guard. 3. **Agent tool** - `services/devii/actions/catalog/` (the relevant module inside the package, e.g. `posts.py`, `admin.py`) exposes the route as an `Action` so Devii can call it on the user's behalf. `requires_auth` mirrors the route guard.
4. **Documented endpoint** - `docs_api.py` `endpoint()` describes it (params, auth, `sample_response`) in the right group, rendered at `/docs/{group}.html`. 4. **Documented endpoint** - `docs_api.py` `endpoint()` describes it (params, auth, `sample_response`) in the right group, rendered at `/docs/{group}.html`.
A new public read almost always needs all four. The cardinal failure mode is changing one face and forgetting a connected one - a real worked example (followers/following listing) touched nine files across data helpers, output schema, route, view, agent tool, API docs, and the docs trio below for one conceptual feature; that count is the norm, not the exception. If a change touches only one file, confirm none of the four faces were missed. Checklist, ordered by data flow: A new public read almost always needs all four. The cardinal failure mode is changing one face and forgetting a connected one - a real worked example (followers/following listing) touched nine files across data helpers, output schema, route, view, agent tool, API docs, and the docs trio below for one conceptual feature; that count is the norm, not the exception. If a change touches only one file, confirm none of the four faces were missed. Checklist, ordered by data flow:
@@ -361,7 +361,7 @@ A new public read almost always needs all four. The cardinal failure mode is cha
3. **Server layer.** Handler with the right auth guard (`get_current_user` public read, `require_user` member POST, `require_admin` admin). Specific paths (`/{username}/followers`) declared before catch-alls (`/{username}`). Return HTML+JSON via `respond(request, template, ctx, model=XOut)` or pure JSON via `JSONResponse`. Register any NEW router in `main.py`. 3. **Server layer.** Handler with the right auth guard (`get_current_user` public read, `require_user` member POST, `require_admin` admin). Specific paths (`/{username}/followers`) declared before catch-alls (`/{username}`). Return HTML+JSON via `respond(request, template, ctx, model=XOut)` or pure JSON via `JSONResponse`. Register any NEW router in `main.py`.
4. **View layer.** Extend `base.html`; page CSS in `{% block extra_head %}`, page JS in `{% block extra_js %}`. Reuse partials and template globals. JS is ES6 modules, one class per file, instantiated on `app`. 4. **View layer.** Extend `base.html`; page CSS in `{% block extra_head %}`, page JS in `{% block extra_js %}`. Reuse partials and template globals. JS is ES6 modules, one class per file, instantiated on `app`.
5. **Safety layer (skip only when the feature stores nothing and shows nothing).** If the feature adds a surface that carries user-generated content, register it in `database/moderation.py` `REPORTABLE_TARGETS` (or in `UNREPORTABLE_TABLES` with a reason), resolve it in `resolve_object_url`, and include `_report_button.html` in its action bar - the registry test and the e2e coverage test both fail otherwise. If it collects or transmits a new *category* of personal data, or sends anything new to a third party, update `/docs/privacy.html` and the app-store privacy declarations in the same change; that disclosure is part of the feature, not a follow-up. 5. **Safety layer (skip only when the feature stores nothing and shows nothing).** If the feature adds a surface that carries user-generated content, register it in `database/moderation.py` `REPORTABLE_TARGETS` (or in `UNREPORTABLE_TABLES` with a reason), resolve it in `resolve_object_url`, and include `_report_button.html` in its action bar - the registry test and the e2e coverage test both fail otherwise. If it collects or transmits a new *category* of personal data, or sends anything new to a third party, update `/docs/privacy.html` and the app-store privacy declarations in the same change; that disclosure is part of the feature, not a follow-up.
6. **Agent + docs layer (the most-forgotten step - do not skip).** `services/devii/actions/catalog.py` - add an `Action` tool if a user could ask Devii to do this (`requires_auth=False` for public reads). `docs_api.py` - every public/auth endpoint gets an `endpoint()` entry with params and a `sample_response`. `routers/docs/pages.py` `DOCS_PAGES` for prose pages. SEO (`seo.py`) - `base_seo_context` and any JSON-LD for a new public page; add to `routers/seo.py` sitemap if indexable. 6. **Agent + docs layer (the most-forgotten step - do not skip).** `services/devii/actions/catalog/` (the relevant module inside the package) - add an `Action` tool if a user could ask Devii to do this (`requires_auth=False` for public reads). `docs_api.py` - every public/auth endpoint gets an `endpoint()` entry with params and a `sample_response`. `routers/docs/pages.py` `DOCS_PAGES` for prose pages. SEO (`seo.py`) - `base_seo_context` and any JSON-LD for a new public page; add to `routers/seo.py` sitemap if indexable.
7. **Document.** `README.md` (product-facing, routes/config/dependencies/user-visible features), the relevant nested `CLAUDE.md` (any new mechanic, helper, pitfall, or cross-layer wiring), this root `CLAUDE.md` only when a NEW architectural rule or workflow step is introduced (not per feature). 7. **Document.** `README.md` (product-facing, routes/config/dependencies/user-visible features), the relevant nested `CLAUDE.md` (any new mechanic, helper, pitfall, or cross-layer wiring), this root `CLAUDE.md` only when a NEW architectural rule or workflow step is introduced (not per feature).
8. **Validate.** Confirm a clean import (`python -c "from devplacepy.main import app"`), check each touched language manually, grep for em-dash in touched files (neither the character nor its HTML entity - use a hyphen), and run `pyflakes`/`ruff check` on every touched file. **If the feature touches a spendable resource, a bounded state machine, or a read-then-write mutation reachable from more than one request path, also run the four-layer procedure in "Rigorous correctness verification" above** - it is the default for that shape of feature, not an optional extra. **Finish by running the full persisted test suite (`make test` - every tier, every test) and fixing any failure before the change is done.** Write new tests in the matching tier/path, following the required patterns above. 8. **Validate.** Confirm a clean import (`python -c "from devplacepy.main import app"`), check each touched language manually, grep for em-dash in touched files (neither the character nor its HTML entity - use a hyphen), and run `pyflakes`/`ruff check` on every touched file. **If the feature touches a spendable resource, a bounded state machine, or a read-then-write mutation reachable from more than one request path, also run the four-layer procedure in "Rigorous correctness verification" above** - it is the default for that shape of feature, not an optional extra. **Finish by running the full persisted test suite (`make test` - every tier, every test) and fixing any failure before the change is done.** Write new tests in the matching tier/path, following the required patterns above.
+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( endpoint(
id="admin-ai-usage", id="admin-ai-usage",
method="GET", method="GET",
@@ -613,6 +636,84 @@ four ways to sign requests.
auth="admin", auth="admin",
destructive=True, 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( endpoint(
id="admin-gateway-quota-rules", id="admin-gateway-quota-rules",
method="GET", method="GET",
@@ -975,5 +1076,190 @@ four ways to sign requests.
destructive=True, destructive=True,
sample_response={"ok": True, "redirect": "/admin/game"}, 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 = [ 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( endpoint(
id="services-data", id="services-data",
method="GET", method="GET",
+3 -2
View File
@@ -5,11 +5,12 @@ import logging
from fastapi import APIRouter, Request from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from devplacepy.responses import respond
from devplacepy.schemas import AdminIssuesPlanningOut
from devplacepy.seo import base_seo_context from devplacepy.seo import base_seo_context
from devplacepy.services.gitea import runtime from devplacepy.services.gitea import runtime
from devplacepy.services.gitea.config import gitea_config from devplacepy.services.gitea.config import gitea_config
from devplacepy.services.gitea.planning import collect_open_issues from devplacepy.services.gitea.planning import collect_open_issues
from devplacepy.templating import templates
from devplacepy.utils import require_admin from devplacepy.utils import require_admin
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -65,4 +66,4 @@ async def admin_issues_planning(request: Request):
"tickets": tickets, "tickets": tickets,
"tickets_error": tickets_error, "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, TelegramPairOut,
) )
from devplacepy.schemas.issues import ( from devplacepy.schemas.issues import (
AdminIssuesPlanningOut,
IssueAttachmentsOut, IssueAttachmentsOut,
IssueCommentOut, IssueCommentOut,
IssueDetailOut, IssueDetailOut,
+4
View File
@@ -2,6 +2,8 @@
from __future__ import annotations from __future__ import annotations
from typing import Optional
from devplacepy.schemas.base import _Out from devplacepy.schemas.base import _Out
@@ -186,6 +188,7 @@ class GameFarmOut(_Out):
class GameStateOut(_Out): class GameStateOut(_Out):
ok: bool = True ok: bool = True
farm: GameFarmOut farm: GameFarmOut
game_error: Optional[str] = None
class GameFarmViewOut(_Out): class GameFarmViewOut(_Out):
@@ -193,6 +196,7 @@ class GameFarmViewOut(_Out):
page_title: str = "" page_title: str = ""
meta_description: str = "" meta_description: str = ""
stole_coins: int = 0 stole_coins: int = 0
game_error: Optional[str] = None
class GameLeaderboardEntryOut(_Out): class GameLeaderboardEntryOut(_Out):
+13
View File
@@ -67,3 +67,16 @@ class IssuesOut(_Out):
state: str = "open" state: str = "open"
configured: bool = True configured: bool = True
error_message: Optional[str] = None 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 detected_builder: Optional[str] = None
dom_slop_score: Optional[float] = None dom_slop_score: Optional[float] = None
error: Optional[str] = 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 content_hash: Optional[str] = None
markdown: str = "" markdown: str = ""
generator_model: str = "" generator_model: str = ""
@@ -226,3 +230,7 @@ class IsslopSourceOut(_Out):
source: str = "" source: str = ""
truncated: bool = False truncated: bool = False
signals: list = [] 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): class QuizAttemptPageOut(_Out):
quiz: QuizOut = QuizOut() quiz: QuizOut = QuizOut()
attempt: QuizAttemptOut = QuizAttemptOut() attempt: QuizAttemptOut = QuizAttemptOut()
answer_max_chars: int = 0
quiz_error: Optional[str] = None
class QuizResultOut(_Out): class QuizResultOut(_Out):
@@ -225,6 +227,7 @@ class QuizBuilderOut(_Out):
questions: list[QuizQuestionOut] = [] questions: list[QuizQuestionOut] = []
kinds: list[Any] = [] kinds: list[Any] = []
validation_errors: list[str] = [] validation_errors: list[str] = []
quiz_error: Optional[str] = None
class QuizFormPageOut(_Out): class QuizFormPageOut(_Out):
+15 -1
View File
@@ -39,7 +39,7 @@ class StatisticsHighlightOut(BaseModel):
value: Any value: Any
class StatisticsOut(BaseModel): class StatisticsPayloadOut(BaseModel):
tab: str tab: str
window_hours: int window_hours: int
granularity: str granularity: str
@@ -50,3 +50,17 @@ class StatisticsOut(BaseModel):
tables: list[StatisticsTableOut] = [] tables: list[StatisticsTableOut] = []
highlights: list[StatisticsHighlightOut] = [] 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) ### 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 ### 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) ## 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 ## nginx
+1 -1
View File
@@ -28,7 +28,7 @@ After any mutation the handler `await`s `_shared.notify_farm(username)` which pu
## Fan-out ## 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) ## Stealing (competitive loop, backwards compatible)