Compare commits
16 Commits
9d86d7b65c
...
499f91e16a
| Author | SHA1 | Date | |
|---|---|---|---|
| 499f91e16a | |||
| 9a8046ab2a | |||
| f1bdefd834 | |||
| 0f872336b1 | |||
| 7002b23eeb | |||
| c79dfd0291 | |||
| 8daee65011 | |||
| 7de7e29d3e | |||
| d556b02bfb | |||
| 574b076f5d | |||
| f521a1130e | |||
| b57e657cf8 | |||
| 0e8b015fe1 | |||
| d45bb37ce1 | |||
| 4618089cea | |||
| 9151876770 |
50
README.md
50
README.md
@ -19,7 +19,7 @@ Open `http://localhost:10500`.
|
||||
|
||||
| Layer | Technology |
|
||||
|-------|-----------|
|
||||
| Backend | Python 3.13+, FastAPI, Uvicorn (multi-worker in production) |
|
||||
| Backend | Python 3.12+, FastAPI, Uvicorn (multi-worker in production) |
|
||||
| Templates | Jinja2 (server-side rendered) |
|
||||
| Frontend | Pure ES6 JavaScript, one class per file |
|
||||
| Database | SQLite via `dataset` (auto-sync schema, `uid` PKs, WAL mode, 30s busy timeout) |
|
||||
@ -35,10 +35,10 @@ Open `http://localhost:10500`.
|
||||
devplacepy/
|
||||
main.py # FastAPI app, router registration
|
||||
config.py # Settings from env vars + .env
|
||||
database.py # dataset connection, index creation
|
||||
database/ # dataset connection, index creation (package)
|
||||
templating.py # Shared Jinja2 environment + globals
|
||||
avatar.py # Multiavatar generation, URL builder
|
||||
utils.py # Password hashing, session mgmt, time_ago, notification hook
|
||||
utils/ # Password hashing, session mgmt, time_ago, notification hook (package)
|
||||
models.py # Pydantic schemas
|
||||
push.py # Web push crypto, VAPID keys, encrypt/send/register
|
||||
routers/ # One file per domain (auth, feed, posts, push, ...)
|
||||
@ -63,16 +63,16 @@ devplacepy/
|
||||
| `/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 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 |
|
||||
| `/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}`. `/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 every container instance across all projects (scoped by project visibility - instances attached to another administrator's hidden project are excluded). 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`) |
|
||||
| `/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, and a public **Media** tab (`?tab=media`) showing every attachment a user uploaded, newest first |
|
||||
| `/profile` | Profile view, editing, a public **Media** tab (`?tab=media`) showing every attachment a user uploaded newest first, and a live **online / last-seen** presence indicator |
|
||||
| `/media` | Per-attachment soft delete and restore: `POST /media/{uid}/delete` (owner or admin), `POST /media/{uid}/restore` (admin) |
|
||||
| `/uploads` | File upload endpoints: `POST /uploads/upload` (multipart), `POST /uploads/upload-url` (from URL); served at `/static/uploads/` |
|
||||
| `/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. The `POST /messages/send` form remains as a no-JavaScript fallback |
|
||||
| `/messages` | Real-time direct messaging over WebSocket (`/messages/ws`): live bidirectional delivery, optimistic send, typing indicators, read receipts, and online/last-seen presence. Messages render through the shared content pipeline (emoji shortcodes, image 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 |
|
||||
| `/votes` | Upvote/downvote on posts, comments, projects |
|
||||
| `/reactions` | Emoji reactions on posts, comments, gists, projects |
|
||||
| `/bookmarks` | Save/unsave content; `/bookmarks/saved` personal list |
|
||||
@ -99,7 +99,7 @@ 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, 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 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.
|
||||
- **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`.
|
||||
@ -141,11 +141,11 @@ The farm refreshes live over the pub/sub bus (a watered build appears on the own
|
||||
- **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.py`). Existing accounts have their XP and levels backfilled once from prior activity at startup (`init_db()`).
|
||||
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 `PRAVDA_BASE_URL`, `PRAVDA_OPENAI_URL`, `PRAVDA_API_KEY`, `PRAVDA_USER_UID`, `PRAVDA_CONTAINER_NAME`, `PRAVDA_CONTAINER_UID`, and `PRAVDA_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`.
|
||||
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`.
|
||||
|
||||
## Admin: Audit Log
|
||||
|
||||
@ -166,6 +166,9 @@ The log is **administrator-only**. `/admin/audit-log` is a paginated, filterable
|
||||
| `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
|
||||
|
||||
@ -192,7 +195,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.py`) so they work everywhere with no per-route changes:
|
||||
(`utils/`) so they work everywhere with no per-route changes:
|
||||
|
||||
- **API key** - `X-API-KEY: <key>`
|
||||
- **Bearer** - `Authorization: Bearer <key>`
|
||||
@ -218,7 +221,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.py` 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/` 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
|
||||
@ -376,6 +379,8 @@ and its full configuration are documented automatically - including future servi
|
||||
|
||||
`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, PDF documents streamed and text-extracted, 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, 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).
|
||||
|
||||
### Adding a service
|
||||
@ -445,7 +450,7 @@ Configuration on the Services tab:
|
||||
| `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` / `30` | Idle pause window a bot takes after each action |
|
||||
| `bot_action_pause_min_seconds` / `bot_action_pause_max_seconds` | `5` / `45` | 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 |
|
||||
@ -581,9 +586,9 @@ Configuration on the Services tab:
|
||||
|
||||
| Parameter | Default | Purpose |
|
||||
|-----------|---------|---------|
|
||||
| `devii_ai_url` | `https://openai.app.molodetz.nl/v1/chat/completions` | OpenAI-compatible reasoning endpoint |
|
||||
| `devii_ai_url` | `http://localhost:10500/openai/v1/chat/completions` | OpenAI-compatible reasoning endpoint (defaults to the internal gateway) |
|
||||
| `devii_ai_model` | `molodetz` | Model name |
|
||||
| `devii_ai_key` | env fallback (`DEVII_AI_KEY`) | AI API key |
|
||||
| `devii_ai_key` | env fallback (`DEVII_AI_KEY`), then the gateway internal 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 |
|
||||
@ -716,7 +721,7 @@ installable Progressive Web App. Push uses only standard libraries (`cryptograph
|
||||
|
||||
### Events
|
||||
|
||||
Every event flows through a single funnel - `create_notification()` in `utils.py` -
|
||||
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
|
||||
@ -787,8 +792,8 @@ every page load. `PushManager.js` owns registration, subscription, and the opt-i
|
||||
|
||||
### PWA
|
||||
|
||||
`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
|
||||
`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
|
||||
network-first strategy for navigations and falls back to `static/offline.html` when
|
||||
offline. Installation requires a secure origin (HTTPS, or `localhost` for development).
|
||||
|
||||
@ -797,7 +802,6 @@ 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 |
|
||||
@ -843,7 +847,7 @@ Two background services bridge persisted state onto the bus so the interface upd
|
||||
|
||||
## Testing
|
||||
|
||||
- **932 tests** split into three tiers under `tests/`: `unit/` (pure in-process), `api/` (HTTP integration against the live server), and `e2e/` (Playwright browser)
|
||||
- **1959 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`
|
||||
@ -909,12 +913,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 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.
|
||||
- **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.
|
||||
- **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 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.
|
||||
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.
|
||||
|
||||
### nginx specifics
|
||||
|
||||
@ -941,7 +945,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 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`.
|
||||
`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`.
|
||||
|
||||
### Multi-worker safety
|
||||
|
||||
|
||||
1080
devplacepy/cli.py
1080
devplacepy/cli.py
File diff suppressed because it is too large
Load Diff
89
devplacepy/cli/__init__.py
Normal file
89
devplacepy/cli/__init__.py
Normal file
@ -0,0 +1,89 @@
|
||||
# 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
devplacepy/cli/__main__.py
Normal file
6
devplacepy/cli/__main__.py
Normal file
@ -0,0 +1,6 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from devplacepy.cli.main import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
18
devplacepy/cli/_shared.py
Normal file
18
devplacepy/cli/_shared.py
Normal file
@ -0,0 +1,18 @@
|
||||
# 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
devplacepy/cli/apikeys.py
Normal file
65
devplacepy/cli/apikeys.py
Normal file
@ -0,0 +1,65 @@
|
||||
# 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
devplacepy/cli/attachments.py
Normal file
43
devplacepy/cli/attachments.py
Normal file
@ -0,0 +1,43 @@
|
||||
# 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
devplacepy/cli/backups.py
Normal file
82
devplacepy/cli/backups.py
Normal file
@ -0,0 +1,82 @@
|
||||
# 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
devplacepy/cli/containers.py
Normal file
107
devplacepy/cli/containers.py
Normal file
@ -0,0 +1,107 @@
|
||||
# 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)
|
||||
66
devplacepy/cli/devii.py
Normal file
66
devplacepy/cli/devii.py
Normal file
@ -0,0 +1,66 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import sys
|
||||
from devplacepy.database import get_table
|
||||
from devplacepy.cli._shared import _audit_cli
|
||||
|
||||
|
||||
def cmd_devii_reset_quota(args):
|
||||
from devplacepy.database import db
|
||||
|
||||
table_name = "devii_usage_ledger"
|
||||
if table_name not in db.tables:
|
||||
print(f"Table '{table_name}' does not exist, nothing to reset")
|
||||
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 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)
|
||||
383
devplacepy/cli/jobs.py
Normal file
383
devplacepy/cli/jobs.py
Normal file
@ -0,0 +1,383 @@
|
||||
# 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
devplacepy/cli/main.py
Normal file
46
devplacepy/cli/main.py
Normal file
@ -0,0 +1,46 @@
|
||||
# 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
devplacepy/cli/migrate.py
Normal file
233
devplacepy/cli/migrate.py
Normal file
@ -0,0 +1,233 @@
|
||||
# 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
devplacepy/cli/news.py
Normal file
53
devplacepy/cli/news.py
Normal file
@ -0,0 +1,53 @@
|
||||
# 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
devplacepy/cli/roles.py
Normal file
57
devplacepy/cli/roles.py
Normal file
@ -0,0 +1,57 @@
|
||||
# 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
devplacepy/cli/tokens.py
Normal file
125
devplacepy/cli/tokens.py
Normal file
@ -0,0 +1,125 @@
|
||||
# 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)
|
||||
@ -26,6 +26,10 @@ 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"
|
||||
@ -43,6 +47,13 @@ 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"))
|
||||
|
||||
@ -89,6 +100,10 @@ DATA_PATHS: dict[str, Path] = {
|
||||
"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,6 +1,6 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
TOPICS = ["devlog", "showcase", "question", "rant", "fun", "random", "signals"]
|
||||
TOPICS = ["devlog", "showcase", "question", "rant", "fun", "random", "politics"]
|
||||
|
||||
REACTION_EMOJI = [
|
||||
"\U0001f44d",
|
||||
|
||||
@ -19,6 +19,7 @@ from devplacepy.database import (
|
||||
get_blocked_uids,
|
||||
get_poll_for_post,
|
||||
update_target_stars,
|
||||
clear_user_stars,
|
||||
get_target_owner_uid,
|
||||
resolve_object_url,
|
||||
soft_delete,
|
||||
@ -121,6 +122,10 @@ def create_content_item(
|
||||
**fields,
|
||||
}
|
||||
)
|
||||
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)
|
||||
@ -209,6 +214,8 @@ def apply_vote(request, user: dict, target_type: str, target_uid: str, value: in
|
||||
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"]:
|
||||
@ -570,9 +577,11 @@ def delete_content_item(
|
||||
soft_delete_engagement("comment", comment_uids, actor)
|
||||
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(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
233
devplacepy/database/__init__.py
Normal file
233
devplacepy/database/__init__.py
Normal file
@ -0,0 +1,233 @@
|
||||
# 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, build_pagination
|
||||
from .soft_delete import SOFT_DELETE_TABLES, ensure_soft_delete_columns, soft_delete, soft_delete_in, restore, purge, list_deleted, count_deleted, restore_event, purge_event
|
||||
from .engagement import _comment_count_cache, get_comment_counts_by_post_uids, get_post_counts_by_user_uids, get_vote_counts, get_user_votes, get_reactions_by_targets, get_user_bookmarks, get_polls_by_post_uids, get_poll_for_post
|
||||
from .usage import _add_usage, _get_usage, add_correction_usage, get_correction_usage, add_modifier_usage, get_modifier_usage, NEWS_USAGE_KEY, add_news_usage, get_news_usage, ISSUE_USAGE_KEY, add_issue_usage, get_issue_usage, SEO_USAGE_KEY, add_seo_usage, get_seo_usage
|
||||
from .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
|
||||
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",
|
||||
"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_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
devplacepy/database/activity.py
Normal file
182
devplacepy/database/activity.py
Normal file
@ -0,0 +1,182 @@
|
||||
# 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
devplacepy/database/attachments_data.py
Normal file
153
devplacepy/database/attachments_data.py
Normal file
@ -0,0 +1,153 @@
|
||||
# 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
|
||||
155
devplacepy/database/comments.py
Normal file
155
devplacepy/database/comments.py
Normal file
@ -0,0 +1,155 @@
|
||||
# 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}) 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
|
||||
124
devplacepy/database/content.py
Normal file
124
devplacepy/database/content.py
Normal file
@ -0,0 +1,124 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from .core import db, get_table, or_
|
||||
|
||||
|
||||
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}"
|
||||
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():
|
||||
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
|
||||
166
devplacepy/database/core.py
Normal file
166
devplacepy/database/core.py
Normal file
@ -0,0 +1,166 @@
|
||||
# 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
|
||||
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()
|
||||
row = next(
|
||||
iter(
|
||||
db.query(
|
||||
"SELECT version FROM cache_state WHERE name = :name", name=name
|
||||
)
|
||||
),
|
||||
None,
|
||||
)
|
||||
version = int(row["version"]) if row else 0
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not read cache version {name}: {e}")
|
||||
return 0
|
||||
_cache_version_cache.set(name, version)
|
||||
return version
|
||||
|
||||
|
||||
def bump_cache_version(name: str) -> None:
|
||||
try:
|
||||
_ensure_cache_state()
|
||||
db.query(
|
||||
"INSERT OR IGNORE INTO cache_state (name, version) VALUES (:name, 0)",
|
||||
name=name,
|
||||
)
|
||||
db.query(
|
||||
"UPDATE cache_state SET version = version + 1 WHERE name = :name", name=name
|
||||
)
|
||||
connection = db.executable
|
||||
if connection.in_transaction():
|
||||
connection.commit()
|
||||
_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
devplacepy/database/customization.py
Normal file
167
devplacepy/database/customization.py
Normal file
@ -0,0 +1,167 @@
|
||||
# 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
devplacepy/database/deepsearch.py
Normal file
119
devplacepy/database/deepsearch.py
Normal file
@ -0,0 +1,119 @@
|
||||
# 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
devplacepy/database/email.py
Normal file
95
devplacepy/database/email.py
Normal file
@ -0,0 +1,95 @@
|
||||
# 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
devplacepy/database/engagement.py
Normal file
187
devplacepy/database/engagement.py
Normal file
@ -0,0 +1,187 @@
|
||||
# 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
devplacepy/database/follows.py
Normal file
64
devplacepy/database/follows.py
Normal file
@ -0,0 +1,64 @@
|
||||
# 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
devplacepy/database/forks.py
Normal file
59
devplacepy/database/forks.py
Normal file
@ -0,0 +1,59 @@
|
||||
# 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)
|
||||
198
devplacepy/database/notifications.py
Normal file
198
devplacepy/database/notifications.py
Normal file
@ -0,0 +1,198 @@
|
||||
# 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"},
|
||||
]
|
||||
|
||||
|
||||
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)
|
||||
95
devplacepy/database/pagination.py
Normal file
95
devplacepy/database/pagination.py
Normal file
@ -0,0 +1,95 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from .core import db, get_table
|
||||
from .relations import get_blocked_uids
|
||||
|
||||
|
||||
PAGE_SIZE = 25
|
||||
|
||||
|
||||
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 get_user_post_count(user_uid: str) -> int:
|
||||
if "posts" not in db.tables:
|
||||
return 0
|
||||
return get_table("posts").count(user_uid=user_uid, deleted_at=None)
|
||||
|
||||
|
||||
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,
|
||||
}
|
||||
180
devplacepy/database/ranking.py
Normal file
180
devplacepy/database/ranking.py
Normal file
@ -0,0 +1,180 @@
|
||||
# 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=15, 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
|
||||
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
|
||||
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
devplacepy/database/relations.py
Normal file
45
devplacepy/database/relations.py
Normal file
@ -0,0 +1,45 @@
|
||||
# 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")
|
||||
1330
devplacepy/database/schema.py
Normal file
1330
devplacepy/database/schema.py
Normal file
File diff suppressed because it is too large
Load Diff
87
devplacepy/database/seo_meta.py
Normal file
87
devplacepy/database/seo_meta.py
Normal file
@ -0,0 +1,87 @@
|
||||
# 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
devplacepy/database/settings.py
Normal file
48
devplacepy/database/settings.py
Normal file
@ -0,0 +1,48 @@
|
||||
# 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")
|
||||
187
devplacepy/database/soft_delete.py
Normal file
187
devplacepy/database/soft_delete.py
Normal file
@ -0,0 +1,187 @@
|
||||
# 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",
|
||||
]
|
||||
|
||||
|
||||
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}"
|
||||
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"]
|
||||
)
|
||||
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))
|
||||
db.query(
|
||||
f"DELETE FROM {table_name} WHERE deleted_at = :s", s=stamp
|
||||
)
|
||||
return purged
|
||||
151
devplacepy/database/stats.py
Normal file
151
devplacepy/database/stats.py
Normal file
@ -0,0 +1,151 @@
|
||||
# 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
|
||||
128
devplacepy/database/usage.py
Normal file
128
devplacepy/database/usage.py
Normal file
@ -0,0 +1,128 @@
|
||||
# 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)
|
||||
108
devplacepy/database/users.py
Normal file
108
devplacepy/database/users.py
Normal file
@ -0,0 +1,108 @@
|
||||
# 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]
|
||||
File diff suppressed because it is too large
Load Diff
41
devplacepy/docs_api/__init__.py
Normal file
41
devplacepy/docs_api/__init__.py
Normal file
@ -0,0 +1,41 @@
|
||||
# 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
devplacepy/docs_api/_shared.py
Normal file
98
devplacepy/docs_api/_shared.py
Normal file
@ -0,0 +1,98 @@
|
||||
# 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
devplacepy/docs_api/groups/__init__.py
Normal file
43
devplacepy/docs_api/groups/__init__.py
Normal file
@ -0,0 +1,43 @@
|
||||
# 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,
|
||||
]
|
||||
740
devplacepy/docs_api/groups/admin.py
Normal file
740
devplacepy/docs_api/groups/admin.py
Normal file
@ -0,0 +1,740 @@
|
||||
# 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-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-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
devplacepy/docs_api/groups/auth.py
Normal file
139
devplacepy/docs_api/groups/auth.py
Normal file
@ -0,0 +1,139 @@
|
||||
# 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,
|
||||
),
|
||||
],
|
||||
}
|
||||
580
devplacepy/docs_api/groups/containers.py
Normal file
580
devplacepy/docs_api/groups/containers.py
Normal file
@ -0,0 +1,580 @@
|
||||
# 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 (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"}]},
|
||||
),
|
||||
],
|
||||
}
|
||||
580
devplacepy/docs_api/groups/containers.py.bak
Normal file
580
devplacepy/docs_api/groups/containers.py.bak
Normal file
@ -0,0 +1,580 @@
|
||||
# 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
devplacepy/docs_api/groups/content.py
Normal file
946
devplacepy/docs_api/groups/content.py
Normal file
@ -0,0 +1,946 @@
|
||||
# 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
devplacepy/docs_api/groups/conventions.py
Normal file
152
devplacepy/docs_api/groups/conventions.py
Normal file
@ -0,0 +1,152 @@
|
||||
# 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
devplacepy/docs_api/groups/game.py
Normal file
195
devplacepy/docs_api/groups/game.py
Normal file
@ -0,0 +1,195 @@
|
||||
# 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}},
|
||||
),
|
||||
],
|
||||
}
|
||||
195
devplacepy/docs_api/groups/gateway.py
Normal file
195
devplacepy/docs_api/groups/gateway.py
Normal file
@ -0,0 +1,195 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from .._shared import endpoint, field
|
||||
|
||||
GROUP = {
|
||||
"slug": "gateway",
|
||||
"title": "OpenAI Gateway",
|
||||
"admin": True,
|
||||
"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.
|
||||
|
||||
## 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`, 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
|
||||
one denied path that makes no upstream call (embeddings disabled) returns no usage headers.
|
||||
|
||||
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="user",
|
||||
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 uses the 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="user",
|
||||
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).",
|
||||
],
|
||||
),
|
||||
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="user",
|
||||
interactive=False,
|
||||
params=[
|
||||
field(
|
||||
"path",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"models",
|
||||
"Upstream API path after /v1/.",
|
||||
)
|
||||
],
|
||||
),
|
||||
],
|
||||
}
|
||||
298
devplacepy/docs_api/groups/issues.py
Normal file
298
devplacepy/docs_api/groups/issues.py
Normal file
@ -0,0 +1,298 @@
|
||||
# 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
devplacepy/docs_api/groups/lookups.py
Normal file
61
devplacepy/docs_api/groups/lookups.py
Normal file
@ -0,0 +1,61 @@
|
||||
# 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
devplacepy/docs_api/groups/messaging.py
Normal file
75
devplacepy/docs_api/groups/messaging.py
Normal file
@ -0,0 +1,75 @@
|
||||
# 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.",
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
}
|
||||
89
devplacepy/docs_api/groups/notifications.py
Normal file
89
devplacepy/docs_api/groups/notifications.py
Normal file
@ -0,0 +1,89 @@
|
||||
# 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",
|
||||
),
|
||||
],
|
||||
}
|
||||
683
devplacepy/docs_api/groups/profiles.py
Normal file
683
devplacepy/docs_api/groups/profiles.py
Normal file
@ -0,0 +1,683 @@
|
||||
# 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"],
|
||||
),
|
||||
],
|
||||
),
|
||||
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"],
|
||||
),
|
||||
],
|
||||
),
|
||||
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 2000 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-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 2000 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-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="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
devplacepy/docs_api/groups/project_files.py
Normal file
550
devplacepy/docs_api/groups/project_files.py
Normal file
@ -0,0 +1,550 @@
|
||||
# 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
devplacepy/docs_api/groups/push.py
Normal file
66
devplacepy/docs_api/groups/push.py
Normal file
@ -0,0 +1,66 @@
|
||||
# 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
devplacepy/docs_api/groups/services.py
Normal file
10
devplacepy/docs_api/groups/services.py
Normal file
@ -0,0 +1,10 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
GROUP = {
|
||||
"slug": "services",
|
||||
"title": "Background Services",
|
||||
"admin": True,
|
||||
"dynamic": True,
|
||||
"intro": "",
|
||||
"endpoints": [],
|
||||
}
|
||||
195
devplacepy/docs_api/groups/social_actions.py
Normal file
195
devplacepy/docs_api/groups/social_actions.py
Normal file
@ -0,0 +1,195 @@
|
||||
# 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",
|
||||
},
|
||||
),
|
||||
],
|
||||
}
|
||||
422
devplacepy/docs_api/groups/tools.py
Normal file
422
devplacepy/docs_api/groups/tools.py
Normal file
@ -0,0 +1,422 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from .._shared import endpoint, field
|
||||
|
||||
GROUP = {
|
||||
"slug": "tools",
|
||||
"title": "Tools (SEO, DeepSearch & AI Usage Analyzer)",
|
||||
"intro": """
|
||||
# Tools: SEO Diagnostics & DeepSearch
|
||||
|
||||
Two public developer tools that run as background jobs.
|
||||
|
||||
**SEO Diagnostics** audits a URL or sitemap with a headless browser and runs a broad battery of
|
||||
technical, on-page, structured-data, Core Web Vitals, accessibility and AI-readiness checks.
|
||||
|
||||
**DeepSearch** is a multi-agent deep web researcher that crawls and indexes sources, then
|
||||
synthesises a cited report with confidence scoring and gap analysis, plus a grounded chat over
|
||||
the results.
|
||||
|
||||
**AI Usage Analyzer** classifies a git repository or website as AI slop, sophisticated AI-assisted
|
||||
work or genuine human work, and publishes a persistent report with an embeddable authenticity
|
||||
badge.
|
||||
|
||||
Every endpoint follows the shared [Conventions & Errors](/docs/conventions.html). These are
|
||||
**capability URLs**: the job `uid` is an unguessable identifier, so anyone holding it can read the
|
||||
status and report.
|
||||
""",
|
||||
"endpoints": [
|
||||
endpoint(
|
||||
id="tools-seo-run",
|
||||
method="POST",
|
||||
path="/tools/seo/run",
|
||||
title="Queue an SEO audit",
|
||||
summary="Start a background SEO audit of a URL or sitemap. Returns the job uid plus status and websocket URLs.",
|
||||
auth="public",
|
||||
encoding="form",
|
||||
params=[
|
||||
field("url", "form", "string", True, "https://example.com", "Page URL or sitemap.xml URL to audit."),
|
||||
field("mode", "form", "enum", False, "url", "'url' (single page) or 'sitemap' (crawl).", ["url", "sitemap"]),
|
||||
field("max_pages", "form", "integer", False, "10", "Max pages to crawl in sitemap mode (1-50)."),
|
||||
],
|
||||
sample_response={
|
||||
"uid": "SEO_JOB_UID",
|
||||
"status_url": "/tools/seo/SEO_JOB_UID",
|
||||
"ws_url": "/tools/seo/SEO_JOB_UID/ws",
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="tools-seo-status",
|
||||
method="GET",
|
||||
path="/tools/seo/{uid}",
|
||||
title="SEO audit status",
|
||||
summary="Poll an SEO audit. Once done, score, grade and report_url are populated.",
|
||||
auth="public",
|
||||
params=[
|
||||
field("uid", "path", "string", True, "SEO_JOB_UID", "SEO job uid returned when the audit was queued."),
|
||||
],
|
||||
sample_response={
|
||||
"uid": "SEO_JOB_UID",
|
||||
"kind": "seo",
|
||||
"status": "done",
|
||||
"target": "https://example.com",
|
||||
"mode": "url",
|
||||
"ws_url": "/tools/seo/SEO_JOB_UID/ws",
|
||||
"report_url": "/tools/seo/SEO_JOB_UID/report",
|
||||
"score": 82,
|
||||
"grade": "B",
|
||||
"page_count": 1,
|
||||
"error": None,
|
||||
"created_at": "2026-06-14T10:00:00+00:00",
|
||||
"completed_at": "2026-06-14T10:00:18+00:00",
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="tools-seo-report",
|
||||
method="GET",
|
||||
path="/tools/seo/{uid}/report",
|
||||
title="SEO audit report",
|
||||
summary="Full categorised report: overall score, per-category subscores, and every check with its recommendation. Negotiates HTML or JSON.",
|
||||
auth="public",
|
||||
params=[
|
||||
field("uid", "path", "string", True, "SEO_JOB_UID", "SEO job uid of a finished audit."),
|
||||
],
|
||||
sample_response={
|
||||
"uid": "SEO_JOB_UID",
|
||||
"status": "done",
|
||||
"target": "https://example.com",
|
||||
"score": 82,
|
||||
"grade": "B",
|
||||
"page_count": 1,
|
||||
"counts": {"pass": 40, "warn": 8, "fail": 3, "info": 5, "skip": 0},
|
||||
"categories": {"crawlability": {"score": 90, "pass": 9, "warn": 1, "fail": 0}},
|
||||
"pages": [{"url": "https://example.com", "status": 200, "score": 82, "grade": "B"}],
|
||||
"checks": [
|
||||
{
|
||||
"id": "meta.title_present",
|
||||
"category": "meta",
|
||||
"title": "Title tag",
|
||||
"status": "pass",
|
||||
"severity": "high",
|
||||
"value": "Example Domain",
|
||||
"recommendation": "",
|
||||
"url": "https://example.com",
|
||||
}
|
||||
],
|
||||
"site": {"robots": {"status": 200}, "sitemap": {"status": 200, "url_count": 12}},
|
||||
"generated_at": "2026-06-14T10:00:18+00:00",
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="tools-seo-screenshot",
|
||||
method="GET",
|
||||
path="/tools/seo/{uid}/screenshot/{index}",
|
||||
title="SEO audit page screenshot",
|
||||
summary="Stream the rendered screenshot (image/png) captured for the audited page at the given zero-based index.",
|
||||
auth="public",
|
||||
interactive=True,
|
||||
params=[
|
||||
field("uid", "path", "string", True, "SEO_JOB_UID", "SEO job uid of a finished audit."),
|
||||
field("index", "path", "integer", True, "0", "Zero-based index of the audited page."),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="tools-seo-meta-status",
|
||||
method="GET",
|
||||
path="/tools/seo-meta/{target_type}/{target_uid}",
|
||||
title="Generated SEO metadata for a content item",
|
||||
summary="Read the clean, AI-generated SEO title, description and keywords for a published post, project, gist, news article or issue. Returns a plain-content default with status 'pending' until the AI value is ready.",
|
||||
auth="public",
|
||||
params=[
|
||||
field("target_type", "path", "enum", True, "post", "Content type.", ["post", "project", "gist", "news", "issue"]),
|
||||
field("target_uid", "path", "string", True, "CONTENT_UID", "The content uid (or issue number)."),
|
||||
],
|
||||
sample_response={
|
||||
"uid": "SEO_META_UID",
|
||||
"target_type": "post",
|
||||
"target_uid": "CONTENT_UID",
|
||||
"seo_title": "Building a fast SQLite social network",
|
||||
"seo_description": "How DevPlace keeps SQLite synchronous and still serves a developer social network fast, with WAL, mmap and batch query helpers.",
|
||||
"seo_keywords": "sqlite, fastapi, social network, performance, wal",
|
||||
"status": "ready",
|
||||
"source": "ai",
|
||||
"generated_at": "2026-06-14T10:00:18+00:00",
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="tools-deepsearch-run",
|
||||
method="POST",
|
||||
path="/tools/deepsearch/run",
|
||||
title="Queue a DeepSearch research job",
|
||||
summary="Start a multi-agent deep web research job. Returns the job uid plus status and websocket URLs. Connect ws_url for live progress frames.",
|
||||
auth="public",
|
||||
encoding="form",
|
||||
params=[
|
||||
field("query", "form", "string", True, "history of the transistor", "The research question to investigate."),
|
||||
field("depth", "form", "integer", False, "2", "Research depth (1-4)."),
|
||||
field("max_pages", "form", "integer", False, "12", "Maximum sources to crawl (1-30)."),
|
||||
],
|
||||
sample_response={
|
||||
"uid": "DEEPSEARCH_JOB_UID",
|
||||
"status_url": "/tools/deepsearch/DEEPSEARCH_JOB_UID",
|
||||
"ws_url": "/tools/deepsearch/DEEPSEARCH_JOB_UID/ws",
|
||||
"progress_frames_note": (
|
||||
"The ws_url stream emits newline-delimited JSON frames; the set is append-only "
|
||||
"and the first frame carries version:1. Each frame has a type: phase "
|
||||
"(phase, index, total, label), stage, substep (planning angles), queries, "
|
||||
"candidates, rsearch, progress (done, total, url), page_loaded (source, render, "
|
||||
"elapsed_ms, done, total), page_cached, page_skipped (reason), page_duplicate, "
|
||||
"embed_batch (batch, total_batches, backend, done, total), embed_done (backend, "
|
||||
"chunk_count), agent (agent, status start|done, elapsed_ms, tokens_in, tokens_out), "
|
||||
"report_ready, done (session_url), failed (message)."
|
||||
),
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="tools-deepsearch-status",
|
||||
method="GET",
|
||||
path="/tools/deepsearch/{uid}",
|
||||
title="DeepSearch status",
|
||||
summary="Poll a DeepSearch job. Once done, score, confidence and session_url are populated.",
|
||||
auth="public",
|
||||
params=[
|
||||
field("uid", "path", "string", True, "DEEPSEARCH_JOB_UID", "DeepSearch job uid returned when the run was queued."),
|
||||
],
|
||||
sample_response={
|
||||
"uid": "DEEPSEARCH_JOB_UID",
|
||||
"kind": "deepsearch",
|
||||
"status": "done",
|
||||
"query": "history of the transistor",
|
||||
"depth": 2,
|
||||
"max_pages": 12,
|
||||
"ws_url": "/tools/deepsearch/DEEPSEARCH_JOB_UID/ws",
|
||||
"chat_ws_url": "/tools/deepsearch/DEEPSEARCH_JOB_UID/chat",
|
||||
"session_url": "/tools/deepsearch/DEEPSEARCH_JOB_UID/session",
|
||||
"score": 78,
|
||||
"confidence": 0.72,
|
||||
"source_diversity": 0.64,
|
||||
"page_count": 11,
|
||||
"chunk_count": 240,
|
||||
"error": None,
|
||||
"created_at": "2026-06-14T10:00:00+00:00",
|
||||
"completed_at": "2026-06-14T10:01:40+00:00",
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="tools-deepsearch-session",
|
||||
method="GET",
|
||||
path="/tools/deepsearch/{uid}/session",
|
||||
title="DeepSearch report",
|
||||
summary="Full cited research report: summary, findings, gaps, sources and metrics. Negotiates HTML or JSON.",
|
||||
auth="public",
|
||||
params=[
|
||||
field("uid", "path", "string", True, "DEEPSEARCH_JOB_UID", "DeepSearch job uid of a finished run."),
|
||||
],
|
||||
sample_response={
|
||||
"uid": "DEEPSEARCH_JOB_UID",
|
||||
"status": "done",
|
||||
"query": "history of the transistor",
|
||||
"score": 78,
|
||||
"confidence": 0.72,
|
||||
"source_diversity": 0.64,
|
||||
"page_count": 11,
|
||||
"chunk_count": 240,
|
||||
"summary": "The transistor was invented at Bell Labs in 1947...",
|
||||
"findings": [
|
||||
{"title": "Invention", "detail": "...", "confidence": 0.8, "citations": [1]}
|
||||
],
|
||||
"gaps": ["Limited coverage of later MOSFET developments."],
|
||||
"sources": [{"url": "https://example.com", "title": "Example", "source": "httpx"}],
|
||||
"chat_ws_url": "/tools/deepsearch/DEEPSEARCH_JOB_UID/chat",
|
||||
"export_md_url": "/tools/deepsearch/DEEPSEARCH_JOB_UID/export.md",
|
||||
"export_json_url": "/tools/deepsearch/DEEPSEARCH_JOB_UID/export.json",
|
||||
"export_pdf_url": "/tools/deepsearch/DEEPSEARCH_JOB_UID/export.pdf",
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="tools-isslop-run",
|
||||
method="POST",
|
||||
path="/tools/isslop/run",
|
||||
title="Queue a AI usage analysis",
|
||||
summary="Start a background authenticity analysis of a git repository or website. Returns the job uid plus status, events and report URLs.",
|
||||
auth="public",
|
||||
encoding="form",
|
||||
params=[
|
||||
field("url", "form", "string", True, "https://github.com/owner/repository", "Repository (http/git/ssh) or website URL to classify."),
|
||||
],
|
||||
sample_response={
|
||||
"uid": "ISSLOP_UID",
|
||||
"status_url": "/tools/isslop/ISSLOP_UID",
|
||||
"events_url": "/tools/isslop/ISSLOP_UID/events",
|
||||
"report_url": "/tools/isslop/ISSLOP_UID/report",
|
||||
"topic": "public.isslop.ISSLOP_UID",
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="tools-isslop-list",
|
||||
method="GET",
|
||||
path="/tools/isslop/list",
|
||||
title="My AI usage analyses",
|
||||
summary="List the caller's analyses, newest first. Member history is account-bound; guest history is session-bound and claimed by the account on first signed-in call.",
|
||||
auth="public",
|
||||
params=[
|
||||
field("limit", "query", "integer", False, "50", "Maximum analyses to return (1-200)."),
|
||||
],
|
||||
sample_response={
|
||||
"analyses": [
|
||||
{
|
||||
"uid": "ISSLOP_UID",
|
||||
"status": "completed",
|
||||
"source_url": "https://github.com/owner/repository",
|
||||
"source_kind": "git",
|
||||
"grade": "B",
|
||||
"human_percent": 71.4,
|
||||
"ai_percent": 28.6,
|
||||
"category": "human-clean",
|
||||
"report_url": "/tools/isslop/ISSLOP_UID/report",
|
||||
"badge_url": "/tools/isslop/ISSLOP_UID/badge.svg",
|
||||
}
|
||||
]
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="tools-isslop-status",
|
||||
method="GET",
|
||||
path="/tools/isslop/{uid}",
|
||||
title="AI usage analysis status",
|
||||
summary="Poll an analysis. Once completed, grade, category and the human/AI split are populated.",
|
||||
auth="public",
|
||||
params=[
|
||||
field("uid", "path", "string", True, "ISSLOP_UID", "Analysis uid returned when the run was queued."),
|
||||
],
|
||||
sample_response={
|
||||
"uid": "ISSLOP_UID",
|
||||
"status": "completed",
|
||||
"source_url": "https://github.com/owner/repository",
|
||||
"source_kind": "git",
|
||||
"grade": "B",
|
||||
"slop_score": 31.2,
|
||||
"origin_score": 28.0,
|
||||
"quality_deficit_score": 22.5,
|
||||
"human_percent": 71.4,
|
||||
"ai_percent": 28.6,
|
||||
"category": "human-clean",
|
||||
"confidence": "medium",
|
||||
"files_total": 120,
|
||||
"files_analyzed": 96,
|
||||
"report_url": "/tools/isslop/ISSLOP_UID/report",
|
||||
"badge_url": "/tools/isslop/ISSLOP_UID/badge.svg",
|
||||
"events_url": "/tools/isslop/ISSLOP_UID/events",
|
||||
"topic": "public.isslop.ISSLOP_UID",
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="tools-isslop-events",
|
||||
method="GET",
|
||||
path="/tools/isslop/{uid}/events",
|
||||
title="AI usage analysis event trail",
|
||||
summary="The persisted, ordered event trail of an analysis. Use ?after=SEQ to poll incrementally; live frames also stream on the pub/sub topic.",
|
||||
auth="public",
|
||||
params=[
|
||||
field("uid", "path", "string", True, "ISSLOP_UID", "Analysis uid."),
|
||||
field("after", "query", "integer", False, "0", "Return only events with a sequence number greater than this."),
|
||||
field("limit", "query", "integer", False, "2000", "Maximum events to return (1-5000)."),
|
||||
],
|
||||
sample_response={
|
||||
"uid": "ISSLOP_UID",
|
||||
"status": "running",
|
||||
"events": [
|
||||
{"seq": 1, "kind": "stage", "message": "Resolving source type", "data": {"stage": "resolve"}, "created_at": "2026-06-14T10:00:00+00:00"}
|
||||
],
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="tools-isslop-report",
|
||||
method="GET",
|
||||
path="/tools/isslop/{uid}/report",
|
||||
title="AI usage analysis report",
|
||||
summary="Full report: verdict, markdown body, per-file results, image review and badge embeds. Negotiates HTML or JSON.",
|
||||
auth="public",
|
||||
params=[
|
||||
field("uid", "path", "string", True, "ISSLOP_UID", "Analysis uid of a finished run."),
|
||||
],
|
||||
sample_response={
|
||||
"uid": "ISSLOP_UID",
|
||||
"status": "completed",
|
||||
"source_url": "https://github.com/owner/repository",
|
||||
"grade": "B",
|
||||
"human_percent": 71.4,
|
||||
"ai_percent": 28.6,
|
||||
"category": "human-clean",
|
||||
"markdown": "# Verdict...",
|
||||
"generator_model": "molodetz",
|
||||
"badge": {
|
||||
"badge_url": "https://devplace.example/tools/isslop/ISSLOP_UID/badge.svg",
|
||||
"report_url": "https://devplace.example/tools/isslop/ISSLOP_UID/report",
|
||||
"markdown": "[](...)",
|
||||
"html": "<a href=...><img src=.../></a>",
|
||||
},
|
||||
"files": [{"path": "src/main.py", "language": "python", "lines": 120, "origin_score": 35.0, "quality_deficit_score": 18.0, "category": "human-clean", "signals": []}],
|
||||
"images": [{"path": "assets/hero.png", "ai_probability": 84.0, "grade": "F", "verdict": "ai-generated", "image_kind": "illustration", "tells": ["waxy skin"], "description": "...", "thumb_url": "/tools/isslop/ISSLOP_UID/media/0f3a9c2d1b4e5a67.webp"}],
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="tools-isslop-report-md",
|
||||
method="GET",
|
||||
path="/tools/isslop/{uid}/report.md",
|
||||
title="Download report markdown",
|
||||
summary="Download the full report as a markdown file.",
|
||||
auth="public",
|
||||
params=[
|
||||
field("uid", "path", "string", True, "ISSLOP_UID", "Analysis uid of a finished run."),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="tools-isslop-source",
|
||||
method="GET",
|
||||
path="/tools/isslop/{uid}/source",
|
||||
title="Annotated source of a flagged file",
|
||||
summary="The persisted source of a signal-bearing file with its signals, rendered with line numbers and highlighted findings (HTML) or as JSON. Linked from the report's file table, signal chips and prose.",
|
||||
auth="public",
|
||||
params=[
|
||||
field("uid", "path", "string", True, "ISSLOP_UID", "Analysis uid."),
|
||||
field("path", "query", "string", True, "src/libs/Env.ts", "Workspace-relative file path from the report."),
|
||||
field("line", "query", "integer", False, "12", "Line to focus and highlight."),
|
||||
],
|
||||
sample_response={
|
||||
"uid": "ISSLOP_UID",
|
||||
"path": "src/libs/Env.ts",
|
||||
"language": "typescript",
|
||||
"category": "human-clean",
|
||||
"origin_score": 24.0,
|
||||
"quality_deficit_score": 34.9,
|
||||
"source": "import { createEnv } from '@t3-oss/env-nextjs';...",
|
||||
"truncated": False,
|
||||
"signals": [{"code": "PUBLIC_ENV_SECRET", "title": "Secret exposed via public env variable", "severity": "strong", "line": 12}],
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="tools-isslop-media",
|
||||
method="GET",
|
||||
path="/tools/isslop/{uid}/media/{name}",
|
||||
title="Reviewed image thumbnail",
|
||||
summary="Aspect-preserving WebP thumbnail of a reviewed image, persisted as evidence. The name comes from the report's images[].thumb_url.",
|
||||
auth="public",
|
||||
params=[
|
||||
field("uid", "path", "string", True, "ISSLOP_UID", "Analysis uid."),
|
||||
field("name", "path", "string", True, "0f3a9c2d1b4e5a67.webp", "Thumbnail file name from the report."),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="tools-isslop-badge",
|
||||
method="GET",
|
||||
path="/tools/isslop/{uid}/badge.svg",
|
||||
title="Authenticity badge",
|
||||
summary="Embeddable SVG badge showing the human score and authenticity grade, linking to the report.",
|
||||
auth="public",
|
||||
params=[
|
||||
field("uid", "path", "string", True, "ISSLOP_UID", "Analysis uid."),
|
||||
],
|
||||
),
|
||||
],
|
||||
}
|
||||
106
devplacepy/docs_api/groups/uploads.py
Normal file
106
devplacepy/docs_api/groups/uploads.py
Normal file
@ -0,0 +1,106 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from .._shared import endpoint, field
|
||||
|
||||
GROUP = {
|
||||
"slug": "uploads",
|
||||
"title": "Uploads",
|
||||
"intro": """
|
||||
# Uploads
|
||||
|
||||
Attachment storage. Upload a file - or hand the server a public URL to fetch - to receive an
|
||||
attachment record, then reference its `uid` in an `attachment_uids` field when creating a post,
|
||||
comment, project, gist, message, or issue - see
|
||||
[Posts, Comments, Projects, Gists & News](/docs/content.html). Images and videos embed and
|
||||
play inline once posted; other types render as download links. The record's `is_image` and
|
||||
`is_video` flags indicate how the file is displayed.
|
||||
|
||||
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="uploads-upload",
|
||||
method="POST",
|
||||
path="/uploads/upload",
|
||||
title="Upload a file",
|
||||
summary="Store a file and return its attachment record.",
|
||||
auth="user",
|
||||
encoding="multipart",
|
||||
params=[field("file", "form", "file", True, "", "The file to upload.")],
|
||||
notes=[
|
||||
"Allowed file types and the size limit are configured by administrators. Images and common video formats (mp4, webm, ogv, mov, m4v) are accepted by default.",
|
||||
"Returns `201` on success, `413` if too large, `415` if the type is not allowed.",
|
||||
],
|
||||
sample_response={
|
||||
"uid": "ATTACHMENT_UID",
|
||||
"filename": "clip.mp4",
|
||||
"url": "/static/uploads/attachments/ab/cd/ATTACHMENT_UID.mp4",
|
||||
"size": 20480,
|
||||
"is_image": False,
|
||||
"is_video": True,
|
||||
"mime_type": "video/mp4",
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="uploads-upload-url",
|
||||
method="POST",
|
||||
path="/uploads/upload-url",
|
||||
title="Attach a file from a URL",
|
||||
summary="Download a public URL on the server and store it as an attachment.",
|
||||
auth="user",
|
||||
params=[
|
||||
field(
|
||||
"url",
|
||||
"form",
|
||||
"string",
|
||||
True,
|
||||
"https://example.com/photo.png",
|
||||
"Public http(s) URL of the file to download and attach.",
|
||||
),
|
||||
field(
|
||||
"filename",
|
||||
"form",
|
||||
"string",
|
||||
False,
|
||||
"photo.png",
|
||||
"Optional filename with an allowed extension, used when the URL has no clear name.",
|
||||
),
|
||||
],
|
||||
notes=[
|
||||
"The server fetches the URL (SSRF-guarded, size-capped) and stores the bytes through the same pipeline as a direct upload; the response is identical to Upload a file.",
|
||||
"The file type is taken from the URL path or the response Content-Type. Returns `201` on success, `413` if too large, `415` if the type cannot be resolved to an allowed type, `400` for an unreachable or private address.",
|
||||
],
|
||||
sample_response={
|
||||
"uid": "ATTACHMENT_UID",
|
||||
"filename": "photo.png",
|
||||
"url": "/static/uploads/attachments/ab/cd/ATTACHMENT_UID.png",
|
||||
"size": 20480,
|
||||
"is_image": True,
|
||||
"is_video": False,
|
||||
"mime_type": "image/png",
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="uploads-delete",
|
||||
method="DELETE",
|
||||
path="/uploads/delete/{attachment_uid}",
|
||||
title="Delete an attachment",
|
||||
summary="Delete an attachment you own; administrators may delete any user's attachment. Soft-deleted (hidden everywhere but restorable; garbage-collected later).",
|
||||
auth="user",
|
||||
destructive=True,
|
||||
params=[
|
||||
field(
|
||||
"attachment_uid",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"ATTACHMENT_UID",
|
||||
"UID of the attachment.",
|
||||
)
|
||||
],
|
||||
sample_response={"status": "deleted"},
|
||||
),
|
||||
],
|
||||
}
|
||||
123
devplacepy/docs_api/negotiation.py
Normal file
123
devplacepy/docs_api/negotiation.py
Normal file
@ -0,0 +1,123 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from devplacepy import schemas
|
||||
from devplacepy.docs_examples import schema_example, action_example
|
||||
from ._shared import NON_BODY_ENDPOINTS
|
||||
|
||||
|
||||
def _classify(ep):
|
||||
if ep["id"] in _PAGE_RESPONSES or ep["id"] in _ACTION_RESPONSES:
|
||||
return "negotiable"
|
||||
if ep.get("ajax"):
|
||||
return "ajax"
|
||||
if ep["id"] in NON_BODY_ENDPOINTS:
|
||||
return "none"
|
||||
return "json"
|
||||
|
||||
|
||||
_PAGE_RESPONSES = {
|
||||
"auth-signup": schemas.AuthPageOut,
|
||||
"auth-login": schemas.AuthPageOut,
|
||||
"auth-forgot-password": schemas.AuthPageOut,
|
||||
"auth-reset-password": schemas.AuthPageOut,
|
||||
"home": schemas.LandingOut,
|
||||
"feed-list": schemas.FeedOut,
|
||||
"posts-detail": schemas.PostDetailOut,
|
||||
"projects-list": schemas.ProjectsOut,
|
||||
"projects-detail": schemas.ProjectDetailOut,
|
||||
"gists-list": schemas.GistsOut,
|
||||
"gists-detail": schemas.GistDetailOut,
|
||||
"news-list": schemas.NewsListOut,
|
||||
"news-detail": schemas.NewsDetailOut,
|
||||
"profile-detail": schemas.ProfileOut,
|
||||
"leaderboard": schemas.LeaderboardOut,
|
||||
"messages-inbox": schemas.MessagesOut,
|
||||
"notifications-list": schemas.NotificationsOut,
|
||||
"issues-list": schemas.IssuesOut,
|
||||
"issues-detail": schemas.IssueDetailOut,
|
||||
"issues-attachments-list": schemas.IssueAttachmentsOut,
|
||||
"bookmarks-saved": schemas.SavedOut,
|
||||
"admin-users": schemas.AdminUsersOut,
|
||||
"admin-news-list": schemas.AdminNewsOut,
|
||||
"admin-settings-get": schemas.AdminSettingsOut,
|
||||
"admin-audit-log": schemas.AuditLogOut,
|
||||
"admin-audit-event": schemas.AuditEventOut,
|
||||
"admin-bots-monitor": schemas.AdminBotsOut,
|
||||
"containers-admin-edit-page": schemas.AdminContainerEditOut,
|
||||
}
|
||||
|
||||
_ACTION_RESPONSES = {
|
||||
"auth-signup-post": ("/feed", {"username": "alice"}),
|
||||
"auth-login-post": ("/feed", None),
|
||||
"auth-forgot-password-post": ("/auth/forgot-password?sent=1", None),
|
||||
"auth-reset-password-post": ("/auth/login", None),
|
||||
"auth-logout": ("/", None),
|
||||
"posts-create": (
|
||||
"/posts/POST_SLUG",
|
||||
{"uid": "POST_UID", "slug": "POST_SLUG", "url": "/posts/POST_SLUG"},
|
||||
),
|
||||
"posts-edit": (
|
||||
"/posts/POST_SLUG",
|
||||
{"uid": "POST_UID", "slug": "POST_SLUG", "url": "/posts/POST_SLUG"},
|
||||
),
|
||||
"posts-delete": ("/feed", None),
|
||||
"comments-create": (
|
||||
"/posts/POST_SLUG#comment-COMMENT_UID",
|
||||
{"uid": "COMMENT_UID", "url": "/posts/POST_SLUG#comment-COMMENT_UID"},
|
||||
),
|
||||
"comments-delete": (
|
||||
"/posts/POST_SLUG",
|
||||
{"deleted_uid": "COMMENT_UID", "target_type": "post", "target_uid": "TARGET_UID"},
|
||||
),
|
||||
"projects-create": (
|
||||
"/projects/PROJECT_SLUG",
|
||||
{"uid": "PROJECT_UID", "slug": "PROJECT_SLUG", "url": "/projects/PROJECT_SLUG"},
|
||||
),
|
||||
"projects-delete": ("/projects", None),
|
||||
"gists-create": (
|
||||
"/gists/GIST_SLUG",
|
||||
{"uid": "GIST_UID", "slug": "GIST_SLUG", "url": "/gists/GIST_SLUG"},
|
||||
),
|
||||
"gists-edit": (
|
||||
"/gists/GIST_SLUG",
|
||||
{"uid": "GIST_UID", "slug": "GIST_SLUG", "url": "/gists/GIST_SLUG"},
|
||||
),
|
||||
"gists-delete": ("/gists", None),
|
||||
"profile-update": ("/profile/YOUR_USERNAME", None),
|
||||
"profile-customization-global": (
|
||||
"/profile/YOUR_USERNAME",
|
||||
{"url": "/profile/YOUR_USERNAME", "cust_disable_global": True},
|
||||
),
|
||||
"profile-customization-pagetype": (
|
||||
"/profile/YOUR_USERNAME",
|
||||
{"url": "/profile/YOUR_USERNAME", "cust_disable_pagetype": True},
|
||||
),
|
||||
"follow-user": ("/profile/bob_test", None),
|
||||
"unfollow-user": ("/profile/bob_test", None),
|
||||
"messages-send": ("/messages?with_uid=RECEIVER_UID", {"uid": "MESSAGE_UID"}),
|
||||
"notifications-open": ("/posts/POST_SLUG#comment-COMMENT_UID", None),
|
||||
"notifications-mark-read": ("/notifications", None),
|
||||
"notifications-mark-all-read": ("/notifications", None),
|
||||
"issues-comment": ("/issues/12", {"comment_id": 1}),
|
||||
"issues-status": ("/issues/12", {"state": "closed"}),
|
||||
"admin-user-role": ("/admin/users", None),
|
||||
"admin-user-password": ("/admin/users", None),
|
||||
"admin-user-toggle": ("/admin/users", None),
|
||||
"admin-news-toggle": ("/admin/news", None),
|
||||
"admin-news-publish": ("/admin/news", None),
|
||||
"admin-news-landing": ("/admin/news", None),
|
||||
"admin-news-delete": ("/admin/news", None),
|
||||
"admin-settings": ("/admin/settings", None),
|
||||
}
|
||||
|
||||
|
||||
def _apply_negotiated_responses(groups):
|
||||
for group in groups:
|
||||
for ep in group.get("endpoints", []) or []:
|
||||
if ep.get("sample_response") is None:
|
||||
if ep["id"] in _PAGE_RESPONSES:
|
||||
ep["sample_response"] = schema_example(_PAGE_RESPONSES[ep["id"]])
|
||||
elif ep["id"] in _ACTION_RESPONSES:
|
||||
redirect, data = _ACTION_RESPONSES[ep["id"]]
|
||||
ep["sample_response"] = action_example(redirect, data)
|
||||
ep["negotiation"] = _classify(ep)
|
||||
47
devplacepy/docs_api/render.py
Normal file
47
devplacepy/docs_api/render.py
Normal file
@ -0,0 +1,47 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from .groups import ORDERED_GROUPS as API_GROUPS
|
||||
|
||||
|
||||
_GROUPS_BY_SLUG = {group["slug"]: group for group in API_GROUPS}
|
||||
|
||||
|
||||
def get_group(slug):
|
||||
return _GROUPS_BY_SLUG.get(slug)
|
||||
|
||||
|
||||
def api_doc_pages():
|
||||
return [
|
||||
{
|
||||
"slug": group["slug"],
|
||||
"title": group["title"],
|
||||
"kind": "api",
|
||||
"admin": group.get("admin", False),
|
||||
"dynamic": group.get("dynamic", False),
|
||||
}
|
||||
for group in API_GROUPS
|
||||
]
|
||||
|
||||
|
||||
def _substitute(value, replacements):
|
||||
if isinstance(value, str):
|
||||
for token, replacement in replacements.items():
|
||||
value = value.replace(token, replacement)
|
||||
return value
|
||||
if isinstance(value, list):
|
||||
return [_substitute(item, replacements) for item in value]
|
||||
if isinstance(value, dict):
|
||||
return {key: _substitute(item, replacements) for key, item in value.items()}
|
||||
return value
|
||||
|
||||
|
||||
def render_group(slug, base, username, api_key):
|
||||
group = get_group(slug)
|
||||
if not group:
|
||||
return None
|
||||
replacements = {
|
||||
"{{ base }}": base,
|
||||
"{{ username }}": username or "YOUR_USERNAME",
|
||||
"{{ api_key }}": api_key or "YOUR_API_KEY",
|
||||
}
|
||||
return _substitute(group, replacements)
|
||||
154
devplacepy/docs_api/services_group.py
Normal file
154
devplacepy/docs_api/services_group.py
Normal file
@ -0,0 +1,154 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from ._shared import SERVICE_ACTIONS, endpoint, field
|
||||
from .negotiation import _classify
|
||||
|
||||
|
||||
def _service_control_endpoints():
|
||||
endpoints = []
|
||||
for action, summary in SERVICE_ACTIONS:
|
||||
endpoints.append(
|
||||
endpoint(
|
||||
id=f"services-{action.replace('-', '')}",
|
||||
method="POST",
|
||||
path=f"/admin/services/{{name}}/{action}",
|
||||
title=f"Service: {action}",
|
||||
summary=summary + ".",
|
||||
auth="admin",
|
||||
interactive=True,
|
||||
destructive=True,
|
||||
params=[
|
||||
field(
|
||||
"name",
|
||||
"path",
|
||||
required=True,
|
||||
example="news",
|
||||
description="Registered service name.",
|
||||
)
|
||||
],
|
||||
sample_response={"ok": True},
|
||||
)
|
||||
)
|
||||
return endpoints
|
||||
|
||||
|
||||
def _field_to_param(spec):
|
||||
description = spec["label"]
|
||||
if spec.get("help"):
|
||||
description = f"{spec['label']} - {spec['help']}"
|
||||
value = "" if spec.get("secret") else str(spec.get("value", ""))
|
||||
if spec.get("secret"):
|
||||
description += " Leave blank to keep the current value."
|
||||
if spec["type"] == "bool":
|
||||
return field(
|
||||
spec["key"], "form", "enum", False, value or "0", description, ["1", "0"]
|
||||
)
|
||||
if spec["type"] == "select":
|
||||
options = [option["value"] for option in spec.get("options") or []]
|
||||
return field(
|
||||
spec["key"],
|
||||
"form",
|
||||
"enum",
|
||||
False,
|
||||
value or (options[0] if options else ""),
|
||||
description,
|
||||
options,
|
||||
)
|
||||
if spec["type"] in ("int", "float"):
|
||||
return field(spec["key"], "form", "int", False, value, description)
|
||||
return field(spec["key"], "form", "string", False, value, description)
|
||||
|
||||
|
||||
def _service_section(service):
|
||||
enabled = "yes" if service.get("enabled") else "no"
|
||||
groups = ", ".join(group["name"] for group in service.get("field_groups", []))
|
||||
lines = [
|
||||
f"## {service.get('title') or service['name']}",
|
||||
"",
|
||||
service.get("description", ""),
|
||||
"",
|
||||
f"- Service name: `{service['name']}`",
|
||||
f"- Enabled: {enabled}",
|
||||
f"- Run interval: {service.get('interval_seconds', 0)}s",
|
||||
]
|
||||
if groups:
|
||||
lines.append(f"- Configuration groups: {groups}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def build_services_group(services, base):
|
||||
intro_parts = [
|
||||
"# Background Services",
|
||||
"",
|
||||
"DevPlace runs background services supervised by a single manager. Each service has its "
|
||||
"own enable flag, run interval, live status, metrics, and a rolling log buffer. Manage "
|
||||
"them from the admin panel at `/admin/services` or with the endpoints below.",
|
||||
"",
|
||||
"Control a service by its `name` (shown per service): start or stop it, trigger a single "
|
||||
"run, clear its logs, or save its configuration. `GET /admin/services/data` returns the "
|
||||
"live status, metrics, and log tail for every service.",
|
||||
"",
|
||||
"See the [Admin API](/docs/admin.html) for the rest of administration; the Devii assistant "
|
||||
"is itself a service, configured in [Configuration and CLI](/docs/devii-config.html).",
|
||||
]
|
||||
config_endpoints = []
|
||||
for service in services:
|
||||
intro_parts.append("")
|
||||
intro_parts.append(_service_section(service))
|
||||
params = [
|
||||
_field_to_param(spec)
|
||||
for spec in service.get("fields", [])
|
||||
if not spec["key"].endswith("_enabled")
|
||||
]
|
||||
config_endpoints.append(
|
||||
endpoint(
|
||||
id=f"services-config-{service['name']}",
|
||||
method="POST",
|
||||
path=f"/admin/services/{service['name']}/config",
|
||||
title=f"Configure {service.get('title') or service['name']}",
|
||||
summary=f"Save configuration for the {service['name']} service. Empty values keep the current setting.",
|
||||
auth="admin",
|
||||
encoding="form",
|
||||
destructive=True,
|
||||
params=params,
|
||||
notes=[
|
||||
"Use start/stop to enable or disable the service; this saves the remaining settings."
|
||||
],
|
||||
)
|
||||
)
|
||||
control_endpoints = [
|
||||
endpoint(
|
||||
id="services-data",
|
||||
method="GET",
|
||||
path="/admin/services/data",
|
||||
title="Service status",
|
||||
summary="Live status, metrics, and log tail for every background service.",
|
||||
auth="admin",
|
||||
sample_response={
|
||||
"services": [{"name": "news", "status": "running", "enabled": True}]
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="services-detail-data",
|
||||
method="GET",
|
||||
path="/admin/services/{name}/data",
|
||||
title="One service status",
|
||||
summary="Live status, metrics, and log tail for a single background service.",
|
||||
auth="admin",
|
||||
params=[field("name", "path", "string", True, "news", "Service name.")],
|
||||
sample_response={
|
||||
"service": {"name": "news", "status": "running", "enabled": True}
|
||||
},
|
||||
),
|
||||
*_service_control_endpoints(),
|
||||
]
|
||||
endpoints = control_endpoints + config_endpoints
|
||||
for ep in endpoints:
|
||||
ep["negotiation"] = _classify(ep)
|
||||
return {
|
||||
"slug": "services",
|
||||
"title": "Background Services",
|
||||
"admin": True,
|
||||
"intro": "\n".join(intro_parts),
|
||||
"endpoints": endpoints,
|
||||
}
|
||||
@ -16,10 +16,37 @@ _RENDER_BLOCK = re.compile(
|
||||
r'<div class="docs-content" data-render>(.*?)</div>', re.DOTALL
|
||||
)
|
||||
|
||||
_HEADING = re.compile(r"<h([23])>(.*?)</h\1>", re.DOTALL)
|
||||
|
||||
|
||||
def heading_slug(text: str) -> str:
|
||||
plain = html.unescape(re.sub(r"<[^>]+>", "", text)).strip().lower()
|
||||
slug = re.sub(r"[^a-z0-9]+", "-", plain).strip("-")
|
||||
return slug or "section"
|
||||
|
||||
|
||||
def _anchor_headings(rendered: str) -> str:
|
||||
seen: dict[str, int] = {}
|
||||
|
||||
def _inject(match: re.Match) -> str:
|
||||
level, inner = match.group(1), match.group(2)
|
||||
slug = heading_slug(inner)
|
||||
count = seen.get(slug, 0)
|
||||
seen[slug] = count + 1
|
||||
if count:
|
||||
slug = f"{slug}-{count}"
|
||||
return (
|
||||
f'<h{level} id="{slug}">{inner}'
|
||||
f'<a class="docs-heading-anchor" href="#{slug}" aria-label="Link to this section">#</a>'
|
||||
f"</h{level}>"
|
||||
)
|
||||
|
||||
return _HEADING.sub(_inject, rendered)
|
||||
|
||||
|
||||
@lru_cache(maxsize=512)
|
||||
def _render_markdown(source: str) -> str:
|
||||
return _markdown(source)
|
||||
return _anchor_headings(_markdown(source))
|
||||
|
||||
|
||||
def _convert(match: re.Match) -> str:
|
||||
|
||||
@ -100,8 +100,11 @@ from devplacepy.services.dbapi.service import DbApiJobService
|
||||
from devplacepy.services.pubsub import PubSubService
|
||||
from devplacepy.services.notification_relay import NotificationRelayService
|
||||
from devplacepy.services.live_view_relay import LiveViewRelayService
|
||||
from devplacepy.services.presence_relay import PresenceRelayService
|
||||
from devplacepy.services import presence
|
||||
from devplacepy.services.correction import PENDING_SCOPE_KEY
|
||||
from devplacepy.services.jobs.deepsearch.service import DeepsearchService
|
||||
from devplacepy.services.jobs.isslop.service import IsslopService
|
||||
from devplacepy.services.gitea.service import IssueTrackerService
|
||||
from devplacepy.services.containers.service import ContainerService
|
||||
from devplacepy.services.xmlrpc import XmlrpcService
|
||||
@ -212,6 +215,7 @@ class UploadStaticFiles(StaticFiles):
|
||||
else "attachment"
|
||||
)
|
||||
response.headers["Content-Disposition"] = disposition
|
||||
response.headers["Cache-Control"] = "public, max-age=604800"
|
||||
return response
|
||||
|
||||
|
||||
@ -225,6 +229,16 @@ class CachedStaticFiles(StaticFiles):
|
||||
return response
|
||||
|
||||
|
||||
class FallbackStaticFiles(StaticFiles):
|
||||
async def get_response(self, path, scope):
|
||||
response = await super().get_response(path, scope)
|
||||
if Path(path).name == "service-worker.js":
|
||||
response.headers["Cache-Control"] = "no-cache"
|
||||
else:
|
||||
response.headers["Cache-Control"] = "public, max-age=3600"
|
||||
return response
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
ensure_data_dirs()
|
||||
@ -246,7 +260,9 @@ async def lifespan(app: FastAPI):
|
||||
service_manager.register(PubSubService())
|
||||
service_manager.register(NotificationRelayService())
|
||||
service_manager.register(LiveViewRelayService())
|
||||
service_manager.register(PresenceRelayService())
|
||||
service_manager.register(DeepsearchService())
|
||||
service_manager.register(IsslopService())
|
||||
service_manager.register(IssueCreateService())
|
||||
service_manager.register(PlanningReportService())
|
||||
service_manager.register(IssueTrackerService())
|
||||
@ -289,7 +305,7 @@ app.mount(
|
||||
CachedStaticFiles(directory=str(STATIC_DIR)),
|
||||
name="static_versioned",
|
||||
)
|
||||
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
||||
app.mount("/static", FallbackStaticFiles(directory=str(STATIC_DIR)), name="static")
|
||||
|
||||
|
||||
@app.exception_handler(404)
|
||||
@ -554,6 +570,16 @@ async def maintenance_middleware(request: Request, call_next):
|
||||
)
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def track_presence(request: Request, call_next):
|
||||
path = request.url.path
|
||||
if not path.startswith(("/static", "/avatar")):
|
||||
user = get_current_user(request)
|
||||
if user:
|
||||
presence.touch(user["uid"])
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def response_timing(request: Request, call_next):
|
||||
start = time.perf_counter()
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import re
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Literal, Optional
|
||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||
@ -460,6 +462,26 @@ class SeoRunForm(BaseModel):
|
||||
return text
|
||||
|
||||
|
||||
ISSLOP_URL_PATTERN = re.compile(r"^(https?://|git://|ssh://|git@)[\w./:@~^-]+$", re.IGNORECASE)
|
||||
ISSLOP_SINGLE_SLASH_PATTERN = re.compile(r"^(https?|git|ssh):/(?!/)", re.IGNORECASE)
|
||||
ISSLOP_SCHEME_PATTERN = re.compile(r"^[a-z][a-z0-9+.-]*://", re.IGNORECASE)
|
||||
|
||||
|
||||
class IsslopRunForm(BaseModel):
|
||||
url: str = Field(min_length=4, max_length=2048)
|
||||
|
||||
@field_validator("url")
|
||||
@classmethod
|
||||
def url_scheme(cls, value):
|
||||
text = value.strip()
|
||||
text = ISSLOP_SINGLE_SLASH_PATTERN.sub(lambda match: f"{match.group(1)}://", text)
|
||||
if not ISSLOP_SCHEME_PATTERN.match(text) and not text.startswith("git@"):
|
||||
text = f"https://{text}"
|
||||
if not ISSLOP_URL_PATTERN.match(text):
|
||||
raise ValueError("URL must be an http(s), git or ssh source location")
|
||||
return text
|
||||
|
||||
|
||||
DEEPSEARCH_MIN_DEPTH = 1
|
||||
DEEPSEARCH_MAX_DEPTH = 4
|
||||
DEEPSEARCH_DEFAULT_DEPTH = 2
|
||||
|
||||
@ -117,6 +117,12 @@ DOCS_PAGES = [
|
||||
"kind": "prose",
|
||||
"section": SECTION_GENERAL,
|
||||
},
|
||||
{
|
||||
"slug": "presence",
|
||||
"title": "Online presence",
|
||||
"kind": "prose",
|
||||
"section": SECTION_GENERAL,
|
||||
},
|
||||
{
|
||||
"slug": "ai-correction",
|
||||
"title": "AI content correction",
|
||||
@ -142,6 +148,18 @@ DOCS_PAGES = [
|
||||
"kind": "prose",
|
||||
"section": SECTION_TOOLS,
|
||||
},
|
||||
{
|
||||
"slug": "tools-isslop",
|
||||
"title": "AI Usage Analyzer",
|
||||
"kind": "prose",
|
||||
"section": SECTION_TOOLS,
|
||||
},
|
||||
{
|
||||
"slug": "isslop-checks",
|
||||
"title": "AI Usage Analyzer checks",
|
||||
"kind": "prose",
|
||||
"section": SECTION_TOOLS,
|
||||
},
|
||||
# Claude Code - the native subagent, command, and workflow setup under .claude/
|
||||
{
|
||||
"slug": "claude",
|
||||
|
||||
@ -19,6 +19,7 @@ from devplacepy.database import (
|
||||
)
|
||||
from devplacepy.attachments import get_attachments_batch
|
||||
from devplacepy.content import enrich_items
|
||||
from devplacepy.services import presence
|
||||
from devplacepy.utils import get_current_user
|
||||
from devplacepy.seo import list_page_seo, next_page_url
|
||||
from devplacepy.responses import respond
|
||||
@ -90,6 +91,7 @@ async def feed_page(
|
||||
stats = get_site_stats()
|
||||
top_authors = get_top_authors(5)
|
||||
daily_topic = get_daily_topic()
|
||||
online_users = presence.online_users()
|
||||
|
||||
post_uids_list = [item["post"]["uid"] for item in posts]
|
||||
attachments_map = get_attachments_batch("post", post_uids_list)
|
||||
@ -134,6 +136,7 @@ async def feed_page(
|
||||
"total_gists": stats["total_gists"],
|
||||
"top_authors": top_authors,
|
||||
"daily_topic": daily_topic,
|
||||
"online_users": online_users,
|
||||
"next_cursor": next_cursor,
|
||||
},
|
||||
model=FeedOut,
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
@ -93,18 +94,28 @@ async def issue_detail(request: Request, number: int):
|
||||
if not gitea_config().is_configured:
|
||||
raise not_found("Issue not found")
|
||||
client = runtime.get_client()
|
||||
try:
|
||||
issue = await client.get_issue(number)
|
||||
except GiteaError as exc:
|
||||
if exc.status == 404:
|
||||
issue_result, comments_result = await asyncio.gather(
|
||||
client.get_issue(number),
|
||||
client.list_comments(number),
|
||||
return_exceptions=True,
|
||||
)
|
||||
if isinstance(issue_result, GiteaError):
|
||||
if issue_result.status == 404:
|
||||
raise not_found("Issue not found")
|
||||
logger.warning("Could not load issue #%s: %s", number, exc)
|
||||
logger.warning("Could not load issue #%s: %s", number, issue_result)
|
||||
return tracker_unavailable(request)
|
||||
try:
|
||||
comments = await client.list_comments(number)
|
||||
except GiteaError as exc:
|
||||
logger.warning("Could not load comments for issue #%s: %s", number, exc)
|
||||
if isinstance(issue_result, BaseException):
|
||||
raise issue_result
|
||||
issue = issue_result
|
||||
if isinstance(comments_result, BaseException):
|
||||
if not isinstance(comments_result, GiteaError):
|
||||
raise comments_result
|
||||
logger.warning(
|
||||
"Could not load comments for issue #%s: %s", number, comments_result
|
||||
)
|
||||
comments = []
|
||||
else:
|
||||
comments = comments_result
|
||||
|
||||
if user:
|
||||
mark_notifications_read_by_target(user["uid"], f"/issues?highlight={number}")
|
||||
|
||||
@ -26,6 +26,7 @@ from devplacepy.utils import (
|
||||
from devplacepy.seo import base_seo_context
|
||||
from devplacepy.responses import respond, action_result
|
||||
from devplacepy.schemas import MessagesOut
|
||||
from devplacepy.services import presence
|
||||
from devplacepy.services.audit import record as audit
|
||||
from devplacepy.services.correction import PENDING_SCOPE_KEY
|
||||
from devplacepy.dependencies import json_or_form
|
||||
@ -41,6 +42,8 @@ router = APIRouter()
|
||||
|
||||
MAX_WS_ATTACHMENTS = 5
|
||||
|
||||
CONVERSATION_MESSAGE_LIMIT = 500
|
||||
|
||||
def mark_conversation_read(user_uid: str, other_uid: str) -> None:
|
||||
if "messages" not in db.tables:
|
||||
return
|
||||
@ -53,64 +56,65 @@ def mark_conversation_read(user_uid: str, other_uid: str) -> None:
|
||||
clear_messages_cache(user_uid)
|
||||
|
||||
def get_conversations(user_uid: str):
|
||||
messages_table = get_table("messages")
|
||||
raw = list(messages_table.find(sender_uid=user_uid)) + list(
|
||||
messages_table.find(receiver_uid=user_uid)
|
||||
)
|
||||
seen = set()
|
||||
all_messages = []
|
||||
for m in raw:
|
||||
if m["uid"] not in seen:
|
||||
seen.add(m["uid"])
|
||||
all_messages.append(m)
|
||||
|
||||
blocked = get_blocked_uids(user_uid)
|
||||
conversation_map = {}
|
||||
other_uids = set()
|
||||
for msg in all_messages:
|
||||
other_uid = (
|
||||
msg["receiver_uid"] if msg["sender_uid"] == user_uid else msg["sender_uid"]
|
||||
if "messages" not in db.tables:
|
||||
return []
|
||||
latest = list(
|
||||
db.query(
|
||||
"SELECT * FROM ("
|
||||
" SELECT *,"
|
||||
" CASE WHEN sender_uid = :me THEN receiver_uid ELSE sender_uid END AS other_uid,"
|
||||
" ROW_NUMBER() OVER ("
|
||||
" PARTITION BY CASE WHEN sender_uid = :me THEN receiver_uid ELSE sender_uid END"
|
||||
" ORDER BY created_at DESC, id DESC"
|
||||
" ) AS rn"
|
||||
" FROM messages"
|
||||
" WHERE sender_uid = :me OR receiver_uid = :me"
|
||||
") WHERE rn = 1 ORDER BY created_at DESC",
|
||||
me=user_uid,
|
||||
)
|
||||
)
|
||||
blocked = get_blocked_uids(user_uid)
|
||||
conversations = []
|
||||
other_uids = []
|
||||
for msg in latest:
|
||||
other_uid = msg["other_uid"]
|
||||
if other_uid in blocked:
|
||||
continue
|
||||
other_uids.add(other_uid)
|
||||
if (
|
||||
other_uid not in conversation_map
|
||||
or msg["created_at"] > conversation_map[other_uid]["last_message_at"]
|
||||
):
|
||||
conversation_map[other_uid] = {
|
||||
other_uids.append(other_uid)
|
||||
conversations.append(
|
||||
{
|
||||
"other_uid": other_uid,
|
||||
"other_user": None,
|
||||
"last_message": msg["content"],
|
||||
"last_message_at": msg["created_at"],
|
||||
"unread": msg["receiver_uid"] == user_uid and not msg["read"],
|
||||
}
|
||||
|
||||
)
|
||||
if other_uids:
|
||||
users_map = get_users_by_uids(list(other_uids))
|
||||
for uid, conv in conversation_map.items():
|
||||
conv["other_user"] = users_map.get(uid)
|
||||
|
||||
conversations = sorted(
|
||||
conversation_map.values(),
|
||||
key=lambda c: c["last_message_at"],
|
||||
reverse=True,
|
||||
)
|
||||
users_map = get_users_by_uids(other_uids)
|
||||
for conv in conversations:
|
||||
conv["other_user"] = users_map.get(conv["other_uid"])
|
||||
for conv in conversations:
|
||||
conv.pop("other_uid", None)
|
||||
return conversations
|
||||
|
||||
def get_conversation_messages(user_uid: str, other_uid: str):
|
||||
if other_uid in get_blocked_uids(user_uid):
|
||||
return [], None
|
||||
messages_table = get_table("messages")
|
||||
raw = list(messages_table.find(sender_uid=user_uid, receiver_uid=other_uid)) + list(
|
||||
messages_table.find(sender_uid=other_uid, receiver_uid=user_uid)
|
||||
if "messages" not in db.tables:
|
||||
return [], get_users_by_uids([other_uid]).get(other_uid)
|
||||
msgs = list(
|
||||
db.query(
|
||||
"SELECT * FROM messages"
|
||||
" WHERE (sender_uid = :me AND receiver_uid = :other)"
|
||||
" OR (sender_uid = :other AND receiver_uid = :me)"
|
||||
" ORDER BY created_at DESC, id DESC LIMIT :lim",
|
||||
me=user_uid,
|
||||
other=other_uid,
|
||||
lim=CONVERSATION_MESSAGE_LIMIT,
|
||||
)
|
||||
)
|
||||
seen = set()
|
||||
msgs = []
|
||||
for m in raw:
|
||||
if m["uid"] not in seen:
|
||||
seen.add(m["uid"])
|
||||
msgs.append(m)
|
||||
msgs.sort(key=lambda m: m["created_at"])
|
||||
msgs.reverse()
|
||||
|
||||
user_ids = list({m["sender_uid"] for m in msgs} | {other_uid})
|
||||
users_map = get_users_by_uids(user_ids)
|
||||
@ -154,8 +158,8 @@ async def messages_page(request: Request, with_uid: str = None, search: str = ""
|
||||
user["uid"], f"/messages?with_uid={with_uid}"
|
||||
)
|
||||
current_conversation = with_uid
|
||||
other_online = message_hub.is_online(with_uid)
|
||||
other_last_seen = message_hub.last_seen(with_uid)
|
||||
other_online = presence.is_online(other_user)
|
||||
other_last_seen = other_user.get("last_seen") if other_user else None
|
||||
audit.record(
|
||||
request,
|
||||
"message.read_on_view",
|
||||
@ -261,18 +265,6 @@ def _resolve_ws_user(websocket: WebSocket):
|
||||
return _user_from_api_key(key)
|
||||
return None
|
||||
|
||||
async def _announce_presence(user_uid: str, online: bool) -> None:
|
||||
other_uids = {c["other_user"]["uid"] for c in get_conversations(user_uid) if c["other_user"]}
|
||||
if not other_uids:
|
||||
return
|
||||
frame = {
|
||||
"type": "presence",
|
||||
"user_uid": user_uid,
|
||||
"online": online,
|
||||
"last_seen": None if online else message_hub.last_seen(user_uid),
|
||||
}
|
||||
await message_hub.send_to_users(list(other_uids), frame)
|
||||
|
||||
@router.websocket("/ws")
|
||||
async def messages_ws(websocket: WebSocket):
|
||||
await websocket.accept()
|
||||
@ -282,12 +274,10 @@ async def messages_ws(websocket: WebSocket):
|
||||
return
|
||||
|
||||
user_uid = user["uid"]
|
||||
was_offline = message_hub.register(user_uid, websocket)
|
||||
message_hub.register(user_uid, websocket)
|
||||
message_relay.start()
|
||||
try:
|
||||
await websocket.send_json({"type": "ready", "user_uid": user_uid})
|
||||
if was_offline:
|
||||
await _announce_presence(user_uid, True)
|
||||
while True:
|
||||
data = await websocket.receive_json()
|
||||
kind = data.get("type")
|
||||
@ -338,25 +328,9 @@ async def messages_ws(websocket: WebSocket):
|
||||
await message_hub.send_to_user(
|
||||
with_uid, {"type": "read", "by_uid": user_uid}
|
||||
)
|
||||
elif kind == "presence":
|
||||
with_uid = str(data.get("with_uid", "")).strip()
|
||||
if with_uid:
|
||||
await websocket.send_json(
|
||||
{
|
||||
"type": "presence",
|
||||
"user_uid": with_uid,
|
||||
"online": message_hub.is_online(with_uid),
|
||||
"last_seen": message_hub.last_seen(with_uid),
|
||||
}
|
||||
)
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("messages websocket loop failed for %s", user_uid)
|
||||
finally:
|
||||
went_offline = message_hub.unregister(user_uid, websocket)
|
||||
if went_offline:
|
||||
try:
|
||||
await _announce_presence(user_uid, False)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.debug("presence offline announce failed for %s", user_uid)
|
||||
message_hub.unregister(user_uid, websocket)
|
||||
|
||||
@ -26,6 +26,7 @@ from devplacepy.database import (
|
||||
get_user_media,
|
||||
search_users_by_username,
|
||||
mark_notifications_read_by_target,
|
||||
resolve_object_url,
|
||||
)
|
||||
from devplacepy.content import can_view_project, enrich_items
|
||||
from devplacepy.utils import (
|
||||
@ -39,7 +40,7 @@ from devplacepy.utils import (
|
||||
track_action,
|
||||
build_achievements,
|
||||
)
|
||||
from devplacepy.responses import respond, action_result
|
||||
from devplacepy.responses import respond, action_result, wants_json
|
||||
from devplacepy.schemas import ProfileOut
|
||||
from devplacepy.avatar import avatar_url, avatar_seed
|
||||
from devplacepy.seo import (
|
||||
@ -48,6 +49,7 @@ from devplacepy.seo import (
|
||||
website_schema,
|
||||
profile_page_schema,
|
||||
)
|
||||
from devplacepy.services import presence
|
||||
from devplacepy.services.audit import record as audit
|
||||
from devplacepy.services.correction import schedule_correction
|
||||
from devplacepy.services.ai_modifier import schedule_modification
|
||||
@ -179,16 +181,20 @@ async def profile_page(
|
||||
achievements = build_achievements({b["badge_name"] for b in badges})
|
||||
badge_total = sum(group["total"] for group in achievements)
|
||||
badge_earned = sum(group["earned"] for group in achievements)
|
||||
projects = list(
|
||||
get_table("projects").find(user_uid=profile_user["uid"], deleted_at=None)
|
||||
)
|
||||
projects = [p for p in projects if can_view_project(p, current_user)]
|
||||
gists_raw = list(
|
||||
get_table("gists").find(user_uid=profile_user["uid"], deleted_at=None)
|
||||
)
|
||||
include_collections = wants_json(request)
|
||||
projects = []
|
||||
if tab == "projects" or include_collections:
|
||||
projects = list(
|
||||
get_table("projects").find(user_uid=profile_user["uid"], deleted_at=None)
|
||||
)
|
||||
projects = [p for p in projects if can_view_project(p, current_user)]
|
||||
gists = []
|
||||
for g in gists_raw:
|
||||
gists.append({"gist": g, "time_ago": time_ago(g["created_at"])})
|
||||
if tab == "gists" or include_collections:
|
||||
gists_raw = list(
|
||||
get_table("gists").find(user_uid=profile_user["uid"], deleted_at=None)
|
||||
)
|
||||
for g in gists_raw:
|
||||
gists.append({"gist": g, "time_ago": time_ago(g["created_at"])})
|
||||
posts_count = get_table("posts").count(
|
||||
user_uid=profile_user["uid"], deleted_at=None
|
||||
)
|
||||
@ -209,6 +215,7 @@ async def profile_page(
|
||||
"time_ago": time_ago(p["created_at"]),
|
||||
"created_at": p["created_at"],
|
||||
"uid": p["uid"],
|
||||
"url": resolve_object_url("post", p["uid"]),
|
||||
}
|
||||
)
|
||||
comments_table = get_table("comments")
|
||||
@ -228,6 +235,7 @@ async def profile_page(
|
||||
"created_at": c["created_at"],
|
||||
"uid": target_uid,
|
||||
"target_type": target_type,
|
||||
"url": f"{resolve_object_url(target_type, target_uid)}#comment-{c['uid']}",
|
||||
}
|
||||
)
|
||||
activities.sort(key=lambda a: a["created_at"], reverse=True)
|
||||
@ -366,6 +374,7 @@ async def profile_page(
|
||||
"is_blocked": is_blocked,
|
||||
"is_muted": is_muted,
|
||||
"is_owner": is_owner,
|
||||
"profile_online": presence.is_online(profile_user),
|
||||
"viewer_is_admin": viewer_is_admin,
|
||||
"media": media,
|
||||
"media_pagination": media_pagination,
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from . import deepsearch, index, seo
|
||||
from . import deepsearch, index, isslop, seo
|
||||
|
||||
router = index.router
|
||||
router.include_router(seo.router, prefix="/seo")
|
||||
router.include_router(deepsearch.router, prefix="/deepsearch")
|
||||
router.include_router(isslop.router, prefix="/isslop")
|
||||
|
||||
@ -26,6 +26,17 @@ TOOLS = [
|
||||
"structured-data, performance, accessibility and AI-readiness checks."
|
||||
),
|
||||
},
|
||||
{
|
||||
"slug": "isslop",
|
||||
"name": "AI Usage Analyzer",
|
||||
"icon": "đź§Ş",
|
||||
"url": "/tools/isslop",
|
||||
"description": (
|
||||
"Measure how a codebase or website was made: untouched AI defaults, AI steered by a "
|
||||
"knowing hand, or work no model would ever produce. Multi-signal analysis, image "
|
||||
"forensics and a shareable authenticity badge."
|
||||
),
|
||||
},
|
||||
{
|
||||
"slug": "deepsearch",
|
||||
"name": "DeepSearch",
|
||||
|
||||
506
devplacepy/routers/tools/isslop.py
Normal file
506
devplacepy/routers/tools/isslop.py
Normal file
@ -0,0 +1,506 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
import re
|
||||
from urllib.parse import quote
|
||||
from typing import Annotated
|
||||
|
||||
import uuid_utils
|
||||
from fastapi import Depends, APIRouter, Request
|
||||
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response
|
||||
|
||||
from devplacepy.config import ISSLOP_MEDIA_DIR
|
||||
from devplacepy.constants import DEVII_GUEST_COOKIE
|
||||
from devplacepy.dependencies import json_or_form
|
||||
from devplacepy.models import IsslopRunForm
|
||||
from devplacepy.responses import respond
|
||||
from devplacepy.schemas import IsslopAnalysisOut, IsslopListOut, IsslopReportOut, IsslopSourceOut
|
||||
from devplacepy.seo import base_seo_context, site_url, web_application_schema, website_schema
|
||||
from devplacepy.services.jobs import queue
|
||||
from devplacepy.services.jobs.isslop import store
|
||||
from devplacepy.services.jobs.isslop.badge import badge_html, badge_markdown, render_badge
|
||||
from devplacepy.services.jobs.isslop.service import topic_for
|
||||
from devplacepy.templating import templates
|
||||
from devplacepy.utils import get_current_user, not_found, track_action
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
ACTIVE_STATES = (queue.PENDING, queue.RUNNING)
|
||||
GUEST_COOKIE_MAX_AGE = 31536000
|
||||
EVENT_LIMIT_MAX = 5000
|
||||
MEDIA_NAME_PATTERN = re.compile(r"^[a-f0-9]{16}\.webp$")
|
||||
SOURCE_NAME_PATTERN = re.compile(r"^s[a-f0-9]{16}\.txt$")
|
||||
SOURCE_LINE_CONTEXT = 400000
|
||||
|
||||
|
||||
def _owner(request: Request) -> tuple[str, str] | None:
|
||||
user = get_current_user(request)
|
||||
if user:
|
||||
return "user", user["uid"]
|
||||
guest = request.cookies.get(DEVII_GUEST_COOKIE)
|
||||
if guest:
|
||||
return "guest", guest
|
||||
return None
|
||||
|
||||
|
||||
def _ensure_owner(request: Request) -> tuple[str, str, str]:
|
||||
owner = _owner(request)
|
||||
if owner:
|
||||
return owner[0], owner[1], ""
|
||||
minted = uuid_utils.uuid7().hex
|
||||
return "guest", minted, minted
|
||||
|
||||
|
||||
def _set_guest_cookie(response, minted: str) -> None:
|
||||
if minted:
|
||||
response.set_cookie(
|
||||
DEVII_GUEST_COOKIE,
|
||||
minted,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
max_age=GUEST_COOKIE_MAX_AGE,
|
||||
)
|
||||
|
||||
|
||||
def _sync_guest_history(request: Request) -> None:
|
||||
user = get_current_user(request)
|
||||
guest = request.cookies.get(DEVII_GUEST_COOKIE)
|
||||
if user and guest:
|
||||
store.claim_guest_analyses(guest, user["uid"])
|
||||
|
||||
|
||||
def _analysis_payload(row: dict) -> dict:
|
||||
uid = row.get("uid", "")
|
||||
return {
|
||||
"uid": uid,
|
||||
"status": row.get("status", ""),
|
||||
"source_url": row.get("source_url", ""),
|
||||
"source_kind": row.get("source_kind", "") or "unknown",
|
||||
"grade": row.get("grade"),
|
||||
"slop_score": row.get("slop_score"),
|
||||
"origin_score": row.get("origin_score"),
|
||||
"quality_deficit_score": row.get("quality_deficit_score"),
|
||||
"human_percent": row.get("human_percent"),
|
||||
"ai_percent": row.get("ai_percent"),
|
||||
"category": row.get("category"),
|
||||
"confidence": row.get("confidence"),
|
||||
"files_total": int(row.get("files_total") or 0),
|
||||
"files_analyzed": int(row.get("files_analyzed") or 0),
|
||||
"detected_builder": row.get("detected_builder") or None,
|
||||
"dom_slop_score": row.get("dom_slop_score"),
|
||||
"error": row.get("error_message_text") or None,
|
||||
"report_url": f"/tools/isslop/{uid}/report",
|
||||
"badge_url": f"/tools/isslop/{uid}/badge.svg",
|
||||
"events_url": f"/tools/isslop/{uid}/events",
|
||||
"topic": topic_for(uid),
|
||||
"created_at": row.get("created_at"),
|
||||
"finished_at": row.get("finished_at"),
|
||||
}
|
||||
|
||||
|
||||
SEVERITY_RANK = {"strong": 0, "medium": 1, "weak": 2}
|
||||
SIGNAL_LINES_CAP = 8
|
||||
|
||||
|
||||
def _signal_groups(signals: list) -> list:
|
||||
groups: dict[str, dict] = {}
|
||||
for signal in signals:
|
||||
if not isinstance(signal, dict):
|
||||
continue
|
||||
code = str(signal.get("code", ""))
|
||||
entry = groups.setdefault(
|
||||
code,
|
||||
{
|
||||
"code": code,
|
||||
"title": str(signal.get("title", "")),
|
||||
"severity": str(signal.get("severity", "weak")),
|
||||
"count": 0,
|
||||
"lines": [],
|
||||
},
|
||||
)
|
||||
entry["count"] += 1
|
||||
line = signal.get("line")
|
||||
if isinstance(line, int) and len(entry["lines"]) < SIGNAL_LINES_CAP:
|
||||
entry["lines"].append(line)
|
||||
return sorted(
|
||||
groups.values(),
|
||||
key=lambda group: (SEVERITY_RANK.get(group["severity"], 3), -group["count"], group["code"]),
|
||||
)
|
||||
|
||||
|
||||
def _source_url(uid: str, path: str, line: int = 0) -> str:
|
||||
url = f"/tools/isslop/{uid}/source?path={quote(path, safe='')}"
|
||||
if line > 0:
|
||||
url += f"&line={line}#L{line}"
|
||||
return url
|
||||
|
||||
|
||||
CRITERIA_URL = "/docs/isslop-checks.html"
|
||||
|
||||
|
||||
def _linkify_sources(markdown: str, uid: str, paths: set[str], signal_codes: set[str]) -> str:
|
||||
for path in sorted(paths, key=len, reverse=True):
|
||||
escaped = re.escape(path)
|
||||
markdown = re.sub(
|
||||
rf"`{escaped}:(\d+)`",
|
||||
lambda match, p=path: f"[`{p}:{match.group(1)}`]({_source_url(uid, p, int(match.group(1)))})",
|
||||
markdown,
|
||||
)
|
||||
markdown = markdown.replace(f"`{path}`", f"[`{path}`]({_source_url(uid, path)})")
|
||||
markdown = re.sub(
|
||||
rf"(?<![\w/`\(\[]){escaped}(?::(\d+))?(?![\w/`])",
|
||||
lambda match, p=path: (
|
||||
f"[{p}:{match.group(1)}]({_source_url(uid, p, int(match.group(1)))})"
|
||||
if match.group(1)
|
||||
else f"[{p}]({_source_url(uid, p)})"
|
||||
),
|
||||
markdown,
|
||||
)
|
||||
for code in sorted(signal_codes, key=len, reverse=True):
|
||||
escaped = re.escape(code)
|
||||
markdown = markdown.replace(f"`{code}`", f"[`{code}`]({CRITERIA_URL})")
|
||||
markdown = re.sub(
|
||||
rf"(?<![\w`\[]){escaped}(?![\w`])",
|
||||
f"[{code}]({CRITERIA_URL})",
|
||||
markdown,
|
||||
)
|
||||
return markdown
|
||||
|
||||
|
||||
def _badge_info(request: Request, uid: str) -> dict:
|
||||
base = site_url(request).rstrip("/")
|
||||
badge_url = f"{base}/tools/isslop/{uid}/badge.svg"
|
||||
report_url = f"{base}/tools/isslop/{uid}/report"
|
||||
return {
|
||||
"badge_url": badge_url,
|
||||
"report_url": report_url,
|
||||
"markdown": badge_markdown(badge_url, report_url),
|
||||
"html": badge_html(badge_url, report_url),
|
||||
}
|
||||
|
||||
|
||||
@router.get("", response_class=HTMLResponse)
|
||||
async def isslop_page(request: Request):
|
||||
user = get_current_user(request)
|
||||
_sync_guest_history(request)
|
||||
base = site_url(request)
|
||||
description = (
|
||||
"Measure how a codebase or website was made: untouched AI defaults, AI steered by a "
|
||||
"knowing hand, or work no model would ever produce. Transparent, reproducible analysis "
|
||||
"with a shareable authenticity badge."
|
||||
)
|
||||
seo_ctx = base_seo_context(
|
||||
request,
|
||||
title="AI Usage Analyzer",
|
||||
description=description,
|
||||
breadcrumbs=[
|
||||
{"name": "Home", "url": "/feed"},
|
||||
{"name": "Tools", "url": "/tools"},
|
||||
{"name": "AI Usage Analyzer", "url": "/tools/isslop"},
|
||||
],
|
||||
schemas=[
|
||||
website_schema(base),
|
||||
web_application_schema("AI Usage Analyzer", description, "/tools/isslop", base),
|
||||
],
|
||||
)
|
||||
minted = "" if _owner(request) else uuid_utils.uuid7().hex
|
||||
response = templates.TemplateResponse(
|
||||
request,
|
||||
"tools/isslop.html",
|
||||
{**seo_ctx, "request": request, "user": user},
|
||||
)
|
||||
_set_guest_cookie(response, minted)
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/run")
|
||||
async def isslop_run(request: Request, data: Annotated[IsslopRunForm, Depends(json_or_form(IsslopRunForm))]):
|
||||
from devplacepy.services.audit import record as audit
|
||||
|
||||
owner_kind, owner_id, minted = _ensure_owner(request)
|
||||
active = [
|
||||
job
|
||||
for job in queue.list_jobs(kind="isslop", owner=(owner_kind, owner_id))
|
||||
if job.get("status") in ACTIVE_STATES
|
||||
]
|
||||
if active:
|
||||
audit.record(
|
||||
request,
|
||||
"isslop.run.request",
|
||||
result="denied",
|
||||
summary=f"AI usage analysis denied for {data.url}: analysis already running",
|
||||
metadata={"target": data.url, "reason": "active_job", "uid": active[0]["uid"]},
|
||||
links=[audit.job(active[0]["uid"])],
|
||||
)
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": {
|
||||
"status": 429,
|
||||
"message": "You already have an analysis running. Wait for it to finish.",
|
||||
"uid": active[0]["uid"],
|
||||
}
|
||||
},
|
||||
status_code=429,
|
||||
)
|
||||
uid = queue.enqueue(
|
||||
"isslop",
|
||||
{"url": data.url},
|
||||
owner_kind,
|
||||
owner_id,
|
||||
f"AI usage: {data.url}"[:64],
|
||||
)
|
||||
store.create_analysis(uid, data.url, owner_kind, owner_id)
|
||||
audit.record(
|
||||
request,
|
||||
"isslop.run.request",
|
||||
summary=f"requested AI usage analysis of {data.url}",
|
||||
metadata={"target": data.url},
|
||||
links=[audit.job(uid)],
|
||||
)
|
||||
if owner_kind == "user":
|
||||
track_action(owner_id, "isslop")
|
||||
response = JSONResponse(
|
||||
{
|
||||
"uid": uid,
|
||||
"status_url": f"/tools/isslop/{uid}",
|
||||
"events_url": f"/tools/isslop/{uid}/events",
|
||||
"report_url": f"/tools/isslop/{uid}/report",
|
||||
"topic": topic_for(uid),
|
||||
}
|
||||
)
|
||||
_set_guest_cookie(response, minted)
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/list")
|
||||
async def isslop_list(request: Request, limit: int = store.LIST_LIMIT_DEFAULT):
|
||||
_sync_guest_history(request)
|
||||
owner = _owner(request)
|
||||
rows = []
|
||||
if owner:
|
||||
rows = store.list_analyses(owner[0], owner[1], min(max(1, limit), 200))
|
||||
return JSONResponse(
|
||||
IsslopListOut.model_validate(
|
||||
{"analyses": [_analysis_payload(row) for row in rows]}
|
||||
).model_dump(mode="json")
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{uid}")
|
||||
async def isslop_status(request: Request, uid: str):
|
||||
row = store.get_analysis(uid)
|
||||
if not row:
|
||||
raise not_found("Analysis not found")
|
||||
return JSONResponse(
|
||||
IsslopAnalysisOut.model_validate(_analysis_payload(row)).model_dump(mode="json")
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{uid}/events")
|
||||
async def isslop_events(request: Request, uid: str, after: int = 0, limit: int = store.EVENT_LIMIT_DEFAULT):
|
||||
row = store.get_analysis(uid)
|
||||
if not row:
|
||||
raise not_found("Analysis not found")
|
||||
rows = store.events_for(uid, max(0, after), min(max(1, limit), EVENT_LIMIT_MAX))
|
||||
events = [
|
||||
{
|
||||
"seq": event["seq"],
|
||||
"kind": event["kind"],
|
||||
"message": event["message"],
|
||||
"data": store.decode_json(event.get("payload"), {}),
|
||||
"created_at": event["created_at"],
|
||||
}
|
||||
for event in rows
|
||||
]
|
||||
return JSONResponse({"uid": uid, "status": row.get("status", ""), "events": events})
|
||||
|
||||
|
||||
@router.get("/{uid}/report")
|
||||
async def isslop_report(request: Request, uid: str):
|
||||
row = store.get_analysis(uid)
|
||||
if not row:
|
||||
raise not_found("Analysis not found")
|
||||
report = store.get_report(uid)
|
||||
files = []
|
||||
linkable_paths: set[str] = set()
|
||||
signal_codes: set[str] = set()
|
||||
for item in store.file_results_for(uid):
|
||||
signals = store.decode_json(item.get("signals"), [])
|
||||
for signal in signals:
|
||||
if isinstance(signal, dict) and signal.get("code"):
|
||||
signal_codes.add(str(signal["code"]))
|
||||
source_name = str(item.get("source") or "")
|
||||
has_source = bool(source_name and SOURCE_NAME_PATTERN.match(source_name))
|
||||
path = item.get("path", "")
|
||||
if has_source:
|
||||
linkable_paths.add(path)
|
||||
files.append(
|
||||
{
|
||||
"path": path,
|
||||
"language": item.get("language", "unknown"),
|
||||
"lines": int(item.get("lines") or 0),
|
||||
"origin_score": float(item.get("origin_score") or 0.0),
|
||||
"quality_deficit_score": float(item.get("quality_deficit_score") or 0.0),
|
||||
"category": item.get("category", "uncertain"),
|
||||
"signals": signals,
|
||||
"signal_groups": _signal_groups(signals),
|
||||
"source_url": _source_url(uid, path) if has_source else None,
|
||||
}
|
||||
)
|
||||
images = [
|
||||
{
|
||||
"path": item.get("path", ""),
|
||||
"ai_probability": float(item.get("ai_probability") or 0.0),
|
||||
"grade": item.get("grade", "n/a"),
|
||||
"verdict": item.get("verdict", "uncertain"),
|
||||
"image_kind": item.get("image_kind", "image"),
|
||||
"tells": store.decode_json(item.get("tells"), []),
|
||||
"description": item.get("description", ""),
|
||||
"thumb_url": (
|
||||
f"/tools/isslop/{uid}/media/{item['thumb']}"
|
||||
if item.get("thumb") and MEDIA_NAME_PATTERN.match(str(item["thumb"]))
|
||||
else None
|
||||
),
|
||||
}
|
||||
for item in store.image_results_for(uid)
|
||||
]
|
||||
dom_pages = [
|
||||
{
|
||||
"url": item.get("url", ""),
|
||||
"detected_builder": item.get("detected_builder") or None,
|
||||
"signal_count": int(item.get("signal_count") or 0),
|
||||
"signals": store.decode_json(item.get("signals"), []),
|
||||
"screenshot_url": (
|
||||
f"/tools/isslop/{uid}/media/{item['screenshot']}"
|
||||
if item.get("screenshot") and MEDIA_NAME_PATTERN.match(str(item["screenshot"]))
|
||||
else None
|
||||
),
|
||||
}
|
||||
for item in store.dom_results_for(uid)
|
||||
]
|
||||
seo_ctx = base_seo_context(
|
||||
request,
|
||||
title="AI usage analysis report",
|
||||
description=f"Authenticity analysis of {row.get('source_url', '')}",
|
||||
robots="noindex,nofollow",
|
||||
breadcrumbs=[
|
||||
{"name": "Home", "url": "/feed"},
|
||||
{"name": "Tools", "url": "/tools"},
|
||||
{"name": "AI Usage Analyzer", "url": "/tools/isslop"},
|
||||
{"name": "Report", "url": f"/tools/isslop/{uid}/report"},
|
||||
],
|
||||
)
|
||||
context = {
|
||||
**seo_ctx,
|
||||
**_analysis_payload(row),
|
||||
"content_hash": row.get("content_hash"),
|
||||
"markdown": _linkify_sources(report.get("markdown", ""), uid, linkable_paths, signal_codes) if report else "",
|
||||
"generator_model": report.get("model_used", "") if report else "",
|
||||
"generated_at": report.get("generated_at") if report else None,
|
||||
"badge": _badge_info(request, uid),
|
||||
"files": files,
|
||||
"images": images,
|
||||
"dom_pages": dom_pages,
|
||||
"request": request,
|
||||
"user": get_current_user(request),
|
||||
}
|
||||
return respond(request, "tools/isslop_report.html", context, model=IsslopReportOut)
|
||||
|
||||
|
||||
@router.get("/{uid}/report.md")
|
||||
async def isslop_report_markdown(request: Request, uid: str):
|
||||
row = store.get_analysis(uid)
|
||||
if not row:
|
||||
raise not_found("Analysis not found")
|
||||
report = store.get_report(uid)
|
||||
if not report:
|
||||
raise not_found("Report not yet generated for this analysis")
|
||||
return Response(
|
||||
content=report["markdown"],
|
||||
media_type="text/markdown; charset=utf-8",
|
||||
headers={"content-disposition": f'attachment; filename="ai-usage-report-{uid}.md"'},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{uid}/source")
|
||||
async def isslop_source(request: Request, uid: str, path: str, line: int = 0):
|
||||
row = store.get_analysis(uid)
|
||||
if not row:
|
||||
raise not_found("Analysis not found")
|
||||
result = store.file_result_for(uid, path)
|
||||
source_name = str(result.get("source") or "") if result else ""
|
||||
if not result or not SOURCE_NAME_PATTERN.match(source_name):
|
||||
raise not_found("Source not available for this file")
|
||||
source_path = (store.media_dir_for(uid) / source_name).resolve()
|
||||
if not source_path.is_relative_to(ISSLOP_MEDIA_DIR.resolve()) or not source_path.is_file():
|
||||
raise not_found("Source not available for this file")
|
||||
text = source_path.read_text(encoding="utf-8", errors="replace")
|
||||
signals = store.decode_json(result.get("signals"), [])
|
||||
marked: dict[int, list] = {}
|
||||
for signal in signals:
|
||||
if isinstance(signal, dict) and isinstance(signal.get("line"), int) and signal["line"] > 0:
|
||||
marked.setdefault(signal["line"], []).append(signal)
|
||||
source_lines = text.split("\n")
|
||||
seo_ctx = base_seo_context(
|
||||
request,
|
||||
title=f"Source: {path}",
|
||||
description=f"Annotated source of {path} from the AI usage analysis",
|
||||
robots="noindex,nofollow",
|
||||
breadcrumbs=[
|
||||
{"name": "Home", "url": "/feed"},
|
||||
{"name": "Tools", "url": "/tools"},
|
||||
{"name": "AI Usage Analyzer", "url": "/tools/isslop"},
|
||||
{"name": "Report", "url": f"/tools/isslop/{uid}/report"},
|
||||
{"name": "Source", "url": _source_url(uid, path)},
|
||||
],
|
||||
)
|
||||
context = {
|
||||
**seo_ctx,
|
||||
"uid": uid,
|
||||
"path": path,
|
||||
"language": result.get("language", "unknown"),
|
||||
"category": result.get("category", "uncertain"),
|
||||
"origin_score": float(result.get("origin_score") or 0.0),
|
||||
"quality_deficit_score": float(result.get("quality_deficit_score") or 0.0),
|
||||
"source": text,
|
||||
"truncated": len(text) >= SOURCE_LINE_CONTEXT,
|
||||
"signals": signals,
|
||||
"source_lines": source_lines,
|
||||
"marked_lines": marked,
|
||||
"focus_line": max(0, line),
|
||||
"report_url": f"/tools/isslop/{uid}/report",
|
||||
"request": request,
|
||||
"user": get_current_user(request),
|
||||
}
|
||||
return respond(request, "tools/isslop_source.html", context, model=IsslopSourceOut)
|
||||
|
||||
|
||||
@router.get("/{uid}/media/{name}")
|
||||
async def isslop_media(request: Request, uid: str, name: str):
|
||||
row = store.get_analysis(uid)
|
||||
if not row or not MEDIA_NAME_PATTERN.match(name):
|
||||
raise not_found("Image not available")
|
||||
root = ISSLOP_MEDIA_DIR.resolve()
|
||||
path = (store.media_dir_for(uid) / name).resolve()
|
||||
if not path.is_relative_to(root) or not path.is_file():
|
||||
raise not_found("Image not available")
|
||||
return FileResponse(
|
||||
path,
|
||||
media_type="image/webp",
|
||||
headers={"cache-control": "public, max-age=86400"},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{uid}/badge.svg")
|
||||
async def isslop_badge(request: Request, uid: str):
|
||||
row = store.get_analysis(uid)
|
||||
if not row:
|
||||
raise not_found("Analysis not found")
|
||||
report_url = _badge_info(request, uid)["report_url"]
|
||||
svg = render_badge(row.get("human_percent"), row.get("grade"), report_url)
|
||||
return Response(
|
||||
content=svg,
|
||||
media_type="image/svg+xml",
|
||||
headers={"cache-control": "no-cache, max-age=300"},
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
151
devplacepy/schemas/__init__.py
Normal file
151
devplacepy/schemas/__init__.py
Normal file
@ -0,0 +1,151 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from devplacepy.schemas.base import (
|
||||
ActionResult,
|
||||
ErrorOut,
|
||||
ValidationErrorOut,
|
||||
_Out,
|
||||
)
|
||||
from devplacepy.schemas.content import (
|
||||
AttachmentOut,
|
||||
BadgeOut,
|
||||
CommentEditOut,
|
||||
CommentItemOut,
|
||||
CommentOut,
|
||||
GistOut,
|
||||
MessageOut,
|
||||
NewsOut,
|
||||
NotificationOut,
|
||||
PollOptionOut,
|
||||
PollOut,
|
||||
PostOut,
|
||||
ProjectFileOut,
|
||||
ProjectFileRawOut,
|
||||
ProjectFilesOut,
|
||||
ProjectOut,
|
||||
ReactionsOut,
|
||||
UserOut,
|
||||
VotesOut,
|
||||
)
|
||||
from devplacepy.schemas.listings import (
|
||||
ConversationOut,
|
||||
FeedItemOut,
|
||||
FeedOut,
|
||||
GistDetailOut,
|
||||
GistItemOut,
|
||||
GistsOut,
|
||||
LeaderboardEntryOut,
|
||||
LeaderboardOut,
|
||||
MessageItemOut,
|
||||
MessagesOut,
|
||||
NewsDetailOut,
|
||||
NewsListItemOut,
|
||||
NewsListOut,
|
||||
NotificationGroupOut,
|
||||
NotificationItemOut,
|
||||
NotificationsOut,
|
||||
PostDetailOut,
|
||||
ProjectDetailOut,
|
||||
ProjectListItemOut,
|
||||
ProjectsOut,
|
||||
SavedItemOut,
|
||||
SavedOut,
|
||||
)
|
||||
from devplacepy.schemas.profile import (
|
||||
MediaItemOut,
|
||||
ProfileOut,
|
||||
TelegramPairOut,
|
||||
)
|
||||
from devplacepy.schemas.issues import (
|
||||
IssueAttachmentsOut,
|
||||
IssueCommentOut,
|
||||
IssueDetailOut,
|
||||
IssueItemOut,
|
||||
IssueJobOut,
|
||||
IssuesOut,
|
||||
)
|
||||
from devplacepy.schemas.containers import (
|
||||
AdminBotsOut,
|
||||
AdminContainerEditOut,
|
||||
AdminContainerInstanceOut,
|
||||
AdminContainersOut,
|
||||
BotFrameOut,
|
||||
ContainersOut,
|
||||
InstanceOut,
|
||||
ScheduleOut,
|
||||
)
|
||||
from devplacepy.schemas.jobs import (
|
||||
DbQueryJobOut,
|
||||
DeepsearchJobOut,
|
||||
DeepsearchSessionOut,
|
||||
ForkJobOut,
|
||||
PlanningJobOut,
|
||||
SeoJobOut,
|
||||
SeoMetaOut,
|
||||
SeoReportOut,
|
||||
ZipJobOut,
|
||||
IsslopAnalysisOut,
|
||||
IsslopListOut,
|
||||
IsslopReportOut,
|
||||
IsslopSourceOut,
|
||||
)
|
||||
from devplacepy.schemas.backups import (
|
||||
BackupDashboardOut,
|
||||
BackupJobOut,
|
||||
BackupOut,
|
||||
BackupScheduleOut,
|
||||
BackupStoragePathOut,
|
||||
)
|
||||
from devplacepy.schemas.admin import (
|
||||
AdminMediaItemOut,
|
||||
AdminMediaOut,
|
||||
AdminNewsItemOut,
|
||||
AdminNewsOut,
|
||||
AdminNotificationsOut,
|
||||
AdminSettingsOut,
|
||||
AdminTrashOut,
|
||||
AdminUserOut,
|
||||
AdminUsersOut,
|
||||
TrashItemOut,
|
||||
)
|
||||
from devplacepy.schemas.gateway import (
|
||||
GatewayUsageOut,
|
||||
UserAiUsageOut,
|
||||
)
|
||||
from devplacepy.schemas.auth import (
|
||||
AuthPageOut,
|
||||
DeviiPageOut,
|
||||
LandingArticleOut,
|
||||
LandingOut,
|
||||
LandingPostOut,
|
||||
)
|
||||
from devplacepy.schemas.audit import (
|
||||
AuditEntryOut,
|
||||
AuditEventOut,
|
||||
AuditLinkOut,
|
||||
AuditLogOut,
|
||||
)
|
||||
from devplacepy.schemas.dbapi import (
|
||||
DbColumnOut,
|
||||
DbQueryOut,
|
||||
DbRowOut,
|
||||
DbRowsOut,
|
||||
DbSchemaOut,
|
||||
DbTableListOut,
|
||||
DbTableOut,
|
||||
NlQueryOut,
|
||||
)
|
||||
from devplacepy.schemas.game import (
|
||||
GameCropOut,
|
||||
GameFarmOut,
|
||||
GameFarmViewOut,
|
||||
GameLeaderboardEntryOut,
|
||||
GameLeaderboardOut,
|
||||
GameLegacyOut,
|
||||
GamePerkOut,
|
||||
GamePlotOut,
|
||||
GameQuestOut,
|
||||
GameStateOut,
|
||||
)
|
||||
74
devplacepy/schemas/admin.py
Normal file
74
devplacepy/schemas/admin.py
Normal file
@ -0,0 +1,74 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from devplacepy.schemas.base import _Out
|
||||
from devplacepy.schemas.content import NewsOut, UserOut
|
||||
from devplacepy.schemas.profile import MediaItemOut
|
||||
|
||||
|
||||
class AdminUserOut(UserOut):
|
||||
role: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
posts_count: Optional[int] = None
|
||||
|
||||
|
||||
class AdminNewsItemOut(_Out):
|
||||
article: NewsOut
|
||||
time_ago: Optional[str] = None
|
||||
synced_at: Optional[str] = None
|
||||
grade: Optional[int] = None
|
||||
has_image: Optional[bool] = None
|
||||
|
||||
|
||||
class AdminUsersOut(_Out):
|
||||
users: list[AdminUserOut] = []
|
||||
pagination: Optional[Any] = None
|
||||
admin_section: Optional[str] = None
|
||||
|
||||
|
||||
class AdminNewsOut(_Out):
|
||||
articles: list[AdminNewsItemOut] = []
|
||||
pagination: Optional[Any] = None
|
||||
admin_section: Optional[str] = None
|
||||
|
||||
|
||||
class AdminSettingsOut(_Out):
|
||||
settings: dict = {}
|
||||
admin_section: Optional[str] = None
|
||||
|
||||
|
||||
class AdminNotificationsOut(_Out):
|
||||
notification_defaults: list[Any] = []
|
||||
admin_section: Optional[str] = None
|
||||
|
||||
|
||||
class AdminMediaItemOut(MediaItemOut):
|
||||
deleted_at: Optional[str] = None
|
||||
uploader: Optional[str] = None
|
||||
|
||||
|
||||
class AdminMediaOut(_Out):
|
||||
media: list[AdminMediaItemOut] = []
|
||||
pagination: Optional[Any] = None
|
||||
admin_section: Optional[str] = None
|
||||
|
||||
|
||||
class TrashItemOut(_Out):
|
||||
uid: str
|
||||
table: str
|
||||
label: Optional[str] = None
|
||||
deleted_at: Optional[str] = None
|
||||
deleted_by: Optional[str] = None
|
||||
target_url: Optional[str] = None
|
||||
|
||||
|
||||
class AdminTrashOut(_Out):
|
||||
items: list[TrashItemOut] = []
|
||||
table: str = "posts"
|
||||
tables: list[dict] = []
|
||||
pagination: Optional[Any] = None
|
||||
admin_section: Optional[str] = None
|
||||
56
devplacepy/schemas/audit.py
Normal file
56
devplacepy/schemas/audit.py
Normal file
@ -0,0 +1,56 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from devplacepy.schemas.base import _Out
|
||||
|
||||
|
||||
class AuditLinkOut(_Out):
|
||||
uid: str = ""
|
||||
relation: str = ""
|
||||
object_type: str = ""
|
||||
object_uid: str = ""
|
||||
object_label: Optional[str] = None
|
||||
created_at: Optional[str] = None
|
||||
|
||||
|
||||
class AuditEntryOut(_Out):
|
||||
uid: str = ""
|
||||
created_at: Optional[str] = None
|
||||
event_key: str = ""
|
||||
category: str = ""
|
||||
actor_kind: str = ""
|
||||
actor_uid: Optional[str] = None
|
||||
actor_username: Optional[str] = None
|
||||
actor_role: str = ""
|
||||
origin: str = ""
|
||||
via_agent: int = 0
|
||||
request_method: Optional[str] = None
|
||||
request_path: Optional[str] = None
|
||||
actor_ip: Optional[str] = None
|
||||
user_agent: Optional[str] = None
|
||||
target_type: Optional[str] = None
|
||||
target_uid: Optional[str] = None
|
||||
target_label: Optional[str] = None
|
||||
old_value: Optional[str] = None
|
||||
new_value: Optional[str] = None
|
||||
summary: Optional[str] = None
|
||||
metadata: Optional[Any] = None
|
||||
result: str = "success"
|
||||
|
||||
|
||||
class AuditLogOut(_Out):
|
||||
entries: list[AuditEntryOut] = []
|
||||
pagination: Optional[dict] = None
|
||||
pagination_query: Optional[str] = None
|
||||
filters: dict = {}
|
||||
options: dict = {}
|
||||
admin_section: Optional[str] = None
|
||||
|
||||
|
||||
class AuditEventOut(_Out):
|
||||
event: Optional[AuditEntryOut] = None
|
||||
links: list[AuditLinkOut] = []
|
||||
admin_section: Optional[str] = None
|
||||
64
devplacepy/schemas/auth.py
Normal file
64
devplacepy/schemas/auth.py
Normal file
@ -0,0 +1,64 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from devplacepy.schemas.base import _Out
|
||||
from devplacepy.schemas.content import UserOut
|
||||
|
||||
|
||||
class AuthPageOut(_Out):
|
||||
page: str = ""
|
||||
next_url: Optional[str] = None
|
||||
registration_closed: Optional[bool] = None
|
||||
sent: Optional[bool] = None
|
||||
token: Optional[str] = None
|
||||
errors: list = []
|
||||
|
||||
|
||||
class LandingArticleOut(_Out):
|
||||
uid: str = ""
|
||||
slug: str = ""
|
||||
title: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
url: Optional[str] = None
|
||||
source_name: Optional[str] = None
|
||||
grade: int = 0
|
||||
featured: int = 0
|
||||
synced_at: Optional[str] = None
|
||||
time_ago: Optional[str] = None
|
||||
image_url: Optional[str] = None
|
||||
|
||||
|
||||
class LandingPostOut(_Out):
|
||||
post: Optional[Any] = None
|
||||
author: Optional[UserOut] = None
|
||||
time_ago: Optional[str] = None
|
||||
comment_count: int = 0
|
||||
stars: int = 0
|
||||
slug: str = ""
|
||||
|
||||
|
||||
class LandingOut(_Out):
|
||||
is_authenticated: bool = False
|
||||
user_post_count: int = 0
|
||||
user_stars: int = 0
|
||||
landing_articles: list[LandingArticleOut] = []
|
||||
landing_posts: list[LandingPostOut] = []
|
||||
|
||||
|
||||
class DeviiPageOut(_Out):
|
||||
page_title: Optional[str] = None
|
||||
meta_description: Optional[str] = None
|
||||
meta_robots: Optional[str] = None
|
||||
canonical_url: Optional[str] = None
|
||||
og_title: Optional[str] = None
|
||||
og_description: Optional[str] = None
|
||||
og_image: Optional[str] = None
|
||||
og_type: Optional[str] = None
|
||||
breadcrumbs: list = []
|
||||
page_schema: Optional[Any] = None
|
||||
prev_url: Optional[str] = None
|
||||
next_url: Optional[str] = None
|
||||
user: Optional[Any] = None
|
||||
80
devplacepy/schemas/backups.py
Normal file
80
devplacepy/schemas/backups.py
Normal file
@ -0,0 +1,80 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from devplacepy.schemas.base import _Out
|
||||
|
||||
|
||||
class BackupStoragePathOut(_Out):
|
||||
key: str = ""
|
||||
label: str = ""
|
||||
path: str = ""
|
||||
size_bytes: int = 0
|
||||
size_human: str = ""
|
||||
file_count: int = 0
|
||||
exists: bool = False
|
||||
|
||||
|
||||
class BackupOut(_Out):
|
||||
uid: str = ""
|
||||
job_uid: str = ""
|
||||
target: str = ""
|
||||
label: str = ""
|
||||
status: str = ""
|
||||
filename: str = ""
|
||||
size_bytes: int = 0
|
||||
size_human: str = ""
|
||||
bytes_in: int = 0
|
||||
file_count: int = 0
|
||||
dir_count: int = 0
|
||||
sha256: str = ""
|
||||
schedule_uid: str = ""
|
||||
created_by: str = ""
|
||||
created_at: Optional[str] = None
|
||||
completed_at: Optional[str] = None
|
||||
error: Optional[str] = None
|
||||
download_url: Optional[str] = None
|
||||
|
||||
|
||||
class BackupJobOut(_Out):
|
||||
uid: str = ""
|
||||
kind: str = ""
|
||||
status: str = ""
|
||||
target: Optional[str] = None
|
||||
backup_uid: Optional[str] = None
|
||||
download_url: Optional[str] = None
|
||||
error: Optional[str] = None
|
||||
bytes_out: int = 0
|
||||
file_count: int = 0
|
||||
sha256: Optional[str] = None
|
||||
created_at: Optional[str] = None
|
||||
completed_at: Optional[str] = None
|
||||
|
||||
|
||||
class BackupScheduleOut(_Out):
|
||||
uid: str = ""
|
||||
name: str = ""
|
||||
target: str = ""
|
||||
kind: str = ""
|
||||
every_seconds: int = 0
|
||||
cron: Optional[str] = None
|
||||
enabled: int = 0
|
||||
keep_last: int = 0
|
||||
next_run_at: Optional[str] = None
|
||||
last_run_at: Optional[str] = None
|
||||
last_job_uid: Optional[str] = None
|
||||
run_count: int = 0
|
||||
created_at: Optional[str] = None
|
||||
|
||||
|
||||
class BackupDashboardOut(_Out):
|
||||
storage: dict = {}
|
||||
backups: list[BackupOut] = []
|
||||
schedules: list[BackupScheduleOut] = []
|
||||
targets: list[dict] = []
|
||||
metrics: dict = {}
|
||||
generated_at: Optional[str] = None
|
||||
can_download_backups: bool = False
|
||||
admin_section: Optional[str] = None
|
||||
27
devplacepy/schemas/base.py
Normal file
27
devplacepy/schemas/base.py
Normal file
@ -0,0 +1,27 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class _Out(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
|
||||
class ActionResult(_Out):
|
||||
ok: bool = True
|
||||
redirect: Optional[str] = None
|
||||
data: Optional[Any] = None
|
||||
|
||||
|
||||
class ErrorOut(_Out):
|
||||
error: dict
|
||||
|
||||
|
||||
class ValidationErrorOut(_Out):
|
||||
error: str = "validation"
|
||||
fields: dict = {}
|
||||
messages: list = []
|
||||
100
devplacepy/schemas/containers.py
Normal file
100
devplacepy/schemas/containers.py
Normal file
@ -0,0 +1,100 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from devplacepy.schemas.base import _Out
|
||||
|
||||
|
||||
class InstanceOut(_Out):
|
||||
uid: str = ""
|
||||
project_uid: str = ""
|
||||
name: str = ""
|
||||
slug: str = ""
|
||||
container_id: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
desired_state: Optional[str] = None
|
||||
restart_policy: Optional[str] = None
|
||||
restart_count: int = 0
|
||||
ingress_slug: Optional[str] = None
|
||||
ingress_port: int = 0
|
||||
boot_command: Optional[str] = None
|
||||
boot_language: Optional[str] = None
|
||||
boot_script: Optional[str] = None
|
||||
run_as_uid: Optional[str] = None
|
||||
start_on_boot: int = 0
|
||||
cpu_limit: Optional[str] = None
|
||||
mem_limit: Optional[str] = None
|
||||
created_at: Optional[str] = None
|
||||
started_at: Optional[str] = None
|
||||
stopped_at: Optional[str] = None
|
||||
|
||||
|
||||
class ScheduleOut(_Out):
|
||||
uid: str = ""
|
||||
instance_uid: str = ""
|
||||
action: Optional[str] = None
|
||||
enabled: int = 0
|
||||
next_run_at: Optional[str] = None
|
||||
last_run_at: Optional[str] = None
|
||||
run_count: int = 0
|
||||
|
||||
|
||||
class ContainersOut(_Out):
|
||||
project: Optional[dict] = None
|
||||
instances: list[InstanceOut] = []
|
||||
|
||||
|
||||
class AdminContainersOut(_Out):
|
||||
instances: list = []
|
||||
projects: list = []
|
||||
boot_languages: list = []
|
||||
restart_policies: list = []
|
||||
admin_section: Optional[str] = None
|
||||
user: Optional[Any] = None
|
||||
|
||||
|
||||
class AdminContainerEditOut(_Out):
|
||||
instance: Optional[Any] = None
|
||||
project: Optional[dict] = None
|
||||
run_as_user: Optional[dict] = None
|
||||
boot_languages: list = []
|
||||
restart_policies: list = []
|
||||
admin_section: Optional[str] = None
|
||||
user: Optional[Any] = None
|
||||
|
||||
|
||||
class AdminContainerInstanceOut(_Out):
|
||||
instance: Optional[Any] = None
|
||||
project: Optional[dict] = None
|
||||
project_slug: Optional[str] = None
|
||||
events: list = []
|
||||
schedules: list = []
|
||||
stats: Optional[Any] = None
|
||||
runtime: Optional[Any] = None
|
||||
admin_section: Optional[str] = None
|
||||
user: Optional[Any] = None
|
||||
|
||||
|
||||
class BotFrameOut(_Out):
|
||||
slot: int = 0
|
||||
username: str = ""
|
||||
persona: str = ""
|
||||
action: str = ""
|
||||
status: str = ""
|
||||
url: str = ""
|
||||
label: str = ""
|
||||
captured_at: int = 0
|
||||
age_seconds: int = 0
|
||||
has_image: bool = False
|
||||
active: bool = False
|
||||
frame_url: str = ""
|
||||
|
||||
|
||||
class AdminBotsOut(_Out):
|
||||
frames: list[BotFrameOut] = []
|
||||
enabled: bool = False
|
||||
service_status: str = ""
|
||||
admin_section: Optional[str] = None
|
||||
user: Optional[Any] = None
|
||||
204
devplacepy/schemas/content.py
Normal file
204
devplacepy/schemas/content.py
Normal file
@ -0,0 +1,204 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from devplacepy.schemas.base import _Out
|
||||
|
||||
|
||||
class UserOut(_Out):
|
||||
uid: str = ""
|
||||
username: str = ""
|
||||
avatar_seed: Optional[str] = None
|
||||
bio: Optional[str] = None
|
||||
location: Optional[str] = None
|
||||
git_link: Optional[str] = None
|
||||
website: Optional[str] = None
|
||||
level: Optional[int] = None
|
||||
xp: Optional[int] = None
|
||||
stars: Optional[int] = None
|
||||
created_at: Optional[str] = None
|
||||
last_seen: Optional[str] = None
|
||||
|
||||
|
||||
class AttachmentOut(_Out):
|
||||
uid: str = ""
|
||||
filename: Optional[str] = None
|
||||
url: Optional[str] = None
|
||||
size: Optional[int] = None
|
||||
is_image: Optional[bool] = None
|
||||
is_video: Optional[bool] = None
|
||||
mime_type: Optional[str] = None
|
||||
created_at: Optional[str] = None
|
||||
can_modify: bool = False
|
||||
|
||||
|
||||
class VotesOut(_Out):
|
||||
up: int = 0
|
||||
down: int = 0
|
||||
|
||||
|
||||
class ReactionsOut(_Out):
|
||||
counts: dict = {}
|
||||
mine: list = []
|
||||
|
||||
|
||||
class PollOptionOut(_Out):
|
||||
uid: str = ""
|
||||
label: Optional[str] = None
|
||||
count: Optional[int] = None
|
||||
votes: Optional[int] = None
|
||||
pct: Optional[int] = None
|
||||
|
||||
|
||||
class PollOut(_Out):
|
||||
uid: str = ""
|
||||
question: Optional[str] = None
|
||||
options: list[PollOptionOut] = []
|
||||
total: int = 0
|
||||
my_choice: Optional[str] = None
|
||||
voted: Optional[str] = None
|
||||
|
||||
|
||||
class BadgeOut(_Out):
|
||||
name: Optional[str] = None
|
||||
created_at: Optional[str] = None
|
||||
|
||||
|
||||
class PostOut(_Out):
|
||||
uid: str = ""
|
||||
slug: Optional[str] = None
|
||||
user_uid: Optional[str] = None
|
||||
title: Optional[str] = None
|
||||
content: Optional[str] = None
|
||||
topic: Optional[str] = None
|
||||
stars: Optional[int] = None
|
||||
image: Optional[str] = None
|
||||
created_at: Optional[str] = None
|
||||
updated_at: Optional[str] = None
|
||||
|
||||
|
||||
class GistOut(_Out):
|
||||
uid: str = ""
|
||||
slug: Optional[str] = None
|
||||
user_uid: Optional[str] = None
|
||||
title: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
source_code: Optional[str] = None
|
||||
language: Optional[str] = None
|
||||
stars: Optional[int] = None
|
||||
created_at: Optional[str] = None
|
||||
updated_at: Optional[str] = None
|
||||
|
||||
|
||||
class ProjectOut(_Out):
|
||||
uid: str = ""
|
||||
slug: Optional[str] = None
|
||||
user_uid: Optional[str] = None
|
||||
title: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
project_type: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
platforms: Optional[Any] = None
|
||||
stars: Optional[int] = None
|
||||
is_private: Optional[bool] = None
|
||||
read_only: Optional[bool] = None
|
||||
release_date: Optional[str] = None
|
||||
demo_date: Optional[str] = None
|
||||
created_at: Optional[str] = None
|
||||
updated_at: Optional[str] = None
|
||||
|
||||
|
||||
class ProjectFileOut(_Out):
|
||||
uid: str = ""
|
||||
path: str = ""
|
||||
name: Optional[str] = None
|
||||
type: Optional[str] = None
|
||||
is_binary: Optional[bool] = None
|
||||
mime_type: Optional[str] = None
|
||||
size: Optional[int] = None
|
||||
url: Optional[str] = None
|
||||
updated_at: Optional[str] = None
|
||||
|
||||
|
||||
class ProjectFileRawOut(ProjectFileOut):
|
||||
content: Optional[str] = None
|
||||
|
||||
|
||||
class ProjectFilesOut(_Out):
|
||||
project: ProjectOut
|
||||
files: list[ProjectFileOut] = []
|
||||
is_owner: bool = False
|
||||
read_only: bool = False
|
||||
is_private: bool = False
|
||||
|
||||
|
||||
class NewsOut(_Out):
|
||||
uid: str = ""
|
||||
slug: Optional[str] = None
|
||||
title: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
content: Optional[str] = None
|
||||
url: Optional[str] = None
|
||||
source_name: Optional[str] = None
|
||||
author: Optional[str] = None
|
||||
grade: Optional[int] = None
|
||||
ai_grade: Optional[int] = None
|
||||
status: Optional[str] = None
|
||||
image_url: Optional[str] = None
|
||||
featured: Optional[int] = None
|
||||
has_unique_image: Optional[int] = None
|
||||
article_published: Optional[str] = None
|
||||
created_at: Optional[str] = None
|
||||
synced_at: Optional[str] = None
|
||||
|
||||
|
||||
class CommentOut(_Out):
|
||||
uid: str = ""
|
||||
user_uid: Optional[str] = None
|
||||
content: Optional[str] = None
|
||||
parent_uid: Optional[str] = None
|
||||
target_type: Optional[str] = None
|
||||
target_uid: Optional[str] = None
|
||||
created_at: Optional[str] = None
|
||||
|
||||
|
||||
class CommentEditOut(_Out):
|
||||
uid: str = ""
|
||||
content: str = ""
|
||||
url: str = ""
|
||||
updated_at: Optional[str] = None
|
||||
|
||||
|
||||
class CommentItemOut(_Out):
|
||||
comment: CommentOut
|
||||
author: Optional[UserOut] = None
|
||||
time_ago: Optional[str] = None
|
||||
votes: VotesOut = VotesOut()
|
||||
my_vote: int = 0
|
||||
children: list["CommentItemOut"] = []
|
||||
attachments: list[AttachmentOut] = []
|
||||
reactions: ReactionsOut = ReactionsOut()
|
||||
|
||||
|
||||
class NotificationOut(_Out):
|
||||
uid: str = ""
|
||||
type: Optional[str] = None
|
||||
message: Optional[str] = None
|
||||
read: Optional[bool] = None
|
||||
related_uid: Optional[str] = None
|
||||
target_url: Optional[str] = None
|
||||
created_at: Optional[str] = None
|
||||
|
||||
|
||||
class MessageOut(_Out):
|
||||
uid: str = ""
|
||||
sender_uid: Optional[str] = None
|
||||
receiver_uid: Optional[str] = None
|
||||
content: Optional[str] = None
|
||||
read: Optional[bool] = None
|
||||
created_at: Optional[str] = None
|
||||
|
||||
|
||||
CommentItemOut.model_rebuild()
|
||||
70
devplacepy/schemas/dbapi.py
Normal file
70
devplacepy/schemas/dbapi.py
Normal file
@ -0,0 +1,70 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from devplacepy.schemas.base import _Out
|
||||
|
||||
|
||||
class DbTableOut(_Out):
|
||||
name: str = ""
|
||||
row_count: int = 0
|
||||
soft_delete: bool = False
|
||||
|
||||
|
||||
class DbTableListOut(_Out):
|
||||
tables: list[DbTableOut] = []
|
||||
count: int = 0
|
||||
|
||||
|
||||
class DbColumnOut(_Out):
|
||||
name: str = ""
|
||||
type: str = ""
|
||||
|
||||
|
||||
class DbSchemaOut(_Out):
|
||||
table: str = ""
|
||||
columns: list[DbColumnOut] = []
|
||||
soft_delete: bool = False
|
||||
row_count: int = 0
|
||||
|
||||
|
||||
class DbRowsOut(_Out):
|
||||
table: str = ""
|
||||
rows: list[dict] = []
|
||||
count: int = 0
|
||||
next_cursor: Optional[Any] = None
|
||||
|
||||
|
||||
class DbRowOut(_Out):
|
||||
table: str = ""
|
||||
row: Optional[dict] = None
|
||||
|
||||
|
||||
class DbQueryOut(_Out):
|
||||
sql: str = ""
|
||||
valid: bool = False
|
||||
statement_type: str = ""
|
||||
rows: list[dict] = []
|
||||
row_count: int = 0
|
||||
truncated: bool = False
|
||||
suspicious: list[str] = []
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
class NlQueryOut(_Out):
|
||||
question: str = ""
|
||||
table: str = ""
|
||||
sql: str = ""
|
||||
valid: bool = False
|
||||
attempts: int = 0
|
||||
dialect: str = "sqlite"
|
||||
applied_soft_delete: bool = False
|
||||
suspicious: list[str] = []
|
||||
tables: list[str] = []
|
||||
executed: bool = False
|
||||
rows: list[dict] = []
|
||||
row_count: int = 0
|
||||
truncated: bool = False
|
||||
error: Optional[str] = None
|
||||
136
devplacepy/schemas/game.py
Normal file
136
devplacepy/schemas/game.py
Normal file
@ -0,0 +1,136 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from devplacepy.schemas.base import _Out
|
||||
|
||||
|
||||
class GameCropOut(_Out):
|
||||
key: str = ""
|
||||
name: str = ""
|
||||
icon: str = ""
|
||||
cost: int = 0
|
||||
reward_coins: int = 0
|
||||
reward_xp: int = 0
|
||||
min_level: int = 1
|
||||
grow_seconds: int = 0
|
||||
locked: bool = False
|
||||
|
||||
|
||||
class GamePlotOut(_Out):
|
||||
slot: int = 0
|
||||
state: str = "empty"
|
||||
crop_key: str = ""
|
||||
crop_name: str = ""
|
||||
crop_icon: str = ""
|
||||
reward_coins: int = 0
|
||||
reward_xp: int = 0
|
||||
ready_at: str = ""
|
||||
remaining_seconds: int = 0
|
||||
watered_count: int = 0
|
||||
max_waters: int = 0
|
||||
can_water: bool = False
|
||||
can_steal: bool = False
|
||||
steal_coins: int = 0
|
||||
steal_cooldown_seconds: int = 0
|
||||
steal_reason: str = ""
|
||||
is_golden: bool = False
|
||||
fertilize_cost: int = 0
|
||||
|
||||
|
||||
class GamePerkOut(_Out):
|
||||
key: str = ""
|
||||
name: str = ""
|
||||
icon: str = ""
|
||||
description: str = ""
|
||||
level: int = 0
|
||||
max_level: int = 0
|
||||
cost: int = 0
|
||||
maxed: bool = False
|
||||
effect: str = ""
|
||||
|
||||
|
||||
class GameLegacyOut(_Out):
|
||||
key: str = ""
|
||||
name: str = ""
|
||||
icon: str = ""
|
||||
description: str = ""
|
||||
level: int = 0
|
||||
max_level: int = 0
|
||||
cost: int = 0
|
||||
maxed: bool = False
|
||||
effect: str = ""
|
||||
|
||||
|
||||
class GameQuestOut(_Out):
|
||||
kind: str = ""
|
||||
label: str = ""
|
||||
goal: int = 0
|
||||
progress: int = 0
|
||||
reward_coins: int = 0
|
||||
reward_xp: int = 0
|
||||
claimed: bool = False
|
||||
can_claim: bool = False
|
||||
|
||||
|
||||
class GameFarmOut(_Out):
|
||||
owner_username: str = ""
|
||||
owner_uid: str = ""
|
||||
is_owner: bool = False
|
||||
coins: int = 0
|
||||
xp: int = 0
|
||||
level: int = 1
|
||||
level_into: int = 0
|
||||
level_span: int = 0
|
||||
level_is_max: bool = False
|
||||
total_harvests: int = 0
|
||||
plot_count: int = 0
|
||||
max_plots: int = 0
|
||||
next_plot_cost: int = 0
|
||||
ci_tier: int = 1
|
||||
ci_label: str = ""
|
||||
ci_speed: float = 1.0
|
||||
ci_next_tier: int = 0
|
||||
ci_next_label: str = ""
|
||||
ci_next_cost: int = 0
|
||||
plots: list[GamePlotOut] = []
|
||||
crops: list[GameCropOut] = []
|
||||
prestige: int = 0
|
||||
prestige_multiplier: float = 1.0
|
||||
prestige_min_level: int = 0
|
||||
prestige_available: bool = False
|
||||
streak: int = 0
|
||||
daily_available: bool = False
|
||||
daily_reward: int = 0
|
||||
perks: list[GamePerkOut] = []
|
||||
quests: list[GameQuestOut] = []
|
||||
stars: int = 0
|
||||
legacy: list[GameLegacyOut] = []
|
||||
steal_cooldown_seconds: int = 0
|
||||
|
||||
|
||||
class GameStateOut(_Out):
|
||||
ok: bool = True
|
||||
farm: GameFarmOut
|
||||
|
||||
|
||||
class GameFarmViewOut(_Out):
|
||||
farm: GameFarmOut
|
||||
page_title: str = ""
|
||||
meta_description: str = ""
|
||||
stole_coins: int = 0
|
||||
|
||||
|
||||
class GameLeaderboardEntryOut(_Out):
|
||||
rank: int = 0
|
||||
username: str = ""
|
||||
level: int = 1
|
||||
xp: int = 0
|
||||
coins: int = 0
|
||||
total_harvests: int = 0
|
||||
prestige: int = 0
|
||||
score: int = 0
|
||||
|
||||
|
||||
class GameLeaderboardOut(_Out):
|
||||
entries: list[GameLeaderboardEntryOut] = []
|
||||
42
devplacepy/schemas/gateway.py
Normal file
42
devplacepy/schemas/gateway.py
Normal file
@ -0,0 +1,42 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from devplacepy.schemas.base import _Out
|
||||
|
||||
|
||||
class GatewayUsageOut(_Out):
|
||||
window_hours: int = 48
|
||||
generated_at: Optional[str] = None
|
||||
requests: int = 0
|
||||
volume: dict = {}
|
||||
tokens: dict = {}
|
||||
latency: dict = {}
|
||||
errors: dict = {}
|
||||
cost: dict = {}
|
||||
behavior: dict = {}
|
||||
hourly: list = []
|
||||
notes: dict = {}
|
||||
admin_section: Optional[str] = None
|
||||
|
||||
|
||||
class UserAiUsageOut(_Out):
|
||||
owner_id: str = ""
|
||||
window_hours: int = 24
|
||||
generated_at: Optional[str] = None
|
||||
requests: int = 0
|
||||
success: int = 0
|
||||
failed: int = 0
|
||||
success_pct: float = 0.0
|
||||
error_pct: float = 0.0
|
||||
tokens: dict = {}
|
||||
cost: dict = {}
|
||||
latency: dict = {}
|
||||
first_used: Optional[str] = None
|
||||
last_used: Optional[str] = None
|
||||
by_model: list = []
|
||||
by_backend: list = []
|
||||
hourly: list = []
|
||||
notes: dict = {}
|
||||
69
devplacepy/schemas/issues.py
Normal file
69
devplacepy/schemas/issues.py
Normal file
@ -0,0 +1,69 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from devplacepy.schemas.base import _Out
|
||||
from devplacepy.schemas.content import AttachmentOut
|
||||
|
||||
|
||||
class IssueItemOut(_Out):
|
||||
number: int = 0
|
||||
title: str = ""
|
||||
state: str = "open"
|
||||
html_url: Optional[str] = None
|
||||
comments_count: int = 0
|
||||
created_at: Optional[str] = None
|
||||
updated_at: Optional[str] = None
|
||||
author_username: Optional[str] = None
|
||||
author_uid: Optional[str] = None
|
||||
author_avatar_seed: Optional[str] = None
|
||||
is_local_author: bool = False
|
||||
|
||||
|
||||
class IssueCommentOut(_Out):
|
||||
id: int = 0
|
||||
body: str = ""
|
||||
html_url: Optional[str] = None
|
||||
created_at: Optional[str] = None
|
||||
author_username: Optional[str] = None
|
||||
author_uid: Optional[str] = None
|
||||
author_avatar_seed: Optional[str] = None
|
||||
is_local_author: bool = False
|
||||
attachments: list[AttachmentOut] = []
|
||||
|
||||
|
||||
class IssueDetailOut(_Out):
|
||||
issue: IssueItemOut
|
||||
body: str = ""
|
||||
comments: list[IssueCommentOut] = []
|
||||
attachments: list[AttachmentOut] = []
|
||||
can_comment: bool = False
|
||||
can_attach: bool = False
|
||||
viewer_is_admin: bool = False
|
||||
|
||||
|
||||
class IssueAttachmentsOut(_Out):
|
||||
number: int = 0
|
||||
attachments: list[AttachmentOut] = []
|
||||
|
||||
|
||||
class IssueJobOut(_Out):
|
||||
uid: str = ""
|
||||
kind: str = ""
|
||||
status: str = ""
|
||||
number: Optional[int] = None
|
||||
issue_url: Optional[str] = None
|
||||
enhanced: bool = False
|
||||
error: Optional[str] = None
|
||||
created_at: Optional[str] = None
|
||||
completed_at: Optional[str] = None
|
||||
|
||||
|
||||
class IssuesOut(_Out):
|
||||
issues: list[IssueItemOut] = []
|
||||
pagination: Optional[Any] = None
|
||||
state: str = "open"
|
||||
configured: bool = True
|
||||
error_message: Optional[str] = None
|
||||
227
devplacepy/schemas/jobs.py
Normal file
227
devplacepy/schemas/jobs.py
Normal file
@ -0,0 +1,227 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from devplacepy.schemas.base import _Out
|
||||
|
||||
|
||||
class ZipJobOut(_Out):
|
||||
uid: str = ""
|
||||
kind: str = ""
|
||||
status: str = ""
|
||||
preferred_name: Optional[str] = None
|
||||
download_url: Optional[str] = None
|
||||
error: Optional[str] = None
|
||||
bytes_in: int = 0
|
||||
bytes_out: int = 0
|
||||
item_count: int = 0
|
||||
file_count: int = 0
|
||||
dir_count: int = 0
|
||||
created_at: Optional[str] = None
|
||||
completed_at: Optional[str] = None
|
||||
|
||||
|
||||
class PlanningJobOut(_Out):
|
||||
uid: str = ""
|
||||
kind: str = ""
|
||||
status: str = ""
|
||||
preferred_name: Optional[str] = None
|
||||
download_url: Optional[str] = None
|
||||
markdown: Optional[str] = None
|
||||
ai_used: bool = False
|
||||
error: Optional[str] = None
|
||||
issue_count: int = 0
|
||||
bytes_out: int = 0
|
||||
created_at: Optional[str] = None
|
||||
completed_at: Optional[str] = None
|
||||
|
||||
|
||||
class ForkJobOut(_Out):
|
||||
uid: str = ""
|
||||
kind: str = ""
|
||||
status: str = ""
|
||||
preferred_name: Optional[str] = None
|
||||
project_uid: Optional[str] = None
|
||||
project_url: Optional[str] = None
|
||||
source_project_uid: Optional[str] = None
|
||||
error: Optional[str] = None
|
||||
item_count: int = 0
|
||||
created_at: Optional[str] = None
|
||||
completed_at: Optional[str] = None
|
||||
|
||||
|
||||
class SeoJobOut(_Out):
|
||||
uid: str = ""
|
||||
kind: str = ""
|
||||
status: str = ""
|
||||
target: Optional[str] = None
|
||||
mode: Optional[str] = None
|
||||
ws_url: Optional[str] = None
|
||||
report_url: Optional[str] = None
|
||||
score: Optional[int] = None
|
||||
grade: Optional[str] = None
|
||||
page_count: int = 0
|
||||
error: Optional[str] = None
|
||||
created_at: Optional[str] = None
|
||||
completed_at: Optional[str] = None
|
||||
|
||||
|
||||
class SeoReportOut(_Out):
|
||||
uid: str = ""
|
||||
status: str = ""
|
||||
target: Optional[str] = None
|
||||
mode: Optional[str] = None
|
||||
score: Optional[int] = None
|
||||
grade: Optional[str] = None
|
||||
page_count: int = 0
|
||||
counts: dict = {}
|
||||
categories: dict = {}
|
||||
pages: list = []
|
||||
checks: list = []
|
||||
site: dict = {}
|
||||
generated_at: Optional[str] = None
|
||||
|
||||
|
||||
class SeoMetaOut(_Out):
|
||||
uid: str = ""
|
||||
target_type: str = ""
|
||||
target_uid: str = ""
|
||||
seo_title: str = ""
|
||||
seo_description: str = ""
|
||||
seo_keywords: str = ""
|
||||
status: str = ""
|
||||
source: str = ""
|
||||
generated_at: Optional[str] = None
|
||||
|
||||
|
||||
class DeepsearchJobOut(_Out):
|
||||
uid: str = ""
|
||||
kind: str = ""
|
||||
status: str = ""
|
||||
query: Optional[str] = None
|
||||
depth: int = 0
|
||||
max_pages: int = 0
|
||||
ws_url: Optional[str] = None
|
||||
chat_ws_url: Optional[str] = None
|
||||
session_url: Optional[str] = None
|
||||
score: Optional[int] = None
|
||||
confidence: Optional[float] = None
|
||||
source_diversity: Optional[float] = None
|
||||
page_count: int = 0
|
||||
chunk_count: int = 0
|
||||
error: Optional[str] = None
|
||||
created_at: Optional[str] = None
|
||||
completed_at: Optional[str] = None
|
||||
|
||||
|
||||
class DeepsearchSessionOut(_Out):
|
||||
uid: str = ""
|
||||
status: str = ""
|
||||
query: Optional[str] = None
|
||||
depth: int = 0
|
||||
max_pages: int = 0
|
||||
score: Optional[int] = None
|
||||
confidence: Optional[float] = None
|
||||
source_diversity: Optional[float] = None
|
||||
page_count: int = 0
|
||||
chunk_count: int = 0
|
||||
summary: Optional[str] = None
|
||||
sources: list = []
|
||||
findings: list = []
|
||||
gaps: list = []
|
||||
timeline: list = []
|
||||
chat_ws_url: Optional[str] = None
|
||||
export_md_url: Optional[str] = None
|
||||
export_json_url: Optional[str] = None
|
||||
export_pdf_url: Optional[str] = None
|
||||
viewer_is_admin: bool = False
|
||||
viewer_owns: bool = False
|
||||
created_at: Optional[str] = None
|
||||
completed_at: Optional[str] = None
|
||||
|
||||
|
||||
class DbQueryJobOut(_Out):
|
||||
uid: str = ""
|
||||
kind: str = ""
|
||||
status: str = ""
|
||||
ws_url: Optional[str] = None
|
||||
result_url: Optional[str] = None
|
||||
row_count: Optional[int] = None
|
||||
truncated: Optional[bool] = None
|
||||
error: Optional[str] = None
|
||||
created_at: Optional[str] = None
|
||||
completed_at: Optional[str] = None
|
||||
|
||||
|
||||
class IsslopAnalysisOut(_Out):
|
||||
uid: str = ""
|
||||
status: str = ""
|
||||
source_url: str = ""
|
||||
source_kind: str = ""
|
||||
grade: Optional[str] = None
|
||||
slop_score: Optional[float] = None
|
||||
origin_score: Optional[float] = None
|
||||
quality_deficit_score: Optional[float] = None
|
||||
human_percent: Optional[float] = None
|
||||
ai_percent: Optional[float] = None
|
||||
category: Optional[str] = None
|
||||
confidence: Optional[str] = None
|
||||
files_total: int = 0
|
||||
files_analyzed: int = 0
|
||||
detected_builder: Optional[str] = None
|
||||
dom_slop_score: Optional[float] = None
|
||||
error: Optional[str] = None
|
||||
report_url: Optional[str] = None
|
||||
badge_url: Optional[str] = None
|
||||
events_url: Optional[str] = None
|
||||
topic: Optional[str] = None
|
||||
created_at: Optional[str] = None
|
||||
finished_at: Optional[str] = None
|
||||
|
||||
|
||||
class IsslopListOut(_Out):
|
||||
analyses: list = []
|
||||
|
||||
|
||||
class IsslopReportOut(_Out):
|
||||
uid: str = ""
|
||||
status: str = ""
|
||||
source_url: str = ""
|
||||
source_kind: str = ""
|
||||
grade: Optional[str] = None
|
||||
slop_score: Optional[float] = None
|
||||
origin_score: Optional[float] = None
|
||||
quality_deficit_score: Optional[float] = None
|
||||
human_percent: Optional[float] = None
|
||||
ai_percent: Optional[float] = None
|
||||
category: Optional[str] = None
|
||||
confidence: Optional[str] = None
|
||||
files_total: int = 0
|
||||
files_analyzed: int = 0
|
||||
detected_builder: Optional[str] = None
|
||||
dom_slop_score: Optional[float] = None
|
||||
error: Optional[str] = None
|
||||
content_hash: Optional[str] = None
|
||||
markdown: str = ""
|
||||
generator_model: str = ""
|
||||
generated_at: Optional[str] = None
|
||||
badge: dict = {}
|
||||
files: list = []
|
||||
images: list = []
|
||||
dom_pages: list = []
|
||||
created_at: Optional[str] = None
|
||||
finished_at: Optional[str] = None
|
||||
|
||||
|
||||
class IsslopSourceOut(_Out):
|
||||
uid: str = ""
|
||||
path: str = ""
|
||||
language: str = ""
|
||||
category: str = ""
|
||||
origin_score: float = 0.0
|
||||
quality_deficit_score: float = 0.0
|
||||
source: str = ""
|
||||
truncated: bool = False
|
||||
signals: list = []
|
||||
232
devplacepy/schemas/listings.py
Normal file
232
devplacepy/schemas/listings.py
Normal file
@ -0,0 +1,232 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from devplacepy.schemas.base import _Out
|
||||
from devplacepy.schemas.content import (
|
||||
AttachmentOut,
|
||||
CommentItemOut,
|
||||
GistOut,
|
||||
MessageOut,
|
||||
NewsOut,
|
||||
NotificationOut,
|
||||
PollOut,
|
||||
PostOut,
|
||||
ProjectOut,
|
||||
ReactionsOut,
|
||||
UserOut,
|
||||
)
|
||||
|
||||
|
||||
class FeedItemOut(_Out):
|
||||
post: PostOut
|
||||
author: Optional[UserOut] = None
|
||||
time_ago: Optional[str] = None
|
||||
my_vote: int = 0
|
||||
comment_count: int = 0
|
||||
attachments: list[AttachmentOut] = []
|
||||
recent_comments: list[CommentItemOut] = []
|
||||
reactions: ReactionsOut = ReactionsOut()
|
||||
bookmarked: bool = False
|
||||
poll: Optional[PollOut] = None
|
||||
|
||||
|
||||
class GistItemOut(_Out):
|
||||
gist: GistOut
|
||||
author: Optional[UserOut] = None
|
||||
time_ago: Optional[str] = None
|
||||
my_vote: int = 0
|
||||
comment_count: int = 0
|
||||
recent_comments: list[CommentItemOut] = []
|
||||
|
||||
|
||||
class ProjectListItemOut(ProjectOut):
|
||||
author_name: Optional[str] = None
|
||||
my_vote: int = 0
|
||||
recent_comments: list[CommentItemOut] = []
|
||||
|
||||
|
||||
class NewsListItemOut(_Out):
|
||||
article: NewsOut
|
||||
time_ago: Optional[str] = None
|
||||
image_url: Optional[str] = None
|
||||
grade: Optional[int] = None
|
||||
featured: Optional[int] = None
|
||||
recent_comments: list[CommentItemOut] = []
|
||||
|
||||
|
||||
class ConversationOut(_Out):
|
||||
other_user: Optional[UserOut] = None
|
||||
last_message: Optional[str] = None
|
||||
last_message_at: Optional[str] = None
|
||||
unread: bool = False
|
||||
|
||||
|
||||
class MessageItemOut(_Out):
|
||||
message: MessageOut
|
||||
sender: Optional[UserOut] = None
|
||||
is_mine: bool = False
|
||||
time_ago: Optional[str] = None
|
||||
attachments: list[AttachmentOut] = []
|
||||
|
||||
|
||||
class NotificationItemOut(_Out):
|
||||
notification: NotificationOut
|
||||
actor: Optional[UserOut] = None
|
||||
time_ago: Optional[str] = None
|
||||
|
||||
|
||||
class NotificationGroupOut(_Out):
|
||||
label: Optional[str] = None
|
||||
entries: list[NotificationItemOut] = []
|
||||
|
||||
|
||||
class LeaderboardEntryOut(_Out):
|
||||
uid: Optional[str] = None
|
||||
username: Optional[str] = None
|
||||
avatar_seed: Optional[str] = None
|
||||
stars: Optional[int] = None
|
||||
level: Optional[int] = None
|
||||
rank: Optional[int] = None
|
||||
|
||||
|
||||
class SavedItemOut(_Out):
|
||||
target_type: Optional[str] = None
|
||||
target_uid: Optional[str] = None
|
||||
type_label: Optional[str] = None
|
||||
title: Optional[str] = None
|
||||
url: Optional[str] = None
|
||||
time_ago: Optional[str] = None
|
||||
|
||||
|
||||
class FeedOut(_Out):
|
||||
posts: list[FeedItemOut] = []
|
||||
current_tab: Optional[str] = None
|
||||
current_topic: Optional[str] = None
|
||||
search: Optional[str] = None
|
||||
next_cursor: Optional[str] = None
|
||||
total_members: Optional[int] = None
|
||||
posts_today: Optional[int] = None
|
||||
total_projects: Optional[int] = None
|
||||
total_gists: Optional[int] = None
|
||||
top_authors: list[UserOut] = []
|
||||
daily_topic: Optional[Any] = None
|
||||
online_users: list[UserOut] = []
|
||||
|
||||
|
||||
class PostDetailOut(_Out):
|
||||
post: PostOut
|
||||
author: Optional[UserOut] = None
|
||||
is_owner: bool = False
|
||||
star_count: int = 0
|
||||
my_vote: int = 0
|
||||
time_ago: Optional[str] = None
|
||||
comments: list[CommentItemOut] = []
|
||||
attachments: list[AttachmentOut] = []
|
||||
reactions: ReactionsOut = ReactionsOut()
|
||||
bookmarked: bool = False
|
||||
poll: Optional[PollOut] = None
|
||||
comment_count: Optional[int] = None
|
||||
related_posts: list[FeedItemOut] = []
|
||||
topics: list[str] = []
|
||||
|
||||
|
||||
class ProjectsOut(_Out):
|
||||
projects: list[ProjectListItemOut] = []
|
||||
current_tab: Optional[str] = None
|
||||
search: Optional[str] = None
|
||||
project_type: Optional[str] = None
|
||||
total_count: Optional[int] = None
|
||||
next_cursor: Optional[str] = None
|
||||
total_members: Optional[int] = None
|
||||
top_authors: list[UserOut] = []
|
||||
|
||||
|
||||
class ProjectDetailOut(_Out):
|
||||
project: ProjectOut
|
||||
author: Optional[UserOut] = None
|
||||
is_owner: bool = False
|
||||
star_count: int = 0
|
||||
my_vote: int = 0
|
||||
time_ago: Optional[str] = None
|
||||
comments: list[CommentItemOut] = []
|
||||
attachments: list[AttachmentOut] = []
|
||||
reactions: ReactionsOut = ReactionsOut()
|
||||
bookmarked: bool = False
|
||||
platforms: Optional[Any] = None
|
||||
is_private: bool = False
|
||||
read_only: bool = False
|
||||
forked_from: Optional[dict] = None
|
||||
fork_count: int = 0
|
||||
file_count: int = 0
|
||||
|
||||
|
||||
class GistsOut(_Out):
|
||||
gists: list[GistItemOut] = []
|
||||
total_count: Optional[int] = None
|
||||
next_cursor: Optional[str] = None
|
||||
current_language: Optional[str] = None
|
||||
search: Optional[str] = None
|
||||
languages: list[tuple[str, str]] = []
|
||||
gist_language_codes: list[str] = []
|
||||
|
||||
|
||||
class GistDetailOut(_Out):
|
||||
gist: GistOut
|
||||
author: Optional[UserOut] = None
|
||||
is_owner: bool = False
|
||||
star_count: int = 0
|
||||
my_vote: int = 0
|
||||
time_ago: Optional[str] = None
|
||||
comments: list[CommentItemOut] = []
|
||||
attachments: list[AttachmentOut] = []
|
||||
reactions: ReactionsOut = ReactionsOut()
|
||||
bookmarked: bool = False
|
||||
|
||||
|
||||
class NewsListOut(_Out):
|
||||
articles: list[NewsListItemOut] = []
|
||||
next_cursor: Optional[str] = None
|
||||
|
||||
|
||||
class NewsDetailOut(_Out):
|
||||
article: NewsOut
|
||||
canonical_slug: Optional[str] = None
|
||||
image_url: Optional[str] = None
|
||||
grade: Optional[int] = None
|
||||
time_ago: Optional[str] = None
|
||||
comments: list[CommentItemOut] = []
|
||||
bookmarked: bool = False
|
||||
|
||||
|
||||
class MessagesOut(_Out):
|
||||
conversations: list[ConversationOut] = []
|
||||
messages: list[MessageItemOut] = []
|
||||
other_user: Optional[UserOut] = None
|
||||
current_conversation: Optional[str] = None
|
||||
search: Optional[str] = None
|
||||
other_online: bool = False
|
||||
other_last_seen: Optional[str] = None
|
||||
|
||||
|
||||
class NotificationsOut(_Out):
|
||||
notification_groups: list[NotificationGroupOut] = []
|
||||
next_cursor: Optional[str] = None
|
||||
|
||||
|
||||
class LeaderboardOut(_Out):
|
||||
entries: list[LeaderboardEntryOut] = []
|
||||
user_rank: Optional[int] = None
|
||||
total_members: Optional[int] = None
|
||||
posts_today: Optional[int] = None
|
||||
total_projects: Optional[int] = None
|
||||
total_gists: Optional[int] = None
|
||||
top_authors: list[UserOut] = []
|
||||
featured_news: list[NewsOut] = []
|
||||
|
||||
|
||||
class SavedOut(_Out):
|
||||
items: list[SavedItemOut] = []
|
||||
next_cursor: Optional[str] = None
|
||||
80
devplacepy/schemas/profile.py
Normal file
80
devplacepy/schemas/profile.py
Normal file
@ -0,0 +1,80 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from devplacepy.schemas.base import _Out
|
||||
from devplacepy.schemas.content import BadgeOut, ProjectOut, UserOut
|
||||
from devplacepy.schemas.listings import FeedItemOut, GistItemOut
|
||||
|
||||
|
||||
class MediaItemOut(_Out):
|
||||
uid: str = ""
|
||||
original_filename: Optional[str] = None
|
||||
file_size: Optional[int] = None
|
||||
mime_type: Optional[str] = None
|
||||
url: Optional[str] = None
|
||||
thumbnail_url: Optional[str] = None
|
||||
has_thumbnail: bool = False
|
||||
is_image: bool = False
|
||||
is_video: bool = False
|
||||
target_type: Optional[str] = None
|
||||
target_uid: Optional[str] = None
|
||||
target_url: Optional[str] = None
|
||||
created_at: Optional[str] = None
|
||||
|
||||
|
||||
class ProfileOut(_Out):
|
||||
profile_user: UserOut
|
||||
posts: list[FeedItemOut] = []
|
||||
badges: list[BadgeOut] = []
|
||||
achievements: list[dict] = []
|
||||
badge_total: int = 0
|
||||
badge_earned: int = 0
|
||||
projects: list[ProjectOut] = []
|
||||
gists: list[GistItemOut] = []
|
||||
current_tab: Optional[str] = None
|
||||
posts_count: Optional[int] = None
|
||||
is_following: bool = False
|
||||
is_blocked: bool = False
|
||||
is_muted: bool = False
|
||||
is_owner: bool = False
|
||||
profile_online: bool = False
|
||||
can_view_api_key: bool = False
|
||||
api_key: Optional[str] = None
|
||||
ai_correction_enabled: bool = False
|
||||
ai_correction_sync: bool = False
|
||||
ai_correction_prompt: Optional[str] = None
|
||||
ai_modifier_enabled: bool = False
|
||||
ai_modifier_sync: bool = False
|
||||
ai_modifier_prompt: Optional[str] = None
|
||||
telegram_paired: bool = False
|
||||
notif_telegram_paired: bool = False
|
||||
can_manage_customization: bool = False
|
||||
cust_disable_global: bool = False
|
||||
cust_disable_pagetype: bool = False
|
||||
ai_quota: Optional[dict] = None
|
||||
correction_usage: Optional[dict] = None
|
||||
modifier_usage: Optional[dict] = None
|
||||
activities: list[Any] = []
|
||||
rank: Optional[int] = None
|
||||
heatmap: Optional[Any] = None
|
||||
heatmap_months: Optional[Any] = None
|
||||
streak: Optional[Any] = None
|
||||
people: list[Any] = []
|
||||
follow_pagination: Optional[Any] = None
|
||||
followers_count: Optional[int] = None
|
||||
following_count: Optional[int] = None
|
||||
viewer_is_admin: bool = False
|
||||
media: list[MediaItemOut] = []
|
||||
media_pagination: Optional[Any] = None
|
||||
notification_prefs: list[Any] = []
|
||||
|
||||
|
||||
class TelegramPairOut(_Out):
|
||||
ok: bool = True
|
||||
paired: bool = False
|
||||
code: Optional[str] = None
|
||||
expires_at: Optional[str] = None
|
||||
ttl_minutes: Optional[int] = None
|
||||
@ -32,6 +32,7 @@ CATEGORY_BY_PREFIX: dict[str, str] = {
|
||||
"proxy": "ingress",
|
||||
"seo": "tools",
|
||||
"deepsearch": "tools",
|
||||
"isslop": "tools",
|
||||
"ai": "ai",
|
||||
"database": "database",
|
||||
"pubsub": "pubsub",
|
||||
|
||||
238
devplacepy/services/bot/agent.py
Normal file
238
devplacepy/services/bot/agent.py
Normal file
@ -0,0 +1,238 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
import random
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BotAgentMixin:
|
||||
def _build_menu(self, page_state: dict) -> list[dict]:
|
||||
page = page_state["page"]
|
||||
menu: list[dict] = []
|
||||
|
||||
def add(action: str, desc: str) -> None:
|
||||
menu.append({"action": action, "desc": desc})
|
||||
|
||||
if page == "post":
|
||||
if not page_state["own_post"] or page_state["mentioned"]:
|
||||
add("comment", "write a comment on this post")
|
||||
add("reply", "reply to an existing comment in the thread")
|
||||
add("vote", "upvote this post")
|
||||
add("vote_comment", "upvote a good comment in the thread")
|
||||
add("vote_poll", "answer the attached poll if there is one")
|
||||
add("react", "add an emoji reaction to this post")
|
||||
elif page == "news_detail":
|
||||
add("comment", "write a comment reacting to this news article")
|
||||
elif page == "project_detail":
|
||||
add("vote", "star this project")
|
||||
add("react", "add an emoji reaction")
|
||||
add("comment", "write a comment on this project")
|
||||
elif page == "gist_detail":
|
||||
add("comment", "write a comment on this code snippet")
|
||||
add("vote", "star this gist")
|
||||
add("react", "add an emoji reaction")
|
||||
elif page == "profile":
|
||||
if page_state.get("own_profile"):
|
||||
if not self.state.profile_filled:
|
||||
add("update_profile", "fill in your own profile bio and links")
|
||||
add("open_post", "open one of your posts to read it")
|
||||
else:
|
||||
if not page_state["follows"]:
|
||||
add("follow", "follow this developer")
|
||||
if page_state["can_message"]:
|
||||
add("message", "send this developer a direct message")
|
||||
add("open_post", "open one of their posts to read it")
|
||||
elif page == "projects_list":
|
||||
add("vote", "star a project in the list")
|
||||
if page_state["can_project"]:
|
||||
add("create_project", "create a new project of your own")
|
||||
add("open_project", "open a project to read it")
|
||||
elif page == "gists_list":
|
||||
add("vote", "star a gist in the list")
|
||||
if not page_state["gists"]:
|
||||
add("create_gist", "publish a new code snippet of your own")
|
||||
add("open_gist", "open a gist to read it")
|
||||
elif page == "news_list":
|
||||
add("open_news", "open a news article to read it")
|
||||
elif page == "notifications":
|
||||
add("check_notifications", "read and act on your notifications")
|
||||
else:
|
||||
add("open_post", "open a post from the feed to read it")
|
||||
add("vote", "upvote a post in the feed")
|
||||
add("react", "react to a post in the feed")
|
||||
add("vote_poll", "answer a poll in the feed")
|
||||
if page_state["can_post"] and not page_state["did_post"]:
|
||||
add("create_post", "write a new post reacting to a tech news article")
|
||||
if not page_state["gists"]:
|
||||
add("create_gist", "publish a new code snippet of your own")
|
||||
add("open_profile", "open another developer's profile")
|
||||
add("search", "search the platform for a topic you care about")
|
||||
add("browse", "browse a category or section")
|
||||
if page != "notifications" and page_state.get("unread_notifications"):
|
||||
add(
|
||||
"check_notifications",
|
||||
"open your notifications and reply to anyone who mentioned or replied to you",
|
||||
)
|
||||
add("navigate", "move to another section: feed, projects, gists, or news")
|
||||
return menu
|
||||
|
||||
def _record_history(self, action: str, rationale: str) -> None:
|
||||
note = action.replace("_", " ")
|
||||
if rationale:
|
||||
note = f"{note} ({rationale[:60]})"
|
||||
self._session_history.append(note)
|
||||
if len(self._session_history) > 20:
|
||||
self._session_history = self._session_history[-20:]
|
||||
|
||||
async def _vote_for_page(self, page: str) -> bool:
|
||||
if page in ("project_detail", "projects_list"):
|
||||
return await self._vote_on_project()
|
||||
if page in ("gist_detail", "gists_list"):
|
||||
return await self._vote_on_gist()
|
||||
return await self._vote_on_feed()
|
||||
|
||||
async def _navigate_to(self, target: str) -> bool:
|
||||
routes = {
|
||||
"feed": "/feed",
|
||||
"projects": "/projects",
|
||||
"gists": "/gists",
|
||||
"news": "/news",
|
||||
"notifications": "/notifications",
|
||||
}
|
||||
path = routes.get((target or "").lower())
|
||||
if path:
|
||||
await self.b.goto(f"{self.base_url}{path}")
|
||||
return True
|
||||
return await self._random_nav_click()
|
||||
|
||||
async def _dispatch_action(self, action: str, target: str, page_state: dict) -> bool:
|
||||
if action == "navigate":
|
||||
return await self._navigate_to(target)
|
||||
if action == "vote":
|
||||
return await self._vote_for_page(page_state["page"])
|
||||
handlers = {
|
||||
"comment": self._comment_on_post,
|
||||
"reply": self._reply_to_random_comment,
|
||||
"vote_comment": self._vote_on_comments,
|
||||
"vote_poll": self._vote_on_poll,
|
||||
"react": self._react_to_object,
|
||||
"create_post": self._create_post,
|
||||
"create_gist": self._create_gist,
|
||||
"create_project": self._click_create_project,
|
||||
"update_profile": self._update_profile,
|
||||
"follow": self._follow_user,
|
||||
"message": self._send_message,
|
||||
"open_post": self._click_post_link,
|
||||
"open_gist": self._click_gist_link,
|
||||
"open_profile": self._click_profile_link,
|
||||
"open_project": lambda: self._open_content("project"),
|
||||
"open_news": lambda: self._open_content("news"),
|
||||
"check_notifications": self._check_notifications,
|
||||
"search": lambda: self._search("feed"),
|
||||
"browse": self._browse_section,
|
||||
}
|
||||
handler = handlers.get(action)
|
||||
if handler is None:
|
||||
return False
|
||||
try:
|
||||
result = await handler()
|
||||
except Exception as e:
|
||||
logger.debug("dispatch %s failed: %s", action, e)
|
||||
return False
|
||||
return True if result is None else bool(result)
|
||||
|
||||
async def _run_plan(
|
||||
self, plan: list[dict], page_state: dict
|
||||
) -> tuple[int, bool, int, int, int]:
|
||||
b = self.b
|
||||
actions = 0
|
||||
did_post = False
|
||||
projs = 0
|
||||
follows = 0
|
||||
gists = 0
|
||||
if not plan:
|
||||
await self._navigate_to("feed")
|
||||
return 0, did_post, projs, follows, gists
|
||||
for entry in plan:
|
||||
action = entry["action"]
|
||||
if action == "create_post" and (did_post or page_state["did_post"]):
|
||||
continue
|
||||
if action == "create_gist" and (gists or page_state["gists"]):
|
||||
continue
|
||||
if action == "create_project" and projs:
|
||||
continue
|
||||
if await self._dispatch_action(action, entry.get("target", ""), page_state):
|
||||
actions += 1
|
||||
self._record_history(action, entry.get("rationale", ""))
|
||||
if action == "create_post":
|
||||
did_post = True
|
||||
elif action == "create_project":
|
||||
projs += 1
|
||||
elif action == "follow":
|
||||
follows += 1
|
||||
elif action == "create_gist":
|
||||
gists += 1
|
||||
await b._idle(0.5, 1.5)
|
||||
return actions, did_post, projs, follows, gists
|
||||
|
||||
async def _cycle_ai(
|
||||
self,
|
||||
mood: str = "normal",
|
||||
*,
|
||||
session_posted: bool = False,
|
||||
session_projects: int = 0,
|
||||
session_follows: int = 0,
|
||||
session_gists: int = 0,
|
||||
can_project: bool = False,
|
||||
can_post: bool = False,
|
||||
can_message: bool = False,
|
||||
) -> tuple[int, bool, int, int, int]:
|
||||
b = self.b
|
||||
curl = await b.url()
|
||||
page_text = (await b.html()).lower()
|
||||
page_state = await self._page_state(
|
||||
curl,
|
||||
page_text,
|
||||
did_post=session_posted,
|
||||
follows=session_follows,
|
||||
gists=session_gists,
|
||||
can_project=can_project,
|
||||
can_post=can_post,
|
||||
can_message=can_message,
|
||||
)
|
||||
if page_state["page"] in (
|
||||
"post",
|
||||
"news_detail",
|
||||
"project_detail",
|
||||
"gist_detail",
|
||||
):
|
||||
await self._read_like_human(4, 90)
|
||||
menu = self._build_menu(page_state)
|
||||
decision = await self._generate(
|
||||
self.llm.decide,
|
||||
self.state.identity,
|
||||
page_state,
|
||||
menu,
|
||||
self._session_history,
|
||||
self._decision_temperature,
|
||||
)
|
||||
if not decision:
|
||||
decision = {"plan": [], "energy": self._session_energy, "stop_after": 0}
|
||||
self._session_energy = decision.get("energy", self._session_energy)
|
||||
self._session_stop_after = decision.get("stop_after", 0)
|
||||
actions, did_post, projs, follows, gists = await self._run_plan(
|
||||
decision["plan"], page_state
|
||||
)
|
||||
return (
|
||||
actions,
|
||||
did_post or session_posted,
|
||||
session_projects + projs,
|
||||
session_follows + follows,
|
||||
session_gists + gists,
|
||||
)
|
||||
|
||||
def _scaled_pause(self) -> int:
|
||||
base = random.randint(self._pause_min, self._pause_max)
|
||||
factor = 1.4 - 0.8 * self._session_energy
|
||||
return max(1, int(base * factor))
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user