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:
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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."),
|
||||||
|
],
|
||||||
|
),
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user