Compare commits
51 Commits
typosaurus
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 0ec3e61118 | |||
| a78b656ef9 | |||
| 7674dac628 | |||
| 53ddf4f233 | |||
| 5079f40f46 | |||
|
|
d895de1b47 | ||
| 571a0485c5 | |||
| b185574760 | |||
| 3709f4fab9 | |||
| 1a87c392bd | |||
| ff49c8342a | |||
| 76d73ccaea | |||
| 8d5d5f90be | |||
| 46f87a48e3 | |||
| e05f97c924 | |||
| 2d72e0785d | |||
| dbe1e2670b | |||
| fda72c5afb | |||
| 6b3df26a52 | |||
| b1a104ebb1 | |||
| b5fb6436d0 | |||
| 3006a1b039 | |||
| 8b89f0adcf | |||
| 9cfaddfc40 | |||
| ca6c527e32 | |||
| 535e9c5dc1 | |||
| 3467f55df9 | |||
| a3963611f0 | |||
| b8277d6351 | |||
| f996336afb | |||
| 4780016980 | |||
| 7f17d69f5c | |||
| 5774d83ece | |||
| ac04cf6817 | |||
| 2620ecc0f1 | |||
|
|
1f320b45ec | ||
|
|
a8ed5b690f | ||
|
|
1eeb54598f | ||
|
|
a0d573375a | ||
| ad1736ebf1 | |||
| ef1c914e23 | |||
| b534a496fd | |||
| 582e37d176 | |||
| 64c3983c9f | |||
| 34fa56a836 | |||
| 77f043640e | |||
| 34f76aad65 | |||
|
|
024edb5291 | ||
|
|
4ffddc8913 | ||
|
|
35e79ba8c7 | ||
|
|
32314fc6d6 |
@ -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.
|
||||
|
||||
20
.claude/settings.local.json
Normal file
20
.claude/settings.local.json
Normal file
@ -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)"
|
||||
]
|
||||
}
|
||||
}
|
||||
12
.editorconfig
Normal file
12
.editorconfig
Normal file
@ -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
|
||||
53
CLAUDE.md
53
CLAUDE.md
@ -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,18 @@ devplace attachments prune # remove orphan attachment records/files
|
||||
devplace devii reset-quota <username> # reset one user's rolling 24h AI quota
|
||||
devplace devii reset-quota --guests # reset every guest quota
|
||||
devplace devii reset-quota --all # reset every quota (users and guests)
|
||||
devplace devii tasks list [--all] # list scheduled Devii tasks and their owners
|
||||
devplace devii tasks disable <uid> # disable one scheduled task
|
||||
devplace devii tasks prune # disable every task whose owner may not schedule
|
||||
devplace gateway quota list # list AI gateway quota rules and current 24h spend
|
||||
devplace gateway quota set --limit-usd N [--owner-kind K] [--owner-id ID] [--app-reference APP] [--label L] [--uid UID]
|
||||
devplace gateway quota delete <uid> # delete a quota rule
|
||||
devplace gateway quota reset [--owner-kind K] [--owner-id ID] [--app-reference APP] # clear the counted 24h spend (keeps the usage history)
|
||||
devplace zips prune # delete expired zip archives + job rows
|
||||
devplace zips clear # delete every zip archive + job row
|
||||
devplace forks prune # delete expired completed fork job rows (forked projects persist)
|
||||
devplace forks clear # delete every fork job row (forked projects persist)
|
||||
devplace messaging prune-tickets # delete expired WebSocket auth tickets (ws_tickets)
|
||||
devplace seo prune # delete expired SEO audit reports + job rows
|
||||
devplace seo clear # delete every SEO audit report + job row
|
||||
devplace seo-meta prune # delete expired SEO metadata job rows (generated metadata persists)
|
||||
@ -66,6 +80,12 @@ devplace deepsearch clear # delete every DeepSearch session + job row + collec
|
||||
devplace isslop analyze <url> # run a AI usage analysis from the terminal (report persists)
|
||||
devplace isslop prune # delete expired AI usage analysis job rows (analyses + reports persist)
|
||||
devplace isslop clear # delete every AI usage analysis, its report and job rows
|
||||
devplace quiz prune # delete abandoned/expired quiz attempts older than the retention window
|
||||
devplace game market prune # delete Code Farm market tick buckets older than the tracking window
|
||||
devplace game steals prune # delete Code Farm raid records older than the raid-efficiency window
|
||||
devplace game era status # show the current Code Farm Era
|
||||
devplace game era start <name> [--days N] # start a Code Farm Era (default 28 days)
|
||||
devplace game era end # end the running Code Farm Era (ranks, awards Stars, records results)
|
||||
devplace backups list # list recorded backups
|
||||
devplace backups run <database|uploads|keys|full> # enqueue a backup (processed by the running server)
|
||||
devplace backups prune # remove backup records whose archive file is missing
|
||||
@ -128,11 +148,14 @@ 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/push/CLAUDE.md` | Push notification providers: the `PushProvider` protocol, the registry, Web Push and APNs, registration storage |
|
||||
| `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 +194,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` |
|
||||
@ -226,7 +250,7 @@ Users and guests inject their own CSS and JS, scoped to a page type or globally,
|
||||
|
||||
### Container manager, Devii assistant, AI gateway, async jobs, audit log
|
||||
|
||||
DevPlace ships a Docker-backed **Container manager** (admin-only, one shared `ppy:latest` image, security-hardened rootless workflow via a `sudo` superclone and `aptroot`), the **Devii** in-platform agentic assistant (WebSocket terminal, per-owner-channel sessions, scheduler/reminders, self-learning lessons, user-defined virtual tools, self-configured behavior, browser-automation client tools), the **AI gateway** (`/openai/v1/*`, single point of truth for every AI call, per-user cost attribution, provider/model routing overlay), **async job services** (`JobService` base pattern: zip, fork, SEO diagnostics, SEO metadata generation, DeepSearch, AI Usage Analyzer), and an admin-only append-only **audit log** (`record`/`record_system` entrypoints, `events.md` catalogue of 223 keys, never raises into the caller). These are among the largest subsystems in the codebase - read their dedicated nested `CLAUDE.md` files (see Subsystem map) before working in any of them; do not assume prior knowledge from this summary.
|
||||
DevPlace ships a Docker-backed **Container manager** (admin-only, one shared `ppy:latest` image, security-hardened rootless workflow via a `sudo` superclone and `aptroot`), the **Devii** in-platform agentic assistant (WebSocket terminal, per-owner-channel sessions, scheduler/reminders, self-learning lessons, user-defined virtual tools, self-configured behavior, browser-automation client tools), the **AI gateway** (`/openai/v1/*`, single point of truth for every AI call, per-user cost attribution, provider/model routing overlay), **async job services** (`JobService` base pattern: zip, fork, SEO diagnostics, SEO metadata generation, DeepSearch, AI Usage Analyzer), and an admin-only append-only **audit log** (`record`/`record_system` entrypoints, `events.md` catalogue of 288 keys, never raises into the caller). These are among the largest subsystems in the codebase - read their dedicated nested `CLAUDE.md` files (see Subsystem map) before working in any of them; do not assume prior knowledge from this summary.
|
||||
|
||||
### Telegram bot, email, devRant compatibility API, issue tracker
|
||||
|
||||
@ -275,13 +299,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 +335,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.
|
||||
|
||||
@ -313,5 +348,7 @@ Gitea Actions workflow at `.gitea/workflows/test.yaml` runs on every push/PR to
|
||||
Single-host Docker Compose (`docker-compose.yml`): an **app** container (Uvicorn, 2 workers) behind an **nginx** container. Full operator reference is the admin-only `Production` docs section (`templates/docs/production*.html`); conventions that must not regress:
|
||||
|
||||
- **Shared DB and files = same as dev.** The app container bind-mounts the host project root (`.:/app`) and runs as `${DEVPLACE_UID}:${DEVPLACE_GID}` (default `1000`), so it reads/writes the same `data/devplace.db`, `data/uploads/`, `data/devii_*.db`, `data/keys/` (VAPID), and `data/locks/devplace-services.lock` as `make dev`. No `DEVPLACE_DATABASE_URL` override - `config.py` resolves an absolute path under the project's `data/` dir. WAL + the `flock` on `devplace-services.lock` make concurrent dev/prod safe and keep a single background-services owner. SQLite is local-file, so prod and dev must be the **same host**.
|
||||
- **Code updates need no rebuild** (bind-mounted source); rebuild only on `pyproject.toml` dependency changes. `.env` is git-ignored; `.env.example` is the committed template. The app reads `DEVPLACE_DATABASE_URL`, never `DATABASE_URL`.
|
||||
- **Code updates need no rebuild** (bind-mounted source); rebuild only on `pyproject.toml` dependency changes. Use **`make docker-reload`** (`restart app` + `up -d --wait`) to pick up new source - a bare `make docker-up` does **not** restart an unchanged container, so the running uvicorn keeps serving the code it imported at boot. `.env` is git-ignored; `.env.example` is the committed template. The app reads `DEVPLACE_DATABASE_URL`, never `DATABASE_URL`.
|
||||
- **Dockerfile layer order is load-bearing for build time.** The dependency layer (`pip install ".[bots]"` + `playwright install --with-deps chromium`, ~3GB and ~2.5 min) must depend on `pyproject.toml` **only**. `COPY devplacepy/` therefore comes *after* it, and the project itself is installed last with `pip install --no-deps --force-reinstall .`. hatchling needs the package directory to exist to build a wheel, so the dependency layer creates a placeholder `devplacepy/__init__.py` that the real `COPY` overwrites (verified: site-packages holds the full 39-entry package, not the stub). Copying source before the install inverts this and makes **every source edit** reinstall every dependency and re-download Chromium - measured 2m36s per source-only rebuild versus 7.4s with the correct order. Never move `COPY devplacepy/` above the dependency layer.
|
||||
- **nginx parity rules** (`nginx/nginx.conf.template`, rendered by `start.sh` via `envsubst` with an allow-list that preserves `$http_upgrade`): `/static/uploads/` must re-apply `nosniff` + a `Content-Disposition` via the `map $uri $upload_disposition` block (`inline` for safe image/video/audio extensions, `attachment` otherwise), mirroring `UploadStaticFiles.INLINE_MEDIA_EXTENSIONS` - an XSS control nginx would otherwise bypass, and the inline branch is what lets video play in production; `/devii/ws` needs the `map $http_upgrade $connection_upgrade` block and `Upgrade`/`Connection` headers or the Devii terminal cannot connect (every new WebSocket route needs its own nginx upgrade location - the catch-all `location /` strips upgrade headers); `client_max_body_size` comes from `NGINX_MAX_BODY_SIZE` (default `50m`) and must be `>= max_upload_size_mb` or uploads 413. nginx serves `devplacepy/static` via a read-only bind mount, so assets stay current without an image rebuild.
|
||||
- **Healthcheck cadence** (`docker-compose.yml` + `Dockerfile`, keep both in step): `start_period: 120s` is the grace window in which a failing probe does not count against `retries`; `start_interval: 2s` is how often the probe runs *inside* that window. Without `start_interval` the first probe only fires after the full `interval: 30s`, so a container ready in 5s still reports healthy at 30s and `depends_on: service_healthy` holds nginx back for no reason. The generous 120s start period is deliberate headroom for a cold page cache on a multi-GB database, not a measure of normal startup - normal startup is a few seconds. **Startup work is a per-worker, lock-serialized cost:** `lifespan` runs `init_db()` under an exclusive `init_lock()`, so every uvicorn worker pays it end to end, one after another, and total time-to-serving is `workers x init_db`. Never put a per-user or per-row scan in `init_db` - see the backfill convergence rule in `devplacepy/database/CLAUDE.md`.
|
||||
|
||||
16
Dockerfile
16
Dockerfile
@ -4,6 +4,8 @@ WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl ca-certificates \
|
||||
libglib2.0-0 libpango-1.0-0 libpangoft2-1.0-0 libharfbuzz0b libfontconfig1 \
|
||||
fonts-dejavu-core \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Optional: the docker CLI so the (admin-only) container manager can drive the host
|
||||
@ -18,22 +20,24 @@ RUN if [ "$INSTALL_DOCKER_CLI" = "true" ]; then \
|
||||
rm -rf /var/lib/apt/lists/* ; \
|
||||
fi
|
||||
|
||||
ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
|
||||
|
||||
COPY pyproject.toml .
|
||||
RUN mkdir -p devplacepy && touch devplacepy/__init__.py \
|
||||
&& pip install --no-cache-dir ".[bots]" \
|
||||
&& python -m playwright install --with-deps chromium \
|
||||
&& chmod -R a+rx /ms-playwright
|
||||
|
||||
COPY devplacepy/ devplacepy/
|
||||
|
||||
ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
|
||||
|
||||
RUN pip install --no-cache-dir ".[bots]" \
|
||||
&& python -m playwright install --with-deps chromium \
|
||||
&& chmod -R a+rx /ms-playwright
|
||||
RUN pip install --no-cache-dir --no-deps --force-reinstall .
|
||||
|
||||
EXPOSE 10500
|
||||
|
||||
ENV DEVPLACE_WEB_WORKERS=2
|
||||
ENV DEVPLACE_TEMPLATE_AUTO_RELOAD=0
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=10s \
|
||||
HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=120s --start-interval=2s \
|
||||
CMD curl -f http://localhost:10500/ || exit 1
|
||||
|
||||
CMD ["sh", "-c", "DEVPLACE_STATIC_VERSION=${DEVPLACE_STATIC_VERSION:-$(date +%s)} exec uvicorn devplacepy.main:app --host 0.0.0.0 --port 10500 --workers 2 --backlog 8192 --proxy-headers --forwarded-allow-ips '*'"]
|
||||
|
||||
34
Makefile
34
Makefile
@ -12,7 +12,7 @@ DEVPLACE_RATE_LIMIT ?= 1000000
|
||||
PYTHONDONTWRITEBYTECODE := 1
|
||||
export PYTHONDONTWRITEBYTECODE
|
||||
|
||||
.PHONY: install dev clean tree tree-loc zip test test-headed coverage coverage-headed coverage-html locust locust-headless
|
||||
.PHONY: install dev clean tree tree-loc zip test test-headed test-unit test-api test-e2e test-fast test-failed test-first-failure test-slowest test-cache-clean coverage coverage-headed coverage-html locust locust-headless
|
||||
|
||||
install:
|
||||
pip install -e .
|
||||
@ -43,19 +43,31 @@ zip:
|
||||
@printf 'Wrote %s (%s files)\n' "$(notdir $(CURDIR)).zip" "$$(git ls-files | wc -l)"
|
||||
|
||||
test:
|
||||
PLAYWRIGHT_HEADLESS=1 python -m pytest tests/ -x
|
||||
PLAYWRIGHT_HEADLESS=1 python -m pytest tests/
|
||||
|
||||
test-headed:
|
||||
PLAYWRIGHT_HEADLESS=0 python -m pytest tests/ -x
|
||||
PLAYWRIGHT_HEADLESS=0 python -m pytest tests/
|
||||
|
||||
test-unit:
|
||||
python -m pytest tests/unit -x
|
||||
python -m pytest tests/unit
|
||||
|
||||
test-api:
|
||||
python -m pytest tests/api -x
|
||||
python -m pytest tests/api
|
||||
|
||||
test-e2e:
|
||||
PLAYWRIGHT_HEADLESS=1 python -m pytest tests/e2e -x
|
||||
PLAYWRIGHT_HEADLESS=1 python -m pytest tests/e2e
|
||||
|
||||
test-fast:
|
||||
python -m pytest tests/unit tests/api
|
||||
|
||||
test-failed:
|
||||
PLAYWRIGHT_HEADLESS=1 python -m pytest tests/ --last-failed --last-failed-no-failures none
|
||||
|
||||
test-first-failure:
|
||||
PLAYWRIGHT_HEADLESS=1 python -m pytest tests/ -x
|
||||
|
||||
test-slowest:
|
||||
PLAYWRIGHT_HEADLESS=1 python -m pytest tests/ --durations=40
|
||||
|
||||
coverage:
|
||||
rm -f .coverage .coverage.*
|
||||
@ -109,8 +121,12 @@ clean:
|
||||
find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
|
||||
find . -type f -name '*.pyc' -delete
|
||||
rm -rf devplacepy.egg-info
|
||||
rm -rf .pytest_cache
|
||||
rm -rf .venv
|
||||
|
||||
test-cache-clean:
|
||||
rm -rf .pytest_cache
|
||||
|
||||
# Container Manager works out of the box: the overlay installs the docker CLI in
|
||||
# the image and mounts the host socket. DOCKER_GID is read straight from the
|
||||
# socket so the UID-1000 app can use it; the data dir is the project's own data/
|
||||
@ -122,7 +138,7 @@ DOCKER_GID ?= $(shell stat -c '%g' /var/run/docker.sock 2>/dev/null)
|
||||
export DEVPLACE_DATA_DIR
|
||||
export DOCKER_GID
|
||||
|
||||
.PHONY: docker-build docker-up docker-down docker-logs docker-clean docker-prep ppy
|
||||
.PHONY: docker-build docker-up docker-reload docker-down docker-logs docker-clean docker-prep ppy
|
||||
|
||||
# Build the single shared container image every instance runs. Build once;
|
||||
# rebuild only when ppy.Dockerfile, the sudo shim, or pagent change.
|
||||
@ -138,6 +154,10 @@ docker-build: docker-prep
|
||||
docker-up: docker-prep
|
||||
$(COMPOSE) up -d
|
||||
|
||||
docker-reload:
|
||||
$(COMPOSE) restart app
|
||||
$(COMPOSE) up -d --wait
|
||||
|
||||
docker-down:
|
||||
$(COMPOSE) down
|
||||
|
||||
|
||||
171
README.md
171
README.md
@ -15,6 +15,12 @@ make test-headed # same tests in visible browser
|
||||
|
||||
Open `http://localhost:10500`.
|
||||
|
||||
PDF export (DeepSearch reports, via weasyprint) needs the Pango text stack installed at system level. The production image installs it; on a development host install it once:
|
||||
|
||||
```bash
|
||||
sudo apt-get install -y libglib2.0-0 libpango-1.0-0 libpangoft2-1.0-0 libharfbuzz0b libfontconfig1 fonts-dejavu-core
|
||||
```
|
||||
|
||||
## Stack
|
||||
|
||||
| Layer | Technology |
|
||||
@ -40,7 +46,7 @@ devplacepy/
|
||||
avatar.py # Multiavatar generation, URL builder
|
||||
utils/ # Password hashing, session mgmt, time_ago, notification hook (package)
|
||||
models.py # Pydantic schemas
|
||||
push.py # Web push crypto, VAPID keys, encrypt/send/register
|
||||
push/ # Push delivery: provider protocol, Web Push, APNs, registrations
|
||||
routers/ # One file per domain (auth, feed, posts, push, ...)
|
||||
templates/ # Jinja2 HTML templates
|
||||
static/css/ # Page-specific CSS files
|
||||
@ -69,10 +75,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 +89,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 +121,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 +174,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`.
|
||||
@ -493,6 +549,17 @@ disclosed only to administrators, while members and guests can see only the perc
|
||||
24-hour quota used. The `/devii/usage` endpoint returns that percentage and the day's turn count to
|
||||
everyone, and includes dollar figures only for administrators.
|
||||
|
||||
**Spend limits (Gateway page, `/admin/gateway`).** An administrator caps the rolling 24-hour
|
||||
gateway spend with quota rules scoped by any combination of caller role, individual user, and
|
||||
application reference (the `X-App-Reference` header), so a single application belonging to one
|
||||
user can be limited independently of that user's other traffic. The most specific matching rule
|
||||
wins; a caller over its cap gets `429`. Because a cap otherwise only lifts with the passage of
|
||||
time, each rule has a **Reset spend** action that clears what has been counted against it
|
||||
without deleting anything from the usage history the cost analytics are built on - the
|
||||
figures on the AI usage page stay intact, only the amount counted towards the limit is cleared.
|
||||
The **Reset all quotas** button on the AI usage page clears the assistant quotas and the gateway
|
||||
spend together. From the terminal: `devplace gateway quota list|set|delete|reset`.
|
||||
|
||||
Configuration on the Services tab:
|
||||
|
||||
| Parameter | Default | Purpose |
|
||||
@ -582,7 +649,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 +697,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
|
||||
@ -717,9 +817,37 @@ calling itself.
|
||||
|
||||
## Push notifications & PWA
|
||||
|
||||
Authenticated users can receive native web push notifications, and the site is an
|
||||
Authenticated users can receive native push notifications, and the site is an
|
||||
installable Progressive Web App. Push uses only standard libraries (`cryptography`,
|
||||
`PyJWT`, `httpx`) against the Web Push Protocol - no third-party push wrapper.
|
||||
`PyJWT`, `httpx`) against the Web Push Protocol and the Apple Push Notification service -
|
||||
no third-party push wrapper.
|
||||
|
||||
### Providers
|
||||
|
||||
Delivery is split into providers behind one protocol (`devplacepy/push/providers/`). A user
|
||||
receives a notification through every provider they hold a live subscription for.
|
||||
|
||||
| Provider | Registration | Transport |
|
||||
|----------|--------------|-----------|
|
||||
| `webpush` | `PushSubscription` from the browser `PushManager` (endpoint + `p256dh`/`auth` keys) | Web Push Protocol, VAPID signed, `aesgcm` encrypted payload |
|
||||
| `apns` | Hexadecimal device token | `POST https://api.push.apple.com/3/device/{token}` over HTTP/2, ES256 provider token |
|
||||
|
||||
`POST /push.json` accepts a registration for any active provider; a body without a
|
||||
`provider` field is a `webpush` body, so browsers need no change. `GET /push.json` returns
|
||||
the VAPID public key plus the providers currently accepting registrations. A provider that
|
||||
is disabled or not fully configured accepts no registrations and is skipped during
|
||||
delivery, so an unconfigured provider is inert rather than an error.
|
||||
|
||||
Every provider setting is edited at **`/admin/services/push`**: per provider an `Enabled`
|
||||
toggle, the VAPID subject for `webpush`, and team id, key id, `.p8` auth key (stored as a
|
||||
masked secret), topic and environment (production or sandbox) for `apns`. The same page
|
||||
holds the shared delivery timeout and the retention window after which dead subscriptions
|
||||
are removed. Push delivery does not depend on that service running; stopping it only stops
|
||||
the pruning sweep.
|
||||
|
||||
Adding a third provider is one file plus one registry entry: the registration route, the
|
||||
delivery loop, the admin page, the audit record and the metrics are all written against the
|
||||
provider protocol.
|
||||
|
||||
### Events
|
||||
|
||||
@ -742,9 +870,11 @@ in real time, bridged onto the in-process pub/sub bus by a lock-owner relay:
|
||||
|
||||
`create_notification` schedules delivery as a fire-and-forget async task, so a dead
|
||||
subscription or push-service error never blocks the triggering request. Delivery
|
||||
(`push.notify_user`) iterates a user's subscriptions, encrypts the payload
|
||||
(legacy `aesgcm` content encoding), and POSTs to each endpoint; subscriptions that
|
||||
return `404`/`410` are soft-deleted.
|
||||
(`push.notify_user`) reads a user's subscriptions once, groups them by provider, builds
|
||||
each provider's payload once, and sends over a single shared HTTP client. A subscription
|
||||
the push service reports as gone (`404`/`410` for Web Push, `410` or an `Unregistered`
|
||||
class reason for APNs) is soft-deleted; any other failure is logged and the subscription is
|
||||
kept.
|
||||
|
||||
A notification is also **marked read automatically when you open the page that shows its
|
||||
content** - viewing a post clears its comment, reply, upvote and mention notifications;
|
||||
@ -801,7 +931,10 @@ offline. Installation requires a secure origin (HTTPS, or `localhost` for develo
|
||||
|
||||
| File | Role |
|
||||
|------|------|
|
||||
| `devplacepy/push.py` | VAPID keys, payload encryption, send, register |
|
||||
| `devplacepy/push/providers/` | Provider protocol, Web Push (VAPID keys, payload encryption), APNs |
|
||||
| `devplacepy/push/store.py` | `push_registration` reads and writes |
|
||||
| `devplacepy/push/delivery.py` | `notify_user` - group by provider, deliver, reap dead subscriptions |
|
||||
| `devplacepy/services/push/service.py` | Provider configuration at `/admin/services/push`, retention sweep, metrics |
|
||||
| `devplacepy/routers/push.py` | `/push.json`, `/service-worker.js`, `/manifest.json` |
|
||||
| `static/js/PushManager.js` | Service-worker registration + subscribe + opt-in UI |
|
||||
| `static/service-worker.js` | Receives push, shows notification, offline fallback |
|
||||
@ -901,11 +1034,13 @@ Open `http://<host>:${PORT}` (default 10500). `make docker-logs` tails output; `
|
||||
|
||||
```bash
|
||||
git pull
|
||||
make docker-up # restart with new code (bind-mounted, no rebuild)
|
||||
make docker-reload # restart workers on the new code (bind-mounted, no rebuild)
|
||||
make docker-build && \
|
||||
make docker-up # only when dependencies in pyproject.toml change
|
||||
```
|
||||
|
||||
`make docker-reload` is the target for a source-only change: `docker compose up -d` leaves an unchanged container running, so the workers would keep serving the code they imported at boot. A rebuild after a source-only change costs about 7 seconds because the Dockerfile installs dependencies from `pyproject.toml` in a layer that no source edit invalidates.
|
||||
|
||||
The `make deploy` target fast-forwards the `production` branch (`git checkout production && git merge master && git push origin production`); pull that branch on the server.
|
||||
|
||||
### Container Manager wiring (what the overlay does)
|
||||
|
||||
@ -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"):
|
||||
|
||||
@ -12,7 +12,7 @@ def enforce_rgba_png(file_bytes: bytes) -> bytes:
|
||||
corner = img.getpixel((0, 0))
|
||||
if len(corner) == 4 and corner[3] == 255:
|
||||
bg = corner[:3]
|
||||
data = img.getdata()
|
||||
data = img.get_flattened_data()
|
||||
cleaned = []
|
||||
for pixel in data:
|
||||
if pixel[:3] == bg:
|
||||
|
||||
@ -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)
|
||||
|
||||
103
devplacepy/cli/game.py
Normal file
103
devplacepy/cli/game.py
Normal file
@ -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)
|
||||
145
devplacepy/cli/gateway.py
Normal file
145
devplacepy/cli/gateway.py
Normal file
@ -0,0 +1,145 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import sys
|
||||
|
||||
from devplacepy.cli._shared import _audit_cli
|
||||
|
||||
|
||||
def cmd_gateway_quota_list(args):
|
||||
from devplacepy.services.openai_gateway import quota
|
||||
|
||||
rules = quota.quota_rule_store.list()
|
||||
if not rules:
|
||||
print("No quota rules. Every caller is capped by the global defaults on /admin/services/openai.")
|
||||
return
|
||||
for rule in rules:
|
||||
spent = quota.spent_24h(rule["owner_kind"], rule["owner_id"], rule["app_reference"])
|
||||
scope = ", ".join(
|
||||
f"{key}={rule[key]}" for key in ("owner_kind", "owner_id", "app_reference") if rule[key]
|
||||
) or "(no dimensions - invalid)"
|
||||
limit = "unlimited" if rule["limit_usd"] == 0 else f"${rule['limit_usd']:.2f}/24h"
|
||||
active = "active" if rule["is_active"] else "inactive"
|
||||
label = f" - {rule['label']}" if rule["label"] else ""
|
||||
print(f"{rule['uid']} [{scope}] {limit} spent=${spent:.4f} {active}{label}")
|
||||
|
||||
|
||||
def cmd_gateway_quota_set(args):
|
||||
from pydantic import ValidationError
|
||||
from devplacepy.services.openai_gateway import quota
|
||||
|
||||
try:
|
||||
payload = quota.QuotaRuleIn(
|
||||
owner_kind=args.owner_kind,
|
||||
owner_id=args.owner_id,
|
||||
app_reference=args.app_reference,
|
||||
limit_usd=args.limit_usd,
|
||||
is_active=not args.inactive,
|
||||
label=args.label or "",
|
||||
)
|
||||
except ValidationError as exc:
|
||||
print(f"Invalid rule: {exc.errors()[0].get('msg', exc)}")
|
||||
sys.exit(1)
|
||||
saved = quota.quota_rule_store.set(payload, uid=args.uid, created_by="cli")
|
||||
_audit_cli(
|
||||
"gateway.quota_rule.update",
|
||||
f"CLI saved gateway quota rule {saved['uid']}",
|
||||
metadata={
|
||||
"owner_kind": saved["owner_kind"],
|
||||
"owner_id": saved["owner_id"],
|
||||
"app_reference": saved["app_reference"],
|
||||
"limit_usd": saved["limit_usd"],
|
||||
},
|
||||
target_type="gateway_quota_rule",
|
||||
target_uid=saved["uid"],
|
||||
)
|
||||
print(f"Saved quota rule {saved['uid']}")
|
||||
|
||||
|
||||
def cmd_gateway_quota_delete(args):
|
||||
from devplacepy.services.openai_gateway import quota
|
||||
|
||||
if not quota.quota_rule_store.remove(args.uid):
|
||||
print(f"Quota rule '{args.uid}' not found")
|
||||
sys.exit(1)
|
||||
_audit_cli(
|
||||
"gateway.quota_rule.delete",
|
||||
f"CLI deleted gateway quota rule {args.uid}",
|
||||
target_type="gateway_quota_rule",
|
||||
target_uid=args.uid,
|
||||
)
|
||||
print(f"Deleted quota rule {args.uid}")
|
||||
|
||||
|
||||
def cmd_gateway_quota_reset(args):
|
||||
from devplacepy.services.openai_gateway import quota
|
||||
|
||||
try:
|
||||
payload = quota.QuotaResetIn(
|
||||
owner_kind=args.owner_kind,
|
||||
owner_id=args.owner_id,
|
||||
app_reference=args.app_reference,
|
||||
)
|
||||
except Exception as exc:
|
||||
print(f"Error: {exc}")
|
||||
sys.exit(1)
|
||||
scope = quota.reset(payload, created_by="cli")
|
||||
label = quota.scope_label(scope, fallback="every caller")
|
||||
_audit_cli(
|
||||
"gateway.quota.reset",
|
||||
f"CLI reset the gateway 24h spend for {label}",
|
||||
target_type="gateway_quota",
|
||||
target_uid=scope["uid"],
|
||||
metadata={
|
||||
"owner_kind": scope["owner_kind"],
|
||||
"owner_id": scope["owner_id"],
|
||||
"app_reference": scope["app_reference"],
|
||||
"reset_at": scope["reset_at"],
|
||||
},
|
||||
)
|
||||
print(f"Reset the rolling 24h spend for {label}")
|
||||
|
||||
|
||||
def register_gateway(subparsers):
|
||||
gateway = subparsers.add_parser("gateway", help="AI gateway management")
|
||||
gateway_sub = gateway.add_subparsers(title="action", dest="action")
|
||||
|
||||
quota = gateway_sub.add_parser("quota", help="Manage rolling-24h AI gateway quota rules")
|
||||
quota_sub = quota.add_subparsers(title="sub-action", dest="sub_action")
|
||||
|
||||
quota_list = quota_sub.add_parser("list", help="List all quota rules and their current 24h spend")
|
||||
quota_list.set_defaults(func=cmd_gateway_quota_list)
|
||||
|
||||
quota_set = quota_sub.add_parser(
|
||||
"set", help="Create or update a quota rule (scope by role/user/app, any combination)"
|
||||
)
|
||||
quota_set.add_argument("--uid", help="Existing rule uid to update; omit to create a new rule")
|
||||
quota_set.add_argument(
|
||||
"--owner-kind",
|
||||
choices=("internal", "key", "user", "admin", "anonymous"),
|
||||
help="Role to scope by. Omit for any role",
|
||||
)
|
||||
quota_set.add_argument("--owner-id", help="Specific user uid to scope by. Omit for any caller")
|
||||
quota_set.add_argument("--app-reference", help="App label to scope by. Omit for any app")
|
||||
quota_set.add_argument(
|
||||
"--limit-usd", type=float, required=True, help="Rolling 24h USD cap (0 = unlimited)"
|
||||
)
|
||||
quota_set.add_argument("--label", help="Optional admin-facing note")
|
||||
quota_set.add_argument("--inactive", action="store_true", help="Create the rule disabled")
|
||||
quota_set.set_defaults(func=cmd_gateway_quota_set)
|
||||
|
||||
quota_delete = quota_sub.add_parser("delete", help="Delete a quota rule by uid")
|
||||
quota_delete.add_argument("uid", help="Quota rule uid")
|
||||
quota_delete.set_defaults(func=cmd_gateway_quota_delete)
|
||||
|
||||
quota_reset = quota_sub.add_parser(
|
||||
"reset",
|
||||
help="Clear the rolling-24h spend so a capped caller can call again (keeps the usage history)",
|
||||
)
|
||||
quota_reset.add_argument(
|
||||
"--owner-kind",
|
||||
choices=("internal", "key", "user", "admin", "anonymous"),
|
||||
help="Role to scope by. Omit to reset every role",
|
||||
)
|
||||
quota_reset.add_argument("--owner-id", help="Specific user uid to scope by. Omit for every caller")
|
||||
quota_reset.add_argument("--app-reference", help="App label to scope by. Omit for every app")
|
||||
quota_reset.set_defaults(func=cmd_gateway_quota_reset)
|
||||
@ -7,12 +7,12 @@ from devplacepy.cli._shared import _audit_cli
|
||||
def _remove_zip_artifacts(job):
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from devplacepy.services.jobs.zip_service import STAGING_DIR
|
||||
from devplacepy.config import ZIP_STAGING_DIR
|
||||
|
||||
local_path = (job.get("result") or {}).get("local_path")
|
||||
if local_path:
|
||||
Path(local_path).unlink(missing_ok=True)
|
||||
shutil.rmtree(STAGING_DIR / job["uid"], ignore_errors=True)
|
||||
shutil.rmtree(ZIP_STAGING_DIR / job["uid"], ignore_errors=True)
|
||||
|
||||
|
||||
def cmd_zips_prune(args):
|
||||
@ -251,7 +251,6 @@ def cmd_isslop_clear(args):
|
||||
|
||||
def cmd_isslop_analyze(args):
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
from devplacepy.config import ISSLOP_WORKSPACES_DIR, ensure_data_dirs
|
||||
from devplacepy.database import INTERNAL_GATEWAY_URL, internal_gateway_key
|
||||
|
||||
@ -12,6 +12,10 @@ from devplacepy.cli.jobs import register_jobs
|
||||
from devplacepy.cli.backups import register_backups
|
||||
from devplacepy.cli.containers import register_containers
|
||||
from devplacepy.cli.migrate import register_migrate
|
||||
from devplacepy.cli.game import register_game
|
||||
from devplacepy.cli.quiz import register_quiz
|
||||
from devplacepy.cli.gateway import register_gateway
|
||||
from devplacepy.cli.messaging import register_messaging
|
||||
|
||||
|
||||
def build_parser():
|
||||
@ -28,6 +32,10 @@ def build_parser():
|
||||
register_backups(sub)
|
||||
register_containers(sub)
|
||||
register_migrate(sub)
|
||||
register_game(sub)
|
||||
register_quiz(sub)
|
||||
register_gateway(sub)
|
||||
register_messaging(sub)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
29
devplacepy/cli/messaging.py
Normal file
29
devplacepy/cli/messaging.py
Normal file
@ -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)
|
||||
31
devplacepy/cli/quiz.py
Normal file
31
devplacepy/cli/quiz.py
Normal file
@ -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`"
|
||||
|
||||
@ -13,6 +13,8 @@ from devplacepy.database import (
|
||||
resolve_by_slug,
|
||||
get_users_by_uids,
|
||||
get_vote_counts,
|
||||
get_comment_counts_by_post_uids,
|
||||
paginate,
|
||||
STAR_TARGETS,
|
||||
get_user_votes,
|
||||
get_reactions_by_targets,
|
||||
@ -52,8 +54,8 @@ from devplacepy.services.seo_meta import schedule_seo_meta_for_table
|
||||
|
||||
CREATE_METADATA_KEYS = ("project_type", "is_private", "language", "topic", "status")
|
||||
|
||||
BOOKMARKABLE_TYPES = {"post", "gist", "project", "news"}
|
||||
REACTABLE_TYPES = {"post", "comment", "gist", "project"}
|
||||
BOOKMARKABLE_TYPES = {"post", "gist", "project", "news", "quiz"}
|
||||
REACTABLE_TYPES = {"post", "comment", "gist", "project", "quiz"}
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -201,7 +203,7 @@ def create_content_item(
|
||||
return uid, slug
|
||||
|
||||
|
||||
VOTE_NOTIFY_TYPES = {"post", "comment", "gist", "project"}
|
||||
VOTE_NOTIFY_TYPES = {"post", "comment", "gist", "project", "quiz"}
|
||||
|
||||
|
||||
def apply_vote(request, user: dict, target_type: str, target_uid: str, value: int) -> dict:
|
||||
@ -621,6 +623,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
|
||||
@ -713,3 +720,24 @@ def enrich_items(
|
||||
)
|
||||
enriched.append(entry)
|
||||
return enriched
|
||||
|
||||
|
||||
def get_project_devlog(
|
||||
project_uid: str, before: str | None = None, viewer: dict | None = None
|
||||
) -> tuple[list, str | None]:
|
||||
posts, next_cursor = paginate(
|
||||
get_table("posts"),
|
||||
before=before,
|
||||
viewer_uid=viewer["uid"] if viewer else None,
|
||||
project_uid=project_uid,
|
||||
)
|
||||
if not posts:
|
||||
return [], None
|
||||
|
||||
authors = get_users_by_uids([post["user_uid"] for post in posts])
|
||||
counts = get_comment_counts_by_post_uids([post["uid"] for post in posts])
|
||||
|
||||
enriched = enrich_items(
|
||||
posts, "post", authors, {"comment_count": counts}, user=viewer
|
||||
)
|
||||
return enriched, next_cursor
|
||||
|
||||
@ -105,6 +105,17 @@ if "comments" not in db.tables:
|
||||
|
||||
`init_db()` ends with `_refresh_query_planner_stats()`: a one-time `ANALYZE` when `sqlite_stat1` is absent, then `PRAGMA optimize` every boot (both inside `with db:`). Validate any index change with `EXPLAIN QUERY PLAN` against `data/devplace.db` (or a copy) - a correct plan reads `SEARCH ... USING INDEX <name>` with no temp b-tree.
|
||||
|
||||
## Startup backfills must converge (hard rule)
|
||||
|
||||
`init_db()` runs inside `lifespan` under an exclusive `init_lock()`, **before the worker accepts a single request**, and every uvicorn worker runs it in turn. Time-to-serving is therefore `workers x init_db`, so anything added there is paid N times on every boot and every deploy. Two rules follow:
|
||||
|
||||
- **A backfill must be able to finish.** A backfill selects the rows that still need migrating and must leave them *not* selected afterwards. If a row can stay in the candidate set after a successful pass, the "one-time migration" is really a permanent per-boot scan that grows with the table. `_backfill_gamification` had exactly this bug: it selected `users.find(xp=0)` and then ran the full milestone sweep over *every* one of them, but a user with no content is awarded no XP, so they stayed at `xp=0` and were re-swept forever. At 7814 such users that was 36s of the 37.5s boot - about 140k queries that provably could not award anything, on every worker, on every restart.
|
||||
- **Never fan a per-row query out over a whole table at boot.** Compute the candidate set with a few set-based `GROUP BY`/`DISTINCT` queries first, then do per-row work only for rows that survive. `_milestone_candidates()` is the pattern: one `SELECT DISTINCT` per milestone source table (`MILESTONE_SOURCES`), unioned into a set, intersected with the pending users. A user absent from all of those tables scores 0 on every milestone metric and the lowest threshold is 5, so skipping them cannot change any award - verified by diffing the full `badges` table between the full sweep and the narrowed one (identical, 9800 rows, 22.6s -> 0.9s).
|
||||
|
||||
Watch for the same shape in `dataset` internals: `db.tables` is a live SQLAlchemy reflection, not a cached attribute. `get_user_stars` does one `in db.tables` check per `STAR_TARGETS` entry, so a per-user loop calling it re-reflects the whole table list on every iteration - 78667 reflections costing 17.7s in the profile above. Hoist `db.tables` into a local when looping.
|
||||
|
||||
Profile with the real database before and after any change here (`cProfile` around `init_db()` against a copy of `data/devplace.db`); a synthetic or empty DB hides every one of these costs.
|
||||
|
||||
## Project-wide soft delete (hard rule)
|
||||
|
||||
Attachments (see "Profile media gallery and soft-deleted attachments" below) are one instance of a platform-wide model: **every removal is a soft delete; only garbage collection is a hard delete.** Every removable row carries two columns, `deleted_at` (ISO timestamp) and `deleted_by` (actor uid, or `system`). A live row has `deleted_at = NULL`, and every list/count read filters `deleted_at IS NULL`.
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
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
|
||||
@ -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",
|
||||
@ -234,6 +236,8 @@ __all__ = [
|
||||
"delete_attachments",
|
||||
"_delete_attachment_file",
|
||||
"get_user_media",
|
||||
"get_user_attachments",
|
||||
"get_user_attachment",
|
||||
"get_deleted_media",
|
||||
"_stats_cache",
|
||||
"get_site_stats",
|
||||
@ -250,3 +254,5 @@ __all__ = [
|
||||
"backfill_api_keys",
|
||||
"_backfill_gamification",
|
||||
]
|
||||
|
||||
|
||||
|
||||
26
devplacepy/database/atomic.py
Normal file
26
devplacepy/database/atomic.py
Normal file
@ -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:
|
||||
|
||||
@ -185,3 +185,5 @@ def get_polls_by_post_uids(post_uids, user=None):
|
||||
|
||||
def get_poll_for_post(post_uid, user=None):
|
||||
return get_polls_by_post_uids([post_uid], user).get(post_uid)
|
||||
|
||||
|
||||
|
||||
@ -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)
|
||||
@ -26,12 +32,13 @@ def _ranked_authors() -> list:
|
||||
cached = _authors_cache.get("ranked")
|
||||
if cached is not None:
|
||||
return cached
|
||||
tables = db.tables
|
||||
sources = [
|
||||
(target_type, table_name)
|
||||
for target_type, table_name in VOTABLE_TARGETS.items()
|
||||
if table_name in db.tables
|
||||
if table_name in tables
|
||||
]
|
||||
if "votes" not in db.tables or not sources:
|
||||
if "votes" not in tables or not sources:
|
||||
_authors_cache.set("ranked", [])
|
||||
return []
|
||||
target_union = " UNION ALL ".join(
|
||||
@ -95,12 +102,13 @@ def get_user_stars(user_uid: str) -> int:
|
||||
cached = _stars_cache.get(user_uid)
|
||||
if cached is not None:
|
||||
return cached
|
||||
if "votes" not in db.tables:
|
||||
tables = db.tables
|
||||
if "votes" not in tables:
|
||||
return 0
|
||||
target_union = " UNION ALL ".join(
|
||||
f"SELECT uid, '{target_type}' AS target_type FROM {table_name} WHERE user_uid = :u AND deleted_at IS NULL"
|
||||
for target_type, table_name in VOTABLE_TARGETS.items()
|
||||
if table_name in db.tables
|
||||
if table_name in tables
|
||||
)
|
||||
if not target_union:
|
||||
return 0
|
||||
@ -148,7 +156,8 @@ def delete_engagement(target_type: str, target_uids: list) -> None:
|
||||
uids = [uid for uid in (target_uids or []) if uid]
|
||||
if not uids:
|
||||
return
|
||||
if "reactions" in db.tables:
|
||||
tables = db.tables
|
||||
if "reactions" in tables:
|
||||
placeholders, params = _in_clause(uids)
|
||||
params["tt"] = target_type
|
||||
with db:
|
||||
@ -156,7 +165,7 @@ def delete_engagement(target_type: str, target_uids: list) -> None:
|
||||
f"DELETE FROM reactions WHERE target_type=:tt AND target_uid IN ({placeholders})",
|
||||
**params,
|
||||
)
|
||||
if "bookmarks" in db.tables:
|
||||
if "bookmarks" in tables:
|
||||
placeholders, params = _in_clause(uids)
|
||||
params["tt"] = target_type
|
||||
with db:
|
||||
@ -164,12 +173,12 @@ def delete_engagement(target_type: str, target_uids: list) -> None:
|
||||
f"DELETE FROM bookmarks WHERE target_type=:tt AND target_uid IN ({placeholders})",
|
||||
**params,
|
||||
)
|
||||
if target_type == "post" and "polls" in db.tables:
|
||||
if target_type == "post" and "polls" in tables:
|
||||
for uid in uids:
|
||||
for poll in db["polls"].find(post_uid=uid):
|
||||
if "poll_votes" in db.tables:
|
||||
if "poll_votes" in tables:
|
||||
db["poll_votes"].delete(poll_uid=poll["uid"])
|
||||
if "poll_options" in db.tables:
|
||||
if "poll_options" in tables:
|
||||
db["poll_options"].delete(poll_uid=poll["uid"])
|
||||
db["polls"].delete(post_uid=uid)
|
||||
|
||||
|
||||
@ -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"):
|
||||
@ -133,7 +134,28 @@ def init_db():
|
||||
)
|
||||
_index(db, "notifications", "idx_notifications_user", ["user_uid"])
|
||||
_index(db, "notifications", "idx_notifications_user_read", ["user_uid", "read"])
|
||||
push_registration = get_table("push_registration")
|
||||
for column, example in (
|
||||
("uid", ""),
|
||||
("user_uid", ""),
|
||||
("provider", "webpush"),
|
||||
("endpoint", ""),
|
||||
("key_auth", ""),
|
||||
("key_p256dh", ""),
|
||||
("token", ""),
|
||||
("created_at", ""),
|
||||
("deleted_at", ""),
|
||||
):
|
||||
if not push_registration.has_column(column):
|
||||
push_registration.create_column_by_example(column, example)
|
||||
_index(db, "push_registration", "idx_push_registration_user", ["user_uid"])
|
||||
_index(db, "push_registration", "idx_push_registration_provider", ["provider"])
|
||||
if "push_registration" in db.tables:
|
||||
with db:
|
||||
db.query(
|
||||
"UPDATE push_registration SET provider = 'webpush' "
|
||||
"WHERE provider IS NULL OR provider = ''"
|
||||
)
|
||||
_index(db, "sessions", "idx_sessions_token", ["session_token"])
|
||||
projects = get_table("projects")
|
||||
for column, example in (
|
||||
@ -358,6 +380,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 +426,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,9 +548,19 @@ 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 (
|
||||
("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", ""),
|
||||
@ -518,6 +605,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 +1105,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 +1155,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 +1163,7 @@ def init_db():
|
||||
("farm_uid", ""),
|
||||
("user_uid", ""),
|
||||
("day", ""),
|
||||
("scope", "daily"),
|
||||
("slot_index", 0),
|
||||
("kind", ""),
|
||||
("label", ""),
|
||||
@ -1052,13 +1171,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 +1193,7 @@ def init_db():
|
||||
("planted_at", ""),
|
||||
("ready_at", ""),
|
||||
("watered_by", "[]"),
|
||||
("raided_fraction", 0.0),
|
||||
("created_at", ""),
|
||||
("updated_at", ""),
|
||||
):
|
||||
@ -1077,6 +1201,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,
|
||||
@ -1377,6 +1722,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"):
|
||||
@ -1435,6 +1782,29 @@ def backfill_api_keys() -> int:
|
||||
return updated
|
||||
|
||||
|
||||
MILESTONE_SOURCES = (
|
||||
("posts", "user_uid"),
|
||||
("comments", "user_uid"),
|
||||
("projects", "user_uid"),
|
||||
("gists", "user_uid"),
|
||||
("follows", "follower_uid"),
|
||||
("follows", "following_uid"),
|
||||
("user_activity", "user_uid"),
|
||||
)
|
||||
|
||||
|
||||
def _milestone_candidates() -> set:
|
||||
tables = db.tables
|
||||
candidates = set()
|
||||
for table, column in MILESTONE_SOURCES:
|
||||
if table not in tables:
|
||||
continue
|
||||
for row in db.query(f"SELECT DISTINCT {column} AS uid FROM {table}"):
|
||||
if row["uid"]:
|
||||
candidates.add(row["uid"])
|
||||
return candidates
|
||||
|
||||
|
||||
def _backfill_gamification():
|
||||
if "users" not in db.tables:
|
||||
return
|
||||
@ -1498,6 +1868,12 @@ def _backfill_gamification():
|
||||
)
|
||||
|
||||
_authors_cache.clear()
|
||||
for user in pending:
|
||||
candidates = _milestone_candidates()
|
||||
checked = [user for user in pending if user["uid"] in candidates]
|
||||
for user in checked:
|
||||
check_milestone_badges(user["uid"])
|
||||
logger.info(f"Gamification backfill processed {len(pending)} users")
|
||||
logger.info(
|
||||
f"Gamification backfill processed {len(pending)} users, "
|
||||
f"{len(checked)} with milestone-eligible activity"
|
||||
)
|
||||
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
from .core import _in_clause, _now_iso, db, get_table
|
||||
|
||||
|
||||
SEO_META_TYPES = ("post", "project", "gist", "news", "issue")
|
||||
SEO_META_TYPES = ("post", "project", "gist", "news", "issue", "quiz")
|
||||
|
||||
|
||||
def get_seo_metadata(target_type: str, target_uid: str) -> dict | None:
|
||||
|
||||
@ -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,15 @@ def get_online_users(cutoff_iso: str, limit: int = 30) -> list:
|
||||
)
|
||||
|
||||
|
||||
def _can_hold_primary_admin(row, tracks_active):
|
||||
if row.get("deleted_at"):
|
||||
return False
|
||||
if not tracks_active:
|
||||
return True
|
||||
is_active = row.get("is_active")
|
||||
return is_active is None or bool(is_active)
|
||||
|
||||
|
||||
def get_primary_admin_uid():
|
||||
sync_local_cache("admins", _admins_cache)
|
||||
cached = _admins_cache.get("primary")
|
||||
@ -80,11 +92,17 @@ def get_primary_admin_uid():
|
||||
return None
|
||||
rows = list(
|
||||
db.query(
|
||||
"SELECT uid FROM users WHERE role = 'Admin' "
|
||||
"ORDER BY created_at ASC, id ASC LIMIT 1"
|
||||
"SELECT * FROM users WHERE role = 'Admin' "
|
||||
"ORDER BY (created_at IS NULL OR created_at = ''), created_at ASC, id ASC "
|
||||
"LIMIT :cap",
|
||||
cap=PRIMARY_ADMIN_CANDIDATES,
|
||||
)
|
||||
)
|
||||
primary = rows[0]["uid"] if rows else None
|
||||
tracks_active = "is_active" in db["users"].columns
|
||||
primary = next(
|
||||
(row["uid"] for row in rows if _can_hold_primary_admin(row, tracks_active)),
|
||||
None,
|
||||
)
|
||||
_admins_cache.set("primary", primary or "")
|
||||
return primary
|
||||
|
||||
|
||||
@ -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,73 @@ four ways to sign requests.
|
||||
auth="admin",
|
||||
destructive=True,
|
||||
),
|
||||
endpoint(
|
||||
id="admin-gateway-quota-rules",
|
||||
method="GET",
|
||||
path="/admin/gateway/quota-rules",
|
||||
title="List AI gateway quota rules",
|
||||
summary=(
|
||||
"List every rolling-24h USD quota rule on /openai/v1/*, each scoped by any "
|
||||
"combination of role, specific user uid, and app_reference label, plus the "
|
||||
"global per-role default caps that apply when no rule matches."
|
||||
),
|
||||
auth="admin",
|
||||
interactive=True,
|
||||
),
|
||||
endpoint(
|
||||
id="admin-gateway-quota-rule-set",
|
||||
method="POST",
|
||||
path="/admin/gateway/quota-rules",
|
||||
title="Create or update an AI gateway quota rule",
|
||||
summary=(
|
||||
"Caps rolling-24h USD spend on /openai/v1/*. At least one of owner_kind, "
|
||||
"owner_id, app_reference must be set; leaving a dimension blank makes it a "
|
||||
"wildcard, and the most specific active match wins over other rules and over "
|
||||
"the global default. Pass uid to update an existing rule."
|
||||
),
|
||||
auth="admin",
|
||||
params=[
|
||||
field("uid", "json", "string", False, "", "Existing rule uid to update; omit to create a new rule."),
|
||||
field("owner_kind", "json", "string", False, "user", "internal, key, user, admin, or anonymous. Blank = any role."),
|
||||
field("owner_id", "json", "string", False, "", "Specific user uid. Blank = any caller of the matched role."),
|
||||
field("app_reference", "json", "string", False, "devplace-bots-v-1-0-0", "App label (the X-App-Reference header). Blank = any app."),
|
||||
field("limit_usd", "json", "number", True, "2.5", "Rolling 24h USD cap. 0 = unlimited."),
|
||||
field("is_active", "json", "boolean", False, "true", "Whether the rule is enforced."),
|
||||
field("label", "json", "string", False, "", "Optional admin-facing note."),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="admin-gateway-quota-reset",
|
||||
method="POST",
|
||||
path="/admin/gateway/quota-resets",
|
||||
title="Reset the AI gateway 24h spend",
|
||||
summary=(
|
||||
"Clear the counted rolling-24h spend for a scope so a capped caller can call "
|
||||
"again, without deleting any usage history (the cost analytics stay intact). "
|
||||
"Scope it exactly like a quota rule; leaving all three dimensions blank resets "
|
||||
"every caller. Only spend recorded before the reset is cleared - new calls "
|
||||
"count again immediately against the same limit."
|
||||
),
|
||||
auth="admin",
|
||||
destructive=True,
|
||||
params=[
|
||||
field("owner_kind", "json", "string", False, "user", "internal, key, user, admin, or anonymous. Blank = every role."),
|
||||
field("owner_id", "json", "string", False, "", "Specific user uid. Blank = every caller."),
|
||||
field("app_reference", "json", "string", False, "typosaurus", "App label (the X-App-Reference header). Blank = every app."),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="admin-gateway-quota-rule-delete",
|
||||
method="DELETE",
|
||||
path="/admin/gateway/quota-rules/{uid}",
|
||||
title="Delete an AI gateway quota rule",
|
||||
summary="Delete a quota rule; callers it covered fall back to the next most specific rule or the global default.",
|
||||
auth="admin",
|
||||
destructive=True,
|
||||
params=[
|
||||
field("uid", "path", "string", True, "RULE_UID", "Quota rule uid."),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="admin-bots-monitor",
|
||||
method="GET",
|
||||
@ -670,6 +737,52 @@ four ways to sign requests.
|
||||
)
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="admin-devii-tasks",
|
||||
method="GET",
|
||||
path="/admin/devii-tasks",
|
||||
title="Scheduled Devii tasks",
|
||||
summary=(
|
||||
"Every scheduled Devii task across all owners with its schedule, run count, "
|
||||
"expiry, failure streak, and whether its owner may still schedule, plus the "
|
||||
"configured automation bounds. Returns HTML (or JSON with "
|
||||
"Accept: application/json)."
|
||||
),
|
||||
auth="admin",
|
||||
interactive=True,
|
||||
params=[
|
||||
field(
|
||||
"state",
|
||||
"query",
|
||||
"string",
|
||||
False,
|
||||
"active",
|
||||
"One of active, inactive, all.",
|
||||
)
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="admin-devii-task-disable",
|
||||
method="POST",
|
||||
path="/admin/devii-tasks/{uid}/disable",
|
||||
title="Disable a scheduled task",
|
||||
summary="Stop one scheduled task. The row is kept and stays auditable.",
|
||||
auth="admin",
|
||||
params=[
|
||||
field("uid", "path", "string", True, "", "Uid of the task."),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="admin-devii-task-delete",
|
||||
method="POST",
|
||||
path="/admin/devii-tasks/{uid}/delete",
|
||||
title="Delete a scheduled task",
|
||||
summary="Soft-delete one scheduled task; it moves to the admin trash.",
|
||||
auth="admin",
|
||||
params=[
|
||||
field("uid", "path", "string", True, "", "Uid of the task."),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="admin-backups",
|
||||
method="GET",
|
||||
@ -830,5 +943,37 @@ four ways to sign requests.
|
||||
params=[field("uid", "path", "string", True, "", "Schedule uid.")],
|
||||
sample_response={"ok": True, "redirect": "/admin/backups"},
|
||||
),
|
||||
endpoint(
|
||||
id="admin-game",
|
||||
method="GET",
|
||||
path="/admin/game",
|
||||
title="Code Farm Era management",
|
||||
summary="View the current Code Farm Era status.",
|
||||
auth="admin",
|
||||
sample_response={"era_active": False, "era_name": ""},
|
||||
),
|
||||
endpoint(
|
||||
id="admin-game-era-start",
|
||||
method="POST",
|
||||
path="/admin/game/era/start",
|
||||
title="Start an Era",
|
||||
summary="Start a new Code Farm Era: resets every farm's visible Era coins/harvests counters to zero. Real coins, prestige, stars, Legacy, and Mastery are never touched.",
|
||||
auth="admin",
|
||||
params=[
|
||||
field("name", "form", "string", True, "Genesis", "Era name."),
|
||||
field("duration_days", "form", "int", False, "28", "Planned Era length in days."),
|
||||
],
|
||||
sample_response={"ok": True, "redirect": "/admin/game"},
|
||||
),
|
||||
endpoint(
|
||||
id="admin-game-era-end",
|
||||
method="POST",
|
||||
path="/admin/game/era/end",
|
||||
title="End the running Era",
|
||||
summary="End the current Era: ranks every participating farm by Era score, awards Stars to the top 10 (and an Era-exclusive cosmetic when available), and permanently records the results.",
|
||||
auth="admin",
|
||||
destructive=True,
|
||||
sample_response={"ok": True, "redirect": "/admin/game"},
|
||||
),
|
||||
],
|
||||
}
|
||||
|
||||
@ -1,580 +0,0 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from .._shared import endpoint, field
|
||||
|
||||
GROUP = {
|
||||
"slug": "containers",
|
||||
"title": "Container Manager",
|
||||
"admin": True,
|
||||
"intro": """
|
||||
# Container Manager
|
||||
|
||||
Run supervised container instances for a project. There is no in-app image building: every instance
|
||||
runs one shared prebuilt image (`ppy:latest`) with the project's workspace mounted at `/app`. Every
|
||||
endpoint is **administrator only** (docker socket access is root-equivalent). Mutations flip desired
|
||||
state; a single reconciler converges containers to it.
|
||||
""",
|
||||
"endpoints": [
|
||||
endpoint(
|
||||
id="containers-page",
|
||||
method="GET",
|
||||
path="/projects/{project_slug}/containers",
|
||||
title="Container manager page",
|
||||
summary="The admin per-project container manager UI (instance creation and lifecycle). Returns 404 for an administrator who is not the owner of an administrator-hidden project.",
|
||||
auth="admin",
|
||||
interactive=True,
|
||||
params=[
|
||||
field(
|
||||
"project_slug",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"PROJECT_SLUG",
|
||||
"Project slug or uid.",
|
||||
)
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="containers-admin-index",
|
||||
method="GET",
|
||||
path="/admin/containers",
|
||||
title="Admin containers list",
|
||||
summary="The admin Containers section: every instance across all projects, each linking to its detail page. Instances attached to another administrator's hidden project are excluded, and per-instance actions return 404 for a non-owner administrator.",
|
||||
auth="admin",
|
||||
interactive=True,
|
||||
),
|
||||
endpoint(
|
||||
id="containers-admin-data",
|
||||
method="GET",
|
||||
path="/admin/containers/data",
|
||||
title="Admin containers list data",
|
||||
summary="JSON of every instance across all projects (decorated with project title/slug) for polling.",
|
||||
auth="admin",
|
||||
sample_response={
|
||||
"instances": [
|
||||
{
|
||||
"uid": "INSTANCE_UID",
|
||||
"name": "staging",
|
||||
"status": "running",
|
||||
"project_slug": "PROJECT_SLUG",
|
||||
"project_title": "My Project",
|
||||
"ingress_slug": "my-service",
|
||||
"restart_policy": "always",
|
||||
}
|
||||
]
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="containers-admin-instance",
|
||||
method="GET",
|
||||
path="/admin/containers/{uid}",
|
||||
title="Instance detail page",
|
||||
summary="The dedicated detail page for one instance (lifecycle, logs, metrics, terminal, schedules, ingress, sync).",
|
||||
auth="admin",
|
||||
interactive=True,
|
||||
params=[
|
||||
field(
|
||||
"uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
|
||||
)
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="containers-admin-edit-page",
|
||||
method="GET",
|
||||
path="/admin/containers/{uid}/edit",
|
||||
title="Edit instance page",
|
||||
summary="The edit page for one instance (run-as user, boot language/script/command, restart policy, start-on-boot, limits).",
|
||||
auth="admin",
|
||||
interactive=True,
|
||||
params=[
|
||||
field(
|
||||
"uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
|
||||
)
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="containers-create-instance",
|
||||
method="POST",
|
||||
path="/projects/{project_slug}/containers/instances",
|
||||
title="Create an instance",
|
||||
summary="Create and (by default) start an instance; it runs the shared ppy image with the project workspace mounted at /app.",
|
||||
auth="admin",
|
||||
encoding="form",
|
||||
destructive=True,
|
||||
params=[
|
||||
field(
|
||||
"project_slug",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"PROJECT_SLUG",
|
||||
"Project slug or uid.",
|
||||
),
|
||||
field("name", "form", "string", True, "staging", "Instance name."),
|
||||
field(
|
||||
"boot_command",
|
||||
"form",
|
||||
"string",
|
||||
False,
|
||||
"python app.py",
|
||||
"Optional boot command.",
|
||||
),
|
||||
field("run_as_uid", "form", "string", False, "USER_UID", "DevPlace user uid whose identity and API key are injected (PRAVDA_API_KEY, PRAVDA_USER_UID). Does NOT change the container OS user (always pravda, uid 1000)."),
|
||||
field("boot_language", "form", "enum", False, "none", "Boot source language.", ["none", "python", "bash"]),
|
||||
field("boot_script", "form", "textarea", False, "print('hi')", "Boot source code run on launch (takes precedence over boot_command)."),
|
||||
field(
|
||||
"env",
|
||||
"form",
|
||||
"textarea",
|
||||
False,
|
||||
"KEY=VALUE",
|
||||
"Env vars, one KEY=VALUE per line.",
|
||||
),
|
||||
field(
|
||||
"ports",
|
||||
"form",
|
||||
"string",
|
||||
False,
|
||||
"80",
|
||||
"Port maps. Bare container port auto-assigns a unique host port above 20000; host:container pins one.",
|
||||
),
|
||||
field("cpu_limit", "form", "string", False, "1.5", "CPU limit."),
|
||||
field(
|
||||
"mem_limit", "form", "string", False, "512m", "Memory limit."
|
||||
),
|
||||
field(
|
||||
"restart_policy",
|
||||
"form",
|
||||
"enum",
|
||||
False,
|
||||
"never",
|
||||
"Restart policy.",
|
||||
["never", "always", "on-failure", "unless-stopped"],
|
||||
),
|
||||
field("start_on_boot", "form", "boolean", False, "false", "Force running whenever the container service starts."),
|
||||
field(
|
||||
"ingress_slug",
|
||||
"form",
|
||||
"string",
|
||||
False,
|
||||
"my-service",
|
||||
"Publish at /p/<slug> (optional).",
|
||||
),
|
||||
field(
|
||||
"ingress_port",
|
||||
"form",
|
||||
"integer",
|
||||
False,
|
||||
"8899",
|
||||
"Container port to publish (must be a mapped port).",
|
||||
),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="containers-ingress",
|
||||
method="GET",
|
||||
path="/p/{slug}",
|
||||
title="Container ingress proxy",
|
||||
summary="Public reverse proxy (HTTP and WebSocket) to a running instance published via ingress_slug. The /p/<slug> prefix is stripped before forwarding.",
|
||||
auth="public",
|
||||
interactive=True,
|
||||
params=[
|
||||
field(
|
||||
"slug",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"my-service",
|
||||
"The instance's ingress_slug.",
|
||||
)
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="containers-instance-action",
|
||||
method="POST",
|
||||
path="/projects/{project_slug}/containers/instances/{uid}/{action}",
|
||||
title="Instance lifecycle",
|
||||
summary="start, stop, restart, pause, or resume an instance (flips desired state).",
|
||||
auth="admin",
|
||||
destructive=True,
|
||||
params=[
|
||||
field(
|
||||
"project_slug",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"PROJECT_SLUG",
|
||||
"Project slug or uid.",
|
||||
),
|
||||
field(
|
||||
"uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
|
||||
),
|
||||
field(
|
||||
"action",
|
||||
"path",
|
||||
"enum",
|
||||
True,
|
||||
"start",
|
||||
"Lifecycle action.",
|
||||
["start", "stop", "restart", "pause", "resume"],
|
||||
),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="containers-instance-logs",
|
||||
method="GET",
|
||||
path="/projects/{project_slug}/containers/instances/{uid}/logs",
|
||||
title="Instance logs",
|
||||
summary="Recent docker logs of a running instance.",
|
||||
auth="admin",
|
||||
params=[
|
||||
field(
|
||||
"project_slug",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"PROJECT_SLUG",
|
||||
"Project slug or uid.",
|
||||
),
|
||||
field(
|
||||
"uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
|
||||
),
|
||||
field("tail", "query", "integer", False, "200", "Number of lines."),
|
||||
],
|
||||
sample_response={"logs": "..."},
|
||||
),
|
||||
endpoint(
|
||||
id="containers-instance-sync",
|
||||
method="POST",
|
||||
path="/projects/{project_slug}/containers/instances/{uid}/sync",
|
||||
title="Sync workspace",
|
||||
summary="Run a one-shot bidirectional newer-wins sync between the project files and the container workspace.",
|
||||
auth="admin",
|
||||
destructive=True,
|
||||
params=[
|
||||
field(
|
||||
"project_slug",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"PROJECT_SLUG",
|
||||
"Project slug or uid.",
|
||||
),
|
||||
field(
|
||||
"uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
|
||||
),
|
||||
],
|
||||
sample_response={"exported": 3, "imported": 1},
|
||||
),
|
||||
endpoint(
|
||||
id="containers-instance-delete",
|
||||
method="POST",
|
||||
path="/projects/{project_slug}/containers/instances/{uid}/delete",
|
||||
title="Delete instance",
|
||||
summary="Remove a container instance and mark its container for removal.",
|
||||
auth="admin",
|
||||
destructive=True,
|
||||
params=[
|
||||
field(
|
||||
"project_slug",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"PROJECT_SLUG",
|
||||
"Project slug or uid.",
|
||||
),
|
||||
field(
|
||||
"uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
|
||||
),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="containers-instance-exec",
|
||||
method="POST",
|
||||
path="/projects/{project_slug}/containers/instances/{uid}/exec",
|
||||
title="Exec a command",
|
||||
summary="Run a one-shot command inside a running instance and return its output.",
|
||||
auth="admin",
|
||||
encoding="form",
|
||||
destructive=True,
|
||||
params=[
|
||||
field(
|
||||
"project_slug",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"PROJECT_SLUG",
|
||||
"Project slug or uid.",
|
||||
),
|
||||
field(
|
||||
"uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
|
||||
),
|
||||
field(
|
||||
"command",
|
||||
"form",
|
||||
"string",
|
||||
True,
|
||||
"ls -la /app",
|
||||
"Shell command to run (via /bin/sh -c).",
|
||||
),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="containers-instance-data",
|
||||
method="GET",
|
||||
path="/projects/{project_slug}/containers/instances/{uid}",
|
||||
title="Instance detail data",
|
||||
summary="Return the full instance row plus runtime info as JSON.",
|
||||
auth="admin",
|
||||
params=[
|
||||
field(
|
||||
"project_slug",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"PROJECT_SLUG",
|
||||
"Project slug or uid.",
|
||||
),
|
||||
field(
|
||||
"uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
|
||||
),
|
||||
],
|
||||
sample_response={"uid": "INSTANCE_UID", "name": "staging", "status": "running"},
|
||||
),
|
||||
endpoint(
|
||||
id="containers-instance-metrics",
|
||||
method="GET",
|
||||
path="/projects/{project_slug}/containers/instances/{uid}/metrics",
|
||||
title="Instance metrics",
|
||||
summary="Return recent metrics ring-buffer and aggregated stats for a running instance.",
|
||||
auth="admin",
|
||||
params=[
|
||||
field(
|
||||
"project_slug",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"PROJECT_SLUG",
|
||||
"Project slug or uid.",
|
||||
),
|
||||
field(
|
||||
"uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
|
||||
),
|
||||
],
|
||||
sample_response={"metrics": [], "stats": {}},
|
||||
),
|
||||
endpoint(
|
||||
id="containers-instance-schedules",
|
||||
method="POST",
|
||||
path="/projects/{project_slug}/containers/instances/{uid}/schedules",
|
||||
title="Create a schedule",
|
||||
summary="Attach a cron, one-time, interval, or delay schedule to an instance.",
|
||||
auth="admin",
|
||||
encoding="form",
|
||||
destructive=True,
|
||||
params=[
|
||||
field(
|
||||
"project_slug",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"PROJECT_SLUG",
|
||||
"Project slug or uid.",
|
||||
),
|
||||
field(
|
||||
"uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
|
||||
),
|
||||
field(
|
||||
"action",
|
||||
"form",
|
||||
"string",
|
||||
True,
|
||||
"start",
|
||||
"Lifecycle action to run on schedule (start, stop, restart).",
|
||||
),
|
||||
field(
|
||||
"kind",
|
||||
"form",
|
||||
"string",
|
||||
True,
|
||||
"cron",
|
||||
"Schedule kind: cron, once, interval, or delay.",
|
||||
),
|
||||
field(
|
||||
"cron",
|
||||
"form",
|
||||
"string",
|
||||
False,
|
||||
"0 * * * *",
|
||||
"Cron expression (when kind is cron).",
|
||||
),
|
||||
field(
|
||||
"run_at",
|
||||
"form",
|
||||
"string",
|
||||
False,
|
||||
"2026-01-01T00:00:00",
|
||||
"ISO timestamp for a one-time run (when kind is once).",
|
||||
),
|
||||
field(
|
||||
"delay_seconds",
|
||||
"form",
|
||||
"integer",
|
||||
False,
|
||||
"60",
|
||||
"Seconds to wait before a single run (when kind is delay).",
|
||||
),
|
||||
field(
|
||||
"every_seconds",
|
||||
"form",
|
||||
"integer",
|
||||
False,
|
||||
"300",
|
||||
"Interval in seconds between runs (when kind is interval).",
|
||||
),
|
||||
field(
|
||||
"max_runs",
|
||||
"form",
|
||||
"integer",
|
||||
False,
|
||||
"10",
|
||||
"Optional cap on the number of runs.",
|
||||
),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="containers-instance-schedule-delete",
|
||||
method="POST",
|
||||
path="/projects/{project_slug}/containers/instances/{uid}/schedules/{sid}/delete",
|
||||
title="Delete a schedule",
|
||||
summary="Remove a schedule from an instance.",
|
||||
auth="admin",
|
||||
destructive=True,
|
||||
params=[
|
||||
field(
|
||||
"project_slug",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"PROJECT_SLUG",
|
||||
"Project slug or uid.",
|
||||
),
|
||||
field(
|
||||
"uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
|
||||
),
|
||||
field(
|
||||
"sid", "path", "string", True, "SCHEDULE_UID", "Schedule uid."
|
||||
),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="containers-admin-create",
|
||||
method="POST",
|
||||
path="/admin/containers/create",
|
||||
title="Admin create instance",
|
||||
summary="Create an instance from the admin Containers page: project search-select, run-as user, boot language/script, restart policy, start-on-boot, plus the usual options.",
|
||||
auth="admin",
|
||||
encoding="form",
|
||||
destructive=True,
|
||||
params=[
|
||||
field("project_slug", "form", "string", True, "PROJECT_SLUG", "Project that becomes the /app root."),
|
||||
field("name", "form", "string", True, "staging", "Instance name."),
|
||||
field("run_as_uid", "form", "string", False, "USER_UID", "DevPlace user uid whose identity and API key are injected (PRAVDA_API_KEY, PRAVDA_USER_UID). Does NOT change the container OS user (always pravda, uid 1000)."),
|
||||
field("boot_language", "form", "enum", False, "none", "Boot source language.", ["none", "python", "bash"]),
|
||||
field("boot_script", "form", "textarea", False, "print('hi')", "Boot source code run on launch (takes precedence over boot_command)."),
|
||||
field("boot_command", "form", "string", False, "python app.py", "Fallback boot command when no boot_script is set."),
|
||||
field("restart_policy", "form", "enum", False, "never", "Restart policy.", ["never", "always", "on-failure", "unless-stopped"]),
|
||||
field("start_on_boot", "form", "boolean", False, "false", "Force running whenever the container service starts."),
|
||||
field("env", "form", "textarea", False, "KEY=VALUE", "Env vars, one KEY=VALUE per line."),
|
||||
field("ports", "form", "string", False, "80", "Port maps; bare container port auto-assigns a host port above 20000."),
|
||||
field("cpu_limit", "form", "string", False, "1.5", "CPU limit."),
|
||||
field("mem_limit", "form", "string", False, "512m", "Memory limit."),
|
||||
field("ingress_slug", "form", "string", False, "my-service", "Publish at /p/<slug> (optional)."),
|
||||
field("ingress_port", "form", "integer", False, "8899", "Container port to publish."),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="containers-admin-edit",
|
||||
method="POST",
|
||||
path="/admin/containers/{uid}/edit",
|
||||
title="Admin edit instance",
|
||||
summary="Update an instance's run-as user, boot language/script/command, restart policy, start-on-boot flag, and resource limits.",
|
||||
auth="admin",
|
||||
encoding="form",
|
||||
destructive=True,
|
||||
params=[
|
||||
field("uid", "path", "string", True, "INSTANCE_UID", "Instance uid."),
|
||||
field("run_as_uid", "form", "string", False, "USER_UID", "Run-as user uid (identity + API key only)."),
|
||||
field("boot_language", "form", "enum", False, "none", "Boot source language.", ["none", "python", "bash"]),
|
||||
field("boot_script", "form", "textarea", False, "print('hi')", "Boot source code."),
|
||||
field("boot_command", "form", "string", False, "python app.py", "Fallback boot command."),
|
||||
field("restart_policy", "form", "enum", False, "never", "Restart policy.", ["never", "always", "on-failure", "unless-stopped"]),
|
||||
field("start_on_boot", "form", "boolean", False, "false", "Force running on container-service boot."),
|
||||
field("cpu_limit", "form", "string", False, "1.5", "CPU limit."),
|
||||
field("mem_limit", "form", "string", False, "512m", "Memory limit."),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="containers-admin-action",
|
||||
method="POST",
|
||||
path="/admin/containers/{uid}/{action}",
|
||||
title="Admin instance lifecycle",
|
||||
summary="start, stop, restart, pause, or resume an instance from the admin Containers page (flips desired state).",
|
||||
auth="admin",
|
||||
destructive=True,
|
||||
params=[
|
||||
field("uid", "path", "string", True, "INSTANCE_UID", "Instance uid."),
|
||||
field("action", "path", "enum", True, "start", "Lifecycle action.", ["start", "stop", "restart", "pause", "resume"]),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="containers-admin-sync",
|
||||
method="POST",
|
||||
path="/admin/containers/{uid}/sync",
|
||||
title="Admin bidirectional sync",
|
||||
summary="Run a one-shot bidirectional newer-wins sync between the project files and the container workspace.",
|
||||
auth="admin",
|
||||
destructive=True,
|
||||
params=[
|
||||
field("uid", "path", "string", True, "INSTANCE_UID", "Instance uid."),
|
||||
],
|
||||
sample_response={"exported": 3, "imported": 1},
|
||||
),
|
||||
endpoint(
|
||||
id="containers-admin-delete",
|
||||
method="POST",
|
||||
path="/admin/containers/{uid}/delete",
|
||||
title="Admin delete instance",
|
||||
summary="Soft-delete an instance and mark its container for removal.",
|
||||
auth="admin",
|
||||
destructive=True,
|
||||
params=[
|
||||
field("uid", "path", "string", True, "INSTANCE_UID", "Instance uid."),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="containers-admin-project-search",
|
||||
method="GET",
|
||||
path="/admin/containers/projects/search",
|
||||
title="Admin project search",
|
||||
summary="Search projects by title for the admin create form (returns uid, slug, title).",
|
||||
auth="admin",
|
||||
params=[
|
||||
field("q", "query", "string", False, "api", "Title fragment."),
|
||||
],
|
||||
sample_response={"results": [{"uid": "PROJECT_UID", "slug": "PROJECT_SLUG", "title": "My Project"}]},
|
||||
),
|
||||
endpoint(
|
||||
id="containers-admin-user-search",
|
||||
method="GET",
|
||||
path="/admin/containers/users/search",
|
||||
title="Admin run-as user search",
|
||||
summary="Search users by username for the run-as-user select (returns uid, username).",
|
||||
auth="admin",
|
||||
params=[
|
||||
field("q", "query", "string", False, "alice", "Username fragment."),
|
||||
],
|
||||
sample_response={"results": [{"uid": "USER_UID", "username": "alice"}]},
|
||||
),
|
||||
],
|
||||
}
|
||||
@ -2,6 +2,27 @@
|
||||
|
||||
from .._shared import endpoint, field
|
||||
|
||||
CROP_KEYS = [
|
||||
"shell",
|
||||
"python",
|
||||
"webapp",
|
||||
"api",
|
||||
"rust",
|
||||
"haskell",
|
||||
"kernel",
|
||||
"distsys",
|
||||
"mlpipe",
|
||||
"secfort",
|
||||
]
|
||||
PERK_KEYS = ["yield", "growth", "discount", "xp"]
|
||||
QUEST_KINDS = ["plant", "harvest", "water", "earn"]
|
||||
QUEST_SCOPES = ["daily", "weekly"]
|
||||
LEGACY_KEYS = ["autoharvest", "multiplier", "speed", "plots", "defense", "carryover"]
|
||||
MASTERY_KEYS = ["autoreplant", "analytics", "contracts"]
|
||||
INFRA_KEYS = ["registry", "canary", "observability"]
|
||||
COSMETIC_KEYS = ["title_architect", "title_refactorer", "title_kernel_hacker", "skin_neon"]
|
||||
BOARD_KEYS = ["score", "prestige", "harvests", "raids", "time_to_kernel", "fair_play", "era"]
|
||||
|
||||
GROUP = {
|
||||
"slug": "game",
|
||||
"title": "Code Farm",
|
||||
@ -12,8 +33,19 @@ The Code Farm is a cooperative idle game. Each member owns a farm of plots, plan
|
||||
projects that build over real time, harvests them for coins and XP, upgrades their CI tier for
|
||||
faster builds, and waters other members' growing builds to speed them up and earn coins.
|
||||
|
||||
All endpoints negotiate HTML or JSON. The action endpoints return the full farm state so a
|
||||
client can refresh without a second request.
|
||||
Refactoring (prestige) costs a dynamic coin fee that grows with prestige and current wealth;
|
||||
the fees fill a community treasury from which active low-balance farms can claim a weekly grant.
|
||||
|
||||
All endpoints negotiate HTML or JSON. POST bodies are form encoded
|
||||
(`application/x-www-form-urlencoded`). Every own-farm action returns `{"ok": true, "farm": {...}}`
|
||||
- the full updated farm state - so a client can refresh without a second request; the two
|
||||
neighbour actions (water, steal) return the neighbour's farm as `{"farm": {...}}`, and a
|
||||
successful steal adds `stole_coins`. An invalid action (not enough coins, wrong plot state, a
|
||||
protected harvest, an active cooldown) returns HTTP 400 as
|
||||
`{"error": {"status": 400, "message": "..."}}`; an unknown farm username is 404. Reading your
|
||||
own farm state also runs lazy owner effects: the CI Bot legacy upgrade auto-harvests ready
|
||||
builds, and any due Defense upkeep is charged. The complete rules, formulas, and an automated
|
||||
client are on the [Code Farm guide](/docs/code-farm.html).
|
||||
""",
|
||||
"endpoints": [
|
||||
endpoint(
|
||||
@ -31,7 +63,7 @@ client can refresh without a second request.
|
||||
method="GET",
|
||||
path="/game/state",
|
||||
title="Farm state",
|
||||
summary="The signed-in player's full farm state as JSON.",
|
||||
summary="The signed-in player's full farm state as JSON. Reading it auto-collects ready builds (with the CI Bot legacy upgrade, reported as auto_harvested/auto_harvest_coins/auto_harvest_xp) and charges any due Defense upkeep.",
|
||||
auth="user",
|
||||
sample_response={
|
||||
"ok": True,
|
||||
@ -40,8 +72,26 @@ client can refresh without a second request.
|
||||
"level": 1,
|
||||
"ci_tier": 1,
|
||||
"plot_count": 4,
|
||||
"plots": [{"slot": 0, "state": "empty"}],
|
||||
"crops": [{"key": "python", "name": "Python Script", "cost": 15}],
|
||||
"prestige": 0,
|
||||
"stars": 0,
|
||||
"refactor_cost": 20000,
|
||||
"plots": [{"slot": 0, "state": "empty", "raided_fraction": 0.0}],
|
||||
"daily_streak_reset": False,
|
||||
"contract_boost_seconds_remaining": 0,
|
||||
"auto_harvested": 0,
|
||||
"steal_max_per_victim_per_day": 3,
|
||||
"defense_downgrade_available": False,
|
||||
"crops": [
|
||||
{
|
||||
"key": "python",
|
||||
"name": "Python Script",
|
||||
"cost": 15,
|
||||
"reward_coins": 36,
|
||||
"grow_seconds": 120,
|
||||
"locked": False,
|
||||
"market_state": "normal",
|
||||
}
|
||||
],
|
||||
},
|
||||
},
|
||||
),
|
||||
@ -50,16 +100,41 @@ client can refresh without a second request.
|
||||
method="GET",
|
||||
path="/game/leaderboard",
|
||||
title="Farm leaderboard",
|
||||
summary="Top farmers ranked by level, XP, and harvests.",
|
||||
summary="Top 25 farmers on a chosen board: score (default), prestige, harvests (this week), raids (avg coins per successful raid over 30 days, min 3 raids), time_to_kernel, fair_play, or era (current Era only, empty when none is running). Cached about 15 seconds.",
|
||||
auth="public",
|
||||
sample_response={"entries": [{"rank": 1, "username": "alice", "level": 4}]},
|
||||
params=[
|
||||
field(
|
||||
"board",
|
||||
"query",
|
||||
"string",
|
||||
False,
|
||||
"score",
|
||||
"Leaderboard board key.",
|
||||
options=BOARD_KEYS,
|
||||
)
|
||||
],
|
||||
sample_response={
|
||||
"entries": [
|
||||
{
|
||||
"rank": 1,
|
||||
"username": "alice",
|
||||
"level": 4,
|
||||
"xp": 600,
|
||||
"coins": 240,
|
||||
"total_harvests": 52,
|
||||
"prestige": 1,
|
||||
"score": 6120,
|
||||
"title": "The Architect",
|
||||
}
|
||||
]
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="game-view-farm",
|
||||
method="GET",
|
||||
path="/game/farm/{username}",
|
||||
title="View a farm",
|
||||
summary="Another player's farm, with water controls on growing builds.",
|
||||
summary="Another player's farm, with per-plot can_water/can_steal flags computed for the viewer.",
|
||||
auth="public",
|
||||
negotiation=True,
|
||||
params=[field("username", "path", "string", True, "alice", "Farm owner's username.")],
|
||||
@ -70,11 +145,11 @@ client can refresh without a second request.
|
||||
method="POST",
|
||||
path="/game/plant",
|
||||
title="Plant a crop",
|
||||
summary="Plant a crop in an empty plot. Costs the crop's coin price.",
|
||||
summary="Plant a crop in an empty plot. Costs the crop's live coin price (the cost field in the farm state's crops list).",
|
||||
auth="user",
|
||||
params=[
|
||||
field("slot", "form", "integer", True, "0", "Plot slot index."),
|
||||
field("crop", "form", "string", True, "python", "Crop key."),
|
||||
field("slot", "form", "integer", True, "0", "Plot slot index, 0-based."),
|
||||
field("crop", "form", "string", True, "python", "Crop key.", options=CROP_KEYS),
|
||||
],
|
||||
sample_response={"ok": True, "farm": {"coins": 35}},
|
||||
),
|
||||
@ -83,9 +158,9 @@ client can refresh without a second request.
|
||||
method="POST",
|
||||
path="/game/harvest",
|
||||
title="Harvest a build",
|
||||
summary="Harvest a finished build for coins and XP.",
|
||||
summary="Harvest a finished (state ready) build for coins and XP.",
|
||||
auth="user",
|
||||
params=[field("slot", "form", "integer", True, "0", "Plot slot index.")],
|
||||
params=[field("slot", "form", "integer", True, "0", "Plot slot index, 0-based.")],
|
||||
sample_response={"ok": True, "farm": {"coins": 86}},
|
||||
),
|
||||
endpoint(
|
||||
@ -93,7 +168,7 @@ client can refresh without a second request.
|
||||
method="POST",
|
||||
path="/game/buy-plot",
|
||||
title="Buy a plot",
|
||||
summary="Unlock a new plot. Cost doubles per extra plot.",
|
||||
summary="Unlock a new plot (up to 12). Cost starts at 100 coins and doubles per extra plot; the exact price is the farm state's next_plot_cost.",
|
||||
auth="user",
|
||||
sample_response={"ok": True, "farm": {"plot_count": 5}},
|
||||
),
|
||||
@ -102,7 +177,7 @@ client can refresh without a second request.
|
||||
method="POST",
|
||||
path="/game/upgrade",
|
||||
title="Upgrade CI",
|
||||
summary="Upgrade the farm CI tier for faster builds.",
|
||||
summary="Upgrade the farm CI tier for faster builds (up to tier 5); the exact price is the farm state's ci_next_cost.",
|
||||
auth="user",
|
||||
sample_response={"ok": True, "farm": {"ci_tier": 2}},
|
||||
),
|
||||
@ -111,11 +186,11 @@ client can refresh without a second request.
|
||||
method="POST",
|
||||
path="/game/farm/{username}/water",
|
||||
title="Water a build",
|
||||
summary="Water another player's growing build to speed it up and earn coins.",
|
||||
summary="Water another player's growing build to cut 8% off its build time; pays the visitor 6 coins scaled by their own prestige and Tech Debt Payoff multiplier, plus 3 XP. Once per visitor per build, 3 waterings per build total.",
|
||||
auth="user",
|
||||
params=[
|
||||
field("username", "path", "string", True, "alice", "Farm owner's username."),
|
||||
field("slot", "form", "integer", True, "0", "Plot slot index."),
|
||||
field("slot", "form", "integer", True, "0", "Plot slot index, 0-based."),
|
||||
],
|
||||
sample_response={"farm": {"owner_username": "alice"}},
|
||||
),
|
||||
@ -124,11 +199,11 @@ client can refresh without a second request.
|
||||
method="POST",
|
||||
path="/game/farm/{username}/steal",
|
||||
title="Steal a build",
|
||||
summary="Steal another player's ready build once its protection window has passed; you receive half the build's coin value. Limited to once per hour per neighbour.",
|
||||
summary="Raid another player's ready build once its protection window has passed. You take a share of the build's realized coin value (half by default, less against defended owners - the plot's steal_coins field is the exact payout) and the owner keeps and can still harvest the remainder; the plot records the share taken as raided_fraction. Limited to once per hour per neighbour and 3 raids per victim per day; a fully stripped build reports steal_reason stripped, and Security Fortress builds are immune.",
|
||||
auth="user",
|
||||
params=[
|
||||
field("username", "path", "string", True, "alice", "Farm owner's username."),
|
||||
field("slot", "form", "integer", True, "0", "Plot slot index."),
|
||||
field("slot", "form", "integer", True, "0", "Plot slot index, 0-based."),
|
||||
],
|
||||
sample_response={"farm": {"owner_username": "alice"}, "stole_coins": 18},
|
||||
),
|
||||
@ -137,9 +212,9 @@ client can refresh without a second request.
|
||||
method="POST",
|
||||
path="/game/fertilize",
|
||||
title="Fertilize a build",
|
||||
summary="Spend coins to halve a growing build's remaining time. The cost scales with the build's realized harvest value, so fertilizing is a pure time-skip and never a profit at any prestige.",
|
||||
summary="Spend coins to halve a growing build's remaining time (the plot's fertilize_cost field is the exact price). The price is computed from the exact value this build will pay out - including the golden multiplier and any active contract, Underdog, or Canary upside - so fertilizing is a pure time-skip and never a profit, on any build, at any prestige, with any combination of boosts.",
|
||||
auth="user",
|
||||
params=[field("slot", "form", "integer", True, "0", "Plot slot index.")],
|
||||
params=[field("slot", "form", "integer", True, "0", "Plot slot index, 0-based.")],
|
||||
sample_response={"ok": True, "farm": {"coins": 12}},
|
||||
),
|
||||
endpoint(
|
||||
@ -147,7 +222,7 @@ client can refresh without a second request.
|
||||
method="POST",
|
||||
path="/game/daily",
|
||||
title="Claim daily bonus",
|
||||
summary="Claim the once-per-day coin bonus; consecutive days grow a streak.",
|
||||
summary="Claim the once-per-UTC-day coin bonus; consecutive days grow a streak (20 coins on day one up to 92 from day seven on), scaled by your own prestige and Tech Debt Payoff multiplier. A lapsed streak resets to day one - the farm state's daily_streak_reset flag and daily_reward already reflect that.",
|
||||
auth="user",
|
||||
sample_response={"ok": True, "farm": {"streak": 3, "coins": 94}},
|
||||
),
|
||||
@ -156,9 +231,9 @@ client can refresh without a second request.
|
||||
method="POST",
|
||||
path="/game/perk",
|
||||
title="Upgrade a perk",
|
||||
summary="Upgrade a permanent perk: yield, growth, discount, or xp.",
|
||||
summary="Upgrade a permanent perk with coins: yield (+5% harvest coins), growth (+4% build speed), discount (-3% planting cost), or xp (+5% harvest XP) per level. Perks reset on refactor.",
|
||||
auth="user",
|
||||
params=[field("perk", "form", "string", True, "growth", "Perk key.")],
|
||||
params=[field("perk", "form", "string", True, "growth", "Perk key.", options=PERK_KEYS)],
|
||||
sample_response={"ok": True, "farm": {"coins": 0}},
|
||||
),
|
||||
endpoint(
|
||||
@ -166,9 +241,20 @@ client can refresh without a second request.
|
||||
method="POST",
|
||||
path="/game/quests/claim",
|
||||
title="Claim a quest",
|
||||
summary="Claim a completed daily quest reward by its kind.",
|
||||
summary="Claim a completed daily quest by its kind, or (with scope=weekly, requires the Legacy Contracts Mastery upgrade) the weekly contract, which pays Stars plus a 48-hour +20% coin boost instead of coins.",
|
||||
auth="user",
|
||||
params=[field("quest", "form", "string", True, "harvest", "Quest kind.")],
|
||||
params=[
|
||||
field("quest", "form", "string", True, "harvest", "Quest kind.", options=QUEST_KINDS),
|
||||
field(
|
||||
"scope",
|
||||
"form",
|
||||
"string",
|
||||
False,
|
||||
"daily",
|
||||
"daily (default) or weekly.",
|
||||
options=QUEST_SCOPES,
|
||||
),
|
||||
],
|
||||
sample_response={"ok": True, "farm": {"coins": 130}},
|
||||
),
|
||||
endpoint(
|
||||
@ -176,20 +262,137 @@ client can refresh without a second request.
|
||||
method="POST",
|
||||
path="/game/prestige",
|
||||
title="Refactor (prestige)",
|
||||
summary="Reset the farm at level 10+ for a permanent +25% coin bonus and earn Stars to spend on Legacy upgrades.",
|
||||
summary="Reset the farm at level 10+ for a permanent +25% coin bonus and earn Stars to spend on Legacy upgrades. Refactoring costs a coin fee that scales with prestige and current wealth (the farm state's refactor_cost); the fee funds the community treasury and a fraction of the remaining coins (10% base, up to 35% with the Golden Parachute Legacy upgrade) carries over. From prestige 50 onward, every 10 more prestige also earns a permanent Mastery point.",
|
||||
auth="user",
|
||||
destructive=True,
|
||||
sample_response={"ok": True, "farm": {"prestige": 1}},
|
||||
sample_response={"ok": True, "farm": {"prestige": 1, "coins": 6550}},
|
||||
),
|
||||
endpoint(
|
||||
id="game-grant",
|
||||
method="POST",
|
||||
path="/game/grant",
|
||||
title="Claim the community grant",
|
||||
summary="Claim the weekly community grant, paid from the treasury filled by refactor fees and divided between everyone currently eligible (capped at 2500 coins, suppressed below 250). Eligible farms are active (5+ harvests this week), below 10000 coins, and at most prestige 5.",
|
||||
auth="user",
|
||||
sample_response={"ok": True, "farm": {"coins": 2550}},
|
||||
),
|
||||
endpoint(
|
||||
id="game-legacy",
|
||||
method="POST",
|
||||
path="/game/legacy",
|
||||
title="Buy a Legacy upgrade",
|
||||
summary="Spend Stars on a permanent Legacy upgrade that survives every refactor: autoharvest, multiplier, speed, plots, or defense.",
|
||||
summary="Spend Stars on a permanent Legacy upgrade that survives every refactor: autoharvest (CI Bot), multiplier (+10% coins/level), speed (+5% build speed/level), plots (+1 starting plot/level), defense (+30s grace, -5% steal loss/level), or carryover (Golden Parachute, +5% refactor carry-over/level).",
|
||||
auth="user",
|
||||
params=[field("key", "form", "string", True, "multiplier", "Legacy upgrade key.")],
|
||||
params=[
|
||||
field(
|
||||
"key",
|
||||
"form",
|
||||
"string",
|
||||
True,
|
||||
"multiplier",
|
||||
"Legacy upgrade key.",
|
||||
options=LEGACY_KEYS,
|
||||
)
|
||||
],
|
||||
sample_response={"ok": True, "farm": {"stars": 1}},
|
||||
),
|
||||
endpoint(
|
||||
id="game-mastery",
|
||||
method="POST",
|
||||
path="/game/mastery",
|
||||
title="Buy a Mastery upgrade",
|
||||
summary="Spend Mastery points (earned at prestige 50 and every 5 prestige after) on a permanent Mastery upgrade: autoreplant (Continuous Delivery, 3 points), analytics (Farm Analytics, 2 points), or contracts (Legacy Contracts, 4 points).",
|
||||
auth="user",
|
||||
params=[
|
||||
field(
|
||||
"key",
|
||||
"form",
|
||||
"string",
|
||||
True,
|
||||
"autoreplant",
|
||||
"Mastery upgrade key.",
|
||||
options=MASTERY_KEYS,
|
||||
)
|
||||
],
|
||||
sample_response={"ok": True, "farm": {"mastery_points": 0}},
|
||||
),
|
||||
endpoint(
|
||||
id="game-infrastructure-buy",
|
||||
method="POST",
|
||||
path="/game/infrastructure/buy",
|
||||
title="Buy Infrastructure",
|
||||
summary="Buy a permanent, expensive, prestige-gated Infrastructure building: registry (Rust/Compiler/Kernel build 15% faster; 3M coins, prestige 3), canary (12% chance to double a harvest, 6% to only refund its planting cost; 6M, prestige 8), or observability (caps what any raider can take from you at 20% of a build's value; 15M, prestige 15).",
|
||||
auth="user",
|
||||
params=[
|
||||
field(
|
||||
"key",
|
||||
"form",
|
||||
"string",
|
||||
True,
|
||||
"registry",
|
||||
"Infrastructure key.",
|
||||
options=INFRA_KEYS,
|
||||
)
|
||||
],
|
||||
sample_response={"ok": True, "farm": {"coins": 0}},
|
||||
),
|
||||
endpoint(
|
||||
id="game-defense-upgrade",
|
||||
method="POST",
|
||||
path="/game/defense/upgrade",
|
||||
title="Upgrade Defense",
|
||||
summary="Buy the next Defense tier (Firewall through Zero Trust Mesh; the farm state's defense_next_cost is the exact price). Each tier multiplicatively reduces what a raider takes from you and adds steal grace, but adds an ongoing daily coin upkeep of max(tier minimum, 0.2% of your balance). If you cannot pay, the tier decays by one level and only what you can afford is taken - your balance is never emptied.",
|
||||
auth="user",
|
||||
sample_response={"ok": True, "farm": {"defense_level": 1}},
|
||||
),
|
||||
endpoint(
|
||||
id="game-defense-downgrade",
|
||||
method="POST",
|
||||
path="/game/defense/downgrade",
|
||||
title="Downgrade Defense",
|
||||
summary="Drop your Defense down one tier to escape its daily upkeep. There is no refund. Available whenever defense_downgrade_available is true in the farm state.",
|
||||
auth="user",
|
||||
sample_response={"ok": True, "farm": {"defense_level": 0}},
|
||||
),
|
||||
endpoint(
|
||||
id="game-cosmetics-buy",
|
||||
method="POST",
|
||||
path="/game/cosmetics/buy",
|
||||
title="Buy a cosmetic",
|
||||
summary="Buy a purely cosmetic title or plot skin with coins. No gameplay effect. The farm state's cosmetics list carries each key, cost, and an owned flag.",
|
||||
auth="user",
|
||||
params=[
|
||||
field(
|
||||
"key",
|
||||
"form",
|
||||
"string",
|
||||
True,
|
||||
"title_architect",
|
||||
"Cosmetic key.",
|
||||
options=COSMETIC_KEYS,
|
||||
)
|
||||
],
|
||||
sample_response={"ok": True, "farm": {"coins": 0}},
|
||||
),
|
||||
endpoint(
|
||||
id="game-cosmetics-equip",
|
||||
method="POST",
|
||||
path="/game/cosmetics/equip",
|
||||
title="Equip a title",
|
||||
summary="Equip an owned title cosmetic so its display name shows next to your name on the leaderboard.",
|
||||
auth="user",
|
||||
params=[
|
||||
field(
|
||||
"key",
|
||||
"form",
|
||||
"string",
|
||||
True,
|
||||
"title_architect",
|
||||
"An owned title cosmetic key.",
|
||||
options=COSMETIC_KEYS,
|
||||
)
|
||||
],
|
||||
sample_response={"ok": True, "farm": {"active_title": "title_architect"}},
|
||||
),
|
||||
],
|
||||
}
|
||||
|
||||
@ -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},
|
||||
),
|
||||
],
|
||||
}
|
||||
|
||||
@ -41,9 +41,12 @@ four ways to sign requests.
|
||||
method="GET",
|
||||
path="/profile/{username}",
|
||||
title="View a profile",
|
||||
summary="Render a user profile, including an online-presence indicator (JSON exposes profile_online and profile_user.last_seen). Returns an HTML page.",
|
||||
summary="Render a user profile, including an online-presence indicator (JSON exposes profile_online, profile_user.last_seen, xp_next_level, and xp_progress_pct). Returns an HTML page.",
|
||||
auth="public",
|
||||
interactive=True,
|
||||
notes=[
|
||||
"Level progress: `xp_next_level = level * 100` (total XP needed), `xp_progress_pct = xp % 100` (percentage towards next level). Both are also embedded in `profile_user`.",
|
||||
],
|
||||
params=[
|
||||
field(
|
||||
"username",
|
||||
@ -320,7 +323,7 @@ four ways to sign requests.
|
||||
),
|
||||
field(
|
||||
"description",
|
||||
"body",
|
||||
"json",
|
||||
"string",
|
||||
True,
|
||||
"Great work on the release!",
|
||||
@ -793,3 +796,4 @@ four ways to sign requests.
|
||||
),
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@ -8,8 +8,15 @@ GROUP = {
|
||||
"intro": """
|
||||
# Web Push
|
||||
|
||||
Browser push notifications via the Web Push protocol. Fetch the public VAPID key, then
|
||||
register a `PushSubscription` obtained from the browser's `PushManager`.
|
||||
Push notifications are delivered by one or more providers. `webpush` is the default and
|
||||
implements the Web Push protocol: fetch the public VAPID key, then register a
|
||||
`PushSubscription` obtained from the browser's `PushManager`. `apns` delivers to an Apple
|
||||
Push Notification service device token and is only offered when an administrator has
|
||||
configured it.
|
||||
|
||||
`GET /push.json` lists the providers that currently accept registrations. A registration
|
||||
body without a `provider` field is a `webpush` registration, so existing clients need no
|
||||
change.
|
||||
|
||||
There is no server-side unsubscribe endpoint: unsubscription is handled entirely in the
|
||||
browser by calling `PushManager.unsubscribe()` on the subscription. The server stops delivering
|
||||
@ -26,39 +33,60 @@ four ways to sign requests.
|
||||
method="GET",
|
||||
path="/push.json",
|
||||
title="Get the public key",
|
||||
summary="Return the VAPID public key for subscribing.",
|
||||
summary="Return the VAPID public key and the providers that accept registrations.",
|
||||
auth="public",
|
||||
sample_response={"publicKey": "BASE64_VAPID_KEY"},
|
||||
sample_response={
|
||||
"publicKey": "BASE64_VAPID_KEY",
|
||||
"providers": {"webpush": {"publicKey": "BASE64_VAPID_KEY"}},
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="push-register",
|
||||
method="POST",
|
||||
path="/push.json",
|
||||
title="Register a subscription",
|
||||
summary="Register a browser push subscription. Sends a welcome notification.",
|
||||
summary="Register a push subscription. Sends a welcome notification.",
|
||||
auth="user",
|
||||
encoding="json",
|
||||
interactive=False,
|
||||
params=[
|
||||
field(
|
||||
"provider",
|
||||
"json",
|
||||
"string",
|
||||
False,
|
||||
"webpush",
|
||||
"Provider to register with. Omit for webpush.",
|
||||
),
|
||||
field(
|
||||
"endpoint",
|
||||
"json",
|
||||
"string",
|
||||
True,
|
||||
False,
|
||||
"https://fcm.googleapis.com/...",
|
||||
"Subscription endpoint URL.",
|
||||
"Subscription endpoint URL. Required for webpush.",
|
||||
),
|
||||
field(
|
||||
"keys",
|
||||
"json",
|
||||
"string",
|
||||
True,
|
||||
False,
|
||||
'{"p256dh":"...","auth":"..."}',
|
||||
"Subscription keys object.",
|
||||
"Subscription keys object. Required for webpush.",
|
||||
),
|
||||
field(
|
||||
"token",
|
||||
"json",
|
||||
"string",
|
||||
False,
|
||||
"a1b2c3...",
|
||||
"Hexadecimal device token. Required for apns.",
|
||||
),
|
||||
],
|
||||
notes=[
|
||||
'The body must be JSON: `{"endpoint": "...", "keys": {"p256dh": "...", "auth": "..."}}`.'
|
||||
'A webpush body is JSON: `{"endpoint": "...", "keys": {"p256dh": "...", "auth": "..."}}`.',
|
||||
'An APNs body is JSON: `{"provider": "apns", "token": "..."}`.',
|
||||
"A provider that is unknown, disabled or unconfigured returns 400.",
|
||||
],
|
||||
sample_response={"registered": True},
|
||||
),
|
||||
|
||||
556
devplacepy/docs_api/groups/quizzes.py
Normal file
556
devplacepy/docs_api/groups/quizzes.py
Normal file
@ -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,
|
||||
@ -114,6 +115,7 @@ from devplacepy.services.containers.service import ContainerService
|
||||
from devplacepy.services.xmlrpc import XmlrpcService
|
||||
from devplacepy.services.audit import AuditService
|
||||
from devplacepy.services.audit import record as audit
|
||||
from devplacepy.services.push import PushService
|
||||
from devplacepy.services.telegram import TelegramService
|
||||
from devplacepy.services.telegram.outbox_service import TelegramOutboxService
|
||||
|
||||
@ -274,6 +276,7 @@ async def lifespan(app: FastAPI):
|
||||
service_manager.register(ContainerService())
|
||||
service_manager.register(XmlrpcService())
|
||||
service_manager.register(AuditService())
|
||||
service_manager.register(PushService())
|
||||
service_manager.register(TelegramService())
|
||||
service_manager.register(TelegramOutboxService())
|
||||
if not os.environ.get("DEVPLACE_DISABLE_SERVICES"):
|
||||
@ -463,6 +466,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")
|
||||
|
||||
@ -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
|
||||
|
||||
52
devplacepy/push/CLAUDE.md
Normal file
52
devplacepy/push/CLAUDE.md
Normal file
@ -0,0 +1,52 @@
|
||||
This file documents `devplacepy/push/` - push notification delivery and its provider architecture. Claude Code loads it automatically whenever a file under this directory is read or edited.
|
||||
|
||||
## What this package is
|
||||
|
||||
One delivery library behind one public surface. `devplacepy.push` exports `notify_user`, `register`, `ensure_certificates`, `public_key_standard_b64` and the Web Push crypto helpers; every caller in the codebase (`main.py`, `utils/notifications.py`, `routers/push.py`) imports only those names. Everything else is internal to the package.
|
||||
|
||||
| Module | Role |
|
||||
|---|---|
|
||||
| `providers/base.py` | `PushProvider` protocol, the `Delivery` outcome and the three outcome constants |
|
||||
| `providers/webpush.py` | VAPID key material, `aesgcm` payload encryption, the Web Push provider |
|
||||
| `providers/apns.py` | Apple Push Notification service provider (token based, HTTP/2) |
|
||||
| `providers/__init__.py` | `PROVIDERS` registry, `get`, `active`, `is_active`, `admin_fields`, `client_config` |
|
||||
| `store.py` | Every `push_registration` read and write |
|
||||
| `delivery.py` | `notify_user`: group by provider, one shared client, one prepared body per provider |
|
||||
|
||||
The admin configuration surface lives in `devplacepy/services/push/service.py` (`PushService`), not here.
|
||||
|
||||
## Adding a provider
|
||||
|
||||
1. Write `providers/<name>.py` with a `PushProvider` subclass: `name`, `label`, `config_fields`, `is_configured`, `parse_registration`, `prepare`, `deliver`, optionally `client_config`.
|
||||
2. Add one entry to `PROVIDERS` in `providers/__init__.py`.
|
||||
|
||||
That is the whole change. The registration route, the delivery loop, the admin page, the audit record, the metrics and the docs are written against the protocol and need no edit. The `Enabled` toggle (`push_<name>_enabled`) comes from the base class, so a provider never declares its own.
|
||||
|
||||
## Invariants
|
||||
|
||||
- **Zero cost for the request.** Delivery is reached only through `utils/notifications.py` `_schedule_push`, a fire-and-forget task. Never make a route await `notify_user`, and never add a queue or a table to this path.
|
||||
- **`deliver` never raises.** Return `Delivery(REJECTED, detail)` instead. `delivery.py` guards anyway, but a raising provider costs a log line per subscription.
|
||||
- **A provider that is not configured is inert, never an error.** `is_configured()` is false, `is_active()` is false, the delivery loop skips it, and `POST /push.json` refuses a registration for it with 400. Nothing else in the platform notices.
|
||||
- **`DEAD` is the only outcome that touches the database.** It soft-deletes the registration (`deleted_at`), exactly like a `404`/`410` Web Push endpoint always did. `REJECTED` keeps the row.
|
||||
- **Every insert writes `deleted_at: None`,** and every read filters `deleted_at IS NULL`. `push_registration` deliberately stays out of `SOFT_DELETE_TABLES` (no `deleted_by`, not restorable from Trash) - a dead device token has no owner action to undo.
|
||||
- **A row without a provider is a Web Push row.** `store.provider_of` resolves `None`/`""` to `DEFAULT_PROVIDER`, so a row written by an old worker during a deploy still delivers. `init_db` backfills the column once with a single converging `UPDATE`.
|
||||
|
||||
## Storage
|
||||
|
||||
`push_registration` columns are ensured in `init_db` (`database/schema.py`) because `dataset` only creates the columns of a table's first insert, and `find(provider=...)` against a missing column matches nothing.
|
||||
|
||||
| Column | webpush | apns |
|
||||
|---|---|---|
|
||||
| `provider` | `webpush` | `apns` |
|
||||
| `endpoint`, `key_auth`, `key_p256dh` | set | `NULL` |
|
||||
| `token` | `NULL` | device token |
|
||||
|
||||
Deduplication is generic: `store.register` looks up `user_uid` + `provider` + exactly the fields the provider's `parse_registration` returned, so a provider never writes its own identity rule.
|
||||
|
||||
## APNs specifics
|
||||
|
||||
- `POST https://{host}/3/device/{token}` over HTTP/2, host from `push_apns_environment` (`api.push.apple.com` or `api.sandbox.push.apple.com`; an unrecognised value falls back to production). HTTP/2 comes from `stealth_async_client` because the origin is `https` - the cleartext downgrade in `curl_transport` does not apply.
|
||||
- Provider token: `ES256`, header `kid` = key id, claims `iss` = team id and `iat`. Cached per credential fingerprint for 45 minutes, so a worker signs at most one token per 45 minutes; Apple refuses tokens regenerated faster than every 20 minutes. Editing any credential changes the fingerprint and takes effect on the next delivery, with no restart.
|
||||
- A `.p8` that does not parse is cached as a failure for the same window, so a misconfiguration costs one error log per window rather than one parse per notification.
|
||||
- `410`, or any status carrying reason `BadDeviceToken`, `Unregistered`, `ExpiredToken`, `DeviceTokenNotForTopic` or `TopicDisallowed`, is `DEAD`. Everything else is `REJECTED`.
|
||||
- The shared payload dict (`title`, `message`, `icon`, `url`) is translated once per batch into `aps.alert` plus the custom `url`/`icon` keys, mirroring what `service-worker.js` does for Web Push. `thread-id` mirrors the service worker's notification `tag`.
|
||||
29
devplacepy/push/__init__.py
Normal file
29
devplacepy/push/__init__.py
Normal file
@ -0,0 +1,29 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from devplacepy.push.delivery import notify_user
|
||||
from devplacepy.push.providers.webpush import (
|
||||
browser_base64,
|
||||
create_notification_authorization,
|
||||
create_notification_info_with_payload,
|
||||
ensure_certificates,
|
||||
generate_pkcs8_private_key,
|
||||
generate_private_key,
|
||||
generate_public_key,
|
||||
hkdf,
|
||||
public_key_standard_b64,
|
||||
)
|
||||
from devplacepy.push.store import register
|
||||
|
||||
__all__ = [
|
||||
"browser_base64",
|
||||
"create_notification_authorization",
|
||||
"create_notification_info_with_payload",
|
||||
"ensure_certificates",
|
||||
"generate_pkcs8_private_key",
|
||||
"generate_private_key",
|
||||
"generate_public_key",
|
||||
"hkdf",
|
||||
"notify_user",
|
||||
"public_key_standard_b64",
|
||||
"register",
|
||||
]
|
||||
83
devplacepy/push/delivery.py
Normal file
83
devplacepy/push/delivery.py
Normal file
@ -0,0 +1,83 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from devplacepy import stealth
|
||||
from devplacepy.database import get_int_setting
|
||||
from devplacepy.push import providers, store
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TIMEOUT_KEY = "push_delivery_timeout_seconds"
|
||||
DEFAULT_TIMEOUT_SECONDS = 10
|
||||
MIN_TIMEOUT_SECONDS = 1
|
||||
MAX_TIMEOUT_SECONDS = 120
|
||||
|
||||
|
||||
def timeout_seconds() -> float:
|
||||
seconds = get_int_setting(TIMEOUT_KEY, DEFAULT_TIMEOUT_SECONDS)
|
||||
return float(min(max(seconds, MIN_TIMEOUT_SECONDS), MAX_TIMEOUT_SECONDS))
|
||||
|
||||
|
||||
def group_by_provider(
|
||||
registrations: list[dict[str, Any]],
|
||||
) -> dict[str, list[dict[str, Any]]]:
|
||||
grouped: dict[str, list[dict[str, Any]]] = {}
|
||||
for registration in registrations:
|
||||
grouped.setdefault(store.provider_of(registration), []).append(registration)
|
||||
return grouped
|
||||
|
||||
|
||||
async def notify_user(user_uid: str, payload: dict[str, Any]) -> None:
|
||||
registrations = store.active_for_user(user_uid)
|
||||
if not registrations:
|
||||
logger.debug("No active push subscriptions for user %s", user_uid)
|
||||
return
|
||||
|
||||
grouped = group_by_provider(registrations)
|
||||
async with stealth.stealth_async_client(timeout=timeout_seconds()) as client:
|
||||
for name, rows in grouped.items():
|
||||
provider = providers.PROVIDERS.get(name)
|
||||
if provider is None:
|
||||
logger.warning(
|
||||
"Unknown push provider %s on %s subscriptions of user %s",
|
||||
name,
|
||||
len(rows),
|
||||
user_uid,
|
||||
)
|
||||
continue
|
||||
if not providers.is_active(provider):
|
||||
logger.debug(
|
||||
"Push provider %s is not active; skipping %s subscriptions",
|
||||
name,
|
||||
len(rows),
|
||||
)
|
||||
continue
|
||||
try:
|
||||
prepared = provider.prepare(payload)
|
||||
except Exception as exc:
|
||||
logger.error("Push provider %s could not build a payload: %s", name, exc)
|
||||
continue
|
||||
for registration in rows:
|
||||
await _deliver_one(provider, client, registration, prepared, user_uid)
|
||||
|
||||
|
||||
async def _deliver_one(provider, client, registration, prepared, user_uid) -> None:
|
||||
try:
|
||||
outcome = await provider.deliver(client, registration, prepared)
|
||||
except Exception as exc:
|
||||
logger.error("Push provider %s raised for %s: %s", provider.name, user_uid, exc)
|
||||
return
|
||||
if outcome.status == providers.ACCEPTED:
|
||||
logger.debug("Push delivered to %s via %s", user_uid, provider.name)
|
||||
return
|
||||
if outcome.status == providers.DEAD:
|
||||
try:
|
||||
store.mark_dead(registration["id"])
|
||||
except Exception as exc:
|
||||
logger.error("Could not soft-delete push subscription: %s", exc)
|
||||
return
|
||||
logger.warning(
|
||||
"Push rejected by %s for %s: %s", provider.name, user_uid, outcome.detail
|
||||
)
|
||||
76
devplacepy/push/providers/__init__.py
Normal file
76
devplacepy/push/providers/__init__.py
Normal file
@ -0,0 +1,76 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from devplacepy.push.providers.apns import ApnsProvider
|
||||
from devplacepy.push.providers.base import (
|
||||
ACCEPTED,
|
||||
DEAD,
|
||||
REJECTED,
|
||||
Delivery,
|
||||
PushProvider,
|
||||
)
|
||||
from devplacepy.push.providers.webpush import WebPushProvider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_PROVIDER = WebPushProvider.name
|
||||
|
||||
PROVIDERS: dict[str, PushProvider] = {
|
||||
provider.name: provider for provider in (WebPushProvider(), ApnsProvider())
|
||||
}
|
||||
|
||||
__all__ = [
|
||||
"ACCEPTED",
|
||||
"DEAD",
|
||||
"DEFAULT_PROVIDER",
|
||||
"Delivery",
|
||||
"PROVIDERS",
|
||||
"PushProvider",
|
||||
"REJECTED",
|
||||
"active",
|
||||
"admin_fields",
|
||||
"client_config",
|
||||
"get",
|
||||
"is_active",
|
||||
"names",
|
||||
]
|
||||
|
||||
|
||||
def get(name: str | None) -> PushProvider | None:
|
||||
if not isinstance(name, str):
|
||||
name = ""
|
||||
return PROVIDERS.get(name.strip().lower() or DEFAULT_PROVIDER)
|
||||
|
||||
|
||||
def names() -> list[str]:
|
||||
return list(PROVIDERS)
|
||||
|
||||
|
||||
def active() -> list[PushProvider]:
|
||||
return [provider for provider in PROVIDERS.values() if is_active(provider)]
|
||||
|
||||
|
||||
def admin_fields() -> list:
|
||||
return [field for provider in PROVIDERS.values() for field in provider.all_fields()]
|
||||
|
||||
|
||||
def client_config() -> dict[str, Any]:
|
||||
return {provider.name: _client_config(provider) for provider in active()}
|
||||
|
||||
|
||||
def is_active(provider: PushProvider) -> bool:
|
||||
try:
|
||||
return provider.is_active()
|
||||
except Exception as exc:
|
||||
logger.error("Push provider %s failed its readiness check: %s", provider.name, exc)
|
||||
return False
|
||||
|
||||
|
||||
def _client_config(provider: PushProvider) -> dict[str, Any]:
|
||||
try:
|
||||
return provider.client_config()
|
||||
except Exception as exc:
|
||||
logger.error("Push provider %s failed to describe itself: %s", provider.name, exc)
|
||||
return {}
|
||||
243
devplacepy/push/providers/apns.py
Normal file
243
devplacepy/push/providers/apns.py
Normal file
@ -0,0 +1,243 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import string
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import jwt
|
||||
|
||||
from devplacepy.config import SECONDS_PER_DAY
|
||||
from devplacepy.database import get_setting
|
||||
from devplacepy.push.providers.base import (
|
||||
ACCEPTED,
|
||||
DEAD,
|
||||
REJECTED,
|
||||
Delivery,
|
||||
PushProvider,
|
||||
)
|
||||
from devplacepy.services.base import ConfigField
|
||||
from devplacepy.utils import DEFAULT_PUSH_URL, PUSH_ICON, generate_uid
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TEAM_ID_KEY = "push_apns_team_id"
|
||||
KEY_ID_KEY = "push_apns_key_id"
|
||||
AUTH_KEY_KEY = "push_apns_auth_key"
|
||||
TOPIC_KEY = "push_apns_topic"
|
||||
ENVIRONMENT_KEY = "push_apns_environment"
|
||||
|
||||
PROVIDER_LABEL = "Apple Push (APNs)"
|
||||
DEFAULT_ENVIRONMENT = "production"
|
||||
HOSTS = {
|
||||
"production": "api.push.apple.com",
|
||||
"sandbox": "api.sandbox.push.apple.com",
|
||||
}
|
||||
ENVIRONMENT_OPTIONS = [
|
||||
{"value": "production", "label": "Production"},
|
||||
{"value": "sandbox", "label": "Sandbox"},
|
||||
]
|
||||
|
||||
TOKEN_REFRESH_SECONDS = 45 * 60
|
||||
TOKEN_MIN_LENGTH = 64
|
||||
TOKEN_MAX_LENGTH = 200
|
||||
THREAD_ID = "devplace-notification"
|
||||
PUSH_TYPE = "alert"
|
||||
PRIORITY = "10"
|
||||
DEAD_REASONS = frozenset(
|
||||
{
|
||||
"BadDeviceToken",
|
||||
"DeviceTokenNotForTopic",
|
||||
"ExpiredToken",
|
||||
"Unregistered",
|
||||
"TopicDisallowed",
|
||||
}
|
||||
)
|
||||
|
||||
_token_state: dict[str, Any] = {}
|
||||
|
||||
|
||||
def _setting(key: str) -> str:
|
||||
return get_setting(key, "").strip()
|
||||
|
||||
|
||||
def _environment() -> str:
|
||||
value = _setting(ENVIRONMENT_KEY) or DEFAULT_ENVIRONMENT
|
||||
return value if value in HOSTS else DEFAULT_ENVIRONMENT
|
||||
|
||||
|
||||
def host() -> str:
|
||||
return HOSTS[_environment()]
|
||||
|
||||
|
||||
def _fingerprint(team_id: str, key_id: str, auth_key: str) -> str:
|
||||
return hashlib.sha256(f"{team_id}:{key_id}:{auth_key}".encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def provider_token(team_id: str, key_id: str, auth_key: str) -> str:
|
||||
fingerprint = _fingerprint(team_id, key_id, auth_key)
|
||||
issued_at = int(time.time())
|
||||
state = _token_state.get("current")
|
||||
if (
|
||||
state
|
||||
and state["fingerprint"] == fingerprint
|
||||
and issued_at - state["issued_at"] < TOKEN_REFRESH_SECONDS
|
||||
):
|
||||
if state["token"] is None:
|
||||
raise ValueError(state["error"])
|
||||
return state["token"]
|
||||
try:
|
||||
token = jwt.encode(
|
||||
{"iss": team_id, "iat": issued_at},
|
||||
auth_key,
|
||||
algorithm="ES256",
|
||||
headers={"kid": key_id},
|
||||
)
|
||||
except Exception as exc:
|
||||
message = f"APNs auth key is not usable: {exc}"
|
||||
_token_state["current"] = {
|
||||
"token": None,
|
||||
"error": message,
|
||||
"issued_at": issued_at,
|
||||
"fingerprint": fingerprint,
|
||||
}
|
||||
logger.error(message)
|
||||
raise ValueError(message) from exc
|
||||
_token_state["current"] = {
|
||||
"token": token,
|
||||
"error": "",
|
||||
"issued_at": issued_at,
|
||||
"fingerprint": fingerprint,
|
||||
}
|
||||
return token
|
||||
|
||||
|
||||
def _reason(response: httpx.Response) -> str:
|
||||
try:
|
||||
body = response.json()
|
||||
except ValueError:
|
||||
return ""
|
||||
if isinstance(body, dict):
|
||||
return str(body.get("reason", ""))
|
||||
return ""
|
||||
|
||||
|
||||
class ApnsProvider(PushProvider):
|
||||
name = "apns"
|
||||
label = PROVIDER_LABEL
|
||||
config_fields = [
|
||||
ConfigField(
|
||||
TEAM_ID_KEY,
|
||||
"Team ID",
|
||||
type="str",
|
||||
default="",
|
||||
help="Ten character Apple Developer team identifier, used as the token iss claim.",
|
||||
group=PROVIDER_LABEL,
|
||||
),
|
||||
ConfigField(
|
||||
KEY_ID_KEY,
|
||||
"Key ID",
|
||||
type="str",
|
||||
default="",
|
||||
help="Ten character identifier of the APNs auth key, sent as the token kid header.",
|
||||
group=PROVIDER_LABEL,
|
||||
),
|
||||
ConfigField(
|
||||
AUTH_KEY_KEY,
|
||||
"Auth key (.p8)",
|
||||
type="text",
|
||||
default="",
|
||||
secret=True,
|
||||
help="Contents of the APNs .p8 signing key, including the BEGIN and END lines. Leave blank to keep the stored key.",
|
||||
group=PROVIDER_LABEL,
|
||||
),
|
||||
ConfigField(
|
||||
TOPIC_KEY,
|
||||
"Topic",
|
||||
type="str",
|
||||
default="",
|
||||
help="Bundle identifier of the receiving app, sent as the apns-topic header.",
|
||||
group=PROVIDER_LABEL,
|
||||
),
|
||||
ConfigField(
|
||||
ENVIRONMENT_KEY,
|
||||
"Environment",
|
||||
type="select",
|
||||
default=DEFAULT_ENVIRONMENT,
|
||||
options=ENVIRONMENT_OPTIONS,
|
||||
help="Production delivers to App Store builds, sandbox to development builds.",
|
||||
group=PROVIDER_LABEL,
|
||||
),
|
||||
]
|
||||
|
||||
def is_configured(self) -> bool:
|
||||
return bool(
|
||||
_setting(TEAM_ID_KEY)
|
||||
and _setting(KEY_ID_KEY)
|
||||
and _setting(AUTH_KEY_KEY)
|
||||
and _setting(TOPIC_KEY)
|
||||
)
|
||||
|
||||
def parse_registration(self, body: dict[str, Any]) -> dict[str, Any] | None:
|
||||
token = body.get("token")
|
||||
if not isinstance(token, str):
|
||||
return None
|
||||
token = token.strip()
|
||||
if not TOKEN_MIN_LENGTH <= len(token) <= TOKEN_MAX_LENGTH:
|
||||
return None
|
||||
if any(character not in string.hexdigits for character in token):
|
||||
return None
|
||||
return {"token": token}
|
||||
|
||||
def prepare(self, payload: dict[str, Any]) -> str:
|
||||
return json.dumps(
|
||||
{
|
||||
"aps": {
|
||||
"alert": {
|
||||
"title": payload.get("title") or "DevPlace",
|
||||
"body": payload.get("message") or "",
|
||||
},
|
||||
"sound": "default",
|
||||
"thread-id": THREAD_ID,
|
||||
},
|
||||
"url": payload.get("url") or DEFAULT_PUSH_URL,
|
||||
"icon": payload.get("icon") or PUSH_ICON,
|
||||
}
|
||||
)
|
||||
|
||||
def headers(self) -> dict[str, str]:
|
||||
return {
|
||||
"authorization": f"bearer {provider_token(_setting(TEAM_ID_KEY), _setting(KEY_ID_KEY), _setting(AUTH_KEY_KEY))}",
|
||||
"apns-topic": _setting(TOPIC_KEY),
|
||||
"apns-push-type": PUSH_TYPE,
|
||||
"apns-priority": PRIORITY,
|
||||
"apns-expiration": str(int(time.time()) + SECONDS_PER_DAY),
|
||||
"apns-id": generate_uid(),
|
||||
"content-type": "application/json",
|
||||
}
|
||||
|
||||
async def deliver(
|
||||
self, client: httpx.AsyncClient, registration: dict[str, Any], prepared: str
|
||||
) -> Delivery:
|
||||
token = (registration.get("token") or "").strip()
|
||||
if not token:
|
||||
return Delivery(DEAD, "missing device token")
|
||||
try:
|
||||
headers = self.headers()
|
||||
response = await client.post(
|
||||
f"https://{host()}/3/device/{token}",
|
||||
headers=headers,
|
||||
content=prepared.encode("utf-8"),
|
||||
)
|
||||
except (httpx.HTTPError, ValueError) as exc:
|
||||
return Delivery(REJECTED, str(exc))
|
||||
if response.status_code == 200:
|
||||
return Delivery(ACCEPTED)
|
||||
reason = _reason(response)
|
||||
detail = f"{response.status_code} {reason}".strip()
|
||||
if response.status_code == 410 or reason in DEAD_REASONS:
|
||||
return Delivery(DEAD, detail)
|
||||
return Delivery(REJECTED, detail)
|
||||
66
devplacepy/push/providers/base.py
Normal file
66
devplacepy/push/providers/base.py
Normal file
@ -0,0 +1,66 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from devplacepy.database import get_setting
|
||||
from devplacepy.services.base import ConfigField
|
||||
|
||||
ACCEPTED = "accepted"
|
||||
DEAD = "dead"
|
||||
REJECTED = "rejected"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Delivery:
|
||||
status: str
|
||||
detail: str = ""
|
||||
|
||||
|
||||
class PushProvider(ABC):
|
||||
name = ""
|
||||
label = ""
|
||||
config_fields: list[ConfigField] = []
|
||||
|
||||
@property
|
||||
def enabled_key(self) -> str:
|
||||
return f"push_{self.name}_enabled"
|
||||
|
||||
def enabled_field(self) -> ConfigField:
|
||||
return ConfigField(
|
||||
self.enabled_key,
|
||||
"Enabled",
|
||||
type="bool",
|
||||
default=True,
|
||||
help=f"Deliver notifications through {self.label}.",
|
||||
group=self.label,
|
||||
)
|
||||
|
||||
def all_fields(self) -> list[ConfigField]:
|
||||
return [self.enabled_field(), *self.config_fields]
|
||||
|
||||
def is_enabled(self) -> bool:
|
||||
return get_setting(self.enabled_key, "1") == "1"
|
||||
|
||||
def is_active(self) -> bool:
|
||||
return self.is_enabled() and self.is_configured()
|
||||
|
||||
def client_config(self) -> dict[str, Any]:
|
||||
return {}
|
||||
|
||||
@abstractmethod
|
||||
def is_configured(self) -> bool: ...
|
||||
|
||||
@abstractmethod
|
||||
def parse_registration(self, body: dict[str, Any]) -> dict[str, Any] | None: ...
|
||||
|
||||
@abstractmethod
|
||||
def prepare(self, payload: dict[str, Any]) -> str: ...
|
||||
|
||||
@abstractmethod
|
||||
async def deliver(
|
||||
self, client: httpx.AsyncClient, registration: dict[str, Any], prepared: str
|
||||
) -> Delivery: ...
|
||||
@ -7,7 +7,6 @@ import logging
|
||||
import os
|
||||
import random
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
@ -20,7 +19,6 @@ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
from cryptography.hazmat.primitives.hashes import SHA256
|
||||
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
||||
|
||||
from devplacepy import stealth
|
||||
from devplacepy.config import (
|
||||
SECONDS_PER_DAY,
|
||||
VAPID_PRIVATE_KEY_FILE,
|
||||
@ -28,7 +26,15 @@ from devplacepy.config import (
|
||||
VAPID_PUBLIC_KEY_FILE,
|
||||
VAPID_SUB,
|
||||
)
|
||||
from devplacepy.database import get_table
|
||||
from devplacepy.database import get_setting
|
||||
from devplacepy.push.providers.base import (
|
||||
ACCEPTED,
|
||||
DEAD,
|
||||
REJECTED,
|
||||
Delivery,
|
||||
PushProvider,
|
||||
)
|
||||
from devplacepy.services.base import ConfigField
|
||||
from devplacepy.utils import generate_uid
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -37,6 +43,8 @@ JWT_LIFETIME_SECONDS = 60 * 60
|
||||
PUSH_TTL_SECONDS = str(SECONDS_PER_DAY)
|
||||
DEAD_SUBSCRIPTION_STATUSES = (404, 410)
|
||||
ACCEPTED_STATUSES = (200, 201)
|
||||
SUBJECT_KEY = "push_webpush_subject"
|
||||
PROVIDER_LABEL = "Web Push (VAPID)"
|
||||
|
||||
|
||||
def generate_private_key() -> None:
|
||||
@ -149,13 +157,17 @@ def public_key_standard_b64() -> str:
|
||||
return base64.b64encode(point).decode("utf-8").rstrip("=")
|
||||
|
||||
|
||||
def subject() -> str:
|
||||
return get_setting(SUBJECT_KEY, "").strip() or VAPID_SUB
|
||||
|
||||
|
||||
def create_notification_authorization(push_url: str) -> str:
|
||||
target = urlparse(push_url)
|
||||
audience = f"{target.scheme}://{target.netloc}"
|
||||
issued_at = int(time.time())
|
||||
return jwt.encode(
|
||||
{
|
||||
"sub": VAPID_SUB,
|
||||
"sub": subject(),
|
||||
"aud": audience,
|
||||
"exp": issued_at + JWT_LIFETIME_SECONDS,
|
||||
"nbf": issued_at,
|
||||
@ -223,78 +235,76 @@ def create_notification_info_with_payload(
|
||||
}
|
||||
|
||||
|
||||
def _mark_subscription_dead(subscription_id: int) -> None:
|
||||
get_table("push_registration").update(
|
||||
{"id": subscription_id, "deleted_at": datetime.now(timezone.utc).isoformat()},
|
||||
["id"],
|
||||
class WebPushProvider(PushProvider):
|
||||
name = "webpush"
|
||||
label = PROVIDER_LABEL
|
||||
config_fields = [
|
||||
ConfigField(
|
||||
SUBJECT_KEY,
|
||||
"VAPID subject",
|
||||
type="str",
|
||||
default=VAPID_SUB,
|
||||
help="Contact sent as the JWT sub claim, a mailto: or https: URL. Blank uses the built-in default.",
|
||||
group=PROVIDER_LABEL,
|
||||
)
|
||||
logger.info("Soft-deleted dead push subscription id=%s", subscription_id)
|
||||
]
|
||||
|
||||
def is_configured(self) -> bool:
|
||||
return True
|
||||
|
||||
async def notify_user(user_uid: str, payload: dict[str, Any]) -> None:
|
||||
registrations = list(
|
||||
get_table("push_registration").find(user_uid=user_uid, deleted_at=None)
|
||||
)
|
||||
if not registrations:
|
||||
logger.debug("No active push subscriptions for user %s", user_uid)
|
||||
return
|
||||
def client_config(self) -> dict[str, Any]:
|
||||
try:
|
||||
return {"publicKey": public_key_standard_b64()}
|
||||
except Exception as exc:
|
||||
logger.error("VAPID key material unavailable: %s", exc)
|
||||
return {}
|
||||
|
||||
body = json.dumps(payload)
|
||||
async with stealth.stealth_async_client(timeout=10.0) as client:
|
||||
for subscription in registrations:
|
||||
endpoint = subscription["endpoint"]
|
||||
def parse_registration(self, body: dict[str, Any]) -> dict[str, Any] | None:
|
||||
keys = body.get("keys")
|
||||
if not isinstance(keys, dict):
|
||||
return None
|
||||
endpoint = body.get("endpoint")
|
||||
key_auth = keys.get("auth")
|
||||
key_p256dh = keys.get("p256dh")
|
||||
if not (
|
||||
isinstance(endpoint, str)
|
||||
and isinstance(key_auth, str)
|
||||
and isinstance(key_p256dh, str)
|
||||
and endpoint
|
||||
and key_auth
|
||||
and key_p256dh
|
||||
):
|
||||
return None
|
||||
return {
|
||||
"endpoint": endpoint,
|
||||
"key_auth": key_auth,
|
||||
"key_p256dh": key_p256dh,
|
||||
}
|
||||
|
||||
def prepare(self, payload: dict[str, Any]) -> str:
|
||||
return json.dumps(payload)
|
||||
|
||||
async def deliver(
|
||||
self, client: httpx.AsyncClient, registration: dict[str, Any], prepared: str
|
||||
) -> Delivery:
|
||||
endpoint = registration.get("endpoint") or ""
|
||||
if not endpoint:
|
||||
return Delivery(DEAD, "missing endpoint")
|
||||
try:
|
||||
notification_payload = create_notification_info_with_payload(
|
||||
endpoint,
|
||||
subscription["key_auth"],
|
||||
subscription["key_p256dh"],
|
||||
body,
|
||||
registration["key_auth"],
|
||||
registration["key_p256dh"],
|
||||
prepared,
|
||||
)
|
||||
headers = {**notification_payload["headers"], "TTL": PUSH_TTL_SECONDS}
|
||||
response = await client.post(
|
||||
endpoint, headers=headers, content=notification_payload["data"]
|
||||
)
|
||||
except (httpx.HTTPError, ValueError) as exc:
|
||||
logger.warning("Push error for %s via %s: %s", user_uid, endpoint, exc)
|
||||
continue
|
||||
|
||||
except (httpx.HTTPError, ValueError, KeyError, TypeError) as exc:
|
||||
return Delivery(REJECTED, str(exc))
|
||||
if response.status_code in ACCEPTED_STATUSES:
|
||||
logger.debug("Push delivered to %s via %s", user_uid, endpoint)
|
||||
elif response.status_code in DEAD_SUBSCRIPTION_STATUSES:
|
||||
_mark_subscription_dead(subscription["id"])
|
||||
else:
|
||||
logger.warning(
|
||||
"Push rejected (%s) for %s via %s",
|
||||
response.status_code,
|
||||
user_uid,
|
||||
endpoint,
|
||||
)
|
||||
|
||||
|
||||
async def register(
|
||||
user_uid: str, endpoint: str, key_auth: str, key_p256dh: str
|
||||
) -> tuple[dict[str, Any], bool]:
|
||||
table = get_table("push_registration")
|
||||
existing = table.find_one(
|
||||
user_uid=user_uid,
|
||||
endpoint=endpoint,
|
||||
key_auth=key_auth,
|
||||
key_p256dh=key_p256dh,
|
||||
deleted_at=None,
|
||||
)
|
||||
if existing:
|
||||
logger.debug("Push subscription already registered for user %s", user_uid)
|
||||
return existing, False
|
||||
|
||||
record = {
|
||||
"uid": generate_uid(),
|
||||
"user_uid": user_uid,
|
||||
"endpoint": endpoint,
|
||||
"key_auth": key_auth,
|
||||
"key_p256dh": key_p256dh,
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
"deleted_at": None,
|
||||
}
|
||||
table.insert(record)
|
||||
logger.info("Registered push subscription for user %s", user_uid)
|
||||
return record, True
|
||||
return Delivery(ACCEPTED)
|
||||
if response.status_code in DEAD_SUBSCRIPTION_STATUSES:
|
||||
return Delivery(DEAD, str(response.status_code))
|
||||
return Delivery(REJECTED, str(response.status_code))
|
||||
83
devplacepy/push/store.py
Normal file
83
devplacepy/push/store.py
Normal file
@ -0,0 +1,83 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from devplacepy.database import db, get_table
|
||||
from devplacepy.push.providers import DEFAULT_PROVIDER
|
||||
from devplacepy.utils import generate_uid
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TABLE = "push_registration"
|
||||
|
||||
|
||||
def table():
|
||||
return get_table(TABLE)
|
||||
|
||||
|
||||
def provider_of(registration: dict[str, Any]) -> str:
|
||||
return registration.get("provider") or DEFAULT_PROVIDER
|
||||
|
||||
|
||||
def active_for_user(user_uid: str) -> list[dict[str, Any]]:
|
||||
return list(table().find(user_uid=user_uid, deleted_at=None))
|
||||
|
||||
|
||||
def register(
|
||||
user_uid: str, provider: str, fields: dict[str, Any]
|
||||
) -> tuple[dict[str, Any], bool]:
|
||||
registrations = table()
|
||||
existing = registrations.find_one(
|
||||
user_uid=user_uid, provider=provider, deleted_at=None, **fields
|
||||
)
|
||||
if existing:
|
||||
logger.debug("Push subscription already registered for user %s", user_uid)
|
||||
return existing, False
|
||||
|
||||
record = {
|
||||
"uid": generate_uid(),
|
||||
"user_uid": user_uid,
|
||||
"provider": provider,
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
"deleted_at": None,
|
||||
**fields,
|
||||
}
|
||||
registrations.insert(record)
|
||||
logger.info("Registered %s push subscription for user %s", provider, user_uid)
|
||||
return record, True
|
||||
|
||||
|
||||
def mark_dead(registration_id: int) -> None:
|
||||
table().update(
|
||||
{"id": registration_id, "deleted_at": datetime.now(timezone.utc).isoformat()},
|
||||
["id"],
|
||||
)
|
||||
logger.info("Soft-deleted dead push subscription id=%s", registration_id)
|
||||
|
||||
|
||||
def prune(cutoff: str) -> int:
|
||||
if TABLE not in db.tables:
|
||||
return 0
|
||||
rows = list(table().find(deleted_at={"<": cutoff}))
|
||||
if not rows:
|
||||
return 0
|
||||
table().delete(deleted_at={"<": cutoff})
|
||||
return len(rows)
|
||||
|
||||
|
||||
def counts() -> dict[str, int]:
|
||||
if TABLE not in db.tables:
|
||||
return {}
|
||||
totals: dict[str, int] = {"dead": 0}
|
||||
for row in db.query(
|
||||
f"SELECT provider AS provider, deleted_at IS NULL AS live, COUNT(*) AS total "
|
||||
f"FROM {TABLE} GROUP BY provider, deleted_at IS NULL"
|
||||
):
|
||||
provider = row["provider"] or DEFAULT_PROVIDER
|
||||
if row["live"]:
|
||||
totals[provider] = totals.get(provider, 0) + int(row["total"])
|
||||
else:
|
||||
totals["dead"] += int(row["total"])
|
||||
return totals
|
||||
@ -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,8 +43,9 @@ 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}` |
|
||||
| (none) | push.py - web push + PWA: `GET`/`POST /push.json` (VAPID public key / subscription register), `GET /service-worker.js`, `GET /manifest.json` |
|
||||
| `/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 - push + PWA: `GET /push.json` (VAPID public key + the providers accepting registrations), `POST /push.json` (register with any active provider; a body without `provider` is a `webpush` body, so existing clients are unchanged; an unknown, disabled or unconfigured provider is a 400), `GET /service-worker.js`, `GET /manifest.json`. Provider protocol and delivery live in `devplacepy/push/` - see `devplacepy/push/CLAUDE.md` |
|
||||
| (none) | docs.py (docs/ package) - the documentation site (prose pages + API reference). See `routers/docs/CLAUDE.md` for the deep detail on this tree (`DOCS_PAGES`, audience tiers, prose rendering pipeline) |
|
||||
| `/pubsub` | pubsub.py - **database-free** publish/subscribe bus. `WS /pubsub/ws` (subscribe/unsubscribe/publish frames, `foo.*` wildcards), `POST /pubsub/publish` + `GET /pubsub/topics` (admin/internal). Lock-owner-gated WS (`4013` retry) so subscribers converge on one worker; topic authz in `services/pubsub/policy.py` (`user.{uid}.*` private, `public.*` shared, admin/internal anywhere, guests opt-in). In-process `services.pubsub.publish(topic, data)` for backends; frontend `app.pubsub`. See `devplacepy/services/pubsub/CLAUDE.md` |
|
||||
| (none) | seo.py - `/robots.txt`, `/sitemap.xml` |
|
||||
@ -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")
|
||||
|
||||
@ -6,6 +6,7 @@ from devplacepy.utils import require_admin
|
||||
from devplacepy.responses import action_result
|
||||
from devplacepy.services.audit import record as audit
|
||||
from devplacepy.services.manager import service_manager
|
||||
from devplacepy.services.openai_gateway import quota
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
@ -34,14 +35,20 @@ async def admin_reset_all_ai_quota(request: Request):
|
||||
admin = require_admin(request)
|
||||
devii = service_manager.get_service("devii")
|
||||
removed = devii.reset_all_quotas() if devii is not None else 0
|
||||
gateway = quota.reset(created_by=admin["uid"])
|
||||
logger.info(
|
||||
f"Admin {admin['username']} reset ALL AI quotas ({removed} ledger rows)"
|
||||
f"Admin {admin['username']} reset ALL AI quotas "
|
||||
f"({removed} Devii ledger rows, gateway watermark {gateway['reset_at']})"
|
||||
)
|
||||
audit.record(
|
||||
request,
|
||||
"admin.ai_quota.reset_all",
|
||||
user=admin,
|
||||
metadata={"rows_removed": removed},
|
||||
summary=f"admin {admin['username']} reset all AI quotas",
|
||||
metadata={"rows_removed": removed, "gateway_reset_at": gateway["reset_at"]},
|
||||
summary=(
|
||||
f"admin {admin['username']} reset all AI quotas "
|
||||
"(Devii assistant and AI gateway)"
|
||||
),
|
||||
)
|
||||
return action_result(request, "/admin/ai-usage")
|
||||
|
||||
|
||||
205
devplacepy/routers/admin/devii_tasks.py
Normal file
205
devplacepy/routers/admin/devii_tasks.py
Normal file
@ -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")
|
||||
97
devplacepy/routers/admin/game.py
Normal file
97
devplacepy/routers/admin/game.py
Normal file
@ -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,113 @@ async def delete_model(request: Request, source_model: str):
|
||||
summary=f"admin {admin['username']} deleted gateway model route {source_model}",
|
||||
)
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
def _quota_defaults_summary() -> dict:
|
||||
svc = service_manager.get_service("openai")
|
||||
cfg = svc.get_config() if svc is not None else {}
|
||||
return {
|
||||
"user": cfg.get(quota.FIELD_DEFAULT_USER, 0.0),
|
||||
"admin": cfg.get(quota.FIELD_DEFAULT_ADMIN, 0.0),
|
||||
"guest": cfg.get(quota.FIELD_DEFAULT_GUEST, 0.0),
|
||||
"internal": cfg.get(quota.FIELD_DEFAULT_INTERNAL, 0.0),
|
||||
"key": cfg.get(quota.FIELD_DEFAULT_KEY, 0.0),
|
||||
}
|
||||
|
||||
|
||||
def _rule_label(rule: dict) -> str:
|
||||
return quota.scope_label(rule, fallback=rule.get("uid", ""))
|
||||
|
||||
|
||||
@router.get("/gateway/quota-rules")
|
||||
async def list_quota_rules(request: Request):
|
||||
require_admin(request)
|
||||
rules = quota.quota_rule_store.list()
|
||||
for rule in rules:
|
||||
rule["spent_24h_usd"] = round(
|
||||
quota.spent_24h(rule["owner_kind"], rule["owner_id"], rule["app_reference"]), 6
|
||||
)
|
||||
return JSONResponse(
|
||||
{
|
||||
"rules": rules,
|
||||
"count": len(rules),
|
||||
"defaults": _quota_defaults_summary(),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.post("/gateway/quota-rules")
|
||||
async def save_quota_rule(request: Request):
|
||||
admin = require_admin(request)
|
||||
body = await _payload(request)
|
||||
uid = str(body.pop("uid", "") or "").strip() or None
|
||||
try:
|
||||
payload = quota.QuotaRuleIn(**body)
|
||||
except ValidationError as exc:
|
||||
return _validation_error(exc)
|
||||
saved = quota.quota_rule_store.set(payload, uid=uid, created_by=admin["uid"])
|
||||
audit.record(
|
||||
request,
|
||||
"gateway.quota_rule.update",
|
||||
user=admin,
|
||||
target_type="gateway_quota_rule",
|
||||
target_uid=saved["uid"],
|
||||
target_label=_rule_label(saved),
|
||||
summary=f"admin {admin['username']} saved gateway quota rule ({_rule_label(saved)}) at ${saved['limit_usd']}/24h",
|
||||
metadata={
|
||||
"owner_kind": saved["owner_kind"],
|
||||
"owner_id": saved["owner_id"],
|
||||
"app_reference": saved["app_reference"],
|
||||
"limit_usd": saved["limit_usd"],
|
||||
"is_active": saved["is_active"],
|
||||
},
|
||||
)
|
||||
return JSONResponse({"ok": True, "rule": saved})
|
||||
|
||||
|
||||
@router.post("/gateway/quota-resets")
|
||||
async def reset_quota_spend(request: Request):
|
||||
admin = require_admin(request)
|
||||
body = await _payload(request)
|
||||
try:
|
||||
payload = quota.QuotaResetIn(**body)
|
||||
except ValidationError as exc:
|
||||
return _validation_error(exc)
|
||||
scope = quota.reset(payload, created_by=admin["uid"])
|
||||
label = quota.scope_label(scope, fallback="every caller")
|
||||
audit.record(
|
||||
request,
|
||||
"gateway.quota.reset",
|
||||
user=admin,
|
||||
target_type="gateway_quota",
|
||||
target_uid=scope["uid"],
|
||||
target_label=label,
|
||||
summary=f"admin {admin['username']} reset the gateway 24h spend for {label}",
|
||||
metadata={
|
||||
"owner_kind": scope["owner_kind"],
|
||||
"owner_id": scope["owner_id"],
|
||||
"app_reference": scope["app_reference"],
|
||||
"reset_at": scope["reset_at"],
|
||||
},
|
||||
)
|
||||
return JSONResponse({"ok": True, "reset": scope})
|
||||
|
||||
|
||||
@router.delete("/gateway/quota-rules/{uid}")
|
||||
async def delete_quota_rule(request: Request, uid: str):
|
||||
admin = require_admin(request)
|
||||
existing = quota.quota_rule_store.get(uid)
|
||||
label = _rule_label(existing.as_dict()) if existing else uid
|
||||
existed = quota.quota_rule_store.remove(uid)
|
||||
if not existed:
|
||||
return JSONResponse({"ok": False, "error": "Quota rule not found"}, status_code=404)
|
||||
audit.record(
|
||||
request,
|
||||
"gateway.quota_rule.delete",
|
||||
user=admin,
|
||||
target_type="gateway_quota_rule",
|
||||
target_uid=uid,
|
||||
target_label=label,
|
||||
summary=f"admin {admin['username']} deleted gateway quota rule ({label})",
|
||||
)
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -7,7 +7,6 @@ from devplacepy.models import ProfileForm
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from devplacepy.database import (
|
||||
get_table,
|
||||
db,
|
||||
get_customization_prefs,
|
||||
get_notification_prefs,
|
||||
get_user_stars,
|
||||
@ -37,6 +36,7 @@ from devplacepy.database.awards import (
|
||||
from devplacepy.content import can_view_project, enrich_items
|
||||
from devplacepy.utils import (
|
||||
get_current_user,
|
||||
get_badge,
|
||||
require_user,
|
||||
require_user_api,
|
||||
time_ago,
|
||||
@ -46,6 +46,7 @@ from devplacepy.utils import (
|
||||
track_action,
|
||||
build_achievements,
|
||||
)
|
||||
from devplacepy.utils.rewards import LEVEL_XP
|
||||
from devplacepy.responses import respond, action_result, wants_json
|
||||
from devplacepy.schemas import ProfileOut
|
||||
from devplacepy.avatar import avatar_url, avatar_seed
|
||||
@ -139,6 +140,12 @@ async def profile_page(
|
||||
current_user["uid"], f"/profile/{profile_user['username']}"
|
||||
)
|
||||
profile_user["stars"] = get_user_stars(profile_user["uid"])
|
||||
xp_raw = profile_user.get("xp") or 0
|
||||
level_raw = profile_user.get("level") or 1
|
||||
xp_progress_pct = xp_raw % LEVEL_XP
|
||||
xp_next_level = level_raw * LEVEL_XP
|
||||
profile_user["xp_progress_pct"] = xp_progress_pct
|
||||
profile_user["xp_next_level"] = xp_next_level
|
||||
rank = get_user_rank(profile_user["uid"])
|
||||
follow_counts = get_follow_counts(profile_user["uid"])
|
||||
|
||||
@ -195,6 +202,8 @@ async def profile_page(
|
||||
item["poll"] = polls_map.get(uid)
|
||||
|
||||
badges = list(get_table("badges").find(user_uid=profile_user["uid"]))
|
||||
for b in badges:
|
||||
b["icon"] = get_badge(b["badge_name"]).get("icon")
|
||||
achievements = build_achievements({b["badge_name"] for b in badges})
|
||||
badge_total = sum(group["total"] for group in achievements)
|
||||
badge_earned = sum(group["earned"] for group in achievements)
|
||||
@ -440,6 +449,8 @@ async def profile_page(
|
||||
"awards_count": awards_count,
|
||||
"prominent_award": prominent_award,
|
||||
"can_give_award": can_give,
|
||||
"xp_next_level": xp_next_level,
|
||||
"xp_progress_pct": xp_progress_pct,
|
||||
},
|
||||
model=ProfileOut,
|
||||
)
|
||||
@ -499,3 +510,7 @@ async def regenerate_api_key(request: Request):
|
||||
links=[audit.target("user", user["uid"], user["username"])],
|
||||
)
|
||||
return JSONResponse({"api_key": new_key})
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@ -4,6 +4,7 @@ import logging
|
||||
|
||||
from devplacepy.database import get_correction_usage, get_modifier_usage
|
||||
from devplacepy.services.manager import service_manager
|
||||
from devplacepy.services.openai_gateway import quota as gateway_quota
|
||||
from devplacepy.services.openai_gateway.analytics import user_spend_24h
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -62,4 +63,22 @@ def _ai_quota(
|
||||
if include_cost:
|
||||
quota["spent_usd"] = round(spent, 4)
|
||||
quota["limit_usd"] = round(limit, 2)
|
||||
gateway_svc = service_manager.get_service("openai")
|
||||
if gateway_svc is not None:
|
||||
try:
|
||||
owner_kind = "admin" if is_admin else "user"
|
||||
cfg = gateway_svc.effective_config()
|
||||
gw_limit, gw_scope, gw_rule = gateway_quota.resolve_for_owner(owner_kind, user_uid, cfg)
|
||||
gw_spent = gateway_quota.spent_24h(*gw_scope)
|
||||
gw_unlimited = gw_limit <= 0
|
||||
quota["gateway_unlimited"] = gw_unlimited
|
||||
quota["gateway_used_pct"] = (
|
||||
0.0 if gw_unlimited else round(min(100.0, gw_spent / gw_limit * 100), 1)
|
||||
)
|
||||
if include_cost:
|
||||
quota["gateway_spent_usd"] = round(gw_spent, 4)
|
||||
quota["gateway_limit_usd"] = round(gw_limit, 2)
|
||||
quota["gateway_pooled"] = bool(gw_rule and gw_rule.owner_id is None)
|
||||
except Exception:
|
||||
logger.exception("Failed to compute gateway-level AI quota for %s", user_uid)
|
||||
return quota
|
||||
|
||||
@ -6,6 +6,7 @@ from sqlalchemy import or_
|
||||
from fastapi import Depends, APIRouter, Request
|
||||
from devplacepy.models import ProjectForm, ProjectEditForm, ProjectFlagForm, ForkForm
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
|
||||
from devplacepy.attachments import get_attachments_batch
|
||||
from devplacepy.database import (
|
||||
get_table,
|
||||
get_users_by_uids,
|
||||
@ -13,6 +14,9 @@ from devplacepy.database import (
|
||||
get_site_stats,
|
||||
get_user_votes,
|
||||
get_recent_comments_by_target_uids,
|
||||
get_reactions_by_targets,
|
||||
get_user_bookmarks,
|
||||
get_polls_by_post_uids,
|
||||
paginate,
|
||||
text_search_clause,
|
||||
resolve_by_slug,
|
||||
@ -35,6 +39,7 @@ from devplacepy.content import (
|
||||
is_owner,
|
||||
can_view_project,
|
||||
can_view_project_containers,
|
||||
get_project_devlog,
|
||||
)
|
||||
from devplacepy.utils import (
|
||||
get_current_user,
|
||||
@ -171,7 +176,7 @@ async def projects_page(
|
||||
)
|
||||
|
||||
@router.get("/{project_slug}", response_class=HTMLResponse)
|
||||
async def project_detail(request: Request, project_slug: str):
|
||||
async def project_detail(request: Request, project_slug: str, before: str = None):
|
||||
user = get_current_user(request)
|
||||
detail = load_detail("projects", "project", project_slug, user)
|
||||
if not detail:
|
||||
@ -216,6 +221,25 @@ async def project_detail(request: Request, project_slug: str):
|
||||
if parent
|
||||
else None
|
||||
)
|
||||
|
||||
devlog_posts, devlog_next_cursor = get_project_devlog(
|
||||
project["uid"], before=before, viewer=user
|
||||
)
|
||||
if devlog_posts:
|
||||
post_uids = [item["post"]["uid"] for item in devlog_posts]
|
||||
attachments_map = get_attachments_batch("post", post_uids)
|
||||
reactions_map = get_reactions_by_targets("post", post_uids, user)
|
||||
bookmark_set = (
|
||||
get_user_bookmarks(user["uid"], "post", post_uids) if user else set()
|
||||
)
|
||||
polls_map = get_polls_by_post_uids(post_uids, user)
|
||||
for item in devlog_posts:
|
||||
uid = item["post"]["uid"]
|
||||
item["attachments"] = attachments_map.get(uid, [])
|
||||
item["reactions"] = reactions_map.get(uid, {"counts": {}, "mine": []})
|
||||
item["bookmarked"] = uid in bookmark_set
|
||||
item["poll"] = polls_map.get(uid)
|
||||
|
||||
return respond(
|
||||
request,
|
||||
"project_detail.html",
|
||||
@ -235,6 +259,8 @@ async def project_detail(request: Request, project_slug: str):
|
||||
"forked_from": forked_from,
|
||||
"fork_count": count_forks(project["uid"]),
|
||||
"file_count": count_files(project["uid"]),
|
||||
"devlog_posts": devlog_posts,
|
||||
"devlog_next_cursor": devlog_next_cursor,
|
||||
},
|
||||
),
|
||||
model=ProjectDetailOut,
|
||||
@ -464,3 +490,5 @@ async def set_project_readonly(
|
||||
request: Request, project_slug: str, data: Annotated[ProjectFlagForm, Depends(json_or_form(ProjectFlagForm))]
|
||||
):
|
||||
return _set_project_flag(request, project_slug, "read_only", data.value)
|
||||
|
||||
|
||||
|
||||
@ -5,6 +5,7 @@ from fastapi import APIRouter, Request
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from devplacepy import push
|
||||
from devplacepy.config import STATIC_DIR
|
||||
from devplacepy.push import providers
|
||||
from devplacepy.utils import require_user_api
|
||||
from urllib.parse import urlparse
|
||||
from devplacepy.services.audit import record as audit
|
||||
@ -22,7 +23,11 @@ WELCOME_PAYLOAD = {
|
||||
|
||||
@router.get("/push.json")
|
||||
async def push_public_key() -> JSONResponse:
|
||||
return JSONResponse({"publicKey": push.public_key_standard_b64()})
|
||||
configs = providers.client_config()
|
||||
webpush = configs.get(providers.DEFAULT_PROVIDER, {})
|
||||
return JSONResponse(
|
||||
{"publicKey": webpush.get("publicKey", ""), "providers": configs}
|
||||
)
|
||||
|
||||
|
||||
@router.post("/push.json")
|
||||
@ -33,21 +38,18 @@ async def push_register(request: Request) -> JSONResponse:
|
||||
except ValueError:
|
||||
return JSONResponse({"error": "Invalid JSON"}, status_code=400)
|
||||
|
||||
keys = body.get("keys") if isinstance(body, dict) else None
|
||||
if not (
|
||||
isinstance(keys, dict)
|
||||
and body.get("endpoint")
|
||||
and keys.get("p256dh")
|
||||
and keys.get("auth")
|
||||
):
|
||||
if not isinstance(body, dict):
|
||||
return JSONResponse({"error": "Invalid request"}, status_code=400)
|
||||
|
||||
_, created = await push.register(
|
||||
user_uid=user["uid"],
|
||||
endpoint=body["endpoint"],
|
||||
key_auth=keys["auth"],
|
||||
key_p256dh=keys["p256dh"],
|
||||
)
|
||||
provider = providers.get(body.get("provider"))
|
||||
if provider is None or not providers.is_active(provider):
|
||||
return JSONResponse({"error": "Unknown provider"}, status_code=400)
|
||||
|
||||
fields = provider.parse_registration(body)
|
||||
if fields is None:
|
||||
return JSONResponse({"error": "Invalid request"}, status_code=400)
|
||||
|
||||
_, created = push.register(user["uid"], provider.name, fields)
|
||||
|
||||
if created:
|
||||
try:
|
||||
@ -62,7 +64,13 @@ async def push_register(request: Request) -> JSONResponse:
|
||||
target_type="user",
|
||||
target_uid=user["uid"],
|
||||
target_label=user.get("username"),
|
||||
metadata={"endpoint_host": urlparse(body["endpoint"]).hostname, "created": created},
|
||||
metadata={
|
||||
"provider": provider.name,
|
||||
"endpoint_host": urlparse(fields["endpoint"]).hostname
|
||||
if fields.get("endpoint")
|
||||
else None,
|
||||
"created": created,
|
||||
},
|
||||
summary=f"{user.get('username')} {'registered' if created else 'updated'} a push subscription",
|
||||
links=[audit.target("user", user["uid"], user.get("username"))],
|
||||
)
|
||||
|
||||
9
devplacepy/routers/quizzes/__init__.py
Normal file
9
devplacepy/routers/quizzes/__init__.py
Normal file
@ -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"]
|
||||
86
devplacepy/routers/quizzes/_shared.py
Normal file
86
devplacepy/routers/quizzes/_shared.py
Normal file
@ -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",
|
||||
]
|
||||
244
devplacepy/routers/quizzes/attempts.py
Normal file
244
devplacepy/routers/quizzes/attempts.py
Normal file
@ -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,
|
||||
)
|
||||
363
devplacepy/routers/quizzes/index.py
Normal file
363
devplacepy/routers/quizzes/index.py
Normal file
@ -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")}
|
||||
)
|
||||
171
devplacepy/routers/quizzes/questions.py
Normal file
171
devplacepy/routers/quizzes/questions.py
Normal file
@ -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
|
||||
|
||||
|
||||
@ -17,6 +19,8 @@ class UserOut(_Out):
|
||||
website: Optional[str] = None
|
||||
level: Optional[int] = None
|
||||
xp: Optional[int] = None
|
||||
xp_progress_pct: Optional[int] = None
|
||||
xp_next_level: Optional[int] = None
|
||||
stars: Optional[int] = None
|
||||
created_at: Optional[str] = None
|
||||
last_seen: Optional[str] = None
|
||||
@ -62,8 +66,10 @@ class PollOut(_Out):
|
||||
|
||||
|
||||
class BadgeOut(_Out):
|
||||
name: Optional[str] = None
|
||||
name: Optional[str] = Field(None, alias="badge_name")
|
||||
icon: Optional[str] = None
|
||||
created_at: Optional[str] = None
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
|
||||
class PostOut(_Out):
|
||||
@ -202,3 +208,4 @@ class MessageOut(_Out):
|
||||
|
||||
|
||||
CommentItemOut.model_rebuild()
|
||||
|
||||
|
||||
@ -15,6 +15,7 @@ class GameCropOut(_Out):
|
||||
min_level: int = 1
|
||||
grow_seconds: int = 0
|
||||
locked: bool = False
|
||||
market_state: str = "normal"
|
||||
|
||||
|
||||
class GamePlotOut(_Out):
|
||||
@ -36,6 +37,8 @@ class GamePlotOut(_Out):
|
||||
steal_reason: str = ""
|
||||
is_golden: bool = False
|
||||
fertilize_cost: int = 0
|
||||
raided_fraction: float = 0.0
|
||||
raided_pct: int = 0
|
||||
|
||||
|
||||
class GamePerkOut(_Out):
|
||||
@ -62,6 +65,38 @@ class GameLegacyOut(_Out):
|
||||
effect: str = ""
|
||||
|
||||
|
||||
class GameMasteryOut(_Out):
|
||||
key: str = ""
|
||||
name: str = ""
|
||||
icon: str = ""
|
||||
description: str = ""
|
||||
level: int = 0
|
||||
max_level: int = 0
|
||||
cost: int = 0
|
||||
maxed: bool = False
|
||||
effect: str = ""
|
||||
|
||||
|
||||
class GameInfrastructureOut(_Out):
|
||||
key: str = ""
|
||||
name: str = ""
|
||||
icon: str = ""
|
||||
description: str = ""
|
||||
cost: int = 0
|
||||
min_prestige: int = 0
|
||||
owned: bool = False
|
||||
|
||||
|
||||
class GameCosmeticOut(_Out):
|
||||
key: str = ""
|
||||
name: str = ""
|
||||
icon: str = ""
|
||||
description: str = ""
|
||||
cost_coins: int = 0
|
||||
kind: str = ""
|
||||
owned: bool = False
|
||||
|
||||
|
||||
class GameQuestOut(_Out):
|
||||
kind: str = ""
|
||||
label: str = ""
|
||||
@ -71,6 +106,8 @@ class GameQuestOut(_Out):
|
||||
reward_xp: int = 0
|
||||
claimed: bool = False
|
||||
can_claim: bool = False
|
||||
scope: str = "daily"
|
||||
reward_stars: int = 0
|
||||
|
||||
|
||||
class GameFarmOut(_Out):
|
||||
@ -99,14 +136,48 @@ class GameFarmOut(_Out):
|
||||
prestige_multiplier: float = 1.0
|
||||
prestige_min_level: int = 0
|
||||
prestige_available: bool = False
|
||||
refactor_cost: int = 0
|
||||
refactor_affordable: bool = False
|
||||
refactor_carryover_pct: int = 0
|
||||
refactor_carryover_preview: int = 0
|
||||
grant_available: bool = False
|
||||
grant_amount: int = 0
|
||||
grant_reason: str = ""
|
||||
treasury_balance: int = 0
|
||||
streak: int = 0
|
||||
daily_available: bool = False
|
||||
daily_streak_reset: bool = False
|
||||
daily_reward: int = 0
|
||||
perks: list[GamePerkOut] = []
|
||||
quests: list[GameQuestOut] = []
|
||||
stars: int = 0
|
||||
legacy: list[GameLegacyOut] = []
|
||||
steal_cooldown_seconds: int = 0
|
||||
mastery_points: int = 0
|
||||
mastery_points_earned_total: int = 0
|
||||
mastery: list[GameMasteryOut] = []
|
||||
infrastructure: list[GameInfrastructureOut] = []
|
||||
defense_level: int = 0
|
||||
defense_tier_name: str = ""
|
||||
defense_upkeep_daily: int = 0
|
||||
defense_next_cost: int = 0
|
||||
cosmetics: list[GameCosmeticOut] = []
|
||||
active_title: str = ""
|
||||
underdog_boost_seconds_remaining: int = 0
|
||||
contract_boost_seconds_remaining: int = 0
|
||||
auto_harvested: int = 0
|
||||
auto_harvest_coins: int = 0
|
||||
auto_harvest_xp: int = 0
|
||||
steal_max_per_victim_per_day: int = 0
|
||||
defense_downgrade_available: bool = False
|
||||
mastery_analytics_unlocked: bool = False
|
||||
lifetime_coins_earned: int = 0
|
||||
lifetime_harvests: int = 0
|
||||
harvests_week: int = 0
|
||||
era_active: bool = False
|
||||
era_name: str = ""
|
||||
era_coins: int = 0
|
||||
era_harvests: int = 0
|
||||
|
||||
|
||||
class GameStateOut(_Out):
|
||||
@ -130,6 +201,9 @@ class GameLeaderboardEntryOut(_Out):
|
||||
total_harvests: int = 0
|
||||
prestige: int = 0
|
||||
score: int = 0
|
||||
raid_avg: float = 0.0
|
||||
time_to_kernel_seconds: int = 0
|
||||
title: str = ""
|
||||
|
||||
|
||||
class GameLeaderboardOut(_Out):
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -72,6 +72,8 @@ class ProfileOut(_Out):
|
||||
followers_count: Optional[int] = None
|
||||
following_count: Optional[int] = None
|
||||
viewer_is_admin: bool = False
|
||||
xp_next_level: int = 0
|
||||
xp_progress_pct: int = 0
|
||||
media: list[MediaItemOut] = []
|
||||
media_pagination: Optional[Any] = None
|
||||
notification_prefs: list[Any] = []
|
||||
@ -88,3 +90,4 @@ class TelegramPairOut(_Out):
|
||||
code: Optional[str] = None
|
||||
expires_at: Optional[str] = None
|
||||
ttl_minutes: Optional[int] = None
|
||||
|
||||
|
||||
230
devplacepy/schemas/quiz.py
Normal file
230
devplacepy/schemas/quiz.py
Normal file
@ -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
|
||||
20
devplacepy/schemas/uploads.py
Normal file
20
devplacepy/schemas/uploads.py
Normal file
@ -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"),
|
||||
|
||||
@ -51,7 +51,7 @@ Generic, lightweight, **fire-and-forget** offload for non-critical side-effects
|
||||
|
||||
`devplacepy/services/` provides a generic framework for running background async services alongside the FastAPI server. `BaseService` provides the async run loop, a `deque(maxlen=20)` log buffer, and graceful cancellation; `ServiceManager` is a singleton that registers, starts, and stops services. Services are fully managed from the **Services admin tab** (`/admin/services`): start/stop, enable-on-boot, run-now, edit parameters, clear logs, adjustable log buffer size, with live status. All of this is generic - a new service gets it for free by declaring its config and implementing `run_once`.
|
||||
|
||||
This section covers only the shared machinery. The individual services built on top of it live in their own subdirectories with their own nested CLAUDE.md: `NewsService` (`services/news/`, see `devplacepy/services/news/CLAUDE.md`), `GatewayService` and provider/model routing (`services/openai_gateway/`, see `devplacepy/services/openai_gateway/CLAUDE.md`), `DeviiService` (`services/devii/`, see `devplacepy/services/devii/CLAUDE.md`), and the bot fleet service (`services/bot/`, see `devplacepy/services/bot/CLAUDE.md`).
|
||||
This section covers only the shared machinery. The individual services built on top of it live in their own subdirectories with their own nested CLAUDE.md: `NewsService` (`services/news/`, see `devplacepy/services/news/CLAUDE.md`), `GatewayService` and provider/model routing (`services/openai_gateway/`, see `devplacepy/services/openai_gateway/CLAUDE.md`), `DeviiService` (`services/devii/`, see `devplacepy/services/devii/CLAUDE.md`), and the bot fleet service (`services/bot/`, see `devplacepy/services/bot/CLAUDE.md`). `PushService` (`services/push/`) is the thinnest example of the pattern: it owns no loop work beyond pruning dead subscriptions, and exists mainly so every push provider's configuration is edited through the same `ConfigField` surface as every other subsystem - its `config_fields` are assembled from `devplacepy.push.providers.admin_fields()`, so a new provider appears at `/admin/services/push` with no edit to the service. Push delivery does NOT depend on that service running (see `devplacepy/push/CLAUDE.md`).
|
||||
|
||||
### DB-backed state (correct across workers)
|
||||
|
||||
|
||||
@ -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],
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user