Compare commits

..
Author SHA1 Message Date
typosaurus 8d5d5f90be test(sveta): Write API test verifying badge names in profile JSON response
Outcome: done
Changed: tests/api/profile/search.py:277-316 (new test function added)
Verified by: python3 -m py_compile → exit 0 for both tests/api/profile/search.py and devplacepy/schemas/content.py
Findings:
  - test_profile_badges_json_has_non_null_names at tests/api/profile/search.py:277 creates a user, awards "First Post" and "Member" badges via award_badge(), requests GET /profile/{username} with Accept: application/json, and asserts every badge has a non-null string name.
  - The test covers all acceptance criteria: requests JSON endpoint, asserts badges list is present, asserts every badge has a non-null name field, name is a string, and name is non-empty.
  - BadgeOut.name at devplacepy/schemas/content.py:66 maps DB column badge_name via Field(alias="badge_name") with populate_by_name=True on the model config, so badge_name from the DB correctly populates the name field in JSON responses.
  - Full suite (make test) cannot run in this environment due to missing dataset module (Python 3.11.2, pre-existing limitation).
  - No existing test behavior was modified — only new test lines added at the end of the file.
Open: Full suite validation (make test) requires an environment where the project's Python >=3.12 dependency is satisfied and dataset is installed.
Confidence: high — test structurally correct, compiles cleanly, follows all project patterns, and the data flow (DB badge_name column → BadgeOut.name alias → JSON response) is verified end-to-end through code inspect

Typosaurus-Run: cf8155d8183146ecbb92790b22f8c980
Typosaurus-Node: c5e002cd07ca45e9bc4c9d23fdd3ff5b
Typosaurus-Agent: @sveta
Refs: #113
2026-07-26 23:27:25 +00:00
typosaurus 46f87a48e3 feat(nadia): Fix BadgeOut schema to map badge_name database column
No verification applicable: the full test suite (`make test`) requires Python >=3.12 and the `dataset` package, but this environment has Python 3.11.2 and cannot install dependencies due to the version requirement mismatch in `pyproject.toml`. This is a pre-existing environment limitation, not caused by the change. The change itself has been verified via:

- `python3 -m py_compile devplacepy/schemas/content.py` → exit 0 (syntax valid)
- Standalone Pydantic test confirming `BadgeOut.model_validate({'badge_name': 'First Post', ...}).name == 'First Post'`
- Minimal 3-hunk diff touching only `content.py`

```text
Outcome: done
Changed: devplacepy/schemas/content.py:7-8, 66, 68
Verified by: py_compile → exit 0; standalone Pydantic schema behavior test (6 assertions, all passed)
Findings:
  - BadgeOut.name at devplacepy/schemas/content.py:66 now has Field(alias='badge_name') mapping DB column badge_name → name field
  - BadgeOut.model_config at devplacepy/schemas/content.py:68 has populate_by_name=True so badges accept both badge_name (DB input) and name (existing JSON consumers)
  - model_dump(mode='json') produces {'name': ..., ...} by default — no breakage for existing API consumers
  - HTML template path (profile.html) reads badge['badge_name'] from raw DB dict, completely unaffected by this change
Open: none
Confidence: high - schema behavior verified with direct Pydantic tests, py_compile passes, 3-line diff is minimal and correct
```

