From 9d7b3db3147ac059dc180419ac03a5baa3288ab1 Mon Sep 17 00:00:00 2001 From: retoor Date: Sun, 30 Aug 2026 00:37:34 +0200 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01VL9Xn57W5UR3HZbbuuzxdK --- CLAUDE.md | 6 +- devplacepy/docs_api/groups/admin.py | 286 ++++++++++++++++++++++++++ devplacepy/docs_api/services_group.py | 19 ++ devplacepy/routers/admin/issues.py | 5 +- devplacepy/schemas/__init__.py | 1 + devplacepy/schemas/game.py | 4 + devplacepy/schemas/issues.py | 13 ++ devplacepy/schemas/jobs.py | 8 + devplacepy/schemas/quiz.py | 3 + devplacepy/schemas/statistics.py | 18 +- devplacepy/services/audit/CLAUDE.md | 2 +- devplacepy/services/dbapi/CLAUDE.md | 2 +- devplacepy/services/game/CLAUDE.md | 2 +- 13 files changed, 359 insertions(+), 10 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a743da9..6ff54a7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 -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 @@ -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. 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`. 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`. 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. -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). 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. diff --git a/devplacepy/docs_api/groups/admin.py b/devplacepy/docs_api/groups/admin.py index e1c69f5..75e50b2 100644 --- a/devplacepy/docs_api/groups/admin.py +++ b/devplacepy/docs_api/groups/admin.py @@ -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."), + ], + ), ], } diff --git a/devplacepy/docs_api/services_group.py b/devplacepy/docs_api/services_group.py index 4c9d314..30589fe 100644 --- a/devplacepy/docs_api/services_group.py +++ b/devplacepy/docs_api/services_group.py @@ -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", diff --git a/devplacepy/routers/admin/issues.py b/devplacepy/routers/admin/issues.py index 51cde80..61ec311 100644 --- a/devplacepy/routers/admin/issues.py +++ b/devplacepy/routers/admin/issues.py @@ -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) diff --git a/devplacepy/schemas/__init__.py b/devplacepy/schemas/__init__.py index b63d03a..85d1522 100644 --- a/devplacepy/schemas/__init__.py +++ b/devplacepy/schemas/__init__.py @@ -62,6 +62,7 @@ from devplacepy.schemas.profile import ( TelegramPairOut, ) from devplacepy.schemas.issues import ( + AdminIssuesPlanningOut, IssueAttachmentsOut, IssueCommentOut, IssueDetailOut, diff --git a/devplacepy/schemas/game.py b/devplacepy/schemas/game.py index d4cff9e..30f51f4 100644 --- a/devplacepy/schemas/game.py +++ b/devplacepy/schemas/game.py @@ -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): diff --git a/devplacepy/schemas/issues.py b/devplacepy/schemas/issues.py index b611460..455a48c 100644 --- a/devplacepy/schemas/issues.py +++ b/devplacepy/schemas/issues.py @@ -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 diff --git a/devplacepy/schemas/jobs.py b/devplacepy/schemas/jobs.py index d2aa9b9..2aacb58 100644 --- a/devplacepy/schemas/jobs.py +++ b/devplacepy/schemas/jobs.py @@ -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 diff --git a/devplacepy/schemas/quiz.py b/devplacepy/schemas/quiz.py index 93ed7b2..81c34c2 100644 --- a/devplacepy/schemas/quiz.py +++ b/devplacepy/schemas/quiz.py @@ -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): diff --git a/devplacepy/schemas/statistics.py b/devplacepy/schemas/statistics.py index bec2d3e..6d35494 100644 --- a/devplacepy/schemas/statistics.py +++ b/devplacepy/schemas/statistics.py @@ -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] = {} \ No newline at end of file + 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 \ No newline at end of file diff --git a/devplacepy/services/audit/CLAUDE.md b/devplacepy/services/audit/CLAUDE.md index 162a186..6d359ab 100644 --- a/devplacepy/services/audit/CLAUDE.md +++ b/devplacepy/services/audit/CLAUDE.md @@ -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 diff --git a/devplacepy/services/dbapi/CLAUDE.md b/devplacepy/services/dbapi/CLAUDE.md index 0bc3ab9..03dd3d9 100644 --- a/devplacepy/services/dbapi/CLAUDE.md +++ b/devplacepy/services/dbapi/CLAUDE.md @@ -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 diff --git a/devplacepy/services/game/CLAUDE.md b/devplacepy/services/game/CLAUDE.md index 0ca9d50..2b86735 100644 --- a/devplacepy/services/game/CLAUDE.md +++ b/devplacepy/services/game/CLAUDE.md @@ -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)