Compare commits
51
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79cd5e2920 | ||
|
|
5079f40f46 | ||
|
|
d895de1b47 | ||
|
|
571a0485c5 | ||
|
|
b185574760 | ||
|
|
3709f4fab9 | ||
|
|
1a87c392bd | ||
|
|
ff49c8342a | ||
|
|
76d73ccaea | ||
|
|
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 |
@@ -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: 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)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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,6 +2,8 @@
|
||||
|
||||
This file provides guidance to Claude Code when working with code in this repository. It holds only what applies regardless of which part of the codebase is being touched. Deep, subsystem-specific detail lives in nested `CLAUDE.md` files placed inside the relevant directory - Claude Code auto-loads a nested file only when a file under that directory is read or edited, so the always-loaded cost of this repository stays proportional to this file alone. See "Subsystem map" below for the full list.
|
||||
|
||||
It is a big project, whatever you are implementing, it is probably done before. You should look it up and match the implementation structurely and visually. For inconsistency there is zero tolerance policy. Develop dry, kiss, re-usable code, consistent with existing implementation. Literally always try to find relatable examples before making a modification. If no-example exists, explain to user what is the case and let user decide what to do and how to continue.
|
||||
|
||||
## Project
|
||||
|
||||
DevPlace is a server-rendered social network for developers. FastAPI backend serves Jinja2 templates with pure ES6 module JavaScript on the frontend. SQLite via the `dataset` library (auto-syncs schema). No JS framework, no NPM, no JWT.
|
||||
@@ -21,7 +23,11 @@ make install # pip install -e . + playwright install chromium
|
||||
make ppy # build the single shared container image (ppy:latest); run once before launching instances
|
||||
make dev # uvicorn --reload on port 10500, backlog 4096
|
||||
make prod # uvicorn --workers $(WEB_WORKERS) (defaults to nproc), port 10500 (backlog 8192)
|
||||
make test # Playwright + unit tests, headless, serial (one at a time), -x fail-fast
|
||||
make test # full suite (unit + api + e2e), headless, serial; one pass reports EVERY failure
|
||||
make test-fast # unit + api only, no browser - the quickest triage pass (~3 min)
|
||||
make test-failed # re-run only the tests that failed in the previous run
|
||||
make test-first-failure # full suite with -x, stops at the first failure
|
||||
make test-slowest # full suite plus the 40 slowest tests, to find what costs wall-clock
|
||||
make test-headed # same tests in a visible Chromium window (single process)
|
||||
make locust # Locust load test, interactive web UI
|
||||
make locust-headless # Locust CLI mode for CI
|
||||
@@ -29,10 +35,12 @@ make locust-headless # Locust CLI mode for CI
|
||||
|
||||
The Makefile exports `PYTHONDONTWRITEBYTECODE=1` for every recipe, so no `.pyc` files or `__pycache__` directories are written by any `make` target. Keep it that way - do not add a target that re-enables bytecode writing. `make clean` removes any stray bytecode left from running Python outside make.
|
||||
|
||||
Validate code without running the suite: confirm `python -c "from devplacepy.main import app"` imports clean and check each touched language manually (Python compiles/imports, JS parses, CSS braces and HTML tags balance).
|
||||
Preliminary validation: confirm `python -c "from devplacepy.main import app"` imports clean and check each touched language manually (Python compiles/imports, JS parses, CSS braces and HTML tags balance). These checks are gates on the way to the real validation, never a substitute for it: **every change ends with the full test suite (`make test` - all three tiers, every test) and it must pass.**
|
||||
|
||||
Single test: `python -m pytest tests/e2e/feed.py::test_name -v --tb=line -x`
|
||||
|
||||
**Finding failures fast (the triage order).** The suite no longer stops at the first failure - `-rf` is in `pyproject.toml` `addopts`, so every run (make target or bare `pytest`) prints one `FAILED <nodeid>` line per failure at the end, giving the complete list from a single pass instead of one pass per bug. Triage cheapest-first: `make test-fast` (unit + api, no browser, ~3 min) covers most regressions; only then pay for the browser tier with `make test` or `make test-e2e`. After a run, `make test-failed` re-runs just the failures from pytest's cache (`--last-failed`), which is the loop to iterate in until it is empty. `make test-first-failure` keeps the old `-x` behaviour for the rare case where a single early failure poisons everything after it.
|
||||
|
||||
CLI (installed as `devplace`):
|
||||
```bash
|
||||
devplace role get <username>
|
||||
@@ -51,10 +59,18 @@ devplace attachments prune # remove orphan attachment records/files
|
||||
devplace devii reset-quota <username> # reset one user's rolling 24h AI quota
|
||||
devplace devii reset-quota --guests # reset every guest quota
|
||||
devplace devii reset-quota --all # reset every quota (users and guests)
|
||||
devplace devii tasks list [--all] # list scheduled Devii tasks and their owners
|
||||
devplace devii tasks disable <uid> # disable one scheduled task
|
||||
devplace devii tasks prune # disable every task whose owner may not schedule
|
||||
devplace gateway quota list # list AI gateway quota rules and current 24h spend
|
||||
devplace gateway quota set --limit-usd N [--owner-kind K] [--owner-id ID] [--app-reference APP] [--label L] [--uid UID]
|
||||
devplace gateway quota delete <uid> # delete a quota rule
|
||||
devplace gateway quota reset [--owner-kind K] [--owner-id ID] [--app-reference APP] # clear the counted 24h spend (keeps the usage history)
|
||||
devplace zips prune # delete expired zip archives + job rows
|
||||
devplace zips clear # delete every zip archive + job row
|
||||
devplace forks prune # delete expired completed fork job rows (forked projects persist)
|
||||
devplace forks clear # delete every fork job row (forked projects persist)
|
||||
devplace messaging prune-tickets # delete expired WebSocket auth tickets (ws_tickets)
|
||||
devplace seo prune # delete expired SEO audit reports + job rows
|
||||
devplace seo clear # delete every SEO audit report + job row
|
||||
devplace seo-meta prune # delete expired SEO metadata job rows (generated metadata persists)
|
||||
@@ -64,6 +80,12 @@ devplace deepsearch clear # delete every DeepSearch session + job row + collec
|
||||
devplace isslop analyze <url> # run a AI usage analysis from the terminal (report persists)
|
||||
devplace isslop prune # delete expired AI usage analysis job rows (analyses + reports persist)
|
||||
devplace isslop clear # delete every AI usage analysis, its report and job rows
|
||||
devplace quiz prune # delete abandoned/expired quiz attempts older than the retention window
|
||||
devplace game market prune # delete Code Farm market tick buckets older than the tracking window
|
||||
devplace game steals prune # delete Code Farm raid records older than the raid-efficiency window
|
||||
devplace game era status # show the current Code Farm Era
|
||||
devplace game era start <name> [--days N] # start a Code Farm Era (default 28 days)
|
||||
devplace game era end # end the running Code Farm Era (ranks, awards Stars, records results)
|
||||
devplace backups list # list recorded backups
|
||||
devplace backups run <database|uploads|keys|full> # enqueue a backup (processed by the running server)
|
||||
devplace backups prune # remove backup records whose archive file is missing
|
||||
@@ -99,6 +121,7 @@ Tests run on port 10501 with a tempfile SQLite DB and a dedicated `DEVPLACE_DATA
|
||||
| `DEVPLACE_PRESENCE_ONLINE_LIMIT` | `30` | Max avatars in the feed's live "Online now" panel. |
|
||||
| `DEVPLACE_PRESENCE_ONLINE_MARGIN_SECONDS` | `20` | Grace margin (hysteresis) before an online user is dropped, kills dot/roster flicker at the boundary. |
|
||||
| `DEVPLACE_DATA_DIR` | `<repo>/data` | Single root for ALL runtime/user-generated data OUTSIDE the package and OUTSIDE `/static`. Point at a volume in prod. |
|
||||
| `DEVPLACE_OUTBOUND_PROXY_URL` | unset | Fallback for the `outbound_proxy_url` site setting (below) when the DB/settings row is unavailable (early CLI contexts). Prefer configuring the setting via `/admin/settings` - it applies live with no restart. |
|
||||
|
||||
## Subsystem map
|
||||
|
||||
@@ -125,11 +148,13 @@ Nested `CLAUDE.md` files (loaded automatically by Claude Code only when a file i
|
||||
| `devplacepy/services/bot/CLAUDE.md` | `BotsService` fleet |
|
||||
| `devplacepy/services/dbapi/CLAUDE.md` | `/dbapi` primary-admin-only read-only database API |
|
||||
| `devplacepy/services/pubsub/CLAUDE.md` | Database-free pub/sub bus |
|
||||
| `devplacepy/services/game/CLAUDE.md` | Code Farm idle game |
|
||||
| `devplacepy/services/game/CLAUDE.md` | Code Farm idle game (economy invariants, raids, the one-pure-function rule) |
|
||||
| `devplacepy/services/quiz/CLAUDE.md` | Quizzes (the terminal publish lock, attempt atomicity, answer-key withholding, AI free-text grading, the best-attempt scoreboard) |
|
||||
| `devplacepy/services/CLAUDE.md` | Background task queue, AI correction/modifier, presence, live view relay, `BaseService`/`ServiceManager` |
|
||||
| `devplacepy/database/CLAUDE.md` | Dataset rules, indexing conventions, soft delete, tables, site settings |
|
||||
| `devplacepy/utils/CLAUDE.md` | Notifications and gamification (XP/levels/badges/leaderboard) |
|
||||
| `devplacepy/static/js/CLAUDE.md` | Custom web components, shared frontend utilities (Http/Poller/FloatingWindow/ScrollMemory) |
|
||||
| `devplacepy/static/css/CLAUDE.md` | CSS system: design tokens (no literals, no `var()` fallbacks), file-scoped palettes, `--z-*` stacking bands, the closed breakpoint set, reduced motion |
|
||||
| `devplacepy/templates/CLAUDE.md` | Modal system, CDN libraries, shared template partials |
|
||||
| `tests/CLAUDE.md` | Detailed testing patterns and pitfalls |
|
||||
|
||||
@@ -152,7 +177,7 @@ Routers in `devplacepy/routers/` are organised as a **directory tree that mirror
|
||||
| `/auth` | auth/ package |
|
||||
| `/feed`, `/posts`, `/comments` | flat files |
|
||||
| `/projects`, `/projects/{slug}/files`, `/projects/{slug}/containers` | projects/ package - see `routers/projects/CLAUDE.md` |
|
||||
| `/profile` | profile/ package (customization, notifications, ai-correction, ai-modifier, telegram, usage) |
|
||||
| `/profile` | profile/ package (customization, notifications, ai-correction, ai-modifier, interactions, telegram, usage) |
|
||||
| `/messages` | messages.py - see `services/messaging/CLAUDE.md` |
|
||||
| `/notifications`, `/votes`, `/reactions`, `/bookmarks`, `/polls`, `/avatar`, `/follow`, `/leaderboard` | flat files |
|
||||
| (none) | relations.py - block/mute |
|
||||
@@ -168,6 +193,7 @@ Routers in `devplacepy/routers/` are organised as a **directory tree that mirror
|
||||
| `/api` | devrant/ package - see `routers/devrant/CLAUDE.md` |
|
||||
| `/dbapi` | dbapi/ package, **primary-administrator-only, strictly READ-ONLY** - see `services/dbapi/CLAUDE.md` |
|
||||
| `/game` | game/ package - see `services/game/CLAUDE.md` |
|
||||
| `/quizzes` | quizzes/ package - see `services/quiz/CLAUDE.md` |
|
||||
| (none) | push.py (web push/PWA), docs.py (see `routers/docs/CLAUDE.md`) |
|
||||
| `/pubsub` | pubsub.py - see `services/pubsub/CLAUDE.md` |
|
||||
| (none) | seo.py - `/robots.txt`, `/sitemap.xml` |
|
||||
@@ -190,7 +216,9 @@ The CLIENT pipeline (`ContentRenderer.js`, `dp-content`/`dp-title`) is retained
|
||||
|
||||
**Emoji shortcodes are the full GitHub/Discord `:name:` set** (~4869 names), generated once from the `emoji` library by `rendering.py` `build_emoji_shortcodes()`; the frontend gets the identical map via the generated `static/js/emoji-shortcodes.js` (regenerate with `devplace emoji-sync` after bumping the dependency, never hand-edit).
|
||||
|
||||
**Template em-dash normalization:** the shared `templates.env.template_class` runs every rendered template's final HTML through `normalize_em_dash` - the em-dash character and its HTML entity forms all become a plain hyphen, application-wide. One hook; never re-strip em-dash per template.
|
||||
**Email anonymization:** both `_render_content` and `_render_title` mask email addresses to prevent doxing. `_mask_emails` in `rendering.py` runs on rendered text nodes only (inside `_transform_text`, `_MediaProcessor`, and `_InlineFilter` - never before mistune, or the `*` mask characters would be parsed as emphasis) and stars ~80% of the local part (keeps a leading 20%, minimum one visible char). Addresses on `molodetz.nl` (and its subdomains) are exempt and render verbatim.
|
||||
|
||||
**Em-dash normalization:** `_normalize_dashes` in `rendering.py` replaces all forms (em dash `\u2014`, en dash `\u2013`, and their HTML entities) with a hyphen BEFORE mistune processes the text. Runs inside `_render_content`/`_render_title` which are `@lru_cache`d, so each unique text is normalized once. No per-template overhead.
|
||||
|
||||
### Auth
|
||||
|
||||
@@ -221,7 +249,7 @@ Users and guests inject their own CSS and JS, scoped to a page type or globally,
|
||||
|
||||
### Container manager, Devii assistant, AI gateway, async jobs, audit log
|
||||
|
||||
DevPlace ships a Docker-backed **Container manager** (admin-only, one shared `ppy:latest` image, security-hardened rootless workflow via a `sudo` superclone and `aptroot`), the **Devii** in-platform agentic assistant (WebSocket terminal, per-owner-channel sessions, scheduler/reminders, self-learning lessons, user-defined virtual tools, self-configured behavior, browser-automation client tools), the **AI gateway** (`/openai/v1/*`, single point of truth for every AI call, per-user cost attribution, provider/model routing overlay), **async job services** (`JobService` base pattern: zip, fork, SEO diagnostics, SEO metadata generation, DeepSearch, AI Usage Analyzer), and an admin-only append-only **audit log** (`record`/`record_system` entrypoints, `events.md` catalogue of 223 keys, never raises into the caller). These are among the largest subsystems in the codebase - read their dedicated nested `CLAUDE.md` files (see Subsystem map) before working in any of them; do not assume prior knowledge from this summary.
|
||||
DevPlace ships a Docker-backed **Container manager** (admin-only, one shared `ppy:latest` image, security-hardened rootless workflow via a `sudo` superclone and `aptroot`), the **Devii** in-platform agentic assistant (WebSocket terminal, per-owner-channel sessions, scheduler/reminders, self-learning lessons, user-defined virtual tools, self-configured behavior, browser-automation client tools), the **AI gateway** (`/openai/v1/*`, single point of truth for every AI call, per-user cost attribution, provider/model routing overlay), **async job services** (`JobService` base pattern: zip, fork, SEO diagnostics, SEO metadata generation, DeepSearch, AI Usage Analyzer), and an admin-only append-only **audit log** (`record`/`record_system` entrypoints, `events.md` catalogue of 288 keys, never raises into the caller). These are among the largest subsystems in the codebase - read their dedicated nested `CLAUDE.md` files (see Subsystem map) before working in any of them; do not assume prior knowledge from this summary.
|
||||
|
||||
### Telegram bot, email, devRant compatibility API, issue tracker
|
||||
|
||||
@@ -235,7 +263,7 @@ Devii is also reachable over **Telegram** (one supervised long-poller subprocess
|
||||
|
||||
- **No comments, no docstrings in source.** Code is self-documenting.
|
||||
- **Forbidden variable name patterns:** `_new`, `_old`, `_temp`, `_v2`, `better_`, `my_`, `the_`.
|
||||
- **Outbound HTTP goes through the stealth client only.** Never instantiate a bare `httpx.AsyncClient(...)`/`httpx.Client(...)` for outbound traffic - build it via `devplacepy.stealth.stealth_async_client(...)`/`stealth_sync_client(...)`. The async factory routes bytes through **`curl_cffi` (curl-impersonate, BoringSSL)** behind an `httpx` transport adapter, so TLS JA3/JA4 and the HTTP/2 fingerprint match Chrome 146 byte-for-byte; falls back to a pure-httpx Chrome-aligned transport if `curl_cffi` is missing. Applies to news, gateway upstream, Devii/DeepSearch/SEO fetch, gitea, push, attachments. SSRF-guarded fetches via `net_guard.guarded_async_client(...)` get the fingerprint automatically. Sole exception: `routers/proxy.py` relays the user's own headers verbatim. **Load-bearing gotcha:** cleartext `http://` is forced to HTTP/1.1 (`curl_transport.http_version_for`) - the Chrome-impersonation profile defaults to HTTP/2, but a plaintext origin has no TLS/ALPN to negotiate h2 and uvicorn (HTTP/1.1 only) returns `400 "Invalid HTTP request received."` once a cleartext HTTP/2 body crosses ~128KB. Every internal AI consumer calls `INTERNAL_GATEWAY_URL` = `http://localhost:<port>/openai/v1/...`, so without this override a large Devii turn (big system prompt + tool list) fails on every request. `https://` keeps the HTTP/2 default. `stealth_sync_client` stays pure-httpx Chrome-aligned (no curl_cffi sync adapter).
|
||||
- **Outbound HTTP goes through the stealth client only.** Never instantiate a bare `httpx.AsyncClient(...)`/`httpx.Client(...)` for outbound traffic - build it via `devplacepy.stealth.stealth_async_client(...)`/`stealth_sync_client(...)`. The async factory routes bytes through **`curl_cffi` (curl-impersonate, BoringSSL)** behind an `httpx` transport adapter, so TLS JA3/JA4 and the HTTP/2 fingerprint match Chrome 146 byte-for-byte; falls back to a pure-httpx Chrome-aligned transport if `curl_cffi` is missing. Applies to news, gateway upstream, Devii/DeepSearch/SEO fetch, gitea, push, attachments. SSRF-guarded fetches via `net_guard.guarded_async_client(...)` get the fingerprint automatically. Sole exception: `routers/proxy.py` relays the user's own headers verbatim. **Load-bearing gotcha:** cleartext `http://` is forced to HTTP/1.1 (`curl_transport.http_version_for`) - the Chrome-impersonation profile defaults to HTTP/2, but a plaintext origin has no TLS/ALPN to negotiate h2 and uvicorn (HTTP/1.1 only) returns `400 "Invalid HTTP request received."` once a cleartext HTTP/2 body crosses ~128KB. Every internal AI consumer calls `INTERNAL_GATEWAY_URL` = `http://localhost:<port>/openai/v1/...`, so without this override a large Devii turn (big system prompt + tool list) fails on every request. `https://` keeps the HTTP/2 default. `stealth_sync_client` stays pure-httpx Chrome-aligned (no curl_cffi sync adapter). **Never bolt a hand-written browser-header dict (UA/`sec-ch-ua`/`Sec-Fetch-*`) onto a `stealth_async_client`/`stealth_sync_client` call** - curl_cffi's impersonation already emits an internally-consistent Chrome 146 identity across TLS, UA, and Client Hints; a caller-supplied header set claiming a different Chrome version or platform creates a UA-vs-TLS-vs-Client-Hints mismatch that is a textbook anti-bot tripwire (this was a real bug in Devii's `fetch_url`/`http_request` tool, fixed by deleting its hardcoded Chrome-131 header block). `stealth.detect_consent_gate(html)` recognizes the common JS-redirect cookie-consent wall pattern (DPG Media's `nu.nl` and sibling sites, and generically any page gated behind a `decodeURIComponent(...)` callback referencing a "privacy gate"/`consent.js`) - reuse it wherever a fetched page's real content sits behind that gate rather than special-casing a domain. `stealth_transport`/`stealth_async_client`/`stealth_sync_client` all accept a `proxy=` override, defaulting to `stealth.configured_proxy_url()` (the admin-editable `outbound_proxy_url` site setting, falling back to `DEVPLACE_OUTBOUND_PROXY_URL` when the setting is unset) - this is the only real fix for a destination that blocks by IP/ASN reputation (e.g. reddit.com returns an identical "blocked by network security" page across every TLS/header fingerprint tried, confirmed by testing curl_cffi's full impersonation list - it is blocking the datacenter ASN outright, not fingerprinting the client).
|
||||
- **Form validation is Pydantic-native.** Routers declare `data: Annotated[SomeForm, Form()]` (models in `models.py`); the global `RequestValidationError` handler re-renders auth pages with messages (400) or redirects other routes. Read raw `await request.form()` only when also reading an uploaded file alongside a model.
|
||||
- **`RedirectResponse(url=..., status_code=302)`** - always pass `status_code` as an integer literal, never `status.HTTP_302_FOUND`.
|
||||
- **Ownership checks:** compare `resource["user_uid"] == user["uid"]` before edit/delete (use `content.is_owner`). **Deletes additionally allow any admin:** `is_owner(...) or is_admin(user)` on every content delete endpoint, so an administrator may delete any member's content while members are limited to their own. Edits stay owner-only. Deletes are **soft** and cascade under one shared stamp - see `devplacepy/database/CLAUDE.md`.
|
||||
@@ -270,13 +298,24 @@ Admin **Trash** at `/admin/trash` restores/purges by event. Full table list, dat
|
||||
|
||||
## Testing
|
||||
|
||||
Playwright (NOT pytest-playwright). Around 1959 tests in `tests/`, split into three category directories by *what they exercise*: `tests/api/` (HTTP integration, no browser), `tests/e2e/` (Playwright browser), `tests/unit/` (pure in-process). **The directory tree mirrors the path** - one segment per directory, last segment is the file. `api`/`e2e` mirror the URL path (`GET /admin/ai-usage` -> `tests/e2e/admin/aiusage.py`); `unit` mirrors the source module path (`devplacepy/utils.py` -> `tests/unit/utils.py`). A test's tier is decided by its fixtures: `page`/`alice`/`bob` = e2e; `app_server`/`seeded_db` or any HTTP call = api; `local_db`-only or no fixture = unit. Run a tier with `make test-unit`/`make test-api`/`make test-e2e`; `make test` runs all three.
|
||||
Playwright (NOT pytest-playwright). Around 2882 tests in `tests/`, split into three category directories by *what they exercise*: `tests/api/` (HTTP integration, no browser), `tests/e2e/` (Playwright browser), `tests/unit/` (pure in-process). **The directory tree mirrors the path** - one segment per directory, last segment is the file. `api`/`e2e` mirror the URL path (`GET /admin/ai-usage` -> `tests/e2e/admin/aiusage.py`); `unit` mirrors the source module path (`devplacepy/utils.py` -> `tests/unit/utils.py`). A test's tier is decided by its fixtures: `page`/`alice`/`bob` = e2e; `app_server`/`seeded_db` or any HTTP call = api; `local_db`-only or no fixture = unit. Run a tier with `make test-unit`/`make test-api`/`make test-e2e`; `make test` runs all three.
|
||||
|
||||
Fixture stack: `app_server` (session-scoped uvicorn subprocess on port 10501), `browser_context` (session-scoped Playwright context), `page` (function-scoped, fresh cookies), `alice`/`bob` (seeded logged-in users, `bob` gets its own context for multi-user tests).
|
||||
|
||||
Required patterns: every `page.goto(...)`/`page.wait_for_url(...)` MUST pass `wait_until="domcontentloaded"`; prefer `page.locator(...).wait_for(state="visible")` over `wait_for_selector`; scope ambiguous selectors (e.g. comment Delete is `.comment-action-btn:has-text('Delete')`). A test that flips a global `site_settings` value MUST restore it in `try/finally`. Full pitfalls/patterns catalogue is in `tests/CLAUDE.md`.
|
||||
|
||||
**Never run tests unless the user explicitly asks for it.** Not the full suite, not a single file. Validate with a clean import (`python -c "from devplacepy.main import app"`) and per-language manual checks instead.
|
||||
**Always run the full test suite (`make test` - unit, api, and e2e, every test) as the final validation of every change.** No tier may be skipped and no subset substituted for the whole. The clean import (`python -c "from devplacepy.main import app"`) and per-language manual checks are preliminary gates before the suite, not replacements for it. Any failure is a real signal and blocks completion until fixed.
|
||||
|
||||
## Rigorous correctness verification (money, state machines, concurrency)
|
||||
|
||||
The persisted test suite (`tests/unit`/`api`/`e2e`) is example-based: it proves the specific inputs someone wrote down behave correctly. It is structurally blind to three classes of bug - a formula wrong at an input nobody tried, an invariant violated only after a long sequence of actions, and a race that only two nearly-simultaneous requests trigger. **Any feature that touches a spendable resource (coins, credits, quotas), a bounded state machine (levels, tiers, counters with a floor/ceiling), or a read-then-write mutation reachable from more than one request path applies this procedure in addition to, not instead of, the normal persisted tests.** A simple CRUD toggle doesn't need it; an economy, inventory, ledger, or scoring feature does. This was built out fully for the Code Farm economy rebalance (see `devplacepy/services/game/CLAUDE.md`, "Every purchase/upgrade is atomic..." - the worked example, including every bug it actually caught) and is the standing procedure for anything shaped like it going forward.
|
||||
|
||||
Four escalating layers, run in order, as disposable Python scripts that call the real functions directly against a temp DB (`DEVPLACE_DATABASE_URL`/`DEVPLACE_DATA_DIR` pointed at a scratch file) - not pytest files, unless the user separately asks for persisted tests too:
|
||||
|
||||
1. **Property/invariant checks on every pure function, across the full input domain.** For each formula (a reward, a cost curve, a score, a discount), assert the mathematical property it must have - monotonicity, bounds, non-negativity, idempotency, additivity over a partition of its domain - by iterating the real range (hundreds to thousands of values), not a handful of hand-picked spot checks. A monotonicity bug at input 347 is invisible if only 0, 1, and 10 are tried.
|
||||
2. **Stateful fuzzing.** Build N independent entities, fire a long randomized sequence of every mutating action across all of them against a live temp DB, and re-check invariants after every single action: balances never negative, counters that should only increase never decrease, levels/tiers never exceed their declared bounds. Catch the domain's expected exception type and continue - the goal is catching invariant violations and *unexpected* exceptions, not asserting every action succeeds. This proves safety (nothing bad happens) but not liveness (legitimate actions actually succeed) - a bug that wrongly blocks a valid action just raises a well-formed expected exception and sails through a pure fuzz test undetected. Layer 3 exists because of exactly this gap.
|
||||
3. **Concurrency: prove races are closed, never infer it from luck.** Any read-then-write mutation reachable from more than one worker process (`uvicorn --workers N`) is a TOCTOU race until proven otherwise. Test with **real separate OS processes**, not threads in one process - `dataset` gives each thread its own pooled connection, and enough threads exhausts that pool and produces `database is locked` noise that is a test-harness artifact, not a finding about the application. Set up genuinely fresh, production-representative state before racing - **never pre-seed or zero a column that the real code path leaves unset/NULL.** This was the single most expensive mistake made building this procedure: a first race-safety pass "passed" only because its own setup script had artificially pre-zeroed columns that a real fresh row leaves as SQL `NULL` - silently hiding the exact bug the fix was supposed to prevent (`NULL = 0` evaluates to `NULL`, not true, in a SQL `WHERE` clause; any column not written in the row's original `INSERT` needs `COALESCE(column, 0)` in every later precondition and every arithmetic `SET`, not a bare comparison). Fire many concurrent attempts at the same resource, assert the exact right number succeed, and verify the final state matches the hand-computed expected total exactly (currency spent, levels advanced) - not just "the others were blocked." Fix a real race with a single atomic conditional SQL statement at the exact chokepoint (`UPDATE ... SET ... WHERE <precondition>`, checked via the driver's real `rowcount` - `dataset`'s wrapped `db.query()` does not expose it, use `db.executable.execute(sqlalchemy.text(...), params).rowcount` inside `with db:`), not a client-side lock or an optimistic-locking library this codebase doesn't otherwise use.
|
||||
4. **Static analysis beyond `py_compile`.** A clean `python -m py_compile` and a clean `from devplacepy.main import app` only prove syntax and module-load order - neither catches a missing import inside a function body, which is a runtime `NameError` invisible until that exact line executes. Run `pyflakes`/`ruff check` on every touched file before calling a change done; it is nearly free and catches an entire class of bug that layers 1-3 can each individually miss if they don't happen to exercise the broken line.
|
||||
|
||||
## Feature workflow
|
||||
|
||||
@@ -295,7 +334,7 @@ A new public read almost always needs all four. The cardinal failure mode is cha
|
||||
4. **View layer.** Extend `base.html`; page CSS in `{% block extra_head %}`, page JS in `{% block extra_js %}`. Reuse partials and template globals. JS is ES6 modules, one class per file, instantiated on `app`.
|
||||
5. **Agent + docs layer (the most-forgotten step - do not skip).** `services/devii/actions/catalog.py` - add an `Action` tool if a user could ask Devii to do this (`requires_auth=False` for public reads). `docs_api.py` - every public/auth endpoint gets an `endpoint()` entry with params and a `sample_response`. `routers/docs/pages.py` `DOCS_PAGES` for prose pages. SEO (`seo.py`) - `base_seo_context` and any JSON-LD for a new public page; add to `routers/seo.py` sitemap if indexable.
|
||||
6. **Document.** `README.md` (product-facing, routes/config/dependencies/user-visible features), the relevant nested `CLAUDE.md` (any new mechanic, helper, pitfall, or cross-layer wiring), this root `CLAUDE.md` only when a NEW architectural rule or workflow step is introduced (not per feature).
|
||||
7. **Validate.** Confirm a clean import (`python -c "from devplacepy.main import app"`), check each touched language manually, grep for em-dash in touched files (neither the character nor its HTML entity - use a hyphen). **Never run the test suite unless the user explicitly asks.** Write new tests in the matching tier/path when asked, following the required patterns above.
|
||||
7. **Validate.** Confirm a clean import (`python -c "from devplacepy.main import app"`), check each touched language manually, grep for em-dash in touched files (neither the character nor its HTML entity - use a hyphen), and run `pyflakes`/`ruff check` on every touched file. **If the feature touches a spendable resource, a bounded state machine, or a read-then-write mutation reachable from more than one request path, also run the four-layer procedure in "Rigorous correctness verification" above** - it is the default for that shape of feature, not an optional extra. **Finish by running the full persisted test suite (`make test` - every tier, every test) and fixing any failure before the change is done.** Write new tests in the matching tier/path, following the required patterns above.
|
||||
|
||||
Failures at any implementation step block the workflow - never skip a failed step.
|
||||
|
||||
@@ -308,5 +347,7 @@ Gitea Actions workflow at `.gitea/workflows/test.yaml` runs on every push/PR to
|
||||
Single-host Docker Compose (`docker-compose.yml`): an **app** container (Uvicorn, 2 workers) behind an **nginx** container. Full operator reference is the admin-only `Production` docs section (`templates/docs/production*.html`); conventions that must not regress:
|
||||
|
||||
- **Shared DB and files = same as dev.** The app container bind-mounts the host project root (`.:/app`) and runs as `${DEVPLACE_UID}:${DEVPLACE_GID}` (default `1000`), so it reads/writes the same `data/devplace.db`, `data/uploads/`, `data/devii_*.db`, `data/keys/` (VAPID), and `data/locks/devplace-services.lock` as `make dev`. No `DEVPLACE_DATABASE_URL` override - `config.py` resolves an absolute path under the project's `data/` dir. WAL + the `flock` on `devplace-services.lock` make concurrent dev/prod safe and keep a single background-services owner. SQLite is local-file, so prod and dev must be the **same host**.
|
||||
- **Code updates need no rebuild** (bind-mounted source); rebuild only on `pyproject.toml` dependency changes. `.env` is git-ignored; `.env.example` is the committed template. The app reads `DEVPLACE_DATABASE_URL`, never `DATABASE_URL`.
|
||||
- **Code updates need no rebuild** (bind-mounted source); rebuild only on `pyproject.toml` dependency changes. Use **`make docker-reload`** (`restart app` + `up -d --wait`) to pick up new source - a bare `make docker-up` does **not** restart an unchanged container, so the running uvicorn keeps serving the code it imported at boot. `.env` is git-ignored; `.env.example` is the committed template. The app reads `DEVPLACE_DATABASE_URL`, never `DATABASE_URL`.
|
||||
- **Dockerfile layer order is load-bearing for build time.** The dependency layer (`pip install ".[bots]"` + `playwright install --with-deps chromium`, ~3GB and ~2.5 min) must depend on `pyproject.toml` **only**. `COPY devplacepy/` therefore comes *after* it, and the project itself is installed last with `pip install --no-deps --force-reinstall .`. hatchling needs the package directory to exist to build a wheel, so the dependency layer creates a placeholder `devplacepy/__init__.py` that the real `COPY` overwrites (verified: site-packages holds the full 39-entry package, not the stub). Copying source before the install inverts this and makes **every source edit** reinstall every dependency and re-download Chromium - measured 2m36s per source-only rebuild versus 7.4s with the correct order. Never move `COPY devplacepy/` above the dependency layer.
|
||||
- **nginx parity rules** (`nginx/nginx.conf.template`, rendered by `start.sh` via `envsubst` with an allow-list that preserves `$http_upgrade`): `/static/uploads/` must re-apply `nosniff` + a `Content-Disposition` via the `map $uri $upload_disposition` block (`inline` for safe image/video/audio extensions, `attachment` otherwise), mirroring `UploadStaticFiles.INLINE_MEDIA_EXTENSIONS` - an XSS control nginx would otherwise bypass, and the inline branch is what lets video play in production; `/devii/ws` needs the `map $http_upgrade $connection_upgrade` block and `Upgrade`/`Connection` headers or the Devii terminal cannot connect (every new WebSocket route needs its own nginx upgrade location - the catch-all `location /` strips upgrade headers); `client_max_body_size` comes from `NGINX_MAX_BODY_SIZE` (default `50m`) and must be `>= max_upload_size_mb` or uploads 413. nginx serves `devplacepy/static` via a read-only bind mount, so assets stay current without an image rebuild.
|
||||
- **Healthcheck cadence** (`docker-compose.yml` + `Dockerfile`, keep both in step): `start_period: 120s` is the grace window in which a failing probe does not count against `retries`; `start_interval: 2s` is how often the probe runs *inside* that window. Without `start_interval` the first probe only fires after the full `interval: 30s`, so a container ready in 5s still reports healthy at 30s and `depends_on: service_healthy` holds nginx back for no reason. The generous 120s start period is deliberate headroom for a cold page cache on a multi-GB database, not a measure of normal startup - normal startup is a few seconds. **Startup work is a per-worker, lock-serialized cost:** `lifespan` runs `init_db()` under an exclusive `init_lock()`, so every uvicorn worker pays it end to end, one after another, and total time-to-serving is `workers x init_db`. Never put a per-user or per-row scan in `init_db` - see the backfill convergence rule in `devplacepy/database/CLAUDE.md`.
|
||||
|
||||
+9
-6
@@ -18,21 +18,24 @@ RUN if [ "$INSTALL_DOCKER_CLI" = "true" ]; then \
|
||||
rm -rf /var/lib/apt/lists/* ; \
|
||||
fi
|
||||
|
||||
ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
|
||||
|
||||
COPY pyproject.toml .
|
||||
RUN mkdir -p devplacepy && touch devplacepy/__init__.py \
|
||||
&& pip install --no-cache-dir ".[bots]" \
|
||||
&& python -m playwright install --with-deps chromium \
|
||||
&& chmod -R a+rx /ms-playwright
|
||||
|
||||
COPY devplacepy/ devplacepy/
|
||||
|
||||
ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
|
||||
|
||||
RUN pip install --no-cache-dir ".[bots]" \
|
||||
&& python -m playwright install --with-deps chromium \
|
||||
&& chmod -R a+rx /ms-playwright
|
||||
RUN pip install --no-cache-dir --no-deps --force-reinstall .
|
||||
|
||||
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 --start-interval=2s \
|
||||
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/
|
||||
@@ -122,7 +138,7 @@ DOCKER_GID ?= $(shell stat -c '%g' /var/run/docker.sock 2>/dev/null)
|
||||
export DEVPLACE_DATA_DIR
|
||||
export DOCKER_GID
|
||||
|
||||
.PHONY: docker-build docker-up docker-down docker-logs docker-clean docker-prep ppy
|
||||
.PHONY: docker-build docker-up docker-reload docker-down docker-logs docker-clean docker-prep ppy
|
||||
|
||||
# Build the single shared container image every instance runs. Build once;
|
||||
# rebuild only when ppy.Dockerfile, the sudo shim, or pagent change.
|
||||
@@ -138,6 +154,10 @@ docker-build: docker-prep
|
||||
docker-up: docker-prep
|
||||
$(COMPOSE) up -d
|
||||
|
||||
docker-reload:
|
||||
$(COMPOSE) restart app
|
||||
$(COMPOSE) up -d --wait
|
||||
|
||||
docker-down:
|
||||
$(COMPOSE) down
|
||||
|
||||
|
||||
@@ -69,10 +69,11 @@ devplacepy/
|
||||
| `/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, 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. An opened conversation loads its 500 most recent messages; older history is retained in the database. 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) |
|
||||
@@ -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,18 +168,25 @@ 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`.
|
||||
@@ -491,6 +543,17 @@ disclosed only to administrators, while members and guests can see only the perc
|
||||
24-hour quota used. The `/devii/usage` endpoint returns that percentage and the day's turn count to
|
||||
everyone, and includes dollar figures only for administrators.
|
||||
|
||||
**Spend limits (Gateway page, `/admin/gateway`).** An administrator caps the rolling 24-hour
|
||||
gateway spend with quota rules scoped by any combination of caller role, individual user, and
|
||||
application reference (the `X-App-Reference` header), so a single application belonging to one
|
||||
user can be limited independently of that user's other traffic. The most specific matching rule
|
||||
wins; a caller over its cap gets `429`. Because a cap otherwise only lifts with the passage of
|
||||
time, each rule has a **Reset spend** action that clears what has been counted against it
|
||||
without deleting anything from the usage history the cost analytics are built on - the
|
||||
figures on the AI usage page stay intact, only the amount counted towards the limit is cleared.
|
||||
The **Reset all quotas** button on the AI usage page clears the assistant quotas and the gateway
|
||||
spend together. From the terminal: `devplace gateway quota list|set|delete|reset`.
|
||||
|
||||
Configuration on the Services tab:
|
||||
|
||||
| Parameter | Default | Purpose |
|
||||
@@ -580,7 +643,31 @@ 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:
|
||||
|
||||
@@ -604,6 +691,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
|
||||
@@ -899,11 +995,13 @@ Open `http://<host>:${PORT}` (default 10500). `make docker-logs` tails output; `
|
||||
|
||||
```bash
|
||||
git pull
|
||||
make docker-up # restart with new code (bind-mounted, no rebuild)
|
||||
make docker-reload # restart workers on the new code (bind-mounted, no rebuild)
|
||||
make docker-build && \
|
||||
make docker-up # only when dependencies in pyproject.toml change
|
||||
```
|
||||
|
||||
`make docker-reload` is the target for a source-only change: `docker compose up -d` leaves an unchanged container running, so the workers would keep serving the code they imported at boot. A rebuild after a source-only change costs about 7 seconds because the Dockerfile installs dependencies from `pyproject.toml` in a layer that no source edit invalidates.
|
||||
|
||||
The `make deploy` target fast-forwards the `production` branch (`git checkout production && git merge master && git push origin production`); pull that branch on the server.
|
||||
|
||||
### Container Manager wiring (what the overlay does)
|
||||
|
||||
@@ -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()
|
||||
@@ -42,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__ = [
|
||||
@@ -84,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,145 @@
|
||||
# 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 cmd_gateway_quota_reset(args):
|
||||
from devplacepy.services.openai_gateway import quota
|
||||
|
||||
try:
|
||||
payload = quota.QuotaResetIn(
|
||||
owner_kind=args.owner_kind,
|
||||
owner_id=args.owner_id,
|
||||
app_reference=args.app_reference,
|
||||
)
|
||||
except Exception as exc:
|
||||
print(f"Error: {exc}")
|
||||
sys.exit(1)
|
||||
scope = quota.reset(payload, created_by="cli")
|
||||
label = quota.scope_label(scope, fallback="every caller")
|
||||
_audit_cli(
|
||||
"gateway.quota.reset",
|
||||
f"CLI reset the gateway 24h spend for {label}",
|
||||
target_type="gateway_quota",
|
||||
target_uid=scope["uid"],
|
||||
metadata={
|
||||
"owner_kind": scope["owner_kind"],
|
||||
"owner_id": scope["owner_id"],
|
||||
"app_reference": scope["app_reference"],
|
||||
"reset_at": scope["reset_at"],
|
||||
},
|
||||
)
|
||||
print(f"Reset the rolling 24h spend for {label}")
|
||||
|
||||
|
||||
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)
|
||||
|
||||
quota_reset = quota_sub.add_parser(
|
||||
"reset",
|
||||
help="Clear the rolling-24h spend so a capped caller can call again (keeps the usage history)",
|
||||
)
|
||||
quota_reset.add_argument(
|
||||
"--owner-kind",
|
||||
choices=("internal", "key", "user", "admin", "anonymous"),
|
||||
help="Role to scope by. Omit to reset every role",
|
||||
)
|
||||
quota_reset.add_argument("--owner-id", help="Specific user uid to scope by. Omit for every caller")
|
||||
quota_reset.add_argument("--app-reference", help="App label to scope by. Omit for every app")
|
||||
quota_reset.set_defaults(func=cmd_gateway_quota_reset)
|
||||
@@ -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):
|
||||
@@ -251,7 +251,6 @@ def cmd_isslop_clear(args):
|
||||
|
||||
def cmd_isslop_analyze(args):
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
from devplacepy.config import ISSLOP_WORKSPACES_DIR, ensure_data_dirs
|
||||
from devplacepy.database import INTERNAL_GATEWAY_URL, internal_gateway_key
|
||||
|
||||
@@ -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)
|
||||
@@ -68,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`"
|
||||
|
||||
+43
-5
@@ -13,6 +13,9 @@ from devplacepy.database import (
|
||||
resolve_by_slug,
|
||||
get_users_by_uids,
|
||||
get_vote_counts,
|
||||
get_comment_counts_by_post_uids,
|
||||
paginate,
|
||||
STAR_TARGETS,
|
||||
get_user_votes,
|
||||
get_reactions_by_targets,
|
||||
get_user_bookmarks,
|
||||
@@ -20,6 +23,7 @@ from devplacepy.database import (
|
||||
get_poll_for_post,
|
||||
update_target_stars,
|
||||
clear_user_stars,
|
||||
clear_user_post_count,
|
||||
get_target_owner_uid,
|
||||
resolve_object_url,
|
||||
soft_delete,
|
||||
@@ -50,8 +54,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__)
|
||||
|
||||
@@ -162,6 +166,8 @@ 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
|
||||
|
||||
@@ -197,7 +203,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:
|
||||
@@ -615,6 +621,13 @@ 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
|
||||
@@ -647,7 +660,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": []}
|
||||
@@ -664,7 +681,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,
|
||||
@@ -703,3 +720,24 @@ def enrich_items(
|
||||
)
|
||||
enriched.append(entry)
|
||||
return enriched
|
||||
|
||||
|
||||
def get_project_devlog(
|
||||
project_uid: str, before: str | None = None, viewer: dict | None = None
|
||||
) -> tuple[list, str | None]:
|
||||
posts, next_cursor = paginate(
|
||||
get_table("posts"),
|
||||
before=before,
|
||||
viewer_uid=viewer["uid"] if viewer else None,
|
||||
project_uid=project_uid,
|
||||
)
|
||||
if not posts:
|
||||
return [], None
|
||||
|
||||
authors = get_users_by_uids([post["user_uid"] for post in posts])
|
||||
counts = get_comment_counts_by_post_uids([post["uid"] for post in posts])
|
||||
|
||||
enriched = enrich_items(
|
||||
posts, "post", authors, {"comment_count": counts}, user=viewer
|
||||
)
|
||||
return enriched, next_cursor
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -99,12 +99,23 @@ if "comments" not in db.tables:
|
||||
|
||||
- **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)`. All were verified to drop the `USE TEMP B-TREE FOR ORDER BY` step.
|
||||
- **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.
|
||||
|
||||
## Startup backfills must converge (hard rule)
|
||||
|
||||
`init_db()` runs inside `lifespan` under an exclusive `init_lock()`, **before the worker accepts a single request**, and every uvicorn worker runs it in turn. Time-to-serving is therefore `workers x init_db`, so anything added there is paid N times on every boot and every deploy. Two rules follow:
|
||||
|
||||
- **A backfill must be able to finish.** A backfill selects the rows that still need migrating and must leave them *not* selected afterwards. If a row can stay in the candidate set after a successful pass, the "one-time migration" is really a permanent per-boot scan that grows with the table. `_backfill_gamification` had exactly this bug: it selected `users.find(xp=0)` and then ran the full milestone sweep over *every* one of them, but a user with no content is awarded no XP, so they stayed at `xp=0` and were re-swept forever. At 7814 such users that was 36s of the 37.5s boot - about 140k queries that provably could not award anything, on every worker, on every restart.
|
||||
- **Never fan a per-row query out over a whole table at boot.** Compute the candidate set with a few set-based `GROUP BY`/`DISTINCT` queries first, then do per-row work only for rows that survive. `_milestone_candidates()` is the pattern: one `SELECT DISTINCT` per milestone source table (`MILESTONE_SOURCES`), unioned into a set, intersected with the pending users. A user absent from all of those tables scores 0 on every milestone metric and the lowest threshold is 5, so skipping them cannot change any award - verified by diffing the full `badges` table between the full sweep and the narrowed one (identical, 9800 rows, 22.6s -> 0.9s).
|
||||
|
||||
Watch for the same shape in `dataset` internals: `db.tables` is a live SQLAlchemy reflection, not a cached attribute. `get_user_stars` does one `in db.tables` check per `STAR_TARGETS` entry, so a per-user loop calling it re-reflects the whole table list on every iteration - 78667 reflections costing 17.7s in the profile above. Hoist `db.tables` into a local when looping.
|
||||
|
||||
Profile with the real database before and after any change here (`cProfile` around `init_db()` against a copy of `data/devplace.db`); a synthetic or empty DB hides every one of these costs.
|
||||
|
||||
## 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`.
|
||||
@@ -180,7 +191,7 @@ Site settings are seeded on startup (`site_settings` table):
|
||||
| `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`). 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).
|
||||
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.
|
||||
|
||||
|
||||
@@ -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, 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 .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
|
||||
@@ -19,8 +37,8 @@ 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, 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",
|
||||
@@ -81,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",
|
||||
@@ -208,6 +228,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",
|
||||
@@ -215,6 +236,8 @@ __all__ = [
|
||||
"delete_attachments",
|
||||
"_delete_attachment_file",
|
||||
"get_user_media",
|
||||
"get_user_attachments",
|
||||
"get_user_attachment",
|
||||
"get_deleted_media",
|
||||
"_stats_cache",
|
||||
"get_site_stats",
|
||||
@@ -231,3 +254,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}")
|
||||
|
||||
@@ -185,3 +185,5 @@ 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)
|
||||
|
||||
|
||||
|
||||
@@ -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,17 @@ 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=15, 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)
|
||||
@@ -26,12 +32,13 @@ def _ranked_authors() -> list:
|
||||
cached = _authors_cache.get("ranked")
|
||||
if cached is not None:
|
||||
return cached
|
||||
tables = db.tables
|
||||
sources = [
|
||||
(target_type, table_name)
|
||||
for target_type, table_name in VOTABLE_TARGETS.items()
|
||||
if table_name in db.tables
|
||||
if table_name in tables
|
||||
]
|
||||
if "votes" not in db.tables or not sources:
|
||||
if "votes" not in tables or not sources:
|
||||
_authors_cache.set("ranked", [])
|
||||
return []
|
||||
target_union = " UNION ALL ".join(
|
||||
@@ -95,12 +102,13 @@ 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:
|
||||
tables = db.tables
|
||||
if "votes" not in tables:
|
||||
return 0
|
||||
target_union = " UNION ALL ".join(
|
||||
f"SELECT uid, '{target_type}' AS target_type FROM {table_name} WHERE user_uid = :u AND deleted_at IS NULL"
|
||||
for target_type, table_name in VOTABLE_TARGETS.items()
|
||||
if table_name in db.tables
|
||||
if table_name in tables
|
||||
)
|
||||
if not target_union:
|
||||
return 0
|
||||
@@ -148,26 +156,29 @@ def delete_engagement(target_type: str, target_uids: list) -> None:
|
||||
uids = [uid for uid in (target_uids or []) if uid]
|
||||
if not uids:
|
||||
return
|
||||
if "reactions" in db.tables:
|
||||
tables = db.tables
|
||||
if "reactions" in 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,
|
||||
)
|
||||
if "bookmarks" in db.tables:
|
||||
with db:
|
||||
db.query(
|
||||
f"DELETE FROM reactions WHERE target_type=:tt AND target_uid IN ({placeholders})",
|
||||
**params,
|
||||
)
|
||||
if "bookmarks" in 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,
|
||||
)
|
||||
if target_type == "post" and "polls" in db.tables:
|
||||
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 tables:
|
||||
for uid in uids:
|
||||
for poll in db["polls"].find(post_uid=uid):
|
||||
if "poll_votes" in db.tables:
|
||||
if "poll_votes" in tables:
|
||||
db["poll_votes"].delete(poll_uid=poll["uid"])
|
||||
if "poll_options" in db.tables:
|
||||
if "poll_options" in tables:
|
||||
db["poll_options"].delete(poll_uid=poll["uid"])
|
||||
db["polls"].delete(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
|
||||
+574
-46
@@ -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
|
||||
@@ -42,6 +42,7 @@ def init_db():
|
||||
_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"):
|
||||
@@ -183,8 +184,10 @@ def init_db():
|
||||
)
|
||||
_index(db, "badges", "idx_badges_user", ["user_uid"])
|
||||
_index(db, "badges", "idx_badges_user_name", ["user_uid", "badge_name"])
|
||||
_index(db, "follows", "idx_follows_follower", ["follower_uid"])
|
||||
_index(db, "follows", "idx_follows_following", ["following_uid"])
|
||||
_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", ""),
|
||||
@@ -356,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:
|
||||
@@ -363,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(
|
||||
@@ -386,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"]
|
||||
)
|
||||
@@ -430,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 (
|
||||
@@ -461,16 +527,26 @@ 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"])
|
||||
@@ -508,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"])
|
||||
@@ -559,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}")
|
||||
|
||||
@@ -582,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}")
|
||||
|
||||
@@ -605,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}")
|
||||
|
||||
@@ -628,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}")
|
||||
|
||||
@@ -651,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", ""),
|
||||
@@ -727,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}")
|
||||
|
||||
@@ -745,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}")
|
||||
|
||||
@@ -934,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", ""),
|
||||
):
|
||||
@@ -958,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 (
|
||||
@@ -965,6 +1142,7 @@ def init_db():
|
||||
("farm_uid", ""),
|
||||
("user_uid", ""),
|
||||
("day", ""),
|
||||
("scope", "daily"),
|
||||
("slot_index", 0),
|
||||
("kind", ""),
|
||||
("label", ""),
|
||||
@@ -972,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 (
|
||||
@@ -990,6 +1172,7 @@ def init_db():
|
||||
("planted_at", ""),
|
||||
("ready_at", ""),
|
||||
("watered_by", "[]"),
|
||||
("raided_fraction", 0.0),
|
||||
("created_at", ""),
|
||||
("updated_at", ""),
|
||||
):
|
||||
@@ -997,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,
|
||||
@@ -1155,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)
|
||||
@@ -1164,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()
|
||||
@@ -1210,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:
|
||||
@@ -1218,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"):
|
||||
@@ -1234,12 +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"
|
||||
@@ -1250,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
|
||||
@@ -1262,6 +1761,29 @@ def backfill_api_keys() -> int:
|
||||
return updated
|
||||
|
||||
|
||||
MILESTONE_SOURCES = (
|
||||
("posts", "user_uid"),
|
||||
("comments", "user_uid"),
|
||||
("projects", "user_uid"),
|
||||
("gists", "user_uid"),
|
||||
("follows", "follower_uid"),
|
||||
("follows", "following_uid"),
|
||||
("user_activity", "user_uid"),
|
||||
)
|
||||
|
||||
|
||||
def _milestone_candidates() -> set:
|
||||
tables = db.tables
|
||||
candidates = set()
|
||||
for table, column in MILESTONE_SOURCES:
|
||||
if table not in tables:
|
||||
continue
|
||||
for row in db.query(f"SELECT DISTINCT {column} AS uid FROM {table}"):
|
||||
if row["uid"]:
|
||||
candidates.add(row["uid"])
|
||||
return candidates
|
||||
|
||||
|
||||
def _backfill_gamification():
|
||||
if "users" not in db.tables:
|
||||
return
|
||||
@@ -1325,6 +1847,12 @@ def _backfill_gamification():
|
||||
)
|
||||
|
||||
_authors_cache.clear()
|
||||
for user in pending:
|
||||
candidates = _milestone_candidates()
|
||||
checked = [user for user in pending if user["uid"] in candidates]
|
||||
for user in checked:
|
||||
check_milestone_badges(user["uid"])
|
||||
logger.info(f"Gamification backfill processed {len(pending)} users")
|
||||
logger.info(
|
||||
f"Gamification backfill processed {len(pending)} users, "
|
||||
f"{len(checked)} with milestone-eligible activity"
|
||||
)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -41,6 +41,12 @@ SOFT_DELETE_TABLES = [
|
||||
"email_accounts",
|
||||
"user_relations",
|
||||
"seo_metadata",
|
||||
"awards",
|
||||
"quizzes",
|
||||
"quiz_questions",
|
||||
"quiz_options",
|
||||
"quiz_attempts",
|
||||
"quiz_answers",
|
||||
]
|
||||
|
||||
|
||||
@@ -93,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)
|
||||
|
||||
|
||||
@@ -160,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
|
||||
|
||||
|
||||
@@ -181,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:
|
||||
@@ -71,6 +74,15 @@ def get_online_users(cutoff_iso: str, limit: int = 30) -> list:
|
||||
)
|
||||
|
||||
|
||||
def _can_hold_primary_admin(row, tracks_active):
|
||||
if row.get("deleted_at"):
|
||||
return False
|
||||
if not tracks_active:
|
||||
return True
|
||||
is_active = row.get("is_active")
|
||||
return is_active is None or bool(is_active)
|
||||
|
||||
|
||||
def get_primary_admin_uid():
|
||||
sync_local_cache("admins", _admins_cache)
|
||||
cached = _admins_cache.get("primary")
|
||||
@@ -80,11 +92,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",
|
||||
@@ -519,6 +613,73 @@ 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-reset",
|
||||
method="POST",
|
||||
path="/admin/gateway/quota-resets",
|
||||
title="Reset the AI gateway 24h spend",
|
||||
summary=(
|
||||
"Clear the counted rolling-24h spend for a scope so a capped caller can call "
|
||||
"again, without deleting any usage history (the cost analytics stay intact). "
|
||||
"Scope it exactly like a quota rule; leaving all three dimensions blank resets "
|
||||
"every caller. Only spend recorded before the reset is cleared - new calls "
|
||||
"count again immediately against the same limit."
|
||||
),
|
||||
auth="admin",
|
||||
destructive=True,
|
||||
params=[
|
||||
field("owner_kind", "json", "string", False, "user", "internal, key, user, admin, or anonymous. Blank = every role."),
|
||||
field("owner_id", "json", "string", False, "", "Specific user uid. Blank = every caller."),
|
||||
field("app_reference", "json", "string", False, "typosaurus", "App label (the X-App-Reference header). Blank = every app."),
|
||||
],
|
||||
),
|
||||
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 +737,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 +943,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"},
|
||||
),
|
||||
],
|
||||
}
|
||||
|
||||
@@ -1,580 +0,0 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from .._shared import endpoint, field
|
||||
|
||||
GROUP = {
|
||||
"slug": "containers",
|
||||
"title": "Container Manager",
|
||||
"admin": True,
|
||||
"intro": """
|
||||
# Container Manager
|
||||
|
||||
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.
|
||||
""",
|
||||
"endpoints": [
|
||||
endpoint(
|
||||
id="containers-page",
|
||||
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.",
|
||||
auth="admin",
|
||||
interactive=True,
|
||||
params=[
|
||||
field(
|
||||
"project_slug",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"PROJECT_SLUG",
|
||||
"Project slug or uid.",
|
||||
)
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="containers-admin-index",
|
||||
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.",
|
||||
auth="admin",
|
||||
interactive=True,
|
||||
),
|
||||
endpoint(
|
||||
id="containers-admin-data",
|
||||
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.",
|
||||
auth="admin",
|
||||
sample_response={
|
||||
"instances": [
|
||||
{
|
||||
"uid": "INSTANCE_UID",
|
||||
"name": "staging",
|
||||
"status": "running",
|
||||
"project_slug": "PROJECT_SLUG",
|
||||
"project_title": "My Project",
|
||||
"ingress_slug": "my-service",
|
||||
"restart_policy": "always",
|
||||
}
|
||||
]
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="containers-admin-instance",
|
||||
method="GET",
|
||||
path="/admin/containers/{uid}",
|
||||
title="Instance detail page",
|
||||
summary="The dedicated detail page for one instance (lifecycle, logs, metrics, terminal, schedules, ingress, sync).",
|
||||
auth="admin",
|
||||
interactive=True,
|
||||
params=[
|
||||
field(
|
||||
"uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
|
||||
)
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="containers-admin-edit-page",
|
||||
method="GET",
|
||||
path="/admin/containers/{uid}/edit",
|
||||
title="Edit instance page",
|
||||
summary="The edit page for one instance (run-as user, boot language/script/command, restart policy, start-on-boot, limits).",
|
||||
auth="admin",
|
||||
interactive=True,
|
||||
params=[
|
||||
field(
|
||||
"uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
|
||||
)
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="containers-create-instance",
|
||||
method="POST",
|
||||
path="/projects/{project_slug}/containers/instances",
|
||||
title="Create an instance",
|
||||
summary="Create and (by default) start an instance; it runs the shared ppy image with the project workspace mounted at /app.",
|
||||
auth="admin",
|
||||
encoding="form",
|
||||
destructive=True,
|
||||
params=[
|
||||
field(
|
||||
"project_slug",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"PROJECT_SLUG",
|
||||
"Project slug or uid.",
|
||||
),
|
||||
field("name", "form", "string", True, "staging", "Instance name."),
|
||||
field(
|
||||
"boot_command",
|
||||
"form",
|
||||
"string",
|
||||
False,
|
||||
"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("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(
|
||||
"env",
|
||||
"form",
|
||||
"textarea",
|
||||
False,
|
||||
"KEY=VALUE",
|
||||
"Env vars, one KEY=VALUE per line.",
|
||||
),
|
||||
field(
|
||||
"ports",
|
||||
"form",
|
||||
"string",
|
||||
False,
|
||||
"80",
|
||||
"Port maps. Bare container port auto-assigns a unique host port above 20000; host:container pins one.",
|
||||
),
|
||||
field("cpu_limit", "form", "string", False, "1.5", "CPU limit."),
|
||||
field(
|
||||
"mem_limit", "form", "string", False, "512m", "Memory limit."
|
||||
),
|
||||
field(
|
||||
"restart_policy",
|
||||
"form",
|
||||
"enum",
|
||||
False,
|
||||
"never",
|
||||
"Restart policy.",
|
||||
["never", "always", "on-failure", "unless-stopped"],
|
||||
),
|
||||
field("start_on_boot", "form", "boolean", False, "false", "Force running whenever the container service starts."),
|
||||
field(
|
||||
"ingress_slug",
|
||||
"form",
|
||||
"string",
|
||||
False,
|
||||
"my-service",
|
||||
"Publish at /p/<slug> (optional).",
|
||||
),
|
||||
field(
|
||||
"ingress_port",
|
||||
"form",
|
||||
"integer",
|
||||
False,
|
||||
"8899",
|
||||
"Container port to publish (must be a mapped port).",
|
||||
),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="containers-ingress",
|
||||
method="GET",
|
||||
path="/p/{slug}",
|
||||
title="Container ingress proxy",
|
||||
summary="Public reverse proxy (HTTP and WebSocket) to a running instance published via ingress_slug. The /p/<slug> prefix is stripped before forwarding.",
|
||||
auth="public",
|
||||
interactive=True,
|
||||
params=[
|
||||
field(
|
||||
"slug",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"my-service",
|
||||
"The instance's ingress_slug.",
|
||||
)
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="containers-instance-action",
|
||||
method="POST",
|
||||
path="/projects/{project_slug}/containers/instances/{uid}/{action}",
|
||||
title="Instance lifecycle",
|
||||
summary="start, stop, restart, pause, or resume an instance (flips desired state).",
|
||||
auth="admin",
|
||||
destructive=True,
|
||||
params=[
|
||||
field(
|
||||
"project_slug",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"PROJECT_SLUG",
|
||||
"Project slug or uid.",
|
||||
),
|
||||
field(
|
||||
"uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
|
||||
),
|
||||
field(
|
||||
"action",
|
||||
"path",
|
||||
"enum",
|
||||
True,
|
||||
"start",
|
||||
"Lifecycle action.",
|
||||
["start", "stop", "restart", "pause", "resume"],
|
||||
),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="containers-instance-logs",
|
||||
method="GET",
|
||||
path="/projects/{project_slug}/containers/instances/{uid}/logs",
|
||||
title="Instance logs",
|
||||
summary="Recent docker logs of a running instance.",
|
||||
auth="admin",
|
||||
params=[
|
||||
field(
|
||||
"project_slug",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"PROJECT_SLUG",
|
||||
"Project slug or uid.",
|
||||
),
|
||||
field(
|
||||
"uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
|
||||
),
|
||||
field("tail", "query", "integer", False, "200", "Number of lines."),
|
||||
],
|
||||
sample_response={"logs": "..."},
|
||||
),
|
||||
endpoint(
|
||||
id="containers-instance-sync",
|
||||
method="POST",
|
||||
path="/projects/{project_slug}/containers/instances/{uid}/sync",
|
||||
title="Sync workspace",
|
||||
summary="Run a one-shot bidirectional newer-wins sync between the project files and the container workspace.",
|
||||
auth="admin",
|
||||
destructive=True,
|
||||
params=[
|
||||
field(
|
||||
"project_slug",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"PROJECT_SLUG",
|
||||
"Project slug or uid.",
|
||||
),
|
||||
field(
|
||||
"uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
|
||||
),
|
||||
],
|
||||
sample_response={"exported": 3, "imported": 1},
|
||||
),
|
||||
endpoint(
|
||||
id="containers-instance-delete",
|
||||
method="POST",
|
||||
path="/projects/{project_slug}/containers/instances/{uid}/delete",
|
||||
title="Delete instance",
|
||||
summary="Remove a container instance and mark its container for removal.",
|
||||
auth="admin",
|
||||
destructive=True,
|
||||
params=[
|
||||
field(
|
||||
"project_slug",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"PROJECT_SLUG",
|
||||
"Project slug or uid.",
|
||||
),
|
||||
field(
|
||||
"uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
|
||||
),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="containers-instance-exec",
|
||||
method="POST",
|
||||
path="/projects/{project_slug}/containers/instances/{uid}/exec",
|
||||
title="Exec a command",
|
||||
summary="Run a one-shot command inside a running instance and return its output.",
|
||||
auth="admin",
|
||||
encoding="form",
|
||||
destructive=True,
|
||||
params=[
|
||||
field(
|
||||
"project_slug",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"PROJECT_SLUG",
|
||||
"Project slug or uid.",
|
||||
),
|
||||
field(
|
||||
"uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
|
||||
),
|
||||
field(
|
||||
"command",
|
||||
"form",
|
||||
"string",
|
||||
True,
|
||||
"ls -la /app",
|
||||
"Shell command to run (via /bin/sh -c).",
|
||||
),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="containers-instance-data",
|
||||
method="GET",
|
||||
path="/projects/{project_slug}/containers/instances/{uid}",
|
||||
title="Instance detail data",
|
||||
summary="Return the full instance row plus runtime info as JSON.",
|
||||
auth="admin",
|
||||
params=[
|
||||
field(
|
||||
"project_slug",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"PROJECT_SLUG",
|
||||
"Project slug or uid.",
|
||||
),
|
||||
field(
|
||||
"uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
|
||||
),
|
||||
],
|
||||
sample_response={"uid": "INSTANCE_UID", "name": "staging", "status": "running"},
|
||||
),
|
||||
endpoint(
|
||||
id="containers-instance-metrics",
|
||||
method="GET",
|
||||
path="/projects/{project_slug}/containers/instances/{uid}/metrics",
|
||||
title="Instance metrics",
|
||||
summary="Return recent metrics ring-buffer and aggregated stats for a running instance.",
|
||||
auth="admin",
|
||||
params=[
|
||||
field(
|
||||
"project_slug",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"PROJECT_SLUG",
|
||||
"Project slug or uid.",
|
||||
),
|
||||
field(
|
||||
"uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
|
||||
),
|
||||
],
|
||||
sample_response={"metrics": [], "stats": {}},
|
||||
),
|
||||
endpoint(
|
||||
id="containers-instance-schedules",
|
||||
method="POST",
|
||||
path="/projects/{project_slug}/containers/instances/{uid}/schedules",
|
||||
title="Create a schedule",
|
||||
summary="Attach a cron, one-time, interval, or delay schedule to an instance.",
|
||||
auth="admin",
|
||||
encoding="form",
|
||||
destructive=True,
|
||||
params=[
|
||||
field(
|
||||
"project_slug",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"PROJECT_SLUG",
|
||||
"Project slug or uid.",
|
||||
),
|
||||
field(
|
||||
"uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
|
||||
),
|
||||
field(
|
||||
"action",
|
||||
"form",
|
||||
"string",
|
||||
True,
|
||||
"start",
|
||||
"Lifecycle action to run on schedule (start, stop, restart).",
|
||||
),
|
||||
field(
|
||||
"kind",
|
||||
"form",
|
||||
"string",
|
||||
True,
|
||||
"cron",
|
||||
"Schedule kind: cron, once, interval, or delay.",
|
||||
),
|
||||
field(
|
||||
"cron",
|
||||
"form",
|
||||
"string",
|
||||
False,
|
||||
"0 * * * *",
|
||||
"Cron expression (when kind is cron).",
|
||||
),
|
||||
field(
|
||||
"run_at",
|
||||
"form",
|
||||
"string",
|
||||
False,
|
||||
"2026-01-01T00:00:00",
|
||||
"ISO timestamp for a one-time run (when kind is once).",
|
||||
),
|
||||
field(
|
||||
"delay_seconds",
|
||||
"form",
|
||||
"integer",
|
||||
False,
|
||||
"60",
|
||||
"Seconds to wait before a single run (when kind is delay).",
|
||||
),
|
||||
field(
|
||||
"every_seconds",
|
||||
"form",
|
||||
"integer",
|
||||
False,
|
||||
"300",
|
||||
"Interval in seconds between runs (when kind is interval).",
|
||||
),
|
||||
field(
|
||||
"max_runs",
|
||||
"form",
|
||||
"integer",
|
||||
False,
|
||||
"10",
|
||||
"Optional cap on the number of runs.",
|
||||
),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="containers-instance-schedule-delete",
|
||||
method="POST",
|
||||
path="/projects/{project_slug}/containers/instances/{uid}/schedules/{sid}/delete",
|
||||
title="Delete a schedule",
|
||||
summary="Remove a schedule from an instance.",
|
||||
auth="admin",
|
||||
destructive=True,
|
||||
params=[
|
||||
field(
|
||||
"project_slug",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"PROJECT_SLUG",
|
||||
"Project slug or uid.",
|
||||
),
|
||||
field(
|
||||
"uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
|
||||
),
|
||||
field(
|
||||
"sid", "path", "string", True, "SCHEDULE_UID", "Schedule uid."
|
||||
),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="containers-admin-create",
|
||||
method="POST",
|
||||
path="/admin/containers/create",
|
||||
title="Admin create instance",
|
||||
summary="Create an instance from the admin Containers page: project search-select, run-as user, boot language/script, restart policy, start-on-boot, plus the usual options.",
|
||||
auth="admin",
|
||||
encoding="form",
|
||||
destructive=True,
|
||||
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("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."),
|
||||
field("restart_policy", "form", "enum", False, "never", "Restart policy.", ["never", "always", "on-failure", "unless-stopped"]),
|
||||
field("start_on_boot", "form", "boolean", False, "false", "Force running whenever the container service starts."),
|
||||
field("env", "form", "textarea", False, "KEY=VALUE", "Env vars, one KEY=VALUE per line."),
|
||||
field("ports", "form", "string", False, "80", "Port maps; bare container port auto-assigns a host port above 20000."),
|
||||
field("cpu_limit", "form", "string", False, "1.5", "CPU limit."),
|
||||
field("mem_limit", "form", "string", False, "512m", "Memory limit."),
|
||||
field("ingress_slug", "form", "string", False, "my-service", "Publish at /p/<slug> (optional)."),
|
||||
field("ingress_port", "form", "integer", False, "8899", "Container port to publish."),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="containers-admin-edit",
|
||||
method="POST",
|
||||
path="/admin/containers/{uid}/edit",
|
||||
title="Admin edit instance",
|
||||
summary="Update an instance's run-as user, boot language/script/command, restart policy, start-on-boot flag, and resource limits.",
|
||||
auth="admin",
|
||||
encoding="form",
|
||||
destructive=True,
|
||||
params=[
|
||||
field("uid", "path", "string", True, "INSTANCE_UID", "Instance uid."),
|
||||
field("run_as_uid", "form", "string", False, "USER_UID", "Run-as user uid (identity + API key only)."),
|
||||
field("boot_language", "form", "enum", False, "none", "Boot source language.", ["none", "python", "bash"]),
|
||||
field("boot_script", "form", "textarea", False, "print('hi')", "Boot source code."),
|
||||
field("boot_command", "form", "string", False, "python app.py", "Fallback boot command."),
|
||||
field("restart_policy", "form", "enum", False, "never", "Restart policy.", ["never", "always", "on-failure", "unless-stopped"]),
|
||||
field("start_on_boot", "form", "boolean", False, "false", "Force running on container-service boot."),
|
||||
field("cpu_limit", "form", "string", False, "1.5", "CPU limit."),
|
||||
field("mem_limit", "form", "string", False, "512m", "Memory limit."),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="containers-admin-action",
|
||||
method="POST",
|
||||
path="/admin/containers/{uid}/{action}",
|
||||
title="Admin instance lifecycle",
|
||||
summary="start, stop, restart, pause, or resume an instance from the admin Containers page (flips desired state).",
|
||||
auth="admin",
|
||||
destructive=True,
|
||||
params=[
|
||||
field("uid", "path", "string", True, "INSTANCE_UID", "Instance uid."),
|
||||
field("action", "path", "enum", True, "start", "Lifecycle action.", ["start", "stop", "restart", "pause", "resume"]),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="containers-admin-sync",
|
||||
method="POST",
|
||||
path="/admin/containers/{uid}/sync",
|
||||
title="Admin bidirectional sync",
|
||||
summary="Run a one-shot bidirectional newer-wins sync between the project files and the container workspace.",
|
||||
auth="admin",
|
||||
destructive=True,
|
||||
params=[
|
||||
field("uid", "path", "string", True, "INSTANCE_UID", "Instance uid."),
|
||||
],
|
||||
sample_response={"exported": 3, "imported": 1},
|
||||
),
|
||||
endpoint(
|
||||
id="containers-admin-delete",
|
||||
method="POST",
|
||||
path="/admin/containers/{uid}/delete",
|
||||
title="Admin delete instance",
|
||||
summary="Soft-delete an instance and mark its container for removal.",
|
||||
auth="admin",
|
||||
destructive=True,
|
||||
params=[
|
||||
field("uid", "path", "string", True, "INSTANCE_UID", "Instance uid."),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="containers-admin-project-search",
|
||||
method="GET",
|
||||
path="/admin/containers/projects/search",
|
||||
title="Admin project search",
|
||||
summary="Search projects by title for the admin create form (returns uid, slug, title).",
|
||||
auth="admin",
|
||||
params=[
|
||||
field("q", "query", "string", False, "api", "Title fragment."),
|
||||
],
|
||||
sample_response={"results": [{"uid": "PROJECT_UID", "slug": "PROJECT_SLUG", "title": "My Project"}]},
|
||||
),
|
||||
endpoint(
|
||||
id="containers-admin-user-search",
|
||||
method="GET",
|
||||
path="/admin/containers/users/search",
|
||||
title="Admin run-as user search",
|
||||
summary="Search users by username for the run-as-user select (returns uid, username).",
|
||||
auth="admin",
|
||||
params=[
|
||||
field("q", "query", "string", False, "alice", "Username fragment."),
|
||||
],
|
||||
sample_response={"results": [{"uid": "USER_UID", "username": "alice"}]},
|
||||
),
|
||||
],
|
||||
}
|
||||
@@ -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,9 +41,12 @@ four ways to sign requests.
|
||||
method="GET",
|
||||
path="/profile/{username}",
|
||||
title="View a profile",
|
||||
summary="Render a user profile, including an online-presence indicator (JSON exposes profile_online and profile_user.last_seen). Returns an HTML page.",
|
||||
summary="Render a user profile, including an online-presence indicator (JSON exposes profile_online, profile_user.last_seen, xp_next_level, and xp_progress_pct). Returns an HTML page.",
|
||||
auth="public",
|
||||
interactive=True,
|
||||
notes=[
|
||||
"Level progress: `xp_next_level = level * 100` (total XP needed), `xp_progress_pct = xp % 100` (percentage towards next level). Both are also embedded in `profile_user`.",
|
||||
],
|
||||
params=[
|
||||
field(
|
||||
"username",
|
||||
@@ -60,7 +63,7 @@ four ways to sign requests.
|
||||
False,
|
||||
"posts",
|
||||
"Profile tab.",
|
||||
["posts", "activity", "followers", "following", "media"],
|
||||
["posts", "activity", "followers", "following", "media", "awards"],
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -136,7 +139,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 +153,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 +240,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 +304,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",
|
||||
@@ -650,6 +734,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",
|
||||
@@ -681,3 +796,4 @@ four ways to sign requests.
|
||||
),
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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},
|
||||
),
|
||||
|
||||
+41
-3
@@ -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,6 +99,7 @@ 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
|
||||
@@ -255,6 +260,7 @@ 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())
|
||||
@@ -281,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()
|
||||
|
||||
@@ -428,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")
|
||||
@@ -451,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)
|
||||
|
||||
|
||||
@@ -580,6 +595,15 @@ async def track_presence(request: Request, call_next):
|
||||
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()
|
||||
@@ -589,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)
|
||||
@@ -681,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",
|
||||
@@ -691,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,
|
||||
)
|
||||
|
||||
+323
-11
@@ -1,12 +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):
|
||||
@@ -157,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] = []
|
||||
|
||||
@@ -172,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):
|
||||
@@ -238,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"]
|
||||
@@ -253,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):
|
||||
@@ -279,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)
|
||||
@@ -376,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):
|
||||
@@ -439,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):
|
||||
@@ -459,9 +484,19 @@ 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)
|
||||
@@ -551,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)
|
||||
@@ -569,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
|
||||
|
||||
+84
-16
@@ -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,7 +75,11 @@ _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"}
|
||||
_TRAILING_PUNCT_RE = re.compile(r"[.,;:!?)\]}\"']+$")
|
||||
_TITLE_INLINE_TAGS = {
|
||||
"b", "strong", "i", "em", "code", "del", "s", "mark", "sub", "sup", "span", "br",
|
||||
}
|
||||
@@ -102,7 +115,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:
|
||||
@@ -117,6 +140,11 @@ def _alt_from_url(url: str) -> str:
|
||||
|
||||
|
||||
def _embed_url(url: str) -> str:
|
||||
trail = ""
|
||||
punct_match = _TRAILING_PUNCT_RE.search(url)
|
||||
if punct_match:
|
||||
trail = punct_match.group()
|
||||
url = url[: punct_match.start()]
|
||||
youtube = _YOUTUBE_RE.search(url)
|
||||
if youtube:
|
||||
video_id = youtube.group(1)
|
||||
@@ -124,28 +152,42 @@ def _embed_url(url: str) -> str:
|
||||
f'<div class="embed-youtube"><iframe '
|
||||
f'src="https://www.youtube.com/embed/{video_id}" '
|
||||
f'frameborder="0" allowfullscreen allow="{_YOUTUBE_ALLOW}">'
|
||||
f"</iframe></div>"
|
||||
f"</iframe></div>{trail}"
|
||||
)
|
||||
escaped = html.escape(url, quote=True)
|
||||
if _IMAGE_RE.search(url):
|
||||
alt = html.escape(_alt_from_url(url), quote=True)
|
||||
return f'<img src="{escaped}" alt="{alt}" loading="lazy" data-lightbox>'
|
||||
return f'<img src="{escaped}" alt="{alt}" loading="lazy" data-lightbox>{trail}'
|
||||
if _VIDEO_RE.search(url):
|
||||
return f'<video src="{escaped}" controls preload="metadata"></video>'
|
||||
return f'<video src="{escaped}" controls preload="metadata"></video>{trail}'
|
||||
if _AUDIO_RE.search(url):
|
||||
return f'<audio src="{escaped}" controls preload="metadata"></audio>'
|
||||
return f'<audio src="{escaped}" controls preload="metadata"></audio>{trail}'
|
||||
return (
|
||||
f'<a href="{escaped}" target="_blank" rel="noopener noreferrer">'
|
||||
f"{html.escape(url)}</a>"
|
||||
f"{html.escape(url)}</a>{trail}"
|
||||
)
|
||||
|
||||
|
||||
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 +198,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 +234,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 +260,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 +294,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:
|
||||
|
||||
@@ -14,8 +14,8 @@ Prefixes are wired in `main.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), `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`, `POST /send` (no-JS fallback), and `WS /messages/ws` (NOT lock-owner gated - accepts on every worker; closes `1008` for guests). The WS adds live bidirectional delivery, typing, read receipts, and in-process presence on top of the existing `messages` table; 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) and broadcasts the FINAL corrected/modified content (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` |
|
||||
| `/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`) |
|
||||
@@ -25,12 +25,12 @@ Prefixes are wired in `main.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`) 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` |
|
||||
| `/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 |
|
||||
| `/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` |
|
||||
@@ -43,7 +43,8 @@ Prefixes are wired in `main.py`:
|
||||
| `/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` |
|
||||
| `/game` | game/ package - the **Code Farm** idle game (`index.py` + `farm.py`): `GET /game` (page), `GET /game/state`, `GET /game/leaderboard`, `POST /game/{plant,harvest,buy-plot,upgrade,fertilize,daily,perk,prestige,legacy,quests/claim}`, plus social `GET /game/farm/{username}` and `POST /game/farm/{username}/{water,steal}` |
|
||||
| `/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` |
|
||||
@@ -188,7 +189,7 @@ News articles have an internal detail page at `/news/{slug}` with full comment s
|
||||
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 hero (`.landing-hero-user`): avatar, "Welcome back, {username}", a `Go to your feed` CTA, a Posts/Stars/Level stat strip (`user_post_count` + the user dict's `stars`/`level`), and quick links. Styles live in `.landing-hero-user`/`.landing-welcome`/`.landing-stats`/`.landing-quicklinks` in `static/css/landing.css`.
|
||||
- **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`).
|
||||
|
||||
@@ -286,7 +287,8 @@ All SEO features are implemented across the following locations:
|
||||
## Engagement: reactions, bookmarks, polls, contribution heatmap
|
||||
|
||||
### Emoji reactions
|
||||
- Curated palette only: `REACTION_EMOJI` in `constants.py` (registered as a template global). `ReactionForm` rejects anything outside it; free-text emoji are not allowed.
|
||||
- **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`.
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -6,6 +6,7 @@ from devplacepy.utils import require_admin
|
||||
from devplacepy.responses import action_result
|
||||
from devplacepy.services.audit import record as audit
|
||||
from devplacepy.services.manager import service_manager
|
||||
from devplacepy.services.openai_gateway import quota
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
@@ -34,14 +35,20 @@ async def admin_reset_all_ai_quota(request: Request):
|
||||
admin = require_admin(request)
|
||||
devii = service_manager.get_service("devii")
|
||||
removed = devii.reset_all_quotas() if devii is not None else 0
|
||||
gateway = quota.reset(created_by=admin["uid"])
|
||||
logger.info(
|
||||
f"Admin {admin['username']} reset ALL AI quotas ({removed} ledger rows)"
|
||||
f"Admin {admin['username']} reset ALL AI quotas "
|
||||
f"({removed} Devii ledger rows, gateway watermark {gateway['reset_at']})"
|
||||
)
|
||||
audit.record(
|
||||
request,
|
||||
"admin.ai_quota.reset_all",
|
||||
user=admin,
|
||||
metadata={"rows_removed": removed},
|
||||
summary=f"admin {admin['username']} reset all AI quotas",
|
||||
metadata={"rows_removed": removed, "gateway_reset_at": gateway["reset_at"]},
|
||||
summary=(
|
||||
f"admin {admin['username']} reset all AI quotas "
|
||||
"(Devii assistant and AI gateway)"
|
||||
),
|
||||
)
|
||||
return action_result(request, "/admin/ai-usage")
|
||||
|
||||
|
||||
@@ -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))
|
||||
@@ -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,113 @@ 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:
|
||||
return quota.scope_label(rule, fallback=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.post("/gateway/quota-resets")
|
||||
async def reset_quota_spend(request: Request):
|
||||
admin = require_admin(request)
|
||||
body = await _payload(request)
|
||||
try:
|
||||
payload = quota.QuotaResetIn(**body)
|
||||
except ValidationError as exc:
|
||||
return _validation_error(exc)
|
||||
scope = quota.reset(payload, created_by=admin["uid"])
|
||||
label = quota.scope_label(scope, fallback="every caller")
|
||||
audit.record(
|
||||
request,
|
||||
"gateway.quota.reset",
|
||||
user=admin,
|
||||
target_type="gateway_quota",
|
||||
target_uid=scope["uid"],
|
||||
target_label=label,
|
||||
summary=f"admin {admin['username']} reset the gateway 24h spend for {label}",
|
||||
metadata={
|
||||
"owner_kind": scope["owner_kind"],
|
||||
"owner_id": scope["owner_id"],
|
||||
"app_reference": scope["app_reference"],
|
||||
"reset_at": scope["reset_at"],
|
||||
},
|
||||
)
|
||||
return JSONResponse({"ok": True, "reset": scope})
|
||||
|
||||
|
||||
@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
|
||||
|
||||
@@ -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",
|
||||
@@ -123,6 +129,12 @@ DOCS_PAGES = [
|
||||
"kind": "prose",
|
||||
"section": SECTION_GENERAL,
|
||||
},
|
||||
{
|
||||
"slug": "awards",
|
||||
"title": "Profile awards",
|
||||
"kind": "prose",
|
||||
"section": SECTION_GENERAL,
|
||||
},
|
||||
{
|
||||
"slug": "ai-correction",
|
||||
"title": "AI content correction",
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Annotated, Optional
|
||||
from fastapi import Depends, APIRouter, Request, WebSocket, WebSocketDisconnect
|
||||
from devplacepy.models import MessageForm
|
||||
@@ -25,16 +26,18 @@ from devplacepy.utils import (
|
||||
)
|
||||
from devplacepy.seo import base_seo_context
|
||||
from devplacepy.responses import respond, action_result
|
||||
from devplacepy.schemas import MessagesOut
|
||||
from devplacepy.schemas import ConversationOut, MessagesOut
|
||||
from devplacepy.services import presence
|
||||
from devplacepy.services.audit import record as audit
|
||||
from devplacepy.services.correction import PENDING_SCOPE_KEY
|
||||
from devplacepy.dependencies import json_or_form
|
||||
from devplacepy.services.messaging import (
|
||||
issue_ticket,
|
||||
message_frame,
|
||||
message_hub,
|
||||
message_relay,
|
||||
persist_message,
|
||||
redeem_ticket,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -44,6 +47,18 @@ MAX_WS_ATTACHMENTS = 5
|
||||
|
||||
CONVERSATION_MESSAGE_LIMIT = 500
|
||||
|
||||
MESSAGE_GROUP_GAP_SECONDS = 300
|
||||
|
||||
def _grouped_with_previous(sender_uid, created_at, previous_sender_uid, previous_created_at) -> bool:
|
||||
if previous_sender_uid is None or sender_uid != previous_sender_uid:
|
||||
return False
|
||||
try:
|
||||
current_dt = datetime.fromisoformat(created_at)
|
||||
previous_dt = datetime.fromisoformat(previous_created_at)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
return (current_dt - previous_dt).total_seconds() <= MESSAGE_GROUP_GAP_SECONDS
|
||||
|
||||
def mark_conversation_read(user_uid: str, other_uid: str) -> None:
|
||||
if "messages" not in db.tables:
|
||||
return
|
||||
@@ -123,6 +138,8 @@ def get_conversation_messages(user_uid: str, other_uid: str):
|
||||
result = []
|
||||
msg_uids = [m["uid"] for m in msgs]
|
||||
attachments_map = get_attachments_batch("message", msg_uids) if msg_uids else {}
|
||||
previous_sender_uid = None
|
||||
previous_created_at = None
|
||||
for m in msgs:
|
||||
result.append(
|
||||
{
|
||||
@@ -131,8 +148,13 @@ def get_conversation_messages(user_uid: str, other_uid: str):
|
||||
"is_mine": m["sender_uid"] == user_uid,
|
||||
"time_ago": time_ago(m["created_at"]),
|
||||
"attachments": attachments_map.get(m["uid"], []),
|
||||
"grouped": _grouped_with_previous(
|
||||
m["sender_uid"], m["created_at"], previous_sender_uid, previous_created_at
|
||||
),
|
||||
}
|
||||
)
|
||||
previous_sender_uid = m["sender_uid"]
|
||||
previous_created_at = m["created_at"]
|
||||
return result, other_user
|
||||
|
||||
@router.get("", response_class=HTMLResponse)
|
||||
@@ -207,6 +229,19 @@ async def search_users(request: Request, q: str = ""):
|
||||
results = search_users_by_username(q, exclude_uid=user["uid"])
|
||||
return JSONResponse({"results": results})
|
||||
|
||||
@router.get("/conversations")
|
||||
async def list_conversations(request: Request):
|
||||
user = require_user(request)
|
||||
conversations = get_conversations(user["uid"])
|
||||
payload = [ConversationOut.model_validate(c).model_dump() for c in conversations]
|
||||
return JSONResponse({"conversations": payload})
|
||||
|
||||
@router.post("/ws-ticket")
|
||||
async def create_ws_ticket(request: Request):
|
||||
user = require_user(request)
|
||||
token = issue_ticket(user["uid"])
|
||||
return JSONResponse({"ticket": token, "expires_in": 30})
|
||||
|
||||
@router.post("/send")
|
||||
async def send_message(request: Request, data: Annotated[MessageForm, Depends(json_or_form(MessageForm))]):
|
||||
user = require_user(request)
|
||||
@@ -223,37 +258,54 @@ async def send_message(request: Request, data: Annotated[MessageForm, Depends(js
|
||||
if message is None:
|
||||
return action_result(request, "/messages")
|
||||
|
||||
await _finalize_and_broadcast(user, message, request)
|
||||
ai_processed = await _finalize_and_broadcast(
|
||||
user, message, request, client_id=data.client_id
|
||||
)
|
||||
frame = message_frame(
|
||||
message, user.get("username", ""), data.client_id,
|
||||
sender_role=user.get("role"), ai_processed=ai_processed,
|
||||
)
|
||||
return action_result(
|
||||
request, f"/messages?with_uid={receiver_uid}", data={"uid": message["uid"]}
|
||||
request, f"/messages?with_uid={receiver_uid}", data=frame
|
||||
)
|
||||
|
||||
async def broadcast_message(
|
||||
sender: dict, message: dict, client_id: Optional[str] = None
|
||||
sender: dict, message: dict, client_id: Optional[str] = None,
|
||||
ai_processed: bool = False,
|
||||
) -> None:
|
||||
frame = message_frame(message, sender.get("username", ""), client_id)
|
||||
frame = message_frame(
|
||||
message, sender.get("username", ""), client_id,
|
||||
sender_role=sender.get("role"), ai_processed=ai_processed,
|
||||
)
|
||||
message_hub.mark_delivered(message["uid"])
|
||||
targets = [message["sender_uid"], message["receiver_uid"]]
|
||||
await message_hub.send_to_users(targets, frame)
|
||||
|
||||
async def _finalize_and_broadcast(
|
||||
sender: dict, message: dict, request: object, client_id: Optional[str] = None
|
||||
) -> None:
|
||||
) -> bool:
|
||||
message_hub.mark_delivered(message["uid"])
|
||||
scope = getattr(request, "scope", None)
|
||||
pending = scope.get(PENDING_SCOPE_KEY) if scope is not None else None
|
||||
ai_processed = bool(pending)
|
||||
if pending:
|
||||
await asyncio.gather(*pending, return_exceptions=True)
|
||||
pending.clear()
|
||||
row = get_table("messages").find_one(uid=message["uid"])
|
||||
if row:
|
||||
message["content"] = row["content"]
|
||||
await broadcast_message(sender, message, client_id)
|
||||
await broadcast_message(sender, message, client_id, ai_processed=ai_processed)
|
||||
return ai_processed
|
||||
|
||||
def _resolve_ws_user(websocket: WebSocket):
|
||||
user = _user_from_session(websocket)
|
||||
if user:
|
||||
return user
|
||||
ticket = websocket.query_params.get("ticket", "").strip()
|
||||
if ticket:
|
||||
user_uid = redeem_ticket(ticket)
|
||||
if user_uid:
|
||||
return get_table("users").find_one(uid=user_uid)
|
||||
key = websocket.headers.get("x-api-key", "").strip()
|
||||
if not key:
|
||||
scheme, _, credentials = websocket.headers.get("authorization", "").partition(
|
||||
|
||||
@@ -4,17 +4,21 @@ from devplacepy.routers.profile import (
|
||||
ai_correction,
|
||||
ai_modifier,
|
||||
avatar,
|
||||
award,
|
||||
customization,
|
||||
interactions,
|
||||
notifications,
|
||||
telegram,
|
||||
)
|
||||
from devplacepy.routers.profile.index import router
|
||||
from devplacepy.routers.profile.usage import _ai_quota
|
||||
|
||||
router.include_router(award.router)
|
||||
router.include_router(customization.router)
|
||||
router.include_router(notifications.router)
|
||||
router.include_router(ai_correction.router)
|
||||
router.include_router(ai_modifier.router)
|
||||
router.include_router(interactions.router)
|
||||
router.include_router(avatar.router)
|
||||
router.include_router(telegram.router)
|
||||
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from devplacepy.database import get_blocked_uids, get_table
|
||||
from devplacepy.database.awards import can_give_award, has_giver_cooldown, has_receiver_cooldown
|
||||
from devplacepy.dependencies import json_or_form
|
||||
from devplacepy.models import AwardGiveForm
|
||||
from devplacepy.responses import action_result, json_error, wants_json
|
||||
from devplacepy.services.audit import record as audit
|
||||
from devplacepy.services.jobs import queue
|
||||
from devplacepy.utils import generate_uid, make_combined_slug, require_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/{username}/award")
|
||||
async def give_award(
|
||||
request: Request,
|
||||
username: str,
|
||||
data: Annotated[AwardGiveForm, json_or_form(AwardGiveForm)],
|
||||
):
|
||||
giver = require_user(request)
|
||||
target = get_table("users").find_one(username=username)
|
||||
redirect = f"/profile/{username}"
|
||||
|
||||
def deny(message: str, status: int = 400):
|
||||
if wants_json(request):
|
||||
return json_error(status, message)
|
||||
return action_result(request, redirect, status_code=302)
|
||||
|
||||
if not target:
|
||||
return deny("User not found", 404)
|
||||
if target["uid"] == giver["uid"]:
|
||||
return deny("You cannot give yourself an award")
|
||||
blocked = get_blocked_uids(giver["uid"])
|
||||
if target["uid"] in blocked:
|
||||
return deny("You cannot give an award to a blocked user")
|
||||
reverse_blocked = get_blocked_uids(target["uid"])
|
||||
if giver["uid"] in reverse_blocked:
|
||||
return deny("You cannot give an award to this user")
|
||||
if has_giver_cooldown(giver["uid"]):
|
||||
return deny("You can give another award later")
|
||||
if has_receiver_cooldown(target["uid"]):
|
||||
return deny("This user received an award recently")
|
||||
if not (giver.get("api_key") or "").strip():
|
||||
return deny("Your account has no API key for award generation")
|
||||
|
||||
description = data.description.strip()
|
||||
uid = generate_uid()
|
||||
slug = make_combined_slug(description, uid)
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
get_table("awards").insert(
|
||||
{
|
||||
"uid": uid,
|
||||
"slug": slug,
|
||||
"description": description,
|
||||
"giver_uid": giver["uid"],
|
||||
"receiver_uid": target["uid"],
|
||||
"attachment_uid_512": "",
|
||||
"attachment_uid_256": "",
|
||||
"attachment_uid_64": "",
|
||||
"generated_at": None,
|
||||
"created_at": now,
|
||||
"job_uid": "",
|
||||
"deleted_at": None,
|
||||
"deleted_by": None,
|
||||
}
|
||||
)
|
||||
job_uid = queue.enqueue(
|
||||
"award",
|
||||
{
|
||||
"award_uid": uid,
|
||||
"giver_uid": giver["uid"],
|
||||
"receiver_uid": target["uid"],
|
||||
"description": description,
|
||||
"api_key": giver.get("api_key", ""),
|
||||
},
|
||||
"user",
|
||||
giver["uid"],
|
||||
)
|
||||
get_table("awards").update({"uid": uid, "job_uid": job_uid}, ["uid"])
|
||||
logger.info("%s gave award %s to %s", giver["username"], uid, username)
|
||||
audit.record(
|
||||
request,
|
||||
"award.give",
|
||||
user=giver,
|
||||
target_type="user",
|
||||
target_uid=target["uid"],
|
||||
target_label=username,
|
||||
summary=f"{giver['username']} gave award to {username}",
|
||||
links=[
|
||||
audit.target("user", target["uid"], username),
|
||||
audit.target("award", uid, slug),
|
||||
audit.job(job_uid),
|
||||
],
|
||||
)
|
||||
return action_result(
|
||||
request,
|
||||
redirect,
|
||||
data={"ok": True, "award_uid": uid, "award_slug": slug},
|
||||
)
|
||||
@@ -7,11 +7,11 @@ from devplacepy.models import ProfileForm
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from devplacepy.database import (
|
||||
get_table,
|
||||
db,
|
||||
get_customization_prefs,
|
||||
get_notification_prefs,
|
||||
get_user_stars,
|
||||
get_user_rank,
|
||||
get_user_post_count,
|
||||
get_comment_counts_by_post_uids,
|
||||
get_reactions_by_targets,
|
||||
get_user_bookmarks,
|
||||
@@ -28,9 +28,15 @@ from devplacepy.database import (
|
||||
mark_notifications_read_by_target,
|
||||
resolve_object_url,
|
||||
)
|
||||
from devplacepy.database.awards import (
|
||||
can_give_award,
|
||||
get_prominent_award,
|
||||
get_user_awards,
|
||||
)
|
||||
from devplacepy.content import can_view_project, enrich_items
|
||||
from devplacepy.utils import (
|
||||
get_current_user,
|
||||
get_badge,
|
||||
require_user,
|
||||
require_user_api,
|
||||
time_ago,
|
||||
@@ -40,6 +46,7 @@ from devplacepy.utils import (
|
||||
track_action,
|
||||
build_achievements,
|
||||
)
|
||||
from devplacepy.utils.rewards import LEVEL_XP
|
||||
from devplacepy.responses import respond, action_result, wants_json
|
||||
from devplacepy.schemas import ProfileOut
|
||||
from devplacepy.avatar import avatar_url, avatar_seed
|
||||
@@ -133,6 +140,12 @@ async def profile_page(
|
||||
current_user["uid"], f"/profile/{profile_user['username']}"
|
||||
)
|
||||
profile_user["stars"] = get_user_stars(profile_user["uid"])
|
||||
xp_raw = profile_user.get("xp") or 0
|
||||
level_raw = profile_user.get("level") or 1
|
||||
xp_progress_pct = xp_raw % LEVEL_XP
|
||||
xp_next_level = level_raw * LEVEL_XP
|
||||
profile_user["xp_progress_pct"] = xp_progress_pct
|
||||
profile_user["xp_next_level"] = xp_next_level
|
||||
rank = get_user_rank(profile_user["uid"])
|
||||
follow_counts = get_follow_counts(profile_user["uid"])
|
||||
|
||||
@@ -147,6 +160,17 @@ async def profile_page(
|
||||
if tab == "media":
|
||||
media, media_pagination = get_user_media(profile_user["uid"], page)
|
||||
|
||||
awards, awards_pagination = [], None
|
||||
if tab == "awards":
|
||||
awards, awards_pagination = get_user_awards(profile_user["uid"], page)
|
||||
prominent_award = get_prominent_award(profile_user)
|
||||
awards_count = int(profile_user.get("award_count") or 0)
|
||||
can_give = bool(
|
||||
current_user
|
||||
and current_user["uid"] != profile_user["uid"]
|
||||
and can_give_award(current_user["uid"], profile_user["uid"])
|
||||
)
|
||||
|
||||
posts = []
|
||||
if tab == "posts":
|
||||
posts_table = get_table("posts")
|
||||
@@ -178,6 +202,8 @@ async def profile_page(
|
||||
item["poll"] = polls_map.get(uid)
|
||||
|
||||
badges = list(get_table("badges").find(user_uid=profile_user["uid"]))
|
||||
for b in badges:
|
||||
b["icon"] = get_badge(b["badge_name"]).get("icon")
|
||||
achievements = build_achievements({b["badge_name"] for b in badges})
|
||||
badge_total = sum(group["total"] for group in achievements)
|
||||
badge_earned = sum(group["earned"] for group in achievements)
|
||||
@@ -195,9 +221,7 @@ async def profile_page(
|
||||
)
|
||||
for g in gists_raw:
|
||||
gists.append({"gist": g, "time_ago": time_ago(g["created_at"])})
|
||||
posts_count = get_table("posts").count(
|
||||
user_uid=profile_user["uid"], deleted_at=None
|
||||
)
|
||||
posts_count = get_user_post_count(profile_user["uid"])
|
||||
|
||||
activities = []
|
||||
if tab == "activity":
|
||||
@@ -286,6 +310,18 @@ async def profile_page(
|
||||
if is_owner
|
||||
else None
|
||||
)
|
||||
from devplacepy.services.devii.interaction import prefs as interaction_prefs
|
||||
|
||||
interactions_snap = (
|
||||
interaction_prefs.snapshot("user", profile_user["uid"], profile_user)
|
||||
if is_owner
|
||||
else {
|
||||
"enabled": True,
|
||||
"source": None,
|
||||
"default": True,
|
||||
"override": None,
|
||||
}
|
||||
)
|
||||
from devplacepy.services.telegram import store as telegram_store
|
||||
|
||||
telegram_paired = (
|
||||
@@ -386,6 +422,10 @@ async def profile_page(
|
||||
"ai_modifier_enabled": ai_modifier_enabled,
|
||||
"ai_modifier_sync": ai_modifier_sync,
|
||||
"ai_modifier_prompt": ai_modifier_prompt,
|
||||
"interactions_enabled": interactions_snap["enabled"],
|
||||
"interactions_source": interactions_snap["source"],
|
||||
"interactions_default": interactions_snap["default"],
|
||||
"interactions_override": interactions_snap["override"],
|
||||
"telegram_paired": telegram_paired,
|
||||
"notif_telegram_paired": notif_telegram_paired,
|
||||
"can_manage_customization": can_manage_customization,
|
||||
@@ -404,6 +444,13 @@ async def profile_page(
|
||||
"follow_pagination": follow_pagination,
|
||||
"followers_count": follow_counts["followers"],
|
||||
"following_count": follow_counts["following"],
|
||||
"awards": awards,
|
||||
"awards_pagination": awards_pagination,
|
||||
"awards_count": awards_count,
|
||||
"prominent_award": prominent_award,
|
||||
"can_give_award": can_give,
|
||||
"xp_next_level": xp_next_level,
|
||||
"xp_progress_pct": xp_progress_pct,
|
||||
},
|
||||
model=ProfileOut,
|
||||
)
|
||||
@@ -463,3 +510,7 @@ async def regenerate_api_key(request: Request):
|
||||
links=[audit.target("user", user["uid"], user["username"])],
|
||||
)
|
||||
return JSONResponse({"api_key": new_key})
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends, APIRouter, Request
|
||||
|
||||
from devplacepy.models import InteractionsForm
|
||||
from devplacepy.responses import action_result
|
||||
from devplacepy.services.audit import record as audit
|
||||
from devplacepy.services.devii.interaction import prefs
|
||||
from devplacepy.routers.profile._shared import resolve_customization_target
|
||||
from devplacepy.dependencies import json_or_form
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/{username}/interactions")
|
||||
async def set_interactions(
|
||||
request: Request,
|
||||
username: str,
|
||||
data: Annotated[InteractionsForm, Depends(json_or_form(InteractionsForm))],
|
||||
):
|
||||
target, denied = resolve_customization_target(request, username)
|
||||
if denied is not None:
|
||||
return denied
|
||||
if data.reset:
|
||||
snap = prefs.set_user_pref(target["uid"], None)
|
||||
summary = f"reset interactive widgets to admin default for {target['username']}"
|
||||
new_value = -1
|
||||
else:
|
||||
snap = prefs.set_user_pref(target["uid"], bool(data.enabled))
|
||||
summary = (
|
||||
f"{'enabled' if data.enabled else 'disabled'} interactive widgets "
|
||||
f"for {target['username']}"
|
||||
)
|
||||
new_value = 1 if data.enabled else 0
|
||||
logger.info(summary)
|
||||
audit.record(
|
||||
request,
|
||||
"profile.interactions",
|
||||
target_type="user",
|
||||
target_uid=target["uid"],
|
||||
target_label=target["username"],
|
||||
new_value=new_value,
|
||||
summary=summary,
|
||||
links=[audit.target("user", target["uid"], target["username"])],
|
||||
)
|
||||
url = f"/profile/{target['username']}"
|
||||
return action_result(
|
||||
request,
|
||||
url,
|
||||
data={
|
||||
"url": url,
|
||||
"enabled": snap["enabled"],
|
||||
"source": snap["source"],
|
||||
"default": snap["default"],
|
||||
"override": snap["override"],
|
||||
},
|
||||
)
|
||||
@@ -4,6 +4,7 @@ import logging
|
||||
|
||||
from devplacepy.database import get_correction_usage, get_modifier_usage
|
||||
from devplacepy.services.manager import service_manager
|
||||
from devplacepy.services.openai_gateway import quota as gateway_quota
|
||||
from devplacepy.services.openai_gateway.analytics import user_spend_24h
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -62,4 +63,22 @@ def _ai_quota(
|
||||
if include_cost:
|
||||
quota["spent_usd"] = round(spent, 4)
|
||||
quota["limit_usd"] = round(limit, 2)
|
||||
gateway_svc = service_manager.get_service("openai")
|
||||
if gateway_svc is not None:
|
||||
try:
|
||||
owner_kind = "admin" if is_admin else "user"
|
||||
cfg = gateway_svc.effective_config()
|
||||
gw_limit, gw_scope, gw_rule = gateway_quota.resolve_for_owner(owner_kind, user_uid, cfg)
|
||||
gw_spent = gateway_quota.spent_24h(*gw_scope)
|
||||
gw_unlimited = gw_limit <= 0
|
||||
quota["gateway_unlimited"] = gw_unlimited
|
||||
quota["gateway_used_pct"] = (
|
||||
0.0 if gw_unlimited else round(min(100.0, gw_spent / gw_limit * 100), 1)
|
||||
)
|
||||
if include_cost:
|
||||
quota["gateway_spent_usd"] = round(gw_spent, 4)
|
||||
quota["gateway_limit_usd"] = round(gw_limit, 2)
|
||||
quota["gateway_pooled"] = bool(gw_rule and gw_rule.owner_id is None)
|
||||
except Exception:
|
||||
logger.exception("Failed to compute gateway-level AI quota for %s", user_uid)
|
||||
return quota
|
||||
|
||||
@@ -6,6 +6,7 @@ from sqlalchemy import or_
|
||||
from fastapi import Depends, APIRouter, Request
|
||||
from devplacepy.models import ProjectForm, ProjectEditForm, ProjectFlagForm, ForkForm
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
|
||||
from devplacepy.attachments import get_attachments_batch
|
||||
from devplacepy.database import (
|
||||
get_table,
|
||||
get_users_by_uids,
|
||||
@@ -13,6 +14,9 @@ from devplacepy.database import (
|
||||
get_site_stats,
|
||||
get_user_votes,
|
||||
get_recent_comments_by_target_uids,
|
||||
get_reactions_by_targets,
|
||||
get_user_bookmarks,
|
||||
get_polls_by_post_uids,
|
||||
paginate,
|
||||
text_search_clause,
|
||||
resolve_by_slug,
|
||||
@@ -35,6 +39,7 @@ from devplacepy.content import (
|
||||
is_owner,
|
||||
can_view_project,
|
||||
can_view_project_containers,
|
||||
get_project_devlog,
|
||||
)
|
||||
from devplacepy.utils import (
|
||||
get_current_user,
|
||||
@@ -171,7 +176,7 @@ async def projects_page(
|
||||
)
|
||||
|
||||
@router.get("/{project_slug}", response_class=HTMLResponse)
|
||||
async def project_detail(request: Request, project_slug: str):
|
||||
async def project_detail(request: Request, project_slug: str, before: str = None):
|
||||
user = get_current_user(request)
|
||||
detail = load_detail("projects", "project", project_slug, user)
|
||||
if not detail:
|
||||
@@ -216,6 +221,25 @@ async def project_detail(request: Request, project_slug: str):
|
||||
if parent
|
||||
else None
|
||||
)
|
||||
|
||||
devlog_posts, devlog_next_cursor = get_project_devlog(
|
||||
project["uid"], before=before, viewer=user
|
||||
)
|
||||
if devlog_posts:
|
||||
post_uids = [item["post"]["uid"] for item in devlog_posts]
|
||||
attachments_map = get_attachments_batch("post", post_uids)
|
||||
reactions_map = get_reactions_by_targets("post", post_uids, user)
|
||||
bookmark_set = (
|
||||
get_user_bookmarks(user["uid"], "post", post_uids) if user else set()
|
||||
)
|
||||
polls_map = get_polls_by_post_uids(post_uids, user)
|
||||
for item in devlog_posts:
|
||||
uid = item["post"]["uid"]
|
||||
item["attachments"] = attachments_map.get(uid, [])
|
||||
item["reactions"] = reactions_map.get(uid, {"counts": {}, "mine": []})
|
||||
item["bookmarked"] = uid in bookmark_set
|
||||
item["poll"] = polls_map.get(uid)
|
||||
|
||||
return respond(
|
||||
request,
|
||||
"project_detail.html",
|
||||
@@ -235,6 +259,8 @@ async def project_detail(request: Request, project_slug: str):
|
||||
"forked_from": forked_from,
|
||||
"fork_count": count_forks(project["uid"]),
|
||||
"file_count": count_files(project["uid"]),
|
||||
"devlog_posts": devlog_posts,
|
||||
"devlog_next_cursor": devlog_next_cursor,
|
||||
},
|
||||
),
|
||||
model=ProjectDetailOut,
|
||||
@@ -464,3 +490,5 @@ async def set_project_readonly(
|
||||
request: Request, project_slug: str, data: Annotated[ProjectFlagForm, Depends(json_or_form(ProjectFlagForm))]
|
||||
):
|
||||
return _set_project_flag(request, project_slug, "read_only", data.value)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from .index import router
|
||||
from . import attempts, questions
|
||||
|
||||
router.include_router(questions.router)
|
||||
router.include_router(attempts.router)
|
||||
|
||||
__all__ = ["router"]
|
||||
@@ -0,0 +1,86 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from urllib.parse import quote
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
|
||||
from devplacepy.database import get_table, resolve_by_slug
|
||||
from devplacepy.responses import json_error, wants_json
|
||||
from devplacepy.seo import base_seo_context
|
||||
from devplacepy.services.pubsub import publish
|
||||
from devplacepy.services.quiz import QuizError, store
|
||||
from devplacepy.utils import is_admin, not_found
|
||||
|
||||
|
||||
def quiz_topic(quiz_uid: str) -> str:
|
||||
return f"public.quiz.{quiz_uid}"
|
||||
|
||||
|
||||
async def notify_quiz(quiz_uid: str) -> None:
|
||||
if not quiz_uid:
|
||||
return
|
||||
try:
|
||||
await publish(quiz_topic(quiz_uid), {"kind": "update", "quiz_uid": quiz_uid})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def load_quiz(slug: str, user: dict | None) -> dict:
|
||||
quiz = resolve_by_slug(get_table("quizzes"), slug)
|
||||
if not quiz or not store.can_view_quiz(quiz, user):
|
||||
raise not_found("Quiz not found")
|
||||
return quiz
|
||||
|
||||
|
||||
def require_owner(quiz: dict, user: dict):
|
||||
if not (store.is_quiz_owner(quiz, user) or is_admin(user)):
|
||||
raise not_found("Quiz not found")
|
||||
return quiz
|
||||
|
||||
|
||||
def require_editor(quiz: dict, user: dict):
|
||||
if not store.is_quiz_owner(quiz, user):
|
||||
raise not_found("Quiz not found")
|
||||
return quiz
|
||||
|
||||
|
||||
def action_error(request: Request, message: str, redirect_url: str):
|
||||
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 quiz_seo(request: Request, title: str, description: str, robots: str = "index,follow", **extra):
|
||||
return base_seo_context(
|
||||
request,
|
||||
title=title,
|
||||
description=description,
|
||||
robots=robots,
|
||||
breadcrumbs=[
|
||||
{"name": "Home", "url": "/feed"},
|
||||
{"name": "Quizzes", "url": "/quizzes"},
|
||||
*extra.pop("breadcrumbs", []),
|
||||
],
|
||||
**extra,
|
||||
)
|
||||
|
||||
|
||||
def quiz_url(quiz: dict) -> str:
|
||||
return f"/quizzes/{quiz.get('slug') or quiz['uid']}"
|
||||
|
||||
|
||||
__all__ = [
|
||||
"QuizError",
|
||||
"action_error",
|
||||
"load_quiz",
|
||||
"notify_quiz",
|
||||
"quiz_seo",
|
||||
"quiz_topic",
|
||||
"quiz_url",
|
||||
"require_editor",
|
||||
"require_owner",
|
||||
]
|
||||
@@ -0,0 +1,244 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
|
||||
from devplacepy.config import QUIZ_ANSWER_MAX_CHARS
|
||||
from devplacepy.database import get_users_by_uids
|
||||
from devplacepy.dependencies import json_or_form
|
||||
from devplacepy.models import QuizAnswerForm
|
||||
from devplacepy.responses import action_result, respond, wants_json
|
||||
from devplacepy.schemas import (
|
||||
QuizAnswerResultOut,
|
||||
QuizAttemptPageOut,
|
||||
QuizResultOut,
|
||||
)
|
||||
from devplacepy.services.audit import record as audit
|
||||
from devplacepy.services.quiz import QuizError, store
|
||||
from devplacepy.utils import (
|
||||
award_rewards,
|
||||
create_notification,
|
||||
is_admin,
|
||||
not_found,
|
||||
require_user,
|
||||
track_action,
|
||||
XP_QUIZ_COMPLETE,
|
||||
)
|
||||
|
||||
from ._shared import action_error, load_quiz, notify_quiz, quiz_seo, quiz_url
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _load_attempt(quiz: dict, attempt_uid: str, user: dict, allow_admin: bool = False):
|
||||
attempt = store.get_attempt(attempt_uid)
|
||||
if not attempt or attempt.get("quiz_uid") != quiz["uid"]:
|
||||
raise not_found("Attempt not found")
|
||||
if attempt.get("user_uid") != user["uid"] and not (allow_admin and is_admin(user)):
|
||||
raise not_found("Attempt not found")
|
||||
return attempt
|
||||
|
||||
|
||||
def _author(quiz: dict):
|
||||
return get_users_by_uids([quiz["user_uid"]]).get(quiz["user_uid"])
|
||||
|
||||
|
||||
@router.post("/{slug}/attempts")
|
||||
async def start_attempt(request: Request, slug: str):
|
||||
user = require_user(request)
|
||||
quiz = load_quiz(slug, user)
|
||||
if quiz.get("status") != "published":
|
||||
return action_error(request, "This quiz is not published yet", quiz_url(quiz))
|
||||
try:
|
||||
attempt = store.start_attempt(user, quiz)
|
||||
except QuizError as exc:
|
||||
return action_error(request, str(exc), quiz_url(quiz))
|
||||
audit.record(
|
||||
request,
|
||||
"quiz.attempt.start",
|
||||
user=user,
|
||||
target_type="quiz",
|
||||
target_uid=quiz["uid"],
|
||||
target_label=quiz.get("title") or quiz["uid"],
|
||||
summary=f"{user['username']} started an attempt on quiz {quiz.get('title')}",
|
||||
metadata={"attempt_uid": attempt["uid"]},
|
||||
links=[audit.target("quiz", quiz["uid"], quiz.get("title") or "")],
|
||||
)
|
||||
url = f"{quiz_url(quiz)}/attempts/{attempt['uid']}"
|
||||
return action_result(
|
||||
request, url, data={"uid": attempt["uid"], "url": url, "status": attempt["status"]}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{slug}/attempts/{attempt_uid}", response_class=HTMLResponse)
|
||||
async def attempt_page(request: Request, slug: str, attempt_uid: str):
|
||||
user = require_user(request)
|
||||
quiz = load_quiz(slug, user)
|
||||
attempt = _load_attempt(quiz, attempt_uid, user)
|
||||
payload = store.serialize_attempt(quiz, attempt, user)
|
||||
seo_ctx = quiz_seo(
|
||||
request,
|
||||
quiz.get("title") or "Quiz",
|
||||
"Play this quiz on DevPlace.",
|
||||
robots="noindex,follow",
|
||||
breadcrumbs=[{"name": quiz.get("title") or "Quiz", "url": quiz_url(quiz)}],
|
||||
)
|
||||
return respond(
|
||||
request,
|
||||
"quiz_play.html",
|
||||
{
|
||||
**seo_ctx,
|
||||
"request": request,
|
||||
"user": user,
|
||||
"quiz": store.serialize_quiz(quiz, user, _author(quiz)),
|
||||
"attempt": payload,
|
||||
"answer_max_chars": QUIZ_ANSWER_MAX_CHARS,
|
||||
"quiz_error": request.query_params.get("error", ""),
|
||||
},
|
||||
model=QuizAttemptPageOut,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{slug}/attempts/{attempt_uid}/answer")
|
||||
async def submit_answer(
|
||||
request: Request,
|
||||
slug: str,
|
||||
attempt_uid: str,
|
||||
data: Annotated[QuizAnswerForm, Depends(json_or_form(QuizAnswerForm))],
|
||||
):
|
||||
user = require_user(request)
|
||||
quiz = load_quiz(slug, user)
|
||||
attempt = _load_attempt(quiz, attempt_uid, user)
|
||||
attempt_url = f"{quiz_url(quiz)}/attempts/{attempt_uid}"
|
||||
try:
|
||||
answer, updated, result = await store.answer(
|
||||
user,
|
||||
quiz,
|
||||
attempt,
|
||||
data.question_uid,
|
||||
data.submission(),
|
||||
)
|
||||
except QuizError as exc:
|
||||
return action_error(request, str(exc), attempt_url)
|
||||
if result.graded_by == "fallback":
|
||||
audit.record_system(
|
||||
"quiz.grade.failed",
|
||||
result="failure",
|
||||
target_type="quiz",
|
||||
target_uid=quiz["uid"],
|
||||
summary=(
|
||||
f"AI grading unavailable for question {data.question_uid}, "
|
||||
"deterministic fallback used"
|
||||
),
|
||||
metadata={"attempt_uid": attempt_uid, "question_uid": data.question_uid},
|
||||
)
|
||||
audit.record(
|
||||
request,
|
||||
"quiz.attempt.answer",
|
||||
user=user,
|
||||
target_type="quiz",
|
||||
target_uid=quiz["uid"],
|
||||
target_label=quiz.get("title") or quiz["uid"],
|
||||
summary=(
|
||||
f"{user['username']} answered a question on quiz {quiz.get('title')} "
|
||||
f"for {answer.get('awarded_points')} points"
|
||||
),
|
||||
metadata={
|
||||
"attempt_uid": attempt_uid,
|
||||
"question_uid": data.question_uid,
|
||||
"graded_by": result.graded_by,
|
||||
},
|
||||
links=[audit.target("quiz", quiz["uid"], quiz.get("title") or "")],
|
||||
)
|
||||
if not wants_json(request):
|
||||
return action_result(request, attempt_url)
|
||||
return JSONResponse(
|
||||
QuizAnswerResultOut(
|
||||
answer=store.serialize_answer(answer),
|
||||
attempt=store.serialize_attempt(quiz, updated, user),
|
||||
).model_dump(mode="json")
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{slug}/attempts/{attempt_uid}/finish")
|
||||
async def finish_attempt(request: Request, slug: str, attempt_uid: str):
|
||||
user = require_user(request)
|
||||
quiz = load_quiz(slug, user)
|
||||
attempt = _load_attempt(quiz, attempt_uid, user)
|
||||
finished, won = store.finish(quiz, attempt)
|
||||
results_url = f"{quiz_url(quiz)}/attempts/{attempt_uid}/results"
|
||||
if won:
|
||||
store.clear_cache()
|
||||
award_rewards(user["uid"], XP_QUIZ_COMPLETE, "Quiz Taker")
|
||||
track_action(user["uid"], "quiz_complete")
|
||||
if float(finished.get("score_percent") or 0.0) >= 100.0:
|
||||
track_action(user["uid"], "quiz_perfect")
|
||||
if quiz["user_uid"] != user["uid"]:
|
||||
create_notification(
|
||||
quiz["user_uid"],
|
||||
"quiz_attempt",
|
||||
f"{user['username']} completed your quiz {quiz.get('title')}",
|
||||
user["uid"],
|
||||
quiz_url(quiz),
|
||||
)
|
||||
audit.record(
|
||||
request,
|
||||
"quiz.attempt.finish",
|
||||
user=user,
|
||||
target_type="quiz",
|
||||
target_uid=quiz["uid"],
|
||||
target_label=quiz.get("title") or quiz["uid"],
|
||||
summary=(
|
||||
f"{user['username']} finished quiz {quiz.get('title')} with "
|
||||
f"{finished.get('score_percent')}%"
|
||||
),
|
||||
metadata={
|
||||
"attempt_uid": attempt_uid,
|
||||
"score_percent": finished.get("score_percent"),
|
||||
"passed": finished.get("passed"),
|
||||
},
|
||||
links=[audit.target("quiz", quiz["uid"], quiz.get("title") or "")],
|
||||
)
|
||||
await notify_quiz(quiz["uid"])
|
||||
if not wants_json(request):
|
||||
return action_result(request, results_url)
|
||||
result = store.serialize_result(quiz, finished, user)
|
||||
return JSONResponse(
|
||||
QuizResultOut(
|
||||
quiz=store.serialize_quiz(quiz, user, _author(quiz)),
|
||||
attempt=result,
|
||||
review=result["review"],
|
||||
fallback_count=result["fallback_count"],
|
||||
).model_dump(mode="json")
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{slug}/attempts/{attempt_uid}/results", response_class=HTMLResponse)
|
||||
async def attempt_results(request: Request, slug: str, attempt_uid: str):
|
||||
user = require_user(request)
|
||||
quiz = load_quiz(slug, user)
|
||||
attempt = _load_attempt(quiz, attempt_uid, user, allow_admin=True)
|
||||
result = store.serialize_result(quiz, attempt, user)
|
||||
seo_ctx = quiz_seo(
|
||||
request,
|
||||
f"{quiz.get('title') or 'Quiz'} results",
|
||||
"Your quiz result on DevPlace.",
|
||||
robots="noindex,follow",
|
||||
breadcrumbs=[{"name": quiz.get("title") or "Quiz", "url": quiz_url(quiz)}],
|
||||
)
|
||||
return respond(
|
||||
request,
|
||||
"quiz_results.html",
|
||||
{
|
||||
**seo_ctx,
|
||||
"request": request,
|
||||
"user": user,
|
||||
"quiz": store.serialize_quiz(quiz, user, _author(quiz)),
|
||||
"attempt": result,
|
||||
"review": result["review"],
|
||||
"fallback_count": result["fallback_count"],
|
||||
},
|
||||
model=QuizResultOut,
|
||||
)
|
||||
@@ -0,0 +1,363 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
|
||||
from devplacepy.config import QUIZ_SCOREBOARD_LIMIT
|
||||
from devplacepy.content import (
|
||||
canonical_redirect,
|
||||
create_content_item,
|
||||
delete_content_item,
|
||||
detail_context,
|
||||
load_detail,
|
||||
)
|
||||
from devplacepy.database import (
|
||||
get_reactions_by_targets,
|
||||
get_recent_comments_by_target_uids,
|
||||
get_user_bookmarks,
|
||||
get_users_by_uids,
|
||||
get_user_votes,
|
||||
mark_notifications_read_by_target,
|
||||
resolve_object_url,
|
||||
)
|
||||
from devplacepy.dependencies import json_or_form
|
||||
from devplacepy.models import QuizForm, QuizImportForm
|
||||
from devplacepy.responses import action_result, respond
|
||||
from devplacepy.schemas import (
|
||||
QuizDetailOut,
|
||||
QuizDocumentOut,
|
||||
QuizFormPageOut,
|
||||
QuizLeaderboardOut,
|
||||
QuizScoreboardOut,
|
||||
QuizzesOut,
|
||||
)
|
||||
from devplacepy.seo import quiz_schema, site_url, website_schema
|
||||
from devplacepy.services.audit import record as audit
|
||||
from devplacepy.services.quiz import QuizError, store
|
||||
from devplacepy.utils import (
|
||||
award_rewards,
|
||||
get_current_user,
|
||||
require_user,
|
||||
time_ago,
|
||||
track_action,
|
||||
XP_QUIZ,
|
||||
XP_QUIZ_PUBLISH,
|
||||
)
|
||||
|
||||
from ._shared import action_error, load_quiz, notify_quiz, quiz_seo, quiz_url, require_editor
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _list_items(rows: list[dict], user: dict | None) -> list[dict]:
|
||||
if not rows:
|
||||
return []
|
||||
uids = [row["uid"] for row in rows]
|
||||
authors = get_users_by_uids([row["user_uid"] for row in rows])
|
||||
counts = store.comment_counts(uids)
|
||||
reactions = get_reactions_by_targets("quiz", uids, user)
|
||||
recent = get_recent_comments_by_target_uids("quiz", uids, 3, user)
|
||||
bookmarks = get_user_bookmarks(user["uid"], "quiz", uids) if user else set()
|
||||
votes = get_user_votes(user["uid"], uids) if user else {}
|
||||
states = store.attempt_states_for(user["uid"], uids) if user else {}
|
||||
items = []
|
||||
for row in rows:
|
||||
state = states.get(row["uid"], {})
|
||||
items.append(
|
||||
{
|
||||
"uid": row["uid"],
|
||||
"slug": row.get("slug") or row["uid"],
|
||||
"url": quiz_url(row),
|
||||
"title": row.get("title") or "",
|
||||
"description": row.get("description") or "",
|
||||
"status": row.get("status") or "published",
|
||||
"created_at": row.get("created_at") or "",
|
||||
"time_ago": time_ago(row.get("created_at") or ""),
|
||||
"author": authors.get(row["user_uid"]),
|
||||
"question_count": int(row.get("question_count") or 0),
|
||||
"total_points": int(row.get("total_points") or 0),
|
||||
"attempt_count": int(row.get("attempt_count") or 0),
|
||||
"time_limit_seconds": int(row.get("time_limit_seconds") or 0),
|
||||
"pass_percent": int(row.get("pass_percent") or 0),
|
||||
"stars": int(row.get("stars") or 0),
|
||||
"my_vote": votes.get(row["uid"], 0),
|
||||
"comment_count": counts.get(row["uid"], 0),
|
||||
"bookmarked": row["uid"] in bookmarks,
|
||||
"reactions": reactions.get(row["uid"], {"counts": {}, "mine": []}),
|
||||
"recent_comments": recent.get(row["uid"], []),
|
||||
"viewer_state": state.get("state", "todo"),
|
||||
"viewer_best_percent": state.get("best_percent", 0.0),
|
||||
"viewer_best_points": state.get("best_points", 0.0),
|
||||
"viewer_attempt_uid": state.get("attempt_uid", ""),
|
||||
}
|
||||
)
|
||||
return items
|
||||
|
||||
|
||||
@router.get("", response_class=HTMLResponse)
|
||||
async def quizzes_page(
|
||||
request: Request, filter: str = "all", search: str = "", page: int = 1
|
||||
):
|
||||
user = get_current_user(request)
|
||||
current_filter = filter if filter in store.FILTERS else "all"
|
||||
rows, pagination = store.list_quizzes(
|
||||
viewer=user, quiz_filter=current_filter, search=search, page=max(1, page)
|
||||
)
|
||||
standing = store.standing_for(user["uid"]) if user else None
|
||||
seo_ctx = quiz_seo(
|
||||
request,
|
||||
"Quizzes",
|
||||
"Author quizzes, play them, and climb the DevPlace quiz scoreboard.",
|
||||
schemas=[website_schema(site_url(request))],
|
||||
)
|
||||
return respond(
|
||||
request,
|
||||
"quizzes.html",
|
||||
{
|
||||
**seo_ctx,
|
||||
"request": request,
|
||||
"user": user,
|
||||
"quizzes": _list_items(rows, user),
|
||||
"search": search,
|
||||
"filter": current_filter,
|
||||
"current_filter": current_filter,
|
||||
"counts": store.filter_counts(user, search),
|
||||
"pagination": pagination,
|
||||
"scoreboard": store.scoreboard(QUIZ_SCOREBOARD_LIMIT),
|
||||
"viewer_standing": standing,
|
||||
"viewer_progress": store.progress_for(user["uid"]) if user else {},
|
||||
"viewer_can_create": bool(user),
|
||||
},
|
||||
model=QuizzesOut,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/scoreboard")
|
||||
async def quizzes_scoreboard(request: Request, limit: int = QUIZ_SCOREBOARD_LIMIT):
|
||||
user = get_current_user(request)
|
||||
bounded = max(1, min(100, int(limit or QUIZ_SCOREBOARD_LIMIT)))
|
||||
return JSONResponse(
|
||||
QuizScoreboardOut(
|
||||
scoreboard=store.scoreboard(bounded),
|
||||
viewer_standing=store.standing_for(user["uid"]) if user else None,
|
||||
limit=bounded,
|
||||
).model_dump(mode="json")
|
||||
)
|
||||
|
||||
|
||||
@router.get("/new", response_class=HTMLResponse)
|
||||
async def quiz_new_page(request: Request):
|
||||
user = require_user(request)
|
||||
seo_ctx = quiz_seo(
|
||||
request,
|
||||
"New quiz",
|
||||
"Create a quiz on DevPlace.",
|
||||
robots="noindex,follow",
|
||||
)
|
||||
return respond(
|
||||
request,
|
||||
"quiz_new.html",
|
||||
{**seo_ctx, "request": request, "user": user, "viewer_can_create": True},
|
||||
model=QuizFormPageOut,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/create")
|
||||
async def create_quiz(
|
||||
request: Request, data: Annotated[QuizForm, Depends(json_or_form(QuizForm))]
|
||||
):
|
||||
user = require_user(request)
|
||||
fields = store.quiz_fields(data)
|
||||
uid, slug = create_content_item(
|
||||
"quizzes",
|
||||
"quiz",
|
||||
user,
|
||||
fields,
|
||||
fields["title"],
|
||||
XP_QUIZ,
|
||||
"First Quiz",
|
||||
fields["description"],
|
||||
None,
|
||||
request,
|
||||
)
|
||||
url = f"/quizzes/{slug}/edit"
|
||||
return action_result(request, url, data={"uid": uid, "slug": slug, "url": url})
|
||||
|
||||
|
||||
@router.post("/import")
|
||||
async def import_quiz(
|
||||
request: Request, data: Annotated[QuizImportForm, Depends(json_or_form(QuizImportForm))]
|
||||
):
|
||||
user = require_user(request)
|
||||
document = data.document
|
||||
fields = {
|
||||
"title": document.title.strip(),
|
||||
"description": document.description.strip(),
|
||||
"status": "draft",
|
||||
"published_at": "",
|
||||
"question_count": 0,
|
||||
"total_points": 0,
|
||||
"attempt_count": 0,
|
||||
**store.document_settings(document),
|
||||
}
|
||||
uid, slug = create_content_item(
|
||||
"quizzes",
|
||||
"quiz",
|
||||
user,
|
||||
fields,
|
||||
fields["title"],
|
||||
XP_QUIZ,
|
||||
"First Quiz",
|
||||
fields["description"],
|
||||
None,
|
||||
request,
|
||||
)
|
||||
imported = store.import_questions(uid, document)
|
||||
audit.record(
|
||||
request,
|
||||
"quiz.import",
|
||||
user=user,
|
||||
target_type="quiz",
|
||||
target_uid=uid,
|
||||
target_label=fields["title"],
|
||||
summary=f"{user['username']} imported quiz {fields['title']} with {imported} questions",
|
||||
metadata={"question_count": imported},
|
||||
links=[audit.target("quiz", uid, fields["title"])],
|
||||
)
|
||||
url = f"/quizzes/{slug}/edit"
|
||||
return action_result(
|
||||
request,
|
||||
url,
|
||||
data={"uid": uid, "slug": slug, "url": url, "question_count": imported},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{slug}", response_class=HTMLResponse)
|
||||
async def quiz_detail(request: Request, slug: str):
|
||||
user = get_current_user(request)
|
||||
quiz = load_quiz(slug, user)
|
||||
redirect = canonical_redirect("quizzes", quiz, slug)
|
||||
if redirect:
|
||||
return redirect
|
||||
detail = load_detail("quizzes", "quiz", quiz["uid"], user)
|
||||
if not detail:
|
||||
return action_error(request, "Quiz not found", "/quizzes")
|
||||
if user:
|
||||
mark_notifications_read_by_target(user["uid"], resolve_object_url("quiz", quiz["uid"]))
|
||||
states = store.attempt_states_for(user["uid"], [quiz["uid"]]) if user else {}
|
||||
state = states.get(quiz["uid"], {})
|
||||
author = detail["author"]
|
||||
seo_ctx = quiz_seo(
|
||||
request,
|
||||
quiz.get("title") or "Quiz",
|
||||
quiz.get("description") or "",
|
||||
robots="index,follow" if quiz.get("status") == "published" else "noindex,nofollow",
|
||||
seo_target=("quiz", quiz["uid"]),
|
||||
og_type="article",
|
||||
breadcrumbs=[{"name": quiz.get("title") or "Quiz", "url": quiz_url(quiz)}],
|
||||
schemas=[quiz_schema(quiz, author, site_url(request))],
|
||||
)
|
||||
context = detail_context(
|
||||
request,
|
||||
user,
|
||||
detail,
|
||||
"quiz_row",
|
||||
seo_ctx,
|
||||
{
|
||||
"quiz": store.serialize_quiz(quiz, user, author),
|
||||
"questions": store.serialize_builder(quiz, user)
|
||||
if store.is_quiz_owner(quiz, user)
|
||||
else [],
|
||||
"leaderboard": store.quiz_leaderboard(quiz["uid"]),
|
||||
"viewer_state": state.get("state", "todo"),
|
||||
"viewer_attempt_uid": state.get("attempt_uid", ""),
|
||||
"target_type": "quiz",
|
||||
"target_uid": quiz["uid"],
|
||||
},
|
||||
)
|
||||
return respond(request, "quiz.html", context, model=QuizDetailOut)
|
||||
|
||||
|
||||
@router.get("/{slug}/export")
|
||||
async def export_quiz(request: Request, slug: str):
|
||||
user = get_current_user(request)
|
||||
quiz = load_quiz(slug, user)
|
||||
include_answers = store.is_quiz_owner(quiz, user)
|
||||
document = store.export_document(quiz["uid"], include_answers)
|
||||
return JSONResponse(QuizDocumentOut.model_validate(document).model_dump(mode="json"))
|
||||
|
||||
|
||||
@router.get("/{slug}/leaderboard")
|
||||
async def quiz_leaderboard(request: Request, slug: str, limit: int = 25):
|
||||
user = get_current_user(request)
|
||||
quiz = load_quiz(slug, user)
|
||||
return JSONResponse(
|
||||
QuizLeaderboardOut(
|
||||
quiz_uid=quiz["uid"],
|
||||
entries=store.quiz_leaderboard(quiz["uid"], max(1, min(100, int(limit or 25)))),
|
||||
).model_dump(mode="json")
|
||||
)
|
||||
|
||||
|
||||
@router.post("/edit/{slug}")
|
||||
async def edit_quiz(
|
||||
request: Request, slug: str, data: Annotated[QuizForm, Depends(json_or_form(QuizForm))]
|
||||
):
|
||||
user = require_user(request)
|
||||
quiz = require_editor(load_quiz(slug, user), user)
|
||||
try:
|
||||
updated = store.edit_quiz(quiz["uid"], store.settings_update(data))
|
||||
except QuizError as exc:
|
||||
return action_error(request, str(exc), quiz_url(quiz))
|
||||
audit.record(
|
||||
request,
|
||||
"quiz.edit",
|
||||
user=user,
|
||||
target_type="quiz",
|
||||
target_uid=quiz["uid"],
|
||||
target_label=updated.get("title") or quiz["uid"],
|
||||
summary=f"{user['username']} edited quiz {updated.get('title')}",
|
||||
links=[audit.target("quiz", quiz["uid"], updated.get("title") or "")],
|
||||
)
|
||||
await notify_quiz(quiz["uid"])
|
||||
url = quiz_url(updated)
|
||||
return action_result(request, url, data={"uid": quiz["uid"], "url": url})
|
||||
|
||||
|
||||
@router.post("/delete/{slug}")
|
||||
async def delete_quiz(request: Request, slug: str):
|
||||
user = require_user(request)
|
||||
response = delete_content_item(request, "quizzes", "quiz", user, slug, "/quizzes")
|
||||
store.clear_cache()
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/{slug}/publish")
|
||||
async def publish_quiz(request: Request, slug: str):
|
||||
user = require_user(request)
|
||||
quiz = require_editor(load_quiz(slug, user), user)
|
||||
try:
|
||||
published = store.publish_quiz(quiz["uid"])
|
||||
except QuizError as exc:
|
||||
return action_error(request, str(exc), f"/quizzes/{slug}/edit")
|
||||
if published.get("publish_won"):
|
||||
award_rewards(user["uid"], XP_QUIZ_PUBLISH, "Quiz Author")
|
||||
track_action(user["uid"], "quiz_publish")
|
||||
audit.record(
|
||||
request,
|
||||
"quiz.publish",
|
||||
user=user,
|
||||
target_type="quiz",
|
||||
target_uid=quiz["uid"],
|
||||
target_label=published.get("title") or quiz["uid"],
|
||||
summary=f"{user['username']} published quiz {published.get('title')}",
|
||||
links=[audit.target("quiz", quiz["uid"], published.get("title") or "")],
|
||||
)
|
||||
store.clear_cache()
|
||||
await notify_quiz(quiz["uid"])
|
||||
url = quiz_url(published)
|
||||
return action_result(
|
||||
request, url, data={"uid": quiz["uid"], "url": url, "status": published.get("status")}
|
||||
)
|
||||
@@ -0,0 +1,171 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from devplacepy.database import get_users_by_uids
|
||||
from devplacepy.dependencies import json_or_form
|
||||
from devplacepy.models import QuizQuestionForm, QuizReorderForm
|
||||
from devplacepy.responses import action_result, respond
|
||||
from devplacepy.schemas import QuizBuilderOut
|
||||
from devplacepy.services.audit import record as audit
|
||||
from devplacepy.services.quiz import QuizError, scoring, store
|
||||
from devplacepy.utils import require_user
|
||||
|
||||
from ._shared import action_error, load_quiz, notify_quiz, quiz_seo, quiz_url, require_editor
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
KIND_SPECS = [
|
||||
{
|
||||
"key": kind.key,
|
||||
"label": kind.label,
|
||||
"icon": kind.icon,
|
||||
"has_options": kind.has_options,
|
||||
"graded_by": kind.graded_by,
|
||||
}
|
||||
for kind in scoring.QUESTION_KINDS
|
||||
]
|
||||
|
||||
|
||||
def _question_payload(data: QuizQuestionForm) -> dict:
|
||||
return {
|
||||
"kind": data.kind,
|
||||
"prompt": data.prompt.strip(),
|
||||
"explanation": data.explanation.strip(),
|
||||
"points": data.points,
|
||||
"media_attachment_uid": data.media_attachment_uid.strip(),
|
||||
"correct_boolean": int(bool(data.correct_boolean)),
|
||||
"expected_answer": data.expected_answer.strip(),
|
||||
"grading_criteria": data.grading_criteria.strip(),
|
||||
"numeric_value": data.numeric_value,
|
||||
"numeric_tolerance": data.numeric_tolerance,
|
||||
"case_sensitive": int(bool(data.case_sensitive)),
|
||||
"options": data.option_rows(),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{slug}/edit", response_class=HTMLResponse)
|
||||
async def quiz_builder(request: Request, slug: str):
|
||||
user = require_user(request)
|
||||
quiz = require_editor(load_quiz(slug, user), user)
|
||||
author = get_users_by_uids([quiz["user_uid"]]).get(quiz["user_uid"])
|
||||
seo_ctx = quiz_seo(
|
||||
request,
|
||||
f"Edit {quiz.get('title') or 'quiz'}",
|
||||
"Build and publish your quiz.",
|
||||
robots="noindex,follow",
|
||||
breadcrumbs=[{"name": quiz.get("title") or "Quiz", "url": quiz_url(quiz)}],
|
||||
)
|
||||
return respond(
|
||||
request,
|
||||
"quiz_edit.html",
|
||||
{
|
||||
**seo_ctx,
|
||||
"request": request,
|
||||
"user": user,
|
||||
"quiz": store.serialize_quiz(quiz, user, author),
|
||||
"questions": store.serialize_builder(quiz, user),
|
||||
"kinds": KIND_SPECS,
|
||||
"validation_errors": store.validation_errors(quiz["uid"]),
|
||||
"quiz_error": request.query_params.get("error", ""),
|
||||
},
|
||||
model=QuizBuilderOut,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{slug}/questions")
|
||||
async def add_question(
|
||||
request: Request,
|
||||
slug: str,
|
||||
data: Annotated[QuizQuestionForm, Depends(json_or_form(QuizQuestionForm))],
|
||||
):
|
||||
user = require_user(request)
|
||||
quiz = require_editor(load_quiz(slug, user), user)
|
||||
try:
|
||||
question = store.add_question(quiz["uid"], _question_payload(data))
|
||||
except QuizError as exc:
|
||||
return action_error(request, str(exc), f"/quizzes/{slug}/edit")
|
||||
_audit_question(request, user, quiz, question, "create")
|
||||
await notify_quiz(quiz["uid"])
|
||||
return action_result(
|
||||
request,
|
||||
f"/quizzes/{slug}/edit",
|
||||
data={"uid": question["uid"], "position": question["position"]},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{slug}/questions/reorder")
|
||||
async def reorder_questions(
|
||||
request: Request,
|
||||
slug: str,
|
||||
data: Annotated[QuizReorderForm, Depends(json_or_form(QuizReorderForm))],
|
||||
):
|
||||
user = require_user(request)
|
||||
quiz = require_editor(load_quiz(slug, user), user)
|
||||
try:
|
||||
store.reorder_questions(quiz["uid"], data.order)
|
||||
except QuizError as exc:
|
||||
return action_error(request, str(exc), f"/quizzes/{slug}/edit")
|
||||
audit.record(
|
||||
request,
|
||||
"quiz.question.reorder",
|
||||
user=user,
|
||||
target_type="quiz",
|
||||
target_uid=quiz["uid"],
|
||||
target_label=quiz.get("title") or quiz["uid"],
|
||||
summary=f"{user['username']} reordered the questions of quiz {quiz.get('title')}",
|
||||
links=[audit.target("quiz", quiz["uid"], quiz.get("title") or "")],
|
||||
)
|
||||
await notify_quiz(quiz["uid"])
|
||||
return action_result(request, f"/quizzes/{slug}/edit", data={"order": data.order})
|
||||
|
||||
|
||||
@router.post("/{slug}/questions/{question_uid}")
|
||||
async def edit_question(
|
||||
request: Request,
|
||||
slug: str,
|
||||
question_uid: str,
|
||||
data: Annotated[QuizQuestionForm, Depends(json_or_form(QuizQuestionForm))],
|
||||
):
|
||||
user = require_user(request)
|
||||
quiz = require_editor(load_quiz(slug, user), user)
|
||||
try:
|
||||
question = store.edit_question(quiz["uid"], question_uid, _question_payload(data))
|
||||
except QuizError as exc:
|
||||
return action_error(request, str(exc), f"/quizzes/{slug}/edit")
|
||||
_audit_question(request, user, quiz, question, "edit")
|
||||
await notify_quiz(quiz["uid"])
|
||||
return action_result(request, f"/quizzes/{slug}/edit", data={"uid": question_uid})
|
||||
|
||||
|
||||
@router.post("/{slug}/questions/{question_uid}/delete")
|
||||
async def delete_question(request: Request, slug: str, question_uid: str):
|
||||
user = require_user(request)
|
||||
quiz = require_editor(load_quiz(slug, user), user)
|
||||
try:
|
||||
question = store.delete_question(quiz["uid"], question_uid, user["uid"])
|
||||
except QuizError as exc:
|
||||
return action_error(request, str(exc), f"/quizzes/{slug}/edit")
|
||||
_audit_question(request, user, quiz, question, "delete")
|
||||
await notify_quiz(quiz["uid"])
|
||||
return action_result(request, f"/quizzes/{slug}/edit", data={"uid": question_uid})
|
||||
|
||||
|
||||
def _audit_question(request, user: dict, quiz: dict, question: dict, action: str) -> None:
|
||||
audit.record(
|
||||
request,
|
||||
f"quiz.question.{action}",
|
||||
user=user,
|
||||
target_type="quiz",
|
||||
target_uid=quiz["uid"],
|
||||
target_label=quiz.get("title") or quiz["uid"],
|
||||
summary=(
|
||||
f"{user['username']} {action}d a {question.get('kind')} question "
|
||||
f"on quiz {quiz.get('title')}"
|
||||
),
|
||||
metadata={"question_uid": question.get("uid"), "kind": question.get("kind")},
|
||||
links=[audit.target("quiz", quiz["uid"], quiz.get("title") or "")],
|
||||
)
|
||||
@@ -13,7 +13,7 @@ from devplacepy.dependencies import json_or_form
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
REACTABLE: set[str] = {"post", "comment", "gist", "project"}
|
||||
REACTABLE: set[str] = {"post", "comment", "gist", "project", "quiz"}
|
||||
|
||||
@router.post("/{target_type}/{target_uid}")
|
||||
async def react(
|
||||
|
||||
@@ -5,16 +5,23 @@ from pathlib import Path
|
||||
from typing import Annotated
|
||||
from fastapi import Depends, APIRouter, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from devplacepy.database import get_table, get_int_setting
|
||||
from devplacepy.models import UploadUrlForm
|
||||
from devplacepy.database import (
|
||||
get_table,
|
||||
get_int_setting,
|
||||
get_user_attachments,
|
||||
get_user_attachment,
|
||||
)
|
||||
from devplacepy.models import UploadUrlForm, AttachmentRenameForm
|
||||
from devplacepy.utils import require_user_api, is_admin, track_action
|
||||
from devplacepy.attachments import (
|
||||
store_attachment,
|
||||
store_attachment_from_url,
|
||||
soft_delete_attachment,
|
||||
rename_attachment,
|
||||
is_extension_allowed,
|
||||
RemoteFetchError,
|
||||
)
|
||||
from devplacepy.schemas import UploadItemOut, UploadsListOut
|
||||
from urllib.parse import urlparse
|
||||
from devplacepy.services.audit import record as audit
|
||||
from devplacepy.dependencies import json_or_form
|
||||
@@ -91,6 +98,74 @@ async def upload_from_url(request: Request, data: Annotated[UploadUrlForm, Depen
|
||||
)
|
||||
return JSONResponse(result, status_code=201)
|
||||
|
||||
def _linked_filter(linked):
|
||||
if linked is None:
|
||||
return None
|
||||
return linked.strip().lower() in ("1", "true", "yes", "linked")
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_attachments(
|
||||
request: Request, page: int = 1, linked: str | None = None
|
||||
):
|
||||
user = require_user_api(request)
|
||||
items, pagination = get_user_attachments(
|
||||
user["uid"], page=page, linked=_linked_filter(linked)
|
||||
)
|
||||
payload = {
|
||||
"attachments": items,
|
||||
"pagination": pagination,
|
||||
"total": pagination.get("total", len(items)),
|
||||
}
|
||||
return JSONResponse(UploadsListOut.model_validate(payload).model_dump(mode="json"))
|
||||
|
||||
|
||||
@router.get("/{attachment_uid}")
|
||||
async def get_attachment_route(request: Request, attachment_uid: str):
|
||||
user = require_user_api(request)
|
||||
item = get_user_attachment(attachment_uid)
|
||||
if not item:
|
||||
return JSONResponse({"error": "Attachment not found"}, status_code=404)
|
||||
if item.get("user_uid") and item["user_uid"] != user["uid"] and not is_admin(user):
|
||||
return JSONResponse({"error": "Not authorized"}, status_code=403)
|
||||
return JSONResponse(UploadItemOut.model_validate(item).model_dump(mode="json"))
|
||||
|
||||
|
||||
@router.patch("/{attachment_uid}")
|
||||
async def rename_attachment_route(
|
||||
request: Request,
|
||||
attachment_uid: str,
|
||||
data: Annotated[
|
||||
AttachmentRenameForm, Depends(json_or_form(AttachmentRenameForm))
|
||||
],
|
||||
):
|
||||
user = require_user_api(request)
|
||||
att = get_table("attachments").find_one(uid=attachment_uid, deleted_at=None)
|
||||
if not att:
|
||||
return JSONResponse({"error": "Attachment not found"}, status_code=404)
|
||||
if att.get("user_uid") and att["user_uid"] != user["uid"] and not is_admin(user):
|
||||
return JSONResponse({"error": "Not authorized"}, status_code=403)
|
||||
old_name = att.get("original_filename")
|
||||
new_name = rename_attachment(attachment_uid, data.filename)
|
||||
if new_name is None:
|
||||
return JSONResponse({"error": "Invalid filename"}, status_code=400)
|
||||
logger.info(f"Attachment {attachment_uid} renamed to {new_name} by {user['username']}")
|
||||
audit.record(
|
||||
request,
|
||||
"attachment.rename",
|
||||
user=user,
|
||||
target_type="attachment",
|
||||
target_uid=attachment_uid,
|
||||
target_label=new_name,
|
||||
old_value=old_name,
|
||||
new_value=new_name,
|
||||
summary=f"{user['username']} renamed attachment to {new_name}",
|
||||
links=[audit.attachment_link(attachment_uid, new_name)],
|
||||
)
|
||||
item = get_user_attachment(attachment_uid)
|
||||
return JSONResponse(UploadItemOut.model_validate(item).model_dump(mode="json"))
|
||||
|
||||
|
||||
@router.delete("/delete/{attachment_uid}")
|
||||
async def delete_attachment_route(request: Request, attachment_uid: str):
|
||||
user = require_user_api(request)
|
||||
|
||||
@@ -12,7 +12,7 @@ from devplacepy.dependencies import json_or_form
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
VOTABLE = {"post", "comment", "gist", "project"}
|
||||
VOTABLE = {"post", "comment", "gist", "project", "quiz"}
|
||||
|
||||
|
||||
@router.post("/{target_type}/{target_uid}")
|
||||
|
||||
@@ -4,6 +4,7 @@ import logging
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Request
|
||||
from starlette.requests import ClientDisconnect
|
||||
from starlette.responses import PlainTextResponse, Response
|
||||
|
||||
from devplacepy.config import XMLRPC_BIND, XMLRPC_PORT
|
||||
@@ -49,7 +50,11 @@ async def info(request: Request, path: str = "") -> PlainTextResponse:
|
||||
@router.post("/")
|
||||
@router.post("/{path:path}")
|
||||
async def proxy(request: Request, path: str = "") -> Response:
|
||||
body = await request.body()
|
||||
try:
|
||||
body = await request.body()
|
||||
except ClientDisconnect:
|
||||
logger.warning("XML-RPC client disconnected before request body was read")
|
||||
return PlainTextResponse("Client disconnected", status_code=400)
|
||||
headers = {
|
||||
key: value
|
||||
for key, value in request.headers.items()
|
||||
|
||||
@@ -99,6 +99,9 @@ from devplacepy.schemas.backups import (
|
||||
BackupStoragePathOut,
|
||||
)
|
||||
from devplacepy.schemas.admin import (
|
||||
AdminDeviiTaskItemOut,
|
||||
AdminDeviiTasksOut,
|
||||
AdminGameOut,
|
||||
AdminMediaItemOut,
|
||||
AdminMediaOut,
|
||||
AdminNewsItemOut,
|
||||
@@ -114,6 +117,11 @@ from devplacepy.schemas.gateway import (
|
||||
GatewayUsageOut,
|
||||
UserAiUsageOut,
|
||||
)
|
||||
from devplacepy.schemas.uploads import (
|
||||
UploadItemOut,
|
||||
UploadsListOut,
|
||||
)
|
||||
from devplacepy.schemas.statistics import StatisticsOut
|
||||
from devplacepy.schemas.auth import (
|
||||
AuthPageOut,
|
||||
DeviiPageOut,
|
||||
@@ -137,6 +145,27 @@ from devplacepy.schemas.dbapi import (
|
||||
DbTableOut,
|
||||
NlQueryOut,
|
||||
)
|
||||
from devplacepy.schemas.quiz import (
|
||||
QuizAnswerOut,
|
||||
QuizAnswerResultOut,
|
||||
QuizAttemptOut,
|
||||
QuizAttemptPageOut,
|
||||
QuizBuilderOut,
|
||||
QuizDetailOut,
|
||||
QuizDocumentOut,
|
||||
QuizFormPageOut,
|
||||
QuizLeaderboardEntryOut,
|
||||
QuizLeaderboardOut,
|
||||
QuizListItemOut,
|
||||
QuizOptionOut,
|
||||
QuizOut,
|
||||
QuizProgressOut,
|
||||
QuizQuestionOut,
|
||||
QuizResultOut,
|
||||
QuizScoreboardEntryOut,
|
||||
QuizScoreboardOut,
|
||||
QuizzesOut,
|
||||
)
|
||||
from devplacepy.schemas.game import (
|
||||
GameCropOut,
|
||||
GameFarmOut,
|
||||
|
||||
@@ -72,3 +72,38 @@ class AdminTrashOut(_Out):
|
||||
tables: list[dict] = []
|
||||
pagination: Optional[Any] = None
|
||||
admin_section: Optional[str] = None
|
||||
|
||||
|
||||
class AdminDeviiTaskItemOut(_Out):
|
||||
uid: str
|
||||
label: Optional[str] = None
|
||||
owner_uid: str = ""
|
||||
owner: str = ""
|
||||
owner_is_admin: bool = False
|
||||
quota: dict = {}
|
||||
schedule: str = ""
|
||||
status: str = ""
|
||||
enabled: bool = False
|
||||
run_count: int = 0
|
||||
max_runs: Optional[int] = None
|
||||
failure_count: int = 0
|
||||
next_run_at: Optional[str] = None
|
||||
expires_at: Optional[str] = None
|
||||
last_error: Optional[str] = None
|
||||
|
||||
|
||||
class AdminDeviiTasksOut(_Out):
|
||||
items: list[AdminDeviiTaskItemOut] = []
|
||||
state: str = "active"
|
||||
tabs: list[dict] = []
|
||||
limits: dict = {}
|
||||
admin_section: Optional[str] = None
|
||||
|
||||
|
||||
class AdminGameOut(_Out):
|
||||
era_active: bool = False
|
||||
era_name: str = ""
|
||||
era_number: int = 0
|
||||
era_started_at: str = ""
|
||||
era_ends_at: str = ""
|
||||
admin_section: Optional[str] = None
|
||||
|
||||
@@ -40,12 +40,23 @@ class LandingPostOut(_Out):
|
||||
slug: str = ""
|
||||
|
||||
|
||||
class TrendingTopicOut(_Out):
|
||||
topic: str = ""
|
||||
count: int = 0
|
||||
|
||||
|
||||
class LandingOut(_Out):
|
||||
is_authenticated: bool = False
|
||||
user_post_count: int = 0
|
||||
user_stars: int = 0
|
||||
user_xp: int = 0
|
||||
user_level: int = 1
|
||||
xp_progress_pct: int = 0
|
||||
unread_count: int = 0
|
||||
landing_articles: list[LandingArticleOut] = []
|
||||
landing_posts: list[LandingPostOut] = []
|
||||
top_contributors: list = []
|
||||
trending_topics: list[TrendingTopicOut] = []
|
||||
|
||||
|
||||
class DeviiPageOut(_Out):
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from devplacepy.schemas.base import _Out
|
||||
from devplacepy.schemas.content import UserOut
|
||||
|
||||
|
||||
class AwardOut(_Out):
|
||||
uid: str = ""
|
||||
slug: str = ""
|
||||
description: str = ""
|
||||
giver_uid: str = ""
|
||||
receiver_uid: str = ""
|
||||
generated_at: Optional[str] = None
|
||||
created_at: Optional[str] = None
|
||||
image_url: Optional[str] = None
|
||||
thumb_url: Optional[str] = None
|
||||
giver: Optional[UserOut] = None
|
||||
@@ -4,6 +4,8 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from pydantic import ConfigDict, Field
|
||||
|
||||
from devplacepy.schemas.base import _Out
|
||||
|
||||
|
||||
@@ -17,6 +19,8 @@ class UserOut(_Out):
|
||||
website: Optional[str] = None
|
||||
level: Optional[int] = None
|
||||
xp: Optional[int] = None
|
||||
xp_progress_pct: Optional[int] = None
|
||||
xp_next_level: Optional[int] = None
|
||||
stars: Optional[int] = None
|
||||
created_at: Optional[str] = None
|
||||
last_seen: Optional[str] = None
|
||||
@@ -62,8 +66,10 @@ class PollOut(_Out):
|
||||
|
||||
|
||||
class BadgeOut(_Out):
|
||||
name: Optional[str] = None
|
||||
name: Optional[str] = Field(None, alias="badge_name")
|
||||
icon: Optional[str] = None
|
||||
created_at: Optional[str] = None
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
|
||||
class PostOut(_Out):
|
||||
@@ -202,3 +208,4 @@ class MessageOut(_Out):
|
||||
|
||||
|
||||
CommentItemOut.model_rebuild()
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ class GameCropOut(_Out):
|
||||
min_level: int = 1
|
||||
grow_seconds: int = 0
|
||||
locked: bool = False
|
||||
market_state: str = "normal"
|
||||
|
||||
|
||||
class GamePlotOut(_Out):
|
||||
@@ -36,6 +37,8 @@ class GamePlotOut(_Out):
|
||||
steal_reason: str = ""
|
||||
is_golden: bool = False
|
||||
fertilize_cost: int = 0
|
||||
raided_fraction: float = 0.0
|
||||
raided_pct: int = 0
|
||||
|
||||
|
||||
class GamePerkOut(_Out):
|
||||
@@ -62,6 +65,38 @@ class GameLegacyOut(_Out):
|
||||
effect: str = ""
|
||||
|
||||
|
||||
class GameMasteryOut(_Out):
|
||||
key: str = ""
|
||||
name: str = ""
|
||||
icon: str = ""
|
||||
description: str = ""
|
||||
level: int = 0
|
||||
max_level: int = 0
|
||||
cost: int = 0
|
||||
maxed: bool = False
|
||||
effect: str = ""
|
||||
|
||||
|
||||
class GameInfrastructureOut(_Out):
|
||||
key: str = ""
|
||||
name: str = ""
|
||||
icon: str = ""
|
||||
description: str = ""
|
||||
cost: int = 0
|
||||
min_prestige: int = 0
|
||||
owned: bool = False
|
||||
|
||||
|
||||
class GameCosmeticOut(_Out):
|
||||
key: str = ""
|
||||
name: str = ""
|
||||
icon: str = ""
|
||||
description: str = ""
|
||||
cost_coins: int = 0
|
||||
kind: str = ""
|
||||
owned: bool = False
|
||||
|
||||
|
||||
class GameQuestOut(_Out):
|
||||
kind: str = ""
|
||||
label: str = ""
|
||||
@@ -71,6 +106,8 @@ class GameQuestOut(_Out):
|
||||
reward_xp: int = 0
|
||||
claimed: bool = False
|
||||
can_claim: bool = False
|
||||
scope: str = "daily"
|
||||
reward_stars: int = 0
|
||||
|
||||
|
||||
class GameFarmOut(_Out):
|
||||
@@ -99,14 +136,48 @@ class GameFarmOut(_Out):
|
||||
prestige_multiplier: float = 1.0
|
||||
prestige_min_level: int = 0
|
||||
prestige_available: bool = False
|
||||
refactor_cost: int = 0
|
||||
refactor_affordable: bool = False
|
||||
refactor_carryover_pct: int = 0
|
||||
refactor_carryover_preview: int = 0
|
||||
grant_available: bool = False
|
||||
grant_amount: int = 0
|
||||
grant_reason: str = ""
|
||||
treasury_balance: int = 0
|
||||
streak: int = 0
|
||||
daily_available: bool = False
|
||||
daily_streak_reset: bool = False
|
||||
daily_reward: int = 0
|
||||
perks: list[GamePerkOut] = []
|
||||
quests: list[GameQuestOut] = []
|
||||
stars: int = 0
|
||||
legacy: list[GameLegacyOut] = []
|
||||
steal_cooldown_seconds: int = 0
|
||||
mastery_points: int = 0
|
||||
mastery_points_earned_total: int = 0
|
||||
mastery: list[GameMasteryOut] = []
|
||||
infrastructure: list[GameInfrastructureOut] = []
|
||||
defense_level: int = 0
|
||||
defense_tier_name: str = ""
|
||||
defense_upkeep_daily: int = 0
|
||||
defense_next_cost: int = 0
|
||||
cosmetics: list[GameCosmeticOut] = []
|
||||
active_title: str = ""
|
||||
underdog_boost_seconds_remaining: int = 0
|
||||
contract_boost_seconds_remaining: int = 0
|
||||
auto_harvested: int = 0
|
||||
auto_harvest_coins: int = 0
|
||||
auto_harvest_xp: int = 0
|
||||
steal_max_per_victim_per_day: int = 0
|
||||
defense_downgrade_available: bool = False
|
||||
mastery_analytics_unlocked: bool = False
|
||||
lifetime_coins_earned: int = 0
|
||||
lifetime_harvests: int = 0
|
||||
harvests_week: int = 0
|
||||
era_active: bool = False
|
||||
era_name: str = ""
|
||||
era_coins: int = 0
|
||||
era_harvests: int = 0
|
||||
|
||||
|
||||
class GameStateOut(_Out):
|
||||
@@ -130,6 +201,9 @@ class GameLeaderboardEntryOut(_Out):
|
||||
total_harvests: int = 0
|
||||
prestige: int = 0
|
||||
score: int = 0
|
||||
raid_avg: float = 0.0
|
||||
time_to_kernel_seconds: int = 0
|
||||
title: str = ""
|
||||
|
||||
|
||||
class GameLeaderboardOut(_Out):
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user