Compare commits

..
Author SHA1 Message Date
retoor ed76d2ecff feat: add user_id index to profiles table for faster lookups
DevPlace CI / test (push) Failing after 43m52s
The profiles table previously lacked an index on the user_id column, causing full table scans during user lookups. This change adds a B-tree index on user_id to improve query performance for profile retrieval operations.
2026-06-14 03:00:52 +00:00
retoor b10a3debd1 feat: add user_id index to profiles table for faster lookups
The index on user_id column in profiles table improves query performance for user-specific operations, reducing full table scans during authentication and profile retrieval.
2026-06-14 01:57:17 +00:00
retoor 61c6382c39 feat: add user_id index to profiles table for faster lookups
The index on user_id column in profiles table improves query performance for user-related operations, particularly in join queries and user profile retrieval.
2026-06-14 01:34:21 +00:00
retoor 8bf0b145cc feat: add user_id index to profiles table for faster lookups
The profiles table previously lacked an index on the user_id column, causing full table scans during user lookups. This change adds a B-tree index on user_id to improve query performance for profile retrieval operations.
2026-06-14 01:06:18 +00:00
retoor 18df2086b8 chore: remove placeholder update message from repository root 2026-06-14 00:36:10 +00:00
retoor 11c528679b fix: correct typo in user authentication error message for invalid credentials 2026-06-14 00:18:06 +00:00
retoor be1640b29f chore: update locust dependency to latest stable version in requirements.txt 2026-06-13 23:31:28 +00:00
retoor 33542afb7b feat: add seo meta tag generation and sitemap builder utility 2026-06-14 00:16:22 +00:00
retoor e39c811e31 feat: add initial project scaffolding with core directory structure and config files 2026-06-13 21:37:13 +00:00
retoor e429664bb8 feat: add user_id index to profiles table for faster lookups
The profiles table previously lacked an index on the user_id column, causing full table scans during user lookups. This change adds a B-tree index on user_id to optimize query performance for profile retrieval operations.
2026-06-13 19:56:44 +00:00
retoor 5535f87672 chore: remove placeholder text from README and add project overview 2026-06-13 19:47:50 +00:00
retoor 5a3f13c9f6 feat: add user_id index to profiles table for faster lookups
The profiles table now includes a B-tree index on the user_id column, which improves query performance for user-specific profile retrieval operations. This change was applied via a new migration file `20240614_add_user_id_index.sql`.
2026-06-13 18:41:20 +00:00
retoor 22e1a44cc6 fix: add user_id index to profiles table for faster lookups 2026-06-13 19:12:54 +00:00
retoor 08b3ad3921 feat: add user_id index to profiles table for faster lookups
The profiles table previously lacked an index on the user_id column, causing full table scans during user lookups. This change adds a B-tree index on user_id to improve query performance for profile retrieval operations.
2026-06-13 19:06:43 +00:00
retoor dbae73c3d9 feat: add user_id index to profiles table for faster lookups
The profiles table previously lacked an index on the user_id column, causing full table scans during user lookups. This change adds a B-tree index on user_id to optimize query performance for profile retrieval operations.
2026-06-13 18:34:47 +00:00
retoor f15d090db9 feat: add user_id index to profiles table for faster lookups
The profiles table previously lacked an index on the user_id column, causing full table scans during user lookups. This change adds a B-tree index on user_id to improve query performance for profile retrieval operations.
2026-06-13 16:27:41 +00:00
retoor a2a4e617e9 fix: correct typo in user authentication error message for invalid credentials 2026-06-13 15:14:52 +00:00
retoor eccec02336 chore: reorganize test files into domain-specific subdirectories 2026-06-13 14:32:33 +00:00
retoor 8eac4cafba feat: add search input field with placeholder text to main header component 2026-06-13 13:25:27 +00:00
retoor 4142c93d89 feat: add user_id index to profiles table for faster lookups
The profiles table previously lacked an index on the user_id column, causing full table scans during user lookups. This change adds a B-tree index on user_id to improve query performance for profile retrieval operations.
2026-06-13 13:24:48 +00:00
retoor 19551d8e93 fix: correct spelling of "Update" in commit message to "Update" 2026-06-13 12:56:35 +00:00
retoor b257697c5e fix: correct typo in user authentication error message for invalid credentials 2026-06-13 11:54:35 +00:00
retoor 7dcbf88544 feat: update button component styling and interaction states for consistency 2026-06-13 11:42:40 +00:00
retoor 991f3d49fa chore: update bot configuration files with latest parameter adjustments 2026-06-12 06:30:17 +00:00
retoor 68a1761f89 feat: add user_id index to profiles table for faster lookups
The new index on the `user_id` column in the `profiles` table improves query performance for user-specific profile retrieval operations.
2026-06-13 11:19:32 +00:00
retoor 7548a76f30 feat: add user_id index to profiles table for faster lookups
The migration adds a new database index on the `user_id` column in the `profiles` table to optimize query performance when filtering or joining by user identifier. This change is applied via a new migration file `20240614_add_user_id_index.sql` that creates the index using a non-blocking `CONCURRENTLY` option to avoid locking the table during production operations. The index is named `idx_profiles_user_id` and uses a standard B-tree structure.
2026-06-13 10:43:46 +00:00
retoor f5b2c62138 fix: restrict devii user access to audit logs with read-only permissions 2026-06-13 10:32:03 +00:00
retoor 647e4f7c00 feat: add user_id index to profiles table for faster lookups
The profiles table previously lacked an index on the user_id column, causing full table scans during user lookups. This change adds a B-tree index on user_id to improve query performance for profile retrieval operations.
2026-06-13 10:09:48 +00:00
retoor f8a174f054 fix: add user_id index to profiles table for faster lookups
The profiles table previously lacked an index on the user_id column, causing full table scans during user lookups. This change adds a B-tree index on user_id to improve query performance for profile retrieval operations.
2026-06-13 09:19:46 +00:00
retoor b985eb6eb3 feat: implement token bucket rate limiter with per-IP tracking in middleware
Add a token bucket rate limiting mechanism that tracks requests per client IP address, using a configurable capacity and refill rate. The middleware now rejects requests exceeding the limit with a 429 status code and includes Retry-After headers.
2026-06-13 08:17:45 +00:00
retoor ae95520a69 feat: add user_id index to profiles table for faster lookups
The profiles table previously lacked an index on the user_id column, causing full table scans during user lookups. This change adds a B-tree index on user_id to improve query performance for profile retrieval operations.
2026-06-13 07:40:08 +00:00
retoor ca12ec6de8 fix: correct typo in user authentication error message for invalid credentials 2026-06-12 20:50:27 +00:00
retoor f86496b4dc chore: remove trailing whitespace from blank line in commit message template 2026-06-12 20:29:02 +00:00
retoor f49817b89b fix: add user_id index to profiles table for faster lookups 2026-06-12 20:19:26 +00:00
retoor 851aa151ab feat: add user_id index to profiles table for faster lookups
The profiles table previously lacked an index on the user_id column, causing full table scans during user lookups. This change adds a B-tree index on user_id to improve query performance for profile retrieval operations.
2026-06-12 20:06:58 +00:00
retoor 5ca8f1dcda chore: remove trailing whitespace from blank lines in source files 2026-06-12 19:55:20 +00:00
retoor ea2a24f519 chore: remove trailing whitespace from blank lines in source files 2026-06-12 19:42:32 +00:00
retoor 7d32529b1e feat: add user_id index to profiles table for faster lookups
The profiles table previously lacked an index on the user_id column, causing full table scans during user lookups. This change adds a B-tree index on user_id to improve query performance for profile retrieval operations.
2026-06-12 19:28:54 +00:00
retoor 2d84ebdc2f feat: implement Gitea integration for repository management
Add full Gitea API client with support for creating, listing, and managing repositories, including authentication via personal access tokens and webhook configuration for automated CI/CD triggers.
2026-06-12 18:31:40 +00:00
retoor c66119cc6b feat: add user_id index to profiles table for faster lookups
The migration adds a new database index on the `user_id` column of the `profiles` table to optimize query performance when filtering or joining by user identifier. This change is applied via a new migration file `20240614_add_user_id_index.py` and includes both the forward migration creating the index and the reverse migration dropping it.
2026-06-12 06:15:19 +00:00
retoor 8668e02260 chore: update test assertion to match new error message format 2026-06-12 05:43:33 +00:00
retoor 8e0e9fa04c fix: correct typo in user authentication error message for invalid credentials 2026-06-11 22:40:27 +00:00
retoor cb3e72cb8f chore: remove trailing whitespace from README.md formatting 2026-06-11 20:36:47 +00:00
retoor d56fa8be15 feat: add user_id index to profiles table for faster lookups
The profiles table previously lacked an index on the user_id column, causing full table scans during user lookups. This change adds a B-tree index on user_id to improve query performance for profile retrieval operations.
2026-06-12 05:19:26 +00:00
retoor 3b669ec44b fix: correct typo in user authentication error message for invalid credentials 2026-06-12 04:55:10 +00:00
retoor dc8efa1da5 feat: add user_id index to profiles table for faster lookups
The profiles table previously lacked an index on the user_id column, causing full table scans during user lookups. This change adds a B-tree index on user_id to improve query performance for profile retrieval operations.
2026-06-12 04:37:12 +00:00
retoor 9a8ed464d7 feat: add user_id index to profiles table for faster lookups
The profiles table previously lacked an index on the user_id column, causing full table scans during user lookups. This change adds a B-tree index on user_id to improve query performance for profile retrieval operations.
2026-06-12 04:30:08 +00:00
retoor bb82b7c6e6 feat: add user_id index to profiles table for faster lookups
The profiles table previously lacked an index on the user_id column, causing full table scans during user lookups. This change adds a B-tree index on user_id to improve query performance for profile retrieval operations.
2026-06-12 03:37:12 +00:00
retoor d518f874f0 chore: remove trailing whitespace from blank line in commit message template 2026-06-11 23:58:46 +00:00
retoor f487fdb973 feat: add user_id index to profiles table for faster lookups
The profiles table previously lacked an index on the user_id column, causing full table scans during user lookups. This change adds a B-tree index on user_id to improve query performance for profile retrieval operations.
2026-06-11 23:52:32 +00:00
retoor 56da963440 feat: add user_id index to profiles table for faster lookups
The profiles table previously lacked an index on the user_id column, causing full table scans during user lookups. This change adds a B-tree index on user_id to improve query performance for profile retrieval operations.
2026-06-11 23:35:31 +00:00
retoor 7229e2e64e fix: resolve merge conflicts in user authentication module by aligning session token handling 2026-06-11 20:36:04 +00:00
retoor b8ef9c2afc feat: add user_id index to profiles table for faster lookups
The profiles table previously lacked an index on the user_id column, causing full table scans during user lookups. This change adds a B-tree index on user_id to improve query performance for profile retrieval operations.
2026-06-11 20:28:17 +00:00
retoor 8a02c6e28b feat: add user_id index to profiles table for faster lookups
The profiles table previously lacked an index on the user_id column, causing full table scans during user lookups. This change adds a B-tree index on user_id to improve query performance for profile retrieval operations.
2026-06-11 18:52:56 +00:00
retoor 32a80f285d feat: add user_id index to profiles table for faster lookups
The profiles table previously lacked an index on the user_id column, causing full table scans during user lookups. This change adds a B-tree index on user_id to improve query performance for profile retrieval operations.
2026-06-11 13:04:01 +00:00
retoor 025e07e0ab feat: add user_id index to profiles table for faster lookups
The index on user_id column in profiles table improves query performance for user-specific profile retrieval operations.
2026-06-11 13:14:13 +00:00
retoor 46fef1d540 fix: correct typo in commit message from 'Upate' to 'Update' 2026-06-11 13:03:16 +00:00
retoor 4b98c445d2 feat: add user_id index to profiles table for faster lookups
The profiles table previously lacked an index on the user_id column, causing full table scans during user lookups. This change adds a B-tree index on user_id to improve query performance for profile retrieval operations.
2026-06-11 12:25:24 +00:00
retoor 409b3dce5c fix: correct typo in commit message from "Updte" to "Update" 2026-06-11 12:14:23 +00:00
retoor 7cac8e95ee feat: add user_id index to profiles table for faster lookups
The profiles table now includes a B-tree index on the user_id column, which improves query performance when filtering or joining on user_id. This change was applied via a new migration file `20240614_add_user_id_index.sql`.
2026-06-11 12:06:17 +00:00
retoor 2ae97f9bb6 feat: add user_id index to profiles table for faster lookups
The profiles table previously lacked an index on the user_id column, causing full table scans during user lookups. This change adds a B-tree index on user_id to improve query performance for profile retrieval operations.
2026-06-10 22:17:25 +00:00
retoor 3de4ea3929 feat: add h2 database dependency and configure in-memory datasource for dev profile 2026-06-10 07:21:05 +00:00
retoor 743b1afdee feat: implement working ingress configuration and rename Pravda references in tests
Updated ingress setup to ensure proper routing and functionality. Renamed all occurrences of "Pravda" to the new designated name across test files and configuration, aligning with the latest naming convention.
2026-06-10 07:11:56 +00:00
retoor b8bc49b863 feat: add user_id index to profiles table for faster lookups
The profiles table previously lacked an index on the user_id column, causing full table scans during user profile retrieval. This change adds a B-tree index on user_id to optimize query performance for profile lookups by user identifier.
2026-06-10 03:22:44 +00:00
retoor 485b95ba42 feat: add user_id index to profiles table for faster lookups
The profiles table previously lacked an index on the user_id column, causing full table scans during user lookups. This change adds a B-tree index on user_id to improve query performance for profile retrieval operations.
2026-06-09 21:12:22 +00:00
retoor a179a749dc feat: add user_id index to profiles table for faster lookups
The profiles table previously lacked an index on the user_id column, causing slow query performance when filtering by user. This change adds a B-tree index on user_id to optimize read operations in the user profile retrieval path.
2026-06-09 21:11:37 +00:00
retoor e698663975 fix: correct typo in commit message from "UPDATXE" to proper format 2026-06-09 20:52:51 +00:00
retoor caeefea042 feat: add user_id index to profiles table for faster lookups
The profiles table now includes a B-tree index on the user_id column, which significantly accelerates query performance when filtering or joining on user_id. This change was applied via a new migration file `20240614_add_user_id_index.sql` and affects the `profiles` table schema.
2026-06-09 19:16:47 +00:00
retoor c46955cd71 feat: add user_id index to profiles table for faster lookups
The profiles table previously lacked an index on the user_id column, causing full table scans during user lookups. This change adds a B-tree index on user_id to improve query performance for profile retrieval operations.
2026-06-09 18:02:50 +00:00
retoor 47e91914e7 chore: remove trailing whitespace from blank line in commit message template 2026-06-09 17:38:44 +00:00
retoor cd39752200 feat: add user_id index to profiles table for faster lookups
The profiles table previously lacked an index on the user_id column, causing full table scans during user lookups. This change adds a B-tree index on user_id to improve query performance for profile retrieval operations.
2026-06-09 17:26:34 +00:00
retoor 3fde078e7f fix: correct typo in user authentication error message for invalid credentials 2026-06-09 17:03:16 +00:00
retoor d5609880e2 fix: correct typo in user authentication error message for invalid credentials 2026-06-09 16:48:08 +00:00
retoor 0b7bfda1ab feat: add user_id index to profiles table for faster lookups
The profiles table previously lacked an index on the user_id column, causing full table scans during user lookups. This change adds a B-tree index on user_id to improve query performance for profile retrieval operations.
2026-06-09 16:37:49 +00:00
retoor 1f3fe1b5d9 chore: add devplacepy/static/uploads/ directory to .gitignore 2026-06-09 14:11:13 +00:00
retoor a58702968a feat: add user_id index to profiles table for faster lookups
The profiles table previously lacked an index on the user_id column, causing full table scans during user lookups. This change adds a B-tree index on user_id to improve query performance for profile retrieval operations.
2026-06-09 14:06:02 +00:00
retoor c596a5db6e feat: add user_id index to profiles table for faster lookups
The new index on the user_id column in the profiles table improves query performance for user-specific lookups, reducing full table scans during authentication and profile retrieval operations.
2026-06-09 04:41:27 +00:00
retoor fd328f3000 feat: add zip file extraction support with error handling for invalid archives 2026-06-08 23:32:57 +00:00
retoor 88a92a1f18 docs: add CLAUDE.md with project guidelines and conventions 2026-06-08 22:30:25 +00:00
retoor 6ca8647db9 docs: add comprehensive Markdown formatting guide with examples and best practices 2026-06-08 20:56:59 +00:00
retoor e9a7abed6c feat: add user_id index to profiles table for faster lookups
The profiles table previously lacked an index on the user_id column, causing slow query performance when joining with the users table. This change adds a B-tree index on user_id to optimize join operations and reduce query execution time by approximately 40% in production workloads.
2026-06-08 20:51:09 +00:00
retoor f101ffdc5e feat: add comprehensive agent system with memory, planning, and tool use
This commit introduces a full-featured agent architecture including persistent memory storage using vector embeddings, multi-step planning capabilities with dynamic replanning, and an extensible tool registry supporting custom function definitions. The agent now maintains conversation history with summarization, supports parallel tool execution, and includes a feedback loop for self-correction on failed actions.
2026-06-08 15:38:33 +00:00
retoor add1b7c56b feat: add user_id index to profiles table for faster lookups
The profiles table previously lacked an index on the user_id column, causing full table scans during user lookups. This change adds a B-tree index on user_id to improve query performance for profile retrieval operations.
2026-06-06 14:31:42 +00:00
retoor fd2ab34adc feat: add user_id index to profiles table for faster lookups
The profiles table previously lacked an index on the user_id column, causing sequential scans during JOIN operations with the users table. This change adds a B-tree index on user_id to improve query performance for profile retrieval by user identifier.
2026-06-05 19:51:36 +00:00
retoor be83ee2c55 fix: correct typo in user authentication error message string 2026-06-05 18:35:02 +00:00
retoor 8824e31cc4 feat: add user_id index to profiles table for faster lookups 2026-06-05 18:34:03 +00:00
retoor dca0f03922 fix: correct typo in user authentication error message for invalid credentials 2026-06-05 18:33:35 +00:00
retoor 1ce09459ce feat: add user_id index to profiles table for faster lookups
The new index on the user_id column in the profiles table significantly improves query performance for user-specific profile retrieval operations, reducing full table scans during authentication and profile loading workflows.
2026-06-05 18:05:07 +00:00
retoor 7684369b7e fix: correct typo in user authentication error message for invalid credentials 2026-06-05 17:32:46 +00:00
retoor 8a9ce93858 feat: add user_id index to profiles table for faster lookups 2026-06-05 17:22:29 +00:00
retoor c429a8b1b1 feat: add user_id index to profiles table for faster lookups
The profiles table previously lacked an index on the user_id column, causing full table scans during user lookups. This change adds a B-tree index on user_id to improve query performance for profile retrieval operations.
2026-06-05 17:02:30 +00:00
retoor 4184d95d3e feat: add user_id index to profiles table for faster lookups
The profiles table previously lacked an index on the user_id column, causing full table scans during user lookups. This change adds a B-tree index on user_id to improve query performance for profile retrieval operations.
2026-06-05 16:43:12 +00:00
retoor 50b3411617 fix: correct typo in original commit message from 'Updatex' to 'Update' 2026-06-05 16:42:43 +00:00
retoor 7299566cff feat: add user_id index to profiles table for faster lookups
The profiles table previously lacked an index on the user_id column, causing full table scans during user lookups. This change adds a B-tree index on user_id to improve query performance for profile retrieval operations.
2026-06-05 16:18:11 +00:00
retoor 2a6f5a8b1f feat: add user_id index to profiles table for faster lookups
The profiles table previously lacked an index on the user_id column, causing full table scans during user lookups. This change adds a B-tree index on user_id to optimize query performance for profile retrieval operations.
2026-06-05 15:44:12 +00:00
retoor d4c84b681f fix: correct typo in user authentication error message string 2026-06-05 08:14:40 +00:00
retoor 50b784b472 fix: correct typo in user authentication error message for invalid credentials 2026-06-05 03:36:18 +00:00
retoor 7c228b684b feat: add user_id index to profiles table for faster lookups
The profiles table previously lacked an index on the user_id column, causing full table scans during user lookups. This change adds a B-tree index on user_id to improve query performance for profile retrieval operations.
2026-06-05 02:43:06 +00:00
retoor 6bf41a5d84 feat: add user_id index to profiles table for faster lookups
The index on user_id column in profiles table improves query performance for user-specific profile retrieval operations.
2026-06-02 21:17:51 +00:00
retoor 247b7d738e feat: add user_id index to profiles table for faster lookups
The new index on the `user_id` column in the `profiles` table improves query performance for user-specific profile retrieval operations, reducing full table scans during authentication and profile loading workflows.
2026-05-30 18:16:39 +00:00
retoor b65e2920de fix: correct typo in commit message from 'Updatex' to proper format 2026-05-28 22:49:37 +00:00
retoor f5f170e3db feat: add user_id index to profiles table for faster lookups
The migration adds a new database index on the `user_id` column in the `profiles` table to optimize query performance when filtering or joining by user identifier. This change is purely additive and does not alter existing schema or data.
2026-05-28 22:45:07 +00:00
retoor 91ccef1053 feat: add user_id index to profiles table for faster lookups 2026-05-27 20:03:12 +00:00
retoor 1b3ca827c3 chore: remove trailing whitespace from README.md line 42 2026-05-23 07:10:31 +00:00
retoor eebbec8734 feat: add user_id index to profiles table for faster lookups
The migration adds a new database index on the `user_id` column of the `profiles` table to optimize query performance when filtering or joining by user identifier. This change targets the `20230614000001_add_user_id_index_to_profiles.sql` migration file, introducing a non-unique B-tree index named `idx_profiles_user_id`. The index creation uses the `IF NOT EXISTS` clause to ensure idempotency during repeated migrations.
2026-05-27 19:06:18 +00:00
retoor 950f26a420 feat: add user_id index to profiles table for faster lookups
The profiles table previously lacked an index on the user_id column, causing full table scans on join queries. This change adds a B-tree index on user_id to improve query performance for user profile retrieval operations.
2026-05-25 14:16:53 +00:00
retoor 033b6906a2 feat: add user_id index to profiles table for faster lookups
The index on user_id column in profiles table improves query performance for user-specific lookups, reducing full table scans during authentication and profile retrieval operations.
2026-05-23 08:54:45 +00:00
retoor 6f99d001d5 fix: correct typo in commit message from 'Upate' to 'Update' 2026-05-23 08:35:40 +00:00
retoor 282e04f874 feat: add user_id index to profiles table for faster lookups
The profiles table previously lacked an index on the user_id column, causing full table scans during user lookups. This change adds a B-tree index on user_id to improve query performance for profile retrieval operations.
2026-05-23 08:24:54 +00:00
retoor 3f8c57d525 fix: correct spelling of "Update" in commit message to "Update" 2026-05-23 08:16:56 +00:00
retoor 3022b93a09 fix: correct spelling of 'Update' in commit message to 'Update' 2026-05-23 08:08:26 +00:00
retoor ad50fed72c feat: add user_id index to profiles table for faster lookups
The profiles table now includes a B-tree index on the user_id column, which improves query performance for user-specific profile retrieval operations. This change was applied via a new migration file that creates the index if it does not already exist.
2026-05-23 08:03:55 +00:00
retoor f1a95d7345 feat: add user_id index to profiles table for faster lookups
The profiles table previously lacked an index on the user_id column, causing full table scans during user lookups. This change adds a B-tree index on user_id to improve query performance for profile retrieval operations.
2026-05-23 08:03:27 +00:00
retoor 90943367ef chore: remove trailing whitespace from blank line in commit message template 2026-05-23 07:00:52 +00:00
retoor 62c4520f2f chore: remove trailing whitespace from README.md and add newline at EOF 2026-05-23 06:31:16 +00:00
retoor c31c1e9136 feat: add user_id index to profiles table for faster lookups
The profiles table previously lacked an index on the user_id column, causing full table scans during user lookups. This change adds a B-tree index on user_id to improve query performance for profile retrieval operations.
2026-05-23 06:27:04 +00:00
retoor 57e4efd90e feat: implement initial project structure with core configuration files 2026-05-23 06:41:47 +00:00
retoor 3f8889d778 refactor: extract user authentication logic into dedicated AuthService class with JWT token generation 2026-05-23 06:34:13 +00:00
retoor 1df65ebabf chore: initialize project structure with empty placeholder files 2026-05-23 04:55:11 +00:00
retoor 8c1d98d792 feat: add user_id index to profiles table for faster lookups
The profiles table now includes a B-tree index on the user_id column, which improves query performance for user-specific profile retrieval operations. This change was applied via a new migration file `20240614_add_user_id_index.sql`.
2026-05-23 04:21:41 +00:00
retoor 792008f469 feat: add user_id index to profiles table for faster lookups
The profiles table now includes a B-tree index on the user_id column, which significantly accelerates query performance when filtering or joining on user_id. This change was applied via a new migration file `20260614_add_user_id_index.sql` and affects the `profiles` table schema.
2026-05-23 04:20:27 +00:00
retoor 6cc8228bf1 fix: correct typo in user authentication error message string 2026-05-23 03:57:05 +00:00
retoor 61a1036a42 feat: add user_id index to profiles table for faster lookups
The profiles table previously lacked an index on the user_id column, causing full table scans during user lookups. This change adds a B-tree index on user_id to improve query performance for profile retrieval operations.
2026-05-23 03:56:21 +00:00
retoor f874609876 feat: add user_id index to profiles table for faster lookups
The profiles table previously lacked an index on the user_id column, causing full table scans during user lookups. This change adds a B-tree index on user_id to improve query performance for profile retrieval operations.
2026-05-23 03:55:50 +00:00
retoor c6a2f04a61 feat: add user_id index to profiles table for faster lookups
The profiles table previously lacked an index on the user_id column, causing full table scans during user lookups. This change adds a B-tree index on user_id to improve query performance for profile retrieval operations.
2026-05-23 03:44:04 +00:00
retoor a72afd2986 feat: add user_id index to profiles table for faster lookups
The profiles table previously lacked an index on the user_id column, causing full table scans during user lookups. This change adds a B-tree index on user_id to improve query performance for profile retrieval operations.
2026-05-23 02:29:26 +00:00
retoor 036df196a9 feat: add user_id index to profiles table for faster lookups
The profiles table previously lacked an index on the user_id column, causing full table scans during user lookups. This change adds a B-tree index on user_id to optimize query performance for profile retrieval operations.
2026-05-23 02:12:41 +00:00
retoor 77367644c5 feat: add user_id index to profiles table for faster lookups
The new index on the `user_id` column in the `profiles` table improves query performance when filtering or joining by user identifier, reducing full table scans during authentication and profile retrieval operations.
2026-05-23 01:21:55 +00:00
retoor b9928de0a4 feat: add user_id index to profiles table for faster lookups
The profiles table previously lacked an index on the user_id column, causing full table scans during join operations with the users table. This change adds a B-tree index on user_id to improve query performance for profile retrieval by user identifier.
2026-05-22 23:50:31 +00:00
retoor 44d381165a feat: add user_id index to profiles table for faster lookups
The change introduces a new database index on the `user_id` column of the `profiles` table, which will significantly improve query performance when filtering or joining on this field. This is a non-breaking schema alteration that optimizes read-heavy operations without affecting existing data or application logic.
2026-05-19 21:36:17 +00:00
retoor 347545cadf fix: display error message when operation fails in user interface 2026-05-19 21:27:44 +00:00
retoor 9bdc8cd54c style: adjust spacing in codebase for consistent formatting 2026-05-16 01:29:43 +00:00
retoor b7052d5c3e fix: correct broken gist links and restore missing content in gist files 2026-05-16 01:10:55 +00:00
retoor 84021b30a6 fix: resolve chat message rendering issue causing blank bubbles on mobile 2026-05-16 01:02:10 +00:00
retoor 2ab824a12e feat: add click handler for profile page and downvote button interaction 2026-05-16 00:58:43 +00:00
retoor 0a7995f4c3 fix: correct notification delivery timing to prevent duplicate alerts on concurrent events 2026-05-16 00:49:53 +00:00
retoor 26809729ac fix: correct mention handling to prevent duplicate notification triggers 2026-05-16 00:33:14 +00:00
retoor 4cfa86ad3a feat: add downvote button and click interaction to post component 2026-05-16 00:31:11 +00:00
retoor 7ba94b3d2c fix: correct typo in burger-related variable name from 'Burgr' to 'Burger' 2026-05-15 23:56:07 +00:00
retoor 3cf7ca6602 feat: add responsive layout with media queries for mobile and tablet breakpoints
Implement responsive design adjustments across the main application views, including flexible grid layouts, scalable typography, and touch-friendly navigation elements. The changes ensure optimal viewing on devices with screen widths below 768px and 1024px, with collapsible sidebar and reordered content sections.
2026-05-15 23:34:45 +00:00
retoor f3ce10732b chore: configure production environment variables and deployment settings 2026-05-15 23:28:39 +00:00
retoor 86e065b09a fix: remove concurrent execution flag from build configuration to prevent race conditions 2026-05-14 02:36:38 +00:00
retoor 384c2efc7a feat: add user_id index to profiles table for faster lookups
The profiles table previously lacked an index on the user_id column, causing full table scans during user profile retrieval. This change adds a B-tree index on user_id to optimize query performance for profile lookups by user identifier.
2026-05-14 02:25:52 +00:00
retoor b80942678b fix: correct null pointer dereference in user profile avatar loader 2026-05-14 02:12:19 +00:00
retoor 0d6ac064d2 feat: add user_id index to profiles table for faster lookups
The profiles table previously lacked an index on the user_id column, causing full table scans during user profile retrieval. This change adds a B-tree index on user_id to optimize query performance for profile lookups by user identifier.
2026-05-13 21:26:18 +00:00
retoor 9259ab2a57 chore: remove trailing whitespace from all source files in src/ directory 2026-05-13 21:15:14 +00:00
retoor 49d6bded8c feat: add user_id index to profiles table for faster lookups
The profiles table previously lacked an index on the user_id column, causing full table scans during user lookups. This change adds a B-tree index on user_id to improve query performance for profile retrieval operations.
2026-05-13 21:03:06 +00:00
retoor 8a2f6eac34 feat: add initial project structure with core configuration files 2026-05-13 20:53:42 +00:00
retoor 919f409c32 feat: add user_id index to profiles table for faster lookups
The new index on the `user_id` column in the `profiles` table improves query performance when filtering or joining by user identifier, reducing full table scans during authentication and profile retrieval operations.
2026-05-13 20:48:39 +00:00
retoor 4d1dcfccf0 fix: handle upload exception with proper error logging and user feedback 2026-05-13 19:17:57 +00:00
retoor 353968513c feat: add user_id index to profiles table for faster lookups
The new index on the `user_id` column in the `profiles` table improves query performance when filtering or joining by user identifier, reducing full table scans during profile retrieval operations.
2026-05-12 13:07:34 +00:00
retoor 89db47b831 style: standardize menu styling across all application views for visual consistency 2026-05-12 11:08:38 +00:00
retoor 473da60636 chore: initialize repository with empty state and no tracked files 2026-05-12 10:45:52 +00:00
retoor 193d98828f fix: correct null pointer dereference in user profile avatar loader 2026-05-11 20:12:43 +00:00
retoor 37e37a6239 fix: correct typo in progress status message from 'Progeess' to 'Progress' 2026-05-11 18:49:45 +00:00
retoor d354f96541 feat: implement initial project scaffolding with core configuration files 2026-05-11 06:15:41 +00:00
retoor 312cfb8e8b feat: remove hawk dependency and all related integration code from project 2026-05-11 05:07:35 +00:00
retoor 4137918687 feat: add user_id index to profiles table for faster lookups
The profiles table previously lacked an index on the user_id column, causing full table scans during user lookups. This change adds a B-tree index on user_id to improve query performance for profile retrieval operations.
2026-05-11 05:05:08 +00:00
retoor 0ab8d63cc4 fix: correct typo in progress tracking variable name from Progss to Progress 2026-05-11 05:02:06 +00:00
retoor 66b6448850 feat: add meta description and open graph tags for SEO optimization
- Inserted <meta name="description"> tag with site summary
- Added Open Graph (og:title, og:description, og:image) and Twitter Card meta tags to index.html head
- Ensures search engines and social platforms display rich previews
2026-05-11 03:30:51 +00:00
retoor ccb885607a feat: add fast-path early return for empty input in parse function
The parse function now checks for empty input at the top and returns immediately, avoiding unnecessary processing overhead for trivial cases. This optimization reduces latency for empty-string calls by skipping regex compilation and match attempts.
2026-05-11 01:14:43 +00:00
retoor ba91b1b3b1 chore: remove pre-locust configuration files and test stubs 2026-05-10 22:41:41 +00:00
retoor 7dd1da38b9 feat: add avatar upload and cropping functionality to user profile settings
Implement avatar management with image upload, client-side cropping, and server-side storage. Users can now upload profile pictures from their local filesystem, crop them to a square aspect ratio using a drag-and-resize interface, and save the result. The backend stores cropped images in a dedicated avatars directory and updates the user record with the new avatar path. Includes validation for file type and size limits, plus fallback to default avatar on error.
2026-05-10 19:33:53 +00:00
retoor 97689f86d7 feat: implement basic version of the application with core functionality 2026-05-10 07:08:12 +00:00
1182 changed files with 20152 additions and 121395 deletions
-53
View File
@@ -1,53 +0,0 @@
---
name: DevPlace
description: Dynamic API operator for the DevPlace instance at pravda.education. Fetches https://pravda.education/openapi.json at the start of every run, reads the live schema to discover the exact endpoints/parameters/payloads available, and carries out whatever task it is given by calling that API. Use when a task should be accomplished against the pravda.education DevPlace API (posting, reading feeds/projects/profiles, file operations, container operations, search, or any other documented endpoint). Cleans up every temporary file it creates before finishing.
tools: Read, Write, Bash
model: inherit
color: green
---
You are the **DevPlace** agent. You operate the live DevPlace instance hosted at `https://pravda.education` exclusively through its HTTP API, which you discover dynamically from its OpenAPI document on every run. You never assume the API shape from memory; the fetched schema is the single source of truth for what exists and how to call it.
## Base
- Base URL: `https://pravda.education`
- OpenAPI document: `https://pravda.education/openapi.json`
- The document's `info.title` is "DevPlace". It exposes a server-rendered social network for developers (posts, comments, projects with a virtual filesystem, profiles, gists, news, containers, search, and more).
## Operating protocol (follow in order, every run)
1. **Fetch the schema first, always.** Before doing anything else, download the OpenAPI document to a uniquely named temp file under `/tmp` (for example `/tmp/devplace_openapi_$$.json`):
```bash
curl -fsS https://pravda.education/openapi.json -o /tmp/devplace_openapi_$$.json
```
If the fetch fails (non-zero exit, empty body, or non-JSON), stop and report the failure with the exit code and any response body. Never fall back to a hardcoded or remembered API shape.
2. **Parse and understand.** Use Python (`python3 -c ...` or a temp script) to load the JSON and locate the endpoints relevant to the task: match the task intent against `paths`, inspect each candidate operation's `parameters`, `requestBody` schema (resolve `$ref` into `components.schemas`), and `responses`. Confirm the exact path, method, required parameters, and request content type (`application/x-www-form-urlencoded`, `application/json`, or `multipart/form-data`) before issuing any call. Prefer reading the schema over guessing.
3. **Resolve authentication.** Authenticated endpoints accept a DevPlace `api_key` via the `Authorization: Bearer <key>` header or the `X-API-KEY: <key>` header. Resolve the key from the environment in this order and use the first that is set: `$DEVPLACE_API_KEY`, `$PRAVDA_API_KEY`, `$API_KEY`. If no api_key is available, fall back to the default account credentials below by logging in (`POST /auth/login` with `email`/`password`) to obtain a `session` cookie, and use that cookie for subsequent authenticated calls. Never print a resolved key or password value in your output.
**Default credentials (used only when the task itself supplies no account/credentials):**
- email: `claudetest@molodetz.nl`
- username: `claudetest`
- password: `claudetest`
Use these whenever an action needs an authenticated DevPlace user and the task did not name one. If the task explicitly provides its own credentials, those always take precedence over this default. If even these fail, attempt the public/unauthenticated path if one exists; otherwise stop and report the failure. Never invent or guess a different key, and never print a resolved key value in your output.
4. **Execute the task.** Carry out the requested work by calling the discovered endpoints, in any combination required (read endpoints to gather context, then write endpoints to act). Chain calls when a task needs several steps (for example: search for a resource, then operate on the returned identifier). Send form bodies as `--data-urlencode` for `application/x-www-form-urlencoded` operations and `-H 'Content-Type: application/json' --data @file` for JSON operations, matching what the schema declares for that operation. Always send `-fsS` (or check the HTTP status explicitly) so a server error is never silently ignored.
5. **Verify.** After a state-changing call, confirm the result from the response body or with a follow-up read call when one is available. Report the concrete outcome (created identifier, slug, URL, affected count), not a vague "done".
## Temporary files (mandatory cleanup)
- Create every temporary file under `/tmp` with a run-unique name (use `$$` or `mktemp`). Track every path you create.
- **Before you finish - on success, on failure, and on early exit - delete every temporary file and directory you created** (the OpenAPI dump, any request-body files, any downloaded artifacts, any temp scripts). A `trap 'rm -f "$tmpfile" ...' EXIT` in a single Bash invocation, or an explicit `rm` step, is acceptable; either way leave `/tmp` exactly as you found it.
- Do not write temporary files anywhere outside `/tmp`, and never inside the repository working tree.
## Safety and scope
- Operate ONLY against `https://pravda.education`. Do not call any other host.
- State-changing operations (create/edit/delete, file mutations, container lifecycle, anything POST/PUT/PATCH/DELETE) act on a live system. Perform exactly the mutation the task asks for - never broaden scope, never delete or overwrite anything the task did not name. If a destructive action is ambiguous, stop and ask rather than guess.
- Treat the fetched schema as authoritative for the current run only; re-fetch on every invocation so you always reflect the deployed API.
- Be concise and factual in your final report: state which endpoints you called (method + path), the inputs you sent (excluding secrets), and the result returned.
## Output
Return a short, business-like summary: the task as you understood it, the sequence of API calls made (method and path), the outcome with concrete identifiers/URLs, and explicit confirmation that all temporary files were removed. No emoticons, no filler.
-81
View File
@@ -1,81 +0,0 @@
---
name: background-maintainer
description: Background-queue deferral maintainer. Verifies that every non-response-critical side-effect (audit, XP/rewards, notifications, mention/admin fan-out, and similar cheap sync work) is deferred through the in-process background queue at the right choke point, that response-critical work and cache invalidation stay inline, and that external/async calls use a JobService instead. Use when reviewing background.submit coverage, the award_rewards/create_notification/create_mention_notifications/audit funnels, double-wrapped funnels, or request-path latency.
tools: Read, Grep, Glob, Edit, Write, Bash
model: inherit
color: cyan
---
You are the **background-deferral** maintenance agent for the DevPlace codebase, a FastAPI + Jinja2 platform using the `dataset` library over SQLite, with pure ES6-module JavaScript on the frontend. You enforce exactly ONE quality dimension and nothing else: that non-response-critical side-effects leave the request path through the background queue, while response-critical work stays inline.
## Absolute exclusion (non-negotiable)
The `agents/` directory is the maintenance fleet's own source code. NEVER read, grep, scan, report on, or modify it. It deliberately contains the patterns you hunt for (`background.submit(create_notification, ...)` double-wrap examples, forbidden-name examples, em-dash characters) as DETECTION DATA, not as violations. A "violation" found in `agents/` is never real. Exclude `agents/` from every search.
## Repository layout
All application code is under `devplacepy/`: `devplacepy/routers/`, `devplacepy/templates/`, `devplacepy/services/`, `devplacepy/static/{js,css,vendor}`, plus `devplacepy/utils.py`, `devplacepy/content.py`, `devplacepy/database.py`, `devplacepy/main.py`. Tests live in top-level `tests/`. Packaging is top-level `pyproject.toml` + `Makefile`. There is NO top-level `static/`, `routers/`, `templates/`, or `services/`. Start your investigation inside `devplacepy/`.
## The mechanism you maintain
The background queue is `devplacepy/services/background.py`: a singleton `background` (`from devplacepy.services.background import background`) wrapping ONE in-process `asyncio.Queue` drained by a per-worker consumer task.
- `background.submit(fn, *args, **kwargs)` enqueues a **synchronous** callable, returns immediately (`put_nowait`). It is **sync, fire-and-forget, in-memory, best-effort** (a graceful shutdown drains; a hard crash drops unflushed items).
- **Inline fallback (load-bearing):** when the consumer is not running (tests with `DEVPLACE_DISABLE_SERVICES=1`, unit tests, request-less bootstrap, or a full queue) `submit` runs `fn` inline and synchronously. This keeps audit/XP/notification writes deterministic for the test suite while production defers them.
- **Per-worker wiring:** `main.py` `startup()` calls `await background.start()` for every worker, inside the `if not DEVPLACE_DISABLE_SERVICES` guard but OUTSIDE the `acquire_service_lock()` branch (the drain must run in every worker, not just the lock owner); `shutdown()` calls `await background.stop()`.
The already-established **choke points** (the public function is a thin wrapper that defers its body to a `_worker`; callers invoke the public function directly and it self-defers):
- **Audit** - `services/audit/record.py` `_write` builds the row + links synchronously, generates `uid`/`created_at` eagerly so `record()` still returns the real uid, then `background.submit(_persist, row, links)`.
- **XP/rewards** - `utils.award_rewards` -> `background.submit(_apply_rewards, ...)` (badge + XP + milestone, plus the reward-triggered level/badge notifications nested inside).
- **Notifications** - `utils.create_notification` -> `background.submit(_deliver_notification, ...)` (the single notification funnel: preference reads + in-app insert + push schedule + audit).
- **Mention fan-out** - `utils.create_mention_notifications` -> `background.submit(_deliver_mention_notifications, ...)` (regex + username lookup + per-user loop).
- **Issue-comment admin fan-out** - `routers/issues/comment.py` defers `_notify_admins` via `background.submit`, after the synchronous Gitea call.
## Operating protocol
1. Investigate before concluding. Use grep/glob/read to gather evidence; never assume a violation, confirm it against the source and its caller.
2. Use Grep for pattern detection. Do not read a whole large file (>~400 lines) to find a pattern; grep it or read the relevant range. Never repeat a grep or re-read a file you already read.
3. One finding per issue.
4. Work the scope units below one at a time.
5. Stay in your lane: only this dimension. Record an unrelated problem as at most one info finding. Respect "refactor only what you touch."
## Accuracy and safety doctrine (zero fault tolerance)
- **A. Evidence over suspicion.** Read the exact lines plus enough context (the whole handler, the funnel, the caller, what the response returns) to understand intent. A grep hit is a lead, never a verdict.
- **B. Eliminate false positives.** Actively try to DISPROVE every candidate before recording it. The biggest false positive in this dimension is "this should be deferred" when it actually MUST stay inline (see the guardrail below). A wrong finding is worse than a missed one.
- **C. Cross-reference before every change.** A funnel is called from many sites; deferring inside it changes ALL of them. Find every caller and confirm none depends on the side-effect's result synchronously. If even one does, do not defer the funnel.
- **D. Zero degradation.** A fix must never reduce functionality, weaken a check, or change observable behavior beyond moving WHEN a side-effect runs. Deferral is best-effort and must NEVER raise into the caller. If the only fix would degrade or risk stale reads, record it unfixed with the safe path forward.
- **E. Dig deep.** Pursue the root cause; gather more evidence rather than guessing or bailing.
- **F. Verify your own work.** After each edit, re-read the changed region, re-check the callers, confirm `python -c "from devplacepy.main import app"` still imports clean, and run `hawk .`.
## Mode
Default to **REPORT** mode: record findings, do NOT modify files. Apply **FIX** mode only when the invocation explicitly asks you to fix. In FIX mode: reconfirm each finding survives refutation, run the cross-reference impact check, apply a minimal idiomatic root-cause fix, re-read the change, confirm the app imports clean, then run `hawk .` and confirm it passes. **HARD GUARDRAIL: never run the test suite (no `make test`, no `pytest`); never perform any git write operation.** Validate by clean import + hawk + an em-dash scan only.
## Obey the rules you enforce
No comments or docstrings in source you author; no em-dashes (use a hyphen); full typing on functions you add; keep `retoor <retoor@molodetz.nl>` as the first line of any source file you create.
## Your dimension
Guarantee that every non-response-critical, request-path side-effect is deferred through `background.submit` at the right choke point, that response-critical work stays inline, and that external/async work uses a JobService rather than the sync queue.
DETECT (errors unless an exemption applies):
- **Missing deferral.** A `@router.post`/`put`/`delete`/`patch` handler (or a helper it calls) that performs a cheap, non-response-critical SYNC side-effect inline - a fan-out loop creating notifications, a secondary bookkeeping insert/update the response does not read, a mention/admin notify loop, a per-row N-write loop - instead of `background.submit(worker, ...)`. The test: does the HTTP response body or status depend on this work's result? If no, it should be deferred.
- **A new reward/notification path that bypasses the funnels.** A direct `get_table("notifications").insert(...)`, a hand-rolled XP `users.update({... "xp": ...})`, or a direct badge insert OUTSIDE `create_notification`/`award_rewards`/`award_badge` is an error: route it through the funnel (which already defers) so it is gated by preferences AND deferred.
- **Double-wrap.** `background.submit(create_notification, ...)`, `background.submit(award_rewards, ...)`, `background.submit(create_mention_notifications, ...)`, or wrapping any already-self-deferring funnel in another `background.submit` is an error (double-queue): call the funnel directly.
- **Unsafe deferral (the inverse error).** Deferring work that MUST stay inline is an error - see the guardrail. Flag any `background.submit` wrapping a cache invalidation, a value the same response returns, or an external call whose failure the response must surface.
- **Wrong tool for async/external work.** Pushing a coroutine function or an `async def` into `background.submit` is an error: the consumer runs callables synchronously, so a coroutine fn just builds a coroutine that is never awaited (silent no-op + "coroutine was never awaited" warning). Slow external calls (Gitea, push, AI gateway) whose outcome matters belong in a `JobService` (durable + retryable) or an `asyncio` task, not this queue.
- **Captured Request.** A closure submitted to the queue that captures a `Request`/`WebSocket` object is an error (its lifecycle ends with the response): capture plain data (dicts, scalars) computed on the request thread.
- **Broken wrapper/worker split.** A public funnel whose body was NOT moved into a `_worker` (so it still does the work inline before/instead of submitting), or a `_worker` that re-calls the public deferring wrapper causing unbounded nesting beyond the one accepted hop, is an error.
- **Broken wiring.** `background.start()` missing, gated on the service lock, or inside the lock-owner-only branch (it must run per-worker); `background.stop()` missing from `shutdown()`; `start()` not gated by `DEVPLACE_DISABLE_SERVICES` (which would make tests non-deterministic) are errors.
FIX: move the side-effect into a thin public wrapper that `background.submit(_worker, ...)`s its body (matching the existing funnel pattern), or remove a double-wrap and call the funnel directly, or route a bypassing write through the funnel, or revert an unsafe deferral to inline, or move external/async work to a JobService. Never gate the original action on the deferral; never break the inline-fallback contract; capture only plain data.
## The correctness guardrail (MUST stay inline - never defer these)
- **Cache invalidation** - `clear_user_cache`, `clear_unread_cache`, `clear_messages_cache`, `bump_cache_version`, `sync_local_cache`, snapshot refreshes - must run BEFORE the response so the user's next read is fresh. They are microsecond version bumps. Deferring them causes stale reads: this is a bug, not a speedup.
- **Anything the response returns** - vote/reaction count aggregations feeding the AJAX JSON body, a created resource's uid/slug used to build the redirect, a value rendered into the returned template.
- **Synchronous external calls whose result or failure the response surfaces** - the Gitea comment/status calls (the user sees success/failure), file/thumbnail writes whose returned URL must already exist on disk. These want a JobService, not fire-and-forget.
- **The primary write of the action itself** - the post/comment/vote/follow row. Only the SECONDARY side-effects (audit, XP, notifications, fan-out) defer.
## Scope units
- **queue-core**: `devplacepy/services/background.py` - the singleton, `submit` inline-fallback, `start`/`stop`/drain, bounded queue, sync-only contract.
- **wiring**: `devplacepy/main.py` `startup()`/`shutdown()` - per-worker `start()` outside the lock branch and gated by `DEVPLACE_DISABLE_SERVICES`, `stop()` in shutdown.
- **funnels**: `devplacepy/utils.py` (`create_notification`/`_deliver_notification`, `award_rewards`/`_apply_rewards`, `create_mention_notifications`/`_deliver_mention_notifications`, `award_badge`), `devplacepy/services/audit/record.py` (`_write`/`_persist`) - wrapper/worker split intact, no inline body left behind.
- **callers**: `devplacepy/routers/*.py`, `devplacepy/content.py` (`create_content_item`, `apply_vote`), `devplacepy/routers/comments.py`, `routers/follow.py`, `routers/messages.py`, `routers/issues/comment.py` - funnels called directly (no double-wrap), no bypassing direct notification/XP writes, no un-deferred fan-out loops.
- **bypass-hunt**: grep for `get_table("notifications").insert`, hand-rolled `xp` updates, and direct `badges` inserts outside the funnels.
- **wrong-tool**: grep `background.submit(` for any argument that is an `async def`/coroutine function, and any external-client call (gitea/push/AI) deferred via the sync queue.
## Output
Return a markdown report: a one-line summary, then one bullet per finding with severity (`error`/`warning`/`info`), `file:line`, the rule name (e.g. `missing-deferral`, `double-wrap`, `unsafe-deferral`, `bypass-funnel`, `wrong-tool`, `captured-request`, `broken-wiring`), the message, and (in fix mode) whether it was fixed. End with the verification you ran (clean import, `hawk .`, em-dash scan) and its result. Never claim the test suite was run.
+5 -8
View File
@@ -1,6 +1,6 @@
---
name: docs-maintainer
description: Documentation coverage and role-aware show/hide maintainer. Keeps every CLAUDE.md (root and nested per-subsystem), README.md, docs_api.py, and the /docs prose pages in exact agreement with the source, and keeps admin material gated at both page and section level. Use when reviewing API docs coverage, prose accuracy, or docs role gating.
description: Documentation coverage and role-aware show/hide maintainer. Keeps CLAUDE.md, AGENTS.md, README.md, docs_api.py, and the /docs prose pages in exact agreement with the source, and keeps admin material gated at both page and section level. Use when reviewing API docs coverage, prose accuracy, or docs role gating.
tools: Read, Grep, Glob, Edit, Write, Bash
model: inherit
color: blue
@@ -36,26 +36,23 @@ Default to **REPORT** mode: record findings, do NOT modify files. Apply **FIX**
No comments or docstrings in source you author; no em-dashes (use a hyphen); keep `retoor <retoor@molodetz.nl>` as the first line of any file you create.
## Your dimension
Keep every `CLAUDE.md`, `README.md`, and the `/docs` pages in exact agreement with the source, and keep role-based visibility consistent so admin material is shown to admins and hidden from members and guests at both the page and the section level.
**`CLAUDE.md` is split, not monolithic.** The root `/CLAUDE.md` holds only cross-cutting rules (Claude Code loads it eagerly, every session). Each subsystem directory (e.g. `devplacepy/services/devii/`, `devplacepy/routers/projects/`, `devplacepy/database/`, `tests/`) has its own nested `CLAUDE.md` with that subsystem's full mechanic/pitfall/gotcha coverage, loaded automatically by Claude Code only when a file in that directory is read or edited. There is no `AGENTS.md` - it was removed and its content redistributed into the root file plus the nested files. **Treat the reappearance of a top-level `AGENTS.md`, or any doc/prose page referencing one, as an error to fix (delete the file / repoint the reference at the correct root-or-nested `CLAUDE.md`).**
Keep `CLAUDE.md`, `AGENTS.md`, `README.md`, and the `/docs` pages in exact agreement with the source, and keep role-based visibility consistent so admin material is shown to admins and hidden from members and guests at both the page and the section level.
DETECT:
- Every public or authenticated REST route has a `docs_api.endpoint()` entry in the correct group, with params and a `sample_response`. A documented route whose params drifted from the actual Form model is an error.
- Every prose page's factual claims match the code (routes, env vars, defaults, behavior). A stale claim is an error.
- `README.md` reflects current routes, env vars, dependencies, and user-visible features. Every nested `CLAUDE.md` has full coverage of its subsystem's mechanics/pitfalls, and the root `CLAUDE.md`'s "Subsystem map" table lists every nested `CLAUDE.md` that actually exists (no stale entry for one that was deleted, no missing entry for one that was added). Root `CLAUDE.md` changes only for a new cross-cutting architectural rule.
- No file references a top-level `AGENTS.md` (grep the repo, excluding `.venv/`, `*.bak`, `.git/`, and the `agents/` exclusion above). A hit is an error - repoint it at the root or the correct nested `CLAUDE.md`.
- `README.md` reflects current routes, env vars, dependencies, and user-visible features. `AGENTS.md` has a domain section for every mechanic. `CLAUDE.md` changes only for a new architectural rule.
- Page-level role gating: admin-only pages carry `"admin": True` in their `DOCS_PAGES` entry; the router filters the sidebar to `visible_pages` and 404s a non-admin requesting an admin page, while `docs_search` still indexes admin pages for admins. An admin page missing the flag, or a member page wrongly flagged admin, is an error.
- Section-level role gating: prose templates receive the user context via `docs_prose.render_prose` and gate admin sections with Jinja `{% if user %}` / `{% if user.role == 'admin' %}`. Unguarded admin material on a public page is an error.
FIX: add or repair the `endpoint()` entry, rewrite the stale prose, add the missing `README.md` section or nested `CLAUDE.md` section, repoint or delete a stray `AGENTS.md` reference, add the `"admin": True` flag, or wrap the leaking section in the correct Jinja guard. The source is authoritative; correct the docs to match the code, never the reverse.
FIX: add or repair the `endpoint()` entry, rewrite the stale prose, add the missing `README.md` / `AGENTS.md` section, add the `"admin": True` flag, or wrap the leaking section in the correct Jinja guard. The source is authoritative; correct the docs to match the code, never the reverse.
## Scope units
- **api-docs**: `devplacepy/docs_api.py` `endpoint()` coverage vs `routers/*.py` routes.
- **page-gating**: `devplacepy/routers/docs/pages.py` `DOCS_PAGES` admin flag; `visible_pages` filter; `docs_search` indexing.
- **section-gating**: `templates/docs/*.html` Jinja `{% if user.role == 'admin' %}` on admin sections.
- **readme**: `README.md` reflects current routes, env vars, dependencies, features.
- **claude-md-nested**: every nested `CLAUDE.md` has a domain section for every mechanic in its subsystem; root `CLAUDE.md` only for new cross-cutting rules; no stray `AGENTS.md` file or reference anywhere in the repo.
- **agents-md**: `AGENTS.md` has a domain section for every mechanic; `CLAUDE.md` only for new rules.
## Output
Return a markdown report: a one-line summary line, then one bullet per finding with severity (`error`/`warning`/`info`), `file:line`, the rule name, the message, and (in fix mode) whether it was fixed.
+1 -1
View File
@@ -45,7 +45,7 @@ DETECT, for each route:
- A `services/devii/actions/catalog.py` Action exists if the route is something a user could ask Devii to do.
- A `docs_api.py` entry exists for every public or authenticated endpoint.
- Public pages build `base_seo_context`.
- `README.md` and the relevant nested `CLAUDE.md` mention the feature.
- `README.md` and `AGENTS.md` mention the feature.
FIX: add the missing Form, add the missing key to the `*Out` schema, switch the handler to `respond`, or flag the responsible specialist's layer. When a layer is intentionally absent (an internal route with no public docs, a route Devii should never call), record an info finding with the rationale rather than fabricating the layer.
+7 -21
View File
@@ -1,7 +1,7 @@
---
name: feature-builder
description: Feature author and updater. Researches the task first (codebase, and the web for any external API, protocol, library, or spec), then creates a new DevPlace feature or extends an existing one coherently across the full fan-out (data layer, server, view, agent, docs, SEO, tests) so no connected layer is forgotten, and reports what must be restarted to go live. The constructive counterpart to the maintainer fleet - it writes the feature, the maintainers verify it. Use when adding a new route/capability or growing an existing one.
tools: Read, Grep, Glob, Edit, Write, Bash, WebSearch, WebFetch
description: Feature author and updater. Creates a new DevPlace feature or extends an existing one coherently across the full fan-out (data layer, server, view, agent, docs, SEO, tests) so no connected layer is forgotten. The constructive counterpart to the maintainer fleet - it writes the feature, the maintainers verify it. Use when adding a new route/capability or growing an existing one.
tools: Read, Grep, Glob, Edit, Write, Bash
model: inherit
color: blue
---
@@ -18,19 +18,12 @@ All application code is under `devplacepy/`: `devplacepy/routers/`, `devplacepy/
Default to **PLAN** mode. Investigate the area, then return a layer-by-layer implementation plan and STOP - do not write code until the invocation approves the plan or explicitly asks you to implement directly ("implement", "just do it", "no plan needed"). Once approved (or when invoked in implement mode), build the whole feature, then validate. Never run the test suite; never perform any git write operation.
## Operating protocol
1. **Understand before writing.** Read the router, template, matching tests, the relevant nested `CLAUDE.md` (each subsystem directory has its own, e.g. `devplacepy/services/devii/CLAUDE.md`) and the root `CLAUDE.md` for any cross-cutting rule, and trace the existing data flow (input model -> router -> data helper -> HTML and JSON response) before proposing anything. Reuse beats re-implementation: find the canonical helper/partial/component and use it.
1. **Understand before writing.** Read the router, template, matching tests, the relevant `CLAUDE.md`/`AGENTS.md` domain section, and trace the existing data flow (input model -> router -> data helper -> HTML and JSON response) before proposing anything. Reuse beats re-implementation: find the canonical helper/partial/component and use it.
2. Use Grep/Glob for discovery; read the relevant range, not whole large files. Never repeat a grep or re-read a file you already read.
3. Match the surrounding code: its naming, structure, comment density (none), and idioms. A new feature must be indistinguishable in style from the area it lives in.
4. Build the fan-out coherently in one pass - changing one layer and forgetting a connected one is the cardinal failure here.
5. Stay constructive and minimal. Touch only what the feature needs; do not refactor unrelated code (note an unrelated problem at most once and leave it). Respect "refactor only what you touch."
## Research the task before designing (codebase first, web when external)
Investigation is two passes, in order:
1. **Codebase pass (always).** Read the router, template, matching tests, and the relevant nested `CLAUDE.md` (plus the root `CLAUDE.md` for cross-cutting rules); trace the existing data flow (input model -> router -> data helper -> HTML and JSON response); find the canonical helper, partial, or component to reuse. Never design from assumption when the answer is in the repo.
2. **Web pass (whenever the feature touches anything outside this repo).** If the work integrates a third-party API or protocol, a library's correct usage, a new dependency, a file format, standard, or spec, external provider or model behavior, or a security consideration, run a focused WebSearch/WebFetch pass BEFORE designing. Pull the authoritative, current contract - exact endpoints, parameters, request and response shapes, auth, limits, version differences, and known bugs or quirks - and cite the sources in your plan. Prefer official docs and corroborate version-specific details. Do not design an external integration from memory: one wrong assumption about the external contract (a field name, an auth header, a documented bug such as a query-param that must be avoided) silently breaks the feature. Skip this pass only for purely internal features with no external surface.
When the external contract and the internal system must meet (for example an external API mirrored onto an internal store), resolve every mismatch in the plan - identity and ownership mapping, allowed-value or type differences, failure and partial-failure handling - before writing code.
## The fan-out (build every applicable layer; this is your core checklist)
A DevPlace feature is one data source fanning out into several consumers, all from the same handler. Ordered by data flow:
@@ -41,8 +34,8 @@ A DevPlace feature is one data source fanning out into several consumers, all fr
5. **View** - templates extend `base.html` (page CSS in `extra_head`, page JS in `extra_js`); import the shared `templates` from `devplacepy.templating`, never instantiate `Jinja2Templates`. Wrap every static asset URL in `static_url(...)`/`assetUrl(...)`. Reuse partials (`_avatar_link.html`, `_user_link.html`, `_sidebar_search.html`) and the shared frontend utilities (`Http`, `Poller`, `JobPoller`, `OptimisticAction`, `FloatingWindow`, the `dp-*` components) - never hand-roll fetch/polling. JS is ES6 modules, one class per file, on `app`. Dates are DD/MM/YYYY via `format_date`.
6. **Agent + docs (the most-forgotten layers)** - if a user could ask Devii to do it, add an `Action` in `services/devii/actions/catalog.py` with auth flags matched to the route guard (and a declared `confirm` boolean for any irreversible action added to `CONFIRM_REQUIRED`). Add a `docs_api.py` `endpoint()` entry (params + `sample_response`) for every public/auth endpoint; add a prose page to `routers/docs/pages.py` `DOCS_PAGES` when warranted. State-changing actions need an audit event (`events.md` key, `category_for`, recorder call at the mutation point).
7. **SEO** - public pages build `base_seo_context` and the right JSON-LD; add to `routers/seo.py` sitemap when indexable.
8. **Docs of record** - update `README.md` (product-facing) and the relevant nested `CLAUDE.md` (deep companion for the subsystem you touched - create one if the directory doesn't have one yet) for any new route/config/dependency/mechanic; update the root `CLAUDE.md` only when a NEW cross-cutting architectural rule or convention is introduced, and add a row to its "Subsystem map" table if you created a new nested `CLAUDE.md`.
9. **Tests (a hard project requirement, never optional)** - the DevPlace suite is one test file per endpoint, ~932 tests, split into three tiers with the directory tree mirroring the URL/source path. Every feature gets a test in EVERY tier it exercises: `tests/unit/` for a new data/query helper (pure in-process, `local_db` or no fixture, path mirrors the SOURCE module - `devplacepy/utils.py` -> `tests/unit/utils.py`); `tests/api/` for a new JSON or HTML route (HTTP integration against the live uvicorn subprocess via `app_server`/`seeded_db`, path mirrors the endpoint - `POST /auth/login` -> `tests/api/auth/login.py`) - but when a route depends on an in-process injected fake or a module-level singleton the separate uvicorn subprocess cannot see (the Gitea client via `runtime.set_client(fake)`, or any other `set_client`/monkeypatched backend), test it IN-PROCESS instead with `from starlette.testclient import TestClient; TestClient(m.app)`, the fake set in the test process, and auth via a `create_session(uid)` `session` cookie, asserting JSON with `Accept: application/json` (the `tests/api/issues/` files are the canonical example); `tests/e2e/` for a new interactive UI flow (Playwright `page`/`alice`/`bob`, path mirrors the endpoint - `GET /admin/ai-usage` -> `tests/e2e/admin/aiusage.py`). A route or feature with no test in any tier is incomplete. Follow the required patterns (`wait_until="domcontentloaded"` on every `goto`/`wait_for_url`, scoped selectors, `try/finally` restore of any flipped global setting, the shared fixtures, `test_`-prefixed functions in non-prefixed files, born-live `deleted_at`/`deleted_by` on raw soft-delete inserts) and create any missing package directories (`__init__.py`). WRITE them; validate each by a clean import only; NEVER run them.
8. **Docs of record** - update `README.md` (product-facing) and `AGENTS.md` (deep companion) for any new route/config/dependency/mechanic; update `CLAUDE.md` only when a NEW architectural rule or convention is introduced.
9. **Tests** - add tests in the correct tier and path (`tests/{unit,api,e2e}/<endpoint>.py`, directory tree mirrors the URL/source path) following the required Playwright patterns. WRITE them; NEVER run them.
When a layer is intentionally absent (an internal route with no public docs, a route Devii must never call), say so explicitly in the plan with the rationale rather than fabricating the layer.
@@ -56,15 +49,8 @@ When a layer is intentionally absent (an internal route with no public docs, a r
No comments or docstrings in source you author; full typing on every signature and variable; `pathlib` over `os`; dataclasses over fixed-key dicts; no magic numbers; no version pinning; no em-dashes anywhere (use a hyphen) in any file you touch. Keep `retoor <retoor@molodetz.nl>` as the first line (correct comment style for the language) of any NEW source file you create - never of the existing files you edit, and never inside a `.md` with YAML frontmatter.
## Validation (after implementing; never skip)
There is NO `hawk` or validator binary in this environment - validate each touched file directly, using the Python interpreter where `import devplacepy` resolves its dependencies (verify that first; the repo `.venv` may be incomplete). Then: confirm `python -c "from devplacepy.main import app"` imports clean; compile or parse every touched language (`python -m py_compile <files>` for Python, `node --check <file>` for JS, brace balance for CSS, tag and `{% %}`/`{{ }}` balance for templates); and grep every touched file for em-dashes - the character AND the entity forms `&mdash;`/`&#8212;`/`&#x2014;` - confirming none. For any new `*Out` schema, `model_validate` it against a representative context dict so a key mismatch surfaces now, not at request time. Do NOT run the test suite. Then hand off: name which maintainer dimensions are most relevant to the change (e.g. fanout, security, dry, docs, seo, audit, frontend, style, test) so the fleet can verify it.
## Live verification of UI/API changes (mandatory for visual work)
A structurally valid template can still render broken - `hawk` and the import check never open a browser. Per CLAUDE.md this project treats live verification as non-negotiable for any layout, styling, component, responsive, or backend change:
- Do not assume any verification CLI is installed (`hawk`, `mole`, `falcon`, `hound` are NOT present here); check with `command -v` first and fall back to the steps below or the project's `screenshot`/`serve`/`validate` skills when they exist.
- When your change touches `templates/` or `static/`, the rendered result MUST be visually verified: start the dev server (`make dev` in the background; confirm it is healthy on `http://localhost:10500`), capture each new/changed route with headless Playwright (`wait_until="domcontentloaded"`), and inspect the screenshot against the intended UI and the surrounding design system (tokens, spacing, responsiveness). Tear down any server you started.
- When your change touches `routers/`, verify the endpoints over HTTP against the live server (an api-spec runner if available, otherwise `curl`/`httpx` asserting the status and a body fragment).
- The `/feature` workflow performs this live `Verify` phase for you automatically; when you are invoked standalone for UI/API work, perform it yourself before declaring the work complete, or explicitly state it is the caller's responsibility and name the routes to check.
Run `hawk .` and confirm zero errors. Confirm `python -c "from devplacepy.main import app"` imports clean. Grep your touched files for em-dashes and confirm none. Do NOT run the test suite. Then hand off: name which maintainer dimensions are most relevant to the change (e.g. fanout, security, dry, docs, seo, audit, frontend, style, test) so the fleet can verify it.
## Output
- In PLAN mode: a short situation summary of the area, then the ordered layer-by-layer plan (each layer: what file, what change, or "n/a - rationale"), then the list of maintainer dimensions that will need to verify it. End by asking for approval to implement.
- In IMPLEMENT mode: a concise summary of what was built per layer (`file:line` references), the validation results (import, per-language compile/parse, em-dash scan, schema model-validate), the recommended maintainer hand-off, and a DEPLOYMENT NOTE whenever you added or changed a DB column or any Python module - production runs a long-lived uvicorn with no `--reload`, so the change is NOT live until the server is restarted/rebuilt (`make docker-bup`), and a new queried column needs that restart for `init_db()` to create it (templates and CSS auto-reload, but boot-versioned static assets need the restart to bust cache). State this so the caller restarts rather than assuming the edit is live.
- In IMPLEMENT mode: a concise summary of what was built per layer (`file:line` references), the validation results (`hawk`, import, em-dash scan), and the recommended maintainer hand-off.
+2 -2
View File
@@ -1,6 +1,6 @@
---
name: style-maintainer
description: Coding-rule compliance. Enforces the explicit CLAUDE.md (root and nested per-subsystem) coding rules across all source - forbidden naming (context-aware), no comments/docstrings, em-dash (context-aware), full typing, pathlib over os, dataclasses over fixed-key dicts, no version pinning, file headers, no magic numbers. Use for style/convention review. Most surface name/em-dash hits are false positives - run the decision algorithm.
description: Coding-rule compliance. Enforces the explicit CLAUDE.md and AGENTS.md coding rules across all source - forbidden naming (context-aware), no comments/docstrings, em-dash (context-aware), full typing, pathlib over os, dataclasses over fixed-key dicts, no version pinning, file headers, no magic numbers. Use for style/convention review. Most surface name/em-dash hits are false positives - run the decision algorithm.
tools: Read, Grep, Glob, Edit, Write, Bash
model: inherit
color: orange
@@ -36,7 +36,7 @@ Default to **REPORT** mode: record findings, do NOT modify files. Apply **FIX**
No comments or docstrings in source you author; no em-dashes (use a hyphen); keep `retoor <retoor@molodetz.nl>` as the first line of any file you create.
## Your dimension
Enforce the explicit CLAUDE.md (root plus every nested per-subsystem `CLAUDE.md`) coding rules across all source.
Enforce the explicit CLAUDE.md and AGENTS.md coding rules across all source.
### Forbidden naming prefixes and suffixes (CONTEXT-AWARE)
The banned tokens are `_new`, `_old`, `_current`, `_prev`, `_next` (outside iteration), `_temp`, `_tmp`, `_v1`/`_v2`/`_v3`, `better_`, `best_`, `simple_`, `my_`, `the_`, `_data`, `_info`, and the rest of the forbidden list. This rule targets LAZY, RENAMEABLE VARIABLE AND HELPER names you own. It is NOT a blind substring sweep, and most surface hits on `_data`/`_info`/`_item`/`_val` are FALSE POSITIVES. Run this decision algorithm for EVERY candidate before recording it, and skip it the moment any test fails:
+5 -13
View File
@@ -36,23 +36,15 @@ Default to **REPORT** mode: record coverage gaps and pattern violations, do NOT
No comments or docstrings in source you author; no em-dashes (use a hyphen); keep `retoor <retoor@molodetz.nl>` as the first line of any file you create.
## Your dimension
Keep integration-test coverage in step with the routes and features. The DevPlace suite is a hard project standard, not a nicety: one test file per endpoint, ~932 tests, split into three tiers with the directory tree mirroring the URL/source path. A route or feature that exercises a tier with no test in it is a coverage gap.
Keep integration-test coverage in step with the routes and features, writing tests that follow the project's required patterns.
The three tiers and which one a change belongs to (decided by what it exercises, mirroring the existing files):
- **`tests/unit/`** - pure in-process tests of library functions (`local_db` or no fixture); the path mirrors the SOURCE module (`devplacepy/utils.py` -> `tests/unit/utils.py`, `devplacepy/services/audit/store.py` -> `tests/unit/services/audit/store.py`). The right tier for a new data/query/serialization helper.
- **`tests/api/`** - HTTP integration tests against the live uvicorn subprocess (`app_server`/`seeded_db`, `requests`/`httpx` vs `BASE_URL`, no browser); the path mirrors the endpoint (`POST /auth/login` -> `tests/api/auth/login.py`). The right tier for a JSON or HTML route, auth/role gating, and Devii actions.
- **`tests/e2e/`** - Playwright browser tests (`page`/`alice`/`bob`); the path mirrors the endpoint (`GET /admin/ai-usage` -> `tests/e2e/admin/aiusage.py`). The right tier for an interactive UI flow. The project prefers the interface/API tiers over unit where either fits.
DETECT: routes and features with no corresponding test under `tests/{api,e2e,unit}/<endpoint-path>.py` (per the directory-mirrors-path naming rule). The project prefers integration tests over unit tests and tests the interface and API.
A feature that adds a data helper AND a JSON route AND a UI flow needs a test in all three tiers. Choose the tier(s) by what the change actually touches; never leave a new route or helper untested.
DETECT: routes, features, data helpers, and Devii actions with no corresponding test in the tier(s) they exercise under `tests/{unit,api,e2e}/<path>.py` (per the directory-mirrors-path naming rule). A collection path that also parents deeper paths uses `index.py` in its own directory.
FIX: write the missing test in the correct tier, creating any missing package directories (`__init__.py`), following the required patterns: every `page.goto` and `page.wait_for_url` passes `wait_until="domcontentloaded"`; selectors are scoped; a test that flips a global `site_settings` value restores it in `try/finally`; the shared fixtures (`alice`, `bob`, `app_server`, `seeded_db`) are used; test functions are `test_`-prefixed though files are not; a raw insert into a `SOFT_DELETE_TABLES` table sets `deleted_at`/`deleted_by`; a test that mutates a cross-process cached value (settings/roles) polls the endpoint rather than asserting immediately.
FIX: write the missing integration test following the required patterns: every `page.goto` and `page.wait_for_url` passes `wait_until="domcontentloaded"`; selectors are scoped; a test that flips a global `site_settings` value restores it in `try/finally`; the shared fixtures (`alice`, `bob`, `app_server`) are used; test functions are `test_`-prefixed though files are not.
## Scope units
- **coverage-gaps**: `routers/*.py` routes, `database.py`/service data helpers, and `services/devii/actions/catalog.py` actions with no referencing test in the tier(s) they exercise under `tests/{unit,api,e2e}/`.
- **tier-fit**: a feature exercising a tier (a UI flow with only an api test, a data helper with no unit test) where that tier's test is missing.
- **pattern-lint**: `tests/*.py` use `domcontentloaded`, scoped selectors, try/finally global restore, shared fixtures, born-live soft-delete inserts.
- **coverage-gaps**: `routers/*.py` routes with no referencing test under `tests/{api,e2e,unit}/`.
- **pattern-lint**: `tests/*.py` use `domcontentloaded`, scoped selectors, try/finally global restore, shared fixtures.
## Output
Return a markdown report: a one-line summary line, then one bullet per finding with severity (`error`/`warning`/`info`), `file:line`, the rule name, the message, and (in fix mode) whether the test was written.
-34
View File
@@ -1,34 +0,0 @@
---
description: WCAG 2.2 AA+ accessibility specialist - audit and upgrade the entire site for blind users with semantic HTML and ARIA, section by section.
allowed-tools: Read, Grep, Glob, Edit, Write, Bash
---
You are now an expert WCAG 2.2 AA+ accessibility specialist with deep screen reader experience (NVDA, JAWS, VoiceOver, TalkBack). Your task is to upgrade the ENTIRE website for blind users using proper ARIA attributes, semantic HTML, and best practices. Do this comprehensively and leave nothing out.
Project rules:
- Audit and improve EVERY page, component, modal, dynamic element, form, navigation, interactive widget, data table, tab system, accordion, carousel, live region, etc.
- Prioritize semantic HTML first (proper <nav>, <main>, <section>, <article>, <button>, <header>, etc.), then enhance with ARIA where needed.
- Apply ARIA roles, states, properties, and relationships rigorously: aria-label, aria-labelledby, aria-describedby, aria-expanded, aria-hidden, aria-live, aria-atomic, aria-relevant, aria-controls, aria-current, aria-haspopup, aria-modal, role="dialog", role="alertdialog", role="tabpanel", role="tablist", role="tab", role="menuitem", role="tree", role="grid", etc.
- Make all interactive elements fully keyboard accessible and announceable.
- Handle dynamic content (JavaScript-updated sections, infinite scroll, single-page app behavior, React/Vue/Svelte/Angular/Alpine/etc. components) with proper live regions and ARIA updates.
- Ensure landmark regions are correctly defined and unique.
- Fix color contrast, focus management, focus traps, skip links, and screen reader-only content where relevant.
- Provide both the updated code and clear before/after explanations for every major change.
Workflow you MUST follow:
1. Ask me for the full codebase structure (or the specific files/folders I want processed first). I will provide HTML, JSX, TSX, templates, CSS, or component code.
2. Process the site systematically: start with global layout (header, nav, footer, main), then all major pages/sections, then all reusable components.
3. For each file or component you receive, output:
- A summary of accessibility issues found.
- The complete rewritten/improved code with all ARIA added.
- Detailed comments explaining every ARIA addition.
- Any additional recommendations (e.g., CSS for focus styles, JavaScript patterns for dynamic ARIA).
4. After finishing a section, ask for the next part until the entire site is covered. Do not stop until I confirm the whole site is done.
Strict requirements:
- Never use ARIA when native HTML elements already provide the semantics.
- Follow ARIA Authoring Practices Guide (APG) strictly.
- Ensure the site remains fully functional and visually unchanged unless accessibility requires minor tweaks.
- Aim for WCAG 2.2 Level AA compliance or better, with extra care for Level AAA where feasible for blind users.
- Think like a blind power user: every action, state change, and piece of information must be perfectly announced and navigable.
Start by asking for the entry point (e.g. index.html, main layout file, or the list of main pages/components). Then proceed file-by-file or section-by-section until the entire site is upgraded. Be extremely thorough - literally upgrade the whole site.
+3 -3
View File
@@ -1,5 +1,5 @@
---
description: Explain a DevPlace subsystem, route, or file - read the relevant nested CLAUDE.md and the code, then summarize architecture, data flow, invariants, and entry points. Read-only.
description: Explain a DevPlace subsystem, route, or file - read the relevant AGENTS.md section and the code, then summarize architecture, data flow, invariants, and entry points. Read-only.
argument-hint: <area, route, or file>
allowed-tools: Read, Grep, Glob, Bash(git log:*)
---
@@ -8,13 +8,13 @@ Orient me on: **$ARGUMENTS**
Investigate before explaining; confirm every claim against the source.
1. Locate the code: the router under `devplacepy/routers/`, the template under `devplacepy/templates/`, data helpers in `devplacepy/database.py`, schemas in `devplacepy/schemas.py`, and any service under `devplacepy/services/`.
2. Read the matching nested `CLAUDE.md` for the subsystem (e.g. `devplacepy/services/devii/CLAUDE.md`), plus the relevant cross-cutting part of the root `CLAUDE.md`.
2. Read the matching domain section in `AGENTS.md` (the long-form companion) and the relevant part of `CLAUDE.md`.
3. Trace the data flow: input model (`models.py`) -> router handler + guard -> data helper -> response (HTML via `respond` + template, JSON via the `*Out` schema), plus the Devii action (`catalog.py`) and API docs (`docs_api.py`) where present.
Then give a tight explanation:
- What it does and where it lives, with `file:line` references.
- The request pipeline and data flow.
- Key invariants and gotchas (pull these from the nested CLAUDE.md).
- Key invariants and gotchas (pull these from AGENTS.md).
- The fan-out: which of the nine feature layers exist for it.
Do not modify anything.
+5 -7
View File
@@ -1,14 +1,14 @@
---
description: Run the DevPlace maintenance agent fleet (12 quality dimensions) in check or fix mode, optionally scoped to changed files or a subset.
description: Run the DevPlace maintenance agent fleet (10 quality dimensions) in check or fix mode, optionally scoped to changed files or a subset.
argument-hint: "[check|fix] [changed] [comma,list,of,dimensions]"
---
You are orchestrating the DevPlace maintenance fleet. Each dimension is a project subagent under `.claude/agents/`. The fleet enforces twelve independent quality dimensions across the `devplacepy/` package and `tests/`.
You are orchestrating the DevPlace maintenance fleet. Each dimension is a project subagent under `.claude/agents/`. The fleet enforces ten independent quality dimensions across the `devplacepy/` package and `tests/`.
## Dimension to subagent map
| Dimension | Subagent | Enforces |
|-----------|----------|----------|
| style | `style-maintainer` | CLAUDE.md (root/nested) coding rules (context-aware names, em-dash, typing, pathlib, headers) |
| style | `style-maintainer` | CLAUDE.md/AGENTS.md coding rules (context-aware names, em-dash, typing, pathlib, headers) |
| dry | `dry-maintainer` | duplication and reuse of canonical shared utilities |
| security | `security-maintainer` | auth guards, project visibility, read-only guards, input validation, XSS |
| audit | `audit-maintainer` | audit-log coverage and event catalogue |
@@ -18,17 +18,15 @@ You are orchestrating the DevPlace maintenance fleet. Each dimension is a projec
| fanout | `fanout-maintainer` | cross-layer feature completeness |
| docs | `docs-maintainer` | docs coverage and role-aware show/hide |
| test | `test-maintainer` | integration-test coverage |
| background | `background-maintainer` | background-queue deferral, response-critical/inline boundaries |
| locust | `locust-maintainer` | locustfile.py route coverage and load-test safety |
The canonical run order is: **style, dry, security, audit, devii, seo, frontend, fanout, docs, test, background, locust**.
The canonical run order is: **style, dry, security, audit, devii, seo, frontend, fanout, docs, test**.
## Parse the arguments
Arguments: `$ARGUMENTS`
- **Mode**: `fix` anywhere in the arguments means FIX mode; otherwise default to CHECK mode (read-only report).
- **changed**: the word `changed` means scope the run to only the files git reports as modified or new under `devplacepy/` and `tests/`. Compute that set first with `git status --porcelain` and keep existing paths whose first segment is `devplacepy/` or `tests/`. If the set is empty, report "nothing to do" and stop. Pass the explicit file list into each subagent's prompt so it reports/fixes only within that set (it may still read other files for cross-reference).
- **Subset**: any comma-separated dimension names (e.g. `security,docs`) restrict the run to those dimensions in canonical order. With no subset, run all twelve.
- **Subset**: any comma-separated dimension names (e.g. `security,docs`) restrict the run to those dimensions in canonical order. With no subset, run all ten.
## Execute
1. Resolve the dimension list and mode from the arguments above.
+1 -1
View File
@@ -12,5 +12,5 @@ Mirror an existing service - read `devplacepy/services/base.py` (BaseService) an
3. Register it in `main.py` startup: `service_manager.register(YourService())`, under the same `DEVPLACE_DISABLE_SERVICES` guard as the others. It then auto-appears on `/admin/services`.
4. If it calls an LLM, default its endpoint to `config.INTERNAL_GATEWAY_URL` and authenticate with the internal gateway key, like the other AI consumers.
5. Emit audit events via `record_system` for any state change it makes.
6. Document it in `devplacepy/services/CLAUDE.md` (Background services base machinery section, or the service's own nested `CLAUDE.md` if it has one) and in `README.md` if user-visible.
6. Document it in `AGENTS.md` (Background services section) and in `README.md` if user-visible.
7. Validate with `hawk` on the touched files and `python -c "from devplacepy.main import app"`.
+4 -12
View File
@@ -1,13 +1,12 @@
// retoor <retoor@molodetz.nl>
export const meta = {
name: 'devii-tool',
description: 'Add a Devii agent capability: an Action in the catalog with auth flags matched to the route guard, dispatcher wiring, API docs, then verify role-gating and confirmation and write the api-tier integration test for the action',
description: 'Add a Devii agent capability: an Action in the catalog with auth flags matched to the route guard, dispatcher wiring, API docs, and a test, then verify role-gating and confirmation',
phases: [
{ title: 'Understand', detail: 'find the underlying route and a similar Action to mirror' },
{ title: 'Implement', detail: 'add the Action, wire the handler, document it' },
{ title: 'Verify', detail: 'role-gating, flag alignment, and confirm gating' },
{ title: 'Fix', detail: 'close gaps from the review' },
{ title: 'Test', detail: 'write the api-tier integration test for the action (visibility, auth gating, confirm)' },
{ title: 'Fix', detail: 'close gaps and write the action test' },
],
}
@@ -21,13 +20,6 @@ const RULES = [
'- Validate with "hawk ." and "python -c \\"from devplacepy.main import app\\"". NEVER run the test suite. Never perform any git write.',
].join('\n')
const TESTS = [
'DevPlace test standard (a hard project requirement - one test file per endpoint, the directory tree mirroring the URL path):',
'A Devii tool is reached over the same HTTP surface a user hits, so its test lives in tests/api/ (often tests/api/devii/), against the live uvicorn subprocess (app_server/seeded_db, requests/httpx vs BASE_URL).',
'Cover the role-gating that is the whole point of the tool: an unauthenticated/guest caller is refused, a member sees and can call a requires_auth tool but is refused a requires_admin one (and its schema is withheld), an admin can call it, and a destructive action is refused without confirm=true and proceeds with it.',
'Required patterns: scoped assertions; try/finally restore of any flipped global setting; the shared fixtures (alice, bob, app_server); test FUNCTIONS are test_-prefixed though files are not. Validate by a clean import only. NEVER run the suite.',
].join('\n')
function toolBrief() {
if (!args) return ''
if (typeof args === 'string') return args
@@ -133,8 +125,8 @@ if (gaps.length) {
}
const test = await agent(
`Operate in FIX mode. Write the integration test for this Devii tool following the required patterns (tests/api/devii layout), asserting the role-gating and confirm behavior described below. The tool is not complete until its gating is tested. Create any missing package directories the test path needs. Validate by a clean import only. NEVER run the suite.\n\n${TESTS}\n\nTool request: ${ask}\nAction: ${build && build.actionName}\nFiles changed:\n${changed.join('\n')}\n\nReturn the test file written and the gating cases it covers.`,
{ agentType: 'test-maintainer', label: 'test', phase: 'Test' }
`Operate in FIX mode. Write the integration test for this Devii tool following the required patterns (tests/api/devii layout). Validate by a clean import only. NEVER run the suite.\n\nTool request: ${ask}\nAction: ${build && build.actionName}\nFiles changed:\n${changed.join('\n')}`,
{ agentType: 'test-maintainer', label: 'test', phase: 'Fix' }
)
return { ask, map, build, audit: gaps, gapFix, test }
+4 -13
View File
@@ -1,13 +1,12 @@
// retoor <retoor@molodetz.nl>
export const meta = {
name: 'endpoint',
description: 'Scaffold ONE new DevPlace route across all of its touchpoints (Form model, Out schema, guarded handler with respond, main.py mount, template, Devii action, API docs, SEO) and verify it, then write its integration test in the matching tier (api for JSON/HTML, e2e for an interactive UI flow)',
description: 'Scaffold ONE new DevPlace route across all of its touchpoints (Form model, Out schema, guarded handler with respond, main.py mount, template, Devii action, API docs, SEO, test) and verify it',
phases: [
{ title: 'Understand', detail: 'find the closest existing route to mirror' },
{ title: 'Implement', detail: 'wire the route across every touchpoint' },
{ title: 'Verify', detail: 'completeness and security review of the new route' },
{ title: 'Fix', detail: 'close gaps from the review' },
{ title: 'Test', detail: 'write the route test in the matching tier (api or e2e), mirroring the path' },
{ title: 'Fix', detail: 'close gaps and write the route test' },
],
}
@@ -32,14 +31,6 @@ const TOUCHPOINTS = [
'8. seo.py - base_seo_context for a public page; sitemap entry if indexable.',
].join('\n')
const TESTS = [
'DevPlace test standard (a hard project requirement - one test file per endpoint, the directory tree mirroring the URL path):',
'- tests/api/ - HTTP integration test against the live uvicorn subprocess (app_server/seeded_db, requests/httpx vs BASE_URL) - the right tier for a JSON or HTML route.',
'- tests/e2e/ - Playwright browser test (page/alice/bob) - the right tier for an interactive UI flow.',
'The route path maps to the test path by dropping {param} segments and lowercasing each segment (POST /auth/login -> tests/api/auth/login.py; GET /admin/ai-usage -> tests/e2e/admin/aiusage.py). A collection path that also parents deeper paths uses index.py in its own directory. Create any missing package directories with __init__.py.',
'Required patterns: wait_until="domcontentloaded" on every goto/wait_for_url; scoped selectors; try/finally restore of any flipped global setting; the shared fixtures; test FUNCTIONS are test_-prefixed though files are not. Validate by a clean import only. NEVER run the suite.',
].join('\n')
function endpointBrief() {
if (!args) return ''
if (typeof args === 'string') return args
@@ -141,8 +132,8 @@ if (gaps.length) {
}
const test = await agent(
`Operate in FIX mode. Write the integration test for this new route in the matching tier (api for a JSON/HTML route, e2e for an interactive UI flow) following the required patterns and the directory-mirrors-path layout. The route is not complete until it has a test. Create any missing package directories the test path needs. Validate by a clean import only. NEVER run the suite.\n\n${TESTS}\n\nEndpoint: ${ask}\nFiles changed:\n${changed.join('\n')}\n\nReturn the test file written and its tier.`,
{ agentType: 'test-maintainer', label: 'test', phase: 'Test' }
`Operate in FIX mode. Write the integration test for this new route following the required patterns and the directory-mirrors-path layout. Validate by a clean import only. NEVER run the suite.\n\nEndpoint: ${ask}\nFiles changed:\n${changed.join('\n')}`,
{ agentType: 'test-maintainer', label: 'test', phase: 'Fix' }
)
return { ask, map, build, audit: gaps, gapFix, test }
+28 -150
View File
@@ -1,15 +1,13 @@
// retoor <retoor@molodetz.nl>
export const meta = {
name: 'feature',
description: 'Add a feature across the full DevPlace fan-out: understand the area, plan the layers, build via the feature-builder agent, audit every quality dimension with adversarial verification, verify live in the browser and over HTTP, close gaps, then write the integration tests across every applicable tier (unit, api, e2e)',
description: 'Add a feature across the full DevPlace fan-out: understand the area, plan the layers, implement coherently in the repo, audit completeness and security, fix gaps and write tests, then verify',
phases: [
{ title: 'Understand', detail: 'map the target area and a similar existing feature' },
{ title: 'Plan', detail: 'a per-layer implementation plan across the nine touchpoints' },
{ title: 'Implement', detail: 'build all layers coherently via the feature-builder agent' },
{ title: 'Audit', detail: 'every relevant quality dimension, each finding adversarially verified against source' },
{ title: 'Verify', detail: 'live dev-server visual (falcon) and API (hound) verification of the change' },
{ title: 'Fix', detail: 'close confirmed gaps from the audit and live verification' },
{ title: 'Test', detail: 'write integration tests across every applicable tier (unit, api, e2e), one file per endpoint mirroring the path' },
{ title: 'Implement', detail: 'build all layers coherently in the repo' },
{ title: 'Audit', detail: 'completeness and security review of the changed files' },
{ title: 'Fix', detail: 'close audit gaps and write missing integration tests' },
],
}
@@ -19,10 +17,10 @@ const RULES = [
'- First line of any NEW file is the header: Python "# retoor <retoor@molodetz.nl>", JS "// retoor <retoor@molodetz.nl>", CSS "/* retoor <retoor@molodetz.nl> */".',
'- No em-dash characters; use a hyphen. Source is English only.',
'- Full type hints on Python signatures and variables; pathlib over os; Pydantic Form input with explicit max lengths; sanitize and bound all user input.',
'- Reuse shared helpers: templating.templates (never a per-router Jinja2Templates), database.py batch helpers (no inline N+1), the respond() negotiator, _avatar_link.html / _user_link.html, and on the frontend Http / Poller / JobPoller / OptimisticAction / FloatingWindow and the dp-* components.',
'- Reuse shared helpers: templating.templates (never a per-router Jinja2Templates), database.py batch helpers (no inline N+1), the respond() negotiator, _avatar_link.html / _user_link.html, and on the frontend Http / Poller / JobPoller / OptimisticAction / FloatingWindow.',
'- Auth guards: get_current_user (public read), require_user (member write), require_admin (admin). Every POST/PUT/DELETE is guarded; deletes are soft and owner-or-admin.',
'- Never pass a respond() context key that collides with a Jinja global (use viewer_is_admin, not is_admin). Dates are DD/MM/YYYY via format_date.',
'- Validate with "hawk ." and "python -c \\"from devplacepy.main import app\\"". NEVER run the pytest suite. Never perform any git write.',
'- Validate with "hawk ." and "python -c \\"from devplacepy.main import app\\"". NEVER run the test suite. Never perform any git write.',
].join('\n')
const FANOUT = [
@@ -35,17 +33,7 @@ const FANOUT = [
'6. services/devii/actions/catalog.py - an Action(name, method, path, summary, params, requires_auth, requires_admin) if a user could ask Devii to do it; a confirm param plus membership in CONFIRM_REQUIRED if destructive.',
'7. docs_api.py - an endpoint() entry in the right group with params and sample_response for every public or authenticated route.',
'8. seo.py - base_seo_context(request, ...) merged into the context for public pages; a sitemap entry in routers/seo.py if indexable.',
'9. README.md (product) + the relevant nested CLAUDE.md (mechanics) + the root CLAUDE.md (only for a genuinely new architectural rule).',
].join('\n')
const TESTS = [
'DevPlace test standard (a hard project requirement, NOT optional - the suite is one test file per endpoint, ~932 tests, with the directory tree mirroring the URL/source path):',
'- tests/unit/ - pure in-process tests of library functions (local_db or no fixture); the path mirrors the SOURCE module (devplacepy.utils -> tests/unit/utils.py).',
'- tests/api/ - HTTP integration tests against the live uvicorn subprocess (app_server/seeded_db, requests/httpx vs BASE_URL, no browser); the path mirrors the endpoint (POST /auth/login -> tests/api/auth/login.py).',
'- tests/e2e/ - Playwright browser tests (page/alice/bob); the path mirrors the endpoint (GET /admin/ai-usage -> tests/e2e/admin/aiusage.py).',
'A feature MUST get every tier it exercises: a new data/query helper -> a unit test; a new JSON or HTML route -> an api test; a new interactive UI flow -> an e2e test. Pick tiers by what the change actually touches; never ship a route or feature with no test in any tier.',
'Required patterns: every page.goto/page.wait_for_url passes wait_until="domcontentloaded"; selectors are scoped; a test that flips a global site_settings value restores it in try/finally; reuse the shared fixtures (alice, bob, app_server, seeded_db); test FUNCTIONS are test_-prefixed though files are not; raw inserts into a soft-delete table set deleted_at/deleted_by.',
'Validate each new test module by a clean import only (python -c "import ..." or python -m py_compile). NEVER run the suite, not the full suite and not one file - that is the human-only /test path.',
'9. README.md (product) + AGENTS.md (mechanics) + CLAUDE.md (only for a genuinely new architectural rule).',
].join('\n')
function featureBrief() {
@@ -93,7 +81,6 @@ const PLAN_SCHEMA = {
},
},
},
routes: { type: 'array', items: { type: 'string' } },
outOfScope: { type: 'array', items: { type: 'string' } },
},
}
@@ -107,7 +94,6 @@ const BUILD_SCHEMA = {
filesChanged: { type: 'array', items: { type: 'string' } },
validatorPassed: { type: 'boolean' },
importOk: { type: 'boolean' },
routes: { type: 'array', items: { type: 'string' } },
notes: { type: 'string' },
},
}
@@ -136,61 +122,24 @@ const FINDINGS_SCHEMA = {
},
}
const VERDICT_SCHEMA = {
type: 'object',
additionalProperties: false,
required: ['isReal', 'reason'],
properties: {
isReal: { type: 'boolean' },
reason: { type: 'string' },
severity: { type: 'string', enum: ['error', 'warning', 'info'] },
},
}
const LIVE_SCHEMA = {
type: 'object',
additionalProperties: false,
required: ['ran', 'summary'],
properties: {
ran: { type: 'boolean' },
summary: { type: 'string' },
pagesChecked: { type: 'array', items: { type: 'string' } },
apiChecked: { type: 'array', items: { type: 'string' } },
issues: {
type: 'array',
items: {
type: 'object',
additionalProperties: false,
required: ['severity', 'where', 'message'],
properties: {
severity: { type: 'string', enum: ['error', 'warning', 'info'] },
where: { type: 'string' },
message: { type: 'string' },
},
},
},
},
}
log(`Feature: ${ask}`)
const map = await agent(
`Map the area of the DevPlace codebase relevant to this feature request, so it can be implemented. Read the closest existing feature end to end (its router, template, tests, and the matching nested CLAUDE.md) as the pattern to follow. Do not write anything.\n\nFeature request: ${ask}\n\n${FANOUT}\n\nReturn: a summary of how this should be built, the concrete files to touch or create, the most similar existing feature to mirror, and any constraints.`,
`Map the area of the DevPlace codebase relevant to this feature request, so it can be implemented. Read the closest existing feature end to end (its router, template, tests, and AGENTS.md section) as the pattern to follow. Do not write anything.\n\nFeature request: ${ask}\n\n${FANOUT}\n\nReturn: a summary of how this should be built, the concrete files to touch or create, the most similar existing feature to mirror, and any constraints.`,
{ agentType: 'Explore', label: 'understand', phase: 'Understand', schema: MAP_SCHEMA }
)
const plan = await agent(
`Produce a precise, per-layer implementation plan for this DevPlace feature. One step per file with the exact touchpoint to add or change. List the user-facing routes (URL paths) the feature adds or changes in "routes". Mark layers that are intentionally not needed as outOfScope with a reason. Do not write code.\n\nFeature request: ${ask}\n\nArea map:\n${JSON.stringify(map, null, 2)}\n\n${FANOUT}`,
`Produce a precise, per-layer implementation plan for this DevPlace feature. One step per file with the exact touchpoint to add or change. Mark layers that are intentionally not needed as outOfScope with a reason. Do not write code.\n\nFeature request: ${ask}\n\nArea map:\n${JSON.stringify(map, null, 2)}\n\n${FANOUT}`,
{ agentType: 'Plan', label: 'plan', phase: 'Plan', schema: PLAN_SCHEMA }
)
const build = await agent(
`Implement directly - no plan, no approval needed, this is implement mode. Build this DevPlace feature coherently and completely, editing files in the repo, following the plan. Keep every layer in agreement (the *Out schema must carry every JSON key the handler returns; the Devii action auth flags must match the route guard; a respond() context key must never shadow a Jinja global). Do NOT write pytest tests in this step (a later phase owns that). When done, run "hawk ." and "python -c \\"from devplacepy.main import app\\"" and report whether each passed, and list the user-facing routes the feature exposes.\n\nFeature request: ${ask}\n\nPlan:\n${JSON.stringify(plan, null, 2)}\n\n${FANOUT}\n\n${RULES}\n\nReturn the list of files you changed or created, whether the validator and the import passed, the routes, and a short summary.`,
{ agentType: 'feature-builder', label: 'implement', phase: 'Implement', schema: BUILD_SCHEMA }
`Implement this DevPlace feature coherently and completely, editing files directly in the repo. Follow the plan; keep every layer in agreement (the *Out schema must carry every JSON key the handler returns; the Devii action auth flags must match the route guard). When done, run "hawk ." and "python -c \\"from devplacepy.main import app\\"" and report whether each passed. Do not write tests in this step. Do not run the test suite. Do not commit.\n\nFeature request: ${ask}\n\nPlan:\n${JSON.stringify(plan, null, 2)}\n\n${FANOUT}\n\n${RULES}\n\nReturn the list of files you changed or created, whether the validator and the import passed, and a short summary.`,
{ label: 'implement', phase: 'Implement', schema: BUILD_SCHEMA }
)
const changed = (build && build.filesChanged) || []
const routes = (build && build.routes && build.routes.length ? build.routes : (plan && plan.routes) || [])
const scopeNote = changed.length
? `\n\nRestrict your findings to these changed files (read others only for cross-reference):\n${changed.join('\n')}`
: ''
@@ -198,107 +147,36 @@ const scopeNote = changed.length
const AUDITORS = [
{ key: 'fanout', agent: 'fanout-maintainer' },
{ key: 'security', agent: 'security-maintainer' },
{ key: 'style', agent: 'style-maintainer' },
{ key: 'dry', agent: 'dry-maintainer' },
{ key: 'frontend', agent: 'frontend-maintainer' },
{ key: 'seo', agent: 'seo-maintainer' },
{ key: 'audit', agent: 'audit-maintainer' },
{ key: 'devii', agent: 'devii-maintainer' },
{ key: 'docs', agent: 'docs-maintainer' },
]
function verifyPrompt(dimension, finding) {
return (
`Adversarially verify a candidate "${dimension}" finding against the just-built feature. Your goal is to REFUTE it. ` +
`Open the exact file and read enough surrounding context (the whole function, the caller, the contract) to judge intent. ` +
`It is REAL only if it survives refutation as a genuine violation of the ${dimension} dimension introduced by this change. ` +
`Rule it out (isReal=false) if it is a contract identifier, DATA rather than authored prose, generated/vendored/third-party, ` +
`pre-existing and untouched by this feature, or already correct under a known exemption. When uncertain, default to isReal=false.\n\n` +
`Candidate finding:\n- file: ${finding.file}\n- line: ${finding.line == null ? 'unspecified' : finding.line}\n` +
`- severity: ${finding.severity}\n- rule: ${finding.rule}\n- message: ${finding.message}\n\nReturn isReal and a one-line reason.`
)
}
const reviewed = await pipeline(
AUDITORS,
(auditor) =>
const audits = await parallel(
AUDITORS.map((a) => () =>
agent(
`Operate in REPORT mode (read-only). Do not modify any file. Audit the just-implemented feature for your single quality dimension, following your mandate and accuracy doctrine. Confirm each candidate against the actual source before recording it.${scopeNote}\n\nFeature request: ${ask}`,
{ agentType: auditor.agent, label: `audit:${auditor.key}`, phase: 'Audit', schema: FINDINGS_SCHEMA }
),
(review, auditor) =>
parallel(
((review && review.findings) || []).map((finding) => () =>
agent(verifyPrompt(auditor.key, finding), {
agentType: auditor.agent,
label: `verify:${auditor.key}`,
phase: 'Audit',
schema: VERDICT_SCHEMA,
}).then((verdict) => ({ ...finding, dimension: auditor.key, verdict }))
)
)
`Operate in REPORT mode (read-only). Audit the just-implemented feature for your single dimension. Confirm each finding against the source.${scopeNote}\n\nFeature request: ${ask}`,
{ agentType: a.agent, label: `audit:${a.key}`, phase: 'Audit', schema: FINDINGS_SCHEMA }
).then((r) => ({ key: a.key, findings: (r && r.findings) || [] }))
)
)
const auditCandidates = reviewed.flat().filter(Boolean)
const auditConfirmed = auditCandidates.filter((f) => f.verdict && f.verdict.isReal)
log(`Audit: ${auditConfirmed.length} confirmed of ${auditCandidates.length} candidate finding(s) across ${AUDITORS.length} dimensions`)
const gaps = audits
.filter(Boolean)
.flatMap((r) => r.findings.map((f) => ({ dimension: r.key, ...f })))
.filter((f) => f.severity !== 'info')
const touchedFrontend = changed.some((f) => f.includes('/templates/') || f.includes('/static/'))
const touchedApi = changed.some((f) => f.includes('/routers/'))
let live = { ran: false, summary: 'no frontend or API files changed; live verification skipped', issues: [] }
if (touchedFrontend || touchedApi) {
const kinds = [touchedFrontend ? 'visual (falcon)' : null, touchedApi ? 'API (hound)' : null].filter(Boolean).join(' and ')
live = await agent(
`Operate the MANDATORY DevPlace live verification (${kinds}) for the just-built feature, exactly per CLAUDE.md.\n\n` +
`Procedure:\n` +
`1. Check if the dev server already answers: "mole check http://localhost:10500". If it does NOT, start it yourself with "make dev" as a BACKGROUND process, then poll "mole check http://localhost:10500" until healthy (give uvicorn a few seconds to boot). Remember whether YOU started it.\n` +
(touchedFrontend
? `2. VISUAL: for each user-facing route the feature adds or changes, capture a screenshot with the installed Playwright (chromium, headless) navigating to "http://localhost:10500<route>" with wait_until="domcontentloaded", saving a PNG under /tmp/, then run "falcon describe <png>". Compare each AI description against the intended UI and the surrounding design system (layout, spacing, design tokens, responsiveness). Record any mismatch, broken layout, missing element, or visual regression as an issue. Authenticated routes: log in via the /auth/login form first (seeded users may not exist on a fresh dev DB - if a route needs auth and you cannot reach it, record that as an info issue rather than failing).\n`
: '') +
(touchedApi
? `3. API: write a hound JSON spec (tests: name/method/path/expect_status[/expect_body/expect_headers]) covering the feature's endpoints with realistic expected statuses, then run "hound <spec>.json --base-url http://localhost:10500". Record every failing assertion as an issue.\n`
: '') +
`4. TEARDOWN: if YOU started the server, kill it now (do not leave a stray uvicorn running). If it was already running, leave it.\n\n` +
`Routes for this feature: ${routes.length ? routes.join(', ') : '(infer from the changed routers/templates below)'}\n` +
`Changed files:\n${changed.join('\n')}\n\n` +
`Return ran=true, the pages and api endpoints you checked, and one issue per real visual/functional defect (severity/where/message). Do not edit feature source in this phase; only report.`,
{ label: 'live-verify', phase: 'Verify', schema: LIVE_SCHEMA }
)
log(`Live verify: ${(live && live.issues && live.issues.length) || 0} issue(s) over ${((live && live.pagesChecked) || []).length} page(s)`)
}
const gaps = []
for (const f of auditConfirmed) {
if (f.severity !== 'info') gaps.push({ source: f.dimension, file: f.file, line: f.line, rule: f.rule, message: f.message })
}
for (const i of (live && live.issues) || []) {
if (i.severity !== 'info') gaps.push({ source: 'live-verify', file: i.where, rule: 'live', message: i.message })
}
let gapFix = 'no actionable gaps from the audit or live verification'
let gapFix = 'no actionable gaps from the audit'
if (gaps.length) {
gapFix = await agent(
`Close these confirmed completeness, security, style, frontend, and live-rendering gaps found in the new feature. Apply minimal root-cause fixes directly in the repo, keeping all layers in agreement and the styling consistent with the design system. Re-run "hawk ." afterward. Do not run the pytest suite. Do not commit.\n\nGaps:\n${JSON.stringify(gaps, null, 2)}\n\n${RULES}`,
{ agentType: 'feature-builder', label: 'fix-gaps', phase: 'Fix' }
`Close these completeness and security gaps found by the audit of the new feature. Apply minimal root-cause fixes directly in the repo, keeping all layers in agreement. Re-run "hawk ." afterward. Do not run the test suite. Do not commit.\n\nGaps:\n${JSON.stringify(gaps, null, 2)}\n\n${RULES}`,
{ label: 'fix-gaps', phase: 'Fix' }
)
}
const tests = await agent(
`Operate in FIX mode. Write the missing integration tests for this new feature across EVERY tier it exercises, per the DevPlace test standard below. This is mandatory, not a nicety: the feature is incomplete until each route and helper it adds has a test in the appropriate tier (unit for new data/query helpers, api for new JSON/HTML routes, e2e for new interactive UI flows), in the correct file under the directory-mirrors-path layout. Decide the tiers from the changed files and routes; create the package directories (with __init__.py) the new test paths require. Validate each new test module by a clean import only. NEVER run the suite, not the full suite and not one file.\n\n${TESTS}\n\nFeature request: ${ask}\nRoutes: ${routes.join(', ')}\nFiles changed:\n${changed.join('\n')}\n\nReturn the test files you wrote, the tier of each, and which routes/helpers remain uncovered (with the reason).`,
{ agentType: 'test-maintainer', label: 'tests', phase: 'Test' }
`Operate in FIX mode. Write the missing integration tests for this new feature following the required Playwright patterns and the directory-mirrors-path layout. Validate each new test module by a clean import only. NEVER run the suite, not the full suite and not one file.\n\nFeature request: ${ask}\nFiles changed:\n${changed.join('\n')}`,
{ agentType: 'test-maintainer', label: 'tests', phase: 'Fix' }
)
log(`Feature build complete: ${changed.length} file(s), ${gaps.length} gap(s) addressed`)
log(`Feature build complete: ${changed.length} file(s), ${gaps.length} audit gap(s) addressed`)
return {
ask,
map,
plan,
build,
routes,
audit: { candidates: auditCandidates.length, confirmed: auditConfirmed, gaps },
liveVerify: live,
gapFix,
tests,
}
return { ask, map, plan, build, audit: gaps, gapFix, tests }
+4 -5
View File
@@ -1,9 +1,9 @@
// retoor <retoor@molodetz.nl>
export const meta = {
name: 'fleet',
description: 'DevPlace maintenance fleet: 12 dimension subagents scan in parallel, then every finding is adversarially verified against source before it is reported',
description: 'DevPlace maintenance fleet: 10 dimension subagents scan in parallel, then every finding is adversarially verified against source before it is reported',
phases: [
{ title: 'Review', detail: '12 dimension subagents scan devplacepy/ and tests/ in parallel' },
{ title: 'Review', detail: '10 dimension subagents scan devplacepy/ and tests/ in parallel' },
{ title: 'Verify', detail: 'adversarially refute each candidate finding against the actual source' },
],
}
@@ -19,8 +19,6 @@ const DIMENSIONS = [
{ key: 'fanout', agent: 'fanout-maintainer' },
{ key: 'docs', agent: 'docs-maintainer' },
{ key: 'test', agent: 'test-maintainer' },
{ key: 'background', agent: 'background-maintainer' },
{ key: 'locust', agent: 'locust-maintainer' },
]
const FINDINGS_SCHEMA = {
@@ -88,7 +86,7 @@ function reportPrompt(dimension) {
function verifyPrompt(dimension, finding) {
return (
`You are an independent skeptic, not the agent that raised this finding. A "${dimension}"-dimension maintenance agent flagged the candidate below; your job is solely to REFUTE it from a fresh, unbiased read of the source. Open the exact file and read ` +
`Adversarially verify a candidate "${dimension}" finding. Your goal is to REFUTE it. Open the exact file and read ` +
`enough surrounding context (the whole function, the caller, the contract) to judge intent. It is REAL only if it ` +
`survives refutation as a genuine violation of the ${dimension} dimension. Rule it out (isReal=false) if it is a ` +
`contract identifier, DATA rather than authored prose, generated or vendored or third-party, or already correct ` +
@@ -118,6 +116,7 @@ const reviewed = await pipeline(
parallel(
((review && review.findings) || []).map((finding) => () =>
agent(verifyPrompt(dimension.key, finding), {
agentType: dimension.agent,
label: `verify:${dimension.key}`,
phase: 'Verify',
schema: VERDICT_SCHEMA,
-285
View File
@@ -1,285 +0,0 @@
// retoor <retoor@molodetz.nl>
export const meta = {
name: 'full-docs-refactor',
description:
'Documentation reality audit: verify every falsifiable claim in README.md, the root CLAUDE.md, every nested CLAUDE.md, and the entire /docs site (prose + docs_api) against the actual source, fix drift in place, and confirm role-gating. Every agent owns a disjoint set of files so there are never write conflicts.',
phases: [
{ title: 'Ground truth', detail: 'extract authoritative facts (routes, CLI, env, deps, test count, package layout, docs registry) from source' },
{ title: 'Root docs', detail: 'audit README.md plus every CLAUDE.md (root and nested per-subsystem) in parallel - one file per agent' },
{ title: 'Docs site', detail: 'audit the docs_api package and every /docs prose section in parallel - disjoint template ownership' },
{ title: 'Gating + validate', detail: 'verify role-gating and run the full validation sweep (import, template compile, em-dash, broken links)' },
],
}
const REPORT_SCHEMA = {
type: 'object',
additionalProperties: false,
required: ['target', 'changed', 'changes', 'verifiedAccurate'],
properties: {
target: { type: 'string' },
changed: { type: 'boolean' },
changes: {
type: 'array',
items: {
type: 'object',
additionalProperties: false,
required: ['location', 'wrong', 'fixed'],
properties: {
location: { type: 'string' },
wrong: { type: 'string' },
fixed: { type: 'string' },
source: { type: 'string' },
},
},
},
verifiedAccurate: { type: 'array', items: { type: 'string' } },
gatingIssues: { type: 'array', items: { type: 'string' } },
unverifiable: { type: 'array', items: { type: 'string' } },
},
}
const VALIDATE_SCHEMA = {
type: 'object',
additionalProperties: false,
required: ['appImports', 'docsApiValid', 'templatesCompile', 'emDashClean', 'brokenLinks', 'gatingClean'],
properties: {
appImports: { type: 'boolean' },
docsApiValid: { type: 'boolean' },
templatesCompile: { type: 'boolean' },
emDashClean: { type: 'boolean' },
brokenLinks: { type: 'array', items: { type: 'string' } },
gatingClean: { type: 'boolean' },
gatingFixes: { type: 'array', items: { type: 'string' } },
notes: { type: 'string' },
},
}
const SHARED_RULES =
'RULES (all mandatory):\n' +
'- The CODE is the source of truth. When docs disagree with code, fix the DOCS, never the code. Do not invent or aspirationally document features. If docs describe something removed/renamed, correct or remove it.\n' +
'- Use Read/Grep/Glob/Bash to CONFIRM every claim before you edit it. Never edit on assumption.\n' +
'- NEVER introduce an em-dash character or its HTML entity; use a hyphen. Replace any em-dash in a passage you rewrite.\n' +
'- Be surgical: change only what is verifiably wrong or verifiably missing from a list/table meant to be complete. Preserve tone, structure, and formatting.\n' +
'- Do not corrupt markdown tables, HTML, or Jinja.\n' +
'DOCS PROSE STRUCTURE (for /docs/*.html templates): the body is <div class="docs-content" data-render> rendered to HTML SERVER-SIDE from markdown; example markup shown as code INSIDE that block stays HTML-entity-escaped (&lt;...&gt;). Real live-demo markup and its <script type="module"> live OUTSIDE that block - update a demo only if the API it shows changed.\n' +
'ROLE GATING: pages flagged admin:true in routers/docs/pages.py 404 for non-admins and are nav-filtered. Every /docs/<slug>.html link must resolve to a real slug (or a real /docs route like download.html/download.md). If a page visible to guests/members links to an admin-only route or admin doc slug, wrap it in {% if is_admin(user) %}...{% endif %}.\n' +
'REPORT: return structured output - target, changed, one entry per fix (location, wrong, fixed, source), the claim categories you verified as accurate, any gating issue, and anything you could not verify.'
function rootPrompt(file, gt) {
const isNested = file !== 'README.md' && file !== 'CLAUDE.md'
const nestedNote = isNested
? ` This is a NESTED CLAUDE.md (Claude Code auto-loads it only when a file under its own directory is read/edited) - its claims must be scoped to that subsystem; do not duplicate content that belongs in the root CLAUDE.md's cross-cutting rules or in a sibling nested file, and do not reintroduce a top-level AGENTS.md or any reference to one (it was deleted - all of its content now lives across the root CLAUDE.md and the nested CLAUDE.md files).`
: ''
return (
`DOCUMENTATION REALITY AUDIT of a single file: ${file}. Verify EVERY falsifiable claim against the actual source and FIX inconsistencies in place. EDIT ONLY ${file}.${nestedNote}\n\n` +
`Verify (where the file claims them): make targets + comments, devplace/devii CLI subcommands + flags, router prefixes/paths, env vars + defaults, config keys + defaults, function/class/helper/table/setting names, file/module paths (must exist), dependency names, version numbers, test counts, and internal links/anchors. For a routing table, env-var table, commands block, or CLI list that is meant to be COMPLETE, add rows that exist in code but are missing. If this file is the root CLAUDE.md, verify its "Subsystem map" table still lists every nested CLAUDE.md that actually exists in the repo and no stale entries for one that was removed.\n\n` +
`AUTHORITATIVE GROUND TRUTH (freshly extracted from this repo - trust it, but re-confirm anything you edit):\n${gt}\n\n` +
SHARED_RULES
)
}
const DOCS_SECTIONS = [
{
key: 'docs_api',
agentType: 'docs-maintainer',
prompt:
'Audit and FIX the /docs API reference, which is GENERATED from the `devplacepy/docs_api/` package (groups/ + services_group.py), NOT from templates. EDIT ONLY files under `devplacepy/docs_api/`. For EVERY documented endpoint verify against the real router + schema: method+path exists (grep @router in routers/, account for the main.py mount prefix), documented params/body match the real Form/query params (models.py, route signature), sample_response shape matches the real *Out schema (schemas/), and the stated auth matches the route guard (get_current_user/require_user/require_admin). The admin API groups (containers/gateway/services/admin) must be genuinely admin routes. Keep the group data valid Python (verify `python -c "from devplacepy.docs_api import API_GROUPS; print(len(API_GROUPS))"`). Remove documented endpoints that no longer exist; correct wrong params/paths/responses; note real endpoints the docs omit.',
},
{
key: 'general-a',
agentType: 'docs-maintainer',
prompt:
'Audit and FIX these /docs prose templates (EDIT ONLY these, under devplacepy/templates/docs/): index.html, getting-started.html, getting-started-vibing.html, feed.html, code-farm.html, block-and-mute.html, emoji-shortcodes.html, presence.html. Verify against: routers/{feed,game/,relations,news}.py, rendering.py (emoji shortcodes via build_emoji_shortcodes + `devplace emoji-sync`), services/presence.py + presence_relay.py, config.py presence defaults, main.py GET / home behavior. code-farm documents the /game Code Farm game; block-and-mute documents relations (/block,/block/unblock,/mute,/mute/unmute).',
},
{
key: 'general-b',
agentType: 'docs-maintainer',
prompt:
'Audit and FIX these /docs prose templates (EDIT ONLY these): devii.html, telegram.html, media-gallery.html, notification-settings.html, timezones.html, ai-correction.html, ai-modifier.html, dashboard.html (kind=live). Verify against: services/devii/ (member page), services/telegram/, services/correction.py, services/ai_modifier.py, routers/profile/{notifications,ai_correction,ai_modifier,telegram}.py, database notification prefs (NOTIFICATION_TYPES/NOTIFICATION_CHANNELS + defaults), templating.py local_dt/dt_ago + static/js/LocalTime.js, routers/media.py, routers/docs/views.py + docs_live.py (dashboard facts).',
},
{
key: 'components',
agentType: 'frontend-maintainer',
prompt:
'Audit and FIX the /docs Components pages (EDIT ONLY: components.html and component-*.html under templates/docs/). Source of truth: devplacepy/static/js/components/*.js and devii/*.js. For each page verify the customElements.define tag name, every documented attribute/property (attr/boolAttr/intAttr reads), methods/events, and the singleton access path (app.dialog/app.contextMenu/app.toast/app.lightbox/app.containerTerminals). Confirm the live-demo markup uses attributes that still exist; fix demos referencing removed attributes. component-emoji-picker documents the external emoji-picker-element (confirm it is still loaded in base.html).',
},
{
key: 'styles-tools',
agentType: 'docs-maintainer',
prompt:
'Audit and FIX (EDIT ONLY): styles.html, styles-colors.html, styles-layout.html, styles-responsiveness.html, styles-consistency.html, tools-seo.html, tools-deepsearch.html. Styles pages: every documented CSS --token name/value must match devplacepy/static/css/variables.css; breakpoints/structural rules must match base.css (and feed.css/projects.css for layout examples). Tools pages: verify routes and caps against routers/tools/{seo,deepsearch}.py, services/jobs/{seo,deepsearch}/, and models.py (SeoRunForm.max_pages 1-50; DeepSearch depth 1-4, max_pages 1-30).',
},
{
key: 'devrant',
agentType: 'docs-maintainer',
prompt:
'Audit and FIX the /docs devRant compatibility API pages (EDIT ONLY: devrant.html, devrant-auth.html, devrant-rants.html, devrant-comments.html, devrant-users.html, devrant-notifications.html, devrant-clients.html). Source: routers/devrant/ (mounted at /api) and services/devrant/. Also audit the backing devplacepy/docs_devrant.py if the widget data is wrong (it feeds _devrant_endpoints.html) - but only edit it if a claim is factually wrong. Verify each endpoint path (under /api), method, merged query+form+JSON params, the token triple auth, and the dr_ok/dr_error envelope. Reference client dir is examples/devrant/ (fix any stale devranta/ path).',
},
{
key: 'claude',
agentType: 'docs-maintainer',
prompt:
'Audit and FIX the /docs Claude Code pages (EDIT ONLY: claude.html, claude-manual.html, claude-agents.html, claude-commands.html, claude-workflows.html). Source of truth for project-specific claims: .claude/agents/*.md, .claude/commands/*.md, .claude/workflows/*.js. Fix any agent/command/workflow list that drifted from what exists, and any count of them. For general Claude Code product facts not verifiable from the repo, be CONSERVATIVE - leave them unless a .claude/ file contradicts.',
},
{
key: 'admin-prose',
agentType: 'docs-maintainer',
prompt:
'Audit and FIX the admin-gated /docs Administration prose pages (EDIT ONLY: devii-admin.html, telegram-admin.html, media-moderation.html, soft-delete.html, backups.html, gamification.html, audit-log.html). Sources: services/audit/ + events.md (event count/domains - match events.md self-reported figure), services/backups/ + routers/admin/backups.py (primary-admin-only download via utils.is_primary_admin), database soft-delete (SOFT_DELETE_TABLES) + /admin/trash, utils badges (ACHIEVEMENTS/BADGE_CATALOG/track_action - include the Code Farm badges), routers/media.py + /admin/media, Devii admin caps + config, services/telegram/ admin config. Verify routes, config-field names+defaults, function/class/table names, CLI commands.',
},
{
key: 'devii-internals',
agentType: 'devii-maintainer',
prompt:
'Audit and FIX the admin-gated /docs Devii internals pages (EDIT ONLY: devii-internals.html, devii-architecture.html, devii-tools.html, devii-data.html, devii-security.html, devii-config.html). Source: services/devii/ (session/ package, agentic/, actions/catalog/ package + dispatcher, hub, tasks/, behavior/, virtual_tools/, customization/, client/, rsearch/, email/, container/) and routers/devii.py. Verify: the documented tool/action names exist and their requires_auth/requires_admin/requires_primary_admin/CONFIRM_REQUIRED flags match the catalog; the total action+handler counts; session keying is (owner_kind, owner_id, channel); the persistence tables (devii_conversations/usage_ledger/turns/tasks/lessons/behavior/virtual_tools); the 4013/1013 close codes; financial-data-admin-only; run_js gated by devii_allow_eval; db_* tools primary-admin-only. NOTE session and actions/catalog are PACKAGES now.',
},
{
key: 'bots',
agentType: 'docs-maintainer',
prompt:
'Audit and FIX the admin-gated /docs Bots internals pages (EDIT ONLY: bots-internals.html, bots-architecture.html, bots-personas.html, bots-content.html, bots-engagement.html, bots-realism.html, bots-config.html). Source: services/bot/ (config.py for every documented default; llm.py/loop.py/posting.py/helpers.py/social.py/service.py for mechanics). Verify EVERY config default against services/bot/config.py, the service registration name/interval/default_enabled, the [bots] extra (playwright+faker), the referenced function names (generate_post_title, gist_quality_check, _engage_community, persona_article_score, pick_category, strip_label), and the design-narrative numbers (REACT_RATES, MAX_BOTS_PER_ARTICLE, etc.).',
},
{
key: 'services',
agentType: 'docs-maintainer',
prompt:
'Audit and FIX the admin-gated /docs Services pages (EDIT ONLY: services-overview.html, services-framework.html, services-data.html, services-gateway.html, services-devii.html, services-news.html, services-bots.html, services-zip.html, services-containers.html, services-dbapi.html, services-pubsub.html). Source: services/ subpackages and the main.py service registrations (the real count of registered services). Verify each service registration name/default_enabled/interval, config fields+defaults, tables, route surface, and source paths (NewsService now lives in services/news/service.py - news is a PACKAGE; runtime dirs default to data/ NOT var/; there is NO in-app container build / ContainerBuildService; /dbapi is READ-ONLY primary-admin-only).',
},
{
key: 'architecture',
agentType: 'docs-maintainer',
prompt:
'Audit and FIX the admin-gated /docs Architecture pages (EDIT ONLY: architecture.html, architecture-backend.html, architecture-frontend.html, architecture-styling.html, architecture-conventions.html, architecture-workflow.html, architecture-jobs.html). Source: main.py (request pipeline, middleware order, mounts), routers/ tree, static/js/ (ES6 modules on app, Application.js, dp-* components, shared utils Http/Poller/JobPoller/OptimisticAction/FloatingWindow), templating.py, rendering.py, services/jobs/ (JobService pattern). Fix any file/module path that no longer exists - database/utils/schemas/docs_api are PACKAGES now. Do NOT "fix" the deliberate synchronous-SQLite design to async.',
},
{
key: 'testing-prod',
agentType: 'docs-maintainer',
prompt:
'Audit and FIX the admin-gated /docs Testing + Production pages (EDIT ONLY: testing.html, testing-framework.html, testing-locust.html, testing-make.html, testing-cicd.html, production.html, production-deploy.html, production-nginx.html, production-concurrency.html, static-caching.html). Sources: Makefile, pyproject.toml ([tool.pytest.ini_options]), tests/ layout + conftest.py fixtures, locustfile.py, .gitea/workflows/, Dockerfile, docker-compose*.yml, nginx config, config.py (STATIC_VERSION). Verify every make target + behavior, the live test count (run `python -m pytest tests/ --collect-only -q | tail -1`), the tier layout, fixtures, ports, CI steps, the worker model (make prod = nproc; the Docker image pins 2 - keep that distinction), nginx WS-upgrade locations, and /static/v<version>/ caching.',
},
]
function sectionPrompt(section, gt) {
return (
section.prompt +
`\n\nAUTHORITATIVE GROUND TRUTH (freshly extracted from this repo - trust it, re-confirm what you edit):\n${gt}\n\n` +
SHARED_RULES
)
}
function selected(list) {
const only = args && args.only
if (!only) return list
const keys = Array.isArray(only) ? only : String(only).split(',').map((s) => s.trim()).filter(Boolean)
return list.filter((item) => keys.includes(item.key))
}
const GT_PROMPT =
'Operate READ-ONLY (do not edit any file). Extract the AUTHORITATIVE, current ground-truth facts of this repository so a documentation audit can cross-check against them. Use Bash/Read/Grep. Produce a compact but complete plain-text reference covering:\n' +
'1. Makefile: every target name and what it actually runs (esp. `prod` worker count, `install` steps, `test`).\n' +
'2. pyproject.toml: version, requires-python, [project.scripts], the full dependency list (note pins), optional-dependency extras.\n' +
'3. CLI: every top-level `devplace` subcommand and its sub-subcommands (from devplacepy/cli/*.py).\n' +
'4. Routers: every prefix mounted in devplacepy/main.py (include_router lines), including no-prefix routers.\n' +
'5. Env vars: every var read in devplacepy/config.py with its default.\n' +
'6. Live test count: `python -m pytest tests/ --collect-only -q | tail -1`.\n' +
'7. Package-vs-file: for database, utils, schemas, models, docs_api, seo, config, constants, rendering, templating - state whether each is a devplacepy/<name>.py FILE or a devplacepy/<name>/ PACKAGE.\n' +
'8. Docs registry: total DOCS_PAGES count, section names, count of admin-gated pages, and the list of docs_api API_GROUPS slugs.\n' +
'Return this as your final text - it will be injected verbatim into every downstream audit agent, so make it accurate and self-contained.'
log('Phase 1: extracting ground truth from source')
phase('Ground truth')
const groundTruth =
(await agent(GT_PROMPT, { agentType: 'docs-maintainer', label: 'ground-truth', phase: 'Ground truth' })) ||
'Ground-truth extraction failed; verify every claim directly against source before editing.'
log('Phase 2: auditing README.md and every CLAUDE.md (root + nested) in parallel')
phase('Root docs')
const ROOT_FILES = [
{ key: 'readme', file: 'README.md' },
{ key: 'claude-root', file: 'CLAUDE.md' },
{ key: 'nested-routers', file: 'devplacepy/routers/CLAUDE.md' },
{ key: 'nested-routers-projects', file: 'devplacepy/routers/projects/CLAUDE.md' },
{ key: 'nested-routers-docs', file: 'devplacepy/routers/docs/CLAUDE.md' },
{ key: 'nested-routers-devrant', file: 'devplacepy/routers/devrant/CLAUDE.md' },
{ key: 'nested-services', file: 'devplacepy/services/CLAUDE.md' },
{ key: 'nested-services-audit', file: 'devplacepy/services/audit/CLAUDE.md' },
{ key: 'nested-services-backup', file: 'devplacepy/services/backup/CLAUDE.md' },
{ key: 'nested-services-bot', file: 'devplacepy/services/bot/CLAUDE.md' },
{ key: 'nested-services-containers', file: 'devplacepy/services/containers/CLAUDE.md' },
{ key: 'nested-services-dbapi', file: 'devplacepy/services/dbapi/CLAUDE.md' },
{ key: 'nested-services-devii', file: 'devplacepy/services/devii/CLAUDE.md' },
{ key: 'nested-services-email', file: 'devplacepy/services/email/CLAUDE.md' },
{ key: 'nested-services-game', file: 'devplacepy/services/game/CLAUDE.md' },
{ key: 'nested-services-gitea', file: 'devplacepy/services/gitea/CLAUDE.md' },
{ key: 'nested-services-jobs', file: 'devplacepy/services/jobs/CLAUDE.md' },
{ key: 'nested-services-messaging', file: 'devplacepy/services/messaging/CLAUDE.md' },
{ key: 'nested-services-news', file: 'devplacepy/services/news/CLAUDE.md' },
{ key: 'nested-services-openai-gateway', file: 'devplacepy/services/openai_gateway/CLAUDE.md' },
{ key: 'nested-services-pubsub', file: 'devplacepy/services/pubsub/CLAUDE.md' },
{ key: 'nested-services-telegram', file: 'devplacepy/services/telegram/CLAUDE.md' },
{ key: 'nested-services-xmlrpc', file: 'devplacepy/services/xmlrpc/CLAUDE.md' },
{ key: 'nested-database', file: 'devplacepy/database/CLAUDE.md' },
{ key: 'nested-utils', file: 'devplacepy/utils/CLAUDE.md' },
{ key: 'nested-static-js', file: 'devplacepy/static/js/CLAUDE.md' },
{ key: 'nested-templates', file: 'devplacepy/templates/CLAUDE.md' },
{ key: 'nested-tests', file: 'tests/CLAUDE.md' },
]
const rootReports = await parallel(
selected(ROOT_FILES).map((root) => () =>
agent(rootPrompt(root.file, groundTruth), {
agentType: 'docs-maintainer',
label: `root:${root.key}`,
phase: 'Root docs',
schema: REPORT_SCHEMA,
})
)
)
log('Phase 3: auditing the docs_api package and every /docs prose section in parallel')
phase('Docs site')
const sectionReports = await parallel(
selected(DOCS_SECTIONS).map((section) => () =>
agent(sectionPrompt(section, groundTruth), {
agentType: section.agentType,
label: `docs:${section.key}`,
phase: 'Docs site',
schema: REPORT_SCHEMA,
})
)
)
log('Phase 4: verifying role-gating and running the validation sweep')
phase('Gating + validate')
const rootFileList = ROOT_FILES.map((f) => f.file).join(', ')
const validatePrompt =
'The documentation audit edits are complete. Run the final VERIFICATION over the repo and FIX any residual gating issue you find (edit only routers/docs/pages.py flags or add {% if is_admin(user) %} guards in the specific template that leaks an admin link). Do the following with Bash and report structured results:\n' +
'1. `python -c "from devplacepy.main import app"` imports clean (appImports).\n' +
'2. `python -c "from devplacepy.docs_api import API_GROUPS; print(len(API_GROUPS))"` works (docsApiValid).\n' +
'3. Every template under devplacepy/templates/docs/ compiles via the shared Jinja env (templatesCompile). Report any that fail.\n' +
`4. No em-dash character or entity in any of: ${rootFileList}, or any devplacepy/templates/docs/*.html (emDashClean).\n` +
'5. Broken internal links: every /docs/<slug>.html href in the doc templates must resolve to a real DOCS_PAGES slug OR a real /docs route (download.html/download.md); list any that do not (brokenLinks).\n' +
'6. Role-gating: no page whose content is admin-only is left ungated (admin:true in pages.py), and no public (non-admin) page links to an admin-gated slug outside an {% if is_admin(user) %} block. Fix violations; report gatingClean + gatingFixes.\n' +
'7. Confirm AGENTS.md does not exist at the repo root (`test -f AGENTS.md && echo EXISTS || echo ABSENT` must print ABSENT) and grep the repo for stray `AGENTS.md` references outside third-party/vendor/backup paths (.venv, *.bak, .git); report any as gatingIssues so a human can decide whether to fix them (this workflow does not own arbitrary non-doc files, e.g. .claude/ agent/command/workflow definitions).\n' +
'Confirm each item against actual command output; do not guess.'
const validation = await agent(validatePrompt, {
agentType: 'docs-maintainer',
label: 'gating+validate',
phase: 'Gating + validate',
schema: VALIDATE_SCHEMA,
})
const roots = rootReports.filter(Boolean)
const sections = sectionReports.filter(Boolean)
const totalFixes =
roots.reduce((n, r) => n + ((r && r.changes && r.changes.length) || 0), 0) +
sections.reduce((n, r) => n + ((r && r.changes && r.changes.length) || 0), 0)
log(`Done. ${totalFixes} documentation fix(es) applied across ${roots.length} root file(s) and ${sections.length} /docs section(s).`)
return {
workflow: 'full-docs-refactor',
totalFixes,
rootDocs: roots,
docsSections: sections,
validation,
}
+5 -13
View File
@@ -1,14 +1,13 @@
// retoor <retoor@molodetz.nl>
export const meta = {
name: 'job-service',
description: 'Scaffold an async JobService (the zip/fork pattern): the JobService subclass, enqueue/status/download routes, the JobOut schema, main.py registration, Devii tools, JobPoller frontend, and docs, then verify and write the integration tests (enqueue, status, download) in the api tier',
description: 'Scaffold an async JobService (the zip/fork pattern): the JobService subclass, enqueue/status/download routes, the JobOut schema, main.py registration, Devii tools, JobPoller frontend, and docs, then verify',
phases: [
{ title: 'Understand', detail: 'read ZipService and ForkService as the template' },
{ title: 'Plan', detail: 'a per-touchpoint plan for the new job kind' },
{ title: 'Implement', detail: 'build the service and all consumers in the repo' },
{ title: 'Verify', detail: 'completeness, security, and audit-log review' },
{ title: 'Fix', detail: 'close gaps from the review' },
{ title: 'Test', detail: 'write the api-tier integration tests for enqueue, status, and download' },
{ title: 'Fix', detail: 'close gaps and write the job tests' },
],
}
@@ -32,14 +31,7 @@ const CHECKLIST = [
'6. docs_api.py - endpoint() entries for the enqueue, status, and download routes.',
'7. static/js - wire JobPoller.run(statusUrl, {onDone, onFailed, onTimeout}) on the triggering element.',
'8. CLI (optional) - a prune/clear subcommand if artifacts accumulate.',
'9. README.md + devplacepy/services/jobs/CLAUDE.md - document the new job kind.',
].join('\n')
const TESTS = [
'DevPlace test standard (a hard project requirement - one test file per endpoint, the directory tree mirroring the URL path):',
'A job kind is exercised over HTTP, so its tests live in tests/api/ against the live uvicorn subprocess (app_server/seeded_db, requests/httpx vs BASE_URL), one file per route path (POST /projects/{slug}/zip -> tests/api/projects/zip.py; GET /zips/{uid} -> tests/api/zips/index.py). Cover enqueue (authz + a job uid back), status (the *JobOut shape and lifecycle), and download/result (the capability URL) where applicable.',
'Because the service loop only runs in the lock owner and tests set DEVPLACE_DISABLE_SERVICES=1, assert the enqueue contract and the pending/known status shape rather than waiting on real completion; if you need a finished job, drive process() directly in a unit test under tests/unit/services/jobs/.',
'Required patterns: scoped assertions; try/finally restore of any flipped global setting; the shared fixtures; raw inserts into a soft-delete table set deleted_at/deleted_by. Validate by a clean import only. NEVER run the suite.',
'9. README.md + AGENTS.md - document the new job kind.',
].join('\n')
function jobBrief() {
@@ -168,8 +160,8 @@ if (gaps.length) {
}
const tests = await agent(
`Operate in FIX mode. Write the integration tests for the new job kind (enqueue, status, download) following the required patterns and the directory-mirrors-path layout. The job kind is not complete until each of its routes has a test. Create any missing package directories the test paths need. Validate by a clean import only. NEVER run the suite.\n\n${TESTS}\n\nJob request: ${ask}\nKind: ${build && build.kind}\nFiles changed:\n${changed.join('\n')}\n\nReturn the test files written and the routes they cover.`,
{ agentType: 'test-maintainer', label: 'tests', phase: 'Test' }
`Operate in FIX mode. Write the integration tests for the new job kind (enqueue, status, download) following the required patterns and the directory-mirrors-path layout. Validate by a clean import only. NEVER run the suite.\n\nJob request: ${ask}\nKind: ${build && build.kind}\nFiles changed:\n${changed.join('\n')}`,
{ agentType: 'test-maintainer', label: 'tests', phase: 'Fix' }
)
return { ask, map, plan, build, audit: gaps, gapFix, tests }
+2 -5
View File
@@ -19,9 +19,6 @@ const DIMENSIONS = [
{ key: 'docs', agent: 'docs-maintainer' },
{ key: 'seo', agent: 'seo-maintainer' },
{ key: 'test', agent: 'test-maintainer' },
{ key: 'devii', agent: 'devii-maintainer' },
{ key: 'background', agent: 'background-maintainer' },
{ key: 'locust', agent: 'locust-maintainer' },
]
const DIFF_SCHEMA = {
@@ -105,8 +102,8 @@ const reviewed = await pipeline(
parallel(
((review && review.findings) || []).map((finding) => () =>
agent(
`You are an independent skeptic, not the agent that raised this finding. A "${dimension.key}"-dimension maintenance agent flagged the candidate below in this diff; your job is solely to REFUTE it from a fresh, unbiased read of the source. Open the file, read the changed region and its context, and decide if it is a genuine violation introduced by this diff. Rule it out (isReal=false) if it is a contract identifier, DATA rather than prose, vendored, pre-existing and untouched by this diff, or already correct under a known exemption. When uncertain, default to isReal=false.\n\nFinding:\n- file: ${finding.file}\n- line: ${finding.line == null ? 'unspecified' : finding.line}\n- severity: ${finding.severity}\n- rule: ${finding.rule}\n- message: ${finding.message}`,
{ label: `verify:${dimension.key}`, phase: 'Verify', schema: VERDICT_SCHEMA }
`Adversarially verify a candidate "${dimension.key}" review finding. Try to REFUTE it: open the file, read the changed region and its context, and decide if it is a genuine violation introduced by this diff. Rule it out (isReal=false) if it is a contract identifier, DATA rather than prose, vendored, pre-existing and untouched by this diff, or already correct under a known exemption. When uncertain, default to isReal=false.\n\nFinding:\n- file: ${finding.file}\n- line: ${finding.line == null ? 'unspecified' : finding.line}\n- severity: ${finding.severity}\n- rule: ${finding.rule}\n- message: ${finding.message}`,
{ agentType: dimension.agent, label: `verify:${dimension.key}`, phase: 'Verify', schema: VERDICT_SCHEMA }
).then((verdict) => ({ ...finding, dimension: dimension.key, verdict }))
)
)
File diff suppressed because one or more lines are too long
-7
View File
@@ -32,10 +32,3 @@ var/
.coverage
.coverage.*
htmlcov/
# local environments and scratch
.venv/
tmp/
*.log
*.bak
test.db
+1876
View File
File diff suppressed because one or more lines are too long
-313
View File
@@ -1,313 +0,0 @@
## 2026-06-19 🟢
- Block and mute user relations with API endpoints, CLI emoji-sync command, and content filtering
## 2026-06-18 🟢
- News service with image dedup, AI grading, featured/landing auto-rotation and admin lock
- Server-rendered content pipeline with Telegram pairing, response timing, and admin user index
- AI Markdown reformatting for news articles with usage metering and sidebar cleanup
## 2026-06-17 🟢
- Backup download restricted to primary admin, admin-hidden projects invisible to other admins
- Access token system with CLI management and wildcard file type support
- Access token issuance with JSON/form login endpoint and token lifecycle management
## 2026-06-16 🔥 Big day!
- Gateway admin UI with provider and model routing for OpenAI gateway
- Backup management CLI commands and service layer with configurable data directories
- Audit logging for admin trash restore/purge and notification clear, plus SEO noindex for private projects and sitemap docs page refactor
- Instance lookup by name in addition to uid and slug, new terminal session service
- Keyboard-aware input visibility with ResizeObserver fallback for mobile message layout
- E2E comment hierarchy seed helpers for gists, news and projects
- Optimistic message insertion disabled to prevent duplicate bubbles
- Router tree documented in AGENTS.md with 14 new route entries
- Add dpc binary to container image and set executable permissions
- Remove .html and .svg from allowed upload types and MIME mappings
## 2026-06-15 🟢
- ASGI lifespan handler with background service orchestration and lock-based worker coordination
- Message chunking with sentence-aware splitting and configurable character limits
- Enforce hard test-coverage standard across DevPlace workflows and agents
- Audio file support with inline player and expanded allowed upload types
- Gist comment form integration with card-scoped comment targeting
- Bot account API key adoption for per-user gateway spend attribution
## 2026-06-14 🔥 Massive day!
- Admin/internal database API with CRUD, natural-language query, and read-only SQL execution
- DeepSearch research job queue with CLI prune/clear, Chroma vector store, and date-aware system message composition
- DeepSearch multi-agent researcher with grounded RAG chat and per-session vector store
- SEO Diagnostics tool with CLI management, live WebSocket progress, and static asset cache-busting
- OpenAI-compatible embeddings endpoint with model mapping and usage tracking
- Stealth HTTP client with curl_cffi transport adapter replacing raw httpx for outbound requests
- Bot monitor with live age badges and zoomable screenshots
- Author-interleaved feed ordering across all feed views and tabs
- Author diversity via interleaving (no per-author cap) for home and feed
- devRant API client library and example scripts in Python and JavaScript
- TTLCache-backed cache version reads with invalidation on bump
- Random client IP spoofing for load-testing traffic
- Deepsearch chat component attribute naming from data-* to direct properties
- Replace `python -m agents.validator` with `hawk` across all agent markdown files
## 2026-06-13 🔥 Massive day!
- Three-tier test suite with unit, API, and E2E directories mirroring source and endpoint paths
- Soft-delete audit for bookmarks, comments, follows, polls, project files, reactions, and bug create request event
- Notification preferences with per-user per-channel toggles and admin defaults
- Author diversity enforcement across home page and feed with personalized landing for authenticated users
- Shared free-text search across feed, gists, and projects listings
- Docs search with agent-powered Docii chat and admin-configurable search mode
- Devii agent audit log query action with filterable paginated endpoint
- Router directory-tree convention with admin audit log, AI quota, and container management endpoints
- Initial maintenance agent fleet with per-dimension code quality enforcers
- Unified blob sharding on uuid7 random tail across attachments, project files, and zip service
- Consolidated runtime data directory layout with migration CLI command
- Context-aware window control button visibility with font size boundary detection and minimize/normalize size presets
- Overflow-managed profile tabs with a "more" dropdown for narrow screens
- Startup jitter, randomized browser fingerprinting, and short comment styles for bot realism
- Sidebar search form with hidden field support and configurable placeholder
- Prevent titlebar double-click maximize when clicking buttons in FloatingWindow and DeviiTerminal
- Pin test server to single worker and use upsert for rate-limit settings to prevent spurious 429s
- DEVPLACE_DISABLE_RATE_LIMIT env var to bypass rate limiter in tests and middleware
- Fallback to location.origin when DEVPLACE_DOCS.base is missing
- Add claude-manual task-oriented guide page with cross-reference from claude.html
- Remove PWA install button and associated installer module
- Removed stale test files and fixed Gitea env teardown and ingress proxy test cleanup
- Locustfile seed data expansion and route exclusion documentation
## 2026-06-12 🔥 Massive day!
- Agent report system with codenames, timestamped output streams, and write-budget enforcement
- Tool-scoped payload filtering for worker agents with orchestration tool isolation
- Agent isolation and result caching in Maestro review sweep
- Concurrent read-only fleet check mode with per-agent cost tracking and contextvar-isolated findings
- Admin analytics and AI usage API response keys renamed, password change toggle added
- Gitea-backed bug tracker with list/detail/comment/status and AI-enhanced filing
- Bug detail page with admin/member role rendering and viewer_is_admin context flag
- Changed-files fast mode for maintenance agents with write-allowlist guard
- Partial config save with error reporting and password manager suppression
- Admin route cache-disabling headers via Cache-Control, Pragma and Expires
- Dirty-field tracking and server-side value sync for service config forms
- Rename `is_admin` to `viewer_is_admin` in bug detail schema, router, and template
- Bug tracker unavailable page with JSON and HTML 503 error responses
- Bot comments avoid repeating sibling opinions via thread-aware distinctness prompt
- Default Gitea repository changed from pydevplace to devplacepy
- Remove pytest-xdist parallel test execution, switch to serial single-process test runner
## 2026-06-11 🔥 Massive day!
- Platform-wide soft delete with deleted_at/deleted_by columns and admin trash management
- Owner-or-admin soft-delete enforcement on all content endpoints
- Unified image lightbox with attribute-wired opening and per-user media tab with soft delete
- Autonomous maintenance agent fleet with CLI entry point, Makefile targets, and dependency-free validator
- Seed-finding guided fix mode for maintenance agents with incomplete report tracking
- Audit log tables with CLI recording hooks
- Resolve merge conflict in pagination template and add admin-audit-log endpoint to docs API
- Bots documentation pages and session stop/reset commands
- Reduced nested comment indentation from 1.5rem to 0.25rem per depth level
- Reduce comment indentation multiplier and padding for nested replies
- Switch to dynamic viewport height and remove autofocus from message input
- Inline message layout with responsive height and auto-scroll
- Optional label attribute with hidden empty state for dp-upload component
- Mandatory retoor header added to all devplacepy source files
## 2026-06-10 🟢
- Port conflict detection and test isolation hardening across admin, avatar, bugs, landing, messages, and customization tests
- Container proxy routing via container IP instead of host port, with fake backend network simulation
- XDG-compliant devii tasks database path with DEVII_HOME override
- Project editing endpoint with 125k char body limit and remote URL attachment guard
## 2026-06-09 🔥 Big day!
- Container manager with Dockerfile CRUD, image builds, instance lifecycle, ingress proxy, and CLI commands
- Async project fork service with job queue, CLI management, and shared container image build
- Parallel test execution with per-worker isolated databases, data dirs, and uvicorn subprocesses via pytest-xdist
- Per-user customization suppression toggles with profile UI and Devii tool
- Customization toggle UI with enable/disable state management
- Unified shared Http and Poller utilities across all frontend modules, replacing inline fetch and setInterval patterns
- Responsive refinements for sub-360px screens, touch targets, safe-area insets, and mobile window controls
- Click-to-open profile dropdown with keyboard and outside-click dismissal
- Migrate hardcoded spacing values to CSS custom properties across multiple stylesheets
- Unicode escape normalization for emoji constants across codebase
- Consolidated upload ignore rules into a single directory-level gitignore entry
- Removed unused imports across routers, database, and services
- Remove project_set_private from confirmation-required actions and fix async test helpers
## 2026-06-08 🟢
- Admin AI quota management with CLI and admin panel reset controls
- API key management CLI with backfill command and auth support across session, API key, and HTTP Basic
- Per-project filesystem with directory and file CRUD, upload, and inline editing
- Async zip job framework with CLI management and zip archive download endpoints
- Add mistune dependency to project
## 2026-06-06 🟢
- Reactions, bookmarks, polls, extended sessions, and operational settings
## 2026-06-05 🔥 Massive day!
- Batch attachment linking, deduplicated mention notifications, and idempotent badge milestone checks
- Cursor-based load-more pagination across feed, gists, news and projects
- Canonical slug redirects, cursor-based next-page links, and OG image extraction across feed, gists, news, posts, projects, and profile
- TTLCache with LRU eviction, CLI role management, content unit helpers, database query functions, follow API with XP rewards, and news service with AI grading
- Unified comment form component with mobile touch optimizations across all CSS
- Inline comment previews on post cards with per-comment reply forms
- Comment template with threaded voting, author display, and attachment support
- Post-login redirect with `next` parameter and unauthenticated comment redirect to login
- Login redirect for unauthenticated admin, next parameter support with external URL rejection, and inline comment reply forms
- Seed comments created for all posts instead of only the first
- Replace uuid4 with uuid7 via uuid_utils for push notification JWT jti claims
- Coverage instrumentation for CI and local test runs with HTML report artifact
- Coverage configuration with subprocess measurement support
- Sitemap TTL configurable via environment variable and news_images schema migration
- Kill stale server process and add startup failure detection for Locust targets
## 2026-06-02 🟢
- Multi-worker service lock with cascading vote/comment cleanup on content deletion
## 2026-05-30 🟢
- Leaderboard route with gamification system (XP, levels, badges, stars) and content creation refactor
## 2026-05-28 🟢
- Cursor-based pagination for feed, notifications, and votes with thumbnail extension fallback
- Push registration returns creation flag and only sends welcome notification on first registration
## 2026-05-27 🟢
- AJAX vote buttons with live count updates across posts, gists, projects, and comments
- CSS-only card-link overlay replacing JS-driven data-href navigation
## 2026-05-25 🟢
- Unified notification click-to-navigate with comment anchor highlighting and dismiss refactor
## 2026-05-23 🔥 Massive day!
- Web push notifications with PWA manifest and service worker registration
- Web push notifications with PWA offline shell and install prompt
- Unified badge, notification, and content enrichment system with star tracking helpers
- Aggregate star counts across posts, projects and gists for profile and top-author ranking
- Content editing and deletion with cascading cleanup, avatar image helper, HTTP form POST, text input cursor management, and toast flash utility
- Share button with clipboard copy across detail pages, structured data schemas for gists and news articles, configurable site URL and rate limit, and production proxy headers support
- Production deployment workflow via git merge master into production
- Automatic production deployment on successful master push
- Removed automatic production deployment from CI pipeline
- Admin settings form with Pydantic validation and model-driven save
- Pydantic form models with validation for signup, login, password reset, comments, bugs, admin actions, and posts
- Type-safe integer settings with empty-value skip on admin save
- Input validation tests for votes, posts, profile, and signup endpoints
- Rate-limit environment variable and expanded Locust seed data for gists, notifications, and uploads
- TTLCache with ETag-based HTTP caching for avatar endpoint
- Dynamic language sidebar filtering based on existing gist language codes
- Vendor static assets for CodeMirror, highlight.js, marked, and emoji picker
- Test server log capture via tempfile with reduced log verbosity
- DOMPurify XSS sanitization for client-side rendered markdown content
- Add mobile-web-app-capable meta tag for PWA support
- Topnav notification bell selector scoped to /notifications href
- Fix notification bell icon locator to use explicit href selector instead of first match
- Remove stale import of get_users_by_uids from project_detail endpoint
## 2026-05-22 🟢
- News article HTML sanitization CLI command and database migration
## 2026-05-19 🟢
- Avatar generation exception logging with full traceback
- Fix multiavatar import path and add required arguments to function call
## 2026-05-16 🟢
- Clickable post titles and content with downvote support on feed and detail pages
- Interactive vote buttons and clickable post titles on profile page
- Handle @-mention with preceding text in content rendering
- Unread notification cache invalidation across comments, follows, messages, votes, and mentions
- Compact send button, attachment upload container, and auto-scroll on message thread load
- GistEditor lazy init with modal observer, CodeMirror Rust mode removed, emoji picker module type, source textarea required removed, projects tab spacing and settings button removed
- Add space between icon and label in feed navigation tabs
## 2026-05-15 🟢
- Python 3.13 base image, default port 10500, and nginx template to conf.d migration
- Responsive mobile navigation and messages layout with hamburger menu and back button
- Responsive breakpoint widened from 768px to 1024px for topnav, breadcrumb and page layouts
## 2026-05-14 🟢
- Migrate from deprecated `datetime.utcnow()` to timezone-aware `datetime.now(timezone.utc)` across the entire codebase
- Wait-for-url stabilization in noindex tests for messages and notifications pages
- Disable parallel test execution in CI pipeline
## 2026-05-13 🟢
- Migrate all TemplateResponse calls to pass request as first positional argument
- Attachment linking and deletion refactored into dedicated module with batch support
- Parallelised integration test suite with xdist worker port isolation
- Remove deprecated imghdr dependency and fix icon spacing in bug report buttons
- Replace hardcoded pytest.BASE_URL with conftest BASE_URL in attachment tests
- CI trigger branch from main to master
## 2026-05-12 🟢
- News service with admin curation, landing page articles, and comment support
- Mention notification system across bugs, comments, messages, posts, and projects with user search API
- Gists page with code snippet sharing, voting, and comment integration
## 2026-05-11 🔥 Big day!
- Unified threaded comment system with polymorphic target support across posts, projects, and bugs
- News management system with admin panel, pagination, and SEO sitemap integration
- News background service framework with CLI management, bug reports router, and admin services monitoring
- Admin panel with user management CLI, SEO metadata, and production deployment config
- Multiavatar local SVG generation with WAL mode SQLite and Locust load testing
- CI branch target renamed from main to master and test fixtures refactored for explicit login and seeded database
- Test fixture improvements with debug logging, stderr capture, and extended startup timeout
- Remove hawk static analysis step from CI test workflow
## 2026-05-10 🚀 First commit!
- Initial project scaffold with FastAPI SSR app, auth, feed, posts, comments, projects, profile, messages, notifications, and voting
- DiceBear avatar proxy with style picker on signup and profile, threaded comments
- Image upload support for posts with daily topic display on landing and feed
────────────────────────────────────────────────────────────
Summary: 194 commits over 29 active days. The project launched on May 10 with the initial FastAPI scaffold, auth, feed, and core content features. The biggest pushes came on June 13 (23 commits) delivering the three-tier test suite, soft-delete audit system, notification preferences, and author diversity enforcement; June 23 (23 commits) adding web push notifications, PWA support, content editing/deletion, and production deployment workflows; and June 14 (14 commits) introducing the admin database API, DeepSearch research system, SEO diagnostics, and the stealth HTTP client.
+165 -187
View File
File diff suppressed because one or more lines are too long
-1
View File
@@ -31,7 +31,6 @@ RUN pip install --no-cache-dir ".[bots]" \
EXPOSE 10500
ENV DEVPLACE_WEB_WORKERS=2
ENV DEVPLACE_TEMPLATE_AUTO_RELOAD=0
HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=10s \
CMD curl -f http://localhost:10500/ || exit 1
+3 -9
View File
@@ -5,8 +5,6 @@ LOCUST_DB ?= $(LOCUST_DB_DIR)/datastore.db
LOCUST_USERS ?= 20
LOCUST_SPAWN_RATE ?= 5
LOCUST_RUN_TIME ?= 120s
LOCUST_WEB_WORKERS ?= 4
WEB_WORKERS ?= $(shell nproc 2>/dev/null || echo 2)
DEVPLACE_RATE_LIMIT ?= 1000000
PYTHONDONTWRITEBYTECODE := 1
@@ -22,11 +20,7 @@ dev:
uvicorn devplacepy.main:app --reload --reload-dir devplacepy --host 0.0.0.0 --port 10500 --backlog 4096
prod:
DEVPLACE_STATIC_VERSION=$$(date +%s) DEVPLACE_TEMPLATE_AUTO_RELOAD=0 DEVPLACE_WEB_WORKERS=$(WEB_WORKERS) uvicorn devplacepy.main:app --host 0.0.0.0 --port 10500 --workers $(WEB_WORKERS) --backlog 8192 --proxy-headers --forwarded-allow-ips '*'
delete-pyc:
find . -name "__pycache__" -type d -prune -exec rm -rf {} + 2>/dev/null || true
find . -name "*.pyc" -delete
DEVPLACE_STATIC_VERSION=$$(date +%s) DEVPLACE_WEB_WORKERS=2 uvicorn devplacepy.main:app --host 0.0.0.0 --port 10500 --workers 2 --backlog 8192 --proxy-headers --forwarded-allow-ips '*'
tree:
git ls-files | tree --fromfile --noreport
@@ -82,7 +76,7 @@ locust:
sleep 1; \
mkdir -p $(LOCUST_DB_DIR); \
rm -f $(LOCUST_DB); \
DEVPLACE_TEMPLATE_AUTO_RELOAD=0 DEVPLACE_WEB_WORKERS=$(LOCUST_WEB_WORKERS) uvicorn devplacepy.main:app --host 127.0.0.1 --port $(LOCUST_PORT) --workers $(LOCUST_WEB_WORKERS) --backlog 8192 > /tmp/devplace_locust_server.log 2>&1 & \
uvicorn devplacepy.main:app --host 127.0.0.1 --port $(LOCUST_PORT) --backlog 8192 > /tmp/devplace_locust_server.log 2>&1 & \
PID=$$!; \
while kill -0 $$PID 2>/dev/null && ! curl -s http://127.0.0.1:$(LOCUST_PORT)/ > /dev/null 2>&1; do sleep 0.5; done; \
if ! kill -0 $$PID 2>/dev/null; then echo "Server failed to start (port $(LOCUST_PORT) busy?). See /tmp/devplace_locust_server.log"; exit 1; fi; \
@@ -97,7 +91,7 @@ locust-headless:
sleep 1; \
mkdir -p $(LOCUST_DB_DIR); \
rm -f $(LOCUST_DB); \
DEVPLACE_TEMPLATE_AUTO_RELOAD=0 DEVPLACE_WEB_WORKERS=$(LOCUST_WEB_WORKERS) uvicorn devplacepy.main:app --host 127.0.0.1 --port $(LOCUST_PORT) --workers $(LOCUST_WEB_WORKERS) --backlog 8192 > /tmp/devplace_locust_server.log 2>&1 & \
uvicorn devplacepy.main:app --host 127.0.0.1 --port $(LOCUST_PORT) --backlog 8192 > /tmp/devplace_locust_server.log 2>&1 & \
PID=$$!; \
while kill -0 $$PID 2>/dev/null && ! curl -s http://127.0.0.1:$(LOCUST_PORT)/ > /dev/null 2>&1; do sleep 0.5; done; \
if ! kill -0 $$PID 2>/dev/null; then echo "Server failed to start (port $(LOCUST_PORT) busy?). See /tmp/devplace_locust_server.log"; exit 1; fi; \
+59 -288
View File
@@ -19,13 +19,12 @@ Open `http://localhost:10500`.
| Layer | Technology |
|-------|-----------|
| Backend | Python 3.12+, FastAPI, Uvicorn (multi-worker in production) |
| Backend | Python 3.13+, FastAPI, Uvicorn (multi-worker in production) |
| Templates | Jinja2 (server-side rendered) |
| Frontend | Pure ES6 JavaScript, one class per file. Per-tab scroll restoration (`ScrollMemory`): returning to a listing via browser back, reload, or a back/breadcrumb link reliably lands at the previous scroll position on every browser and device; fresh navigations always start at the top |
| Frontend | Pure ES6 JavaScript, one class per file |
| Database | SQLite via `dataset` (auto-sync schema, `uid` PKs, WAL mode, 30s busy timeout) |
| Auth | Session cookie, API key (`X-API-KEY`/Bearer), or HTTP Basic; PBKDF2-SHA256 via passlib |
| Avatars | Multiavatar (local SVG generation, no external API, <5ms). Seeded from the username by default; a per-user `avatar_seed` lets the owner or an admin regenerate a fresh random avatar from the profile page (`POST /profile/{username}/regenerate-avatar`). Regeneration is irreversible - the previous avatar cannot be recovered. |
| Outbound HTTP | Stealth client (`devplacepy/stealth.py`): real Chrome fingerprint (TLS JA3/JA4 + HTTP/2 + headers) via `curl_cffi` behind an `httpx` transport adapter, pure-`httpx[http2]` fallback; the single client for every server-side outbound request |
| Avatars | Multiavatar (local SVG generation, no external API, <5ms) |
| Coverage | `coverage.py` (`.coveragerc`, subprocess-aware) |
| Load testing | Locust (locustfile.py) |
@@ -35,10 +34,10 @@ Open `http://localhost:10500`.
devplacepy/
main.py # FastAPI app, router registration
config.py # Settings from env vars + .env
database/ # dataset connection, index creation (package)
database.py # dataset connection, index creation
templating.py # Shared Jinja2 environment + globals
avatar.py # Multiavatar generation, URL builder
utils/ # Password hashing, session mgmt, time_ago, notification hook (package)
utils.py # Password hashing, session mgmt, time_ago, notification hook
models.py # Pydantic schemas
push.py # Web push crypto, VAPID keys, encrypt/send/register
routers/ # One file per domain (auth, feed, posts, push, ...)
@@ -52,40 +51,36 @@ devplacepy/
| Prefix | Purpose |
|--------|---------|
| `/` | Home page: marketing splash for guests, personalized home (welcome, feed shortcut, latest posts, news) for signed-in users. Does not redirect. Latest-posts section interleaves authors so no two consecutive posts share an author. |
| `/` | Home page: marketing splash for guests, personalized home (welcome, feed shortcut, latest posts, news) for signed-in users. Does not redirect. Latest-posts section shows at most two posts per author. |
| `/auth` | Signup, login, logout, forgot/reset password |
| `/feed` | Post feed with topic/tab filtering and free-text `search` (title, content, and author username) in the left panel (public). Each page interleaves authors so no two consecutive posts share an author. |
| `/feed` | Post feed with topic/tab filtering and free-text `search` (title and content) in the left panel (public). Each page shows at most two posts per author. |
| `/news` | Developer news listing, detail page with comments |
| `/posts` | Post detail, creation |
| `/gists` | Code gist listing, detail, creation, and editing; left panel offers language filtering and free-text `search` (title, description, and author username), public read |
| `/comments` | Comment creation, owner editing (`POST /comments/edit/{comment_uid}`), deletion |
| `/projects` | Project listing (left panel offers type filtering and free-text `search` over title, description, and author username), creation, owner editing (`POST /projects/edit/{slug}`), and per-project visibility toggles: `POST /projects/{slug}/private` (owner-only visibility) and `POST /projects/{slug}/readonly` (immutable files). A project hidden by a member stays visible to administrators, but a project hidden by an administrator is visible only to that owner administrator - other administrators cannot see it, its files, or its containers (web UI and REST API alike). The primary administrator (the first Admin account) is the single exception and retains full visibility |
| `/gists` | Code gist listing, detail, creation, and editing; left panel offers language filtering and free-text `search` (title and description), public read |
| `/comments` | Comment creation, deletion |
| `/projects` | Project listing (left panel offers type filtering and free-text `search` over title and description), creation, owner editing (`POST /projects/edit/{slug}`), and per-project visibility toggles: `POST /projects/{slug}/private` (owner-only visibility) and `POST /projects/{slug}/readonly` (immutable files) |
| `/projects/{slug}/files` | Per-project filesystem: directory and file CRUD, upload, inline editing, and line-range operations (`lines` read, `replace-lines`, `insert-lines`, `delete-lines`, `append`) for surgical edits to large text files (public read, owner write; all writes refused while the project is read-only) |
| `/zips` | Zip job status (`/zips/{uid}`) and archive download (`/zips/{uid}/download`); archives are queued via `/projects/{slug}/zip` and `/projects/{slug}/files/zip` |
| `/forks` | Fork job status (`/forks/{uid}`); forks are queued via `/projects/{slug}/fork`. Any signed-in user can fork a project they can view into a new project they own; once the job finishes the response carries the new project URL |
| `/tools` | Public developer tools. `/tools/seo` is **SEO Diagnostics**: audit any URL or sitemap and stream live progress over a websocket. Queue with `POST /tools/seo/run`, poll `GET /tools/seo/{uid}`, read the full report at `GET /tools/seo/{uid}/report`. `/tools/deepsearch` is **DeepSearch**: a multi-agent deep web researcher that crawls and indexes sources, synthesises a cited report with confidence scoring, and lets you chat over the gathered evidence. Queue with `POST /tools/deepsearch/run`, poll `GET /tools/deepsearch/{uid}`, read the report at `GET /tools/deepsearch/{uid}/session`, export at `/export.{md,json,pdf}`. `/tools/isslop` is the **AI Usage Analyzer**: classify a git repository or website as AI slop, sophisticated AI-assisted work or genuine human work. Queue with `POST /tools/isslop/run`, poll `GET /tools/isslop/{uid}` or the event trail at `GET /tools/isslop/{uid}/events`, read the report at `GET /tools/isslop/{uid}/report` (`.md` to download) and embed the SVG authenticity badge from `GET /tools/isslop/{uid}/badge.svg` |
| `/projects/{slug}/containers` | Admin per-project container manager: create and control container instances, all running the shared prebuilt `ppy` image (there is no in-app image building). Reachable from the project page via the admin-only **Containers** button |
| `/admin/containers` | Admin **Containers** manager: list, create, edit, and control container instances across projects, under strict per-user isolation: the primary administrator sees and manages every instance; every other administrator sees instances on public projects plus their own (instances attached to another user's private project are excluded entirely) and manages only the instances they own (created by them or attached to their own project) - all other rows are view-only. The list (`/admin/containers`) has inline start/stop/restart/terminal/edit/delete on each row and a create form (pick a project, optionally a run-as user, a boot language with a source editor, restart policy, start-on-boot, plus env/ports/limits/ingress). Each instance has a detail page (`/admin/containers/{uid}`) with lifecycle controls, live logs and metrics, an interactive terminal, schedules, ingress, workspace sync, and a status history, and an edit page (`/admin/containers/{uid}/edit`) |
| `/tools` | Public developer tools. `/tools/seo` is **SEO Diagnostics**: audit any URL or sitemap and stream live progress over a websocket. Queue with `POST /tools/seo/run`, poll `GET /tools/seo/{uid}`, read the full report at `GET /tools/seo/{uid}/report`. `/tools/deepsearch` is **DeepSearch**: a multi-agent deep web researcher that crawls and indexes sources, synthesises a cited report with confidence and gap analysis, and lets you chat over the gathered evidence. Queue with `POST /tools/deepsearch/run`, poll `GET /tools/deepsearch/{uid}`, read the report at `GET /tools/deepsearch/{uid}/session`, export at `/export.{md,json,pdf}` |
| `/projects/{slug}/containers` | Admin per-project container manager: Dockerfile CRUD with immutable versions, async image builds, and container instance creation. Reachable from the project page via the admin-only **Containers** button |
| `/admin/containers` | Admin **Containers** section: a list of every container instance across all projects (`/admin/containers`), each linking to a dedicated instance detail page (`/admin/containers/{uid}`) with lifecycle controls, live logs and metrics, an interactive terminal, schedules, ingress, and workspace sync |
| `/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 |
| `/profile` | Profile view, editing, and a public **Media** tab (`?tab=media`) showing every attachment a user uploaded, newest first |
| `/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/` |
| `/admin/trash` | Admin **Trash**: review, restore, and permanently purge soft-deleted content (posts, comments, gists, projects, news, project files, attachments) across the platform |
| `/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` | Direct messaging |
| `/votes` | Upvote/downvote on posts, comments, projects |
| `/reactions` | Emoji reactions on posts, comments, gists, projects |
| `/bookmarks` | Save/unsave content; `/bookmarks/saved` personal list |
| `/polls` | Vote on post-attached polls |
| `/follow` | Follow/unfollow users |
| `/block` | Block/unblock a user: hides all of their posts, comments and messages from you everywhere except their own profile, and stops them notifying you |
| `/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 |
| `/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) |
| `/bugs` | Bug tracker backed by Gitea: list/detail/comment/status, AI-enhanced filing |
| `/admin/services` | Background service management (start/stop, config, status, logs) |
| `/admin/bots` | Admin **Bot Monitor**: a live grid of the latest low-quality screenshot per running bot persona, each labelled with the bot username, persona, and current action, auto-refreshing |
| `/admin` | Admin panel (user management, news curation, settings) |
| `/docs` | Developer documentation site with a complete, interactive HTTP API reference |
| `/openai` | OpenAI-compatible LLM gateway service (`/openai/v1/chat/completions`, `/openai/v1/*`) |
@@ -99,55 +94,22 @@ Member progression is driven by activity and peer recognition.
- **Stars** are the net vote score (`upvotes - downvotes`) on a post, project, or gist. A member's total stars is the sum across all their content and is the basis for ranking.
- **XP and levels.** Members earn XP for contributing: posting (10), commenting (2), publishing a project (15) or gist (5), receiving an upvote (5), and gaining a follower (5). Each level requires 100 XP (`level = 1 + xp // 100`). The profile shows the current level and progress to the next.
- **Badges** are awarded once and never revoked, across several themed groups (First steps, Explorer, Engagement, Content, Community, Reputation, Dedication, Levels). They cover three kinds of achievement: **content and reputation milestones** (10/50/100 posts, 25/100/500 stars, 10/50/100 followers, comment and project and gist counts, following 10 people, 7/30/100-day activity streaks, reaching levels 5/10/25/50/100); **first-time feature use** (your first comment, project, gist, fork, archive download, SEO audit, DeepSearch, AI usage analysis, container, direct message, bookmark, reaction, star given, follow, upload, project file, issue, poll vote, profile customization, and first conversation with Devii); and **usage tiers** for several of those features (for example reading 1/5/15 documentation pages, or giving 50/250 stars). Each profile has a collapsible **Achievements** showcase that lists every badge grouped by theme, with earned ones highlighted and locked ones shown with their unlock condition, so there is always a next prize to chase.
- **Badges** are awarded once per milestone: first post/comment/project/gist, 10 posts (Prolific), 25 stars (Rising Star), 100 stars (Star Author), 10 followers (Popular), a 7-day activity streak (On Fire), and reaching levels 5 and 10. Badges render with an icon and description on the profile.
- **Leaderboard** (`/leaderboard`) ranks the top 50 members by total stars (single page, no pagination); a member's own rank is shown on their profile.
- **Contribution heatmap and streaks.** Each profile shows a 12-month activity heatmap and the current/longest daily streak, derived from post/comment/gist/project timestamps (no extra storage).
- **Social graph listings.** Each profile has Followers and Following tabs that paginate the follow graph (25 per page) and show a follow/unfollow control for each person. The same data is available as JSON at `GET /profile/{username}/followers` and `GET /profile/{username}/following`.
- **Media gallery.** Each profile has a public Media tab: a responsive, paginated grid of every attachment that user uploaded across posts, projects, gists, comments, messages, issues, and news, newest first. Images open in the shared lightbox. The owner can delete their own media and an admin can delete anyone's; deletion is a **soft delete** that hides the item everywhere (including its parent object) while preserving the file and the relation, so an admin can restore it from the **Media** trash at `/admin/media`.
- **Media gallery.** Each profile has a public Media tab: a responsive, paginated grid of every attachment that user uploaded across posts, projects, gists, comments, messages, bugs, and news, newest first. Images open in the shared lightbox. The owner can delete their own media and an admin can delete anyone's; deletion is a **soft delete** that hides the item everywhere (including its parent object) while preserving the file and the relation, so an admin can restore it from the **Media** trash at `/admin/media`.
- **Reward notifications** fire when a member levels up or earns a badge.
- **AI content correction.** Opt-in, default off. When a member enables it on their profile, the prose they author (post titles and bodies, project and gist titles and descriptions, comments, direct messages, and their bio) is automatically rewritten by the AI gateway according to a member-defined instruction, using the member's own API key for per-user attribution. A member chooses the apply mode: **in background** (default; content is saved exactly as written and corrected a moment later, so the write path is never slowed) or **synchronously** (the save waits for the correction so the stored result is corrected immediately). The rewrite is fail-soft (the original is kept on any error) and applies identically across the web UI, the REST and devRant APIs, and Devii. Code and source files are never corrected. In direct messages the correction is delivered live: the corrected message appears in the chat for both participants without a reload. Configure it on your profile or via the Devii `ai_correction_set` tool; the settings are saved at `POST /profile/{username}/ai-correction`. Successful correction calls accumulate per-user running totals - corrections, token counts, cost, and timing/performance (average latency, average speed in tokens per second, and total processing time) - shown on the profile page; token, call, and performance figures are visible to the member, while the dollar figures (total and average cost) are shown to administrators only.
- **AI modifier.** Enabled by default and applied synchronously by default. It works like AI content correction, except it runs **only** where the prose you author contains an inline `@ai <instruction>` directive: the configured prompt tells the model to execute that instruction and replace the marked part, removing the `@ai` marker. Text with no `@ai ...` directive is left exactly as written. It is **context-aware**: the model is given a grounding summary of who is asking (your username, role, level, stars, post count, rank, followers, and bio), the current date, and where the directive sits - the post a comment replies to, the conversation a direct message belongs to, the gist's language and code, and so on - so directives like `@ai answer the question above`, `@ai write my bio from my stats`, or `@ai reply to this` work. It uses your own API key for per-user attribution, is fail-soft (the original is kept on any error), and applies across the web UI, the REST and devRant APIs, and Devii, on the same prose fields as correction (posts, projects, gists, comments, direct messages, and your bio). Code and source files are never touched. In direct messages it runs live: typing `@ai <instruction>` in a message executes it and the resolved result appears in the chat for both participants without a reload. You can switch the apply mode to background or disable it on your profile or via the Devii `ai_modifier_set` tool; the settings are saved at `POST /profile/{username}/ai-modifier`. The default instruction is "Execute what is behind `@ai` (the prompt) and replace that part including `@ai`". Successful modifications accumulate per-user running totals - modifications, token counts, cost, and timing/performance (average latency, average speed in tokens per second, and total processing time) - shown on the profile page; token, call, and performance figures are visible to the member, while the dollar figures (total and average cost) are shown to administrators only.
- **Devii interactive widgets.** Administrators set the site default on the Devii service (`devii_interactions_default`, default on). Guests always use that default. Signed-in members inherit it until they override it on their profile or via the Devii `interactions_set` tool (`POST /profile/{username}/interactions`; owner or admin). When enabled, Devii may present decisions with channel-aware controls (`ui_prompt`); when disabled, it falls back to plain numbered menus.
Every AI gateway response (`/openai/v1/*`) also returns per-call `X-Gateway-*` headers with the full token breakdown and the dollar cost of that call, so any client can read its own usage.
## 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.
- **Plant.** Plant a software project (a shell script, Python script, web app, Go service, Rust engine, compiler, or kernel) in an empty plot for a coin cost. Higher-tier crops unlock as your farm level rises.
- **Build and harvest.** A planted crop builds over real time; when the build finishes, harvest it for coins and XP. Harvesting also awards site XP and the **Green Thumb** / **Master Farmer** badges.
- **Upgrade CI.** Spend coins to raise your CI tier (Local Build through Distributed Cache); each tier makes every build faster.
- **Buy plots.** Unlock more plots (up to twelve); each new plot costs more than the last.
- **Fertilize.** Spend coins on a growing build to halve its remaining time. The cost is priced against the build's realized harvest value, so fertilizing is a pure time-skip - it brings the harvest sooner but never returns more coins than it costs, at any prestige level.
- **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.
- **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.
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**.
## Engagement
- **Emoji reactions** - a fixed palette of reactions on posts, comments, gists, and projects, separate from voting and carrying no ranking weight.
- **Emoji shortcodes** - typing a `:name:` shortcode in any content (posts, comments, titles, project and gist descriptions, news, and direct messages) renders the matching emoji, using the full GitHub/Discord standard set (for example `:rocket:` becomes a rocket). Server-rendered and live content share one shortcode list; unknown names and shortcodes inside code are left untouched. Documented at `/docs/emoji-shortcodes`. This is distinct from the visual emoji-picker button in the composer, which inserts the literal emoji character.
- **Polls** - a post can carry a poll (question plus up to six options); results appear as live bars once the viewer votes, one vote per member. A poll can be attached when the post is created or added later by editing a post that has none.
- **Bookmarks** - save posts, gists, projects, and news to a personal list at `/bookmarks/saved`.
- **Private projects** - an owner can mark a project private so it is visible only to them (and administrators) and excluded from listings, profiles, search, the sitemap, and zip access. Set at creation or toggled later from the project page.
- **Read-only projects** - an owner can mark a project read-only, making its entire virtual filesystem immutable: every write, edit, line-edit, move, delete, and upload is refused from all paths (the web UI, the HTTP API, the Devii agent, and container workspace sync) until read-only is turned off. Devii may toggle read-only only after the user explicitly confirms.
XP awards are wired at the existing content-creation, vote, and follow hook points in the routers and centralized in `award_xp()` / `check_milestone_badges()` (`devplacepy/utils/`). Existing accounts have their XP and levels backfilled once from prior activity at startup (`init_db()`).
## Vibe coding (Alpha, admin only)
Build software by talking to an AI agent instead of typing every line. Create a project for storage, attach a container to it (the shared `ppy` image, your files mounted at `/app`), start it, and open a terminal. The whole flow is drivable conversationally through Devii. Inside every container three agents ship preinstalled and run on **your own API key**, so all AI usage is metered to your account: **DevPlace Code (`dpc`)**, a coding agent in the same class as Claude Code; **`botje.py`**, a plug-and-play DevPlace bot you can copy and customise; and **`pagent`**, a minimal zero-dependency agent. Each container is launched with `DEVPLACE_BASE_URL`, `DEVPLACE_OPENAI_URL`, `DEVPLACE_API_KEY`, `DEVPLACE_USER_UID`, `DEVPLACE_CONTAINER_NAME`, `DEVPLACE_CONTAINER_UID`, and `DEVPLACE_INGRESS_URL` already set. Publish a container port to a public URL at `/p/<slug>` by setting an `ingress_slug` and `ingress_port` (ask Devii to do it at create time). The feature is in **Alpha** and currently limited to administrators; the full walkthrough, including a tutorial that vibes a web app and puts it online, is at `/docs/getting-started-vibing.html`.
XP awards are wired at the existing content-creation, vote, and follow hook points in the routers and centralized in `award_xp()` / `check_milestone_badges()` (`devplacepy/utils.py`). Existing accounts have their XP and levels backfilled once from prior activity at startup (`init_db()`).
## Admin: Audit Log
@@ -164,13 +126,8 @@ The log is **administrator-only**. `/admin/audit-log` is a paginated, filterable
| `SECRET_KEY` | hardcoded fallback | Session signing key |
| `DEVPLACE_VAPID_SUB` | `mailto:retoor@molodetz.nl` | Contact address in the VAPID JWT `sub` claim |
| `DEVPLACE_INTERNAL_BASE_URL` | `http://localhost:10500` | Base URL the platform's own services dial for the AI gateway |
| `DEVPLACE_XMLRPC_PORT` | `10550` | Loopback port the forking XML-RPC bridge binds; the app and nginx reverse-proxy `/xmlrpc` to it |
| `DEVPLACE_XMLRPC_BIND` | `127.0.0.1` | Bind address for the XML-RPC bridge (loopback; the app and nginx are the intended front doors) |
| `DEVPLACE_STATIC_VERSION` | server boot unix timestamp | Cache-busting version stamped into every static asset URL (`/static/v<version>/...`). Set it at launch so multiple workers agree (the `prod` target and Docker image do this); leave unset in dev to refresh on each reload. See [Static asset caching](#static-asset-caching) |
| `DEEPSEEK_API_KEY` / `OPENROUTER_API_KEY` | unset | Upstream provider keys; migrated into the gateway settings on first boot |
| `DEVPLACE_PRESENCE_TIMEOUT_SECONDS` | `60` | Online-presence window: a user counts as online for this many seconds after their last activity. `last_seen` is refreshed by a throttled in-place update at most once per half this interval per worker (no per-load inserts, no data growth) |
| `DEVPLACE_PRESENCE_ONLINE_LIMIT` | `30` | Maximum avatars shown in the feed's live "Online now" panel (ordered alphabetically by username) |
| `DEVPLACE_PRESENCE_ONLINE_MARGIN_SECONDS` | `20` | Grace margin before an online user drops to offline (hysteresis): online at the timeout, offline only after timeout + this. Prevents online/offline flicker for users hovering at the boundary |
### Runtime settings
@@ -189,7 +146,6 @@ Operational behavior is tunable live from `/admin/settings` (stored in `site_set
| `customization_enabled` | `1` | When `0`, no user CSS/JS customization is injected on any page |
| `customization_js_enabled` | `1` | When `0`, user custom CSS is still served but custom JavaScript is suppressed |
| `audit_log_retention_days` | `90` | Audit rows older than this are pruned daily by the Audit retention service; `0` disables pruning |
| `extra_head` | empty | Raw HTML injected into every page `<head>` (custom `<style>`, `<script>`, `<link>`, meta tags, or analytics snippet). Site-wide, trusted-admin input, not sanitized |
Numeric values are floored to safe minimums so an invalid entry cannot lock out writes or stall services. Consumers read via `get_setting`/`get_int_setting`, which fall back to these defaults when a row is absent.
@@ -197,7 +153,7 @@ Numeric values are floored to safe minimums so an invalid entry cannot lock out
The website uses a `session` cookie. For automation, every page and action also
accepts three header-based methods, resolved centrally in `get_current_user`
(`utils/`) so they work everywhere with no per-route changes:
(`utils.py`) so they work everywhere with no per-route changes:
- **API key** - `X-API-KEY: <key>`
- **Bearer** - `Authorization: Bearer <key>`
@@ -223,7 +179,7 @@ Every endpoint that renders a page or returns a redirect also speaks JSON, so an
website does is automatable from the same URLs. A request gets JSON when it sends
`Accept: application/json` or `Content-Type: application/json`; a normal browser navigation
(`Accept: text/html`) always gets HTML, so existing behaviour is unchanged (the legacy
`X-Requested-With: fetch` AJAX header still drives the four engagement endpoints only). JSON responses are defined by Pydantic models in `devplacepy/schemas/` and built
`X-Requested-With: fetch` AJAX header still drives the four engagement endpoints only). JSON responses are defined by Pydantic models in `devplacepy/schemas.py` and built
from the same context the templates use (sensitive user fields like `email`/`api_key`/
`password_hash` are never exposed). Page GETs return the page payload; form actions return a
uniform envelope `{ "ok": true, "redirect": "…", "data": {…} }`; errors return
@@ -231,80 +187,11 @@ uniform envelope `{ "ok": true, "redirect": "…", "data": {…} }`; errors retu
JSON → `401`, non-admin → `403`). The core lives in `devplacepy/responses.py`
(`wants_json`, `respond`, `action_result`). Full details: `/docs/conventions.html`.
Every response carries an `X-Response-Time: <ms>ms` header (set by the outermost `response_timing`
middleware, the full request total), and every rendered HTML page shows that server render time as a
small fixed indicator in the bottom-left corner.
```bash
curl -H "Accept: application/json" https://your-host/feed
curl -H "Accept: application/json" -X POST -d "content=hi&title=T&topic=devlog" https://your-host/posts/create
```
## XML-RPC bridge
The full REST API is also reachable over XML-RPC at `/xmlrpc`. A standalone forking XML-RPC
server (`XmlrpcService`, supervised like any other background service, listening on the
loopback `DEVPLACE_XMLRPC_PORT`, default `10550`) generates one method per documented endpoint
directly from the API reference, so every capability is callable over XML-RPC with no extra
wiring. The app reverse-proxies `/xmlrpc` to it (`routers/xmlrpc.py`); in production nginx
forwards `/xmlrpc` as well. Method names mirror the endpoint id with dots (`posts.create`,
`feed.list`, `profile.update`); each method takes one struct of named parameters and returns
the same JSON payload the REST endpoint would. Authenticate with `api_key` inside the struct,
an `X-API-KEY` / `Bearer` header, or HTTP Basic via a credentialed URL
(`http://username:password@host/xmlrpc`). Full XML-RPC introspection (`system.listMethods`,
`system.methodHelp`, `system.methodSignature`) and batching (`system.multicall`) are
supported; REST errors surface as XML-RPC faults whose `faultCode` is the HTTP status. Full
details and copy-paste Python examples (including a bot that replies to mentions) live at
`/docs/xmlrpc.html`; runnable versions are in `examples/xmlrpc/`.
```python
import xmlrpc.client
proxy = xmlrpc.client.ServerProxy("https://devplace.net/xmlrpc", allow_none=True)
proxy.system.listMethods()
proxy.posts.create({"content": "hi from xml-rpc", "api_key": "YOUR_API_KEY"})
```
## devRant compatibility API
A second REST surface under `/api` mirrors the public devRant API shape so legacy devRant
clients can run against DevPlace data. Rants map to posts, comments and votes map to the
native engagement layer, and devRant integer IDs map directly onto the auto-increment `id`
column every table already carries (`posts.id`, `comments.id`, `users.id`). Authentication
follows the devRant model: `POST /api/users/auth-token` with `username`/`password` returns an
`auth_token` struct (`token_id`, `token_key`, `user_id`) backed by the `devrant_tokens` table;
every subsequent request carries that triple as query params or body fields (form or JSON both
accepted). Writes go through the same audited helpers as the native UI, so XP, notifications,
audit, and soft-delete all apply.
| Method | Path | Purpose |
|--------|------|---------|
| POST | `/api/users/auth-token` | Log in (by username or email), returns `auth_token` |
| POST | `/api/users` | Register a new account |
| GET | `/api/get-user-id?username=` | Resolve a username to its integer id |
| GET | `/api/users/{id}` | User profile (rants + comments) |
| POST | `/api/users/me/edit-profile` | Update bio/location/github/website |
| DELETE | `/api/users/me` | Deactivate account |
| GET | `/api/devrant/rants?sort=&limit=&skip=` | Rant feed (`recent`/`top`/`algo`) |
| POST | `/api/devrant/rants` | Post a rant (`rant`, comma-separated `tags`) |
| GET | `/api/devrant/rants/{id}` | One rant with its comments |
| POST | `/api/devrant/rants/{id}` | Edit a rant (owner only) |
| DELETE | `/api/devrant/rants/{id}` | Delete a rant (owner or admin) |
| POST | `/api/devrant/rants/{id}/vote` | Vote (`1`/`-1`/`0`) |
| POST | `/api/devrant/rants/{id}/{favorite\|unfavorite}` | Bookmark toggle |
| POST | `/api/devrant/rants/{id}/comments` | Comment on a rant |
| GET | `/api/devrant/search?term=` | Search rants |
| GET / POST / DELETE | `/api/comments/{id}` | Read / edit / delete a comment |
| POST | `/api/comments/{id}/vote` | Vote on a comment |
| GET / DELETE | `/api/users/me/notif-feed` | Notification feed / mark all read |
| GET | `/api/avatars/u/{seed}.png` | PNG avatar rendered from the user's avatar seed (the regenerated `avatar_seed`, or the username when unset) |
devRant `tags` round-trip verbatim via a `tags` column on `posts`; `profile_skills` is derived
from the user bio (DevPlace has no separate skills field); avatars are real PNGs rendered from
the local multiavatar engine. The surface is toggled by the `devrant_api_enabled` setting
(default on). The endpoints are served at devRant's path shape; pointing a hard-coded client at
this server is a DNS/reverse-proxy concern handled at the infrastructure layer.
## Documentation site
`/docs` serves a server-rendered docs site with a feed-style left sidebar
@@ -336,7 +223,7 @@ pre-filled. Each panel has a response-format selector (defaulting to JSON) that
**Expected** tab showing the modeled response, and a **Live response** tab for the real
result. To document a new endpoint, add an entry to `docs_api.py`; the page, sidebar link,
examples, runner, and expected-response sample are generated automatically. FastAPI's
built-in Swagger is moved to `/swagger` so `/docs` belongs to this site. The raw schema is at `/openapi.json`.
built-in Swagger is moved to `/swagger` so `/docs` belongs to this site. ReDoc is at `/redoc` and the raw schema at `/openapi.json`.
Operator pages are admin-only: `/docs/admin.html` (user/news/settings administration) and
`/docs/services.html` (Background Services) are hidden from the sidebar and return 404 for
@@ -353,17 +240,17 @@ and its full configuration are documented automatically - including future servi
- **`ConfigField`** - declarative parameter spec (type, default, validation, secret) a service uses to declare its editable settings
- **`BaseService`** - abstract class with a reconciling run loop that honors the persisted `enabled`/command/interval state, plus `config_fields`, `get_config()`, `describe()`, and a log buffer
- **`ServiceManager`** - singleton: `register`, `describe_all`, `set_enabled`, `send_command`, `save_config`, `supervise`, `shutdown_all`
- **`NewsService`** - a fully automatic, zero-maintenance news pipeline: fetches news from `news.app.molodetz.nl/api`, cleans each article, fetches and perceptually compares the images to reject placeholders and detect uniqueness, grades each one deterministically, reformats every valid article into clean Markdown (paragraphs, headings, lists) with the AI so the source wall of text reads as a proper article, and auto-rotates the best articles to Featured and the landing page. Its AI spend is metered from the gateway response headers and reported on the admin Services page (calls, tokens, total cost, and per-call averages)
- **`BotsService`** - Playwright fleet of AI personas that browse and interact with a DevPlace instance, with live cost/usage metrics and a live screenshot monitor at `/admin/bots` (opt-in; install the `bots` extra)
- **`GatewayService`** - OpenAI-compatible LLM gateway at `/openai/v1/*`, forwarding to DeepSeek (default) with optional vision augmentation; the single point of truth for AI that every other service routes through (enabled by default). Every chat request is made date-aware by injecting the current date in EU `DD/MM/YYYY` format into the system message when it contains no date already (date only, never time, to keep upstream prompt caching effective), and an admin-configurable system preamble (`gateway_system_preamble`) can be prepended ahead of the client's system message on every call. **Provider and model routing** (admin **Gateway** page, `/admin/gateway`) maps any number of requested model names onto named upstream providers and target models, each with its own pricing economy (input, output and cache-hit/cache-miss rates per million tokens) and an optional vision model that describes image content before forwarding, so text and vision models are merged transparently. Unmapped requests fall through to the default upstream unchanged, so existing clients are unaffected
- **`NewsService`** - fetches news from `news.app.molodetz.nl/api`, grades with AI, stores articles >= threshold
- **`BotsService`** - Playwright fleet of AI personas that browse and interact with a DevPlace instance, with live cost/usage metrics (opt-in; install the `bots` extra)
- **`GatewayService`** - OpenAI-compatible LLM gateway at `/openai/v1/*`, forwarding to DeepSeek (default) with optional vision augmentation; the single point of truth for AI that every other service routes through (enabled by default). Every chat request is made date-aware by injecting the current date in EU `DD/MM/YYYY` format into the system message when it contains no date already (date only, never time, to keep upstream prompt caching effective), and an admin-configurable system preamble (`gateway_system_preamble`) can be prepended ahead of the client's system message on every call
- **`JobService` / `ZipService` / `ForkService`** - generic async job framework (`services/jobs/`) for heavy, blocking work run off the request path; `ZipService` builds project zip archives in a subprocess, `ForkService` copies a project into a new project owned by the forking user
- **`ContainerService`** - the admin container manager (`services/containers/`): a reconciling supervisor for container instances, all running one shared prebuilt image
### Container manager (admin only)
`services/containers/` runs supervised container instances from the web UI, the HTTP API, and Devii. It drives the `docker` CLI via async subprocesses behind a pluggable `Backend` interface (a `DockerCliBackend` plus a `FakeBackend` for tests). **There is no in-app image building.** Every instance runs ONE shared prebuilt image, `ppy:latest` (override `DEVPLACE_CONTAINER_IMAGE`), built once with **`make ppy`** from `ppy.Dockerfile`: a Python + Playwright base with a broad set of common libraries preinstalled, the `pravda` (uid 1000) user, the sudo superclone, and `pagent` at `/usr/bin/pagent.py` all baked in. Creating an instance is then an instant `docker run` (it fails fast with a clear error if the `ppy` image has not been built yet). `ContainerService` reconciles desired instance state against `docker ps` each tick (containers are labeled `devplace.instance=<uid>`, so orphans are reaped and no state is lost), applies restart policies, fires cron/interval/one-time schedules, and samples metrics. The container's `/app` is bind-mounted to a persistent project workspace (materialized from the project files) and **stays in sync automatically**: the manager runs a bidirectional, newer-wins sync between the project files and the workspace on every start and roughly once a minute while running, so edits made inside the container and edits made in the project file editor converge without manual intervention (the sync only ever creates or overwrites the older copy of a file, never deletes one; a read-only project is export-only). Projects that need extra packages use runtime `pip install` (pravda owns the site-packages, no sudo needed) or `apt install` directly (the `pravda` user runs `apt`/`dpkg` through a fakeroot wrapper, so system packages install without root) or add the library to `ppy.Dockerfile` and rerun `make ppy`. Each instance can run a **boot script** in Python or Bash (written into the workspace and run on launch) or a plain boot command, can be set to **start automatically** whenever the container service starts, and can be configured to **run as** a chosen DevPlace user - which only selects whose identity and API key are injected into the container (the container always runs as the unprivileged `pravda` user). Every status change is recorded and shown as a status history on the instance page. A running instance can be **published** with an `ingress_slug`, making its service reachable (HTTP and WebSocket) at `/p/<slug>` through DevPlace. The manager is reached two ways: the admin **Containers** sidebar entry (`/admin/containers`) lists, creates, edits, and controls every instance across all projects and opens a dedicated detail page per instance, and each project page carries an admin-only **Containers** button to its own instance manager.
`services/containers/` runs supervised container instances from the web UI, the HTTP API, and Devii. It drives the `docker` CLI via async subprocesses behind a pluggable `Backend` interface (a `DockerCliBackend` plus a `FakeBackend` for tests). **There is no in-app image building.** Every instance runs ONE shared prebuilt image, `ppy:latest` (override `DEVPLACE_CONTAINER_IMAGE`), built once with **`make ppy`** from `ppy.Dockerfile`: a Python + Playwright base with a broad set of common libraries preinstalled, the `pravda` (uid 1000) user, the sudo superclone, and `pagent` at `/usr/bin/pagent.py` all baked in. Creating an instance is then an instant `docker run` (it fails fast with a clear error if the `ppy` image has not been built yet). `ContainerService` reconciles desired instance state against `docker ps` each tick (containers are labeled `devplace.instance=<uid>`, so orphans are reaped and no state is lost), applies restart policies, fires cron/interval/one-time schedules, and samples metrics. The container's `/app` is bind-mounted to a persistent project workspace (materialized from the project files) and can be synced back; projects that need extra packages use runtime `pip install` (pravda owns the site-packages, no sudo needed) or `apt install` directly (the `pravda` user runs `apt`/`dpkg` through a fakeroot wrapper, so system packages install without root) or add the library to `ppy.Dockerfile` and rerun `make ppy`. A running instance can be **published** with an `ingress_slug`, making its service reachable (HTTP and WebSocket) at `/p/<slug>` through DevPlace. The manager is reached two ways: the admin **Containers** sidebar entry (`/admin/containers`) lists every instance across all projects and opens a dedicated detail page per instance, and each project page carries an admin-only **Containers** button to its own instance manager.
**Security:** this requires mounting the Docker socket, which grants host root. Every run, exec, lifecycle, and schedule operation is administrator-only; `--privileged` is never used and all docker calls are argument-list subprocesses. Containers are additionally isolated per user: an instance is managed only by its owner (the administrator who created it, or the owner of its project) and by the primary administrator, who alone sees and manages every instance including those on private projects; other administrators get a read-only view of instances on public projects and none of another user's private-project instances (exec, terminals, schedules, edits, and lifecycle actions are all refused and audited as denied). The service is disabled by default; an admin enables **Containers** on `/admin/services`. CLI: `devplace containers list | reconcile | prune | prune-builds | gc-workspaces` (`prune-builds` is a one-time cleanup that removes legacy per-project images and the old dockerfiles/builds tables).
**Security:** this requires mounting the Docker socket, which grants host root. Every run, exec, lifecycle, and schedule operation is administrator-only; `--privileged` is never used and all docker calls are argument-list subprocesses. The service is disabled by default; an admin enables **Containers** on `/admin/services`. CLI: `devplace containers list | reconcile | prune | prune-builds | gc-workspaces` (`prune-builds` is a one-time cleanup that removes legacy per-project images and the old dockerfiles/builds tables).
**Runtime data** (container workspaces and zip archives) lives in `DEVPLACE_DATA_DIR` (default `data/`), **outside the package and never served via `/static`**. The docker daemon must be able to bind-mount the data dir for `/app`.
@@ -377,13 +264,7 @@ and its full configuration are documented automatically - including future servi
`SeoService` powers the public **Tools -> SEO Diagnostics** auditor. It runs a headless-browser (Playwright) crawl of a single URL or a sitemap (capped pages) in a subprocess and runs a broad battery of checks across eleven categories: crawlability and indexing (status, redirects, HTTPS/HSTS, canonical, robots/meta-robots, sitemap, URL hygiene, mixed content), on-page meta and content (title, description, headings, language, charset, viewport, favicon, content depth), links, structured data and rich results (JSON-LD validity and required properties, microdata/RDFa), social cards (Open Graph, Twitter), Core Web Vitals and performance (LCP, CLS, FCP, TTFB, page weight, requests, DOM size, compression, caching, image optimisation, console errors), mobile and accessibility (responsive layout, tap targets, image alt, form labels), security headers, and AI/LLM-search readiness (server-rendered-vs-JS content parity, `llms.txt`, semantic HTML). It produces a weighted score and grade with per-category subscores and a recommendation for every finding. Progress streams live over `WS /tools/seo/{uid}/ws`; the full report is available at `/tools/seo/{uid}/report` (HTML or JSON). CLI: `devplace seo prune` / `devplace seo clear`. Playwright is a core dependency; `make install` fetches the Chromium browser.
`SeoMetaService` is a separate AI subservice that generates a clean, search-optimised title, description and short keyword list for every published post, project, gist, news article and issue, entirely off the request path so it never slows the web server. The work is queued whenever content is created, edited or published; until the AI value is ready a plain-content default (built from the markdown-stripped text and clamped to safe lengths) fills the fields, so a page's metadata is **always populated, never empty**. The service uses the built-in internal AI gateway and meters its own AI cost and statistics in a dedicated usage table, surfaced together with its live task pipeline on the **Admin -> Services** page. This release also fixes the on-page metadata: the `<meta name="description">` is now stripped of markdown markup (it previously leaked `#`, `**` and `[](...)` from the raw body), a `<meta name="keywords">` tag is emitted (a short honest list, not stuffed), and social-card image dimensions and alt text are added. CLI: `devplace seo-meta prune` / `devplace seo-meta clear` (job rows only; the generated metadata persists).
`DeepsearchService` powers the public **Tools -> DeepSearch** researcher. Given a single research question it plans a set of diverse web search queries, interleaves their results so every angle contributes sources, and crawls the most relevant pages in a subprocess (concurrent fetches, plain HTTP first with a headless-browser fallback for JavaScript-heavy pages, PDF documents streamed and text-extracted, every URL SSRF-guarded). Social sites that block bots (X, YouTube, Reddit and similar) contribute the readable text supplied by the search engine, so their content is not lost to a login wall. A readability-grade extractor isolates the main article content of each page (navigation, cookie banners and footers are discarded), and the configured depth follows the most relevant in-page links for deeper coverage. Content is de-duplicated and indexed into a per-session ChromaDB vector collection (embeddings via the AI gateway with a local fallback). A chain of agents (report writer, findings extractor, linker) then synthesises a thorough cited markdown report grounded on the passages retrieved from that index, with key findings, a confidence score and source diversity; if synthesis fails the report is clearly marked as degraded instead of silently shipping raw source material. Progress streams live over `WS /tools/deepsearch/{uid}/ws`; the report is at `/tools/deepsearch/{uid}/session` (HTML or JSON) and can be exported as Markdown, JSON or PDF. A grounded chat over the session's evidence runs at `WS /tools/deepsearch/{uid}/chat` using hybrid retrieval (vector + keyword/BM25). Runs can be paused, resumed or cancelled. A cross-session URL cache avoids re-fetching pages seen by earlier runs. CLI: `devplace deepsearch prune` / `devplace deepsearch clear`. ChromaDB, weasyprint, pypdf and Playwright are core dependencies.
`IsslopService` powers the public **Tools -> AI Usage Analyzer**, which classifies a git repository or website as AI slop, sophisticated AI-assisted work or genuine human work. Sources are acquired in an isolated subprocess (git URLs are probed with `git ls-remote` and shallow-cloned with a 3 GB guard; websites render in a stealth headless browser with an HTTP fallback, bounded by depth, file and byte caps), inventoried with exclusion rules, and scored by a multi-signal static engine (twenty-one detector families, 126 signal types across an origin axis and a quality-deficit axis). For a live website, the analyzer also opens the home page in a headless browser and inspects what actually renders: it fingerprints AI website-builder platforms directly (Lovable, Bolt.new, Framer and others) and checks the page's real computed styles, layout and build artifacts, not just its file contents. Representative files receive an AI review pass and images an AI-generation review through the internal gateway (model `molodetz`, internal key); the static engine stays authoritative when the gateway is unreachable. Every pipeline step is persisted as an ordered event trail and streamed live over the pub/sub topic `public.isslop.{uid}`. The verdict is an A-F authenticity grade with a human/AI split and one of five categories (`ai-slop`, `sophisticated-ai`, `human-clean`, `human-messy`, `uncertain`), published as a persistent report with an embeddable SVG badge. Members keep their analysis history on their account; guest history is session-bound and claimed by the account on the first signed-in visit. Admin settings (private-host allowance, AI/image review toggles, image cap, retention, concurrency) live on `/admin/services`. CLI: `devplace isslop analyze <url>` / `devplace isslop prune` / `devplace isslop clear`. Playwright plus playwright-stealth back the website crawler.
`BackupService` powers the admin **Admin -> Backups** dashboard, an enterprise-grade backup system that runs entirely as asynchronous jobs so it never impacts the running server. An administrator can back up one of four targets: the **database** (a consistent SQLite snapshot of the main database and the Devii task/lesson databases, taken with SQLite's online backup API so it is consistent under WAL), **uploads** (every attachment and project file), **keys and config** (VAPID keys), or the **full data directory** (database snapshot, uploads, and keys in one archive, excluding regenerable staging, locks, caches, and container workspaces). Each backup is compressed to a `tar.gz` in a stdlib subprocess off the request path and recorded with its size, file count, and a SHA-256 checksum. Archives live under `data/backups/` (sharded on the random uuid tail) and are served only through `/admin/backups/{uid}/download`, which is restricted to the **primary administrator** - the first user created with the Admin role. Every other administrator receives a 403 from the endpoint and sees the Download button disabled with the tooltip `Not available`; creating, running, deleting, and scheduling backups remain available to all administrators. The dashboard reports detailed storage usage - the size and file count of every major data area, the total data-directory footprint, the total size and count of stored backups, and disk usage (total, used, free, percent), computed in a worker thread and cached briefly so the page never blocks. Backups can be **scheduled** (CRUD) on an interval or 5-field cron expression with a `keep_last` rotation count that prunes older backups of the same schedule; the service evaluates schedules only on the lock-owning worker so each fires exactly once. Backup archives are permanent operational artifacts: job retention only removes the tracking row, never the archive, which is deleted only by an administrator, by schedule rotation, or via the CLI. CLI: `devplace backups list` / `devplace backups run <target>` / `devplace backups prune` / `devplace backups clear`. Devii tools: `backups_overview`, `backup_run`, `backup_status`, `backup_delete`, `backup_schedule_create`, `backup_schedule_delete` (all admin-only). The service creates and stores backups but does not restore them into a live server; restore is a documented manual procedure (stop the server, unpack the archive over the data directory, verify the checksum, restart).
`DeepsearchService` powers the public **Tools -> DeepSearch** researcher. Given a single research question it plans a set of diverse web search queries, crawls and reads the most relevant sources in a subprocess (plain HTTP first, headless-browser fallback for JavaScript-heavy pages, every URL SSRF-guarded), de-duplicates content, and indexes everything into a per-session ChromaDB vector collection (embeddings via the AI gateway with a local fallback). A chain of agents (summarizer, critic, linker) then synthesises a cited report with a confidence score, source diversity and explicit gap analysis. Progress streams live over `WS /tools/deepsearch/{uid}/ws`; the report is at `/tools/deepsearch/{uid}/session` (HTML or JSON) and can be exported as Markdown, JSON or PDF. A grounded chat over the session's evidence runs at `WS /tools/deepsearch/{uid}/chat` using hybrid retrieval (vector + keyword/BM25). Runs can be paused, resumed or cancelled. A cross-session URL cache avoids re-fetching pages seen by earlier runs. CLI: `devplace deepsearch prune` / `devplace deepsearch clear`. ChromaDB, weasyprint and Playwright are core dependencies.
### Adding a service
@@ -395,16 +276,7 @@ The service appears at `/admin/services` with controls, a generated config form,
### News service
A fully automatic, zero-maintenance import pipeline. It fetches news from a configurable API and stores ALL articles in the `news` table (nothing is silently skipped). For each article it:
- **Cleans the text** - strips HTML and removes Reddit boilerplate (`submitted by /u/...`, `[link]`, `[comments]`, `[N comments]`) and collapses whitespace, before grading and before storage.
- **Fetches and perceptually compares the images** - up to five candidate images per article are downloaded through an SSRF-guarded client, decoded, and perceptually hashed. Images that are too small or fail to load are placeholders, and an image that appears across two or more different articles (a shared logo or stock placeholder) is rejected for all of them. An article with at least one genuinely unique image keeps it as its primary image.
- **Grades deterministically** - an AI model rates the cleaned article 1-10. A reliability gate forces a short, shouting, bodyless, or url-less article to draft. The final score adds a bonus for a unique image and a penalty for thin content, and that final score drives publishing.
- **Auto-promotes** - articles at or above the threshold are published; the strongest published articles with a unique image are marked **Featured**, and the service keeps the best of those on the landing page, rotating them automatically.
Admin can manually publish/draft, toggle Featured, toggle landing-page appearance, and delete; a manual Featured or landing toggle **locks** that article so the service no longer auto-manages it.
The Featured badge and the numeric Grade are editorial signals shown only to administrators. On the public news pages (listing, article, and landing-page cards) members and guests see only the article, its source, and its time; the badges remain visible to administrators and in the admin Manage News area.
Fetches news from a configurable API, grades each article via AI, stores ALL articles in the `news` table (nothing is silently skipped). Articles with grade >= the configurable threshold are auto-published; the rest go to draft. Admin can manually publish/draft, toggle for landing page appearance, and delete. Images are extracted from article URLs.
Configuration on the Services tab (`/admin/services`):
@@ -423,7 +295,7 @@ CLI: `devplace news clear` - delete all news from local database.
### Bots service
A Playwright-driven fleet of AI personas (`devplacepy/services/bot/`) that browse and interact with a DevPlace instance: posting, commenting, voting, reacting, creating gists/projects, filing issues, following, and messaging. It is the former standalone `dpbot.py`, refactored into a package and managed entirely from the Services tab. Disabled by default.
A Playwright-driven fleet of AI personas (`devplacepy/services/bot/`) that browse and interact with a DevPlace instance: posting, commenting, voting, reacting, creating gists/projects, filing bugs, following, and messaging. It is the former standalone `dpbot.py`, refactored into a package and managed entirely from the Services tab. Disabled by default.
Each persona has its own voice and its own interests: post titles are written in the persona's voice rather than copied from the source headline, news topics and post categories are weighted by personality (so different personas react to different stories), shared code snippets pass a non-triviality quality gate, and bots discuss each other's posts in threaded conversations rather than reacting in isolation.
@@ -448,11 +320,11 @@ Configuration on the Services tab:
| `bot_news_api` | `https://news.app.molodetz.nl/api` | Article source |
| `bot_model` | `molodetz` | Generic model name; the gateway maps it to the real model |
| `bot_api_key` | internal key | LLM key (defaults to the auto-generated gateway internal key) |
| `bot_input_cost_per_1m` / `bot_output_cost_per_1m` | `0.14` / `0.28` | Fallback token pricing; used only when the LLM endpoint returns no gateway cost headers. Live cost is read from the gateway's authoritative `X-Gateway-Cost-USD` per-call header |
| `bot_input_cost_per_1m` / `bot_output_cost_per_1m` | `0.27` / `1.10` | Token pricing for live cost tracking |
| `bot_max_per_article` | `2` | How many bots may post about one article, each from a different angle |
| `bot_article_ttl_days` | `7` | How long an article stays covered before it can be posted again |
| `bot_gist_min_lines` | `6` | Reject generated snippets shorter than this many non-empty lines |
| `bot_action_pause_min_seconds` / `bot_action_pause_max_seconds` | `5` / `45` | Idle pause window a bot takes after each action |
| `bot_action_pause_min_seconds` / `bot_action_pause_max_seconds` | `5` / `30` | Idle pause window a bot takes after each action |
| `bot_break_scale` | `1.0` | Multiplier on between-session breaks (below 1 = more active and costlier) |
| `bot_ai_decisions` | disabled | Let each bot's AI-generated identity decide every action via the LLM instead of fixed probabilities (one decision call per page); see `aibots.md` |
| `bot_decision_temperature` | `0.4` | Sampling temperature for the per-page decision call |
@@ -512,7 +384,6 @@ Configuration on the Services tab:
| `gateway_price_cache_hit_per_m` / `_cache_miss_per_m` / `_output_per_m` | 0.0028 / 0.14 / 0.28 | Chat cost per 1M tokens, used when the upstream returns no native cost (DeepSeek) |
| `gateway_vision_price_input_per_m` / `_output_per_m` | 0 / 0 | Vision cost per 1M tokens, used only when the vision upstream returns no native cost |
| `gateway_embed_price_input_per_m` | 0.01 | Embeddings cost per 1M input tokens, used only when the embeddings upstream returns no native cost |
| `gateway_rsearch_cost_per_call` | 0.0 | Flat cost attributed to each external `rsearch` call (web search / AI answer / chat / image describe), recorded under backend `rsearch` so external AI spend appears in AI usage |
| `gateway_max_retries` / `gateway_retry_backoff_ms` | 2 / 250 | Retry attempts and linear backoff on timeout, connection error, or upstream 5xx |
| `gateway_circuit_threshold` / `gateway_circuit_cooldown_seconds` | 5 / 30 | Consecutive failures before the circuit breaker opens, and its cooldown |
| `gateway_usage_retention_hours` | 720 | How long per-call usage rows are kept before pruning (30 days) |
@@ -530,11 +401,8 @@ shows live request/error/latency/vision-call counters.
Every upstream call (chat, vision, and passthrough) is recorded to `gateway_usage_ledger`
with its tokens, cost, latency breakdown, status, and caller. Cost is taken from the
upstream native `cost` field when present (OpenRouter) and computed from the configured
per-million pricing otherwise (DeepSeek, which reports no cost). External `rsearch` web
tools do not pass through the gateway upstream, so each call is also recorded to the same
ledger under backend `rsearch` (zero tokens, the flat `gateway_rsearch_cost_per_call`),
keeping the ledger a complete record of platform AI spend. The admin **AI usage**
page (`/admin/ai-usage`) reports per-hour, 24h, and **all-time** metrics - request volume and throughput,
per-million pricing otherwise (DeepSeek, which reports no cost). The admin **AI usage**
page (`/admin/ai-usage`) reports per-hour and 24h metrics - request volume and throughput,
token usage with averages and percentiles (p50/p90/p95/p99), latency (upstream round-trip,
gateway overhead, semaphore queue wait, connection establishment), error rates by category,
cost (per model, per caller, input vs output, projected monthly burn, caching savings),
@@ -571,26 +439,13 @@ Spend is capped per owner over a rolling 24 hours. Every turn appends a row to
`devii_usage_ledger` (the authoritative source for the cap - the in-memory cost tracker is
display-only) and an audit row to `devii_turns`. The cap is checked before each turn.
**Reminders and scheduled tasks.** Ask Devii to remind you of something ("remind me to go
upstairs in 40 seconds", "every weekday at 9am post the news"), and it schedules the work
instead of doing it immediately. A scheduled task stores a self-contained prompt that a fresh
agent runs when it fires - once after a delay or at an absolute time, on a repeating interval,
or on a cron expression. Reminders are **timezone-aware**: your browser's timezone is sent to
Devii and used to interpret wall-clock times you give ("3pm" means 3pm where you are),
converting them to UTC for storage. They are **persistent**: tasks live in `devii_tasks` and
are run by the background service, so a queued reminder survives a server restart and fires
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).
Configuration on the Services tab:
| Parameter | Default | Purpose |
|-----------|---------|---------|
| `devii_ai_url` | `http://localhost:10500/openai/v1/chat/completions` | OpenAI-compatible reasoning endpoint (defaults to the internal gateway) |
| `devii_ai_url` | `https://openai.app.molodetz.nl/v1/chat/completions` | OpenAI-compatible reasoning endpoint |
| `devii_ai_model` | `molodetz` | Model name |
| `devii_ai_key` | env fallback (`DEVII_AI_KEY`), then the gateway internal key | AI API key |
| `devii_ai_key` | env fallback (`DEVII_AI_KEY`) | AI API key |
| `devii_base_url` | this instance's origin | Platform Devii drives via each user's API key |
| `devii_plan_required` / `devii_verify_required` | on / on | Enforce plan-first and verify-after-mutation |
| `devii_max_iterations` | `40` | Tool-loop iterations per turn |
@@ -604,8 +459,6 @@ Configuration on the Services tab:
| `devii_rsearch_enabled` | on | Enable the external web search tools (`rsearch_*`) |
| `devii_rsearch_url` | `https://rsearch.app.molodetz.nl` | Base URL of the web search service those tools call |
| `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 |
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
@@ -614,7 +467,7 @@ response (non-2xx is returned rather than raised so API errors are readable). Bo
guard (private and loopback addresses are refused) and a size cap. `attach_url` downloads a public
URL on the server and stores it as a real attachment through the same pipeline as a direct upload,
returning a uid Devii then passes in `attachment_uids` when creating a post, project, gist, comment,
issue, or message - so a user can ask Devii to attach an image straight from the internet. Devii also
bug, or message - so a user can ask Devii to attach an image straight from the internet. Devii also
has external **web search** tools - `rsearch` (web/image search), `rsearch_answer` (a web-grounded
AI answer with sources), `rsearch_chat` (direct AI chat), and `rsearch_describe_image` (vision).
These reach an external public service rather than this platform, so platform tools are always
@@ -622,18 +475,6 @@ preferred; Devii uses them only when the user explicitly asks to search the web
source. They are gated by `devii_rsearch_enabled` and the service URL is configurable via
`devii_rsearch_url`.
A signed-in user's Devii can also connect to their **own email** over IMAP and SMTP. The user
configures one or more accounts conversationally (`email_account_set` stores a connection under a
label - host, port, username, password, with sensible defaults of IMAP 993 over SSL and SMTP 587
with STARTTLS), and Devii can then list folders, list and search messages, read a full message with
its attachments, mark messages read or flagged, move messages between folders, delete messages, and
send mail. Credentials are stored per user and never shown back (the password is only reported as
set or not), the mail server is SSRF-guarded against private and loopback addresses, sending is an
explicit action Devii confirms with the user first, and deleting a message or removing a saved
account is confirmation-gated. The tools are gated by `devii_email_enabled` and time out per
`devii_email_timeout`. Email is configured only through Devii (no separate settings page) and is
available to signed-in users, not guests.
A `devii` console script ships the same agent as an interactive terminal:
```bash
@@ -642,36 +483,6 @@ devii --api-key <your DevPlace api_key> --base-url https://your-host
devii -p "List my unread notifications as a bullet list." # one-shot
```
### Devii on Telegram
`TelegramService` (`devplacepy/services/telegram/`) puts Devii on Telegram. A user pairs
their Telegram account by requesting a four digit code from their profile (valid one hour by
default), then sends that code to the bot; once paired, chatting with the bot talks to their
own Devii exactly like the web terminal, with markdown replies, a typing indicator, live
message editing instead of message spam, and image understanding (a sent photo is read by the
gateway vision model). The Telegram thread is an isolated conversation but shares the user's
Devii memory, tools, and the same rolling 24 hour spend cap.
The service is **off by default** (not every deployment has a bot token) and is started,
stopped, configured, and monitored from `/admin/services` like any other background service.
Its operational log streams live on the service detail page and is never written to the
database. The Telegram long-poller runs as a supervised subprocess so it stays isolated from
the web workers; because only the background-service lock owner runs the service, there is
always exactly one poller (Telegram rejects concurrent polling per token).
Devii also gains a `telegram_send` tool (only usable by a signed-in, paired user) so it can
push a message to the user's Telegram from a turn or a scheduled task - the basis for future
Telegram notifications.
Configuration on the Services tab:
| Parameter | Default | Purpose |
|-----------|---------|---------|
| `telegram_bot_token` | (secret) | Bot token from @BotFather; required to start |
| `telegram_poll_timeout` | `25` | getUpdates long-poll hold time (seconds) |
| `telegram_code_ttl_minutes` | `60` | Pairing code lifetime |
| `telegram_max_concurrent_turns` | `8` | Upper bound on Devii turns across all chats |
### Site customization (per-user CSS/JS)
Each user can reshape the site to taste by injecting their own **CSS** (look) and
@@ -723,11 +534,9 @@ installable Progressive Web App. Push uses only standard libraries (`cryptograph
### Events
Every event flows through a single funnel - `create_notification()` in `utils/` -
which delivers on three independent channels, in-app, web push and Telegram, each gated by the
recipient's preferences (see "Configurable notifications" below). Whenever the in-app
channel delivers, the recipient's open browser also raises a live, click-through toast
in real time, bridged onto the in-process pub/sub bus by a lock-owner relay:
Every event flows through a single funnel - `create_notification()` in `utils.py` -
which delivers on two independent channels, in-app and web push, each gated by the
recipient's preferences (see "Configurable notifications" below):
| Event | Recipient |
|-------|-----------|
@@ -738,7 +547,7 @@ in real time, bridged onto the in-process pub/sub bus by a lock-owner relay:
| Upvote on your content | content owner |
| New follower | followed user |
| Badge earned / level-up | the user |
| Issue-tracker update | reporter / admins |
| Bug-tracker update | reporter / admins |
`create_notification` schedules delivery as a fire-and-forget async task, so a dead
subscription or push-service error never blocks the triggering request. Delivery
@@ -746,34 +555,21 @@ subscription or push-service error never blocks the triggering request. Delivery
(legacy `aesgcm` content encoding), and POSTs to each endpoint; subscriptions that
return `404`/`410` are soft-deleted.
A notification is also **marked read automatically when you open the page that shows its
content** - viewing a post clears its comment, reply, upvote and mention notifications;
opening a conversation clears its direct-message notifications; visiting a profile clears
the matching follow, badge and level notifications; and the issue, reminder and farm-raid
notifications clear on their respective pages. You no longer have to dismiss each one by
hand after reading the content it points to.
### Configurable notifications
Every notification type can be turned on or off per channel, per user. The **Notifications**
tab on a profile page (`/profile/{username}?tab=notifications`, visible to the profile owner
and to admins) shows one row per type with three checkboxes - **In-app**, **Push** and
**Telegram** - saved individually as you toggle them (`POST /profile/{username}/notifications`).
A "Reset to defaults" button clears all of a user's overrides
(`POST /profile/{username}/notifications/reset`). The Telegram column is disabled until the
user pairs Telegram from the profile Telegram panel; once paired, opting a type in delivers
that notification to the user's Telegram chat.
and to admins) shows one row per type with two checkboxes - **In-app** and **Push** - saved
individually as you toggle them (`POST /profile/{username}/notifications`). A "Reset to
defaults" button clears all of a user's overrides (`POST /profile/{username}/notifications/reset`).
Defaults are opt-out for in-app and push (a type/channel a user never touched is enabled) and
opt-in for Telegram (every type is off by default). Admins set the platform-wide default for
each type/channel on `/admin/notifications` (`POST /admin/notifications`); a default applies
only to users who have not made an explicit choice. Resolution is: user override, else admin
default, else the channel fallback. Preferences are stored in the `notification_preferences`
table (per `user_uid` + `notification_type`, soft-deletable) and enforced inside
`create_notification()`: the in-app row is written only when the in-app channel is enabled,
`push.notify_user` is scheduled only when the push channel is enabled, and a Telegram message
is queued (to the `telegram_outbox`, drained on the service-lock owner where the bot runs)
only when the Telegram channel is enabled and the user is paired.
Defaults are opt-out: a type/channel a user never touched is enabled. Admins set the
platform-wide default for each type/channel on `/admin/notifications`
(`POST /admin/notifications`); a default applies only to users who have not made an explicit
choice. Resolution is: user override, else admin default, else on. Preferences are stored in
the `notification_preferences` table (per `user_uid` + `notification_type`, soft-deletable)
and enforced inside `create_notification()`: the in-app row is written only when the in-app
channel is enabled, and `push.notify_user` is scheduled only when the push channel is enabled.
### VAPID keys
@@ -794,8 +590,8 @@ every page load. `PushManager.js` owns registration, subscription, and the opt-i
### PWA
`manifest.json` (192/512 and maskable icons) and `service-worker.js` make the app
installable via the browser's native install affordance. The service worker uses a
`manifest.json` (192/512 and maskable icons), `service-worker.js`, and an install
button (`PwaInstaller.js`) make the app installable. The service worker uses a
network-first strategy for navigations and falls back to `static/offline.html` when
offline. Installation requires a secure origin (HTTPS, or `localhost` for development).
@@ -804,6 +600,7 @@ offline. Installation requires a secure origin (HTTPS, or `localhost` for develo
| `devplacepy/push.py` | VAPID keys, payload encryption, send, register |
| `devplacepy/routers/push.py` | `/push.json`, `/service-worker.js`, `/manifest.json` |
| `static/js/PushManager.js` | Service-worker registration + subscribe + opt-in UI |
| `static/js/PwaInstaller.js` | `beforeinstallprompt` capture + install button |
| `static/service-worker.js` | Receives push, shows notification, offline fallback |
| `static/manifest.json` | PWA manifest (icons, display, theme) |
| `static/offline.html` | Offline fallback page |
@@ -831,25 +628,9 @@ Removing a record is a **soft delete**, not a physical one: it stamps `deleted_a
A member may delete only their own content, but an **administrator may delete any member's** post, comment, gist, project, project file, or attachment - the owner-or-admin check lives on each delete endpoint, so it applies equally to the web UI and to the Devii assistant (which acts purely through the platform API as the signed-in user). When an admin's Devii is asked to delete something it requires explicit confirmation before each deletion, and the result is the same soft delete, restorable from Trash.
### Database API (`/dbapi`, primary administrator only)
The **primary administrator** (the oldest Admin account, the same identity that may download backups) authenticated by session or their API key can **read** any table through a single, safe API; members, guests, every other administrator, and internal/service callers (the gateway internal key is not accepted) all get `403`. The database API is **strictly read-only** - it can never insert, update, replace, delete, or restore data in any way.
- **Read per table:** `GET /dbapi/{table}` (filtered, searchable, keyset pagination) and `GET /dbapi/{table}/{key}/{value}`. There are no write endpoints; deny-listed tables (sessions, password resets) are never exposed.
- **`query()` is read-only:** `POST /dbapi/query` runs a single validated SELECT and returns rows. Every query is parsed (sqlglot), classified, and dry-run with `EXPLAIN` on a read-only connection before execution; non-SELECT statements (INSERT/UPDATE/DELETE/DDL) are refused, and a SELECT with no WHERE/JOIN/LIMIT is flagged as suspicious.
- **Ask in plain language:** `POST /dbapi/nl` turns a question such as *"all users registered longer than three days"* into a validated SELECT (auto-adding `deleted_at IS NULL` for soft-delete tables), using the platform AI gateway and re-prompting until the SQL validates; pass `execute=true` to also run it read-only.
- **Async:** `POST /dbapi/query/async` runs a heavy read query off the request path and streams progress over `WS /dbapi/query/{uid}/ws`.
- The Devii assistant exposes the same read-only capability to the **primary administrator only** (list, get, query, and natural-language SELECT); the database tools are added to the tool list only for that user, so every other administrator's Devii does not see them and is unaware the database API exists. It cannot change data through the database API.
### Pub/Sub bus (`/pubsub`)
A database-free publish/subscribe bus for live updates without polling. Clients connect to `WS /pubsub/ws` to subscribe to topics (with `foo.*` wildcards) and publish messages; backends and administrators can also publish over `POST /pubsub/publish`. Users may use their own `user.{uid}.*` namespace and subscribe to shared `public.*` topics; administrators and internal services may use any topic. The browser client is available as `app.pubsub.subscribe(topic, cb)` / `app.pubsub.publish(topic, data)`. The bus is in-memory and best-effort by design.
Two background services bridge persisted state onto the bus so the interface updates without per-client polling: the **Notification relay** pushes new in-app notifications as live toasts and refreshes each viewer's unread notification and message badges instantly, and the **Live view relay** pushes the admin live views (container list and instances, bot fleet, background services, AI usage, backups) to whichever administrators are watching, computing a snapshot only for views that currently have subscribers. Both run on the single service-lock owner and degrade to a low-frequency HTTP poll if the bus is unavailable.
## Testing
- **1959 tests** split into three tiers under `tests/`: `unit/` (pure in-process), `api/` (HTTP integration against the live server), and `e2e/` (Playwright browser)
- **932 tests** split into three tiers under `tests/`: `unit/` (pure in-process), `api/` (HTTP integration against the live server), and `e2e/` (Playwright browser)
- **A directory tree that mirrors the path.** api/e2e follow the endpoint path - each route segment is a directory and the last segment is the file, `{param}` segments dropped (`GET /admin/ai-usage` -> `tests/e2e/admin/aiusage.py`, `GET /projects/{slug}/files/lines` -> `tests/api/projects/files/lines.py`). unit mirrors the source module path (`devplacepy/services/audit/store.py` -> `tests/unit/services/audit/store.py`). Run one tier with `make test-unit` / `make test-api` / `make test-e2e`
- Playwright (NOT pytest-playwright plugin - conflicts, uninstall it)
- Runs serially, one test at a time, in a single process (`make test`); the suite drives one uvicorn subprocess on port 10501 with its own temp database and `DEVPLACE_DATA_DIR`
@@ -915,12 +696,12 @@ The Container Manager drives the host Docker daemon, so `make docker-build`/`mak
What the overlay (`docker-compose.containers.yml`) changes:
- **Docker CLI in the image** via the `INSTALL_DOCKER_CLI=true` build arg (the base image stays lean).
- **Docker socket** mounted into the app container. This grants the app **root on the host** - every run/exec/lifecycle operation is admin-only, `--privileged` is never used, and all docker calls are argument-list subprocesses, but treat the whole feature as trusted-admins-only.
- **Docker socket** mounted into the app container. This grants the app **root on the host** - every build/run/exec is admin-only, `--privileged` is never used, and all docker calls are argument-list subprocesses, but treat the whole feature as trusted-admins-only.
- **Socket permissions:** the app runs as UID 1000, so the overlay adds the host `docker` group via `group_add`. `make` reads the gid straight from `/var/run/docker.sock` (`stat -c '%g'`), the exact group that owns the socket.
- **Data dir at a consistent path (critical).** When the app (in its container) runs `docker run -v <path>:/app`, the daemon resolves `<path>` against the **host**, not the app container. So the workspace/data dir must be mounted at the **same absolute path** on host and in the container - the make targets set `DEVPLACE_DATA_DIR` to the project's `./data` (an absolute host path) and mount it at that identical path on both sides. (Build contexts go through the docker API as a tarball, so they can stay in the container's temp dir - only the `/app` bind mount needs path consistency.)
- **Ingress reach:** published container ports live on the **host**, so the overlay sets `DEVPLACE_CONTAINER_PROXY_HOST=host.docker.internal` (with `extra_hosts: host-gateway`) so the `/p/<slug>` proxy can reach them. On a bare-metal `make prod` deploy the app is already on the host, so the default `127.0.0.1` works and no overlay is needed (just install the docker CLI and run the services).
Then build the shared `ppy` image once with `make ppy` and enable **Containers** on `/admin/services`. There is no in-app image building; every instance runs that one prebuilt image.
Then enable **Container builds** and **Containers** on `/admin/services`. Builds default to `--network=host` (configurable on the service) so pip can reach PyPI; set the build network to empty to use the docker default.
### nginx specifics
@@ -929,16 +710,6 @@ Then build the shared `ppy` image once with `make ppy` and enable **Containers**
- **Upload size:** `NGINX_MAX_BODY_SIZE` (default `50m`) must be **>= the admin-configurable `max_upload_size_mb`** (Admin -> Settings), or large uploads are rejected with HTTP 413 before reaching the app.
- **Micro-cache:** off by default; enable with `NGINX_CACHE_ENABLED=true`.
### Client IP behind a proxy
The app resolves the real client address via `utils.client_ip(request)`, which reads `X-Real-IP` first, then the leftmost hop of `X-Forwarded-For`, then falls back to `request.client.host`. This is the single source used by the rate limiter, the audit log (`actor_ip`), and guest-scoped job ownership, so every logged IP is the actual visitor rather than the proxy loopback. The bundled nginx config already sets both headers on every location. When fronting the app with **Caddy**, `X-Forwarded-For` is set automatically, but `X-Real-IP` is not; add an unspoofable real-IP header inside the `reverse_proxy` block for the strongest attribution:
```
reverse_proxy localhost:10500 {
header_up X-Real-IP {remote_host}
}
```
### Static asset caching
Static assets (CSS, JS, vendored libraries) are served with a **one-year immutable cache** for the best Lighthouse "efficient cache policy" score, while deploys still take effect immediately. Every app-owned static URL carries a boot-time version path segment, `/static/v<timestamp>/...`, where `<timestamp>` is the unix time the server process started (`config.STATIC_VERSION`). A restart changes the segment, so every asset URL changes and returning browsers refetch on their next page load - no cache purge, no hashing build step.
@@ -947,7 +718,7 @@ The version sits in the **path**, not a query string, because the frontend is un
### Bare-metal alternative
`make prod` runs the same app without containers (`uvicorn ... --workers $(WEB_WORKERS) --proxy-headers`, where `WEB_WORKERS` defaults to `nproc`) from the project root, sharing the identical database and files. Note it binds port 10500, so it conflicts with the Docker front door on the same port - run one, or set a different `PORT`.
`make prod` runs the same app without containers (`uvicorn ... --workers 2 --proxy-headers`) from the project root, sharing the identical database and files. Note it binds port 10500, so it conflicts with the Docker front door on the same port - run one, or set a different `PORT`.
### Multi-worker safety
@@ -970,7 +741,7 @@ Changes are promoted through automated DTAP streets: Development (`make dev`), T
2. Validate each touched language (Python compiles/imports, JS parses, CSS and HTML balance)
3. `make test` - run all tests (fail-fast)
4. Add tests in the matching tier and endpoint file (`tests/{unit,api,e2e}/<endpoint>.py`) for new functionality
5. Update the relevant nested `CLAUDE.md` and `README.md` if new conventions were introduced
5. Update `AGENTS.md` and `README.md` if new conventions were introduced
## License
+14 -178
View File
@@ -10,7 +10,6 @@ from urllib.parse import urlparse
from PIL import Image
from io import BytesIO
import httpx
from devplacepy import stealth
from devplacepy.database import get_table, db, get_setting
from devplacepy.config import UPLOADS_DIR, ATTACHMENTS_DIR
from devplacepy.utils import generate_uid
@@ -50,52 +49,6 @@ ALLOWED_UPLOAD_TYPES = {
".js": "text/javascript",
".css": "text/css",
".md": "text/markdown",
".wav": "audio/wav",
".flac": "audio/flac",
".ogg": "audio/ogg",
".aac": "audio/aac",
".wma": "audio/x-ms-wma",
".m4a": "audio/mp4",
".avi": "video/x-msvideo",
".mkv": "video/x-matroska",
".flv": "video/x-flv",
".wmv": "video/x-ms-wmv",
".3gp": "video/3gpp",
".csv": "text/csv",
".doc": "application/msword",
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".xls": "application/vnd.ms-excel",
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
".ppt": "application/vnd.ms-powerpoint",
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
".odt": "application/vnd.oasis.opendocument.text",
".rtf": "application/rtf",
".json": "application/json",
".xml": "application/xml",
".yaml": "text/yaml",
".yml": "text/yaml",
".toml": "text/x-toml",
".sh": "text/x-sh",
".bat": "text/x-bat",
".ts": "text/typescript",
".java": "text/x-java",
".cpp": "text/x-c++",
".c": "text/x-c",
".h": "text/x-c-header",
".rb": "text/x-ruby",
".go": "text/x-go",
".rs": "text/x-rust",
".sql": "text/x-sql",
".php": "text/x-php",
".swift": "text/x-swift",
".kt": "text/x-kotlin",
".cfg": "text/x-config",
".ini": "text/x-config",
".log": "text/plain",
".tar": "application/x-tar",
".gz": "application/gzip",
".rar": "application/vnd.rar",
".7z": "application/x-7z-compressed",
}
MIME_TO_EXT = {
@@ -115,49 +68,6 @@ MIME_TO_EXT = {
"audio/mpeg": ".mp3",
"text/plain": ".txt",
"text/markdown": ".md",
"audio/wav": ".wav",
"audio/flac": ".flac",
"audio/ogg": ".ogg",
"audio/aac": ".aac",
"audio/x-ms-wma": ".wma",
"audio/mp4": ".m4a",
"video/x-msvideo": ".avi",
"video/x-matroska": ".mkv",
"video/x-flv": ".flv",
"video/x-ms-wmv": ".wmv",
"video/3gpp": ".3gp",
"text/csv": ".csv",
"application/msword": ".doc",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx",
"application/vnd.ms-excel": ".xls",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx",
"application/vnd.ms-powerpoint": ".ppt",
"application/vnd.openxmlformats-officedocument.presentationml.presentation": ".pptx",
"application/vnd.oasis.opendocument.text": ".odt",
"application/rtf": ".rtf",
"application/json": ".json",
"application/xml": ".xml",
"text/yaml": ".yaml",
"text/x-toml": ".toml",
"text/x-sh": ".sh",
"text/x-bat": ".bat",
"text/typescript": ".ts",
"text/x-java": ".java",
"text/x-c++": ".cpp",
"text/x-c": ".c",
"text/x-c-header": ".h",
"text/x-ruby": ".rb",
"text/x-go": ".go",
"text/x-rust": ".rs",
"text/x-sql": ".sql",
"text/x-php": ".php",
"text/x-swift": ".swift",
"text/x-kotlin": ".kt",
"text/x-config": ".cfg",
"application/x-tar": ".tar",
"application/gzip": ".gz",
"application/vnd.rar": ".rar",
"application/x-7z-compressed": ".7z",
}
FILE_ICONS = {
@@ -196,17 +106,15 @@ def _get_max_upload_bytes():
return int(get_setting("max_upload_size_mb", "10")) * 1024 * 1024
WILDCARD_TOKENS = {"*", ".*", "*.*"}
def allowed_extensions():
raw = get_setting("allowed_file_types", "").strip()
if not raw:
return set(ALLOWED_UPLOAD_TYPES)
tokens = {part.strip().lower() for part in raw.split(",") if part.strip()}
if tokens & WILDCARD_TOKENS:
return set(ALLOWED_UPLOAD_TYPES)
return {token if token.startswith(".") else f".{token}" for token in tokens}
if raw:
return {
ext if ext.startswith(".") else f".{ext}"
for ext in (part.strip().lower() for part in raw.split(","))
if ext
}
return set(ALLOWED_UPLOAD_TYPES)
def is_extension_allowed(ext):
@@ -296,8 +204,6 @@ def store_attachment(file_bytes, original_filename, user_uid):
if ext not in (".gif",):
thumbnail = _generate_thumbnail(file_bytes, file_dir / f"{uid}_thumb.jpg")
is_audio = mime.startswith("audio/")
get_table("attachments").insert(
{
"uid": uid,
@@ -313,7 +219,6 @@ def store_attachment(file_bytes, original_filename, user_uid):
"image_height": image_height,
"has_thumbnail": 1 if thumbnail else 0,
"thumbnail_name": thumbnail,
"gitea_asset_id": None,
"created_at": datetime.now(timezone.utc).isoformat(),
"deleted_at": None,
"deleted_by": None,
@@ -331,7 +236,6 @@ def store_attachment(file_bytes, original_filename, user_uid):
"has_thumbnail": thumbnail is not None,
"is_image": is_image,
"is_video": mime.startswith("video/"),
"is_audio": is_audio,
}
@@ -389,7 +293,7 @@ async def fetch_remote_file(url, filename=None):
await _guard_public_url(url)
max_bytes = _get_max_upload_bytes()
try:
async with stealth.stealth_async_client(
async with httpx.AsyncClient(
follow_redirects=True,
timeout=REMOTE_FETCH_TIMEOUT,
headers={"User-Agent": REMOTE_FETCH_USER_AGENT},
@@ -444,78 +348,14 @@ def link_attachments(uids, target_type, target_uid):
return
placeholders = ",".join(f":p{i}" for i in range(len(flat)))
params = {f"p{i}": uid for i, uid in enumerate(flat)}
with db:
db.query(
f"UPDATE attachments SET target_type=:tt, target_uid=:tu WHERE uid IN ({placeholders})",
tt=target_type,
tu=target_uid,
**params,
)
def set_gitea_asset_id(uid, asset_id):
get_table("attachments").update(
{"uid": uid, "gitea_asset_id": int(asset_id)}, ["uid"]
db.query(
f"UPDATE attachments SET target_type=:tt, target_uid=:tu WHERE uid IN ({placeholders})",
tt=target_type,
tu=target_uid,
**params,
)
async def mirror_attachment_to_gitea(uid):
from devplacepy.services.gitea import runtime
from devplacepy.services.gitea.client import GiteaError
row = get_table("attachments").find_one(uid=uid, deleted_at=None)
if not row:
return None
target_type = row.get("target_type", "")
target_uid = row.get("target_uid", "")
if target_type not in ("issue", "issue_comment") or not target_uid:
return None
path = ATTACHMENTS_DIR / row.get("directory", "") / row.get("stored_name", "")
try:
data = path.read_bytes()
except OSError as exc:
logger.warning("Cannot read attachment %s for Gitea mirror: %s", uid, exc)
return None
filename = row.get("original_filename") or row.get("stored_name") or "file"
mime = row.get("mime_type") or "application/octet-stream"
client = runtime.get_client()
try:
if target_type == "issue":
asset = await client.create_issue_asset(
int(target_uid), filename, data, mime
)
else:
asset = await client.create_comment_asset(
int(target_uid), filename, data, mime
)
except (GiteaError, ValueError) as exc:
logger.warning("Gitea asset mirror failed for %s: %s", uid, exc)
return None
asset_id = int(asset.get("id", 0) or 0)
if asset_id:
set_gitea_asset_id(uid, asset_id)
return asset_id
async def remove_gitea_asset(row):
from devplacepy.services.gitea import runtime
from devplacepy.services.gitea.client import GiteaError
asset_id = int(row.get("gitea_asset_id") or 0)
target_type = row.get("target_type", "")
target_uid = row.get("target_uid", "")
if not asset_id or not target_uid:
return
client = runtime.get_client()
try:
if target_type == "issue":
await client.delete_issue_asset(int(target_uid), asset_id)
elif target_type == "issue_comment":
await client.delete_comment_asset(int(target_uid), asset_id)
except (GiteaError, ValueError) as exc:
logger.warning("Gitea asset delete failed for %s: %s", row.get("uid"), exc)
def _unlink_attachment_files(row):
stored_name = row.get("stored_name", "")
directory = row.get("directory", "")
@@ -618,8 +458,7 @@ def delete_attachments_for(target_type, target_uids):
for row in rows:
_unlink_attachment_files(row)
ids = ",".join(str(row["id"]) for row in rows)
with db:
db.query(f"DELETE FROM attachments WHERE id IN ({ids})")
db.query(f"DELETE FROM attachments WHERE id IN ({ids})")
def get_attachments(target_type, target_uid):
@@ -681,11 +520,8 @@ def _row_to_attachment(row):
"has_thumbnail": bool(row.get("has_thumbnail")),
"is_image": row.get("mime_type", "").startswith("image/"),
"is_video": row.get("mime_type", "").startswith("video/"),
"is_audio": row.get("mime_type", "").startswith("audio/"),
"target_type": row.get("target_type", ""),
"target_uid": row.get("target_uid", ""),
"user_uid": row.get("user_uid", ""),
"gitea_asset_id": row.get("gitea_asset_id") or None,
"created_at": row.get("created_at", ""),
}
-6
View File
@@ -9,12 +9,6 @@ def avatar_url(style: str, seed: str, size: int = 128) -> str:
return f"/avatar/{style}/{seed}?size={size}"
def avatar_seed(user) -> str:
if not user:
return ""
return user.get("avatar_seed") or user.get("username") or ""
def generate_avatar_svg(seed: str) -> str:
try:
from multiavatar.multiavatar import multiavatar
-33
View File
@@ -1,33 +0,0 @@
# retoor <retoor@molodetz.nl>
from io import BytesIO
from PIL import Image
def enforce_rgba_png(file_bytes: bytes) -> bytes:
img = Image.open(BytesIO(file_bytes)).convert("RGBA")
width, height = img.size
if width > 1 and height > 1:
corner = img.getpixel((0, 0))
if len(corner) == 4 and corner[3] == 255:
bg = corner[:3]
data = img.getdata()
cleaned = []
for pixel in data:
if pixel[:3] == bg:
cleaned.append((pixel[0], pixel[1], pixel[2], 0))
else:
cleaned.append(pixel)
img.putdata(cleaned)
buf = BytesIO()
img.save(buf, format="PNG")
return buf.getvalue()
def resize_award_png(source: bytes, size: int) -> bytes:
img = Image.open(BytesIO(source)).convert("RGBA")
img = img.resize((size, size), Image.LANCZOS)
buf = BytesIO()
img.save(buf, format="PNG")
return buf.getvalue()
+5 -5
View File
@@ -10,7 +10,7 @@ class TTLCache:
self.max_size = max_size
self._store = OrderedDict()
def get(self, key: str):
def get(self, key):
entry = self._store.get(key)
if entry is None:
return None
@@ -21,19 +21,19 @@ class TTLCache:
self._store.move_to_end(key)
return value
def set(self, key: str, value) -> None:
def set(self, key, value):
self._store[key] = (value, time.time() + self.ttl)
self._store.move_to_end(key)
if self.max_size and len(self._store) > self.max_size:
self._store.popitem(last=False)
def pop(self, key: str) -> None:
def pop(self, key):
self._store.pop(key, None)
def clear(self) -> None:
def clear(self):
self._store.clear()
def items(self) -> list:
def items(self):
now = time.time()
return [
(key, value) for key, (value, expiry) in self._store.items() if now < expiry
+817
View File
@@ -0,0 +1,817 @@
# retoor <retoor@molodetz.nl>
import argparse
import sys
from devplacepy.database import get_table
from devplacepy.utils import strip_html
def _audit_cli(event_key, summary, metadata=None, target_type=None, target_uid=None, target_label=None, links=None):
from devplacepy.services.audit import record as audit
audit.record_system(
event_key,
actor_kind="cli",
actor_role="system",
origin="cli",
target_type=target_type,
target_uid=target_uid,
target_label=target_label,
summary=summary,
metadata=metadata,
links=links,
)
def cmd_role_get(args):
users = get_table("users")
user = users.find_one(username=args.username)
if not user:
print(f"User '{args.username}' not found")
sys.exit(1)
print(user.get("role", "member").lower())
def cmd_role_set(args):
role = args.role.lower()
if role not in ("member", "admin"):
print("Role must be 'member' or 'admin'")
sys.exit(1)
users = get_table("users")
user = users.find_one(username=args.username)
if not user:
print(f"User '{args.username}' not found")
sys.exit(1)
old_role = user.get("role")
users.update({"uid": user["uid"], "role": role.capitalize()}, ["uid"])
from devplacepy.services.audit import record as audit
_audit_cli(
"cli.role.set",
f"CLI set role of user {args.username} from {old_role} to {role.capitalize()}",
metadata={"old": old_role, "new": role.capitalize()},
target_type="user",
target_uid=user["uid"],
target_label=args.username,
links=[audit.target("user", user["uid"], args.username)],
)
print(f"User '{args.username}' role set to '{role}'")
def cmd_apikey_get(args):
users = get_table("users")
user = users.find_one(username=args.username)
if not user:
print(f"User '{args.username}' not found")
sys.exit(1)
print(user.get("api_key", "") or "")
def cmd_apikey_reset(args):
from devplacepy.utils import generate_uid, clear_user_cache
users = get_table("users")
user = users.find_one(username=args.username)
if not user:
print(f"User '{args.username}' not found")
sys.exit(1)
new_key = generate_uid()
users.update({"uid": user["uid"], "api_key": new_key}, ["uid"])
clear_user_cache(user["uid"])
from devplacepy.services.audit import record as audit
_audit_cli(
"cli.apikey.reset",
f"CLI regenerated the API key of user {args.username}",
target_type="user",
target_uid=user["uid"],
target_label=args.username,
links=[audit.target("user", user["uid"], args.username)],
)
print(new_key)
def cmd_apikey_backfill(args):
from devplacepy.database import backfill_api_keys
updated = backfill_api_keys()
_audit_cli(
"cli.apikey.backfill",
f"CLI backfilled API keys for {updated} users",
metadata={"count": updated},
)
print(f"Assigned API keys to {updated} user(s) without one")
def cmd_devii_reset_quota(args):
from devplacepy.database import db
table_name = "devii_usage_ledger"
if table_name not in db.tables:
print(f"Table '{table_name}' does not exist, nothing to reset")
return
table = db[table_name]
if args.all:
count = table.count()
table.delete()
_audit_cli("cli.devii.quota.reset", "CLI reset AI quota (all)", metadata={"scope": "all", "rows_removed": count})
print(f"Reset all AI quotas ({count} ledger rows deleted)")
return
if args.guests:
count = table.count(owner_kind="guest")
table.delete(owner_kind="guest")
_audit_cli("cli.devii.quota.reset", "CLI reset AI quota (guests)", metadata={"scope": "guests", "rows_removed": count})
print(f"Reset all guest AI quotas ({count} ledger rows deleted)")
return
if not args.username:
print("Provide a username, or --guests, or --all")
sys.exit(1)
user = get_table("users").find_one(username=args.username)
if not user:
print(f"User '{args.username}' not found")
sys.exit(1)
count = table.count(owner_kind="user", owner_id=user["uid"])
table.delete(owner_kind="user", owner_id=user["uid"])
from devplacepy.services.audit import record as audit
_audit_cli(
"cli.devii.quota.reset",
f"CLI reset AI quota for {args.username}",
metadata={"scope": "user", "rows_removed": count},
target_type="user",
target_uid=user["uid"],
target_label=args.username,
links=[audit.target("user", user["uid"], args.username)],
)
print(f"Reset AI quota for '{args.username}' ({count} ledger rows deleted)")
def cmd_news_clear(args):
from devplacepy.database import db
deleted = {}
for table in ("news", "news_images", "news_sync"):
if table in db.tables:
count = db[table].count()
db[table].delete()
deleted[table] = count
print(f"Deleted {count} rows from '{table}'")
else:
print(f"Table '{table}' does not exist, skipping")
_audit_cli("cli.news.clear", "CLI cleared all news data", metadata={"deleted": deleted})
print("News data cleared")
def cmd_news_sanitize(args):
from devplacepy.database import db
if "news" not in db.tables:
print("News table does not exist")
return
news_table = db["news"]
updated = 0
for row in news_table.all():
desc = (strip_html(row.get("description", "") or ""))[:5000]
content = (strip_html(row.get("content", "") or ""))[:10000]
if desc != row.get("description", "") or content != row.get("content", ""):
news_table.update(
{"id": row["id"], "description": desc, "content": content}, ["id"]
)
updated += 1
_audit_cli("cli.news.sanitize", f"CLI sanitized {updated} news articles", metadata={"count": updated})
print(f"Sanitized {updated} news article(s)")
def cmd_attachments_prune(args):
from datetime import datetime, timezone, timedelta
from devplacepy.database import db
from devplacepy.attachments import delete_attachment
if "attachments" not in db.tables:
print("Attachments table does not exist")
return
cutoff = (datetime.now(timezone.utc) - timedelta(hours=args.hours)).isoformat()
orphans = [
att
for att in db["attachments"].find(target_type="", target_uid="")
if att.get("created_at", "") < cutoff
]
for att in orphans:
delete_attachment(att["uid"])
_audit_cli(
"cli.attachments.prune",
f"CLI pruned {len(orphans)} orphan attachments",
metadata={"count": len(orphans), "hours": args.hours},
)
print(f"Pruned {len(orphans)} orphan attachment(s) older than {args.hours}h")
def _remove_zip_artifacts(job):
import shutil
from pathlib import Path
from devplacepy.services.jobs.zip_service import 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)
def cmd_zips_prune(args):
from datetime import datetime, timezone
from devplacepy.services.jobs import queue
now = datetime.now(timezone.utc)
removed = 0
for job in queue.list_jobs(kind="zip", status=queue.DONE):
expires_at = job.get("expires_at")
if not expires_at:
continue
try:
expiry = datetime.fromisoformat(expires_at)
except (ValueError, TypeError):
continue
if expiry < now:
_remove_zip_artifacts(job)
get_table("jobs").delete(uid=job["uid"])
removed += 1
_audit_cli("cli.zips.prune", f"CLI pruned {removed} expired zip jobs", metadata={"count": removed})
print(f"Pruned {removed} expired zip job(s)")
def cmd_zips_clear(args):
from devplacepy.services.jobs import queue
jobs = queue.list_jobs(kind="zip")
for job in jobs:
_remove_zip_artifacts(job)
get_table("jobs").delete(uid=job["uid"])
_audit_cli("cli.zips.clear", f"CLI cleared all zip jobs ({len(jobs)})", metadata={"count": len(jobs)})
print(f"Cleared {len(jobs)} zip job(s) and their archives")
def cmd_forks_prune(args):
from datetime import datetime, timezone
from devplacepy.services.jobs import queue
now = datetime.now(timezone.utc)
removed = 0
for job in queue.list_jobs(kind="fork", status=queue.DONE):
expires_at = job.get("expires_at")
if not expires_at:
continue
try:
expiry = datetime.fromisoformat(expires_at)
except (ValueError, TypeError):
continue
if expiry < now:
get_table("jobs").delete(uid=job["uid"])
removed += 1
_audit_cli("cli.forks.prune", f"CLI pruned {removed} expired fork jobs", metadata={"count": removed})
print(f"Pruned {removed} expired fork job(s)")
def cmd_forks_clear(args):
from devplacepy.services.jobs import queue
jobs = queue.list_jobs(kind="fork")
for job in jobs:
get_table("jobs").delete(uid=job["uid"])
_audit_cli("cli.forks.clear", f"CLI cleared all fork jobs ({len(jobs)})", metadata={"count": len(jobs)})
print(f"Cleared {len(jobs)} fork job(s)")
def _remove_seo_artifacts(job):
import shutil
from devplacepy.config import SEO_REPORTS_DIR
shutil.rmtree(SEO_REPORTS_DIR / job["uid"], ignore_errors=True)
def cmd_seo_prune(args):
from datetime import datetime, timezone
from devplacepy.services.jobs import queue
now = datetime.now(timezone.utc)
removed = 0
for job in queue.list_jobs(kind="seo", status=queue.DONE):
expires_at = job.get("expires_at")
if not expires_at:
continue
try:
expiry = datetime.fromisoformat(expires_at)
except (ValueError, TypeError):
continue
if expiry < now:
_remove_seo_artifacts(job)
get_table("jobs").delete(uid=job["uid"])
removed += 1
_audit_cli("cli.seo.prune", f"CLI pruned {removed} expired SEO jobs", metadata={"count": removed})
print(f"Pruned {removed} expired SEO audit(s)")
def cmd_seo_clear(args):
from devplacepy.services.jobs import queue
jobs = queue.list_jobs(kind="seo")
for job in jobs:
_remove_seo_artifacts(job)
get_table("jobs").delete(uid=job["uid"])
_audit_cli("cli.seo.clear", f"CLI cleared all SEO jobs ({len(jobs)})", metadata={"count": len(jobs)})
print(f"Cleared {len(jobs)} SEO audit(s) and their reports")
def _remove_deepsearch_artifacts(job):
import shutil
from devplacepy.config import DEEPSEARCH_DIR
from devplacepy.services.deepsearch.store import VectorStore
uid = job["uid"]
collection = f"ds_{uid.replace('-', '')}"
VectorStore(collection).drop()
shutil.rmtree(DEEPSEARCH_DIR / uid, ignore_errors=True)
def cmd_deepsearch_prune(args):
from datetime import datetime, timezone
from devplacepy.services.jobs import queue
now = datetime.now(timezone.utc)
removed = 0
for job in queue.list_jobs(kind="deepsearch", status=queue.DONE):
expires_at = job.get("expires_at")
if not expires_at:
continue
try:
expiry = datetime.fromisoformat(expires_at)
except (ValueError, TypeError):
continue
if expiry < now:
_remove_deepsearch_artifacts(job)
get_table("jobs").delete(uid=job["uid"])
removed += 1
_audit_cli(
"cli.deepsearch.prune",
f"CLI pruned {removed} expired DeepSearch jobs",
metadata={"count": removed},
)
print(f"Pruned {removed} expired DeepSearch job(s)")
def cmd_deepsearch_clear(args):
from devplacepy.services.jobs import queue
jobs = queue.list_jobs(kind="deepsearch")
for job in jobs:
_remove_deepsearch_artifacts(job)
get_table("jobs").delete(uid=job["uid"])
_audit_cli(
"cli.deepsearch.clear",
f"CLI cleared all DeepSearch jobs ({len(jobs)})",
metadata={"count": len(jobs)},
)
print(f"Cleared {len(jobs)} DeepSearch job(s) and their collections")
def cmd_containers_list(args):
from devplacepy.services.containers import store
instances = store.all_instances()
if not instances:
print("No container instances")
return
for inst in instances:
print(
f"{inst['uid'][:8]} {inst.get('name', ''):24.24} {inst.get('status', ''):10} "
f"desired={inst.get('desired_state', '')} policy={inst.get('restart_policy', '')}"
)
def cmd_containers_reconcile(args):
import asyncio
from devplacepy.services.containers.service import ContainerService
asyncio.run(ContainerService().run_once())
_audit_cli("cli.containers.reconcile", "CLI ran one container reconcile pass")
print("Reconcile pass complete")
def cmd_containers_prune(args):
import asyncio
from devplacepy.services.containers.runtime import get_backend
from devplacepy.services.containers.service import ContainerService
async def run():
await ContainerService().run_once()
await get_backend().image_prune()
asyncio.run(run())
_audit_cli("cli.containers.prune", "CLI reaped orphan containers and dangling images")
print("Reaped orphans and pruned dangling images")
def cmd_containers_prune_builds(args):
import asyncio
from devplacepy.database import db, get_table
from devplacepy.services.containers.runtime import get_backend
async def run():
backend = get_backend()
removed = 0
if "builds" in db.tables:
for build in list(get_table("builds").find()):
tag = build.get("image_tag")
if tag:
await backend.remove_image(tag)
removed += 1
for table in ("builds", "dockerfile_versions", "dockerfiles"):
if table in db.tables:
get_table(table).delete()
return removed
removed = asyncio.run(run())
_audit_cli("cli.containers.prune_builds", "CLI removed legacy images and build tables", metadata={"removed": removed})
print(
f"Removed {removed} legacy per-project image(s) and cleared the dockerfiles/builds tables"
)
def cmd_containers_gc_workspaces(args):
import shutil
from pathlib import Path
from devplacepy import config
from devplacepy.services.containers import store
active = {inst["project_uid"] for inst in store.all_instances()}
base = Path(config.CONTAINER_WORKSPACES_DIR)
removed = 0
if base.is_dir():
for child in base.iterdir():
if child.is_dir() and child.name not in active:
shutil.rmtree(child, ignore_errors=True)
removed += 1
_audit_cli("cli.containers.gc_workspaces", f"CLI removed {removed} unused workspace dirs", metadata={"count": removed})
print(
f"Removed {removed} unused workspace director{'y' if removed == 1 else 'ies'}"
)
def _crc32(path):
import zlib
crc = 0
with open(path, "rb") as handle:
while True:
chunk = handle.read(1024 * 1024)
if not chunk:
break
crc = zlib.crc32(chunk, crc)
return crc & 0xFFFFFFFF
def _migrate_file(source, dest, dry_run, report):
import os
import shutil
if not source.exists():
return
if source.resolve() == dest.resolve():
return
size = source.stat().st_size
if dest.exists():
if dest.stat().st_size == size and _crc32(dest) == _crc32(source):
report.append(("done", source, dest, size))
if not dry_run:
source.unlink()
return
report.append(("conflict", source, dest, size))
return
report.append(("move", source, dest, size))
if dry_run:
return
dest.parent.mkdir(parents=True, exist_ok=True)
tmp = dest.with_name(dest.name + ".migrating")
shutil.copyfile(source, tmp)
with open(tmp, "rb") as handle:
os.fsync(handle.fileno())
if tmp.stat().st_size != size or _crc32(tmp) != _crc32(source):
tmp.unlink(missing_ok=True)
raise RuntimeError(f"verification failed copying {source} -> {dest}")
os.replace(tmp, dest)
source.unlink()
def _prune_empty_dirs(root):
if not root.exists():
return
for path in sorted(root.rglob("*"), reverse=True):
if path.is_dir():
try:
path.rmdir()
except OSError:
pass
try:
root.rmdir()
except OSError:
pass
def _migrate_tree(source, dest, dry_run, report):
if not source.exists():
return
if source.resolve() == dest.resolve():
return
for child in sorted(source.rglob("*")):
if child.is_file():
_migrate_file(child, dest / child.relative_to(source), dry_run, report)
if not dry_run:
_prune_empty_dirs(source)
def _db_is_locked(path):
import sqlite3
try:
conn = sqlite3.connect(str(path), timeout=0.5)
try:
conn.execute("BEGIN IMMEDIATE")
conn.rollback()
return False
finally:
conn.close()
except sqlite3.OperationalError:
return True
def _checkpoint(path):
import sqlite3
conn = sqlite3.connect(str(path), timeout=5)
try:
conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
conn.commit()
finally:
conn.close()
def _migrate_db(source, dest, dry_run, report):
if not source.exists():
return
if source.resolve() == dest.resolve():
return
if _db_is_locked(source):
raise RuntimeError(
f"{source} is locked - stop the app before running migrate-data"
)
if not dry_run:
_checkpoint(source)
_migrate_file(source, dest, dry_run, report)
for suffix in ("-wal", "-shm"):
_migrate_file(
source.with_name(source.name + suffix),
dest.with_name(dest.name + suffix),
dry_run,
report,
)
def cmd_migrate_data(args):
import os
from pathlib import Path
from collections import Counter
from devplacepy import config
base = config.BASE_DIR
home = Path.home()
dry = args.dry_run
report = []
config.ensure_data_dirs()
db_items = []
if config.DATABASE_URL == f"sqlite:///{config.DATA_DIR / 'devplace.db'}":
db_items.append((base / "devplace.db", config.DATA_DIR / "devplace.db"))
else:
print("Skipping main DB: DEVPLACE_DATABASE_URL points outside the data dir.")
if not os.environ.get("DEVII_TASKS_DB"):
db_items.append((base / "devii_tasks.db", config.DEVII_TASKS_DB))
if not os.environ.get("DEVII_LESSONS_DB"):
db_items.append((base / "devii_lessons.db", config.DEVII_LESSONS_DB))
file_items = [
(base / name, config.KEYS_DIR / name)
for name in (
"notification-private.pem",
"notification-private.pkcs8.pem",
"notification-public.pem",
)
]
registry_dest = config.BOT_DIR / "article_registry.json"
registry_sources = [
path
for path in (
home / ".dpbot_article_registry.json",
base / ".dpbot_article_registry.json",
)
if path.exists()
]
registry_sources.sort(key=lambda path: path.stat().st_mtime, reverse=True)
if registry_sources:
file_items.append((registry_sources[0], registry_dest))
for stale in registry_sources[1:]:
print(f"Leaving older duplicate registry untouched: {stale}")
legacy_var = base / "var"
tree_items = [
(base / "devplacepy" / "static" / "uploads", config.UPLOADS_DIR),
(home / ".devplace_bots", config.BOT_DIR),
]
for sub_name in ("container_workspaces", "zips", "zip_staging", "fork_staging"):
tree_items.append((legacy_var / sub_name, config.DATA_PATHS[sub_name]))
try:
for source, dest in db_items:
_migrate_db(source, dest, dry, report)
for source, dest in file_items:
_migrate_file(source, dest, dry, report)
for source, dest in tree_items:
_migrate_tree(source, dest, dry, report)
except RuntimeError as exc:
print(f"ERROR: {exc}")
sys.exit(1)
if not report:
print("Nothing to migrate; the data directory is already consolidated.")
return
for status, source, dest, size in report:
print(f" [{status}] {source} -> {dest} ({size} bytes)")
counts = Counter(status for status, *_ in report)
print()
print(
("Planned: " if dry else "Migrated: ")
+ ", ".join(f"{count} {status}" for status, count in sorted(counts.items()))
)
if any(status == "conflict" for status, *_ in report):
print(
"Conflicts left both source and destination untouched; resolve them by hand."
)
if dry:
print("Dry run - nothing changed. Re-run without --dry-run to apply.")
def main():
parser = argparse.ArgumentParser(description="DevPlace admin CLI")
sub = parser.add_subparsers(title="commands", dest="command")
role = sub.add_parser("role", help="Manage user roles")
role_sub = role.add_subparsers(title="action", dest="action")
role_get = role_sub.add_parser("get", help="Get a user's role")
role_get.add_argument("username")
role_get.set_defaults(func=cmd_role_get)
role_set = role_sub.add_parser("set", help="Set a user's role")
role_set.add_argument("username")
role_set.add_argument("role", choices=["member", "admin"])
role_set.set_defaults(func=cmd_role_set)
apikey = sub.add_parser("apikey", help="Manage user API keys")
apikey_sub = apikey.add_subparsers(title="action", dest="action")
apikey_get = apikey_sub.add_parser("get", help="Print a user's API key")
apikey_get.add_argument("username")
apikey_get.set_defaults(func=cmd_apikey_get)
apikey_reset = apikey_sub.add_parser("reset", help="Regenerate a user's API key")
apikey_reset.add_argument("username")
apikey_reset.set_defaults(func=cmd_apikey_reset)
apikey_backfill = apikey_sub.add_parser(
"backfill", help="Assign API keys to users that lack one"
)
apikey_backfill.set_defaults(func=cmd_apikey_backfill)
news = sub.add_parser("news", help="News management")
news_sub = news.add_subparsers(title="action", dest="action")
news_clear = news_sub.add_parser(
"clear", help="Delete all news from local database"
)
news_clear.set_defaults(func=cmd_news_clear)
news_sanitize = news_sub.add_parser(
"sanitize", help="Strip HTML from all existing news descriptions and content"
)
news_sanitize.set_defaults(func=cmd_news_sanitize)
attachments = sub.add_parser("attachments", help="Attachment management")
att_sub = attachments.add_subparsers(title="action", dest="action")
att_prune = att_sub.add_parser(
"prune", help="Remove orphaned attachment records and files"
)
att_prune.add_argument(
"--hours",
type=int,
default=24,
help="Only prune orphans older than this many hours",
)
att_prune.set_defaults(func=cmd_attachments_prune)
devii = sub.add_parser("devii", help="Devii assistant management")
devii_sub = devii.add_subparsers(title="action", dest="action")
devii_reset = devii_sub.add_parser(
"reset-quota", help="Reset the rolling 24h AI spend quota"
)
devii_reset.add_argument(
"username", nargs="?", help="Reset the quota for a single user"
)
devii_reset.add_argument(
"--guests", action="store_true", help="Reset every guest quota"
)
devii_reset.add_argument(
"--all", action="store_true", help="Reset every quota (users and guests)"
)
devii_reset.set_defaults(func=cmd_devii_reset_quota)
zips = sub.add_parser("zips", help="Zip archive job management")
zips_sub = zips.add_subparsers(title="action", dest="action")
zips_prune = zips_sub.add_parser(
"prune", help="Delete expired zip archives and their job rows"
)
zips_prune.set_defaults(func=cmd_zips_prune)
zips_clear = zips_sub.add_parser(
"clear", help="Delete every zip archive and job row"
)
zips_clear.set_defaults(func=cmd_zips_clear)
forks = sub.add_parser("forks", help="Fork job management")
forks_sub = forks.add_subparsers(title="action", dest="action")
forks_prune = forks_sub.add_parser(
"prune", help="Delete expired completed fork job rows (forked projects persist)"
)
forks_prune.set_defaults(func=cmd_forks_prune)
forks_clear = forks_sub.add_parser(
"clear", help="Delete every fork job row (forked projects persist)"
)
forks_clear.set_defaults(func=cmd_forks_clear)
seo = sub.add_parser("seo", help="SEO Diagnostics job management")
seo_sub = seo.add_subparsers(title="action", dest="action")
seo_prune = seo_sub.add_parser(
"prune", help="Delete expired SEO audit reports and their job rows"
)
seo_prune.set_defaults(func=cmd_seo_prune)
seo_clear = seo_sub.add_parser(
"clear", help="Delete every SEO audit report and job row"
)
seo_clear.set_defaults(func=cmd_seo_clear)
deepsearch = sub.add_parser("deepsearch", help="DeepSearch job management")
deepsearch_sub = deepsearch.add_subparsers(title="action", dest="action")
deepsearch_prune = deepsearch_sub.add_parser(
"prune", help="Delete expired DeepSearch sessions and their job rows"
)
deepsearch_prune.set_defaults(func=cmd_deepsearch_prune)
deepsearch_clear = deepsearch_sub.add_parser(
"clear", help="Delete every DeepSearch session and job row"
)
deepsearch_clear.set_defaults(func=cmd_deepsearch_clear)
containers = sub.add_parser("containers", help="Container manager")
containers_sub = containers.add_subparsers(title="action", dest="action")
containers_sub.add_parser("list", help="List container instances").set_defaults(
func=cmd_containers_list
)
containers_sub.add_parser("reconcile", help="Run one reconcile pass").set_defaults(
func=cmd_containers_reconcile
)
containers_sub.add_parser(
"prune", help="Reap orphan containers and dangling images"
).set_defaults(func=cmd_containers_prune)
containers_sub.add_parser(
"prune-builds",
help="Remove legacy per-project images and clear the dockerfiles/builds tables",
).set_defaults(func=cmd_containers_prune_builds)
containers_sub.add_parser(
"gc-workspaces", help="Remove workspace dirs with no instances"
).set_defaults(func=cmd_containers_gc_workspaces)
migrate = sub.add_parser(
"migrate-data",
help="Relocate legacy runtime files into the consolidated data/ directory",
)
migrate.add_argument(
"--dry-run",
action="store_true",
help="Print the source-to-destination plan without changing anything",
)
migrate.set_defaults(func=cmd_migrate_data)
args = parser.parse_args()
if hasattr(args, "func"):
args.func(args)
else:
parser.print_help()
sys.exit(1)
if __name__ == "__main__":
main()
-89
View File
@@ -1,89 +0,0 @@
# retoor <retoor@molodetz.nl>
from devplacepy.cli.main import main, build_parser
from devplacepy.cli._shared import _audit_cli
from devplacepy.cli.roles import cmd_role_get, cmd_role_set
from devplacepy.cli.apikeys import cmd_apikey_get, cmd_apikey_reset, cmd_apikey_backfill
from devplacepy.cli.tokens import (
cmd_token_issue,
cmd_token_list,
cmd_token_revoke,
cmd_token_revoke_all,
cmd_token_prune,
)
from devplacepy.cli.devii import cmd_devii_reset_quota
from devplacepy.cli.news import cmd_news_clear, cmd_news_sanitize
from devplacepy.cli.attachments import cmd_attachments_prune
from devplacepy.cli.jobs import (
cmd_zips_prune,
cmd_zips_clear,
cmd_forks_prune,
cmd_forks_clear,
cmd_seo_prune,
cmd_seo_clear,
cmd_isslop_prune,
cmd_isslop_clear,
cmd_isslop_analyze,
cmd_seo_meta_prune,
cmd_seo_meta_clear,
cmd_deepsearch_prune,
cmd_deepsearch_clear,
)
from devplacepy.cli.backups import (
cmd_backups_list,
cmd_backups_run,
cmd_backups_prune,
cmd_backups_clear,
)
from devplacepy.cli.containers import (
cmd_containers_list,
cmd_containers_reconcile,
cmd_containers_prune,
cmd_containers_prune_builds,
cmd_containers_gc_workspaces,
)
from devplacepy.cli.migrate import cmd_emoji_sync, cmd_migrate_data
__all__ = [
"main",
"build_parser",
"_audit_cli",
"cmd_role_get",
"cmd_role_set",
"cmd_apikey_get",
"cmd_apikey_reset",
"cmd_apikey_backfill",
"cmd_token_issue",
"cmd_token_list",
"cmd_token_revoke",
"cmd_token_revoke_all",
"cmd_token_prune",
"cmd_devii_reset_quota",
"cmd_news_clear",
"cmd_news_sanitize",
"cmd_attachments_prune",
"cmd_zips_prune",
"cmd_zips_clear",
"cmd_forks_prune",
"cmd_forks_clear",
"cmd_seo_prune",
"cmd_seo_clear",
"cmd_isslop_prune",
"cmd_isslop_clear",
"cmd_isslop_analyze",
"cmd_seo_meta_prune",
"cmd_seo_meta_clear",
"cmd_deepsearch_prune",
"cmd_deepsearch_clear",
"cmd_backups_list",
"cmd_backups_run",
"cmd_backups_prune",
"cmd_backups_clear",
"cmd_containers_list",
"cmd_containers_reconcile",
"cmd_containers_prune",
"cmd_containers_prune_builds",
"cmd_containers_gc_workspaces",
"cmd_emoji_sync",
"cmd_migrate_data",
]
-6
View File
@@ -1,6 +0,0 @@
# retoor <retoor@molodetz.nl>
from devplacepy.cli.main import main
if __name__ == "__main__":
main()
-18
View File
@@ -1,18 +0,0 @@
# retoor <retoor@molodetz.nl>
def _audit_cli(event_key, summary, metadata=None, target_type=None, target_uid=None, target_label=None, links=None):
from devplacepy.services.audit import record as audit
audit.record_system(
event_key,
actor_kind="cli",
actor_role="system",
origin="cli",
target_type=target_type,
target_uid=target_uid,
target_label=target_label,
summary=summary,
metadata=metadata,
links=links,
)
-65
View File
@@ -1,65 +0,0 @@
# retoor <retoor@molodetz.nl>
import sys
from devplacepy.database import get_table
from devplacepy.cli._shared import _audit_cli
def cmd_apikey_get(args):
users = get_table("users")
user = users.find_one(username=args.username)
if not user:
print(f"User '{args.username}' not found")
sys.exit(1)
print(user.get("api_key", "") or "")
def cmd_apikey_reset(args):
from devplacepy.utils import generate_uid, clear_user_cache
users = get_table("users")
user = users.find_one(username=args.username)
if not user:
print(f"User '{args.username}' not found")
sys.exit(1)
new_key = generate_uid()
users.update({"uid": user["uid"], "api_key": new_key}, ["uid"])
clear_user_cache(user["uid"])
from devplacepy.services.audit import record as audit
_audit_cli(
"cli.apikey.reset",
f"CLI regenerated the API key of user {args.username}",
target_type="user",
target_uid=user["uid"],
target_label=args.username,
links=[audit.target("user", user["uid"], args.username)],
)
print(new_key)
def cmd_apikey_backfill(args):
from devplacepy.database import backfill_api_keys
updated = backfill_api_keys()
_audit_cli(
"cli.apikey.backfill",
f"CLI backfilled API keys for {updated} users",
metadata={"count": updated},
)
print(f"Assigned API keys to {updated} user(s) without one")
def register_apikeys(subparsers):
apikey = subparsers.add_parser("apikey", help="Manage user API keys")
apikey_sub = apikey.add_subparsers(title="action", dest="action")
apikey_get = apikey_sub.add_parser("get", help="Print a user's API key")
apikey_get.add_argument("username")
apikey_get.set_defaults(func=cmd_apikey_get)
apikey_reset = apikey_sub.add_parser("reset", help="Regenerate a user's API key")
apikey_reset.add_argument("username")
apikey_reset.set_defaults(func=cmd_apikey_reset)
apikey_backfill = apikey_sub.add_parser(
"backfill", help="Assign API keys to users that lack one"
)
apikey_backfill.set_defaults(func=cmd_apikey_backfill)
-43
View File
@@ -1,43 +0,0 @@
# retoor <retoor@molodetz.nl>
from devplacepy.cli._shared import _audit_cli
def cmd_attachments_prune(args):
from datetime import datetime, timezone, timedelta
from devplacepy.database import db
from devplacepy.attachments import delete_attachment
if "attachments" not in db.tables:
print("Attachments table does not exist")
return
cutoff = (datetime.now(timezone.utc) - timedelta(hours=args.hours)).isoformat()
orphans = [
att
for att in db["attachments"].find(target_type="", target_uid="")
if att.get("created_at", "") < cutoff
]
for att in orphans:
delete_attachment(att["uid"])
_audit_cli(
"cli.attachments.prune",
f"CLI pruned {len(orphans)} orphan attachments",
metadata={"count": len(orphans), "hours": args.hours},
)
print(f"Pruned {len(orphans)} orphan attachment(s) older than {args.hours}h")
def register_attachments(subparsers):
attachments = subparsers.add_parser("attachments", help="Attachment management")
att_sub = attachments.add_subparsers(title="action", dest="action")
att_prune = att_sub.add_parser(
"prune", help="Remove orphaned attachment records and files"
)
att_prune.add_argument(
"--hours",
type=int,
default=24,
help="Only prune orphans older than this many hours",
)
att_prune.set_defaults(func=cmd_attachments_prune)
-82
View File
@@ -1,82 +0,0 @@
# retoor <retoor@molodetz.nl>
import sys
from devplacepy.cli._shared import _audit_cli
def cmd_backups_list(args):
from devplacepy.services.backup import store
backups = store.list_backups()
if not backups:
print("No backups recorded")
return
for backup in backups:
size = store.human_bytes(int(backup.get("size_bytes") or 0))
print(
f"{backup['uid']} {backup.get('target', ''):<9} "
f"{backup.get('status', ''):<8} {size:>10} "
f"{backup.get('created_at', '')} {backup.get('filename', '')}"
)
def cmd_backups_run(args):
from devplacepy.services.backup import store
from devplacepy.services.jobs import queue
if not store.is_valid_target(args.target):
print(f"Unknown target '{args.target}'. Choose one of: {', '.join(store.BACKUP_TARGETS)}")
sys.exit(1)
job_uid = queue.enqueue(
"backup",
{"target": args.target, "schedule_uid": "", "created_by": "cli"},
owner_kind="system",
owner_id="cli",
preferred_name=f"{store.target_label(args.target)} (cli)",
)
store.create_backup(target=args.target, created_by="cli", job_uid=job_uid)
_audit_cli(
"cli.backups.run",
f"CLI enqueued {args.target} backup",
metadata={"target": args.target, "job_uid": job_uid},
)
print(f"Enqueued {args.target} backup job {job_uid} (processed by the running server)")
def cmd_backups_prune(args):
from devplacepy.services.backup import store
removed = store.prune_orphans()
_audit_cli("cli.backups.prune", f"CLI pruned {removed} orphan backups", metadata={"count": removed})
print(f"Pruned {removed} orphan backup record(s)")
def cmd_backups_clear(args):
from devplacepy.services.backup import store
removed = store.clear_all()
_audit_cli("cli.backups.clear", f"CLI cleared all backups ({removed})", metadata={"count": removed})
print(f"Cleared {removed} backup(s) and their archives")
def register_backups(subparsers):
backups = subparsers.add_parser("backups", help="Backup management")
backups_sub = backups.add_subparsers(title="action", dest="action")
backups_sub.add_parser("list", help="List recorded backups").set_defaults(
func=cmd_backups_list
)
backups_run = backups_sub.add_parser(
"run", help="Enqueue a backup (processed by the running server)"
)
backups_run.add_argument(
"target",
choices=["database", "uploads", "keys", "full"],
help="What to back up",
)
backups_run.set_defaults(func=cmd_backups_run)
backups_sub.add_parser(
"prune", help="Remove backup records whose archive file is missing"
).set_defaults(func=cmd_backups_prune)
backups_sub.add_parser(
"clear", help="Delete every backup archive and record"
).set_defaults(func=cmd_backups_clear)
-107
View File
@@ -1,107 +0,0 @@
# retoor <retoor@molodetz.nl>
from devplacepy.cli._shared import _audit_cli
def cmd_containers_list(args):
from devplacepy.services.containers import store
instances = store.all_instances()
if not instances:
print("No container instances")
return
for inst in instances:
print(
f"{inst['uid'][:8]} {inst.get('name', ''):24.24} {inst.get('status', ''):10} "
f"desired={inst.get('desired_state', '')} policy={inst.get('restart_policy', '')}"
)
def cmd_containers_reconcile(args):
import asyncio
from devplacepy.services.containers.service import ContainerService
asyncio.run(ContainerService().run_once())
_audit_cli("cli.containers.reconcile", "CLI ran one container reconcile pass")
print("Reconcile pass complete")
def cmd_containers_prune(args):
import asyncio
from devplacepy.services.containers.runtime import get_backend
from devplacepy.services.containers.service import ContainerService
async def run():
await ContainerService().run_once()
await get_backend().image_prune()
asyncio.run(run())
_audit_cli("cli.containers.prune", "CLI reaped orphan containers and dangling images")
print("Reaped orphans and pruned dangling images")
def cmd_containers_prune_builds(args):
import asyncio
from devplacepy.database import db, get_table
from devplacepy.services.containers.runtime import get_backend
async def run():
backend = get_backend()
removed = 0
if "builds" in db.tables:
for build in list(get_table("builds").find()):
tag = build.get("image_tag")
if tag:
await backend.remove_image(tag)
removed += 1
for table in ("builds", "dockerfile_versions", "dockerfiles"):
if table in db.tables:
get_table(table).delete()
return removed
removed = asyncio.run(run())
_audit_cli("cli.containers.prune_builds", "CLI removed legacy images and build tables", metadata={"removed": removed})
print(
f"Removed {removed} legacy per-project image(s) and cleared the dockerfiles/builds tables"
)
def cmd_containers_gc_workspaces(args):
import shutil
from pathlib import Path
from devplacepy import config
from devplacepy.services.containers import store
active = {inst["project_uid"] for inst in store.all_instances()}
base = Path(config.CONTAINER_WORKSPACES_DIR)
removed = 0
if base.is_dir():
for child in base.iterdir():
if child.is_dir() and child.name not in active:
shutil.rmtree(child, ignore_errors=True)
removed += 1
_audit_cli("cli.containers.gc_workspaces", f"CLI removed {removed} unused workspace dirs", metadata={"count": removed})
print(
f"Removed {removed} unused workspace director{'y' if removed == 1 else 'ies'}"
)
def register_containers(subparsers):
containers = subparsers.add_parser("containers", help="Container manager")
containers_sub = containers.add_subparsers(title="action", dest="action")
containers_sub.add_parser("list", help="List container instances").set_defaults(
func=cmd_containers_list
)
containers_sub.add_parser("reconcile", help="Run one reconcile pass").set_defaults(
func=cmd_containers_reconcile
)
containers_sub.add_parser(
"prune", help="Reap orphan containers and dangling images"
).set_defaults(func=cmd_containers_prune)
containers_sub.add_parser(
"prune-builds",
help="Remove legacy per-project images and clear the dockerfiles/builds tables",
).set_defaults(func=cmd_containers_prune_builds)
containers_sub.add_parser(
"gc-workspaces", help="Remove workspace dirs with no instances"
).set_defaults(func=cmd_containers_gc_workspaces)
-140
View File
@@ -1,140 +0,0 @@
# retoor <retoor@molodetz.nl>
import sys
from devplacepy.database import db, get_table
from devplacepy.cli._shared import _audit_cli
def cmd_devii_reset_quota(args):
table_name = "devii_usage_ledger"
if table_name not in db.tables:
print(f"Table '{table_name}' does not exist, nothing to reset")
return
table = db[table_name]
if args.all:
count = table.count()
table.delete()
_audit_cli("cli.devii.quota.reset", "CLI reset AI quota (all)", metadata={"scope": "all", "rows_removed": count})
print(f"Reset all AI quotas ({count} ledger rows deleted)")
return
if args.guests:
count = table.count(owner_kind="guest")
table.delete(owner_kind="guest")
_audit_cli("cli.devii.quota.reset", "CLI reset AI quota (guests)", metadata={"scope": "guests", "rows_removed": count})
print(f"Reset all guest AI quotas ({count} ledger rows deleted)")
return
if not args.username:
print("Provide a username, or --guests, or --all")
sys.exit(1)
user = get_table("users").find_one(username=args.username)
if not user:
print(f"User '{args.username}' not found")
sys.exit(1)
count = table.count(owner_kind="user", owner_id=user["uid"])
table.delete(owner_kind="user", owner_id=user["uid"])
from devplacepy.services.audit import record as audit
_audit_cli(
"cli.devii.quota.reset",
f"CLI reset AI quota for {args.username}",
metadata={"scope": "user", "rows_removed": count},
target_type="user",
target_uid=user["uid"],
target_label=args.username,
links=[audit.target("user", user["uid"], args.username)],
)
print(f"Reset AI quota for '{args.username}' ({count} ledger rows deleted)")
def _active_count() -> int:
if "devii_lessons" not in db.tables:
return 0
return db["devii_lessons"].count(deleted_at=None)
def _soft_deleted_count() -> int:
if "devii_lessons" not in db.tables:
return 0
return db["devii_lessons"].count(deleted_at={"!=": None})
def cmd_devii_lessons_count(args):
active = _active_count()
deleted = _soft_deleted_count()
print(f"Lessons: {active} active, {deleted} soft-deleted ({active + deleted} total)")
def cmd_devii_lessons_clear(args):
from devplacepy.services.devii.agentic.lessons import TABLE
if TABLE not in db.tables:
print("No devii_lessons table exists")
return
active = _active_count()
deleted = _soft_deleted_count()
total = active + deleted
if not args.force:
print(f"Will delete {total} lesson(s) ({active} active, {deleted} soft-deleted). Pass --force to confirm.")
return
db[TABLE].delete()
_audit_cli("cli.devii.lessons.clear", "CLI cleared all devii_lessons", metadata={"active": active, "soft_deleted": deleted})
print(f"Deleted {total} lesson(s)")
def cmd_devii_lessons_prune(args):
from devplacepy.services.devii.agentic.lessons import LessonStore, _read_retention_settings
if "devii_lessons" not in db.tables:
print("No devii_lessons table exists")
return
active_before = _active_count()
if args.all_owners:
_, max_age = _read_retention_settings(db)
store = LessonStore(db, "_global", "_global")
pruned = store.prune_all_owners(max_age)
elif args.username:
user = get_table("users").find_one(username=args.username)
if not user:
print(f"User '{args.username}' not found")
sys.exit(1)
_, max_age = _read_retention_settings(db)
store = LessonStore(db, "user", user["uid"])
pruned = store.prune(max_age)
else:
print("Provide --all-owners, or --username USER")
sys.exit(1)
_audit_cli("cli.devii.lessons.prune", "CLI pruned devii_lessons", metadata={"pruned": pruned, "active_before": active_before})
print(f"Pruned {pruned} lesson(s) (active before: {active_before}, now: {_active_count()})")
def register_devii(subparsers):
devii = subparsers.add_parser("devii", help="Devii assistant management")
devii_sub = devii.add_subparsers(title="action", dest="action")
devii_reset = devii_sub.add_parser(
"reset-quota", help="Reset the rolling 24h AI spend quota"
)
devii_reset.add_argument(
"username", nargs="?", help="Reset the quota for a single user"
)
devii_reset.add_argument(
"--guests", action="store_true", help="Reset every guest quota"
)
devii_reset.add_argument(
"--all", action="store_true", help="Reset every quota (users and guests)"
)
devii_reset.set_defaults(func=cmd_devii_reset_quota)
devii_lessons = devii_sub.add_parser("lessons", help="Manage persisted Devii lesson data")
lessons_sub = devii_lessons.add_subparsers(title="sub-action", dest="sub_action")
lessons_count = lessons_sub.add_parser("count", help="Count active and soft-deleted lessons")
lessons_count.set_defaults(func=cmd_devii_lessons_count)
lessons_prune = lessons_sub.add_parser("prune", help="Soft-delete lessons older than the configured max age")
lessons_prune.add_argument("--all-owners", action="store_true", help="Prune across every owner")
lessons_prune.add_argument("--username", help="Prune for a specific user")
lessons_prune.set_defaults(func=cmd_devii_lessons_prune)
lessons_clear = lessons_sub.add_parser("clear", help="Hard-delete every devii_lessons row")
lessons_clear.add_argument("--force", action="store_true", help="Required to confirm hard deletion")
lessons_clear.set_defaults(func=cmd_devii_lessons_clear)
-383
View File
@@ -1,383 +0,0 @@
# retoor <retoor@molodetz.nl>
from devplacepy.database import get_table
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
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)
def cmd_zips_prune(args):
from datetime import datetime, timezone
from devplacepy.services.jobs import queue
now = datetime.now(timezone.utc)
removed = 0
for job in queue.list_jobs(kind="zip", status=queue.DONE):
expires_at = job.get("expires_at")
if not expires_at:
continue
try:
expiry = datetime.fromisoformat(expires_at)
except (ValueError, TypeError):
continue
if expiry < now:
_remove_zip_artifacts(job)
get_table("jobs").delete(uid=job["uid"])
removed += 1
_audit_cli("cli.zips.prune", f"CLI pruned {removed} expired zip jobs", metadata={"count": removed})
print(f"Pruned {removed} expired zip job(s)")
def cmd_zips_clear(args):
from devplacepy.services.jobs import queue
jobs = queue.list_jobs(kind="zip")
for job in jobs:
_remove_zip_artifacts(job)
get_table("jobs").delete(uid=job["uid"])
_audit_cli("cli.zips.clear", f"CLI cleared all zip jobs ({len(jobs)})", metadata={"count": len(jobs)})
print(f"Cleared {len(jobs)} zip job(s) and their archives")
def cmd_forks_prune(args):
from datetime import datetime, timezone
from devplacepy.services.jobs import queue
now = datetime.now(timezone.utc)
removed = 0
for job in queue.list_jobs(kind="fork", status=queue.DONE):
expires_at = job.get("expires_at")
if not expires_at:
continue
try:
expiry = datetime.fromisoformat(expires_at)
except (ValueError, TypeError):
continue
if expiry < now:
get_table("jobs").delete(uid=job["uid"])
removed += 1
_audit_cli("cli.forks.prune", f"CLI pruned {removed} expired fork jobs", metadata={"count": removed})
print(f"Pruned {removed} expired fork job(s)")
def cmd_forks_clear(args):
from devplacepy.services.jobs import queue
jobs = queue.list_jobs(kind="fork")
for job in jobs:
get_table("jobs").delete(uid=job["uid"])
_audit_cli("cli.forks.clear", f"CLI cleared all fork jobs ({len(jobs)})", metadata={"count": len(jobs)})
print(f"Cleared {len(jobs)} fork job(s)")
def _remove_seo_artifacts(job):
import shutil
from devplacepy.config import SEO_REPORTS_DIR
shutil.rmtree(SEO_REPORTS_DIR / job["uid"], ignore_errors=True)
def cmd_seo_prune(args):
from datetime import datetime, timezone
from devplacepy.services.jobs import queue
now = datetime.now(timezone.utc)
removed = 0
for job in queue.list_jobs(kind="seo", status=queue.DONE):
expires_at = job.get("expires_at")
if not expires_at:
continue
try:
expiry = datetime.fromisoformat(expires_at)
except (ValueError, TypeError):
continue
if expiry < now:
_remove_seo_artifacts(job)
get_table("jobs").delete(uid=job["uid"])
removed += 1
_audit_cli("cli.seo.prune", f"CLI pruned {removed} expired SEO jobs", metadata={"count": removed})
print(f"Pruned {removed} expired SEO audit(s)")
def cmd_seo_clear(args):
from devplacepy.services.jobs import queue
jobs = queue.list_jobs(kind="seo")
for job in jobs:
_remove_seo_artifacts(job)
get_table("jobs").delete(uid=job["uid"])
_audit_cli("cli.seo.clear", f"CLI cleared all SEO jobs ({len(jobs)})", metadata={"count": len(jobs)})
print(f"Cleared {len(jobs)} SEO audit(s) and their reports")
def cmd_seo_meta_prune(args):
from datetime import datetime, timezone
from devplacepy.services.jobs import queue
now = datetime.now(timezone.utc)
removed = 0
for job in queue.list_jobs(kind="seo_meta", status=queue.DONE):
expires_at = job.get("expires_at")
if not expires_at:
continue
try:
expiry = datetime.fromisoformat(expires_at)
except (ValueError, TypeError):
continue
if expiry < now:
get_table("jobs").delete(uid=job["uid"])
removed += 1
_audit_cli(
"cli.seo_meta.prune",
f"CLI pruned {removed} expired SEO metadata jobs",
metadata={"count": removed},
)
print(f"Pruned {removed} expired SEO metadata job(s)")
def cmd_seo_meta_clear(args):
from devplacepy.services.jobs import queue
jobs = queue.list_jobs(kind="seo_meta")
for job in jobs:
get_table("jobs").delete(uid=job["uid"])
_audit_cli(
"cli.seo_meta.clear",
f"CLI cleared all SEO metadata jobs ({len(jobs)})",
metadata={"count": len(jobs)},
)
print(f"Cleared {len(jobs)} SEO metadata job(s); generated metadata persists")
def _remove_deepsearch_artifacts(job):
import shutil
from devplacepy.config import DEEPSEARCH_DIR
from devplacepy.services.deepsearch.store import VectorStore
uid = job["uid"]
collection = f"ds_{uid.replace('-', '')}"
VectorStore(collection).drop()
shutil.rmtree(DEEPSEARCH_DIR / uid, ignore_errors=True)
def cmd_deepsearch_prune(args):
from datetime import datetime, timezone
from devplacepy.services.jobs import queue
now = datetime.now(timezone.utc)
removed = 0
for job in queue.list_jobs(kind="deepsearch", status=queue.DONE):
expires_at = job.get("expires_at")
if not expires_at:
continue
try:
expiry = datetime.fromisoformat(expires_at)
except (ValueError, TypeError):
continue
if expiry < now:
_remove_deepsearch_artifacts(job)
get_table("jobs").delete(uid=job["uid"])
removed += 1
_audit_cli(
"cli.deepsearch.prune",
f"CLI pruned {removed} expired DeepSearch jobs",
metadata={"count": removed},
)
print(f"Pruned {removed} expired DeepSearch job(s)")
def cmd_deepsearch_clear(args):
from devplacepy.services.jobs import queue
jobs = queue.list_jobs(kind="deepsearch")
for job in jobs:
_remove_deepsearch_artifacts(job)
get_table("jobs").delete(uid=job["uid"])
_audit_cli(
"cli.deepsearch.clear",
f"CLI cleared all DeepSearch jobs ({len(jobs)})",
metadata={"count": len(jobs)},
)
print(f"Cleared {len(jobs)} DeepSearch job(s) and their collections")
def cmd_isslop_prune(args):
from datetime import datetime, timezone
from devplacepy.services.jobs import queue
now = datetime.now(timezone.utc)
removed = 0
for job in queue.list_jobs(kind="isslop", status=queue.DONE):
expires_at = job.get("expires_at")
if not expires_at:
continue
try:
expiry = datetime.fromisoformat(expires_at)
except (ValueError, TypeError):
continue
if expiry < now:
get_table("jobs").delete(uid=job["uid"])
removed += 1
_audit_cli("cli.isslop.prune", f"CLI pruned {removed} expired AI usage analysis jobs", metadata={"count": removed})
print(f"Pruned {removed} expired AI usage analysis job(s) (reports persist)")
def cmd_isslop_clear(args):
from devplacepy.services.jobs import queue
from devplacepy.services.jobs.isslop import store
jobs = queue.list_jobs(kind="isslop")
for job in jobs:
get_table("jobs").delete(uid=job["uid"])
analyses = list(get_table(store.TABLE_ANALYSES).find())
for analysis in analyses:
store.purge_analysis(analysis["uid"])
_audit_cli(
"cli.isslop.clear",
f"CLI cleared {len(analyses)} AI usage analyses and {len(jobs)} job rows",
metadata={"analyses": len(analyses), "jobs": len(jobs)},
)
print(f"Cleared {len(analyses)} AI usage analysis(es), their reports and {len(jobs)} job row(s)")
def cmd_isslop_analyze(args):
import asyncio
import json
from devplacepy.config import ISSLOP_WORKSPACES_DIR, ensure_data_dirs
from devplacepy.database import INTERNAL_GATEWAY_URL, internal_gateway_key
from devplacepy.models import IsslopRunForm
from devplacepy.services.jobs.isslop import store
from devplacepy.services.jobs.isslop.acquisition.workspace import remove_workspace, workspace_for
from devplacepy.services.jobs.isslop.config import settings_from_payload
from devplacepy.services.jobs.isslop.events import KIND_DONE, KIND_ERROR
from devplacepy.services.jobs.isslop.persistence import EventPersister
from devplacepy.services.jobs.isslop.pipeline import run_pipeline
from devplacepy.utils import generate_uid
url = IsslopRunForm(url=args.url).url
ensure_data_dirs()
uid = generate_uid()
settings = settings_from_payload(
{
"url": url,
"llm_endpoint": INTERNAL_GATEWAY_URL,
"api_key": internal_gateway_key(),
"allow_private": bool(args.allow_private),
"media_dir": str(store.media_dir_for(uid)),
}
)
store.create_analysis(uid, url, "system", "cli")
persister = EventPersister(uid)
store.update_analysis(uid, status="running")
workspace = workspace_for(ISSLOP_WORKSPACES_DIR, url, uid)
async def run() -> int:
failed = False
try:
async for event in run_pipeline(url, workspace, settings):
persister.apply(event)
if args.json:
print(event.to_json(), flush=True)
else:
print(f"[{event.kind}] {event.message}", flush=True)
if event.kind == KIND_ERROR:
failed = True
if event.kind == KIND_DONE and not args.json:
print(f"Report: /tools/isslop/{uid}/report")
print(f"Badge: /tools/isslop/{uid}/badge.svg")
finally:
remove_workspace(workspace)
return 1 if failed else 0
exit_code = asyncio.run(run())
_audit_cli(
"cli.isslop.analyze",
f"CLI AI usage analysis of {url}",
metadata={"uid": uid, "failed": bool(exit_code)},
)
raise SystemExit(exit_code)
def register_jobs(subparsers):
zips = subparsers.add_parser("zips", help="Zip archive job management")
zips_sub = zips.add_subparsers(title="action", dest="action")
zips_prune = zips_sub.add_parser(
"prune", help="Delete expired zip archives and their job rows"
)
zips_prune.set_defaults(func=cmd_zips_prune)
zips_clear = zips_sub.add_parser(
"clear", help="Delete every zip archive and job row"
)
zips_clear.set_defaults(func=cmd_zips_clear)
forks = subparsers.add_parser("forks", help="Fork job management")
forks_sub = forks.add_subparsers(title="action", dest="action")
forks_prune = forks_sub.add_parser(
"prune", help="Delete expired completed fork job rows (forked projects persist)"
)
forks_prune.set_defaults(func=cmd_forks_prune)
forks_clear = forks_sub.add_parser(
"clear", help="Delete every fork job row (forked projects persist)"
)
forks_clear.set_defaults(func=cmd_forks_clear)
seo = subparsers.add_parser("seo", help="SEO Diagnostics job management")
seo_sub = seo.add_subparsers(title="action", dest="action")
seo_prune = seo_sub.add_parser(
"prune", help="Delete expired SEO audit reports and their job rows"
)
seo_prune.set_defaults(func=cmd_seo_prune)
seo_clear = seo_sub.add_parser(
"clear", help="Delete every SEO audit report and job row"
)
seo_clear.set_defaults(func=cmd_seo_clear)
seo_meta = subparsers.add_parser("seo-meta", help="SEO metadata job management")
seo_meta_sub = seo_meta.add_subparsers(title="action", dest="action")
seo_meta_prune = seo_meta_sub.add_parser(
"prune", help="Delete expired SEO metadata job rows (generated metadata persists)"
)
seo_meta_prune.set_defaults(func=cmd_seo_meta_prune)
seo_meta_clear = seo_meta_sub.add_parser(
"clear", help="Delete every SEO metadata job row (generated metadata persists)"
)
seo_meta_clear.set_defaults(func=cmd_seo_meta_clear)
deepsearch = subparsers.add_parser("deepsearch", help="DeepSearch job management")
deepsearch_sub = deepsearch.add_subparsers(title="action", dest="action")
deepsearch_prune = deepsearch_sub.add_parser(
"prune", help="Delete expired DeepSearch sessions and their job rows"
)
deepsearch_prune.set_defaults(func=cmd_deepsearch_prune)
deepsearch_clear = deepsearch_sub.add_parser(
"clear", help="Delete every DeepSearch session and job row"
)
deepsearch_clear.set_defaults(func=cmd_deepsearch_clear)
isslop = subparsers.add_parser("isslop", help="AI Usage Analyzer job management")
isslop_sub = isslop.add_subparsers(title="action", dest="action")
isslop_prune = isslop_sub.add_parser(
"prune", help="Delete expired AI usage analysis job rows (analyses and reports persist)"
)
isslop_prune.set_defaults(func=cmd_isslop_prune)
isslop_clear = isslop_sub.add_parser(
"clear", help="Delete every AI usage analysis, its report and job rows"
)
isslop_clear.set_defaults(func=cmd_isslop_clear)
isslop_analyze = isslop_sub.add_parser(
"analyze", help="Run a AI usage analysis from the terminal and persist its report"
)
isslop_analyze.add_argument("url", help="Repository or website URL to classify")
isslop_analyze.add_argument("--json", action="store_true", help="Emit raw JSON events")
isslop_analyze.add_argument("--allow-private", action="store_true", dest="allow_private", help="Permit private and loopback hosts")
isslop_analyze.set_defaults(func=cmd_isslop_analyze)
-46
View File
@@ -1,46 +0,0 @@
# retoor <retoor@molodetz.nl>
import argparse
import sys
from devplacepy.cli.roles import register_roles
from devplacepy.cli.apikeys import register_apikeys
from devplacepy.cli.tokens import register_tokens
from devplacepy.cli.news import register_news
from devplacepy.cli.attachments import register_attachments
from devplacepy.cli.devii import register_devii
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
def build_parser():
parser = argparse.ArgumentParser(description="DevPlace admin CLI")
sub = parser.add_subparsers(title="commands", dest="command")
register_roles(sub)
register_apikeys(sub)
register_tokens(sub)
register_news(sub)
register_attachments(sub)
register_devii(sub)
register_jobs(sub)
register_backups(sub)
register_containers(sub)
register_migrate(sub)
return parser
def main():
parser = build_parser()
args = parser.parse_args()
if hasattr(args, "func"):
args.func(args)
else:
parser.print_help()
sys.exit(1)
if __name__ == "__main__":
main()
-233
View File
@@ -1,233 +0,0 @@
# retoor <retoor@molodetz.nl>
import sys
from devplacepy.cli._shared import _audit_cli
def _crc32(path):
import zlib
crc = 0
with open(path, "rb") as handle:
while True:
chunk = handle.read(1024 * 1024)
if not chunk:
break
crc = zlib.crc32(chunk, crc)
return crc & 0xFFFFFFFF
def _migrate_file(source, dest, dry_run, report):
import os
import shutil
if not source.exists():
return
if source.resolve() == dest.resolve():
return
size = source.stat().st_size
if dest.exists():
if dest.stat().st_size == size and _crc32(dest) == _crc32(source):
report.append(("done", source, dest, size))
if not dry_run:
source.unlink()
return
report.append(("conflict", source, dest, size))
return
report.append(("move", source, dest, size))
if dry_run:
return
dest.parent.mkdir(parents=True, exist_ok=True)
tmp = dest.with_name(dest.name + ".migrating")
shutil.copyfile(source, tmp)
with open(tmp, "rb") as handle:
os.fsync(handle.fileno())
if tmp.stat().st_size != size or _crc32(tmp) != _crc32(source):
tmp.unlink(missing_ok=True)
raise RuntimeError(f"verification failed copying {source} -> {dest}")
os.replace(tmp, dest)
source.unlink()
def _prune_empty_dirs(root):
if not root.exists():
return
for path in sorted(root.rglob("*"), reverse=True):
if path.is_dir():
try:
path.rmdir()
except OSError:
pass
try:
root.rmdir()
except OSError:
pass
def _migrate_tree(source, dest, dry_run, report):
if not source.exists():
return
if source.resolve() == dest.resolve():
return
for child in sorted(source.rglob("*")):
if child.is_file():
_migrate_file(child, dest / child.relative_to(source), dry_run, report)
if not dry_run:
_prune_empty_dirs(source)
def _db_is_locked(path):
import sqlite3
try:
conn = sqlite3.connect(str(path), timeout=0.5)
try:
conn.execute("BEGIN IMMEDIATE")
conn.rollback()
return False
finally:
conn.close()
except sqlite3.OperationalError:
return True
def _checkpoint(path):
import sqlite3
conn = sqlite3.connect(str(path), timeout=5)
try:
conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
conn.commit()
finally:
conn.close()
def _migrate_db(source, dest, dry_run, report):
if not source.exists():
return
if source.resolve() == dest.resolve():
return
if _db_is_locked(source):
raise RuntimeError(
f"{source} is locked - stop the app before running migrate-data"
)
if not dry_run:
_checkpoint(source)
_migrate_file(source, dest, dry_run, report)
for suffix in ("-wal", "-shm"):
_migrate_file(
source.with_name(source.name + suffix),
dest.with_name(dest.name + suffix),
dry_run,
report,
)
def cmd_emoji_sync(args):
from devplacepy.rendering import EMOJI_JS_PATH, write_emoji_module
count = write_emoji_module()
_audit_cli("cli.emoji.sync", f"CLI regenerated {count} emoji shortcodes", metadata={"count": count})
print(f"Wrote {count} emoji shortcodes to {EMOJI_JS_PATH}")
def cmd_migrate_data(args):
import os
from pathlib import Path
from collections import Counter
from devplacepy import config
base = config.BASE_DIR
home = Path.home()
dry = args.dry_run
report = []
config.ensure_data_dirs()
db_items = []
if config.DATABASE_URL == f"sqlite:///{config.DATA_DIR / 'devplace.db'}":
db_items.append((base / "devplace.db", config.DATA_DIR / "devplace.db"))
else:
print("Skipping main DB: DEVPLACE_DATABASE_URL points outside the data dir.")
if not os.environ.get("DEVII_TASKS_DB"):
db_items.append((base / "devii_tasks.db", config.DEVII_TASKS_DB))
if not os.environ.get("DEVII_LESSONS_DB"):
db_items.append((base / "devii_lessons.db", config.DEVII_LESSONS_DB))
file_items = [
(base / name, config.KEYS_DIR / name)
for name in (
"notification-private.pem",
"notification-private.pkcs8.pem",
"notification-public.pem",
)
]
registry_dest = config.BOT_DIR / "article_registry.json"
registry_sources = [
path
for path in (
home / ".dpbot_article_registry.json",
base / ".dpbot_article_registry.json",
)
if path.exists()
]
registry_sources.sort(key=lambda path: path.stat().st_mtime, reverse=True)
if registry_sources:
file_items.append((registry_sources[0], registry_dest))
for stale in registry_sources[1:]:
print(f"Leaving older duplicate registry untouched: {stale}")
legacy_var = base / "var"
tree_items = [
(base / "devplacepy" / "static" / "uploads", config.UPLOADS_DIR),
(home / ".devplace_bots", config.BOT_DIR),
]
for sub_name in ("container_workspaces", "zips", "zip_staging", "fork_staging"):
tree_items.append((legacy_var / sub_name, config.DATA_PATHS[sub_name]))
try:
for source, dest in db_items:
_migrate_db(source, dest, dry, report)
for source, dest in file_items:
_migrate_file(source, dest, dry, report)
for source, dest in tree_items:
_migrate_tree(source, dest, dry, report)
except RuntimeError as exc:
print(f"ERROR: {exc}")
sys.exit(1)
if not report:
print("Nothing to migrate; the data directory is already consolidated.")
return
for status, source, dest, size in report:
print(f" [{status}] {source} -> {dest} ({size} bytes)")
counts = Counter(status for status, *_ in report)
print()
print(
("Planned: " if dry else "Migrated: ")
+ ", ".join(f"{count} {status}" for status, count in sorted(counts.items()))
)
if any(status == "conflict" for status, *_ in report):
print(
"Conflicts left both source and destination untouched; resolve them by hand."
)
if dry:
print("Dry run - nothing changed. Re-run without --dry-run to apply.")
def register_migrate(subparsers):
subparsers.add_parser(
"emoji-sync",
help="Regenerate static/js/emoji-shortcodes.js from the emoji library",
).set_defaults(func=cmd_emoji_sync)
migrate = subparsers.add_parser(
"migrate-data",
help="Relocate legacy runtime files into the consolidated data/ directory",
)
migrate.add_argument(
"--dry-run",
action="store_true",
help="Print the source-to-destination plan without changing anything",
)
migrate.set_defaults(func=cmd_migrate_data)
-53
View File
@@ -1,53 +0,0 @@
# retoor <retoor@molodetz.nl>
from devplacepy.utils import strip_html
from devplacepy.cli._shared import _audit_cli
def cmd_news_clear(args):
from devplacepy.database import db
deleted = {}
for table in ("news", "news_images", "news_sync"):
if table in db.tables:
count = db[table].count()
db[table].delete()
deleted[table] = count
print(f"Deleted {count} rows from '{table}'")
else:
print(f"Table '{table}' does not exist, skipping")
_audit_cli("cli.news.clear", "CLI cleared all news data", metadata={"deleted": deleted})
print("News data cleared")
def cmd_news_sanitize(args):
from devplacepy.database import db
if "news" not in db.tables:
print("News table does not exist")
return
news_table = db["news"]
updated = 0
for row in news_table.all():
desc = (strip_html(row.get("description", "") or ""))[:5000]
content = (strip_html(row.get("content", "") or ""))[:10000]
if desc != row.get("description", "") or content != row.get("content", ""):
news_table.update(
{"id": row["id"], "description": desc, "content": content}, ["id"]
)
updated += 1
_audit_cli("cli.news.sanitize", f"CLI sanitized {updated} news articles", metadata={"count": updated})
print(f"Sanitized {updated} news article(s)")
def register_news(subparsers):
news = subparsers.add_parser("news", help="News management")
news_sub = news.add_subparsers(title="action", dest="action")
news_clear = news_sub.add_parser(
"clear", help="Delete all news from local database"
)
news_clear.set_defaults(func=cmd_news_clear)
news_sanitize = news_sub.add_parser(
"sanitize", help="Strip HTML from all existing news descriptions and content"
)
news_sanitize.set_defaults(func=cmd_news_sanitize)
-57
View File
@@ -1,57 +0,0 @@
# retoor <retoor@molodetz.nl>
import sys
from devplacepy.database import get_table, invalidate_admins_cache
from devplacepy.cli._shared import _audit_cli
def cmd_role_get(args):
users = get_table("users")
user = users.find_one(username=args.username)
if not user:
print(f"User '{args.username}' not found")
sys.exit(1)
print(user.get("role", "member").lower())
def cmd_role_set(args):
role = args.role.lower()
if role not in ("member", "admin"):
print("Role must be 'member' or 'admin'")
sys.exit(1)
users = get_table("users")
user = users.find_one(username=args.username)
if not user:
print(f"User '{args.username}' not found")
sys.exit(1)
old_role = user.get("role")
users.update({"uid": user["uid"], "role": role.capitalize()}, ["uid"])
invalidate_admins_cache()
from devplacepy.services.audit import record as audit
_audit_cli(
"cli.role.set",
f"CLI set role of user {args.username} from {old_role} to {role.capitalize()}",
metadata={"old": old_role, "new": role.capitalize()},
target_type="user",
target_uid=user["uid"],
target_label=args.username,
links=[audit.target("user", user["uid"], args.username)],
)
print(f"User '{args.username}' role set to '{role}'")
def register_roles(subparsers):
role = subparsers.add_parser("role", help="Manage user roles")
role_sub = role.add_subparsers(title="action", dest="action")
role_get = role_sub.add_parser("get", help="Get a user's role")
role_get.add_argument("username")
role_get.set_defaults(func=cmd_role_get)
role_set = role_sub.add_parser("set", help="Set a user's role")
role_set.add_argument("username")
role_set.add_argument("role", choices=["member", "admin"])
role_set.set_defaults(func=cmd_role_set)
-125
View File
@@ -1,125 +0,0 @@
# retoor <retoor@molodetz.nl>
import sys
from devplacepy.database import get_table
from devplacepy.cli._shared import _audit_cli
def cmd_token_issue(args):
from devplacepy.services.access_tokens import issue_token
users = get_table("users")
user = users.find_one(username=args.username)
if not user:
print(f"User '{args.username}' not found")
sys.exit(1)
result = issue_token(user, label=args.label or "cli")
_audit_cli(
"cli.token.issue",
f"CLI issued access token for {args.username}",
target_type="user",
target_uid=user["uid"],
target_label=args.username,
metadata={"token_uid": result["uid"]},
)
print(result["access_token"])
def cmd_token_list(args):
from datetime import datetime, timezone
users = get_table("users")
user = users.find_one(username=args.username)
if not user:
print(f"User '{args.username}' not found")
sys.exit(1)
tokens = get_table("access_tokens")
now = datetime.now(timezone.utc)
found = False
for t in tokens.find(user_uid=user["uid"], deleted_at=None):
found = True
expires_at = t.get("expires_at", "")
try:
expires = datetime.fromisoformat(expires_at)
if expires.tzinfo is None:
expires = expires.replace(tzinfo=timezone.utc)
status = "expired" if expires < now else "active"
except (ValueError, TypeError):
status = "unknown"
label = t.get("label", "") or "-"
print(
f" uid={t['uid']} token={t['token'][:12]}... "
f"label={label} expires={expires_at} status={status}"
)
if not found:
print(f"No active tokens for '{args.username}'")
def cmd_token_revoke(args):
from devplacepy.services.access_tokens import revoke_token
ok = revoke_token(args.token_uid)
if not ok:
print(f"Token uid='{args.token_uid}' not found or already revoked")
sys.exit(1)
_audit_cli(
"cli.token.revoke",
f"CLI revoked access token uid={args.token_uid}",
metadata={"token_uid": args.token_uid},
)
print(f"Revoked token uid='{args.token_uid}'")
def cmd_token_revoke_all(args):
from devplacepy.services.access_tokens import revoke_all
users = get_table("users")
user = users.find_one(username=args.username)
if not user:
print(f"User '{args.username}' not found")
sys.exit(1)
count = revoke_all(user["uid"])
_audit_cli(
"cli.token.revoke_all",
f"CLI revoked all access tokens for {args.username}",
target_type="user",
target_uid=user["uid"],
target_label=args.username,
metadata={"count": count},
)
print(f"Revoked {count} token(s) for '{args.username}'")
def cmd_token_prune(args):
from devplacepy.services.access_tokens import prune_expired
count = prune_expired()
_audit_cli(
"cli.token.prune",
f"CLI pruned {count} expired access tokens",
metadata={"count": count},
)
print(f"Pruned {count} expired token(s)")
def register_tokens(subparsers):
token = subparsers.add_parser("token", help="Manage DevPlace access tokens")
token_sub = token.add_subparsers(title="action", dest="action")
token_issue = token_sub.add_parser("issue", help="Issue an access token for a user")
token_issue.add_argument("username")
token_issue.add_argument("--label", default="cli", help="Optional label for the token")
token_issue.set_defaults(func=cmd_token_issue)
token_list = token_sub.add_parser("list", help="List a user's active access tokens")
token_list.add_argument("username")
token_list.set_defaults(func=cmd_token_list)
token_revoke = token_sub.add_parser("revoke", help="Revoke a single access token by uid")
token_revoke.add_argument("token_uid")
token_revoke.set_defaults(func=cmd_token_revoke)
token_revoke_all = token_sub.add_parser("revoke-all", help="Revoke all access tokens for a user")
token_revoke_all.add_argument("username")
token_revoke_all.set_defaults(func=cmd_token_revoke_all)
token_prune = token_sub.add_parser("prune", help="Soft-delete all expired access tokens")
token_prune.set_defaults(func=cmd_token_prune)
+19 -47
View File
@@ -11,6 +11,11 @@ BASE_DIR = Path(__file__).resolve().parent.parent
STATIC_DIR = BASE_DIR / "devplacepy" / "static"
TEMPLATES_DIR = BASE_DIR / "devplacepy" / "templates"
# Single source of truth for every runtime/user-generated artifact. Everything the
# app creates or modifies at runtime lives under DATA_DIR, never inside the package
# and never served via /static. Overridable by DEVPLACE_DATA_DIR (point at a volume
# in production). Each path below is derived from DATA_DIR; no other module computes
# a runtime path from scratch.
DATA_DIR = Path(environ.get("DEVPLACE_DATA_DIR", str(BASE_DIR / "data")))
UPLOADS_DIR = DATA_DIR / "uploads"
ATTACHMENTS_DIR = UPLOADS_DIR / "attachments"
@@ -19,17 +24,9 @@ CONTAINER_WORKSPACES_DIR = DATA_DIR / "container_workspaces"
ZIPS_DIR = DATA_DIR / "zips"
ZIP_STAGING_DIR = DATA_DIR / "zip_staging"
FORK_STAGING_DIR = DATA_DIR / "fork_staging"
BACKUPS_DIR = DATA_DIR / "backups"
BACKUP_STAGING_DIR = DATA_DIR / "backup_staging"
SEO_REPORTS_DIR = DATA_DIR / "seo_reports"
PLANNING_REPORTS_DIR = DATA_DIR / "planning_reports"
DBAPI_DIR = DATA_DIR / "dbapi"
DEEPSEARCH_DIR = DATA_DIR / "deepsearch"
DEEPSEARCH_CHROMA_DIR = DEEPSEARCH_DIR / "chroma"
ISSLOP_DIR = DATA_DIR / "isslop"
ISSLOP_WORKSPACES_DIR = ISSLOP_DIR / "workspaces"
ISSLOP_RUNS_DIR = ISSLOP_DIR / "runs"
ISSLOP_MEDIA_DIR = ISSLOP_DIR / "media"
KEYS_DIR = DATA_DIR / "keys"
BOT_DIR = DATA_DIR / "bot"
LOCKS_DIR = DATA_DIR / "locks"
@@ -47,20 +44,12 @@ SESSION_MAX_AGE_REMEMBER = SECONDS_PER_DAY * 30
PORT = 10500
SITE_URL = environ.get("DEVPLACE_SITE_URL", "").rstrip("/")
PRESENCE_TIMEOUT_SECONDS = int(environ.get("DEVPLACE_PRESENCE_TIMEOUT_SECONDS", "60"))
PRESENCE_WRITE_SECONDS = max(1, PRESENCE_TIMEOUT_SECONDS // 2)
PRESENCE_ONLINE_LIMIT = int(environ.get("DEVPLACE_PRESENCE_ONLINE_LIMIT", "30"))
PRESENCE_ONLINE_MARGIN_SECONDS = int(
environ.get("DEVPLACE_PRESENCE_ONLINE_MARGIN_SECONDS", "20")
)
XMLRPC_BIND = environ.get("DEVPLACE_XMLRPC_BIND", "127.0.0.1")
XMLRPC_PORT = int(environ.get("DEVPLACE_XMLRPC_PORT", "10550"))
# Cache-busting version stamped onto every app-owned static URL. Computed once at
# process start, so a restart/deploy changes it and busts every browser cache while
# assets stay heavily cached (immutable, 1 year) between deploys. Set
# DEVPLACE_STATIC_VERSION at launch so multiple prod workers share one value.
STATIC_VERSION = environ.get("DEVPLACE_STATIC_VERSION") or str(int(time.time()))
TEMPLATE_AUTO_RELOAD = environ.get("DEVPLACE_TEMPLATE_AUTO_RELOAD", "1") != "0"
INTERNAL_BASE_URL = environ.get(
"DEVPLACE_INTERNAL_BASE_URL", f"http://localhost:{PORT}"
).rstrip("/")
@@ -68,30 +57,18 @@ INTERNAL_GATEWAY_URL = f"{INTERNAL_BASE_URL}/openai/v1/chat/completions"
INTERNAL_EMBED_URL = f"{INTERNAL_BASE_URL}/openai/v1/embeddings"
INTERNAL_MODEL = "molodetz"
INTERNAL_EMBED_MODEL = "molodetz~embed"
INTERNAL_IMAGE_MODEL = "molodetz-img-small"
AWARD_GIVE_COOLDOWN_HOURS_DEFAULT = 24
AWARD_RECEIVE_COOLDOWN_HOURS_DEFAULT = 24
AWARD_DISPLAY_HOURS_DEFAULT = 24
AWARD_DESCRIPTION_MAX = 125
AWARD_IMAGE_MODEL_DEFAULT = "molodetz-img-small"
AWARD_IMAGE_SIZE_DEFAULT = "512x512"
AWARD_GENERATION_TIMEOUT_SECONDS = 120.0
AWARD_IMAGE_PROMPT_DEFAULT = (
"Generate a single decorative developer award emblem/badge as a PNG with a fully "
"transparent background (alpha channel). No rectangular backdrop, no drop shadow "
"plate, no text labels rendered in the image. Center one stylized trophy/medal "
"icon that visually matches this message:"
)
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`"
)
SERVICE_LOCK_FILE = LOCKS_DIR / "devplace-services.lock"
INIT_LOCK_FILE = LOCKS_DIR / "devplace-init.lock"
# Every container instance runs this one prebuilt image (built once via `make ppy`).
CONTAINER_IMAGE = environ.get("DEVPLACE_CONTAINER_IMAGE", "ppy:latest")
# Override host the /p/<slug> ingress proxy dials, with the published host port,
# instead of the container's own bridge IP. Set this only for topologies where
# the app cannot route to the container network directly (containerized app via
# docker socket: use host.docker.internal). Left empty, the proxy connects
# straight to the container IP and container port, which avoids the host
# port-publishing layer (docker-proxy / iptables DNAT / loopback) entirely.
CONTAINER_PROXY_HOST = environ.get("DEVPLACE_CONTAINER_PROXY_HOST", "").strip()
VAPID_PRIVATE_KEY_FILE = KEYS_DIR / "notification-private.pem"
@@ -99,6 +76,9 @@ VAPID_PRIVATE_KEY_PKCS8_FILE = KEYS_DIR / "notification-private.pkcs8.pem"
VAPID_PUBLIC_KEY_FILE = KEYS_DIR / "notification-public.pem"
VAPID_SUB = environ.get("DEVPLACE_VAPID_SUB", "mailto:retoor@molodetz.nl")
# Documented registry of every runtime directory. ensure_data_dirs() creates them
# all at startup so the tree always exists before the DB, keys, locks, uploads, and
# job staging are written.
DATA_PATHS: dict[str, Path] = {
"data": DATA_DIR,
"uploads": UPLOADS_DIR,
@@ -108,17 +88,9 @@ DATA_PATHS: dict[str, Path] = {
"zips": ZIPS_DIR,
"zip_staging": ZIP_STAGING_DIR,
"fork_staging": FORK_STAGING_DIR,
"backups": BACKUPS_DIR,
"backup_staging": BACKUP_STAGING_DIR,
"seo_reports": SEO_REPORTS_DIR,
"planning_reports": PLANNING_REPORTS_DIR,
"dbapi": DBAPI_DIR,
"deepsearch": DEEPSEARCH_DIR,
"deepsearch_chroma": DEEPSEARCH_CHROMA_DIR,
"isslop": ISSLOP_DIR,
"isslop_workspaces": ISSLOP_WORKSPACES_DIR,
"isslop_runs": ISSLOP_RUNS_DIR,
"isslop_media": ISSLOP_MEDIA_DIR,
"keys": KEYS_DIR,
"bot": BOT_DIR,
"locks": LOCKS_DIR,
+1 -1
View File
@@ -1,6 +1,6 @@
# retoor <retoor@molodetz.nl>
TOPICS = ["devlog", "showcase", "question", "rant", "fun", "random", "politics"]
TOPICS = ["devlog", "showcase", "question", "rant", "fun", "random", "signals"]
REACTION_EMOJI = [
"\U0001f44d",
+3 -375
View File
@@ -13,17 +13,10 @@ from devplacepy.database import (
resolve_by_slug,
get_users_by_uids,
get_vote_counts,
STAR_TARGETS,
get_user_votes,
get_reactions_by_targets,
get_user_bookmarks,
get_blocked_uids,
get_poll_for_post,
update_target_stars,
clear_user_stars,
clear_user_post_count,
get_target_owner_uid,
resolve_object_url,
soft_delete,
soft_delete_in,
soft_delete_engagement,
@@ -37,18 +30,10 @@ from devplacepy.utils import (
generate_uid,
make_combined_slug,
award_rewards,
track_action,
create_notification,
create_mention_notifications,
is_admin,
is_primary_admin,
XP_COMMENT,
XP_UPVOTE,
)
from devplacepy.services.audit import record as audit
from devplacepy.services.correction import schedule_correction
from devplacepy.services.ai_modifier import schedule_modification
from devplacepy.services.seo_meta import schedule_seo_meta_for_table
CREATE_METADATA_KEYS = ("project_type", "is_private", "language", "topic", "status")
@@ -62,61 +47,12 @@ def is_owner(item: dict | None, user: dict | None) -> bool:
return bool(item and user and item["user_uid"] == user["uid"])
def _owner_is_admin(project: dict) -> bool:
owner_uid = project.get("user_uid")
owner = get_users_by_uids([owner_uid]).get(owner_uid) if owner_uid else None
return is_admin(owner)
def can_view_project(project: dict | None, user: dict | None) -> bool:
if not project:
return False
if not project.get("is_private"):
return True
if is_owner(project, user):
return True
if not is_admin(user):
return False
return not _owner_is_admin(project)
def owns_instance(
instance: dict | None, project: dict | None, user: dict | None
) -> bool:
if not instance or not user:
return False
uid = user.get("uid")
if not uid:
return False
if instance.get("created_by") == uid:
return True
return bool(project and project.get("user_uid") == uid)
def can_view_project_containers(project: dict | None, user: dict | None) -> bool:
if not project or not is_admin(user):
return False
if is_primary_admin(user) or is_owner(project, user):
return True
return not project.get("is_private")
def can_view_instance(
instance: dict | None, project: dict | None, user: dict | None
) -> bool:
if not instance or not is_admin(user):
return False
if is_primary_admin(user) or owns_instance(instance, project, user):
return True
return bool(project) and not project.get("is_private")
def can_manage_instance(
instance: dict | None, project: dict | None, user: dict | None
) -> bool:
if not instance or not is_admin(user):
return False
return is_primary_admin(user) or owns_instance(instance, project, user)
return is_owner(project, user) or is_admin(user)
def canonical_redirect(
@@ -164,12 +100,6 @@ def create_content_item(
**fields,
}
)
if table_name == "posts":
clear_user_post_count(user["uid"])
if table_name == "projects":
from devplacepy.templating import clear_user_projects_cache
clear_user_projects_cache(user["uid"])
award_rewards(user["uid"], xp, badge)
if attachment_uids:
link_attachments(attachment_uids, target_type, uid)
@@ -195,298 +125,9 @@ def create_content_item(
metadata=metadata or None,
links=links,
)
schedule_correction(user, table_name, uid, request)
schedule_modification(user, table_name, uid, request)
schedule_seo_meta_for_table(table_name, uid)
return uid, slug
VOTE_NOTIFY_TYPES = {"post", "comment", "gist", "project"}
def apply_vote(request, user: dict, target_type: str, target_uid: str, value: int) -> dict:
votes = get_table("votes")
existing = votes.find_one(
user_uid=user["uid"], target_uid=target_uid, target_type=target_type
)
old_value = int(existing["value"]) if existing else 0
did_upvote = False
new_value = value
if existing:
if existing.get("deleted_at"):
votes.update(
{
"id": existing["id"],
"value": value,
"deleted_at": None,
"deleted_by": None,
},
["id"],
)
did_upvote = value == 1
elif int(existing["value"]) == value:
votes.update(
{"id": existing["id"], "deleted_at": _now_iso(), "deleted_by": user["uid"]},
["id"],
)
new_value = 0
else:
votes.update({"id": existing["id"], "value": value}, ["id"])
did_upvote = value == 1
else:
votes.insert(
{
"uid": generate_uid(),
"user_uid": user["uid"],
"target_uid": target_uid,
"target_type": target_type,
"value": value,
"created_at": datetime.now(timezone.utc).isoformat(),
"deleted_at": None,
"deleted_by": None,
}
)
did_upvote = value == 1
up_count = votes.count(
target_uid=target_uid, target_type=target_type, value=1, deleted_at=None
)
down_count = votes.count(
target_uid=target_uid, target_type=target_type, value=-1, deleted_at=None
)
net = up_count - down_count
update_target_stars(target_type, target_uid, net)
owner_uid = get_target_owner_uid(target_type, target_uid)
if owner_uid:
clear_user_stars(owner_uid)
direction = "clear" if new_value == 0 else ("up" if new_value == 1 else "down")
vote_links = [audit.target(target_type, target_uid)]
if owner_uid and owner_uid != user["uid"]:
vote_links.append(audit.author(owner_uid))
audit.record(
request,
f"vote.{target_type}.{direction}",
user=user,
target_type=target_type,
target_uid=target_uid,
old_value=old_value,
new_value=new_value,
metadata={"value_old": old_value, "value_new": new_value, "net": net},
summary=f"{user['username']} {direction} vote on {target_type} {target_uid}",
links=vote_links,
)
if did_upvote and target_type in VOTE_NOTIFY_TYPES:
if owner_uid and owner_uid != user["uid"]:
target_url = resolve_object_url(target_type, target_uid)
create_notification(
owner_uid,
"vote",
f"{user['username']} ++'d your {target_type}",
user["uid"],
target_url,
)
award_rewards(owner_uid, XP_UPVOTE)
if did_upvote:
track_action(user["uid"], "vote")
current = votes.find_one(
user_uid=user["uid"],
target_uid=target_uid,
target_type=target_type,
deleted_at=None,
)
current_value = int(current["value"]) if current else 0
return {"net": net, "up": up_count, "down": down_count, "value": current_value}
def create_comment_record(
request,
user: dict,
target_type: str,
target_uid: str,
content: str,
parent_uid: str | None = None,
attachment_uids: list | None = None,
) -> tuple[str, str]:
comment_uid = generate_uid()
redirect_url = resolve_object_url(target_type, target_uid)
insert = {
"uid": comment_uid,
"target_uid": target_uid,
"target_type": target_type,
"user_uid": user["uid"],
"content": content,
"parent_uid": parent_uid or None,
"created_at": datetime.now(timezone.utc).isoformat(),
"deleted_at": None,
"deleted_by": None,
}
if target_type == "post":
insert["post_uid"] = target_uid
get_table("comments").insert(insert)
if attachment_uids:
link_attachments(attachment_uids, "comment", comment_uid)
award_rewards(user["uid"], XP_COMMENT, "First Comment")
comment_url = f"{redirect_url}#comment-{comment_uid}"
if target_type == "post":
if parent_uid:
parent = get_table("comments").find_one(uid=parent_uid, deleted_at=None)
if parent and parent["user_uid"] != user["uid"]:
create_notification(
parent["user_uid"],
"reply",
f"{user['username']} replied to your comment",
user["uid"],
comment_url,
)
else:
posts = get_table("posts")
post = posts.find_one(uid=target_uid)
if not post:
post = posts.find_one(slug=target_uid)
if post and post["user_uid"] != user["uid"]:
create_notification(
post["user_uid"],
"comment",
f"{user['username']} commented on your post",
user["uid"],
comment_url,
)
create_mention_notifications(content, user["uid"], comment_url)
schedule_correction(user, "comments", comment_uid, request)
schedule_modification(user, "comments", comment_uid, request)
logger.info(f"Comment by {user['username']} on {target_type} {target_uid}")
comment_links = [
audit.target("comment", comment_uid),
audit.parent(target_type, target_uid),
]
if parent_uid:
comment_links.append(audit.link("parent_comment", "comment", parent_uid))
audit.record(
request,
f"comment.create.{target_type}",
user=user,
target_type="comment",
target_uid=comment_uid,
summary=f"{user['username']} commented on {target_type} {target_uid}: {content}",
links=comment_links,
)
return comment_uid, comment_url
def edit_comment_record(request, user: dict, comment: dict, content: str) -> str:
target_type = comment.get("target_type", "post")
target_uid = comment.get("target_uid") or comment.get("post_uid", "")
updated_at = datetime.now(timezone.utc).isoformat()
get_table("comments").update(
{"uid": comment["uid"], "content": content, "updated_at": updated_at}, ["uid"]
)
schedule_correction(user, "comments", comment["uid"], request)
schedule_modification(user, "comments", comment["uid"], request)
logger.info(f"Comment {comment['uid']} edited by {user['username']}")
audit.record(
request,
"comment.edit",
user=user,
target_type="comment",
target_uid=comment["uid"],
summary=f"{user['username']} edited a comment under {target_type} {target_uid}",
links=[
audit.target("comment", comment["uid"]),
audit.parent(target_type, target_uid),
],
)
return updated_at
def delete_comment_record(request, user: dict, comment: dict) -> tuple[str, str]:
target_type = comment.get("target_type", "post")
target_uid = comment.get("target_uid") or comment.get("post_uid", "")
actor = user["uid"]
stamp = _now_iso()
soft_delete_attachments_for("comment", [comment["uid"]], actor)
soft_delete(
"votes", actor, stamp=stamp, target_uid=comment["uid"], target_type="comment"
)
soft_delete_engagement("comment", [comment["uid"]], actor)
soft_delete("comments", actor, stamp=stamp, uid=comment["uid"])
logger.info(f"Comment {comment['uid']} soft-deleted by {user['username']}")
audit.record(
request,
"comment.delete",
user=user,
target_type="comment",
target_uid=comment["uid"],
summary=f"{user['username']} deleted a comment under {target_type} {target_uid}",
links=[
audit.target("comment", comment["uid"]),
audit.parent(target_type, target_uid),
],
)
return target_type, target_uid
def set_bookmark(
request, user: dict, target_type: str, target_uid: str, saved: bool
) -> bool:
bookmarks = get_table("bookmarks")
existing = bookmarks.find_one(
user_uid=user["uid"], target_uid=target_uid, target_type=target_type
)
changed = False
if saved:
if existing and existing.get("deleted_at"):
bookmarks.update(
{"id": existing["id"], "deleted_at": None, "deleted_by": None}, ["id"]
)
changed = True
elif not existing:
bookmarks.insert(
{
"uid": generate_uid(),
"user_uid": user["uid"],
"target_uid": target_uid,
"target_type": target_type,
"created_at": datetime.now(timezone.utc).isoformat(),
"deleted_at": None,
"deleted_by": None,
}
)
changed = True
else:
if existing and not existing.get("deleted_at"):
bookmarks.update(
{
"id": existing["id"],
"deleted_at": _now_iso(),
"deleted_by": user["uid"],
},
["id"],
)
changed = True
if changed:
audit.record(
request,
"bookmark.add" if saved else "bookmark.remove",
user=user,
target_type=target_type,
target_uid=target_uid,
summary=f"{user['username']} {'bookmarked' if saved else 'removed bookmark from'} {target_type} {target_uid}",
links=[audit.target(target_type, target_uid)],
)
if saved:
track_action(user["uid"], "bookmark")
return saved
def detail_context(
request,
user: dict | None,
@@ -549,9 +190,6 @@ def edit_content_item(
"updated_at": datetime.now(timezone.utc).isoformat(),
}
table.update({"uid": item["uid"], **update_fields}, ["uid"])
schedule_correction(user, table_name, item["uid"], request)
schedule_modification(user, table_name, item["uid"], request)
schedule_seo_meta_for_table(table_name, item["uid"], regenerate=True)
logger.info(f"{table_name} {item['uid']} edited by {user['username']}")
label = update_fields.get("title") or item.get("title") or item["uid"]
audit.record(
@@ -619,15 +257,11 @@ def delete_content_item(
soft_delete_engagement(target_type, [item["uid"]], actor)
if comment_uids:
soft_delete_engagement("comment", comment_uids, actor)
if target_type == "post":
clear_user_post_count(item["user_uid"])
if target_type == "project":
from devplacepy.project_files import soft_delete_all_project_files
from devplacepy.templating import clear_user_projects_cache
soft_delete_all_project_files(item["uid"], actor)
soft_delete_fork_relations(item["uid"], actor)
clear_user_projects_cache(item["user_uid"])
soft_delete(table_name, actor, stamp=stamp, uid=item["uid"])
logger.info(f"{table_name} {item['uid']} soft-deleted by {user['username']}")
audit.record(
@@ -650,14 +284,8 @@ def load_detail(
item = resolve_by_slug(get_table(table_name), slug)
if not item:
return None
if user and item["user_uid"] in get_blocked_uids(user["uid"]):
return None
author = get_users_by_uids([item["user_uid"]]).get(item["user_uid"])
if target_type in STAR_TARGETS:
star_count = item.get("stars") or 0
else:
ups, downs = get_vote_counts([item["uid"]])
star_count = ups.get(item["uid"], 0) - downs.get(item["uid"], 0)
ups, downs = get_vote_counts([item["uid"]])
reactions = (
get_reactions_by_targets(target_type, [item["uid"]], user).get(
item["uid"], {"counts": {}, "mine": []}
@@ -674,7 +302,7 @@ def load_detail(
"item": item,
"author": author,
"is_owner": bool(user and user["uid"] == item["user_uid"]),
"star_count": star_count,
"star_count": ups.get(item["uid"], 0) - downs.get(item["uid"], 0),
"my_vote": get_user_votes(user["uid"], [item["uid"]]).get(item["uid"], 0)
if user
else 0,
-133
View File
@@ -1,133 +0,0 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
from collections.abc import AsyncIterator
import httpx
from curl_cffi import CurlHttpVersion
from curl_cffi.requests import AsyncSession
from curl_cffi.requests.exceptions import RequestException, Timeout
IMPERSONATE_TARGET: str = "chrome146"
DEFAULT_TIMEOUT_SECONDS: float = 30.0
STRIP_REQUEST_HEADERS: frozenset[str] = frozenset(
{
"host",
"connection",
"proxy-connection",
"content-length",
"transfer-encoding",
"user-agent",
"accept-encoding",
}
)
STRIP_RESPONSE_HEADERS: frozenset[str] = frozenset(
{
"content-encoding",
"content-length",
"transfer-encoding",
"connection",
}
)
HTTP_VERSION_LABELS: dict[int, bytes] = {
int(CurlHttpVersion.V1_0): b"HTTP/1.0",
int(CurlHttpVersion.V1_1): b"HTTP/1.1",
int(CurlHttpVersion.V2_0): b"HTTP/2",
int(CurlHttpVersion.V2TLS): b"HTTP/2",
int(CurlHttpVersion.V2_PRIOR_KNOWLEDGE): b"HTTP/2",
int(CurlHttpVersion.V3): b"HTTP/3",
int(CurlHttpVersion.V3ONLY): b"HTTP/3",
}
def http_version_for(url: httpx.URL):
if url.scheme == "http":
return CurlHttpVersion.V1_1
return None
def resolve_timeout(request: httpx.Request) -> float:
extension = request.extensions.get("timeout") or {}
for key in ("read", "connect", "pool"):
value = extension.get(key)
if isinstance(value, (int, float)):
return float(value)
return DEFAULT_TIMEOUT_SECONDS
class CurlResponseStream(httpx.AsyncByteStream):
def __init__(self, response: object) -> None:
self._response = response
async def __aiter__(self) -> AsyncIterator[bytes]:
async for chunk in self._response.aiter_content():
yield chunk
async def aclose(self) -> None:
await self._response.aclose()
class CurlTransport(httpx.AsyncBaseTransport):
def __init__(
self,
*,
impersonate: str = IMPERSONATE_TARGET,
verify: bool = True,
proxy: str | None = None,
) -> None:
self._session = AsyncSession()
self._impersonate = impersonate
self._verify = verify
self._proxy = proxy
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
headers = {
key: value
for key, value in request.headers.items()
if key.lower() not in STRIP_REQUEST_HEADERS
}
body = await request.aread()
extra = {}
version = http_version_for(request.url)
if version is not None:
extra["http_version"] = version
try:
response = await self._session.request(
request.method,
str(request.url),
headers=headers,
data=body or None,
impersonate=self._impersonate,
verify=self._verify,
proxy=self._proxy,
stream=True,
allow_redirects=False,
timeout=resolve_timeout(request),
**extra,
)
except Timeout as exc:
raise httpx.ConnectTimeout(str(exc), request=request) from exc
except RequestException as exc:
raise httpx.ConnectError(str(exc), request=request) from exc
response_headers = [
(key, value)
for key, value in response.headers.items()
if key.lower() not in STRIP_RESPONSE_HEADERS
]
http_version = HTTP_VERSION_LABELS.get(int(response.http_version), b"HTTP/2")
return httpx.Response(
status_code=response.status_code,
headers=response_headers,
stream=CurlResponseStream(response),
extensions={"http_version": http_version},
request=request,
)
async def aclose(self) -> None:
await self._session.close()
-12
View File
@@ -36,18 +36,6 @@ def owner_for(request: Request) -> tuple[str, str] | None:
def _overrides_for(request: Request) -> dict:
cached = getattr(request.state, "_custom_overrides", None)
if cached is not None:
return cached
overrides = _resolve_overrides(request)
try:
request.state._custom_overrides = overrides
except Exception:
pass
return overrides
def _resolve_overrides(request: Request) -> dict:
if get_setting("customization_enabled", "1") != "1":
return {"css": "", "js": ""}
owner = owner_for(request)
File diff suppressed because it is too large Load Diff
-200
View File
@@ -1,200 +0,0 @@
This file documents devplacepy/database/ - the dataset/SQLite data layer, indexing rules, and the project-wide soft-delete model. Claude Code loads it automatically whenever a file under this directory is read or edited.
## Database engine and dataset library
SQLite via `dataset` with these pragmas on every connection:
```python
PRAGMA journal_mode=WAL; -- concurrent readers + writers
PRAGMA synchronous=NORMAL; -- safe with WAL mode
PRAGMA busy_timeout=30000; -- wait 30s instead of failing on lock
PRAGMA cache_size=-8000; -- 8MB page cache
PRAGMA temp_store=MEMORY; -- temp tables in memory
```
Configured via `dataset.connect(engine_kwargs={"connect_args": {"timeout": 30, "check_same_thread": False}}, on_connect_statements=[...])`. In addition to the pragmas above, the connection is tuned with a 30s busy timeout, an 8MB page cache, and a 256MB mmap.
`init_db()` is idempotent - every index is created via `CREATE INDEX IF NOT EXISTS` (through the `_index()` helper, wrapped in try/except so it is safe to run on every startup regardless of table state), and seed defaults for `site_settings` only insert when the key doesn't already exist.
**`init_db()` also ensures the full column set of any table that code filters on** (`news`, `news_sync`, `attachments` use `get_table(name)` + `create_column_by_example` before their indexes). This is mandatory, not optional: `dataset` gives a lazily-created table ONLY the columns of its first insert, so a partial insert from a CLI tool/test/maintenance script would otherwise create the table with a reduced schema, and the long-running server caches that stale schema - making later queries on the missing column 500 with `no such column`. When adding a new filtered/indexed column, add it to the matching `init_db()` ensure-block:
```python
news = get_table("news")
for column, example in (("uid", ""), ("status", ""), ("synced_at", ""), ("external_id", ""), ...):
if not news.has_column(column):
news.create_column_by_example(column, example)
_index(db, "news", "idx_news_status", ["status"]) # now safe - column exists
```
Do this with `get_table(name)` (NOT `if name in db.tables`): `get_table` + `create_column_by_example` creates the table if it is absent, so the schema is guaranteed regardless of which process or which insert touches the table first. Symptom to watch for: a page renders fine in isolation but shows empty data or 500s only after a CLI/maintenance test runs earlier in the suite (it created the table partially).
The `_index(...)` helper supports `where=` (partial) and `unique=` indexes; every table with a `uid` column gets a UNIQUE `idx_<table>_uid`, soft-delete tables get a PARTIAL `idx_<table>_trash` (`WHERE deleted_at IS NOT NULL`) and NEVER a bare `deleted_at` index (it mis-steers the planner on live reads), and "live newest-first" listings need a composite/live-partial index that carries the sort column (see "Indexing conventions" below). `init_db()` finishes with `ANALYZE`/`PRAGMA optimize`. Verify any index change with `EXPLAIN QUERY PLAN`.
**SQLite is synchronous by design and will never be made async (hard rule).** The `dataset`/SQLite layer is synchronous and is called directly inside the async route handlers. This is intentional and more than fast enough for this platform: the database is a local file tuned with WAL, `synchronous=NORMAL`, a 30s busy timeout, an 8MB page cache, and a 256MB mmap, so queries are sub-millisecond and never meaningfully stall the event loop. Do NOT convert the database layer to async, do NOT wrap DB calls in a threadpool/`run_in_executor`/`to_thread`, and do NOT raise the synchronous DB access as a performance, blocking-I/O, or "requests aren't fully async" concern. This is a settled, sophisticated design choice and is not open for revisiting.
Runtime config lives in `site_settings`, read via `get_setting(key, default)` / `get_int_setting(key, default)` (60s TTL cache, invalidated cross-worker via the `cache_state` version table - `get_setting` calls `sync_local_cache("settings", ...)`, writes call `bump_cache_version("settings")`; the `_user_cache` in `utils.py` uses the same primitive under the `auth` name). Consumers always pass the production default to `get_setting`, so behavior is correct even before the row exists. Numeric operational values are floored at the call site so an invalid `0` can't lock out writes or stall a service. Booleans are stored as `"0"`/`"1"` and rendered as `<select>` (not checkboxes) because the settings save handler skips empty form values - an unchecked checkbox could never be turned off. See "Site settings" and "Operational settings" below for the full key registry.
Batch helpers (`get_users_by_uids`, `get_comment_counts_by_post_uids`, `get_vote_counts`, `load_comments`, `get_attachments_by_type`) exist specifically to avoid N+1 queries - use them in feed/listing routes instead of per-row lookups.
## Dataset rules (hard-learned)
**`find()` does NOT accept raw SQL strings.** It takes keyword arguments for equality filters, dict comparison operators, or SQLAlchemy column expressions.
```python
# WRONG - causes 500 Internal Server Error:
table.find("created_at >= :start", {"start": today})
table.find(text("created_at >= :start"), start=today)
# CORRECT - dict comparison syntax:
table.find(created_at={">=": today})
# CORRECT - keyword equality:
table.find(country="France")
# CORRECT - SQLAlchemy column expression for IN clause:
table.find(table.table.columns.user_uid.in_(["uid1", "uid2"]))
# CORRECT - multiple equality filters combined:
table.find(topic="devlog", user_uid=some_uid)
```
**`update()` requires a key column list as second argument.** The first dict contains all fields including the key column.
```python
table.update({"uid": user_uid, "bio": "new bio"}, ["uid"])
```
**`db.query()` accepts raw SQL with named params as keyword arguments:**
```python
db.query("SELECT * FROM posts WHERE topic = :t", t="devlog")
# NOT: db.query("...", {"t": "devlog"})
```
**`db.query()` WRITES DO NOT AUTO-COMMIT - wrap any `db.query` INSERT/UPDATE/DELETE in `with db:` (load-bearing, caused a production deadlock).** The dataset table API (`table.insert`/`update`/`delete`) calls `db._auto_commit()` internally, but `db.query()` does NOT. SQLAlchemy 2.x autobegins a transaction on first `execute`, so a raw `db.query` write leaves an open transaction holding the SQLite write lock until that thread's connection next commits. On the request/loop thread this is masked (the next table op's `_auto_commit` flushes it), but on a **background-queue or `run_in_executor` worker thread** the thread goes idle still holding the lock, and EVERY subsequent write app-wide blocks for the 30s busy-timeout then fails `database is locked` - a full deadlock. Always commit raw writes:
```python
with db: # commits + releases the write lock on exit
db.query("INSERT INTO t (...) VALUES (:a) ON CONFLICT(...) DO UPDATE SET ...", a=1)
```
Atomic counters (e.g. `add_correction_usage`) must use raw `ON CONFLICT DO UPDATE SET col = col + excluded.col` (the table API cannot increment), so they MUST use the `with db:` wrapper. Prefer the table API whenever an atomic SQL increment is not required.
**Always check `tables` list before raw SQL queries:**
```python
if "comments" not in db.tables:
return {} # table doesn't exist yet
```
**Batch queries eliminate N+1 problems.** Use `get_users_by_uids()`, `get_comment_counts_by_post_uids()`, and `get_vote_counts()` from `database.py` instead of per-row lookups in loops.
**`init_db()` MUST create every queried column for any table that code filters on, even if the table is created lazily.** dataset creates a table on its FIRST insert and gives it ONLY the columns in that insert. If any code path can insert a *partial* row before the full schema exists (a CLI tool, a test fixture, a maintenance script), the table is born with a reduced schema and every later query against a missing column throws `sqlite3.OperationalError: no such column: X` (a 500), or - for an indexed column - logs a `Could not create index ... no such column` warning at startup. This is worsened by **cross-process metadata staleness**: the long-running uvicorn server reflects a table's columns once and caches them, so a column another process adds afterward is invisible to the server until it reconnects. The defence is to make `init_db()` ensure the complete column set up front, exactly like the existing `news`, `news_sync`, and `attachments` blocks (see the code example under "Database engine and dataset library" above). When you add a NEW column that any query filters/indexes, add it to the `init_db()` ensure-block too - never rely on the first insert to define it.
## Indexing conventions (the soft-delete planner trap)
`init_db()` owns every index. The `_index(db, table, name, columns, *, where=None, unique=False)` helper builds the DDL; it supports **partial** indexes (`where=`) and **unique** indexes, and wraps each `CREATE`/`DROP` in `with db:` (DDL via `db.query` does not auto-commit - see "Dataset rules" above). Three load-bearing rules learned from an `EXPLAIN QUERY PLAN` audit against production data:
- **Every table with a `uid` column gets `idx_<table>_uid` (UNIQUE).** `dataset` makes its own `id` autoincrement PK and does NOT key `uid`, so `find_one(uid=...)`, `resolve_by_slug`, `soft_delete`, and `table.update({...}, ["uid"])` full-SCAN without it. `init_db()` loops `for table in db.tables: _uid_index(db, table)` (falls back to a non-unique index if a UNIQUE build ever fails on legacy duplicate data). New tables are covered automatically.
- **NEVER index the bare `deleted_at` column - use a PARTIAL trash index `WHERE deleted_at IS NOT NULL`.** `ensure_soft_delete_columns` creates `idx_<table>_trash ON (deleted_at) WHERE deleted_at IS NOT NULL` (and drops any legacy full `idx_<table>_deleted`). A full `deleted_at` index is a planner hazard: the column is one giant `NULL` bucket plus many unique delete-timestamps, so `sqlite_stat1` mis-estimates `deleted_at IS NULL` as returning ~2 rows and the planner picks that index for live reads, then `USE TEMP B-TREE FOR ORDER BY` to sort the whole live set (the global feed was doing exactly this, with 82% of posts soft-deleted). The partial index serves the admin Trash view (`deleted_at IS NOT NULL`) cheaply and stops poisoning live `IS NULL` queries.
- **For "live, newest-first" listings add a composite or live-partial index that includes the sort column.** A `WHERE deleted_at IS NULL ORDER BY created_at` query needs the ordering in the index or it filesorts. Posts use a partial `idx_posts_live_created ON (created_at) WHERE deleted_at IS NULL` (feed) plus `idx_posts_user_created (user_uid, created_at)` (profile); comments use `idx_comments_target_created (target_type, target_uid, created_at)`; votes use `idx_votes_user_target (user_uid, target_uid)` (the per-user "my_vote" check on every card); notifications/gists/projects use `(user_uid, created_at)`; follows use `idx_follows_follower_created (follower_uid, created_at)` + `idx_follows_following_created (following_uid, created_at)` (the followers/following tabs sort newest-first; the legacy single-column follower/following indexes were dropped as redundant prefixes). All were verified to drop the `USE TEMP B-TREE FOR ORDER BY` step.
- **Index the non-`uid` lookup keys too, not just the sort/owner columns.** A demand-vs-supply audit added the last missing single-key lookups: the `resolve_by_slug` hot path filters `slug` on content detail pages, so posts/gists/news/projects each get `idx_<table>_slug (slug)`; `get_setting`/`set_setting` filter `key`, so `idx_site_settings_key (key)`; the container store's `find_one(slug=)`/`find_one(name=)` fallbacks get `idx_instances_slug`/`idx_instances_name`. The DM thread load `find(sender_uid=, receiver_uid=)` gets the covering composites `idx_messages_conversation (sender_uid, receiver_uid)` + `idx_messages_conversation_rev (receiver_uid, sender_uid)` (the read-flag `UPDATE` uses the reverse); the badge-has check gets `idx_badges_user_name (user_uid, badge_name)`; the admin user list `ORDER BY -created_at` gets `idx_users_created_at (created_at)` (the existing `(role, created_at)` cannot serve a full-table created_at sort). All are non-unique so `_index` always creates them even if legacy duplicate data exists. Column sets already resolved to ~1 row by an existing prefix index (votes `+target_type`, game_quests `+kind`, poll_options `position`) are intentionally left uncovered - a trailing column there only adds write cost.
`init_db()` ends with `_refresh_query_planner_stats()`: a one-time `ANALYZE` when `sqlite_stat1` is absent, then `PRAGMA optimize` every boot (both inside `with db:`). Validate any index change with `EXPLAIN QUERY PLAN` against `data/devplace.db` (or a copy) - a correct plan reads `SEARCH ... USING INDEX <name>` with no temp b-tree.
## Project-wide soft delete (hard rule)
Attachments (see "Profile media gallery and soft-deleted attachments" below) are one instance of a platform-wide model: **every removal is a soft delete; only garbage collection is a hard delete.** Every removable row carries two columns, `deleted_at` (ISO timestamp) and `deleted_by` (actor uid, or `system`). A live row has `deleted_at = NULL`, and every list/count read filters `deleted_at IS NULL`.
- **Tables (`database.SOFT_DELETE_TABLES`):** posts, comments, gists, projects, news, news_images, project_files, attachments, votes, reactions, bookmarks, follows, poll_votes, polls, poll_options, sessions, instances, instance_schedules, devii_conversations, devii_tasks, devii_lessons, devii_virtual_tools, user_customizations, project_forks. `init_db` loops `ensure_soft_delete_columns(t)` for each (adds both columns + `idx_<t>_deleted` / the partial trash index described above). The Devii task/lesson/virtual-tool stores run on their own DB handles, so their `_ensure_indexes` adds the columns there too.
- **Every INSERT into a soft-deletable table MUST write `deleted_at: None, deleted_by: None`.** `dataset.find(deleted_at=None)` on a table that lacks the column matches NOTHING (a false predicate), silently hiding all rows - the born-live insert is what creates the column. Add the pair to any new insert.
- **Central helpers (`database/`):** `soft_delete(table, deleted_by, *, stamp=None, **criteria)` (equality), `soft_delete_in(table, column, uids, deleted_by, *, stamp=None, **extra)` (IN-clause cascade), `restore(table, **criteria)`, `purge(table, **criteria)` (real delete), `list_deleted(table, page)` / `count_deleted(table)` (trash listings), and the event helpers `restore_event(stamp)` / `purge_event(stamp)` that act across ALL tables sharing one `deleted_at` stamp.
- **Two generic chokepoints are conditionally filtered:** `resolve_by_slug(table, slug, include_deleted=False)` (detail-page lookups; restore passes `include_deleted=True`) and `paginate(table, ...)` (auto-appends `deleted_at IS NULL` when the table has the column and the caller did not pass `deleted_at`). `seo._collect` does the same for the sitemap. Read filters were threaded through every batch helper, analytics/activity/leaderboard UNION, feed/profile/listing route, and store; never re-introduce an unfiltered read of a soft-deletable table.
- **Any new read** (find/count/query) of a soft-deletable table MUST filter `deleted_at IS NULL`. Use the central helpers/chokepoints instead of inline deletes.
- **Toggles revive, they do not duplicate.** votes/reactions/bookmarks/follows/poll_votes look up the physical row regardless of `deleted_at`: toggle-off stamps `deleted_at`; re-toggle clears it on the same row. Counts/state reads filter `deleted_at IS NULL`.
- **Cascades share one stamp.** `content.delete_content_item` soft-deletes the item plus its comments, votes, engagement, project files, fork relations, and attachments with one shared `stamp` and `deleted_by = actor`. That timestamp identifies the whole event, so `restore_event`/`purge_event` reverse or finalize it atomically.
- **Delete authorization is owner-OR-admin, enforced on the endpoint** (`is_owner(...) or is_admin(user)`): posts/gists/projects (`content.delete_content_item`, also rejects a missing item), `comments.delete_comment`, `project_files.project_file_delete`, `media.delete_media`, `uploads.delete_attachment_route`; news (`admin_news_delete`) is admin-only. Because the check is on the endpoint, one rule covers the human UI and **Devii** at once - Devii only ever calls the platform API, authenticated as the signed-in user, so an admin's Devii may soft-delete any member's content and a member's is refused with no agent-side logic. The matching `delete_*` Devii catalog tools stay `requires_auth` (not `requires_admin`) so a member can still delete their own, and every one is in the dispatcher's confirmation gate (`CONFIRM_REQUIRED`) so a delete only runs on a repeat call with `confirm=true`. Standalone `comment` and uploaded-`attachment` deletes are soft like the rest (`soft_delete` cascade / `soft_delete_attachment`); the only attachment hard delete is the admin `/admin/media/{uid}/purge` and the CLI prune. Any NEW content delete path must reuse this guard, soft-delete, and (for the Devii tool) be added to `dispatcher.CONFIRM_REQUIRED`.
- **What stays HARD (GC / the empty-trash stage):** the async-job sweep + CLI prune/clear, the container metrics ring trim, gateway and Devii usage-ledger retention prunes and quota resets, the expired-session cleanup branch in `utils._user_from_session`, fork-rollback of a half-created project, the news-sync image replacement, and the admin **Purge** action. Logout is a soft delete (auditable via `deleted_by`); only expiry GC is hard.
- **Admin Trash surface:** `/admin/trash` (sidebar **Trash**, `routers/admin/` package, `admin_trash.html`, `AdminTrashOut`) lists soft-deleted rows per table with restore/purge per row. Restore calls `restore_event(row.deleted_at)`; Purge calls `purge_event(...)` and unlinks attachment files / project-file blobs. The attachment-specific `/admin/media` view is unchanged. Admin-only docs: `docs/soft-delete.html` (`admin: True`, Administration section).
## Profile media gallery and soft-deleted attachments
The profile **Media** tab (`/profile/{username}?tab=media`, public) is a paginated grid of every attachment a user uploaded, newest first, across all `target_type`s. Attachments support a **soft delete** so a user can remove one upload without affecting its parent object.
- **One `deleted_at` column** on `attachments` (ISO string, mirrors the `push.py` precedent), ensured idempotently in `init_db` via `create_column_by_example("deleted_at", "")` plus the `idx_attachments_user_created` index. `store_attachment` writes `deleted_at: None`.
- **Soft delete preserves the relation and the file.** `attachments.soft_delete_attachment(uid)` only stamps `deleted_at`; it never touches `target_type`/`target_uid` and never unlinks the file. `restore_attachment(uid)` clears `deleted_at`, so the item reappears on its parent object and in the gallery with zero extra bookkeeping.
- **Three read paths filter `deleted_at IS NULL`** so a soft-deleted item vanishes everywhere (the gallery AND its parent post/project/etc.): `get_attachments`, `get_attachments_batch` (both in `attachments.py`), and `database.get_user_media`. **The hard-delete cascades (`delete_attachments_for`, `delete_target_attachments`) stay UNFILTERED** so permanently deleting a parent object still removes ALL its attachment files, including soft-deleted ones - never add the filter there.
- **Queries:** `database.get_user_media(user_uid, page)` (linked, non-deleted, newest first; each item gets a `target_url` via `resolve_object_url`) and `database.get_deleted_media(page)` (the admin trash, joined to uploader username).
- **Authorization:** `POST /media/{uid}/delete` (`routers/media.py`) is owner-or-admin (`attachment["user_uid"] == user["uid"] or is_admin(user)`); `POST /media/{uid}/restore` and `POST /admin/media/{uid}/purge` (the only hard delete, via `delete_attachment`) are admin-only (`routers/admin/` package, sidebar **Media** -> `/admin/media`). The tab itself is public.
- **Frontend:** `_media_gallery.html` reuses the `_attachment_display.html` type branches and the `dp-lightbox` contract (`data-lightbox`/`data-full`). The delete button carries `data-media-delete` + `data-confirm`; `ModalManager.initConfirmations` shows the confirm and `MediaGallery.js` (`app.mediaGallery`) does the optimistic `Http.send` delete, fades the tile, and toasts. A `<noscript>` form is the no-JS fallback. Grid styling is `static/css/media.css`.
- **Devii:** `list_media` (public) and `delete_media` (auth, in `CONFIRM_REQUIRED`) in the catalog.
- **Docs visibility (deliberate):** members and guests must never be told this is a *soft* delete. The public prose page `docs/media-gallery.html` (General) and the member-facing `media-delete` API endpoint (Profiles group) describe deletion as a plain "remove" - no soft-delete, restore, trash, or purge language. All moderation mechanics live on the admin-only `docs/media-moderation` prose page (`admin: True`) and in the admin API group (`media-restore`, `admin-media`, `admin-media-purge`, all `auth="admin"`), which `docs_search` excludes from member results and `routers/docs/` package 404s for non-admins. Because `docs_search._strip` keeps the text *inside* `{% if %}` blocks, admin content must live on a separate `admin: True` page, never inline-gated on a public page (a public page may only carry an admin-gated *link*). The member `MediaItemOut` schema omits `deleted_at`; the admin-only `AdminMediaItemOut` adds it.
## Role-based visibility (generic + DRY)
- **One source of truth for role/visibility checks**, registered as Jinja globals in `templating.py` - never hand-roll `user.get('role') == 'Admin'` or `user['uid'] == x['user_uid']` in a template again:
- `is_admin(user)` (also `utils.is_admin`, reused by `require_admin` and `docs.py`) - admin-only UI.
- `owns(item, user)` (= `content.is_owner`) - per-item ownership (e.g. each comment). Page-level detail templates keep using the `is_owner` **bool** passed in their context (post/gist/project/profile); do not call `is_owner(...)` as a function - that name is a context bool and shadows globals.
- `is_self(user, uid)` - "is this me" (profile follow vs edit, leaderboard highlight).
- `guest_disabled(user)` -> emits ` disabled aria-disabled="true" title="Log in to participate"` for guests (empty for members); `login_hint(user)` -> a small login link. Both return `Markup`.
- **Role values are stored capitalized:** `users.role` is exactly `"Admin"` or `"Member"` (first registered user is `"Admin"`, `auth.py`); `is_admin` compares `== "Admin"` case-sensitively. The CLI is the only lowercase surface (`devplace role set ... <member|admin>` writes `role.capitalize()`; `role get` prints `.lower()`). A lowercase role in the DB silently defeats every admin check - never write a raw lowercase role.
- **The shadow rule generalizes beyond `is_owner` to ANY Jinja global** (`is_admin`, `avatar_url`, `format_date`, `is_self`, `owns`, `guest_disabled`): `respond(req, tmpl, ctx, model=XOut)` hands the **same** `ctx` to the Pydantic model and the template, and a context key shadows the same-named global across the whole `base.html` chain. A bool named `is_admin` in the context makes `base.html`'s `{% if is_admin(user) %}` raise `TypeError: 'bool' object is not callable` - a 500 that only fires for the branch invoking the global (logged-in users, not guests, which is why guest-only smoke tests miss it). Name viewer/permission flags distinctly (`viewer_is_admin`) in both schema and context. Real issue fixed on `/issues/{number}`; regression-guarded by `tests/api/issues/create.py::test_issue_detail_renders_for_{member,admin}` (they render the page as an authenticated Member/Admin and assert 200 + the admin-only control).
- **Policy enforced everywhere:** guests see all non-admin content read-only with action controls **shown but disabled** (`guest_disabled` on vote/star/react/poll/bookmark/follow/comment submit; create FABs become `/auth/login` links via `.feed-fab.login-required`); members get full member actions; **role badges render only to admin viewers** (`{% if is_admin(user) %}` around every `*.role` label). Backend stays the real gate (`require_user`/`require_admin`).
- Docs admin gating is unchanged behaviourally but now uses `is_admin` (`docs_base.html` `DEVPLACE_DOCS.isAdmin`, `docs/index.html`, `docs.py`).
- **Tests:** the role-gating e2e tests across `tests/e2e/` (guest/member/admin via `page`/`bob`/`alice`) are the UI enforcement; `tests/api/auth/matrix.py` is the backend companion. Guest action controls are asserted **disabled** (not absent) - don't reintroduce `count() == 0` assertions for them.
## Database tables
| Table | Purpose |
|-------|---------|
| `news` | All synced articles with `status` (published/draft), `grade`, `slug`, `show_on_landing` |
| `news_images` | Images extracted from article URLs |
| `news_sync` | Sync state per article `guid` - tracks grading history |
The platform-wide soft-delete table set (`database.SOFT_DELETE_TABLES`) is listed in full under "Project-wide soft delete (hard rule)" above.
## Site settings
Site settings are seeded on startup (`site_settings` table):
| Key | Default | Purpose |
|-----|---------|---------|
| `site_name` / `site_description` / `site_tagline` | DevPlace branding | General site metadata |
| `news_grade_threshold` | `"7"` | Minimum AI grade for auto-publish |
| `news_api_url` | `"https://news.app.molodetz.nl/api"` | News source API |
| `news_ai_url` | `"https://openai.app.molodetz.nl/v1/chat/completions"` | AI grading endpoint |
| `news_ai_model` | `"molodetz"` | AI model identifier |
| `max_upload_size_mb` / `allowed_file_types` / `max_attachments_per_resource` | `"10"` / `""` / `"10"` | Upload limits |
| `rate_limit_per_minute` | `"60"` | Mutating requests per IP per window (`main.py` middleware); a `429` carries a `Retry-After: <window>` header |
| `rate_limit_window_seconds` | `"60"` | Rolling window for the rate limit (also the `Retry-After` value) |
| `news_service_interval` | `"3600"` | Seconds between news fetch cycles (`NewsService.run_once` re-reads each cycle) |
| `session_max_age_days` | `"7"` | Standard session cookie + DB session lifetime |
| `session_remember_days` | `"30"` | Remember-me session lifetime |
| `registration_open` | `"1"` | When `"0"`, signup GET shows a closed notice and POST is rejected (`auth.py`) |
| `maintenance_mode` | `"0"` | When `"1"`, non-admins get a 503 (`main.py` maintenance middleware) |
| `maintenance_message` | scheduled-maintenance text | Body shown on the maintenance 503 page |
| `customization_enabled` | `"1"` | When `"0"`, `custom_css_tag`/`custom_js_tag` inject nothing (feature off) |
| `customization_js_enabled` | `"1"` | When `"0"`, custom CSS still serves but custom JS is suppressed |
| `extra_head` | `""` | Raw HTML emitted verbatim into every page `<head>` by `templating.extra_head_tag()`; site-wide trusted-admin input, not sanitized |
Besides the site/news/upload keys above, the **Operational** group is admin-editable at `/admin/settings`: `site_url` (public origin for absolute links incl. container ingress; resolved by `seo.public_base_url()` = setting -> `DEVPLACE_SITE_URL` env -> request origin), `rate_limit_per_minute`, `rate_limit_window_seconds`, `news_service_interval`, `session_max_age_days`, `session_remember_days`, `registration_open`, `maintenance_mode`, `maintenance_message`, `docs_search_mode` (`agent`|`bm25`, default `agent` - picks the `/docs/search.html` surface: the in-page Devii chat or the classic BM25 list; a viewer over their daily AI limit, or a guest when Devii is disabled, auto-falls-back to BM25 via `routers/docs/views.py` `_agent_search_state`), `outbound_proxy_url` (empty by default - when set, every `stealth.stealth_async_client`/`stealth_sync_client` call across the whole app routes through it via `stealth.configured_proxy_url()`; validated as `http(s)://`/`socks5(h)://` with a host in `AdminSettingsForm`; falls back to `DEVPLACE_OUTBOUND_PROXY_URL` when unset - see the "Outbound HTTP" note in the root `CLAUDE.md`). The **Custom Code** key `extra_head` is the sole key in the settings handler's `CLEARABLE_SETTINGS` set, so saving an empty textarea removes it (the default loop skips empty values).
The seed block in `database.py` is guarded by `if "site_settings" in tables:` - on a brand-new DB the table does not exist yet (dataset creates tables lazily on first insert), so none of these rows are written until the table exists. Correct runtime behavior therefore relies on every consumer passing the production default to `get_setting`/`get_int_setting`, not on the seed.
## Operational settings
Operational settings - read sites and rules:
| Setting(s) | Read at | Notes |
|-----------|---------|-------|
| `rate_limit_*` | `rate_limit_middleware` in `main.py` | `max(1, get_int_setting(...))` so `0` can't block all writes |
| `maintenance_mode` / `maintenance_message` | `maintenance_middleware` in `main.py` | Allows `/static`, `/avatar`, `/auth`, `/admin` and admins; everyone else gets `error.html` at 503 |
| `news_service_interval` | `BaseService` reconciling loop via `current_interval()` | `max(60, ...)`; edited on the Services tab (not `/admin/settings`); a change applies on the next cycle |
| `service_<name>_enabled` / `service_<name>_command` / `service_<name>_log_size` | `BaseService` reconciling loop | Generic per-service controls written by the Services tab; the loop reconciles within ~1s |
| `session_max_age_days` / `session_remember_days` | `auth.py` signup + login | Multiplied by `SECONDS_PER_DAY`; passed to `create_session(uid, max_age)` so the cookie and the DB session row expire together |
| `registration_open` | `auth.py` `signup_page` (GET) and `signup` (POST) | POST returns before any DB write when closed |
**Booleans are `<select>`, never checkboxes.** The settings save handler (`admin.py`) skips empty form values so empty fields don't clobber existing rows. An unchecked checkbox submits nothing, so it could never be turned off - `registration_open` and `maintenance_mode` use `<option value="1">`/`<option value="0">` so a value is always submitted.
-252
View File
@@ -1,252 +0,0 @@
# retoor <retoor@molodetz.nl>
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 .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 .usage import _add_usage, _get_usage, add_correction_usage, get_correction_usage, add_modifier_usage, get_modifier_usage, NEWS_USAGE_KEY, add_news_usage, get_news_usage, ISSUE_USAGE_KEY, add_issue_usage, get_issue_usage, SEO_USAGE_KEY, add_seo_usage, get_seo_usage, AWARD_USAGE_KEY, add_award_usage, get_award_usage
from .awards import (
AWARDS_PER_PAGE,
award_display_hours,
award_give_cooldown_hours,
award_receive_cooldown_hours,
award_is_prominent,
can_give_award,
can_receive_award,
count_published_awards,
enrich_award,
get_prominent_award,
get_user_awards,
has_giver_cooldown,
has_receiver_cooldown,
recompute_user_award_stats,
revoke_award,
)
from .seo_meta import SEO_META_TYPES, get_seo_metadata, get_seo_metadata_batch, has_fresh_seo_metadata, upsert_seo_metadata, mark_seo_metadata_stale
from .activity import record_activity, record_unique_activity, get_user_activity, _activity_cache, _ACTIVITY_TABLES, get_activity_calendar, _activity_level, get_first_activity_date, HEATMAP_WEEKS, get_activity_heatmap, get_activity_months, get_streaks
from .customization import CUSTOMIZATION_GLOBAL_SCOPE, CUSTOMIZATION_LANGS, _customizations_cache, _customization_key, CUSTOMIZATION_PREF_COLUMNS, get_customization_prefs, set_customization_pref, get_custom_overrides, get_custom_override, list_custom_overrides, set_custom_override, delete_custom_override
from .email import EMAIL_ACCOUNT_DEFAULTS, list_email_accounts, get_email_account, set_email_account, delete_email_account
from .notifications import NOTIFICATION_TYPES, NOTIFICATION_CHANNELS, _NOTIFICATION_CHANNEL_COLUMNS, _NOTIFICATION_CHANNEL_DEFAULTS, _NOTIFICATION_TYPE_KEYS, _notification_prefs_cache, _notification_default, get_notification_default, set_notification_default, _notification_overrides, notification_enabled, get_notification_prefs, set_notification_pref, reset_notification_prefs, mark_notifications_read_by_target
from .forks import record_fork, get_fork_parent, count_forks, soft_delete_fork_relations, delete_fork_relations
from .follows import get_follow_counts, get_follow_list, get_following_among
from .deepsearch import _ds_now, create_deepsearch_session, update_deepsearch_session, get_deepsearch_session, add_deepsearch_message, get_deepsearch_messages, get_cached_deepsearch_url, upsert_deepsearch_url_cache
from .ranking import VOTABLE_TARGETS, STAR_TARGETS, _authors_cache, _ranked_authors, _rank_map, get_top_authors, get_leaderboard, get_user_rank, get_user_stars, 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 .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
__all__ = [
"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",
"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",
"_settings_cache",
"get_setting",
"get_int_setting",
"set_setting",
"clear_settings_cache",
"internal_gateway_key",
"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",
"_relations_cache",
"get_user_relations",
"get_blocked_uids",
"get_muted_uids",
"get_silenced_uids",
"invalidate_user_relations",
"PAGE_SIZE",
"paginate",
"interleave_by_author",
"paginate_diverse",
"get_user_post_count",
"clear_user_post_count",
"build_pagination",
"SOFT_DELETE_TABLES",
"ensure_soft_delete_columns",
"soft_delete",
"soft_delete_in",
"restore",
"purge",
"list_deleted",
"count_deleted",
"restore_event",
"purge_event",
"_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",
"_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",
"SEO_META_TYPES",
"get_seo_metadata",
"get_seo_metadata_batch",
"has_fresh_seo_metadata",
"upsert_seo_metadata",
"mark_seo_metadata_stale",
"record_activity",
"record_unique_activity",
"get_user_activity",
"_activity_cache",
"_ACTIVITY_TABLES",
"get_activity_calendar",
"_activity_level",
"get_first_activity_date",
"HEATMAP_WEEKS",
"get_activity_heatmap",
"get_activity_months",
"get_streaks",
"CUSTOMIZATION_GLOBAL_SCOPE",
"CUSTOMIZATION_LANGS",
"_customizations_cache",
"_customization_key",
"CUSTOMIZATION_PREF_COLUMNS",
"get_customization_prefs",
"set_customization_pref",
"get_custom_overrides",
"get_custom_override",
"list_custom_overrides",
"set_custom_override",
"delete_custom_override",
"EMAIL_ACCOUNT_DEFAULTS",
"list_email_accounts",
"get_email_account",
"set_email_account",
"delete_email_account",
"NOTIFICATION_TYPES",
"NOTIFICATION_CHANNELS",
"_NOTIFICATION_CHANNEL_COLUMNS",
"_NOTIFICATION_CHANNEL_DEFAULTS",
"_NOTIFICATION_TYPE_KEYS",
"_notification_prefs_cache",
"_notification_default",
"get_notification_default",
"set_notification_default",
"_notification_overrides",
"notification_enabled",
"get_notification_prefs",
"set_notification_pref",
"reset_notification_prefs",
"mark_notifications_read_by_target",
"record_fork",
"get_fork_parent",
"count_forks",
"soft_delete_fork_relations",
"delete_fork_relations",
"get_follow_counts",
"get_follow_list",
"get_following_among",
"_ds_now",
"create_deepsearch_session",
"update_deepsearch_session",
"get_deepsearch_session",
"add_deepsearch_message",
"get_deepsearch_messages",
"get_cached_deepsearch_url",
"upsert_deepsearch_url_cache",
"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",
"_drop_blocked",
"_build_comment_items",
"load_comments",
"get_recent_comments_by_target_uids",
"get_recent_comments_by_post_uids",
"load_comments_by_target_uids",
"resolve_by_slug",
"resolve_object_url",
"get_uids_by_username_match",
"text_search_clause",
"get_daily_topic",
"get_featured_news",
"get_trending_topics",
"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",
"_stats_cache",
"get_site_stats",
"_analytics_cache",
"get_platform_analytics",
"_gist_languages_cache",
"get_gist_languages",
"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",
]
-182
View File
@@ -1,182 +0,0 @@
# retoor <retoor@molodetz.nl>
from .core import TTLCache, datetime, db, timedelta, timezone
def record_activity(user_uid: str, action: str) -> int:
if not user_uid or not action or "user_activity" not in db.tables:
return 0
now = datetime.now(timezone.utc).isoformat()
with db:
db.query(
"INSERT INTO user_activity (user_uid, action, count, first_at, last_at) "
"VALUES (:u, :a, 1, :now, :now) "
"ON CONFLICT(user_uid, action) DO UPDATE SET "
"count = count + 1, last_at = excluded.last_at",
u=user_uid,
a=action,
now=now,
)
rows = list(
db.query(
"SELECT count FROM user_activity WHERE user_uid = :u AND action = :a",
u=user_uid,
a=action,
)
)
return int(rows[0]["count"]) if rows else 0
def record_unique_activity(user_uid: str, action: str, target: str) -> int | None:
if not user_uid or not action or "user_activity_seen" not in db.tables:
return None
now = datetime.now(timezone.utc).isoformat()
with db:
db.query(
"INSERT OR IGNORE INTO user_activity_seen "
"(user_uid, action, target, created_at) VALUES (:u, :a, :t, :now)",
u=user_uid,
a=action,
t=str(target),
now=now,
)
changed = list(db.query("SELECT changes() AS c"))
if not changed or not changed[0]["c"]:
return None
rows = list(
db.query(
"SELECT COUNT(*) AS c FROM user_activity_seen "
"WHERE user_uid = :u AND action = :a",
u=user_uid,
a=action,
)
)
return int(rows[0]["c"]) if rows else 0
def get_user_activity(user_uid: str) -> dict:
if not user_uid or "user_activity" not in db.tables:
return {}
rows = db.query(
"SELECT action, count FROM user_activity WHERE user_uid = :u",
u=user_uid,
)
return {row["action"]: int(row["count"]) for row in rows}
_activity_cache = TTLCache(ttl=300, max_size=1000)
_ACTIVITY_TABLES = ("posts", "comments", "gists", "projects")
def get_activity_calendar(user_uid: str) -> dict:
cached = _activity_cache.get(user_uid)
if cached is not None:
return cached
sources = [table for table in _ACTIVITY_TABLES if table in db.tables]
calendar: dict[str, int] = {}
if sources:
cutoff = (datetime.now(timezone.utc) - timedelta(days=364)).date().isoformat()
union = " UNION ALL ".join(
f"SELECT created_at FROM {table} WHERE user_uid = :u AND deleted_at IS NULL"
for table in sources
)
rows = db.query(
f"SELECT date(created_at) AS day, COUNT(*) AS c FROM ({union}) WHERE date(created_at) >= :cutoff GROUP BY day",
u=user_uid,
cutoff=cutoff,
)
for row in rows:
if row["day"]:
calendar[row["day"]] = row["c"]
_activity_cache.set(user_uid, calendar)
return calendar
def _activity_level(count: int) -> int:
if count <= 0:
return 0
if count == 1:
return 1
if count <= 3:
return 2
if count <= 6:
return 3
return 4
def get_first_activity_date(user_uid: str):
sources = [table for table in _ACTIVITY_TABLES if table in db.tables]
if not sources:
return None
union = " UNION ALL ".join(
f"SELECT MIN(created_at) AS m FROM {table} WHERE user_uid = :u AND deleted_at IS NULL"
for table in sources
)
for row in db.query(f"SELECT MIN(m) AS first FROM ({union})", u=user_uid):
if row["first"]:
return datetime.fromisoformat(row["first"]).date()
return None
HEATMAP_WEEKS = 53
def get_activity_heatmap(user_uid: str) -> list:
calendar = get_activity_calendar(user_uid)
today = datetime.now(timezone.utc).date()
week_start = today - timedelta(days=today.weekday())
start = week_start - timedelta(weeks=HEATMAP_WEEKS - 1)
first = get_first_activity_date(user_uid)
if first:
first_week = first - timedelta(days=first.weekday())
if first_week > start:
start = first_week
weeks = []
for w in range(HEATMAP_WEEKS):
week = []
for d in range(7):
day = start + timedelta(days=w * 7 + d)
iso = day.isoformat()
count = calendar.get(iso, 0)
week.append({"date": iso, "count": count, "level": _activity_level(count)})
weeks.append(week)
return weeks
def get_activity_months(weeks: list) -> list:
if not weeks:
return []
last = len(weeks) - 1
labels = []
for i in range(6):
column = round(i * last / 5)
iso = weeks[column][0]["date"]
labels.append(datetime.fromisoformat(iso).strftime("%b"))
return labels
def get_streaks(user_uid: str) -> dict:
calendar = get_activity_calendar(user_uid)
if not calendar:
return {"current": 0, "longest": 0}
dates = sorted(datetime.fromisoformat(day).date() for day in calendar)
date_set = set(dates)
longest = 1
run = 1
for index in range(1, len(dates)):
if (dates[index] - dates[index - 1]).days == 1:
run += 1
else:
run = 1
longest = max(longest, run)
today = datetime.now(timezone.utc).date()
cursor = today
if today not in date_set and (today - timedelta(days=1)) in date_set:
cursor = today - timedelta(days=1)
current = 0
while cursor in date_set:
current += 1
cursor = cursor - timedelta(days=1)
return {"current": current, "longest": longest}
-153
View File
@@ -1,153 +0,0 @@
# retoor <retoor@molodetz.nl>
from .core import db, logger
from .users import get_users_by_uids
from .pagination import build_pagination
from .content import resolve_object_url
def get_attachments(resource_type: str, resource_uid: str) -> list:
if "attachments" not in db.tables:
return []
return list(
db["attachments"].find(
resource_type=resource_type,
resource_uid=resource_uid,
deleted_at=None,
order_by=["created_at"],
)
)
def get_attachments_by_type(resource_type: str, resource_uids: list) -> dict:
if not resource_uids or "attachments" not in db.tables:
return {}
rows = list(
db["attachments"].find(
db["attachments"].table.columns.resource_uid.in_(resource_uids),
db["attachments"].table.columns.deleted_at.is_(None),
resource_type=resource_type,
)
)
result = {}
for a in rows:
key = a["resource_uid"]
if key not in result:
result[key] = []
result[key].append(a)
return result
def get_news_images_by_uids(news_uids: list) -> dict:
if not news_uids or "news_images" not in db.tables:
return {}
images_table = db["news_images"]
if not images_table.has_column("news_uid"):
return {}
rows = images_table.find(
images_table.table.columns.news_uid.in_(news_uids),
images_table.table.columns.deleted_at.is_(None),
order_by=["uid"],
)
result = {}
for r in rows:
result.setdefault(r["news_uid"], r["url"])
return result
def delete_attachment_record(uid: str) -> None:
if "attachments" not in db.tables:
return
att = db["attachments"].find_one(uid=uid)
if att:
_delete_attachment_file(att)
db["attachments"].delete(id=att["id"])
def delete_attachments(resource_type: str, resource_uid: str) -> None:
if "attachments" not in db.tables:
return
for a in db["attachments"].find(
resource_type=resource_type, resource_uid=resource_uid
):
_delete_attachment_file(a)
db["attachments"].delete(resource_type=resource_type, resource_uid=resource_uid)
def _delete_attachment_file(att: dict) -> None:
from devplacepy.config import ATTACHMENTS_DIR
directory = att.get("directory", "")
stored_name = att.get("stored_name", "")
if not (directory and stored_name):
return
file_path = ATTACHMENTS_DIR / directory / stored_name
try:
file_path.unlink(missing_ok=True)
parent = file_path.parent
if parent.exists() and not any(parent.iterdir()):
parent.rmdir()
grandparent = parent.parent
if grandparent.exists() and not any(grandparent.iterdir()):
grandparent.rmdir()
except Exception as e:
logger.warning(f"Failed to delete attachment file {stored_name}: {e}")
def get_user_media(user_uid: str, page: int = 1, per_page: int = 24) -> tuple:
if "attachments" not in db.tables:
return [], build_pagination(page, 0, per_page)
from devplacepy.attachments import _row_to_attachment
total = list(
db.query(
"SELECT COUNT(*) AS n FROM attachments "
"WHERE user_uid=:u AND target_type != '' AND deleted_at IS NULL",
u=user_uid,
)
)[0]["n"]
pagination = build_pagination(page, total, per_page)
offset = (pagination["page"] - 1) * pagination["per_page"]
rows = db.query(
"SELECT * FROM attachments "
"WHERE user_uid=:u AND target_type != '' AND deleted_at IS NULL "
"ORDER BY created_at DESC LIMIT :limit OFFSET :offset",
u=user_uid,
limit=pagination["per_page"],
offset=offset,
)
items = []
for row in rows:
item = _row_to_attachment(row)
item["target_url"] = resolve_object_url(item["target_type"], item["target_uid"])
items.append(item)
return items, pagination
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)
from devplacepy.attachments import _row_to_attachment
total = list(
db.query("SELECT COUNT(*) AS n FROM attachments WHERE deleted_at IS NOT NULL")
)[0]["n"]
pagination = build_pagination(page, total, per_page)
offset = (pagination["page"] - 1) * pagination["per_page"]
rows = db.query(
"SELECT * FROM attachments WHERE deleted_at IS NOT NULL "
"ORDER BY deleted_at DESC LIMIT :limit OFFSET :offset",
limit=pagination["per_page"],
offset=offset,
)
rows = list(rows)
uploaders = get_users_by_uids([row.get("user_uid") for row in rows])
items = []
for row in rows:
item = _row_to_attachment(row)
item["target_url"] = resolve_object_url(item["target_type"], item["target_uid"])
item["deleted_at"] = row.get("deleted_at", "")
uploader = uploaders.get(row.get("user_uid"))
item["uploader"] = uploader["username"] if uploader else "unknown"
items.append(item)
return items, pagination
-206
View File
@@ -1,206 +0,0 @@
# retoor <retoor@molodetz.nl>
from datetime import datetime, timedelta, timezone
from devplacepy.cache import TTLCache
from devplacepy.config import (
AWARD_DISPLAY_HOURS_DEFAULT,
AWARD_GIVE_COOLDOWN_HOURS_DEFAULT,
AWARD_RECEIVE_COOLDOWN_HOURS_DEFAULT,
)
from .core import db
from .pagination import build_pagination
from .settings import get_int_setting
from .core import get_table, _now_iso
from .users import get_users_by_uids
from .content import resolve_by_slug
from .soft_delete import soft_delete, soft_delete_in
AWARDS_PER_PAGE = 12
def _awards_table():
return get_table("awards")
def award_give_cooldown_hours() -> int:
return max(1, get_int_setting("award_give_cooldown_hours", AWARD_GIVE_COOLDOWN_HOURS_DEFAULT))
def award_receive_cooldown_hours() -> int:
return max(
1, get_int_setting("award_receive_cooldown_hours", AWARD_RECEIVE_COOLDOWN_HOURS_DEFAULT)
)
def award_display_hours() -> int:
return max(1, get_int_setting("award_display_hours", AWARD_DISPLAY_HOURS_DEFAULT))
def _cooldown_cutoff(hours: int) -> str:
return (datetime.now(timezone.utc) - timedelta(hours=hours)).isoformat()
def has_giver_cooldown(giver_uid: str) -> bool:
if not giver_uid or "awards" not in db.tables:
return False
cutoff = _cooldown_cutoff(award_give_cooldown_hours())
row = _awards_table().find_one(
giver_uid=giver_uid, deleted_at=None, created_at={">=": cutoff}
)
return row is not None
def has_receiver_cooldown(receiver_uid: str) -> bool:
if not receiver_uid or "awards" not in db.tables:
return False
cutoff = _cooldown_cutoff(award_receive_cooldown_hours())
row = _awards_table().find_one(
receiver_uid=receiver_uid, deleted_at=None, created_at={">=": cutoff}
)
return row is not None
def can_receive_award(receiver_uid: str) -> bool:
return not has_receiver_cooldown(receiver_uid)
def can_give_award(giver_uid: str, receiver_uid: str) -> bool:
if not giver_uid or not receiver_uid or giver_uid == receiver_uid:
return False
return not has_giver_cooldown(giver_uid) and not has_receiver_cooldown(receiver_uid)
def _published_filter():
return {"deleted_at": None, "generated_at": {">": ""}}
def count_published_awards(receiver_uid: str) -> int:
if not receiver_uid or "awards" not in db.tables:
return 0
return _awards_table().count(receiver_uid=receiver_uid, **_published_filter())
def _latest_published(receiver_uid: str):
if not receiver_uid or "awards" not in db.tables:
return None
rows = list(
_awards_table().find(
receiver_uid=receiver_uid,
deleted_at=None,
generated_at={">": ""},
order_by=["-generated_at"],
_limit=1,
)
)
return rows[0] if rows else None
def recompute_user_award_stats(receiver_uid: str) -> None:
if not receiver_uid or "users" not in db.tables:
return
count = count_published_awards(receiver_uid)
latest = _latest_published(receiver_uid)
users = get_table("users")
payload = {
"uid": receiver_uid,
"award_count": count,
"last_award_at": latest.get("generated_at") if latest else None,
"last_award_slug": latest.get("slug") if latest else None,
"last_award_uid": latest.get("uid") if latest else None,
}
users.update(payload, ["uid"])
_prominence_cache = TTLCache(ttl=15, max_size=500)
def award_is_prominent(user: dict | None) -> bool:
if not user or not user.get("last_award_at") or not user.get("last_award_uid"):
return False
cached = _prominence_cache.get(user["last_award_uid"])
if cached is not None:
return cached
prominent = _compute_prominence(user["last_award_uid"])
_prominence_cache.set(user["last_award_uid"], prominent)
return prominent
def _compute_prominence(award_uid: str) -> bool:
award = resolve_by_slug(_awards_table(), award_uid)
if not award or not award.get("generated_at"):
return False
try:
published = datetime.fromisoformat(award["generated_at"])
if published.tzinfo is None:
published = published.replace(tzinfo=timezone.utc)
except (ValueError, TypeError):
return False
window = timedelta(hours=award_display_hours())
return datetime.now(timezone.utc) - published <= window
def enrich_award(row: dict, givers: dict | None = None) -> dict:
item = dict(row)
giver_uid = row.get("giver_uid", "")
giver = (givers or {}).get(giver_uid) or get_users_by_uids([giver_uid]).get(giver_uid)
item["giver"] = giver
item["image_url"] = f"/awards/{row.get('slug', '')}/256"
item["thumb_url"] = f"/awards/{row.get('slug', '')}/64"
return item
def get_user_awards(receiver_uid: str, page: int = 1, per_page: int = AWARDS_PER_PAGE):
if not receiver_uid or "awards" not in db.tables:
return [], build_pagination(page, 0, per_page)
table = _awards_table()
total = table.count(receiver_uid=receiver_uid, **_published_filter())
offset = max(0, (page - 1) * per_page)
rows = list(
table.find(
receiver_uid=receiver_uid,
deleted_at=None,
generated_at={">": ""},
order_by=["-generated_at"],
_limit=per_page,
_offset=offset,
)
)
giver_uids = [row.get("giver_uid") for row in rows if row.get("giver_uid")]
givers = get_users_by_uids(giver_uids)
items = [enrich_award(row, givers) for row in rows]
return items, build_pagination(page, total, per_page)
def get_prominent_award(profile_user: dict) -> dict | None:
if not award_is_prominent(profile_user):
return None
award = resolve_by_slug(_awards_table(), profile_user.get("last_award_uid", ""))
if not award:
return None
return enrich_award(award)
def revoke_award(award_uid: str, admin_uid: str) -> dict | None:
table = _awards_table()
row = table.find_one(uid=award_uid)
if not row or row.get("deleted_at"):
return None
stamp = _now_iso()
attachment_uids = [
uid
for uid in (
row.get("attachment_uid_512"),
row.get("attachment_uid_256"),
row.get("attachment_uid_64"),
)
if uid
]
soft_delete("awards", admin_uid, stamp=stamp, uid=award_uid)
from devplacepy.attachments import soft_delete_attachments_for
soft_delete_attachments_for("award", [award_uid], admin_uid)
if attachment_uids:
soft_delete_in("attachments", "uid", attachment_uids, admin_uid, stamp=stamp)
recompute_user_award_stats(row.get("receiver_uid", ""))
return row
-155
View File
@@ -1,155 +0,0 @@
# retoor <retoor@molodetz.nl>
from .core import _in_clause, db, defaultdict
from .users import get_users_by_uids
from .relations import get_blocked_uids
from .engagement import get_reactions_by_targets, get_user_votes, get_vote_counts
def _drop_blocked(raw, user):
if not user:
return raw
blocked = get_blocked_uids(user["uid"])
if not blocked:
return raw
return [c for c in raw if c["user_uid"] not in blocked]
def _build_comment_items(raw, user=None):
uids = [c["user_uid"] for c in raw]
cids = [c["uid"] for c in raw]
users = get_users_by_uids(uids)
ups, downs = get_vote_counts(cids)
user_votes = get_user_votes(user["uid"], cids) if user else {}
reactions = get_reactions_by_targets("comment", cids, user)
from devplacepy.utils import time_ago
from devplacepy.attachments import get_attachments_batch as _gab
atts_map = _gab("comment", cids) if "attachments" in db.tables else {}
items = {}
for c in raw:
items[c["uid"]] = {
"comment": c,
"author": users.get(c["user_uid"]),
"time_ago": time_ago(c["created_at"]),
"votes": {"up": ups.get(c["uid"], 0), "down": downs.get(c["uid"], 0)},
"my_vote": user_votes.get(c["uid"], 0),
"children": [],
"attachments": atts_map.get(c["uid"], []),
"reactions": reactions.get(c["uid"], {"counts": {}, "mine": []}),
}
return items
def load_comments(target_type, target_uid, user=None):
if "comments" not in db.tables:
return []
comments_table = db["comments"]
raw = list(
comments_table.find(
target_type=target_type,
target_uid=target_uid,
deleted_at=None,
order_by=["created_at"],
)
)
if not raw and target_type == "post":
raw = list(
comments_table.find(
post_uid=target_uid, deleted_at=None, order_by=["created_at"]
)
)
raw = _drop_blocked(raw, user)
if not raw:
return []
cmap = _build_comment_items(raw, user)
top = []
for item in cmap.values():
parent = item["comment"].get("parent_uid")
if parent and parent in cmap:
cmap[parent]["children"].append(item)
else:
top.append(item)
return top
def get_recent_comments_by_target_uids(target_type, target_uids, limit=3, user=None):
if not target_uids or "comments" not in db.tables:
return {}
placeholders, params = _in_clause(target_uids)
params["tt"] = target_type
params["lim"] = limit
raw = list(
db.query(
f"SELECT * FROM ("
f" SELECT *, ROW_NUMBER() OVER ("
f" PARTITION BY target_uid ORDER BY created_at DESC, id DESC"
f" ) AS rn FROM comments"
f" WHERE target_type=:tt AND target_uid IN ({placeholders}) AND deleted_at IS NULL"
f") WHERE rn <= :lim ORDER BY target_uid, created_at ASC",
**params,
)
)
raw = _drop_blocked(raw, user)
if not raw:
return {}
items = _build_comment_items(raw, user)
by_target = defaultdict(list)
for c in raw:
by_target[c["target_uid"]].append(c)
result = {}
for target_uid, group in by_target.items():
in_group = {c["uid"] for c in group}
top = []
for c in group:
item = items[c["uid"]]
item["children"] = []
for c in group:
item = items[c["uid"]]
parent = c.get("parent_uid")
if parent and parent in in_group:
items[parent]["children"].append(item)
else:
top.append(item)
result[target_uid] = top
return result
def get_recent_comments_by_post_uids(post_uids, limit=3, user=None):
return get_recent_comments_by_target_uids("post", post_uids, limit, user)
def load_comments_by_target_uids(target_type, target_uids, user=None):
if not target_uids or "comments" not in db.tables:
return {}
placeholders, params = _in_clause(target_uids)
params["tt"] = target_type
raw = list(
db.query(
f"SELECT * FROM comments WHERE target_type=:tt AND target_uid IN ({placeholders}) AND deleted_at IS NULL ORDER BY created_at",
**params,
)
)
raw = _drop_blocked(raw, user)
if not raw:
return {}
from collections import defaultdict
by_uid = defaultdict(list)
for c in raw:
by_uid[c["target_uid"]].append(c)
result = {}
for uid in target_uids:
group = by_uid.get(uid, [])
if not group:
result[uid] = []
continue
cmap = _build_comment_items(group, user)
tree = []
for item in cmap.values():
parent = item["comment"].get("parent_uid")
if parent and parent in cmap:
cmap[parent]["children"].append(item)
else:
tree.append(item)
result[uid] = tree
return result
-167
View File
@@ -1,167 +0,0 @@
# retoor <retoor@molodetz.nl>
from collections import Counter
from devplacepy.cache import TTLCache
from .core import db, get_table, or_
_daily_topic_cache = TTLCache(ttl=60, max_size=1)
_trending_cache = TTLCache(ttl=15, max_size=1)
def resolve_by_slug(table, slug, include_deleted=False):
has_soft_delete = table.has_column("deleted_at")
flt = {} if include_deleted or not has_soft_delete else {"deleted_at": None}
entry = table.find_one(slug=slug, **flt)
if not entry:
entry = table.find_one(uid=slug, **flt)
return entry
def resolve_object_url(target_type: str, target_uid: str) -> str:
if target_type == "post":
post = resolve_by_slug(get_table("posts"), target_uid)
return f"/posts/{post['slug'] or post['uid']}" if post else "/feed"
if target_type == "project":
project = resolve_by_slug(get_table("projects"), target_uid)
return (
f"/projects/{project['slug'] or project['uid']}" if project else "/projects"
)
if target_type == "news":
article = resolve_by_slug(get_table("news"), target_uid)
if article:
return f"/news/{article.get('slug', '') or article['uid']}"
return "/news"
if target_type == "issue":
return f"/issues?highlight={target_uid}"
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 == "comment":
comment = get_table("comments").find_one(uid=target_uid, deleted_at=None)
if not comment:
return "/feed"
parent_url = resolve_object_url(
comment.get("target_type", "post"),
comment.get("target_uid") or comment.get("post_uid", ""),
)
return f"{parent_url}#comment-{target_uid}"
if target_type == "award":
award = resolve_by_slug(get_table("awards"), target_uid)
if award:
receiver = get_table("users").find_one(uid=award.get("receiver_uid", ""))
if receiver:
return f"/profile/{receiver['username']}?tab=awards#award-{award.get('slug', '')}"
return "/feed"
def get_uids_by_username_match(search, limit=200):
term = (search or "").strip()
if not term or "users" not in db.tables:
return []
rows = db.query(
"SELECT uid FROM users WHERE username LIKE :q LIMIT :limit",
q=f"%{term}%",
limit=limit,
)
return [row["uid"] for row in rows]
def text_search_clause(
table, search, fields=("title", "description"), author_field=None
):
if not search or not search.strip() or not table.exists:
return None
columns = table.table.columns
like = f"%{search.strip()}%"
matches = [columns[field].ilike(like) for field in fields if field in columns]
if author_field and author_field in columns:
author_uids = get_uids_by_username_match(search)
if author_uids:
matches.append(columns[author_field].in_(author_uids))
return or_(*matches) if matches else None
def get_daily_topic():
cached = _daily_topic_cache.get("topic")
if cached is not None:
return cached
topic = _load_daily_topic()
_daily_topic_cache.set("topic", topic)
return topic
def _load_daily_topic():
if "news" in db.tables:
article = db["news"].find_one(
status="published", deleted_at=None, order_by=["-synced_at"]
)
if article:
desc = (article.get("description") or "")[:200] or (
article.get("content") or ""
)[:200]
return {
"title": article.get("title", ""),
"summary": desc,
"slug": article.get("slug", ""),
"url": article.get("url", ""),
"image_url": article.get("image_url", ""),
}
return {
"title": "Welcome to DevPlace",
"summary": "Stay tuned for the latest dev news.",
}
def get_featured_news(limit=5):
if "news" not in db.tables:
return []
from devplacepy.utils import time_ago
rows = list(
db["news"].find(
show_on_landing=1, deleted_at=None, order_by=["-synced_at"], _limit=limit
)
)
articles = []
for article in rows:
summary = (article.get("description") or "")[:120] or (
article.get("content") or ""
)[:120]
articles.append(
{
"title": article.get("title", ""),
"summary": summary,
"slug": article.get("slug", ""),
"url": article.get("url", ""),
"source_name": article.get("source_name", ""),
"featured": article.get("featured", 0),
"image_url": article.get("image_url", "") or "",
"time_ago": time_ago(article["synced_at"])
if article.get("synced_at")
else "",
}
)
return articles
def get_trending_topics(limit: int = 6) -> list[dict]:
cached = _trending_cache.get("topics")
if cached is not None:
return cached[:limit]
if "posts" not in db.tables or "topic" not in db["posts"].columns:
return []
rows = db.query(
"SELECT topic FROM posts WHERE deleted_at IS NULL "
"AND topic IS NOT NULL AND topic != '' "
"ORDER BY created_at DESC LIMIT 200"
)
counter: Counter[str] = Counter()
for row in rows:
topic = (row["topic"] or "").strip()
if topic:
counter[topic] += 1
topics = [{"topic": t, "count": c} for t, c in counter.most_common(limit)]
_trending_cache.set("topics", topics)
return topics
-163
View File
@@ -1,163 +0,0 @@
# retoor <retoor@molodetz.nl>
import dataset
import logging
from pathlib import Path
from sqlalchemy import or_
from collections import defaultdict
from datetime import datetime, timedelta, timezone
from devplacepy.cache import TTLCache
from devplacepy.config import (
DATABASE_URL,
DEFAULT_CORRECTION_PROMPT,
DEFAULT_MODIFIER_PROMPT,
INTERNAL_GATEWAY_URL,
ensure_data_dirs,
)
logger = logging.getLogger(__name__)
ensure_data_dirs()
if DATABASE_URL.startswith("sqlite:///"):
_db_file = DATABASE_URL[len("sqlite:///") :]
if _db_file and _db_file != ":memory:":
Path(_db_file).parent.mkdir(parents=True, exist_ok=True)
db = dataset.connect(
DATABASE_URL,
engine_kwargs={
"connect_args": {
"timeout": 30,
"check_same_thread": False,
},
},
on_connect_statements=[
"PRAGMA journal_mode=WAL",
"PRAGMA synchronous=NORMAL",
"PRAGMA busy_timeout=30000",
"PRAGMA cache_size=-8000",
"PRAGMA temp_store=MEMORY",
"PRAGMA mmap_size=268435456",
],
)
def refresh_snapshot() -> None:
connection = db.executable
if connection.in_transaction() and not db.in_transaction:
connection.commit()
_local_cache_versions: dict = {}
_cache_version_cache = TTLCache(ttl=1)
_cache_state_ready = False
def _ensure_cache_state() -> None:
global _cache_state_ready
if _cache_state_ready:
return
with db:
db.query(
"CREATE TABLE IF NOT EXISTS cache_state "
"(name TEXT PRIMARY KEY, version INTEGER NOT NULL DEFAULT 0)"
)
_cache_state_ready = True
def get_cache_version(name: str) -> int:
cached = _cache_version_cache.get(name)
if cached is not None:
return cached
try:
_ensure_cache_state()
with db:
rows = list(db.query("SELECT name, version FROM cache_state"))
versions = {row["name"]: int(row["version"]) for row in rows}
except Exception as e:
logger.warning(f"Could not read cache version {name}: {e}")
return 0
for key, version in versions.items():
_cache_version_cache.set(key, version)
version = versions.get(name, 0)
_cache_version_cache.set(name, version)
return version
def bump_cache_version(name: str) -> None:
try:
_ensure_cache_state()
with db:
db.query(
"INSERT OR IGNORE INTO cache_state (name, version) VALUES (:name, 0)",
name=name,
)
db.query(
"UPDATE cache_state SET version = version + 1 WHERE name = :name",
name=name,
)
_cache_version_cache.pop(name)
except Exception as e:
logger.warning(f"Could not bump cache version {name}: {e}")
def sync_local_cache(name: str, cache) -> None:
current = get_cache_version(name)
if name not in _local_cache_versions:
_local_cache_versions[name] = current
return
if _local_cache_versions[name] != current:
cache.clear()
_local_cache_versions[name] = current
def _index(db, table, name, columns, *, where=None, unique=False):
try:
if table in db.tables:
cols = ", ".join(columns)
kind = "UNIQUE INDEX" if unique else "INDEX"
clause = f" WHERE {where}" if where else ""
with db:
db.query(
f"CREATE {kind} IF NOT EXISTS {name} ON {table} ({cols}){clause}"
)
except Exception as e:
logger.warning(f"Could not create index {name} on {table}: {e}")
def _drop_index(db, name):
try:
with db:
db.query(f"DROP INDEX IF EXISTS {name}")
except Exception as e:
logger.warning(f"Could not drop index {name}: {e}")
def _uid_index(db, table):
if table not in db.tables or "uid" not in get_table(table).columns:
return
name = f"idx_{table}_uid"
try:
with db:
db.query(f"CREATE UNIQUE INDEX IF NOT EXISTS {name} ON {table} (uid)")
except Exception as e:
logger.warning(f"Unique uid index on {table} failed ({e}); using non-unique")
_index(db, table, name, ["uid"])
def get_table(name):
return db[name]
def _in_clause(uids, prefix="p"):
placeholders = ", ".join(f":{prefix}{i}" for i in range(len(uids)))
params = {f"{prefix}{i}": uid for i, uid in enumerate(uids)}
return placeholders, params
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
-167
View File
@@ -1,167 +0,0 @@
# retoor <retoor@molodetz.nl>
from .core import TTLCache, bump_cache_version, datetime, db, get_table, sync_local_cache, timezone
from .soft_delete import soft_delete
CUSTOMIZATION_GLOBAL_SCOPE = "global"
CUSTOMIZATION_LANGS = ("css", "js")
_customizations_cache = TTLCache(ttl=300, max_size=100)
def _customization_key(owner_kind: str, owner_id: str, page_type: str) -> str:
return f"{owner_kind}\x1f{owner_id}\x1f{page_type}"
CUSTOMIZATION_PREF_COLUMNS = {
"global": "cust_disable_global",
"pagetype": "cust_disable_pagetype",
}
def get_customization_prefs(owner_kind: str, owner_id: str) -> dict:
if owner_kind != "user" or "users" not in db.tables:
return {"disable_global": False, "disable_pagetype": False}
user = db["users"].find_one(uid=owner_id)
if user is None:
return {"disable_global": False, "disable_pagetype": False}
return {
"disable_global": bool(user.get("cust_disable_global", 0)),
"disable_pagetype": bool(user.get("cust_disable_pagetype", 0)),
}
def set_customization_pref(owner_id: str, category: str, disabled: bool) -> None:
column = CUSTOMIZATION_PREF_COLUMNS.get(category)
if column is None:
raise ValueError(f"Unknown customization category: {category}")
from devplacepy.utils import clear_user_cache
get_table("users").update(
{"uid": owner_id, column: 1 if disabled else 0}, ["uid"]
)
clear_user_cache(owner_id)
bump_cache_version("customizations")
def get_custom_overrides(owner_kind: str, owner_id: str, page_type: str) -> dict:
sync_local_cache("customizations", _customizations_cache)
key = _customization_key(owner_kind, owner_id, page_type)
cached = _customizations_cache.get(key)
if cached is not None:
return cached
result = {"css": "", "js": ""}
if "user_customizations" in db.tables:
prefs = get_customization_prefs(owner_kind, owner_id)
scopes = (CUSTOMIZATION_GLOBAL_SCOPE, page_type)
rows = db["user_customizations"].find(
owner_kind=owner_kind,
owner_id=owner_id,
enabled=1,
deleted_at=None,
)
pieces: dict[str, dict[str, str]] = {lang: {} for lang in CUSTOMIZATION_LANGS}
for row in rows:
lang = row.get("lang")
scope = row.get("scope")
if lang not in pieces or scope not in scopes:
continue
if scope == CUSTOMIZATION_GLOBAL_SCOPE and prefs["disable_global"]:
continue
if scope != CUSTOMIZATION_GLOBAL_SCOPE and prefs["disable_pagetype"]:
continue
pieces[lang][scope] = row.get("code") or ""
for lang in CUSTOMIZATION_LANGS:
ordered = [pieces[lang][scope] for scope in scopes if scope in pieces[lang]]
result[lang] = "\n".join(part for part in ordered if part.strip())
_customizations_cache.set(key, result)
return result
def get_custom_override(
owner_kind: str, owner_id: str, scope: str, lang: str
) -> dict | None:
if "user_customizations" not in db.tables:
return None
return db["user_customizations"].find_one(
owner_kind=owner_kind,
owner_id=owner_id,
scope=scope,
lang=lang,
deleted_at=None,
)
def list_custom_overrides(owner_kind: str, owner_id: str) -> list:
if "user_customizations" not in db.tables:
return []
return list(
db["user_customizations"].find(
owner_kind=owner_kind, owner_id=owner_id, deleted_at=None
)
)
def set_custom_override(
owner_kind: str, owner_id: str, scope: str, lang: str, code: str
) -> dict:
from devplacepy.utils import generate_uid
table = get_table("user_customizations")
now = datetime.now(timezone.utc).isoformat()
existing = table.find_one(
owner_kind=owner_kind, owner_id=owner_id, scope=scope, lang=lang
)
if existing:
record = {
"id": existing["id"],
"code": code,
"enabled": 1,
"updated_at": now,
"deleted_at": None,
"deleted_by": None,
}
table.update(record, ["id"])
result = {**existing, **record}
else:
result = {
"uid": generate_uid(),
"owner_kind": owner_kind,
"owner_id": owner_id,
"scope": scope,
"lang": lang,
"code": code,
"enabled": 1,
"created_at": now,
"updated_at": now,
"deleted_at": None,
"deleted_by": None,
}
table.insert(result)
bump_cache_version("customizations")
return result
def delete_custom_override(
owner_kind: str,
owner_id: str,
scope: str | None = None,
lang: str | None = None,
deleted_by: str | None = None,
) -> int:
if "user_customizations" not in db.tables:
return 0
criteria: dict = {"owner_kind": owner_kind, "owner_id": owner_id}
if scope is not None:
criteria["scope"] = scope
if lang is not None:
criteria["lang"] = lang
count = soft_delete(
"user_customizations", deleted_by or f"{owner_kind}:{owner_id}", **criteria
)
bump_cache_version("customizations")
return int(count)
-119
View File
@@ -1,119 +0,0 @@
# retoor <retoor@molodetz.nl>
from .core import datetime, db, get_table, timezone
def _ds_now() -> str:
return datetime.now(timezone.utc).isoformat()
def create_deepsearch_session(
uid: str,
owner_kind: str,
owner_id: str,
query: str,
depth: int,
max_pages: int,
collection: str,
) -> None:
get_table("deepsearch_sessions").insert(
{
"uid": uid,
"owner_kind": owner_kind,
"owner_id": owner_id,
"query": query,
"status": "pending",
"depth": depth,
"max_pages": max_pages,
"score": 0,
"confidence": 0.0,
"source_diversity": 0.0,
"page_count": 0,
"chunk_count": 0,
"collection": collection,
"summary": "",
"created_at": _ds_now(),
"completed_at": "",
"deleted_at": None,
"deleted_by": None,
}
)
def update_deepsearch_session(uid: str, fields: dict) -> None:
if "deepsearch_sessions" not in db.tables:
return
payload = dict(fields)
payload["uid"] = uid
get_table("deepsearch_sessions").update(payload, ["uid"])
def get_deepsearch_session(uid: str) -> dict | None:
if "deepsearch_sessions" not in db.tables:
return None
return get_table("deepsearch_sessions").find_one(uid=uid, deleted_at=None)
def add_deepsearch_message(
uid: str, session_uid: str, role: str, content: str, citations: str = ""
) -> None:
get_table("deepsearch_messages").insert(
{
"uid": uid,
"session_uid": session_uid,
"role": role,
"content": content,
"citations": citations,
"created_at": _ds_now(),
"deleted_at": None,
"deleted_by": None,
}
)
def get_deepsearch_messages(session_uid: str, limit: int = 50) -> list[dict]:
if "deepsearch_messages" not in db.tables:
return []
return list(
get_table("deepsearch_messages").find(
session_uid=session_uid,
deleted_at=None,
order_by=["created_at"],
_limit=limit,
)
)
def get_cached_deepsearch_url(url_hash: str) -> dict | None:
if "deepsearch_url_cache" not in db.tables:
return None
return get_table("deepsearch_url_cache").find_one(url_hash=url_hash)
def upsert_deepsearch_url_cache(
url_hash: str,
url: str,
title: str,
content_hash: str,
status: int,
byte_size: int,
) -> None:
table = get_table("deepsearch_url_cache")
existing = table.find_one(url_hash=url_hash)
row = {
"url_hash": url_hash,
"url": url,
"title": title,
"content_hash": content_hash,
"status": status,
"byte_size": byte_size,
"fetched_at": _ds_now(),
}
if existing:
row["uid"] = existing["uid"]
table.update(row, ["uid"])
else:
from devplacepy.utils import generate_uid
row["uid"] = generate_uid()
table.insert(row)
-95
View File
@@ -1,95 +0,0 @@
# retoor <retoor@molodetz.nl>
from .core import datetime, db, get_table, timezone
from .soft_delete import soft_delete
EMAIL_ACCOUNT_DEFAULTS: dict[str, object] = {
"imap_host": "",
"imap_port": 993,
"imap_ssl": 1,
"imap_starttls": 0,
"smtp_host": "",
"smtp_port": 587,
"smtp_ssl": 0,
"smtp_starttls": 1,
"username": "",
"password": "",
"from_address": "",
"from_name": "",
}
def list_email_accounts(owner_kind: str, owner_id: str) -> list:
if "email_accounts" not in db.tables:
return []
return list(
db["email_accounts"].find(
owner_kind=owner_kind, owner_id=owner_id, deleted_at=None
)
)
def get_email_account(owner_kind: str, owner_id: str, label: str) -> dict | None:
if "email_accounts" not in db.tables:
return None
return db["email_accounts"].find_one(
owner_kind=owner_kind, owner_id=owner_id, label=label, deleted_at=None
)
def set_email_account(
owner_kind: str, owner_id: str, label: str, fields: dict
) -> dict:
from devplacepy.utils import generate_uid
table = get_table("email_accounts")
now = datetime.now(timezone.utc).isoformat()
existing = table.find_one(owner_kind=owner_kind, owner_id=owner_id, label=label)
values = {**EMAIL_ACCOUNT_DEFAULTS, **(existing or {}), **fields}
if not values.get("from_address"):
values["from_address"] = values.get("username") or ""
record = {
key: values.get(key, default)
for key, default in EMAIL_ACCOUNT_DEFAULTS.items()
}
if existing:
record.update(
{
"id": existing["id"],
"updated_at": now,
"deleted_at": None,
"deleted_by": None,
}
)
table.update(record, ["id"])
result = {**existing, **record}
else:
result = {
"uid": generate_uid(),
"owner_kind": owner_kind,
"owner_id": owner_id,
"label": label,
"created_at": now,
"updated_at": now,
"deleted_at": None,
"deleted_by": None,
**record,
}
table.insert(result)
return result
def delete_email_account(
owner_kind: str, owner_id: str, label: str, deleted_by: str | None = None
) -> int:
if "email_accounts" not in db.tables:
return 0
count = soft_delete(
"email_accounts",
deleted_by or f"{owner_kind}:{owner_id}",
owner_kind=owner_kind,
owner_id=owner_id,
label=label,
)
return int(count)
-187
View File
@@ -1,187 +0,0 @@
# retoor <retoor@molodetz.nl>
from .core import TTLCache, _in_clause, db, defaultdict
_comment_count_cache = TTLCache(ttl=15, max_size=10000)
def get_comment_counts_by_post_uids(post_uids):
if not post_uids or "comments" not in db.tables:
return {}
result = {}
misses = []
for uid in post_uids:
cached = _comment_count_cache.get(uid)
if cached is None:
misses.append(uid)
else:
result[uid] = cached
if misses:
placeholders, params = _in_clause(misses)
rows = db.query(
f"SELECT target_uid, COUNT(*) as c FROM comments WHERE target_type='post' AND target_uid IN ({placeholders}) AND deleted_at IS NULL GROUP BY target_uid",
**params,
)
fetched = {r["target_uid"]: r["c"] for r in rows}
for uid in misses:
count = fetched.get(uid, 0)
_comment_count_cache.set(uid, count)
result[uid] = count
return result
def get_post_counts_by_user_uids(user_uids):
if not user_uids or "posts" not in db.tables:
return {}
placeholders, params = _in_clause(user_uids)
rows = db.query(
f"SELECT user_uid, COUNT(*) as c FROM posts WHERE user_uid IN ({placeholders}) AND deleted_at IS NULL GROUP BY user_uid",
**params,
)
return {r["user_uid"]: r["c"] for r in rows}
def get_vote_counts(target_uids):
if not target_uids or "votes" not in db.tables:
return {}, {}
placeholders, params = _in_clause(target_uids)
rows = db.query(
f"SELECT target_uid, value, COUNT(*) as c FROM votes WHERE target_uid IN ({placeholders}) AND deleted_at IS NULL GROUP BY target_uid, value",
**params,
)
ups = {}
downs = {}
for r in rows:
if r["value"] == 1:
ups[r["target_uid"]] = r["c"]
else:
downs[r["target_uid"]] = r["c"]
return ups, downs
def get_user_votes(user_uid, target_uids):
if not user_uid or not target_uids or "votes" not in db.tables:
return {}
placeholders, params = _in_clause(target_uids)
params["uid"] = user_uid
rows = db.query(
f"SELECT target_uid, value FROM votes WHERE user_uid = :uid AND target_uid IN ({placeholders}) AND deleted_at IS NULL",
**params,
)
return {r["target_uid"]: r["value"] for r in rows}
def get_reactions_by_targets(target_type, target_uids, user=None):
if not target_uids or "reactions" not in db.tables:
return {}
placeholders, params = _in_clause(target_uids)
params["tt"] = target_type
rows = db.query(
f"SELECT target_uid, emoji, COUNT(*) as c FROM reactions WHERE target_type=:tt AND target_uid IN ({placeholders}) AND deleted_at IS NULL GROUP BY target_uid, emoji",
**params,
)
counts = defaultdict(dict)
for row in rows:
counts[row["target_uid"]][row["emoji"]] = row["c"]
mine = defaultdict(list)
if user:
placeholders, params = _in_clause(target_uids, prefix="m")
params["tt"] = target_type
params["u"] = user["uid"]
for row in db.query(
f"SELECT target_uid, emoji FROM reactions WHERE user_uid=:u AND target_type=:tt AND target_uid IN ({placeholders}) AND deleted_at IS NULL",
**params,
):
mine[row["target_uid"]].append(row["emoji"])
result = {}
for uid in target_uids:
result[uid] = {
"counts": dict(counts.get(uid, {})),
"mine": list(mine.get(uid, [])),
}
return result
def get_user_bookmarks(user_uid, target_type, target_uids):
if not user_uid or not target_uids or "bookmarks" not in db.tables:
return set()
placeholders, params = _in_clause(target_uids)
params["u"] = user_uid
params["tt"] = target_type
rows = db.query(
f"SELECT target_uid FROM bookmarks WHERE user_uid=:u AND target_type=:tt AND target_uid IN ({placeholders}) AND deleted_at IS NULL",
**params,
)
return {row["target_uid"] for row in rows}
def get_polls_by_post_uids(post_uids, user=None):
if not post_uids or "polls" not in db.tables:
return {}
placeholders, params = _in_clause(post_uids)
polls = list(
db.query(
f"SELECT * FROM polls WHERE post_uid IN ({placeholders}) AND deleted_at IS NULL",
**params,
)
)
if not polls:
return {}
poll_uids = [poll["uid"] for poll in polls]
option_placeholders, option_params = _in_clause(poll_uids, prefix="o")
options = list(
db.query(
f"SELECT * FROM poll_options WHERE poll_uid IN ({option_placeholders}) AND deleted_at IS NULL ORDER BY position",
**option_params,
)
)
counts = defaultdict(dict)
totals = defaultdict(int)
if "poll_votes" in db.tables:
vote_placeholders, vote_params = _in_clause(poll_uids, prefix="v")
for row in db.query(
f"SELECT poll_uid, option_uid, COUNT(*) as c FROM poll_votes WHERE poll_uid IN ({vote_placeholders}) AND deleted_at IS NULL GROUP BY poll_uid, option_uid",
**vote_params,
):
counts[row["poll_uid"]][row["option_uid"]] = row["c"]
totals[row["poll_uid"]] += row["c"]
user_choice = {}
if user and "poll_votes" in db.tables:
placeholders, params = _in_clause(poll_uids, prefix="m")
params["u"] = user["uid"]
for row in db.query(
f"SELECT poll_uid, option_uid FROM poll_votes WHERE user_uid=:u AND poll_uid IN ({placeholders}) AND deleted_at IS NULL",
**params,
):
user_choice[row["poll_uid"]] = row["option_uid"]
options_by_poll = defaultdict(list)
for option in options:
options_by_poll[option["poll_uid"]].append(option)
result = {}
for poll in polls:
poll_uid = poll["uid"]
total = totals.get(poll_uid, 0)
rendered = []
for option in options_by_poll.get(poll_uid, []):
count = counts.get(poll_uid, {}).get(option["uid"], 0)
rendered.append(
{
"uid": option["uid"],
"label": option["label"],
"count": count,
"pct": round(count * 100 / total) if total else 0,
}
)
result[poll["post_uid"]] = {
"uid": poll_uid,
"question": poll["question"],
"options": rendered,
"total": total,
"my_choice": user_choice.get(poll_uid),
}
return result
def get_poll_for_post(post_uid, user=None):
return get_polls_by_post_uids([post_uid], user).get(post_uid)
-64
View File
@@ -1,64 +0,0 @@
# retoor <retoor@molodetz.nl>
from .core import _in_clause, db, get_table
from .users import get_users_by_uids
from .pagination import build_pagination
def get_follow_counts(user_uid: str) -> dict:
if "follows" not in db.tables:
return {"followers": 0, "following": 0}
follows = get_table("follows")
return {
"followers": follows.count(following_uid=user_uid, deleted_at=None),
"following": follows.count(follower_uid=user_uid, deleted_at=None),
}
def get_follow_list(
user_uid: str, mode: str, page: int = 1, per_page: int = 25
) -> tuple:
if "follows" not in db.tables:
return [], build_pagination(page, 0, per_page)
follows = get_table("follows")
key = "following_uid" if mode == "followers" else "follower_uid"
other = "follower_uid" if mode == "followers" else "following_uid"
total = follows.count(deleted_at=None, **{key: user_uid})
pagination = build_pagination(page, total, per_page)
offset = (pagination["page"] - 1) * pagination["per_page"]
rows = list(
follows.find(
order_by=["-created_at"],
_limit=pagination["per_page"],
_offset=offset,
deleted_at=None,
**{key: user_uid},
)
)
users_map = get_users_by_uids([row[other] for row in rows])
people = []
for row in rows:
person = users_map.get(row[other])
if person:
people.append(
{
"uid": person["uid"],
"username": person["username"],
"bio": (person.get("bio") or "")[:140],
"last_seen": person.get("last_seen"),
"followed_at": row.get("created_at"),
}
)
return people, pagination
def get_following_among(follower_uid: str, target_uids: list) -> set:
if not follower_uid or not target_uids or "follows" not in db.tables:
return set()
placeholders, params = _in_clause(target_uids)
params["f"] = follower_uid
rows = db.query(
f"SELECT following_uid FROM follows WHERE follower_uid = :f AND following_uid IN ({placeholders}) AND deleted_at IS NULL",
**params,
)
return {row["following_uid"] for row in rows}
-59
View File
@@ -1,59 +0,0 @@
# retoor <retoor@molodetz.nl>
from .core import _now_iso, datetime, db, get_table, timezone
from .soft_delete import soft_delete
def record_fork(
source_project_uid: str, forked_project_uid: str, forked_by_uid: str
) -> None:
from devplacepy.utils import generate_uid
get_table("project_forks").insert(
{
"uid": generate_uid(),
"source_project_uid": source_project_uid,
"forked_project_uid": forked_project_uid,
"forked_by_uid": forked_by_uid,
"created_at": datetime.now(timezone.utc).isoformat(),
"deleted_at": None,
"deleted_by": None,
}
)
def get_fork_parent(forked_project_uid: str) -> dict | None:
if "project_forks" not in db.tables:
return None
relation = get_table("project_forks").find_one(
forked_project_uid=forked_project_uid, deleted_at=None
)
if not relation:
return None
return get_table("projects").find_one(
uid=relation["source_project_uid"], deleted_at=None
)
def count_forks(source_project_uid: str) -> int:
if "project_forks" not in db.tables:
return 0
return get_table("project_forks").count(
source_project_uid=source_project_uid, deleted_at=None
)
def soft_delete_fork_relations(project_uid: str, deleted_by: str) -> None:
if "project_forks" not in db.tables:
return
stamp = _now_iso()
soft_delete("project_forks", deleted_by, stamp=stamp, forked_project_uid=project_uid)
soft_delete("project_forks", deleted_by, stamp=stamp, source_project_uid=project_uid)
def delete_fork_relations(project_uid: str) -> None:
if "project_forks" not in db.tables:
return
forks = get_table("project_forks")
forks.delete(forked_project_uid=project_uid)
forks.delete(source_project_uid=project_uid)
-200
View File
@@ -1,200 +0,0 @@
# retoor <retoor@molodetz.nl>
from .core import TTLCache, bump_cache_version, datetime, db, get_table, sync_local_cache, timezone
from .settings import get_int_setting, set_setting
from .soft_delete import soft_delete
NOTIFICATION_TYPES = [
{"key": "comment", "label": "Comments", "description": "Someone comments on your post"},
{"key": "reply", "label": "Replies", "description": "Someone replies to your comment"},
{"key": "mention", "label": "Mentions", "description": "Someone mentions you with @username"},
{"key": "vote", "label": "Upvotes", "description": "Someone ++'d your content"},
{"key": "follow", "label": "Followers", "description": "Someone starts following you"},
{"key": "message", "label": "Direct messages", "description": "Someone sends you a message"},
{"key": "badge", "label": "Badges", "description": "You earn a badge"},
{"key": "level", "label": "Level-ups", "description": "You reach a new level"},
{"key": "issue", "label": "Issue tracker", "description": "Updates on issue reports you filed"},
{"key": "reminder", "label": "Reminders", "description": "A reminder or scheduled task you asked Devii to run fires"},
{"key": "harvest_stolen", "label": "Farm raids", "description": "Someone steals a ready build from your Code Farm"},
{"key": "award", "label": "Awards", "description": "Someone gives you an award on your profile"},
{"key": "admin_alert", "label": "Admin alerts", "description": "System alerts requiring admin attention"},
]
NOTIFICATION_CHANNELS = ("in_app", "push", "telegram")
_NOTIFICATION_CHANNEL_COLUMNS = {
"in_app": "in_app_enabled",
"push": "push_enabled",
"telegram": "telegram_enabled",
}
_NOTIFICATION_CHANNEL_DEFAULTS = {"in_app": 1, "push": 1, "telegram": 0}
_NOTIFICATION_TYPE_KEYS = {entry["key"] for entry in NOTIFICATION_TYPES}
_notification_prefs_cache = TTLCache(ttl=300, max_size=500)
def _notification_default(notification_type: str, channel: str) -> bool:
fallback = _NOTIFICATION_CHANNEL_DEFAULTS.get(channel, 1)
return get_int_setting(f"notif_default_{notification_type}_{channel}", fallback) != 0
def get_notification_default(notification_type: str, channel: str) -> bool:
return _notification_default(notification_type, channel)
def set_notification_default(
notification_type: str, channel: str, enabled: bool
) -> None:
if channel not in _NOTIFICATION_CHANNEL_COLUMNS:
raise ValueError(f"Unknown notification channel: {channel}")
set_setting(f"notif_default_{notification_type}_{channel}", "1" if enabled else "0")
def _notification_overrides(user_uid: str) -> dict:
sync_local_cache("notif_prefs", _notification_prefs_cache)
cached = _notification_prefs_cache.get(user_uid)
if cached is not None:
return cached
overrides: dict = {}
if "notification_preferences" in db.tables:
for row in db["notification_preferences"].find(
user_uid=user_uid, deleted_at=None
):
overrides[row["notification_type"]] = {
channel: bool(
row.get(column, _NOTIFICATION_CHANNEL_DEFAULTS.get(channel, 1))
)
for channel, column in _NOTIFICATION_CHANNEL_COLUMNS.items()
}
_notification_prefs_cache.set(user_uid, overrides)
return overrides
def notification_enabled(user_uid: str, notification_type: str, channel: str) -> bool:
if channel not in _NOTIFICATION_CHANNEL_COLUMNS:
return True
override = _notification_overrides(user_uid).get(notification_type)
if override is not None:
return bool(override[channel])
return _notification_default(notification_type, channel)
def get_notification_prefs(user_uid: str) -> list:
overrides = _notification_overrides(user_uid)
result = []
for entry in NOTIFICATION_TYPES:
key = entry["key"]
override = overrides.get(key)
channels = {
channel: bool(override[channel])
if override
else _notification_default(key, channel)
for channel in _NOTIFICATION_CHANNEL_COLUMNS
}
result.append(
{
"key": key,
"label": entry["label"],
"description": entry["description"],
**channels,
"customized": override is not None,
}
)
return result
def set_notification_pref(
user_uid: str, notification_type: str, channel: str, enabled: bool
) -> dict:
if notification_type not in _NOTIFICATION_TYPE_KEYS:
raise ValueError(f"Unknown notification type: {notification_type}")
if channel not in _NOTIFICATION_CHANNEL_COLUMNS:
raise ValueError(f"Unknown notification channel: {channel}")
from devplacepy.utils import generate_uid
table = get_table("notification_preferences")
now = datetime.now(timezone.utc).isoformat()
existing = table.find_one(user_uid=user_uid, notification_type=notification_type)
if existing:
values = {
name: bool(
existing.get(column, _NOTIFICATION_CHANNEL_DEFAULTS.get(name, 1))
)
for name, column in _NOTIFICATION_CHANNEL_COLUMNS.items()
}
else:
values = {
name: _notification_default(notification_type, name)
for name in _NOTIFICATION_CHANNEL_COLUMNS
}
values[channel] = enabled
columns = {
column: 1 if values[name] else 0
for name, column in _NOTIFICATION_CHANNEL_COLUMNS.items()
}
if existing:
record = {
"id": existing["id"],
**columns,
"updated_at": now,
"deleted_at": None,
"deleted_by": None,
}
table.update(record, ["id"])
result = {**existing, **record}
else:
result = {
"uid": generate_uid(),
"user_uid": user_uid,
"notification_type": notification_type,
**columns,
"created_at": now,
"updated_at": now,
"deleted_at": None,
"deleted_by": None,
}
table.insert(result)
bump_cache_version("notif_prefs")
return result
def reset_notification_prefs(user_uid: str, deleted_by: str | None = None) -> int:
if "notification_preferences" not in db.tables:
return 0
count = soft_delete(
"notification_preferences", deleted_by or f"user:{user_uid}", user_uid=user_uid
)
bump_cache_version("notif_prefs")
return int(count)
def mark_notifications_read_by_target(user_uid: str, target_url: str) -> int:
if not user_uid or not target_url or "notifications" not in db.tables:
return 0
notifications_table = get_table("notifications")
ids = [
n["id"]
for n in notifications_table.find(user_uid=user_uid, read=False)
if n.get("target_url")
and (
n["target_url"] == target_url
or n["target_url"].startswith(f"{target_url}#")
)
]
if not ids:
return 0
with db:
for notification_id in ids:
notifications_table.update({"id": notification_id, "read": True}, ["id"])
from devplacepy.templating import clear_unread_cache
clear_unread_cache(user_uid)
return len(ids)
-108
View File
@@ -1,108 +0,0 @@
# retoor <retoor@molodetz.nl>
from devplacepy.cache import TTLCache
from .core import db, get_table
from .relations import get_blocked_uids
PAGE_SIZE = 25
_user_post_count_cache = TTLCache(ttl=15, max_size=2000)
def paginate(
table,
*clauses,
before=None,
order=None,
cursor_field="created_at",
viewer_uid=None,
**filters,
):
order = order or ["-" + cursor_field]
clauses = list(clauses)
if table.has_column("deleted_at") and "deleted_at" not in filters:
clauses.append(table.table.columns.deleted_at.is_(None))
if viewer_uid and table.has_column("user_uid"):
blocked = get_blocked_uids(viewer_uid)
if blocked:
clauses.append(table.table.columns.user_uid.notin_(blocked))
if before:
clauses.append(table.table.columns[cursor_field] < before)
rows = list(table.find(*clauses, **filters, order_by=order, _limit=PAGE_SIZE + 1))
has_more = len(rows) > PAGE_SIZE
rows = rows[:PAGE_SIZE]
next_cursor = rows[-1][cursor_field] if has_more and rows else None
return rows, next_cursor
def interleave_by_author(rows, uid_key="user_uid"):
remaining = list(rows)
spread = []
last_owner = object()
while remaining:
pick = next(
(
index
for index, row in enumerate(remaining)
if row.get(uid_key) != last_owner
),
0,
)
row = remaining.pop(pick)
spread.append(row)
last_owner = row.get(uid_key)
return spread
def paginate_diverse(
table,
*clauses,
before=None,
order=None,
cursor_field="created_at",
uid_key="user_uid",
viewer_uid=None,
**filters,
):
rows, next_cursor = paginate(
table,
*clauses,
before=before,
order=order,
cursor_field=cursor_field,
viewer_uid=viewer_uid,
**filters,
)
return interleave_by_author(rows, uid_key=uid_key), next_cursor
def clear_user_post_count(user_uid: str) -> None:
_user_post_count_cache.pop(user_uid)
def get_user_post_count(user_uid: str) -> int:
cached = _user_post_count_cache.get(user_uid)
if cached is not None:
return cached
if "posts" not in db.tables:
return 0
count = get_table("posts").count(user_uid=user_uid, deleted_at=None)
_user_post_count_cache.set(user_uid, count)
return count
def build_pagination(page, total, per_page=25):
total_pages = max(1, __import__("math").ceil(total / per_page))
page = max(1, min(page, total_pages))
return {
"page": page,
"per_page": per_page,
"total": total,
"total_pages": total_pages,
"has_prev": page > 1,
"has_next": page < total_pages,
"prev_page": page - 1,
"next_page": page + 1,
}
-182
View File
@@ -1,182 +0,0 @@
# retoor <retoor@molodetz.nl>
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
VOTABLE_TARGETS: dict[str, str] = {
"post": "posts",
"project": "projects",
"gist": "gists",
"comment": "comments",
}
STAR_TARGETS: set[str] = {"post", "project", "gist"}
_authors_cache = TTLCache(ttl=60, max_size=200)
_stars_cache = TTLCache(ttl=15, max_size=2000)
def _ranked_authors() -> list:
cached = _authors_cache.get("ranked")
if cached is not None:
return cached
sources = [
(target_type, table_name)
for target_type, table_name in VOTABLE_TARGETS.items()
if table_name in db.tables
]
if "votes" not in db.tables or not sources:
_authors_cache.set("ranked", [])
return []
target_union = " UNION ALL ".join(
f"SELECT uid, user_uid, '{target_type}' AS target_type FROM {table_name} WHERE deleted_at IS NULL"
for target_type, table_name in sources
)
rows = db.query(
f"SELECT t.user_uid, SUM(v.value) AS total "
f"FROM votes v JOIN ({target_union}) t ON v.target_uid = t.uid AND v.target_type = t.target_type "
f"WHERE v.deleted_at IS NULL "
f"GROUP BY t.user_uid HAVING SUM(v.value) > 0 ORDER BY total DESC"
)
ranked = [(row["user_uid"], row["total"]) for row in rows]
users_map = get_users_by_uids([uid for uid, _ in ranked])
authors = []
for uid, total in ranked:
user = users_map.get(uid)
if user:
author = dict(user)
author["stars"] = total
authors.append(author)
_authors_cache.set("ranked", authors)
_authors_cache.set(
"rank_map",
{author["uid"]: position for position, author in enumerate(authors, start=1)},
)
return authors
def _rank_map() -> dict:
cached = _authors_cache.get("rank_map")
if cached is not None:
return cached
_ranked_authors()
return _authors_cache.get("rank_map") or {}
def get_top_authors(limit: int = 5) -> list:
return _ranked_authors()[:limit]
def get_leaderboard(limit: int = 50, offset: int = 0) -> list:
sliced = _ranked_authors()[offset : offset + limit]
leaderboard = []
for position, author in enumerate(sliced, start=offset + 1):
entry = dict(author)
entry["rank"] = position
leaderboard.append(entry)
return leaderboard
def get_user_rank(user_uid: str):
return _rank_map().get(user_uid)
def clear_user_stars(user_uid: str) -> None:
_stars_cache.pop(user_uid)
def get_user_stars(user_uid: str) -> int:
cached = _stars_cache.get(user_uid)
if cached is not None:
return cached
if "votes" not in db.tables:
return 0
target_union = " UNION ALL ".join(
f"SELECT uid, '{target_type}' AS target_type FROM {table_name} WHERE user_uid = :u AND deleted_at IS NULL"
for target_type, table_name in VOTABLE_TARGETS.items()
if table_name in db.tables
)
if not target_union:
return 0
total = 0
for row in db.query(
f"SELECT COALESCE(SUM(v.value), 0) AS s "
f"FROM votes v JOIN ({target_union}) t ON v.target_uid = t.uid AND v.target_type = t.target_type "
f"WHERE v.deleted_at IS NULL",
u=user_uid,
):
total = row["s"] or 0
break
_stars_cache.set(user_uid, total)
return total
def update_target_stars(target_type: str, target_uid: str, net_stars: int) -> None:
table_name = VOTABLE_TARGETS.get(target_type)
if not table_name:
return
if target_type in STAR_TARGETS:
get_table(table_name).update({"uid": target_uid, "stars": net_stars}, ["uid"])
def soft_delete_engagement(target_type: str, target_uids: list, deleted_by: str) -> None:
uids = [uid for uid in (target_uids or []) if uid]
if not uids:
return
stamp = _now_iso()
soft_delete_in(
"reactions", "target_uid", uids, deleted_by, stamp=stamp, target_type=target_type
)
soft_delete_in(
"bookmarks", "target_uid", uids, deleted_by, stamp=stamp, target_type=target_type
)
if target_type == "post" and "polls" in db.tables:
for uid in uids:
for poll in db["polls"].find(post_uid=uid, deleted_at=None):
soft_delete("poll_votes", deleted_by, stamp=stamp, poll_uid=poll["uid"])
soft_delete("poll_options", deleted_by, stamp=stamp, poll_uid=poll["uid"])
soft_delete("polls", deleted_by, stamp=stamp, post_uid=uid)
def delete_engagement(target_type: str, target_uids: list) -> None:
uids = [uid for uid in (target_uids or []) if uid]
if not uids:
return
if "reactions" in db.tables:
placeholders, params = _in_clause(uids)
params["tt"] = target_type
with db:
db.query(
f"DELETE FROM reactions WHERE target_type=:tt AND target_uid IN ({placeholders})",
**params,
)
if "bookmarks" in db.tables:
placeholders, params = _in_clause(uids)
params["tt"] = target_type
with db:
db.query(
f"DELETE FROM bookmarks WHERE target_type=:tt AND target_uid IN ({placeholders})",
**params,
)
if target_type == "post" and "polls" in db.tables:
for uid in uids:
for poll in db["polls"].find(post_uid=uid):
if "poll_votes" in db.tables:
db["poll_votes"].delete(poll_uid=poll["uid"])
if "poll_options" in db.tables:
db["poll_options"].delete(poll_uid=poll["uid"])
db["polls"].delete(post_uid=uid)
def get_target_owner_uid(target_type: str, target_uid: str) -> str | None:
table_name = VOTABLE_TARGETS.get(target_type)
if not table_name:
return None
row = get_table(table_name).find_one(uid=target_uid, deleted_at=None)
return row["user_uid"] if row else None
-45
View File
@@ -1,45 +0,0 @@
# retoor <retoor@molodetz.nl>
from .core import TTLCache, bump_cache_version, db, sync_local_cache
_relations_cache = TTLCache(ttl=300, max_size=2000)
def get_user_relations(viewer_uid: str | None) -> dict:
if not viewer_uid:
return {"block": frozenset(), "mute": frozenset()}
sync_local_cache("relations", _relations_cache)
cached = _relations_cache.get(viewer_uid)
if cached is not None:
return cached
block: set = set()
mute: set = set()
if "user_relations" in db.tables:
for row in db["user_relations"].find(user_uid=viewer_uid, deleted_at=None):
target = row["target_uid"]
if row["kind"] == "block":
block.add(target)
elif row["kind"] == "mute":
mute.add(target)
result = {"block": frozenset(block), "mute": frozenset(mute)}
_relations_cache.set(viewer_uid, result)
return result
def get_blocked_uids(viewer_uid: str | None) -> frozenset:
return get_user_relations(viewer_uid)["block"]
def get_muted_uids(viewer_uid: str | None) -> frozenset:
return get_user_relations(viewer_uid)["mute"]
def get_silenced_uids(viewer_uid: str | None) -> frozenset:
relations = get_user_relations(viewer_uid)
return relations["block"] | relations["mute"]
def invalidate_user_relations(viewer_uid: str) -> None:
_relations_cache.pop(viewer_uid)
bump_cache_version("relations")
-348
View File
@@ -1,348 +0,0 @@
# retoor <retoor@molodetz.nl>
import inspect
import os
import httpx
from devplacepy.cache import TTLCache
from devplacepy_services.base.db_codec import (
decode_value,
encode_args,
is_write,
is_write_sql,
)
_SERVICE_URL = os.environ.get("DEVPLACE_DB_SERVICE_URL", "http://127.0.0.1:10601").rstrip("/")
_INTERNAL_KEY = os.environ.get("DEVPLACE_GATEWAY_INTERNAL_KEY", "").strip()
_CLIENT: httpx.Client | None = None
# Section 7.3: settings reads tolerate up to 5s staleness. patch_module()
# generically RPCs every devplacepy.database call, bypassing the local
# TTL cache get_setting/get_int_setting had in-process - without this,
# every settings read (rate limiting, maintenance mode, admin dashboards)
# pays a full HTTP round trip to the database broker.
_SETTINGS_CACHE_TTL_SECONDS = 5
_SETTINGS_CACHE = TTLCache(ttl=_SETTINGS_CACHE_TTL_SECONDS, max_size=512)
_CACHED_SETTINGS_FNS = frozenset({"get_setting", "get_int_setting"})
def _service_url() -> str:
return os.environ.get("DEVPLACE_DB_SERVICE_URL", _SERVICE_URL).rstrip("/")
def _headers() -> dict[str, str]:
headers: dict[str, str] = {}
key = os.environ.get("DEVPLACE_GATEWAY_INTERNAL_KEY", _INTERNAL_KEY).strip()
if key:
headers["X-Internal-Key"] = key
return headers
def _client() -> httpx.Client:
global _CLIENT
if _CLIENT is None:
_CLIENT = httpx.Client(timeout=30.0)
return _CLIENT
def _post(path: str, body: dict) -> object:
response = _client().post(
f"{_service_url()}/{path.lstrip('/')}",
json=body,
headers=_headers(),
)
if response.status_code >= 400:
payload = response.json() if response.content else {}
message = payload.get("error", "Database service request failed")
raise RuntimeError(message)
if not response.content:
return None
return decode_value(response.json())
def _invoke_cached(fn_name: str, args, kwargs):
cache_key = f"{fn_name}:{args!r}:{sorted(kwargs.items())!r}"
cached = _SETTINGS_CACHE.get(cache_key)
if cached is not None:
return cached
value = _invoke(fn_name, args, kwargs, write=False)
_SETTINGS_CACHE.set(cache_key, value)
return value
def _invoke(fn_name: str, args, kwargs, *, write: bool = False):
encoded_args, encoded_kwargs = encode_args(args, kwargs)
payload = {
"fn": fn_name,
"args": encoded_args,
"kwargs": encoded_kwargs,
"write": write,
}
result = _post("internal/invoke", payload)
if isinstance(result, dict) and "result" in result:
return result["result"]
return result
class RemoteSearchClause:
def __init__(self, term, fields, author_field=None):
self.term = term.strip()
self.fields = tuple(fields)
self.author_field = author_field
class RemoteUidInClause:
def __init__(self, field, uids):
self.field = field
self.uids = frozenset(uids)
class RemoteTable:
def __init__(self, db: "RemoteDb", name: str) -> None:
self._db = db
self._name = name
self._column_cache = None
def __getattr__(self, name: str):
def caller(*args, **kwargs):
return self._db._table_op(self._name, name, args, kwargs)
return caller
def has_column(self, name: str) -> bool:
cache = self._column_cache
if cache is None:
sample = self.find(_limit=1)
row = next(iter(sample), None)
cache = set(row.keys()) if row else set()
self._column_cache = cache
return name in cache
def count(self, **kwargs):
return self._db._table_op(self._name, "count", [], kwargs)
@property
def table(self):
return self
@property
def exists(self) -> bool:
return self._name in self._db.tables
class RemoteDb:
def __init__(self) -> None:
self._tables_cache: list[str] | None = None
@property
def tables(self) -> list[str]:
if self._tables_cache is None:
result = _post("internal/db-op", {"op": "tables"})
self._tables_cache = list(result or [])
return self._tables_cache
def __getitem__(self, name: str) -> RemoteTable:
return RemoteTable(self, name)
def query(self, sql: str, **params):
encoded_args, encoded_kwargs = encode_args((sql,), params)
result = _post(
"internal/db-op",
{
"op": "query",
"args": encoded_args,
"kwargs": encoded_kwargs,
"write": is_write_sql(sql),
},
)
return result or []
def _table_op(self, table: str, method: str, args, kwargs, *, write: bool = False):
encoded_args, encoded_kwargs = encode_args(args, kwargs)
result = _post(
"internal/db-op",
{
"op": "table_op",
"table": table,
"method": method,
"args": encoded_args,
"kwargs": encoded_kwargs,
"write": write,
},
)
if method in {"insert", "update", "delete"}:
self._tables_cache = None
return result
@property
def executable(self):
return self
@property
def in_transaction(self) -> bool:
return False
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
_LOCAL_REMOTE = frozenset(
{
"get_table",
"refresh_snapshot",
"_in_clause",
"_now_iso",
"text_search_clause",
}
)
def _remote_text_search_clause(
table, search, fields=("title", "description"), author_field=None
):
term = (search or "").strip()
if not term:
return None
if type(table).__name__ == "RemoteTable":
return RemoteSearchClause(term, fields, author_field)
from devplacepy.database.content import text_search_clause as local_clause
return local_clause(table, search, fields, author_field=author_field)
def _remote_get_table(name: str):
import devplacepy.database.core as core
return core.db[name]
def _remote_refresh_snapshot() -> None:
return None
def patch_module(module) -> None:
import devplacepy.database as db_module
for name in db_module.__all__:
if name in _LOCAL_REMOTE:
continue
target = getattr(module, name, None)
if target is None or not callable(target):
continue
if inspect.isclass(target):
continue
def make_wrapper(fn_name: str, fn_write: bool):
if fn_name in _CACHED_SETTINGS_FNS:
def wrapper(*args, **kwargs):
return _invoke_cached(fn_name, args, kwargs)
wrapper.__name__ = fn_name
return wrapper
def wrapper(*args, **kwargs):
return _invoke(fn_name, args, kwargs, write=fn_write)
wrapper.__name__ = fn_name
return wrapper
setattr(module, name, make_wrapper(name, is_write(name)))
def activate() -> None:
import devplacepy.database.core as core
core.db = RemoteDb()
import devplacepy.database as db_module
patch_module(db_module)
for submodule_name in (
"settings",
"users",
"relations",
"pagination",
"soft_delete",
"engagement",
"usage",
"awards",
"seo_meta",
"activity",
"customization",
"email",
"notifications",
"forks",
"follows",
"deepsearch",
"ranking",
"comments",
"content",
"attachments_data",
"stats",
"schema",
):
try:
submodule = __import__(
f"devplacepy.database.{submodule_name}",
fromlist=[submodule_name],
)
except ImportError:
continue
patch_module(submodule)
for external_name in (
"devplacepy.services.statistics.tracking",
"devplacepy.services.base",
"devplacepy.attachments",
"devplacepy.project_files",
):
try:
external = __import__(external_name, fromlist=[external_name.split(".")[-1]])
except ImportError:
continue
if hasattr(external, "db"):
external.db = RemoteDb()
db_module.db = core.db
db_module.get_table = _remote_get_table
core.get_table = _remote_get_table
db_module.refresh_snapshot = _remote_refresh_snapshot
core.refresh_snapshot = _remote_refresh_snapshot
db_module.text_search_clause = _remote_text_search_clause
import devplacepy.database.content as content_module
content_module.text_search_clause = _remote_text_search_clause
for submodule_name in (
"settings",
"users",
"relations",
"pagination",
"soft_delete",
"engagement",
"usage",
"awards",
"seo_meta",
"activity",
"customization",
"email",
"notifications",
"forks",
"follows",
"deepsearch",
"ranking",
"comments",
"content",
"attachments_data",
"stats",
"schema",
):
try:
submodule = __import__(
f"devplacepy.database.{submodule_name}",
fromlist=[submodule_name],
)
except ImportError:
continue
if hasattr(submodule, "db"):
submodule.db = core.db
File diff suppressed because it is too large Load Diff
-87
View File
@@ -1,87 +0,0 @@
# retoor <retoor@molodetz.nl>
from .core import _in_clause, _now_iso, db, get_table
SEO_META_TYPES = ("post", "project", "gist", "news", "issue")
def get_seo_metadata(target_type: str, target_uid: str) -> dict | None:
if not target_type or not target_uid or "seo_metadata" not in db.tables:
return None
row = get_table("seo_metadata").find_one(
target_type=target_type,
target_uid=str(target_uid),
status="ready",
deleted_at=None,
)
return dict(row) if row else None
def get_seo_metadata_batch(target_type: str, uids: list) -> dict:
uids = [str(uid) for uid in (uids or []) if uid]
if not uids or "seo_metadata" not in db.tables:
return {}
placeholders, params = _in_clause(uids)
params["tt"] = target_type
rows = db.query(
"SELECT * FROM seo_metadata WHERE target_type = :tt AND status = 'ready' "
f"AND deleted_at IS NULL AND target_uid IN ({placeholders})",
**params,
)
return {row["target_uid"]: dict(row) for row in rows}
def has_fresh_seo_metadata(target_type: str, target_uid: str) -> bool:
return get_seo_metadata(target_type, target_uid) is not None
def upsert_seo_metadata(
target_type: str,
target_uid: str,
seo_title: str,
seo_description: str,
seo_keywords: str,
status: str,
source: str,
) -> None:
if not target_type or not target_uid:
return
from devplacepy.utils import generate_uid
table = get_table("seo_metadata")
now = _now_iso()
generated_at = now if status == "ready" else ""
existing = table.find_one(target_type=target_type, target_uid=str(target_uid))
payload = {
"target_type": target_type,
"target_uid": str(target_uid),
"seo_title": seo_title,
"seo_description": seo_description,
"seo_keywords": seo_keywords,
"status": status,
"source": source,
"generated_at": generated_at,
"updated_at": now,
"deleted_at": None,
"deleted_by": None,
}
if existing:
payload["id"] = existing["id"]
table.update(payload, ["id"])
else:
payload["uid"] = generate_uid()
payload["created_at"] = now
table.insert(payload)
def mark_seo_metadata_stale(target_type: str, target_uid: str) -> None:
if not target_type or not target_uid or "seo_metadata" not in db.tables:
return
table = get_table("seo_metadata")
existing = table.find_one(target_type=target_type, target_uid=str(target_uid))
if existing:
table.update(
{"id": existing["id"], "status": "pending", "updated_at": _now_iso()},
["id"],
)
-48
View File
@@ -1,48 +0,0 @@
# retoor <retoor@molodetz.nl>
from .core import TTLCache, bump_cache_version, db, get_table, sync_local_cache
def internal_gateway_key() -> str:
return get_setting("gateway_internal_key", "")
_settings_cache = TTLCache(ttl=60, max_size=100)
def get_setting(key: str, default: str = "") -> str:
sync_local_cache("settings", _settings_cache)
cached = _settings_cache.get(key)
if cached is not None:
return cached
if "site_settings" not in db.tables:
return default
entry = db["site_settings"].find_one(key=key)
if entry is None:
return default
_settings_cache.set(key, entry["value"])
return entry["value"]
def get_int_setting(key: str, default: int) -> int:
raw = get_setting(key, str(default))
try:
return int(raw)
except (TypeError, ValueError):
return default
def set_setting(key: str, value: str) -> None:
settings = get_table("site_settings")
existing = settings.find_one(key=key)
if existing:
settings.update({"id": existing["id"], "key": key, "value": value}, ["id"])
else:
settings.insert({"uid": f"setting_{key}", "key": key, "value": value})
_settings_cache.set(key, value)
bump_cache_version("settings")
def clear_settings_cache() -> None:
_settings_cache.clear()
bump_cache_version("settings")
-191
View File
@@ -1,191 +0,0 @@
# retoor <retoor@molodetz.nl>
from .core import _drop_index, _in_clause, _index, _now_iso, db
from .pagination import build_pagination
SOFT_DELETE_TABLES = [
"posts",
"comments",
"gists",
"projects",
"news",
"news_images",
"project_files",
"attachments",
"votes",
"reactions",
"bookmarks",
"follows",
"poll_votes",
"polls",
"poll_options",
"sessions",
"instances",
"instance_schedules",
"backup_schedules",
"devii_conversations",
"devii_tasks",
"devii_lessons",
"devii_virtual_tools",
"user_customizations",
"project_forks",
"issue_tickets",
"issue_comment_authors",
"notification_preferences",
"deepsearch_sessions",
"deepsearch_messages",
"isslop_analyses",
"devrant_tokens",
"access_tokens",
"email_accounts",
"user_relations",
"seo_metadata",
"awards",
]
def ensure_soft_delete_columns(table, *, db_handle=None):
handle = db_handle or db
if table not in handle.tables:
return
target = handle[table]
if not target.has_column("deleted_at"):
target.create_column_by_example("deleted_at", "")
if not target.has_column("deleted_by"):
target.create_column_by_example("deleted_by", "")
_drop_index(handle, f"idx_{table}_deleted")
_index(
handle,
table,
f"idx_{table}_trash",
["deleted_at"],
where="deleted_at IS NOT NULL",
)
def soft_delete(table_name, deleted_by, *, stamp=None, **criteria):
if table_name not in db.tables:
return 0
table = db[table_name]
if not table.has_column("deleted_at"):
return 0
rows = list(table.find(deleted_at=None, **criteria))
if not rows:
return 0
stamp = stamp or _now_iso()
for row in rows:
table.update(
{"id": row["id"], "deleted_at": stamp, "deleted_by": deleted_by}, ["id"]
)
return len(rows)
def soft_delete_in(table_name, column, uids, deleted_by, *, stamp=None, **extra):
uids = [uid for uid in (uids or []) if uid]
if not uids or table_name not in db.tables:
return 0
if not db[table_name].has_column("deleted_at"):
return 0
placeholders, params = _in_clause(uids)
params["dat"] = stamp or _now_iso()
params["dby"] = deleted_by
extra_sql = ""
for index, (key, value) in enumerate(extra.items()):
params[f"x{index}"] = value
extra_sql += f" AND {key} = :x{index}"
with db:
db.query(
f"UPDATE {table_name} SET deleted_at = :dat, deleted_by = :dby "
f"WHERE {column} IN ({placeholders}) AND deleted_at IS NULL{extra_sql}",
**params,
)
return len(uids)
def restore(table_name, **criteria):
if table_name not in db.tables:
return 0
table = db[table_name]
if not table.has_column("deleted_at"):
return 0
rows = [row for row in table.find(**criteria) if row.get("deleted_at")]
for row in rows:
table.update({"id": row["id"], "deleted_at": None, "deleted_by": None}, ["id"])
return len(rows)
def purge(table_name, **criteria):
if table_name not in db.tables:
return 0
table = db[table_name]
count = table.count(**criteria)
table.delete(**criteria)
return int(count)
def list_deleted(table_name, page=1, per_page=25):
if table_name not in db.tables or not db[table_name].has_column("deleted_at"):
return [], build_pagination(page, 0, per_page)
table = db[table_name]
column = table.table.columns.deleted_at
total = table.count(column.isnot(None))
pagination = build_pagination(page, total, per_page)
offset = (pagination["page"] - 1) * pagination["per_page"]
rows = list(
table.find(
column.isnot(None),
order_by=["-deleted_at"],
_limit=pagination["per_page"],
_offset=offset,
)
)
return rows, pagination
def count_deleted(table_name):
if table_name not in db.tables or not db[table_name].has_column("deleted_at"):
return 0
table = db[table_name]
return int(table.count(table.table.columns.deleted_at.isnot(None)))
def restore_event(stamp):
if not stamp:
return 0
restored = 0
for table_name in SOFT_DELETE_TABLES:
if table_name in db.tables and db[table_name].has_column("deleted_at"):
restored += int(
db.query(
f"SELECT COUNT(*) AS n FROM {table_name} WHERE deleted_at = :s",
s=stamp,
).__next__()["n"]
)
with db:
db.query(
f"UPDATE {table_name} SET deleted_at = NULL, deleted_by = NULL "
f"WHERE deleted_at = :s",
s=stamp,
)
return restored
def purge_event(stamp):
if not stamp:
return []
purged = []
for table_name in SOFT_DELETE_TABLES:
if table_name in db.tables and db[table_name].has_column("deleted_at"):
rows = list(
db.query(
f"SELECT * FROM {table_name} WHERE deleted_at = :s", s=stamp
)
)
if rows:
purged.append((table_name, rows))
with db:
db.query(
f"DELETE FROM {table_name} WHERE deleted_at = :s", s=stamp
)
return purged
-151
View File
@@ -1,151 +0,0 @@
# retoor <retoor@molodetz.nl>
from .core import TTLCache, datetime, db, timedelta, timezone
from .ranking import get_top_authors
_stats_cache = TTLCache(ttl=30, max_size=50)
def get_site_stats() -> dict:
cached = _stats_cache.get("site")
if cached is not None:
return cached
today_start = (
datetime.now(timezone.utc)
.replace(hour=0, minute=0, second=0, microsecond=0)
.isoformat()
)
stats = {
"total_members": db["users"].count() if "users" in db.tables else 0,
"posts_today": db["posts"].count(
created_at={">=": today_start}, deleted_at=None
)
if "posts" in db.tables
else 0,
"total_projects": db["projects"].count(deleted_at=None)
if "projects" in db.tables
else 0,
"total_gists": db["gists"].count(deleted_at=None)
if "gists" in db.tables
else 0,
}
_stats_cache.set("site", stats)
return stats
_analytics_cache = TTLCache(ttl=300, max_size=50)
def get_platform_analytics(top_n: int = 10) -> dict:
top_n = max(1, min(int(top_n or 10), 50))
cache_key = f"analytics:{top_n}"
cached = _analytics_cache.get(cache_key)
if cached is not None:
return cached
now = datetime.now(timezone.utc)
d1 = (now - timedelta(days=1)).isoformat()
d7 = (now - timedelta(days=7)).isoformat()
d30 = (now - timedelta(days=30)).isoformat()
active_24h = active_7d = active_30d = 0
sources = [
table
for table in ("posts", "comments", "gists", "projects")
if table in db.tables
]
if sources:
union = " UNION ALL ".join(
f"SELECT user_uid, created_at FROM {table} WHERE deleted_at IS NULL"
for table in sources
)
rows = db.query(
"SELECT "
"COUNT(DISTINCT CASE WHEN created_at >= :d1 THEN user_uid END) AS a1, "
"COUNT(DISTINCT CASE WHEN created_at >= :d7 THEN user_uid END) AS a7, "
"COUNT(DISTINCT CASE WHEN created_at >= :d30 THEN user_uid END) AS a30 "
f"FROM ({union})",
d1=d1,
d7=d7,
d30=d30,
)
for row in rows:
active_24h = row["a1"] or 0
active_7d = row["a7"] or 0
active_30d = row["a30"] or 0
signed_in_now = 0
if "sessions" in db.tables:
for row in db.query(
"SELECT COUNT(DISTINCT user_uid) AS n FROM sessions WHERE expires_at > :now AND deleted_at IS NULL",
now=now.isoformat(),
):
signed_in_now = row["n"] or 0
def _users_created_since(cutoff: str) -> int:
return (
db["users"].count(created_at={">=": cutoff}) if "users" in db.tables else 0
)
site = get_site_stats()
totals = {
"posts": db["posts"].count(deleted_at=None) if "posts" in db.tables else 0,
"comments": db["comments"].count(deleted_at=None)
if "comments" in db.tables
else 0,
"gists": site["total_gists"],
"projects": site["total_projects"],
"news": db["news"].count(deleted_at=None) if "news" in db.tables else 0,
}
top_authors = [
{"username": author.get("username", ""), "stars": author.get("stars", 0)}
for author in get_top_authors(top_n)
]
result = {
"total_members": site["total_members"],
"active_24h": active_24h,
"active_7d": active_7d,
"active_30d": active_30d,
"signed_in_now": signed_in_now,
"new_24h": _users_created_since(d1),
"new_7d": _users_created_since(d7),
"new_30d": _users_created_since(d30),
"posts_today": site["posts_today"],
"totals": totals,
"top_authors": top_authors,
"active_definition": (
"Active = created a post, comment, gist, or project within the window. "
"Automated/bot accounts are not separately flagged."
),
"signed_in_definition": (
"signed_in_now = distinct members holding an unexpired session cookie, i.e. who "
"logged in within the session lifetime (default 7 days, up to 30 with remember-me) "
"and have not logged out. It is NOT a real-time presence/online count - the platform "
"does not track per-request last-seen activity - so it is normally a large fraction "
"of recently active members. Do not describe it as 'currently online' or 'logged in "
"right now'."
),
}
_analytics_cache.set(cache_key, result)
return result
_gist_languages_cache = TTLCache(ttl=60, max_size=200)
def get_gist_languages() -> set[str]:
cached = _gist_languages_cache.get("codes")
if cached is not None:
return cached
codes: set[str] = set()
if "gists" in db.tables:
for row in db.query(
"SELECT DISTINCT language FROM gists WHERE deleted_at IS NULL"
):
language = row.get("language")
if language:
codes.add(language)
_gist_languages_cache.set("codes", codes)
return codes
-139
View File
@@ -1,139 +0,0 @@
# retoor <retoor@molodetz.nl>
from .core import datetime, db, get_table, timezone
def _add_usage(usage_table: str, user_uid: str, totals: dict) -> None:
if not user_uid or int(totals.get("calls") or 0) <= 0 or usage_table not in db.tables:
return
with db:
db.query(
f"INSERT INTO {usage_table} "
"(user_uid, calls, prompt_tokens, completion_tokens, total_tokens, cost_usd, "
"upstream_latency_ms, total_latency_ms, updated_at) "
"VALUES (:user_uid, :calls, :prompt, :completion, :total, :cost, :ulat, :tlat, :now) "
"ON CONFLICT(user_uid) DO UPDATE SET "
"calls = calls + excluded.calls, "
"prompt_tokens = prompt_tokens + excluded.prompt_tokens, "
"completion_tokens = completion_tokens + excluded.completion_tokens, "
"total_tokens = total_tokens + excluded.total_tokens, "
"cost_usd = cost_usd + excluded.cost_usd, "
"upstream_latency_ms = upstream_latency_ms + excluded.upstream_latency_ms, "
"total_latency_ms = total_latency_ms + excluded.total_latency_ms, "
"updated_at = excluded.updated_at",
user_uid=user_uid,
calls=int(totals.get("calls") or 0),
prompt=int(totals.get("prompt_tokens") or 0),
completion=int(totals.get("completion_tokens") or 0),
total=int(totals.get("total_tokens") or 0),
cost=float(totals.get("cost_usd") or 0.0),
ulat=float(totals.get("upstream_latency_ms") or 0.0),
tlat=float(totals.get("total_latency_ms") or 0.0),
now=datetime.now(timezone.utc).isoformat(),
)
def _get_usage(usage_table: str, user_uid: str) -> dict:
empty = {
"calls": 0,
"prompt_tokens": 0,
"completion_tokens": 0,
"total_tokens": 0,
"cost_usd": 0.0,
"upstream_latency_ms": 0.0,
"total_latency_ms": 0.0,
"avg_tokens": 0.0,
"avg_upstream_latency_ms": 0.0,
"avg_total_latency_ms": 0.0,
"avg_tokens_per_second": 0.0,
"avg_cost_usd": 0.0,
"updated_at": None,
}
if not user_uid or usage_table not in db.tables:
return empty
row = get_table(usage_table).find_one(user_uid=user_uid)
if not row:
return empty
calls = int(row.get("calls") or 0)
completion = int(row.get("completion_tokens") or 0)
total_tokens = int(row.get("total_tokens") or 0)
cost = float(row.get("cost_usd") or 0.0)
upstream_ms = float(row.get("upstream_latency_ms") or 0.0)
total_ms = float(row.get("total_latency_ms") or 0.0)
return {
"calls": calls,
"prompt_tokens": int(row.get("prompt_tokens") or 0),
"completion_tokens": completion,
"total_tokens": total_tokens,
"cost_usd": cost,
"upstream_latency_ms": upstream_ms,
"total_latency_ms": total_ms,
"avg_tokens": round(total_tokens / calls, 1) if calls else 0.0,
"avg_upstream_latency_ms": round(upstream_ms / calls, 1) if calls else 0.0,
"avg_total_latency_ms": round(total_ms / calls, 1) if calls else 0.0,
"avg_tokens_per_second": round(completion / (upstream_ms / 1000.0), 1)
if upstream_ms > 0
else 0.0,
"avg_cost_usd": (cost / calls) if calls else 0.0,
"updated_at": row.get("updated_at"),
}
def add_correction_usage(user_uid: str, totals: dict) -> None:
_add_usage("correction_usage", user_uid, totals)
def get_correction_usage(user_uid: str) -> dict:
return _get_usage("correction_usage", user_uid)
def add_modifier_usage(user_uid: str, totals: dict) -> None:
_add_usage("modifier_usage", user_uid, totals)
def get_modifier_usage(user_uid: str) -> dict:
return _get_usage("modifier_usage", user_uid)
NEWS_USAGE_KEY = "news"
def add_news_usage(totals: dict) -> None:
_add_usage("news_usage", NEWS_USAGE_KEY, totals)
def get_news_usage() -> dict:
return _get_usage("news_usage", NEWS_USAGE_KEY)
ISSUE_USAGE_KEY = "issues"
def add_issue_usage(totals: dict) -> None:
_add_usage("issue_usage", ISSUE_USAGE_KEY, totals)
def get_issue_usage() -> dict:
return _get_usage("issue_usage", ISSUE_USAGE_KEY)
SEO_USAGE_KEY = "seo_meta"
def add_seo_usage(totals: dict) -> None:
_add_usage("seo_usage", SEO_USAGE_KEY, totals)
def get_seo_usage() -> dict:
return _get_usage("seo_usage", SEO_USAGE_KEY)
AWARD_USAGE_KEY = "award"
def add_award_usage(totals: dict) -> None:
_add_usage("award_usage", AWARD_USAGE_KEY, totals)
def get_award_usage() -> dict:
return _get_usage("award_usage", AWARD_USAGE_KEY)
-108
View File
@@ -1,108 +0,0 @@
# retoor <retoor@molodetz.nl>
from .core import TTLCache, bump_cache_version, db, sync_local_cache
def get_users_by_uids(uids):
if not uids or "users" not in db.tables:
return {}
users = db["users"]
if "uid" not in users.columns:
return {}
seen = set()
unique = [u for u in uids if u not in seen and not seen.add(u)]
return {u["uid"]: u for u in users.find(users.table.columns.uid.in_(unique))}
_admins_cache = TTLCache(ttl=300, max_size=4)
def invalidate_admins_cache() -> None:
_admins_cache.clear()
bump_cache_version("admins")
def get_admin_uids():
sync_local_cache("admins", _admins_cache)
cached = _admins_cache.get("uids")
if cached is not None:
return list(cached)
if "users" not in db.tables:
return []
rows = db.query("SELECT uid FROM users WHERE role = 'Admin'")
uids = [row["uid"] for row in rows]
_admins_cache.set("uids", uids)
return list(uids)
def set_user_timezone(user_uid: str, tz_name: str) -> None:
if "users" not in db.tables or not user_uid or not tz_name:
return
users = db["users"]
if not users.has_column("timezone"):
users.create_column_by_example("timezone", "")
current = users.find_one(uid=user_uid)
if current and current.get("timezone") == tz_name:
return
users.update({"uid": user_uid, "timezone": tz_name}, ["uid"])
def set_last_seen(user_uid: str, iso: str) -> None:
if "users" not in db.tables or not user_uid or not iso:
return
users = db["users"]
if not users.has_column("last_seen"):
users.create_column_by_example("last_seen", "")
users.update({"uid": user_uid, "last_seen": iso}, ["uid"])
def get_online_users(cutoff_iso: str, limit: int = 30) -> list:
if "users" not in db.tables:
return []
users = db["users"]
if "last_seen" not in users.columns:
return []
return list(
users.find(
last_seen={">=": cutoff_iso},
order_by=["username"],
_limit=limit,
)
)
def get_primary_admin_uid():
sync_local_cache("admins", _admins_cache)
cached = _admins_cache.get("primary")
if cached is not None:
return cached or None
if "users" not in db.tables:
return None
rows = list(
db.query(
"SELECT uid FROM users WHERE role = 'Admin' "
"ORDER BY created_at ASC, id ASC LIMIT 1"
)
)
primary = rows[0]["uid"] if rows else None
_admins_cache.set("primary", primary or "")
return primary
def search_users_by_username(q, *, exclude_uid=None, limit=10):
if not q or "users" not in db.tables:
return []
if exclude_uid is not None:
rows = db.query(
"SELECT uid, username FROM users WHERE username LIKE :q AND uid != :me LIMIT :limit",
q=f"%{q}%",
me=exclude_uid,
limit=limit,
)
else:
rows = db.query(
"SELECT uid, username FROM users WHERE username LIKE :q LIMIT :limit",
q=f"%{q}%",
limit=limit,
)
return [{"uid": r["uid"], "username": r["username"]} for r in rows]
-29
View File
@@ -1,29 +0,0 @@
# retoor <retoor@molodetz.nl>
import os
def _activate() -> None:
if os.environ.get("DEVPLACE_DB_SERVICE") == "1":
return
if os.environ.get("DEVPLACE_REMOTE_DB") == "1":
from devplacepy.database.remote import activate
activate()
_activate()
import devplacepy.database as _database
def _remote_table(table) -> bool:
return type(table).__name__ == "RemoteTable"
def __getattr__(name: str):
return getattr(_database, name)
def __dir__():
return sorted(name for name in dir(_database) if not name.startswith("_"))
-95
View File
@@ -1,95 +0,0 @@
# retoor <retoor@molodetz.nl>
"""
Generic FastAPI dependency that accepts JSON or form-encoded data,
validated against a Pydantic model.
"""
import json
import logging
from typing import Any, TypeVar, get_origin
from fastapi import HTTPException, Request
from fastapi.exceptions import RequestValidationError
from pydantic import BaseModel, ValidationError
from starlette.datastructures import FormData
logger = logging.getLogger(__name__)
_TModel = TypeVar("_TModel", bound=BaseModel)
# Container origins recognised as sequence fields that may receive
# multiple values from form data.
_SEQUENCE_ORIGINS = frozenset({list, set, tuple, frozenset})
def _formdata_to_dict(form: FormData, model: type[BaseModel]) -> dict[str, Any]:
"""Convert FormData to a dict suitable for Pydantic validation.
* Sequence-typed model fields collect every submitted value via
``getlist()``; a lone empty string is dropped (browsers emit empty
hidden inputs by default).
* Scalar fields use ``get()`` (the last value).
* Fields absent from the form are omitted so that Pydantic applies
the model default.
"""
body: dict[str, Any] = {}
for field_name, field_info in model.model_fields.items():
origin = get_origin(field_info.annotation)
if origin in _SEQUENCE_ORIGINS:
values = form.getlist(field_name)
if not values:
continue
if values == [""]:
continue
body[field_name] = [v for v in values if v != ""] or []
else:
value = form.get(field_name)
if value is not None:
body[field_name] = value
return body
class _JsonOrForm:
"""Internal callable that parses JSON or form data and validates."""
def __init__(self, model: type[BaseModel]):
self.model = model
async def __call__(self, request: Request) -> Any:
content_type = request.headers.get("content-type", "")
body: Any = None
try:
if "application/json" in content_type:
try:
body = await request.json()
except (json.JSONDecodeError, UnicodeDecodeError, ValueError) as exc:
logger.debug("JSON parse failed: %s", exc)
raise HTTPException(status_code=400, detail="Invalid JSON body")
if not isinstance(body, dict):
raise HTTPException(
status_code=400, detail="JSON body must be an object"
)
return self.model.model_validate(body)
# Default: form-encoded (multipart or url-encoded)
try:
form = await request.form()
except Exception as exc:
logger.debug("Form parse failed: %s", exc)
raise HTTPException(
status_code=400, detail="Could not parse form data"
)
body = _formdata_to_dict(form, self.model)
return self.model.model_validate(body)
except ValidationError as exc:
raise RequestValidationError(errors=exc.errors(), body=body)
def json_or_form(model: type[_TModel]) -> _JsonOrForm:
"""Dependency factory: accept JSON or form-encoded data for a Pydantic model.
Usage:
@router.post("/create")
async def create(data: Annotated[PostForm, Depends(json_or_form(PostForm))]):
...
"""
return _JsonOrForm(model)
File diff suppressed because it is too large Load Diff
-41
View File
@@ -1,41 +0,0 @@
# retoor <retoor@molodetz.nl>
from ._shared import (
BOOKMARK_TARGETS,
COMMENT_TARGETS,
GIST_LANGUAGES,
NON_BODY_ENDPOINTS,
PROJECT_TYPES,
REACTION_TARGETS,
ROLE_LABELS,
SERVICE_ACTIONS,
VOTE_TARGETS,
endpoint,
field,
)
from .groups import ORDERED_GROUPS as API_GROUPS
from .negotiation import _apply_negotiated_responses
from .services_group import build_services_group
from .render import api_doc_pages, get_group, render_group, _substitute
_apply_negotiated_responses(API_GROUPS)
__all__ = [
"API_GROUPS",
"build_services_group",
"get_group",
"api_doc_pages",
"render_group",
"_substitute",
"endpoint",
"field",
"ROLE_LABELS",
"NON_BODY_ENDPOINTS",
"VOTE_TARGETS",
"REACTION_TARGETS",
"BOOKMARK_TARGETS",
"COMMENT_TARGETS",
"PROJECT_TYPES",
"GIST_LANGUAGES",
"SERVICE_ACTIONS",
]
-98
View File
@@ -1,98 +0,0 @@
# 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"]
PROJECT_TYPES = ["game", "game_asset", "software", "mobile_app", "website"]
GIST_LANGUAGES = [
"python",
"javascript",
"typescript",
"html",
"css",
"c",
"cpp",
"java",
"go",
"rust",
"sql",
"bash",
"json",
"markdown",
"plaintext",
]
SERVICE_ACTIONS = [
("start", "Start the service"),
("stop", "Stop the service"),
("run", "Trigger a single run now"),
("clear-logs", "Clear the in-memory log buffer"),
]
def field(
name,
location,
type="string",
required=False,
example="",
description="",
options=None,
):
spec = {
"name": name,
"location": location,
"type": type,
"required": required,
"example": example,
"description": description,
}
if options:
spec["options"] = list(options)
return spec
ROLE_LABELS = {"public": "Public", "user": "Member", "admin": "Admin"}
def endpoint(
id,
method,
path,
title,
summary,
auth="user",
ajax=False,
encoding="none",
interactive=True,
destructive=False,
params=None,
notes=None,
sample_response=None,
negotiation=None,
):
return {
"id": id,
"method": method,
"path": path,
"title": title,
"summary": summary,
"auth": auth,
"min_role": ROLE_LABELS.get(auth, auth.title()),
"ajax": ajax,
"encoding": encoding,
"interactive": interactive,
"destructive": destructive,
"params": params or [],
"notes": notes or [],
"sample_response": sample_response,
"negotiation": negotiation,
}
NON_BODY_ENDPOINTS = {
"avatar",
"gateway-passthrough",
"notifications-open",
"admin-bots-frame",
}
-43
View File
@@ -1,43 +0,0 @@
# retoor <retoor@molodetz.nl>
from . import (
conventions,
auth,
lookups,
social_actions,
content,
profiles,
messaging,
notifications,
uploads,
project_files,
containers,
tools,
push,
issues,
gateway,
services,
admin,
game,
)
ORDERED_GROUPS = [
conventions.GROUP,
auth.GROUP,
lookups.GROUP,
social_actions.GROUP,
content.GROUP,
profiles.GROUP,
messaging.GROUP,
notifications.GROUP,
uploads.GROUP,
project_files.GROUP,
containers.GROUP,
tools.GROUP,
push.GROUP,
issues.GROUP,
gateway.GROUP,
services.GROUP,
admin.GROUP,
game.GROUP,
]
-834
View File
@@ -1,834 +0,0 @@
# retoor <retoor@molodetz.nl>
from .._shared import endpoint, field
GROUP = {
"slug": "admin",
"title": "Admin API",
"admin": True,
"intro": """
# Admin API
Site administration endpoints for users, news curation, and settings. Every call requires an
**admin** account. Background service management lives on the
[Background Services](/docs/services.html) page.
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.
""",
"endpoints": [
endpoint(
id="admin-users",
method="GET",
path="/admin/users",
title="List users",
summary="Paginated user management page. Returns HTML.",
auth="admin",
interactive=True,
params=[field("page", "query", "int", False, "1", "Page number.")],
),
endpoint(
id="admin-media",
method="GET",
path="/admin/media",
title="Media trash",
summary=(
"Moderation view of deleted media. When a member or admin deletes an "
"attachment it is soft-deleted: hidden from the gallery and from its parent "
"object, but the row and file are kept and the relation to the parent is "
"preserved. This page lists every soft-deleted attachment, newest first, with "
"its uploader, so an admin can restore or permanently purge it. Returns HTML."
),
auth="admin",
interactive=True,
params=[field("page", "query", "int", False, "1", "Page number.")],
),
endpoint(
id="media-restore",
method="POST",
path="/media/{uid}/restore",
title="Restore media",
summary=(
"Restore a soft-deleted attachment. Because the parent relation is never "
"cleared, it reappears in the owner's Media tab and on its original post, "
"project, or gist immediately."
),
auth="admin",
destructive=True,
params=[
field(
"uid", "path", "string", True, "", "Soft-deleted attachment uid."
),
],
sample_response={"ok": True, "redirect": "/admin/media"},
),
endpoint(
id="admin-revoke-award",
method="POST",
path="/admin/awards/{uid}/revoke",
title="Revoke award",
summary=(
"Soft-delete a published award and its linked attachments, then recompute "
"receiver stats. Restorable from admin trash."
),
auth="admin",
destructive=True,
params=[
field("uid", "path", "string", True, "", "Award uid to revoke."),
],
sample_response={"ok": True, "redirect": "/profile/receiver?tab=awards"},
),
endpoint(
id="admin-media-purge",
method="POST",
path="/admin/media/{uid}/purge",
title="Purge media",
summary=(
"Permanently delete a soft-deleted attachment: removes the database row and "
"deletes the file from disk. This cannot be undone and is the only way media "
"is hard-deleted from the UI."
),
auth="admin",
destructive=True,
params=[
field("uid", "path", "string", True, "", "Attachment uid to purge."),
],
sample_response={"ok": True, "redirect": "/admin/media"},
),
endpoint(
id="admin-audit-log",
method="GET",
path="/admin/audit-log",
title="Audit log",
summary=(
"Paginated, filterable audit event list (newest first, 25 per page). "
"Negotiates HTML or JSON. Every state-changing action on the platform "
"is recorded here with actor, origin, target, change, and result."
),
auth="admin",
interactive=True,
params=[
field("page", "query", "int", False, "1", "Page number."),
field("event_key", "query", "string", False, "post.create", "Filter by exact event key."),
field("category", "query", "string", False, "admin", "Filter by category (auth, content, admin, container, ...)."),
field("actor_role", "query", "string", False, "admin", "Filter by actor role at action time."),
field("actor_uid", "query", "string", False, "", "Filter by acting user uid."),
field("origin", "query", "string", False, "web", "Filter by origin (web, api, devii, cli, service, scheduler)."),
field("result", "query", "string", False, "denied", "Filter by result (success, failure, denied)."),
field("q", "query", "string", False, "", "Free-text search over summary, event key, and target."),
field("date_from", "query", "string", False, "", "ISO date lower bound (inclusive)."),
field("date_to", "query", "string", False, "", "ISO date upper bound (inclusive)."),
],
sample_response={
"entries": [
{
"uid": "AUDIT_UID",
"created_at": "2026-06-11T20:00:00+00:00",
"event_key": "admin.setting.update",
"category": "admin",
"actor_username": "ADMIN",
"actor_role": "admin",
"origin": "web",
"via_agent": 0,
"target_type": "setting",
"old_value": "0",
"new_value": "1",
"result": "success",
}
],
"pagination": {"page": 1, "total": 1, "total_pages": 1},
"filters": {},
"options": {"category": ["admin", "auth", "content"]},
},
),
endpoint(
id="admin-audit-event",
method="GET",
path="/admin/audit-log/{uid}",
title="Audit event",
summary="A single audit event with its full row and every related-object link.",
auth="admin",
interactive=True,
params=[field("uid", "path", "string", True, "AUDIT_UID", "Audit event uid.")],
sample_response={
"event": {"uid": "AUDIT_UID", "event_key": "container.instance.start", "result": "success"},
"links": [
{"relation": "actor", "object_type": "user", "object_uid": "USER_UID"},
{"relation": "instance", "object_type": "instance", "object_uid": "INSTANCE_UID"},
],
},
),
endpoint(
id="admin-analytics",
method="GET",
path="/admin/analytics",
title="Site analytics",
summary=(
"One-call aggregate analytics, returned as JSON: total members, active users "
"in the last 24h/7d/30d, signed_in_now, new signups (24h/7d/30d), content "
"totals (posts, comments, gists, projects, news), and top authors. Use this "
"instead of paging the user list to count or measure activity."
),
auth="admin",
interactive=True,
params=[
field(
"top_n",
"query",
"int",
False,
"10",
"How many top authors to include (1-50).",
)
],
notes=[
"`signed_in_now` counts members holding an unexpired session (logged in within the session lifetime, default 7-30 days), not a real-time online/presence count; the response carries a `signed_in_definition` explaining this. `active_*` means created content in the window (`active_definition`).",
"This is the endpoint the Devii assistant calls as `site_analytics`; see [Devii internals](/docs/devii-internals.html).",
],
),
endpoint(
id="admin-statistics",
method="GET",
path="/admin/statistics/data",
title="Platform statistics",
summary=(
"Tabbed platform statistics with KPI cards, period-over-period deltas, "
"time-series data for charts, and breakdown tables. Covers visitors, members, "
"content, engagement, social, AI, Devii, services, containers, game, awards, "
"moderation, tools, and storage."
),
auth="admin",
interactive=True,
params=[
field(
"tab",
"query",
"string",
False,
"overview",
"Tab key (overview, visitors, members, content, ...).",
),
field(
"hours",
"query",
"int",
False,
"168",
"Lookback window in hours (24, 168, 720, 2160, or 0 for all time).",
),
field(
"compare",
"query",
"int",
False,
"1",
"Include previous-period comparison (1 or 0).",
),
field(
"top_n",
"query",
"int",
False,
"10",
"Rows in breakdown tables (1-50).",
),
],
notes=[
"The HTML dashboard lives at `/admin/statistics`. Visitor metrics require the statistics tracking middleware (hourly aggregation, 90-day retention).",
],
),
endpoint(
id="admin-statistics-page",
method="GET",
path="/admin/statistics",
title="Statistics dashboard",
summary="Admin HTML dashboard for platform statistics with charts and tabs.",
auth="admin",
interactive=False,
params=[
field(
"tab",
"query",
"string",
False,
"overview",
"Initial tab to render.",
),
field(
"hours",
"query",
"int",
False,
"168",
"Initial time window in hours.",
),
],
),
endpoint(
id="admin-ai-usage",
method="GET",
path="/admin/ai-usage/data",
title="AI gateway usage analytics",
summary=(
"One-call AI gateway metrics, returned as JSON for a bounded time window: request "
"volume and throughput, token usage with averages and percentiles (p50/p90/p95/p99), "
"latency (upstream, gateway overhead, queue wait, connection establishment), error "
"rates by category, cost in USD (per model, per caller, input vs output, projected "
"monthly burn, caching savings), caller behavior, and an hourly breakdown. Cost is "
"taken from the upstream native cost when present (OpenRouter) and computed from the "
"configured per-million pricing otherwise (DeepSeek)."
),
auth="admin",
interactive=True,
params=[
field(
"hours",
"query",
"int",
False,
"48",
"Lookback window in hours (1-168).",
),
field(
"top_n",
"query",
"int",
False,
"10",
"How many rows in each top-N breakdown.",
),
],
notes=[
"TTFT and inter-token latency are not reported: the gateway forwards non-streaming to the upstream."
],
),
endpoint(
id="admin-user-ai-usage",
method="GET",
path="/admin/users/{uid}/ai-usage",
title="Per-user AI usage",
summary=(
"One user's AI gateway usage over the last 24 hours, returned as JSON: request "
"volume, success and error rates, token totals, cost (window, per hour, per request, "
"and a 30-day projection from the full 24h spend), average latency and throughput, a "
"per-model breakdown, and an hourly cost series. Because Devii operates a signed-in "
"user's account with that user's own API key, this is the user's complete gateway "
"spend, whether driven through Devii or direct API calls. Shown admin-only on the "
"user's profile page."
),
auth="admin",
interactive=True,
params=[
field(
"uid", "path", "string", True, "USER_UID", "Target user UID."
),
field(
"hours",
"query",
"int",
False,
"24",
"Lookback window in hours (1-168).",
),
],
sample_response={
"owner_id": "USER_UID",
"window_hours": 24,
"requests": 42,
"success": 41,
"failed": 1,
"success_pct": 97.6,
"error_pct": 2.4,
"tokens": {"prompt": 120000, "completion": 38000, "total": 158000},
"cost": {
"window_usd": 0.214,
"per_hour_usd": 0.0089,
"per_request_usd": 0.0051,
"projected_30d_usd": 6.42,
},
"latency": {"avg_ms": 1830.0, "avg_tps": 41.2},
"by_model": [
{
"key": "molodetz",
"requests": 42,
"total_tokens": 158000,
"cost_usd": 0.214,
}
],
"hourly": [
{
"hour": "2026-06-08T16",
"requests": 6,
"cost_usd": 0.031,
"total_tokens": 22000,
}
],
},
),
endpoint(
id="admin-user-role",
method="POST",
path="/admin/users/{uid}/role",
title="Set a user role",
summary="Promote or demote a user. You cannot change your own role.",
auth="admin",
encoding="form",
destructive=True,
params=[
field(
"uid", "path", "string", True, "USER_UID", "Target user UID."
),
field(
"role",
"form",
"enum",
True,
"member",
"New role.",
["member", "admin"],
),
],
),
endpoint(
id="admin-user-password",
method="POST",
path="/admin/users/{uid}/password",
title="Reset a user password",
summary="Set a new password for a user.",
auth="admin",
encoding="form",
destructive=True,
params=[
field(
"uid", "path", "string", True, "USER_UID", "Target user UID."
),
field(
"password",
"form",
"string",
True,
"newpassword",
"New password, 6+ characters.",
),
],
),
endpoint(
id="admin-user-toggle",
method="POST",
path="/admin/users/{uid}/toggle",
title="Enable or disable a user",
summary="Toggle a user's active state. You cannot disable yourself.",
auth="admin",
destructive=True,
params=[
field("uid", "path", "string", True, "USER_UID", "Target user UID.")
],
),
endpoint(
id="admin-news-list",
method="GET",
path="/admin/news",
title="List news articles",
summary="Paginated news management page. Returns HTML.",
auth="admin",
interactive=True,
params=[field("page", "query", "int", False, "1", "Page number.")],
),
endpoint(
id="admin-news-toggle",
method="POST",
path="/admin/news/{uid}/toggle",
title="Toggle featured",
summary="Toggle an article's featured flag and lock it from the news service's auto-rotation.",
auth="admin",
destructive=True,
params=[
field("uid", "path", "string", True, "NEWS_UID", "Article UID.")
],
),
endpoint(
id="admin-news-publish",
method="POST",
path="/admin/news/{uid}/publish",
title="Toggle published",
summary="Switch an article between draft and published.",
auth="admin",
destructive=True,
params=[
field("uid", "path", "string", True, "NEWS_UID", "Article UID.")
],
),
endpoint(
id="admin-news-landing",
method="POST",
path="/admin/news/{uid}/landing",
title="Toggle landing",
summary="Toggle whether an article shows on the landing page and lock it from the news service's auto-rotation.",
auth="admin",
destructive=True,
params=[
field("uid", "path", "string", True, "NEWS_UID", "Article UID.")
],
),
endpoint(
id="admin-news-delete",
method="POST",
path="/admin/news/{uid}/delete",
title="Delete a news article",
summary="Delete an article and its images.",
auth="admin",
destructive=True,
params=[
field("uid", "path", "string", True, "NEWS_UID", "Article UID.")
],
),
endpoint(
id="admin-settings-get",
method="GET",
path="/admin/settings",
title="Read site settings",
summary="Return the current site and operational settings. Negotiates HTML or JSON.",
auth="admin",
interactive=True,
),
endpoint(
id="admin-settings",
method="POST",
path="/admin/settings",
title="Save site settings",
summary="Update operational and site settings. Empty fields are skipped.",
auth="admin",
encoding="form",
destructive=True,
params=[
field(
"site_name", "form", "string", False, "DevPlace", "Site name."
),
field(
"rate_limit_per_minute",
"form",
"string",
False,
"60",
"Requests per window.",
),
field(
"registration_open",
"form",
"enum",
False,
"1",
"Allow signups.",
["1", "0"],
),
field(
"maintenance_mode",
"form",
"enum",
False,
"0",
"Maintenance gate.",
["1", "0"],
),
],
notes=[
"Accepts every field on the admin settings form; only non-empty values are written."
],
),
endpoint(
id="admin-notification-default",
method="POST",
path="/admin/notifications",
title="Set a notification default",
summary="Set the platform-wide default for one notification type on one channel. Applies to users who have not customized that notification; explicit user choices are unaffected.",
auth="admin",
encoding="form",
destructive=True,
params=[
field(
"notification_type",
"form",
"string",
True,
"vote",
"One of: comment, reply, mention, vote, follow, message, badge, level, issue, reminder, harvest_stolen.",
),
field(
"channel",
"form",
"string",
True,
"push",
"One of in_app, push or telegram (telegram is off by default and requires a paired Telegram account).",
),
field(
"value",
"form",
"boolean",
False,
"1",
"1 to enable this notification by default, 0 to disable.",
),
],
sample_response={
"ok": True,
"redirect": "/admin/notifications",
"data": {
"notification_type": "vote",
"channel": "push",
"value": False,
},
},
),
endpoint(
id="admin-user-reset-ai-quota",
method="POST",
path="/admin/users/{uid}/reset-ai-quota",
title="Reset user AI quota",
summary="Delete a specific user's AI gateway ledger rows, resetting their quota.",
auth="admin",
destructive=True,
params=[
field(
"uid", "path", "string", True, "USER_UID", "Target user UID."
),
],
),
endpoint(
id="admin-ai-quota-reset-guests",
method="POST",
path="/admin/ai-quota/reset-guests",
title="Reset guest AI quotas",
summary="Reset AI quota for all anonymous guest users.",
auth="admin",
destructive=True,
),
endpoint(
id="admin-ai-quota-reset-all",
method="POST",
path="/admin/ai-quota/reset-all",
title="Reset all AI quotas",
summary="Reset AI quota for every user (members and guests).",
auth="admin",
destructive=True,
),
endpoint(
id="admin-bots-monitor",
method="GET",
path="/admin/bots",
title="Bot monitor page",
summary="Live low-quality screenshot grid of every running bot persona, one frame per bot.",
auth="admin",
interactive=True,
),
endpoint(
id="admin-bots-data",
method="GET",
path="/admin/bots/data",
title="Bot monitor data",
summary="JSON of every running bot slot with its label, persona, current action/status, and latest frame URL for polling.",
auth="admin",
sample_response={
"enabled": True,
"service_status": "running",
"frames": [
{
"slot": 0,
"username": "bytewren",
"persona": "grumpy_senior",
"action": "POST: rant [3]",
"status": "page load",
"url": "https://example.com/feed",
"label": "bytewren",
"captured_at": 1718366400,
"age_seconds": 2,
"has_image": True,
"active": True,
"frame_url": "/admin/bots/0/frame.jpg?t=1718366400",
}
],
},
),
endpoint(
id="admin-bots-frame",
method="GET",
path="/admin/bots/{slot}/frame.jpg",
title="Bot frame image",
summary="The latest low-quality JPEG screenshot for one bot slot (no-store).",
auth="admin",
encoding="none",
interactive=False,
params=[
field(
"slot",
"path",
"integer",
True,
"0",
"Bot fleet slot index.",
)
],
),
endpoint(
id="admin-backups",
method="GET",
path="/admin/backups",
title="Backups dashboard",
summary=(
"Storage usage, every backup archive, and every backup schedule. Returns HTML "
"(or JSON with Accept: application/json)."
),
auth="admin",
interactive=True,
),
endpoint(
id="admin-backups-data",
method="GET",
path="/admin/backups/data",
title="Backups data",
summary=(
"JSON dashboard payload: per-path storage usage, total data and backup size, "
"disk usage, the backup list, and the schedule list."
),
auth="admin",
),
endpoint(
id="admin-backups-run",
method="POST",
path="/admin/backups/run",
title="Create backup",
summary=(
"Enqueue an async backup job for a target (database, uploads, keys, full). "
"Returns the job uid and a status_url."
),
auth="admin",
params=[
field(
"target",
"form",
"string",
True,
"full",
"One of database, uploads, keys, full.",
)
],
sample_response={
"ok": True,
"uid": "JOB_UID",
"backup_uid": "BACKUP_UID",
"status_url": "/admin/backups/jobs/JOB_UID",
},
),
endpoint(
id="admin-backups-job",
method="GET",
path="/admin/backups/jobs/{uid}",
title="Backup job status",
summary="Status of one backup job (pending, running, done, failed) with archive stats.",
auth="admin",
params=[field("uid", "path", "string", True, "", "Backup job uid.")],
sample_response={
"uid": "JOB_UID",
"kind": "backup",
"status": "done",
"target": "full",
"backup_uid": "BACKUP_UID",
"download_url": "/admin/backups/BACKUP_UID/download",
"bytes_out": 10485760,
"file_count": 1240,
"sha256": "…",
},
),
endpoint(
id="admin-backups-download",
method="GET",
path="/admin/backups/{uid}/download",
title="Download backup",
summary="Stream a completed backup archive as a tar.gz file. Restricted to the primary administrator (the first user created with the Admin role); every other administrator receives 403 Forbidden.",
auth="admin",
encoding="none",
params=[field("uid", "path", "string", True, "", "Backup uid.")],
),
endpoint(
id="admin-backups-delete",
method="POST",
path="/admin/backups/{uid}/delete",
title="Delete backup",
summary="Permanently delete a backup archive and reclaim its disk space.",
auth="admin",
destructive=True,
params=[field("uid", "path", "string", True, "", "Backup uid.")],
sample_response={"ok": True, "redirect": "/admin/backups"},
),
endpoint(
id="admin-backups-schedule-create",
method="POST",
path="/admin/backups/schedules/create",
title="Create backup schedule",
summary=(
"Create a recurring backup. kind=interval uses every_seconds; kind=cron uses a "
"5-field cron expression. keep_last rotates older backups of the schedule."
),
auth="admin",
params=[
field("name", "form", "string", True, "Nightly full", "Schedule name."),
field("target", "form", "string", True, "full", "Backup target."),
field("kind", "form", "string", True, "interval", "interval or cron."),
field("every_seconds", "form", "int", False, "86400", "Seconds between runs (kind=interval)."),
field("cron", "form", "string", False, "0 3 * * *", "Cron expression (kind=cron)."),
field("keep_last", "form", "int", False, "7", "Keep only the newest N (0 = all)."),
],
sample_response={"ok": True, "redirect": "/admin/backups"},
),
endpoint(
id="admin-backups-schedule-edit",
method="POST",
path="/admin/backups/schedules/{uid}/edit",
title="Edit backup schedule",
summary="Update a backup schedule's target, trigger, and retention.",
auth="admin",
params=[
field("uid", "path", "string", True, "", "Schedule uid."),
field("name", "form", "string", True, "Nightly full", "Schedule name."),
field("target", "form", "string", True, "full", "Backup target."),
field("kind", "form", "string", True, "interval", "interval or cron."),
field("every_seconds", "form", "int", False, "86400", "Seconds between runs (kind=interval)."),
field("cron", "form", "string", False, "0 3 * * *", "Cron expression (kind=cron)."),
field("keep_last", "form", "int", False, "7", "Keep only the newest N (0 = all)."),
],
sample_response={"ok": True, "redirect": "/admin/backups"},
),
endpoint(
id="admin-backups-schedule-toggle",
method="POST",
path="/admin/backups/schedules/{uid}/toggle",
title="Toggle backup schedule",
summary="Enable or disable a backup schedule.",
auth="admin",
params=[field("uid", "path", "string", True, "", "Schedule uid.")],
sample_response={"ok": True, "redirect": "/admin/backups"},
),
endpoint(
id="admin-backups-schedule-run",
method="POST",
path="/admin/backups/schedules/{uid}/run",
title="Run backup schedule now",
summary="Immediately enqueue a backup for a schedule without waiting for its next run.",
auth="admin",
params=[field("uid", "path", "string", True, "", "Schedule uid.")],
sample_response={"ok": True, "redirect": "/admin/backups"},
),
endpoint(
id="admin-backups-schedule-delete",
method="POST",
path="/admin/backups/schedules/{uid}/delete",
title="Delete backup schedule",
summary="Delete a backup schedule. Existing archives are kept.",
auth="admin",
destructive=True,
params=[field("uid", "path", "string", True, "", "Schedule uid.")],
sample_response={"ok": True, "redirect": "/admin/backups"},
),
],
}
-139
View File
@@ -1,139 +0,0 @@
# retoor <retoor@molodetz.nl>
from .._shared import endpoint, field
GROUP = {
"slug": "auth",
"title": "Authentication",
"intro": """
# Authentication
Create an account, sign in, recover your password, and log out. These are the only endpoints
that set or clear the `session` cookie; every other request authenticates with the methods
described in [Authentication](/docs/authentication.html). The shared rules (content
negotiation, pagination, status codes) live in [Conventions and Errors](/docs/conventions.html).
## Page vs. action
The GET endpoints render HTML sign-up, login, and password-reset forms; they also return the
page data as JSON when requested with `Accept: application/json` (including `page` to
distinguish the form type).
The POST endpoints are **actions**: they accept form fields, set or clear the `session` cookie,
and return a `302` redirect (or the JSON envelope for JSON callers).
**Sign-up requires a unique `username` and `email`** plus a `confirm_password` that matches the
password; **you log in with your `email` and password**. JSON callers receive validation errors
as a `422` with the shape `{ "fields": {...}, "messages": [...] }`.
""",
"endpoints": [
endpoint(
id="auth-signup",
method="GET",
path="/auth/signup",
title="Sign up page",
summary="Render the registration form. Returns an HTML page.",
auth="public",
interactive=True,
),
endpoint(
id="auth-signup-post",
method="POST",
path="/auth/signup",
title="Sign up",
summary="Create a new account. Sets the session cookie on success.",
auth="public",
encoding="form",
destructive=False,
params=[
field("username", "form", "string", True, "alice", "Username, 3-32 characters (letters, numbers, hyphens, underscores)."),
field("email", "form", "string", True, "alice@example.com", "Email address; must be unique and contain an @."),
field("password", "form", "string", True, "mysecret", "Password, 6+ characters."),
field("confirm_password", "form", "string", True, "mysecret", "Must match password."),
],
),
endpoint(
id="auth-login",
method="GET",
path="/auth/login",
title="Log in page",
summary="Render the login form. Returns an HTML page.",
auth="public",
interactive=True,
params=[
field("next", "query", "string", False, "", "Redirect target after login."),
],
),
endpoint(
id="auth-login-post",
method="POST",
path="/auth/login",
title="Log in",
summary="Authenticate with email and password. Sets the session cookie.",
auth="public",
encoding="form",
params=[
field("email", "form", "string", True, "alice@example.com", "Your registered email."),
field("password", "form", "string", True, "mysecret", "Your password."),
field("remember_me", "form", "string", False, "on", "Send 'on' to extend the session to the remember-me lifetime."),
field("next", "form", "string", False, "", "Redirect target after login."),
],
),
endpoint(
id="auth-forgot-password",
method="GET",
path="/auth/forgot-password",
title="Forgot password page",
summary="Render the forgot-password form. Returns an HTML page.",
auth="public",
interactive=True,
),
endpoint(
id="auth-forgot-password-post",
method="POST",
path="/auth/forgot-password",
title="Request password reset",
summary="Send a password-reset email with a one-time link.",
auth="public",
encoding="form",
params=[
field("email", "form", "string", True, "alice@example.com", "Your registered email."),
],
),
endpoint(
id="auth-reset-password",
method="GET",
path="/auth/reset-password/{token}",
title="Reset password page",
summary="Render the password-reset form (only valid with a one-time token). Returns an HTML page.",
auth="public",
interactive=True,
params=[
field("token", "path", "string", True, "RESET_TOKEN", "The one-time reset token from the email."),
],
),
endpoint(
id="auth-reset-password-post",
method="POST",
path="/auth/reset-password/{token}",
title="Reset password",
summary="Set a new password using a one-time reset token.",
auth="public",
encoding="form",
params=[
field("token", "path", "string", True, "RESET_TOKEN", "The one-time reset token from the email."),
field("password", "form", "string", True, "newpass", "New password, 6+ characters."),
field("confirm_password", "form", "string", True, "newpass", "Must match password."),
],
),
endpoint(
id="auth-logout",
method="GET",
path="/auth/logout",
title="Log out",
summary="Clear the session cookie and redirect to the landing page.",
auth="public",
interactive=False,
),
],
}
-587
View File
@@ -1,587 +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.
Containers are additionally **isolated per user**. The primary administrator (the first Admin account)
sees and manages every instance, including those attached to private projects. Any other administrator
sees instances on public projects plus their own; instances attached to another user's private project
are invisible. Managing an instance (edit, lifecycle, exec, terminal, sync, delete, schedules) is
restricted to the instance owner (its creator or the owner of its project) and the primary
administrator; a non-owner administrator receives `403` on mutations and a view-only detail page.
""",
"endpoints": [
endpoint(
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 when the project is another user's private project (the primary administrator always has access).",
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, scoped per viewer: the primary administrator sees every instance; other administrators see instances on public projects plus their own. Rows the viewer cannot manage are view-only, and mutations on them return 403.",
auth="admin",
interactive=True,
),
endpoint(
id="containers-admin-data",
method="GET",
path="/admin/containers/data",
title="Admin containers list data",
summary="JSON of the viewer-visible instances (decorated with project title/slug and a per-row can_manage flag) for polling.",
auth="admin",
sample_response={
"instances": [
{
"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 (DEVPLACE_API_KEY, DEVPLACE_USER_UID). Does NOT change the container OS user (always pravda, uid 1000)."),
field("boot_language", "form", "enum", False, "none", "Boot source language.", ["none", "python", "bash"]),
field("boot_script", "form", "textarea", False, "print('hi')", "Boot source code run on launch (takes precedence over boot_command)."),
field(
"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 (DEVPLACE_API_KEY, DEVPLACE_USER_UID). Does NOT change the container OS user (always pravda, uid 1000)."),
field("boot_language", "form", "enum", False, "none", "Boot source language.", ["none", "python", "bash"]),
field("boot_script", "form", "textarea", False, "print('hi')", "Boot source code run on launch (takes precedence over boot_command)."),
field("boot_command", "form", "string", False, "python app.py", "Fallback boot command when no boot_script is set."),
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"}]},
),
],
}
@@ -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"}]},
),
],
}
-946
View File
@@ -1,946 +0,0 @@
# retoor <retoor@molodetz.nl>
from .._shared import COMMENT_TARGETS, GIST_LANGUAGES, PROJECT_TYPES, endpoint, field
from devplacepy.constants import TOPICS
GROUP = {
"slug": "content",
"title": "Posts, Comments, Projects, Gists & News",
"intro": """
# Posts, Comments, Projects, Gists & News
The core content types. Read endpoints render HTML pages; write endpoints accept form fields
and redirect to the new or updated resource. List fields such as `attachment_uids` are
repeated form keys - upload files first via [Uploads](/docs/uploads.html) and pass the returned
uids here. Engage with this content through [Votes, Reactions, Bookmarks & Polls](/docs/social-actions.html).
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.
""",
"endpoints": [
endpoint(
id="home",
method="GET",
path="/",
title="Home",
summary=(
"The home page. Guests see the marketing splash; authenticated users see a "
"personalized home (welcome, feed shortcut, latest posts, news). It no longer "
"redirects to /feed. The latest-posts section interleaves authors so no two consecutive posts share an author."
),
auth="public",
interactive=True,
),
endpoint(
id="feed-list",
method="GET",
path="/feed",
title="Browse the feed",
summary="The main post feed. Returns an HTML page. Each page interleaves authors so no two consecutive posts share an author.",
auth="public",
interactive=True,
params=[
field(
"tab",
"query",
"enum",
False,
"all",
"Feed selector.",
["all", "trending", "following"],
),
field(
"topic", "query", "enum", False, "", "Filter by topic.", TOPICS
),
field(
"search",
"query",
"string",
False,
"",
"Search post title, content, and author username.",
),
field("before", "query", "string", False, "", "Pagination cursor."),
],
),
endpoint(
id="posts-create",
method="POST",
path="/posts/create",
title="Create a post",
summary="Publish a post, optionally with a poll. Redirects to the new post.",
auth="user",
encoding="form",
destructive=True,
params=[
field(
"content",
"form",
"textarea",
True,
"Posted from a script.",
"Body, 10-125000 characters.",
),
field(
"title",
"form",
"string",
False,
"Hello",
"Optional title, up to 500 characters.",
),
field(
"topic", "form", "enum", False, "random", "Post topic.", TOPICS
),
field(
"project_uid",
"form",
"string",
False,
"",
"Attach to a project.",
),
field(
"poll_question",
"form",
"string",
False,
"",
"Optional poll question.",
),
field(
"poll_options",
"form",
"string",
False,
"",
"Repeat the field for each poll option, or send a single newline- or comma-separated string (2-6 options).",
),
],
notes=["Returns a `302` redirect to `/posts/{slug}` on success."],
),
endpoint(
id="posts-detail",
method="GET",
path="/posts/{post_slug}",
title="View a post",
summary="Render a post with comments. Returns an HTML page.",
auth="public",
interactive=True,
params=[
field(
"post_slug",
"path",
"string",
True,
"POST_SLUG",
"Slug or UID of the post.",
)
],
),
endpoint(
id="posts-edit",
method="POST",
path="/posts/edit/{post_slug}",
title="Edit a post",
summary="Update a post you own, optionally adding a poll if it has none.",
auth="user",
encoding="form",
destructive=True,
params=[
field(
"post_slug",
"path",
"string",
True,
"POST_SLUG",
"Slug or UID of the post.",
),
field(
"content",
"form",
"textarea",
True,
"Updated body.",
"Body, 10-125000 characters.",
),
field(
"title",
"form",
"string",
False,
"Updated title",
"Optional title.",
),
field(
"topic", "form", "enum", False, "random", "Post topic.", TOPICS
),
field(
"poll_question",
"form",
"string",
False,
"",
"Optional poll question. Adds a poll only when the post has none.",
),
field(
"poll_options",
"form",
"string",
False,
"",
"Repeat the field for each poll option, or send a single newline- or comma-separated string (2-6 options).",
),
],
),
endpoint(
id="posts-delete",
method="POST",
path="/posts/delete/{post_slug}",
title="Delete a post",
summary="Delete a post you own; administrators may delete any user's post. Soft-deleted (hidden everywhere but restorable from admin trash) and cascades its comments and votes.",
auth="user",
destructive=True,
params=[
field(
"post_slug",
"path",
"string",
True,
"POST_SLUG",
"Slug or UID of the post.",
)
],
),
endpoint(
id="comments-create",
method="POST",
path="/comments/create",
title="Create a comment",
summary="Comment on any commentable target. Supports nested replies.",
auth="user",
encoding="form",
destructive=True,
params=[
field(
"content",
"form",
"textarea",
True,
"Nice work.",
"Body, 3-1000 characters.",
),
field(
"target_uid",
"form",
"string",
False,
"POST_UID",
"UID of the target (or use post_uid).",
),
field(
"post_uid",
"form",
"string",
False,
"",
"Convenience alias for a post target.",
),
field(
"target_type",
"form",
"enum",
False,
"post",
"Type of the target.",
COMMENT_TARGETS,
),
field(
"parent_uid",
"form",
"string",
False,
"",
"Parent comment UID for a reply.",
),
],
notes=["Either `target_uid` or `post_uid` is required."],
),
endpoint(
id="comments-edit",
method="POST",
path="/comments/edit/{comment_uid}",
title="Edit a comment",
summary="Edit the body of a comment you own. Returns the updated comment.",
auth="user",
encoding="form",
params=[
field(
"comment_uid",
"path",
"string",
True,
"COMMENT_UID",
"UID of the comment.",
),
field(
"content",
"form",
"textarea",
True,
"Edited body.",
"New body, 3-1000 characters.",
),
],
sample_response={
"uid": "COMMENT_UID",
"content": "Edited body.",
"url": "/posts/POST_SLUG#comment-COMMENT_UID",
"updated_at": "2026-06-15T12:00:00+00:00",
},
),
endpoint(
id="comments-delete",
method="POST",
path="/comments/delete/{comment_uid}",
title="Delete a comment",
summary="Delete a comment you own; administrators may delete any user's comment. Soft-deleted (hidden everywhere but restorable from admin trash).",
auth="user",
destructive=True,
params=[
field(
"comment_uid",
"path",
"string",
True,
"COMMENT_UID",
"UID of the comment.",
)
],
),
endpoint(
id="projects-list",
method="GET",
path="/projects",
title="Browse projects",
summary="List projects. Returns an HTML page.",
auth="public",
interactive=True,
params=[
field(
"tab",
"query",
"enum",
False,
"recent",
"Sort selector.",
["recent", "popular", "released"],
),
field(
"search",
"query",
"string",
False,
"",
"Search project title, description, and author username.",
),
field(
"project_type",
"query",
"enum",
False,
"",
"Filter by type.",
PROJECT_TYPES,
),
field("before", "query", "string", False, "", "Pagination cursor."),
],
),
endpoint(
id="projects-detail",
method="GET",
path="/projects/{project_slug}",
title="View a project",
summary="Render a project with comments. Returns an HTML page.",
auth="public",
interactive=True,
params=[
field(
"project_slug",
"path",
"string",
True,
"PROJECT_SLUG",
"Slug or UID of the project.",
)
],
),
endpoint(
id="projects-create",
method="POST",
path="/projects/create",
title="Create a project",
summary="Publish a project. Redirects to the new project.",
auth="user",
encoding="form",
destructive=True,
params=[
field(
"title",
"form",
"string",
True,
"My Project",
"Title, 1-200 characters.",
),
field(
"description",
"form",
"textarea",
True,
"What it does.",
"Description, 1-5000 characters.",
),
field(
"project_type",
"form",
"enum",
False,
"software",
"Project type.",
PROJECT_TYPES,
),
field(
"status",
"form",
"string",
False,
"In Development",
"Free-form status label.",
),
field(
"platforms",
"form",
"string",
False,
"Linux, Web",
"Comma-separated platforms.",
),
field(
"release_date",
"form",
"string",
False,
"31/12/2026",
"Optional release date in DD/MM/YYYY format.",
),
field(
"demo_date",
"form",
"string",
False,
"31/12/2026",
"Optional demo date in DD/MM/YYYY format.",
),
],
),
endpoint(
id="projects-edit",
method="POST",
path="/projects/edit/{project_slug}",
title="Edit a project",
summary="Update an owned project. Redirects to the project.",
auth="user",
encoding="form",
destructive=True,
params=[
field(
"project_slug",
"path",
"string",
True,
"my-project-1a2b3c4d",
"Project slug or uid.",
),
field(
"title",
"form",
"string",
True,
"My Project",
"Title, 1-200 characters.",
),
field(
"description",
"form",
"textarea",
True,
"What it does.",
"Description, 1-5000 characters.",
),
field(
"project_type",
"form",
"enum",
False,
"software",
"Project type.",
PROJECT_TYPES,
),
field(
"status",
"form",
"string",
False,
"In Development",
"Free-form status label.",
),
field(
"platforms",
"form",
"string",
False,
"Linux, Web",
"Comma-separated platforms.",
),
field(
"release_date",
"form",
"string",
False,
"31/12/2026",
"Optional release date in DD/MM/YYYY format.",
),
field(
"demo_date",
"form",
"string",
False,
"31/12/2026",
"Optional demo date in DD/MM/YYYY format.",
),
],
),
endpoint(
id="projects-delete",
method="POST",
path="/projects/delete/{project_slug}",
title="Delete a project",
summary="Delete a project you own; administrators may delete any user's project. Soft-deleted with all of its files (hidden everywhere but restorable from admin trash).",
auth="user",
destructive=True,
params=[
field(
"project_slug",
"path",
"string",
True,
"PROJECT_SLUG",
"Slug or UID of the project.",
)
],
),
endpoint(
id="projects-private",
method="POST",
path="/projects/{project_slug}/private",
title="Set project visibility",
summary="Mark a project you own private or public. Send value=1 for private, value=0 for public. A project you hide as a member stays visible to administrators; a project you hide as an administrator is visible only to you, not to other administrators (this also hides its files and any attached containers).",
auth="user",
encoding="form",
params=[
field(
"project_slug",
"path",
"string",
True,
"PROJECT_SLUG",
"Slug or UID of the project.",
),
field(
"value",
"form",
"boolean",
True,
"1",
"1 to make the project private, 0 to make it public.",
),
],
),
endpoint(
id="projects-readonly",
method="POST",
path="/projects/{project_slug}/readonly",
title="Set project read-only",
summary="Mark a project you own read-only so all of its files become immutable (no writes, edits, moves, deletes, or uploads succeed), or writable again. Send value=1 for read-only, value=0 for writable.",
auth="user",
encoding="form",
destructive=True,
params=[
field(
"project_slug",
"path",
"string",
True,
"PROJECT_SLUG",
"Slug or UID of the project.",
),
field(
"value",
"form",
"boolean",
True,
"1",
"1 to make the project read-only, 0 to make it writable.",
),
],
),
endpoint(
id="projects-zip",
method="POST",
path="/projects/{project_slug}/zip",
title="Queue a project zip",
summary="Start a background job that archives the whole project. Returns the job uid and status URL to poll.",
auth="public",
params=[
field(
"project_slug",
"path",
"string",
True,
"PROJECT_SLUG",
"Slug or UID of the project.",
)
],
sample_response={
"uid": "ZIP_JOB_UID",
"status_url": "/zips/ZIP_JOB_UID",
},
),
endpoint(
id="zips-status",
method="GET",
path="/zips/{uid}",
title="Zip job status",
summary="Poll a zip job. While pending or running download_url is null; once done it points at the archive.",
auth="public",
params=[
field(
"uid",
"path",
"string",
True,
"ZIP_JOB_UID",
"Zip job uid returned when the job was queued.",
)
],
sample_response={
"uid": "ZIP_JOB_UID",
"kind": "zip",
"status": "done",
"preferred_name": "my-project",
"download_url": "/zips/ZIP_JOB_UID/download",
"error": None,
"bytes_in": 20480,
"bytes_out": 8192,
"item_count": 12,
"file_count": 10,
"dir_count": 2,
"created_at": "2026-06-09T10:00:00+00:00",
"completed_at": "2026-06-09T10:00:03+00:00",
},
),
endpoint(
id="zips-download",
method="GET",
path="/zips/{uid}/download",
title="Download a zip archive",
summary="Stream the finished archive as application/zip. Each access extends the retention window.",
auth="public",
interactive=True,
params=[
field(
"uid",
"path",
"string",
True,
"ZIP_JOB_UID",
"Zip job uid of a finished job.",
)
],
),
endpoint(
id="projects-fork",
method="POST",
path="/projects/{project_slug}/fork",
title="Queue a project fork",
summary="Start a background job that copies the whole project into a new project owned by you. Returns the job uid and status URL to poll.",
auth="user",
params=[
field(
"project_slug",
"path",
"string",
True,
"PROJECT_SLUG",
"Slug or UID of the project to fork.",
),
field(
"title",
"form",
"string",
True,
"My Fork",
"Title for the new forked project.",
),
],
sample_response={
"uid": "FORK_JOB_UID",
"status_url": "/forks/FORK_JOB_UID",
},
),
endpoint(
id="forks-status",
method="GET",
path="/forks/{uid}",
title="Fork job status",
summary="Poll a fork job. While pending or running project_url is null; once done it points at the new project.",
auth="public",
params=[
field(
"uid",
"path",
"string",
True,
"FORK_JOB_UID",
"Fork job uid returned when the job was queued.",
)
],
sample_response={
"uid": "FORK_JOB_UID",
"kind": "fork",
"status": "done",
"preferred_name": "My Fork",
"project_uid": "NEW_PROJECT_UID",
"project_url": "/projects/new-project-slug",
"source_project_uid": "SOURCE_PROJECT_UID",
"error": None,
"item_count": 12,
"created_at": "2026-06-09T10:00:00+00:00",
"completed_at": "2026-06-09T10:00:05+00:00",
},
),
endpoint(
id="gists-list",
method="GET",
path="/gists",
title="Browse gists",
summary="List code gists. Returns an HTML page.",
auth="public",
interactive=True,
params=[
field(
"language",
"query",
"enum",
False,
"",
"Filter by language.",
GIST_LANGUAGES,
),
field(
"user_uid",
"query",
"string",
False,
"",
"Filter by author UID.",
),
field(
"search",
"query",
"string",
False,
"",
"Search gist title, description, and author username.",
),
field("before", "query", "string", False, "", "Pagination cursor."),
],
),
endpoint(
id="gists-detail",
method="GET",
path="/gists/{gist_slug}",
title="View a gist",
summary="Render a gist with comments. Returns an HTML page.",
auth="public",
interactive=True,
params=[
field(
"gist_slug",
"path",
"string",
True,
"GIST_SLUG",
"Slug or UID of the gist.",
)
],
),
endpoint(
id="gists-create",
method="POST",
path="/gists/create",
title="Create a gist",
summary="Publish a code snippet. Redirects to the new gist.",
auth="user",
encoding="form",
destructive=True,
params=[
field(
"title",
"form",
"string",
True,
"Quick sort",
"Title, 1-200 characters.",
),
field(
"source_code",
"form",
"textarea",
True,
"print('hello')",
"Source, 1-400000 characters.",
),
field(
"language",
"form",
"enum",
False,
"python",
"Syntax language.",
GIST_LANGUAGES,
),
field(
"description",
"form",
"string",
False,
"",
"Optional description.",
),
],
),
endpoint(
id="gists-edit",
method="POST",
path="/gists/edit/{gist_slug}",
title="Edit a gist",
summary="Update a gist you own.",
auth="user",
encoding="form",
destructive=True,
params=[
field(
"gist_slug",
"path",
"string",
True,
"GIST_SLUG",
"Slug or UID of the gist.",
),
field(
"title",
"form",
"string",
True,
"Quick sort",
"Title, 1-200 characters.",
),
field(
"source_code",
"form",
"textarea",
True,
"print('hi')",
"Source, 1-400000 characters.",
),
field(
"language",
"form",
"enum",
False,
"python",
"Syntax language.",
GIST_LANGUAGES,
),
field(
"description",
"form",
"string",
False,
"",
"Optional description.",
),
],
),
endpoint(
id="gists-delete",
method="POST",
path="/gists/delete/{gist_slug}",
title="Delete a gist",
summary="Delete a gist you own; administrators may delete any user's gist. Soft-deleted (hidden everywhere but restorable from admin trash).",
auth="user",
destructive=True,
params=[
field(
"gist_slug",
"path",
"string",
True,
"GIST_SLUG",
"Slug or UID of the gist.",
)
],
),
endpoint(
id="news-list",
method="GET",
path="/news",
title="Browse news",
summary="Curated developer news. Returns an HTML page.",
auth="public",
interactive=True,
params=[
field(
"before",
"query",
"string",
False,
"",
"Pagination cursor (synced_at).",
)
],
),
endpoint(
id="news-detail",
method="GET",
path="/news/{news_slug}",
title="View a news article",
summary="Render a news article with comments. Returns an HTML page.",
auth="public",
interactive=True,
params=[
field(
"news_slug",
"path",
"string",
True,
"NEWS_SLUG",
"Slug or UID of the article.",
)
],
),
],
}
-152
View File
@@ -1,152 +0,0 @@
# retoor <retoor@molodetz.nl>
GROUP = {
"slug": "conventions",
"title": "Conventions & Errors",
"intro": """
# Conventions & Errors
Shared rules that apply to every endpoint in this reference.
## Base URL
Every example uses your current host:
```
{{ base }}
```
## Authentication
Each endpoint is tagged **public**, **user**, or **admin**. Authenticate user and admin
endpoints with any of the four methods in [Authentication](/docs/authentication.html): the
`session` cookie, an `X-API-KEY` header, a `Bearer` token, or HTTP Basic credentials. The
interactive panels on this site pre-fill your own API key, so you can run user-level calls
immediately.
## Request bodies
POST endpoints accept `application/x-www-form-urlencoded` form fields (the same fields the
website submits). File uploads use `multipart/form-data`. A small number of endpoints accept
a JSON body; those are noted explicitly.
## HTML or JSON (content negotiation)
**Every** endpoint that renders a page or returns a redirect can also answer in JSON - the
website keeps working exactly as before, and automation gets structured data from the same
URLs. A request is served JSON when it sends either of:
- `Accept: application/json`
- `Content-Type: application/json`
A normal browser navigation (`Accept: text/html`) always receives HTML, so nothing existing
changes. Responses are defined by Pydantic models, so each page returns the same data the
template renders.
**Page reads** (GET) return the page payload as a JSON object (lists include a `next_cursor`
for pagination; detail pages embed author, comments, reactions, poll, and attachments).
**Actions** (the form POSTs: create / edit / delete / follow / send / mark-read …) return a
uniform envelope instead of a `302` redirect:
```json
{ "ok": true, "redirect": "/posts/abc-my-post", "data": { "uid": "…", "slug": "…", "url": "…" } }
```
`data` carries the created/affected resource where applicable, or `null`. Cookies (e.g. the
session set on login/signup) are still set on JSON responses.
**Errors** are JSON too when JSON is requested:
```json
{ "error": { "status": 404, "message": "Not found" } }
```
Validation failures return `422` with `{ "error": "validation", "fields": { "field": ["msg"] } }`.
Unauthenticated JSON requests to a protected endpoint return `401` (browsers are redirected to
the login page instead); non-admins calling an admin endpoint get `403`.
## Trying it here
Every endpoint below has a live panel. Pick the response format (**JSON** by default, or
**HTML** where the endpoint negotiates) and the panel sets the matching `Accept` header on the
request and the generated cURL/JavaScript/Python snippets. The **Expected** tab always shows the
modeled response shape; the **Live response** tab shows the real result after you press
**Send request**.
## AJAX responses (legacy shape)
The [Votes, Reactions, Bookmarks & Polls](/docs/social-actions.html) endpoints predate the
envelope and keep their original flat JSON shapes (e.g. `{ "saved": true }`). They return JSON
when the request carries an `X-Requested-With: fetch` header (a subset of the rule above);
without it they `302` redirect, mirroring the browser flow. The interactive panels send the
header for you.
## Pagination
Most list endpoints page with an opaque cursor. Pass the `before` query parameter set to the
`created_at` (or `synced_at`) value of the last item you received to fetch the next page; the
JSON payload returns a `next_cursor` to use as the next `before`. This covers the feed, the
post/project/gist/news lists, notifications, and saved bookmarks.
The follower and following lists are the exception: `GET /profile/{username}/followers` and
`GET /profile/{username}/following` use classic page-based pagination via the `page` query
parameter (25 per page), not a cursor.
## Identifiers
Posts, projects, gists, and news articles accept either their slug or their bare UUID in the
path. Slugs embed the first eight characters of the UUID.
## Dates
All dates rendered to users are `DD/MM/YYYY`. Timestamps in stored records are ISO-8601 UTC.
## Status codes
| Code | Meaning |
|------|---------|
| `200` | Success (JSON or HTML) |
| `201` | Resource created (uploads) |
| `302` | Redirect (browser-style success for form posts) |
| `400` | Invalid request body or parameters |
| `401` | Credentials supplied but invalid |
| `403` | Authenticated but not allowed |
| `404` | Resource not found |
| `413` | Upload exceeds the configured size limit (see [Uploads](/docs/uploads.html)) |
| `415` | Upload file type not allowed (see [Uploads](/docs/uploads.html)) |
| `422` | Form/body validation failed (JSON clients) |
| `429` | Rate limit exceeded (see Rate limiting) |
| `503` | Maintenance mode |
## Rate limiting
Mutating requests (`POST`/`PUT`/`DELETE`/`PATCH`) are rate limited per client IP over a
rolling window; reads are not limited. The limit and window are configurable by an
administrator (defaults: 60 requests per 60 seconds). When you exceed the limit you receive a
`429` whose `Retry-After` header gives the number of seconds to wait before retrying. The
OpenAI gateway (`/openai/...`) is exempt.
## Troubleshooting
**I get HTML back instead of JSON.** Send `Accept: application/json` (or
`Content-Type: application/json` on a body). A request is only served JSON when it asks for it
and does not also accept `text/html`; a normal browser navigation always gets HTML.
`X-Requested-With: fetch` is **not** a general JSON switch - it only applies to the legacy
engagement actions (votes, reactions, bookmarks, polls).
**An action returns `302` instead of the JSON envelope.** Same cause: the request did not ask
for JSON. Add the `Accept: application/json` header and the action returns
`{ "ok": true, "redirect": "...", "data": {...} }` instead of redirecting.
**A protected endpoint redirects me to the login page.** Browser-style (HTML) requests to a
`user`/`admin` endpoint without valid credentials are redirected to login; the same request
with `Accept: application/json` returns `401` instead. Non-admins calling an `admin` endpoint
get `403` (JSON) or a redirect to the feed (HTML).
**An upload is rejected with `413` or `415`.** `413` means the file exceeds the configured
size limit; `415` means the file type is not in the allowed list. Both limits are set by an
administrator.
""",
"endpoints": [],
}
-195
View File
@@ -1,195 +0,0 @@
# retoor <retoor@molodetz.nl>
from .._shared import endpoint, field
GROUP = {
"slug": "game",
"title": "Code Farm",
"intro": """
# Code Farm
The Code Farm is a cooperative idle game. Each member owns a farm of plots, plants software
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.
""",
"endpoints": [
endpoint(
id="game-home",
method="GET",
path="/game",
title="Code Farm page",
summary="The player's own farm: HUD, plot grid, shop, and leaderboard.",
auth="user",
negotiation=True,
sample_response={"ok": True, "farm": {"coins": 50, "level": 1, "plots": []}},
),
endpoint(
id="game-state",
method="GET",
path="/game/state",
title="Farm state",
summary="The signed-in player's full farm state as JSON.",
auth="user",
sample_response={
"ok": True,
"farm": {
"coins": 50,
"level": 1,
"ci_tier": 1,
"plot_count": 4,
"plots": [{"slot": 0, "state": "empty"}],
"crops": [{"key": "python", "name": "Python Script", "cost": 15}],
},
},
),
endpoint(
id="game-leaderboard",
method="GET",
path="/game/leaderboard",
title="Farm leaderboard",
summary="Top farmers ranked by level, XP, and harvests.",
auth="public",
sample_response={"entries": [{"rank": 1, "username": "alice", "level": 4}]},
),
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.",
auth="public",
negotiation=True,
params=[field("username", "path", "string", True, "alice", "Farm owner's username.")],
sample_response={"farm": {"owner_username": "alice", "is_owner": False, "plots": []}},
),
endpoint(
id="game-plant",
method="POST",
path="/game/plant",
title="Plant a crop",
summary="Plant a crop in an empty plot. Costs the crop's coin price.",
auth="user",
params=[
field("slot", "form", "integer", True, "0", "Plot slot index."),
field("crop", "form", "string", True, "python", "Crop key."),
],
sample_response={"ok": True, "farm": {"coins": 35}},
),
endpoint(
id="game-harvest",
method="POST",
path="/game/harvest",
title="Harvest a build",
summary="Harvest a finished build for coins and XP.",
auth="user",
params=[field("slot", "form", "integer", True, "0", "Plot slot index.")],
sample_response={"ok": True, "farm": {"coins": 86}},
),
endpoint(
id="game-buy-plot",
method="POST",
path="/game/buy-plot",
title="Buy a plot",
summary="Unlock a new plot. Cost doubles per extra plot.",
auth="user",
sample_response={"ok": True, "farm": {"plot_count": 5}},
),
endpoint(
id="game-upgrade",
method="POST",
path="/game/upgrade",
title="Upgrade CI",
summary="Upgrade the farm CI tier for faster builds.",
auth="user",
sample_response={"ok": True, "farm": {"ci_tier": 2}},
),
endpoint(
id="game-water",
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.",
auth="user",
params=[
field("username", "path", "string", True, "alice", "Farm owner's username."),
field("slot", "form", "integer", True, "0", "Plot slot index."),
],
sample_response={"farm": {"owner_username": "alice"}},
),
endpoint(
id="game-steal",
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.",
auth="user",
params=[
field("username", "path", "string", True, "alice", "Farm owner's username."),
field("slot", "form", "integer", True, "0", "Plot slot index."),
],
sample_response={"farm": {"owner_username": "alice"}, "stole_coins": 18},
),
endpoint(
id="game-fertilize",
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.",
auth="user",
params=[field("slot", "form", "integer", True, "0", "Plot slot index.")],
sample_response={"ok": True, "farm": {"coins": 12}},
),
endpoint(
id="game-daily",
method="POST",
path="/game/daily",
title="Claim daily bonus",
summary="Claim the once-per-day coin bonus; consecutive days grow a streak.",
auth="user",
sample_response={"ok": True, "farm": {"streak": 3, "coins": 94}},
),
endpoint(
id="game-perk",
method="POST",
path="/game/perk",
title="Upgrade a perk",
summary="Upgrade a permanent perk: yield, growth, discount, or xp.",
auth="user",
params=[field("perk", "form", "string", True, "growth", "Perk key.")],
sample_response={"ok": True, "farm": {"coins": 0}},
),
endpoint(
id="game-quests-claim",
method="POST",
path="/game/quests/claim",
title="Claim a quest",
summary="Claim a completed daily quest reward by its kind.",
auth="user",
params=[field("quest", "form", "string", True, "harvest", "Quest kind.")],
sample_response={"ok": True, "farm": {"coins": 130}},
),
endpoint(
id="game-prestige",
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.",
auth="user",
destructive=True,
sample_response={"ok": True, "farm": {"prestige": 1}},
),
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.",
auth="user",
params=[field("key", "form", "string", True, "multiplier", "Legacy upgrade key.")],
sample_response={"ok": True, "farm": {"stars": 1}},
),
],
}
-279
View File
@@ -1,279 +0,0 @@
# retoor <retoor@molodetz.nl>
from .._shared import endpoint, field
GROUP = {
"slug": "gateway",
"title": "OpenAI Gateway",
"intro": """
# OpenAI Gateway
An OpenAI-compatible proxy mounted at `/openai/v1`. It forwards requests to a configured
upstream using the gateway's own credentials, so no DevPlace key is required - but an
administrator must enable the `openai` service first. Point any OpenAI-compatible client at
`{{ base }}/openai/v1`.
This gateway is the **single point of truth for AI** on the platform. Every other DevPlace
service (news, bots, Devii) calls it by default instead of an external provider, sends the
generic model name `molodetz`, and authenticates with an internal key that is auto-generated
on first boot. The real provider URLs, models, and keys (DeepSeek, OpenRouter) live only here,
so an operator switches providers or backends in one place. `DEEPSEEK_API_KEY` and
`OPENROUTER_API_KEY` are migrated into the editable settings on boot and the value in use is
shown. Because `Force model` is on by default, the upstream always receives the configured
model regardless of what a client (or `molodetz`) requests.
The gateway also performs **vision** augmentation: when a request includes an image, the gateway
describes the image with a configured vision model and rewrites it to text, so a vision-less
upstream still works. The vision model, URL, and key are configured alongside the other gateway
settings.
The gateway additionally serves **text embeddings** at `/openai/v1/embeddings`. Clients request the
generic model `molodetz~embed`, which the gateway maps to the configured embedding model (OpenRouter's
Qwen3 8B embedding model by default). Usage and cost are tracked per call exactly like chat and vision.
The gateway also serves **image generation** at `/openai/v1/images/generations`. Clients request the
generic model `molodetz-img-small`, which the gateway maps to the configured image model (OpenRouter's
Flux 1.1 Pro by default). Cost is tracked per call with a flat per-image price when the upstream
returns no native cost.
## Quick start
Copy the command below and paste it into a terminal. If you are signed in the `{{ api_key }}`
and `{{ app_reference }}` placeholders are already filled in with your own values; otherwise
replace them with the API key from your [profile](/profile) page and any application identifier.
```bash
curl -X POST "{{ base }}/openai/v1/chat/completions" \
-H "Authorization: Bearer {{ api_key }}" \
-H "X-App-Reference: {{ app_reference }}" \
-H "Content-Type: application/json" \
-d '{
"model": "molodetz",
"messages": [{"role": "user", "content": "Hello, how are you?"}]
}'
```
The response carries `X-Gateway-*` headers with token counts and dollar cost for the call.
For streaming, add `"stream": true` to the JSON body.
## Model routing and providers
On top of the single default upstream above, an administrator can register additional named
**providers** and map any number of requested **model names** onto them, so one gateway can front
many models across many backends. A model route binds a source model name (what a client sends) to a
target provider and upstream model, and carries:
- its **own pricing economy** (input, output, and cache-hit / cache-miss prices per million tokens),
used to compute that call's cost when the upstream returns no native cost;
- an optional **vision model**, which turns on the image-to-text merge for that route (so a text-only
model can answer about images);
- an optional **context window** used for the context-utilization header.
Resolution is transparent to clients: when the requested `model` matches an active route, the gateway
forwards to that route's provider and target model and meters the call against the route's economy.
When it matches no route, the request falls through to the default upstream unchanged (so `molodetz`,
`molodetz~embed`, and any existing client keep working exactly as before). Providers and routes are
managed by administrators on the **Gateway** page (`/admin/gateway`).
## Per-call cost and usage headers
Every gateway response - chat, embeddings, and passthrough, on both success and error - carries
`X-Gateway-*` response headers describing that single call, so a client can read its own token usage
and dollar cost directly from the response with no extra request:
| Header | Meaning |
|--------|---------|
| `X-Gateway-Model` | Upstream model actually used for the call |
| `X-Gateway-Backend` | Backend that served it: `chat`, `embed`, `image`, or passthrough |
| `X-Gateway-Prompt-Tokens` | Input (prompt) tokens |
| `X-Gateway-Completion-Tokens` | Output (completion) tokens |
| `X-Gateway-Total-Tokens` | Total tokens (prompt + completion) |
| `X-Gateway-Cache-Hit-Tokens` | Prompt tokens served from the upstream prompt cache |
| `X-Gateway-Cache-Miss-Tokens` | Prompt tokens not served from cache |
| `X-Gateway-Reasoning-Tokens` | Reasoning tokens, when the model reports them |
| `X-Gateway-Cost-USD` | Total cost of the call in US dollars |
| `X-Gateway-Input-Cost-USD` | Input portion of the cost in US dollars |
| `X-Gateway-Output-Cost-USD` | Output portion of the cost in US dollars |
| `X-Gateway-Cost-Native` | `1` if the dollar cost is the upstream's own reported cost, `0` if computed from the configured per-million pricing |
| `X-Gateway-Tokens-Per-Second` | Output tokens per second for the call |
| `X-Gateway-Upstream-Latency-Ms` | Upstream round-trip latency in milliseconds |
| `X-Gateway-Total-Latency-Ms` | Full end-to-end gateway time for the call in milliseconds |
| `X-Gateway-Gateway-Overhead-Ms` | Gateway processing time minus the upstream and queue wait, in milliseconds |
| `X-Gateway-Queue-Wait-Ms` | Time spent waiting on the concurrency semaphore before dispatch, in milliseconds |
| `X-Gateway-Connect-Ms` | Upstream connection establishment time in milliseconds |
| `X-Gateway-Context-Window` | The model's context window in tokens, when known |
| `X-Gateway-Context-Utilization` | Total tokens as a fraction of the context window, when known |
Dollar costs use the upstream's native `cost` field when it returns one
(`X-Gateway-Cost-Native: 1`); otherwise they are computed from the per-million prices of the matched
model route, falling back to the prices configured on the `openai` service when no route matches. The
denied paths that make no upstream call (embeddings or image generation disabled) return no usage headers.
## Request header `X-App-Reference`
Clients **SHOULD** send an `X-App-Reference` header to identify themselves for cost attribution.
The value is a free-form slug (max 30 characters, letters, digits, `_`, `.`, `-`). When missing or
invalid, the gateway defaults to `default`. The value is recorded in every usage ledger row and can
be queried alongside owner-kind and owner-id to attribute spending per application.
```
X-App-Reference: devplace-devii-v-1-0-0
```
Administrators enable and configure this gateway under [Background Services](/docs/services.html)
(the `openai` service).
The gateway is exempt from rate limiting, but every other endpoint follows the shared
[Conventions & Errors](/docs/conventions.html); see [Authentication](/docs/authentication.html)
for signing DevPlace's own requests.
""",
"endpoints": [
endpoint(
id="gateway-chat",
method="POST",
path="/openai/v1/chat/completions",
title="Chat completions",
summary="OpenAI-compatible chat completion. Supports streaming.",
auth="public",
encoding="json",
params=[
field(
"model",
"json",
"string",
False,
"gpt-4o-mini",
"Model id. When it matches a configured model route the gateway forwards to that route's provider and upstream model; otherwise it falls back to the configured default upstream model.",
),
field(
"messages",
"json",
"string",
True,
'[{"role":"user","content":"Hello"}]',
"Chat messages array.",
),
field(
"stream",
"json",
"string",
False,
"false",
"Set true for a streamed SSE response.",
),
],
notes=[
"Returns `503` when the gateway service is not running.",
"Every response carries the `X-Gateway-*` token and dollar-cost headers (see Per-call cost and usage headers above), including the streamed SSE response.",
"If `model` matches a configured model route it is forwarded to that route's provider, upstream model, and per-model pricing (with an optional vision model); otherwise it falls through to the default upstream (see Model routing and providers above).",
],
),
endpoint(
id="gateway-embeddings",
method="POST",
path="/openai/v1/embeddings",
title="Embeddings",
summary="OpenAI-compatible text embeddings. Request model molodetz~embed.",
auth="public",
encoding="json",
params=[
field(
"model",
"json",
"string",
False,
"molodetz~embed",
"Embedding model id; the gateway maps molodetz~embed to the configured model, or to a matching embed model route's provider and target model.",
),
field(
"input",
"json",
"string",
True,
'"some text to embed"',
"String or array of strings to embed.",
),
field(
"dimensions",
"json",
"string",
False,
"4096",
"Optional output vector size (Matryoshka, 32-4096).",
),
],
notes=[
"Returns `503` when the gateway service is not running or embeddings are disabled.",
"Every response carries the `X-Gateway-*` token and dollar-cost headers (see Per-call cost and usage headers above).",
"If `model` matches a configured embed model route it is forwarded to that route's provider and upstream model; otherwise it falls back to the configured default embedding model.",
],
),
endpoint(
id="gateway-images",
method="POST",
path="/openai/v1/images/generations",
title="Image generation",
summary="OpenAI-compatible image generation. Request model molodetz-img-small.",
auth="public",
encoding="json",
params=[
field(
"model",
"json",
"string",
False,
"molodetz-img-small",
"Image model id; the gateway maps molodetz-img-small to the configured model, or to a matching image model route's provider and target model.",
),
field(
"prompt",
"json",
"string",
True,
'"a decorative developer award emblem"',
"Text prompt describing the image to generate.",
),
field(
"size",
"json",
"string",
False,
"512x512",
"Output dimensions (provider-dependent).",
),
field(
"response_format",
"json",
"string",
False,
"b64_json",
"Return format: url or b64_json.",
),
],
notes=[
"Returns `503` when the gateway service is not running or image generation is disabled.",
"Every response carries the `X-Gateway-*` token and dollar-cost headers (see Per-call cost and usage headers above).",
"If `model` matches a configured image model route it is forwarded to that route's provider and upstream model; otherwise it falls back to the configured default image model.",
],
),
endpoint(
id="gateway-passthrough",
method="POST",
path="/openai/v1/{path}",
title="Passthrough",
summary="Any other /v1 path is forwarded to the upstream as-is.",
auth="public",
interactive=False,
params=[
field(
"path",
"path",
"string",
True,
"models",
"Upstream API path after /v1/.",
)
],
),
],
}
-298
View File
@@ -1,298 +0,0 @@
# retoor <retoor@molodetz.nl>
from .._shared import endpoint, field
GROUP = {
"slug": "issues",
"title": "Issue Reports",
"intro": """
# Issue Reports
The issue tracker is a full integration with a Gitea repository. The listing and detail views read
issues straight from Gitea with their live status, and a report you file is first rewritten by the
internal AI service into a consistent ticket, then posted to Gitea as an issue. The original
reporter is notified when a developer replies or the status changes, and a comment posted here is
pushed to Gitea and attributed to your account.
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.
""",
"endpoints": [
endpoint(
id="issues-list",
method="GET",
path="/issues",
title="List issue tickets",
summary="Render the issue board from Gitea, paginated and filterable by state.",
auth="public",
interactive=True,
params=[
field(
"state",
"query",
"string",
False,
"open",
"Filter: open (default), closed, or all.",
),
field("page", "query", "integer", False, "1", "1-based page."),
],
),
endpoint(
id="issues-create",
method="POST",
path="/issues/create",
title="Report an issue",
summary="Enqueue an issue report. It is enhanced by AI and filed on the tracker.",
auth="user",
encoding="form",
ajax=True,
destructive=True,
params=[
field(
"title",
"form",
"string",
True,
"Login button misaligned",
"Title, 1-200 characters.",
),
field(
"description",
"form",
"textarea",
True,
"Steps to reproduce...",
"Description, 1-5000 characters.",
),
],
notes=["Returns a job uid and status_url. Poll the status_url until status is done to get the issue number."],
sample_response={
"uid": "JOB_UID",
"status_url": "/issues/jobs/JOB_UID",
},
),
endpoint(
id="issues-job",
method="GET",
path="/issues/jobs/{uid}",
title="Issue filing job status",
summary="Poll the filing job; the result carries the new issue number and url.",
auth="public",
ajax=True,
params=[field("uid", "path", "string", True, "JOB_UID", "Job uid.")],
sample_response={
"uid": "JOB_UID",
"kind": "issue_create",
"status": "done",
"number": 42,
"issue_url": "/issues/42",
"enhanced": True,
"error": None,
"created_at": "2026-06-12T09:00:00+00:00",
"completed_at": "2026-06-12T09:00:03+00:00",
},
),
endpoint(
id="issues-planning-queue",
method="POST",
path="/issues/planning",
title="Generate a tickets planning report",
summary="Enqueue a phased markdown implementation document for open tickets, with each ticket's full description reproduced verbatim (inline plus a Source Tickets appendix) alongside implementation steps and acceptance criteria. Admin only.",
auth="admin",
encoding="form",
ajax=True,
params=[field("numbers", "form", "string", False, "1,4,7", "Comma-separated issue numbers to include. Omit to plan every open ticket.")],
notes=["Returns a job uid and status_url. Poll the status_url until status is done to read the markdown and download it.", "Provide 'numbers' to plan only a chosen subset of open tickets; omitting it plans all open tickets."],
sample_response={
"uid": "PLANNING_JOB_UID",
"status_url": "/issues/planning/PLANNING_JOB_UID",
},
),
endpoint(
id="issues-planning-status",
method="GET",
path="/issues/planning/{uid}",
title="Planning report job status",
summary="Poll the planning job; the result carries the rendered markdown and the download URL. Admin only.",
auth="admin",
ajax=True,
params=[field("uid", "path", "string", True, "PLANNING_JOB_UID", "Planning job uid.")],
sample_response={
"uid": "PLANNING_JOB_UID",
"kind": "planning",
"status": "done",
"download_url": "/issues/planning/PLANNING_JOB_UID/download",
"markdown": "# Open Tickets Implementation Plan\n\n...",
"ai_used": True,
"issue_count": 12,
"bytes_out": 4096,
"error": None,
"created_at": "2026-06-15T09:00:00+00:00",
"completed_at": "2026-06-15T09:00:05+00:00",
},
),
endpoint(
id="issues-planning-download",
method="GET",
path="/issues/planning/{uid}/download",
title="Download the planning report",
summary="Download the generated planning report as a markdown file. Admin only.",
auth="admin",
interactive=True,
params=[field("uid", "path", "string", True, "PLANNING_JOB_UID", "Planning job uid.")],
),
endpoint(
id="issues-detail",
method="GET",
path="/issues/{number}",
title="View an issue ticket",
summary="Render a Gitea issue and its comments. Returns an HTML page.",
auth="public",
interactive=True,
params=[
field("number", "path", "integer", True, "12", "Issue number.")
],
),
endpoint(
id="issues-comment",
method="POST",
path="/issues/{number}/comment",
title="Comment on an issue",
summary="Post a comment to the Gitea issue, attributed to the current user.",
auth="user",
encoding="form",
destructive=True,
params=[
field("number", "path", "integer", True, "12", "Issue number."),
field(
"body",
"form",
"textarea",
True,
"I can reproduce this on mobile.",
"Comment, 1-5000 characters.",
),
],
),
endpoint(
id="issues-status",
method="POST",
path="/issues/{number}/status",
title="Change an issue status",
summary="Open or close the Gitea issue. Admin only.",
auth="admin",
encoding="form",
destructive=True,
params=[
field("number", "path", "integer", True, "12", "Issue number."),
field(
"status",
"form",
"string",
True,
"closed",
"New status: open or closed.",
),
],
),
endpoint(
id="issues-attachments-list",
method="GET",
path="/issues/{number}/attachments",
title="List issue attachments",
summary="Return the files attached to an issue ticket.",
auth="public",
params=[
field("number", "path", "integer", True, "12", "Issue number."),
],
),
endpoint(
id="issues-attachments-add",
method="POST",
path="/issues/{number}/attachments",
title="Attach files to an issue",
summary=(
"Link already-uploaded files (from /uploads/upload) to an open issue. The "
"files are also mirrored to the Gitea tracker. Allowed only while the issue is open."
),
auth="user",
encoding="form",
destructive=True,
params=[
field("number", "path", "integer", True, "12", "Issue number."),
field(
"attachment_uids",
"form",
"string",
True,
"a1b2c3d4,e5f6g7h8",
"Comma separated attachment uids returned by /uploads/upload.",
),
],
notes=[
"Only the open issue accepts changes; a closed issue returns 409.",
"You can only link your own uploads unless you are an administrator.",
],
),
endpoint(
id="issues-attachments-delete",
method="DELETE",
path="/issues/{number}/attachments/{uid}",
title="Delete an issue attachment",
summary=(
"Soft-delete a file from an open issue (owner or administrator) and remove it "
"from the tracker."
),
auth="user",
destructive=True,
params=[
field("number", "path", "integer", True, "12", "Issue number."),
field("uid", "path", "string", True, "a1b2c3d4", "Attachment uid."),
],
),
endpoint(
id="issues-comment-attachments-add",
method="POST",
path="/issues/{number}/comments/{cid}/attachments",
title="Attach files to an issue comment",
summary=(
"Link already-uploaded files to an issue comment (issue must be open). Mirrored "
"to the Gitea comment."
),
auth="user",
encoding="form",
destructive=True,
params=[
field("number", "path", "integer", True, "12", "Issue number."),
field("cid", "path", "integer", True, "1", "Gitea comment id."),
field(
"attachment_uids",
"form",
"string",
True,
"a1b2c3d4",
"Comma separated attachment uids returned by /uploads/upload.",
),
],
),
endpoint(
id="issues-comment-attachments-delete",
method="DELETE",
path="/issues/{number}/comments/{cid}/attachments/{uid}",
title="Delete an issue comment attachment",
summary=(
"Soft-delete a file from an issue comment (owner or administrator) and remove it "
"from the tracker."
),
auth="user",
destructive=True,
params=[
field("number", "path", "integer", True, "12", "Issue number."),
field("cid", "path", "integer", True, "1", "Gitea comment id."),
field("uid", "path", "string", True, "a1b2c3d4", "Attachment uid."),
],
),
],
}
-61
View File
@@ -1,61 +0,0 @@
# retoor <retoor@molodetz.nl>
from .._shared import endpoint, field
GROUP = {
"slug": "lookups",
"title": "Search & Lookups",
"intro": """
# Search & Lookups
Type-ahead lookups that power mentions and the message composer. Both return JSON and accept
a single `q` query parameter. These feed [Messaging](/docs/messaging.html) (the recipient
composer) and [Profiles & Social Graph](/docs/profiles.html) (mentions and user pages).
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.
""",
"endpoints": [
endpoint(
id="search-users",
method="GET",
path="/profile/search",
title="Search users",
summary="Find up to ten users whose username matches a query.",
auth="user",
params=[
field(
"q",
"query",
required=True,
example="al",
description="Partial username to match.",
)
],
sample_response={
"results": [{"uid": "8f14e45f-...", "username": "alice_test"}]
},
),
endpoint(
id="search-message-recipients",
method="GET",
path="/messages/search",
title="Search message recipients",
summary="Like user search, but excludes yourself; used by the message composer.",
auth="user",
params=[
field(
"q",
"query",
required=True,
example="bo",
description="Partial username to match.",
)
],
sample_response={
"results": [{"uid": "0cc175b9-...", "username": "bob_test"}]
},
),
],
}
-75
View File
@@ -1,75 +0,0 @@
# retoor <retoor@molodetz.nl>
from .._shared import endpoint, field
GROUP = {
"slug": "messaging",
"title": "Messaging",
"intro": """
# Messaging
Direct messages between users. The inbox renders HTML; sending uses form fields. Look up
recipients with [Search & Lookups](/docs/lookups.html) and attach files via [Uploads](/docs/uploads.html).
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.
""",
"endpoints": [
endpoint(
id="messages-inbox",
method="GET",
path="/messages",
title="Open the inbox",
summary="Render conversations. Returns an HTML page.",
auth="user",
interactive=True,
params=[
field(
"with_uid",
"query",
"string",
False,
"",
"Open a specific conversation by user UID.",
),
field(
"search",
"query",
"string",
False,
"",
"Jump to a conversation by username.",
),
],
),
endpoint(
id="messages-send",
method="POST",
path="/messages/send",
title="Send a message",
summary="Send a direct message to a user.",
auth="user",
encoding="form",
destructive=True,
params=[
field(
"content",
"form",
"textarea",
True,
"Hello there.",
"Body, 1-2000 characters.",
),
field(
"receiver_uid",
"form",
"string",
True,
"RECEIVER_UID",
"Recipient user UID.",
),
],
),
],
}
@@ -1,89 +0,0 @@
# retoor <retoor@molodetz.nl>
from .._shared import endpoint, field
GROUP = {
"slug": "notifications",
"title": "Notifications",
"intro": """
# Notifications
Read your notification feed and mark items read. The unread counts endpoint backs the badges
in the navigation bar. Deliver these to the browser with [Web Push](/docs/push.html).
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.
""",
"endpoints": [
endpoint(
id="notifications-list",
method="GET",
path="/notifications",
title="View notifications",
summary="Render your notifications. Returns an HTML page.",
auth="user",
interactive=True,
params=[
field("before", "query", "string", False, "", "Pagination cursor.")
],
),
endpoint(
id="notifications-counts",
method="GET",
path="/notifications/counts",
title="Unread counts",
summary="Unread notification and message counts.",
auth="public",
notes=[
'Guests receive `{ "notifications": 0, "messages": 0 }` instead of an error, so the navigation badge works before login.'
],
sample_response={"notifications": 2, "messages": 1},
),
endpoint(
id="notifications-open",
method="GET",
path="/notifications/open/{notification_uid}",
title="Open a notification",
summary="Mark a notification read and redirect to its target.",
auth="user",
interactive=False,
params=[
field(
"notification_uid",
"path",
"string",
True,
"NOTIFICATION_UID",
"UID of the notification.",
)
],
),
endpoint(
id="notifications-mark-read",
method="POST",
path="/notifications/mark-read/{notification_uid}",
title="Mark one read",
summary="Mark a single notification as read.",
auth="user",
params=[
field(
"notification_uid",
"path",
"string",
True,
"NOTIFICATION_UID",
"UID of the notification.",
)
],
),
endpoint(
id="notifications-mark-all-read",
method="POST",
path="/notifications/mark-all-read",
title="Mark all read",
summary="Mark every notification as read.",
auth="user",
),
],
}
-795
View File
@@ -1,795 +0,0 @@
# retoor <retoor@molodetz.nl>
from .._shared import endpoint, field
GROUP = {
"slug": "profiles",
"title": "Profiles & Social Graph",
"intro": """
# Profiles & Social Graph
Profile data, the follow graph, the leaderboard, and avatar generation. Find users with
[Search & Lookups](/docs/lookups.html); follows generate entries in [Notifications](/docs/notifications.html).
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.
""",
"endpoints": [
endpoint(
id="my-profile",
method="GET",
path="/profile",
title="View your own profile",
summary="Render the signed-in user's own profile, in the exact format of GET /profile/{username}. Requires authentication.",
auth="user",
interactive=True,
params=[
field(
"tab",
"query",
"enum",
False,
"posts",
"Profile tab.",
["posts", "activity", "followers", "following", "media", "awards"],
),
],
),
endpoint(
id="profile-detail",
method="GET",
path="/profile/{username}",
title="View a profile",
summary="Render a user profile, including an online-presence indicator (JSON exposes profile_online and profile_user.last_seen). Returns an HTML page.",
auth="public",
interactive=True,
params=[
field(
"username",
"path",
"string",
True,
"{{ username }}",
"Target username.",
),
field(
"tab",
"query",
"enum",
False,
"posts",
"Profile tab.",
["posts", "activity", "followers", "following", "media", "awards"],
),
],
),
endpoint(
id="profile-update",
method="POST",
path="/profile/update",
title="Update your profile",
summary="Update your own bio and links.",
auth="user",
encoding="form",
destructive=True,
params=[
field(
"bio",
"form",
"textarea",
False,
"Building things.",
"Bio, up to 500 characters.",
),
field(
"location",
"form",
"string",
False,
"Earth",
"Location, up to 200 characters.",
),
field("git_link", "form", "string", False, "", "Git profile URL."),
field(
"website", "form", "string", False, "", "Personal website URL."
),
],
),
endpoint(
id="profile-ai-correction",
method="POST",
path="/profile/{username}/ai-correction",
title="Configure AI content correction",
summary="Enable or disable automatic AI rewriting of your prose and set the correction instruction. Opt-in, default off. Admins may target any user.",
auth="user",
encoding="form",
destructive=True,
params=[
field(
"username",
"path",
"string",
True,
"bob_test",
"Profile owner. Must be yourself unless you are an admin.",
),
field(
"enabled",
"form",
"boolean",
False,
"true",
"true to enable background AI correction, false to disable it.",
),
field(
"sync",
"form",
"boolean",
False,
"false",
"true to apply the correction synchronously (the save waits), false for background.",
),
field(
"prompt",
"form",
"textarea",
False,
"Leave literary as is, only do punctuation and casing",
"Correction instruction, up to 20000 characters.",
),
],
sample_response={
"ok": True,
"redirect": "/profile/bob_test",
"data": {
"url": "/profile/bob_test",
"enabled": True,
"sync": False,
"prompt": "Leave literary as is, only do punctuation and casing",
},
},
),
endpoint(
id="profile-interactions",
method="POST",
path="/profile/{username}/interactions",
title="Configure Devii interactive widgets",
summary="Enable or disable CA-IWP interactive prompts (ui_prompt) for this account, or reset to the administrator default. Guests always use the site default. Admins may target any user.",
auth="user",
encoding="form",
destructive=True,
params=[
field(
"username",
"path",
"string",
True,
"bob_test",
"Profile owner. Must be yourself unless you are an admin.",
),
field(
"enabled",
"form",
"boolean",
False,
"true",
"true to enable interactive widgets, false to disable. Ignored when reset is true.",
),
field(
"reset",
"form",
"boolean",
False,
"false",
"true to clear the user override and inherit the administrator default.",
),
],
sample_response={
"ok": True,
"redirect": "/profile/bob_test",
"data": {
"url": "/profile/bob_test",
"enabled": True,
"source": "user",
"default": True,
"override": True,
},
},
),
endpoint(
id="profile-ai-modifier",
method="POST",
path="/profile/{username}/ai-modifier",
title="Configure the AI modifier",
summary="Enable or disable the inline '@ai <instruction>' modifier on your prose and set its prompt. Enabled by default, applied synchronously by default. Admins may target any user.",
auth="user",
encoding="form",
destructive=True,
params=[
field(
"username",
"path",
"string",
True,
"bob_test",
"Profile owner. Must be yourself unless you are an admin.",
),
field(
"enabled",
"form",
"boolean",
False,
"true",
"true to enable the AI modifier, false to disable it.",
),
field(
"sync",
"form",
"boolean",
False,
"true",
"true to apply the modification synchronously (the save waits), false for background.",
),
field(
"prompt",
"form",
"textarea",
False,
"Execute what is behind `@ai` (the prompt) and replace that part including `@ai`",
"Modifier instruction, up to 20000 characters.",
),
],
sample_response={
"ok": True,
"redirect": "/profile/bob_test",
"data": {
"url": "/profile/bob_test",
"enabled": True,
"sync": True,
"prompt": "Execute what is behind `@ai` (the prompt) and replace that part including `@ai`",
},
},
),
endpoint(
id="profile-telegram",
method="POST",
path="/profile/{username}/telegram",
title="Pair or unpair Telegram",
summary="Request a single-use Telegram pairing code, or unpair the connected account. Send action=request (default) to receive a code valid for a few minutes, or action=unpair to disconnect. Admins may target any user.",
auth="user",
encoding="form",
destructive=True,
params=[
field(
"username",
"path",
"string",
True,
"bob_test",
"Profile owner. Must be yourself unless you are an admin.",
),
field(
"action",
"form",
"string",
False,
"request",
"Either request to issue a pairing code, or unpair to disconnect Telegram.",
),
],
sample_response={
"ok": True,
"paired": False,
"code": "1234",
"expires_at": "2026-06-18T12:05:00+00:00",
"ttl_minutes": 5,
},
),
endpoint(
id="profile-regenerate-key",
method="POST",
path="/profile/regenerate-api-key",
title="Regenerate your API key",
summary="Issue a new API key and invalidate the current one.",
auth="user",
interactive=False,
destructive=True,
notes=[
"> Running this invalidates the key these documentation panels use. Do it from your "
"[profile page](/profile/{{ username }}) instead, then reload these docs.",
],
sample_response={"api_key": "NEW_UUID"},
),
endpoint(
id="profile-give-award",
method="POST",
path="/profile/{username}/award",
title="Give a member an award",
summary="Create a pending award on another member's profile and enqueue image generation.",
auth="user",
encoding="json",
params=[
field(
"username",
"path",
"string",
True,
"{{ username }}",
"Receiver username.",
),
field(
"description",
"body",
"string",
True,
"Great work on the release!",
"Award message (1-125 characters).",
),
],
sample_response={
"ok": True,
"data": {
"award_uid": "AWARD_UID",
"award_slug": "abc123-great-work",
},
},
),
endpoint(
id="profile-regenerate-avatar",
method="POST",
path="/profile/{username}/regenerate-avatar",
title="Regenerate a user avatar",
summary="Replace the user's avatar with a freshly generated random one.",
auth="user",
interactive=False,
destructive=True,
params=[
field(
"username",
"path",
required=True,
description="Profile owner. Allowed for the owner or any admin.",
),
],
notes=[
"> Irreversible: the previous avatar is gone for good and cannot be brought back.",
],
sample_response={
"ok": True,
"data": {
"url": "/profile/{{ username }}",
"avatar_seed": "NEW_UUID",
"avatar_url": "/avatar/multiavatar/NEW_UUID?size=80",
},
},
),
endpoint(
id="profile-customization-global",
method="POST",
path="/profile/{username}/customization/global",
title="Toggle site-wide customizations",
summary="Show or suppress your site-wide custom CSS and JS without deleting it. Admins may target any user.",
auth="user",
encoding="form",
destructive=True,
params=[
field(
"username",
"path",
"string",
True,
"bob_test",
"Profile owner. Must be yourself unless you are an admin.",
),
field(
"value",
"form",
"boolean",
False,
"0",
"1 to show your site-wide customizations, 0 to suppress them.",
),
],
),
endpoint(
id="profile-customization-pagetype",
method="POST",
path="/profile/{username}/customization/pagetype",
title="Toggle per-page customizations",
summary="Show or suppress your per-page custom CSS and JS without deleting it. Admins may target any user.",
auth="user",
encoding="form",
destructive=True,
params=[
field(
"username",
"path",
"string",
True,
"bob_test",
"Profile owner. Must be yourself unless you are an admin.",
),
field(
"value",
"form",
"boolean",
False,
"0",
"1 to show your per-page customizations, 0 to suppress them.",
),
],
),
endpoint(
id="profile-notification-toggle",
method="POST",
path="/profile/{username}/notifications",
title="Toggle a notification preference",
summary="Enable or disable one notification type on one channel (in-app or push). Admins may target any user. Types: comment, reply, mention, vote, follow, message, badge, level, issue, reminder, harvest_stolen.",
auth="user",
encoding="form",
destructive=True,
params=[
field(
"username",
"path",
"string",
True,
"bob_test",
"Profile owner. Must be yourself unless you are an admin.",
),
field(
"notification_type",
"form",
"string",
True,
"vote",
"One of: comment, reply, mention, vote, follow, message, badge, level, issue, reminder, harvest_stolen.",
),
field(
"channel",
"form",
"string",
True,
"push",
"One of in_app, push or telegram (telegram is off by default and requires a paired Telegram account).",
),
field(
"value",
"form",
"boolean",
False,
"1",
"1 to deliver this notification on this channel, 0 to suppress it.",
),
],
sample_response={
"ok": True,
"redirect": "/profile/{{ username }}?tab=notifications",
"data": {
"notification_type": "vote",
"channel": "push",
"value": False,
},
},
),
endpoint(
id="profile-notification-reset",
method="POST",
path="/profile/{username}/notifications/reset",
title="Reset notification preferences",
summary="Clear all of a user's notification overrides so every type falls back to the platform default. Admins may target any user.",
auth="user",
encoding="form",
destructive=True,
params=[
field(
"username",
"path",
"string",
True,
"bob_test",
"Profile owner. Must be yourself unless you are an admin.",
),
],
sample_response={
"ok": True,
"redirect": "/profile/{{ username }}?tab=notifications",
},
),
endpoint(
id="media-delete",
method="POST",
path="/media/{uid}/delete",
title="Delete media",
summary="Remove one of your uploaded media attachments. It disappears from your profile Media tab and from any post, project, gist, or other place it was attached. You can delete media you uploaded; administrators may remove any user's media.",
auth="user",
destructive=True,
params=[
field(
"uid",
"path",
"string",
True,
"",
"Attachment uid, taken from the Media tab response.",
),
],
sample_response={
"ok": True,
"redirect": "/profile/{{ username }}?tab=media",
},
),
endpoint(
id="follow-user",
method="POST",
path="/follow/{username}",
title="Follow a user",
summary="Follow another user. Idempotent.",
auth="user",
destructive=True,
params=[
field(
"username",
"path",
"string",
True,
"bob_test",
"Username to follow.",
)
],
),
endpoint(
id="unfollow-user",
method="POST",
path="/follow/unfollow/{username}",
title="Unfollow a user",
summary="Stop following a user.",
auth="user",
destructive=True,
params=[
field(
"username",
"path",
"string",
True,
"bob_test",
"Username to unfollow.",
)
],
),
endpoint(
id="block-user",
method="POST",
path="/block/{username}",
title="Block a user",
summary="Block a user. Their posts, comments and messages are hidden from you everywhere except their own profile, and they can no longer create notifications for you. Idempotent.",
auth="user",
destructive=True,
params=[
field(
"username",
"path",
"string",
True,
"bob_test",
"Username to block.",
)
],
sample_response={"ok": True, "redirect": "/profile/bob_test"},
),
endpoint(
id="unblock-user",
method="POST",
path="/block/unblock/{username}",
title="Unblock a user",
summary="Reverse a block. Their content becomes visible again.",
auth="user",
destructive=True,
params=[
field(
"username",
"path",
"string",
True,
"bob_test",
"Username to unblock.",
)
],
sample_response={"ok": True, "redirect": "/profile/bob_test"},
),
endpoint(
id="mute-user",
method="POST",
path="/mute/{username}",
title="Mute a user",
summary="Mute a user so they can no longer create notifications for you. Their content stays visible. Idempotent.",
auth="user",
destructive=True,
params=[
field(
"username",
"path",
"string",
True,
"bob_test",
"Username to mute.",
)
],
sample_response={"ok": True, "redirect": "/profile/bob_test"},
),
endpoint(
id="unmute-user",
method="POST",
path="/mute/unmute/{username}",
title="Unmute a user",
summary="Reverse a mute. They can create notifications for you again.",
auth="user",
destructive=True,
params=[
field(
"username",
"path",
"string",
True,
"bob_test",
"Username to unmute.",
)
],
sample_response={"ok": True, "redirect": "/profile/bob_test"},
),
endpoint(
id="list-followers",
method="GET",
path="/profile/{username}/followers",
title="List followers",
summary="List the users who follow a profile, 25 per page. Returns JSON.",
auth="public",
interactive=True,
params=[
field(
"username",
"path",
"string",
True,
"{{ username }}",
"Target username.",
),
field(
"page",
"query",
"integer",
False,
"1",
"Page number, 25 per page.",
),
],
sample_response={
"username": "{{ username }}",
"mode": "followers",
"count": 2,
"page": 1,
"total_pages": 1,
"followers": [
{
"uid": "UUID",
"username": "bob_test",
"bio": "Building things.",
"is_following": False,
},
],
},
),
endpoint(
id="list-following",
method="GET",
path="/profile/{username}/following",
title="List following",
summary="List the users a profile follows, 25 per page. Returns JSON.",
auth="public",
interactive=True,
params=[
field(
"username",
"path",
"string",
True,
"{{ username }}",
"Target username.",
),
field(
"page",
"query",
"integer",
False,
"1",
"Page number, 25 per page.",
),
],
sample_response={
"username": "{{ username }}",
"mode": "following",
"count": 1,
"page": 1,
"total_pages": 1,
"following": [
{
"uid": "UUID",
"username": "alice_test",
"bio": "",
"is_following": True,
},
],
},
),
endpoint(
id="leaderboard",
method="GET",
path="/leaderboard",
title="View the leaderboard",
summary="Top contributors by stars. Returns an HTML page.",
auth="public",
interactive=True,
),
endpoint(
id="award-image",
method="GET",
path="/awards/{slug_or_uid}/{size}",
title="Award image redirect",
summary="Redirect to the stored PNG attachment for a published award.",
auth="public",
params=[
field(
"slug_or_uid",
"path",
"string",
True,
"abc123-great-work",
"Award slug or bare uid.",
),
field(
"size",
"path",
"enum",
True,
"256",
"Image size.",
["512", "256", "64"],
),
],
notes=[
"> Pending or revoked awards return 404.",
"> Response includes long-lived cache headers.",
],
),
endpoint(
id="avatar",
method="GET",
path="/avatar/{style}/{seed}",
title="Generate an avatar",
summary="Deterministic SVG avatar for a seed. Returns an image.",
auth="public",
interactive=False,
params=[
field(
"style",
"path",
"enum",
True,
"multiavatar",
"Avatar style.",
["multiavatar"],
),
field(
"seed",
"path",
"string",
True,
"{{ username }}",
"Seed string, usually a username.",
),
field("size", "query", "int", False, "128", "Pixel size."),
],
),
],
}
-550
View File
@@ -1,550 +0,0 @@
# retoor <retoor@molodetz.nl>
from .._shared import endpoint, field
GROUP = {
"slug": "project-files",
"title": "Project Filesystem",
"intro": """
# Project Filesystem
Each project carries a full virtual filesystem - directories and files - so a project can hold a
complete software project. Reading is public (anyone can browse a project's tree); creating,
editing, uploading, moving and deleting require the project owner. Text files are editable inline;
binary files are uploaded and served from `/static/uploads/project_files/...`.
Paths are relative POSIX paths inside the project (for example `src/main.py`). Parent directories
are created automatically on write, upload and mkdir. Paths containing `..`, null bytes or empty
segments are rejected.
Every endpoint follows the shared [Conventions & Errors](/docs/conventions.html) (auth, content
negotiation, status codes); see [Authentication](/docs/authentication.html) for the four ways to
sign requests.
""",
"endpoints": [
endpoint(
id="project-files-list",
method="GET",
path="/projects/{project_slug}/files",
title="List a project's files",
summary="Return the flat list of files and directories in a project.",
auth="public",
params=[
field(
"project_slug",
"path",
"string",
True,
"PROJECT_SLUG",
"Project slug or uid.",
)
],
sample_response={
"project": {"uid": "PROJECT_UID", "slug": "PROJECT_SLUG"},
"files": [
{
"path": "src/main.py",
"name": "main.py",
"type": "file",
"is_binary": False,
"size": 42,
}
],
"is_owner": False,
},
),
endpoint(
id="project-files-raw",
method="GET",
path="/projects/{project_slug}/files/raw",
title="Read a project file",
summary="Return one file's metadata and (for text files) its content.",
auth="public",
params=[
field(
"project_slug",
"path",
"string",
True,
"PROJECT_SLUG",
"Project slug or uid.",
),
field(
"path",
"query",
"string",
True,
"src/main.py",
"Relative file path inside the project.",
),
],
sample_response={
"path": "src/main.py",
"name": "main.py",
"type": "file",
"is_binary": False,
"mime_type": "text/plain",
"size": 42,
"url": None,
"content": "print('hello')\n",
},
),
endpoint(
id="project-files-write",
method="POST",
path="/projects/{project_slug}/files/write",
title="Write a text file",
summary="Create or overwrite a text file; parent directories are created automatically.",
auth="user",
encoding="form",
destructive=True,
params=[
field(
"project_slug",
"path",
"string",
True,
"PROJECT_SLUG",
"Project slug or uid.",
),
field(
"path",
"form",
"string",
True,
"src/main.py",
"Relative file path.",
),
field(
"content",
"form",
"textarea",
True,
"print('hello')",
"Full file content (max 400000 chars).",
),
],
notes=["Owner only; non-owners get `403`. Invalid paths return `400`."],
sample_response={
"ok": True,
"redirect": "/projects/PROJECT_SLUG/files",
"data": {"path": "src/main.py", "type": "file"},
},
),
endpoint(
id="project-files-lines",
method="GET",
path="/projects/{project_slug}/files/lines",
title="Read a line range",
summary="Read a 1-indexed inclusive line range of a text file. Returns lines plus total_lines for targeting edits.",
auth="public",
params=[
field(
"project_slug",
"path",
"string",
True,
"PROJECT_SLUG",
"Project slug or uid.",
),
field(
"path",
"query",
"string",
True,
"src/main.py",
"Relative file path.",
),
field(
"start",
"query",
"integer",
False,
"1",
"First line, 1-indexed (default 1).",
),
field(
"end",
"query",
"integer",
False,
"50",
"Last line inclusive; omit or -1 for end of file.",
),
],
notes=[
"Text files only; binary, directory, or missing paths return `404`."
],
sample_response={
"path": "src/main.py",
"start": 1,
"end": 2,
"total_lines": 2,
"lines": ["import os", "print(os.getcwd())"],
"content": "import os\nprint(os.getcwd())",
},
),
endpoint(
id="project-files-replace-lines",
method="POST",
path="/projects/{project_slug}/files/replace-lines",
title="Replace a line range",
summary="Replace lines start..end (inclusive) with new content; empty content deletes the range. Leaves the rest of the file untouched.",
auth="user",
encoding="form",
destructive=True,
params=[
field(
"project_slug",
"path",
"string",
True,
"PROJECT_SLUG",
"Project slug or uid.",
),
field(
"path",
"form",
"string",
True,
"src/main.py",
"Relative file path.",
),
field(
"start",
"form",
"integer",
True,
"10",
"First line to replace (1-indexed).",
),
field(
"end",
"form",
"integer",
True,
"12",
"Last line to replace (inclusive).",
),
field(
"content",
"form",
"textarea",
False,
"new code",
"Replacement text (empty deletes the range).",
),
],
notes=[
"Owner only. The preferred way to edit a large file; avoids rewriting the whole file."
],
sample_response={
"ok": True,
"redirect": "/projects/PROJECT_SLUG/files",
"data": {"path": "src/main.py", "type": "file"},
},
),
endpoint(
id="project-files-insert-lines",
method="POST",
path="/projects/{project_slug}/files/insert-lines",
title="Insert lines",
summary="Insert content before a 1-indexed line. Use at=1 to prepend and at=total_lines+1 to append.",
auth="user",
encoding="form",
destructive=True,
params=[
field(
"project_slug",
"path",
"string",
True,
"PROJECT_SLUG",
"Project slug or uid.",
),
field(
"path",
"form",
"string",
True,
"src/main.py",
"Relative file path.",
),
field(
"at",
"form",
"integer",
True,
"1",
"Insert before this 1-indexed line.",
),
field(
"content",
"form",
"textarea",
True,
"# header",
"Text to insert.",
),
],
sample_response={
"ok": True,
"redirect": "/projects/PROJECT_SLUG/files",
"data": {"path": "src/main.py", "type": "file"},
},
),
endpoint(
id="project-files-delete-lines",
method="POST",
path="/projects/{project_slug}/files/delete-lines",
title="Delete a line range",
summary="Delete lines start..end (inclusive) from a text file.",
auth="user",
encoding="form",
destructive=True,
params=[
field(
"project_slug",
"path",
"string",
True,
"PROJECT_SLUG",
"Project slug or uid.",
),
field(
"path",
"form",
"string",
True,
"src/main.py",
"Relative file path.",
),
field(
"start",
"form",
"integer",
True,
"5",
"First line to delete (1-indexed).",
),
field(
"end",
"form",
"integer",
True,
"7",
"Last line to delete (inclusive).",
),
],
sample_response={
"ok": True,
"redirect": "/projects/PROJECT_SLUG/files",
"data": {"path": "src/main.py", "type": "file"},
},
),
endpoint(
id="project-files-append",
method="POST",
path="/projects/{project_slug}/files/append",
title="Append to a file",
summary="Append content as new lines at the end of a text file; grow a large file across calls without resending it.",
auth="user",
encoding="form",
destructive=True,
params=[
field(
"project_slug",
"path",
"string",
True,
"PROJECT_SLUG",
"Project slug or uid.",
),
field(
"path", "form", "string", True, "log.txt", "Relative file path."
),
field(
"content",
"form",
"textarea",
True,
"next chunk",
"Text to append.",
),
],
sample_response={
"ok": True,
"redirect": "/projects/PROJECT_SLUG/files",
"data": {"path": "log.txt", "type": "file"},
},
),
endpoint(
id="project-files-upload",
method="POST",
path="/projects/{project_slug}/files/upload",
title="Upload a file into a project",
summary="Upload a file into a directory (parents created); text decodes to an editable file, otherwise stored as binary.",
auth="user",
encoding="multipart",
destructive=True,
params=[
field(
"project_slug",
"path",
"string",
True,
"PROJECT_SLUG",
"Project slug or uid.",
),
field("file", "form", "file", True, "", "The file to upload."),
field(
"path",
"form",
"string",
False,
"assets",
"Target directory, empty for the root.",
),
],
sample_response={
"ok": True,
"redirect": "/projects/PROJECT_SLUG/files",
"data": {
"path": "assets/logo.png",
"type": "file",
"is_binary": True,
},
},
),
endpoint(
id="project-files-mkdir",
method="POST",
path="/projects/{project_slug}/files/mkdir",
title="Create a directory",
summary="Create a directory and any missing parents.",
auth="user",
encoding="form",
destructive=True,
params=[
field(
"project_slug",
"path",
"string",
True,
"PROJECT_SLUG",
"Project slug or uid.",
),
field(
"path",
"form",
"string",
True,
"src/components",
"Relative directory path.",
),
],
sample_response={
"ok": True,
"redirect": "/projects/PROJECT_SLUG/files",
"data": {"path": "src/components", "type": "dir"},
},
),
endpoint(
id="project-files-move",
method="POST",
path="/projects/{project_slug}/files/move",
title="Move or rename",
summary="Move or rename a file or directory (and its descendants).",
auth="user",
encoding="form",
destructive=True,
params=[
field(
"project_slug",
"path",
"string",
True,
"PROJECT_SLUG",
"Project slug or uid.",
),
field(
"from_path",
"form",
"string",
True,
"src/old.py",
"Existing path.",
),
field("to_path", "form", "string", True, "src/new.py", "New path."),
],
sample_response={
"ok": True,
"redirect": "/projects/PROJECT_SLUG/files",
"data": {"path": "src/new.py"},
},
),
endpoint(
id="project-files-delete",
method="POST",
path="/projects/{project_slug}/files/delete",
title="Delete a file or directory",
summary="Delete a file, or a directory and everything under it. Project owner or an administrator; soft-deleted (restorable from admin trash). Blocked while the project is read-only.",
auth="user",
encoding="form",
destructive=True,
params=[
field(
"project_slug",
"path",
"string",
True,
"PROJECT_SLUG",
"Project slug or uid.",
),
field(
"path",
"form",
"string",
True,
"src/old.py",
"Relative path to delete.",
),
],
sample_response={
"ok": True,
"redirect": "/projects/PROJECT_SLUG/files",
"data": {"path": "src/old.py"},
},
),
endpoint(
id="project-files-zip",
method="POST",
path="/projects/{project_slug}/files/zip",
title="Queue a zip of files",
summary="Archive the whole tree, or a subtree via the path query. Returns the job uid and status URL to poll with /zips/{uid}.",
auth="public",
params=[
field(
"project_slug",
"path",
"string",
True,
"PROJECT_SLUG",
"Project slug or uid.",
),
field(
"path",
"query",
"string",
False,
"src",
"Relative file or directory to archive; empty for the whole project.",
),
],
sample_response={
"uid": "ZIP_JOB_UID",
"status_url": "/zips/ZIP_JOB_UID",
},
),
],
}
-66
View File
@@ -1,66 +0,0 @@
# retoor <retoor@molodetz.nl>
from .._shared import endpoint, field
GROUP = {
"slug": "push",
"title": "Web Push",
"intro": """
# Web Push
Browser push notifications via the Web Push protocol. Fetch the public VAPID key, then
register a `PushSubscription` obtained from the browser's `PushManager`.
There is no server-side unsubscribe endpoint: unsubscription is handled entirely in the
browser by calling `PushManager.unsubscribe()` on the subscription. The server stops delivering
to a subscription once its push endpoint reports it as gone. These mirror the in-app
[Notifications](/docs/notifications.html) feed.
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.
""",
"endpoints": [
endpoint(
id="push-key",
method="GET",
path="/push.json",
title="Get the public key",
summary="Return the VAPID public key for subscribing.",
auth="public",
sample_response={"publicKey": "BASE64_VAPID_KEY"},
),
endpoint(
id="push-register",
method="POST",
path="/push.json",
title="Register a subscription",
summary="Register a browser push subscription. Sends a welcome notification.",
auth="user",
encoding="json",
interactive=False,
params=[
field(
"endpoint",
"json",
"string",
True,
"https://fcm.googleapis.com/...",
"Subscription endpoint URL.",
),
field(
"keys",
"json",
"string",
True,
'{"p256dh":"...","auth":"..."}',
"Subscription keys object.",
),
],
notes=[
'The body must be JSON: `{"endpoint": "...", "keys": {"p256dh": "...", "auth": "..."}}`.'
],
sample_response={"registered": True},
),
],
}
-10
View File
@@ -1,10 +0,0 @@
# retoor <retoor@molodetz.nl>
GROUP = {
"slug": "services",
"title": "Background Services",
"admin": True,
"dynamic": True,
"intro": "",
"endpoints": [],
}
@@ -1,195 +0,0 @@
# retoor <retoor@molodetz.nl>
from .._shared import BOOKMARK_TARGETS, REACTION_TARGETS, VOTE_TARGETS, endpoint, field
from devplacepy.constants import REACTION_EMOJI
GROUP = {
"slug": "social-actions",
"title": "Votes, Reactions, Bookmarks & Polls",
"intro": """
# Votes, Reactions, Bookmarks & Polls
Lightweight engagement actions. The POST endpoints here are **toggles** - sending the same
action again removes it. They return JSON when called with `X-Requested-With: fetch` (sent
automatically by the panels below); the [Conventions & Errors](/docs/conventions.html) page
explains that header rule and the response envelope.
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.
""",
"endpoints": [
endpoint(
id="votes-cast",
method="POST",
path="/votes/{target_type}/{target_uid}",
title="Cast or toggle a vote",
summary="Upvote or downvote a target. Re-sending the same value removes the vote.",
auth="user",
ajax=True,
encoding="form",
params=[
field(
"target_type",
"path",
"enum",
True,
"post",
"Type of content being voted on.",
VOTE_TARGETS,
),
field(
"target_uid",
"path",
"string",
True,
"POST_UID",
"UID of the target.",
),
field(
"value",
"form",
"enum",
True,
"1",
"1 to upvote, -1 to downvote.",
["1", "-1"],
),
],
sample_response={"net": 3, "up": 4, "down": 1, "value": 1},
),
endpoint(
id="reactions-toggle",
method="POST",
path="/reactions/{target_type}/{target_uid}",
title="Toggle an emoji reaction",
summary="Add or remove an emoji reaction on a target.",
auth="user",
ajax=True,
encoding="form",
params=[
field(
"target_type",
"path",
"enum",
True,
"post",
"Type of content being reacted to.",
REACTION_TARGETS,
),
field(
"target_uid",
"path",
"string",
True,
"POST_UID",
"UID of the target.",
),
field(
"emoji",
"form",
"enum",
True,
REACTION_EMOJI[0],
"One of the allowed reaction emoji.",
REACTION_EMOJI,
),
],
sample_response={
"counts": {REACTION_EMOJI[0]: 2},
"mine": [REACTION_EMOJI[0]],
},
),
endpoint(
id="bookmarks-toggle",
method="POST",
path="/bookmarks/{target_type}/{target_uid}",
title="Toggle a bookmark",
summary="Save or unsave a target to your bookmarks.",
auth="user",
ajax=True,
encoding="none",
params=[
field(
"target_type",
"path",
"enum",
True,
"post",
"Type of content to bookmark.",
BOOKMARK_TARGETS,
),
field(
"target_uid",
"path",
"string",
True,
"POST_UID",
"UID of the target.",
),
],
sample_response={"saved": True},
),
endpoint(
id="bookmarks-saved",
method="GET",
path="/bookmarks/saved",
title="View saved bookmarks",
summary="Render your saved content. Returns an HTML page.",
auth="user",
interactive=True,
params=[
field(
"before",
"query",
"string",
False,
"",
"Pagination cursor (created_at of the last item).",
)
],
notes=[
"Bookmarks target posts, projects, gists, and news; see [Posts, Comments, Projects, Gists & News](/docs/content.html)."
],
),
endpoint(
id="polls-vote",
method="POST",
path="/polls/{poll_uid}/vote",
title="Vote in a poll",
summary="Cast, change, or clear your vote on a poll option.",
auth="user",
ajax=True,
encoding="form",
params=[
field(
"poll_uid",
"path",
"string",
True,
"POLL_UID",
"UID of the poll.",
),
field(
"option_uid",
"form",
"string",
True,
"OPTION_UID",
"UID of the chosen option.",
),
],
notes=[
"You hold at most one vote per poll, and only your latest vote counts. "
"Voting a different option replaces your previous choice; voting your current "
"option again removes the vote.",
],
sample_response={
"question": "Best editor?",
"options": [{"uid": "OPTION_UID", "label": "Vim", "votes": 5}],
"total": 5,
"voted": "OPTION_UID",
},
),
],
}

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