forked from retoor/devplacepy
Compare commits
39
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d5d5f90be | ||
|
|
46f87a48e3 | ||
|
|
e05f97c924 | ||
|
|
2d72e0785d | ||
|
|
dbe1e2670b | ||
|
|
fda72c5afb | ||
|
|
6b3df26a52 | ||
|
|
b1a104ebb1 | ||
|
|
b5fb6436d0 | ||
|
|
3006a1b039 | ||
|
|
8b89f0adcf | ||
|
|
9cfaddfc40 | ||
|
|
ca6c527e32 | ||
|
|
535e9c5dc1 | ||
|
|
3467f55df9 | ||
|
|
a3963611f0 | ||
|
|
b8277d6351 | ||
|
|
f996336afb | ||
|
|
4780016980 | ||
|
|
7f17d69f5c | ||
|
|
5774d83ece | ||
|
|
ac04cf6817 | ||
|
|
2620ecc0f1 | ||
|
|
1f320b45ec | ||
|
|
a8ed5b690f | ||
|
|
1eeb54598f | ||
|
|
a0d573375a | ||
|
|
ad1736ebf1 | ||
|
|
ef1c914e23 | ||
|
|
b534a496fd | ||
|
|
582e37d176 | ||
|
|
64c3983c9f | ||
|
|
34fa56a836 | ||
|
|
77f043640e | ||
|
|
34f76aad65 | ||
|
|
024edb5291 | ||
|
|
4ffddc8913 | ||
|
|
35e79ba8c7 | ||
|
|
32314fc6d6 |
@@ -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)"
|
||||
]
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -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
|
||||
@@ -23,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
|
||||
@@ -31,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>
|
||||
@@ -53,10 +59,17 @@ 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 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)
|
||||
@@ -66,6 +79,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
|
||||
@@ -128,11 +147,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 |
|
||||
|
||||
@@ -171,6 +192,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` |
|
||||
@@ -275,13 +297,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
|
||||
|
||||
@@ -300,7 +333,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.
|
||||
|
||||
@@ -315,3 +348,4 @@ Single-host Docker Compose (`docker-compose.yml`): an **app** container (Uvicorn
|
||||
- **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`.
|
||||
- **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 start period** (`start_period: 120s` in `docker-compose.yml`, `--start-period=120s` in `Dockerfile`): full startup takes ~110s (DB init, services, uvicorn workers). The start period must stay above that. Bump both files if startup grows.
|
||||
|
||||
+1
-1
@@ -33,7 +33,7 @@ EXPOSE 10500
|
||||
ENV DEVPLACE_WEB_WORKERS=2
|
||||
ENV DEVPLACE_TEMPLATE_AUTO_RELOAD=0
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=10s \
|
||||
HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=120s \
|
||||
CMD curl -f http://localhost:10500/ || exit 1
|
||||
|
||||
CMD ["sh", "-c", "DEVPLACE_STATIC_VERSION=${DEVPLACE_STATIC_VERSION:-$(date +%s)} exec uvicorn devplacepy.main:app --host 0.0.0.0 --port 10500 --workers 2 --backlog 8192 --proxy-headers --forwarded-allow-ips '*'"]
|
||||
|
||||
@@ -12,7 +12,7 @@ DEVPLACE_RATE_LIMIT ?= 1000000
|
||||
PYTHONDONTWRITEBYTECODE := 1
|
||||
export PYTHONDONTWRITEBYTECODE
|
||||
|
||||
.PHONY: install dev clean tree tree-loc zip test test-headed coverage coverage-headed coverage-html locust locust-headless
|
||||
.PHONY: install dev clean tree tree-loc zip test test-headed test-unit test-api test-e2e test-fast test-failed test-first-failure test-slowest test-cache-clean coverage coverage-headed coverage-html locust locust-headless
|
||||
|
||||
install:
|
||||
pip install -e .
|
||||
@@ -43,19 +43,31 @@ zip:
|
||||
@printf 'Wrote %s (%s files)\n' "$(notdir $(CURDIR)).zip" "$$(git ls-files | wc -l)"
|
||||
|
||||
test:
|
||||
PLAYWRIGHT_HEADLESS=1 python -m pytest tests/ -x
|
||||
PLAYWRIGHT_HEADLESS=1 python -m pytest tests/
|
||||
|
||||
test-headed:
|
||||
PLAYWRIGHT_HEADLESS=0 python -m pytest tests/ -x
|
||||
PLAYWRIGHT_HEADLESS=0 python -m pytest tests/
|
||||
|
||||
test-unit:
|
||||
python -m pytest tests/unit -x
|
||||
python -m pytest tests/unit
|
||||
|
||||
test-api:
|
||||
python -m pytest tests/api -x
|
||||
python -m pytest tests/api
|
||||
|
||||
test-e2e:
|
||||
PLAYWRIGHT_HEADLESS=1 python -m pytest tests/e2e -x
|
||||
PLAYWRIGHT_HEADLESS=1 python -m pytest tests/e2e
|
||||
|
||||
test-fast:
|
||||
python -m pytest tests/unit tests/api
|
||||
|
||||
test-failed:
|
||||
PLAYWRIGHT_HEADLESS=1 python -m pytest tests/ --last-failed --last-failed-no-failures none
|
||||
|
||||
test-first-failure:
|
||||
PLAYWRIGHT_HEADLESS=1 python -m pytest tests/ -x
|
||||
|
||||
test-slowest:
|
||||
PLAYWRIGHT_HEADLESS=1 python -m pytest tests/ --durations=40
|
||||
|
||||
coverage:
|
||||
rm -f .coverage .coverage.*
|
||||
@@ -109,8 +121,12 @@ clean:
|
||||
find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
|
||||
find . -type f -name '*.pyc' -delete
|
||||
rm -rf devplacepy.egg-info
|
||||
rm -rf .pytest_cache
|
||||
rm -rf .venv
|
||||
|
||||
test-cache-clean:
|
||||
rm -rf .pytest_cache
|
||||
|
||||
# Container Manager works out of the box: the overlay installs the docker CLI in
|
||||
# the image and mounts the host socket. DOCKER_GID is read straight from the
|
||||
# socket so the UID-1000 app can use it; the data dir is the project's own data/
|
||||
|
||||
@@ -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) |
|
||||
@@ -113,6 +115,47 @@ Member progression is driven by activity and peer recognition.
|
||||
|
||||
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.
|
||||
@@ -125,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`.
|
||||
@@ -582,7 +632,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:
|
||||
|
||||
@@ -606,6 +680,15 @@ Configuration on the Services tab:
|
||||
| `devii_rsearch_timeout` | `300` | Read timeout (seconds) for `rsearch_*` calls; web-grounded answers can take minutes; minimum five minutes |
|
||||
| `devii_email_enabled` | on | Enable the email tools (`email_*`) for signed-in users |
|
||||
| `devii_email_timeout` | `30` | Connection/read timeout (seconds) for IMAP and SMTP calls |
|
||||
| `devii_task_member_create_24h` | `5` | Tasks a member may create per rolling 24 hours (`0` = unlimited) |
|
||||
| `devii_task_member_runs_24h` | `10` | Task runs a member may execute per rolling 24 hours; excess runs are postponed |
|
||||
| `devii_task_admin_create_24h` | `5` | Tasks an administrator may create per rolling 24 hours |
|
||||
| `devii_task_admin_runs_24h` | `100` | Task runs an administrator may execute per rolling 24 hours |
|
||||
| `devii_task_max_concurrent` | `4` | Scheduled tasks running at once across all owners, handed out round-robin, one at a time per owner |
|
||||
| `devii_task_max_per_owner` | `10` | Active scheduled tasks one administrator may hold (`0` = no cap) |
|
||||
| `devii_task_daily_usd` | `0.5` | Rolling 24h spend cap for scheduled runs, separate from the interactive quota (`0` = unlimited) |
|
||||
| `devii_task_max_failures` | `3` | Consecutive failures after which a task disables itself (`0` = never) |
|
||||
| `devii_task_owner_idle_days` | `30` | Disable an owner's tasks after this many days without activity (`0` = never) |
|
||||
|
||||
Beyond the platform tools, Devii has external **web** tools. `fetch_url` reads a web page;
|
||||
`http_request` makes an arbitrary HTTP call (GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS) to any
|
||||
|
||||
@@ -546,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"):
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -107,6 +107,106 @@ def cmd_devii_lessons_prune(args):
|
||||
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")
|
||||
@@ -138,3 +238,19 @@ def register_devii(subparsers):
|
||||
lessons_clear = lessons_sub.add_parser("clear", help="Hard-delete every devii_lessons row")
|
||||
lessons_clear.add_argument("--force", action="store_true", help="Required to confirm hard deletion")
|
||||
lessons_clear.set_defaults(func=cmd_devii_lessons_clear)
|
||||
|
||||
devii_tasks = devii_sub.add_parser("tasks", help="Inspect and stop scheduled Devii tasks")
|
||||
tasks_sub = devii_tasks.add_subparsers(title="sub-action", dest="sub_action")
|
||||
|
||||
tasks_list = tasks_sub.add_parser("list", help="List scheduled tasks and their owners")
|
||||
tasks_list.add_argument("--all", action="store_true", help="Include disabled tasks")
|
||||
tasks_list.set_defaults(func=cmd_devii_tasks_list)
|
||||
|
||||
tasks_disable = tasks_sub.add_parser("disable", help="Disable one task by uid")
|
||||
tasks_disable.add_argument("uid", help="Uid of the task")
|
||||
tasks_disable.set_defaults(func=cmd_devii_tasks_disable)
|
||||
|
||||
tasks_prune = tasks_sub.add_parser(
|
||||
"prune", help="Disable every task whose owner is not an administrator"
|
||||
)
|
||||
tasks_prune.set_defaults(func=cmd_devii_tasks_prune)
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from devplacepy.cli._shared import _audit_cli
|
||||
|
||||
|
||||
def cmd_game_market_prune(args):
|
||||
from devplacepy.services.game import store
|
||||
|
||||
removed = store.prune_ticks()
|
||||
_audit_cli(
|
||||
"cli.game.market.prune",
|
||||
f"CLI pruned {removed} stale Code Farm market tick(s)",
|
||||
metadata={"count": removed},
|
||||
)
|
||||
print(f"Pruned {removed} stale market tick bucket(s)")
|
||||
|
||||
|
||||
def cmd_game_steals_prune(args):
|
||||
from devplacepy.services.game import store
|
||||
|
||||
removed = store.prune_steals()
|
||||
_audit_cli(
|
||||
"cli.game.steals.prune",
|
||||
f"CLI pruned {removed} old Code Farm raid record(s)",
|
||||
metadata={"count": removed},
|
||||
)
|
||||
print(f"Pruned {removed} raid record(s)")
|
||||
|
||||
|
||||
def cmd_game_era_status(args):
|
||||
from devplacepy.services.game import store
|
||||
|
||||
era = store.active_era()
|
||||
if not era:
|
||||
print("No Era is currently running.")
|
||||
return
|
||||
print(f"Era {era['era_number']}: {era['name']}")
|
||||
print(f"Started: {era['started_at']}")
|
||||
print(f"Scheduled end: {era['ends_at']}")
|
||||
|
||||
|
||||
def cmd_game_era_start(args):
|
||||
from devplacepy.services.game import GameError, store
|
||||
|
||||
try:
|
||||
era = store.start_era(args.name, args.duration_days)
|
||||
except GameError as exc:
|
||||
print(f"Error: {exc}")
|
||||
return
|
||||
_audit_cli(
|
||||
"cli.game.era.start",
|
||||
f"CLI started Code Farm Era {era['era_number']}: {era['name']}",
|
||||
metadata={"era_number": era["era_number"], "name": era["name"]},
|
||||
)
|
||||
print(f"Started Era {era['era_number']}: {era['name']}")
|
||||
|
||||
|
||||
def cmd_game_era_end(args):
|
||||
from devplacepy.services.game import GameError, store
|
||||
|
||||
try:
|
||||
result = store.end_era()
|
||||
except GameError as exc:
|
||||
print(f"Error: {exc}")
|
||||
return
|
||||
_audit_cli(
|
||||
"cli.game.era.end",
|
||||
f"CLI ended Code Farm Era {result['era_number']}",
|
||||
metadata=result,
|
||||
)
|
||||
print(f"Ended Era {result['era_number']} ({result['participants']} participant(s) ranked)")
|
||||
|
||||
|
||||
def register_game(subparsers):
|
||||
game = subparsers.add_parser("game", help="Code Farm management")
|
||||
game_sub = game.add_subparsers(title="action", dest="action")
|
||||
|
||||
market = game_sub.add_parser("market", help="Code Farm market saturation data")
|
||||
market_sub = market.add_subparsers(title="market_action", dest="market_action")
|
||||
market_prune = market_sub.add_parser(
|
||||
"prune", help="Delete market tick buckets older than the tracking window"
|
||||
)
|
||||
market_prune.set_defaults(func=cmd_game_market_prune)
|
||||
|
||||
steals = game_sub.add_parser("steals", help="Code Farm raid history")
|
||||
steals_sub = steals.add_subparsers(title="steals_action", dest="steals_action")
|
||||
steals_prune = steals_sub.add_parser(
|
||||
"prune", help="Delete raid records older than the raid-efficiency window"
|
||||
)
|
||||
steals_prune.set_defaults(func=cmd_game_steals_prune)
|
||||
|
||||
era = game_sub.add_parser("era", help="Code Farm Era management")
|
||||
era_sub = era.add_subparsers(title="era_action", dest="era_action")
|
||||
era_status = era_sub.add_parser("status", help="Show the current Era status")
|
||||
era_status.set_defaults(func=cmd_game_era_status)
|
||||
era_start = era_sub.add_parser("start", help="Start a new Era")
|
||||
era_start.add_argument("name", help="Era name")
|
||||
era_start.add_argument(
|
||||
"--days", dest="duration_days", type=int, default=28, help="Planned Era length in days"
|
||||
)
|
||||
era_start.set_defaults(func=cmd_game_era_start)
|
||||
era_end = era_sub.add_parser("end", help="End the currently running Era")
|
||||
era_end.set_defaults(func=cmd_game_era_end)
|
||||
@@ -0,0 +1,103 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import sys
|
||||
|
||||
from devplacepy.cli._shared import _audit_cli
|
||||
|
||||
|
||||
def cmd_gateway_quota_list(args):
|
||||
from devplacepy.services.openai_gateway import quota
|
||||
|
||||
rules = quota.quota_rule_store.list()
|
||||
if not rules:
|
||||
print("No quota rules. Every caller is capped by the global defaults on /admin/services/openai.")
|
||||
return
|
||||
for rule in rules:
|
||||
spent = quota.spent_24h(rule["owner_kind"], rule["owner_id"], rule["app_reference"])
|
||||
scope = ", ".join(
|
||||
f"{key}={rule[key]}" for key in ("owner_kind", "owner_id", "app_reference") if rule[key]
|
||||
) or "(no dimensions - invalid)"
|
||||
limit = "unlimited" if rule["limit_usd"] == 0 else f"${rule['limit_usd']:.2f}/24h"
|
||||
active = "active" if rule["is_active"] else "inactive"
|
||||
label = f" - {rule['label']}" if rule["label"] else ""
|
||||
print(f"{rule['uid']} [{scope}] {limit} spent=${spent:.4f} {active}{label}")
|
||||
|
||||
|
||||
def cmd_gateway_quota_set(args):
|
||||
from pydantic import ValidationError
|
||||
from devplacepy.services.openai_gateway import quota
|
||||
|
||||
try:
|
||||
payload = quota.QuotaRuleIn(
|
||||
owner_kind=args.owner_kind,
|
||||
owner_id=args.owner_id,
|
||||
app_reference=args.app_reference,
|
||||
limit_usd=args.limit_usd,
|
||||
is_active=not args.inactive,
|
||||
label=args.label or "",
|
||||
)
|
||||
except ValidationError as exc:
|
||||
print(f"Invalid rule: {exc.errors()[0].get('msg', exc)}")
|
||||
sys.exit(1)
|
||||
saved = quota.quota_rule_store.set(payload, uid=args.uid, created_by="cli")
|
||||
_audit_cli(
|
||||
"gateway.quota_rule.update",
|
||||
f"CLI saved gateway quota rule {saved['uid']}",
|
||||
metadata={
|
||||
"owner_kind": saved["owner_kind"],
|
||||
"owner_id": saved["owner_id"],
|
||||
"app_reference": saved["app_reference"],
|
||||
"limit_usd": saved["limit_usd"],
|
||||
},
|
||||
target_type="gateway_quota_rule",
|
||||
target_uid=saved["uid"],
|
||||
)
|
||||
print(f"Saved quota rule {saved['uid']}")
|
||||
|
||||
|
||||
def cmd_gateway_quota_delete(args):
|
||||
from devplacepy.services.openai_gateway import quota
|
||||
|
||||
if not quota.quota_rule_store.remove(args.uid):
|
||||
print(f"Quota rule '{args.uid}' not found")
|
||||
sys.exit(1)
|
||||
_audit_cli(
|
||||
"gateway.quota_rule.delete",
|
||||
f"CLI deleted gateway quota rule {args.uid}",
|
||||
target_type="gateway_quota_rule",
|
||||
target_uid=args.uid,
|
||||
)
|
||||
print(f"Deleted quota rule {args.uid}")
|
||||
|
||||
|
||||
def register_gateway(subparsers):
|
||||
gateway = subparsers.add_parser("gateway", help="AI gateway management")
|
||||
gateway_sub = gateway.add_subparsers(title="action", dest="action")
|
||||
|
||||
quota = gateway_sub.add_parser("quota", help="Manage rolling-24h AI gateway quota rules")
|
||||
quota_sub = quota.add_subparsers(title="sub-action", dest="sub_action")
|
||||
|
||||
quota_list = quota_sub.add_parser("list", help="List all quota rules and their current 24h spend")
|
||||
quota_list.set_defaults(func=cmd_gateway_quota_list)
|
||||
|
||||
quota_set = quota_sub.add_parser(
|
||||
"set", help="Create or update a quota rule (scope by role/user/app, any combination)"
|
||||
)
|
||||
quota_set.add_argument("--uid", help="Existing rule uid to update; omit to create a new rule")
|
||||
quota_set.add_argument(
|
||||
"--owner-kind",
|
||||
choices=("internal", "key", "user", "admin", "anonymous"),
|
||||
help="Role to scope by. Omit for any role",
|
||||
)
|
||||
quota_set.add_argument("--owner-id", help="Specific user uid to scope by. Omit for any caller")
|
||||
quota_set.add_argument("--app-reference", help="App label to scope by. Omit for any app")
|
||||
quota_set.add_argument(
|
||||
"--limit-usd", type=float, required=True, help="Rolling 24h USD cap (0 = unlimited)"
|
||||
)
|
||||
quota_set.add_argument("--label", help="Optional admin-facing note")
|
||||
quota_set.add_argument("--inactive", action="store_true", help="Create the rule disabled")
|
||||
quota_set.set_defaults(func=cmd_gateway_quota_set)
|
||||
|
||||
quota_delete = quota_sub.add_parser("delete", help="Delete a quota rule by uid")
|
||||
quota_delete.add_argument("uid", help="Quota rule uid")
|
||||
quota_delete.set_defaults(func=cmd_gateway_quota_delete)
|
||||
@@ -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)
|
||||
@@ -83,6 +83,18 @@ AWARD_IMAGE_PROMPT_DEFAULT = (
|
||||
"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`"
|
||||
|
||||
@@ -52,8 +52,8 @@ from devplacepy.services.seo_meta import schedule_seo_meta_for_table
|
||||
|
||||
CREATE_METADATA_KEYS = ("project_type", "is_private", "language", "topic", "status")
|
||||
|
||||
BOOKMARKABLE_TYPES = {"post", "gist", "project", "news"}
|
||||
REACTABLE_TYPES = {"post", "comment", "gist", "project"}
|
||||
BOOKMARKABLE_TYPES = {"post", "gist", "project", "news", "quiz"}
|
||||
REACTABLE_TYPES = {"post", "comment", "gist", "project", "quiz"}
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -201,7 +201,7 @@ def create_content_item(
|
||||
return uid, slug
|
||||
|
||||
|
||||
VOTE_NOTIFY_TYPES = {"post", "comment", "gist", "project"}
|
||||
VOTE_NOTIFY_TYPES = {"post", "comment", "gist", "project", "quiz"}
|
||||
|
||||
|
||||
def apply_vote(request, user: dict, target_type: str, target_uid: str, value: int) -> dict:
|
||||
@@ -621,6 +621,11 @@ def delete_content_item(
|
||||
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
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
|
||||
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, 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 .engagement import _comment_count_cache, get_comment_counts_by_post_uids, get_post_counts_by_user_uids, get_project_devlog, get_vote_counts, get_user_votes, get_reactions_by_targets, get_user_bookmarks, get_polls_by_post_uids, get_poll_for_post
|
||||
from .usage import _add_usage, _get_usage, add_correction_usage, get_correction_usage, add_modifier_usage, get_modifier_usage, NEWS_USAGE_KEY, add_news_usage, get_news_usage, ISSUE_USAGE_KEY, add_issue_usage, get_issue_usage, SEO_USAGE_KEY, add_seo_usage, get_seo_usage, AWARD_USAGE_KEY, add_award_usage, get_award_usage
|
||||
from .awards import (
|
||||
AWARDS_PER_PAGE,
|
||||
@@ -37,7 +38,7 @@ from .deepsearch import _ds_now, create_deepsearch_session, update_deepsearch_se
|
||||
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, 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_deleted_media
|
||||
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
|
||||
|
||||
@@ -72,6 +73,7 @@ __all__ = [
|
||||
"get_table",
|
||||
"_in_clause",
|
||||
"_now_iso",
|
||||
"conditional_update_row",
|
||||
"_settings_cache",
|
||||
"get_setting",
|
||||
"get_int_setting",
|
||||
@@ -113,6 +115,7 @@ __all__ = [
|
||||
"_comment_count_cache",
|
||||
"get_comment_counts_by_post_uids",
|
||||
"get_post_counts_by_user_uids",
|
||||
"get_project_devlog",
|
||||
"get_vote_counts",
|
||||
"get_user_votes",
|
||||
"get_reactions_by_targets",
|
||||
@@ -234,6 +237,8 @@ __all__ = [
|
||||
"delete_attachments",
|
||||
"_delete_attachment_file",
|
||||
"get_user_media",
|
||||
"get_user_attachments",
|
||||
"get_user_attachment",
|
||||
"get_deleted_media",
|
||||
"_stats_cache",
|
||||
"get_site_stats",
|
||||
@@ -250,3 +255,5 @@ __all__ = [
|
||||
"backfill_api_keys",
|
||||
"_backfill_gamification",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from .core import db
|
||||
|
||||
|
||||
def conditional_update_row(
|
||||
table_name: str, row_uid: str, set_clause: str, where_clause: str, params: dict
|
||||
) -> int:
|
||||
sql = (
|
||||
f"UPDATE {table_name} SET {set_clause}, updated_at = :updated_at "
|
||||
f"WHERE uid = :row_uid AND ({where_clause})"
|
||||
)
|
||||
bind = {
|
||||
**params,
|
||||
"updated_at": datetime.now(timezone.utc).isoformat(),
|
||||
"row_uid": row_uid,
|
||||
}
|
||||
with db:
|
||||
result = db.executable.execute(text(sql), bind)
|
||||
return result.rowcount
|
||||
@@ -124,6 +124,53 @@ def get_user_media(user_uid: str, page: int = 1, per_page: int = 24) -> tuple:
|
||||
return items, pagination
|
||||
|
||||
|
||||
def _decorate_attachment(row: dict) -> dict:
|
||||
from devplacepy.attachments import _row_to_attachment
|
||||
|
||||
item = _row_to_attachment(row)
|
||||
item["linked"] = bool(item.get("target_type"))
|
||||
item["target_url"] = (
|
||||
resolve_object_url(item["target_type"], item["target_uid"])
|
||||
if item["linked"]
|
||||
else None
|
||||
)
|
||||
return item
|
||||
|
||||
|
||||
def get_user_attachments(
|
||||
user_uid: str, page: int = 1, per_page: int = 24, linked=None
|
||||
) -> tuple:
|
||||
if "attachments" not in db.tables:
|
||||
return [], build_pagination(page, 0, per_page)
|
||||
clause = "user_uid=:u AND deleted_at IS NULL"
|
||||
if linked is True:
|
||||
clause += " AND target_type != ''"
|
||||
elif linked is False:
|
||||
clause += " AND target_type = ''"
|
||||
total = list(
|
||||
db.query(f"SELECT COUNT(*) AS n FROM attachments WHERE {clause}", u=user_uid)
|
||||
)[0]["n"]
|
||||
pagination = build_pagination(page, total, per_page)
|
||||
offset = (pagination["page"] - 1) * pagination["per_page"]
|
||||
rows = db.query(
|
||||
f"SELECT * FROM attachments WHERE {clause} "
|
||||
"ORDER BY created_at DESC LIMIT :limit OFFSET :offset",
|
||||
u=user_uid,
|
||||
limit=pagination["per_page"],
|
||||
offset=offset,
|
||||
)
|
||||
return [_decorate_attachment(row) for row in rows], pagination
|
||||
|
||||
|
||||
def get_user_attachment(uid: str) -> dict | None:
|
||||
if "attachments" not in db.tables:
|
||||
return None
|
||||
row = db["attachments"].find_one(uid=uid, deleted_at=None)
|
||||
if not row:
|
||||
return None
|
||||
return _decorate_attachment(row)
|
||||
|
||||
|
||||
def get_deleted_media(page: int = 1, per_page: int = 24) -> tuple:
|
||||
if "attachments" not in db.tables:
|
||||
return [], build_pagination(page, 0, per_page)
|
||||
|
||||
@@ -38,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:
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from .core import TTLCache, _in_clause, db, defaultdict
|
||||
from .core import TTLCache, _in_clause, db, defaultdict, get_table
|
||||
from .pagination import paginate
|
||||
from devplacepy.content import enrich_items
|
||||
from .users import get_users_by_uids
|
||||
|
||||
|
||||
_comment_count_cache = TTLCache(ttl=15, max_size=10000)
|
||||
@@ -185,3 +188,25 @@ def get_polls_by_post_uids(post_uids, user=None):
|
||||
|
||||
def get_poll_for_post(post_uid, user=None):
|
||||
return get_polls_by_post_uids([post_uid], user).get(post_uid)
|
||||
|
||||
|
||||
def get_project_devlog(project_uid: str, before: str | None = None, viewer: dict | None = None) -> tuple[list, str | None]:
|
||||
posts_table = get_table("posts")
|
||||
posts, next_cursor = paginate(
|
||||
posts_table,
|
||||
before=before,
|
||||
viewer_uid=viewer["uid"] if viewer else None,
|
||||
project_uid=project_uid,
|
||||
)
|
||||
if not posts:
|
||||
return [], None
|
||||
|
||||
authors = get_users_by_uids([p["user_uid"] for p in posts])
|
||||
counts = get_comment_counts_by_post_uids([p["uid"] for p in posts])
|
||||
|
||||
result = enrich_items(
|
||||
posts, "post", authors, {"comment_count": counts}, user=viewer
|
||||
)
|
||||
return result, next_cursor
|
||||
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ NOTIFICATION_TYPES = [
|
||||
{"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,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=60, 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)
|
||||
|
||||
+337
-13
@@ -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"):
|
||||
@@ -358,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:
|
||||
@@ -389,6 +405,46 @@ 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"]
|
||||
@@ -471,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"])
|
||||
@@ -518,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"])
|
||||
@@ -1014,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", ""),
|
||||
):
|
||||
@@ -1038,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 (
|
||||
@@ -1045,6 +1142,7 @@ def init_db():
|
||||
("farm_uid", ""),
|
||||
("user_uid", ""),
|
||||
("day", ""),
|
||||
("scope", "daily"),
|
||||
("slot_index", 0),
|
||||
("kind", ""),
|
||||
("label", ""),
|
||||
@@ -1052,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 (
|
||||
@@ -1070,6 +1172,7 @@ def init_db():
|
||||
("planted_at", ""),
|
||||
("ready_at", ""),
|
||||
("watered_by", "[]"),
|
||||
("raided_fraction", 0.0),
|
||||
("created_at", ""),
|
||||
("updated_at", ""),
|
||||
):
|
||||
@@ -1077,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,
|
||||
@@ -1353,9 +1677,6 @@ def migrate_ai_gateway_settings() -> None:
|
||||
if not get_setting("gateway_vision_key", "") and openrouter:
|
||||
set_setting("gateway_vision_key", openrouter)
|
||||
logger.info("Migrated gateway vision key from environment")
|
||||
if not get_setting("gateway_embed_key", "") and openrouter:
|
||||
set_setting("gateway_embed_key", openrouter)
|
||||
logger.info("Migrated gateway embed key from environment")
|
||||
for key in ("news_ai_url", "bot_api_url", "devii_ai_url"):
|
||||
if get_setting(key, "") == OLD_GATEWAY_URL:
|
||||
set_setting(key, INTERNAL_GATEWAY_URL)
|
||||
@@ -1380,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"):
|
||||
@@ -1504,3 +1827,4 @@ def _backfill_gamification():
|
||||
for user in pending:
|
||||
check_milestone_badges(user["uid"])
|
||||
logger.info(f"Gamification backfill processed {len(pending)} users")
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from .core import _in_clause, _now_iso, db, get_table
|
||||
|
||||
|
||||
SEO_META_TYPES = ("post", "project", "gist", "news", "issue")
|
||||
SEO_META_TYPES = ("post", "project", "gist", "news", "issue", "quiz")
|
||||
|
||||
|
||||
def get_seo_metadata(target_type: str, target_uid: str) -> dict | None:
|
||||
|
||||
@@ -42,6 +42,11 @@ SOFT_DELETE_TABLES = [
|
||||
"user_relations",
|
||||
"seo_metadata",
|
||||
"awards",
|
||||
"quizzes",
|
||||
"quiz_questions",
|
||||
"quiz_options",
|
||||
"quiz_attempts",
|
||||
"quiz_answers",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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,12 @@ 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
|
||||
return not tracks_active or bool(row.get("is_active"))
|
||||
|
||||
|
||||
def get_primary_admin_uid():
|
||||
sync_local_cache("admins", _admins_cache)
|
||||
cached = _admins_cache.get("primary")
|
||||
@@ -80,11 +89,17 @@ def get_primary_admin_uid():
|
||||
return None
|
||||
rows = list(
|
||||
db.query(
|
||||
"SELECT uid FROM users WHERE role = 'Admin' "
|
||||
"ORDER BY created_at ASC, id ASC LIMIT 1"
|
||||
"SELECT * FROM users WHERE role = 'Admin' "
|
||||
"ORDER BY (created_at IS NULL OR created_at = ''), created_at ASC, id ASC "
|
||||
"LIMIT :cap",
|
||||
cap=PRIMARY_ADMIN_CANDIDATES,
|
||||
)
|
||||
)
|
||||
primary = rows[0]["uid"] if rows else None
|
||||
tracks_active = "is_active" in db["users"].columns
|
||||
primary = next(
|
||||
(row["uid"] for row in rows if _can_hold_primary_admin(row, tracks_active)),
|
||||
None,
|
||||
)
|
||||
_admins_cache.set("primary", primary or "")
|
||||
return primary
|
||||
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
|
||||
@@ -613,6 +613,53 @@ four ways to sign requests.
|
||||
auth="admin",
|
||||
destructive=True,
|
||||
),
|
||||
endpoint(
|
||||
id="admin-gateway-quota-rules",
|
||||
method="GET",
|
||||
path="/admin/gateway/quota-rules",
|
||||
title="List AI gateway quota rules",
|
||||
summary=(
|
||||
"List every rolling-24h USD quota rule on /openai/v1/*, each scoped by any "
|
||||
"combination of role, specific user uid, and app_reference label, plus the "
|
||||
"global per-role default caps that apply when no rule matches."
|
||||
),
|
||||
auth="admin",
|
||||
interactive=True,
|
||||
),
|
||||
endpoint(
|
||||
id="admin-gateway-quota-rule-set",
|
||||
method="POST",
|
||||
path="/admin/gateway/quota-rules",
|
||||
title="Create or update an AI gateway quota rule",
|
||||
summary=(
|
||||
"Caps rolling-24h USD spend on /openai/v1/*. At least one of owner_kind, "
|
||||
"owner_id, app_reference must be set; leaving a dimension blank makes it a "
|
||||
"wildcard, and the most specific active match wins over other rules and over "
|
||||
"the global default. Pass uid to update an existing rule."
|
||||
),
|
||||
auth="admin",
|
||||
params=[
|
||||
field("uid", "json", "string", False, "", "Existing rule uid to update; omit to create a new rule."),
|
||||
field("owner_kind", "json", "string", False, "user", "internal, key, user, admin, or anonymous. Blank = any role."),
|
||||
field("owner_id", "json", "string", False, "", "Specific user uid. Blank = any caller of the matched role."),
|
||||
field("app_reference", "json", "string", False, "devplace-bots-v-1-0-0", "App label (the X-App-Reference header). Blank = any app."),
|
||||
field("limit_usd", "json", "number", True, "2.5", "Rolling 24h USD cap. 0 = unlimited."),
|
||||
field("is_active", "json", "boolean", False, "true", "Whether the rule is enforced."),
|
||||
field("label", "json", "string", False, "", "Optional admin-facing note."),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="admin-gateway-quota-rule-delete",
|
||||
method="DELETE",
|
||||
path="/admin/gateway/quota-rules/{uid}",
|
||||
title="Delete an AI gateway quota rule",
|
||||
summary="Delete a quota rule; callers it covered fall back to the next most specific rule or the global default.",
|
||||
auth="admin",
|
||||
destructive=True,
|
||||
params=[
|
||||
field("uid", "path", "string", True, "RULE_UID", "Quota rule uid."),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="admin-bots-monitor",
|
||||
method="GET",
|
||||
@@ -670,6 +717,52 @@ four ways to sign requests.
|
||||
)
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="admin-devii-tasks",
|
||||
method="GET",
|
||||
path="/admin/devii-tasks",
|
||||
title="Scheduled Devii tasks",
|
||||
summary=(
|
||||
"Every scheduled Devii task across all owners with its schedule, run count, "
|
||||
"expiry, failure streak, and whether its owner may still schedule, plus the "
|
||||
"configured automation bounds. Returns HTML (or JSON with "
|
||||
"Accept: application/json)."
|
||||
),
|
||||
auth="admin",
|
||||
interactive=True,
|
||||
params=[
|
||||
field(
|
||||
"state",
|
||||
"query",
|
||||
"string",
|
||||
False,
|
||||
"active",
|
||||
"One of active, inactive, all.",
|
||||
)
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="admin-devii-task-disable",
|
||||
method="POST",
|
||||
path="/admin/devii-tasks/{uid}/disable",
|
||||
title="Disable a scheduled task",
|
||||
summary="Stop one scheduled task. The row is kept and stays auditable.",
|
||||
auth="admin",
|
||||
params=[
|
||||
field("uid", "path", "string", True, "", "Uid of the task."),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="admin-devii-task-delete",
|
||||
method="POST",
|
||||
path="/admin/devii-tasks/{uid}/delete",
|
||||
title="Delete a scheduled task",
|
||||
summary="Soft-delete one scheduled task; it moves to the admin trash.",
|
||||
auth="admin",
|
||||
params=[
|
||||
field("uid", "path", "string", True, "", "Uid of the task."),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="admin-backups",
|
||||
method="GET",
|
||||
@@ -830,5 +923,37 @@ four ways to sign requests.
|
||||
params=[field("uid", "path", "string", True, "", "Schedule uid.")],
|
||||
sample_response={"ok": True, "redirect": "/admin/backups"},
|
||||
),
|
||||
endpoint(
|
||||
id="admin-game",
|
||||
method="GET",
|
||||
path="/admin/game",
|
||||
title="Code Farm Era management",
|
||||
summary="View the current Code Farm Era status.",
|
||||
auth="admin",
|
||||
sample_response={"era_active": False, "era_name": ""},
|
||||
),
|
||||
endpoint(
|
||||
id="admin-game-era-start",
|
||||
method="POST",
|
||||
path="/admin/game/era/start",
|
||||
title="Start an Era",
|
||||
summary="Start a new Code Farm Era: resets every farm's visible Era coins/harvests counters to zero. Real coins, prestige, stars, Legacy, and Mastery are never touched.",
|
||||
auth="admin",
|
||||
params=[
|
||||
field("name", "form", "string", True, "Genesis", "Era name."),
|
||||
field("duration_days", "form", "int", False, "28", "Planned Era length in days."),
|
||||
],
|
||||
sample_response={"ok": True, "redirect": "/admin/game"},
|
||||
),
|
||||
endpoint(
|
||||
id="admin-game-era-end",
|
||||
method="POST",
|
||||
path="/admin/game/era/end",
|
||||
title="End the running Era",
|
||||
summary="End the current Era: ranks every participating farm by Era score, awards Stars to the top 10 (and an Era-exclusive cosmetic when available), and permanently records the results.",
|
||||
auth="admin",
|
||||
destructive=True,
|
||||
sample_response={"ok": True, "redirect": "/admin/game"},
|
||||
),
|
||||
],
|
||||
}
|
||||
|
||||
@@ -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"}},
|
||||
),
|
||||
],
|
||||
}
|
||||
|
||||
@@ -134,7 +134,7 @@ for signing DevPlace's own requests.
|
||||
path="/openai/v1/chat/completions",
|
||||
title="Chat completions",
|
||||
summary="OpenAI-compatible chat completion. Supports streaming.",
|
||||
auth="public",
|
||||
auth="user",
|
||||
encoding="json",
|
||||
params=[
|
||||
field(
|
||||
@@ -174,7 +174,7 @@ for signing DevPlace's own requests.
|
||||
path="/openai/v1/embeddings",
|
||||
title="Embeddings",
|
||||
summary="OpenAI-compatible text embeddings. Request model molodetz~embed.",
|
||||
auth="public",
|
||||
auth="user",
|
||||
encoding="json",
|
||||
params=[
|
||||
field(
|
||||
@@ -214,7 +214,7 @@ for signing DevPlace's own requests.
|
||||
path="/openai/v1/images/generations",
|
||||
title="Image generation",
|
||||
summary="OpenAI-compatible image generation. Request model molodetz-img-small.",
|
||||
auth="public",
|
||||
auth="user",
|
||||
encoding="json",
|
||||
params=[
|
||||
field(
|
||||
@@ -262,7 +262,7 @@ for signing DevPlace's own requests.
|
||||
path="/openai/v1/{path}",
|
||||
title="Passthrough",
|
||||
summary="Any other /v1 path is forwarded to the upstream as-is.",
|
||||
auth="public",
|
||||
auth="user",
|
||||
interactive=False,
|
||||
params=[
|
||||
field(
|
||||
|
||||
@@ -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},
|
||||
),
|
||||
],
|
||||
}
|
||||
|
||||
@@ -320,7 +320,7 @@ four ways to sign requests.
|
||||
),
|
||||
field(
|
||||
"description",
|
||||
"body",
|
||||
"json",
|
||||
"string",
|
||||
True,
|
||||
"Great work on the release!",
|
||||
|
||||
@@ -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"},
|
||||
),
|
||||
],
|
||||
|
||||
@@ -66,6 +66,7 @@ from devplacepy.routers import (
|
||||
issues,
|
||||
news,
|
||||
gists,
|
||||
quizzes,
|
||||
uploads,
|
||||
media,
|
||||
push,
|
||||
@@ -463,6 +464,7 @@ 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")
|
||||
|
||||
+297
-7
@@ -1,13 +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):
|
||||
@@ -161,7 +170,7 @@ class CommentForm(BaseModel):
|
||||
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] = []
|
||||
|
||||
@@ -289,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)
|
||||
@@ -386,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):
|
||||
@@ -449,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):
|
||||
@@ -469,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)
|
||||
@@ -591,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
|
||||
|
||||
@@ -45,6 +45,12 @@ 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"
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ Prefixes are wired in `main.py`:
|
||||
| `/projects` | projects/ package - `index.py` (listing/detail/create/delete plus owner-only visibility toggles `POST /{slug}/private` and `POST /{slug}/readonly`, and async `POST /{slug}/fork`), `files.py`, and the `containers/` subpackage (below). `main.py` mounts the whole `/projects` tree from this one package. See `routers/projects/CLAUDE.md` for the deep detail on this tree |
|
||||
| `/projects/{slug}/files` | projects/files.py - per-project virtual filesystem (CRUD dirs/files, upload, inline edit, plus line-range ops: `lines` read, `replace-lines`, `insert-lines`, `delete-lines`, `append` for large text files) |
|
||||
| `/profile` | profile/ package - `index.py` (page/search/followers/update/api-key; the **Posts** tab shows only the 10 most recent posts, while the sidebar `posts_count` stat always uses the real `count()` so it is never capped to the rendered 10), `customization.py`, `notifications.py`, `ai_correction.py` (`POST /{username}/ai-correction`, owner-or-admin AI content correction toggle + prompt), `ai_modifier.py` (`POST /{username}/ai-modifier`, owner-or-admin AI modifier toggle + prompt), `interactions.py` (`POST /{username}/interactions`, owner-or-admin Devii interactive widgets preference; admin default via `devii_interactions_default`), `telegram.py` (`POST /{username}/telegram`, owner-or-admin Telegram pairing-code request/unpair), `usage.py` (`_ai_quota`, re-exported from the package) |
|
||||
| `/messages` | messages.py - real-time DM chat. `GET ""` (page; the conversation sidebar is one windowed SQL aggregate and an opened thread loads only the newest `CONVERSATION_MESSAGE_LIMIT` = 500 messages - keep these queries bounded), `GET /search`, `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` |
|
||||
| `/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`.
|
||||
|
||||
@@ -8,6 +8,8 @@ from devplacepy.routers.admin import (
|
||||
backups,
|
||||
bots,
|
||||
containers,
|
||||
devii_tasks,
|
||||
game,
|
||||
gateway_configs,
|
||||
issues,
|
||||
media,
|
||||
@@ -36,5 +38,7 @@ router.include_router(auditlog.router)
|
||||
router.include_router(backups.router)
|
||||
router.include_router(bots.router)
|
||||
router.include_router(gateway_configs.router)
|
||||
router.include_router(devii_tasks.router)
|
||||
router.include_router(game.router)
|
||||
router.include_router(services.router, prefix="/services")
|
||||
router.include_router(containers.router, prefix="/containers")
|
||||
|
||||
@@ -0,0 +1,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
|
||||
|
||||
@@ -182,3 +182,92 @@ async def delete_model(request: Request, source_model: str):
|
||||
summary=f"admin {admin['username']} deleted gateway model route {source_model}",
|
||||
)
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
def _quota_defaults_summary() -> dict:
|
||||
svc = service_manager.get_service("openai")
|
||||
cfg = svc.get_config() if svc is not None else {}
|
||||
return {
|
||||
"user": cfg.get(quota.FIELD_DEFAULT_USER, 0.0),
|
||||
"admin": cfg.get(quota.FIELD_DEFAULT_ADMIN, 0.0),
|
||||
"guest": cfg.get(quota.FIELD_DEFAULT_GUEST, 0.0),
|
||||
"internal": cfg.get(quota.FIELD_DEFAULT_INTERNAL, 0.0),
|
||||
"key": cfg.get(quota.FIELD_DEFAULT_KEY, 0.0),
|
||||
}
|
||||
|
||||
|
||||
def _rule_label(rule: dict) -> str:
|
||||
parts = []
|
||||
if rule.get("owner_kind"):
|
||||
parts.append(f"role={rule['owner_kind']}")
|
||||
if rule.get("owner_id"):
|
||||
parts.append(f"user={rule['owner_id']}")
|
||||
if rule.get("app_reference"):
|
||||
parts.append(f"app={rule['app_reference']}")
|
||||
return ", ".join(parts) or rule.get("uid", "")
|
||||
|
||||
|
||||
@router.get("/gateway/quota-rules")
|
||||
async def list_quota_rules(request: Request):
|
||||
require_admin(request)
|
||||
rules = quota.quota_rule_store.list()
|
||||
for rule in rules:
|
||||
rule["spent_24h_usd"] = round(
|
||||
quota.spent_24h(rule["owner_kind"], rule["owner_id"], rule["app_reference"]), 6
|
||||
)
|
||||
return JSONResponse(
|
||||
{
|
||||
"rules": rules,
|
||||
"count": len(rules),
|
||||
"defaults": _quota_defaults_summary(),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.post("/gateway/quota-rules")
|
||||
async def save_quota_rule(request: Request):
|
||||
admin = require_admin(request)
|
||||
body = await _payload(request)
|
||||
uid = str(body.pop("uid", "") or "").strip() or None
|
||||
try:
|
||||
payload = quota.QuotaRuleIn(**body)
|
||||
except ValidationError as exc:
|
||||
return _validation_error(exc)
|
||||
saved = quota.quota_rule_store.set(payload, uid=uid, created_by=admin["uid"])
|
||||
audit.record(
|
||||
request,
|
||||
"gateway.quota_rule.update",
|
||||
user=admin,
|
||||
target_type="gateway_quota_rule",
|
||||
target_uid=saved["uid"],
|
||||
target_label=_rule_label(saved),
|
||||
summary=f"admin {admin['username']} saved gateway quota rule ({_rule_label(saved)}) at ${saved['limit_usd']}/24h",
|
||||
metadata={
|
||||
"owner_kind": saved["owner_kind"],
|
||||
"owner_id": saved["owner_id"],
|
||||
"app_reference": saved["app_reference"],
|
||||
"limit_usd": saved["limit_usd"],
|
||||
"is_active": saved["is_active"],
|
||||
},
|
||||
)
|
||||
return JSONResponse({"ok": True, "rule": saved})
|
||||
|
||||
|
||||
@router.delete("/gateway/quota-rules/{uid}")
|
||||
async def delete_quota_rule(request: Request, uid: str):
|
||||
admin = require_admin(request)
|
||||
existing = quota.quota_rule_store.get(uid)
|
||||
label = _rule_label(existing.as_dict()) if existing else uid
|
||||
existed = quota.quota_rule_store.remove(uid)
|
||||
if not existed:
|
||||
return JSONResponse({"ok": False, "error": "Quota rule not found"}, status_code=404)
|
||||
audit.record(
|
||||
request,
|
||||
"gateway.quota_rule.delete",
|
||||
user=admin,
|
||||
target_type="gateway_quota_rule",
|
||||
target_uid=uid,
|
||||
target_label=label,
|
||||
summary=f"admin {admin['username']} deleted gateway quota rule ({label})",
|
||||
)
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
@@ -30,6 +30,7 @@ TRASH_TABLES = [
|
||||
{"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},
|
||||
]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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, sender_role=sender.get("role"))
|
||||
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,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,10 @@ 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,
|
||||
get_project_devlog,
|
||||
paginate,
|
||||
text_search_clause,
|
||||
resolve_by_slug,
|
||||
@@ -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}")
|
||||
|
||||
@@ -99,6 +99,9 @@ from devplacepy.schemas.backups import (
|
||||
BackupStoragePathOut,
|
||||
)
|
||||
from devplacepy.schemas.admin import (
|
||||
AdminDeviiTaskItemOut,
|
||||
AdminDeviiTasksOut,
|
||||
AdminGameOut,
|
||||
AdminMediaItemOut,
|
||||
AdminMediaOut,
|
||||
AdminNewsItemOut,
|
||||
@@ -114,6 +117,10 @@ 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,
|
||||
@@ -138,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
|
||||
|
||||
@@ -4,6 +4,8 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from pydantic import ConfigDict, Field
|
||||
|
||||
from devplacepy.schemas.base import _Out
|
||||
|
||||
|
||||
@@ -62,8 +64,9 @@ class PollOut(_Out):
|
||||
|
||||
|
||||
class BadgeOut(_Out):
|
||||
name: Optional[str] = None
|
||||
name: Optional[str] = Field(None, alias="badge_name")
|
||||
created_at: Optional[str] = None
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
|
||||
class PostOut(_Out):
|
||||
@@ -202,3 +205,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):
|
||||
|
||||
@@ -70,6 +70,7 @@ class MessageItemOut(_Out):
|
||||
is_mine: bool = False
|
||||
time_ago: Optional[str] = None
|
||||
attachments: list[AttachmentOut] = []
|
||||
grouped: bool = False
|
||||
|
||||
|
||||
class NotificationItemOut(_Out):
|
||||
@@ -162,6 +163,8 @@ class ProjectDetailOut(_Out):
|
||||
forked_from: Optional[dict] = None
|
||||
fork_count: int = 0
|
||||
file_count: int = 0
|
||||
devlog_posts: list[FeedItemOut] = []
|
||||
devlog_next_cursor: Optional[str] = None
|
||||
|
||||
|
||||
class GistsOut(_Out):
|
||||
@@ -231,3 +234,4 @@ class LeaderboardOut(_Out):
|
||||
class SavedOut(_Out):
|
||||
items: list[SavedItemOut] = []
|
||||
next_cursor: Optional[str] = None
|
||||
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from devplacepy.schemas.base import _Out
|
||||
from devplacepy.schemas.content import CommentItemOut, UserOut
|
||||
|
||||
|
||||
class QuizOptionOut(_Out):
|
||||
uid: str = ""
|
||||
position: int = 0
|
||||
label: str = ""
|
||||
is_correct: Optional[bool] = None
|
||||
match_value: Optional[str] = None
|
||||
|
||||
|
||||
class QuizAnswerOut(_Out):
|
||||
uid: str = ""
|
||||
question_uid: str = ""
|
||||
position: int = 0
|
||||
answer_text: str = ""
|
||||
option_uids: list[str] = []
|
||||
answered: bool = False
|
||||
answered_at: str = ""
|
||||
is_correct: bool = False
|
||||
awarded_points: float = 0.0
|
||||
feedback: str = ""
|
||||
graded_by: str = ""
|
||||
confidence: float = 0.0
|
||||
|
||||
|
||||
class QuizQuestionOut(_Out):
|
||||
uid: str = ""
|
||||
quiz_uid: str = ""
|
||||
position: int = 0
|
||||
kind: str = ""
|
||||
kind_label: str = ""
|
||||
prompt: str = ""
|
||||
explanation: str = ""
|
||||
points: int = 1
|
||||
media_attachment_uid: str = ""
|
||||
case_sensitive: bool = False
|
||||
options: list[QuizOptionOut] = []
|
||||
option_count: int = 0
|
||||
match_choices: list[str] = []
|
||||
correct_boolean: Optional[bool] = None
|
||||
expected_answer: Optional[str] = None
|
||||
grading_criteria: Optional[str] = None
|
||||
numeric_value: Optional[float] = None
|
||||
numeric_tolerance: Optional[float] = None
|
||||
answer: Optional[QuizAnswerOut] = None
|
||||
|
||||
|
||||
class QuizOut(_Out):
|
||||
uid: str = ""
|
||||
slug: str = ""
|
||||
url: str = ""
|
||||
title: str = ""
|
||||
description: str = ""
|
||||
status: str = "draft"
|
||||
published_at: str = ""
|
||||
shuffle_questions: bool = False
|
||||
shuffle_options: bool = False
|
||||
reveal_answers: bool = False
|
||||
allow_review: bool = True
|
||||
time_limit_seconds: int = 0
|
||||
pass_percent: int = 0
|
||||
question_count: int = 0
|
||||
total_points: int = 0
|
||||
attempt_count: int = 0
|
||||
stars: int = 0
|
||||
created_at: str = ""
|
||||
author: Optional[UserOut] = None
|
||||
viewer_owns: bool = False
|
||||
viewer_is_admin: bool = False
|
||||
viewer_can_edit: bool = False
|
||||
viewer_can_play: bool = False
|
||||
viewer_state: str = "todo"
|
||||
validation_errors: list[str] = []
|
||||
|
||||
|
||||
class QuizDetailOut(_Out):
|
||||
quiz: QuizOut = QuizOut()
|
||||
questions: list[QuizQuestionOut] = []
|
||||
comments: list[CommentItemOut] = []
|
||||
attachments: list[Any] = []
|
||||
reactions: dict = {}
|
||||
bookmarked: bool = False
|
||||
star_count: int = 0
|
||||
my_vote: int = 0
|
||||
leaderboard: list[Any] = []
|
||||
viewer_state: str = "todo"
|
||||
viewer_attempt_uid: str = ""
|
||||
|
||||
|
||||
class QuizListItemOut(_Out):
|
||||
uid: str = ""
|
||||
slug: str = ""
|
||||
url: str = ""
|
||||
title: str = ""
|
||||
description: str = ""
|
||||
status: str = "published"
|
||||
created_at: str = ""
|
||||
time_ago: str = ""
|
||||
author: Optional[UserOut] = None
|
||||
question_count: int = 0
|
||||
total_points: int = 0
|
||||
attempt_count: int = 0
|
||||
time_limit_seconds: int = 0
|
||||
pass_percent: int = 0
|
||||
stars: int = 0
|
||||
my_vote: int = 0
|
||||
comment_count: int = 0
|
||||
bookmarked: bool = False
|
||||
reactions: dict = {}
|
||||
recent_comments: list[CommentItemOut] = []
|
||||
viewer_state: str = "todo"
|
||||
viewer_best_percent: float = 0.0
|
||||
viewer_best_points: float = 0.0
|
||||
viewer_attempt_uid: str = ""
|
||||
|
||||
|
||||
class QuizScoreboardEntryOut(_Out):
|
||||
rank: int = 0
|
||||
user: Optional[UserOut] = None
|
||||
total_points: float = 0.0
|
||||
quizzes_completed: int = 0
|
||||
avg_percent: float = 0.0
|
||||
perfect_count: int = 0
|
||||
|
||||
|
||||
class QuizProgressOut(_Out):
|
||||
completed: int = 0
|
||||
todo: int = 0
|
||||
avg_percent: float = 0.0
|
||||
total_points: float = 0.0
|
||||
rank: int = 0
|
||||
perfect_count: int = 0
|
||||
|
||||
|
||||
class QuizScoreboardOut(_Out):
|
||||
scoreboard: list[QuizScoreboardEntryOut] = []
|
||||
viewer_standing: Optional[QuizScoreboardEntryOut] = None
|
||||
limit: int = 0
|
||||
|
||||
|
||||
class QuizzesOut(_Out):
|
||||
quizzes: list[QuizListItemOut] = []
|
||||
search: str = ""
|
||||
filter: str = "all"
|
||||
counts: dict = {}
|
||||
pagination: dict = {}
|
||||
scoreboard: list[QuizScoreboardEntryOut] = []
|
||||
viewer_standing: Optional[QuizScoreboardEntryOut] = None
|
||||
viewer_progress: QuizProgressOut = QuizProgressOut()
|
||||
viewer_can_create: bool = False
|
||||
|
||||
|
||||
class QuizAttemptOut(_Out):
|
||||
uid: str = ""
|
||||
quiz_uid: str = ""
|
||||
quiz_slug: str = ""
|
||||
quiz_title: str = ""
|
||||
user_uid: str = ""
|
||||
status: str = ""
|
||||
started_at: str = ""
|
||||
expires_at: str = ""
|
||||
completed_at: str = ""
|
||||
remaining_seconds: int = 0
|
||||
answered_count: int = 0
|
||||
question_count: int = 0
|
||||
score_points: float = 0.0
|
||||
max_points: int = 0
|
||||
score_percent: float = 0.0
|
||||
passed: bool = False
|
||||
pass_percent: int = 0
|
||||
allow_review: bool = True
|
||||
questions: list[QuizQuestionOut] = []
|
||||
|
||||
|
||||
class QuizAttemptPageOut(_Out):
|
||||
quiz: QuizOut = QuizOut()
|
||||
attempt: QuizAttemptOut = QuizAttemptOut()
|
||||
|
||||
|
||||
class QuizResultOut(_Out):
|
||||
quiz: QuizOut = QuizOut()
|
||||
attempt: QuizAttemptOut = QuizAttemptOut()
|
||||
review: list[QuizQuestionOut] = []
|
||||
fallback_count: int = 0
|
||||
|
||||
|
||||
class QuizAnswerResultOut(_Out):
|
||||
ok: bool = True
|
||||
answer: QuizAnswerOut = QuizAnswerOut()
|
||||
attempt: QuizAttemptOut = QuizAttemptOut()
|
||||
|
||||
|
||||
class QuizLeaderboardEntryOut(_Out):
|
||||
rank: int = 0
|
||||
user: Optional[UserOut] = None
|
||||
score_points: float = 0.0
|
||||
score_percent: float = 0.0
|
||||
passed: bool = False
|
||||
completed_at: str = ""
|
||||
|
||||
|
||||
class QuizLeaderboardOut(_Out):
|
||||
quiz_uid: str = ""
|
||||
entries: list[QuizLeaderboardEntryOut] = []
|
||||
|
||||
|
||||
class QuizDocumentOut(_Out):
|
||||
title: str = ""
|
||||
description: str = ""
|
||||
settings: dict = {}
|
||||
questions: list[Any] = []
|
||||
|
||||
|
||||
class QuizBuilderOut(_Out):
|
||||
quiz: QuizOut = QuizOut()
|
||||
questions: list[QuizQuestionOut] = []
|
||||
kinds: list[Any] = []
|
||||
validation_errors: list[str] = []
|
||||
|
||||
|
||||
class QuizFormPageOut(_Out):
|
||||
viewer_can_create: bool = False
|
||||
@@ -0,0 +1,20 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from devplacepy.schemas.base import _Out
|
||||
from devplacepy.schemas.profile import MediaItemOut
|
||||
|
||||
|
||||
class UploadItemOut(MediaItemOut):
|
||||
is_audio: bool = False
|
||||
linked: bool = False
|
||||
user_uid: Optional[str] = None
|
||||
|
||||
|
||||
class UploadsListOut(_Out):
|
||||
attachments: list[UploadItemOut] = []
|
||||
pagination: Optional[Any] = None
|
||||
total: int = 0
|
||||
@@ -201,6 +201,24 @@ def software_source_code_schema(gist, base_url):
|
||||
}
|
||||
|
||||
|
||||
def quiz_schema(quiz, author, base_url):
|
||||
return {
|
||||
"@type": "Quiz",
|
||||
"name": quiz.get("title") or "Quiz",
|
||||
"description": truncate(plain_markdown(quiz.get("description", "") or ""), 200),
|
||||
"url": f"{base_url}/quizzes/{quiz.get('slug') or quiz['uid']}",
|
||||
"about": quiz.get("title") or "Quiz",
|
||||
"educationalLevel": "beginner",
|
||||
"numberOfQuestions": int(quiz.get("question_count") or 0),
|
||||
"author": {
|
||||
"@type": "Person",
|
||||
"name": (author or {}).get("username", "") or "DevPlace member",
|
||||
"url": f"{base_url}/profile/{(author or {}).get('username', '')}",
|
||||
},
|
||||
"dateCreated": quiz.get("created_at", ""),
|
||||
}
|
||||
|
||||
|
||||
def _json_ld_dumps(payload):
|
||||
raw = json.dumps(payload, ensure_ascii=False)
|
||||
return (
|
||||
@@ -398,6 +416,7 @@ def _build_sitemap(base_url):
|
||||
url_element(f"{base_url}/projects", changefreq="daily", priority="0.8")
|
||||
)
|
||||
urlset.append(url_element(f"{base_url}/gists", changefreq="daily", priority="0.8"))
|
||||
urlset.append(url_element(f"{base_url}/quizzes", changefreq="daily", priority="0.8"))
|
||||
urlset.append(
|
||||
url_element(f"{base_url}/leaderboard", changefreq="daily", priority="0.7")
|
||||
)
|
||||
@@ -453,6 +472,24 @@ def _build_sitemap(base_url):
|
||||
)
|
||||
)
|
||||
|
||||
if "quizzes" in db.tables:
|
||||
quizzes = _collect(
|
||||
get_table("quizzes"),
|
||||
SITEMAP_URL_LIMIT,
|
||||
"quizzes",
|
||||
status="published",
|
||||
order_by=["-created_at"],
|
||||
)
|
||||
for quiz in quizzes:
|
||||
urlset.append(
|
||||
url_element(
|
||||
f"{base_url}/quizzes/{quiz.get('slug') or quiz['uid']}",
|
||||
lastmod=quiz.get("published_at", "") or quiz.get("created_at", ""),
|
||||
changefreq="weekly",
|
||||
priority="0.6",
|
||||
)
|
||||
)
|
||||
|
||||
if "news" in db.tables:
|
||||
articles = _collect(
|
||||
get_table("news"),
|
||||
|
||||
@@ -13,6 +13,7 @@ CATEGORY_BY_PREFIX: dict[str, str] = {
|
||||
"comment": "content",
|
||||
"gist": "content",
|
||||
"issue": "content",
|
||||
"quiz": "content",
|
||||
"vote": "engagement",
|
||||
"reaction": "engagement",
|
||||
"bookmark": "engagement",
|
||||
@@ -26,6 +27,7 @@ CATEGORY_BY_PREFIX: dict[str, str] = {
|
||||
"backup": "backup",
|
||||
"attachment": "attachment",
|
||||
"news": "news",
|
||||
"game": "game",
|
||||
"admin": "admin",
|
||||
"service": "service",
|
||||
"gateway": "ai",
|
||||
|
||||
@@ -50,6 +50,14 @@ Five content/behaviour mechanics keep the fleet from reading as machine-generate
|
||||
- **Usernames read like nerd handles, not real names.** Bots do NOT sign up with faker person names; they pick devRant/Hacker-News-style handles. Two layers, LLM-first with an offline fallback: (1) `llm.generate_handle_candidates(persona)` asks the model for ~8 distinct handles grounded in the persona and its `SEARCH_TERMS` interests (tech nouns, leetspeak, adjective+noun, creatures, short word+number), each run through `handles.sanitize_handle`; the pool is cached on `BotState.handle_candidates` and consumed by `_next_handle()`. (2) `handles.make_handle(interests)` is the pure, offline algorithmic generator (curated word banks + probabilistic leetspeak + number/separator/casing decoration, persona-seeded from `SEARCH_TERMS`), used as the fallback whenever the LLM pool is empty or a signup collides. Both layers emit only `[A-Za-z0-9_-]`, 3 to 20 chars (the old `first.last` styles silently failed signup validation, which rejects dots). On a third signup retry a random number is appended to bust collisions. Word banks and tuning constants live in `services/bot/handles.py`; never reintroduce faker person-name handles.
|
||||
- **Bots engage each other, and threads deepen.** `ArticleRegistry` allows up to `max_per_article` holders per article (admin `bot_max_per_article`, default 2), each with a **distinct category** (stored as `holders: [{bot, category, time}]`; old `{bot, time}` rows are normalized on load), so two bots can post different angles on the same trending news - which gives them each other's posts to discuss. A holder ages out after `ttl_days` (admin `bot_article_ttl_days`, default 7). `_engage_community()` (called once per normal/deep session after notifications) goes to `/feed?tab=recent|trending`, ranks non-own posts with a preference for `known_users` authors, opens one, comments, and replies into the comment thread. The on-post reply-to-comment probability is also raised so multi-bot threads actually form. `reserve(title, owner, category)` is idempotent per owner and enforces the same distinct-angle / max-holders rules as `reserve_unused`.
|
||||
|
||||
## Profile disclosure signal (soft bot marker)
|
||||
|
||||
Every bot's profile must carry a vague, never-literal hint that the account is synthetic, anchored on `config.HOME_URL` (`https://devplace.net`). The deterministic marker is the host `devplace.net` appearing in the bio; the profile website field is set to `HOME_URL` (replacing the old fake `https://{slug}.dev`). Three layers, all funneled through the single `_update_profile` choke point (so the procedural path and the AI-decision `update_profile` action both comply):
|
||||
|
||||
- **Generation:** `llm.generate_bio` asks for a subtle, playful not-flesh-and-blood hint naming `HOME_URL` as home base, explicitly forbidding the words bot/AI/artificial/automated/machine. `generate_profile_fields` returns `HOME_URL` as the website.
|
||||
- **Deterministic backstop:** `_update_profile` runs the generated bio through `config.ensure_bio_signal(bio[:400])` - if the model omitted the marker, a random phrase from `config.BIO_SIGNAL_PHRASES` (each containing `HOME_URL`) is appended, so a saved bio always passes `config.bio_has_signal`.
|
||||
- **Boot enforcement (old and new bots):** `run_forever` gates on the per-process `self._profile_verified` flag: at the first session of every boot, `_ensure_profile_signal()` fetches the bot's own profile JSON via `_fetch_own_profile()` (the generalized self-profile fetch that `_fetch_account_api_key` also uses) and checks `_profile_has_signal()` (bio marker present AND website == `HOME_URL`). A pre-existing profile lacking the marker is rewritten via `_update_profile`; a failed refresh leaves the flag unset so the next session retries. Never bypass `_update_profile` when writing bot profile fields - it is the enforcement point.
|
||||
|
||||
## AI-driven decisions and identity cards (`bot_ai_decisions`)
|
||||
|
||||
Opt-in mechanic (off by default; full design and cost model in `aibots.md`). When `bot_ai_decisions` is on, a bot replaces the procedural action cascade with one LLM decision call per page, driven by a unique AI-generated identity. **Do not regress the grounding and the kill-switch fallback.**
|
||||
|
||||
@@ -87,25 +87,28 @@ class BotAuthMixin:
|
||||
self._log(f"Signup failed, retrying as {self.state.username}")
|
||||
return False
|
||||
|
||||
async def _fetch_account_api_key(self) -> str:
|
||||
async def _fetch_own_profile(self) -> dict:
|
||||
if not self.state.username:
|
||||
return ""
|
||||
return {}
|
||||
try:
|
||||
key = await self.b.page.evaluate(
|
||||
data = await self.b.page.evaluate(
|
||||
"""async (username) => {
|
||||
const resp = await fetch(`/profile/${username}`, {
|
||||
headers: {Accept: 'application/json'},
|
||||
});
|
||||
if (!resp.ok) return '';
|
||||
const data = await resp.json();
|
||||
return data.api_key || '';
|
||||
if (!resp.ok) return null;
|
||||
return await resp.json();
|
||||
}""",
|
||||
self.state.username,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("fetch account api key failed: %s", e)
|
||||
return ""
|
||||
return (key or "").strip()
|
||||
logger.debug("fetch own profile failed: %s", e)
|
||||
return {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
async def _fetch_account_api_key(self) -> str:
|
||||
profile = await self._fetch_own_profile()
|
||||
return (profile.get("api_key") or "").strip()
|
||||
|
||||
async def _adopt_account_api_key(self) -> bool:
|
||||
for attempt in range(5):
|
||||
|
||||
@@ -110,6 +110,7 @@ class DevPlaceBot(
|
||||
self._session_comments = 0
|
||||
self._session_comment_cap = 0
|
||||
self._session_mention_replies = 0
|
||||
self._profile_verified = False
|
||||
self._post_cache: list[tuple[str, str, str, str, str]] = []
|
||||
self._project_cache: list[tuple[str, str]] = []
|
||||
self._issue_cache: list[tuple[str, str]] = []
|
||||
|
||||
@@ -7,6 +7,16 @@ from devplacepy.config import INTERNAL_GATEWAY_URL, INTERNAL_MODEL, BOT_DIR
|
||||
BASE_URL_DEFAULT = "https://pravda.education"
|
||||
API_URL_DEFAULT = INTERNAL_GATEWAY_URL
|
||||
NEWS_API_DEFAULT = "https://news.app.molodetz.nl/api"
|
||||
HOME_URL = "https://devplace.net"
|
||||
HOME_HOST = "devplace.net"
|
||||
|
||||
BIO_SIGNAL_PHRASES = [
|
||||
f"Compiled with care at {HOME_URL}.",
|
||||
f"Homegrown at {HOME_URL}.",
|
||||
f"Assembled, not raised, at {HOME_URL}.",
|
||||
f"Running my daily routines from {HOME_URL}.",
|
||||
f"Tirelessly online, courtesy of {HOME_URL}.",
|
||||
]
|
||||
MODEL_DEFAULT = INTERNAL_MODEL
|
||||
INPUT_COST_PER_1M_DEFAULT = 0.14
|
||||
OUTPUT_COST_PER_1M_DEFAULT = 0.28
|
||||
@@ -312,3 +322,15 @@ def persona_article_score(article: dict, persona: str) -> float:
|
||||
return 0.0
|
||||
haystack = f"{article.get('title', '')} {article.get('description', '')}".lower()
|
||||
return float(sum(1 for term in terms if term.lower() in haystack))
|
||||
|
||||
|
||||
def bio_has_signal(bio: str) -> bool:
|
||||
return HOME_HOST in (bio or "").lower()
|
||||
|
||||
|
||||
def ensure_bio_signal(bio: str) -> str:
|
||||
text = (bio or "").strip()
|
||||
if bio_has_signal(text):
|
||||
return text
|
||||
phrase = random.choice(BIO_SIGNAL_PHRASES)
|
||||
return f"{text} {phrase}".strip()
|
||||
|
||||
@@ -13,6 +13,7 @@ from devplacepy import stealth
|
||||
from devplacepy.services.bot.config import (
|
||||
GIST_LANGUAGES,
|
||||
GIST_MIN_LINES,
|
||||
HOME_URL,
|
||||
PERSONA_GIST_FLAVOR,
|
||||
PERSONA_LANGUAGES,
|
||||
SEARCH_TERMS,
|
||||
@@ -671,7 +672,9 @@ class LLMClient:
|
||||
|
||||
def generate_bio(self) -> str:
|
||||
return self._call(
|
||||
"Write a short bio for a developer profile. 1-2 sentences. What they work on, what they like. Sound human. No em dashes.",
|
||||
"Write a short bio for a developer profile. 1-2 sentences. What they work on, what they like. Sound human. "
|
||||
f"End with one subtle, playful hint that the account is not exactly flesh and blood, naming {HOME_URL} as home base. "
|
||||
"Never use the words bot, AI, artificial, automated, or machine. No em dashes.",
|
||||
"Bio:",
|
||||
)
|
||||
|
||||
@@ -685,8 +688,7 @@ class LLMClient:
|
||||
)[:80]
|
||||
slug = re.sub(r"[^a-z0-9_-]", "", handle.lower()) or "dev"
|
||||
git_link = f"https://github.com/{slug}"
|
||||
website = f"https://{slug}.dev"
|
||||
return location, git_link, website
|
||||
return location, git_link, HOME_URL
|
||||
|
||||
def generate_dm(self, persona: str = "", context: str = "") -> str:
|
||||
extra = {
|
||||
|
||||
@@ -369,10 +369,8 @@ class BotLoopMixin:
|
||||
await self._warm_cache()
|
||||
self._log(f"Session active ({mood}) as {self._identity()}")
|
||||
|
||||
if not self.state.profile_filled:
|
||||
await self._update_profile()
|
||||
await b.goto(f"{self.base_url}/feed")
|
||||
await b._idle(0.8, 2.0)
|
||||
if not self._profile_verified:
|
||||
self._profile_verified = await self._ensure_profile_signal()
|
||||
|
||||
unread = await self._unread_notification_count()
|
||||
if unread:
|
||||
|
||||
@@ -6,9 +6,12 @@ import logging
|
||||
import random
|
||||
|
||||
from devplacepy.services.bot.config import (
|
||||
HOME_URL,
|
||||
MAX_THREAD_REPLIES,
|
||||
MENTION_REPLIES_PER_SESSION,
|
||||
PROJECT_STATUSES,
|
||||
bio_has_signal,
|
||||
ensure_bio_signal,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -142,6 +145,21 @@ class BotSocialMixin:
|
||||
return True
|
||||
return False
|
||||
|
||||
async def _profile_has_signal(self) -> bool:
|
||||
profile = await self._fetch_own_profile()
|
||||
user = profile.get("profile_user") or {}
|
||||
website = (user.get("website") or "").strip().rstrip("/")
|
||||
return bio_has_signal(user.get("bio") or "") and website == HOME_URL
|
||||
|
||||
async def _ensure_profile_signal(self) -> bool:
|
||||
if self.state.profile_filled and await self._profile_has_signal():
|
||||
return True
|
||||
self._log("Profile signal missing, refreshing profile")
|
||||
ok = await self._update_profile()
|
||||
await self.b.goto(f"{self.base_url}/feed")
|
||||
await self.b._idle(0.8, 2.0)
|
||||
return ok
|
||||
|
||||
async def _update_profile(self) -> bool:
|
||||
b = self.b
|
||||
await b.goto(f"{self.base_url}/profile/{self.state.username}")
|
||||
@@ -155,7 +173,7 @@ class BotSocialMixin:
|
||||
if not bio:
|
||||
self._log("Update profile: bio generation failed")
|
||||
return False
|
||||
bio = bio[:500]
|
||||
bio = ensure_bio_signal(bio[:400])[:500]
|
||||
await b.fill("textarea[name='bio']", bio)
|
||||
await b._idle(0.3, 0.8)
|
||||
|
||||
|
||||
@@ -1,637 +0,0 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
import socket
|
||||
from pathlib import Path
|
||||
|
||||
from devplacepy import config, project_files, stealth
|
||||
from devplacepy.services.containers import store
|
||||
from devplacepy.services.containers.backend.base import (
|
||||
WORKSPACE_MOUNT,
|
||||
Mount,
|
||||
PortMapping,
|
||||
RunSpec,
|
||||
)
|
||||
from devplacepy.services.containers.runtime import get_backend
|
||||
from devplacepy.services.devii.tasks.schedule import (
|
||||
Schedule,
|
||||
now_utc,
|
||||
to_iso,
|
||||
)
|
||||
|
||||
IMAGE_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9._-]{0,62}$")
|
||||
INGRESS_SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,62}$")
|
||||
MEM_RE = re.compile(r"^\d+(\.\d+)?[bkmgBKMG]?$")
|
||||
CPU_RE = re.compile(r"^\d+(\.\d+)?$")
|
||||
ENV_KEY_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
||||
INSTANCE_LABEL = "devplace.instance"
|
||||
PROJECT_LABEL = "devplace.project"
|
||||
HOST_PORT_MIN = 20001
|
||||
HOST_PORT_MAX = 65535
|
||||
BOOT_LANGUAGES = ("none", "python", "bash")
|
||||
BOOT_SCRIPT_FILES = {"python": ".devplace_boot.py", "bash": ".devplace_boot.sh"}
|
||||
BOOT_SCRIPT_RUNNERS = {"python": "python", "bash": "bash"}
|
||||
MAX_BOOT_SCRIPT_CHARS = 100_000
|
||||
|
||||
|
||||
class ContainerError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def _validate_limits(cpu_limit: str, mem_limit: str) -> None:
|
||||
if cpu_limit and not CPU_RE.match(str(cpu_limit)):
|
||||
raise ContainerError("cpu limit must be a number, e.g. 1 or 1.5")
|
||||
if mem_limit and not MEM_RE.match(str(mem_limit)):
|
||||
raise ContainerError("memory limit must look like 512m, 1g, or a byte count")
|
||||
|
||||
|
||||
def validate_run_as(run_as_uid) -> str:
|
||||
uid = str(run_as_uid or "").strip()
|
||||
if not uid:
|
||||
return ""
|
||||
from devplacepy import database
|
||||
|
||||
user = database.get_users_by_uids([uid]).get(uid)
|
||||
if not user:
|
||||
raise ContainerError(f"run-as user not found: {uid}")
|
||||
return uid
|
||||
|
||||
|
||||
def validate_boot(boot_language, boot_script) -> tuple:
|
||||
language = str(boot_language or "none").strip().lower() or "none"
|
||||
if language not in BOOT_LANGUAGES:
|
||||
raise ContainerError(
|
||||
f"boot language must be one of {', '.join(BOOT_LANGUAGES)}"
|
||||
)
|
||||
script = str(boot_script or "")
|
||||
if language == "none":
|
||||
script = ""
|
||||
if len(script) > MAX_BOOT_SCRIPT_CHARS:
|
||||
raise ContainerError(
|
||||
f"boot script exceeds the {MAX_BOOT_SCRIPT_CHARS}-character limit"
|
||||
)
|
||||
if language != "none" and not script.strip():
|
||||
raise ContainerError("boot script is required when a boot language is set")
|
||||
return language, script
|
||||
|
||||
|
||||
def parse_ports(value) -> list:
|
||||
ports = []
|
||||
if not value:
|
||||
return ports
|
||||
items = (
|
||||
value if isinstance(value, list) else str(value).replace(",", "\n").splitlines()
|
||||
)
|
||||
for item in items:
|
||||
item = str(item).strip()
|
||||
if not item:
|
||||
continue
|
||||
proto = "tcp"
|
||||
if "/" in item:
|
||||
item, proto = item.split("/", 1)
|
||||
if ":" in item:
|
||||
host, container = item.split(":", 1)
|
||||
else:
|
||||
host, container = "0", item
|
||||
if not host.isdigit() or not container.isdigit():
|
||||
raise ContainerError(
|
||||
f"port '{item}' must be numeric host:container or a bare container port"
|
||||
)
|
||||
ports.append(PortMapping(int(host), int(container), proto.strip() or "tcp"))
|
||||
return ports
|
||||
|
||||
|
||||
def used_host_ports() -> set:
|
||||
ports = set()
|
||||
for instance in store.all_instances():
|
||||
for mapping in json.loads(instance.get("ports_json") or "[]"):
|
||||
host = int(mapping.get("host") or 0)
|
||||
if host:
|
||||
ports.add(host)
|
||||
return ports
|
||||
|
||||
|
||||
def _host_port_free(port: int) -> bool:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
try:
|
||||
sock.bind(("0.0.0.0", port))
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def allocate_host_port(reserved: set) -> int:
|
||||
for port in range(HOST_PORT_MIN, HOST_PORT_MAX + 1):
|
||||
if port in reserved:
|
||||
continue
|
||||
if _host_port_free(port):
|
||||
return port
|
||||
raise ContainerError(
|
||||
f"no free host port available in range {HOST_PORT_MIN}-{HOST_PORT_MAX}"
|
||||
)
|
||||
|
||||
|
||||
def assign_host_ports(port_list: list) -> list:
|
||||
reserved = used_host_ports()
|
||||
assigned = []
|
||||
for mapping in port_list:
|
||||
host = mapping.host
|
||||
if not host:
|
||||
host = allocate_host_port(reserved)
|
||||
elif host in reserved:
|
||||
raise ContainerError(
|
||||
f"host port {host} is already published by another instance"
|
||||
)
|
||||
reserved.add(host)
|
||||
assigned.append(PortMapping(host, mapping.container, mapping.proto))
|
||||
return assigned
|
||||
|
||||
|
||||
def parse_env(value) -> dict:
|
||||
env = {}
|
||||
if not value:
|
||||
return env
|
||||
if isinstance(value, dict):
|
||||
items = value.items()
|
||||
else:
|
||||
items = (line.split("=", 1) for line in str(value).splitlines() if "=" in line)
|
||||
for key, val in items:
|
||||
key = str(key).strip()
|
||||
if not ENV_KEY_RE.match(key):
|
||||
raise ContainerError(f"invalid environment variable name: {key}")
|
||||
env[key] = str(val)
|
||||
return env
|
||||
|
||||
|
||||
# ---------------- instances ----------------
|
||||
|
||||
|
||||
def validate_ingress(slug: str, port, port_list) -> tuple:
|
||||
slug = (slug or "").strip().lower()
|
||||
if not slug:
|
||||
return "", 0
|
||||
if not INGRESS_SLUG_RE.match(slug):
|
||||
raise ContainerError(
|
||||
"ingress slug must be lowercase letters, digits, or '-' (max 63 chars)"
|
||||
)
|
||||
for other in store.all_instances():
|
||||
if other.get("ingress_slug") == slug:
|
||||
raise ContainerError(f"ingress slug '{slug}' is already in use")
|
||||
ingress_port = int(port) if port else 0
|
||||
container_ports = {p.container for p in port_list}
|
||||
if ingress_port and ingress_port not in container_ports:
|
||||
raise ContainerError(
|
||||
f"ingress_port {ingress_port} must be one of the container ports you mapped"
|
||||
)
|
||||
if not ingress_port and len(container_ports) != 1:
|
||||
raise ContainerError(
|
||||
"set ingress_port to choose which mapped container port to publish"
|
||||
)
|
||||
return slug, ingress_port
|
||||
|
||||
|
||||
async def create_instance(
|
||||
project: dict,
|
||||
*,
|
||||
name: str,
|
||||
boot_command: str = "",
|
||||
boot_language: str = "none",
|
||||
boot_script: str = "",
|
||||
run_as_uid: str = "",
|
||||
start_on_boot: bool = False,
|
||||
env="",
|
||||
cpu_limit: str = "",
|
||||
mem_limit: str = "",
|
||||
ports="",
|
||||
volumes="",
|
||||
restart_policy: str = "never",
|
||||
autostart: bool = True,
|
||||
ingress_slug: str = "",
|
||||
ingress_port=None,
|
||||
actor=("system", "system"),
|
||||
) -> dict:
|
||||
if not await get_backend().image_exists(config.CONTAINER_IMAGE):
|
||||
raise ContainerError(
|
||||
f"the '{config.CONTAINER_IMAGE}' image is not built - run 'make ppy'"
|
||||
)
|
||||
if restart_policy not in store.RESTART_POLICIES:
|
||||
raise ContainerError(
|
||||
f"restart policy must be one of {', '.join(store.RESTART_POLICIES)}"
|
||||
)
|
||||
_validate_limits(cpu_limit, mem_limit)
|
||||
run_as_uid = validate_run_as(run_as_uid)
|
||||
boot_language, boot_script = validate_boot(boot_language, boot_script)
|
||||
port_list = assign_host_ports(parse_ports(ports))
|
||||
env_map = parse_env(env)
|
||||
name = (name or "").strip()
|
||||
if not name:
|
||||
raise ContainerError("instance name is required")
|
||||
ingress_slug, ingress_port = validate_ingress(ingress_slug, ingress_port, port_list)
|
||||
|
||||
workspace = Path(config.CONTAINER_WORKSPACES_DIR) / project["uid"]
|
||||
await asyncio.to_thread(
|
||||
project_files.export_to_dir, project["uid"], "", str(workspace)
|
||||
)
|
||||
|
||||
row = {
|
||||
"project_uid": project["uid"],
|
||||
"created_by": actor[1] if actor and actor[0] == "user" else "",
|
||||
"owner_uid": project.get("user_uid", ""),
|
||||
"run_as_uid": run_as_uid,
|
||||
"name": name,
|
||||
"boot_command": boot_command or "",
|
||||
"boot_language": boot_language,
|
||||
"boot_script": boot_script,
|
||||
"start_on_boot": 1 if start_on_boot else 0,
|
||||
"env_json": json.dumps(env_map),
|
||||
"cpu_limit": str(cpu_limit or ""),
|
||||
"mem_limit": str(mem_limit or ""),
|
||||
"ports_json": json.dumps(
|
||||
[
|
||||
{"host": p.host, "container": p.container, "proto": p.proto}
|
||||
for p in port_list
|
||||
]
|
||||
),
|
||||
"volumes_json": volumes
|
||||
if isinstance(volumes, str)
|
||||
else json.dumps(volumes or []),
|
||||
"restart_policy": restart_policy,
|
||||
"ingress_slug": ingress_slug,
|
||||
"ingress_port": ingress_port,
|
||||
"desired_state": store.DESIRED_RUNNING if autostart else store.DESIRED_STOPPED,
|
||||
"status": store.ST_CREATED,
|
||||
"workspace_dir": str(workspace),
|
||||
}
|
||||
instance = store.create_instance(row)
|
||||
store.record_event(
|
||||
instance, "created", actor[0], actor[1], {"image": config.CONTAINER_IMAGE}
|
||||
)
|
||||
if actor and actor[0] == "user":
|
||||
from devplacepy.utils import track_action
|
||||
|
||||
track_action(actor[1], "container")
|
||||
return instance
|
||||
|
||||
|
||||
def set_desired_state(
|
||||
instance: dict, desired: str, *, actor=("system", "system")
|
||||
) -> dict:
|
||||
if desired not in (
|
||||
store.DESIRED_RUNNING,
|
||||
store.DESIRED_STOPPED,
|
||||
store.DESIRED_PAUSED,
|
||||
):
|
||||
raise ContainerError("desired state must be running, stopped, or paused")
|
||||
store.update_instance(instance["uid"], {"desired_state": desired})
|
||||
store.record_event(instance, f"desire_{desired}", actor[0], actor[1])
|
||||
return store.get_instance(instance["uid"])
|
||||
|
||||
|
||||
def request_restart(instance: dict, *, actor=("system", "system")) -> dict:
|
||||
store.update_instance(
|
||||
instance["uid"],
|
||||
{"desired_state": store.DESIRED_RUNNING, "status": store.ST_RESTARTING},
|
||||
)
|
||||
store.record_event(instance, "restart", actor[0], actor[1])
|
||||
return store.get_instance(instance["uid"])
|
||||
|
||||
|
||||
def mark_for_removal(instance: dict, *, actor=("system", "system")) -> None:
|
||||
store.update_instance(
|
||||
instance["uid"],
|
||||
{"desired_state": store.DESIRED_STOPPED, "status": store.ST_REMOVING},
|
||||
)
|
||||
store.record_event(instance, "remove", actor[0], actor[1])
|
||||
|
||||
|
||||
def update_instance_config(
|
||||
instance: dict,
|
||||
*,
|
||||
run_as_uid=None,
|
||||
boot_language=None,
|
||||
boot_script=None,
|
||||
boot_command=None,
|
||||
restart_policy=None,
|
||||
start_on_boot=None,
|
||||
cpu_limit=None,
|
||||
mem_limit=None,
|
||||
actor=("system", "system"),
|
||||
) -> dict:
|
||||
changes: dict = {}
|
||||
if run_as_uid is not None:
|
||||
changes["run_as_uid"] = validate_run_as(run_as_uid)
|
||||
if boot_language is not None or boot_script is not None:
|
||||
language = (
|
||||
boot_language
|
||||
if boot_language is not None
|
||||
else instance.get("boot_language", "none")
|
||||
)
|
||||
script = (
|
||||
boot_script if boot_script is not None else instance.get("boot_script", "")
|
||||
)
|
||||
language, script = validate_boot(language, script)
|
||||
changes["boot_language"] = language
|
||||
changes["boot_script"] = script
|
||||
if boot_command is not None:
|
||||
changes["boot_command"] = str(boot_command or "")[:500]
|
||||
if restart_policy is not None:
|
||||
if restart_policy not in store.RESTART_POLICIES:
|
||||
raise ContainerError(
|
||||
f"restart policy must be one of {', '.join(store.RESTART_POLICIES)}"
|
||||
)
|
||||
changes["restart_policy"] = restart_policy
|
||||
if start_on_boot is not None:
|
||||
changes["start_on_boot"] = 1 if start_on_boot else 0
|
||||
if cpu_limit is not None or mem_limit is not None:
|
||||
cpu = cpu_limit if cpu_limit is not None else instance.get("cpu_limit", "")
|
||||
mem = mem_limit if mem_limit is not None else instance.get("mem_limit", "")
|
||||
_validate_limits(cpu, mem)
|
||||
changes["cpu_limit"] = str(cpu or "")
|
||||
changes["mem_limit"] = str(mem or "")
|
||||
if not changes:
|
||||
return store.get_instance(instance["uid"])
|
||||
store.update_instance(instance["uid"], changes)
|
||||
store.record_event(
|
||||
instance, "configure", actor[0], actor[1], {"fields": sorted(changes)}
|
||||
)
|
||||
return store.get_instance(instance["uid"])
|
||||
|
||||
|
||||
def set_start_on_boot(
|
||||
instance: dict, enabled: bool, *, actor=("system", "system")
|
||||
) -> dict:
|
||||
store.update_instance(instance["uid"], {"start_on_boot": 1 if enabled else 0})
|
||||
store.record_event(
|
||||
instance, "start_on_boot", actor[0], actor[1], {"enabled": bool(enabled)}
|
||||
)
|
||||
return store.get_instance(instance["uid"])
|
||||
|
||||
|
||||
def materialize_boot_script(instance: dict) -> None:
|
||||
language = (instance.get("boot_language") or "none").strip().lower()
|
||||
workspace = instance.get("workspace_dir")
|
||||
if not workspace:
|
||||
return
|
||||
for filename in BOOT_SCRIPT_FILES.values():
|
||||
stale = Path(workspace) / filename
|
||||
if stale.is_file():
|
||||
try:
|
||||
stale.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
if language not in BOOT_SCRIPT_FILES:
|
||||
return
|
||||
script = instance.get("boot_script") or ""
|
||||
if not script.strip():
|
||||
return
|
||||
target = Path(workspace) / BOOT_SCRIPT_FILES[language]
|
||||
try:
|
||||
Path(workspace).mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(script, encoding="utf-8")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def pravda_env(instance: dict) -> dict:
|
||||
from devplacepy import database, seo
|
||||
|
||||
base_url = seo.public_base_url()
|
||||
api_key = ""
|
||||
user_uid = instance.get("owner_uid") or ""
|
||||
for uid in (
|
||||
instance.get("run_as_uid"),
|
||||
instance.get("created_by"),
|
||||
instance.get("owner_uid"),
|
||||
):
|
||||
if not uid:
|
||||
continue
|
||||
user = database.get_users_by_uids([uid]).get(uid)
|
||||
if user and user.get("api_key"):
|
||||
api_key = user["api_key"]
|
||||
break
|
||||
slug = instance.get("ingress_slug") or ""
|
||||
ingress_url = (f"{base_url}/p/{slug}" if base_url else f"/p/{slug}") if slug else ""
|
||||
return {
|
||||
"PRAVDA_BASE_URL": base_url,
|
||||
"PRAVDA_OPENAI_URL": f"{base_url}/openai/v1" if base_url else "",
|
||||
"PRAVDA_API_KEY": api_key,
|
||||
"PRAVDA_USER_UID": instance.get("run_as_uid") or user_uid,
|
||||
"PRAVDA_CONTAINER_NAME": instance.get("name") or "",
|
||||
"PRAVDA_CONTAINER_UID": instance.get("uid") or "",
|
||||
"PRAVDA_INGRESS_URL": ingress_url,
|
||||
}
|
||||
|
||||
|
||||
def run_spec_for(instance: dict, image_tag: str) -> RunSpec:
|
||||
env = {**json.loads(instance.get("env_json") or "{}"), **pravda_env(instance)}
|
||||
ports = [
|
||||
PortMapping(p["host"], p["container"], p.get("proto", "tcp"))
|
||||
for p in json.loads(instance.get("ports_json") or "[]")
|
||||
]
|
||||
mounts = [Mount(instance["workspace_dir"], WORKSPACE_MOUNT, "rw")]
|
||||
for extra in json.loads(instance.get("volumes_json") or "[]"):
|
||||
if isinstance(extra, dict) and extra.get("host") and extra.get("container"):
|
||||
mounts.append(
|
||||
Mount(extra["host"], extra["container"], extra.get("mode", "rw"))
|
||||
)
|
||||
language = (instance.get("boot_language") or "none").strip().lower()
|
||||
boot = (instance.get("boot_command") or "").strip()
|
||||
if language in BOOT_SCRIPT_FILES and (instance.get("boot_script") or "").strip():
|
||||
script_path = f"{WORKSPACE_MOUNT}/{BOOT_SCRIPT_FILES[language]}"
|
||||
command = [BOOT_SCRIPT_RUNNERS[language], script_path]
|
||||
elif boot:
|
||||
command = ["/bin/sh", "-c", boot]
|
||||
else:
|
||||
command = ["sleep", "infinity"]
|
||||
return RunSpec(
|
||||
image=image_tag,
|
||||
name=instance["slug"],
|
||||
labels={
|
||||
INSTANCE_LABEL: instance["uid"],
|
||||
PROJECT_LABEL: instance["project_uid"],
|
||||
},
|
||||
env=env,
|
||||
cpu_limit=instance.get("cpu_limit", ""),
|
||||
mem_limit=instance.get("mem_limit", ""),
|
||||
ports=ports,
|
||||
mounts=mounts,
|
||||
restart_policy=instance.get("restart_policy", "never"),
|
||||
command=command,
|
||||
)
|
||||
|
||||
|
||||
async def sync_workspace(instance: dict, user: dict) -> dict:
|
||||
workspace = instance.get("workspace_dir")
|
||||
if not workspace:
|
||||
raise ContainerError("instance has no workspace")
|
||||
counts = await asyncio.to_thread(
|
||||
project_files.sync_dir_bidirectional, instance["project_uid"], workspace, user
|
||||
)
|
||||
store.record_event(
|
||||
instance,
|
||||
"sync",
|
||||
"user",
|
||||
user["uid"],
|
||||
{"exported": counts["exported"], "imported": counts["imported"]},
|
||||
)
|
||||
return counts
|
||||
|
||||
|
||||
def sync_bidirectional_sync(instance: dict, user: dict) -> dict:
|
||||
workspace = instance.get("workspace_dir")
|
||||
if not workspace:
|
||||
return {"exported": 0, "imported": 0}
|
||||
counts = project_files.sync_dir_bidirectional(
|
||||
instance["project_uid"], workspace, user
|
||||
)
|
||||
if counts["exported"] or counts["imported"]:
|
||||
store.record_event(
|
||||
instance,
|
||||
"sync",
|
||||
"service",
|
||||
"system",
|
||||
{"exported": counts["exported"], "imported": counts["imported"]},
|
||||
)
|
||||
return counts
|
||||
|
||||
|
||||
def add_schedule(instance: dict, action: str, schedule: Schedule) -> dict:
|
||||
if action not in ("start", "stop"):
|
||||
raise ContainerError("schedule action must be start or stop")
|
||||
first = schedule.first_run(now_utc())
|
||||
return store.create_schedule(instance, action, schedule.columns(), to_iso(first))
|
||||
|
||||
|
||||
# ---------------- aggregation ----------------
|
||||
|
||||
|
||||
def _percentile(values: list, pct: float) -> float:
|
||||
if not values:
|
||||
return 0.0
|
||||
ordered = sorted(values)
|
||||
index = min(len(ordered) - 1, int(round((pct / 100.0) * (len(ordered) - 1))))
|
||||
return float(ordered[index])
|
||||
|
||||
|
||||
def instance_stats(instance_uid: str) -> dict:
|
||||
metrics = store.recent_metrics(instance_uid, limit=720)
|
||||
cpu = [m.get("cpu_pct", 0) for m in metrics]
|
||||
mem = [m.get("mem_bytes", 0) for m in metrics]
|
||||
return {
|
||||
"samples": len(metrics),
|
||||
"cpu_avg": round(sum(cpu) / len(cpu), 2) if cpu else 0.0,
|
||||
"cpu_p95": round(_percentile(cpu, 95), 2),
|
||||
"mem_max": max(mem) if mem else 0,
|
||||
"mem_avg": int(sum(mem) / len(mem)) if mem else 0,
|
||||
}
|
||||
|
||||
|
||||
def _port_reachable(host: str, port: int, timeout: float = 0.3) -> bool:
|
||||
try:
|
||||
with socket.create_connection((host, port), timeout=timeout):
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _http_probe(host: str, port: int, timeout: float = 1.0) -> str:
|
||||
try:
|
||||
with stealth.stealth_sync_client(timeout=timeout) as client:
|
||||
response = client.get(f"http://{host}:{port}/")
|
||||
return f"HTTP {response.status_code}"
|
||||
except Exception as exc: # noqa: BLE001 - diagnostic, any failure is informative
|
||||
return f"unreachable: {type(exc).__name__}"
|
||||
|
||||
|
||||
def _net_entry(data: dict) -> dict:
|
||||
net = (data or {}).get("NetworkSettings") or {}
|
||||
if net.get("IPAddress") or net.get("Gateway"):
|
||||
return net
|
||||
for entry in (net.get("Networks") or {}).values():
|
||||
if entry and (entry.get("IPAddress") or entry.get("Gateway")):
|
||||
return entry
|
||||
return {}
|
||||
|
||||
|
||||
def container_ip_from_inspect(data: dict) -> str:
|
||||
return (_net_entry(data).get("IPAddress") or "").strip()
|
||||
|
||||
|
||||
def container_gateway_from_inspect(data: dict) -> str:
|
||||
return (_net_entry(data).get("Gateway") or "").strip()
|
||||
|
||||
|
||||
def _ingress_container_port(instance: dict, port_maps: list) -> int:
|
||||
ingress_port = int(instance.get("ingress_port") or 0)
|
||||
if ingress_port:
|
||||
for mapping in port_maps:
|
||||
if int(mapping.get("container") or 0) == ingress_port:
|
||||
return ingress_port
|
||||
return 0
|
||||
return int(port_maps[0].get("container") or 0) if port_maps else 0
|
||||
|
||||
|
||||
def _host_port_for(port_maps: list, container_port: int) -> int:
|
||||
for mapping in port_maps:
|
||||
if int(mapping.get("container") or 0) == container_port:
|
||||
return int(mapping.get("host") or 0)
|
||||
return 0
|
||||
|
||||
|
||||
def proxy_target(instance: dict) -> tuple:
|
||||
port_maps = json.loads(instance.get("ports_json") or "[]")
|
||||
container_port = _ingress_container_port(instance, port_maps)
|
||||
if not container_port:
|
||||
return None, None
|
||||
host_port = _host_port_for(port_maps, container_port)
|
||||
if config.CONTAINER_PROXY_HOST:
|
||||
return (config.CONTAINER_PROXY_HOST, host_port) if host_port else (None, None)
|
||||
if not host_port:
|
||||
return None, None
|
||||
gateway = (instance.get("container_gateway") or "").strip()
|
||||
return (gateway or "127.0.0.1", host_port)
|
||||
|
||||
|
||||
def instance_runtime(instance: dict) -> dict:
|
||||
boot = (instance.get("boot_command") or "").strip()
|
||||
port_maps = json.loads(instance.get("ports_json") or "[]")
|
||||
container_ip = (instance.get("container_ip") or "").strip()
|
||||
container_gateway = (instance.get("container_gateway") or "").strip()
|
||||
target_host, target_port = proxy_target(instance)
|
||||
probe_host = config.CONTAINER_PROXY_HOST or container_gateway or "127.0.0.1"
|
||||
ports = []
|
||||
for mapping in port_maps:
|
||||
host_port = int(mapping.get("host") or 0)
|
||||
ports.append(
|
||||
{
|
||||
"container": int(mapping.get("container") or 0),
|
||||
"host": host_port,
|
||||
"proto": mapping.get("proto", "tcp"),
|
||||
"reachable": _port_reachable(probe_host, host_port)
|
||||
if host_port
|
||||
else False,
|
||||
}
|
||||
)
|
||||
ingress_serving = (
|
||||
_http_probe(target_host, target_port)
|
||||
if target_host and target_port
|
||||
else "no ingress port mapped"
|
||||
)
|
||||
return {
|
||||
"command": boot or "image CMD (no boot_command set)",
|
||||
"ports": ports,
|
||||
"container_ip": container_ip,
|
||||
"container_gateway": container_gateway,
|
||||
"ingress_target": (
|
||||
f"{target_host}:{target_port}" if target_host and target_port else ""
|
||||
),
|
||||
"ingress_port": int(instance.get("ingress_port") or 0),
|
||||
"ingress_serving": ingress_serving,
|
||||
"status_ok_for_ingress": instance.get("status") == store.ST_RUNNING,
|
||||
"restart_count": int(instance.get("restart_count") or 0),
|
||||
"exit_code": instance.get("exit_code"),
|
||||
"container_id": (instance.get("container_id") or "")[:12],
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
" retoor <retoor@molodetz.nl>
|
||||
" Self-contained config for the ppy container. No external plugins or managers:
|
||||
" it works out of the box with the stock vim, and the AI edit feature uses only
|
||||
" curl and the PRAVDA_* gateway env that every instance is launched with.
|
||||
|
||||
set nocompatible
|
||||
filetype plugin indent on
|
||||
syntax on
|
||||
|
||||
set encoding=utf-8
|
||||
set fileencoding=utf-8
|
||||
set termencoding=utf-8
|
||||
set mouse=a
|
||||
set backspace=indent,eol,start
|
||||
set autoindent
|
||||
set smartindent
|
||||
set tabstop=4
|
||||
set shiftwidth=4
|
||||
set expandtab
|
||||
set number
|
||||
set showmatch
|
||||
set showtabline=2
|
||||
set laststatus=2
|
||||
set hidden
|
||||
set incsearch
|
||||
set hlsearch
|
||||
set wildmenu
|
||||
set ttimeoutlen=50
|
||||
|
||||
if has('clipboard')
|
||||
set clipboard=unnamedplus
|
||||
endif
|
||||
|
||||
if !isdirectory(expand('~/.vim/undo'))
|
||||
call mkdir(expand('~/.vim/undo'), 'p')
|
||||
endif
|
||||
set undofile
|
||||
set undodir=~/.vim/undo
|
||||
|
||||
set statusline=%f\ %h%m%r\ %=\ [%{&filetype}]\ [%l,%c]\ %p%%
|
||||
highlight StatusLine cterm=bold ctermfg=15 ctermbg=24
|
||||
highlight StatusLineNC cterm=none ctermfg=250 ctermbg=236
|
||||
highlight ErrorMsg cterm=bold ctermfg=red ctermbg=none
|
||||
|
||||
let mapleader = ","
|
||||
|
||||
inoremap <C-n> <ESC>:tabnext<CR>
|
||||
inoremap <C-p> <ESC>:tabprevious<CR>
|
||||
nnoremap <C-n> :tabnext<CR>
|
||||
nnoremap <C-p> :tabprevious<CR>
|
||||
nnoremap <Tab> :tabnext<CR>
|
||||
nnoremap <C-Tab> :tabprevious<CR>
|
||||
nnoremap <C-e> :tabnew<Space>
|
||||
inoremap <C-e> <ESC>:tabnew<Space>
|
||||
|
||||
if has('autocmd')
|
||||
autocmd BufReadPost * if line("'\"") > 0 && line("'\"") <= line("$") | exe "normal! g'\"" | endif
|
||||
endif
|
||||
|
||||
function! s:GetVisualSelection() abort
|
||||
let [l:line_start, l:col_start] = [line("'<"), col("'<")]
|
||||
let [l:line_end, l:col_end] = [line("'>"), col("'>")]
|
||||
let l:lines = getline(l:line_start, l:line_end)
|
||||
if empty(l:lines)
|
||||
return ''
|
||||
endif
|
||||
let l:lines[-1] = l:lines[-1][: l:col_end - (l:line_start == l:line_end ? 1 : 2)]
|
||||
let l:lines[0] = l:lines[0][l:col_start - 1 :]
|
||||
return join(l:lines, "\n")
|
||||
endfunction
|
||||
|
||||
function! s:GatewayUrl() abort
|
||||
let l:base = substitute($PRAVDA_OPENAI_URL, '/\+$', '', '')
|
||||
if empty(l:base)
|
||||
return 'https://openai.app.molodetz.nl/v1/chat/completions'
|
||||
elseif l:base =~# '/chat/completions$'
|
||||
return l:base
|
||||
endif
|
||||
return l:base . '/chat/completions'
|
||||
endfunction
|
||||
|
||||
function! AiEditSelection() abort
|
||||
let l:instruction = input('AI instruction: ')
|
||||
if empty(l:instruction)
|
||||
echo 'Cancelled.'
|
||||
return
|
||||
endif
|
||||
let l:orig = s:GetVisualSelection()
|
||||
if empty(l:orig)
|
||||
echo 'No selection.'
|
||||
return
|
||||
endif
|
||||
let l:prompt = l:instruction . "\n\nHere is the text:\n" . l:orig
|
||||
\ . "\n\nOutput only the transformed text. No explanations, markdown, or code blocks."
|
||||
let l:api_key = !empty($PRAVDA_API_KEY) ? $PRAVDA_API_KEY : $DEEPSEEK_API_KEY
|
||||
let l:json = '{"model":"deepseek-chat","messages":[{"role":"user","content":' . json_encode(l:prompt) . '}]}'
|
||||
let l:cmd = 'curl -sS -X POST ' . shellescape(s:GatewayUrl())
|
||||
\ . ' -H ' . shellescape('Authorization: Bearer ' . l:api_key)
|
||||
\ . ' -H ' . shellescape('Content-Type: application/json')
|
||||
\ . ' -d ' . shellescape(l:json)
|
||||
let l:reply = system(l:cmd)
|
||||
if v:shell_error
|
||||
echohl ErrorMsg | echom 'AI request failed' | echohl None
|
||||
return
|
||||
endif
|
||||
let l:text = matchstr(l:reply, '"content":\s*"\zs\(.\{-}\)\ze"\s*[,}]')
|
||||
if empty(l:text)
|
||||
echohl ErrorMsg | echom 'No content in AI response' | echohl None
|
||||
return
|
||||
endif
|
||||
let l:text = substitute(l:text, '\\n', "\n", 'g')
|
||||
let l:text = substitute(l:text, '\\"', '"', 'g')
|
||||
let l:text = substitute(l:text, '\\t', "\t", 'g')
|
||||
normal! gv
|
||||
normal! c
|
||||
call feedkeys(l:text, 'n')
|
||||
endfunction
|
||||
|
||||
xnoremap <silent> <Leader>a :<C-u>call AiEditSelection()<CR>
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -23,6 +23,7 @@ CORRECTABLE_FIELDS: dict[str, tuple[str, ...]] = {
|
||||
"posts": ("title", "content"),
|
||||
"projects": ("title", "description"),
|
||||
"gists": ("title", "description"),
|
||||
"quizzes": ("title", "description"),
|
||||
"comments": ("content",),
|
||||
"messages": ("content",),
|
||||
"users": ("bio",),
|
||||
|
||||
@@ -22,7 +22,7 @@ The `docs` channel is branded **Docii**, a documentation-only assistant, built b
|
||||
- **search_docs.** `services/devii/docs/controller.py` delegates to the in-process page index `docs_search.search_pages(...)` (no HTTP), returning per result `title`, `url` (`/docs/{slug}.html`), `score` (BM25), and `content` (page text truncated to `CONTENT_CHARS`), admin-filtered by the dispatcher's `is_admin`. So "visiting the search results" is repeated `search_docs` calls; the default 40 `max_tool_iterations` leaves ample room to recurse.
|
||||
- **References and inline linking.** Because results carry real `url`s and `score`s, the prompt REQUIRES the answer to (a) link inline to documented pages with markdown links to their `url` and (b) end with a `## References` list of the pages used as clickable links with their score. The devii markdown renderer makes `/docs/...` links clickable client-side.
|
||||
- **Topic gate (follow-up vs new).** `_run_turn` runs `_docs_topic_gate(text)` before the main loop (docs channel only). When there is prior history it calls `_classify_topic` (a no-tools `LLMClient.complete_text` with `TOPIC_CLASSIFIER_PROMPT`, parsed by `_parse_topic`, defaulting to `follow_up` on any failure). On `new` it resets `agent._messages` to `[system]`, clears the persisted docs conversation, and emits `{type:"topic", decision, reason}`; the frontend clears the log, re-shows the current question, and prints a reasoning note. On `follow_up` it keeps context and shows the note.
|
||||
- **Isolation from Devii (critical).** The docs channel is built with an EPHEMERAL `owned_db` (`hub.get_or_create`: `db if owner_kind=="user" and channel=="main" else memory_db()`), so its task/lesson/behavior/virtual-tool stores are private and empty - a Docii reflection never pollutes the user's Devii memory and vice-versa. The **Scheduler only starts in the `main` channel** (`ensure_scheduler_started`: `if not self._started and self.channel == "main"`), so Devii's scheduled tasks never fire inside a docs session. (Regression fixed: previously the docs session's scheduler ran owner tasks over the shared `devii_tasks` table, and the search-only Docii agent tried to fulfil a `run_js` task by spamming `search_docs` - tasks are a `main`-only feature.)
|
||||
- **Isolation from Devii (critical).** The docs channel is built with an EPHEMERAL `owned_db` (`hub.get_or_create`: `db if owner_kind=="user" and channel=="main" else memory_db()`), so its task/lesson/behavior/virtual-tool stores are private and empty - a Docii reflection never pollutes the user's Devii memory and vice-versa. **Scheduled tasks only ever run in the `main` channel**: there is one global scheduler and `DeviiService._resolve_task_owner` always resolves the owner's session with `channel="main"`, so a due task can never be handed to a docs session. (Regression fixed: previously the docs session's scheduler ran owner tasks over the shared `devii_tasks` table, and the search-only Docii agent tried to fulfil a `run_js` task by spamming `search_docs` - tasks are a `main`-only feature.)
|
||||
- The `main` channel is untouched (full 90-tool catalog + behavior header + Devii greeting).
|
||||
|
||||
## Docs search mode and BM25 fallback
|
||||
@@ -43,9 +43,46 @@ Conversation history persists to `devii_conversations` (rehydrated on reconnect,
|
||||
|
||||
## Reminders and scheduled tasks (persistent across reboot)
|
||||
|
||||
`create_task` queues a self-contained prompt that a fresh agent runs later (`services/devii/tasks/`): `kind=once` (`delay_seconds` for relative, `run_at` UTC for absolute), `interval` (`every_seconds`), or `cron`. The per-session `Scheduler` ticks every 1s and executes due rows through the session's executor, so the result is broadcast to any connected tab and buffered (`type:"task"` frame) when the terminal is closed.
|
||||
`create_task` queues a self-contained prompt that a fresh agent runs later (`services/devii/tasks/`): `kind=once` (`delay_seconds` for relative, `run_at` UTC for absolute), `interval` (`every_seconds`), or `cron`. The result is broadcast to any connected tab and buffered (`type:"task"` frame) when the terminal is closed.
|
||||
|
||||
**The scheduler is no longer tied to a live WebSocket.** It is started by `session.ensure_scheduler_started()`, called both on `attach` AND by the lock-owner `DeviiService._ensure_task_schedulers()` each `run_once` (and promptly at boot): that pass scans the shared `devii_tasks` for every user owner with an enabled `pending`/`running` task (`tasks.store.pending_owner_ids(db)`), resolves the user (api_key/username/is_admin/timezone), and `hub.get_or_create(...)`s a headless session whose scheduler then runs the task - so a queued reminder survives a server reboot and fires even if the user never reopens Devii. `hub.gc_idle()` skips a session with `has_pending_tasks()` so a task-only session is not reaped between ticks.
|
||||
**Any signed-in account may schedule; two rolling 24-hour quotas bound it, and role decides the size.** Guests never can (`automation_allowed` requires `owner_kind == "user"`). Defaults: a member may CREATE `devii_task_member_create_24h` (5) tasks and EXECUTE `devii_task_member_runs_24h` (10) runs per 24h; an administrator gets `devii_task_admin_create_24h` (5) and `devii_task_admin_runs_24h` (100). All four are admin-editable on the Devii service; `0` means unlimited.
|
||||
|
||||
**Each quota is enforced by a single atomic conditional INSERT, never a check-then-act** (`tasks/limits.py`):
|
||||
|
||||
- `reserve_run(...)` -> `INSERT INTO devii_task_runs ... SELECT ... WHERE (SELECT COUNT(*) ... created_at >= :cutoff) < :limit`, decided on the driver's real `rowcount`. The scheduler calls it AFTER `claim` and BEFORE dispatch; a refusal releases the claim via `defer`.
|
||||
- `insert_task_within_quota(...)` -> the same shape for the task row itself, so two concurrent `create_task` calls (main + telegram channel, or two workers) can never both slip past a check. `TaskStore.create` pre-checks for a good error message, then relies on this insert for the actual guarantee.
|
||||
|
||||
Both are proven with real multi-process races (16 processes -> exactly 10 runs for a member, 12 processes -> exactly 5 creations). **Never replace either with a count-then-insert.**
|
||||
|
||||
**Creation counts the task row itself, so deleting a task does NOT give the slot back** - the window counts `devii_tasks.created_at` regardless of `deleted_at`. Runs are counted in the dedicated `devii_task_runs` ledger (a derived counter table, NOT in `SOFT_DELETE_TABLES`; hard-pruned to 48h by `run_once`).
|
||||
|
||||
**A quota refusal postpones, it never disables.** `retire_reason` (terminal: guest owner, expiry, `max_runs`, failure streak) disables the task; `run_deferral`/`budget_deferral` push `next_run_at` to when the slot frees (`Quota.free_at` = the oldest run in the window + 24h) and leave the task enabled, audited `devii.task.deferred`. Keep that split: a rate limit that disabled tasks would silently destroy a user's automation.
|
||||
|
||||
**The task-run context is the unhackable part** (`tasks/context.py`). `task_run_scope()` sets a `contextvars.ContextVar` around the executor inside `run_row`, so:
|
||||
|
||||
- Everything the scheduled agent does - including subagents, `delegate`, and virtual tools, which all share the dispatcher - runs inside that context.
|
||||
- A concurrent interactive turn runs in its own asyncio Task context and can never see or clear it (asyncio copies the context at task creation).
|
||||
- **There is no tool, argument, or prompt that can write it.** The model only ever influences tool arguments; the flag lives in process state set by the scheduler. This is why the nesting rule is enforced on the ContextVar rather than on anything the model can say.
|
||||
|
||||
`nesting_allowed(owner_id)` = `not in_task_run() or owner_is_admin(owner_id)`. `TaskStore.create` and `TaskStore.update`-when-enabling both consult it, so a member's task run cannot create a task, re-enable one, or `run_task_now` - while an administrator's task may schedule follow-up work. The session also TELLS the agent where it is: `_compose_system_prompt()` injects the `# TASK RUN ENVIRONMENT` block (`TASK_RUN_MEMBER`/`TASK_RUN_ADMIN`) only when `in_task_run()`.
|
||||
|
||||
Beyond the quotas, three chokepoints still gate scheduling (one alone is not enough - the tool gate is a UI control, the store gate covers every writer, and the claim gate is the only one that also retires rows already in the database):
|
||||
|
||||
1. **Tool visibility and dispatch.** `create_task`/`update_task`/`run_task_now` are `requires_auth=True`, so guests are never offered them. `list_tasks`/`get_task`/`delete_task` stay open.
|
||||
2. **`TaskStore.create` / `TaskStore.update`.** Raises `AutomationDenied` (with `retry_at` when a quota is the cause) for a guest owner, a nested member creation, or a spent creation quota. Enabling a task takes the same nesting check; disabling is always allowed, which is what lets the scheduler and admins stop things. This sits below the dispatcher, so it also covers the standalone `devii` CLI - whose store passes `operator=True`, the one deliberate exemption for the local operator - and anything added later.
|
||||
3. **`Scheduler`/`GlobalScheduler` claim.** `retire_reason` runs before every execution and re-checks owner kind, expiry, run limit, and failure streak, so a task that must stop is retired at execution time with no migration and no manual step.
|
||||
|
||||
**`TaskStore._ensure_schema` declares the FULL column set** (`TASK_COLUMNS`) rather than leaning on dataset's lazy schema - the atomic INSERT names columns explicitly, and dataset skips a `None`-valued key when creating columns, so a lazily-built table would be missing `run_at`/`cron`/`start_at`. Both `_ensure_schema` and `_ensure_indexes` tolerate a concurrent `duplicate column` / `already exists` error: several processes construct a `TaskStore` at once and SQLite DDL is not idempotent (this was a real crash, found by racing 12 processes).
|
||||
|
||||
**Role is live, never frozen into the session.** `DeviiSession.is_admin`/`is_primary_admin` are properties resolving `get_admin_uids()`/`get_primary_admin_uid()` (both TTL-cached and cross-worker invalidated). `_refresh_privileges()` runs at the top of every turn AND inside the scheduled executor: it rewrites the base system prompt when the role changed and pushes the flags into the dispatcher via `Dispatcher.set_admin`. Regression to avoid: the old code captured `is_admin` as a bool at session construction, and a session with pending tasks was exempt from `gc_idle`, so a **demoted admin kept admin tools indefinitely**.
|
||||
|
||||
**One global scheduler, not one per owner.** `DeviiService.scheduler()` owns a single `GlobalScheduler` started in `on_enable` on the lock-owner worker. Each 1s tick issues ONE indexed query (`tasks.store.due_rows`, served by `idx_devii_tasks_due` on `(enabled, status, next_run_at)`) instead of one query per owner per second, and hands out at most `devii_task_max_concurrent` slots **round-robin, one at a time per owner**, so no owner can monopolise the scheduler. A row is taken with an atomic conditional claim (`tasks.store.claim` -> `UPDATE ... WHERE uid=? AND status='pending' AND enabled=1 AND deleted_at IS NULL`, decided on the driver's real `rowcount`), which closes the TOCTOU between a tick and `run_task_now`. Sessions are materialized lazily by `_resolve_task_owner` only when a task actually fires, so `gc_idle` now reaps on `session.is_busy()` (a turn in flight) rather than exempting every task-owning session forever. The per-store `Scheduler` class remains for the standalone `devii` CLI; both share `run_row`/`retire`/`refusal`, so there is one implementation of the guard logic.
|
||||
|
||||
**Every recurring task stops by itself.** `Schedule` enforces `MIN_INTERVAL_SECONDS` (900) on `every_seconds` and on cron spacing - the cron check walks a full day of fires from a canonical midnight reference and rejects the smallest gap, so `*/18` is refused for its 6-minute hop at the hour boundary and the verdict never depends on the current minute. `tasks.guards.task_columns` defaults `max_runs` to `DEFAULT_MAX_RUNS` for recurring kinds and stamps `expires_at`, capped at `MAX_LIFETIME_DAYS` (30) past the first run. `guards.expiry_of` falls back to `created_at + MAX_LIFETIME_DAYS` when the column is absent, so a legacy or hand-written row is bounded too. A run that raises increments `failure_count` and reschedules; `devii_task_max_failures` consecutive failures disable the task (previously a single failure left it stuck in `status="error"` forever, silently). `run_once` housekeeping also disables tasks whose owner has not been seen for `devii_task_owner_idle_days`. Every automatic stop writes `devii.task.blocked` with its reason.
|
||||
|
||||
**Automation has its own budget.** `quota_exceeded` guards only interactive turns, and `devii_admin_daily_usd` defaults to 0 (unlimited) - which after the admin-only rule is exactly the population that schedules. `devii_task_daily_usd` (`DeviiService.automation_budget_exceeded`) is a separate rolling-24h cap checked in the claim path; spend itself was always ledgered by `_record_spend` in the executor's `finally`.
|
||||
|
||||
Admin surface: `/admin/devii-tasks` (`routers/admin/devii_tasks.py`) lists every task across owners with its bounds and per-row disable/delete; `devplace devii tasks list|disable|prune` is the CLI counterpart (`prune` disables every task whose owner may no longer schedule).
|
||||
|
||||
A task created as a reminder carries `notify=1`: when it finishes, `session._deliver_reminder` sends a `utils.create_notification(owner, "reminder", result, target_url="/devii")` (the `reminder` notification type), so the in-app notification + live toast reach the user regardless of the terminal.
|
||||
|
||||
@@ -79,6 +116,10 @@ Beyond the avatar, Devii has a `client`/browser channel using the same request/r
|
||||
|
||||
**Target selection (visibility-aware).** Unlike replies/traces, which `_emit` *broadcasts* to every tab, a browser request is sent to one **target** chosen by `_pick_target()`. The terminal reports each connection's `document` visibility and focus via `{"type":"visibility"}` (on connect, `focus`, `blur`, and `visibilitychange`); the session stores it in `_conn_meta` and ranks connections `(focused, visible, attach_seq)`, so the command runs on the tab the user is actually looking at, falling back to the most recently attached when none reports focus. **Regression to avoid: do NOT route to a single "last-attached primary" socket** - with the `/docs` auto-open tab or any second tab, `reload_page`/`navigate_to` ran on the wrong (hidden) tab while still reporting success, so "the reload did not happen" from the user's view. `run_js` is gated by the `devii_allow_eval` config field (default on), checked in `ClientController` before any round-trip. Overlays (`.devii-hl-box`, `.devii-hl-callout`, `.devii-toast`) attach to `document.body`, not the terminal.
|
||||
|
||||
## Stale API key auto-recovery (401 self-healing)
|
||||
|
||||
A user session captures the owner's platform `api_key` into its frozen `Settings` at build time (both `Settings.ai_key` for the gateway and `Settings.platform_api_key` for REST). Regenerating that key (profile regenerate, Devii tool, `devplace apikey reset` - possibly from another worker or process) would strand every live session with a permanent `401 Unauthorized` on all LLM and platform calls. Recovery is **reactive at the two HTTP chokepoints**, never proactive session invalidation (which is per-process and cannot reach the hub on the lock-owner worker): `hub.get_or_create` builds a `_user_key_resolver(owner_id)` (a fresh `users.api_key` DB read, user owners only - guests get `None` and keep the internal gateway key) and passes it to `LLMClient(settings, key_resolver=)` and through `DeviiSession(key_resolver=)` into `PlatformClient`. On a 401 (`LLMClient._post`) or a 401/redirect-to-login (`PlatformClient.call` via `_auth_failed`), the client calls `_refresh_key()`: resolve the current key, and only when it is non-empty AND differs from the header already sent, rewrite the auth headers and retry the request **once**. An unchanged or unresolvable key skips the retry so a genuinely invalid credential still fails closed with the original error. The resolver read is cross-worker correct because it goes straight to SQLite, not any per-process cache.
|
||||
|
||||
## Shared browser session (auth adoption)
|
||||
|
||||
The terminal and the browser are one session. `/devii/ws` resolves its owner from the browser's `session` cookie (`_resolve_ws_owner`), so the terminal is whoever the browser is logged in as. Agent-initiated auth propagates: the session watches its own trace stream (`_trace`) and, on a successful `login`/`signup`, captures the real session token the `PlatformClient` minted against this instance (`session_cookie()`) and broadcasts `{"type":"auth","action":"adopt"}`; the browser navigates to single-use `GET /devii/adopt` which sets the httpOnly `session` cookie and redirects (reload). On `logout` it broadcasts `{"type":"auth","action":"logout"}` and the browser goes to `/auth/logout`. Browser->terminal: login/logout are navigations that reconnect the WS; a cross-tab change is caught by a focus check against `/devii/session` that reloads. The httpOnly cookie is only ever set/cleared by HTTP endpoints, never JS.
|
||||
@@ -115,6 +156,8 @@ Devii is reachable over Telegram via `channel="telegram"` sessions driven in-pro
|
||||
|
||||
Devii exposes read-only `db_list_tables`, `db_table_schema`, `db_list_rows`, `db_get_row`, `db_query`, and `db_design_query` tools gated `requires_primary_admin=True`. See `devplacepy/services/dbapi/CLAUDE.md` for the full auth boundary, validation pipeline, and async query service - these tools are added to the LLM tool list only for the primary administrator; every other session is unaware they exist.
|
||||
|
||||
**`db_query` and `db_design_query` MUST stay marked `is_read_only=True`.** They are POST routes, so they carry a request body and would otherwise be classified as mutating by `method in MUTATING_METHODS` alone - but they only read. Marking them read-only is what keeps them from tripping the agentic plan gate and the verification gate, which would discard the very rows the user asked for. `tests/unit/services/devii/actions/catalog.py` guards it.
|
||||
|
||||
## All Devii/gateway network timeouts are admin-configurable with a five-minute (300s) floor
|
||||
|
||||
The Devii LLM-client and `PlatformClient` read timeout is `timeout_seconds` (field `devii_timeout`), the fetch/docs tool timeout is `fetch_timeout_seconds` (field `devii_fetch_timeout`), web search is `rsearch_timeout_seconds` (`devii_rsearch_timeout`); each defaults to 300s with `minimum=MIN_TIMEOUT_SECONDS` (300). The gateway upstream timeout is `gateway_timeout` (`minimum=TIMEOUT_MIN`=300), which also bounds the vision describe-image call (it shares the gateway's httpx client, no per-call override). A stored value below the floor fails `ConfigField.coerce()` and `read()` falls back to the default, so old sub-300 values upgrade automatically. Devii's LLM timeout was previously a hardcoded 45s while the gateway waited up to 180s x retries - large prompts aborted as `[model error] Could not reach the model endpoint:` (an httpx timeout, which stringifies to empty). `build_settings()` now reads these from config instead of hardcoding them.
|
||||
|
||||
@@ -20,6 +20,7 @@ from .posts import POSTS_ACTIONS
|
||||
from .profile import PROFILE_ACTIONS
|
||||
from .project_files import PROJECT_FILE_ACTIONS
|
||||
from .projects import PROJECTS_ACTIONS
|
||||
from .quizzes import QUIZ_ACTIONS
|
||||
from .social import SOCIAL_ACTIONS
|
||||
from .tools import TOOLS_ACTIONS
|
||||
from .uploads import UPLOAD_ACTIONS
|
||||
@@ -45,6 +46,7 @@ ACTIONS: tuple[Action, ...] = (
|
||||
+ DBAPI_ACTIONS
|
||||
+ GATEWAY_ACTIONS
|
||||
+ GAME_ACTIONS
|
||||
+ QUIZ_ACTIONS
|
||||
)
|
||||
|
||||
PLATFORM_CATALOG = Catalog(actions=ACTIONS)
|
||||
|
||||
@@ -40,7 +40,7 @@ ENGAGEMENT_ACTIONS: tuple[Action, ...] = (
|
||||
"target_uid",
|
||||
"Uid of the target, copied from a listing response; do not invent it.",
|
||||
),
|
||||
body("emoji", "One of the allowed reaction emoji.", required=True),
|
||||
body("emoji", "Any single emoji character, for example \U0001F984.", required=True),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ..spec import Action, Param
|
||||
from ._shared import body, path
|
||||
from ._shared import body, confirm, path, query
|
||||
|
||||
|
||||
GAME_ACTIONS: tuple[Action, ...] = (
|
||||
@@ -24,10 +24,18 @@ GAME_ACTIONS: tuple[Action, ...] = (
|
||||
name="game_leaderboard",
|
||||
method="GET",
|
||||
path="/game/leaderboard",
|
||||
summary="List the top Code Farm players",
|
||||
summary="List the top Code Farm players on a given board",
|
||||
description=(
|
||||
"Boards: score (default, overall composite score), prestige, harvests "
|
||||
"(this week), raids (avg coins per successful raid, min 3 raids), "
|
||||
"time_to_kernel (fastest since last refactor), fair_play (rewards recent "
|
||||
"activity over hoarding), era (current Era-only leaderboard, empty when no "
|
||||
"Era is running)."
|
||||
),
|
||||
handler="http",
|
||||
requires_auth=False,
|
||||
read_only=True,
|
||||
params=(query("board", "Leaderboard board key, defaults to score."),),
|
||||
),
|
||||
Action(
|
||||
name="game_view_farm",
|
||||
@@ -48,7 +56,12 @@ GAME_ACTIONS: tuple[Action, ...] = (
|
||||
requires_auth=True,
|
||||
params=(
|
||||
Param(name="slot", location="body", description="Plot slot index.", required=True, type="integer"),
|
||||
body("crop", "Crop key, e.g. shell, python, webapp, api, rust, haskell, kernel.", required=True),
|
||||
body(
|
||||
"crop",
|
||||
"Crop key: shell, python, webapp, api, rust, haskell, kernel, or "
|
||||
"(with Mastery) distsys, mlpipe, secfort.",
|
||||
required=True,
|
||||
),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
@@ -136,18 +149,41 @@ GAME_ACTIONS: tuple[Action, ...] = (
|
||||
name="game_claim_quest",
|
||||
method="POST",
|
||||
path="/game/quests/claim",
|
||||
summary="Claim a completed daily Code Farm quest reward",
|
||||
summary="Claim a completed daily or weekly Code Farm quest/contract reward",
|
||||
handler="http",
|
||||
requires_auth=True,
|
||||
params=(
|
||||
body("quest", "Quest kind: plant, harvest, water, or earn.", required=True),
|
||||
body(
|
||||
"scope",
|
||||
"daily (default) or weekly (requires the Legacy Contracts Mastery upgrade).",
|
||||
required=False,
|
||||
),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="game_prestige",
|
||||
method="POST",
|
||||
path="/game/prestige",
|
||||
summary="Refactor (prestige) your Code Farm for a permanent coin bonus and Stars (requires level 10)",
|
||||
summary=(
|
||||
"Refactor (prestige) your Code Farm for a permanent coin bonus and Stars. "
|
||||
"Requires level 10 AND a coin fee that scales with prestige and wealth "
|
||||
"(shown as refactor_cost in game_state); the fee funds the community "
|
||||
"treasury and a fraction of the remaining coins carries over. "
|
||||
"Irreversible, confirmation required"
|
||||
),
|
||||
handler="http",
|
||||
requires_auth=True,
|
||||
params=(confirm(),),
|
||||
),
|
||||
Action(
|
||||
name="game_claim_grant",
|
||||
method="POST",
|
||||
path="/game/grant",
|
||||
summary=(
|
||||
"Claim the weekly Code Farm community grant, paid from the treasury of "
|
||||
"refactor fees (active low-balance, low-prestige farms only)"
|
||||
),
|
||||
handler="http",
|
||||
requires_auth=True,
|
||||
),
|
||||
@@ -155,15 +191,86 @@ GAME_ACTIONS: tuple[Action, ...] = (
|
||||
name="game_upgrade_legacy",
|
||||
method="POST",
|
||||
path="/game/legacy",
|
||||
summary="Spend Stars on a permanent Legacy upgrade that survives refactor (autoharvest, multiplier, speed, plots, defense)",
|
||||
summary="Spend Stars on a permanent Legacy upgrade that survives refactor (autoharvest, multiplier, speed, plots, defense, carryover)",
|
||||
handler="http",
|
||||
requires_auth=True,
|
||||
params=(
|
||||
body(
|
||||
"key",
|
||||
"Legacy key: autoharvest, multiplier, speed, plots, or defense.",
|
||||
"Legacy key: autoharvest, multiplier, speed, plots, defense, or carryover.",
|
||||
required=True,
|
||||
),
|
||||
confirm(),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="game_upgrade_mastery",
|
||||
method="POST",
|
||||
path="/game/mastery",
|
||||
summary="Spend Mastery points on a permanent Mastery upgrade (unlocked at prestige 50+): autoreplant, analytics, or contracts",
|
||||
handler="http",
|
||||
requires_auth=True,
|
||||
params=(
|
||||
body("key", "Mastery key: autoreplant, analytics, or contracts.", required=True),
|
||||
confirm(),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="game_buy_infrastructure",
|
||||
method="POST",
|
||||
path="/game/infrastructure/buy",
|
||||
summary="Buy a permanent Infrastructure building (registry, canary, observability); expensive, prestige-gated, big coin sinks",
|
||||
handler="http",
|
||||
requires_auth=True,
|
||||
params=(
|
||||
body("key", "Infrastructure key: registry, canary, or observability.", required=True),
|
||||
confirm(),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="game_upgrade_defense",
|
||||
method="POST",
|
||||
path="/game/defense/upgrade",
|
||||
summary=(
|
||||
"Upgrade your farm's Defense tier: reduces raid losses and adds steal grace, "
|
||||
"but commits you to an ongoing daily coin upkeep proportional to your wealth. "
|
||||
"Irreversible spend, confirmation required"
|
||||
),
|
||||
handler="http",
|
||||
requires_auth=True,
|
||||
params=(confirm(),),
|
||||
),
|
||||
Action(
|
||||
name="game_downgrade_defense",
|
||||
method="POST",
|
||||
path="/game/defense/downgrade",
|
||||
summary=(
|
||||
"Drop your farm's Defense down one tier to escape its daily upkeep. "
|
||||
"No refund, confirmation required"
|
||||
),
|
||||
handler="http",
|
||||
requires_auth=True,
|
||||
params=(confirm(),),
|
||||
),
|
||||
Action(
|
||||
name="game_buy_cosmetic",
|
||||
method="POST",
|
||||
path="/game/cosmetics/buy",
|
||||
summary="Buy a purely cosmetic title or plot skin with coins (no gameplay effect)",
|
||||
handler="http",
|
||||
requires_auth=True,
|
||||
params=(
|
||||
body("key", "Cosmetic key from the farm's cosmetics list.", required=True),
|
||||
confirm(),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="game_equip_cosmetic",
|
||||
method="POST",
|
||||
path="/game/cosmetics/equip",
|
||||
summary="Equip an owned cosmetic title so it shows on the leaderboard",
|
||||
handler="http",
|
||||
requires_auth=True,
|
||||
params=(body("key", "An owned title cosmetic key.", required=True),),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -117,4 +117,57 @@ GATEWAY_ACTIONS: tuple[Action, ...] = (
|
||||
requires_admin=True,
|
||||
params=(path("source_model", "Source model name."), confirm()),
|
||||
),
|
||||
Action(
|
||||
name="gateway_quota_rules",
|
||||
method="GET",
|
||||
path="/admin/gateway/quota-rules",
|
||||
summary="List AI gateway quota rules and the current global defaults (admin only)",
|
||||
description=(
|
||||
"Returns JSON: every quota rule (each scoped by any combination of role/owner_kind, "
|
||||
"a specific user uid, and an app_reference label) with its 24h limit, current 24h spend "
|
||||
"against that exact scope, active flag, and label, plus the global per-role default caps "
|
||||
"used when no rule matches a request."
|
||||
),
|
||||
handler="http",
|
||||
requires_admin=True,
|
||||
read_only=True,
|
||||
),
|
||||
Action(
|
||||
name="gateway_quota_rule_set",
|
||||
method="POST",
|
||||
path="/admin/gateway/quota-rules",
|
||||
summary="Create or update an AI gateway quota rule (admin only)",
|
||||
description=(
|
||||
"Caps rolling-24h USD spend on /openai/v1/*. Scope by any combination of owner_kind "
|
||||
"(internal/key/user/admin/anonymous), a specific owner_id (user uid), and app_reference "
|
||||
"(the X-App-Reference header apps send). At least one of the three must be set - an "
|
||||
"unscoped cap belongs in the global default fields on the gateway service config instead. "
|
||||
"Leaving a dimension blank makes it a wildcard: an app_reference-only rule pools spend "
|
||||
"across every caller using that app; an owner_kind-only rule pools spend across every "
|
||||
"caller of that role. Setting owner_id pins the rule to one specific caller. When several "
|
||||
"rules match one request, the MOST SPECIFIC one wins (most non-blank dimensions); ties "
|
||||
"break toward the smaller limit. limit_usd of 0 means unlimited for that rule. Pass uid "
|
||||
"to update an existing rule instead of creating a new one."
|
||||
),
|
||||
handler="http",
|
||||
requires_admin=True,
|
||||
params=(
|
||||
body("uid", "Existing rule uid to update; omit to create a new rule."),
|
||||
body("owner_kind", "Role to scope by: internal, key, user, admin, or anonymous. Blank = any role."),
|
||||
body("owner_id", "Specific user uid to scope by. Blank = any caller of the matched role."),
|
||||
body("app_reference", "App label to scope by (the X-App-Reference header). Blank = any app."),
|
||||
Param(name="limit_usd", location="body", description="Rolling 24h USD cap for this rule. 0 = unlimited.", required=True, type="number"),
|
||||
Param(name="is_active", location="body", description="Whether the rule is enforced ('1' or '0'). Defaults to active.", required=False, type="boolean"),
|
||||
body("label", "Optional admin-facing note describing what this rule is for."),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="gateway_quota_rule_delete",
|
||||
method="DELETE",
|
||||
path="/admin/gateway/quota-rules/{uid}",
|
||||
summary="Delete an AI gateway quota rule (admin only, confirmation required)",
|
||||
handler="http",
|
||||
requires_admin=True,
|
||||
params=(path("uid", "Quota rule uid."), confirm()),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ..spec import Action
|
||||
from ._shared import body, confirm, path, query
|
||||
|
||||
KIND_LIST = (
|
||||
"single_choice, multiple_choice, true_false, free_text, fill_blank, numeric, "
|
||||
"ordering, matching"
|
||||
)
|
||||
|
||||
DOCUMENT_HELP = (
|
||||
"The complete quiz as a JSON string: "
|
||||
'{"title": "...", "description": "...", '
|
||||
'"settings": {"shuffle_questions": true, "reveal_answers": true, '
|
||||
'"pass_percent": 70, "time_limit_seconds": 900}, '
|
||||
'"questions": [{"kind": "single_choice", "prompt": "...", "points": 1, '
|
||||
'"explanation": "...", "options": [{"label": "A"}, {"label": "B", "is_correct": true}]}]}. '
|
||||
f"Question kinds: {KIND_LIST}. A free_text question needs expected_answer or "
|
||||
"grading_criteria; numeric needs numeric_value; true_false needs correct_boolean; "
|
||||
"fill_blank and matching need a match_value on every option."
|
||||
)
|
||||
|
||||
|
||||
QUIZ_ACTIONS: tuple[Action, ...] = (
|
||||
Action(
|
||||
name="list_quizzes",
|
||||
method="GET",
|
||||
path="/quizzes",
|
||||
summary="List quizzes with the viewer's per-quiz progress",
|
||||
description=(
|
||||
"Returns published quizzes plus the signed-in viewer's state per quiz "
|
||||
"(todo, in_progress, done) and the cross-quiz scoreboard."
|
||||
),
|
||||
handler="http",
|
||||
requires_auth=False,
|
||||
read_only=True,
|
||||
params=(
|
||||
query("search", "Match the title, description or author username."),
|
||||
query("filter", "One of all, todo, done, mine, drafts. Defaults to all."),
|
||||
query("page", "1-based page number."),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="get_quiz",
|
||||
method="GET",
|
||||
path="/quizzes/{slug}",
|
||||
summary="Get one quiz with its stats, leaderboard and comments",
|
||||
handler="http",
|
||||
requires_auth=False,
|
||||
read_only=True,
|
||||
params=(path("slug", "Quiz slug or uid."),),
|
||||
),
|
||||
Action(
|
||||
name="export_quiz",
|
||||
method="GET",
|
||||
path="/quizzes/{slug}/export",
|
||||
summary="Export a quiz as a full JSON document",
|
||||
description=(
|
||||
"The inverse of import_quiz. The owner's export includes every correct "
|
||||
"answer; a public export of a published quiz omits them."
|
||||
),
|
||||
handler="http",
|
||||
requires_auth=False,
|
||||
read_only=True,
|
||||
params=(path("slug", "Quiz slug or uid."),),
|
||||
),
|
||||
Action(
|
||||
name="create_quiz",
|
||||
method="POST",
|
||||
path="/quizzes/create",
|
||||
summary="Create an empty draft quiz",
|
||||
handler="http",
|
||||
requires_auth=True,
|
||||
params=(
|
||||
body("title", "Quiz title, 3 to 200 characters.", required=True),
|
||||
body("description", "Markdown description."),
|
||||
body("shuffle_questions", "Set to 1 to shuffle the question order."),
|
||||
body("shuffle_options", "Set to 1 to shuffle the answer options."),
|
||||
body("reveal_answers", "Set to 1 to reveal answers after each question."),
|
||||
body("allow_review", "Set to 1 to allow reviewing answers on the results screen."),
|
||||
body("time_limit_seconds", "Time limit in seconds, 0 for none."),
|
||||
body("pass_percent", "Pass mark 0-100, 0 for no verdict."),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="import_quiz",
|
||||
method="POST",
|
||||
path="/quizzes/import",
|
||||
summary="Create a complete quiz from one JSON document",
|
||||
description=(
|
||||
"The full-automation entry point: creates the draft quiz, every question "
|
||||
"and every option in one call. Publish it afterwards with publish_quiz."
|
||||
),
|
||||
handler="http",
|
||||
requires_auth=True,
|
||||
params=(body("document", DOCUMENT_HELP, required=True),),
|
||||
),
|
||||
Action(
|
||||
name="edit_quiz",
|
||||
method="POST",
|
||||
path="/quizzes/edit/{slug}",
|
||||
summary="Edit a draft quiz's title, description and settings",
|
||||
description="Refused with a 400 once the quiz is published.",
|
||||
handler="http",
|
||||
requires_auth=True,
|
||||
params=(
|
||||
path("slug", "Quiz slug or uid."),
|
||||
body("title", "Quiz title, 3 to 200 characters.", required=True),
|
||||
body("description", "Markdown description."),
|
||||
body("shuffle_questions", "Set to 1 to shuffle the question order."),
|
||||
body("shuffle_options", "Set to 1 to shuffle the answer options."),
|
||||
body("reveal_answers", "Set to 1 to reveal answers after each question."),
|
||||
body("allow_review", "Set to 1 to allow reviewing answers on the results screen."),
|
||||
body("time_limit_seconds", "Time limit in seconds, 0 for none."),
|
||||
body("pass_percent", "Pass mark 0-100, 0 for no verdict."),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="publish_quiz",
|
||||
method="POST",
|
||||
path="/quizzes/{slug}/publish",
|
||||
summary="Publish a quiz, freezing it forever",
|
||||
description=(
|
||||
"IRREVERSIBLE. A published quiz, its questions and its options can never be "
|
||||
"edited again; only deletion remains. Refused with the exact list of problems "
|
||||
"when the quiz is incomplete."
|
||||
),
|
||||
handler="http",
|
||||
requires_auth=True,
|
||||
params=(path("slug", "Quiz slug or uid."), confirm()),
|
||||
),
|
||||
Action(
|
||||
name="delete_quiz",
|
||||
method="POST",
|
||||
path="/quizzes/delete/{slug}",
|
||||
summary="Delete a quiz and everything attached to it",
|
||||
handler="http",
|
||||
requires_auth=True,
|
||||
params=(path("slug", "Quiz slug or uid."), confirm()),
|
||||
),
|
||||
Action(
|
||||
name="add_quiz_question",
|
||||
method="POST",
|
||||
path="/quizzes/{slug}/questions",
|
||||
summary="Add one question to a draft quiz",
|
||||
handler="http",
|
||||
requires_auth=True,
|
||||
params=(
|
||||
path("slug", "Quiz slug or uid."),
|
||||
body("kind", f"Question kind, one of: {KIND_LIST}.", required=True),
|
||||
body("prompt", "The question prompt, markdown.", required=True),
|
||||
body("points", "Points for this question, 1 to 100."),
|
||||
body("explanation", "Explanation shown after answering."),
|
||||
body("options", "Option labels, one per line or comma separated."),
|
||||
body("match_values", "Accepted answers aligned with the options, one per line."),
|
||||
body("correct_indexes", "Comma separated 0-based indexes of the correct options."),
|
||||
body("correct_boolean", "true_false only: 1 when the statement is true."),
|
||||
body("expected_answer", "free_text only: the reference answer."),
|
||||
body("grading_criteria", "free_text only: criteria for the AI reviewer."),
|
||||
body("numeric_value", "numeric only: the correct value."),
|
||||
body("numeric_tolerance", "numeric only: the accepted absolute tolerance."),
|
||||
body("case_sensitive", "fill_blank only: 1 to compare case sensitively."),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="edit_quiz_question",
|
||||
method="POST",
|
||||
path="/quizzes/{slug}/questions/{question_uid}",
|
||||
summary="Replace one question of a draft quiz",
|
||||
handler="http",
|
||||
requires_auth=True,
|
||||
params=(
|
||||
path("slug", "Quiz slug or uid."),
|
||||
path("question_uid", "The question uid."),
|
||||
body("kind", f"Question kind, one of: {KIND_LIST}.", required=True),
|
||||
body("prompt", "The question prompt, markdown.", required=True),
|
||||
body("points", "Points for this question, 1 to 100."),
|
||||
body("explanation", "Explanation shown after answering."),
|
||||
body("options", "Option labels, one per line or comma separated."),
|
||||
body("match_values", "Accepted answers aligned with the options, one per line."),
|
||||
body("correct_indexes", "Comma separated 0-based indexes of the correct options."),
|
||||
body("correct_boolean", "true_false only: 1 when the statement is true."),
|
||||
body("expected_answer", "free_text only: the reference answer."),
|
||||
body("grading_criteria", "free_text only: criteria for the AI reviewer."),
|
||||
body("numeric_value", "numeric only: the correct value."),
|
||||
body("numeric_tolerance", "numeric only: the accepted absolute tolerance."),
|
||||
body("case_sensitive", "fill_blank only: 1 to compare case sensitively."),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="delete_quiz_question",
|
||||
method="POST",
|
||||
path="/quizzes/{slug}/questions/{question_uid}/delete",
|
||||
summary="Delete one question from a draft quiz",
|
||||
handler="http",
|
||||
requires_auth=True,
|
||||
params=(
|
||||
path("slug", "Quiz slug or uid."),
|
||||
path("question_uid", "The question uid."),
|
||||
confirm(),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="reorder_quiz_questions",
|
||||
method="POST",
|
||||
path="/quizzes/{slug}/questions/reorder",
|
||||
summary="Set the question order of a draft quiz",
|
||||
handler="http",
|
||||
requires_auth=True,
|
||||
params=(
|
||||
path("slug", "Quiz slug or uid."),
|
||||
body(
|
||||
"order",
|
||||
"Every question uid in the wanted order, comma separated.",
|
||||
required=True,
|
||||
),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="start_quiz_attempt",
|
||||
method="POST",
|
||||
path="/quizzes/{slug}/attempts",
|
||||
summary="Start or resume an attempt on a published quiz",
|
||||
description="Always returns the single in-progress attempt for this member.",
|
||||
handler="http",
|
||||
requires_auth=True,
|
||||
params=(path("slug", "Quiz slug or uid."),),
|
||||
),
|
||||
Action(
|
||||
name="get_quiz_attempt",
|
||||
method="GET",
|
||||
path="/quizzes/{slug}/attempts/{attempt_uid}",
|
||||
summary="Read an attempt with its questions in play order",
|
||||
description=(
|
||||
"Correct answers are withheld until a question has been answered and the "
|
||||
"quiz reveals answers."
|
||||
),
|
||||
handler="http",
|
||||
requires_auth=True,
|
||||
read_only=True,
|
||||
params=(
|
||||
path("slug", "Quiz slug or uid."),
|
||||
path("attempt_uid", "The attempt uid."),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="answer_quiz_question",
|
||||
method="POST",
|
||||
path="/quizzes/{slug}/attempts/{attempt_uid}/answer",
|
||||
summary="Submit one answer and get it graded",
|
||||
description=(
|
||||
"Each question can be answered exactly once. free_text answers are graded "
|
||||
"by the AI reviewer and fall back to keyword overlap when it is unavailable."
|
||||
),
|
||||
handler="http",
|
||||
requires_auth=True,
|
||||
params=(
|
||||
path("slug", "Quiz slug or uid."),
|
||||
path("attempt_uid", "The attempt uid."),
|
||||
body("question_uid", "The question being answered.", required=True),
|
||||
body("answer_text", "Free text, the numeric value, or true/false."),
|
||||
body("option_uids", "Chosen option uids, comma separated and in order for ordering."),
|
||||
body("blanks", "fill_blank only: one answer per blank, comma separated."),
|
||||
body("matches", "matching only: the chosen right-hand value per option_uid, in order."),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="finish_quiz_attempt",
|
||||
method="POST",
|
||||
path="/quizzes/{slug}/attempts/{attempt_uid}/finish",
|
||||
summary="Finish an attempt and get the final score",
|
||||
handler="http",
|
||||
requires_auth=True,
|
||||
params=(
|
||||
path("slug", "Quiz slug or uid."),
|
||||
path("attempt_uid", "The attempt uid."),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="get_quiz_result",
|
||||
method="GET",
|
||||
path="/quizzes/{slug}/attempts/{attempt_uid}/results",
|
||||
summary="Read the result of a finished attempt",
|
||||
handler="http",
|
||||
requires_auth=True,
|
||||
read_only=True,
|
||||
params=(
|
||||
path("slug", "Quiz slug or uid."),
|
||||
path("attempt_uid", "The attempt uid."),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="quiz_leaderboard",
|
||||
method="GET",
|
||||
path="/quizzes/{slug}/leaderboard",
|
||||
summary="Top completed attempts on one quiz",
|
||||
handler="http",
|
||||
requires_auth=False,
|
||||
read_only=True,
|
||||
params=(
|
||||
path("slug", "Quiz slug or uid."),
|
||||
query("limit", "How many entries to return, up to 100."),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="quiz_scoreboard",
|
||||
method="GET",
|
||||
path="/quizzes/scoreboard",
|
||||
summary="The cross-quiz score per user",
|
||||
description=(
|
||||
"Each member contributes their best completed attempt per quiz, including "
|
||||
"quizzes they wrote themselves."
|
||||
),
|
||||
handler="http",
|
||||
requires_auth=False,
|
||||
read_only=True,
|
||||
params=(query("limit", "How many entries to return, up to 100."),),
|
||||
),
|
||||
)
|
||||
@@ -3,7 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ..spec import Action
|
||||
from ._shared import body, confirm, path, upload
|
||||
from ._shared import body, confirm, path, query, upload
|
||||
|
||||
|
||||
UPLOAD_ACTIONS: tuple[Action, ...] = (
|
||||
@@ -14,6 +14,44 @@ UPLOAD_ACTIONS: tuple[Action, ...] = (
|
||||
summary="Upload a local file and obtain its attachment uid",
|
||||
params=(upload("file", "Local filesystem path of the file to upload."),),
|
||||
),
|
||||
Action(
|
||||
name="list_attachments",
|
||||
method="GET",
|
||||
path="/uploads",
|
||||
summary="List your own uploaded attachments, newest first",
|
||||
description=(
|
||||
"Returns the signed-in user's attachments as {attachments, pagination, total}. Each item "
|
||||
"carries its uid, original_filename, mime_type, url, size, target_type/target_uid/target_url "
|
||||
"(where it is used, if any), and a 'linked' flag. Pass linked=true to list only attachments "
|
||||
"already attached to a post/comment/project/gist/issue, or linked=false for orphaned uploads."
|
||||
),
|
||||
params=(
|
||||
query("page", "1-based page number, 24 per page."),
|
||||
query("linked", "Filter: true = attached only, false = orphaned only."),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="get_attachment",
|
||||
method="GET",
|
||||
path="/uploads/{attachment_uid}",
|
||||
summary="Get the metadata of one of your attachments by uid",
|
||||
params=(path("attachment_uid", "Uid of the attachment."),),
|
||||
),
|
||||
Action(
|
||||
name="rename_attachment",
|
||||
method="PATCH",
|
||||
path="/uploads/{attachment_uid}",
|
||||
summary="Rename an attachment you own (its display filename); the file extension is preserved",
|
||||
description=(
|
||||
"Updates only the display filename of the attachment; the stored file and its extension are "
|
||||
"unchanged (the original extension is always kept, so the file type cannot be altered). "
|
||||
"Administrators may rename any user's attachment."
|
||||
),
|
||||
params=(
|
||||
path("attachment_uid", "Uid of the attachment."),
|
||||
body("filename", "New display filename.", required=True),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="attach_url",
|
||||
method="POST",
|
||||
@@ -39,7 +77,16 @@ UPLOAD_ACTIONS: tuple[Action, ...] = (
|
||||
name="delete_attachment",
|
||||
method="DELETE",
|
||||
path="/uploads/delete/{attachment_uid}",
|
||||
summary="Delete an uploaded attachment (your own, or anyone's when you are an administrator). Soft delete, confirmation required",
|
||||
summary="Delete an uploaded attachment (your own, or anyone's when you are an administrator). Confirmation required",
|
||||
description=(
|
||||
"Removes one attachment by uid. Use list_attachments (or get_attachment) to find the uid of the "
|
||||
"file the user wants gone, then delete it. The attachment leaves the owner's attachment list and "
|
||||
"disappears from every post, comment, project, gist, message, or issue it was attached to, and its "
|
||||
"file stops being served. Only the owner may delete their own; an administrator may delete anyone's "
|
||||
"(otherwise 403); an unknown or already-removed uid returns 404. This is confirmation-gated: the "
|
||||
"first call is refused so you can show the user the exact attachment (filename + where it is used) "
|
||||
"first, and only a repeat call with confirm=true performs it."
|
||||
),
|
||||
params=(path("attachment_uid", "Uid of the attachment."), confirm()),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1,200 +0,0 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from .spec import Action, Param
|
||||
|
||||
|
||||
def arg(
|
||||
name: str, description: str, required: bool = False, kind: str = "string"
|
||||
) -> Param:
|
||||
return Param(
|
||||
name=name,
|
||||
location="body",
|
||||
description=description,
|
||||
required=required,
|
||||
type=kind,
|
||||
)
|
||||
|
||||
|
||||
SLUG = arg(
|
||||
"project_slug",
|
||||
"Project slug or uid that owns the container resources.",
|
||||
required=True,
|
||||
)
|
||||
|
||||
CONTAINER_ACTIONS: tuple[Action, ...] = (
|
||||
Action(
|
||||
name="container_list_instances",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
handler="container",
|
||||
requires_admin=True,
|
||||
read_only=True,
|
||||
summary="List a project's container instances and their status",
|
||||
params=(SLUG,),
|
||||
),
|
||||
Action(
|
||||
name="container_create_instance",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
handler="container",
|
||||
requires_admin=True,
|
||||
summary="Create and start a container instance (runs the shared ppy image with the project files mounted at /app)",
|
||||
params=(
|
||||
SLUG,
|
||||
arg("name", "Instance name.", required=True),
|
||||
arg(
|
||||
"boot_command", "Optional command to run on boot, e.g. 'python app.py'."
|
||||
),
|
||||
arg(
|
||||
"boot_language",
|
||||
"Optional boot source language: 'none', 'python', or 'bash'. When set with boot_script, the script is materialized into /app and run on launch (takes precedence over boot_command).",
|
||||
),
|
||||
arg(
|
||||
"boot_script",
|
||||
"Optional boot source code (the body of the python or bash script) run on launch when boot_language is python or bash.",
|
||||
),
|
||||
arg(
|
||||
"run_as_uid",
|
||||
"Optional DevPlace user uid whose identity and API key are injected (PRAVDA_API_KEY, PRAVDA_USER_UID). Does NOT change the container OS user, which is always pravda (uid 1000).",
|
||||
),
|
||||
arg(
|
||||
"start_on_boot",
|
||||
"Force this instance to running whenever the container service starts ('true' or 'false', default false).",
|
||||
),
|
||||
arg("restart_policy", "never, always, on-failure, or unless-stopped."),
|
||||
arg("env", "Optional env vars as KEY=VALUE lines."),
|
||||
arg(
|
||||
"ports",
|
||||
"Port maps per line or comma separated. Use a bare container port (e.g. '8899') to auto-assign a unique host port above 20000, or 'host:container' to pin one.",
|
||||
),
|
||||
arg("cpu_limit", "Optional CPU limit, e.g. 1 or 1.5."),
|
||||
arg("mem_limit", "Optional memory limit, e.g. 512m or 1g."),
|
||||
arg("autostart", "Start immediately ('true' or 'false', default true)."),
|
||||
arg(
|
||||
"ingress_slug",
|
||||
"Optional public ingress slug; the service is then reachable at /p/<slug>.",
|
||||
),
|
||||
arg(
|
||||
"ingress_port",
|
||||
"Container port to publish at /p/<slug> (must be one of the mapped ports).",
|
||||
kind="integer",
|
||||
),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="container_instance_action",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
handler="container",
|
||||
requires_admin=True,
|
||||
summary="Control an instance: start, stop, restart, pause, resume, delete, or sync",
|
||||
description="sync imports the container /app workspace back into the project files.",
|
||||
params=(
|
||||
SLUG,
|
||||
arg("instance", "Instance name, slug, or uid.", required=True),
|
||||
arg(
|
||||
"action",
|
||||
"start, stop, restart, pause, resume, delete, or sync.",
|
||||
required=True,
|
||||
),
|
||||
arg(
|
||||
"confirm",
|
||||
"Required only for action=delete: set true ONLY after the user has explicitly "
|
||||
"confirmed destroying the instance. Leave unset otherwise; the delete is refused "
|
||||
"until you pass confirm=true.",
|
||||
kind="boolean",
|
||||
),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="container_configure_instance",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
handler="container",
|
||||
requires_admin=True,
|
||||
summary="Update an instance's run-as user, boot language/script/command, restart policy, start-on-boot flag, and resource limits",
|
||||
params=(
|
||||
SLUG,
|
||||
arg("instance", "Instance name, slug, or uid.", required=True),
|
||||
arg(
|
||||
"run_as_uid",
|
||||
"DevPlace user uid whose identity and API key are injected (PRAVDA_API_KEY, PRAVDA_USER_UID); pass empty to clear. Does NOT change the container OS user (always pravda, uid 1000).",
|
||||
),
|
||||
arg("boot_language", "Boot source language: 'none', 'python', or 'bash'."),
|
||||
arg("boot_script", "Boot source code body run on launch."),
|
||||
arg("boot_command", "Fallback boot command used when no boot_script is set."),
|
||||
arg("restart_policy", "never, always, on-failure, or unless-stopped."),
|
||||
arg(
|
||||
"start_on_boot",
|
||||
"Force running on container-service start ('true' or 'false').",
|
||||
),
|
||||
arg("cpu_limit", "CPU limit, e.g. 1 or 1.5."),
|
||||
arg("mem_limit", "Memory limit, e.g. 512m or 1g."),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="container_logs",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
handler="container",
|
||||
requires_admin=True,
|
||||
read_only=True,
|
||||
summary="Read the recent logs of a running instance",
|
||||
params=(
|
||||
SLUG,
|
||||
arg("instance", "Instance name, slug, or uid.", required=True),
|
||||
arg("tail", "Number of log lines (default 200).", kind="integer"),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="container_exec",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
handler="container",
|
||||
requires_admin=True,
|
||||
summary="Run a one-shot command inside a running instance and return its output. The command runs in /app (the project workspace) by default, so never prefix it with 'cd /app'",
|
||||
params=(
|
||||
SLUG,
|
||||
arg("instance", "Instance name, slug, or uid.", required=True),
|
||||
arg(
|
||||
"command",
|
||||
"Command to run, e.g. 'git clone ... && ls'. Runs in /app already; do not prepend 'cd /app'.",
|
||||
required=True,
|
||||
),
|
||||
arg(
|
||||
"confirm",
|
||||
"Required only when the command is destructive (rm, dd, truncate, drop, etc.): set "
|
||||
"true ONLY after the user has explicitly confirmed. Leave unset otherwise; a "
|
||||
"destructive command is refused until you pass confirm=true.",
|
||||
kind="boolean",
|
||||
),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="container_stats",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
handler="container",
|
||||
requires_admin=True,
|
||||
read_only=True,
|
||||
summary="Get aggregated resource and runtime statistics for an instance",
|
||||
params=(SLUG, arg("instance", "Instance name, slug, or uid.", required=True)),
|
||||
),
|
||||
Action(
|
||||
name="container_schedule",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
handler="container",
|
||||
requires_admin=True,
|
||||
summary="Schedule a start or stop of an instance (cron, interval, or one-time)",
|
||||
params=(
|
||||
SLUG,
|
||||
arg("instance", "Instance name, slug, or uid.", required=True),
|
||||
arg("action", "start or stop.", required=True),
|
||||
arg("kind", "once, interval, or cron.", required=True),
|
||||
arg("cron", "Cron expression for kind=cron, e.g. '0 2 * * *'."),
|
||||
arg("run_at", "ISO time for kind=once, e.g. 2026-06-15T02:00:00."),
|
||||
arg("every_seconds", "Interval seconds for kind=interval.", kind="integer"),
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -58,8 +58,19 @@ CONFIRM_REQUIRED = {
|
||||
"notification_reset",
|
||||
"gateway_provider_delete",
|
||||
"gateway_model_delete",
|
||||
"gateway_quota_rule_delete",
|
||||
"email_account_delete",
|
||||
"email_delete_message",
|
||||
"game_prestige",
|
||||
"game_buy_infrastructure",
|
||||
"game_upgrade_defense",
|
||||
"game_downgrade_defense",
|
||||
"game_buy_cosmetic",
|
||||
"game_upgrade_legacy",
|
||||
"game_upgrade_mastery",
|
||||
"publish_quiz",
|
||||
"delete_quiz",
|
||||
"delete_quiz_question",
|
||||
}
|
||||
|
||||
CONDITIONAL_CONFIRM = {
|
||||
@@ -313,6 +324,11 @@ class Dispatcher:
|
||||
self._interaction = interaction
|
||||
self._read_files: set[tuple[str, str]] = set()
|
||||
|
||||
def set_admin(self, is_admin: bool, is_primary_admin: bool) -> None:
|
||||
self._is_admin = is_admin
|
||||
self._is_primary_admin = is_primary_admin
|
||||
self._docs.set_admin(is_admin)
|
||||
|
||||
@staticmethod
|
||||
def _file_key(arguments: dict[str, Any]) -> tuple[str, str] | None:
|
||||
from devplacepy.project_files import (
|
||||
|
||||
@@ -18,7 +18,7 @@ def arg(
|
||||
|
||||
|
||||
TYPES = (
|
||||
"One of: comment, reply, mention, vote, follow, message, badge, level, issue, award."
|
||||
"One of: comment, reply, mention, vote, follow, message, badge, level, issue, award, quiz_attempt."
|
||||
)
|
||||
|
||||
NOTIFICATION_ACTIONS: tuple[Action, ...] = (
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user