207 lines
30 KiB
Markdown
207 lines
30 KiB
Markdown
|
|
# CLAUDE.md
|
|||
|
|
|
|||
|
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|||
|
|
|
|||
|
|
## Project
|
|||
|
|
|
|||
|
|
DevPlace is a server-rendered social network for developers. FastAPI backend serves Jinja2 templates with pure ES6 module JavaScript on the frontend. SQLite via the `dataset` library (auto-syncs schema). No JS framework, no NPM, no JWT.
|
|||
|
|
|
|||
|
|
## Commands
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
make install # pip install -e .
|
|||
|
|
make dev # uvicorn --reload on port 10500
|
|||
|
|
make prod # uvicorn 2 workers, port 10500 (backlog 8192)
|
|||
|
|
make test # Playwright + unit tests, headless, -x fail-fast
|
|||
|
|
make test-headed # same tests in visible browser
|
|||
|
|
make locust # Locust load test, interactive web UI
|
|||
|
|
make locust-headless # Locust CLI mode for CI
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Validate code without running the suite: confirm `python -c "from devplacepy.main import app"` imports clean and check each touched language manually (Python compiles/imports, JS parses, CSS braces and HTML tags balance).
|
|||
|
|
|
|||
|
|
Single test: `python -m pytest tests/test_feed.py::test_name -v --tb=line -x`
|
|||
|
|
|
|||
|
|
CLI (installed as `devplace`):
|
|||
|
|
```bash
|
|||
|
|
devplace role get <username>
|
|||
|
|
devplace role set <username> <member|admin>
|
|||
|
|
devplace news clear # delete all news rows
|
|||
|
|
devplace news sanitize # strip HTML from news descriptions/content
|
|||
|
|
devplace attachments prune # remove orphan attachment records/files
|
|||
|
|
devplace devii reset-quota <username> # reset one user's rolling 24h AI quota
|
|||
|
|
devplace devii reset-quota --guests # reset every guest quota
|
|||
|
|
devplace devii reset-quota --all # reset every quota (users and guests)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Tests run on ports 10501+ (xdist worker offset) with a tempfile SQLite DB and `DEVPLACE_DISABLE_SERVICES=1` so background services don't start.
|
|||
|
|
|
|||
|
|
## Environment variables
|
|||
|
|
|
|||
|
|
| Var | Default | Purpose |
|
|||
|
|
|-----|---------|---------|
|
|||
|
|
| `DEVPLACE_DATABASE_URL` | `sqlite:///devplace.db` | Override DB path (tests use this) |
|
|||
|
|
| `SECRET_KEY` | hardcoded fallback | Session signing |
|
|||
|
|
| `DEVPLACE_DISABLE_SERVICES` | unset | When `1`, NewsService and other background services skip start (set by test conftest) |
|
|||
|
|
| `PLAYWRIGHT_HEADLESS` | `1` in tests | Toggle headed mode |
|
|||
|
|
|
|||
|
|
## Architecture
|
|||
|
|
|
|||
|
|
### Request pipeline
|
|||
|
|
`main.py` mounts `/static`, registers every router with its prefix, installs middlewares (`X-Robots-Tag`/`X-Content-Type-Options` security headers, a per-IP rate limit held in an in-process `defaultdict`, and a maintenance gate), and registers global 404/500 handlers that render `error.html` via the shared templates instance. The rate limit reads `rate_limit_per_minute` / `rate_limit_window_seconds` from `site_settings` (default 60/60s, floored to `max(1, …)`). The maintenance middleware short-circuits non-admin requests with a 503 `error.html` when `maintenance_mode="1"`, but always allows `/static`, `/avatar`, `/auth`, `/admin` and admin users so an operator can never lock themselves out.
|
|||
|
|
|
|||
|
|
`@app.on_event("startup")` calls `init_db()` and, unless `DEVPLACE_DISABLE_SERVICES` is set, registers `NewsService` with `service_manager` and kicks off `start_all()` as an asyncio task. `@app.on_event("shutdown")` calls `service_manager.stop_all()`.
|
|||
|
|
|
|||
|
|
`GET /` is the landing page for guests; authenticated users get redirected to `/feed`. The landing pulls up to 6 articles where `show_on_landing=1` from the `news` table plus 6 recent posts (joined via the batch helpers in `database.py`).
|
|||
|
|
|
|||
|
|
### Routing layout
|
|||
|
|
One file per domain in `devplacepy/routers/`. Prefixes are wired in `main.py`:
|
|||
|
|
|
|||
|
|
| Prefix | Router |
|
|||
|
|
|--------|--------|
|
|||
|
|
| `/auth` | auth.py |
|
|||
|
|
| `/feed` | feed.py |
|
|||
|
|
| `/posts` | posts.py |
|
|||
|
|
| `/comments` | comments.py |
|
|||
|
|
| `/projects` | projects.py |
|
|||
|
|
| `/projects/{slug}/files` | project_files.py - per-project virtual filesystem (CRUD dirs/files, upload, inline edit) |
|
|||
|
|
| `/profile` | profile.py |
|
|||
|
|
| `/messages` | messages.py |
|
|||
|
|
| `/notifications` | notifications.py |
|
|||
|
|
| `/votes` | votes.py |
|
|||
|
|
| `/avatar` | avatar.py |
|
|||
|
|
| `/follow` | follow.py |
|
|||
|
|
| `/admin` | admin.py |
|
|||
|
|
| `/admin/services` | services.py |
|
|||
|
|
| `/bugs` | bugs.py |
|
|||
|
|
| `/gists` | gists.py |
|
|||
|
|
| `/news` | news.py |
|
|||
|
|
| `/uploads` | uploads.py |
|
|||
|
|
| `/openai` | openai_gateway.py |
|
|||
|
|
| `/devii` | devii.py - WebSocket terminal (`/devii/ws`), page, `/devii/usage`, `/devii/session` |
|
|||
|
|
| (none) | seo.py - `/robots.txt`, `/sitemap.xml` |
|
|||
|
|
|
|||
|
|
Docs (`routers/docs.py` `DOCS_PAGES`) entries take optional `admin: True` (hidden + 404 for non-admins, but still indexed and surfaced only to admins by `docs_search`) and `section: "..."` (a nested sidebar group rendered by `docs_base.html`). The member-facing `devii` prose page is functional; admins also get a `Devii internals` section of `devii-*` technical subpages. Prose docs pages are rendered to HTML **server-side** (`docs_prose.py`, mistune GFM): the handler converts the `data-render` markdown block to HTML before sending and drops the `data-render` attribute, so the client never re-renders it (no markdown-to-HTML flicker, faster first paint). Authored example markup inside that block is therefore still HTML-escaped (`<dp-...>`); content outside the block (component live-demo blocks and their `<script type="module">`) passes through untouched. User-generated content elsewhere still uses the client `data-render` pipeline. The public `Components` section (`component-*` prose pages) documents the custom web components with a **live, interactive example** on each page. A prose page's `<div class="docs-content" data-render>` is rendered client-side via marked + DOMPurify on `textContent`, so any example markup shown as code inside it MUST be HTML-escaped (`<dp-dialog>`) or the browser parses it as a real element before render; the live demo itself goes in a separate block OUTSIDE the `data-render` div, where a `<script type="module">` (which imports its own component module, since it executes before `Application.js`) wires it up.
|
|||
|
|
|
|||
|
|
### Templates and frontend
|
|||
|
|
- All routers MUST import the shared `templates` from `devplacepy.templating`. Do NOT instantiate `Jinja2Templates` per router - globals (`avatar_url`, `get_unread_count`, `get_user_projects`, `format_date`) are registered on that single instance.
|
|||
|
|
- Templates extend `base.html`, add page CSS via `{% block extra_head %}`, page JS via `{% block extra_js %}`.
|
|||
|
|
- All JS in `static/js/` is ES6 modules, one class per file, instantiated as `app` and reachable everywhere. The browser-side application root is `Application.js`.
|
|||
|
|
- **Custom web components** live in `static/js/components/` (prefix `dp-`): `dp-avatar`, `dp-code`, `dp-content` (wraps the `contentRenderer` markdown/sanitize/media engine behind `data-render`), `dp-toast`, `dp-dialog`, `dp-context-menu`, `dp-upload` (the single file-upload button used everywhere; replaced the old `AttachmentUploader`). Each extends `Component` (a thin `HTMLElement` base with `attr`/`boolAttr`/`intAttr` helpers), renders into the **light DOM** (no shadow root, so global CSS applies) and self-registers via `customElements.define` at the bottom of its file. `components/index.js` imports them all and is imported by `Application.js`, so every element is defined site-wide. The singletons are created once in `Application.js` and exposed as `app.dialog` / `app.contextMenu` / `app.toast`. Self-contained presentational widgets become components; partial-bound controllers (votes, reactions, comments) and pure utilities stay plain modules because they enhance server-rendered markup rather than render standalone UI. They are documented in the public docs **Components** section.
|
|||
|
|
- CDN scripts in `base.html` (marked, highlight.js, emoji-picker-element) MUST use `defer` or `type="module"` - otherwise `wait_until="domcontentloaded"` in Playwright tests will time out.
|
|||
|
|
- For clickable avatars/usernames, reuse `templates/_avatar_link.html` and `templates/_user_link.html` via `{% include %}` with `_user`, `_size`, `_size_class` locals.
|
|||
|
|
|
|||
|
|
### Content rendering pipeline
|
|||
|
|
`ContentRenderer.js` runs on every element with `data-render`, in this exact order: emoji shortcode → markdown via `marked` → **`DOMPurify.sanitize`** → `highlight.js` on `<pre><code>` → image URL → YouTube URL → autolink. Uses a `NodeIterator` that skips `CODE`/`PRE`/`SCRIPT`/`STYLE` so URLs inside code blocks are never rewritten.
|
|||
|
|
|
|||
|
|
`DOMPurify.sanitize` runs on the raw `marked` output (untrusted) before `processMedia` injects trusted YouTube iframes, so user content cannot inject script/event-handler/iframe payloads while our own embeds survive. Sanitization is fail-closed: `render()` throws if `DOMPurify` is not loaded rather than emitting unsanitized HTML. Because content is rendered client-side from `element.textContent`, Jinja autoescaping alone does not protect it - this sanitize step is the XSS control. Server-side, JSON-LD emitted via `{{ page_schema | safe }}` is escaped in `seo.py` `_json_ld_dumps` (`<`/`>`/`&` and line separators → `\uXXXX`) to prevent `</script>` breakout.
|
|||
|
|
|
|||
|
|
### Auth
|
|||
|
|
Session cookies named `session`, value is a 64-char hex token from `secrets.token_hex(32)`. Passwords hashed with `pbkdf2_sha256` via passlib. `get_current_user(request)` returns user dict or `None`, with a per-process TTL cache (`_user_cache`, 300s) keyed by token. `require_user(request)` raises a 303 redirect to `/` for guests; `require_admin(request)` redirects to `/feed` for non-admins. Public pages (feed, news detail, project detail, gists) use `get_current_user` so guests can browse - POSTs are all guarded by `require_user`.
|
|||
|
|
|
|||
|
|
### Database (`devplacepy/database.py`)
|
|||
|
|
SQLite via `dataset.connect` with WAL + 30s busy timeout + 256MB mmap. `init_db()` is idempotent - every index is created via `CREATE INDEX IF NOT EXISTS` wrapped in try/except, and seed defaults for `site_settings` only insert when the key doesn't already exist.
|
|||
|
|
|
|||
|
|
**SQLite is synchronous by design and will never be made async (hard rule).** The `dataset`/SQLite layer is synchronous and is called directly inside the async route handlers. This is intentional and more than fast enough for this platform: the database is a local file tuned with WAL, `synchronous=NORMAL`, a 30s busy timeout, an 8MB page cache, and a 256MB mmap, so queries are sub-millisecond and never meaningfully stall the event loop. Do NOT convert the database layer to async, do NOT wrap DB calls in a threadpool/`run_in_executor`/`to_thread`, and do NOT raise the synchronous DB access as a performance, blocking-I/O, or "requests aren't fully async" concern. This is a settled, sophisticated design choice and is not open for revisiting.
|
|||
|
|
|
|||
|
|
Runtime config lives in `site_settings`, read via `get_setting(key, default)` / `get_int_setting(key, default)` (60s TTL cache, invalidated cross-worker via the `cache_state` version table - `get_setting` calls `sync_local_cache("settings", ...)`, writes call `bump_cache_version("settings")`; the `_user_cache` in `utils.py` uses the same primitive under the `auth` name). Besides site/news/upload keys, the **Operational** group is admin-editable at `/admin/settings`: `rate_limit_per_minute`, `rate_limit_window_seconds`, `news_service_interval`, `session_max_age_days`, `session_remember_days`, `registration_open`, `maintenance_mode`, `maintenance_message`. Consumers always pass the production default to `get_setting`, so behavior is correct even before the row exists. Numeric operational values are floored at the call site so an invalid `0` can't lock out writes or stall a service. Booleans are stored as `"0"`/`"1"` and rendered as `<select>` (not checkboxes) because the settings save handler skips empty form values - an unchecked checkbox could never be turned off.
|
|||
|
|
|
|||
|
|
Batch helpers (`get_users_by_uids`, `get_comment_counts_by_post_uids`, `get_vote_counts`, `load_comments`, `get_attachments_by_type`) exist specifically to avoid N+1 queries - use them in feed/listing routes instead of per-row lookups.
|
|||
|
|
|
|||
|
|
`dataset.find()` does NOT accept raw SQL. Use kwargs for equality, dict syntax (`{">=": value}`) for comparisons, or `table.table.columns.<col>.in_([...])` for IN clauses. `db.query("... :p", p=val)` is the raw-SQL escape hatch (named params as kwargs, not a dict). `table.update({"uid": x, ...}, ["uid"])` requires the key columns as a list. Always `if "table" in db.tables:` before raw SQL, since `dataset` only auto-creates tables when something is first inserted.
|
|||
|
|
|
|||
|
|
### Background services (`devplacepy/services/`)
|
|||
|
|
`BaseService` provides the async run loop, a `deque(maxlen=20)` log buffer, and graceful cancellation. `ServiceManager` is a singleton that registers, starts, and stops services. `NewsService` fetches articles from `news_api_url`, grades each via `news_ai_url`, and inserts ALL of them into `news` with `status="published"` or `"draft"` based on the configurable `news_grade_threshold` - nothing is silently skipped. The `/admin/services` page polls live status and the log tail.
|
|||
|
|
|
|||
|
|
`GatewayService` (`/openai/v1/*`) is the **single point of truth for AI**. Every other AI consumer (news, bots, Devii guests) defaults its endpoint to `config.INTERNAL_GATEWAY_URL` (`http://localhost:10500/openai/v1/chat/completions`, override via `DEVPLACE_INTERNAL_BASE_URL`), sends the generic model `molodetz`, and authenticates with the auto-generated `gateway_internal_key` (via `database.internal_gateway_key()`) when its own key is unset. **Per-user attribution:** the gateway also accepts a real user's `api_key` (Bearer / X-API-KEY / session), gated by `gateway_allow_users` (default on); `resolve_owner()` records `(owner_kind, owner_id)` per call, so usage is attributed and limitable per user. Devii operating a signed-in user authenticates its LLM calls with that user's own `api_key` (set as the session's `ai_key` in `build_settings(..., owner_kind="user")`), so a user's full gateway spend (Devii and direct API calls) rolls up under their uid - surfaced admin-only on the profile page via `build_user_usage()` / `GET /admin/users/{uid}/ai-usage`. Guests keep the internal key. Real provider keys/URLs/models live only in the gateway; `database.migrate_ai_gateway_settings()` (run in `init_db()`) generates the internal key, migrates `DEEPSEEK_API_KEY`/`OPENROUTER_API_KEY` env into the editable, non-secret `gateway_api_key`/`gateway_vision_key` settings, and rewrites old external AI URLs to the gateway. The gateway is `default_enabled=True`. The gateway records every upstream call to `gateway_usage_ledger` (cost from the upstream native `cost` field when present, else computed from per-1M pricing) and samples `gateway_concurrency_samples`; `services/openai_gateway/analytics.py` rolls these into per-hour/24h token, cost, latency, error, and behavior metrics shown on `/admin/ai-usage` (`AiUsageMonitor.js`, schema `GatewayUsageOut`, Devii action `ai_usage`). It also retries transient upstream failures and trips a circuit breaker (`services/openai_gateway/reliability.py`). TTFT/inter-token latency are not tracked because the upstream is called non-streaming.
|
|||
|
|
|
|||
|
|
To add a service: extend `BaseService`, override `async def run_once(self) -> None`, register in `main.py`'s startup with `service_manager.register(YourService())`. It auto-appears on the services page.
|
|||
|
|
|
|||
|
|
### Devii assistant (`devplacepy/services/devii/`)
|
|||
|
|
`DeviiService` is the in-platform agentic assistant (a relocated standalone agent), exposed as a WebSocket terminal at `/devii/ws` (router `routers/devii.py`, prefix `/devii`) and reachable from the **Devii** user-dropdown item (`data-devii-open`) and auto-opened on `/docs` (`data-devii-autoopen`). Frontend is `static/js/DeviiTerminal.js` (an `Application.js`-instantiated controller, `app.devii`) plus the `devii-terminal`/`devii-avatar` custom elements and ported renderers under `static/js/devii/`, the vendored md-clippy avatar under `static/vendor/md-clippy/`, and `static/css/devii.css`. A signed-in user's Devii operates their **own** account (PlatformClient base_url = this instance, auth = the user's `api_key`); guests get a sandbox. `DeviiHub` keys one `DeviiSession` per owner `(owner_kind, owner_id)` - `user`/uid or `guest`/cookie - broadcasting every frame to all of that owner's sockets. Conversation history persists to `devii_conversations` (rehydrated on reconnect, surviving restarts); per-turn cost goes to `devii_usage_ledger` (the authoritative 24h-spend source - the in-memory `CostTracker` is display-only) and an audit row to `devii_turns`; tasks live in `devii_tasks` (guests use an in-memory store). The self-learning `LessonStore` (reflect/recall) is **owner-scoped and never shared** - `LessonStore(db, owner_kind, owner_id)` filters every read/write by owner, persistent in `devii_lessons` for users and ephemeral (`memory_db()`) for guests; `forget_lessons` purges them. (Never share one `LessonStore` across sessions - it leaks one user's lessons into others' recall.) The 24h `$` cap is checked before each turn. The effective cap is resolved by `config.effective_daily_limit(cfg, owner_kind, is_admin)` (the single source of truth, used by both `service.daily_limit_for` and `build_settings`): guests use `devii_guest_daily_usd` (default $0.05), users `devii_user_daily_usd` ($1.00), and **administrators are exempt by default** via `devii_admin_daily_usd` (default `0.0`, where **`0` = unlimited**). All three are editable on the Devii service config (`/admin/services`). The WS gate is `if limit > 0 and spent >= limit` so a `0` cap never blocks. Quotas are **resettable** (clearing the owner's `devii_usage_ledger` rows): per-user via `POST /admin/users/{uid}/reset-ai-quota` (button on the admin Users page), globally via `POST /admin/ai-quota/reset-guests` and `/reset-all` (buttons on `/admin/ai-usage`), and from the CLI via `devplace devii reset-quota <username> | --guests | --all`. **Financial data is admin-only:** any monetary figure (USD cost, pricing, spend, limit) is restricted to administrators; members and guests see only the **percentage** of their 24h quota used. The owner's admin status is resolved server-side (`is_admin(user)`) and threaded WS → `hub.get_or_create(is_admin=)` → `DeviiSession` → `Dispatcher`. The `Action` dataclass has a `requires_admin` flag (`cost_stats` is admin-only USD; the member-safe `usage_quota` returns only `{used_pct, turns_today, limit_reached}`), gated twice: `Catalog.tool_schemas_for(authenticated, is_admin)` withholds the schema from non-admins and the dispatcher raises `AuthRequiredError` for any `requires_admin` action a non-admin attempts. `GET /devii/usage` adds `spent_24h`/`limit` only for admins; the standalone `devii` CLI runs `is_admin=True`. The catalog cost/analytics tools `ai_usage` and `site_analytics` are `requires_admin=True` too. The profile route's `_ai_quota(include_cost=viewer_is_admin)` emits `spent_usd`/`limit_usd` only for admins in **both** the HTML and the `respond(..., model=ProfileOut)` JSON form (hiding dollars in the template alone would still leak them to a member fetching their own profile with `Accept: application/json`). For aggregate questions Devii calls a documented, admin-secured HTTP endpoint `GET /admin/analytics` (`routers/admin.py`, JSON, `is_admin`→403; backed by `database.get_platform_analytics()` - one cached UNION query for active-user windows/signups/totals/top authors) via the normal `http` catalog action `site_analytics`, so
|
|||
|
|
|
|||
|
|
Devii also drives the **user's own browser** through a `client` tool channel (same WS request/response pattern as the avatar, via `_browser_request`): `services/devii/client/ClientController` + `actions/client_actions.py` expose `get_page_context`, `run_js`, `highlight_element`/`clear_highlights`, `show_toast`, `scroll_to_element`, `navigate_to`, `reload_page` - executed by `static/js/devii/DeviiClient.js` and routed by the terminal (`type:"client"` → `client_result`). This powers live on-screen tutorials, page-context awareness, and navigation/refresh. `run_js` is gated by the `devii_allow_eval` config field (default on). Because one session spans all of an owner's tabs, a client/avatar request is dispatched to a single **target** tab via `_pick_target()` (ranked by reported `(focused, visible, attach_seq)` from the terminal's `{"type":"visibility"}` frames), not broadcast and not the last-attached socket - otherwise `reload_page` reloads a hidden background tab while reporting success.
|
|||
|
|
|
|||
|
|
### SEO
|
|||
|
|
`devplacepy/seo.py` generates JSON-LD schemas (WebSite, BreadcrumbList, DiscussionForumPosting, ProfilePage, SoftwareApplication). Every router builds context via `base_seo_context(request, ...)` and merges it into the template response. `base.html` reads `page_title`, `meta_description`, `meta_robots`, `canonical_url`, `og_*`, `breadcrumbs`, `page_schema`. Auth/messages/notifications are `noindex,nofollow`; profiles with fewer than 2 posts are `noindex,follow`. `/robots.txt` and `/sitemap.xml` are served by `routers/seo.py`.
|
|||
|
|
|
|||
|
|
## Conventions (project-specific)
|
|||
|
|
|
|||
|
|
- **No comments, no docstrings in source.** Code is self-documenting.
|
|||
|
|
- **Forbidden variable name patterns:** `_new`, `_old`, `_temp`, `_v2`, `better_`, `my_`, `the_`.
|
|||
|
|
- **Form validation is Pydantic-native.** Routers declare a typed body param `data: Annotated[SomeForm, Form()]` (models live in `models.py`); FastAPI validates it and the global `RequestValidationError` handler in `main.py` re-renders auth pages with messages (400) or redirects other routes. Read raw `await request.form()` only when also reading an uploaded file alongside a model - adding a `File()` param would embed the model under its parameter name.
|
|||
|
|
- **`RedirectResponse(url=..., status_code=302)`** - always pass `status_code` as an integer literal, never `status.HTTP_302_FOUND`.
|
|||
|
|
- **Ownership checks:** compare `resource["user_uid"] == user["uid"]` before edit/delete. Deletes must cascade: comments → votes → resource.
|
|||
|
|
- **All dates shown to users are DD/MM/YYYY** (European). Use `format_date()` from `utils.py` (template global), or `time_ago()` which auto-switches to DD/MM/YYYY past 30 days.
|
|||
|
|
- **Slug + UUID lookup:** resources with slugs (posts, gists, news, projects) accept either the slug or the bare UUID - see `resolve_by_slug()` in `database.py`. Slugs are generated via `make_combined_slug(title, uid)` which prefixes the first 8 chars of the UUID.
|
|||
|
|
- **Username:** 3–32 chars, letters/numbers/hyphens/underscores. **Password:** min 6 chars.
|
|||
|
|
- **Avatars:** Multiavatar SVG generated locally from the username seed in <5ms, in-memory cached (cleared on restart). URL `/avatar/multiavatar/{seed}?size={size}`. Falls back to initial-based SVG on error.
|
|||
|
|
|
|||
|
|
## Modal pattern
|
|||
|
|
|
|||
|
|
`Application.js` `initModals()` toggles a `.visible` CSS class on `.modal-overlay`; the CSS rule `.modal-overlay.visible { display: flex; }` handles visibility. Triggers usually have `href="#"`, so call `e.preventDefault()` in click handlers. `.modal-close` is wired generically - no inline JS needed.
|
|||
|
|
|
|||
|
|
## Polymorphic comments and votes
|
|||
|
|
|
|||
|
|
The `comments` table uses `(target_type, target_uid)` so the same `_comment_section.html` component works for `post`, `project`, `gist`, and `news`. Votes follow the same shape via `/votes/{target_type}/{uid}`. `resolve_target_redirect()` in `comments.py` maps target_type back to the correct detail URL.
|
|||
|
|
|
|||
|
|
## Testing
|
|||
|
|
|
|||
|
|
Playwright (NOT pytest-playwright - uninstall if present, it conflicts). 419 tests across 34 files in `tests/` (mixed Playwright UI tests and in-process `TestClient`/asyncio unit tests). The fixture stack:
|
|||
|
|
|
|||
|
|
- `app_server` (session-scoped): spawns uvicorn as a subprocess on `_xdist_port()` (10501 + worker index) with a tempfile DB.
|
|||
|
|
- `browser_context` (session-scoped): one Playwright context shared by all tests.
|
|||
|
|
- `page` (function-scoped): `clear_cookies()` on the session context, then a fresh page.
|
|||
|
|
- `alice` / `bob`: seeded users `alice_test` / `bob_test` logged in via the login form. `bob` gets its own context for multi-user tests. Returns `(page, user_dict)`.
|
|||
|
|
|
|||
|
|
Required test patterns:
|
|||
|
|
- Every `page.goto(...)` and `page.wait_for_url(...)` MUST pass `wait_until="domcontentloaded"`. CDN libs and avatars cause the default `"load"` to time out.
|
|||
|
|
- Prefer `page.locator(...).wait_for(state="visible")` over `wait_for_selector`.
|
|||
|
|
- Scope ambiguous selectors: comment Delete is `.comment-action-btn:has-text('Delete')`; create-post Post button is `#create-post-modal button.btn-primary:has-text('Post')`; comment textarea is `.comment-form textarea[name='content']`.
|
|||
|
|
- Failure screenshots auto-save to `/tmp/devplace_test_screenshots/` via the `pytest_runtest_makereport` hook in `conftest.py`.
|
|||
|
|
- The server is shared session-wide, so a test that flips a global `site_settings` value (maintenance mode, registration, rate limit, session length) MUST restore it in a `try/finally`. `conftest.py`'s `app_server` seeds `rate_limit_per_minute="1000000"` so the suite is never throttled; rate-limit unit tests patch `m.get_int_setting` rather than the `RATE_LIMIT` constant (the constant is only a fallback now). See `tests/test_operational.py` and `tests/test_ratelimit.py`.
|
|||
|
|
|
|||
|
|
**Never run tests unless the user explicitly asks for it.** Not the full suite, not a single file. Validate code with a clean import (`python -c "from devplacepy.main import app"`) and per-language manual checks instead.
|
|||
|
|
|
|||
|
|
## Feature workflow
|
|||
|
|
|
|||
|
|
Every feature in DevPlace is **one data source fanning out into several consumers**. A single route is rendered as HTML, served as JSON, called by the Devii agent, and described in the API docs - all from the same handler. The cardinal failure mode is changing one layer and forgetting a connected one (add a route but no JSON schema; add a JSON endpoint but no Devii tool; ship a feature but never document it). The checklist below is ordered by data flow so nothing is dropped. See `AGENTS.md` -> "Anatomy of a feature" for the deep relationship map and a worked example.
|
|||
|
|
|
|||
|
|
### 1. Understand (read before writing)
|
|||
|
|
Read the router (`routers/{area}.py`), template (`templates/{area}.html`), test file (`tests/test_{area}.py`), and the matching `AGENTS.md` domain section. Trace the existing data flow: input model -> router -> data helper -> response (HTML and JSON).
|
|||
|
|
|
|||
|
|
### 2. Data layer (`database.py`, `models.py`, `schemas.py`)
|
|||
|
|
- **`database.py`** - add query/batch helpers here, never inline N+1 loops in routers (use `get_users_by_uids`, `build_pagination`, `_in_clause`). Guard raw SQL with `if "table" in db.tables`. Schema auto-syncs via `dataset`; add indexes in `init_db()` with `CREATE INDEX IF NOT EXISTS`.
|
|||
|
|
- **`models.py`** - the Pydantic `Form` model for any new input (`data: Annotated[SomeForm, Form()]`).
|
|||
|
|
- **`schemas.py`** - the Pydantic `*Out` model for the JSON response. If the route serves JSON via `respond(..., model=XOut)`, every new context key the JSON exposes MUST exist on `XOut`, or it is silently dropped.
|
|||
|
|
|
|||
|
|
### 3. Server layer (`routers/{area}.py`, `main.py`)
|
|||
|
|
- Handler with the right auth guard: `get_current_user` (public read), `require_user` (member POST), `require_admin` (admin). POSTs are always guarded.
|
|||
|
|
- **Route order matters**: specific paths (`/{username}/followers`) must be declared before catch-alls (`/{username}`).
|
|||
|
|
- Return HTML+JSON via `respond(request, template, ctx, model=XOut)`; return pure JSON via `JSONResponse`.
|
|||
|
|
- Register any NEW router in `main.py` with its prefix.
|
|||
|
|
|
|||
|
|
### 4. View layer (`templates/`, `static/css/`, `static/js/`)
|
|||
|
|
- Import the shared `templates` from `devplacepy.templating` (never instantiate per router). Extend `base.html`; page CSS in `{% block extra_head %}`, page JS in `{% block extra_js %}`.
|
|||
|
|
- Reuse partials (`_avatar_link.html`, `_user_link.html`) and template globals (`avatar_url`, `format_date`, `guest_disabled`). Dates are DD/MM/YYYY via `format_date`.
|
|||
|
|
- JS is ES6 modules, one class per file, instantiated on `app` (`Application.js` is the root).
|
|||
|
|
|
|||
|
|
### 5. Agent + docs layer (do not skip - these are the most-forgotten)
|
|||
|
|
- **`services/devii/actions/catalog.py`** - if the route is something a user could ask Devii to do, add an `Action` tool (set `requires_auth=False` for public reads). Without this, the feature exists for humans but is invisible to the assistant.
|
|||
|
|
- **`docs_api.py`** - every public/auth endpoint gets an `endpoint()` entry in the correct group, with params and a `sample_response`. **`routers/docs.py`** `DOCS_PAGES` - prose pages.
|
|||
|
|
- **SEO** (`seo.py`) - if a new public page, set `base_seo_context` and any JSON-LD; add to `routers/seo.py` sitemap if indexable.
|
|||
|
|
|
|||
|
|
### 6. Document the change (the three files, every time)
|
|||
|
|
- **`README.md`** - product-facing. Update when routes, config/env vars, dependencies, or user-visible features change. No dev process, no begging, no emoticons.
|
|||
|
|
- **`AGENTS.md`** - the deep companion. Add a domain section for any new mechanic, helper, pitfall, or cross-layer wiring.
|
|||
|
|
- **`CLAUDE.md`** - this file. Update only when a NEW architectural rule, convention, or workflow step is introduced (not per feature).
|
|||
|
|
|
|||
|
|
### 7. Validate (never skip; never run the test suite unless asked)
|
|||
|
|
- Validate each touched language manually (Python compiles/imports, JS parses, CSS braces and HTML tags balance).
|
|||
|
|
- Build: `python -c "from devplacepy.main import app"` must import clean.
|
|||
|
|
- **No em-dash** (neither the long dash character nor its HTML entity) anywhere in touched files - grep and confirm; use a hyphen instead.
|
|||
|
|
- If UI changed: `falcon take --output /tmp/verify.png && falcon describe /tmp/verify.png`.
|
|||
|
|
- **Never run tests unless the user explicitly asks.**
|
|||
|
|
|
|||
|
|
`AGENTS.md` is the long-form companion to this file with every domain-specific pitfall (notifications, gists CodeMirror setup, public feed rules, post editing, news detail comments, etc.) and the full feature relationship map. Read it when working on an unfamiliar area.
|