Compare commits
No commits in common. "master" and "typosaurus/138-fix-navigation-bar-link-icons-and-text-appearing-on-separate" have entirely different histories.
master
...
typosaurus
@ -65,7 +65,6 @@ devplace devii tasks prune # disable every task whose owner may not
|
|||||||
devplace gateway quota list # list AI gateway quota rules and current 24h spend
|
devplace gateway quota list # list AI gateway quota rules and current 24h spend
|
||||||
devplace gateway quota set --limit-usd N [--owner-kind K] [--owner-id ID] [--app-reference APP] [--label L] [--uid UID]
|
devplace gateway quota set --limit-usd N [--owner-kind K] [--owner-id ID] [--app-reference APP] [--label L] [--uid UID]
|
||||||
devplace gateway quota delete <uid> # delete a quota rule
|
devplace gateway quota delete <uid> # delete a quota rule
|
||||||
devplace gateway quota reset [--owner-kind K] [--owner-id ID] [--app-reference APP] # clear the counted 24h spend (keeps the usage history)
|
|
||||||
devplace zips prune # delete expired zip archives + job rows
|
devplace zips prune # delete expired zip archives + job rows
|
||||||
devplace zips clear # delete every zip archive + job row
|
devplace zips clear # delete every zip archive + job row
|
||||||
devplace forks prune # delete expired completed fork job rows (forked projects persist)
|
devplace forks prune # delete expired completed fork job rows (forked projects persist)
|
||||||
@ -148,7 +147,6 @@ Nested `CLAUDE.md` files (loaded automatically by Claude Code only when a file i
|
|||||||
| `devplacepy/services/bot/CLAUDE.md` | `BotsService` fleet |
|
| `devplacepy/services/bot/CLAUDE.md` | `BotsService` fleet |
|
||||||
| `devplacepy/services/dbapi/CLAUDE.md` | `/dbapi` primary-admin-only read-only database API |
|
| `devplacepy/services/dbapi/CLAUDE.md` | `/dbapi` primary-admin-only read-only database API |
|
||||||
| `devplacepy/services/pubsub/CLAUDE.md` | Database-free pub/sub bus |
|
| `devplacepy/services/pubsub/CLAUDE.md` | Database-free pub/sub bus |
|
||||||
| `devplacepy/push/CLAUDE.md` | Push notification providers: the `PushProvider` protocol, the registry, Web Push and APNs, registration storage |
|
|
||||||
| `devplacepy/services/game/CLAUDE.md` | Code Farm idle game (economy invariants, raids, the one-pure-function rule) |
|
| `devplacepy/services/game/CLAUDE.md` | Code Farm idle game (economy invariants, raids, the one-pure-function rule) |
|
||||||
| `devplacepy/services/quiz/CLAUDE.md` | Quizzes (the terminal publish lock, attempt atomicity, answer-key withholding, AI free-text grading, the best-attempt scoreboard) |
|
| `devplacepy/services/quiz/CLAUDE.md` | Quizzes (the terminal publish lock, attempt atomicity, answer-key withholding, AI free-text grading, the best-attempt scoreboard) |
|
||||||
| `devplacepy/services/CLAUDE.md` | Background task queue, AI correction/modifier, presence, live view relay, `BaseService`/`ServiceManager` |
|
| `devplacepy/services/CLAUDE.md` | Background task queue, AI correction/modifier, presence, live view relay, `BaseService`/`ServiceManager` |
|
||||||
@ -250,7 +248,7 @@ Users and guests inject their own CSS and JS, scoped to a page type or globally,
|
|||||||
|
|
||||||
### Container manager, Devii assistant, AI gateway, async jobs, audit log
|
### Container manager, Devii assistant, AI gateway, async jobs, audit log
|
||||||
|
|
||||||
DevPlace ships a Docker-backed **Container manager** (admin-only, one shared `ppy:latest` image, security-hardened rootless workflow via a `sudo` superclone and `aptroot`), the **Devii** in-platform agentic assistant (WebSocket terminal, per-owner-channel sessions, scheduler/reminders, self-learning lessons, user-defined virtual tools, self-configured behavior, browser-automation client tools), the **AI gateway** (`/openai/v1/*`, single point of truth for every AI call, per-user cost attribution, provider/model routing overlay), **async job services** (`JobService` base pattern: zip, fork, SEO diagnostics, SEO metadata generation, DeepSearch, AI Usage Analyzer), and an admin-only append-only **audit log** (`record`/`record_system` entrypoints, `events.md` catalogue of 288 keys, never raises into the caller). These are among the largest subsystems in the codebase - read their dedicated nested `CLAUDE.md` files (see Subsystem map) before working in any of them; do not assume prior knowledge from this summary.
|
DevPlace ships a Docker-backed **Container manager** (admin-only, one shared `ppy:latest` image, security-hardened rootless workflow via a `sudo` superclone and `aptroot`), the **Devii** in-platform agentic assistant (WebSocket terminal, per-owner-channel sessions, scheduler/reminders, self-learning lessons, user-defined virtual tools, self-configured behavior, browser-automation client tools), the **AI gateway** (`/openai/v1/*`, single point of truth for every AI call, per-user cost attribution, provider/model routing overlay), **async job services** (`JobService` base pattern: zip, fork, SEO diagnostics, SEO metadata generation, DeepSearch, AI Usage Analyzer), and an admin-only append-only **audit log** (`record`/`record_system` entrypoints, `events.md` catalogue of 223 keys, never raises into the caller). These are among the largest subsystems in the codebase - read their dedicated nested `CLAUDE.md` files (see Subsystem map) before working in any of them; do not assume prior knowledge from this summary.
|
||||||
|
|
||||||
### Telegram bot, email, devRant compatibility API, issue tracker
|
### Telegram bot, email, devRant compatibility API, issue tracker
|
||||||
|
|
||||||
@ -348,7 +346,6 @@ Gitea Actions workflow at `.gitea/workflows/test.yaml` runs on every push/PR to
|
|||||||
Single-host Docker Compose (`docker-compose.yml`): an **app** container (Uvicorn, 2 workers) behind an **nginx** container. Full operator reference is the admin-only `Production` docs section (`templates/docs/production*.html`); conventions that must not regress:
|
Single-host Docker Compose (`docker-compose.yml`): an **app** container (Uvicorn, 2 workers) behind an **nginx** container. Full operator reference is the admin-only `Production` docs section (`templates/docs/production*.html`); conventions that must not regress:
|
||||||
|
|
||||||
- **Shared DB and files = same as dev.** The app container bind-mounts the host project root (`.:/app`) and runs as `${DEVPLACE_UID}:${DEVPLACE_GID}` (default `1000`), so it reads/writes the same `data/devplace.db`, `data/uploads/`, `data/devii_*.db`, `data/keys/` (VAPID), and `data/locks/devplace-services.lock` as `make dev`. No `DEVPLACE_DATABASE_URL` override - `config.py` resolves an absolute path under the project's `data/` dir. WAL + the `flock` on `devplace-services.lock` make concurrent dev/prod safe and keep a single background-services owner. SQLite is local-file, so prod and dev must be the **same host**.
|
- **Shared DB and files = same as dev.** The app container bind-mounts the host project root (`.:/app`) and runs as `${DEVPLACE_UID}:${DEVPLACE_GID}` (default `1000`), so it reads/writes the same `data/devplace.db`, `data/uploads/`, `data/devii_*.db`, `data/keys/` (VAPID), and `data/locks/devplace-services.lock` as `make dev`. No `DEVPLACE_DATABASE_URL` override - `config.py` resolves an absolute path under the project's `data/` dir. WAL + the `flock` on `devplace-services.lock` make concurrent dev/prod safe and keep a single background-services owner. SQLite is local-file, so prod and dev must be the **same host**.
|
||||||
- **Code updates need no rebuild** (bind-mounted source); rebuild only on `pyproject.toml` dependency changes. Use **`make docker-reload`** (`restart app` + `up -d --wait`) to pick up new source - a bare `make docker-up` does **not** restart an unchanged container, so the running uvicorn keeps serving the code it imported at boot. `.env` is git-ignored; `.env.example` is the committed template. The app reads `DEVPLACE_DATABASE_URL`, never `DATABASE_URL`.
|
- **Code updates need no rebuild** (bind-mounted source); rebuild only on `pyproject.toml` dependency changes. `.env` is git-ignored; `.env.example` is the committed template. The app reads `DEVPLACE_DATABASE_URL`, never `DATABASE_URL`.
|
||||||
- **Dockerfile layer order is load-bearing for build time.** The dependency layer (`pip install ".[bots]"` + `playwright install --with-deps chromium`, ~3GB and ~2.5 min) must depend on `pyproject.toml` **only**. `COPY devplacepy/` therefore comes *after* it, and the project itself is installed last with `pip install --no-deps --force-reinstall .`. hatchling needs the package directory to exist to build a wheel, so the dependency layer creates a placeholder `devplacepy/__init__.py` that the real `COPY` overwrites (verified: site-packages holds the full 39-entry package, not the stub). Copying source before the install inverts this and makes **every source edit** reinstall every dependency and re-download Chromium - measured 2m36s per source-only rebuild versus 7.4s with the correct order. Never move `COPY devplacepy/` above the dependency layer.
|
|
||||||
- **nginx parity rules** (`nginx/nginx.conf.template`, rendered by `start.sh` via `envsubst` with an allow-list that preserves `$http_upgrade`): `/static/uploads/` must re-apply `nosniff` + a `Content-Disposition` via the `map $uri $upload_disposition` block (`inline` for safe image/video/audio extensions, `attachment` otherwise), mirroring `UploadStaticFiles.INLINE_MEDIA_EXTENSIONS` - an XSS control nginx would otherwise bypass, and the inline branch is what lets video play in production; `/devii/ws` needs the `map $http_upgrade $connection_upgrade` block and `Upgrade`/`Connection` headers or the Devii terminal cannot connect (every new WebSocket route needs its own nginx upgrade location - the catch-all `location /` strips upgrade headers); `client_max_body_size` comes from `NGINX_MAX_BODY_SIZE` (default `50m`) and must be `>= max_upload_size_mb` or uploads 413. nginx serves `devplacepy/static` via a read-only bind mount, so assets stay current without an image rebuild.
|
- **nginx parity rules** (`nginx/nginx.conf.template`, rendered by `start.sh` via `envsubst` with an allow-list that preserves `$http_upgrade`): `/static/uploads/` must re-apply `nosniff` + a `Content-Disposition` via the `map $uri $upload_disposition` block (`inline` for safe image/video/audio extensions, `attachment` otherwise), mirroring `UploadStaticFiles.INLINE_MEDIA_EXTENSIONS` - an XSS control nginx would otherwise bypass, and the inline branch is what lets video play in production; `/devii/ws` needs the `map $http_upgrade $connection_upgrade` block and `Upgrade`/`Connection` headers or the Devii terminal cannot connect (every new WebSocket route needs its own nginx upgrade location - the catch-all `location /` strips upgrade headers); `client_max_body_size` comes from `NGINX_MAX_BODY_SIZE` (default `50m`) and must be `>= max_upload_size_mb` or uploads 413. nginx serves `devplacepy/static` via a read-only bind mount, so assets stay current without an image rebuild.
|
||||||
- **Healthcheck cadence** (`docker-compose.yml` + `Dockerfile`, keep both in step): `start_period: 120s` is the grace window in which a failing probe does not count against `retries`; `start_interval: 2s` is how often the probe runs *inside* that window. Without `start_interval` the first probe only fires after the full `interval: 30s`, so a container ready in 5s still reports healthy at 30s and `depends_on: service_healthy` holds nginx back for no reason. The generous 120s start period is deliberate headroom for a cold page cache on a multi-GB database, not a measure of normal startup - normal startup is a few seconds. **Startup work is a per-worker, lock-serialized cost:** `lifespan` runs `init_db()` under an exclusive `init_lock()`, so every uvicorn worker pays it end to end, one after another, and total time-to-serving is `workers x init_db`. Never put a per-user or per-row scan in `init_db` - see the backfill convergence rule in `devplacepy/database/CLAUDE.md`.
|
- **Healthcheck start period** (`start_period: 120s` in `docker-compose.yml`, `--start-period=120s` in `Dockerfile`): full startup takes ~110s (DB init, services, uvicorn workers). The start period must stay above that. Bump both files if startup grows.
|
||||||
|
|||||||
16
Dockerfile
16
Dockerfile
@ -4,8 +4,6 @@ WORKDIR /app
|
|||||||
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
curl ca-certificates \
|
curl ca-certificates \
|
||||||
libglib2.0-0 libpango-1.0-0 libpangoft2-1.0-0 libharfbuzz0b libfontconfig1 \
|
|
||||||
fonts-dejavu-core \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Optional: the docker CLI so the (admin-only) container manager can drive the host
|
# Optional: the docker CLI so the (admin-only) container manager can drive the host
|
||||||
@ -20,24 +18,22 @@ RUN if [ "$INSTALL_DOCKER_CLI" = "true" ]; then \
|
|||||||
rm -rf /var/lib/apt/lists/* ; \
|
rm -rf /var/lib/apt/lists/* ; \
|
||||||
fi
|
fi
|
||||||
|
|
||||||
ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
|
|
||||||
|
|
||||||
COPY pyproject.toml .
|
COPY pyproject.toml .
|
||||||
RUN mkdir -p devplacepy && touch devplacepy/__init__.py \
|
|
||||||
&& pip install --no-cache-dir ".[bots]" \
|
|
||||||
&& python -m playwright install --with-deps chromium \
|
|
||||||
&& chmod -R a+rx /ms-playwright
|
|
||||||
|
|
||||||
COPY devplacepy/ devplacepy/
|
COPY devplacepy/ devplacepy/
|
||||||
|
|
||||||
RUN pip install --no-cache-dir --no-deps --force-reinstall .
|
ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
|
||||||
|
|
||||||
|
RUN pip install --no-cache-dir ".[bots]" \
|
||||||
|
&& python -m playwright install --with-deps chromium \
|
||||||
|
&& chmod -R a+rx /ms-playwright
|
||||||
|
|
||||||
EXPOSE 10500
|
EXPOSE 10500
|
||||||
|
|
||||||
ENV DEVPLACE_WEB_WORKERS=2
|
ENV DEVPLACE_WEB_WORKERS=2
|
||||||
ENV DEVPLACE_TEMPLATE_AUTO_RELOAD=0
|
ENV DEVPLACE_TEMPLATE_AUTO_RELOAD=0
|
||||||
|
|
||||||
HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=120s --start-interval=2s \
|
HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=120s \
|
||||||
CMD curl -f http://localhost:10500/ || exit 1
|
CMD curl -f http://localhost:10500/ || exit 1
|
||||||
|
|
||||||
CMD ["sh", "-c", "DEVPLACE_STATIC_VERSION=${DEVPLACE_STATIC_VERSION:-$(date +%s)} exec uvicorn devplacepy.main:app --host 0.0.0.0 --port 10500 --workers 2 --backlog 8192 --proxy-headers --forwarded-allow-ips '*'"]
|
CMD ["sh", "-c", "DEVPLACE_STATIC_VERSION=${DEVPLACE_STATIC_VERSION:-$(date +%s)} exec uvicorn devplacepy.main:app --host 0.0.0.0 --port 10500 --workers 2 --backlog 8192 --proxy-headers --forwarded-allow-ips '*'"]
|
||||||
|
|||||||
6
Makefile
6
Makefile
@ -138,7 +138,7 @@ DOCKER_GID ?= $(shell stat -c '%g' /var/run/docker.sock 2>/dev/null)
|
|||||||
export DEVPLACE_DATA_DIR
|
export DEVPLACE_DATA_DIR
|
||||||
export DOCKER_GID
|
export DOCKER_GID
|
||||||
|
|
||||||
.PHONY: docker-build docker-up docker-reload docker-down docker-logs docker-clean docker-prep ppy
|
.PHONY: docker-build docker-up docker-down docker-logs docker-clean docker-prep ppy
|
||||||
|
|
||||||
# Build the single shared container image every instance runs. Build once;
|
# Build the single shared container image every instance runs. Build once;
|
||||||
# rebuild only when ppy.Dockerfile, the sudo shim, or pagent change.
|
# rebuild only when ppy.Dockerfile, the sudo shim, or pagent change.
|
||||||
@ -154,10 +154,6 @@ docker-build: docker-prep
|
|||||||
docker-up: docker-prep
|
docker-up: docker-prep
|
||||||
$(COMPOSE) up -d
|
$(COMPOSE) up -d
|
||||||
|
|
||||||
docker-reload:
|
|
||||||
$(COMPOSE) restart app
|
|
||||||
$(COMPOSE) up -d --wait
|
|
||||||
|
|
||||||
docker-down:
|
docker-down:
|
||||||
$(COMPOSE) down
|
$(COMPOSE) down
|
||||||
|
|
||||||
|
|||||||
68
README.md
68
README.md
@ -15,12 +15,6 @@ make test-headed # same tests in visible browser
|
|||||||
|
|
||||||
Open `http://localhost:10500`.
|
Open `http://localhost:10500`.
|
||||||
|
|
||||||
PDF export (DeepSearch reports, via weasyprint) needs the Pango text stack installed at system level. The production image installs it; on a development host install it once:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo apt-get install -y libglib2.0-0 libpango-1.0-0 libpangoft2-1.0-0 libharfbuzz0b libfontconfig1 fonts-dejavu-core
|
|
||||||
```
|
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
| Layer | Technology |
|
| Layer | Technology |
|
||||||
@ -46,7 +40,7 @@ devplacepy/
|
|||||||
avatar.py # Multiavatar generation, URL builder
|
avatar.py # Multiavatar generation, URL builder
|
||||||
utils/ # Password hashing, session mgmt, time_ago, notification hook (package)
|
utils/ # Password hashing, session mgmt, time_ago, notification hook (package)
|
||||||
models.py # Pydantic schemas
|
models.py # Pydantic schemas
|
||||||
push/ # Push delivery: provider protocol, Web Push, APNs, registrations
|
push.py # Web push crypto, VAPID keys, encrypt/send/register
|
||||||
routers/ # One file per domain (auth, feed, posts, push, ...)
|
routers/ # One file per domain (auth, feed, posts, push, ...)
|
||||||
templates/ # Jinja2 HTML templates
|
templates/ # Jinja2 HTML templates
|
||||||
static/css/ # Page-specific CSS files
|
static/css/ # Page-specific CSS files
|
||||||
@ -549,17 +543,6 @@ disclosed only to administrators, while members and guests can see only the perc
|
|||||||
24-hour quota used. The `/devii/usage` endpoint returns that percentage and the day's turn count to
|
24-hour quota used. The `/devii/usage` endpoint returns that percentage and the day's turn count to
|
||||||
everyone, and includes dollar figures only for administrators.
|
everyone, and includes dollar figures only for administrators.
|
||||||
|
|
||||||
**Spend limits (Gateway page, `/admin/gateway`).** An administrator caps the rolling 24-hour
|
|
||||||
gateway spend with quota rules scoped by any combination of caller role, individual user, and
|
|
||||||
application reference (the `X-App-Reference` header), so a single application belonging to one
|
|
||||||
user can be limited independently of that user's other traffic. The most specific matching rule
|
|
||||||
wins; a caller over its cap gets `429`. Because a cap otherwise only lifts with the passage of
|
|
||||||
time, each rule has a **Reset spend** action that clears what has been counted against it
|
|
||||||
without deleting anything from the usage history the cost analytics are built on - the
|
|
||||||
figures on the AI usage page stay intact, only the amount counted towards the limit is cleared.
|
|
||||||
The **Reset all quotas** button on the AI usage page clears the assistant quotas and the gateway
|
|
||||||
spend together. From the terminal: `devplace gateway quota list|set|delete|reset`.
|
|
||||||
|
|
||||||
Configuration on the Services tab:
|
Configuration on the Services tab:
|
||||||
|
|
||||||
| Parameter | Default | Purpose |
|
| Parameter | Default | Purpose |
|
||||||
@ -817,37 +800,9 @@ calling itself.
|
|||||||
|
|
||||||
## Push notifications & PWA
|
## Push notifications & PWA
|
||||||
|
|
||||||
Authenticated users can receive native push notifications, and the site is an
|
Authenticated users can receive native web push notifications, and the site is an
|
||||||
installable Progressive Web App. Push uses only standard libraries (`cryptography`,
|
installable Progressive Web App. Push uses only standard libraries (`cryptography`,
|
||||||
`PyJWT`, `httpx`) against the Web Push Protocol and the Apple Push Notification service -
|
`PyJWT`, `httpx`) against the Web Push Protocol - no third-party push wrapper.
|
||||||
no third-party push wrapper.
|
|
||||||
|
|
||||||
### Providers
|
|
||||||
|
|
||||||
Delivery is split into providers behind one protocol (`devplacepy/push/providers/`). A user
|
|
||||||
receives a notification through every provider they hold a live subscription for.
|
|
||||||
|
|
||||||
| Provider | Registration | Transport |
|
|
||||||
|----------|--------------|-----------|
|
|
||||||
| `webpush` | `PushSubscription` from the browser `PushManager` (endpoint + `p256dh`/`auth` keys) | Web Push Protocol, VAPID signed, `aesgcm` encrypted payload |
|
|
||||||
| `apns` | Hexadecimal device token | `POST https://api.push.apple.com/3/device/{token}` over HTTP/2, ES256 provider token |
|
|
||||||
|
|
||||||
`POST /push.json` accepts a registration for any active provider; a body without a
|
|
||||||
`provider` field is a `webpush` body, so browsers need no change. `GET /push.json` returns
|
|
||||||
the VAPID public key plus the providers currently accepting registrations. A provider that
|
|
||||||
is disabled or not fully configured accepts no registrations and is skipped during
|
|
||||||
delivery, so an unconfigured provider is inert rather than an error.
|
|
||||||
|
|
||||||
Every provider setting is edited at **`/admin/services/push`**: per provider an `Enabled`
|
|
||||||
toggle, the VAPID subject for `webpush`, and team id, key id, `.p8` auth key (stored as a
|
|
||||||
masked secret), topic and environment (production or sandbox) for `apns`. The same page
|
|
||||||
holds the shared delivery timeout and the retention window after which dead subscriptions
|
|
||||||
are removed. Push delivery does not depend on that service running; stopping it only stops
|
|
||||||
the pruning sweep.
|
|
||||||
|
|
||||||
Adding a third provider is one file plus one registry entry: the registration route, the
|
|
||||||
delivery loop, the admin page, the audit record and the metrics are all written against the
|
|
||||||
provider protocol.
|
|
||||||
|
|
||||||
### Events
|
### Events
|
||||||
|
|
||||||
@ -870,11 +825,9 @@ in real time, bridged onto the in-process pub/sub bus by a lock-owner relay:
|
|||||||
|
|
||||||
`create_notification` schedules delivery as a fire-and-forget async task, so a dead
|
`create_notification` schedules delivery as a fire-and-forget async task, so a dead
|
||||||
subscription or push-service error never blocks the triggering request. Delivery
|
subscription or push-service error never blocks the triggering request. Delivery
|
||||||
(`push.notify_user`) reads a user's subscriptions once, groups them by provider, builds
|
(`push.notify_user`) iterates a user's subscriptions, encrypts the payload
|
||||||
each provider's payload once, and sends over a single shared HTTP client. A subscription
|
(legacy `aesgcm` content encoding), and POSTs to each endpoint; subscriptions that
|
||||||
the push service reports as gone (`404`/`410` for Web Push, `410` or an `Unregistered`
|
return `404`/`410` are soft-deleted.
|
||||||
class reason for APNs) is soft-deleted; any other failure is logged and the subscription is
|
|
||||||
kept.
|
|
||||||
|
|
||||||
A notification is also **marked read automatically when you open the page that shows its
|
A notification is also **marked read automatically when you open the page that shows its
|
||||||
content** - viewing a post clears its comment, reply, upvote and mention notifications;
|
content** - viewing a post clears its comment, reply, upvote and mention notifications;
|
||||||
@ -931,10 +884,7 @@ offline. Installation requires a secure origin (HTTPS, or `localhost` for develo
|
|||||||
|
|
||||||
| File | Role |
|
| File | Role |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `devplacepy/push/providers/` | Provider protocol, Web Push (VAPID keys, payload encryption), APNs |
|
| `devplacepy/push.py` | VAPID keys, payload encryption, send, register |
|
||||||
| `devplacepy/push/store.py` | `push_registration` reads and writes |
|
|
||||||
| `devplacepy/push/delivery.py` | `notify_user` - group by provider, deliver, reap dead subscriptions |
|
|
||||||
| `devplacepy/services/push/service.py` | Provider configuration at `/admin/services/push`, retention sweep, metrics |
|
|
||||||
| `devplacepy/routers/push.py` | `/push.json`, `/service-worker.js`, `/manifest.json` |
|
| `devplacepy/routers/push.py` | `/push.json`, `/service-worker.js`, `/manifest.json` |
|
||||||
| `static/js/PushManager.js` | Service-worker registration + subscribe + opt-in UI |
|
| `static/js/PushManager.js` | Service-worker registration + subscribe + opt-in UI |
|
||||||
| `static/service-worker.js` | Receives push, shows notification, offline fallback |
|
| `static/service-worker.js` | Receives push, shows notification, offline fallback |
|
||||||
@ -1034,13 +984,11 @@ Open `http://<host>:${PORT}` (default 10500). `make docker-logs` tails output; `
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
git pull
|
git pull
|
||||||
make docker-reload # restart workers on the new code (bind-mounted, no rebuild)
|
make docker-up # restart with new code (bind-mounted, no rebuild)
|
||||||
make docker-build && \
|
make docker-build && \
|
||||||
make docker-up # only when dependencies in pyproject.toml change
|
make docker-up # only when dependencies in pyproject.toml change
|
||||||
```
|
```
|
||||||
|
|
||||||
`make docker-reload` is the target for a source-only change: `docker compose up -d` leaves an unchanged container running, so the workers would keep serving the code they imported at boot. A rebuild after a source-only change costs about 7 seconds because the Dockerfile installs dependencies from `pyproject.toml` in a layer that no source edit invalidates.
|
|
||||||
|
|
||||||
The `make deploy` target fast-forwards the `production` branch (`git checkout production && git merge master && git push origin production`); pull that branch on the server.
|
The `make deploy` target fast-forwards the `production` branch (`git checkout production && git merge master && git push origin production`); pull that branch on the server.
|
||||||
|
|
||||||
### Container Manager wiring (what the overlay does)
|
### Container Manager wiring (what the overlay does)
|
||||||
|
|||||||
@ -12,7 +12,7 @@ def enforce_rgba_png(file_bytes: bytes) -> bytes:
|
|||||||
corner = img.getpixel((0, 0))
|
corner = img.getpixel((0, 0))
|
||||||
if len(corner) == 4 and corner[3] == 255:
|
if len(corner) == 4 and corner[3] == 255:
|
||||||
bg = corner[:3]
|
bg = corner[:3]
|
||||||
data = img.get_flattened_data()
|
data = img.getdata()
|
||||||
cleaned = []
|
cleaned = []
|
||||||
for pixel in data:
|
for pixel in data:
|
||||||
if pixel[:3] == bg:
|
if pixel[:3] == bg:
|
||||||
|
|||||||
@ -70,35 +70,6 @@ def cmd_gateway_quota_delete(args):
|
|||||||
print(f"Deleted quota rule {args.uid}")
|
print(f"Deleted quota rule {args.uid}")
|
||||||
|
|
||||||
|
|
||||||
def cmd_gateway_quota_reset(args):
|
|
||||||
from devplacepy.services.openai_gateway import quota
|
|
||||||
|
|
||||||
try:
|
|
||||||
payload = quota.QuotaResetIn(
|
|
||||||
owner_kind=args.owner_kind,
|
|
||||||
owner_id=args.owner_id,
|
|
||||||
app_reference=args.app_reference,
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
print(f"Error: {exc}")
|
|
||||||
sys.exit(1)
|
|
||||||
scope = quota.reset(payload, created_by="cli")
|
|
||||||
label = quota.scope_label(scope, fallback="every caller")
|
|
||||||
_audit_cli(
|
|
||||||
"gateway.quota.reset",
|
|
||||||
f"CLI reset the gateway 24h spend for {label}",
|
|
||||||
target_type="gateway_quota",
|
|
||||||
target_uid=scope["uid"],
|
|
||||||
metadata={
|
|
||||||
"owner_kind": scope["owner_kind"],
|
|
||||||
"owner_id": scope["owner_id"],
|
|
||||||
"app_reference": scope["app_reference"],
|
|
||||||
"reset_at": scope["reset_at"],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
print(f"Reset the rolling 24h spend for {label}")
|
|
||||||
|
|
||||||
|
|
||||||
def register_gateway(subparsers):
|
def register_gateway(subparsers):
|
||||||
gateway = subparsers.add_parser("gateway", help="AI gateway management")
|
gateway = subparsers.add_parser("gateway", help="AI gateway management")
|
||||||
gateway_sub = gateway.add_subparsers(title="action", dest="action")
|
gateway_sub = gateway.add_subparsers(title="action", dest="action")
|
||||||
@ -130,16 +101,3 @@ def register_gateway(subparsers):
|
|||||||
quota_delete = quota_sub.add_parser("delete", help="Delete a quota rule by uid")
|
quota_delete = quota_sub.add_parser("delete", help="Delete a quota rule by uid")
|
||||||
quota_delete.add_argument("uid", help="Quota rule uid")
|
quota_delete.add_argument("uid", help="Quota rule uid")
|
||||||
quota_delete.set_defaults(func=cmd_gateway_quota_delete)
|
quota_delete.set_defaults(func=cmd_gateway_quota_delete)
|
||||||
|
|
||||||
quota_reset = quota_sub.add_parser(
|
|
||||||
"reset",
|
|
||||||
help="Clear the rolling-24h spend so a capped caller can call again (keeps the usage history)",
|
|
||||||
)
|
|
||||||
quota_reset.add_argument(
|
|
||||||
"--owner-kind",
|
|
||||||
choices=("internal", "key", "user", "admin", "anonymous"),
|
|
||||||
help="Role to scope by. Omit to reset every role",
|
|
||||||
)
|
|
||||||
quota_reset.add_argument("--owner-id", help="Specific user uid to scope by. Omit for every caller")
|
|
||||||
quota_reset.add_argument("--app-reference", help="App label to scope by. Omit for every app")
|
|
||||||
quota_reset.set_defaults(func=cmd_gateway_quota_reset)
|
|
||||||
|
|||||||
@ -13,8 +13,6 @@ from devplacepy.database import (
|
|||||||
resolve_by_slug,
|
resolve_by_slug,
|
||||||
get_users_by_uids,
|
get_users_by_uids,
|
||||||
get_vote_counts,
|
get_vote_counts,
|
||||||
get_comment_counts_by_post_uids,
|
|
||||||
paginate,
|
|
||||||
STAR_TARGETS,
|
STAR_TARGETS,
|
||||||
get_user_votes,
|
get_user_votes,
|
||||||
get_reactions_by_targets,
|
get_reactions_by_targets,
|
||||||
@ -60,21 +58,6 @@ REACTABLE_TYPES = {"post", "comment", "gist", "project", "quiz"}
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def get_project_by_uid(project_uid: str | None) -> dict | None:
|
|
||||||
if not project_uid:
|
|
||||||
return None
|
|
||||||
project = get_table("projects").find_one(uid=project_uid)
|
|
||||||
if not project:
|
|
||||||
return None
|
|
||||||
slug = project.get("slug") or project["uid"]
|
|
||||||
return {
|
|
||||||
"uid": project["uid"],
|
|
||||||
"name": project.get("title") or project.get("name", ""),
|
|
||||||
"slug": slug,
|
|
||||||
"url": f"/projects/{slug}",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def is_owner(item: dict | None, user: dict | None) -> bool:
|
def is_owner(item: dict | None, user: dict | None) -> bool:
|
||||||
return bool(item and user and item["user_uid"] == user["uid"])
|
return bool(item and user and item["user_uid"] == user["uid"])
|
||||||
|
|
||||||
@ -527,7 +510,6 @@ def detail_context(
|
|||||||
"reactions": detail.get("reactions", {"counts": {}, "mine": []}),
|
"reactions": detail.get("reactions", {"counts": {}, "mine": []}),
|
||||||
"bookmarked": detail.get("bookmarked", False),
|
"bookmarked": detail.get("bookmarked", False),
|
||||||
"poll": detail.get("poll"),
|
"poll": detail.get("poll"),
|
||||||
"project_link": detail.get("project_link"),
|
|
||||||
}
|
}
|
||||||
if extra:
|
if extra:
|
||||||
context.update(extra)
|
context.update(extra)
|
||||||
@ -707,7 +689,6 @@ def load_detail(
|
|||||||
"reactions": reactions,
|
"reactions": reactions,
|
||||||
"bookmarked": bookmarked,
|
"bookmarked": bookmarked,
|
||||||
"poll": get_poll_for_post(item["uid"], user) if target_type == "post" else None,
|
"poll": get_poll_for_post(item["uid"], user) if target_type == "post" else None,
|
||||||
"project_link": get_project_by_uid(item.get("project_uid")) if target_type == "post" else None,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -735,28 +716,5 @@ def enrich_items(
|
|||||||
entry[name] = (
|
entry[name] = (
|
||||||
source(item) if callable(source) else source.get(item["uid"], 0)
|
source(item) if callable(source) else source.get(item["uid"], 0)
|
||||||
)
|
)
|
||||||
if key == "post" and item.get("project_uid"):
|
|
||||||
entry["project_link"] = get_project_by_uid(item["project_uid"])
|
|
||||||
enriched.append(entry)
|
enriched.append(entry)
|
||||||
return enriched
|
return enriched
|
||||||
|
|
||||||
|
|
||||||
def get_project_devlog(
|
|
||||||
project_uid: str, before: str | None = None, viewer: dict | None = None
|
|
||||||
) -> tuple[list, str | None]:
|
|
||||||
posts, next_cursor = paginate(
|
|
||||||
get_table("posts"),
|
|
||||||
before=before,
|
|
||||||
viewer_uid=viewer["uid"] if viewer else None,
|
|
||||||
project_uid=project_uid,
|
|
||||||
)
|
|
||||||
if not posts:
|
|
||||||
return [], None
|
|
||||||
|
|
||||||
authors = get_users_by_uids([post["user_uid"] for post in posts])
|
|
||||||
counts = get_comment_counts_by_post_uids([post["uid"] for post in posts])
|
|
||||||
|
|
||||||
enriched = enrich_items(
|
|
||||||
posts, "post", authors, {"comment_count": counts}, user=viewer
|
|
||||||
)
|
|
||||||
return enriched, next_cursor
|
|
||||||
|
|||||||
@ -105,17 +105,6 @@ if "comments" not in db.tables:
|
|||||||
|
|
||||||
`init_db()` ends with `_refresh_query_planner_stats()`: a one-time `ANALYZE` when `sqlite_stat1` is absent, then `PRAGMA optimize` every boot (both inside `with db:`). Validate any index change with `EXPLAIN QUERY PLAN` against `data/devplace.db` (or a copy) - a correct plan reads `SEARCH ... USING INDEX <name>` with no temp b-tree.
|
`init_db()` ends with `_refresh_query_planner_stats()`: a one-time `ANALYZE` when `sqlite_stat1` is absent, then `PRAGMA optimize` every boot (both inside `with db:`). Validate any index change with `EXPLAIN QUERY PLAN` against `data/devplace.db` (or a copy) - a correct plan reads `SEARCH ... USING INDEX <name>` with no temp b-tree.
|
||||||
|
|
||||||
## Startup backfills must converge (hard rule)
|
|
||||||
|
|
||||||
`init_db()` runs inside `lifespan` under an exclusive `init_lock()`, **before the worker accepts a single request**, and every uvicorn worker runs it in turn. Time-to-serving is therefore `workers x init_db`, so anything added there is paid N times on every boot and every deploy. Two rules follow:
|
|
||||||
|
|
||||||
- **A backfill must be able to finish.** A backfill selects the rows that still need migrating and must leave them *not* selected afterwards. If a row can stay in the candidate set after a successful pass, the "one-time migration" is really a permanent per-boot scan that grows with the table. `_backfill_gamification` had exactly this bug: it selected `users.find(xp=0)` and then ran the full milestone sweep over *every* one of them, but a user with no content is awarded no XP, so they stayed at `xp=0` and were re-swept forever. At 7814 such users that was 36s of the 37.5s boot - about 140k queries that provably could not award anything, on every worker, on every restart.
|
|
||||||
- **Never fan a per-row query out over a whole table at boot.** Compute the candidate set with a few set-based `GROUP BY`/`DISTINCT` queries first, then do per-row work only for rows that survive. `_milestone_candidates()` is the pattern: one `SELECT DISTINCT` per milestone source table (`MILESTONE_SOURCES`), unioned into a set, intersected with the pending users. A user absent from all of those tables scores 0 on every milestone metric and the lowest threshold is 5, so skipping them cannot change any award - verified by diffing the full `badges` table between the full sweep and the narrowed one (identical, 9800 rows, 22.6s -> 0.9s).
|
|
||||||
|
|
||||||
Watch for the same shape in `dataset` internals: `db.tables` is a live SQLAlchemy reflection, not a cached attribute. `get_user_stars` does one `in db.tables` check per `STAR_TARGETS` entry, so a per-user loop calling it re-reflects the whole table list on every iteration - 78667 reflections costing 17.7s in the profile above. Hoist `db.tables` into a local when looping.
|
|
||||||
|
|
||||||
Profile with the real database before and after any change here (`cProfile` around `init_db()` against a copy of `data/devplace.db`); a synthetic or empty DB hides every one of these costs.
|
|
||||||
|
|
||||||
## Project-wide soft delete (hard rule)
|
## Project-wide soft delete (hard rule)
|
||||||
|
|
||||||
Attachments (see "Profile media gallery and soft-deleted attachments" below) are one instance of a platform-wide model: **every removal is a soft delete; only garbage collection is a hard delete.** Every removable row carries two columns, `deleted_at` (ISO timestamp) and `deleted_by` (actor uid, or `system`). A live row has `deleted_at = NULL`, and every list/count read filters `deleted_at IS NULL`.
|
Attachments (see "Profile media gallery and soft-deleted attachments" below) are one instance of a platform-wide model: **every removal is a soft delete; only garbage collection is a hard delete.** Every removable row carries two columns, `deleted_at` (ISO timestamp) and `deleted_by` (actor uid, or `system`). A live row has `deleted_at = NULL`, and every list/count read filters `deleted_at IS NULL`.
|
||||||
|
|||||||
@ -254,5 +254,3 @@ __all__ = [
|
|||||||
"backfill_api_keys",
|
"backfill_api_keys",
|
||||||
"_backfill_gamification",
|
"_backfill_gamification",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -185,5 +185,3 @@ def get_polls_by_post_uids(post_uids, user=None):
|
|||||||
|
|
||||||
def get_poll_for_post(post_uid, user=None):
|
def get_poll_for_post(post_uid, user=None):
|
||||||
return get_polls_by_post_uids([post_uid], user).get(post_uid)
|
return get_polls_by_post_uids([post_uid], user).get(post_uid)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -32,13 +32,12 @@ def _ranked_authors() -> list:
|
|||||||
cached = _authors_cache.get("ranked")
|
cached = _authors_cache.get("ranked")
|
||||||
if cached is not None:
|
if cached is not None:
|
||||||
return cached
|
return cached
|
||||||
tables = db.tables
|
|
||||||
sources = [
|
sources = [
|
||||||
(target_type, table_name)
|
(target_type, table_name)
|
||||||
for target_type, table_name in VOTABLE_TARGETS.items()
|
for target_type, table_name in VOTABLE_TARGETS.items()
|
||||||
if table_name in tables
|
if table_name in db.tables
|
||||||
]
|
]
|
||||||
if "votes" not in tables or not sources:
|
if "votes" not in db.tables or not sources:
|
||||||
_authors_cache.set("ranked", [])
|
_authors_cache.set("ranked", [])
|
||||||
return []
|
return []
|
||||||
target_union = " UNION ALL ".join(
|
target_union = " UNION ALL ".join(
|
||||||
@ -102,13 +101,12 @@ def get_user_stars(user_uid: str) -> int:
|
|||||||
cached = _stars_cache.get(user_uid)
|
cached = _stars_cache.get(user_uid)
|
||||||
if cached is not None:
|
if cached is not None:
|
||||||
return cached
|
return cached
|
||||||
tables = db.tables
|
if "votes" not in db.tables:
|
||||||
if "votes" not in tables:
|
|
||||||
return 0
|
return 0
|
||||||
target_union = " UNION ALL ".join(
|
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"
|
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()
|
for target_type, table_name in VOTABLE_TARGETS.items()
|
||||||
if table_name in tables
|
if table_name in db.tables
|
||||||
)
|
)
|
||||||
if not target_union:
|
if not target_union:
|
||||||
return 0
|
return 0
|
||||||
@ -156,8 +154,7 @@ def delete_engagement(target_type: str, target_uids: list) -> None:
|
|||||||
uids = [uid for uid in (target_uids or []) if uid]
|
uids = [uid for uid in (target_uids or []) if uid]
|
||||||
if not uids:
|
if not uids:
|
||||||
return
|
return
|
||||||
tables = db.tables
|
if "reactions" in db.tables:
|
||||||
if "reactions" in tables:
|
|
||||||
placeholders, params = _in_clause(uids)
|
placeholders, params = _in_clause(uids)
|
||||||
params["tt"] = target_type
|
params["tt"] = target_type
|
||||||
with db:
|
with db:
|
||||||
@ -165,7 +162,7 @@ def delete_engagement(target_type: str, target_uids: list) -> None:
|
|||||||
f"DELETE FROM reactions WHERE target_type=:tt AND target_uid IN ({placeholders})",
|
f"DELETE FROM reactions WHERE target_type=:tt AND target_uid IN ({placeholders})",
|
||||||
**params,
|
**params,
|
||||||
)
|
)
|
||||||
if "bookmarks" in tables:
|
if "bookmarks" in db.tables:
|
||||||
placeholders, params = _in_clause(uids)
|
placeholders, params = _in_clause(uids)
|
||||||
params["tt"] = target_type
|
params["tt"] = target_type
|
||||||
with db:
|
with db:
|
||||||
@ -173,12 +170,12 @@ def delete_engagement(target_type: str, target_uids: list) -> None:
|
|||||||
f"DELETE FROM bookmarks WHERE target_type=:tt AND target_uid IN ({placeholders})",
|
f"DELETE FROM bookmarks WHERE target_type=:tt AND target_uid IN ({placeholders})",
|
||||||
**params,
|
**params,
|
||||||
)
|
)
|
||||||
if target_type == "post" and "polls" in tables:
|
if target_type == "post" and "polls" in db.tables:
|
||||||
for uid in uids:
|
for uid in uids:
|
||||||
for poll in db["polls"].find(post_uid=uid):
|
for poll in db["polls"].find(post_uid=uid):
|
||||||
if "poll_votes" in tables:
|
if "poll_votes" in db.tables:
|
||||||
db["poll_votes"].delete(poll_uid=poll["uid"])
|
db["poll_votes"].delete(poll_uid=poll["uid"])
|
||||||
if "poll_options" in tables:
|
if "poll_options" in db.tables:
|
||||||
db["poll_options"].delete(poll_uid=poll["uid"])
|
db["poll_options"].delete(poll_uid=poll["uid"])
|
||||||
db["polls"].delete(post_uid=uid)
|
db["polls"].delete(post_uid=uid)
|
||||||
|
|
||||||
|
|||||||
@ -42,7 +42,6 @@ def init_db():
|
|||||||
_index(db, "posts", "idx_posts_created_at", ["created_at"])
|
_index(db, "posts", "idx_posts_created_at", ["created_at"])
|
||||||
_index(db, "posts", "idx_posts_topic", ["topic"])
|
_index(db, "posts", "idx_posts_topic", ["topic"])
|
||||||
_index(db, "posts", "idx_posts_slug", ["slug"])
|
_index(db, "posts", "idx_posts_slug", ["slug"])
|
||||||
_index(db, "posts", "idx_posts_project_uid", ["project_uid"])
|
|
||||||
if "posts" in tables:
|
if "posts" in tables:
|
||||||
posts_table = get_table("posts")
|
posts_table = get_table("posts")
|
||||||
if not posts_table.has_column("tags"):
|
if not posts_table.has_column("tags"):
|
||||||
@ -134,28 +133,7 @@ def init_db():
|
|||||||
)
|
)
|
||||||
_index(db, "notifications", "idx_notifications_user", ["user_uid"])
|
_index(db, "notifications", "idx_notifications_user", ["user_uid"])
|
||||||
_index(db, "notifications", "idx_notifications_user_read", ["user_uid", "read"])
|
_index(db, "notifications", "idx_notifications_user_read", ["user_uid", "read"])
|
||||||
push_registration = get_table("push_registration")
|
|
||||||
for column, example in (
|
|
||||||
("uid", ""),
|
|
||||||
("user_uid", ""),
|
|
||||||
("provider", "webpush"),
|
|
||||||
("endpoint", ""),
|
|
||||||
("key_auth", ""),
|
|
||||||
("key_p256dh", ""),
|
|
||||||
("token", ""),
|
|
||||||
("created_at", ""),
|
|
||||||
("deleted_at", ""),
|
|
||||||
):
|
|
||||||
if not push_registration.has_column(column):
|
|
||||||
push_registration.create_column_by_example(column, example)
|
|
||||||
_index(db, "push_registration", "idx_push_registration_user", ["user_uid"])
|
_index(db, "push_registration", "idx_push_registration_user", ["user_uid"])
|
||||||
_index(db, "push_registration", "idx_push_registration_provider", ["provider"])
|
|
||||||
if "push_registration" in db.tables:
|
|
||||||
with db:
|
|
||||||
db.query(
|
|
||||||
"UPDATE push_registration SET provider = 'webpush' "
|
|
||||||
"WHERE provider IS NULL OR provider = ''"
|
|
||||||
)
|
|
||||||
_index(db, "sessions", "idx_sessions_token", ["session_token"])
|
_index(db, "sessions", "idx_sessions_token", ["session_token"])
|
||||||
projects = get_table("projects")
|
projects = get_table("projects")
|
||||||
for column, example in (
|
for column, example in (
|
||||||
@ -168,9 +146,6 @@ def init_db():
|
|||||||
("is_private", 0),
|
("is_private", 0),
|
||||||
("read_only", 0),
|
("read_only", 0),
|
||||||
("updated_at", ""),
|
("updated_at", ""),
|
||||||
("title", ""),
|
|
||||||
("description", ""),
|
|
||||||
("status", ""),
|
|
||||||
):
|
):
|
||||||
if not projects.has_column(column):
|
if not projects.has_column(column):
|
||||||
projects.create_column_by_example(column, example)
|
projects.create_column_by_example(column, example)
|
||||||
@ -1785,29 +1760,6 @@ def backfill_api_keys() -> int:
|
|||||||
return updated
|
return updated
|
||||||
|
|
||||||
|
|
||||||
MILESTONE_SOURCES = (
|
|
||||||
("posts", "user_uid"),
|
|
||||||
("comments", "user_uid"),
|
|
||||||
("projects", "user_uid"),
|
|
||||||
("gists", "user_uid"),
|
|
||||||
("follows", "follower_uid"),
|
|
||||||
("follows", "following_uid"),
|
|
||||||
("user_activity", "user_uid"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _milestone_candidates() -> set:
|
|
||||||
tables = db.tables
|
|
||||||
candidates = set()
|
|
||||||
for table, column in MILESTONE_SOURCES:
|
|
||||||
if table not in tables:
|
|
||||||
continue
|
|
||||||
for row in db.query(f"SELECT DISTINCT {column} AS uid FROM {table}"):
|
|
||||||
if row["uid"]:
|
|
||||||
candidates.add(row["uid"])
|
|
||||||
return candidates
|
|
||||||
|
|
||||||
|
|
||||||
def _backfill_gamification():
|
def _backfill_gamification():
|
||||||
if "users" not in db.tables:
|
if "users" not in db.tables:
|
||||||
return
|
return
|
||||||
@ -1871,12 +1823,6 @@ def _backfill_gamification():
|
|||||||
)
|
)
|
||||||
|
|
||||||
_authors_cache.clear()
|
_authors_cache.clear()
|
||||||
candidates = _milestone_candidates()
|
for user in pending:
|
||||||
checked = [user for user in pending if user["uid"] in candidates]
|
|
||||||
for user in checked:
|
|
||||||
check_milestone_badges(user["uid"])
|
check_milestone_badges(user["uid"])
|
||||||
logger.info(
|
logger.info(f"Gamification backfill processed {len(pending)} users")
|
||||||
f"Gamification backfill processed {len(pending)} users, "
|
|
||||||
f"{len(checked)} with milestone-eligible activity"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|||||||
@ -77,10 +77,7 @@ def get_online_users(cutoff_iso: str, limit: int = 30) -> list:
|
|||||||
def _can_hold_primary_admin(row, tracks_active):
|
def _can_hold_primary_admin(row, tracks_active):
|
||||||
if row.get("deleted_at"):
|
if row.get("deleted_at"):
|
||||||
return False
|
return False
|
||||||
if not tracks_active:
|
return not tracks_active or bool(row.get("is_active"))
|
||||||
return True
|
|
||||||
is_active = row.get("is_active")
|
|
||||||
return is_active is None or bool(is_active)
|
|
||||||
|
|
||||||
|
|
||||||
def get_primary_admin_uid():
|
def get_primary_admin_uid():
|
||||||
|
|||||||
@ -648,26 +648,6 @@ four ways to sign requests.
|
|||||||
field("label", "json", "string", False, "", "Optional admin-facing note."),
|
field("label", "json", "string", False, "", "Optional admin-facing note."),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
endpoint(
|
|
||||||
id="admin-gateway-quota-reset",
|
|
||||||
method="POST",
|
|
||||||
path="/admin/gateway/quota-resets",
|
|
||||||
title="Reset the AI gateway 24h spend",
|
|
||||||
summary=(
|
|
||||||
"Clear the counted rolling-24h spend for a scope so a capped caller can call "
|
|
||||||
"again, without deleting any usage history (the cost analytics stay intact). "
|
|
||||||
"Scope it exactly like a quota rule; leaving all three dimensions blank resets "
|
|
||||||
"every caller. Only spend recorded before the reset is cleared - new calls "
|
|
||||||
"count again immediately against the same limit."
|
|
||||||
),
|
|
||||||
auth="admin",
|
|
||||||
destructive=True,
|
|
||||||
params=[
|
|
||||||
field("owner_kind", "json", "string", False, "user", "internal, key, user, admin, or anonymous. Blank = every role."),
|
|
||||||
field("owner_id", "json", "string", False, "", "Specific user uid. Blank = every caller."),
|
|
||||||
field("app_reference", "json", "string", False, "typosaurus", "App label (the X-App-Reference header). Blank = every app."),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
endpoint(
|
endpoint(
|
||||||
id="admin-gateway-quota-rule-delete",
|
id="admin-gateway-quota-rule-delete",
|
||||||
method="DELETE",
|
method="DELETE",
|
||||||
|
|||||||
@ -41,12 +41,9 @@ four ways to sign requests.
|
|||||||
method="GET",
|
method="GET",
|
||||||
path="/profile/{username}",
|
path="/profile/{username}",
|
||||||
title="View a profile",
|
title="View a profile",
|
||||||
summary="Render a user profile, including an online-presence indicator (JSON exposes profile_online, profile_user.last_seen, xp_next_level, and xp_progress_pct). Returns an HTML page.",
|
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",
|
auth="public",
|
||||||
interactive=True,
|
interactive=True,
|
||||||
notes=[
|
|
||||||
"Level progress: `xp_next_level = level * 100` (total XP needed), `xp_progress_pct = xp % 100` (percentage towards next level). Both are also embedded in `profile_user`.",
|
|
||||||
],
|
|
||||||
params=[
|
params=[
|
||||||
field(
|
field(
|
||||||
"username",
|
"username",
|
||||||
@ -796,4 +793,3 @@ four ways to sign requests.
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -8,15 +8,8 @@ GROUP = {
|
|||||||
"intro": """
|
"intro": """
|
||||||
# Web Push
|
# Web Push
|
||||||
|
|
||||||
Push notifications are delivered by one or more providers. `webpush` is the default and
|
Browser push notifications via the Web Push protocol. Fetch the public VAPID key, then
|
||||||
implements the Web Push protocol: fetch the public VAPID key, then register a
|
register a `PushSubscription` obtained from the browser's `PushManager`.
|
||||||
`PushSubscription` obtained from the browser's `PushManager`. `apns` delivers to an Apple
|
|
||||||
Push Notification service device token and is only offered when an administrator has
|
|
||||||
configured it.
|
|
||||||
|
|
||||||
`GET /push.json` lists the providers that currently accept registrations. A registration
|
|
||||||
body without a `provider` field is a `webpush` registration, so existing clients need no
|
|
||||||
change.
|
|
||||||
|
|
||||||
There is no server-side unsubscribe endpoint: unsubscription is handled entirely in the
|
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
|
browser by calling `PushManager.unsubscribe()` on the subscription. The server stops delivering
|
||||||
@ -33,60 +26,39 @@ four ways to sign requests.
|
|||||||
method="GET",
|
method="GET",
|
||||||
path="/push.json",
|
path="/push.json",
|
||||||
title="Get the public key",
|
title="Get the public key",
|
||||||
summary="Return the VAPID public key and the providers that accept registrations.",
|
summary="Return the VAPID public key for subscribing.",
|
||||||
auth="public",
|
auth="public",
|
||||||
sample_response={
|
sample_response={"publicKey": "BASE64_VAPID_KEY"},
|
||||||
"publicKey": "BASE64_VAPID_KEY",
|
|
||||||
"providers": {"webpush": {"publicKey": "BASE64_VAPID_KEY"}},
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
endpoint(
|
endpoint(
|
||||||
id="push-register",
|
id="push-register",
|
||||||
method="POST",
|
method="POST",
|
||||||
path="/push.json",
|
path="/push.json",
|
||||||
title="Register a subscription",
|
title="Register a subscription",
|
||||||
summary="Register a push subscription. Sends a welcome notification.",
|
summary="Register a browser push subscription. Sends a welcome notification.",
|
||||||
auth="user",
|
auth="user",
|
||||||
encoding="json",
|
encoding="json",
|
||||||
interactive=False,
|
interactive=False,
|
||||||
params=[
|
params=[
|
||||||
field(
|
|
||||||
"provider",
|
|
||||||
"json",
|
|
||||||
"string",
|
|
||||||
False,
|
|
||||||
"webpush",
|
|
||||||
"Provider to register with. Omit for webpush.",
|
|
||||||
),
|
|
||||||
field(
|
field(
|
||||||
"endpoint",
|
"endpoint",
|
||||||
"json",
|
"json",
|
||||||
"string",
|
"string",
|
||||||
False,
|
True,
|
||||||
"https://fcm.googleapis.com/...",
|
"https://fcm.googleapis.com/...",
|
||||||
"Subscription endpoint URL. Required for webpush.",
|
"Subscription endpoint URL.",
|
||||||
),
|
),
|
||||||
field(
|
field(
|
||||||
"keys",
|
"keys",
|
||||||
"json",
|
"json",
|
||||||
"string",
|
"string",
|
||||||
False,
|
True,
|
||||||
'{"p256dh":"...","auth":"..."}',
|
'{"p256dh":"...","auth":"..."}',
|
||||||
"Subscription keys object. Required for webpush.",
|
"Subscription keys object.",
|
||||||
),
|
|
||||||
field(
|
|
||||||
"token",
|
|
||||||
"json",
|
|
||||||
"string",
|
|
||||||
False,
|
|
||||||
"a1b2c3...",
|
|
||||||
"Hexadecimal device token. Required for apns.",
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
notes=[
|
notes=[
|
||||||
'A webpush body is JSON: `{"endpoint": "...", "keys": {"p256dh": "...", "auth": "..."}}`.',
|
'The body must be JSON: `{"endpoint": "...", "keys": {"p256dh": "...", "auth": "..."}}`.'
|
||||||
'An APNs body is JSON: `{"provider": "apns", "token": "..."}`.',
|
|
||||||
"A provider that is unknown, disabled or unconfigured returns 400.",
|
|
||||||
],
|
],
|
||||||
sample_response={"registered": True},
|
sample_response={"registered": True},
|
||||||
),
|
),
|
||||||
|
|||||||
@ -115,7 +115,6 @@ from devplacepy.services.containers.service import ContainerService
|
|||||||
from devplacepy.services.xmlrpc import XmlrpcService
|
from devplacepy.services.xmlrpc import XmlrpcService
|
||||||
from devplacepy.services.audit import AuditService
|
from devplacepy.services.audit import AuditService
|
||||||
from devplacepy.services.audit import record as audit
|
from devplacepy.services.audit import record as audit
|
||||||
from devplacepy.services.push import PushService
|
|
||||||
from devplacepy.services.telegram import TelegramService
|
from devplacepy.services.telegram import TelegramService
|
||||||
from devplacepy.services.telegram.outbox_service import TelegramOutboxService
|
from devplacepy.services.telegram.outbox_service import TelegramOutboxService
|
||||||
|
|
||||||
@ -276,7 +275,6 @@ async def lifespan(app: FastAPI):
|
|||||||
service_manager.register(ContainerService())
|
service_manager.register(ContainerService())
|
||||||
service_manager.register(XmlrpcService())
|
service_manager.register(XmlrpcService())
|
||||||
service_manager.register(AuditService())
|
service_manager.register(AuditService())
|
||||||
service_manager.register(PushService())
|
|
||||||
service_manager.register(TelegramService())
|
service_manager.register(TelegramService())
|
||||||
service_manager.register(TelegramOutboxService())
|
service_manager.register(TelegramOutboxService())
|
||||||
if not os.environ.get("DEVPLACE_DISABLE_SERVICES"):
|
if not os.environ.get("DEVPLACE_DISABLE_SERVICES"):
|
||||||
|
|||||||
@ -484,19 +484,9 @@ class SeoRunForm(BaseModel):
|
|||||||
text = value.strip()
|
text = value.strip()
|
||||||
if not text:
|
if not text:
|
||||||
raise ValueError("A URL is required")
|
raise ValueError("A URL is required")
|
||||||
if "://" in text:
|
|
||||||
scheme = text.split("://", 1)[0]
|
|
||||||
if scheme not in ("http", "https"):
|
|
||||||
raise ValueError(f"Only http and https URLs are allowed; got '{scheme}://'")
|
|
||||||
else:
|
|
||||||
text = f"https://{text}"
|
|
||||||
if not SEO_URL_PATTERN.match(text):
|
|
||||||
raise ValueError("URL must be a valid http or https source location")
|
|
||||||
return text
|
return text
|
||||||
|
|
||||||
|
|
||||||
SEO_URL_PATTERN = re.compile(r"^https?://[a-zA-Z0-9][\w./:@~^?&#%=;-]*$")
|
|
||||||
|
|
||||||
ISSLOP_URL_PATTERN = re.compile(r"^(https?://|git://|ssh://|git@)[\w./:@~^-]+$", re.IGNORECASE)
|
ISSLOP_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_SINGLE_SLASH_PATTERN = re.compile(r"^(https?|git|ssh):/(?!/)", re.IGNORECASE)
|
||||||
ISSLOP_SCHEME_PATTERN = re.compile(r"^[a-z][a-z0-9+.-]*://", re.IGNORECASE)
|
ISSLOP_SCHEME_PATTERN = re.compile(r"^[a-z][a-z0-9+.-]*://", re.IGNORECASE)
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import logging
|
|||||||
import os
|
import os
|
||||||
import random
|
import random
|
||||||
import time
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
@ -19,6 +20,7 @@ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|||||||
from cryptography.hazmat.primitives.hashes import SHA256
|
from cryptography.hazmat.primitives.hashes import SHA256
|
||||||
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
||||||
|
|
||||||
|
from devplacepy import stealth
|
||||||
from devplacepy.config import (
|
from devplacepy.config import (
|
||||||
SECONDS_PER_DAY,
|
SECONDS_PER_DAY,
|
||||||
VAPID_PRIVATE_KEY_FILE,
|
VAPID_PRIVATE_KEY_FILE,
|
||||||
@ -26,15 +28,7 @@ from devplacepy.config import (
|
|||||||
VAPID_PUBLIC_KEY_FILE,
|
VAPID_PUBLIC_KEY_FILE,
|
||||||
VAPID_SUB,
|
VAPID_SUB,
|
||||||
)
|
)
|
||||||
from devplacepy.database import get_setting
|
from devplacepy.database import get_table
|
||||||
from devplacepy.push.providers.base import (
|
|
||||||
ACCEPTED,
|
|
||||||
DEAD,
|
|
||||||
REJECTED,
|
|
||||||
Delivery,
|
|
||||||
PushProvider,
|
|
||||||
)
|
|
||||||
from devplacepy.services.base import ConfigField
|
|
||||||
from devplacepy.utils import generate_uid
|
from devplacepy.utils import generate_uid
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@ -43,8 +37,6 @@ JWT_LIFETIME_SECONDS = 60 * 60
|
|||||||
PUSH_TTL_SECONDS = str(SECONDS_PER_DAY)
|
PUSH_TTL_SECONDS = str(SECONDS_PER_DAY)
|
||||||
DEAD_SUBSCRIPTION_STATUSES = (404, 410)
|
DEAD_SUBSCRIPTION_STATUSES = (404, 410)
|
||||||
ACCEPTED_STATUSES = (200, 201)
|
ACCEPTED_STATUSES = (200, 201)
|
||||||
SUBJECT_KEY = "push_webpush_subject"
|
|
||||||
PROVIDER_LABEL = "Web Push (VAPID)"
|
|
||||||
|
|
||||||
|
|
||||||
def generate_private_key() -> None:
|
def generate_private_key() -> None:
|
||||||
@ -157,17 +149,13 @@ def public_key_standard_b64() -> str:
|
|||||||
return base64.b64encode(point).decode("utf-8").rstrip("=")
|
return base64.b64encode(point).decode("utf-8").rstrip("=")
|
||||||
|
|
||||||
|
|
||||||
def subject() -> str:
|
|
||||||
return get_setting(SUBJECT_KEY, "").strip() or VAPID_SUB
|
|
||||||
|
|
||||||
|
|
||||||
def create_notification_authorization(push_url: str) -> str:
|
def create_notification_authorization(push_url: str) -> str:
|
||||||
target = urlparse(push_url)
|
target = urlparse(push_url)
|
||||||
audience = f"{target.scheme}://{target.netloc}"
|
audience = f"{target.scheme}://{target.netloc}"
|
||||||
issued_at = int(time.time())
|
issued_at = int(time.time())
|
||||||
return jwt.encode(
|
return jwt.encode(
|
||||||
{
|
{
|
||||||
"sub": subject(),
|
"sub": VAPID_SUB,
|
||||||
"aud": audience,
|
"aud": audience,
|
||||||
"exp": issued_at + JWT_LIFETIME_SECONDS,
|
"exp": issued_at + JWT_LIFETIME_SECONDS,
|
||||||
"nbf": issued_at,
|
"nbf": issued_at,
|
||||||
@ -235,76 +223,78 @@ def create_notification_info_with_payload(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class WebPushProvider(PushProvider):
|
def _mark_subscription_dead(subscription_id: int) -> None:
|
||||||
name = "webpush"
|
get_table("push_registration").update(
|
||||||
label = PROVIDER_LABEL
|
{"id": subscription_id, "deleted_at": datetime.now(timezone.utc).isoformat()},
|
||||||
config_fields = [
|
["id"],
|
||||||
ConfigField(
|
)
|
||||||
SUBJECT_KEY,
|
logger.info("Soft-deleted dead push subscription id=%s", subscription_id)
|
||||||
"VAPID subject",
|
|
||||||
type="str",
|
|
||||||
default=VAPID_SUB,
|
|
||||||
help="Contact sent as the JWT sub claim, a mailto: or https: URL. Blank uses the built-in default.",
|
|
||||||
group=PROVIDER_LABEL,
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
def is_configured(self) -> bool:
|
|
||||||
return True
|
|
||||||
|
|
||||||
def client_config(self) -> dict[str, Any]:
|
async def notify_user(user_uid: str, payload: dict[str, Any]) -> None:
|
||||||
try:
|
registrations = list(
|
||||||
return {"publicKey": public_key_standard_b64()}
|
get_table("push_registration").find(user_uid=user_uid, deleted_at=None)
|
||||||
except Exception as exc:
|
)
|
||||||
logger.error("VAPID key material unavailable: %s", exc)
|
if not registrations:
|
||||||
return {}
|
logger.debug("No active push subscriptions for user %s", user_uid)
|
||||||
|
return
|
||||||
|
|
||||||
def parse_registration(self, body: dict[str, Any]) -> dict[str, Any] | None:
|
body = json.dumps(payload)
|
||||||
keys = body.get("keys")
|
async with stealth.stealth_async_client(timeout=10.0) as client:
|
||||||
if not isinstance(keys, dict):
|
for subscription in registrations:
|
||||||
return None
|
endpoint = subscription["endpoint"]
|
||||||
endpoint = body.get("endpoint")
|
try:
|
||||||
key_auth = keys.get("auth")
|
notification_payload = create_notification_info_with_payload(
|
||||||
key_p256dh = keys.get("p256dh")
|
endpoint,
|
||||||
if not (
|
subscription["key_auth"],
|
||||||
isinstance(endpoint, str)
|
subscription["key_p256dh"],
|
||||||
and isinstance(key_auth, str)
|
body,
|
||||||
and isinstance(key_p256dh, str)
|
)
|
||||||
and endpoint
|
headers = {**notification_payload["headers"], "TTL": PUSH_TTL_SECONDS}
|
||||||
and key_auth
|
response = await client.post(
|
||||||
and key_p256dh
|
endpoint, headers=headers, content=notification_payload["data"]
|
||||||
):
|
)
|
||||||
return None
|
except (httpx.HTTPError, ValueError) as exc:
|
||||||
return {
|
logger.warning("Push error for %s via %s: %s", user_uid, endpoint, exc)
|
||||||
"endpoint": endpoint,
|
continue
|
||||||
"key_auth": key_auth,
|
|
||||||
"key_p256dh": key_p256dh,
|
|
||||||
}
|
|
||||||
|
|
||||||
def prepare(self, payload: dict[str, Any]) -> str:
|
if response.status_code in ACCEPTED_STATUSES:
|
||||||
return json.dumps(payload)
|
logger.debug("Push delivered to %s via %s", user_uid, endpoint)
|
||||||
|
elif response.status_code in DEAD_SUBSCRIPTION_STATUSES:
|
||||||
|
_mark_subscription_dead(subscription["id"])
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"Push rejected (%s) for %s via %s",
|
||||||
|
response.status_code,
|
||||||
|
user_uid,
|
||||||
|
endpoint,
|
||||||
|
)
|
||||||
|
|
||||||
async def deliver(
|
|
||||||
self, client: httpx.AsyncClient, registration: dict[str, Any], prepared: str
|
async def register(
|
||||||
) -> Delivery:
|
user_uid: str, endpoint: str, key_auth: str, key_p256dh: str
|
||||||
endpoint = registration.get("endpoint") or ""
|
) -> tuple[dict[str, Any], bool]:
|
||||||
if not endpoint:
|
table = get_table("push_registration")
|
||||||
return Delivery(DEAD, "missing endpoint")
|
existing = table.find_one(
|
||||||
try:
|
user_uid=user_uid,
|
||||||
notification_payload = create_notification_info_with_payload(
|
endpoint=endpoint,
|
||||||
endpoint,
|
key_auth=key_auth,
|
||||||
registration["key_auth"],
|
key_p256dh=key_p256dh,
|
||||||
registration["key_p256dh"],
|
deleted_at=None,
|
||||||
prepared,
|
)
|
||||||
)
|
if existing:
|
||||||
headers = {**notification_payload["headers"], "TTL": PUSH_TTL_SECONDS}
|
logger.debug("Push subscription already registered for user %s", user_uid)
|
||||||
response = await client.post(
|
return existing, False
|
||||||
endpoint, headers=headers, content=notification_payload["data"]
|
|
||||||
)
|
record = {
|
||||||
except (httpx.HTTPError, ValueError, KeyError, TypeError) as exc:
|
"uid": generate_uid(),
|
||||||
return Delivery(REJECTED, str(exc))
|
"user_uid": user_uid,
|
||||||
if response.status_code in ACCEPTED_STATUSES:
|
"endpoint": endpoint,
|
||||||
return Delivery(ACCEPTED)
|
"key_auth": key_auth,
|
||||||
if response.status_code in DEAD_SUBSCRIPTION_STATUSES:
|
"key_p256dh": key_p256dh,
|
||||||
return Delivery(DEAD, str(response.status_code))
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||||
return Delivery(REJECTED, str(response.status_code))
|
"deleted_at": None,
|
||||||
|
}
|
||||||
|
table.insert(record)
|
||||||
|
logger.info("Registered push subscription for user %s", user_uid)
|
||||||
|
return record, True
|
||||||
@ -1,52 +0,0 @@
|
|||||||
This file documents `devplacepy/push/` - push notification delivery and its provider architecture. Claude Code loads it automatically whenever a file under this directory is read or edited.
|
|
||||||
|
|
||||||
## What this package is
|
|
||||||
|
|
||||||
One delivery library behind one public surface. `devplacepy.push` exports `notify_user`, `register`, `ensure_certificates`, `public_key_standard_b64` and the Web Push crypto helpers; every caller in the codebase (`main.py`, `utils/notifications.py`, `routers/push.py`) imports only those names. Everything else is internal to the package.
|
|
||||||
|
|
||||||
| Module | Role |
|
|
||||||
|---|---|
|
|
||||||
| `providers/base.py` | `PushProvider` protocol, the `Delivery` outcome and the three outcome constants |
|
|
||||||
| `providers/webpush.py` | VAPID key material, `aesgcm` payload encryption, the Web Push provider |
|
|
||||||
| `providers/apns.py` | Apple Push Notification service provider (token based, HTTP/2) |
|
|
||||||
| `providers/__init__.py` | `PROVIDERS` registry, `get`, `active`, `is_active`, `admin_fields`, `client_config` |
|
|
||||||
| `store.py` | Every `push_registration` read and write |
|
|
||||||
| `delivery.py` | `notify_user`: group by provider, one shared client, one prepared body per provider |
|
|
||||||
|
|
||||||
The admin configuration surface lives in `devplacepy/services/push/service.py` (`PushService`), not here.
|
|
||||||
|
|
||||||
## Adding a provider
|
|
||||||
|
|
||||||
1. Write `providers/<name>.py` with a `PushProvider` subclass: `name`, `label`, `config_fields`, `is_configured`, `parse_registration`, `prepare`, `deliver`, optionally `client_config`.
|
|
||||||
2. Add one entry to `PROVIDERS` in `providers/__init__.py`.
|
|
||||||
|
|
||||||
That is the whole change. The registration route, the delivery loop, the admin page, the audit record, the metrics and the docs are written against the protocol and need no edit. The `Enabled` toggle (`push_<name>_enabled`) comes from the base class, so a provider never declares its own.
|
|
||||||
|
|
||||||
## Invariants
|
|
||||||
|
|
||||||
- **Zero cost for the request.** Delivery is reached only through `utils/notifications.py` `_schedule_push`, a fire-and-forget task. Never make a route await `notify_user`, and never add a queue or a table to this path.
|
|
||||||
- **`deliver` never raises.** Return `Delivery(REJECTED, detail)` instead. `delivery.py` guards anyway, but a raising provider costs a log line per subscription.
|
|
||||||
- **A provider that is not configured is inert, never an error.** `is_configured()` is false, `is_active()` is false, the delivery loop skips it, and `POST /push.json` refuses a registration for it with 400. Nothing else in the platform notices.
|
|
||||||
- **`DEAD` is the only outcome that touches the database.** It soft-deletes the registration (`deleted_at`), exactly like a `404`/`410` Web Push endpoint always did. `REJECTED` keeps the row.
|
|
||||||
- **Every insert writes `deleted_at: None`,** and every read filters `deleted_at IS NULL`. `push_registration` deliberately stays out of `SOFT_DELETE_TABLES` (no `deleted_by`, not restorable from Trash) - a dead device token has no owner action to undo.
|
|
||||||
- **A row without a provider is a Web Push row.** `store.provider_of` resolves `None`/`""` to `DEFAULT_PROVIDER`, so a row written by an old worker during a deploy still delivers. `init_db` backfills the column once with a single converging `UPDATE`.
|
|
||||||
|
|
||||||
## Storage
|
|
||||||
|
|
||||||
`push_registration` columns are ensured in `init_db` (`database/schema.py`) because `dataset` only creates the columns of a table's first insert, and `find(provider=...)` against a missing column matches nothing.
|
|
||||||
|
|
||||||
| Column | webpush | apns |
|
|
||||||
|---|---|---|
|
|
||||||
| `provider` | `webpush` | `apns` |
|
|
||||||
| `endpoint`, `key_auth`, `key_p256dh` | set | `NULL` |
|
|
||||||
| `token` | `NULL` | device token |
|
|
||||||
|
|
||||||
Deduplication is generic: `store.register` looks up `user_uid` + `provider` + exactly the fields the provider's `parse_registration` returned, so a provider never writes its own identity rule.
|
|
||||||
|
|
||||||
## APNs specifics
|
|
||||||
|
|
||||||
- `POST https://{host}/3/device/{token}` over HTTP/2, host from `push_apns_environment` (`api.push.apple.com` or `api.sandbox.push.apple.com`; an unrecognised value falls back to production). HTTP/2 comes from `stealth_async_client` because the origin is `https` - the cleartext downgrade in `curl_transport` does not apply.
|
|
||||||
- Provider token: `ES256`, header `kid` = key id, claims `iss` = team id and `iat`. Cached per credential fingerprint for 45 minutes, so a worker signs at most one token per 45 minutes; Apple refuses tokens regenerated faster than every 20 minutes. Editing any credential changes the fingerprint and takes effect on the next delivery, with no restart.
|
|
||||||
- A `.p8` that does not parse is cached as a failure for the same window, so a misconfiguration costs one error log per window rather than one parse per notification.
|
|
||||||
- `410`, or any status carrying reason `BadDeviceToken`, `Unregistered`, `ExpiredToken`, `DeviceTokenNotForTopic` or `TopicDisallowed`, is `DEAD`. Everything else is `REJECTED`.
|
|
||||||
- The shared payload dict (`title`, `message`, `icon`, `url`) is translated once per batch into `aps.alert` plus the custom `url`/`icon` keys, mirroring what `service-worker.js` does for Web Push. `thread-id` mirrors the service worker's notification `tag`.
|
|
||||||
@ -1,29 +0,0 @@
|
|||||||
# retoor <retoor@molodetz.nl>
|
|
||||||
|
|
||||||
from devplacepy.push.delivery import notify_user
|
|
||||||
from devplacepy.push.providers.webpush import (
|
|
||||||
browser_base64,
|
|
||||||
create_notification_authorization,
|
|
||||||
create_notification_info_with_payload,
|
|
||||||
ensure_certificates,
|
|
||||||
generate_pkcs8_private_key,
|
|
||||||
generate_private_key,
|
|
||||||
generate_public_key,
|
|
||||||
hkdf,
|
|
||||||
public_key_standard_b64,
|
|
||||||
)
|
|
||||||
from devplacepy.push.store import register
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"browser_base64",
|
|
||||||
"create_notification_authorization",
|
|
||||||
"create_notification_info_with_payload",
|
|
||||||
"ensure_certificates",
|
|
||||||
"generate_pkcs8_private_key",
|
|
||||||
"generate_private_key",
|
|
||||||
"generate_public_key",
|
|
||||||
"hkdf",
|
|
||||||
"notify_user",
|
|
||||||
"public_key_standard_b64",
|
|
||||||
"register",
|
|
||||||
]
|
|
||||||
@ -1,83 +0,0 @@
|
|||||||
# retoor <retoor@molodetz.nl>
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from devplacepy import stealth
|
|
||||||
from devplacepy.database import get_int_setting
|
|
||||||
from devplacepy.push import providers, store
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
TIMEOUT_KEY = "push_delivery_timeout_seconds"
|
|
||||||
DEFAULT_TIMEOUT_SECONDS = 10
|
|
||||||
MIN_TIMEOUT_SECONDS = 1
|
|
||||||
MAX_TIMEOUT_SECONDS = 120
|
|
||||||
|
|
||||||
|
|
||||||
def timeout_seconds() -> float:
|
|
||||||
seconds = get_int_setting(TIMEOUT_KEY, DEFAULT_TIMEOUT_SECONDS)
|
|
||||||
return float(min(max(seconds, MIN_TIMEOUT_SECONDS), MAX_TIMEOUT_SECONDS))
|
|
||||||
|
|
||||||
|
|
||||||
def group_by_provider(
|
|
||||||
registrations: list[dict[str, Any]],
|
|
||||||
) -> dict[str, list[dict[str, Any]]]:
|
|
||||||
grouped: dict[str, list[dict[str, Any]]] = {}
|
|
||||||
for registration in registrations:
|
|
||||||
grouped.setdefault(store.provider_of(registration), []).append(registration)
|
|
||||||
return grouped
|
|
||||||
|
|
||||||
|
|
||||||
async def notify_user(user_uid: str, payload: dict[str, Any]) -> None:
|
|
||||||
registrations = store.active_for_user(user_uid)
|
|
||||||
if not registrations:
|
|
||||||
logger.debug("No active push subscriptions for user %s", user_uid)
|
|
||||||
return
|
|
||||||
|
|
||||||
grouped = group_by_provider(registrations)
|
|
||||||
async with stealth.stealth_async_client(timeout=timeout_seconds()) as client:
|
|
||||||
for name, rows in grouped.items():
|
|
||||||
provider = providers.PROVIDERS.get(name)
|
|
||||||
if provider is None:
|
|
||||||
logger.warning(
|
|
||||||
"Unknown push provider %s on %s subscriptions of user %s",
|
|
||||||
name,
|
|
||||||
len(rows),
|
|
||||||
user_uid,
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
if not providers.is_active(provider):
|
|
||||||
logger.debug(
|
|
||||||
"Push provider %s is not active; skipping %s subscriptions",
|
|
||||||
name,
|
|
||||||
len(rows),
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
prepared = provider.prepare(payload)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.error("Push provider %s could not build a payload: %s", name, exc)
|
|
||||||
continue
|
|
||||||
for registration in rows:
|
|
||||||
await _deliver_one(provider, client, registration, prepared, user_uid)
|
|
||||||
|
|
||||||
|
|
||||||
async def _deliver_one(provider, client, registration, prepared, user_uid) -> None:
|
|
||||||
try:
|
|
||||||
outcome = await provider.deliver(client, registration, prepared)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.error("Push provider %s raised for %s: %s", provider.name, user_uid, exc)
|
|
||||||
return
|
|
||||||
if outcome.status == providers.ACCEPTED:
|
|
||||||
logger.debug("Push delivered to %s via %s", user_uid, provider.name)
|
|
||||||
return
|
|
||||||
if outcome.status == providers.DEAD:
|
|
||||||
try:
|
|
||||||
store.mark_dead(registration["id"])
|
|
||||||
except Exception as exc:
|
|
||||||
logger.error("Could not soft-delete push subscription: %s", exc)
|
|
||||||
return
|
|
||||||
logger.warning(
|
|
||||||
"Push rejected by %s for %s: %s", provider.name, user_uid, outcome.detail
|
|
||||||
)
|
|
||||||
@ -1,76 +0,0 @@
|
|||||||
# retoor <retoor@molodetz.nl>
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from devplacepy.push.providers.apns import ApnsProvider
|
|
||||||
from devplacepy.push.providers.base import (
|
|
||||||
ACCEPTED,
|
|
||||||
DEAD,
|
|
||||||
REJECTED,
|
|
||||||
Delivery,
|
|
||||||
PushProvider,
|
|
||||||
)
|
|
||||||
from devplacepy.push.providers.webpush import WebPushProvider
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
DEFAULT_PROVIDER = WebPushProvider.name
|
|
||||||
|
|
||||||
PROVIDERS: dict[str, PushProvider] = {
|
|
||||||
provider.name: provider for provider in (WebPushProvider(), ApnsProvider())
|
|
||||||
}
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"ACCEPTED",
|
|
||||||
"DEAD",
|
|
||||||
"DEFAULT_PROVIDER",
|
|
||||||
"Delivery",
|
|
||||||
"PROVIDERS",
|
|
||||||
"PushProvider",
|
|
||||||
"REJECTED",
|
|
||||||
"active",
|
|
||||||
"admin_fields",
|
|
||||||
"client_config",
|
|
||||||
"get",
|
|
||||||
"is_active",
|
|
||||||
"names",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def get(name: str | None) -> PushProvider | None:
|
|
||||||
if not isinstance(name, str):
|
|
||||||
name = ""
|
|
||||||
return PROVIDERS.get(name.strip().lower() or DEFAULT_PROVIDER)
|
|
||||||
|
|
||||||
|
|
||||||
def names() -> list[str]:
|
|
||||||
return list(PROVIDERS)
|
|
||||||
|
|
||||||
|
|
||||||
def active() -> list[PushProvider]:
|
|
||||||
return [provider for provider in PROVIDERS.values() if is_active(provider)]
|
|
||||||
|
|
||||||
|
|
||||||
def admin_fields() -> list:
|
|
||||||
return [field for provider in PROVIDERS.values() for field in provider.all_fields()]
|
|
||||||
|
|
||||||
|
|
||||||
def client_config() -> dict[str, Any]:
|
|
||||||
return {provider.name: _client_config(provider) for provider in active()}
|
|
||||||
|
|
||||||
|
|
||||||
def is_active(provider: PushProvider) -> bool:
|
|
||||||
try:
|
|
||||||
return provider.is_active()
|
|
||||||
except Exception as exc:
|
|
||||||
logger.error("Push provider %s failed its readiness check: %s", provider.name, exc)
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _client_config(provider: PushProvider) -> dict[str, Any]:
|
|
||||||
try:
|
|
||||||
return provider.client_config()
|
|
||||||
except Exception as exc:
|
|
||||||
logger.error("Push provider %s failed to describe itself: %s", provider.name, exc)
|
|
||||||
return {}
|
|
||||||
@ -1,243 +0,0 @@
|
|||||||
# retoor <retoor@molodetz.nl>
|
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import string
|
|
||||||
import time
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
import jwt
|
|
||||||
|
|
||||||
from devplacepy.config import SECONDS_PER_DAY
|
|
||||||
from devplacepy.database import get_setting
|
|
||||||
from devplacepy.push.providers.base import (
|
|
||||||
ACCEPTED,
|
|
||||||
DEAD,
|
|
||||||
REJECTED,
|
|
||||||
Delivery,
|
|
||||||
PushProvider,
|
|
||||||
)
|
|
||||||
from devplacepy.services.base import ConfigField
|
|
||||||
from devplacepy.utils import DEFAULT_PUSH_URL, PUSH_ICON, generate_uid
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
TEAM_ID_KEY = "push_apns_team_id"
|
|
||||||
KEY_ID_KEY = "push_apns_key_id"
|
|
||||||
AUTH_KEY_KEY = "push_apns_auth_key"
|
|
||||||
TOPIC_KEY = "push_apns_topic"
|
|
||||||
ENVIRONMENT_KEY = "push_apns_environment"
|
|
||||||
|
|
||||||
PROVIDER_LABEL = "Apple Push (APNs)"
|
|
||||||
DEFAULT_ENVIRONMENT = "production"
|
|
||||||
HOSTS = {
|
|
||||||
"production": "api.push.apple.com",
|
|
||||||
"sandbox": "api.sandbox.push.apple.com",
|
|
||||||
}
|
|
||||||
ENVIRONMENT_OPTIONS = [
|
|
||||||
{"value": "production", "label": "Production"},
|
|
||||||
{"value": "sandbox", "label": "Sandbox"},
|
|
||||||
]
|
|
||||||
|
|
||||||
TOKEN_REFRESH_SECONDS = 45 * 60
|
|
||||||
TOKEN_MIN_LENGTH = 64
|
|
||||||
TOKEN_MAX_LENGTH = 200
|
|
||||||
THREAD_ID = "devplace-notification"
|
|
||||||
PUSH_TYPE = "alert"
|
|
||||||
PRIORITY = "10"
|
|
||||||
DEAD_REASONS = frozenset(
|
|
||||||
{
|
|
||||||
"BadDeviceToken",
|
|
||||||
"DeviceTokenNotForTopic",
|
|
||||||
"ExpiredToken",
|
|
||||||
"Unregistered",
|
|
||||||
"TopicDisallowed",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
_token_state: dict[str, Any] = {}
|
|
||||||
|
|
||||||
|
|
||||||
def _setting(key: str) -> str:
|
|
||||||
return get_setting(key, "").strip()
|
|
||||||
|
|
||||||
|
|
||||||
def _environment() -> str:
|
|
||||||
value = _setting(ENVIRONMENT_KEY) or DEFAULT_ENVIRONMENT
|
|
||||||
return value if value in HOSTS else DEFAULT_ENVIRONMENT
|
|
||||||
|
|
||||||
|
|
||||||
def host() -> str:
|
|
||||||
return HOSTS[_environment()]
|
|
||||||
|
|
||||||
|
|
||||||
def _fingerprint(team_id: str, key_id: str, auth_key: str) -> str:
|
|
||||||
return hashlib.sha256(f"{team_id}:{key_id}:{auth_key}".encode("utf-8")).hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
def provider_token(team_id: str, key_id: str, auth_key: str) -> str:
|
|
||||||
fingerprint = _fingerprint(team_id, key_id, auth_key)
|
|
||||||
issued_at = int(time.time())
|
|
||||||
state = _token_state.get("current")
|
|
||||||
if (
|
|
||||||
state
|
|
||||||
and state["fingerprint"] == fingerprint
|
|
||||||
and issued_at - state["issued_at"] < TOKEN_REFRESH_SECONDS
|
|
||||||
):
|
|
||||||
if state["token"] is None:
|
|
||||||
raise ValueError(state["error"])
|
|
||||||
return state["token"]
|
|
||||||
try:
|
|
||||||
token = jwt.encode(
|
|
||||||
{"iss": team_id, "iat": issued_at},
|
|
||||||
auth_key,
|
|
||||||
algorithm="ES256",
|
|
||||||
headers={"kid": key_id},
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
message = f"APNs auth key is not usable: {exc}"
|
|
||||||
_token_state["current"] = {
|
|
||||||
"token": None,
|
|
||||||
"error": message,
|
|
||||||
"issued_at": issued_at,
|
|
||||||
"fingerprint": fingerprint,
|
|
||||||
}
|
|
||||||
logger.error(message)
|
|
||||||
raise ValueError(message) from exc
|
|
||||||
_token_state["current"] = {
|
|
||||||
"token": token,
|
|
||||||
"error": "",
|
|
||||||
"issued_at": issued_at,
|
|
||||||
"fingerprint": fingerprint,
|
|
||||||
}
|
|
||||||
return token
|
|
||||||
|
|
||||||
|
|
||||||
def _reason(response: httpx.Response) -> str:
|
|
||||||
try:
|
|
||||||
body = response.json()
|
|
||||||
except ValueError:
|
|
||||||
return ""
|
|
||||||
if isinstance(body, dict):
|
|
||||||
return str(body.get("reason", ""))
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
class ApnsProvider(PushProvider):
|
|
||||||
name = "apns"
|
|
||||||
label = PROVIDER_LABEL
|
|
||||||
config_fields = [
|
|
||||||
ConfigField(
|
|
||||||
TEAM_ID_KEY,
|
|
||||||
"Team ID",
|
|
||||||
type="str",
|
|
||||||
default="",
|
|
||||||
help="Ten character Apple Developer team identifier, used as the token iss claim.",
|
|
||||||
group=PROVIDER_LABEL,
|
|
||||||
),
|
|
||||||
ConfigField(
|
|
||||||
KEY_ID_KEY,
|
|
||||||
"Key ID",
|
|
||||||
type="str",
|
|
||||||
default="",
|
|
||||||
help="Ten character identifier of the APNs auth key, sent as the token kid header.",
|
|
||||||
group=PROVIDER_LABEL,
|
|
||||||
),
|
|
||||||
ConfigField(
|
|
||||||
AUTH_KEY_KEY,
|
|
||||||
"Auth key (.p8)",
|
|
||||||
type="text",
|
|
||||||
default="",
|
|
||||||
secret=True,
|
|
||||||
help="Contents of the APNs .p8 signing key, including the BEGIN and END lines. Leave blank to keep the stored key.",
|
|
||||||
group=PROVIDER_LABEL,
|
|
||||||
),
|
|
||||||
ConfigField(
|
|
||||||
TOPIC_KEY,
|
|
||||||
"Topic",
|
|
||||||
type="str",
|
|
||||||
default="",
|
|
||||||
help="Bundle identifier of the receiving app, sent as the apns-topic header.",
|
|
||||||
group=PROVIDER_LABEL,
|
|
||||||
),
|
|
||||||
ConfigField(
|
|
||||||
ENVIRONMENT_KEY,
|
|
||||||
"Environment",
|
|
||||||
type="select",
|
|
||||||
default=DEFAULT_ENVIRONMENT,
|
|
||||||
options=ENVIRONMENT_OPTIONS,
|
|
||||||
help="Production delivers to App Store builds, sandbox to development builds.",
|
|
||||||
group=PROVIDER_LABEL,
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
def is_configured(self) -> bool:
|
|
||||||
return bool(
|
|
||||||
_setting(TEAM_ID_KEY)
|
|
||||||
and _setting(KEY_ID_KEY)
|
|
||||||
and _setting(AUTH_KEY_KEY)
|
|
||||||
and _setting(TOPIC_KEY)
|
|
||||||
)
|
|
||||||
|
|
||||||
def parse_registration(self, body: dict[str, Any]) -> dict[str, Any] | None:
|
|
||||||
token = body.get("token")
|
|
||||||
if not isinstance(token, str):
|
|
||||||
return None
|
|
||||||
token = token.strip()
|
|
||||||
if not TOKEN_MIN_LENGTH <= len(token) <= TOKEN_MAX_LENGTH:
|
|
||||||
return None
|
|
||||||
if any(character not in string.hexdigits for character in token):
|
|
||||||
return None
|
|
||||||
return {"token": token}
|
|
||||||
|
|
||||||
def prepare(self, payload: dict[str, Any]) -> str:
|
|
||||||
return json.dumps(
|
|
||||||
{
|
|
||||||
"aps": {
|
|
||||||
"alert": {
|
|
||||||
"title": payload.get("title") or "DevPlace",
|
|
||||||
"body": payload.get("message") or "",
|
|
||||||
},
|
|
||||||
"sound": "default",
|
|
||||||
"thread-id": THREAD_ID,
|
|
||||||
},
|
|
||||||
"url": payload.get("url") or DEFAULT_PUSH_URL,
|
|
||||||
"icon": payload.get("icon") or PUSH_ICON,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
def headers(self) -> dict[str, str]:
|
|
||||||
return {
|
|
||||||
"authorization": f"bearer {provider_token(_setting(TEAM_ID_KEY), _setting(KEY_ID_KEY), _setting(AUTH_KEY_KEY))}",
|
|
||||||
"apns-topic": _setting(TOPIC_KEY),
|
|
||||||
"apns-push-type": PUSH_TYPE,
|
|
||||||
"apns-priority": PRIORITY,
|
|
||||||
"apns-expiration": str(int(time.time()) + SECONDS_PER_DAY),
|
|
||||||
"apns-id": generate_uid(),
|
|
||||||
"content-type": "application/json",
|
|
||||||
}
|
|
||||||
|
|
||||||
async def deliver(
|
|
||||||
self, client: httpx.AsyncClient, registration: dict[str, Any], prepared: str
|
|
||||||
) -> Delivery:
|
|
||||||
token = (registration.get("token") or "").strip()
|
|
||||||
if not token:
|
|
||||||
return Delivery(DEAD, "missing device token")
|
|
||||||
try:
|
|
||||||
headers = self.headers()
|
|
||||||
response = await client.post(
|
|
||||||
f"https://{host()}/3/device/{token}",
|
|
||||||
headers=headers,
|
|
||||||
content=prepared.encode("utf-8"),
|
|
||||||
)
|
|
||||||
except (httpx.HTTPError, ValueError) as exc:
|
|
||||||
return Delivery(REJECTED, str(exc))
|
|
||||||
if response.status_code == 200:
|
|
||||||
return Delivery(ACCEPTED)
|
|
||||||
reason = _reason(response)
|
|
||||||
detail = f"{response.status_code} {reason}".strip()
|
|
||||||
if response.status_code == 410 or reason in DEAD_REASONS:
|
|
||||||
return Delivery(DEAD, detail)
|
|
||||||
return Delivery(REJECTED, detail)
|
|
||||||
@ -1,66 +0,0 @@
|
|||||||
# retoor <retoor@molodetz.nl>
|
|
||||||
|
|
||||||
from abc import ABC, abstractmethod
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
from devplacepy.database import get_setting
|
|
||||||
from devplacepy.services.base import ConfigField
|
|
||||||
|
|
||||||
ACCEPTED = "accepted"
|
|
||||||
DEAD = "dead"
|
|
||||||
REJECTED = "rejected"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class Delivery:
|
|
||||||
status: str
|
|
||||||
detail: str = ""
|
|
||||||
|
|
||||||
|
|
||||||
class PushProvider(ABC):
|
|
||||||
name = ""
|
|
||||||
label = ""
|
|
||||||
config_fields: list[ConfigField] = []
|
|
||||||
|
|
||||||
@property
|
|
||||||
def enabled_key(self) -> str:
|
|
||||||
return f"push_{self.name}_enabled"
|
|
||||||
|
|
||||||
def enabled_field(self) -> ConfigField:
|
|
||||||
return ConfigField(
|
|
||||||
self.enabled_key,
|
|
||||||
"Enabled",
|
|
||||||
type="bool",
|
|
||||||
default=True,
|
|
||||||
help=f"Deliver notifications through {self.label}.",
|
|
||||||
group=self.label,
|
|
||||||
)
|
|
||||||
|
|
||||||
def all_fields(self) -> list[ConfigField]:
|
|
||||||
return [self.enabled_field(), *self.config_fields]
|
|
||||||
|
|
||||||
def is_enabled(self) -> bool:
|
|
||||||
return get_setting(self.enabled_key, "1") == "1"
|
|
||||||
|
|
||||||
def is_active(self) -> bool:
|
|
||||||
return self.is_enabled() and self.is_configured()
|
|
||||||
|
|
||||||
def client_config(self) -> dict[str, Any]:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def is_configured(self) -> bool: ...
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def parse_registration(self, body: dict[str, Any]) -> dict[str, Any] | None: ...
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def prepare(self, payload: dict[str, Any]) -> str: ...
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
async def deliver(
|
|
||||||
self, client: httpx.AsyncClient, registration: dict[str, Any], prepared: str
|
|
||||||
) -> Delivery: ...
|
|
||||||
@ -1,83 +0,0 @@
|
|||||||
# retoor <retoor@molodetz.nl>
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from devplacepy.database import db, get_table
|
|
||||||
from devplacepy.push.providers import DEFAULT_PROVIDER
|
|
||||||
from devplacepy.utils import generate_uid
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
TABLE = "push_registration"
|
|
||||||
|
|
||||||
|
|
||||||
def table():
|
|
||||||
return get_table(TABLE)
|
|
||||||
|
|
||||||
|
|
||||||
def provider_of(registration: dict[str, Any]) -> str:
|
|
||||||
return registration.get("provider") or DEFAULT_PROVIDER
|
|
||||||
|
|
||||||
|
|
||||||
def active_for_user(user_uid: str) -> list[dict[str, Any]]:
|
|
||||||
return list(table().find(user_uid=user_uid, deleted_at=None))
|
|
||||||
|
|
||||||
|
|
||||||
def register(
|
|
||||||
user_uid: str, provider: str, fields: dict[str, Any]
|
|
||||||
) -> tuple[dict[str, Any], bool]:
|
|
||||||
registrations = table()
|
|
||||||
existing = registrations.find_one(
|
|
||||||
user_uid=user_uid, provider=provider, deleted_at=None, **fields
|
|
||||||
)
|
|
||||||
if existing:
|
|
||||||
logger.debug("Push subscription already registered for user %s", user_uid)
|
|
||||||
return existing, False
|
|
||||||
|
|
||||||
record = {
|
|
||||||
"uid": generate_uid(),
|
|
||||||
"user_uid": user_uid,
|
|
||||||
"provider": provider,
|
|
||||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
||||||
"deleted_at": None,
|
|
||||||
**fields,
|
|
||||||
}
|
|
||||||
registrations.insert(record)
|
|
||||||
logger.info("Registered %s push subscription for user %s", provider, user_uid)
|
|
||||||
return record, True
|
|
||||||
|
|
||||||
|
|
||||||
def mark_dead(registration_id: int) -> None:
|
|
||||||
table().update(
|
|
||||||
{"id": registration_id, "deleted_at": datetime.now(timezone.utc).isoformat()},
|
|
||||||
["id"],
|
|
||||||
)
|
|
||||||
logger.info("Soft-deleted dead push subscription id=%s", registration_id)
|
|
||||||
|
|
||||||
|
|
||||||
def prune(cutoff: str) -> int:
|
|
||||||
if TABLE not in db.tables:
|
|
||||||
return 0
|
|
||||||
rows = list(table().find(deleted_at={"<": cutoff}))
|
|
||||||
if not rows:
|
|
||||||
return 0
|
|
||||||
table().delete(deleted_at={"<": cutoff})
|
|
||||||
return len(rows)
|
|
||||||
|
|
||||||
|
|
||||||
def counts() -> dict[str, int]:
|
|
||||||
if TABLE not in db.tables:
|
|
||||||
return {}
|
|
||||||
totals: dict[str, int] = {"dead": 0}
|
|
||||||
for row in db.query(
|
|
||||||
f"SELECT provider AS provider, deleted_at IS NULL AS live, COUNT(*) AS total "
|
|
||||||
f"FROM {TABLE} GROUP BY provider, deleted_at IS NULL"
|
|
||||||
):
|
|
||||||
provider = row["provider"] or DEFAULT_PROVIDER
|
|
||||||
if row["live"]:
|
|
||||||
totals[provider] = totals.get(provider, 0) + int(row["total"])
|
|
||||||
else:
|
|
||||||
totals["dead"] += int(row["total"])
|
|
||||||
return totals
|
|
||||||
@ -45,7 +45,7 @@ Prefixes are wired in `main.py`:
|
|||||||
| `/dbapi` | dbapi/ package - **primary-administrator-only, strictly READ-ONLY** generic database API over `dataset` (never inserts/updates/deletes/restores; the write surface was removed because it bypassed every per-route admin safeguard). `tables.py` (`GET /tables`, `GET /{table}/schema`), `crud.py` (read only: `GET /{table}` + `GET /{table}/{key}/{value}`, `?include_deleted`), `query.py` (`POST /query` hard SELECT-only validated read-only run; `POST /query/async` + `GET /query/{uid}` + `WS /query/{uid}/ws` via `DbApiJobService` kind `dbquery`), `nl.py` (`POST /nl` natural-language->SQL via the AI gateway, returns validated SELECT, `execute=true` runs it read-only). **Auth = the PRIMARY administrator only** (the earliest-created Admin, `utils.is_primary_admin` / `database.get_primary_admin_uid` - the same identity that gates backup downloads) by session/api_key (`services/dbapi/policy.py`); every other administrator is refused like a member, and there is **no internal-key path** - the gateway `internal_gateway_key()` is NOT accepted (no service-to-service access). SQL validated by `services/dbapi/validate.py` (sqlglot classify + suspicious-flag + read-only `EXPLAIN` dry-run). Devii tools `db_*` are read-only (list/get/query/nl; no write tools) and flagged `requires_primary_admin=True`, so they are added to the LLM tool list ONLY for the primary administrator (every other session never sees them and Devii is unaware they exist). `services/pubsub/policy.py` resolves its own admin/internal actor and does NOT reuse `dbapi.policy.caller_for`, so the primary-admin restriction does not leak onto the pub/sub bus. See `devplacepy/services/dbapi/CLAUDE.md` |
|
| `/dbapi` | dbapi/ package - **primary-administrator-only, strictly READ-ONLY** generic database API over `dataset` (never inserts/updates/deletes/restores; the write surface was removed because it bypassed every per-route admin safeguard). `tables.py` (`GET /tables`, `GET /{table}/schema`), `crud.py` (read only: `GET /{table}` + `GET /{table}/{key}/{value}`, `?include_deleted`), `query.py` (`POST /query` hard SELECT-only validated read-only run; `POST /query/async` + `GET /query/{uid}` + `WS /query/{uid}/ws` via `DbApiJobService` kind `dbquery`), `nl.py` (`POST /nl` natural-language->SQL via the AI gateway, returns validated SELECT, `execute=true` runs it read-only). **Auth = the PRIMARY administrator only** (the earliest-created Admin, `utils.is_primary_admin` / `database.get_primary_admin_uid` - the same identity that gates backup downloads) by session/api_key (`services/dbapi/policy.py`); every other administrator is refused like a member, and there is **no internal-key path** - the gateway `internal_gateway_key()` is NOT accepted (no service-to-service access). SQL validated by `services/dbapi/validate.py` (sqlglot classify + suspicious-flag + read-only `EXPLAIN` dry-run). Devii tools `db_*` are read-only (list/get/query/nl; no write tools) and flagged `requires_primary_admin=True`, so they are added to the LLM tool list ONLY for the primary administrator (every other session never sees them and Devii is unaware they exist). `services/pubsub/policy.py` resolves its own admin/internal actor and does NOT reuse `dbapi.policy.caller_for`, so the primary-admin restriction does not leak onto the pub/sub bus. See `devplacepy/services/dbapi/CLAUDE.md` |
|
||||||
| `/quizzes` | quizzes/ package - **Quizzes**: `index.py` (hub, create, import, detail, export, edit, delete, publish, per-quiz leaderboard, cross-quiz scoreboard), `questions.py` (builder page + question CRUD + reorder), `attempts.py` (start/resume, play, answer, finish, results). The hub `GET /quizzes` is the approved three-column `.feed-layout` reused verbatim from `/feed`: filters + search left (`?filter=all|todo|done|mine|drafts`, `?search=`, `?page=`), the quiz list centre with the **New quiz** and **Create quiz with Devii** actions, and the cross-quiz **scoreboard** rail right (score per user, best attempt per quiz, 15s display cache). Publishing is terminal - every write on a published quiz is a 400 and there is no unpublish route. See `devplacepy/services/quiz/CLAUDE.md` |
|
| `/quizzes` | quizzes/ package - **Quizzes**: `index.py` (hub, create, import, detail, export, edit, delete, publish, per-quiz leaderboard, cross-quiz scoreboard), `questions.py` (builder page + question CRUD + reorder), `attempts.py` (start/resume, play, answer, finish, results). The hub `GET /quizzes` is the approved three-column `.feed-layout` reused verbatim from `/feed`: filters + search left (`?filter=all|todo|done|mine|drafts`, `?search=`, `?page=`), the quiz list centre with the **New quiz** and **Create quiz with Devii** actions, and the cross-quiz **scoreboard** rail right (score per user, best attempt per quiz, 15s display cache). Publishing is terminal - every write on a published quiz is a 400 and there is no unpublish route. See `devplacepy/services/quiz/CLAUDE.md` |
|
||||||
| `/game` | game/ package - the **Code Farm** idle game (`index.py` + `farm.py`): `GET /game` (page), `GET /game/state`, `GET /game/leaderboard?board=`, `POST /game/{plant,harvest,buy-plot,upgrade,fertilize,daily,grant,perk,prestige,legacy,mastery,quests/claim}`, `POST /game/{defense/upgrade,defense/downgrade,infrastructure/buy,cosmetics/buy,cosmetics/equip}`, plus social `GET /game/farm/{username}` and `POST /game/farm/{username}/{water,steal}`. See `devplacepy/services/game/CLAUDE.md` |
|
| `/game` | game/ package - the **Code Farm** idle game (`index.py` + `farm.py`): `GET /game` (page), `GET /game/state`, `GET /game/leaderboard?board=`, `POST /game/{plant,harvest,buy-plot,upgrade,fertilize,daily,grant,perk,prestige,legacy,mastery,quests/claim}`, `POST /game/{defense/upgrade,defense/downgrade,infrastructure/buy,cosmetics/buy,cosmetics/equip}`, plus social `GET /game/farm/{username}` and `POST /game/farm/{username}/{water,steal}`. See `devplacepy/services/game/CLAUDE.md` |
|
||||||
| (none) | push.py - push + PWA: `GET /push.json` (VAPID public key + the providers accepting registrations), `POST /push.json` (register with any active provider; a body without `provider` is a `webpush` body, so existing clients are unchanged; an unknown, disabled or unconfigured provider is a 400), `GET /service-worker.js`, `GET /manifest.json`. Provider protocol and delivery live in `devplacepy/push/` - see `devplacepy/push/CLAUDE.md` |
|
| (none) | push.py - web push + PWA: `GET`/`POST /push.json` (VAPID public key / subscription register), `GET /service-worker.js`, `GET /manifest.json` |
|
||||||
| (none) | docs.py (docs/ package) - the documentation site (prose pages + API reference). See `routers/docs/CLAUDE.md` for the deep detail on this tree (`DOCS_PAGES`, audience tiers, prose rendering pipeline) |
|
| (none) | docs.py (docs/ package) - the documentation site (prose pages + API reference). See `routers/docs/CLAUDE.md` for the deep detail on this tree (`DOCS_PAGES`, audience tiers, prose rendering pipeline) |
|
||||||
| `/pubsub` | pubsub.py - **database-free** publish/subscribe bus. `WS /pubsub/ws` (subscribe/unsubscribe/publish frames, `foo.*` wildcards), `POST /pubsub/publish` + `GET /pubsub/topics` (admin/internal). Lock-owner-gated WS (`4013` retry) so subscribers converge on one worker; topic authz in `services/pubsub/policy.py` (`user.{uid}.*` private, `public.*` shared, admin/internal anywhere, guests opt-in). In-process `services.pubsub.publish(topic, data)` for backends; frontend `app.pubsub`. See `devplacepy/services/pubsub/CLAUDE.md` |
|
| `/pubsub` | pubsub.py - **database-free** publish/subscribe bus. `WS /pubsub/ws` (subscribe/unsubscribe/publish frames, `foo.*` wildcards), `POST /pubsub/publish` + `GET /pubsub/topics` (admin/internal). Lock-owner-gated WS (`4013` retry) so subscribers converge on one worker; topic authz in `services/pubsub/policy.py` (`user.{uid}.*` private, `public.*` shared, admin/internal anywhere, guests opt-in). In-process `services.pubsub.publish(topic, data)` for backends; frontend `app.pubsub`. See `devplacepy/services/pubsub/CLAUDE.md` |
|
||||||
| (none) | seo.py - `/robots.txt`, `/sitemap.xml` |
|
| (none) | seo.py - `/robots.txt`, `/sitemap.xml` |
|
||||||
|
|||||||
@ -6,7 +6,6 @@ from devplacepy.utils import require_admin
|
|||||||
from devplacepy.responses import action_result
|
from devplacepy.responses import action_result
|
||||||
from devplacepy.services.audit import record as audit
|
from devplacepy.services.audit import record as audit
|
||||||
from devplacepy.services.manager import service_manager
|
from devplacepy.services.manager import service_manager
|
||||||
from devplacepy.services.openai_gateway import quota
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@ -35,20 +34,14 @@ async def admin_reset_all_ai_quota(request: Request):
|
|||||||
admin = require_admin(request)
|
admin = require_admin(request)
|
||||||
devii = service_manager.get_service("devii")
|
devii = service_manager.get_service("devii")
|
||||||
removed = devii.reset_all_quotas() if devii is not None else 0
|
removed = devii.reset_all_quotas() if devii is not None else 0
|
||||||
gateway = quota.reset(created_by=admin["uid"])
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Admin {admin['username']} reset ALL AI quotas "
|
f"Admin {admin['username']} reset ALL AI quotas ({removed} ledger rows)"
|
||||||
f"({removed} Devii ledger rows, gateway watermark {gateway['reset_at']})"
|
|
||||||
)
|
)
|
||||||
audit.record(
|
audit.record(
|
||||||
request,
|
request,
|
||||||
"admin.ai_quota.reset_all",
|
"admin.ai_quota.reset_all",
|
||||||
user=admin,
|
user=admin,
|
||||||
metadata={"rows_removed": removed, "gateway_reset_at": gateway["reset_at"]},
|
metadata={"rows_removed": removed},
|
||||||
summary=(
|
summary=f"admin {admin['username']} reset all AI quotas",
|
||||||
f"admin {admin['username']} reset all AI quotas "
|
|
||||||
"(Devii assistant and AI gateway)"
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
return action_result(request, "/admin/ai-usage")
|
return action_result(request, "/admin/ai-usage")
|
||||||
|
|
||||||
|
|||||||
@ -197,7 +197,14 @@ def _quota_defaults_summary() -> dict:
|
|||||||
|
|
||||||
|
|
||||||
def _rule_label(rule: dict) -> str:
|
def _rule_label(rule: dict) -> str:
|
||||||
return quota.scope_label(rule, fallback=rule.get("uid", ""))
|
parts = []
|
||||||
|
if rule.get("owner_kind"):
|
||||||
|
parts.append(f"role={rule['owner_kind']}")
|
||||||
|
if rule.get("owner_id"):
|
||||||
|
parts.append(f"user={rule['owner_id']}")
|
||||||
|
if rule.get("app_reference"):
|
||||||
|
parts.append(f"app={rule['app_reference']}")
|
||||||
|
return ", ".join(parts) or rule.get("uid", "")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/gateway/quota-rules")
|
@router.get("/gateway/quota-rules")
|
||||||
@ -246,34 +253,6 @@ async def save_quota_rule(request: Request):
|
|||||||
return JSONResponse({"ok": True, "rule": saved})
|
return JSONResponse({"ok": True, "rule": saved})
|
||||||
|
|
||||||
|
|
||||||
@router.post("/gateway/quota-resets")
|
|
||||||
async def reset_quota_spend(request: Request):
|
|
||||||
admin = require_admin(request)
|
|
||||||
body = await _payload(request)
|
|
||||||
try:
|
|
||||||
payload = quota.QuotaResetIn(**body)
|
|
||||||
except ValidationError as exc:
|
|
||||||
return _validation_error(exc)
|
|
||||||
scope = quota.reset(payload, created_by=admin["uid"])
|
|
||||||
label = quota.scope_label(scope, fallback="every caller")
|
|
||||||
audit.record(
|
|
||||||
request,
|
|
||||||
"gateway.quota.reset",
|
|
||||||
user=admin,
|
|
||||||
target_type="gateway_quota",
|
|
||||||
target_uid=scope["uid"],
|
|
||||||
target_label=label,
|
|
||||||
summary=f"admin {admin['username']} reset the gateway 24h spend for {label}",
|
|
||||||
metadata={
|
|
||||||
"owner_kind": scope["owner_kind"],
|
|
||||||
"owner_id": scope["owner_id"],
|
|
||||||
"app_reference": scope["app_reference"],
|
|
||||||
"reset_at": scope["reset_at"],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return JSONResponse({"ok": True, "reset": scope})
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/gateway/quota-rules/{uid}")
|
@router.delete("/gateway/quota-rules/{uid}")
|
||||||
async def delete_quota_rule(request: Request, uid: str):
|
async def delete_quota_rule(request: Request, uid: str):
|
||||||
admin = require_admin(request)
|
admin = require_admin(request)
|
||||||
|
|||||||
@ -87,7 +87,6 @@ async def create_rant(request: Request):
|
|||||||
if len(text) > 125000:
|
if len(text) > 125000:
|
||||||
return dr_error("Your rant is too long.")
|
return dr_error("Your rant is too long.")
|
||||||
tags = _parse_tags(params.get("tags"))
|
tags = _parse_tags(params.get("tags"))
|
||||||
project_uid = params.get("project_uid") or None
|
|
||||||
uid, slug = create_content_item(
|
uid, slug = create_content_item(
|
||||||
"posts",
|
"posts",
|
||||||
"post",
|
"post",
|
||||||
@ -96,7 +95,7 @@ async def create_rant(request: Request):
|
|||||||
"title": None,
|
"title": None,
|
||||||
"content": text,
|
"content": text,
|
||||||
"topic": "rant",
|
"topic": "rant",
|
||||||
"project_uid": project_uid,
|
"project_uid": None,
|
||||||
"image": None,
|
"image": None,
|
||||||
"tags": encode_tags(tags),
|
"tags": encode_tags(tags),
|
||||||
},
|
},
|
||||||
|
|||||||
@ -7,6 +7,7 @@ from devplacepy.models import ProfileForm
|
|||||||
from fastapi.responses import HTMLResponse, JSONResponse
|
from fastapi.responses import HTMLResponse, JSONResponse
|
||||||
from devplacepy.database import (
|
from devplacepy.database import (
|
||||||
get_table,
|
get_table,
|
||||||
|
db,
|
||||||
get_customization_prefs,
|
get_customization_prefs,
|
||||||
get_notification_prefs,
|
get_notification_prefs,
|
||||||
get_user_stars,
|
get_user_stars,
|
||||||
@ -36,7 +37,6 @@ from devplacepy.database.awards import (
|
|||||||
from devplacepy.content import can_view_project, enrich_items
|
from devplacepy.content import can_view_project, enrich_items
|
||||||
from devplacepy.utils import (
|
from devplacepy.utils import (
|
||||||
get_current_user,
|
get_current_user,
|
||||||
get_badge,
|
|
||||||
require_user,
|
require_user,
|
||||||
require_user_api,
|
require_user_api,
|
||||||
time_ago,
|
time_ago,
|
||||||
@ -46,7 +46,6 @@ from devplacepy.utils import (
|
|||||||
track_action,
|
track_action,
|
||||||
build_achievements,
|
build_achievements,
|
||||||
)
|
)
|
||||||
from devplacepy.utils.rewards import LEVEL_XP
|
|
||||||
from devplacepy.responses import respond, action_result, wants_json
|
from devplacepy.responses import respond, action_result, wants_json
|
||||||
from devplacepy.schemas import ProfileOut
|
from devplacepy.schemas import ProfileOut
|
||||||
from devplacepy.avatar import avatar_url, avatar_seed
|
from devplacepy.avatar import avatar_url, avatar_seed
|
||||||
@ -140,12 +139,6 @@ async def profile_page(
|
|||||||
current_user["uid"], f"/profile/{profile_user['username']}"
|
current_user["uid"], f"/profile/{profile_user['username']}"
|
||||||
)
|
)
|
||||||
profile_user["stars"] = get_user_stars(profile_user["uid"])
|
profile_user["stars"] = get_user_stars(profile_user["uid"])
|
||||||
xp_raw = profile_user.get("xp") or 0
|
|
||||||
level_raw = profile_user.get("level") or 1
|
|
||||||
xp_progress_pct = xp_raw % LEVEL_XP
|
|
||||||
xp_next_level = level_raw * LEVEL_XP
|
|
||||||
profile_user["xp_progress_pct"] = xp_progress_pct
|
|
||||||
profile_user["xp_next_level"] = xp_next_level
|
|
||||||
rank = get_user_rank(profile_user["uid"])
|
rank = get_user_rank(profile_user["uid"])
|
||||||
follow_counts = get_follow_counts(profile_user["uid"])
|
follow_counts = get_follow_counts(profile_user["uid"])
|
||||||
|
|
||||||
@ -202,8 +195,6 @@ async def profile_page(
|
|||||||
item["poll"] = polls_map.get(uid)
|
item["poll"] = polls_map.get(uid)
|
||||||
|
|
||||||
badges = list(get_table("badges").find(user_uid=profile_user["uid"]))
|
badges = list(get_table("badges").find(user_uid=profile_user["uid"]))
|
||||||
for b in badges:
|
|
||||||
b["icon"] = get_badge(b["badge_name"]).get("icon")
|
|
||||||
achievements = build_achievements({b["badge_name"] for b in badges})
|
achievements = build_achievements({b["badge_name"] for b in badges})
|
||||||
badge_total = sum(group["total"] for group in achievements)
|
badge_total = sum(group["total"] for group in achievements)
|
||||||
badge_earned = sum(group["earned"] for group in achievements)
|
badge_earned = sum(group["earned"] for group in achievements)
|
||||||
@ -449,8 +440,6 @@ async def profile_page(
|
|||||||
"awards_count": awards_count,
|
"awards_count": awards_count,
|
||||||
"prominent_award": prominent_award,
|
"prominent_award": prominent_award,
|
||||||
"can_give_award": can_give,
|
"can_give_award": can_give,
|
||||||
"xp_next_level": xp_next_level,
|
|
||||||
"xp_progress_pct": xp_progress_pct,
|
|
||||||
},
|
},
|
||||||
model=ProfileOut,
|
model=ProfileOut,
|
||||||
)
|
)
|
||||||
@ -510,7 +499,3 @@ async def regenerate_api_key(request: Request):
|
|||||||
links=[audit.target("user", user["uid"], user["username"])],
|
links=[audit.target("user", user["uid"], user["username"])],
|
||||||
)
|
)
|
||||||
return JSONResponse({"api_key": new_key})
|
return JSONResponse({"api_key": new_key})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -6,7 +6,6 @@ from sqlalchemy import or_
|
|||||||
from fastapi import Depends, APIRouter, Request
|
from fastapi import Depends, APIRouter, Request
|
||||||
from devplacepy.models import ProjectForm, ProjectEditForm, ProjectFlagForm, ForkForm
|
from devplacepy.models import ProjectForm, ProjectEditForm, ProjectFlagForm, ForkForm
|
||||||
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
|
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
|
||||||
from devplacepy.attachments import get_attachments_batch
|
|
||||||
from devplacepy.database import (
|
from devplacepy.database import (
|
||||||
get_table,
|
get_table,
|
||||||
get_users_by_uids,
|
get_users_by_uids,
|
||||||
@ -14,9 +13,6 @@ from devplacepy.database import (
|
|||||||
get_site_stats,
|
get_site_stats,
|
||||||
get_user_votes,
|
get_user_votes,
|
||||||
get_recent_comments_by_target_uids,
|
get_recent_comments_by_target_uids,
|
||||||
get_reactions_by_targets,
|
|
||||||
get_user_bookmarks,
|
|
||||||
get_polls_by_post_uids,
|
|
||||||
paginate,
|
paginate,
|
||||||
text_search_clause,
|
text_search_clause,
|
||||||
resolve_by_slug,
|
resolve_by_slug,
|
||||||
@ -39,7 +35,6 @@ from devplacepy.content import (
|
|||||||
is_owner,
|
is_owner,
|
||||||
can_view_project,
|
can_view_project,
|
||||||
can_view_project_containers,
|
can_view_project_containers,
|
||||||
get_project_devlog,
|
|
||||||
)
|
)
|
||||||
from devplacepy.utils import (
|
from devplacepy.utils import (
|
||||||
get_current_user,
|
get_current_user,
|
||||||
@ -176,7 +171,7 @@ async def projects_page(
|
|||||||
)
|
)
|
||||||
|
|
||||||
@router.get("/{project_slug}", response_class=HTMLResponse)
|
@router.get("/{project_slug}", response_class=HTMLResponse)
|
||||||
async def project_detail(request: Request, project_slug: str, before: str = None):
|
async def project_detail(request: Request, project_slug: str):
|
||||||
user = get_current_user(request)
|
user = get_current_user(request)
|
||||||
detail = load_detail("projects", "project", project_slug, user)
|
detail = load_detail("projects", "project", project_slug, user)
|
||||||
if not detail:
|
if not detail:
|
||||||
@ -221,25 +216,6 @@ async def project_detail(request: Request, project_slug: str, before: str = None
|
|||||||
if parent
|
if parent
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
|
|
||||||
devlog_posts, devlog_next_cursor = get_project_devlog(
|
|
||||||
project["uid"], before=before, viewer=user
|
|
||||||
)
|
|
||||||
if devlog_posts:
|
|
||||||
post_uids = [item["post"]["uid"] for item in devlog_posts]
|
|
||||||
attachments_map = get_attachments_batch("post", post_uids)
|
|
||||||
reactions_map = get_reactions_by_targets("post", post_uids, user)
|
|
||||||
bookmark_set = (
|
|
||||||
get_user_bookmarks(user["uid"], "post", post_uids) if user else set()
|
|
||||||
)
|
|
||||||
polls_map = get_polls_by_post_uids(post_uids, user)
|
|
||||||
for item in devlog_posts:
|
|
||||||
uid = item["post"]["uid"]
|
|
||||||
item["attachments"] = attachments_map.get(uid, [])
|
|
||||||
item["reactions"] = reactions_map.get(uid, {"counts": {}, "mine": []})
|
|
||||||
item["bookmarked"] = uid in bookmark_set
|
|
||||||
item["poll"] = polls_map.get(uid)
|
|
||||||
|
|
||||||
return respond(
|
return respond(
|
||||||
request,
|
request,
|
||||||
"project_detail.html",
|
"project_detail.html",
|
||||||
@ -259,8 +235,6 @@ async def project_detail(request: Request, project_slug: str, before: str = None
|
|||||||
"forked_from": forked_from,
|
"forked_from": forked_from,
|
||||||
"fork_count": count_forks(project["uid"]),
|
"fork_count": count_forks(project["uid"]),
|
||||||
"file_count": count_files(project["uid"]),
|
"file_count": count_files(project["uid"]),
|
||||||
"devlog_posts": devlog_posts,
|
|
||||||
"devlog_next_cursor": devlog_next_cursor,
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
model=ProjectDetailOut,
|
model=ProjectDetailOut,
|
||||||
@ -490,5 +464,3 @@ async def set_project_readonly(
|
|||||||
request: Request, project_slug: str, data: Annotated[ProjectFlagForm, Depends(json_or_form(ProjectFlagForm))]
|
request: Request, project_slug: str, data: Annotated[ProjectFlagForm, Depends(json_or_form(ProjectFlagForm))]
|
||||||
):
|
):
|
||||||
return _set_project_flag(request, project_slug, "read_only", data.value)
|
return _set_project_flag(request, project_slug, "read_only", data.value)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -5,7 +5,6 @@ from fastapi import APIRouter, Request
|
|||||||
from fastapi.responses import FileResponse, JSONResponse
|
from fastapi.responses import FileResponse, JSONResponse
|
||||||
from devplacepy import push
|
from devplacepy import push
|
||||||
from devplacepy.config import STATIC_DIR
|
from devplacepy.config import STATIC_DIR
|
||||||
from devplacepy.push import providers
|
|
||||||
from devplacepy.utils import require_user_api
|
from devplacepy.utils import require_user_api
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
from devplacepy.services.audit import record as audit
|
from devplacepy.services.audit import record as audit
|
||||||
@ -23,11 +22,7 @@ WELCOME_PAYLOAD = {
|
|||||||
|
|
||||||
@router.get("/push.json")
|
@router.get("/push.json")
|
||||||
async def push_public_key() -> JSONResponse:
|
async def push_public_key() -> JSONResponse:
|
||||||
configs = providers.client_config()
|
return JSONResponse({"publicKey": push.public_key_standard_b64()})
|
||||||
webpush = configs.get(providers.DEFAULT_PROVIDER, {})
|
|
||||||
return JSONResponse(
|
|
||||||
{"publicKey": webpush.get("publicKey", ""), "providers": configs}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/push.json")
|
@router.post("/push.json")
|
||||||
@ -38,18 +33,21 @@ async def push_register(request: Request) -> JSONResponse:
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
return JSONResponse({"error": "Invalid JSON"}, status_code=400)
|
return JSONResponse({"error": "Invalid JSON"}, status_code=400)
|
||||||
|
|
||||||
if not isinstance(body, dict):
|
keys = body.get("keys") if isinstance(body, dict) else None
|
||||||
|
if not (
|
||||||
|
isinstance(keys, dict)
|
||||||
|
and body.get("endpoint")
|
||||||
|
and keys.get("p256dh")
|
||||||
|
and keys.get("auth")
|
||||||
|
):
|
||||||
return JSONResponse({"error": "Invalid request"}, status_code=400)
|
return JSONResponse({"error": "Invalid request"}, status_code=400)
|
||||||
|
|
||||||
provider = providers.get(body.get("provider"))
|
_, created = await push.register(
|
||||||
if provider is None or not providers.is_active(provider):
|
user_uid=user["uid"],
|
||||||
return JSONResponse({"error": "Unknown provider"}, status_code=400)
|
endpoint=body["endpoint"],
|
||||||
|
key_auth=keys["auth"],
|
||||||
fields = provider.parse_registration(body)
|
key_p256dh=keys["p256dh"],
|
||||||
if fields is None:
|
)
|
||||||
return JSONResponse({"error": "Invalid request"}, status_code=400)
|
|
||||||
|
|
||||||
_, created = push.register(user["uid"], provider.name, fields)
|
|
||||||
|
|
||||||
if created:
|
if created:
|
||||||
try:
|
try:
|
||||||
@ -64,13 +62,7 @@ async def push_register(request: Request) -> JSONResponse:
|
|||||||
target_type="user",
|
target_type="user",
|
||||||
target_uid=user["uid"],
|
target_uid=user["uid"],
|
||||||
target_label=user.get("username"),
|
target_label=user.get("username"),
|
||||||
metadata={
|
metadata={"endpoint_host": urlparse(body["endpoint"]).hostname, "created": created},
|
||||||
"provider": provider.name,
|
|
||||||
"endpoint_host": urlparse(fields["endpoint"]).hostname
|
|
||||||
if fields.get("endpoint")
|
|
||||||
else None,
|
|
||||||
"created": created,
|
|
||||||
},
|
|
||||||
summary=f"{user.get('username')} {'registered' if created else 'updated'} a push subscription",
|
summary=f"{user.get('username')} {'registered' if created else 'updated'} a push subscription",
|
||||||
links=[audit.target("user", user["uid"], user.get("username"))],
|
links=[audit.target("user", user["uid"], user.get("username"))],
|
||||||
)
|
)
|
||||||
|
|||||||
@ -4,8 +4,6 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
from pydantic import ConfigDict, Field
|
|
||||||
|
|
||||||
from devplacepy.schemas.base import _Out
|
from devplacepy.schemas.base import _Out
|
||||||
|
|
||||||
|
|
||||||
@ -19,8 +17,6 @@ class UserOut(_Out):
|
|||||||
website: Optional[str] = None
|
website: Optional[str] = None
|
||||||
level: Optional[int] = None
|
level: Optional[int] = None
|
||||||
xp: Optional[int] = None
|
xp: Optional[int] = None
|
||||||
xp_progress_pct: Optional[int] = None
|
|
||||||
xp_next_level: Optional[int] = None
|
|
||||||
stars: Optional[int] = None
|
stars: Optional[int] = None
|
||||||
created_at: Optional[str] = None
|
created_at: Optional[str] = None
|
||||||
last_seen: Optional[str] = None
|
last_seen: Optional[str] = None
|
||||||
@ -66,17 +62,8 @@ class PollOut(_Out):
|
|||||||
|
|
||||||
|
|
||||||
class BadgeOut(_Out):
|
class BadgeOut(_Out):
|
||||||
name: Optional[str] = Field(None, alias="badge_name")
|
|
||||||
icon: Optional[str] = None
|
|
||||||
created_at: Optional[str] = None
|
|
||||||
model_config = ConfigDict(populate_by_name=True)
|
|
||||||
|
|
||||||
|
|
||||||
class ProjectLinkOut(_Out):
|
|
||||||
uid: str = ""
|
|
||||||
name: Optional[str] = None
|
name: Optional[str] = None
|
||||||
slug: Optional[str] = None
|
created_at: Optional[str] = None
|
||||||
url: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
class PostOut(_Out):
|
class PostOut(_Out):
|
||||||
@ -88,7 +75,6 @@ class PostOut(_Out):
|
|||||||
topic: Optional[str] = None
|
topic: Optional[str] = None
|
||||||
stars: Optional[int] = None
|
stars: Optional[int] = None
|
||||||
image: Optional[str] = None
|
image: Optional[str] = None
|
||||||
project_uid: Optional[str] = None
|
|
||||||
created_at: Optional[str] = None
|
created_at: Optional[str] = None
|
||||||
updated_at: Optional[str] = None
|
updated_at: Optional[str] = None
|
||||||
|
|
||||||
@ -216,4 +202,3 @@ class MessageOut(_Out):
|
|||||||
|
|
||||||
|
|
||||||
CommentItemOut.model_rebuild()
|
CommentItemOut.model_rebuild()
|
||||||
|
|
||||||
|
|||||||
@ -14,7 +14,6 @@ from devplacepy.schemas.content import (
|
|||||||
NotificationOut,
|
NotificationOut,
|
||||||
PollOut,
|
PollOut,
|
||||||
PostOut,
|
PostOut,
|
||||||
ProjectLinkOut,
|
|
||||||
ProjectOut,
|
ProjectOut,
|
||||||
ReactionsOut,
|
ReactionsOut,
|
||||||
UserOut,
|
UserOut,
|
||||||
@ -32,7 +31,6 @@ class FeedItemOut(_Out):
|
|||||||
reactions: ReactionsOut = ReactionsOut()
|
reactions: ReactionsOut = ReactionsOut()
|
||||||
bookmarked: bool = False
|
bookmarked: bool = False
|
||||||
poll: Optional[PollOut] = None
|
poll: Optional[PollOut] = None
|
||||||
project_link: Optional[ProjectLinkOut] = None
|
|
||||||
|
|
||||||
|
|
||||||
class GistItemOut(_Out):
|
class GistItemOut(_Out):
|
||||||
@ -134,7 +132,6 @@ class PostDetailOut(_Out):
|
|||||||
comment_count: Optional[int] = None
|
comment_count: Optional[int] = None
|
||||||
related_posts: list[FeedItemOut] = []
|
related_posts: list[FeedItemOut] = []
|
||||||
topics: list[str] = []
|
topics: list[str] = []
|
||||||
project_link: Optional[ProjectLinkOut] = None
|
|
||||||
|
|
||||||
|
|
||||||
class ProjectsOut(_Out):
|
class ProjectsOut(_Out):
|
||||||
@ -166,8 +163,6 @@ class ProjectDetailOut(_Out):
|
|||||||
forked_from: Optional[dict] = None
|
forked_from: Optional[dict] = None
|
||||||
fork_count: int = 0
|
fork_count: int = 0
|
||||||
file_count: int = 0
|
file_count: int = 0
|
||||||
devlog_posts: list[FeedItemOut] = []
|
|
||||||
devlog_next_cursor: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
class GistsOut(_Out):
|
class GistsOut(_Out):
|
||||||
@ -237,4 +232,3 @@ class LeaderboardOut(_Out):
|
|||||||
class SavedOut(_Out):
|
class SavedOut(_Out):
|
||||||
items: list[SavedItemOut] = []
|
items: list[SavedItemOut] = []
|
||||||
next_cursor: Optional[str] = None
|
next_cursor: Optional[str] = None
|
||||||
|
|
||||||
|
|||||||
@ -72,8 +72,6 @@ class ProfileOut(_Out):
|
|||||||
followers_count: Optional[int] = None
|
followers_count: Optional[int] = None
|
||||||
following_count: Optional[int] = None
|
following_count: Optional[int] = None
|
||||||
viewer_is_admin: bool = False
|
viewer_is_admin: bool = False
|
||||||
xp_next_level: int = 0
|
|
||||||
xp_progress_pct: int = 0
|
|
||||||
media: list[MediaItemOut] = []
|
media: list[MediaItemOut] = []
|
||||||
media_pagination: Optional[Any] = None
|
media_pagination: Optional[Any] = None
|
||||||
notification_prefs: list[Any] = []
|
notification_prefs: list[Any] = []
|
||||||
@ -90,4 +88,3 @@ class TelegramPairOut(_Out):
|
|||||||
code: Optional[str] = None
|
code: Optional[str] = None
|
||||||
expires_at: Optional[str] = None
|
expires_at: Optional[str] = None
|
||||||
ttl_minutes: Optional[int] = None
|
ttl_minutes: Optional[int] = None
|
||||||
|
|
||||||
|
|||||||
@ -51,7 +51,7 @@ Generic, lightweight, **fire-and-forget** offload for non-critical side-effects
|
|||||||
|
|
||||||
`devplacepy/services/` provides a generic framework for running background async services alongside the FastAPI server. `BaseService` provides the async run loop, a `deque(maxlen=20)` log buffer, and graceful cancellation; `ServiceManager` is a singleton that registers, starts, and stops services. Services are fully managed from the **Services admin tab** (`/admin/services`): start/stop, enable-on-boot, run-now, edit parameters, clear logs, adjustable log buffer size, with live status. All of this is generic - a new service gets it for free by declaring its config and implementing `run_once`.
|
`devplacepy/services/` provides a generic framework for running background async services alongside the FastAPI server. `BaseService` provides the async run loop, a `deque(maxlen=20)` log buffer, and graceful cancellation; `ServiceManager` is a singleton that registers, starts, and stops services. Services are fully managed from the **Services admin tab** (`/admin/services`): start/stop, enable-on-boot, run-now, edit parameters, clear logs, adjustable log buffer size, with live status. All of this is generic - a new service gets it for free by declaring its config and implementing `run_once`.
|
||||||
|
|
||||||
This section covers only the shared machinery. The individual services built on top of it live in their own subdirectories with their own nested CLAUDE.md: `NewsService` (`services/news/`, see `devplacepy/services/news/CLAUDE.md`), `GatewayService` and provider/model routing (`services/openai_gateway/`, see `devplacepy/services/openai_gateway/CLAUDE.md`), `DeviiService` (`services/devii/`, see `devplacepy/services/devii/CLAUDE.md`), and the bot fleet service (`services/bot/`, see `devplacepy/services/bot/CLAUDE.md`). `PushService` (`services/push/`) is the thinnest example of the pattern: it owns no loop work beyond pruning dead subscriptions, and exists mainly so every push provider's configuration is edited through the same `ConfigField` surface as every other subsystem - its `config_fields` are assembled from `devplacepy.push.providers.admin_fields()`, so a new provider appears at `/admin/services/push` with no edit to the service. Push delivery does NOT depend on that service running (see `devplacepy/push/CLAUDE.md`).
|
This section covers only the shared machinery. The individual services built on top of it live in their own subdirectories with their own nested CLAUDE.md: `NewsService` (`services/news/`, see `devplacepy/services/news/CLAUDE.md`), `GatewayService` and provider/model routing (`services/openai_gateway/`, see `devplacepy/services/openai_gateway/CLAUDE.md`), `DeviiService` (`services/devii/`, see `devplacepy/services/devii/CLAUDE.md`), and the bot fleet service (`services/bot/`, see `devplacepy/services/bot/CLAUDE.md`).
|
||||||
|
|
||||||
### DB-backed state (correct across workers)
|
### DB-backed state (correct across workers)
|
||||||
|
|
||||||
|
|||||||
@ -161,30 +161,6 @@ GATEWAY_ACTIONS: tuple[Action, ...] = (
|
|||||||
body("label", "Optional admin-facing note describing what this rule is for."),
|
body("label", "Optional admin-facing note describing what this rule is for."),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Action(
|
|
||||||
name="gateway_quota_reset",
|
|
||||||
method="POST",
|
|
||||||
path="/admin/gateway/quota-resets",
|
|
||||||
summary="Reset the rolling-24h AI gateway spend so a capped caller can call again (admin only)",
|
|
||||||
description=(
|
|
||||||
"Clears the counted spend for a scope without deleting any usage history, so the "
|
|
||||||
"cost analytics on /admin/ai-usage stay intact. Scope it exactly like a quota rule: "
|
|
||||||
"owner_kind (internal/key/user/admin/anonymous), a specific owner_id (user uid), and "
|
|
||||||
"app_reference (the X-App-Reference header). Leaving all three blank resets every "
|
|
||||||
"caller. A reset only clears spend recorded BEFORE it - new calls start counting "
|
|
||||||
"again immediately against the same limit. Use this when an app is stuck on "
|
|
||||||
"'AI gateway daily quota exceeded' and you want it running again without raising "
|
|
||||||
"its cap."
|
|
||||||
),
|
|
||||||
handler="http",
|
|
||||||
requires_admin=True,
|
|
||||||
params=(
|
|
||||||
body("owner_kind", "Role to scope by: internal, key, user, admin, or anonymous. Blank = every role."),
|
|
||||||
body("owner_id", "Specific user uid to scope by. Blank = every caller."),
|
|
||||||
body("app_reference", "App label to scope by (the X-App-Reference header). Blank = every app."),
|
|
||||||
confirm(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Action(
|
Action(
|
||||||
name="gateway_quota_rule_delete",
|
name="gateway_quota_rule_delete",
|
||||||
method="DELETE",
|
method="DELETE",
|
||||||
|
|||||||
@ -59,7 +59,6 @@ CONFIRM_REQUIRED = {
|
|||||||
"gateway_provider_delete",
|
"gateway_provider_delete",
|
||||||
"gateway_model_delete",
|
"gateway_model_delete",
|
||||||
"gateway_quota_rule_delete",
|
"gateway_quota_rule_delete",
|
||||||
"gateway_quota_reset",
|
|
||||||
"email_account_delete",
|
"email_account_delete",
|
||||||
"email_delete_message",
|
"email_delete_message",
|
||||||
"game_prestige",
|
"game_prestige",
|
||||||
|
|||||||
@ -4,7 +4,6 @@ import json
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from devplacepy.avatar import avatar_seed
|
from devplacepy.avatar import avatar_seed
|
||||||
from devplacepy.database import get_table
|
|
||||||
from devplacepy.services.devrant.avatar import avatar_payload
|
from devplacepy.services.devrant.avatar import avatar_payload
|
||||||
from devplacepy.services.devrant.ids import to_unix
|
from devplacepy.services.devrant.ids import to_unix
|
||||||
|
|
||||||
@ -27,22 +26,6 @@ def encode_tags(tags: list) -> str:
|
|||||||
return json.dumps(cleaned)
|
return json.dumps(cleaned)
|
||||||
|
|
||||||
|
|
||||||
def _rant_project(post: dict) -> dict | None:
|
|
||||||
project_uid = post.get("project_uid")
|
|
||||||
if not project_uid:
|
|
||||||
return None
|
|
||||||
project = get_table("projects").find_one(uid=project_uid)
|
|
||||||
if not project:
|
|
||||||
return None
|
|
||||||
slug = project.get("slug") or project["uid"]
|
|
||||||
return {
|
|
||||||
"uid": project["uid"],
|
|
||||||
"name": project.get("title") or project.get("name", ""),
|
|
||||||
"slug": slug,
|
|
||||||
"url": f"/projects/{slug}",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def rant_text(post: dict) -> str:
|
def rant_text(post: dict) -> str:
|
||||||
title = (post.get("title") or "").strip()
|
title = (post.get("title") or "").strip()
|
||||||
content = post.get("content") or ""
|
content = post.get("content") or ""
|
||||||
@ -73,7 +56,6 @@ def serialize_rant(
|
|||||||
author = authors.get(post["user_uid"]) or {}
|
author = authors.get(post["user_uid"]) or {}
|
||||||
uid = post["uid"]
|
uid = post["uid"]
|
||||||
username = author.get("username") or ""
|
username = author.get("username") or ""
|
||||||
project = _rant_project(post)
|
|
||||||
return {
|
return {
|
||||||
"id": int(post["id"]),
|
"id": int(post["id"]),
|
||||||
"text": rant_text(post),
|
"text": rant_text(post),
|
||||||
@ -93,8 +75,6 @@ def serialize_rant(
|
|||||||
"user_avatar": avatar_payload(avatar_seed(author)),
|
"user_avatar": avatar_payload(avatar_seed(author)),
|
||||||
"user_avatar_lg": avatar_payload(avatar_seed(author)),
|
"user_avatar_lg": avatar_payload(avatar_seed(author)),
|
||||||
"editable": bool(viewer and viewer.get("uid") == post["user_uid"]),
|
"editable": bool(viewer and viewer.get("uid") == post["user_uid"]),
|
||||||
"project_uid": post.get("project_uid"),
|
|
||||||
"project": project,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -188,7 +188,7 @@ def _screenshot_hue_buckets(screenshot_bytes: bytes) -> dict[int, int]:
|
|||||||
image = Image.open(io.BytesIO(screenshot_bytes)).convert("RGB")
|
image = Image.open(io.BytesIO(screenshot_bytes)).convert("RGB")
|
||||||
image = image.resize((64, 64))
|
image = image.resize((64, 64))
|
||||||
buckets: dict[int, int] = {}
|
buckets: dict[int, int] = {}
|
||||||
for r, g, b in image.get_flattened_data():
|
for r, g, b in image.getdata():
|
||||||
hue, lightness, saturation = colorsys.rgb_to_hls(r / 255.0, g / 255.0, b / 255.0)
|
hue, lightness, saturation = colorsys.rgb_to_hls(r / 255.0, g / 255.0, b / 255.0)
|
||||||
if saturation < 0.15 or lightness < 0.05 or lightness > 0.95:
|
if saturation < 0.15 or lightness < 0.05 or lightness > 0.95:
|
||||||
continue
|
continue
|
||||||
|
|||||||
@ -73,11 +73,7 @@ The gateway records one row per upstream call (chat, vision, passthrough) and su
|
|||||||
|
|
||||||
**429 body never carries a dollar figure**, admin or not (`{"detail": "AI gateway daily quota exceeded"}`) - mirrors Devii's own over-limit WS message, which likewise never states a number. The admin-only services log line and the `ai.quota.exceeded` audit row (`GatewayService._audit_quota_exceeded`, reusing `usage.audit_actor_for`) do carry the spend/limit/matched-rule-uid, since those are admin-only surfaces.
|
**429 body never carries a dollar figure**, admin or not (`{"detail": "AI gateway daily quota exceeded"}`) - mirrors Devii's own over-limit WS message, which likewise never states a number. The admin-only services log line and the `ai.quota.exceeded` audit row (`GatewayService._audit_quota_exceeded`, reusing `usage.audit_actor_for`) do carry the spend/limit/matched-rule-uid, since those are admin-only surfaces.
|
||||||
|
|
||||||
**Resetting the counted spend (`gateway_quota_resets`).** A cap is only lifted by *time* otherwise, so there is a reset that clears what has been counted **without deleting any ledger row** - `gateway_usage_ledger` is the cost-analytics source for `/admin/ai-usage`, so a reset must never truncate it. `quota.reset(QuotaResetIn, created_by=)` upserts one watermark row into `gateway_quota_resets` (same `ensure_tables()`/`"gateway_quota"` cache-version/hard-CRUD shape as the rules table, same two indexes) scoped by the SAME three nullable dimensions as a rule, and `quota.spent_24h` sums from `max(24h cutoff, reset_watermark(scope))`. A reset row applies to a queried scope when each of its non-null dimensions equals that scope's - so an all-null reset clears everyone, while a reset scoped to one app deliberately does NOT clear a broader per-user-all-apps scope (clearing a narrower window can only over-credit). Spend recorded after the reset counts again immediately against the same limit. `QuotaScopeIn` is the shared base holding the three dimensions and their validators; `QuotaRuleIn` and `QuotaResetIn` both extend it, so scope parsing exists once.
|
**CRUD.** Admin JSON at `/admin/gateway/quota-rules` (`routers/admin/gateway_configs.py`, list returns each rule's live `spent_24h_usd` plus the Layer A defaults for context), audited `gateway.quota_rule.update`/`gateway.quota_rule.delete` (category `ai`, both already in `events.md`), rendered in the **Quota rules** section of `/admin/gateway` (`GatewayAdmin.js`, mirrors the providers/models CRUD tables). Devii tools `gateway_quota_rules`/`gateway_quota_rule_set`/`gateway_quota_rule_delete` (`requires_admin=True`, delete is `CONFIRM_REQUIRED`) proxy the same endpoints via `handler="http"`, same as the provider/model tools. CLI: `devplace gateway quota list|set|delete`.
|
||||||
|
|
||||||
**The two AI quotas are separate systems and the reset surfaces must say so.** `/admin/ai-usage`'s *Reset all quotas* clears the Devii `devii_usage_ledger` AND now also stamps a global gateway watermark, because a caller hitting `429 AI gateway daily quota exceeded` had no reset at all before and the button looked global. *Reset guest quotas* stays Devii-only (guest gateway calls ride the shared internal key, so there is no per-guest gateway scope to clear).
|
|
||||||
|
|
||||||
**CRUD.** Admin JSON at `/admin/gateway/quota-rules` (`routers/admin/gateway_configs.py`, list returns each rule's live `spent_24h_usd` plus the Layer A defaults for context), audited `gateway.quota_rule.update`/`gateway.quota_rule.delete` (category `ai`, both already in `events.md`), rendered in the **Quota rules** section of `/admin/gateway` (`GatewayAdmin.js`, mirrors the providers/models CRUD tables). Devii tools `gateway_quota_rules`/`gateway_quota_rule_set`/`gateway_quota_rule_delete` (`requires_admin=True`, delete is `CONFIRM_REQUIRED`) proxy the same endpoints via `handler="http"`, same as the provider/model tools. Reset is `POST /admin/gateway/quota-resets` (same file, `_payload`/`ValidationError` shape as the rule CRUD), audited `gateway.quota.reset` (category `ai`), surfaced as a per-rule **Reset spend** button in the Quota rules table (`GatewayAdmin.js`), and exposed as the Devii tool `gateway_quota_reset` (`requires_admin=True`, in `CONFIRM_REQUIRED` with a declared `confirm` param, like the other quota-lifting admin resets). CLI: `devplace gateway quota list|set|delete|reset`.
|
|
||||||
|
|
||||||
## Image generation
|
## Image generation
|
||||||
|
|
||||||
|
|||||||
@ -16,7 +16,6 @@ from devplacepy.database import bump_cache_version, db, get_table, sync_local_ca
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
RULES_TABLE = "gateway_quota_rules"
|
RULES_TABLE = "gateway_quota_rules"
|
||||||
RESETS_TABLE = "gateway_quota_resets"
|
|
||||||
CACHE_NAME = "gateway_quota"
|
CACHE_NAME = "gateway_quota"
|
||||||
APP_REFERENCE_PATTERN = re.compile(r"^[a-zA-Z0-9_.-]{1,30}$")
|
APP_REFERENCE_PATTERN = re.compile(r"^[a-zA-Z0-9_.-]{1,30}$")
|
||||||
OWNER_KINDS = ("internal", "key", "user", "admin", "anonymous")
|
OWNER_KINDS = ("internal", "key", "user", "admin", "anonymous")
|
||||||
@ -63,38 +62,22 @@ def ensure_tables() -> None:
|
|||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("gateway quota rule index creation failed: %s", exc)
|
logger.warning("gateway quota rule index creation failed: %s", exc)
|
||||||
db.query(
|
|
||||||
"CREATE TABLE IF NOT EXISTS "
|
|
||||||
+ RESETS_TABLE
|
|
||||||
+ " (id INTEGER PRIMARY KEY, uid TEXT, owner_kind TEXT, owner_id TEXT, "
|
|
||||||
"app_reference TEXT, reset_at TEXT, created_by TEXT)"
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
db.query(
|
|
||||||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_gateway_quota_resets_uid ON "
|
|
||||||
+ RESETS_TABLE
|
|
||||||
+ " (uid)"
|
|
||||||
)
|
|
||||||
db.query(
|
|
||||||
"CREATE INDEX IF NOT EXISTS idx_gateway_quota_resets_lookup ON "
|
|
||||||
+ RESETS_TABLE
|
|
||||||
+ " (owner_kind, owner_id, app_reference)"
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning("gateway quota reset index creation failed: %s", exc)
|
|
||||||
|
|
||||||
|
|
||||||
class QuotaScopeIn(BaseModel):
|
class QuotaRuleIn(BaseModel):
|
||||||
owner_kind: Optional[str] = None
|
owner_kind: Optional[str] = None
|
||||||
owner_id: Optional[str] = Field(default=None, max_length=64)
|
owner_id: Optional[str] = Field(default=None, max_length=64)
|
||||||
app_reference: Optional[str] = Field(default=None, max_length=30)
|
app_reference: Optional[str] = Field(default=None, max_length=30)
|
||||||
|
limit_usd: float = Field(default=0.0, ge=0)
|
||||||
|
is_active: bool = True
|
||||||
|
label: str = Field(default="", max_length=200)
|
||||||
|
|
||||||
@field_validator("owner_kind")
|
@field_validator("owner_kind")
|
||||||
@classmethod
|
@classmethod
|
||||||
def _clean_owner_kind(cls, value: Optional[str]) -> Optional[str]:
|
def _clean_owner_kind(cls, value: Optional[str]) -> Optional[str]:
|
||||||
value = (value or "").strip().lower()
|
|
||||||
if not value:
|
if not value:
|
||||||
return None
|
return None
|
||||||
|
value = value.strip().lower()
|
||||||
if value not in OWNER_KINDS:
|
if value not in OWNER_KINDS:
|
||||||
raise ValueError(f"owner_kind must be one of {', '.join(OWNER_KINDS)}")
|
raise ValueError(f"owner_kind must be one of {', '.join(OWNER_KINDS)}")
|
||||||
return value
|
return value
|
||||||
@ -102,24 +85,20 @@ class QuotaScopeIn(BaseModel):
|
|||||||
@field_validator("owner_id")
|
@field_validator("owner_id")
|
||||||
@classmethod
|
@classmethod
|
||||||
def _clean_owner_id(cls, value: Optional[str]) -> Optional[str]:
|
def _clean_owner_id(cls, value: Optional[str]) -> Optional[str]:
|
||||||
return (value or "").strip() or None
|
if not value:
|
||||||
|
return None
|
||||||
|
return value.strip()
|
||||||
|
|
||||||
@field_validator("app_reference")
|
@field_validator("app_reference")
|
||||||
@classmethod
|
@classmethod
|
||||||
def _clean_app_reference(cls, value: Optional[str]) -> Optional[str]:
|
def _clean_app_reference(cls, value: Optional[str]) -> Optional[str]:
|
||||||
value = (value or "").strip()
|
|
||||||
if not value:
|
if not value:
|
||||||
return None
|
return None
|
||||||
|
value = value.strip()
|
||||||
if not APP_REFERENCE_PATTERN.match(value):
|
if not APP_REFERENCE_PATTERN.match(value):
|
||||||
raise ValueError("app_reference must match ^[a-zA-Z0-9_.-]{1,30}$")
|
raise ValueError("app_reference must match ^[a-zA-Z0-9_.-]{1,30}$")
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
class QuotaRuleIn(QuotaScopeIn):
|
|
||||||
limit_usd: float = Field(default=0.0, ge=0)
|
|
||||||
is_active: bool = True
|
|
||||||
label: str = Field(default="", max_length=200)
|
|
||||||
|
|
||||||
@field_validator("label")
|
@field_validator("label")
|
||||||
@classmethod
|
@classmethod
|
||||||
def _clean_label(cls, value: str) -> str:
|
def _clean_label(cls, value: str) -> str:
|
||||||
@ -135,10 +114,6 @@ class QuotaRuleIn(QuotaScopeIn):
|
|||||||
return self
|
return self
|
||||||
|
|
||||||
|
|
||||||
class QuotaResetIn(QuotaScopeIn):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class QuotaRule:
|
class QuotaRule:
|
||||||
uid: str
|
uid: str
|
||||||
@ -275,83 +250,6 @@ class QuotaRuleStore:
|
|||||||
quota_rule_store = QuotaRuleStore()
|
quota_rule_store = QuotaRuleStore()
|
||||||
|
|
||||||
|
|
||||||
def _load_resets() -> list[dict]:
|
|
||||||
sync_local_cache(CACHE_NAME, _QUOTA_CACHE)
|
|
||||||
if "resets" not in _QUOTA_CACHE:
|
|
||||||
resets: list[dict] = []
|
|
||||||
try:
|
|
||||||
if RESETS_TABLE in db.tables:
|
|
||||||
for row in get_table(RESETS_TABLE).all():
|
|
||||||
if row.get("reset_at"):
|
|
||||||
resets.append(
|
|
||||||
{
|
|
||||||
"owner_kind": row.get("owner_kind") or None,
|
|
||||||
"owner_id": row.get("owner_id") or None,
|
|
||||||
"app_reference": row.get("app_reference") or None,
|
|
||||||
"reset_at": str(row["reset_at"]),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning("gateway quota reset load failed: %s", exc)
|
|
||||||
_QUOTA_CACHE["resets"] = resets
|
|
||||||
return _QUOTA_CACHE["resets"]
|
|
||||||
|
|
||||||
|
|
||||||
def _reset_applies(reset: dict, scope: dict) -> bool:
|
|
||||||
for field in ("owner_kind", "owner_id", "app_reference"):
|
|
||||||
wanted = reset.get(field)
|
|
||||||
if wanted is None:
|
|
||||||
continue
|
|
||||||
if scope.get(field) is None or scope[field] != wanted:
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def reset_watermark(
|
|
||||||
owner_kind: Optional[str], owner_id: Optional[str], app_reference: Optional[str]
|
|
||||||
) -> str:
|
|
||||||
scope = {
|
|
||||||
"owner_kind": owner_kind,
|
|
||||||
"owner_id": owner_id,
|
|
||||||
"app_reference": app_reference,
|
|
||||||
}
|
|
||||||
stamps = [r["reset_at"] for r in _load_resets() if _reset_applies(r, scope)]
|
|
||||||
return max(stamps) if stamps else ""
|
|
||||||
|
|
||||||
|
|
||||||
def reset(payload: Optional[QuotaResetIn] = None, *, created_by: str = "") -> dict:
|
|
||||||
ensure_tables()
|
|
||||||
payload = payload or QuotaResetIn()
|
|
||||||
table = get_table(RESETS_TABLE)
|
|
||||||
scope = {
|
|
||||||
"owner_kind": payload.owner_kind,
|
|
||||||
"owner_id": payload.owner_id,
|
|
||||||
"app_reference": payload.app_reference,
|
|
||||||
}
|
|
||||||
stamp = _now()
|
|
||||||
existing = table.find_one(**scope)
|
|
||||||
if existing:
|
|
||||||
table.update({"id": existing["id"], "reset_at": stamp, "created_by": created_by}, ["id"])
|
|
||||||
uid = existing.get("uid") or uuid.uuid4().hex
|
|
||||||
else:
|
|
||||||
uid = uuid.uuid4().hex
|
|
||||||
table.insert({**scope, "uid": uid, "reset_at": stamp, "created_by": created_by})
|
|
||||||
bump_cache_version(CACHE_NAME)
|
|
||||||
_QUOTA_CACHE.clear()
|
|
||||||
return {**scope, "uid": uid, "reset_at": stamp}
|
|
||||||
|
|
||||||
|
|
||||||
def scope_label(scope: dict, fallback: str = "") -> str:
|
|
||||||
parts = []
|
|
||||||
if scope.get("owner_kind"):
|
|
||||||
parts.append(f"role={scope['owner_kind']}")
|
|
||||||
if scope.get("owner_id"):
|
|
||||||
parts.append(f"user={scope['owner_id']}")
|
|
||||||
if scope.get("app_reference"):
|
|
||||||
parts.append(f"app={scope['app_reference']}")
|
|
||||||
return ", ".join(parts) or fallback
|
|
||||||
|
|
||||||
|
|
||||||
def default_limit(owner_kind: str, cfg: dict) -> float:
|
def default_limit(owner_kind: str, cfg: dict) -> float:
|
||||||
field = _DEFAULT_FIELD_BY_KIND.get(owner_kind, FIELD_DEFAULT_USER)
|
field = _DEFAULT_FIELD_BY_KIND.get(owner_kind, FIELD_DEFAULT_USER)
|
||||||
return float(cfg.get(field, 0.0) or 0.0)
|
return float(cfg.get(field, 0.0) or 0.0)
|
||||||
@ -396,10 +294,10 @@ def spent_24h(
|
|||||||
|
|
||||||
if GATEWAY_LEDGER not in db.tables:
|
if GATEWAY_LEDGER not in db.tables:
|
||||||
return 0.0
|
return 0.0
|
||||||
cutoff = (datetime.now(timezone.utc) - timedelta(hours=24)).isoformat()
|
|
||||||
watermark = reset_watermark(owner_kind, owner_id, app_reference)
|
|
||||||
clauses = ["created_at >= :cutoff"]
|
clauses = ["created_at >= :cutoff"]
|
||||||
params: dict = {"cutoff": max(cutoff, watermark) if watermark else cutoff}
|
params: dict = {
|
||||||
|
"cutoff": (datetime.now(timezone.utc) - timedelta(hours=24)).isoformat()
|
||||||
|
}
|
||||||
if owner_kind is not None:
|
if owner_kind is not None:
|
||||||
clauses.append("owner_kind = :owner_kind")
|
clauses.append("owner_kind = :owner_kind")
|
||||||
params["owner_kind"] = owner_kind
|
params["owner_kind"] = owner_kind
|
||||||
|
|||||||
@ -1,9 +0,0 @@
|
|||||||
# retoor <retoor@molodetz.nl>
|
|
||||||
|
|
||||||
from devplacepy.services.push.service import (
|
|
||||||
DEFAULT_RETENTION_DAYS,
|
|
||||||
RETENTION_KEY,
|
|
||||||
PushService,
|
|
||||||
)
|
|
||||||
|
|
||||||
__all__ = ["DEFAULT_RETENTION_DAYS", "PushService", "RETENTION_KEY"]
|
|
||||||
@ -1,79 +0,0 @@
|
|||||||
# retoor <retoor@molodetz.nl>
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
|
|
||||||
from devplacepy.database import get_int_setting
|
|
||||||
from devplacepy.push import providers, store
|
|
||||||
from devplacepy.push.delivery import (
|
|
||||||
DEFAULT_TIMEOUT_SECONDS,
|
|
||||||
MAX_TIMEOUT_SECONDS,
|
|
||||||
MIN_TIMEOUT_SECONDS,
|
|
||||||
TIMEOUT_KEY,
|
|
||||||
)
|
|
||||||
from devplacepy.services.base import BaseService, ConfigField
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
RETENTION_KEY = "push_dead_retention_days"
|
|
||||||
DEFAULT_RETENTION_DAYS = 30
|
|
||||||
|
|
||||||
|
|
||||||
class PushService(BaseService):
|
|
||||||
title = "Push notifications"
|
|
||||||
description = (
|
|
||||||
"Configures the push providers and prunes dead subscriptions. Delivery is "
|
|
||||||
"independent of this service and continues while it is stopped."
|
|
||||||
)
|
|
||||||
details = (
|
|
||||||
"Notifications reach a user through every provider they have a live subscription "
|
|
||||||
"for. A provider delivers only while its own Enabled toggle is on and its "
|
|
||||||
"configuration is complete, so an unconfigured provider is inert."
|
|
||||||
)
|
|
||||||
default_enabled = True
|
|
||||||
min_interval = 3600
|
|
||||||
METRICS_SECONDS = 300
|
|
||||||
config_fields = [
|
|
||||||
ConfigField(
|
|
||||||
RETENTION_KEY,
|
|
||||||
"Dead subscription retention (days)",
|
|
||||||
type="int",
|
|
||||||
default=DEFAULT_RETENTION_DAYS,
|
|
||||||
minimum=0,
|
|
||||||
help="Subscriptions the push services rejected as gone are removed after this many days. 0 disables pruning.",
|
|
||||||
group="General",
|
|
||||||
),
|
|
||||||
ConfigField(
|
|
||||||
TIMEOUT_KEY,
|
|
||||||
"Delivery timeout (seconds)",
|
|
||||||
type="int",
|
|
||||||
default=DEFAULT_TIMEOUT_SECONDS,
|
|
||||||
minimum=MIN_TIMEOUT_SECONDS,
|
|
||||||
maximum=MAX_TIMEOUT_SECONDS,
|
|
||||||
help="Per request timeout used for every push provider.",
|
|
||||||
group="General",
|
|
||||||
),
|
|
||||||
*providers.admin_fields(),
|
|
||||||
]
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
|
||||||
super().__init__("push", interval_seconds=86400)
|
|
||||||
|
|
||||||
async def run_once(self) -> None:
|
|
||||||
days = get_int_setting(RETENTION_KEY, DEFAULT_RETENTION_DAYS)
|
|
||||||
if days <= 0:
|
|
||||||
self.log("Retention disabled (0 days); nothing pruned")
|
|
||||||
return
|
|
||||||
cutoff = (datetime.now(timezone.utc) - timedelta(days=days)).isoformat()
|
|
||||||
removed = store.prune(cutoff)
|
|
||||||
self.log(f"Pruned {removed} dead push subscriptions older than {days}d")
|
|
||||||
|
|
||||||
def collect_metrics(self) -> dict:
|
|
||||||
totals = store.counts()
|
|
||||||
metrics = {"dead": totals.get("dead", 0)}
|
|
||||||
for provider in providers.PROVIDERS.values():
|
|
||||||
metrics[f"{provider.name}_active"] = totals.get(provider.name, 0)
|
|
||||||
metrics[f"{provider.name}_ready"] = (
|
|
||||||
1 if providers.is_active(provider) else 0
|
|
||||||
)
|
|
||||||
return metrics
|
|
||||||
@ -221,7 +221,7 @@ for `target_type == "quiz"`, soft-deleting questions, options, attempts and answ
|
|||||||
| SEO | `seo.quiz_schema`, `/quizzes` + the newest published quizzes in the sitemap; hub and detail `index,follow`, builder/player/results `noindex,follow`, a draft detail `noindex,nofollow` |
|
| SEO | `seo.quiz_schema`, `/quizzes` + the newest published quizzes in the sitemap; hub and detail `index,follow`, builder/player/results `noindex,follow`, a draft detail `noindex,nofollow` |
|
||||||
| Notifications | `quiz_attempt`, fired once on the finish transition to the author, never per answer |
|
| Notifications | `quiz_attempt`, fired once on the finish transition to the author, never per answer |
|
||||||
| Gamification | `XP_QUIZ`/`XP_QUIZ_PUBLISH`/`XP_QUIZ_COMPLETE`, achievements `quiz_publish`/`quiz_complete`/`quiz_perfect`, the **Quizzes** badge group |
|
| Gamification | `XP_QUIZ`/`XP_QUIZ_PUBLISH`/`XP_QUIZ_COMPLETE`, achievements `quiz_publish`/`quiz_complete`/`quiz_perfect`, the **Quizzes** badge group |
|
||||||
| Audit | prefix `quiz` -> category `content`; thirteen keys in `events.md` |
|
| Audit | prefix `quiz` -> category `content`; eleven keys in `events.md` |
|
||||||
| Devii | `actions/catalog/quizzes.py`; `publish_quiz`, `delete_quiz` and `delete_quiz_question` are in `dispatcher.CONFIRM_REQUIRED` and each declares a `confirm` param |
|
| Devii | `actions/catalog/quizzes.py`; `publish_quiz`, `delete_quiz` and `delete_quiz_question` are in `dispatcher.CONFIRM_REQUIRED` and each declares a `confirm` param |
|
||||||
| CLI | `devplace quiz prune` - hard-deletes abandoned and expired attempts older than `QUIZ_ATTEMPT_RETENTION_DAYS`. Completed attempts are never pruned; they are the player's record |
|
| CLI | `devplace quiz prune` - hard-deletes abandoned and expired attempts older than `QUIZ_ATTEMPT_RETENTION_DAYS`. Completed attempts are never pruned; they are the player's record |
|
||||||
| Frontend | `dp-quiz-player`/`dp-quiz-builder` (light DOM, adopt the server-rendered markup), `static/css/quiz.css` (adds only what is new - the layout and card chrome come from `feed.css`/`sidebar.css`) |
|
| Frontend | `dp-quiz-player`/`dp-quiz-builder` (light DOM, adopt the server-rendered markup), `static/css/quiz.css` (adds only what is new - the layout and card chrome come from `feed.css`/`sidebar.css`) |
|
||||||
|
|||||||
@ -245,24 +245,16 @@
|
|||||||
min-width: 2.2em;
|
min-width: 2.2em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.game-lb-name-group {
|
.game-lb-name {
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
.game-lb-name {
|
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.game-lb-name:hover {
|
.game-lb-name:hover {
|
||||||
@ -276,14 +268,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.game-lb-title {
|
.game-lb-title {
|
||||||
|
flex-shrink: 0;
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
font-size: 0.7rem;
|
font-size: 0.75rem;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
display: block;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.game-lb-score {
|
.game-lb-score {
|
||||||
|
|||||||
@ -55,22 +55,6 @@
|
|||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-link {
|
|
||||||
display: inline-block;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: var(--accent);
|
|
||||||
font-weight: 600;
|
|
||||||
padding: 0.375rem 0.75rem;
|
|
||||||
margin-bottom: 0.75rem;
|
|
||||||
background: var(--bg-card-hover);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-link:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.post-detail-actions {
|
.post-detail-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@ -305,10 +305,6 @@
|
|||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-devlog {
|
|
||||||
margin-top: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-section-label {
|
.project-section-label {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@ -317,4 +313,3 @@
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -388,7 +388,7 @@ export class GameFarm {
|
|||||||
.map((entry) => {
|
.map((entry) => {
|
||||||
const title = entry.title ? `<span class="game-lb-title">${entry.title}</span>` : "";
|
const title = entry.title ? `<span class="game-lb-title">${entry.title}</span>` : "";
|
||||||
const value = this._leaderboardValue(board, entry);
|
const value = this._leaderboardValue(board, entry);
|
||||||
return `<li class="game-lb-row${entry.username === this.username ? " game-lb-self" : ""}"><span class="game-lb-rank">#${entry.rank}</span><div class="game-lb-name-group"><a class="game-lb-name" href="/game/farm/${entry.username}">${entry.username}</a>${title}</div><span class="game-lb-level">Lv ${entry.level}</span><span class="game-lb-score">${value}</span></li>`;
|
return `<li class="game-lb-row${entry.username === this.username ? " game-lb-self" : ""}"><span class="game-lb-rank">#${entry.rank}</span><a class="game-lb-name" href="/game/farm/${entry.username}">${entry.username}</a>${title}<span class="game-lb-level">Lv ${entry.level}</span><span class="game-lb-score">${value}</span></li>`;
|
||||||
})
|
})
|
||||||
.join("");
|
.join("");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@ -279,9 +279,9 @@ export class GatewayAdmin {
|
|||||||
form.name.scrollIntoView({ block: "center" });
|
form.name.scrollIntoView({ block: "center" });
|
||||||
}
|
}
|
||||||
|
|
||||||
async confirmAction(message, confirmLabel = "Delete") {
|
async confirmDelete(message) {
|
||||||
if (window.app && window.app.dialog) {
|
if (window.app && window.app.dialog) {
|
||||||
return window.app.dialog.confirm({ message, danger: true, confirmLabel });
|
return window.app.dialog.confirm({ message, danger: true, confirmLabel: "Delete" });
|
||||||
}
|
}
|
||||||
return window.confirm(message);
|
return window.confirm(message);
|
||||||
}
|
}
|
||||||
@ -294,7 +294,7 @@ export class GatewayAdmin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async deleteProvider(name) {
|
async deleteProvider(name) {
|
||||||
if (!(await this.confirmAction(`Delete provider "${name}"?`))) return;
|
if (!(await this.confirmDelete(`Delete provider "${name}"?`))) return;
|
||||||
try {
|
try {
|
||||||
await this.remove(`/admin/gateway/providers/${encodeURIComponent(name)}`);
|
await this.remove(`/admin/gateway/providers/${encodeURIComponent(name)}`);
|
||||||
this.notify("Provider deleted", "success");
|
this.notify("Provider deleted", "success");
|
||||||
@ -338,7 +338,7 @@ export class GatewayAdmin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async deleteModel(source) {
|
async deleteModel(source) {
|
||||||
if (!(await this.confirmAction(`Delete model route "${source}"?`))) return;
|
if (!(await this.confirmDelete(`Delete model route "${source}"?`))) return;
|
||||||
try {
|
try {
|
||||||
await this.remove(`/admin/gateway/models/${encodeURIComponent(source)}`);
|
await this.remove(`/admin/gateway/models/${encodeURIComponent(source)}`);
|
||||||
this.notify("Model route deleted", "success");
|
this.notify("Model route deleted", "success");
|
||||||
@ -394,7 +394,6 @@ export class GatewayAdmin {
|
|||||||
<td>${this.escape(r.label || "")}</td>
|
<td>${this.escape(r.label || "")}</td>
|
||||||
<td class="gw-actions">
|
<td class="gw-actions">
|
||||||
<button class="admin-btn admin-btn-sm" data-edit-quota='${this.attr(JSON.stringify(r))}'>Edit</button>
|
<button class="admin-btn admin-btn-sm" data-edit-quota='${this.attr(JSON.stringify(r))}'>Edit</button>
|
||||||
<button class="admin-btn admin-btn-sm" data-reset-quota='${this.attr(JSON.stringify(r))}'>Reset spend</button>
|
|
||||||
<button class="admin-btn admin-btn-sm admin-btn-danger" data-del-quota="${this.attr(r.uid)}">Delete</button>
|
<button class="admin-btn admin-btn-sm admin-btn-danger" data-del-quota="${this.attr(r.uid)}">Delete</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
@ -444,31 +443,13 @@ export class GatewayAdmin {
|
|||||||
|
|
||||||
onQuotaRuleClick(event) {
|
onQuotaRuleClick(event) {
|
||||||
const editRaw = event.target.dataset.editQuota;
|
const editRaw = event.target.dataset.editQuota;
|
||||||
const resetRaw = event.target.dataset.resetQuota;
|
|
||||||
const delUid = event.target.dataset.delQuota;
|
const delUid = event.target.dataset.delQuota;
|
||||||
if (editRaw) this.fillQuotaForm(JSON.parse(editRaw));
|
if (editRaw) this.fillQuotaForm(JSON.parse(editRaw));
|
||||||
if (resetRaw) this.resetQuotaSpend(JSON.parse(resetRaw));
|
|
||||||
if (delUid) this.deleteQuotaRule(delUid);
|
if (delUid) this.deleteQuotaRule(delUid);
|
||||||
}
|
}
|
||||||
|
|
||||||
async resetQuotaSpend(rule) {
|
|
||||||
const label = this.scopeLabel(rule);
|
|
||||||
if (!(await this.confirmAction(`Reset the counted 24h spend for ${label}? The usage history is kept.`, "Reset"))) return;
|
|
||||||
try {
|
|
||||||
await Http.postJson("/admin/gateway/quota-resets", {
|
|
||||||
owner_kind: rule.owner_kind || "",
|
|
||||||
owner_id: rule.owner_id || "",
|
|
||||||
app_reference: rule.app_reference || "",
|
|
||||||
});
|
|
||||||
this.notify("Quota spend reset", "success");
|
|
||||||
await this.reload();
|
|
||||||
} catch (err) {
|
|
||||||
this.notify(err.message || "Reset failed", "error");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteQuotaRule(uid) {
|
async deleteQuotaRule(uid) {
|
||||||
if (!(await this.confirmAction("Delete this quota rule?"))) return;
|
if (!(await this.confirmDelete("Delete this quota rule?"))) return;
|
||||||
try {
|
try {
|
||||||
await this.remove(`/admin/gateway/quota-rules/${encodeURIComponent(uid)}`);
|
await this.remove(`/admin/gateway/quota-rules/${encodeURIComponent(uid)}`);
|
||||||
this.notify("Quota rule deleted", "success");
|
this.notify("Quota rule deleted", "success");
|
||||||
|
|||||||
@ -54,9 +54,6 @@ export class PushManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const keyData = await Http.getJson("/push.json");
|
const keyData = await Http.getJson("/push.json");
|
||||||
if (!keyData.publicKey) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const applicationServerKey = Uint8Array.from(atob(keyData.publicKey), (c) => c.charCodeAt(0));
|
const applicationServerKey = Uint8Array.from(atob(keyData.publicKey), (c) => c.charCodeAt(0));
|
||||||
|
|
||||||
const subscription = await registration.pushManager.subscribe({
|
const subscription = await registration.pushManager.subscribe({
|
||||||
|
|||||||
@ -17,10 +17,6 @@
|
|||||||
|
|
||||||
<div class="post-content rendered-content">{{ render_content(item.post['content'][:300] ~ ('...' if item.post['content']|length > 300 else ''), author_is_admin=is_admin(item.author)) }}</div>
|
<div class="post-content rendered-content">{{ render_content(item.post['content'][:300] ~ ('...' if item.post['content']|length > 300 else ''), author_is_admin=is_admin(item.author)) }}</div>
|
||||||
|
|
||||||
{% if item.project_link %}
|
|
||||||
<a href="{{ item.project_link.url }}" class="project-link">Project: {{ item.project_link.name }}</a>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if item.attachments %}
|
{% if item.attachments %}
|
||||||
{% include "_attachment_display.html" %}
|
{% include "_attachment_display.html" %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@ -42,7 +42,7 @@ Open `http://<host>:<PORT>`. Useful targets: `make docker-logs` (tail), `make do
|
|||||||
|
|
||||||
The make targets always apply the `docker-compose.containers.yml` overlay, so the admin-only Container Manager works with no extra setup. Always update through them: a plain `docker compose up -d` drops the overlay and silently removes the docker socket and CLI the manager needs.
|
The make targets always apply the `docker-compose.containers.yml` overlay, so the admin-only Container Manager works with no extra setup. Always update through them: a plain `docker compose up -d` drops the overlay and silently removes the docker socket and CLI the manager needs.
|
||||||
|
|
||||||
The healthcheck gives the app a 120s start window in which a failing probe does not count against `retries` (`start_period: 120s`), and probes every 2s inside that window (`start_interval: 2s`) so the container is marked healthy as soon as it actually serves rather than at the next 30s tick. Both settings live in `docker-compose.yml` and the `Dockerfile` and must be changed together. Normal startup is a few seconds; the 120s window is headroom for a cold page cache on a multi-GB database. Startup cost is paid once per uvicorn worker, serialized under an exclusive init lock, so anything added to `init_db` is multiplied by the worker count. If you add heavyweight init work, measure the real boot time before relying on the existing window.
|
The healthcheck gives the app a 120s start window before any failure counts (`start_period: 120s` in `docker-compose.yml`). The full startup (DB init, background service registration, uvicorn workers) takes roughly 110s, so the start period must stay well above that. If you add heavyweight init work, verify the app boots within 120s or bump the start period to match.
|
||||||
|
|
||||||
## Updating
|
## Updating
|
||||||
|
|
||||||
@ -50,19 +50,15 @@ Code is bind-mounted, so most updates need no rebuild:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
git pull
|
git pull
|
||||||
make docker-reload # restart workers on the new code
|
make docker-up # restart workers on the new code
|
||||||
```
|
```
|
||||||
|
|
||||||
Use `make docker-reload`, not `make docker-up`: `docker compose up -d` sees an unchanged container and leaves it running, so the workers keep serving the code they imported at boot. `docker-reload` restarts the app and waits for it to report healthy again.
|
|
||||||
|
|
||||||
Rebuild the image only when dependencies change:
|
Rebuild the image only when dependencies change:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make docker-build && make docker-up
|
make docker-build && make docker-up
|
||||||
```
|
```
|
||||||
|
|
||||||
A rebuild after a source-only change takes about 7 seconds. Dependencies install from `pyproject.toml` in a layer that source edits cannot invalidate, so `pip install` and the Chromium download stay cached; only the source copy and the final project install re-run.
|
|
||||||
|
|
||||||
## Release branch
|
## Release branch
|
||||||
|
|
||||||
`make deploy` fast-forwards the release branch:
|
`make deploy` fast-forwards the release branch:
|
||||||
|
|||||||
@ -28,10 +28,6 @@
|
|||||||
|
|
||||||
<div class="post-detail-content rendered-content">{{ render_content(post['content'], author_is_admin=is_admin(author)) }}</div>
|
<div class="post-detail-content rendered-content">{{ render_content(post['content'], author_is_admin=is_admin(author)) }}</div>
|
||||||
|
|
||||||
{% if project_link %}
|
|
||||||
<a href="{{ project_link.url }}" class="project-link">Project: {{ project_link.name }}</a>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if attachments %}
|
{% if attachments %}
|
||||||
{% include "_attachment_display.html" %}
|
{% include "_attachment_display.html" %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@ -42,10 +42,10 @@
|
|||||||
<div class="profile-level-bar">
|
<div class="profile-level-bar">
|
||||||
<div class="level-label">
|
<div class="level-label">
|
||||||
<span>Progress to next level</span>
|
<span>Progress to next level</span>
|
||||||
<span>{{ xp_progress_pct }}%</span>
|
<span>{{ (profile_user.get('xp') or 0) % 100 }}%</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="bar">
|
<div class="bar">
|
||||||
<div class="bar-fill" style="--bar-pct: {{ xp_progress_pct }}%;"></div>
|
<div class="bar-fill" style="--bar-pct: {{ (profile_user.get('xp') or 0) % 100 }}%;"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -765,6 +765,3 @@ import { AwardGiver } from "{{ static_url('/static/js/AwardGiver.js') }}";
|
|||||||
new AwardGiver();
|
new AwardGiver();
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -102,18 +102,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<section class="project-devlog">
|
|
||||||
<h3 class="project-section-label">Devlog</h3>
|
|
||||||
{% if devlog_posts %}
|
|
||||||
{% for item in devlog_posts %}
|
|
||||||
{% set _author = item.author %}{% set _time = item.time_ago %}{% set _show_share = false %}{% set _show_comment_form = false %}{% include "_post_card.html" %}
|
|
||||||
{% endfor %}
|
|
||||||
{% set next_cursor = devlog_next_cursor %}{% include "_load_more.html" %}
|
|
||||||
{% else %}
|
|
||||||
<p class="empty-state">No devlog posts yet.</p>
|
|
||||||
{% endif %}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{% if is_owner %}
|
{% if is_owner %}
|
||||||
{% call modal('edit-project-modal', 'Edit Project') %}
|
{% call modal('edit-project-modal', 'Edit Project') %}
|
||||||
<form method="POST" action="/projects/edit/{{ project['slug'] or project['uid'] }}">
|
<form method="POST" action="/projects/edit/{{ project['slug'] or project['uid'] }}">
|
||||||
@ -196,6 +184,3 @@ if (actions) {
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -25,7 +25,6 @@ services:
|
|||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 120s
|
start_period: 120s
|
||||||
start_interval: 2s
|
|
||||||
|
|
||||||
nginx:
|
nginx:
|
||||||
build:
|
build:
|
||||||
|
|||||||
484
events.md
484
events.md
@ -1,484 +0,0 @@
|
|||||||
# Audit event catalogue
|
|
||||||
|
|
||||||
retoor <retoor@molodetz.nl>
|
|
||||||
|
|
||||||
Every state-changing action in DevPlace records one append-only row through `devplacepy/services/audit/record.py` (`record` for a request-scoped actor, `record_system` for a background one). This file is the authoritative catalogue of the 288 event keys currently emitted, grouped by the category `services/audit/categories.py` `category_for` resolves them to.
|
|
||||||
|
|
||||||
## Rules
|
|
||||||
|
|
||||||
- An event key is `<domain>.<noun>.<verb>` (or `<domain>.<verb>` when the domain is the noun). The domain is the first segment and MUST have an entry in `CATEGORY_BY_PREFIX`; an unmapped domain resolves to category `other` and is a bug.
|
|
||||||
- `target_type` names the entity acted upon, not the event (`gateway_quota`, not `gateway_quota_reset`).
|
|
||||||
- Every row carries a result: `success`, `failure`, `denied`. A refused action is recorded with `denied`, never dropped.
|
|
||||||
- Actor kinds: `user`, `guest`, `system`, `cli`, `service`. Origins: `web`, `api`, `devii`, `cli`, `service`, `scheduler`, `devrant`.
|
|
||||||
- The recorder never raises into its caller; a failed audit write is logged and swallowed so it can never break the action it describes.
|
|
||||||
- Some families are built from a variable at the call site (`vote.{target_type}.{direction}`, `comment.create.{target_type}`, `quiz.question.{action}`, `profile.customization.{scope}.toggle`, and the `create`/`edit`/`delete` verbs the shared `content.py` helpers emit per content type). Every member of those families is listed below individually.
|
|
||||||
- Adding an event means all three of: the key in this file, a `CATEGORY_BY_PREFIX` entry for a new domain, and the recorder call at the mutation point.
|
|
||||||
|
|
||||||
## Categories
|
|
||||||
|
|
||||||
| Category | Keys |
|
|
||||||
|---|---|
|
|
||||||
| `account` | 10 |
|
|
||||||
| `admin` | 21 |
|
|
||||||
| `ai` | 10 |
|
|
||||||
| `attachment` | 5 |
|
|
||||||
| `auth` | 9 |
|
|
||||||
| `backup` | 2 |
|
|
||||||
| `cli` | 42 |
|
|
||||||
| `container` | 12 |
|
|
||||||
| `content` | 37 |
|
|
||||||
| `database` | 4 |
|
|
||||||
| `devii` | 18 |
|
|
||||||
| `email` | 6 |
|
|
||||||
| `engagement` | 23 |
|
|
||||||
| `game` | 6 |
|
|
||||||
| `ingress` | 1 |
|
|
||||||
| `message` | 2 |
|
|
||||||
| `news` | 9 |
|
|
||||||
| `notification` | 4 |
|
|
||||||
| `project` | 11 |
|
|
||||||
| `project_files` | 14 |
|
|
||||||
| `pubsub` | 1 |
|
|
||||||
| `push` | 2 |
|
|
||||||
| `reward` | 4 |
|
|
||||||
| `security` | 3 |
|
|
||||||
| `service` | 5 |
|
|
||||||
| `social` | 10 |
|
|
||||||
| `telegram` | 5 |
|
|
||||||
| `tools` | 12 |
|
|
||||||
|
|
||||||
**Total: 288 keys.**
|
|
||||||
|
|
||||||
## Account and profile (`account`)
|
|
||||||
|
|
||||||
| Event key | Recorded in |
|
|
||||||
|---|---|
|
|
||||||
| `profile.ai_correction` | `routers/profile/ai_correction.py` |
|
|
||||||
| `profile.ai_modifier` | `routers/profile/ai_modifier.py` |
|
|
||||||
| `profile.api_key.regenerate` | `routers/profile/index.py` |
|
|
||||||
| `profile.avatar.regenerate` | `routers/profile/avatar.py` |
|
|
||||||
| `profile.customization.global.toggle` | `routers/profile/customization.py` |
|
|
||||||
| `profile.customization.pagetype.toggle` | `routers/profile/customization.py` |
|
|
||||||
| `profile.interactions` | `routers/profile/interactions.py`, `services/devii/actions/dispatcher.py` |
|
|
||||||
| `profile.notification.reset` | `routers/profile/notifications.py` |
|
|
||||||
| `profile.notification.toggle` | `routers/profile/notifications.py` |
|
|
||||||
| `profile.update` | `routers/devrant/auth.py`, `routers/profile/index.py` |
|
|
||||||
|
|
||||||
## Administration (`admin`)
|
|
||||||
|
|
||||||
| Event key | Recorded in |
|
|
||||||
|---|---|
|
|
||||||
| `admin.ai_quota.reset_all` | `routers/admin/aiquota.py` |
|
|
||||||
| `admin.ai_quota.reset_guests` | `routers/admin/aiquota.py` |
|
|
||||||
| `admin.backup.delete` | `routers/admin/backups.py` |
|
|
||||||
| `admin.backup.run` | `routers/admin/backups.py` |
|
|
||||||
| `admin.backup_schedule.create` | `routers/admin/backups.py` |
|
|
||||||
| `admin.backup_schedule.delete` | `routers/admin/backups.py` |
|
|
||||||
| `admin.backup_schedule.toggle` | `routers/admin/backups.py` |
|
|
||||||
| `admin.backup_schedule.update` | `routers/admin/backups.py` |
|
|
||||||
| `admin.devii_task.delete` | `routers/admin/devii_tasks.py` |
|
|
||||||
| `admin.devii_task.disable` | `routers/admin/devii_tasks.py` |
|
|
||||||
| `admin.game.era_end` | `routers/admin/game.py` |
|
|
||||||
| `admin.game.era_start` | `routers/admin/game.py` |
|
|
||||||
| `admin.notification.default` | `routers/admin/notifications.py` |
|
|
||||||
| `admin.setting.update` | `docs_api/groups/admin.py`, `routers/admin/settings.py` |
|
|
||||||
| `admin.trash.purge` | `routers/admin/trash.py` |
|
|
||||||
| `admin.trash.restore` | `routers/admin/trash.py` |
|
|
||||||
| `admin.user.active.disable` | `routers/admin/users.py` |
|
|
||||||
| `admin.user.active.enable` | `routers/admin/users.py` |
|
|
||||||
| `admin.user.ai_quota.reset` | `routers/admin/users.py` |
|
|
||||||
| `admin.user.password.reset` | `routers/admin/users.py` |
|
|
||||||
| `admin.user.role.change` | `routers/admin/users.py` |
|
|
||||||
|
|
||||||
## AI gateway (`ai`)
|
|
||||||
|
|
||||||
| Event key | Recorded in |
|
|
||||||
|---|---|
|
|
||||||
| `ai.clippy.chat` | `routers/devii.py` |
|
|
||||||
| `ai.gateway.call` | `services/openai_gateway/gateway.py`, `services/openai_gateway/usage.py` |
|
|
||||||
| `ai.quota.exceeded` | `routers/devii.py`, `services/openai_gateway/service.py` |
|
|
||||||
| `gateway.model.delete` | `routers/admin/gateway_configs.py` |
|
|
||||||
| `gateway.model.update` | `routers/admin/gateway_configs.py` |
|
|
||||||
| `gateway.provider.delete` | `routers/admin/gateway_configs.py` |
|
|
||||||
| `gateway.provider.update` | `routers/admin/gateway_configs.py` |
|
|
||||||
| `gateway.quota.reset` | `cli/gateway.py`, `routers/admin/gateway_configs.py` |
|
|
||||||
| `gateway.quota_rule.delete` | `cli/gateway.py`, `routers/admin/gateway_configs.py` |
|
|
||||||
| `gateway.quota_rule.update` | `cli/gateway.py`, `routers/admin/gateway_configs.py` |
|
|
||||||
|
|
||||||
## Attachments (`attachment`)
|
|
||||||
|
|
||||||
| Event key | Recorded in |
|
|
||||||
|---|---|
|
|
||||||
| `attachment.delete` | `routers/admin/media.py`, `routers/media.py` |
|
|
||||||
| `attachment.rename` | `routers/uploads.py` |
|
|
||||||
| `attachment.restore` | `routers/media.py` |
|
|
||||||
| `attachment.upload` | `routers/uploads.py` |
|
|
||||||
| `attachment.upload_url` | `routers/uploads.py` |
|
|
||||||
|
|
||||||
## Authentication (`auth`)
|
|
||||||
|
|
||||||
| Event key | Recorded in |
|
|
||||||
|---|---|
|
|
||||||
| `auth.account.disable` | `routers/devrant/auth.py` |
|
|
||||||
| `auth.login.failure` | `routers/auth/login.py`, `routers/devrant/auth.py` |
|
|
||||||
| `auth.login.success` | `routers/auth/login.py`, `routers/devrant/auth.py` |
|
|
||||||
| `auth.logout` | `routers/auth/logout.py` |
|
|
||||||
| `auth.password.forgot_request` | `routers/auth/forgotpassword.py` |
|
|
||||||
| `auth.password.reset_complete` | `routers/auth/resetpassword.py` |
|
|
||||||
| `auth.signup` | `routers/auth/signup.py`, `routers/devrant/auth.py` |
|
|
||||||
| `auth.token.failure` | `routers/auth/token.py` |
|
|
||||||
| `auth.token.issued` | `routers/auth/token.py` |
|
|
||||||
|
|
||||||
## Backups (`backup`)
|
|
||||||
|
|
||||||
| Event key | Recorded in |
|
|
||||||
|---|---|
|
|
||||||
| `job.backup.complete` | `services/backup/service.py` |
|
|
||||||
| `job.backup.failed` | `services/backup/service.py` |
|
|
||||||
|
|
||||||
## Command line (`cli`)
|
|
||||||
|
|
||||||
| Event key | Recorded in |
|
|
||||||
|---|---|
|
|
||||||
| `cli.apikey.backfill` | `cli/apikeys.py` |
|
|
||||||
| `cli.apikey.reset` | `cli/apikeys.py` |
|
|
||||||
| `cli.attachments.prune` | `cli/attachments.py` |
|
|
||||||
| `cli.backups.clear` | `cli/backups.py` |
|
|
||||||
| `cli.backups.prune` | `cli/backups.py` |
|
|
||||||
| `cli.backups.run` | `cli/backups.py` |
|
|
||||||
| `cli.containers.gc_workspaces` | `cli/containers.py` |
|
|
||||||
| `cli.containers.prune` | `cli/containers.py` |
|
|
||||||
| `cli.containers.prune_builds` | `cli/containers.py` |
|
|
||||||
| `cli.containers.reconcile` | `cli/containers.py` |
|
|
||||||
| `cli.deepsearch.clear` | `cli/jobs.py` |
|
|
||||||
| `cli.deepsearch.prune` | `cli/jobs.py` |
|
|
||||||
| `cli.devii.lessons.clear` | `cli/devii.py` |
|
|
||||||
| `cli.devii.lessons.prune` | `cli/devii.py` |
|
|
||||||
| `cli.devii.quota.reset` | `cli/devii.py` |
|
|
||||||
| `cli.devii.task.disable` | `cli/devii.py` |
|
|
||||||
| `cli.devii.task.prune` | `cli/devii.py` |
|
|
||||||
| `cli.emoji.sync` | `cli/migrate.py` |
|
|
||||||
| `cli.forks.clear` | `cli/jobs.py` |
|
|
||||||
| `cli.forks.prune` | `cli/jobs.py` |
|
|
||||||
| `cli.game.era.end` | `cli/game.py` |
|
|
||||||
| `cli.game.era.start` | `cli/game.py` |
|
|
||||||
| `cli.game.market.prune` | `cli/game.py` |
|
|
||||||
| `cli.game.steals.prune` | `cli/game.py` |
|
|
||||||
| `cli.isslop.analyze` | `cli/jobs.py` |
|
|
||||||
| `cli.isslop.clear` | `cli/jobs.py` |
|
|
||||||
| `cli.isslop.prune` | `cli/jobs.py` |
|
|
||||||
| `cli.messaging.prune_tickets` | `cli/messaging.py` |
|
|
||||||
| `cli.news.clear` | `cli/news.py` |
|
|
||||||
| `cli.news.sanitize` | `cli/news.py` |
|
|
||||||
| `cli.quiz.prune` | `cli/quiz.py` |
|
|
||||||
| `cli.role.set` | `cli/roles.py` |
|
|
||||||
| `cli.seo.clear` | `cli/jobs.py` |
|
|
||||||
| `cli.seo.prune` | `cli/jobs.py` |
|
|
||||||
| `cli.seo_meta.clear` | `cli/jobs.py` |
|
|
||||||
| `cli.seo_meta.prune` | `cli/jobs.py` |
|
|
||||||
| `cli.token.issue` | `cli/tokens.py` |
|
|
||||||
| `cli.token.prune` | `cli/tokens.py` |
|
|
||||||
| `cli.token.revoke` | `cli/tokens.py` |
|
|
||||||
| `cli.token.revoke_all` | `cli/tokens.py` |
|
|
||||||
| `cli.zips.clear` | `cli/jobs.py` |
|
|
||||||
| `cli.zips.prune` | `cli/jobs.py` |
|
|
||||||
|
|
||||||
## Containers (`container`)
|
|
||||||
|
|
||||||
| Event key | Recorded in |
|
|
||||||
|---|---|
|
|
||||||
| `container.instance.configure` | `routers/admin/containers.py`, `services/devii/actions/dispatcher.py` |
|
|
||||||
| `container.instance.create` | `routers/admin/containers.py`, `routers/projects/containers/instances.py` |
|
|
||||||
| `container.instance.delete` | `routers/admin/containers.py`, `routers/projects/containers/instances.py` |
|
|
||||||
| `container.instance.exec` | `routers/projects/containers/instances.py`, `services/devii/actions/dispatcher.py` |
|
|
||||||
| `container.instance.shell.close` | `routers/projects/containers/instances.py` |
|
|
||||||
| `container.instance.shell.open` | `routers/projects/containers/instances.py` |
|
|
||||||
| `container.instance.start` | `docs_api/groups/admin.py` |
|
|
||||||
| `container.instance.status` | `services/containers/service.py` |
|
|
||||||
| `container.instance.sync` | `routers/admin/containers.py`, `routers/projects/containers/instances.py` |
|
|
||||||
| `container.reconcile.action` | `services/containers/service.py` |
|
|
||||||
| `container.schedule.create` | `routers/projects/containers/schedules.py`, `services/devii/actions/dispatcher.py` |
|
|
||||||
| `container.schedule.delete` | `routers/projects/containers/schedules.py` |
|
|
||||||
|
|
||||||
## Content (`content`)
|
|
||||||
|
|
||||||
| Event key | Recorded in |
|
|
||||||
|---|---|
|
|
||||||
| `comment.create.gist` | `content.py` |
|
|
||||||
| `comment.create.issue` | `content.py` |
|
|
||||||
| `comment.create.news` | `content.py` |
|
|
||||||
| `comment.create.post` | `content.py` |
|
|
||||||
| `comment.create.project` | `content.py` |
|
|
||||||
| `comment.create.quiz` | `content.py` |
|
|
||||||
| `comment.delete` | `content.py`, `routers/comments.py` |
|
|
||||||
| `comment.edit` | `content.py`, `routers/comments.py` |
|
|
||||||
| `gist.create` | `content.py` |
|
|
||||||
| `gist.delete` | `content.py` |
|
|
||||||
| `gist.edit` | `content.py` |
|
|
||||||
| `issue.attachment.add` | `routers/issues/attachments.py` |
|
|
||||||
| `issue.attachment.delete` | `routers/issues/attachments.py` |
|
|
||||||
| `issue.comment` | `routers/issues/comment.py` |
|
|
||||||
| `issue.create` | `services/jobs/issue_create_service.py` |
|
|
||||||
| `issue.create.request` | `routers/issues/create.py` |
|
|
||||||
| `issue.planning.generate` | `services/jobs/planning_service.py` |
|
|
||||||
| `issue.planning.request` | `routers/issues/planning.py` |
|
|
||||||
| `issue.status` | `routers/issues/status.py` |
|
|
||||||
| `issue.sync.reply` | `services/gitea/service.py` |
|
|
||||||
| `issue.sync.status` | `services/gitea/service.py` |
|
|
||||||
| `post.create` | `content.py` |
|
|
||||||
| `post.delete` | `content.py` |
|
|
||||||
| `post.edit` | `content.py` |
|
|
||||||
| `quiz.attempt.answer` | `routers/quizzes/attempts.py` |
|
|
||||||
| `quiz.attempt.finish` | `routers/quizzes/attempts.py` |
|
|
||||||
| `quiz.attempt.start` | `routers/quizzes/attempts.py` |
|
|
||||||
| `quiz.create` | `content.py` |
|
|
||||||
| `quiz.delete` | `content.py` |
|
|
||||||
| `quiz.edit` | `content.py` |
|
|
||||||
| `quiz.grade.failed` | `routers/quizzes/attempts.py` |
|
|
||||||
| `quiz.import` | `routers/quizzes/index.py` |
|
|
||||||
| `quiz.publish` | `routers/quizzes/index.py` |
|
|
||||||
| `quiz.question.create` | `routers/quizzes/questions.py` |
|
|
||||||
| `quiz.question.delete` | `routers/quizzes/questions.py` |
|
|
||||||
| `quiz.question.edit` | `routers/quizzes/questions.py` |
|
|
||||||
| `quiz.question.reorder` | `routers/quizzes/questions.py` |
|
|
||||||
|
|
||||||
## Database API (`database`)
|
|
||||||
|
|
||||||
| Event key | Recorded in |
|
|
||||||
|---|---|
|
|
||||||
| `database.access.denied` | `routers/dbapi/_shared.py` |
|
|
||||||
| `database.nl.design` | `routers/dbapi/nl.py` |
|
|
||||||
| `database.query` | `routers/dbapi/query.py` |
|
|
||||||
| `database.query.async` | `routers/dbapi/query.py` |
|
|
||||||
|
|
||||||
## Devii assistant (`devii`)
|
|
||||||
|
|
||||||
| Event key | Recorded in |
|
|
||||||
|---|---|
|
|
||||||
| `devii.behavior.update` | `services/devii/actions/dispatcher.py` |
|
|
||||||
| `devii.customization.css.set` | `services/devii/actions/dispatcher.py` |
|
|
||||||
| `devii.customization.js.set` | `services/devii/actions/dispatcher.py` |
|
|
||||||
| `devii.customization.reset` | `services/devii/actions/dispatcher.py` |
|
|
||||||
| `devii.lesson.forget` | `services/devii/actions/dispatcher.py` |
|
|
||||||
| `devii.lesson.reflect` | `services/devii/actions/dispatcher.py` |
|
|
||||||
| `devii.notification.reset` | `services/devii/actions/dispatcher.py` |
|
|
||||||
| `devii.notification.set` | `services/devii/actions/dispatcher.py` |
|
|
||||||
| `devii.task.blocked` | `services/devii/tasks/scheduler.py` |
|
|
||||||
| `devii.task.create` | `services/devii/actions/dispatcher.py` |
|
|
||||||
| `devii.task.deferred` | `services/devii/tasks/scheduler.py` |
|
|
||||||
| `devii.task.delete` | `services/devii/actions/dispatcher.py` |
|
|
||||||
| `devii.task.execute` | `services/devii/tasks/scheduler.py` |
|
|
||||||
| `devii.task.update` | `services/devii/actions/dispatcher.py` |
|
|
||||||
| `devii.tool.create` | `services/devii/actions/dispatcher.py` |
|
|
||||||
| `devii.tool.delete` | `services/devii/actions/dispatcher.py` |
|
|
||||||
| `devii.tool.update` | `services/devii/actions/dispatcher.py` |
|
|
||||||
| `devii.turn` | `services/devii/session/core.py` |
|
|
||||||
|
|
||||||
## Email (`email`)
|
|
||||||
|
|
||||||
| Event key | Recorded in |
|
|
||||||
|---|---|
|
|
||||||
| `email.account.delete` | `services/devii/actions/dispatcher.py` |
|
|
||||||
| `email.account.set` | `services/devii/actions/dispatcher.py` |
|
|
||||||
| `email.message.delete` | `services/devii/actions/dispatcher.py` |
|
|
||||||
| `email.message.flag` | `services/devii/actions/dispatcher.py` |
|
|
||||||
| `email.message.move` | `services/devii/actions/dispatcher.py` |
|
|
||||||
| `email.send` | `services/devii/actions/dispatcher.py` |
|
|
||||||
|
|
||||||
## Engagement (`engagement`)
|
|
||||||
|
|
||||||
| Event key | Recorded in |
|
|
||||||
|---|---|
|
|
||||||
| `bookmark.add` | `content.py`, `routers/bookmarks.py` |
|
|
||||||
| `bookmark.remove` | `content.py`, `routers/bookmarks.py` |
|
|
||||||
| `poll.create` | `routers/posts.py` |
|
|
||||||
| `poll.vote.cast` | `routers/polls.py` |
|
|
||||||
| `poll.vote.change` | `routers/polls.py` |
|
|
||||||
| `poll.vote.clear` | `routers/polls.py` |
|
|
||||||
| `reaction.add` | `routers/reactions.py` |
|
|
||||||
| `reaction.remove` | `routers/reactions.py` |
|
|
||||||
| `vote.comment.clear` | `content.py` |
|
|
||||||
| `vote.comment.down` | `content.py` |
|
|
||||||
| `vote.comment.up` | `content.py` |
|
|
||||||
| `vote.gist.clear` | `content.py` |
|
|
||||||
| `vote.gist.down` | `content.py` |
|
|
||||||
| `vote.gist.up` | `content.py` |
|
|
||||||
| `vote.post.clear` | `content.py` |
|
|
||||||
| `vote.post.down` | `content.py` |
|
|
||||||
| `vote.post.up` | `content.py` |
|
|
||||||
| `vote.project.clear` | `content.py` |
|
|
||||||
| `vote.project.down` | `content.py` |
|
|
||||||
| `vote.project.up` | `content.py` |
|
|
||||||
| `vote.quiz.clear` | `content.py` |
|
|
||||||
| `vote.quiz.down` | `content.py` |
|
|
||||||
| `vote.quiz.up` | `content.py` |
|
|
||||||
|
|
||||||
## Code Farm (`game`)
|
|
||||||
|
|
||||||
| Event key | Recorded in |
|
|
||||||
|---|---|
|
|
||||||
| `game.cosmetic.buy` | `routers/game/index.py` |
|
|
||||||
| `game.defense.downgrade` | `routers/game/index.py` |
|
|
||||||
| `game.defense.upgrade` | `routers/game/index.py` |
|
|
||||||
| `game.grant.claim` | `routers/game/index.py` |
|
|
||||||
| `game.infrastructure.buy` | `routers/game/index.py` |
|
|
||||||
| `game.prestige` | `routers/game/index.py` |
|
|
||||||
|
|
||||||
## Container ingress (`ingress`)
|
|
||||||
|
|
||||||
| Event key | Recorded in |
|
|
||||||
|---|---|
|
|
||||||
| `proxy.access` | `routers/proxy.py` |
|
|
||||||
|
|
||||||
## Direct messages (`message`)
|
|
||||||
|
|
||||||
| Event key | Recorded in |
|
|
||||||
|---|---|
|
|
||||||
| `message.read_on_view` | `routers/messages.py` |
|
|
||||||
| `message.send` | `services/messaging/persist.py` |
|
|
||||||
|
|
||||||
## News (`news`)
|
|
||||||
|
|
||||||
| Event key | Recorded in |
|
|
||||||
|---|---|
|
|
||||||
| `news.delete` | `routers/admin/news.py` |
|
|
||||||
| `news.featured.toggle` | `routers/admin/news.py` |
|
|
||||||
| `news.landing.toggle` | `routers/admin/news.py` |
|
|
||||||
| `news.publish.toggle` | `routers/admin/news.py` |
|
|
||||||
| `news.service.draft` | `services/news/service.py` |
|
|
||||||
| `news.service.ingest` | `services/news/service.py` |
|
|
||||||
| `news.service.landing` | `services/news/service.py` |
|
|
||||||
| `news.service.publish` | `services/news/service.py` |
|
|
||||||
| `news.service.reject` | `services/news/service.py` |
|
|
||||||
|
|
||||||
## Notifications (`notification`)
|
|
||||||
|
|
||||||
| Event key | Recorded in |
|
|
||||||
|---|---|
|
|
||||||
| `notification.create` | `utils/notifications.py` |
|
|
||||||
| `notification.open` | `routers/notifications.py` |
|
|
||||||
| `notification.read.all` | `routers/devrant/notifs.py`, `routers/notifications.py` |
|
|
||||||
| `notification.read.one` | `routers/notifications.py` |
|
|
||||||
|
|
||||||
## Projects (`project`)
|
|
||||||
|
|
||||||
| Event key | Recorded in |
|
|
||||||
|---|---|
|
|
||||||
| `project.create` | `content.py` |
|
|
||||||
| `project.delete` | `content.py` |
|
|
||||||
| `project.edit` | `content.py` |
|
|
||||||
| `project.fork.complete` | `services/jobs/fork_service.py` |
|
|
||||||
| `project.fork.failed` | `services/jobs/fork_service.py` |
|
|
||||||
| `project.fork.request` | `routers/projects/index.py` |
|
|
||||||
| `project.readonly.disable` | `routers/projects/index.py` |
|
|
||||||
| `project.readonly.enable` | `routers/projects/index.py` |
|
|
||||||
| `project.visibility.private` | `routers/projects/index.py` |
|
|
||||||
| `project.visibility.public` | `routers/projects/index.py` |
|
|
||||||
| `project.zip.request` | `routers/projects/index.py` |
|
|
||||||
|
|
||||||
## Project files (`project_files`)
|
|
||||||
|
|
||||||
| Event key | Recorded in |
|
|
||||||
|---|---|
|
|
||||||
| `dir.create` | `routers/projects/files.py` |
|
|
||||||
| `file.append` | `routers/projects/files.py` |
|
|
||||||
| `file.delete` | `routers/projects/files.py` |
|
|
||||||
| `file.delete_lines` | `routers/projects/files.py` |
|
|
||||||
| `file.insert_lines` | `routers/projects/files.py` |
|
|
||||||
| `file.move` | `routers/projects/files.py` |
|
|
||||||
| `file.replace_lines` | `routers/projects/files.py` |
|
|
||||||
| `file.upload` | `routers/projects/files.py` |
|
|
||||||
| `file.write` | `routers/projects/files.py` |
|
|
||||||
| `file.write.create` | `routers/projects/files.py` |
|
|
||||||
| `file.write.overwrite` | `routers/projects/files.py` |
|
|
||||||
| `files.zip.request` | `routers/projects/files.py` |
|
|
||||||
| `job.zip.complete` | `services/jobs/zip_service.py` |
|
|
||||||
| `job.zip.failed` | `services/jobs/zip_service.py` |
|
|
||||||
|
|
||||||
## Pub/sub (`pubsub`)
|
|
||||||
|
|
||||||
| Event key | Recorded in |
|
|
||||||
|---|---|
|
|
||||||
| `pubsub.publish` | `routers/pubsub.py` |
|
|
||||||
|
|
||||||
## Web push (`push`)
|
|
||||||
|
|
||||||
| Event key | Recorded in |
|
|
||||||
|---|---|
|
|
||||||
| `push.subscribe` | `routers/push.py` |
|
|
||||||
| `push.update` | `routers/push.py` |
|
|
||||||
|
|
||||||
Both carry `metadata.provider` (`webpush`, `apns`, ...) plus `created`; `metadata.endpoint_host` is set for endpoint-based providers and `null` for token-based ones.
|
|
||||||
|
|
||||||
## Gamification (`reward`)
|
|
||||||
|
|
||||||
| Event key | Recorded in |
|
|
||||||
|---|---|
|
|
||||||
| `reward.badge.award` | `utils/badges.py` |
|
|
||||||
| `reward.level.up` | `utils/rewards.py` |
|
|
||||||
| `reward.streak.milestone` | `utils/rewards.py` |
|
|
||||||
| `reward.xp.grant` | `utils/rewards.py` |
|
|
||||||
|
|
||||||
## Security (`security`)
|
|
||||||
|
|
||||||
| Event key | Recorded in |
|
|
||||||
|---|---|
|
|
||||||
| `security.authz.denied` | `routers/admin/backups.py`, `services/devii/actions/dispatcher.py` |
|
|
||||||
| `security.maintenance.block` | `main.py` |
|
|
||||||
| `security.rate_limit.block` | `main.py` |
|
|
||||||
|
|
||||||
## Background services (`service`)
|
|
||||||
|
|
||||||
| Event key | Recorded in |
|
|
||||||
|---|---|
|
|
||||||
| `service.config.update` | `routers/admin/services.py` |
|
|
||||||
| `service.logs.clear` | `routers/admin/services.py` |
|
|
||||||
| `service.run_now` | `routers/admin/services.py` |
|
|
||||||
| `service.start` | `routers/admin/services.py` |
|
|
||||||
| `service.stop` | `routers/admin/services.py` |
|
|
||||||
|
|
||||||
## Social graph (`social`)
|
|
||||||
|
|
||||||
| Event key | Recorded in |
|
|
||||||
|---|---|
|
|
||||||
| `award.complete` | `services/jobs/award_service.py` |
|
|
||||||
| `award.failed` | `services/jobs/award_service.py` |
|
|
||||||
| `award.give` | `routers/profile/award.py` |
|
|
||||||
| `award.revoke` | `routers/admin/awards.py` |
|
|
||||||
| `follow.follow` | `routers/follow.py` |
|
|
||||||
| `follow.unfollow` | `routers/follow.py` |
|
|
||||||
| `relation.block` | `routers/relations.py` |
|
|
||||||
| `relation.mute` | `routers/relations.py` |
|
|
||||||
| `relation.unblock` | `routers/relations.py` |
|
|
||||||
| `relation.unmute` | `routers/relations.py` |
|
|
||||||
|
|
||||||
## Telegram (`telegram`)
|
|
||||||
|
|
||||||
| Event key | Recorded in |
|
|
||||||
|---|---|
|
|
||||||
| `telegram.pair.failure` | `services/telegram/bridge.py` |
|
|
||||||
| `telegram.pair.request` | `routers/profile/telegram.py` |
|
|
||||||
| `telegram.pair.success` | `services/telegram/bridge.py` |
|
|
||||||
| `telegram.send` | `services/devii/actions/dispatcher.py` |
|
|
||||||
| `telegram.unpair` | `routers/profile/telegram.py` |
|
|
||||||
|
|
||||||
## Developer tools (`tools`)
|
|
||||||
|
|
||||||
| Event key | Recorded in |
|
|
||||||
|---|---|
|
|
||||||
| `deepsearch.chat` | `routers/tools/deepsearch.py` |
|
|
||||||
| `deepsearch.run.complete` | `services/jobs/deepsearch/service.py` |
|
|
||||||
| `deepsearch.run.failed` | `services/jobs/deepsearch/service.py` |
|
|
||||||
| `deepsearch.run.request` | `routers/tools/deepsearch.py` |
|
|
||||||
| `isslop.run.complete` | `services/jobs/isslop/service.py` |
|
|
||||||
| `isslop.run.failed` | `services/jobs/isslop/service.py` |
|
|
||||||
| `isslop.run.request` | `routers/tools/isslop.py` |
|
|
||||||
| `seo.meta.failed` | `services/jobs/seo_meta_service.py` |
|
|
||||||
| `seo.meta.generate` | `services/jobs/seo_meta_service.py` |
|
|
||||||
| `seo.run.complete` | `services/jobs/seo/service.py` |
|
|
||||||
| `seo.run.failed` | `services/jobs/seo/service.py` |
|
|
||||||
| `seo.run.request` | `routers/tools/seo.py` |
|
|
||||||
|
|
||||||
## Mapped domains with no recorder
|
|
||||||
|
|
||||||
The `backup` prefix has a `CATEGORY_BY_PREFIX` entry but no call site emits a bare `backup.*` key - backup activity is recorded as `admin.backup*` (operator actions) and `job.backup.*` (worker outcomes), both of which resolve to the `backup` category through their own prefixes. Keep the mapping: it is what routes `job.backup.*` correctly.
|
|
||||||
@ -1,78 +0,0 @@
|
|||||||
# retoor <retoor@molodetz.nl>
|
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
import requests
|
|
||||||
|
|
||||||
from devplacepy.database import get_table, refresh_snapshot
|
|
||||||
from devplacepy.services.openai_gateway.usage import GATEWAY_LEDGER
|
|
||||||
from devplacepy.utils import generate_uid
|
|
||||||
from tests.api.admin.gateway.index import JSON_gateway, admin_session
|
|
||||||
from tests.conftest import BASE_URL
|
|
||||||
|
|
||||||
RESET_ALL_URL = f"{BASE_URL}/admin/ai-quota/reset-all"
|
|
||||||
|
|
||||||
_counter = [0]
|
|
||||||
|
|
||||||
|
|
||||||
def _owner():
|
|
||||||
_counter[0] += 1
|
|
||||||
return f"resetall{_counter[0]}-{generate_uid()}"
|
|
||||||
|
|
||||||
|
|
||||||
def _burn(owner_id, cost):
|
|
||||||
refresh_snapshot()
|
|
||||||
get_table(GATEWAY_LEDGER).insert(
|
|
||||||
{
|
|
||||||
"uid": generate_uid(),
|
|
||||||
"owner_kind": "user",
|
|
||||||
"owner_id": owner_id,
|
|
||||||
"app_reference": "typosaurus",
|
|
||||||
"cost_usd": cost,
|
|
||||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
refresh_snapshot()
|
|
||||||
|
|
||||||
|
|
||||||
def _spent(owner_id):
|
|
||||||
from devplacepy.services.openai_gateway import quota
|
|
||||||
|
|
||||||
refresh_snapshot()
|
|
||||||
quota._QUOTA_CACHE.clear()
|
|
||||||
return quota.spent_24h("user", owner_id, "typosaurus")
|
|
||||||
|
|
||||||
|
|
||||||
def test_reset_all_clears_the_gateway_spend(seeded_db):
|
|
||||||
owner = _owner()
|
|
||||||
_burn(owner, 3.0)
|
|
||||||
assert _spent(owner) == 3.0
|
|
||||||
response = admin_session(seeded_db).post(RESET_ALL_URL, allow_redirects=False)
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert _spent(owner) == 0.0
|
|
||||||
|
|
||||||
|
|
||||||
def test_reset_all_keeps_the_gateway_usage_history(seeded_db):
|
|
||||||
owner = _owner()
|
|
||||||
_burn(owner, 3.0)
|
|
||||||
before = get_table(GATEWAY_LEDGER).count(owner_id=owner)
|
|
||||||
admin_session(seeded_db).post(RESET_ALL_URL, allow_redirects=False)
|
|
||||||
refresh_snapshot()
|
|
||||||
assert get_table(GATEWAY_LEDGER).count(owner_id=owner) == before
|
|
||||||
|
|
||||||
|
|
||||||
def test_gateway_spend_after_reset_all_counts_again(seeded_db):
|
|
||||||
owner = _owner()
|
|
||||||
_burn(owner, 3.0)
|
|
||||||
admin_session(seeded_db).post(RESET_ALL_URL, allow_redirects=False)
|
|
||||||
_burn(owner, 0.25)
|
|
||||||
assert _spent(owner) == 0.25
|
|
||||||
|
|
||||||
|
|
||||||
def test_reset_all_requires_admin(seeded_db):
|
|
||||||
assert (
|
|
||||||
requests.post(
|
|
||||||
RESET_ALL_URL, headers=JSON_gateway, allow_redirects=False
|
|
||||||
).status_code
|
|
||||||
== 401
|
|
||||||
)
|
|
||||||
@ -1,162 +0,0 @@
|
|||||||
# retoor <retoor@molodetz.nl>
|
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
import requests
|
|
||||||
|
|
||||||
from devplacepy.database import get_table, refresh_snapshot
|
|
||||||
from devplacepy.services.openai_gateway.usage import GATEWAY_LEDGER
|
|
||||||
from devplacepy.utils import generate_uid
|
|
||||||
from tests.api.admin.gateway.index import (
|
|
||||||
JSON_gateway,
|
|
||||||
admin_session,
|
|
||||||
member_key,
|
|
||||||
)
|
|
||||||
from tests.conftest import BASE_URL
|
|
||||||
|
|
||||||
RESETS_URL = f"{BASE_URL}/admin/gateway/quota-resets"
|
|
||||||
|
|
||||||
_counter = [0]
|
|
||||||
|
|
||||||
|
|
||||||
def _owner():
|
|
||||||
_counter[0] += 1
|
|
||||||
return f"gwquota{_counter[0]}-{generate_uid()}"
|
|
||||||
|
|
||||||
|
|
||||||
def _burn(owner_id, app_reference, cost):
|
|
||||||
refresh_snapshot()
|
|
||||||
get_table(GATEWAY_LEDGER).insert(
|
|
||||||
{
|
|
||||||
"uid": generate_uid(),
|
|
||||||
"owner_kind": "user",
|
|
||||||
"owner_id": owner_id,
|
|
||||||
"app_reference": app_reference,
|
|
||||||
"cost_usd": cost,
|
|
||||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
refresh_snapshot()
|
|
||||||
|
|
||||||
|
|
||||||
def _spent(owner_id, app_reference):
|
|
||||||
from devplacepy.services.openai_gateway import quota
|
|
||||||
|
|
||||||
refresh_snapshot()
|
|
||||||
quota._QUOTA_CACHE.clear()
|
|
||||||
return quota.spent_24h("user", owner_id, app_reference)
|
|
||||||
|
|
||||||
|
|
||||||
def test_quota_reset_requires_admin(seeded_db):
|
|
||||||
assert (
|
|
||||||
requests.post(RESETS_URL, headers=JSON_gateway, allow_redirects=False).status_code
|
|
||||||
== 401
|
|
||||||
)
|
|
||||||
key = member_key()
|
|
||||||
assert (
|
|
||||||
requests.post(
|
|
||||||
RESETS_URL,
|
|
||||||
headers={**JSON_gateway, "X-API-KEY": key},
|
|
||||||
json={},
|
|
||||||
allow_redirects=False,
|
|
||||||
).status_code
|
|
||||||
== 403
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_admin_can_reset_a_scoped_spend(seeded_db):
|
|
||||||
owner = _owner()
|
|
||||||
_burn(owner, "appa", 2.5)
|
|
||||||
assert _spent(owner, "appa") == 2.5
|
|
||||||
response = admin_session(seeded_db).post(
|
|
||||||
RESETS_URL,
|
|
||||||
json={"owner_kind": "user", "owner_id": owner, "app_reference": "appa"},
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.json()["ok"] is True
|
|
||||||
assert _spent(owner, "appa") == 0.0
|
|
||||||
|
|
||||||
|
|
||||||
def test_the_reset_response_echoes_the_scope(seeded_db):
|
|
||||||
owner = _owner()
|
|
||||||
_burn(owner, "appa", 1.0)
|
|
||||||
payload = admin_session(seeded_db).post(
|
|
||||||
RESETS_URL,
|
|
||||||
json={"owner_kind": "user", "owner_id": owner, "app_reference": "appa"},
|
|
||||||
).json()["reset"]
|
|
||||||
assert payload["owner_kind"] == "user"
|
|
||||||
assert payload["owner_id"] == owner
|
|
||||||
assert payload["app_reference"] == "appa"
|
|
||||||
assert payload["reset_at"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_reset_leaves_another_caller_capped(seeded_db):
|
|
||||||
first, second = _owner(), _owner()
|
|
||||||
_burn(first, "appa", 2.0)
|
|
||||||
_burn(second, "appa", 2.0)
|
|
||||||
admin_session(seeded_db).post(
|
|
||||||
RESETS_URL,
|
|
||||||
json={"owner_kind": "user", "owner_id": first, "app_reference": "appa"},
|
|
||||||
)
|
|
||||||
assert _spent(first, "appa") == 0.0
|
|
||||||
assert _spent(second, "appa") == 2.0
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_reset_keeps_the_usage_history(seeded_db):
|
|
||||||
owner = _owner()
|
|
||||||
_burn(owner, "appa", 2.0)
|
|
||||||
before = get_table(GATEWAY_LEDGER).count(owner_id=owner)
|
|
||||||
admin_session(seeded_db).post(
|
|
||||||
RESETS_URL,
|
|
||||||
json={"owner_kind": "user", "owner_id": owner, "app_reference": "appa"},
|
|
||||||
)
|
|
||||||
refresh_snapshot()
|
|
||||||
assert get_table(GATEWAY_LEDGER).count(owner_id=owner) == before
|
|
||||||
|
|
||||||
|
|
||||||
def test_an_unscoped_reset_clears_every_caller(seeded_db):
|
|
||||||
owner = _owner()
|
|
||||||
_burn(owner, "appa", 2.0)
|
|
||||||
assert admin_session(seeded_db).post(RESETS_URL, json={}).status_code == 200
|
|
||||||
assert _spent(owner, "appa") == 0.0
|
|
||||||
|
|
||||||
|
|
||||||
def test_an_unknown_owner_kind_is_rejected(seeded_db):
|
|
||||||
response = admin_session(seeded_db).post(RESETS_URL, json={"owner_kind": "wizard"})
|
|
||||||
assert response.status_code == 400
|
|
||||||
assert response.json()["ok"] is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_malformed_app_reference_is_rejected(seeded_db):
|
|
||||||
response = admin_session(seeded_db).post(
|
|
||||||
RESETS_URL, json={"app_reference": "not a valid app!"}
|
|
||||||
)
|
|
||||||
assert response.status_code == 400
|
|
||||||
|
|
||||||
|
|
||||||
def test_the_reset_is_audited(seeded_db):
|
|
||||||
owner = _owner()
|
|
||||||
_burn(owner, "appa", 1.0)
|
|
||||||
admin = admin_session(seeded_db)
|
|
||||||
admin.post(
|
|
||||||
RESETS_URL,
|
|
||||||
json={"owner_kind": "user", "owner_id": owner, "app_reference": "appa"},
|
|
||||||
)
|
|
||||||
refresh_snapshot()
|
|
||||||
entries = admin.get(
|
|
||||||
f"{BASE_URL}/admin/audit-log",
|
|
||||||
headers=JSON_gateway,
|
|
||||||
params={"event_key": "gateway.quota.reset"},
|
|
||||||
).json()["entries"]
|
|
||||||
assert any(entry.get("target_type") == "gateway_quota" for entry in entries)
|
|
||||||
|
|
||||||
|
|
||||||
def test_spend_recorded_after_a_reset_counts_again(seeded_db):
|
|
||||||
owner = _owner()
|
|
||||||
_burn(owner, "appa", 2.0)
|
|
||||||
admin_session(seeded_db).post(
|
|
||||||
RESETS_URL,
|
|
||||||
json={"owner_kind": "user", "owner_id": owner, "app_reference": "appa"},
|
|
||||||
)
|
|
||||||
_burn(owner, "appa", 0.5)
|
|
||||||
assert _spent(owner, "appa") == 0.5
|
|
||||||
@ -1,12 +1,10 @@
|
|||||||
# retoor <retoor@molodetz.nl>
|
# retoor <retoor@molodetz.nl>
|
||||||
|
|
||||||
import time
|
import time
|
||||||
from datetime import datetime, timezone
|
|
||||||
import pytest
|
import pytest
|
||||||
import requests
|
import requests
|
||||||
from tests.conftest import BASE_URL
|
from tests.conftest import BASE_URL
|
||||||
from devplacepy.database import get_table, refresh_snapshot, set_setting
|
from devplacepy.database import get_table, refresh_snapshot, set_setting
|
||||||
from devplacepy.utils import generate_uid, make_combined_slug
|
|
||||||
|
|
||||||
_counter_dr = [0]
|
_counter_dr = [0]
|
||||||
|
|
||||||
@ -48,13 +46,10 @@ def _register(password="secret123"):
|
|||||||
raise AssertionError("registration did not open")
|
raise AssertionError("registration did not open")
|
||||||
|
|
||||||
|
|
||||||
def _create_rant(params, text="this is a devrant rant body", tags="dev,test", project_uid=None):
|
def _create_rant(params, text="this is a devrant rant body", tags="dev,test"):
|
||||||
data = {**params, "rant": text, "tags": tags}
|
|
||||||
if project_uid:
|
|
||||||
data["project_uid"] = project_uid
|
|
||||||
r = requests.post(
|
r = requests.post(
|
||||||
f"{BASE_URL}/api/devrant/rants",
|
f"{BASE_URL}/api/devrant/rants",
|
||||||
data=data,
|
data={**params, "rant": text, "tags": tags},
|
||||||
)
|
)
|
||||||
return r
|
return r
|
||||||
|
|
||||||
@ -240,43 +235,3 @@ def test_search_returns_results_shape(app_server):
|
|||||||
r = requests.get(f"{BASE_URL}/api/devrant/search", params={"term": "uniquesearchtoken"})
|
r = requests.get(f"{BASE_URL}/api/devrant/search", params={"term": "uniquesearchtoken"})
|
||||||
assert r.json()["success"] is True
|
assert r.json()["success"] is True
|
||||||
assert isinstance(r.json()["results"], list)
|
assert isinstance(r.json()["results"], list)
|
||||||
|
|
||||||
|
|
||||||
def test_rant_serialization_includes_project(app_server):
|
|
||||||
name, params = _register()
|
|
||||||
refresh_snapshot()
|
|
||||||
user = get_table("users").find_one(username=name)
|
|
||||||
uid = user["uid"]
|
|
||||||
project_uid = generate_uid()
|
|
||||||
slug = make_combined_slug("project-for-rant", project_uid)
|
|
||||||
projects = get_table("projects")
|
|
||||||
projects.insert({
|
|
||||||
"uid": project_uid,
|
|
||||||
"user_uid": uid,
|
|
||||||
"slug": slug,
|
|
||||||
"title": "Project For Rant",
|
|
||||||
"description": "project linked to a rant",
|
|
||||||
"project_type": "software",
|
|
||||||
"status": "In Development",
|
|
||||||
"stars": 0,
|
|
||||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
||||||
"deleted_at": None,
|
|
||||||
"deleted_by": None,
|
|
||||||
})
|
|
||||||
refresh_snapshot()
|
|
||||||
rant_id = _create_rant(
|
|
||||||
params,
|
|
||||||
text="rant with a project link here",
|
|
||||||
tags="dev",
|
|
||||||
project_uid=project_uid,
|
|
||||||
).json()["rant_id"]
|
|
||||||
refresh_snapshot()
|
|
||||||
rant = requests.get(
|
|
||||||
f"{BASE_URL}/api/devrant/rants/{rant_id}",
|
|
||||||
params=params,
|
|
||||||
).json()["rant"]
|
|
||||||
assert rant.get("project_uid") == project_uid
|
|
||||||
assert rant.get("project") is not None
|
|
||||||
assert rant["project"]["uid"] == project_uid
|
|
||||||
assert rant["project"]["name"] == "Project For Rant"
|
|
||||||
assert "/projects/" in rant["project"]["url"]
|
|
||||||
|
|||||||
@ -716,69 +716,3 @@ def test_short_post_content_rejected(app_server):
|
|||||||
allow_redirects=False,
|
allow_redirects=False,
|
||||||
)
|
)
|
||||||
assert "/posts/" not in (r.headers.get("location") or "")
|
assert "/posts/" not in (r.headers.get("location") or "")
|
||||||
|
|
||||||
|
|
||||||
def _new_project(session):
|
|
||||||
return session.post(
|
|
||||||
f"{BASE_URL}/projects/create",
|
|
||||||
headers=JSON_audit_log,
|
|
||||||
data={
|
|
||||||
"title": _unique("aupj"),
|
|
||||||
"description": "project for linked post test",
|
|
||||||
"project_type": "software",
|
|
||||||
"status": "In Development",
|
|
||||||
"platforms": "",
|
|
||||||
},
|
|
||||||
).json()["data"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_post_detail_includes_project_when_linked(app_server):
|
|
||||||
s, _ = _member()
|
|
||||||
project = _new_project(s)
|
|
||||||
project_uid = project["uid"]
|
|
||||||
created = s.post(
|
|
||||||
f"{BASE_URL}/posts/create",
|
|
||||||
headers=JSON_audit_log,
|
|
||||||
data={
|
|
||||||
"title": _unique("aupjpost"),
|
|
||||||
"content": "this post is linked to a project",
|
|
||||||
"topic": "devlog",
|
|
||||||
"project_uid": project_uid,
|
|
||||||
},
|
|
||||||
).json()["data"]
|
|
||||||
slug = created["slug"]
|
|
||||||
detail = s.get(
|
|
||||||
f"{BASE_URL}/posts/{slug}",
|
|
||||||
headers=JSON_audit_log,
|
|
||||||
).json()
|
|
||||||
assert detail.get("project_link") is not None, "linked project must appear in post detail"
|
|
||||||
assert detail["project_link"]["uid"] == project_uid
|
|
||||||
assert detail["project_link"]["name"] is not None
|
|
||||||
assert "/projects/" in detail["project_link"]["url"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_feed_includes_project_when_linked(app_server):
|
|
||||||
s, _ = _member()
|
|
||||||
project = _new_project(s)
|
|
||||||
project_uid = project["uid"]
|
|
||||||
s.post(
|
|
||||||
f"{BASE_URL}/posts/create",
|
|
||||||
headers=JSON_audit_log,
|
|
||||||
data={
|
|
||||||
"title": _unique("aupjfeed"),
|
|
||||||
"content": "this post appears in feed with a project link",
|
|
||||||
"topic": "devlog",
|
|
||||||
"project_uid": project_uid,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
feed = s.get(
|
|
||||||
f"{BASE_URL}/feed",
|
|
||||||
headers=JSON_audit_log,
|
|
||||||
).json()
|
|
||||||
found = False
|
|
||||||
for item in feed["posts"]:
|
|
||||||
if item.get("project_link") is not None and item["project_link"]["uid"] == project_uid:
|
|
||||||
found = True
|
|
||||||
assert "/projects/" in item["project_link"]["url"]
|
|
||||||
break
|
|
||||||
assert found, "feed must include project info for linked posts"
|
|
||||||
|
|||||||
@ -234,6 +234,7 @@ def test_activity_comment_url_matches_notification_target(app_server):
|
|||||||
|
|
||||||
|
|
||||||
def test_activity_post_exposes_url(app_server):
|
def test_activity_post_exposes_url(app_server):
|
||||||
|
from datetime import datetime, timezone
|
||||||
from devplacepy.database import get_table, refresh_snapshot
|
from devplacepy.database import get_table, refresh_snapshot
|
||||||
|
|
||||||
owner = _seed_owner()
|
owner = _seed_owner()
|
||||||
@ -406,96 +407,3 @@ def test_viewing_profile_marks_notification_read(app_server):
|
|||||||
|
|
||||||
refresh_snapshot()
|
refresh_snapshot()
|
||||||
assert bool(get_table("notifications").find_one(uid=notif_uid)["read"]) is True
|
assert bool(get_table("notifications").find_one(uid=notif_uid)["read"]) is True
|
||||||
|
|
||||||
|
|
||||||
def test_own_profile_json_exposes_xp_fields(app_server, seeded_db):
|
|
||||||
"""GET /profile (own) with Accept: application/json includes xp_next_level and xp_progress_pct."""
|
|
||||||
import requests
|
|
||||||
|
|
||||||
session = requests.Session()
|
|
||||||
session.post(
|
|
||||||
f"{BASE_URL}/auth/login",
|
|
||||||
data={
|
|
||||||
"email": seeded_db["alice"]["email"],
|
|
||||||
"password": seeded_db["alice"]["password"],
|
|
||||||
},
|
|
||||||
allow_redirects=True,
|
|
||||||
)
|
|
||||||
r = session.get(f"{BASE_URL}/profile", headers={"Accept": "application/json"})
|
|
||||||
assert r.status_code == 200
|
|
||||||
data = r.json()
|
|
||||||
pu = data["profile_user"]
|
|
||||||
assert "xp_next_level" in pu, "xp_next_level missing from own profile JSON"
|
|
||||||
assert "xp_progress_pct" in pu, "xp_progress_pct missing from own profile JSON"
|
|
||||||
assert isinstance(pu["xp_next_level"], int)
|
|
||||||
assert isinstance(pu["xp_progress_pct"], int)
|
|
||||||
|
|
||||||
|
|
||||||
def test_other_profile_json_exposes_xp_fields(app_server):
|
|
||||||
"""GET /profile/{username} with Accept: application/json includes xp_next_level and xp_progress_pct."""
|
|
||||||
from devplacepy.database import get_table, refresh_snapshot
|
|
||||||
|
|
||||||
owner = _seed_owner()
|
|
||||||
username = get_table("users").find_one(uid=owner)["username"]
|
|
||||||
refresh_snapshot()
|
|
||||||
|
|
||||||
r = requests.get(
|
|
||||||
f"{BASE_URL}/profile/{username}", headers={"Accept": "application/json"}
|
|
||||||
)
|
|
||||||
assert r.status_code == 200
|
|
||||||
data = r.json()
|
|
||||||
pu = data["profile_user"]
|
|
||||||
assert "xp_next_level" in pu, "xp_next_level missing from other profile JSON"
|
|
||||||
assert "xp_progress_pct" in pu, "xp_progress_pct missing from other profile JSON"
|
|
||||||
assert isinstance(pu["xp_next_level"], int)
|
|
||||||
assert isinstance(pu["xp_progress_pct"], int)
|
|
||||||
|
|
||||||
|
|
||||||
def test_profile_json_xp_fields_zero_xp(app_server):
|
|
||||||
"""User with 0 XP returns xp_next_level=100 (level 1) and xp_progress_pct=0."""
|
|
||||||
from devplacepy.database import get_table, refresh_snapshot
|
|
||||||
from devplacepy.utils.rewards import LEVEL_XP
|
|
||||||
|
|
||||||
owner = _seed_owner()
|
|
||||||
username = get_table("users").find_one(uid=owner)["username"]
|
|
||||||
refresh_snapshot()
|
|
||||||
|
|
||||||
r = requests.get(
|
|
||||||
f"{BASE_URL}/profile/{username}", headers={"Accept": "application/json"}
|
|
||||||
)
|
|
||||||
assert r.status_code == 200
|
|
||||||
pu = r.json()["profile_user"]
|
|
||||||
assert pu["xp"] == 0 or pu["xp"] is None, f"expected 0 xp, got {pu['xp']}"
|
|
||||||
assert pu["xp_next_level"] == LEVEL_XP, (
|
|
||||||
f"expected xp_next_level={LEVEL_XP} for level 1, got {pu['xp_next_level']}"
|
|
||||||
)
|
|
||||||
assert pu["xp_progress_pct"] == 0, (
|
|
||||||
f"expected xp_progress_pct=0 for 0 XP, got {pu['xp_progress_pct']}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_profile_json_xp_fields_boundary_xp(app_server):
|
|
||||||
"""User with exactly 100 XP (level 2) returns xp_next_level=200 and xp_progress_pct=0."""
|
|
||||||
from devplacepy.database import get_table, refresh_snapshot
|
|
||||||
from devplacepy.utils.rewards import award_xp
|
|
||||||
|
|
||||||
owner = _seed_owner()
|
|
||||||
username = get_table("users").find_one(uid=owner)["username"]
|
|
||||||
award_xp(owner, 100)
|
|
||||||
refresh_snapshot()
|
|
||||||
|
|
||||||
r = requests.get(
|
|
||||||
f"{BASE_URL}/profile/{username}", headers={"Accept": "application/json"}
|
|
||||||
)
|
|
||||||
assert r.status_code == 200
|
|
||||||
pu = r.json()["profile_user"]
|
|
||||||
assert pu["xp"] == 100, f"expected 100 xp, got {pu['xp']}"
|
|
||||||
assert pu["level"] == 2, f"expected level 2, got {pu['level']}"
|
|
||||||
assert pu["xp_next_level"] == 200, (
|
|
||||||
f"expected xp_next_level=200 for level 2, got {pu['xp_next_level']}"
|
|
||||||
)
|
|
||||||
assert pu["xp_progress_pct"] == 0, (
|
|
||||||
f"expected xp_progress_pct=0 at boundary, got {pu['xp_progress_pct']}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -271,43 +271,3 @@ def test_profile_renders_heatmap_and_streak(app_server):
|
|||||||
html = s.get(f"{BASE_URL}/profile/{name}").text
|
html = s.get(f"{BASE_URL}/profile/{name}").text
|
||||||
assert "heatmap-grid" in html
|
assert "heatmap-grid" in html
|
||||||
assert "1 day streak" in html
|
assert "1 day streak" in html
|
||||||
|
|
||||||
|
|
||||||
def test_profile_badges_json_has_non_null_names(app_server):
|
|
||||||
import time
|
|
||||||
from devplacepy.database import get_table, refresh_snapshot
|
|
||||||
from devplacepy.utils import award_badge
|
|
||||||
|
|
||||||
name = f"bdg{int(time.time() * 1000)}"
|
|
||||||
session = requests.Session()
|
|
||||||
session.post(
|
|
||||||
f"{BASE_URL}/auth/signup",
|
|
||||||
data={
|
|
||||||
"username": name,
|
|
||||||
"email": f"{name}@t.dev",
|
|
||||||
"password": "secret123",
|
|
||||||
"confirm_password": "secret123",
|
|
||||||
},
|
|
||||||
allow_redirects=True,
|
|
||||||
)
|
|
||||||
refresh_snapshot()
|
|
||||||
user = get_table("users").find_one(username=name)
|
|
||||||
assert user, f"user {name} not found after signup"
|
|
||||||
award_badge(user["uid"], "First Post")
|
|
||||||
award_badge(user["uid"], "Member")
|
|
||||||
refresh_snapshot()
|
|
||||||
r = session.get(
|
|
||||||
f"{BASE_URL}/profile/{name}", headers={"Accept": "application/json"}
|
|
||||||
)
|
|
||||||
assert r.status_code == 200
|
|
||||||
body = r.json()
|
|
||||||
assert "badges" in body, "badges key missing from profile JSON"
|
|
||||||
assert isinstance(body["badges"], list), "badges is not a list"
|
|
||||||
assert len(body["badges"]) >= 2, f"expected at least 2 badges, got {len(body['badges'])}"
|
|
||||||
for badge in body["badges"]:
|
|
||||||
assert "name" in badge, f"badge missing name key: {badge}"
|
|
||||||
assert badge["name"] is not None, f"badge name is null: {badge}"
|
|
||||||
assert isinstance(badge["name"], str), f"badge name is not a string: {badge}"
|
|
||||||
assert len(badge["name"]) > 0, f"badge name is empty: {badge}"
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -1,271 +0,0 @@
|
|||||||
# retoor <retoor@molodetz.nl>
|
|
||||||
|
|
||||||
import time
|
|
||||||
from datetime import datetime, timezone, timedelta
|
|
||||||
import pytest
|
|
||||||
import requests
|
|
||||||
from tests.conftest import BASE_URL
|
|
||||||
from devplacepy.database import get_table, refresh_snapshot, set_setting
|
|
||||||
from devplacepy.utils import generate_uid
|
|
||||||
|
|
||||||
|
|
||||||
JSON = {"Accept": "application/json"}
|
|
||||||
_counter = [0]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="module", autouse=True)
|
|
||||||
def _devlog_test_settings(app_server):
|
|
||||||
for key, value in {
|
|
||||||
"rate_limit_per_minute": "1000000",
|
|
||||||
"rate_limit_window_seconds": "60",
|
|
||||||
"registration_open": "1",
|
|
||||||
"maintenance_mode": "0",
|
|
||||||
}.items():
|
|
||||||
set_setting(key, value)
|
|
||||||
yield
|
|
||||||
|
|
||||||
|
|
||||||
def _unique(prefix="dl"):
|
|
||||||
_counter[0] += 1
|
|
||||||
return f"{prefix}{int(time.time() * 1000)}{_counter[0]}"
|
|
||||||
|
|
||||||
|
|
||||||
def _member():
|
|
||||||
name = _unique("dlmem")
|
|
||||||
s = requests.Session()
|
|
||||||
s.post(
|
|
||||||
f"{BASE_URL}/auth/signup",
|
|
||||||
data={
|
|
||||||
"username": name,
|
|
||||||
"email": f"{name}@t.dev",
|
|
||||||
"password": "secret123",
|
|
||||||
"confirm_password": "secret123",
|
|
||||||
},
|
|
||||||
allow_redirects=True,
|
|
||||||
)
|
|
||||||
return s, name
|
|
||||||
|
|
||||||
|
|
||||||
def _db_user(name):
|
|
||||||
refresh_snapshot()
|
|
||||||
return get_table("users").find_one(username=name)
|
|
||||||
|
|
||||||
|
|
||||||
def _create_project(session, title=None):
|
|
||||||
title = title or _unique("dlproj")
|
|
||||||
r = session.post(
|
|
||||||
f"{BASE_URL}/projects/create",
|
|
||||||
headers=JSON,
|
|
||||||
data={
|
|
||||||
"title": title,
|
|
||||||
"description": "Devlog test project",
|
|
||||||
"project_type": "software",
|
|
||||||
"status": "In Development",
|
|
||||||
"platforms": "",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
assert r.status_code == 200, r.text[:300]
|
|
||||||
return r.json()["data"]
|
|
||||||
|
|
||||||
|
|
||||||
def _create_post(session, content, project_uid, title=None):
|
|
||||||
title = title or _unique("dlpost")
|
|
||||||
r = session.post(
|
|
||||||
f"{BASE_URL}/posts/create",
|
|
||||||
headers=JSON,
|
|
||||||
data={
|
|
||||||
"title": title,
|
|
||||||
"content": content,
|
|
||||||
"topic": "devlog",
|
|
||||||
"project_uid": project_uid,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
assert r.status_code == 200, r.text[:300]
|
|
||||||
return r.json()["data"]
|
|
||||||
|
|
||||||
|
|
||||||
def _create_post_direct(project_uid, user_uid, order, marker=None):
|
|
||||||
"""Insert a post directly into DB with precise created_at ordering."""
|
|
||||||
uid = generate_uid()
|
|
||||||
marker = marker or f"dlpost-{uid[:8]}"
|
|
||||||
get_table("posts").insert(
|
|
||||||
{
|
|
||||||
"deleted_at": None,
|
|
||||||
"deleted_by": None,
|
|
||||||
"uid": uid,
|
|
||||||
"user_uid": user_uid,
|
|
||||||
"slug": f"{uid[:8]}-devlog-post",
|
|
||||||
"title": marker,
|
|
||||||
"content": f"Devlog post content {order}",
|
|
||||||
"topic": "devlog",
|
|
||||||
"project_uid": project_uid,
|
|
||||||
"image": None,
|
|
||||||
"stars": 0,
|
|
||||||
"created_at": (datetime(2026, 1, 1, tzinfo=timezone.utc) + timedelta(seconds=order)).isoformat(),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
refresh_snapshot()
|
|
||||||
return uid, marker
|
|
||||||
|
|
||||||
|
|
||||||
def test_devlog_empty_state_when_no_posts(app_server):
|
|
||||||
"""Project with no linked posts returns empty devlog_posts list."""
|
|
||||||
session, _ = _member()
|
|
||||||
project = _create_project(session)
|
|
||||||
slug = project["slug"] or project["uid"]
|
|
||||||
|
|
||||||
r = session.get(f"{BASE_URL}/projects/{slug}", headers=JSON)
|
|
||||||
assert r.status_code == 200, r.text[:300]
|
|
||||||
body = r.json()
|
|
||||||
assert body["devlog_posts"] == []
|
|
||||||
assert body["devlog_next_cursor"] is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_devlog_shows_linked_post(app_server):
|
|
||||||
"""A post linked via project_uid appears in the project's devlog."""
|
|
||||||
session, name = _member()
|
|
||||||
project = _create_project(session)
|
|
||||||
slug = project["slug"] or project["uid"]
|
|
||||||
|
|
||||||
marker = _unique("dllink")
|
|
||||||
_create_post(session, marker, project["uid"], title=marker)
|
|
||||||
|
|
||||||
r = session.get(f"{BASE_URL}/projects/{slug}", headers=JSON)
|
|
||||||
assert r.status_code == 200, r.text[:300]
|
|
||||||
body = r.json()
|
|
||||||
assert len(body["devlog_posts"]) == 1
|
|
||||||
assert body["devlog_next_cursor"] is None
|
|
||||||
post_item = body["devlog_posts"][0]
|
|
||||||
assert post_item["post"]["title"] == marker
|
|
||||||
assert post_item["author"]["username"] == name
|
|
||||||
|
|
||||||
|
|
||||||
def test_devlog_reverse_chronological_order(app_server):
|
|
||||||
"""Multiple linked posts appear newest-first."""
|
|
||||||
session, name = _member()
|
|
||||||
project = _create_project(session)
|
|
||||||
slug = project["slug"] or project["uid"]
|
|
||||||
user = _db_user(name)
|
|
||||||
project_uid = project["uid"]
|
|
||||||
|
|
||||||
markers = []
|
|
||||||
for i in range(3):
|
|
||||||
marker = f"dlorder-{i}-{generate_uid()[:8]}"
|
|
||||||
markers.append(marker)
|
|
||||||
_create_post_direct(project_uid, user["uid"], i, marker=marker)
|
|
||||||
|
|
||||||
r = session.get(f"{BASE_URL}/projects/{slug}", headers=JSON)
|
|
||||||
assert r.status_code == 200, r.text[:300]
|
|
||||||
body = r.json()
|
|
||||||
titles = [item["post"]["title"] for item in body["devlog_posts"]]
|
|
||||||
assert titles == list(reversed(markers)), (
|
|
||||||
f"Expected newest-first order: {list(reversed(markers))}, got: {titles}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_devlog_pagination(app_server):
|
|
||||||
"""More than PAGE_SIZE posts produce next_cursor."""
|
|
||||||
from devplacepy.database.pagination import PAGE_SIZE
|
|
||||||
|
|
||||||
session, name = _member()
|
|
||||||
project = _create_project(session)
|
|
||||||
slug = project["slug"] or project["uid"]
|
|
||||||
user = _db_user(name)
|
|
||||||
project_uid = project["uid"]
|
|
||||||
|
|
||||||
count = PAGE_SIZE + 1
|
|
||||||
for i in range(count):
|
|
||||||
_create_post_direct(project_uid, user["uid"], i, marker=f"dlpag-{i}")
|
|
||||||
|
|
||||||
r = session.get(f"{BASE_URL}/projects/{slug}", headers=JSON)
|
|
||||||
assert r.status_code == 200, r.text[:300]
|
|
||||||
body = r.json()
|
|
||||||
assert len(body["devlog_posts"]) == PAGE_SIZE, (
|
|
||||||
f"Expected {PAGE_SIZE} posts on first page, got {len(body['devlog_posts'])}"
|
|
||||||
)
|
|
||||||
assert body["devlog_next_cursor"] is not None, (
|
|
||||||
"Expected next_cursor when more than PAGE_SIZE posts exist"
|
|
||||||
)
|
|
||||||
|
|
||||||
before = body["devlog_next_cursor"]
|
|
||||||
r2 = session.get(f"{BASE_URL}/projects/{slug}", headers=JSON, params={"before": before})
|
|
||||||
assert r2.status_code == 200, r2.text[:300]
|
|
||||||
body2 = r2.json()
|
|
||||||
assert len(body2["devlog_posts"]) == 1, (
|
|
||||||
f"Expected 1 post on second page, got {len(body2['devlog_posts'])}"
|
|
||||||
)
|
|
||||||
assert body2["devlog_next_cursor"] is None, (
|
|
||||||
"Expected no next_cursor on last page"
|
|
||||||
)
|
|
||||||
r_html = session.get(f"{BASE_URL}/projects/{slug}")
|
|
||||||
assert r_html.status_code == 200
|
|
||||||
assert 'class="load-more-wrap"' in r_html.text, (
|
|
||||||
"Expected Load More button in HTML for paginated devlog"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_devlog_excludes_unlinked_posts(app_server):
|
|
||||||
"""Posts without project_uid do not appear in any project's devlog."""
|
|
||||||
session, _ = _member()
|
|
||||||
project = _create_project(session)
|
|
||||||
slug = project["slug"] or project["uid"]
|
|
||||||
|
|
||||||
unlinked = _unique("dlnolink")
|
|
||||||
session.post(
|
|
||||||
f"{BASE_URL}/posts/create",
|
|
||||||
headers=JSON,
|
|
||||||
data={
|
|
||||||
"title": unlinked,
|
|
||||||
"content": "This post has no project",
|
|
||||||
"topic": "devlog",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
r = session.get(f"{BASE_URL}/projects/{slug}", headers=JSON)
|
|
||||||
assert r.status_code == 200, r.text[:300]
|
|
||||||
body = r.json()
|
|
||||||
titles = [item["post"]["title"] for item in body["devlog_posts"]]
|
|
||||||
assert unlinked not in titles, (
|
|
||||||
"Post without project_uid must not appear in devlog"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_devlog_enriches_author_and_metadata(app_server):
|
|
||||||
"""Devlog posts include author data, comment count, and vote info."""
|
|
||||||
session, name = _member()
|
|
||||||
user = _db_user(name)
|
|
||||||
project = _create_project(session)
|
|
||||||
slug = project["slug"] or project["uid"]
|
|
||||||
|
|
||||||
marker = _unique("dlenrich")
|
|
||||||
post_data = _create_post(session, marker, project["uid"], title=marker)
|
|
||||||
|
|
||||||
r = session.get(f"{BASE_URL}/projects/{slug}", headers=JSON)
|
|
||||||
assert r.status_code == 200, r.text[:300]
|
|
||||||
body = r.json()
|
|
||||||
assert len(body["devlog_posts"]) == 1
|
|
||||||
item = body["devlog_posts"][0]
|
|
||||||
|
|
||||||
assert item["author"]["username"] == name
|
|
||||||
assert item["author"]["uid"] == user["uid"]
|
|
||||||
assert isinstance(item["my_vote"], int)
|
|
||||||
assert isinstance(item["comment_count"], int)
|
|
||||||
assert item["comment_count"] == 0
|
|
||||||
assert item["post"]["uid"] == post_data["uid"]
|
|
||||||
assert item["post"]["slug"] == post_data["slug"]
|
|
||||||
assert item["post"]["title"] == marker
|
|
||||||
assert item["time_ago"] is not None
|
|
||||||
|
|
||||||
|
|
||||||
def test_devlog_works_for_guest_visitor(app_server):
|
|
||||||
"""Unauthenticated visitors can see the devlog section."""
|
|
||||||
session, _ = _member()
|
|
||||||
project = _create_project(session)
|
|
||||||
slug = project["slug"] or project["uid"]
|
|
||||||
|
|
||||||
r = requests.get(f"{BASE_URL}/projects/{slug}", headers=JSON)
|
|
||||||
assert r.status_code == 200, r.text[:300]
|
|
||||||
body = r.json()
|
|
||||||
assert "devlog_posts" in body
|
|
||||||
assert body["devlog_posts"] == []
|
|
||||||
|
|
||||||
@ -64,56 +64,3 @@ def test_register_invalid_json_rejected(app_server):
|
|||||||
headers={"Content-Type": "application/json"},
|
headers={"Content-Type": "application/json"},
|
||||||
)
|
)
|
||||||
assert r.status_code == 400
|
assert r.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
def test_public_key_endpoint_lists_providers(app_server):
|
|
||||||
r = requests.get(f"{BASE_URL}/push.json")
|
|
||||||
assert r.status_code == 200
|
|
||||||
body = r.json()
|
|
||||||
assert body["publicKey"]
|
|
||||||
assert body["providers"]["webpush"]["publicKey"] == body["publicKey"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_register_accepts_an_explicit_webpush_provider(app_server):
|
|
||||||
s = _session_push()
|
|
||||||
r = s.post(
|
|
||||||
f"{BASE_URL}/push.json",
|
|
||||||
json={
|
|
||||||
"provider": "webpush",
|
|
||||||
"endpoint": "https://push.example.com/explicit",
|
|
||||||
"keys": {"p256dh": "p256dh_fake", "auth": "auth_fake"},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
assert r.status_code == 200, r.text
|
|
||||||
assert r.json().get("registered") is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_register_is_idempotent_for_the_same_subscription(app_server):
|
|
||||||
s = _session_push()
|
|
||||||
body = {
|
|
||||||
"endpoint": "https://push.example.com/idempotent",
|
|
||||||
"keys": {"p256dh": "p256dh_fake", "auth": "auth_fake"},
|
|
||||||
}
|
|
||||||
assert s.post(f"{BASE_URL}/push.json", json=body).status_code == 200
|
|
||||||
assert s.post(f"{BASE_URL}/push.json", json=body).status_code == 200
|
|
||||||
|
|
||||||
|
|
||||||
def test_register_unknown_provider_rejected(app_server):
|
|
||||||
s = _session_push()
|
|
||||||
r = s.post(
|
|
||||||
f"{BASE_URL}/push.json",
|
|
||||||
json={"provider": "carrier-pigeon", "token": "a" * 64},
|
|
||||||
)
|
|
||||||
assert r.status_code == 400
|
|
||||||
|
|
||||||
|
|
||||||
def test_register_apns_rejected_while_unconfigured(app_server):
|
|
||||||
s = _session_push()
|
|
||||||
r = s.post(f"{BASE_URL}/push.json", json={"provider": "apns", "token": "a" * 64})
|
|
||||||
assert r.status_code == 400
|
|
||||||
|
|
||||||
|
|
||||||
def test_register_non_object_body_rejected(app_server):
|
|
||||||
s = _session_push()
|
|
||||||
r = s.post(f"{BASE_URL}/push.json", json=["nope"])
|
|
||||||
assert r.status_code == 400
|
|
||||||
|
|||||||
@ -89,53 +89,6 @@ def test_run_clamps_max_pages_above_cap(app_server):
|
|||||||
_clear_seo_jobs()
|
_clear_seo_jobs()
|
||||||
|
|
||||||
|
|
||||||
def test_run_rejects_malformed_scheme(app_server):
|
|
||||||
r = requests.post(
|
|
||||||
f"{BASE_URL}/tools/seo/run",
|
|
||||||
headers=_json_headers(),
|
|
||||||
data={"url": "ahttps://devplace.net/sitem", "mode": "url"},
|
|
||||||
allow_redirects=False,
|
|
||||||
)
|
|
||||||
assert r.status_code in (400, 422), r.text
|
|
||||||
|
|
||||||
|
|
||||||
def test_run_rejects_relative_path(app_server):
|
|
||||||
r = requests.post(
|
|
||||||
f"{BASE_URL}/tools/seo/run",
|
|
||||||
headers=_json_headers(),
|
|
||||||
data={"url": "/feed", "mode": "url"},
|
|
||||||
allow_redirects=False,
|
|
||||||
)
|
|
||||||
assert r.status_code in (400, 422), r.text
|
|
||||||
|
|
||||||
|
|
||||||
def test_run_normalizes_missing_scheme(app_server):
|
|
||||||
try:
|
|
||||||
r = requests.post(
|
|
||||||
f"{BASE_URL}/tools/seo/run",
|
|
||||||
headers=_json_headers(),
|
|
||||||
data={"url": "example.com", "mode": "url", "max_pages": "5"},
|
|
||||||
)
|
|
||||||
assert r.status_code == 200, r.text
|
|
||||||
uid = r.json()["uid"]
|
|
||||||
refresh_snapshot()
|
|
||||||
job = queue.get_job(uid)
|
|
||||||
assert job is not None
|
|
||||||
assert job["payload"]["url"] == "https://example.com"
|
|
||||||
finally:
|
|
||||||
_clear_seo_jobs()
|
|
||||||
|
|
||||||
|
|
||||||
def test_run_rejects_non_http_scheme(app_server):
|
|
||||||
r = requests.post(
|
|
||||||
f"{BASE_URL}/tools/seo/run",
|
|
||||||
headers=_json_headers(),
|
|
||||||
data={"url": "ftp://example.com", "mode": "url"},
|
|
||||||
allow_redirects=False,
|
|
||||||
)
|
|
||||||
assert r.status_code in (400, 422), r.text
|
|
||||||
|
|
||||||
|
|
||||||
def test_run_enforces_one_active_job_per_owner(app_server):
|
def test_run_enforces_one_active_job_per_owner(app_server):
|
||||||
try:
|
try:
|
||||||
first = requests.post(
|
first = requests.post(
|
||||||
|
|||||||
@ -1,188 +0,0 @@
|
|||||||
# retoor <retoor@molodetz.nl>
|
|
||||||
|
|
||||||
from uuid import uuid4
|
|
||||||
from datetime import datetime, timezone, timedelta
|
|
||||||
from playwright.sync_api import expect
|
|
||||||
from tests.conftest import BASE_URL
|
|
||||||
from devplacepy.database import get_table
|
|
||||||
from devplacepy.utils import make_combined_slug
|
|
||||||
|
|
||||||
|
|
||||||
def _seed_project():
|
|
||||||
"""Create a project seeded directly into DB and return (slug, uid, user_uid)."""
|
|
||||||
owner = str(uuid4())
|
|
||||||
get_table("users").insert(
|
|
||||||
{
|
|
||||||
"uid": owner,
|
|
||||||
"username": f"dlowner_{owner[:8]}",
|
|
||||||
"email": f"{owner[:8]}@dl.test",
|
|
||||||
"password_hash": "x",
|
|
||||||
"role": "Member",
|
|
||||||
"is_active": True,
|
|
||||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
uid = str(uuid4())
|
|
||||||
slug = make_combined_slug("E2E Devlog Project", uid)
|
|
||||||
get_table("projects").insert(
|
|
||||||
{
|
|
||||||
"deleted_at": None,
|
|
||||||
"deleted_by": None,
|
|
||||||
"uid": uid,
|
|
||||||
"user_uid": owner,
|
|
||||||
"slug": slug,
|
|
||||||
"title": "E2E Devlog Project",
|
|
||||||
"description": "Project for devlog browser tests.",
|
|
||||||
"project_type": "software",
|
|
||||||
"platforms": "",
|
|
||||||
"status": "In Development",
|
|
||||||
"stars": 0,
|
|
||||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return slug, uid, owner
|
|
||||||
|
|
||||||
|
|
||||||
def _seed_project_post(project_uid, user_uid, order, title=None):
|
|
||||||
"""Insert a post linked to a project with precise ordering."""
|
|
||||||
uid = str(uuid4())
|
|
||||||
marker = title or f"dlpost-{uid[:8]}"
|
|
||||||
get_table("posts").insert(
|
|
||||||
{
|
|
||||||
"deleted_at": None,
|
|
||||||
"deleted_by": None,
|
|
||||||
"uid": uid,
|
|
||||||
"user_uid": user_uid,
|
|
||||||
"slug": make_combined_slug(marker, uid),
|
|
||||||
"title": marker,
|
|
||||||
"content": f"Devlog post content {order}",
|
|
||||||
"topic": "devlog",
|
|
||||||
"project_uid": project_uid,
|
|
||||||
"image": None,
|
|
||||||
"stars": 0,
|
|
||||||
"created_at": (datetime(2026, 1, 1, tzinfo=timezone.utc) + timedelta(seconds=order)).isoformat(),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return marker, uid
|
|
||||||
|
|
||||||
|
|
||||||
def _create_project_ui(page, title):
|
|
||||||
"""Create a project via the UI."""
|
|
||||||
page.goto(f"{BASE_URL}/projects", wait_until="domcontentloaded")
|
|
||||||
page.locator("#create-project-btn").click()
|
|
||||||
page.fill("#title", title)
|
|
||||||
page.fill("#description", "Project for devlog UI test")
|
|
||||||
page.click("button:has-text('Create Project')")
|
|
||||||
page.wait_for_url(f"{BASE_URL}/projects/*", wait_until="domcontentloaded")
|
|
||||||
return page.url
|
|
||||||
|
|
||||||
|
|
||||||
def test_devlog_empty_state_on_project_page(alice):
|
|
||||||
"""Project with no linked posts displays 'No devlog posts yet'."""
|
|
||||||
page, _ = alice
|
|
||||||
_create_project_ui(page, "Empty Devlog Project")
|
|
||||||
|
|
||||||
devlog_section = page.locator(".project-devlog")
|
|
||||||
expect(devlog_section).to_be_visible()
|
|
||||||
expect(devlog_section.locator("h3:has-text('Devlog')")).to_be_visible()
|
|
||||||
expect(page.locator(".empty-state:has-text('No devlog posts yet.')")).to_be_visible()
|
|
||||||
|
|
||||||
|
|
||||||
def test_devlog_shows_linked_post_title(alice):
|
|
||||||
"""Linked post title renders in the devlog section."""
|
|
||||||
page, _ = alice
|
|
||||||
slug, project_uid, owner_uid = _seed_project()
|
|
||||||
|
|
||||||
marker = f"visible-dl-{uuid4().hex[:8]}"
|
|
||||||
_seed_project_post(project_uid, owner_uid, 0, title=marker)
|
|
||||||
|
|
||||||
page.goto(f"{BASE_URL}/projects/{slug}", wait_until="domcontentloaded")
|
|
||||||
|
|
||||||
devlog = page.locator(".project-devlog")
|
|
||||||
expect(devlog.locator(f"h3:has-text('{marker}')")).to_be_visible()
|
|
||||||
|
|
||||||
|
|
||||||
def test_devlog_shows_author_info(alice):
|
|
||||||
"""Author avatar, name and level appear on devlog posts."""
|
|
||||||
page, _ = alice
|
|
||||||
slug, project_uid, owner_uid = _seed_project()
|
|
||||||
|
|
||||||
marker = f"auth-dl-{uuid4().hex[:8]}"
|
|
||||||
_seed_project_post(project_uid, owner_uid, 0, title=marker)
|
|
||||||
|
|
||||||
page.goto(f"{BASE_URL}/projects/{slug}", wait_until="domcontentloaded")
|
|
||||||
|
|
||||||
post_card = page.locator(".project-devlog .post-card").first
|
|
||||||
expect(post_card.locator(".post-header")).to_be_visible()
|
|
||||||
expect(post_card.locator(".post-author-link")).to_be_visible()
|
|
||||||
expect(post_card.locator(".post-time")).to_be_visible()
|
|
||||||
|
|
||||||
|
|
||||||
def test_devlog_shows_vote_and_comment_buttons(alice):
|
|
||||||
"""Devlog post renders vote buttons and comment count."""
|
|
||||||
page, _ = alice
|
|
||||||
slug, project_uid, owner_uid = _seed_project()
|
|
||||||
|
|
||||||
marker = f"action-dl-{uuid4().hex[:8]}"
|
|
||||||
_seed_project_post(project_uid, owner_uid, 0, title=marker)
|
|
||||||
|
|
||||||
page.goto(f"{BASE_URL}/projects/{slug}", wait_until="domcontentloaded")
|
|
||||||
|
|
||||||
post_card = page.locator(".project-devlog .post-card").first
|
|
||||||
expect(post_card.locator(".post-action-btn.vote-up")).to_be_visible()
|
|
||||||
expect(post_card.locator(".post-action-btn.vote-down")).to_be_visible()
|
|
||||||
expect(post_card.locator(".post-vote-count")).to_be_visible()
|
|
||||||
expect(post_card.locator("form[action*='/posts/delete/']")).to_be_visible()
|
|
||||||
|
|
||||||
|
|
||||||
def test_devlog_load_more_appears_with_many_posts(alice):
|
|
||||||
"""More than PAGE_SIZE posts produces a Load More link."""
|
|
||||||
from devplacepy.database.pagination import PAGE_SIZE
|
|
||||||
|
|
||||||
page, _ = alice
|
|
||||||
slug, project_uid, owner_uid = _seed_project()
|
|
||||||
|
|
||||||
count = PAGE_SIZE + 1
|
|
||||||
for i in range(count):
|
|
||||||
_seed_project_post(project_uid, owner_uid, i, title=f"loadmore-{i}")
|
|
||||||
|
|
||||||
page.goto(f"{BASE_URL}/projects/{slug}", wait_until="domcontentloaded")
|
|
||||||
|
|
||||||
devlog = page.locator(".project-devlog")
|
|
||||||
expect(devlog.locator(".post-card")).to_have_count(PAGE_SIZE)
|
|
||||||
expect(devlog.locator(".load-more-wrap")).to_be_visible()
|
|
||||||
|
|
||||||
|
|
||||||
def test_devlog_guest_sees_devlog_section(app_server):
|
|
||||||
"""Unauthenticated visitors can see the devlog section."""
|
|
||||||
import requests
|
|
||||||
|
|
||||||
slug, _, _ = _seed_project()
|
|
||||||
|
|
||||||
r = requests.get(f"{BASE_URL}/projects/{slug}")
|
|
||||||
assert r.status_code == 200, r.text[:200]
|
|
||||||
|
|
||||||
assert "No devlog posts yet." in r.text
|
|
||||||
assert 'class="project-devlog"' in r.text
|
|
||||||
|
|
||||||
|
|
||||||
def test_devlog_multiple_posts_order(alice):
|
|
||||||
"""Posts appear newest-first in the devlog."""
|
|
||||||
page, _ = alice
|
|
||||||
slug, project_uid, owner_uid = _seed_project()
|
|
||||||
|
|
||||||
markers = []
|
|
||||||
for i in range(3):
|
|
||||||
marker = f"order-{i}-{uuid4().hex[:8]}"
|
|
||||||
markers.append(marker)
|
|
||||||
_seed_project_post(project_uid, owner_uid, i, title=marker)
|
|
||||||
|
|
||||||
page.goto(f"{BASE_URL}/projects/{slug}", wait_until="domcontentloaded")
|
|
||||||
|
|
||||||
titles = page.locator(".project-devlog .post-card .post-title")
|
|
||||||
expect(titles).to_have_count(3)
|
|
||||||
first_text = titles.nth(0).inner_text()
|
|
||||||
assert markers[-1] == first_text, (
|
|
||||||
f"Expected newest post first: {markers[-1]}, got: {first_text}"
|
|
||||||
)
|
|
||||||
|
|
||||||
@ -9,7 +9,7 @@ def test_primary_admin_ignores_accounts_without_a_signup_time(local_db):
|
|||||||
"uid": "pa-real",
|
"uid": "pa-real",
|
||||||
"username": "pa-real",
|
"username": "pa-real",
|
||||||
"role": "Admin",
|
"role": "Admin",
|
||||||
"created_at": "1990-01-01T00:00:00",
|
"created_at": "2020-01-01T00:00:00",
|
||||||
"deleted_at": None,
|
"deleted_at": None,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@ -45,9 +45,9 @@ def test_primary_admin_skips_deactivated_and_deleted_founders(local_db):
|
|||||||
|
|
||||||
users = local_db["users"]
|
users = local_db["users"]
|
||||||
seeded = [
|
seeded = [
|
||||||
("pa-deleted", "1991-01-01T00:00:00", True, "2020-01-01T00:00:00"),
|
("pa-deleted", "2001-01-01T00:00:00", True, "2020-01-01T00:00:00"),
|
||||||
("pa-inactive", "1992-01-01T00:00:00", False, None),
|
("pa-inactive", "2002-01-01T00:00:00", False, None),
|
||||||
("pa-usable", "1993-01-01T00:00:00", True, None),
|
("pa-usable", "2003-01-01T00:00:00", True, None),
|
||||||
]
|
]
|
||||||
for uid, created_at, active, deleted_at in seeded:
|
for uid, created_at, active, deleted_at in seeded:
|
||||||
users.insert(
|
users.insert(
|
||||||
|
|||||||
@ -48,298 +48,3 @@ def test_create_notification_authorization_is_jwt():
|
|||||||
|
|
||||||
token = push.create_notification_authorization("https://push.example.com/endpoint")
|
token = push.create_notification_authorization("https://push.example.com/endpoint")
|
||||||
assert token.count(".") == 2
|
assert token.count(".") == 2
|
||||||
|
|
||||||
|
|
||||||
def test_provider_registry_resolves_default_for_missing_name():
|
|
||||||
from devplacepy.push import providers
|
|
||||||
|
|
||||||
assert providers.get(None) is providers.PROVIDERS["webpush"]
|
|
||||||
assert providers.get("") is providers.PROVIDERS["webpush"]
|
|
||||||
assert providers.get(" APNS ") is providers.PROVIDERS["apns"]
|
|
||||||
assert providers.get("nope") is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_webpush_parse_registration_accepts_subscription_shape():
|
|
||||||
from devplacepy.push import providers
|
|
||||||
|
|
||||||
webpush = providers.PROVIDERS["webpush"]
|
|
||||||
fields = webpush.parse_registration(
|
|
||||||
{
|
|
||||||
"endpoint": "https://push.example.com/sub",
|
|
||||||
"expirationTime": None,
|
|
||||||
"keys": {"p256dh": "p", "auth": "a"},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
assert fields == {
|
|
||||||
"endpoint": "https://push.example.com/sub",
|
|
||||||
"key_auth": "a",
|
|
||||||
"key_p256dh": "p",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_webpush_parse_registration_rejects_incomplete_bodies():
|
|
||||||
from devplacepy.push import providers
|
|
||||||
|
|
||||||
webpush = providers.PROVIDERS["webpush"]
|
|
||||||
assert webpush.parse_registration({"endpoint": "https://push.example.com/x"}) is None
|
|
||||||
assert webpush.parse_registration({"keys": {"p256dh": "p", "auth": "a"}}) is None
|
|
||||||
assert (
|
|
||||||
webpush.parse_registration(
|
|
||||||
{"endpoint": "https://push.example.com/x", "keys": {"p256dh": "p"}}
|
|
||||||
)
|
|
||||||
is None
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_apns_parse_registration_validates_device_token():
|
|
||||||
from devplacepy.push import providers
|
|
||||||
|
|
||||||
apns = providers.PROVIDERS["apns"]
|
|
||||||
token = "a1b2c3d4" * 8
|
|
||||||
assert apns.parse_registration({"token": f" {token} "}) == {"token": token}
|
|
||||||
assert apns.parse_registration({"token": "abc"}) is None
|
|
||||||
assert apns.parse_registration({"token": "z" * 64}) is None
|
|
||||||
assert apns.parse_registration({"token": "a" * 500}) is None
|
|
||||||
assert apns.parse_registration({"token": None}) is None
|
|
||||||
assert apns.parse_registration({}) is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_apns_prepare_translates_the_shared_payload():
|
|
||||||
import json
|
|
||||||
from devplacepy.push import providers
|
|
||||||
|
|
||||||
body = json.loads(
|
|
||||||
providers.PROVIDERS["apns"].prepare(
|
|
||||||
{
|
|
||||||
"title": "DevPlace",
|
|
||||||
"message": "You have a new notification.",
|
|
||||||
"icon": "/static/apple-touch-icon.png",
|
|
||||||
"url": "/notifications",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
assert body["aps"]["alert"] == {
|
|
||||||
"title": "DevPlace",
|
|
||||||
"body": "You have a new notification.",
|
|
||||||
}
|
|
||||||
assert body["aps"]["thread-id"] == "devplace-notification"
|
|
||||||
assert body["url"] == "/notifications"
|
|
||||||
assert body["icon"] == "/static/apple-touch-icon.png"
|
|
||||||
|
|
||||||
|
|
||||||
def test_apns_prepare_survives_an_empty_payload():
|
|
||||||
import json
|
|
||||||
from devplacepy.push import providers
|
|
||||||
|
|
||||||
body = json.loads(providers.PROVIDERS["apns"].prepare({}))
|
|
||||||
assert body["aps"]["alert"]["title"] == "DevPlace"
|
|
||||||
assert body["url"] == "/notifications"
|
|
||||||
|
|
||||||
|
|
||||||
def _apns_settings(monkeypatch, **values):
|
|
||||||
from devplacepy.push.providers import apns
|
|
||||||
|
|
||||||
defaults = {
|
|
||||||
apns.TEAM_ID_KEY: "",
|
|
||||||
apns.KEY_ID_KEY: "",
|
|
||||||
apns.AUTH_KEY_KEY: "",
|
|
||||||
apns.TOPIC_KEY: "",
|
|
||||||
apns.ENVIRONMENT_KEY: "",
|
|
||||||
}
|
|
||||||
defaults.update(values)
|
|
||||||
monkeypatch.setattr(apns, "_setting", lambda key: defaults.get(key, ""))
|
|
||||||
apns._token_state.clear()
|
|
||||||
return defaults
|
|
||||||
|
|
||||||
|
|
||||||
def _ec_private_key_pem():
|
|
||||||
from cryptography.hazmat.primitives import serialization
|
|
||||||
from cryptography.hazmat.primitives.asymmetric import ec
|
|
||||||
|
|
||||||
key = ec.generate_private_key(ec.SECP256R1())
|
|
||||||
return key.private_bytes(
|
|
||||||
encoding=serialization.Encoding.PEM,
|
|
||||||
format=serialization.PrivateFormat.PKCS8,
|
|
||||||
encryption_algorithm=serialization.NoEncryption(),
|
|
||||||
).decode("utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
def test_apns_is_configured_requires_every_credential(monkeypatch):
|
|
||||||
from devplacepy.push import providers
|
|
||||||
from devplacepy.push.providers import apns
|
|
||||||
|
|
||||||
provider = providers.PROVIDERS["apns"]
|
|
||||||
_apns_settings(monkeypatch)
|
|
||||||
assert provider.is_configured() is False
|
|
||||||
assert providers.is_active(provider) is False
|
|
||||||
|
|
||||||
_apns_settings(
|
|
||||||
monkeypatch,
|
|
||||||
**{
|
|
||||||
apns.TEAM_ID_KEY: "TEAMID1234",
|
|
||||||
apns.KEY_ID_KEY: "KEYID12345",
|
|
||||||
apns.AUTH_KEY_KEY: "pem",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
assert provider.is_configured() is False
|
|
||||||
|
|
||||||
_apns_settings(
|
|
||||||
monkeypatch,
|
|
||||||
**{
|
|
||||||
apns.TEAM_ID_KEY: "TEAMID1234",
|
|
||||||
apns.KEY_ID_KEY: "KEYID12345",
|
|
||||||
apns.AUTH_KEY_KEY: "pem",
|
|
||||||
apns.TOPIC_KEY: "nl.molodetz.devplace",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
assert provider.is_configured() is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_apns_host_falls_back_to_production(monkeypatch):
|
|
||||||
from devplacepy.push.providers import apns
|
|
||||||
|
|
||||||
_apns_settings(monkeypatch)
|
|
||||||
assert apns.host() == "api.push.apple.com"
|
|
||||||
|
|
||||||
_apns_settings(monkeypatch, **{apns.ENVIRONMENT_KEY: "sandbox"})
|
|
||||||
assert apns.host() == "api.sandbox.push.apple.com"
|
|
||||||
|
|
||||||
_apns_settings(monkeypatch, **{apns.ENVIRONMENT_KEY: "nonsense"})
|
|
||||||
assert apns.host() == "api.push.apple.com"
|
|
||||||
|
|
||||||
|
|
||||||
def test_apns_provider_token_is_signed_and_cached(monkeypatch):
|
|
||||||
import jwt
|
|
||||||
from devplacepy.push.providers import apns
|
|
||||||
|
|
||||||
_apns_settings(monkeypatch)
|
|
||||||
pem = _ec_private_key_pem()
|
|
||||||
token = apns.provider_token("TEAMID1234", "KEYID12345", pem)
|
|
||||||
assert apns.provider_token("TEAMID1234", "KEYID12345", pem) == token
|
|
||||||
|
|
||||||
header = jwt.get_unverified_header(token)
|
|
||||||
claims = jwt.decode(token, options={"verify_signature": False})
|
|
||||||
assert header["alg"] == "ES256"
|
|
||||||
assert header["kid"] == "KEYID12345"
|
|
||||||
assert claims["iss"] == "TEAMID1234"
|
|
||||||
assert isinstance(claims["iat"], int)
|
|
||||||
|
|
||||||
other = apns.provider_token("TEAMID1234", "KEYID12345", _ec_private_key_pem())
|
|
||||||
assert other != token
|
|
||||||
|
|
||||||
|
|
||||||
def test_apns_provider_token_rejects_a_broken_auth_key(monkeypatch):
|
|
||||||
import pytest
|
|
||||||
from devplacepy.push.providers import apns
|
|
||||||
|
|
||||||
_apns_settings(monkeypatch)
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
apns.provider_token("TEAMID1234", "KEYID12345", "not-a-pem")
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
apns.provider_token("TEAMID1234", "KEYID12345", "not-a-pem")
|
|
||||||
|
|
||||||
|
|
||||||
def _apns_response_status(monkeypatch, status, body):
|
|
||||||
import httpx
|
|
||||||
from tests.conftest import run_async
|
|
||||||
from devplacepy.push import providers
|
|
||||||
from devplacepy.push.providers import apns
|
|
||||||
|
|
||||||
_apns_settings(
|
|
||||||
monkeypatch,
|
|
||||||
**{
|
|
||||||
apns.TEAM_ID_KEY: "TEAMID1234",
|
|
||||||
apns.KEY_ID_KEY: "KEYID12345",
|
|
||||||
apns.AUTH_KEY_KEY: _ec_private_key_pem(),
|
|
||||||
apns.TOPIC_KEY: "nl.molodetz.devplace",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
provider = providers.PROVIDERS["apns"]
|
|
||||||
seen = {}
|
|
||||||
|
|
||||||
def handler(request):
|
|
||||||
seen["url"] = str(request.url)
|
|
||||||
seen["headers"] = dict(request.headers)
|
|
||||||
return httpx.Response(status, json=body)
|
|
||||||
|
|
||||||
async def run():
|
|
||||||
transport = httpx.MockTransport(handler)
|
|
||||||
async with httpx.AsyncClient(transport=transport) as client:
|
|
||||||
return await provider.deliver(
|
|
||||||
client, {"token": "a" * 64}, provider.prepare({"message": "hi"})
|
|
||||||
)
|
|
||||||
|
|
||||||
return run_async(run()), seen
|
|
||||||
|
|
||||||
|
|
||||||
def test_apns_delivery_maps_statuses(monkeypatch):
|
|
||||||
from devplacepy.push import providers
|
|
||||||
|
|
||||||
accepted, seen = _apns_response_status(monkeypatch, 200, {})
|
|
||||||
assert accepted.status == providers.ACCEPTED
|
|
||||||
assert seen["url"] == f"https://api.push.apple.com/3/device/{'a' * 64}"
|
|
||||||
assert seen["headers"]["apns-topic"] == "nl.molodetz.devplace"
|
|
||||||
assert seen["headers"]["apns-push-type"] == "alert"
|
|
||||||
assert seen["headers"]["apns-priority"] == "10"
|
|
||||||
assert seen["headers"]["authorization"].startswith("bearer ")
|
|
||||||
assert int(seen["headers"]["apns-expiration"]) > 0
|
|
||||||
assert seen["headers"]["apns-id"]
|
|
||||||
|
|
||||||
gone, _ = _apns_response_status(monkeypatch, 410, {"reason": "Unregistered"})
|
|
||||||
assert gone.status == providers.DEAD
|
|
||||||
|
|
||||||
bad_token, _ = _apns_response_status(monkeypatch, 400, {"reason": "BadDeviceToken"})
|
|
||||||
assert bad_token.status == providers.DEAD
|
|
||||||
|
|
||||||
payload_error, _ = _apns_response_status(
|
|
||||||
monkeypatch, 400, {"reason": "PayloadTooLarge"}
|
|
||||||
)
|
|
||||||
assert payload_error.status == providers.REJECTED
|
|
||||||
|
|
||||||
throttled, _ = _apns_response_status(monkeypatch, 429, {"reason": "TooManyRequests"})
|
|
||||||
assert throttled.status == providers.REJECTED
|
|
||||||
|
|
||||||
|
|
||||||
def test_apns_delivery_without_configuration_never_raises(monkeypatch):
|
|
||||||
import httpx
|
|
||||||
from tests.conftest import run_async
|
|
||||||
from devplacepy.push import providers
|
|
||||||
|
|
||||||
_apns_settings(monkeypatch)
|
|
||||||
provider = providers.PROVIDERS["apns"]
|
|
||||||
|
|
||||||
async def run():
|
|
||||||
transport = httpx.MockTransport(lambda request: httpx.Response(200, json={}))
|
|
||||||
async with httpx.AsyncClient(transport=transport) as client:
|
|
||||||
return await provider.deliver(client, {"token": "a" * 64}, "{}")
|
|
||||||
|
|
||||||
assert run_async(run()).status == providers.REJECTED
|
|
||||||
|
|
||||||
|
|
||||||
def test_group_by_provider_treats_a_missing_provider_as_webpush():
|
|
||||||
from devplacepy.push.delivery import group_by_provider
|
|
||||||
|
|
||||||
grouped = group_by_provider(
|
|
||||||
[
|
|
||||||
{"id": 1, "provider": None},
|
|
||||||
{"id": 2, "provider": ""},
|
|
||||||
{"id": 3, "provider": "webpush"},
|
|
||||||
{"id": 4, "provider": "apns"},
|
|
||||||
]
|
|
||||||
)
|
|
||||||
assert sorted(grouped) == ["apns", "webpush"]
|
|
||||||
assert len(grouped["webpush"]) == 3
|
|
||||||
assert len(grouped["apns"]) == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_delivery_timeout_is_clamped(monkeypatch):
|
|
||||||
from devplacepy.push import delivery
|
|
||||||
|
|
||||||
monkeypatch.setattr(delivery, "get_int_setting", lambda key, default: default)
|
|
||||||
assert delivery.timeout_seconds() == float(delivery.DEFAULT_TIMEOUT_SECONDS)
|
|
||||||
|
|
||||||
monkeypatch.setattr(delivery, "get_int_setting", lambda key, default: 0)
|
|
||||||
assert delivery.timeout_seconds() == float(delivery.MIN_TIMEOUT_SECONDS)
|
|
||||||
|
|
||||||
monkeypatch.setattr(delivery, "get_int_setting", lambda key, default: 100000)
|
|
||||||
assert delivery.timeout_seconds() == float(delivery.MAX_TIMEOUT_SECONDS)
|
|
||||||
|
|||||||
@ -1,164 +0,0 @@
|
|||||||
# retoor <retoor@molodetz.nl>
|
|
||||||
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from pydantic import ValidationError
|
|
||||||
|
|
||||||
from devplacepy.database import get_table
|
|
||||||
from devplacepy.services.openai_gateway import quota as q
|
|
||||||
from devplacepy.services.openai_gateway.usage import GATEWAY_LEDGER
|
|
||||||
from devplacepy.utils import generate_uid
|
|
||||||
|
|
||||||
_counter = [0]
|
|
||||||
|
|
||||||
|
|
||||||
def _owner():
|
|
||||||
_counter[0] += 1
|
|
||||||
return f"quotauser{_counter[0]}-{generate_uid()}"
|
|
||||||
|
|
||||||
|
|
||||||
def _burn(owner_id, app_reference, cost, minutes_ago=0):
|
|
||||||
stamp = datetime.now(timezone.utc) - timedelta(minutes=minutes_ago)
|
|
||||||
get_table(GATEWAY_LEDGER).insert(
|
|
||||||
{
|
|
||||||
"uid": generate_uid(),
|
|
||||||
"owner_kind": "user",
|
|
||||||
"owner_id": owner_id,
|
|
||||||
"app_reference": app_reference,
|
|
||||||
"cost_usd": cost,
|
|
||||||
"created_at": stamp.isoformat(),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_reset_requires_no_dimension(local_db):
|
|
||||||
assert q.QuotaResetIn().owner_kind is None
|
|
||||||
assert q.QuotaResetIn().owner_id is None
|
|
||||||
assert q.QuotaResetIn().app_reference is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_reset_rejects_an_unknown_owner_kind(local_db):
|
|
||||||
with pytest.raises(ValidationError):
|
|
||||||
q.QuotaResetIn(owner_kind="wizard")
|
|
||||||
|
|
||||||
|
|
||||||
def test_reset_rejects_a_malformed_app_reference(local_db):
|
|
||||||
with pytest.raises(ValidationError):
|
|
||||||
q.QuotaResetIn(app_reference="not a valid app!")
|
|
||||||
|
|
||||||
|
|
||||||
def test_reset_normalizes_blanks_to_wildcards(local_db):
|
|
||||||
payload = q.QuotaResetIn(owner_kind="", owner_id=" ", app_reference="")
|
|
||||||
assert payload.owner_kind is None
|
|
||||||
assert payload.owner_id is None
|
|
||||||
assert payload.app_reference is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_spend_counts_before_any_reset(local_db):
|
|
||||||
owner = _owner()
|
|
||||||
_burn(owner, "appa", 1.5)
|
|
||||||
assert q.spent_24h("user", owner, "appa") == 1.5
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_scoped_reset_clears_that_scope(local_db):
|
|
||||||
owner = _owner()
|
|
||||||
_burn(owner, "appa", 1.5)
|
|
||||||
q.reset(q.QuotaResetIn(owner_kind="user", owner_id=owner, app_reference="appa"))
|
|
||||||
assert q.spent_24h("user", owner, "appa") == 0.0
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_scoped_reset_leaves_another_caller_alone(local_db):
|
|
||||||
first, second = _owner(), _owner()
|
|
||||||
_burn(first, "appa", 1.5)
|
|
||||||
_burn(second, "appa", 2.0)
|
|
||||||
q.reset(q.QuotaResetIn(owner_kind="user", owner_id=first, app_reference="appa"))
|
|
||||||
assert q.spent_24h("user", first, "appa") == 0.0
|
|
||||||
assert q.spent_24h("user", second, "appa") == 2.0
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_scoped_reset_leaves_another_app_alone(local_db):
|
|
||||||
owner = _owner()
|
|
||||||
_burn(owner, "appa", 1.5)
|
|
||||||
_burn(owner, "appb", 2.0)
|
|
||||||
q.reset(q.QuotaResetIn(owner_kind="user", owner_id=owner, app_reference="appa"))
|
|
||||||
assert q.spent_24h("user", owner, "appa") == 0.0
|
|
||||||
assert q.spent_24h("user", owner, "appb") == 2.0
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_global_reset_clears_every_scope(local_db):
|
|
||||||
first, second = _owner(), _owner()
|
|
||||||
_burn(first, "appa", 1.5)
|
|
||||||
_burn(second, "appb", 2.0)
|
|
||||||
q.reset(q.QuotaResetIn())
|
|
||||||
assert q.spent_24h("user", first, "appa") == 0.0
|
|
||||||
assert q.spent_24h("user", second, "appb") == 0.0
|
|
||||||
|
|
||||||
|
|
||||||
def test_spend_after_a_reset_counts_again(local_db):
|
|
||||||
owner = _owner()
|
|
||||||
_burn(owner, "appa", 1.5)
|
|
||||||
q.reset(q.QuotaResetIn(owner_kind="user", owner_id=owner, app_reference="appa"))
|
|
||||||
_burn(owner, "appa", 0.75)
|
|
||||||
assert q.spent_24h("user", owner, "appa") == 0.75
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_reset_keeps_the_usage_history(local_db):
|
|
||||||
owner = _owner()
|
|
||||||
_burn(owner, "appa", 1.5)
|
|
||||||
before = get_table(GATEWAY_LEDGER).count(owner_id=owner)
|
|
||||||
q.reset(q.QuotaResetIn(owner_kind="user", owner_id=owner, app_reference="appa"))
|
|
||||||
assert get_table(GATEWAY_LEDGER).count(owner_id=owner) == before
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_narrower_reset_does_not_clear_a_broader_scope(local_db):
|
|
||||||
owner = _owner()
|
|
||||||
_burn(owner, "appa", 1.5)
|
|
||||||
q.reset(q.QuotaResetIn(owner_kind="user", owner_id=owner, app_reference="appa"))
|
|
||||||
assert q.spent_24h("user", owner, None) == 1.5
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_broader_reset_clears_a_narrower_scope(local_db):
|
|
||||||
owner = _owner()
|
|
||||||
_burn(owner, "appa", 1.5)
|
|
||||||
q.reset(q.QuotaResetIn(owner_kind="user", owner_id=owner))
|
|
||||||
assert q.spent_24h("user", owner, "appa") == 0.0
|
|
||||||
|
|
||||||
|
|
||||||
def test_resetting_the_same_scope_twice_reuses_one_row(local_db):
|
|
||||||
owner = _owner()
|
|
||||||
scope = q.QuotaResetIn(owner_kind="user", owner_id=owner, app_reference="appa")
|
|
||||||
first = q.reset(scope)
|
|
||||||
second = q.reset(scope)
|
|
||||||
assert first["uid"] == second["uid"]
|
|
||||||
assert second["reset_at"] >= first["reset_at"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_rule_scope_is_unblocked_by_a_reset(local_db):
|
|
||||||
owner = _owner()
|
|
||||||
q.quota_rule_store.set(
|
|
||||||
q.QuotaRuleIn(
|
|
||||||
owner_kind="user", owner_id=owner, app_reference="appa", limit_usd=1.0
|
|
||||||
),
|
|
||||||
created_by="test",
|
|
||||||
)
|
|
||||||
_burn(owner, "appa", 1.5)
|
|
||||||
limit, scope, rule = q.resolve("user", owner, "appa", {})
|
|
||||||
assert q.spent_24h(*scope) >= limit
|
|
||||||
q.reset(
|
|
||||||
q.QuotaResetIn(
|
|
||||||
owner_kind=scope[0], owner_id=scope[1], app_reference=scope[2]
|
|
||||||
)
|
|
||||||
)
|
|
||||||
assert q.spent_24h(*scope) < limit
|
|
||||||
q.quota_rule_store.remove(rule.uid)
|
|
||||||
|
|
||||||
|
|
||||||
def test_scope_label_reads_like_the_rule_label(local_db):
|
|
||||||
scope = {"owner_kind": "user", "owner_id": "u1", "app_reference": "appa"}
|
|
||||||
assert q.scope_label(scope) == "role=user, user=u1, app=appa"
|
|
||||||
|
|
||||||
|
|
||||||
def test_scope_label_falls_back_for_a_wildcard_scope(local_db):
|
|
||||||
empty = {"owner_kind": None, "owner_id": None, "app_reference": None}
|
|
||||||
assert q.scope_label(empty, fallback="every caller") == "every caller"
|
|
||||||
@ -1,117 +0,0 @@
|
|||||||
# retoor <retoor@molodetz.nl>
|
|
||||||
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
|
|
||||||
from devplacepy.database import get_table
|
|
||||||
from devplacepy.push import store
|
|
||||||
from devplacepy.services.push import PushService
|
|
||||||
from devplacepy.utils import generate_uid
|
|
||||||
|
|
||||||
|
|
||||||
def _registration(user_uid, deleted_at=None, provider="webpush"):
|
|
||||||
record = {
|
|
||||||
"uid": generate_uid(),
|
|
||||||
"user_uid": user_uid,
|
|
||||||
"provider": provider,
|
|
||||||
"endpoint": f"https://push.example.com/{generate_uid()}",
|
|
||||||
"key_auth": "a",
|
|
||||||
"key_p256dh": "p",
|
|
||||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
||||||
"deleted_at": deleted_at,
|
|
||||||
}
|
|
||||||
get_table("push_registration").insert(record)
|
|
||||||
return record
|
|
||||||
|
|
||||||
|
|
||||||
def test_config_fields_cover_every_provider(local_db):
|
|
||||||
keys = [field.key for field in PushService().all_fields()]
|
|
||||||
for expected in (
|
|
||||||
"service_push_enabled",
|
|
||||||
"push_dead_retention_days",
|
|
||||||
"push_delivery_timeout_seconds",
|
|
||||||
"push_webpush_enabled",
|
|
||||||
"push_webpush_subject",
|
|
||||||
"push_apns_enabled",
|
|
||||||
"push_apns_team_id",
|
|
||||||
"push_apns_key_id",
|
|
||||||
"push_apns_auth_key",
|
|
||||||
"push_apns_topic",
|
|
||||||
"push_apns_environment",
|
|
||||||
):
|
|
||||||
assert expected in keys
|
|
||||||
|
|
||||||
|
|
||||||
def test_apns_auth_key_field_is_a_masked_secret(local_db):
|
|
||||||
fields = {field.key: field for field in PushService().all_fields()}
|
|
||||||
auth_key = fields["push_apns_auth_key"]
|
|
||||||
assert auth_key.secret is True
|
|
||||||
assert auth_key.type == "text"
|
|
||||||
assert auth_key.display_value() == ""
|
|
||||||
|
|
||||||
|
|
||||||
def test_run_once_prunes_only_stale_dead_rows(local_db, monkeypatch):
|
|
||||||
from devplacepy.services import push as push_service
|
|
||||||
|
|
||||||
user_uid = f"prune_{generate_uid()}"
|
|
||||||
live = _registration(user_uid)
|
|
||||||
fresh_dead = _registration(
|
|
||||||
user_uid,
|
|
||||||
deleted_at=(datetime.now(timezone.utc) - timedelta(days=1)).isoformat(),
|
|
||||||
)
|
|
||||||
stale_dead = _registration(
|
|
||||||
user_uid,
|
|
||||||
deleted_at=(datetime.now(timezone.utc) - timedelta(days=90)).isoformat(),
|
|
||||||
)
|
|
||||||
|
|
||||||
service = PushService()
|
|
||||||
monkeypatch.setattr(
|
|
||||||
push_service.service, "get_int_setting", lambda key, default: 30
|
|
||||||
)
|
|
||||||
from tests.conftest import run_async
|
|
||||||
|
|
||||||
run_async(service.run_once())
|
|
||||||
|
|
||||||
registrations = get_table("push_registration")
|
|
||||||
assert registrations.find_one(uid=live["uid"]) is not None
|
|
||||||
assert registrations.find_one(uid=fresh_dead["uid"]) is not None
|
|
||||||
assert registrations.find_one(uid=stale_dead["uid"]) is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_run_once_with_retention_disabled_prunes_nothing(local_db, monkeypatch):
|
|
||||||
from devplacepy.services import push as push_service
|
|
||||||
from tests.conftest import run_async
|
|
||||||
|
|
||||||
user_uid = f"keep_{generate_uid()}"
|
|
||||||
stale_dead = _registration(
|
|
||||||
user_uid,
|
|
||||||
deleted_at=(datetime.now(timezone.utc) - timedelta(days=900)).isoformat(),
|
|
||||||
)
|
|
||||||
|
|
||||||
service = PushService()
|
|
||||||
monkeypatch.setattr(push_service.service, "get_int_setting", lambda key, default: 0)
|
|
||||||
run_async(service.run_once())
|
|
||||||
|
|
||||||
assert get_table("push_registration").find_one(uid=stale_dead["uid"]) is not None
|
|
||||||
|
|
||||||
|
|
||||||
def test_metrics_count_live_rows_per_provider(local_db):
|
|
||||||
user_uid = f"metrics_{generate_uid()}"
|
|
||||||
_registration(user_uid)
|
|
||||||
_registration(user_uid, provider="apns")
|
|
||||||
|
|
||||||
metrics = PushService().collect_metrics()
|
|
||||||
assert metrics["webpush_active"] >= 1
|
|
||||||
assert metrics["apns_active"] >= 1
|
|
||||||
assert metrics["webpush_ready"] == 1
|
|
||||||
assert "dead" in metrics
|
|
||||||
|
|
||||||
|
|
||||||
def test_store_counts_treats_a_missing_provider_as_webpush(local_db):
|
|
||||||
user_uid = f"legacy_{generate_uid()}"
|
|
||||||
record = _registration(user_uid)
|
|
||||||
get_table("push_registration").update(
|
|
||||||
{"id": get_table("push_registration").find_one(uid=record["uid"])["id"], "provider": None},
|
|
||||||
["id"],
|
|
||||||
)
|
|
||||||
|
|
||||||
assert store.counts().get("webpush", 0) >= 1
|
|
||||||
Loading…
Reference in New Issue
Block a user