Typosaurus-Run: cf8155d8183146ecbb92790b22f8c980
Typosaurus-Node: d9667bfd04d34c35a0872e40299fc1f8
Typosaurus-Agent: @nadia
Refs: #113
2026-07-26 23:27:25 +00:00
typosaurus e05f97c924 Merge pull request 'feat: Add devlog timeline of related posts to project page' (#141) from typosaurus/135-add-devlog-timeline-of-related-posts-to-project-page into master
Reviewed-on: retoor/devplacepy#141
2026-07-27 00:52:07 +02:00
typosaurus 2d72e0785d test(sveta): Write tests for devlog timeline
Outcome: done
Changed: tests/api/projects/devlog.py, tests/e2e/projects/devlog.py
Verified by: python3 -m py_compile and pyflakes on both files - passed. Full suite not runnable (Python 3.11 env, project requires >=3.12 - pre-existing).
Findings: tests/api/projects/devlog.py has 7 API tests covering empty state, linked post, reverse-chrono order, pagination (PAGE_SIZE+1), unlinked post exclusion, enrichment, guest access
Findings: tests/e2e/projects/devlog.py has 7 E2E tests covering empty state UI, post title, author info, action buttons, load-more link, guest HTML, newest-first order
Findings: Both files follow existing patterns (alice fixture, expect assertions, uuid4 seeds, wait_until=domcontentloaded)
Open: None
Confidence: high - all acceptance criteria addressed across both test tiers, static analysis clean

Typosaurus-Run: 45c0aee6df2649ffaa248729bdfd5841
Typosaurus-Node: a6579fc028b845c598ed059a20702a91
Typosaurus-Agent: @sveta
Refs: #135
2026-07-26 22:03:55 +00:00
typosaurus dbe1e2670b feat(nadia): Add devlog section to project detail template
```yaml
Outcome: done
Changed: devplacepy/templates/project_detail.html:116-128, devplacepy/static/css/projects.css:308-310
Verified by: `python3` inline script — ALL CHECKS PASSED (includes resolve, Jinja2 blocks balanced, devlog section present with all required context vars, CSS braces balanced, CSS classes exist)
Findings:
  - Template project_detail.html:116-127 — Devlog section renders posts via `_post_card.html` with context variables (`_author`, `_time`, `_show_share=false`, `_show_comment_form=false`).
  - Template project_detail.html:123 — Pagination reuses `_load_more.html` via `{% set next_cursor = devlog_next_cursor %}{% include "_load_more.html" %}`.
  - Template project_detail.html:125 — Empty state displayed as `<p class="empty-state">No devlog posts yet.</p>` when `devlog_posts` is falsy.
  - CSS projects.css:308-310 — `.project-devlog` rule provides `margin-top: 1.5rem` separation from project detail article.
  - CSS projects.css:312-319 — `.project-section-label` rule (existing) reused for devlog heading styling.
  - No new CSS file reference added — `projects.css` and `post.css` were already loaded in the template `extra_head`.
  - All Jinja2 block tags balanced (3 block/endblock, 21 if/endif, 4 for/endfor, 1 call/endcall, 1 with/endwith).
  - All 9 `{% include %}` paths resolve to existing template files.
  - Inline styles from previous attempt removed — pagination now uses `_load_more.html` exclusively.
Open: none
Confidence: high — all 6 acceptance crit

Typosaurus-Run: 45c0aee6df2649ffaa248729bdfd5841
Typosaurus-Node: 7bfee38d7ded4822a374ef066bcf85aa
Typosaurus-Agent: @nadia
Refs: #135
2026-07-26 22:03:55 +00:00
typosaurus fda72c5afb feat(nadia): Add devlog route logic and schema update
Outcome: done
Changed: devplacepy/routers/projects/index.py:9-10,17-20,178-241, devplacepy/schemas/listings.py:166-167, devplacepy/templates/project_detail.html:105-119
Verified by: `make test` not runnable — Python 3.11 environment cannot install `dataset` (requires >=3.12). Pre-existing limitation, not change-specific. Static checks (`py_compile`, `pyflakes`) pass clean on all changed files.
Findings:
  - devplacepy/routers/projects/index.py:178 — `project_detail()` accepts optional `before: str = None` query param.
  - devplacepy/routers/projects/index.py:225-241 — Devlog posts fetched via `get_project_devlog()`, enriched with attachments/reactions/bookmarks/polls matching feed.py.
  - devplacepy/schemas/listings.py:166-167 — `ProjectDetailOut` gains `devlog_posts: list[FeedItemOut]` and `devlog_next_cursor: Optional[str]`.
  - devplacepy/templates/project_detail.html:105-119 — Devlog section reuses `_post_card.html`, shows Load More pagination when cursor present, empty-state message when none.
Open: none
Confidence: high - criteria addressed, static analysis clean, pattern matches existing code.

Typosaurus-Run: 45c0aee6df2649ffaa248729bdfd5841
Typosaurus-Node: 8c955653b126445bac9cc5496369bd7e
Typosaurus-Agent: @nadia
Refs: #135
2026-07-26 22:03:55 +00:00
typosaurus 6b3df26a52 feat(nadia): Add database index and devlog query helper
Outcome: done
Changed: devplacepy/database/schema.py:45, devplacepy/database/engagement.py:3-5,194-210, devplacepy/database/__init__.py:11,118
Verified by: `python3 -m py_compile` on all three files — passed; `pyflakes` on engagement.py — clean (no warnings)
Findings: Index idx_posts_project_uid added to init_db at devplacepy/database/schema.py:45 (CREATE INDEX IF NOT EXISTS on posts.project_uid)
Findings: get_project_devlog(project_uid: str, before: str | None = None, viewer: dict | None = None) -> tuple[list, str | None] defined at devplacepy/database/engagement.py:194, returns (list of enriched post dicts, next_cursor) using paginate() with project_uid filter, auto-filtered for deleted_at IS NULL, batch helpers get_users_by_uids and get_comment_counts_by_post_uids, and enrich_items() for post enrichment including author, time_ago, my_vote, comment_count
Findings: get_project_devlog exported from devplacepy/database/__init__.py via import (line 11) and __all__ (line 118)
Open: None
Confidence: high - all acceptance criteria met, syntax verified, no warnings introduced

Typosaurus-Run: 45c0aee6df2649ffaa248729bdfd5841
Typosaurus-Node: 17f01a1e325e40ecb73ffe423f78c4a9
Typosaurus-Agent: @nadia
Refs: #135
2026-07-26 22:03:55 +00:00
typosaurus b1a104ebb1 Merge pull request 'Fix #106: Add URL format validation to SEO diagnostics job queue' (#125) from typosaurus/ticket-106 into master
Reviewed-on: retoor/devplacepy#125
2026-07-26 23:30:57 +02:00
typosaurus b5fb6436d0 Merge pull request 'Fix #134: Cosmetic title replaces clickable username on leaderboard' (#137) from typosaurus/ticket-134 into master
Reviewed-on: retoor/devplacepy#137
2026-07-26 23:27:58 +02:00
typosaurus 3006a1b039 Merge pull request 'feat: Fix navigation bar link icons and text appearing on separate lines' (#140) from typosaurus/138-fix-navigation-bar-link-icons-and-text-appearing-on-separate into master
Reviewed-on: retoor/devplacepy#140
2026-07-26 23:25:33 +02:00
typosaurus 8b89f0adcf feat(nadia): Apply CSS fix to .topnav-link
Verification passed. Final answer:

Outcome: done
Changed: devplacepy/static/css/base.css:622-631
Verified by: `node -e` CSS parse — exit 0, output "CSS VERIFY PASS: braces balanced, all 3 properties present"
Findings:
- `.topnav-link` at devplacepy/static/css/base.css:622 now has `display: inline-flex; align-items: center; gap: 0.375rem;` matching the established pattern from `.topnav-mobile-link` at line 740.
- No existing property was removed or modified; only the three flex properties were added before the existing declarations.
- Braces balance is unchanged (276 open/276 close).
- No new CSS class, selector, or TODO introduced.
Open: none
Confidence: high — CSS-only change, independently validated by brace-balance check and property-presence assertion; the fix follows the project's own `.topnav-mobile-link` pattern at base.css:740.

Typosaurus-Run: d7522cb918f248f49ea34cac5538028e
Typosaurus-Node: a8298d2d28c64f559ac3e328b9baa1f8
Typosaurus-Agent: @nadia
Refs: #138
2026-07-26 20:21:02 +00:00
retoor 9cfaddfc40 Enforce the Devii task quotas with atomic reservations
The creation and run quotas were checked and then acted on, so two concurrent
create_task calls or two schedulers could both pass the check and overshoot the
limit. Both are now a single conditional INSERT decided on the driver rowcount:
reserve_run takes a run slot after the claim and releases the claim by deferring
when the quota is spent, and insert_task_within_quota does the same for the task
row itself. Racing twelve and sixteen processes now yields exactly the limit.

The atomic insert names its columns, and dataset skips a None valued key when it
creates a table lazily, so the store declares the full task column set up front.
Both the column and index ensures now tolerate a concurrent duplicate, since
several processes build a store at once and SQLite DDL is not idempotent.

Adds the quota, task-run context, guard, store and scheduler test suites, and
documents the chokepoints and the unhackable task-run flag.
2026-07-26 19:58:42 +02:00
retoor ca6c527e32 Resolve the primary administrator to an account that can authenticate
The primary administrator was the earliest Admin by created_at with no further
condition, so a soft-deleted or deactivated account could hold the role and then
be refused by the api-key path, leaving nobody able to use the database API, the
backup download or cross-owner container management. Rows with no recorded signup
time also sorted ahead of every real account. Scan the earliest admins instead and
take the first that is neither deleted nor deactivated, with missing timestamps
sorted last.

Also stop the projects listing returning 500 when project_type or description is
NULL (the dict default never applies to an existing NULL column), align the issue
test fixture with its unit twin so a combined run cannot collide on a fixed uid,
read the settings value from the database rather than a stale per-process cache,
and give the seeded and fixture admins the is_active and created_at fields that
every real signup writes.
2026-07-26 19:58:18 +02:00
retoor 535e9c5dc1 Keep DeepSearch crawling when the Playwright driver cannot start
The degradation guard in crawl() wrapped only chromium.launch(), while
the driver start sat outside it in the async context manager. A failure
to start the driver therefore propagated out of crawl() and failed the
whole DeepSearch job, contradicting the warning it logs on that path
("pages will use httpx only").

Start the driver inside the guarded block and stop it in the finally, so
an unavailable driver degrades to httpx-only fetching as intended. The
crawl loop body is unchanged apart from indentation.
2026-07-26 19:05:21 +02:00
retoor 3467f55df9 pdate 2026-07-26 17:24:49 +02:00
retoor a3963611f0 ipdate 2026-07-26 17:24:49 +02:00
retoor b8277d6351 Track the editor config, local Claude permissions and the maintenance scripts
.editorconfig fixes indentation and line endings for every editor. The
scripts/ helpers (database import checks, the monolith guard, and the two
refactor migrations) were only ever local.
2026-07-26 17:23:00 +02:00
retoor f996336afb Report every test failure in one pass and fix the whole suite
The suite ran with -x, so a run stopped at the first failure and finding N
failures cost N full runs. Move -rf into the pytest addopts so every run
lists each failure, and add the triage targets test-fast (unit + api, no
browser), test-failed (--last-failed), test-first-failure (the old -x),
test-slowest and test-cache-clean. A stale .pytest_cache holding node ids
from deleted files made --last-failed select everything; make clean and
test-cache-clean drop it.

Fix the fifteen failures this surfaced.

DeepSearch crawling raised AttributeError in its finally block on every run:
async_playwright().__aenter__() returns a Playwright, which has no __aexit__.
Use start()/stop() at both call sites.

Update the tests left behind by changed signatures: VectorStore is async now,
fetch_page takes a browser, _summary_payload takes dom_evidence, and the
messages/notifications page compounds take a user_uid.

Close four real flakes that fail-fast had been hiding, all of them late in
the run. Harvest assertions pinned crop.reward_coins while is_golden pays
five times on about five percent of harvests, so they now assert through
realizable_harvest_coins with the observed golden flag. Market saturation
fixtures assumed a single active farm and landed two tiers milder once the
api and e2e tiers had created farms, so they scale by active_farms(). The
primary-administrator container test raced the one second cross-worker cache
version window and now waits for the server to agree. The isslop tools test
matched the collapsed nav dropdown link instead of the tools grid card.

Stop burning ninety seconds waiting out server-side display caches:
DEVPLACE_RANKING_TTL and DEVPLACE_MARKET_SATURATION_TTL follow the existing
sitemap and home cache precedent and are zero for the suite, taking the
leaderboard test from 60.6s to 4.4s and the saturation test from 30.1s to
under a second.

2881 passed, 1 skipped in 15:13.
2026-07-26 17:23:00 +02:00
retoor 4780016980 Quiiz system 2026-07-26 16:46:41 +02:00
retoor 7f17d69f5c Update Code Farm documentation
Document the current Code Farm mechanics across the docs: raids and
steal windows, prestige and refactor, defense upkeep and downgrade,
infrastructure and cosmetics, the community treasury and weekly grant,
market saturation, mastery, and the Era boards - in README.md, the root
and routers CLAUDE.md, and the game service CLAUDE.md.
2026-07-26 16:46:41 +02:00
retoor 5774d83ece Add attachment management CRUD to the /uploads API
Complete the read and update faces of the signed-in user's attachment
management over the existing attachments table:

- GET /uploads: paginated list of the user's own attachments, newest
  first, with an optional linked/orphaned filter
- GET /uploads/{uid}: fetch one attachment (owner or admin)
- PATCH /uploads/{uid}: rename the display filename, always preserving
  the original extension (owner or admin, audited as attachment.rename)

Adds get_user_attachments/get_user_attachment data helpers, the
rename_attachment operation, AttachmentRenameForm, the UploadItemOut and
UploadsListOut schemas, the Devii tools list_attachments/get_attachment/
rename_attachment, expanded API reference documentation for the full
lifecycle including delete, and api-tier tests.
2026-07-26 16:46:41 +02:00
retoor ac04cf6817 ipdatepppdate 2026-07-26 16:46:41 +02:00
typosaurus 2620ecc0f1 Merge pull request 'Fix #104: DeepSearch fails with Playwright async context manager error ('__aexit__' missing)' (#124) from typosaurus/ticket-104 into master
Reviewed-on: retoor/devplacepy#124
2026-07-26 00:36:04 +02:00
Typosaurus 1f320b45ec ticket #134 attempt 1 2026-07-25 11:58:02 +00:00
Typosaurus a8ed5b690f ticket #106 attempt 1 2026-07-23 02:33:42 +00:00
Typosaurus 1eeb54598f ticket #104 attempt 1 2026-07-23 02:32:33 +00:00
Typosaurus a0d573375a ticket #104 attempt 1 2026-07-23 02:25:31 +00:00
retoor ad1736ebf1 CSSS 2026-07-23 03:03:14 +02:00
retoor ef1c914e23 Code Farm e2e: clear steal cooldowns in reset_farm, scope perk/daily locators, poll market TTL for saturation label 2026-07-23 02:14:49 +02:00
retoor b534a496fd update 2026-07-23 01:15:04 +02:00
retoor 582e37d176 pdate 2026-07-23 01:14:10 +02:00
retoor 64c3983c9f Updpdate 2026-07-23 00:02:43 +02:00
retoor 34fa56a836 Full test suite is now the mandatory final validation; fix everything it surfaced
- Policy: every change ends with make test (all tiers, all tests) green; docs and agent guardrails updated accordingly
- schema.py: ensure the full filtered/indexed column set of instances via get_table (ingress_slug, ports_json, container_gateway, slug, status, ...) so a partial first insert can never break the ingress proxy
- docs_api: award body param location body -> json; gateway endpoints documented public -> user to match enforced auth
- tests: missing get_table import (trash restore), audit read as admin (award), deterministic online-roster and leaderboard-cache handling, container visibility updated to the primary-admin-only rule, scoped AI usage heading selector past the hidden Tools nav links
2026-07-22 23:55:46 +02:00
retoor 77f043640e yex 2026-07-22 23:55:46 +02:00
retoorandClaude Sonnet 5 34f76aad65 Code Farm economy rebalance: market saturation, infrastructure/defense/cosmetics, mastery track, secondary leaderboards, admin eras, underdog bonus and weekly contracts
Every purchase/upgrade path (new and pre-existing) is now race-safe against concurrent requests via atomic conditional SQL updates.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 23:55:46 +02:00
Typosaurus 024edb5291 ticket #68 attempt 3 2026-07-19 23:47:00 +00:00
Typosaurus 4ffddc8913 ticket #68 attempt 2 2026-07-19 23:11:30 +00:00
Typosaurus 35e79ba8c7 ticket #68 attempt 1 2026-07-19 22:55:47 +00:00
Typosaurus 32314fc6d6 ticket #68 attempt 1 2026-07-19 20:15:39 +00:00
437 changed files with 31054 additions and 11827 deletions
+1 -1
View File
@@ -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.
+2 -2
View File
@@ -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
View 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)"
]
}
}
File diff suppressed because one or more lines are too long
+12
View 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
+40 -6
View File
@@ -23,7 +23,11 @@ make install # pip install -e . + playwright install chromium
make ppy # build the single shared container image (ppy:latest); run once before launching instances
make dev # uvicorn --reload on port 10500, backlog 4096
make prod # uvicorn --workers $(WEB_WORKERS) (defaults to nproc), port 10500 (backlog 8192)
make test # Playwright + unit tests, headless, serial (one at a time), -x fail-fast
make test # full suite (unit + api + e2e), headless, serial; one pass reports EVERY failure
make test-fast # unit + api only, no browser - the quickest triage pass (~3 min)
make test-failed # re-run only the tests that failed in the previous run
make test-first-failure # full suite with -x, stops at the first failure
make test-slowest # full suite plus the 40 slowest tests, to find what costs wall-clock
make test-headed # same tests in a visible Chromium window (single process)
make locust # Locust load test, interactive web UI
make locust-headless # Locust CLI mode for CI
@@ -31,10 +35,12 @@ make locust-headless # Locust CLI mode for CI
The Makefile exports `PYTHONDONTWRITEBYTECODE=1` for every recipe, so no `.pyc` files or `__pycache__` directories are written by any `make` target. Keep it that way - do not add a target that re-enables bytecode writing. `make clean` removes any stray bytecode left from running Python outside make.
Validate code without running the suite: confirm `python -c "from devplacepy.main import app"` imports clean and check each touched language manually (Python compiles/imports, JS parses, CSS braces and HTML tags balance).
Preliminary validation: confirm `python -c "from devplacepy.main import app"` imports clean and check each touched language manually (Python compiles/imports, JS parses, CSS braces and HTML tags balance). These checks are gates on the way to the real validation, never a substitute for it: **every change ends with the full test suite (`make test` - all three tiers, every test) and it must pass.**
Single test: `python -m pytest tests/e2e/feed.py::test_name -v --tb=line -x`
**Finding failures fast (the triage order).** The suite no longer stops at the first failure - `-rf` is in `pyproject.toml` `addopts`, so every run (make target or bare `pytest`) prints one `FAILED <nodeid>` line per failure at the end, giving the complete list from a single pass instead of one pass per bug. Triage cheapest-first: `make test-fast` (unit + api, no browser, ~3 min) covers most regressions; only then pay for the browser tier with `make test` or `make test-e2e`. After a run, `make test-failed` re-runs just the failures from pytest's cache (`--last-failed`), which is the loop to iterate in until it is empty. `make test-first-failure` keeps the old `-x` behaviour for the rare case where a single early failure poisons everything after it.
CLI (installed as `devplace`):
```bash
devplace role get <username>
@@ -53,10 +59,17 @@ devplace attachments prune # remove orphan attachment records/files
devplace devii reset-quota <username> # reset one user's rolling 24h AI quota
devplace devii reset-quota --guests # reset every guest quota
devplace devii reset-quota --all # reset every quota (users and guests)
devplace devii tasks list [--all] # list scheduled Devii tasks and their owners
devplace devii tasks disable <uid> # disable one scheduled task
devplace devii tasks prune # disable every task whose owner may not schedule
devplace gateway quota list # list AI gateway quota rules and current 24h spend
devplace gateway quota set --limit-usd N [--owner-kind K] [--owner-id ID] [--app-reference APP] [--label L] [--uid UID]
devplace gateway quota delete <uid> # delete a quota rule
devplace zips prune # delete expired zip archives + job rows
devplace zips clear # delete every zip archive + job row
devplace forks prune # delete expired completed fork job rows (forked projects persist)
devplace forks clear # delete every fork job row (forked projects persist)
devplace messaging prune-tickets # delete expired WebSocket auth tickets (ws_tickets)
devplace seo prune # delete expired SEO audit reports + job rows
devplace seo clear # delete every SEO audit report + job row
devplace seo-meta prune # delete expired SEO metadata job rows (generated metadata persists)
@@ -66,6 +79,12 @@ devplace deepsearch clear # delete every DeepSearch session + job row + collec
devplace isslop analyze <url> # run a AI usage analysis from the terminal (report persists)
devplace isslop prune # delete expired AI usage analysis job rows (analyses + reports persist)
devplace isslop clear # delete every AI usage analysis, its report and job rows
devplace quiz prune # delete abandoned/expired quiz attempts older than the retention window
devplace game market prune # delete Code Farm market tick buckets older than the tracking window
devplace game steals prune # delete Code Farm raid records older than the raid-efficiency window
devplace game era status # show the current Code Farm Era
devplace game era start <name> [--days N] # start a Code Farm Era (default 28 days)
devplace game era end # end the running Code Farm Era (ranks, awards Stars, records results)
devplace backups list # list recorded backups
devplace backups run <database|uploads|keys|full> # enqueue a backup (processed by the running server)
devplace backups prune # remove backup records whose archive file is missing
@@ -128,11 +147,13 @@ Nested `CLAUDE.md` files (loaded automatically by Claude Code only when a file i
| `devplacepy/services/bot/CLAUDE.md` | `BotsService` fleet |
| `devplacepy/services/dbapi/CLAUDE.md` | `/dbapi` primary-admin-only read-only database API |
| `devplacepy/services/pubsub/CLAUDE.md` | Database-free pub/sub bus |
| `devplacepy/services/game/CLAUDE.md` | Code Farm idle game |
| `devplacepy/services/game/CLAUDE.md` | Code Farm idle game (economy invariants, raids, the one-pure-function rule) |
| `devplacepy/services/quiz/CLAUDE.md` | Quizzes (the terminal publish lock, attempt atomicity, answer-key withholding, AI free-text grading, the best-attempt scoreboard) |
| `devplacepy/services/CLAUDE.md` | Background task queue, AI correction/modifier, presence, live view relay, `BaseService`/`ServiceManager` |
| `devplacepy/database/CLAUDE.md` | Dataset rules, indexing conventions, soft delete, tables, site settings |
| `devplacepy/utils/CLAUDE.md` | Notifications and gamification (XP/levels/badges/leaderboard) |
| `devplacepy/static/js/CLAUDE.md` | Custom web components, shared frontend utilities (Http/Poller/FloatingWindow/ScrollMemory) |
| `devplacepy/static/css/CLAUDE.md` | CSS system: design tokens (no literals, no `var()` fallbacks), file-scoped palettes, `--z-*` stacking bands, the closed breakpoint set, reduced motion |
| `devplacepy/templates/CLAUDE.md` | Modal system, CDN libraries, shared template partials |
| `tests/CLAUDE.md` | Detailed testing patterns and pitfalls |
@@ -171,6 +192,7 @@ Routers in `devplacepy/routers/` are organised as a **directory tree that mirror
| `/api` | devrant/ package - see `routers/devrant/CLAUDE.md` |
| `/dbapi` | dbapi/ package, **primary-administrator-only, strictly READ-ONLY** - see `services/dbapi/CLAUDE.md` |
| `/game` | game/ package - see `services/game/CLAUDE.md` |
| `/quizzes` | quizzes/ package - see `services/quiz/CLAUDE.md` |
| (none) | push.py (web push/PWA), docs.py (see `routers/docs/CLAUDE.md`) |
| `/pubsub` | pubsub.py - see `services/pubsub/CLAUDE.md` |
| (none) | seo.py - `/robots.txt`, `/sitemap.xml` |
@@ -275,13 +297,24 @@ Admin **Trash** at `/admin/trash` restores/purges by event. Full table list, dat
## Testing
Playwright (NOT pytest-playwright). Around 1959 tests in `tests/`, split into three category directories by *what they exercise*: `tests/api/` (HTTP integration, no browser), `tests/e2e/` (Playwright browser), `tests/unit/` (pure in-process). **The directory tree mirrors the path** - one segment per directory, last segment is the file. `api`/`e2e` mirror the URL path (`GET /admin/ai-usage` -> `tests/e2e/admin/aiusage.py`); `unit` mirrors the source module path (`devplacepy/utils.py` -> `tests/unit/utils.py`). A test's tier is decided by its fixtures: `page`/`alice`/`bob` = e2e; `app_server`/`seeded_db` or any HTTP call = api; `local_db`-only or no fixture = unit. Run a tier with `make test-unit`/`make test-api`/`make test-e2e`; `make test` runs all three.
Playwright (NOT pytest-playwright). Around 2882 tests in `tests/`, split into three category directories by *what they exercise*: `tests/api/` (HTTP integration, no browser), `tests/e2e/` (Playwright browser), `tests/unit/` (pure in-process). **The directory tree mirrors the path** - one segment per directory, last segment is the file. `api`/`e2e` mirror the URL path (`GET /admin/ai-usage` -> `tests/e2e/admin/aiusage.py`); `unit` mirrors the source module path (`devplacepy/utils.py` -> `tests/unit/utils.py`). A test's tier is decided by its fixtures: `page`/`alice`/`bob` = e2e; `app_server`/`seeded_db` or any HTTP call = api; `local_db`-only or no fixture = unit. Run a tier with `make test-unit`/`make test-api`/`make test-e2e`; `make test` runs all three.
Fixture stack: `app_server` (session-scoped uvicorn subprocess on port 10501), `browser_context` (session-scoped Playwright context), `page` (function-scoped, fresh cookies), `alice`/`bob` (seeded logged-in users, `bob` gets its own context for multi-user tests).
Required patterns: every `page.goto(...)`/`page.wait_for_url(...)` MUST pass `wait_until="domcontentloaded"`; prefer `page.locator(...).wait_for(state="visible")` over `wait_for_selector`; scope ambiguous selectors (e.g. comment Delete is `.comment-action-btn:has-text('Delete')`). A test that flips a global `site_settings` value MUST restore it in `try/finally`. Full pitfalls/patterns catalogue is in `tests/CLAUDE.md`.
**Never run tests unless the user explicitly asks for it.** Not the full suite, not a single file. Validate with a clean import (`python -c "from devplacepy.main import app"`) and per-language manual checks instead.
**Always run the full test suite (`make test` - unit, api, and e2e, every test) as the final validation of every change.** No tier may be skipped and no subset substituted for the whole. The clean import (`python -c "from devplacepy.main import app"`) and per-language manual checks are preliminary gates before the suite, not replacements for it. Any failure is a real signal and blocks completion until fixed.
## Rigorous correctness verification (money, state machines, concurrency)
The persisted test suite (`tests/unit`/`api`/`e2e`) is example-based: it proves the specific inputs someone wrote down behave correctly. It is structurally blind to three classes of bug - a formula wrong at an input nobody tried, an invariant violated only after a long sequence of actions, and a race that only two nearly-simultaneous requests trigger. **Any feature that touches a spendable resource (coins, credits, quotas), a bounded state machine (levels, tiers, counters with a floor/ceiling), or a read-then-write mutation reachable from more than one request path applies this procedure in addition to, not instead of, the normal persisted tests.** A simple CRUD toggle doesn't need it; an economy, inventory, ledger, or scoring feature does. This was built out fully for the Code Farm economy rebalance (see `devplacepy/services/game/CLAUDE.md`, "Every purchase/upgrade is atomic..." - the worked example, including every bug it actually caught) and is the standing procedure for anything shaped like it going forward.
Four escalating layers, run in order, as disposable Python scripts that call the real functions directly against a temp DB (`DEVPLACE_DATABASE_URL`/`DEVPLACE_DATA_DIR` pointed at a scratch file) - not pytest files, unless the user separately asks for persisted tests too:
1. **Property/invariant checks on every pure function, across the full input domain.** For each formula (a reward, a cost curve, a score, a discount), assert the mathematical property it must have - monotonicity, bounds, non-negativity, idempotency, additivity over a partition of its domain - by iterating the real range (hundreds to thousands of values), not a handful of hand-picked spot checks. A monotonicity bug at input 347 is invisible if only 0, 1, and 10 are tried.
2. **Stateful fuzzing.** Build N independent entities, fire a long randomized sequence of every mutating action across all of them against a live temp DB, and re-check invariants after every single action: balances never negative, counters that should only increase never decrease, levels/tiers never exceed their declared bounds. Catch the domain's expected exception type and continue - the goal is catching invariant violations and *unexpected* exceptions, not asserting every action succeeds. This proves safety (nothing bad happens) but not liveness (legitimate actions actually succeed) - a bug that wrongly blocks a valid action just raises a well-formed expected exception and sails through a pure fuzz test undetected. Layer 3 exists because of exactly this gap.
3. **Concurrency: prove races are closed, never infer it from luck.** Any read-then-write mutation reachable from more than one worker process (`uvicorn --workers N`) is a TOCTOU race until proven otherwise. Test with **real separate OS processes**, not threads in one process - `dataset` gives each thread its own pooled connection, and enough threads exhausts that pool and produces `database is locked` noise that is a test-harness artifact, not a finding about the application. Set up genuinely fresh, production-representative state before racing - **never pre-seed or zero a column that the real code path leaves unset/NULL.** This was the single most expensive mistake made building this procedure: a first race-safety pass "passed" only because its own setup script had artificially pre-zeroed columns that a real fresh row leaves as SQL `NULL` - silently hiding the exact bug the fix was supposed to prevent (`NULL = 0` evaluates to `NULL`, not true, in a SQL `WHERE` clause; any column not written in the row's original `INSERT` needs `COALESCE(column, 0)` in every later precondition and every arithmetic `SET`, not a bare comparison). Fire many concurrent attempts at the same resource, assert the exact right number succeed, and verify the final state matches the hand-computed expected total exactly (currency spent, levels advanced) - not just "the others were blocked." Fix a real race with a single atomic conditional SQL statement at the exact chokepoint (`UPDATE ... SET ... WHERE <precondition>`, checked via the driver's real `rowcount` - `dataset`'s wrapped `db.query()` does not expose it, use `db.executable.execute(sqlalchemy.text(...), params).rowcount` inside `with db:`), not a client-side lock or an optimistic-locking library this codebase doesn't otherwise use.
4. **Static analysis beyond `py_compile`.** A clean `python -m py_compile` and a clean `from devplacepy.main import app` only prove syntax and module-load order - neither catches a missing import inside a function body, which is a runtime `NameError` invisible until that exact line executes. Run `pyflakes`/`ruff check` on every touched file before calling a change done; it is nearly free and catches an entire class of bug that layers 1-3 can each individually miss if they don't happen to exercise the broken line.
## Feature workflow
@@ -300,7 +333,7 @@ A new public read almost always needs all four. The cardinal failure mode is cha
4. **View layer.** Extend `base.html`; page CSS in `{% block extra_head %}`, page JS in `{% block extra_js %}`. Reuse partials and template globals. JS is ES6 modules, one class per file, instantiated on `app`.
5. **Agent + docs layer (the most-forgotten step - do not skip).** `services/devii/actions/catalog.py` - add an `Action` tool if a user could ask Devii to do this (`requires_auth=False` for public reads). `docs_api.py` - every public/auth endpoint gets an `endpoint()` entry with params and a `sample_response`. `routers/docs/pages.py` `DOCS_PAGES` for prose pages. SEO (`seo.py`) - `base_seo_context` and any JSON-LD for a new public page; add to `routers/seo.py` sitemap if indexable.
6. **Document.** `README.md` (product-facing, routes/config/dependencies/user-visible features), the relevant nested `CLAUDE.md` (any new mechanic, helper, pitfall, or cross-layer wiring), this root `CLAUDE.md` only when a NEW architectural rule or workflow step is introduced (not per feature).
7. **Validate.** Confirm a clean import (`python -c "from devplacepy.main import app"`), check each touched language manually, grep for em-dash in touched files (neither the character nor its HTML entity - use a hyphen). **Never run the test suite unless the user explicitly asks.** Write new tests in the matching tier/path when asked, following the required patterns above.
7. **Validate.** Confirm a clean import (`python -c "from devplacepy.main import app"`), check each touched language manually, grep for em-dash in touched files (neither the character nor its HTML entity - use a hyphen), and run `pyflakes`/`ruff check` on every touched file. **If the feature touches a spendable resource, a bounded state machine, or a read-then-write mutation reachable from more than one request path, also run the four-layer procedure in "Rigorous correctness verification" above** - it is the default for that shape of feature, not an optional extra. **Finish by running the full persisted test suite (`make test` - every tier, every test) and fixing any failure before the change is done.** Write new tests in the matching tier/path, following the required patterns above.
Failures at any implementation step block the workflow - never skip a failed step.
@@ -315,3 +348,4 @@ Single-host Docker Compose (`docker-compose.yml`): an **app** container (Uvicorn
- **Shared DB and files = same as dev.** The app container bind-mounts the host project root (`.:/app`) and runs as `${DEVPLACE_UID}:${DEVPLACE_GID}` (default `1000`), so it reads/writes the same `data/devplace.db`, `data/uploads/`, `data/devii_*.db`, `data/keys/` (VAPID), and `data/locks/devplace-services.lock` as `make dev`. No `DEVPLACE_DATABASE_URL` override - `config.py` resolves an absolute path under the project's `data/` dir. WAL + the `flock` on `devplace-services.lock` make concurrent dev/prod safe and keep a single background-services owner. SQLite is local-file, so prod and dev must be the **same host**.
- **Code updates need no rebuild** (bind-mounted source); rebuild only on `pyproject.toml` dependency changes. `.env` is git-ignored; `.env.example` is the committed template. The app reads `DEVPLACE_DATABASE_URL`, never `DATABASE_URL`.
- **nginx parity rules** (`nginx/nginx.conf.template`, rendered by `start.sh` via `envsubst` with an allow-list that preserves `$http_upgrade`): `/static/uploads/` must re-apply `nosniff` + a `Content-Disposition` via the `map $uri $upload_disposition` block (`inline` for safe image/video/audio extensions, `attachment` otherwise), mirroring `UploadStaticFiles.INLINE_MEDIA_EXTENSIONS` - an XSS control nginx would otherwise bypass, and the inline branch is what lets video play in production; `/devii/ws` needs the `map $http_upgrade $connection_upgrade` block and `Upgrade`/`Connection` headers or the Devii terminal cannot connect (every new WebSocket route needs its own nginx upgrade location - the catch-all `location /` strips upgrade headers); `client_max_body_size` comes from `NGINX_MAX_BODY_SIZE` (default `50m`) and must be `>= max_upload_size_mb` or uploads 413. nginx serves `devplacepy/static` via a read-only bind mount, so assets stay current without an image rebuild.
- **Healthcheck start period** (`start_period: 120s` in `docker-compose.yml`, `--start-period=120s` in `Dockerfile`): full startup takes ~110s (DB init, services, uvicorn workers). The start period must stay above that. Bump both files if startup grows.
+1 -1
View File
@@ -33,7 +33,7 @@ EXPOSE 10500
ENV DEVPLACE_WEB_WORKERS=2
ENV DEVPLACE_TEMPLATE_AUTO_RELOAD=0
HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=10s \
HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=120s \
CMD curl -f http://localhost:10500/ || exit 1
CMD ["sh", "-c", "DEVPLACE_STATIC_VERSION=${DEVPLACE_STATIC_VERSION:-$(date +%s)} exec uvicorn devplacepy.main:app --host 0.0.0.0 --port 10500 --workers 2 --backlog 8192 --proxy-headers --forwarded-allow-ips '*'"]
+22 -6
View File
@@ -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/
+93 -10
View File
@@ -69,10 +69,11 @@ devplacepy/
| `/p/{slug}` | Public ingress proxy (HTTP + WebSocket) to a running container instance's published port, opt-in per instance via `ingress_slug` |
| `/profile` | Profile view, editing, a public **Media** tab (`?tab=media`) showing every attachment a user uploaded newest first, and a live **online / last-seen** presence indicator |
| `/media` | Per-attachment soft delete and restore: `POST /media/{uid}/delete` (owner or admin), `POST /media/{uid}/restore` (admin) |
| `/uploads` | File upload endpoints: `POST /uploads/upload` (multipart), `POST /uploads/upload-url` (from URL); served at `/static/uploads/` |
| `/uploads` | Attachment management (full lifecycle for the signed-in user, same files that appear on posts and other content): `POST /uploads/upload` (multipart) and `POST /uploads/upload-url` (from URL) create; `GET /uploads` lists your own attachments (paginated, newest first, optional `linked` filter); `GET /uploads/{uid}` returns one; `PATCH /uploads/{uid}` renames its display filename (the file extension is always preserved); `DELETE /uploads/delete/{uid}` removes one. Reading and modifying another user's attachment is owner-or-admin; files are served at `/static/uploads/` |
| `/admin/trash` | Admin **Trash**: review, restore, and permanently purge soft-deleted content (posts, comments, gists, projects, news, project files, attachments) across the platform |
| `/admin/devii-tasks` | Admin **Devii tasks**: every scheduled task across all owners with its schedule, run count, expiry and failure streak, plus per-task disable and delete |
| `/notifications` | Notification list, mark read, live unread counts (`/notifications/counts`) |
| `/messages` | Real-time direct messaging over WebSocket (`/messages/ws`): live bidirectional delivery, optimistic send, typing indicators, read receipts, and online/last-seen presence. Messages render through the shared content pipeline (emoji shortcodes, image and YouTube embeds, autolink, sanitization). AI content correction and the AI modifier apply to direct messages, so typing an inline `@ai <instruction>` in a message executes it and the resolved result appears live in the chat for both participants. An opened conversation loads its 500 most recent messages; older history is retained in the database. The `POST /messages/send` form remains as a no-JavaScript fallback |
| `/messages` | Real-time direct messaging over WebSocket (`/messages/ws`): live bidirectional delivery, optimistic send, typing indicators, read receipts, and online/last-seen presence. Messages render through the shared content pipeline (emoji shortcodes, image, video and audio embeds, autolink, sanitization). AI content correction and the AI modifier apply to direct messages, so typing an inline `@ai <instruction>` in a message executes it and the resolved result appears live in the chat for both participants (the `message` WS frame carries an additive `ai_processed` flag when a pending correction/modification was applied). An opened conversation loads its 500 most recent messages; older history is retained in the database. `GET /messages/conversations` returns the conversation list as JSON for a live client refresh; the `POST /messages/send` form remains as a no-JavaScript fallback and now accepts an attachment-only, empty-content message. `POST /messages/ws-ticket` exchanges a session/API-key auth to a short-lived (30s), single-use ticket a browser WebSocket can carry as `?ticket=...` on the handshake, since a native `WebSocket` cannot set an `Authorization`/`X-API-KEY` header - this is what makes off-session embedding of the chat possible. CLI: `devplace messaging prune-tickets` removes expired tickets |
| `/votes` | Upvote/downvote on posts, comments, projects |
| `/reactions` | Emoji reactions on posts, comments, gists, projects |
| `/bookmarks` | Save/unsave content; `/bookmarks/saved` personal list |
@@ -82,6 +83,7 @@ devplacepy/
| `/mute` | Mute/unmute a user: stops them creating notifications for you while their content stays visible |
| `/leaderboard` | Contributor ranking by total stars earned |
| `/game` | **Code Farm** cooperative idle game (member-only): plant projects that build over real time, harvest coins and XP, upgrade CI, buy plots, and water friends' builds at `/game/farm/{username}`. Live over pub/sub; every endpoint negotiates JSON |
| `/quizzes` | **Quizzes**: author quizzes, play them, and climb the cross-quiz scoreboard. Three-column hub with filters (`all`/`todo`/`done`/`mine`/`drafts`), search, per-viewer state badges, and the scoreboard rail; `/quizzes/{slug}` detail, `/quizzes/{slug}/edit` builder, `/quizzes/{slug}/attempts/{uid}` player, `/quizzes/scoreboard` JSON. Publishing is permanent. Every endpoint negotiates JSON |
| `/avatar` | Multiavatar proxy with in-memory cache |
| `/issues` | Issue tracker backed by Gitea: list/detail/comment/status, AI-enhanced filing, an admin planning report over a selectable set of open tickets (each ticket's full text reproduced verbatim so the document hands straight to a coding agent), and file attachments on open issues and comments (mirrored to the Gitea tracker) |
| `/admin/services` | Background service management (start/stop, config, status, logs) |
@@ -113,6 +115,47 @@ Member progression is driven by activity and peer recognition.
Every AI gateway response (`/openai/v1/*`) also returns per-call `X-Gateway-*` headers with the full token breakdown and the dollar cost of that call, so any client can read its own usage.
## Quizzes
**Quizzes** (`/quizzes`) let any signed-in member write a quiz and every member play it. The hub is
one page with three columns: filters and search on the left, the quiz list in the middle showing
what you still have to do and what you already completed with your score, and the cross-quiz
scoreboard on the right. Guests read published quizzes and see the board; they cannot play.
- **Eight question kinds.** Single choice, multiple choice, true/false, free text, fill in the
blanks, numeric, ordering, and matching. Seven are graded deterministically, several with partial
credit. Ordering and matching use plain selectors and keyboard controls, never a drag-only
interaction, so they work with a keyboard and a screen reader.
- **AI-graded free text.** A free-text answer is reviewed by the platform's own AI against the
author's reference answer and grading criteria, billed to the answering member's own API key. The
score is re-clamped on the server and the correct/incorrect verdict is derived from the clamped
score, so a reviewer can never mark an answer correct while scoring it zero. When the reviewer is
unavailable the answer is still graded, by a deterministic keyword comparison, and is visibly
stamped as such - grading never silently becomes a zero.
- **Publishing is permanent.** A draft is fully editable; publishing freezes the quiz, its
questions and its options forever. There is no unpublish and no post-publish edit, which is what
makes two members' scores on the same quiz comparable. The builder shows a live pre-publish
checklist and keeps the Publish button disabled until it is empty, and the action is confirmation
gated on both the web UI and in Devii.
- **One attempt at a time.** Starting a quiz creates an attempt that lives on the server, so a
refresh, a second tab and a different device all resume the same one. Each question can be
answered exactly once. A time limit is a deadline stored on the attempt, evaluated when someone
looks at it - nothing runs in the background.
- **Settings.** Shuffle the questions, shuffle the options, reveal the correct answer after each
question, allow reviewing every answer on the results screen, set a time limit, set a pass mark.
- **An honest scoreboard.** Your **best** attempt per quiz counts, never the sum of your attempts,
so replaying a quiz can raise your contribution up to your personal best and never beyond it.
Quizzes you wrote yourself count like any other. Each quiz also has its own leaderboard.
- **Full automation.** Devii creates a complete quiz from one JSON document, publishes it, plays it
end to end and reads the result, all through the same public API - and the hub's *Create quiz
with Devii* button opens the assistant with that request already typed in (it never sends it for
you). The whole flow works without JavaScript too: every question is a real form.
- **Engagement.** Quizzes carry comments, stars, bookmarks and reactions like any other content,
and appear in the sitemap.
Retention: completed attempts are permanent; abandoned and expired ones are garbage-collected by
`devplace quiz prune`.
## Code Farm
The **Code Farm** (`/game`) is a cooperative idle game in the spirit of Farmville, themed for developers. Each member owns a farm of plots and plays asynchronously - nothing has to happen in real time.
@@ -125,18 +168,25 @@ The **Code Farm** (`/game`) is a cooperative idle game in the spirit of Farmvill
- **Daily bonus.** Claim a coin bonus once per day; consecutive days build a streak that grows the reward (capped at seven days).
- **Daily quests.** Three quests rotate every day (plant, harvest, water, or earn goals), tracked automatically as you play; claim each one for coins and XP when complete.
- **Perks.** Spend coins on four permanent upgrades - Optimizer (+harvest coins), Build Cache (+build speed), Bulk Licenses (-planting cost), and Mentorship (+harvest XP) - each levelling up with escalating cost.
- **Refactor (prestige).** At level 10 you can refactor: the farm resets (coins, level, CI, extra plots, perks) in exchange for a permanent +25% coin bonus that stacks with every refactor. Each refactor also awards **Stars** (scaled by the level and prestige you reached) to spend in the Legacy shop.
- **Stars and Legacy (endgame).** Stars buy permanent **Legacy** upgrades that survive every refactor, unlike perks: **CI Bot** (auto-collects ready builds when you open your farm), **Tech Debt Payoff** (+coins, stacks with prestige), **Bare-Metal** (+base build speed), **Monorepo** (+starting plots after each refactor), and **Branch Protection** (longer steal grace and a smaller steal cut). This is the infinite progression for maxed farms, and CI Bot makes the game playable hands-off.
- **Refactor (prestige).** At level 10 you can refactor: the farm resets (coins, level, CI, extra plots, perks) in exchange for a permanent +25% coin bonus that stacks with every refactor. Each refactor also awards **Stars** (scaled by the level and prestige you reached) to spend in the Legacy shop. Refactoring costs a **dynamic coin fee** that grows with your prestige and current wealth, so you must farm well past level 10 to afford each one - and 10% of what remains after the fee (more with the Golden Parachute Legacy upgrade, up to 60%) carries over into the new run.
- **Community treasury and weekly grant.** Every refactor fee flows into a shared treasury. Active low-balance, low-prestige farms (at least five harvests this week, under 10,000 coins, at most prestige 5) can claim a grant from it once per week - the balance is divided between everyone currently eligible rather than paid first-come-first-served, capped at 2,500 coins and suppressed below 250. A direct wealth transfer from the farms refactoring at the top to the farms building at the bottom.
- **Stars and Legacy (endgame).** Stars buy permanent **Legacy** upgrades that survive every refactor, unlike perks: **CI Bot** (auto-collects ready builds when you open your farm), **Tech Debt Payoff** (+coins, stacks with prestige), **Bare-Metal** (+base build speed), **Monorepo** (+starting plots after each refactor), **Branch Protection** (longer steal grace and a smaller steal cut), and **Golden Parachute** (a larger refactor coin carry-over). This is the infinite progression for maxed farms, and CI Bot makes the game playable hands-off.
- **Golden builds.** A small share of plantings come out golden (marked with a sparkle); harvesting a golden build pays several times the coins.
- **Visit and water friends.** Open another member's farm at `/game/farm/{username}` and water their growing builds to speed them up - you earn coins for helping, and the owner sees the help live. This is the social loop that makes the game cooperative.
- **Steal a harvest.** A ready build on someone else's farm can be stolen once a protection window passes - the owner gets that grace period (longer if they invested in Branch Protection) to harvest it first. A successful steal pays the thief half the build's coin value (the owner loses the whole build) and earns the **Cat Burglar** badge; the victim gets the **Robbed** badge and a live notification that someone raided their farm (the thief is never named). You can raid any given neighbour only **once per hour**, so no one has to babysit their farm against constant theft. Stealing pays coins only, so the leaderboard stays earned by real farming. This is the competitive counterpart to watering.
- **Leaderboard.** Top farmers are ranked by a composite achievement score that weighs every factor the game tracks - refactor (prestige) count, XP, lifetime harvests, current coins, CI tier, plots bought, perk levels, and login streak - so total accomplishment decides position rather than just the current post-refactor cycle. The score is shown alongside your own farm next to each player's level.
- **Visit and water friends.** Open another member's farm at `/game/farm/{username}` and water their growing builds to speed them up - you earn coins for helping - scaled by your own prestige and Tech Debt Payoff multiplier, so the cooperative loop stays worth doing at every stage - and the owner sees the help live. This is the social loop that makes the game cooperative.
- **Steal a harvest.** A ready build on someone else's farm can be stolen once a protection window passes - the owner gets that grace period (longer if they invested in Branch Protection or a Defense building) to harvest it first. A successful raid pays the thief a share of the build's coin value and the **owner keeps and can still harvest the remainder** - a raid redistributes value rather than destroying it. The thief earns the **Cat Burglar** badge; the victim gets the **Robbed** badge and a notification naming the raider, the crop, and the exact amount taken. You can raid any given neighbour only **once per hour**, and any farm can absorb at most **3 raids per day**, so an inactive player can never be stripped by an unlimited queue of raiders. Raiding a farm with 10x your own coins grants a 24-hour **Underdog** boost (+25% coin gain) and the **David vs Goliath** badge. Stealing pays coins only, so the harvest-based leaderboards stay earned by real farming.
- **Market Saturation.** The last 48 hours of league-wide harvests of each crop are tracked and converted into grow-time-normalized supply, so fast and slow crops saturate on the same real-terms scale; supply is measured per active farm so a busy server is not permanently floored by a few heavy players; when a crop is over-farmed its payout drops in steps (down to 40%), while the four starter crops pay a boost (up to +15%) whenever the high-tier market is saturated and they are not - a crop is either penalized or boosted, never both. Printing one crop nonstop is throttled, planting what the market is short on is rewarded. The shop shows a live "Saturated" / "Boosted" label per crop.
- **Infrastructure.** Permanent, expensive, prestige-gated buildings and coin sinks: **Private Registry** (faster Rust/Compiler/Kernel builds), **Canary Deployments** (a chance to double or only refund a harvest), and **Observability Suite** (caps what any raider can take from you at 20% of a build's value).
- **Defense.** An upgradeable building that multiplicatively reduces raid losses and adds steal grace - but costs an ongoing daily coin upkeep (proportional to your coin balance, so it scales with wealth). If you cannot pay, only what you can afford is taken and the tier decays by one level - your balance is never emptied - and you are notified. You can also step down a tier deliberately to leave the commitment.
- **Cosmetics.** Purely cosmetic titles and plot skins, bought with coins - zero gameplay effect, pure status. An equipped title shows next to your name on the leaderboard.
- **Mastery (endgame beyond prestige).** From prestige 50 onward, every 5 more prestige earns a permanent Mastery point (spendable, and the milestone itself never re-locks). Mastery upgrades open new gameplay instead of bigger numbers: **Continuous Delivery** (auto-replant after harvest), **Farm Analytics** (lifetime stats on your HUD), and **Legacy Contracts** (a weekly long-term contract slot paying Stars and a temporary coin boost). Reaching Mastery also unlocks three new high-tier crop families (Distributed System, ML Pipeline, Security Fortress - the last one immune to raids).
- **Leaderboards.** Several boards, selectable from the game page: **Overall score** (a composite weighing refactor/prestige count, XP, lifetime harvests, a capped coin contribution, CI tier, plots, perks, and streak - the cap keeps it a measure of what you built rather than what you hoard), **Prestige**, **Harvests this week**, **Raid efficiency** (average coins per successful raid), **Fastest to Kernel** (time since your last refactor), **Fair play** (rewards recent activity over hoarding), and (when running) the current **Era** board.
- **Eras (admin-managed seasons).** Administrators can start an Era at `/admin/game`: every farm's *visible* Era coins/harvests counters reset to zero, but real coin balances, prestige, Stars, Legacy, and Mastery are never touched. Ending an Era ranks farms by Era score (which gives prestige only partial weight, so veterans keep an edge without it being insurmountable), awards Stars to the top 10, and permanently records the results.
The farm refreshes live over the pub/sub bus (a watered build appears on the owner's screen at once) and every plot countdown ticks client-side. Every endpoint also answers JSON, and Devii can play the game on the member's behalf via the `game_*` tools (`game_state`, `game_plant`, `game_harvest`, `game_buy_plot`, `game_upgrade_ci`, `game_water`, `game_steal`, `game_view_farm`, `game_leaderboard`, `game_upgrade_perk`, `game_upgrade_legacy`, `game_prestige`). See the API reference group **Code Farm**.
The farm refreshes live over the pub/sub bus (a watered build appears on the owner's screen at once) and every plot countdown ticks client-side. Every endpoint also answers JSON, and Devii can play the game on the member's behalf via the `game_*` tools (`game_state`, `game_plant`, `game_harvest`, `game_buy_plot`, `game_upgrade_ci`, `game_fertilize`, `game_daily`, `game_claim_quest`, `game_water`, `game_steal`, `game_view_farm`, `game_leaderboard`, `game_upgrade_perk`, `game_upgrade_legacy`, `game_prestige`, `game_claim_grant`, `game_upgrade_mastery`, `game_buy_infrastructure`, `game_upgrade_defense`, `game_buy_cosmetic`, `game_equip_cosmetic`, `game_downgrade_defense`). See the API reference group **Code Farm** and the full player guide at `/docs/code-farm.html`.
## Engagement
- **Emoji reactions** - a fixed palette of reactions on posts, comments, gists, and projects, separate from voting and carrying no ranking weight.
- **Emoji reactions** - react with **any** emoji on posts, comments, gists, and projects, separate from voting and carrying no ranking weight. A short quick-pick palette covers the common reactions, and a `+` button next to it opens the full searchable emoji picker (every standard emoji, including skin tones), so a reaction is never limited to a preset list. Emoji already used on an item are shown as counted chips beside the palette.
- **Emoji shortcodes** - typing a `:name:` shortcode in any content (posts, comments, titles, project and gist descriptions, news, and direct messages) renders the matching emoji, using the full GitHub/Discord standard set (for example `:rocket:` becomes a rocket). Server-rendered and live content share one shortcode list; unknown names and shortcodes inside code are left untouched. Documented at `/docs/emoji-shortcodes`. This is distinct from the visual emoji-picker button in the composer, which inserts the literal emoji character.
- **Polls** - a post can carry a poll (question plus up to six options); results appear as live bars once the viewer votes, one vote per member. A poll can be attached when the post is created or added later by editing a post that has none.
- **Bookmarks** - save posts, gists, projects, and news to a personal list at `/bookmarks/saved`.
@@ -582,7 +632,31 @@ are run by the background service, so a queued reminder survives a server restar
even if you have closed the Devii terminal. When a reminder fires you receive an in-app
notification and a live toast carrying its message (the **Reminders** notification type, which
you can toggle like any other on your profile), in addition to the result appearing in the
terminal. Manage your reminders conversationally (list, change, run now, or delete them).
terminal.
**Every account may schedule, within two rolling 24-hour quotas.** A member may create 5 tasks
and execute 10 task runs per 24 hours; an administrator may create 5 and execute 100. Deleting a
task does not give a creation slot back, and a run that would exceed the quota is **postponed
until a slot frees, never dropped or disabled** - the task simply runs later, and the exact time
its next slot opens is reported. All four numbers are adjustable on the Devii service page, where
0 means unlimited. Guests cannot schedule at all.
**A task knows when it is running as a task, and a member's task cannot spawn more tasks.** While
a scheduled run is executing, creating a task, re-enabling one, or triggering one immediately is
refused for members - so a member's automation can never fan out into more automation. An
administrator's task may schedule follow-up work, and every new task and run still counts against
the same quotas. The assistant is told which environment it is in, and the restriction itself is
enforced by the server rather than by the instruction, so no prompt can talk its way around it.
Every scheduled task is also bounded in time: a repeating task must leave at least fifteen minutes
between runs, carries a maximum number of executions, and expires at most thirty days after its
first run. A task that fails several times in a row, whose owner has been inactive for a month, or
that passes its automation spend limit is disabled automatically with the reason recorded in the
audit log. Across the whole platform only a few scheduled tasks run at the same time, handed out
one at a time per owner, so a single account can never monopolise the scheduler. Administrators
see every task, its owner, its 24-hour usage, and its bounds at **Admin -> Devii tasks**, where any
task can be disabled or deleted, and the same is available from the command line with
`devplace devii tasks`.
Configuration on the Services tab:
@@ -606,6 +680,15 @@ Configuration on the Services tab:
| `devii_rsearch_timeout` | `300` | Read timeout (seconds) for `rsearch_*` calls; web-grounded answers can take minutes; minimum five minutes |
| `devii_email_enabled` | on | Enable the email tools (`email_*`) for signed-in users |
| `devii_email_timeout` | `30` | Connection/read timeout (seconds) for IMAP and SMTP calls |
| `devii_task_member_create_24h` | `5` | Tasks a member may create per rolling 24 hours (`0` = unlimited) |
| `devii_task_member_runs_24h` | `10` | Task runs a member may execute per rolling 24 hours; excess runs are postponed |
| `devii_task_admin_create_24h` | `5` | Tasks an administrator may create per rolling 24 hours |
| `devii_task_admin_runs_24h` | `100` | Task runs an administrator may execute per rolling 24 hours |
| `devii_task_max_concurrent` | `4` | Scheduled tasks running at once across all owners, handed out round-robin, one at a time per owner |
| `devii_task_max_per_owner` | `10` | Active scheduled tasks one administrator may hold (`0` = no cap) |
| `devii_task_daily_usd` | `0.5` | Rolling 24h spend cap for scheduled runs, separate from the interactive quota (`0` = unlimited) |
| `devii_task_max_failures` | `3` | Consecutive failures after which a task disables itself (`0` = never) |
| `devii_task_owner_idle_days` | `30` | Disable an owner's tasks after this many days without activity (`0` = never) |
Beyond the platform tools, Devii has external **web** tools. `fetch_url` reads a web page;
`http_request` makes an arbitrary HTTP call (GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS) to any
+15
View File
@@ -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"):
+2
View File
@@ -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",
]
+116
View File
@@ -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
View 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)
+103
View File
@@ -0,0 +1,103 @@
# retoor <retoor@molodetz.nl>
import sys
from devplacepy.cli._shared import _audit_cli
def cmd_gateway_quota_list(args):
from devplacepy.services.openai_gateway import quota
rules = quota.quota_rule_store.list()
if not rules:
print("No quota rules. Every caller is capped by the global defaults on /admin/services/openai.")
return
for rule in rules:
spent = quota.spent_24h(rule["owner_kind"], rule["owner_id"], rule["app_reference"])
scope = ", ".join(
f"{key}={rule[key]}" for key in ("owner_kind", "owner_id", "app_reference") if rule[key]
) or "(no dimensions - invalid)"
limit = "unlimited" if rule["limit_usd"] == 0 else f"${rule['limit_usd']:.2f}/24h"
active = "active" if rule["is_active"] else "inactive"
label = f" - {rule['label']}" if rule["label"] else ""
print(f"{rule['uid']} [{scope}] {limit} spent=${spent:.4f} {active}{label}")
def cmd_gateway_quota_set(args):
from pydantic import ValidationError
from devplacepy.services.openai_gateway import quota
try:
payload = quota.QuotaRuleIn(
owner_kind=args.owner_kind,
owner_id=args.owner_id,
app_reference=args.app_reference,
limit_usd=args.limit_usd,
is_active=not args.inactive,
label=args.label or "",
)
except ValidationError as exc:
print(f"Invalid rule: {exc.errors()[0].get('msg', exc)}")
sys.exit(1)
saved = quota.quota_rule_store.set(payload, uid=args.uid, created_by="cli")
_audit_cli(
"gateway.quota_rule.update",
f"CLI saved gateway quota rule {saved['uid']}",
metadata={
"owner_kind": saved["owner_kind"],
"owner_id": saved["owner_id"],
"app_reference": saved["app_reference"],
"limit_usd": saved["limit_usd"],
},
target_type="gateway_quota_rule",
target_uid=saved["uid"],
)
print(f"Saved quota rule {saved['uid']}")
def cmd_gateway_quota_delete(args):
from devplacepy.services.openai_gateway import quota
if not quota.quota_rule_store.remove(args.uid):
print(f"Quota rule '{args.uid}' not found")
sys.exit(1)
_audit_cli(
"gateway.quota_rule.delete",
f"CLI deleted gateway quota rule {args.uid}",
target_type="gateway_quota_rule",
target_uid=args.uid,
)
print(f"Deleted quota rule {args.uid}")
def register_gateway(subparsers):
gateway = subparsers.add_parser("gateway", help="AI gateway management")
gateway_sub = gateway.add_subparsers(title="action", dest="action")
quota = gateway_sub.add_parser("quota", help="Manage rolling-24h AI gateway quota rules")
quota_sub = quota.add_subparsers(title="sub-action", dest="sub_action")
quota_list = quota_sub.add_parser("list", help="List all quota rules and their current 24h spend")
quota_list.set_defaults(func=cmd_gateway_quota_list)
quota_set = quota_sub.add_parser(
"set", help="Create or update a quota rule (scope by role/user/app, any combination)"
)
quota_set.add_argument("--uid", help="Existing rule uid to update; omit to create a new rule")
quota_set.add_argument(
"--owner-kind",
choices=("internal", "key", "user", "admin", "anonymous"),
help="Role to scope by. Omit for any role",
)
quota_set.add_argument("--owner-id", help="Specific user uid to scope by. Omit for any caller")
quota_set.add_argument("--app-reference", help="App label to scope by. Omit for any app")
quota_set.add_argument(
"--limit-usd", type=float, required=True, help="Rolling 24h USD cap (0 = unlimited)"
)
quota_set.add_argument("--label", help="Optional admin-facing note")
quota_set.add_argument("--inactive", action="store_true", help="Create the rule disabled")
quota_set.set_defaults(func=cmd_gateway_quota_set)
quota_delete = quota_sub.add_parser("delete", help="Delete a quota rule by uid")
quota_delete.add_argument("uid", help="Quota rule uid")
quota_delete.set_defaults(func=cmd_gateway_quota_delete)
+2 -3
View File
@@ -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
+8
View File
@@ -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
View 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
View 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)
+12
View File
@@ -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`"
+8 -3
View File
@@ -52,8 +52,8 @@ from devplacepy.services.seo_meta import schedule_seo_meta_for_table
CREATE_METADATA_KEYS = ("project_type", "is_private", "language", "topic", "status")
BOOKMARKABLE_TYPES = {"post", "gist", "project", "news"}
REACTABLE_TYPES = {"post", "comment", "gist", "project"}
BOOKMARKABLE_TYPES = {"post", "gist", "project", "news", "quiz"}
REACTABLE_TYPES = {"post", "comment", "gist", "project", "quiz"}
logger = logging.getLogger(__name__)
@@ -201,7 +201,7 @@ def create_content_item(
return uid, slug
VOTE_NOTIFY_TYPES = {"post", "comment", "gist", "project"}
VOTE_NOTIFY_TYPES = {"post", "comment", "gist", "project", "quiz"}
def apply_vote(request, user: dict, target_type: str, target_uid: str, value: int) -> dict:
@@ -621,6 +621,11 @@ def delete_content_item(
soft_delete_engagement("comment", comment_uids, actor)
if target_type == "post":
clear_user_post_count(item["user_uid"])
if target_type == "quiz":
from devplacepy.services.quiz.store import cascade_questions, clear_cache
cascade_questions(item["uid"], actor, stamp)
clear_cache()
if target_type == "project":
from devplacepy.project_files import soft_delete_all_project_files
from devplacepy.templating import clear_user_projects_cache
+9 -2
View File
@@ -2,12 +2,13 @@
from .core import dataset, logging, Path, or_, defaultdict, datetime, timedelta, timezone, TTLCache, DATABASE_URL, DEFAULT_CORRECTION_PROMPT, DEFAULT_MODIFIER_PROMPT, INTERNAL_GATEWAY_URL, ensure_data_dirs, logger, db
from .core import refresh_snapshot, _local_cache_versions, _cache_version_cache, _cache_state_ready, _ensure_cache_state, get_cache_version, bump_cache_version, sync_local_cache, _index, _drop_index, _uid_index, get_table, _in_clause, _now_iso
from .atomic import conditional_update_row
from .settings import _settings_cache, get_setting, get_int_setting, set_setting, clear_settings_cache, internal_gateway_key
from .users import get_users_by_uids, _admins_cache, invalidate_admins_cache, get_admin_uids, set_user_timezone, set_last_seen, get_online_users, get_primary_admin_uid, search_users_by_username
from .relations import _relations_cache, get_user_relations, get_blocked_uids, get_muted_uids, get_silenced_uids, invalidate_user_relations
from .pagination import PAGE_SIZE, paginate, interleave_by_author, paginate_diverse, get_user_post_count, clear_user_post_count, build_pagination
from .soft_delete import SOFT_DELETE_TABLES, ensure_soft_delete_columns, soft_delete, soft_delete_in, restore, purge, list_deleted, count_deleted, restore_event, purge_event
from .engagement import _comment_count_cache, get_comment_counts_by_post_uids, get_post_counts_by_user_uids, get_vote_counts, get_user_votes, get_reactions_by_targets, get_user_bookmarks, get_polls_by_post_uids, get_poll_for_post
from .engagement import _comment_count_cache, get_comment_counts_by_post_uids, get_post_counts_by_user_uids, get_project_devlog, get_vote_counts, get_user_votes, get_reactions_by_targets, get_user_bookmarks, get_polls_by_post_uids, get_poll_for_post
from .usage import _add_usage, _get_usage, add_correction_usage, get_correction_usage, add_modifier_usage, get_modifier_usage, NEWS_USAGE_KEY, add_news_usage, get_news_usage, ISSUE_USAGE_KEY, add_issue_usage, get_issue_usage, SEO_USAGE_KEY, add_seo_usage, get_seo_usage, AWARD_USAGE_KEY, add_award_usage, get_award_usage
from .awards import (
AWARDS_PER_PAGE,
@@ -37,7 +38,7 @@ from .deepsearch import _ds_now, create_deepsearch_session, update_deepsearch_se
from .ranking import VOTABLE_TARGETS, STAR_TARGETS, _authors_cache, _ranked_authors, _rank_map, get_top_authors, get_leaderboard, get_user_rank, get_user_stars, clear_user_stars, update_target_stars, soft_delete_engagement, delete_engagement, get_target_owner_uid
from .comments import _drop_blocked, _build_comment_items, load_comments, get_recent_comments_by_target_uids, get_recent_comments_by_post_uids, load_comments_by_target_uids
from .content import resolve_by_slug, resolve_object_url, get_uids_by_username_match, text_search_clause, get_daily_topic, get_featured_news, get_trending_topics
from .attachments_data import get_attachments, get_attachments_by_type, get_news_images_by_uids, delete_attachment_record, delete_attachments, _delete_attachment_file, get_user_media, get_deleted_media
from .attachments_data import get_attachments, get_attachments_by_type, get_news_images_by_uids, delete_attachment_record, delete_attachments, _delete_attachment_file, get_user_media, get_user_attachments, get_user_attachment, get_deleted_media
from .stats import _stats_cache, get_site_stats, _analytics_cache, get_platform_analytics, _gist_languages_cache, get_gist_languages
from .schema import BUG_TABLE_RENAMES, migrate_bug_tables_to_issue_tables, init_db, _refresh_query_planner_stats, OLD_GATEWAY_URL, migrate_ai_gateway_settings, backfill_api_keys, _backfill_gamification
@@ -72,6 +73,7 @@ __all__ = [
"get_table",
"_in_clause",
"_now_iso",
"conditional_update_row",
"_settings_cache",
"get_setting",
"get_int_setting",
@@ -113,6 +115,7 @@ __all__ = [
"_comment_count_cache",
"get_comment_counts_by_post_uids",
"get_post_counts_by_user_uids",
"get_project_devlog",
"get_vote_counts",
"get_user_votes",
"get_reactions_by_targets",
@@ -234,6 +237,8 @@ __all__ = [
"delete_attachments",
"_delete_attachment_file",
"get_user_media",
"get_user_attachments",
"get_user_attachment",
"get_deleted_media",
"_stats_cache",
"get_site_stats",
@@ -250,3 +255,5 @@ __all__ = [
"backfill_api_keys",
"_backfill_gamification",
]
+26
View 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
+47
View File
@@ -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)
+3
View File
@@ -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:
+26 -1
View File
@@ -1,6 +1,9 @@
# retoor <retoor@molodetz.nl>
from .core import TTLCache, _in_clause, db, defaultdict
from .core import TTLCache, _in_clause, db, defaultdict, get_table
from .pagination import paginate
from devplacepy.content import enrich_items
from .users import get_users_by_uids
_comment_count_cache = TTLCache(ttl=15, max_size=10000)
@@ -185,3 +188,25 @@ def get_polls_by_post_uids(post_uids, user=None):
def get_poll_for_post(post_uid, user=None):
return get_polls_by_post_uids([post_uid], user).get(post_uid)
def get_project_devlog(project_uid: str, before: str | None = None, viewer: dict | None = None) -> tuple[list, str | None]:
posts_table = get_table("posts")
posts, next_cursor = paginate(
posts_table,
before=before,
viewer_uid=viewer["uid"] if viewer else None,
project_uid=project_uid,
)
if not posts:
return [], None
authors = get_users_by_uids([p["user_uid"] for p in posts])
counts = get_comment_counts_by_post_uids([p["uid"] for p in posts])
result = enrich_items(
posts, "post", authors, {"comment_count": counts}, user=viewer
)
return result, next_cursor
+2
View File
@@ -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)"},
]
+8 -2
View File
@@ -1,5 +1,7 @@
# retoor <retoor@molodetz.nl>
import os
from .core import TTLCache, _in_clause, _now_iso, db, get_table
from .users import get_users_by_uids
from .soft_delete import soft_delete, soft_delete_in
@@ -10,13 +12,17 @@ VOTABLE_TARGETS: dict[str, str] = {
"project": "projects",
"gist": "gists",
"comment": "comments",
"quiz": "quizzes",
}
STAR_TARGETS: set[str] = {"post", "project", "gist"}
STAR_TARGETS: set[str] = {"post", "project", "gist", "quiz"}
_authors_cache = TTLCache(ttl=60, max_size=200)
RANKING_TTL = int(os.environ.get("DEVPLACE_RANKING_TTL", "60"))
_authors_cache = TTLCache(ttl=RANKING_TTL, max_size=200)
_stars_cache = TTLCache(ttl=15, max_size=2000)
+337 -13
View File
@@ -42,6 +42,7 @@ def init_db():
_index(db, "posts", "idx_posts_created_at", ["created_at"])
_index(db, "posts", "idx_posts_topic", ["topic"])
_index(db, "posts", "idx_posts_slug", ["slug"])
_index(db, "posts", "idx_posts_project_uid", ["project_uid"])
if "posts" in tables:
posts_table = get_table("posts")
if not posts_table.has_column("tags"):
@@ -358,6 +359,21 @@ def init_db():
_index(
db, "issue_comment_authors", "idx_issue_comment_authors_number", ["gitea_number"]
)
ws_tickets = get_table("ws_tickets")
for column, example in (
("uid", ""),
("token", ""),
("user_uid", ""),
("created_at", ""),
("expires_at", ""),
("used_at", ""),
):
if not ws_tickets.has_column(column):
ws_tickets.create_column_by_example(column, example)
_index(db, "ws_tickets", "idx_ws_tickets_token", ["token"], unique=True)
_index(db, "ws_tickets", "idx_ws_tickets_expires", ["expires_at"])
migrate_bug_tables_to_issue_tables()
_index(db, "service_state", "idx_service_state_name", ["name"])
if "devii_conversations" in db.tables:
@@ -389,6 +405,46 @@ def init_db():
"idx_devii_turns_owner_time",
["owner_kind", "owner_id", "started_at"],
)
if "devii_tasks" in db.tables:
tasks = get_table("devii_tasks")
for column, example in (
("expires_at", ""),
("failure_count", 0),
("notify", 0),
("tz", ""),
):
if not tasks.has_column(column):
tasks.create_column_by_example(column, example)
try:
with db:
db.query(
"UPDATE devii_tasks SET failure_count = 0 WHERE failure_count IS NULL"
)
except Exception as e: # noqa: BLE001
logger.warning(f"Could not backfill devii_tasks.failure_count: {e}")
task_runs = get_table("devii_task_runs")
for column, example in (
("uid", ""),
("owner_kind", ""),
("owner_id", ""),
("task_uid", ""),
("created_at", ""),
):
if not task_runs.has_column(column):
task_runs.create_column_by_example(column, example)
_index(
db,
"devii_task_runs",
"idx_devii_task_runs_owner_time",
["owner_kind", "owner_id", "created_at"],
)
_index(db, "devii_task_runs", "idx_devii_task_runs_time", ["created_at"])
_index(
db,
"devii_tasks",
"idx_devii_tasks_owner_created",
["owner_kind", "owner_id", "created_at"],
)
_index(db, "devii_tasks", "idx_devii_tasks_owner", ["owner_kind", "owner_id"])
_index(
db, "devii_tasks", "idx_devii_tasks_due", ["enabled", "status", "next_run_at"]
@@ -471,16 +527,26 @@ def init_db():
_index(db, "jobs", "idx_jobs_expires", ["expires_at"])
_index(db, "project_forks", "idx_project_forks_source", ["source_project_uid"])
_index(db, "project_forks", "idx_project_forks_forked", ["forked_project_uid"])
if "instances" in db.tables:
instances = get_table("instances")
for column, example in (
("run_as_uid", ""),
("boot_language", "none"),
("boot_script", ""),
("start_on_boot", 0),
):
if not instances.has_column(column):
instances.create_column_by_example(column, example)
instances = get_table("instances")
for column, example in (
("uid", ""),
("project_uid", ""),
("slug", ""),
("name", ""),
("status", ""),
("desired_state", ""),
("container_id", ""),
("ingress_slug", ""),
("ingress_port", 0),
("ports_json", ""),
("container_gateway", ""),
("run_as_uid", ""),
("boot_language", "none"),
("boot_script", ""),
("start_on_boot", 0),
):
if not instances.has_column(column):
instances.create_column_by_example(column, example)
_index(db, "instances", "idx_instances_project", ["project_uid"])
_index(db, "instances", "idx_instances_slug", ["slug"])
@@ -518,6 +584,10 @@ def init_db():
from devplacepy.services.openai_gateway import routing as gateway_routing
gateway_routing.ensure_tables()
from devplacepy.services.openai_gateway import quota as gateway_quota
gateway_quota.ensure_tables()
_index(db, "audit_log", "idx_audit_created_at", ["created_at"])
_index(db, "audit_log", "idx_audit_event_key", ["event_key"])
_index(db, "audit_log", "idx_audit_category", ["category"])
@@ -1014,6 +1084,32 @@ def init_db():
("legacy_speed", 0),
("legacy_plots", 0),
("legacy_defense", 0),
("legacy_carryover", 0),
("last_grant_week", ""),
("prestiged_at", ""),
("mastery_points", 0),
("mastery_points_earned_total", 0),
("mastery_autoreplant", 0),
("mastery_analytics", 0),
("mastery_contracts", 0),
("lifetime_coins_earned", 0),
("lifetime_harvests", 0),
("infra_registry", 0),
("infra_canary", 0),
("infra_observability", 0),
("defense_level", 0),
("defense_last_upkeep_at", ""),
("upkeep_amnesty", 0),
("active_title", ""),
("underdog_boost_until", ""),
("contract_boost_until", ""),
("harvests_week", 0),
("harvests_week_start", ""),
("last_kernel_harvest_prestige", 0),
("time_to_kernel_seconds", 0),
("era_coins", 0),
("era_harvests", 0),
("era_joined_at", ""),
("created_at", ""),
("updated_at", ""),
):
@@ -1038,6 +1134,7 @@ def init_db():
_index(
db, "game_steals", "idx_game_steals_pair", ["thief_uid", "owner_uid", "stolen_at"]
)
_index(db, "game_steals", "idx_game_steals_owner_time", ["owner_uid", "stolen_at"])
game_quests = get_table("game_quests")
for column, example in (
@@ -1045,6 +1142,7 @@ def init_db():
("farm_uid", ""),
("user_uid", ""),
("day", ""),
("scope", "daily"),
("slot_index", 0),
("kind", ""),
("label", ""),
@@ -1052,13 +1150,17 @@ def init_db():
("progress", 0),
("reward_coins", 0),
("reward_xp", 0),
("reward_stars", 0),
("claimed", 0),
("created_at", ""),
("updated_at", ""),
):
if not game_quests.has_column(column):
game_quests.create_column_by_example(column, example)
with db:
db.query("UPDATE game_quests SET scope = 'daily' WHERE scope IS NULL OR scope = ''")
_index(db, "game_quests", "idx_game_quests_farm_day", ["farm_uid", "day"])
_index(db, "game_quests", "idx_game_quests_farm_day_scope", ["farm_uid", "day", "scope"])
game_plots = get_table("game_plots")
for column, example in (
@@ -1070,6 +1172,7 @@ def init_db():
("planted_at", ""),
("ready_at", ""),
("watered_by", "[]"),
("raided_fraction", 0.0),
("created_at", ""),
("updated_at", ""),
):
@@ -1077,6 +1180,227 @@ def init_db():
game_plots.create_column_by_example(column, example)
_index(db, "game_plots", "idx_game_plots_farm", ["farm_uid", "slot_index"])
game_market_ticks = get_table("game_market_ticks")
for column, example in (
("uid", ""),
("crop_key", ""),
("hour_bucket", ""),
("harvests", 0),
("updated_at", ""),
):
if not game_market_ticks.has_column(column):
game_market_ticks.create_column_by_example(column, example)
_index(
db,
"game_market_ticks",
"idx_game_market_ticks_bucket",
["crop_key", "hour_bucket"],
unique=True,
)
game_cosmetics = get_table("game_cosmetics")
for column, example in (
("uid", ""),
("user_uid", ""),
("cosmetic_key", ""),
("purchased_at", ""),
("created_at", ""),
):
if not game_cosmetics.has_column(column):
game_cosmetics.create_column_by_example(column, example)
_index(
db,
"game_cosmetics",
"idx_game_cosmetics_owner",
["user_uid", "cosmetic_key"],
unique=True,
)
game_treasury = get_table("game_treasury")
for column, example in (
("uid", ""),
("balance", 0),
("collected_total", 0),
("granted_total", 0),
("updated_at", ""),
):
if not game_treasury.has_column(column):
game_treasury.create_column_by_example(column, example)
game_eras = get_table("game_eras")
for column, example in (
("uid", ""),
("era_number", 0),
("name", ""),
("started_at", ""),
("ends_at", ""),
("active", 0),
("created_at", ""),
):
if not game_eras.has_column(column):
game_eras.create_column_by_example(column, example)
_index(db, "game_eras", "idx_game_eras_active", ["active"])
game_era_results = get_table("game_era_results")
for column, example in (
("uid", ""),
("era_number", 0),
("user_uid", ""),
("rank", 0),
("era_score", 0),
("era_coins_final", 0),
("joined_at", ""),
("reward_stars", 0),
("reward_cosmetic_key", ""),
("created_at", ""),
):
if not game_era_results.has_column(column):
game_era_results.create_column_by_example(column, example)
_index(db, "game_era_results", "idx_game_era_results_era", ["era_number", "rank"])
quizzes = get_table("quizzes")
for column, example in (
("uid", ""),
("user_uid", ""),
("slug", ""),
("title", ""),
("description", ""),
("status", "draft"),
("published_at", ""),
("shuffle_questions", 0),
("shuffle_options", 0),
("reveal_answers", 0),
("allow_review", 0),
("time_limit_seconds", 0),
("pass_percent", 0),
("question_count", 0),
("total_points", 0),
("attempt_count", 0),
("stars", 0),
("content_version", 0),
("created_at", ""),
("updated_at", ""),
("deleted_at", ""),
("deleted_by", ""),
):
if not quizzes.has_column(column):
quizzes.create_column_by_example(column, example)
_index(db, "quizzes", "idx_quizzes_slug", ["slug"], unique=True)
_index(db, "quizzes", "idx_quizzes_user_created", ["user_uid", "created_at"])
_index(db, "quizzes", "idx_quizzes_status_created", ["status", "created_at"])
_index(
db,
"quizzes",
"idx_quizzes_live_created",
["created_at"],
where="deleted_at IS NULL",
)
quiz_questions = get_table("quiz_questions")
for column, example in (
("uid", ""),
("quiz_uid", ""),
("position", 0),
("kind", ""),
("prompt", ""),
("explanation", ""),
("points", 1),
("media_attachment_uid", ""),
("correct_boolean", 0),
("expected_answer", ""),
("grading_criteria", ""),
("numeric_value", 0.0),
("numeric_tolerance", 0.0),
("case_sensitive", 0),
("created_at", ""),
("updated_at", ""),
("deleted_at", ""),
("deleted_by", ""),
):
if not quiz_questions.has_column(column):
quiz_questions.create_column_by_example(column, example)
_index(
db, "quiz_questions", "idx_quiz_questions_quiz_position", ["quiz_uid", "position"]
)
quiz_options = get_table("quiz_options")
for column, example in (
("uid", ""),
("question_uid", ""),
("quiz_uid", ""),
("position", 0),
("label", ""),
("match_value", ""),
("is_correct", 0),
("created_at", ""),
("updated_at", ""),
("deleted_at", ""),
("deleted_by", ""),
):
if not quiz_options.has_column(column):
quiz_options.create_column_by_example(column, example)
_index(
db,
"quiz_options",
"idx_quiz_options_question_position",
["question_uid", "position"],
)
_index(db, "quiz_options", "idx_quiz_options_quiz", ["quiz_uid"])
quiz_attempts = get_table("quiz_attempts")
for column, example in (
("uid", ""),
("quiz_uid", ""),
("user_uid", ""),
("status", "in_progress"),
("question_order", "[]"),
("started_at", ""),
("expires_at", ""),
("completed_at", ""),
("answered_count", 0),
("score_points", 0.0),
("max_points", 0),
("score_percent", 0.0),
("passed", 0),
("created_at", ""),
("updated_at", ""),
("deleted_at", ""),
("deleted_by", ""),
):
if not quiz_attempts.has_column(column):
quiz_attempts.create_column_by_example(column, example)
_index(db, "quiz_attempts", "idx_quiz_attempts_user_created", ["user_uid", "created_at"])
_index(db, "quiz_attempts", "idx_quiz_attempts_quiz_status", ["quiz_uid", "status"])
_index(db, "quiz_attempts", "idx_quiz_attempts_user_quiz", ["user_uid", "quiz_uid"])
_index(db, "quiz_attempts", "idx_quiz_attempts_status_user", ["status", "user_uid"])
quiz_answers = get_table("quiz_answers")
for column, example in (
("uid", ""),
("attempt_uid", ""),
("question_uid", ""),
("quiz_uid", ""),
("position", 0),
("answer_text", ""),
("option_uids", "[]"),
("answered_at", ""),
("is_correct", 0),
("awarded_points", 0.0),
("feedback", ""),
("graded_by", ""),
("confidence", 0.0),
("created_at", ""),
("updated_at", ""),
("deleted_at", ""),
("deleted_by", ""),
):
if not quiz_answers.has_column(column):
quiz_answers.create_column_by_example(column, example)
_index(
db, "quiz_answers", "idx_quiz_answers_attempt_position", ["attempt_uid", "position"]
)
_index(db, "quiz_answers", "idx_quiz_answers_quiz", ["quiz_uid"])
_index(db, "posts", "idx_posts_user_created", ["user_uid", "created_at"])
_index(
db,
@@ -1353,9 +1677,6 @@ def migrate_ai_gateway_settings() -> None:
if not get_setting("gateway_vision_key", "") and openrouter:
set_setting("gateway_vision_key", openrouter)
logger.info("Migrated gateway vision key from environment")
if not get_setting("gateway_embed_key", "") and openrouter:
set_setting("gateway_embed_key", openrouter)
logger.info("Migrated gateway embed key from environment")
for key in ("news_ai_url", "bot_api_url", "devii_ai_url"):
if get_setting(key, "") == OLD_GATEWAY_URL:
set_setting(key, INTERNAL_GATEWAY_URL)
@@ -1380,6 +1701,8 @@ def backfill_api_keys() -> int:
users = db["users"]
if not users.has_column("api_key"):
users.create_column_by_example("api_key", "")
if not users.has_column("created_at"):
users.create_column_by_example("created_at", "")
if not users.has_column("cust_disable_global"):
users.create_column_by_example("cust_disable_global", 0)
if not users.has_column("cust_disable_pagetype"):
@@ -1504,3 +1827,4 @@ def _backfill_gamification():
for user in pending:
check_milestone_badges(user["uid"])
logger.info(f"Gamification backfill processed {len(pending)} users")
+1 -1
View File
@@ -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:
+5
View File
@@ -42,6 +42,11 @@ SOFT_DELETE_TABLES = [
"user_relations",
"seo_metadata",
"awards",
"quizzes",
"quiz_questions",
"quiz_options",
"quiz_attempts",
"quiz_answers",
]
+18 -3
View File
@@ -15,6 +15,9 @@ def get_users_by_uids(uids):
_admins_cache = TTLCache(ttl=300, max_size=4)
# The primary administrator must be an account that can actually authenticate, so scan a
# few of the earliest admins and skip any that are soft-deleted or deactivated.
PRIMARY_ADMIN_CANDIDATES = 50
def invalidate_admins_cache() -> None:
@@ -71,6 +74,12 @@ def get_online_users(cutoff_iso: str, limit: int = 30) -> list:
)
def _can_hold_primary_admin(row, tracks_active):
if row.get("deleted_at"):
return False
return not tracks_active or bool(row.get("is_active"))
def get_primary_admin_uid():
sync_local_cache("admins", _admins_cache)
cached = _admins_cache.get("primary")
@@ -80,11 +89,17 @@ def get_primary_admin_uid():
return None
rows = list(
db.query(
"SELECT uid FROM users WHERE role = 'Admin' "
"ORDER BY created_at ASC, id ASC LIMIT 1"
"SELECT * FROM users WHERE role = 'Admin' "
"ORDER BY (created_at IS NULL OR created_at = ''), created_at ASC, id ASC "
"LIMIT :cap",
cap=PRIMARY_ADMIN_CANDIDATES,
)
)
primary = rows[0]["uid"] if rows else None
tracks_active = "is_active" in db["users"].columns
primary = next(
(row["uid"] for row in rows if _can_hold_primary_admin(row, tracks_active)),
None,
)
_admins_cache.set("primary", primary or "")
return primary
+4 -4
View File
@@ -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",
+2
View File
@@ -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,
]
+125
View File
@@ -613,6 +613,53 @@ four ways to sign requests.
auth="admin",
destructive=True,
),
endpoint(
id="admin-gateway-quota-rules",
method="GET",
path="/admin/gateway/quota-rules",
title="List AI gateway quota rules",
summary=(
"List every rolling-24h USD quota rule on /openai/v1/*, each scoped by any "
"combination of role, specific user uid, and app_reference label, plus the "
"global per-role default caps that apply when no rule matches."
),
auth="admin",
interactive=True,
),
endpoint(
id="admin-gateway-quota-rule-set",
method="POST",
path="/admin/gateway/quota-rules",
title="Create or update an AI gateway quota rule",
summary=(
"Caps rolling-24h USD spend on /openai/v1/*. At least one of owner_kind, "
"owner_id, app_reference must be set; leaving a dimension blank makes it a "
"wildcard, and the most specific active match wins over other rules and over "
"the global default. Pass uid to update an existing rule."
),
auth="admin",
params=[
field("uid", "json", "string", False, "", "Existing rule uid to update; omit to create a new rule."),
field("owner_kind", "json", "string", False, "user", "internal, key, user, admin, or anonymous. Blank = any role."),
field("owner_id", "json", "string", False, "", "Specific user uid. Blank = any caller of the matched role."),
field("app_reference", "json", "string", False, "devplace-bots-v-1-0-0", "App label (the X-App-Reference header). Blank = any app."),
field("limit_usd", "json", "number", True, "2.5", "Rolling 24h USD cap. 0 = unlimited."),
field("is_active", "json", "boolean", False, "true", "Whether the rule is enforced."),
field("label", "json", "string", False, "", "Optional admin-facing note."),
],
),
endpoint(
id="admin-gateway-quota-rule-delete",
method="DELETE",
path="/admin/gateway/quota-rules/{uid}",
title="Delete an AI gateway quota rule",
summary="Delete a quota rule; callers it covered fall back to the next most specific rule or the global default.",
auth="admin",
destructive=True,
params=[
field("uid", "path", "string", True, "RULE_UID", "Quota rule uid."),
],
),
endpoint(
id="admin-bots-monitor",
method="GET",
@@ -670,6 +717,52 @@ four ways to sign requests.
)
],
),
endpoint(
id="admin-devii-tasks",
method="GET",
path="/admin/devii-tasks",
title="Scheduled Devii tasks",
summary=(
"Every scheduled Devii task across all owners with its schedule, run count, "
"expiry, failure streak, and whether its owner may still schedule, plus the "
"configured automation bounds. Returns HTML (or JSON with "
"Accept: application/json)."
),
auth="admin",
interactive=True,
params=[
field(
"state",
"query",
"string",
False,
"active",
"One of active, inactive, all.",
)
],
),
endpoint(
id="admin-devii-task-disable",
method="POST",
path="/admin/devii-tasks/{uid}/disable",
title="Disable a scheduled task",
summary="Stop one scheduled task. The row is kept and stays auditable.",
auth="admin",
params=[
field("uid", "path", "string", True, "", "Uid of the task."),
],
),
endpoint(
id="admin-devii-task-delete",
method="POST",
path="/admin/devii-tasks/{uid}/delete",
title="Delete a scheduled task",
summary="Soft-delete one scheduled task; it moves to the admin trash.",
auth="admin",
params=[
field("uid", "path", "string", True, "", "Uid of the task."),
],
),
endpoint(
id="admin-backups",
method="GET",
@@ -830,5 +923,37 @@ four ways to sign requests.
params=[field("uid", "path", "string", True, "", "Schedule uid.")],
sample_response={"ok": True, "redirect": "/admin/backups"},
),
endpoint(
id="admin-game",
method="GET",
path="/admin/game",
title="Code Farm Era management",
summary="View the current Code Farm Era status.",
auth="admin",
sample_response={"era_active": False, "era_name": ""},
),
endpoint(
id="admin-game-era-start",
method="POST",
path="/admin/game/era/start",
title="Start an Era",
summary="Start a new Code Farm Era: resets every farm's visible Era coins/harvests counters to zero. Real coins, prestige, stars, Legacy, and Mastery are never touched.",
auth="admin",
params=[
field("name", "form", "string", True, "Genesis", "Era name."),
field("duration_days", "form", "int", False, "28", "Planned Era length in days."),
],
sample_response={"ok": True, "redirect": "/admin/game"},
),
endpoint(
id="admin-game-era-end",
method="POST",
path="/admin/game/era/end",
title="End the running Era",
summary="End the current Era: ranks every participating farm by Era score, awards Stars to the top 10 (and an Era-exclusive cosmetic when available), and permanently records the results.",
auth="admin",
destructive=True,
sample_response={"ok": True, "redirect": "/admin/game"},
),
],
}
@@ -1,580 +0,0 @@
# retoor <retoor@molodetz.nl>
from .._shared import endpoint, field
GROUP = {
"slug": "containers",
"title": "Container Manager",
"admin": True,
"intro": """
# Container Manager
Run supervised container instances for a project. There is no in-app image building: every instance
runs one shared prebuilt image (`ppy:latest`) with the project's workspace mounted at `/app`. Every
endpoint is **administrator only** (docker socket access is root-equivalent). Mutations flip desired
state; a single reconciler converges containers to it.
""",
"endpoints": [
endpoint(
id="containers-page",
method="GET",
path="/projects/{project_slug}/containers",
title="Container manager page",
summary="The admin per-project container manager UI (instance creation and lifecycle). Returns 404 for an administrator who is not the owner of an administrator-hidden project.",
auth="admin",
interactive=True,
params=[
field(
"project_slug",
"path",
"string",
True,
"PROJECT_SLUG",
"Project slug or uid.",
)
],
),
endpoint(
id="containers-admin-index",
method="GET",
path="/admin/containers",
title="Admin containers list",
summary="The admin Containers section: every instance across all projects, each linking to its detail page. Instances attached to another administrator's hidden project are excluded, and per-instance actions return 404 for a non-owner administrator.",
auth="admin",
interactive=True,
),
endpoint(
id="containers-admin-data",
method="GET",
path="/admin/containers/data",
title="Admin containers list data",
summary="JSON of every instance across all projects (decorated with project title/slug) for polling.",
auth="admin",
sample_response={
"instances": [
{
"uid": "INSTANCE_UID",
"name": "staging",
"status": "running",
"project_slug": "PROJECT_SLUG",
"project_title": "My Project",
"ingress_slug": "my-service",
"restart_policy": "always",
}
]
},
),
endpoint(
id="containers-admin-instance",
method="GET",
path="/admin/containers/{uid}",
title="Instance detail page",
summary="The dedicated detail page for one instance (lifecycle, logs, metrics, terminal, schedules, ingress, sync).",
auth="admin",
interactive=True,
params=[
field(
"uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
)
],
),
endpoint(
id="containers-admin-edit-page",
method="GET",
path="/admin/containers/{uid}/edit",
title="Edit instance page",
summary="The edit page for one instance (run-as user, boot language/script/command, restart policy, start-on-boot, limits).",
auth="admin",
interactive=True,
params=[
field(
"uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
)
],
),
endpoint(
id="containers-create-instance",
method="POST",
path="/projects/{project_slug}/containers/instances",
title="Create an instance",
summary="Create and (by default) start an instance; it runs the shared ppy image with the project workspace mounted at /app.",
auth="admin",
encoding="form",
destructive=True,
params=[
field(
"project_slug",
"path",
"string",
True,
"PROJECT_SLUG",
"Project slug or uid.",
),
field("name", "form", "string", True, "staging", "Instance name."),
field(
"boot_command",
"form",
"string",
False,
"python app.py",
"Optional boot command.",
),
field("run_as_uid", "form", "string", False, "USER_UID", "DevPlace user uid whose identity and API key are injected (PRAVDA_API_KEY, PRAVDA_USER_UID). Does NOT change the container OS user (always pravda, uid 1000)."),
field("boot_language", "form", "enum", False, "none", "Boot source language.", ["none", "python", "bash"]),
field("boot_script", "form", "textarea", False, "print('hi')", "Boot source code run on launch (takes precedence over boot_command)."),
field(
"env",
"form",
"textarea",
False,
"KEY=VALUE",
"Env vars, one KEY=VALUE per line.",
),
field(
"ports",
"form",
"string",
False,
"80",
"Port maps. Bare container port auto-assigns a unique host port above 20000; host:container pins one.",
),
field("cpu_limit", "form", "string", False, "1.5", "CPU limit."),
field(
"mem_limit", "form", "string", False, "512m", "Memory limit."
),
field(
"restart_policy",
"form",
"enum",
False,
"never",
"Restart policy.",
["never", "always", "on-failure", "unless-stopped"],
),
field("start_on_boot", "form", "boolean", False, "false", "Force running whenever the container service starts."),
field(
"ingress_slug",
"form",
"string",
False,
"my-service",
"Publish at /p/<slug> (optional).",
),
field(
"ingress_port",
"form",
"integer",
False,
"8899",
"Container port to publish (must be a mapped port).",
),
],
),
endpoint(
id="containers-ingress",
method="GET",
path="/p/{slug}",
title="Container ingress proxy",
summary="Public reverse proxy (HTTP and WebSocket) to a running instance published via ingress_slug. The /p/<slug> prefix is stripped before forwarding.",
auth="public",
interactive=True,
params=[
field(
"slug",
"path",
"string",
True,
"my-service",
"The instance's ingress_slug.",
)
],
),
endpoint(
id="containers-instance-action",
method="POST",
path="/projects/{project_slug}/containers/instances/{uid}/{action}",
title="Instance lifecycle",
summary="start, stop, restart, pause, or resume an instance (flips desired state).",
auth="admin",
destructive=True,
params=[
field(
"project_slug",
"path",
"string",
True,
"PROJECT_SLUG",
"Project slug or uid.",
),
field(
"uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
),
field(
"action",
"path",
"enum",
True,
"start",
"Lifecycle action.",
["start", "stop", "restart", "pause", "resume"],
),
],
),
endpoint(
id="containers-instance-logs",
method="GET",
path="/projects/{project_slug}/containers/instances/{uid}/logs",
title="Instance logs",
summary="Recent docker logs of a running instance.",
auth="admin",
params=[
field(
"project_slug",
"path",
"string",
True,
"PROJECT_SLUG",
"Project slug or uid.",
),
field(
"uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
),
field("tail", "query", "integer", False, "200", "Number of lines."),
],
sample_response={"logs": "..."},
),
endpoint(
id="containers-instance-sync",
method="POST",
path="/projects/{project_slug}/containers/instances/{uid}/sync",
title="Sync workspace",
summary="Run a one-shot bidirectional newer-wins sync between the project files and the container workspace.",
auth="admin",
destructive=True,
params=[
field(
"project_slug",
"path",
"string",
True,
"PROJECT_SLUG",
"Project slug or uid.",
),
field(
"uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
),
],
sample_response={"exported": 3, "imported": 1},
),
endpoint(
id="containers-instance-delete",
method="POST",
path="/projects/{project_slug}/containers/instances/{uid}/delete",
title="Delete instance",
summary="Remove a container instance and mark its container for removal.",
auth="admin",
destructive=True,
params=[
field(
"project_slug",
"path",
"string",
True,
"PROJECT_SLUG",
"Project slug or uid.",
),
field(
"uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
),
],
),
endpoint(
id="containers-instance-exec",
method="POST",
path="/projects/{project_slug}/containers/instances/{uid}/exec",
title="Exec a command",
summary="Run a one-shot command inside a running instance and return its output.",
auth="admin",
encoding="form",
destructive=True,
params=[
field(
"project_slug",
"path",
"string",
True,
"PROJECT_SLUG",
"Project slug or uid.",
),
field(
"uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
),
field(
"command",
"form",
"string",
True,
"ls -la /app",
"Shell command to run (via /bin/sh -c).",
),
],
),
endpoint(
id="containers-instance-data",
method="GET",
path="/projects/{project_slug}/containers/instances/{uid}",
title="Instance detail data",
summary="Return the full instance row plus runtime info as JSON.",
auth="admin",
params=[
field(
"project_slug",
"path",
"string",
True,
"PROJECT_SLUG",
"Project slug or uid.",
),
field(
"uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
),
],
sample_response={"uid": "INSTANCE_UID", "name": "staging", "status": "running"},
),
endpoint(
id="containers-instance-metrics",
method="GET",
path="/projects/{project_slug}/containers/instances/{uid}/metrics",
title="Instance metrics",
summary="Return recent metrics ring-buffer and aggregated stats for a running instance.",
auth="admin",
params=[
field(
"project_slug",
"path",
"string",
True,
"PROJECT_SLUG",
"Project slug or uid.",
),
field(
"uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
),
],
sample_response={"metrics": [], "stats": {}},
),
endpoint(
id="containers-instance-schedules",
method="POST",
path="/projects/{project_slug}/containers/instances/{uid}/schedules",
title="Create a schedule",
summary="Attach a cron, one-time, interval, or delay schedule to an instance.",
auth="admin",
encoding="form",
destructive=True,
params=[
field(
"project_slug",
"path",
"string",
True,
"PROJECT_SLUG",
"Project slug or uid.",
),
field(
"uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
),
field(
"action",
"form",
"string",
True,
"start",
"Lifecycle action to run on schedule (start, stop, restart).",
),
field(
"kind",
"form",
"string",
True,
"cron",
"Schedule kind: cron, once, interval, or delay.",
),
field(
"cron",
"form",
"string",
False,
"0 * * * *",
"Cron expression (when kind is cron).",
),
field(
"run_at",
"form",
"string",
False,
"2026-01-01T00:00:00",
"ISO timestamp for a one-time run (when kind is once).",
),
field(
"delay_seconds",
"form",
"integer",
False,
"60",
"Seconds to wait before a single run (when kind is delay).",
),
field(
"every_seconds",
"form",
"integer",
False,
"300",
"Interval in seconds between runs (when kind is interval).",
),
field(
"max_runs",
"form",
"integer",
False,
"10",
"Optional cap on the number of runs.",
),
],
),
endpoint(
id="containers-instance-schedule-delete",
method="POST",
path="/projects/{project_slug}/containers/instances/{uid}/schedules/{sid}/delete",
title="Delete a schedule",
summary="Remove a schedule from an instance.",
auth="admin",
destructive=True,
params=[
field(
"project_slug",
"path",
"string",
True,
"PROJECT_SLUG",
"Project slug or uid.",
),
field(
"uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
),
field(
"sid", "path", "string", True, "SCHEDULE_UID", "Schedule uid."
),
],
),
endpoint(
id="containers-admin-create",
method="POST",
path="/admin/containers/create",
title="Admin create instance",
summary="Create an instance from the admin Containers page: project search-select, run-as user, boot language/script, restart policy, start-on-boot, plus the usual options.",
auth="admin",
encoding="form",
destructive=True,
params=[
field("project_slug", "form", "string", True, "PROJECT_SLUG", "Project that becomes the /app root."),
field("name", "form", "string", True, "staging", "Instance name."),
field("run_as_uid", "form", "string", False, "USER_UID", "DevPlace user uid whose identity and API key are injected (PRAVDA_API_KEY, PRAVDA_USER_UID). Does NOT change the container OS user (always pravda, uid 1000)."),
field("boot_language", "form", "enum", False, "none", "Boot source language.", ["none", "python", "bash"]),
field("boot_script", "form", "textarea", False, "print('hi')", "Boot source code run on launch (takes precedence over boot_command)."),
field("boot_command", "form", "string", False, "python app.py", "Fallback boot command when no boot_script is set."),
field("restart_policy", "form", "enum", False, "never", "Restart policy.", ["never", "always", "on-failure", "unless-stopped"]),
field("start_on_boot", "form", "boolean", False, "false", "Force running whenever the container service starts."),
field("env", "form", "textarea", False, "KEY=VALUE", "Env vars, one KEY=VALUE per line."),
field("ports", "form", "string", False, "80", "Port maps; bare container port auto-assigns a host port above 20000."),
field("cpu_limit", "form", "string", False, "1.5", "CPU limit."),
field("mem_limit", "form", "string", False, "512m", "Memory limit."),
field("ingress_slug", "form", "string", False, "my-service", "Publish at /p/<slug> (optional)."),
field("ingress_port", "form", "integer", False, "8899", "Container port to publish."),
],
),
endpoint(
id="containers-admin-edit",
method="POST",
path="/admin/containers/{uid}/edit",
title="Admin edit instance",
summary="Update an instance's run-as user, boot language/script/command, restart policy, start-on-boot flag, and resource limits.",
auth="admin",
encoding="form",
destructive=True,
params=[
field("uid", "path", "string", True, "INSTANCE_UID", "Instance uid."),
field("run_as_uid", "form", "string", False, "USER_UID", "Run-as user uid (identity + API key only)."),
field("boot_language", "form", "enum", False, "none", "Boot source language.", ["none", "python", "bash"]),
field("boot_script", "form", "textarea", False, "print('hi')", "Boot source code."),
field("boot_command", "form", "string", False, "python app.py", "Fallback boot command."),
field("restart_policy", "form", "enum", False, "never", "Restart policy.", ["never", "always", "on-failure", "unless-stopped"]),
field("start_on_boot", "form", "boolean", False, "false", "Force running on container-service boot."),
field("cpu_limit", "form", "string", False, "1.5", "CPU limit."),
field("mem_limit", "form", "string", False, "512m", "Memory limit."),
],
),
endpoint(
id="containers-admin-action",
method="POST",
path="/admin/containers/{uid}/{action}",
title="Admin instance lifecycle",
summary="start, stop, restart, pause, or resume an instance from the admin Containers page (flips desired state).",
auth="admin",
destructive=True,
params=[
field("uid", "path", "string", True, "INSTANCE_UID", "Instance uid."),
field("action", "path", "enum", True, "start", "Lifecycle action.", ["start", "stop", "restart", "pause", "resume"]),
],
),
endpoint(
id="containers-admin-sync",
method="POST",
path="/admin/containers/{uid}/sync",
title="Admin bidirectional sync",
summary="Run a one-shot bidirectional newer-wins sync between the project files and the container workspace.",
auth="admin",
destructive=True,
params=[
field("uid", "path", "string", True, "INSTANCE_UID", "Instance uid."),
],
sample_response={"exported": 3, "imported": 1},
),
endpoint(
id="containers-admin-delete",
method="POST",
path="/admin/containers/{uid}/delete",
title="Admin delete instance",
summary="Soft-delete an instance and mark its container for removal.",
auth="admin",
destructive=True,
params=[
field("uid", "path", "string", True, "INSTANCE_UID", "Instance uid."),
],
),
endpoint(
id="containers-admin-project-search",
method="GET",
path="/admin/containers/projects/search",
title="Admin project search",
summary="Search projects by title for the admin create form (returns uid, slug, title).",
auth="admin",
params=[
field("q", "query", "string", False, "api", "Title fragment."),
],
sample_response={"results": [{"uid": "PROJECT_UID", "slug": "PROJECT_SLUG", "title": "My Project"}]},
),
endpoint(
id="containers-admin-user-search",
method="GET",
path="/admin/containers/users/search",
title="Admin run-as user search",
summary="Search users by username for the run-as-user select (returns uid, username).",
auth="admin",
params=[
field("q", "query", "string", False, "alice", "Username fragment."),
],
sample_response={"results": [{"uid": "USER_UID", "username": "alice"}]},
),
],
}
+233 -30
View File
@@ -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"}},
),
],
}
+4 -4
View File
@@ -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(
+35 -2
View File
@@ -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},
),
],
}
+1 -1
View File
@@ -320,7 +320,7 @@ four ways to sign requests.
),
field(
"description",
"body",
"json",
"string",
True,
"Great work on the release!",
+556
View 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,
},
),
],
}
+2 -3
View File
@@ -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={
+144 -6
View File
@@ -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"},
),
],
+2
View File
@@ -66,6 +66,7 @@ from devplacepy.routers import (
issues,
news,
gists,
quizzes,
uploads,
media,
push,
@@ -463,6 +464,7 @@ app.include_router(devrant.router, prefix="/api")
app.include_router(dbapi.router, prefix="/dbapi")
app.include_router(pubsub.router, prefix="/pubsub")
app.include_router(game.router, prefix="/game")
app.include_router(quizzes.router, prefix="/quizzes")
@app.middleware("http")
+297 -7
View File
@@ -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
+16 -8
View File
@@ -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
+6
View File
@@ -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"
+8 -6
View File
@@ -15,7 +15,7 @@ Prefixes are wired in `main.py`:
| `/projects` | projects/ package - `index.py` (listing/detail/create/delete plus owner-only visibility toggles `POST /{slug}/private` and `POST /{slug}/readonly`, and async `POST /{slug}/fork`), `files.py`, and the `containers/` subpackage (below). `main.py` mounts the whole `/projects` tree from this one package. See `routers/projects/CLAUDE.md` for the deep detail on this tree |
| `/projects/{slug}/files` | projects/files.py - per-project virtual filesystem (CRUD dirs/files, upload, inline edit, plus line-range ops: `lines` read, `replace-lines`, `insert-lines`, `delete-lines`, `append` for large text files) |
| `/profile` | profile/ package - `index.py` (page/search/followers/update/api-key; the **Posts** tab shows only the 10 most recent posts, while the sidebar `posts_count` stat always uses the real `count()` so it is never capped to the rendered 10), `customization.py`, `notifications.py`, `ai_correction.py` (`POST /{username}/ai-correction`, owner-or-admin AI content correction toggle + prompt), `ai_modifier.py` (`POST /{username}/ai-modifier`, owner-or-admin AI modifier toggle + prompt), `interactions.py` (`POST /{username}/interactions`, owner-or-admin Devii interactive widgets preference; admin default via `devii_interactions_default`), `telegram.py` (`POST /{username}/telegram`, owner-or-admin Telegram pairing-code request/unpair), `usage.py` (`_ai_quota`, re-exported from the package) |
| `/messages` | messages.py - real-time DM chat. `GET ""` (page; the conversation sidebar is one windowed SQL aggregate and an opened thread loads only the newest `CONVERSATION_MESSAGE_LIMIT` = 500 messages - keep these queries bounded), `GET /search`, `POST /send` (no-JS fallback), and `WS /messages/ws` (NOT lock-owner gated - accepts on every worker; closes `1008` for guests). The WS adds live bidirectional delivery, typing, read receipts, and in-process presence on top of the existing `messages` table; both the WS `send` and HTTP `POST /send` route through the shared `services/messaging/persist.py` `persist_message` choke point (identical audit/notification/mention) and then through `_finalize_and_broadcast`, which awaits any pending SYNC AI correction/modifier futures (DM content runs through both, so typing `@ai <instruction>` executes live) and broadcasts the FINAL corrected/modified content (the WS path passes `request=websocket` and awaits directly since HTTP middleware does not run for websockets; the frontend swaps the sender's optimistic bubble to the authoritative content). Same-worker delivery is instant via the per-worker `message_hub`; cross-worker delivery is filled by `services/messaging/relay.py` `message_relay` (per-worker ~1s DB poll on the `messages.id` watermark, deduped against the hub's delivered set). See `devplacepy/services/messaging/CLAUDE.md` |
| `/messages` | messages.py - real-time DM chat. `GET ""` (page; the conversation sidebar is one windowed SQL aggregate and an opened thread loads only the newest `CONVERSATION_MESSAGE_LIMIT` = 500 messages - keep these queries bounded), `GET /search`, `GET /conversations` (flat JSON conversation list, same `get_conversations` helper as the page), `POST /send` (no-JS fallback, also accepts an attachment-only empty-content message), `POST /ws-ticket` (issues a 30s single-use WS auth ticket so a browser WebSocket can authenticate cross-context, since a native `WebSocket` cannot set an `Authorization`/`X-API-KEY` header), and `WS /messages/ws` (NOT lock-owner gated - accepts on every worker; closes `1008` for guests). The WS adds live bidirectional delivery, typing, and read receipts on top of the existing `messages` table - presence is NOT part of this WS (it is the generic `PresenceManager`/pub/sub mechanism, see `devplacepy/services/CLAUDE.md`). Both the WS `send` and HTTP `POST /send` route through the shared `services/messaging/persist.py` `persist_message` choke point (identical audit/notification/mention) and then through `_finalize_and_broadcast`, which awaits any pending SYNC AI correction/modifier futures (DM content runs through both, so typing `@ai <instruction>` executes live), broadcasts the FINAL corrected/modified content with an additive `ai_processed` frame flag (the WS path passes `request=websocket` and awaits directly since HTTP middleware does not run for websockets; the frontend swaps the sender's optimistic bubble to the authoritative content). Same-worker delivery is instant via the per-worker `message_hub`; cross-worker delivery is filled by `services/messaging/relay.py` `message_relay` (per-worker ~1s DB poll on the `messages.id` watermark, deduped against the hub's delivered set). See `devplacepy/services/messaging/CLAUDE.md` |
| `/notifications` | notifications.py |
| `/votes` | votes.py |
| `/reactions` | reactions.py - emoji reaction toggle on any target: `POST /reactions/{target_type}/{target_uid}` (optimistic `ReactionBar`) |
@@ -25,12 +25,12 @@ Prefixes are wired in `main.py`:
| `/follow` | follow.py |
| (none) | relations.py - per-user block/mute relations: `POST /block/{username}`, `/block/unblock/{username}`, `/mute/{username}`, `/mute/unmute/{username}` (soft-deletable `user_relations` rows) |
| `/leaderboard` | leaderboard.py - `GET /leaderboard` XP/stars leaderboard page |
| `/admin` | admin/ package - one leaf per sub-resource (`index`, `users`, `aiusage`, `aiquota`, `media`, `trash`, `settings`, `notifications`, `news`, `auditlog`, `backups`) plus the folded-in `services.py` and `containers.py` (mounted with `/services` and `/containers` sub-prefixes). `main.py` mounts the whole `/admin` tree from this one package. The `backups` leaf is the admin **Backups** dashboard (`BackupService`, kind `backup`): storage usage, backup archives, and interval/cron backup schedules (CRUD + rotation). **Archive download is restricted to the primary administrator** (the earliest-created Admin, resolved by `database.get_primary_admin_uid` / `utils.is_primary_admin`): `GET /admin/backups/{uid}/download` 403s every other admin, the `download_url` field is withheld from them at every endpoint (`can_download = is_primary_admin(admin)`, surfaced as `BackupDashboardOut.can_download_backups`), and `BackupMonitor.js` renders their Download control as a disabled button tooltipped `Not available`. See `devplacepy/services/backup/CLAUDE.md` |
| `/admin` | admin/ package - one leaf per sub-resource (`index`, `users`, `aiusage`, `aiquota`, `media`, `trash`, `settings`, `notifications`, `news`, `auditlog`, `backups`, `game`) plus the folded-in `services.py` and `containers.py` (mounted with `/services` and `/containers` sub-prefixes). `main.py` mounts the whole `/admin` tree from this one package. The `backups` leaf is the admin **Backups** dashboard (`BackupService`, kind `backup`): storage usage, backup archives, and interval/cron backup schedules (CRUD + rotation). **Archive download is restricted to the primary administrator** (the earliest-created Admin, resolved by `database.get_primary_admin_uid` / `utils.is_primary_admin`): `GET /admin/backups/{uid}/download` 403s every other admin, the `download_url` field is withheld from them at every endpoint (`can_download = is_primary_admin(admin)`, surfaced as `BackupDashboardOut.can_download_backups`), and `BackupMonitor.js` renders their Download control as a disabled button tooltipped `Not available`. See `devplacepy/services/backup/CLAUDE.md`. The `game` leaf (`/admin/game`) is the Code Farm Era admin page: `GET /admin/game` (status), `POST /admin/game/era/start` and `/era/end` - see `devplacepy/services/game/CLAUDE.md` |
| `/admin/services` | admin/services.py |
| `/issues` | issues/ package - issue tracker backed by Gitea (no local issue store): `index.py` (list `?state=`/`?page=`, detail `/{number}` with comments), `create.py` (async AI-enhanced filing `/create` enqueues a `issue_create` job, status at `/jobs/{uid}`), `comment.py` (synchronous, pushes to Gitea + notifies admins), `status.py` (admin open/closed), `attachments.py` (file attachments on open issues + comments, mirrored to Gitea native assets; add/list/delete with owner-or-admin + open-state guards) |
| `/gists` | gists.py |
| `/news` | news.py |
| `/uploads` | uploads.py |
| `/uploads` | uploads.py - attachment management CRUD for the signed-in user over the ONE `attachments` table (the same rows that appear on posts/comments/projects/gists/issues). Create: `POST /upload` (multipart), `POST /upload-url` (server-side fetch). Read: `GET ""` (own attachments, paginated 24/page newest-first, `?page=`, `?linked=true|false` via `database.get_user_attachments`), `GET /{attachment_uid}` (one, via `database.get_user_attachment`). Update: `PATCH /{attachment_uid}` (rename display filename via `attachments.rename_attachment`; the original file extension is ALWAYS preserved - renaming can never change the file type, the upload-time security control - audit `attachment.rename`). Delete: `DELETE /delete/{attachment_uid}` (soft delete). All `require_user_api` (401 for guests); read/rename/delete of another user's row is owner-or-admin. JSON-only router (no HTML/`respond`); list uses `UploadsListOut`, single/rename return `UploadItemOut`. Devii tools mirror every face: `upload_file`/`attach_url`/`list_attachments`/`get_attachment`/`rename_attachment`/`delete_attachment` |
| `/media` | media.py - profile media gallery item soft delete/restore: `POST /media/{uid}/delete` and `POST /media/{uid}/restore` (owner or admin) |
| `/openai` | openai_gateway.py |
| `/devii` | devii.py - WebSocket terminal (`/devii/ws`), page, `/devii/usage`, `/devii/session` |
@@ -43,7 +43,8 @@ Prefixes are wired in `main.py`:
| `/xmlrpc` | xmlrpc.py - reverse-proxies XML-RPC calls to the forking XML-RPC bridge (`services/xmlrpc/`, supervised by `XmlrpcService` on loopback `config.XMLRPC_PORT`), which generates one XML-RPC method per documented REST endpoint from `docs_api.API_GROUPS` (`posts.create`, `feed.list`, ...). One struct of named params per call; auth via in-band `api_key`, `X-API-KEY`/`Bearer` header, or `http://user:pass@host/xmlrpc` Basic. Full introspection + `system.multicall`; REST errors become XML-RPC faults. Exempt from the rate limiter (enforced on the forwarded internal hop). See `devplacepy/services/xmlrpc/CLAUDE.md` |
| `/api` | devrant/ package - devRant-compatible REST protocol (`auth.py`, `rants.py`, `comments.py`, `notifs.py`). Translates devRant requests onto DevPlace data: rants<->posts, comments/votes onto the native engagement layer, devRant integer ids onto each table's auto-increment `id`. Token auth via `devrant_tokens`. See `routers/devrant/CLAUDE.md` for the deep detail on this tree |
| `/dbapi` | dbapi/ package - **primary-administrator-only, strictly READ-ONLY** generic database API over `dataset` (never inserts/updates/deletes/restores; the write surface was removed because it bypassed every per-route admin safeguard). `tables.py` (`GET /tables`, `GET /{table}/schema`), `crud.py` (read only: `GET /{table}` + `GET /{table}/{key}/{value}`, `?include_deleted`), `query.py` (`POST /query` hard SELECT-only validated read-only run; `POST /query/async` + `GET /query/{uid}` + `WS /query/{uid}/ws` via `DbApiJobService` kind `dbquery`), `nl.py` (`POST /nl` natural-language->SQL via the AI gateway, returns validated SELECT, `execute=true` runs it read-only). **Auth = the PRIMARY administrator only** (the earliest-created Admin, `utils.is_primary_admin` / `database.get_primary_admin_uid` - the same identity that gates backup downloads) by session/api_key (`services/dbapi/policy.py`); every other administrator is refused like a member, and there is **no internal-key path** - the gateway `internal_gateway_key()` is NOT accepted (no service-to-service access). SQL validated by `services/dbapi/validate.py` (sqlglot classify + suspicious-flag + read-only `EXPLAIN` dry-run). Devii tools `db_*` are read-only (list/get/query/nl; no write tools) and flagged `requires_primary_admin=True`, so they are added to the LLM tool list ONLY for the primary administrator (every other session never sees them and Devii is unaware they exist). `services/pubsub/policy.py` resolves its own admin/internal actor and does NOT reuse `dbapi.policy.caller_for`, so the primary-admin restriction does not leak onto the pub/sub bus. See `devplacepy/services/dbapi/CLAUDE.md` |
| `/game` | game/ package - the **Code Farm** idle game (`index.py` + `farm.py`): `GET /game` (page), `GET /game/state`, `GET /game/leaderboard`, `POST /game/{plant,harvest,buy-plot,upgrade,fertilize,daily,perk,prestige,legacy,quests/claim}`, plus social `GET /game/farm/{username}` and `POST /game/farm/{username}/{water,steal}` |
| `/quizzes` | quizzes/ package - **Quizzes**: `index.py` (hub, create, import, detail, export, edit, delete, publish, per-quiz leaderboard, cross-quiz scoreboard), `questions.py` (builder page + question CRUD + reorder), `attempts.py` (start/resume, play, answer, finish, results). The hub `GET /quizzes` is the approved three-column `.feed-layout` reused verbatim from `/feed`: filters + search left (`?filter=all|todo|done|mine|drafts`, `?search=`, `?page=`), the quiz list centre with the **New quiz** and **Create quiz with Devii** actions, and the cross-quiz **scoreboard** rail right (score per user, best attempt per quiz, 15s display cache). Publishing is terminal - every write on a published quiz is a 400 and there is no unpublish route. See `devplacepy/services/quiz/CLAUDE.md` |
| `/game` | game/ package - the **Code Farm** idle game (`index.py` + `farm.py`): `GET /game` (page), `GET /game/state`, `GET /game/leaderboard?board=`, `POST /game/{plant,harvest,buy-plot,upgrade,fertilize,daily,grant,perk,prestige,legacy,mastery,quests/claim}`, `POST /game/{defense/upgrade,defense/downgrade,infrastructure/buy,cosmetics/buy,cosmetics/equip}`, plus social `GET /game/farm/{username}` and `POST /game/farm/{username}/{water,steal}`. See `devplacepy/services/game/CLAUDE.md` |
| (none) | push.py - web push + PWA: `GET`/`POST /push.json` (VAPID public key / subscription register), `GET /service-worker.js`, `GET /manifest.json` |
| (none) | docs.py (docs/ package) - the documentation site (prose pages + API reference). See `routers/docs/CLAUDE.md` for the deep detail on this tree (`DOCS_PAGES`, audience tiers, prose rendering pipeline) |
| `/pubsub` | pubsub.py - **database-free** publish/subscribe bus. `WS /pubsub/ws` (subscribe/unsubscribe/publish frames, `foo.*` wildcards), `POST /pubsub/publish` + `GET /pubsub/topics` (admin/internal). Lock-owner-gated WS (`4013` retry) so subscribers converge on one worker; topic authz in `services/pubsub/policy.py` (`user.{uid}.*` private, `public.*` shared, admin/internal anywhere, guests opt-in). In-process `services.pubsub.publish(topic, data)` for backends; frontend `app.pubsub`. See `devplacepy/services/pubsub/CLAUDE.md` |
@@ -188,7 +189,7 @@ News articles have an internal detail page at `/news/{slug}` with full comment s
The home route (`main.py` `landing()`) never redirects - it renders `templates/landing.html` for everyone, branching on `user`:
- **Guests** get the marketing hero (`Join DevPlace Free` CTA + features grid).
- **Signed-in users** get a personalized hero (`.landing-hero-user`): avatar, "Welcome back, {username}", a `Go to your feed` CTA, a Posts/Stars/Level stat strip (`user_post_count` + the user dict's `stars`/`level`), and quick links. Styles live in `.landing-hero-user`/`.landing-welcome`/`.landing-stats`/`.landing-quicklinks` in `static/css/landing.css`.
- **Signed-in users** get a personalized dashboard hero (`.dashboard-welcome`): avatar, "Welcome back, {username}", quicklink buttons (`.dashboard-btn`, with `New Post` -> `/feed` as `.dashboard-btn-primary`, plus Code Farm/Projects/Gists), and a Posts/Stars/Level stat strip (`.dashboard-stats`, `user_post_count` + the user dict's `stars`/`level`). Styles live in the `.dashboard-*` classes in `static/css/landing.css`.
- Both states share the Latest Posts + Developer News + "Build With Us" sections. The **Build With Us** section is static HTML/CSS (`.landing-help-*` in `landing.css`): four cards linking to `/docs/index.html` (Documentation), `/swagger` + `/openapi.json` (API Reference), `/issues` (Contribute & Report), and Devii. The Devii card's `Launch Devii` button is a plain `<button data-devii-open>` that opens the globally mounted `DeviiTerminal` (`app.devii`) in place - no extra JS, route, schema, or Devii action; a secondary link points to `/devii/` for the full terminal page.
- Context adds `user`, `is_authenticated`, `user_post_count`; `LandingOut` carries `is_authenticated`/`user_post_count` for the JSON form. `GET /` is documented in `docs_api.py` (id `home`, mapped to `LandingOut`).
@@ -286,7 +287,8 @@ All SEO features are implemented across the following locations:
## Engagement: reactions, bookmarks, polls, contribution heatmap
### Emoji reactions
- Curated palette only: `REACTION_EMOJI` in `constants.py` (registered as a template global). `ReactionForm` rejects anything outside it; free-text emoji are not allowed.
- **Any single emoji is allowed.** `ReactionForm` validates with `rendering.is_single_emoji(value)` (`emoji.emoji_count(text) == 1 and emoji.purely_emoji(text)`, whitespace stripped) - so every emoji the picker can emit (all 3953 fully-qualified sequences, skin tones and ZWJ families included) is accepted, while text, mixed text+emoji, and multi-emoji strings are rejected. `REACTION_EMOJI` in `constants.py` (a template global) is now only the **quick-pick palette** shown by default, not an allowlist; the full set comes from the vendored `emoji-picker-element` opened by the palette's `+` button.
- The rendered chips are `reaction_emojis(_reactions)` (a `templating.py` global): the quick-pick palette plus any other emoji already used on that target (from `counts`/`mine`), so an off-palette reaction renders server-side too. `ReactionBar.js` creates a chip on the fly for any emoji returned by the JSON response that has none yet.
- Endpoint `POST /reactions/{target_type}/{target_uid}` (`routers/reactions.py`) toggles one `(user, target, emoji)` row in the `reactions` table. Target types: `post`, `comment`, `gist`, `project`. AJAX (`x-requested-with: fetch`) returns `{counts, mine}`.
- Reactions are **non-ranking** - they never touch `stars` or XP and intentionally send **no notifications** (votes already notify; reactions would be notification spam).
- Batch reads via `get_reactions_by_targets(target_type, uids, user)` in `database.py` (used by feed, profile, comment loader, `load_detail`) - never per-row. The `_reaction_bar.html` partial takes `_type`, `_uid`, `_reactions` ({counts, mine}) and renders the full palette as toggle chips; `ReactionBar.js` uses document-level click delegation. All four engagement controllers (`ReactionBar`, `VoteManager`, `BookmarkManager`, `PollManager`) extend the shared `OptimisticAction` base (the `Http.sendForm -> render -> error` core); each keeps only its own event wiring and `_render`.
+4
View File
@@ -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")
+205
View 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
View 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")
+90 -1
View File
@@ -9,7 +9,7 @@ from pydantic import ValidationError
from devplacepy.seo import base_seo_context, site_url, website_schema
from devplacepy.services.audit import record as audit
from devplacepy.services.manager import service_manager
from devplacepy.services.openai_gateway import routing
from devplacepy.services.openai_gateway import quota, routing
from devplacepy.templating import templates
from devplacepy.utils import require_admin
@@ -182,3 +182,92 @@ async def delete_model(request: Request, source_model: str):
summary=f"admin {admin['username']} deleted gateway model route {source_model}",
)
return JSONResponse({"ok": True})
def _quota_defaults_summary() -> dict:
svc = service_manager.get_service("openai")
cfg = svc.get_config() if svc is not None else {}
return {
"user": cfg.get(quota.FIELD_DEFAULT_USER, 0.0),
"admin": cfg.get(quota.FIELD_DEFAULT_ADMIN, 0.0),
"guest": cfg.get(quota.FIELD_DEFAULT_GUEST, 0.0),
"internal": cfg.get(quota.FIELD_DEFAULT_INTERNAL, 0.0),
"key": cfg.get(quota.FIELD_DEFAULT_KEY, 0.0),
}
def _rule_label(rule: dict) -> str:
parts = []
if rule.get("owner_kind"):
parts.append(f"role={rule['owner_kind']}")
if rule.get("owner_id"):
parts.append(f"user={rule['owner_id']}")
if rule.get("app_reference"):
parts.append(f"app={rule['app_reference']}")
return ", ".join(parts) or rule.get("uid", "")
@router.get("/gateway/quota-rules")
async def list_quota_rules(request: Request):
require_admin(request)
rules = quota.quota_rule_store.list()
for rule in rules:
rule["spent_24h_usd"] = round(
quota.spent_24h(rule["owner_kind"], rule["owner_id"], rule["app_reference"]), 6
)
return JSONResponse(
{
"rules": rules,
"count": len(rules),
"defaults": _quota_defaults_summary(),
}
)
@router.post("/gateway/quota-rules")
async def save_quota_rule(request: Request):
admin = require_admin(request)
body = await _payload(request)
uid = str(body.pop("uid", "") or "").strip() or None
try:
payload = quota.QuotaRuleIn(**body)
except ValidationError as exc:
return _validation_error(exc)
saved = quota.quota_rule_store.set(payload, uid=uid, created_by=admin["uid"])
audit.record(
request,
"gateway.quota_rule.update",
user=admin,
target_type="gateway_quota_rule",
target_uid=saved["uid"],
target_label=_rule_label(saved),
summary=f"admin {admin['username']} saved gateway quota rule ({_rule_label(saved)}) at ${saved['limit_usd']}/24h",
metadata={
"owner_kind": saved["owner_kind"],
"owner_id": saved["owner_id"],
"app_reference": saved["app_reference"],
"limit_usd": saved["limit_usd"],
"is_active": saved["is_active"],
},
)
return JSONResponse({"ok": True, "rule": saved})
@router.delete("/gateway/quota-rules/{uid}")
async def delete_quota_rule(request: Request, uid: str):
admin = require_admin(request)
existing = quota.quota_rule_store.get(uid)
label = _rule_label(existing.as_dict()) if existing else uid
existed = quota.quota_rule_store.remove(uid)
if not existed:
return JSONResponse({"ok": False, "error": "Quota rule not found"}, status_code=404)
audit.record(
request,
"gateway.quota_rule.delete",
user=admin,
target_type="gateway_quota_rule",
target_uid=uid,
target_label=label,
summary=f"admin {admin['username']} deleted gateway quota rule ({label})",
)
return JSONResponse({"ok": True})
+1
View File
@@ -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},
]
+1 -1
View File
@@ -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",
+6
View File
@@ -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",
+23 -1
View File
@@ -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:
+12 -9
View File
@@ -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",
)
+145 -12
View File
@@ -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)
)
+59 -7
View File
@@ -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(
+19
View File
@@ -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
+29 -1
View File
@@ -6,6 +6,7 @@ from sqlalchemy import or_
from fastapi import Depends, APIRouter, Request
from devplacepy.models import ProjectForm, ProjectEditForm, ProjectFlagForm, ForkForm
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
from devplacepy.attachments import get_attachments_batch
from devplacepy.database import (
get_table,
get_users_by_uids,
@@ -13,6 +14,10 @@ from devplacepy.database import (
get_site_stats,
get_user_votes,
get_recent_comments_by_target_uids,
get_reactions_by_targets,
get_user_bookmarks,
get_polls_by_post_uids,
get_project_devlog,
paginate,
text_search_clause,
resolve_by_slug,
@@ -171,7 +176,7 @@ async def projects_page(
)
@router.get("/{project_slug}", response_class=HTMLResponse)
async def project_detail(request: Request, project_slug: str):
async def project_detail(request: Request, project_slug: str, before: str = None):
user = get_current_user(request)
detail = load_detail("projects", "project", project_slug, user)
if not detail:
@@ -216,6 +221,25 @@ async def project_detail(request: Request, project_slug: str):
if parent
else None
)
devlog_posts, devlog_next_cursor = get_project_devlog(
project["uid"], before=before, viewer=user
)
if devlog_posts:
post_uids = [item["post"]["uid"] for item in devlog_posts]
attachments_map = get_attachments_batch("post", post_uids)
reactions_map = get_reactions_by_targets("post", post_uids, user)
bookmark_set = (
get_user_bookmarks(user["uid"], "post", post_uids) if user else set()
)
polls_map = get_polls_by_post_uids(post_uids, user)
for item in devlog_posts:
uid = item["post"]["uid"]
item["attachments"] = attachments_map.get(uid, [])
item["reactions"] = reactions_map.get(uid, {"counts": {}, "mine": []})
item["bookmarked"] = uid in bookmark_set
item["poll"] = polls_map.get(uid)
return respond(
request,
"project_detail.html",
@@ -235,6 +259,8 @@ async def project_detail(request: Request, project_slug: str):
"forked_from": forked_from,
"fork_count": count_forks(project["uid"]),
"file_count": count_files(project["uid"]),
"devlog_posts": devlog_posts,
"devlog_next_cursor": devlog_next_cursor,
},
),
model=ProjectDetailOut,
@@ -464,3 +490,5 @@ async def set_project_readonly(
request: Request, project_slug: str, data: Annotated[ProjectFlagForm, Depends(json_or_form(ProjectFlagForm))]
):
return _set_project_flag(request, project_slug, "read_only", data.value)
+9
View 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
View 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
View 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
View 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
View 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 "")],
)
+1 -1
View File
@@ -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(
+77 -2
View File
@@ -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)
+1 -1
View File
@@ -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}")
+28
View File
@@ -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,
+35
View File
@@ -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
+5 -1
View File
@@ -4,6 +4,8 @@ from __future__ import annotations
from typing import Any, Optional
from pydantic import ConfigDict, Field
from devplacepy.schemas.base import _Out
@@ -62,8 +64,9 @@ class PollOut(_Out):
class BadgeOut(_Out):
name: Optional[str] = None
name: Optional[str] = Field(None, alias="badge_name")
created_at: Optional[str] = None
model_config = ConfigDict(populate_by_name=True)
class PostOut(_Out):
@@ -202,3 +205,4 @@ class MessageOut(_Out):
CommentItemOut.model_rebuild()
+74
View File
@@ -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):
+4
View File
@@ -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
+230
View 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
View 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
+37
View File
@@ -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"),
+2
View File
@@ -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",
+8
View File
@@ -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.**
+12 -9
View File
@@ -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):
+1
View File
@@ -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]] = []
+22
View File
@@ -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()
+5 -3
View File
@@ -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 = {
+2 -4
View File
@@ -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:
+19 -1
View File
@@ -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)
-637
View File
@@ -1,637 +0,0 @@
# retoor <retoor@molodetz.nl>
import asyncio
import json
import re
import socket
from pathlib import Path
from devplacepy import config, project_files, stealth
from devplacepy.services.containers import store
from devplacepy.services.containers.backend.base import (
WORKSPACE_MOUNT,
Mount,
PortMapping,
RunSpec,
)
from devplacepy.services.containers.runtime import get_backend
from devplacepy.services.devii.tasks.schedule import (
Schedule,
now_utc,
to_iso,
)
IMAGE_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9._-]{0,62}$")
INGRESS_SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,62}$")
MEM_RE = re.compile(r"^\d+(\.\d+)?[bkmgBKMG]?$")
CPU_RE = re.compile(r"^\d+(\.\d+)?$")
ENV_KEY_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
INSTANCE_LABEL = "devplace.instance"
PROJECT_LABEL = "devplace.project"
HOST_PORT_MIN = 20001
HOST_PORT_MAX = 65535
BOOT_LANGUAGES = ("none", "python", "bash")
BOOT_SCRIPT_FILES = {"python": ".devplace_boot.py", "bash": ".devplace_boot.sh"}
BOOT_SCRIPT_RUNNERS = {"python": "python", "bash": "bash"}
MAX_BOOT_SCRIPT_CHARS = 100_000
class ContainerError(ValueError):
pass
def _validate_limits(cpu_limit: str, mem_limit: str) -> None:
if cpu_limit and not CPU_RE.match(str(cpu_limit)):
raise ContainerError("cpu limit must be a number, e.g. 1 or 1.5")
if mem_limit and not MEM_RE.match(str(mem_limit)):
raise ContainerError("memory limit must look like 512m, 1g, or a byte count")
def validate_run_as(run_as_uid) -> str:
uid = str(run_as_uid or "").strip()
if not uid:
return ""
from devplacepy import database
user = database.get_users_by_uids([uid]).get(uid)
if not user:
raise ContainerError(f"run-as user not found: {uid}")
return uid
def validate_boot(boot_language, boot_script) -> tuple:
language = str(boot_language or "none").strip().lower() or "none"
if language not in BOOT_LANGUAGES:
raise ContainerError(
f"boot language must be one of {', '.join(BOOT_LANGUAGES)}"
)
script = str(boot_script or "")
if language == "none":
script = ""
if len(script) > MAX_BOOT_SCRIPT_CHARS:
raise ContainerError(
f"boot script exceeds the {MAX_BOOT_SCRIPT_CHARS}-character limit"
)
if language != "none" and not script.strip():
raise ContainerError("boot script is required when a boot language is set")
return language, script
def parse_ports(value) -> list:
ports = []
if not value:
return ports
items = (
value if isinstance(value, list) else str(value).replace(",", "\n").splitlines()
)
for item in items:
item = str(item).strip()
if not item:
continue
proto = "tcp"
if "/" in item:
item, proto = item.split("/", 1)
if ":" in item:
host, container = item.split(":", 1)
else:
host, container = "0", item
if not host.isdigit() or not container.isdigit():
raise ContainerError(
f"port '{item}' must be numeric host:container or a bare container port"
)
ports.append(PortMapping(int(host), int(container), proto.strip() or "tcp"))
return ports
def used_host_ports() -> set:
ports = set()
for instance in store.all_instances():
for mapping in json.loads(instance.get("ports_json") or "[]"):
host = int(mapping.get("host") or 0)
if host:
ports.add(host)
return ports
def _host_port_free(port: int) -> bool:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try:
sock.bind(("0.0.0.0", port))
return True
except OSError:
return False
def allocate_host_port(reserved: set) -> int:
for port in range(HOST_PORT_MIN, HOST_PORT_MAX + 1):
if port in reserved:
continue
if _host_port_free(port):
return port
raise ContainerError(
f"no free host port available in range {HOST_PORT_MIN}-{HOST_PORT_MAX}"
)
def assign_host_ports(port_list: list) -> list:
reserved = used_host_ports()
assigned = []
for mapping in port_list:
host = mapping.host
if not host:
host = allocate_host_port(reserved)
elif host in reserved:
raise ContainerError(
f"host port {host} is already published by another instance"
)
reserved.add(host)
assigned.append(PortMapping(host, mapping.container, mapping.proto))
return assigned
def parse_env(value) -> dict:
env = {}
if not value:
return env
if isinstance(value, dict):
items = value.items()
else:
items = (line.split("=", 1) for line in str(value).splitlines() if "=" in line)
for key, val in items:
key = str(key).strip()
if not ENV_KEY_RE.match(key):
raise ContainerError(f"invalid environment variable name: {key}")
env[key] = str(val)
return env
# ---------------- instances ----------------
def validate_ingress(slug: str, port, port_list) -> tuple:
slug = (slug or "").strip().lower()
if not slug:
return "", 0
if not INGRESS_SLUG_RE.match(slug):
raise ContainerError(
"ingress slug must be lowercase letters, digits, or '-' (max 63 chars)"
)
for other in store.all_instances():
if other.get("ingress_slug") == slug:
raise ContainerError(f"ingress slug '{slug}' is already in use")
ingress_port = int(port) if port else 0
container_ports = {p.container for p in port_list}
if ingress_port and ingress_port not in container_ports:
raise ContainerError(
f"ingress_port {ingress_port} must be one of the container ports you mapped"
)
if not ingress_port and len(container_ports) != 1:
raise ContainerError(
"set ingress_port to choose which mapped container port to publish"
)
return slug, ingress_port
async def create_instance(
project: dict,
*,
name: str,
boot_command: str = "",
boot_language: str = "none",
boot_script: str = "",
run_as_uid: str = "",
start_on_boot: bool = False,
env="",
cpu_limit: str = "",
mem_limit: str = "",
ports="",
volumes="",
restart_policy: str = "never",
autostart: bool = True,
ingress_slug: str = "",
ingress_port=None,
actor=("system", "system"),
) -> dict:
if not await get_backend().image_exists(config.CONTAINER_IMAGE):
raise ContainerError(
f"the '{config.CONTAINER_IMAGE}' image is not built - run 'make ppy'"
)
if restart_policy not in store.RESTART_POLICIES:
raise ContainerError(
f"restart policy must be one of {', '.join(store.RESTART_POLICIES)}"
)
_validate_limits(cpu_limit, mem_limit)
run_as_uid = validate_run_as(run_as_uid)
boot_language, boot_script = validate_boot(boot_language, boot_script)
port_list = assign_host_ports(parse_ports(ports))
env_map = parse_env(env)
name = (name or "").strip()
if not name:
raise ContainerError("instance name is required")
ingress_slug, ingress_port = validate_ingress(ingress_slug, ingress_port, port_list)
workspace = Path(config.CONTAINER_WORKSPACES_DIR) / project["uid"]
await asyncio.to_thread(
project_files.export_to_dir, project["uid"], "", str(workspace)
)
row = {
"project_uid": project["uid"],
"created_by": actor[1] if actor and actor[0] == "user" else "",
"owner_uid": project.get("user_uid", ""),
"run_as_uid": run_as_uid,
"name": name,
"boot_command": boot_command or "",
"boot_language": boot_language,
"boot_script": boot_script,
"start_on_boot": 1 if start_on_boot else 0,
"env_json": json.dumps(env_map),
"cpu_limit": str(cpu_limit or ""),
"mem_limit": str(mem_limit or ""),
"ports_json": json.dumps(
[
{"host": p.host, "container": p.container, "proto": p.proto}
for p in port_list
]
),
"volumes_json": volumes
if isinstance(volumes, str)
else json.dumps(volumes or []),
"restart_policy": restart_policy,
"ingress_slug": ingress_slug,
"ingress_port": ingress_port,
"desired_state": store.DESIRED_RUNNING if autostart else store.DESIRED_STOPPED,
"status": store.ST_CREATED,
"workspace_dir": str(workspace),
}
instance = store.create_instance(row)
store.record_event(
instance, "created", actor[0], actor[1], {"image": config.CONTAINER_IMAGE}
)
if actor and actor[0] == "user":
from devplacepy.utils import track_action
track_action(actor[1], "container")
return instance
def set_desired_state(
instance: dict, desired: str, *, actor=("system", "system")
) -> dict:
if desired not in (
store.DESIRED_RUNNING,
store.DESIRED_STOPPED,
store.DESIRED_PAUSED,
):
raise ContainerError("desired state must be running, stopped, or paused")
store.update_instance(instance["uid"], {"desired_state": desired})
store.record_event(instance, f"desire_{desired}", actor[0], actor[1])
return store.get_instance(instance["uid"])
def request_restart(instance: dict, *, actor=("system", "system")) -> dict:
store.update_instance(
instance["uid"],
{"desired_state": store.DESIRED_RUNNING, "status": store.ST_RESTARTING},
)
store.record_event(instance, "restart", actor[0], actor[1])
return store.get_instance(instance["uid"])
def mark_for_removal(instance: dict, *, actor=("system", "system")) -> None:
store.update_instance(
instance["uid"],
{"desired_state": store.DESIRED_STOPPED, "status": store.ST_REMOVING},
)
store.record_event(instance, "remove", actor[0], actor[1])
def update_instance_config(
instance: dict,
*,
run_as_uid=None,
boot_language=None,
boot_script=None,
boot_command=None,
restart_policy=None,
start_on_boot=None,
cpu_limit=None,
mem_limit=None,
actor=("system", "system"),
) -> dict:
changes: dict = {}
if run_as_uid is not None:
changes["run_as_uid"] = validate_run_as(run_as_uid)
if boot_language is not None or boot_script is not None:
language = (
boot_language
if boot_language is not None
else instance.get("boot_language", "none")
)
script = (
boot_script if boot_script is not None else instance.get("boot_script", "")
)
language, script = validate_boot(language, script)
changes["boot_language"] = language
changes["boot_script"] = script
if boot_command is not None:
changes["boot_command"] = str(boot_command or "")[:500]
if restart_policy is not None:
if restart_policy not in store.RESTART_POLICIES:
raise ContainerError(
f"restart policy must be one of {', '.join(store.RESTART_POLICIES)}"
)
changes["restart_policy"] = restart_policy
if start_on_boot is not None:
changes["start_on_boot"] = 1 if start_on_boot else 0
if cpu_limit is not None or mem_limit is not None:
cpu = cpu_limit if cpu_limit is not None else instance.get("cpu_limit", "")
mem = mem_limit if mem_limit is not None else instance.get("mem_limit", "")
_validate_limits(cpu, mem)
changes["cpu_limit"] = str(cpu or "")
changes["mem_limit"] = str(mem or "")
if not changes:
return store.get_instance(instance["uid"])
store.update_instance(instance["uid"], changes)
store.record_event(
instance, "configure", actor[0], actor[1], {"fields": sorted(changes)}
)
return store.get_instance(instance["uid"])
def set_start_on_boot(
instance: dict, enabled: bool, *, actor=("system", "system")
) -> dict:
store.update_instance(instance["uid"], {"start_on_boot": 1 if enabled else 0})
store.record_event(
instance, "start_on_boot", actor[0], actor[1], {"enabled": bool(enabled)}
)
return store.get_instance(instance["uid"])
def materialize_boot_script(instance: dict) -> None:
language = (instance.get("boot_language") or "none").strip().lower()
workspace = instance.get("workspace_dir")
if not workspace:
return
for filename in BOOT_SCRIPT_FILES.values():
stale = Path(workspace) / filename
if stale.is_file():
try:
stale.unlink()
except OSError:
pass
if language not in BOOT_SCRIPT_FILES:
return
script = instance.get("boot_script") or ""
if not script.strip():
return
target = Path(workspace) / BOOT_SCRIPT_FILES[language]
try:
Path(workspace).mkdir(parents=True, exist_ok=True)
target.write_text(script, encoding="utf-8")
except OSError:
pass
def pravda_env(instance: dict) -> dict:
from devplacepy import database, seo
base_url = seo.public_base_url()
api_key = ""
user_uid = instance.get("owner_uid") or ""
for uid in (
instance.get("run_as_uid"),
instance.get("created_by"),
instance.get("owner_uid"),
):
if not uid:
continue
user = database.get_users_by_uids([uid]).get(uid)
if user and user.get("api_key"):
api_key = user["api_key"]
break
slug = instance.get("ingress_slug") or ""
ingress_url = (f"{base_url}/p/{slug}" if base_url else f"/p/{slug}") if slug else ""
return {
"PRAVDA_BASE_URL": base_url,
"PRAVDA_OPENAI_URL": f"{base_url}/openai/v1" if base_url else "",
"PRAVDA_API_KEY": api_key,
"PRAVDA_USER_UID": instance.get("run_as_uid") or user_uid,
"PRAVDA_CONTAINER_NAME": instance.get("name") or "",
"PRAVDA_CONTAINER_UID": instance.get("uid") or "",
"PRAVDA_INGRESS_URL": ingress_url,
}
def run_spec_for(instance: dict, image_tag: str) -> RunSpec:
env = {**json.loads(instance.get("env_json") or "{}"), **pravda_env(instance)}
ports = [
PortMapping(p["host"], p["container"], p.get("proto", "tcp"))
for p in json.loads(instance.get("ports_json") or "[]")
]
mounts = [Mount(instance["workspace_dir"], WORKSPACE_MOUNT, "rw")]
for extra in json.loads(instance.get("volumes_json") or "[]"):
if isinstance(extra, dict) and extra.get("host") and extra.get("container"):
mounts.append(
Mount(extra["host"], extra["container"], extra.get("mode", "rw"))
)
language = (instance.get("boot_language") or "none").strip().lower()
boot = (instance.get("boot_command") or "").strip()
if language in BOOT_SCRIPT_FILES and (instance.get("boot_script") or "").strip():
script_path = f"{WORKSPACE_MOUNT}/{BOOT_SCRIPT_FILES[language]}"
command = [BOOT_SCRIPT_RUNNERS[language], script_path]
elif boot:
command = ["/bin/sh", "-c", boot]
else:
command = ["sleep", "infinity"]
return RunSpec(
image=image_tag,
name=instance["slug"],
labels={
INSTANCE_LABEL: instance["uid"],
PROJECT_LABEL: instance["project_uid"],
},
env=env,
cpu_limit=instance.get("cpu_limit", ""),
mem_limit=instance.get("mem_limit", ""),
ports=ports,
mounts=mounts,
restart_policy=instance.get("restart_policy", "never"),
command=command,
)
async def sync_workspace(instance: dict, user: dict) -> dict:
workspace = instance.get("workspace_dir")
if not workspace:
raise ContainerError("instance has no workspace")
counts = await asyncio.to_thread(
project_files.sync_dir_bidirectional, instance["project_uid"], workspace, user
)
store.record_event(
instance,
"sync",
"user",
user["uid"],
{"exported": counts["exported"], "imported": counts["imported"]},
)
return counts
def sync_bidirectional_sync(instance: dict, user: dict) -> dict:
workspace = instance.get("workspace_dir")
if not workspace:
return {"exported": 0, "imported": 0}
counts = project_files.sync_dir_bidirectional(
instance["project_uid"], workspace, user
)
if counts["exported"] or counts["imported"]:
store.record_event(
instance,
"sync",
"service",
"system",
{"exported": counts["exported"], "imported": counts["imported"]},
)
return counts
def add_schedule(instance: dict, action: str, schedule: Schedule) -> dict:
if action not in ("start", "stop"):
raise ContainerError("schedule action must be start or stop")
first = schedule.first_run(now_utc())
return store.create_schedule(instance, action, schedule.columns(), to_iso(first))
# ---------------- aggregation ----------------
def _percentile(values: list, pct: float) -> float:
if not values:
return 0.0
ordered = sorted(values)
index = min(len(ordered) - 1, int(round((pct / 100.0) * (len(ordered) - 1))))
return float(ordered[index])
def instance_stats(instance_uid: str) -> dict:
metrics = store.recent_metrics(instance_uid, limit=720)
cpu = [m.get("cpu_pct", 0) for m in metrics]
mem = [m.get("mem_bytes", 0) for m in metrics]
return {
"samples": len(metrics),
"cpu_avg": round(sum(cpu) / len(cpu), 2) if cpu else 0.0,
"cpu_p95": round(_percentile(cpu, 95), 2),
"mem_max": max(mem) if mem else 0,
"mem_avg": int(sum(mem) / len(mem)) if mem else 0,
}
def _port_reachable(host: str, port: int, timeout: float = 0.3) -> bool:
try:
with socket.create_connection((host, port), timeout=timeout):
return True
except OSError:
return False
def _http_probe(host: str, port: int, timeout: float = 1.0) -> str:
try:
with stealth.stealth_sync_client(timeout=timeout) as client:
response = client.get(f"http://{host}:{port}/")
return f"HTTP {response.status_code}"
except Exception as exc: # noqa: BLE001 - diagnostic, any failure is informative
return f"unreachable: {type(exc).__name__}"
def _net_entry(data: dict) -> dict:
net = (data or {}).get("NetworkSettings") or {}
if net.get("IPAddress") or net.get("Gateway"):
return net
for entry in (net.get("Networks") or {}).values():
if entry and (entry.get("IPAddress") or entry.get("Gateway")):
return entry
return {}
def container_ip_from_inspect(data: dict) -> str:
return (_net_entry(data).get("IPAddress") or "").strip()
def container_gateway_from_inspect(data: dict) -> str:
return (_net_entry(data).get("Gateway") or "").strip()
def _ingress_container_port(instance: dict, port_maps: list) -> int:
ingress_port = int(instance.get("ingress_port") or 0)
if ingress_port:
for mapping in port_maps:
if int(mapping.get("container") or 0) == ingress_port:
return ingress_port
return 0
return int(port_maps[0].get("container") or 0) if port_maps else 0
def _host_port_for(port_maps: list, container_port: int) -> int:
for mapping in port_maps:
if int(mapping.get("container") or 0) == container_port:
return int(mapping.get("host") or 0)
return 0
def proxy_target(instance: dict) -> tuple:
port_maps = json.loads(instance.get("ports_json") or "[]")
container_port = _ingress_container_port(instance, port_maps)
if not container_port:
return None, None
host_port = _host_port_for(port_maps, container_port)
if config.CONTAINER_PROXY_HOST:
return (config.CONTAINER_PROXY_HOST, host_port) if host_port else (None, None)
if not host_port:
return None, None
gateway = (instance.get("container_gateway") or "").strip()
return (gateway or "127.0.0.1", host_port)
def instance_runtime(instance: dict) -> dict:
boot = (instance.get("boot_command") or "").strip()
port_maps = json.loads(instance.get("ports_json") or "[]")
container_ip = (instance.get("container_ip") or "").strip()
container_gateway = (instance.get("container_gateway") or "").strip()
target_host, target_port = proxy_target(instance)
probe_host = config.CONTAINER_PROXY_HOST or container_gateway or "127.0.0.1"
ports = []
for mapping in port_maps:
host_port = int(mapping.get("host") or 0)
ports.append(
{
"container": int(mapping.get("container") or 0),
"host": host_port,
"proto": mapping.get("proto", "tcp"),
"reachable": _port_reachable(probe_host, host_port)
if host_port
else False,
}
)
ingress_serving = (
_http_probe(target_host, target_port)
if target_host and target_port
else "no ingress port mapped"
)
return {
"command": boot or "image CMD (no boot_command set)",
"ports": ports,
"container_ip": container_ip,
"container_gateway": container_gateway,
"ingress_target": (
f"{target_host}:{target_port}" if target_host and target_port else ""
),
"ingress_port": int(instance.get("ingress_port") or 0),
"ingress_serving": ingress_serving,
"status_ok_for_ingress": instance.get("status") == store.ST_RUNNING,
"restart_count": int(instance.get("restart_count") or 0),
"exit_code": instance.get("exit_code"),
"container_id": (instance.get("container_id") or "")[:12],
}
@@ -1,119 +0,0 @@
" retoor <retoor@molodetz.nl>
" Self-contained config for the ppy container. No external plugins or managers:
" it works out of the box with the stock vim, and the AI edit feature uses only
" curl and the PRAVDA_* gateway env that every instance is launched with.
set nocompatible
filetype plugin indent on
syntax on
set encoding=utf-8
set fileencoding=utf-8
set termencoding=utf-8
set mouse=a
set backspace=indent,eol,start
set autoindent
set smartindent
set tabstop=4
set shiftwidth=4
set expandtab
set number
set showmatch
set showtabline=2
set laststatus=2
set hidden
set incsearch
set hlsearch
set wildmenu
set ttimeoutlen=50
if has('clipboard')
set clipboard=unnamedplus
endif
if !isdirectory(expand('~/.vim/undo'))
call mkdir(expand('~/.vim/undo'), 'p')
endif
set undofile
set undodir=~/.vim/undo
set statusline=%f\ %h%m%r\ %=\ [%{&filetype}]\ [%l,%c]\ %p%%
highlight StatusLine cterm=bold ctermfg=15 ctermbg=24
highlight StatusLineNC cterm=none ctermfg=250 ctermbg=236
highlight ErrorMsg cterm=bold ctermfg=red ctermbg=none
let mapleader = ","
inoremap <C-n> <ESC>:tabnext<CR>
inoremap <C-p> <ESC>:tabprevious<CR>
nnoremap <C-n> :tabnext<CR>
nnoremap <C-p> :tabprevious<CR>
nnoremap <Tab> :tabnext<CR>
nnoremap <C-Tab> :tabprevious<CR>
nnoremap <C-e> :tabnew<Space>
inoremap <C-e> <ESC>:tabnew<Space>
if has('autocmd')
autocmd BufReadPost * if line("'\"") > 0 && line("'\"") <= line("$") | exe "normal! g'\"" | endif
endif
function! s:GetVisualSelection() abort
let [l:line_start, l:col_start] = [line("'<"), col("'<")]
let [l:line_end, l:col_end] = [line("'>"), col("'>")]
let l:lines = getline(l:line_start, l:line_end)
if empty(l:lines)
return ''
endif
let l:lines[-1] = l:lines[-1][: l:col_end - (l:line_start == l:line_end ? 1 : 2)]
let l:lines[0] = l:lines[0][l:col_start - 1 :]
return join(l:lines, "\n")
endfunction
function! s:GatewayUrl() abort
let l:base = substitute($PRAVDA_OPENAI_URL, '/\+$', '', '')
if empty(l:base)
return 'https://openai.app.molodetz.nl/v1/chat/completions'
elseif l:base =~# '/chat/completions$'
return l:base
endif
return l:base . '/chat/completions'
endfunction
function! AiEditSelection() abort
let l:instruction = input('AI instruction: ')
if empty(l:instruction)
echo 'Cancelled.'
return
endif
let l:orig = s:GetVisualSelection()
if empty(l:orig)
echo 'No selection.'
return
endif
let l:prompt = l:instruction . "\n\nHere is the text:\n" . l:orig
\ . "\n\nOutput only the transformed text. No explanations, markdown, or code blocks."
let l:api_key = !empty($PRAVDA_API_KEY) ? $PRAVDA_API_KEY : $DEEPSEEK_API_KEY
let l:json = '{"model":"deepseek-chat","messages":[{"role":"user","content":' . json_encode(l:prompt) . '}]}'
let l:cmd = 'curl -sS -X POST ' . shellescape(s:GatewayUrl())
\ . ' -H ' . shellescape('Authorization: Bearer ' . l:api_key)
\ . ' -H ' . shellescape('Content-Type: application/json')
\ . ' -d ' . shellescape(l:json)
let l:reply = system(l:cmd)
if v:shell_error
echohl ErrorMsg | echom 'AI request failed' | echohl None
return
endif
let l:text = matchstr(l:reply, '"content":\s*"\zs\(.\{-}\)\ze"\s*[,}]')
if empty(l:text)
echohl ErrorMsg | echom 'No content in AI response' | echohl None
return
endif
let l:text = substitute(l:text, '\\n', "\n", 'g')
let l:text = substitute(l:text, '\\"', '"', 'g')
let l:text = substitute(l:text, '\\t', "\t", 'g')
normal! gv
normal! c
call feedkeys(l:text, 'n')
endfunction
xnoremap <silent> <Leader>a :<C-u>call AiEditSelection()<CR>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1
View File
@@ -23,6 +23,7 @@ CORRECTABLE_FIELDS: dict[str, tuple[str, ...]] = {
"posts": ("title", "content"),
"projects": ("title", "description"),
"gists": ("title", "description"),
"quizzes": ("title", "description"),
"comments": ("content",),
"messages": ("content",),
"users": ("bio",),
+46 -3
View File
@@ -22,7 +22,7 @@ The `docs` channel is branded **Docii**, a documentation-only assistant, built b
- **search_docs.** `services/devii/docs/controller.py` delegates to the in-process page index `docs_search.search_pages(...)` (no HTTP), returning per result `title`, `url` (`/docs/{slug}.html`), `score` (BM25), and `content` (page text truncated to `CONTENT_CHARS`), admin-filtered by the dispatcher's `is_admin`. So "visiting the search results" is repeated `search_docs` calls; the default 40 `max_tool_iterations` leaves ample room to recurse.
- **References and inline linking.** Because results carry real `url`s and `score`s, the prompt REQUIRES the answer to (a) link inline to documented pages with markdown links to their `url` and (b) end with a `## References` list of the pages used as clickable links with their score. The devii markdown renderer makes `/docs/...` links clickable client-side.
- **Topic gate (follow-up vs new).** `_run_turn` runs `_docs_topic_gate(text)` before the main loop (docs channel only). When there is prior history it calls `_classify_topic` (a no-tools `LLMClient.complete_text` with `TOPIC_CLASSIFIER_PROMPT`, parsed by `_parse_topic`, defaulting to `follow_up` on any failure). On `new` it resets `agent._messages` to `[system]`, clears the persisted docs conversation, and emits `{type:"topic", decision, reason}`; the frontend clears the log, re-shows the current question, and prints a reasoning note. On `follow_up` it keeps context and shows the note.
- **Isolation from Devii (critical).** The docs channel is built with an EPHEMERAL `owned_db` (`hub.get_or_create`: `db if owner_kind=="user" and channel=="main" else memory_db()`), so its task/lesson/behavior/virtual-tool stores are private and empty - a Docii reflection never pollutes the user's Devii memory and vice-versa. The **Scheduler only starts in the `main` channel** (`ensure_scheduler_started`: `if not self._started and self.channel == "main"`), so Devii's scheduled tasks never fire inside a docs session. (Regression fixed: previously the docs session's scheduler ran owner tasks over the shared `devii_tasks` table, and the search-only Docii agent tried to fulfil a `run_js` task by spamming `search_docs` - tasks are a `main`-only feature.)
- **Isolation from Devii (critical).** The docs channel is built with an EPHEMERAL `owned_db` (`hub.get_or_create`: `db if owner_kind=="user" and channel=="main" else memory_db()`), so its task/lesson/behavior/virtual-tool stores are private and empty - a Docii reflection never pollutes the user's Devii memory and vice-versa. **Scheduled tasks only ever run in the `main` channel**: there is one global scheduler and `DeviiService._resolve_task_owner` always resolves the owner's session with `channel="main"`, so a due task can never be handed to a docs session. (Regression fixed: previously the docs session's scheduler ran owner tasks over the shared `devii_tasks` table, and the search-only Docii agent tried to fulfil a `run_js` task by spamming `search_docs` - tasks are a `main`-only feature.)
- The `main` channel is untouched (full 90-tool catalog + behavior header + Devii greeting).
## Docs search mode and BM25 fallback
@@ -43,9 +43,46 @@ Conversation history persists to `devii_conversations` (rehydrated on reconnect,
## Reminders and scheduled tasks (persistent across reboot)
`create_task` queues a self-contained prompt that a fresh agent runs later (`services/devii/tasks/`): `kind=once` (`delay_seconds` for relative, `run_at` UTC for absolute), `interval` (`every_seconds`), or `cron`. The per-session `Scheduler` ticks every 1s and executes due rows through the session's executor, so the result is broadcast to any connected tab and buffered (`type:"task"` frame) when the terminal is closed.
`create_task` queues a self-contained prompt that a fresh agent runs later (`services/devii/tasks/`): `kind=once` (`delay_seconds` for relative, `run_at` UTC for absolute), `interval` (`every_seconds`), or `cron`. The result is broadcast to any connected tab and buffered (`type:"task"` frame) when the terminal is closed.
**The scheduler is no longer tied to a live WebSocket.** It is started by `session.ensure_scheduler_started()`, called both on `attach` AND by the lock-owner `DeviiService._ensure_task_schedulers()` each `run_once` (and promptly at boot): that pass scans the shared `devii_tasks` for every user owner with an enabled `pending`/`running` task (`tasks.store.pending_owner_ids(db)`), resolves the user (api_key/username/is_admin/timezone), and `hub.get_or_create(...)`s a headless session whose scheduler then runs the task - so a queued reminder survives a server reboot and fires even if the user never reopens Devii. `hub.gc_idle()` skips a session with `has_pending_tasks()` so a task-only session is not reaped between ticks.
**Any signed-in account may schedule; two rolling 24-hour quotas bound it, and role decides the size.** Guests never can (`automation_allowed` requires `owner_kind == "user"`). Defaults: a member may CREATE `devii_task_member_create_24h` (5) tasks and EXECUTE `devii_task_member_runs_24h` (10) runs per 24h; an administrator gets `devii_task_admin_create_24h` (5) and `devii_task_admin_runs_24h` (100). All four are admin-editable on the Devii service; `0` means unlimited.
**Each quota is enforced by a single atomic conditional INSERT, never a check-then-act** (`tasks/limits.py`):
- `reserve_run(...)` -> `INSERT INTO devii_task_runs ... SELECT ... WHERE (SELECT COUNT(*) ... created_at >= :cutoff) < :limit`, decided on the driver's real `rowcount`. The scheduler calls it AFTER `claim` and BEFORE dispatch; a refusal releases the claim via `defer`.
- `insert_task_within_quota(...)` -> the same shape for the task row itself, so two concurrent `create_task` calls (main + telegram channel, or two workers) can never both slip past a check. `TaskStore.create` pre-checks for a good error message, then relies on this insert for the actual guarantee.
Both are proven with real multi-process races (16 processes -> exactly 10 runs for a member, 12 processes -> exactly 5 creations). **Never replace either with a count-then-insert.**
**Creation counts the task row itself, so deleting a task does NOT give the slot back** - the window counts `devii_tasks.created_at` regardless of `deleted_at`. Runs are counted in the dedicated `devii_task_runs` ledger (a derived counter table, NOT in `SOFT_DELETE_TABLES`; hard-pruned to 48h by `run_once`).
**A quota refusal postpones, it never disables.** `retire_reason` (terminal: guest owner, expiry, `max_runs`, failure streak) disables the task; `run_deferral`/`budget_deferral` push `next_run_at` to when the slot frees (`Quota.free_at` = the oldest run in the window + 24h) and leave the task enabled, audited `devii.task.deferred`. Keep that split: a rate limit that disabled tasks would silently destroy a user's automation.
**The task-run context is the unhackable part** (`tasks/context.py`). `task_run_scope()` sets a `contextvars.ContextVar` around the executor inside `run_row`, so:
- Everything the scheduled agent does - including subagents, `delegate`, and virtual tools, which all share the dispatcher - runs inside that context.
- A concurrent interactive turn runs in its own asyncio Task context and can never see or clear it (asyncio copies the context at task creation).
- **There is no tool, argument, or prompt that can write it.** The model only ever influences tool arguments; the flag lives in process state set by the scheduler. This is why the nesting rule is enforced on the ContextVar rather than on anything the model can say.
`nesting_allowed(owner_id)` = `not in_task_run() or owner_is_admin(owner_id)`. `TaskStore.create` and `TaskStore.update`-when-enabling both consult it, so a member's task run cannot create a task, re-enable one, or `run_task_now` - while an administrator's task may schedule follow-up work. The session also TELLS the agent where it is: `_compose_system_prompt()` injects the `# TASK RUN ENVIRONMENT` block (`TASK_RUN_MEMBER`/`TASK_RUN_ADMIN`) only when `in_task_run()`.
Beyond the quotas, three chokepoints still gate scheduling (one alone is not enough - the tool gate is a UI control, the store gate covers every writer, and the claim gate is the only one that also retires rows already in the database):
1. **Tool visibility and dispatch.** `create_task`/`update_task`/`run_task_now` are `requires_auth=True`, so guests are never offered them. `list_tasks`/`get_task`/`delete_task` stay open.
2. **`TaskStore.create` / `TaskStore.update`.** Raises `AutomationDenied` (with `retry_at` when a quota is the cause) for a guest owner, a nested member creation, or a spent creation quota. Enabling a task takes the same nesting check; disabling is always allowed, which is what lets the scheduler and admins stop things. This sits below the dispatcher, so it also covers the standalone `devii` CLI - whose store passes `operator=True`, the one deliberate exemption for the local operator - and anything added later.
3. **`Scheduler`/`GlobalScheduler` claim.** `retire_reason` runs before every execution and re-checks owner kind, expiry, run limit, and failure streak, so a task that must stop is retired at execution time with no migration and no manual step.
**`TaskStore._ensure_schema` declares the FULL column set** (`TASK_COLUMNS`) rather than leaning on dataset's lazy schema - the atomic INSERT names columns explicitly, and dataset skips a `None`-valued key when creating columns, so a lazily-built table would be missing `run_at`/`cron`/`start_at`. Both `_ensure_schema` and `_ensure_indexes` tolerate a concurrent `duplicate column` / `already exists` error: several processes construct a `TaskStore` at once and SQLite DDL is not idempotent (this was a real crash, found by racing 12 processes).
**Role is live, never frozen into the session.** `DeviiSession.is_admin`/`is_primary_admin` are properties resolving `get_admin_uids()`/`get_primary_admin_uid()` (both TTL-cached and cross-worker invalidated). `_refresh_privileges()` runs at the top of every turn AND inside the scheduled executor: it rewrites the base system prompt when the role changed and pushes the flags into the dispatcher via `Dispatcher.set_admin`. Regression to avoid: the old code captured `is_admin` as a bool at session construction, and a session with pending tasks was exempt from `gc_idle`, so a **demoted admin kept admin tools indefinitely**.
**One global scheduler, not one per owner.** `DeviiService.scheduler()` owns a single `GlobalScheduler` started in `on_enable` on the lock-owner worker. Each 1s tick issues ONE indexed query (`tasks.store.due_rows`, served by `idx_devii_tasks_due` on `(enabled, status, next_run_at)`) instead of one query per owner per second, and hands out at most `devii_task_max_concurrent` slots **round-robin, one at a time per owner**, so no owner can monopolise the scheduler. A row is taken with an atomic conditional claim (`tasks.store.claim` -> `UPDATE ... WHERE uid=? AND status='pending' AND enabled=1 AND deleted_at IS NULL`, decided on the driver's real `rowcount`), which closes the TOCTOU between a tick and `run_task_now`. Sessions are materialized lazily by `_resolve_task_owner` only when a task actually fires, so `gc_idle` now reaps on `session.is_busy()` (a turn in flight) rather than exempting every task-owning session forever. The per-store `Scheduler` class remains for the standalone `devii` CLI; both share `run_row`/`retire`/`refusal`, so there is one implementation of the guard logic.
**Every recurring task stops by itself.** `Schedule` enforces `MIN_INTERVAL_SECONDS` (900) on `every_seconds` and on cron spacing - the cron check walks a full day of fires from a canonical midnight reference and rejects the smallest gap, so `*/18` is refused for its 6-minute hop at the hour boundary and the verdict never depends on the current minute. `tasks.guards.task_columns` defaults `max_runs` to `DEFAULT_MAX_RUNS` for recurring kinds and stamps `expires_at`, capped at `MAX_LIFETIME_DAYS` (30) past the first run. `guards.expiry_of` falls back to `created_at + MAX_LIFETIME_DAYS` when the column is absent, so a legacy or hand-written row is bounded too. A run that raises increments `failure_count` and reschedules; `devii_task_max_failures` consecutive failures disable the task (previously a single failure left it stuck in `status="error"` forever, silently). `run_once` housekeeping also disables tasks whose owner has not been seen for `devii_task_owner_idle_days`. Every automatic stop writes `devii.task.blocked` with its reason.
**Automation has its own budget.** `quota_exceeded` guards only interactive turns, and `devii_admin_daily_usd` defaults to 0 (unlimited) - which after the admin-only rule is exactly the population that schedules. `devii_task_daily_usd` (`DeviiService.automation_budget_exceeded`) is a separate rolling-24h cap checked in the claim path; spend itself was always ledgered by `_record_spend` in the executor's `finally`.
Admin surface: `/admin/devii-tasks` (`routers/admin/devii_tasks.py`) lists every task across owners with its bounds and per-row disable/delete; `devplace devii tasks list|disable|prune` is the CLI counterpart (`prune` disables every task whose owner may no longer schedule).
A task created as a reminder carries `notify=1`: when it finishes, `session._deliver_reminder` sends a `utils.create_notification(owner, "reminder", result, target_url="/devii")` (the `reminder` notification type), so the in-app notification + live toast reach the user regardless of the terminal.
@@ -79,6 +116,10 @@ Beyond the avatar, Devii has a `client`/browser channel using the same request/r
**Target selection (visibility-aware).** Unlike replies/traces, which `_emit` *broadcasts* to every tab, a browser request is sent to one **target** chosen by `_pick_target()`. The terminal reports each connection's `document` visibility and focus via `{"type":"visibility"}` (on connect, `focus`, `blur`, and `visibilitychange`); the session stores it in `_conn_meta` and ranks connections `(focused, visible, attach_seq)`, so the command runs on the tab the user is actually looking at, falling back to the most recently attached when none reports focus. **Regression to avoid: do NOT route to a single "last-attached primary" socket** - with the `/docs` auto-open tab or any second tab, `reload_page`/`navigate_to` ran on the wrong (hidden) tab while still reporting success, so "the reload did not happen" from the user's view. `run_js` is gated by the `devii_allow_eval` config field (default on), checked in `ClientController` before any round-trip. Overlays (`.devii-hl-box`, `.devii-hl-callout`, `.devii-toast`) attach to `document.body`, not the terminal.
## Stale API key auto-recovery (401 self-healing)
A user session captures the owner's platform `api_key` into its frozen `Settings` at build time (both `Settings.ai_key` for the gateway and `Settings.platform_api_key` for REST). Regenerating that key (profile regenerate, Devii tool, `devplace apikey reset` - possibly from another worker or process) would strand every live session with a permanent `401 Unauthorized` on all LLM and platform calls. Recovery is **reactive at the two HTTP chokepoints**, never proactive session invalidation (which is per-process and cannot reach the hub on the lock-owner worker): `hub.get_or_create` builds a `_user_key_resolver(owner_id)` (a fresh `users.api_key` DB read, user owners only - guests get `None` and keep the internal gateway key) and passes it to `LLMClient(settings, key_resolver=)` and through `DeviiSession(key_resolver=)` into `PlatformClient`. On a 401 (`LLMClient._post`) or a 401/redirect-to-login (`PlatformClient.call` via `_auth_failed`), the client calls `_refresh_key()`: resolve the current key, and only when it is non-empty AND differs from the header already sent, rewrite the auth headers and retry the request **once**. An unchanged or unresolvable key skips the retry so a genuinely invalid credential still fails closed with the original error. The resolver read is cross-worker correct because it goes straight to SQLite, not any per-process cache.
## Shared browser session (auth adoption)
The terminal and the browser are one session. `/devii/ws` resolves its owner from the browser's `session` cookie (`_resolve_ws_owner`), so the terminal is whoever the browser is logged in as. Agent-initiated auth propagates: the session watches its own trace stream (`_trace`) and, on a successful `login`/`signup`, captures the real session token the `PlatformClient` minted against this instance (`session_cookie()`) and broadcasts `{"type":"auth","action":"adopt"}`; the browser navigates to single-use `GET /devii/adopt` which sets the httpOnly `session` cookie and redirects (reload). On `logout` it broadcasts `{"type":"auth","action":"logout"}` and the browser goes to `/auth/logout`. Browser->terminal: login/logout are navigations that reconnect the WS; a cross-tab change is caught by a focus check against `/devii/session` that reloads. The httpOnly cookie is only ever set/cleared by HTTP endpoints, never JS.
@@ -115,6 +156,8 @@ Devii is reachable over Telegram via `channel="telegram"` sessions driven in-pro
Devii exposes read-only `db_list_tables`, `db_table_schema`, `db_list_rows`, `db_get_row`, `db_query`, and `db_design_query` tools gated `requires_primary_admin=True`. See `devplacepy/services/dbapi/CLAUDE.md` for the full auth boundary, validation pipeline, and async query service - these tools are added to the LLM tool list only for the primary administrator; every other session is unaware they exist.
**`db_query` and `db_design_query` MUST stay marked `is_read_only=True`.** They are POST routes, so they carry a request body and would otherwise be classified as mutating by `method in MUTATING_METHODS` alone - but they only read. Marking them read-only is what keeps them from tripping the agentic plan gate and the verification gate, which would discard the very rows the user asked for. `tests/unit/services/devii/actions/catalog.py` guards it.
## All Devii/gateway network timeouts are admin-configurable with a five-minute (300s) floor
The Devii LLM-client and `PlatformClient` read timeout is `timeout_seconds` (field `devii_timeout`), the fetch/docs tool timeout is `fetch_timeout_seconds` (field `devii_fetch_timeout`), web search is `rsearch_timeout_seconds` (`devii_rsearch_timeout`); each defaults to 300s with `minimum=MIN_TIMEOUT_SECONDS` (300). The gateway upstream timeout is `gateway_timeout` (`minimum=TIMEOUT_MIN`=300), which also bounds the vision describe-image call (it shares the gateway's httpx client, no per-call override). A stored value below the floor fails `ConfigField.coerce()` and `read()` falls back to the default, so old sub-300 values upgrade automatically. Devii's LLM timeout was previously a hardcoded 45s while the gateway waited up to 180s x retries - large prompts aborted as `[model error] Could not reach the model endpoint:` (an httpx timeout, which stringifies to empty). `build_settings()` now reads these from config instead of hardcoding them.
@@ -20,6 +20,7 @@ from .posts import POSTS_ACTIONS
from .profile import PROFILE_ACTIONS
from .project_files import PROJECT_FILE_ACTIONS
from .projects import PROJECTS_ACTIONS
from .quizzes import QUIZ_ACTIONS
from .social import SOCIAL_ACTIONS
from .tools import TOOLS_ACTIONS
from .uploads import UPLOAD_ACTIONS
@@ -45,6 +46,7 @@ ACTIONS: tuple[Action, ...] = (
+ DBAPI_ACTIONS
+ GATEWAY_ACTIONS
+ GAME_ACTIONS
+ QUIZ_ACTIONS
)
PLATFORM_CATALOG = Catalog(actions=ACTIONS)
@@ -40,7 +40,7 @@ ENGAGEMENT_ACTIONS: tuple[Action, ...] = (
"target_uid",
"Uid of the target, copied from a listing response; do not invent it.",
),
body("emoji", "One of the allowed reaction emoji.", required=True),
body("emoji", "Any single emoji character, for example \U0001F984.", required=True),
),
),
Action(
@@ -3,7 +3,7 @@
from __future__ import annotations
from ..spec import Action, Param
from ._shared import body, path
from ._shared import body, confirm, path, query
GAME_ACTIONS: tuple[Action, ...] = (
@@ -24,10 +24,18 @@ GAME_ACTIONS: tuple[Action, ...] = (
name="game_leaderboard",
method="GET",
path="/game/leaderboard",
summary="List the top Code Farm players",
summary="List the top Code Farm players on a given board",
description=(
"Boards: score (default, overall composite score), prestige, harvests "
"(this week), raids (avg coins per successful raid, min 3 raids), "
"time_to_kernel (fastest since last refactor), fair_play (rewards recent "
"activity over hoarding), era (current Era-only leaderboard, empty when no "
"Era is running)."
),
handler="http",
requires_auth=False,
read_only=True,
params=(query("board", "Leaderboard board key, defaults to score."),),
),
Action(
name="game_view_farm",
@@ -48,7 +56,12 @@ GAME_ACTIONS: tuple[Action, ...] = (
requires_auth=True,
params=(
Param(name="slot", location="body", description="Plot slot index.", required=True, type="integer"),
body("crop", "Crop key, e.g. shell, python, webapp, api, rust, haskell, kernel.", required=True),
body(
"crop",
"Crop key: shell, python, webapp, api, rust, haskell, kernel, or "
"(with Mastery) distsys, mlpipe, secfort.",
required=True,
),
),
),
Action(
@@ -136,18 +149,41 @@ GAME_ACTIONS: tuple[Action, ...] = (
name="game_claim_quest",
method="POST",
path="/game/quests/claim",
summary="Claim a completed daily Code Farm quest reward",
summary="Claim a completed daily or weekly Code Farm quest/contract reward",
handler="http",
requires_auth=True,
params=(
body("quest", "Quest kind: plant, harvest, water, or earn.", required=True),
body(
"scope",
"daily (default) or weekly (requires the Legacy Contracts Mastery upgrade).",
required=False,
),
),
),
Action(
name="game_prestige",
method="POST",
path="/game/prestige",
summary="Refactor (prestige) your Code Farm for a permanent coin bonus and Stars (requires level 10)",
summary=(
"Refactor (prestige) your Code Farm for a permanent coin bonus and Stars. "
"Requires level 10 AND a coin fee that scales with prestige and wealth "
"(shown as refactor_cost in game_state); the fee funds the community "
"treasury and a fraction of the remaining coins carries over. "
"Irreversible, confirmation required"
),
handler="http",
requires_auth=True,
params=(confirm(),),
),
Action(
name="game_claim_grant",
method="POST",
path="/game/grant",
summary=(
"Claim the weekly Code Farm community grant, paid from the treasury of "
"refactor fees (active low-balance, low-prestige farms only)"
),
handler="http",
requires_auth=True,
),
@@ -155,15 +191,86 @@ GAME_ACTIONS: tuple[Action, ...] = (
name="game_upgrade_legacy",
method="POST",
path="/game/legacy",
summary="Spend Stars on a permanent Legacy upgrade that survives refactor (autoharvest, multiplier, speed, plots, defense)",
summary="Spend Stars on a permanent Legacy upgrade that survives refactor (autoharvest, multiplier, speed, plots, defense, carryover)",
handler="http",
requires_auth=True,
params=(
body(
"key",
"Legacy key: autoharvest, multiplier, speed, plots, or defense.",
"Legacy key: autoharvest, multiplier, speed, plots, defense, or carryover.",
required=True,
),
confirm(),
),
),
Action(
name="game_upgrade_mastery",
method="POST",
path="/game/mastery",
summary="Spend Mastery points on a permanent Mastery upgrade (unlocked at prestige 50+): autoreplant, analytics, or contracts",
handler="http",
requires_auth=True,
params=(
body("key", "Mastery key: autoreplant, analytics, or contracts.", required=True),
confirm(),
),
),
Action(
name="game_buy_infrastructure",
method="POST",
path="/game/infrastructure/buy",
summary="Buy a permanent Infrastructure building (registry, canary, observability); expensive, prestige-gated, big coin sinks",
handler="http",
requires_auth=True,
params=(
body("key", "Infrastructure key: registry, canary, or observability.", required=True),
confirm(),
),
),
Action(
name="game_upgrade_defense",
method="POST",
path="/game/defense/upgrade",
summary=(
"Upgrade your farm's Defense tier: reduces raid losses and adds steal grace, "
"but commits you to an ongoing daily coin upkeep proportional to your wealth. "
"Irreversible spend, confirmation required"
),
handler="http",
requires_auth=True,
params=(confirm(),),
),
Action(
name="game_downgrade_defense",
method="POST",
path="/game/defense/downgrade",
summary=(
"Drop your farm's Defense down one tier to escape its daily upkeep. "
"No refund, confirmation required"
),
handler="http",
requires_auth=True,
params=(confirm(),),
),
Action(
name="game_buy_cosmetic",
method="POST",
path="/game/cosmetics/buy",
summary="Buy a purely cosmetic title or plot skin with coins (no gameplay effect)",
handler="http",
requires_auth=True,
params=(
body("key", "Cosmetic key from the farm's cosmetics list.", required=True),
confirm(),
),
),
Action(
name="game_equip_cosmetic",
method="POST",
path="/game/cosmetics/equip",
summary="Equip an owned cosmetic title so it shows on the leaderboard",
handler="http",
requires_auth=True,
params=(body("key", "An owned title cosmetic key.", required=True),),
),
)
@@ -117,4 +117,57 @@ GATEWAY_ACTIONS: tuple[Action, ...] = (
requires_admin=True,
params=(path("source_model", "Source model name."), confirm()),
),
Action(
name="gateway_quota_rules",
method="GET",
path="/admin/gateway/quota-rules",
summary="List AI gateway quota rules and the current global defaults (admin only)",
description=(
"Returns JSON: every quota rule (each scoped by any combination of role/owner_kind, "
"a specific user uid, and an app_reference label) with its 24h limit, current 24h spend "
"against that exact scope, active flag, and label, plus the global per-role default caps "
"used when no rule matches a request."
),
handler="http",
requires_admin=True,
read_only=True,
),
Action(
name="gateway_quota_rule_set",
method="POST",
path="/admin/gateway/quota-rules",
summary="Create or update an AI gateway quota rule (admin only)",
description=(
"Caps rolling-24h USD spend on /openai/v1/*. Scope by any combination of owner_kind "
"(internal/key/user/admin/anonymous), a specific owner_id (user uid), and app_reference "
"(the X-App-Reference header apps send). At least one of the three must be set - an "
"unscoped cap belongs in the global default fields on the gateway service config instead. "
"Leaving a dimension blank makes it a wildcard: an app_reference-only rule pools spend "
"across every caller using that app; an owner_kind-only rule pools spend across every "
"caller of that role. Setting owner_id pins the rule to one specific caller. When several "
"rules match one request, the MOST SPECIFIC one wins (most non-blank dimensions); ties "
"break toward the smaller limit. limit_usd of 0 means unlimited for that rule. Pass uid "
"to update an existing rule instead of creating a new one."
),
handler="http",
requires_admin=True,
params=(
body("uid", "Existing rule uid to update; omit to create a new rule."),
body("owner_kind", "Role to scope by: internal, key, user, admin, or anonymous. Blank = any role."),
body("owner_id", "Specific user uid to scope by. Blank = any caller of the matched role."),
body("app_reference", "App label to scope by (the X-App-Reference header). Blank = any app."),
Param(name="limit_usd", location="body", description="Rolling 24h USD cap for this rule. 0 = unlimited.", required=True, type="number"),
Param(name="is_active", location="body", description="Whether the rule is enforced ('1' or '0'). Defaults to active.", required=False, type="boolean"),
body("label", "Optional admin-facing note describing what this rule is for."),
),
),
Action(
name="gateway_quota_rule_delete",
method="DELETE",
path="/admin/gateway/quota-rules/{uid}",
summary="Delete an AI gateway quota rule (admin only, confirmation required)",
handler="http",
requires_admin=True,
params=(path("uid", "Quota rule uid."), confirm()),
),
)
@@ -0,0 +1,321 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
from ..spec import Action
from ._shared import body, confirm, path, query
KIND_LIST = (
"single_choice, multiple_choice, true_false, free_text, fill_blank, numeric, "
"ordering, matching"
)
DOCUMENT_HELP = (
"The complete quiz as a JSON string: "
'{"title": "...", "description": "...", '
'"settings": {"shuffle_questions": true, "reveal_answers": true, '
'"pass_percent": 70, "time_limit_seconds": 900}, '
'"questions": [{"kind": "single_choice", "prompt": "...", "points": 1, '
'"explanation": "...", "options": [{"label": "A"}, {"label": "B", "is_correct": true}]}]}. '
f"Question kinds: {KIND_LIST}. A free_text question needs expected_answer or "
"grading_criteria; numeric needs numeric_value; true_false needs correct_boolean; "
"fill_blank and matching need a match_value on every option."
)
QUIZ_ACTIONS: tuple[Action, ...] = (
Action(
name="list_quizzes",
method="GET",
path="/quizzes",
summary="List quizzes with the viewer's per-quiz progress",
description=(
"Returns published quizzes plus the signed-in viewer's state per quiz "
"(todo, in_progress, done) and the cross-quiz scoreboard."
),
handler="http",
requires_auth=False,
read_only=True,
params=(
query("search", "Match the title, description or author username."),
query("filter", "One of all, todo, done, mine, drafts. Defaults to all."),
query("page", "1-based page number."),
),
),
Action(
name="get_quiz",
method="GET",
path="/quizzes/{slug}",
summary="Get one quiz with its stats, leaderboard and comments",
handler="http",
requires_auth=False,
read_only=True,
params=(path("slug", "Quiz slug or uid."),),
),
Action(
name="export_quiz",
method="GET",
path="/quizzes/{slug}/export",
summary="Export a quiz as a full JSON document",
description=(
"The inverse of import_quiz. The owner's export includes every correct "
"answer; a public export of a published quiz omits them."
),
handler="http",
requires_auth=False,
read_only=True,
params=(path("slug", "Quiz slug or uid."),),
),
Action(
name="create_quiz",
method="POST",
path="/quizzes/create",
summary="Create an empty draft quiz",
handler="http",
requires_auth=True,
params=(
body("title", "Quiz title, 3 to 200 characters.", required=True),
body("description", "Markdown description."),
body("shuffle_questions", "Set to 1 to shuffle the question order."),
body("shuffle_options", "Set to 1 to shuffle the answer options."),
body("reveal_answers", "Set to 1 to reveal answers after each question."),
body("allow_review", "Set to 1 to allow reviewing answers on the results screen."),
body("time_limit_seconds", "Time limit in seconds, 0 for none."),
body("pass_percent", "Pass mark 0-100, 0 for no verdict."),
),
),
Action(
name="import_quiz",
method="POST",
path="/quizzes/import",
summary="Create a complete quiz from one JSON document",
description=(
"The full-automation entry point: creates the draft quiz, every question "
"and every option in one call. Publish it afterwards with publish_quiz."
),
handler="http",
requires_auth=True,
params=(body("document", DOCUMENT_HELP, required=True),),
),
Action(
name="edit_quiz",
method="POST",
path="/quizzes/edit/{slug}",
summary="Edit a draft quiz's title, description and settings",
description="Refused with a 400 once the quiz is published.",
handler="http",
requires_auth=True,
params=(
path("slug", "Quiz slug or uid."),
body("title", "Quiz title, 3 to 200 characters.", required=True),
body("description", "Markdown description."),
body("shuffle_questions", "Set to 1 to shuffle the question order."),
body("shuffle_options", "Set to 1 to shuffle the answer options."),
body("reveal_answers", "Set to 1 to reveal answers after each question."),
body("allow_review", "Set to 1 to allow reviewing answers on the results screen."),
body("time_limit_seconds", "Time limit in seconds, 0 for none."),
body("pass_percent", "Pass mark 0-100, 0 for no verdict."),
),
),
Action(
name="publish_quiz",
method="POST",
path="/quizzes/{slug}/publish",
summary="Publish a quiz, freezing it forever",
description=(
"IRREVERSIBLE. A published quiz, its questions and its options can never be "
"edited again; only deletion remains. Refused with the exact list of problems "
"when the quiz is incomplete."
),
handler="http",
requires_auth=True,
params=(path("slug", "Quiz slug or uid."), confirm()),
),
Action(
name="delete_quiz",
method="POST",
path="/quizzes/delete/{slug}",
summary="Delete a quiz and everything attached to it",
handler="http",
requires_auth=True,
params=(path("slug", "Quiz slug or uid."), confirm()),
),
Action(
name="add_quiz_question",
method="POST",
path="/quizzes/{slug}/questions",
summary="Add one question to a draft quiz",
handler="http",
requires_auth=True,
params=(
path("slug", "Quiz slug or uid."),
body("kind", f"Question kind, one of: {KIND_LIST}.", required=True),
body("prompt", "The question prompt, markdown.", required=True),
body("points", "Points for this question, 1 to 100."),
body("explanation", "Explanation shown after answering."),
body("options", "Option labels, one per line or comma separated."),
body("match_values", "Accepted answers aligned with the options, one per line."),
body("correct_indexes", "Comma separated 0-based indexes of the correct options."),
body("correct_boolean", "true_false only: 1 when the statement is true."),
body("expected_answer", "free_text only: the reference answer."),
body("grading_criteria", "free_text only: criteria for the AI reviewer."),
body("numeric_value", "numeric only: the correct value."),
body("numeric_tolerance", "numeric only: the accepted absolute tolerance."),
body("case_sensitive", "fill_blank only: 1 to compare case sensitively."),
),
),
Action(
name="edit_quiz_question",
method="POST",
path="/quizzes/{slug}/questions/{question_uid}",
summary="Replace one question of a draft quiz",
handler="http",
requires_auth=True,
params=(
path("slug", "Quiz slug or uid."),
path("question_uid", "The question uid."),
body("kind", f"Question kind, one of: {KIND_LIST}.", required=True),
body("prompt", "The question prompt, markdown.", required=True),
body("points", "Points for this question, 1 to 100."),
body("explanation", "Explanation shown after answering."),
body("options", "Option labels, one per line or comma separated."),
body("match_values", "Accepted answers aligned with the options, one per line."),
body("correct_indexes", "Comma separated 0-based indexes of the correct options."),
body("correct_boolean", "true_false only: 1 when the statement is true."),
body("expected_answer", "free_text only: the reference answer."),
body("grading_criteria", "free_text only: criteria for the AI reviewer."),
body("numeric_value", "numeric only: the correct value."),
body("numeric_tolerance", "numeric only: the accepted absolute tolerance."),
body("case_sensitive", "fill_blank only: 1 to compare case sensitively."),
),
),
Action(
name="delete_quiz_question",
method="POST",
path="/quizzes/{slug}/questions/{question_uid}/delete",
summary="Delete one question from a draft quiz",
handler="http",
requires_auth=True,
params=(
path("slug", "Quiz slug or uid."),
path("question_uid", "The question uid."),
confirm(),
),
),
Action(
name="reorder_quiz_questions",
method="POST",
path="/quizzes/{slug}/questions/reorder",
summary="Set the question order of a draft quiz",
handler="http",
requires_auth=True,
params=(
path("slug", "Quiz slug or uid."),
body(
"order",
"Every question uid in the wanted order, comma separated.",
required=True,
),
),
),
Action(
name="start_quiz_attempt",
method="POST",
path="/quizzes/{slug}/attempts",
summary="Start or resume an attempt on a published quiz",
description="Always returns the single in-progress attempt for this member.",
handler="http",
requires_auth=True,
params=(path("slug", "Quiz slug or uid."),),
),
Action(
name="get_quiz_attempt",
method="GET",
path="/quizzes/{slug}/attempts/{attempt_uid}",
summary="Read an attempt with its questions in play order",
description=(
"Correct answers are withheld until a question has been answered and the "
"quiz reveals answers."
),
handler="http",
requires_auth=True,
read_only=True,
params=(
path("slug", "Quiz slug or uid."),
path("attempt_uid", "The attempt uid."),
),
),
Action(
name="answer_quiz_question",
method="POST",
path="/quizzes/{slug}/attempts/{attempt_uid}/answer",
summary="Submit one answer and get it graded",
description=(
"Each question can be answered exactly once. free_text answers are graded "
"by the AI reviewer and fall back to keyword overlap when it is unavailable."
),
handler="http",
requires_auth=True,
params=(
path("slug", "Quiz slug or uid."),
path("attempt_uid", "The attempt uid."),
body("question_uid", "The question being answered.", required=True),
body("answer_text", "Free text, the numeric value, or true/false."),
body("option_uids", "Chosen option uids, comma separated and in order for ordering."),
body("blanks", "fill_blank only: one answer per blank, comma separated."),
body("matches", "matching only: the chosen right-hand value per option_uid, in order."),
),
),
Action(
name="finish_quiz_attempt",
method="POST",
path="/quizzes/{slug}/attempts/{attempt_uid}/finish",
summary="Finish an attempt and get the final score",
handler="http",
requires_auth=True,
params=(
path("slug", "Quiz slug or uid."),
path("attempt_uid", "The attempt uid."),
),
),
Action(
name="get_quiz_result",
method="GET",
path="/quizzes/{slug}/attempts/{attempt_uid}/results",
summary="Read the result of a finished attempt",
handler="http",
requires_auth=True,
read_only=True,
params=(
path("slug", "Quiz slug or uid."),
path("attempt_uid", "The attempt uid."),
),
),
Action(
name="quiz_leaderboard",
method="GET",
path="/quizzes/{slug}/leaderboard",
summary="Top completed attempts on one quiz",
handler="http",
requires_auth=False,
read_only=True,
params=(
path("slug", "Quiz slug or uid."),
query("limit", "How many entries to return, up to 100."),
),
),
Action(
name="quiz_scoreboard",
method="GET",
path="/quizzes/scoreboard",
summary="The cross-quiz score per user",
description=(
"Each member contributes their best completed attempt per quiz, including "
"quizzes they wrote themselves."
),
handler="http",
requires_auth=False,
read_only=True,
params=(query("limit", "How many entries to return, up to 100."),),
),
)
@@ -3,7 +3,7 @@
from __future__ import annotations
from ..spec import Action
from ._shared import body, confirm, path, upload
from ._shared import body, confirm, path, query, upload
UPLOAD_ACTIONS: tuple[Action, ...] = (
@@ -14,6 +14,44 @@ UPLOAD_ACTIONS: tuple[Action, ...] = (
summary="Upload a local file and obtain its attachment uid",
params=(upload("file", "Local filesystem path of the file to upload."),),
),
Action(
name="list_attachments",
method="GET",
path="/uploads",
summary="List your own uploaded attachments, newest first",
description=(
"Returns the signed-in user's attachments as {attachments, pagination, total}. Each item "
"carries its uid, original_filename, mime_type, url, size, target_type/target_uid/target_url "
"(where it is used, if any), and a 'linked' flag. Pass linked=true to list only attachments "
"already attached to a post/comment/project/gist/issue, or linked=false for orphaned uploads."
),
params=(
query("page", "1-based page number, 24 per page."),
query("linked", "Filter: true = attached only, false = orphaned only."),
),
),
Action(
name="get_attachment",
method="GET",
path="/uploads/{attachment_uid}",
summary="Get the metadata of one of your attachments by uid",
params=(path("attachment_uid", "Uid of the attachment."),),
),
Action(
name="rename_attachment",
method="PATCH",
path="/uploads/{attachment_uid}",
summary="Rename an attachment you own (its display filename); the file extension is preserved",
description=(
"Updates only the display filename of the attachment; the stored file and its extension are "
"unchanged (the original extension is always kept, so the file type cannot be altered). "
"Administrators may rename any user's attachment."
),
params=(
path("attachment_uid", "Uid of the attachment."),
body("filename", "New display filename.", required=True),
),
),
Action(
name="attach_url",
method="POST",
@@ -39,7 +77,16 @@ UPLOAD_ACTIONS: tuple[Action, ...] = (
name="delete_attachment",
method="DELETE",
path="/uploads/delete/{attachment_uid}",
summary="Delete an uploaded attachment (your own, or anyone's when you are an administrator). Soft delete, confirmation required",
summary="Delete an uploaded attachment (your own, or anyone's when you are an administrator). Confirmation required",
description=(
"Removes one attachment by uid. Use list_attachments (or get_attachment) to find the uid of the "
"file the user wants gone, then delete it. The attachment leaves the owner's attachment list and "
"disappears from every post, comment, project, gist, message, or issue it was attached to, and its "
"file stops being served. Only the owner may delete their own; an administrator may delete anyone's "
"(otherwise 403); an unknown or already-removed uid returns 404. This is confirmation-gated: the "
"first call is refused so you can show the user the exact attachment (filename + where it is used) "
"first, and only a repeat call with confirm=true performs it."
),
params=(path("attachment_uid", "Uid of the attachment."), confirm()),
),
)
@@ -1,200 +0,0 @@
# retoor <retoor@molodetz.nl>
from .spec import Action, Param
def arg(
name: str, description: str, required: bool = False, kind: str = "string"
) -> Param:
return Param(
name=name,
location="body",
description=description,
required=required,
type=kind,
)
SLUG = arg(
"project_slug",
"Project slug or uid that owns the container resources.",
required=True,
)
CONTAINER_ACTIONS: tuple[Action, ...] = (
Action(
name="container_list_instances",
method="LOCAL",
path="",
handler="container",
requires_admin=True,
read_only=True,
summary="List a project's container instances and their status",
params=(SLUG,),
),
Action(
name="container_create_instance",
method="LOCAL",
path="",
handler="container",
requires_admin=True,
summary="Create and start a container instance (runs the shared ppy image with the project files mounted at /app)",
params=(
SLUG,
arg("name", "Instance name.", required=True),
arg(
"boot_command", "Optional command to run on boot, e.g. 'python app.py'."
),
arg(
"boot_language",
"Optional boot source language: 'none', 'python', or 'bash'. When set with boot_script, the script is materialized into /app and run on launch (takes precedence over boot_command).",
),
arg(
"boot_script",
"Optional boot source code (the body of the python or bash script) run on launch when boot_language is python or bash.",
),
arg(
"run_as_uid",
"Optional DevPlace user uid whose identity and API key are injected (PRAVDA_API_KEY, PRAVDA_USER_UID). Does NOT change the container OS user, which is always pravda (uid 1000).",
),
arg(
"start_on_boot",
"Force this instance to running whenever the container service starts ('true' or 'false', default false).",
),
arg("restart_policy", "never, always, on-failure, or unless-stopped."),
arg("env", "Optional env vars as KEY=VALUE lines."),
arg(
"ports",
"Port maps per line or comma separated. Use a bare container port (e.g. '8899') to auto-assign a unique host port above 20000, or 'host:container' to pin one.",
),
arg("cpu_limit", "Optional CPU limit, e.g. 1 or 1.5."),
arg("mem_limit", "Optional memory limit, e.g. 512m or 1g."),
arg("autostart", "Start immediately ('true' or 'false', default true)."),
arg(
"ingress_slug",
"Optional public ingress slug; the service is then reachable at /p/<slug>.",
),
arg(
"ingress_port",
"Container port to publish at /p/<slug> (must be one of the mapped ports).",
kind="integer",
),
),
),
Action(
name="container_instance_action",
method="LOCAL",
path="",
handler="container",
requires_admin=True,
summary="Control an instance: start, stop, restart, pause, resume, delete, or sync",
description="sync imports the container /app workspace back into the project files.",
params=(
SLUG,
arg("instance", "Instance name, slug, or uid.", required=True),
arg(
"action",
"start, stop, restart, pause, resume, delete, or sync.",
required=True,
),
arg(
"confirm",
"Required only for action=delete: set true ONLY after the user has explicitly "
"confirmed destroying the instance. Leave unset otherwise; the delete is refused "
"until you pass confirm=true.",
kind="boolean",
),
),
),
Action(
name="container_configure_instance",
method="LOCAL",
path="",
handler="container",
requires_admin=True,
summary="Update an instance's run-as user, boot language/script/command, restart policy, start-on-boot flag, and resource limits",
params=(
SLUG,
arg("instance", "Instance name, slug, or uid.", required=True),
arg(
"run_as_uid",
"DevPlace user uid whose identity and API key are injected (PRAVDA_API_KEY, PRAVDA_USER_UID); pass empty to clear. Does NOT change the container OS user (always pravda, uid 1000).",
),
arg("boot_language", "Boot source language: 'none', 'python', or 'bash'."),
arg("boot_script", "Boot source code body run on launch."),
arg("boot_command", "Fallback boot command used when no boot_script is set."),
arg("restart_policy", "never, always, on-failure, or unless-stopped."),
arg(
"start_on_boot",
"Force running on container-service start ('true' or 'false').",
),
arg("cpu_limit", "CPU limit, e.g. 1 or 1.5."),
arg("mem_limit", "Memory limit, e.g. 512m or 1g."),
),
),
Action(
name="container_logs",
method="LOCAL",
path="",
handler="container",
requires_admin=True,
read_only=True,
summary="Read the recent logs of a running instance",
params=(
SLUG,
arg("instance", "Instance name, slug, or uid.", required=True),
arg("tail", "Number of log lines (default 200).", kind="integer"),
),
),
Action(
name="container_exec",
method="LOCAL",
path="",
handler="container",
requires_admin=True,
summary="Run a one-shot command inside a running instance and return its output. The command runs in /app (the project workspace) by default, so never prefix it with 'cd /app'",
params=(
SLUG,
arg("instance", "Instance name, slug, or uid.", required=True),
arg(
"command",
"Command to run, e.g. 'git clone ... && ls'. Runs in /app already; do not prepend 'cd /app'.",
required=True,
),
arg(
"confirm",
"Required only when the command is destructive (rm, dd, truncate, drop, etc.): set "
"true ONLY after the user has explicitly confirmed. Leave unset otherwise; a "
"destructive command is refused until you pass confirm=true.",
kind="boolean",
),
),
),
Action(
name="container_stats",
method="LOCAL",
path="",
handler="container",
requires_admin=True,
read_only=True,
summary="Get aggregated resource and runtime statistics for an instance",
params=(SLUG, arg("instance", "Instance name, slug, or uid.", required=True)),
),
Action(
name="container_schedule",
method="LOCAL",
path="",
handler="container",
requires_admin=True,
summary="Schedule a start or stop of an instance (cron, interval, or one-time)",
params=(
SLUG,
arg("instance", "Instance name, slug, or uid.", required=True),
arg("action", "start or stop.", required=True),
arg("kind", "once, interval, or cron.", required=True),
arg("cron", "Cron expression for kind=cron, e.g. '0 2 * * *'."),
arg("run_at", "ISO time for kind=once, e.g. 2026-06-15T02:00:00."),
arg("every_seconds", "Interval seconds for kind=interval.", kind="integer"),
),
),
)
@@ -58,8 +58,19 @@ CONFIRM_REQUIRED = {
"notification_reset",
"gateway_provider_delete",
"gateway_model_delete",
"gateway_quota_rule_delete",
"email_account_delete",
"email_delete_message",
"game_prestige",
"game_buy_infrastructure",
"game_upgrade_defense",
"game_downgrade_defense",
"game_buy_cosmetic",
"game_upgrade_legacy",
"game_upgrade_mastery",
"publish_quiz",
"delete_quiz",
"delete_quiz_question",
}
CONDITIONAL_CONFIRM = {
@@ -313,6 +324,11 @@ class Dispatcher:
self._interaction = interaction
self._read_files: set[tuple[str, str]] = set()
def set_admin(self, is_admin: bool, is_primary_admin: bool) -> None:
self._is_admin = is_admin
self._is_primary_admin = is_primary_admin
self._docs.set_admin(is_admin)
@staticmethod
def _file_key(arguments: dict[str, Any]) -> tuple[str, str] | None:
from devplacepy.project_files import (
@@ -18,7 +18,7 @@ def arg(
TYPES = (
"One of: comment, reply, mention, vote, follow, message, badge, level, issue, award."
"One of: comment, reply, mention, vote, follow, message, badge, level, issue, award, quiz_attempt."
)
NOTIFICATION_ACTIONS: tuple[Action, ...] = (

Some files were not shown because too many files have changed in this diff Show More