Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d5d5f90be | ||
|
|
46f87a48e3 | ||
|
|
e05f97c924 | ||
|
|
2d72e0785d | ||
|
|
dbe1e2670b | ||
|
|
fda72c5afb | ||
|
|
6b3df26a52 | ||
|
|
b1a104ebb1 | ||
|
|
b5fb6436d0 | ||
|
|
3006a1b039 | ||
|
|
8b89f0adcf | ||
|
|
9cfaddfc40 | ||
|
|
ca6c527e32 | ||
|
|
535e9c5dc1 | ||
|
|
3467f55df9 | ||
|
|
a3963611f0 | ||
|
|
b8277d6351 | ||
|
|
f996336afb | ||
|
|
4780016980 | ||
|
|
7f17d69f5c | ||
|
|
5774d83ece | ||
|
|
ac04cf6817 | ||
|
|
2620ecc0f1 | ||
|
|
1f320b45ec | ||
|
|
a8ed5b690f | ||
|
|
1eeb54598f | ||
|
|
a0d573375a | ||
|
|
ad1736ebf1 | ||
|
|
ef1c914e23 | ||
|
|
b534a496fd | ||
|
|
582e37d176 | ||
|
|
64c3983c9f | ||
|
|
34fa56a836 | ||
|
|
77f043640e | ||
|
|
34f76aad65 | ||
|
|
024edb5291 | ||
|
|
4ffddc8913 | ||
|
|
35e79ba8c7 | ||
|
|
32314fc6d6 | ||
|
|
43c5a948e8 | ||
|
|
c53e2a3319 | ||
|
|
48bb6c2ec2 | ||
|
|
818568c609 | ||
|
|
5083efb150 | ||
|
|
32c8bbe0a9 | ||
|
|
499f91e16a | ||
|
|
9a8046ab2a | ||
|
|
f1bdefd834 | ||
|
|
0f872336b1 | ||
|
|
7002b23eeb | ||
|
|
c79dfd0291 | ||
|
|
8daee65011 | ||
|
|
7de7e29d3e | ||
|
|
d556b02bfb | ||
|
|
574b076f5d | ||
|
|
f521a1130e | ||
|
|
b57e657cf8 | ||
|
|
0e8b015fe1 | ||
|
|
d45bb37ce1 | ||
|
|
4618089cea | ||
|
|
9151876770 |
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: docs-maintainer
|
||||
description: Documentation coverage and role-aware show/hide maintainer. Keeps CLAUDE.md, AGENTS.md, README.md, docs_api.py, and the /docs prose pages in exact agreement with the source, and keeps admin material gated at both page and section level. Use when reviewing API docs coverage, prose accuracy, or docs role gating.
|
||||
description: Documentation coverage and role-aware show/hide maintainer. Keeps every CLAUDE.md (root and nested per-subsystem), README.md, docs_api.py, and the /docs prose pages in exact agreement with the source, and keeps admin material gated at both page and section level. Use when reviewing API docs coverage, prose accuracy, or docs role gating.
|
||||
tools: Read, Grep, Glob, Edit, Write, Bash
|
||||
model: inherit
|
||||
color: blue
|
||||
@@ -36,23 +36,26 @@ Default to **REPORT** mode: record findings, do NOT modify files. Apply **FIX**
|
||||
No comments or docstrings in source you author; no em-dashes (use a hyphen); keep `retoor <retoor@molodetz.nl>` as the first line of any file you create.
|
||||
|
||||
## Your dimension
|
||||
Keep `CLAUDE.md`, `AGENTS.md`, `README.md`, and the `/docs` pages in exact agreement with the source, and keep role-based visibility consistent so admin material is shown to admins and hidden from members and guests at both the page and the section level.
|
||||
Keep every `CLAUDE.md`, `README.md`, and the `/docs` pages in exact agreement with the source, and keep role-based visibility consistent so admin material is shown to admins and hidden from members and guests at both the page and the section level.
|
||||
|
||||
**`CLAUDE.md` is split, not monolithic.** The root `/CLAUDE.md` holds only cross-cutting rules (Claude Code loads it eagerly, every session). Each subsystem directory (e.g. `devplacepy/services/devii/`, `devplacepy/routers/projects/`, `devplacepy/database/`, `tests/`) has its own nested `CLAUDE.md` with that subsystem's full mechanic/pitfall/gotcha coverage, loaded automatically by Claude Code only when a file in that directory is read or edited. There is no `AGENTS.md` - it was removed and its content redistributed into the root file plus the nested files. **Treat the reappearance of a top-level `AGENTS.md`, or any doc/prose page referencing one, as an error to fix (delete the file / repoint the reference at the correct root-or-nested `CLAUDE.md`).**
|
||||
|
||||
DETECT:
|
||||
- Every public or authenticated REST route has a `docs_api.endpoint()` entry in the correct group, with params and a `sample_response`. A documented route whose params drifted from the actual Form model is an error.
|
||||
- Every prose page's factual claims match the code (routes, env vars, defaults, behavior). A stale claim is an error.
|
||||
- `README.md` reflects current routes, env vars, dependencies, and user-visible features. `AGENTS.md` has a domain section for every mechanic. `CLAUDE.md` changes only for a new architectural rule.
|
||||
- `README.md` reflects current routes, env vars, dependencies, and user-visible features. Every nested `CLAUDE.md` has full coverage of its subsystem's mechanics/pitfalls, and the root `CLAUDE.md`'s "Subsystem map" table lists every nested `CLAUDE.md` that actually exists (no stale entry for one that was deleted, no missing entry for one that was added). Root `CLAUDE.md` changes only for a new cross-cutting architectural rule.
|
||||
- No file references a top-level `AGENTS.md` (grep the repo, excluding `.venv/`, `*.bak`, `.git/`, and the `agents/` exclusion above). A hit is an error - repoint it at the root or the correct nested `CLAUDE.md`.
|
||||
- Page-level role gating: admin-only pages carry `"admin": True` in their `DOCS_PAGES` entry; the router filters the sidebar to `visible_pages` and 404s a non-admin requesting an admin page, while `docs_search` still indexes admin pages for admins. An admin page missing the flag, or a member page wrongly flagged admin, is an error.
|
||||
- Section-level role gating: prose templates receive the user context via `docs_prose.render_prose` and gate admin sections with Jinja `{% if user %}` / `{% if user.role == 'admin' %}`. Unguarded admin material on a public page is an error.
|
||||
|
||||
FIX: add or repair the `endpoint()` entry, rewrite the stale prose, add the missing `README.md` / `AGENTS.md` section, add the `"admin": True` flag, or wrap the leaking section in the correct Jinja guard. The source is authoritative; correct the docs to match the code, never the reverse.
|
||||
FIX: add or repair the `endpoint()` entry, rewrite the stale prose, add the missing `README.md` section or nested `CLAUDE.md` section, repoint or delete a stray `AGENTS.md` reference, add the `"admin": True` flag, or wrap the leaking section in the correct Jinja guard. The source is authoritative; correct the docs to match the code, never the reverse.
|
||||
|
||||
## Scope units
|
||||
- **api-docs**: `devplacepy/docs_api.py` `endpoint()` coverage vs `routers/*.py` routes.
|
||||
- **page-gating**: `devplacepy/routers/docs/pages.py` `DOCS_PAGES` admin flag; `visible_pages` filter; `docs_search` indexing.
|
||||
- **section-gating**: `templates/docs/*.html` Jinja `{% if user.role == 'admin' %}` on admin sections.
|
||||
- **readme**: `README.md` reflects current routes, env vars, dependencies, features.
|
||||
- **agents-md**: `AGENTS.md` has a domain section for every mechanic; `CLAUDE.md` only for new rules.
|
||||
- **claude-md-nested**: every nested `CLAUDE.md` has a domain section for every mechanic in its subsystem; root `CLAUDE.md` only for new cross-cutting rules; no stray `AGENTS.md` file or reference anywhere in the repo.
|
||||
|
||||
## Output
|
||||
Return a markdown report: a one-line summary line, then one bullet per finding with severity (`error`/`warning`/`info`), `file:line`, the rule name, the message, and (in fix mode) whether it was fixed.
|
||||
|
||||
@@ -45,7 +45,7 @@ DETECT, for each route:
|
||||
- A `services/devii/actions/catalog.py` Action exists if the route is something a user could ask Devii to do.
|
||||
- A `docs_api.py` entry exists for every public or authenticated endpoint.
|
||||
- Public pages build `base_seo_context`.
|
||||
- `README.md` and `AGENTS.md` mention the feature.
|
||||
- `README.md` and the relevant nested `CLAUDE.md` mention the feature.
|
||||
|
||||
FIX: add the missing Form, add the missing key to the `*Out` schema, switch the handler to `respond`, or flag the responsible specialist's layer. When a layer is intentionally absent (an internal route with no public docs, a route Devii should never call), record an info finding with the rationale rather than fabricating the layer.
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ All application code is under `devplacepy/`: `devplacepy/routers/`, `devplacepy/
|
||||
Default to **PLAN** mode. Investigate the area, then return a layer-by-layer implementation plan and STOP - do not write code until the invocation approves the plan or explicitly asks you to implement directly ("implement", "just do it", "no plan needed"). Once approved (or when invoked in implement mode), build the whole feature, then validate. Never run the test suite; never perform any git write operation.
|
||||
|
||||
## Operating protocol
|
||||
1. **Understand before writing.** Read the router, template, matching tests, the relevant `CLAUDE.md`/`AGENTS.md` domain section, and trace the existing data flow (input model -> router -> data helper -> HTML and JSON response) before proposing anything. Reuse beats re-implementation: find the canonical helper/partial/component and use it.
|
||||
1. **Understand before writing.** Read the router, template, matching tests, the relevant nested `CLAUDE.md` (each subsystem directory has its own, e.g. `devplacepy/services/devii/CLAUDE.md`) and the root `CLAUDE.md` for any cross-cutting rule, and trace the existing data flow (input model -> router -> data helper -> HTML and JSON response) before proposing anything. Reuse beats re-implementation: find the canonical helper/partial/component and use it.
|
||||
2. Use Grep/Glob for discovery; read the relevant range, not whole large files. Never repeat a grep or re-read a file you already read.
|
||||
3. Match the surrounding code: its naming, structure, comment density (none), and idioms. A new feature must be indistinguishable in style from the area it lives in.
|
||||
4. Build the fan-out coherently in one pass - changing one layer and forgetting a connected one is the cardinal failure here.
|
||||
@@ -26,7 +26,7 @@ Default to **PLAN** mode. Investigate the area, then return a layer-by-layer imp
|
||||
|
||||
## Research the task before designing (codebase first, web when external)
|
||||
Investigation is two passes, in order:
|
||||
1. **Codebase pass (always).** Read the router, template, matching tests, and the relevant `CLAUDE.md`/`AGENTS.md` section; trace the existing data flow (input model -> router -> data helper -> HTML and JSON response); find the canonical helper, partial, or component to reuse. Never design from assumption when the answer is in the repo.
|
||||
1. **Codebase pass (always).** Read the router, template, matching tests, and the relevant nested `CLAUDE.md` (plus the root `CLAUDE.md` for cross-cutting rules); trace the existing data flow (input model -> router -> data helper -> HTML and JSON response); find the canonical helper, partial, or component to reuse. Never design from assumption when the answer is in the repo.
|
||||
2. **Web pass (whenever the feature touches anything outside this repo).** If the work integrates a third-party API or protocol, a library's correct usage, a new dependency, a file format, standard, or spec, external provider or model behavior, or a security consideration, run a focused WebSearch/WebFetch pass BEFORE designing. Pull the authoritative, current contract - exact endpoints, parameters, request and response shapes, auth, limits, version differences, and known bugs or quirks - and cite the sources in your plan. Prefer official docs and corroborate version-specific details. Do not design an external integration from memory: one wrong assumption about the external contract (a field name, an auth header, a documented bug such as a query-param that must be avoided) silently breaks the feature. Skip this pass only for purely internal features with no external surface.
|
||||
|
||||
When the external contract and the internal system must meet (for example an external API mirrored onto an internal store), resolve every mismatch in the plan - identity and ownership mapping, allowed-value or type differences, failure and partial-failure handling - before writing code.
|
||||
@@ -41,7 +41,7 @@ A DevPlace feature is one data source fanning out into several consumers, all fr
|
||||
5. **View** - templates extend `base.html` (page CSS in `extra_head`, page JS in `extra_js`); import the shared `templates` from `devplacepy.templating`, never instantiate `Jinja2Templates`. Wrap every static asset URL in `static_url(...)`/`assetUrl(...)`. Reuse partials (`_avatar_link.html`, `_user_link.html`, `_sidebar_search.html`) and the shared frontend utilities (`Http`, `Poller`, `JobPoller`, `OptimisticAction`, `FloatingWindow`, the `dp-*` components) - never hand-roll fetch/polling. JS is ES6 modules, one class per file, on `app`. Dates are DD/MM/YYYY via `format_date`.
|
||||
6. **Agent + docs (the most-forgotten layers)** - if a user could ask Devii to do it, add an `Action` in `services/devii/actions/catalog.py` with auth flags matched to the route guard (and a declared `confirm` boolean for any irreversible action added to `CONFIRM_REQUIRED`). Add a `docs_api.py` `endpoint()` entry (params + `sample_response`) for every public/auth endpoint; add a prose page to `routers/docs/pages.py` `DOCS_PAGES` when warranted. State-changing actions need an audit event (`events.md` key, `category_for`, recorder call at the mutation point).
|
||||
7. **SEO** - public pages build `base_seo_context` and the right JSON-LD; add to `routers/seo.py` sitemap when indexable.
|
||||
8. **Docs of record** - update `README.md` (product-facing) and `AGENTS.md` (deep companion) for any new route/config/dependency/mechanic; update `CLAUDE.md` only when a NEW architectural rule or convention is introduced.
|
||||
8. **Docs of record** - update `README.md` (product-facing) and the relevant nested `CLAUDE.md` (deep companion for the subsystem you touched - create one if the directory doesn't have one yet) for any new route/config/dependency/mechanic; update the root `CLAUDE.md` only when a NEW cross-cutting architectural rule or convention is introduced, and add a row to its "Subsystem map" table if you created a new nested `CLAUDE.md`.
|
||||
9. **Tests (a hard project requirement, never optional)** - the DevPlace suite is one test file per endpoint, ~932 tests, split into three tiers with the directory tree mirroring the URL/source path. Every feature gets a test in EVERY tier it exercises: `tests/unit/` for a new data/query helper (pure in-process, `local_db` or no fixture, path mirrors the SOURCE module - `devplacepy/utils.py` -> `tests/unit/utils.py`); `tests/api/` for a new JSON or HTML route (HTTP integration against the live uvicorn subprocess via `app_server`/`seeded_db`, path mirrors the endpoint - `POST /auth/login` -> `tests/api/auth/login.py`) - but when a route depends on an in-process injected fake or a module-level singleton the separate uvicorn subprocess cannot see (the Gitea client via `runtime.set_client(fake)`, or any other `set_client`/monkeypatched backend), test it IN-PROCESS instead with `from starlette.testclient import TestClient; TestClient(m.app)`, the fake set in the test process, and auth via a `create_session(uid)` `session` cookie, asserting JSON with `Accept: application/json` (the `tests/api/issues/` files are the canonical example); `tests/e2e/` for a new interactive UI flow (Playwright `page`/`alice`/`bob`, path mirrors the endpoint - `GET /admin/ai-usage` -> `tests/e2e/admin/aiusage.py`). A route or feature with no test in any tier is incomplete. Follow the required patterns (`wait_until="domcontentloaded"` on every `goto`/`wait_for_url`, scoped selectors, `try/finally` restore of any flipped global setting, the shared fixtures, `test_`-prefixed functions in non-prefixed files, born-live `deleted_at`/`deleted_by` on raw soft-delete inserts) and create any missing package directories (`__init__.py`). WRITE them; validate each by a clean import only; NEVER run them.
|
||||
|
||||
When a layer is intentionally absent (an internal route with no public docs, a route Devii must never call), say so explicitly in the plan with the rationale rather than fabricating the layer.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: style-maintainer
|
||||
description: Coding-rule compliance. Enforces the explicit CLAUDE.md and AGENTS.md coding rules across all source - forbidden naming (context-aware), no comments/docstrings, em-dash (context-aware), full typing, pathlib over os, dataclasses over fixed-key dicts, no version pinning, file headers, no magic numbers. Use for style/convention review. Most surface name/em-dash hits are false positives - run the decision algorithm.
|
||||
description: Coding-rule compliance. Enforces the explicit CLAUDE.md (root and nested per-subsystem) coding rules across all source - forbidden naming (context-aware), no comments/docstrings, em-dash (context-aware), full typing, pathlib over os, dataclasses over fixed-key dicts, no version pinning, file headers, no magic numbers. Use for style/convention review. Most surface name/em-dash hits are false positives - run the decision algorithm.
|
||||
tools: Read, Grep, Glob, Edit, Write, Bash
|
||||
model: inherit
|
||||
color: orange
|
||||
@@ -36,7 +36,7 @@ Default to **REPORT** mode: record findings, do NOT modify files. Apply **FIX**
|
||||
No comments or docstrings in source you author; no em-dashes (use a hyphen); keep `retoor <retoor@molodetz.nl>` as the first line of any file you create.
|
||||
|
||||
## Your dimension
|
||||
Enforce the explicit CLAUDE.md and AGENTS.md coding rules across all source.
|
||||
Enforce the explicit CLAUDE.md (root plus every nested per-subsystem `CLAUDE.md`) coding rules across all source.
|
||||
|
||||
### Forbidden naming prefixes and suffixes (CONTEXT-AWARE)
|
||||
The banned tokens are `_new`, `_old`, `_current`, `_prev`, `_next` (outside iteration), `_temp`, `_tmp`, `_v1`/`_v2`/`_v3`, `better_`, `best_`, `simple_`, `my_`, `the_`, `_data`, `_info`, and the rest of the forbidden list. This rule targets LAZY, RENAMEABLE VARIABLE AND HELPER names you own. It is NOT a blind substring sweep, and most surface hits on `_data`/`_info`/`_item`/`_val` are FALSE POSITIVES. Run this decision algorithm for EVERY candidate before recording it, and skip it the moment any test fails:
|
||||
|
||||
@@ -30,7 +30,7 @@ All application code is under `devplacepy/`: `devplacepy/routers/`, `devplacepy/
|
||||
- **F. Verify your own work.** After writing a test module, validate it ONLY by a clean import (`python -c "import tests..."` or `python -m py_compile`).
|
||||
|
||||
## Mode
|
||||
Default to **REPORT** mode: record coverage gaps and pattern violations, do NOT write files. Apply **FIX** mode only when the invocation explicitly asks you to fix; then write the missing integration test following the required patterns. **HARD GUARDRAIL: write tests but NEVER run the suite, not the full suite and not a single file.** Validate only by a clean import of the new test module. Never perform any git write operation.
|
||||
Default to **REPORT** mode: record coverage gaps and pattern violations, do NOT write files. Apply **FIX** mode only when the invocation explicitly asks you to fix; then write the missing integration test following the required patterns. **HARD GUARDRAIL: write tests but NEVER launch the suite yourself, not the full suite and not a single file - the serial single-process suite cannot run concurrently with other agents, so the orchestrating session runs `make test` (all tests) after your work.** Validate only by a clean import of the new test module. Never perform any git write operation.
|
||||
|
||||
## Obey the rules you enforce
|
||||
No comments or docstrings in source you author; no em-dashes (use a hyphen); keep `retoor <retoor@molodetz.nl>` as the first line of any file you create.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
description: Explain a DevPlace subsystem, route, or file - read the relevant AGENTS.md section and the code, then summarize architecture, data flow, invariants, and entry points. Read-only.
|
||||
description: Explain a DevPlace subsystem, route, or file - read the relevant nested CLAUDE.md and the code, then summarize architecture, data flow, invariants, and entry points. Read-only.
|
||||
argument-hint: <area, route, or file>
|
||||
allowed-tools: Read, Grep, Glob, Bash(git log:*)
|
||||
---
|
||||
@@ -8,13 +8,13 @@ Orient me on: **$ARGUMENTS**
|
||||
Investigate before explaining; confirm every claim against the source.
|
||||
|
||||
1. Locate the code: the router under `devplacepy/routers/`, the template under `devplacepy/templates/`, data helpers in `devplacepy/database.py`, schemas in `devplacepy/schemas.py`, and any service under `devplacepy/services/`.
|
||||
2. Read the matching domain section in `AGENTS.md` (the long-form companion) and the relevant part of `CLAUDE.md`.
|
||||
2. Read the matching nested `CLAUDE.md` for the subsystem (e.g. `devplacepy/services/devii/CLAUDE.md`), plus the relevant cross-cutting part of the root `CLAUDE.md`.
|
||||
3. Trace the data flow: input model (`models.py`) -> router handler + guard -> data helper -> response (HTML via `respond` + template, JSON via the `*Out` schema), plus the Devii action (`catalog.py`) and API docs (`docs_api.py`) where present.
|
||||
|
||||
Then give a tight explanation:
|
||||
- What it does and where it lives, with `file:line` references.
|
||||
- The request pipeline and data flow.
|
||||
- Key invariants and gotchas (pull these from AGENTS.md).
|
||||
- Key invariants and gotchas (pull these from the nested CLAUDE.md).
|
||||
- The fan-out: which of the nine feature layers exist for it.
|
||||
|
||||
Do not modify anything.
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
---
|
||||
description: Run the DevPlace maintenance agent fleet (10 quality dimensions) in check or fix mode, optionally scoped to changed files or a subset.
|
||||
description: Run the DevPlace maintenance agent fleet (12 quality dimensions) in check or fix mode, optionally scoped to changed files or a subset.
|
||||
argument-hint: "[check|fix] [changed] [comma,list,of,dimensions]"
|
||||
---
|
||||
|
||||
You are orchestrating the DevPlace maintenance fleet. Each dimension is a project subagent under `.claude/agents/`. The fleet enforces ten independent quality dimensions across the `devplacepy/` package and `tests/`.
|
||||
You are orchestrating the DevPlace maintenance fleet. Each dimension is a project subagent under `.claude/agents/`. The fleet enforces twelve independent quality dimensions across the `devplacepy/` package and `tests/`.
|
||||
|
||||
## Dimension to subagent map
|
||||
| Dimension | Subagent | Enforces |
|
||||
|-----------|----------|----------|
|
||||
| style | `style-maintainer` | CLAUDE.md/AGENTS.md coding rules (context-aware names, em-dash, typing, pathlib, headers) |
|
||||
| style | `style-maintainer` | CLAUDE.md (root/nested) coding rules (context-aware names, em-dash, typing, pathlib, headers) |
|
||||
| dry | `dry-maintainer` | duplication and reuse of canonical shared utilities |
|
||||
| security | `security-maintainer` | auth guards, project visibility, read-only guards, input validation, XSS |
|
||||
| audit | `audit-maintainer` | audit-log coverage and event catalogue |
|
||||
@@ -18,15 +18,17 @@ You are orchestrating the DevPlace maintenance fleet. Each dimension is a projec
|
||||
| fanout | `fanout-maintainer` | cross-layer feature completeness |
|
||||
| docs | `docs-maintainer` | docs coverage and role-aware show/hide |
|
||||
| test | `test-maintainer` | integration-test coverage |
|
||||
| background | `background-maintainer` | background-queue deferral, response-critical/inline boundaries |
|
||||
| locust | `locust-maintainer` | locustfile.py route coverage and load-test safety |
|
||||
|
||||
The canonical run order is: **style, dry, security, audit, devii, seo, frontend, fanout, docs, test**.
|
||||
The canonical run order is: **style, dry, security, audit, devii, seo, frontend, fanout, docs, test, background, locust**.
|
||||
|
||||
## Parse the arguments
|
||||
Arguments: `$ARGUMENTS`
|
||||
|
||||
- **Mode**: `fix` anywhere in the arguments means FIX mode; otherwise default to CHECK mode (read-only report).
|
||||
- **changed**: the word `changed` means scope the run to only the files git reports as modified or new under `devplacepy/` and `tests/`. Compute that set first with `git status --porcelain` and keep existing paths whose first segment is `devplacepy/` or `tests/`. If the set is empty, report "nothing to do" and stop. Pass the explicit file list into each subagent's prompt so it reports/fixes only within that set (it may still read other files for cross-reference).
|
||||
- **Subset**: any comma-separated dimension names (e.g. `security,docs`) restrict the run to those dimensions in canonical order. With no subset, run all ten.
|
||||
- **Subset**: any comma-separated dimension names (e.g. `security,docs`) restrict the run to those dimensions in canonical order. With no subset, run all twelve.
|
||||
|
||||
## Execute
|
||||
1. Resolve the dimension list and mode from the arguments above.
|
||||
|
||||
@@ -12,5 +12,5 @@ Mirror an existing service - read `devplacepy/services/base.py` (BaseService) an
|
||||
3. Register it in `main.py` startup: `service_manager.register(YourService())`, under the same `DEVPLACE_DISABLE_SERVICES` guard as the others. It then auto-appears on `/admin/services`.
|
||||
4. If it calls an LLM, default its endpoint to `config.INTERNAL_GATEWAY_URL` and authenticate with the internal gateway key, like the other AI consumers.
|
||||
5. Emit audit events via `record_system` for any state change it makes.
|
||||
6. Document it in `AGENTS.md` (Background services section) and in `README.md` if user-visible.
|
||||
6. Document it in `devplacepy/services/CLAUDE.md` (Background services base machinery section, or the service's own nested `CLAUDE.md` if it has one) and in `README.md` if user-visible.
|
||||
7. Validate with `hawk` on the touched files and `python -c "from devplacepy.main import app"`.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
description: Run DevPlace tests - the sanctioned explicit-ask path. Run a tier, a file, or a single test with the correct flags. The agents never run tests themselves; this command is how you ask.
|
||||
description: Run DevPlace tests. Run a tier, a file, or a single test with the correct flags. Subagents never run tests themselves (the serial suite cannot run concurrently); the orchestrating session always runs the full suite as the final validation of every change.
|
||||
argument-hint: [unit|api|e2e|all|<path::test_name>]
|
||||
allowed-tools: Bash(make test*), Bash(python -m pytest *), Read
|
||||
---
|
||||
@@ -12,6 +12,6 @@ Mapping:
|
||||
- `all` or empty -> `make test`
|
||||
- a path like `tests/api/posts/create.py::test_x` -> `python -m pytest <that> -v --tb=line -x`
|
||||
|
||||
Tests run serially on port 10501 with a tempfile SQLite DB and `DEVPLACE_DISABLE_SERVICES=1`. This command is the one sanctioned way to run them (the subagents and workflows never do).
|
||||
Tests run serially on port 10501 with a tempfile SQLite DB and `DEVPLACE_DISABLE_SERVICES=1`. Subagents and workflows never launch tests (the serial single-process suite cannot run concurrently); the orchestrating session runs the full suite (`make test`) as the mandatory final validation of every change.
|
||||
|
||||
Report results clearly. On a failure, show the relevant output, and if a browser (e2e) test failed, point me at the screenshot under `/tmp/devplace_test_screenshots/`. Never weaken a test to make it pass; if a test reveals a real bug, report it - do not edit the test.
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(python *)",
|
||||
"Bash(DEVPLACE_DISABLE_SERVICES=1 python -)",
|
||||
"Bash(command -v hawk)",
|
||||
"Bash(export DEVPLACE_DISABLE_SERVICES=1)",
|
||||
"Bash(export DEVPLACE_DATABASE_URL=\"sqlite:///tmp/devplace_verify.db\")",
|
||||
"Bash(rm -f /tmp/devplace_verify.db)",
|
||||
"Bash(export DEVPLACE_DATABASE_URL=\"sqlite:////tmp/devplace_verify.db\")",
|
||||
"Bash",
|
||||
"Edit(/home/retoor/projects/devplacepy/devplacepy/routers/projects/containers/instances.py)",
|
||||
"Edit(/home/retoor/projects/devplacepy/devplacepy/static/js/components/ContainerTerminal.js)",
|
||||
"Edit(/home/retoor/projects/devplacepy/devplacepy/services/containers/store.py)",
|
||||
"Verify",
|
||||
"Edit(/home/retoor/projects/devplacepy/devplacepy/static/js/MessagesLayout.js)",
|
||||
"Write(/home/retoor/projects/devplacepy/devplacepy/static/css/messages.css)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,7 @@ const FANOUT = [
|
||||
'6. services/devii/actions/catalog.py - an Action(name, method, path, summary, params, requires_auth, requires_admin) if a user could ask Devii to do it; a confirm param plus membership in CONFIRM_REQUIRED if destructive.',
|
||||
'7. docs_api.py - an endpoint() entry in the right group with params and sample_response for every public or authenticated route.',
|
||||
'8. seo.py - base_seo_context(request, ...) merged into the context for public pages; a sitemap entry in routers/seo.py if indexable.',
|
||||
'9. README.md (product) + AGENTS.md (mechanics) + CLAUDE.md (only for a genuinely new architectural rule).',
|
||||
'9. README.md (product) + the relevant nested CLAUDE.md (mechanics) + the root CLAUDE.md (only for a genuinely new architectural rule).',
|
||||
].join('\n')
|
||||
|
||||
const TESTS = [
|
||||
@@ -175,7 +175,7 @@ const LIVE_SCHEMA = {
|
||||
log(`Feature: ${ask}`)
|
||||
|
||||
const map = await agent(
|
||||
`Map the area of the DevPlace codebase relevant to this feature request, so it can be implemented. Read the closest existing feature end to end (its router, template, tests, and AGENTS.md section) as the pattern to follow. Do not write anything.\n\nFeature request: ${ask}\n\n${FANOUT}\n\nReturn: a summary of how this should be built, the concrete files to touch or create, the most similar existing feature to mirror, and any constraints.`,
|
||||
`Map the area of the DevPlace codebase relevant to this feature request, so it can be implemented. Read the closest existing feature end to end (its router, template, tests, and the matching nested CLAUDE.md) as the pattern to follow. Do not write anything.\n\nFeature request: ${ask}\n\n${FANOUT}\n\nReturn: a summary of how this should be built, the concrete files to touch or create, the most similar existing feature to mirror, and any constraints.`,
|
||||
{ agentType: 'Explore', label: 'understand', phase: 'Understand', schema: MAP_SCHEMA }
|
||||
)
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
export const meta = {
|
||||
name: 'fleet',
|
||||
description: 'DevPlace maintenance fleet: 10 dimension subagents scan in parallel, then every finding is adversarially verified against source before it is reported',
|
||||
description: 'DevPlace maintenance fleet: 12 dimension subagents scan in parallel, then every finding is adversarially verified against source before it is reported',
|
||||
phases: [
|
||||
{ title: 'Review', detail: '10 dimension subagents scan devplacepy/ and tests/ in parallel' },
|
||||
{ title: 'Review', detail: '12 dimension subagents scan devplacepy/ and tests/ in parallel' },
|
||||
{ title: 'Verify', detail: 'adversarially refute each candidate finding against the actual source' },
|
||||
],
|
||||
}
|
||||
@@ -19,6 +19,8 @@ const DIMENSIONS = [
|
||||
{ key: 'fanout', agent: 'fanout-maintainer' },
|
||||
{ key: 'docs', agent: 'docs-maintainer' },
|
||||
{ key: 'test', agent: 'test-maintainer' },
|
||||
{ key: 'background', agent: 'background-maintainer' },
|
||||
{ key: 'locust', agent: 'locust-maintainer' },
|
||||
]
|
||||
|
||||
const FINDINGS_SCHEMA = {
|
||||
@@ -86,7 +88,7 @@ function reportPrompt(dimension) {
|
||||
|
||||
function verifyPrompt(dimension, finding) {
|
||||
return (
|
||||
`Adversarially verify a candidate "${dimension}" finding. Your goal is to REFUTE it. Open the exact file and read ` +
|
||||
`You are an independent skeptic, not the agent that raised this finding. A "${dimension}"-dimension maintenance agent flagged the candidate below; your job is solely to REFUTE it from a fresh, unbiased read of the source. Open the exact file and read ` +
|
||||
`enough surrounding context (the whole function, the caller, the contract) to judge intent. It is REAL only if it ` +
|
||||
`survives refutation as a genuine violation of the ${dimension} dimension. Rule it out (isReal=false) if it is a ` +
|
||||
`contract identifier, DATA rather than authored prose, generated or vendored or third-party, or already correct ` +
|
||||
@@ -116,7 +118,6 @@ const reviewed = await pipeline(
|
||||
parallel(
|
||||
((review && review.findings) || []).map((finding) => () =>
|
||||
agent(verifyPrompt(dimension.key, finding), {
|
||||
agentType: dimension.agent,
|
||||
label: `verify:${dimension.key}`,
|
||||
phase: 'Verify',
|
||||
schema: VERDICT_SCHEMA,
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
export const meta = {
|
||||
name: 'full-docs-refactor',
|
||||
description:
|
||||
'Documentation reality audit: verify every falsifiable claim in README.md, the root CLAUDE.md, every nested CLAUDE.md, and the entire /docs site (prose + docs_api) against the actual source, fix drift in place, and confirm role-gating. Every agent owns a disjoint set of files so there are never write conflicts.',
|
||||
phases: [
|
||||
{ title: 'Ground truth', detail: 'extract authoritative facts (routes, CLI, env, deps, test count, package layout, docs registry) from source' },
|
||||
{ title: 'Root docs', detail: 'audit README.md plus every CLAUDE.md (root and nested per-subsystem) in parallel - one file per agent' },
|
||||
{ title: 'Docs site', detail: 'audit the docs_api package and every /docs prose section in parallel - disjoint template ownership' },
|
||||
{ title: 'Gating + validate', detail: 'verify role-gating and run the full validation sweep (import, template compile, em-dash, broken links)' },
|
||||
],
|
||||
}
|
||||
|
||||
const REPORT_SCHEMA = {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['target', 'changed', 'changes', 'verifiedAccurate'],
|
||||
properties: {
|
||||
target: { type: 'string' },
|
||||
changed: { type: 'boolean' },
|
||||
changes: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['location', 'wrong', 'fixed'],
|
||||
properties: {
|
||||
location: { type: 'string' },
|
||||
wrong: { type: 'string' },
|
||||
fixed: { type: 'string' },
|
||||
source: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
verifiedAccurate: { type: 'array', items: { type: 'string' } },
|
||||
gatingIssues: { type: 'array', items: { type: 'string' } },
|
||||
unverifiable: { type: 'array', items: { type: 'string' } },
|
||||
},
|
||||
}
|
||||
|
||||
const VALIDATE_SCHEMA = {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['appImports', 'docsApiValid', 'templatesCompile', 'emDashClean', 'brokenLinks', 'gatingClean'],
|
||||
properties: {
|
||||
appImports: { type: 'boolean' },
|
||||
docsApiValid: { type: 'boolean' },
|
||||
templatesCompile: { type: 'boolean' },
|
||||
emDashClean: { type: 'boolean' },
|
||||
brokenLinks: { type: 'array', items: { type: 'string' } },
|
||||
gatingClean: { type: 'boolean' },
|
||||
gatingFixes: { type: 'array', items: { type: 'string' } },
|
||||
notes: { type: 'string' },
|
||||
},
|
||||
}
|
||||
|
||||
const SHARED_RULES =
|
||||
'RULES (all mandatory):\n' +
|
||||
'- The CODE is the source of truth. When docs disagree with code, fix the DOCS, never the code. Do not invent or aspirationally document features. If docs describe something removed/renamed, correct or remove it.\n' +
|
||||
'- Use Read/Grep/Glob/Bash to CONFIRM every claim before you edit it. Never edit on assumption.\n' +
|
||||
'- NEVER introduce an em-dash character or its HTML entity; use a hyphen. Replace any em-dash in a passage you rewrite.\n' +
|
||||
'- Be surgical: change only what is verifiably wrong or verifiably missing from a list/table meant to be complete. Preserve tone, structure, and formatting.\n' +
|
||||
'- Do not corrupt markdown tables, HTML, or Jinja.\n' +
|
||||
'DOCS PROSE STRUCTURE (for /docs/*.html templates): the body is <div class="docs-content" data-render> rendered to HTML SERVER-SIDE from markdown; example markup shown as code INSIDE that block stays HTML-entity-escaped (<...>). Real live-demo markup and its <script type="module"> live OUTSIDE that block - update a demo only if the API it shows changed.\n' +
|
||||
'ROLE GATING: pages flagged admin:true in routers/docs/pages.py 404 for non-admins and are nav-filtered. Every /docs/<slug>.html link must resolve to a real slug (or a real /docs route like download.html/download.md). If a page visible to guests/members links to an admin-only route or admin doc slug, wrap it in {% if is_admin(user) %}...{% endif %}.\n' +
|
||||
'REPORT: return structured output - target, changed, one entry per fix (location, wrong, fixed, source), the claim categories you verified as accurate, any gating issue, and anything you could not verify.'
|
||||
|
||||
function rootPrompt(file, gt) {
|
||||
const isNested = file !== 'README.md' && file !== 'CLAUDE.md'
|
||||
const nestedNote = isNested
|
||||
? ` This is a NESTED CLAUDE.md (Claude Code auto-loads it only when a file under its own directory is read/edited) - its claims must be scoped to that subsystem; do not duplicate content that belongs in the root CLAUDE.md's cross-cutting rules or in a sibling nested file, and do not reintroduce a top-level AGENTS.md or any reference to one (it was deleted - all of its content now lives across the root CLAUDE.md and the nested CLAUDE.md files).`
|
||||
: ''
|
||||
return (
|
||||
`DOCUMENTATION REALITY AUDIT of a single file: ${file}. Verify EVERY falsifiable claim against the actual source and FIX inconsistencies in place. EDIT ONLY ${file}.${nestedNote}\n\n` +
|
||||
`Verify (where the file claims them): make targets + comments, devplace/devii CLI subcommands + flags, router prefixes/paths, env vars + defaults, config keys + defaults, function/class/helper/table/setting names, file/module paths (must exist), dependency names, version numbers, test counts, and internal links/anchors. For a routing table, env-var table, commands block, or CLI list that is meant to be COMPLETE, add rows that exist in code but are missing. If this file is the root CLAUDE.md, verify its "Subsystem map" table still lists every nested CLAUDE.md that actually exists in the repo and no stale entries for one that was removed.\n\n` +
|
||||
`AUTHORITATIVE GROUND TRUTH (freshly extracted from this repo - trust it, but re-confirm anything you edit):\n${gt}\n\n` +
|
||||
SHARED_RULES
|
||||
)
|
||||
}
|
||||
|
||||
const DOCS_SECTIONS = [
|
||||
{
|
||||
key: 'docs_api',
|
||||
agentType: 'docs-maintainer',
|
||||
prompt:
|
||||
'Audit and FIX the /docs API reference, which is GENERATED from the `devplacepy/docs_api/` package (groups/ + services_group.py), NOT from templates. EDIT ONLY files under `devplacepy/docs_api/`. For EVERY documented endpoint verify against the real router + schema: method+path exists (grep @router in routers/, account for the main.py mount prefix), documented params/body match the real Form/query params (models.py, route signature), sample_response shape matches the real *Out schema (schemas/), and the stated auth matches the route guard (get_current_user/require_user/require_admin). The admin API groups (containers/gateway/services/admin) must be genuinely admin routes. Keep the group data valid Python (verify `python -c "from devplacepy.docs_api import API_GROUPS; print(len(API_GROUPS))"`). Remove documented endpoints that no longer exist; correct wrong params/paths/responses; note real endpoints the docs omit.',
|
||||
},
|
||||
{
|
||||
key: 'general-a',
|
||||
agentType: 'docs-maintainer',
|
||||
prompt:
|
||||
'Audit and FIX these /docs prose templates (EDIT ONLY these, under devplacepy/templates/docs/): index.html, getting-started.html, getting-started-vibing.html, feed.html, code-farm.html, block-and-mute.html, emoji-shortcodes.html, presence.html. Verify against: routers/{feed,game/,relations,news}.py, rendering.py (emoji shortcodes via build_emoji_shortcodes + `devplace emoji-sync`), services/presence.py + presence_relay.py, config.py presence defaults, main.py GET / home behavior. code-farm documents the /game Code Farm game; block-and-mute documents relations (/block,/block/unblock,/mute,/mute/unmute).',
|
||||
},
|
||||
{
|
||||
key: 'general-b',
|
||||
agentType: 'docs-maintainer',
|
||||
prompt:
|
||||
'Audit and FIX these /docs prose templates (EDIT ONLY these): devii.html, telegram.html, media-gallery.html, notification-settings.html, timezones.html, ai-correction.html, ai-modifier.html, dashboard.html (kind=live). Verify against: services/devii/ (member page), services/telegram/, services/correction.py, services/ai_modifier.py, routers/profile/{notifications,ai_correction,ai_modifier,telegram}.py, database notification prefs (NOTIFICATION_TYPES/NOTIFICATION_CHANNELS + defaults), templating.py local_dt/dt_ago + static/js/LocalTime.js, routers/media.py, routers/docs/views.py + docs_live.py (dashboard facts).',
|
||||
},
|
||||
{
|
||||
key: 'components',
|
||||
agentType: 'frontend-maintainer',
|
||||
prompt:
|
||||
'Audit and FIX the /docs Components pages (EDIT ONLY: components.html and component-*.html under templates/docs/). Source of truth: devplacepy/static/js/components/*.js and devii/*.js. For each page verify the customElements.define tag name, every documented attribute/property (attr/boolAttr/intAttr reads), methods/events, and the singleton access path (app.dialog/app.contextMenu/app.toast/app.lightbox/app.containerTerminals). Confirm the live-demo markup uses attributes that still exist; fix demos referencing removed attributes. component-emoji-picker documents the external emoji-picker-element (confirm it is still loaded in base.html).',
|
||||
},
|
||||
{
|
||||
key: 'styles-tools',
|
||||
agentType: 'docs-maintainer',
|
||||
prompt:
|
||||
'Audit and FIX (EDIT ONLY): styles.html, styles-colors.html, styles-layout.html, styles-responsiveness.html, styles-consistency.html, tools-seo.html, tools-deepsearch.html. Styles pages: every documented CSS --token name/value must match devplacepy/static/css/variables.css; breakpoints/structural rules must match base.css (and feed.css/projects.css for layout examples). Tools pages: verify routes and caps against routers/tools/{seo,deepsearch}.py, services/jobs/{seo,deepsearch}/, and models.py (SeoRunForm.max_pages 1-50; DeepSearch depth 1-4, max_pages 1-30).',
|
||||
},
|
||||
{
|
||||
key: 'devrant',
|
||||
agentType: 'docs-maintainer',
|
||||
prompt:
|
||||
'Audit and FIX the /docs devRant compatibility API pages (EDIT ONLY: devrant.html, devrant-auth.html, devrant-rants.html, devrant-comments.html, devrant-users.html, devrant-notifications.html, devrant-clients.html). Source: routers/devrant/ (mounted at /api) and services/devrant/. Also audit the backing devplacepy/docs_devrant.py if the widget data is wrong (it feeds _devrant_endpoints.html) - but only edit it if a claim is factually wrong. Verify each endpoint path (under /api), method, merged query+form+JSON params, the token triple auth, and the dr_ok/dr_error envelope. Reference client dir is examples/devrant/ (fix any stale devranta/ path).',
|
||||
},
|
||||
{
|
||||
key: 'claude',
|
||||
agentType: 'docs-maintainer',
|
||||
prompt:
|
||||
'Audit and FIX the /docs Claude Code pages (EDIT ONLY: claude.html, claude-manual.html, claude-agents.html, claude-commands.html, claude-workflows.html). Source of truth for project-specific claims: .claude/agents/*.md, .claude/commands/*.md, .claude/workflows/*.js. Fix any agent/command/workflow list that drifted from what exists, and any count of them. For general Claude Code product facts not verifiable from the repo, be CONSERVATIVE - leave them unless a .claude/ file contradicts.',
|
||||
},
|
||||
{
|
||||
key: 'admin-prose',
|
||||
agentType: 'docs-maintainer',
|
||||
prompt:
|
||||
'Audit and FIX the admin-gated /docs Administration prose pages (EDIT ONLY: devii-admin.html, telegram-admin.html, media-moderation.html, soft-delete.html, backups.html, gamification.html, audit-log.html). Sources: services/audit/ + events.md (event count/domains - match events.md self-reported figure), services/backups/ + routers/admin/backups.py (primary-admin-only download via utils.is_primary_admin), database soft-delete (SOFT_DELETE_TABLES) + /admin/trash, utils badges (ACHIEVEMENTS/BADGE_CATALOG/track_action - include the Code Farm badges), routers/media.py + /admin/media, Devii admin caps + config, services/telegram/ admin config. Verify routes, config-field names+defaults, function/class/table names, CLI commands.',
|
||||
},
|
||||
{
|
||||
key: 'devii-internals',
|
||||
agentType: 'devii-maintainer',
|
||||
prompt:
|
||||
'Audit and FIX the admin-gated /docs Devii internals pages (EDIT ONLY: devii-internals.html, devii-architecture.html, devii-tools.html, devii-data.html, devii-security.html, devii-config.html). Source: services/devii/ (session/ package, agentic/, actions/catalog/ package + dispatcher, hub, tasks/, behavior/, virtual_tools/, customization/, client/, rsearch/, email/, container/) and routers/devii.py. Verify: the documented tool/action names exist and their requires_auth/requires_admin/requires_primary_admin/CONFIRM_REQUIRED flags match the catalog; the total action+handler counts; session keying is (owner_kind, owner_id, channel); the persistence tables (devii_conversations/usage_ledger/turns/tasks/lessons/behavior/virtual_tools); the 4013/1013 close codes; financial-data-admin-only; run_js gated by devii_allow_eval; db_* tools primary-admin-only. NOTE session and actions/catalog are PACKAGES now.',
|
||||
},
|
||||
{
|
||||
key: 'bots',
|
||||
agentType: 'docs-maintainer',
|
||||
prompt:
|
||||
'Audit and FIX the admin-gated /docs Bots internals pages (EDIT ONLY: bots-internals.html, bots-architecture.html, bots-personas.html, bots-content.html, bots-engagement.html, bots-realism.html, bots-config.html). Source: services/bot/ (config.py for every documented default; llm.py/loop.py/posting.py/helpers.py/social.py/service.py for mechanics). Verify EVERY config default against services/bot/config.py, the service registration name/interval/default_enabled, the [bots] extra (playwright+faker), the referenced function names (generate_post_title, gist_quality_check, _engage_community, persona_article_score, pick_category, strip_label), and the design-narrative numbers (REACT_RATES, MAX_BOTS_PER_ARTICLE, etc.).',
|
||||
},
|
||||
{
|
||||
key: 'services',
|
||||
agentType: 'docs-maintainer',
|
||||
prompt:
|
||||
'Audit and FIX the admin-gated /docs Services pages (EDIT ONLY: services-overview.html, services-framework.html, services-data.html, services-gateway.html, services-devii.html, services-news.html, services-bots.html, services-zip.html, services-containers.html, services-dbapi.html, services-pubsub.html). Source: services/ subpackages and the main.py service registrations (the real count of registered services). Verify each service registration name/default_enabled/interval, config fields+defaults, tables, route surface, and source paths (NewsService now lives in services/news/service.py - news is a PACKAGE; runtime dirs default to data/ NOT var/; there is NO in-app container build / ContainerBuildService; /dbapi is READ-ONLY primary-admin-only).',
|
||||
},
|
||||
{
|
||||
key: 'architecture',
|
||||
agentType: 'docs-maintainer',
|
||||
prompt:
|
||||
'Audit and FIX the admin-gated /docs Architecture pages (EDIT ONLY: architecture.html, architecture-backend.html, architecture-frontend.html, architecture-styling.html, architecture-conventions.html, architecture-workflow.html, architecture-jobs.html). Source: main.py (request pipeline, middleware order, mounts), routers/ tree, static/js/ (ES6 modules on app, Application.js, dp-* components, shared utils Http/Poller/JobPoller/OptimisticAction/FloatingWindow), templating.py, rendering.py, services/jobs/ (JobService pattern). Fix any file/module path that no longer exists - database/utils/schemas/docs_api are PACKAGES now. Do NOT "fix" the deliberate synchronous-SQLite design to async.',
|
||||
},
|
||||
{
|
||||
key: 'testing-prod',
|
||||
agentType: 'docs-maintainer',
|
||||
prompt:
|
||||
'Audit and FIX the admin-gated /docs Testing + Production pages (EDIT ONLY: testing.html, testing-framework.html, testing-locust.html, testing-make.html, testing-cicd.html, production.html, production-deploy.html, production-nginx.html, production-concurrency.html, static-caching.html). Sources: Makefile, pyproject.toml ([tool.pytest.ini_options]), tests/ layout + conftest.py fixtures, locustfile.py, .gitea/workflows/, Dockerfile, docker-compose*.yml, nginx config, config.py (STATIC_VERSION). Verify every make target + behavior, the live test count (run `python -m pytest tests/ --collect-only -q | tail -1`), the tier layout, fixtures, ports, CI steps, the worker model (make prod = nproc; the Docker image pins 2 - keep that distinction), nginx WS-upgrade locations, and /static/v<version>/ caching.',
|
||||
},
|
||||
]
|
||||
|
||||
function sectionPrompt(section, gt) {
|
||||
return (
|
||||
section.prompt +
|
||||
`\n\nAUTHORITATIVE GROUND TRUTH (freshly extracted from this repo - trust it, re-confirm what you edit):\n${gt}\n\n` +
|
||||
SHARED_RULES
|
||||
)
|
||||
}
|
||||
|
||||
function selected(list) {
|
||||
const only = args && args.only
|
||||
if (!only) return list
|
||||
const keys = Array.isArray(only) ? only : String(only).split(',').map((s) => s.trim()).filter(Boolean)
|
||||
return list.filter((item) => keys.includes(item.key))
|
||||
}
|
||||
|
||||
const GT_PROMPT =
|
||||
'Operate READ-ONLY (do not edit any file). Extract the AUTHORITATIVE, current ground-truth facts of this repository so a documentation audit can cross-check against them. Use Bash/Read/Grep. Produce a compact but complete plain-text reference covering:\n' +
|
||||
'1. Makefile: every target name and what it actually runs (esp. `prod` worker count, `install` steps, `test`).\n' +
|
||||
'2. pyproject.toml: version, requires-python, [project.scripts], the full dependency list (note pins), optional-dependency extras.\n' +
|
||||
'3. CLI: every top-level `devplace` subcommand and its sub-subcommands (from devplacepy/cli/*.py).\n' +
|
||||
'4. Routers: every prefix mounted in devplacepy/main.py (include_router lines), including no-prefix routers.\n' +
|
||||
'5. Env vars: every var read in devplacepy/config.py with its default.\n' +
|
||||
'6. Live test count: `python -m pytest tests/ --collect-only -q | tail -1`.\n' +
|
||||
'7. Package-vs-file: for database, utils, schemas, models, docs_api, seo, config, constants, rendering, templating - state whether each is a devplacepy/<name>.py FILE or a devplacepy/<name>/ PACKAGE.\n' +
|
||||
'8. Docs registry: total DOCS_PAGES count, section names, count of admin-gated pages, and the list of docs_api API_GROUPS slugs.\n' +
|
||||
'Return this as your final text - it will be injected verbatim into every downstream audit agent, so make it accurate and self-contained.'
|
||||
|
||||
log('Phase 1: extracting ground truth from source')
|
||||
phase('Ground truth')
|
||||
const groundTruth =
|
||||
(await agent(GT_PROMPT, { agentType: 'docs-maintainer', label: 'ground-truth', phase: 'Ground truth' })) ||
|
||||
'Ground-truth extraction failed; verify every claim directly against source before editing.'
|
||||
|
||||
log('Phase 2: auditing README.md and every CLAUDE.md (root + nested) in parallel')
|
||||
phase('Root docs')
|
||||
const ROOT_FILES = [
|
||||
{ key: 'readme', file: 'README.md' },
|
||||
{ key: 'claude-root', file: 'CLAUDE.md' },
|
||||
{ key: 'nested-routers', file: 'devplacepy/routers/CLAUDE.md' },
|
||||
{ key: 'nested-routers-projects', file: 'devplacepy/routers/projects/CLAUDE.md' },
|
||||
{ key: 'nested-routers-docs', file: 'devplacepy/routers/docs/CLAUDE.md' },
|
||||
{ key: 'nested-routers-devrant', file: 'devplacepy/routers/devrant/CLAUDE.md' },
|
||||
{ key: 'nested-services', file: 'devplacepy/services/CLAUDE.md' },
|
||||
{ key: 'nested-services-audit', file: 'devplacepy/services/audit/CLAUDE.md' },
|
||||
{ key: 'nested-services-backup', file: 'devplacepy/services/backup/CLAUDE.md' },
|
||||
{ key: 'nested-services-bot', file: 'devplacepy/services/bot/CLAUDE.md' },
|
||||
{ key: 'nested-services-containers', file: 'devplacepy/services/containers/CLAUDE.md' },
|
||||
{ key: 'nested-services-dbapi', file: 'devplacepy/services/dbapi/CLAUDE.md' },
|
||||
{ key: 'nested-services-devii', file: 'devplacepy/services/devii/CLAUDE.md' },
|
||||
{ key: 'nested-services-email', file: 'devplacepy/services/email/CLAUDE.md' },
|
||||
{ key: 'nested-services-game', file: 'devplacepy/services/game/CLAUDE.md' },
|
||||
{ key: 'nested-services-gitea', file: 'devplacepy/services/gitea/CLAUDE.md' },
|
||||
{ key: 'nested-services-jobs', file: 'devplacepy/services/jobs/CLAUDE.md' },
|
||||
{ key: 'nested-services-messaging', file: 'devplacepy/services/messaging/CLAUDE.md' },
|
||||
{ key: 'nested-services-news', file: 'devplacepy/services/news/CLAUDE.md' },
|
||||
{ key: 'nested-services-openai-gateway', file: 'devplacepy/services/openai_gateway/CLAUDE.md' },
|
||||
{ key: 'nested-services-pubsub', file: 'devplacepy/services/pubsub/CLAUDE.md' },
|
||||
{ key: 'nested-services-telegram', file: 'devplacepy/services/telegram/CLAUDE.md' },
|
||||
{ key: 'nested-services-xmlrpc', file: 'devplacepy/services/xmlrpc/CLAUDE.md' },
|
||||
{ key: 'nested-database', file: 'devplacepy/database/CLAUDE.md' },
|
||||
{ key: 'nested-utils', file: 'devplacepy/utils/CLAUDE.md' },
|
||||
{ key: 'nested-static-js', file: 'devplacepy/static/js/CLAUDE.md' },
|
||||
{ key: 'nested-templates', file: 'devplacepy/templates/CLAUDE.md' },
|
||||
{ key: 'nested-tests', file: 'tests/CLAUDE.md' },
|
||||
]
|
||||
const rootReports = await parallel(
|
||||
selected(ROOT_FILES).map((root) => () =>
|
||||
agent(rootPrompt(root.file, groundTruth), {
|
||||
agentType: 'docs-maintainer',
|
||||
label: `root:${root.key}`,
|
||||
phase: 'Root docs',
|
||||
schema: REPORT_SCHEMA,
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
log('Phase 3: auditing the docs_api package and every /docs prose section in parallel')
|
||||
phase('Docs site')
|
||||
const sectionReports = await parallel(
|
||||
selected(DOCS_SECTIONS).map((section) => () =>
|
||||
agent(sectionPrompt(section, groundTruth), {
|
||||
agentType: section.agentType,
|
||||
label: `docs:${section.key}`,
|
||||
phase: 'Docs site',
|
||||
schema: REPORT_SCHEMA,
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
log('Phase 4: verifying role-gating and running the validation sweep')
|
||||
phase('Gating + validate')
|
||||
const rootFileList = ROOT_FILES.map((f) => f.file).join(', ')
|
||||
const validatePrompt =
|
||||
'The documentation audit edits are complete. Run the final VERIFICATION over the repo and FIX any residual gating issue you find (edit only routers/docs/pages.py flags or add {% if is_admin(user) %} guards in the specific template that leaks an admin link). Do the following with Bash and report structured results:\n' +
|
||||
'1. `python -c "from devplacepy.main import app"` imports clean (appImports).\n' +
|
||||
'2. `python -c "from devplacepy.docs_api import API_GROUPS; print(len(API_GROUPS))"` works (docsApiValid).\n' +
|
||||
'3. Every template under devplacepy/templates/docs/ compiles via the shared Jinja env (templatesCompile). Report any that fail.\n' +
|
||||
`4. No em-dash character or entity in any of: ${rootFileList}, or any devplacepy/templates/docs/*.html (emDashClean).\n` +
|
||||
'5. Broken internal links: every /docs/<slug>.html href in the doc templates must resolve to a real DOCS_PAGES slug OR a real /docs route (download.html/download.md); list any that do not (brokenLinks).\n' +
|
||||
'6. Role-gating: no page whose content is admin-only is left ungated (admin:true in pages.py), and no public (non-admin) page links to an admin-gated slug outside an {% if is_admin(user) %} block. Fix violations; report gatingClean + gatingFixes.\n' +
|
||||
'7. Confirm AGENTS.md does not exist at the repo root (`test -f AGENTS.md && echo EXISTS || echo ABSENT` must print ABSENT) and grep the repo for stray `AGENTS.md` references outside third-party/vendor/backup paths (.venv, *.bak, .git); report any as gatingIssues so a human can decide whether to fix them (this workflow does not own arbitrary non-doc files, e.g. .claude/ agent/command/workflow definitions).\n' +
|
||||
'Confirm each item against actual command output; do not guess.'
|
||||
const validation = await agent(validatePrompt, {
|
||||
agentType: 'docs-maintainer',
|
||||
label: 'gating+validate',
|
||||
phase: 'Gating + validate',
|
||||
schema: VALIDATE_SCHEMA,
|
||||
})
|
||||
|
||||
const roots = rootReports.filter(Boolean)
|
||||
const sections = sectionReports.filter(Boolean)
|
||||
const totalFixes =
|
||||
roots.reduce((n, r) => n + ((r && r.changes && r.changes.length) || 0), 0) +
|
||||
sections.reduce((n, r) => n + ((r && r.changes && r.changes.length) || 0), 0)
|
||||
|
||||
log(`Done. ${totalFixes} documentation fix(es) applied across ${roots.length} root file(s) and ${sections.length} /docs section(s).`)
|
||||
|
||||
return {
|
||||
workflow: 'full-docs-refactor',
|
||||
totalFixes,
|
||||
rootDocs: roots,
|
||||
docsSections: sections,
|
||||
validation,
|
||||
}
|
||||
@@ -32,7 +32,7 @@ const CHECKLIST = [
|
||||
'6. docs_api.py - endpoint() entries for the enqueue, status, and download routes.',
|
||||
'7. static/js - wire JobPoller.run(statusUrl, {onDone, onFailed, onTimeout}) on the triggering element.',
|
||||
'8. CLI (optional) - a prune/clear subcommand if artifacts accumulate.',
|
||||
'9. README.md + AGENTS.md - document the new job kind.',
|
||||
'9. README.md + devplacepy/services/jobs/CLAUDE.md - document the new job kind.',
|
||||
].join('\n')
|
||||
|
||||
const TESTS = [
|
||||
|
||||
@@ -19,6 +19,9 @@ const DIMENSIONS = [
|
||||
{ key: 'docs', agent: 'docs-maintainer' },
|
||||
{ key: 'seo', agent: 'seo-maintainer' },
|
||||
{ key: 'test', agent: 'test-maintainer' },
|
||||
{ key: 'devii', agent: 'devii-maintainer' },
|
||||
{ key: 'background', agent: 'background-maintainer' },
|
||||
{ key: 'locust', agent: 'locust-maintainer' },
|
||||
]
|
||||
|
||||
const DIFF_SCHEMA = {
|
||||
@@ -102,8 +105,8 @@ const reviewed = await pipeline(
|
||||
parallel(
|
||||
((review && review.findings) || []).map((finding) => () =>
|
||||
agent(
|
||||
`Adversarially verify a candidate "${dimension.key}" review finding. Try to REFUTE it: open the file, read the changed region and its context, and decide if it is a genuine violation introduced by this diff. Rule it out (isReal=false) if it is a contract identifier, DATA rather than prose, vendored, pre-existing and untouched by this diff, or already correct under a known exemption. When uncertain, default to isReal=false.\n\nFinding:\n- file: ${finding.file}\n- line: ${finding.line == null ? 'unspecified' : finding.line}\n- severity: ${finding.severity}\n- rule: ${finding.rule}\n- message: ${finding.message}`,
|
||||
{ agentType: dimension.agent, label: `verify:${dimension.key}`, phase: 'Verify', schema: VERDICT_SCHEMA }
|
||||
`You are an independent skeptic, not the agent that raised this finding. A "${dimension.key}"-dimension maintenance agent flagged the candidate below in this diff; your job is solely to REFUTE it from a fresh, unbiased read of the source. Open the file, read the changed region and its context, and decide if it is a genuine violation introduced by this diff. Rule it out (isReal=false) if it is a contract identifier, DATA rather than prose, vendored, pre-existing and untouched by this diff, or already correct under a known exemption. When uncertain, default to isReal=false.\n\nFinding:\n- file: ${finding.file}\n- line: ${finding.line == null ? 'unspecified' : finding.line}\n- severity: ${finding.severity}\n- rule: ${finding.rule}\n- message: ${finding.message}`,
|
||||
{ label: `verify:${dimension.key}`, phase: 'Verify', schema: VERDICT_SCHEMA }
|
||||
).then((verdict) => ({ ...finding, dimension: dimension.key, verdict }))
|
||||
)
|
||||
)
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
@@ -32,3 +32,10 @@ var/
|
||||
.coverage
|
||||
.coverage.*
|
||||
htmlcov/
|
||||
|
||||
# local environments and scratch
|
||||
.venv/
|
||||
tmp/
|
||||
*.log
|
||||
*.bak
|
||||
test.db
|
||||
|
||||
+2
-1
@@ -31,8 +31,9 @@ RUN pip install --no-cache-dir ".[bots]" \
|
||||
EXPOSE 10500
|
||||
|
||||
ENV DEVPLACE_WEB_WORKERS=2
|
||||
ENV DEVPLACE_TEMPLATE_AUTO_RELOAD=0
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=10s \
|
||||
HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=120s \
|
||||
CMD curl -f http://localhost:10500/ || exit 1
|
||||
|
||||
CMD ["sh", "-c", "DEVPLACE_STATIC_VERSION=${DEVPLACE_STATIC_VERSION:-$(date +%s)} exec uvicorn devplacepy.main:app --host 0.0.0.0 --port 10500 --workers 2 --backlog 8192 --proxy-headers --forwarded-allow-ips '*'"]
|
||||
|
||||
@@ -12,7 +12,7 @@ DEVPLACE_RATE_LIMIT ?= 1000000
|
||||
PYTHONDONTWRITEBYTECODE := 1
|
||||
export PYTHONDONTWRITEBYTECODE
|
||||
|
||||
.PHONY: install dev clean tree tree-loc zip test test-headed coverage coverage-headed coverage-html locust locust-headless
|
||||
.PHONY: install dev clean tree tree-loc zip test test-headed test-unit test-api test-e2e test-fast test-failed test-first-failure test-slowest test-cache-clean coverage coverage-headed coverage-html locust locust-headless
|
||||
|
||||
install:
|
||||
pip install -e .
|
||||
@@ -43,19 +43,31 @@ zip:
|
||||
@printf 'Wrote %s (%s files)\n' "$(notdir $(CURDIR)).zip" "$$(git ls-files | wc -l)"
|
||||
|
||||
test:
|
||||
PLAYWRIGHT_HEADLESS=1 python -m pytest tests/ -x
|
||||
PLAYWRIGHT_HEADLESS=1 python -m pytest tests/
|
||||
|
||||
test-headed:
|
||||
PLAYWRIGHT_HEADLESS=0 python -m pytest tests/ -x
|
||||
PLAYWRIGHT_HEADLESS=0 python -m pytest tests/
|
||||
|
||||
test-unit:
|
||||
python -m pytest tests/unit -x
|
||||
python -m pytest tests/unit
|
||||
|
||||
test-api:
|
||||
python -m pytest tests/api -x
|
||||
python -m pytest tests/api
|
||||
|
||||
test-e2e:
|
||||
PLAYWRIGHT_HEADLESS=1 python -m pytest tests/e2e -x
|
||||
PLAYWRIGHT_HEADLESS=1 python -m pytest tests/e2e
|
||||
|
||||
test-fast:
|
||||
python -m pytest tests/unit tests/api
|
||||
|
||||
test-failed:
|
||||
PLAYWRIGHT_HEADLESS=1 python -m pytest tests/ --last-failed --last-failed-no-failures none
|
||||
|
||||
test-first-failure:
|
||||
PLAYWRIGHT_HEADLESS=1 python -m pytest tests/ -x
|
||||
|
||||
test-slowest:
|
||||
PLAYWRIGHT_HEADLESS=1 python -m pytest tests/ --durations=40
|
||||
|
||||
coverage:
|
||||
rm -f .coverage .coverage.*
|
||||
@@ -109,8 +121,12 @@ clean:
|
||||
find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
|
||||
find . -type f -name '*.pyc' -delete
|
||||
rm -rf devplacepy.egg-info
|
||||
rm -rf .pytest_cache
|
||||
rm -rf .venv
|
||||
|
||||
test-cache-clean:
|
||||
rm -rf .pytest_cache
|
||||
|
||||
# Container Manager works out of the box: the overlay installs the docker CLI in
|
||||
# the image and mounts the host socket. DOCKER_GID is read straight from the
|
||||
# socket so the UID-1000 app can use it; the data dir is the project's own data/
|
||||
|
||||
@@ -19,9 +19,9 @@ Open `http://localhost:10500`.
|
||||
|
||||
| Layer | Technology |
|
||||
|-------|-----------|
|
||||
| Backend | Python 3.13+, FastAPI, Uvicorn (multi-worker in production) |
|
||||
| Backend | Python 3.12+, FastAPI, Uvicorn (multi-worker in production) |
|
||||
| Templates | Jinja2 (server-side rendered) |
|
||||
| Frontend | Pure ES6 JavaScript, one class per file |
|
||||
| Frontend | Pure ES6 JavaScript, one class per file. Per-tab scroll restoration (`ScrollMemory`): returning to a listing via browser back, reload, or a back/breadcrumb link reliably lands at the previous scroll position on every browser and device; fresh navigations always start at the top |
|
||||
| Database | SQLite via `dataset` (auto-sync schema, `uid` PKs, WAL mode, 30s busy timeout) |
|
||||
| Auth | Session cookie, API key (`X-API-KEY`/Bearer), or HTTP Basic; PBKDF2-SHA256 via passlib |
|
||||
| Avatars | Multiavatar (local SVG generation, no external API, <5ms). Seeded from the username by default; a per-user `avatar_seed` lets the owner or an admin regenerate a fresh random avatar from the profile page (`POST /profile/{username}/regenerate-avatar`). Regeneration is irreversible - the previous avatar cannot be recovered. |
|
||||
@@ -35,10 +35,10 @@ Open `http://localhost:10500`.
|
||||
devplacepy/
|
||||
main.py # FastAPI app, router registration
|
||||
config.py # Settings from env vars + .env
|
||||
database.py # dataset connection, index creation
|
||||
database/ # dataset connection, index creation (package)
|
||||
templating.py # Shared Jinja2 environment + globals
|
||||
avatar.py # Multiavatar generation, URL builder
|
||||
utils.py # Password hashing, session mgmt, time_ago, notification hook
|
||||
utils/ # Password hashing, session mgmt, time_ago, notification hook (package)
|
||||
models.py # Pydantic schemas
|
||||
push.py # Web push crypto, VAPID keys, encrypt/send/register
|
||||
routers/ # One file per domain (auth, feed, posts, push, ...)
|
||||
@@ -59,20 +59,21 @@ devplacepy/
|
||||
| `/posts` | Post detail, creation |
|
||||
| `/gists` | Code gist listing, detail, creation, and editing; left panel offers language filtering and free-text `search` (title, description, and author username), public read |
|
||||
| `/comments` | Comment creation, owner editing (`POST /comments/edit/{comment_uid}`), deletion |
|
||||
| `/projects` | Project listing (left panel offers type filtering and free-text `search` over title, description, and author username), creation, owner editing (`POST /projects/edit/{slug}`), and per-project visibility toggles: `POST /projects/{slug}/private` (owner-only visibility) and `POST /projects/{slug}/readonly` (immutable files). A project hidden by a member stays visible to administrators, but a project hidden by an administrator is visible only to that owner administrator - other administrators cannot see it, its files, or its containers (web UI and REST API alike) |
|
||||
| `/projects` | Project listing (left panel offers type filtering and free-text `search` over title, description, and author username), creation, owner editing (`POST /projects/edit/{slug}`), and per-project visibility toggles: `POST /projects/{slug}/private` (owner-only visibility) and `POST /projects/{slug}/readonly` (immutable files). A project hidden by a member stays visible to administrators, but a project hidden by an administrator is visible only to that owner administrator - other administrators cannot see it, its files, or its containers (web UI and REST API alike). The primary administrator (the first Admin account) is the single exception and retains full visibility |
|
||||
| `/projects/{slug}/files` | Per-project filesystem: directory and file CRUD, upload, inline editing, and line-range operations (`lines` read, `replace-lines`, `insert-lines`, `delete-lines`, `append`) for surgical edits to large text files (public read, owner write; all writes refused while the project is read-only) |
|
||||
| `/zips` | Zip job status (`/zips/{uid}`) and archive download (`/zips/{uid}/download`); archives are queued via `/projects/{slug}/zip` and `/projects/{slug}/files/zip` |
|
||||
| `/forks` | Fork job status (`/forks/{uid}`); forks are queued via `/projects/{slug}/fork`. Any signed-in user can fork a project they can view into a new project they own; once the job finishes the response carries the new project URL |
|
||||
| `/tools` | Public developer tools. `/tools/seo` is **SEO Diagnostics**: audit any URL or sitemap and stream live progress over a websocket. Queue with `POST /tools/seo/run`, poll `GET /tools/seo/{uid}`, read the full report at `GET /tools/seo/{uid}/report`. `/tools/deepsearch` is **DeepSearch**: a multi-agent deep web researcher that crawls and indexes sources, synthesises a cited report with confidence and gap analysis, and lets you chat over the gathered evidence. Queue with `POST /tools/deepsearch/run`, poll `GET /tools/deepsearch/{uid}`, read the report at `GET /tools/deepsearch/{uid}/session`, export at `/export.{md,json,pdf}` |
|
||||
| `/projects/{slug}/containers` | Admin per-project container manager: Dockerfile CRUD with immutable versions, async image builds, and container instance creation. Reachable from the project page via the admin-only **Containers** button |
|
||||
| `/admin/containers` | Admin **Containers** manager: list, create, edit, and control every container instance across all projects (scoped by project visibility - instances attached to another administrator's hidden project are excluded). The list (`/admin/containers`) has inline start/stop/restart/terminal/edit/delete on each row and a create form (pick a project, optionally a run-as user, a boot language with a source editor, restart policy, start-on-boot, plus env/ports/limits/ingress). Each instance has a detail page (`/admin/containers/{uid}`) with lifecycle controls, live logs and metrics, an interactive terminal, schedules, ingress, workspace sync, and a status history, and an edit page (`/admin/containers/{uid}/edit`) |
|
||||
| `/tools` | Public developer tools. `/tools/seo` is **SEO Diagnostics**: audit any URL or sitemap and stream live progress over a websocket. Queue with `POST /tools/seo/run`, poll `GET /tools/seo/{uid}`, read the full report at `GET /tools/seo/{uid}/report`. `/tools/deepsearch` is **DeepSearch**: a multi-agent deep web researcher that crawls and indexes sources, synthesises a cited report with confidence scoring, and lets you chat over the gathered evidence. Queue with `POST /tools/deepsearch/run`, poll `GET /tools/deepsearch/{uid}`, read the report at `GET /tools/deepsearch/{uid}/session`, export at `/export.{md,json,pdf}`. `/tools/isslop` is the **AI Usage Analyzer**: classify a git repository or website as AI slop, sophisticated AI-assisted work or genuine human work. Queue with `POST /tools/isslop/run`, poll `GET /tools/isslop/{uid}` or the event trail at `GET /tools/isslop/{uid}/events`, read the report at `GET /tools/isslop/{uid}/report` (`.md` to download) and embed the SVG authenticity badge from `GET /tools/isslop/{uid}/badge.svg` |
|
||||
| `/projects/{slug}/containers` | Admin per-project container manager: create and control container instances, all running the shared prebuilt `ppy` image (there is no in-app image building). Reachable from the project page via the admin-only **Containers** button |
|
||||
| `/admin/containers` | Admin **Containers** manager: list, create, edit, and control container instances across projects, under strict per-user isolation: the primary administrator sees and manages every instance; every other administrator sees instances on public projects plus their own (instances attached to another user's private project are excluded entirely) and manages only the instances they own (created by them or attached to their own project) - all other rows are view-only. The list (`/admin/containers`) has inline start/stop/restart/terminal/edit/delete on each row and a create form (pick a project, optionally a run-as user, a boot language with a source editor, restart policy, start-on-boot, plus env/ports/limits/ingress). Each instance has a detail page (`/admin/containers/{uid}`) with lifecycle controls, live logs and metrics, an interactive terminal, schedules, ingress, workspace sync, and a status history, and an edit page (`/admin/containers/{uid}/edit`) |
|
||||
| `/p/{slug}` | Public ingress proxy (HTTP + WebSocket) to a running container instance's published port, opt-in per instance via `ingress_slug` |
|
||||
| `/profile` | Profile view, editing, and a public **Media** tab (`?tab=media`) showing every attachment a user uploaded, newest first |
|
||||
| `/profile` | Profile view, editing, a public **Media** tab (`?tab=media`) showing every attachment a user uploaded newest first, and a live **online / last-seen** presence indicator |
|
||||
| `/media` | Per-attachment soft delete and restore: `POST /media/{uid}/delete` (owner or admin), `POST /media/{uid}/restore` (admin) |
|
||||
| `/uploads` | File upload endpoints: `POST /uploads/upload` (multipart), `POST /uploads/upload-url` (from URL); served at `/static/uploads/` |
|
||||
| `/uploads` | Attachment management (full lifecycle for the signed-in user, same files that appear on posts and other content): `POST /uploads/upload` (multipart) and `POST /uploads/upload-url` (from URL) create; `GET /uploads` lists your own attachments (paginated, newest first, optional `linked` filter); `GET /uploads/{uid}` returns one; `PATCH /uploads/{uid}` renames its display filename (the file extension is always preserved); `DELETE /uploads/delete/{uid}` removes one. Reading and modifying another user's attachment is owner-or-admin; files are served at `/static/uploads/` |
|
||||
| `/admin/trash` | Admin **Trash**: review, restore, and permanently purge soft-deleted content (posts, comments, gists, projects, news, project files, attachments) across the platform |
|
||||
| `/admin/devii-tasks` | Admin **Devii tasks**: every scheduled task across all owners with its schedule, run count, expiry and failure streak, plus per-task disable and delete |
|
||||
| `/notifications` | Notification list, mark read, live unread counts (`/notifications/counts`) |
|
||||
| `/messages` | Real-time direct messaging over WebSocket (`/messages/ws`): live bidirectional delivery, optimistic send, typing indicators, read receipts, and online/last-seen presence. Messages render through the shared content pipeline (emoji shortcodes, image and YouTube embeds, autolink, sanitization). AI content correction and the AI modifier apply to direct messages, so typing an inline `@ai <instruction>` in a message executes it and the resolved result appears live in the chat for both participants. The `POST /messages/send` form remains as a no-JavaScript fallback |
|
||||
| `/messages` | Real-time direct messaging over WebSocket (`/messages/ws`): live bidirectional delivery, optimistic send, typing indicators, read receipts, and online/last-seen presence. Messages render through the shared content pipeline (emoji shortcodes, image, video and audio embeds, autolink, sanitization). AI content correction and the AI modifier apply to direct messages, so typing an inline `@ai <instruction>` in a message executes it and the resolved result appears live in the chat for both participants (the `message` WS frame carries an additive `ai_processed` flag when a pending correction/modification was applied). An opened conversation loads its 500 most recent messages; older history is retained in the database. `GET /messages/conversations` returns the conversation list as JSON for a live client refresh; the `POST /messages/send` form remains as a no-JavaScript fallback and now accepts an attachment-only, empty-content message. `POST /messages/ws-ticket` exchanges a session/API-key auth to a short-lived (30s), single-use ticket a browser WebSocket can carry as `?ticket=...` on the handshake, since a native `WebSocket` cannot set an `Authorization`/`X-API-KEY` header - this is what makes off-session embedding of the chat possible. CLI: `devplace messaging prune-tickets` removes expired tickets |
|
||||
| `/votes` | Upvote/downvote on posts, comments, projects |
|
||||
| `/reactions` | Emoji reactions on posts, comments, gists, projects |
|
||||
| `/bookmarks` | Save/unsave content; `/bookmarks/saved` personal list |
|
||||
@@ -82,6 +83,7 @@ devplacepy/
|
||||
| `/mute` | Mute/unmute a user: stops them creating notifications for you while their content stays visible |
|
||||
| `/leaderboard` | Contributor ranking by total stars earned |
|
||||
| `/game` | **Code Farm** cooperative idle game (member-only): plant projects that build over real time, harvest coins and XP, upgrade CI, buy plots, and water friends' builds at `/game/farm/{username}`. Live over pub/sub; every endpoint negotiates JSON |
|
||||
| `/quizzes` | **Quizzes**: author quizzes, play them, and climb the cross-quiz scoreboard. Three-column hub with filters (`all`/`todo`/`done`/`mine`/`drafts`), search, per-viewer state badges, and the scoreboard rail; `/quizzes/{slug}` detail, `/quizzes/{slug}/edit` builder, `/quizzes/{slug}/attempts/{uid}` player, `/quizzes/scoreboard` JSON. Publishing is permanent. Every endpoint negotiates JSON |
|
||||
| `/avatar` | Multiavatar proxy with in-memory cache |
|
||||
| `/issues` | Issue tracker backed by Gitea: list/detail/comment/status, AI-enhanced filing, an admin planning report over a selectable set of open tickets (each ticket's full text reproduced verbatim so the document hands straight to a coding agent), and file attachments on open issues and comments (mirrored to the Gitea tracker) |
|
||||
| `/admin/services` | Background service management (start/stop, config, status, logs) |
|
||||
@@ -99,7 +101,7 @@ Member progression is driven by activity and peer recognition.
|
||||
|
||||
- **Stars** are the net vote score (`upvotes - downvotes`) on a post, project, or gist. A member's total stars is the sum across all their content and is the basis for ranking.
|
||||
- **XP and levels.** Members earn XP for contributing: posting (10), commenting (2), publishing a project (15) or gist (5), receiving an upvote (5), and gaining a follower (5). Each level requires 100 XP (`level = 1 + xp // 100`). The profile shows the current level and progress to the next.
|
||||
- **Badges** are awarded once and never revoked, across several themed groups (First steps, Explorer, Engagement, Content, Community, Reputation, Dedication, Levels). They cover three kinds of achievement: **content and reputation milestones** (10/50/100 posts, 25/100/500 stars, 10/50/100 followers, comment and project and gist counts, following 10 people, 7/30/100-day activity streaks, reaching levels 5/10/25/50/100); **first-time feature use** (your first comment, project, gist, fork, archive download, SEO audit, DeepSearch, container, direct message, bookmark, reaction, star given, follow, upload, project file, issue, poll vote, profile customization, and first conversation with Devii); and **usage tiers** for several of those features (for example reading 1/5/15 documentation pages, or giving 50/250 stars). Each profile has a collapsible **Achievements** showcase that lists every badge grouped by theme, with earned ones highlighted and locked ones shown with their unlock condition, so there is always a next prize to chase.
|
||||
- **Badges** are awarded once and never revoked, across several themed groups (First steps, Explorer, Engagement, Content, Community, Reputation, Dedication, Levels). They cover three kinds of achievement: **content and reputation milestones** (10/50/100 posts, 25/100/500 stars, 10/50/100 followers, comment and project and gist counts, following 10 people, 7/30/100-day activity streaks, reaching levels 5/10/25/50/100); **first-time feature use** (your first comment, project, gist, fork, archive download, SEO audit, DeepSearch, AI usage analysis, container, direct message, bookmark, reaction, star given, follow, upload, project file, issue, poll vote, profile customization, and first conversation with Devii); and **usage tiers** for several of those features (for example reading 1/5/15 documentation pages, or giving 50/250 stars). Each profile has a collapsible **Achievements** showcase that lists every badge grouped by theme, with earned ones highlighted and locked ones shown with their unlock condition, so there is always a next prize to chase.
|
||||
- **Leaderboard** (`/leaderboard`) ranks the top 50 members by total stars (single page, no pagination); a member's own rank is shown on their profile.
|
||||
- **Contribution heatmap and streaks.** Each profile shows a 12-month activity heatmap and the current/longest daily streak, derived from post/comment/gist/project timestamps (no extra storage).
|
||||
- **Social graph listings.** Each profile has Followers and Following tabs that paginate the follow graph (25 per page) and show a follow/unfollow control for each person. The same data is available as JSON at `GET /profile/{username}/followers` and `GET /profile/{username}/following`.
|
||||
@@ -109,8 +111,51 @@ Member progression is driven by activity and peer recognition.
|
||||
|
||||
- **AI modifier.** Enabled by default and applied synchronously by default. It works like AI content correction, except it runs **only** where the prose you author contains an inline `@ai <instruction>` directive: the configured prompt tells the model to execute that instruction and replace the marked part, removing the `@ai` marker. Text with no `@ai ...` directive is left exactly as written. It is **context-aware**: the model is given a grounding summary of who is asking (your username, role, level, stars, post count, rank, followers, and bio), the current date, and where the directive sits - the post a comment replies to, the conversation a direct message belongs to, the gist's language and code, and so on - so directives like `@ai answer the question above`, `@ai write my bio from my stats`, or `@ai reply to this` work. It uses your own API key for per-user attribution, is fail-soft (the original is kept on any error), and applies across the web UI, the REST and devRant APIs, and Devii, on the same prose fields as correction (posts, projects, gists, comments, direct messages, and your bio). Code and source files are never touched. In direct messages it runs live: typing `@ai <instruction>` in a message executes it and the resolved result appears in the chat for both participants without a reload. You can switch the apply mode to background or disable it on your profile or via the Devii `ai_modifier_set` tool; the settings are saved at `POST /profile/{username}/ai-modifier`. The default instruction is "Execute what is behind `@ai` (the prompt) and replace that part including `@ai`". Successful modifications accumulate per-user running totals - modifications, token counts, cost, and timing/performance (average latency, average speed in tokens per second, and total processing time) - shown on the profile page; token, call, and performance figures are visible to the member, while the dollar figures (total and average cost) are shown to administrators only.
|
||||
|
||||
- **Devii interactive widgets.** Administrators set the site default on the Devii service (`devii_interactions_default`, default on). Guests always use that default. Signed-in members inherit it until they override it on their profile or via the Devii `interactions_set` tool (`POST /profile/{username}/interactions`; owner or admin). When enabled, Devii may present decisions with channel-aware controls (`ui_prompt`); when disabled, it falls back to plain numbered menus.
|
||||
|
||||
Every AI gateway response (`/openai/v1/*`) also returns per-call `X-Gateway-*` headers with the full token breakdown and the dollar cost of that call, so any client can read its own usage.
|
||||
|
||||
## Quizzes
|
||||
|
||||
**Quizzes** (`/quizzes`) let any signed-in member write a quiz and every member play it. The hub is
|
||||
one page with three columns: filters and search on the left, the quiz list in the middle showing
|
||||
what you still have to do and what you already completed with your score, and the cross-quiz
|
||||
scoreboard on the right. Guests read published quizzes and see the board; they cannot play.
|
||||
|
||||
- **Eight question kinds.** Single choice, multiple choice, true/false, free text, fill in the
|
||||
blanks, numeric, ordering, and matching. Seven are graded deterministically, several with partial
|
||||
credit. Ordering and matching use plain selectors and keyboard controls, never a drag-only
|
||||
interaction, so they work with a keyboard and a screen reader.
|
||||
- **AI-graded free text.** A free-text answer is reviewed by the platform's own AI against the
|
||||
author's reference answer and grading criteria, billed to the answering member's own API key. The
|
||||
score is re-clamped on the server and the correct/incorrect verdict is derived from the clamped
|
||||
score, so a reviewer can never mark an answer correct while scoring it zero. When the reviewer is
|
||||
unavailable the answer is still graded, by a deterministic keyword comparison, and is visibly
|
||||
stamped as such - grading never silently becomes a zero.
|
||||
- **Publishing is permanent.** A draft is fully editable; publishing freezes the quiz, its
|
||||
questions and its options forever. There is no unpublish and no post-publish edit, which is what
|
||||
makes two members' scores on the same quiz comparable. The builder shows a live pre-publish
|
||||
checklist and keeps the Publish button disabled until it is empty, and the action is confirmation
|
||||
gated on both the web UI and in Devii.
|
||||
- **One attempt at a time.** Starting a quiz creates an attempt that lives on the server, so a
|
||||
refresh, a second tab and a different device all resume the same one. Each question can be
|
||||
answered exactly once. A time limit is a deadline stored on the attempt, evaluated when someone
|
||||
looks at it - nothing runs in the background.
|
||||
- **Settings.** Shuffle the questions, shuffle the options, reveal the correct answer after each
|
||||
question, allow reviewing every answer on the results screen, set a time limit, set a pass mark.
|
||||
- **An honest scoreboard.** Your **best** attempt per quiz counts, never the sum of your attempts,
|
||||
so replaying a quiz can raise your contribution up to your personal best and never beyond it.
|
||||
Quizzes you wrote yourself count like any other. Each quiz also has its own leaderboard.
|
||||
- **Full automation.** Devii creates a complete quiz from one JSON document, publishes it, plays it
|
||||
end to end and reads the result, all through the same public API - and the hub's *Create quiz
|
||||
with Devii* button opens the assistant with that request already typed in (it never sends it for
|
||||
you). The whole flow works without JavaScript too: every question is a real form.
|
||||
- **Engagement.** Quizzes carry comments, stars, bookmarks and reactions like any other content,
|
||||
and appear in the sitemap.
|
||||
|
||||
Retention: completed attempts are permanent; abandoned and expired ones are garbage-collected by
|
||||
`devplace quiz prune`.
|
||||
|
||||
## Code Farm
|
||||
|
||||
The **Code Farm** (`/game`) is a cooperative idle game in the spirit of Farmville, themed for developers. Each member owns a farm of plots and plays asynchronously - nothing has to happen in real time.
|
||||
@@ -123,29 +168,36 @@ The **Code Farm** (`/game`) is a cooperative idle game in the spirit of Farmvill
|
||||
- **Daily bonus.** Claim a coin bonus once per day; consecutive days build a streak that grows the reward (capped at seven days).
|
||||
- **Daily quests.** Three quests rotate every day (plant, harvest, water, or earn goals), tracked automatically as you play; claim each one for coins and XP when complete.
|
||||
- **Perks.** Spend coins on four permanent upgrades - Optimizer (+harvest coins), Build Cache (+build speed), Bulk Licenses (-planting cost), and Mentorship (+harvest XP) - each levelling up with escalating cost.
|
||||
- **Refactor (prestige).** At level 10 you can refactor: the farm resets (coins, level, CI, extra plots, perks) in exchange for a permanent +25% coin bonus that stacks with every refactor. Each refactor also awards **Stars** (scaled by the level and prestige you reached) to spend in the Legacy shop.
|
||||
- **Stars and Legacy (endgame).** Stars buy permanent **Legacy** upgrades that survive every refactor, unlike perks: **CI Bot** (auto-collects ready builds when you open your farm), **Tech Debt Payoff** (+coins, stacks with prestige), **Bare-Metal** (+base build speed), **Monorepo** (+starting plots after each refactor), and **Branch Protection** (longer steal grace and a smaller steal cut). This is the infinite progression for maxed farms, and CI Bot makes the game playable hands-off.
|
||||
- **Refactor (prestige).** At level 10 you can refactor: the farm resets (coins, level, CI, extra plots, perks) in exchange for a permanent +25% coin bonus that stacks with every refactor. Each refactor also awards **Stars** (scaled by the level and prestige you reached) to spend in the Legacy shop. Refactoring costs a **dynamic coin fee** that grows with your prestige and current wealth, so you must farm well past level 10 to afford each one - and 10% of what remains after the fee (more with the Golden Parachute Legacy upgrade, up to 60%) carries over into the new run.
|
||||
- **Community treasury and weekly grant.** Every refactor fee flows into a shared treasury. Active low-balance, low-prestige farms (at least five harvests this week, under 10,000 coins, at most prestige 5) can claim a grant from it once per week - the balance is divided between everyone currently eligible rather than paid first-come-first-served, capped at 2,500 coins and suppressed below 250. A direct wealth transfer from the farms refactoring at the top to the farms building at the bottom.
|
||||
- **Stars and Legacy (endgame).** Stars buy permanent **Legacy** upgrades that survive every refactor, unlike perks: **CI Bot** (auto-collects ready builds when you open your farm), **Tech Debt Payoff** (+coins, stacks with prestige), **Bare-Metal** (+base build speed), **Monorepo** (+starting plots after each refactor), **Branch Protection** (longer steal grace and a smaller steal cut), and **Golden Parachute** (a larger refactor coin carry-over). This is the infinite progression for maxed farms, and CI Bot makes the game playable hands-off.
|
||||
- **Golden builds.** A small share of plantings come out golden (marked with a sparkle); harvesting a golden build pays several times the coins.
|
||||
- **Visit and water friends.** Open another member's farm at `/game/farm/{username}` and water their growing builds to speed them up - you earn coins for helping, and the owner sees the help live. This is the social loop that makes the game cooperative.
|
||||
- **Steal a harvest.** A ready build on someone else's farm can be stolen once a protection window passes - the owner gets that grace period (longer if they invested in Branch Protection) to harvest it first. A successful steal pays the thief half the build's coin value (the owner loses the whole build) and earns the **Cat Burglar** badge; the victim gets the **Robbed** badge and a live notification that someone raided their farm (the thief is never named). You can raid any given neighbour only **once per hour**, so no one has to babysit their farm against constant theft. Stealing pays coins only, so the leaderboard stays earned by real farming. This is the competitive counterpart to watering.
|
||||
- **Leaderboard.** Top farmers are ranked by a composite achievement score that weighs every factor the game tracks - refactor (prestige) count, XP, lifetime harvests, current coins, CI tier, plots bought, perk levels, and login streak - so total accomplishment decides position rather than just the current post-refactor cycle. The score is shown alongside your own farm next to each player's level.
|
||||
- **Visit and water friends.** Open another member's farm at `/game/farm/{username}` and water their growing builds to speed them up - you earn coins for helping - scaled by your own prestige and Tech Debt Payoff multiplier, so the cooperative loop stays worth doing at every stage - and the owner sees the help live. This is the social loop that makes the game cooperative.
|
||||
- **Steal a harvest.** A ready build on someone else's farm can be stolen once a protection window passes - the owner gets that grace period (longer if they invested in Branch Protection or a Defense building) to harvest it first. A successful raid pays the thief a share of the build's coin value and the **owner keeps and can still harvest the remainder** - a raid redistributes value rather than destroying it. The thief earns the **Cat Burglar** badge; the victim gets the **Robbed** badge and a notification naming the raider, the crop, and the exact amount taken. You can raid any given neighbour only **once per hour**, and any farm can absorb at most **3 raids per day**, so an inactive player can never be stripped by an unlimited queue of raiders. Raiding a farm with 10x your own coins grants a 24-hour **Underdog** boost (+25% coin gain) and the **David vs Goliath** badge. Stealing pays coins only, so the harvest-based leaderboards stay earned by real farming.
|
||||
- **Market Saturation.** The last 48 hours of league-wide harvests of each crop are tracked and converted into grow-time-normalized supply, so fast and slow crops saturate on the same real-terms scale; supply is measured per active farm so a busy server is not permanently floored by a few heavy players; when a crop is over-farmed its payout drops in steps (down to 40%), while the four starter crops pay a boost (up to +15%) whenever the high-tier market is saturated and they are not - a crop is either penalized or boosted, never both. Printing one crop nonstop is throttled, planting what the market is short on is rewarded. The shop shows a live "Saturated" / "Boosted" label per crop.
|
||||
- **Infrastructure.** Permanent, expensive, prestige-gated buildings and coin sinks: **Private Registry** (faster Rust/Compiler/Kernel builds), **Canary Deployments** (a chance to double or only refund a harvest), and **Observability Suite** (caps what any raider can take from you at 20% of a build's value).
|
||||
- **Defense.** An upgradeable building that multiplicatively reduces raid losses and adds steal grace - but costs an ongoing daily coin upkeep (proportional to your coin balance, so it scales with wealth). If you cannot pay, only what you can afford is taken and the tier decays by one level - your balance is never emptied - and you are notified. You can also step down a tier deliberately to leave the commitment.
|
||||
- **Cosmetics.** Purely cosmetic titles and plot skins, bought with coins - zero gameplay effect, pure status. An equipped title shows next to your name on the leaderboard.
|
||||
- **Mastery (endgame beyond prestige).** From prestige 50 onward, every 5 more prestige earns a permanent Mastery point (spendable, and the milestone itself never re-locks). Mastery upgrades open new gameplay instead of bigger numbers: **Continuous Delivery** (auto-replant after harvest), **Farm Analytics** (lifetime stats on your HUD), and **Legacy Contracts** (a weekly long-term contract slot paying Stars and a temporary coin boost). Reaching Mastery also unlocks three new high-tier crop families (Distributed System, ML Pipeline, Security Fortress - the last one immune to raids).
|
||||
- **Leaderboards.** Several boards, selectable from the game page: **Overall score** (a composite weighing refactor/prestige count, XP, lifetime harvests, a capped coin contribution, CI tier, plots, perks, and streak - the cap keeps it a measure of what you built rather than what you hoard), **Prestige**, **Harvests this week**, **Raid efficiency** (average coins per successful raid), **Fastest to Kernel** (time since your last refactor), **Fair play** (rewards recent activity over hoarding), and (when running) the current **Era** board.
|
||||
- **Eras (admin-managed seasons).** Administrators can start an Era at `/admin/game`: every farm's *visible* Era coins/harvests counters reset to zero, but real coin balances, prestige, Stars, Legacy, and Mastery are never touched. Ending an Era ranks farms by Era score (which gives prestige only partial weight, so veterans keep an edge without it being insurmountable), awards Stars to the top 10, and permanently records the results.
|
||||
|
||||
The farm refreshes live over the pub/sub bus (a watered build appears on the owner's screen at once) and every plot countdown ticks client-side. Every endpoint also answers JSON, and Devii can play the game on the member's behalf via the `game_*` tools (`game_state`, `game_plant`, `game_harvest`, `game_buy_plot`, `game_upgrade_ci`, `game_water`, `game_steal`, `game_view_farm`, `game_leaderboard`, `game_upgrade_perk`, `game_upgrade_legacy`, `game_prestige`). See the API reference group **Code Farm**.
|
||||
The farm refreshes live over the pub/sub bus (a watered build appears on the owner's screen at once) and every plot countdown ticks client-side. Every endpoint also answers JSON, and Devii can play the game on the member's behalf via the `game_*` tools (`game_state`, `game_plant`, `game_harvest`, `game_buy_plot`, `game_upgrade_ci`, `game_fertilize`, `game_daily`, `game_claim_quest`, `game_water`, `game_steal`, `game_view_farm`, `game_leaderboard`, `game_upgrade_perk`, `game_upgrade_legacy`, `game_prestige`, `game_claim_grant`, `game_upgrade_mastery`, `game_buy_infrastructure`, `game_upgrade_defense`, `game_buy_cosmetic`, `game_equip_cosmetic`, `game_downgrade_defense`). See the API reference group **Code Farm** and the full player guide at `/docs/code-farm.html`.
|
||||
|
||||
## Engagement
|
||||
|
||||
- **Emoji reactions** - a fixed palette of reactions on posts, comments, gists, and projects, separate from voting and carrying no ranking weight.
|
||||
- **Emoji reactions** - react with **any** emoji on posts, comments, gists, and projects, separate from voting and carrying no ranking weight. A short quick-pick palette covers the common reactions, and a `+` button next to it opens the full searchable emoji picker (every standard emoji, including skin tones), so a reaction is never limited to a preset list. Emoji already used on an item are shown as counted chips beside the palette.
|
||||
- **Emoji shortcodes** - typing a `:name:` shortcode in any content (posts, comments, titles, project and gist descriptions, news, and direct messages) renders the matching emoji, using the full GitHub/Discord standard set (for example `:rocket:` becomes a rocket). Server-rendered and live content share one shortcode list; unknown names and shortcodes inside code are left untouched. Documented at `/docs/emoji-shortcodes`. This is distinct from the visual emoji-picker button in the composer, which inserts the literal emoji character.
|
||||
- **Polls** - a post can carry a poll (question plus up to six options); results appear as live bars once the viewer votes, one vote per member. A poll can be attached when the post is created or added later by editing a post that has none.
|
||||
- **Bookmarks** - save posts, gists, projects, and news to a personal list at `/bookmarks/saved`.
|
||||
- **Private projects** - an owner can mark a project private so it is visible only to them (and administrators) and excluded from listings, profiles, search, the sitemap, and zip access. Set at creation or toggled later from the project page.
|
||||
- **Read-only projects** - an owner can mark a project read-only, making its entire virtual filesystem immutable: every write, edit, line-edit, move, delete, and upload is refused from all paths (the web UI, the HTTP API, the Devii agent, and container workspace sync) until read-only is turned off. Devii may toggle read-only only after the user explicitly confirms.
|
||||
|
||||
XP awards are wired at the existing content-creation, vote, and follow hook points in the routers and centralized in `award_xp()` / `check_milestone_badges()` (`devplacepy/utils.py`). Existing accounts have their XP and levels backfilled once from prior activity at startup (`init_db()`).
|
||||
XP awards are wired at the existing content-creation, vote, and follow hook points in the routers and centralized in `award_xp()` / `check_milestone_badges()` (`devplacepy/utils/`). Existing accounts have their XP and levels backfilled once from prior activity at startup (`init_db()`).
|
||||
|
||||
## Vibe coding (Alpha, admin only)
|
||||
|
||||
Build software by talking to an AI agent instead of typing every line. Create a project for storage, attach a container to it (the shared `ppy` image, your files mounted at `/app`), start it, and open a terminal. The whole flow is drivable conversationally through Devii. Inside every container three agents ship preinstalled and run on **your own API key**, so all AI usage is metered to your account: **DevPlace Code (`dpc`)**, a coding agent in the same class as Claude Code; **`botje.py`**, a plug-and-play DevPlace bot you can copy and customise; and **`pagent`**, a minimal zero-dependency agent. Each container is launched with `PRAVDA_BASE_URL`, `PRAVDA_OPENAI_URL`, `PRAVDA_API_KEY`, `PRAVDA_USER_UID`, `PRAVDA_CONTAINER_NAME`, `PRAVDA_CONTAINER_UID`, and `PRAVDA_INGRESS_URL` already set. Publish a container port to a public URL at `/p/<slug>` by setting an `ingress_slug` and `ingress_port` (ask Devii to do it at create time). The feature is in **Alpha** and currently limited to administrators; the full walkthrough, including a tutorial that vibes a web app and puts it online, is at `/docs/getting-started-vibing.html`.
|
||||
Build software by talking to an AI agent instead of typing every line. Create a project for storage, attach a container to it (the shared `ppy` image, your files mounted at `/app`), start it, and open a terminal. The whole flow is drivable conversationally through Devii. Inside every container three agents ship preinstalled and run on **your own API key**, so all AI usage is metered to your account: **DevPlace Code (`dpc`)**, a coding agent in the same class as Claude Code; **`botje.py`**, a plug-and-play DevPlace bot you can copy and customise; and **`pagent`**, a minimal zero-dependency agent. Each container is launched with `DEVPLACE_BASE_URL`, `DEVPLACE_OPENAI_URL`, `DEVPLACE_API_KEY`, `DEVPLACE_USER_UID`, `DEVPLACE_CONTAINER_NAME`, `DEVPLACE_CONTAINER_UID`, and `DEVPLACE_INGRESS_URL` already set. Publish a container port to a public URL at `/p/<slug>` by setting an `ingress_slug` and `ingress_port` (ask Devii to do it at create time). The feature is in **Alpha** and currently limited to administrators; the full walkthrough, including a tutorial that vibes a web app and puts it online, is at `/docs/getting-started-vibing.html`.
|
||||
|
||||
## Admin: Audit Log
|
||||
|
||||
@@ -166,6 +218,9 @@ The log is **administrator-only**. `/admin/audit-log` is a paginated, filterable
|
||||
| `DEVPLACE_XMLRPC_BIND` | `127.0.0.1` | Bind address for the XML-RPC bridge (loopback; the app and nginx are the intended front doors) |
|
||||
| `DEVPLACE_STATIC_VERSION` | server boot unix timestamp | Cache-busting version stamped into every static asset URL (`/static/v<version>/...`). Set it at launch so multiple workers agree (the `prod` target and Docker image do this); leave unset in dev to refresh on each reload. See [Static asset caching](#static-asset-caching) |
|
||||
| `DEEPSEEK_API_KEY` / `OPENROUTER_API_KEY` | unset | Upstream provider keys; migrated into the gateway settings on first boot |
|
||||
| `DEVPLACE_PRESENCE_TIMEOUT_SECONDS` | `60` | Online-presence window: a user counts as online for this many seconds after their last activity. `last_seen` is refreshed by a throttled in-place update at most once per half this interval per worker (no per-load inserts, no data growth) |
|
||||
| `DEVPLACE_PRESENCE_ONLINE_LIMIT` | `30` | Maximum avatars shown in the feed's live "Online now" panel (ordered alphabetically by username) |
|
||||
| `DEVPLACE_PRESENCE_ONLINE_MARGIN_SECONDS` | `20` | Grace margin before an online user drops to offline (hysteresis): online at the timeout, offline only after timeout + this. Prevents online/offline flicker for users hovering at the boundary |
|
||||
|
||||
### Runtime settings
|
||||
|
||||
@@ -192,7 +247,7 @@ Numeric values are floored to safe minimums so an invalid entry cannot lock out
|
||||
|
||||
The website uses a `session` cookie. For automation, every page and action also
|
||||
accepts three header-based methods, resolved centrally in `get_current_user`
|
||||
(`utils.py`) so they work everywhere with no per-route changes:
|
||||
(`utils/`) so they work everywhere with no per-route changes:
|
||||
|
||||
- **API key** - `X-API-KEY: <key>`
|
||||
- **Bearer** - `Authorization: Bearer <key>`
|
||||
@@ -218,7 +273,7 @@ Every endpoint that renders a page or returns a redirect also speaks JSON, so an
|
||||
website does is automatable from the same URLs. A request gets JSON when it sends
|
||||
`Accept: application/json` or `Content-Type: application/json`; a normal browser navigation
|
||||
(`Accept: text/html`) always gets HTML, so existing behaviour is unchanged (the legacy
|
||||
`X-Requested-With: fetch` AJAX header still drives the four engagement endpoints only). JSON responses are defined by Pydantic models in `devplacepy/schemas.py` and built
|
||||
`X-Requested-With: fetch` AJAX header still drives the four engagement endpoints only). JSON responses are defined by Pydantic models in `devplacepy/schemas/` and built
|
||||
from the same context the templates use (sensitive user fields like `email`/`api_key`/
|
||||
`password_hash` are never exposed). Page GETs return the page payload; form actions return a
|
||||
uniform envelope `{ "ok": true, "redirect": "…", "data": {…} }`; errors return
|
||||
@@ -358,7 +413,7 @@ and its full configuration are documented automatically - including future servi
|
||||
|
||||
`services/containers/` runs supervised container instances from the web UI, the HTTP API, and Devii. It drives the `docker` CLI via async subprocesses behind a pluggable `Backend` interface (a `DockerCliBackend` plus a `FakeBackend` for tests). **There is no in-app image building.** Every instance runs ONE shared prebuilt image, `ppy:latest` (override `DEVPLACE_CONTAINER_IMAGE`), built once with **`make ppy`** from `ppy.Dockerfile`: a Python + Playwright base with a broad set of common libraries preinstalled, the `pravda` (uid 1000) user, the sudo superclone, and `pagent` at `/usr/bin/pagent.py` all baked in. Creating an instance is then an instant `docker run` (it fails fast with a clear error if the `ppy` image has not been built yet). `ContainerService` reconciles desired instance state against `docker ps` each tick (containers are labeled `devplace.instance=<uid>`, so orphans are reaped and no state is lost), applies restart policies, fires cron/interval/one-time schedules, and samples metrics. The container's `/app` is bind-mounted to a persistent project workspace (materialized from the project files) and **stays in sync automatically**: the manager runs a bidirectional, newer-wins sync between the project files and the workspace on every start and roughly once a minute while running, so edits made inside the container and edits made in the project file editor converge without manual intervention (the sync only ever creates or overwrites the older copy of a file, never deletes one; a read-only project is export-only). Projects that need extra packages use runtime `pip install` (pravda owns the site-packages, no sudo needed) or `apt install` directly (the `pravda` user runs `apt`/`dpkg` through a fakeroot wrapper, so system packages install without root) or add the library to `ppy.Dockerfile` and rerun `make ppy`. Each instance can run a **boot script** in Python or Bash (written into the workspace and run on launch) or a plain boot command, can be set to **start automatically** whenever the container service starts, and can be configured to **run as** a chosen DevPlace user - which only selects whose identity and API key are injected into the container (the container always runs as the unprivileged `pravda` user). Every status change is recorded and shown as a status history on the instance page. A running instance can be **published** with an `ingress_slug`, making its service reachable (HTTP and WebSocket) at `/p/<slug>` through DevPlace. The manager is reached two ways: the admin **Containers** sidebar entry (`/admin/containers`) lists, creates, edits, and controls every instance across all projects and opens a dedicated detail page per instance, and each project page carries an admin-only **Containers** button to its own instance manager.
|
||||
|
||||
**Security:** this requires mounting the Docker socket, which grants host root. Every run, exec, lifecycle, and schedule operation is administrator-only; `--privileged` is never used and all docker calls are argument-list subprocesses. The service is disabled by default; an admin enables **Containers** on `/admin/services`. CLI: `devplace containers list | reconcile | prune | prune-builds | gc-workspaces` (`prune-builds` is a one-time cleanup that removes legacy per-project images and the old dockerfiles/builds tables).
|
||||
**Security:** this requires mounting the Docker socket, which grants host root. Every run, exec, lifecycle, and schedule operation is administrator-only; `--privileged` is never used and all docker calls are argument-list subprocesses. Containers are additionally isolated per user: an instance is managed only by its owner (the administrator who created it, or the owner of its project) and by the primary administrator, who alone sees and manages every instance including those on private projects; other administrators get a read-only view of instances on public projects and none of another user's private-project instances (exec, terminals, schedules, edits, and lifecycle actions are all refused and audited as denied). The service is disabled by default; an admin enables **Containers** on `/admin/services`. CLI: `devplace containers list | reconcile | prune | prune-builds | gc-workspaces` (`prune-builds` is a one-time cleanup that removes legacy per-project images and the old dockerfiles/builds tables).
|
||||
|
||||
**Runtime data** (container workspaces and zip archives) lives in `DEVPLACE_DATA_DIR` (default `data/`), **outside the package and never served via `/static`**. The docker daemon must be able to bind-mount the data dir for `/app`.
|
||||
|
||||
@@ -374,7 +429,9 @@ and its full configuration are documented automatically - including future servi
|
||||
|
||||
`SeoMetaService` is a separate AI subservice that generates a clean, search-optimised title, description and short keyword list for every published post, project, gist, news article and issue, entirely off the request path so it never slows the web server. The work is queued whenever content is created, edited or published; until the AI value is ready a plain-content default (built from the markdown-stripped text and clamped to safe lengths) fills the fields, so a page's metadata is **always populated, never empty**. The service uses the built-in internal AI gateway and meters its own AI cost and statistics in a dedicated usage table, surfaced together with its live task pipeline on the **Admin -> Services** page. This release also fixes the on-page metadata: the `<meta name="description">` is now stripped of markdown markup (it previously leaked `#`, `**` and `[](...)` from the raw body), a `<meta name="keywords">` tag is emitted (a short honest list, not stuffed), and social-card image dimensions and alt text are added. CLI: `devplace seo-meta prune` / `devplace seo-meta clear` (job rows only; the generated metadata persists).
|
||||
|
||||
`DeepsearchService` powers the public **Tools -> DeepSearch** researcher. Given a single research question it plans a set of diverse web search queries, crawls and reads the most relevant sources in a subprocess (plain HTTP first, headless-browser fallback for JavaScript-heavy pages, PDF documents streamed and text-extracted, every URL SSRF-guarded), de-duplicates content, and indexes everything into a per-session ChromaDB vector collection (embeddings via the AI gateway with a local fallback). A chain of agents (summarizer, critic, linker) then synthesises a cited report with a confidence score, source diversity and explicit gap analysis. Progress streams live over `WS /tools/deepsearch/{uid}/ws`; the report is at `/tools/deepsearch/{uid}/session` (HTML or JSON) and can be exported as Markdown, JSON or PDF. A grounded chat over the session's evidence runs at `WS /tools/deepsearch/{uid}/chat` using hybrid retrieval (vector + keyword/BM25). Runs can be paused, resumed or cancelled. A cross-session URL cache avoids re-fetching pages seen by earlier runs. CLI: `devplace deepsearch prune` / `devplace deepsearch clear`. ChromaDB, weasyprint, pypdf and Playwright are core dependencies.
|
||||
`DeepsearchService` powers the public **Tools -> DeepSearch** researcher. Given a single research question it plans a set of diverse web search queries, interleaves their results so every angle contributes sources, and crawls the most relevant pages in a subprocess (concurrent fetches, plain HTTP first with a headless-browser fallback for JavaScript-heavy pages, PDF documents streamed and text-extracted, every URL SSRF-guarded). Social sites that block bots (X, YouTube, Reddit and similar) contribute the readable text supplied by the search engine, so their content is not lost to a login wall. A readability-grade extractor isolates the main article content of each page (navigation, cookie banners and footers are discarded), and the configured depth follows the most relevant in-page links for deeper coverage. Content is de-duplicated and indexed into a per-session ChromaDB vector collection (embeddings via the AI gateway with a local fallback). A chain of agents (report writer, findings extractor, linker) then synthesises a thorough cited markdown report grounded on the passages retrieved from that index, with key findings, a confidence score and source diversity; if synthesis fails the report is clearly marked as degraded instead of silently shipping raw source material. Progress streams live over `WS /tools/deepsearch/{uid}/ws`; the report is at `/tools/deepsearch/{uid}/session` (HTML or JSON) and can be exported as Markdown, JSON or PDF. A grounded chat over the session's evidence runs at `WS /tools/deepsearch/{uid}/chat` using hybrid retrieval (vector + keyword/BM25). Runs can be paused, resumed or cancelled. A cross-session URL cache avoids re-fetching pages seen by earlier runs. CLI: `devplace deepsearch prune` / `devplace deepsearch clear`. ChromaDB, weasyprint, pypdf and Playwright are core dependencies.
|
||||
|
||||
`IsslopService` powers the public **Tools -> AI Usage Analyzer**, which classifies a git repository or website as AI slop, sophisticated AI-assisted work or genuine human work. Sources are acquired in an isolated subprocess (git URLs are probed with `git ls-remote` and shallow-cloned with a 3 GB guard; websites render in a stealth headless browser with an HTTP fallback, bounded by depth, file and byte caps), inventoried with exclusion rules, and scored by a multi-signal static engine (twenty-one detector families, 126 signal types across an origin axis and a quality-deficit axis). For a live website, the analyzer also opens the home page in a headless browser and inspects what actually renders: it fingerprints AI website-builder platforms directly (Lovable, Bolt.new, Framer and others) and checks the page's real computed styles, layout and build artifacts, not just its file contents. Representative files receive an AI review pass and images an AI-generation review through the internal gateway (model `molodetz`, internal key); the static engine stays authoritative when the gateway is unreachable. Every pipeline step is persisted as an ordered event trail and streamed live over the pub/sub topic `public.isslop.{uid}`. The verdict is an A-F authenticity grade with a human/AI split and one of five categories (`ai-slop`, `sophisticated-ai`, `human-clean`, `human-messy`, `uncertain`), published as a persistent report with an embeddable SVG badge. Members keep their analysis history on their account; guest history is session-bound and claimed by the account on the first signed-in visit. Admin settings (private-host allowance, AI/image review toggles, image cap, retention, concurrency) live on `/admin/services`. CLI: `devplace isslop analyze <url>` / `devplace isslop prune` / `devplace isslop clear`. Playwright plus playwright-stealth back the website crawler.
|
||||
|
||||
`BackupService` powers the admin **Admin -> Backups** dashboard, an enterprise-grade backup system that runs entirely as asynchronous jobs so it never impacts the running server. An administrator can back up one of four targets: the **database** (a consistent SQLite snapshot of the main database and the Devii task/lesson databases, taken with SQLite's online backup API so it is consistent under WAL), **uploads** (every attachment and project file), **keys and config** (VAPID keys), or the **full data directory** (database snapshot, uploads, and keys in one archive, excluding regenerable staging, locks, caches, and container workspaces). Each backup is compressed to a `tar.gz` in a stdlib subprocess off the request path and recorded with its size, file count, and a SHA-256 checksum. Archives live under `data/backups/` (sharded on the random uuid tail) and are served only through `/admin/backups/{uid}/download`, which is restricted to the **primary administrator** - the first user created with the Admin role. Every other administrator receives a 403 from the endpoint and sees the Download button disabled with the tooltip `Not available`; creating, running, deleting, and scheduling backups remain available to all administrators. The dashboard reports detailed storage usage - the size and file count of every major data area, the total data-directory footprint, the total size and count of stored backups, and disk usage (total, used, free, percent), computed in a worker thread and cached briefly so the page never blocks. Backups can be **scheduled** (CRUD) on an interval or 5-field cron expression with a `keep_last` rotation count that prunes older backups of the same schedule; the service evaluates schedules only on the lock-owning worker so each fires exactly once. Backup archives are permanent operational artifacts: job retention only removes the tracking row, never the archive, which is deleted only by an administrator, by schedule rotation, or via the CLI. CLI: `devplace backups list` / `devplace backups run <target>` / `devplace backups prune` / `devplace backups clear`. Devii tools: `backups_overview`, `backup_run`, `backup_status`, `backup_delete`, `backup_schedule_create`, `backup_schedule_delete` (all admin-only). The service creates and stores backups but does not restore them into a live server; restore is a documented manual procedure (stop the server, unpack the archive over the data directory, verify the checksum, restart).
|
||||
|
||||
@@ -445,7 +502,7 @@ Configuration on the Services tab:
|
||||
| `bot_max_per_article` | `2` | How many bots may post about one article, each from a different angle |
|
||||
| `bot_article_ttl_days` | `7` | How long an article stays covered before it can be posted again |
|
||||
| `bot_gist_min_lines` | `6` | Reject generated snippets shorter than this many non-empty lines |
|
||||
| `bot_action_pause_min_seconds` / `bot_action_pause_max_seconds` | `5` / `30` | Idle pause window a bot takes after each action |
|
||||
| `bot_action_pause_min_seconds` / `bot_action_pause_max_seconds` | `5` / `45` | Idle pause window a bot takes after each action |
|
||||
| `bot_break_scale` | `1.0` | Multiplier on between-session breaks (below 1 = more active and costlier) |
|
||||
| `bot_ai_decisions` | disabled | Let each bot's AI-generated identity decide every action via the LLM instead of fixed probabilities (one decision call per page); see `aibots.md` |
|
||||
| `bot_decision_temperature` | `0.4` | Sampling temperature for the per-page decision call |
|
||||
@@ -575,15 +632,39 @@ are run by the background service, so a queued reminder survives a server restar
|
||||
even if you have closed the Devii terminal. When a reminder fires you receive an in-app
|
||||
notification and a live toast carrying its message (the **Reminders** notification type, which
|
||||
you can toggle like any other on your profile), in addition to the result appearing in the
|
||||
terminal. Manage your reminders conversationally (list, change, run now, or delete them).
|
||||
terminal.
|
||||
|
||||
**Every account may schedule, within two rolling 24-hour quotas.** A member may create 5 tasks
|
||||
and execute 10 task runs per 24 hours; an administrator may create 5 and execute 100. Deleting a
|
||||
task does not give a creation slot back, and a run that would exceed the quota is **postponed
|
||||
until a slot frees, never dropped or disabled** - the task simply runs later, and the exact time
|
||||
its next slot opens is reported. All four numbers are adjustable on the Devii service page, where
|
||||
0 means unlimited. Guests cannot schedule at all.
|
||||
|
||||
**A task knows when it is running as a task, and a member's task cannot spawn more tasks.** While
|
||||
a scheduled run is executing, creating a task, re-enabling one, or triggering one immediately is
|
||||
refused for members - so a member's automation can never fan out into more automation. An
|
||||
administrator's task may schedule follow-up work, and every new task and run still counts against
|
||||
the same quotas. The assistant is told which environment it is in, and the restriction itself is
|
||||
enforced by the server rather than by the instruction, so no prompt can talk its way around it.
|
||||
|
||||
Every scheduled task is also bounded in time: a repeating task must leave at least fifteen minutes
|
||||
between runs, carries a maximum number of executions, and expires at most thirty days after its
|
||||
first run. A task that fails several times in a row, whose owner has been inactive for a month, or
|
||||
that passes its automation spend limit is disabled automatically with the reason recorded in the
|
||||
audit log. Across the whole platform only a few scheduled tasks run at the same time, handed out
|
||||
one at a time per owner, so a single account can never monopolise the scheduler. Administrators
|
||||
see every task, its owner, its 24-hour usage, and its bounds at **Admin -> Devii tasks**, where any
|
||||
task can be disabled or deleted, and the same is available from the command line with
|
||||
`devplace devii tasks`.
|
||||
|
||||
Configuration on the Services tab:
|
||||
|
||||
| Parameter | Default | Purpose |
|
||||
|-----------|---------|---------|
|
||||
| `devii_ai_url` | `https://openai.app.molodetz.nl/v1/chat/completions` | OpenAI-compatible reasoning endpoint |
|
||||
| `devii_ai_url` | `http://localhost:10500/openai/v1/chat/completions` | OpenAI-compatible reasoning endpoint (defaults to the internal gateway) |
|
||||
| `devii_ai_model` | `molodetz` | Model name |
|
||||
| `devii_ai_key` | env fallback (`DEVII_AI_KEY`) | AI API key |
|
||||
| `devii_ai_key` | env fallback (`DEVII_AI_KEY`), then the gateway internal key | AI API key |
|
||||
| `devii_base_url` | this instance's origin | Platform Devii drives via each user's API key |
|
||||
| `devii_plan_required` / `devii_verify_required` | on / on | Enforce plan-first and verify-after-mutation |
|
||||
| `devii_max_iterations` | `40` | Tool-loop iterations per turn |
|
||||
@@ -599,6 +680,15 @@ Configuration on the Services tab:
|
||||
| `devii_rsearch_timeout` | `300` | Read timeout (seconds) for `rsearch_*` calls; web-grounded answers can take minutes; minimum five minutes |
|
||||
| `devii_email_enabled` | on | Enable the email tools (`email_*`) for signed-in users |
|
||||
| `devii_email_timeout` | `30` | Connection/read timeout (seconds) for IMAP and SMTP calls |
|
||||
| `devii_task_member_create_24h` | `5` | Tasks a member may create per rolling 24 hours (`0` = unlimited) |
|
||||
| `devii_task_member_runs_24h` | `10` | Task runs a member may execute per rolling 24 hours; excess runs are postponed |
|
||||
| `devii_task_admin_create_24h` | `5` | Tasks an administrator may create per rolling 24 hours |
|
||||
| `devii_task_admin_runs_24h` | `100` | Task runs an administrator may execute per rolling 24 hours |
|
||||
| `devii_task_max_concurrent` | `4` | Scheduled tasks running at once across all owners, handed out round-robin, one at a time per owner |
|
||||
| `devii_task_max_per_owner` | `10` | Active scheduled tasks one administrator may hold (`0` = no cap) |
|
||||
| `devii_task_daily_usd` | `0.5` | Rolling 24h spend cap for scheduled runs, separate from the interactive quota (`0` = unlimited) |
|
||||
| `devii_task_max_failures` | `3` | Consecutive failures after which a task disables itself (`0` = never) |
|
||||
| `devii_task_owner_idle_days` | `30` | Disable an owner's tasks after this many days without activity (`0` = never) |
|
||||
|
||||
Beyond the platform tools, Devii has external **web** tools. `fetch_url` reads a web page;
|
||||
`http_request` makes an arbitrary HTTP call (GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS) to any
|
||||
@@ -716,7 +806,7 @@ installable Progressive Web App. Push uses only standard libraries (`cryptograph
|
||||
|
||||
### Events
|
||||
|
||||
Every event flows through a single funnel - `create_notification()` in `utils.py` -
|
||||
Every event flows through a single funnel - `create_notification()` in `utils/` -
|
||||
which delivers on three independent channels, in-app, web push and Telegram, each gated by the
|
||||
recipient's preferences (see "Configurable notifications" below). Whenever the in-app
|
||||
channel delivers, the recipient's open browser also raises a live, click-through toast
|
||||
@@ -787,8 +877,8 @@ every page load. `PushManager.js` owns registration, subscription, and the opt-i
|
||||
|
||||
### PWA
|
||||
|
||||
`manifest.json` (192/512 and maskable icons), `service-worker.js`, and an install
|
||||
button (`PwaInstaller.js`) make the app installable. The service worker uses a
|
||||
`manifest.json` (192/512 and maskable icons) and `service-worker.js` make the app
|
||||
installable via the browser's native install affordance. The service worker uses a
|
||||
network-first strategy for navigations and falls back to `static/offline.html` when
|
||||
offline. Installation requires a secure origin (HTTPS, or `localhost` for development).
|
||||
|
||||
@@ -797,7 +887,6 @@ offline. Installation requires a secure origin (HTTPS, or `localhost` for develo
|
||||
| `devplacepy/push.py` | VAPID keys, payload encryption, send, register |
|
||||
| `devplacepy/routers/push.py` | `/push.json`, `/service-worker.js`, `/manifest.json` |
|
||||
| `static/js/PushManager.js` | Service-worker registration + subscribe + opt-in UI |
|
||||
| `static/js/PwaInstaller.js` | `beforeinstallprompt` capture + install button |
|
||||
| `static/service-worker.js` | Receives push, shows notification, offline fallback |
|
||||
| `static/manifest.json` | PWA manifest (icons, display, theme) |
|
||||
| `static/offline.html` | Offline fallback page |
|
||||
@@ -843,7 +932,7 @@ Two background services bridge persisted state onto the bus so the interface upd
|
||||
|
||||
## Testing
|
||||
|
||||
- **932 tests** split into three tiers under `tests/`: `unit/` (pure in-process), `api/` (HTTP integration against the live server), and `e2e/` (Playwright browser)
|
||||
- **1959 tests** split into three tiers under `tests/`: `unit/` (pure in-process), `api/` (HTTP integration against the live server), and `e2e/` (Playwright browser)
|
||||
- **A directory tree that mirrors the path.** api/e2e follow the endpoint path - each route segment is a directory and the last segment is the file, `{param}` segments dropped (`GET /admin/ai-usage` -> `tests/e2e/admin/aiusage.py`, `GET /projects/{slug}/files/lines` -> `tests/api/projects/files/lines.py`). unit mirrors the source module path (`devplacepy/services/audit/store.py` -> `tests/unit/services/audit/store.py`). Run one tier with `make test-unit` / `make test-api` / `make test-e2e`
|
||||
- Playwright (NOT pytest-playwright plugin - conflicts, uninstall it)
|
||||
- Runs serially, one test at a time, in a single process (`make test`); the suite drives one uvicorn subprocess on port 10501 with its own temp database and `DEVPLACE_DATA_DIR`
|
||||
@@ -909,12 +998,12 @@ The Container Manager drives the host Docker daemon, so `make docker-build`/`mak
|
||||
What the overlay (`docker-compose.containers.yml`) changes:
|
||||
|
||||
- **Docker CLI in the image** via the `INSTALL_DOCKER_CLI=true` build arg (the base image stays lean).
|
||||
- **Docker socket** mounted into the app container. This grants the app **root on the host** - every build/run/exec is admin-only, `--privileged` is never used, and all docker calls are argument-list subprocesses, but treat the whole feature as trusted-admins-only.
|
||||
- **Docker socket** mounted into the app container. This grants the app **root on the host** - every run/exec/lifecycle operation is admin-only, `--privileged` is never used, and all docker calls are argument-list subprocesses, but treat the whole feature as trusted-admins-only.
|
||||
- **Socket permissions:** the app runs as UID 1000, so the overlay adds the host `docker` group via `group_add`. `make` reads the gid straight from `/var/run/docker.sock` (`stat -c '%g'`), the exact group that owns the socket.
|
||||
- **Data dir at a consistent path (critical).** When the app (in its container) runs `docker run -v <path>:/app`, the daemon resolves `<path>` against the **host**, not the app container. So the workspace/data dir must be mounted at the **same absolute path** on host and in the container - the make targets set `DEVPLACE_DATA_DIR` to the project's `./data` (an absolute host path) and mount it at that identical path on both sides. (Build contexts go through the docker API as a tarball, so they can stay in the container's temp dir - only the `/app` bind mount needs path consistency.)
|
||||
- **Ingress reach:** published container ports live on the **host**, so the overlay sets `DEVPLACE_CONTAINER_PROXY_HOST=host.docker.internal` (with `extra_hosts: host-gateway`) so the `/p/<slug>` proxy can reach them. On a bare-metal `make prod` deploy the app is already on the host, so the default `127.0.0.1` works and no overlay is needed (just install the docker CLI and run the services).
|
||||
|
||||
Then enable **Container builds** and **Containers** on `/admin/services`. Builds default to `--network=host` (configurable on the service) so pip can reach PyPI; set the build network to empty to use the docker default.
|
||||
Then build the shared `ppy` image once with `make ppy` and enable **Containers** on `/admin/services`. There is no in-app image building; every instance runs that one prebuilt image.
|
||||
|
||||
### nginx specifics
|
||||
|
||||
@@ -941,7 +1030,7 @@ The version sits in the **path**, not a query string, because the frontend is un
|
||||
|
||||
### Bare-metal alternative
|
||||
|
||||
`make prod` runs the same app without containers (`uvicorn ... --workers 2 --proxy-headers`) from the project root, sharing the identical database and files. Note it binds port 10500, so it conflicts with the Docker front door on the same port - run one, or set a different `PORT`.
|
||||
`make prod` runs the same app without containers (`uvicorn ... --workers $(WEB_WORKERS) --proxy-headers`, where `WEB_WORKERS` defaults to `nproc`) from the project root, sharing the identical database and files. Note it binds port 10500, so it conflicts with the Docker front door on the same port - run one, or set a different `PORT`.
|
||||
|
||||
### Multi-worker safety
|
||||
|
||||
@@ -964,7 +1053,7 @@ Changes are promoted through automated DTAP streets: Development (`make dev`), T
|
||||
2. Validate each touched language (Python compiles/imports, JS parses, CSS and HTML balance)
|
||||
3. `make test` - run all tests (fail-fast)
|
||||
4. Add tests in the matching tier and endpoint file (`tests/{unit,api,e2e}/<endpoint>.py`) for new functionality
|
||||
5. Update `AGENTS.md` and `README.md` if new conventions were introduced
|
||||
5. Update the relevant nested `CLAUDE.md` and `README.md` if new conventions were introduced
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -444,12 +444,13 @@ def link_attachments(uids, target_type, target_uid):
|
||||
return
|
||||
placeholders = ",".join(f":p{i}" for i in range(len(flat)))
|
||||
params = {f"p{i}": uid for i, uid in enumerate(flat)}
|
||||
db.query(
|
||||
f"UPDATE attachments SET target_type=:tt, target_uid=:tu WHERE uid IN ({placeholders})",
|
||||
tt=target_type,
|
||||
tu=target_uid,
|
||||
**params,
|
||||
)
|
||||
with db:
|
||||
db.query(
|
||||
f"UPDATE attachments SET target_type=:tt, target_uid=:tu WHERE uid IN ({placeholders})",
|
||||
tt=target_type,
|
||||
tu=target_uid,
|
||||
**params,
|
||||
)
|
||||
|
||||
|
||||
def set_gitea_asset_id(uid, asset_id):
|
||||
@@ -545,6 +546,21 @@ def delete_attachment(uid):
|
||||
_delete_attachment_row(row)
|
||||
|
||||
|
||||
def rename_attachment(uid, filename):
|
||||
row = get_table("attachments").find_one(uid=uid, deleted_at=None)
|
||||
if not row:
|
||||
return None
|
||||
ext = Path(row.get("stored_name", "")).suffix.lower()
|
||||
stem = Path(str(filename)).name.strip()
|
||||
if ext:
|
||||
stem = Path(stem).stem
|
||||
if not stem:
|
||||
return None
|
||||
clean = f"{stem}{ext}"
|
||||
get_table("attachments").update({"uid": uid, "original_filename": clean}, ["uid"])
|
||||
return clean
|
||||
|
||||
|
||||
def soft_delete_attachment(uid, deleted_by="system"):
|
||||
row = get_table("attachments").find_one(uid=uid)
|
||||
if not row or row.get("deleted_at"):
|
||||
@@ -617,7 +633,8 @@ def delete_attachments_for(target_type, target_uids):
|
||||
for row in rows:
|
||||
_unlink_attachment_files(row)
|
||||
ids = ",".join(str(row["id"]) for row in rows)
|
||||
db.query(f"DELETE FROM attachments WHERE id IN ({ids})")
|
||||
with db:
|
||||
db.query(f"DELETE FROM attachments WHERE id IN ({ids})")
|
||||
|
||||
|
||||
def get_attachments(target_type, target_uid):
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from io import BytesIO
|
||||
|
||||
from PIL import Image
|
||||
|
||||
|
||||
def enforce_rgba_png(file_bytes: bytes) -> bytes:
|
||||
img = Image.open(BytesIO(file_bytes)).convert("RGBA")
|
||||
width, height = img.size
|
||||
if width > 1 and height > 1:
|
||||
corner = img.getpixel((0, 0))
|
||||
if len(corner) == 4 and corner[3] == 255:
|
||||
bg = corner[:3]
|
||||
data = img.getdata()
|
||||
cleaned = []
|
||||
for pixel in data:
|
||||
if pixel[:3] == bg:
|
||||
cleaned.append((pixel[0], pixel[1], pixel[2], 0))
|
||||
else:
|
||||
cleaned.append(pixel)
|
||||
img.putdata(cleaned)
|
||||
buf = BytesIO()
|
||||
img.save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def resize_award_png(source: bytes, size: int) -> bytes:
|
||||
img = Image.open(BytesIO(source)).convert("RGBA")
|
||||
img = img.resize((size, size), Image.LANCZOS)
|
||||
buf = BytesIO()
|
||||
img.save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
@@ -21,6 +21,9 @@ from devplacepy.cli.jobs import (
|
||||
cmd_forks_clear,
|
||||
cmd_seo_prune,
|
||||
cmd_seo_clear,
|
||||
cmd_isslop_prune,
|
||||
cmd_isslop_clear,
|
||||
cmd_isslop_analyze,
|
||||
cmd_seo_meta_prune,
|
||||
cmd_seo_meta_clear,
|
||||
cmd_deepsearch_prune,
|
||||
@@ -39,6 +42,7 @@ from devplacepy.cli.containers import (
|
||||
cmd_containers_prune_builds,
|
||||
cmd_containers_gc_workspaces,
|
||||
)
|
||||
from devplacepy.cli.quiz import cmd_quiz_prune
|
||||
from devplacepy.cli.migrate import cmd_emoji_sync, cmd_migrate_data
|
||||
|
||||
__all__ = [
|
||||
@@ -65,6 +69,9 @@ __all__ = [
|
||||
"cmd_forks_clear",
|
||||
"cmd_seo_prune",
|
||||
"cmd_seo_clear",
|
||||
"cmd_isslop_prune",
|
||||
"cmd_isslop_clear",
|
||||
"cmd_isslop_analyze",
|
||||
"cmd_seo_meta_prune",
|
||||
"cmd_seo_meta_clear",
|
||||
"cmd_deepsearch_prune",
|
||||
@@ -78,6 +85,7 @@ __all__ = [
|
||||
"cmd_containers_prune",
|
||||
"cmd_containers_prune_builds",
|
||||
"cmd_containers_gc_workspaces",
|
||||
"cmd_quiz_prune",
|
||||
"cmd_emoji_sync",
|
||||
"cmd_migrate_data",
|
||||
]
|
||||
|
||||
+193
-3
@@ -1,13 +1,11 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import sys
|
||||
from devplacepy.database import get_table
|
||||
from devplacepy.database import db, get_table
|
||||
from devplacepy.cli._shared import _audit_cli
|
||||
|
||||
|
||||
def cmd_devii_reset_quota(args):
|
||||
from devplacepy.database import db
|
||||
|
||||
table_name = "devii_usage_ledger"
|
||||
if table_name not in db.tables:
|
||||
print(f"Table '{table_name}' does not exist, nothing to reset")
|
||||
@@ -48,6 +46,167 @@ def cmd_devii_reset_quota(args):
|
||||
print(f"Reset AI quota for '{args.username}' ({count} ledger rows deleted)")
|
||||
|
||||
|
||||
def _active_count() -> int:
|
||||
if "devii_lessons" not in db.tables:
|
||||
return 0
|
||||
return db["devii_lessons"].count(deleted_at=None)
|
||||
|
||||
|
||||
def _soft_deleted_count() -> int:
|
||||
if "devii_lessons" not in db.tables:
|
||||
return 0
|
||||
return db["devii_lessons"].count(deleted_at={"!=": None})
|
||||
|
||||
|
||||
def cmd_devii_lessons_count(args):
|
||||
active = _active_count()
|
||||
deleted = _soft_deleted_count()
|
||||
print(f"Lessons: {active} active, {deleted} soft-deleted ({active + deleted} total)")
|
||||
|
||||
|
||||
def cmd_devii_lessons_clear(args):
|
||||
from devplacepy.services.devii.agentic.lessons import TABLE
|
||||
|
||||
if TABLE not in db.tables:
|
||||
print("No devii_lessons table exists")
|
||||
return
|
||||
active = _active_count()
|
||||
deleted = _soft_deleted_count()
|
||||
total = active + deleted
|
||||
if not args.force:
|
||||
print(f"Will delete {total} lesson(s) ({active} active, {deleted} soft-deleted). Pass --force to confirm.")
|
||||
return
|
||||
db[TABLE].delete()
|
||||
_audit_cli("cli.devii.lessons.clear", "CLI cleared all devii_lessons", metadata={"active": active, "soft_deleted": deleted})
|
||||
print(f"Deleted {total} lesson(s)")
|
||||
|
||||
|
||||
def cmd_devii_lessons_prune(args):
|
||||
from devplacepy.services.devii.agentic.lessons import LessonStore, _read_retention_settings
|
||||
|
||||
if "devii_lessons" not in db.tables:
|
||||
print("No devii_lessons table exists")
|
||||
return
|
||||
active_before = _active_count()
|
||||
if args.all_owners:
|
||||
_, max_age = _read_retention_settings(db)
|
||||
store = LessonStore(db, "_global", "_global")
|
||||
pruned = store.prune_all_owners(max_age)
|
||||
elif args.username:
|
||||
user = get_table("users").find_one(username=args.username)
|
||||
if not user:
|
||||
print(f"User '{args.username}' not found")
|
||||
sys.exit(1)
|
||||
_, max_age = _read_retention_settings(db)
|
||||
store = LessonStore(db, "user", user["uid"])
|
||||
pruned = store.prune(max_age)
|
||||
else:
|
||||
print("Provide --all-owners, or --username USER")
|
||||
sys.exit(1)
|
||||
_audit_cli("cli.devii.lessons.prune", "CLI pruned devii_lessons", metadata={"pruned": pruned, "active_before": active_before})
|
||||
print(f"Pruned {pruned} lesson(s) (active before: {active_before}, now: {_active_count()})")
|
||||
|
||||
|
||||
def _task_rows(enabled_only: bool) -> list:
|
||||
from devplacepy.services.devii.tasks.store import TABLE
|
||||
|
||||
if TABLE not in db.tables:
|
||||
return []
|
||||
criteria = {"deleted_at": None}
|
||||
if enabled_only:
|
||||
criteria["enabled"] = True
|
||||
rows = list(db[TABLE].find(**criteria))
|
||||
rows.sort(key=lambda row: int(row.get("run_count") or 0), reverse=True)
|
||||
return rows
|
||||
|
||||
|
||||
def _owner_name(owner_id: str) -> str:
|
||||
user = get_table("users").find_one(uid=owner_id)
|
||||
return user["username"] if user else owner_id
|
||||
|
||||
|
||||
def cmd_devii_tasks_list(args):
|
||||
rows = _task_rows(not args.all)
|
||||
if not rows:
|
||||
print("No tasks")
|
||||
return
|
||||
for row in rows:
|
||||
schedule = (
|
||||
f"every {row.get('every_seconds')}s"
|
||||
if row.get("kind") == "interval"
|
||||
else (row.get("cron") or row.get("run_at") or "")
|
||||
)
|
||||
print(
|
||||
f"{row.get('uid')} {_owner_name(str(row.get('owner_id') or '')):16} "
|
||||
f"{'on ' if row.get('enabled') else 'off'} {str(row.get('status')):9} "
|
||||
f"runs={row.get('run_count')}/{row.get('max_runs') or '-'} "
|
||||
f"{schedule:24} {row.get('label') or ''}"
|
||||
)
|
||||
|
||||
|
||||
def cmd_devii_tasks_disable(args):
|
||||
from devplacepy.services.devii.tasks.store import TABLE, TaskStore
|
||||
|
||||
if TABLE not in db.tables:
|
||||
print("No devii_tasks table exists")
|
||||
return
|
||||
row = db[TABLE].find_one(uid=args.uid, deleted_at=None)
|
||||
if not row:
|
||||
print(f"Task '{args.uid}' not found")
|
||||
sys.exit(1)
|
||||
store = TaskStore(db, str(row.get("owner_kind") or "user"), str(row.get("owner_id") or ""))
|
||||
store.update(
|
||||
args.uid,
|
||||
{
|
||||
"enabled": False,
|
||||
"status": "disabled",
|
||||
"next_run_at": None,
|
||||
"last_error": "disabled from the command line",
|
||||
},
|
||||
)
|
||||
_audit_cli(
|
||||
"cli.devii.task.disable",
|
||||
f"CLI disabled Devii task {args.uid}",
|
||||
metadata={"owner_id": row.get("owner_id"), "run_count": row.get("run_count")},
|
||||
target_type="task",
|
||||
target_uid=args.uid,
|
||||
target_label=row.get("label"),
|
||||
)
|
||||
print(f"Disabled task '{args.uid}'")
|
||||
|
||||
|
||||
def cmd_devii_tasks_prune(args):
|
||||
from devplacepy.services.devii.tasks.guards import automation_allowed
|
||||
from devplacepy.services.devii.tasks.store import TABLE, TaskStore
|
||||
|
||||
if TABLE not in db.tables:
|
||||
print("No devii_tasks table exists")
|
||||
return
|
||||
pruned = 0
|
||||
for row in list(db[TABLE].find(enabled=True, deleted_at=None)):
|
||||
owner_kind = str(row.get("owner_kind") or "")
|
||||
owner_id = str(row.get("owner_id") or "")
|
||||
if automation_allowed(owner_kind, owner_id):
|
||||
continue
|
||||
store = TaskStore(db, owner_kind, owner_id)
|
||||
store.update(
|
||||
row["uid"],
|
||||
{
|
||||
"enabled": False,
|
||||
"status": "disabled",
|
||||
"next_run_at": None,
|
||||
"last_error": "owner is not an administrator",
|
||||
},
|
||||
)
|
||||
pruned += 1
|
||||
_audit_cli(
|
||||
"cli.devii.task.prune",
|
||||
"CLI disabled tasks whose owner may not schedule",
|
||||
metadata={"disabled": pruned},
|
||||
)
|
||||
print(f"Disabled {pruned} task(s) whose owner is not an administrator")
|
||||
|
||||
|
||||
def register_devii(subparsers):
|
||||
devii = subparsers.add_parser("devii", help="Devii assistant management")
|
||||
devii_sub = devii.add_subparsers(title="action", dest="action")
|
||||
@@ -64,3 +223,34 @@ def register_devii(subparsers):
|
||||
"--all", action="store_true", help="Reset every quota (users and guests)"
|
||||
)
|
||||
devii_reset.set_defaults(func=cmd_devii_reset_quota)
|
||||
|
||||
devii_lessons = devii_sub.add_parser("lessons", help="Manage persisted Devii lesson data")
|
||||
lessons_sub = devii_lessons.add_subparsers(title="sub-action", dest="sub_action")
|
||||
|
||||
lessons_count = lessons_sub.add_parser("count", help="Count active and soft-deleted lessons")
|
||||
lessons_count.set_defaults(func=cmd_devii_lessons_count)
|
||||
|
||||
lessons_prune = lessons_sub.add_parser("prune", help="Soft-delete lessons older than the configured max age")
|
||||
lessons_prune.add_argument("--all-owners", action="store_true", help="Prune across every owner")
|
||||
lessons_prune.add_argument("--username", help="Prune for a specific user")
|
||||
lessons_prune.set_defaults(func=cmd_devii_lessons_prune)
|
||||
|
||||
lessons_clear = lessons_sub.add_parser("clear", help="Hard-delete every devii_lessons row")
|
||||
lessons_clear.add_argument("--force", action="store_true", help="Required to confirm hard deletion")
|
||||
lessons_clear.set_defaults(func=cmd_devii_lessons_clear)
|
||||
|
||||
devii_tasks = devii_sub.add_parser("tasks", help="Inspect and stop scheduled Devii tasks")
|
||||
tasks_sub = devii_tasks.add_subparsers(title="sub-action", dest="sub_action")
|
||||
|
||||
tasks_list = tasks_sub.add_parser("list", help="List scheduled tasks and their owners")
|
||||
tasks_list.add_argument("--all", action="store_true", help="Include disabled tasks")
|
||||
tasks_list.set_defaults(func=cmd_devii_tasks_list)
|
||||
|
||||
tasks_disable = tasks_sub.add_parser("disable", help="Disable one task by uid")
|
||||
tasks_disable.add_argument("uid", help="Uid of the task")
|
||||
tasks_disable.set_defaults(func=cmd_devii_tasks_disable)
|
||||
|
||||
tasks_prune = tasks_sub.add_parser(
|
||||
"prune", help="Disable every task whose owner is not an administrator"
|
||||
)
|
||||
tasks_prune.set_defaults(func=cmd_devii_tasks_prune)
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from devplacepy.cli._shared import _audit_cli
|
||||
|
||||
|
||||
def cmd_game_market_prune(args):
|
||||
from devplacepy.services.game import store
|
||||
|
||||
removed = store.prune_ticks()
|
||||
_audit_cli(
|
||||
"cli.game.market.prune",
|
||||
f"CLI pruned {removed} stale Code Farm market tick(s)",
|
||||
metadata={"count": removed},
|
||||
)
|
||||
print(f"Pruned {removed} stale market tick bucket(s)")
|
||||
|
||||
|
||||
def cmd_game_steals_prune(args):
|
||||
from devplacepy.services.game import store
|
||||
|
||||
removed = store.prune_steals()
|
||||
_audit_cli(
|
||||
"cli.game.steals.prune",
|
||||
f"CLI pruned {removed} old Code Farm raid record(s)",
|
||||
metadata={"count": removed},
|
||||
)
|
||||
print(f"Pruned {removed} raid record(s)")
|
||||
|
||||
|
||||
def cmd_game_era_status(args):
|
||||
from devplacepy.services.game import store
|
||||
|
||||
era = store.active_era()
|
||||
if not era:
|
||||
print("No Era is currently running.")
|
||||
return
|
||||
print(f"Era {era['era_number']}: {era['name']}")
|
||||
print(f"Started: {era['started_at']}")
|
||||
print(f"Scheduled end: {era['ends_at']}")
|
||||
|
||||
|
||||
def cmd_game_era_start(args):
|
||||
from devplacepy.services.game import GameError, store
|
||||
|
||||
try:
|
||||
era = store.start_era(args.name, args.duration_days)
|
||||
except GameError as exc:
|
||||
print(f"Error: {exc}")
|
||||
return
|
||||
_audit_cli(
|
||||
"cli.game.era.start",
|
||||
f"CLI started Code Farm Era {era['era_number']}: {era['name']}",
|
||||
metadata={"era_number": era["era_number"], "name": era["name"]},
|
||||
)
|
||||
print(f"Started Era {era['era_number']}: {era['name']}")
|
||||
|
||||
|
||||
def cmd_game_era_end(args):
|
||||
from devplacepy.services.game import GameError, store
|
||||
|
||||
try:
|
||||
result = store.end_era()
|
||||
except GameError as exc:
|
||||
print(f"Error: {exc}")
|
||||
return
|
||||
_audit_cli(
|
||||
"cli.game.era.end",
|
||||
f"CLI ended Code Farm Era {result['era_number']}",
|
||||
metadata=result,
|
||||
)
|
||||
print(f"Ended Era {result['era_number']} ({result['participants']} participant(s) ranked)")
|
||||
|
||||
|
||||
def register_game(subparsers):
|
||||
game = subparsers.add_parser("game", help="Code Farm management")
|
||||
game_sub = game.add_subparsers(title="action", dest="action")
|
||||
|
||||
market = game_sub.add_parser("market", help="Code Farm market saturation data")
|
||||
market_sub = market.add_subparsers(title="market_action", dest="market_action")
|
||||
market_prune = market_sub.add_parser(
|
||||
"prune", help="Delete market tick buckets older than the tracking window"
|
||||
)
|
||||
market_prune.set_defaults(func=cmd_game_market_prune)
|
||||
|
||||
steals = game_sub.add_parser("steals", help="Code Farm raid history")
|
||||
steals_sub = steals.add_subparsers(title="steals_action", dest="steals_action")
|
||||
steals_prune = steals_sub.add_parser(
|
||||
"prune", help="Delete raid records older than the raid-efficiency window"
|
||||
)
|
||||
steals_prune.set_defaults(func=cmd_game_steals_prune)
|
||||
|
||||
era = game_sub.add_parser("era", help="Code Farm Era management")
|
||||
era_sub = era.add_subparsers(title="era_action", dest="era_action")
|
||||
era_status = era_sub.add_parser("status", help="Show the current Era status")
|
||||
era_status.set_defaults(func=cmd_game_era_status)
|
||||
era_start = era_sub.add_parser("start", help="Start a new Era")
|
||||
era_start.add_argument("name", help="Era name")
|
||||
era_start.add_argument(
|
||||
"--days", dest="duration_days", type=int, default=28, help="Planned Era length in days"
|
||||
)
|
||||
era_start.set_defaults(func=cmd_game_era_start)
|
||||
era_end = era_sub.add_parser("end", help="End the currently running Era")
|
||||
era_end.set_defaults(func=cmd_game_era_end)
|
||||
@@ -0,0 +1,103 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import sys
|
||||
|
||||
from devplacepy.cli._shared import _audit_cli
|
||||
|
||||
|
||||
def cmd_gateway_quota_list(args):
|
||||
from devplacepy.services.openai_gateway import quota
|
||||
|
||||
rules = quota.quota_rule_store.list()
|
||||
if not rules:
|
||||
print("No quota rules. Every caller is capped by the global defaults on /admin/services/openai.")
|
||||
return
|
||||
for rule in rules:
|
||||
spent = quota.spent_24h(rule["owner_kind"], rule["owner_id"], rule["app_reference"])
|
||||
scope = ", ".join(
|
||||
f"{key}={rule[key]}" for key in ("owner_kind", "owner_id", "app_reference") if rule[key]
|
||||
) or "(no dimensions - invalid)"
|
||||
limit = "unlimited" if rule["limit_usd"] == 0 else f"${rule['limit_usd']:.2f}/24h"
|
||||
active = "active" if rule["is_active"] else "inactive"
|
||||
label = f" - {rule['label']}" if rule["label"] else ""
|
||||
print(f"{rule['uid']} [{scope}] {limit} spent=${spent:.4f} {active}{label}")
|
||||
|
||||
|
||||
def cmd_gateway_quota_set(args):
|
||||
from pydantic import ValidationError
|
||||
from devplacepy.services.openai_gateway import quota
|
||||
|
||||
try:
|
||||
payload = quota.QuotaRuleIn(
|
||||
owner_kind=args.owner_kind,
|
||||
owner_id=args.owner_id,
|
||||
app_reference=args.app_reference,
|
||||
limit_usd=args.limit_usd,
|
||||
is_active=not args.inactive,
|
||||
label=args.label or "",
|
||||
)
|
||||
except ValidationError as exc:
|
||||
print(f"Invalid rule: {exc.errors()[0].get('msg', exc)}")
|
||||
sys.exit(1)
|
||||
saved = quota.quota_rule_store.set(payload, uid=args.uid, created_by="cli")
|
||||
_audit_cli(
|
||||
"gateway.quota_rule.update",
|
||||
f"CLI saved gateway quota rule {saved['uid']}",
|
||||
metadata={
|
||||
"owner_kind": saved["owner_kind"],
|
||||
"owner_id": saved["owner_id"],
|
||||
"app_reference": saved["app_reference"],
|
||||
"limit_usd": saved["limit_usd"],
|
||||
},
|
||||
target_type="gateway_quota_rule",
|
||||
target_uid=saved["uid"],
|
||||
)
|
||||
print(f"Saved quota rule {saved['uid']}")
|
||||
|
||||
|
||||
def cmd_gateway_quota_delete(args):
|
||||
from devplacepy.services.openai_gateway import quota
|
||||
|
||||
if not quota.quota_rule_store.remove(args.uid):
|
||||
print(f"Quota rule '{args.uid}' not found")
|
||||
sys.exit(1)
|
||||
_audit_cli(
|
||||
"gateway.quota_rule.delete",
|
||||
f"CLI deleted gateway quota rule {args.uid}",
|
||||
target_type="gateway_quota_rule",
|
||||
target_uid=args.uid,
|
||||
)
|
||||
print(f"Deleted quota rule {args.uid}")
|
||||
|
||||
|
||||
def register_gateway(subparsers):
|
||||
gateway = subparsers.add_parser("gateway", help="AI gateway management")
|
||||
gateway_sub = gateway.add_subparsers(title="action", dest="action")
|
||||
|
||||
quota = gateway_sub.add_parser("quota", help="Manage rolling-24h AI gateway quota rules")
|
||||
quota_sub = quota.add_subparsers(title="sub-action", dest="sub_action")
|
||||
|
||||
quota_list = quota_sub.add_parser("list", help="List all quota rules and their current 24h spend")
|
||||
quota_list.set_defaults(func=cmd_gateway_quota_list)
|
||||
|
||||
quota_set = quota_sub.add_parser(
|
||||
"set", help="Create or update a quota rule (scope by role/user/app, any combination)"
|
||||
)
|
||||
quota_set.add_argument("--uid", help="Existing rule uid to update; omit to create a new rule")
|
||||
quota_set.add_argument(
|
||||
"--owner-kind",
|
||||
choices=("internal", "key", "user", "admin", "anonymous"),
|
||||
help="Role to scope by. Omit for any role",
|
||||
)
|
||||
quota_set.add_argument("--owner-id", help="Specific user uid to scope by. Omit for any caller")
|
||||
quota_set.add_argument("--app-reference", help="App label to scope by. Omit for any app")
|
||||
quota_set.add_argument(
|
||||
"--limit-usd", type=float, required=True, help="Rolling 24h USD cap (0 = unlimited)"
|
||||
)
|
||||
quota_set.add_argument("--label", help="Optional admin-facing note")
|
||||
quota_set.add_argument("--inactive", action="store_true", help="Create the rule disabled")
|
||||
quota_set.set_defaults(func=cmd_gateway_quota_set)
|
||||
|
||||
quota_delete = quota_sub.add_parser("delete", help="Delete a quota rule by uid")
|
||||
quota_delete.add_argument("uid", help="Quota rule uid")
|
||||
quota_delete.set_defaults(func=cmd_gateway_quota_delete)
|
||||
+117
-2
@@ -7,12 +7,12 @@ from devplacepy.cli._shared import _audit_cli
|
||||
def _remove_zip_artifacts(job):
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from devplacepy.services.jobs.zip_service import STAGING_DIR
|
||||
from devplacepy.config import ZIP_STAGING_DIR
|
||||
|
||||
local_path = (job.get("result") or {}).get("local_path")
|
||||
if local_path:
|
||||
Path(local_path).unlink(missing_ok=True)
|
||||
shutil.rmtree(STAGING_DIR / job["uid"], ignore_errors=True)
|
||||
shutil.rmtree(ZIP_STAGING_DIR / job["uid"], ignore_errors=True)
|
||||
|
||||
|
||||
def cmd_zips_prune(args):
|
||||
@@ -210,6 +210,103 @@ def cmd_deepsearch_clear(args):
|
||||
print(f"Cleared {len(jobs)} DeepSearch job(s) and their collections")
|
||||
|
||||
|
||||
def cmd_isslop_prune(args):
|
||||
from datetime import datetime, timezone
|
||||
from devplacepy.services.jobs import queue
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
removed = 0
|
||||
for job in queue.list_jobs(kind="isslop", status=queue.DONE):
|
||||
expires_at = job.get("expires_at")
|
||||
if not expires_at:
|
||||
continue
|
||||
try:
|
||||
expiry = datetime.fromisoformat(expires_at)
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
if expiry < now:
|
||||
get_table("jobs").delete(uid=job["uid"])
|
||||
removed += 1
|
||||
_audit_cli("cli.isslop.prune", f"CLI pruned {removed} expired AI usage analysis jobs", metadata={"count": removed})
|
||||
print(f"Pruned {removed} expired AI usage analysis job(s) (reports persist)")
|
||||
|
||||
|
||||
def cmd_isslop_clear(args):
|
||||
from devplacepy.services.jobs import queue
|
||||
from devplacepy.services.jobs.isslop import store
|
||||
|
||||
jobs = queue.list_jobs(kind="isslop")
|
||||
for job in jobs:
|
||||
get_table("jobs").delete(uid=job["uid"])
|
||||
analyses = list(get_table(store.TABLE_ANALYSES).find())
|
||||
for analysis in analyses:
|
||||
store.purge_analysis(analysis["uid"])
|
||||
_audit_cli(
|
||||
"cli.isslop.clear",
|
||||
f"CLI cleared {len(analyses)} AI usage analyses and {len(jobs)} job rows",
|
||||
metadata={"analyses": len(analyses), "jobs": len(jobs)},
|
||||
)
|
||||
print(f"Cleared {len(analyses)} AI usage analysis(es), their reports and {len(jobs)} job row(s)")
|
||||
|
||||
|
||||
def cmd_isslop_analyze(args):
|
||||
import asyncio
|
||||
|
||||
from devplacepy.config import ISSLOP_WORKSPACES_DIR, ensure_data_dirs
|
||||
from devplacepy.database import INTERNAL_GATEWAY_URL, internal_gateway_key
|
||||
from devplacepy.models import IsslopRunForm
|
||||
from devplacepy.services.jobs.isslop import store
|
||||
from devplacepy.services.jobs.isslop.acquisition.workspace import remove_workspace, workspace_for
|
||||
from devplacepy.services.jobs.isslop.config import settings_from_payload
|
||||
from devplacepy.services.jobs.isslop.events import KIND_DONE, KIND_ERROR
|
||||
from devplacepy.services.jobs.isslop.persistence import EventPersister
|
||||
from devplacepy.services.jobs.isslop.pipeline import run_pipeline
|
||||
from devplacepy.utils import generate_uid
|
||||
|
||||
url = IsslopRunForm(url=args.url).url
|
||||
ensure_data_dirs()
|
||||
uid = generate_uid()
|
||||
settings = settings_from_payload(
|
||||
{
|
||||
"url": url,
|
||||
"llm_endpoint": INTERNAL_GATEWAY_URL,
|
||||
"api_key": internal_gateway_key(),
|
||||
"allow_private": bool(args.allow_private),
|
||||
"media_dir": str(store.media_dir_for(uid)),
|
||||
}
|
||||
)
|
||||
store.create_analysis(uid, url, "system", "cli")
|
||||
persister = EventPersister(uid)
|
||||
store.update_analysis(uid, status="running")
|
||||
workspace = workspace_for(ISSLOP_WORKSPACES_DIR, url, uid)
|
||||
|
||||
async def run() -> int:
|
||||
failed = False
|
||||
try:
|
||||
async for event in run_pipeline(url, workspace, settings):
|
||||
persister.apply(event)
|
||||
if args.json:
|
||||
print(event.to_json(), flush=True)
|
||||
else:
|
||||
print(f"[{event.kind}] {event.message}", flush=True)
|
||||
if event.kind == KIND_ERROR:
|
||||
failed = True
|
||||
if event.kind == KIND_DONE and not args.json:
|
||||
print(f"Report: /tools/isslop/{uid}/report")
|
||||
print(f"Badge: /tools/isslop/{uid}/badge.svg")
|
||||
finally:
|
||||
remove_workspace(workspace)
|
||||
return 1 if failed else 0
|
||||
|
||||
exit_code = asyncio.run(run())
|
||||
_audit_cli(
|
||||
"cli.isslop.analyze",
|
||||
f"CLI AI usage analysis of {url}",
|
||||
metadata={"uid": uid, "failed": bool(exit_code)},
|
||||
)
|
||||
raise SystemExit(exit_code)
|
||||
|
||||
|
||||
def register_jobs(subparsers):
|
||||
zips = subparsers.add_parser("zips", help="Zip archive job management")
|
||||
zips_sub = zips.add_subparsers(title="action", dest="action")
|
||||
@@ -265,3 +362,21 @@ def register_jobs(subparsers):
|
||||
"clear", help="Delete every DeepSearch session and job row"
|
||||
)
|
||||
deepsearch_clear.set_defaults(func=cmd_deepsearch_clear)
|
||||
|
||||
isslop = subparsers.add_parser("isslop", help="AI Usage Analyzer job management")
|
||||
isslop_sub = isslop.add_subparsers(title="action", dest="action")
|
||||
isslop_prune = isslop_sub.add_parser(
|
||||
"prune", help="Delete expired AI usage analysis job rows (analyses and reports persist)"
|
||||
)
|
||||
isslop_prune.set_defaults(func=cmd_isslop_prune)
|
||||
isslop_clear = isslop_sub.add_parser(
|
||||
"clear", help="Delete every AI usage analysis, its report and job rows"
|
||||
)
|
||||
isslop_clear.set_defaults(func=cmd_isslop_clear)
|
||||
isslop_analyze = isslop_sub.add_parser(
|
||||
"analyze", help="Run a AI usage analysis from the terminal and persist its report"
|
||||
)
|
||||
isslop_analyze.add_argument("url", help="Repository or website URL to classify")
|
||||
isslop_analyze.add_argument("--json", action="store_true", help="Emit raw JSON events")
|
||||
isslop_analyze.add_argument("--allow-private", action="store_true", dest="allow_private", help="Permit private and loopback hosts")
|
||||
isslop_analyze.set_defaults(func=cmd_isslop_analyze)
|
||||
|
||||
@@ -12,6 +12,10 @@ from devplacepy.cli.jobs import register_jobs
|
||||
from devplacepy.cli.backups import register_backups
|
||||
from devplacepy.cli.containers import register_containers
|
||||
from devplacepy.cli.migrate import register_migrate
|
||||
from devplacepy.cli.game import register_game
|
||||
from devplacepy.cli.quiz import register_quiz
|
||||
from devplacepy.cli.gateway import register_gateway
|
||||
from devplacepy.cli.messaging import register_messaging
|
||||
|
||||
|
||||
def build_parser():
|
||||
@@ -28,6 +32,10 @@ def build_parser():
|
||||
register_backups(sub)
|
||||
register_containers(sub)
|
||||
register_migrate(sub)
|
||||
register_game(sub)
|
||||
register_quiz(sub)
|
||||
register_gateway(sub)
|
||||
register_messaging(sub)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from devplacepy.cli._shared import _audit_cli
|
||||
|
||||
|
||||
def cmd_messaging_prune_tickets(args):
|
||||
from datetime import datetime, timezone
|
||||
from devplacepy.database import get_table
|
||||
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
tickets = get_table("ws_tickets")
|
||||
expired = list(tickets.find(expires_at={"<": now}))
|
||||
for ticket in expired:
|
||||
tickets.delete(uid=ticket["uid"])
|
||||
_audit_cli(
|
||||
"cli.messaging.prune_tickets",
|
||||
f"CLI pruned {len(expired)} expired WS tickets",
|
||||
metadata={"count": len(expired)},
|
||||
)
|
||||
print(f"Pruned {len(expired)} expired WS ticket(s)")
|
||||
|
||||
|
||||
def register_messaging(subparsers):
|
||||
messaging = subparsers.add_parser("messaging", help="Messaging WS ticket management")
|
||||
messaging_sub = messaging.add_subparsers(title="action", dest="action")
|
||||
messaging_prune_tickets = messaging_sub.add_parser(
|
||||
"prune-tickets", help="Delete expired WebSocket auth tickets"
|
||||
)
|
||||
messaging_prune_tickets.set_defaults(func=cmd_messaging_prune_tickets)
|
||||
@@ -0,0 +1,31 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from devplacepy.cli._shared import _audit_cli
|
||||
|
||||
|
||||
def cmd_quiz_prune(args):
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from devplacepy.config import QUIZ_ATTEMPT_RETENTION_DAYS
|
||||
from devplacepy.services.quiz import store
|
||||
|
||||
cutoff = (
|
||||
datetime.now(timezone.utc) - timedelta(days=QUIZ_ATTEMPT_RETENTION_DAYS)
|
||||
).isoformat()
|
||||
removed = store.prune_attempts(cutoff)
|
||||
_audit_cli(
|
||||
"cli.quiz.prune",
|
||||
f"CLI pruned {removed} abandoned quiz attempt(s)",
|
||||
metadata={"count": removed, "retention_days": QUIZ_ATTEMPT_RETENTION_DAYS},
|
||||
)
|
||||
print(f"Pruned {removed} abandoned or expired quiz attempt(s)")
|
||||
|
||||
|
||||
def register_quiz(subparsers):
|
||||
quiz = subparsers.add_parser("quiz", help="Quiz management")
|
||||
quiz_sub = quiz.add_subparsers(title="action", dest="action")
|
||||
prune = quiz_sub.add_parser(
|
||||
"prune",
|
||||
help="Delete abandoned and expired attempts older than the retention window",
|
||||
)
|
||||
prune.set_defaults(func=cmd_quiz_prune)
|
||||
@@ -26,6 +26,10 @@ PLANNING_REPORTS_DIR = DATA_DIR / "planning_reports"
|
||||
DBAPI_DIR = DATA_DIR / "dbapi"
|
||||
DEEPSEARCH_DIR = DATA_DIR / "deepsearch"
|
||||
DEEPSEARCH_CHROMA_DIR = DEEPSEARCH_DIR / "chroma"
|
||||
ISSLOP_DIR = DATA_DIR / "isslop"
|
||||
ISSLOP_WORKSPACES_DIR = ISSLOP_DIR / "workspaces"
|
||||
ISSLOP_RUNS_DIR = ISSLOP_DIR / "runs"
|
||||
ISSLOP_MEDIA_DIR = ISSLOP_DIR / "media"
|
||||
KEYS_DIR = DATA_DIR / "keys"
|
||||
BOT_DIR = DATA_DIR / "bot"
|
||||
LOCKS_DIR = DATA_DIR / "locks"
|
||||
@@ -43,6 +47,13 @@ SESSION_MAX_AGE_REMEMBER = SECONDS_PER_DAY * 30
|
||||
PORT = 10500
|
||||
SITE_URL = environ.get("DEVPLACE_SITE_URL", "").rstrip("/")
|
||||
|
||||
PRESENCE_TIMEOUT_SECONDS = int(environ.get("DEVPLACE_PRESENCE_TIMEOUT_SECONDS", "60"))
|
||||
PRESENCE_WRITE_SECONDS = max(1, PRESENCE_TIMEOUT_SECONDS // 2)
|
||||
PRESENCE_ONLINE_LIMIT = int(environ.get("DEVPLACE_PRESENCE_ONLINE_LIMIT", "30"))
|
||||
PRESENCE_ONLINE_MARGIN_SECONDS = int(
|
||||
environ.get("DEVPLACE_PRESENCE_ONLINE_MARGIN_SECONDS", "20")
|
||||
)
|
||||
|
||||
XMLRPC_BIND = environ.get("DEVPLACE_XMLRPC_BIND", "127.0.0.1")
|
||||
XMLRPC_PORT = int(environ.get("DEVPLACE_XMLRPC_PORT", "10550"))
|
||||
|
||||
@@ -57,6 +68,33 @@ INTERNAL_GATEWAY_URL = f"{INTERNAL_BASE_URL}/openai/v1/chat/completions"
|
||||
INTERNAL_EMBED_URL = f"{INTERNAL_BASE_URL}/openai/v1/embeddings"
|
||||
INTERNAL_MODEL = "molodetz"
|
||||
INTERNAL_EMBED_MODEL = "molodetz~embed"
|
||||
INTERNAL_IMAGE_MODEL = "molodetz-img-small"
|
||||
|
||||
AWARD_GIVE_COOLDOWN_HOURS_DEFAULT = 24
|
||||
AWARD_RECEIVE_COOLDOWN_HOURS_DEFAULT = 24
|
||||
AWARD_DISPLAY_HOURS_DEFAULT = 24
|
||||
AWARD_DESCRIPTION_MAX = 125
|
||||
AWARD_IMAGE_MODEL_DEFAULT = "molodetz-img-small"
|
||||
AWARD_IMAGE_SIZE_DEFAULT = "512x512"
|
||||
AWARD_GENERATION_TIMEOUT_SECONDS = 120.0
|
||||
AWARD_IMAGE_PROMPT_DEFAULT = (
|
||||
"Generate a single decorative developer award emblem/badge as a PNG with a fully "
|
||||
"transparent background (alpha channel). No rectangular backdrop, no drop shadow "
|
||||
"plate, no text labels rendered in the image. Center one stylized trophy/medal "
|
||||
"icon that visually matches this message:"
|
||||
)
|
||||
QUIZ_ANSWER_MAX_CHARS = 2000
|
||||
QUIZ_FEEDBACK_MAX_CHARS = 400
|
||||
QUIZ_MAX_QUESTIONS = 100
|
||||
QUIZ_MAX_OPTIONS = 12
|
||||
QUIZ_MAX_TIME_LIMIT_SECONDS = 86400
|
||||
QUIZ_AI_CORRECT_THRESHOLD = 0.5
|
||||
QUIZ_GRADING_TIMEOUT_SECONDS = 45.0
|
||||
QUIZ_ATTEMPT_RETENTION_DAYS = 90
|
||||
QUIZ_SCOREBOARD_LIMIT = 20
|
||||
QUIZ_SCOREBOARD_CACHE_SECONDS = 15
|
||||
QUIZ_LIST_PER_PAGE = 20
|
||||
|
||||
DEFAULT_CORRECTION_PROMPT = "Leave literary as is, only do punctuation and casing"
|
||||
DEFAULT_MODIFIER_PROMPT = (
|
||||
"Execute what is behind `@ai` (the prompt) and replace that part including `@ai`"
|
||||
@@ -89,6 +127,10 @@ DATA_PATHS: dict[str, Path] = {
|
||||
"dbapi": DBAPI_DIR,
|
||||
"deepsearch": DEEPSEARCH_DIR,
|
||||
"deepsearch_chroma": DEEPSEARCH_CHROMA_DIR,
|
||||
"isslop": ISSLOP_DIR,
|
||||
"isslop_workspaces": ISSLOP_WORKSPACES_DIR,
|
||||
"isslop_runs": ISSLOP_RUNS_DIR,
|
||||
"isslop_media": ISSLOP_MEDIA_DIR,
|
||||
"keys": KEYS_DIR,
|
||||
"bot": BOT_DIR,
|
||||
"locks": LOCKS_DIR,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
TOPICS = ["devlog", "showcase", "question", "rant", "fun", "random", "signals"]
|
||||
TOPICS = ["devlog", "showcase", "question", "rant", "fun", "random", "politics"]
|
||||
|
||||
REACTION_EMOJI = [
|
||||
"\U0001f44d",
|
||||
|
||||
+69
-5
@@ -13,12 +13,15 @@ from devplacepy.database import (
|
||||
resolve_by_slug,
|
||||
get_users_by_uids,
|
||||
get_vote_counts,
|
||||
STAR_TARGETS,
|
||||
get_user_votes,
|
||||
get_reactions_by_targets,
|
||||
get_user_bookmarks,
|
||||
get_blocked_uids,
|
||||
get_poll_for_post,
|
||||
update_target_stars,
|
||||
clear_user_stars,
|
||||
clear_user_post_count,
|
||||
get_target_owner_uid,
|
||||
resolve_object_url,
|
||||
soft_delete,
|
||||
@@ -38,6 +41,7 @@ from devplacepy.utils import (
|
||||
create_notification,
|
||||
create_mention_notifications,
|
||||
is_admin,
|
||||
is_primary_admin,
|
||||
XP_COMMENT,
|
||||
XP_UPVOTE,
|
||||
)
|
||||
@@ -48,8 +52,8 @@ from devplacepy.services.seo_meta import schedule_seo_meta_for_table
|
||||
|
||||
CREATE_METADATA_KEYS = ("project_type", "is_private", "language", "topic", "status")
|
||||
|
||||
BOOKMARKABLE_TYPES = {"post", "gist", "project", "news"}
|
||||
REACTABLE_TYPES = {"post", "comment", "gist", "project"}
|
||||
BOOKMARKABLE_TYPES = {"post", "gist", "project", "news", "quiz"}
|
||||
REACTABLE_TYPES = {"post", "comment", "gist", "project", "quiz"}
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -76,6 +80,45 @@ def can_view_project(project: dict | None, user: dict | None) -> bool:
|
||||
return not _owner_is_admin(project)
|
||||
|
||||
|
||||
def owns_instance(
|
||||
instance: dict | None, project: dict | None, user: dict | None
|
||||
) -> bool:
|
||||
if not instance or not user:
|
||||
return False
|
||||
uid = user.get("uid")
|
||||
if not uid:
|
||||
return False
|
||||
if instance.get("created_by") == uid:
|
||||
return True
|
||||
return bool(project and project.get("user_uid") == uid)
|
||||
|
||||
|
||||
def can_view_project_containers(project: dict | None, user: dict | None) -> bool:
|
||||
if not project or not is_admin(user):
|
||||
return False
|
||||
if is_primary_admin(user) or is_owner(project, user):
|
||||
return True
|
||||
return not project.get("is_private")
|
||||
|
||||
|
||||
def can_view_instance(
|
||||
instance: dict | None, project: dict | None, user: dict | None
|
||||
) -> bool:
|
||||
if not instance or not is_admin(user):
|
||||
return False
|
||||
if is_primary_admin(user) or owns_instance(instance, project, user):
|
||||
return True
|
||||
return bool(project) and not project.get("is_private")
|
||||
|
||||
|
||||
def can_manage_instance(
|
||||
instance: dict | None, project: dict | None, user: dict | None
|
||||
) -> bool:
|
||||
if not instance or not is_admin(user):
|
||||
return False
|
||||
return is_primary_admin(user) or owns_instance(instance, project, user)
|
||||
|
||||
|
||||
def canonical_redirect(
|
||||
area: str, item: dict, requested: str
|
||||
) -> RedirectResponse | None:
|
||||
@@ -121,6 +164,12 @@ def create_content_item(
|
||||
**fields,
|
||||
}
|
||||
)
|
||||
if table_name == "posts":
|
||||
clear_user_post_count(user["uid"])
|
||||
if table_name == "projects":
|
||||
from devplacepy.templating import clear_user_projects_cache
|
||||
|
||||
clear_user_projects_cache(user["uid"])
|
||||
award_rewards(user["uid"], xp, badge)
|
||||
if attachment_uids:
|
||||
link_attachments(attachment_uids, target_type, uid)
|
||||
@@ -152,7 +201,7 @@ def create_content_item(
|
||||
return uid, slug
|
||||
|
||||
|
||||
VOTE_NOTIFY_TYPES = {"post", "comment", "gist", "project"}
|
||||
VOTE_NOTIFY_TYPES = {"post", "comment", "gist", "project", "quiz"}
|
||||
|
||||
|
||||
def apply_vote(request, user: dict, target_type: str, target_uid: str, value: int) -> dict:
|
||||
@@ -209,6 +258,8 @@ def apply_vote(request, user: dict, target_type: str, target_uid: str, value: in
|
||||
update_target_stars(target_type, target_uid, net)
|
||||
|
||||
owner_uid = get_target_owner_uid(target_type, target_uid)
|
||||
if owner_uid:
|
||||
clear_user_stars(owner_uid)
|
||||
direction = "clear" if new_value == 0 else ("up" if new_value == 1 else "down")
|
||||
vote_links = [audit.target(target_type, target_uid)]
|
||||
if owner_uid and owner_uid != user["uid"]:
|
||||
@@ -568,11 +619,20 @@ def delete_content_item(
|
||||
soft_delete_engagement(target_type, [item["uid"]], actor)
|
||||
if comment_uids:
|
||||
soft_delete_engagement("comment", comment_uids, actor)
|
||||
if target_type == "post":
|
||||
clear_user_post_count(item["user_uid"])
|
||||
if target_type == "quiz":
|
||||
from devplacepy.services.quiz.store import cascade_questions, clear_cache
|
||||
|
||||
cascade_questions(item["uid"], actor, stamp)
|
||||
clear_cache()
|
||||
if target_type == "project":
|
||||
from devplacepy.project_files import soft_delete_all_project_files
|
||||
from devplacepy.templating import clear_user_projects_cache
|
||||
|
||||
soft_delete_all_project_files(item["uid"], actor)
|
||||
soft_delete_fork_relations(item["uid"], actor)
|
||||
clear_user_projects_cache(item["user_uid"])
|
||||
soft_delete(table_name, actor, stamp=stamp, uid=item["uid"])
|
||||
logger.info(f"{table_name} {item['uid']} soft-deleted by {user['username']}")
|
||||
audit.record(
|
||||
@@ -598,7 +658,11 @@ def load_detail(
|
||||
if user and item["user_uid"] in get_blocked_uids(user["uid"]):
|
||||
return None
|
||||
author = get_users_by_uids([item["user_uid"]]).get(item["user_uid"])
|
||||
ups, downs = get_vote_counts([item["uid"]])
|
||||
if target_type in STAR_TARGETS:
|
||||
star_count = item.get("stars") or 0
|
||||
else:
|
||||
ups, downs = get_vote_counts([item["uid"]])
|
||||
star_count = ups.get(item["uid"], 0) - downs.get(item["uid"], 0)
|
||||
reactions = (
|
||||
get_reactions_by_targets(target_type, [item["uid"]], user).get(
|
||||
item["uid"], {"counts": {}, "mine": []}
|
||||
@@ -615,7 +679,7 @@ def load_detail(
|
||||
"item": item,
|
||||
"author": author,
|
||||
"is_owner": bool(user and user["uid"] == item["user_uid"]),
|
||||
"star_count": ups.get(item["uid"], 0) - downs.get(item["uid"], 0),
|
||||
"star_count": star_count,
|
||||
"my_vote": get_user_votes(user["uid"], [item["uid"]]).get(item["uid"], 0)
|
||||
if user
|
||||
else 0,
|
||||
|
||||
@@ -73,10 +73,17 @@ class CurlResponseStream(httpx.AsyncByteStream):
|
||||
|
||||
|
||||
class CurlTransport(httpx.AsyncBaseTransport):
|
||||
def __init__(self, *, impersonate: str = IMPERSONATE_TARGET, verify: bool = True) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
impersonate: str = IMPERSONATE_TARGET,
|
||||
verify: bool = True,
|
||||
proxy: str | None = None,
|
||||
) -> None:
|
||||
self._session = AsyncSession()
|
||||
self._impersonate = impersonate
|
||||
self._verify = verify
|
||||
self._proxy = proxy
|
||||
|
||||
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
|
||||
headers = {
|
||||
@@ -97,6 +104,7 @@ class CurlTransport(httpx.AsyncBaseTransport):
|
||||
data=body or None,
|
||||
impersonate=self._impersonate,
|
||||
verify=self._verify,
|
||||
proxy=self._proxy,
|
||||
stream=True,
|
||||
allow_redirects=False,
|
||||
timeout=resolve_timeout(request),
|
||||
|
||||
@@ -36,6 +36,18 @@ def owner_for(request: Request) -> tuple[str, str] | None:
|
||||
|
||||
|
||||
def _overrides_for(request: Request) -> dict:
|
||||
cached = getattr(request.state, "_custom_overrides", None)
|
||||
if cached is not None:
|
||||
return cached
|
||||
overrides = _resolve_overrides(request)
|
||||
try:
|
||||
request.state._custom_overrides = overrides
|
||||
except Exception:
|
||||
pass
|
||||
return overrides
|
||||
|
||||
|
||||
def _resolve_overrides(request: Request) -> dict:
|
||||
if get_setting("customization_enabled", "1") != "1":
|
||||
return {"css": "", "js": ""}
|
||||
owner = owner_for(request)
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
This file documents devplacepy/database/ - the dataset/SQLite data layer, indexing rules, and the project-wide soft-delete model. Claude Code loads it automatically whenever a file under this directory is read or edited.
|
||||
|
||||
## Database engine and dataset library
|
||||
|
||||
SQLite via `dataset` with these pragmas on every connection:
|
||||
|
||||
```python
|
||||
PRAGMA journal_mode=WAL; -- concurrent readers + writers
|
||||
PRAGMA synchronous=NORMAL; -- safe with WAL mode
|
||||
PRAGMA busy_timeout=30000; -- wait 30s instead of failing on lock
|
||||
PRAGMA cache_size=-8000; -- 8MB page cache
|
||||
PRAGMA temp_store=MEMORY; -- temp tables in memory
|
||||
```
|
||||
|
||||
Configured via `dataset.connect(engine_kwargs={"connect_args": {"timeout": 30, "check_same_thread": False}}, on_connect_statements=[...])`. In addition to the pragmas above, the connection is tuned with a 30s busy timeout, an 8MB page cache, and a 256MB mmap.
|
||||
|
||||
`init_db()` is idempotent - every index is created via `CREATE INDEX IF NOT EXISTS` (through the `_index()` helper, wrapped in try/except so it is safe to run on every startup regardless of table state), and seed defaults for `site_settings` only insert when the key doesn't already exist.
|
||||
|
||||
**`init_db()` also ensures the full column set of any table that code filters on** (`news`, `news_sync`, `attachments` use `get_table(name)` + `create_column_by_example` before their indexes). This is mandatory, not optional: `dataset` gives a lazily-created table ONLY the columns of its first insert, so a partial insert from a CLI tool/test/maintenance script would otherwise create the table with a reduced schema, and the long-running server caches that stale schema - making later queries on the missing column 500 with `no such column`. When adding a new filtered/indexed column, add it to the matching `init_db()` ensure-block:
|
||||
|
||||
```python
|
||||
news = get_table("news")
|
||||
for column, example in (("uid", ""), ("status", ""), ("synced_at", ""), ("external_id", ""), ...):
|
||||
if not news.has_column(column):
|
||||
news.create_column_by_example(column, example)
|
||||
_index(db, "news", "idx_news_status", ["status"]) # now safe - column exists
|
||||
```
|
||||
|
||||
Do this with `get_table(name)` (NOT `if name in db.tables`): `get_table` + `create_column_by_example` creates the table if it is absent, so the schema is guaranteed regardless of which process or which insert touches the table first. Symptom to watch for: a page renders fine in isolation but shows empty data or 500s only after a CLI/maintenance test runs earlier in the suite (it created the table partially).
|
||||
|
||||
The `_index(...)` helper supports `where=` (partial) and `unique=` indexes; every table with a `uid` column gets a UNIQUE `idx_<table>_uid`, soft-delete tables get a PARTIAL `idx_<table>_trash` (`WHERE deleted_at IS NOT NULL`) and NEVER a bare `deleted_at` index (it mis-steers the planner on live reads), and "live newest-first" listings need a composite/live-partial index that carries the sort column (see "Indexing conventions" below). `init_db()` finishes with `ANALYZE`/`PRAGMA optimize`. Verify any index change with `EXPLAIN QUERY PLAN`.
|
||||
|
||||
**SQLite is synchronous by design and will never be made async (hard rule).** The `dataset`/SQLite layer is synchronous and is called directly inside the async route handlers. This is intentional and more than fast enough for this platform: the database is a local file tuned with WAL, `synchronous=NORMAL`, a 30s busy timeout, an 8MB page cache, and a 256MB mmap, so queries are sub-millisecond and never meaningfully stall the event loop. Do NOT convert the database layer to async, do NOT wrap DB calls in a threadpool/`run_in_executor`/`to_thread`, and do NOT raise the synchronous DB access as a performance, blocking-I/O, or "requests aren't fully async" concern. This is a settled, sophisticated design choice and is not open for revisiting.
|
||||
|
||||
Runtime config lives in `site_settings`, read via `get_setting(key, default)` / `get_int_setting(key, default)` (60s TTL cache, invalidated cross-worker via the `cache_state` version table - `get_setting` calls `sync_local_cache("settings", ...)`, writes call `bump_cache_version("settings")`; the `_user_cache` in `utils.py` uses the same primitive under the `auth` name). Consumers always pass the production default to `get_setting`, so behavior is correct even before the row exists. Numeric operational values are floored at the call site so an invalid `0` can't lock out writes or stall a service. Booleans are stored as `"0"`/`"1"` and rendered as `<select>` (not checkboxes) because the settings save handler skips empty form values - an unchecked checkbox could never be turned off. See "Site settings" and "Operational settings" below for the full key registry.
|
||||
|
||||
Batch helpers (`get_users_by_uids`, `get_comment_counts_by_post_uids`, `get_vote_counts`, `load_comments`, `get_attachments_by_type`) exist specifically to avoid N+1 queries - use them in feed/listing routes instead of per-row lookups.
|
||||
|
||||
## Dataset rules (hard-learned)
|
||||
|
||||
**`find()` does NOT accept raw SQL strings.** It takes keyword arguments for equality filters, dict comparison operators, or SQLAlchemy column expressions.
|
||||
|
||||
```python
|
||||
# WRONG - causes 500 Internal Server Error:
|
||||
table.find("created_at >= :start", {"start": today})
|
||||
table.find(text("created_at >= :start"), start=today)
|
||||
|
||||
# CORRECT - dict comparison syntax:
|
||||
table.find(created_at={">=": today})
|
||||
|
||||
# CORRECT - keyword equality:
|
||||
table.find(country="France")
|
||||
|
||||
# CORRECT - SQLAlchemy column expression for IN clause:
|
||||
table.find(table.table.columns.user_uid.in_(["uid1", "uid2"]))
|
||||
|
||||
# CORRECT - multiple equality filters combined:
|
||||
table.find(topic="devlog", user_uid=some_uid)
|
||||
```
|
||||
|
||||
**`update()` requires a key column list as second argument.** The first dict contains all fields including the key column.
|
||||
|
||||
```python
|
||||
table.update({"uid": user_uid, "bio": "new bio"}, ["uid"])
|
||||
```
|
||||
|
||||
**`db.query()` accepts raw SQL with named params as keyword arguments:**
|
||||
|
||||
```python
|
||||
db.query("SELECT * FROM posts WHERE topic = :t", t="devlog")
|
||||
# NOT: db.query("...", {"t": "devlog"})
|
||||
```
|
||||
|
||||
**`db.query()` WRITES DO NOT AUTO-COMMIT - wrap any `db.query` INSERT/UPDATE/DELETE in `with db:` (load-bearing, caused a production deadlock).** The dataset table API (`table.insert`/`update`/`delete`) calls `db._auto_commit()` internally, but `db.query()` does NOT. SQLAlchemy 2.x autobegins a transaction on first `execute`, so a raw `db.query` write leaves an open transaction holding the SQLite write lock until that thread's connection next commits. On the request/loop thread this is masked (the next table op's `_auto_commit` flushes it), but on a **background-queue or `run_in_executor` worker thread** the thread goes idle still holding the lock, and EVERY subsequent write app-wide blocks for the 30s busy-timeout then fails `database is locked` - a full deadlock. Always commit raw writes:
|
||||
|
||||
```python
|
||||
with db: # commits + releases the write lock on exit
|
||||
db.query("INSERT INTO t (...) VALUES (:a) ON CONFLICT(...) DO UPDATE SET ...", a=1)
|
||||
```
|
||||
|
||||
Atomic counters (e.g. `add_correction_usage`) must use raw `ON CONFLICT DO UPDATE SET col = col + excluded.col` (the table API cannot increment), so they MUST use the `with db:` wrapper. Prefer the table API whenever an atomic SQL increment is not required.
|
||||
|
||||
**Always check `tables` list before raw SQL queries:**
|
||||
|
||||
```python
|
||||
if "comments" not in db.tables:
|
||||
return {} # table doesn't exist yet
|
||||
```
|
||||
|
||||
**Batch queries eliminate N+1 problems.** Use `get_users_by_uids()`, `get_comment_counts_by_post_uids()`, and `get_vote_counts()` from `database.py` instead of per-row lookups in loops.
|
||||
|
||||
**`init_db()` MUST create every queried column for any table that code filters on, even if the table is created lazily.** dataset creates a table on its FIRST insert and gives it ONLY the columns in that insert. If any code path can insert a *partial* row before the full schema exists (a CLI tool, a test fixture, a maintenance script), the table is born with a reduced schema and every later query against a missing column throws `sqlite3.OperationalError: no such column: X` (a 500), or - for an indexed column - logs a `Could not create index ... no such column` warning at startup. This is worsened by **cross-process metadata staleness**: the long-running uvicorn server reflects a table's columns once and caches them, so a column another process adds afterward is invisible to the server until it reconnects. The defence is to make `init_db()` ensure the complete column set up front, exactly like the existing `news`, `news_sync`, and `attachments` blocks (see the code example under "Database engine and dataset library" above). When you add a NEW column that any query filters/indexes, add it to the `init_db()` ensure-block too - never rely on the first insert to define it.
|
||||
|
||||
## Indexing conventions (the soft-delete planner trap)
|
||||
|
||||
`init_db()` owns every index. The `_index(db, table, name, columns, *, where=None, unique=False)` helper builds the DDL; it supports **partial** indexes (`where=`) and **unique** indexes, and wraps each `CREATE`/`DROP` in `with db:` (DDL via `db.query` does not auto-commit - see "Dataset rules" above). Three load-bearing rules learned from an `EXPLAIN QUERY PLAN` audit against production data:
|
||||
|
||||
- **Every table with a `uid` column gets `idx_<table>_uid` (UNIQUE).** `dataset` makes its own `id` autoincrement PK and does NOT key `uid`, so `find_one(uid=...)`, `resolve_by_slug`, `soft_delete`, and `table.update({...}, ["uid"])` full-SCAN without it. `init_db()` loops `for table in db.tables: _uid_index(db, table)` (falls back to a non-unique index if a UNIQUE build ever fails on legacy duplicate data). New tables are covered automatically.
|
||||
|
||||
- **NEVER index the bare `deleted_at` column - use a PARTIAL trash index `WHERE deleted_at IS NOT NULL`.** `ensure_soft_delete_columns` creates `idx_<table>_trash ON (deleted_at) WHERE deleted_at IS NOT NULL` (and drops any legacy full `idx_<table>_deleted`). A full `deleted_at` index is a planner hazard: the column is one giant `NULL` bucket plus many unique delete-timestamps, so `sqlite_stat1` mis-estimates `deleted_at IS NULL` as returning ~2 rows and the planner picks that index for live reads, then `USE TEMP B-TREE FOR ORDER BY` to sort the whole live set (the global feed was doing exactly this, with 82% of posts soft-deleted). The partial index serves the admin Trash view (`deleted_at IS NOT NULL`) cheaply and stops poisoning live `IS NULL` queries.
|
||||
|
||||
- **For "live, newest-first" listings add a composite or live-partial index that includes the sort column.** A `WHERE deleted_at IS NULL ORDER BY created_at` query needs the ordering in the index or it filesorts. Posts use a partial `idx_posts_live_created ON (created_at) WHERE deleted_at IS NULL` (feed) plus `idx_posts_user_created (user_uid, created_at)` (profile); comments use `idx_comments_target_created (target_type, target_uid, created_at)`; votes use `idx_votes_user_target (user_uid, target_uid)` (the per-user "my_vote" check on every card); notifications/gists/projects use `(user_uid, created_at)`; follows use `idx_follows_follower_created (follower_uid, created_at)` + `idx_follows_following_created (following_uid, created_at)` (the followers/following tabs sort newest-first; the legacy single-column follower/following indexes were dropped as redundant prefixes). All were verified to drop the `USE TEMP B-TREE FOR ORDER BY` step.
|
||||
|
||||
- **Index the non-`uid` lookup keys too, not just the sort/owner columns.** A demand-vs-supply audit added the last missing single-key lookups: the `resolve_by_slug` hot path filters `slug` on content detail pages, so posts/gists/news/projects each get `idx_<table>_slug (slug)`; `get_setting`/`set_setting` filter `key`, so `idx_site_settings_key (key)`; the container store's `find_one(slug=)`/`find_one(name=)` fallbacks get `idx_instances_slug`/`idx_instances_name`. The DM thread load `find(sender_uid=, receiver_uid=)` gets the covering composites `idx_messages_conversation (sender_uid, receiver_uid)` + `idx_messages_conversation_rev (receiver_uid, sender_uid)` (the read-flag `UPDATE` uses the reverse); the badge-has check gets `idx_badges_user_name (user_uid, badge_name)`; the admin user list `ORDER BY -created_at` gets `idx_users_created_at (created_at)` (the existing `(role, created_at)` cannot serve a full-table created_at sort). All are non-unique so `_index` always creates them even if legacy duplicate data exists. Column sets already resolved to ~1 row by an existing prefix index (votes `+target_type`, game_quests `+kind`, poll_options `position`) are intentionally left uncovered - a trailing column there only adds write cost.
|
||||
|
||||
`init_db()` ends with `_refresh_query_planner_stats()`: a one-time `ANALYZE` when `sqlite_stat1` is absent, then `PRAGMA optimize` every boot (both inside `with db:`). Validate any index change with `EXPLAIN QUERY PLAN` against `data/devplace.db` (or a copy) - a correct plan reads `SEARCH ... USING INDEX <name>` with no temp b-tree.
|
||||
|
||||
## Project-wide soft delete (hard rule)
|
||||
|
||||
Attachments (see "Profile media gallery and soft-deleted attachments" below) are one instance of a platform-wide model: **every removal is a soft delete; only garbage collection is a hard delete.** Every removable row carries two columns, `deleted_at` (ISO timestamp) and `deleted_by` (actor uid, or `system`). A live row has `deleted_at = NULL`, and every list/count read filters `deleted_at IS NULL`.
|
||||
|
||||
- **Tables (`database.SOFT_DELETE_TABLES`):** posts, comments, gists, projects, news, news_images, project_files, attachments, votes, reactions, bookmarks, follows, poll_votes, polls, poll_options, sessions, instances, instance_schedules, devii_conversations, devii_tasks, devii_lessons, devii_virtual_tools, user_customizations, project_forks. `init_db` loops `ensure_soft_delete_columns(t)` for each (adds both columns + `idx_<t>_deleted` / the partial trash index described above). The Devii task/lesson/virtual-tool stores run on their own DB handles, so their `_ensure_indexes` adds the columns there too.
|
||||
- **Every INSERT into a soft-deletable table MUST write `deleted_at: None, deleted_by: None`.** `dataset.find(deleted_at=None)` on a table that lacks the column matches NOTHING (a false predicate), silently hiding all rows - the born-live insert is what creates the column. Add the pair to any new insert.
|
||||
- **Central helpers (`database/`):** `soft_delete(table, deleted_by, *, stamp=None, **criteria)` (equality), `soft_delete_in(table, column, uids, deleted_by, *, stamp=None, **extra)` (IN-clause cascade), `restore(table, **criteria)`, `purge(table, **criteria)` (real delete), `list_deleted(table, page)` / `count_deleted(table)` (trash listings), and the event helpers `restore_event(stamp)` / `purge_event(stamp)` that act across ALL tables sharing one `deleted_at` stamp.
|
||||
- **Two generic chokepoints are conditionally filtered:** `resolve_by_slug(table, slug, include_deleted=False)` (detail-page lookups; restore passes `include_deleted=True`) and `paginate(table, ...)` (auto-appends `deleted_at IS NULL` when the table has the column and the caller did not pass `deleted_at`). `seo._collect` does the same for the sitemap. Read filters were threaded through every batch helper, analytics/activity/leaderboard UNION, feed/profile/listing route, and store; never re-introduce an unfiltered read of a soft-deletable table.
|
||||
- **Any new read** (find/count/query) of a soft-deletable table MUST filter `deleted_at IS NULL`. Use the central helpers/chokepoints instead of inline deletes.
|
||||
- **Toggles revive, they do not duplicate.** votes/reactions/bookmarks/follows/poll_votes look up the physical row regardless of `deleted_at`: toggle-off stamps `deleted_at`; re-toggle clears it on the same row. Counts/state reads filter `deleted_at IS NULL`.
|
||||
- **Cascades share one stamp.** `content.delete_content_item` soft-deletes the item plus its comments, votes, engagement, project files, fork relations, and attachments with one shared `stamp` and `deleted_by = actor`. That timestamp identifies the whole event, so `restore_event`/`purge_event` reverse or finalize it atomically.
|
||||
- **Delete authorization is owner-OR-admin, enforced on the endpoint** (`is_owner(...) or is_admin(user)`): posts/gists/projects (`content.delete_content_item`, also rejects a missing item), `comments.delete_comment`, `project_files.project_file_delete`, `media.delete_media`, `uploads.delete_attachment_route`; news (`admin_news_delete`) is admin-only. Because the check is on the endpoint, one rule covers the human UI and **Devii** at once - Devii only ever calls the platform API, authenticated as the signed-in user, so an admin's Devii may soft-delete any member's content and a member's is refused with no agent-side logic. The matching `delete_*` Devii catalog tools stay `requires_auth` (not `requires_admin`) so a member can still delete their own, and every one is in the dispatcher's confirmation gate (`CONFIRM_REQUIRED`) so a delete only runs on a repeat call with `confirm=true`. Standalone `comment` and uploaded-`attachment` deletes are soft like the rest (`soft_delete` cascade / `soft_delete_attachment`); the only attachment hard delete is the admin `/admin/media/{uid}/purge` and the CLI prune. Any NEW content delete path must reuse this guard, soft-delete, and (for the Devii tool) be added to `dispatcher.CONFIRM_REQUIRED`.
|
||||
- **What stays HARD (GC / the empty-trash stage):** the async-job sweep + CLI prune/clear, the container metrics ring trim, gateway and Devii usage-ledger retention prunes and quota resets, the expired-session cleanup branch in `utils._user_from_session`, fork-rollback of a half-created project, the news-sync image replacement, and the admin **Purge** action. Logout is a soft delete (auditable via `deleted_by`); only expiry GC is hard.
|
||||
- **Admin Trash surface:** `/admin/trash` (sidebar **Trash**, `routers/admin/` package, `admin_trash.html`, `AdminTrashOut`) lists soft-deleted rows per table with restore/purge per row. Restore calls `restore_event(row.deleted_at)`; Purge calls `purge_event(...)` and unlinks attachment files / project-file blobs. The attachment-specific `/admin/media` view is unchanged. Admin-only docs: `docs/soft-delete.html` (`admin: True`, Administration section).
|
||||
|
||||
## Profile media gallery and soft-deleted attachments
|
||||
|
||||
The profile **Media** tab (`/profile/{username}?tab=media`, public) is a paginated grid of every attachment a user uploaded, newest first, across all `target_type`s. Attachments support a **soft delete** so a user can remove one upload without affecting its parent object.
|
||||
|
||||
- **One `deleted_at` column** on `attachments` (ISO string, mirrors the `push.py` precedent), ensured idempotently in `init_db` via `create_column_by_example("deleted_at", "")` plus the `idx_attachments_user_created` index. `store_attachment` writes `deleted_at: None`.
|
||||
- **Soft delete preserves the relation and the file.** `attachments.soft_delete_attachment(uid)` only stamps `deleted_at`; it never touches `target_type`/`target_uid` and never unlinks the file. `restore_attachment(uid)` clears `deleted_at`, so the item reappears on its parent object and in the gallery with zero extra bookkeeping.
|
||||
- **Three read paths filter `deleted_at IS NULL`** so a soft-deleted item vanishes everywhere (the gallery AND its parent post/project/etc.): `get_attachments`, `get_attachments_batch` (both in `attachments.py`), and `database.get_user_media`. **The hard-delete cascades (`delete_attachments_for`, `delete_target_attachments`) stay UNFILTERED** so permanently deleting a parent object still removes ALL its attachment files, including soft-deleted ones - never add the filter there.
|
||||
- **Queries:** `database.get_user_media(user_uid, page)` (linked, non-deleted, newest first; each item gets a `target_url` via `resolve_object_url`) and `database.get_deleted_media(page)` (the admin trash, joined to uploader username).
|
||||
- **Authorization:** `POST /media/{uid}/delete` (`routers/media.py`) is owner-or-admin (`attachment["user_uid"] == user["uid"] or is_admin(user)`); `POST /media/{uid}/restore` and `POST /admin/media/{uid}/purge` (the only hard delete, via `delete_attachment`) are admin-only (`routers/admin/` package, sidebar **Media** -> `/admin/media`). The tab itself is public.
|
||||
- **Frontend:** `_media_gallery.html` reuses the `_attachment_display.html` type branches and the `dp-lightbox` contract (`data-lightbox`/`data-full`). The delete button carries `data-media-delete` + `data-confirm`; `ModalManager.initConfirmations` shows the confirm and `MediaGallery.js` (`app.mediaGallery`) does the optimistic `Http.send` delete, fades the tile, and toasts. A `<noscript>` form is the no-JS fallback. Grid styling is `static/css/media.css`.
|
||||
- **Devii:** `list_media` (public) and `delete_media` (auth, in `CONFIRM_REQUIRED`) in the catalog.
|
||||
- **Docs visibility (deliberate):** members and guests must never be told this is a *soft* delete. The public prose page `docs/media-gallery.html` (General) and the member-facing `media-delete` API endpoint (Profiles group) describe deletion as a plain "remove" - no soft-delete, restore, trash, or purge language. All moderation mechanics live on the admin-only `docs/media-moderation` prose page (`admin: True`) and in the admin API group (`media-restore`, `admin-media`, `admin-media-purge`, all `auth="admin"`), which `docs_search` excludes from member results and `routers/docs/` package 404s for non-admins. Because `docs_search._strip` keeps the text *inside* `{% if %}` blocks, admin content must live on a separate `admin: True` page, never inline-gated on a public page (a public page may only carry an admin-gated *link*). The member `MediaItemOut` schema omits `deleted_at`; the admin-only `AdminMediaItemOut` adds it.
|
||||
|
||||
## Role-based visibility (generic + DRY)
|
||||
|
||||
- **One source of truth for role/visibility checks**, registered as Jinja globals in `templating.py` - never hand-roll `user.get('role') == 'Admin'` or `user['uid'] == x['user_uid']` in a template again:
|
||||
- `is_admin(user)` (also `utils.is_admin`, reused by `require_admin` and `docs.py`) - admin-only UI.
|
||||
- `owns(item, user)` (= `content.is_owner`) - per-item ownership (e.g. each comment). Page-level detail templates keep using the `is_owner` **bool** passed in their context (post/gist/project/profile); do not call `is_owner(...)` as a function - that name is a context bool and shadows globals.
|
||||
- `is_self(user, uid)` - "is this me" (profile follow vs edit, leaderboard highlight).
|
||||
- `guest_disabled(user)` -> emits ` disabled aria-disabled="true" title="Log in to participate"` for guests (empty for members); `login_hint(user)` -> a small login link. Both return `Markup`.
|
||||
- **Role values are stored capitalized:** `users.role` is exactly `"Admin"` or `"Member"` (first registered user is `"Admin"`, `auth.py`); `is_admin` compares `== "Admin"` case-sensitively. The CLI is the only lowercase surface (`devplace role set ... <member|admin>` writes `role.capitalize()`; `role get` prints `.lower()`). A lowercase role in the DB silently defeats every admin check - never write a raw lowercase role.
|
||||
- **The shadow rule generalizes beyond `is_owner` to ANY Jinja global** (`is_admin`, `avatar_url`, `format_date`, `is_self`, `owns`, `guest_disabled`): `respond(req, tmpl, ctx, model=XOut)` hands the **same** `ctx` to the Pydantic model and the template, and a context key shadows the same-named global across the whole `base.html` chain. A bool named `is_admin` in the context makes `base.html`'s `{% if is_admin(user) %}` raise `TypeError: 'bool' object is not callable` - a 500 that only fires for the branch invoking the global (logged-in users, not guests, which is why guest-only smoke tests miss it). Name viewer/permission flags distinctly (`viewer_is_admin`) in both schema and context. Real issue fixed on `/issues/{number}`; regression-guarded by `tests/api/issues/create.py::test_issue_detail_renders_for_{member,admin}` (they render the page as an authenticated Member/Admin and assert 200 + the admin-only control).
|
||||
- **Policy enforced everywhere:** guests see all non-admin content read-only with action controls **shown but disabled** (`guest_disabled` on vote/star/react/poll/bookmark/follow/comment submit; create FABs become `/auth/login` links via `.feed-fab.login-required`); members get full member actions; **role badges render only to admin viewers** (`{% if is_admin(user) %}` around every `*.role` label). Backend stays the real gate (`require_user`/`require_admin`).
|
||||
- Docs admin gating is unchanged behaviourally but now uses `is_admin` (`docs_base.html` `DEVPLACE_DOCS.isAdmin`, `docs/index.html`, `docs.py`).
|
||||
- **Tests:** the role-gating e2e tests across `tests/e2e/` (guest/member/admin via `page`/`bob`/`alice`) are the UI enforcement; `tests/api/auth/matrix.py` is the backend companion. Guest action controls are asserted **disabled** (not absent) - don't reintroduce `count() == 0` assertions for them.
|
||||
|
||||
## Database tables
|
||||
|
||||
| Table | Purpose |
|
||||
|-------|---------|
|
||||
| `news` | All synced articles with `status` (published/draft), `grade`, `slug`, `show_on_landing` |
|
||||
| `news_images` | Images extracted from article URLs |
|
||||
| `news_sync` | Sync state per article `guid` - tracks grading history |
|
||||
|
||||
The platform-wide soft-delete table set (`database.SOFT_DELETE_TABLES`) is listed in full under "Project-wide soft delete (hard rule)" above.
|
||||
|
||||
## Site settings
|
||||
|
||||
Site settings are seeded on startup (`site_settings` table):
|
||||
|
||||
| Key | Default | Purpose |
|
||||
|-----|---------|---------|
|
||||
| `site_name` / `site_description` / `site_tagline` | DevPlace branding | General site metadata |
|
||||
| `news_grade_threshold` | `"7"` | Minimum AI grade for auto-publish |
|
||||
| `news_api_url` | `"https://news.app.molodetz.nl/api"` | News source API |
|
||||
| `news_ai_url` | `"https://openai.app.molodetz.nl/v1/chat/completions"` | AI grading endpoint |
|
||||
| `news_ai_model` | `"molodetz"` | AI model identifier |
|
||||
| `max_upload_size_mb` / `allowed_file_types` / `max_attachments_per_resource` | `"10"` / `""` / `"10"` | Upload limits |
|
||||
| `rate_limit_per_minute` | `"60"` | Mutating requests per IP per window (`main.py` middleware); a `429` carries a `Retry-After: <window>` header |
|
||||
| `rate_limit_window_seconds` | `"60"` | Rolling window for the rate limit (also the `Retry-After` value) |
|
||||
| `news_service_interval` | `"3600"` | Seconds between news fetch cycles (`NewsService.run_once` re-reads each cycle) |
|
||||
| `session_max_age_days` | `"7"` | Standard session cookie + DB session lifetime |
|
||||
| `session_remember_days` | `"30"` | Remember-me session lifetime |
|
||||
| `registration_open` | `"1"` | When `"0"`, signup GET shows a closed notice and POST is rejected (`auth.py`) |
|
||||
| `maintenance_mode` | `"0"` | When `"1"`, non-admins get a 503 (`main.py` maintenance middleware) |
|
||||
| `maintenance_message` | scheduled-maintenance text | Body shown on the maintenance 503 page |
|
||||
| `customization_enabled` | `"1"` | When `"0"`, `custom_css_tag`/`custom_js_tag` inject nothing (feature off) |
|
||||
| `customization_js_enabled` | `"1"` | When `"0"`, custom CSS still serves but custom JS is suppressed |
|
||||
| `extra_head` | `""` | Raw HTML emitted verbatim into every page `<head>` by `templating.extra_head_tag()`; site-wide trusted-admin input, not sanitized |
|
||||
|
||||
Besides the site/news/upload keys above, the **Operational** group is admin-editable at `/admin/settings`: `site_url` (public origin for absolute links incl. container ingress; resolved by `seo.public_base_url()` = setting -> `DEVPLACE_SITE_URL` env -> request origin), `rate_limit_per_minute`, `rate_limit_window_seconds`, `news_service_interval`, `session_max_age_days`, `session_remember_days`, `registration_open`, `maintenance_mode`, `maintenance_message`, `docs_search_mode` (`agent`|`bm25`, default `agent` - picks the `/docs/search.html` surface: the in-page Devii chat or the classic BM25 list; a viewer over their daily AI limit, or a guest when Devii is disabled, auto-falls-back to BM25 via `routers/docs/views.py` `_agent_search_state`), `outbound_proxy_url` (empty by default - when set, every `stealth.stealth_async_client`/`stealth_sync_client` call across the whole app routes through it via `stealth.configured_proxy_url()`; validated as `http(s)://`/`socks5(h)://` with a host in `AdminSettingsForm`; falls back to `DEVPLACE_OUTBOUND_PROXY_URL` when unset - see the "Outbound HTTP" note in the root `CLAUDE.md`). The **Custom Code** key `extra_head` is the sole key in the settings handler's `CLEARABLE_SETTINGS` set, so saving an empty textarea removes it (the default loop skips empty values).
|
||||
|
||||
The seed block in `database.py` is guarded by `if "site_settings" in tables:` - on a brand-new DB the table does not exist yet (dataset creates tables lazily on first insert), so none of these rows are written until the table exists. Correct runtime behavior therefore relies on every consumer passing the production default to `get_setting`/`get_int_setting`, not on the seed.
|
||||
|
||||
## Operational settings
|
||||
|
||||
Operational settings - read sites and rules:
|
||||
|
||||
| Setting(s) | Read at | Notes |
|
||||
|-----------|---------|-------|
|
||||
| `rate_limit_*` | `rate_limit_middleware` in `main.py` | `max(1, get_int_setting(...))` so `0` can't block all writes |
|
||||
| `maintenance_mode` / `maintenance_message` | `maintenance_middleware` in `main.py` | Allows `/static`, `/avatar`, `/auth`, `/admin` and admins; everyone else gets `error.html` at 503 |
|
||||
| `news_service_interval` | `BaseService` reconciling loop via `current_interval()` | `max(60, ...)`; edited on the Services tab (not `/admin/settings`); a change applies on the next cycle |
|
||||
| `service_<name>_enabled` / `service_<name>_command` / `service_<name>_log_size` | `BaseService` reconciling loop | Generic per-service controls written by the Services tab; the loop reconciles within ~1s |
|
||||
| `session_max_age_days` / `session_remember_days` | `auth.py` signup + login | Multiplied by `SECONDS_PER_DAY`; passed to `create_session(uid, max_age)` so the cookie and the DB session row expire together |
|
||||
| `registration_open` | `auth.py` `signup_page` (GET) and `signup` (POST) | POST returns before any DB write when closed |
|
||||
|
||||
**Booleans are `<select>`, never checkboxes.** The settings save handler (`admin.py`) skips empty form values so empty fields don't clobber existing rows. An unchecked checkbox submits nothing, so it could never be turned off - `registration_open` and `maintenance_mode` use `<option value="1">`/`<option value="0">` so a value is always submitted.
|
||||
@@ -2,13 +2,31 @@
|
||||
|
||||
from .core import dataset, logging, Path, or_, defaultdict, datetime, timedelta, timezone, TTLCache, DATABASE_URL, DEFAULT_CORRECTION_PROMPT, DEFAULT_MODIFIER_PROMPT, INTERNAL_GATEWAY_URL, ensure_data_dirs, logger, db
|
||||
from .core import refresh_snapshot, _local_cache_versions, _cache_version_cache, _cache_state_ready, _ensure_cache_state, get_cache_version, bump_cache_version, sync_local_cache, _index, _drop_index, _uid_index, get_table, _in_clause, _now_iso
|
||||
from .atomic import conditional_update_row
|
||||
from .settings import _settings_cache, get_setting, get_int_setting, set_setting, clear_settings_cache, internal_gateway_key
|
||||
from .users import get_users_by_uids, _admins_cache, invalidate_admins_cache, get_admin_uids, set_user_timezone, get_primary_admin_uid, search_users_by_username
|
||||
from .users import get_users_by_uids, _admins_cache, invalidate_admins_cache, get_admin_uids, set_user_timezone, set_last_seen, get_online_users, get_primary_admin_uid, search_users_by_username
|
||||
from .relations import _relations_cache, get_user_relations, get_blocked_uids, get_muted_uids, get_silenced_uids, invalidate_user_relations
|
||||
from .pagination import PAGE_SIZE, paginate, interleave_by_author, paginate_diverse, get_user_post_count, build_pagination
|
||||
from .pagination import PAGE_SIZE, paginate, interleave_by_author, paginate_diverse, get_user_post_count, clear_user_post_count, build_pagination
|
||||
from .soft_delete import SOFT_DELETE_TABLES, ensure_soft_delete_columns, soft_delete, soft_delete_in, restore, purge, list_deleted, count_deleted, restore_event, purge_event
|
||||
from .engagement import _comment_count_cache, get_comment_counts_by_post_uids, get_post_counts_by_user_uids, get_vote_counts, get_user_votes, get_reactions_by_targets, get_user_bookmarks, get_polls_by_post_uids, get_poll_for_post
|
||||
from .usage import _add_usage, _get_usage, add_correction_usage, get_correction_usage, add_modifier_usage, get_modifier_usage, NEWS_USAGE_KEY, add_news_usage, get_news_usage, ISSUE_USAGE_KEY, add_issue_usage, get_issue_usage, SEO_USAGE_KEY, add_seo_usage, get_seo_usage
|
||||
from .engagement import _comment_count_cache, get_comment_counts_by_post_uids, get_post_counts_by_user_uids, get_project_devlog, get_vote_counts, get_user_votes, get_reactions_by_targets, get_user_bookmarks, get_polls_by_post_uids, get_poll_for_post
|
||||
from .usage import _add_usage, _get_usage, add_correction_usage, get_correction_usage, add_modifier_usage, get_modifier_usage, NEWS_USAGE_KEY, add_news_usage, get_news_usage, ISSUE_USAGE_KEY, add_issue_usage, get_issue_usage, SEO_USAGE_KEY, add_seo_usage, get_seo_usage, AWARD_USAGE_KEY, add_award_usage, get_award_usage
|
||||
from .awards import (
|
||||
AWARDS_PER_PAGE,
|
||||
award_display_hours,
|
||||
award_give_cooldown_hours,
|
||||
award_receive_cooldown_hours,
|
||||
award_is_prominent,
|
||||
can_give_award,
|
||||
can_receive_award,
|
||||
count_published_awards,
|
||||
enrich_award,
|
||||
get_prominent_award,
|
||||
get_user_awards,
|
||||
has_giver_cooldown,
|
||||
has_receiver_cooldown,
|
||||
recompute_user_award_stats,
|
||||
revoke_award,
|
||||
)
|
||||
from .seo_meta import SEO_META_TYPES, get_seo_metadata, get_seo_metadata_batch, has_fresh_seo_metadata, upsert_seo_metadata, mark_seo_metadata_stale
|
||||
from .activity import record_activity, record_unique_activity, get_user_activity, _activity_cache, _ACTIVITY_TABLES, get_activity_calendar, _activity_level, get_first_activity_date, HEATMAP_WEEKS, get_activity_heatmap, get_activity_months, get_streaks
|
||||
from .customization import CUSTOMIZATION_GLOBAL_SCOPE, CUSTOMIZATION_LANGS, _customizations_cache, _customization_key, CUSTOMIZATION_PREF_COLUMNS, get_customization_prefs, set_customization_pref, get_custom_overrides, get_custom_override, list_custom_overrides, set_custom_override, delete_custom_override
|
||||
@@ -17,10 +35,10 @@ from .notifications import NOTIFICATION_TYPES, NOTIFICATION_CHANNELS, _NOTIFICAT
|
||||
from .forks import record_fork, get_fork_parent, count_forks, soft_delete_fork_relations, delete_fork_relations
|
||||
from .follows import get_follow_counts, get_follow_list, get_following_among
|
||||
from .deepsearch import _ds_now, create_deepsearch_session, update_deepsearch_session, get_deepsearch_session, add_deepsearch_message, get_deepsearch_messages, get_cached_deepsearch_url, upsert_deepsearch_url_cache
|
||||
from .ranking import VOTABLE_TARGETS, STAR_TARGETS, _authors_cache, _ranked_authors, _rank_map, get_top_authors, get_leaderboard, get_user_rank, get_user_stars, update_target_stars, soft_delete_engagement, delete_engagement, get_target_owner_uid
|
||||
from .ranking import VOTABLE_TARGETS, STAR_TARGETS, _authors_cache, _ranked_authors, _rank_map, get_top_authors, get_leaderboard, get_user_rank, get_user_stars, clear_user_stars, update_target_stars, soft_delete_engagement, delete_engagement, get_target_owner_uid
|
||||
from .comments import _drop_blocked, _build_comment_items, load_comments, get_recent_comments_by_target_uids, get_recent_comments_by_post_uids, load_comments_by_target_uids
|
||||
from .content import resolve_by_slug, resolve_object_url, get_uids_by_username_match, text_search_clause, get_daily_topic, get_featured_news
|
||||
from .attachments_data import get_attachments, get_attachments_by_type, get_news_images_by_uids, delete_attachment_record, delete_attachments, _delete_attachment_file, get_user_media, get_deleted_media
|
||||
from .content import resolve_by_slug, resolve_object_url, get_uids_by_username_match, text_search_clause, get_daily_topic, get_featured_news, get_trending_topics
|
||||
from .attachments_data import get_attachments, get_attachments_by_type, get_news_images_by_uids, delete_attachment_record, delete_attachments, _delete_attachment_file, get_user_media, get_user_attachments, get_user_attachment, get_deleted_media
|
||||
from .stats import _stats_cache, get_site_stats, _analytics_cache, get_platform_analytics, _gist_languages_cache, get_gist_languages
|
||||
from .schema import BUG_TABLE_RENAMES, migrate_bug_tables_to_issue_tables, init_db, _refresh_query_planner_stats, OLD_GATEWAY_URL, migrate_ai_gateway_settings, backfill_api_keys, _backfill_gamification
|
||||
|
||||
@@ -55,6 +73,7 @@ __all__ = [
|
||||
"get_table",
|
||||
"_in_clause",
|
||||
"_now_iso",
|
||||
"conditional_update_row",
|
||||
"_settings_cache",
|
||||
"get_setting",
|
||||
"get_int_setting",
|
||||
@@ -66,6 +85,8 @@ __all__ = [
|
||||
"invalidate_admins_cache",
|
||||
"get_admin_uids",
|
||||
"set_user_timezone",
|
||||
"set_last_seen",
|
||||
"get_online_users",
|
||||
"get_primary_admin_uid",
|
||||
"search_users_by_username",
|
||||
"_relations_cache",
|
||||
@@ -79,6 +100,7 @@ __all__ = [
|
||||
"interleave_by_author",
|
||||
"paginate_diverse",
|
||||
"get_user_post_count",
|
||||
"clear_user_post_count",
|
||||
"build_pagination",
|
||||
"SOFT_DELETE_TABLES",
|
||||
"ensure_soft_delete_columns",
|
||||
@@ -93,6 +115,7 @@ __all__ = [
|
||||
"_comment_count_cache",
|
||||
"get_comment_counts_by_post_uids",
|
||||
"get_post_counts_by_user_uids",
|
||||
"get_project_devlog",
|
||||
"get_vote_counts",
|
||||
"get_user_votes",
|
||||
"get_reactions_by_targets",
|
||||
@@ -189,6 +212,7 @@ __all__ = [
|
||||
"get_leaderboard",
|
||||
"get_user_rank",
|
||||
"get_user_stars",
|
||||
"clear_user_stars",
|
||||
"update_target_stars",
|
||||
"soft_delete_engagement",
|
||||
"delete_engagement",
|
||||
@@ -205,6 +229,7 @@ __all__ = [
|
||||
"text_search_clause",
|
||||
"get_daily_topic",
|
||||
"get_featured_news",
|
||||
"get_trending_topics",
|
||||
"get_attachments",
|
||||
"get_attachments_by_type",
|
||||
"get_news_images_by_uids",
|
||||
@@ -212,6 +237,8 @@ __all__ = [
|
||||
"delete_attachments",
|
||||
"_delete_attachment_file",
|
||||
"get_user_media",
|
||||
"get_user_attachments",
|
||||
"get_user_attachment",
|
||||
"get_deleted_media",
|
||||
"_stats_cache",
|
||||
"get_site_stats",
|
||||
@@ -228,3 +255,5 @@ __all__ = [
|
||||
"backfill_api_keys",
|
||||
"_backfill_gamification",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from .core import db
|
||||
|
||||
|
||||
def conditional_update_row(
|
||||
table_name: str, row_uid: str, set_clause: str, where_clause: str, params: dict
|
||||
) -> int:
|
||||
sql = (
|
||||
f"UPDATE {table_name} SET {set_clause}, updated_at = :updated_at "
|
||||
f"WHERE uid = :row_uid AND ({where_clause})"
|
||||
)
|
||||
bind = {
|
||||
**params,
|
||||
"updated_at": datetime.now(timezone.utc).isoformat(),
|
||||
"row_uid": row_uid,
|
||||
}
|
||||
with db:
|
||||
result = db.executable.execute(text(sql), bind)
|
||||
return result.rowcount
|
||||
@@ -124,6 +124,53 @@ def get_user_media(user_uid: str, page: int = 1, per_page: int = 24) -> tuple:
|
||||
return items, pagination
|
||||
|
||||
|
||||
def _decorate_attachment(row: dict) -> dict:
|
||||
from devplacepy.attachments import _row_to_attachment
|
||||
|
||||
item = _row_to_attachment(row)
|
||||
item["linked"] = bool(item.get("target_type"))
|
||||
item["target_url"] = (
|
||||
resolve_object_url(item["target_type"], item["target_uid"])
|
||||
if item["linked"]
|
||||
else None
|
||||
)
|
||||
return item
|
||||
|
||||
|
||||
def get_user_attachments(
|
||||
user_uid: str, page: int = 1, per_page: int = 24, linked=None
|
||||
) -> tuple:
|
||||
if "attachments" not in db.tables:
|
||||
return [], build_pagination(page, 0, per_page)
|
||||
clause = "user_uid=:u AND deleted_at IS NULL"
|
||||
if linked is True:
|
||||
clause += " AND target_type != ''"
|
||||
elif linked is False:
|
||||
clause += " AND target_type = ''"
|
||||
total = list(
|
||||
db.query(f"SELECT COUNT(*) AS n FROM attachments WHERE {clause}", u=user_uid)
|
||||
)[0]["n"]
|
||||
pagination = build_pagination(page, total, per_page)
|
||||
offset = (pagination["page"] - 1) * pagination["per_page"]
|
||||
rows = db.query(
|
||||
f"SELECT * FROM attachments WHERE {clause} "
|
||||
"ORDER BY created_at DESC LIMIT :limit OFFSET :offset",
|
||||
u=user_uid,
|
||||
limit=pagination["per_page"],
|
||||
offset=offset,
|
||||
)
|
||||
return [_decorate_attachment(row) for row in rows], pagination
|
||||
|
||||
|
||||
def get_user_attachment(uid: str) -> dict | None:
|
||||
if "attachments" not in db.tables:
|
||||
return None
|
||||
row = db["attachments"].find_one(uid=uid, deleted_at=None)
|
||||
if not row:
|
||||
return None
|
||||
return _decorate_attachment(row)
|
||||
|
||||
|
||||
def get_deleted_media(page: int = 1, per_page: int = 24) -> tuple:
|
||||
if "attachments" not in db.tables:
|
||||
return [], build_pagination(page, 0, per_page)
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from devplacepy.cache import TTLCache
|
||||
from devplacepy.config import (
|
||||
AWARD_DISPLAY_HOURS_DEFAULT,
|
||||
AWARD_GIVE_COOLDOWN_HOURS_DEFAULT,
|
||||
AWARD_RECEIVE_COOLDOWN_HOURS_DEFAULT,
|
||||
)
|
||||
from .core import db
|
||||
from .pagination import build_pagination
|
||||
from .settings import get_int_setting
|
||||
from .core import get_table, _now_iso
|
||||
from .users import get_users_by_uids
|
||||
from .content import resolve_by_slug
|
||||
from .soft_delete import soft_delete, soft_delete_in
|
||||
|
||||
AWARDS_PER_PAGE = 12
|
||||
|
||||
|
||||
def _awards_table():
|
||||
return get_table("awards")
|
||||
|
||||
|
||||
def award_give_cooldown_hours() -> int:
|
||||
return max(1, get_int_setting("award_give_cooldown_hours", AWARD_GIVE_COOLDOWN_HOURS_DEFAULT))
|
||||
|
||||
|
||||
def award_receive_cooldown_hours() -> int:
|
||||
return max(
|
||||
1, get_int_setting("award_receive_cooldown_hours", AWARD_RECEIVE_COOLDOWN_HOURS_DEFAULT)
|
||||
)
|
||||
|
||||
|
||||
def award_display_hours() -> int:
|
||||
return max(1, get_int_setting("award_display_hours", AWARD_DISPLAY_HOURS_DEFAULT))
|
||||
|
||||
|
||||
def _cooldown_cutoff(hours: int) -> str:
|
||||
return (datetime.now(timezone.utc) - timedelta(hours=hours)).isoformat()
|
||||
|
||||
|
||||
def has_giver_cooldown(giver_uid: str) -> bool:
|
||||
if not giver_uid or "awards" not in db.tables:
|
||||
return False
|
||||
cutoff = _cooldown_cutoff(award_give_cooldown_hours())
|
||||
row = _awards_table().find_one(
|
||||
giver_uid=giver_uid, deleted_at=None, created_at={">=": cutoff}
|
||||
)
|
||||
return row is not None
|
||||
|
||||
|
||||
def has_receiver_cooldown(receiver_uid: str) -> bool:
|
||||
if not receiver_uid or "awards" not in db.tables:
|
||||
return False
|
||||
cutoff = _cooldown_cutoff(award_receive_cooldown_hours())
|
||||
row = _awards_table().find_one(
|
||||
receiver_uid=receiver_uid, deleted_at=None, created_at={">=": cutoff}
|
||||
)
|
||||
return row is not None
|
||||
|
||||
|
||||
def can_receive_award(receiver_uid: str) -> bool:
|
||||
return not has_receiver_cooldown(receiver_uid)
|
||||
|
||||
|
||||
def can_give_award(giver_uid: str, receiver_uid: str) -> bool:
|
||||
if not giver_uid or not receiver_uid or giver_uid == receiver_uid:
|
||||
return False
|
||||
return not has_giver_cooldown(giver_uid) and not has_receiver_cooldown(receiver_uid)
|
||||
|
||||
|
||||
def _published_filter():
|
||||
return {"deleted_at": None, "generated_at": {">": ""}}
|
||||
|
||||
|
||||
def count_published_awards(receiver_uid: str) -> int:
|
||||
if not receiver_uid or "awards" not in db.tables:
|
||||
return 0
|
||||
return _awards_table().count(receiver_uid=receiver_uid, **_published_filter())
|
||||
|
||||
|
||||
def _latest_published(receiver_uid: str):
|
||||
if not receiver_uid or "awards" not in db.tables:
|
||||
return None
|
||||
rows = list(
|
||||
_awards_table().find(
|
||||
receiver_uid=receiver_uid,
|
||||
deleted_at=None,
|
||||
generated_at={">": ""},
|
||||
order_by=["-generated_at"],
|
||||
_limit=1,
|
||||
)
|
||||
)
|
||||
return rows[0] if rows else None
|
||||
|
||||
|
||||
def recompute_user_award_stats(receiver_uid: str) -> None:
|
||||
if not receiver_uid or "users" not in db.tables:
|
||||
return
|
||||
count = count_published_awards(receiver_uid)
|
||||
latest = _latest_published(receiver_uid)
|
||||
users = get_table("users")
|
||||
payload = {
|
||||
"uid": receiver_uid,
|
||||
"award_count": count,
|
||||
"last_award_at": latest.get("generated_at") if latest else None,
|
||||
"last_award_slug": latest.get("slug") if latest else None,
|
||||
"last_award_uid": latest.get("uid") if latest else None,
|
||||
}
|
||||
users.update(payload, ["uid"])
|
||||
|
||||
|
||||
_prominence_cache = TTLCache(ttl=15, max_size=500)
|
||||
|
||||
|
||||
def award_is_prominent(user: dict | None) -> bool:
|
||||
if not user or not user.get("last_award_at") or not user.get("last_award_uid"):
|
||||
return False
|
||||
cached = _prominence_cache.get(user["last_award_uid"])
|
||||
if cached is not None:
|
||||
return cached
|
||||
prominent = _compute_prominence(user["last_award_uid"])
|
||||
_prominence_cache.set(user["last_award_uid"], prominent)
|
||||
return prominent
|
||||
|
||||
|
||||
def _compute_prominence(award_uid: str) -> bool:
|
||||
award = resolve_by_slug(_awards_table(), award_uid)
|
||||
if not award or not award.get("generated_at"):
|
||||
return False
|
||||
try:
|
||||
published = datetime.fromisoformat(award["generated_at"])
|
||||
if published.tzinfo is None:
|
||||
published = published.replace(tzinfo=timezone.utc)
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
window = timedelta(hours=award_display_hours())
|
||||
return datetime.now(timezone.utc) - published <= window
|
||||
|
||||
|
||||
def enrich_award(row: dict, givers: dict | None = None) -> dict:
|
||||
item = dict(row)
|
||||
giver_uid = row.get("giver_uid", "")
|
||||
giver = (givers or {}).get(giver_uid) or get_users_by_uids([giver_uid]).get(giver_uid)
|
||||
item["giver"] = giver
|
||||
item["image_url"] = f"/awards/{row.get('slug', '')}/256"
|
||||
item["thumb_url"] = f"/awards/{row.get('slug', '')}/64"
|
||||
return item
|
||||
|
||||
|
||||
def get_user_awards(receiver_uid: str, page: int = 1, per_page: int = AWARDS_PER_PAGE):
|
||||
if not receiver_uid or "awards" not in db.tables:
|
||||
return [], build_pagination(page, 0, per_page)
|
||||
table = _awards_table()
|
||||
total = table.count(receiver_uid=receiver_uid, **_published_filter())
|
||||
offset = max(0, (page - 1) * per_page)
|
||||
rows = list(
|
||||
table.find(
|
||||
receiver_uid=receiver_uid,
|
||||
deleted_at=None,
|
||||
generated_at={">": ""},
|
||||
order_by=["-generated_at"],
|
||||
_limit=per_page,
|
||||
_offset=offset,
|
||||
)
|
||||
)
|
||||
giver_uids = [row.get("giver_uid") for row in rows if row.get("giver_uid")]
|
||||
givers = get_users_by_uids(giver_uids)
|
||||
items = [enrich_award(row, givers) for row in rows]
|
||||
return items, build_pagination(page, total, per_page)
|
||||
|
||||
|
||||
def get_prominent_award(profile_user: dict) -> dict | None:
|
||||
if not award_is_prominent(profile_user):
|
||||
return None
|
||||
award = resolve_by_slug(_awards_table(), profile_user.get("last_award_uid", ""))
|
||||
if not award:
|
||||
return None
|
||||
return enrich_award(award)
|
||||
|
||||
|
||||
def revoke_award(award_uid: str, admin_uid: str) -> dict | None:
|
||||
table = _awards_table()
|
||||
row = table.find_one(uid=award_uid)
|
||||
if not row or row.get("deleted_at"):
|
||||
return None
|
||||
stamp = _now_iso()
|
||||
attachment_uids = [
|
||||
uid
|
||||
for uid in (
|
||||
row.get("attachment_uid_512"),
|
||||
row.get("attachment_uid_256"),
|
||||
row.get("attachment_uid_64"),
|
||||
)
|
||||
if uid
|
||||
]
|
||||
soft_delete("awards", admin_uid, stamp=stamp, uid=award_uid)
|
||||
from devplacepy.attachments import soft_delete_attachments_for
|
||||
|
||||
soft_delete_attachments_for("award", [award_uid], admin_uid)
|
||||
if attachment_uids:
|
||||
soft_delete_in("attachments", "uid", attachment_uids, admin_uid, stamp=stamp)
|
||||
recompute_user_award_stats(row.get("receiver_uid", ""))
|
||||
return row
|
||||
@@ -126,7 +126,7 @@ def load_comments_by_target_uids(target_type, target_uids, user=None):
|
||||
params["tt"] = target_type
|
||||
raw = list(
|
||||
db.query(
|
||||
f"SELECT * FROM comments WHERE target_type=:tt AND target_uid IN ({placeholders}) ORDER BY created_at",
|
||||
f"SELECT * FROM comments WHERE target_type=:tt AND target_uid IN ({placeholders}) AND deleted_at IS NULL ORDER BY created_at",
|
||||
**params,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from collections import Counter
|
||||
|
||||
from devplacepy.cache import TTLCache
|
||||
|
||||
from .core import db, get_table, or_
|
||||
|
||||
_daily_topic_cache = TTLCache(ttl=60, max_size=1)
|
||||
_trending_cache = TTLCache(ttl=15, max_size=1)
|
||||
|
||||
|
||||
def resolve_by_slug(table, slug, include_deleted=False):
|
||||
has_soft_delete = table.has_column("deleted_at")
|
||||
@@ -31,6 +38,9 @@ def resolve_object_url(target_type: str, target_uid: str) -> str:
|
||||
if target_type == "gist":
|
||||
gist = resolve_by_slug(get_table("gists"), target_uid)
|
||||
return f"/gists/{gist['slug'] or gist['uid']}" if gist else "/gists"
|
||||
if target_type == "quiz":
|
||||
quiz = resolve_by_slug(get_table("quizzes"), target_uid)
|
||||
return f"/quizzes/{quiz['slug'] or quiz['uid']}" if quiz else "/quizzes"
|
||||
if target_type == "comment":
|
||||
comment = get_table("comments").find_one(uid=target_uid, deleted_at=None)
|
||||
if not comment:
|
||||
@@ -40,6 +50,12 @@ def resolve_object_url(target_type: str, target_uid: str) -> str:
|
||||
comment.get("target_uid") or comment.get("post_uid", ""),
|
||||
)
|
||||
return f"{parent_url}#comment-{target_uid}"
|
||||
if target_type == "award":
|
||||
award = resolve_by_slug(get_table("awards"), target_uid)
|
||||
if award:
|
||||
receiver = get_table("users").find_one(uid=award.get("receiver_uid", ""))
|
||||
if receiver:
|
||||
return f"/profile/{receiver['username']}?tab=awards#award-{award.get('slug', '')}"
|
||||
return "/feed"
|
||||
|
||||
|
||||
@@ -71,6 +87,15 @@ def text_search_clause(
|
||||
|
||||
|
||||
def get_daily_topic():
|
||||
cached = _daily_topic_cache.get("topic")
|
||||
if cached is not None:
|
||||
return cached
|
||||
topic = _load_daily_topic()
|
||||
_daily_topic_cache.set("topic", topic)
|
||||
return topic
|
||||
|
||||
|
||||
def _load_daily_topic():
|
||||
if "news" in db.tables:
|
||||
article = db["news"].find_one(
|
||||
status="published", deleted_at=None, order_by=["-synced_at"]
|
||||
@@ -122,3 +147,24 @@ def get_featured_news(limit=5):
|
||||
}
|
||||
)
|
||||
return articles
|
||||
|
||||
|
||||
def get_trending_topics(limit: int = 6) -> list[dict]:
|
||||
cached = _trending_cache.get("topics")
|
||||
if cached is not None:
|
||||
return cached[:limit]
|
||||
if "posts" not in db.tables or "topic" not in db["posts"].columns:
|
||||
return []
|
||||
rows = db.query(
|
||||
"SELECT topic FROM posts WHERE deleted_at IS NULL "
|
||||
"AND topic IS NOT NULL AND topic != '' "
|
||||
"ORDER BY created_at DESC LIMIT 200"
|
||||
)
|
||||
counter: Counter[str] = Counter()
|
||||
for row in rows:
|
||||
topic = (row["topic"] or "").strip()
|
||||
if topic:
|
||||
counter[topic] += 1
|
||||
topics = [{"topic": t, "count": c} for t, c in counter.most_common(limit)]
|
||||
_trending_cache.set("topics", topics)
|
||||
return topics
|
||||
|
||||
+20
-23
@@ -61,10 +61,11 @@ def _ensure_cache_state() -> None:
|
||||
global _cache_state_ready
|
||||
if _cache_state_ready:
|
||||
return
|
||||
db.query(
|
||||
"CREATE TABLE IF NOT EXISTS cache_state "
|
||||
"(name TEXT PRIMARY KEY, version INTEGER NOT NULL DEFAULT 0)"
|
||||
)
|
||||
with db:
|
||||
db.query(
|
||||
"CREATE TABLE IF NOT EXISTS cache_state "
|
||||
"(name TEXT PRIMARY KEY, version INTEGER NOT NULL DEFAULT 0)"
|
||||
)
|
||||
_cache_state_ready = True
|
||||
|
||||
|
||||
@@ -74,18 +75,15 @@ def get_cache_version(name: str) -> int:
|
||||
return cached
|
||||
try:
|
||||
_ensure_cache_state()
|
||||
row = next(
|
||||
iter(
|
||||
db.query(
|
||||
"SELECT version FROM cache_state WHERE name = :name", name=name
|
||||
)
|
||||
),
|
||||
None,
|
||||
)
|
||||
version = int(row["version"]) if row else 0
|
||||
with db:
|
||||
rows = list(db.query("SELECT name, version FROM cache_state"))
|
||||
versions = {row["name"]: int(row["version"]) for row in rows}
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not read cache version {name}: {e}")
|
||||
return 0
|
||||
for key, version in versions.items():
|
||||
_cache_version_cache.set(key, version)
|
||||
version = versions.get(name, 0)
|
||||
_cache_version_cache.set(name, version)
|
||||
return version
|
||||
|
||||
@@ -93,16 +91,15 @@ def get_cache_version(name: str) -> int:
|
||||
def bump_cache_version(name: str) -> None:
|
||||
try:
|
||||
_ensure_cache_state()
|
||||
db.query(
|
||||
"INSERT OR IGNORE INTO cache_state (name, version) VALUES (:name, 0)",
|
||||
name=name,
|
||||
)
|
||||
db.query(
|
||||
"UPDATE cache_state SET version = version + 1 WHERE name = :name", name=name
|
||||
)
|
||||
connection = db.executable
|
||||
if connection.in_transaction():
|
||||
connection.commit()
|
||||
with db:
|
||||
db.query(
|
||||
"INSERT OR IGNORE INTO cache_state (name, version) VALUES (:name, 0)",
|
||||
name=name,
|
||||
)
|
||||
db.query(
|
||||
"UPDATE cache_state SET version = version + 1 WHERE name = :name",
|
||||
name=name,
|
||||
)
|
||||
_cache_version_cache.pop(name)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not bump cache version {name}: {e}")
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from .core import TTLCache, _in_clause, db, defaultdict
|
||||
from .core import TTLCache, _in_clause, db, defaultdict, get_table
|
||||
from .pagination import paginate
|
||||
from devplacepy.content import enrich_items
|
||||
from .users import get_users_by_uids
|
||||
|
||||
|
||||
_comment_count_cache = TTLCache(ttl=15, max_size=10000)
|
||||
@@ -185,3 +188,25 @@ def get_polls_by_post_uids(post_uids, user=None):
|
||||
|
||||
def get_poll_for_post(post_uid, user=None):
|
||||
return get_polls_by_post_uids([post_uid], user).get(post_uid)
|
||||
|
||||
|
||||
def get_project_devlog(project_uid: str, before: str | None = None, viewer: dict | None = None) -> tuple[list, str | None]:
|
||||
posts_table = get_table("posts")
|
||||
posts, next_cursor = paginate(
|
||||
posts_table,
|
||||
before=before,
|
||||
viewer_uid=viewer["uid"] if viewer else None,
|
||||
project_uid=project_uid,
|
||||
)
|
||||
if not posts:
|
||||
return [], None
|
||||
|
||||
authors = get_users_by_uids([p["user_uid"] for p in posts])
|
||||
counts = get_comment_counts_by_post_uids([p["uid"] for p in posts])
|
||||
|
||||
result = enrich_items(
|
||||
posts, "post", authors, {"comment_count": counts}, user=viewer
|
||||
)
|
||||
return result, next_cursor
|
||||
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ def get_follow_list(
|
||||
"uid": person["uid"],
|
||||
"username": person["username"],
|
||||
"bio": (person.get("bio") or "")[:140],
|
||||
"last_seen": person.get("last_seen"),
|
||||
"followed_at": row.get("created_at"),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -17,6 +17,9 @@ NOTIFICATION_TYPES = [
|
||||
{"key": "issue", "label": "Issue tracker", "description": "Updates on issue reports you filed"},
|
||||
{"key": "reminder", "label": "Reminders", "description": "A reminder or scheduled task you asked Devii to run fires"},
|
||||
{"key": "harvest_stolen", "label": "Farm raids", "description": "Someone steals a ready build from your Code Farm"},
|
||||
{"key": "award", "label": "Awards", "description": "Someone gives you an award on your profile"},
|
||||
{"key": "quiz_attempt", "label": "Quiz attempts", "description": "Someone completes one of your quizzes"},
|
||||
{"key": "system", "label": "System alerts", "description": "Platform infrastructure alerts (e.g. the AI gateway going down)"},
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from devplacepy.cache import TTLCache
|
||||
from .core import db, get_table
|
||||
from .relations import get_blocked_uids
|
||||
|
||||
@@ -7,6 +8,9 @@ from .relations import get_blocked_uids
|
||||
PAGE_SIZE = 25
|
||||
|
||||
|
||||
_user_post_count_cache = TTLCache(ttl=15, max_size=2000)
|
||||
|
||||
|
||||
def paginate(
|
||||
table,
|
||||
*clauses,
|
||||
@@ -74,10 +78,19 @@ def paginate_diverse(
|
||||
return interleave_by_author(rows, uid_key=uid_key), next_cursor
|
||||
|
||||
|
||||
def clear_user_post_count(user_uid: str) -> None:
|
||||
_user_post_count_cache.pop(user_uid)
|
||||
|
||||
|
||||
def get_user_post_count(user_uid: str) -> int:
|
||||
cached = _user_post_count_cache.get(user_uid)
|
||||
if cached is not None:
|
||||
return cached
|
||||
if "posts" not in db.tables:
|
||||
return 0
|
||||
return get_table("posts").count(user_uid=user_uid, deleted_at=None)
|
||||
count = get_table("posts").count(user_uid=user_uid, deleted_at=None)
|
||||
_user_post_count_cache.set(user_uid, count)
|
||||
return count
|
||||
|
||||
|
||||
def build_pagination(page, total, per_page=25):
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import os
|
||||
|
||||
from .core import TTLCache, _in_clause, _now_iso, db, get_table
|
||||
from .users import get_users_by_uids
|
||||
from .soft_delete import soft_delete, soft_delete_in
|
||||
@@ -10,13 +12,20 @@ VOTABLE_TARGETS: dict[str, str] = {
|
||||
"project": "projects",
|
||||
"gist": "gists",
|
||||
"comment": "comments",
|
||||
"quiz": "quizzes",
|
||||
}
|
||||
|
||||
|
||||
STAR_TARGETS: set[str] = {"post", "project", "gist"}
|
||||
STAR_TARGETS: set[str] = {"post", "project", "gist", "quiz"}
|
||||
|
||||
|
||||
_authors_cache = TTLCache(ttl=300, max_size=200)
|
||||
RANKING_TTL = int(os.environ.get("DEVPLACE_RANKING_TTL", "60"))
|
||||
|
||||
|
||||
_authors_cache = TTLCache(ttl=RANKING_TTL, max_size=200)
|
||||
|
||||
|
||||
_stars_cache = TTLCache(ttl=15, max_size=2000)
|
||||
|
||||
|
||||
def _ranked_authors() -> list:
|
||||
@@ -84,7 +93,14 @@ def get_user_rank(user_uid: str):
|
||||
return _rank_map().get(user_uid)
|
||||
|
||||
|
||||
def clear_user_stars(user_uid: str) -> None:
|
||||
_stars_cache.pop(user_uid)
|
||||
|
||||
|
||||
def get_user_stars(user_uid: str) -> int:
|
||||
cached = _stars_cache.get(user_uid)
|
||||
if cached is not None:
|
||||
return cached
|
||||
if "votes" not in db.tables:
|
||||
return 0
|
||||
target_union = " UNION ALL ".join(
|
||||
@@ -94,14 +110,17 @@ def get_user_stars(user_uid: str) -> int:
|
||||
)
|
||||
if not target_union:
|
||||
return 0
|
||||
total = 0
|
||||
for row in db.query(
|
||||
f"SELECT COALESCE(SUM(v.value), 0) AS s "
|
||||
f"FROM votes v JOIN ({target_union}) t ON v.target_uid = t.uid AND v.target_type = t.target_type "
|
||||
f"WHERE v.deleted_at IS NULL",
|
||||
u=user_uid,
|
||||
):
|
||||
return row["s"] or 0
|
||||
return 0
|
||||
total = row["s"] or 0
|
||||
break
|
||||
_stars_cache.set(user_uid, total)
|
||||
return total
|
||||
|
||||
|
||||
def update_target_stars(target_type: str, target_uid: str, net_stars: int) -> None:
|
||||
@@ -110,7 +129,6 @@ def update_target_stars(target_type: str, target_uid: str, net_stars: int) -> No
|
||||
return
|
||||
if target_type in STAR_TARGETS:
|
||||
get_table(table_name).update({"uid": target_uid, "stars": net_stars}, ["uid"])
|
||||
_authors_cache.clear()
|
||||
|
||||
|
||||
def soft_delete_engagement(target_type: str, target_uids: list, deleted_by: str) -> None:
|
||||
@@ -139,17 +157,19 @@ def delete_engagement(target_type: str, target_uids: list) -> None:
|
||||
if "reactions" in db.tables:
|
||||
placeholders, params = _in_clause(uids)
|
||||
params["tt"] = target_type
|
||||
db.query(
|
||||
f"DELETE FROM reactions WHERE target_type=:tt AND target_uid IN ({placeholders})",
|
||||
**params,
|
||||
)
|
||||
with db:
|
||||
db.query(
|
||||
f"DELETE FROM reactions WHERE target_type=:tt AND target_uid IN ({placeholders})",
|
||||
**params,
|
||||
)
|
||||
if "bookmarks" in db.tables:
|
||||
placeholders, params = _in_clause(uids)
|
||||
params["tt"] = target_type
|
||||
db.query(
|
||||
f"DELETE FROM bookmarks WHERE target_type=:tt AND target_uid IN ({placeholders})",
|
||||
**params,
|
||||
)
|
||||
with db:
|
||||
db.query(
|
||||
f"DELETE FROM bookmarks WHERE target_type=:tt AND target_uid IN ({placeholders})",
|
||||
**params,
|
||||
)
|
||||
if target_type == "post" and "polls" in db.tables:
|
||||
for uid in uids:
|
||||
for poll in db["polls"].find(post_uid=uid):
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import inspect
|
||||
import os
|
||||
|
||||
import httpx
|
||||
|
||||
from devplacepy.cache import TTLCache
|
||||
from devplacepy_services.base.db_codec import (
|
||||
decode_value,
|
||||
encode_args,
|
||||
is_write,
|
||||
is_write_sql,
|
||||
)
|
||||
|
||||
_SERVICE_URL = os.environ.get("DEVPLACE_DB_SERVICE_URL", "http://127.0.0.1:10601").rstrip("/")
|
||||
_INTERNAL_KEY = os.environ.get("DEVPLACE_GATEWAY_INTERNAL_KEY", "").strip()
|
||||
_CLIENT: httpx.Client | None = None
|
||||
|
||||
# Section 7.3: settings reads tolerate up to 5s staleness. patch_module()
|
||||
# generically RPCs every devplacepy.database call, bypassing the local
|
||||
# TTL cache get_setting/get_int_setting had in-process - without this,
|
||||
# every settings read (rate limiting, maintenance mode, admin dashboards)
|
||||
# pays a full HTTP round trip to the database broker.
|
||||
_SETTINGS_CACHE_TTL_SECONDS = 5
|
||||
_SETTINGS_CACHE = TTLCache(ttl=_SETTINGS_CACHE_TTL_SECONDS, max_size=512)
|
||||
_CACHED_SETTINGS_FNS = frozenset({"get_setting", "get_int_setting"})
|
||||
|
||||
|
||||
def _service_url() -> str:
|
||||
return os.environ.get("DEVPLACE_DB_SERVICE_URL", _SERVICE_URL).rstrip("/")
|
||||
|
||||
|
||||
def _headers() -> dict[str, str]:
|
||||
headers: dict[str, str] = {}
|
||||
key = os.environ.get("DEVPLACE_GATEWAY_INTERNAL_KEY", _INTERNAL_KEY).strip()
|
||||
if key:
|
||||
headers["X-Internal-Key"] = key
|
||||
return headers
|
||||
|
||||
|
||||
def _client() -> httpx.Client:
|
||||
global _CLIENT
|
||||
if _CLIENT is None:
|
||||
_CLIENT = httpx.Client(timeout=30.0)
|
||||
return _CLIENT
|
||||
|
||||
|
||||
def _post(path: str, body: dict) -> object:
|
||||
response = _client().post(
|
||||
f"{_service_url()}/{path.lstrip('/')}",
|
||||
json=body,
|
||||
headers=_headers(),
|
||||
)
|
||||
if response.status_code >= 400:
|
||||
payload = response.json() if response.content else {}
|
||||
message = payload.get("error", "Database service request failed")
|
||||
raise RuntimeError(message)
|
||||
if not response.content:
|
||||
return None
|
||||
return decode_value(response.json())
|
||||
|
||||
|
||||
def _invoke_cached(fn_name: str, args, kwargs):
|
||||
cache_key = f"{fn_name}:{args!r}:{sorted(kwargs.items())!r}"
|
||||
cached = _SETTINGS_CACHE.get(cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
value = _invoke(fn_name, args, kwargs, write=False)
|
||||
_SETTINGS_CACHE.set(cache_key, value)
|
||||
return value
|
||||
|
||||
|
||||
def _invoke(fn_name: str, args, kwargs, *, write: bool = False):
|
||||
encoded_args, encoded_kwargs = encode_args(args, kwargs)
|
||||
payload = {
|
||||
"fn": fn_name,
|
||||
"args": encoded_args,
|
||||
"kwargs": encoded_kwargs,
|
||||
"write": write,
|
||||
}
|
||||
result = _post("internal/invoke", payload)
|
||||
if isinstance(result, dict) and "result" in result:
|
||||
return result["result"]
|
||||
return result
|
||||
|
||||
|
||||
class RemoteSearchClause:
|
||||
def __init__(self, term, fields, author_field=None):
|
||||
self.term = term.strip()
|
||||
self.fields = tuple(fields)
|
||||
self.author_field = author_field
|
||||
|
||||
|
||||
class RemoteUidInClause:
|
||||
def __init__(self, field, uids):
|
||||
self.field = field
|
||||
self.uids = frozenset(uids)
|
||||
|
||||
|
||||
class RemoteTable:
|
||||
def __init__(self, db: "RemoteDb", name: str) -> None:
|
||||
self._db = db
|
||||
self._name = name
|
||||
self._column_cache = None
|
||||
|
||||
def __getattr__(self, name: str):
|
||||
def caller(*args, **kwargs):
|
||||
return self._db._table_op(self._name, name, args, kwargs)
|
||||
|
||||
return caller
|
||||
|
||||
def has_column(self, name: str) -> bool:
|
||||
cache = self._column_cache
|
||||
if cache is None:
|
||||
sample = self.find(_limit=1)
|
||||
row = next(iter(sample), None)
|
||||
cache = set(row.keys()) if row else set()
|
||||
self._column_cache = cache
|
||||
return name in cache
|
||||
|
||||
def count(self, **kwargs):
|
||||
return self._db._table_op(self._name, "count", [], kwargs)
|
||||
|
||||
@property
|
||||
def table(self):
|
||||
return self
|
||||
|
||||
@property
|
||||
def exists(self) -> bool:
|
||||
return self._name in self._db.tables
|
||||
|
||||
class RemoteDb:
|
||||
def __init__(self) -> None:
|
||||
self._tables_cache: list[str] | None = None
|
||||
|
||||
@property
|
||||
def tables(self) -> list[str]:
|
||||
if self._tables_cache is None:
|
||||
result = _post("internal/db-op", {"op": "tables"})
|
||||
self._tables_cache = list(result or [])
|
||||
return self._tables_cache
|
||||
|
||||
def __getitem__(self, name: str) -> RemoteTable:
|
||||
return RemoteTable(self, name)
|
||||
|
||||
def query(self, sql: str, **params):
|
||||
encoded_args, encoded_kwargs = encode_args((sql,), params)
|
||||
result = _post(
|
||||
"internal/db-op",
|
||||
{
|
||||
"op": "query",
|
||||
"args": encoded_args,
|
||||
"kwargs": encoded_kwargs,
|
||||
"write": is_write_sql(sql),
|
||||
},
|
||||
)
|
||||
return result or []
|
||||
|
||||
def _table_op(self, table: str, method: str, args, kwargs, *, write: bool = False):
|
||||
encoded_args, encoded_kwargs = encode_args(args, kwargs)
|
||||
result = _post(
|
||||
"internal/db-op",
|
||||
{
|
||||
"op": "table_op",
|
||||
"table": table,
|
||||
"method": method,
|
||||
"args": encoded_args,
|
||||
"kwargs": encoded_kwargs,
|
||||
"write": write,
|
||||
},
|
||||
)
|
||||
if method in {"insert", "update", "delete"}:
|
||||
self._tables_cache = None
|
||||
return result
|
||||
|
||||
@property
|
||||
def executable(self):
|
||||
return self
|
||||
|
||||
@property
|
||||
def in_transaction(self) -> bool:
|
||||
return False
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
|
||||
_LOCAL_REMOTE = frozenset(
|
||||
{
|
||||
"get_table",
|
||||
"refresh_snapshot",
|
||||
"_in_clause",
|
||||
"_now_iso",
|
||||
"text_search_clause",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _remote_text_search_clause(
|
||||
table, search, fields=("title", "description"), author_field=None
|
||||
):
|
||||
term = (search or "").strip()
|
||||
if not term:
|
||||
return None
|
||||
if type(table).__name__ == "RemoteTable":
|
||||
return RemoteSearchClause(term, fields, author_field)
|
||||
from devplacepy.database.content import text_search_clause as local_clause
|
||||
|
||||
return local_clause(table, search, fields, author_field=author_field)
|
||||
|
||||
|
||||
def _remote_get_table(name: str):
|
||||
import devplacepy.database.core as core
|
||||
|
||||
return core.db[name]
|
||||
|
||||
|
||||
def _remote_refresh_snapshot() -> None:
|
||||
return None
|
||||
|
||||
|
||||
def patch_module(module) -> None:
|
||||
import devplacepy.database as db_module
|
||||
|
||||
for name in db_module.__all__:
|
||||
if name in _LOCAL_REMOTE:
|
||||
continue
|
||||
target = getattr(module, name, None)
|
||||
if target is None or not callable(target):
|
||||
continue
|
||||
if inspect.isclass(target):
|
||||
continue
|
||||
|
||||
def make_wrapper(fn_name: str, fn_write: bool):
|
||||
if fn_name in _CACHED_SETTINGS_FNS:
|
||||
|
||||
def wrapper(*args, **kwargs):
|
||||
return _invoke_cached(fn_name, args, kwargs)
|
||||
|
||||
wrapper.__name__ = fn_name
|
||||
return wrapper
|
||||
|
||||
def wrapper(*args, **kwargs):
|
||||
return _invoke(fn_name, args, kwargs, write=fn_write)
|
||||
|
||||
wrapper.__name__ = fn_name
|
||||
return wrapper
|
||||
|
||||
setattr(module, name, make_wrapper(name, is_write(name)))
|
||||
|
||||
|
||||
def activate() -> None:
|
||||
import devplacepy.database.core as core
|
||||
|
||||
core.db = RemoteDb()
|
||||
import devplacepy.database as db_module
|
||||
|
||||
patch_module(db_module)
|
||||
for submodule_name in (
|
||||
"settings",
|
||||
"users",
|
||||
"relations",
|
||||
"pagination",
|
||||
"soft_delete",
|
||||
"engagement",
|
||||
"usage",
|
||||
"awards",
|
||||
"seo_meta",
|
||||
"activity",
|
||||
"customization",
|
||||
"email",
|
||||
"notifications",
|
||||
"forks",
|
||||
"follows",
|
||||
"deepsearch",
|
||||
"ranking",
|
||||
"comments",
|
||||
"content",
|
||||
"attachments_data",
|
||||
"stats",
|
||||
"schema",
|
||||
):
|
||||
try:
|
||||
submodule = __import__(
|
||||
f"devplacepy.database.{submodule_name}",
|
||||
fromlist=[submodule_name],
|
||||
)
|
||||
except ImportError:
|
||||
continue
|
||||
patch_module(submodule)
|
||||
for external_name in (
|
||||
"devplacepy.services.statistics.tracking",
|
||||
"devplacepy.services.base",
|
||||
"devplacepy.attachments",
|
||||
"devplacepy.project_files",
|
||||
):
|
||||
try:
|
||||
external = __import__(external_name, fromlist=[external_name.split(".")[-1]])
|
||||
except ImportError:
|
||||
continue
|
||||
if hasattr(external, "db"):
|
||||
external.db = RemoteDb()
|
||||
db_module.db = core.db
|
||||
db_module.get_table = _remote_get_table
|
||||
core.get_table = _remote_get_table
|
||||
db_module.refresh_snapshot = _remote_refresh_snapshot
|
||||
core.refresh_snapshot = _remote_refresh_snapshot
|
||||
db_module.text_search_clause = _remote_text_search_clause
|
||||
import devplacepy.database.content as content_module
|
||||
|
||||
content_module.text_search_clause = _remote_text_search_clause
|
||||
for submodule_name in (
|
||||
"settings",
|
||||
"users",
|
||||
"relations",
|
||||
"pagination",
|
||||
"soft_delete",
|
||||
"engagement",
|
||||
"usage",
|
||||
"awards",
|
||||
"seo_meta",
|
||||
"activity",
|
||||
"customization",
|
||||
"email",
|
||||
"notifications",
|
||||
"forks",
|
||||
"follows",
|
||||
"deepsearch",
|
||||
"ranking",
|
||||
"comments",
|
||||
"content",
|
||||
"attachments_data",
|
||||
"stats",
|
||||
"schema",
|
||||
):
|
||||
try:
|
||||
submodule = __import__(
|
||||
f"devplacepy.database.{submodule_name}",
|
||||
fromlist=[submodule_name],
|
||||
)
|
||||
except ImportError:
|
||||
continue
|
||||
if hasattr(submodule, "db"):
|
||||
submodule.db = core.db
|
||||
+667
-44
@@ -1,6 +1,6 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from .core import DEFAULT_CORRECTION_PROMPT, DEFAULT_MODIFIER_PROMPT, INTERNAL_GATEWAY_URL, _index, _uid_index, db, defaultdict, get_table, logger
|
||||
from .core import DEFAULT_CORRECTION_PROMPT, DEFAULT_MODIFIER_PROMPT, INTERNAL_GATEWAY_URL, _drop_index, _index, _uid_index, db, defaultdict, get_table, logger
|
||||
from .settings import get_setting, set_setting
|
||||
from .soft_delete import SOFT_DELETE_TABLES, ensure_soft_delete_columns
|
||||
from .ranking import _authors_cache
|
||||
@@ -36,9 +36,13 @@ def init_db():
|
||||
_index(db, "users", "idx_users_email", ["email"])
|
||||
_index(db, "users", "idx_users_api_key", ["api_key"])
|
||||
_index(db, "users", "idx_users_role", ["role", "created_at"])
|
||||
_index(db, "users", "idx_users_last_seen", ["last_seen"])
|
||||
_index(db, "users", "idx_users_created_at", ["created_at"])
|
||||
_index(db, "posts", "idx_posts_user_uid", ["user_uid"])
|
||||
_index(db, "posts", "idx_posts_created_at", ["created_at"])
|
||||
_index(db, "posts", "idx_posts_topic", ["topic"])
|
||||
_index(db, "posts", "idx_posts_slug", ["slug"])
|
||||
_index(db, "posts", "idx_posts_project_uid", ["project_uid"])
|
||||
if "posts" in tables:
|
||||
posts_table = get_table("posts")
|
||||
if not posts_table.has_column("tags"):
|
||||
@@ -119,6 +123,15 @@ def init_db():
|
||||
_index(db, "votes", "idx_votes_target", ["target_uid", "target_type"])
|
||||
_index(db, "messages", "idx_messages_sender", ["sender_uid"])
|
||||
_index(db, "messages", "idx_messages_receiver", ["receiver_uid"])
|
||||
_index(
|
||||
db, "messages", "idx_messages_conversation", ["sender_uid", "receiver_uid"]
|
||||
)
|
||||
_index(
|
||||
db,
|
||||
"messages",
|
||||
"idx_messages_conversation_rev",
|
||||
["receiver_uid", "sender_uid"],
|
||||
)
|
||||
_index(db, "notifications", "idx_notifications_user", ["user_uid"])
|
||||
_index(db, "notifications", "idx_notifications_user_read", ["user_uid", "read"])
|
||||
_index(db, "push_registration", "idx_push_registration_user", ["user_uid"])
|
||||
@@ -139,6 +152,7 @@ def init_db():
|
||||
projects.create_column_by_example(column, example)
|
||||
|
||||
_index(db, "projects", "idx_projects_user", ["user_uid"])
|
||||
_index(db, "projects", "idx_projects_slug", ["slug"])
|
||||
_index(db, "projects", "idx_projects_private", ["is_private"])
|
||||
_index(db, "projects", "idx_projects_stars", ["stars"])
|
||||
_index(db, "projects", "idx_projects_type", ["project_type"])
|
||||
@@ -169,8 +183,11 @@ def init_db():
|
||||
db, "project_files", "idx_project_files_parent", ["project_uid", "parent_path"]
|
||||
)
|
||||
_index(db, "badges", "idx_badges_user", ["user_uid"])
|
||||
_index(db, "follows", "idx_follows_follower", ["follower_uid"])
|
||||
_index(db, "follows", "idx_follows_following", ["following_uid"])
|
||||
_index(db, "badges", "idx_badges_user_name", ["user_uid", "badge_name"])
|
||||
_drop_index(db, "idx_follows_follower")
|
||||
_drop_index(db, "idx_follows_following")
|
||||
_index(db, "follows", "idx_follows_follower_created", ["follower_uid", "created_at"])
|
||||
_index(db, "follows", "idx_follows_following_created", ["following_uid", "created_at"])
|
||||
user_relations = get_table("user_relations")
|
||||
for column, example in (
|
||||
("uid", ""),
|
||||
@@ -188,6 +205,7 @@ def init_db():
|
||||
_index(db, "password_resets", "idx_password_resets_token", ["token"])
|
||||
_index(db, "gists", "idx_gists_user_uid", ["user_uid"])
|
||||
_index(db, "gists", "idx_gists_language", ["language"])
|
||||
_index(db, "gists", "idx_gists_slug", ["slug"])
|
||||
attachments = get_table("attachments")
|
||||
for column, example in (
|
||||
("uid", ""),
|
||||
@@ -243,6 +261,7 @@ def init_db():
|
||||
db["site_settings"].insert(
|
||||
{"uid": f"default_{key}", "key": key, "value": value}
|
||||
)
|
||||
_index(db, "site_settings", "idx_site_settings_key", ["key"])
|
||||
|
||||
news = get_table("news")
|
||||
for column, example in (
|
||||
@@ -271,6 +290,7 @@ def init_db():
|
||||
news.create_column_by_example(column, example)
|
||||
|
||||
_index(db, "news", "idx_news_external_id", ["external_id"])
|
||||
_index(db, "news", "idx_news_slug", ["slug"])
|
||||
_index(db, "news", "idx_news_synced_at", ["synced_at"])
|
||||
_index(db, "news", "idx_news_status", ["status"])
|
||||
_index(db, "news", "idx_news_featured", ["featured"])
|
||||
@@ -339,6 +359,21 @@ def init_db():
|
||||
_index(
|
||||
db, "issue_comment_authors", "idx_issue_comment_authors_number", ["gitea_number"]
|
||||
)
|
||||
|
||||
ws_tickets = get_table("ws_tickets")
|
||||
for column, example in (
|
||||
("uid", ""),
|
||||
("token", ""),
|
||||
("user_uid", ""),
|
||||
("created_at", ""),
|
||||
("expires_at", ""),
|
||||
("used_at", ""),
|
||||
):
|
||||
if not ws_tickets.has_column(column):
|
||||
ws_tickets.create_column_by_example(column, example)
|
||||
_index(db, "ws_tickets", "idx_ws_tickets_token", ["token"], unique=True)
|
||||
_index(db, "ws_tickets", "idx_ws_tickets_expires", ["expires_at"])
|
||||
|
||||
migrate_bug_tables_to_issue_tables()
|
||||
_index(db, "service_state", "idx_service_state_name", ["name"])
|
||||
if "devii_conversations" in db.tables:
|
||||
@@ -346,9 +381,10 @@ def init_db():
|
||||
if not conversations.has_column("channel"):
|
||||
conversations.create_column_by_example("channel", "main")
|
||||
try:
|
||||
db.query(
|
||||
"UPDATE devii_conversations SET channel='main' WHERE channel IS NULL"
|
||||
)
|
||||
with db:
|
||||
db.query(
|
||||
"UPDATE devii_conversations SET channel='main' WHERE channel IS NULL"
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning(f"Could not backfill devii_conversations.channel: {e}")
|
||||
_index(
|
||||
@@ -369,11 +405,52 @@ def init_db():
|
||||
"idx_devii_turns_owner_time",
|
||||
["owner_kind", "owner_id", "started_at"],
|
||||
)
|
||||
if "devii_tasks" in db.tables:
|
||||
tasks = get_table("devii_tasks")
|
||||
for column, example in (
|
||||
("expires_at", ""),
|
||||
("failure_count", 0),
|
||||
("notify", 0),
|
||||
("tz", ""),
|
||||
):
|
||||
if not tasks.has_column(column):
|
||||
tasks.create_column_by_example(column, example)
|
||||
try:
|
||||
with db:
|
||||
db.query(
|
||||
"UPDATE devii_tasks SET failure_count = 0 WHERE failure_count IS NULL"
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning(f"Could not backfill devii_tasks.failure_count: {e}")
|
||||
task_runs = get_table("devii_task_runs")
|
||||
for column, example in (
|
||||
("uid", ""),
|
||||
("owner_kind", ""),
|
||||
("owner_id", ""),
|
||||
("task_uid", ""),
|
||||
("created_at", ""),
|
||||
):
|
||||
if not task_runs.has_column(column):
|
||||
task_runs.create_column_by_example(column, example)
|
||||
_index(
|
||||
db,
|
||||
"devii_task_runs",
|
||||
"idx_devii_task_runs_owner_time",
|
||||
["owner_kind", "owner_id", "created_at"],
|
||||
)
|
||||
_index(db, "devii_task_runs", "idx_devii_task_runs_time", ["created_at"])
|
||||
_index(
|
||||
db,
|
||||
"devii_tasks",
|
||||
"idx_devii_tasks_owner_created",
|
||||
["owner_kind", "owner_id", "created_at"],
|
||||
)
|
||||
_index(db, "devii_tasks", "idx_devii_tasks_owner", ["owner_kind", "owner_id"])
|
||||
_index(
|
||||
db, "devii_tasks", "idx_devii_tasks_due", ["enabled", "status", "next_run_at"]
|
||||
)
|
||||
_index(db, "devii_lessons", "idx_devii_lessons_owner", ["owner_kind", "owner_id"])
|
||||
_index(db, "devii_lessons", "idx_devii_lessons_owner_created", ["owner_kind", "owner_id", "created_at"])
|
||||
_index(
|
||||
db, "devii_virtual_tools", "idx_devii_vtools_owner", ["owner_kind", "owner_id"]
|
||||
)
|
||||
@@ -413,6 +490,12 @@ def init_db():
|
||||
"idx_gw_usage_endpoint_time",
|
||||
["endpoint", "created_at"],
|
||||
)
|
||||
_index(
|
||||
db,
|
||||
"gateway_usage_ledger",
|
||||
"idx_gw_usage_appref_time",
|
||||
["app_reference", "created_at"],
|
||||
)
|
||||
_index(db, "gateway_concurrency_samples", "idx_gw_conc_time", ["created_at"])
|
||||
jobs_table = get_table("jobs")
|
||||
for column, example in (
|
||||
@@ -444,18 +527,30 @@ def init_db():
|
||||
_index(db, "jobs", "idx_jobs_expires", ["expires_at"])
|
||||
_index(db, "project_forks", "idx_project_forks_source", ["source_project_uid"])
|
||||
_index(db, "project_forks", "idx_project_forks_forked", ["forked_project_uid"])
|
||||
if "instances" in db.tables:
|
||||
instances = get_table("instances")
|
||||
for column, example in (
|
||||
("run_as_uid", ""),
|
||||
("boot_language", "none"),
|
||||
("boot_script", ""),
|
||||
("start_on_boot", 0),
|
||||
):
|
||||
if not instances.has_column(column):
|
||||
instances.create_column_by_example(column, example)
|
||||
instances = get_table("instances")
|
||||
for column, example in (
|
||||
("uid", ""),
|
||||
("project_uid", ""),
|
||||
("slug", ""),
|
||||
("name", ""),
|
||||
("status", ""),
|
||||
("desired_state", ""),
|
||||
("container_id", ""),
|
||||
("ingress_slug", ""),
|
||||
("ingress_port", 0),
|
||||
("ports_json", ""),
|
||||
("container_gateway", ""),
|
||||
("run_as_uid", ""),
|
||||
("boot_language", "none"),
|
||||
("boot_script", ""),
|
||||
("start_on_boot", 0),
|
||||
):
|
||||
if not instances.has_column(column):
|
||||
instances.create_column_by_example(column, example)
|
||||
|
||||
_index(db, "instances", "idx_instances_project", ["project_uid"])
|
||||
_index(db, "instances", "idx_instances_slug", ["slug"])
|
||||
_index(db, "instances", "idx_instances_name", ["name"])
|
||||
_index(db, "instances", "idx_instances_state", ["desired_state", "status"])
|
||||
_index(db, "instances", "idx_instances_container", ["container_id"])
|
||||
_index(db, "instances", "idx_instances_ingress", ["ingress_slug"])
|
||||
@@ -489,6 +584,10 @@ def init_db():
|
||||
from devplacepy.services.openai_gateway import routing as gateway_routing
|
||||
|
||||
gateway_routing.ensure_tables()
|
||||
|
||||
from devplacepy.services.openai_gateway import quota as gateway_quota
|
||||
|
||||
gateway_quota.ensure_tables()
|
||||
_index(db, "audit_log", "idx_audit_created_at", ["created_at"])
|
||||
_index(db, "audit_log", "idx_audit_event_key", ["event_key"])
|
||||
_index(db, "audit_log", "idx_audit_category", ["category"])
|
||||
@@ -540,10 +639,11 @@ def init_db():
|
||||
correction_usage.create_column_by_example(column, example)
|
||||
try:
|
||||
if "correction_usage" in db.tables:
|
||||
db.query(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_correction_usage_user "
|
||||
"ON correction_usage (user_uid)"
|
||||
)
|
||||
with db:
|
||||
db.query(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_correction_usage_user "
|
||||
"ON correction_usage (user_uid)"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not create unique index on correction_usage: {e}")
|
||||
|
||||
@@ -563,10 +663,11 @@ def init_db():
|
||||
modifier_usage.create_column_by_example(column, example)
|
||||
try:
|
||||
if "modifier_usage" in db.tables:
|
||||
db.query(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_modifier_usage_user "
|
||||
"ON modifier_usage (user_uid)"
|
||||
)
|
||||
with db:
|
||||
db.query(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_modifier_usage_user "
|
||||
"ON modifier_usage (user_uid)"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not create unique index on modifier_usage: {e}")
|
||||
|
||||
@@ -586,10 +687,11 @@ def init_db():
|
||||
news_usage.create_column_by_example(column, example)
|
||||
try:
|
||||
if "news_usage" in db.tables:
|
||||
db.query(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_news_usage_user "
|
||||
"ON news_usage (user_uid)"
|
||||
)
|
||||
with db:
|
||||
db.query(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_news_usage_user "
|
||||
"ON news_usage (user_uid)"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not create unique index on news_usage: {e}")
|
||||
|
||||
@@ -609,10 +711,11 @@ def init_db():
|
||||
issue_usage.create_column_by_example(column, example)
|
||||
try:
|
||||
if "issue_usage" in db.tables:
|
||||
db.query(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_issue_usage_user "
|
||||
"ON issue_usage (user_uid)"
|
||||
)
|
||||
with db:
|
||||
db.query(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_issue_usage_user "
|
||||
"ON issue_usage (user_uid)"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not create unique index on issue_usage: {e}")
|
||||
|
||||
@@ -632,13 +735,77 @@ def init_db():
|
||||
seo_usage.create_column_by_example(column, example)
|
||||
try:
|
||||
if "seo_usage" in db.tables:
|
||||
db.query(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_seo_usage_user "
|
||||
"ON seo_usage (user_uid)"
|
||||
)
|
||||
with db:
|
||||
db.query(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_seo_usage_user "
|
||||
"ON seo_usage (user_uid)"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not create unique index on seo_usage: {e}")
|
||||
|
||||
award_usage = get_table("award_usage")
|
||||
for column, example in (
|
||||
("user_uid", ""),
|
||||
("calls", 0),
|
||||
("prompt_tokens", 0),
|
||||
("completion_tokens", 0),
|
||||
("total_tokens", 0),
|
||||
("cost_usd", 0.0),
|
||||
("upstream_latency_ms", 0.0),
|
||||
("total_latency_ms", 0.0),
|
||||
("updated_at", ""),
|
||||
):
|
||||
if not award_usage.has_column(column):
|
||||
award_usage.create_column_by_example(column, example)
|
||||
try:
|
||||
if "award_usage" in db.tables:
|
||||
with db:
|
||||
db.query(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_award_usage_user "
|
||||
"ON award_usage (user_uid)"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not create unique index on award_usage: {e}")
|
||||
|
||||
awards = get_table("awards")
|
||||
for column, example in (
|
||||
("uid", ""),
|
||||
("slug", ""),
|
||||
("description", ""),
|
||||
("giver_uid", ""),
|
||||
("receiver_uid", ""),
|
||||
("attachment_uid_512", ""),
|
||||
("attachment_uid_256", ""),
|
||||
("attachment_uid_64", ""),
|
||||
("generated_at", ""),
|
||||
("created_at", ""),
|
||||
("job_uid", ""),
|
||||
("deleted_at", ""),
|
||||
("deleted_by", ""),
|
||||
):
|
||||
if not awards.has_column(column):
|
||||
awards.create_column_by_example(column, example)
|
||||
try:
|
||||
if "awards" in db.tables:
|
||||
with db:
|
||||
db.query(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_awards_slug ON awards (slug)"
|
||||
)
|
||||
db.query(
|
||||
"CREATE INDEX IF NOT EXISTS idx_awards_receiver_created "
|
||||
"ON awards (receiver_uid, created_at)"
|
||||
)
|
||||
db.query(
|
||||
"CREATE INDEX IF NOT EXISTS idx_awards_giver_created "
|
||||
"ON awards (giver_uid, created_at)"
|
||||
)
|
||||
db.query(
|
||||
"CREATE INDEX IF NOT EXISTS idx_awards_generated "
|
||||
"ON awards (receiver_uid, generated_at)"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not create awards indexes: {e}")
|
||||
|
||||
seo_metadata = get_table("seo_metadata")
|
||||
for column, example in (
|
||||
("uid", ""),
|
||||
@@ -708,10 +875,11 @@ def init_db():
|
||||
user_activity.create_column_by_example(column, example)
|
||||
try:
|
||||
if "user_activity" in db.tables:
|
||||
db.query(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_user_activity_user_action "
|
||||
"ON user_activity (user_uid, action)"
|
||||
)
|
||||
with db:
|
||||
db.query(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_user_activity_user_action "
|
||||
"ON user_activity (user_uid, action)"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not create unique index on user_activity: {e}")
|
||||
|
||||
@@ -726,10 +894,11 @@ def init_db():
|
||||
user_activity_seen.create_column_by_example(column, example)
|
||||
try:
|
||||
if "user_activity_seen" in db.tables:
|
||||
db.query(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_user_activity_seen_unique "
|
||||
"ON user_activity_seen (user_uid, action, target)"
|
||||
)
|
||||
with db:
|
||||
db.query(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_user_activity_seen_unique "
|
||||
"ON user_activity_seen (user_uid, action, target)"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not create unique index on user_activity_seen: {e}")
|
||||
|
||||
@@ -790,6 +959,108 @@ def init_db():
|
||||
deepsearch_url_cache.create_column_by_example(column, example)
|
||||
_index(db, "deepsearch_url_cache", "idx_deepsearch_url_cache_hash", ["url_hash"])
|
||||
|
||||
isslop_analyses = get_table("isslop_analyses")
|
||||
for column, example in (
|
||||
("uid", ""),
|
||||
("owner_kind", ""),
|
||||
("owner_id", ""),
|
||||
("source_url", ""),
|
||||
("source_kind", ""),
|
||||
("status", ""),
|
||||
("created_at", ""),
|
||||
("finished_at", ""),
|
||||
("content_hash", ""),
|
||||
("grade", ""),
|
||||
("slop_score", 0.0),
|
||||
("origin_score", 0.0),
|
||||
("quality_deficit_score", 0.0),
|
||||
("human_percent", 0.0),
|
||||
("ai_percent", 0.0),
|
||||
("category", ""),
|
||||
("confidence", ""),
|
||||
("files_total", 0),
|
||||
("files_analyzed", 0),
|
||||
("error_message_text", ""),
|
||||
("detected_builder", ""),
|
||||
("dom_slop_score", 0.0),
|
||||
("deleted_at", ""),
|
||||
("deleted_by", ""),
|
||||
):
|
||||
if not isslop_analyses.has_column(column):
|
||||
isslop_analyses.create_column_by_example(column, example)
|
||||
_index(db, "isslop_analyses", "idx_isslop_analyses_owner", ["owner_kind", "owner_id", "created_at"])
|
||||
_index(db, "isslop_analyses", "idx_isslop_analyses_status", ["status"])
|
||||
_index(db, "isslop_analyses", "idx_isslop_analyses_hash", ["content_hash"])
|
||||
|
||||
isslop_events = get_table("isslop_events")
|
||||
for column, example in (
|
||||
("analysis_uid", ""),
|
||||
("seq", 0),
|
||||
("kind", ""),
|
||||
("message", ""),
|
||||
("payload", ""),
|
||||
("created_at", ""),
|
||||
):
|
||||
if not isslop_events.has_column(column):
|
||||
isslop_events.create_column_by_example(column, example)
|
||||
_index(db, "isslop_events", "idx_isslop_events_analysis", ["analysis_uid", "seq"])
|
||||
|
||||
isslop_file_results = get_table("isslop_file_results")
|
||||
for column, example in (
|
||||
("analysis_uid", ""),
|
||||
("path", ""),
|
||||
("language", ""),
|
||||
("lines", 0),
|
||||
("origin_score", 0.0),
|
||||
("quality_deficit_score", 0.0),
|
||||
("category", ""),
|
||||
("signals", ""),
|
||||
("source", ""),
|
||||
):
|
||||
if not isslop_file_results.has_column(column):
|
||||
isslop_file_results.create_column_by_example(column, example)
|
||||
_index(db, "isslop_file_results", "idx_isslop_file_results_analysis", ["analysis_uid"])
|
||||
|
||||
isslop_image_results = get_table("isslop_image_results")
|
||||
for column, example in (
|
||||
("analysis_uid", ""),
|
||||
("path", ""),
|
||||
("ai_probability", 0.0),
|
||||
("grade", ""),
|
||||
("verdict", ""),
|
||||
("image_kind", ""),
|
||||
("tells", ""),
|
||||
("description", ""),
|
||||
("thumb", ""),
|
||||
):
|
||||
if not isslop_image_results.has_column(column):
|
||||
isslop_image_results.create_column_by_example(column, example)
|
||||
_index(db, "isslop_image_results", "idx_isslop_image_results_analysis", ["analysis_uid"])
|
||||
|
||||
isslop_dom_results = get_table("isslop_dom_results")
|
||||
for column, example in (
|
||||
("analysis_uid", ""),
|
||||
("url", ""),
|
||||
("detected_builder", ""),
|
||||
("signal_count", 0),
|
||||
("screenshot", ""),
|
||||
("signals", ""),
|
||||
):
|
||||
if not isslop_dom_results.has_column(column):
|
||||
isslop_dom_results.create_column_by_example(column, example)
|
||||
_index(db, "isslop_dom_results", "idx_isslop_dom_results_analysis", ["analysis_uid"])
|
||||
|
||||
isslop_reports = get_table("isslop_reports")
|
||||
for column, example in (
|
||||
("analysis_uid", ""),
|
||||
("markdown", ""),
|
||||
("model_used", ""),
|
||||
("generated_at", ""),
|
||||
):
|
||||
if not isslop_reports.has_column(column):
|
||||
isslop_reports.create_column_by_example(column, example)
|
||||
_index(db, "isslop_reports", "idx_isslop_reports_analysis", ["analysis_uid"], unique=True)
|
||||
|
||||
game_farms = get_table("game_farms")
|
||||
for column, example in (
|
||||
("uid", ""),
|
||||
@@ -813,6 +1084,32 @@ def init_db():
|
||||
("legacy_speed", 0),
|
||||
("legacy_plots", 0),
|
||||
("legacy_defense", 0),
|
||||
("legacy_carryover", 0),
|
||||
("last_grant_week", ""),
|
||||
("prestiged_at", ""),
|
||||
("mastery_points", 0),
|
||||
("mastery_points_earned_total", 0),
|
||||
("mastery_autoreplant", 0),
|
||||
("mastery_analytics", 0),
|
||||
("mastery_contracts", 0),
|
||||
("lifetime_coins_earned", 0),
|
||||
("lifetime_harvests", 0),
|
||||
("infra_registry", 0),
|
||||
("infra_canary", 0),
|
||||
("infra_observability", 0),
|
||||
("defense_level", 0),
|
||||
("defense_last_upkeep_at", ""),
|
||||
("upkeep_amnesty", 0),
|
||||
("active_title", ""),
|
||||
("underdog_boost_until", ""),
|
||||
("contract_boost_until", ""),
|
||||
("harvests_week", 0),
|
||||
("harvests_week_start", ""),
|
||||
("last_kernel_harvest_prestige", 0),
|
||||
("time_to_kernel_seconds", 0),
|
||||
("era_coins", 0),
|
||||
("era_harvests", 0),
|
||||
("era_joined_at", ""),
|
||||
("created_at", ""),
|
||||
("updated_at", ""),
|
||||
):
|
||||
@@ -837,6 +1134,7 @@ def init_db():
|
||||
_index(
|
||||
db, "game_steals", "idx_game_steals_pair", ["thief_uid", "owner_uid", "stolen_at"]
|
||||
)
|
||||
_index(db, "game_steals", "idx_game_steals_owner_time", ["owner_uid", "stolen_at"])
|
||||
|
||||
game_quests = get_table("game_quests")
|
||||
for column, example in (
|
||||
@@ -844,6 +1142,7 @@ def init_db():
|
||||
("farm_uid", ""),
|
||||
("user_uid", ""),
|
||||
("day", ""),
|
||||
("scope", "daily"),
|
||||
("slot_index", 0),
|
||||
("kind", ""),
|
||||
("label", ""),
|
||||
@@ -851,13 +1150,17 @@ def init_db():
|
||||
("progress", 0),
|
||||
("reward_coins", 0),
|
||||
("reward_xp", 0),
|
||||
("reward_stars", 0),
|
||||
("claimed", 0),
|
||||
("created_at", ""),
|
||||
("updated_at", ""),
|
||||
):
|
||||
if not game_quests.has_column(column):
|
||||
game_quests.create_column_by_example(column, example)
|
||||
with db:
|
||||
db.query("UPDATE game_quests SET scope = 'daily' WHERE scope IS NULL OR scope = ''")
|
||||
_index(db, "game_quests", "idx_game_quests_farm_day", ["farm_uid", "day"])
|
||||
_index(db, "game_quests", "idx_game_quests_farm_day_scope", ["farm_uid", "day", "scope"])
|
||||
|
||||
game_plots = get_table("game_plots")
|
||||
for column, example in (
|
||||
@@ -869,6 +1172,7 @@ def init_db():
|
||||
("planted_at", ""),
|
||||
("ready_at", ""),
|
||||
("watered_by", "[]"),
|
||||
("raided_fraction", 0.0),
|
||||
("created_at", ""),
|
||||
("updated_at", ""),
|
||||
):
|
||||
@@ -876,6 +1180,227 @@ def init_db():
|
||||
game_plots.create_column_by_example(column, example)
|
||||
_index(db, "game_plots", "idx_game_plots_farm", ["farm_uid", "slot_index"])
|
||||
|
||||
game_market_ticks = get_table("game_market_ticks")
|
||||
for column, example in (
|
||||
("uid", ""),
|
||||
("crop_key", ""),
|
||||
("hour_bucket", ""),
|
||||
("harvests", 0),
|
||||
("updated_at", ""),
|
||||
):
|
||||
if not game_market_ticks.has_column(column):
|
||||
game_market_ticks.create_column_by_example(column, example)
|
||||
_index(
|
||||
db,
|
||||
"game_market_ticks",
|
||||
"idx_game_market_ticks_bucket",
|
||||
["crop_key", "hour_bucket"],
|
||||
unique=True,
|
||||
)
|
||||
|
||||
game_cosmetics = get_table("game_cosmetics")
|
||||
for column, example in (
|
||||
("uid", ""),
|
||||
("user_uid", ""),
|
||||
("cosmetic_key", ""),
|
||||
("purchased_at", ""),
|
||||
("created_at", ""),
|
||||
):
|
||||
if not game_cosmetics.has_column(column):
|
||||
game_cosmetics.create_column_by_example(column, example)
|
||||
_index(
|
||||
db,
|
||||
"game_cosmetics",
|
||||
"idx_game_cosmetics_owner",
|
||||
["user_uid", "cosmetic_key"],
|
||||
unique=True,
|
||||
)
|
||||
|
||||
game_treasury = get_table("game_treasury")
|
||||
for column, example in (
|
||||
("uid", ""),
|
||||
("balance", 0),
|
||||
("collected_total", 0),
|
||||
("granted_total", 0),
|
||||
("updated_at", ""),
|
||||
):
|
||||
if not game_treasury.has_column(column):
|
||||
game_treasury.create_column_by_example(column, example)
|
||||
|
||||
game_eras = get_table("game_eras")
|
||||
for column, example in (
|
||||
("uid", ""),
|
||||
("era_number", 0),
|
||||
("name", ""),
|
||||
("started_at", ""),
|
||||
("ends_at", ""),
|
||||
("active", 0),
|
||||
("created_at", ""),
|
||||
):
|
||||
if not game_eras.has_column(column):
|
||||
game_eras.create_column_by_example(column, example)
|
||||
_index(db, "game_eras", "idx_game_eras_active", ["active"])
|
||||
|
||||
game_era_results = get_table("game_era_results")
|
||||
for column, example in (
|
||||
("uid", ""),
|
||||
("era_number", 0),
|
||||
("user_uid", ""),
|
||||
("rank", 0),
|
||||
("era_score", 0),
|
||||
("era_coins_final", 0),
|
||||
("joined_at", ""),
|
||||
("reward_stars", 0),
|
||||
("reward_cosmetic_key", ""),
|
||||
("created_at", ""),
|
||||
):
|
||||
if not game_era_results.has_column(column):
|
||||
game_era_results.create_column_by_example(column, example)
|
||||
_index(db, "game_era_results", "idx_game_era_results_era", ["era_number", "rank"])
|
||||
|
||||
quizzes = get_table("quizzes")
|
||||
for column, example in (
|
||||
("uid", ""),
|
||||
("user_uid", ""),
|
||||
("slug", ""),
|
||||
("title", ""),
|
||||
("description", ""),
|
||||
("status", "draft"),
|
||||
("published_at", ""),
|
||||
("shuffle_questions", 0),
|
||||
("shuffle_options", 0),
|
||||
("reveal_answers", 0),
|
||||
("allow_review", 0),
|
||||
("time_limit_seconds", 0),
|
||||
("pass_percent", 0),
|
||||
("question_count", 0),
|
||||
("total_points", 0),
|
||||
("attempt_count", 0),
|
||||
("stars", 0),
|
||||
("content_version", 0),
|
||||
("created_at", ""),
|
||||
("updated_at", ""),
|
||||
("deleted_at", ""),
|
||||
("deleted_by", ""),
|
||||
):
|
||||
if not quizzes.has_column(column):
|
||||
quizzes.create_column_by_example(column, example)
|
||||
_index(db, "quizzes", "idx_quizzes_slug", ["slug"], unique=True)
|
||||
_index(db, "quizzes", "idx_quizzes_user_created", ["user_uid", "created_at"])
|
||||
_index(db, "quizzes", "idx_quizzes_status_created", ["status", "created_at"])
|
||||
_index(
|
||||
db,
|
||||
"quizzes",
|
||||
"idx_quizzes_live_created",
|
||||
["created_at"],
|
||||
where="deleted_at IS NULL",
|
||||
)
|
||||
|
||||
quiz_questions = get_table("quiz_questions")
|
||||
for column, example in (
|
||||
("uid", ""),
|
||||
("quiz_uid", ""),
|
||||
("position", 0),
|
||||
("kind", ""),
|
||||
("prompt", ""),
|
||||
("explanation", ""),
|
||||
("points", 1),
|
||||
("media_attachment_uid", ""),
|
||||
("correct_boolean", 0),
|
||||
("expected_answer", ""),
|
||||
("grading_criteria", ""),
|
||||
("numeric_value", 0.0),
|
||||
("numeric_tolerance", 0.0),
|
||||
("case_sensitive", 0),
|
||||
("created_at", ""),
|
||||
("updated_at", ""),
|
||||
("deleted_at", ""),
|
||||
("deleted_by", ""),
|
||||
):
|
||||
if not quiz_questions.has_column(column):
|
||||
quiz_questions.create_column_by_example(column, example)
|
||||
_index(
|
||||
db, "quiz_questions", "idx_quiz_questions_quiz_position", ["quiz_uid", "position"]
|
||||
)
|
||||
|
||||
quiz_options = get_table("quiz_options")
|
||||
for column, example in (
|
||||
("uid", ""),
|
||||
("question_uid", ""),
|
||||
("quiz_uid", ""),
|
||||
("position", 0),
|
||||
("label", ""),
|
||||
("match_value", ""),
|
||||
("is_correct", 0),
|
||||
("created_at", ""),
|
||||
("updated_at", ""),
|
||||
("deleted_at", ""),
|
||||
("deleted_by", ""),
|
||||
):
|
||||
if not quiz_options.has_column(column):
|
||||
quiz_options.create_column_by_example(column, example)
|
||||
_index(
|
||||
db,
|
||||
"quiz_options",
|
||||
"idx_quiz_options_question_position",
|
||||
["question_uid", "position"],
|
||||
)
|
||||
_index(db, "quiz_options", "idx_quiz_options_quiz", ["quiz_uid"])
|
||||
|
||||
quiz_attempts = get_table("quiz_attempts")
|
||||
for column, example in (
|
||||
("uid", ""),
|
||||
("quiz_uid", ""),
|
||||
("user_uid", ""),
|
||||
("status", "in_progress"),
|
||||
("question_order", "[]"),
|
||||
("started_at", ""),
|
||||
("expires_at", ""),
|
||||
("completed_at", ""),
|
||||
("answered_count", 0),
|
||||
("score_points", 0.0),
|
||||
("max_points", 0),
|
||||
("score_percent", 0.0),
|
||||
("passed", 0),
|
||||
("created_at", ""),
|
||||
("updated_at", ""),
|
||||
("deleted_at", ""),
|
||||
("deleted_by", ""),
|
||||
):
|
||||
if not quiz_attempts.has_column(column):
|
||||
quiz_attempts.create_column_by_example(column, example)
|
||||
_index(db, "quiz_attempts", "idx_quiz_attempts_user_created", ["user_uid", "created_at"])
|
||||
_index(db, "quiz_attempts", "idx_quiz_attempts_quiz_status", ["quiz_uid", "status"])
|
||||
_index(db, "quiz_attempts", "idx_quiz_attempts_user_quiz", ["user_uid", "quiz_uid"])
|
||||
_index(db, "quiz_attempts", "idx_quiz_attempts_status_user", ["status", "user_uid"])
|
||||
|
||||
quiz_answers = get_table("quiz_answers")
|
||||
for column, example in (
|
||||
("uid", ""),
|
||||
("attempt_uid", ""),
|
||||
("question_uid", ""),
|
||||
("quiz_uid", ""),
|
||||
("position", 0),
|
||||
("answer_text", ""),
|
||||
("option_uids", "[]"),
|
||||
("answered_at", ""),
|
||||
("is_correct", 0),
|
||||
("awarded_points", 0.0),
|
||||
("feedback", ""),
|
||||
("graded_by", ""),
|
||||
("confidence", 0.0),
|
||||
("created_at", ""),
|
||||
("updated_at", ""),
|
||||
("deleted_at", ""),
|
||||
("deleted_by", ""),
|
||||
):
|
||||
if not quiz_answers.has_column(column):
|
||||
quiz_answers.create_column_by_example(column, example)
|
||||
_index(
|
||||
db, "quiz_answers", "idx_quiz_answers_attempt_position", ["attempt_uid", "position"]
|
||||
)
|
||||
_index(db, "quiz_answers", "idx_quiz_answers_quiz", ["quiz_uid"])
|
||||
|
||||
_index(db, "posts", "idx_posts_user_created", ["user_uid", "created_at"])
|
||||
_index(
|
||||
db,
|
||||
@@ -1034,7 +1559,11 @@ def init_db():
|
||||
"customization_enabled": "1",
|
||||
"customization_js_enabled": "1",
|
||||
"audit_log_retention_days": "90",
|
||||
"statistics_tracking_enabled": "1",
|
||||
"docs_search_mode": "agent",
|
||||
"outbound_proxy_url": "",
|
||||
"devii_lessons_max_per_owner": "500",
|
||||
"devii_lessons_max_age_days": "90",
|
||||
}
|
||||
for key, value in operational_defaults.items():
|
||||
existing = db["site_settings"].find_one(key=key)
|
||||
@@ -1043,6 +1572,72 @@ def init_db():
|
||||
{"uid": f"default_{key}", "key": key, "value": value}
|
||||
)
|
||||
|
||||
with db:
|
||||
db.query(
|
||||
"CREATE TABLE IF NOT EXISTS visit_stats_hourly ("
|
||||
"bucket_start TEXT NOT NULL, "
|
||||
"page_group TEXT NOT NULL, "
|
||||
"referrer_group TEXT NOT NULL, "
|
||||
"views INTEGER NOT NULL DEFAULT 0, "
|
||||
"member_views INTEGER NOT NULL DEFAULT 0, "
|
||||
"guest_views INTEGER NOT NULL DEFAULT 0)"
|
||||
)
|
||||
db.query(
|
||||
"CREATE TABLE IF NOT EXISTS visit_unique_slots ("
|
||||
"bucket_start TEXT NOT NULL, "
|
||||
"visitor_hash TEXT NOT NULL, "
|
||||
"page_group TEXT NOT NULL, "
|
||||
"user_uid TEXT)"
|
||||
)
|
||||
_index(db, "visit_stats_hourly", "idx_visit_hourly_bucket", ["bucket_start"])
|
||||
_index(
|
||||
db,
|
||||
"visit_stats_hourly",
|
||||
"idx_visit_hourly_page_time",
|
||||
["page_group", "bucket_start"],
|
||||
)
|
||||
_index(
|
||||
db,
|
||||
"visit_stats_hourly",
|
||||
"idx_visit_hourly_ref_time",
|
||||
["referrer_group", "bucket_start"],
|
||||
)
|
||||
_index(
|
||||
db,
|
||||
"visit_stats_hourly",
|
||||
"idx_visit_hourly_unique_row",
|
||||
["bucket_start", "page_group", "referrer_group"],
|
||||
unique=True,
|
||||
)
|
||||
_index(db, "visit_unique_slots", "idx_visit_unique_bucket", ["bucket_start"])
|
||||
_index(
|
||||
db,
|
||||
"visit_unique_slots",
|
||||
"idx_visit_unique_hash",
|
||||
["bucket_start", "visitor_hash"],
|
||||
)
|
||||
_index(
|
||||
db,
|
||||
"visit_unique_slots",
|
||||
"idx_visit_unique_row",
|
||||
["bucket_start", "visitor_hash", "page_group"],
|
||||
unique=True,
|
||||
)
|
||||
_index(db, "issue_tickets", "idx_issue_tickets_created", ["created_at"])
|
||||
_index(db, "devii_turns", "idx_devii_turns_started", ["started_at"])
|
||||
_index(db, "instance_events", "idx_instance_events_created", ["created_at"])
|
||||
_index(db, "game_steals", "idx_game_steals_stolen_at", ["stolen_at"])
|
||||
_index(db, "messages", "idx_messages_created_at", ["created_at"])
|
||||
_index(db, "notifications", "idx_notifications_created", ["created_at"])
|
||||
_index(db, "follows", "idx_follows_created_at", ["created_at"])
|
||||
_index(db, "reactions", "idx_reactions_created_at", ["created_at"])
|
||||
_index(db, "votes", "idx_votes_created_at", ["created_at"])
|
||||
_index(db, "bookmarks", "idx_bookmarks_created_at", ["created_at"])
|
||||
_index(db, "badges", "idx_badges_created_at", ["created_at"])
|
||||
_index(db, "audit_log", "idx_audit_result_created", ["result", "created_at"])
|
||||
_index(db, "jobs", "idx_jobs_created_at", ["created_at"])
|
||||
_index(db, "attachments", "idx_attachments_created_at", ["created_at"])
|
||||
|
||||
_backfill_gamification()
|
||||
backfill_api_keys()
|
||||
migrate_ai_gateway_settings()
|
||||
@@ -1089,6 +1684,15 @@ def migrate_ai_gateway_settings() -> None:
|
||||
if get_setting("bot_model", "") == "deepseek-chat":
|
||||
set_setting("bot_model", "molodetz")
|
||||
logger.info("Migrated bot_model to molodetz")
|
||||
from devplacepy.services.openai_gateway.routing import (
|
||||
migrate_retired_image_gateway,
|
||||
seed_default_deepseek_routes,
|
||||
seed_default_image_routes,
|
||||
)
|
||||
|
||||
seed_default_deepseek_routes()
|
||||
seed_default_image_routes()
|
||||
migrate_retired_image_gateway()
|
||||
|
||||
|
||||
def backfill_api_keys() -> int:
|
||||
@@ -1097,6 +1701,8 @@ def backfill_api_keys() -> int:
|
||||
users = db["users"]
|
||||
if not users.has_column("api_key"):
|
||||
users.create_column_by_example("api_key", "")
|
||||
if not users.has_column("created_at"):
|
||||
users.create_column_by_example("created_at", "")
|
||||
if not users.has_column("cust_disable_global"):
|
||||
users.create_column_by_example("cust_disable_global", 0)
|
||||
if not users.has_column("cust_disable_pagetype"):
|
||||
@@ -1113,10 +1719,22 @@ def backfill_api_keys() -> int:
|
||||
users.create_column_by_example("ai_modifier_sync", 1)
|
||||
if not users.has_column("ai_modifier_prompt"):
|
||||
users.create_column_by_example("ai_modifier_prompt", DEFAULT_MODIFIER_PROMPT)
|
||||
if not users.has_column("interactions_enabled"):
|
||||
users.create_column_by_example("interactions_enabled", -1)
|
||||
if not users.has_column("timezone"):
|
||||
users.create_column_by_example("timezone", "")
|
||||
if not users.has_column("avatar_seed"):
|
||||
users.create_column_by_example("avatar_seed", "")
|
||||
if not users.has_column("last_seen"):
|
||||
users.create_column_by_example("last_seen", "")
|
||||
if not users.has_column("award_count"):
|
||||
users.create_column_by_example("award_count", 0)
|
||||
if not users.has_column("last_award_at"):
|
||||
users.create_column_by_example("last_award_at", "")
|
||||
if not users.has_column("last_award_slug"):
|
||||
users.create_column_by_example("last_award_slug", "")
|
||||
if not users.has_column("last_award_uid"):
|
||||
users.create_column_by_example("last_award_uid", "")
|
||||
with db:
|
||||
db.query(
|
||||
"UPDATE users SET ai_modifier_enabled = 1 WHERE ai_modifier_enabled IS NULL"
|
||||
@@ -1127,6 +1745,10 @@ def backfill_api_keys() -> int:
|
||||
"WHERE ai_modifier_prompt IS NULL OR ai_modifier_prompt = ''",
|
||||
prompt=DEFAULT_MODIFIER_PROMPT,
|
||||
)
|
||||
db.query(
|
||||
"UPDATE users SET interactions_enabled = -1 "
|
||||
"WHERE interactions_enabled IS NULL"
|
||||
)
|
||||
import uuid_utils
|
||||
|
||||
updated = 0
|
||||
@@ -1205,3 +1827,4 @@ def _backfill_gamification():
|
||||
for user in pending:
|
||||
check_milestone_badges(user["uid"])
|
||||
logger.info(f"Gamification backfill processed {len(pending)} users")
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from .core import _in_clause, _now_iso, db, get_table
|
||||
|
||||
|
||||
SEO_META_TYPES = ("post", "project", "gist", "news", "issue")
|
||||
SEO_META_TYPES = ("post", "project", "gist", "news", "issue", "quiz")
|
||||
|
||||
|
||||
def get_seo_metadata(target_type: str, target_uid: str) -> dict | None:
|
||||
|
||||
@@ -35,11 +35,18 @@ SOFT_DELETE_TABLES = [
|
||||
"notification_preferences",
|
||||
"deepsearch_sessions",
|
||||
"deepsearch_messages",
|
||||
"isslop_analyses",
|
||||
"devrant_tokens",
|
||||
"access_tokens",
|
||||
"email_accounts",
|
||||
"user_relations",
|
||||
"seo_metadata",
|
||||
"awards",
|
||||
"quizzes",
|
||||
"quiz_questions",
|
||||
"quiz_options",
|
||||
"quiz_attempts",
|
||||
"quiz_answers",
|
||||
]
|
||||
|
||||
|
||||
@@ -92,11 +99,12 @@ def soft_delete_in(table_name, column, uids, deleted_by, *, stamp=None, **extra)
|
||||
for index, (key, value) in enumerate(extra.items()):
|
||||
params[f"x{index}"] = value
|
||||
extra_sql += f" AND {key} = :x{index}"
|
||||
db.query(
|
||||
f"UPDATE {table_name} SET deleted_at = :dat, deleted_by = :dby "
|
||||
f"WHERE {column} IN ({placeholders}) AND deleted_at IS NULL{extra_sql}",
|
||||
**params,
|
||||
)
|
||||
with db:
|
||||
db.query(
|
||||
f"UPDATE {table_name} SET deleted_at = :dat, deleted_by = :dby "
|
||||
f"WHERE {column} IN ({placeholders}) AND deleted_at IS NULL{extra_sql}",
|
||||
**params,
|
||||
)
|
||||
return len(uids)
|
||||
|
||||
|
||||
@@ -159,11 +167,12 @@ def restore_event(stamp):
|
||||
s=stamp,
|
||||
).__next__()["n"]
|
||||
)
|
||||
db.query(
|
||||
f"UPDATE {table_name} SET deleted_at = NULL, deleted_by = NULL "
|
||||
f"WHERE deleted_at = :s",
|
||||
s=stamp,
|
||||
)
|
||||
with db:
|
||||
db.query(
|
||||
f"UPDATE {table_name} SET deleted_at = NULL, deleted_by = NULL "
|
||||
f"WHERE deleted_at = :s",
|
||||
s=stamp,
|
||||
)
|
||||
return restored
|
||||
|
||||
|
||||
@@ -180,7 +189,8 @@ def purge_event(stamp):
|
||||
)
|
||||
if rows:
|
||||
purged.append((table_name, rows))
|
||||
db.query(
|
||||
f"DELETE FROM {table_name} WHERE deleted_at = :s", s=stamp
|
||||
)
|
||||
with db:
|
||||
db.query(
|
||||
f"DELETE FROM {table_name} WHERE deleted_at = :s", s=stamp
|
||||
)
|
||||
return purged
|
||||
|
||||
@@ -126,3 +126,14 @@ def add_seo_usage(totals: dict) -> None:
|
||||
|
||||
def get_seo_usage() -> dict:
|
||||
return _get_usage("seo_usage", SEO_USAGE_KEY)
|
||||
|
||||
|
||||
AWARD_USAGE_KEY = "award"
|
||||
|
||||
|
||||
def add_award_usage(totals: dict) -> None:
|
||||
_add_usage("award_usage", AWARD_USAGE_KEY, totals)
|
||||
|
||||
|
||||
def get_award_usage() -> dict:
|
||||
return _get_usage("award_usage", AWARD_USAGE_KEY)
|
||||
|
||||
@@ -15,6 +15,9 @@ def get_users_by_uids(uids):
|
||||
|
||||
|
||||
_admins_cache = TTLCache(ttl=300, max_size=4)
|
||||
# The primary administrator must be an account that can actually authenticate, so scan a
|
||||
# few of the earliest admins and skip any that are soft-deleted or deactivated.
|
||||
PRIMARY_ADMIN_CANDIDATES = 50
|
||||
|
||||
|
||||
def invalidate_admins_cache() -> None:
|
||||
@@ -47,6 +50,36 @@ def set_user_timezone(user_uid: str, tz_name: str) -> None:
|
||||
users.update({"uid": user_uid, "timezone": tz_name}, ["uid"])
|
||||
|
||||
|
||||
def set_last_seen(user_uid: str, iso: str) -> None:
|
||||
if "users" not in db.tables or not user_uid or not iso:
|
||||
return
|
||||
users = db["users"]
|
||||
if not users.has_column("last_seen"):
|
||||
users.create_column_by_example("last_seen", "")
|
||||
users.update({"uid": user_uid, "last_seen": iso}, ["uid"])
|
||||
|
||||
|
||||
def get_online_users(cutoff_iso: str, limit: int = 30) -> list:
|
||||
if "users" not in db.tables:
|
||||
return []
|
||||
users = db["users"]
|
||||
if "last_seen" not in users.columns:
|
||||
return []
|
||||
return list(
|
||||
users.find(
|
||||
last_seen={">=": cutoff_iso},
|
||||
order_by=["username"],
|
||||
_limit=limit,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _can_hold_primary_admin(row, tracks_active):
|
||||
if row.get("deleted_at"):
|
||||
return False
|
||||
return not tracks_active or bool(row.get("is_active"))
|
||||
|
||||
|
||||
def get_primary_admin_uid():
|
||||
sync_local_cache("admins", _admins_cache)
|
||||
cached = _admins_cache.get("primary")
|
||||
@@ -56,11 +89,17 @@ def get_primary_admin_uid():
|
||||
return None
|
||||
rows = list(
|
||||
db.query(
|
||||
"SELECT uid FROM users WHERE role = 'Admin' "
|
||||
"ORDER BY created_at ASC, id ASC LIMIT 1"
|
||||
"SELECT * FROM users WHERE role = 'Admin' "
|
||||
"ORDER BY (created_at IS NULL OR created_at = ''), created_at ASC, id ASC "
|
||||
"LIMIT :cap",
|
||||
cap=PRIMARY_ADMIN_CANDIDATES,
|
||||
)
|
||||
)
|
||||
primary = rows[0]["uid"] if rows else None
|
||||
tracks_active = "is_active" in db["users"].columns
|
||||
primary = next(
|
||||
(row["uid"] for row in rows if _can_hold_primary_admin(row, tracks_active)),
|
||||
None,
|
||||
)
|
||||
_admins_cache.set("primary", primary or "")
|
||||
return primary
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import os
|
||||
|
||||
|
||||
def _activate() -> None:
|
||||
if os.environ.get("DEVPLACE_DB_SERVICE") == "1":
|
||||
return
|
||||
if os.environ.get("DEVPLACE_REMOTE_DB") == "1":
|
||||
from devplacepy.database.remote import activate
|
||||
|
||||
activate()
|
||||
|
||||
|
||||
_activate()
|
||||
|
||||
import devplacepy.database as _database
|
||||
|
||||
|
||||
def _remote_table(table) -> bool:
|
||||
return type(table).__name__ == "RemoteTable"
|
||||
|
||||
|
||||
def __getattr__(name: str):
|
||||
return getattr(_database, name)
|
||||
|
||||
|
||||
def __dir__():
|
||||
return sorted(name for name in dir(_database) if not name.startswith("_"))
|
||||
@@ -1,9 +1,9 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
VOTE_TARGETS = ["post", "comment", "gist", "project"]
|
||||
REACTION_TARGETS = ["post", "comment", "gist", "project"]
|
||||
BOOKMARK_TARGETS = ["post", "gist", "project", "news"]
|
||||
COMMENT_TARGETS = ["post", "project", "news", "issue", "gist"]
|
||||
VOTE_TARGETS = ["post", "comment", "gist", "project", "quiz"]
|
||||
REACTION_TARGETS = ["post", "comment", "gist", "project", "quiz"]
|
||||
BOOKMARK_TARGETS = ["post", "gist", "project", "news", "quiz"]
|
||||
COMMENT_TARGETS = ["post", "project", "news", "issue", "gist", "quiz"]
|
||||
PROJECT_TYPES = ["game", "game_asset", "software", "mobile_app", "website"]
|
||||
GIST_LANGUAGES = [
|
||||
"python",
|
||||
|
||||
@@ -19,6 +19,7 @@ from . import (
|
||||
services,
|
||||
admin,
|
||||
game,
|
||||
quizzes,
|
||||
)
|
||||
|
||||
ORDERED_GROUPS = [
|
||||
@@ -40,4 +41,5 @@ ORDERED_GROUPS = [
|
||||
services.GROUP,
|
||||
admin.GROUP,
|
||||
game.GROUP,
|
||||
quizzes.GROUP,
|
||||
]
|
||||
|
||||
@@ -63,6 +63,22 @@ four ways to sign requests.
|
||||
],
|
||||
sample_response={"ok": True, "redirect": "/admin/media"},
|
||||
),
|
||||
endpoint(
|
||||
id="admin-revoke-award",
|
||||
method="POST",
|
||||
path="/admin/awards/{uid}/revoke",
|
||||
title="Revoke award",
|
||||
summary=(
|
||||
"Soft-delete a published award and its linked attachments, then recompute "
|
||||
"receiver stats. Restorable from admin trash."
|
||||
),
|
||||
auth="admin",
|
||||
destructive=True,
|
||||
params=[
|
||||
field("uid", "path", "string", True, "", "Award uid to revoke."),
|
||||
],
|
||||
sample_response={"ok": True, "redirect": "/profile/receiver?tab=awards"},
|
||||
),
|
||||
endpoint(
|
||||
id="admin-media-purge",
|
||||
method="POST",
|
||||
@@ -171,6 +187,84 @@ four ways to sign requests.
|
||||
"This is the endpoint the Devii assistant calls as `site_analytics`; see [Devii internals](/docs/devii-internals.html).",
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="admin-statistics",
|
||||
method="GET",
|
||||
path="/admin/statistics/data",
|
||||
title="Platform statistics",
|
||||
summary=(
|
||||
"Tabbed platform statistics with KPI cards, period-over-period deltas, "
|
||||
"time-series data for charts, and breakdown tables. Covers visitors, members, "
|
||||
"content, engagement, social, AI, Devii, services, containers, game, awards, "
|
||||
"moderation, tools, and storage."
|
||||
),
|
||||
auth="admin",
|
||||
interactive=True,
|
||||
params=[
|
||||
field(
|
||||
"tab",
|
||||
"query",
|
||||
"string",
|
||||
False,
|
||||
"overview",
|
||||
"Tab key (overview, visitors, members, content, ...).",
|
||||
),
|
||||
field(
|
||||
"hours",
|
||||
"query",
|
||||
"int",
|
||||
False,
|
||||
"168",
|
||||
"Lookback window in hours (24, 168, 720, 2160, or 0 for all time).",
|
||||
),
|
||||
field(
|
||||
"compare",
|
||||
"query",
|
||||
"int",
|
||||
False,
|
||||
"1",
|
||||
"Include previous-period comparison (1 or 0).",
|
||||
),
|
||||
field(
|
||||
"top_n",
|
||||
"query",
|
||||
"int",
|
||||
False,
|
||||
"10",
|
||||
"Rows in breakdown tables (1-50).",
|
||||
),
|
||||
],
|
||||
notes=[
|
||||
"The HTML dashboard lives at `/admin/statistics`. Visitor metrics require the statistics tracking middleware (hourly aggregation, 90-day retention).",
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="admin-statistics-page",
|
||||
method="GET",
|
||||
path="/admin/statistics",
|
||||
title="Statistics dashboard",
|
||||
summary="Admin HTML dashboard for platform statistics with charts and tabs.",
|
||||
auth="admin",
|
||||
interactive=False,
|
||||
params=[
|
||||
field(
|
||||
"tab",
|
||||
"query",
|
||||
"string",
|
||||
False,
|
||||
"overview",
|
||||
"Initial tab to render.",
|
||||
),
|
||||
field(
|
||||
"hours",
|
||||
"query",
|
||||
"int",
|
||||
False,
|
||||
"168",
|
||||
"Initial time window in hours.",
|
||||
),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="admin-ai-usage",
|
||||
method="GET",
|
||||
@@ -458,7 +552,7 @@ four ways to sign requests.
|
||||
"string",
|
||||
True,
|
||||
"vote",
|
||||
"One of: comment, reply, mention, vote, follow, message, badge, level, issue.",
|
||||
"One of: comment, reply, mention, vote, follow, message, badge, level, issue, reminder, harvest_stolen.",
|
||||
),
|
||||
field(
|
||||
"channel",
|
||||
@@ -519,6 +613,53 @@ four ways to sign requests.
|
||||
auth="admin",
|
||||
destructive=True,
|
||||
),
|
||||
endpoint(
|
||||
id="admin-gateway-quota-rules",
|
||||
method="GET",
|
||||
path="/admin/gateway/quota-rules",
|
||||
title="List AI gateway quota rules",
|
||||
summary=(
|
||||
"List every rolling-24h USD quota rule on /openai/v1/*, each scoped by any "
|
||||
"combination of role, specific user uid, and app_reference label, plus the "
|
||||
"global per-role default caps that apply when no rule matches."
|
||||
),
|
||||
auth="admin",
|
||||
interactive=True,
|
||||
),
|
||||
endpoint(
|
||||
id="admin-gateway-quota-rule-set",
|
||||
method="POST",
|
||||
path="/admin/gateway/quota-rules",
|
||||
title="Create or update an AI gateway quota rule",
|
||||
summary=(
|
||||
"Caps rolling-24h USD spend on /openai/v1/*. At least one of owner_kind, "
|
||||
"owner_id, app_reference must be set; leaving a dimension blank makes it a "
|
||||
"wildcard, and the most specific active match wins over other rules and over "
|
||||
"the global default. Pass uid to update an existing rule."
|
||||
),
|
||||
auth="admin",
|
||||
params=[
|
||||
field("uid", "json", "string", False, "", "Existing rule uid to update; omit to create a new rule."),
|
||||
field("owner_kind", "json", "string", False, "user", "internal, key, user, admin, or anonymous. Blank = any role."),
|
||||
field("owner_id", "json", "string", False, "", "Specific user uid. Blank = any caller of the matched role."),
|
||||
field("app_reference", "json", "string", False, "devplace-bots-v-1-0-0", "App label (the X-App-Reference header). Blank = any app."),
|
||||
field("limit_usd", "json", "number", True, "2.5", "Rolling 24h USD cap. 0 = unlimited."),
|
||||
field("is_active", "json", "boolean", False, "true", "Whether the rule is enforced."),
|
||||
field("label", "json", "string", False, "", "Optional admin-facing note."),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="admin-gateway-quota-rule-delete",
|
||||
method="DELETE",
|
||||
path="/admin/gateway/quota-rules/{uid}",
|
||||
title="Delete an AI gateway quota rule",
|
||||
summary="Delete a quota rule; callers it covered fall back to the next most specific rule or the global default.",
|
||||
auth="admin",
|
||||
destructive=True,
|
||||
params=[
|
||||
field("uid", "path", "string", True, "RULE_UID", "Quota rule uid."),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="admin-bots-monitor",
|
||||
method="GET",
|
||||
@@ -576,6 +717,52 @@ four ways to sign requests.
|
||||
)
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="admin-devii-tasks",
|
||||
method="GET",
|
||||
path="/admin/devii-tasks",
|
||||
title="Scheduled Devii tasks",
|
||||
summary=(
|
||||
"Every scheduled Devii task across all owners with its schedule, run count, "
|
||||
"expiry, failure streak, and whether its owner may still schedule, plus the "
|
||||
"configured automation bounds. Returns HTML (or JSON with "
|
||||
"Accept: application/json)."
|
||||
),
|
||||
auth="admin",
|
||||
interactive=True,
|
||||
params=[
|
||||
field(
|
||||
"state",
|
||||
"query",
|
||||
"string",
|
||||
False,
|
||||
"active",
|
||||
"One of active, inactive, all.",
|
||||
)
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="admin-devii-task-disable",
|
||||
method="POST",
|
||||
path="/admin/devii-tasks/{uid}/disable",
|
||||
title="Disable a scheduled task",
|
||||
summary="Stop one scheduled task. The row is kept and stays auditable.",
|
||||
auth="admin",
|
||||
params=[
|
||||
field("uid", "path", "string", True, "", "Uid of the task."),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="admin-devii-task-delete",
|
||||
method="POST",
|
||||
path="/admin/devii-tasks/{uid}/delete",
|
||||
title="Delete a scheduled task",
|
||||
summary="Soft-delete one scheduled task; it moves to the admin trash.",
|
||||
auth="admin",
|
||||
params=[
|
||||
field("uid", "path", "string", True, "", "Uid of the task."),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="admin-backups",
|
||||
method="GET",
|
||||
@@ -736,5 +923,37 @@ four ways to sign requests.
|
||||
params=[field("uid", "path", "string", True, "", "Schedule uid.")],
|
||||
sample_response={"ok": True, "redirect": "/admin/backups"},
|
||||
),
|
||||
endpoint(
|
||||
id="admin-game",
|
||||
method="GET",
|
||||
path="/admin/game",
|
||||
title="Code Farm Era management",
|
||||
summary="View the current Code Farm Era status.",
|
||||
auth="admin",
|
||||
sample_response={"era_active": False, "era_name": ""},
|
||||
),
|
||||
endpoint(
|
||||
id="admin-game-era-start",
|
||||
method="POST",
|
||||
path="/admin/game/era/start",
|
||||
title="Start an Era",
|
||||
summary="Start a new Code Farm Era: resets every farm's visible Era coins/harvests counters to zero. Real coins, prestige, stars, Legacy, and Mastery are never touched.",
|
||||
auth="admin",
|
||||
params=[
|
||||
field("name", "form", "string", True, "Genesis", "Era name."),
|
||||
field("duration_days", "form", "int", False, "28", "Planned Era length in days."),
|
||||
],
|
||||
sample_response={"ok": True, "redirect": "/admin/game"},
|
||||
),
|
||||
endpoint(
|
||||
id="admin-game-era-end",
|
||||
method="POST",
|
||||
path="/admin/game/era/end",
|
||||
title="End the running Era",
|
||||
summary="End the current Era: ranks every participating farm by Era score, awards Stars to the top 10 (and an Era-exclusive cosmetic when available), and permanently records the results.",
|
||||
auth="admin",
|
||||
destructive=True,
|
||||
sample_response={"ok": True, "redirect": "/admin/game"},
|
||||
),
|
||||
],
|
||||
}
|
||||
|
||||
@@ -22,8 +22,9 @@ distinguish the form type).
|
||||
The POST endpoints are **actions**: they accept form fields, set or clear the `session` cookie,
|
||||
and return a `302` redirect (or the JSON envelope for JSON callers).
|
||||
|
||||
**Sign-up requires a valid `g-recaptcha-response`** when reCAPTCHA is enabled. Use the JSON
|
||||
envelope to see validation errors as `{ "error": "validation", "fields": {...} }`.
|
||||
**Sign-up requires a unique `username` and `email`** plus a `confirm_password` that matches the
|
||||
password; **you log in with your `email` and password**. JSON callers receive validation errors
|
||||
as a `422` with the shape `{ "fields": {...}, "messages": [...] }`.
|
||||
""",
|
||||
"endpoints": [
|
||||
endpoint(
|
||||
@@ -45,10 +46,10 @@ envelope to see validation errors as `{ "error": "validation", "fields": {...} }
|
||||
encoding="form",
|
||||
destructive=False,
|
||||
params=[
|
||||
field("username", "form", "string", True, "alice", "Username, 3-20 characters."),
|
||||
field("username", "form", "string", True, "alice", "Username, 3-32 characters (letters, numbers, hyphens, underscores)."),
|
||||
field("email", "form", "string", True, "alice@example.com", "Email address; must be unique and contain an @."),
|
||||
field("password", "form", "string", True, "mysecret", "Password, 6+ characters."),
|
||||
field("confirm_password", "form", "string", True, "mysecret", "Must match password."),
|
||||
field("g-recaptcha-response", "form", "string", False, "", "reCAPTCHA token when enabled."),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
@@ -68,12 +69,13 @@ envelope to see validation errors as `{ "error": "validation", "fields": {...} }
|
||||
method="POST",
|
||||
path="/auth/login",
|
||||
title="Log in",
|
||||
summary="Authenticate with username and password. Sets the session cookie.",
|
||||
summary="Authenticate with email and password. Sets the session cookie.",
|
||||
auth="public",
|
||||
encoding="form",
|
||||
params=[
|
||||
field("username", "form", "string", True, "alice", "Your username."),
|
||||
field("email", "form", "string", True, "alice@example.com", "Your registered email."),
|
||||
field("password", "form", "string", True, "mysecret", "Your password."),
|
||||
field("remember_me", "form", "string", False, "on", "Send 'on' to extend the session to the remember-me lifetime."),
|
||||
field("next", "form", "string", False, "", "Redirect target after login."),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -9,9 +9,17 @@ GROUP = {
|
||||
"intro": """
|
||||
# Container Manager
|
||||
|
||||
Build versioned Docker images for a project and run supervised container instances. Every endpoint is
|
||||
**administrator only** (running arbitrary Dockerfiles with docker socket access is root-equivalent).
|
||||
Mutations flip desired state; a single reconciler converges containers to it.
|
||||
Run supervised container instances for a project. There is no in-app image building: every instance
|
||||
runs one shared prebuilt image (`ppy:latest`) with the project's workspace mounted at `/app`. Every
|
||||
endpoint is **administrator only** (docker socket access is root-equivalent). Mutations flip desired
|
||||
state; a single reconciler converges containers to it.
|
||||
|
||||
Containers are additionally **isolated per user**. The primary administrator (the first Admin account)
|
||||
sees and manages every instance, including those attached to private projects. Any other administrator
|
||||
sees instances on public projects plus their own; instances attached to another user's private project
|
||||
are invisible. Managing an instance (edit, lifecycle, exec, terminal, sync, delete, schedules) is
|
||||
restricted to the instance owner (its creator or the owner of its project) and the primary
|
||||
administrator; a non-owner administrator receives `403` on mutations and a view-only detail page.
|
||||
""",
|
||||
"endpoints": [
|
||||
endpoint(
|
||||
@@ -19,7 +27,7 @@ Mutations flip desired state; a single reconciler converges containers to it.
|
||||
method="GET",
|
||||
path="/projects/{project_slug}/containers",
|
||||
title="Container manager page",
|
||||
summary="The admin per-project container manager UI (instance creation and lifecycle). Returns 404 for an administrator who is not the owner of an administrator-hidden project.",
|
||||
summary="The admin per-project container manager UI (instance creation and lifecycle). Returns 404 for an administrator when the project is another user's private project (the primary administrator always has access).",
|
||||
auth="admin",
|
||||
interactive=True,
|
||||
params=[
|
||||
@@ -38,7 +46,7 @@ Mutations flip desired state; a single reconciler converges containers to it.
|
||||
method="GET",
|
||||
path="/admin/containers",
|
||||
title="Admin containers list",
|
||||
summary="The admin Containers section: every instance across all projects, each linking to its detail page. Instances attached to another administrator's hidden project are excluded, and per-instance actions return 404 for a non-owner administrator.",
|
||||
summary="The admin Containers section, scoped per viewer: the primary administrator sees every instance; other administrators see instances on public projects plus their own. Rows the viewer cannot manage are view-only, and mutations on them return 403.",
|
||||
auth="admin",
|
||||
interactive=True,
|
||||
),
|
||||
@@ -47,7 +55,7 @@ Mutations flip desired state; a single reconciler converges containers to it.
|
||||
method="GET",
|
||||
path="/admin/containers/data",
|
||||
title="Admin containers list data",
|
||||
summary="JSON of every instance across all projects (decorated with project title/slug) for polling.",
|
||||
summary="JSON of the viewer-visible instances (decorated with project title/slug and a per-row can_manage flag) for polling.",
|
||||
auth="admin",
|
||||
sample_response={
|
||||
"instances": [
|
||||
@@ -118,7 +126,7 @@ Mutations flip desired state; a single reconciler converges containers to it.
|
||||
"python app.py",
|
||||
"Optional boot command.",
|
||||
),
|
||||
field("run_as_uid", "form", "string", False, "USER_UID", "DevPlace user uid whose identity and API key are injected (PRAVDA_API_KEY, PRAVDA_USER_UID). Does NOT change the container OS user (always pravda, uid 1000)."),
|
||||
field("run_as_uid", "form", "string", False, "USER_UID", "DevPlace user uid whose identity and API key are injected (DEVPLACE_API_KEY, DEVPLACE_USER_UID). Does NOT change the container OS user (always pravda, uid 1000)."),
|
||||
field("boot_language", "form", "enum", False, "none", "Boot source language.", ["none", "python", "bash"]),
|
||||
field("boot_script", "form", "textarea", False, "print('hi')", "Boot source code run on launch (takes precedence over boot_command)."),
|
||||
field(
|
||||
@@ -478,7 +486,7 @@ Mutations flip desired state; a single reconciler converges containers to it.
|
||||
params=[
|
||||
field("project_slug", "form", "string", True, "PROJECT_SLUG", "Project that becomes the /app root."),
|
||||
field("name", "form", "string", True, "staging", "Instance name."),
|
||||
field("run_as_uid", "form", "string", False, "USER_UID", "DevPlace user uid whose identity and API key are injected (PRAVDA_API_KEY, PRAVDA_USER_UID). Does NOT change the container OS user (always pravda, uid 1000)."),
|
||||
field("run_as_uid", "form", "string", False, "USER_UID", "DevPlace user uid whose identity and API key are injected (DEVPLACE_API_KEY, DEVPLACE_USER_UID). Does NOT change the container OS user (always pravda, uid 1000)."),
|
||||
field("boot_language", "form", "enum", False, "none", "Boot source language.", ["none", "python", "bash"]),
|
||||
field("boot_script", "form", "textarea", False, "print('hi')", "Boot source code run on launch (takes precedence over boot_command)."),
|
||||
field("boot_command", "form", "string", False, "python app.py", "Fallback boot command when no boot_script is set."),
|
||||
|
||||
@@ -2,6 +2,27 @@
|
||||
|
||||
from .._shared import endpoint, field
|
||||
|
||||
CROP_KEYS = [
|
||||
"shell",
|
||||
"python",
|
||||
"webapp",
|
||||
"api",
|
||||
"rust",
|
||||
"haskell",
|
||||
"kernel",
|
||||
"distsys",
|
||||
"mlpipe",
|
||||
"secfort",
|
||||
]
|
||||
PERK_KEYS = ["yield", "growth", "discount", "xp"]
|
||||
QUEST_KINDS = ["plant", "harvest", "water", "earn"]
|
||||
QUEST_SCOPES = ["daily", "weekly"]
|
||||
LEGACY_KEYS = ["autoharvest", "multiplier", "speed", "plots", "defense", "carryover"]
|
||||
MASTERY_KEYS = ["autoreplant", "analytics", "contracts"]
|
||||
INFRA_KEYS = ["registry", "canary", "observability"]
|
||||
COSMETIC_KEYS = ["title_architect", "title_refactorer", "title_kernel_hacker", "skin_neon"]
|
||||
BOARD_KEYS = ["score", "prestige", "harvests", "raids", "time_to_kernel", "fair_play", "era"]
|
||||
|
||||
GROUP = {
|
||||
"slug": "game",
|
||||
"title": "Code Farm",
|
||||
@@ -12,8 +33,19 @@ The Code Farm is a cooperative idle game. Each member owns a farm of plots, plan
|
||||
projects that build over real time, harvests them for coins and XP, upgrades their CI tier for
|
||||
faster builds, and waters other members' growing builds to speed them up and earn coins.
|
||||
|
||||
All endpoints negotiate HTML or JSON. The action endpoints return the full farm state so a
|
||||
client can refresh without a second request.
|
||||
Refactoring (prestige) costs a dynamic coin fee that grows with prestige and current wealth;
|
||||
the fees fill a community treasury from which active low-balance farms can claim a weekly grant.
|
||||
|
||||
All endpoints negotiate HTML or JSON. POST bodies are form encoded
|
||||
(`application/x-www-form-urlencoded`). Every own-farm action returns `{"ok": true, "farm": {...}}`
|
||||
- the full updated farm state - so a client can refresh without a second request; the two
|
||||
neighbour actions (water, steal) return the neighbour's farm as `{"farm": {...}}`, and a
|
||||
successful steal adds `stole_coins`. An invalid action (not enough coins, wrong plot state, a
|
||||
protected harvest, an active cooldown) returns HTTP 400 as
|
||||
`{"error": {"status": 400, "message": "..."}}`; an unknown farm username is 404. Reading your
|
||||
own farm state also runs lazy owner effects: the CI Bot legacy upgrade auto-harvests ready
|
||||
builds, and any due Defense upkeep is charged. The complete rules, formulas, and an automated
|
||||
client are on the [Code Farm guide](/docs/code-farm.html).
|
||||
""",
|
||||
"endpoints": [
|
||||
endpoint(
|
||||
@@ -31,7 +63,7 @@ client can refresh without a second request.
|
||||
method="GET",
|
||||
path="/game/state",
|
||||
title="Farm state",
|
||||
summary="The signed-in player's full farm state as JSON.",
|
||||
summary="The signed-in player's full farm state as JSON. Reading it auto-collects ready builds (with the CI Bot legacy upgrade, reported as auto_harvested/auto_harvest_coins/auto_harvest_xp) and charges any due Defense upkeep.",
|
||||
auth="user",
|
||||
sample_response={
|
||||
"ok": True,
|
||||
@@ -40,8 +72,26 @@ client can refresh without a second request.
|
||||
"level": 1,
|
||||
"ci_tier": 1,
|
||||
"plot_count": 4,
|
||||
"plots": [{"slot": 0, "state": "empty"}],
|
||||
"crops": [{"key": "python", "name": "Python Script", "cost": 15}],
|
||||
"prestige": 0,
|
||||
"stars": 0,
|
||||
"refactor_cost": 20000,
|
||||
"plots": [{"slot": 0, "state": "empty", "raided_fraction": 0.0}],
|
||||
"daily_streak_reset": False,
|
||||
"contract_boost_seconds_remaining": 0,
|
||||
"auto_harvested": 0,
|
||||
"steal_max_per_victim_per_day": 3,
|
||||
"defense_downgrade_available": False,
|
||||
"crops": [
|
||||
{
|
||||
"key": "python",
|
||||
"name": "Python Script",
|
||||
"cost": 15,
|
||||
"reward_coins": 36,
|
||||
"grow_seconds": 120,
|
||||
"locked": False,
|
||||
"market_state": "normal",
|
||||
}
|
||||
],
|
||||
},
|
||||
},
|
||||
),
|
||||
@@ -50,16 +100,41 @@ client can refresh without a second request.
|
||||
method="GET",
|
||||
path="/game/leaderboard",
|
||||
title="Farm leaderboard",
|
||||
summary="Top farmers ranked by level, XP, and harvests.",
|
||||
summary="Top 25 farmers on a chosen board: score (default), prestige, harvests (this week), raids (avg coins per successful raid over 30 days, min 3 raids), time_to_kernel, fair_play, or era (current Era only, empty when none is running). Cached about 15 seconds.",
|
||||
auth="public",
|
||||
sample_response={"entries": [{"rank": 1, "username": "alice", "level": 4}]},
|
||||
params=[
|
||||
field(
|
||||
"board",
|
||||
"query",
|
||||
"string",
|
||||
False,
|
||||
"score",
|
||||
"Leaderboard board key.",
|
||||
options=BOARD_KEYS,
|
||||
)
|
||||
],
|
||||
sample_response={
|
||||
"entries": [
|
||||
{
|
||||
"rank": 1,
|
||||
"username": "alice",
|
||||
"level": 4,
|
||||
"xp": 600,
|
||||
"coins": 240,
|
||||
"total_harvests": 52,
|
||||
"prestige": 1,
|
||||
"score": 6120,
|
||||
"title": "The Architect",
|
||||
}
|
||||
]
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="game-view-farm",
|
||||
method="GET",
|
||||
path="/game/farm/{username}",
|
||||
title="View a farm",
|
||||
summary="Another player's farm, with water controls on growing builds.",
|
||||
summary="Another player's farm, with per-plot can_water/can_steal flags computed for the viewer.",
|
||||
auth="public",
|
||||
negotiation=True,
|
||||
params=[field("username", "path", "string", True, "alice", "Farm owner's username.")],
|
||||
@@ -70,11 +145,11 @@ client can refresh without a second request.
|
||||
method="POST",
|
||||
path="/game/plant",
|
||||
title="Plant a crop",
|
||||
summary="Plant a crop in an empty plot. Costs the crop's coin price.",
|
||||
summary="Plant a crop in an empty plot. Costs the crop's live coin price (the cost field in the farm state's crops list).",
|
||||
auth="user",
|
||||
params=[
|
||||
field("slot", "form", "integer", True, "0", "Plot slot index."),
|
||||
field("crop", "form", "string", True, "python", "Crop key."),
|
||||
field("slot", "form", "integer", True, "0", "Plot slot index, 0-based."),
|
||||
field("crop", "form", "string", True, "python", "Crop key.", options=CROP_KEYS),
|
||||
],
|
||||
sample_response={"ok": True, "farm": {"coins": 35}},
|
||||
),
|
||||
@@ -83,9 +158,9 @@ client can refresh without a second request.
|
||||
method="POST",
|
||||
path="/game/harvest",
|
||||
title="Harvest a build",
|
||||
summary="Harvest a finished build for coins and XP.",
|
||||
summary="Harvest a finished (state ready) build for coins and XP.",
|
||||
auth="user",
|
||||
params=[field("slot", "form", "integer", True, "0", "Plot slot index.")],
|
||||
params=[field("slot", "form", "integer", True, "0", "Plot slot index, 0-based.")],
|
||||
sample_response={"ok": True, "farm": {"coins": 86}},
|
||||
),
|
||||
endpoint(
|
||||
@@ -93,7 +168,7 @@ client can refresh without a second request.
|
||||
method="POST",
|
||||
path="/game/buy-plot",
|
||||
title="Buy a plot",
|
||||
summary="Unlock a new plot. Cost doubles per extra plot.",
|
||||
summary="Unlock a new plot (up to 12). Cost starts at 100 coins and doubles per extra plot; the exact price is the farm state's next_plot_cost.",
|
||||
auth="user",
|
||||
sample_response={"ok": True, "farm": {"plot_count": 5}},
|
||||
),
|
||||
@@ -102,7 +177,7 @@ client can refresh without a second request.
|
||||
method="POST",
|
||||
path="/game/upgrade",
|
||||
title="Upgrade CI",
|
||||
summary="Upgrade the farm CI tier for faster builds.",
|
||||
summary="Upgrade the farm CI tier for faster builds (up to tier 5); the exact price is the farm state's ci_next_cost.",
|
||||
auth="user",
|
||||
sample_response={"ok": True, "farm": {"ci_tier": 2}},
|
||||
),
|
||||
@@ -111,11 +186,11 @@ client can refresh without a second request.
|
||||
method="POST",
|
||||
path="/game/farm/{username}/water",
|
||||
title="Water a build",
|
||||
summary="Water another player's growing build to speed it up and earn coins.",
|
||||
summary="Water another player's growing build to cut 8% off its build time; pays the visitor 6 coins scaled by their own prestige and Tech Debt Payoff multiplier, plus 3 XP. Once per visitor per build, 3 waterings per build total.",
|
||||
auth="user",
|
||||
params=[
|
||||
field("username", "path", "string", True, "alice", "Farm owner's username."),
|
||||
field("slot", "form", "integer", True, "0", "Plot slot index."),
|
||||
field("slot", "form", "integer", True, "0", "Plot slot index, 0-based."),
|
||||
],
|
||||
sample_response={"farm": {"owner_username": "alice"}},
|
||||
),
|
||||
@@ -124,11 +199,11 @@ client can refresh without a second request.
|
||||
method="POST",
|
||||
path="/game/farm/{username}/steal",
|
||||
title="Steal a build",
|
||||
summary="Steal another player's ready build once its protection window has passed; you receive half the build's coin value. Limited to once per hour per neighbour.",
|
||||
summary="Raid another player's ready build once its protection window has passed. You take a share of the build's realized coin value (half by default, less against defended owners - the plot's steal_coins field is the exact payout) and the owner keeps and can still harvest the remainder; the plot records the share taken as raided_fraction. Limited to once per hour per neighbour and 3 raids per victim per day; a fully stripped build reports steal_reason stripped, and Security Fortress builds are immune.",
|
||||
auth="user",
|
||||
params=[
|
||||
field("username", "path", "string", True, "alice", "Farm owner's username."),
|
||||
field("slot", "form", "integer", True, "0", "Plot slot index."),
|
||||
field("slot", "form", "integer", True, "0", "Plot slot index, 0-based."),
|
||||
],
|
||||
sample_response={"farm": {"owner_username": "alice"}, "stole_coins": 18},
|
||||
),
|
||||
@@ -137,9 +212,9 @@ client can refresh without a second request.
|
||||
method="POST",
|
||||
path="/game/fertilize",
|
||||
title="Fertilize a build",
|
||||
summary="Spend coins to halve a growing build's remaining time. The cost scales with the build's realized harvest value, so fertilizing is a pure time-skip and never a profit at any prestige.",
|
||||
summary="Spend coins to halve a growing build's remaining time (the plot's fertilize_cost field is the exact price). The price is computed from the exact value this build will pay out - including the golden multiplier and any active contract, Underdog, or Canary upside - so fertilizing is a pure time-skip and never a profit, on any build, at any prestige, with any combination of boosts.",
|
||||
auth="user",
|
||||
params=[field("slot", "form", "integer", True, "0", "Plot slot index.")],
|
||||
params=[field("slot", "form", "integer", True, "0", "Plot slot index, 0-based.")],
|
||||
sample_response={"ok": True, "farm": {"coins": 12}},
|
||||
),
|
||||
endpoint(
|
||||
@@ -147,7 +222,7 @@ client can refresh without a second request.
|
||||
method="POST",
|
||||
path="/game/daily",
|
||||
title="Claim daily bonus",
|
||||
summary="Claim the once-per-day coin bonus; consecutive days grow a streak.",
|
||||
summary="Claim the once-per-UTC-day coin bonus; consecutive days grow a streak (20 coins on day one up to 92 from day seven on), scaled by your own prestige and Tech Debt Payoff multiplier. A lapsed streak resets to day one - the farm state's daily_streak_reset flag and daily_reward already reflect that.",
|
||||
auth="user",
|
||||
sample_response={"ok": True, "farm": {"streak": 3, "coins": 94}},
|
||||
),
|
||||
@@ -156,9 +231,9 @@ client can refresh without a second request.
|
||||
method="POST",
|
||||
path="/game/perk",
|
||||
title="Upgrade a perk",
|
||||
summary="Upgrade a permanent perk: yield, growth, discount, or xp.",
|
||||
summary="Upgrade a permanent perk with coins: yield (+5% harvest coins), growth (+4% build speed), discount (-3% planting cost), or xp (+5% harvest XP) per level. Perks reset on refactor.",
|
||||
auth="user",
|
||||
params=[field("perk", "form", "string", True, "growth", "Perk key.")],
|
||||
params=[field("perk", "form", "string", True, "growth", "Perk key.", options=PERK_KEYS)],
|
||||
sample_response={"ok": True, "farm": {"coins": 0}},
|
||||
),
|
||||
endpoint(
|
||||
@@ -166,9 +241,20 @@ client can refresh without a second request.
|
||||
method="POST",
|
||||
path="/game/quests/claim",
|
||||
title="Claim a quest",
|
||||
summary="Claim a completed daily quest reward by its kind.",
|
||||
summary="Claim a completed daily quest by its kind, or (with scope=weekly, requires the Legacy Contracts Mastery upgrade) the weekly contract, which pays Stars plus a 48-hour +20% coin boost instead of coins.",
|
||||
auth="user",
|
||||
params=[field("quest", "form", "string", True, "harvest", "Quest kind.")],
|
||||
params=[
|
||||
field("quest", "form", "string", True, "harvest", "Quest kind.", options=QUEST_KINDS),
|
||||
field(
|
||||
"scope",
|
||||
"form",
|
||||
"string",
|
||||
False,
|
||||
"daily",
|
||||
"daily (default) or weekly.",
|
||||
options=QUEST_SCOPES,
|
||||
),
|
||||
],
|
||||
sample_response={"ok": True, "farm": {"coins": 130}},
|
||||
),
|
||||
endpoint(
|
||||
@@ -176,20 +262,137 @@ client can refresh without a second request.
|
||||
method="POST",
|
||||
path="/game/prestige",
|
||||
title="Refactor (prestige)",
|
||||
summary="Reset the farm at level 10+ for a permanent +25% coin bonus and earn Stars to spend on Legacy upgrades.",
|
||||
summary="Reset the farm at level 10+ for a permanent +25% coin bonus and earn Stars to spend on Legacy upgrades. Refactoring costs a coin fee that scales with prestige and current wealth (the farm state's refactor_cost); the fee funds the community treasury and a fraction of the remaining coins (10% base, up to 35% with the Golden Parachute Legacy upgrade) carries over. From prestige 50 onward, every 10 more prestige also earns a permanent Mastery point.",
|
||||
auth="user",
|
||||
destructive=True,
|
||||
sample_response={"ok": True, "farm": {"prestige": 1}},
|
||||
sample_response={"ok": True, "farm": {"prestige": 1, "coins": 6550}},
|
||||
),
|
||||
endpoint(
|
||||
id="game-grant",
|
||||
method="POST",
|
||||
path="/game/grant",
|
||||
title="Claim the community grant",
|
||||
summary="Claim the weekly community grant, paid from the treasury filled by refactor fees and divided between everyone currently eligible (capped at 2500 coins, suppressed below 250). Eligible farms are active (5+ harvests this week), below 10000 coins, and at most prestige 5.",
|
||||
auth="user",
|
||||
sample_response={"ok": True, "farm": {"coins": 2550}},
|
||||
),
|
||||
endpoint(
|
||||
id="game-legacy",
|
||||
method="POST",
|
||||
path="/game/legacy",
|
||||
title="Buy a Legacy upgrade",
|
||||
summary="Spend Stars on a permanent Legacy upgrade that survives every refactor: autoharvest, multiplier, speed, plots, or defense.",
|
||||
summary="Spend Stars on a permanent Legacy upgrade that survives every refactor: autoharvest (CI Bot), multiplier (+10% coins/level), speed (+5% build speed/level), plots (+1 starting plot/level), defense (+30s grace, -5% steal loss/level), or carryover (Golden Parachute, +5% refactor carry-over/level).",
|
||||
auth="user",
|
||||
params=[field("key", "form", "string", True, "multiplier", "Legacy upgrade key.")],
|
||||
params=[
|
||||
field(
|
||||
"key",
|
||||
"form",
|
||||
"string",
|
||||
True,
|
||||
"multiplier",
|
||||
"Legacy upgrade key.",
|
||||
options=LEGACY_KEYS,
|
||||
)
|
||||
],
|
||||
sample_response={"ok": True, "farm": {"stars": 1}},
|
||||
),
|
||||
endpoint(
|
||||
id="game-mastery",
|
||||
method="POST",
|
||||
path="/game/mastery",
|
||||
title="Buy a Mastery upgrade",
|
||||
summary="Spend Mastery points (earned at prestige 50 and every 5 prestige after) on a permanent Mastery upgrade: autoreplant (Continuous Delivery, 3 points), analytics (Farm Analytics, 2 points), or contracts (Legacy Contracts, 4 points).",
|
||||
auth="user",
|
||||
params=[
|
||||
field(
|
||||
"key",
|
||||
"form",
|
||||
"string",
|
||||
True,
|
||||
"autoreplant",
|
||||
"Mastery upgrade key.",
|
||||
options=MASTERY_KEYS,
|
||||
)
|
||||
],
|
||||
sample_response={"ok": True, "farm": {"mastery_points": 0}},
|
||||
),
|
||||
endpoint(
|
||||
id="game-infrastructure-buy",
|
||||
method="POST",
|
||||
path="/game/infrastructure/buy",
|
||||
title="Buy Infrastructure",
|
||||
summary="Buy a permanent, expensive, prestige-gated Infrastructure building: registry (Rust/Compiler/Kernel build 15% faster; 3M coins, prestige 3), canary (12% chance to double a harvest, 6% to only refund its planting cost; 6M, prestige 8), or observability (caps what any raider can take from you at 20% of a build's value; 15M, prestige 15).",
|
||||
auth="user",
|
||||
params=[
|
||||
field(
|
||||
"key",
|
||||
"form",
|
||||
"string",
|
||||
True,
|
||||
"registry",
|
||||
"Infrastructure key.",
|
||||
options=INFRA_KEYS,
|
||||
)
|
||||
],
|
||||
sample_response={"ok": True, "farm": {"coins": 0}},
|
||||
),
|
||||
endpoint(
|
||||
id="game-defense-upgrade",
|
||||
method="POST",
|
||||
path="/game/defense/upgrade",
|
||||
title="Upgrade Defense",
|
||||
summary="Buy the next Defense tier (Firewall through Zero Trust Mesh; the farm state's defense_next_cost is the exact price). Each tier multiplicatively reduces what a raider takes from you and adds steal grace, but adds an ongoing daily coin upkeep of max(tier minimum, 0.2% of your balance). If you cannot pay, the tier decays by one level and only what you can afford is taken - your balance is never emptied.",
|
||||
auth="user",
|
||||
sample_response={"ok": True, "farm": {"defense_level": 1}},
|
||||
),
|
||||
endpoint(
|
||||
id="game-defense-downgrade",
|
||||
method="POST",
|
||||
path="/game/defense/downgrade",
|
||||
title="Downgrade Defense",
|
||||
summary="Drop your Defense down one tier to escape its daily upkeep. There is no refund. Available whenever defense_downgrade_available is true in the farm state.",
|
||||
auth="user",
|
||||
sample_response={"ok": True, "farm": {"defense_level": 0}},
|
||||
),
|
||||
endpoint(
|
||||
id="game-cosmetics-buy",
|
||||
method="POST",
|
||||
path="/game/cosmetics/buy",
|
||||
title="Buy a cosmetic",
|
||||
summary="Buy a purely cosmetic title or plot skin with coins. No gameplay effect. The farm state's cosmetics list carries each key, cost, and an owned flag.",
|
||||
auth="user",
|
||||
params=[
|
||||
field(
|
||||
"key",
|
||||
"form",
|
||||
"string",
|
||||
True,
|
||||
"title_architect",
|
||||
"Cosmetic key.",
|
||||
options=COSMETIC_KEYS,
|
||||
)
|
||||
],
|
||||
sample_response={"ok": True, "farm": {"coins": 0}},
|
||||
),
|
||||
endpoint(
|
||||
id="game-cosmetics-equip",
|
||||
method="POST",
|
||||
path="/game/cosmetics/equip",
|
||||
title="Equip a title",
|
||||
summary="Equip an owned title cosmetic so its display name shows next to your name on the leaderboard.",
|
||||
auth="user",
|
||||
params=[
|
||||
field(
|
||||
"key",
|
||||
"form",
|
||||
"string",
|
||||
True,
|
||||
"title_architect",
|
||||
"An owned title cosmetic key.",
|
||||
options=COSMETIC_KEYS,
|
||||
)
|
||||
],
|
||||
sample_response={"ok": True, "farm": {"active_title": "title_architect"}},
|
||||
),
|
||||
],
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ from .._shared import endpoint, field
|
||||
GROUP = {
|
||||
"slug": "gateway",
|
||||
"title": "OpenAI Gateway",
|
||||
"admin": True,
|
||||
"intro": """
|
||||
# OpenAI Gateway
|
||||
|
||||
@@ -32,6 +31,31 @@ The gateway additionally serves **text embeddings** at `/openai/v1/embeddings`.
|
||||
generic model `molodetz~embed`, which the gateway maps to the configured embedding model (OpenRouter's
|
||||
Qwen3 8B embedding model by default). Usage and cost are tracked per call exactly like chat and vision.
|
||||
|
||||
The gateway also serves **image generation** at `/openai/v1/images/generations`. Clients request the
|
||||
generic model `molodetz-img-small`, which the gateway maps to the configured image model (OpenRouter's
|
||||
Flux 1.1 Pro by default). Cost is tracked per call with a flat per-image price when the upstream
|
||||
returns no native cost.
|
||||
|
||||
## Quick start
|
||||
|
||||
Copy the command below and paste it into a terminal. If you are signed in the `{{ api_key }}`
|
||||
and `{{ app_reference }}` placeholders are already filled in with your own values; otherwise
|
||||
replace them with the API key from your [profile](/profile) page and any application identifier.
|
||||
|
||||
```bash
|
||||
curl -X POST "{{ base }}/openai/v1/chat/completions" \
|
||||
-H "Authorization: Bearer {{ api_key }}" \
|
||||
-H "X-App-Reference: {{ app_reference }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"model": "molodetz",
|
||||
"messages": [{"role": "user", "content": "Hello, how are you?"}]
|
||||
}'
|
||||
```
|
||||
|
||||
The response carries `X-Gateway-*` headers with token counts and dollar cost for the call.
|
||||
For streaming, add `"stream": true` to the JSON body.
|
||||
|
||||
## Model routing and providers
|
||||
|
||||
On top of the single default upstream above, an administrator can register additional named
|
||||
@@ -60,7 +84,7 @@ and dollar cost directly from the response with no extra request:
|
||||
| Header | Meaning |
|
||||
|--------|---------|
|
||||
| `X-Gateway-Model` | Upstream model actually used for the call |
|
||||
| `X-Gateway-Backend` | Backend that served it: `chat`, `embed`, or passthrough |
|
||||
| `X-Gateway-Backend` | Backend that served it: `chat`, `embed`, `image`, or passthrough |
|
||||
| `X-Gateway-Prompt-Tokens` | Input (prompt) tokens |
|
||||
| `X-Gateway-Completion-Tokens` | Output (completion) tokens |
|
||||
| `X-Gateway-Total-Tokens` | Total tokens (prompt + completion) |
|
||||
@@ -83,7 +107,18 @@ and dollar cost directly from the response with no extra request:
|
||||
Dollar costs use the upstream's native `cost` field when it returns one
|
||||
(`X-Gateway-Cost-Native: 1`); otherwise they are computed from the per-million prices of the matched
|
||||
model route, falling back to the prices configured on the `openai` service when no route matches. The
|
||||
one denied path that makes no upstream call (embeddings disabled) returns no usage headers.
|
||||
denied paths that make no upstream call (embeddings or image generation disabled) return no usage headers.
|
||||
|
||||
## Request header `X-App-Reference`
|
||||
|
||||
Clients **SHOULD** send an `X-App-Reference` header to identify themselves for cost attribution.
|
||||
The value is a free-form slug (max 30 characters, letters, digits, `_`, `.`, `-`). When missing or
|
||||
invalid, the gateway defaults to `default`. The value is recorded in every usage ledger row and can
|
||||
be queried alongside owner-kind and owner-id to attribute spending per application.
|
||||
|
||||
```
|
||||
X-App-Reference: devplace-devii-v-1-0-0
|
||||
```
|
||||
|
||||
Administrators enable and configure this gateway under [Background Services](/docs/services.html)
|
||||
(the `openai` service).
|
||||
@@ -108,7 +143,7 @@ for signing DevPlace's own requests.
|
||||
"string",
|
||||
False,
|
||||
"gpt-4o-mini",
|
||||
"Model id. When it matches a configured model route the gateway forwards to that route's provider and upstream model; otherwise it uses the default upstream model.",
|
||||
"Model id. When it matches a configured model route the gateway forwards to that route's provider and upstream model; otherwise it falls back to the configured default upstream model.",
|
||||
),
|
||||
field(
|
||||
"messages",
|
||||
@@ -170,6 +205,55 @@ for signing DevPlace's own requests.
|
||||
notes=[
|
||||
"Returns `503` when the gateway service is not running or embeddings are disabled.",
|
||||
"Every response carries the `X-Gateway-*` token and dollar-cost headers (see Per-call cost and usage headers above).",
|
||||
"If `model` matches a configured embed model route it is forwarded to that route's provider and upstream model; otherwise it falls back to the configured default embedding model.",
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="gateway-images",
|
||||
method="POST",
|
||||
path="/openai/v1/images/generations",
|
||||
title="Image generation",
|
||||
summary="OpenAI-compatible image generation. Request model molodetz-img-small.",
|
||||
auth="user",
|
||||
encoding="json",
|
||||
params=[
|
||||
field(
|
||||
"model",
|
||||
"json",
|
||||
"string",
|
||||
False,
|
||||
"molodetz-img-small",
|
||||
"Image model id; the gateway maps molodetz-img-small to the configured model, or to a matching image model route's provider and target model.",
|
||||
),
|
||||
field(
|
||||
"prompt",
|
||||
"json",
|
||||
"string",
|
||||
True,
|
||||
'"a decorative developer award emblem"',
|
||||
"Text prompt describing the image to generate.",
|
||||
),
|
||||
field(
|
||||
"size",
|
||||
"json",
|
||||
"string",
|
||||
False,
|
||||
"512x512",
|
||||
"Output dimensions (provider-dependent).",
|
||||
),
|
||||
field(
|
||||
"response_format",
|
||||
"json",
|
||||
"string",
|
||||
False,
|
||||
"b64_json",
|
||||
"Return format: url or b64_json.",
|
||||
),
|
||||
],
|
||||
notes=[
|
||||
"Returns `503` when the gateway service is not running or image generation is disabled.",
|
||||
"Every response carries the `X-Gateway-*` token and dollar-cost headers (see Per-call cost and usage headers above).",
|
||||
"If `model` matches a configured image model route it is forwarded to that route's provider and upstream model; otherwise it falls back to the configured default image model.",
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
|
||||
@@ -57,9 +57,9 @@ four ways to sign requests.
|
||||
"content",
|
||||
"form",
|
||||
"textarea",
|
||||
True,
|
||||
False,
|
||||
"Hello there.",
|
||||
"Body, 1-2000 characters.",
|
||||
"Body, 0-2000 characters. May be empty when at least one attachment is provided.",
|
||||
),
|
||||
field(
|
||||
"receiver_uid",
|
||||
@@ -71,5 +71,38 @@ four ways to sign requests.
|
||||
),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="messages-conversations",
|
||||
method="GET",
|
||||
path="/messages/conversations",
|
||||
title="List conversations",
|
||||
summary="Return the signed-in user's conversation list as JSON, for live refresh without a full page reload.",
|
||||
auth="user",
|
||||
interactive=False,
|
||||
sample_response={
|
||||
"conversations": [
|
||||
{
|
||||
"other_user": {"uid": "8f14e45f-...", "username": "alice_test"},
|
||||
"last_message": "Hello there.",
|
||||
"last_message_at": "2026-07-21T10:00:00+00:00",
|
||||
"unread": True,
|
||||
}
|
||||
]
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="messages-ws-ticket",
|
||||
method="POST",
|
||||
path="/messages/ws-ticket",
|
||||
title="Issue a WebSocket ticket",
|
||||
summary="Exchange the caller's session/API-key auth for a short-lived, single-use ticket that a browser WebSocket handshake can carry as a query parameter (a native WebSocket cannot set custom auth headers).",
|
||||
auth="user",
|
||||
encoding="none",
|
||||
interactive=False,
|
||||
notes=[
|
||||
"The ticket is valid for 30 seconds and can be redeemed exactly once, as `wss://.../messages/ws?ticket=<ticket>`.",
|
||||
],
|
||||
sample_response={"ticket": "3f9c2a...", "expires_in": 30},
|
||||
),
|
||||
],
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ four ways to sign requests.
|
||||
False,
|
||||
"posts",
|
||||
"Profile tab.",
|
||||
["posts", "activity", "followers", "following", "media"],
|
||||
["posts", "activity", "followers", "following", "media", "awards"],
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -41,7 +41,7 @@ four ways to sign requests.
|
||||
method="GET",
|
||||
path="/profile/{username}",
|
||||
title="View a profile",
|
||||
summary="Render a user profile. Returns an HTML page.",
|
||||
summary="Render a user profile, including an online-presence indicator (JSON exposes profile_online and profile_user.last_seen). Returns an HTML page.",
|
||||
auth="public",
|
||||
interactive=True,
|
||||
params=[
|
||||
@@ -60,7 +60,7 @@ four ways to sign requests.
|
||||
False,
|
||||
"posts",
|
||||
"Profile tab.",
|
||||
["posts", "activity", "followers", "following", "media"],
|
||||
["posts", "activity", "followers", "following", "media", "awards"],
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -136,7 +136,7 @@ four ways to sign requests.
|
||||
"textarea",
|
||||
False,
|
||||
"Leave literary as is, only do punctuation and casing",
|
||||
"Correction instruction, up to 2000 characters.",
|
||||
"Correction instruction, up to 20000 characters.",
|
||||
),
|
||||
],
|
||||
sample_response={
|
||||
@@ -150,6 +150,53 @@ four ways to sign requests.
|
||||
},
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="profile-interactions",
|
||||
method="POST",
|
||||
path="/profile/{username}/interactions",
|
||||
title="Configure Devii interactive widgets",
|
||||
summary="Enable or disable CA-IWP interactive prompts (ui_prompt) for this account, or reset to the administrator default. Guests always use the site default. Admins may target any user.",
|
||||
auth="user",
|
||||
encoding="form",
|
||||
destructive=True,
|
||||
params=[
|
||||
field(
|
||||
"username",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"bob_test",
|
||||
"Profile owner. Must be yourself unless you are an admin.",
|
||||
),
|
||||
field(
|
||||
"enabled",
|
||||
"form",
|
||||
"boolean",
|
||||
False,
|
||||
"true",
|
||||
"true to enable interactive widgets, false to disable. Ignored when reset is true.",
|
||||
),
|
||||
field(
|
||||
"reset",
|
||||
"form",
|
||||
"boolean",
|
||||
False,
|
||||
"false",
|
||||
"true to clear the user override and inherit the administrator default.",
|
||||
),
|
||||
],
|
||||
sample_response={
|
||||
"ok": True,
|
||||
"redirect": "/profile/bob_test",
|
||||
"data": {
|
||||
"url": "/profile/bob_test",
|
||||
"enabled": True,
|
||||
"source": "user",
|
||||
"default": True,
|
||||
"override": True,
|
||||
},
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="profile-ai-modifier",
|
||||
method="POST",
|
||||
@@ -190,7 +237,7 @@ four ways to sign requests.
|
||||
"textarea",
|
||||
False,
|
||||
"Execute what is behind `@ai` (the prompt) and replace that part including `@ai`",
|
||||
"Modifier instruction, up to 2000 characters.",
|
||||
"Modifier instruction, up to 20000 characters.",
|
||||
),
|
||||
],
|
||||
sample_response={
|
||||
@@ -254,6 +301,40 @@ four ways to sign requests.
|
||||
],
|
||||
sample_response={"api_key": "NEW_UUID"},
|
||||
),
|
||||
endpoint(
|
||||
id="profile-give-award",
|
||||
method="POST",
|
||||
path="/profile/{username}/award",
|
||||
title="Give a member an award",
|
||||
summary="Create a pending award on another member's profile and enqueue image generation.",
|
||||
auth="user",
|
||||
encoding="json",
|
||||
params=[
|
||||
field(
|
||||
"username",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"{{ username }}",
|
||||
"Receiver username.",
|
||||
),
|
||||
field(
|
||||
"description",
|
||||
"json",
|
||||
"string",
|
||||
True,
|
||||
"Great work on the release!",
|
||||
"Award message (1-125 characters).",
|
||||
),
|
||||
],
|
||||
sample_response={
|
||||
"ok": True,
|
||||
"data": {
|
||||
"award_uid": "AWARD_UID",
|
||||
"award_slug": "abc123-great-work",
|
||||
},
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="profile-regenerate-avatar",
|
||||
method="POST",
|
||||
@@ -344,7 +425,7 @@ four ways to sign requests.
|
||||
method="POST",
|
||||
path="/profile/{username}/notifications",
|
||||
title="Toggle a notification preference",
|
||||
summary="Enable or disable one notification type on one channel (in-app or push). Admins may target any user. Types: comment, reply, mention, vote, follow, message, badge, level, issue.",
|
||||
summary="Enable or disable one notification type on one channel (in-app or push). Admins may target any user. Types: comment, reply, mention, vote, follow, message, badge, level, issue, reminder, harvest_stolen.",
|
||||
auth="user",
|
||||
encoding="form",
|
||||
destructive=True,
|
||||
@@ -363,7 +444,7 @@ four ways to sign requests.
|
||||
"string",
|
||||
True,
|
||||
"vote",
|
||||
"One of: comment, reply, mention, vote, follow, message, badge, level, issue.",
|
||||
"One of: comment, reply, mention, vote, follow, message, badge, level, issue, reminder, harvest_stolen.",
|
||||
),
|
||||
field(
|
||||
"channel",
|
||||
@@ -650,6 +731,37 @@ four ways to sign requests.
|
||||
auth="public",
|
||||
interactive=True,
|
||||
),
|
||||
endpoint(
|
||||
id="award-image",
|
||||
method="GET",
|
||||
path="/awards/{slug_or_uid}/{size}",
|
||||
title="Award image redirect",
|
||||
summary="Redirect to the stored PNG attachment for a published award.",
|
||||
auth="public",
|
||||
params=[
|
||||
field(
|
||||
"slug_or_uid",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"abc123-great-work",
|
||||
"Award slug or bare uid.",
|
||||
),
|
||||
field(
|
||||
"size",
|
||||
"path",
|
||||
"enum",
|
||||
True,
|
||||
"256",
|
||||
"Image size.",
|
||||
["512", "256", "64"],
|
||||
),
|
||||
],
|
||||
notes=[
|
||||
"> Pending or revoked awards return 404.",
|
||||
"> Response includes long-lived cache headers.",
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="avatar",
|
||||
method="GET",
|
||||
|
||||
@@ -0,0 +1,556 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from devplacepy.services.quiz import scoring
|
||||
|
||||
from .._shared import endpoint, field
|
||||
|
||||
KIND_KEYS = list(scoring.KIND_KEYS)
|
||||
FILTER_KEYS = ["all", "todo", "done", "mine", "drafts"]
|
||||
STATUS_KEYS = ["draft", "published"]
|
||||
GRADED_BY_KEYS = ["auto", "ai", "fallback"]
|
||||
VIEWER_STATES = ["todo", "in_progress", "done"]
|
||||
|
||||
SAMPLE_QUIZ = {
|
||||
"uid": "0198f2c0-1111-7aaa-8bbb-000000000001",
|
||||
"slug": "8bbb000000000001-sqlite-fundamentals",
|
||||
"url": "/quizzes/8bbb000000000001-sqlite-fundamentals",
|
||||
"title": "SQLite fundamentals",
|
||||
"status": "published",
|
||||
"question_count": 10,
|
||||
"total_points": 14,
|
||||
"attempt_count": 23,
|
||||
"time_limit_seconds": 900,
|
||||
"pass_percent": 70,
|
||||
"viewer_owns": False,
|
||||
"viewer_can_edit": False,
|
||||
"viewer_can_play": True,
|
||||
"viewer_state": "todo",
|
||||
"validation_errors": [],
|
||||
}
|
||||
|
||||
GROUP = {
|
||||
"slug": "quizzes",
|
||||
"title": "Quizzes",
|
||||
"intro": """
|
||||
# Quizzes
|
||||
|
||||
A quiz is user-generated content like a gist or a project: it has an owner, a slug, comments,
|
||||
votes, bookmarks and reactions. Any signed-in member authors quizzes, every member plays them,
|
||||
and guests read published ones.
|
||||
|
||||
**Publishing is terminal.** A draft is fully editable; the moment its owner publishes it, the
|
||||
quiz, its questions and its options are frozen forever. There is no unpublish and no
|
||||
post-publish edit, which is what makes two members' scores on the same quiz comparable. Every
|
||||
write endpoint on a published quiz returns `400`; only delete still works. Publish validates
|
||||
the whole quiz first and refuses with the exact list of problems.
|
||||
|
||||
Playing a quiz creates an **attempt**. There is at most one in-progress attempt per member per
|
||||
quiz - starting again returns the existing one. Each question can be answered exactly once; a
|
||||
second submit returns `400` and credits nothing. A time limit is stored on the attempt and
|
||||
evaluated lazily on read, so an expired attempt reads as `expired` with no background process
|
||||
involved.
|
||||
|
||||
Seven question kinds are graded deterministically. The eighth, `free_text`, is graded by the
|
||||
internal AI gateway against the author's criteria and billed to the answering member's own API
|
||||
key. When the gateway is unavailable the answer is still graded, by a deterministic
|
||||
token-overlap fallback, and the answer carries `graded_by: "fallback"` so the degradation is
|
||||
visible rather than silent. `graded_by` is one of `auto`, `ai`, `fallback`.
|
||||
|
||||
**Correct answers are never served to a player mid-attempt.** `is_correct` on the options and
|
||||
`correct_boolean` / `expected_answer` / `numeric_value` / `match_value` on the question are
|
||||
omitted unless the viewer owns the quiz, or the question has already been answered in this
|
||||
attempt and the quiz has `reveal_answers` on. A public export of a published quiz omits them
|
||||
too; the owner's export includes them.
|
||||
|
||||
The **scoreboard** at `/quizzes/scoreboard` sums each member's **best** completed attempt per
|
||||
quiz, never the sum of all attempts, so replaying a quiz can raise a member's contribution to
|
||||
their personal best and never beyond it. Quizzes a member wrote themselves count like any
|
||||
other.
|
||||
|
||||
All endpoints negotiate HTML or JSON. POST bodies are form encoded
|
||||
(`application/x-www-form-urlencoded`). Action POSTs answer `{"ok": true, "redirect": "...",
|
||||
"data": {...}}`; an invalid domain operation answers `400` as
|
||||
`{"error": {"status": 400, "message": "..."}}`.
|
||||
""",
|
||||
"endpoints": [
|
||||
endpoint(
|
||||
id="quizzes-list",
|
||||
method="GET",
|
||||
path="/quizzes",
|
||||
title="Quiz hub",
|
||||
summary=(
|
||||
"Published quizzes with the viewer's per-quiz state, the filter counts and "
|
||||
"the cross-quiz scoreboard."
|
||||
),
|
||||
auth="public",
|
||||
negotiation=True,
|
||||
params=[
|
||||
field("search", "query", "string", False, "sqlite", "Match the title, description or author username."),
|
||||
field("filter", "query", "enum", False, "all", "Which quizzes to list.", options=FILTER_KEYS),
|
||||
field("page", "query", "integer", False, "1", "1-based page number."),
|
||||
],
|
||||
sample_response={
|
||||
"quizzes": [
|
||||
{
|
||||
**SAMPLE_QUIZ,
|
||||
"viewer_best_percent": 0.0,
|
||||
"comment_count": 3,
|
||||
"stars": 5,
|
||||
}
|
||||
],
|
||||
"filter": "all",
|
||||
"counts": {"all": 12, "todo": 9, "done": 3, "mine": 2, "drafts": 1},
|
||||
"pagination": {"page": 1, "total": 12, "total_pages": 1},
|
||||
"scoreboard": [
|
||||
{"rank": 1, "user": {"username": "alice"}, "total_points": 84.0,
|
||||
"quizzes_completed": 7, "avg_percent": 88.4, "perfect_count": 2}
|
||||
],
|
||||
"viewer_can_create": True,
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="quizzes-scoreboard",
|
||||
method="GET",
|
||||
path="/quizzes/scoreboard",
|
||||
title="Quiz scoreboard",
|
||||
summary=(
|
||||
"Score per user across every published quiz, counting each member's best "
|
||||
"attempt per quiz. Cached about 15 seconds."
|
||||
),
|
||||
auth="public",
|
||||
params=[
|
||||
field("limit", "query", "integer", False, "20", "How many entries to return, up to 100."),
|
||||
],
|
||||
sample_response={
|
||||
"scoreboard": [
|
||||
{"rank": 1, "user": {"username": "alice"}, "total_points": 84.0,
|
||||
"quizzes_completed": 7, "avg_percent": 88.4, "perfect_count": 2}
|
||||
],
|
||||
"viewer_standing": None,
|
||||
"limit": 20,
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="quizzes-new",
|
||||
method="GET",
|
||||
path="/quizzes/new",
|
||||
title="New quiz form",
|
||||
summary="The create form behind the New quiz button.",
|
||||
auth="user",
|
||||
negotiation=True,
|
||||
sample_response={"viewer_can_create": True},
|
||||
),
|
||||
endpoint(
|
||||
id="quizzes-create",
|
||||
method="POST",
|
||||
path="/quizzes/create",
|
||||
title="Create a quiz",
|
||||
summary="Create a draft quiz. Add its questions afterwards, then publish it.",
|
||||
auth="user",
|
||||
encoding="form",
|
||||
params=[
|
||||
field("title", "form", "string", True, "SQLite fundamentals", "3 to 200 characters."),
|
||||
field("description", "form", "string", False, "Ten questions on WAL.", "Markdown, up to 5000 characters."),
|
||||
field("shuffle_questions", "form", "boolean", False, "1", "Shuffle the question order per attempt."),
|
||||
field("shuffle_options", "form", "boolean", False, "1", "Shuffle the answer options."),
|
||||
field("reveal_answers", "form", "boolean", False, "1", "Reveal the correct answer after each question."),
|
||||
field("allow_review", "form", "boolean", False, "1", "Allow reviewing every answer on the results screen."),
|
||||
field("time_limit_seconds", "form", "integer", False, "900", "0 for no limit, up to 86400."),
|
||||
field("pass_percent", "form", "integer", False, "70", "0 to 100, 0 for no pass or fail verdict."),
|
||||
],
|
||||
sample_response={
|
||||
"ok": True,
|
||||
"redirect": "/quizzes/8bbb000000000001-sqlite-fundamentals/edit",
|
||||
"data": {"uid": SAMPLE_QUIZ["uid"], "slug": SAMPLE_QUIZ["slug"]},
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="quizzes-import",
|
||||
method="POST",
|
||||
path="/quizzes/import",
|
||||
title="Import a quiz document",
|
||||
summary=(
|
||||
"Create a complete quiz - metadata, settings, every question and every option - "
|
||||
"from one JSON document. Capped at 100 questions and 12 options per question."
|
||||
),
|
||||
auth="user",
|
||||
encoding="form",
|
||||
params=[
|
||||
field(
|
||||
"document",
|
||||
"form",
|
||||
"string",
|
||||
True,
|
||||
'{"title": "SQLite fundamentals", "questions": [{"kind": "single_choice", "prompt": "Which journal mode allows concurrent readers?", "options": [{"label": "DELETE"}, {"label": "WAL", "is_correct": true}]}]}',
|
||||
"The complete quiz as a JSON string. See the export endpoint for the exact shape.",
|
||||
),
|
||||
],
|
||||
sample_response={
|
||||
"ok": True,
|
||||
"redirect": "/quizzes/8bbb000000000001-sqlite-fundamentals/edit",
|
||||
"data": {"uid": SAMPLE_QUIZ["uid"], "slug": SAMPLE_QUIZ["slug"], "question_count": 10},
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="quizzes-detail",
|
||||
method="GET",
|
||||
path="/quizzes/{slug}",
|
||||
title="Quiz detail",
|
||||
summary=(
|
||||
"One quiz with its stats, its leaderboard, its comments and the viewer's own "
|
||||
"state. A draft is visible only to its owner and to administrators."
|
||||
),
|
||||
auth="public",
|
||||
negotiation=True,
|
||||
params=[field("slug", "path", "string", True, SAMPLE_QUIZ["slug"], "Quiz slug or uid.")],
|
||||
sample_response={
|
||||
"quiz": SAMPLE_QUIZ,
|
||||
"leaderboard": [],
|
||||
"comments": [],
|
||||
"viewer_state": "todo",
|
||||
"star_count": 5,
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="quizzes-export",
|
||||
method="GET",
|
||||
path="/quizzes/{slug}/export",
|
||||
title="Export a quiz",
|
||||
summary=(
|
||||
"The full quiz document, the exact inverse of the import endpoint. The owner "
|
||||
"gets every correct answer; everyone else gets the questions without the key."
|
||||
),
|
||||
auth="public",
|
||||
params=[field("slug", "path", "string", True, SAMPLE_QUIZ["slug"], "Quiz slug or uid.")],
|
||||
sample_response={
|
||||
"title": "SQLite fundamentals",
|
||||
"description": "Ten questions on WAL, indexing and transactions.",
|
||||
"settings": {"shuffle_questions": True, "reveal_answers": True,
|
||||
"pass_percent": 70, "time_limit_seconds": 900},
|
||||
"questions": [
|
||||
{
|
||||
"kind": "single_choice",
|
||||
"prompt": "Which journal mode allows concurrent readers and one writer?",
|
||||
"points": 1,
|
||||
"options": [{"label": "DELETE"}, {"label": "WAL", "is_correct": True}],
|
||||
}
|
||||
],
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="quizzes-leaderboard",
|
||||
method="GET",
|
||||
path="/quizzes/{slug}/leaderboard",
|
||||
title="Quiz leaderboard",
|
||||
summary="Top completed attempts on one quiz, best percentage first.",
|
||||
auth="public",
|
||||
params=[
|
||||
field("slug", "path", "string", True, SAMPLE_QUIZ["slug"], "Quiz slug or uid."),
|
||||
field("limit", "query", "integer", False, "25", "How many entries to return, up to 100."),
|
||||
],
|
||||
sample_response={
|
||||
"quiz_uid": SAMPLE_QUIZ["uid"],
|
||||
"entries": [
|
||||
{"rank": 1, "user": {"username": "bob"}, "score_points": 13.0,
|
||||
"score_percent": 92.86, "passed": True, "completed_at": "2026-07-25T10:00:00+00:00"}
|
||||
],
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="quizzes-builder",
|
||||
method="GET",
|
||||
path="/quizzes/{slug}/edit",
|
||||
title="Quiz builder",
|
||||
summary=(
|
||||
"The owner's builder page: the quiz, every question with its answer key, the "
|
||||
"question-kind catalogue and the live pre-publish checklist."
|
||||
),
|
||||
auth="user",
|
||||
negotiation=True,
|
||||
params=[field("slug", "path", "string", True, SAMPLE_QUIZ["slug"], "Quiz slug or uid.")],
|
||||
sample_response={
|
||||
"quiz": SAMPLE_QUIZ,
|
||||
"questions": [],
|
||||
"kinds": [{"key": "single_choice", "label": "Single choice", "has_options": True}],
|
||||
"validation_errors": ["Add at least one question before publishing."],
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="quizzes-edit",
|
||||
method="POST",
|
||||
path="/quizzes/edit/{slug}",
|
||||
title="Edit a quiz",
|
||||
summary="Change the title, description and settings of a DRAFT quiz. 400 once published.",
|
||||
auth="user",
|
||||
encoding="form",
|
||||
params=[
|
||||
field("slug", "path", "string", True, SAMPLE_QUIZ["slug"], "Quiz slug or uid."),
|
||||
field("title", "form", "string", True, "SQLite fundamentals", "3 to 200 characters."),
|
||||
field("description", "form", "string", False, "Updated description.", "Markdown, up to 5000 characters."),
|
||||
field("shuffle_questions", "form", "boolean", False, "1", "Shuffle the question order per attempt."),
|
||||
field("shuffle_options", "form", "boolean", False, "1", "Shuffle the answer options."),
|
||||
field("reveal_answers", "form", "boolean", False, "1", "Reveal the correct answer after each question."),
|
||||
field("allow_review", "form", "boolean", False, "1", "Allow reviewing every answer on the results screen."),
|
||||
field("time_limit_seconds", "form", "integer", False, "900", "0 for no limit, up to 86400."),
|
||||
field("pass_percent", "form", "integer", False, "70", "0 to 100, 0 for no pass or fail verdict."),
|
||||
],
|
||||
sample_response={"ok": True, "redirect": "/quizzes/8bbb000000000001-sqlite-fundamentals"},
|
||||
),
|
||||
endpoint(
|
||||
id="quizzes-publish",
|
||||
method="POST",
|
||||
path="/quizzes/{slug}/publish",
|
||||
title="Publish a quiz",
|
||||
summary=(
|
||||
"IRREVERSIBLE. Freezes the quiz, its questions and its options forever. "
|
||||
"Refuses with the validation problems when the quiz is incomplete."
|
||||
),
|
||||
auth="user",
|
||||
encoding="form",
|
||||
destructive=True,
|
||||
params=[field("slug", "path", "string", True, SAMPLE_QUIZ["slug"], "Quiz slug or uid.")],
|
||||
sample_response={
|
||||
"ok": True,
|
||||
"redirect": "/quizzes/8bbb000000000001-sqlite-fundamentals",
|
||||
"data": {"uid": SAMPLE_QUIZ["uid"], "status": "published"},
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="quizzes-delete",
|
||||
method="POST",
|
||||
path="/quizzes/delete/{slug}",
|
||||
title="Delete a quiz",
|
||||
summary=(
|
||||
"Owner or administrator. Removes the quiz with its questions, options, "
|
||||
"attempts and answers. The only operation left on a published quiz."
|
||||
),
|
||||
auth="user",
|
||||
encoding="form",
|
||||
destructive=True,
|
||||
params=[field("slug", "path", "string", True, SAMPLE_QUIZ["slug"], "Quiz slug or uid.")],
|
||||
sample_response={"ok": True, "redirect": "/quizzes"},
|
||||
),
|
||||
endpoint(
|
||||
id="quizzes-question-add",
|
||||
method="POST",
|
||||
path="/quizzes/{slug}/questions",
|
||||
title="Add a question",
|
||||
summary="Append one question with its options to a DRAFT quiz. 400 once published.",
|
||||
auth="user",
|
||||
encoding="form",
|
||||
params=[
|
||||
field("slug", "path", "string", True, SAMPLE_QUIZ["slug"], "Quiz slug or uid."),
|
||||
field("kind", "form", "enum", True, "single_choice", "The question kind.", options=KIND_KEYS),
|
||||
field("prompt", "form", "string", True, "Which journal mode allows concurrent readers?", "Markdown, up to 2000 characters."),
|
||||
field("points", "form", "integer", False, "1", "1 to 100."),
|
||||
field("explanation", "form", "string", False, "WAL keeps readers off the writer's lock.", "Shown after answering."),
|
||||
field("options", "form", "string", False, "DELETE\nWAL\nMEMORY", "Option labels, one per line or comma separated."),
|
||||
field("match_values", "form", "string", False, "", "Accepted answers aligned with the options, for fill_blank and matching."),
|
||||
field("correct_indexes", "form", "string", True, "1", "0-based indexes of the correct options, comma separated. Required for choice questions."),
|
||||
field("correct_boolean", "form", "boolean", False, "1", "true_false only: the statement is true."),
|
||||
field("expected_answer", "form", "string", False, "", "free_text only: the reference answer."),
|
||||
field("grading_criteria", "form", "string", False, "", "free_text only: criteria for the AI reviewer."),
|
||||
field("numeric_value", "form", "number", False, "0", "numeric only: the correct value."),
|
||||
field("numeric_tolerance", "form", "number", False, "0", "numeric only: accepted absolute tolerance."),
|
||||
field("case_sensitive", "form", "boolean", False, "0", "fill_blank only: compare case sensitively."),
|
||||
],
|
||||
sample_response={
|
||||
"ok": True,
|
||||
"redirect": "/quizzes/8bbb000000000001-sqlite-fundamentals/edit",
|
||||
"data": {"uid": "0198f2c0-2222-7aaa-8bbb-000000000002", "position": 0},
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="quizzes-question-edit",
|
||||
method="POST",
|
||||
path="/quizzes/{slug}/questions/{question_uid}",
|
||||
title="Edit a question",
|
||||
summary="Replace one question and its options on a DRAFT quiz. 400 once published.",
|
||||
auth="user",
|
||||
encoding="form",
|
||||
params=[
|
||||
field("slug", "path", "string", True, SAMPLE_QUIZ["slug"], "Quiz slug or uid."),
|
||||
field("question_uid", "path", "string", True, "0198f2c0-2222-7aaa-8bbb-000000000002", "The question uid."),
|
||||
field("kind", "form", "enum", True, "single_choice", "The question kind.", options=KIND_KEYS),
|
||||
field("prompt", "form", "string", True, "Which journal mode allows concurrent readers?", "Markdown, up to 2000 characters."),
|
||||
field("points", "form", "integer", False, "1", "1 to 100."),
|
||||
field("explanation", "form", "string", False, "", "Shown after answering."),
|
||||
field("options", "form", "string", False, "DELETE\nWAL\nMEMORY", "Option labels, one per line or comma separated."),
|
||||
field("match_values", "form", "string", False, "", "Accepted answers aligned with the options."),
|
||||
field("correct_indexes", "form", "string", True, "1", "0-based indexes of the correct options."),
|
||||
field("correct_boolean", "form", "boolean", False, "1", "true_false only."),
|
||||
field("expected_answer", "form", "string", False, "", "free_text only."),
|
||||
field("grading_criteria", "form", "string", False, "", "free_text only."),
|
||||
field("numeric_value", "form", "number", False, "0", "numeric only."),
|
||||
field("numeric_tolerance", "form", "number", False, "0", "numeric only."),
|
||||
field("case_sensitive", "form", "boolean", False, "0", "fill_blank only."),
|
||||
],
|
||||
sample_response={"ok": True, "redirect": "/quizzes/8bbb000000000001-sqlite-fundamentals/edit"},
|
||||
),
|
||||
endpoint(
|
||||
id="quizzes-question-delete",
|
||||
method="POST",
|
||||
path="/quizzes/{slug}/questions/{question_uid}/delete",
|
||||
title="Delete a question",
|
||||
summary="Remove one question and its options from a DRAFT quiz, then renumber.",
|
||||
auth="user",
|
||||
encoding="form",
|
||||
destructive=True,
|
||||
params=[
|
||||
field("slug", "path", "string", True, SAMPLE_QUIZ["slug"], "Quiz slug or uid."),
|
||||
field("question_uid", "path", "string", True, "0198f2c0-2222-7aaa-8bbb-000000000002", "The question uid."),
|
||||
],
|
||||
sample_response={"ok": True, "redirect": "/quizzes/8bbb000000000001-sqlite-fundamentals/edit"},
|
||||
),
|
||||
endpoint(
|
||||
id="quizzes-question-reorder",
|
||||
method="POST",
|
||||
path="/quizzes/{slug}/questions/reorder",
|
||||
title="Reorder the questions",
|
||||
summary="Set a new question order on a DRAFT quiz. Every uid must be listed exactly once.",
|
||||
auth="user",
|
||||
encoding="form",
|
||||
params=[
|
||||
field("slug", "path", "string", True, SAMPLE_QUIZ["slug"], "Quiz slug or uid."),
|
||||
field("order", "form", "string", True, "uid-b,uid-a,uid-c", "Every question uid in the wanted order, comma separated."),
|
||||
],
|
||||
sample_response={"ok": True, "redirect": "/quizzes/8bbb000000000001-sqlite-fundamentals/edit"},
|
||||
),
|
||||
endpoint(
|
||||
id="quizzes-attempt-start",
|
||||
method="POST",
|
||||
path="/quizzes/{slug}/attempts",
|
||||
title="Start or resume an attempt",
|
||||
summary=(
|
||||
"Returns the member's single in-progress attempt, creating it when there is "
|
||||
"none. The question order and one blank answer row per question are "
|
||||
"materialized at start."
|
||||
),
|
||||
auth="user",
|
||||
encoding="form",
|
||||
params=[field("slug", "path", "string", True, SAMPLE_QUIZ["slug"], "Quiz slug or uid.")],
|
||||
sample_response={
|
||||
"ok": True,
|
||||
"redirect": "/quizzes/8bbb000000000001-sqlite-fundamentals/attempts/0198f2c0-3333-7aaa-8bbb-000000000003",
|
||||
"data": {"uid": "0198f2c0-3333-7aaa-8bbb-000000000003", "status": "in_progress"},
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="quizzes-attempt-get",
|
||||
method="GET",
|
||||
path="/quizzes/{slug}/attempts/{attempt_uid}",
|
||||
title="Read an attempt",
|
||||
summary=(
|
||||
"The attempt with its questions in play order. Correct answers are withheld "
|
||||
"until a question is answered and the quiz reveals answers."
|
||||
),
|
||||
auth="user",
|
||||
negotiation=True,
|
||||
params=[
|
||||
field("slug", "path", "string", True, SAMPLE_QUIZ["slug"], "Quiz slug or uid."),
|
||||
field("attempt_uid", "path", "string", True, "0198f2c0-3333-7aaa-8bbb-000000000003", "The attempt uid."),
|
||||
],
|
||||
sample_response={
|
||||
"quiz": SAMPLE_QUIZ,
|
||||
"attempt": {
|
||||
"uid": "0198f2c0-3333-7aaa-8bbb-000000000003",
|
||||
"status": "in_progress",
|
||||
"remaining_seconds": 812,
|
||||
"answered_count": 2,
|
||||
"question_count": 10,
|
||||
"score_points": 2.0,
|
||||
"max_points": 14,
|
||||
"score_percent": 14.29,
|
||||
"questions": [
|
||||
{
|
||||
"uid": "0198f2c0-2222-7aaa-8bbb-000000000002",
|
||||
"kind": "single_choice",
|
||||
"prompt": "Which journal mode allows concurrent readers?",
|
||||
"points": 1,
|
||||
"options": [{"uid": "opt-a", "label": "DELETE"}, {"uid": "opt-b", "label": "WAL"}],
|
||||
}
|
||||
],
|
||||
},
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="quizzes-attempt-answer",
|
||||
method="POST",
|
||||
path="/quizzes/{slug}/attempts/{attempt_uid}/answer",
|
||||
title="Answer a question",
|
||||
summary=(
|
||||
"Grade and record one answer. Each question can be answered exactly once; a "
|
||||
"second submit answers 400 and credits nothing."
|
||||
),
|
||||
auth="user",
|
||||
encoding="form",
|
||||
params=[
|
||||
field("slug", "path", "string", True, SAMPLE_QUIZ["slug"], "Quiz slug or uid."),
|
||||
field("attempt_uid", "path", "string", True, "0198f2c0-3333-7aaa-8bbb-000000000003", "The attempt uid."),
|
||||
field("question_uid", "form", "string", True, "0198f2c0-2222-7aaa-8bbb-000000000002", "The question being answered."),
|
||||
field("answer_text", "form", "string", False, "true", "Free text, the numeric value, or true/false."),
|
||||
field("option_uids", "form", "string", False, "opt-b", "Chosen option uids, comma separated and in order for ordering."),
|
||||
field("blanks", "form", "string", False, "WAL,NORMAL", "fill_blank only: one answer per blank, comma separated."),
|
||||
field("matches", "form", "string", False, "one,two", "matching only: the chosen right-hand value per option_uid, in order."),
|
||||
],
|
||||
sample_response={
|
||||
"ok": True,
|
||||
"answer": {
|
||||
"question_uid": "0198f2c0-2222-7aaa-8bbb-000000000002",
|
||||
"answered": True,
|
||||
"is_correct": True,
|
||||
"awarded_points": 1.0,
|
||||
"feedback": "Correct.",
|
||||
"graded_by": "auto",
|
||||
"confidence": 1.0,
|
||||
},
|
||||
"attempt": {"answered_count": 3, "score_points": 3.0, "max_points": 14},
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="quizzes-attempt-finish",
|
||||
method="POST",
|
||||
path="/quizzes/{slug}/attempts/{attempt_uid}/finish",
|
||||
title="Finish an attempt",
|
||||
summary=(
|
||||
"Close the attempt and compute the final score from its answer rows. A second "
|
||||
"finish returns the same result and awards nothing again."
|
||||
),
|
||||
auth="user",
|
||||
encoding="form",
|
||||
params=[
|
||||
field("slug", "path", "string", True, SAMPLE_QUIZ["slug"], "Quiz slug or uid."),
|
||||
field("attempt_uid", "path", "string", True, "0198f2c0-3333-7aaa-8bbb-000000000003", "The attempt uid."),
|
||||
],
|
||||
sample_response={
|
||||
"quiz": SAMPLE_QUIZ,
|
||||
"attempt": {"status": "completed", "score_points": 13.0, "max_points": 14,
|
||||
"score_percent": 92.86, "passed": True},
|
||||
"review": [],
|
||||
"fallback_count": 0,
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="quizzes-attempt-results",
|
||||
method="GET",
|
||||
path="/quizzes/{slug}/attempts/{attempt_uid}/results",
|
||||
title="Attempt results",
|
||||
summary=(
|
||||
"The result of one attempt: score, percentage, pass verdict, and the "
|
||||
"per-question review when the author allowed it. Attempt owner or admin."
|
||||
),
|
||||
auth="user",
|
||||
negotiation=True,
|
||||
params=[
|
||||
field("slug", "path", "string", True, SAMPLE_QUIZ["slug"], "Quiz slug or uid."),
|
||||
field("attempt_uid", "path", "string", True, "0198f2c0-3333-7aaa-8bbb-000000000003", "The attempt uid."),
|
||||
],
|
||||
sample_response={
|
||||
"quiz": SAMPLE_QUIZ,
|
||||
"attempt": {"status": "completed", "score_percent": 92.86, "passed": True},
|
||||
"review": [],
|
||||
"fallback_count": 0,
|
||||
},
|
||||
),
|
||||
],
|
||||
}
|
||||
@@ -88,11 +88,10 @@ four ways to sign requests.
|
||||
field(
|
||||
"emoji",
|
||||
"form",
|
||||
"enum",
|
||||
"string",
|
||||
True,
|
||||
REACTION_EMOJI[0],
|
||||
"One of the allowed reaction emoji.",
|
||||
REACTION_EMOJI,
|
||||
"Any single emoji character. Re-sending the same one removes it.",
|
||||
),
|
||||
],
|
||||
sample_response={
|
||||
|
||||
@@ -4,7 +4,7 @@ from .._shared import endpoint, field
|
||||
|
||||
GROUP = {
|
||||
"slug": "tools",
|
||||
"title": "Tools (SEO & DeepSearch)",
|
||||
"title": "Tools (SEO, DeepSearch & AI Usage Analyzer)",
|
||||
"intro": """
|
||||
# Tools: SEO Diagnostics & DeepSearch
|
||||
|
||||
@@ -17,6 +17,10 @@ technical, on-page, structured-data, Core Web Vitals, accessibility and AI-readi
|
||||
synthesises a cited report with confidence scoring and gap analysis, plus a grounded chat over
|
||||
the results.
|
||||
|
||||
**AI Usage Analyzer** classifies a git repository or website as AI slop, sophisticated AI-assisted
|
||||
work or genuine human work, and publishes a persistent report with an embeddable authenticity
|
||||
badge.
|
||||
|
||||
Every endpoint follows the shared [Conventions & Errors](/docs/conventions.html). These are
|
||||
**capability URLs**: the job `uid` is an unguessable identifier, so anyone holding it can read the
|
||||
status and report.
|
||||
@@ -203,7 +207,7 @@ status and report.
|
||||
method="GET",
|
||||
path="/tools/deepsearch/{uid}/session",
|
||||
title="DeepSearch report",
|
||||
summary="Full cited research report: summary, findings, gaps, sources and metrics. Negotiates HTML or JSON.",
|
||||
summary="Full cited research report: summary, findings, sources and metrics. Negotiates HTML or JSON.",
|
||||
auth="public",
|
||||
params=[
|
||||
field("uid", "path", "string", True, "DEEPSEARCH_JOB_UID", "DeepSearch job uid of a finished run."),
|
||||
@@ -221,7 +225,6 @@ status and report.
|
||||
"findings": [
|
||||
{"title": "Invention", "detail": "...", "confidence": 0.8, "citations": [1]}
|
||||
],
|
||||
"gaps": ["Limited coverage of later MOSFET developments."],
|
||||
"sources": [{"url": "https://example.com", "title": "Example", "source": "httpx"}],
|
||||
"chat_ws_url": "/tools/deepsearch/DEEPSEARCH_JOB_UID/chat",
|
||||
"export_md_url": "/tools/deepsearch/DEEPSEARCH_JOB_UID/export.md",
|
||||
@@ -229,5 +232,190 @@ status and report.
|
||||
"export_pdf_url": "/tools/deepsearch/DEEPSEARCH_JOB_UID/export.pdf",
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="tools-isslop-run",
|
||||
method="POST",
|
||||
path="/tools/isslop/run",
|
||||
title="Queue a AI usage analysis",
|
||||
summary="Start a background authenticity analysis of a git repository or website. Returns the job uid plus status, events and report URLs.",
|
||||
auth="public",
|
||||
encoding="form",
|
||||
params=[
|
||||
field("url", "form", "string", True, "https://github.com/owner/repository", "Repository (http/git/ssh) or website URL to classify."),
|
||||
],
|
||||
sample_response={
|
||||
"uid": "ISSLOP_UID",
|
||||
"status_url": "/tools/isslop/ISSLOP_UID",
|
||||
"events_url": "/tools/isslop/ISSLOP_UID/events",
|
||||
"report_url": "/tools/isslop/ISSLOP_UID/report",
|
||||
"topic": "public.isslop.ISSLOP_UID",
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="tools-isslop-list",
|
||||
method="GET",
|
||||
path="/tools/isslop/list",
|
||||
title="My AI usage analyses",
|
||||
summary="List the caller's analyses, newest first. Member history is account-bound; guest history is session-bound and claimed by the account on first signed-in call.",
|
||||
auth="public",
|
||||
params=[
|
||||
field("limit", "query", "integer", False, "50", "Maximum analyses to return (1-200)."),
|
||||
],
|
||||
sample_response={
|
||||
"analyses": [
|
||||
{
|
||||
"uid": "ISSLOP_UID",
|
||||
"status": "completed",
|
||||
"source_url": "https://github.com/owner/repository",
|
||||
"source_kind": "git",
|
||||
"grade": "B",
|
||||
"human_percent": 71.4,
|
||||
"ai_percent": 28.6,
|
||||
"category": "human-clean",
|
||||
"report_url": "/tools/isslop/ISSLOP_UID/report",
|
||||
"badge_url": "/tools/isslop/ISSLOP_UID/badge.svg",
|
||||
}
|
||||
]
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="tools-isslop-status",
|
||||
method="GET",
|
||||
path="/tools/isslop/{uid}",
|
||||
title="AI usage analysis status",
|
||||
summary="Poll an analysis. Once completed, grade, category and the human/AI split are populated.",
|
||||
auth="public",
|
||||
params=[
|
||||
field("uid", "path", "string", True, "ISSLOP_UID", "Analysis uid returned when the run was queued."),
|
||||
],
|
||||
sample_response={
|
||||
"uid": "ISSLOP_UID",
|
||||
"status": "completed",
|
||||
"source_url": "https://github.com/owner/repository",
|
||||
"source_kind": "git",
|
||||
"grade": "B",
|
||||
"slop_score": 31.2,
|
||||
"origin_score": 28.0,
|
||||
"quality_deficit_score": 22.5,
|
||||
"human_percent": 71.4,
|
||||
"ai_percent": 28.6,
|
||||
"category": "human-clean",
|
||||
"confidence": "medium",
|
||||
"files_total": 120,
|
||||
"files_analyzed": 96,
|
||||
"report_url": "/tools/isslop/ISSLOP_UID/report",
|
||||
"badge_url": "/tools/isslop/ISSLOP_UID/badge.svg",
|
||||
"events_url": "/tools/isslop/ISSLOP_UID/events",
|
||||
"topic": "public.isslop.ISSLOP_UID",
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="tools-isslop-events",
|
||||
method="GET",
|
||||
path="/tools/isslop/{uid}/events",
|
||||
title="AI usage analysis event trail",
|
||||
summary="The persisted, ordered event trail of an analysis. Use ?after=SEQ to poll incrementally; live frames also stream on the pub/sub topic.",
|
||||
auth="public",
|
||||
params=[
|
||||
field("uid", "path", "string", True, "ISSLOP_UID", "Analysis uid."),
|
||||
field("after", "query", "integer", False, "0", "Return only events with a sequence number greater than this."),
|
||||
field("limit", "query", "integer", False, "2000", "Maximum events to return (1-5000)."),
|
||||
],
|
||||
sample_response={
|
||||
"uid": "ISSLOP_UID",
|
||||
"status": "running",
|
||||
"events": [
|
||||
{"seq": 1, "kind": "stage", "message": "Resolving source type", "data": {"stage": "resolve"}, "created_at": "2026-06-14T10:00:00+00:00"}
|
||||
],
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="tools-isslop-report",
|
||||
method="GET",
|
||||
path="/tools/isslop/{uid}/report",
|
||||
title="AI usage analysis report",
|
||||
summary="Full report: verdict, markdown body, per-file results, image review and badge embeds. Negotiates HTML or JSON.",
|
||||
auth="public",
|
||||
params=[
|
||||
field("uid", "path", "string", True, "ISSLOP_UID", "Analysis uid of a finished run."),
|
||||
],
|
||||
sample_response={
|
||||
"uid": "ISSLOP_UID",
|
||||
"status": "completed",
|
||||
"source_url": "https://github.com/owner/repository",
|
||||
"grade": "B",
|
||||
"human_percent": 71.4,
|
||||
"ai_percent": 28.6,
|
||||
"category": "human-clean",
|
||||
"markdown": "# Verdict...",
|
||||
"generator_model": "molodetz",
|
||||
"badge": {
|
||||
"badge_url": "https://devplace.example/tools/isslop/ISSLOP_UID/badge.svg",
|
||||
"report_url": "https://devplace.example/tools/isslop/ISSLOP_UID/report",
|
||||
"markdown": "[](...)",
|
||||
"html": "<a href=...><img src=.../></a>",
|
||||
},
|
||||
"files": [{"path": "src/main.py", "language": "python", "lines": 120, "origin_score": 35.0, "quality_deficit_score": 18.0, "category": "human-clean", "signals": []}],
|
||||
"images": [{"path": "assets/hero.png", "ai_probability": 84.0, "grade": "F", "verdict": "ai-generated", "image_kind": "illustration", "tells": ["waxy skin"], "description": "...", "thumb_url": "/tools/isslop/ISSLOP_UID/media/0f3a9c2d1b4e5a67.webp"}],
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="tools-isslop-report-md",
|
||||
method="GET",
|
||||
path="/tools/isslop/{uid}/report.md",
|
||||
title="Download report markdown",
|
||||
summary="Download the full report as a markdown file.",
|
||||
auth="public",
|
||||
params=[
|
||||
field("uid", "path", "string", True, "ISSLOP_UID", "Analysis uid of a finished run."),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="tools-isslop-source",
|
||||
method="GET",
|
||||
path="/tools/isslop/{uid}/source",
|
||||
title="Annotated source of a flagged file",
|
||||
summary="The persisted source of a signal-bearing file with its signals, rendered with line numbers and highlighted findings (HTML) or as JSON. Linked from the report's file table, signal chips and prose.",
|
||||
auth="public",
|
||||
params=[
|
||||
field("uid", "path", "string", True, "ISSLOP_UID", "Analysis uid."),
|
||||
field("path", "query", "string", True, "src/libs/Env.ts", "Workspace-relative file path from the report."),
|
||||
field("line", "query", "integer", False, "12", "Line to focus and highlight."),
|
||||
],
|
||||
sample_response={
|
||||
"uid": "ISSLOP_UID",
|
||||
"path": "src/libs/Env.ts",
|
||||
"language": "typescript",
|
||||
"category": "human-clean",
|
||||
"origin_score": 24.0,
|
||||
"quality_deficit_score": 34.9,
|
||||
"source": "import { createEnv } from '@t3-oss/env-nextjs';...",
|
||||
"truncated": False,
|
||||
"signals": [{"code": "PUBLIC_ENV_SECRET", "title": "Secret exposed via public env variable", "severity": "strong", "line": 12}],
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="tools-isslop-media",
|
||||
method="GET",
|
||||
path="/tools/isslop/{uid}/media/{name}",
|
||||
title="Reviewed image thumbnail",
|
||||
summary="Aspect-preserving WebP thumbnail of a reviewed image, persisted as evidence. The name comes from the report's images[].thumb_url.",
|
||||
auth="public",
|
||||
params=[
|
||||
field("uid", "path", "string", True, "ISSLOP_UID", "Analysis uid."),
|
||||
field("name", "path", "string", True, "0f3a9c2d1b4e5a67.webp", "Thumbnail file name from the report."),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="tools-isslop-badge",
|
||||
method="GET",
|
||||
path="/tools/isslop/{uid}/badge.svg",
|
||||
title="Authenticity badge",
|
||||
summary="Embeddable SVG badge showing the human score and authenticity grade, linking to the report.",
|
||||
auth="public",
|
||||
params=[
|
||||
field("uid", "path", "string", True, "ISSLOP_UID", "Analysis uid."),
|
||||
],
|
||||
),
|
||||
],
|
||||
}
|
||||
|
||||
@@ -15,6 +15,11 @@ comment, project, gist, message, or issue - see
|
||||
play inline once posted; other types render as download links. The record's `is_image` and
|
||||
`is_video` flags indicate how the file is displayed.
|
||||
|
||||
You manage your own attachments over the full lifecycle: **list** every file you uploaded, **get**
|
||||
one by uid, **rename** its display filename, and **delete** it. The list is the same set of
|
||||
attachments that appear on your posts and other content - listing, renaming, or deleting one is
|
||||
reflected everywhere it is used.
|
||||
|
||||
Every endpoint follows the shared [Conventions & Errors](/docs/conventions.html) (auth, content
|
||||
negotiation, pagination, status codes); see [Authentication](/docs/authentication.html) for the
|
||||
four ways to sign requests.
|
||||
@@ -83,13 +88,62 @@ four ways to sign requests.
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="uploads-delete",
|
||||
method="DELETE",
|
||||
path="/uploads/delete/{attachment_uid}",
|
||||
title="Delete an attachment",
|
||||
summary="Delete an attachment you own; administrators may delete any user's attachment. Soft-deleted (hidden everywhere but restorable; garbage-collected later).",
|
||||
id="uploads-list",
|
||||
method="GET",
|
||||
path="/uploads",
|
||||
title="List your attachments",
|
||||
summary="List every attachment you uploaded, newest first, paginated (24 per page).",
|
||||
auth="user",
|
||||
params=[
|
||||
field(
|
||||
"page",
|
||||
"query",
|
||||
"integer",
|
||||
False,
|
||||
"1",
|
||||
"1-based page number.",
|
||||
),
|
||||
field(
|
||||
"linked",
|
||||
"query",
|
||||
"string",
|
||||
False,
|
||||
"",
|
||||
"Filter: `true` returns only attachments already used on a post/comment/project/gist/issue, `false` returns only orphaned uploads. Omit for all.",
|
||||
),
|
||||
],
|
||||
notes=[
|
||||
"Each item carries `uid`, `original_filename`, `mime_type`, `url`, `file_size`, its `target_type`/`target_uid`/`target_url` when linked, and a `linked` flag.",
|
||||
],
|
||||
sample_response={
|
||||
"attachments": [
|
||||
{
|
||||
"uid": "ATTACHMENT_UID",
|
||||
"original_filename": "photo.png",
|
||||
"file_size": 20480,
|
||||
"mime_type": "image/png",
|
||||
"url": "/static/uploads/attachments/ab/cd/ATTACHMENT_UID.png",
|
||||
"is_image": True,
|
||||
"is_video": False,
|
||||
"is_audio": False,
|
||||
"linked": True,
|
||||
"target_type": "post",
|
||||
"target_uid": "POST_UID",
|
||||
"target_url": "/posts/POST_SLUG",
|
||||
"created_at": "2026-01-01T12:00:00+00:00",
|
||||
}
|
||||
],
|
||||
"pagination": {"page": 1, "per_page": 24, "total": 1, "total_pages": 1},
|
||||
"total": 1,
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="uploads-get",
|
||||
method="GET",
|
||||
path="/uploads/{attachment_uid}",
|
||||
title="Get one attachment",
|
||||
summary="Fetch the metadata of a single attachment you own; administrators may fetch any user's attachment.",
|
||||
auth="user",
|
||||
destructive=True,
|
||||
params=[
|
||||
field(
|
||||
"attachment_uid",
|
||||
@@ -100,6 +154,90 @@ four ways to sign requests.
|
||||
"UID of the attachment.",
|
||||
)
|
||||
],
|
||||
notes=["Returns `404` if the attachment does not exist, `403` if it is not yours."],
|
||||
sample_response={
|
||||
"uid": "ATTACHMENT_UID",
|
||||
"original_filename": "photo.png",
|
||||
"file_size": 20480,
|
||||
"mime_type": "image/png",
|
||||
"url": "/static/uploads/attachments/ab/cd/ATTACHMENT_UID.png",
|
||||
"is_image": True,
|
||||
"is_video": False,
|
||||
"is_audio": False,
|
||||
"linked": True,
|
||||
"target_type": "post",
|
||||
"target_uid": "POST_UID",
|
||||
"target_url": "/posts/POST_SLUG",
|
||||
"created_at": "2026-01-01T12:00:00+00:00",
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="uploads-rename",
|
||||
method="PATCH",
|
||||
path="/uploads/{attachment_uid}",
|
||||
title="Rename an attachment",
|
||||
summary="Change the display filename of an attachment you own; administrators may rename any user's attachment.",
|
||||
auth="user",
|
||||
params=[
|
||||
field(
|
||||
"attachment_uid",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"ATTACHMENT_UID",
|
||||
"UID of the attachment.",
|
||||
),
|
||||
field(
|
||||
"filename",
|
||||
"form",
|
||||
"string",
|
||||
True,
|
||||
"renamed.png",
|
||||
"New display filename.",
|
||||
),
|
||||
],
|
||||
notes=[
|
||||
"Only the display filename changes; the stored file and its extension are untouched. The original extension is always preserved, so the file type cannot be altered.",
|
||||
"Returns the updated attachment record. `404` if it does not exist, `403` if it is not yours, `400` for an empty filename.",
|
||||
],
|
||||
sample_response={
|
||||
"uid": "ATTACHMENT_UID",
|
||||
"original_filename": "renamed.png",
|
||||
"file_size": 20480,
|
||||
"mime_type": "image/png",
|
||||
"url": "/static/uploads/attachments/ab/cd/ATTACHMENT_UID.png",
|
||||
"is_image": True,
|
||||
"linked": True,
|
||||
"target_type": "post",
|
||||
"target_uid": "POST_UID",
|
||||
"target_url": "/posts/POST_SLUG",
|
||||
"created_at": "2026-01-01T12:00:00+00:00",
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="uploads-delete",
|
||||
method="DELETE",
|
||||
path="/uploads/delete/{attachment_uid}",
|
||||
title="Delete an attachment",
|
||||
summary="Remove an attachment you previously uploaded; administrators may remove any user's attachment.",
|
||||
auth="user",
|
||||
destructive=True,
|
||||
params=[
|
||||
field(
|
||||
"attachment_uid",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"ATTACHMENT_UID",
|
||||
"UID of the attachment (the `uid` returned by Upload a file, Attach a file from a URL, or List your attachments).",
|
||||
)
|
||||
],
|
||||
notes=[
|
||||
"Only the owner may delete their own attachment; an administrator may delete any user's. Deleting one you do not own returns `403`.",
|
||||
"The attachment is removed everywhere at once: it leaves your attachment list (List your attachments) and disappears from every post, comment, project, gist, message, or issue it was attached to, and its file stops being served under `/static/uploads/`.",
|
||||
"Idempotent from the caller's view: an already-removed or unknown uid returns `404`. A successful delete returns `200` with `{\"status\": \"deleted\"}`.",
|
||||
"To detach a file from a single post/comment without removing the upload itself, edit that object's attachment list instead - deleting here removes the attachment from every place it is used.",
|
||||
],
|
||||
sample_response={"status": "deleted"},
|
||||
),
|
||||
],
|
||||
|
||||
@@ -43,5 +43,6 @@ def render_group(slug, base, username, api_key):
|
||||
"{{ base }}": base,
|
||||
"{{ username }}": username or "YOUR_USERNAME",
|
||||
"{{ api_key }}": api_key or "YOUR_API_KEY",
|
||||
"{{ app_reference }}": f"user-{username}-app-v-1-0-0" if username else "user-app-v-13.37.0",
|
||||
}
|
||||
return _substitute(group, replacements)
|
||||
|
||||
@@ -237,7 +237,7 @@ DEVRANT_GROUPS = {
|
||||
encoding="form",
|
||||
params=[
|
||||
field("rant_id", "path", type="int", required=True, example="1", description="Rant id."),
|
||||
field("comment", "body", type="textarea", required=True, example="Great rant!", description="Comment text, 1-1000 chars."),
|
||||
field("comment", "body", type="textarea", required=True, example="Great rant!", description="Comment text, 1-125000 chars."),
|
||||
],
|
||||
sample_response={"success": True},
|
||||
),
|
||||
|
||||
@@ -16,10 +16,37 @@ _RENDER_BLOCK = re.compile(
|
||||
r'<div class="docs-content" data-render>(.*?)</div>', re.DOTALL
|
||||
)
|
||||
|
||||
_HEADING = re.compile(r"<h([23])>(.*?)</h\1>", re.DOTALL)
|
||||
|
||||
|
||||
def heading_slug(text: str) -> str:
|
||||
plain = html.unescape(re.sub(r"<[^>]+>", "", text)).strip().lower()
|
||||
slug = re.sub(r"[^a-z0-9]+", "-", plain).strip("-")
|
||||
return slug or "section"
|
||||
|
||||
|
||||
def _anchor_headings(rendered: str) -> str:
|
||||
seen: dict[str, int] = {}
|
||||
|
||||
def _inject(match: re.Match) -> str:
|
||||
level, inner = match.group(1), match.group(2)
|
||||
slug = heading_slug(inner)
|
||||
count = seen.get(slug, 0)
|
||||
seen[slug] = count + 1
|
||||
if count:
|
||||
slug = f"{slug}-{count}"
|
||||
return (
|
||||
f'<h{level} id="{slug}">{inner}'
|
||||
f'<a class="docs-heading-anchor" href="#{slug}" aria-label="Link to this section">#</a>'
|
||||
f"</h{level}>"
|
||||
)
|
||||
|
||||
return _HEADING.sub(_inject, rendered)
|
||||
|
||||
|
||||
@lru_cache(maxsize=512)
|
||||
def _render_markdown(source: str) -> str:
|
||||
return _markdown(source)
|
||||
return _anchor_headings(_markdown(source))
|
||||
|
||||
|
||||
def _convert(match: re.Match) -> str:
|
||||
|
||||
+68
-4
@@ -37,8 +37,10 @@ from devplacepy.database import (
|
||||
get_user_post_count,
|
||||
get_user_stars,
|
||||
get_blocked_uids,
|
||||
get_top_authors,
|
||||
get_trending_topics,
|
||||
)
|
||||
from devplacepy.templating import templates
|
||||
from devplacepy.templating import templates, jinja_unread_count
|
||||
from devplacepy.cache import TTLCache
|
||||
from devplacepy.responses import respond, wants_json, json_error
|
||||
from devplacepy.schemas import LandingOut, ValidationErrorOut
|
||||
@@ -56,6 +58,7 @@ from devplacepy.routers import (
|
||||
notifications,
|
||||
votes,
|
||||
avatar,
|
||||
awards,
|
||||
follow,
|
||||
relations,
|
||||
admin,
|
||||
@@ -63,6 +66,7 @@ from devplacepy.routers import (
|
||||
issues,
|
||||
news,
|
||||
gists,
|
||||
quizzes,
|
||||
uploads,
|
||||
media,
|
||||
push,
|
||||
@@ -95,13 +99,17 @@ from devplacepy.services.jobs.issue_create_service import IssueCreateService
|
||||
from devplacepy.services.jobs.planning_service import PlanningReportService
|
||||
from devplacepy.services.jobs.seo.service import SeoService
|
||||
from devplacepy.services.jobs.seo_meta_service import SeoMetaService
|
||||
from devplacepy.services.jobs.award_service import AwardService
|
||||
from devplacepy.services.backup import BackupService
|
||||
from devplacepy.services.dbapi.service import DbApiJobService
|
||||
from devplacepy.services.pubsub import PubSubService
|
||||
from devplacepy.services.notification_relay import NotificationRelayService
|
||||
from devplacepy.services.live_view_relay import LiveViewRelayService
|
||||
from devplacepy.services.presence_relay import PresenceRelayService
|
||||
from devplacepy.services import presence
|
||||
from devplacepy.services.correction import PENDING_SCOPE_KEY
|
||||
from devplacepy.services.jobs.deepsearch.service import DeepsearchService
|
||||
from devplacepy.services.jobs.isslop.service import IsslopService
|
||||
from devplacepy.services.gitea.service import IssueTrackerService
|
||||
from devplacepy.services.containers.service import ContainerService
|
||||
from devplacepy.services.xmlrpc import XmlrpcService
|
||||
@@ -212,6 +220,7 @@ class UploadStaticFiles(StaticFiles):
|
||||
else "attachment"
|
||||
)
|
||||
response.headers["Content-Disposition"] = disposition
|
||||
response.headers["Cache-Control"] = "public, max-age=604800"
|
||||
return response
|
||||
|
||||
|
||||
@@ -225,6 +234,16 @@ class CachedStaticFiles(StaticFiles):
|
||||
return response
|
||||
|
||||
|
||||
class FallbackStaticFiles(StaticFiles):
|
||||
async def get_response(self, path, scope):
|
||||
response = await super().get_response(path, scope)
|
||||
if Path(path).name == "service-worker.js":
|
||||
response.headers["Cache-Control"] = "no-cache"
|
||||
else:
|
||||
response.headers["Cache-Control"] = "public, max-age=3600"
|
||||
return response
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
ensure_data_dirs()
|
||||
@@ -241,12 +260,15 @@ async def lifespan(app: FastAPI):
|
||||
service_manager.register(ForkService())
|
||||
service_manager.register(SeoService())
|
||||
service_manager.register(SeoMetaService())
|
||||
service_manager.register(AwardService())
|
||||
service_manager.register(BackupService())
|
||||
service_manager.register(DbApiJobService())
|
||||
service_manager.register(PubSubService())
|
||||
service_manager.register(NotificationRelayService())
|
||||
service_manager.register(LiveViewRelayService())
|
||||
service_manager.register(PresenceRelayService())
|
||||
service_manager.register(DeepsearchService())
|
||||
service_manager.register(IsslopService())
|
||||
service_manager.register(IssueCreateService())
|
||||
service_manager.register(PlanningReportService())
|
||||
service_manager.register(IssueTrackerService())
|
||||
@@ -265,9 +287,15 @@ async def lifespan(app: FastAPI):
|
||||
logger.info(
|
||||
f"Worker pid {os.getpid()} declined service lock; another worker owns background services"
|
||||
)
|
||||
from devplacepy.services.statistics.tracking import start_visit_flusher
|
||||
|
||||
start_visit_flusher()
|
||||
logger.info(f"DevPlace started on port {PORT}")
|
||||
yield
|
||||
logger.info("Shutting down services...")
|
||||
from devplacepy.services.statistics.tracking import flush_visits
|
||||
|
||||
flush_visits()
|
||||
await service_manager.shutdown_all()
|
||||
await background.stop()
|
||||
|
||||
@@ -289,7 +317,7 @@ app.mount(
|
||||
CachedStaticFiles(directory=str(STATIC_DIR)),
|
||||
name="static_versioned",
|
||||
)
|
||||
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
||||
app.mount("/static", FallbackStaticFiles(directory=str(STATIC_DIR)), name="static")
|
||||
|
||||
|
||||
@app.exception_handler(404)
|
||||
@@ -412,6 +440,7 @@ app.include_router(reactions.router, prefix="/reactions")
|
||||
app.include_router(bookmarks.router, prefix="/bookmarks")
|
||||
app.include_router(polls.router, prefix="/polls")
|
||||
app.include_router(avatar.router, prefix="/avatar")
|
||||
app.include_router(awards.router, prefix="/awards")
|
||||
app.include_router(follow.router, prefix="/follow")
|
||||
app.include_router(relations.router)
|
||||
app.include_router(leaderboard.router, prefix="/leaderboard")
|
||||
@@ -435,11 +464,13 @@ app.include_router(devrant.router, prefix="/api")
|
||||
app.include_router(dbapi.router, prefix="/dbapi")
|
||||
app.include_router(pubsub.router, prefix="/pubsub")
|
||||
app.include_router(game.router, prefix="/game")
|
||||
app.include_router(quizzes.router, prefix="/quizzes")
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def refresh_db_snapshot(request: Request, call_next):
|
||||
refresh_snapshot()
|
||||
if not request.url.path.startswith(("/static", "/avatar")):
|
||||
refresh_snapshot()
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
@@ -554,6 +585,25 @@ async def maintenance_middleware(request: Request, call_next):
|
||||
)
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def track_presence(request: Request, call_next):
|
||||
path = request.url.path
|
||||
if not path.startswith(("/static", "/avatar")):
|
||||
user = get_current_user(request)
|
||||
if user:
|
||||
presence.touch(user["uid"])
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def visit_statistics(request: Request, call_next):
|
||||
from devplacepy.services.statistics.tracking import track_visit
|
||||
|
||||
response = await call_next(request)
|
||||
track_visit(request, response.status_code)
|
||||
return response
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def response_timing(request: Request, call_next):
|
||||
start = time.perf_counter()
|
||||
@@ -563,7 +613,7 @@ async def response_timing(request: Request, call_next):
|
||||
return response
|
||||
|
||||
|
||||
app.add_middleware(GZipMiddleware, minimum_size=512, compresslevel=6)
|
||||
app.add_middleware(GZipMiddleware, minimum_size=512, compresslevel=5)
|
||||
|
||||
|
||||
_home_cache = TTLCache(ttl=int(os.environ.get("DEVPLACE_HOME_CACHE_TTL", "60")), max_size=4)
|
||||
@@ -655,6 +705,14 @@ async def landing(request: Request):
|
||||
breadcrumbs=[],
|
||||
schemas=[website_schema(base)],
|
||||
)
|
||||
user_xp = user.get("xp", 0) or 0 if user else 0
|
||||
user_level = user.get("level", 1) or 1 if user else 1
|
||||
xp_progress_pct = (user_xp % 100) if user_xp else 0
|
||||
unread_count = jinja_unread_count(user["uid"]) if user else 0
|
||||
|
||||
top_contributors = get_top_authors(5) if not blocked else []
|
||||
trending_topics = get_trending_topics(6) if not blocked else []
|
||||
|
||||
return respond(
|
||||
request,
|
||||
"landing.html",
|
||||
@@ -665,8 +723,14 @@ async def landing(request: Request):
|
||||
"is_authenticated": bool(user),
|
||||
"user_post_count": get_user_post_count(user["uid"]) if user else 0,
|
||||
"user_stars": get_user_stars(user["uid"]) if user else 0,
|
||||
"user_xp": user_xp,
|
||||
"user_level": user_level,
|
||||
"xp_progress_pct": xp_progress_pct,
|
||||
"unread_count": unread_count,
|
||||
"landing_articles": landing_articles,
|
||||
"landing_posts": landing_posts,
|
||||
"top_contributors": top_contributors,
|
||||
"trending_topics": trending_topics,
|
||||
},
|
||||
model=LandingOut,
|
||||
)
|
||||
|
||||
+345
-11
@@ -1,10 +1,22 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import json
|
||||
import re
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Literal, Optional
|
||||
from urllib.parse import urlsplit
|
||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||
from devplacepy.constants import TOPICS, REACTION_EMOJI
|
||||
from devplacepy.config import DEFAULT_CORRECTION_PROMPT, DEFAULT_MODIFIER_PROMPT
|
||||
from devplacepy.constants import TOPICS
|
||||
from devplacepy.rendering import is_single_emoji
|
||||
from devplacepy.config import (
|
||||
DEFAULT_CORRECTION_PROMPT,
|
||||
DEFAULT_MODIFIER_PROMPT,
|
||||
QUIZ_ANSWER_MAX_CHARS,
|
||||
QUIZ_MAX_OPTIONS,
|
||||
QUIZ_MAX_QUESTIONS,
|
||||
QUIZ_MAX_TIME_LIMIT_SECONDS,
|
||||
)
|
||||
|
||||
|
||||
def normalize_european_date(value):
|
||||
@@ -155,10 +167,10 @@ class PostEditForm(BaseModel):
|
||||
|
||||
|
||||
class CommentForm(BaseModel):
|
||||
content: str = Field(min_length=3, max_length=1000)
|
||||
content: str = Field(min_length=3, max_length=125000)
|
||||
target_uid: str = Field(default="", max_length=36)
|
||||
post_uid: str = Field(default="", max_length=36)
|
||||
target_type: Literal["post", "project", "news", "issue", "gist"] = "post"
|
||||
target_type: Literal["post", "project", "news", "issue", "gist", "quiz"] = "post"
|
||||
parent_uid: str = Field(default="", max_length=36)
|
||||
attachment_uids: list[str] = []
|
||||
|
||||
@@ -170,7 +182,7 @@ class CommentForm(BaseModel):
|
||||
|
||||
|
||||
class CommentEditForm(BaseModel):
|
||||
content: str = Field(min_length=3, max_length=1000)
|
||||
content: str = Field(min_length=3, max_length=125000)
|
||||
|
||||
|
||||
class ProjectForm(BaseModel):
|
||||
@@ -236,6 +248,10 @@ class CustomizationToggleForm(BaseModel):
|
||||
value: bool = False
|
||||
|
||||
|
||||
class AwardGiveForm(BaseModel):
|
||||
description: str = Field(min_length=1, max_length=125)
|
||||
|
||||
|
||||
class NotificationPrefForm(BaseModel):
|
||||
notification_type: str = Field(min_length=1, max_length=40)
|
||||
channel: Literal["in_app", "push", "telegram"]
|
||||
@@ -251,13 +267,18 @@ class NotificationDefaultForm(BaseModel):
|
||||
class AiCorrectionForm(BaseModel):
|
||||
enabled: bool = False
|
||||
sync: bool = False
|
||||
prompt: str = Field(default=DEFAULT_CORRECTION_PROMPT, max_length=2000)
|
||||
prompt: str = Field(default=DEFAULT_CORRECTION_PROMPT, max_length=20000)
|
||||
|
||||
|
||||
class AiModifierForm(BaseModel):
|
||||
enabled: bool = False
|
||||
sync: bool = False
|
||||
prompt: str = Field(default=DEFAULT_MODIFIER_PROMPT, max_length=2000)
|
||||
prompt: str = Field(default=DEFAULT_MODIFIER_PROMPT, max_length=20000)
|
||||
|
||||
|
||||
class InteractionsForm(BaseModel):
|
||||
enabled: bool = True
|
||||
reset: bool = False
|
||||
|
||||
|
||||
class TelegramPairForm(BaseModel):
|
||||
@@ -277,6 +298,10 @@ class UploadUrlForm(BaseModel):
|
||||
filename: Optional[str] = Field(default=None, max_length=255)
|
||||
|
||||
|
||||
class AttachmentRenameForm(BaseModel):
|
||||
filename: str = Field(min_length=1, max_length=255)
|
||||
|
||||
|
||||
class ProjectFileWriteForm(BaseModel):
|
||||
path: str = Field(min_length=1, max_length=1024)
|
||||
content: str = Field(default="", max_length=400000)
|
||||
@@ -374,9 +399,10 @@ class ContainerScheduleForm(BaseModel):
|
||||
|
||||
|
||||
class MessageForm(BaseModel):
|
||||
content: str = Field(min_length=1, max_length=2000)
|
||||
content: str = Field(min_length=0, max_length=2000)
|
||||
receiver_uid: str = Field(min_length=1, max_length=36)
|
||||
attachment_uids: list[str] = []
|
||||
client_id: Optional[str] = Field(default=None, max_length=64)
|
||||
|
||||
|
||||
class ProfileForm(BaseModel):
|
||||
@@ -437,9 +463,10 @@ class ReactionForm(BaseModel):
|
||||
@field_validator("emoji")
|
||||
@classmethod
|
||||
def valid_emoji(cls, value):
|
||||
if value not in REACTION_EMOJI:
|
||||
raise ValueError("Invalid reaction")
|
||||
return value
|
||||
reaction = (value or "").strip()
|
||||
if not is_single_emoji(reaction):
|
||||
raise ValueError("Reaction must be a single emoji")
|
||||
return reaction
|
||||
|
||||
|
||||
class PollVoteForm(BaseModel):
|
||||
@@ -457,6 +484,36 @@ class SeoRunForm(BaseModel):
|
||||
text = value.strip()
|
||||
if not text:
|
||||
raise ValueError("A URL is required")
|
||||
if "://" in text:
|
||||
scheme = text.split("://", 1)[0]
|
||||
if scheme not in ("http", "https"):
|
||||
raise ValueError(f"Only http and https URLs are allowed; got '{scheme}://'")
|
||||
else:
|
||||
text = f"https://{text}"
|
||||
if not SEO_URL_PATTERN.match(text):
|
||||
raise ValueError("URL must be a valid http or https source location")
|
||||
return text
|
||||
|
||||
|
||||
SEO_URL_PATTERN = re.compile(r"^https?://[a-zA-Z0-9][\w./:@~^?&#%=;-]*$")
|
||||
|
||||
ISSLOP_URL_PATTERN = re.compile(r"^(https?://|git://|ssh://|git@)[\w./:@~^-]+$", re.IGNORECASE)
|
||||
ISSLOP_SINGLE_SLASH_PATTERN = re.compile(r"^(https?|git|ssh):/(?!/)", re.IGNORECASE)
|
||||
ISSLOP_SCHEME_PATTERN = re.compile(r"^[a-z][a-z0-9+.-]*://", re.IGNORECASE)
|
||||
|
||||
|
||||
class IsslopRunForm(BaseModel):
|
||||
url: str = Field(min_length=4, max_length=2048)
|
||||
|
||||
@field_validator("url")
|
||||
@classmethod
|
||||
def url_scheme(cls, value):
|
||||
text = value.strip()
|
||||
text = ISSLOP_SINGLE_SLASH_PATTERN.sub(lambda match: f"{match.group(1)}://", text)
|
||||
if not ISSLOP_SCHEME_PATTERN.match(text) and not text.startswith("git@"):
|
||||
text = f"https://{text}"
|
||||
if not ISSLOP_URL_PATTERN.match(text):
|
||||
raise ValueError("URL must be an http(s), git or ssh source location")
|
||||
return text
|
||||
|
||||
|
||||
@@ -529,8 +586,20 @@ class AdminSettingsForm(BaseModel):
|
||||
maintenance_mode: str = Field(default="", max_length=1)
|
||||
maintenance_message: str = Field(default="", max_length=300)
|
||||
docs_search_mode: str = Field(default="", max_length=20)
|
||||
outbound_proxy_url: str = Field(default="", max_length=500)
|
||||
extra_head: str = Field(default="", max_length=50000)
|
||||
|
||||
@field_validator("outbound_proxy_url")
|
||||
@classmethod
|
||||
def validate_outbound_proxy_url(cls, value):
|
||||
text = value.strip()
|
||||
if not text:
|
||||
return text
|
||||
parsed = urlsplit(text)
|
||||
if parsed.scheme not in ("http", "https", "socks5", "socks5h") or not parsed.hostname:
|
||||
raise ValueError("Proxy URL must be http(s):// or socks5(h):// with a host, e.g. http://user:pass@host:port")
|
||||
return text
|
||||
|
||||
|
||||
class GamePlantForm(BaseModel):
|
||||
slot: int = Field(ge=0, le=64)
|
||||
@@ -547,7 +616,272 @@ class GamePerkForm(BaseModel):
|
||||
|
||||
class GameQuestForm(BaseModel):
|
||||
quest: str = Field(min_length=1, max_length=40)
|
||||
scope: str = Field(default="daily", min_length=1, max_length=10)
|
||||
|
||||
|
||||
class GameLegacyForm(BaseModel):
|
||||
key: str = Field(min_length=1, max_length=40)
|
||||
|
||||
|
||||
class GameInfraForm(BaseModel):
|
||||
key: str = Field(min_length=1, max_length=40)
|
||||
|
||||
|
||||
class GameCosmeticForm(BaseModel):
|
||||
key: str = Field(min_length=1, max_length=40)
|
||||
|
||||
|
||||
class GameMasteryForm(BaseModel):
|
||||
key: str = Field(min_length=1, max_length=40)
|
||||
|
||||
|
||||
class GameEraStartForm(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=60)
|
||||
duration_days: int = Field(default=28, ge=1, le=180)
|
||||
|
||||
|
||||
QUIZ_KINDS = (
|
||||
"single_choice",
|
||||
"multiple_choice",
|
||||
"true_false",
|
||||
"free_text",
|
||||
"fill_blank",
|
||||
"numeric",
|
||||
"ordering",
|
||||
"matching",
|
||||
)
|
||||
|
||||
QUIZ_OPTION_KINDS = frozenset(
|
||||
{"single_choice", "multiple_choice", "fill_blank", "ordering", "matching"}
|
||||
)
|
||||
|
||||
|
||||
def normalize_index_list(value):
|
||||
parts = normalize_poll_options(value)
|
||||
if not isinstance(parts, list):
|
||||
return []
|
||||
indexes = []
|
||||
for part in parts:
|
||||
try:
|
||||
indexes.append(int(str(part).strip()))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return indexes
|
||||
|
||||
|
||||
class QuizForm(BaseModel):
|
||||
title: str = Field(min_length=3, max_length=200)
|
||||
description: str = Field(default="", max_length=5000)
|
||||
shuffle_questions: bool = False
|
||||
shuffle_options: bool = False
|
||||
reveal_answers: bool = False
|
||||
allow_review: bool = True
|
||||
time_limit_seconds: int = Field(default=0, ge=0, le=QUIZ_MAX_TIME_LIMIT_SECONDS)
|
||||
pass_percent: int = Field(default=0, ge=0, le=100)
|
||||
|
||||
|
||||
class QuizQuestionForm(BaseModel):
|
||||
kind: Literal[QUIZ_KINDS]
|
||||
prompt: str = Field(min_length=1, max_length=2000)
|
||||
explanation: str = Field(default="", max_length=2000)
|
||||
points: int = Field(default=1, ge=1, le=100)
|
||||
media_attachment_uid: str = Field(default="", max_length=36)
|
||||
correct_boolean: bool = False
|
||||
expected_answer: str = Field(default="", max_length=2000)
|
||||
grading_criteria: str = Field(default="", max_length=2000)
|
||||
numeric_value: float = 0.0
|
||||
numeric_tolerance: float = Field(default=0.0, ge=0.0)
|
||||
case_sensitive: bool = False
|
||||
options: list[str] = []
|
||||
match_values: list[str] = []
|
||||
correct_indexes: list[int] = []
|
||||
|
||||
@field_validator("options", "match_values", mode="before")
|
||||
@classmethod
|
||||
def split_lists(cls, value):
|
||||
return normalize_poll_options(value)
|
||||
|
||||
@field_validator("correct_indexes", mode="before")
|
||||
@classmethod
|
||||
def split_indexes(cls, value):
|
||||
return normalize_index_list(value)
|
||||
|
||||
@field_validator("options", "match_values")
|
||||
@classmethod
|
||||
def bounded_options(cls, value):
|
||||
if len(value) > QUIZ_MAX_OPTIONS:
|
||||
raise ValueError(f"A question takes at most {QUIZ_MAX_OPTIONS} options")
|
||||
for entry in value:
|
||||
if len(entry) > 500:
|
||||
raise ValueError("Each option must be 500 characters or fewer")
|
||||
return value
|
||||
|
||||
@model_validator(mode="after")
|
||||
def kind_requirements(self):
|
||||
options = [option for option in self.options if option.strip()]
|
||||
if self.kind in QUIZ_OPTION_KINDS and not options:
|
||||
raise ValueError("This question type needs at least one option")
|
||||
if self.kind in ("single_choice", "multiple_choice") and len(options) < 2:
|
||||
raise ValueError("Choice questions need at least two options")
|
||||
if self.kind == "single_choice" and len(self.correct_indexes) != 1:
|
||||
raise ValueError("A single choice question needs exactly one correct option")
|
||||
if self.kind == "multiple_choice" and not self.correct_indexes:
|
||||
raise ValueError("A multiple choice question needs at least one correct option")
|
||||
if self.kind in ("fill_blank", "matching") and len(self.match_values) < len(options):
|
||||
raise ValueError("Every option needs an accepted answer")
|
||||
if self.kind == "matching" and len(options) < 2:
|
||||
raise ValueError("A matching question needs at least two pairs")
|
||||
if self.kind == "ordering" and len(options) < 2:
|
||||
raise ValueError("An ordering question needs at least two items")
|
||||
if self.kind == "free_text" and not (
|
||||
self.expected_answer.strip() or self.grading_criteria.strip()
|
||||
):
|
||||
raise ValueError("A free text question needs a reference answer or grading criteria")
|
||||
return self
|
||||
|
||||
def option_rows(self) -> list[dict]:
|
||||
rows = []
|
||||
for index, label in enumerate(self.options):
|
||||
if not label.strip():
|
||||
continue
|
||||
match_value = (
|
||||
self.match_values[index] if index < len(self.match_values) else ""
|
||||
)
|
||||
rows.append(
|
||||
{
|
||||
"label": label.strip(),
|
||||
"match_value": match_value.strip(),
|
||||
"is_correct": index in set(self.correct_indexes),
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
class QuizReorderForm(BaseModel):
|
||||
order: list[str] = []
|
||||
|
||||
@field_validator("order", mode="before")
|
||||
@classmethod
|
||||
def split_order(cls, value):
|
||||
return normalize_poll_options(value)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def require_order(self):
|
||||
if not self.order:
|
||||
raise ValueError("The new question order is required")
|
||||
return self
|
||||
|
||||
|
||||
class QuizAnswerForm(BaseModel):
|
||||
question_uid: str = Field(min_length=1, max_length=36)
|
||||
answer_text: str = Field(default="", max_length=QUIZ_ANSWER_MAX_CHARS)
|
||||
option_uids: list[str] = []
|
||||
blanks: list[str] = []
|
||||
matches: list[str] = []
|
||||
|
||||
@field_validator("option_uids", "blanks", "matches", mode="before")
|
||||
@classmethod
|
||||
def split_lists(cls, value):
|
||||
return normalize_poll_options(value)
|
||||
|
||||
def submission(self) -> dict:
|
||||
if self.blanks:
|
||||
return {
|
||||
"answer_text": json.dumps(self.blanks, ensure_ascii=False),
|
||||
"option_uids": self.option_uids,
|
||||
}
|
||||
if self.matches:
|
||||
pairs = dict(zip(self.option_uids, self.matches))
|
||||
return {
|
||||
"answer_text": json.dumps(pairs, ensure_ascii=False),
|
||||
"option_uids": self.option_uids,
|
||||
}
|
||||
return {"answer_text": self.answer_text, "option_uids": self.option_uids}
|
||||
|
||||
|
||||
class QuizDocumentOption(BaseModel):
|
||||
label: str = Field(default="", max_length=500)
|
||||
match_value: str = Field(default="", max_length=500)
|
||||
is_correct: bool = False
|
||||
|
||||
|
||||
class QuizDocumentQuestion(BaseModel):
|
||||
kind: Literal[QUIZ_KINDS]
|
||||
prompt: str = Field(min_length=1, max_length=2000)
|
||||
explanation: str = Field(default="", max_length=2000)
|
||||
points: int = Field(default=1, ge=1, le=100)
|
||||
media_attachment_uid: str = Field(default="", max_length=36)
|
||||
correct_boolean: bool = False
|
||||
expected_answer: str = Field(default="", max_length=2000)
|
||||
grading_criteria: str = Field(default="", max_length=2000)
|
||||
numeric_value: float = 0.0
|
||||
numeric_tolerance: float = Field(default=0.0, ge=0.0)
|
||||
case_sensitive: bool = False
|
||||
options: list[QuizDocumentOption] = Field(default_factory=list, max_length=QUIZ_MAX_OPTIONS)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def kind_requirements(self):
|
||||
labelled = [option for option in self.options if option.label.strip()]
|
||||
if self.kind in ("single_choice", "multiple_choice") and len(labelled) < 2:
|
||||
raise ValueError("Choice questions need at least two options")
|
||||
if self.kind == "single_choice" and sum(
|
||||
1 for option in labelled if option.is_correct
|
||||
) != 1:
|
||||
raise ValueError("A single choice question needs exactly one correct option")
|
||||
if self.kind == "multiple_choice" and not any(
|
||||
option.is_correct for option in labelled
|
||||
):
|
||||
raise ValueError("A multiple choice question needs at least one correct option")
|
||||
if self.kind in ("ordering", "matching") and len(labelled) < 2:
|
||||
raise ValueError("This question type needs at least two entries")
|
||||
if self.kind == "matching" and any(
|
||||
not option.match_value.strip() for option in labelled
|
||||
):
|
||||
raise ValueError("Every matching pair needs a right-hand value")
|
||||
if self.kind == "fill_blank" and any(
|
||||
not option.match_value.strip() for option in labelled
|
||||
):
|
||||
raise ValueError("Every blank needs an accepted answer")
|
||||
if self.kind == "free_text" and not (
|
||||
self.expected_answer.strip() or self.grading_criteria.strip()
|
||||
):
|
||||
raise ValueError("A free text question needs a reference answer or grading criteria")
|
||||
return self
|
||||
|
||||
|
||||
class QuizDocumentSettings(BaseModel):
|
||||
shuffle_questions: bool = False
|
||||
shuffle_options: bool = False
|
||||
reveal_answers: bool = False
|
||||
allow_review: bool = True
|
||||
time_limit_seconds: int = Field(default=0, ge=0, le=QUIZ_MAX_TIME_LIMIT_SECONDS)
|
||||
pass_percent: int = Field(default=0, ge=0, le=100)
|
||||
|
||||
|
||||
class QuizDocument(BaseModel):
|
||||
title: str = Field(min_length=3, max_length=200)
|
||||
description: str = Field(default="", max_length=5000)
|
||||
settings: QuizDocumentSettings = Field(default_factory=QuizDocumentSettings)
|
||||
questions: list[QuizDocumentQuestion] = Field(
|
||||
default_factory=list, max_length=QUIZ_MAX_QUESTIONS
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def require_questions(self):
|
||||
if not self.questions:
|
||||
raise ValueError("A quiz document needs at least one question")
|
||||
return self
|
||||
|
||||
|
||||
class QuizImportForm(BaseModel):
|
||||
document: QuizDocument
|
||||
|
||||
@field_validator("document", mode="before")
|
||||
@classmethod
|
||||
def parse_document(cls, value):
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
return json.loads(value)
|
||||
except ValueError as exc:
|
||||
raise ValueError("document must be valid JSON") from exc
|
||||
return value
|
||||
|
||||
@@ -463,11 +463,15 @@ def delete_node(project_uid: str, raw_path: str, deleted_by: str = "system") ->
|
||||
if node is None:
|
||||
raise ProjectFileError(f"'{path}' does not exist")
|
||||
stamp = _now()
|
||||
for row in _descendants(project_uid, path):
|
||||
rows = _descendants(project_uid, path)
|
||||
for row in rows:
|
||||
_table().update(
|
||||
{"uid": row["uid"], "deleted_at": stamp, "deleted_by": deleted_by},
|
||||
["uid"],
|
||||
)
|
||||
for row in rows:
|
||||
if row.get("is_binary"):
|
||||
_unlink_blob(row)
|
||||
|
||||
|
||||
def soft_delete_all_project_files(project_uid: str, deleted_by: str) -> None:
|
||||
@@ -578,9 +582,11 @@ def _export_node(row: dict, dest: Path) -> None:
|
||||
if target.is_symlink():
|
||||
target.unlink()
|
||||
if row.get("is_binary") and row.get("stored_name") and row.get("directory"):
|
||||
shutil.copyfile(
|
||||
PROJECT_FILES_DIR / row["directory"] / row["stored_name"], target
|
||||
)
|
||||
src = PROJECT_FILES_DIR / row["directory"] / row["stored_name"]
|
||||
try:
|
||||
shutil.copyfile(src, target)
|
||||
except (FileNotFoundError, OSError):
|
||||
logger.warning("Blob file missing during export: %s", src)
|
||||
else:
|
||||
target.write_text(row.get("content") or "", encoding="utf-8")
|
||||
|
||||
@@ -685,7 +691,6 @@ def import_from_dir(project_uid: str, src_dir, user: dict, *, skip_names=None) -
|
||||
|
||||
|
||||
def export_to_dir(project_uid: str, subpath: str, dest_dir) -> int:
|
||||
_guard_writable(project_uid)
|
||||
dest = Path(dest_dir).resolve()
|
||||
dest.mkdir(parents=True, exist_ok=True)
|
||||
if subpath:
|
||||
@@ -708,9 +713,12 @@ def export_to_dir(project_uid: str, subpath: str, dest_dir) -> int:
|
||||
if target.is_symlink() or target.is_file():
|
||||
target.unlink()
|
||||
if row.get("is_binary") and row.get("stored_name") and row.get("directory"):
|
||||
shutil.copyfile(
|
||||
PROJECT_FILES_DIR / row["directory"] / row["stored_name"], target
|
||||
)
|
||||
src = PROJECT_FILES_DIR / row["directory"] / row["stored_name"]
|
||||
try:
|
||||
shutil.copyfile(src, target)
|
||||
except (FileNotFoundError, OSError):
|
||||
logger.warning("Blob file missing: %s", src)
|
||||
continue
|
||||
else:
|
||||
target.write_text(row.get("content") or "", encoding="utf-8")
|
||||
written += 1
|
||||
|
||||
+73
-11
@@ -45,6 +45,15 @@ def write_emoji_module(path: Path = EMOJI_JS_PATH) -> int:
|
||||
|
||||
EMOJI_MAP = build_emoji_shortcodes()
|
||||
|
||||
|
||||
def is_single_emoji(value: str) -> bool:
|
||||
text = (value or "").strip()
|
||||
return emoji.emoji_count(text) == 1 and emoji.purely_emoji(text)
|
||||
|
||||
|
||||
_WIDGET_RE = re.compile(r"<dp-widget>(.*?)</dp-widget>", re.DOTALL | re.IGNORECASE)
|
||||
_WIDGET_PH = "\x00WIDGET_{}\x00"
|
||||
|
||||
_SHORTCODE_RE = re.compile(r":([A-Za-z0-9_+\-]+):")
|
||||
_YOUTUBE_RE = re.compile(
|
||||
r"(?:https?://)?(?:www\.)?"
|
||||
@@ -66,6 +75,9 @@ _YOUTUBE_ALLOW = (
|
||||
"gyroscope; picture-in-picture"
|
||||
)
|
||||
|
||||
_EMAIL_RE = re.compile(r"\b[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}\b")
|
||||
_EMAIL_KEEP_DOMAIN = "molodetz.nl"
|
||||
|
||||
_MEDIA_SKIP_TAGS = {"a", "code", "pre"}
|
||||
_TITLE_INLINE_TAGS = {
|
||||
"b", "strong", "i", "em", "code", "del", "s", "mark", "sub", "sup", "span", "br",
|
||||
@@ -102,7 +114,17 @@ _content_markdown = mistune.create_markdown(
|
||||
|
||||
|
||||
def _normalize_dashes(text: str) -> str:
|
||||
return text.replace("\u2014", "-")
|
||||
text = text.replace("\u2014", "-")
|
||||
text = text.replace("\u2013", "-")
|
||||
text = text.replace("—", "-")
|
||||
text = text.replace("–", "-")
|
||||
text = text.replace("—", "-")
|
||||
text = text.replace("–", "-")
|
||||
text = text.replace("—", "-")
|
||||
text = text.replace("—", "-")
|
||||
text = text.replace("–", "-")
|
||||
text = text.replace("–", "-")
|
||||
return text
|
||||
|
||||
|
||||
def _replace_shortcodes(text: str) -> str:
|
||||
@@ -140,12 +162,26 @@ def _embed_url(url: str) -> str:
|
||||
)
|
||||
|
||||
|
||||
def _mask_email(match: re.Match) -> str:
|
||||
email = match.group(0)
|
||||
local, _, domain = email.partition("@")
|
||||
lowered = domain.lower()
|
||||
if lowered == _EMAIL_KEEP_DOMAIN or lowered.endswith("." + _EMAIL_KEEP_DOMAIN):
|
||||
return email
|
||||
reveal = max(1, len(local) - round(len(local) * 0.8))
|
||||
return f"{local[:reveal]}{'*' * (len(local) - reveal)}@{domain}"
|
||||
|
||||
|
||||
def _mask_emails(text: str) -> str:
|
||||
return _EMAIL_RE.sub(_mask_email, text)
|
||||
|
||||
|
||||
def _transform_text(text: str) -> str:
|
||||
out: list[str] = []
|
||||
pos = 0
|
||||
for match in _TOKEN_RE.finditer(text):
|
||||
if match.start() > pos:
|
||||
out.append(html.escape(text[pos:match.start()]))
|
||||
out.append(html.escape(_mask_emails(text[pos:match.start()])))
|
||||
if match.group("url"):
|
||||
out.append(_embed_url(match.group("url")))
|
||||
else:
|
||||
@@ -156,7 +192,7 @@ def _transform_text(text: str) -> str:
|
||||
)
|
||||
pos = match.end()
|
||||
if pos < len(text):
|
||||
out.append(html.escape(text[pos:]))
|
||||
out.append(html.escape(_mask_emails(text[pos:])))
|
||||
return "".join(out)
|
||||
|
||||
|
||||
@@ -192,7 +228,7 @@ class _MediaProcessor(HTMLParser):
|
||||
|
||||
def handle_data(self, data: str) -> None:
|
||||
if self._skip_depth > 0:
|
||||
self._out.append(html.escape(data))
|
||||
self._out.append(html.escape(_mask_emails(data)))
|
||||
else:
|
||||
self._out.append(_transform_text(data))
|
||||
|
||||
@@ -218,7 +254,7 @@ class _InlineFilter(HTMLParser):
|
||||
self._out.append(f"</{tag}>")
|
||||
|
||||
def handle_data(self, data: str) -> None:
|
||||
self._out.append(html.escape(data))
|
||||
self._out.append(html.escape(_mask_emails(data)))
|
||||
|
||||
def result(self) -> str:
|
||||
return "".join(self._out).strip()
|
||||
@@ -252,16 +288,42 @@ def _render_title(text: str) -> str:
|
||||
return _keep_inline(_content_markdown(text))
|
||||
|
||||
|
||||
def render_content(text) -> Markup:
|
||||
if not text:
|
||||
return Markup("")
|
||||
return Markup(_render_content(str(text)))
|
||||
def _extract_widgets(text: str) -> tuple[str, list[str]]:
|
||||
widgets: list[str] = []
|
||||
def _replacer(m: re.Match) -> str:
|
||||
widgets.append(m.group(1))
|
||||
return _WIDGET_PH.format(len(widgets) - 1)
|
||||
return _WIDGET_RE.sub(_replacer, text), widgets
|
||||
|
||||
|
||||
def render_title(text) -> Markup:
|
||||
def _reinsert_widgets(text: str, widgets: list[str]) -> str:
|
||||
for i, widget in enumerate(widgets):
|
||||
text = text.replace(_WIDGET_PH.format(i), widget)
|
||||
return text
|
||||
|
||||
|
||||
def render_content(text, author_is_admin: bool = False) -> Markup:
|
||||
if not text:
|
||||
return Markup("")
|
||||
return Markup(_render_title(str(text)))
|
||||
text_str = str(text)
|
||||
if author_is_admin and _WIDGET_RE.search(text_str):
|
||||
modified, widgets = _extract_widgets(text_str)
|
||||
rendered = _render_content(modified)
|
||||
result = _reinsert_widgets(rendered, widgets)
|
||||
return Markup(result)
|
||||
return Markup(_render_content(text_str))
|
||||
|
||||
|
||||
def render_title(text, author_is_admin: bool = False) -> Markup:
|
||||
if not text:
|
||||
return Markup("")
|
||||
text_str = str(text)
|
||||
if author_is_admin and _WIDGET_RE.search(text_str):
|
||||
modified, widgets = _extract_widgets(text_str)
|
||||
rendered = _render_title(modified)
|
||||
result = _reinsert_widgets(rendered, widgets)
|
||||
return Markup(result)
|
||||
return Markup(_render_title(text_str))
|
||||
|
||||
|
||||
def content_preview(text, length: int = 60) -> str:
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
This file documents devplacepy/routers/ - route organization, the full URL prefix map, and small single-file feature behaviors. Claude Code loads it automatically whenever a file under this directory is read or edited.
|
||||
|
||||
## Routing layout / prefix table
|
||||
|
||||
Routers in `devplacepy/routers/` are organised as a **directory tree that mirrors the endpoint (URL) path**, exactly like `tests/`. A domain with a single resource stays one flat file (`feed.py`, `posts.py`, `gists.py`, ...); a domain with several sub-resources is a **package directory** split **one file per sub-resource** (a distinct noun under the domain), never one file per individual endpoint. Each leaf module declares its own `router = APIRouter()` keeping the exact path strings, and the package `__init__.py` aggregates them with `router.include_router(...)`. Because a package exposes `.router` like a module, `main.py` mounts each domain at its prefix unchanged.
|
||||
|
||||
Prefixes are wired in `main.py`:
|
||||
|
||||
| Prefix | Router |
|
||||
|--------|--------|
|
||||
| `/auth` | auth/ package - one leaf per flow (`signup`, `login`, `logout`, `forgotpassword`, `resetpassword`) |
|
||||
| `/feed` | feed.py |
|
||||
| `/posts` | posts.py |
|
||||
| `/comments` | comments.py |
|
||||
| `/projects` | projects/ package - `index.py` (listing/detail/create/delete plus owner-only visibility toggles `POST /{slug}/private` and `POST /{slug}/readonly`, and async `POST /{slug}/fork`), `files.py`, and the `containers/` subpackage (below). `main.py` mounts the whole `/projects` tree from this one package. See `routers/projects/CLAUDE.md` for the deep detail on this tree |
|
||||
| `/projects/{slug}/files` | projects/files.py - per-project virtual filesystem (CRUD dirs/files, upload, inline edit, plus line-range ops: `lines` read, `replace-lines`, `insert-lines`, `delete-lines`, `append` for large text files) |
|
||||
| `/profile` | profile/ package - `index.py` (page/search/followers/update/api-key; the **Posts** tab shows only the 10 most recent posts, while the sidebar `posts_count` stat always uses the real `count()` so it is never capped to the rendered 10), `customization.py`, `notifications.py`, `ai_correction.py` (`POST /{username}/ai-correction`, owner-or-admin AI content correction toggle + prompt), `ai_modifier.py` (`POST /{username}/ai-modifier`, owner-or-admin AI modifier toggle + prompt), `interactions.py` (`POST /{username}/interactions`, owner-or-admin Devii interactive widgets preference; admin default via `devii_interactions_default`), `telegram.py` (`POST /{username}/telegram`, owner-or-admin Telegram pairing-code request/unpair), `usage.py` (`_ai_quota`, re-exported from the package) |
|
||||
| `/messages` | messages.py - real-time DM chat. `GET ""` (page; the conversation sidebar is one windowed SQL aggregate and an opened thread loads only the newest `CONVERSATION_MESSAGE_LIMIT` = 500 messages - keep these queries bounded), `GET /search`, `GET /conversations` (flat JSON conversation list, same `get_conversations` helper as the page), `POST /send` (no-JS fallback, also accepts an attachment-only empty-content message), `POST /ws-ticket` (issues a 30s single-use WS auth ticket so a browser WebSocket can authenticate cross-context, since a native `WebSocket` cannot set an `Authorization`/`X-API-KEY` header), and `WS /messages/ws` (NOT lock-owner gated - accepts on every worker; closes `1008` for guests). The WS adds live bidirectional delivery, typing, and read receipts on top of the existing `messages` table - presence is NOT part of this WS (it is the generic `PresenceManager`/pub/sub mechanism, see `devplacepy/services/CLAUDE.md`). Both the WS `send` and HTTP `POST /send` route through the shared `services/messaging/persist.py` `persist_message` choke point (identical audit/notification/mention) and then through `_finalize_and_broadcast`, which awaits any pending SYNC AI correction/modifier futures (DM content runs through both, so typing `@ai <instruction>` executes live), broadcasts the FINAL corrected/modified content with an additive `ai_processed` frame flag (the WS path passes `request=websocket` and awaits directly since HTTP middleware does not run for websockets; the frontend swaps the sender's optimistic bubble to the authoritative content). Same-worker delivery is instant via the per-worker `message_hub`; cross-worker delivery is filled by `services/messaging/relay.py` `message_relay` (per-worker ~1s DB poll on the `messages.id` watermark, deduped against the hub's delivered set). See `devplacepy/services/messaging/CLAUDE.md` |
|
||||
| `/notifications` | notifications.py |
|
||||
| `/votes` | votes.py |
|
||||
| `/reactions` | reactions.py - emoji reaction toggle on any target: `POST /reactions/{target_type}/{target_uid}` (optimistic `ReactionBar`) |
|
||||
| `/bookmarks` | bookmarks.py - bookmark/favorite toggle: `GET /bookmarks/saved` (the viewer's saved list) and `POST /bookmarks/{target_type}/{target_uid}` (toggle a bookmark) |
|
||||
| `/polls` | polls.py - poll voting: `POST /polls/{poll_uid}/vote` |
|
||||
| `/avatar` | avatar.py |
|
||||
| `/follow` | follow.py |
|
||||
| (none) | relations.py - per-user block/mute relations: `POST /block/{username}`, `/block/unblock/{username}`, `/mute/{username}`, `/mute/unmute/{username}` (soft-deletable `user_relations` rows) |
|
||||
| `/leaderboard` | leaderboard.py - `GET /leaderboard` XP/stars leaderboard page |
|
||||
| `/admin` | admin/ package - one leaf per sub-resource (`index`, `users`, `aiusage`, `aiquota`, `media`, `trash`, `settings`, `notifications`, `news`, `auditlog`, `backups`, `game`) plus the folded-in `services.py` and `containers.py` (mounted with `/services` and `/containers` sub-prefixes). `main.py` mounts the whole `/admin` tree from this one package. The `backups` leaf is the admin **Backups** dashboard (`BackupService`, kind `backup`): storage usage, backup archives, and interval/cron backup schedules (CRUD + rotation). **Archive download is restricted to the primary administrator** (the earliest-created Admin, resolved by `database.get_primary_admin_uid` / `utils.is_primary_admin`): `GET /admin/backups/{uid}/download` 403s every other admin, the `download_url` field is withheld from them at every endpoint (`can_download = is_primary_admin(admin)`, surfaced as `BackupDashboardOut.can_download_backups`), and `BackupMonitor.js` renders their Download control as a disabled button tooltipped `Not available`. See `devplacepy/services/backup/CLAUDE.md`. The `game` leaf (`/admin/game`) is the Code Farm Era admin page: `GET /admin/game` (status), `POST /admin/game/era/start` and `/era/end` - see `devplacepy/services/game/CLAUDE.md` |
|
||||
| `/admin/services` | admin/services.py |
|
||||
| `/issues` | issues/ package - issue tracker backed by Gitea (no local issue store): `index.py` (list `?state=`/`?page=`, detail `/{number}` with comments), `create.py` (async AI-enhanced filing `/create` enqueues a `issue_create` job, status at `/jobs/{uid}`), `comment.py` (synchronous, pushes to Gitea + notifies admins), `status.py` (admin open/closed), `attachments.py` (file attachments on open issues + comments, mirrored to Gitea native assets; add/list/delete with owner-or-admin + open-state guards) |
|
||||
| `/gists` | gists.py |
|
||||
| `/news` | news.py |
|
||||
| `/uploads` | uploads.py - attachment management CRUD for the signed-in user over the ONE `attachments` table (the same rows that appear on posts/comments/projects/gists/issues). Create: `POST /upload` (multipart), `POST /upload-url` (server-side fetch). Read: `GET ""` (own attachments, paginated 24/page newest-first, `?page=`, `?linked=true|false` via `database.get_user_attachments`), `GET /{attachment_uid}` (one, via `database.get_user_attachment`). Update: `PATCH /{attachment_uid}` (rename display filename via `attachments.rename_attachment`; the original file extension is ALWAYS preserved - renaming can never change the file type, the upload-time security control - audit `attachment.rename`). Delete: `DELETE /delete/{attachment_uid}` (soft delete). All `require_user_api` (401 for guests); read/rename/delete of another user's row is owner-or-admin. JSON-only router (no HTML/`respond`); list uses `UploadsListOut`, single/rename return `UploadItemOut`. Devii tools mirror every face: `upload_file`/`attach_url`/`list_attachments`/`get_attachment`/`rename_attachment`/`delete_attachment` |
|
||||
| `/media` | media.py - profile media gallery item soft delete/restore: `POST /media/{uid}/delete` and `POST /media/{uid}/restore` (owner or admin) |
|
||||
| `/openai` | openai_gateway.py |
|
||||
| `/devii` | devii.py - WebSocket terminal (`/devii/ws`), page, `/devii/usage`, `/devii/session` |
|
||||
| `/zips` | zips.py - generic zip job status (`/zips/{uid}`) and archive download (`/zips/{uid}/download`); enqueued from `/projects/{slug}/zip` and `/projects/{slug}/files/zip` |
|
||||
| `/forks` | forks.py - fork job status (`/forks/{uid}`); enqueued from `/projects/{slug}/fork`. When done its `project_url` points at the new forked project |
|
||||
| `/tools` | tools/ package - public developer tools surface. `index.py` (`/tools` landing) plus `seo.py` (**SEO Diagnostics**): `GET /tools/seo` page, `POST /tools/seo/run` (enqueue `seo` job, per-owner one-active-job cap), `GET /tools/seo/{uid}` (`SeoJobOut`), `GET /tools/seo/{uid}/report` (HTML+JSON `SeoReportOut`), `WS /tools/seo/{uid}/ws` (live progress, lock-owner gated, close `4013` retry), `GET /tools/seo/{uid}/screenshot/{n}`. Also `deepsearch.py` (**DeepSearch**): `GET /tools/deepsearch` page, `POST /tools/deepsearch/run` (enqueue `deepsearch` job, per-owner one-active-job cap; resolves the user `api_key` into the payload, guests use the internal key), `GET /tools/deepsearch/{uid}` (`DeepsearchJobOut`), `GET /tools/deepsearch/{uid}/session` (HTML+JSON `DeepsearchSessionOut`), `WS /tools/deepsearch/{uid}/ws` (live progress, lock-owner gated, `4013` retry), `WS /tools/deepsearch/{uid}/chat` (grounded RAG chat over the session collection), `POST /tools/deepsearch/{uid}/{pause|resume|cancel}`, `GET /tools/deepsearch/{uid}/export.{md,json,pdf}`. The shared owner helper is `routers/tools/_shared.py` `owner_for`. Also `isslop.py` (**AI Usage Analyzer**): `GET /tools/isslop` page, `POST /tools/isslop/run` (enqueue `isslop` job, per-owner one-active-job cap; owner = user uid or the shared `DEVII_GUEST_COOKIE` guest identity, minted when absent), `GET /tools/isslop/list` (owner history; a signed-in request first claims any guest-cookie analyses via `store.claim_guest_analyses` - a move, never a copy), `GET /tools/isslop/{uid}` (`IsslopAnalysisOut`), `GET /tools/isslop/{uid}/events` (persisted ordered event trail, `?after=SEQ` incremental poll; live frames also publish on pub/sub `public.isslop.{uid}` - the DB trail is the source of truth, pub/sub the fast path), `GET /tools/isslop/{uid}/report` (HTML+JSON `IsslopReportOut`; live `<dp-isslop-run>` while running, server-rendered report via `render_content` when done), `GET /tools/isslop/{uid}/report.md`, `GET /tools/isslop/{uid}/badge.svg` (embeddable SVG authenticity badge). Analyses/reports/badges are permanent public capability URLs (`IsslopService.cleanup` never deletes them; only the job row is swept). Surfaced by a collapsible **Tools** header dropdown (`base.html`, visible to all) |
|
||||
| `/projects/{slug}/containers` | projects/containers/ subpackage - admin per-project container manager (`instances.py` for creation/lifecycle/exec/logs/metrics/sync plus the exec websocket, `schedules.py` for cron/interval/once schedules, shared helpers in `_shared.py`). Every instance runs the shared `ppy` image. Discoverable from the project detail page (admin-only **Containers** button) and from the admin index |
|
||||
| `/admin/containers` | admin/containers.py - admin **Containers** manager: `/admin/containers` lists every instance across all projects with inline actions (start/stop/restart/terminal/edit/delete) plus a create modal (project search-select, run-as user search-select, boot language + source editor, restart policy, start-on-boot, env/ports/limits/ingress); `/admin/containers/{uid}` is the per-instance detail page (lifecycle, live logs/metrics, PTY terminal, schedules, ingress, sync, status history) and `/admin/containers/{uid}/edit` edits run-as user, boot language/script/command, restart policy, start-on-boot, and limits. `POST /admin/containers/create`, `/{uid}/edit`, `/{uid}/{start,stop,restart,pause,resume}`, `/{uid}/sync`, `/{uid}/delete` call `api.*` directly under `require_admin` (no docker/exec backend duplicated); `GET /admin/containers/projects/search` and `/users/search` back the create/edit search-selects. The lifecycle and detail views stay layered over the per-project `/projects/{slug}/containers/instances/{uid}/...` endpoints (the instance carries its `project_uid`) |
|
||||
| `/p/{slug}` | proxy.py - public ingress reverse proxy (HTTP + WebSocket) to a running container instance's published host port, opt-in per instance via `ingress_slug` |
|
||||
| `/xmlrpc` | xmlrpc.py - reverse-proxies XML-RPC calls to the forking XML-RPC bridge (`services/xmlrpc/`, supervised by `XmlrpcService` on loopback `config.XMLRPC_PORT`), which generates one XML-RPC method per documented REST endpoint from `docs_api.API_GROUPS` (`posts.create`, `feed.list`, ...). One struct of named params per call; auth via in-band `api_key`, `X-API-KEY`/`Bearer` header, or `http://user:pass@host/xmlrpc` Basic. Full introspection + `system.multicall`; REST errors become XML-RPC faults. Exempt from the rate limiter (enforced on the forwarded internal hop). See `devplacepy/services/xmlrpc/CLAUDE.md` |
|
||||
| `/api` | devrant/ package - devRant-compatible REST protocol (`auth.py`, `rants.py`, `comments.py`, `notifs.py`). Translates devRant requests onto DevPlace data: rants<->posts, comments/votes onto the native engagement layer, devRant integer ids onto each table's auto-increment `id`. Token auth via `devrant_tokens`. See `routers/devrant/CLAUDE.md` for the deep detail on this tree |
|
||||
| `/dbapi` | dbapi/ package - **primary-administrator-only, strictly READ-ONLY** generic database API over `dataset` (never inserts/updates/deletes/restores; the write surface was removed because it bypassed every per-route admin safeguard). `tables.py` (`GET /tables`, `GET /{table}/schema`), `crud.py` (read only: `GET /{table}` + `GET /{table}/{key}/{value}`, `?include_deleted`), `query.py` (`POST /query` hard SELECT-only validated read-only run; `POST /query/async` + `GET /query/{uid}` + `WS /query/{uid}/ws` via `DbApiJobService` kind `dbquery`), `nl.py` (`POST /nl` natural-language->SQL via the AI gateway, returns validated SELECT, `execute=true` runs it read-only). **Auth = the PRIMARY administrator only** (the earliest-created Admin, `utils.is_primary_admin` / `database.get_primary_admin_uid` - the same identity that gates backup downloads) by session/api_key (`services/dbapi/policy.py`); every other administrator is refused like a member, and there is **no internal-key path** - the gateway `internal_gateway_key()` is NOT accepted (no service-to-service access). SQL validated by `services/dbapi/validate.py` (sqlglot classify + suspicious-flag + read-only `EXPLAIN` dry-run). Devii tools `db_*` are read-only (list/get/query/nl; no write tools) and flagged `requires_primary_admin=True`, so they are added to the LLM tool list ONLY for the primary administrator (every other session never sees them and Devii is unaware they exist). `services/pubsub/policy.py` resolves its own admin/internal actor and does NOT reuse `dbapi.policy.caller_for`, so the primary-admin restriction does not leak onto the pub/sub bus. See `devplacepy/services/dbapi/CLAUDE.md` |
|
||||
| `/quizzes` | quizzes/ package - **Quizzes**: `index.py` (hub, create, import, detail, export, edit, delete, publish, per-quiz leaderboard, cross-quiz scoreboard), `questions.py` (builder page + question CRUD + reorder), `attempts.py` (start/resume, play, answer, finish, results). The hub `GET /quizzes` is the approved three-column `.feed-layout` reused verbatim from `/feed`: filters + search left (`?filter=all|todo|done|mine|drafts`, `?search=`, `?page=`), the quiz list centre with the **New quiz** and **Create quiz with Devii** actions, and the cross-quiz **scoreboard** rail right (score per user, best attempt per quiz, 15s display cache). Publishing is terminal - every write on a published quiz is a 400 and there is no unpublish route. See `devplacepy/services/quiz/CLAUDE.md` |
|
||||
| `/game` | game/ package - the **Code Farm** idle game (`index.py` + `farm.py`): `GET /game` (page), `GET /game/state`, `GET /game/leaderboard?board=`, `POST /game/{plant,harvest,buy-plot,upgrade,fertilize,daily,grant,perk,prestige,legacy,mastery,quests/claim}`, `POST /game/{defense/upgrade,defense/downgrade,infrastructure/buy,cosmetics/buy,cosmetics/equip}`, plus social `GET /game/farm/{username}` and `POST /game/farm/{username}/{water,steal}`. See `devplacepy/services/game/CLAUDE.md` |
|
||||
| (none) | push.py - web push + PWA: `GET`/`POST /push.json` (VAPID public key / subscription register), `GET /service-worker.js`, `GET /manifest.json` |
|
||||
| (none) | docs.py (docs/ package) - the documentation site (prose pages + API reference). See `routers/docs/CLAUDE.md` for the deep detail on this tree (`DOCS_PAGES`, audience tiers, prose rendering pipeline) |
|
||||
| `/pubsub` | pubsub.py - **database-free** publish/subscribe bus. `WS /pubsub/ws` (subscribe/unsubscribe/publish frames, `foo.*` wildcards), `POST /pubsub/publish` + `GET /pubsub/topics` (admin/internal). Lock-owner-gated WS (`4013` retry) so subscribers converge on one worker; topic authz in `services/pubsub/policy.py` (`user.{uid}.*` private, `public.*` shared, admin/internal anywhere, guests opt-in). In-process `services.pubsub.publish(topic, data)` for backends; frontend `app.pubsub`. See `devplacepy/services/pubsub/CLAUDE.md` |
|
||||
| (none) | seo.py - `/robots.txt`, `/sitemap.xml` |
|
||||
|
||||
## Route aggregation rules
|
||||
|
||||
**Aggregation rules:** a leaf that owns the domain's collection-root (`""`) route must be the package's base router (FastAPI rejects an empty path included under an empty prefix), so the package `__init__` imports that leaf's `router` and includes the rest onto it (see `routers/issues`, `routers/admin`, `routers/projects`); leaves carved out of a former monolith keep their relative path strings and are included with no sub-prefix, while a router folded in from a deeper mount keeps its own paths and is included with a sub-prefix - `admin/__init__` includes `services.router` with `prefix="/services"` and `containers.router` with `prefix="/containers"`. Module-private helpers shared across a package's leaves live in its `_shared.py`. The `/projects` tree (project CRUD + `files.py` + `containers/`) and the `/admin` tree (every admin sub-resource plus the folded-in `services` and `containers`) are each mounted from a single package.
|
||||
|
||||
## HTML/JSON content negotiation
|
||||
|
||||
Every page/redirect endpoint also returns JSON when the client asks. Core in `devplacepy/responses.py`: `wants_json(request)` (true for `Accept: application/json` or `Content-Type: application/json`; browser `text/html` -> HTML, so existing behaviour is unchanged). **`X-Requested-With: fetch` is deliberately NOT a trigger** - the frontend sends it on form/engagement fetches expecting the old redirect, and the four legacy engagement endpoints (votes/reactions/bookmarks/polls) handle that header themselves. Two helpers replace the direct returns:
|
||||
|
||||
- **Page GETs:** `return respond(request, "x.html", context, model=XOut)` - HTML renders the template; JSON does `XOut.model_validate(context).model_dump()`. One context, two renderings.
|
||||
- **Action POSTs:** `return action_result(request, url, data=<resource|None>)` - HTML 302 redirects; JSON returns `{ok, redirect, data}`. (Set cookies on the returned response after calling it, as `auth.py` login/signup do.)
|
||||
|
||||
Response models live in `devplacepy/schemas.py` (Pydantic v2, `extra="ignore"`, all-Optional so they validate the existing context dicts directly). **Always project users through `UserOut`** (and `AdminUserOut`) - the raw user rows contain `email`/`api_key`/`password_hash`, and the models drop them; never serialize a raw user row. List item shapes vary (feed/gists/news/admin-news are wrapped `{post|gist|article: ...}`; projects are flat rows with `author_name`/`my_vote`) - match the context exactly. Errors negotiate centrally: `main.py` 404/500/validation handlers and the rate-limit/maintenance middleware, plus `utils.require_user`/`require_admin` (401/403 for JSON, 303 redirect for browsers). The four legacy AJAX endpoints (votes/reactions/bookmarks/polls) keep their original flat JSON shapes and are left untouched. Documented in `docs_api.py`'s Conventions group.
|
||||
|
||||
## FastAPI patterns
|
||||
|
||||
- **All routes are async.** Form data is validated via a typed Pydantic body param: `data: Annotated[SomeForm, Form()]` (models in `models.py`). Read raw `await request.form()` only when also handling an uploaded file (a separate `File()` param would embed the model under its parameter name).
|
||||
- **Return `RedirectResponse(url=..., status_code=302)`** for redirects - always pass `status_code` as an integer literal, never `status.HTTP_302_FOUND`.
|
||||
- **Return `templates.TemplateResponse("name.html", {...})`** from `devplacepy.templating` to render.
|
||||
- **Never create your own `Jinja2Templates` instance.** Import the shared one: `from devplacepy.templating import templates`.
|
||||
- **Register new routers in `main.py`:** `app.include_router(router_instance, prefix="/{path}")`
|
||||
- **`require_user(request)`** redirects guests (303) to login, but raises **401** when credentials *were* supplied yet invalid (so API clients get a clear error). Only post/comment/vote/etc. routes use it - the feed is public. `require_admin` and `require_user_api` (401-only) build on it. None of these changed signatures, so all auth schemes work through existing call sites.
|
||||
- **For a missing detail resource, `raise not_found("X not found")`** (`utils.py`) - it returns an `HTTPException(404)` that the global handler renders as `error.html`. Do not return a bare `HTMLResponse(..., status_code=404)`.
|
||||
- **Detail pages reuse `load_detail(table, target_type, slug, user)`** (`content.py`) for item+author+comments+attachments+`star_count`+`my_vote`; list pages reuse `enrich_items(items, key, authors, extra_maps, user=...)`. Prefer these over manual per-row loading (posts/projects/gists detail and feed/gists/profile lists already do).
|
||||
- **`get_current_user(request)` resolves ALL auth schemes** (`utils.py`), in order: `session` cookie -> `X-API-KEY` header -> `Authorization: Bearer <api_key>` -> `Authorization: Basic base64(username-or-email:password)`. It memoizes the result on `request.state._auth_user` (resolved once per request - it is called by both the maintenance middleware and the route) and caches users in `_user_cache` (by session token, `"k:"+api_key`, or a hash of the Basic header). Because every router already routes through this one function, API-key/Bearer/Basic auth work on every page/action with no per-route code. Use it for pages viewable by guests too (feed, news detail, projects).
|
||||
- **Post deletion must cascade:** delete comments and votes first, then the post. Always check ownership: `post["user_uid"] == user["uid"]`.
|
||||
- **Message deduplication needed** when `sender_uid == receiver_uid` (messaging yourself): `seen = set()` of message UIDs before appending to result list.
|
||||
|
||||
## Polymorphic comments and votes
|
||||
|
||||
The `comments` table uses `(target_type, target_uid)` so the same `_comment_section.html` component works for `post`, `project`, `gist`, and `news`. Votes follow the same shape via `/votes/{target_type}/{uid}`. `resolve_target_redirect()` in `comments.py` maps target_type back to the correct detail URL.
|
||||
|
||||
## Small feature notes: comment editing, post editing, inline comments
|
||||
|
||||
### Inline comment on feed cards
|
||||
|
||||
Every post card on the feed has an inline comment form (`.feed-comment-form`) beneath the post actions. It posts to `/comments/create` like the detail page comment form. Tests must scope Post button clicks to `#create-post-modal button.btn-primary:has-text('Post')` to avoid matching the inline comment's Post button.
|
||||
|
||||
### Comment editing
|
||||
|
||||
A comment's owner (only the owner, never an admin) sees an inline "Edit" button (`data-action='edit'`) in `_comment.html`. `CommentManager.toggleEditForm` swaps the `.comment-text` for a textarea seeded from its `data-raw` attribute (the raw markdown, since `contentRenderer.applyTo` overwrites `textContent` on first render), posts via `Http.send` to `POST /comments/edit/{comment_uid}`, then re-renders the new body in place with `contentRenderer.applyTo`. The route (`content.edit_comment_record`) is `is_owner`-only, writes `content` + `updated_at`, records the `comment.edit` audit event, and branches on `wants_json`: JSON clients get `CommentEditOut{uid, content, url, updated_at}`, the no-JS form falls back to a redirect to the comment anchor. Edits are NOT soft-delete related (the body is overwritten in place). Devii tool: `edit_comment` (owner-only, no confirm). Scope test Edit clicks to `.comment-action-btn:has-text('Edit')`.
|
||||
|
||||
### Post editing
|
||||
|
||||
Post owners see an "Edit" button on the post detail page that opens `#edit-post-modal`. The edit form allows changing title, content, and topic. The POST route is `/posts/edit/{post_uid}` with ownership check. The edit modal's textarea has `id="edit-content"` - tests must scope to `.comment-form textarea[name='content']` for comment operations.
|
||||
|
||||
## Gists
|
||||
|
||||
A dedicated `/gists` page for sharing code snippets. Uses `gists` table (auto-created by `dataset`).
|
||||
|
||||
### Database columns
|
||||
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| `uid` | text | UUID |
|
||||
| `user_uid` | text | FK -> users.uid |
|
||||
| `title` | text | Required, max 200 |
|
||||
| `description` | text | Optional, max 5000, markdown (rendered by ContentRenderer) |
|
||||
| `source_code` | text | Required, max 50000 |
|
||||
| `language` | text | One of 27 supported languages |
|
||||
| `slug` | text | `make_combined_slug(title, uid)` |
|
||||
| `stars` | int | Net vote count (via `/votes/gist/{uid}`) |
|
||||
| `created_at` | text | ISO datetime |
|
||||
|
||||
### Routes
|
||||
|
||||
| Method | Path | Handler | Auth |
|
||||
|--------|------|---------|------|
|
||||
| GET | `/gists` | `gists_page` | No |
|
||||
| GET | `/gists/{slug}` | `gist_detail` | No |
|
||||
| POST | `/gists/create` | `create_gist` | Yes |
|
||||
| POST | `/gists/delete/{slug}` | `delete_gist` | Yes (owner) |
|
||||
|
||||
### Polymorphic reuse
|
||||
|
||||
- **Comments**: Uses `_comment_section.html` with `target_type="gist"` - same component as posts/projects
|
||||
- **Voting**: Uses existing `/votes/gist/{uid}` route - updates `gists.stars`
|
||||
- **Content rendering**: Description rendered via `ContentRenderer.js` (`.rendered-content[data-render]`)
|
||||
- **Profile tab**: "Gists" tab between Projects and Activity on profile pages
|
||||
|
||||
### CodeMirror editor
|
||||
|
||||
- CodeMirror 5 loaded from CDN in `gists.html` via `{% block extra_js %}`
|
||||
- 22 language modes pre-loaded (Python, JS, TS, HTML, CSS, C, C++, Java, Go, Rust, SQL, Bash, YAML, Markdown, Swift, PHP, Ruby, Kotlin, Haskell, Lua, Perl, R, Dart, Scala)
|
||||
- `GistEditor.js` initializes CodeMirror on `#gist-source-editor` textarea
|
||||
- Language selector dropdown dynamically switches CodeMirror mode
|
||||
- `Ctrl+S` shortcut saves and submits the form
|
||||
- On form submit, `editor.save()` syncs CodeMirror content back to the hidden textarea
|
||||
|
||||
### Display
|
||||
|
||||
- Source code rendered in `<pre><code class="language-xxx">` block on detail page
|
||||
- Syntax highlighting handled by existing `highlight.js` loaded globally in `base.html`
|
||||
- Copy button uses `navigator.clipboard.writeText()`
|
||||
- Cards in listing show language badge, title, truncated description, author, star count
|
||||
|
||||
### Sitemap
|
||||
|
||||
- Latest 500 gists included in sitemap, `changefreq="weekly"`, `priority="0.6"`
|
||||
|
||||
## Feed and listing features
|
||||
|
||||
### Politics category
|
||||
|
||||
The `politics` topic is available as a feed filter sidebar item (icon `🏛`) and post topic. It defines the CSS variable `--topic-politics: #00bcd4` in `variables.css` and the `.badge-politics` class in `base.css`. It is validated like every topic via the canonical `TOPICS` list in `constants.py` (consumed by `models.py` `valid_topic`, `templating.py` `TOPICS` global, `routers/posts.py`, `docs_api`). Unlike the former `signals` topic, `politics` is also part of the bot fleet's rotation - it is in `services/bot/config.py` `CATEGORIES`/`FEED_TOPICS`, carries a modest per-persona weight in `PERSONA_CATEGORY_WEIGHTS`, and has a writing instruction in `services/bot/llm.py` `category_extras`.
|
||||
|
||||
### Date format
|
||||
|
||||
All dates displayed to users use European DD/MM/YYYY format. Implemented via:
|
||||
|
||||
- **`format_date(dt_str, include_time=False)`** in `utils.py` - converts ISO datetime -> `DD/MM/YYYY` or `DD/MM/YYYY HH:MM`
|
||||
- Registered as template global in `templating.py`: `{{ format_date(dt) }}`
|
||||
- **`time_ago()`** returns `DD/MM/YYYY` for items older than 30 days (instead of `"Xmo ago"`)
|
||||
- Services page has a JS `formatDate()` function for live polling updates
|
||||
|
||||
### Admin pagination
|
||||
|
||||
Both `/admin/users` and `/admin/news` use offset-based pagination via a reusable component:
|
||||
|
||||
- **`templates/_pagination.html`** - numbered page links with ellipsis, Previous/Next buttons, total count
|
||||
- Routes accept `?page=N` query param, clamped to valid range
|
||||
- `per_page = 25`, pagination metadata computed server-side and passed as `pagination` dict
|
||||
- Only renders when `total_pages > 1`
|
||||
- CSS in `admin.css` (`.pagination`, `.pagination-btn`, `.pagination-page`, `.pagination-ellipsis`)
|
||||
|
||||
### News detail and comments
|
||||
|
||||
News articles have an internal detail page at `/news/{slug}` with full comment support:
|
||||
|
||||
- **Route:** `GET /news/{news_slug}` in `routers/news.py` - resolves by slug first, then UUID
|
||||
- **Template:** `templates/news_detail.html` - shows image, source, grade, description, content, external link
|
||||
- **Comments:** Uses `_comment_section.html` with `target_type="news"` - same component as posts/projects
|
||||
- **`resolve_target_redirect()`** in `comments.py` handles `"news"` -> `/news/{slug}`
|
||||
- Listing links in `news.html` point to internal detail page; "Read on Source" still goes to external URL
|
||||
|
||||
### Home page (`GET /`)
|
||||
|
||||
The home route (`main.py` `landing()`) never redirects - it renders `templates/landing.html` for everyone, branching on `user`:
|
||||
|
||||
- **Guests** get the marketing hero (`Join DevPlace Free` CTA + features grid).
|
||||
- **Signed-in users** get a personalized dashboard hero (`.dashboard-welcome`): avatar, "Welcome back, {username}", quicklink buttons (`.dashboard-btn`, with `New Post` -> `/feed` as `.dashboard-btn-primary`, plus Code Farm/Projects/Gists), and a Posts/Stars/Level stat strip (`.dashboard-stats`, `user_post_count` + the user dict's `stars`/`level`). Styles live in the `.dashboard-*` classes in `static/css/landing.css`.
|
||||
- Both states share the Latest Posts + Developer News + "Build With Us" sections. The **Build With Us** section is static HTML/CSS (`.landing-help-*` in `landing.css`): four cards linking to `/docs/index.html` (Documentation), `/swagger` + `/openapi.json` (API Reference), `/issues` (Contribute & Report), and Devii. The Devii card's `Launch Devii` button is a plain `<button data-devii-open>` that opens the globally mounted `DeviiTerminal` (`app.devii`) in place - no extra JS, route, schema, or Devii action; a secondary link points to `/devii/` for the full terminal page.
|
||||
- Context adds `user`, `is_authenticated`, `user_post_count`; `LandingOut` carries `is_authenticated`/`user_post_count` for the JSON form. `GET /` is documented in `docs_api.py` (id `home`, mapped to `LandingOut`).
|
||||
|
||||
### Author diversity (interleave, never drop)
|
||||
|
||||
Both the home page Latest Posts section and the main `/feed` *interleave* authors so one prolific account cannot fill a contiguous run, **without dropping any post** (the old per-author cap is gone). Helpers in `database.py`:
|
||||
|
||||
- `interleave_by_author(rows, uid_key="user_uid")` - pure reordering of a row list. Greedy and order-preserving: it walks the list and at each step emits the earliest row whose author differs from the last emitted one (deferring a same-author run to the next available author, recursively); when only same-author rows remain it emits them in order. The result is a permutation of the input - never a subset - so each author's own posts keep their relative (date) order and no two consecutive rows share an author unless the whole list is one author. The home route fetches the 6 newest posts and interleaves them.
|
||||
- `paginate_diverse(table, *clauses, ..., uid_key="user_uid", **filters)` - a drop-in replacement for `paginate` (same `(rows, next_cursor)` contract) that calls `paginate` then `interleave_by_author` on the page. Because the interleave is a pure permutation of the page, the cursor (oldest `created_at` in the page) is identical to `paginate`'s, so infinite scroll stays correct with no gaps/overlaps. `feed.py` `get_feed_posts` uses it for every tab.
|
||||
|
||||
When building any new "recent items" list, reuse these instead of raw `find(order_by=...)` / `paginate`.
|
||||
|
||||
### Listing search (feed, gists, projects)
|
||||
|
||||
The three public listings - `/feed`, `/gists`, `/projects` - share one free-text search box at the top of the left filter panel. Two reuse points keep it DRY and consistent:
|
||||
|
||||
- **Data layer:** `database.text_search_clause(table, search, fields=("title", "description"), author_field=None)` returns a single SQLAlchemy `or_(col.ilike("%term%") ...)` clause over the given columns (skipping any not present on the table), or `None` when `search` is blank or the table does not exist yet. Append it as a positional clause to `paginate` / `paginate_diverse` / `table.count(...)` **only when not None** (no search leaves the queries unchanged). Field mapping: projects/gists use `("title", "description")`, posts use `("title", "content")`. **Author-username matching:** posts/gists/projects store `user_uid`, not the author's username text, so a username query never matched a text column - the fix is the `author_field` argument. When set (all three listings pass `author_field="user_uid"`), `text_search_clause` resolves usernames matching the search term to uids via `database.get_uids_by_username_match(search)` (a capped `users.username LIKE` over the existing `idx_users_username` index) and OR-includes `columns[author_field].in_(uids)` in the same clause - no SQL JOIN, staying within the `dataset` query pattern. So each listing now matches its text fields **plus** the author username. `projects.py`, `gists.py`, `feed.py`, and the devRant `services/devrant/feed.py` all call it; never re-inline an `ilike` search clause and never add a JOIN - resolve username->uids and reuse `author_field`.
|
||||
- **View layer:** `templates/_sidebar_search.html` is the single search-box partial (the `Filter` heading + the GET form). Include it at the very top of `<aside class="sidebar-card">` with three locals: `_action` (the listing URL), `_placeholder`, and `_hidden` (a dict of `name -> value` rendered as hidden inputs so the active category/tab filter survives a search submit - e.g. `{"tab": current_tab, "topic": current_topic}` for the feed). It reads the `search` context var for the current value. Any future listing with a left filter panel reuses this partial plus `text_search_clause`; do not hand-roll another search form.
|
||||
|
||||
The `search` value is threaded back into each listing's context and exposed on `FeedOut.search` / `GistsOut.search` / `ProjectsOut.search`, documented as a `search` query param on the `feed-list` / `gists-list` / `projects-list` endpoints in `docs_api.py`, and offered to Devii as the `search` query param on `view_feed` / `list_gists` / `list_projects`.
|
||||
|
||||
### Recent comments on listing/feed cards (consistent across post, gist, project, news)
|
||||
|
||||
Every public listing card that shows the last few comments must render them with the **same visual hierarchy as the detail page** (depth indentation, vote rail, reply nesting). One pipeline drives all four surfaces - never inline a flat-only render:
|
||||
|
||||
- **Data layer:** `database.get_recent_comments_by_target_uids(target_type, target_uids, limit=3, user=None)` is the single batch helper. It runs one `ROW_NUMBER() OVER (PARTITION BY target_uid ORDER BY created_at DESC, id DESC)` window over `comments` filtered by `target_type=:tt` + `deleted_at IS NULL`, builds each item via `_build_comment_items` (identical dict shape to the detail page: `comment`/`author`/`time_ago`/`votes`/`my_vote`/`children`/`attachments`/`reactions`), then **nests the limited set by `parent_uid` into `children`** so replies render indented. Returns `{target_uid: [top-level items]}`. `get_recent_comments_by_post_uids` is now a thin wrapper delegating with `target_type="post"`, so `feed.py` is unchanged and there is no behavior drift.
|
||||
- **Routers:** `feed.py` (post), `gists.py` (gist), `projects/index.py` (project), `news.py` (news) each batch-fetch with the helper and attach `recent_comments` per item. Gists and news are wrapper dicts (`item["recent_comments"]`); projects are **flat dicts** (`project["recent_comments"]`).
|
||||
- **Schemas:** `FeedItemOut`, `GistItemOut`, `NewsListItemOut`, and the flat `ProjectListItemOut` all carry `recent_comments: list[CommentItemOut] = []`, so the `respond(model=...)` JSON exposes the same nested tree and never silently drops the key.
|
||||
- **View:** each card renders the block with the shared `_comment.html` `render_comment(c, 0)` macro inside a `.post-card-comments` wrapper (copied from `_post_card.html`). The macro recurses on `item.children` for indentation. **CSS requirement:** the template must load `post.css` (the `.comment`/`.comment-depth`/`.comment-replies` hierarchy) and `feed.css` (the `.post-card-comments` wrapper). `feed.html`/`gists.html`/`projects.html` already loaded `feed.css`; `news.html` loads neither by default, so it loads both.
|
||||
|
||||
### Landing page news
|
||||
|
||||
Articles can be toggled to appear on the landing page via `/admin/news/{uid}/landing`:
|
||||
|
||||
- **`show_on_landing`** field on `news` table
|
||||
- Landing route (`main.py` `GET /`) fetches up to 6 articles with `show_on_landing=1`
|
||||
- Rendered as a 3-column card grid with image, source, title, date (responsive -> 1 column on mobile)
|
||||
- Toggleable individually from the admin news table
|
||||
|
||||
### Public feed
|
||||
|
||||
The feed page (`GET /feed`) is accessible without authentication:
|
||||
|
||||
- Uses `get_current_user(request)` instead of `require_user()` - returns `None` for guests
|
||||
- Guests see posts but not the FAB, create modal, inline comment forms, or following tab
|
||||
- All POST routes (create, comment, vote) remain guarded by `require_user()`
|
||||
- Topnav shows Login/Sign Up for unauthenticated visitors; Messages, Admin, notifications for authenticated
|
||||
|
||||
## SEO implementation
|
||||
|
||||
All SEO features are implemented across the following locations:
|
||||
|
||||
### Core SEO utilities
|
||||
- `devplacepy/seo.py` - JSON-LD schema generators (WebSite, BreadcrumbList, DiscussionForumPosting, ProfilePage, SoftwareApplication), meta description truncation, schema combiner, sitemap XML generator
|
||||
- `routers/seo.py` - robots.txt and sitemap.xml routes
|
||||
|
||||
### SEO template context
|
||||
- Every router passes `page_title`, `meta_description`, `meta_robots`, `canonical_url`, `og_title`, `og_description`, `og_image`, `og_type`, `breadcrumbs`, `page_schema` via `base_seo_context()`
|
||||
- Auth pages: `noindex,nofollow`
|
||||
- Messages/Notifications: `noindex,nofollow`
|
||||
- Profiles with < 2 posts: `noindex,follow`
|
||||
- All other pages: `index,follow`
|
||||
|
||||
### Template layer
|
||||
- `templates/base.html` - dynamic `<title>`, `<meta description>`, `<link canonical>`, `<meta robots>`, Open Graph, Twitter Cards, JSON-LD injection, breadcrumb nav, CDN `dns-prefetch`/`preconnect`
|
||||
- `static/css/base.css` - `.breadcrumb` (aria-label breadcrumb nav), `.sr-only` (accessible hidden headings)
|
||||
|
||||
### Heading hierarchy
|
||||
- `feed.html` - `<h1 class="sr-only">Feed</h1>`
|
||||
- `profile.html` - username rendered as `<h1 class="profile-name">`
|
||||
- `messages.html` - `<h1 class="sr-only">Messages</h1>`
|
||||
- `projects.html` - `<h1>Projects</h1>`
|
||||
- `post.html` - post title as `<h1>`, "Related Discussions" as `<h3>`
|
||||
|
||||
### Post slugs
|
||||
- Slug generated on post creation via `slugify()` and stored in `posts.slug` column
|
||||
- Posts can be looked up by slug or UUID
|
||||
- Minimum content validation: post body >= 10 chars, comment >= 3 chars
|
||||
|
||||
### Related posts
|
||||
- `templates/post.html` - "Related Discussions" widget at bottom of post page (queried by matching topic)
|
||||
|
||||
### Performance
|
||||
- `loading="lazy"` on all avatar images
|
||||
- `dns-prefetch` + `preconnect` for CDN resources in `<head>`
|
||||
- Security headers middleware: `X-Robots-Tag`, `X-Content-Type-Options`
|
||||
|
||||
### Default OG image
|
||||
- `static/og-default.svg` - 1200x630 SVG with DevPlace branding
|
||||
- Used as fallback `og:image` on all pages
|
||||
|
||||
### SEO tests
|
||||
- SEO tests are split by surface: `tests/api/robotstxt.py`, `tests/api/sitemapxml.py`, and `tests/unit/seo.py` plus the per-page e2e checks - covering robots.txt, sitemap.xml, page titles, noindex, canonical URLs, OG tags, Twitter cards, structured data, security headers
|
||||
|
||||
## Engagement: reactions, bookmarks, polls, contribution heatmap
|
||||
|
||||
### Emoji reactions
|
||||
- **Any single emoji is allowed.** `ReactionForm` validates with `rendering.is_single_emoji(value)` (`emoji.emoji_count(text) == 1 and emoji.purely_emoji(text)`, whitespace stripped) - so every emoji the picker can emit (all 3953 fully-qualified sequences, skin tones and ZWJ families included) is accepted, while text, mixed text+emoji, and multi-emoji strings are rejected. `REACTION_EMOJI` in `constants.py` (a template global) is now only the **quick-pick palette** shown by default, not an allowlist; the full set comes from the vendored `emoji-picker-element` opened by the palette's `+` button.
|
||||
- The rendered chips are `reaction_emojis(_reactions)` (a `templating.py` global): the quick-pick palette plus any other emoji already used on that target (from `counts`/`mine`), so an off-palette reaction renders server-side too. `ReactionBar.js` creates a chip on the fly for any emoji returned by the JSON response that has none yet.
|
||||
- Endpoint `POST /reactions/{target_type}/{target_uid}` (`routers/reactions.py`) toggles one `(user, target, emoji)` row in the `reactions` table. Target types: `post`, `comment`, `gist`, `project`. AJAX (`x-requested-with: fetch`) returns `{counts, mine}`.
|
||||
- Reactions are **non-ranking** - they never touch `stars` or XP and intentionally send **no notifications** (votes already notify; reactions would be notification spam).
|
||||
- Batch reads via `get_reactions_by_targets(target_type, uids, user)` in `database.py` (used by feed, profile, comment loader, `load_detail`) - never per-row. The `_reaction_bar.html` partial takes `_type`, `_uid`, `_reactions` ({counts, mine}) and renders the full palette as toggle chips; `ReactionBar.js` uses document-level click delegation. All four engagement controllers (`ReactionBar`, `VoteManager`, `BookmarkManager`, `PollManager`) extend the shared `OptimisticAction` base (the `Http.sendForm -> render -> error` core); each keeps only its own event wiring and `_render`.
|
||||
|
||||
### Bookmarks
|
||||
- `POST /bookmarks/{target_type}/{target_uid}` toggles a `bookmarks` row; `GET /bookmarks/saved` renders the personal list (`saved.html`). Target types: `post`, `gist`, `project`, `news`.
|
||||
- `_bookmark_button.html` takes `_type`, `_uid`, `_bookmarked`; `BookmarkManager.js` swaps the label/`bookmarked` class from the JSON `{saved}`. Batch state via `get_user_bookmarks(user_uid, target_type, uids)`.
|
||||
|
||||
### Polls
|
||||
- A poll rides on a post (one `polls` row keyed by `post_uid`, options in `poll_options`, one-per-user votes in `poll_votes`). Created in `posts.py:create_poll` when `poll_question` plus >= 2 non-empty `poll_options` are submitted (capped at 6). Both `create_post` and `edit_post` accept the poll fields; `edit_post` only attaches a poll when the post has **none** yet (it never replaces an existing poll). The builders live in the create-post modal (`feed.html`) and the edit-post modal (`post.html`, rendered only when the post has no poll) using `data-poll-toggle` / `data-poll-add-option`.
|
||||
- `poll_options` accepts either repeated form fields (the web builders, which preserve commas inside an option label) **or** a single newline- or comma-separated string (the API/Devii path). `models.py:normalize_poll_options` splits a lone delimited element - applied as a `mode="before"` validator on `PostForm`/`PostEditForm` - so the documented "one per line or comma separated" agent format actually produces a multi-option poll instead of a single dropped option.
|
||||
- `POST /polls/{poll_uid}/vote` toggles the voter's choice and returns the full poll dict: voting on a new option switches, voting the **same** option again **retracts** (one vote per user, like reactions). Results (bars + `%`) are **always shown** to everyone; the chosen option gets `.chosen` + a checkmark. Guests see read-only bars with a "Log in to vote" hint (options `disabled`). Batch reads via `get_polls_by_post_uids(post_uids, user)` / `get_poll_for_post(post_uid, user)`.
|
||||
- Composer poll builder (`feed.html`, `PollManager.js`): poll inputs are `disabled` until "Add poll" is opened (so a never-opened or "Remove poll"-collapsed builder submits **nothing**); a capture-phase `submit` listener blocks an incomplete poll (question + >= 2 options) with an inline error instead of silently dropping it - capture phase + `stopImmediatePropagation()` is required so it pre-empts `FormManager`'s bubble-phase submit handlers.
|
||||
|
||||
### Cascade
|
||||
- `soft_delete_engagement(target_type, uids, deleted_by)` in `database.py` soft-deletes reactions, bookmarks and (for posts) poll rows. It is called from `content.py:delete_content_item` (for the item and its comments) and `comments.py:delete_comment` (which now soft-deletes the comment plus its attachments, votes, and engagement under one `stamp`). Add it to any new delete path. The unfiltered hard `delete_engagement` remains for GC only.
|
||||
|
||||
### Contribution heatmap and streaks
|
||||
- Derived, **no new table**: `get_activity_calendar(user_uid)` aggregates `date(created_at)` across `posts`/`comments`/`gists`/`projects` (cached 120s); `get_activity_heatmap` returns 53 Monday-aligned weeks of `{date, count, level}` (level 0-4); `get_streaks` returns `{current, longest}`. Rendered server-side in `profile.html` (`.heatmap-grid`, styles in `profile.css`). The `On Fire` badge is awarded in `check_milestone_badges` when the current streak >= 7.
|
||||
|
||||
### Follow graph listings
|
||||
- `profile.html` has Followers and Following tabs (`?tab=followers` / `?tab=following`, `?page=N`). `profile_page` computes `get_follow_counts(uid)` for the tab labels and, only for those two tabs, `_follow_people(uid, tab, current_user, page)`. The same two listings are exposed as JSON at `GET /profile/{username}/followers` and `GET /profile/{username}/following` (public, 25 per page) returning `{username, mode, count, page, total_pages, <mode>: [{uid, username, bio, is_following}]}`.
|
||||
- `database.py` helpers: `get_follow_counts(uid)` (`{followers, following}`), `get_follow_list(uid, mode, page)` (paginated people + `build_pagination`, ordered newest-first), and `get_following_among(follower_uid, target_uids)` (single IN-clause set used to set `is_following` per row, avoiding N+1). `mode` is `"followers"` (people who follow `uid`) or `"following"` (people `uid` follows).
|
||||
- Devii catalog tools `list_followers` / `list_following` (`requires_auth=False`) map to the JSON endpoints; documented in `docs_api.py` under the `profiles` group.
|
||||
|
||||
### Block and mute (`routers/relations.py`)
|
||||
A logged-in user can **block** or **mute** another user; both are one-directional and reversible. **Block** hides every piece of the blocked user's content from the blocker - posts, comments (any category), feed, listings, issue list, detail pages, and DMs - everywhere EXCEPT the blocked user's own profile page (kept fully visible so the blocker can review and unblock), and it also suppresses any notification that user would generate. **Mute** is the lighter option: it only suppresses the muted user's notifications while their content stays visible. The blocked/muted user is unaffected and is not told.
|
||||
|
||||
- **One table** `user_relations(uid, user_uid, target_uid, kind, created_at, deleted_at, deleted_by)` in `SOFT_DELETE_TABLES`; `user_uid` is the actor/owner, `kind` is `"block"` or `"mute"`. Indexes `idx_user_relations_owner_kind` (`user_uid, kind`) and `idx_user_relations_target_kind` (`target_uid, kind`, backs the DM-send reverse check), ensured in `init_db`.
|
||||
- **One cached accessor** `database.get_user_relations(viewer_uid) -> {"block": frozenset, "mute": frozenset}` (single query, both kinds), cache-invalidated under the `"relations"` cache-version name; anonymous viewer returns empty frozensets with **zero queries**. Derived helpers `get_blocked_uids`, `get_muted_uids`, `get_silenced_uids` (block | mute) and `invalidate_user_relations(uid)` (local pop + `bump_cache_version("relations")`). This is read on every content listing, so it must stay cache-hot - never query `user_relations` directly in a read path.
|
||||
- **Filtering choke points (DRY, do not re-implement inline):** `paginate`/`paginate_diverse` accept a `viewer_uid` kwarg that appends `user_uid NOT IN (blocked)` only when the table has `user_uid` and the block set is non-empty (wired in `feed`/`gists`/`projects` listings; safe no-op for bookmarks/news); the three comment loaders drop blocked authors via `database._drop_blocked(raw, user)` before building trees; `content.load_detail` returns `None` (404) for a blocked author; the landing query and the issues list filter by the block set; messages filter the conversation list/thread and `persist_message` rejects a DM when the recipient blocked the sender (returns `None`, already handled).
|
||||
- **Notification suppression is one line** at the single funnel `utils._deliver_notification`: `if related_uid and related_uid in get_silenced_uids(user_uid): return`. This covers follows, mentions, DMs, votes, reactions at once (block silences too, hence `get_silenced_uids`). A non-user `related_uid` is harmless (uuids never collide).
|
||||
- **Routes** mirror `follow.py`: `POST /block/{username}`, `POST /block/unblock/{username}`, `POST /mute/{username}`, `POST /mute/unmute/{username}` (born-live insert / soft-delete revive, `invalidate_user_relations`, best-effort audit `relation.block|unblock|mute|unmute`, `action_result`). Mounted with no prefix.
|
||||
- **Fan-out:** profile route passes `is_blocked`/`is_muted` (named to avoid the `is_self` Jinja-global collision rule) on the `respond` context + `ProfileOut`; `profile.html` renders Block/Unblock + Mute/Unmute POST forms next to Follow using the existing `data-confirm` (+`data-confirm-danger`) pattern - **no new JS**. Devii actions `block_user`/`unblock_user`/`mute_user`/`unmute_user`; docs in `docs_api.py`; audit prefix `relation` -> category `social`.
|
||||
|
||||
### CSS
|
||||
- Reactions, bookmarks, polls and the saved page live in `static/css/engagement.css`, loaded globally in `base.html` (the controls appear across feed, detail pages and comments). Heatmap styles are in `profile.css`. Follow-list rows (`.follow-row`, `.follow-user`, `.follow-pagination`) are in `profile.css`.
|
||||
|
||||
## Profile activity tab (clickable comments)
|
||||
|
||||
The profile **Activity** tab (`/profile/{username}?tab=activity`, public) interleaves the user's 10 most recent posts and 10 most recent comments, newest first. Each activity item is a plain dict built inline in `routers/profile/index.py` (no dedicated DB helper) and carries a `url` so the whole card is clickable, exactly like the feed's post cards:
|
||||
|
||||
- **Post items** get `"url": resolve_object_url("post", p["uid"])` -> `/posts/{slug}`.
|
||||
- **Comment items** get `"url": f"{resolve_object_url(target_type, target_uid)}#comment-{c['uid']}"`, the SEO-friendly parent detail URL plus the `#comment-{uid}` anchor. This is **byte-identical to a comment notification's `target_url`** (`content.create_comment_record`), so clicking an activity comment reproduces the notification click exactly: it lands on the parent post and `NotificationManager.js` scrolls to / highlights the `id="comment-{uid}"` element (`_comment.html`). Reuse `resolve_object_url` (`database.py`) - never rebuild this URL by hand, and do not call `resolve_object_url("comment", uid)` here (it re-fetches the row the loop already has).
|
||||
- **Frontend:** the card is wrapped in the shared full-card overlay link (`card-link-host` + `_card_link.html`), the same pattern `_post_card.html` uses; inner content links (mentions, URLs rendered by `render_content`) stay clickable via the existing `card-link-host a:not(.card-link)` z-index rule (`base.css`). No new CSS.
|
||||
- **API:** `ProfileOut.activities` is `list[Any]`, so the added `url` (and, on comments, the existing `target_type` + `uid` = parent target uid) flow into the JSON response with no schema change - this is the parent-post reference exposed on each comment item.
|
||||
@@ -1,12 +1,15 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from devplacepy.routers.admin import (
|
||||
awards,
|
||||
aiquota,
|
||||
aiusage,
|
||||
auditlog,
|
||||
backups,
|
||||
bots,
|
||||
containers,
|
||||
devii_tasks,
|
||||
game,
|
||||
gateway_configs,
|
||||
issues,
|
||||
media,
|
||||
@@ -14,13 +17,16 @@ from devplacepy.routers.admin import (
|
||||
notifications,
|
||||
services,
|
||||
settings,
|
||||
statistics,
|
||||
trash,
|
||||
users,
|
||||
)
|
||||
from devplacepy.routers.admin.index import router
|
||||
|
||||
router.include_router(awards.router)
|
||||
router.include_router(users.router)
|
||||
router.include_router(aiusage.router)
|
||||
router.include_router(statistics.router)
|
||||
router.include_router(aiquota.router)
|
||||
router.include_router(media.router)
|
||||
router.include_router(trash.router)
|
||||
@@ -32,5 +38,7 @@ router.include_router(auditlog.router)
|
||||
router.include_router(backups.router)
|
||||
router.include_router(bots.router)
|
||||
router.include_router(gateway_configs.router)
|
||||
router.include_router(devii_tasks.router)
|
||||
router.include_router(game.router)
|
||||
router.include_router(services.router, prefix="/services")
|
||||
router.include_router(containers.router, prefix="/containers")
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from devplacepy.database import get_table
|
||||
from devplacepy.database.awards import revoke_award
|
||||
from devplacepy.responses import action_result, json_error, wants_json
|
||||
from devplacepy.services.audit import record as audit
|
||||
from devplacepy.utils import not_found, require_admin, safe_next
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _redirect_back(request: Request, award: dict) -> str:
|
||||
referer = request.headers.get("referer", "")
|
||||
if referer and safe_next(referer, "") == referer:
|
||||
return referer
|
||||
receiver = get_table("users").find_one(uid=award.get("receiver_uid", ""))
|
||||
if receiver:
|
||||
return f"/profile/{receiver['username']}?tab=awards"
|
||||
return "/admin"
|
||||
|
||||
|
||||
@router.post("/awards/{uid}/revoke")
|
||||
async def admin_revoke_award(request: Request, uid: str):
|
||||
admin = require_admin(request)
|
||||
row = revoke_award(uid, admin["uid"])
|
||||
if not row:
|
||||
if wants_json(request):
|
||||
return json_error(404, "Award not found")
|
||||
raise not_found("Award not found")
|
||||
receiver = get_table("users").find_one(uid=row.get("receiver_uid", ""))
|
||||
logger.info("Admin %s revoked award %s", admin["username"], uid)
|
||||
audit.record(
|
||||
request,
|
||||
"award.revoke",
|
||||
user=admin,
|
||||
target_type="award",
|
||||
target_uid=uid,
|
||||
target_label=row.get("slug", uid),
|
||||
summary=f"admin {admin['username']} revoked award {row.get('slug', uid)}",
|
||||
links=[
|
||||
audit.target("award", uid, row.get("slug")),
|
||||
audit.target("user", row.get("receiver_uid"), receiver.get("username") if receiver else None),
|
||||
],
|
||||
)
|
||||
return action_result(request, _redirect_back(request, row))
|
||||
@@ -3,7 +3,7 @@
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends, APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||
|
||||
from devplacepy.database import (
|
||||
db,
|
||||
@@ -12,7 +12,11 @@ from devplacepy.database import (
|
||||
resolve_by_slug,
|
||||
search_users_by_username,
|
||||
)
|
||||
from devplacepy.content import can_view_project
|
||||
from devplacepy.content import (
|
||||
can_manage_instance,
|
||||
can_view_instance,
|
||||
can_view_project_containers,
|
||||
)
|
||||
from devplacepy.models import ContainerAdminCreateForm, ContainerEditForm
|
||||
from devplacepy.responses import action_result, json_error, respond
|
||||
from devplacepy.schemas import (
|
||||
@@ -44,11 +48,18 @@ def _decorate(instances: list, viewer: dict | None = None) -> list:
|
||||
decorated = []
|
||||
for inst in instances:
|
||||
project = index.get(inst["project_uid"], {})
|
||||
if viewer is not None and project and not can_view_project(project, viewer):
|
||||
continue
|
||||
if viewer is None:
|
||||
if not project or project.get("is_private"):
|
||||
continue
|
||||
manageable = False
|
||||
else:
|
||||
if not can_view_instance(inst, project or None, viewer):
|
||||
continue
|
||||
manageable = can_manage_instance(inst, project or None, viewer)
|
||||
row = dict(inst)
|
||||
row["project_title"] = project.get("title", "")
|
||||
row["project_slug"] = project.get("slug") or project.get("uid") or ""
|
||||
row["can_manage"] = manageable
|
||||
decorated.append(row)
|
||||
decorated.sort(key=lambda r: r.get("created_at", ""), reverse=True)
|
||||
return decorated
|
||||
@@ -65,11 +76,28 @@ def _project_of(inst: dict) -> dict:
|
||||
def _viewable_instance_or_404(uid: str, viewer: dict) -> dict:
|
||||
inst = _instance_or_404(uid)
|
||||
project = _project_of(inst)
|
||||
if project and not can_view_project(project, viewer):
|
||||
if not can_view_instance(inst, project or None, viewer):
|
||||
raise not_found("Instance not found")
|
||||
return inst
|
||||
|
||||
def _audit_admin(request: Request, admin: dict, event_key: str, inst: dict, summary: str, metadata=None) -> None:
|
||||
|
||||
def _manage_denied(
|
||||
request: Request, admin: dict, inst: dict, event_key: str
|
||||
) -> JSONResponse | None:
|
||||
project = _project_of(inst)
|
||||
if can_manage_instance(inst, project or None, admin):
|
||||
return None
|
||||
_audit_admin(
|
||||
request,
|
||||
admin,
|
||||
event_key,
|
||||
inst,
|
||||
f"admin {admin['username']} denied {event_key} on instance {inst.get('name')}",
|
||||
result="denied",
|
||||
)
|
||||
return json_error(403, "Only the container owner or the primary administrator can manage this instance")
|
||||
|
||||
def _audit_admin(request: Request, admin: dict, event_key: str, inst: dict, summary: str, metadata=None, result: str = "success") -> None:
|
||||
audit.record(
|
||||
request,
|
||||
event_key,
|
||||
@@ -80,6 +108,7 @@ def _audit_admin(request: Request, admin: dict, event_key: str, inst: dict, summ
|
||||
metadata=metadata,
|
||||
summary=summary,
|
||||
links=[audit.instance(inst["uid"], inst.get("name"))],
|
||||
result=result,
|
||||
)
|
||||
|
||||
@router.get("", response_class=HTMLResponse)
|
||||
@@ -133,7 +162,7 @@ async def project_search(request: Request, q: str = ""):
|
||||
results = [
|
||||
{"uid": r["uid"], "slug": r["slug"] or r["uid"], "title": r["title"]}
|
||||
for r in rows
|
||||
if can_view_project(r, admin)
|
||||
if can_view_project_containers(r, admin)
|
||||
][:10]
|
||||
return JSONResponse({"results": results})
|
||||
|
||||
@@ -150,7 +179,7 @@ async def container_create(
|
||||
):
|
||||
admin = require_admin(request)
|
||||
project = resolve_by_slug(get_table("projects"), data.project_slug)
|
||||
if not project or not can_view_project(project, admin):
|
||||
if not project or not can_view_project_containers(project, admin):
|
||||
return json_error(404, "project not found")
|
||||
try:
|
||||
inst = await api.create_instance(
|
||||
@@ -194,6 +223,8 @@ async def container_edit_page(request: Request, uid: str):
|
||||
admin = require_admin(request)
|
||||
inst = _viewable_instance_or_404(uid, admin)
|
||||
project = _project_of(inst)
|
||||
if not can_manage_instance(inst, project or None, admin):
|
||||
return RedirectResponse(url=f"/admin/containers/{uid}", status_code=302)
|
||||
run_as_user = None
|
||||
if inst.get("run_as_uid"):
|
||||
run_as_user = get_users_by_uids([inst["run_as_uid"]]).get(inst["run_as_uid"])
|
||||
@@ -235,6 +266,9 @@ async def container_edit(
|
||||
):
|
||||
admin = require_admin(request)
|
||||
inst = _viewable_instance_or_404(uid, admin)
|
||||
denied = _manage_denied(request, admin, inst, "container.instance.configure")
|
||||
if denied:
|
||||
return denied
|
||||
try:
|
||||
updated = api.update_instance_config(
|
||||
inst,
|
||||
@@ -282,8 +316,11 @@ _ACTIONS = {
|
||||
async def container_action(request: Request, uid: str):
|
||||
admin = require_admin(request)
|
||||
inst = _viewable_instance_or_404(uid, admin)
|
||||
actor = ("user", admin["uid"])
|
||||
action = request.url.path.rsplit("/", 1)[-1]
|
||||
denied = _manage_denied(request, admin, inst, f"container.instance.{action}")
|
||||
if denied:
|
||||
return denied
|
||||
actor = ("user", admin["uid"])
|
||||
if action == "restart":
|
||||
api.request_restart(inst, actor=actor)
|
||||
else:
|
||||
@@ -303,6 +340,9 @@ async def container_action(request: Request, uid: str):
|
||||
async def container_sync(request: Request, uid: str):
|
||||
admin = require_admin(request)
|
||||
inst = _viewable_instance_or_404(uid, admin)
|
||||
denied = _manage_denied(request, admin, inst, "container.instance.sync")
|
||||
if denied:
|
||||
return denied
|
||||
try:
|
||||
counts = await api.sync_workspace(inst, admin)
|
||||
except ContainerError as exc:
|
||||
@@ -321,6 +361,9 @@ async def container_sync(request: Request, uid: str):
|
||||
async def container_delete(request: Request, uid: str):
|
||||
admin = require_admin(request)
|
||||
inst = _viewable_instance_or_404(uid, admin)
|
||||
denied = _manage_denied(request, admin, inst, "container.instance.delete")
|
||||
if denied:
|
||||
return denied
|
||||
api.mark_for_removal(inst, actor=("user", admin["uid"]))
|
||||
_audit_admin(
|
||||
request,
|
||||
@@ -337,6 +380,7 @@ async def container_instance_page(request: Request, uid: str):
|
||||
inst = _viewable_instance_or_404(uid, admin)
|
||||
project = _project_of(inst)
|
||||
project_slug = project.get("slug") or project.get("uid") or ""
|
||||
can_manage = can_manage_instance(inst, project or None, admin)
|
||||
base = site_url(request)
|
||||
seo_ctx = base_seo_context(
|
||||
request,
|
||||
@@ -366,6 +410,7 @@ async def container_instance_page(request: Request, uid: str):
|
||||
"schedules": store.list_schedules(inst["uid"]),
|
||||
"stats": api.instance_stats(inst["uid"]),
|
||||
"runtime": api.instance_runtime(inst),
|
||||
"can_manage": can_manage,
|
||||
"admin_section": "containers",
|
||||
},
|
||||
model=AdminContainerInstanceOut,
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from devplacepy.database import db, get_admin_uids, get_int_setting, get_users_by_uids
|
||||
from devplacepy.responses import action_result, respond
|
||||
from devplacepy.schemas import AdminDeviiTasksOut
|
||||
from devplacepy.seo import base_seo_context, site_url, website_schema
|
||||
from devplacepy.services.audit import record as audit
|
||||
from devplacepy.services.devii import config as devii_config
|
||||
from devplacepy.services.devii.tasks import limits
|
||||
from devplacepy.services.devii.tasks.guards import DEFAULT_MAX_PER_OWNER
|
||||
from devplacepy.services.devii.tasks.schedule import now_utc
|
||||
from devplacepy.services.devii.tasks.store import TABLE, TaskStore
|
||||
from devplacepy.utils import not_found, require_admin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
STATES = ("active", "inactive", "all")
|
||||
|
||||
|
||||
def _schedule_text(row: dict) -> str:
|
||||
kind = row.get("kind") or ""
|
||||
if kind == "interval":
|
||||
return f"every {row.get('every_seconds')}s"
|
||||
if kind == "cron":
|
||||
return f"cron {row.get('cron')}"
|
||||
return f"once {row.get('run_at') or ''}".strip()
|
||||
|
||||
|
||||
def _rows(state: str) -> list[dict]:
|
||||
if TABLE not in db.tables:
|
||||
return []
|
||||
criteria: dict = {"deleted_at": None}
|
||||
if state == "active":
|
||||
criteria["enabled"] = True
|
||||
elif state == "inactive":
|
||||
criteria["enabled"] = False
|
||||
rows = list(db[TABLE].find(**criteria))
|
||||
rows.sort(key=lambda row: int(row.get("run_count") or 0), reverse=True)
|
||||
return rows
|
||||
|
||||
|
||||
def _quotas(owner_uids: set[str]) -> dict[str, dict]:
|
||||
reference = now_utc()
|
||||
quotas = {}
|
||||
for owner_uid in owner_uids:
|
||||
runs = limits.run_quota(db, "user", owner_uid, reference)
|
||||
creations = limits.create_quota(db, "user", owner_uid, reference)
|
||||
quotas[owner_uid] = {
|
||||
"runs_used": runs.used,
|
||||
"runs_limit": runs.limit,
|
||||
"creates_used": creations.used,
|
||||
"creates_limit": creations.limit,
|
||||
}
|
||||
return quotas
|
||||
|
||||
|
||||
def _items(rows: list[dict]) -> list[dict]:
|
||||
owners = get_users_by_uids([row.get("owner_id") for row in rows if row.get("owner_id")])
|
||||
admins = get_admin_uids()
|
||||
quotas = _quotas({str(row.get("owner_id") or "") for row in rows if row.get("owner_id")})
|
||||
items = []
|
||||
for row in rows:
|
||||
owner_uid = row.get("owner_id") or ""
|
||||
owner = owners.get(owner_uid)
|
||||
items.append(
|
||||
{
|
||||
"uid": row.get("uid"),
|
||||
"label": row.get("label") or row.get("uid"),
|
||||
"owner_uid": owner_uid,
|
||||
"owner": owner["username"] if owner else owner_uid,
|
||||
"owner_is_admin": owner_uid in admins,
|
||||
"quota": quotas.get(owner_uid, {}),
|
||||
"schedule": _schedule_text(row),
|
||||
"status": row.get("status") or "",
|
||||
"enabled": bool(row.get("enabled")),
|
||||
"run_count": int(row.get("run_count") or 0),
|
||||
"max_runs": row.get("max_runs"),
|
||||
"failure_count": int(row.get("failure_count") or 0),
|
||||
"next_run_at": row.get("next_run_at"),
|
||||
"expires_at": row.get("expires_at"),
|
||||
"last_error": row.get("last_error"),
|
||||
}
|
||||
)
|
||||
return items
|
||||
|
||||
|
||||
def _require_task(uid: str) -> dict:
|
||||
row = db[TABLE].find_one(uid=uid, deleted_at=None) if TABLE in db.tables else None
|
||||
if row is None:
|
||||
raise not_found("Unknown task")
|
||||
return row
|
||||
|
||||
|
||||
@router.get("/devii-tasks", response_class=HTMLResponse)
|
||||
async def admin_devii_tasks(request: Request, state: str = "active"):
|
||||
admin = require_admin(request)
|
||||
if state not in STATES:
|
||||
state = "active"
|
||||
rows = _rows(state)
|
||||
items = _items(rows)
|
||||
bounds = {
|
||||
"max_concurrent": get_int_setting(
|
||||
devii_config.FIELD_TASK_MAX_CONCURRENT,
|
||||
devii_config.DEFAULT_TASK_MAX_CONCURRENT,
|
||||
),
|
||||
"max_per_owner": get_int_setting(
|
||||
devii_config.FIELD_TASK_MAX_PER_OWNER, DEFAULT_MAX_PER_OWNER
|
||||
),
|
||||
"max_failures": get_int_setting(
|
||||
devii_config.FIELD_TASK_MAX_FAILURES,
|
||||
devii_config.DEFAULT_TASK_MAX_FAILURES,
|
||||
),
|
||||
"idle_days": get_int_setting(
|
||||
devii_config.FIELD_TASK_IDLE_DAYS, devii_config.DEFAULT_TASK_IDLE_DAYS
|
||||
),
|
||||
"member_create_24h": limits.create_limit(False),
|
||||
"member_runs_24h": limits.run_limit(False),
|
||||
"admin_create_24h": limits.create_limit(True),
|
||||
"admin_runs_24h": limits.run_limit(True),
|
||||
}
|
||||
tabs = [
|
||||
{"key": key, "label": key.capitalize(), "active": key == state}
|
||||
for key in STATES
|
||||
]
|
||||
base = site_url(request)
|
||||
seo_ctx = base_seo_context(
|
||||
request,
|
||||
title="Devii tasks - Admin",
|
||||
description="Every scheduled Devii task, its owner, and its bounds.",
|
||||
breadcrumbs=[
|
||||
{"name": "Home", "url": "/feed"},
|
||||
{"name": "Admin", "url": "/admin"},
|
||||
{"name": "Devii tasks", "url": "/admin/devii-tasks"},
|
||||
],
|
||||
schemas=[website_schema(base)],
|
||||
)
|
||||
return respond(
|
||||
request,
|
||||
"admin_devii_tasks.html",
|
||||
{
|
||||
**seo_ctx,
|
||||
"request": request,
|
||||
"user": admin,
|
||||
"items": items,
|
||||
"state": state,
|
||||
"tabs": tabs,
|
||||
"limits": bounds,
|
||||
"admin_section": "devii-tasks",
|
||||
},
|
||||
model=AdminDeviiTasksOut,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/devii-tasks/{uid}/disable")
|
||||
async def admin_devii_task_disable(request: Request, uid: str):
|
||||
admin = require_admin(request)
|
||||
row = _require_task(uid)
|
||||
store = TaskStore(db, str(row.get("owner_kind") or "user"), str(row.get("owner_id") or ""))
|
||||
store.update(
|
||||
uid,
|
||||
{
|
||||
"enabled": False,
|
||||
"status": "disabled",
|
||||
"next_run_at": None,
|
||||
"last_error": f"disabled by {admin['username']}",
|
||||
},
|
||||
)
|
||||
logger.info(f"Admin {admin['username']} disabled Devii task {uid}")
|
||||
audit.record(
|
||||
request,
|
||||
"admin.devii_task.disable",
|
||||
user=admin,
|
||||
target_type="task",
|
||||
target_uid=uid,
|
||||
target_label=row.get("label"),
|
||||
metadata={"owner_id": row.get("owner_id"), "run_count": row.get("run_count")},
|
||||
summary=f"{admin['username']} disabled Devii task {uid}",
|
||||
)
|
||||
return action_result(request, "/admin/devii-tasks")
|
||||
|
||||
|
||||
@router.post("/devii-tasks/{uid}/delete")
|
||||
async def admin_devii_task_delete(request: Request, uid: str):
|
||||
admin = require_admin(request)
|
||||
row = _require_task(uid)
|
||||
store = TaskStore(db, str(row.get("owner_kind") or "user"), str(row.get("owner_id") or ""))
|
||||
store.delete(uid)
|
||||
logger.info(f"Admin {admin['username']} deleted Devii task {uid}")
|
||||
audit.record(
|
||||
request,
|
||||
"admin.devii_task.delete",
|
||||
user=admin,
|
||||
target_type="task",
|
||||
target_uid=uid,
|
||||
target_label=row.get("label"),
|
||||
metadata={"owner_id": row.get("owner_id"), "run_count": row.get("run_count")},
|
||||
summary=f"{admin['username']} deleted Devii task {uid}",
|
||||
)
|
||||
return action_result(request, "/admin/devii-tasks")
|
||||
@@ -0,0 +1,97 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
from typing import Annotated
|
||||
from fastapi import APIRouter, Form, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from devplacepy.models import GameEraStartForm
|
||||
from devplacepy.responses import respond, action_result, json_error, wants_json
|
||||
from devplacepy.schemas import AdminGameOut
|
||||
from devplacepy.seo import base_seo_context, site_url, website_schema
|
||||
from devplacepy.services.audit import record as audit
|
||||
from devplacepy.services.game import GameError, store
|
||||
from devplacepy.utils import require_admin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _era_context() -> dict:
|
||||
era = store.active_era()
|
||||
return {
|
||||
"era_active": bool(era),
|
||||
"era_name": era["name"] if era else "",
|
||||
"era_number": int(era["era_number"]) if era else 0,
|
||||
"era_started_at": era["started_at"] if era else "",
|
||||
"era_ends_at": era["ends_at"] if era else "",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/game", response_class=HTMLResponse)
|
||||
async def admin_game(request: Request):
|
||||
admin = require_admin(request)
|
||||
base = site_url(request)
|
||||
seo_ctx = base_seo_context(
|
||||
request,
|
||||
title="Code Farm - Admin",
|
||||
description="Manage Code Farm Eras.",
|
||||
robots="noindex,nofollow",
|
||||
breadcrumbs=[
|
||||
{"name": "Home", "url": "/feed"},
|
||||
{"name": "Admin", "url": "/admin"},
|
||||
{"name": "Code Farm", "url": "/admin/game"},
|
||||
],
|
||||
schemas=[website_schema(base)],
|
||||
)
|
||||
return respond(
|
||||
request,
|
||||
"admin_game.html",
|
||||
{
|
||||
**seo_ctx,
|
||||
"request": request,
|
||||
"user": admin,
|
||||
"admin_section": "game",
|
||||
**_era_context(),
|
||||
},
|
||||
model=AdminGameOut,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/game/era/start")
|
||||
async def admin_game_era_start(request: Request, data: Annotated[GameEraStartForm, Form()]):
|
||||
admin = require_admin(request)
|
||||
try:
|
||||
era = store.start_era(data.name, data.duration_days)
|
||||
except GameError as exc:
|
||||
logger.warning(f"Admin {admin['username']} failed to start Era: {exc}")
|
||||
if wants_json(request):
|
||||
return json_error(400, str(exc))
|
||||
return action_result(request, "/admin/game")
|
||||
audit.record(
|
||||
request,
|
||||
"admin.game.era_start",
|
||||
user=admin,
|
||||
metadata={"era_number": era["era_number"], "name": era["name"]},
|
||||
summary=f"admin {admin['username']} started Era {era['name']}",
|
||||
)
|
||||
return action_result(request, "/admin/game")
|
||||
|
||||
|
||||
@router.post("/game/era/end")
|
||||
async def admin_game_era_end(request: Request):
|
||||
admin = require_admin(request)
|
||||
try:
|
||||
result = store.end_era()
|
||||
except GameError as exc:
|
||||
logger.warning(f"Admin {admin['username']} failed to end Era: {exc}")
|
||||
if wants_json(request):
|
||||
return json_error(400, str(exc))
|
||||
return action_result(request, "/admin/game")
|
||||
audit.record(
|
||||
request,
|
||||
"admin.game.era_end",
|
||||
user=admin,
|
||||
metadata=result,
|
||||
summary=f"admin {admin['username']} ended Era {result['era_number']}",
|
||||
)
|
||||
return action_result(request, "/admin/game")
|
||||
@@ -9,7 +9,7 @@ from pydantic import ValidationError
|
||||
from devplacepy.seo import base_seo_context, site_url, website_schema
|
||||
from devplacepy.services.audit import record as audit
|
||||
from devplacepy.services.manager import service_manager
|
||||
from devplacepy.services.openai_gateway import routing
|
||||
from devplacepy.services.openai_gateway import quota, routing
|
||||
from devplacepy.templating import templates
|
||||
from devplacepy.utils import require_admin
|
||||
|
||||
@@ -25,6 +25,8 @@ def _default_provider_summary() -> dict:
|
||||
"model": cfg.get("gateway_model", ""),
|
||||
"embed_url": cfg.get("gateway_embed_url", ""),
|
||||
"embed_model": cfg.get("gateway_embed_model", ""),
|
||||
"image_url": cfg.get("gateway_image_url", ""),
|
||||
"image_model": cfg.get("gateway_image_model", ""),
|
||||
"vision_url": cfg.get("gateway_vision_url", ""),
|
||||
"vision_model": cfg.get("gateway_vision_model", ""),
|
||||
}
|
||||
@@ -180,3 +182,92 @@ async def delete_model(request: Request, source_model: str):
|
||||
summary=f"admin {admin['username']} deleted gateway model route {source_model}",
|
||||
)
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
def _quota_defaults_summary() -> dict:
|
||||
svc = service_manager.get_service("openai")
|
||||
cfg = svc.get_config() if svc is not None else {}
|
||||
return {
|
||||
"user": cfg.get(quota.FIELD_DEFAULT_USER, 0.0),
|
||||
"admin": cfg.get(quota.FIELD_DEFAULT_ADMIN, 0.0),
|
||||
"guest": cfg.get(quota.FIELD_DEFAULT_GUEST, 0.0),
|
||||
"internal": cfg.get(quota.FIELD_DEFAULT_INTERNAL, 0.0),
|
||||
"key": cfg.get(quota.FIELD_DEFAULT_KEY, 0.0),
|
||||
}
|
||||
|
||||
|
||||
def _rule_label(rule: dict) -> str:
|
||||
parts = []
|
||||
if rule.get("owner_kind"):
|
||||
parts.append(f"role={rule['owner_kind']}")
|
||||
if rule.get("owner_id"):
|
||||
parts.append(f"user={rule['owner_id']}")
|
||||
if rule.get("app_reference"):
|
||||
parts.append(f"app={rule['app_reference']}")
|
||||
return ", ".join(parts) or rule.get("uid", "")
|
||||
|
||||
|
||||
@router.get("/gateway/quota-rules")
|
||||
async def list_quota_rules(request: Request):
|
||||
require_admin(request)
|
||||
rules = quota.quota_rule_store.list()
|
||||
for rule in rules:
|
||||
rule["spent_24h_usd"] = round(
|
||||
quota.spent_24h(rule["owner_kind"], rule["owner_id"], rule["app_reference"]), 6
|
||||
)
|
||||
return JSONResponse(
|
||||
{
|
||||
"rules": rules,
|
||||
"count": len(rules),
|
||||
"defaults": _quota_defaults_summary(),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.post("/gateway/quota-rules")
|
||||
async def save_quota_rule(request: Request):
|
||||
admin = require_admin(request)
|
||||
body = await _payload(request)
|
||||
uid = str(body.pop("uid", "") or "").strip() or None
|
||||
try:
|
||||
payload = quota.QuotaRuleIn(**body)
|
||||
except ValidationError as exc:
|
||||
return _validation_error(exc)
|
||||
saved = quota.quota_rule_store.set(payload, uid=uid, created_by=admin["uid"])
|
||||
audit.record(
|
||||
request,
|
||||
"gateway.quota_rule.update",
|
||||
user=admin,
|
||||
target_type="gateway_quota_rule",
|
||||
target_uid=saved["uid"],
|
||||
target_label=_rule_label(saved),
|
||||
summary=f"admin {admin['username']} saved gateway quota rule ({_rule_label(saved)}) at ${saved['limit_usd']}/24h",
|
||||
metadata={
|
||||
"owner_kind": saved["owner_kind"],
|
||||
"owner_id": saved["owner_id"],
|
||||
"app_reference": saved["app_reference"],
|
||||
"limit_usd": saved["limit_usd"],
|
||||
"is_active": saved["is_active"],
|
||||
},
|
||||
)
|
||||
return JSONResponse({"ok": True, "rule": saved})
|
||||
|
||||
|
||||
@router.delete("/gateway/quota-rules/{uid}")
|
||||
async def delete_quota_rule(request: Request, uid: str):
|
||||
admin = require_admin(request)
|
||||
existing = quota.quota_rule_store.get(uid)
|
||||
label = _rule_label(existing.as_dict()) if existing else uid
|
||||
existed = quota.quota_rule_store.remove(uid)
|
||||
if not existed:
|
||||
return JSONResponse({"ok": False, "error": "Quota rule not found"}, status_code=404)
|
||||
audit.record(
|
||||
request,
|
||||
"gateway.quota_rule.delete",
|
||||
user=admin,
|
||||
target_type="gateway_quota_rule",
|
||||
target_uid=uid,
|
||||
target_label=label,
|
||||
summary=f"admin {admin['username']} deleted gateway quota rule ({label})",
|
||||
)
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from devplacepy.responses import respond
|
||||
from devplacepy.schemas.statistics import StatisticsOut
|
||||
from devplacepy.seo import base_seo_context, site_url, website_schema
|
||||
from devplacepy.services.statistics.build import build_statistics_tab
|
||||
from devplacepy.services.statistics.common import VALID_TABS
|
||||
from devplacepy.utils import require_admin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
TAB_LABELS = (
|
||||
("overview", "Overview", "\U0001f4ca"),
|
||||
("visitors", "Visitors", "\U0001f441\ufe0f"),
|
||||
("members", "Members", "\U0001f465"),
|
||||
("content", "Content", "\U0001f4dd"),
|
||||
("engagement", "Engagement", "\U0001f525"),
|
||||
("social", "Social", "\U0001f91d"),
|
||||
("ai", "AI", "\U0001f916"),
|
||||
("devii", "Devii", "\u2728"),
|
||||
("services", "Services", "\u2699\ufe0f"),
|
||||
("containers", "Containers", "\U0001f4e6"),
|
||||
("game", "Game", "\U0001f3ae"),
|
||||
("awards", "Awards", "\U0001f3c6"),
|
||||
("moderation", "Moderation", "\U0001f6e1\ufe0f"),
|
||||
("tools", "Tools", "\U0001f527"),
|
||||
("storage", "Storage", "\U0001f4be"),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/statistics", response_class=HTMLResponse)
|
||||
async def admin_statistics(request: Request, tab: str = "overview", hours: int = 168):
|
||||
admin = require_admin(request)
|
||||
active = tab if tab in VALID_TABS else "overview"
|
||||
base = site_url(request)
|
||||
seo_ctx = base_seo_context(
|
||||
request,
|
||||
title="Statistics - Admin",
|
||||
description="Platform statistics with trends, visitors, content, engagement, and operations.",
|
||||
robots="noindex,nofollow",
|
||||
breadcrumbs=[
|
||||
{"name": "Home", "url": "/feed"},
|
||||
{"name": "Admin", "url": "/admin"},
|
||||
{"name": "Statistics", "url": "/admin/statistics"},
|
||||
],
|
||||
schemas=[website_schema(base)],
|
||||
)
|
||||
initial = build_statistics_tab(active, hours, compare=True, top_n=10)
|
||||
tabs = [
|
||||
{"key": key, "label": label, "icon": icon, "active": key == active}
|
||||
for key, label, icon in TAB_LABELS
|
||||
]
|
||||
return respond(
|
||||
request,
|
||||
"admin_statistics.html",
|
||||
{
|
||||
**seo_ctx,
|
||||
"request": request,
|
||||
"user": admin,
|
||||
"admin_section": "statistics",
|
||||
"tabs": tabs,
|
||||
"active_tab": active,
|
||||
"window_hours": hours,
|
||||
"initial": initial,
|
||||
},
|
||||
model=StatisticsOut,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/statistics/data")
|
||||
async def admin_statistics_data(
|
||||
request: Request,
|
||||
tab: str = "overview",
|
||||
hours: int = 168,
|
||||
compare: int = 1,
|
||||
top_n: int = 10,
|
||||
):
|
||||
require_admin(request)
|
||||
return JSONResponse(
|
||||
build_statistics_tab(
|
||||
tab,
|
||||
hours,
|
||||
compare=bool(compare),
|
||||
top_n=top_n,
|
||||
)
|
||||
)
|
||||
@@ -24,13 +24,15 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
TRASH_TABLES = [
|
||||
{"key": "posts", "label": "Posts", "type": "post"},
|
||||
{"key": "comments", "label": "Comments", "type": "comment"},
|
||||
{"key": "gists", "label": "Gists", "type": "gist"},
|
||||
{"key": "projects", "label": "Projects", "type": "project"},
|
||||
{"key": "news", "label": "News", "type": "news"},
|
||||
{"key": "project_files", "label": "Project files", "type": None},
|
||||
{"key": "attachments", "label": "Attachments", "type": None},
|
||||
{"key": "posts", "label": "Posts", "icon": "\U0001f4dd", "type": "post"},
|
||||
{"key": "comments", "label": "Comments", "icon": "\U0001f4ac", "type": "comment"},
|
||||
{"key": "gists", "label": "Gists", "icon": "\U0001f4cb", "type": "gist"},
|
||||
{"key": "projects", "label": "Projects", "icon": "\U0001f680", "type": "project"},
|
||||
{"key": "news", "label": "News", "icon": "\U0001f4f0", "type": "news"},
|
||||
{"key": "awards", "label": "Awards", "icon": "\U0001f3c6", "type": "award"},
|
||||
{"key": "quizzes", "label": "Quizzes", "icon": "\U0001f9e9", "type": "quiz"},
|
||||
{"key": "project_files", "label": "Project files", "icon": "\U0001f4c1", "type": None},
|
||||
{"key": "attachments", "label": "Attachments", "icon": "\U0001f4ce", "type": None},
|
||||
]
|
||||
_TRASH_KEYS = {entry["key"] for entry in TRASH_TABLES}
|
||||
_TRASH_TYPE = {entry["key"]: entry["type"] for entry in TRASH_TABLES}
|
||||
@@ -113,6 +115,10 @@ async def admin_trash_restore(request: Request, table: str, uid: str):
|
||||
row = get_table(table).find_one(uid=uid)
|
||||
if row and row.get("deleted_at"):
|
||||
restored = restore_event(row["deleted_at"])
|
||||
if table == "awards":
|
||||
from devplacepy.database.awards import recompute_user_award_stats
|
||||
|
||||
recompute_user_award_stats(row.get("receiver_uid", ""))
|
||||
logger.info(
|
||||
f"Admin {admin['username']} restored {table} {uid} ({restored} rows)"
|
||||
)
|
||||
|
||||
@@ -17,15 +17,14 @@ _CACHE_CONTROL = f"public, max-age={SECONDS_PER_DAY}, immutable"
|
||||
|
||||
@router.get("/{style}/{seed}")
|
||||
async def avatar_proxy(request: Request, style: str, seed: str, size: int = 128):
|
||||
cache_key = f"{seed}:{size}"
|
||||
etag = '"' + hashlib.md5(cache_key.encode("utf-8")).hexdigest() + '"'
|
||||
etag = '"' + hashlib.md5(f"{seed}:{size}".encode("utf-8")).hexdigest() + '"'
|
||||
headers = {"ETag": etag, "Cache-Control": _CACHE_CONTROL}
|
||||
|
||||
if request.headers.get("if-none-match") == etag:
|
||||
return Response(status_code=304, headers=headers)
|
||||
|
||||
svg = _cache.get(cache_key)
|
||||
svg = _cache.get(seed)
|
||||
if svg is None:
|
||||
svg = generate_avatar_svg(seed)
|
||||
_cache.set(cache_key, svg)
|
||||
_cache.set(seed, svg)
|
||||
return Response(content=svg, media_type="image/svg+xml", headers=headers)
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
from devplacepy.attachments import _row_to_attachment
|
||||
from devplacepy.database import get_table, resolve_by_slug
|
||||
from devplacepy.utils import not_found
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
_VALID_SIZES = {"512", "256", "64"}
|
||||
_CACHE_CONTROL = "public, max-age=86400, immutable"
|
||||
|
||||
|
||||
@router.get("/{slug_or_uid}/{size}")
|
||||
async def award_image(request: Request, slug_or_uid: str, size: str):
|
||||
if size not in _VALID_SIZES:
|
||||
raise not_found("Award image not found")
|
||||
award = resolve_by_slug(get_table("awards"), slug_or_uid)
|
||||
if not award or not award.get("generated_at"):
|
||||
raise not_found("Award image not found")
|
||||
attachment_uid = award.get(f"attachment_uid_{size}") or ""
|
||||
if not attachment_uid:
|
||||
raise not_found("Award image not found")
|
||||
row = get_table("attachments").find_one(uid=attachment_uid, deleted_at=None)
|
||||
attachment = _row_to_attachment(row) if row else None
|
||||
if not attachment:
|
||||
raise not_found("Award image not found")
|
||||
url = attachment.get("url") or ""
|
||||
if not url:
|
||||
raise not_found("Award image not found")
|
||||
headers = {
|
||||
"Cache-Control": _CACHE_CONTROL,
|
||||
"ETag": f'"{attachment_uid}"',
|
||||
}
|
||||
return RedirectResponse(url=url, status_code=302, headers=headers)
|
||||
@@ -13,7 +13,7 @@ from devplacepy.services.audit import record as audit
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
BOOKMARKABLE: set[str] = {"post", "gist", "project", "news"}
|
||||
BOOKMARKABLE: set[str] = {"post", "gist", "project", "news", "quiz"}
|
||||
|
||||
TABLE_BY_TYPE: dict[str, str] = {
|
||||
"post": "posts",
|
||||
|
||||
@@ -185,7 +185,10 @@ async def clippy_proxy(request: Request):
|
||||
return JSONResponse({"error": "Devii is unavailable"}, status_code=503)
|
||||
cfg = svc.effective_config()
|
||||
body = await request.body()
|
||||
headers = {"Content-Type": "application/json"}
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"X-App-Reference": "devplace-devii-v-1-0-0",
|
||||
}
|
||||
if cfg.get("devii_ai_key"):
|
||||
headers["Authorization"] = f"Bearer {cfg['devii_ai_key']}"
|
||||
async with stealth.stealth_async_client(timeout=45.0) as client:
|
||||
@@ -260,6 +263,8 @@ async def devii_ws(websocket: WebSocket):
|
||||
if command == "reset":
|
||||
await session.reset()
|
||||
continue
|
||||
if await session.try_answer_interaction(text):
|
||||
continue
|
||||
if svc.quota_exceeded(owner_kind, owner_id, owner_is_admin):
|
||||
limit = svc.daily_limit_for(owner_kind, owner_is_admin)
|
||||
audit.record_system(
|
||||
@@ -302,6 +307,11 @@ async def devii_ws(websocket: WebSocket):
|
||||
)
|
||||
elif kind in ("avatar_result", "client_result"):
|
||||
session.resolve_query(str(data.get("id", "")), data.get("result"))
|
||||
elif kind == "interaction_result":
|
||||
session.resolve_interaction(
|
||||
str(data.get("id", data.get("interaction_id", ""))),
|
||||
data.get("result") or data,
|
||||
)
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
except Exception: # noqa: BLE001 - never let the socket loop crash the worker
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
This file documents the devRant compatibility API mounted at `/api`. Claude Code auto-loads it whenever a file in `routers/devrant/` is read or edited.
|
||||
|
||||
## Routing overview
|
||||
|
||||
- `/api` - `devrant/` package: `devRant`-compatible REST protocol (`auth.py`, `rants.py`, `comments.py`, `notifs.py`). Translates devRant requests onto DevPlace data: rants<->posts, comments/votes onto the native engagement layer, devRant integer ids onto each table's auto-increment `id`. Token auth via `devrant_tokens`.
|
||||
|
||||
## devRant compatibility API (`routers/devrant/`, `services/devrant/`)
|
||||
|
||||
A second REST protocol mounted at `/api` that reproduces the public devRant API shape on DevPlace data, so legacy devRant clients (the `./devranta` reference client is the spec; real captured responses are in `./devranta/api_test_results.json`) run unchanged. It is a pure **translation layer** over the existing domain - it adds no new content model.
|
||||
|
||||
**Layout.** `routers/devrant/` is the thin endpoint package: `__init__.py` builds the aggregate router with `dependencies=[Depends(ensure_enabled)]` (gated by the `devrant_api_enabled` setting, default on) and includes `auth.py` (login/register/profile/edit/avatar), `rants.py` (feed, CRUD, vote, favorite, comment, search), `comments.py` (read/edit/delete/vote), `notifs.py` (feed/clear); `_shared.py` holds the `dr_ok`/`dr_error` envelope helpers + the enable gate. `services/devrant/` is the logic: `params.merge_params` (merges query string + body, accepting BOTH form-encoded and JSON since devRant clients use both, GET sends auth as query params), `tokens` (issue/validate/revoke the `devrant_tokens` triple), `ids` (`as_int`, `to_unix`, `post_by_id`/`comment_by_id`/`user_by_id`), `serializers` (`serialize_rant`/`serialize_comment`, `encode_tags`/`decode_tags`), `feed` (`list_rants`/`search_rants`/`load_rant_detail`, offset pagination via `find(_limit, _offset)`), `profile` (`build_profile`), `notifications` (`build_notif_feed`/`clear_notifications`), `avatar` (`avatar_payload` + `render_png` via `cairosvg`).
|
||||
|
||||
**ID mapping (load-bearing).** devRant integer ids ARE the auto-increment `id` PK every `dataset` table already has: `rant_id`=`posts.id`, `comment_id`=`comments.id`, `user_id`=`users.id`, `token_id`=`devrant_tokens.id`. No translation table exists - `post_by_id` is `find_one(id=...)`. Serialization converts ISO `created_at` to unix via `ids.to_unix`.
|
||||
|
||||
**Auth.** `POST /api/users/auth-token` accepts username OR email, verifies with passlib, and inserts a `devrant_tokens` row (in `SOFT_DELETE_TABLES`; born-live; `key`=`secrets.token_hex`, `expire_time` from `session_max_age_days`). Every later call re-validates `(token_id, token_key, user_id)` with `tokens.resolve_user(params)`. Read endpoints take an OPTIONAL viewer (`resolve_user` may return None); write endpoints return `_shared.unauthorized()` (401) when it does.
|
||||
|
||||
**Writes reuse the audited native cores - never duplicate.** Implementing this drove four DRY extractions in `content.py` (`apply_vote`, `create_comment_record`, `delete_comment_record`, `set_bookmark`) and one in `utils.py` (`register_account`); the native `routers/votes.py`, `routers/comments.py`, and `auth/signup.py` were refactored onto the SAME functions. So a devRant rant/comment/vote awards XP, fires notifications, writes the audit row, and soft-deletes exactly like the UI path. Rant create calls `content.create_content_item` directly; rant delete calls `content.delete_content_item` (full cascade) and returns the devRant envelope.
|
||||
|
||||
**Field mappings.** Rant `text` = `title\n\n content` (inbound rants have no title, `topic` forced to `"rant"`). devRant `tags` round-trip verbatim through a new `posts.tags` JSON column (`init_db` ensures it; `decode_tags` falls back to `[topic]`). `profile_skills` is derived from `bio` (`skills_from_bio`, no native skills field). `favorite`/`unfavorite` map to bookmarks via `set_bookmark`. Avatars are real PNGs from the multiavatar engine at `GET /api/avatars/u/{username}.png`; `user_avatar.i` points at that path with a deterministic `b` background colour. The feed envelope includes the auxiliary devRant keys (`settings`, `set`, `wrw`, `dpp`, `num_notifs`, `unread`, `news`) with safe values so clients parse cleanly.
|
||||
|
||||
**Envelope.** `dr_ok(**f)` -> `{success:true, **f}`; `dr_error(msg, status, **extra)` -> `{success:false, error:msg, ...}`. Logical failures stay `200` except bad login -> `400` (matches the captured devRant behaviour). Reference client + captured responses live in `./devranta/`. Host routing (legacy clients hard-code devrant.com) is an infra/DNS concern, out of app scope.
|
||||
|
||||
**Cross-cutting note.** AI content correction (`devplacepy/services/correction.py`) hooks the two devRant direct-edit paths alongside the native content/comment/messaging entrypoints, so it applies identically across the web UI, REST/JSON API, Devii, and devRant.
|
||||
@@ -0,0 +1,71 @@
|
||||
This file documents the documentation site (`/docs`) - prose pages, API reference generation, search, and the interactive tester. Claude Code auto-loads it whenever a file in this directory is read or edited.
|
||||
|
||||
## Routing overview
|
||||
|
||||
- `(none)` - `docs.py` (`docs/` package), the documentation site (prose pages + API reference). See `DOCS_PAGES` below.
|
||||
|
||||
## Documentation site (`/docs`)
|
||||
|
||||
`routers/docs/ package` serves the docs. `DOCS_PAGES` (`routers/docs/pages.py`, re-exported from the `routers/docs` package; handlers in `routers/docs/views.py`) keeps curated prose pages (`kind: "prose"`, each with its own template under `templates/docs/<slug>.html`) and spreads `api_doc_pages()` from `devplacepy/docs_api.py` for every API reference page. Prose pages grouped by `section`: `General` (`index`, `getting-started`, `devii`, `dashboard`, `media-gallery`, `notification-settings`), admin-only `Devii internals` (`devii-*`), admin-only `Services` (`services-*`, documenting the `BaseService` framework and every background service including the live data at `GET /admin/services/data`), and admin-only `Production` (`production-*`, documenting the deployment). A page is admin-gated by `"admin": True`; search/export pick it up automatically by slug and respect the admin flag - no extra wiring.
|
||||
|
||||
## Audience tiers and navigation
|
||||
|
||||
`DOCS_PAGES` entries take optional `admin: True` (hidden + 404 for non-admins, but still indexed and surfaced only to admins by `docs_search`) and `section: "..."` (a nested sidebar group rendered by `docs_base.html`). The sidebar groups `section`s under four ordered **audience tiers** (`AUDIENCES` in `routers/docs/pages.py`): `Start here` (General), `Build with the API` (API, Components, Styles), `Contribute and internals` (Architecture, Services, Devii internals, Bots internals, Testing, Claude Code), and `Operate` (Administration, Production). `nav_groups(visible_pages)` builds the `[(audience, [(section, [pages])])]` tree from the flat visible-page list (so a section's pages collect under one heading regardless of `DOCS_PAGES` order or the API/Administration interleave from `api_doc_pages()`); `views.py` passes it as `nav`, and `docs_base.html` renders an audience super-header (`.sidebar-tier`) above each section subheading (`.sidebar-subheading`). `DOCS_PAGES` stays the canonical list for search/export/routing - the tiering is sidebar-only.
|
||||
|
||||
The public `getting-started` page (`SECTION_GENERAL`) is the new-contributor on-ramp (install/run, the four-faces workflow, validation); gate its deep-internals links with `{% if is_admin(user) %}` so guests get no 404s. Keep one canonical home per concept: the `auth` API group intro in `docs_api.py` defers method detail to the `authentication` prose page rather than re-listing the four methods. The member-facing `devii` prose page is functional; admins also get a `Devii internals` section of `devii-*` technical subpages.
|
||||
|
||||
## Single source of truth
|
||||
|
||||
`docs_api.py` `API_GROUPS` defines each reference page and its endpoints (method, path, auth, params, notes, sample_response). Add an endpoint there and the page, sidebar link, code examples, and interactive runner appear automatically.
|
||||
|
||||
## Downloads
|
||||
|
||||
`devplacepy/docs_export.py`: `/docs/download.md` returns the entire docs as one Markdown file; `/docs/download.html` returns a single **self-contained** HTML (vendored `marked` + `highlight.js` + theme inlined, the Markdown embedded base64 and rendered on open - works offline). Both are admin-filtered like the rest of the docs and reuse the same group/prose sources. Routes are declared before `/docs/{slug}.html` so `download.html` isn't caught by the slug pattern.
|
||||
|
||||
## Docs search (`/docs/search.html`)
|
||||
|
||||
A backend-rendered BM25 search over all docs pages, in `devplacepy/docs_search.py`. The corpus = prose page text (template stripped of Jinja/HTML) + each API group's intro/endpoints + the live services group; the inverted index (postings + idf) is built **once** lazily (`get_index()`, cached module-global) so a query is just postings lookups (~120us). `docs.py` special-cases `slug == "search"` (before the page registry) and renders `docs/_search.html` inside `docs_base.html` (`kind == 'search'`). Admin-only pages are filtered from results for non-admins, exactly like the sidebar. Snippets are HTML-escaped then wrapped in `<mark>` (XSS-safe). `_strip` removes `<script>`/`<style>` blocks and runs `_demarkdown` (drops headings, list/quote markers, table pipes, code fences, link/image syntax, and backtick/asterisk/tilde emphasis, but keeps `_` so identifiers like `owner_kind` stay searchable), so both the index and the snippets read as clean prose rather than raw markdown. The former `search` API group (user/recipient lookups) was renamed to slug **`lookups`** ("Search & Lookups") to free the `search` slug - keep that in mind if adding endpoints there.
|
||||
|
||||
## Token substitution
|
||||
|
||||
Intros/notes/examples may use `{{ base }}`, `{{ username }}`, `{{ api_key }}` - these are substituted server-side by `render_group()` (plain string replace, not Jinja), because the registry is data, not template source.
|
||||
|
||||
## Rendering pipeline (`kind` branch, prose rendering)
|
||||
|
||||
`docs_base.html` renders api pages via `_api_page.html` -> `_endpoint.html` per endpoint. Prose pages are rendered **server-side**: `docs.py` calls `docs_prose.render_prose(slug, ctx)` (`devplacepy/docs_prose.py`, mistune GFM with tables/strikethrough/url + `hard_wrap` to mirror the client `marked` config), which renders the template, converts the single `<div class="docs-content" data-render>` markdown block to HTML (after `html.unescape`, matching the client's `textContent` read), and **strips `data-render`**. `docs_base.html` outputs the result via `{{ prose_html|safe }}`. This eliminates the client markdown-to-HTML flicker and improves first paint. Anything outside that block (component live-demo blocks + their `<script type="module">`, the `devii.html` hero/CTA) is passed through verbatim. `hljs` highlighting and `CodeCopy` still run client-side over the now-server-rendered `<pre><code>` (text is already present, only colors/copy button appear after JS).
|
||||
|
||||
Every rendered `h2`/`h3` automatically gets a slugified `id` plus a hover permalink (`docs_prose._anchor_headings`, `heading_slug`), so any prose page can deep-link its sections; a page wanting a contents index places a `.docs-toc` nav (styled in `docs.css`) OUTSIDE the `data-render` block linking to those slugs (see `isslop-checks`). Authored example markup inside that block is therefore still HTML-escaped (`<dp-...>`); content outside the block passes through untouched. User-generated content elsewhere still uses the client `data-render` pipeline.
|
||||
|
||||
The public `Components` section (`component-*` prose pages) documents the custom web components with a **live, interactive example** on each page. The public `Styles` section (`styles`, `styles-colors`, `styles-layout`, `styles-responsiveness`, `styles-consistency`) is the design-system reference: the colour tokens and their meaning, the approved page layouts, the responsive breakpoint ladder, and the HARD structural rules every page must follow (taken from the feed/posts page as the canonical implementation). It uses the same live-demo convention (real demo markup OUTSIDE the `data-render` block, example markup inside it entity-escaped).
|
||||
|
||||
**`data-render` destroys inner HTML** (it renders `textContent`). Only group-intro / prose markdown lives inside a `data-render` block; every endpoint card, `[data-api-tester]` mount, and component live-demo lives OUTSIDE it. A prose page's `<div class="docs-content" data-render>` is rendered client-side via marked + DOMPurify on `textContent`, so any example markup shown as code inside it MUST be HTML-escaped (`<dp-dialog>`) or the browser parses it as a real element before render; the live demo itself goes in a separate block OUTSIDE the `data-render` div, where a `<script type="module">` (which imports its own component module, since it executes before `Application.js`) wires it up.
|
||||
|
||||
**`data-config` must be single-quoted:** `_endpoint.html` emits `data-config='{{ endpoint|tojson }}'`. `tojson` escapes `'` to `'`, so single quotes are safe; double quotes would break.
|
||||
|
||||
## Interactive API tester
|
||||
|
||||
The reusable widget is `static/js/ApiTester.js` (one instance per `[data-api-tester]`, wired by `ApiDocs.js` loaded in the docs `extra_js` block). It builds the param form, a **response-format picker**, live cURL/JS/Python tabs, a Send button that runs the real call, and a two-tab response area. It reads `window.DEVPLACE_DOCS` (`base`, `loggedIn`, `username`, `apiKey`, `isAdmin`) injected in `docs_base.html`. **Code blocks** are decorated by the shared `static/js/CodeBlock.js` (highlight via `hljs` + a line-number gutter + an always-visible Copy button); the widget calls `CodeBlock.refresh(pre)` on every tab switch (re-highlights - it clears `data-highlighted` first, the fix for stale tabs), the response panes use `CodeBlock.enhance(pre, {lineNumbers:false})`, and `CodeCopy.js` runs `CodeBlock.enhance` over prose `.docs-content pre`. Styling lives in `docs.css` (`.code-pre`/`.code-gutter`/`.code-has-copy`, `.format-picker`/`.format-option`, `.response-tabs`/`.response-pane`).
|
||||
|
||||
## Response-format negotiation
|
||||
|
||||
Every endpoint dict carries a `negotiation` field, set by `_classify()` in `docs_api.py` (not hand-written): `"negotiable"` (page GETs + action POSTs - toggle JSON/HTML via the `Accept` header), `"ajax"` (votes/reactions/bookmarks/polls - JSON via `X-Requested-With: fetch`, HTML shows the redirect), `"json"` (always JSON), `"none"` (avatar/proxy/redirect - no body). The picker **defaults to JSON** everywhere; `ApiTester.headerPairs()` adds `Accept: application/json|text/html` accordingly and only sends `X-Requested-With` for ajax endpoints in JSON mode, so the live request **and** the generated snippets stay in sync. The **Expected** tab (always visible, default) renders the endpoint's `sample_response`; the **Live response** tab fills in after Send.
|
||||
|
||||
## Runnable scope
|
||||
|
||||
Page GETs are `interactive: true` (they get a Send button, gated by `ctx.loggedIn`/`ctx.isAdmin` for user/admin endpoints). Mutations use `destructive: true` (confirm dialog). Only `avatar`, `gateway-passthrough`, `notifications-open`, `push-register`, `profile-regenerate-key`, and `profile-regenerate-avatar` stay `interactive: false` (image/proxy/redirect/side-effecting) - they still show the Expected tab. Admin endpoints (`auth: "admin"`) only run for admins.
|
||||
|
||||
## Enum params
|
||||
|
||||
Pass `options`, never hardcode allowed values in prose. A `field(... type="enum", options=[...])` auto-renders an "Allowed: a, b, c" line under the control in the live tester (`ApiTester.js` `buildParams`) and appends `Allowed: ...` to the Description column in the Markdown/HTML export (`docs_export.py` `_params_table`). Source enum lists from `devplacepy/constants.py` (`TOPICS`, `REACTION_EMOJI`) or the model `Literal`s (`PROJECT_TYPES`, `VOTE_TARGETS`) so docs stay in sync. Do NOT spell the values into the description - it would duplicate and drift.
|
||||
|
||||
## Minimal role documentation and validation
|
||||
|
||||
The `endpoint()` factory derives `min_role` from `auth` (`public` -> Public, `user` -> Member, `admin` -> Admin); it is rendered as the "Minimal role:" badge in `_endpoint.html` and as `*Minimal role:*` in the Markdown/HTML export. The `auth` value MUST reflect the real enforcement: `tests/api/auth/matrix.py` drives every documented endpoint (incl. the dynamic services group) as anonymous, member, and admin and asserts the enforcement matches the doc - public allows anonymous, `user` rejects anonymous (401/login-redirect), `admin` rejects a non-admin member (403 /feed-redirect). It forces explicit roles to survive the `is_first`-becomes-Admin rule. If you add/relax a route's auth, update its `auth` in `docs_api.py` or this test fails. (Example caught by it: `/notifications/counts` uses `get_current_user` and returns zeros to guests, so it is documented `public`, not `user`.)
|
||||
|
||||
## Admin-only pages
|
||||
|
||||
A group with `"admin": True` (currently `services`, `admin`) is hidden from the sidebar (`docs.py` filters `visible_pages`) and returns 404 for non-admins (`page.get("admin") and not is_admin -> not_found`, same message as unknown slugs). `index.html` wraps the operator links in `{% if user and user.get('role') == 'Admin' %}`.
|
||||
|
||||
## Dynamic Background Services page
|
||||
|
||||
The `services` group is a placeholder (`"dynamic": True`, empty endpoints). `docs.py` branches on `dynamic` and calls `build_services_group(service_manager.describe_all(), base)` to generate it live from the registered services and their `ConfigField` specs (one `POST /admin/services/{name}/config` card per service, `*_enabled` fields excluded). Add a service to `main.py` startup and it documents itself - keep `docs_api.py` import-pure (no `describe_all()` at import time).
|
||||
@@ -69,6 +69,12 @@ DOCS_PAGES = [
|
||||
"kind": "prose",
|
||||
"section": SECTION_GENERAL,
|
||||
},
|
||||
{
|
||||
"slug": "quizzes",
|
||||
"title": "Quizzes",
|
||||
"kind": "prose",
|
||||
"section": SECTION_GENERAL,
|
||||
},
|
||||
{
|
||||
"slug": "block-and-mute",
|
||||
"title": "Block and mute",
|
||||
@@ -117,6 +123,18 @@ DOCS_PAGES = [
|
||||
"kind": "prose",
|
||||
"section": SECTION_GENERAL,
|
||||
},
|
||||
{
|
||||
"slug": "presence",
|
||||
"title": "Online presence",
|
||||
"kind": "prose",
|
||||
"section": SECTION_GENERAL,
|
||||
},
|
||||
{
|
||||
"slug": "awards",
|
||||
"title": "Profile awards",
|
||||
"kind": "prose",
|
||||
"section": SECTION_GENERAL,
|
||||
},
|
||||
{
|
||||
"slug": "ai-correction",
|
||||
"title": "AI content correction",
|
||||
@@ -142,6 +160,18 @@ DOCS_PAGES = [
|
||||
"kind": "prose",
|
||||
"section": SECTION_TOOLS,
|
||||
},
|
||||
{
|
||||
"slug": "tools-isslop",
|
||||
"title": "AI Usage Analyzer",
|
||||
"kind": "prose",
|
||||
"section": SECTION_TOOLS,
|
||||
},
|
||||
{
|
||||
"slug": "isslop-checks",
|
||||
"title": "AI Usage Analyzer checks",
|
||||
"kind": "prose",
|
||||
"section": SECTION_TOOLS,
|
||||
},
|
||||
# Claude Code - the native subagent, command, and workflow setup under .claude/
|
||||
{
|
||||
"slug": "claude",
|
||||
|
||||
@@ -19,6 +19,7 @@ from devplacepy.database import (
|
||||
)
|
||||
from devplacepy.attachments import get_attachments_batch
|
||||
from devplacepy.content import enrich_items
|
||||
from devplacepy.services import presence
|
||||
from devplacepy.utils import get_current_user
|
||||
from devplacepy.seo import list_page_seo, next_page_url
|
||||
from devplacepy.responses import respond
|
||||
@@ -90,6 +91,7 @@ async def feed_page(
|
||||
stats = get_site_stats()
|
||||
top_authors = get_top_authors(5)
|
||||
daily_topic = get_daily_topic()
|
||||
online_users = presence.online_users()
|
||||
|
||||
post_uids_list = [item["post"]["uid"] for item in posts]
|
||||
attachments_map = get_attachments_batch("post", post_uids_list)
|
||||
@@ -134,6 +136,7 @@ async def feed_page(
|
||||
"total_gists": stats["total_gists"],
|
||||
"top_authors": top_authors,
|
||||
"daily_topic": daily_topic,
|
||||
"online_users": online_users,
|
||||
"next_cursor": next_cursor,
|
||||
},
|
||||
model=FeedOut,
|
||||
|
||||
@@ -25,8 +25,30 @@ def owner_by_username(username: str) -> dict | None:
|
||||
|
||||
|
||||
def state_payload(user: dict, viewer: dict | None = None) -> dict:
|
||||
from devplacepy.services.game import economy
|
||||
from devplacepy.utils import award_rewards, track_action
|
||||
|
||||
farm = store.ensure_farm(user["uid"])
|
||||
return store.serialize_farm(farm, viewer=viewer or user, owner=user)
|
||||
payload = store.serialize_farm(farm, viewer=viewer or user, owner=user)
|
||||
harvested = int(payload.get("auto_harvested") or 0)
|
||||
if harvested:
|
||||
track_action(user["uid"], "harvest")
|
||||
award_rewards(user["uid"], economy.site_xp_for(payload.get("auto_harvest_xp") or 0))
|
||||
return payload
|
||||
|
||||
|
||||
def action_error(request: Request, message: str, redirect_url: str):
|
||||
from urllib.parse import quote
|
||||
|
||||
from devplacepy.responses import json_error, wants_json
|
||||
from fastapi.responses import RedirectResponse
|
||||
|
||||
if wants_json(request):
|
||||
return json_error(400, message)
|
||||
separator = "&" if "?" in redirect_url else "?"
|
||||
return RedirectResponse(
|
||||
url=f"{redirect_url}{separator}error={quote(message)}", status_code=302
|
||||
)
|
||||
|
||||
|
||||
def game_seo(request: Request, title: str, description: str) -> dict:
|
||||
|
||||
@@ -6,7 +6,7 @@ from fastapi import APIRouter, Form, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||
|
||||
from devplacepy.models import GameSlotForm
|
||||
from devplacepy.responses import json_error, respond, wants_json
|
||||
from devplacepy.responses import respond, wants_json
|
||||
from devplacepy.schemas import GameFarmViewOut
|
||||
from devplacepy.services.game import GameError, store
|
||||
from devplacepy.utils import (
|
||||
@@ -16,7 +16,7 @@ from devplacepy.utils import (
|
||||
track_action,
|
||||
)
|
||||
|
||||
from ._shared import game_seo, notify_farm, owner_by_username
|
||||
from ._shared import action_error, game_seo, notify_farm, owner_by_username
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -43,6 +43,7 @@ async def view_farm(request: Request, username: str):
|
||||
"user": viewer,
|
||||
"viewer": viewer,
|
||||
"farm": data,
|
||||
"game_error": request.query_params.get("error", ""),
|
||||
},
|
||||
model=GameFarmViewOut,
|
||||
)
|
||||
@@ -59,9 +60,7 @@ async def water_farm(
|
||||
try:
|
||||
store.water(viewer, owner, data.slot)
|
||||
except GameError as exc:
|
||||
if wants_json(request):
|
||||
return json_error(400, str(exc))
|
||||
return RedirectResponse(url=f"/game/farm/{username}", status_code=302)
|
||||
return action_error(request, str(exc), f"/game/farm/{username}")
|
||||
track_action(viewer["uid"], "water")
|
||||
await notify_farm(owner["username"])
|
||||
if wants_json(request):
|
||||
@@ -83,15 +82,19 @@ async def steal_farm(
|
||||
try:
|
||||
result = store.steal(viewer, owner, data.slot)
|
||||
except GameError as exc:
|
||||
if wants_json(request):
|
||||
return json_error(400, str(exc))
|
||||
return RedirectResponse(url=f"/game/farm/{username}", status_code=302)
|
||||
return action_error(request, str(exc), f"/game/farm/{username}")
|
||||
track_action(viewer["uid"], "harvest_stolen")
|
||||
track_action(owner["uid"], "got_stolen_from")
|
||||
if result.get("underdog_triggered"):
|
||||
track_action(viewer["uid"], "underdog_raid")
|
||||
create_notification(
|
||||
owner["uid"],
|
||||
"harvest_stolen",
|
||||
"Someone raided your Code Farm and stole a ready build.",
|
||||
(
|
||||
f"{viewer['username']} raided your Code Farm and took "
|
||||
f"{result['coins']} coins ({round(result['share'] * 100)}%) "
|
||||
f"from your {result['crop_name']} build. You keep the rest - harvest it."
|
||||
),
|
||||
viewer["uid"],
|
||||
"/game",
|
||||
)
|
||||
|
||||
@@ -6,7 +6,10 @@ from fastapi import APIRouter, Form, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||
|
||||
from devplacepy.models import (
|
||||
GameCosmeticForm,
|
||||
GameInfraForm,
|
||||
GameLegacyForm,
|
||||
GameMasteryForm,
|
||||
GamePerkForm,
|
||||
GamePlantForm,
|
||||
GameQuestForm,
|
||||
@@ -15,10 +18,11 @@ from devplacepy.models import (
|
||||
from devplacepy.database import mark_notifications_read_by_target
|
||||
from devplacepy.responses import json_error, respond, wants_json
|
||||
from devplacepy.schemas import GameLeaderboardOut, GameStateOut
|
||||
from devplacepy.services.game import GameError, store
|
||||
from devplacepy.services.game import GameError, economy, store
|
||||
from devplacepy.services.audit import record as audit
|
||||
from devplacepy.utils import award_rewards, get_current_user, require_user, track_action
|
||||
|
||||
from ._shared import game_seo, notify_farm, state_payload
|
||||
from ._shared import action_error, game_seo, notify_farm, state_payload
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -28,6 +32,7 @@ async def game_home(request: Request):
|
||||
user = require_user(request)
|
||||
mark_notifications_read_by_target(user["uid"], "/game")
|
||||
farm = state_payload(user)
|
||||
error = request.query_params.get("error", "")
|
||||
seo_ctx = game_seo(
|
||||
request,
|
||||
"Code Farm",
|
||||
@@ -37,7 +42,7 @@ async def game_home(request: Request):
|
||||
return respond(
|
||||
request,
|
||||
"game.html",
|
||||
{**seo_ctx, "request": request, "user": user, "farm": farm},
|
||||
{**seo_ctx, "request": request, "user": user, "farm": farm, "game_error": error},
|
||||
model=GameStateOut,
|
||||
)
|
||||
|
||||
@@ -49,9 +54,9 @@ async def game_state(request: Request):
|
||||
|
||||
|
||||
@router.get("/leaderboard")
|
||||
async def game_leaderboard(request: Request):
|
||||
async def game_leaderboard(request: Request, board: str = "score"):
|
||||
get_current_user(request)
|
||||
entries = store.leaderboard(25)
|
||||
entries = store.leaderboard_for(board, 25)
|
||||
return JSONResponse(
|
||||
GameLeaderboardOut(entries=entries).model_dump(mode="json")
|
||||
)
|
||||
@@ -61,9 +66,7 @@ async def _respond_action(request: Request, user: dict, fn, on_success=None):
|
||||
try:
|
||||
result = fn()
|
||||
except GameError as exc:
|
||||
if wants_json(request):
|
||||
return json_error(400, str(exc))
|
||||
return RedirectResponse(url="/game", status_code=302)
|
||||
return action_error(request, str(exc), "/game")
|
||||
if on_success:
|
||||
on_success(result)
|
||||
await notify_farm(user.get("username", ""))
|
||||
@@ -88,7 +91,7 @@ async def game_harvest(request: Request, data: Annotated[GameSlotForm, Form()]):
|
||||
|
||||
def reward(result):
|
||||
track_action(user["uid"], "harvest")
|
||||
award_rewards(user["uid"], result.get("xp", 0))
|
||||
award_rewards(user["uid"], economy.site_xp_for(result.get("xp", 0)))
|
||||
|
||||
return await _respond_action(
|
||||
request, user, lambda: store.harvest(user, data.slot), reward
|
||||
@@ -119,6 +122,22 @@ async def game_daily(request: Request):
|
||||
return await _respond_action(request, user, lambda: store.claim_daily(user))
|
||||
|
||||
|
||||
@router.post("/grant")
|
||||
async def game_claim_grant(request: Request):
|
||||
user = require_user(request)
|
||||
|
||||
def recorded(result):
|
||||
audit.record(
|
||||
request,
|
||||
"game.grant.claim",
|
||||
user=user,
|
||||
metadata=result,
|
||||
summary=f"{user['username']} claimed a {result['amount']} coin community grant",
|
||||
)
|
||||
|
||||
return await _respond_action(request, user, lambda: store.claim_grant(user), recorded)
|
||||
|
||||
|
||||
@router.post("/perk")
|
||||
async def game_perk(request: Request, data: Annotated[GamePerkForm, Form()]):
|
||||
user = require_user(request)
|
||||
@@ -130,7 +149,20 @@ async def game_perk(request: Request, data: Annotated[GamePerkForm, Form()]):
|
||||
@router.post("/prestige")
|
||||
async def game_prestige(request: Request):
|
||||
user = require_user(request)
|
||||
return await _respond_action(request, user, lambda: store.prestige(user))
|
||||
|
||||
def recorded(result):
|
||||
audit.record(
|
||||
request,
|
||||
"game.prestige",
|
||||
user=user,
|
||||
metadata=result,
|
||||
summary=(
|
||||
f"{user['username']} refactored to prestige {result['prestige']} "
|
||||
f"for {result['fee']} coins"
|
||||
),
|
||||
)
|
||||
|
||||
return await _respond_action(request, user, lambda: store.prestige(user), recorded)
|
||||
|
||||
|
||||
@router.post("/legacy")
|
||||
@@ -146,8 +178,109 @@ async def game_claim_quest(request: Request, data: Annotated[GameQuestForm, Form
|
||||
user = require_user(request)
|
||||
|
||||
def reward(result):
|
||||
award_rewards(user["uid"], result.get("reward_xp", 0))
|
||||
award_rewards(user["uid"], economy.site_xp_for(result.get("reward_xp", 0)))
|
||||
|
||||
return await _respond_action(
|
||||
request, user, lambda: store.claim_quest(user, data.quest), reward
|
||||
request, user, lambda: store.claim_quest(user, data.quest, data.scope), reward
|
||||
)
|
||||
|
||||
|
||||
@router.post("/defense/upgrade")
|
||||
async def game_upgrade_defense(request: Request):
|
||||
user = require_user(request)
|
||||
|
||||
def reward(result):
|
||||
track_action(user["uid"], "defense_upgraded")
|
||||
audit.record(
|
||||
request,
|
||||
"game.defense.upgrade",
|
||||
user=user,
|
||||
metadata=result,
|
||||
summary=(
|
||||
f"{user['username']} bought Code Farm defense level "
|
||||
f"{result['defense_level']} for {result['spent']} coins"
|
||||
),
|
||||
)
|
||||
|
||||
return await _respond_action(request, user, lambda: store.upgrade_defense(user), reward)
|
||||
|
||||
|
||||
@router.post("/defense/downgrade")
|
||||
async def game_downgrade_defense(request: Request):
|
||||
user = require_user(request)
|
||||
|
||||
def recorded(result):
|
||||
audit.record(
|
||||
request,
|
||||
"game.defense.downgrade",
|
||||
user=user,
|
||||
metadata=result,
|
||||
summary=(
|
||||
f"{user['username']} dropped Code Farm defense to level "
|
||||
f"{result['defense_level']}"
|
||||
),
|
||||
)
|
||||
|
||||
return await _respond_action(
|
||||
request, user, lambda: store.downgrade_defense(user), recorded
|
||||
)
|
||||
|
||||
|
||||
@router.post("/infrastructure/buy")
|
||||
async def game_buy_infrastructure(request: Request, data: Annotated[GameInfraForm, Form()]):
|
||||
user = require_user(request)
|
||||
|
||||
def reward(result):
|
||||
track_action(user["uid"], "infra_bought")
|
||||
audit.record(
|
||||
request,
|
||||
"game.infrastructure.buy",
|
||||
user=user,
|
||||
metadata=result,
|
||||
summary=(
|
||||
f"{user['username']} bought Code Farm infrastructure {result['key']} "
|
||||
f"for {result['spent']} coins"
|
||||
),
|
||||
)
|
||||
|
||||
return await _respond_action(
|
||||
request, user, lambda: store.buy_infrastructure(user, data.key), reward
|
||||
)
|
||||
|
||||
|
||||
@router.post("/mastery")
|
||||
async def game_upgrade_mastery(request: Request, data: Annotated[GameMasteryForm, Form()]):
|
||||
user = require_user(request)
|
||||
return await _respond_action(
|
||||
request, user, lambda: store.upgrade_mastery(user, data.key)
|
||||
)
|
||||
|
||||
|
||||
@router.post("/cosmetics/buy")
|
||||
async def game_buy_cosmetic(request: Request, data: Annotated[GameCosmeticForm, Form()]):
|
||||
user = require_user(request)
|
||||
|
||||
def reward(result):
|
||||
track_action(user["uid"], "cosmetic_bought")
|
||||
audit.record(
|
||||
request,
|
||||
"game.cosmetic.buy",
|
||||
user=user,
|
||||
metadata=result,
|
||||
summary=(
|
||||
f"{user['username']} bought Code Farm cosmetic {result['key']} "
|
||||
f"for {result['spent']} coins"
|
||||
),
|
||||
)
|
||||
|
||||
return await _respond_action(
|
||||
request, user, lambda: store.buy_cosmetic(user, data.key), reward
|
||||
)
|
||||
|
||||
|
||||
@router.post("/cosmetics/equip")
|
||||
async def game_equip_cosmetic(request: Request, data: Annotated[GameCosmeticForm, Form()]):
|
||||
user = require_user(request)
|
||||
return await _respond_action(
|
||||
request, user, lambda: store.equip_title(user, data.key)
|
||||
)
|
||||
|
||||
@@ -57,6 +57,7 @@ LANGUAGES = [
|
||||
("yaml", "YAML"),
|
||||
("json", "JSON"),
|
||||
("markdown", "Markdown"),
|
||||
("markdown_rendered", "Markdown Rendered"),
|
||||
("swift", "Swift"),
|
||||
("php", "PHP"),
|
||||
("ruby", "Ruby"),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
@@ -93,18 +94,28 @@ async def issue_detail(request: Request, number: int):
|
||||
if not gitea_config().is_configured:
|
||||
raise not_found("Issue not found")
|
||||
client = runtime.get_client()
|
||||
try:
|
||||
issue = await client.get_issue(number)
|
||||
except GiteaError as exc:
|
||||
if exc.status == 404:
|
||||
issue_result, comments_result = await asyncio.gather(
|
||||
client.get_issue(number),
|
||||
client.list_comments(number),
|
||||
return_exceptions=True,
|
||||
)
|
||||
if isinstance(issue_result, GiteaError):
|
||||
if issue_result.status == 404:
|
||||
raise not_found("Issue not found")
|
||||
logger.warning("Could not load issue #%s: %s", number, exc)
|
||||
logger.warning("Could not load issue #%s: %s", number, issue_result)
|
||||
return tracker_unavailable(request)
|
||||
try:
|
||||
comments = await client.list_comments(number)
|
||||
except GiteaError as exc:
|
||||
logger.warning("Could not load comments for issue #%s: %s", number, exc)
|
||||
if isinstance(issue_result, BaseException):
|
||||
raise issue_result
|
||||
issue = issue_result
|
||||
if isinstance(comments_result, BaseException):
|
||||
if not isinstance(comments_result, GiteaError):
|
||||
raise comments_result
|
||||
logger.warning(
|
||||
"Could not load comments for issue #%s: %s", number, comments_result
|
||||
)
|
||||
comments = []
|
||||
else:
|
||||
comments = comments_result
|
||||
|
||||
if user:
|
||||
mark_notifications_read_by_target(user["uid"], f"/issues?highlight={number}")
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user