diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8d50bcdc..a0cc362c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,8 +15,17 @@ permissions: packages: write jobs: + # Images are never published from a red tree: the whole test workflow runs first and + # every job below waits on it. + tests: + if: github.ref == 'refs/heads/main' + uses: ./.github/workflows/tests.yml + permissions: + contents: read + prepare: if: github.ref == 'refs/heads/main' + needs: tests runs-on: ubuntu-latest outputs: base: ${{ steps.release.outputs.base }} @@ -62,9 +71,16 @@ jobs: matrix: include: - context: backend + dockerfile: backend/Dockerfile image: spaceworks-backend - context: frontend + dockerfile: frontend/Dockerfile image: spaceworks-frontend + # The single-box image (Dockerfile.allinone) builds from the repo root because it + # needs both trees. + - context: . + dockerfile: Dockerfile.allinone + image: spaceworks-allinone steps: - uses: actions/checkout@v4 @@ -88,6 +104,7 @@ jobs: uses: docker/build-push-action@v6 with: context: ./${{ matrix.context }} + file: ./${{ matrix.dockerfile }} push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} @@ -118,6 +135,7 @@ jobs: - `ghcr.io/${{ needs.prepare.outputs.registry_owner }}/spaceworks-backend:${{ needs.prepare.outputs.version }}` - `ghcr.io/${{ needs.prepare.outputs.registry_owner }}/spaceworks-frontend:${{ needs.prepare.outputs.version }}` + - `ghcr.io/${{ needs.prepare.outputs.registry_owner }}/spaceworks-allinone:${{ needs.prepare.outputs.version }}` (single-box) promote-latest: needs: @@ -165,7 +183,7 @@ jobs: SERIES: ${{ needs.prepare.outputs.series }} run: | set -euo pipefail - for package in spaceworks-backend spaceworks-frontend; do + for package in spaceworks-backend spaceworks-frontend spaceworks-allinone; do image="ghcr.io/${OWNER}/${package}" docker buildx imagetools create \ --tag "${image}:${SERIES}" \ @@ -236,6 +254,7 @@ jobs: --owner "$OWNER" \ --package spaceworks-backend \ --package spaceworks-frontend \ + --package spaceworks-allinone \ --version "$VERSION" \ --sha-tag "sha-${SHORT_SHA}" \ --series "$SERIES" \ diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 00000000..fb91bc0f --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,279 @@ +name: Tests + +# The suite that every pull request must pass, and that the release workflow depends on. +# Environment parity with scripts/dev-local.sh is deliberate: the host topology there is +# what developers run, and a CI job that reproduced a different one would report reds +# nobody can see locally (docs/DEV-WORKFLOW.md, "Docker vs host test split"). +on: + pull_request: + branches: [dev, main] + push: + branches: [dev] + workflow_call: + workflow_dispatch: + +permissions: + contents: read + +env: + PYTHON_VERSION: "3.12" + NODE_VERSION: "22" + +jobs: + docs-drift: + name: CLAUDE.md and AGENTS.md are one document + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: diff CLAUDE.md AGENTS.md + + frontend: + name: Frontend typecheck, unit tests, build + runs-on: ubuntu-latest + timeout-minutes: 20 + defaults: + run: + working-directory: frontend + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + cache-dependency-path: frontend/package-lock.json + - run: npm ci + - run: npx tsc -b + - run: npm test + - run: npm run build + + backend-host: + # Mirrors `./scripts/dev-local.sh test`: Postgres, Redis and MinIO as services, the + # Django test process on the runner. tests/backup and tests/tenant_migration are + # excluded here and run in the job below with a version-matched Postgres client. + name: Backend suite (host topology) + runs-on: ubuntu-latest + timeout-minutes: 120 + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: makerspace + POSTGRES_PASSWORD: makerspace + POSTGRES_DB: makerspace_manager + ports: ["5432:5432"] + options: >- + --health-cmd "pg_isready -U makerspace" + --health-interval 5s --health-timeout 5s --health-retries 20 + redis: + image: redis:7-alpine + ports: ["6379:6379"] + options: >- + --health-cmd "redis-cli ping" --health-interval 5s --health-timeout 5s --health-retries 20 + # GitHub Actions does not support YAML anchors, so this block is repeated verbatim in + # backend-pg-client below; keep the two in step. + env: + DATABASE_URL: postgres://makerspace:makerspace@localhost:5432/makerspace_manager + CELERY_BROKER_URL: redis://localhost:6379/0 + AWS_S3_ENDPOINT_URL: http://localhost:9200 + AWS_S3_PUBLIC_ENDPOINT_URL: http://localhost:9200 + PUBLIC_IMAGE_BASE_URL: http://localhost:9200/public-images + AWS_ACCESS_KEY_ID: minioadmin + AWS_SECRET_ACCESS_KEY: minioadmin + AWS_STORAGE_BUCKET_NAME: evidence + PUBLIC_IMAGE_BUCKET: public-images + STORAGE_PRESIGN_METHOD: post + DEBUG: "True" + SECRET_KEY: ci-only-secret-key-not-for-production + ALLOWED_HOSTS: localhost,127.0.0.1 + CORS_ALLOWED_ORIGINS: http://localhost:5000,http://localhost:5173 + AUTH_COOKIE_SECURE: "False" + AUTH_COOKIE_SAMESITE: Lax + EMAIL_BACKEND: django.core.mail.backends.console.EmailBackend + PYTHONDONTWRITEBYTECODE: "1" + steps: + - uses: actions/checkout@v4 + - name: Start MinIO and create the buckets the compose stack creates + run: | + set -euo pipefail + docker run -d --name minio -p 9200:9000 minio/minio:latest server /data + for _ in $(seq 1 30); do + curl -sf http://localhost:9200/minio/health/live && break + sleep 1 + done + docker run --rm --network host --entrypoint sh minio/mc:latest -c ' + mc alias set local http://localhost:9200 minioadmin minioadmin && + mc mb --ignore-existing local/evidence && + mc version enable local/evidence && + mc anonymous set none local/evidence && + mc mb --ignore-existing local/public-images && + mc version enable local/public-images && + mc anonymous set download local/public-images' + - uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + cache: pip + cache-dependency-path: backend/requirements.txt + - run: pip install -r backend/requirements.txt + - name: Django system checks + working-directory: backend + run: python manage.py check + - name: pytest (excluding the Postgres-client-bound suites) + working-directory: backend + run: >- + pytest -q -p no:cacheprovider + --ignore=tests/backup --ignore=tests/tenant_migration + + backend-pg-client: + # tests/backup and tests/tenant_migration shell out to pg_dump/pg_restore and refuse + # unless the client MAJOR equals the server's (16). The runner gets postgresql-client-16 + # from PGDG so postgres_client.client_binary resolves /usr/lib/postgresql/16/bin. + name: Backup and tenant-migration suites (pg client 16) + runs-on: ubuntu-latest + timeout-minutes: 90 + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: makerspace + POSTGRES_PASSWORD: makerspace + POSTGRES_DB: makerspace_manager + ports: ["5432:5432"] + options: >- + --health-cmd "pg_isready -U makerspace" + --health-interval 5s --health-timeout 5s --health-retries 20 + redis: + image: redis:7-alpine + ports: ["6379:6379"] + env: + DATABASE_URL: postgres://makerspace:makerspace@localhost:5432/makerspace_manager + CELERY_BROKER_URL: redis://localhost:6379/0 + AWS_S3_ENDPOINT_URL: http://localhost:9200 + AWS_S3_PUBLIC_ENDPOINT_URL: http://localhost:9200 + PUBLIC_IMAGE_BASE_URL: http://localhost:9200/public-images + AWS_ACCESS_KEY_ID: minioadmin + AWS_SECRET_ACCESS_KEY: minioadmin + AWS_STORAGE_BUCKET_NAME: evidence + PUBLIC_IMAGE_BUCKET: public-images + STORAGE_PRESIGN_METHOD: post + DEBUG: "True" + SECRET_KEY: ci-only-secret-key-not-for-production + ALLOWED_HOSTS: localhost,127.0.0.1 + CORS_ALLOWED_ORIGINS: http://localhost:5000,http://localhost:5173 + AUTH_COOKIE_SECURE: "False" + AUTH_COOKIE_SAMESITE: Lax + EMAIL_BACKEND: django.core.mail.backends.console.EmailBackend + PYTHONDONTWRITEBYTECODE: "1" + steps: + - uses: actions/checkout@v4 + - name: Install postgresql-client-16 and age + run: | + set -euo pipefail + sudo install -d /usr/share/postgresql-common/pgdg + curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc \ + | sudo gpg --dearmor -o /usr/share/postgresql-common/pgdg/apt.postgresql.org.gpg + . /etc/os-release + echo "deb [signed-by=/usr/share/postgresql-common/pgdg/apt.postgresql.org.gpg] https://apt.postgresql.org/pub/repos/apt ${VERSION_CODENAME}-pgdg main" \ + | sudo tee /etc/apt/sources.list.d/pgdg.list + sudo apt-get update + sudo apt-get install -y --no-install-recommends postgresql-client-16 age + /usr/lib/postgresql/16/bin/pg_dump --version + - name: Start MinIO and create buckets + run: | + set -euo pipefail + docker run -d --name minio -p 9200:9000 minio/minio:latest server /data + for _ in $(seq 1 30); do + curl -sf http://localhost:9200/minio/health/live && break + sleep 1 + done + docker run --rm --network host --entrypoint sh minio/mc:latest -c ' + mc alias set local http://localhost:9200 minioadmin minioadmin && + mc mb --ignore-existing local/evidence && + mc version enable local/evidence && + mc anonymous set none local/evidence && + mc mb --ignore-existing local/public-images && + mc version enable local/public-images && + mc anonymous set download local/public-images' + - uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + cache: pip + cache-dependency-path: backend/requirements.txt + - run: pip install -r backend/requirements.txt + - name: pytest tests/backup tests/tenant_migration + working-directory: backend + run: pytest -q -p no:cacheprovider tests/backup tests/tenant_migration + + e2e: + # Playwright against a real stack in the host topology (Django runserver + Vite + the + # same Postgres/Redis/MinIO services), driven by scripts/e2e-local.sh so CI and a laptop + # run the identical recipe. Pins the Hard Rules through a real browser and a real + # presigned upload to object storage. + name: End-to-end (Playwright) + runs-on: ubuntu-latest + timeout-minutes: 30 + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: makerspace + POSTGRES_PASSWORD: makerspace + POSTGRES_DB: makerspace_manager + ports: ["5432:5432"] + options: >- + --health-cmd "pg_isready -U makerspace" + --health-interval 5s --health-timeout 5s --health-retries 20 + redis: + image: redis:7-alpine + ports: ["6379:6379"] + env: + PYTHON: python + PG_PORT: "5432" + MINIO_PORT: "9200" + DATABASE_URL: postgres://makerspace:makerspace@localhost:5432/makerspace_manager + CELERY_BROKER_URL: redis://localhost:6379/0 + SECRET_KEY: ci-only-secret-key-not-for-production + DEBUG: "True" + ALLOWED_HOSTS: localhost,127.0.0.1 + PYTHONDONTWRITEBYTECODE: "1" + CI: "true" + steps: + - uses: actions/checkout@v4 + - name: Start MinIO and create buckets + run: | + set -euo pipefail + docker run -d --name minio -p 9200:9000 minio/minio:latest server /data + for _ in $(seq 1 30); do + curl -sf http://localhost:9200/minio/health/live && break + sleep 1 + done + docker run --rm --network host --entrypoint sh minio/mc:latest -c ' + mc alias set local http://localhost:9200 minioadmin minioadmin && + mc mb --ignore-existing local/evidence && + mc version enable local/evidence && + mc anonymous set none local/evidence && + mc mb --ignore-existing local/public-images && + mc version enable local/public-images && + mc anonymous set download local/public-images' + - uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + cache: pip + cache-dependency-path: backend/requirements.txt + - run: pip install -r backend/requirements.txt + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + cache-dependency-path: frontend/package-lock.json + - run: npm ci + working-directory: frontend + - run: npx playwright install --with-deps chromium + working-directory: frontend + - name: Seed, start the stack, run Playwright + run: ./scripts/e2e-local.sh + - uses: actions/upload-artifact@v4 + if: failure() + with: + name: playwright-report + path: frontend/playwright-report + retention-days: 7 diff --git a/.gitignore b/.gitignore index 216497d1..da2a2f55 100644 --- a/.gitignore +++ b/.gitignore @@ -61,3 +61,8 @@ docs/roadmap.md .DS_Store .idea/ .vscode/ + +# End-to-end test artifacts (scripts/e2e-local.sh) +frontend/e2e/.seed.json +frontend/playwright-report/ +frontend/test-results/ diff --git a/AGENTS.md b/AGENTS.md index f214858a..b6f26bbd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -122,6 +122,39 @@ the Auth module** — forgetting this is a cross-tenant data leak, not just a bu - Evidence endpoints require per-makerspace `UPLOAD_EVIDENCE` plus active status; QR management also checks active status. - **Every presigned upload lands on the staging key; the final object key is never client-writable.** A workflow promotes it exactly once, so an accepted evidence photo cannot be replaced through a still-valid presign. Before retention expiry, read paths — the evidence endpoint, the admin preview, and backup/tenant-migration object capture — therefore fall back to the staging key, or an uploaded-but-unconsumed photo reads as missing. A terminal expired state returns 410 and never consults storage. +- **Money owed is tracked independently of the `payments` module.** `charge_tracking_enabled` + (`payments/availability.py`) decides whether a debt is RECORDED and has no module or credential + clause; `online_payments_enabled` still governs the Stripe/Razorpay rail only and keeps all four of + its clauses. The `charges.*` capability family carries tracking — `charges.enabled` is a standalone + master switch (off = the space charges for nothing) and each domain key keeps its real DOMAIN parent, + never a payments one. All six charge seams record first and add a rail second; the sixth is the + scheduled renewal in `makerspaces/membership_plan_services.py`, which gates independently. + **`payments` is now opt-in** and buys the online rail alone. A charge raised with no gateway is + stamped `provider=unclaimed` and claimed exactly once by the first checkout that reaches a provider + (DB-enforced). Marking one paid offline requires a `ManualSettlement` receipt — method, reference, + received date — written in the same transaction and append-only, with corrections as `amends` rows. +- **The payment LEDGER is permanently core; only the RAIL is separable.** `apps.payments` + (Payment, ManualSettlement, reconciliation, member history, receipts, reports) can never be + tombstoned — a deployment that cannot read or settle money it is holding has lost data. + `apps.payments_rail` owns the removable half: checkout, the native payment sheet, Connect, + refunds, credential settings and every webhook. The `payments` MODULE key belongs to the rail, + so tombstoning the rail drops the key. `TOMBSTONED_APPS=payments` still works and is + translated to `payments_rail` (`separability.tombstones.RENAMED_LABELS`). +- **Pending payments travel in a portable dump.** A single pending row used to refuse the whole + dump; that is gone, because money owed is now recorded by default and the refusal made a dump + impossible for any space keeping a ledger. Three things replace it: the preflight refuses a + pending row with a **live rail** (hosted session OR native intent), the projection clears every + provider handle on pending rows exactly as on terminal ones, and the capture records a + `money_fingerprint_sha256` that **publication revalidates** — refusing with `money_drift` if the + source settled or raised a debt after the freeze, since the artifact cannot be merged forward. +- **The member dashboard is gated on the `membership` module** (off by default): request + history, returned-item history, membership fee plus outstanding-per-currency, and a notices + feed, all served by the one `membership`-gated activity endpoint. Its notices are **derived + from the member's own rows**, never from `notifications.Notification` — that table is + makerspace-wide with no recipient and one shared `read_at`. **Payment visibility is NOT behind + that gate**: `member_may_see_own_charges` admits a live, unrestricted account that owns a + charge here and holds no membership, so an account-only loan borrower can read their own debt; + a REVOKED member is still refused. - Evidence photo **rows** and QR scan records are **immutable**; audit logs are **append-only**. Evidence retention may delete every final and staging object version only after the configured window, but it does not update or delete the retained `EvidencePhoto` row. - Public inventory must never expose: storage locations, box IDs, QR codes, scan history, evidence photos, requester history, or hidden counts. Public visibility is governed per-item by `is_public`, @@ -150,12 +183,15 @@ the Auth module** — forgetting this is a cross-tenant data leak, not just a bu the original file as a **thin re-export barrel** (explicit `from .submodule import (...)`, never `import *`) so `from app.views import X` and `views.X` keep resolving; for `admin.py` the barrel must still import the admin submodules so the `@admin.register` side effects fire. **The ceiling is enforced - on what you touch, and it is NOT currently met repo-wide: 37 backend files exceed 300 lines** — largest - first, `config/settings.py` (929, the accepted exception — Django settings are conventionally a single - file), `admin_api/urls.py` (825), `makerspaces/models.py` (682), `accounts/rbac.py` (609), - `inventory/availability.py` (596), `admin_api/serializers_makerspaces.py` (561), - `makerspaces/module_registry.py` (503), `machines/role_scope.py` (489). Measured 2026-08-20; an earlier - version of this line claimed every file but `settings.py` was compliant, which was false by 36 files. + on what you touch, and it is nearly met repo-wide: five `backend/apps/` files (non-migration, non-test) + exceed 300 lines** — `machines/access.py` (367), `makerspaces/module_registry.py` (322), + `inventory/middleware.py` (308), `tenant_migration/tenant_dump_authority.py` (305), + `tenant_migration/source_gate_guards.py` (301) — plus `config/settings.py` (1146, the accepted + exception — Django settings are conventionally a single file). `backend/tests/` is not held to the + ceiling. Frontend: **zero** non-test, non-generated files exceed it — `8be90478` split all eleven, + so a frontend file over the ceiling is now new debt, not inherited. Measured 2026-09-06; every earlier + version of this line (37 files 2026-08-20, eleven frontend files 2026-09-03) was already stale by the + time it was read. **Split an over-ceiling file in its own commit before adding to it**, and when splitting one that other modules import from, check for guards pinned to its path: `tests/makerspaces/test_tenant_servability_guard.py` pins two function *bodies* to `accounts/rbac.py` by `(path, function)`, and @@ -212,10 +248,41 @@ starting a build.** These are the rules you must not violate without having read container runs as the least-privilege `spaceworks_app` role, which has no CREATEDB, so pytest cannot build a test database as itself. + **CHECK WHICH TREE THE CONTAINER MOUNTS BEFORE BELIEVING ANY RESULT FROM IT.** There is more than + one clone of this repo on the dev machine, and `spaceworks-backend` has mounted + `~/Projects/SpaceInventory/SpaceWorks` while the session worked in `~/Projects/SpaceWorks/SpaceWorks`. + `dev-docker.sh exec` then tests *someone else's checkout* — on 2026-09-07 a whole afternoon of + "verification" ran against `b15c4e11`, a tree so old it had no `money_digest.py`, and reported + green for code it had never loaded. It also explains a flood of `relation ... does not exist` and + truncate-FK teardown errors: that tree predates dozens of migrations. Confirm first, every time: + + ```bash + docker inspect spaceworks-backend --format '{{range .Mounts}}{{.Source}} -> {{.Destination}}{{"\n"}}{{end}}' + ``` + + **When it is the wrong tree — or the shared stack is busy — run a ONE-OFF container against your + own isolated infra.** `--entrypoint pytest` is required: the image entrypoint is a production + admission gate that demands `--role ` plus a host restore-marker. Supply + `API_CLIENT_ENC_KEY` and `AUDIT_MAC_MASTER_KEY` too — both come from `backend/.env` on the host, + are **empty in the image**, and without them 19 `tests/tenant_migration` tests fail as + `ImproperlyConfigured` buried inside a `PairingError` from `deployment_keys.py`, which reads exactly + like a custody regression and is not one. Both are Fernet keys; generate throwaways per run. The + `db`/`redis`/`minio` network aliases exist in an isolated compose project too, so the image's baked + defaults resolve without further overrides. + ```bash - ./scripts/dev-docker.sh exec -e DATABASE_URL=postgres://makerspace:makerspace@db:5432/makerspace_manager \ - -T backend pytest tests/backup tests/tenant_migration -q + docker run --rm --network _default --entrypoint pytest \ + -v "$PWD/backend:/app" -v "$PWD:/workspace" -v "$PWD/scripts:/run/spaceworks-privileged-scripts" \ + -w /app \ + -e DATABASE_URL=postgres://makerspace:makerspace@db:5432/makerspace_manager \ + -e SECRET_KEY=test-only \ + -e API_CLIENT_ENC_KEY="$(python3 -c 'from cryptography.fernet import Fernet;print(Fernet.generate_key().decode())')" \ + -e AUDIT_MAC_MASTER_KEY="$(python3 -c 'from cryptography.fernet import Fernet;print(Fernet.generate_key().decode())')" \ + spaceworks-backend tests/backup tests/tenant_migration -q ``` + + Verified this way on 2026-09-07 at `6629e60a`: **1559 passed, 0 failed** in ~59 minutes, which is + also the proof that the host's `PostgresClientUnavailable` failures above are purely environmental. - **Chain every new migration off the ACTUAL leaf** — `ls backend/apps//migrations/`, never the number a spec quotes. - **Commits sit local and unpushed on `dev`; pushing is the owner's call alone.** Ask @@ -233,6 +300,7 @@ starting a build.** These are the rules you must not violate without having read ./scripts/dev-local.sh infra && ./scripts/dev-local.sh test # host: faster pytest, most of the suite # In Docker, pytest needs the DB OWNER: the backend runs as `spaceworks_app`, which has no CREATEDB. +# `tests/backup` and `tests/tenant_migration` additionally need the two Fernet keys above. ./scripts/dev-docker.sh exec -e DATABASE_URL=postgres://makerspace:makerspace@db:5432/makerspace_manager \ -T backend pytest ``` diff --git a/CLAUDE.md b/CLAUDE.md index f214858a..b6f26bbd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -122,6 +122,39 @@ the Auth module** — forgetting this is a cross-tenant data leak, not just a bu - Evidence endpoints require per-makerspace `UPLOAD_EVIDENCE` plus active status; QR management also checks active status. - **Every presigned upload lands on the staging key; the final object key is never client-writable.** A workflow promotes it exactly once, so an accepted evidence photo cannot be replaced through a still-valid presign. Before retention expiry, read paths — the evidence endpoint, the admin preview, and backup/tenant-migration object capture — therefore fall back to the staging key, or an uploaded-but-unconsumed photo reads as missing. A terminal expired state returns 410 and never consults storage. +- **Money owed is tracked independently of the `payments` module.** `charge_tracking_enabled` + (`payments/availability.py`) decides whether a debt is RECORDED and has no module or credential + clause; `online_payments_enabled` still governs the Stripe/Razorpay rail only and keeps all four of + its clauses. The `charges.*` capability family carries tracking — `charges.enabled` is a standalone + master switch (off = the space charges for nothing) and each domain key keeps its real DOMAIN parent, + never a payments one. All six charge seams record first and add a rail second; the sixth is the + scheduled renewal in `makerspaces/membership_plan_services.py`, which gates independently. + **`payments` is now opt-in** and buys the online rail alone. A charge raised with no gateway is + stamped `provider=unclaimed` and claimed exactly once by the first checkout that reaches a provider + (DB-enforced). Marking one paid offline requires a `ManualSettlement` receipt — method, reference, + received date — written in the same transaction and append-only, with corrections as `amends` rows. +- **The payment LEDGER is permanently core; only the RAIL is separable.** `apps.payments` + (Payment, ManualSettlement, reconciliation, member history, receipts, reports) can never be + tombstoned — a deployment that cannot read or settle money it is holding has lost data. + `apps.payments_rail` owns the removable half: checkout, the native payment sheet, Connect, + refunds, credential settings and every webhook. The `payments` MODULE key belongs to the rail, + so tombstoning the rail drops the key. `TOMBSTONED_APPS=payments` still works and is + translated to `payments_rail` (`separability.tombstones.RENAMED_LABELS`). +- **Pending payments travel in a portable dump.** A single pending row used to refuse the whole + dump; that is gone, because money owed is now recorded by default and the refusal made a dump + impossible for any space keeping a ledger. Three things replace it: the preflight refuses a + pending row with a **live rail** (hosted session OR native intent), the projection clears every + provider handle on pending rows exactly as on terminal ones, and the capture records a + `money_fingerprint_sha256` that **publication revalidates** — refusing with `money_drift` if the + source settled or raised a debt after the freeze, since the artifact cannot be merged forward. +- **The member dashboard is gated on the `membership` module** (off by default): request + history, returned-item history, membership fee plus outstanding-per-currency, and a notices + feed, all served by the one `membership`-gated activity endpoint. Its notices are **derived + from the member's own rows**, never from `notifications.Notification` — that table is + makerspace-wide with no recipient and one shared `read_at`. **Payment visibility is NOT behind + that gate**: `member_may_see_own_charges` admits a live, unrestricted account that owns a + charge here and holds no membership, so an account-only loan borrower can read their own debt; + a REVOKED member is still refused. - Evidence photo **rows** and QR scan records are **immutable**; audit logs are **append-only**. Evidence retention may delete every final and staging object version only after the configured window, but it does not update or delete the retained `EvidencePhoto` row. - Public inventory must never expose: storage locations, box IDs, QR codes, scan history, evidence photos, requester history, or hidden counts. Public visibility is governed per-item by `is_public`, @@ -150,12 +183,15 @@ the Auth module** — forgetting this is a cross-tenant data leak, not just a bu the original file as a **thin re-export barrel** (explicit `from .submodule import (...)`, never `import *`) so `from app.views import X` and `views.X` keep resolving; for `admin.py` the barrel must still import the admin submodules so the `@admin.register` side effects fire. **The ceiling is enforced - on what you touch, and it is NOT currently met repo-wide: 37 backend files exceed 300 lines** — largest - first, `config/settings.py` (929, the accepted exception — Django settings are conventionally a single - file), `admin_api/urls.py` (825), `makerspaces/models.py` (682), `accounts/rbac.py` (609), - `inventory/availability.py` (596), `admin_api/serializers_makerspaces.py` (561), - `makerspaces/module_registry.py` (503), `machines/role_scope.py` (489). Measured 2026-08-20; an earlier - version of this line claimed every file but `settings.py` was compliant, which was false by 36 files. + on what you touch, and it is nearly met repo-wide: five `backend/apps/` files (non-migration, non-test) + exceed 300 lines** — `machines/access.py` (367), `makerspaces/module_registry.py` (322), + `inventory/middleware.py` (308), `tenant_migration/tenant_dump_authority.py` (305), + `tenant_migration/source_gate_guards.py` (301) — plus `config/settings.py` (1146, the accepted + exception — Django settings are conventionally a single file). `backend/tests/` is not held to the + ceiling. Frontend: **zero** non-test, non-generated files exceed it — `8be90478` split all eleven, + so a frontend file over the ceiling is now new debt, not inherited. Measured 2026-09-06; every earlier + version of this line (37 files 2026-08-20, eleven frontend files 2026-09-03) was already stale by the + time it was read. **Split an over-ceiling file in its own commit before adding to it**, and when splitting one that other modules import from, check for guards pinned to its path: `tests/makerspaces/test_tenant_servability_guard.py` pins two function *bodies* to `accounts/rbac.py` by `(path, function)`, and @@ -212,10 +248,41 @@ starting a build.** These are the rules you must not violate without having read container runs as the least-privilege `spaceworks_app` role, which has no CREATEDB, so pytest cannot build a test database as itself. + **CHECK WHICH TREE THE CONTAINER MOUNTS BEFORE BELIEVING ANY RESULT FROM IT.** There is more than + one clone of this repo on the dev machine, and `spaceworks-backend` has mounted + `~/Projects/SpaceInventory/SpaceWorks` while the session worked in `~/Projects/SpaceWorks/SpaceWorks`. + `dev-docker.sh exec` then tests *someone else's checkout* — on 2026-09-07 a whole afternoon of + "verification" ran against `b15c4e11`, a tree so old it had no `money_digest.py`, and reported + green for code it had never loaded. It also explains a flood of `relation ... does not exist` and + truncate-FK teardown errors: that tree predates dozens of migrations. Confirm first, every time: + + ```bash + docker inspect spaceworks-backend --format '{{range .Mounts}}{{.Source}} -> {{.Destination}}{{"\n"}}{{end}}' + ``` + + **When it is the wrong tree — or the shared stack is busy — run a ONE-OFF container against your + own isolated infra.** `--entrypoint pytest` is required: the image entrypoint is a production + admission gate that demands `--role ` plus a host restore-marker. Supply + `API_CLIENT_ENC_KEY` and `AUDIT_MAC_MASTER_KEY` too — both come from `backend/.env` on the host, + are **empty in the image**, and without them 19 `tests/tenant_migration` tests fail as + `ImproperlyConfigured` buried inside a `PairingError` from `deployment_keys.py`, which reads exactly + like a custody regression and is not one. Both are Fernet keys; generate throwaways per run. The + `db`/`redis`/`minio` network aliases exist in an isolated compose project too, so the image's baked + defaults resolve without further overrides. + ```bash - ./scripts/dev-docker.sh exec -e DATABASE_URL=postgres://makerspace:makerspace@db:5432/makerspace_manager \ - -T backend pytest tests/backup tests/tenant_migration -q + docker run --rm --network _default --entrypoint pytest \ + -v "$PWD/backend:/app" -v "$PWD:/workspace" -v "$PWD/scripts:/run/spaceworks-privileged-scripts" \ + -w /app \ + -e DATABASE_URL=postgres://makerspace:makerspace@db:5432/makerspace_manager \ + -e SECRET_KEY=test-only \ + -e API_CLIENT_ENC_KEY="$(python3 -c 'from cryptography.fernet import Fernet;print(Fernet.generate_key().decode())')" \ + -e AUDIT_MAC_MASTER_KEY="$(python3 -c 'from cryptography.fernet import Fernet;print(Fernet.generate_key().decode())')" \ + spaceworks-backend tests/backup tests/tenant_migration -q ``` + + Verified this way on 2026-09-07 at `6629e60a`: **1559 passed, 0 failed** in ~59 minutes, which is + also the proof that the host's `PostgresClientUnavailable` failures above are purely environmental. - **Chain every new migration off the ACTUAL leaf** — `ls backend/apps//migrations/`, never the number a spec quotes. - **Commits sit local and unpushed on `dev`; pushing is the owner's call alone.** Ask @@ -233,6 +300,7 @@ starting a build.** These are the rules you must not violate without having read ./scripts/dev-local.sh infra && ./scripts/dev-local.sh test # host: faster pytest, most of the suite # In Docker, pytest needs the DB OWNER: the backend runs as `spaceworks_app`, which has no CREATEDB. +# `tests/backup` and `tests/tenant_migration` additionally need the two Fernet keys above. ./scripts/dev-docker.sh exec -e DATABASE_URL=postgres://makerspace:makerspace@db:5432/makerspace_manager \ -T backend pytest ``` diff --git a/Dockerfile.allinone b/Dockerfile.allinone new file mode 100644 index 00000000..762ded18 --- /dev/null +++ b/Dockerfile.allinone @@ -0,0 +1,83 @@ +# The single-box image: nginx + the built frontend + gunicorn + the live SSE process + a +# Celery worker + the scheduler loop, in ONE container, supervised by docker/allinone/run.sh. +# +# It is the same backend image plus nginx and the frontend build, so every fail-closed +# rule the multi-service stack enforces still holds: each process goes through +# scripts/spaceworks_entrypoint.py with its own --role, the host marker is read the same +# way, and the container runs unprivileged. What it removes is the number of things an +# operator has to run: `app`, Postgres and MinIO (and Redis inside `app`). +# +# docker build -f Dockerfile.allinone -t spaceworks-allinone . +# docker compose -f docker-compose.yml -f docker/compose.single.yml --profile single up -d + +FROM node:22-alpine AS frontend +WORKDIR /app +COPY frontend/package*.json ./ +RUN npm ci +ARG VITE_API_URL=/api +ARG VITE_TENANT_TOKEN= +ARG VITE_PUBLIC_API_KEY= +ARG VITE_PUBLIC_CLIENT_ID= +ENV VITE_API_URL=$VITE_API_URL VITE_TENANT_TOKEN=$VITE_TENANT_TOKEN \ + VITE_PUBLIC_API_KEY=$VITE_PUBLIC_API_KEY VITE_PUBLIC_CLIENT_ID=$VITE_PUBLIC_CLIENT_ID +COPY frontend/ . +RUN npm run build + +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 + +ARG POSTGRESQL_CLIENT_MAJOR=17 +ARG POSTGRESQL_LOCAL_SERVER_MAJOR=16 +# Same two Postgres client majors as backend/Dockerfile (see the reasoning there), plus +# nginx and redis-server so the box needs no separate cache/broker container. +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates curl gnupg age nginx redis-server \ + && install -d /usr/share/postgresql-common/pgdg \ + && curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc \ + | gpg --dearmor -o /usr/share/postgresql-common/pgdg/apt.postgresql.org.gpg \ + && . /etc/os-release \ + && echo "deb [signed-by=/usr/share/postgresql-common/pgdg/apt.postgresql.org.gpg] https://apt.postgresql.org/pub/repos/apt ${VERSION_CODENAME}-pgdg main" \ + > /etc/apt/sources.list.d/pgdg.list \ + && apt-get update \ + && apt-get install -y --no-install-recommends \ + "postgresql-client-$POSTGRESQL_CLIENT_MAJOR" \ + "postgresql-client-$POSTGRESQL_LOCAL_SERVER_MAJOR" \ + && rm -rf /var/lib/apt/lists/* + +RUN groupadd --system --gid 10001 app \ + && useradd --system --create-home --uid 10001 --gid app app + +WORKDIR /app +COPY backend/requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY --chown=app:app backend/ . + +ARG BUILD_GIT_SHA=unknown +ARG BUILD_GIT_DESCRIBE=unknown +RUN BUILD_GIT_SHA="$BUILD_GIT_SHA" BUILD_GIT_DESCRIBE="$BUILD_GIT_DESCRIBE" \ + python /app/scripts/write_build_info.py \ + && chown app:app /app/BUILD_INFO.json + +# nginx runs unprivileged too: it listens on 8080 (not 80), keeps its pid and temp files +# under /var/lib/spaceworks/nginx, and serves the SPA from /srv/www. The frontend's +# runtime config writer is reused so TENANT_API_URL / TENANT_TOKEN behave as in the +# multi-service image. +COPY --from=frontend /app/dist /srv/www +COPY frontend/docker-entrypoint.d/30-write-config.sh /app/allinone/write-config.sh +COPY docker/allinone/nginx.conf /app/allinone/nginx.conf +COPY docker/allinone/run.sh /app/allinone/run.sh +RUN sed -i 's/\r$//' /app/allinone/write-config.sh /app/allinone/run.sh \ + && chmod +x /app/allinone/write-config.sh /app/allinone/run.sh \ + && mkdir -p /app/staticfiles /var/lib/celery /var/lib/spaceworks/ops /var/lib/spaceworks/nginx \ + /var/lib/spaceworks/redis /run/spaceworks-host \ + && chown -R app:app /app/staticfiles /var/lib/celery /var/lib/spaceworks /srv/www + +USER app + +EXPOSE 8080 + +# Every process inside is launched through the fail-closed entrypoint by run.sh. +ENTRYPOINT ["/app/allinone/run.sh"] diff --git a/README.md b/README.md index 64309ebb..2b6a3da5 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,15 @@ Telegram group, QR namespace, and audit scope — fully isolated from the others linked to any number of spaces, with a public profile and a cross-makerspace event catalogue. An organization grant confers **actions, never identity**. - **QR everywhere** — boxes, tools, and individual assets; immutable scan history. +- **Live staff console and one search box** — queues, loans and check-ins update the moment a change + commits (Server-Sent Events over the deployment's Redis; falls back to polling without it), and every + inventory, machine and event list answers `?q=` with full-text search that tolerates typos. +- **Editions and a single box** — an events-only or bookings-only installation hides the loan spine it + does not use (`SPACEWORKS_EDITION`), and the whole application can run as one container beside + Postgres and MinIO when a makerspace has one small server and nobody to operate a stack. +- **Operable in production** — every request carries an `X-Request-ID` through JSON logs and Celery, + a token-protected Prometheus endpoint reports queue depth, delivery failures and storage, and CI runs + the whole suite before an image is published. - **Action-based staff console** — editable per-makerspace roles over a fixed action set, four seeded defaults, and a superadmin-only Django control plane. - **Reports & ledger** — what's out, who has it, overdue tracking, CSV/XLSX export, plus accessible @@ -260,6 +269,7 @@ cannot be removed. **Default** means it is on when you install without choosing | | [`slack`](docs/MODULES.md#slack) | | | Slack alerts | | | [`mattermost`](docs/MODULES.md#mattermost) | | | Mattermost alerts | | | [`discord`](docs/MODULES.md#discord) | | | Discord alerts | +| | [`webhook`](docs/MODULES.md#webhook) | | | Signed JSON webhooks to your own systems | | **Reports** | [`reports`](docs/MODULES.md#reports) | | | Analytics, the ledger and CSV/XLSX export | | **Payments** | [`payments`](docs/MODULES.md#payments) | | ● | Taking money online (Stripe or Razorpay) | | **Mobile apps** | [`mobile`](docs/MODULES.md#mobile) | | | Attested device sessions, native push, payment sheet | @@ -286,8 +296,10 @@ Manager** in the console rather than a superadmin. | `payments.bookings` | `bookings` | | Charge for bookings | | `payments.events` | `events` | | Charge for event registration | | `payments.membership` | `membership` | | Charge membership dues | +| `payments.loans` | `payments` | | Raise a deposit when a loan is issued and a capped late fee when it comes back late | | `mobile.push` | `mobile` | ● | Native push notifications | | `events.offline_checkin` | `events` | | Expiring on-device roster and event-scoped PIN check-in stations | +| `machines.certifications` | `machines` | | Members need an unexpired certification per machine type to book a linked space or request work; overrides need machine-type authority and are audited | | `notifications.delegated_recipients` | `notifications` | | Machine-scoped maintainers manage maintenance recipients for their own machines (also needs `maintenance` and `machines`) | | `inventory.self_checkout` | — | ● | Member self-checkout and staff direct handouts | | `presence.geofence` | — | ● | Advisory location check at check-in (never blocks) | @@ -309,7 +321,7 @@ Stripe or Razorpay credentials resolve. The same is true of push (needs FCM/APNs ### Notification channels are modules -`email`, `telegram`, `slack`, `mattermost` and `discord` are each a module, so a space that lives in +`email`, `telegram`, `slack`, `mattermost`, `discord` and `webhook` are each a module, so a space that lives in Discord ships no Slack surface at all. Turning a channel's module on never makes it start sending on its own — you still add the webhook or token, and you still enable the events you want in the per-feature × per-channel matrix. Turning it off stops delivery but **keeps the stored credential**, diff --git a/backend/apps/accounts/claim_routes_member.py b/backend/apps/accounts/claim_routes_member.py index d8d27d2d..c97be535 100644 --- a/backend/apps/accounts/claim_routes_member.py +++ b/backend/apps/accounts/claim_routes_member.py @@ -78,6 +78,35 @@ def _options(name): tenant=ID, audited=True, ownership=RowOwnership.LOCALLY_FILTERED ), **_options("member-profile-image"), + # Member ID cards (forward plan phase 5): same rule as the calendar-feed bearer token — + # a walk-in claim session never holds or edits a durable credential. + ("member-card", "GET"): Refused( + "a member card is a durable physical credential; claim sessions cannot read or manage it" + ), + ("member-card", "HEAD"): Refused( + "a member card is a durable physical credential; claim sessions cannot read or manage it" + ), + ("member-card", "PATCH"): Refused( + "a member card is a durable physical credential; claim sessions cannot read or manage it" + ), + **_options("member-card"), + ("member-card-photo", "POST"): Refused( + "a member card is a durable physical credential; claim sessions cannot read or manage it" + ), + ("member-card-photo", "PUT"): Refused( + "a member card is a durable physical credential; claim sessions cannot read or manage it" + ), + ("member-card-photo", "DELETE"): Refused( + "a member card is a durable physical credential; claim sessions cannot read or manage it" + ), + **_options("member-card-photo"), + ("member-card-preview", "GET"): Refused( + "a member card is a durable physical credential; claim sessions cannot read or manage it" + ), + ("member-card-preview", "HEAD"): Refused( + "a member card is a durable physical credential; claim sessions cannot read or manage it" + ), + **_options("member-card-preview"), ("member-directory", "GET"): ReadOnly(tenant=ID), ("member-directory", "HEAD"): ReadOnly(tenant=ID), **_options("member-directory"), diff --git a/backend/apps/accounts/claim_routes_public.py b/backend/apps/accounts/claim_routes_public.py index 720fc0a2..a76b7914 100644 --- a/backend/apps/accounts/claim_routes_public.py +++ b/backend/apps/accounts/claim_routes_public.py @@ -69,6 +69,12 @@ def _anonymous(name): "walk-ins cannot satisfy verified-email eligibility" ), ("public-membership-request", "OPTIONS"): AnonymousRead(), + # A walk-in already holds a membership claim; asking to be invited makes no sense + # from inside one, and the anonymous path stays open to everyone else. + ("public-invitation-request", "POST"): Refused( + "walk-ins already hold a membership; invitation requests are for outsiders" + ), + ("public-invitation-request", "OPTIONS"): AnonymousRead(), ("hardware_requests:request-submit", "POST"): Allowed( tenant=SLUG, audited=True ), diff --git a/backend/apps/accounts/rbac_actions.py b/backend/apps/accounts/rbac_actions.py index f163a49a..8464339f 100644 --- a/backend/apps/accounts/rbac_actions.py +++ b/backend/apps/accounts/rbac_actions.py @@ -31,6 +31,11 @@ class Action: MANAGE_QR = "manage_qr" MANAGE_PRINTING = "manage_printing" MANAGE_MACHINES = "manage_machines" + # Member ID cards (phase 5). SCAN resolves a card to minimal identity; MANAGE issues, + # reissues, revokes and prints, and implies SCAN. Deliberately not part of + # VIEW_INVENTORY: an inventory-only role must never resolve a person from a QR. + SCAN_MEMBER_CARDS = "scan_member_cards" + MANAGE_MEMBER_CARDS = "manage_member_cards" VIEW_AUDIT = "view_audit" TRANSFER_STOCK = "transfer_stock" # superadmin only MANAGE_STAFF = "manage_staff" # superadmin only @@ -43,6 +48,7 @@ class Action: # MANAGE_MACHINES, so they keep collect authority without a single stored role # changing. Only a role that holds *neither* has to be given the narrow action. Action.MANAGE_MACHINES: {Action.MANAGE_PRINTING, Action.COLLECT_SERVICE_REQUEST}, + Action.MANAGE_MEMBER_CARDS: {Action.SCAN_MEMBER_CARDS}, } @@ -82,7 +88,7 @@ def actions_satisfying(requested_action) -> frozenset: Action.ISSUE_DIRECT_LOAN, Action.RETURN_REQUEST, Action.UPLOAD_EVIDENCE, Action.MANAGE_QR, Action.MANAGE_PRINTING, Action.VIEW_AUDIT, Action.MANAGE_MAKERSPACE, Action.MANAGE_MACHINES, Action.MANAGE_EVENTS, - Action.MANAGE_BOOKINGS, + Action.MANAGE_BOOKINGS, Action.MANAGE_MEMBER_CARDS, } _PRINT_MANAGER_ACTIONS = { Action.MANAGE_PRINTING, diff --git a/backend/apps/admin_api/inventory_filters.py b/backend/apps/admin_api/inventory_filters.py index a847971d..f2d593b5 100644 --- a/backend/apps/admin_api/inventory_filters.py +++ b/backend/apps/admin_api/inventory_filters.py @@ -1,5 +1,7 @@ from django.db.models import F, Q +from apps.inventory.search import apply_q + def apply_inventory_list_filters(queryset, query_params): archived = query_params.get("archived") @@ -7,13 +9,10 @@ def apply_inventory_list_filters(queryset, query_params): queryset = queryset.filter(is_archived=(archived == "true")) q = (query_params.get("q") or "").strip() if q: - queryset = queryset.filter( - Q(name__icontains=q) - | Q(description__icontains=q) - | Q(tracking_mode__icontains=q) - | Q(storage_location__icontains=q) - | Q(category__name__icontains=q) - ) + # Full-text + trigram over the product's own columns (apps/inventory/search.py); the + # category name is not in the vector, so it stays a plain match ORed in. + matched = apply_q(queryset, q).values("pk") + queryset = queryset.filter(Q(pk__in=matched) | Q(category__name__icontains=q)) if query_params.get("low_stock") == "true": queryset = queryset.annotate(available_x5=F("available_quantity") * 5).filter( available_x5__lte=F("total_quantity") diff --git a/backend/apps/admin_api/serializers_certifications.py b/backend/apps/admin_api/serializers_certifications.py new file mode 100644 index 00000000..74adeda8 --- /dev/null +++ b/backend/apps/admin_api/serializers_certifications.py @@ -0,0 +1,75 @@ +"""Serializers for the staff certification-type and grant surfaces.""" + +from rest_framework import serializers + +from apps.machines.models import CertificationGrant, CertificationType + + +class CertificationTypeSerializer(serializers.ModelSerializer): + class Meta: + model = CertificationType + fields = [ + "id", + "machine_type", + "name", + "description", + "validity_days", + "is_required_for_service", + "is_required_for_booking", + "is_active", + "created_at", + "updated_at", + ] + # `makerspace` comes from the URL, never the body: accepting it would let a + # staff member in one lab create a requirement in another. `is_active` is + # cleared by DELETE (soft-delete) rather than edited straight to False, so the + # deactivation is audited as such. + read_only_fields = ["id", "is_active", "created_at", "updated_at"] + + +class CertificationTypeUpdateSerializer(CertificationTypeSerializer): + class Meta(CertificationTypeSerializer.Meta): + # The machine type is immutable: repointing it would silently move every issued + # grant to hardware the trained members were never assessed on. + read_only_fields = CertificationTypeSerializer.Meta.read_only_fields + [ + "machine_type" + ] + + +class CertificationGrantSerializer(serializers.ModelSerializer): + member_name = serializers.SerializerMethodField() + is_live = serializers.SerializerMethodField() + + class Meta: + model = CertificationGrant + fields = [ + "id", + "certification_type", + "membership", + "member_name", + "granted_by", + "granted_at", + "expires_at", + "revoked_at", + "revoked_by", + "notes", + "is_live", + ] + read_only_fields = fields + + def get_member_name(self, obj): + user = obj.membership.user + return user.display_name or user.get_full_name() or user.username + + def get_is_live(self, obj): + return obj.is_active() + + +class CertificationGrantCreateSerializer(serializers.Serializer): + membership_id = serializers.IntegerField() + expires_at = serializers.DateTimeField(required=False, allow_null=True) + notes = serializers.CharField(required=False, allow_blank=True, default="") + + +class CertificationGrantRevokeSerializer(serializers.Serializer): + notes = serializers.CharField(required=False, allow_blank=True, default="") diff --git a/backend/apps/admin_api/serializers_member_memberships.py b/backend/apps/admin_api/serializers_member_memberships.py index 94770dae..5eb38ecc 100644 --- a/backend/apps/admin_api/serializers_member_memberships.py +++ b/backend/apps/admin_api/serializers_member_memberships.py @@ -14,6 +14,11 @@ class InvitationSerializer(RoleIdSerializer): invite_email = serializers.EmailField(max_length=254) +class ApproveRequestSerializer(RoleIdSerializer): + # Optional: approving with a plan opens the membership's first term in the same call. + plan_id = serializers.IntegerField(required=False, allow_null=True) + + class RevokeSerializer(serializers.Serializer): reason = serializers.CharField(required=False, allow_blank=True) diff --git a/backend/apps/admin_api/serializers_membership_plans.py b/backend/apps/admin_api/serializers_membership_plans.py new file mode 100644 index 00000000..069b1a63 --- /dev/null +++ b/backend/apps/admin_api/serializers_membership_plans.py @@ -0,0 +1,67 @@ +"""Serializers for the staff membership-plan, term and invitation-request surfaces.""" +import re + +from rest_framework import serializers + +from apps.makerspaces.models import InvitationRequest, MembershipPlan, MembershipTerm + +_CURRENCY = re.compile(r"^[A-Za-z]{3}$") + + +class MembershipPlanSerializer(serializers.ModelSerializer): + class Meta: + model = MembershipPlan + fields = [ + "id", "name", "interval", "custom_days", "amount", "currency", "is_active", + "created_at", "updated_at", + ] + # `makerspace` comes from the URL, never the body: accepting it would let a + # manager in one space price memberships in another. + read_only_fields = ["id", "created_at", "updated_at"] + + def validate_currency(self, value): + if not _CURRENCY.match(value or ""): + raise serializers.ValidationError("Use a three-letter ISO 4217 code.") + return value.lower() + + def validate(self, attrs): + interval = attrs.get("interval", getattr(self.instance, "interval", None)) + custom_days = attrs.get("custom_days", getattr(self.instance, "custom_days", None)) + if interval == MembershipPlan.Interval.CUSTOM_DAYS: + if not custom_days: + raise serializers.ValidationError( + {"custom_days": "Required for a custom-days interval."} + ) + elif custom_days is not None: + # Silently normalising would hide a client bug; refusing keeps the row honest. + raise serializers.ValidationError( + {"custom_days": "Only a custom-days interval takes a day count."} + ) + return attrs + + +class MembershipTermSerializer(serializers.ModelSerializer): + plan_name = serializers.CharField(source="plan.name", read_only=True) + + class Meta: + model = MembershipTerm + fields = [ + "id", "membership", "plan", "plan_name", "starts_at", "ends_at", "status", + "renewal_payment", "created_by", "created_at", + ] + read_only_fields = fields + + +class MembershipTermCreateSerializer(serializers.Serializer): + plan_id = serializers.IntegerField() + starts_at = serializers.DateTimeField(required=False, allow_null=True) + + +class InvitationRequestSerializer(serializers.ModelSerializer): + class Meta: + model = InvitationRequest + fields = [ + "id", "name", "email", "phone", "message", "status", "handled_by", + "handled_at", "created_at", + ] + read_only_fields = fields diff --git a/backend/apps/admin_api/serializers_memberships.py b/backend/apps/admin_api/serializers_memberships.py index 73efdef6..f8a25dfa 100644 --- a/backend/apps/admin_api/serializers_memberships.py +++ b/backend/apps/admin_api/serializers_memberships.py @@ -26,7 +26,7 @@ class Meta: model = MakerspaceMembership fields = ( "id", "user", "makerspace_id", "makerspace_slug", "role", - "assigned_role", "created_at", "payment", + "assigned_role", "status", "created_at", "payment", ) read_only_fields = fields diff --git a/backend/apps/admin_api/serializers_notification_destinations.py b/backend/apps/admin_api/serializers_notification_destinations.py index 2b95b338..07543f17 100644 --- a/backend/apps/admin_api/serializers_notification_destinations.py +++ b/backend/apps/admin_api/serializers_notification_destinations.py @@ -21,6 +21,7 @@ class NotificationDestinationSerializer(serializers.ModelSerializer): # it can post into the room. `credential_set` is what the console renders instead, the # same contract as the makerspace `*_set` booleans it replaces. credential_set = serializers.SerializerMethodField() + signing_secret_set = serializers.SerializerMethodField() scope = serializers.SerializerMethodField() class Meta: @@ -32,6 +33,7 @@ class Meta: "telegram_chat_id", "is_active", "credential_set", + "signing_secret_set", "scope", "created_at", "updated_at", @@ -41,6 +43,9 @@ class Meta: def get_credential_set(self, obj): return bool(obj.webhook_url or obj.telegram_chat_id) + def get_signing_secret_set(self, obj): + return bool(obj.signing_secret) + def get_scope(self, obj): return { "machine_type_ids": [row.machine_type_id for row in obj.machine_type_scopes.all()], @@ -60,6 +65,11 @@ class NotificationDestinationWriteSerializer(serializers.Serializer): telegram_chat_id = serializers.CharField( required=False, allow_blank=True, max_length=64 ) + # `webhook` channel only. Write-only and optional on update, like the URL. + signing_secret = serializers.CharField( + required=False, allow_blank=True, write_only=True, min_length=16, max_length=512, + trim_whitespace=False, + ) is_active = serializers.BooleanField(required=False, default=True) scope = DestinationScopeSerializer(required=False) @@ -97,6 +107,17 @@ def validate(self, attrs): attrs["webhook_url"] = validate_webhook_url(webhook) except serializers.ValidationError as exc: raise serializers.ValidationError({"webhook_url": exc.detail}) from exc + secret = attrs.get("signing_secret") or "" + has_stored_secret = bool(existing.signing_secret) if existing else False + if channel == ChatNotificationChannel.WEBHOOK: + if not (secret or has_stored_secret): + raise serializers.ValidationError( + {"signing_secret": "A signed webhook needs a signing secret (16+ characters)."} + ) + elif secret: + raise serializers.ValidationError( + {"signing_secret": "Only signed-webhook destinations carry a signing secret."} + ) if existing is not None and existing.channel != channel: # Changing a room's channel would leave a credential of the wrong shape and # silently repoint an operator's scope links at a different provider. diff --git a/backend/apps/admin_api/serializers_payments.py b/backend/apps/admin_api/serializers_payments.py index f9f25c94..190fb763 100644 --- a/backend/apps/admin_api/serializers_payments.py +++ b/backend/apps/admin_api/serializers_payments.py @@ -47,6 +47,12 @@ class Meta: "connect_payouts_enabled", "connect_status_updated_at", "effective_mode", + "loan_deposit_mode", + "loan_deposit_amount", + "loan_late_fee_per_day", + "loan_late_fee_cap", + "loan_grace_days", + "loan_deposit_blocks_issue", ) read_only_fields = ( "connect_account_id", diff --git a/backend/apps/admin_api/urls.py b/backend/apps/admin_api/urls.py index 420f4f55..4a9e60de 100644 --- a/backend/apps/admin_api/urls.py +++ b/backend/apps/admin_api/urls.py @@ -1,3 +1,4 @@ +from .urls_certifications import urlpatterns as certification_urlpatterns from .urls_api_clients import ( client_urlpatterns, makerspace_urlpatterns as api_client_makerspace_urlpatterns, @@ -6,6 +7,7 @@ from .urls_machine_service import urlpatterns as machine_service_urlpatterns from .urls_machines import urlpatterns as machine_urlpatterns from .urls_makerspaces import urlpatterns as makerspace_urlpatterns +from .urls_membership_plans import urlpatterns as membership_plan_urlpatterns from .urls_memberships import management_urlpatterns, roster_urlpatterns from .urls_notifications import urlpatterns as notification_urlpatterns from .urls_platform import settings_urlpatterns, urlpatterns as platform_urlpatterns @@ -19,6 +21,8 @@ *machine_service_urlpatterns, *management_urlpatterns, *machine_urlpatterns, + *certification_urlpatterns, + *membership_plan_urlpatterns, *settings_urlpatterns, *makerspace_urlpatterns, *inventory_urlpatterns, diff --git a/backend/apps/admin_api/urls_certifications.py b/backend/apps/admin_api/urls_certifications.py new file mode 100644 index 00000000..c4f18385 --- /dev/null +++ b/backend/apps/admin_api/urls_certifications.py @@ -0,0 +1,32 @@ +from django.urls import path + +from apps.admin_api.views_certifications import ( + CertificationGrantListCreateView, + CertificationGrantRevokeView, + CertificationTypeDetailView, + CertificationTypeListCreateView, +) + + +urlpatterns = [ + path( + 'makerspace//certification-types', + CertificationTypeListCreateView.as_view(), + name='admin-certification-types', + ), + path( + 'certification-types/', + CertificationTypeDetailView.as_view(), + name='admin-certification-type-detail', + ), + path( + 'certification-types//grants', + CertificationGrantListCreateView.as_view(), + name='admin-certification-type-grants', + ), + path( + 'certification-grants//revoke', + CertificationGrantRevokeView.as_view(), + name='admin-certification-grant-revoke', + ), +] diff --git a/backend/apps/admin_api/urls_machine_service.py b/backend/apps/admin_api/urls_machine_service.py index c1aebd0f..6a59b109 100644 --- a/backend/apps/admin_api/urls_machine_service.py +++ b/backend/apps/admin_api/urls_machine_service.py @@ -75,11 +75,10 @@ MachineServiceCollectView.as_view(), name="admin-machine-service-request-collect", ), - *_separable( - "payments", - path("machine-service/payments//mark-offline", PaymentMarkOfflineView.as_view(), name="admin-machine-service-payment-mark-offline"), - path("machine-service/payments//waive", PaymentWaiveView.as_view(), name="admin-machine-service-payment-waive"), - ), + # Unconditional: these SETTLE a machine job's charge, they do not collect it through + # a provider. A deployment shipping no rail still has staff taking cash at the desk. + path("machine-service/payments//mark-offline", PaymentMarkOfflineView.as_view(), name="admin-machine-service-payment-mark-offline"), + path("machine-service/payments//waive", PaymentWaiveView.as_view(), name="admin-machine-service-payment-waive"), path( "machine-service/requests//reprint", MachineServiceReprintView.as_view(), diff --git a/backend/apps/admin_api/urls_membership_plans.py b/backend/apps/admin_api/urls_membership_plans.py new file mode 100644 index 00000000..bb2bdfe3 --- /dev/null +++ b/backend/apps/admin_api/urls_membership_plans.py @@ -0,0 +1,52 @@ +from django.urls import path + +from apps.admin_api.views_invitation_requests import ( + InvitationRequestDeclineView, + InvitationRequestInviteView, + InvitationRequestListView, +) +from apps.admin_api.views_membership_plans import ( + MembershipPlanDetailView, + MembershipPlanListCreateView, + MembershipTermCancelView, + MembershipTermListCreateView, +) + + +urlpatterns = [ + path( + "makerspaces//membership-plans", + MembershipPlanListCreateView.as_view(), + name="admin-membership-plans", + ), + path( + "membership-plans/", + MembershipPlanDetailView.as_view(), + name="admin-membership-plan-detail", + ), + path( + "memberships//terms", + MembershipTermListCreateView.as_view(), + name="admin-membership-terms", + ), + path( + "membership-terms//cancel", + MembershipTermCancelView.as_view(), + name="admin-membership-term-cancel", + ), + path( + "makerspaces//invitation-requests", + InvitationRequestListView.as_view(), + name="admin-invitation-requests", + ), + path( + "invitation-requests//invite", + InvitationRequestInviteView.as_view(), + name="admin-invitation-request-invite", + ), + path( + "invitation-requests//decline", + InvitationRequestDeclineView.as_view(), + name="admin-invitation-request-decline", + ), +] diff --git a/backend/apps/admin_api/urls_memberships.py b/backend/apps/admin_api/urls_memberships.py index 78262f57..9111079b 100644 --- a/backend/apps/admin_api/urls_memberships.py +++ b/backend/apps/admin_api/urls_memberships.py @@ -30,6 +30,16 @@ RoleMachineScopeView, ) from apps.admin_api.views_walk_in import WalkInMemberCreateView +from apps.makerspaces.member_card_admin_views import ( + MemberCardIssueView, + MemberCardListView, + MemberCardPrintView, + MemberCardReissueView, + MemberCardResolveView, + MemberCardRevokeView, + MemberCardSheetView, + MemberCardTemplateView, +) from apps.admin_api.views_waiver_witness import AdminWitnessWaiverAcceptanceView @@ -60,6 +70,14 @@ WalkInMemberCreateView.as_view(), name="admin-walk-in-member-create", ), + path("makerspaces//member-cards", MemberCardListView.as_view(), name="admin-member-cards"), + path("makerspaces//member-cards//issue", MemberCardIssueView.as_view(), name="admin-member-card-issue"), + path("makerspaces//member-cards.pdf", MemberCardSheetView.as_view(), name="admin-member-cards-sheet"), + path("makerspaces//member-cards/resolve", MemberCardResolveView.as_view(), name="admin-member-card-resolve"), + path("makerspaces//member-card-template", MemberCardTemplateView.as_view(), name="admin-member-card-template"), + path("member-cards//reissue", MemberCardReissueView.as_view(), name="admin-member-card-reissue"), + path("member-cards//revoke", MemberCardRevokeView.as_view(), name="admin-member-card-revoke"), + path("member-cards//print.pdf", MemberCardPrintView.as_view(), name="admin-member-card-print"), path( "makerspaces//member-claim-codes", MemberClaimCodeListCreateView.as_view(), diff --git a/backend/apps/admin_api/urls_platform.py b/backend/apps/admin_api/urls_platform.py index 28e5d8f9..2dbe9066 100644 --- a/backend/apps/admin_api/urls_platform.py +++ b/backend/apps/admin_api/urls_platform.py @@ -198,7 +198,7 @@ ), ), *_separable( - "payments", + "payments_rail", path( "platform/payment-settings", PlatformStripeConnectSettingsView.as_view(), diff --git a/backend/apps/admin_api/views_certifications.py b/backend/apps/admin_api/views_certifications.py new file mode 100644 index 00000000..005f1de4 --- /dev/null +++ b/backend/apps/admin_api/views_certifications.py @@ -0,0 +1,288 @@ +"""Staff surfaces for certification types and the grants issued against them. + +Authority is per MACHINE TYPE, not per makerspace: a role scoped to the lasers must not +be able to declare who is trained on the printers. `access.can_create_machine` is the +type-level predicate (`MANAGE_MACHINES` narrowed to a linked type, or the type's own +direct manager), and the SAME predicate filters the list — a row that lists and then 403s +on click is the failure mode `docs/INVARIANTS.md` calls out under Machine scoping. +""" + +from datetime import timedelta + +from django.shortcuts import get_object_or_404 +from django.utils import timezone +from drf_spectacular.utils import OpenApiResponse, extend_schema +from rest_framework import status +from rest_framework.exceptions import PermissionDenied, ValidationError +from rest_framework.response import Response +from rest_framework.views import APIView + +from apps.accounts import rbac +from apps.admin_api.permissions import IsActiveStaff +from apps.admin_api.serializers_certifications import ( + CertificationGrantCreateSerializer, + CertificationGrantRevokeSerializer, + CertificationGrantSerializer, + CertificationTypeSerializer, + CertificationTypeUpdateSerializer, +) +from apps.audit import services as audit +from apps.machines import access +from apps.machines.models import CertificationGrant, CertificationType, MachineType +from apps.makerspaces.guards import require_module +from apps.makerspaces.models import MakerspaceMembership + + +def _scoped_types(user): + """Certification types inside the actor's tenant scope. Type authority is separate.""" + return rbac.scope_by_makerspace( + user, CertificationType.objects.select_related("makerspace", "machine_type") + ) + + +def _resolved_type(user, pk): + row = get_object_or_404(_scoped_types(user), pk=pk) + require_module(row.makerspace, "machines") + if not access.can_create_machine(user, row.makerspace_id, row.machine_type): + raise PermissionDenied() + return row + + +class CertificationTypeListCreateView(APIView): + permission_classes = [IsActiveStaff] + + @extend_schema( + tags=["Admin machines"], + summary="List certification types in a makerspace", + request=None, + responses={200: CertificationTypeSerializer(many=True)}, + ) + def get(self, request, makerspace_id, *args, **kwargs): + require_module(makerspace_id, "machines") + if not access.can_see_machines(request.user, makerspace_id): + raise PermissionDenied() + rows = [ + row + for row in _scoped_types(request.user) + .filter(makerspace_id=makerspace_id) + .order_by("machine_type__name", "name", "pk") + # Same predicate as the mutation gate, deliberately per-row: the set of + # certification types in a lab is small, and agreeing exactly with the + # object check matters more here than saving a query. + if access.can_create_machine(request.user, makerspace_id, row.machine_type) + ] + return Response(CertificationTypeSerializer(rows, many=True).data) + + @extend_schema( + tags=["Admin machines"], + summary="Create a certification type", + request=CertificationTypeSerializer, + responses={ + 201: CertificationTypeSerializer, + 400: OpenApiResponse(description="Invalid certification type."), + 403: OpenApiResponse(description="Machine type authority required."), + }, + ) + def post(self, request, makerspace_id, *args, **kwargs): + makerspace = require_module(makerspace_id, "machines") + serializer = CertificationTypeSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + machine_type = get_object_or_404( + MachineType.objects.filter( + pk=serializer.validated_data["machine_type"].pk + ), + ) + if machine_type.makerspace_id not in (None, makerspace_id): + raise ValidationError({"machine_type": "Unknown machine type."}) + if not access.can_create_machine(request.user, makerspace_id, machine_type): + raise PermissionDenied() + row = CertificationType.objects.create( + makerspace=makerspace, **serializer.validated_data + ) + audit.record( + request.user, + "certification_type.created", + makerspace=makerspace, + target=row, + meta={"machine_type_id": machine_type.pk, "name": row.name}, + ) + return Response( + CertificationTypeSerializer(row).data, status=status.HTTP_201_CREATED + ) + + +class CertificationTypeDetailView(APIView): + permission_classes = [IsActiveStaff] + + @extend_schema( + tags=["Admin machines"], + summary="Update a certification type", + request=CertificationTypeUpdateSerializer, + responses={ + 200: CertificationTypeSerializer, + 400: OpenApiResponse(description="Invalid certification type."), + 403: OpenApiResponse(description="Machine type authority required."), + }, + ) + def patch(self, request, pk, *args, **kwargs): + row = _resolved_type(request.user, pk) + serializer = CertificationTypeUpdateSerializer( + row, data=request.data, partial=True + ) + serializer.is_valid(raise_exception=True) + row = serializer.save() + audit.record( + request.user, + "certification_type.updated", + makerspace=row.makerspace, + target=row, + meta={"fields": sorted(serializer.validated_data.keys())}, + ) + return Response(CertificationTypeSerializer(row).data) + + @extend_schema( + tags=["Admin machines"], + summary="Deactivate a certification type", + request=None, + responses={ + 200: CertificationTypeSerializer, + 403: OpenApiResponse(description="Machine type authority required."), + }, + ) + def delete(self, request, pk, *args, **kwargs): + # Soft-delete. Issued grants are evidence that a member was trained on a date; + # deleting the type would cascade them away and erase that trail. + row = _resolved_type(request.user, pk) + if row.is_active: + row.is_active = False + row.save(update_fields=["is_active", "updated_at"]) + audit.record( + request.user, + "certification_type.deactivated", + makerspace=row.makerspace, + target=row, + meta={"name": row.name}, + ) + return Response(CertificationTypeSerializer(row).data) + + +class CertificationGrantListCreateView(APIView): + permission_classes = [IsActiveStaff] + + @extend_schema( + tags=["Admin machines"], + summary="List grants for a certification type", + request=None, + responses={200: CertificationGrantSerializer(many=True)}, + ) + def get(self, request, pk, *args, **kwargs): + row = _resolved_type(request.user, pk) + grants = ( + CertificationGrant.objects.filter(certification_type=row) + .select_related("membership__user") + .order_by("-granted_at", "-pk") + ) + return Response(CertificationGrantSerializer(grants, many=True).data) + + @extend_schema( + tags=["Admin machines"], + summary="Grant a certification to a membership", + request=CertificationGrantCreateSerializer, + responses={ + 201: CertificationGrantSerializer, + 400: OpenApiResponse(description="Invalid grant."), + 403: OpenApiResponse(description="Machine type authority required."), + }, + ) + def post(self, request, pk, *args, **kwargs): + row = _resolved_type(request.user, pk) + serializer = CertificationGrantCreateSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + data = serializer.validated_data + membership = MakerspaceMembership.objects.filter( + pk=data["membership_id"], makerspace_id=row.makerspace_id + ).first() + if membership is None: + # A membership in another makerspace is "unknown" here, never a 403 that + # confirms it exists elsewhere. + raise ValidationError({"membership_id": "Unknown membership."}) + expires_at = data.get("expires_at") + if expires_at is None and row.validity_days: + expires_at = timezone.now() + timedelta(days=row.validity_days) + grant = CertificationGrant.objects.create( + certification_type=row, + membership=membership, + granted_by=request.user, + expires_at=expires_at, + notes=data.get("notes", ""), + ) + audit.record( + request.user, + "certification.granted", + makerspace=row.makerspace, + target=grant, + meta={ + "certification_type_id": row.pk, + "membership_id": membership.pk, + "expires_at": expires_at.isoformat() if expires_at else None, + }, + ) + return Response( + CertificationGrantSerializer(grant).data, status=status.HTTP_201_CREATED + ) + + +class CertificationGrantRevokeView(APIView): + permission_classes = [IsActiveStaff] + + @extend_schema( + tags=["Admin machines"], + summary="Revoke a certification grant", + request=CertificationGrantRevokeSerializer, + responses={ + 200: CertificationGrantSerializer, + 400: OpenApiResponse(description="Already revoked."), + 403: OpenApiResponse(description="Machine type authority required."), + }, + ) + def post(self, request, pk, *args, **kwargs): + grant = get_object_or_404( + CertificationGrant.objects.select_related( + "certification_type__makerspace", + "certification_type__machine_type", + "membership__user", + ).filter( + certification_type__in=_scoped_types(request.user).values("pk") + ), + pk=pk, + ) + certification_type = grant.certification_type + require_module(certification_type.makerspace, "machines") + if not access.can_create_machine( + request.user, + certification_type.makerspace_id, + certification_type.machine_type, + ): + raise PermissionDenied() + if grant.revoked_at is not None: + raise ValidationError("This certification is already revoked.") + serializer = CertificationGrantRevokeSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + grant.revoked_at = timezone.now() + grant.revoked_by = request.user + notes = serializer.validated_data.get("notes", "") + if notes: + grant.notes = notes + grant.save(update_fields=["revoked_at", "revoked_by", "notes"]) + audit.record( + request.user, + "certification.revoked", + makerspace=certification_type.makerspace, + target=grant, + meta={ + "certification_type_id": certification_type.pk, + "membership_id": grant.membership_id, + "notes": notes, + }, + ) + return Response(CertificationGrantSerializer(grant).data) diff --git a/backend/apps/admin_api/views_inventory_products.py b/backend/apps/admin_api/views_inventory_products.py index cd8489b1..d3e7f892 100644 --- a/backend/apps/admin_api/views_inventory_products.py +++ b/backend/apps/admin_api/views_inventory_products.py @@ -1,5 +1,5 @@ from django.shortcuts import get_object_or_404 -from drf_spectacular.utils import extend_schema +from drf_spectacular.utils import OpenApiParameter, extend_schema, extend_schema_view from rest_framework import generics from rest_framework.exceptions import ValidationError from rest_framework.pagination import PageNumberPagination @@ -28,6 +28,21 @@ class InventoryPagination(PageNumberPagination): from apps.makerspaces.guards import require_module +@extend_schema_view( + get=extend_schema( + parameters=[ + OpenApiParameter( + name="q", type=str, location=OpenApiParameter.QUERY, required=False, + description=( + "Full-text search over name, storage location, tracking mode and description " + "(\"phrases\", -exclusions, typo-tolerant on the name), or a category name." + ), + ), + OpenApiParameter(name="archived", type=str, location=OpenApiParameter.QUERY, required=False, description="`true` or `false`."), + OpenApiParameter(name="low_stock", type=str, location=OpenApiParameter.QUERY, required=False, description="`true` to list items at or below 20% of total."), + ] + ) +) @extend_schema(tags=["Admin inventory"], summary="List or create inventory products") class InventoryListCreateView(generics.ListCreateAPIView): serializer_class = InventoryProductAdminSerializer diff --git a/backend/apps/admin_api/views_invitation_requests.py b/backend/apps/admin_api/views_invitation_requests.py new file mode 100644 index 00000000..046085df --- /dev/null +++ b/backend/apps/admin_api/views_invitation_requests.py @@ -0,0 +1,89 @@ +"""Staff queue for public invitation requests: list, invite, decline. + +`MANAGE_MAKERSPACE` surfaces, module-gated by `membership`. "Invite" calls the ordinary +`membership_services.invite_membership`, so the invitation it produces is subject to the +same role non-escalation and community/staff discrimination as one typed in by hand. +""" +from django.shortcuts import get_object_or_404 +from drf_spectacular.utils import OpenApiResponse, extend_schema +from rest_framework.response import Response +from rest_framework.views import APIView + +from apps.accounts import rbac +from apps.admin_api.permissions import IsActiveStaff +from apps.admin_api.serializers_member_memberships import RoleIdSerializer +from apps.admin_api.serializers_membership_plans import InvitationRequestSerializer +from apps.admin_api.views_member_memberships import _makerspace as manageable_makerspace +from apps.makerspaces.guards import require_module +from apps.makerspaces.invitation_request_services import decline_request, invite_from_request +from apps.makerspaces.models import InvitationRequest, MakerspaceRole + +INVALID = OpenApiResponse(description="Invalid input or request already handled.") + + +def _row(actor, pk): + row = get_object_or_404( + rbac.scope_by_action( + actor, + rbac.Action.MANAGE_MAKERSPACE, + InvitationRequest.objects.select_related("makerspace"), + ), + pk=pk, + ) + require_module(manageable_makerspace(actor, row.makerspace_id), "membership") + return row + + +class InvitationRequestListView(APIView): + permission_classes = [IsActiveStaff] + + @extend_schema( + tags=["Admin memberships"], + summary="List invitation requests (optionally filtered by ?status=)", + responses={200: InvitationRequestSerializer(many=True)}, + ) + def get(self, request, makerspace_id): + makerspace = require_module( + manageable_makerspace(request.user, makerspace_id), "membership" + ) + rows = InvitationRequest.objects.filter(makerspace=makerspace) + wanted = request.query_params.get("status") + if wanted in InvitationRequest.Status.values: + rows = rows.filter(status=wanted) + return Response( + InvitationRequestSerializer(rows.order_by("-created_at", "-pk"), many=True).data + ) + + +class InvitationRequestInviteView(APIView): + permission_classes = [IsActiveStaff] + + @extend_schema( + tags=["Admin memberships"], + summary="Turn an invitation request into a membership invitation", + request=RoleIdSerializer, + responses={200: InvitationRequestSerializer, 400: INVALID}, + ) + def post(self, request, pk): + row = _row(request.user, pk) + serializer = RoleIdSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + role = get_object_or_404( + MakerspaceRole.objects.filter(makerspace_id=row.makerspace_id), + pk=serializer.validated_data["role_id"], + ) + return Response(InvitationRequestSerializer(invite_from_request(request.user, row, role)).data) + + +class InvitationRequestDeclineView(APIView): + permission_classes = [IsActiveStaff] + + @extend_schema( + tags=["Admin memberships"], + summary="Decline an invitation request", + request=None, + responses={200: InvitationRequestSerializer, 400: INVALID}, + ) + def post(self, request, pk): + row = _row(request.user, pk) + return Response(InvitationRequestSerializer(decline_request(request.user, row)).data) diff --git a/backend/apps/admin_api/views_machines.py b/backend/apps/admin_api/views_machines.py index eb4e3053..358bfbe0 100644 --- a/backend/apps/admin_api/views_machines.py +++ b/backend/apps/admin_api/views_machines.py @@ -4,13 +4,14 @@ from django.db.models import Q, Sum from django.db.models.functions import Coalesce from django.shortcuts import get_object_or_404 -from drf_spectacular.utils import OpenApiResponse, extend_schema +from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema from rest_framework import status from rest_framework.exceptions import PermissionDenied from rest_framework.pagination import PageNumberPagination from rest_framework.response import Response from rest_framework.views import APIView +from apps.inventory.search import apply_q from apps.admin_api.machine_access import resolve_machine from apps.admin_api.permissions import IsActiveStaff from apps.audit import services as audit @@ -48,6 +49,12 @@ class MachineListCreateView(APIView): tags=['Admin machines'], summary='List machines in a makerspace', request=None, + parameters=[ + OpenApiParameter( + name='q', type=str, location=OpenApiParameter.QUERY, required=False, + description='Full-text search over name, location, firmware version and notes.', + ), + ], responses={200: MachineListResponseSerializer}, ) def get(self, request, makerspace_id, *args, **kwargs): @@ -70,6 +77,9 @@ def get(self, request, makerspace_id, *args, **kwargs): # by name alone is still unstable wherever two machines share one. .order_by('machine_type__name', 'name', 'pk'), ) + query = request.query_params.get('q', '') + if query.strip(): + queryset = apply_q(queryset, query) paginator = _MachinePagination() page = paginator.paginate_queryset(queryset, request, view=self) capabilities = access.capabilities_for_machines(request.user, page) diff --git a/backend/apps/admin_api/views_member_memberships.py b/backend/apps/admin_api/views_member_memberships.py index 46b8efb8..bd798c18 100644 --- a/backend/apps/admin_api/views_member_memberships.py +++ b/backend/apps/admin_api/views_member_memberships.py @@ -8,13 +8,13 @@ from apps.accounts import rbac from apps.admin_api.permissions import IsActiveStaff -from apps.admin_api.serializers_member_memberships import (AdminMembershipSerializer, InvitationSerializer, - MembershipRequestSerializer, RevokeSerializer, RoleIdSerializer) +from apps.admin_api.serializers_member_memberships import (AdminMembershipSerializer, ApproveRequestSerializer, + InvitationSerializer, MembershipRequestSerializer, RevokeSerializer, RoleIdSerializer) from apps.admin_api.serializers_payment_summary import scoped_payment_context from apps.admin_api.views_roles import ERRORS from apps.makerspaces import membership_services, waiver_services from apps.makerspaces.guards import require_module -from apps.makerspaces.models import MakerspaceMembership, MakerspaceRole, MakerspaceWaiver, MembershipRequest +from apps.makerspaces.models import MakerspaceMembership, MakerspaceRole, MakerspaceWaiver, MembershipPlan, MembershipRequest from apps.makerspaces.serializers_memberships import WaiverPublishSerializer from apps.makerspaces.servability import servable_queryset from apps.payments.models import Payment @@ -110,14 +110,17 @@ def post(self, request, makerspace_id): class AdminRequestApproveView(APIView): permission_classes = [IsActiveStaff] - @extend_schema(tags=["Admin memberships"], request=RoleIdSerializer, responses={200: AdminMembershipSerializer, **ERRORS}) + @extend_schema(tags=["Admin memberships"], request=ApproveRequestSerializer, responses={200: AdminMembershipSerializer, **ERRORS}) def post(self, request, pk): item = get_object_or_404(rbac.scope_by_action(request.user, rbac.Action.MANAGE_MAKERSPACE, MembershipRequest.objects.select_related("makerspace")), pk=pk) makerspace = _makerspace(request.user, item.makerspace_id) require_module(makerspace, "membership") - serializer = RoleIdSerializer(data=request.data) + serializer = ApproveRequestSerializer(data=request.data) serializer.is_valid(raise_exception=True) - membership = membership_services.approve_request(request.user, item, _role(makerspace, serializer.validated_data["role_id"])) + plan = None + if serializer.validated_data.get("plan_id") is not None: + plan = get_object_or_404(MembershipPlan.objects.filter(makerspace=makerspace), pk=serializer.validated_data["plan_id"]) + membership = membership_services.approve_request(request.user, item, _role(makerspace, serializer.validated_data["role_id"]), plan=plan) membership = MakerspaceMembership.objects.select_related( "user", "assigned_role" ).get(pk=membership.pk) diff --git a/backend/apps/admin_api/views_membership_plans.py b/backend/apps/admin_api/views_membership_plans.py new file mode 100644 index 00000000..28d757ae --- /dev/null +++ b/backend/apps/admin_api/views_membership_plans.py @@ -0,0 +1,172 @@ +"""Staff surfaces for membership plans and the terms held under them. + +All four views are `MANAGE_MAKERSPACE` surfaces scoped through `rbac.scope_by_action`, +so a manager of another makerspace sees 404, never 403, and every one of them is gated +by the `membership` module -- plans are community-membership behaviour (A7). +""" +from django.db import IntegrityError, transaction +from django.shortcuts import get_object_or_404 +from drf_spectacular.utils import OpenApiResponse, extend_schema +from rest_framework import status +from rest_framework.exceptions import ValidationError +from rest_framework.response import Response +from rest_framework.views import APIView + +from apps.accounts import rbac +from apps.admin_api.permissions import IsActiveStaff +from apps.admin_api.serializers_membership_plans import ( + MembershipPlanSerializer, + MembershipTermCreateSerializer, + MembershipTermSerializer, +) +from apps.admin_api.views_member_memberships import _makerspace as manageable_makerspace +from apps.audit import services as audit +from apps.makerspaces.guards import require_module +from apps.makerspaces.membership_plan_services import cancel_term, create_term +from apps.makerspaces.models import MakerspaceMembership, MembershipPlan, MembershipTerm + +_MANAGE = rbac.Action.MANAGE_MAKERSPACE +INVALID = OpenApiResponse(description="Invalid input.") + + +def _gated_makerspace(actor, makerspace_id): + return require_module(manageable_makerspace(actor, makerspace_id), "membership") + + +def _scoped(actor, queryset, field="makerspace_id"): + return rbac.scope_by_action(actor, _MANAGE, queryset, field=field) + + +class MembershipPlanListCreateView(APIView): + permission_classes = [IsActiveStaff] + + @extend_schema( + tags=["Admin memberships"], + summary="List membership plans", + responses={200: MembershipPlanSerializer(many=True)}, + ) + def get(self, request, makerspace_id): + makerspace = _gated_makerspace(request.user, makerspace_id) + rows = MembershipPlan.objects.filter(makerspace=makerspace).order_by("name", "pk") + return Response(MembershipPlanSerializer(rows, many=True).data) + + @extend_schema( + tags=["Admin memberships"], + summary="Create a membership plan", + request=MembershipPlanSerializer, + responses={201: MembershipPlanSerializer, 400: INVALID}, + ) + def post(self, request, makerspace_id): + makerspace = _gated_makerspace(request.user, makerspace_id) + serializer = MembershipPlanSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + try: + with transaction.atomic(): + row = MembershipPlan.objects.create( + makerspace=makerspace, **serializer.validated_data + ) + except IntegrityError as exc: + raise ValidationError({"name": "A plan with this name already exists."}) from exc + audit.record( + request.user, + "membership_plan.created", + makerspace=makerspace, + target=row, + meta={"plan_id": row.pk, "name": row.name}, + ) + return Response(MembershipPlanSerializer(row).data, status=status.HTTP_201_CREATED) + + +class MembershipPlanDetailView(APIView): + permission_classes = [IsActiveStaff] + + @extend_schema( + tags=["Admin memberships"], + summary="Update a membership plan", + request=MembershipPlanSerializer, + responses={200: MembershipPlanSerializer, 400: INVALID}, + ) + def patch(self, request, pk): + row = get_object_or_404( + _scoped(request.user, MembershipPlan.objects.select_related("makerspace")), pk=pk + ) + makerspace = _gated_makerspace(request.user, row.makerspace_id) + serializer = MembershipPlanSerializer(row, data=request.data, partial=True) + serializer.is_valid(raise_exception=True) + try: + with transaction.atomic(): + row = serializer.save() + except IntegrityError as exc: + raise ValidationError({"name": "A plan with this name already exists."}) from exc + audit.record( + request.user, + "membership_plan.updated", + makerspace=makerspace, + target=row, + meta={"plan_id": row.pk, "fields": sorted(serializer.validated_data)}, + ) + return Response(MembershipPlanSerializer(row).data) + + +class MembershipTermListCreateView(APIView): + permission_classes = [IsActiveStaff] + + def _membership(self, actor, pk): + membership = get_object_or_404( + _scoped(actor, MakerspaceMembership.objects.select_related("makerspace")), pk=pk + ) + return membership, _gated_makerspace(actor, membership.makerspace_id) + + @extend_schema( + tags=["Admin memberships"], + summary="List the terms of a membership", + responses={200: MembershipTermSerializer(many=True)}, + ) + def get(self, request, pk): + membership, _ = self._membership(request.user, pk) + rows = membership.terms.select_related("plan").order_by("-starts_at", "-pk") + return Response(MembershipTermSerializer(rows, many=True).data) + + @extend_schema( + tags=["Admin memberships"], + summary="Open a term for a membership", + request=MembershipTermCreateSerializer, + responses={201: MembershipTermSerializer, 400: INVALID}, + ) + def post(self, request, pk): + membership, makerspace = self._membership(request.user, pk) + serializer = MembershipTermCreateSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + plan = get_object_or_404( + MembershipPlan.objects.filter(makerspace=makerspace), + pk=serializer.validated_data["plan_id"], + ) + term = create_term( + request.user, membership, plan, starts_at=serializer.validated_data.get("starts_at") + ) + term = MembershipTerm.objects.select_related("plan").get(pk=term.pk) + return Response(MembershipTermSerializer(term).data, status=status.HTTP_201_CREATED) + + +class MembershipTermCancelView(APIView): + permission_classes = [IsActiveStaff] + + @extend_schema( + tags=["Admin memberships"], + summary="Cancel an active term", + request=None, + responses={200: MembershipTermSerializer, 400: INVALID}, + ) + def post(self, request, pk): + term = get_object_or_404( + _scoped( + request.user, + MembershipTerm.objects.select_related("membership__makerspace", "plan"), + field="membership__makerspace_id", + ), + pk=pk, + ) + _gated_makerspace(request.user, term.membership.makerspace_id) + term = cancel_term(request.user, term) + term = MembershipTerm.objects.select_related("plan").get(pk=term.pk) + return Response(MembershipTermSerializer(term).data) diff --git a/backend/apps/admin_api/views_notification_destinations.py b/backend/apps/admin_api/views_notification_destinations.py index d6a6096e..502b3596 100644 --- a/backend/apps/admin_api/views_notification_destinations.py +++ b/backend/apps/admin_api/views_notification_destinations.py @@ -83,6 +83,9 @@ def post(self, request, makerspace_id, *args, **kwargs): raw = (data.get("webhook_url") or "").strip() if raw: destination.set_webhook_url(raw) + secret = data.get("signing_secret") or "" + if secret: + destination.set_signing_secret(secret) destination.save() try: apply_scope(destination, data.get("scope"), makerspace) @@ -137,6 +140,9 @@ def put(self, request, makerspace_id, destination_id, *args, **kwargs): # Blank means "keep the stored credential": the caller cannot read it back, # so requiring it on every edit would force a re-entry to rename a room. destination.set_webhook_url(raw) + secret = data.get("signing_secret") or "" + if secret: + destination.set_signing_secret(secret) destination.save() try: apply_scope(destination, data.get("scope"), makerspace) diff --git a/backend/apps/admin_api/views_payments.py b/backend/apps/admin_api/views_payments.py index 03eef77f..e8d1d0f3 100644 --- a/backend/apps/admin_api/views_payments.py +++ b/backend/apps/admin_api/views_payments.py @@ -11,8 +11,10 @@ from apps.machines import role_scope from apps.machines.models import MachineServiceRequest from apps.payments.models import Payment +from apps.payments.reconciliation import reconcile_payments from apps.payments.serializers import StaffPaymentSerializer -from apps.payments.services import mark_offline, waive +from apps.payments.serializers_reconciliation import PaymentOfflineSerializer +from apps.payments.services import mark_offline, waive # noqa: F401 def _manageable_payment(actor, pk): @@ -34,14 +36,27 @@ class _PaymentActionView(APIView): def post(self, request, pk): payment = _manageable_payment(request.user, pk) - payment = mark_offline(payment, request.user) if self.operation == "offline" else waive(payment, request.user) + if self.operation == "offline": + # Same receipt contract as the generic reconciliation routes: a charge marked + # paid offline must always be able to say how and when the money arrived. + payload = PaymentOfflineSerializer(data=request.data) + payload.is_valid(raise_exception=True) + payment = reconcile_payments( + actor=request.user, + makerspace_id=payment.makerspace_id, + payment_ids=[payment.pk], + target_status=Payment.Status.PAID_OFFLINE, + settlement=payload.validated_data["settlement"], + )[0] + else: + payment = waive(payment, request.user) return Response(StaffPaymentSerializer(payment).data) class PaymentMarkOfflineView(_PaymentActionView): operation = "offline" - @extend_schema(tags=["Payments"], summary="Mark a machine-service payment paid offline", request=None, responses={200: StaffPaymentSerializer, 403: OpenApiResponse(ErrorSerializer), 404: OpenApiResponse(ErrorSerializer)}) + @extend_schema(tags=["Payments"], summary="Mark a machine-service payment paid offline", request=PaymentOfflineSerializer, responses={200: StaffPaymentSerializer, 403: OpenApiResponse(ErrorSerializer), 404: OpenApiResponse(ErrorSerializer)}) def post(self, request, pk): return super().post(request, pk) diff --git a/backend/apps/apiclients/scope_registry_routes.py b/backend/apps/apiclients/scope_registry_routes.py index baa6ad4f..76db1c5b 100644 --- a/backend/apps/apiclients/scope_registry_routes.py +++ b/backend/apps/apiclients/scope_registry_routes.py @@ -71,6 +71,7 @@ class ScopeRegistryEntry: ("presence-current", _READ, PUBLIC_READ_SCOPES, TARGET_TENANT_SLUG, False, True), ("presence-end", _WRITE, PUBLIC_WRITE_SCOPES, TARGET_TENANT_SLUG, False, True), ("public-membership-request", _WRITE, PUBLIC_WRITE_SCOPES, TARGET_TENANT_SLUG, False, True), + ("public-invitation-request", _WRITE, PUBLIC_WRITE_SCOPES, TARGET_TENANT_SLUG, False, True), ( "hardware_requests:request-submit", _WRITE, PUBLIC_WRITE_SCOPES, TARGET_TENANT_SLUG, False, True, diff --git a/backend/apps/audit/services.py b/backend/apps/audit/services.py index e8f8d648..c4d2146a 100644 --- a/backend/apps/audit/services.py +++ b/backend/apps/audit/services.py @@ -17,6 +17,7 @@ ) from apps.audit.models import AuditLog from apps.encryption.blind_index import canonical_email +from apps.operations import live logger = logging.getLogger(__name__) @@ -128,7 +129,7 @@ def record(actor, action, *, makerspace=None, target=None, target_type="", meta= created_at=created_at, ) - return AuditLog.objects.create( + row = AuditLog.objects.create( actor_id=actor_id, action=action, target_type=target_type, @@ -139,3 +140,23 @@ def record(actor, action, *, makerspace=None, target=None, target_type="", meta= row_mac=row_mac, created_at=created_at, ) + # Correlation lives in the log line, not in `meta`: the row's MAC covers meta, and the + # request id is an operational breadcrumb rather than part of the attested record. The + # log formatter adds request_id, so `grep ` finds the request that wrote it. + live.publish_audit_event( + action=action, + makerspace_id=makerspace_id, + target_type=target_type, + target_id=target_id, + actor_id=actor_id, + ) + logger.info( + "audit_recorded", + extra={ + "audit_event_uuid": str(event_uuid), + "audit_action": action, + "makerspace_id": makerspace_id, + "attested": row_mac is not None, + }, + ) + return row diff --git a/backend/apps/backup/archive_objects.py b/backend/apps/backup/archive_objects.py index e02965d2..b9f93287 100644 --- a/backend/apps/backup/archive_objects.py +++ b/backend/apps/backup/archive_objects.py @@ -10,6 +10,7 @@ OBJECT_FIELD_NAMES = frozenset({ "object_key", "image_key", "avatar_key", "cover_image_key", "copy_key", "logo_key", + "photo_object_key", }) NON_OBJECT_KEY_FIELDS = frozenset({ @@ -28,6 +29,7 @@ ("operations.ReportMetricRollup", "dimension_key"), ("operations.ReportMetricRollup", "metric_key"), ("operations.ReportMetricRollup", "report_key"), + ("operations.ReportSchedule", "report_key"), ("backup.RestoreRollbackObject", "source_key"), # Run-owned promotion staging is retry coordination, not durable archive # content. The final object and artifact ledger are the restore authority. @@ -182,4 +184,5 @@ def module_for_model(label): "makerspaces.MemberProfile": "membership", "makerspaces.MemberProject": "membership", "machines.ServiceRequestFile": "machine_service", + "operations.ReportDelivery": "reports", }.get(label, "") diff --git a/backend/apps/backup/object_ownership_registry.py b/backend/apps/backup/object_ownership_registry.py index 2dabaa04..aca3391e 100644 --- a/backend/apps/backup/object_ownership_registry.py +++ b/backend/apps/backup/object_ownership_registry.py @@ -69,7 +69,15 @@ class FieldObjectRule: FieldObjectRule("makerspaces.Makerspace", "cover_image_key", BucketRule.PUBLIC_IMAGE), FieldObjectRule("makerspaces.Makerspace", "logo_key", BucketRule.PUBLIC_IMAGE), FieldObjectRule("makerspaces.MemberProfile", "avatar_key", BucketRule.PUBLIC_IMAGE), + # A member card photo is a face: private bucket, short-lived signed reads only. + FieldObjectRule("makerspaces.MemberCard", "photo_object_key", BucketRule.PRIVATE), FieldObjectRule("makerspaces.MemberProject", "image_key", BucketRule.PUBLIC_IMAGE), + # A delivered report file: private bucket, blanked once its signed link has expired. + # The row is omitted telemetry, so the object is coordination, never archive content. + FieldObjectRule("operations.ReportDelivery", "object_key", BucketRule.PRIVATE, + ReferencePolicy.COORDINATION_ONLY, + coordination_path="schedule__makerspace_id", + coordination_reason="report_delivery_coordination"), FieldObjectRule("organizations.Organization", "logo_key", BucketRule.PUBLIC_IMAGE), FieldObjectRule("procurement.ToBuyReceipt", "object_key", BucketRule.PRIVATE), FieldObjectRule("warranty.WarrantyDocument", "object_key", BucketRule.PRIVATE), diff --git a/backend/apps/backup/settings_policy.py b/backend/apps/backup/settings_policy.py index 195b1cd1..88b1824b 100644 --- a/backend/apps/backup/settings_policy.py +++ b/backend/apps/backup/settings_policy.py @@ -55,6 +55,7 @@ class SettingPolicy: EVENT_CHECKIN_WINDOW_AFTER_HOURS EVENT_CHECKIN_WINDOW_BEFORE_HOURS EVENT_STATION_PIN_PEPPER EVIDENCE_MAX_BYTES EVIDENCE_URL_TTL_SECONDS EVIDENCE_OBJECT_RETENTION_DAYS +REPORT_DELIVERY_URL_TTL_SECONDS EVIDENCE_OBJECT_EXPIRY_ENABLED EVIDENCE_RETENTION_BATCH_SIZE GITHUB_API_TOKEN HMAC_CLIENT_ID HMAC_MAX_CLOCK_SKEW_SECONDS HMAC_PROTECTED_PATH_PREFIXES HMAC_SECRET INFRA_HOSTS @@ -92,7 +93,8 @@ class SettingPolicy: THROTTLE_PASSWORD_RESET_CONFIRM_EMAIL THROTTLE_PASSWORD_RESET_EMAIL THROTTLE_PASSWORD_RESET_REQUEST THROTTLE_PHONE_CONFIRM_NUMBER THROTTLE_PHONE_LOGIN_CONFIRM THROTTLE_PHONE_OTP_NUMBER THROTTLE_PHONE_OTP_REQUEST -THROTTLE_PRINT_REQUEST_SUBMIT THROTTLE_PUBLIC_READ THROTTLE_PUBLIC_REQUEST_SUBMIT +THROTTLE_PRINT_REQUEST_SUBMIT THROTTLE_PUBLIC_INVITATION_REQUEST THROTTLE_PUBLIC_READ +THROTTLE_PUBLIC_REQUEST_SUBMIT THROTTLE_PUBLIC_STATS THROTTLE_PUBLIC_TOOL_CHECKOUT THROTTLE_PUBLIC_TOOL_RETURN THROTTLE_PUSH_DEVICE_REGISTRATION THROTTLE_REQUEST_STATUS THROTTLE_REQUEST_SUBMIT THROTTLE_SOCIAL_LOGIN THROTTLE_SOCIAL_NONCE THROTTLE_TELEGRAM_WEBHOOK @@ -103,6 +105,9 @@ class SettingPolicy: BUILD_GIT_DESCRIBE BUILD_GIT_SHA SETUP_MAKERSPACE_NAME SETUP_MAKERSPACE_SLUG SETUP_MODULE_PROFILE SETUP_SUPERADMIN_EMAIL SETUP_SUPERADMIN_PASSWORD SETUP_SUPERADMIN_USERNAME SPACEWORKS_OCI_DIGEST TOMBSTONED_APPS +LOG_LEVEL LOG_JSON METRICS_TOKEN SENTRY_DSN SENTRY_TRACES_SAMPLE_RATE SENTRY_ENVIRONMENT +CONN_HEALTH_CHECKS LIVE_REDIS_URL LIVE_MAX_STREAM_SECONDS E2E_SEED_ALLOWED E2E_PASSWORD +SPACEWORKS_EDITION """.split()) # The source-gate lease and presign-drain settings are portable operational timing @@ -132,7 +137,7 @@ class SettingPolicy: "PII_ENCRYPTION_DUAL_READ", "PII_KEY_BROKER", "DEVICE_ATTESTATION_APPS", "BACKUP_AGE_RECIPIENT", "TENANT_MIGRATION_AGE_RECIPIENT", "BACKUP_ARCHIVE_VERIFY_PUBLIC_KEY", - "BACKUP_OPS_DIR", "SPACEWORKS_HOST_MARKER_PATH", "MANAGED_POSTGRES", + "BACKUP_OPS_DIR", "SPACEWORKS_HOST_MARKER_PATH", "MANAGED_POSTGRES", "SPACEWORKS_EDITION", # Host-local producer-gate paths: compared like SPACEWORKS_HOST_MARKER_PATH, and # non-blocking because a restore target legitimately installs them elsewhere. "BACKUP_PRODUCER_CAPABILITY_MARKER_PATH", "BACKUP_PRODUCER_PRIVILEGED_SCRIPTS_DIR", @@ -157,7 +162,7 @@ class SettingPolicy: secret_bearing=name in EXACT or name in { "AWS_SECRET_ACCESS_KEY", "EMAIL_HOST_PASSWORD", "CELERY_BROKER_URL", "CELERY_RESULT_BACKEND", "DATABASE_URL", "SPACEWORKS_RUNTIME_DATABASE_URL", - "BACKUP_ARCHIVE_SIGNING_PRIVATE_KEY", + "BACKUP_ARCHIVE_SIGNING_PRIVATE_KEY", "METRICS_TOKEN", "SENTRY_DSN", "LIVE_REDIS_URL", }, blocks_restore=name in EXACT or name in CAPABILITY or name in BLOCKING_VALUE, ) diff --git a/backend/apps/bookings/migrations/0009_certifications.py b/backend/apps/bookings/migrations/0009_certifications.py new file mode 100644 index 00000000..3c39ddfa --- /dev/null +++ b/backend/apps/bookings/migrations/0009_certifications.py @@ -0,0 +1,20 @@ +# Generated by Django 6.0.8 on 2026-09-03 18:48 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('bookings', '0008_alter_bookablespace_approval_mode'), + ('machines', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='bookablespace', + name='machine_type', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='machines.machinetype'), + ), + ] diff --git a/backend/apps/bookings/models.py b/backend/apps/bookings/models.py index af7a4901..25a50580 100644 --- a/backend/apps/bookings/models.py +++ b/backend/apps/bookings/models.py @@ -86,6 +86,18 @@ class ApprovalMode(models.TextChoices): validators=[MinValueValidator(1)], ) is_active = models.BooleanField(default=True) + # Optional link to the kind of machine this space is booked to use, which is what + # lets certification gating apply to a booking. Deliberately nullable and only + # consulted when set: `bookings` must keep working with the `machines` module + # uninstalled, so this can never become a hard dependency. `related_name="+"` keeps + # `machines` free of a reverse accessor into an app it does not require. + machine_type = models.ForeignKey( + "machines.MachineType", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="+", + ) created_by = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, diff --git a/backend/apps/bookings/service_payments.py b/backend/apps/bookings/service_payments.py index 355d2339..a2708b46 100644 --- a/backend/apps/bookings/service_payments.py +++ b/backend/apps/bookings/service_payments.py @@ -2,7 +2,7 @@ from django.db import IntegrityError, transaction -from apps.payments.availability import online_payments_enabled +from apps.payments.availability import charge_tracking_enabled, online_payments_enabled from apps.payments.models import MakerspacePaymentSettings, Payment from apps.payments.reconciliation import cancel_pending from apps.payments.services import create_checkout, create_payment @@ -16,11 +16,15 @@ def create_for_confirmed_booking(booking, actor): if ( member is None or space.payment_amount <= 0 - or not online_payments_enabled(makerspace, "bookings") + or not charge_tracking_enabled(makerspace, "bookings") ): return None + # The debt is recorded whether or not it can be collected online; the rail is + # only added when one is actually live. payment = _get_or_create(booking, actor or member) - if payment.status == Payment.Status.PENDING: + if payment.status == Payment.Status.PENDING and online_payments_enabled( + makerspace, "bookings" + ): _schedule_checkout(payment) return payment except Exception: diff --git a/backend/apps/bookings/services_bookings.py b/backend/apps/bookings/services_bookings.py index 99adcf48..7fe1ba5d 100644 --- a/backend/apps/bookings/services_bookings.py +++ b/backend/apps/bookings/services_bookings.py @@ -44,6 +44,23 @@ def _audit(space, actor, action, target, meta=None): ) +def _require_certification(space, member, actor, override_reason): + """Gate a member's booking on machine-type training, when the space names a type. + + Imported lazily and skipped entirely unless `machine_type_id` is set, so `bookings` + never starts requiring the `machines` module: a space with no machine type (every + space in a bookings-only install) never reaches this import. + """ + if member is None or not space.machine_type_id: + return + from apps.machines.certifications import require_certification_for_member + + require_certification_for_member( + space.makerspace, member, space.machine_type, purpose='booking', + actor=actor, override_reason=override_reason, + ) + + def _refresh(instance): instance.refresh_from_db() return instance @@ -52,7 +69,7 @@ def _refresh(instance): @transaction.atomic def create_booking( space, *, starts_at, ends_at, member=None, name=None, email=None, phone=None, - custom_answers=None, note='', actor=None, + custom_answers=None, note='', actor=None, certification_override_reason='', ): from apps.bookings.services_rules import enforce_booking_rules from apps.encryption.write_fence import assert_mapped_write_allowed @@ -61,6 +78,7 @@ def create_booking( locked_space = _locked_space(space.pk) if not locked_space.is_active: raise BookingInvalidTransition('Inactive spaces cannot accept bookings.') + _require_certification(locked_space, member, actor, certification_override_reason) now = timezone.now() status = ( diff --git a/backend/apps/boxes/api_views_qr.py b/backend/apps/boxes/api_views_qr.py index 3bdc4f4a..6e9e1e2f 100644 --- a/backend/apps/boxes/api_views_qr.py +++ b/backend/apps/boxes/api_views_qr.py @@ -1,5 +1,6 @@ from django.shortcuts import get_object_or_404 from drf_spectacular.utils import OpenApiResponse, extend_schema +from django.http import Http404 from rest_framework import status from rest_framework.exceptions import PermissionDenied, ValidationError from rest_framework.response import Response @@ -192,6 +193,9 @@ def post(self, request, *args, **kwargs): serializer = QrResolveSerializer(data=request.data) serializer.is_valid(raise_exception=True) qr = qr_for_action(request.user, rbac.Action.VIEW_INVENTORY, payload=serializer.validated_data["payload"], status=QrCode.Status.ACTIVE) + if qr.target_type == QrCode.TargetType.MEMBER_CARD: + # Identity never travels through the inventory scanner; see member_card_services. + raise Http404 require_module(qr.makerspace, "scanner") QrScanEvent.objects.create( makerspace=qr.makerspace, diff --git a/backend/apps/boxes/migrations/0010_member_cards.py b/backend/apps/boxes/migrations/0010_member_cards.py new file mode 100644 index 00000000..32dc4fe1 --- /dev/null +++ b/backend/apps/boxes/migrations/0010_member_cards.py @@ -0,0 +1,23 @@ +# Generated by Django 6.0.8 on 2026-09-03 18:51 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('boxes', '0009_qrscanevent_qrscan_ms_qrcode_created_idx_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='qrcode', + name='target_type', + field=models.CharField(choices=[('box', 'Box'), ('product', 'Product'), ('asset', 'Asset'), ('member_card', 'Member card')], max_length=20), + ), + migrations.AlterField( + model_name='qrscanevent', + name='context', + field=models.CharField(choices=[('issue', 'Issue'), ('return', 'Return'), ('inventory_check', 'Inventory Check'), ('reassignment', 'Reassignment'), ('scanner_lookup', 'Scanner Lookup'), ('member_lookup', 'Member card lookup')], max_length=32), + ), + ] diff --git a/backend/apps/boxes/models.py b/backend/apps/boxes/models.py index 57016a36..0d338d64 100644 --- a/backend/apps/boxes/models.py +++ b/backend/apps/boxes/models.py @@ -115,6 +115,9 @@ class TargetType(models.TextChoices): BOX = "box", "Box" PRODUCT = "product", "Product" ASSET = "asset", "Asset" + # A member ID card (makerspaces.MemberCard). Resolved ONLY through the dedicated + # member-card resolver, never the generic scanner path. + MEMBER_CARD = "member_card", "Member card" class Status(models.TextChoices): ACTIVE = "active", "Active" @@ -166,6 +169,7 @@ class Context(models.TextChoices): INVENTORY_CHECK = "inventory_check", "Inventory Check" REASSIGNMENT = "reassignment", "Reassignment" SCANNER_LOOKUP = "scanner_lookup", "Scanner Lookup" + MEMBER_LOOKUP = "member_lookup", "Member card lookup" makerspace = models.ForeignKey( Makerspace, diff --git a/backend/apps/boxes/serializers.py b/backend/apps/boxes/serializers.py index 27f492e9..598f837f 100644 --- a/backend/apps/boxes/serializers.py +++ b/backend/apps/boxes/serializers.py @@ -1,3 +1,4 @@ +from django.http import Http404 from rest_framework import serializers from apps.boxes.models import Box, QrCode, QrScanEvent @@ -123,6 +124,9 @@ class QrRebindResultSerializer(serializers.Serializer): def qr_target_payload(qr): + if qr.target_type == QrCode.TargetType.MEMBER_CARD: + # Never a person from the generic scanner: VIEW_INVENTORY must not resolve identity. + raise Http404 if qr.target_type == QrCode.TargetType.BOX: box = Box.objects.get(pk=qr.target_id) return {"type": "box", "id": box.id, "label": box.label, "code": box.code} diff --git a/backend/apps/data_export/classification.py b/backend/apps/data_export/classification.py index 7a0ca657..d88c80f6 100644 --- a/backend/apps/data_export/classification.py +++ b/backend/apps/data_export/classification.py @@ -11,7 +11,7 @@ "apiclients.ApiKeyRequest": "id makerspace requester label reason allowed_origins status resolution_note resolved_by resolved_at created_at updated_at", "audit.AuditLog": "id actor action target_type target_id makerspace meta event_uuid row_mac created_at", "backup.MakerspaceArchiveRecipient": "id makerspace public_recipient fingerprint label added_by added_at revoked_at compromised_at verified_at challenge_nonce_digest challenge_issued_at", - "bookings.BookableSpace": "id public_token makerspace name kind description capacity location image_key is_public show_public_availability show_public_booker_names approval_mode custom_form requester_notifications_enabled payment_amount min_booking_duration_minutes max_booking_duration_minutes booking_lead_time_minutes max_booking_advance_days is_active created_by created_at updated_at", + "bookings.BookableSpace": "id public_token makerspace name kind description capacity location image_key is_public show_public_availability show_public_booker_names approval_mode custom_form requester_notifications_enabled payment_amount min_booking_duration_minutes max_booking_duration_minutes booking_lead_time_minutes max_booking_advance_days is_active created_by created_at updated_at machine_type", "bookings.Booking": "id space public_token name email phone member starts_at ends_at status note custom_answers created_at", "boxes.Box": "id makerspace parent code label location description is_active created_at updated_at", "boxes.BoxScan": "id makerspace box request actor context created_at", @@ -19,7 +19,7 @@ "boxes.QrScanEvent": "id makerspace qr_code request actor context created_at", "events.EventSeries": "id public_token calendar_uid calendar_sequence calendar_updated_at makerspace title description location location_kind custom_form capacity payment_amount registration_requires_approval registration_cutoff_lead_minutes is_public image_key recurrence_timezone dtstart_local_date dtstart_local_time recurrence_rule duration_minutes revision status last_materialized_at last_generation_error_code created_by created_at updated_at", "events.EventSeriesCollaborator": "id series makerspace status invited_by responded_by created_at responded_at", - "events.Event": "id public_token calendar_uid calendar_sequence calendar_updated_at timezone_name badge_template makerspace series series_occurrence_key series_revision series_override_fields title description starts_at ends_at location location_kind custom_form capacity payment_amount registration_requires_approval registration_cutoff_at registration_cutoff_lead_minutes is_public image_key status created_by created_at updated_at", + "events.Event": "id public_token calendar_uid calendar_sequence calendar_updated_at timezone_name badge_template makerspace series series_occurrence_key series_revision series_override_fields title description starts_at ends_at location location_kind custom_form capacity payment_amount registration_requires_approval registration_cutoff_at registration_cutoff_lead_minutes is_public image_key status created_by created_at updated_at search_vector", "events.EventCollaborator": "id event makerspace status invited_by responded_by created_at responded_at source_series_collaboration", "events.EventRegistration": "id event checkin_token name email phone member registered_via_makerspace payment_via_makerspace host_waiver host_waiver_accepted_at host_waiver_version_accepted email_exact_hash email_hash_generation custom_answers status calendar_sequence calendar_updated_at created_at", "events.EventCheckInEvent": "id makerspace event registration operation_id source attended_at recorded_at actor session_id station_version", @@ -43,7 +43,7 @@ "integrations.EmailNotificationMute": "id makerspace target stream event audience created_at created_by", "integrations.EmailTemplate": "id stream audience key makerspace subject text_body html_body is_active created_at updated_at", "integrations.MachineTypeEmailTemplate": "id stream audience key makerspace machine_type subject text_body html_body is_active created_at updated_at", - "integrations.NotificationDestination": "id makerspace channel label webhook_url telegram_chat_id is_active created_at updated_at", + "integrations.NotificationDestination": "id makerspace channel label webhook_url telegram_chat_id signing_secret is_active created_at updated_at", "integrations.NotificationPreference": "id makerspace feature channel enabled updated_by created_at updated_at", "integrations.NotificationRecipient": "id makerspace feature event kind role user created_at created_by", "integrations.RecipientCategoryScope": "id recipient category", @@ -51,8 +51,8 @@ "integrations.RecipientMachineTypeScope": "id recipient machine_type", "inventory.Category": "id makerspace name slug display_order icon created_at updated_at", "inventory.InventoryAsset": "id makerspace product box asset_tag serial_number status public_self_checkout_enabled notes created_at updated_at", - "inventory.InventoryProduct": "id makerspace box category name description image_key tracking_mode total_quantity available_quantity reserved_quantity issued_quantity damaged_quantity lost_quantity needs_fix_quantity is_public public_self_checkout_enabled show_public_count public_availability_mode storage_location is_archived created_at updated_at", - "machines.Machine": "id makerspace machine_type name location notes status firmware_version camera_feed_url image_key is_public is_active service_file_policy type_payload legacy_print_printer_id created_at updated_at created_by", + "inventory.InventoryProduct": "id makerspace box category name description image_key tracking_mode total_quantity available_quantity reserved_quantity issued_quantity damaged_quantity lost_quantity needs_fix_quantity is_public public_self_checkout_enabled show_public_count public_availability_mode storage_location deposit_amount is_archived created_at updated_at search_vector", + "machines.Machine": "id makerspace machine_type name location notes status firmware_version camera_feed_url image_key is_public is_active service_file_policy type_payload legacy_print_printer_id created_at updated_at created_by search_vector", "machines.MachineConsumable": "id machine measurement product label remaining low_threshold note created_by created_at", "machines.MachineConsumableAdjustment": "id consumable_pool makerspace kind quantity_delta metering_unit consumed_quantity service_request usage_entry reason created_by created_at legacy_filament_adjustment_id", "machines.MachineConsumablePool": "id makerspace machine machine_type material color color_hex brand unit lot_code initial_grams remaining_grams low_threshold_grams is_active is_public opened_at legacy_filament_spool_id created_by created_at updated_at", @@ -60,6 +60,8 @@ "machines.MachineErrorLog": "id machine severity message logged_by created_at", "machines.MachineOperator": "id machine user access_level assigned_by assigned_at", "machines.MachineServiceRequest": "id bucket queue makerspace requester member requester_name contact_email contact_phone public_token legacy_print_request_id title description source_link status reason assigned_machine handled_by accepted_by accepted_at started_at completed_at collected_by collected_at failed_at estimated_minutes actual_minutes fail_percent_complete capability_payload planned_grams reserved_grams actual_consumed_grams metering_unit planned_quantity reserved_quantity actual_consumed_quantity run_consumable_pool payment_amount payment_status paid_at run_machine_name run_machine_model run_consumable_label run_consumable_material run_consumable_color run_estimated_minutes run_planned_grams reprint_of created_at updated_at", + "machines.CertificationGrant": "id certification_type membership granted_by granted_at expires_at revoked_at revoked_by notes", + "machines.CertificationType": "id makerspace machine_type name description validity_days is_required_for_service is_required_for_booking is_active created_at updated_at", "machines.MachineType": "id makerspace slug name icon is_builtin managing_action capability_config", "machines.MachineUsageEntry": "id machine hours source note service_request consumable_pool duration_minutes outcome percent_complete reason consumed_grams metering_unit consumed_quantity legacy_manual_print_log_id title requester_name contact_email contact_phone logged_by created_at", "machines.MakerspaceMachineTypePricing": "id makerspace machine_type rate_per_unit flat_fee payment_enabled created_by updated_by created_at updated_at", @@ -72,25 +74,32 @@ "maintenance.MaintenanceLog": "id machine performed_by performed_at summary cost parts_note created_at", "maintenance.MaintenanceLogDocument": "id log object_key size_bytes uploaded_by created_at", "maintenance.MaintenanceSchedule": "id machine description interval_days next_due is_active created_by created_at updated_at", - "makerspaces.Makerspace": "id name slug public_code anonymous_requests_enabled anonymous_requester location map_url geofence_latitude geofence_longitude geofence_radius_m geofence_enabled public_inventory_enabled public_stats_enabled public_stats_show_holder_names public_print_status_lookup_policy membership_policy membership_dues_amount referrals_enabled filament_low_stock_threshold_grams superadmin_access_enabled staff_notifications_enabled booking_requester_notifications_enabled logo_key cover_image_key frontend_domain frontend_domain_status domain_verification_token domain_verified_at frontend_domain_changed_at hidden_from_central_directory public_api_key cors_allowed_origins enabled_modules enabled_features resource_limit_overrides storage_bytes_used theme_config branding_config telegram_group_chat_id telegram_bot_token smtp_host smtp_port smtp_username smtp_password smtp_use_tls smtp_use_ssl smtp_from_email slack_webhook_url mattermost_webhook_url discord_webhook_url default_loan_days presence_preset_minutes created_by archived_at archived_by lifecycle_state created_at updated_at", + "makerspaces.Makerspace": "id name slug public_code anonymous_requests_enabled anonymous_requester location map_url geofence_latitude geofence_longitude geofence_radius_m geofence_enabled public_inventory_enabled public_stats_enabled public_stats_show_holder_names public_print_status_lookup_policy membership_policy membership_dues_amount referrals_enabled lapsed_members_cannot_borrow filament_low_stock_threshold_grams superadmin_access_enabled staff_notifications_enabled booking_requester_notifications_enabled logo_key cover_image_key frontend_domain frontend_domain_status domain_verification_token domain_verified_at frontend_domain_changed_at hidden_from_central_directory public_api_key cors_allowed_origins enabled_modules enabled_features resource_limit_overrides storage_bytes_used theme_config branding_config telegram_group_chat_id telegram_bot_token smtp_host smtp_port smtp_username smtp_password smtp_use_tls smtp_use_ssl smtp_from_email slack_webhook_url mattermost_webhook_url discord_webhook_url default_loan_days presence_preset_minutes created_by archived_at archived_by lifecycle_state created_at updated_at", "makerspaces.MakerspaceMembership": "id makerspace user role assigned_role receives_notifications can_refer can_verify verified_at verified_by status activated_at activated_by revoked_at revoked_by revocation_reason waiver_accepted_at waiver_version_accepted accepted_waiver witnessed_waiver witnessed_waiver_version witnessed_at witnessed_by witnessed_actor_snapshot verified_actor_snapshot activated_actor_snapshot revoked_actor_snapshot created_at", "makerspaces.MakerspaceRole": "id makerspace name slug granted_actions legacy_role is_default is_protected created_at updated_at", "makerspaces.MakerspaceWaiver": "id makerspace body version is_active created_by created_at superseded_at", - "makerspaces.MemberProfile": "id membership is_visible show_attended_events headline institution bio avatar_key interests languages education github_username github_contributions github_synced_at created_at updated_at", + "makerspaces.MemberCard": "id makerspace membership card_number printed_name photo_object_key photo_content_type photo_size_bytes photo_consent_at photo_consent_version template_version_at_issue print_count last_printed_at issued_at revoked_at revoked_reason created_at updated_at", + "makerspaces.MemberProfile": "id membership is_visible show_attended_events show_certifications headline institution bio avatar_key interests languages education github_username github_contributions github_synced_at created_at updated_at", "makerspaces.MemberProject": "id profile title description image_key links position created_at updated_at", "makerspaces.MembershipRequest": "id makerspace user invite_email kind state requested_by invited_by decided_by assigned_role auto_activate_on_claim decision_note created_at decided_at updated_at", + "makerspaces.MembershipPlan": "id makerspace name interval custom_days amount currency is_active created_at updated_at", + "makerspaces.MembershipTerm": "id membership plan starts_at ends_at status renewal_payment created_by created_at", + "makerspaces.InvitationRequest": "id makerspace name email phone message status handled_by handled_at created_at", "notifications.Notification": "id makerspace level event title body url_path read_at created_at", "operations.InventoryAdjustment": "id makerspace stocktake transfer product asset delta_available delta_damaged delta_lost reason created_by created_at", "operations.QrPrintBatch": "id makerspace title status created_by created_at printed_at", "operations.QrPrintBatchItem": "id batch qr_code label_text target_type target_id sort_order", "operations.ReportMetricRollup": "id makerspace source_module report_key metric_key bucket_start grain dimension_key dimensions value sample_count revision source_cutoff computed_at checksum", + "operations.ReportSchedule": "id makerspace report_key filters grain format cadence next_run_at last_run_at is_active destination recipient_emails created_by created_at updated_at", "operations.StocktakeLedgerEntry": "id makerspace stocktake line product asset bucket delta old_asset_status new_asset_status reason created_by created_at", "operations.StocktakeLine": "id stocktake product asset container expected_quantity counted_quantity variance_quantity condition notes", "operations.StocktakeSession": "id makerspace container status started_by approved_by started_at completed_at approved_at notes", "operations.StockTransfer": "id makerspace source_container destination_container source_makerspace destination_makerspace created_by reason status created_at applied_at", "operations.StockTransferLine": "id transfer product asset quantity from_status to_status notes", - "payments.MakerspacePaymentSettings": "id makerspace provider stripe_publishable_key stripe_secret_key stripe_webhook_secret default_currency connect_account_id connect_status connect_charges_enabled connect_payouts_enabled connect_account_assigned_at connect_status_updated_at razorpay_key_id razorpay_key_secret razorpay_webhook_secret", + "payments.MakerspacePaymentSettings": "id makerspace provider stripe_publishable_key stripe_secret_key stripe_webhook_secret default_currency connect_account_id connect_status connect_charges_enabled connect_payouts_enabled connect_account_assigned_at connect_status_updated_at razorpay_key_id razorpay_key_secret razorpay_webhook_secret loan_deposit_mode loan_deposit_amount loan_late_fee_per_day loan_late_fee_cap loan_grace_days loan_deposit_blocks_issue", "payments.Payment": "id makerspace subject_type subject_id member via_makerspace subject_label amount currency status provider external_order_id external_payment_id checkout_url stripe_provider stripe_connected_account_id stripe_application_fee_amount online_rail stripe_checkout_session_id stripe_checkout_url stripe_checkout_session_expired_at stripe_payment_intent_id created_by created_at updated_at", + "payments.ManualSettlement": "id payment method reference received_at amount currency recorded_by amends created_at", + "payments.Refund": "id payment amount currency status provider external_refund_id reason created_by created_at settled_at", "presence.PresenceSession": "id member makerspace membership started_at expires_at ended_at ended_by end_reason created_via_claim_session", "procurement.ToBuyItem": "id makerspace machine_type kind name quantity link status estimated_unit_cost vendor_name actual_unit_cost purchaser ordered_at received_at moved_to_inventory_at resulting_product resulting_pool resulting_machine source_pool created_by created_at updated_at", "procurement.ToBuyReceipt": "id to_buy_item object_key uploaded_by created_at", @@ -184,6 +193,7 @@ "makerspaces.ImportedUserReconciliation": "Target-side operator reconciliation input.", "makerspaces.SubdomainRequest": "Source-deployment routing request.", "operations.PeriodicTaskRun": "Deployment scheduler state.", + "operations.ReportDelivery": "Report delivery telemetry and a short-lived download coordination key; not rebuild data.", "operations.ReportRollupCursor": "Rebuildable report rollup coordination and retention-fence state.", "events.EventOrganizer": ( "It references a deployment-global organization that does not travel with " diff --git a/backend/apps/data_export/datasets.py b/backend/apps/data_export/datasets.py index 3653f5bf..cc957c68 100644 --- a/backend/apps/data_export/datasets.py +++ b/backend/apps/data_export/datasets.py @@ -73,6 +73,8 @@ "machines.MachineErrorLog": ("machines/error_logs.csv", P(("machine__makerspace",))), "machines.MachineOperator": ("machines/operators.csv", P(("machine__makerspace",))), "machines.MachineServiceRequest": ("machine_service/requests.csv", P(("makerspace",))), + "machines.CertificationGrant": ("machines/certification_grants.csv", P(("certification_type__makerspace",))), + "machines.CertificationType": ("machines/certification_types.csv", P(("makerspace",))), "machines.MachineType": ("machines/types.csv", P(("makerspace",), include_global_if_unowned=True)), "machines.MachineUsageEntry": ("machines/usage_entries.csv", P(("machine__makerspace",))), "machines.MakerspaceMachineTypePricing": ("machines/type_pricing.csv", P(("makerspace",), ("machine_type__makerspace",))), @@ -89,14 +91,19 @@ "makerspaces.MakerspaceMembership": ("members/roster.csv", P(("makerspace",))), "makerspaces.MakerspaceRole": ("members/roles.csv", P(("makerspace",))), "makerspaces.MakerspaceWaiver": ("members/waivers.csv", P(("makerspace",))), + "makerspaces.MemberCard": ("members/member_cards.csv", P(("makerspace",))), "makerspaces.MemberProfile": ("members/profiles.csv", P(("membership__makerspace",))), "makerspaces.MemberProject": ("members/projects.csv", P(("profile__membership__makerspace",))), "makerspaces.MembershipRequest": ("members/membership_requests.csv", P(("makerspace",))), + "makerspaces.MembershipPlan": ("members/membership_plans.csv", P(("makerspace",))), + "makerspaces.MembershipTerm": ("members/membership_terms.csv", P(("membership__makerspace",))), + "makerspaces.InvitationRequest": ("members/invitation_requests.csv", P(("makerspace",))), "notifications.Notification": ("notifications/inbox.csv", P(("makerspace",))), "operations.InventoryAdjustment": ("operations/inventory_adjustments.csv", P(("makerspace",))), "operations.QrPrintBatch": ("operations/qr_print_batches.csv", P(("makerspace",))), "operations.QrPrintBatchItem": ("operations/qr_print_batch_items.csv", P(("batch__makerspace",))), "operations.ReportMetricRollup": ("reports/metric_rollups.csv", P(("makerspace",))), + "operations.ReportSchedule": ("reports/schedules.csv", P(("makerspace",))), "operations.StocktakeLedgerEntry": ("stocktake/ledger.csv", P(("makerspace",), ("stocktake__makerspace",))), "operations.StocktakeLine": ("stocktake/lines.csv", P(("stocktake__makerspace",), ("product__makerspace", "asset__makerspace", "container__makerspace"))), "operations.StocktakeSession": ("stocktake/sessions.csv", P(("makerspace",), ("container__makerspace",))), @@ -104,6 +111,8 @@ "operations.StockTransferLine": ("transfers/lines.csv", P(("transfer__makerspace", "transfer__source_makerspace", "transfer__destination_makerspace"))), "payments.MakerspacePaymentSettings": ("makerspace/payment_settings.csv", P(("makerspace",))), "payments.Payment": ("payments/payments.csv", P(("makerspace",))), + "payments.ManualSettlement": ("payments/manual_settlements.csv", P(("payment__makerspace",))), + "payments.Refund": ("payments/refunds.csv", P(("payment__makerspace",))), "presence.PresenceSession": ("presence/sessions.csv", P(("makerspace",), ("membership__makerspace",))), "procurement.ToBuyItem": ("procurement/to_buy_items.csv", P(("makerspace",))), "procurement.ToBuyReceipt": ("procurement/receipts.csv", P(("to_buy_item__makerspace",))), diff --git a/backend/apps/data_export/fields.py b/backend/apps/data_export/fields.py index 665f28aa..40086de8 100644 --- a/backend/apps/data_export/fields.py +++ b/backend/apps/data_export/fields.py @@ -22,6 +22,11 @@ } ALWAYS_OMITTED = { + # Trigger-maintained full-text vectors: derived from exported columns and rebuilt by + # the search trigger on the target, so they never travel (apps/inventory/search.py). + ("inventory.InventoryProduct", "search_vector"): "Derived search vector, rebuilt by trigger.", + ("machines.Machine", "search_vector"): "Derived search vector, rebuilt by trigger.", + ("events.Event", "search_vector"): "Derived search vector, rebuilt by trigger.", ("apiclients.ApiClient", "client_id"): "A rebuild issues a fresh client identifier.", ("apiclients.ApiClient", "secret_encrypted"): "API client credential.", ("apiclients.ApiClient", "previous_secret_encrypted"): ( @@ -59,6 +64,7 @@ ), ("hardware_requests.HardwareRequest", "public_token"): "Source bearer/status token.", ("integrations.NotificationDestination", "webhook_url"): "Encrypted webhook credential.", + ("integrations.NotificationDestination", "signing_secret"): "Encrypted webhook signing secret.", ("machines.Machine", "camera_feed_url"): "May embed camera credentials.", ("machines.Machine", "legacy_print_printer_id"): "Retired cutover provenance.", ("machines.MachineConsumableAdjustment", "legacy_filament_adjustment_id"): "Retired cutover provenance.", @@ -87,6 +93,7 @@ ("payments.MakerspacePaymentSettings", "razorpay_webhook_secret"): "Source payment credential.", ("payments.Payment", "external_order_id"): "Source provider identifier.", ("payments.Payment", "external_payment_id"): "Source provider identifier.", + ("payments.Refund", "external_refund_id"): "Source provider identifier.", ("payments.Payment", "checkout_url"): "Source checkout bearer URL.", ("payments.Payment", "stripe_connected_account_id"): "Source provider account binding.", ("payments.Payment", "stripe_checkout_session_id"): "Source provider session identifier.", diff --git a/backend/apps/data_export/guards.py b/backend/apps/data_export/guards.py index 3ec2a483..e0d2eaae 100644 --- a/backend/apps/data_export/guards.py +++ b/backend/apps/data_export/guards.py @@ -232,6 +232,9 @@ def validate_user_edges(user_edges=USER_EDGES): NON_REFERENCE_JSON_FIELDS = frozenset({ # Controlled metric dimension labels (module key, period, report key) -- never an id. ("operations.ReportMetricRollup", "dimensions"), + # Export inputs (dates, day window, payment enums) and staff-entered emails; no ids. + ("operations.ReportSchedule", "filters"), + ("operations.ReportSchedule", "recipient_emails"), }) diff --git a/backend/apps/data_export/references.py b/backend/apps/data_export/references.py index 419da68a..ffd06034 100644 --- a/backend/apps/data_export/references.py +++ b/backend/apps/data_export/references.py @@ -51,6 +51,8 @@ def require_raw_user(fidelity, *, model, row_pk, field, user_id, existing_user_i ("apiclients.ApiKeyRequest", "resolved_by"), ("audit.AuditLog", "actor"), ("bookings.BookableSpace", "created_by"), + ("machines.CertificationGrant", "granted_by"), + ("machines.CertificationGrant", "revoked_by"), ("bookings.Booking", "member"), ("boxes.BoxScan", "actor"), ("boxes.QrCode", "created_by"), @@ -139,10 +141,13 @@ def require_raw_user(fidelity, *, model, row_pk, field, user_id, existing_user_i ("makerspaces.MembershipRequest", "requested_by"), ("makerspaces.MembershipRequest", "invited_by"), ("makerspaces.MembershipRequest", "decided_by"), + ("makerspaces.MembershipTerm", "created_by"), + ("makerspaces.InvitationRequest", "handled_by"), ("makerspaces.SubdomainRequest", "requested_by"), ("makerspaces.SubdomainRequest", "decided_by"), ("operations.InventoryAdjustment", "created_by"), ("operations.QrPrintBatch", "created_by"), + ("operations.ReportSchedule", "created_by"), ("operations.StocktakeLedgerEntry", "created_by"), ("operations.StocktakeSession", "started_by"), ("operations.StocktakeSession", "approved_by"), @@ -155,6 +160,8 @@ def require_raw_user(fidelity, *, model, row_pk, field, user_id, existing_user_i ("organizations.OrganizationInvitation", "redeemed_by"), ("payments.Payment", "member"), ("payments.Payment", "created_by"), + ("payments.ManualSettlement", "recorded_by"), + ("payments.Refund", "created_by"), ("payments.StripeConnectOAuthState", "initiated_by"), ("presence.PresenceSession", "member"), ("presence.PresenceSession", "ended_by"), diff --git a/backend/apps/encryption/registry.py b/backend/apps/encryption/registry.py index 50c66f3a..d1081ba3 100644 --- a/backend/apps/encryption/registry.py +++ b/backend/apps/encryption/registry.py @@ -25,6 +25,8 @@ def _fields(label, names, path, classification, indexes): *_fields("events.EventRegistration", ("name", "email", "phone"), "event.makerspace_id", "source", ((200, 254, 32), ("none", "event_exact", "none"))), *_fields("events.EventFeedbackResponse", ("answers_snapshot",), "survey.event.makerspace_id", "source", ((None,), ("none",))), *_fields("events.EventAttendanceCertificate", ("recipient_name",), "registration.event.makerspace_id", "source", ((None,), ("none",))), + *_fields("makerspaces.MemberCard", ("printed_name",), "makerspace_id", "source", ((200,), ("none",))), + *_fields("makerspaces.InvitationRequest", ("name", "email", "phone"), "makerspace_id", "source", ((200, 254, 32), ("none", "none", "none"))), *_fields("bookings.Booking", ("name", "email", "phone", "note"), "space.makerspace_id", "source", ((200, 254, 32, None), ("none", "none", "none", "none"))), *_fields("machines.MachineServiceRequest", ("requester_name", "contact_email", "contact_phone"), "makerspace_id", "source", ((None, None, None), ("bloom", "bloom_exact", "none"))), *_fields("machines.MachineUsageEntry", ("requester_name", "contact_email", "contact_phone", "note"), "machine.makerspace_id", "source", ((120, 254, 40, None), ("bloom", "bloom_exact", "none", "none"))), diff --git a/backend/apps/events/admin.py b/backend/apps/events/admin.py index f2a1f66b..f99d8295 100644 --- a/backend/apps/events/admin.py +++ b/backend/apps/events/admin.py @@ -2,7 +2,7 @@ from unfold.admin import ModelAdmin from apps.accounts import rbac -from apps.audit import services as audit +from apps.events import services_series_organizers from apps.events.models import Event, EventOrganizer, EventSeries, EventSeriesOrganizer from apps.separability.tombstones import app_is_tombstoned from config.admin_access import SuperuserOnlyModelAdmin @@ -65,42 +65,20 @@ def formfield_for_foreignkey(self, db_field, request, **kwargs): ) return super().formfield_for_foreignkey(db_field, request, **kwargs) + # Superadmin operations route through services, never the ORM: the service takes the + # series row lock and the events module lock, checks authority, projects the organizer + # onto every occurrence and writes the audit entry. The admin only chooses the rows. def save_model(self, request, obj, form, change): - if not change: - obj.created_by = request.user - super().save_model(request, obj, form, change) - for event in obj.series.occurrences.all(): - EventOrganizer.objects.get_or_create( - event=event, - organization=obj.organization, - defaults={"created_by": request.user, "source_series_organizer": obj}, - ) - audit.record( - request.user, - "event.series_organizer_created" if not change else "event.series_organizer_updated", - makerspace=obj.series.makerspace, - target=obj, - meta={"series_id": obj.series_id, "organization_slug": obj.organization.slug}, + services_series_organizers.add_series_organizer( + obj.series, actor=request.user, organization=obj.organization ) def delete_model(self, request, obj): - EventOrganizer.objects.filter(source_series_organizer=obj).delete() - audit.record( - request.user, "event.series_organizer_deleted", - makerspace=obj.series.makerspace, target=obj, - meta={"series_id": obj.series_id, "organization_slug": obj.organization.slug}, - ) - super().delete_model(request, obj) + services_series_organizers.remove_series_organizer(obj, actor=request.user) def delete_queryset(self, request, queryset): for obj in queryset.select_related("series__makerspace", "organization"): - EventOrganizer.objects.filter(source_series_organizer=obj).delete() - audit.record( - request.user, "event.series_organizer_deleted", - makerspace=obj.series.makerspace, target=obj, - meta={"series_id": obj.series_id, "organization_slug": obj.organization.slug}, - ) - super().delete_queryset(request, queryset) + services_series_organizers.remove_series_organizer(obj, actor=request.user) if not app_is_tombstoned("events"): diff --git a/backend/apps/events/migrations/0022_event_search_vector.py b/backend/apps/events/migrations/0022_event_search_vector.py new file mode 100644 index 00000000..5d5f4613 --- /dev/null +++ b/backend/apps/events/migrations/0022_event_search_vector.py @@ -0,0 +1,33 @@ +import django.contrib.postgres.indexes +import django.contrib.postgres.search +from django.db import migrations + +from apps.inventory.search import vector_trigger_sql + +FORWARD, REVERSE = vector_trigger_sql( + "events_event", + [("title", "A"), ("location", "B"), ("description", "D")], + trigger_name="event_search_vector_trg", +) + + +class Migration(migrations.Migration): + dependencies = [ + ("events", "0021_offline_and_station_checkin"), + ("inventory", "0010_inventoryproduct_search_vector"), + ] + + operations = [ + migrations.AddField( + model_name="event", + name="search_vector", + field=django.contrib.postgres.search.SearchVectorField(editable=False, null=True), + ), + migrations.AddIndex( + model_name="event", + index=django.contrib.postgres.indexes.GinIndex( + fields=["search_vector"], name="event_search_gin" + ), + ), + migrations.RunSQL(FORWARD, REVERSE), + ] diff --git a/backend/apps/events/models_event.py b/backend/apps/events/models_event.py index 357b67be..924c9deb 100644 --- a/backend/apps/events/models_event.py +++ b/backend/apps/events/models_event.py @@ -3,6 +3,8 @@ from django.conf import settings from django.core.exceptions import ValidationError from django.core.validators import MinValueValidator +from django.contrib.postgres.indexes import GinIndex +from django.contrib.postgres.search import SearchVectorField from django.db import models from django.db.models import F, Q from django.utils import timezone @@ -95,6 +97,8 @@ class LocationKind(models.TextChoices): ) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) + # Derived: trigger-maintained (events migration 0022); see apps/inventory/search.py. + search_vector = SearchVectorField(null=True, editable=False) class Meta: ordering = ["starts_at", "id"] @@ -146,6 +150,7 @@ class Meta: ), ] indexes = [ + GinIndex(fields=["search_vector"], name="event_search_gin"), models.Index( fields=["makerspace", "starts_at"], name="event_ms_starts_idx", diff --git a/backend/apps/events/service_payments.py b/backend/apps/events/service_payments.py index d1c04997..809b2273 100644 --- a/backend/apps/events/service_payments.py +++ b/backend/apps/events/service_payments.py @@ -2,7 +2,7 @@ from django.db import IntegrityError, transaction -from apps.payments.availability import online_payments_enabled +from apps.payments.availability import charge_tracking_enabled, online_payments_enabled from apps.payments.models import MakerspacePaymentSettings, Payment from apps.payments.reconciliation import cancel_pending from apps.payments.services import create_checkout, create_payment @@ -15,11 +15,13 @@ def create_for_registered_registration(registration, actor): if ( member is None or event.payment_amount <= 0 - or not online_payments_enabled(event.makerspace, "events") + or not charge_tracking_enabled(event.makerspace, "events") ): return None payment = _get_or_create(registration, actor or member) - if payment.status == Payment.Status.PENDING: + if payment.status == Payment.Status.PENDING and online_payments_enabled( + event.makerspace, "events" + ): _schedule_checkout(payment) return payment except Exception: diff --git a/backend/apps/events/services_series.py b/backend/apps/events/services_series.py index 7473ef47..cc711245 100644 --- a/backend/apps/events/services_series.py +++ b/backend/apps/events/services_series.py @@ -117,12 +117,10 @@ def _project_authority(series, event): "source_series_collaboration": source, }, ) + from apps.events.services_series_organizers import project_organizer + for source in series.organizers.all(): - EventOrganizer.objects.get_or_create( - event=event, - organization=source.organization, - defaults={"created_by": source.created_by, "source_series_organizer": source}, - ) + project_organizer(source, [event]) def _materialize_locked(series, *, actor, now): diff --git a/backend/apps/events/services_series_organizers.py b/backend/apps/events/services_series_organizers.py new file mode 100644 index 00000000..b06099ed --- /dev/null +++ b/backend/apps/events/services_series_organizers.py @@ -0,0 +1,72 @@ +"""Series-level organizer changes and their projection onto occurrences. + +Before this module the superadmin console added and removed ``EventSeriesOrganizer`` rows +directly in ``admin.py`` and re-implemented the occurrence projection inline, so the admin +path skipped the module lock and the authority check that ``services_series`` applies when +it materializes a series. Superadmin operations must route through services, never through +the ORM (``docs/PROJECT-STATUS.md``); this is the service. +""" +from django.core.exceptions import PermissionDenied +from django.db import transaction + +from apps.audit import services as audit +from apps.events.models_series import EventSeries, EventSeriesOrganizer +from apps.events.organizer_models import EventOrganizer +from apps.events.series_authority import can_manage_series +from apps.makerspaces.guards import require_module_locked + + +def project_organizer(source, events): + """Create the per-occurrence organizer rows a series organizer implies.""" + for event in events: + EventOrganizer.objects.get_or_create( + event=event, + organization=source.organization, + defaults={"created_by": source.created_by, "source_series_organizer": source}, + ) + + +def _lock(series): + locked = EventSeries.objects.select_for_update().get(pk=series.pk) + require_module_locked(locked.makerspace_id, "events") + return locked + + +def _meta(series, organization): + return {"series_id": series.pk, "organization_slug": organization.slug} + + +@transaction.atomic +def add_series_organizer(series, *, actor, organization): + locked = _lock(series) + if not can_manage_series(actor, locked): + raise PermissionDenied() + row, created = EventSeriesOrganizer.objects.get_or_create( + series=locked, organization=organization, defaults={"created_by": actor} + ) + project_organizer(row, locked.occurrences.all()) + audit.record( + actor, + "event.series_organizer_created" if created else "event.series_organizer_updated", + makerspace=locked.makerspace, + target=row, + meta=_meta(locked, organization), + ) + return row + + +@transaction.atomic +def remove_series_organizer(row, *, actor): + locked = _lock(row.series) + if not can_manage_series(actor, locked): + raise PermissionDenied() + organization = row.organization + EventOrganizer.objects.filter(source_series_organizer=row).delete() + audit.record( + actor, + "event.series_organizer_deleted", + makerspace=locked.makerspace, + target=row, + meta=_meta(locked, organization), + ) + row.delete() diff --git a/backend/apps/events/views_admin_events.py b/backend/apps/events/views_admin_events.py index af15ae5a..5f81b817 100644 --- a/backend/apps/events/views_admin_events.py +++ b/backend/apps/events/views_admin_events.py @@ -1,12 +1,13 @@ from django.db.models import Count, Q from django.shortcuts import get_object_or_404 -from drf_spectacular.utils import OpenApiResponse, extend_schema +from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema from rest_framework import status from rest_framework.exceptions import PermissionDenied from rest_framework.pagination import PageNumberPagination from rest_framework.response import Response from rest_framework.views import APIView +from apps.inventory.search import apply_q from apps.accounts import rbac from apps.accounts.models import User from apps.admin_api.permissions import IsActiveStaff @@ -149,6 +150,12 @@ class EventListCreateView(APIView): tags=['Admin events'], summary='List events in a makerspace', request=None, + parameters=[ + OpenApiParameter( + name='q', type=str, location=OpenApiParameter.QUERY, required=False, + description='Full-text search over title, location and description.', + ), + ], responses={200: EventListResponseSerializer}, ) def get(self, request, makerspace_id, *args, **kwargs): @@ -165,6 +172,9 @@ def get(self, request, makerspace_id, *args, **kwargs): .prefetch_related('organizers__organization') .order_by('starts_at', 'id') ) + query = request.query_params.get('q', '') + if query.strip(): + queryset = apply_q(queryset, query, label_field='title') paginator = _EventPagination() page = paginator.paginate_queryset(queryset, request, view=self) return _paginated_response(paginator, page, EventAdminSerializer) diff --git a/backend/apps/events/views_public.py b/backend/apps/events/views_public.py index b161f1eb..da37f151 100644 --- a/backend/apps/events/views_public.py +++ b/backend/apps/events/views_public.py @@ -1,12 +1,13 @@ from django.db.models import Count, Prefetch, Q from django.shortcuts import get_object_or_404 from django.utils import timezone -from drf_spectacular.utils import OpenApiResponse, extend_schema +from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema from rest_framework import status from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView +from apps.inventory.search import apply_q from apps.apiclients.throttling import ClientTierRateThrottle from apps.events import services from apps.events.exceptions import DuplicateRegistration @@ -56,6 +57,12 @@ class PublicEventListView(APIView): tags=['Public events'], auth=[], request=None, + parameters=[ + OpenApiParameter( + name='q', type=str, location=OpenApiParameter.QUERY, required=False, + description='Full-text search over title, location and description; supports "phrases" and -exclusions, tolerates typos in the title.', + ), + ], responses={200: PublicEventSerializer(many=True), **PUBLIC_EVENT_ERRORS}, ) def get(self, request, makerspace_slug): @@ -83,6 +90,9 @@ def get(self, request, makerspace_slug): ) .order_by('starts_at', 'id') ) + query = request.query_params.get('q', '') + if query.strip(): + events = apply_q(events, query, label_field='title') return Response(PublicEventSerializer(events, many=True).data) diff --git a/backend/apps/hardware_requests/exceptions.py b/backend/apps/hardware_requests/exceptions.py index 8db86ccd..f82cc9ee 100644 --- a/backend/apps/hardware_requests/exceptions.py +++ b/backend/apps/hardware_requests/exceptions.py @@ -21,6 +21,7 @@ AnonymousRequestOutstandingLimit, BoxUnavailable, BoxValidationError, + DepositRequired, EvidenceNotUploaded, InvalidTransition, RequesterBlocked, @@ -227,6 +228,11 @@ class ErrorSerializer(serializers.Serializer): "evidence_not_uploaded", "Evidence has not been uploaded.", ), + DepositRequired: ( + status.HTTP_409_CONFLICT, + "deposit_required", + "The loan deposit must be settled before issue.", + ), StorageUnavailable: ( status.HTTP_503_SERVICE_UNAVAILABLE, "evidence_storage_unavailable", diff --git a/backend/apps/hardware_requests/handover_workflow.py b/backend/apps/hardware_requests/handover_workflow.py index bac6074e..02fd71a0 100644 --- a/backend/apps/hardware_requests/handover_workflow.py +++ b/backend/apps/hardware_requests/handover_workflow.py @@ -9,7 +9,7 @@ from apps.evidence import storage from apps.evidence.finalization import charge_storage_once, lock_evidence_for_attachment from apps.evidence.models import EvidencePhoto -from apps.hardware_requests import notifications +from apps.hardware_requests import loan_payments, notifications from apps.hardware_requests.handover_issue_helpers import ( issue_individual_assets, validate_broken_rejects, @@ -91,6 +91,10 @@ def issue_request(actor, request, evidence_id, remark="", asset_qr_payloads=None for entry in (rejects or []) if int(entry.get("broken", 0)) > 0 } + # Payment gate FIRST, then the QR/evidence Hard Rules: an unpaid blocking deposit is + # reported before a missing photo, so staff are not asked for evidence of a handover + # that cannot happen yet. It never refuses unless the makerspace opted into blocking. + loan_payments.require_deposit_settled(request, actor) evidence = EvidencePhoto.objects.filter( pk=evidence_id, makerspace_id=request.makerspace_id, @@ -184,6 +188,8 @@ def issue_request(actor, request, evidence_id, remark="", asset_qr_payloads=None meta={"box_id": locked.assigned_box_id, "evidence_id": evidence.pk}, ) notifications.notify_request_issued(locked) + request_id = locked.pk + transaction.on_commit(lambda: loan_payments.raise_deposit(request_id, actor)) return locked def set_return_due(actor, request, return_due_at): diff --git a/backend/apps/hardware_requests/loan_payments.py b/backend/apps/hardware_requests/loan_payments.py new file mode 100644 index 00000000..f47fcc28 --- /dev/null +++ b/backend/apps/hardware_requests/loan_payments.py @@ -0,0 +1,218 @@ +"""Loan deposits and late fees: the payments boundary for reviewed hardware loans. + +Mirrors `apps/makerspaces/membership_payments.py`. Every entry point swallows payment +failures, because a provider outage must never stop a handover or a return. The +workflow module calls in at three points, and only the workflow module may: + +* `require_deposit_settled` -- the issue gate, run BEFORE the QR/evidence Hard Rules. + It only ever refuses when `loan_deposit_blocks_issue` is on and the deposit is unpaid; + it raises the deposit itself so the member has something to pay. +* `raise_deposit` -- after the issue transition commits (non-blocking mode). +* `on_request_closed` -- after a return closes the loan: raise the late fee (computed + once, never mutated) and cancel a deposit that was never collected. + +Amounts are `Decimal` major units, like every Payment row. Never floats. +""" + +import logging +import math +from datetime import timedelta +from decimal import Decimal + +from django.db import IntegrityError, transaction +from django.utils import timezone + +from apps.audit import services as audit +from apps.hardware_requests import notifications +from apps.hardware_requests.models import HardwareRequest +from apps.hardware_requests.workflow_errors import DepositRequired +from apps.payments.availability import charge_tracking_enabled, online_payments_enabled +from apps.payments.models import MakerspacePaymentSettings, Payment +from apps.payments.reconciliation import cancel_pending +from apps.payments.services import create_checkout, create_payment + +logger = logging.getLogger(__name__) + +CLOSED_STATUSES = frozenset( + {HardwareRequest.Status.RETURNED, HardwareRequest.Status.CLOSED_WITH_ISSUE} +) +ZERO = Decimal("0.00") + + +def loans_enabled(makerspace): + """Whether loan deposits and late fees are RECORDED for this space. + + Tracking, not the online rail. `loan_deposit_blocks_issue` reads this, so keying it + off gateway credentials would have made that setting silently mean nothing in a + cash-only space: staff could switch "a deposit blocks issue" on and it would never + block, because no deposit was ever raised to be unpaid. The rail is applied + separately in `_schedule_checkout`. + """ + return charge_tracking_enabled(makerspace, "loans") + + +def deposit_amount(request, settings_row): + mode = settings_row.loan_deposit_mode + if mode == MakerspacePaymentSettings.LoanDepositMode.FIXED: + return Decimal(settings_row.loan_deposit_amount or 0) + if mode == MakerspacePaymentSettings.LoanDepositMode.PER_PRODUCT: + total = ZERO + for item in request.items.select_related("product"): + per_unit = item.product.deposit_amount + if per_unit and item.accepted_quantity > 0: + total += Decimal(per_unit) * item.accepted_quantity + return total + return ZERO + + +def require_deposit_settled(request, actor): + """Refuse an issue only while a blocking deposit is unpaid. Failing open on any + payment-system error is deliberate: a Stripe outage is not a reason to keep tools + on the shelf, and the deposit is still raised post-commit by `raise_deposit`.""" + try: + if request.status != HardwareRequest.Status.ACCEPTED: + return None + settings_row = MakerspacePaymentSettings.for_makerspace(request.makerspace) + if not settings_row.loan_deposit_blocks_issue or not loans_enabled(request.makerspace): + return None + amount = deposit_amount(request, settings_row) + if amount <= 0: + return None + payment = _deposit(request, actor, amount, settings_row.default_currency) + except Exception: + logger.exception("loan_deposit_gate_failed", extra={"request_id": request.pk}) + return None + if payment.status == Payment.Status.PENDING: + raise DepositRequired(payment) + return payment + + +def raise_deposit(request_id, actor): + """Post-commit: raise (or find) the deposit for a loan that was just issued.""" + try: + request = HardwareRequest.objects.select_related("makerspace").get(pk=request_id) + if request.status != HardwareRequest.Status.ISSUED: + return None + settings_row = MakerspacePaymentSettings.for_makerspace(request.makerspace) + if not loans_enabled(request.makerspace): + return None + amount = deposit_amount(request, settings_row) + if amount <= 0: + return None + return _deposit(request, actor, amount, settings_row.default_currency) + except Exception: + logger.exception("loan_deposit_raise_failed", extra={"request_id": request_id}) + return None + + +def on_request_closed(request_id, actor, now=None): + """Post-commit: late fee first, then release an uncollected deposit.""" + try: + request = HardwareRequest.objects.select_related("makerspace").get(pk=request_id) + if request.status not in CLOSED_STATUSES: + return None + fee = raise_late_fee(request, actor, now or timezone.now()) + # A deposit nobody paid secures nothing once the loan is over, whether or not a + # fee was raised -- the fee is its own charge. A PAID deposit stays on the + # ledger for staff to refund (or keep against the fee) through the refund path. + cancel_pending( + makerspace=request.makerspace, + subject_type=Payment.SubjectType.LOAN_DEPOSIT, + subject_id=request.pk, + actor=actor, + ) + return fee + except Exception: + logger.exception("loan_close_payments_failed", extra={"request_id": request_id}) + return None + + +def raise_late_fee(request, actor, now): + settings_row = MakerspacePaymentSettings.for_makerspace(request.makerspace) + per_day = Decimal(settings_row.loan_late_fee_per_day or 0) + if per_day <= 0 or request.return_due_at is None or not loans_enabled(request.makerspace): + return None + deadline = request.return_due_at + timedelta(days=settings_row.loan_grace_days or 0) + if now <= deadline: + return None + existing = _existing(request, Payment.SubjectType.LOAN_LATE_FEE) + if existing is not None: + # Computed once at close, never recomputed: a fee that grew after the fact + # would be a charge the member never saw raised. + return existing + days_late = math.ceil((now - deadline).total_seconds() / 86400) + fee = per_day * days_late + cap = Decimal(settings_row.loan_late_fee_cap or 0) + if cap > 0: + fee = min(fee, cap) + if fee <= 0: + return None + payment, created = _get_or_create( + request, Payment.SubjectType.LOAN_LATE_FEE, fee, settings_row.default_currency, + actor, f"Late return fee - request #{request.pk}", + ) + if created: + audit.record( + actor, "loan.late_fee_raised", makerspace=request.makerspace, target=request, + meta={"request_id": request.pk, "payment_id": payment.pk}, + ) + notifications.notify_loan_charge(request, "late_fee_raised", payment) + if payment.status == Payment.Status.PENDING: + _schedule_checkout(payment) + return payment + + +def _deposit(request, actor, amount, currency): + payment, created = _get_or_create( + request, Payment.SubjectType.LOAN_DEPOSIT, amount, currency, + actor, f"Loan deposit - request #{request.pk}", + ) + if created: + audit.record( + actor, "loan.deposit_raised", makerspace=request.makerspace, target=request, + meta={"request_id": request.pk, "payment_id": payment.pk}, + ) + notifications.notify_loan_charge(request, "deposit_raised", payment) + if payment.status == Payment.Status.PENDING: + _schedule_checkout(payment) + return payment + + +def _existing(request, subject_type): + return Payment.objects.filter( + makerspace=request.makerspace, subject_type=subject_type, subject_id=request.pk + ).first() + + +def _get_or_create(request, subject_type, amount, currency, actor, label): + existing = _existing(request, subject_type) + if existing is not None: + return existing, False + try: + with transaction.atomic(): + return create_payment( + makerspace=request.makerspace, + subject_type=subject_type, + subject_id=request.pk, + member=request.requester, + amount=amount.quantize(Decimal("0.01")), + currency=currency, + created_by=actor or request.requester, + subject_label=label, + ), True + except IntegrityError: + return _existing(request, subject_type), False + + +def _schedule_checkout(payment): + # The debt is already recorded by the caller; a rail is optional on top of it. + if not online_payments_enabled(payment.makerspace, "loans"): + return + + def create_safely(): + try: + create_checkout(payment) + except Exception: + logger.exception("loan_checkout_schedule_failed", extra={"payment_id": payment.pk}) + + transaction.on_commit(create_safely) diff --git a/backend/apps/hardware_requests/notifications.py b/backend/apps/hardware_requests/notifications.py index 85d3ee5a..56ace4cb 100644 --- a/backend/apps/hardware_requests/notifications.py +++ b/backend/apps/hardware_requests/notifications.py @@ -64,6 +64,26 @@ def notify_request_returned(request): ) +def notify_loan_charge(request, event, payment): + """`deposit_raised` / `late_fee_raised`. Chat, push and webhook text only: there is + no email template for loan charges yet, so the email channel gets no deliveries.""" + label = "Loan deposit" if event == "deposit_raised" else "Late return fee" + text = ( + f"Hardware request #{request.pk}: {label} of " + f"{payment.amount} {payment.currency.upper()} raised." + ) + logger.info( + "Hardware request loan charge notification.", + extra={"request_id": request.pk, "makerspace_id": request.makerspace_id, "event": event}, + ) + return notify_lifecycle( + request.makerspace, + feature="hardware_requests", + event=event, + build=lambda: LifecyclePayload(text=text, emails=()), + ) + + def notify_return_due(request): result = _notify( request, diff --git a/backend/apps/hardware_requests/public_views.py b/backend/apps/hardware_requests/public_views.py index a69ae279..a3bc4c25 100644 --- a/backend/apps/hardware_requests/public_views.py +++ b/backend/apps/hardware_requests/public_views.py @@ -37,7 +37,7 @@ from apps.makerspaces.anonymous_requesters import get_or_create_anonymous_requester from apps.makerspaces.lookup import get_public_makerspace from apps.makerspaces.platform import module_enabled -from apps.makerspaces.request_access import anonymous_requests_allowed +from apps.makerspaces.request_access import anonymous_requests_allowed, require_current_term from apps.makerspaces.servability import servable_queryset from apps.presence.guard import require_active_account, require_active_member_presence from apps.openapi import ( @@ -114,7 +114,7 @@ def post(self, request, makerspace_slug, *args, **kwargs): else: _require_module(makerspace, "request_workflow") if module_enabled(makerspace, "membership"): - require_active_member_presence(request.user, makerspace) + require_current_term(makerspace, require_active_member_presence(request.user, makerspace).membership) else: # Waiver acceptance lives on MakerspaceMembership and cannot be recorded # with membership off. In this configuration the flow is public request -> @@ -293,5 +293,8 @@ def _requestable_products(product_ids, makerspace): def _require_module(makerspace, module_key): + from apps.makerspaces.editions import require_public_surface + + require_public_surface(module_key) if not module_enabled(makerspace, module_key): raise ValidationError({"module": f"{module_key} is disabled for this makerspace."}) diff --git a/backend/apps/hardware_requests/return_workflow.py b/backend/apps/hardware_requests/return_workflow.py index 04f3abfd..ff2733f8 100644 --- a/backend/apps/hardware_requests/return_workflow.py +++ b/backend/apps/hardware_requests/return_workflow.py @@ -6,7 +6,7 @@ from apps.evidence import storage from apps.evidence.finalization import charge_storage_once, lock_evidence_for_attachment from apps.evidence.models import EvidencePhoto -from apps.hardware_requests import notifications +from apps.hardware_requests import loan_payments, notifications from apps.hardware_requests.models import ( HardwareRequest, PublicToolLoan, @@ -68,6 +68,9 @@ def return_items(actor, request, evidence_id, remark, box_code, resolutions): request_action = finalize_return_status(locked, actor) _audit_return(actor, locked, box, evidence, scan, request_action) notifications.notify_request_returned(locked) + if locked.status in loan_payments.CLOSED_STATUSES: + request_id = locked.pk + transaction.on_commit(lambda: loan_payments.on_request_closed(request_id, actor)) return locked diff --git a/backend/apps/hardware_requests/workflow.py b/backend/apps/hardware_requests/workflow.py index d1fac415..ba8cdef2 100644 --- a/backend/apps/hardware_requests/workflow.py +++ b/backend/apps/hardware_requests/workflow.py @@ -14,6 +14,7 @@ AnonymousRequestOutstandingLimit, BoxUnavailable, BoxValidationError, + DepositRequired, EvidenceNotUploaded, InvalidTransition, RequestValidationError, @@ -26,6 +27,7 @@ "AnonymousRequestOutstandingLimit", "BoxUnavailable", "BoxValidationError", + "DepositRequired", "EvidenceNotUploaded", "InvalidTransition", "RequestValidationError", diff --git a/backend/apps/hardware_requests/workflow_errors.py b/backend/apps/hardware_requests/workflow_errors.py index f857292d..0d864978 100644 --- a/backend/apps/hardware_requests/workflow_errors.py +++ b/backend/apps/hardware_requests/workflow_errors.py @@ -32,3 +32,13 @@ class BoxUnavailable(Exception): class EvidenceNotUploaded(Exception): pass + + +class DepositRequired(Exception): + """A blocking loan deposit is still unpaid - maps to 409, before evidence checks.""" + + def __init__(self, payment): + self.payment = payment + super().__init__( + f"Loan deposit #{payment.pk} must be settled before this request can be issued." + ) diff --git a/backend/apps/integrations/admin_notifications.py b/backend/apps/integrations/admin_notifications.py index be94ffbc..8bee0746 100644 --- a/backend/apps/integrations/admin_notifications.py +++ b/backend/apps/integrations/admin_notifications.py @@ -29,13 +29,36 @@ def has_delete_permission(self, request, obj=None): @admin.register(NotificationDeliveryLog) class NotificationDeliveryLogAdmin(SuperuserOnlyModelAdmin, ModelAdmin): - """Read-only durable delivery record for non-email channels. Celery owns retry.""" + """Read-only durable delivery record for non-email channels. Celery owns retry; the one + superadmin action re-queues rows that exhausted it (the dead-letter path).""" list_display = ( "makerspace", "channel", "feature", "event", "status", "attempts", "created_at", "sent_at", ) list_filter = ("makerspace", "channel", "feature", "status") + actions = ("requeue_failed_deliveries",) + + @admin.action(description="Re-queue failed deliveries", permissions=["view"]) + def requeue_failed_deliveries(self, request, queryset): + from apps.audit import services as audit + from apps.integrations.dispatch_channels import _enqueue_notification + from apps.integrations.notification_enums import NotificationDeliveryStatus + + failed = list(queryset.filter(status=NotificationDeliveryStatus.FAILED)) + for log in failed: + log.status = NotificationDeliveryStatus.PENDING + log.error = "" + log.save(update_fields=["status", "error", "updated_at"]) + audit.record( + request.user, + "notification.delivery_requeued", + makerspace=log.makerspace, + target=log, + meta={"channel": log.channel, "event": log.event, "attempts": log.attempts}, + ) + _enqueue_notification(log.pk) + self.message_user(request, f"Re-queued {len(failed)} failed deliver{'y' if len(failed) == 1 else 'ies'}.") readonly_fields = ( "makerspace", "channel", "feature", "event", "text_body", "payload", "status", "error", "attempts", "created_at", "updated_at", "sent_at", diff --git a/backend/apps/integrations/dispatch_channels.py b/backend/apps/integrations/dispatch_channels.py index c3f15695..d71903d1 100644 --- a/backend/apps/integrations/dispatch_channels.py +++ b/backend/apps/integrations/dispatch_channels.py @@ -45,6 +45,12 @@ def _channel_configured(makerspace, channel, destination=None) -> bool: resolve_bot_token(makerspace) and resolve_chat_id(makerspace, destination) ) + if channel == NonEmailNotificationChannel.WEBHOOK: + # Destination-only: there is no makerspace-wide signed endpoint, and a + # destination without a signing secret is not-configured, never unsigned. + return destination is not None and bool( + destination.get_webhook_url() and destination.get_signing_secret() + ) if channel in ( NonEmailNotificationChannel.SLACK, NonEmailNotificationChannel.MATTERMOST, @@ -252,6 +258,10 @@ def _deliver_notification(log) -> NotificationDeliveryLog: log.text_body, destination=log.destination, ) + elif log.channel == NonEmailNotificationChannel.WEBHOOK: + from apps.integrations.webhooks import send_signed_webhook + + ok = send_signed_webhook(log) else: from apps.integrations.webhooks import send_webhook diff --git a/backend/apps/integrations/dispatch_destination.py b/backend/apps/integrations/dispatch_destination.py new file mode 100644 index 00000000..739881fe --- /dev/null +++ b/backend/apps/integrations/dispatch_destination.py @@ -0,0 +1,87 @@ +"""Post one plain-text message into ONE explicitly chosen room. + +The lifecycle fan-out (`notify.notify_lifecycle`) resolves rooms from the notification +matrix and destination scopes. A scheduled report is different: the schedule names the +exact destination it should land in, so this sends to that row and nothing else. It keeps +every other rule the fan-out applies -- the channel's module gate, the credential check, +the daily quota, the same transports -- and returns `(ok, error_code)` instead of raising, +because the caller records the outcome on its own delivery row. +""" + +import logging +from dataclasses import dataclass, field +from datetime import datetime + +from django.utils import timezone + +from apps.integrations.dispatch_channels import ( + _channel_configured, + channel_module_blocks, + sanitize_notification_error, +) +from apps.integrations.notification_enums import ChatNotificationChannel +from apps.makerspaces import limits + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class DestinationEnvelope: + """The log-shaped object `webhooks.send_signed_webhook` builds its JSON body from. + + A scheduled report is not a lifecycle notification, so no `NotificationDeliveryLog` + row exists for it; the signed-webhook sender only reads these attributes. + """ + + pk: str + makerspace_id: int + destination: object + feature: str + event: str + text_body: str + payload: dict = field(default_factory=dict) + created_at: datetime = field(default_factory=timezone.now) + + +def deliver_text_to_destination( + makerspace, destination, *, text, feature, event, reference, payload=None +): + """Send `text` to `destination`; returns (delivered, error_code).""" + if destination.makerspace_id != makerspace.pk: + return False, "notification_destination_foreign" + if not destination.is_active: + return False, "notification_destination_inactive" + channel = destination.channel + if channel not in ChatNotificationChannel.values: + return False, "notification_channel_unsupported" + if channel_module_blocks(makerspace, channel): + return False, "notification_channel_module_disabled" + if not _channel_configured(makerspace, channel, destination): + return False, "notification_channel_not_configured" + if not limits.reserve_notification_quota(makerspace, channel): + return False, "notification_quota_exceeded" + try: + if channel == ChatNotificationChannel.TELEGRAM: + from apps.integrations.telegram import send_message + + ok = send_message(makerspace, text, destination=destination) + elif channel == ChatNotificationChannel.WEBHOOK: + from apps.integrations.webhooks import send_signed_webhook + + ok = send_signed_webhook(DestinationEnvelope( + pk=str(reference), makerspace_id=makerspace.pk, destination=destination, + feature=feature, event=event, text_body=text, payload=dict(payload or {}), + )) + else: + from apps.integrations.webhooks import send_webhook + + ok = send_webhook(makerspace, channel=channel, text=text, destination=destination) + except Exception as exc: + logger.warning( + "destination_delivery_failed", + extra={"makerspace_id": makerspace.pk, "destination_id": destination.pk, "channel": channel}, + ) + return False, sanitize_notification_error(exc) + if ok is True: + return True, "" + return False, "notification_channel_not_configured" diff --git a/backend/apps/integrations/migrations/0025_webhook_channel.py b/backend/apps/integrations/migrations/0025_webhook_channel.py new file mode 100644 index 00000000..c5fc57ae --- /dev/null +++ b/backend/apps/integrations/migrations/0025_webhook_channel.py @@ -0,0 +1,47 @@ +# Generated by Django 6.0.8 on 2026-09-03 17:28 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('integrations', '0024_machinetypeemailtemplate'), + ('makerspaces', '0067_reconcile_anonymous_requests_with_membership'), + ] + + operations = [ + migrations.RemoveConstraint( + model_name='notificationdestination', + name='notification_destination_credential_matches_channel', + ), + migrations.AddField( + model_name='notificationdestination', + name='signing_secret', + field=models.TextField(blank=True, default=''), + ), + migrations.AlterField( + model_name='dailynotificationcounter', + name='channel', + field=models.CharField(choices=[('telegram', 'Telegram'), ('slack', 'Slack'), ('mattermost', 'Mattermost'), ('discord', 'Discord'), ('webhook', 'Signed webhook'), ('native_push', 'Native push')], max_length=16), + ), + migrations.AlterField( + model_name='notificationdeliverylog', + name='channel', + field=models.CharField(choices=[('telegram', 'Telegram'), ('slack', 'Slack'), ('mattermost', 'Mattermost'), ('discord', 'Discord'), ('webhook', 'Signed webhook'), ('native_push', 'Native push')], max_length=16), + ), + migrations.AlterField( + model_name='notificationdestination', + name='channel', + field=models.CharField(choices=[('telegram', 'Telegram'), ('slack', 'Slack'), ('mattermost', 'Mattermost'), ('discord', 'Discord'), ('webhook', 'Signed webhook')], max_length=16), + ), + migrations.AlterField( + model_name='notificationpreference', + name='channel', + field=models.CharField(choices=[('email', 'Email'), ('telegram', 'Telegram'), ('slack', 'Slack'), ('mattermost', 'Mattermost'), ('discord', 'Discord'), ('webhook', 'Signed webhook'), ('native_push', 'Native push')], max_length=16), + ), + migrations.AddConstraint( + model_name='notificationdestination', + constraint=models.CheckConstraint(condition=models.Q(models.Q(('channel__in', ['slack', 'mattermost', 'discord', 'webhook']), ('telegram_chat_id', ''), models.Q(('webhook_url', ''), _negated=True)), models.Q(('channel', 'telegram'), ('webhook_url', ''), models.Q(('telegram_chat_id', ''), _negated=True)), _connector='OR'), name='notification_destination_credential_matches_channel'), + ), + ] diff --git a/backend/apps/integrations/models_destinations.py b/backend/apps/integrations/models_destinations.py index 326b7228..13e7d2cd 100644 --- a/backend/apps/integrations/models_destinations.py +++ b/backend/apps/integrations/models_destinations.py @@ -39,6 +39,7 @@ ChatNotificationChannel.SLACK, ChatNotificationChannel.MATTERMOST, ChatNotificationChannel.DISCORD, + ChatNotificationChannel.WEBHOOK, ) @@ -54,6 +55,9 @@ class NotificationDestination(models.Model): # a serializer -- the staff API exposes a `*_set` boolean instead. webhook_url = models.TextField(blank=True, default="") telegram_chat_id = models.CharField(max_length=64, blank=True, default="") + # Fernet ciphertext. Only the `webhook` channel carries one: the receiver verifies + # `X-SpaceWorks-Signature` with it. Never echoed; the API exposes `signing_secret_set`. + signing_secret = models.TextField(blank=True, default="") is_active = models.BooleanField(default=True) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) @@ -91,6 +95,12 @@ def set_webhook_url(self, raw): def get_webhook_url(self): return decrypt_value(self.webhook_url) + def set_signing_secret(self, raw): + self.signing_secret = encrypt_value(raw) + + def get_signing_secret(self): + return decrypt_value(self.signing_secret) if self.signing_secret else "" + def __str__(self): return f"{self.makerspace_id}:{self.channel}/{self.label}" diff --git a/backend/apps/integrations/notification_catalog.py b/backend/apps/integrations/notification_catalog.py index a7134138..b192f40f 100644 --- a/backend/apps/integrations/notification_catalog.py +++ b/backend/apps/integrations/notification_catalog.py @@ -23,6 +23,7 @@ F.HARDWARE_REQUESTS: ( "submitted", "accepted", "rejected", "issued", "partially_returned", "returned", "closed_with_issue", "return_reminder", + "deposit_raised", "late_fee_raised", ), F.PRINTING: ( "submitted", "accepted", "started", "rejected", "completed", "failed", @@ -52,12 +53,12 @@ # Discord is listed explicitly rather than relying on default_state's False fallback, so the # table stays a complete picture of every channel a reader can toggle. DEFAULT_CHANNEL_STATE = { - F.HARDWARE_REQUESTS: {C.EMAIL: True, C.TELEGRAM: True, C.SLACK: False, C.MATTERMOST: False, C.DISCORD: False, C.NATIVE_PUSH: False}, - F.PRINTING: {C.EMAIL: True, C.TELEGRAM: False, C.SLACK: False, C.MATTERMOST: False, C.DISCORD: False, C.NATIVE_PUSH: False}, - F.EVENTS: {C.EMAIL: False, C.TELEGRAM: False, C.SLACK: False, C.MATTERMOST: False, C.DISCORD: False, C.NATIVE_PUSH: False}, - F.BOOKINGS: {C.EMAIL: True, C.TELEGRAM: True, C.SLACK: False, C.MATTERMOST: False, C.DISCORD: False, C.NATIVE_PUSH: False}, - F.MAINTENANCE: {C.EMAIL: False, C.TELEGRAM: False, C.SLACK: False, C.MATTERMOST: False, C.DISCORD: False, C.NATIVE_PUSH: False}, - F.MEMBERS: {C.EMAIL: False, C.TELEGRAM: False, C.SLACK: False, C.MATTERMOST: False, C.DISCORD: False, C.NATIVE_PUSH: False}, + F.HARDWARE_REQUESTS: {C.EMAIL: True, C.TELEGRAM: True, C.SLACK: False, C.MATTERMOST: False, C.DISCORD: False, C.WEBHOOK: False, C.NATIVE_PUSH: False}, + F.PRINTING: {C.EMAIL: True, C.TELEGRAM: False, C.SLACK: False, C.MATTERMOST: False, C.DISCORD: False, C.WEBHOOK: False, C.NATIVE_PUSH: False}, + F.EVENTS: {C.EMAIL: False, C.TELEGRAM: False, C.SLACK: False, C.MATTERMOST: False, C.DISCORD: False, C.WEBHOOK: False, C.NATIVE_PUSH: False}, + F.BOOKINGS: {C.EMAIL: True, C.TELEGRAM: True, C.SLACK: False, C.MATTERMOST: False, C.DISCORD: False, C.WEBHOOK: False, C.NATIVE_PUSH: False}, + F.MAINTENANCE: {C.EMAIL: False, C.TELEGRAM: False, C.SLACK: False, C.MATTERMOST: False, C.DISCORD: False, C.WEBHOOK: False, C.NATIVE_PUSH: False}, + F.MEMBERS: {C.EMAIL: False, C.TELEGRAM: False, C.SLACK: False, C.MATTERMOST: False, C.DISCORD: False, C.WEBHOOK: False, C.NATIVE_PUSH: False}, } diff --git a/backend/apps/integrations/notification_enums.py b/backend/apps/integrations/notification_enums.py index 02bb668e..86abf978 100644 --- a/backend/apps/integrations/notification_enums.py +++ b/backend/apps/integrations/notification_enums.py @@ -29,6 +29,7 @@ class NotificationChannel(models.TextChoices): SLACK = "slack", "Slack" MATTERMOST = "mattermost", "Mattermost" DISCORD = "discord", "Discord" + WEBHOOK = "webhook", "Signed webhook" NATIVE_PUSH = "native_push", "Native push" @@ -37,6 +38,7 @@ class NonEmailNotificationChannel(models.TextChoices): SLACK = "slack", "Slack" MATTERMOST = "mattermost", "Mattermost" DISCORD = "discord", "Discord" + WEBHOOK = "webhook", "Signed webhook" NATIVE_PUSH = "native_push", "Native push" @@ -51,6 +53,9 @@ class ChatNotificationChannel(models.TextChoices): SLACK = "slack", "Slack" MATTERMOST = "mattermost", "Mattermost" DISCORD = "discord", "Discord" + # Not a chat room: a machine endpoint that receives the SAME notification a room would, + # as JSON, signed with a per-destination secret (apps/integrations/webhooks.py). + WEBHOOK = "webhook", "Signed webhook" # Hard per-provider message ceilings, in characters. One table because the failure mode @@ -62,6 +67,7 @@ class ChatNotificationChannel(models.TextChoices): "slack": 40000, # incoming webhook `text` "mattermost": 16383, # incoming webhook `text` "discord": 2000, # webhook `content` + "webhook": 100000, # our own JSON `text`; generous, but bounded like every channel } @@ -97,4 +103,5 @@ class NotificationDeliveryStatus(models.TextChoices): "slack": "slack", "mattermost": "mattermost", "discord": "discord", + "webhook": "webhook", } diff --git a/backend/apps/integrations/notify.py b/backend/apps/integrations/notify.py index 2a5104e2..f8e832f3 100644 --- a/backend/apps/integrations/notify.py +++ b/backend/apps/integrations/notify.py @@ -129,6 +129,7 @@ def _run_guarded(makerspace, feature, event, build, sync): NotificationChannel.SLACK, NotificationChannel.MATTERMOST, NotificationChannel.DISCORD, + NotificationChannel.WEBHOOK, NotificationChannel.NATIVE_PUSH, ): if not enabled[channel]: diff --git a/backend/apps/integrations/reports_communications.py b/backend/apps/integrations/reports_communications.py index a69f2929..56f2b3b4 100644 --- a/backend/apps/integrations/reports_communications.py +++ b/backend/apps/integrations/reports_communications.py @@ -22,7 +22,7 @@ def build_communications_health(makerspace_id, *, limit=None, date_range=None): _notification_rows(space.id, records, aggregate, date_range) if module_enabled(space, "email"): _email_rows(space.id, records, aggregate, date_range) - for channel in ("telegram", "slack", "mattermost", "discord"): + for channel in ("telegram", "slack", "mattermost", "discord", "webhook"): if module_enabled(space, channel): _channel_rows(space.id, channel, records, aggregate, date_range) fields = (("makerspace_id",) + FIELDS) if aggregate else FIELDS diff --git a/backend/apps/integrations/webhooks.py b/backend/apps/integrations/webhooks.py index e0c5f7de..fdd78251 100644 --- a/backend/apps/integrations/webhooks.py +++ b/backend/apps/integrations/webhooks.py @@ -1,7 +1,10 @@ +import hashlib +import hmac import http.client import json import logging import socket +import time from urllib.parse import urljoin from apps.integrations.notification_enums import trim_for_channel @@ -58,7 +61,7 @@ def _resolve_url(makerspace, channel): def _post_to_target( - target: ResolvedWebhookTarget, payload: bytes + target: ResolvedWebhookTarget, payload: bytes, extra_headers=None ) -> tuple[int, str | None]: last_error = None for address in target.addresses: @@ -71,6 +74,7 @@ def _post_to_target( headers={ "Content-Type": "application/json", "Host": target.host_header, + **(extra_headers or {}), }, ) response = connection.getresponse() @@ -84,13 +88,17 @@ def _post_to_target( raise WebhookDeliveryError("Webhook delivery failed.") -def _deliver(url: str, payload: bytes) -> None: +def _deliver(url: str, payload: bytes, extra_headers=None) -> None: current_url = url for hop in range(_MAX_REDIRECTS + 1): # Resolve at send time even though the URL was validated when saved. The returned # socket addresses are the exact ones used by `_PinnedHTTPSConnection`. target = resolve_webhook_target(current_url) - status, location = _post_to_target(target, payload) + status, location = ( + _post_to_target(target, payload, extra_headers) + if extra_headers + else _post_to_target(target, payload) + ) if status in _REDIRECT_STATUSES: if hop == _MAX_REDIRECTS or not location: raise WebhookDeliveryError("Webhook redirect was refused.") @@ -129,3 +137,61 @@ def send_webhook(makerspace, *, channel: str, text: str, destination=None) -> bo }, ) raise WebhookDeliveryError("Webhook delivery failed.") from exc + + +SIGNATURE_HEADER = "X-SpaceWorks-Signature" +EVENT_HEADER = "X-SpaceWorks-Event" +DELIVERY_HEADER = "X-SpaceWorks-Delivery" +SIGNATURE_VERSION = "v1" + + +def sign_webhook_body(secret: str, body: bytes, timestamp: int) -> str: + """`t=,v1=.")>` — the receiver recomputes over the + exact bytes it received. Same discipline as the API-client HMAC: sign what is sent.""" + digest = hmac.new( + secret.encode("utf-8"), f"{timestamp}.".encode("utf-8") + body, hashlib.sha256 + ).hexdigest() + return f"t={timestamp},{SIGNATURE_VERSION}={digest}" + + +def webhook_event_body(log) -> dict: + """The JSON a signed webhook receives: exactly the notification a chat room would get, + plus the ids the matrix already attached. No `meta`, no contact fields.""" + return { + "id": log.pk, + "event": log.event, + "feature": log.feature, + "makerspace_id": log.makerspace_id, + "text": log.text_body, + "data": log.payload or {}, + "created_at": log.created_at.isoformat() if log.created_at else None, + } + + +def send_signed_webhook(log) -> bool: + """Deliver one `webhook`-channel notification: JSON body, signed with the destination's secret.""" + destination = log.destination + if destination is None: + return False + url = destination.get_webhook_url() + secret = destination.get_signing_secret() + if not url or not secret: + return False + body = json.dumps(webhook_event_body(log), separators=(",", ":"), sort_keys=True).encode("utf-8") + timestamp = int(time.time()) + headers = { + SIGNATURE_HEADER: sign_webhook_body(secret, body, timestamp), + EVENT_HEADER: log.event, + DELIVERY_HEADER: str(log.pk), + "User-Agent": "SpaceWorks-Webhook/1", + } + try: + _deliver(url, body, headers) + return True + except Exception as exc: + logger.warning( + "Signed webhook delivery failed.", + extra={"makerspace_id": log.makerspace_id, "destination_id": destination.pk}, + ) + raise WebhookDeliveryError("Webhook delivery failed.") from exc + diff --git a/backend/apps/inventory/migrations/0010_inventoryproduct_search_vector.py b/backend/apps/inventory/migrations/0010_inventoryproduct_search_vector.py new file mode 100644 index 00000000..5aa998b1 --- /dev/null +++ b/backend/apps/inventory/migrations/0010_inventoryproduct_search_vector.py @@ -0,0 +1,34 @@ +import django.contrib.postgres.indexes +import django.contrib.postgres.search +from django.contrib.postgres.operations import TrigramExtension +from django.db import migrations + +from apps.inventory.search import vector_trigger_sql + +FORWARD, REVERSE = vector_trigger_sql( + "inventory_inventoryproduct", + [("name", "A"), ("storage_location", "B"), ("tracking_mode", "C"), ("description", "D")], + trigger_name="inventoryproduct_search_vector_trg", +) + + +class Migration(migrations.Migration): + dependencies = [ + ("inventory", "0009_inventoryproduct_image_key"), + ] + + operations = [ + TrigramExtension(), + migrations.AddField( + model_name="inventoryproduct", + name="search_vector", + field=django.contrib.postgres.search.SearchVectorField(editable=False, null=True), + ), + migrations.AddIndex( + model_name="inventoryproduct", + index=django.contrib.postgres.indexes.GinIndex( + fields=["search_vector"], name="inventoryproduct_search_gin" + ), + ), + migrations.RunSQL(FORWARD, REVERSE), + ] diff --git a/backend/apps/inventory/migrations/0011_inventoryproduct_deposit_amount.py b/backend/apps/inventory/migrations/0011_inventoryproduct_deposit_amount.py new file mode 100644 index 00000000..8b9d1605 --- /dev/null +++ b/backend/apps/inventory/migrations/0011_inventoryproduct_deposit_amount.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.8 on 2026-09-03 21:45 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('inventory', '0010_inventoryproduct_search_vector'), + ] + + operations = [ + migrations.AddField( + model_name='inventoryproduct', + name='deposit_amount', + field=models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True), + ), + ] diff --git a/backend/apps/inventory/models.py b/backend/apps/inventory/models.py index 9014e162..b03c50a5 100644 --- a/backend/apps/inventory/models.py +++ b/backend/apps/inventory/models.py @@ -1,4 +1,6 @@ from django.core.exceptions import ValidationError +from django.contrib.postgres.indexes import GinIndex +from django.contrib.postgres.search import SearchVectorField from django.db import models from apps.makerspaces.models import Makerspace @@ -90,15 +92,24 @@ class InventoryProduct(models.Model): default=PublicAvailabilityMode.STATUS_ONLY, ) storage_location = models.CharField(max_length=200, blank=True) + # Per-unit loan deposit in major units, used only when the makerspace's + # `loan_deposit_mode` is `per_product`. Null means "no deposit for this item". + deposit_amount = models.DecimalField( + max_digits=12, decimal_places=2, null=True, blank=True + ) is_archived = models.BooleanField(default=False) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) + # Derived: filled by the Postgres trigger in migration 0010, never written by Django, + # omitted from export and tenant migration (apps/inventory/search.py). + search_vector = SearchVectorField(null=True, editable=False) class Meta: verbose_name = "Inventory item" verbose_name_plural = "Inventory" indexes = [ models.Index(fields=["makerspace", "is_public", "is_archived"]), + GinIndex(fields=["search_vector"], name="inventoryproduct_search_gin"), ] constraints = [ models.CheckConstraint( diff --git a/backend/apps/inventory/search.py b/backend/apps/inventory/search.py new file mode 100644 index 00000000..246d368b --- /dev/null +++ b/backend/apps/inventory/search.py @@ -0,0 +1,94 @@ +"""One search contract for every list endpoint: ``?q=`` over a Postgres search vector. + +Three models carry a trigger-maintained ``search_vector`` column (``InventoryProduct``, +``machines.Machine``, ``events.Event``). The vector is built in Postgres, not in ``save()``, +so bulk import, admin edits and raw SQL all keep it fresh, and it is a **derived** column: +omitted from data export and rebuilt by the trigger after a tenant move. + +``apply_q`` combines a websearch-style full-text match (``"exact phrase"``, ``-excluded``) +with trigram similarity on the primary label, so a typo still finds the tool. Rows are +ordered by rank when a query is present; callers apply their own default ordering otherwise. + +Never index a scoped-PII column. The trigger SQL below names plain columns only, and the +encrypted requester fields on ``HardwareRequest`` are deliberately not part of this contract +(that queue keeps its blind-index search in ``hardware_requests/queue_views.py``). + +``apps/encryption/search.py`` is a different thing — blind-index lookups over encrypted +fields — and is not related to this module. +""" +import re + +from django.contrib.postgres.search import SearchQuery, SearchRank, TrigramSimilarity +from django.db.models import F, Q, Value + +SEARCH_CONFIG = "simple" # language-neutral until phase 8 adds per-makerspace locales +TRIGRAM_THRESHOLD = 0.3 +MAX_QUERY_LENGTH = 200 + + +def clean_query(raw): + text = (raw or "").strip() + return text[:MAX_QUERY_LENGTH] + + +def _operators_present(query): + lowered = f" {query.lower()} " + return '"' in query or " -" in lowered or query.startswith("-") or " or " in lowered + + +def search_query(query): + """Plain words match as prefixes (``solder`` finds ``soldering``, like the old substring + search did); anything using quotes, ``-word`` or ``OR`` gets websearch semantics instead.""" + if _operators_present(query): + return SearchQuery(query, search_type="websearch", config=SEARCH_CONFIG) + terms = re.findall(r"\w+", query) + if not terms: + return SearchQuery(query, search_type="plain", config=SEARCH_CONFIG) + return SearchQuery(" & ".join(f"{term}:*" for term in terms), search_type="raw", config=SEARCH_CONFIG) + + +def apply_q(queryset, raw_query, *, label_field="name", vector_field="search_vector"): + """Filter ``queryset`` by ``raw_query``; unchanged (and unordered) when the query is blank.""" + query = clean_query(raw_query) + if not query: + return queryset + search = search_query(query) + similarity = TrigramSimilarity(label_field, Value(query)) + return ( + queryset.annotate( + _rank=SearchRank(F(vector_field), search), + _similarity=similarity, + ) + .filter(Q(**{vector_field: search}) | Q(_similarity__gte=TRIGRAM_THRESHOLD)) + .order_by("-_rank", "-_similarity", label_field, "pk") + ) + + +def vector_trigger_sql(table, columns, *, trigger_name): + """Forward/reverse SQL for a BEFORE INSERT OR UPDATE trigger that fills ``search_vector``. + + ``columns`` is an ordered list of (column, weight) pairs; weight A outranks D. + """ + parts = " || ".join( + f"setweight(to_tsvector('{SEARCH_CONFIG}', coalesce(NEW.{column}, '')), '{weight}')" + for column, weight in columns + ) + function = f"{trigger_name}_fn" + forward = f""" +CREATE OR REPLACE FUNCTION {function}() RETURNS trigger AS $$ +BEGIN + NEW.search_vector := {parts}; + RETURN NEW; +END +$$ LANGUAGE plpgsql; +DROP TRIGGER IF EXISTS {trigger_name} ON {table}; +CREATE TRIGGER {trigger_name} + BEFORE INSERT OR UPDATE ON {table} + FOR EACH ROW EXECUTE FUNCTION {function}(); +UPDATE {table} SET search_vector = {parts.replace('NEW.', '')}; +""" + reverse = f""" +DROP TRIGGER IF EXISTS {trigger_name} ON {table}; +DROP FUNCTION IF EXISTS {function}(); +""" + return forward, reverse diff --git a/backend/apps/inventory/views.py b/backend/apps/inventory/views.py index c1659002..1ea19f06 100644 --- a/backend/apps/inventory/views.py +++ b/backend/apps/inventory/views.py @@ -6,6 +6,8 @@ from rest_framework.permissions import AllowAny from apps.apiclients.throttling import ClientTierRateThrottle +from apps.inventory.search import apply_q +from apps.makerspaces.editions import public_surface_available from apps.inventory.serializers import ( PublicCategorySerializer, PublicMakerspaceSerializer, @@ -54,7 +56,7 @@ def get_queryset(self): name="q", type=str, location=OpenApiParameter.QUERY, - description="Search public products by name or description.", + description="Full-text search over name, storage location, tracking mode and description; supports \"phrases\" and -exclusions, tolerates typos in the name.", ), OpenApiParameter( name="category", @@ -80,9 +82,10 @@ class PublicInventoryListView(ListAPIView): def get_queryset(self): makerspace = get_public_makerspace(self.kwargs["makerspace_slug"]) - if not makerspace.public_inventory_enabled or not module_enabled( - makerspace, - "public_inventory", + if ( + not public_surface_available("public_inventory") + or not makerspace.public_inventory_enabled + or not module_enabled(makerspace, "public_inventory") ): raise Http404 @@ -95,9 +98,7 @@ def get_queryset(self): ) query = self.request.query_params.get("q", "").strip() if query: - queryset = queryset.filter( - Q(name__icontains=query) | Q(description__icontains=query) - ) + queryset = apply_q(queryset, query) category_slug = self.request.query_params.get("category", "").strip() if category_slug: @@ -140,9 +141,10 @@ class PublicCategoryListView(ListAPIView): def get_queryset(self): makerspace = get_public_makerspace(self.kwargs["makerspace_slug"]) - if not makerspace.public_inventory_enabled or not module_enabled( - makerspace, - "public_inventory", + if ( + not public_surface_available("public_inventory") + or not makerspace.public_inventory_enabled + or not module_enabled(makerspace, "public_inventory") ): raise Http404 return ( @@ -171,9 +173,10 @@ class PublicInventoryDetailView(RetrieveAPIView): def get_queryset(self): makerspace = get_public_makerspace(self.kwargs["makerspace_slug"]) - if not makerspace.public_inventory_enabled or not module_enabled( - makerspace, - "public_inventory", + if ( + not public_surface_available("public_inventory") + or not makerspace.public_inventory_enabled + or not module_enabled(makerspace, "public_inventory") ): raise Http404 return servable_queryset( diff --git a/backend/apps/machines/certifications.py b/backend/apps/machines/certifications.py new file mode 100644 index 00000000..c248930b --- /dev/null +++ b/backend/apps/machines/certifications.py @@ -0,0 +1,183 @@ +"""The single authority on whether a member is certified to use a machine type. + +Nothing else may decide "is this member trained". The two callers are +``service_workflow_actions.submit`` (a member asking for work on a machine) and +``services_bookings.create_booking`` (a member booking a space wired to a machine type); +both call ``require_certification`` and let it raise. + +Three properties are load-bearing: + +- **Off by default, and a no-op when off.** Gating lives behind the + ``machines.certifications`` feature, so an existing makerspace that upgrades keeps + accepting every request and booking until someone opts in. `feature_enabled` also + covers the `machines` module being uninstalled, so a bookings-only space is never + gated by a module it does not have. +- **It fails CLOSED once on.** No resolvable membership, or a membership with no live + grant, is a refusal — not a pass. A gate that lets an unknown identity through is not + a gate. +- **A bypass is authorized and audited, never silent.** Only an actor with authority over + that machine type may override, only with a stated reason, and every override writes an + audit row naming the certification it skipped. Staff will need to let a trained-but- + unrecorded member through; the answer is an accountable exception, not a loophole. +""" + +from django.db.models import Q +from django.utils import timezone +from rest_framework.exceptions import PermissionDenied + +from apps.audit import services as audit +from apps.machines import access +from apps.machines.models import CertificationGrant, CertificationType +from apps.makerspaces.platform import feature_enabled + +FEATURE_KEY = "machines.certifications" +PURPOSE_SERVICE = "service" +PURPOSE_BOOKING = "booking" +PURPOSES = (PURPOSE_SERVICE, PURPOSE_BOOKING) + + +def active_grant(membership, certification_type, now=None): + """The membership's live grant for this type, or None. + + "Live" is unrevoked AND unexpired at ``now``; expiry is read from the grant's stored + ``expires_at`` rather than recomputed from the type's current ``validity_days``, so + tightening the policy cannot retroactively invalidate correctly-issued training. + """ + if membership is None or certification_type is None: + return None + moment = now or timezone.now() + return ( + CertificationGrant.objects.filter( + membership=membership, + certification_type=certification_type, + revoked_at__isnull=True, + ) + .filter(Q(expires_at__isnull=True) | Q(expires_at__gt=moment)) + .order_by("-granted_at", "-pk") + .first() + ) + + +def live_certification_names(membership, now=None): + """Sorted names of the membership's live grants on active types (profile + card print).""" + if membership is None: + return () + moment = now or timezone.now() + rows = ( + CertificationGrant.objects.filter( + membership=membership, revoked_at__isnull=True, certification_type__is_active=True + ) + .filter(Q(expires_at__isnull=True) | Q(expires_at__gt=moment)) + .values_list("certification_type__name", flat=True) + ) + return tuple(sorted(set(rows))) + + +def required_types(makerspace, machine_type, *, for_service=False, for_booking=False): + """Active certification types for this machine type, narrowed by purpose. + + With neither flag this is the plain listing of active types (what the staff console + reads); the gate always passes exactly one, so it never over-gates. + """ + queryset = CertificationType.objects.filter( + makerspace=makerspace, machine_type=machine_type, is_active=True + ).order_by("name", "pk") + if not (for_service or for_booking): + return queryset + flags = Q() + if for_service: + flags |= Q(is_required_for_service=True) + if for_booking: + flags |= Q(is_required_for_booking=True) + return queryset.filter(flags) + + +def require_certification( + makerspace, membership, machine_type, *, purpose, actor=None, override_reason="" +): + """Raise unless ``membership`` holds every certification this purpose requires. + + A no-op while the feature is off, or when the machine type carries no requirement for + this purpose. ``override_reason`` is honoured only for an actor with authority over + the machine type, and every honoured override is audited. + """ + if purpose not in PURPOSES: + raise ValueError(f"purpose must be one of {PURPOSES!r}, got {purpose!r}") + if machine_type is None or makerspace is None: + return + if not feature_enabled(makerspace, FEATURE_KEY): + return + + required = list( + required_types( + makerspace, + machine_type, + for_service=purpose == PURPOSE_SERVICE, + for_booking=purpose == PURPOSE_BOOKING, + ) + ) + if not required: + return + + now = timezone.now() + missing = [ + row for row in required if active_grant(membership, row, now) is None + ] + if not missing: + return + + reason = str(override_reason or "").strip() + if reason and _may_override(actor, makerspace, machine_type): + for row in missing: + audit.record( + actor, + "certification.override", + makerspace=makerspace, + target=membership, + meta={"reason": reason, "certification_type_id": row.pk}, + ) + return + + raise PermissionDenied( + { + "code": "certification_required", + "certifications": [row.name for row in missing], + } + ) + + +def require_certification_for_member( + makerspace, member, machine_type, *, purpose, actor=None, override_reason="" +): + """`require_certification` keyed by the member USER, resolving their membership. + + Both call sites hold a user, not a membership row. Resolving here keeps the lookup + (and its fail-closed `None`) in one place instead of duplicated in two workflows. + """ + from apps.makerspaces.models import MakerspaceMembership + + membership = None + if member is not None and makerspace is not None: + membership = MakerspaceMembership.objects.filter( + makerspace=makerspace, user=member, status="active" + ).first() + require_certification( + makerspace, + membership, + machine_type, + purpose=purpose, + actor=actor, + override_reason=override_reason, + ) + + +def _may_override(actor, makerspace, machine_type): + """Authority over the machine TYPE, which is exactly what `can_create_machine` encodes. + + Tier 1 `MANAGE_MACHINES` narrowed to a role that is linked to this type, or the type's + own direct manager. A per-machine link deliberately does not qualify: being handed one + machine is not authority to declare who is trained on its whole class. + """ + if actor is None or not getattr(actor, "is_authenticated", False): + return False + return access.can_create_machine(actor, makerspace.pk, machine_type) diff --git a/backend/apps/machines/migrations/0023_machine_search_vector.py b/backend/apps/machines/migrations/0023_machine_search_vector.py new file mode 100644 index 00000000..f9e25dfc --- /dev/null +++ b/backend/apps/machines/migrations/0023_machine_search_vector.py @@ -0,0 +1,34 @@ +import django.contrib.postgres.indexes +import django.contrib.postgres.search +from django.db import migrations + +from apps.inventory.search import vector_trigger_sql + +FORWARD, REVERSE = vector_trigger_sql( + "machines_machine", + [("name", "A"), ("location", "B"), ("firmware_version", "C"), ("notes", "D")], + trigger_name="machine_search_vector_trg", +) + + +class Migration(migrations.Migration): + dependencies = [ + ("machines", "0022_consumable_pool_color_hex"), + # pg_trgm is created once, by the inventory migration. + ("inventory", "0010_inventoryproduct_search_vector"), + ] + + operations = [ + migrations.AddField( + model_name="machine", + name="search_vector", + field=django.contrib.postgres.search.SearchVectorField(editable=False, null=True), + ), + migrations.AddIndex( + model_name="machine", + index=django.contrib.postgres.indexes.GinIndex( + fields=["search_vector"], name="machine_search_gin" + ), + ), + migrations.RunSQL(FORWARD, REVERSE), + ] diff --git a/backend/apps/machines/migrations/0024_certifications.py b/backend/apps/machines/migrations/0024_certifications.py new file mode 100644 index 00000000..7a9c38a0 --- /dev/null +++ b/backend/apps/machines/migrations/0024_certifications.py @@ -0,0 +1,56 @@ +# Generated by Django 6.0.8 on 2026-09-03 18:48 + +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('machines', '0023_machine_search_vector'), + ('makerspaces', '0067_reconcile_anonymous_requests_with_membership'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='CertificationType', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=200)), + ('description', models.TextField(blank=True)), + ('validity_days', models.PositiveIntegerField(blank=True, null=True)), + ('is_required_for_service', models.BooleanField(default=False)), + ('is_required_for_booking', models.BooleanField(default=False)), + ('is_active', models.BooleanField(default=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('machine_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='certification_types', to='machines.machinetype')), + ('makerspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='certification_types', to='makerspaces.makerspace')), + ], + ), + migrations.CreateModel( + name='CertificationGrant', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('granted_at', models.DateTimeField(default=django.utils.timezone.now)), + ('expires_at', models.DateTimeField(blank=True, null=True)), + ('revoked_at', models.DateTimeField(blank=True, null=True)), + ('notes', models.TextField(blank=True)), + ('granted_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='certifications_granted', to=settings.AUTH_USER_MODEL)), + ('membership', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='certification_grants', to='makerspaces.makerspacemembership')), + ('revoked_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='certifications_revoked', to=settings.AUTH_USER_MODEL)), + ('certification_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='grants', to='machines.certificationtype')), + ], + ), + migrations.AddConstraint( + model_name='certificationtype', + constraint=models.UniqueConstraint(fields=('makerspace', 'machine_type', 'name'), name='certificationtype_uniq_name'), + ), + migrations.AddIndex( + model_name='certificationgrant', + index=models.Index(fields=['membership', 'certification_type'], name='certgrant_member_type_idx'), + ), + ] diff --git a/backend/apps/machines/models.py b/backend/apps/machines/models.py index 9d469452..5c97550d 100644 --- a/backend/apps/machines/models.py +++ b/backend/apps/machines/models.py @@ -10,6 +10,9 @@ ServiceRequestFile, get_or_create_default_bucket, ) +# Training/certification records. String FK references throughout, so import order +# relative to MachineType below does not matter. +from .models_certifications import CertificationGrant, CertificationType from .printing_cutover_models import PrintingCutoverRepair, PrintingCutoverState # Role -> machine/type scope links. Imported here so Django registers them with this app; @@ -31,6 +34,8 @@ ) __all__ = [ + "CertificationGrant", + "CertificationType", "Machine", "MachineConsumable", "MachineConsumableAdjustment", diff --git a/backend/apps/machines/models_catalog.py b/backend/apps/machines/models_catalog.py index 795bf0d2..ce3d34f5 100644 --- a/backend/apps/machines/models_catalog.py +++ b/backend/apps/machines/models_catalog.py @@ -1,4 +1,6 @@ from django.conf import settings +from django.contrib.postgres.indexes import GinIndex +from django.contrib.postgres.search import SearchVectorField from django.db import models from django.db.models import Q from apps.machines.metering import MeteringUnit, validate_type_config @@ -136,9 +138,12 @@ class Status(models.TextChoices): on_delete=models.SET_NULL, related_name="+", ) + # Derived: trigger-maintained (machines migration 0023); see apps/inventory/search.py. + search_vector = SearchVectorField(null=True, editable=False) class Meta: ordering = ["makerspace__name", "name"] + indexes = [GinIndex(fields=["search_vector"], name="machine_search_gin")] def __str__(self): return self.name diff --git a/backend/apps/machines/models_certifications.py b/backend/apps/machines/models_certifications.py new file mode 100644 index 00000000..454cc6c2 --- /dev/null +++ b/backend/apps/machines/models_certifications.py @@ -0,0 +1,114 @@ +"""Training records: which machine types a member has been certified to use. + +A makerspace defines a ``CertificationType`` per machine type ("Laser cutter +induction"), and issues a ``CertificationGrant`` to one membership when that member has +been trained. The gate in ``certifications.py`` reads these two tables; nothing else may +compute "is this member certified". + +Two deliberate shapes: + +- **The requirement lives on the type, not the machine.** Training is about a class of + hardware, not one serial number, so buying another laser cutter must not silently + un-gate it. ``is_required_for_service`` and ``is_required_for_booking`` are separate + flags because the two are genuinely different risks: a staff-operated print job needs + no member training, while booking the machine to use it yourself does. +- **A grant is never deleted, and never edited into invalidity.** Revocation sets + ``revoked_at``/``revoked_by`` and expiry is a stored ``expires_at`` rather than a + recomputation from ``validity_days``, so shortening a type's validity window later + cannot retroactively invalidate training that was correctly issued under the old + policy. The grant row is the evidence that someone was trained on a date; a delete + would erase the accountability trail that justified letting them near the machine. +""" + +from django.conf import settings +from django.db import models +from django.utils import timezone + + +class CertificationType(models.Model): + """One training requirement a makerspace defines over one machine type.""" + + makerspace = models.ForeignKey( + "makerspaces.Makerspace", + on_delete=models.CASCADE, + related_name="certification_types", + ) + machine_type = models.ForeignKey( + "machines.MachineType", + on_delete=models.CASCADE, + related_name="certification_types", + ) + name = models.CharField(max_length=200) + description = models.TextField(blank=True) + # NULL means "never expires". A stored window rather than a policy the gate + # recomputes: see the module docstring. + validity_days = models.PositiveIntegerField(null=True, blank=True) + is_required_for_service = models.BooleanField(default=False) + is_required_for_booking = models.BooleanField(default=False) + # Soft-delete. Deactivating stops the gate consulting the type while leaving every + # issued grant intact, so re-activating does not require re-training everybody. + is_active = models.BooleanField(default=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["makerspace", "machine_type", "name"], + name="certificationtype_uniq_name", + ), + ] + + def __str__(self): + return f"{self.name} ({self.machine_type_id})" + + +class CertificationGrant(models.Model): + """Records that one membership holds one certification, until revoked or expired.""" + + certification_type = models.ForeignKey( + "machines.CertificationType", + on_delete=models.CASCADE, + related_name="grants", + ) + membership = models.ForeignKey( + "makerspaces.MakerspaceMembership", + on_delete=models.CASCADE, + related_name="certification_grants", + ) + granted_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="certifications_granted", + ) + granted_at = models.DateTimeField(default=timezone.now) + expires_at = models.DateTimeField(null=True, blank=True) + revoked_at = models.DateTimeField(null=True, blank=True) + revoked_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="certifications_revoked", + ) + notes = models.TextField(blank=True) + + class Meta: + indexes = [ + models.Index( + fields=["membership", "certification_type"], + name="certgrant_member_type_idx", + ), + ] + + def __str__(self): + return f"{self.membership_id} -> {self.certification_type_id}" + + def is_active(self, now=None): + """Unrevoked and unexpired at ``now``. The single definition of a live grant.""" + if self.revoked_at is not None: + return False + moment = now or timezone.now() + return self.expires_at is None or self.expires_at > moment diff --git a/backend/apps/machines/reports_certifications.py b/backend/apps/machines/reports_certifications.py new file mode 100644 index 00000000..81c58295 --- /dev/null +++ b/backend/apps/machines/reports_certifications.py @@ -0,0 +1,50 @@ +"""Certification coverage report: per machine type, how many active members are trained.""" +from datetime import timedelta + +from django.db.models import Q +from django.utils import timezone + +from apps.machines.models import CertificationGrant, CertificationType +from apps.makerspaces.models import MakerspaceMembership +from apps.makerspaces.platform import feature_enabled, module_enabled +from apps.operations.report_types import ReportResult +from apps.operations.reports_common import limited, report_spaces + +FIELDS = ( + "machine_type", "certification_type", "gating_enabled", "required_for_service", + "required_for_booking", "active_members", "certified_members", "expiring_30d", + "revoked_grants", "coverage_percent", +) + + +def build_certification_coverage(makerspace_id, *, limit=None, date_range=None, grain="day"): + aggregate = makerspace_id is None + now = timezone.now() + records = [] + for space in report_spaces(makerspace_id): + if not module_enabled(space, "machines"): + continue + gating = feature_enabled(space, "machines.certifications") + active_members = MakerspaceMembership.objects.filter(makerspace=space, status="active").count() + types = CertificationType.objects.filter(makerspace=space, is_active=True).select_related("machine_type").order_by("machine_type__name", "name") + for row in types: + live = CertificationGrant.objects.filter( + certification_type=row, revoked_at__isnull=True, membership__status="active" + ).filter(Q(expires_at__isnull=True) | Q(expires_at__gt=now)) + certified = live.values("membership_id").distinct().count() + record = { + "machine_type": row.machine_type.name, + "certification_type": row.name, + "gating_enabled": gating, + "required_for_service": row.is_required_for_service, + "required_for_booking": row.is_required_for_booking, + "active_members": active_members, + "certified_members": certified, + "expiring_30d": live.filter(expires_at__lte=now + timedelta(days=30)).values("membership_id").distinct().count(), + "revoked_grants": CertificationGrant.objects.filter(certification_type=row, revoked_at__isnull=False).count(), + "coverage_percent": round(100 * certified / active_members, 1) if active_members else 0, + } + if aggregate: + record["makerspace_id"] = space.id + records.append(record) + return ReportResult(FIELDS, limited(records, limit)) diff --git a/backend/apps/machines/service_lifecycle.py b/backend/apps/machines/service_lifecycle.py index 6c44b08b..b4dec7f9 100644 --- a/backend/apps/machines/service_lifecycle.py +++ b/backend/apps/machines/service_lifecycle.py @@ -43,7 +43,8 @@ def delete_for_makerspace(makerspace, cursor): cursor.execute("DELETE FROM machines_machineconsumableadjustment WHERE makerspace_id = %s", [makerspace.id]) cursor.execute("DELETE FROM machines_machineusageentry WHERE machine_id IN (SELECT id FROM machines_machine WHERE makerspace_id = %s)", [makerspace.id]) ServiceRequestFile.objects.filter(makerspace=makerspace).delete() - # Generic-subject payments must go before their MachineServiceRequest subjects. + # Manual settlements cascade with their payments (triggers are suspended for + # this transaction), so the cash book needs no separate pass. Payment.objects.filter(makerspace=makerspace).delete() ProcessedStripeEvent.objects.filter(makerspace=makerspace).delete() MachineServiceRequest.objects.filter(makerspace=makerspace).delete() diff --git a/backend/apps/machines/service_payments.py b/backend/apps/machines/service_payments.py index 98c8954d..f414f507 100644 --- a/backend/apps/machines/service_payments.py +++ b/backend/apps/machines/service_payments.py @@ -5,7 +5,7 @@ from django.db import transaction from apps.machines.models import MakerspaceMachineTypePricing -from apps.payments.availability import online_payments_enabled +from apps.payments.availability import charge_tracking_enabled, online_payments_enabled from apps.payments.models import MakerspacePaymentSettings, Payment from apps.payments.services import create_checkout, create_payment @@ -24,7 +24,7 @@ def create_for_completed_request(service_request, actor): try: with transaction.atomic(): machine_type = service_request.assigned_machine.machine_type - if not online_payments_enabled(service_request.makerspace, "machines"): + if not charge_tracking_enabled(service_request.makerspace, "machines"): return None pricing = MakerspaceMachineTypePricing.objects.filter( makerspace=service_request.makerspace, machine_type=machine_type, payment_enabled=True @@ -52,10 +52,11 @@ def create_for_completed_request(service_request, actor): ) except Exception: return None - try: - create_checkout(payment) - except Exception: - pass + if online_payments_enabled(service_request.makerspace, "machines"): + try: + create_checkout(payment) + except Exception: + pass return payment diff --git a/backend/apps/machines/service_workflow_actions.py b/backend/apps/machines/service_workflow_actions.py index e0c75bd9..f086b5d7 100644 --- a/backend/apps/machines/service_workflow_actions.py +++ b/backend/apps/machines/service_workflow_actions.py @@ -5,6 +5,7 @@ from apps.makerspaces import limits from apps.machines import role_scope +from apps.machines.certifications import require_certification_for_member from apps.machines.models import Machine, MachineServiceRequest, ServiceBucket, ServiceQueue, get_or_create_default_bucket from apps.machines.service_consumption import debit_consumptions from apps.machines.service_errors import ServiceConsumptionInvalid, ServiceInvalidTransition, ServiceMachineUnavailable @@ -29,7 +30,7 @@ ) -def submit(bucket_or_machine, requester, *, requester_name, contact_email, contact_phone, title, description="", source_link="", actor=None, member=None, capability_payload=None): +def submit(bucket_or_machine, requester, *, requester_name, contact_email, contact_phone, title, description="", source_link="", actor=None, member=None, capability_payload=None, certification_override_reason=""): """Create a pending legacy bucket request or unassigned pooled request.""" with transaction.atomic(): _assert_submission_write_allowed(bucket_or_machine) @@ -37,6 +38,13 @@ def submit(bucket_or_machine, requester, *, requester_name, contact_email, conta makerspace = target.makerspace machine_type = target.machine_type if isinstance(target, ServiceQueue) else target.machine_type _validate_capability_payload(machine_type, capability_payload or {}) + # Certification gating. Only for a member-attributed submission: an anonymous or + # staff-entered request is somebody asking staff to run the machine, which is not + # the member operating it, so there is nothing to have been trained for. + require_certification_for_member( + makerspace, member, machine_type, purpose="service", actor=actor, + override_reason=certification_override_reason, + ) _require_module(makerspace, locked=True) limits.check_quota(makerspace, "machine_service_open", adding=1) limits.check_quota(makerspace, "machine_service_submit", adding=1) diff --git a/backend/apps/machines/views_public.py b/backend/apps/machines/views_public.py index 7ac7717b..551a99ca 100644 --- a/backend/apps/machines/views_public.py +++ b/backend/apps/machines/views_public.py @@ -7,6 +7,8 @@ from rest_framework.generics import ListAPIView from rest_framework.permissions import AllowAny +from apps.inventory.search import apply_q +from apps.makerspaces.editions import require_public_surface from apps.apiclients.throttling import ClientTierRateThrottle from apps.machines.models import Machine from apps.machines.serializers_public_machines import PublicMachineSerializer @@ -27,6 +29,10 @@ location=OpenApiParameter.PATH, description='Public makerspace code or slug.', ), + OpenApiParameter( + name='q', type=str, location=OpenApiParameter.QUERY, required=False, + description='Full-text search over name, location, firmware version and notes; tolerates typos in the name.', + ), ], responses=PublicMachineSerializer(many=True), ) @@ -38,11 +44,12 @@ class PublicMachineListView(ListAPIView): def get_queryset(self): makerspace = get_public_makerspace(self.kwargs['makerspace_slug']) + require_public_surface('machines') if not makerspace.public_inventory_enabled or not module_enabled( makerspace, 'machines' ): raise Http404 - return ( + queryset = ( Machine.objects.select_related('machine_type') .filter( makerspace=makerspace, @@ -58,3 +65,5 @@ def get_queryset(self): ) .order_by('name', 'id') ) + query = self.request.query_params.get('q', '') + return apply_q(queryset, query) if query.strip() else queryset diff --git a/backend/apps/makerspaces/admin_memberships.py b/backend/apps/makerspaces/admin_memberships.py index 0ce35584..ef09367b 100644 --- a/backend/apps/makerspaces/admin_memberships.py +++ b/backend/apps/makerspaces/admin_memberships.py @@ -3,6 +3,7 @@ from apps.makerspaces.models import ( MakerspaceMembership, + MemberCard, MakerspaceWaiver, MemberProfile, MemberProject, @@ -80,3 +81,23 @@ def has_add_permission(self, request): def has_change_permission(self, request, obj=None): return request.method in ("GET", "HEAD") and super().has_change_permission(request, obj) + + +@admin.register(MemberCard) +class MemberCardAdmin(SuperuserOnlyModelAdmin, ModelAdmin): + """Read-only: issue/reissue/revoke are staff-console actions routed through the + member-card services, and the photo is a private object with no admin preview.""" + + list_display = ("makerspace", "card_number", "membership", "issued_at", "revoked_at", "print_count") + list_filter = ("makerspace", ("revoked_at", admin.EmptyFieldListFilter)) + readonly_fields = tuple(field.name for field in MemberCard._meta.fields) + + def has_add_permission(self, request): + return False + + def has_change_permission(self, request, obj=None): + return request.method in ("GET", "HEAD") and super().has_change_permission(request, obj) + + def has_delete_permission(self, request, obj=None): + return False + diff --git a/backend/apps/makerspaces/capabilities.py b/backend/apps/makerspaces/capabilities.py index f864c4ae..75f36710 100644 --- a/backend/apps/makerspaces/capabilities.py +++ b/backend/apps/makerspaces/capabilities.py @@ -49,6 +49,59 @@ class FeatureDefinition: "Charge membership dues via Stripe.", requires_modules=("payments",), ), + FeatureDefinition( + "payments.loans", "payments", "Loan deposits and late fees", + "Raise a deposit when a reviewed loan is issued and a capped late fee when it " + "comes back after its due date; both settle through the same payment rails.", + default_enabled=False, + ), + # --- Charge tracking (money owed) ------------------------------------------------- + # Deliberately NOT dependent on the `payments` module. Recording what a member owes + # and collecting it online are different capabilities: a space that takes cash still + # needs the debt on its books, visible in the member area, settleable by staff and + # counted in reports. The `payments.*` family above stays the ONLINE-RAIL capability + # set and keeps its `payments` module dependency; these govern whether a charge is + # recorded at all. + # + # Each keeps its real DOMAIN dependency -- there is no point tracking booking money + # in a space with no bookings -- but never a payments one. `charges.enabled` is the + # standalone master switch, and a space that charges for nothing simply turns it off. + # It is NOT expressed as `requires_features` on the domain keys: that would make the + # kill switch un-flippable until every domain was unticked first (the A6 rule). + FeatureDefinition( + "charges.enabled", None, "Track money owed", + "Record what members owe for bookings, events, machine jobs, dues and loans -- " + "whether or not online payment is configured. Off means the space charges for " + "nothing.", + default_enabled=True, + ), + FeatureDefinition( + "charges.bookings", "bookings", "Track booking charges", + "Record a charge when a paid booking is confirmed.", + default_enabled=True, + ), + FeatureDefinition( + "charges.events", "events", "Track event charges", + "Record a charge when a member registers for a paid event.", + default_enabled=True, + ), + FeatureDefinition( + "charges.machines", "machines", "Track machine job charges", + "Record a charge when a priced machine service request completes.", + default_enabled=True, + requires_modules=("machine_service",), + ), + FeatureDefinition( + "charges.membership", "membership", "Track membership charges", + "Record dues and renewal charges for members.", + default_enabled=True, + ), + FeatureDefinition( + "charges.loans", "request_workflow", "Track loan charges", + "Record loan deposits and late fees. Parented to the core request workflow, so " + "it never drags the payments module into lending.", + default_enabled=True, + ), FeatureDefinition( "inventory.self_checkout", None, "Self checkout", "Member self-checkout and staff direct handouts of QR tools.", @@ -94,6 +147,12 @@ class FeatureDefinition: # reactivated delegated access without anybody opting in again. requires_modules=("maintenance", "machines"), ), + FeatureDefinition( + "machines.certifications", "machines", "Certification gating", + "Members must hold an unexpired certification for a machine type before " + "booking it or requesting work on it.", + default_enabled=False, + ), FeatureDefinition( "events.offline_checkin", "events", "Offline & station check-in", "Store a minimal expiring roster on event devices and enable event-scoped " diff --git a/backend/apps/makerspaces/editions.py b/backend/apps/makerspaces/editions.py new file mode 100644 index 00000000..524ef107 --- /dev/null +++ b/backend/apps/makerspaces/editions.py @@ -0,0 +1,83 @@ +"""Deployment editions: what this box is FOR, one level above modules. + +The six core modules are the hardware loan spine and cannot be uninstalled (the Hard Rules +need a box scan and an issue photo to hand anything over), so an events-only or +bookings-only installation would otherwise still show a catalogue, a borrow flow and a +scanner it never uses. An edition **hides** a fixed set of module keys and makes their +public routes answer 404. It changes nothing else: `core_module_keys()`, `module_enabled()` +and every `require_module` gate are untouched, staff endpoints keep answering, migrations, +purge plans and backups are identical. That asymmetry is deliberate -- a hidden surface must +stay recoverable, and nothing recorded may become unreachable to staff. + +`SPACEWORKS_EDITION` is a deployment-level setting, never per makerspace, read the same way +`member_accounts`/`updates` are read deployment-wide. `organization` is a labelling edition: +one makerspace row presented as "the organization"; tenancy is never re-anchored. +""" +from dataclasses import dataclass + +from django.conf import settings +from django.core.exceptions import ImproperlyConfigured +from django.http import Http404 + +MAKERSPACE = "makerspace" +EVENTS = "events" +BOOKINGS = "bookings" +ORGANIZATION = "organization" + +# Everything that only makes sense when the box lends hardware or runs machines. +_LOAN_AND_MACHINE_KEYS = frozenset({ + "public_inventory", "request_workflow", "scanner", "asset_units", "containers", + "bulk_import", "stock_transfers", "qr_print_batches", "guest_handover", "procurement", + "stocktake", "machines", "machine_service", "printing", "maintenance", +}) + + +@dataclass(frozen=True) +class Edition: + key: str + label: str + hidden_module_keys: frozenset + # Which install profile setup should start from; `None` keeps the registry default. + default_profile: str | None = None + # The public home the frontend lands on for `/m/` (mirrors AppRoutes.tsx). + public_home: str = "catalogue" + organization_label: bool = False + + +EDITIONS = { + MAKERSPACE: Edition(MAKERSPACE, "Makerspace", frozenset()), + EVENTS: Edition( + EVENTS, "Events", _LOAN_AND_MACHINE_KEYS | {"bookings"}, + default_profile="events", public_home="events", + ), + BOOKINGS: Edition( + BOOKINGS, "Bookings", _LOAN_AND_MACHINE_KEYS | {"events"}, + default_profile="bookings", public_home="bookings", + ), + ORGANIZATION: Edition(ORGANIZATION, "Organization", frozenset(), organization_label=True), +} + + +def current_edition() -> Edition: + key = (getattr(settings, "SPACEWORKS_EDITION", MAKERSPACE) or MAKERSPACE).strip().lower() + try: + return EDITIONS[key] + except KeyError as exc: + raise ImproperlyConfigured( + f"SPACEWORKS_EDITION={key!r} is not an edition; choose one of {', '.join(sorted(EDITIONS))}." + ) from exc + + +def hidden_module_keys() -> frozenset: + return current_edition().hidden_module_keys + + +def public_surface_available(module_key: str) -> bool: + """False when the edition hides this module's PUBLIC surface (staff surfaces stay).""" + return module_key not in hidden_module_keys() + + +def require_public_surface(module_key: str) -> None: + """404, not 403: a hidden public route does not exist as far as a visitor can tell.""" + if not public_surface_available(module_key): + raise Http404 diff --git a/backend/apps/makerspaces/guards.py b/backend/apps/makerspaces/guards.py index ba40bc1e..099ae9ba 100644 --- a/backend/apps/makerspaces/guards.py +++ b/backend/apps/makerspaces/guards.py @@ -18,9 +18,15 @@ def require_module(makerspace_or_id, module_key): def require_module_for_servable(makerspace, module_key): - """Check capability after a canonical servable lookup without querying it twice.""" + """Check capability after a canonical servable lookup without querying it twice. + + Public surfaces only: an edition that hides this module answers 404 here. + """ + from apps.makerspaces.editions import require_public_surface + if not isinstance(makerspace, Makerspace): raise TypeError("A servability-checked Makerspace instance is required.") + require_public_surface(module_key) if ( module_key not in set(makerspace.enabled_modules or []) or not module_available(module_key) diff --git a/backend/apps/makerspaces/invitation_request_services.py b/backend/apps/makerspaces/invitation_request_services.py new file mode 100644 index 00000000..63520030 --- /dev/null +++ b/backend/apps/makerspaces/invitation_request_services.py @@ -0,0 +1,79 @@ +"""Invitation requests: public submission and the staff invite/decline decisions.""" +from django.db import transaction +from django.utils import timezone +from rest_framework.exceptions import ValidationError + +from apps.audit import services as audit +from apps.makerspaces import membership_services +from apps.makerspaces.models import InvitationRequest + + +def submit_invitation_request(makerspace, *, name, email, phone="", message=""): + with transaction.atomic(): + row = InvitationRequest.objects.create( + makerspace=makerspace, + name=name, + email=membership_services.normalized_email(email), + phone=phone, + message=message, + ) + # Anonymous submitter: the row is the target, the makerspace is the scope. + audit.record( + None, + "invitation_request.submitted", + makerspace=makerspace, + target=row, + meta={"invitation_request_id": row.pk}, + ) + return row + + +def _lock_pending(row): + locked = InvitationRequest.objects.select_for_update(of=("self",)).select_related( + "makerspace" + ).get(pk=row.pk) + if locked.status != InvitationRequest.Status.PENDING: + raise ValidationError({"detail": "This invitation request has already been handled."}) + return locked + + +def invite_from_request(actor, row, assigned_role): + """Issue the ordinary membership invitation, then mark the lead as invited.""" + with transaction.atomic(): + row = _lock_pending(row) + invitation = membership_services.invite_membership( + actor, row.makerspace, row.email, assigned_role + ) + row.status = InvitationRequest.Status.INVITED + row.handled_by = actor + row.handled_at = timezone.now() + row.save(update_fields=["status", "handled_by", "handled_at"]) + audit.record( + actor, + "invitation_request.invited", + makerspace=row.makerspace, + target=row, + meta={ + "invitation_request_id": row.pk, + "membership_request_id": invitation.pk, + "role_id": assigned_role.pk, + }, + ) + return row + + +def decline_request(actor, row): + with transaction.atomic(): + row = _lock_pending(row) + row.status = InvitationRequest.Status.DECLINED + row.handled_by = actor + row.handled_at = timezone.now() + row.save(update_fields=["status", "handled_by", "handled_at"]) + audit.record( + actor, + "invitation_request.declined", + makerspace=row.makerspace, + target=row, + meta={"invitation_request_id": row.pk}, + ) + return row diff --git a/backend/apps/makerspaces/lifecycle_purge.py b/backend/apps/makerspaces/lifecycle_purge.py index aa5d45ec..16019bc0 100644 --- a/backend/apps/makerspaces/lifecycle_purge.py +++ b/backend/apps/makerspaces/lifecycle_purge.py @@ -194,6 +194,8 @@ def _delete_object_graph(makerspace): ) AuditSigningKeyRotation.objects.filter(makerspace=makerspace).delete() AuditLog.objects.filter(makerspace=makerspace).delete() + # Manual settlements cascade with their payments (triggers are suspended for + # this transaction), so the cash book needs no separate pass. Payment.objects.filter(makerspace=makerspace).delete() ProcessedStripeEvent.objects.filter(makerspace=makerspace).delete() # An elsewhere-hosted registration can reference this space's waiver. Clear all diff --git a/backend/apps/makerspaces/limits_core.py b/backend/apps/makerspaces/limits_core.py index 312a7844..9a2b617b 100644 --- a/backend/apps/makerspaces/limits_core.py +++ b/backend/apps/makerspaces/limits_core.py @@ -17,6 +17,7 @@ "slack", "mattermost", "discord", + "webhook", "native_push", "api_clients", "custom_roles", @@ -43,6 +44,7 @@ "slack": "daily Slack notifications", "mattermost": "daily Mattermost notifications", "discord": "daily Discord notifications", + "webhook": "daily signed webhook deliveries", "native_push": "daily native push notifications", "api_clients": "API clients", "custom_roles": "custom roles", diff --git a/backend/apps/makerspaces/member_activity_serializers.py b/backend/apps/makerspaces/member_activity_serializers.py index a63ce7dc..a5bdaf27 100644 --- a/backend/apps/makerspaces/member_activity_serializers.py +++ b/backend/apps/makerspaces/member_activity_serializers.py @@ -63,6 +63,46 @@ class MemberAccountabilitySerializer(serializers.Serializer): restriction_code = serializers.CharField(allow_null=True) +class MemberLoanHistorySerializer(serializers.Serializer): + label = serializers.CharField() + checked_out_at = serializers.DateTimeField() + returned_at = serializers.DateTimeField(allow_null=True) + due_at = serializers.DateTimeField(allow_null=True) + returned_late = serializers.BooleanField() + + +class MemberRequestHistorySerializer(serializers.Serializer): + status = serializers.CharField() + created_at = serializers.DateTimeField() + item_count = serializers.IntegerField() + returned_quantity = serializers.IntegerField() + damaged_quantity = serializers.IntegerField() + missing_quantity = serializers.IntegerField() + + +class MemberDuesSerializer(serializers.Serializer): + dues_amount = serializers.CharField() + # Amounts are strings keyed by currency code, never one summed number: outstanding + # money cannot be added across currencies. Null when the ledger could not be read -- + # distinct from an empty map, which means nothing is outstanding. + outstanding_by_currency = serializers.DictField( + child=serializers.CharField(), allow_null=True + ) + + +class MemberNoticeSerializer(serializers.Serializer): + """A fact about the reader, derived from their own rows. + + Not `notifications.Notification`: that table is makerspace-wide, has no recipient + and shares one `read_at`, so serving it would leak staff alerts. + """ + + level = serializers.CharField() + event = serializers.CharField() + title = serializers.CharField() + body = serializers.CharField(allow_blank=True) + + class MemberActivitySerializer(serializers.Serializer): active_hardware_loans = MemberLoanActivitySerializer(many=True) print_requests = MemberPrintActivitySerializer(many=True, required=False) @@ -72,3 +112,7 @@ class MemberActivitySerializer(serializers.Serializer): recent_presence_sessions = MemberPresenceActivitySerializer(many=True) currently_checked_in = serializers.BooleanField() accountability = MemberAccountabilitySerializer() + loan_history = MemberLoanHistorySerializer(many=True, required=False) + request_history = MemberRequestHistorySerializer(many=True, required=False) + membership_dues = MemberDuesSerializer(required=False) + notices = MemberNoticeSerializer(many=True, required=False) diff --git a/backend/apps/makerspaces/member_activity_service.py b/backend/apps/makerspaces/member_activity_service.py index bdb5a391..e89e0af0 100644 --- a/backend/apps/makerspaces/member_activity_service.py +++ b/backend/apps/makerspaces/member_activity_service.py @@ -8,6 +8,7 @@ from apps.makerspaces.platform import module_enabled from apps.makerspaces.servability import servable_queryset from apps.makerspaces.waiver_state import current_acceptance +from apps.makerspaces import member_dashboard_service as dashboard from apps.presence.models import PresenceSession from apps.separability.registry import runtime_active @@ -71,6 +72,13 @@ def member_activity(membership): # "are the tables there?" when this asks "are the surfaces live?". if module_enabled(makerspace, "machine_service") and runtime_active("machines"): payload["machine_service_requests"] = _machine_service_requests(makerspace.id, member) + # The dashboard half (D8): history, money and notices. Only reachable through the + # `membership`-gated endpoint, so a space without memberships is unchanged. Notices + # are derived LAST because they read the sections above. + payload["loan_history"] = dashboard.loan_history(makerspace.id, member) + payload["request_history"] = dashboard.request_history(makerspace.id, member) + payload["membership_dues"] = dashboard.membership_dues(membership) + payload["notices"] = dashboard.notices(membership, payload) return payload diff --git a/backend/apps/makerspaces/member_card_admin_views.py b/backend/apps/makerspaces/member_card_admin_views.py new file mode 100644 index 00000000..06bc84b8 --- /dev/null +++ b/backend/apps/makerspaces/member_card_admin_views.py @@ -0,0 +1,193 @@ +"""Staff console: issue, reissue, revoke, print, resolve and template member cards. + +Gated by ACTIONS (`MANAGE_MEMBER_CARDS`, `SCAN_MEMBER_CARDS`), scoped through `rbac`. +""" +from django.http import Http404, HttpResponse +from django.shortcuts import get_object_or_404 +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema +from rest_framework import status +from rest_framework.exceptions import PermissionDenied +from rest_framework.pagination import PageNumberPagination +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from apps.accounts import rbac +from apps.audit import services as audit +from apps.makerspaces import member_card_printing, member_card_services, member_card_templates +from apps.makerspaces.guards import require_module +from apps.makerspaces.member_card_serializers import ( + MemberCardIssueSerializer, + MemberCardPrintSerializer, + MemberCardReissueSerializer, + MemberCardResolveResultSerializer, + MemberCardResolveSerializer, + MemberCardRevokeSerializer, + MemberCardSerializer, + MemberCardTemplateSerializer, +) +from apps.makerspaces.member_card_views import snapshot_for +from apps.makerspaces.models import Makerspace, MakerspaceMembership, MemberCard + +ERRORS = {403: OpenApiResponse(description="Missing member-card action."), 404: OpenApiResponse(description="Not found.")} +TAG = "Admin memberships" + + +def _makerspace_for(actor, action, makerspace_id): + makerspace = get_object_or_404( + rbac.scope_by_action(actor, action, Makerspace.objects.all(), field="id"), pk=makerspace_id + ) + require_module(makerspace, "membership") + return makerspace + + +def _card_for(actor, action, pk): + return get_object_or_404( + rbac.scope_by_action(actor, action, MemberCard.objects.select_related("makerspace", "membership__assigned_role")), + pk=pk, + ) + + +class _Pagination(PageNumberPagination): + page_size = 24 + + +class MemberCardListView(APIView): + permission_classes = [IsAuthenticated] + + @extend_schema(tags=[TAG], summary="List member cards", request=None, + parameters=[OpenApiParameter("status", str, OpenApiParameter.QUERY, description="active | revoked")], + responses={200: MemberCardSerializer(many=True), **ERRORS}) + def get(self, request, makerspace_id): + makerspace = _makerspace_for(request.user, rbac.Action.SCAN_MEMBER_CARDS, makerspace_id) + queryset = MemberCard.objects.filter(makerspace=makerspace).select_related("membership__user") + wanted = request.query_params.get("status") + if wanted == "active": + queryset = queryset.filter(revoked_at__isnull=True, membership__isnull=False) + elif wanted == "revoked": + queryset = queryset.filter(revoked_at__isnull=False) + paginator = _Pagination() + page = paginator.paginate_queryset(queryset.order_by("card_number"), request, view=self) + return paginator.get_paginated_response(MemberCardSerializer(page, many=True).data) + + +class MemberCardIssueView(APIView): + permission_classes = [IsAuthenticated] + + @extend_schema(tags=[TAG], summary="Issue a card to a membership", request=MemberCardIssueSerializer, + responses={201: MemberCardSerializer, **ERRORS}) + def post(self, request, makerspace_id, membership_id): + makerspace = _makerspace_for(request.user, rbac.Action.MANAGE_MEMBER_CARDS, makerspace_id) + membership = get_object_or_404(MakerspaceMembership, pk=membership_id, makerspace=makerspace) + serializer = MemberCardIssueSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + card = member_card_services.issue_card( + request.user, membership, printed_name=serializer.validated_data.get("printed_name", "") + ) + return Response(MemberCardSerializer(card).data, status=status.HTTP_201_CREATED) + + +class MemberCardReissueView(APIView): + permission_classes = [IsAuthenticated] + + @extend_schema(tags=[TAG], summary="Reissue (rotate the QR of) a card", request=MemberCardReissueSerializer, + responses={200: MemberCardSerializer, **ERRORS}) + def post(self, request, pk): + card = _card_for(request.user, rbac.Action.MANAGE_MEMBER_CARDS, pk) + serializer = MemberCardReissueSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + card = member_card_services.reissue_card(request.user, card, reason=serializer.validated_data["reason"]) + return Response(MemberCardSerializer(card).data) + + +class MemberCardRevokeView(APIView): + permission_classes = [IsAuthenticated] + + @extend_schema(tags=[TAG], summary="Revoke a card (redacts name and photo)", request=MemberCardRevokeSerializer, + responses={200: MemberCardSerializer, **ERRORS}) + def post(self, request, pk): + card = _card_for(request.user, rbac.Action.MANAGE_MEMBER_CARDS, pk) + serializer = MemberCardRevokeSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + card = member_card_services.revoke_card(request.user, card, reason=serializer.validated_data.get("reason") or "revoked") + return Response(MemberCardSerializer(card).data) + + +def _pdf_response(pdf, filename): + response = HttpResponse(pdf, content_type="application/pdf") + response["Content-Disposition"] = f'attachment; filename="{filename}"' + return response + + +class MemberCardPrintView(APIView): + permission_classes = [IsAuthenticated] + + @extend_schema(tags=[TAG], summary="Print one card (CR80 PDF)", request=None, + responses={(200, "application/pdf"): OpenApiTypes.BINARY, **ERRORS}) + def post(self, request, pk): + card = _card_for(request.user, rbac.Action.MANAGE_MEMBER_CARDS, pk) + if not card.is_active: + raise PermissionDenied("A revoked card cannot be printed.") + template = member_card_templates.normalize_template(dict(member_card_templates.template_for(card.makerspace), page="cr80")) + pdf = member_card_printing.render_cards_pdf(template, [snapshot_for(card)], title=f"Member card {card.card_number}") + member_card_services.record_print(request.user, [card]) + return _pdf_response(pdf, f"member-card-{card.card_number:05d}.pdf") + + +class MemberCardSheetView(APIView): + permission_classes = [IsAuthenticated] + + @extend_schema(tags=[TAG], summary="Print a sheet of cards", request=MemberCardPrintSerializer, + responses={(200, "application/pdf"): OpenApiTypes.BINARY, **ERRORS}) + def post(self, request, makerspace_id): + makerspace = _makerspace_for(request.user, rbac.Action.MANAGE_MEMBER_CARDS, makerspace_id) + serializer = MemberCardPrintSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + cards = MemberCard.objects.filter(makerspace=makerspace, revoked_at__isnull=True, membership__isnull=False).select_related("makerspace", "membership__assigned_role") + ids = serializer.validated_data.get("card_ids") + if ids: + cards = cards.filter(pk__in=ids) + cards = list(cards.order_by("card_number")[:200]) + template = member_card_templates.template_for(makerspace) + if serializer.validated_data.get("preset") == "single": + template = member_card_templates.normalize_template(dict(template, page="cr80")) + pdf = member_card_printing.render_cards_pdf(template, [snapshot_for(card) for card in cards], title="Member cards") + member_card_services.record_print(request.user, cards) + return _pdf_response(pdf, "member-cards.pdf") + + +class MemberCardResolveView(APIView): + permission_classes = [IsAuthenticated] + + @extend_schema(tags=[TAG], summary="Resolve a scanned member card to minimal identity", + request=MemberCardResolveSerializer, responses={200: MemberCardResolveResultSerializer, **ERRORS}) + def post(self, request, makerspace_id): + makerspace = _makerspace_for(request.user, rbac.Action.SCAN_MEMBER_CARDS, makerspace_id) + serializer = MemberCardResolveSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + try: + result = member_card_services.resolve(request.user, makerspace, serializer.validated_data["payload"]) + except Http404: + raise + return Response(result) + + +class MemberCardTemplateView(APIView): + permission_classes = [IsAuthenticated] + + @extend_schema(tags=[TAG], summary="Read the card layout template", request=None, + responses={200: MemberCardTemplateSerializer, **ERRORS}) + def get(self, request, makerspace_id): + makerspace = _makerspace_for(request.user, rbac.Action.SCAN_MEMBER_CARDS, makerspace_id) + return Response(member_card_templates.template_for(makerspace)) + + @extend_schema(tags=[TAG], summary="Replace the card layout template", request=MemberCardTemplateSerializer, + responses={200: MemberCardTemplateSerializer, **ERRORS}) + def put(self, request, makerspace_id): + makerspace = _makerspace_for(request.user, rbac.Action.MANAGE_MEMBER_CARDS, makerspace_id) + serializer = MemberCardTemplateSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + template = member_card_templates.save_template(makerspace, serializer.validated_data) + audit.record(request.user, "member_card.template_updated", makerspace=makerspace, target=makerspace, meta={"version": template["version"]}) + return Response(template) diff --git a/backend/apps/makerspaces/member_card_printing.py b/backend/apps/makerspaces/member_card_printing.py new file mode 100644 index 00000000..2baa7beb --- /dev/null +++ b/backend/apps/makerspaces/member_card_printing.py @@ -0,0 +1,151 @@ +"""Render member cards to PDF: one CR80 card or a sheet of them. + +Nothing rendered is written to object storage — a PDF of names and faces would be a second +PII retention surface — so the response streams the bytes and the audit trail records the +print. Same reportlab/segno stack as the event badges. +""" +from dataclasses import dataclass +from io import BytesIO +from pathlib import Path + +import segno + +from apps.makerspaces.member_card_templates import page_layout + + +@dataclass(frozen=True) +class CardSnapshot: + card_number: int + printed_name: str + makerspace_name: str + issued_at: str + membership_role: str + qr_payload: str | None + photo: bytes | None + watermark: str = "" + certifications: tuple = () + + +def _register_fonts(): + import reportlab + from reportlab.pdfbase import pdfmetrics + from reportlab.pdfbase.ttfonts import TTFont + + fonts = Path(reportlab.__file__).parent / "fonts" + if "CardVera" not in pdfmetrics.getRegisteredFontNames(): + pdfmetrics.registerFont(TTFont("CardVera", fonts / "Vera.ttf")) + pdfmetrics.registerFont(TTFont("CardVeraBold", fonts / "VeraBd.ttf")) + + +def _fit(text, font, size, width): + from reportlab.pdfbase import pdfmetrics + + value = " ".join((text or "").split()) + if pdfmetrics.stringWidth(value, font, size) <= width: + return value + while value and pdfmetrics.stringWidth(value + "...", font, size) > width: + value = value[:-1] + return value.rstrip() + "..." + + +def _qr_reader(payload): + from reportlab.lib.utils import ImageReader + + stream = BytesIO() + segno.make(payload, error="M").save(stream, kind="png", scale=6, border=1) + stream.seek(0) + return ImageReader(stream) + + +def _field_value(snapshot, key): + return { + "printed_name": snapshot.printed_name or "-", + "card_number": f"No. {snapshot.card_number:05d}", + "makerspace": snapshot.makerspace_name, + "issued_at": snapshot.issued_at, + "membership_role": snapshot.membership_role, + "certifications": ", ".join(snapshot.certifications) or "-", + }[key] + + +def _draw_card(canvas, snapshot, template, x, y, width, height): + from reportlab.lib.colors import HexColor + from reportlab.lib.units import mm + from reportlab.lib.utils import ImageReader + + pad = 4 * mm + canvas.setStrokeColor(HexColor("#CBD5E1")) + canvas.setFillColor(HexColor("#FFFFFF")) + canvas.roundRect(x, y, width, height, 3 * mm, stroke=1, fill=1) + cursor_x = x + pad + photo_w = 0 + if template["include_photo"]: + photo_w = min(22 * mm, height - 2 * pad) + if snapshot.photo: + canvas.drawImage(ImageReader(BytesIO(snapshot.photo)), cursor_x, y + height - pad - photo_w, width=photo_w, height=photo_w, preserveAspectRatio=True, mask="auto") + else: + canvas.setFillColor(HexColor("#E2E8F0")) + canvas.rect(cursor_x, y + height - pad - photo_w, photo_w, photo_w, stroke=0, fill=1) + cursor_x += photo_w + 3 * mm + qr_w = min(20 * mm, height - 2 * pad) if template["include_qr"] and snapshot.qr_payload else 0 + text_w = width - (cursor_x - x) - pad - (qr_w + 3 * mm if qr_w else 0) + cursor_y = y + height - pad + for index, key in enumerate(template["front_fields"]): + font = "CardVeraBold" if index == 0 else "CardVera" + size = template["name_font_size_pt"] if index == 0 else template["font_size_pt"] + cursor_y -= size + 2 + if cursor_y < y + pad: + break + canvas.setFillColor(HexColor("#0F172A" if index == 0 else "#475569")) + canvas.setFont(font, size) + canvas.drawString(cursor_x, cursor_y, _fit(_field_value(snapshot, key), font, size, text_w)) + if qr_w: + canvas.drawImage(_qr_reader(snapshot.qr_payload), x + width - pad - qr_w, y + pad, width=qr_w, height=qr_w, mask="auto") + if snapshot.watermark: + canvas.saveState() + canvas.setFillColor(HexColor("#94A3B8")) + canvas.setFont("CardVeraBold", 10) + canvas.drawCentredString(x + width / 2, y + 2 * mm, snapshot.watermark) + canvas.restoreState() + + +def _crop_marks(canvas, x, y, width, height): + from reportlab.lib.colors import HexColor + from reportlab.lib.units import mm + + canvas.setStrokeColor(HexColor("#94A3B8")) + canvas.setLineWidth(0.3) + for cx, cy, dx, dy in ((x, y, -1, -1), (x + width, y, 1, -1), (x, y + height, -1, 1), (x + width, y + height, 1, 1)): + canvas.line(cx + dx * 1 * mm, cy, cx + dx * 4 * mm, cy) + canvas.line(cx, cy + dy * 1 * mm, cx, cy + dy * 4 * mm) + + +def render_cards_pdf(template, snapshots, *, title): + from reportlab.lib.units import mm + from reportlab.pdfgen.canvas import Canvas + + _register_fonts() + page_w_mm, page_h_mm, columns, rows = page_layout(template) + page = (page_w_mm * mm, page_h_mm * mm) + card_w, card_h = template["card_width_mm"] * mm, template["card_height_mm"] * mm + margin, gap = template["margin_mm"] * mm, template["gap_mm"] * mm + output = BytesIO() + canvas = Canvas(output, pagesize=page, pageCompression=1, invariant=1) + canvas.setTitle(title) + per_page = columns * rows + for index, snapshot in enumerate(snapshots): + slot = index % per_page + if index and slot == 0: + canvas.showPage() + column, row = slot % columns, slot // columns + x = margin + column * (card_w + gap) + y = page[1] - margin - (row + 1) * card_h - row * gap + _draw_card(canvas, snapshot, template, x, y, card_w, card_h) + if template["crop_marks"] and template["page"] != "cr80": + _crop_marks(canvas, x, y, card_w, card_h) + if not snapshots: + canvas.setFont("CardVera", 10) + canvas.drawString(20 * mm, page[1] - 20 * mm, "No cards selected.") + canvas.showPage() + canvas.save() + return output.getvalue() diff --git a/backend/apps/makerspaces/member_card_serializers.py b/backend/apps/makerspaces/member_card_serializers.py new file mode 100644 index 00000000..8661ed7c --- /dev/null +++ b/backend/apps/makerspaces/member_card_serializers.py @@ -0,0 +1,90 @@ +from rest_framework import serializers + +from apps.makerspaces.member_card_services import REISSUE_REASONS +from apps.makerspaces.models import MemberCard + + +class MemberCardSerializer(serializers.ModelSerializer): + """Never carries the photo key or a URL that outlives the response.""" + + membership_id = serializers.IntegerField(read_only=True, allow_null=True) + is_active = serializers.BooleanField(read_only=True) + photo_set = serializers.SerializerMethodField() + qr_active = serializers.SerializerMethodField() + + class Meta: + model = MemberCard + fields = ( + "id", "card_number", "printed_name", "membership_id", "is_active", "photo_set", + "photo_consent_at", "qr_active", "print_count", "last_printed_at", "issued_at", + "revoked_at", "revoked_reason", "created_at", "updated_at", + ) + read_only_fields = fields + + def get_photo_set(self, obj) -> bool: + return bool(obj.photo_object_key) + + def get_qr_active(self, obj) -> bool: + return getattr(obj, "_qr_active", None) is not False and obj.is_active + + +class MemberCardIssueSerializer(serializers.Serializer): + printed_name = serializers.CharField(max_length=200, required=False, allow_blank=True) + + +class MemberCardReissueSerializer(serializers.Serializer): + reason = serializers.ChoiceField(choices=[(r, r) for r in REISSUE_REASONS]) + + +class MemberCardRevokeSerializer(serializers.Serializer): + reason = serializers.CharField(max_length=32, required=False, allow_blank=True) + + +class MemberCardOwnUpdateSerializer(serializers.Serializer): + printed_name = serializers.CharField(max_length=200, allow_blank=True) + + +class MemberCardPhotoPresignSerializer(serializers.Serializer): + content_type = serializers.CharField(max_length=64) + + +class MemberCardPhotoFinalizeSerializer(serializers.Serializer): + object_key = serializers.CharField(max_length=300) + content_type = serializers.CharField(max_length=64) + consent = serializers.BooleanField() + + +class MemberCardPrintSerializer(serializers.Serializer): + card_ids = serializers.ListField(child=serializers.IntegerField(), required=False, allow_empty=True) + preset = serializers.ChoiceField(choices=[("sheet", "sheet"), ("single", "single")], required=False, default="sheet") + + +class MemberCardResolveSerializer(serializers.Serializer): + payload = serializers.CharField(max_length=64) + + +class MemberCardResolveResultSerializer(serializers.Serializer): + outcome = serializers.ChoiceField(choices=[("ok", "ok"), ("revoked", "revoked"), ("inactive", "inactive")]) + card_id = serializers.IntegerField(required=False) + card_number = serializers.IntegerField(required=False) + printed_name = serializers.CharField(required=False, allow_blank=True) + membership_id = serializers.IntegerField(required=False) + membership_status = serializers.CharField(required=False) + photo_url = serializers.CharField(required=False, allow_null=True) + + +class MemberCardTemplateSerializer(serializers.Serializer): + version = serializers.IntegerField(required=False) + page = serializers.CharField(required=False) + orientation = serializers.CharField(required=False) + card_width_mm = serializers.FloatField(required=False) + card_height_mm = serializers.FloatField(required=False) + margin_mm = serializers.FloatField(required=False) + gap_mm = serializers.FloatField(required=False) + front_fields = serializers.ListField(child=serializers.CharField(), required=False) + back_text = serializers.CharField(required=False, allow_blank=True) + include_photo = serializers.BooleanField(required=False) + include_qr = serializers.BooleanField(required=False) + name_font_size_pt = serializers.IntegerField(required=False) + font_size_pt = serializers.IntegerField(required=False) + crop_marks = serializers.BooleanField(required=False) diff --git a/backend/apps/makerspaces/member_card_services.py b/backend/apps/makerspaces/member_card_services.py new file mode 100644 index 00000000..19bde6d5 --- /dev/null +++ b/backend/apps/makerspaces/member_card_services.py @@ -0,0 +1,171 @@ +"""Issue, reissue, revoke, redact and resolve member ID cards. + +Every mutation is audited and every lookup writes an immutable `QrScanEvent` with the +`member_lookup` context. Authority is by ACTION (`MANAGE_MEMBER_CARDS` / `SCAN_MEMBER_CARDS`), +never by role name, and a card never resolves through the generic inventory scanner. +""" +from django.core.exceptions import PermissionDenied +from django.db import transaction +from django.db.models import Max +from django.http import Http404 +from django.utils import timezone + +from apps.accounts import rbac +from apps.audit import services as audit +from apps.boxes.models import QrCode, QrScanEvent +from apps.boxes.services import revoke_qr_code +from apps.makerspaces import member_card_storage +from apps.makerspaces.guards import require_module_locked +from apps.makerspaces.models import MakerspaceMembership, MemberCard + +REISSUE_REASONS = ("lost", "stolen", "damaged", "renewed") + + +def _require(actor, action, makerspace_id): + if not rbac.can(actor, action, makerspace_id): + raise PermissionDenied() + + +def active_qr(card): + return QrCode.objects.filter( + makerspace_id=card.makerspace_id, + target_type=QrCode.TargetType.MEMBER_CARD, + target_id=card.pk, + status=QrCode.Status.ACTIVE, + ).first() + + +def _next_card_number(makerspace_id): + current = MemberCard.objects.filter(makerspace_id=makerspace_id).aggregate( + top=Max("card_number") + )["top"] + return (current or 0) + 1 + + +@transaction.atomic +def issue_card(actor, membership, *, printed_name=""): + _require(actor, rbac.Action.MANAGE_MEMBER_CARDS, membership.makerspace_id) + require_module_locked(membership.makerspace_id, "membership") + membership = MakerspaceMembership.objects.select_for_update().get(pk=membership.pk) + if membership.status != "active": + raise PermissionDenied("Only an active membership can hold a card.") + if MemberCard.objects.filter(membership=membership).exists(): + raise PermissionDenied("This membership already holds a card; reissue it instead.") + card = MemberCard.objects.create( + makerspace_id=membership.makerspace_id, + membership=membership, + card_number=_next_card_number(membership.makerspace_id), + printed_name=printed_name or "", + ) + QrCode.objects.create( + makerspace_id=card.makerspace_id, + target_type=QrCode.TargetType.MEMBER_CARD, + target_id=card.pk, + created_by=actor, + ) + audit.record(actor, "member_card.created", makerspace=membership.makerspace, target=card, meta={"card_id": card.pk, "card_number": card.card_number}) + audit.record(actor, "member_card.issued", makerspace=membership.makerspace, target=card, meta={"card_id": card.pk, "card_number": card.card_number}) + return card + + +@transaction.atomic +def reissue_card(actor, card, *, reason): + """Rotate the QR: the old payload keeps resolving as REVOKED and stays in scan history.""" + _require(actor, rbac.Action.MANAGE_MEMBER_CARDS, card.makerspace_id) + if reason not in REISSUE_REASONS: + raise PermissionDenied("Unknown reissue reason.") + card = MemberCard.objects.select_for_update().get(pk=card.pk) + if not card.is_active: + raise PermissionDenied("A revoked card cannot be reissued; issue a new one.") + old = active_qr(card) + if old is not None: + revoke_qr_code(actor, old) + QrCode.objects.create( + makerspace_id=card.makerspace_id, + target_type=QrCode.TargetType.MEMBER_CARD, + target_id=card.pk, + created_by=actor, + ) + card.template_version_at_issue += 1 + card.issued_at = timezone.now() + card.save(update_fields=["template_version_at_issue", "issued_at", "updated_at"]) + audit.record(actor, "member_card.reissued", makerspace=card.makerspace, target=card, meta={"card_id": card.pk, "card_number": card.card_number, "reason": reason}) + return card + + +def redact(card): + """Delete the face photo and blank the name. Idempotent; used on revoke and purge.""" + if card.photo_object_key: + member_card_storage.delete_photo(card) + card.printed_name = "" + card.photo_object_key = "" + card.photo_content_type = "" + card.photo_size_bytes = None + card.photo_consent_at = None + card.photo_consent_version = "" + card.save( + update_fields=[ + "printed_name", "photo_object_key", "photo_content_type", "photo_size_bytes", + "photo_consent_at", "photo_consent_version", "updated_at", + ] + ) + + +@transaction.atomic +def revoke_card(actor, card, *, reason="revoked"): + _require(actor, rbac.Action.MANAGE_MEMBER_CARDS, card.makerspace_id) + card = MemberCard.objects.select_for_update().get(pk=card.pk) + if card.revoked_at is not None: + return card + qr = active_qr(card) + if qr is not None: + revoke_qr_code(actor, qr) + card.revoked_at = timezone.now() + card.revoked_reason = reason[:32] + card.save(update_fields=["revoked_at", "revoked_reason", "updated_at"]) + redact(card) + audit.record(actor, "member_card.revoked", makerspace=card.makerspace, target=card, meta={"card_id": card.pk, "card_number": card.card_number, "reason": reason}) + return card + + +def record_print(actor, cards): + now = timezone.now() + for card in cards: + card.print_count += 1 + card.last_printed_at = now + card.save(update_fields=["print_count", "last_printed_at", "updated_at"]) + audit.record(actor, "member_card.printed", makerspace=card.makerspace, target=card, meta={"card_id": card.pk, "card_number": card.card_number, "count": card.print_count}) + + +@transaction.atomic +def resolve(actor, makerspace, payload): + """Identify a member from a scanned card. Records the lookup even when it is refused.""" + _require(actor, rbac.Action.SCAN_MEMBER_CARDS, makerspace.pk) + qr = QrCode.objects.filter( + payload=payload, makerspace=makerspace, target_type=QrCode.TargetType.MEMBER_CARD + ).first() + if qr is None: + # Uniform refusal: a payload from another tenant, a box QR, or nothing at all + # all look the same to the scanner. + raise Http404 + QrScanEvent.objects.create( + makerspace=makerspace, qr_code=qr, actor=actor, context=QrScanEvent.Context.MEMBER_LOOKUP + ) + card = MemberCard.objects.select_related("membership__user").filter(pk=qr.target_id, makerspace=makerspace).first() + outcome = "ok" + if qr.status != QrCode.Status.ACTIVE: + outcome = "revoked" + elif card is None or not card.is_active or card.membership.status != "active": + outcome = "inactive" + audit.record(actor, "member_card.scanned", makerspace=makerspace, target=card or qr, meta={"outcome": outcome, "qr_id": qr.pk}) + if outcome != "ok": + return {"outcome": outcome} + return { + "outcome": "ok", + "card_id": card.pk, + "card_number": card.card_number, + "printed_name": card.printed_name, + "membership_id": card.membership_id, + "membership_status": card.membership.status, + "photo_url": member_card_storage.photo_url(card), + } diff --git a/backend/apps/makerspaces/member_card_storage.py b/backend/apps/makerspaces/member_card_storage.py new file mode 100644 index 00000000..ad04e2bb --- /dev/null +++ b/backend/apps/makerspaces/member_card_storage.py @@ -0,0 +1,107 @@ +"""Card photos: staging-only presigns, single promotion to an unguessable final key, private reads. + +Reuses the evidence bucket helpers: the same private bucket, the same staging prefix, the +same MIME and size bounds (`EVIDENCE_ALLOWED_MIME`, `EVIDENCE_MAX_BYTES`). The final key is +never client-writable: the browser only ever receives a presign for `staging/`, and +`finalize_photo` copies it into place, deletes the staging object and saves the row. +""" +import uuid + +from django.conf import settings +from django.db import transaction +from django.utils import timezone +from rest_framework.exceptions import ValidationError + +from apps.audit import services as audit +from apps.evidence import storage +from apps.makerspaces.limits_usage import add_storage, free_storage + +PHOTO_CONSENT_VERSION = "2026-09" + + +def _final_key(makerspace_id): + return f"member-cards/{makerspace_id}/{uuid.uuid4().hex}" + + +def presign_photo(card, content_type): + if content_type not in settings.EVIDENCE_ALLOWED_MIME: + raise ValidationError({"content_type": "Unsupported image type."}) + final_key = _final_key(card.makerspace_id) + upload = storage.presigned_upload(final_key, content_type) + return {"object_key": final_key, "content_type": content_type, **upload} + + +def _validate_key(card, object_key): + prefix = f"member-cards/{card.makerspace_id}/" + if not object_key.startswith(prefix) or "/" in object_key[len(prefix):]: + # The prefix test stops one makerspace attaching another's object. + raise ValidationError({"object_key": "Unknown upload."}) + + +@transaction.atomic +def finalize_photo(actor, card, *, object_key, content_type, consent): + _validate_key(card, object_key) + if not consent: + raise ValidationError({"consent": "A photo needs the member's consent to be stored."}) + staging = storage.staging_key(object_key) + size = storage.object_size(staging) + if not size: + raise ValidationError({"object_key": "Upload not found; upload the photo first."}) + if size > settings.EVIDENCE_MAX_BYTES: + storage.delete_object(staging) + raise ValidationError({"object_key": "Photo exceeds the size limit."}) + storage.validate_evidence_object(staging) + storage.copy_object(staging, object_key) + storage.delete_object(staging) + card = type(card).objects.select_for_update().get(pk=card.pk) + old_key, old_size = card.photo_object_key, card.photo_size_bytes or 0 + add_storage(card.makerspace, size) + card.photo_object_key = object_key + card.photo_content_type = content_type + card.photo_size_bytes = size + card.photo_consent_at = timezone.now() + card.photo_consent_version = PHOTO_CONSENT_VERSION + card.save( + update_fields=[ + "photo_object_key", "photo_content_type", "photo_size_bytes", + "photo_consent_at", "photo_consent_version", "updated_at", + ] + ) + if old_key: + transaction.on_commit(lambda: _release(card.makerspace, old_key, old_size)) + audit.record(actor, "member_card.photo_updated", makerspace=card.makerspace, target=card, meta={"card_id": card.pk}) + return card + + +def _release(makerspace, object_key, size): + storage.delete_object(object_key) + if size: + free_storage(makerspace, size) + + +def delete_photo(card): + """Delete the bytes and free the quota; the caller blanks the row.""" + if card.photo_object_key: + _release(card.makerspace, card.photo_object_key, card.photo_size_bytes or 0) + + +def photo_url(card): + if not card.photo_object_key: + return None + try: + return storage.presigned_get_url(card.photo_object_key) + except Exception: + return None + + +def photo_bytes(card): + """Server-side read for PDF rendering; never exposed as a URL.""" + if not card.photo_object_key: + return None + try: + response = storage._client().get_object( + Bucket=settings.AWS_STORAGE_BUCKET_NAME, Key=card.photo_object_key + ) + return response["Body"].read(settings.EVIDENCE_MAX_BYTES) + except Exception: + return None diff --git a/backend/apps/makerspaces/member_card_templates.py b/backend/apps/makerspaces/member_card_templates.py new file mode 100644 index 00000000..7edf5317 --- /dev/null +++ b/backend/apps/makerspaces/member_card_templates.py @@ -0,0 +1,96 @@ +"""Card layout templates: selectors and dimensions only, never member values. + +Stored per makerspace as JSON (`Makerspace.member_card_template`-less by design: the template +lives in `branding_config["member_card_template"]` so no schema change is needed and it +travels with the branding it belongs to). Presets are the two things a card printer wants: +one CR80 card, or an A4/Letter sheet of ten. +""" +from math import floor + +from rest_framework.exceptions import ValidationError + +CR80_WIDTH_MM = 85.6 +CR80_HEIGHT_MM = 54.0 +PAGE_SIZES_MM = {"cr80": (CR80_WIDTH_MM, CR80_HEIGHT_MM), "a4": (210.0, 297.0), "letter": (215.9, 279.4)} +FIELD_CHOICES = ("printed_name", "card_number", "makerspace", "issued_at", "membership_role", "certifications") +DEFAULT_TEMPLATE = { + "version": 1, + "page": "a4", + "orientation": "portrait", + "card_width_mm": CR80_WIDTH_MM, + "card_height_mm": CR80_HEIGHT_MM, + "margin_mm": 10.0, + "gap_mm": 5.0, + "front_fields": ["printed_name", "card_number", "makerspace"], + "back_text": "", + "include_photo": True, + "include_qr": True, + "name_font_size_pt": 12, + "font_size_pt": 8, + "crop_marks": True, +} +BRANDING_KEY = "member_card_template" + + +def normalize_template(value): + template = dict(DEFAULT_TEMPLATE) + template.update({k: v for k, v in (value or {}).items() if k in DEFAULT_TEMPLATE}) + errors = {} + if template["page"] not in PAGE_SIZES_MM: + errors["page"] = f"Choose one of {', '.join(PAGE_SIZES_MM)}." + if template["orientation"] not in ("portrait", "landscape"): + errors["orientation"] = "portrait or landscape." + for key in ("card_width_mm", "card_height_mm", "margin_mm", "gap_mm"): + try: + template[key] = float(template[key]) + except (TypeError, ValueError): + errors[key] = "A number of millimetres." + continue + if template[key] < 0 or template[key] > 400: + errors[key] = "Between 0 and 400 mm." + for key in ("name_font_size_pt", "font_size_pt"): + try: + template[key] = int(template[key]) + except (TypeError, ValueError): + errors[key] = "A whole number of points." + continue + if not 5 <= template[key] <= 40: + errors[key] = "Between 5 and 40 pt." + fields = template["front_fields"] + if not isinstance(fields, list) or not fields or any(f not in FIELD_CHOICES for f in fields): + errors["front_fields"] = f"A non-empty list drawn from {', '.join(FIELD_CHOICES)}." + if not isinstance(template["back_text"], str) or len(template["back_text"]) > 600: + errors["back_text"] = "Up to 600 characters." + template["include_photo"] = bool(template["include_photo"]) + template["include_qr"] = bool(template["include_qr"]) + template["crop_marks"] = bool(template["crop_marks"]) + if errors: + raise ValidationError(errors) + if template["page"] == "cr80": + template["margin_mm"], template["gap_mm"] = 0.0, 0.0 + template["card_width_mm"], template["card_height_mm"] = CR80_WIDTH_MM, CR80_HEIGHT_MM + return template + + +def page_layout(template): + width, height = PAGE_SIZES_MM[template["page"]] + if template["orientation"] == "landscape" and template["page"] != "cr80": + width, height = height, width + if template["page"] == "cr80": + return width, height, 1, 1 + columns = max(1, floor((width - 2 * template["margin_mm"] + template["gap_mm"]) / (template["card_width_mm"] + template["gap_mm"]))) + rows = max(1, floor((height - 2 * template["margin_mm"] + template["gap_mm"]) / (template["card_height_mm"] + template["gap_mm"]))) + return width, height, columns, rows + + +def template_for(makerspace): + return normalize_template((makerspace.branding_config or {}).get(BRANDING_KEY)) + + +def save_template(makerspace, value): + template = normalize_template(value) + branding = dict(makerspace.branding_config or {}) + branding[BRANDING_KEY] = template + makerspace.branding_config = branding + makerspace.save(update_fields=["branding_config", "updated_at"]) + return template diff --git a/backend/apps/makerspaces/member_card_views.py b/backend/apps/makerspaces/member_card_views.py new file mode 100644 index 00000000..970b6cd4 --- /dev/null +++ b/backend/apps/makerspaces/member_card_views.py @@ -0,0 +1,143 @@ +"""Member-facing card endpoints: the caller's OWN card only.""" +from django.http import Http404, HttpResponse +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiResponse, extend_schema +from rest_framework import status +from rest_framework.response import Response + +from apps.audit import services as audit +from apps.evidence.responses import storage_unavailable_response +from apps.evidence.storage import StorageUnavailable +from apps.makerspaces import member_card_printing, member_card_storage, member_card_templates +from apps.makerspaces.member_card_serializers import ( + MemberCardOwnUpdateSerializer, + MemberCardPhotoFinalizeSerializer, + MemberCardPhotoPresignSerializer, + MemberCardSerializer, +) +from apps.makerspaces.member_card_services import active_qr +from apps.makerspaces.models import MemberCard +from apps.makerspaces.profile_views import MemberProfileBaseView +from apps.makerspaces.throttles import MemberImagePresignThrottle + +ERRORS = { + 403: OpenApiResponse(description="An active membership is required."), + 404: OpenApiResponse(description="No card has been issued to this membership."), +} + + +def _certification_names(membership): + from apps.machines.certifications import live_certification_names + + return live_certification_names(membership) + + +def snapshot_for(card, *, watermark=""): + membership = card.membership + role = membership.assigned_role.name if membership and membership.assigned_role_id else (membership.role if membership else "") + qr = active_qr(card) + return member_card_printing.CardSnapshot( + card_number=card.card_number, + printed_name=card.printed_name, + makerspace_name=(card.makerspace.branding_config or {}).get("display_name") or card.makerspace.name, + issued_at=card.issued_at.date().isoformat(), + membership_role=str(role or ""), + qr_payload=qr.payload if qr else None, + photo=member_card_storage.photo_bytes(card), + watermark=watermark, + certifications=_certification_names(membership), + ) + + +class OwnMemberCardMixin(MemberProfileBaseView): + def card(self, request, makerspace_id): + membership = self.membership(request, makerspace_id) + card = MemberCard.objects.select_related("makerspace", "membership__assigned_role").filter( + membership=membership, revoked_at__isnull=True + ).first() + if card is None: + raise Http404 + return card + + +class MemberCardOwnView(OwnMemberCardMixin): + @extend_schema(tags=["Member profile"], summary="Read my member card", request=None, + responses={200: MemberCardSerializer, **ERRORS}) + def get(self, request, makerspace_id): + return Response(MemberCardSerializer(self.card(request, makerspace_id)).data) + + @extend_schema(tags=["Member profile"], summary="Set the name printed on my card", + request=MemberCardOwnUpdateSerializer, responses={200: MemberCardSerializer, **ERRORS}) + def patch(self, request, makerspace_id): + card = self.card(request, makerspace_id) + serializer = MemberCardOwnUpdateSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + card.printed_name = serializer.validated_data["printed_name"] + card.save(update_fields=["printed_name", "updated_at"]) + audit.record(request.user, "member_card.name_updated", makerspace=card.makerspace, target=card, meta={"card_id": card.pk}) + return Response(MemberCardSerializer(card).data) + + +class MemberCardPhotoView(OwnMemberCardMixin): + throttle_classes = [MemberImagePresignThrottle] + + @extend_schema(tags=["Member profile"], summary="Create a card photo upload URL (staging only)", + request=MemberCardPhotoPresignSerializer, responses={201: OpenApiTypes.OBJECT, **ERRORS, + 503: OpenApiResponse(description="Storage unavailable.")}) + def post(self, request, makerspace_id): + card = self.card(request, makerspace_id) + serializer = MemberCardPhotoPresignSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + try: + payload = member_card_storage.presign_photo(card, serializer.validated_data["content_type"]) + except StorageUnavailable: + return storage_unavailable_response() + return Response(payload, status=status.HTTP_201_CREATED) + + @extend_schema(tags=["Member profile"], summary="Attach an uploaded card photo (requires consent)", + request=MemberCardPhotoFinalizeSerializer, responses={200: MemberCardSerializer, **ERRORS}) + def put(self, request, makerspace_id): + card = self.card(request, makerspace_id) + serializer = MemberCardPhotoFinalizeSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + data = serializer.validated_data + try: + card = member_card_storage.finalize_photo( + request.user, card, object_key=data["object_key"], + content_type=data["content_type"], consent=data["consent"], + ) + except StorageUnavailable: + return storage_unavailable_response() + return Response(MemberCardSerializer(card).data) + + @extend_schema(tags=["Member profile"], summary="Remove my card photo", request=None, + responses={200: MemberCardSerializer, **ERRORS}) + def delete(self, request, makerspace_id): + card = self.card(request, makerspace_id) + if card.photo_object_key: + member_card_storage.delete_photo(card) + card.photo_object_key = "" + card.photo_content_type = "" + card.photo_size_bytes = None + card.photo_consent_at = None + card.photo_consent_version = "" + card.save(update_fields=["photo_object_key", "photo_content_type", "photo_size_bytes", + "photo_consent_at", "photo_consent_version", "updated_at"]) + audit.record(request.user, "member_card.photo_removed", makerspace=card.makerspace, target=card, meta={"card_id": card.pk}) + return Response(MemberCardSerializer(card).data) + + +class MemberCardPreviewView(OwnMemberCardMixin): + @extend_schema(tags=["Member profile"], summary="Preview my card as a watermarked PDF", request=None, + responses={(200, "application/pdf"): OpenApiTypes.BINARY, **ERRORS}) + def get(self, request, makerspace_id): + card = self.card(request, makerspace_id) + template = dict(member_card_templates.template_for(card.makerspace), page="cr80") + pdf = member_card_printing.render_cards_pdf( + member_card_templates.normalize_template(template), + [snapshot_for(card, watermark="PREVIEW - not valid")], + title="Member card preview", + ) + response = HttpResponse(pdf, content_type="application/pdf") + response["Content-Disposition"] = 'inline; filename="member-card-preview.pdf"' + return response diff --git a/backend/apps/makerspaces/member_dashboard_service.py b/backend/apps/makerspaces/member_dashboard_service.py new file mode 100644 index 00000000..54941c60 --- /dev/null +++ b/backend/apps/makerspaces/member_dashboard_service.py @@ -0,0 +1,199 @@ +"""The member dashboard's history, money and notices (owner decision D8). + +`member_activity_service` answers "what is happening right now" -- active loans, today's +bookings, presence. This answers the three things a member actually opens a dashboard +for: what have I borrowed and given back, what do I owe, and what needs my attention. + +Gated by the `membership` module through the one endpoint that serves it, so a space +that does not run memberships is unchanged. **Payment visibility is deliberately NOT +behind that gate** and lives in `apps.payments`: an account-only borrower with a loan +deposit must be able to see and settle a debt whether or not they hold a membership. +What is here is the dashboard's *summary* of dues, for a member who by definition has +one. + +The notices feed is DERIVED from the member's own rows, not read from +`notifications.Notification`. That table is makerspace-wide: it has no recipient column +and one shared `read_at`, so serving it to members would hand them staff alerts and let +one member's read mark speak for everyone. A derived feed cannot leak, needs no +migration, and says only things that are true of the member reading it. +""" + +from datetime import timedelta + +from django.db.models import Sum +from django.utils import timezone + +from apps.hardware_requests.models import HardwareRequest +from apps.hardware_requests.self_checkout_models import PublicToolLoan + +HISTORY_LIMIT = 20 + + +def loan_history(makerspace_id, member): + """Returned self-checkout loans, newest first, with what came back.""" + rows = ( + PublicToolLoan.objects.filter( + makerspace_id=makerspace_id, + requester=member, + status=PublicToolLoan.Status.RETURNED, + ) + .only("target_label", "checked_out_at", "due_at", "returned_at") + .order_by("-returned_at")[:HISTORY_LIMIT] + ) + return [ + { + "label": row.target_label, + "checked_out_at": row.checked_out_at, + "returned_at": row.returned_at, + "due_at": row.due_at, + # Whether it came back late is the member's own record, and it is the one + # fact a borrower is most likely to want to check. + "returned_late": bool( + row.due_at and row.returned_at and row.returned_at > row.due_at + ), + } + for row in rows + ] + + +def request_history(makerspace_id, member): + """The member's own reviewed hardware requests, whatever became of them. + + `member_activity` only ever showed ACTIVE self-checkout loans, so a member could not + see a request they submitted last week, whether it was accepted, or what they + returned. Scoped by `requester`, so it is their own history and nobody else's. + """ + rows = ( + HardwareRequest.objects.filter(makerspace_id=makerspace_id, requester=member) + .only("status", "created_at") + .prefetch_related("items") + .order_by("-created_at")[:HISTORY_LIMIT] + ) + history = [] + for row in rows: + items = list(row.items.all()) + history.append( + { + "status": row.status, + "created_at": row.created_at, + "item_count": len(items), + "returned_quantity": sum(item.returned_quantity for item in items), + # Surfaced because it is what an accountability restriction is based on: + # a member should see the same damage/loss record staff are acting on. + "damaged_quantity": sum(item.damaged_quantity for item in items), + "missing_quantity": sum(item.missing_quantity for item in items), + } + ) + return history + + +def membership_dues(membership): + """What this membership costs and what of it is outstanding. + + Read through the payments app rather than recomputed here: the ledger is the single + authority on what is owed, and a second sum would eventually disagree with it. + + `outstanding_by_currency` is `None` when the ledger could not be read at all, which + is deliberately distinct from `{}` for "nothing outstanding": a member must never be + told they owe nothing on the strength of a query that failed. + """ + makerspace = membership.makerspace + totals = {} + try: + from apps.payments.models import Payment + + # Grouped BY CURRENCY and never added across them, the same rule the staff + # dashboard and the reconciliation report follow: one combined figure mixing + # INR and USD is not money. + rows = ( + Payment.objects.filter( + makerspace=makerspace, + member=membership.user, + status=Payment.Status.PENDING, + ) + .values("currency") + .annotate(total=Sum("amount")) + .order_by("currency") + ) + totals = {row["currency"]: str(row["total"]) for row in rows} + except Exception: + # A payments failure must not blank a member's loans and bookings: money is one + # section of this dashboard, not a precondition for rendering it. But it must not + # read as "you owe nothing" either -- that is a false statement about someone's + # money, and the reader cannot tell it from a real zero. `None` means "could not + # be read" and renders as unavailable; `{}` means "nothing is outstanding". + totals = None + return { + "dues_amount": str(makerspace.membership_dues_amount or 0), + "outstanding_by_currency": totals, + } + + +def notices(membership, activity): + """Things that need this member's attention, derived from their own rows. + + Ordered most-urgent first. Every entry is a fact about the reader, which is what + makes serving it safe without a recipient-scoped notification table. + """ + now = timezone.now() + feed = [] + accountability = activity.get("accountability") or {} + if accountability.get("restriction_code"): + feed.append({ + "level": "critical", + "event": "access_restricted", + "title": "Your borrowing is restricted", + "body": "Speak to staff to resolve an outstanding accountability issue.", + }) + if accountability.get("waiver_acceptance_required"): + feed.append({ + "level": "warning", + "event": "waiver_required", + "title": "A waiver needs your acceptance", + "body": "You cannot borrow or check in until the current waiver is accepted.", + }) + if not accountability.get("membership_active", True): + feed.append({ + "level": "warning", + "event": "membership_inactive", + "title": "Your membership is not active", + "body": "Renew to keep borrowing and booking.", + }) + overdue = [ + loan for loan in activity.get("active_hardware_loans") or [] if loan.get("overdue") + ] + if overdue: + feed.append({ + "level": "critical", + "event": "loan_overdue", + "title": f"{len(overdue)} item(s) are overdue", + "body": "Return them as soon as you can; a late fee may apply.", + }) + due_soon = [ + loan + for loan in activity.get("active_hardware_loans") or [] + if loan.get("due_at") and not loan.get("overdue") + # The whole timedelta, not `.days`: that floors, so everything under 48 hours + # reported as 1 and a loan due in two days was announced as due within one. + and loan["due_at"] - now <= timedelta(days=1) + ] + if due_soon: + feed.append({ + "level": "info", + "event": "loan_due_soon", + "title": f"{len(due_soon)} item(s) are due within a day", + "body": "", + }) + dues = activity.get("membership_dues") or {} + if dues.get("outstanding_by_currency"): + owed = ", ".join( + f"{amount} {currency.upper()}" + for currency, amount in sorted(dues["outstanding_by_currency"].items()) + ) + feed.append({ + "level": "warning", + "event": "payment_due", + "title": f"You have {owed} outstanding", + "body": "Pay online where available, or settle it at the space.", + }) + return feed diff --git a/backend/apps/makerspaces/membership_payments.py b/backend/apps/makerspaces/membership_payments.py index 48243148..6be78487 100644 --- a/backend/apps/makerspaces/membership_payments.py +++ b/backend/apps/makerspaces/membership_payments.py @@ -2,7 +2,7 @@ from django.db import IntegrityError, transaction -from apps.payments.availability import online_payments_enabled +from apps.payments.availability import charge_tracking_enabled, online_payments_enabled from apps.payments.models import MakerspacePaymentSettings, Payment from apps.payments.reconciliation import cancel_pending from apps.payments.services import create_checkout, create_payment @@ -13,11 +13,13 @@ def create_for_active_membership(membership, actor): makerspace = membership.makerspace if ( makerspace.membership_dues_amount <= 0 - or not online_payments_enabled(makerspace, "membership") + or not charge_tracking_enabled(makerspace, "membership") ): return None payment = _get_or_create(membership, actor or membership.user) - if payment.status == Payment.Status.PENDING: + if payment.status == Payment.Status.PENDING and online_payments_enabled( + makerspace, "membership" + ): _schedule_checkout(payment) return payment except Exception: diff --git a/backend/apps/makerspaces/membership_plan_services.py b/backend/apps/makerspaces/membership_plan_services.py new file mode 100644 index 00000000..c9709c90 --- /dev/null +++ b/backend/apps/makerspaces/membership_plan_services.py @@ -0,0 +1,218 @@ +"""Membership terms: creation, cancellation and the renewal/expiry sweep. + +Renewal charges are raised through `apps.payments.services.create_payment` with subject +type `MEMBERSHIP_TERM` and the term pk as subject id, so the Payment table's one-row-per- +subject constraint is what guarantees "exactly one renewal charge per term". A payment +failure is logged and skipped -- it never stops the sweep and never touches the term -- +mirroring `membership_payments.py`: dues are a boundary the membership must not depend on. +""" +import logging +from datetime import timedelta + +from dateutil.relativedelta import relativedelta +from django.db import IntegrityError, transaction +from django.utils import timezone +from rest_framework.exceptions import ValidationError + +from apps.audit import services as audit +from apps.makerspaces.guards import require_module_locked +from apps.makerspaces.models import MakerspaceMembership, MembershipPlan, MembershipTerm + +logger = logging.getLogger(__name__) + +# Deliberately not a plan attribute: a single window keeps the sweep one query. +RENEWAL_WINDOW = timedelta(days=7) + + +def term_end(plan, starts_at): + if plan.interval == MembershipPlan.Interval.MONTHLY: + return starts_at + relativedelta(months=1) + if plan.interval == MembershipPlan.Interval.YEARLY: + return starts_at + relativedelta(years=1) + return starts_at + timedelta(days=plan.custom_days or 0) + + +def create_term(actor, membership, plan, starts_at=None): + """Open a term for an active membership; a new term follows the current one.""" + with transaction.atomic(): + # Makerspace lock first, then the membership -- the order `_activate_membership` + # mandates -- and the module gate re-checked under that lock (plan A8). + makerspace = require_module_locked(membership.makerspace_id, "membership") + membership = ( + MakerspaceMembership.objects.select_for_update(of=("self",)) + .select_related("user") + .get(pk=membership.pk) + ) + if plan.makerspace_id != makerspace.pk: + raise ValidationError({"plan_id": "Plan must belong to this makerspace."}) + if not plan.is_active: + raise ValidationError({"plan_id": "This plan is no longer active."}) + if membership.status != "active": + raise ValidationError({"detail": "Only an active membership can hold a term."}) + now = timezone.now() + if starts_at is None: + current = ( + membership.terms.filter(status=MembershipTerm.Status.ACTIVE, ends_at__gt=now) + .order_by("-ends_at") + .first() + ) + starts_at = current.ends_at if current is not None else now + term = MembershipTerm.objects.create( + membership=membership, + plan=plan, + starts_at=starts_at, + ends_at=term_end(plan, starts_at), + created_by=actor, + ) + audit.record( + actor, + "membership.term_created", + makerspace=makerspace, + target=term, + meta={"membership_id": membership.pk, "plan_id": plan.pk, "term_id": term.pk}, + ) + return term + + +def cancel_term(actor, term): + with transaction.atomic(): + term = ( + MembershipTerm.objects.select_for_update(of=("self",)) + .select_related("membership__makerspace") + .get(pk=term.pk) + ) + if term.status != MembershipTerm.Status.ACTIVE: + raise ValidationError({"detail": "This term is not active."}) + term.status = MembershipTerm.Status.CANCELLED + term.save(update_fields=["status"]) + makerspace = term.membership.makerspace + audit.record( + actor, + "membership.term_cancelled", + makerspace=makerspace, + target=term, + meta={"membership_id": term.membership_id, "term_id": term.pk}, + ) + _cancel_pending_renewal(makerspace, term, actor) + return term + + +def _cancel_pending_renewal(makerspace, term, actor): + from apps.payments.models import Payment + from apps.payments.reconciliation import cancel_pending + + try: + with transaction.atomic(): + cancel_pending( + makerspace=makerspace, + subject_type=Payment.SubjectType.MEMBERSHIP_TERM, + subject_id=term.pk, + actor=actor, + ) + except Exception: # noqa: BLE001 - money must never block the membership workflow + logger.exception("membership_renewal_cancel_failed", extra={"term_id": term.pk}) + + +def run_membership_renewals(*, now=None, limit=200): + """Expire terms past `ends_at`, then raise one renewal charge per term in the window.""" + from apps.tenant_migration.gate_runtime import fanout_tenant_write + + now = now or timezone.now() + counts = {"expired": 0, "charged": 0, "skipped": 0, "failed": 0} + due = MembershipTerm.objects.filter( + status=MembershipTerm.Status.ACTIVE, ends_at__lte=now + ).select_related("membership")[:limit] + for term in due: + with fanout_tenant_write( + term.membership.makerspace_id, operation="membership_term_expiry", counts=counts + ) as should_process: + if not should_process: + continue + _expire(term) + counts["expired"] += 1 + renewable = ( + MembershipTerm.objects.filter( + status=MembershipTerm.Status.ACTIVE, + ends_at__gt=now, + ends_at__lte=now + RENEWAL_WINDOW, + renewal_payment__isnull=True, + membership__status="active", + ) + .select_related("membership__user", "membership__makerspace", "plan") + .order_by("ends_at", "pk")[:limit] + ) + for term in renewable: + with fanout_tenant_write( + term.membership.makerspace_id, operation="membership_renewal", counts=counts + ) as should_process: + if not should_process: + continue + counts[_raise_renewal(term)] += 1 + return counts + + +def _expire(term): + with transaction.atomic(): + term.status = MembershipTerm.Status.EXPIRED + term.save(update_fields=["status"]) + audit.record( + None, + "membership.term_expired", + makerspace=term.membership.makerspace, + target=term, + meta={"membership_id": term.membership_id, "term_id": term.pk}, + ) + + +def _raise_renewal(term): + """One charge, or a logged skip. Returns the counter to bump.""" + from apps.payments.availability import ( + charge_tracking_enabled, + online_payments_enabled, + ) + from apps.payments.models import Payment + from apps.payments.services import create_checkout, create_payment + + makerspace = term.membership.makerspace + plan = term.plan + if plan.amount <= 0 or not charge_tracking_enabled(makerspace, "membership"): + return "skipped" + lookup = { + "makerspace": makerspace, + "subject_type": Payment.SubjectType.MEMBERSHIP_TERM, + "subject_id": term.pk, + } + try: + with transaction.atomic(): + try: + payment = create_payment( + **lookup, + member=term.membership.user, + amount=plan.amount, + currency=plan.currency, + created_by=term.membership.user, + subject_label=f"Membership renewal: {plan.name}", + ) + except IntegrityError: + # A previous run raised the charge but lost the race to record it. + payment = Payment.objects.get(**lookup) + MembershipTerm.objects.filter(pk=term.pk).update(renewal_payment=payment) + audit.record( + None, + "membership.renewal_raised", + makerspace=makerspace, + target=term, + meta={ + "membership_id": term.membership_id, + "term_id": term.pk, + "payment_id": payment.pk, + }, + ) + if payment.status == Payment.Status.PENDING and online_payments_enabled( + makerspace, "membership" + ): + create_checkout(payment) + except Exception: # noqa: BLE001 - one failing charge must not stop the sweep + logger.exception("membership_renewal_charge_failed", extra={"term_id": term.pk}) + return "failed" + return "charged" diff --git a/backend/apps/makerspaces/membership_services.py b/backend/apps/makerspaces/membership_services.py index 02715792..ae07accc 100644 --- a/backend/apps/makerspaces/membership_services.py +++ b/backend/apps/makerspaces/membership_services.py @@ -201,7 +201,17 @@ def claim_invitation(user, request_id): request=request, source="claim") -def approve_request(actor, request, assigned_role): +def approve_request(actor, request, assigned_role, plan=None): + """Activate the membership; with a `plan`, open its first term in the same call.""" + membership = _approve_request(actor, request, assigned_role) + if plan is not None: + from apps.makerspaces.membership_plan_services import create_term + + create_term(actor, membership, plan) + return membership + + +def _approve_request(actor, request, assigned_role): request = MembershipRequest.objects.select_related("makerspace", "user").get(pk=request.pk) with transaction.atomic(): makerspace = Makerspace.objects.select_for_update().get(pk=request.makerspace_id) diff --git a/backend/apps/makerspaces/migrations/0068_member_cards.py b/backend/apps/makerspaces/migrations/0068_member_cards.py new file mode 100644 index 00000000..96800f4e --- /dev/null +++ b/backend/apps/makerspaces/migrations/0068_member_cards.py @@ -0,0 +1,43 @@ +# Generated by Django 6.0.8 on 2026-09-03 18:51 + +import django.db.models.deletion +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('makerspaces', '0067_reconcile_anonymous_requests_with_membership'), + ] + + operations = [ + migrations.CreateModel( + name='MemberCard', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('card_number', models.PositiveIntegerField()), + ('printed_name', models.TextField(blank=True, default='')), + ('photo_object_key', models.CharField(blank=True, default='', max_length=300)), + ('photo_content_type', models.CharField(blank=True, default='', max_length=64)), + ('photo_size_bytes', models.PositiveBigIntegerField(blank=True, null=True)), + ('photo_consent_at', models.DateTimeField(blank=True, null=True)), + ('photo_consent_version', models.CharField(blank=True, default='', max_length=32)), + ('template_version_at_issue', models.PositiveIntegerField(default=1)), + ('print_count', models.PositiveIntegerField(default=0)), + ('last_printed_at', models.DateTimeField(blank=True, null=True)), + ('issued_at', models.DateTimeField(default=django.utils.timezone.now)), + ('revoked_at', models.DateTimeField(blank=True, null=True)), + ('revoked_reason', models.CharField(blank=True, default='', max_length=32)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('makerspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='member_cards', to='makerspaces.makerspace')), + ('membership', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='member_card', to='makerspaces.makerspacemembership')), + ], + options={ + 'ordering': ['makerspace_id', 'card_number'], + 'indexes': [models.Index(fields=['makerspace', 'revoked_at'], name='membercard_ms_revoked_idx')], + 'constraints': [models.UniqueConstraint(fields=('makerspace', 'card_number'), name='uniq_member_card_number'), models.CheckConstraint(condition=models.Q(models.Q(('photo_consent_at__isnull', True), ('photo_consent_version', ''), ('photo_content_type', ''), ('photo_object_key', ''), ('photo_size_bytes__isnull', True)), models.Q(('photo_consent_at__isnull', False), ('photo_size_bytes__isnull', False), models.Q(('photo_object_key', ''), _negated=True), models.Q(('photo_content_type', ''), _negated=True), models.Q(('photo_consent_version', ''), _negated=True)), _connector='OR'), name='member_card_photo_all_or_nothing')], + }, + ), + ] diff --git a/backend/apps/makerspaces/migrations/0069_member_card_actions.py b/backend/apps/makerspaces/migrations/0069_member_card_actions.py new file mode 100644 index 00000000..f5143d56 --- /dev/null +++ b/backend/apps/makerspaces/migrations/0069_member_card_actions.py @@ -0,0 +1,36 @@ +"""Grant the member-card actions to every protected Space Manager default role. + +`roles.DEFAULT_ROLE_DEFINITIONS` now lists `manage_member_cards` for `space_manager`, but +`ensure_default_roles` deliberately never rewrites an existing protected row (administrator +edits win), so existing makerspaces need this one-time additive backfill. Custom roles are +untouched: they receive nothing until an administrator grants it. `scan_member_cards` is +implied by `manage_member_cards` and is therefore not stored. +""" +from django.db import migrations + +ACTION = "manage_member_cards" + + +def forwards(apps, schema_editor): + MakerspaceRole = apps.get_model("makerspaces", "MakerspaceRole") + for role in MakerspaceRole.objects.filter(legacy_role="space_manager", is_protected=True).iterator(): + granted = list(role.granted_actions or []) + if ACTION not in granted: + role.granted_actions = sorted({*granted, ACTION}) + role.save(update_fields=["granted_actions"]) + + +def backwards(apps, schema_editor): + MakerspaceRole = apps.get_model("makerspaces", "MakerspaceRole") + for role in MakerspaceRole.objects.filter(legacy_role="space_manager", is_protected=True).iterator(): + granted = [action for action in (role.granted_actions or []) if action != ACTION] + role.granted_actions = granted + role.save(update_fields=["granted_actions"]) + + +class Migration(migrations.Migration): + dependencies = [ + ("makerspaces", "0068_member_cards"), + ] + + operations = [migrations.RunPython(forwards, backwards)] diff --git a/backend/apps/makerspaces/migrations/0070_memberprofile_show_certifications.py b/backend/apps/makerspaces/migrations/0070_memberprofile_show_certifications.py new file mode 100644 index 00000000..536bd6f6 --- /dev/null +++ b/backend/apps/makerspaces/migrations/0070_memberprofile_show_certifications.py @@ -0,0 +1,18 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("makerspaces", "0069_member_card_actions"), + ] + + operations = [ + migrations.AddField( + model_name="memberprofile", + name="show_certifications", + field=models.BooleanField( + default=False, + help_text="Whether to publish held certifications on this member profile.", + ), + ), + ] diff --git a/backend/apps/makerspaces/migrations/0071_membership_plans_terms_invitation_requests.py b/backend/apps/makerspaces/migrations/0071_membership_plans_terms_invitation_requests.py new file mode 100644 index 00000000..627671aa --- /dev/null +++ b/backend/apps/makerspaces/migrations/0071_membership_plans_terms_invitation_requests.py @@ -0,0 +1,97 @@ +# Generated by Django 6.0.8 on 2026-09-03 21:46 + +import django.core.validators +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('makerspaces', '0070_memberprofile_show_certifications'), + ('payments', '0014_refund_and_loan_settings'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='makerspace', + name='lapsed_members_cannot_borrow', + field=models.BooleanField(default=False), + ), + migrations.CreateModel( + name='MembershipPlan', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=120)), + ('interval', models.CharField(choices=[('monthly', 'Monthly'), ('yearly', 'Yearly'), ('custom_days', 'Custom number of days')], max_length=16)), + ('custom_days', models.PositiveIntegerField(blank=True, null=True)), + ('amount', models.DecimalField(decimal_places=2, default=0, max_digits=12, validators=[django.core.validators.MinValueValidator(0)])), + ('currency', models.CharField(default='usd', max_length=3)), + ('is_active', models.BooleanField(default=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('makerspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='membership_plans', to='makerspaces.makerspace')), + ], + options={ + 'ordering': ['makerspace_id', 'name'], + }, + ), + migrations.CreateModel( + name='MembershipTerm', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('starts_at', models.DateTimeField()), + ('ends_at', models.DateTimeField()), + ('status', models.CharField(choices=[('active', 'Active'), ('expired', 'Expired'), ('cancelled', 'Cancelled')], default='active', max_length=16)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_membership_terms', to=settings.AUTH_USER_MODEL)), + ('membership', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='terms', to='makerspaces.makerspacemembership')), + ('plan', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='terms', to='makerspaces.membershipplan')), + ('renewal_payment', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='renewed_membership_terms', to='payments.payment')), + ], + options={ + 'ordering': ['-starts_at', '-pk'], + }, + ), + migrations.CreateModel( + name='InvitationRequest', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.TextField(blank=True, default='')), + ('email', models.TextField(blank=True, default='')), + ('phone', models.TextField(blank=True, default='')), + ('message', models.TextField(blank=True, default='', validators=[django.core.validators.MaxLengthValidator(1000)])), + ('status', models.CharField(choices=[('pending', 'Pending'), ('invited', 'Invited'), ('declined', 'Declined')], default='pending', max_length=16)), + ('handled_at', models.DateTimeField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('handled_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='handled_invitation_requests', to=settings.AUTH_USER_MODEL)), + ('makerspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invitation_requests', to='makerspaces.makerspace')), + ], + options={ + 'ordering': ['-created_at', '-pk'], + 'indexes': [models.Index(fields=['makerspace', 'status', 'created_at'], name='invitationreq_ms_status_idx')], + }, + ), + migrations.AddConstraint( + model_name='membershipplan', + constraint=models.UniqueConstraint(fields=('makerspace', 'name'), name='uniq_membership_plan_name'), + ), + migrations.AddConstraint( + model_name='membershipplan', + constraint=models.CheckConstraint(condition=models.Q(('amount__gte', 0)), name='membership_plan_amount_non_negative'), + ), + migrations.AddConstraint( + model_name='membershipplan', + constraint=models.CheckConstraint(condition=models.Q(models.Q(('custom_days__gt', 0), ('interval', 'custom_days')), models.Q(models.Q(('interval', 'custom_days'), _negated=True), ('custom_days__isnull', True)), _connector='OR'), name='membership_plan_custom_days_matches_interval'), + ), + migrations.AddIndex( + model_name='membershipterm', + index=models.Index(fields=['status', 'ends_at'], name='membershipterm_status_ends_idx'), + ), + migrations.AddConstraint( + model_name='membershipterm', + constraint=models.CheckConstraint(condition=models.Q(('ends_at__gt', models.F('starts_at'))), name='membership_term_ends_after_start'), + ), + ] diff --git a/backend/apps/makerspaces/migrations/0072_enable_charge_tracking_for_existing_makerspaces.py b/backend/apps/makerspaces/migrations/0072_enable_charge_tracking_for_existing_makerspaces.py new file mode 100644 index 00000000..8efc361b --- /dev/null +++ b/backend/apps/makerspaces/migrations/0072_enable_charge_tracking_for_existing_makerspaces.py @@ -0,0 +1,74 @@ +"""Turn charge tracking ON for makerspaces that already exist. + +The `charges.*` family decides whether money owed is RECORDED at all, independently of +the `payments` module, which now governs only the online rail. `enabled_features` is +stored per row, so a new `default_enabled=True` reaches new makerspaces only -- without +this backfill every existing space would read the keys as OFF and silently stop +recording booking, event, machine, membership and loan charges the day this ships. + +Each domain key inherits the state of the `payments.` feature it replaces, so a +space keeps charging for exactly what it charged for yesterday and nothing more. New +makerspaces get all six on by default instead, since they have no prior intent to carry. + +Same shape as `0050` and `0051`: a one-time backfill with a working reverse, touching +only rows that lack the keys. +""" + +from django.db import migrations + +FEATURE_KEYS = ( + "charges.enabled", + "charges.bookings", + "charges.events", + "charges.machines", + "charges.membership", + "charges.loans", +) + + +#: Each tracking key and the online-payment feature whose state it inherits. A space +#: that deliberately kept `payments.bookings` OFF was not charging for bookings, and +#: switching tracking on for it would start creating debts it never had -- with +#: `charges.loans` it could even start BLOCKING issue. So intent is carried across per +#: domain rather than assumed. +DOMAIN_SOURCE = { + "charges.bookings": "payments.bookings", + "charges.events": "payments.events", + "charges.machines": "payments.machines", + "charges.membership": "payments.membership", + "charges.loans": "payments.loans", +} + + +def enable_tracking(apps, schema_editor): + Makerspace = apps.get_model("makerspaces", "Makerspace") + for makerspace in Makerspace.objects.all().iterator(): + features = set(makerspace.enabled_features or []) + # The master switch goes on for everyone: on its own it enables nothing, because + # each domain key still has to be present below. + wanted = {"charges.enabled"} | { + key for key, source in DOMAIN_SOURCE.items() if source in features + } + if features.issuperset(wanted): + continue + makerspace.enabled_features = sorted(features | wanted) + makerspace.save(update_fields=["enabled_features"]) + + +def disable_tracking(apps, schema_editor): + Makerspace = apps.get_model("makerspaces", "Makerspace") + for makerspace in Makerspace.objects.all().iterator(): + features = set(makerspace.enabled_features or []) + if features.isdisjoint(FEATURE_KEYS): + continue + makerspace.enabled_features = sorted(features - set(FEATURE_KEYS)) + makerspace.save(update_fields=["enabled_features"]) + + +class Migration(migrations.Migration): + # Chained off the ACTUAL leaf, read from the migrations directory. + dependencies = [ + ("makerspaces", "0071_membership_plans_terms_invitation_requests") + ] + + operations = [migrations.RunPython(enable_tracking, disable_tracking)] diff --git a/backend/apps/makerspaces/models.py b/backend/apps/makerspaces/models.py index 87ca74b4..cc481a82 100644 --- a/backend/apps/makerspaces/models.py +++ b/backend/apps/makerspaces/models.py @@ -78,6 +78,14 @@ def presence_presets(makerspace): MemberProfile, MemberProject, ) +from apps.makerspaces.models_member_cards import MemberCard # noqa: E402,F401 +from apps.makerspaces.models_membership_plans import ( # noqa: E402,F401 + MembershipPlan, + MembershipTerm, +) +from apps.makerspaces.models_invitation_requests import ( # noqa: E402,F401 + InvitationRequest, +) from apps.makerspaces.models_archive_requests import ( # noqa: E402,F401 MakerspaceArchiveRequest, ) diff --git a/backend/apps/makerspaces/models_invitation_requests.py b/backend/apps/makerspaces/models_invitation_requests.py new file mode 100644 index 00000000..2de6ae36 --- /dev/null +++ b/backend/apps/makerspaces/models_invitation_requests.py @@ -0,0 +1,55 @@ +"""Invitation requests: "I would like to join" from someone who is not yet a member. + +Forward plan phase 6 ("Invitation requests"). The public makerspace site accepts a +name, contact and message from an anonymous visitor; staff see the queue beside join +requests and either **Invite** (which issues the ordinary membership invitation through +`membership_services.invite_membership`) or decline. Nothing here grants anything: the +row is a lead, and the invitation it turns into is the existing, audited path. + +Privacy shape: `name`, `email` and `phone` are scoped source PII registered in +`apps/encryption/registry.py` and encrypted at rest when encryption is enabled, like the +requester fields on `HardwareRequest`. They are TextFields for that reason -- an envelope +is longer than the plaintext limit the registry enforces. +""" +from django.conf import settings +from django.core.validators import MaxLengthValidator +from django.db import models + +from apps.encryption.mappers import ScopedPiiModelMixin + + +class InvitationRequest(ScopedPiiModelMixin, models.Model): + class Status(models.TextChoices): + PENDING = "pending", "Pending" + INVITED = "invited", "Invited" + DECLINED = "declined", "Declined" + + makerspace = models.ForeignKey( + "makerspaces.Makerspace", on_delete=models.CASCADE, related_name="invitation_requests" + ) + name = models.TextField(blank=True, default="") + email = models.TextField(blank=True, default="") + phone = models.TextField(blank=True, default="") + message = models.TextField(blank=True, default="", validators=[MaxLengthValidator(1000)]) + status = models.CharField(max_length=16, choices=Status.choices, default=Status.PENDING) + handled_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="handled_invitation_requests", + ) + handled_at = models.DateTimeField(null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + indexes = [ + models.Index( + fields=["makerspace", "status", "created_at"], + name="invitationreq_ms_status_idx", + ), + ] + ordering = ["-created_at", "-pk"] + + def __str__(self): + return f"invitation request {self.pk} ({self.makerspace_id})" diff --git a/backend/apps/makerspaces/models_makerspace.py b/backend/apps/makerspaces/models_makerspace.py index 469488d1..7a74e2c8 100644 --- a/backend/apps/makerspaces/models_makerspace.py +++ b/backend/apps/makerspaces/models_makerspace.py @@ -103,6 +103,10 @@ class PublicPrintStatusLookupPolicy(models.TextChoices): validators=[MinValueValidator(0)], ) referrals_enabled = models.BooleanField(default=False) + # Opt-in: a member whose membership terms have all expired or been cancelled is + # refused a borrow request like a non-member. A member with no term at all is never + # affected -- plans are optional. Enforced by `request_access.require_current_term`. + lapsed_members_cannot_borrow = models.BooleanField(default=False) # 0 = off. When > 0, active filament spools at/below this remaining weight # can auto-create a printing procurement item. filament_low_stock_threshold_grams = models.DecimalField( diff --git a/backend/apps/makerspaces/models_member_cards.py b/backend/apps/makerspaces/models_member_cards.py new file mode 100644 index 00000000..3ac1d27d --- /dev/null +++ b/backend/apps/makerspaces/models_member_cards.py @@ -0,0 +1,82 @@ +"""Member ID cards: a durable, revocable QR credential over one membership. + +Design: docs/plans/2026-09-02-codex-plans/ev_artifacts.md §2 (reviewed), built in forward-plan +phase 5. The card is `membership`-module behaviour with no module key of its own; its QR is +a core `qr_management` `QrCode` with target type `member_card`, so revocation, active-target +uniqueness and the immutable scan history are the ones every other QR already has. + +Privacy shape: `printed_name` is scoped source PII (encrypted at rest, registered in +`apps/encryption/registry.py`); the photo is a PRIVATE object (`apps/backup/ +object_ownership_registry.py`) readable only through short-lived signed GETs. Revocation +deletes the photo bytes and blanks the name immediately (`member_card_services.redact`); +only the redacted row, the revoked QR, the scans and the audit entries remain. +""" +from django.db import models +from django.utils import timezone + +from apps.encryption.mappers import ScopedPiiModelMixin + + +class MemberCard(ScopedPiiModelMixin, models.Model): + makerspace = models.ForeignKey( + "makerspaces.Makerspace", on_delete=models.CASCADE, related_name="member_cards" + ) + # SET_NULL so a redacted card target survives membership deletion and historic + # immutable scans keep pointing at a row, never at a reused integer. + membership = models.OneToOneField( + "makerspaces.MakerspaceMembership", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="member_card", + ) + # Printed on the card; per-makerspace sequence, never the QR payload. + card_number = models.PositiveIntegerField() + printed_name = models.TextField(blank=True, default="") + photo_object_key = models.CharField(max_length=300, blank=True, default="") + photo_content_type = models.CharField(max_length=64, blank=True, default="") + photo_size_bytes = models.PositiveBigIntegerField(null=True, blank=True) + photo_consent_at = models.DateTimeField(null=True, blank=True) + photo_consent_version = models.CharField(max_length=32, blank=True, default="") + template_version_at_issue = models.PositiveIntegerField(default=1) + print_count = models.PositiveIntegerField(default=0) + last_printed_at = models.DateTimeField(null=True, blank=True) + issued_at = models.DateTimeField(default=timezone.now) + revoked_at = models.DateTimeField(null=True, blank=True) + revoked_reason = models.CharField(max_length=32, blank=True, default="") + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["makerspace", "card_number"], name="uniq_member_card_number" + ), + # Photo fields travel as a set: all present (with consent) or all absent. + models.CheckConstraint( + condition=( + models.Q( + photo_object_key="", photo_content_type="", photo_size_bytes__isnull=True, + photo_consent_at__isnull=True, photo_consent_version="", + ) + | models.Q( + photo_size_bytes__isnull=False, photo_consent_at__isnull=False, + ) + & ~models.Q(photo_object_key="") + & ~models.Q(photo_content_type="") + & ~models.Q(photo_consent_version="") + ), + name="member_card_photo_all_or_nothing", + ), + ] + indexes = [ + models.Index(fields=["makerspace", "revoked_at"], name="membercard_ms_revoked_idx"), + ] + ordering = ["makerspace_id", "card_number"] + + @property + def is_active(self): + return self.revoked_at is None and self.membership_id is not None + + def __str__(self): + return f"card #{self.card_number} ({self.makerspace_id})" diff --git a/backend/apps/makerspaces/models_membership_plans.py b/backend/apps/makerspaces/models_membership_plans.py new file mode 100644 index 00000000..f5caea4c --- /dev/null +++ b/backend/apps/makerspaces/models_membership_plans.py @@ -0,0 +1,115 @@ +"""Membership plans and the dated terms a membership holds under them. + +Forward plan phase 6 ("Membership plans and renewals"). Plans are OPTIONAL: a membership +with no term at all is a perfectly good membership, exactly as before this file existed. +A term is created when a join request is approved with a plan, or by staff. The renewal +task (`membership_plan_services.run_membership_renewals`) raises ONE charge inside the +renewal window when `payments.membership` is effectively on, and flips terms past +`ends_at` to `expired`; with the feature off the term simply expires and staff renew by +hand, dues out of band. + +Expiry never touches `MakerspaceMembership.status` or `User.access_status`. The only +effect an expired term can have is the per-makerspace opt-in +`Makerspace.lapsed_members_cannot_borrow`, enforced by `request_access.require_current_term` +through the existing who-may-submit rule -- no new access state. +""" +from django.conf import settings +from django.core.validators import MinValueValidator +from django.db import models +from django.db.models import F, Q + + +class MembershipPlan(models.Model): + class Interval(models.TextChoices): + MONTHLY = "monthly", "Monthly" + YEARLY = "yearly", "Yearly" + CUSTOM_DAYS = "custom_days", "Custom number of days" + + makerspace = models.ForeignKey( + "makerspaces.Makerspace", on_delete=models.CASCADE, related_name="membership_plans" + ) + name = models.CharField(max_length=120) + interval = models.CharField(max_length=16, choices=Interval.choices) + custom_days = models.PositiveIntegerField(null=True, blank=True) + amount = models.DecimalField( + max_digits=12, decimal_places=2, default=0, validators=[MinValueValidator(0)] + ) + # ISO 4217 lowercase, the way `payments.Payment.currency` stores it. + currency = models.CharField(max_length=3, default="usd") + is_active = models.BooleanField(default=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["makerspace", "name"], name="uniq_membership_plan_name" + ), + models.CheckConstraint( + condition=Q(amount__gte=0), name="membership_plan_amount_non_negative" + ), + # `custom_days` is meaningful for exactly one interval, and required there. + models.CheckConstraint( + condition=( + Q(interval="custom_days", custom_days__gt=0) + | (~Q(interval="custom_days") & Q(custom_days__isnull=True)) + ), + name="membership_plan_custom_days_matches_interval", + ), + ] + ordering = ["makerspace_id", "name"] + + def save(self, *args, **kwargs): + self.currency = (self.currency or "").lower() + super().save(*args, **kwargs) + + def __str__(self): + return f"{self.name} ({self.makerspace_id})" + + +class MembershipTerm(models.Model): + class Status(models.TextChoices): + ACTIVE = "active", "Active" + EXPIRED = "expired", "Expired" + CANCELLED = "cancelled", "Cancelled" + + membership = models.ForeignKey( + "makerspaces.MakerspaceMembership", on_delete=models.CASCADE, related_name="terms" + ) + # PROTECT: a term is the record of what a member was sold; deleting the plan under it + # would erase the price history behind a real charge. Deactivate plans instead. + plan = models.ForeignKey(MembershipPlan, on_delete=models.PROTECT, related_name="terms") + starts_at = models.DateTimeField() + ends_at = models.DateTimeField() + status = models.CharField(max_length=16, choices=Status.choices, default=Status.ACTIVE) + # The ONE renewal charge raised for this term. Money is never deleted, so SET_NULL is + # only ever reached by the payments purge path; the Payment row itself stays. + renewal_payment = models.ForeignKey( + "payments.Payment", + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="renewed_membership_terms", + ) + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="created_membership_terms", + ) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + constraints = [ + models.CheckConstraint( + condition=Q(ends_at__gt=F("starts_at")), name="membership_term_ends_after_start" + ), + ] + indexes = [ + models.Index(fields=["status", "ends_at"], name="membershipterm_status_ends_idx"), + ] + ordering = ["-starts_at", "-pk"] + + def __str__(self): + return f"term {self.pk} of membership {self.membership_id}" diff --git a/backend/apps/makerspaces/models_profiles.py b/backend/apps/makerspaces/models_profiles.py index 7d3f2f38..e200bc8c 100644 --- a/backend/apps/makerspaces/models_profiles.py +++ b/backend/apps/makerspaces/models_profiles.py @@ -41,6 +41,12 @@ class MemberProfile(models.Model): default=False, help_text="Whether to publish recently attended events on this member profile.", ) + # Same consent rule: certifications are staff-derived training records, not text the + # member typed, so publishing them on a visible profile needs its own opt-in. + show_certifications = models.BooleanField( + default=False, + help_text="Whether to publish held certifications on this member profile.", + ) headline = models.CharField(max_length=200, blank=True, default="") institution = models.CharField(max_length=200, blank=True, default="") bio = models.TextField(blank=True, default="") diff --git a/backend/apps/makerspaces/module_profiles.py b/backend/apps/makerspaces/module_profiles.py index c27e306f..df819eef 100644 --- a/backend/apps/makerspaces/module_profiles.py +++ b/backend/apps/makerspaces/module_profiles.py @@ -15,6 +15,10 @@ WORKSHOP = "workshop" RECOMMENDED = "recommended" EVERYTHING = "everything" +# Edition profiles (phase 4): what an events-only or bookings-only box installs. Core is +# present too -- it always is -- but the matching SPACEWORKS_EDITION hides it. +EVENTS_ONLY = "events" +BOOKINGS_ONLY = "bookings" # Core plus what a makerspace lending hardware realistically needs on day one: # the inventory lifecycle, reporting, and machines. @@ -66,6 +70,13 @@ "notifications", "email", "payments", }) +_EVENTS_ONLY_EXTRAS = frozenset({ + "events", "notifications", "email", "member_accounts", "membership", "payments", "reports", +}) +_BOOKINGS_ONLY_EXTRAS = frozenset({ + "bookings", "notifications", "email", "member_accounts", "membership", "payments", "reports", +}) + PROFILES = { MINIMAL: "Core only -- the smallest coherent install.", CLOUD: "A single Django process: no worker, no beat, object storage on R2.", @@ -74,6 +85,8 @@ WORKSHOP: "A machine shop: machines, the service queue and maintenance.", RECOMMENDED: "Core plus the inventory lifecycle, reports and machines.", EVERYTHING: "Every module (the pre-opt-in default).", + EVENTS_ONLY: "An events programme: events, sign-ups, check-in, notifications and payments.", + BOOKINGS_ONLY: "Bookable rooms and resources with notifications and payments.", } # NOTE ON HOW LEAN A PROFILE CAN GET. Six modules are core and no profile can drop them @@ -101,6 +114,10 @@ def profile_modules(name): keys = core_module_keys() | _LENDING_EXTRAS elif name == WORKSHOP: keys = core_module_keys() | _WORKSHOP_EXTRAS + elif name == EVENTS_ONLY: + keys = core_module_keys() | _EVENTS_ONLY_EXTRAS + elif name == BOOKINGS_ONLY: + keys = core_module_keys() | _BOOKINGS_ONLY_EXTRAS else: keys = set(core_module_keys()) return sorted(with_dependencies(keys)) diff --git a/backend/apps/makerspaces/module_purge_collectors.py b/backend/apps/makerspaces/module_purge_collectors.py index bfa6a4d4..d637531d 100644 --- a/backend/apps/makerspaces/module_purge_collectors.py +++ b/backend/apps/makerspaces/module_purge_collectors.py @@ -16,6 +16,12 @@ scope would drag half the app graph into every `manage.py` invocation. """ from apps.makerspaces.module_purge_collectors_machine_service import machine_service_delete +from apps.makerspaces.module_purge_collectors_membership import ( + membership_delete, + membership_private_key_sizes, + membership_private_keys, + membership_public_image_keys, +) from apps.makerspaces.module_purge_collectors_single_model import ( _counts, _delete, @@ -256,45 +262,5 @@ def discord_destinations_delete(makerspace, cursor): return _chat_destinations_delete(makerspace, "discord") -def membership_public_image_keys(makerspace): - """Avatars and project images, collected BEFORE the rows that name them go. - - Without this the objects outlive every row that could name them: nothing else in the - system knows a `member//...` key exists once the profile is deleted, so they - would sit in the bucket forever and keep counting against the space's storage. - """ - from apps.makerspaces.models import MemberProfile, MemberProject - - keys = list( - MemberProfile.objects.filter(membership__makerspace=makerspace).values_list( - "avatar_key", flat=True - ) - ) - keys += list( - MemberProject.objects.filter( - profile__membership__makerspace=makerspace - ).values_list("image_key", flat=True) - ) - return [key for key in dict.fromkeys(keys) if key] - - -def membership_delete(makerspace, cursor): - from apps.makerspaces.models import MemberProfile, MembershipRequest - - # `MakerspaceMembership` itself is core RBAC state and is NEVER deleted here -- the - # module gates community enrolment/content, not the roster (plan A7). Waivers and - # both acceptance evidence types are core liability records and likewise survive. - # Profiles go even though the membership stays: a profile is community content the - # module owns, not the RBAC state the module deliberately leaves behind. Projects - # cascade from the profile. - profiles, profile_labels = _delete( - MemberProfile.objects.filter(membership__makerspace=makerspace) - ) - requests, request_labels = _delete( - MembershipRequest.objects.filter(makerspace=makerspace) - ) - return _counts( - model_labels=profile_labels | request_labels, - member_profiles=profiles, - membership_requests=requests, - ) +def webhook_destinations_delete(makerspace, cursor): + return _chat_destinations_delete(makerspace, "webhook") diff --git a/backend/apps/makerspaces/module_purge_collectors_membership.py b/backend/apps/makerspaces/module_purge_collectors_membership.py new file mode 100644 index 00000000..50c9f34e --- /dev/null +++ b/backend/apps/makerspaces/module_purge_collectors_membership.py @@ -0,0 +1,98 @@ +"""Membership-module purge collectors (profiles, projects, requests, member ID cards). + +Split out of `module_purge_collectors` to keep that barrel under the file-size ceiling; the +same two rules apply (only what the module owns; model imports stay function-local). +""" +from apps.makerspaces.module_purge_collectors_single_model import _counts, _delete + + +def membership_public_image_keys(makerspace): + """Avatars and project images, collected BEFORE the rows that name them go. + + Without this the objects outlive every row that could name them: nothing else in the + system knows a `member//...` key exists once the profile is deleted, so they + would sit in the bucket forever and keep counting against the space's storage. + """ + from apps.makerspaces.models import MemberProfile, MemberProject + + keys = list( + MemberProfile.objects.filter(membership__makerspace=makerspace).values_list( + "avatar_key", flat=True + ) + ) + keys += list( + MemberProject.objects.filter( + profile__membership__makerspace=makerspace + ).values_list("image_key", flat=True) + ) + return [key for key in dict.fromkeys(keys) if key] + + +def membership_private_keys(makerspace, add): + """Card photos: private objects deleted with the module (bytes are faces).""" + from apps.makerspaces.models import MemberCard + + for key in MemberCard.objects.filter(makerspace=makerspace).values_list("photo_object_key", flat=True): + if key: + add(key) + + +def membership_private_key_sizes(makerspace): + from apps.makerspaces.models import MemberCard + + return { + key: size or 0 + for key, size in MemberCard.objects.filter(makerspace=makerspace).exclude(photo_object_key="").values_list("photo_object_key", "photo_size_bytes") + } + + +def membership_delete(makerspace, cursor): + from apps.makerspaces.models import ( + InvitationRequest, + MemberCard, + MemberProfile, + MembershipPlan, + MembershipRequest, + MembershipTerm, + ) + + # `MakerspaceMembership` itself is core RBAC state and is NEVER deleted here -- the + # module gates community enrolment/content, not the roster (plan A7). Waivers and + # both acceptance evidence types are core liability records and likewise survive. + # Profiles go even though the membership stays: a profile is community content the + # module owns, not the RBAC state the module deliberately leaves behind. Projects + # cascade from the profile. + profiles, profile_labels = _delete( + MemberProfile.objects.filter(membership__makerspace=makerspace) + ) + requests, request_labels = _delete( + MembershipRequest.objects.filter(makerspace=makerspace) + ) + # Cards go with the module: their QR codes are revoked so historic scans still resolve + # as revoked, never as an unknown payload. + from apps.boxes.models import QrCode + + QrCode.objects.filter( + makerspace=makerspace, target_type=QrCode.TargetType.MEMBER_CARD, + status=QrCode.Status.ACTIVE, + ).update(status=QrCode.Status.REVOKED) + cards, card_labels = _delete(MemberCard.objects.filter(makerspace=makerspace)) + # Terms before plans: a term PROTECTs its plan. Renewal Payment rows are money and + # stay (the FK to them lives on the term side); the leads queue goes with the module. + terms, term_labels = _delete( + MembershipTerm.objects.filter(membership__makerspace=makerspace) + ) + plans, plan_labels = _delete(MembershipPlan.objects.filter(makerspace=makerspace)) + leads, lead_labels = _delete(InvitationRequest.objects.filter(makerspace=makerspace)) + return _counts( + model_labels=( + profile_labels | request_labels | card_labels | term_labels | plan_labels + | lead_labels + ), + member_cards=cards, + member_profiles=profiles, + membership_requests=requests, + membership_terms=terms, + membership_plans=plans, + invitation_requests=leads, + ) diff --git a/backend/apps/makerspaces/module_purge_collectors_reports.py b/backend/apps/makerspaces/module_purge_collectors_reports.py new file mode 100644 index 00000000..9b23aaae --- /dev/null +++ b/backend/apps/makerspaces/module_purge_collectors_reports.py @@ -0,0 +1,25 @@ +"""Reports-module purge collectors: schedules, their deliveries and the delivered files. + +Metric rollups are deliberately NOT here: they are append-only rows behind a retention +fence (`report_rollups.satisfy_retention_fence`), so removing them is a retention decision +the evidence sweep depends on, not a "turn the module off" clean-up. Same two rules as the +other collectors: only what the module owns, model imports function-local. +""" +from apps.makerspaces.module_purge_collectors_single_model import _counts, _delete + + +def reports_private_keys(makerspace, add): + """Delivered report files: private objects deleted with the module.""" + from apps.operations.models import ReportDelivery + + for key in ReportDelivery.objects.filter(schedule__makerspace=makerspace).exclude( + object_key="" + ).values_list("object_key", flat=True): + add(key) + + +def reports_delete(makerspace, cursor): + from apps.operations.models import ReportSchedule + + deleted, labels = _delete(ReportSchedule.objects.filter(makerspace=makerspace)) + return _counts(model_labels=labels, report_schedules=deleted) diff --git a/backend/apps/makerspaces/module_purge_plans.py b/backend/apps/makerspaces/module_purge_plans.py index 9f8bd92a..dcdf820a 100644 --- a/backend/apps/makerspaces/module_purge_plans.py +++ b/backend/apps/makerspaces/module_purge_plans.py @@ -36,6 +36,8 @@ maintenance_private_key_sizes, maintenance_private_keys, membership_delete, + membership_private_key_sizes, + membership_private_keys, membership_public_image_keys, notifications_delete, procurement_delete, @@ -45,6 +47,11 @@ stock_transfers_delete, stocktake_delete, telegram_destinations_delete, + webhook_destinations_delete, +) +from apps.makerspaces.module_purge_collectors_reports import ( + reports_delete, + reports_private_keys, ) @@ -127,9 +134,13 @@ class ModulePurgePlan: # always the exception; they are now simply the rule. ModulePurgePlan( "membership", - "Join requests and member profiles with their projects and imagery. " - "Memberships, waivers and acceptance evidence stay as core RBAC/liability state.", + "Join requests, invitation requests, membership plans and terms, and member " + "profiles with their projects and imagery. Memberships, waivers and acceptance " + "evidence stay as core RBAC/liability state.", membership_delete, + pii_labels=("makerspaces.MemberCard", "makerspaces.InvitationRequest"), + private_keys=membership_private_keys, + private_key_sizes=membership_private_key_sizes, public_image_keys=membership_public_image_keys, ), ModulePurgePlan( @@ -159,6 +170,20 @@ class ModulePurgePlan: ModulePurgePlan( "discord", "Discord destinations and their stored webhooks.", discord_destinations_delete ), + ModulePurgePlan( + "webhook", + "Signed-webhook destinations, their endpoint URLs and signing secrets.", + webhook_destinations_delete, + ), + # Metric rollups stay: they are append-only rows behind the evidence retention fence, + # so removing them is a retention decision, not part of switching reporting off. + ModulePurgePlan( + "reports", + "Scheduled report deliveries: schedules, delivery history and the delivered files. " + "Metric rollups stay (retention fence).", + reports_delete, + private_keys=reports_private_keys, + ), ModulePurgePlan("stocktake", "Stocktake sessions, lines and ledger entries.", stocktake_delete), ModulePurgePlan("stock_transfers", "Stock transfers and their lines.", stock_transfers_delete), ModulePurgePlan("qr_print_batches", "QR print batches and their items.", qr_print_batches_delete), diff --git a/backend/apps/makerspaces/module_registry.py b/backend/apps/makerspaces/module_registry.py index 78ef8691..d886b6f2 100644 --- a/backend/apps/makerspaces/module_registry.py +++ b/backend/apps/makerspaces/module_registry.py @@ -181,13 +181,25 @@ "discord", "Discord", "Per-makerspace Discord incoming-webhook alerts.", "integrations", GUARD, group=GROUP_NOTIFICATIONS, ), + # Not a chat room: an HTTPS endpoint of the makerspace's own that receives every + # notification the matrix routes to it as signed JSON. Same destination model, same + # matrix, same retry and delivery log as the chat channels. + ModuleDefinition( + "webhook", "Signed webhooks", + "Per-makerspace signed JSON webhooks to your own systems.", + "integrations", GUARD, group=GROUP_NOTIFICATIONS, + ), # These keys were placed in front of substrate that had been unconditionally # present, so migration 0057 backfilled their original keys onto existing rows. - # Payments and updates remain default-enabled; member accounts and mobile are now + # Updates remains default-enabled; member accounts, mobile and now payments are # opt-in for newly created makerspaces. + # `payments` stopped being default-enabled when charge TRACKING moved out from under + # it (`charges.*`): the module now buys only the online rail, which a cash-taking + # space needs none of. Existing rows keep the key from migration 0057. ModuleDefinition( - "payments", "Payments", "Online payment for machine jobs, bookings, events and dues.", - "payments", GUARD, group=GROUP_PAYMENTS, default_enabled=True, + "payments", "Payments", + "Online card payment (Stripe/Razorpay). Money owed is tracked without it.", + "payments_rail", GUARD, group=GROUP_PAYMENTS, ), # Member-facing identity only. Staff authentication is core RBAC and is NEVER gated: # a space that could switch off its own staff logins could not be administered, the diff --git a/backend/apps/makerspaces/platform.py b/backend/apps/makerspaces/platform.py index 7c8f8ba8..b78cb7f3 100644 --- a/backend/apps/makerspaces/platform.py +++ b/backend/apps/makerspaces/platform.py @@ -134,7 +134,14 @@ def available_modules(makerspace): `/control/` and for `module_install`, which must show and edit what is stored, not what happens to be reachable today. """ - return sorted(key for key in set(makerspace.enabled_modules or []) if module_available(key)) + from apps.makerspaces.editions import hidden_module_keys + + hidden = hidden_module_keys() + return sorted( + key + for key in set(makerspace.enabled_modules or []) + if module_available(key) and key not in hidden + ) def feature_enabled(makerspace, key): @@ -159,6 +166,8 @@ def feature_enabled(makerspace, key): ) and all(feature_enabled(makerspace, feature) for feature in definition.requires_features) def bootstrap_payload(makerspace): + from apps.makerspaces.editions import current_edition + modules = sorted(key for key in available_modules(makerspace) if is_frontend_exposed(key)) features = sorted(key for key, definition in FEATURES.items() if definition.frontend_exposed and feature_enabled(makerspace, key)) theme = default_theme_config() @@ -218,6 +227,7 @@ def bootstrap_payload(makerspace): "allowed_origins": sorted(makerspace_public_origins(makerspace)), }, "modules": modules, + "edition": current_edition().key, "features": features, "workflows": workflows, "theme": theme, diff --git a/backend/apps/makerspaces/profile_directory.py b/backend/apps/makerspaces/profile_directory.py new file mode 100644 index 00000000..0df49495 --- /dev/null +++ b/backend/apps/makerspaces/profile_directory.py @@ -0,0 +1,59 @@ +"""The member-visible directory: who opted in, and one other member's visible profile.""" + +from django.db.models import Q + +from apps.inventory import public_image_storage +from apps.makerspaces.models import MakerspaceMembership +from apps.makerspaces.profile_services import display_name_for, read_profile + + +def directory(makerspace, query=""): + """Visible profiles, plus a count of everyone who did not opt in. + + ``query`` matches the plain-text identity columns only (username, display name, profile + headline and institution). Contact fields are scoped PII and are never searched here. + """ + memberships = MakerspaceMembership.objects.filter( + makerspace=makerspace, status="active", user__is_active=True + ).select_related("user", "profile") + query = (query or "").strip()[:200] + if query: + memberships = memberships.filter( + Q(user__username__icontains=query) + | Q(user__display_name__icontains=query) + | Q(user__first_name__icontains=query) + | Q(user__last_name__icontains=query) + | Q(profile__headline__icontains=query) + | Q(profile__institution__icontains=query) + ) + members, hidden = [], 0 + for membership in memberships: + profile = getattr(membership, "profile", None) + # No profile row at all is the same answer as one that is not visible: nobody + # is listed until they choose to be. + if profile is None or not profile.is_visible: + hidden += 1 + continue + members.append( + { + "membership_id": membership.pk, + "display_name": display_name_for(membership), + "headline": profile.headline, + "avatar_url": public_image_storage.public_url(profile.avatar_key) or None, + } + ) + members.sort(key=lambda row: row["display_name"].lower()) + return {"members": members, "hidden_count": hidden} + + +def visible_profile(makerspace, membership_id, *, local_activity_only=False): + """One other member's profile, or None when it is not theirs to see.""" + membership = MakerspaceMembership.objects.select_related("user", "profile").filter( + pk=membership_id, makerspace=makerspace, status="active", user__is_active=True + ).first() + if membership is None: + return None + profile = getattr(membership, "profile", None) + if profile is None or not profile.is_visible: + return None + return read_profile(membership, local_activity_only=local_activity_only) diff --git a/backend/apps/makerspaces/profile_serializers.py b/backend/apps/makerspaces/profile_serializers.py index 7398a933..88db8ca9 100644 --- a/backend/apps/makerspaces/profile_serializers.py +++ b/backend/apps/makerspaces/profile_serializers.py @@ -65,6 +65,7 @@ class ProjectWriteSerializer(serializers.Serializer): class ProfileWriteSerializer(serializers.Serializer): is_visible = serializers.BooleanField(required=False) show_attended_events = serializers.BooleanField(required=False) + show_certifications = serializers.BooleanField(required=False) headline = serializers.CharField(max_length=200, required=False, allow_blank=True) institution = serializers.CharField(max_length=200, required=False, allow_blank=True) bio = serializers.CharField(max_length=MAX_BIO, required=False, allow_blank=True) @@ -116,6 +117,8 @@ class ProfileReadSerializer(serializers.Serializer): display_name = serializers.CharField() is_visible = serializers.BooleanField() show_attended_events = serializers.BooleanField() + show_certifications = serializers.BooleanField() + certifications = serializers.ListField(child=serializers.CharField()) headline = serializers.CharField() institution = serializers.CharField() bio = serializers.CharField() diff --git a/backend/apps/makerspaces/profile_services.py b/backend/apps/makerspaces/profile_services.py index 50bb1e99..97bc051f 100644 --- a/backend/apps/makerspaces/profile_services.py +++ b/backend/apps/makerspaces/profile_services.py @@ -25,6 +25,15 @@ def display_name_for(membership): return user.display_name or user.get_full_name().strip() or user.username +def _published_certifications(membership, profile): + """Live certification names, only once the member has opted in (consent, not config).""" + if not profile.show_certifications: + return [] + from apps.machines.certifications import live_certification_names + + return list(live_certification_names(membership)) + + def read_profile(membership, *, include_activity=True, local_activity_only=False): # Reads must not call the write-side ``profile_for`` helper. An unsaved model # instance gives serializers the model defaults without publishing a row merely @@ -37,6 +46,8 @@ def read_profile(membership, *, include_activity=True, local_activity_only=False "display_name": display_name_for(membership), "is_visible": profile.is_visible, "show_attended_events": profile.show_attended_events, + "show_certifications": profile.show_certifications, + "certifications": _published_certifications(membership, profile), "headline": profile.headline, "institution": profile.institution, "bio": profile.bio, @@ -132,8 +143,8 @@ def save_profile(membership, data): project_count_before = profile.projects.count() fields = [] for field in ( - "is_visible", "show_attended_events", "headline", "institution", "bio", - "interests", "languages", "education", + "is_visible", "show_attended_events", "show_certifications", "headline", + "institution", "bio", "interests", "languages", "education", ): if field in data: setattr(profile, field, data[field]) @@ -178,7 +189,7 @@ def _audit_profile_saved( copy member PII into a store that is deliberately impossible to edit or delete. The meta therefore names the fields touched and the boolean publication transitions. """ - profile.refresh_from_db(fields=["is_visible", "show_attended_events"]) + profile.refresh_from_db(fields=["is_visible", "show_attended_events", "show_certifications"]) audit.record( membership.user, "member.profile_updated", @@ -189,6 +200,7 @@ def _audit_profile_saved( "visibility_changed": was_visible != profile.is_visible, "is_visible": profile.is_visible, "attended_events_shown": profile.show_attended_events, + "certifications_shown": profile.show_certifications, "attended_events_changed": ( attended_events_were_shown != profile.show_attended_events ), @@ -246,39 +258,11 @@ def save_projects(profile, rows): ).delete() -def directory(makerspace): - """Visible profiles, plus a count of everyone who did not opt in.""" - memberships = MakerspaceMembership.objects.filter( - makerspace=makerspace, status="active", user__is_active=True - ).select_related("user", "profile") - members, hidden = [], 0 - for membership in memberships: - profile = getattr(membership, "profile", None) - # No profile row at all is the same answer as one that is not visible: nobody - # is listed until they choose to be. - if profile is None or not profile.is_visible: - hidden += 1 - continue - members.append( - { - "membership_id": membership.pk, - "display_name": display_name_for(membership), - "headline": profile.headline, - "avatar_url": public_image_storage.public_url(profile.avatar_key) or None, - } - ) - members.sort(key=lambda row: row["display_name"].lower()) - return {"members": members, "hidden_count": hidden} - - -def visible_profile(makerspace, membership_id, *, local_activity_only=False): - """One other member's profile, or None when it is not theirs to see.""" - membership = MakerspaceMembership.objects.select_related("user", "profile").filter( - pk=membership_id, makerspace=makerspace, status="active", user__is_active=True - ).first() - if membership is None: - return None - profile = getattr(membership, "profile", None) - if profile is None or not profile.is_visible: - return None - return read_profile(membership, local_activity_only=local_activity_only) +# The member-visible directory lives in its own module (file-size ceiling); re-exported so +# `profile_services.directory` / `.visible_profile` keep resolving for views and tests. +from apps.makerspaces.profile_directory import directory, visible_profile # noqa: E402 + +__all__ = [ + "directory", "display_name_for", "profile_activity", "profile_for", "read_profile", + "save_profile", "save_projects", "visible_profile", +] diff --git a/backend/apps/makerspaces/profile_views.py b/backend/apps/makerspaces/profile_views.py index 75f9bdea..6c33a1f5 100644 --- a/backend/apps/makerspaces/profile_views.py +++ b/backend/apps/makerspaces/profile_views.py @@ -5,7 +5,7 @@ not something a passer-by gets to enumerate, even reduced to display names. """ -from drf_spectacular.utils import OpenApiResponse, extend_schema +from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView @@ -71,12 +71,23 @@ def put(self, request, makerspace_id): class MemberDirectoryView(MemberProfileBaseView): @extend_schema( tags=["Member profile"], summary="List members who published a profile", - request=None, responses={200: DirectorySerializer, **ERRORS}, + request=None, + parameters=[ + OpenApiParameter( + name="q", type=str, location=OpenApiParameter.QUERY, required=False, + description="Match on username, display name, headline or institution. Contact details are never searched.", + ), + ], + responses={200: DirectorySerializer, **ERRORS}, ) def get(self, request, makerspace_id): membership = self.membership(request, makerspace_id) return Response( - DirectorySerializer(profile_services.directory(membership.makerspace)).data + DirectorySerializer( + profile_services.directory( + membership.makerspace, request.query_params.get("q", "") + ) + ).data ) diff --git a/backend/apps/makerspaces/request_access.py b/backend/apps/makerspaces/request_access.py index eec65387..fa73bdf6 100644 --- a/backend/apps/makerspaces/request_access.py +++ b/backend/apps/makerspaces/request_access.py @@ -77,6 +77,31 @@ def anonymous_requests_allowed(makerspace) -> bool: return effective_policy(makerspace) == ANYONE +def require_current_term(makerspace, membership): + """The optional "lapsed members cannot borrow" rule, applied AFTER membership passed. + + Plans are optional, so this is deliberately narrow: a member who has never held a + term is untouched, and the flag off means nothing here runs. Only a member whose + every term has expired or been cancelled is refused -- and refused with the same + exception a non-member gets, because that is what the operator asked for. No new + access state: `MakerspaceMembership.status` and `User.access_status` stay as they are. + """ + if membership is None or not makerspace.lapsed_members_cannot_borrow: + return + from django.utils import timezone + + from apps.makerspaces.models import MembershipTerm + + terms = MembershipTerm.objects.filter(membership=membership) + if not terms.exists() or terms.filter( + status=MembershipTerm.Status.ACTIVE, ends_at__gt=timezone.now() + ).exists(): + return + from apps.presence.guard import MemberPresenceRequired + + raise MemberPresenceRequired() + + class RequestAccessConflict(Exception): """Account-less requests were asked for while `membership` is installed.""" diff --git a/backend/apps/makerspaces/roles.py b/backend/apps/makerspaces/roles.py index 6621ce0e..0b85202d 100644 --- a/backend/apps/makerspaces/roles.py +++ b/backend/apps/makerspaces/roles.py @@ -12,7 +12,7 @@ """ DEFAULT_ROLE_DEFINITIONS = ( - ("space_manager", "Space Manager", ["accept_request", "assign_box", "edit_inventory", "issue_direct_loan", "issue_request", "manage_bookings", "manage_events", "manage_machines", "manage_makerspace", "manage_printing", "manage_qr", "reject_request", "return_request", "upload_evidence", "view_audit", "view_inventory"]), + ("space_manager", "Space Manager", ["accept_request", "assign_box", "edit_inventory", "issue_direct_loan", "issue_request", "manage_bookings", "manage_events", "manage_machines", "manage_makerspace", "manage_member_cards", "manage_printing", "manage_qr", "reject_request", "return_request", "upload_evidence", "view_audit", "view_inventory"]), ("inventory_manager", "Inventory Manager", ["accept_request", "assign_box", "edit_inventory", "issue_direct_loan", "issue_request", "manage_qr", "reject_request", "return_request", "upload_evidence", "view_audit", "view_inventory"]), ("machine_manager", "Machine Manager", ["manage_machines"]), ) diff --git a/backend/apps/makerspaces/tasks_membership.py b/backend/apps/makerspaces/tasks_membership.py new file mode 100644 index 00000000..ac826bb5 --- /dev/null +++ b/backend/apps/makerspaces/tasks_membership.py @@ -0,0 +1,10 @@ +"""Beat-less-safe membership renewal sweep (registered in `run_scheduled_tasks`).""" + +from celery import shared_task + + +@shared_task(name="apps.makerspaces.tasks_membership.run_membership_renewals_task") +def run_membership_renewals_task(): + from apps.makerspaces.membership_plan_services import run_membership_renewals + + return run_membership_renewals() diff --git a/backend/apps/makerspaces/urls.py b/backend/apps/makerspaces/urls.py index e69243b8..ee7a4c8c 100644 --- a/backend/apps/makerspaces/urls.py +++ b/backend/apps/makerspaces/urls.py @@ -7,8 +7,14 @@ MemberWaiverAcceptView, MemberWaiverView, MyMembershipsView, PublicMembershipRequestView, ) +from apps.makerspaces.views_invitation_requests import PublicInvitationRequestView from apps.makerspaces.views_member_referrals import MemberReferralView from apps.makerspaces.member_activity_views import MemberActivityView +from apps.makerspaces.member_card_views import ( + MemberCardOwnView, + MemberCardPhotoView, + MemberCardPreviewView, +) from apps.makerspaces.profile_image_views import MemberProfileImageView from apps.makerspaces.profile_views import ( MemberDirectoryDetailView, @@ -19,6 +25,7 @@ path("bootstrap", BootstrapView.as_view(), name="tenant-bootstrap"), path("config", PublicConfigView.as_view(), name="public-config"), path("public//membership-requests", PublicMembershipRequestView.as_view(), name="public-membership-request"), + path("public//invitation-requests", PublicInvitationRequestView.as_view(), name="public-invitation-request"), path("memberships/me", MyMembershipsView.as_view(), name="my-memberships"), path("memberships/invitations", InvitationDiscoveryView.as_view(), name="membership-invitations"), path("memberships/invitations//claim", InvitationClaimView.as_view(), name="membership-invitation-claim"), @@ -33,6 +40,9 @@ path("member/makerspaces//referrals", MemberReferralView.as_view(), name="member-referrals"), path("member/makerspaces//profile", MemberProfileView.as_view(), name="member-profile"), path("member/makerspaces//profile/image", MemberProfileImageView.as_view(), name="member-profile-image"), + path("member/makerspaces//member-card", MemberCardOwnView.as_view(), name="member-card"), + path("member/makerspaces//member-card/photo", MemberCardPhotoView.as_view(), name="member-card-photo"), + path("member/makerspaces//member-card/preview.pdf", MemberCardPreviewView.as_view(), name="member-card-preview"), path("member/makerspaces//directory", MemberDirectoryView.as_view(), name="member-directory"), path( "member/makerspaces//directory/", diff --git a/backend/apps/makerspaces/views.py b/backend/apps/makerspaces/views.py index 549c5b74..25fbb214 100644 --- a/backend/apps/makerspaces/views.py +++ b/backend/apps/makerspaces/views.py @@ -53,6 +53,7 @@ }, ), "modules": serializers.ListField(child=serializers.CharField()), + "edition": serializers.CharField(), "features": serializers.ListField(child=serializers.CharField()), "workflows": serializers.ListField(child=serializers.CharField()), "theme": serializers.JSONField(), diff --git a/backend/apps/makerspaces/views_invitation_requests.py b/backend/apps/makerspaces/views_invitation_requests.py new file mode 100644 index 00000000..6edad525 --- /dev/null +++ b/backend/apps/makerspaces/views_invitation_requests.py @@ -0,0 +1,76 @@ +"""Public "ask to be invited" endpoint on the makerspace site. + +Anonymous, throttled per client, honeypotted exactly like `RequestSubmitView`: the hidden +`website` field being filled returns the SAME 202 acknowledgement a real submission gets +and stores nothing, so a bot cannot tell the two apart. The module gate answers 404 -- +a makerspace without `membership` has no invitation queue to fill. +""" +from django.http import Http404 +from django.shortcuts import get_object_or_404 +from drf_spectacular.utils import OpenApiResponse, extend_schema +from rest_framework import serializers, status +from rest_framework.permissions import AllowAny +from rest_framework.response import Response +from rest_framework.throttling import ScopedRateThrottle +from rest_framework.views import APIView + +from apps.hardware_requests.exceptions import ErrorSerializer +from apps.hardware_requests.public_views import _honeypot_filled +from apps.makerspaces.editions import require_public_surface +from apps.makerspaces.invitation_request_services import submit_invitation_request +from apps.makerspaces.platform import module_enabled +from apps.makerspaces.servability import servable_queryset + +ACK = {"detail": "Thanks - the makerspace will be in touch."} + + +class InvitationRequestCreateSerializer(serializers.Serializer): + name = serializers.CharField(max_length=200) + email = serializers.EmailField(max_length=254) + phone = serializers.CharField(max_length=32, required=False, allow_blank=True, default="") + message = serializers.CharField( + max_length=1000, required=False, allow_blank=True, default="" + ) + website = serializers.CharField(required=False, allow_blank=True, write_only=True) + + +class InvitationRequestAckSerializer(serializers.Serializer): + detail = serializers.CharField() + + +class PublicInvitationRequestView(APIView): + permission_classes = [AllowAny] + throttle_classes = [ScopedRateThrottle] + throttle_scope = "public_invitation_request" + + @extend_schema( + tags=["Memberships"], + summary="Ask a makerspace to invite you", + request=InvitationRequestCreateSerializer, + responses={ + 202: InvitationRequestAckSerializer, + 400: ErrorSerializer, + 404: OpenApiResponse(description="Unknown makerspace or membership disabled."), + 429: ErrorSerializer, + }, + ) + def post(self, request, makerspace_slug): + makerspace = get_object_or_404(servable_queryset(), slug=makerspace_slug) + # The honeypot precedes the module check and the serializer, as on request submit: + # a bot gets its fake success before it learns anything about this space. + if _honeypot_filled(request.data): + return Response(ACK, status=status.HTTP_202_ACCEPTED) + require_public_surface("membership") + if not module_enabled(makerspace, "membership"): + raise Http404() + serializer = InvitationRequestCreateSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + data = serializer.validated_data + submit_invitation_request( + makerspace, + name=data["name"], + email=data["email"], + phone=data.get("phone", ""), + message=data.get("message", ""), + ) + return Response(ACK, status=status.HTTP_202_ACCEPTED) diff --git a/backend/apps/operations/admin.py b/backend/apps/operations/admin.py index 723fe443..bbf86d32 100644 --- a/backend/apps/operations/admin.py +++ b/backend/apps/operations/admin.py @@ -23,6 +23,9 @@ from apps.operations.qr_zip import build_batch_zip from config.admin_access import SuperuserOnlyModelAdmin +# Imported for its `@admin.register` side effect (the split pattern from CLAUDE.md). +from apps.operations import admin_report_schedules # noqa: F401,E402 + class StockTransferLineInline(TabularInline): # Transfer lines are created by services.apply_stock_transfer with the parent transfer. diff --git a/backend/apps/operations/admin_report_schedules.py b/backend/apps/operations/admin_report_schedules.py new file mode 100644 index 00000000..8ef49674 --- /dev/null +++ b/backend/apps/operations/admin_report_schedules.py @@ -0,0 +1,49 @@ +"""Read-only `/control/` view of report schedules and their deliveries. + +Schedules are created and edited through the staff API so the RBAC check against the +report's action and the audit entries always fire; the admin is an inspection surface. +""" + +from django.contrib import admin +from unfold.admin import ModelAdmin, TabularInline + +from apps.operations.models_report_schedules import ReportDelivery, ReportSchedule +from config.admin_access import SuperuserOnlyModelAdmin + + +class ReportDeliveryInline(TabularInline): + model = ReportDelivery + extra = 0 + can_delete = False + readonly_fields = ("status", "error", "object_key", "created_at", "expires_at") + fields = readonly_fields + + def has_add_permission(self, request, obj=None): + return False + + def has_change_permission(self, request, obj=None): + return False + + +@admin.register(ReportSchedule) +class ReportScheduleAdmin(SuperuserOnlyModelAdmin, ModelAdmin): + list_display = ( + "id", "makerspace", "report_key", "cadence", "format", "is_active", "next_run_at", "last_run_at", + ) + list_filter = ("cadence", "format", "is_active", "makerspace") + readonly_fields = ( + "makerspace", "report_key", "filters", "grain", "format", "cadence", "next_run_at", + "last_run_at", "is_active", "destination", "recipient_emails", "created_by", + "created_at", "updated_at", + ) + fields = readonly_fields + inlines = (ReportDeliveryInline,) + + def has_add_permission(self, request): + return False + + def has_change_permission(self, request, obj=None): + return False + + def has_delete_permission(self, request, obj=None): + return False diff --git a/backend/apps/operations/live.py b/backend/apps/operations/live.py new file mode 100644 index 00000000..4816f7de --- /dev/null +++ b/backend/apps/operations/live.py @@ -0,0 +1,95 @@ +"""Live-update fan-out: one compact event per committed state change, per makerspace. + +The audit log is already the universal record of every state change ("every state-changing +endpoint emits its audit entry"), so it is also the one place to publish from: ``record()`` +schedules ``publish_audit_event`` on commit, and no workflow module needs a second hook. The +payload is deliberately tiny and PII-free — an action name and a target reference. Browsers +never render it; they invalidate the matching TanStack query keys and refetch through the +normal, RBAC-scoped endpoints. + +Redis pub/sub is the transport because the broker is already there for Celery. Publishing is +best-effort: a failure is logged and swallowed, because a live hint must never break the +request that produced the audit row. +""" +import json +import logging +from datetime import UTC, datetime + +from django.conf import settings +from django.db import transaction + +logger = logging.getLogger(__name__) + +CHANNEL_PREFIX = "spaceworks:live" + + +def makerspace_channel(makerspace_id): + return f"{CHANNEL_PREFIX}:makerspace:{int(makerspace_id)}" + + +def user_channel(user_id): + return f"{CHANNEL_PREFIX}:user:{int(user_id)}" + + +def live_redis_url(): + """The Redis the stream uses; empty means live updates are off for this deployment.""" + url = getattr(settings, "LIVE_REDIS_URL", "") or getattr(settings, "CELERY_BROKER_URL", "") + if getattr(settings, "CELERY_TASK_ALWAYS_EAGER", False) and not getattr(settings, "LIVE_REDIS_URL", ""): + # No broker configured (documented local flow): nothing to publish to. + return "" + return url + + +def redis_client(): + url = live_redis_url() + if not url: + return None + try: + import redis + except ImportError: # pragma: no cover - redis is a hard requirement in practice + return None + return redis.Redis.from_url(url, socket_timeout=1, socket_connect_timeout=1) + + +def _payload(kind, makerspace_id, target_type, target_id, actor_id): + return json.dumps( + { + "kind": kind, + "makerspace_id": makerspace_id, + "target_type": target_type, + "target_id": target_id, + "actor_id": actor_id, + "ts": datetime.now(UTC).isoformat(timespec="milliseconds"), + }, + separators=(",", ":"), + ) + + +def publish(channel, message): + client = redis_client() + if client is None: + return False + try: + client.publish(channel, message) + except Exception: + logger.warning("live_publish_failed", extra={"channel": channel}) + return False + return True + + +def publish_audit_event(*, action, makerspace_id, target_type, target_id, actor_id): + """Called by ``audit.services.record``; runs after the surrounding transaction commits.""" + if not live_redis_url(): + return + + def _send(): + message = _payload(action, makerspace_id, target_type, target_id, actor_id) + if makerspace_id is not None: + publish(makerspace_channel(makerspace_id), message) + if actor_id is not None: + publish(user_channel(actor_id), message) + + try: + transaction.on_commit(_send, robust=True) + except Exception: + logger.warning("live_publish_schedule_failed", extra={"action": action}) diff --git a/backend/apps/operations/management/commands/run_scheduled_tasks.py b/backend/apps/operations/management/commands/run_scheduled_tasks.py index 5cb59424..b4125a9c 100644 --- a/backend/apps/operations/management/commands/run_scheduled_tasks.py +++ b/backend/apps/operations/management/commands/run_scheduled_tasks.py @@ -62,6 +62,13 @@ "apps.makerspaces.tasks.refresh_github_contributions_task", 24 * 60, ), + # Expires membership terms and raises the one renewal charge per term inside the + # window. Hourly is plenty: the window is seven days wide. + ( + "membership-renewals", + "apps.makerspaces.tasks_membership.run_membership_renewals_task", + 60, + ), # Same fixed-hour-versus-interval reasoning as above. Without this entry a beat-less # cloud deployment would retain expired export archives -- and the download bearer # tokens that reach them -- indefinitely. @@ -75,6 +82,13 @@ "apps.operations.tasks.finalize_report_rollups_task", 24 * 60, ), + # Due schedules are claimed with `skip_locked` and advance `next_run_at` before any + # work, so a 15-minute cadence is a latency bound, not a duplicate-delivery risk. + ( + "report-schedules", + "apps.operations.tasks_report_schedules.run_report_schedules_task", + 15, + ), ( "scheduled-deployment-backup", "apps.backup.tasks.scheduled_deployment_backup_task", diff --git a/backend/apps/operations/management/commands/seed_e2e.py b/backend/apps/operations/management/commands/seed_e2e.py new file mode 100644 index 00000000..d6b85a4b --- /dev/null +++ b/backend/apps/operations/management/commands/seed_e2e.py @@ -0,0 +1,130 @@ +"""Seed one disposable makerspace for the Playwright suite (frontend/e2e/). + +Every run creates a FRESH makerspace with a unique slug and writes its identifiers to a JSON +file the specs read, because the loan spine is built on immutable rows (scan events, evidence +rows, audit entries) that cannot be deleted to "reset" an existing one. Old e2e makerspaces +simply accumulate in a development database; CI databases are ephemeral. + +Refuses to run against anything that is not a development database: `DEBUG` must be on, or +`E2E_SEED_ALLOWED=1` must be set explicitly (the CI job sets it). +""" +import json +import os +import secrets +from pathlib import Path +from types import SimpleNamespace + +from django.conf import settings +from django.core.management.base import BaseCommand, CommandError +from django.db import transaction + +from apps.accounts.models import User +from apps.backup.custody import initialize_custody_state +from apps.boxes.models import Box +from apps.hardware_requests import request_workflow +from apps.inventory.categories import ensure_default_categories +from apps.inventory.models import InventoryProduct, PublicAvailabilityMode, TrackingMode +from apps.makerspaces.models import Makerspace, MakerspaceMembership +from apps.makerspaces.module_install import apply_profile +from apps.makerspaces.module_profiles import RECOMMENDED + +DEFAULT_PASSWORD = "e2e-pass-12345" +SECRET_LOCATION = "E2E SECRET SHELF 42" +PRODUCT_NAME = "E2E Cordless Drill" +PENDING_REQUEST_FOR = "E2E robotics workshop" +PROBE_REQUEST_FOR = "E2E hard-rules probe" + + +class Command(BaseCommand): + help = "Seed a fresh makerspace, staff, member, product, box and two requests for the e2e suite." + + def add_arguments(self, parser): + parser.add_argument("--slug", default=None, help="Makerspace slug (default: e2e-).") + parser.add_argument("--password", default=os.environ.get("E2E_PASSWORD", DEFAULT_PASSWORD)) + parser.add_argument("--write-json", default=None, help="Write the seeded identifiers here.") + parser.add_argument("--reset", action="store_true", help="Accepted for symmetry; every run is fresh.") + + def handle(self, *args, **options): + if not settings.DEBUG and os.environ.get("E2E_SEED_ALLOWED") != "1": + raise CommandError( + "seed_e2e only runs against a development database (DEBUG=True) or with " + "E2E_SEED_ALLOWED=1 set explicitly." + ) + token = secrets.token_hex(3) + slug = options["slug"] or f"e2e-{token}" + password = options["password"] + with transaction.atomic(): + seeded = self._seed(slug, token, password) + payload = json.dumps(seeded, indent=2) + if options["write_json"]: + Path(options["write_json"]).write_text(payload + "\n") + self.stdout.write(payload) + + def _user(self, username, role, password, **flags): + user = User.objects.create_user( + username=username, email=f"{username}@e2e.local", password=password, role=role, + access_status=User.AccessStatus.ACTIVE, **flags, + ) + user.must_change_password = False + user.save(update_fields=["must_change_password"]) + return user + + def _seed(self, slug, token, password): + superadmin = User.objects.filter(is_superuser=True).order_by("pk").first() or self._user( + f"e2e_root_{token}", User.Role.SUPERADMIN, password, is_staff=True, is_superuser=True, + ) + makerspace = Makerspace.objects.create( + slug=slug, name="E2E Makerspace", location="E2E Test Bench", + public_inventory_enabled=True, superadmin_access_enabled=True, created_by=superadmin, + ) + initialize_custody_state(makerspace.pk) + apply_profile(makerspace, RECOMMENDED, actor=superadmin) + ensure_default_categories(makerspace) + + manager = self._user(f"e2e_manager_{token}", User.Role.SPACE_MANAGER, password) + MakerspaceMembership.objects.create( + user=manager, makerspace=makerspace, role=MakerspaceMembership.Role.SPACE_MANAGER + ) + member = self._user(f"e2e_member_{token}", User.Role.REQUESTER, password) + MakerspaceMembership.objects.create( + user=member, makerspace=makerspace, role=MakerspaceMembership.Role.INVENTORY_MANAGER + ) + + box = Box.objects.create( + makerspace=makerspace, code=f"e2ebox{token}", label="E2E Shelf A", location="E2E Test Bench", + ) + product = InventoryProduct.objects.create( + makerspace=makerspace, box=box, name=PRODUCT_NAME, + description="Seeded for the end-to-end suite.", tracking_mode=TrackingMode.QUANTITY, + total_quantity=5, available_quantity=5, is_public=True, show_public_count=True, + public_availability_mode=PublicAvailabilityMode.EXACT_COUNT, + storage_location=SECRET_LOCATION, + ) + snapshot = SimpleNamespace( + username=member.username, name="E2E Member", email=member.email, phone="", + contact_verified=True, + ) + pending = request_workflow.submit_request( + makerspace, [{"product": product, "quantity": 1}], PENDING_REQUEST_FOR, + requester_principal=member, contact_snapshot=snapshot, audit_actor=member, + ) + probe = request_workflow.submit_request( + makerspace, [{"product": product, "quantity": 1}], PROBE_REQUEST_FOR, + requester_principal=member, contact_snapshot=snapshot, audit_actor=member, + ) + request_workflow.accept_request(manager, probe) + return { + "slug": slug, + "makerspace_id": makerspace.pk, + "makerspace_name": makerspace.name, + "manager": {"username": manager.username, "password": password}, + "member": {"username": member.username, "password": password}, + "box_code": box.code, + "box_label": box.label, + "product": product.name, + "secret_location": SECRET_LOCATION, + "pending_request_for": PENDING_REQUEST_FOR, + "pending_request_id": pending.pk, + "probe_request_for": PROBE_REQUEST_FOR, + "probe_request_id": probe.pk, + } diff --git a/backend/apps/operations/migrations/0009_report_schedules.py b/backend/apps/operations/migrations/0009_report_schedules.py new file mode 100644 index 00000000..e071f559 --- /dev/null +++ b/backend/apps/operations/migrations/0009_report_schedules.py @@ -0,0 +1,63 @@ +# Generated by Django 6.0.8 on 2026-09-03 23:56 + +import apps.operations.models_report_schedules +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('integrations', '0025_webhook_channel'), + ('makerspaces', '0070_memberprofile_show_certifications'), + ('operations', '0008_report_rollups'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='ReportSchedule', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('report_key', models.CharField(max_length=80)), + ('filters', models.JSONField(blank=True, default=dict, validators=[apps.operations.models_report_schedules.validate_schedule_filters])), + ('grain', models.CharField(default='day', max_length=8)), + ('format', models.CharField(choices=[('csv', 'CSV'), ('xlsx', 'XLSX')], default='csv', max_length=8)), + ('cadence', models.CharField(choices=[('daily', 'Daily'), ('weekly', 'Weekly'), ('monthly', 'Monthly')], default='weekly', max_length=8)), + ('next_run_at', models.DateTimeField()), + ('last_run_at', models.DateTimeField(blank=True, null=True)), + ('is_active', models.BooleanField(default=True)), + ('recipient_emails', models.JSONField(blank=True, default=list, validators=[apps.operations.models_report_schedules.validate_recipient_emails])), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ('destination', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='report_schedules', to='integrations.notificationdestination')), + ('makerspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='report_schedules', to='makerspaces.makerspace')), + ], + ), + migrations.CreateModel( + name='ReportDelivery', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('object_key', models.CharField(blank=True, default='', max_length=255)), + ('status', models.CharField(choices=[('sent', 'Sent'), ('failed', 'Failed')], max_length=8)), + ('error', models.TextField(blank=True, default='')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('expires_at', models.DateTimeField(blank=True, null=True)), + ('schedule', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='deliveries', to='operations.reportschedule')), + ], + ), + migrations.AddIndex( + model_name='reportschedule', + index=models.Index(fields=['is_active', 'next_run_at'], name='report_schedule_due_idx'), + ), + migrations.AddIndex( + model_name='reportschedule', + index=models.Index(fields=['makerspace', '-created_at'], name='report_schedule_ms_created_idx'), + ), + migrations.AddIndex( + model_name='reportdelivery', + index=models.Index(fields=['schedule', '-created_at'], name='report_delivery_sched_idx'), + ), + ] diff --git a/backend/apps/operations/models.py b/backend/apps/operations/models.py index 0b610ce5..508194e0 100644 --- a/backend/apps/operations/models.py +++ b/backend/apps/operations/models.py @@ -13,6 +13,10 @@ ReportMetricRollup, ReportRollupCursor, ) +from apps.operations.models_report_schedules import ( # noqa: F401,E402 + ReportDelivery, + ReportSchedule, +) class StockTransfer(models.Model): diff --git a/backend/apps/operations/models_report_schedules.py b/backend/apps/operations/models_report_schedules.py new file mode 100644 index 00000000..5d7695e6 --- /dev/null +++ b/backend/apps/operations/models_report_schedules.py @@ -0,0 +1,124 @@ +"""Scheduled report delivery: a recurring export and the record of each run. + +A schedule names a report definition, the filters the manual export would take, a cadence +and where the file goes -- a chat destination, up to ten email addresses, or both. Each run +writes ONE `ReportDelivery` row: the private object key the file was stored under, whether +every channel accepted the link, and when the link (and the object) expire. Deliveries never +carry bytes into chat channels; they carry a short-lived signed download URL. +""" + +import calendar +from datetime import timedelta + +from django.conf import settings +from django.core.exceptions import ValidationError +from django.core.validators import validate_email +from django.db import models + +MAX_RECIPIENT_EMAILS = 10 + + +def validate_recipient_emails(value): + if not isinstance(value, list): + raise ValidationError("Recipient emails must be a list.") + if len(value) > MAX_RECIPIENT_EMAILS: + raise ValidationError(f"At most {MAX_RECIPIENT_EMAILS} recipient emails are allowed.") + for item in value: + if not isinstance(item, str): + raise ValidationError("Recipient emails must be strings.") + validate_email(item) + + +def validate_schedule_filters(value): + if not isinstance(value, dict): + raise ValidationError("Report filters must be an object.") + + +def step_cadence(moment, cadence): + """The next occurrence after `moment` for one cadence step.""" + if cadence == ReportSchedule.Cadence.DAILY: + return moment + timedelta(days=1) + if cadence == ReportSchedule.Cadence.WEEKLY: + return moment + timedelta(days=7) + year = moment.year + (1 if moment.month == 12 else 0) + month = 1 if moment.month == 12 else moment.month + 1 + day = min(moment.day, calendar.monthrange(year, month)[1]) + return moment.replace(year=year, month=month, day=day) + + +def advance_next_run(next_run_at, cadence, now): + """Advance from the SCHEDULED time, not from `now`, so the clock does not drift; but + skip every occurrence already in the past so a long outage produces one run, not a + catch-up storm of stale reports.""" + following = step_cadence(next_run_at, cadence) + while following <= now: + following = step_cadence(following, cadence) + return following + + +class ReportSchedule(models.Model): + class Format(models.TextChoices): + CSV = "csv", "CSV" + XLSX = "xlsx", "XLSX" + + class Cadence(models.TextChoices): + DAILY = "daily", "Daily" + WEEKLY = "weekly", "Weekly" + MONTHLY = "monthly", "Monthly" + + makerspace = models.ForeignKey( + "makerspaces.Makerspace", on_delete=models.CASCADE, related_name="report_schedules" + ) + report_key = models.CharField(max_length=80) + filters = models.JSONField(default=dict, blank=True, validators=[validate_schedule_filters]) + grain = models.CharField(max_length=8, default="day") + format = models.CharField(max_length=8, choices=Format.choices, default=Format.CSV) + cadence = models.CharField(max_length=8, choices=Cadence.choices, default=Cadence.WEEKLY) + next_run_at = models.DateTimeField() + last_run_at = models.DateTimeField(null=True, blank=True) + is_active = models.BooleanField(default=True) + # SET_NULL: deleting a room must not delete the schedule's history or its email leg. + destination = models.ForeignKey( + "integrations.NotificationDestination", + null=True, blank=True, on_delete=models.SET_NULL, related_name="report_schedules", + ) + recipient_emails = models.JSONField(default=list, blank=True, validators=[validate_recipient_emails]) + # The run re-checks that this person still holds the report's action: a schedule must + # not keep disclosing a report its creator can no longer open. + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.SET_NULL, related_name="+" + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + indexes = [ + models.Index(fields=["is_active", "next_run_at"], name="report_schedule_due_idx"), + models.Index(fields=["makerspace", "-created_at"], name="report_schedule_ms_created_idx"), + ] + + def __str__(self): + return f"{self.makerspace_id}:{self.report_key} {self.cadence} ({self.format})" + + +class ReportDelivery(models.Model): + class Status(models.TextChoices): + SENT = "sent", "Sent" + FAILED = "failed", "Failed" + + schedule = models.ForeignKey(ReportSchedule, on_delete=models.CASCADE, related_name="deliveries") + # Private bucket key under `reports//...`; blanked once the object is + # swept after `expires_at`. Empty when the build itself failed. + object_key = models.CharField(max_length=255, blank=True, default="") + status = models.CharField(max_length=8, choices=Status.choices) + error = models.TextField(blank=True, default="") + created_at = models.DateTimeField(auto_now_add=True) + expires_at = models.DateTimeField(null=True, blank=True) + + class Meta: + indexes = [ + models.Index(fields=["schedule", "-created_at"], name="report_delivery_sched_idx"), + ] + + def __str__(self): + return f"delivery {self.pk} of schedule {self.schedule_id}: {self.status}" diff --git a/backend/apps/operations/org_report_aggregate.py b/backend/apps/operations/org_report_aggregate.py index ac663ffc..1b831cfb 100644 --- a/backend/apps/operations/org_report_aggregate.py +++ b/backend/apps/operations/org_report_aggregate.py @@ -19,8 +19,11 @@ def aggregate_rows(report_key, rows_by_space, *, limit): if report_key == "qr-scans": return _group_sum(rows, ("context",), ("count",), limit) if report_key == "payment-reconciliation": + # `settlement_method` is part of the GRAIN. Omitting it merged cash and UPI rows + # that shared a currency, subject and status, and the projection then emitted a + # null method -- silently undoing the split the report exists for. return _group_sum( - rows, ("currency", "subject_type", "status"), + rows, ("currency", "subject_type", "status", "settlement_method"), ("payment_count", "amount_total", "outstanding_amount"), limit, ) if report_key == "event-attendance": diff --git a/backend/apps/operations/org_report_scope.py b/backend/apps/operations/org_report_scope.py index b3997336..4ae6cfb6 100644 --- a/backend/apps/operations/org_report_scope.py +++ b/backend/apps/operations/org_report_scope.py @@ -48,6 +48,9 @@ # Active accounts are distinct-person metrics that may refer to the same person # in several owned makerspaces and therefore cannot be summed safely. "community-engagement", + # Certified-member counts are distinct-person metrics per makerspace; one member + # trained in two owned spaces would be double-counted by a naive sum. + "certification-coverage", # Enabled/available/rollup state is per makerspace; combining it would mask the # specific unhealthy or stale tenant that an operator must repair. "module-operational-health", diff --git a/backend/apps/operations/org_report_strategies.py b/backend/apps/operations/org_report_strategies.py index 8d3879c1..cf0c4e99 100644 --- a/backend/apps/operations/org_report_strategies.py +++ b/backend/apps/operations/org_report_strategies.py @@ -89,10 +89,21 @@ def _strategy(kind, groups, totals, *, breakdown=(), ordering=(), limit=None, te text=(("machine_name", "carry"), ("machine_type", "carry"), ("is_active", "carry")), ), "payment-reconciliation": _strategy( - AggregationKind.GROUP_SUM, ("currency", "subject_type", "status"), - ("currency", "subject_type", "status", "payment_count", "amount_total", "outstanding_amount"), - ordering=("currency", "subject_type", "status"), - text=(("currency", "group and carry"), ("subject_type", "group and carry"), ("status", "group and carry")), + AggregationKind.GROUP_SUM, + ("currency", "subject_type", "status", "settlement_method"), + ( + "currency", "subject_type", "status", "settlement_method", + "payment_count", "amount_total", "outstanding_amount", + ), + ordering=("currency", "subject_type", "status", "settlement_method"), + text=( + ("currency", "group and carry"), + ("subject_type", "group and carry"), + ("status", "group and carry"), + # Part of the grain, not a summable measure: two spaces both taking cash + # aggregate into one cash row, and cash never merges with a card total. + ("settlement_method", "group and carry"), + ), ), "most-lent": _strategy( AggregationKind.ROW_UNION, ("product_id",), diff --git a/backend/apps/operations/report_coverage.py b/backend/apps/operations/report_coverage.py index 195de61f..138f6367 100644 --- a/backend/apps/operations/report_coverage.py +++ b/backend/apps/operations/report_coverage.py @@ -39,6 +39,7 @@ class ModuleReportCoverage: "notifications": ModuleReportCoverage("composite", ("communications-health",)), "email": ModuleReportCoverage("composite", ("communications-health",)), "slack": ModuleReportCoverage("composite", ("communications-health",)), + "webhook": ModuleReportCoverage("composite", ("communications-health",)), "mattermost": ModuleReportCoverage("composite", ("communications-health",)), "discord": ModuleReportCoverage("composite", ("communications-health",)), "payments": ModuleReportCoverage("substantive", ("payment-reconciliation",)), diff --git a/backend/apps/operations/report_definitions_coverage.py b/backend/apps/operations/report_definitions_coverage.py index aa56721d..85099734 100644 --- a/backend/apps/operations/report_definitions_coverage.py +++ b/backend/apps/operations/report_definitions_coverage.py @@ -35,7 +35,7 @@ "communications-health", "apps.integrations.reports_communications.build_communications_health", ("module_key", "channel", "feature", "status", "delivery_count", "attempt_count", "destination_count", "success_rate_percent", "unread_count", "last_activity_at"), required_action=rbac.Action.MANAGE_MAKERSPACE, title="Communications health", - chart_hint="stacked_bar", section_modules=("notifications", "email", "telegram", "slack", "mattermost", "discord"), + chart_hint="stacked_bar", section_modules=("notifications", "email", "telegram", "slack", "mattermost", "discord", "webhook"), ), ReportDefinition( "community-engagement", "apps.makerspaces.reports_community.build_community_engagement", @@ -43,12 +43,17 @@ title="Community engagement", chart_hint="line", grains=("day", "month"), section_modules=("membership", "member_accounts", "mobile"), ), + ReportDefinition( + "certification-coverage", "apps.machines.reports_certifications.build_certification_coverage", + ("machine_type", "certification_type", "gating_enabled", "required_for_service", "required_for_booking", "active_members", "certified_members", "expiring_30d", "revoked_grants", "coverage_percent"), + ("machines",), title="Certification coverage", chart_hint="grouped_bar", section_modules=("machines",), + ), ReportDefinition( "module-operational-health", "apps.operations.reports_module_health.build_module_operational_health", ("module_key", "enabled", "runtime_available", "coverage_kind", "activity_count", "failure_count", "last_activity_at", "rollup_watermark", "rollup_state"), required_action=rbac.Action.MANAGE_MAKERSPACE, title="Module operational health", chart_hint="status_grid", section_modules=( - "public_inventory", "request_workflow", "staff_admin", "guest_handover", "scanner", "printing", "telegram", "evidence_uploads", "qr_management", "bulk_import", "containers", "stock_transfers", "stocktake", "reports", "qr_print_batches", "asset_units", "procurement", "machines", "machine_service", "events", "bookings", "maintenance", "membership", "notifications", "email", "slack", "mattermost", "discord", "payments", "member_accounts", "mobile", "updates", + "public_inventory", "request_workflow", "staff_admin", "guest_handover", "scanner", "printing", "telegram", "evidence_uploads", "qr_management", "bulk_import", "containers", "stock_transfers", "stocktake", "reports", "qr_print_batches", "asset_units", "procurement", "machines", "machine_service", "events", "bookings", "maintenance", "membership", "notifications", "email", "slack", "mattermost", "discord", "webhook", "payments", "member_accounts", "mobile", "updates", ), ), ) diff --git a/backend/apps/operations/report_definitions_existing.py b/backend/apps/operations/report_definitions_existing.py index cce640d2..cf8cddf8 100644 --- a/backend/apps/operations/report_definitions_existing.py +++ b/backend/apps/operations/report_definitions_existing.py @@ -36,8 +36,16 @@ def _legacy(key, fields, *, exportable=True, summary=False, chart_hint="bar"): ), title="FabLab health", chart_hint="status_grid", section_modules=("events", "bookings", "machines", "maintenance")), ReportDefinition( "payment-reconciliation", "apps.operations.reports_payments.build_payment_reconciliation", - ("currency", "subject_type", "status", "payment_count", "amount_total", "outstanding_amount"), + ( + "currency", "subject_type", "status", "settlement_method", + "payment_count", "amount_total", "outstanding_amount", + ), required_action=rbac.Action.MANAGE_MAKERSPACE, title="Payment reconciliation", chart_hint="stacked_bar", + # v2: `settlement_method` splits settled money by how it actually arrived, so the + # column set and the row grain both changed. Deliberately NOT module-gated -- a + # space with the payments module uninstalled still tracks and settles money, and + # this is where it reconciles the cash box. + version=2, ), ) diff --git a/backend/apps/operations/report_delivery_storage.py b/backend/apps/operations/report_delivery_storage.py new file mode 100644 index 00000000..5e69b12f --- /dev/null +++ b/backend/apps/operations/report_delivery_storage.py @@ -0,0 +1,49 @@ +"""Private-bucket storage for scheduled report files. + +Reuses the evidence bucket client the same way member-card photos do: one private bucket, +keys namespaced per makerspace (`reports///.`), and +reads only through short-lived signed URLs. Object keys are identifiers, not secrets. +""" + +import uuid + +from botocore.exceptions import BotoCoreError, ClientError +from django.conf import settings + +from apps.evidence import storage +from apps.evidence.storage import StorageUnavailable +from apps.operations.report_exports import XLSX_CONTENT_TYPE + +CONTENT_TYPES = {"csv": "text/csv", "xlsx": XLSX_CONTENT_TYPE} + + +def report_object_key(makerspace_id, report_key, fmt): + return f"reports/{makerspace_id}/{report_key}/{uuid.uuid4().hex}.{fmt}" + + +def store_report_object(object_key, payload, fmt): + try: + storage._client().put_object( + Bucket=settings.AWS_STORAGE_BUCKET_NAME, + Key=object_key, + Body=payload, + ContentType=CONTENT_TYPES[fmt], + ) + except (BotoCoreError, ClientError) as exc: + raise StorageUnavailable from exc + + +def signed_download_url(object_key): + try: + return storage._public_client().generate_presigned_url( + "get_object", + Params={"Bucket": settings.AWS_STORAGE_BUCKET_NAME, "Key": object_key}, + ExpiresIn=settings.REPORT_DELIVERY_URL_TTL_SECONDS, + ) + except (BotoCoreError, ClientError) as exc: + raise StorageUnavailable from exc + + +def delete_report_object(object_key): + """Best-effort: the evidence helper logs and swallows a failed delete.""" + storage.delete_object(object_key) diff --git a/backend/apps/operations/report_exports.py b/backend/apps/operations/report_exports.py index 7a945e99..3bff058b 100644 --- a/backend/apps/operations/report_exports.py +++ b/backend/apps/operations/report_exports.py @@ -2,30 +2,65 @@ from datetime import datetime from io import BytesIO, StringIO -from django.http import HttpResponse +from django.http import HttpResponse, StreamingHttpResponse from openpyxl import Workbook +XLSX_CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" -def _csv_response(rows, filename): + +class StreamingCsvResponse(StreamingHttpResponse): + """A streamed CSV that still answers `.content` for callers that read the whole body. + + Django's streaming response deliberately has no `content`; the test client and any + caller that wants the bytes get them here by materialising the stream once. + """ + + @property + def content(self): + # The generator is one-shot; cache so a second read sees the same bytes. + if not hasattr(self, "_materialized_content"): + self._materialized_content = b"".join(self.streaming_content) + return self._materialized_content + + +def _csv_lines(rows, provenance): buffer = StringIO() writer = csv.writer(buffer) - writer.writerows([[_export_cell(value) for value in row] for row in rows]) - response = HttpResponse(buffer.getvalue(), content_type="text/csv") + # Written raw, not through the writer: the JSON filters contain commas and the whole + # point of the line is to stay readable as one `# key=value ...` comment. + yield provenance.header_line() + "\r\n" + for row in rows: + writer.writerow([_export_cell(value) for value in row]) + yield buffer.getvalue() + buffer.seek(0) + buffer.truncate(0) + + +def _csv_response(rows, filename, *, provenance): + response = StreamingCsvResponse(_csv_lines(rows, provenance), content_type="text/csv") response["Content-Disposition"] = f'attachment; filename="{filename}"' return response -def _xlsx_response(rows, filename): - workbook = Workbook() - sheet = workbook.active +def csv_bytes(rows, *, provenance): + return "".join(_csv_lines(rows, provenance)).encode("utf-8") + + +def xlsx_bytes(rows, *, provenance): + workbook = Workbook(write_only=True) + sheet = workbook.create_sheet("Report") for row in rows: sheet.append([_xlsx_cell(value) for value in row]) + provenance_sheet = workbook.create_sheet("Provenance") + for key, value in provenance.items(): + provenance_sheet.append([key, _export_cell(value)]) buffer = BytesIO() workbook.save(buffer) - response = HttpResponse( - buffer.getvalue(), - content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - ) + return buffer.getvalue() + + +def _xlsx_response(rows, filename, *, provenance): + response = HttpResponse(xlsx_bytes(rows, provenance=provenance), content_type=XLSX_CONTENT_TYPE) response["Content-Disposition"] = f'attachment; filename="{filename}"' return response diff --git a/backend/apps/operations/report_exports_provenance.py b/backend/apps/operations/report_exports_provenance.py new file mode 100644 index 00000000..2e9e872f --- /dev/null +++ b/backend/apps/operations/report_exports_provenance.py @@ -0,0 +1,68 @@ +"""Provenance stamped onto every report export (CSV header row, XLSX sheet). + +One value per FILE, not per row: who generated it, when, for which makerspace, from which +report definition/version and with which filters. It rides as a leading `#` line in CSV +and as a second `Provenance` sheet in XLSX, so the data columns stay exactly the fields +the report registry declares. +""" + +import json +from dataclasses import dataclass +from datetime import datetime + +from django.utils import timezone + +LEDGER_REPORT_KEY = "ledger" +LEDGER_REPORT_VERSION = 1 + + +@dataclass(frozen=True) +class ExportProvenance: + report_key: str + report_version: int + makerspace_id: int | None + generated_by: str + filters: dict + generated_at: datetime + + def items(self): + return ( + ("generated_at", self.generated_at.isoformat()), + ("generated_by", self.generated_by), + ("makerspace_id", "all" if self.makerspace_id is None else str(self.makerspace_id)), + ("report_key", self.report_key), + ("report_version", str(self.report_version)), + ("filters", json.dumps(self.filters, sort_keys=True, separators=(",", ":"), default=str)), + ) + + def header_line(self): + return "# " + " ".join(f"{key}={value}" for key, value in self.items()) + + +def build_provenance(report_key, *, version, makerspace_id, generated_by, filters=None, now=None): + return ExportProvenance( + report_key=report_key, + report_version=int(version), + makerspace_id=makerspace_id, + generated_by=generated_by, + filters=dict(filters or {}), + generated_at=now or timezone.now(), + ) + + +def actor_label(user): + username = getattr(user, "username", "") if getattr(user, "is_authenticated", False) else "" + return username or "system" + + +def export_filters(*, date_range=None, report_filters=None, grain=None, extra=None): + """Flatten the export inputs into the JSON the provenance row carries; drop empties.""" + start, end = date_range or (None, None) + filters = { + "start": start.isoformat() if start else None, + "end": end.isoformat() if end else None, + "grain": grain, + **(report_filters or {}), + **(extra or {}), + } + return {key: value for key, value in filters.items() if value not in (None, "")} diff --git a/backend/apps/operations/report_schedule_services.py b/backend/apps/operations/report_schedule_services.py new file mode 100644 index 00000000..1c0bd0e6 --- /dev/null +++ b/backend/apps/operations/report_schedule_services.py @@ -0,0 +1,233 @@ +"""Run due report schedules: build through the manual export path, store, deliver, record. + +Fail-safe per schedule: one broken schedule (bad filters, storage down, a dead webhook) +records a FAILED delivery and lets the sweep carry on. The run is idempotent under a +coarse cron because the claim advances `next_run_at` under a `skip_locked` row lock +BEFORE any work happens; a second worker in the same minute finds nothing due. +""" + +import logging +from datetime import timedelta + +from django.conf import settings +from django.db import transaction +from django.utils import timezone +from django.utils.dateparse import parse_date + +from apps.accounts import rbac +from apps.audit import services as audit +from apps.integrations.dispatch import dispatch_email +from apps.integrations.dispatch_destination import deliver_text_to_destination +from apps.integrations.models import EmailLog +from apps.makerspaces.platform import module_enabled +from apps.operations import reports +from apps.operations.models_report_schedules import ( + ReportDelivery, + ReportSchedule, + advance_next_run, +) +from apps.operations.report_delivery_storage import ( + delete_report_object, + report_object_key, + signed_download_url, + store_report_object, +) +from apps.operations.report_exports import csv_bytes, xlsx_bytes +from apps.operations.report_exports_provenance import build_provenance, export_filters +from apps.operations.report_registry import REPORT_REGISTRY +from apps.operations.report_scope import eligible_makerspace_ids +from apps.operations.views_report_helpers import date_range_from_dates +from apps.tenant_migration.gate_runtime import fanout_tenant_write + +logger = logging.getLogger(__name__) + +NOTIFICATION_FEATURE = "reports" +NOTIFICATION_EVENT = "scheduled_report" +PAYMENT_FILTER_KEYS = ("status", "subject_type") + + +def run_report_schedules(*, now=None, limit=50): + now = now or timezone.now() + # Archived, reports-disabled and superadmin-hidden makerspaces never run: the same + # eligibility the aggregate reports use, applied before any row is claimed. + due = list( + ReportSchedule.objects.filter( + is_active=True, next_run_at__lte=now, makerspace_id__in=eligible_makerspace_ids(), + ).order_by("next_run_at", "id").values_list("id", "makerspace_id")[: max(int(limit), 1)] + ) + counts = {"delivered": 0, "failed": 0, "skipped": 0} + for schedule_id, makerspace_id in due: + with fanout_tenant_write( + makerspace_id, operation="report_schedule", counts=counts + ) as should_process: + if not should_process: + continue + try: + run_schedule(schedule_id, now=now, counts=counts) + except Exception: # noqa: BLE001 - one schedule must never stop the sweep + logger.exception("report_schedule_run_failed", extra={"schedule_id": schedule_id}) + counts["failed"] += 1 + return counts + + +def run_schedule(schedule_id, *, now=None, counts=None, force=False, actor=None): + """Claim and run one schedule. `force` runs it now regardless of `next_run_at`.""" + now = now or timezone.now() + counts = counts if counts is not None else {"delivered": 0, "failed": 0, "skipped": 0} + schedule = _claim(schedule_id, now, force) + if schedule is None: + return None + _sweep_expired(schedule, now) + definition = REPORT_REGISTRY.get(schedule.report_key) + reason = _skip_reason(schedule, definition) + if reason: + audit.record( + actor, "report_schedule.skipped", makerspace=schedule.makerspace, target=schedule, + meta={"schedule_id": schedule.pk, "reason": reason}, + ) + counts["skipped"] += 1 + return None + delivery = _build_and_deliver(schedule, definition, now, actor) + counts["delivered" if delivery.status == ReportDelivery.Status.SENT else "failed"] += 1 + return delivery + + +def _claim(schedule_id, now, force): + with transaction.atomic(): + # `of=("self",)`: the joined creator/destination rows are nullable, and Postgres + # refuses FOR UPDATE across a nullable outer join unless the lock names one table. + queryset = ( + ReportSchedule.objects.select_for_update(skip_locked=True, of=("self",)) + .select_related("makerspace", "created_by", "destination") + .filter(pk=schedule_id, is_active=True) + ) + if not force: + queryset = queryset.filter(next_run_at__lte=now) + schedule = queryset.first() + if schedule is None: + return None + if schedule.next_run_at <= now: + schedule.next_run_at = advance_next_run(schedule.next_run_at, schedule.cadence, now) + schedule.last_run_at = now + schedule.save(update_fields=["next_run_at", "last_run_at", "updated_at"]) + return schedule + + +def _skip_reason(schedule, definition): + if definition is None or not definition.exportable: + return "report_unavailable" + if not module_enabled(schedule.makerspace, "reports"): + return "module_disabled:reports" + for module in definition.required_modules: + if not module_enabled(schedule.makerspace, module): + return f"module_disabled:{module}" + creator = schedule.created_by + if creator is None or not rbac.can(creator, definition.required_action, schedule.makerspace_id): + return "creator_lacks_action" + return "" + + +def schedule_inputs(schedule, now): + """Translate the stored filters into exactly what the manual export view passes.""" + filters = dict(schedule.filters or {}) + window_days = filters.get("window_days") + if window_days: + date_range = (now - timedelta(days=int(window_days)), now) + else: + date_range = date_range_from_dates( + parse_date(filters.get("start") or "") if filters.get("start") else None, + parse_date(filters.get("end") or "") if filters.get("end") else None, + ) + report_filters = {} + if schedule.report_key == "payment-reconciliation": + report_filters = {key: filters[key] for key in PAYMENT_FILTER_KEYS if filters.get(key)} + return date_range, report_filters + + +def _build_and_deliver(schedule, definition, now, actor): + makerspace = schedule.makerspace + errors, object_key = [], "" + try: + date_range, report_filters = schedule_inputs(schedule, now) + provenance = build_provenance( + definition.key, version=definition.version, makerspace_id=makerspace.id, + generated_by=f"schedule:{schedule.pk}", now=now, + filters=export_filters( + date_range=date_range, report_filters=report_filters, grain=schedule.grain, + ), + ) + rows = reports.report_rows( + definition.key, makerspace.id, date_range=date_range, + report_filters=report_filters, grain=schedule.grain, + ) + render = xlsx_bytes if schedule.format == ReportSchedule.Format.XLSX else csv_bytes + payload = render(rows, provenance=provenance) + key = report_object_key(makerspace.id, definition.key, schedule.format) + store_report_object(key, payload, schedule.format) + url = signed_download_url(key) + object_key = key + except Exception as exc: # noqa: BLE001 - recorded on the delivery row instead + logger.exception("report_schedule_build_failed", extra={"schedule_id": schedule.pk}) + errors.append(f"build:{type(exc).__name__}") + else: + errors.extend(_send(schedule, definition, url)) + delivered = not errors + delivery = ReportDelivery.objects.create( + schedule=schedule, object_key=object_key, + status=ReportDelivery.Status.SENT if delivered else ReportDelivery.Status.FAILED, + error="; ".join(errors)[:2000], + expires_at=( + now + timedelta(seconds=settings.REPORT_DELIVERY_URL_TTL_SECONDS) if object_key else None + ), + ) + audit.record( + actor, "report_schedule.delivered" if delivered else "report_schedule.failed", + makerspace=makerspace, target=schedule, + meta={"schedule_id": schedule.pk, "delivery_id": delivery.pk}, + ) + return delivery + + +def _send(schedule, definition, url): + """Deliver the LINK to every configured leg; the bytes never enter a chat channel.""" + makerspace = schedule.makerspace + title = definition.title or definition.key + hours = max(settings.REPORT_DELIVERY_URL_TTL_SECONDS // 3600, 1) + text = ( + f"Scheduled report '{title}' for {makerspace.name} ({schedule.format.upper()}) is ready. " + f"Download (link valid about {hours}h): {url}" + ) + errors = [] + if schedule.destination_id: + ok, error = deliver_text_to_destination( + makerspace, schedule.destination, text=text, feature=NOTIFICATION_FEATURE, + event=NOTIFICATION_EVENT, reference=f"report-schedule-{schedule.pk}", + payload={"schedule_id": schedule.pk, "report_key": definition.key, "download_url": url}, + ) + if not ok: + errors.append(f"{schedule.destination.channel}:{error}") + for email in schedule.recipient_emails or []: + try: + log = dispatch_email( + makerspace=makerspace, to_email=email, + subject=f"[{makerspace.name}] Scheduled report: {title}", text_body=text, + stream="reports", event=NOTIFICATION_EVENT, audience="staff", sync=True, + ) + except Exception as exc: # noqa: BLE001 - SMTP/fence failures are per-recipient facts + errors.append(f"email:{type(exc).__name__}") + continue + if log.status != EmailLog.Status.SENT: + # SKIPPED means the `email` module is off for this makerspace; that leg did + # not deliver, and the row must say so rather than count a silent skip as sent. + errors.append(f"email:{log.status}") + if not schedule.destination_id and not schedule.recipient_emails: + errors.append("no_recipients") + return errors + + +def _sweep_expired(schedule, now): + """Delete this schedule's expired objects; the row keeps the delivery history.""" + for delivery in schedule.deliveries.filter(expires_at__lt=now).exclude(object_key=""): + delete_report_object(delivery.object_key) + delivery.object_key = "" + delivery.save(update_fields=["object_key"]) diff --git a/backend/apps/operations/report_types.py b/backend/apps/operations/report_types.py index 0f2cb4d0..0515b504 100644 --- a/backend/apps/operations/report_types.py +++ b/backend/apps/operations/report_types.py @@ -27,6 +27,9 @@ class ReportDefinition: chart_hint: str = "table" grains: tuple[str, ...] = () section_modules: tuple[str, ...] = () + # Bumped when the exported column set or its meaning changes, so a file's provenance + # row says which shape it carries. Independent of the deployment release. + version: int = 1 def builder(self) -> Callable: return import_string(self.builder_path) diff --git a/backend/apps/operations/reports_payments.py b/backend/apps/operations/reports_payments.py index c5aa5fe8..55866e7c 100644 --- a/backend/apps/operations/reports_payments.py +++ b/backend/apps/operations/reports_payments.py @@ -1,13 +1,30 @@ -from django.db.models import Case, Count, DecimalField, Q, Sum, Value, When +from django.db.models import ( + Case, + CharField, + Count, + DecimalField, + OuterRef, + Q, + Subquery, + Sum, + Value, + When, +) +from django.db.models.functions import Coalesce from apps.operations.report_registry import ReportResult from apps.operations.report_scope import scoped_ids -from apps.payments.models import Payment +from apps.payments.models import ManualSettlement, Payment FIELDS = ( "currency", "subject_type", "status", + # How settled money actually arrived: the manual method (cash / UPI / bank transfer / + # card machine / cheque) for an offline settlement, the vendor for an online one, and + # "" for rows where nothing has been collected. Splitting on it is what makes the + # report reconcile against a physical cash box. + "settlement_method", "payment_count", "amount_total", "outstanding_amount", @@ -37,7 +54,36 @@ def build_payment_reconciliation( dated &= Q(created_at__lt=end) queryset = queryset.filter(Q(status=Payment.Status.PENDING) | dated) - group_fields = ["makerspace_id", "currency", "subject_type", "status"] + # A settled row's method comes from the effective (unamended) receipt; an online + # charge reports its vendor instead, so one column answers "where did this money come + # from" for both rails. Currencies are never combined -- `currency` stays a grouping + # key, so a space taking both INR and USD gets one row per currency, never a total + # that silently adds them. + queryset = queryset.annotate( + settlement_method=Case( + When( + status=Payment.Status.PAID_OFFLINE, + then=Coalesce( + Subquery( + ManualSettlement.objects.filter( + payment_id=OuterRef("pk"), amended_by__isnull=True + ) + .order_by("-created_at", "-pk") + .values("method")[:1] + ), + # Settled before the ledger existed, or by a path that recorded no + # receipt. Reported honestly rather than folded into "cash". + Value("unknown"), + ), + ), + When(status=Payment.Status.PAID_ONLINE, then="provider"), + default=Value(""), + output_field=CharField(), + ) + ) + group_fields = [ + "makerspace_id", "currency", "subject_type", "status", "settlement_method" + ] fields = (("makerspace_id",) + FIELDS) if aggregate else FIELDS money = DecimalField(max_digits=20, decimal_places=2) rows = ( diff --git a/backend/apps/operations/serializers_report_schedules.py b/backend/apps/operations/serializers_report_schedules.py new file mode 100644 index 00000000..9ec235da --- /dev/null +++ b/backend/apps/operations/serializers_report_schedules.py @@ -0,0 +1,120 @@ +from django.utils import timezone +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import extend_schema_field +from rest_framework import serializers + +from apps.evidence.storage import StorageUnavailable +from apps.integrations.models_destinations import NotificationDestination +from apps.integrations.notification_enums import ChatNotificationChannel +from apps.operations.models_report_schedules import ( + MAX_RECIPIENT_EMAILS, + ReportDelivery, + ReportSchedule, +) +from apps.operations.report_delivery_storage import signed_download_url +from apps.operations.report_registry import REPORT_KEYS, REPORT_REGISTRY +from apps.payments.models import Payment + + +class ReportScheduleFiltersSerializer(serializers.Serializer): + """The subset of manual-export query parameters a schedule may pin. + + `window_days` is the recurring-report shape ("the last 7 days, every Monday"); fixed + `start`/`end` reproduce the same file each run and are mutually exclusive with it. + """ + + start = serializers.DateField(required=False) + end = serializers.DateField(required=False) + window_days = serializers.IntegerField(required=False, min_value=1, max_value=366) + status = serializers.ChoiceField(choices=Payment.Status.choices, required=False) + subject_type = serializers.ChoiceField(choices=Payment.SubjectType.choices, required=False) + + def validate(self, attrs): + if attrs.get("window_days") and (attrs.get("start") or attrs.get("end")): + raise serializers.ValidationError("Use either window_days or start/end, not both.") + if attrs.get("start") and attrs.get("end") and attrs["start"] > attrs["end"]: + raise serializers.ValidationError({"end": "End date must be on or after start date."}) + return attrs + + +class ReportDeliverySerializer(serializers.ModelSerializer): + download_url = serializers.SerializerMethodField() + + class Meta: + model = ReportDelivery + fields = ("id", "status", "error", "created_at", "expires_at", "download_url") + read_only_fields = fields + + @extend_schema_field(OpenApiTypes.URI) + def get_download_url(self, delivery): + if not delivery.object_key or (delivery.expires_at and delivery.expires_at <= timezone.now()): + return None + try: + return signed_download_url(delivery.object_key) + except StorageUnavailable: + return None + + +class ReportScheduleSerializer(serializers.ModelSerializer): + report_key = serializers.ChoiceField(choices=[(key, key) for key in REPORT_KEYS]) + filters = serializers.JSONField(required=False) + next_run_at = serializers.DateTimeField(required=False) + recipient_emails = serializers.ListField( + child=serializers.EmailField(), required=False, max_length=MAX_RECIPIENT_EMAILS, + ) + destination = serializers.PrimaryKeyRelatedField( + queryset=NotificationDestination.objects.all(), required=False, allow_null=True, + ) + last_delivery = serializers.SerializerMethodField() + + class Meta: + model = ReportSchedule + fields = ( + "id", "makerspace", "report_key", "filters", "grain", "format", "cadence", + "next_run_at", "last_run_at", "is_active", "destination", "recipient_emails", + "created_by", "created_at", "updated_at", "last_delivery", + ) + read_only_fields = ("id", "makerspace", "last_run_at", "created_by", "created_at", "updated_at") + + @extend_schema_field(ReportDeliverySerializer(allow_null=True)) + def get_last_delivery(self, schedule): + delivery = schedule.deliveries.order_by("-created_at", "-id").first() + return ReportDeliverySerializer(delivery).data if delivery else None + + def validate_filters(self, value): + nested = ReportScheduleFiltersSerializer(data=value if value is not None else {}) + nested.is_valid(raise_exception=True) + # Stored as JSON, so dates become ISO strings; the runner parses them back. + return { + key: item.isoformat() if hasattr(item, "isoformat") else item + for key, item in nested.validated_data.items() + } + + def validate_destination(self, destination): + makerspace = self.context["makerspace"] + if destination is None: + return None + # One message for both "not yours" and "not a room": no cross-tenant id probing. + if destination.makerspace_id != makerspace.id or destination.channel not in ChatNotificationChannel.values: + raise serializers.ValidationError("Unknown destination.") + return destination + + def validate(self, attrs): + current = self.instance + report_key = attrs.get("report_key", getattr(current, "report_key", None)) + definition = REPORT_REGISTRY[report_key] + if not definition.exportable: + raise serializers.ValidationError({"report_key": "Report is not exportable."}) + grain = attrs.get("grain", getattr(current, "grain", "day")) + allowed = definition.grains or ("day",) + if grain not in allowed: + raise serializers.ValidationError({"grain": f"Use one of: {', '.join(allowed)}."}) + destination = attrs.get("destination", getattr(current, "destination", None)) + emails = attrs.get("recipient_emails", getattr(current, "recipient_emails", None) or []) + if destination is None and not emails: + raise serializers.ValidationError( + {"recipient_emails": "Choose a chat destination or at least one email address."} + ) + if current is None and not attrs.get("next_run_at"): + attrs["next_run_at"] = timezone.now() + return attrs diff --git a/backend/apps/operations/tasks_report_schedules.py b/backend/apps/operations/tasks_report_schedules.py new file mode 100644 index 00000000..23261950 --- /dev/null +++ b/backend/apps/operations/tasks_report_schedules.py @@ -0,0 +1,8 @@ +from celery import shared_task + +from apps.operations.report_schedule_services import run_report_schedules + + +@shared_task(name="apps.operations.tasks_report_schedules.run_report_schedules_task") +def run_report_schedules_task(): + return run_report_schedules() diff --git a/backend/apps/operations/urls.py b/backend/apps/operations/urls.py index 0b37ad75..69f13c5d 100644 --- a/backend/apps/operations/urls.py +++ b/backend/apps/operations/urls.py @@ -1,10 +1,22 @@ from django.urls import path from apps.operations import views +from apps.operations.views_live import LiveStreamView +from apps.operations.views_metrics import MetricsView +from apps.operations.views_report_schedules import ( + ReportScheduleDetailView, + ReportScheduleListCreateView, + ReportScheduleRunNowView, +) urlpatterns = [ + path("admin/makerspaces//report-schedules", ReportScheduleListCreateView.as_view(), name="report-schedules"), + path("admin/report-schedules/", ReportScheduleDetailView.as_view(), name="report-schedule-detail"), + path("admin/report-schedules//run-now", ReportScheduleRunNowView.as_view(), name="report-schedule-run-now"), path("health/", views.HealthView.as_view(), name="health"), path("health/readiness/", views.ReadinessView.as_view(), name="readiness"), + path("metrics/", MetricsView.as_view(), name="metrics"), + path("live/", LiveStreamView.as_view(), name="live"), path("admin/makerspace//dashboard", views.DashboardView.as_view(), name="operations-dashboard"), path("admin/makerspace//containers", views.ContainerListCreateView.as_view(), name="containers"), path("admin/containers/", views.ContainerDetailView.as_view(), name="container-detail"), diff --git a/backend/apps/operations/views_dashboard_counts.py b/backend/apps/operations/views_dashboard_counts.py index 56a88a03..d688a304 100644 --- a/backend/apps/operations/views_dashboard_counts.py +++ b/backend/apps/operations/views_dashboard_counts.py @@ -1,6 +1,6 @@ from datetime import timedelta -from django.db.models import Q +from django.db.models import Q, Sum from django.utils import timezone from rest_framework import serializers @@ -33,6 +33,13 @@ class DashboardSerializer(serializers.Serializer): warranty_expiring = serializers.IntegerField(required=False, default=0) maintenance_overdue = serializers.IntegerField(required=False, default=0) pending_payments = serializers.IntegerField(required=False, default=0) + # Declared so the schema and the generated TypeScript carry it: the endpoint emits + # this map and an undeclared field is invisible to typed frontend consumers. Amounts + # are strings keyed by currency code -- never one combined number, since adding INR + # to USD would be meaningless money. + outstanding_by_currency = serializers.DictField( + child=serializers.CharField(), required=False, default=dict + ) def build_dashboard( @@ -222,9 +229,19 @@ def build_dashboard( if include_pending_payments and not restricted: try: - counts["pending_payments"] = Payment.objects.filter( + pending = Payment.objects.filter( makerspace=makerspace, status=Payment.Status.PENDING - ).count() + ) + counts["pending_payments"] = pending.count() + # How much, not just how many. Grouped BY CURRENCY and never summed across + # them: a space taking both INR and USD has two outstanding figures, and one + # combined number would be meaningless money. + counts["outstanding_by_currency"] = { + row["currency"]: str(row["total"]) + for row in pending.values("currency") + .annotate(total=Sum("amount")) + .order_by("currency") + } except Exception: pass elif not restricted: diff --git a/backend/apps/operations/views_ledger.py b/backend/apps/operations/views_ledger.py index 6e885fd2..6f8c5555 100644 --- a/backend/apps/operations/views_ledger.py +++ b/backend/apps/operations/views_ledger.py @@ -10,6 +10,13 @@ from apps.makerspaces.models import Makerspace from apps.operations import ledger from apps.operations.report_exports import _csv_response, _xlsx_response +from apps.operations.report_exports_provenance import ( + LEDGER_REPORT_KEY, + LEDGER_REPORT_VERSION, + actor_label, + build_provenance, + export_filters, +) from apps.operations.serializers import EmptySerializer, LedgerResponseSerializer from apps.operations.views_reports import ( _makerspace_for_inventory_view, @@ -160,10 +167,15 @@ def _ledger_payload(makerspace_id, request): def _ledger_export_response(makerspace_id, request, filename_base): fmt = _export_format(request) - rows = ledger.ledger_export_rows(makerspace_id, filters=_ledger_filters(request)) + filters = _ledger_filters(request) + rows = ledger.ledger_export_rows(makerspace_id, filters=filters) + provenance = build_provenance( + LEDGER_REPORT_KEY, version=LEDGER_REPORT_VERSION, makerspace_id=makerspace_id, + generated_by=actor_label(request.user), filters=export_filters(extra=filters), + ) if fmt == "xlsx": - return _xlsx_response(rows, f"{filename_base}.xlsx") - return _csv_response(rows, f"{filename_base}.csv") + return _xlsx_response(rows, f"{filename_base}.xlsx", provenance=provenance) + return _csv_response(rows, f"{filename_base}.csv", provenance=provenance) def _ledger_filters(request): diff --git a/backend/apps/operations/views_live.py b/backend/apps/operations/views_live.py new file mode 100644 index 00000000..7f7a2cfe --- /dev/null +++ b/backend/apps/operations/views_live.py @@ -0,0 +1,113 @@ +"""Server-Sent Events stream of live-update hints. + +One long-lived GET per browser session. The client is told *that* something changed and +which kind of thing; it refetches through the ordinary endpoints, so authorization is never +re-implemented here — the stream only decides which channels a session may subscribe to, +using the same RBAC scoping as every other query. + +Served by the dedicated ``live`` gunicorn service (thread workers, no request timeout, no +worker recycling); see docker-compose*.yml and frontend/nginx.conf. +""" +import logging +import time + +from django.conf import settings +from django.http import JsonResponse, StreamingHttpResponse +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import extend_schema +from rest_framework.permissions import IsAuthenticated +from rest_framework.renderers import BaseRenderer, JSONRenderer +from rest_framework.views import APIView + +from apps.accounts import rbac +from apps.makerspaces.models import Makerspace +from apps.operations import live + +logger = logging.getLogger(__name__) + +HEARTBEAT_SECONDS = 25 +MAX_STREAM_SECONDS = 60 * 60 # the client reconnects; bounded so a worker thread is never held forever + + +def subscribed_channels(user): + """Every channel this session may hear: its own user channel plus each visible makerspace.""" + channels = [live.user_channel(user.pk)] + # scope_by_makerspace is the one authority: superadmin => every makerspace, staff => their + # memberships (archived/hidden excluded), a plain member => none. + scoped = rbac.scope_by_makerspace(user, Makerspace.objects.all(), makerspace_field="pk") + channels.extend( + live.makerspace_channel(pk) for pk in scoped.order_by("pk").values_list("pk", flat=True) + ) + return channels + + +def _event_stream(pubsub, deadline): + yield "retry: 5000\n\n" + last_beat = time.monotonic() + while time.monotonic() < deadline: + message = pubsub.get_message(ignore_subscribe_messages=True, timeout=1.0) + if message and message.get("type") == "message": + data = message["data"] + if isinstance(data, bytes): + data = data.decode("utf-8", "replace") + yield f"event: change\ndata: {data}\n\n" + if time.monotonic() - last_beat >= HEARTBEAT_SECONDS: + yield ": keep-alive\n\n" + last_beat = time.monotonic() + + +class EventStreamRenderer(BaseRenderer): + """Lets DRF's content negotiation accept ``Accept: text/event-stream``. + + Without it the browser's EventSource-style request is refused with 406 before ``get`` + runs. The body itself is a StreamingHttpResponse, so ``render`` only ever sees the + error payloads, which are returned as plain JsonResponse objects instead. + """ + + media_type = "text/event-stream" + format = "sse" + charset = "utf-8" + + def render(self, data, accepted_media_type=None, renderer_context=None): + return b"" if data is None else str(data).encode("utf-8") + + +def _unavailable(detail): + return JsonResponse({"detail": detail}, status=503) + + +class LiveStreamView(APIView): + permission_classes = [IsAuthenticated] + throttle_classes = [] + renderer_classes = [EventStreamRenderer, JSONRenderer] + + @extend_schema( + tags=["Health"], + summary="Live update stream (Server-Sent Events)", + description=( + "text/event-stream of `change` events `{kind, makerspace_id, target_type, target_id, " + "actor_id, ts}` for every makerspace the session may see. Clients invalidate cached " + "queries on an event and refetch; no tenant content travels on the stream. 503 when the " + "deployment has no Redis." + ), + request=None, + responses={(200, "text/event-stream"): OpenApiTypes.STR, 503: None}, + ) + def get(self, request, *args, **kwargs): + client = live.redis_client() + if client is None: + return _unavailable("Live updates are not available on this deployment.") + channels = subscribed_channels(request.user) + try: + pubsub = client.pubsub() + pubsub.subscribe(*channels) + except Exception: + logger.warning("live_subscribe_failed", extra={"user_id": request.user.pk}) + return _unavailable("Live updates are temporarily unavailable.") + deadline = time.monotonic() + getattr(settings, "LIVE_MAX_STREAM_SECONDS", MAX_STREAM_SECONDS) + response = StreamingHttpResponse( + _event_stream(pubsub, deadline), content_type="text/event-stream" + ) + response["Cache-Control"] = "no-cache" + response["X-Accel-Buffering"] = "no" + return response diff --git a/backend/apps/operations/views_metrics.py b/backend/apps/operations/views_metrics.py new file mode 100644 index 00000000..703e4731 --- /dev/null +++ b/backend/apps/operations/views_metrics.py @@ -0,0 +1,166 @@ +"""Prometheus text exposition for the deployment. + +Deliberately dependency-free: the exposition format is a handful of lines and pulling in a +metrics library for six gauges would add a process-wide registry the tests then have to +reset. Everything here is a point-in-time read of state the platform already keeps. + +Access is a static bearer token. With no token configured the route answers 404, not 401, +so an unconfigured deployment does not even advertise that the surface exists. +""" +from datetime import timedelta + +from django.conf import settings +from django.db.models import Count, Max, Sum +from django.http import Http404, HttpResponse +from django.utils import timezone +from django.utils.crypto import constant_time_compare +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import extend_schema +from rest_framework.permissions import AllowAny +from rest_framework.views import APIView + +CONTENT_TYPE = "text/plain; version=0.0.4; charset=utf-8" + + +def _escape_label(value): + return str(value).replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n") + + +class _Exposition: + def __init__(self): + self.lines = [] + + def gauge(self, name, help_text, samples): + """``samples`` is an iterable of (labels dict, value).""" + self.lines.append(f"# HELP {name} {help_text}") + self.lines.append(f"# TYPE {name} gauge") + for labels, value in samples: + rendered = ",".join( + f'{key}="{_escape_label(val)}"' for key, val in sorted(labels.items()) + ) + suffix = f"{{{rendered}}}" if rendered else "" + self.lines.append(f"{name}{suffix} {value}") + + def render(self): + return "\n".join(self.lines) + "\n" + + +def _celery_queue_lengths(): + """Queue depth from the broker; empty when the deployment runs tasks eagerly.""" + if getattr(settings, "CELERY_TASK_ALWAYS_EAGER", False): + return [] + try: + import redis + except ImportError: # pragma: no cover - redis is a hard requirement in practice + return [] + queue = getattr(settings, "CELERY_TASK_DEFAULT_QUEUE", None) or "celery" + try: + client = redis.Redis.from_url(settings.CELERY_BROKER_URL, socket_timeout=1) + return [({"queue": queue}, int(client.llen(queue)))] + except Exception: # broker unreachable: report nothing rather than fail the scrape + return [] + + +def _collect(): + from apps.evidence.retention_models import EvidenceObjectRetentionState + from apps.hardware_requests.models import HardwareRequest + from apps.integrations.models import EmailLog, NotificationDeliveryLog + from apps.makerspaces.models import Makerspace + + since = timezone.now() - timedelta(hours=1) + out = _Exposition() + + out.gauge( + "spaceworks_celery_queue_length", + "Messages waiting in the Celery broker queue.", + _celery_queue_lengths(), + ) + out.gauge( + "spaceworks_hardware_requests", + "Hardware requests by workflow status.", + [ + ({"status": row["status"]}, row["n"]) + for row in HardwareRequest.objects.values("status").annotate(n=Count("id")) + ], + ) + deliveries = [ + ({"channel": row["channel"], "status": row["status"]}, row["n"]) + for row in NotificationDeliveryLog.objects.filter(created_at__gte=since) + .values("channel", "status") + .annotate(n=Count("id")) + ] + deliveries += [ + ({"channel": "email", "status": row["status"]}, row["n"]) + for row in EmailLog.objects.filter(created_at__gte=since) + .values("status") + .annotate(n=Count("id")) + ] + out.gauge( + "spaceworks_notification_deliveries_last_hour", + "Notification delivery attempts in the last hour by channel and status.", + deliveries, + ) + out.gauge( + "spaceworks_storage_bytes_used", + "Managed object storage accounted to each makerspace.", + [ + ({"makerspace_id": row["id"]}, row["storage_bytes_used"]) + for row in Makerspace.objects.values("id", "storage_bytes_used") + ], + ) + out.gauge( + "spaceworks_storage_bytes_used_total", + "Managed object storage accounted across all makerspaces.", + [({}, Makerspace.objects.aggregate(total=Sum("storage_bytes_used"))["total"] or 0)], + ) + out.gauge( + "spaceworks_evidence_retention_states", + "Evidence photos by object-retention state.", + [ + ({"status": row["status"]}, row["n"]) + for row in EvidenceObjectRetentionState.objects.values("status").annotate(n=Count("id")) + ], + ) + last_expiry = EvidenceObjectRetentionState.objects.aggregate( + latest=Max("object_expired_at") + )["latest"] + out.gauge( + "spaceworks_evidence_retention_last_expiry_timestamp_seconds", + "Unix time of the most recent evidence object expiry (0 when none).", + [({}, int(last_expiry.timestamp()) if last_expiry else 0)], + ) + return out.render() + + +def _presented_token(request): + header = request.headers.get("Authorization", "") + if header.startswith("Bearer "): + return header[len("Bearer "):].strip() + return request.headers.get("X-Metrics-Token", "") + + +class MetricsView(APIView): + authentication_classes = [] + permission_classes = [AllowAny] + throttle_classes = [] + + @extend_schema( + tags=["Health"], + summary="Prometheus metrics", + description=( + "Prometheus text exposition of queue depth, request states, notification " + "delivery outcomes, storage accounting and evidence retention. Requires the " + "deployment's METRICS_TOKEN as a bearer token; 404 when no token is configured." + ), + request=None, + responses={(200, "text/plain"): OpenApiTypes.STR, 401: None, 404: None}, + ) + def get(self, request, *args, **kwargs): + expected = getattr(settings, "METRICS_TOKEN", "") + if not expected: + raise Http404 + if not constant_time_compare(_presented_token(request), expected): + response = HttpResponse("unauthorized\n", status=401, content_type="text/plain") + response["WWW-Authenticate"] = "Bearer" + return response + return HttpResponse(_collect(), content_type=CONTENT_TYPE) diff --git a/backend/apps/operations/views_report_helpers.py b/backend/apps/operations/views_report_helpers.py index 6164f8b3..bd8f7c8a 100644 --- a/backend/apps/operations/views_report_helpers.py +++ b/backend/apps/operations/views_report_helpers.py @@ -18,8 +18,15 @@ def _require_source_modules(makerspace, modules): def _date_range(request): - start = _date_param(request, "start") - end = _date_param(request, "end") + return date_range_from_dates(_date_param(request, "start"), _date_param(request, "end")) + + +def date_range_from_dates(start, end): + """Inclusive calendar dates -> the half-open aware datetime range every builder takes. + + Shared by the export views and the scheduled-report runner so a schedule's stored + `start`/`end` mean exactly what the same dates mean on a manual export. + """ if start and end and start > end: raise ValidationError({"end": "End date must be on or after start date."}) start_dt = timezone.make_aware(datetime.combine(start, time.min)) if start else None diff --git a/backend/apps/operations/views_report_schedules.py b/backend/apps/operations/views_report_schedules.py new file mode 100644 index 00000000..5829da1f --- /dev/null +++ b/backend/apps/operations/views_report_schedules.py @@ -0,0 +1,154 @@ +from django.http import Http404 +from django.shortcuts import get_object_or_404 +from drf_spectacular.utils import OpenApiResponse, extend_schema, extend_schema_view +from rest_framework import generics, status +from rest_framework.response import Response +from rest_framework.views import APIView + +from apps.accounts import rbac +from apps.admin_api.permissions import IsActiveStaff, require_action +from apps.audit import services as audit +from apps.makerspaces.guards import require_module +from apps.operations.models_report_schedules import ReportSchedule +from apps.operations.report_delivery_storage import delete_report_object +from apps.operations.report_registry import REPORT_DEFINITIONS, REPORT_REGISTRY +from apps.operations.report_schedule_services import run_schedule +from apps.operations.serializers_report_schedules import ( + ReportDeliverySerializer, + ReportScheduleSerializer, +) +from apps.operations.serializers_reports import ReportErrorSerializer +from apps.operations.views_report_helpers import _makerspace_for_catalog + +ERROR_RESPONSES = { + 400: OpenApiResponse(ReportErrorSerializer, description="Invalid schedule or module disabled."), + 403: OpenApiResponse(ReportErrorSerializer, description="Permission denied."), + 404: OpenApiResponse(ReportErrorSerializer, description="Makerspace or schedule not found."), +} + + +def _scoped_schedule(user, pk): + """Resolve a schedule through the RBAC scope of ITS report's action (404 otherwise).""" + report_key = ReportSchedule.objects.filter(pk=pk).values_list("report_key", flat=True).first() + if report_key is None: + raise Http404 + definition = REPORT_REGISTRY.get(report_key) + action = definition.required_action if definition else rbac.Action.MANAGE_MAKERSPACE + queryset = rbac.scope_by_action( + user, action, ReportSchedule.objects.select_related("makerspace", "destination", "created_by") + ) + schedule = get_object_or_404(rbac.hide_from_superadmin(user, queryset), pk=pk) + require_module(schedule.makerspace, "reports") + return schedule + + +@extend_schema_view( + get=extend_schema( + tags=["Reports"], summary="List report schedules", request=None, + responses={200: ReportScheduleSerializer(many=True), **ERROR_RESPONSES}, + ), + post=extend_schema( + tags=["Reports"], summary="Create report schedule", request=ReportScheduleSerializer, + responses={201: ReportScheduleSerializer, **ERROR_RESPONSES}, + ), +) +class ReportScheduleListCreateView(generics.ListCreateAPIView): + permission_classes = [IsActiveStaff] + serializer_class = ReportScheduleSerializer + + def _makerspace(self): + if not hasattr(self, "_resolved_makerspace"): + makerspace = _makerspace_for_catalog(self.request.user, self.kwargs["makerspace_id"]) + require_module(makerspace, "reports") + self._resolved_makerspace = makerspace + return self._resolved_makerspace + + def get_serializer_context(self): + return {**super().get_serializer_context(), "makerspace": self._makerspace()} + + def get_queryset(self): + makerspace = self._makerspace() + # Only schedules for reports this actor may run: a MANAGE_MAKERSPACE-only report's + # schedule is invisible to a VIEW_AUDIT holder, exactly like the catalog. + keys = [ + definition.key for definition in REPORT_DEFINITIONS + if rbac.can(self.request.user, definition.required_action, makerspace.id) + ] + return ( + ReportSchedule.objects.filter(makerspace=makerspace, report_key__in=keys) + .select_related("destination", "created_by") + .order_by("-created_at", "-id") + ) + + def perform_create(self, serializer): + makerspace = self._makerspace() + definition = REPORT_REGISTRY[serializer.validated_data["report_key"]] + require_action(self.request.user, definition.required_action, makerspace.id) + schedule = serializer.save(makerspace=makerspace, created_by=self.request.user) + audit.record( + self.request.user, "report_schedule.created", makerspace=makerspace, target=schedule, + meta={"schedule_id": schedule.pk, "report_key": schedule.report_key}, + ) + + +class ReportScheduleDetailView(APIView): + permission_classes = [IsActiveStaff] + serializer_class = ReportScheduleSerializer + + @extend_schema( + tags=["Reports"], summary="Update report schedule", request=ReportScheduleSerializer, + responses={200: ReportScheduleSerializer, **ERROR_RESPONSES}, + ) + def patch(self, request, pk, *args, **kwargs): + schedule = _scoped_schedule(request.user, pk) + context = {"request": request, "makerspace": schedule.makerspace} + serializer = ReportScheduleSerializer(schedule, data=request.data, partial=True, context=context) + serializer.is_valid(raise_exception=True) + new_key = serializer.validated_data.get("report_key", schedule.report_key) + require_action(request.user, REPORT_REGISTRY[new_key].required_action, schedule.makerspace_id) + schedule = serializer.save() + audit.record( + request.user, "report_schedule.updated", makerspace=schedule.makerspace, target=schedule, + meta={"schedule_id": schedule.pk, "report_key": schedule.report_key}, + ) + return Response(ReportScheduleSerializer(schedule, context=context).data) + + @extend_schema( + tags=["Reports"], summary="Delete report schedule", request=None, + responses={204: None, **ERROR_RESPONSES}, + ) + def delete(self, request, pk, *args, **kwargs): + schedule = _scoped_schedule(request.user, pk) + keys = list(schedule.deliveries.exclude(object_key="").values_list("object_key", flat=True)) + audit.record( + request.user, "report_schedule.deleted", makerspace=schedule.makerspace, target=schedule, + meta={"schedule_id": schedule.pk, "report_key": schedule.report_key}, + ) + schedule.delete() + for key in keys: + delete_report_object(key) + return Response(status=status.HTTP_204_NO_CONTENT) + + +class ReportScheduleRunNowView(APIView): + permission_classes = [IsActiveStaff] + serializer_class = ReportDeliverySerializer + + @extend_schema( + tags=["Reports"], summary="Run a report schedule now", request=None, + responses={ + 200: ReportDeliverySerializer, + 409: OpenApiResponse(ReportErrorSerializer, description="Schedule inactive or skipped."), + **ERROR_RESPONSES, + }, + ) + def post(self, request, pk, *args, **kwargs): + schedule = _scoped_schedule(request.user, pk) + require_action(request.user, REPORT_REGISTRY[schedule.report_key].required_action, schedule.makerspace_id) + delivery = run_schedule(schedule.pk, force=True, actor=request.user) + if delivery is None: + return Response( + {"detail": "Schedule is inactive or was skipped.", "code": "report_schedule_skipped"}, + status=status.HTTP_409_CONFLICT, + ) + return Response(ReportDeliverySerializer(delivery).data) diff --git a/backend/apps/operations/views_reports.py b/backend/apps/operations/views_reports.py index 93a1a849..46d00bd7 100644 --- a/backend/apps/operations/views_reports.py +++ b/backend/apps/operations/views_reports.py @@ -12,6 +12,11 @@ from apps.operations.org_report_strategies import STRATEGIES from apps.operations.report_registry import REPORT_DEFINITIONS from apps.operations.report_exports import _csv_response, _xlsx_cell, _xlsx_response +from apps.operations.report_exports_provenance import ( + actor_label, + build_provenance, + export_filters, +) from apps.operations.schemas_reports import ANALYTICS_REPORT_RESPONSE from apps.operations.serializers import EmptySerializer, GenericObjectSerializer from apps.operations.serializers_reports import ReportErrorSerializer @@ -183,13 +188,7 @@ def get(self, request, makerspace_id, report_key, *args, **kwargs): require_action(request.user, definition.required_action, makerspace.id) require_module(makerspace, "reports") _require_source_modules(makerspace, definition.required_modules) - fmt = _export_format(request) - rows = reports.report_rows( - report_key, makerspace.id, date_range=_date_range(request), - report_filters=_report_filters(request, report_key), - grain=_grain_param(request, definition), - ) - return _xlsx_response(rows, f"{report_key}.xlsx") if fmt == "xlsx" else _csv_response(rows, f"{report_key}.csv") + return _export_response(request, definition, makerspace.id) class AggregateReportExportView(APIView): @@ -209,14 +208,32 @@ class AggregateReportExportView(APIView): ) def get(self, request, report_key, *args, **kwargs): _require_superadmin(request.user) - reports.validate_report_key(report_key, for_export=True) - fmt = _export_format(request) - rows = reports.report_rows( - report_key, date_range=_date_range(request), - report_filters=_report_filters(request, report_key), - grain=_grain_param(request, reports.validate_report_key(report_key)), - ) - return _xlsx_response(rows, f"{report_key}.xlsx") if fmt == "xlsx" else _csv_response(rows, f"{report_key}.csv") + definition = reports.validate_report_key(report_key, for_export=True) + return _export_response(request, definition, None) + + +def _export_response(request, definition, makerspace_id): + """Build the file through the canonical row path and stamp its provenance. + + Every input the builder saw is echoed into the provenance filters, so a reader of the + file can tell what subset it is without the URL that produced it. + """ + fmt = _export_format(request) + date_range = _date_range(request) + report_filters = _report_filters(request, definition.key) + grain = _grain_param(request, definition) + rows = reports.report_rows( + definition.key, makerspace_id, date_range=date_range, + report_filters=report_filters, grain=grain, + ) + provenance = build_provenance( + definition.key, version=definition.version, makerspace_id=makerspace_id, + generated_by=actor_label(request.user), + filters=export_filters(date_range=date_range, report_filters=report_filters, grain=grain), + ) + if fmt == "xlsx": + return _xlsx_response(rows, f"{definition.key}.xlsx", provenance=provenance) + return _csv_response(rows, f"{definition.key}.csv", provenance=provenance) def report_data(makerspace_id, report_key): diff --git a/backend/apps/payments/admin.py b/backend/apps/payments/admin.py index d592c511..c0bf9ba8 100644 --- a/backend/apps/payments/admin.py +++ b/backend/apps/payments/admin.py @@ -224,6 +224,6 @@ def save_model(self, request, obj, form, change): # `runtime_active` because django.contrib.admin autodiscovers every admin.py *before* the # owning app's ready() has registered anything -- the manifest is not populated yet. # The classes stay defined either way so the module still imports. -if not app_is_tombstoned("payments"): +if not app_is_tombstoned("payments_rail"): admin.site.register(MakerspacePaymentSettings, MakerspacePaymentSettingsAdmin) admin.site.register(PlatformStripeConnectSettings, PlatformStripeConnectSettingsAdmin) diff --git a/backend/apps/payments/apps.py b/backend/apps/payments/apps.py index 1d3d2e0f..8895c00c 100644 --- a/backend/apps/payments/apps.py +++ b/backend/apps/payments/apps.py @@ -5,7 +5,6 @@ class PaymentsConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" name = "apps.payments" - def ready(self): - from apps.separability.tombstones import register_separable_app - - register_separable_app("payments") + # No `register_separable_app`: the payment ledger is permanently core. Its surfaces + # are the record of money owed and taken, and a deployment that cannot read or settle + # them has lost data it is still holding. The removable half is `apps.payments_rail`. diff --git a/backend/apps/payments/availability.py b/backend/apps/payments/availability.py index b7f2b183..64b3d598 100644 --- a/backend/apps/payments/availability.py +++ b/backend/apps/payments/availability.py @@ -24,3 +24,56 @@ def online_payments_enabled(makerspace, domain): and feature_enabled(makerspace, f"payments.{domain}") and resolve_payment_source(makerspace) is not None ) + + +def charge_tracking_enabled(makerspace, domain): + """Whether a debt in this domain is RECORDED at all. + + Deliberately shorter than `online_payments_enabled` above: no module clause and no + credential clause. Knowing that a member owes money is not the same capability as + being able to take it from them online, and conflating the two is what made cash-only + spaces lose the debt entirely -- no pending row, nothing to settle, nothing in + reports. + + So this governs whether the charge exists; `online_payments_enabled` governs only + whether a Stripe/Razorpay rail is put behind it. A space with the `payments` module + uninstalled still tracks and settles money by hand. + + `charges.enabled` is the master switch and is checked ONLY here -- it is not a + `requires_features` edge on the domain keys, which would make it un-flippable until + every domain was unticked first (the same A6 rule the payments master switch follows). + """ + # No tombstone clause here any more. The ledger surfaces -- member history, + # reconciliation, receipts -- are permanently core since the rail was split into + # `apps.payments_rail`, so a recorded debt is always readable and settleable by + # someone. Tombstoning only removes the ability to pay it by card. + return ( + feature_enabled(makerspace, "charges.enabled") + and feature_enabled(makerspace, f"charges.{domain}") + ) + + +#: Which capability domain each payment subject belongs to. Kept beside the two +#: predicates so a new SubjectType cannot quietly acquire a rail nobody switched on. +SUBJECT_DOMAINS = { + "machine_service_request": "machines", + "booking": "bookings", + "event_registration": "events", + "makerspace_membership": "membership", + "membership_term": "membership", + "loan_deposit": "loans", + "loan_late_fee": "loans", +} + + +def online_payments_enabled_for(payment): + """Whether an online rail may be raised for THIS charge. + + The member checkout and native payment-intent endpoints both create provider objects + directly, so without this an unclaimed cash-only debt could still mint a live payment + link from a space that has switched its rail off -- or has none at all. + """ + domain = SUBJECT_DOMAINS.get(payment.subject_type) + if domain is None: + return False + return online_payments_enabled(payment.makerspace, domain) diff --git a/backend/apps/payments/member_access.py b/backend/apps/payments/member_access.py index 80b95248..51586be7 100644 --- a/backend/apps/payments/member_access.py +++ b/backend/apps/payments/member_access.py @@ -21,3 +21,47 @@ def member_payment_memberships(user): def member_payment_actor(user, makerspace_id): """The membership that may see and settle THIS member's charges, archived or not.""" return member_payment_memberships(user).filter(makerspace_id=makerspace_id).first() + + +def member_may_see_own_charges(user, makerspace_id): + """Whether this caller may see the charges they themselves owe here. + + An active membership is the ordinary answer, but it cannot be the only one. A loan + deposit or late fee is raised against a BORROWER, and borrowing needs an active + account -- not a membership -- so an account-only borrower was told "an active + membership is required" about a debt in their own name, with no way to read the + amount or see that staff had settled it. + + Deliberately narrow in three ways, because widening a money surface is exactly where + a security check gets quietly lost: + + * the account itself must still be live and unrestricted -- the same clauses + `active_member_memberships` applies, so a suspended or restricted user gains + nothing here; + * it only admits a caller who holds NO membership in this makerspace. If they hold + one, its status decides, so a REVOKED member is still refused -- that is a + deliberate existing contract and not mine to reverse; + * ownership is read through `member_payment_queryset`, already filtered to + `member=user`, so it can only ever admit someone to their own money. + """ + from apps.makerspaces.models import MakerspaceMembership + + if member_payment_actor(user, makerspace_id) is not None: + return True + if not ( + user + and user.is_authenticated + and user.pk + and user.is_active + and user.access_status == user.AccessStatus.ACTIVE + ): + return False + if MakerspaceMembership.objects.filter( + user=user, makerspace_id=makerspace_id + ).exists(): + # A membership exists but did not qualify above, so it is revoked, pending or + # otherwise inactive. That answer stands. + return False + from apps.payments.member_scope import member_payment_queryset + + return member_payment_queryset(user, makerspace_id).exists() diff --git a/backend/apps/payments/migrations/0014_refund_and_loan_settings.py b/backend/apps/payments/migrations/0014_refund_and_loan_settings.py new file mode 100644 index 00000000..f855caf0 --- /dev/null +++ b/backend/apps/payments/migrations/0014_refund_and_loan_settings.py @@ -0,0 +1,72 @@ +# Generated by Django 6.0.8 on 2026-09-03 21:45 + +import django.core.validators +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('payments', '0013_allow_waived_online_settlement'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='makerspacepaymentsettings', + name='loan_deposit_amount', + field=models.DecimalField(decimal_places=2, default=0, max_digits=12), + ), + migrations.AddField( + model_name='makerspacepaymentsettings', + name='loan_deposit_blocks_issue', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='makerspacepaymentsettings', + name='loan_deposit_mode', + field=models.CharField(choices=[('none', 'No deposit'), ('fixed', 'Fixed amount per loan'), ('per_product', 'Sum of product deposits')], default='none', max_length=16), + ), + migrations.AddField( + model_name='makerspacepaymentsettings', + name='loan_grace_days', + field=models.PositiveSmallIntegerField(default=0), + ), + migrations.AddField( + model_name='makerspacepaymentsettings', + name='loan_late_fee_cap', + field=models.DecimalField(decimal_places=2, default=0, max_digits=12), + ), + migrations.AddField( + model_name='makerspacepaymentsettings', + name='loan_late_fee_per_day', + field=models.DecimalField(decimal_places=2, default=0, max_digits=12), + ), + migrations.AlterField( + model_name='payment', + name='subject_type', + field=models.CharField(choices=[('machine_service_request', 'Machine service request'), ('booking', 'Booking'), ('event_registration', 'Event registration'), ('makerspace_membership', 'Makerspace membership'), ('membership_term', 'Membership renewal'), ('loan_deposit', 'Loan deposit'), ('loan_late_fee', 'Loan late fee')], max_length=48), + ), + migrations.CreateModel( + name='Refund', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('amount', models.DecimalField(decimal_places=2, max_digits=12)), + ('currency', models.CharField(max_length=3, validators=[django.core.validators.RegexValidator(message='Default currency must be a three-letter lowercase ISO currency code.', regex='^[a-z]{3}$')])), + ('status', models.CharField(choices=[('pending', 'Pending'), ('succeeded', 'Succeeded'), ('failed', 'Failed')], default='pending', max_length=16)), + ('provider', models.CharField(choices=[('stripe', 'Stripe'), ('razorpay', 'Razorpay')], max_length=16)), + ('external_refund_id', models.CharField(blank=True, max_length=255, null=True)), + ('reason', models.CharField(blank=True, default='', max_length=255)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('settled_at', models.DateTimeField(blank=True, null=True)), + ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='created_refunds', to=settings.AUTH_USER_MODEL)), + ('payment', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='refunds', to='payments.payment')), + ], + options={ + 'ordering': ['-created_at', '-pk'], + 'constraints': [models.CheckConstraint(condition=models.Q(('amount__gt', 0)), name='refund_amount_positive'), models.UniqueConstraint(condition=models.Q(('external_refund_id__isnull', False)), fields=('provider', 'external_refund_id'), name='refund_external_once_per_provider')], + }, + ), + ] diff --git a/backend/apps/payments/migrations/0015_payment_unclaimed_provider.py b/backend/apps/payments/migrations/0015_payment_unclaimed_provider.py new file mode 100644 index 00000000..3188616e --- /dev/null +++ b/backend/apps/payments/migrations/0015_payment_unclaimed_provider.py @@ -0,0 +1,85 @@ +from django.db import migrations, models + + +# Extends the live guard from 0013 (which this REPLACES -- 0013's body is reproduced +# below, waived->paid_online exception included) with provider-claim enforcement. +# +# A charge raised while the space had no gateway configured is stamped `unclaimed`. The +# first checkout that reaches a provider claims it. That transition must be possible +# exactly once and never reversible, otherwise a settled charge could be re-pointed at a +# merchant account that never took the money -- the same reasoning that made provider +# provenance immutable in the first place. +FORWARD_SQL = """CREATE OR REPLACE FUNCTION payments_payment_terminal_guard() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + IF TG_OP = 'DELETE' THEN + IF current_setting('app.allow_immutable_delete', true) = 'on' THEN RETURN OLD; END IF; + RAISE EXCEPTION 'payment is immutable'; + END IF; + IF TG_OP = 'UPDATE' AND OLD.provider <> 'unclaimed' AND NEW.provider <> OLD.provider THEN + RAISE EXCEPTION 'payment provider is immutable once claimed'; + END IF; + IF TG_OP = 'UPDATE' AND OLD.status <> 'pending' AND (NEW.status <> OLD.status OR NEW.amount <> OLD.amount) THEN + IF current_setting('app.allow_waived_online_settlement', true) = 'on' + AND OLD.status = 'waived' AND NEW.status = 'paid_online' AND NEW.amount = OLD.amount THEN + RETURN NEW; + END IF; + RAISE EXCEPTION 'terminal payment is immutable'; + END IF; + RETURN COALESCE(NEW, OLD); +END; $$;""" + +REVERSE_SQL = """CREATE OR REPLACE FUNCTION payments_payment_terminal_guard() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + IF TG_OP = 'DELETE' THEN + IF current_setting('app.allow_immutable_delete', true) = 'on' THEN RETURN OLD; END IF; + RAISE EXCEPTION 'payment is immutable'; + END IF; + IF TG_OP = 'UPDATE' AND OLD.status <> 'pending' AND (NEW.status <> OLD.status OR NEW.amount <> OLD.amount) THEN + IF current_setting('app.allow_waived_online_settlement', true) = 'on' + AND OLD.status = 'waived' AND NEW.status = 'paid_online' AND NEW.amount = OLD.amount THEN + RETURN NEW; + END IF; + RAISE EXCEPTION 'terminal payment is immutable'; + END IF; + RETURN COALESCE(NEW, OLD); +END; $$;""" + + +class Migration(migrations.Migration): + # Chained off the ACTUAL leaf, read from the migrations directory -- not a number + # quoted in a spec. + dependencies = [("payments", "0014_refund_and_loan_settings")] + + operations = [ + # Choices-only: Django records the new member, the column is unchanged. Existing + # rows keep their `stripe`/`razorpay` stamp and are therefore already claimed. + migrations.AlterField( + model_name="payment", + name="provider", + field=models.CharField( + choices=[ + ("stripe", "Stripe"), + ("razorpay", "Razorpay"), + ("unclaimed", "No online rail"), + ], + default="stripe", + max_length=16, + ), + ), + # Refund.provider reuses Payment.Provider.choices, so Django's state needs the + # new member here too. It is unreachable in practice: a Refund is only ever raised + # against a paid_online Payment, which is claimed by definition. + migrations.AlterField( + model_name="refund", + name="provider", + field=models.CharField( + choices=[ + ("stripe", "Stripe"), + ("razorpay", "Razorpay"), + ("unclaimed", "No online rail"), + ], + max_length=16, + ), + ), + migrations.RunSQL(FORWARD_SQL, REVERSE_SQL), + ] diff --git a/backend/apps/payments/migrations/0016_manual_settlement_ledger.py b/backend/apps/payments/migrations/0016_manual_settlement_ledger.py new file mode 100644 index 00000000..671a0ade --- /dev/null +++ b/backend/apps/payments/migrations/0016_manual_settlement_ledger.py @@ -0,0 +1,63 @@ +# Generated by Django 6.0.8 on 2026-09-05 20:11 + +import django.core.validators +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +# Append-only at the DATABASE, not merely in the ORM. The whole reason this ledger is a +# separate table rather than four columns on `Payment` is that a receipt must not be +# quietly rewritten after the fact -- the payment terminal-guard only protects `status` +# and `amount`, so columns there would have been editable with nothing to show for it. +# Corrections are an appended `amends` row instead. +SETTLEMENT_GUARD_SQL = """CREATE OR REPLACE FUNCTION payments_manual_settlement_guard() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + IF TG_OP = 'DELETE' THEN + -- The lifecycle purge is the one legitimate eraser, exactly as for Payment itself. + IF current_setting('app.allow_immutable_delete', true) = 'on' THEN RETURN OLD; END IF; + RAISE EXCEPTION 'manual settlement is append-only'; + END IF; + RAISE EXCEPTION 'manual settlement is append-only'; +END; $$;""" + +DROP_SETTLEMENT_GUARD_SQL = "DROP FUNCTION IF EXISTS payments_manual_settlement_guard() CASCADE;" + +ATTACH_SQL = """DROP TRIGGER IF EXISTS payments_manual_settlement_guard ON payments_manualsettlement; +CREATE TRIGGER payments_manual_settlement_guard +BEFORE UPDATE OR DELETE ON payments_manualsettlement +FOR EACH ROW EXECUTE FUNCTION payments_manual_settlement_guard();""" + +DETACH_SQL = "DROP TRIGGER IF EXISTS payments_manual_settlement_guard ON payments_manualsettlement;" + + +class Migration(migrations.Migration): + + dependencies = [ + ('payments', '0015_payment_unclaimed_provider'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='ManualSettlement', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('method', models.CharField(choices=[('cash', 'Cash'), ('upi', 'UPI'), ('bank_transfer', 'Bank transfer'), ('card_machine', 'Card machine'), ('cheque', 'Cheque'), ('other', 'Other')], max_length=16)), + ('reference', models.CharField(blank=True, default='', max_length=64)), + ('received_at', models.DateTimeField()), + ('amount', models.DecimalField(decimal_places=2, max_digits=12)), + ('currency', models.CharField(max_length=3, validators=[django.core.validators.RegexValidator(message='Default currency must be a three-letter lowercase ISO currency code.', regex='^[a-z]{3}$')])), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('amends', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='amended_by', to='payments.manualsettlement')), + ('payment', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='manual_settlements', to='payments.payment')), + ('recorded_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='recorded_settlements', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-created_at', '-pk'], + 'constraints': [models.CheckConstraint(condition=models.Q(('amount__gt', 0)), name='manual_settlement_amount_positive')], + }, + ), + migrations.RunSQL(SETTLEMENT_GUARD_SQL, DROP_SETTLEMENT_GUARD_SQL), + migrations.RunSQL(ATTACH_SQL, DETACH_SQL), + ] diff --git a/backend/apps/payments/migrations/0017_settlement_cascade.py b/backend/apps/payments/migrations/0017_settlement_cascade.py new file mode 100644 index 00000000..9d3f72e0 --- /dev/null +++ b/backend/apps/payments/migrations/0017_settlement_cascade.py @@ -0,0 +1,24 @@ +# Generated by Django 6.0.8 on 2026-09-05 22:43 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('payments', '0016_manual_settlement_ledger'), + ] + + operations = [ + migrations.AlterField( + model_name='manualsettlement', + name='amends', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='amended_by', to='payments.manualsettlement'), + ), + migrations.AlterField( + model_name='manualsettlement', + name='payment', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='manual_settlements', to='payments.payment'), + ), + ] diff --git a/backend/apps/payments/models.py b/backend/apps/payments/models.py index 7b698a4b..c6155a4e 100644 --- a/backend/apps/payments/models.py +++ b/backend/apps/payments/models.py @@ -1,4 +1,6 @@ from apps.payments.models_payment import Payment, ProcessedStripeEvent +from apps.payments.models_refund import Refund +from apps.payments.models_settlement import ManualSettlement from apps.payments.models_settings import ( MakerspacePaymentSettings, PlatformStripeConnectSettings, @@ -8,9 +10,11 @@ __all__ = [ "MakerspacePaymentSettings", + "ManualSettlement", "Payment", "PlatformStripeConnectSettings", "ProcessedStripeEvent", + "Refund", "StripeConnectOAuthState", "currency_validator", ] diff --git a/backend/apps/payments/models_payment.py b/backend/apps/payments/models_payment.py index b1b7eda6..6af83277 100644 --- a/backend/apps/payments/models_payment.py +++ b/backend/apps/payments/models_payment.py @@ -10,6 +10,15 @@ class SubjectType(models.TextChoices): BOOKING = "booking", "Booking" EVENT_REGISTRATION = "event_registration", "Event registration" MAKERSPACE_MEMBERSHIP = "makerspace_membership", "Makerspace membership" + # subject_id is the `makerspaces.MembershipTerm` pk: the membership pk is already + # taken by the dues charge above, and one term gets exactly one renewal charge. + MEMBERSHIP_TERM = "membership_term", "Membership renewal" + # Loan charges (forward plan phase 6). subject_id is the HardwareRequest id, so + # the one-per-subject constraint gives one deposit and one late fee per loan. + LOAN_DEPOSIT = "loan_deposit", "Loan deposit" + LOAN_LATE_FEE = "loan_late_fee", "Loan late fee" + + LOAN_SUBJECT_TYPES = ("loan_deposit", "loan_late_fee") class Status(models.TextChoices): PENDING = "pending", "Pending" @@ -31,6 +40,13 @@ class Provider(models.TextChoices): STRIPE = "stripe", "Stripe" RAZORPAY = "razorpay", "Razorpay" + # A debt raised while the space had no gateway configured. It is real money owed + # and fully reconcilable offline; it simply has no rail behind it yet. The first + # checkout that reaches a provider claims the row (services._claim_provider), and + # a DB trigger permits that transition exactly once. Without this state such a row + # would be stamped `stripe` and could never be claimed by a gateway configured + # later, because provider provenance is immutable. + UNCLAIMED = "unclaimed", "No online rail" class OnlineRail(models.TextChoices): CHECKOUT = 'checkout', 'Stripe Checkout' @@ -170,6 +186,22 @@ def clean(self): raise ValidationError( {"subject_id": "Payment subject must belong to the payment makerspace."} ) + if self.subject_type in self.LOAN_SUBJECT_TYPES and self.subject_id: + from apps.hardware_requests.models import HardwareRequest + + if not subject_identity_unchanged and not HardwareRequest.objects.filter( + pk=self.subject_id, + makerspace_id=self.makerspace_id, + ).exists(): + raise ValidationError({"subject_id": "Payment subject must belong to the payment makerspace."}) + if self.subject_type == self.SubjectType.MEMBERSHIP_TERM and self.subject_id: + from apps.makerspaces.models import MembershipTerm + + if not subject_identity_unchanged and not MembershipTerm.objects.filter( + pk=self.subject_id, + membership__makerspace_id=self.makerspace_id, + ).exists(): + raise ValidationError({"subject_id": "Payment subject must belong to the payment makerspace."}) if self.subject_type == self.SubjectType.MAKERSPACE_MEMBERSHIP and self.subject_id: from apps.makerspaces.models import MakerspaceMembership @@ -195,7 +227,18 @@ def save(self, *args, **kwargs): original["status"] != self.status or original["amount"] != self.amount ): raise ValidationError("Terminal payments are immutable.") - if original and any( + # A charge raised with no gateway configured carries `provider=unclaimed`: + # it has no provenance yet, so the first checkout that reaches a provider is + # allowed to stamp one, exactly once. Without this exemption the guard below + # rejected that stamp and an unclaimed debt could NEVER be claimed, even + # after valid credentials were added -- the database trigger permitting the + # transition was never reached. + claiming = ( + original + and original["provider"] == self.Provider.UNCLAIMED + and self.provider != self.Provider.UNCLAIMED + ) + if not claiming and original and any( original[field] != getattr(self, field) for field in ("provider", "stripe_provider", "stripe_connected_account_id", "stripe_application_fee_amount") ): diff --git a/backend/apps/payments/models_refund.py b/backend/apps/payments/models_refund.py new file mode 100644 index 00000000..2a5e1e8f --- /dev/null +++ b/backend/apps/payments/models_refund.py @@ -0,0 +1,94 @@ +"""Refunds: a separate ledger line, because a terminal Payment row may never change. + +A refund is money going back on a charge that was really collected online. The Payment +row is immutable once terminal (Postgres trigger), so refund state lives here and only +here: one row per refund attempt, PENDING until the provider confirms, then SUCCEEDED or +FAILED, never edited afterwards. Partial refunds are several rows; the invariant that +their (pending + succeeded) sum never exceeds the charge is enforced by the service +under the Payment row lock and re-checked in `clean()` as a last line of defence. +""" + +from decimal import Decimal + +from django.core.exceptions import ValidationError +from django.db import models +from django.db.models import Sum + +from apps.payments.models_payment import Payment +from apps.payments.models_settings import currency_validator + + +class Refund(models.Model): + class Status(models.TextChoices): + PENDING = "pending", "Pending" + SUCCEEDED = "succeeded", "Succeeded" + FAILED = "failed", "Failed" + + #: Statuses that count against the refundable balance. A failed attempt returned + #: nothing, so it must not block a retry. + COUNTED_STATUSES = (Status.PENDING, Status.SUCCEEDED) + + payment = models.ForeignKey(Payment, on_delete=models.PROTECT, related_name="refunds") + amount = models.DecimalField(max_digits=12, decimal_places=2) + currency = models.CharField(max_length=3, validators=[currency_validator]) + status = models.CharField(max_length=16, choices=Status.choices, default=Status.PENDING) + provider = models.CharField(max_length=16, choices=Payment.Provider.choices) + external_refund_id = models.CharField(max_length=255, null=True, blank=True) + # Staff-authored, short, shown on the receipt. Financial metadata: never a person's + # contact details or free-form PII -- the same rule as `Payment.subject_label`. + reason = models.CharField(max_length=255, blank=True, default="") + created_by = models.ForeignKey( + "accounts.User", on_delete=models.PROTECT, related_name="created_refunds" + ) + created_at = models.DateTimeField(auto_now_add=True) + settled_at = models.DateTimeField(null=True, blank=True) + + class Meta: + ordering = ["-created_at", "-pk"] + constraints = [ + models.CheckConstraint(condition=models.Q(amount__gt=0), name="refund_amount_positive"), + # Scoped by provider for the same reason as Payment's external ids: two + # vendors are free to mint the same opaque id. + models.UniqueConstraint( + fields=["provider", "external_refund_id"], + name="refund_external_once_per_provider", + condition=models.Q(external_refund_id__isnull=False), + ), + ] + + def __str__(self): + return f"Refund {self.pk} ({self.status}) on payment {self.payment_id}" + + @classmethod + def counted_total(cls, payment, *, exclude_pk=None): + """Sum of refunds that hold or have returned money on `payment`.""" + rows = cls.objects.filter(payment=payment, status__in=cls.COUNTED_STATUSES) + if exclude_pk is not None: + rows = rows.exclude(pk=exclude_pk) + return rows.aggregate(total=Sum("amount"))["total"] or Decimal("0.00") + + def clean(self): + self.currency = (self.currency or "").lower() + currency_validator(self.currency) + if self.amount is None or self.amount <= 0: + raise ValidationError({"amount": "Refund amount must be positive."}) + if self.status in self.COUNTED_STATUSES and self.payment_id: + payment = Payment.objects.filter(pk=self.payment_id).only("amount").first() + if payment is not None and ( + self.counted_total(payment, exclude_pk=self.pk) + self.amount > payment.amount + ): + raise ValidationError({"amount": "Refunds exceed the payment amount."}) + + def save(self, *args, **kwargs): + if self.pk: + original = ( + type(self).objects.filter(pk=self.pk).values("status", "amount", "payment_id").first() + ) + if original and original["status"] != self.Status.PENDING and ( + original["status"] != self.status or original["amount"] != self.amount + ): + raise ValidationError("Settled refunds are immutable.") + if original and original["payment_id"] != self.payment_id: + raise ValidationError("A refund cannot move between payments.") + self.full_clean() + return super().save(*args, **kwargs) diff --git a/backend/apps/payments/models_settings.py b/backend/apps/payments/models_settings.py index ecefd9c7..a9de3f06 100644 --- a/backend/apps/payments/models_settings.py +++ b/backend/apps/payments/models_settings.py @@ -17,6 +17,11 @@ class MakerspacePaymentSettings(models.Model): + class LoanDepositMode(models.TextChoices): + NONE = "none", "No deposit" + FIXED = "fixed", "Fixed amount per loan" + PER_PRODUCT = "per_product", "Sum of product deposits" + class ConnectStatus(models.TextChoices): UNCONNECTED = "unconnected", "Unconnected" PENDING = "pending", "Pending" @@ -62,6 +67,19 @@ class ConnectStatus(models.TextChoices): razorpay_key_id = models.CharField(max_length=255, blank=True, default="") razorpay_key_secret = models.TextField(blank=True, default="") razorpay_webhook_secret = models.TextField(blank=True, default="") + # Loan deposits and late fees (`payments.loans`, default off). Major units like + # `Payment.amount`; zero means "not charged", and a zero cap means "no cap". + loan_deposit_mode = models.CharField( + max_length=16, choices=LoanDepositMode.choices, default=LoanDepositMode.NONE + ) + loan_deposit_amount = models.DecimalField(max_digits=12, decimal_places=2, default=0) + loan_late_fee_per_day = models.DecimalField(max_digits=12, decimal_places=2, default=0) + loan_late_fee_cap = models.DecimalField(max_digits=12, decimal_places=2, default=0) + loan_grace_days = models.PositiveSmallIntegerField(default=0) + # Off by default: a deposit is RAISED at issue and payable afterwards. On, the + # handover refuses until the deposit is settled -- and that gate runs before the + # QR/evidence Hard Rules, so an unpaid deposit is reported before a missing photo. + loan_deposit_blocks_issue = models.BooleanField(default=False) class Meta: verbose_name = "makerspace payment settings" @@ -136,6 +154,13 @@ def clean(self): raise ValidationError( {"default_currency": "Enter a three-letter lowercase currency code."} ) + negative = { + name: "Loan amounts cannot be negative." + for name in ("loan_deposit_amount", "loan_late_fee_per_day", "loan_late_fee_cap") + if (getattr(self, name) or 0) < 0 + } + if negative: + raise ValidationError(negative) def save(self, *args, **kwargs): self.default_currency = (self.default_currency or "").strip().lower() diff --git a/backend/apps/payments/models_settlement.py b/backend/apps/payments/models_settlement.py new file mode 100644 index 00000000..b4a27740 --- /dev/null +++ b/backend/apps/payments/models_settlement.py @@ -0,0 +1,136 @@ +"""The manual-settlement ledger: how a charge was paid when there was no online rail. + +A `Payment` row records that money is OWED. When staff take that money in person -- +cash, UPI, a bank transfer, a card machine, a cheque -- the row's status flips to +`paid_offline`, and this ledger records *how*, *when* and *by whom*, so the space has a +real cash book rather than a bare boolean. + +Append-only by design. Putting these columns on `Payment` was rejected because the +terminal-guard trigger only protects `status` and `amount`: a receipt reference sitting +on the payment row could be quietly rewritten afterwards with nothing to show for it. +Corrections are therefore an appended `amends` row -- the same shape `Refund` and +`AuditLog` already use -- so a mistyped reference leaves both the error and the fix +visible. +""" + +from decimal import Decimal + +from django.core.exceptions import ValidationError +from django.db import models + +from apps.payments.models_payment import Payment +from apps.payments.models_settings import currency_validator + + +class ManualSettlement(models.Model): + class Method(models.TextChoices): + CASH = "cash", "Cash" + UPI = "upi", "UPI" + BANK_TRANSFER = "bank_transfer", "Bank transfer" + CARD_MACHINE = "card_machine", "Card machine" + CHEQUE = "cheque", "Cheque" + OTHER = "other", "Other" + + # CASCADE, not PROTECT: a receipt explains exactly one payment and has no meaning + # without it. PROTECT made the lifecycle purge raise ProtectedError instead of + # deleting, and it masked the payment's own immutability trigger outside a purge -- + # a delete attempt failed with the wrong error for the wrong reason. Deletion stays + # impossible in normal operation because this table's own trigger refuses DELETE + # unless the purge GUC is set, exactly as Payment does. + payment = models.ForeignKey( + Payment, on_delete=models.CASCADE, related_name="manual_settlements" + ) + method = models.CharField(max_length=16, choices=Method.choices) + # A short operator-entered handle: a UPI reference, a cheque number, a terminal slip + # id. Financial metadata, never a person's contact details -- the same rule written + # on `Payment.subject_label` and `Refund.reason`. Bounded so it cannot become a + # free-text notes field by habit. + reference = models.CharField(max_length=64, blank=True, default="") + #: When the money actually changed hands, which is not always when staff recorded it. + received_at = models.DateTimeField() + amount = models.DecimalField(max_digits=12, decimal_places=2) + currency = models.CharField(max_length=3, validators=[currency_validator]) + recorded_by = models.ForeignKey( + "accounts.User", on_delete=models.PROTECT, related_name="recorded_settlements" + ) + #: Set when this row REPLACES an earlier receipt on the same payment. The chain head + #: -- the row nothing amends -- is the effective receipt. + amends = models.OneToOneField( + "self", + null=True, + blank=True, + # Same reasoning: the whole correction chain belongs to one payment and dies + # with it. PROTECT here meant a chain could only be purged one layer at a time. + on_delete=models.CASCADE, + related_name="amended_by", + ) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ["-created_at", "-pk"] + constraints = [ + models.CheckConstraint( + condition=models.Q(amount__gt=0), name="manual_settlement_amount_positive" + ), + ] + + def __str__(self): + return f"{self.get_method_display()} settlement on payment {self.payment_id}" + + @classmethod + def effective_for(cls, payment): + """The receipt that currently stands: the newest row nothing has amended.""" + return ( + cls.objects.filter(payment=payment, amended_by__isnull=True) + .order_by("-created_at", "-pk") + .first() + ) + + def clean(self): + self.currency = (self.currency or "").lower() + currency_validator(self.currency) + if self.amount is None or self.amount <= 0: + raise ValidationError({"amount": "Settlement amount must be positive."}) + if self.received_at is None: + raise ValidationError({"received_at": "A settlement needs a received date."}) + if self.amends_id: + # A correction replaces a receipt on the SAME debt. Allowing it to cross + # payments would let one space's cash book absorb another's. + amended = type(self).objects.filter(pk=self.amends_id).values( + "payment_id" + ).first() + if amended and amended["payment_id"] != self.payment_id: + raise ValidationError( + {"amends": "An amendment must stay on the same payment."} + ) + if self.amends_id == self.pk: + raise ValidationError({"amends": "A settlement cannot amend itself."}) + if self.payment_id: + payment = ( + Payment.objects.filter(pk=self.payment_id) + .only("amount", "currency") + .first() + ) + if payment is not None: + # Partial manual settlement is deliberately NOT introduced here: one + # receipt settles one debt in full, so a mismatch is an operator error + # rather than a part payment nobody can reconcile later. + if Decimal(self.amount) != payment.amount: + raise ValidationError( + {"amount": "A settlement must match the payment amount."} + ) + if self.currency != payment.currency: + raise ValidationError( + {"currency": "A settlement must match the payment currency."} + ) + + def save(self, *args, **kwargs): + if self.pk: + raise ValidationError( + "Manual settlements are append-only; record an amendment instead." + ) + self.full_clean() + return super().save(*args, **kwargs) + + def delete(self, *args, **kwargs): + raise ValidationError("Manual settlements are append-only and cannot be deleted.") diff --git a/backend/apps/payments/providers/__init__.py b/backend/apps/payments/providers/__init__.py index a28c5b75..cd8842d0 100644 --- a/backend/apps/payments/providers/__init__.py +++ b/backend/apps/payments/providers/__init__.py @@ -11,6 +11,8 @@ CheckoutResult, PaymentProvider, PaymentsUnavailable, + RefundRequest, + RefundResult, WebhookEvent, WebhookVerificationError, ) diff --git a/backend/apps/payments/providers/base.py b/backend/apps/payments/providers/base.py index 248a3d53..675b0388 100644 --- a/backend/apps/payments/providers/base.py +++ b/backend/apps/payments/providers/base.py @@ -68,6 +68,28 @@ class CheckoutResult: payment_id: str = "" +@dataclass(frozen=True) +class RefundRequest: + """Everything a provider needs to send money back on a captured charge.""" + + #: The provider's id for the captured payment (Stripe PaymentIntent, Razorpay payment). + payment_id: str + amount_minor: int + currency: str + #: Our Refund row id, echoed back so the webhook can bind the provider's refund id. + reference: str + idempotency_key: str + metadata: dict + + +@dataclass(frozen=True) +class RefundResult: + """The provider's synchronous answer. `status` is one of pending/succeeded/failed.""" + + refund_id: str + status: str + + @dataclass(frozen=True) class WebhookEvent: """A verified, provider-agnostic settlement notification.""" @@ -81,6 +103,11 @@ class WebhookEvent: payment_id: str = "" #: The `metadata`/`notes` echoed back from CheckoutRequest. metadata: dict | None = None + #: Refund notifications. Non-empty `refund_id` marks the event as a refund event; + #: `refund_status` is normalised to pending/succeeded/failed by the provider. + refund_id: str = "" + refund_status: str = "" + refund_amount_minor: int = 0 class PaymentProvider(Protocol): @@ -100,6 +127,14 @@ def expire_checkout(self, source, order_id: str) -> None: """ ... + def create_refund(self, source, request: RefundRequest) -> RefundResult: + """Send money back on a captured charge. Raises PaymentsUnavailable on failure. + + The caller has already recorded a PENDING Refund row; this must return the + provider's refund id so a later webhook can settle that exact row. + """ + ... + def verify_webhook(self, source, *, payload: bytes, headers) -> WebhookEvent: """Verify the signature over the RAW body and return the event. diff --git a/backend/apps/payments/providers/razorpay.py b/backend/apps/payments/providers/razorpay.py index 17f597d3..e6411b28 100644 --- a/backend/apps/payments/providers/razorpay.py +++ b/backend/apps/payments/providers/razorpay.py @@ -30,6 +30,8 @@ CheckoutRequest, CheckoutResult, PaymentsUnavailable, + RefundRequest, + RefundResult, WebhookEvent, WebhookVerificationError, ) @@ -43,6 +45,14 @@ # an authorised-but-uncaptured payment is not settled, and treating it as paid would # mark a charge complete for money that can still fail to arrive. PAID_EVENTS = frozenset({"payment_link.paid", "order.paid", "payment.captured"}) +# Razorpay refund statuses, normalised to the three the Refund row knows. `created` +# and `pending` both mean "accepted, money not yet back"; only `processed` is final. +REFUND_STATUSES = { + "processed": "succeeded", + "pending": "pending", + "created": "pending", + "failed": "failed", +} class RazorpayProvider: @@ -121,6 +131,27 @@ def expire_checkout(self, source, order_id: str) -> None: # corrects a waiver or raises an explicit refund-required audit condition. logger.info("razorpay_link_cancel_failed", extra={"order_id": order_id}) + def create_refund(self, source, request: RefundRequest) -> RefundResult: + if not request.payment_id: + raise PaymentsUnavailable("The payment has no Razorpay payment id to refund.") + result = self._request( + source, + "POST", + f"/payments/{request.payment_id}/refund", + { + "amount": request.amount_minor, + "receipt": request.reference[:40], + "notes": {key: str(value) for key, value in (request.metadata or {}).items()}, + }, + ) + refund_id = result.get("id") + if not refund_id: + raise PaymentsUnavailable("Razorpay did not return a refund id.") + return RefundResult( + refund_id=refund_id, + status=REFUND_STATUSES.get(result.get("status") or "", "pending"), + ) + def verify_webhook(self, source, *, payload: bytes, headers) -> WebhookEvent: signature = headers.get("X-Razorpay-Signature") or headers.get( "HTTP_X_RAZORPAY_SIGNATURE", "" @@ -145,6 +176,7 @@ def verify_webhook(self, source, *, payload: bytes, headers) -> WebhookEvent: link = (entities.get("payment_link") or {}).get("entity") or {} payment = (entities.get("payment") or {}).get("entity") or {} order = (entities.get("order") or {}).get("entity") or {} + refund = (entities.get("refund") or {}).get("entity") or {} # Razorpay does not send a top-level event id, so the delivery id header is the # idempotency anchor. Falling back to the entity id keeps the record unique per @@ -153,12 +185,15 @@ def verify_webhook(self, source, *, payload: bytes, headers) -> WebhookEvent: event_id = ( headers.get("X-Razorpay-Event-Id") or headers.get("HTTP_X_RAZORPAY_EVENT_ID") - or f"{event_type}:{link.get('id') or payment.get('id') or order.get('id') or ''}" + or f"{event_type}:{refund.get('id') or link.get('id') or payment.get('id') or order.get('id') or ''}" ) return WebhookEvent( event_id=event_id, is_paid=event_type in PAID_EVENTS, order_id=link.get("id") or order.get("id") or payment.get("order_id") or "", - payment_id=payment.get("id") or "", + payment_id=payment.get("id") or refund.get("payment_id") or "", metadata=link.get("notes") or payment.get("notes") or order.get("notes") or {}, + refund_id=refund.get("id") or "", + refund_status=REFUND_STATUSES.get(refund.get("status") or "", ""), + refund_amount_minor=int(refund.get("amount") or 0), ) diff --git a/backend/apps/payments/providers/stripe.py b/backend/apps/payments/providers/stripe.py index 847a4ed4..3ba518f6 100644 --- a/backend/apps/payments/providers/stripe.py +++ b/backend/apps/payments/providers/stripe.py @@ -13,10 +13,21 @@ CheckoutRequest, CheckoutResult, PaymentsUnavailable, + RefundRequest, + RefundResult, WebhookEvent, WebhookVerificationError, ) +# Stripe refund statuses, normalised to the three the Refund row knows. +REFUND_STATUSES = { + "succeeded": "succeeded", + "pending": "pending", + "requires_action": "pending", + "failed": "failed", + "canceled": "failed", +} + def _value(value, key): """Stripe objects are attribute-accessed; the test doubles are plain dicts.""" @@ -61,6 +72,29 @@ def expire_checkout(self, source, order_id: str) -> None: return stripe_client.expire_checkout_session(source, order_id) + def create_refund(self, source, request: RefundRequest) -> RefundResult: + if not request.payment_id: + raise PaymentsUnavailable("The payment has no Stripe PaymentIntent to refund.") + try: + refund = stripe_client.create_refund( + source, + idempotency_key=request.idempotency_key, + payment_intent=request.payment_id, + amount=request.amount_minor, + metadata={key: str(value) for key, value in (request.metadata or {}).items()}, + ) + except PaymentsUnavailable: + raise + except Exception as exc: + raise PaymentsUnavailable("Stripe rejected the refund.") from exc + refund_id = _value(refund, "id") + if not refund_id: + raise PaymentsUnavailable("Stripe did not return a refund id.") + return RefundResult( + refund_id=refund_id, + status=REFUND_STATUSES.get(_value(refund, "status") or "", "pending"), + ) + def verify_webhook(self, source, *, payload: bytes, headers) -> WebhookEvent: signature = headers.get("Stripe-Signature") or headers.get("HTTP_STRIPE_SIGNATURE", "") try: diff --git a/backend/apps/payments/reconciliation.py b/backend/apps/payments/reconciliation.py index 1b60c78e..acf19997 100644 --- a/backend/apps/payments/reconciliation.py +++ b/backend/apps/payments/reconciliation.py @@ -1,25 +1,27 @@ """Transactional reconciliation for every payment subject type.""" import logging +from decimal import Decimal from django.db import transaction -from django.utils import timezone -from rest_framework.exceptions import APIException, NotFound, PermissionDenied +from django.db.models import DecimalField, Q, Sum, Value +from django.db.models.functions import Coalesce +from rest_framework.exceptions import APIException, NotFound from apps.accounts import rbac from apps.audit import services as audit -from apps.payments import stripe_client from apps.payments.models import Payment -from apps.payments.resolution import source_for_payment -logger = logging.getLogger(__name__) +# Re-export barrel: these moved to submodules at the 300-line ceiling, but +# `from apps.payments.reconciliation import X` must keep resolving for every caller. +from apps.payments.reconciliation_authority import ( # noqa: F401 + SUBJECT_ACTIONS, + _require_machine_scope, + _require_subject_authority, +) +from apps.payments.reconciliation_rail import _expire_checkout_best_effort # noqa: F401 -SUBJECT_ACTIONS = { - Payment.SubjectType.MACHINE_SERVICE_REQUEST: rbac.Action.MANAGE_MACHINES, - Payment.SubjectType.BOOKING: rbac.Action.MANAGE_BOOKINGS, - Payment.SubjectType.EVENT_REGISTRATION: rbac.Action.MANAGE_EVENTS, - Payment.SubjectType.MAKERSPACE_MEMBERSHIP: rbac.Action.MANAGE_MAKERSPACE, -} +logger = logging.getLogger(__name__) class PaymentConflict(APIException): @@ -44,14 +46,33 @@ def list_payments(*, actor, makerspace_id, status=None, subject_type=None): queryset = queryset.filter(status=status) if subject_type: queryset = queryset.filter(subject_type=subject_type) - return queryset.order_by("-created_at", "-pk") + return with_refunds(queryset).order_by("-created_at", "-pk") + + +def with_refunds(queryset): + """Annotate the money actually sent back and prefetch the refund lines.""" + from apps.payments.models import Refund + + return queryset.prefetch_related("refunds").annotate( + refunded_amount=Coalesce( + Sum("refunds__amount", filter=Q(refunds__status=Refund.Status.SUCCEEDED)), + Value(Decimal("0.00")), + output_field=DecimalField(max_digits=12, decimal_places=2), + ) + ) + +def mark_offline(payment, actor, settlement=None): + """Settle one charge in cash. `settlement` is REQUIRED -- see reconcile_payments. -def mark_offline(payment, actor): + Kept as a one-row convenience over the batch service. Callers that genuinely have no + receipt detail want `waive` instead: that is the transition meaning "no money moved". + """ return _compat_reconcile( actor=actor, payment=payment, target_status=Payment.Status.PAID_OFFLINE, + settlement=settlement, ) @@ -95,7 +116,7 @@ def cancel_pending(*, makerspace, subject_type, subject_id, actor): return payment -def _compat_reconcile(*, payment, actor, target_status): +def _compat_reconcile(*, payment, actor, target_status, settlement=None): current = Payment.objects.get(pk=payment.pk) if current.status != Payment.Status.PENDING: return current @@ -104,14 +125,30 @@ def _compat_reconcile(*, payment, actor, target_status): makerspace_id=current.makerspace_id, payment_ids=[current.pk], target_status=target_status, + settlement=settlement, )[0] @transaction.atomic -def reconcile_payments(*, actor, makerspace_id, payment_ids, target_status): - """Lock, validate, then reconcile a batch without partial mutations.""" +def reconcile_payments( + *, actor, makerspace_id, payment_ids, target_status, settlement=None +): + """Lock, validate, then reconcile a batch without partial mutations. + + `settlement` is the manual receipt -- method, reference, received_at -- recorded for + a PAID_OFFLINE batch. It is written in the same transaction as the status flip, so a + settled charge can never exist without the cash-book row that explains it. Waiving + takes no settlement: no money changed hands. + """ if target_status not in {Payment.Status.PAID_OFFLINE, Payment.Status.WAIVED}: raise ValueError("Unsupported reconciliation status.") + if settlement and target_status != Payment.Status.PAID_OFFLINE: + raise ValueError("Only an offline settlement carries receipt details.") + # Enforced HERE, not only in the HTTP serializers: `mark_offline()` is exported and + # called directly, so a serializer-only rule would let a settled charge exist with no + # record of how the money arrived -- exactly what the ledger exists to prevent. + if target_status == Payment.Status.PAID_OFFLINE and not settlement: + raise ValueError("Marking a payment paid offline requires settlement details.") requested_ids = list(payment_ids) locked = list( @@ -144,130 +181,80 @@ def reconcile_payments(*, actor, makerspace_id, payment_ids, target_status): "updated_at", ] ) - audit.record(actor, action, makerspace=payment.makerspace, target=payment) + meta = None + if settlement and target_status == Payment.Status.PAID_OFFLINE: + receipt = _record_settlement(payment, actor, settlement) + meta = {"method": receipt.method, "settlement_id": receipt.pk} + audit.record( + actor, action, makerspace=payment.makerspace, target=payment, meta=meta + ) return [by_id[payment_id] for payment_id in requested_ids] -def _require_subject_authority(actor, payments): - for subject_type, action in SUBJECT_ACTIONS.items(): - ids = [payment.pk for payment in payments if payment.subject_type == subject_type] - if not ids: - continue - visible = set( - rbac.scope_by_action( - actor, - action, - Payment.objects.filter(pk__in=ids), - field="makerspace_id", - ).values_list("pk", flat=True) - ) - if visible != set(ids): - raise PermissionDenied("Payment action is not permitted.") - if subject_type == Payment.SubjectType.MACHINE_SERVICE_REQUEST: - _require_machine_scope(actor, payments) - if any(payment.subject_type not in SUBJECT_ACTIONS for payment in payments): - raise PermissionDenied("Payment subject type is not supported.") +@transaction.atomic +def amend_settlement(*, actor, makerspace_id, payment_id, settlement): + """Correct a receipt by APPENDING a replacement, never by editing one. + + The payment itself is already terminal and stays untouched -- this corrects only the + record of how the money arrived, which is exactly the case the `amends` chain exists + for: a mistyped reference or the wrong method picked at the desk. Without this the + field was unreachable and a wrong receipt was permanent, since every reconciliation + endpoint refuses a terminal payment. + """ + from apps.payments.models import ManualSettlement + + payment = ( + Payment.objects.select_for_update() + .select_related("makerspace") + .filter(makerspace_id=makerspace_id, pk=payment_id) + .first() + ) + if payment is None or payment.status != Payment.Status.PAID_OFFLINE: + raise NotFound("Settled payment not found.") + _require_subject_authority(actor, [payment]) + current = ManualSettlement.effective_for(payment) + if current is None: + raise NotFound("This payment has no settlement to correct.") + receipt = ManualSettlement.objects.create( + payment=payment, + method=settlement["method"], + reference=settlement.get("reference", ""), + received_at=settlement["received_at"], + amount=payment.amount, + currency=payment.currency, + recorded_by=actor, + amends=current, + ) + audit.record( + actor, + "payment.settlement_amended", + makerspace=payment.makerspace, + target=payment, + meta={ + "settlement_id": receipt.pk, + "amends_id": current.pk, + "method": receipt.method, + "previous_method": current.method, + }, + ) + return receipt -def _require_machine_scope(actor, payments): - """MANAGE_MACHINES is scoped per role, so reconciling a charge follows the job. +def _record_settlement(payment, actor, settlement): + """Append the cash-book row for one settled charge. - Imported locally: `apps.machines` reaches into `apps.payments` for service pricing, so - a module-level edge back would close the cycle. + Amount and currency are taken from the PAYMENT, never from the caller: the receipt + describes the debt that was settled, and letting a client name its own figure would + let the books disagree with the ledger they are supposed to explain. """ - from apps.machines.models import MachineServiceRequest - from apps.machines.role_scope import EXEMPT, manage_scopes_for, scoped_service_requests - - machine_payments = [ - payment - for payment in payments - if payment.subject_type == Payment.SubjectType.MACHINE_SERVICE_REQUEST - ] - subject_ids = {payment.subject_id for payment in machine_payments} - if not subject_ids: - return - requests = MachineServiceRequest.objects.filter(pk__in=subject_ids) - live_ids = set(requests.values_list("pk", flat=True)) - covered = set( - scoped_service_requests( - actor, - requests, - set(requests.values_list("makerspace_id", flat=True)), - ).values_list("pk", flat=True) + from apps.payments.models import ManualSettlement + + return ManualSettlement.objects.create( + payment=payment, + method=settlement["method"], + reference=settlement.get("reference", ""), + received_at=settlement["received_at"], + amount=payment.amount, + currency=payment.currency, + recorded_by=actor, ) - if covered != live_ids: - raise PermissionDenied("Payment action is not permitted.") - - # A charge whose service request was purged names no machine, type or team, so there is - # nothing left for machine scoping to answer. Comparing against `subject_ids` here made - # the set unequal for every actor, so a pending charge could never be waived or marked - # paid in cash -- stranding it forever, which is the exact failure that preserving the - # payment exists to prevent. Failing OPEN to every `MANAGE_MACHINES` holder would - # silently widen a scoped role, and scoping is documented as failing closed. So the - # orphan is actionable only by the actor machine scoping already exempts -- a space - # manager, a superadmin, or the null-`assigned_role` legacy fallback -- all of whom are - # unscoped everywhere else in this mechanism. - orphaned = subject_ids - live_ids - if not orphaned: - return - orphan_makerspace_ids = { - payment.makerspace_id - for payment in machine_payments - if payment.subject_id in orphaned - } - scopes = manage_scopes_for(actor, orphan_makerspace_ids) - if any(scopes.get(ms_id) is not EXEMPT for ms_id in orphan_makerspace_ids): - raise PermissionDenied("Payment action is not permitted.") - - -def _expire_checkout_best_effort(payment): - """Close a live online rail when staff settle a charge another way. - - Without this a member can still pay a hosted link or confirm a native PaymentIntent - for a charge already marked offline or waived. Best-effort by contract: the - reconciliation that called this must succeed regardless of what the vendor says. - """ - if ( - payment.online_rail == Payment.OnlineRail.NATIVE_PAYMENT_INTENT - and payment.stripe_payment_intent_id - ): - try: - source = source_for_payment(payment) - if source is None: - raise stripe_client.PaymentsUnavailable( - "The payment's provider credentials are no longer configured." - ) - if not stripe_client.cancel_payment_intent( - source, payment.stripe_payment_intent_id - ): - logger.warning( - "payment_intent_cancellation_unconfirmed", - extra={"payment_id": payment.pk}, - ) - except Exception: - logger.exception( - "payment_intent_cancellation_failed", - extra={"payment_id": payment.pk}, - ) - return - - order_id = payment.external_order_id or payment.stripe_checkout_session_id - if not order_id or payment.stripe_checkout_session_expired_at: - return - try: - source = source_for_payment(payment) - if source is None: - raise stripe_client.PaymentsUnavailable( - "The payment's provider credentials are no longer configured." - ) - if payment.provider != payment.Provider.STRIPE: - from apps.payments.providers import get_provider - - get_provider(payment.provider).expire_checkout(source, order_id) - payment.stripe_checkout_session_expired_at = timezone.now() - elif stripe_client.expire_checkout_session(source, order_id): - payment.stripe_checkout_session_expired_at = timezone.now() - except Exception: - logger.exception( - "payment_checkout_expiry_failed", extra={"payment_id": payment.pk} - ) diff --git a/backend/apps/payments/reconciliation_authority.py b/backend/apps/payments/reconciliation_authority.py new file mode 100644 index 00000000..9208a363 --- /dev/null +++ b/backend/apps/payments/reconciliation_authority.py @@ -0,0 +1,99 @@ +"""Who may reconcile a payment: subject-type authority and machine scoping. + +Split out of `reconciliation.py` at the 300-line ceiling. Import through the +`reconciliation` barrel -- `views_refunds` already imports `_require_subject_authority` +from there, so the name must keep resolving at its old path. +""" + +from rest_framework.exceptions import PermissionDenied + +from apps.accounts import rbac +from apps.payments.models import Payment + +SUBJECT_ACTIONS = { + Payment.SubjectType.MACHINE_SERVICE_REQUEST: rbac.Action.MANAGE_MACHINES, + Payment.SubjectType.BOOKING: rbac.Action.MANAGE_BOOKINGS, + Payment.SubjectType.EVENT_REGISTRATION: rbac.Action.MANAGE_EVENTS, + Payment.SubjectType.MAKERSPACE_MEMBERSHIP: rbac.Action.MANAGE_MAKERSPACE, + # A renewal charge is membership money like the dues above, so it takes the same + # authority. Its absence here meant `_require_subject_authority` fell through to the + # unsupported-subject refusal below and NO actor could ever reconcile a renewal -- + # a space collecting renewals in cash had no way to mark one paid. + Payment.SubjectType.MEMBERSHIP_TERM: rbac.Action.MANAGE_MAKERSPACE, + # Loan charges follow the handover job: whoever may issue settles the deposit, + # whoever may take a return settles the late fee. + Payment.SubjectType.LOAN_DEPOSIT: rbac.Action.ISSUE_REQUEST, + Payment.SubjectType.LOAN_LATE_FEE: rbac.Action.RETURN_REQUEST, +} + + +def _require_subject_authority(actor, payments): + for subject_type, action in SUBJECT_ACTIONS.items(): + ids = [payment.pk for payment in payments if payment.subject_type == subject_type] + if not ids: + continue + visible = set( + rbac.scope_by_action( + actor, + action, + Payment.objects.filter(pk__in=ids), + field="makerspace_id", + ).values_list("pk", flat=True) + ) + if visible != set(ids): + raise PermissionDenied("Payment action is not permitted.") + if subject_type == Payment.SubjectType.MACHINE_SERVICE_REQUEST: + _require_machine_scope(actor, payments) + if any(payment.subject_type not in SUBJECT_ACTIONS for payment in payments): + raise PermissionDenied("Payment subject type is not supported.") + + +def _require_machine_scope(actor, payments): + """MANAGE_MACHINES is scoped per role, so reconciling a charge follows the job. + + Imported locally: `apps.machines` reaches into `apps.payments` for service pricing, so + a module-level edge back would close the cycle. + """ + from apps.machines.models import MachineServiceRequest + from apps.machines.role_scope import EXEMPT, manage_scopes_for, scoped_service_requests + + machine_payments = [ + payment + for payment in payments + if payment.subject_type == Payment.SubjectType.MACHINE_SERVICE_REQUEST + ] + subject_ids = {payment.subject_id for payment in machine_payments} + if not subject_ids: + return + requests = MachineServiceRequest.objects.filter(pk__in=subject_ids) + live_ids = set(requests.values_list("pk", flat=True)) + covered = set( + scoped_service_requests( + actor, + requests, + set(requests.values_list("makerspace_id", flat=True)), + ).values_list("pk", flat=True) + ) + if covered != live_ids: + raise PermissionDenied("Payment action is not permitted.") + + # A charge whose service request was purged names no machine, type or team, so there is + # nothing left for machine scoping to answer. Comparing against `subject_ids` here made + # the set unequal for every actor, so a pending charge could never be waived or marked + # paid in cash -- stranding it forever, which is the exact failure that preserving the + # payment exists to prevent. Failing OPEN to every `MANAGE_MACHINES` holder would + # silently widen a scoped role, and scoping is documented as failing closed. So the + # orphan is actionable only by the actor machine scoping already exempts -- a space + # manager, a superadmin, or the null-`assigned_role` legacy fallback -- all of whom are + # unscoped everywhere else in this mechanism. + orphaned = subject_ids - live_ids + if not orphaned: + return + orphan_makerspace_ids = { + payment.makerspace_id + for payment in machine_payments + if payment.subject_id in orphaned + } + scopes = manage_scopes_for(actor, orphan_makerspace_ids) + if any(scopes.get(ms_id) is not EXEMPT for ms_id in orphan_makerspace_ids): + raise PermissionDenied("Payment action is not permitted.") diff --git a/backend/apps/payments/reconciliation_rail.py b/backend/apps/payments/reconciliation_rail.py new file mode 100644 index 00000000..9f370ee3 --- /dev/null +++ b/backend/apps/payments/reconciliation_rail.py @@ -0,0 +1,67 @@ +"""Closing a live online rail when staff settle a charge another way. + +Split out of `reconciliation.py` at the 300-line ceiling. +""" + +import logging + +from django.utils import timezone + +from apps.payments import stripe_client +from apps.payments.models import Payment +from apps.payments.resolution import source_for_payment + +logger = logging.getLogger(__name__) + + +def _expire_checkout_best_effort(payment): + """Close a live online rail when staff settle a charge another way. + + Without this a member can still pay a hosted link or confirm a native PaymentIntent + for a charge already marked offline or waived. Best-effort by contract: the + reconciliation that called this must succeed regardless of what the vendor says. + """ + if ( + payment.online_rail == Payment.OnlineRail.NATIVE_PAYMENT_INTENT + and payment.stripe_payment_intent_id + ): + try: + source = source_for_payment(payment) + if source is None: + raise stripe_client.PaymentsUnavailable( + "The payment's provider credentials are no longer configured." + ) + if not stripe_client.cancel_payment_intent( + source, payment.stripe_payment_intent_id + ): + logger.warning( + "payment_intent_cancellation_unconfirmed", + extra={"payment_id": payment.pk}, + ) + except Exception: + logger.exception( + "payment_intent_cancellation_failed", + extra={"payment_id": payment.pk}, + ) + return + + order_id = payment.external_order_id or payment.stripe_checkout_session_id + if not order_id or payment.stripe_checkout_session_expired_at: + return + try: + source = source_for_payment(payment) + if source is None: + raise stripe_client.PaymentsUnavailable( + "The payment's provider credentials are no longer configured." + ) + if payment.provider != payment.Provider.STRIPE: + from apps.payments.providers import get_provider + + get_provider(payment.provider).expire_checkout(source, order_id) + payment.stripe_checkout_session_expired_at = timezone.now() + elif stripe_client.expire_checkout_session(source, order_id): + payment.stripe_checkout_session_expired_at = timezone.now() + except Exception: + logger.exception( + "payment_checkout_expiry_failed", extra={"payment_id": payment.pk} + ) diff --git a/backend/apps/payments/serializers.py b/backend/apps/payments/serializers.py index a4039da2..b8a2ab6c 100644 --- a/backend/apps/payments/serializers.py +++ b/backend/apps/payments/serializers.py @@ -1,16 +1,34 @@ +from drf_spectacular.utils import extend_schema_field from rest_framework import serializers from apps.payments.models import Payment from apps.payments.subjects import subject_label +class MemberSettlementSerializer(serializers.Serializer): + """The receipt for a charge staff settled in person, shown back to the payer.""" + + method = serializers.CharField() + received_at = serializers.DateTimeField() + reference = serializers.CharField() + + class MemberPaymentSerializer(serializers.ModelSerializer): subject_label = serializers.SerializerMethodField() checkout_url = serializers.SerializerMethodField() + online_payment_available = serializers.SerializerMethodField() + settlement = serializers.SerializerMethodField() class Meta: model = Payment - fields = ("id", "subject_type", "subject_label", "status", "checkout_url", "created_at") + # `amount` and `currency` are the member's OWN debt. They were staff-private + # while a Stripe link always carried the figure; with cash settlement the payer + # has no other way to learn what to bring to the desk, so withholding it made + # the charge unpayable. The queryset is already scoped to this member. + fields = ( + "id", "subject_type", "subject_label", "status", "amount", "currency", + "checkout_url", "online_payment_available", "settlement", "created_at", + ) def get_subject_label(self, payment) -> str: labels = self.context.get("payment_subject_labels", {}) @@ -19,6 +37,44 @@ def get_subject_label(self, payment) -> str: def get_checkout_url(self, payment) -> str: return payment.stripe_checkout_url if payment.status == Payment.Status.PENDING else "" + def get_online_payment_available(self, payment) -> bool: + """Whether offering to pay online is honest for this charge. + + Resolved once per makerspace by the view and passed in: doing it per row would + re-read settings and credentials for every charge in the list. Falls back to a + live check only when a caller has not supplied it. + """ + from apps.payments.availability import online_payments_enabled_for + + rails = self.context.get("payment_rails") + if rails is not None: + # Keyed by (owning makerspace, subject type): both decide the answer, and a + # member list can mix charges owned by different spaces. + key = (payment.makerspace_id, payment.subject_type) + if key in rails: + return bool(rails[key]) + return online_payments_enabled_for(payment) + + @extend_schema_field(MemberSettlementSerializer(allow_null=True)) + def get_settlement(self, payment): + if payment.status != Payment.Status.PAID_OFFLINE: + return None + receipts = self.context.get("payment_settlements") + receipt = ( + receipts.get(payment.pk) + if receipts is not None + else _effective_settlement(payment) + ) + if receipt is None: + return None + return MemberSettlementSerializer(receipt).data + + +def _effective_settlement(payment): + from apps.payments.models import ManualSettlement + + return ManualSettlement.effective_for(payment) + class CheckoutUrlSerializer(serializers.Serializer): checkout_url = serializers.URLField() @@ -37,5 +93,6 @@ class ArchivedPaymentSummarySerializer(serializers.Serializer): class StaffPaymentSerializer(MemberPaymentSerializer): + # Amount and currency moved onto the member serializer, so staff inherit them. class Meta(MemberPaymentSerializer.Meta): - fields = MemberPaymentSerializer.Meta.fields + ("amount", "currency") + fields = MemberPaymentSerializer.Meta.fields diff --git a/backend/apps/payments/serializers_reconciliation.py b/backend/apps/payments/serializers_reconciliation.py index dd926516..97569458 100644 --- a/backend/apps/payments/serializers_reconciliation.py +++ b/backend/apps/payments/serializers_reconciliation.py @@ -1,11 +1,25 @@ +from datetime import timedelta +from decimal import Decimal + +from django.utils import timezone + from rest_framework import serializers -from apps.payments.models import Payment +from apps.payments.models import ManualSettlement, Payment, Refund from apps.payments.subjects import subject_label +class RefundSerializer(serializers.ModelSerializer): + class Meta: + model = Refund + fields = ("id", "amount", "currency", "status", "reason", "created_at", "settled_at") + read_only_fields = fields + + class PaymentReconciliationSerializer(serializers.ModelSerializer): subject_label = serializers.SerializerMethodField() + refunded_amount = serializers.SerializerMethodField() + refunds = RefundSerializer(many=True, read_only=True) class Meta: model = Payment @@ -17,6 +31,8 @@ class Meta: "status", "amount", "currency", + "refunded_amount", + "refunds", "created_at", "updated_at", ) @@ -28,6 +44,16 @@ def get_subject_label(self, payment) -> str: self.context.get("payment_subject_labels", {}), ) + def get_refunded_amount(self, payment) -> Decimal: + # `list_payments` annotates this; a single freshly reconciled row computes it. + annotated = getattr(payment, "refunded_amount", None) + if annotated is not None: + return annotated + return sum( + (refund.amount for refund in payment.refunds.all() if refund.status == Refund.Status.SUCCEEDED), + Decimal("0.00"), + ) + class PaymentListFilterSerializer(serializers.Serializer): status = serializers.ChoiceField(choices=Payment.Status.choices, required=False) @@ -45,3 +71,41 @@ def validate_ids(self, value): if len(value) != len(set(value)): raise serializers.ValidationError("Payment IDs must be unique.") return value + + +class ManualSettlementSerializer(serializers.Serializer): + """The receipt required to mark a charge paid offline. + + Method and received date are REQUIRED: the point of the ledger is that a settled + charge can always say how and when the money arrived, and an optional field would + quietly reproduce the bare boolean it replaces. `reference` stays optional because + cash genuinely has none. + """ + + method = serializers.ChoiceField(choices=ManualSettlement.Method.choices) + reference = serializers.CharField( + max_length=64, required=False, allow_blank=True, default="" + ) + received_at = serializers.DateTimeField() + + def validate_received_at(self, value): + # A receipt dated in the future is an operator slip, not a record of money that + # has already changed hands. Small tolerance for clock skew between devices. + if value > timezone.now() + timedelta(minutes=5): + raise serializers.ValidationError("A settlement cannot be received in the future.") + return value + + +class PaymentBulkOfflineSerializer(PaymentBulkActionSerializer): + settlement = ManualSettlementSerializer() + + +class PaymentOfflineSerializer(serializers.Serializer): + settlement = ManualSettlementSerializer() + + +class PaymentRefundRequestSerializer(serializers.Serializer): + amount = serializers.DecimalField( + max_digits=12, decimal_places=2, min_value=Decimal("0.01") + ) + reason = serializers.CharField(max_length=255, required=False, allow_blank=True, default="") diff --git a/backend/apps/payments/services.py b/backend/apps/payments/services.py index 1c3086ff..5107a90d 100644 --- a/backend/apps/payments/services.py +++ b/backend/apps/payments/services.py @@ -1,21 +1,26 @@ -"""Payment checkout boundary and compatibility exports.""" +"""Raising a payment: the charge itself, independent of any online rail. + +The rail lives in `services_checkout`; this module re-exports it, and the webhook +handlers, so `from apps.payments.services import X` keeps resolving for every caller. +""" import logging -from decimal import Decimal, ROUND_HALF_UP -from django.db import transaction + from apps.audit import services as audit -from apps.makerspaces.platform import member_payment_return_url -from apps.payments import stripe_client -from apps.payments.connect import refresh_connected_account, restrict_account_status -from apps.payments.models import ( - MakerspacePaymentSettings, - Payment, - PlatformStripeConnectSettings, +# Re-exported, not used here: tests and callers reach it through this barrel. +from apps.makerspaces.platform import member_payment_return_url # noqa: F401 +from apps.payments.models import Payment +from apps.payments.resolution import resolve_payment_source + +# Re-export barrel: these moved out at the 300-line ceiling. +from apps.payments.services_checkout import ( # noqa: F401 + PaymentRailConflict, + _application_fee_amount, + _ConnectAccountCannotCharge, + create_checkout, + create_checkout_url, ) -from apps.payments.resolution import resolve_payment_source, source_for_payment -from apps.payments.services_checkout_audit import record_checkout_created -from apps.payments.subjects import resolve_subject_labels, subject_label -from apps.payments.services_webhooks import ( +from apps.payments.services_webhooks import ( # noqa: F401 apply_connect_webhook_event, apply_razorpay_webhook_event, apply_webhook_event, @@ -24,27 +29,25 @@ logger = logging.getLogger(__name__) -class _ConnectAccountCannotCharge(Exception): - def __init__(self, merchant_id): - self.merchant_id = merchant_id - - -class PaymentRailConflict(Exception): - pass - - def create_payment( *, makerspace, subject_type, subject_id, member, amount, currency, created_by, via_makerspace=None, subject_label="", ): + # A missing source is NOT fatal any more. Recording what a member owes is separate + # from being able to collect it online: a space that takes cash still needs the debt + # on the books, visible in the member area and settleable by staff. The row is stamped + # `unclaimed` and the first checkout that reaches a provider claims it. source = resolve_payment_source(makerspace) if source is None: - raise stripe_client.PaymentsUnavailable( - "Payments are not configured for this makerspace." - ) - provider = source.provider - connected_account_id = source.connected_account_id - fee_amount = _application_fee_amount(amount, source.application_fee_bps) + vendor = Payment.Provider.UNCLAIMED + provider = Payment.StripeProvider.RAW + connected_account_id = None + fee_amount = 0 + else: + vendor = source.vendor + provider = source.provider + connected_account_id = source.connected_account_id + fee_amount = _application_fee_amount(amount, source.application_fee_bps) payment = Payment.objects.create( makerspace=makerspace, subject_type=subject_type, @@ -59,7 +62,7 @@ def create_payment( # space that switches vendor must still settle and expire the charges it raised # under the old one, and moving a row between vendors would point it at a # merchant account that never took the money. - provider=source.vendor, + provider=vendor, stripe_provider=provider, stripe_connected_account_id=connected_account_id, stripe_application_fee_amount=fee_amount, @@ -83,205 +86,4 @@ def create_payment( return payment -def create_checkout(payment): - """Schedule checkout creation; Stripe failure is deliberately never caller-visible.""" - transaction.on_commit( - lambda: _create_checkout_safely(payment.pk, actor=payment.created_by) - ) - - -def _create_checkout_safely(payment_id, *, actor=None): - try: - create_checkout_url(payment_id, actor=actor) - except Exception: - logger.exception("payment_checkout_creation_failed", extra={"payment_id": payment_id}) - - -def create_checkout_url(payment_id, *, actor=None): - """Create and persist a pending payment's Checkout URL exactly once.""" - try: - return _create_checkout_url_atomic(payment_id, actor=actor) - except _ConnectAccountCannotCharge as exc: - merchant = MakerspacePaymentSettings.objects.get(pk=exc.merchant_id) - restrict_account_status(merchant) - raise stripe_client.PaymentsUnavailable( - "Stripe Connect account cannot accept charges." - ) from None - - -def _create_checkout_url_atomic(payment_id, *, actor=None): - payment_snapshot = Payment.objects.only( - "makerspace_id", "stripe_provider" - ).get(pk=payment_id) - with transaction.atomic(): - # Checkout lock order is platform settings (Connect only) -> makerspace - # settings -> Payment. Credential updates take their corresponding - # settings lock before checking Payment rows. Payment-only reconciliation - # must never acquire either settings lock, keeping the order acyclic. - if payment_snapshot.stripe_provider == Payment.StripeProvider.CONNECT: - platform = ( - PlatformStripeConnectSettings.objects.select_for_update() - .filter(pk=1) - .first() - ) - if platform is None: - raise stripe_client.PaymentsUnavailable( - "Stripe Connect is not configured." - ) - merchant = ( - MakerspacePaymentSettings.objects.select_for_update() - .filter(makerspace_id=payment_snapshot.makerspace_id) - .first() - ) - payment = Payment.objects.select_for_update().select_related("makerspace").get(pk=payment_id) - if payment.status != Payment.Status.PENDING: - return "" - if payment.online_rail == Payment.OnlineRail.NATIVE_PAYMENT_INTENT: - raise PaymentRailConflict('The payment already uses the native payment rail.') - # Generic column first: a Razorpay row never fills the Stripe one, and without - # this the service would mint a fresh payment link on every call -- several live - # links for one charge, any of which a member could pay. - if payment.checkout_url or payment.stripe_checkout_url: - return payment.checkout_url or payment.stripe_checkout_url - if payment.online_rail is None: - payment.online_rail = Payment.OnlineRail.CHECKOUT - source = source_for_payment(payment) - if source is None: - raise stripe_client.PaymentsUnavailable("Payments are not configured.") - if payment.stripe_provider == Payment.StripeProvider.CONNECT: - if ( - merchant is None - or merchant.connect_account_id - != payment.stripe_connected_account_id - ): - raise stripe_client.PaymentsUnavailable( - "Stripe Connect account is unavailable." - ) - refreshed = refresh_connected_account(merchant) - if not ( - refreshed.connect_status == MakerspacePaymentSettings.ConnectStatus.ACTIVE - and refreshed.connect_charges_enabled - ): - raise _ConnectAccountCannotCharge(refreshed.pk) - # Return the payer to the space they reached the charge THROUGH, not the one that - # owns it. For a collaborative-event charge those differ: host A owns the payment, - # member space B routed it, and B is the only member area the visitor can actually - # sign into -- `via_makerspace` is exactly that routing record. Sending them to A - # lands them in a space where they hold no membership. Archived spaces then resolve - # to the central recovery route instead of a dead tenant domain. Both the Stripe - # branch below and the provider seam read this one value. - member_url = member_payment_return_url( - payment.via_makerspace or payment.makerspace - ) - if not member_url: - logger.warning("payment_checkout_return_url_unavailable", extra={"payment_id": payment_id}) - raise stripe_client.PaymentsUnavailable("A payment return URL is not configured.") - label = subject_label(payment, resolve_subject_labels([payment])) - if payment.provider != Payment.Provider.STRIPE: - # Non-Stripe rows go through the provider seam. The Stripe branch below is - # left untouched on purpose: it is the path taking real money today, and - # this phase must not change its behaviour to add a second vendor. - checkout_url = _create_checkout_via_provider( - payment, source, label, member_url - ) - record_checkout_created(payment, actor=actor) - return checkout_url - checkout_params = { - "mode": "payment", - "client_reference_id": str(payment.pk), - "success_url": f"{member_url}?checkout=success", - "cancel_url": f"{member_url}?checkout=cancelled", - "metadata": {"payment_id": str(payment.pk), "makerspace_id": str(payment.makerspace_id)}, - "line_items": [{"price_data": {"currency": payment.currency, "unit_amount": int(payment.amount * 100), "product_data": {"name": label}}, "quantity": 1}], - } - if payment.stripe_application_fee_amount: - checkout_params["payment_intent_data"] = { - "application_fee_amount": payment.stripe_application_fee_amount - } - session = stripe_client.create_checkout_session( - source, - idempotency_key=_checkout_idempotency_key(payment), - **checkout_params, - ) - session_id, checkout_url = _value(session, "id"), _value(session, "url") - if not session_id or not checkout_url: - raise stripe_client.PaymentsUnavailable("Stripe did not return a Checkout URL.") - payment.stripe_checkout_session_id = session_id - payment.stripe_checkout_url = checkout_url - payment.stripe_checkout_session_expired_at = None - # Written in step with the historic columns. Migration 0010 backfilled existing - # rows for the same reason: a checkout only the Stripe-shaped columns know about - # is invisible to every provider-agnostic path added from here on. - payment.external_order_id = session_id - payment.checkout_url = checkout_url - payment.save( - update_fields=[ - "stripe_checkout_session_id", - "stripe_checkout_url", - "stripe_checkout_session_expired_at", - "external_order_id", - "checkout_url", - "online_rail", - "updated_at", - ] - ) - record_checkout_created(payment, actor=actor) - return checkout_url - - -def _create_checkout_via_provider(payment, source, label, member_url): - """Raise a hosted page through the provider seam and record the generic ids. - - Called with the Payment row already locked by `_create_checkout_url_atomic`, so the - lock ordering (platform settings -> makerspace settings -> Payment) is unchanged. - """ - from apps.payments.providers import CheckoutRequest, get_provider - - provider = get_provider(payment.provider) - result = provider.create_checkout( - source, - CheckoutRequest( - # Integer minor units. A float here is a rounding bug that reconciles as a - # real money difference. - amount_minor=int(payment.amount * 100), - currency=payment.currency, - description=label, - reference=str(payment.pk), - success_url=f"{member_url}?checkout=success", - cancel_url=f"{member_url}?checkout=cancelled", - metadata={"payment_id": payment.pk, "makerspace_id": payment.makerspace_id}, - idempotency_key=_checkout_idempotency_key(payment), - ), - ) - payment.external_order_id = result.order_id - payment.checkout_url = result.checkout_url - payment.save( - update_fields=["external_order_id", "checkout_url", "online_rail", "updated_at"] - ) - return result.checkout_url - - -def _value(value, key): - return value.get(key) if isinstance(value, dict) else getattr(value, key, None) - - -def _application_fee_amount(amount, basis_points): - minor_units = (Decimal(amount) * 100).quantize(Decimal("1"), rounding=ROUND_HALF_UP) - return int( - (minor_units * Decimal(basis_points) / Decimal(10000)).quantize( - Decimal("1"), rounding=ROUND_HALF_UP - ) - ) - - -def _checkout_idempotency_key(payment): - generation = 0 - if payment.stripe_checkout_session_expired_at is not None: - generation = int( - payment.stripe_checkout_session_expired_at.timestamp() * 1_000_000 - ) - return f"payment-checkout-{payment.pk}-{generation}" - - -# Compatibility imports for established machine-service callers. -from apps.payments.reconciliation import mark_offline, waive # noqa: E402 +from apps.payments.reconciliation import mark_offline, waive # noqa: E402,F401 diff --git a/backend/apps/payments/services_checkout.py b/backend/apps/payments/services_checkout.py new file mode 100644 index 00000000..378fcf09 --- /dev/null +++ b/backend/apps/payments/services_checkout.py @@ -0,0 +1,287 @@ +"""Creating and claiming the online rail for a payment. + +Split out of `services.py` at the 300-line ceiling. Import through the `services` +barrel: `create_checkout`, `create_checkout_url` and `PaymentRailConflict` are all +imported from there by callers across the codebase. + +Deliberately does NOT import `services`: `services.create_payment` needs +`_application_fee_amount` from here, so the edge runs one way only. +""" + +import logging +from decimal import Decimal, ROUND_HALF_UP + +from django.db import transaction + +from apps.makerspaces.platform import member_payment_return_url +from apps.payments import stripe_client +from apps.payments.connect import refresh_connected_account, restrict_account_status +from apps.payments.models import ( + MakerspacePaymentSettings, + Payment, + PlatformStripeConnectSettings, +) +from apps.payments.resolution import resolve_payment_source, source_for_payment +from apps.payments.services_checkout_audit import record_checkout_created +from apps.payments.subjects import resolve_subject_labels, subject_label + +logger = logging.getLogger(__name__) + + +class _ConnectAccountCannotCharge(Exception): + def __init__(self, merchant_id): + self.merchant_id = merchant_id + + +class PaymentRailConflict(Exception): + pass + + +def create_checkout(payment): + """Schedule checkout creation; Stripe failure is deliberately never caller-visible.""" + transaction.on_commit( + lambda: _create_checkout_safely(payment.pk, actor=payment.created_by) + ) + + +def _claim_provider(payment): + """Stamp vendor provenance on an unclaimed row exactly once, under its lock. + + A charge raised while no gateway was configured carries `provider=unclaimed`: real + money owed with no rail behind it. The first checkout that actually reaches a provider + is what decides whose rail it is, so the stamp happens here -- inside the transaction + already holding the settings and Payment locks -- and never again. A Postgres trigger + enforces the "never again", so it cannot be undone by a later save or bulk update. + + Silent when nothing resolves: the caller's own `source_for_payment` check raises the + user-visible PaymentsUnavailable, and duplicating it here would report a claim failure + for a space that simply has no credentials. + """ + source = resolve_payment_source(payment.makerspace) + if source is None: + return + payment.provider = source.vendor + payment.stripe_provider = source.provider + payment.stripe_connected_account_id = source.connected_account_id + payment.stripe_application_fee_amount = _application_fee_amount( + payment.amount, source.application_fee_bps + ) + payment.save( + update_fields=[ + "provider", + "stripe_provider", + "stripe_connected_account_id", + "stripe_application_fee_amount", + "updated_at", + ] + ) + + +def _create_checkout_safely(payment_id, *, actor=None): + try: + create_checkout_url(payment_id, actor=actor) + except Exception: + logger.exception("payment_checkout_creation_failed", extra={"payment_id": payment_id}) + + +def create_checkout_url(payment_id, *, actor=None): + """Create and persist a pending payment's Checkout URL exactly once.""" + try: + return _create_checkout_url_atomic(payment_id, actor=actor) + except _ConnectAccountCannotCharge as exc: + merchant = MakerspacePaymentSettings.objects.get(pk=exc.merchant_id) + restrict_account_status(merchant) + raise stripe_client.PaymentsUnavailable( + "Stripe Connect account cannot accept charges." + ) from None + + +def _create_checkout_url_atomic(payment_id, *, actor=None): + payment_snapshot = Payment.objects.only( + "makerspace_id", "stripe_provider", "provider" + ).get(pk=payment_id) + with transaction.atomic(): + # Checkout lock order is platform settings (Connect only) -> makerspace + # settings -> Payment. Credential updates take their corresponding + # settings lock before checking Payment rows. Payment-only reconciliation + # must never acquire either settings lock, keeping the order acyclic. + # An unclaimed row's rail is not known until the source resolves, and it may turn + # out to be Connect. Taking the platform lock we might not need is safe; discovering + # we need it after taking the makerspace lock would invert the documented order. + unclaimed = payment_snapshot.provider == Payment.Provider.UNCLAIMED + if ( + payment_snapshot.stripe_provider == Payment.StripeProvider.CONNECT + or unclaimed + ): + platform = ( + PlatformStripeConnectSettings.objects.select_for_update() + .filter(pk=1) + .first() + ) + # An unclaimed row only MIGHT resolve to Connect, so the lock is taken + # speculatively to keep the platform -> makerspace -> Payment order. Its + # absence is only fatal for a row already stamped Connect: a self-hosted + # deployment with raw credentials has no platform row at all, and demanding + # one here refused every cash-raised charge the moment Stripe was configured. + if platform is None and not unclaimed: + raise stripe_client.PaymentsUnavailable( + "Stripe Connect is not configured." + ) + merchant = ( + MakerspacePaymentSettings.objects.select_for_update() + .filter(makerspace_id=payment_snapshot.makerspace_id) + .first() + ) + payment = Payment.objects.select_for_update().select_related("makerspace").get(pk=payment_id) + if payment.status != Payment.Status.PENDING: + return "" + if payment.online_rail == Payment.OnlineRail.NATIVE_PAYMENT_INTENT: + raise PaymentRailConflict('The payment already uses the native payment rail.') + # Generic column first: a Razorpay row never fills the Stripe one, and without + # this the service would mint a fresh payment link on every call -- several live + # links for one charge, any of which a member could pay. + if payment.checkout_url or payment.stripe_checkout_url: + return payment.checkout_url or payment.stripe_checkout_url + if payment.online_rail is None: + payment.online_rail = Payment.OnlineRail.CHECKOUT + if payment.provider == Payment.Provider.UNCLAIMED: + _claim_provider(payment) + source = source_for_payment(payment) + if source is None: + raise stripe_client.PaymentsUnavailable("Payments are not configured.") + if payment.stripe_provider == Payment.StripeProvider.CONNECT: + if ( + merchant is None + or merchant.connect_account_id + != payment.stripe_connected_account_id + ): + raise stripe_client.PaymentsUnavailable( + "Stripe Connect account is unavailable." + ) + refreshed = refresh_connected_account(merchant) + if not ( + refreshed.connect_status == MakerspacePaymentSettings.ConnectStatus.ACTIVE + and refreshed.connect_charges_enabled + ): + raise _ConnectAccountCannotCharge(refreshed.pk) + # Return the payer to the space they reached the charge THROUGH, not the one that + # owns it. For a collaborative-event charge those differ: host A owns the payment, + # member space B routed it, and B is the only member area the visitor can actually + # sign into -- `via_makerspace` is exactly that routing record. Sending them to A + # lands them in a space where they hold no membership. Archived spaces then resolve + # to the central recovery route instead of a dead tenant domain. Both the Stripe + # branch below and the provider seam read this one value. + member_url = member_payment_return_url( + payment.via_makerspace or payment.makerspace + ) + if not member_url: + logger.warning("payment_checkout_return_url_unavailable", extra={"payment_id": payment_id}) + raise stripe_client.PaymentsUnavailable("A payment return URL is not configured.") + label = subject_label(payment, resolve_subject_labels([payment])) + if payment.provider != Payment.Provider.STRIPE: + # Non-Stripe rows go through the provider seam. The Stripe branch below is + # left untouched on purpose: it is the path taking real money today, and + # this phase must not change its behaviour to add a second vendor. + checkout_url = _create_checkout_via_provider( + payment, source, label, member_url + ) + record_checkout_created(payment, actor=actor) + return checkout_url + checkout_params = { + "mode": "payment", + "client_reference_id": str(payment.pk), + "success_url": f"{member_url}?checkout=success", + "cancel_url": f"{member_url}?checkout=cancelled", + "metadata": {"payment_id": str(payment.pk), "makerspace_id": str(payment.makerspace_id)}, + "line_items": [{"price_data": {"currency": payment.currency, "unit_amount": int(payment.amount * 100), "product_data": {"name": label}}, "quantity": 1}], + } + if payment.stripe_application_fee_amount: + checkout_params["payment_intent_data"] = { + "application_fee_amount": payment.stripe_application_fee_amount + } + session = stripe_client.create_checkout_session( + source, + idempotency_key=_checkout_idempotency_key(payment), + **checkout_params, + ) + session_id, checkout_url = _value(session, "id"), _value(session, "url") + if not session_id or not checkout_url: + raise stripe_client.PaymentsUnavailable("Stripe did not return a Checkout URL.") + payment.stripe_checkout_session_id = session_id + payment.stripe_checkout_url = checkout_url + payment.stripe_checkout_session_expired_at = None + # Written in step with the historic columns. Migration 0010 backfilled existing + # rows for the same reason: a checkout only the Stripe-shaped columns know about + # is invisible to every provider-agnostic path added from here on. + payment.external_order_id = session_id + payment.checkout_url = checkout_url + payment.save( + update_fields=[ + "stripe_checkout_session_id", + "stripe_checkout_url", + "stripe_checkout_session_expired_at", + "external_order_id", + "checkout_url", + "online_rail", + "updated_at", + ] + ) + record_checkout_created(payment, actor=actor) + return checkout_url + + +def _create_checkout_via_provider(payment, source, label, member_url): + """Raise a hosted page through the provider seam and record the generic ids. + + Called with the Payment row already locked by `_create_checkout_url_atomic`, so the + lock ordering (platform settings -> makerspace settings -> Payment) is unchanged. + """ + from apps.payments.providers import CheckoutRequest, get_provider + + provider = get_provider(payment.provider) + result = provider.create_checkout( + source, + CheckoutRequest( + # Integer minor units. A float here is a rounding bug that reconciles as a + # real money difference. + amount_minor=int(payment.amount * 100), + currency=payment.currency, + description=label, + reference=str(payment.pk), + success_url=f"{member_url}?checkout=success", + cancel_url=f"{member_url}?checkout=cancelled", + metadata={"payment_id": payment.pk, "makerspace_id": payment.makerspace_id}, + idempotency_key=_checkout_idempotency_key(payment), + ), + ) + payment.external_order_id = result.order_id + payment.checkout_url = result.checkout_url + payment.save( + update_fields=["external_order_id", "checkout_url", "online_rail", "updated_at"] + ) + return result.checkout_url + + +def _value(value, key): + return value.get(key) if isinstance(value, dict) else getattr(value, key, None) + + +def _application_fee_amount(amount, basis_points): + minor_units = (Decimal(amount) * 100).quantize(Decimal("1"), rounding=ROUND_HALF_UP) + return int( + (minor_units * Decimal(basis_points) / Decimal(10000)).quantize( + Decimal("1"), rounding=ROUND_HALF_UP + ) + ) + + +def _checkout_idempotency_key(payment): + generation = 0 + if payment.stripe_checkout_session_expired_at is not None: + generation = int( + payment.stripe_checkout_session_expired_at.timestamp() * 1_000_000 + ) + return f"payment-checkout-{payment.pk}-{generation}" + + +# Compatibility imports for established machine-service callers. diff --git a/backend/apps/payments/services_mobile.py b/backend/apps/payments/services_mobile.py index bb80c16a..f9c5de48 100644 --- a/backend/apps/payments/services_mobile.py +++ b/backend/apps/payments/services_mobile.py @@ -10,18 +10,26 @@ ) from apps.payments.resolution import source_for_payment from apps.payments.services import PaymentRailConflict +from apps.payments.services_checkout import _claim_provider def create_mobile_intent(payment_id, *, actor): - snapshot = Payment.objects.only('makerspace_id', 'stripe_provider').get(pk=payment_id) + snapshot = Payment.objects.only( + 'makerspace_id', 'stripe_provider', 'provider' + ).get(pk=payment_id) with transaction.atomic(): - if snapshot.stripe_provider == Payment.StripeProvider.CONNECT: + # An unclaimed row's rail is unknown until the source resolves and may turn out + # to be Connect, so the platform lock is taken speculatively to preserve the + # platform -> makerspace -> Payment order. Its absence only matters for a row + # already stamped Connect; a self-hosted deployment has no platform row at all. + unclaimed = snapshot.provider == Payment.Provider.UNCLAIMED + if snapshot.stripe_provider == Payment.StripeProvider.CONNECT or unclaimed: platform = ( PlatformStripeConnectSettings.objects.select_for_update() .filter(pk=1) .first() ) - if platform is None: + if platform is None and not unclaimed: raise stripe_client.PaymentsUnavailable( 'Stripe Connect is not configured.' ) @@ -41,6 +49,12 @@ def create_mobile_intent(payment_id, *, actor): raise PaymentRailConflict( 'The payment already uses the Checkout payment rail.' ) + # Claim the rail before creating a provider object, exactly as hosted checkout + # does. Without this a cash-raised charge could settle online while still stamped + # `unclaimed`, which would misreport it and leave refunds with no vendor to + # dispatch to. + if payment.provider == Payment.Provider.UNCLAIMED: + _claim_provider(payment) source = source_for_payment(payment) if source is None or not source.publishable_key: raise stripe_client.PaymentsUnavailable( diff --git a/backend/apps/payments/services_refund_webhooks.py b/backend/apps/payments/services_refund_webhooks.py new file mode 100644 index 00000000..215a5e4b --- /dev/null +++ b/backend/apps/payments/services_refund_webhooks.py @@ -0,0 +1,142 @@ +"""Settle PENDING refunds from provider webhooks, idempotently. + +Split from `services_webhooks` because that module sits at the file ceiling; the +idempotency helpers it shares with this one live here and are re-exported there. +Only refunds this deployment raised are settled: a refund made in a vendor dashboard +has no `Refund` row and no staff actor, so it is logged and left for reconciliation +to notice through the provider, never invented locally. +""" + +import logging + +from django.db import IntegrityError, transaction + +from apps.payments.models import Payment, ProcessedStripeEvent, Refund +from apps.payments.services_refunds import settle_refund + +logger = logging.getLogger(__name__) + +STRIPE_REFUND_EVENT_TYPES = frozenset( + {"charge.refunded", "charge.refund.updated", "refund.created", "refund.updated", "refund.failed"} +) +_STRIPE_STATUSES = { + "succeeded": Refund.Status.SUCCEEDED, + "failed": Refund.Status.FAILED, + "canceled": Refund.Status.FAILED, +} + + +def _value(value, key): + return value.get(key) if isinstance(value, dict) else getattr(value, key, None) + + +def _record_once(makerspace, event_id, provider=Payment.Provider.STRIPE): + """Claim an event id, scoped by provider. + + Two vendors can mint the same event id; without the provider in the key the second + one would be swallowed as a duplicate and a real charge would never settle. + """ + try: + ProcessedStripeEvent.objects.create( + makerspace=makerspace, provider=provider, stripe_event_id=event_id + ) + except IntegrityError: + return False + return True + + +def apply_stripe_refund_event(makerspace, event, *, provider, connected_account_id=None): + event_id, event_type = _value(event, "id"), _value(event, "type") + obj = _value(_value(event, "data") or {}, "object") or {} + intent_id = _value(obj, "payment_intent") + entries = _stripe_refund_entries(event_type, obj) + if not event_id or not intent_id or not entries: + return None + with transaction.atomic(): + payments = Payment.objects.select_for_update().filter( + makerspace=makerspace, + provider=Payment.Provider.STRIPE, + stripe_provider=provider, + stripe_payment_intent_id=intent_id, + ) + if connected_account_id is not None: + payments = payments.filter(stripe_connected_account_id=connected_account_id) + payment = payments.first() + if payment is None or not _record_once(makerspace, event_id): + return None + return _settle_entries(payment, entries) + + +def apply_razorpay_refund_event(makerspace, event): + """`event` is a providers.base.WebhookEvent carrying a refund id.""" + if not event.event_id or not event.refund_id or not event.payment_id: + return None + with transaction.atomic(): + payment = ( + Payment.objects.select_for_update() + .filter( + makerspace=makerspace, + provider=Payment.Provider.RAZORPAY, + external_payment_id=event.payment_id, + ) + .first() + ) + if payment is None or not _record_once( + makerspace, event.event_id, Payment.Provider.RAZORPAY + ): + return None + status = { + "succeeded": Refund.Status.SUCCEEDED, + "failed": Refund.Status.FAILED, + }.get(event.refund_status, Refund.Status.PENDING) + return _settle_entries( + payment, [(event.refund_id, status, event.refund_amount_minor)] + ) + + +def _stripe_refund_entries(event_type, obj): + """(external id, normalised status, amount in minor units) for each refund in the event.""" + if event_type == "charge.refunded": + refunds = _value(_value(obj, "refunds") or {}, "data") or [] + elif event_type in STRIPE_REFUND_EVENT_TYPES: + refunds = [obj] + else: + return [] + entries = [] + for refund in refunds: + refund_id = _value(refund, "id") + if not refund_id: + continue + status = _STRIPE_STATUSES.get(_value(refund, "status") or "", Refund.Status.PENDING) + entries.append((refund_id, status, int(_value(refund, "amount") or 0))) + return entries + + +def _settle_entries(payment, entries): + settled = [] + for external_id, status, amount_minor in entries: + row = _match_refund(payment, external_id, amount_minor) + if row is None: + logger.warning( + "payment_refund_unmatched", + extra={"payment_id": payment.pk, "provider": payment.provider}, + ) + continue + settled.append( + settle_refund(row, external_refund_id=external_id, status=status, actor=None) + ) + return settled or None + + +def _match_refund(payment, external_id, amount_minor): + """Our row for the provider's refund: by bound id first, else the oldest unbound + PENDING refund of the same amount, which the settlement then binds.""" + rows = Refund.objects.select_for_update().filter(payment=payment, provider=payment.provider) + bound = rows.filter(external_refund_id=external_id).first() + if bound is not None: + return bound + unbound = rows.filter(external_refund_id__isnull=True, status=Refund.Status.PENDING) + if amount_minor: + unbound = [row for row in unbound.order_by("created_at", "pk") if int(row.amount * 100) == amount_minor] + return unbound[0] if unbound else None + return unbound.order_by("created_at", "pk").first() diff --git a/backend/apps/payments/services_refunds.py b/backend/apps/payments/services_refunds.py new file mode 100644 index 00000000..b72080c0 --- /dev/null +++ b/backend/apps/payments/services_refunds.py @@ -0,0 +1,155 @@ +"""Refund a paid-online charge through the provider seam. + +Three steps, and the split is deliberate: + +1. Under the Payment row lock: validate and write a PENDING `Refund` row. Pending rows + count against the refundable balance, so two staff refunding at once cannot both pass. +2. Outside any row lock: call the provider. A webhook for the same payment must not queue + behind vendor I/O, and a lock held across a network call is how deadlocks start. +3. Settle the row from the synchronous answer. Async providers leave it PENDING and the + webhook (`services_refund_webhooks`) finishes the job idempotently. + +The Payment row itself is never touched: it is terminal and the database forbids it. +""" + +import logging +from decimal import Decimal, InvalidOperation + +from django.db import transaction +from django.utils import timezone +from rest_framework.exceptions import APIException + +from apps.audit import services as audit +from apps.payments.models import Payment, Refund +from apps.payments.providers import PaymentsUnavailable, RefundRequest, get_provider +from apps.payments.resolution import source_for_payment + +logger = logging.getLogger(__name__) + + +class RefundNotAllowed(APIException): + status_code = 400 + + def __init__(self, detail, code="refund_not_allowed"): + self.detail = {"detail": detail, "code": code} + + +class RefundProviderFailure(APIException): + status_code = 502 + + def __init__(self, refund): + self.detail = { + "detail": "The payment provider rejected the refund.", + "code": "refund_provider_failed", + "refund_id": refund.pk, + } + + +def refund_payment(payment, *, amount, reason, actor): + refund = _open_refund(payment, amount=amount, reason=reason, actor=actor) + result, failed = _send_to_provider(refund) + if failed: + settle_refund(refund, external_refund_id=None, status=Refund.Status.FAILED, actor=actor) + raise RefundProviderFailure(refund) + settle_refund(refund, external_refund_id=result.refund_id, status=result.status, actor=actor) + refund.refresh_from_db() + return refund + + +def _open_refund(payment, *, amount, reason, actor): + try: + amount = Decimal(amount).quantize(Decimal("0.01")) + except (InvalidOperation, TypeError, ValueError): + raise RefundNotAllowed("Refund amount is invalid.", "refund_amount_invalid") + if amount <= 0: + raise RefundNotAllowed("Refund amount must be positive.", "refund_amount_invalid") + with transaction.atomic(): + locked = Payment.objects.select_for_update().select_related("makerspace").get(pk=payment.pk) + if locked.status != Payment.Status.PAID_ONLINE: + # Offline and waived charges took no money through a provider, so there is + # nothing a provider can send back; staff correct those in the till. + raise RefundNotAllowed( + "Only payments settled online can be refunded through the provider.", + "refund_not_online", + ) + if Refund.counted_total(locked) + amount > locked.amount: + raise RefundNotAllowed( + "Refund exceeds the refundable balance.", "refund_exceeds_balance" + ) + return Refund.objects.create( + payment=locked, + amount=amount, + currency=locked.currency, + provider=locked.provider, + reason=(reason or "")[:255], + created_by=actor, + ) + + +def _send_to_provider(refund): + payment = refund.payment + try: + source = source_for_payment(payment) + if source is None: + raise PaymentsUnavailable("The payment's provider credentials are no longer configured.") + provider_payment_id = ( + payment.stripe_payment_intent_id + if payment.provider == Payment.Provider.STRIPE + else payment.external_payment_id + ) + result = get_provider(payment.provider).create_refund( + source, + RefundRequest( + payment_id=provider_payment_id or "", + # Integer minor units; a float here reconciles as a real money difference. + amount_minor=int(refund.amount * 100), + currency=refund.currency, + reference=str(refund.pk), + idempotency_key=f"payment-refund-{refund.pk}", + metadata={"refund_id": refund.pk, "payment_id": payment.pk}, + ), + ) + return result, False + except PaymentsUnavailable: + logger.warning("payment_refund_provider_failed", extra={"refund_id": refund.pk}) + return None, True + except Exception: + logger.exception("payment_refund_provider_error", extra={"refund_id": refund.pk}) + return None, True + + +def settle_refund(refund, *, external_refund_id, status, actor): + """Move a PENDING refund to its final state exactly once; later calls are no-ops. + + Shared by the synchronous path and the webhook path so both write the same audit + entries with the same meta, whichever one gets there first. + """ + with transaction.atomic(): + locked = Refund.objects.select_for_update().select_related("payment__makerspace").get(pk=refund.pk) + fields = [] + if external_refund_id and not locked.external_refund_id: + locked.external_refund_id = external_refund_id + fields.append("external_refund_id") + if locked.status != Refund.Status.PENDING: + if fields: + locked.save(update_fields=fields) + return locked + if status == Refund.Status.PENDING: + if fields: + locked.save(update_fields=fields) + audit.record( + actor, "payment.refund_requested", + makerspace=locked.payment.makerspace, target=locked.payment, + meta={"payment_id": locked.payment_id, "refund_id": locked.pk, "amount": str(locked.amount)}, + ) + return locked + locked.status = status + locked.settled_at = timezone.now() + locked.save(update_fields=[*fields, "status", "settled_at"]) + action = "payment.refunded" if status == Refund.Status.SUCCEEDED else "payment.refund_failed" + audit.record( + actor, action, + makerspace=locked.payment.makerspace, target=locked.payment, + meta={"payment_id": locked.payment_id, "refund_id": locked.pk, "amount": str(locked.amount)}, + ) + return locked diff --git a/backend/apps/payments/services_webhooks.py b/backend/apps/payments/services_webhooks.py index 5d305df8..ef8514c2 100644 --- a/backend/apps/payments/services_webhooks.py +++ b/backend/apps/payments/services_webhooks.py @@ -1,4 +1,4 @@ -from django.db import IntegrityError, transaction +from django.db import transaction from django.db.models import Q from django.utils import timezone @@ -6,7 +6,13 @@ from apps.payments.models import ( MakerspacePaymentSettings, Payment, - ProcessedStripeEvent, +) +from apps.payments.services_refund_webhooks import ( # noqa: F401 (_record_once re-exported) + STRIPE_REFUND_EVENT_TYPES, + _record_once, + _value, + apply_razorpay_refund_event, + apply_stripe_refund_event, ) from apps.payments.terminal_settlement import ( handle_razorpay_paid_after_terminal, @@ -27,6 +33,10 @@ def apply_webhook_event( return None event_type, data = _value(event, "type"), _value(event, "data") or {} obj = _value(data, "object") or {} + if event_type in STRIPE_REFUND_EVENT_TYPES: + return apply_stripe_refund_event( + makerspace, event, provider=provider, connected_account_id=connected_account_id + ) if event_type not in { "checkout.session.completed", "checkout.session.async_payment_succeeded", @@ -223,21 +233,6 @@ def _event_is_stale(event_created, account_assigned_at): return event_created < int(account_assigned_at.timestamp()) -def _record_once(makerspace, event_id, provider=Payment.Provider.STRIPE): - """Claim an event id, scoped by provider. - - Two vendors can mint the same event id; without the provider in the key the second - one would be swallowed as a duplicate and a real charge would never settle. - """ - try: - ProcessedStripeEvent.objects.create( - makerspace=makerspace, provider=provider, stripe_event_id=event_id - ) - except IntegrityError: - return False - return True - - def apply_razorpay_webhook_event(makerspace, event): """Settle a VERIFIED Razorpay event. `event` is a providers.base.WebhookEvent. @@ -250,7 +245,11 @@ def apply_razorpay_webhook_event(makerspace, event): * A paid event after waiver corrects the ledger; one after offline settlement raises an explicit refund-required audit condition instead of silently double-settling. """ - if not is_servable(makerspace, allow_archived=True) or not event.event_id or not event.is_paid: + if not is_servable(makerspace, allow_archived=True) or not event.event_id: + return None + if event.refund_id: + return apply_razorpay_refund_event(makerspace, event) + if not event.is_paid: # Non-payment events are not recorded: claiming their id would make a later # genuine settlement carrying the same delivery id read as a duplicate. return None @@ -291,7 +290,3 @@ def apply_razorpay_webhook_event(makerspace, event): meta={"provider": "razorpay", "event_id": event.event_id}, ) return payment - - -def _value(value, key): - return value.get(key) if isinstance(value, dict) else getattr(value, key, None) diff --git a/backend/apps/payments/stripe_client.py b/backend/apps/payments/stripe_client.py index 6dec6ad1..e0fe475b 100644 --- a/backend/apps/payments/stripe_client.py +++ b/backend/apps/payments/stripe_client.py @@ -75,6 +75,17 @@ def create_payment_intent(makerspace_or_settings, *, idempotency_key, **params): ) +def create_refund(makerspace_or_settings, *, idempotency_key, **params): + """Refund a captured PaymentIntent, fully or partially, exactly once per key.""" + source = _source(makerspace_or_settings) + if source is None: + raise PaymentsUnavailable('Stripe is not configured for this makerspace.') + options = {'idempotency_key': idempotency_key} + if source.provider == 'connect' and source.connected_account_id: + options['stripe_account'] = source.connected_account_id + return build_client(source).v1.refunds.create(params=params, options=options) + + def retrieve_payment_intent(makerspace_or_settings, intent_id): source = _source(makerspace_or_settings) if source is None: diff --git a/backend/apps/payments/subjects.py b/backend/apps/payments/subjects.py index 6c74a6ff..aad91773 100644 --- a/backend/apps/payments/subjects.py +++ b/backend/apps/payments/subjects.py @@ -57,11 +57,18 @@ def resolve_subject_labels(payments): makerspace_id, member_id, ) + fixed_labels = { + Payment.SubjectType.MAKERSPACE_MEMBERSHIP: "Membership dues", + Payment.SubjectType.MEMBERSHIP_TERM: "Membership renewal", + Payment.SubjectType.LOAN_DEPOSIT: "Loan deposit", + Payment.SubjectType.LOAN_LATE_FEE: "Late return fee", + } for payment in rows: - if payment.subject_type != Payment.SubjectType.MAKERSPACE_MEMBERSHIP: + label = fixed_labels.get(payment.subject_type) + if label is None: continue labels[(payment.subject_type, payment.subject_id)] = ( - "Membership dues", + label, payment.makerspace_id, None, ) diff --git a/backend/apps/payments/urls.py b/backend/apps/payments/urls.py index f1932e89..431eabe4 100644 --- a/backend/apps/payments/urls.py +++ b/backend/apps/payments/urls.py @@ -1,3 +1,11 @@ +"""The payment LEDGER's staff surface. Mounted unconditionally. + +Recording, listing and settling money owed is not an online-payment feature: a +deployment that ships no provider rail at all still has members who owe money and staff +who take it at the desk. Only the routes that actually talk to a provider live in +`apps.payments_rail.urls`, where a tombstone can withdraw them. +""" + from django.urls import path from apps.payments.views_member import ArchivedPaymentDiscoveryView @@ -6,6 +14,7 @@ PaymentBulkWaiveView, PaymentListView, PaymentMarkOfflineView, + PaymentSettlementAmendView, PaymentWaiveView, ) @@ -18,6 +27,11 @@ path("admin/makerspace//payments", PaymentListView.as_view(), name="payment-reconciliation-list"), path("admin/makerspace//payments//mark-offline", PaymentMarkOfflineView.as_view(), name="payment-reconciliation-mark-offline"), path("admin/makerspace//payments//waive", PaymentWaiveView.as_view(), name="payment-reconciliation-waive"), + path( + "admin/makerspace//payments//amend-settlement", + PaymentSettlementAmendView.as_view(), + name="payment-settlement-amend", + ), path("admin/makerspace//payments/bulk/mark-offline", PaymentBulkMarkOfflineView.as_view(), name="payment-reconciliation-bulk-mark-offline"), path("admin/makerspace//payments/bulk/waive", PaymentBulkWaiveView.as_view(), name="payment-reconciliation-bulk-waive"), ] diff --git a/backend/apps/payments/urls_member.py b/backend/apps/payments/urls_member.py index ef58336f..d417291d 100644 --- a/backend/apps/payments/urls_member.py +++ b/backend/apps/payments/urls_member.py @@ -1,14 +1,13 @@ -"""Payments' member-facing surface, mounted under ``/api/v1/member/``. +"""The member's own view of what they owe. Mounted unconditionally. -These routes live with the payments app so ``config.urls.separable`` can withdraw -them when payments is tombstoned. Keeping them in the makerspaces urlconf would leave -checkout creation reachable after the rest of the payment surface disappeared. +Seeing a charge, its amount and its receipt is ledger business, so it survives a rail +tombstone -- withdrawing it would leave members with debts they cannot even read. Paying +one online lives in `apps.payments_rail.urls_member`. """ from django.urls import path -from apps.payments.views_member import MemberPaymentCheckoutView, MemberPaymentHistoryView -from apps.payments.views_member_mobile import MemberMobilePaymentIntentView +from apps.payments.views_member import MemberPaymentHistoryView urlpatterns = [ @@ -17,14 +16,4 @@ MemberPaymentHistoryView.as_view(), name="member-payment-history", ), - path( - "makerspaces//payments//checkout", - MemberPaymentCheckoutView.as_view(), - name="member-payment-checkout", - ), - path( - "makerspaces//payments//mobile-intent", - MemberMobilePaymentIntentView.as_view(), - name="member-payment-mobile-intent", - ), ] diff --git a/backend/apps/payments/views_member.py b/backend/apps/payments/views_member.py index ca941a36..1537f296 100644 --- a/backend/apps/payments/views_member.py +++ b/backend/apps/payments/views_member.py @@ -7,7 +7,13 @@ from rest_framework.views import APIView from apps.hardware_requests.exceptions import ErrorSerializer -from apps.payments.member_access import member_payment_actor, member_payment_memberships +from apps.payments import stripe_client +from apps.payments.member_access import ( + member_may_see_own_charges, + member_payment_actor, + member_payment_memberships, +) +from apps.payments.availability import online_payments_enabled_for from apps.payments.member_scope import member_payment_queryset from apps.payments.models import Payment from apps.payments.serializers import ( @@ -90,8 +96,11 @@ class MemberPaymentHistoryView(APIView): @extend_schema(tags=["Payments"], summary="List the caller's payment history", request=None, responses={200: MemberPaymentSerializer(many=True), 403: OpenApiResponse(ErrorSerializer)}) def get(self, request, makerspace_id): - if member_payment_actor(request.user, makerspace_id) is None: - return Response({"detail": "An active membership is required."}, status=403) + if not member_may_see_own_charges(request.user, makerspace_id): + return Response( + {"detail": "An active membership or an existing charge is required."}, + status=403, + ) rows = list( member_payment_queryset(request.user, makerspace_id).order_by("-created_at") ) @@ -99,41 +108,42 @@ def get(self, request, makerspace_id): MemberPaymentSerializer( rows, many=True, - context={"payment_subject_labels": resolve_subject_labels(rows)}, + context={ + "payment_subject_labels": resolve_subject_labels(rows), + **_member_payment_context(rows), + }, ).data ) -class MemberPaymentCheckoutView(APIView): - permission_classes = [IsAuthenticated] +def _member_payment_context(rows): + """Rail availability and receipts for a page of charges, resolved in bulk. - @extend_schema( - tags=["Payments"], - summary="Generate a Checkout link for the caller's pending payment", - request=None, - responses={200: CheckoutUrlSerializer, 404: OpenApiResponse(ErrorSerializer), 503: OpenApiResponse(ErrorSerializer)}, - ) - def post(self, request, makerspace_id, payment_id): - payment = member_payment_queryset(request.user, makerspace_id).filter( - pk=payment_id, - status=Payment.Status.PENDING, - ).first() - if payment is None: - raise NotFound() - if payment.stripe_checkout_url: - return Response({"checkout_url": payment.stripe_checkout_url}) - try: - checkout_url = create_checkout_url(payment.pk, actor=request.user) - except PaymentRailConflict: - return Response( - { - 'detail': 'The payment already uses a different online payment rail.', - 'code': 'payment_rail_conflict', - }, - status=status.HTTP_409_CONFLICT, - ) - except Exception: - return Response({"detail": "Payments are temporarily unavailable.", "code": "payments_unavailable"}, status=status.HTTP_503_SERVICE_UNAVAILABLE) - if not checkout_url: - raise NotFound() - return Response({"checkout_url": checkout_url}) + Both are per-row questions with per-makerspace answers, so asking them inside the + serializer would re-read settings, credentials and the settlement table once per + charge. Every row in one member list belongs to one space, so the rail resolves once. + """ + from apps.payments.models import ManualSettlement + + if not rows: + return {"payment_rails": {}, "payment_settlements": {}} + settled_ids = [ + row.pk for row in rows if row.status == Payment.Status.PAID_OFFLINE + ] + receipts = {} + if settled_ids: + for receipt in ManualSettlement.objects.filter( + payment_id__in=settled_ids, amended_by__isnull=True + ).order_by("payment_id", "-created_at", "-pk"): + receipts.setdefault(receipt.payment_id, receipt) + # Keyed by OWNING makerspace, not answered once for the page. A collaborative-event + # charge is owned by the host space and merely routed here through `via_makerspace`, + # and that host has its own modules, features and credentials -- so one scalar taken + # from the first row would hide a valid checkout, or advertise an impossible one, for + # every charge belonging to a different owner. Resolved once per distinct owner, so + # a page of charges from one space still costs one lookup. + rails = {} + for row in rows: + if (row.makerspace_id, row.subject_type) not in rails: + rails[(row.makerspace_id, row.subject_type)] = online_payments_enabled_for(row) + return {"payment_rails": rails, "payment_settlements": receipts} diff --git a/backend/apps/payments/views_reconciliation.py b/backend/apps/payments/views_reconciliation.py index ebb3e303..d6d79df0 100644 --- a/backend/apps/payments/views_reconciliation.py +++ b/backend/apps/payments/views_reconciliation.py @@ -8,10 +8,16 @@ from apps.accounts import rbac from apps.hardware_requests.exceptions import ErrorSerializer from apps.payments.models import Payment -from apps.payments.reconciliation import list_payments, reconcile_payments +from apps.payments.reconciliation import ( + amend_settlement, + list_payments, + reconcile_payments, +) from apps.payments.serializers_reconciliation import ( PaymentBulkActionSerializer, + PaymentBulkOfflineSerializer, PaymentListFilterSerializer, + PaymentOfflineSerializer, PaymentReconciliationSerializer, ) from apps.makerspaces.servability import servable_queryset @@ -66,13 +72,21 @@ def get(self, request, makerspace_id): class _PaymentActionView(APIView): permission_classes = [IsActiveStaff] target_status = None + #: Offline settlement carries a receipt; waiving does not, since no money moved. + settlement_serializer = None def post(self, request, makerspace_id, payment_id): + settlement = None + if self.settlement_serializer is not None: + payload = self.settlement_serializer(data=request.data) + payload.is_valid(raise_exception=True) + settlement = payload.validated_data["settlement"] payment = reconcile_payments( actor=request.user, makerspace_id=makerspace_id, payment_ids=[payment_id], target_status=self.target_status, + settlement=settlement, )[0] context = { "payment_subject_labels": resolve_subject_labels([payment]) @@ -82,9 +96,11 @@ def post(self, request, makerspace_id, payment_id): class PaymentMarkOfflineView(_PaymentActionView): target_status = Payment.Status.PAID_OFFLINE + settlement_serializer = PaymentOfflineSerializer @extend_schema( - tags=["Payments"], summary="Mark a payment paid offline", request=None, + tags=["Payments"], summary="Mark a payment paid offline", + request=PaymentOfflineSerializer, responses={200: PaymentReconciliationSerializer, **ERRORS}, ) def post(self, request, makerspace_id, payment_id): @@ -102,18 +118,51 @@ def post(self, request, makerspace_id, payment_id): return super().post(request, makerspace_id, payment_id) +class PaymentSettlementAmendView(APIView): + """Append a corrected receipt to an already-settled charge.""" + + permission_classes = [IsActiveStaff] + + @extend_schema( + tags=["Payments"], summary="Correct how a settled payment was received", + request=PaymentOfflineSerializer, + responses={200: PaymentReconciliationSerializer, **ERRORS}, + ) + def post(self, request, makerspace_id, payment_id): + payload = PaymentOfflineSerializer(data=request.data) + payload.is_valid(raise_exception=True) + receipt = amend_settlement( + actor=request.user, + makerspace_id=makerspace_id, + payment_id=payment_id, + settlement=payload.validated_data["settlement"], + ) + payment = receipt.payment + return Response( + PaymentReconciliationSerializer( + payment, + context={"payment_subject_labels": resolve_subject_labels([payment])}, + ).data + ) + + class _PaymentBulkActionView(APIView): permission_classes = [IsActiveStaff] target_status = None + request_serializer = PaymentBulkActionSerializer def post(self, request, makerspace_id): - payload = PaymentBulkActionSerializer(data=request.data) + payload = self.request_serializer(data=request.data) payload.is_valid(raise_exception=True) + # One receipt covers the batch: staff settling several charges in one handover + # took the money once, and splitting it into per-row receipts would invent detail + # nobody entered. payments = reconcile_payments( actor=request.user, makerspace_id=makerspace_id, payment_ids=payload.validated_data["ids"], target_status=self.target_status, + settlement=payload.validated_data.get("settlement"), ) context = { "payment_subject_labels": resolve_subject_labels(payments) @@ -125,10 +174,11 @@ def post(self, request, makerspace_id): class PaymentBulkMarkOfflineView(_PaymentBulkActionView): target_status = Payment.Status.PAID_OFFLINE + request_serializer = PaymentBulkOfflineSerializer @extend_schema( tags=["Payments"], summary="Mark payments paid offline in one transaction", - request=PaymentBulkActionSerializer, + request=PaymentBulkOfflineSerializer, responses={200: PaymentReconciliationSerializer(many=True), **ERRORS}, ) def post(self, request, makerspace_id): diff --git a/backend/apps/payments/views_refunds.py b/backend/apps/payments/views_refunds.py new file mode 100644 index 00000000..4505c2f6 --- /dev/null +++ b/backend/apps/payments/views_refunds.py @@ -0,0 +1,60 @@ +"""Staff refund endpoint. Gated exactly like waive: same subject-authority helper.""" + +from drf_spectacular.utils import OpenApiResponse, extend_schema +from rest_framework.exceptions import NotFound +from rest_framework.response import Response +from rest_framework.views import APIView + +from apps.admin_api.permissions import IsActiveStaff +from apps.hardware_requests.exceptions import ErrorSerializer +from apps.payments.models import Payment +from apps.payments.reconciliation import _require_subject_authority +from apps.payments.serializers_reconciliation import ( + PaymentReconciliationSerializer, + PaymentRefundRequestSerializer, +) +from apps.payments.services_refunds import refund_payment +from apps.payments.subjects import resolve_subject_labels + + +class PaymentRefundView(APIView): + permission_classes = [IsActiveStaff] + + @extend_schema( + tags=["Payments"], + summary="Refund a payment settled online, fully or partially", + description=( + "Sends money back through the provider that took it and records a Refund line " + "on the payment. Only `paid_online` payments qualify; the sum of pending and " + "succeeded refunds can never exceed the payment amount. The Payment row itself " + "is immutable -- refund state lives on the Refund rows returned in `refunds`." + ), + request=PaymentRefundRequestSerializer, + responses={ + 200: PaymentReconciliationSerializer, + 400: OpenApiResponse(ErrorSerializer, description="Refund not allowed or over the balance."), + 403: OpenApiResponse(ErrorSerializer, description="Permission denied."), + 404: OpenApiResponse(ErrorSerializer, description="Payment or makerspace not found."), + 502: OpenApiResponse(ErrorSerializer, description="The provider rejected the refund."), + }, + ) + def post(self, request, makerspace_id, payment_id): + payload = PaymentRefundRequestSerializer(data=request.data) + payload.is_valid(raise_exception=True) + payment = ( + Payment.objects.select_related("makerspace") + .filter(makerspace_id=makerspace_id, pk=payment_id) + .first() + ) + if payment is None: + raise NotFound("Payment not found.") + _require_subject_authority(request.user, [payment]) + refund_payment( + payment, + amount=payload.validated_data["amount"], + reason=payload.validated_data.get("reason", ""), + actor=request.user, + ) + payment = Payment.objects.prefetch_related("refunds").get(pk=payment.pk) + context = {"payment_subject_labels": resolve_subject_labels([payment])} + return Response(PaymentReconciliationSerializer(payment, context=context).data) diff --git a/backend/apps/payments_rail/__init__.py b/backend/apps/payments_rail/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/apps/payments_rail/apps.py b/backend/apps/payments_rail/apps.py new file mode 100644 index 00000000..b61b2ae9 --- /dev/null +++ b/backend/apps/payments_rail/apps.py @@ -0,0 +1,12 @@ +from django.apps import AppConfig + + +class PaymentsRailConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.payments_rail" + label = "payments_rail" + + def ready(self): + from apps.separability.tombstones import register_separable_app + + register_separable_app("payments_rail") diff --git a/backend/apps/payments_rail/urls.py b/backend/apps/payments_rail/urls.py new file mode 100644 index 00000000..8edd0a19 --- /dev/null +++ b/backend/apps/payments_rail/urls.py @@ -0,0 +1,17 @@ +"""Staff surfaces that talk to a payment provider. Withdrawn by a rail tombstone. + +A refund is provider I/O: without the rail there is no vendor to send money back +through, so the route would answer only to fail. +""" + +from django.urls import path + +from apps.payments.views_refunds import PaymentRefundView + +urlpatterns = [ + path( + "admin/makerspace//payments//refund", + PaymentRefundView.as_view(), + name="payment-reconciliation-refund", + ), +] diff --git a/backend/apps/payments_rail/urls_member.py b/backend/apps/payments_rail/urls_member.py new file mode 100644 index 00000000..f2601d1b --- /dev/null +++ b/backend/apps/payments_rail/urls_member.py @@ -0,0 +1,25 @@ +"""Member surfaces that raise a provider object. Withdrawn by a rail tombstone. + +Both mint something at Stripe or Razorpay, so on a deployment shipping no rail they +would exist only to 503. The member's payment HISTORY is not here: that is ledger +business and stays reachable. +""" + +from django.urls import path + +from apps.payments_rail.views_member_checkout import MemberPaymentCheckoutView +from apps.payments_rail.views_member_mobile import MemberMobilePaymentIntentView + + +urlpatterns = [ + path( + "makerspaces//payments//checkout", + MemberPaymentCheckoutView.as_view(), + name="member-payment-checkout", + ), + path( + "makerspaces//payments//mobile-intent", + MemberMobilePaymentIntentView.as_view(), + name="member-payment-mobile-intent", + ), +] diff --git a/backend/apps/payments/views_connect.py b/backend/apps/payments_rail/views_connect.py similarity index 100% rename from backend/apps/payments/views_connect.py rename to backend/apps/payments_rail/views_connect.py diff --git a/backend/apps/payments_rail/views_member_checkout.py b/backend/apps/payments_rail/views_member_checkout.py new file mode 100644 index 00000000..f4a854c2 --- /dev/null +++ b/backend/apps/payments_rail/views_member_checkout.py @@ -0,0 +1,72 @@ +"""Raising an online checkout for a member's own charge. + +Split from `apps.payments.views_member` when the ledger and the rail became separately +tombstonable. The history and archived-discovery surfaces stayed core -- a member must +always be able to see what they owe and what they have paid -- while creating a provider +checkout is only meaningful on a deployment that ships the rail at all. +""" + +from drf_spectacular.utils import OpenApiResponse, extend_schema +from rest_framework import status +from rest_framework.exceptions import NotFound +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from apps.hardware_requests.exceptions import ErrorSerializer +from apps.payments import stripe_client +from apps.payments.availability import online_payments_enabled_for +from apps.payments.member_scope import member_payment_queryset +from apps.payments.models import Payment +from apps.payments.serializers import CheckoutUrlSerializer +from apps.payments.services import PaymentRailConflict, create_checkout_url + + +class MemberPaymentCheckoutView(APIView): + permission_classes = [IsAuthenticated] + + @extend_schema( + tags=["Payments"], + summary="Generate a Checkout link for the caller's pending payment", + request=None, + responses={200: CheckoutUrlSerializer, 404: OpenApiResponse(ErrorSerializer), 503: OpenApiResponse(ErrorSerializer)}, + ) + def post(self, request, makerspace_id, payment_id): + payment = member_payment_queryset(request.user, makerspace_id).filter( + pk=payment_id, + status=Payment.Status.PENDING, + ).first() + if payment is None: + raise NotFound() + if payment.stripe_checkout_url: + return Response({"checkout_url": payment.stripe_checkout_url}) + # A charge can exist with no rail behind it (cash-only space, or the rail switched + # off after the debt was raised). Refuse rather than mint a link the space cannot + # honour; the member settles this one at the desk. + if not online_payments_enabled_for(payment): + # Returned directly, not raised: the handler below that converts provider + # failures into a structured 503 only wraps the create_checkout_url call, so + # raising here escaped it and DRF answered 500 for an ordinary cash-only + # charge. + return Response( + { + "detail": "Online payment is not available for this charge.", + "code": "payments_unavailable", + }, + status=status.HTTP_503_SERVICE_UNAVAILABLE, + ) + try: + checkout_url = create_checkout_url(payment.pk, actor=request.user) + except PaymentRailConflict: + return Response( + { + 'detail': 'The payment already uses a different online payment rail.', + 'code': 'payment_rail_conflict', + }, + status=status.HTTP_409_CONFLICT, + ) + except Exception: + return Response({"detail": "Payments are temporarily unavailable.", "code": "payments_unavailable"}, status=status.HTTP_503_SERVICE_UNAVAILABLE) + if not checkout_url: + raise NotFound() + return Response({"checkout_url": checkout_url}) diff --git a/backend/apps/payments/views_member_mobile.py b/backend/apps/payments_rail/views_member_mobile.py similarity index 81% rename from backend/apps/payments/views_member_mobile.py rename to backend/apps/payments_rail/views_member_mobile.py index 6e3a1c15..ba5f1f59 100644 --- a/backend/apps/payments/views_member_mobile.py +++ b/backend/apps/payments_rail/views_member_mobile.py @@ -8,6 +8,7 @@ from apps.hardware_requests.exceptions import ErrorSerializer from apps.makerspaces.origin_scope import require_native_selected_makerspace from apps.payments import stripe_client +from apps.payments.availability import online_payments_enabled_for from apps.payments.member_scope import member_payment_queryset from apps.payments.models import Payment from apps.payments.serializers_mobile import MobilePaymentIntentResponseSerializer @@ -42,6 +43,18 @@ def post(self, request, makerspace_id, payment_id): ).first() if payment is None: raise NotFound() + # Same rule as the web checkout: a debt with no live rail behind it is settled at + # the desk, not by minting a native intent the space cannot capture. + if not online_payments_enabled_for(payment): + # Same reason as the web surface: the structured 503 handler below wraps only + # the intent call, so raising here would surface as a 500. + return Response( + { + 'detail': 'Online payment is not available for this charge.', + 'code': 'payments_unavailable', + }, + status=status.HTTP_503_SERVICE_UNAVAILABLE, + ) try: payload = create_mobile_intent(payment.pk, actor=request.user) except Payment.DoesNotExist as exc: diff --git a/backend/apps/separability/tombstones.py b/backend/apps/separability/tombstones.py index 95650364..231dfc0b 100644 --- a/backend/apps/separability/tombstones.py +++ b/backend/apps/separability/tombstones.py @@ -33,10 +33,12 @@ # working tombstone. SEPARABLE_APPS = frozenset({ "procurement", "notifications", "warranty", "maintenance", "presence", "events", "bookings", - # A makerspace that takes no money online ships no Stripe surfaces at all. The models - # stay (historic charges must remain purgeable and readable), and `payments.enabled` - # already lets a tenant switch the feature off -- this removes the code paths too. - "payments", + # A deployment that takes no money online ships no provider surfaces: checkout, + # the native payment sheet, Connect, refunds and every webhook. The LEDGER is not + # here and cannot be tombstoned -- money owed, its receipts and its reconciliation + # must stay readable and settleable on any deployment, which is exactly what + # splitting `payments_rail` out of `payments` bought. + "payments_rail", # Superadmin-only release plumbing. A deployment updated by its own host tooling has # no use for the in-app control surface. "updates", @@ -65,6 +67,18 @@ def unavailable_apps(): return sorted(app for app in SEPARABLE_APPS if not runtime_active(app)) +#: Labels that have been renamed, mapped to what they mean now. A deployment's env is +#: written once and lives for years, so an existing `TOMBSTONED_APPS=payments` must keep +#: working rather than failing startup with separability.E007 on upgrade. +#: +#: `payments` -> `payments_rail` also carries the intent across faithfully. It always +#: meant "this deployment ships no online payment surfaces", and that is now exactly what +#: the rail label removes -- the difference is that the LEDGER stays, so such a +#: deployment gains readable receipts and offline reconciliation it should never have +#: been without. +RENAMED_LABELS = {"payments": "payments_rail"} + + def tombstoned_app_labels(): """Parse the deployment's tombstone list. The one parser; everything else reads it. @@ -74,7 +88,8 @@ def tombstoned_app_labels(): single spelling of the parse and the two can never disagree. """ raw = os.environ.get("TOMBSTONED_APPS", "") - return frozenset(label.strip() for label in raw.split(",") if label.strip()) + labels = {label.strip() for label in raw.split(",") if label.strip()} + return frozenset(RENAMED_LABELS.get(label, label) for label in labels) def app_is_tombstoned(app_label): diff --git a/backend/apps/tenant_migration/audit_references_meta.py b/backend/apps/tenant_migration/audit_references_meta.py index b866a843..c8a6b49c 100644 --- a/backend/apps/tenant_migration/audit_references_meta.py +++ b/backend/apps/tenant_migration/audit_references_meta.py @@ -4,6 +4,10 @@ AuditReference, AuditReferenceDisposition, ) +from .audit_references_meta_membership import MEMBERSHIP_AUDIT_EDGES +from .audit_references_meta_payments import PAYMENT_AUDIT_EDGES +from .audit_references_meta_phase5 import PHASE5_AUDIT_EDGES +from .audit_references_meta_reports import REPORT_SCHEDULE_AUDIT_EDGES from .audit_references_meta_source_local import SOURCE_LOCAL_AUDIT_EDGES _SOURCE_LOCAL_EDGES = SOURCE_LOCAL_AUDIT_EDGES @@ -263,3 +267,7 @@ def _reference(disposition, model, *edges): AUDIT_META_REFERENCES.update( _reference(S, "boxes.QrScanEvent", ("qr.scanned", "scan_id")) ) +AUDIT_META_REFERENCES.update(PHASE5_AUDIT_EDGES) +AUDIT_META_REFERENCES.update(MEMBERSHIP_AUDIT_EDGES) +AUDIT_META_REFERENCES.update(PAYMENT_AUDIT_EDGES) +AUDIT_META_REFERENCES.update(REPORT_SCHEDULE_AUDIT_EDGES) diff --git a/backend/apps/tenant_migration/audit_references_meta_membership.py b/backend/apps/tenant_migration/audit_references_meta_membership.py new file mode 100644 index 00000000..ae348f92 --- /dev/null +++ b/backend/apps/tenant_migration/audit_references_meta_membership.py @@ -0,0 +1,55 @@ +"""Audit meta references for membership plans, terms and invitation requests (phase 6). + +Split from `audit_references_meta.py` to keep that file under the size ceiling; the +entries are merged into `AUDIT_META_REFERENCES` there. +""" + +from .audit_references_targets import AuditReference, AuditReferenceDisposition + +R = AuditReferenceDisposition.REMAP + +_TERM_ACTIONS = ( + "membership.term_created", + "membership.term_cancelled", + "membership.term_expired", + "membership.renewal_raised", +) +_INVITATION_ACTIONS = ( + "invitation_request.submitted", + "invitation_request.invited", + "invitation_request.declined", +) + + +def _edges(model, pairs): + return {edge: AuditReference(R, model) for edge in pairs} + + +MEMBERSHIP_AUDIT_EDGES = { + **_edges( + "makerspaces.MakerspaceMembership", + tuple((action, "membership_id") for action in _TERM_ACTIONS), + ), + **_edges( + "makerspaces.MembershipTerm", + tuple((action, "term_id") for action in _TERM_ACTIONS), + ), + **_edges( + "makerspaces.MembershipPlan", + ( + ("membership.term_created", "plan_id"), + ("membership_plan.created", "plan_id"), + ("membership_plan.updated", "plan_id"), + ), + ), + **_edges("payments.Payment", (("membership.renewal_raised", "payment_id"),)), + **_edges( + "makerspaces.InvitationRequest", + tuple((action, "invitation_request_id") for action in _INVITATION_ACTIONS), + ), + **_edges( + "makerspaces.MembershipRequest", + (("invitation_request.invited", "membership_request_id"),), + ), + **_edges("makerspaces.MakerspaceRole", (("invitation_request.invited", "role_id"),)), +} diff --git a/backend/apps/tenant_migration/audit_references_meta_payments.py b/backend/apps/tenant_migration/audit_references_meta_payments.py new file mode 100644 index 00000000..7af6e290 --- /dev/null +++ b/backend/apps/tenant_migration/audit_references_meta_payments.py @@ -0,0 +1,45 @@ +"""Audit meta references for refunds and loan charges (forward plan phase 6). + +Split from `audit_references_meta.py` to keep that file under the size ceiling; the +entries are merged into `AUDIT_META_REFERENCES` there. Every id here remaps: the +Payment, Refund and HardwareRequest rows all travel in a tenant archive. +""" + +from .audit_references_targets import AuditReference, AuditReferenceDisposition + +R = AuditReferenceDisposition.REMAP + +_REFUND_ACTIONS = ("payment.refund_requested", "payment.refunded", "payment.refund_failed") +_LOAN_ACTIONS = ("loan.deposit_raised", "loan.late_fee_raised") + + +def _edges(model, pairs): + return {edge: AuditReference(R, model) for edge in pairs} + + +#: Settlement receipts are audited too, and their primary keys are semantic references +#: like any other: left undeclared they would survive a Lane D import as stale SOURCE +#: ids inside immutable audit metadata, pointing at whatever row happened to take that +#: number on the target. +_SETTLEMENT_ACTIONS = ("payment.paid_offline", "payment.settlement_amended") + + +PAYMENT_AUDIT_EDGES = { + **_edges( + "payments.ManualSettlement", + [(action, "settlement_id") for action in _SETTLEMENT_ACTIONS], + ), + **_edges( + "payments.ManualSettlement", + [("payment.settlement_amended", "amends_id")], + ), + **_edges( + "payments.Payment", + [(action, "payment_id") for action in (*_REFUND_ACTIONS, *_LOAN_ACTIONS)], + ), + **_edges("payments.Refund", [(action, "refund_id") for action in _REFUND_ACTIONS]), + **_edges( + "hardware_requests.HardwareRequest", + [(action, "request_id") for action in _LOAN_ACTIONS], + ), +} diff --git a/backend/apps/tenant_migration/audit_references_meta_phase5.py b/backend/apps/tenant_migration/audit_references_meta_phase5.py new file mode 100644 index 00000000..572bb5b1 --- /dev/null +++ b/backend/apps/tenant_migration/audit_references_meta_phase5.py @@ -0,0 +1,41 @@ +"""Audit meta id edges added by forward plan phase 5 (member ID cards, certification gating).""" +from .audit_references_targets import AuditReference, AuditReferenceDisposition + +R = AuditReferenceDisposition.REMAP + + +def _reference(disposition, model, *edges): + return {edge: AuditReference(disposition, model) for edge in edges} + + +PHASE5_AUDIT_EDGES = {} +# Member ID cards (forward plan phase 5): card ids remap to the exported MemberCard row; +# the scan-time QR id remaps like every other QR reference. +PHASE5_AUDIT_EDGES.update( + _reference( + R, "makerspaces.MemberCard", + ("member_card.created", "card_id"), ("member_card.issued", "card_id"), + ("member_card.reissued", "card_id"), ("member_card.revoked", "card_id"), + ("member_card.printed", "card_id"), ("member_card.name_updated", "card_id"), + ("member_card.photo_updated", "card_id"), ("member_card.photo_removed", "card_id"), + ) +) +PHASE5_AUDIT_EDGES.update(_reference(R, "boxes.QrCode", ("member_card.scanned", "qr_id"))) +# Certification gating (phase 5, machines lane). +PHASE5_AUDIT_EDGES.update( + _reference( + R, "machines.CertificationType", + ("certification.granted", "certification_type_id"), + ("certification.revoked", "certification_type_id"), + ("certification.override", "certification_type_id"), + ) +) +PHASE5_AUDIT_EDGES.update( + _reference( + R, "makerspaces.MakerspaceMembership", + ("certification.granted", "membership_id"), ("certification.revoked", "membership_id"), + ) +) +PHASE5_AUDIT_EDGES.update( + _reference(R, "machines.MachineType", ("certification_type.created", "machine_type_id")) +) diff --git a/backend/apps/tenant_migration/audit_references_meta_reports.py b/backend/apps/tenant_migration/audit_references_meta_reports.py new file mode 100644 index 00000000..fb82cb4c --- /dev/null +++ b/backend/apps/tenant_migration/audit_references_meta_reports.py @@ -0,0 +1,24 @@ +"""Scheduled report delivery audit edges (forward plan phase 6, reports lane). + +Schedule ids remap to the exported `ReportSchedule` row. Delivery ids name omitted +delivery telemetry (`ReportDelivery` never travels), so they are snapshotted, not bound. +""" + +from .audit_references_targets import AuditReference, AuditReferenceDisposition + +R = AuditReferenceDisposition.REMAP +S = AuditReferenceDisposition.SOURCE_LOCAL_SNAPSHOT + +_SCHEDULE_ACTIONS = ( + "report_schedule.created", "report_schedule.updated", "report_schedule.deleted", + "report_schedule.skipped", "report_schedule.delivered", "report_schedule.failed", +) + +REPORT_SCHEDULE_AUDIT_EDGES = { + **{ + (action, "schedule_id"): AuditReference(R, "operations.ReportSchedule") + for action in _SCHEDULE_ACTIONS + }, + ("report_schedule.delivered", "delivery_id"): AuditReference(S, "operations.ReportDelivery"), + ("report_schedule.failed", "delivery_id"): AuditReference(S, "operations.ReportDelivery"), +} diff --git a/backend/apps/tenant_migration/gate_policy.py b/backend/apps/tenant_migration/gate_policy.py index e1004f0b..a3e19858 100644 --- a/backend/apps/tenant_migration/gate_policy.py +++ b/backend/apps/tenant_migration/gate_policy.py @@ -69,7 +69,7 @@ "apps.accounts.views_session.LogoutView.post": "Global user session state.", "apps.accounts.views_social.SocialNonceView.post": "Global social-login nonce state.", "apps.accounts.views_social.SocialLoginView.post": "Global social identity and session state.", - "apps.payments.views_connect.StripeConnectWebhookView.post": "Platform Connect routing state.", + "apps.payments_rail.views_connect.StripeConnectWebhookView.post": "Platform Connect routing state.", "apps.integrations.views.TelegramWebhookView.post": ( "Writes nothing at all: the callback route was removed when chat stopped being an " "action surface, and the view only acknowledges so an already-registered webhook " @@ -177,9 +177,16 @@ "Each rollup finalisation uses the skip-and-count tenant boundary; the " "makerspace queryset is servable-filtered before iteration." ), + "apps.operations.tasks_report_schedules.run_report_schedules_task": ( + "Each due schedule runs inside its own skip-and-count tenant boundary; the " + "due queryset is filtered to report-eligible (servable) makerspaces first." + ), "apps.makerspaces.tasks.refresh_github_contributions_task": ( "Each profile refresh uses the skip-and-count tenant boundary." ), + "apps.makerspaces.tasks_membership.run_membership_renewals_task": ( + "Each term expiry and renewal charge uses the skip-and-count tenant boundary." + ), "apps.apiclients.tasks.flush_api_client_usage_task": ( "Writes only deployment-local last-seen telemetry on ApiClient rows; it moves no " "tenant data and so needs no per-tenant gate." @@ -198,6 +205,10 @@ "Overdue loan reminders." ), "apps.makerspaces.tasks.refresh_github_contributions_task": "GitHub profile refresh.", + "apps.makerspaces.membership_plan_services.run_membership_renewals": ( + "Membership term expiry and renewal charges." + ), + "apps.operations.report_schedule_services.run_report_schedules": "Due scheduled report deliveries.", } @@ -225,6 +236,9 @@ "The fan-out service owns one tenant source-gate boundary at a time." ), "apps.events.services_images.remove_image": "Called by the tenant-resolved event image route.", + "apps.makerspaces.member_card_storage.presign_photo": "Called only by the tenant-resolved member-card photo route.", + "apps.makerspaces.member_card_storage.finalize_photo": "Called only by the tenant-resolved member-card photo route.", + "apps.makerspaces.member_card_storage._release": "Reached only through the member-card photo route, revoke and the membership purge.", "apps.events.services_images.update_image": "Called by the tenant-resolved event image route.", "apps.events.services_series_images.remove_image": "Called by the tenant-resolved event series image route.", "apps.events.services_series_images.update_image": "Called by the tenant-resolved event series image route.", @@ -243,6 +257,10 @@ "apps.maintenance.services_documents.delete_log_document": "Runs inside the model-resolved maintenance document route.", "apps.maintenance.services_documents.finalize_log_document": "Runs inside the model-resolved maintenance document route.", "apps.makerspaces.lifecycle_storage._delete_public_image_keys": "Tenant purge is an express source-gate exclusion.", + "apps.operations.report_delivery_storage.delete_report_object": ( + "Reached only inside run_report_schedules' skip-and-count tenant boundary, the " + "tenant-resolved schedule delete route and the reports module purge." + ), "apps.makerspaces.profile_images._swap": "Runs inside the authenticated member profile image route.", "apps.tenant_migration.tenant_dump_cleanup.cleanup_refused_tenant_dump_artifacts": ( "Deletes only private Lane D coordination artifacts after a refused run." diff --git a/backend/apps/tenant_migration/membership_dependencies.py b/backend/apps/tenant_migration/membership_dependencies.py index bccb20dd..65313640 100644 --- a/backend/apps/tenant_migration/membership_dependencies.py +++ b/backend/apps/tenant_migration/membership_dependencies.py @@ -25,6 +25,14 @@ class MembershipDependency: retained_by_import=True, reason="A member profile is owned by one non-null makerspace membership.", ), + "makerspaces.MembershipTerm": MembershipDependency( + retained_by_import=True, + reason="A term records what one membership was sold and when; it travels with the membership.", + ), + "machines.CertificationGrant": MembershipDependency( + retained_by_import=True, + reason="A certification grant records that one membership was trained; it travels with the membership.", + ), "presence.PresenceSession": MembershipDependency( retained_by_import=True, reason="Presence retains its non-null protected membership attribution.", diff --git a/backend/apps/tenant_migration/migrations/0010_money_fingerprint.py b/backend/apps/tenant_migration/migrations/0010_money_fingerprint.py new file mode 100644 index 00000000..a97f36df --- /dev/null +++ b/backend/apps/tenant_migration/migrations/0010_money_fingerprint.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.8 on 2026-09-06 05:15 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tenant_migration', '0009_tenant_dump_capture'), + ] + + operations = [ + migrations.AddField( + model_name='tenantdumpcapture', + name='money_fingerprint_sha256', + field=models.CharField(blank=True, max_length=64), + ), + ] diff --git a/backend/apps/tenant_migration/models_tenant_dump.py b/backend/apps/tenant_migration/models_tenant_dump.py index 8bc2e14e..2c5f8660 100644 --- a/backend/apps/tenant_migration/models_tenant_dump.py +++ b/backend/apps/tenant_migration/models_tenant_dump.py @@ -48,6 +48,10 @@ class Status(models.TextChoices): database_image_sha256 = models.CharField(max_length=64, blank=True) object_ledger = models.JSONField(default=list, blank=True) object_ledger_sha256 = models.CharField(max_length=64, blank=True) + #: Digest of the tenant's unsettled money at capture, revalidated at cutover so a + #: debt settled on the source after the freeze cannot be billed again on the target. + #: Blank on captures taken before this existed -- "not recorded", not "nothing owed". + money_fingerprint_sha256 = models.CharField(max_length=64, blank=True) source_encryption_mode = models.BooleanField() catalog_digest = models.CharField(max_length=64) capture_completed_at = models.DateTimeField(null=True, blank=True) diff --git a/backend/apps/tenant_migration/money_digest.py b/backend/apps/tenant_migration/money_digest.py new file mode 100644 index 00000000..97a351a1 --- /dev/null +++ b/backend/apps/tenant_migration/money_digest.py @@ -0,0 +1,113 @@ +"""A capture-bound fingerprint of a tenant's unsettled money. + +Pending payments travel in a portable dump now (owner decision D5). They could not +before: a single pending row refused the whole dump, which made a dump nearly +impossible for any space that tracks what members owe -- and tracking is on by default +since charges stopped depending on the payments module. + +Letting them travel needs one protection the old refusal gave for free. A capture +freezes the database under an exclusive gate and then REOPENS the source, so between +capture and cutover the source can settle a captured debt, raise a new one, or start a +rail. A dump carrying a debt the source has since collected would let the target bill +the member a second time. + +So the capture records a digest over exactly the rows that could drift -- pending +payments and the settlement chain that explains them -- and the cutover recomputes it +against the live source. A mismatch is not repairable by merging: the artifact was +derived from the frozen image, so the only honest answer is to recapture. That is what +`assert_money_unchanged` refuses with. + +Deliberately narrow: terminal payments are immutable, so they cannot drift, and +including them would make every ordinary settlement look like a drift. +""" + +import hashlib +import json + + +def money_fingerprint(makerspace_id, *, using="default"): + """Canonical digest of the tenant's unsettled money, plus its receipts. + + Ordered by primary key and rendered with sorted keys, so the same database state + always produces the same digest regardless of query plan or row order. + """ + from django.apps import apps + + Payment = apps.get_model("payments.Payment") + ManualSettlement = apps.get_model("payments.ManualSettlement") + + # The live-rail columns are here for a case that is easy to miss. A pending row can + # already carry a provider while holding no handle -- a checkout whose creation + # failed, or one that expired -- and the reopened source can then mint a fresh + # session or intent for it. `status`, `amount` and `provider` all sit still through + # that, so on their own the digest matched and publication handed out an artifact + # whose handles were stripped while the source stayed payable: the very double + # collection `preflight._check_live_checkouts` refuses before the freeze. Same field + # set as that check, plus the expiry that makes a handle inert and the rail label, + # so a rail appearing after capture is drift like any other. + payments = list( + Payment._base_manager.using(using) + .filter(makerspace_id=makerspace_id, status="pending") + .order_by("pk") + .values( + "id", + "status", + "amount", + "currency", + "provider", + "online_rail", + "external_order_id", + "checkout_url", + "stripe_checkout_session_id", + "stripe_checkout_url", + "stripe_checkout_session_expired_at", + "stripe_payment_intent_id", + ) + ) + # A receipt appearing (or being amended) against a captured pending row means that + # row was settled at the source after capture -- exactly the drift this catches. + settlements = list( + ManualSettlement.objects.using(using) + .filter(payment__makerspace_id=makerspace_id) + .order_by("pk") + .values("id", "payment_id", "method", "received_at", "amends_id") + ) + payload = { + "pending_payments": [ + {key: str(value) for key, value in row.items()} for row in payments + ], + "settlements": [ + {key: str(value) for key, value in row.items()} for row in settlements + ], + } + encoded = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode() + return hashlib.sha256(encoded).hexdigest() + + +class MoneyDriftRefused(Exception): + """The source's unsettled money moved after the capture that produced the dump.""" + + def __init__(self, expected, actual): + self.expected = expected + self.actual = actual + super().__init__( + "Source money state changed after capture: a pending charge was settled, " + "raised, amended, or given a live payment rail. The artifact was derived " + "from the frozen image and cannot be merged forward -- recapture the tenant." + ) + + +def assert_money_unchanged(capture, *, using="default"): + """Refuse a cutover whose dump no longer describes the source's obligations. + + A capture taken before this field existed carries an empty digest; those are not + revalidated, because a blank is "not recorded", not "nothing was owed", and + inventing a comparison would refuse every pre-existing capture. + """ + expected = capture.money_fingerprint_sha256 + if not expected: + return None + actual = money_fingerprint(capture.source_makerspace_id, using=using) + if actual != expected: + raise MoneyDriftRefused(expected, actual) + return actual diff --git a/backend/apps/tenant_migration/omitted_fields.py b/backend/apps/tenant_migration/omitted_fields.py index 95afc25c..39ef9bfc 100644 --- a/backend/apps/tenant_migration/omitted_fields.py +++ b/backend/apps/tenant_migration/omitted_fields.py @@ -44,6 +44,7 @@ def _rules(disposition, *fields): # Webhook destinations cannot satisfy their credential check constraint after # the encrypted webhook is removed, even when the source row is inactive. ("integrations.NotificationDestination", "webhook_url"), + ("integrations.NotificationDestination", "signing_secret"), ), **_rules( FRESH, @@ -71,6 +72,11 @@ def _rules(disposition, *fields): # cannot survive: the target recomputes it under the TARGET key after all # remapping. Never carry the source value. ("audit.AuditLog", "row_mac"), + # Full-text vectors are recomputed by the search trigger on the first write the + # importer makes; carrying a source value would only be overwritten. + ("inventory.InventoryProduct", "search_vector"), + ("machines.Machine", "search_vector"), + ("events.Event", "search_vector"), ), **_rules( EMPTY_STRING, @@ -115,6 +121,10 @@ def _rules(disposition, *fields): ("payments.MakerspacePaymentSettings", "connect_account_id"), ("payments.Payment", "external_order_id"), ("payments.Payment", "external_payment_id"), + # Same rule as the payment's own external ids: a provider-side refund id names a + # movement of money at a vendor account the target does not hold, and its unique + # constraint is what `unique_values` already calls inert once the value is dropped. + ("payments.Refund", "external_refund_id"), ("payments.Payment", "stripe_connected_account_id"), ("payments.Payment", "stripe_checkout_session_id"), ("payments.Payment", "stripe_checkout_session_expired_at"), diff --git a/backend/apps/tenant_migration/preflight.py b/backend/apps/tenant_migration/preflight.py index 0d64632d..dd7a1727 100644 --- a/backend/apps/tenant_migration/preflight.py +++ b/backend/apps/tenant_migration/preflight.py @@ -119,6 +119,10 @@ def _check_live_checkouts(makerspace): | ~Q(checkout_url="") | Q(stripe_checkout_session_id__isnull=False) | ~Q(stripe_checkout_url="") + # A native PaymentIntent is a live rail exactly like a hosted page: the member + # can still confirm it in the app. Omitting it let a pending row with an open + # intent pass a check whose whole purpose is "nothing is in flight". + | Q(stripe_payment_intent_id__isnull=False) ) live = Payment.objects.filter( makerspace=makerspace, @@ -129,8 +133,11 @@ def _check_live_checkouts(makerspace): # Importing this row would mint a second payable checkout while the source link # remained live. The target cannot expire the old page because reconciliation # needs the omitted session/order id, so each deployment could see one settlement. + # This is now the gate that makes carrying pending charges safe at all: a pending + # row travels only when nothing is in flight at a provider, so the target cannot + # collect the same debt the source is still able to collect. if live.exists(): - _fail("unresolved_live_checkout", "A pending payment has a live checkout session.") + _fail("unresolved_live_checkout", "A pending payment has a live payment rail.") def _fail(check, detail): diff --git a/backend/apps/tenant_migration/references.py b/backend/apps/tenant_migration/references.py index 9c0cc2f4..7d4710b2 100644 --- a/backend/apps/tenant_migration/references.py +++ b/backend/apps/tenant_migration/references.py @@ -37,11 +37,13 @@ class ClearWithProvenance: "box": "boxes.Box", "product": "inventory.InventoryProduct", "asset": "inventory.InventoryAsset", + "member_card": "makerspaces.MemberCard", }, ("hardware_requests.PublicToolLoan", "target_type", "target_id"): { "box": "boxes.Box", "product": "inventory.InventoryProduct", "asset": "inventory.InventoryAsset", + "member_card": "makerspaces.MemberCard", # Direct handouts point at the request created for that handover. This value # cannot share the QR discriminator map used by self-checkout loans. "direct": "hardware_requests.HardwareRequest", @@ -53,6 +55,7 @@ class ClearWithProvenance: "box": "boxes.Box", "product": "inventory.InventoryProduct", "asset": "inventory.InventoryAsset", + "member_card": "makerspaces.MemberCard", }, } @@ -75,6 +78,9 @@ class ClearWithProvenance: "booking": "bookings.Booking", "event_registration": "events.EventRegistration", "makerspace_membership": "makerspaces.MakerspaceMembership", + "membership_term": "makerspaces.MembershipTerm", + "loan_deposit": "hardware_requests.HardwareRequest", + "loan_late_fee": "hardware_requests.HardwareRequest", } NOTIFICATION_URL_ROUTES = ( diff --git a/backend/apps/tenant_migration/source_gate_guards.py b/backend/apps/tenant_migration/source_gate_guards.py index 400bbab2..06c639e4 100644 --- a/backend/apps/tenant_migration/source_gate_guards.py +++ b/backend/apps/tenant_migration/source_gate_guards.py @@ -111,7 +111,7 @@ def validate_webhook_coverage(apps_dir=APPS_DIR): entries = discover_webhooks(apps_dir) actual = {entry.target for entry in entries} exemptions = { - "apps.payments.views_connect.StripeConnectWebhookView.post": + "apps.payments_rail.views_connect.StripeConnectWebhookView.post": HTTP_EXEMPTIONS["stripe-connect-webhook"], "apps.integrations.views.TelegramWebhookView.post": HTTP_EXEMPTIONS["telegram-webhook"], diff --git a/backend/apps/tenant_migration/target_projection.py b/backend/apps/tenant_migration/target_projection.py index 556009e5..bf0e2151 100644 --- a/backend/apps/tenant_migration/target_projection.py +++ b/backend/apps/tenant_migration/target_projection.py @@ -195,6 +195,7 @@ def _default(reason, *, allow_superadmin_override=False): "integrations.RecipientMachineTypeScope": RowPolicy(RowDisposition.DROP, "A child of a dropped recipient cannot arrive live."), "integrations.RecipientMachineScope": RowPolicy(RowDisposition.DROP, "A child of a dropped recipient cannot arrive live."), "integrations.RecipientCategoryScope": RowPolicy(RowDisposition.DROP, "A child of a dropped recipient cannot arrive live."), + "operations.ReportSchedule": RowPolicy(RowDisposition.DROP, "A scheduled report delivery is a live disclosure rule the target must re-create."), "integrations.NotificationDestination": RowPolicy( RowDisposition.STAGE_INERT, "Imported Telegram rooms require target-authorized activation.", @@ -239,6 +240,8 @@ def _default(reason, *, allow_superadmin_override=False): ("machines.MachineConsumablePool", "machine_type"): ForeignKeyPolicy(ReferenceDisposition.REMAP_RESOLVED, "Use the built-in/custom machine type map."), ("machines.MakerspaceMachineTypePricing", "machine_type"): ForeignKeyPolicy(ReferenceDisposition.REMAP_RESOLVED, "Use the built-in/custom machine type map."), ("machines.ServiceQueue", "machine_type"): ForeignKeyPolicy(ReferenceDisposition.REMAP_RESOLVED, "Use the built-in/custom machine type map."), + ("machines.CertificationType", "machine_type"): ForeignKeyPolicy(ReferenceDisposition.REMAP_RESOLVED, "Use the built-in/custom machine type map."), + ("bookings.BookableSpace", "machine_type"): ForeignKeyPolicy(ReferenceDisposition.REMAP_RESOLVED, "Use the built-in/custom machine type map."), ("procurement.ToBuyItem", "machine_type"): ForeignKeyPolicy(ReferenceDisposition.REMAP_RESOLVED, "Use the built-in/custom machine type map."), ("integrations.MachineTypeEmailTemplate", "machine_type"): ForeignKeyPolicy(ReferenceDisposition.REMAP_RESOLVED, "Use the built-in/custom machine type map."), ("integrations.DestinationMachineTypeScope", "machine_type"): ForeignKeyPolicy(ReferenceDisposition.REMAP_RESOLVED, "Use the built-in/custom machine type map."), diff --git a/backend/apps/tenant_migration/tenant_dump_authority.py b/backend/apps/tenant_migration/tenant_dump_authority.py index 841b9c71..af928b9c 100644 --- a/backend/apps/tenant_migration/tenant_dump_authority.py +++ b/backend/apps/tenant_migration/tenant_dump_authority.py @@ -198,7 +198,7 @@ def _build(entries): ), *_same( "makerspaces.MemberProfile", - "is_visible show_attended_events", + "is_visible show_attended_events show_certifications", (D.PRESERVE, D.DROP), "Full-user consent travels; every stub-linked profile is dropped.", ), diff --git a/backend/apps/tenant_migration/tenant_dump_authority_supplement.py b/backend/apps/tenant_migration/tenant_dump_authority_supplement.py index 9ce3a48a..d0d04ebe 100644 --- a/backend/apps/tenant_migration/tenant_dump_authority_supplement.py +++ b/backend/apps/tenant_migration/tenant_dump_authority_supplement.py @@ -53,4 +53,61 @@ def _same(label, names, disposition, reason): D.RESET, "Provider handles and cross-tenant routing cannot authorize target operations.", ), + *_same( + "payments.ManualSettlement", + "method reference received_at amount currency", + D.PRESERVE, + "The cash book travels with the payments it explains: how and when money was " + "taken is the space's own record, not a provider handle.", + ), + *_same( + "payments.Refund", + "status amount currency provider reason", + D.PRESERVE, + "Refund history remains readable alongside the payment it corrects.", + ), + *_same( + "payments.Refund", + "external_refund_id", + D.RESET, + "Provider refund handles cannot authorize target operations.", + ), + *_same( + "makerspaces.MemberCard", + "printed_name photo_object_key photo_content_type photo_size_bytes photo_consent_at " + "photo_consent_version", + (D.PRESERVE, D.DROP), + "Full-user card identity travels; stub-linked identity content does not.", + ), + # Membership plans, terms and invitation requests (forward plan phase 6). + *_same( + "makerspaces.InvitationRequest", + "name email phone message", + (D.PRESERVE, D.DROP), + "A lead's contact details travel with a full tenant; stub-linked content does not.", + ), + *_same( + "makerspaces.InvitationRequest", + "status", + D.PRESERVE, + "Whether a lead was invited or declined is honest history, not authority.", + ), + *_same( + "makerspaces.MembershipPlan", + "is_active", + D.PRESERVE, + "Which plans a tenant still sells is its own pricing choice.", + ), + *_same( + "makerspaces.MembershipTerm", + "status", + D.PRESERVE, + "Term history stays readable; it never changes membership authority.", + ), + *_same( + "makerspaces.Makerspace", + "lapsed_members_cannot_borrow", + D.PRESERVE, + "A tenant's own borrowing rule for lapsed members, still bounded by target modules.", + ), ) diff --git a/backend/apps/tenant_migration/tenant_dump_capture.py b/backend/apps/tenant_migration/tenant_dump_capture.py index dffaae13..771790c0 100644 --- a/backend/apps/tenant_migration/tenant_dump_capture.py +++ b/backend/apps/tenant_migration/tenant_dump_capture.py @@ -25,6 +25,7 @@ from .tenant_dump_capture_database import capture_database_image from .tenant_dump_catalog import CATALOG_SCHEMA_SHA256, validate_catalog from .tenant_dump_errors import TenantDumpBuildError, TenantDumpCustodyError +from .money_digest import money_fingerprint from .tenant_dump_lineage import canonical_digest, object_ledger from .tenant_dump_staging import create_capture_root, delete_owned_root @@ -146,6 +147,9 @@ def capture_tenant_dump_source(capture_id, *, sleep=None, storage_modes=None): ) modes = storage_modes or _storage_modes() objects = capture_tenant_objects(root, capture.makerspace, modes) + # Taken INSIDE the gate, against the same quiesced state as the image, so + # the digest describes exactly what the artifact will carry. + money_sha256 = money_fingerprint(capture.makerspace_id) ledger = object_ledger(objects) database_sha256 = sha256_file(database_image) @@ -158,6 +162,7 @@ def capture_tenant_dump_source(capture_id, *, sleep=None, storage_modes=None): database_sha256=database_sha256, objects=ledger, object_ledger_sha256=ledger_sha256, + money_fingerprint_sha256=money_sha256, ) lineage_persisted = True release_after_copy_capture(lease, actor=capture.requested_by) @@ -199,6 +204,7 @@ def _complete_capture( database_sha256, objects, object_ledger_sha256, + money_fingerprint_sha256="", ): capture = TenantDumpCapture.objects.select_for_update().get(pk=capture_id) if capture.status != TenantDumpCapture.Status.CAPTURING: @@ -211,6 +217,7 @@ def _complete_capture( capture.database_image_sha256 = database_sha256 capture.object_ledger = list(objects) capture.object_ledger_sha256 = object_ledger_sha256 + capture.money_fingerprint_sha256 = money_fingerprint_sha256 capture.capture_completed_at = timezone.now() capture.save() audit.record( @@ -223,6 +230,7 @@ def _complete_capture( "gate_fencing_token": lease.fencing_token, "database_image_sha256": database_sha256, "object_ledger_sha256": object_ledger_sha256, + "money_fingerprint_sha256": money_fingerprint_sha256, }, ) diff --git a/backend/apps/tenant_migration/tenant_dump_catalog.py b/backend/apps/tenant_migration/tenant_dump_catalog.py index 43e551e8..6187f623 100644 --- a/backend/apps/tenant_migration/tenant_dump_catalog.py +++ b/backend/apps/tenant_migration/tenant_dump_catalog.py @@ -44,7 +44,18 @@ class TenantDumpCatalogError(AssertionError): # so this was recomputed after the merge rather than taken from either side. # Re-blessed again after both evidence retention models gained a normal auto primary key # so that it can travel with a tenant at all. -CATALOG_SCHEMA_SHA256 = "3822e7a1d25be00cb59304f0afa3d576f59c63aa065a7b91d991156b33bd4c70" +# Re-blessed for the forward plan (2026-09-04): trigger-maintained `search_vector` columns on +# Event/InventoryProduct/Machine (phase 1, DERIVED on import), MemberCard, CertificationType, +# CertificationGrant, BookableSpace.machine_type and MemberProfile.show_certifications (phase 5); +# again for phase 6: Refund, loan settings/deposit_amount, MembershipPlan/Term, InvitationRequest, +# Makerspace.lapsed_members_cannot_borrow, ReportSchedule/ReportDelivery. +# Re-blessed for the manual-settlement ledger: `payments.ManualSettlement` is the +# append-only cash book recording how and when an offline charge was actually paid, so +# it must travel with the payments it explains. +# Re-blessed again for `TenantDumpCapture.money_fingerprint_sha256`, which binds a +# capture to the tenant's unsettled money so publication can refuse a dump the source +# has since moved past. +CATALOG_SCHEMA_SHA256 = "5f48719d15dbf79c58c0d70de104e3107a9c15d4895bf9f6fbe1dceb55175c59" def catalog_models(apps_registry=apps): diff --git a/backend/apps/tenant_migration/tenant_dump_cross_tenant.py b/backend/apps/tenant_migration/tenant_dump_cross_tenant.py index fe286d81..dc977c60 100644 --- a/backend/apps/tenant_migration/tenant_dump_cross_tenant.py +++ b/backend/apps/tenant_migration/tenant_dump_cross_tenant.py @@ -134,11 +134,17 @@ def inspect_cross_tenant_source(makerspace_id, *, using="default"): for row in Payment._base_manager.using(using).filter( makerspace_id=makerspace_id ).values("id", "status"): + # A pending charge no longer refuses the dump. It used to, from before money + # owed could be recorded without a gateway -- which made a dump nearly + # impossible for any space that tracks debts, and tracking is on by default now. + # Two things make it safe to carry: the preflight refuses any pending row with a + # LIVE rail, and projection strips every provider HANDLE off the row -- session, + # intent, order id, checkout URL, connected account, rail -- so the target cannot + # resume the source's. The `provider` label itself travels, so a charge raised + # under one vendor stays attributed to it. Drift between capture and cutover is + # caught by the capture's money fingerprint. if row["status"] == "pending": - raise TenantDumpDispositionRefused( - f"Pending Payment {row['id']} is an unresolved obligation.", - reason_code="pending_payment", - ) + continue if row["status"] not in TERMINAL_PAYMENT_STATUSES: raise TenantDumpDispositionRefused( f"Payment {row['id']} has an unclassified status.", diff --git a/backend/apps/tenant_migration/tenant_dump_cross_tenant_verify.py b/backend/apps/tenant_migration/tenant_dump_cross_tenant_verify.py index f0a6fcb3..14a89291 100644 --- a/backend/apps/tenant_migration/tenant_dump_cross_tenant_verify.py +++ b/backend/apps/tenant_migration/tenant_dump_cross_tenant_verify.py @@ -26,11 +26,17 @@ def verify_cross_tenant_projection(using, makerspace_id): for row in Payment._base_manager.using(using).values( "status", *PAYMENT_CLEARED_VALUES ): - if row["status"] not in TERMINAL_PAYMENT_STATUSES or any( - row[name] != value for name, value in PAYMENT_CLEARED_VALUES.items() - ): + # Pending rows travel now (D5). They are held to the SAME cleared-handle rule as + # terminal ones -- no order id, session, intent, checkout URL, connected account + # or routing -- which is what stops a target resuming the source's rail. The + # status set is no longer part of the assertion, only the projection is. + if any(row[name] != value for name, value in PAYMENT_CLEARED_VALUES.items()): raise TenantDumpVerificationError( - "A payment violates the terminal-history projection." + "A payment violates the provider-history projection." + ) + if row["status"] not in TERMINAL_PAYMENT_STATUSES and row["status"] != "pending": + raise TenantDumpVerificationError( + f"A payment carries an unexpected status: {row['status']}." ) _verify_transfers(using, int(makerspace_id)) return True diff --git a/backend/apps/tenant_migration/tenant_dump_field_snapshot.py b/backend/apps/tenant_migration/tenant_dump_field_snapshot.py index f2e00b92..f436f205 100644 --- a/backend/apps/tenant_migration/tenant_dump_field_snapshot.py +++ b/backend/apps/tenant_migration/tenant_dump_field_snapshot.py @@ -23,14 +23,18 @@ 'accounts.SocialLoginNonce': frozenset('attestation_challenge client_platform consumed_at created_at delivery device_grant expires_at id nonce_digest origin provider surface'.split()), 'accounts.PlatformSocialAuthSettings': frozenset('apple_key_id apple_native_app_ids apple_private_key apple_service_id apple_team_id google_android_client_id google_ios_client_id google_web_client_id id updated_at'.split()), 'accounts.OidcProvider': frozenset('allow_auto_link client_id created_at display_name id is_enabled issuer jwks_url slug updated_at'.split()), - 'makerspaces.Makerspace': frozenset('anonymous_requester anonymous_requests_enabled archived_at archived_by booking_requester_notifications_enabled branding_config cors_allowed_origins cover_image_key created_at created_by default_loan_days discord_webhook_url domain_verification_token domain_verified_at enabled_features enabled_modules filament_low_stock_threshold_grams frontend_domain frontend_domain_changed_at frontend_domain_status geofence_enabled geofence_latitude geofence_longitude geofence_radius_m hidden_from_central_directory id lifecycle_state location logo_key map_url mattermost_webhook_url membership_dues_amount membership_policy name presence_preset_minutes public_api_key public_code public_inventory_enabled public_print_status_lookup_policy public_stats_enabled public_stats_show_holder_names referrals_enabled resource_limit_overrides slack_webhook_url slug smtp_from_email smtp_host smtp_password smtp_port smtp_use_ssl smtp_use_tls smtp_username staff_notifications_enabled storage_bytes_used superadmin_access_enabled telegram_bot_token telegram_group_chat_id theme_config updated_at'.split()), + 'makerspaces.Makerspace': frozenset('anonymous_requester anonymous_requests_enabled archived_at archived_by booking_requester_notifications_enabled branding_config cors_allowed_origins cover_image_key created_at created_by default_loan_days discord_webhook_url domain_verification_token domain_verified_at enabled_features enabled_modules filament_low_stock_threshold_grams frontend_domain frontend_domain_changed_at frontend_domain_status geofence_enabled geofence_latitude geofence_longitude geofence_radius_m hidden_from_central_directory id lifecycle_state location logo_key map_url mattermost_webhook_url membership_dues_amount membership_policy name presence_preset_minutes public_api_key public_code public_inventory_enabled public_print_status_lookup_policy public_stats_enabled public_stats_show_holder_names lapsed_members_cannot_borrow referrals_enabled resource_limit_overrides slack_webhook_url slug smtp_from_email smtp_host smtp_password smtp_port smtp_use_ssl smtp_use_tls smtp_username staff_notifications_enabled storage_bytes_used superadmin_access_enabled telegram_bot_token telegram_group_chat_id theme_config updated_at'.split()), 'makerspaces.MakerspaceMembership': frozenset('accepted_waiver activated_actor_snapshot activated_at activated_by assigned_role can_refer can_verify created_at id makerspace receives_notifications revocation_reason revoked_actor_snapshot revoked_at revoked_by role status user verified_actor_snapshot verified_at verified_by waiver_accepted_at waiver_version_accepted witnessed_actor_snapshot witnessed_at witnessed_by witnessed_waiver witnessed_waiver_version'.split()), 'makerspaces.MakerspaceRole': frozenset('created_at granted_actions id is_default is_protected legacy_role makerspace name slug updated_at'.split()), 'makerspaces.MakerspaceWaiver': frozenset('body created_at created_by id is_active makerspace superseded_at version'.split()), 'makerspaces.MembershipRequest': frozenset('assigned_role auto_activate_on_claim created_at decided_at decided_by decision_note id invite_email invited_by kind makerspace requested_by state updated_at user'.split()), 'makerspaces.SubdomainRequest': frozenset('created_at decided_at decided_by id makerspace note requested_by requested_label status updated_at'.split()), - 'makerspaces.MemberProfile': frozenset('avatar_key bio created_at education github_contributions github_synced_at github_username headline id institution interests is_visible languages membership show_attended_events updated_at'.split()), + 'makerspaces.MemberCard': frozenset('card_number created_at id issued_at last_printed_at makerspace membership photo_consent_at photo_consent_version photo_content_type photo_object_key photo_size_bytes print_count printed_name revoked_at revoked_reason template_version_at_issue updated_at'.split()), + 'makerspaces.MemberProfile': frozenset('avatar_key bio created_at education github_contributions github_synced_at github_username headline id institution interests is_visible languages membership show_attended_events show_certifications updated_at'.split()), 'makerspaces.MemberProject': frozenset('created_at description id image_key links position profile title updated_at'.split()), + 'makerspaces.MembershipPlan': frozenset('amount created_at currency custom_days id interval is_active makerspace name updated_at'.split()), + 'makerspaces.MembershipTerm': frozenset('created_at created_by ends_at id membership plan renewal_payment starts_at status'.split()), + 'makerspaces.InvitationRequest': frozenset('created_at email handled_at handled_by id makerspace message name phone status'.split()), 'makerspaces.MakerspaceArchiveRequest': frozenset('id makerspace reason requested_at requested_by resolution_note resolved_at resolved_by status'.split()), 'makerspaces.ImportedUserReconciliation': frozenset('created_at id makerspace source_user_id source_username target_user'.split()), 'makerspaces.PendingImportedMembership': frozenset('accepted_waiver activated_actor_snapshot activated_at adopted_at adopted_membership archived_role_label can_refer can_verify created_at email id makerspace receives_notifications revocation_reason revoked_actor_snapshot revoked_at source_membership_id status unresolved_reason verified_actor_snapshot verified_at waiver_accepted_at waiver_version_accepted witnessed_actor_snapshot witnessed_at witnessed_waiver witnessed_waiver_version'.split()), @@ -38,7 +42,9 @@ 'organizations.OrganizationMakerspace': frozenset('created_at created_by id makerspace organization relationship updated_at'.split()), 'organizations.OrganizationMembership': frozenset('created_at created_by governance_actions granted_actions id organization status updated_at user'.split()), 'organizations.OrganizationInvitation': frozenset('created_at created_by expires_at governance_actions granted_actions id organization redeemed_at redeemed_by revoked_at token_digest updated_at'.split()), - 'payments.MakerspacePaymentSettings': frozenset('connect_account_assigned_at connect_account_id connect_charges_enabled connect_payouts_enabled connect_status connect_status_updated_at default_currency id makerspace provider razorpay_key_id razorpay_key_secret razorpay_webhook_secret stripe_publishable_key stripe_secret_key stripe_webhook_secret'.split()), + 'payments.MakerspacePaymentSettings': frozenset('connect_account_assigned_at connect_account_id connect_charges_enabled connect_payouts_enabled connect_status connect_status_updated_at default_currency id loan_deposit_amount loan_deposit_blocks_issue loan_deposit_mode loan_grace_days loan_late_fee_cap loan_late_fee_per_day makerspace provider razorpay_key_id razorpay_key_secret razorpay_webhook_secret stripe_publishable_key stripe_secret_key stripe_webhook_secret'.split()), + 'payments.ManualSettlement': frozenset('amends amount created_at currency id method payment received_at recorded_by reference'.split()), + 'payments.Refund': frozenset('amount created_at created_by currency external_refund_id id payment provider reason settled_at status'.split()), 'payments.PlatformStripeConnectSettings': frozenset('application_fee_bps id stripe_connect_client_id stripe_publishable_key stripe_secret_key stripe_webhook_secret updated_at'.split()), 'payments.StripeConnectOAuthState': frozenset('consumed_at created_at expires_at id initiated_by makerspace state_digest'.split()), 'payments.Payment': frozenset('amount checkout_url created_at created_by currency external_order_id external_payment_id id makerspace member online_rail provider status stripe_application_fee_amount stripe_checkout_session_expired_at stripe_checkout_session_id stripe_checkout_url stripe_connected_account_id stripe_payment_intent_id stripe_provider subject_id subject_label subject_type updated_at via_makerspace'.split()), @@ -57,7 +63,7 @@ 'boxes.QrCode': frozenset('created_at created_by id makerspace payload revoked_at status target_id target_type updated_at'.split()), 'boxes.QrScanEvent': frozenset('actor context created_at id makerspace qr_code request'.split()), 'inventory.Category': frozenset('created_at display_order icon id makerspace name slug updated_at'.split()), - 'inventory.InventoryProduct': frozenset('available_quantity box category created_at damaged_quantity description id image_key is_archived is_public issued_quantity lost_quantity makerspace name needs_fix_quantity public_availability_mode public_self_checkout_enabled reserved_quantity show_public_count storage_location total_quantity tracking_mode updated_at'.split()), + 'inventory.InventoryProduct': frozenset('available_quantity box category created_at damaged_quantity deposit_amount description id image_key is_archived is_public issued_quantity lost_quantity makerspace name needs_fix_quantity public_availability_mode public_self_checkout_enabled reserved_quantity search_vector show_public_count storage_location total_quantity tracking_mode updated_at'.split()), 'inventory.InventoryAsset': frozenset('asset_tag box created_at id makerspace notes product public_self_checkout_enabled serial_number status updated_at'.split()), 'hardware_requests.HardwareRequest': frozenset('accepted_at accepted_by anonymous_idempotency_key_fingerprint anonymous_payload_fingerprint assigned_box closed_at closed_by created_at id issue_evidence issue_remark issued_at issued_by makerspace public_token rejection_reason requested_for requester requester_contact_email requester_contact_phone requester_contact_verified requester_name requester_username return_due_at return_reminder_sent_at status updated_at'.split()), 'hardware_requests.HardwareRequestItem': frozenset('accepted_quantity damaged_quantity id issued_quantity missing_quantity needs_fix_quantity product request requested_quantity returned_quantity'.split()), @@ -91,7 +97,7 @@ 'integrations.DailyNotificationCounter': frozenset('channel count day id makerspace'.split()), 'integrations.ChatTemplate': frozenset('created_at event feature id is_active makerspace text_body updated_at updated_by'.split()), 'integrations.MachineTypeEmailTemplate': frozenset('audience created_at html_body id is_active key machine_type makerspace stream subject text_body updated_at'.split()), - 'integrations.NotificationDestination': frozenset('channel created_at id is_active label makerspace telegram_chat_id updated_at webhook_url'.split()), + 'integrations.NotificationDestination': frozenset('channel created_at id is_active label makerspace signing_secret telegram_chat_id updated_at webhook_url'.split()), 'integrations.DestinationMachineTypeScope': frozenset('destination id machine_type'.split()), 'integrations.DestinationMachineScope': frozenset('destination id machine'.split()), 'integrations.DestinationCategoryScope': frozenset('category destination id'.split()), @@ -106,6 +112,8 @@ 'operations.PeriodicTaskRun': frozenset('id last_error last_run_at name'.split()), 'operations.ReportMetricRollup': frozenset('bucket_start checksum computed_at dimension_key dimensions grain id makerspace metric_key report_key revision sample_count source_cutoff source_module value'.split()), 'operations.ReportRollupCursor': frozenset('id last_error_code last_success_at makerspace rolled_through source_module updated_at'.split()), + 'operations.ReportSchedule': frozenset('cadence created_at created_by destination filters format grain id is_active last_run_at makerspace next_run_at recipient_emails report_key updated_at'.split()), + 'operations.ReportDelivery': frozenset('created_at error expires_at id object_key schedule status'.split()), 'operations.StockTransfer': frozenset('applied_at created_at created_by destination_container destination_makerspace id makerspace reason source_container source_makerspace status'.split()), 'operations.StockTransferLine': frozenset('asset from_status id notes product quantity to_status transfer'.split()), 'operations.StocktakeSession': frozenset('approved_at approved_by completed_at container id makerspace notes started_at started_by status'.split()), @@ -152,9 +160,11 @@ 'machines.PrintingCutoverRepair': frozenset('created_at detail id kind legacy_id legacy_model makerspace resolved_at resolved_by'.split()), 'machines.RoleMachineTypeScope': frozenset('created_at id machine_type role'.split()), 'machines.RoleMachineScope': frozenset('created_at id machine role'.split()), + 'machines.CertificationGrant': frozenset('certification_type expires_at granted_at granted_by id membership notes revoked_at revoked_by'.split()), + 'machines.CertificationType': frozenset('created_at description id is_active is_required_for_booking is_required_for_service machine_type makerspace name updated_at validity_days'.split()), 'machines.MachineType': frozenset('capability_config icon id is_builtin makerspace managing_action name slug'.split()), 'machines.MakerspaceMachineTypePricing': frozenset('created_at created_by flat_fee id machine_type makerspace payment_enabled rate_per_unit updated_at updated_by'.split()), - 'machines.Machine': frozenset('camera_feed_url created_at created_by firmware_version id image_key is_active is_public legacy_print_printer_id location machine_type makerspace name notes service_file_policy status type_payload updated_at'.split()), + 'machines.Machine': frozenset('camera_feed_url created_at created_by firmware_version id image_key is_active is_public legacy_print_printer_id location machine_type makerspace name notes search_vector service_file_policy status type_payload updated_at'.split()), 'machines.MachineOperator': frozenset('access_level assigned_at assigned_by id machine user'.split()), 'machines.MachineUsageEntry': frozenset('consumable_pool consumed_grams consumed_quantity contact_email contact_phone created_at duration_minutes hours id legacy_manual_print_log_id logged_by machine metering_unit note outcome percent_complete reason requester_name service_request source title'.split()), 'machines.MachineDocument': frozenset('content_type created_at doc_type id machine object_key original_filename size_bytes uploaded_by'.split()), @@ -164,7 +174,7 @@ 'events.EventSeriesCollaborator': frozenset('created_at id invited_by makerspace responded_at responded_by series status'.split()), 'events.EventSeriesOrganizer': frozenset('created_at created_by id organization series'.split()), 'events.EventOrganizer': frozenset('created_at created_by event id organization source_series_organizer'.split()), - 'events.Event': frozenset('badge_template calendar_sequence calendar_uid calendar_updated_at capacity created_at created_by custom_form description ends_at id image_key is_public location location_kind makerspace payment_amount public_token registration_cutoff_at registration_cutoff_lead_minutes registration_requires_approval series series_occurrence_key series_override_fields series_revision starts_at status timezone_name title updated_at'.split()), + 'events.Event': frozenset('badge_template calendar_sequence calendar_uid calendar_updated_at capacity created_at created_by custom_form description ends_at id image_key is_public location location_kind makerspace payment_amount public_token registration_cutoff_at registration_cutoff_lead_minutes registration_requires_approval search_vector series series_occurrence_key series_override_fields series_revision starts_at status timezone_name title updated_at'.split()), 'events.EventCollaborator': frozenset('created_at event id invited_by makerspace responded_at responded_by source_series_collaboration status'.split()), 'events.EventRegistration': frozenset('calendar_sequence calendar_updated_at checkin_token created_at custom_answers email email_exact_hash email_hash_generation event host_waiver host_waiver_accepted_at host_waiver_version_accepted id member name payment_via_makerspace phone registered_via_makerspace status'.split()), 'events.MemberCalendarFeed': frozenset('created_at id membership revoked_at rotated_at token_digest token_hint'.split()), @@ -173,7 +183,7 @@ 'events.EventFeedbackSurvey': frozenset('answered_question_ids certificate_enabled closed_at created_at event id is_open opened_at questions thank_you_text title updated_at'.split()), 'events.EventFeedbackResponse': frozenset('answers_snapshot certificate_requested created_at id registration survey'.split()), 'events.EventAttendanceCertificate': frozenset('content_type event_ends_at event_starts_at event_title id issued_at issuer_name object_key recipient_name registration rendered_at response revision revocation_reason revoked_at revoked_by serial sha256 size_bytes status'.split()), - 'bookings.BookableSpace': frozenset('approval_mode booking_lead_time_minutes capacity created_at created_by custom_form description id image_key is_active is_public kind location makerspace max_booking_advance_days max_booking_duration_minutes min_booking_duration_minutes name payment_amount public_token requester_notifications_enabled show_public_availability show_public_booker_names updated_at'.split()), + 'bookings.BookableSpace': frozenset('approval_mode booking_lead_time_minutes capacity created_at created_by custom_form description id image_key is_active is_public kind location machine_type makerspace max_booking_advance_days max_booking_duration_minutes min_booking_duration_minutes name payment_amount public_token requester_notifications_enabled show_public_availability show_public_booker_names updated_at'.split()), 'bookings.Booking': frozenset('created_at custom_answers email ends_at id member name note phone public_token space starts_at status'.split()), 'maintenance.MaintenanceSchedule': frozenset('created_at created_by description id interval_days is_active machine next_due updated_at'.split()), 'maintenance.MaintenanceLog': frozenset('cost created_at id machine parts_note performed_at performed_by summary'.split()), @@ -190,7 +200,7 @@ 'tenant_migration.ReceiptConsumption': frozenset('consumed_at consumed_by purpose receipt'.split()), 'tenant_migration.MigratedOutHandoff': frozenset('abort_receipt archive_digest id migrated_out_at pairing reopened_at reopened_by source_cutover_receipt source_tenant state target_deployment_id'.split()), 'tenant_migration.SourceMigrationGate': frozenset('actor fencing_token heartbeat_at lease_expires_at makerspace owner_id presign_drain_until purpose quiesced_at reopened_at state updated_at'.split()), - 'tenant_migration.TenantDumpCapture': frozenset('artifact_sha256 artifact_size_bytes capture_completed_at catalog_digest content_ledger created_at database_image_sha256 database_snapshot_at derivation_policy_sha256 download_token_consumed_at download_token_digest download_token_expires_at frozen_tenant_recipients gate_fencing_token gate_owner_id id makerspace manifest object_key object_ledger object_ledger_sha256 parent_database_sha256 parent_object_ledger_sha256 published_at refusal_code refusal_detail requested_by source_deployment_identity source_encryption_mode source_makerspace_id source_makerspace_slug source_postgres_major status superadmin_access_at_decision unpublished_object_key updated_at'.split()), + 'tenant_migration.TenantDumpCapture': frozenset('artifact_sha256 artifact_size_bytes capture_completed_at catalog_digest content_ledger created_at database_image_sha256 database_snapshot_at derivation_policy_sha256 download_token_consumed_at download_token_digest download_token_expires_at frozen_tenant_recipients gate_fencing_token gate_owner_id id makerspace manifest money_fingerprint_sha256 object_key object_ledger object_ledger_sha256 parent_database_sha256 parent_object_ledger_sha256 published_at refusal_code refusal_detail requested_by source_deployment_identity source_encryption_mode source_makerspace_id source_makerspace_slug source_postgres_major status superadmin_access_at_decision unpublished_object_key updated_at'.split()), 'tenant_migration.ExternalTenantReference': frozenset('created_at field_name id makerspace snapshot source_archive_digest source_model_label source_object_id target_model_label target_object_id'.split()), } diff --git a/backend/apps/tenant_migration/tenant_dump_model_catalog.py b/backend/apps/tenant_migration/tenant_dump_model_catalog.py index 50d6eb63..6823ada6 100644 --- a/backend/apps/tenant_migration/tenant_dump_model_catalog.py +++ b/backend/apps/tenant_migration/tenant_dump_model_catalog.py @@ -8,7 +8,8 @@ FIRST_PARTY_APP_LABELS = frozenset( """accounts admin_api apiclients audit backup bookings boxes data_export encryption events evidence hardware_requests integrations inventory machines - maintenance makerspaces notifications operations organizations payments presence + maintenance makerspaces notifications operations organizations payments payments_rail + presence printing procurement roadmap separability tenant_migration updates warranty""".split() ) THIRD_PARTY_MODEL_APP_LABELS = frozenset( @@ -38,7 +39,8 @@ integrations.DestinationMachineScope integrations.DestinationMachineTypeScope integrations.EmailTemplate integrations.MachineTypeEmailTemplate integrations.NotificationDestination inventory.Category inventory.InventoryAsset - inventory.InventoryProduct machines.Machine machines.MachineConsumable + inventory.InventoryProduct machines.CertificationGrant machines.CertificationType + machines.Machine machines.MachineConsumable machines.MachineConsumableAdjustment machines.MachineConsumablePool machines.MachineDocument machines.MachineErrorLog machines.MachineServiceRequest machines.MachineType machines.MachineUsageEntry @@ -47,12 +49,15 @@ maintenance.MaintenanceLog maintenance.MaintenanceLogDocument maintenance.MaintenanceSchedule makerspaces.Makerspace makerspaces.MakerspaceMembership makerspaces.MakerspaceWaiver - makerspaces.MemberProfile makerspaces.MemberProject makerspaces.MembershipRequest + makerspaces.MemberCard makerspaces.MemberProfile makerspaces.MemberProject + makerspaces.MembershipRequest makerspaces.MembershipPlan makerspaces.MembershipTerm + makerspaces.InvitationRequest notifications.Notification operations.InventoryAdjustment operations.QrPrintBatch operations.QrPrintBatchItem operations.ReportMetricRollup operations.StockTransfer operations.StockTransferLine operations.StocktakeLedgerEntry operations.StocktakeLine operations.StocktakeSession - payments.MakerspacePaymentSettings payments.Payment presence.PresenceSession + payments.MakerspacePaymentSettings payments.ManualSettlement payments.Payment + payments.Refund presence.PresenceSession procurement.ToBuyItem procurement.ToBuyReceipt tenant_migration.ExternalTenantReference warranty.Warranty warranty.WarrantyDocument""".split() @@ -67,6 +72,7 @@ "integrations.EmailNotificationMute": "Source delivery suppression does not control target mail.", "integrations.NotificationPreference": "Target notification defaults are authoritative.", "integrations.NotificationRecipient": "Every explicit recipient is a live disclosure rule.", + "operations.ReportSchedule": "A scheduled report delivery is a live disclosure rule; the target re-creates it.", "integrations.RecipientCategoryScope": "The owning disclosure recipient is dropped.", "integrations.RecipientMachineScope": "The owning disclosure recipient is dropped.", "integrations.RecipientMachineTypeScope": "The owning disclosure recipient is dropped.", diff --git a/backend/apps/tenant_migration/tenant_dump_publication.py b/backend/apps/tenant_migration/tenant_dump_publication.py index 659e8db7..3bafc575 100644 --- a/backend/apps/tenant_migration/tenant_dump_publication.py +++ b/backend/apps/tenant_migration/tenant_dump_publication.py @@ -27,6 +27,7 @@ TenantDumpPublicationRefused, TenantDumpVerificationError, ) +from .money_digest import MoneyDriftRefused, assert_money_unchanged from .tenant_dump_lineage import verify_artifact_lineage from .tenant_dump_outer_artifact import read_outer_manifest from .tenant_dump_staging import delete_owned_root @@ -115,9 +116,19 @@ def publish_tenant_dump(capture_id): else: try: _verify_publication_lineage(capture) + # Pending charges travel in the artifact now, and the capture REOPENED + # the source -- so between the freeze and this moment the space may have + # settled a captured debt or raised a new one. Publishing then would hand + # out an artifact that bills a member for money already taken. This is + # the last source-side moment before the bytes become fetchable, and it + # runs under the same custody lock. + assert_money_unchanged(capture) except TenantDumpPublicationRefused as exc: refusal = str(exc) _refuse_locked(capture, refusal, "lineage_mismatch") + except MoneyDriftRefused as exc: + refusal = str(exc) + _refuse_locked(capture, refusal, "money_drift") else: if not capture.unpublished_object_key or not capture.artifact_sha256: raise TenantDumpBuildError( diff --git a/backend/apps/tenant_migration/tenant_dump_source_projection.py b/backend/apps/tenant_migration/tenant_dump_source_projection.py index 27d88939..4433d6d3 100644 --- a/backend/apps/tenant_migration/tenant_dump_source_projection.py +++ b/backend/apps/tenant_migration/tenant_dump_source_projection.py @@ -108,10 +108,6 @@ def _source_row_allowed(label, row, makerspace_id, cross_tenant): return row["state"] not in {"requested", "invited"} if label == "apiclients.ApiKeyRequest": return row["status"] != "pending" - if label == "payments.Payment" and row["status"] == "pending": - raise TenantDumpProjectionError( - f"pending Payment {row['id']} is an unresolved obligation" - ) if label == "integrations.NotificationDestination": return row["channel"] == "telegram" if label == "operations.StockTransfer": diff --git a/backend/apps/tenant_migration/unique_values.py b/backend/apps/tenant_migration/unique_values.py index 1691bcfb..02bc0621 100644 --- a/backend/apps/tenant_migration/unique_values.py +++ b/backend/apps/tenant_migration/unique_values.py @@ -205,6 +205,10 @@ def _policy(disposition, reason, *, field=None, generator=None): field="object_key", generator=_maintenance_document_key, ), + ("makerspaces.MemberCard", "field:membership"): _policy( + REMAP, + "One card per membership: the remapped membership reference keeps the pair unique.", + ), ("makerspaces.Makerspace", "field:anonymous_requester"): _policy( NULL, "The anonymous-request principal is a per-deployment system row, not a person: " @@ -254,6 +258,19 @@ def _policy(disposition, reason, *, field=None, generator=None): ): _policy( NULL, "The omitted external_payment_id makes this target constraint inert." ), + ( + "payments.Refund", + "refund_external_once_per_provider", + ): _policy( + NULL, "The omitted external_refund_id makes this target constraint inert." + ), + # `amends` is a OneToOne, so it is deployment-globally unique: only one correction + # may replace a given receipt, which is what stops an amendment chain branching. + # REMAP, not PRESERVE: it is a reference to another settlement row, so it follows + # that row's imported identity. There is nothing to regenerate on a collision. + ("payments.ManualSettlement", "field:amends"): _policy( + REMAP, "The one-to-one amendment reference is remapped to the imported receipt." + ), ("procurement.ToBuyReceipt", "field:object_key"): _policy( PRESERVE, "Keep the archived receipt key unless it collides on the target.", diff --git a/backend/config/celery.py b/backend/config/celery.py index 691493b7..236da1df 100644 --- a/backend/config/celery.py +++ b/backend/config/celery.py @@ -9,3 +9,7 @@ ) app.config_from_object("django.conf:settings", namespace="CELERY") app.autodiscover_tasks() + +# Registers the before_task_publish / task_prerun handlers that carry the request id +# from the web process into the worker. Import for its side effect; nothing to call. +import config.celery_signals # noqa: E402,F401 diff --git a/backend/config/celery_signals.py b/backend/config/celery_signals.py new file mode 100644 index 00000000..e3d0a2b5 --- /dev/null +++ b/backend/config/celery_signals.py @@ -0,0 +1,50 @@ +"""Carry the request id across the Celery boundary. + +``before_task_publish`` runs in the web process with the request's contextvar still bound, +so the id is copied into the message headers. ``task_prerun`` runs in the worker, where +Celery exposes custom headers as attributes on ``task.request``; the id is rebound there so +every log line the task emits shares it with the request that enqueued the work. + +Eager execution (``CELERY_TASK_ALWAYS_EAGER``) never publishes, but it also never leaves the +thread, so the contextvar is simply inherited and these handlers are harmless no-ops. +""" +from celery.signals import before_task_publish, task_postrun, task_prerun + +from config.request_id import get_request_id, reset_request_id, set_request_id + +HEADER = "spaceworks_request_id" +_TOKEN_ATTR = "_spaceworks_request_id_token" + + +@before_task_publish.connect +def propagate_request_id(headers=None, **_kwargs): + request_id = get_request_id() + if request_id and headers is not None: + headers.setdefault(HEADER, request_id) + + +def _header_from_task(task): + request = getattr(task, "request", None) + if request is None: + return None + value = getattr(request, HEADER, None) + if value: + return value + raw_headers = getattr(request, "headers", None) or {} + return raw_headers.get(HEADER) if isinstance(raw_headers, dict) else None + + +@task_prerun.connect +def bind_request_id(task=None, **_kwargs): + request_id = _header_from_task(task) + if request_id: + setattr(task.request, _TOKEN_ATTR, set_request_id(request_id)) + + +@task_postrun.connect +def unbind_request_id(task=None, **_kwargs): + request = getattr(task, "request", None) + token = getattr(request, _TOKEN_ATTR, None) if request is not None else None + if token is not None: + reset_request_id(token) + delattr(request, _TOKEN_ATTR) diff --git a/backend/config/log_setup.py b/backend/config/log_setup.py new file mode 100644 index 00000000..4b9cc8b7 --- /dev/null +++ b/backend/config/log_setup.py @@ -0,0 +1,88 @@ +"""Structured logging configuration. + +Production emits one JSON object per line so a log shipper can index by ``request_id``, +``logger`` or any ``extra=`` key without regex; local development keeps the plain +single-line format because a human is reading it. Both carry the request id from +``config.request_id`` so the two formats differ only in shape, never in content. + +Named ``log_setup`` rather than ``logging`` on purpose: a module called ``config.logging`` +is one careless ``sys.path`` entry away from shadowing the standard library. +""" +import json +import logging +from datetime import UTC, datetime + +from config.request_id import get_request_id + +# Attributes every LogRecord carries. Anything else on the record came from ``extra=`` and +# is worth surfacing as its own JSON key. +_STANDARD_RECORD_ATTRS = frozenset( + { + "args", "asctime", "created", "exc_info", "exc_text", "filename", "funcName", + "levelname", "levelno", "lineno", "message", "module", "msecs", "msg", "name", + "pathname", "process", "processName", "relativeCreated", "stack_info", "thread", + "threadName", "taskName", "request_id", + } +) + + +class RequestIdFilter(logging.Filter): + """Stamp the bound request id (or ``-``) on every record so formatters can rely on it.""" + + def filter(self, record): + record.request_id = get_request_id() or "-" + return True + + +class JsonFormatter(logging.Formatter): + def format(self, record): + payload = { + "ts": datetime.fromtimestamp(record.created, UTC).isoformat(timespec="milliseconds"), + "level": record.levelname, + "logger": record.name, + "message": record.getMessage(), + "request_id": getattr(record, "request_id", None) or "-", + } + for key, value in record.__dict__.items(): + if key not in _STANDARD_RECORD_ATTRS and not key.startswith("_"): + payload[key] = value + if record.exc_info: + payload["exception"] = self.formatException(record.exc_info) + if record.stack_info: + payload["stack"] = self.formatStack(record.stack_info) + return json.dumps(payload, default=str, ensure_ascii=False) + + +def build_logging(level: str, *, json_output: bool) -> dict: + formatter = "json" if json_output else "plain" + return { + "version": 1, + "disable_existing_loggers": False, + "filters": { + "request_id": {"()": "config.log_setup.RequestIdFilter"}, + }, + "formatters": { + "json": {"()": "config.log_setup.JsonFormatter"}, + "plain": { + "format": "%(asctime)s %(levelname)s %(name)s [%(request_id)s] %(message)s", + }, + }, + "handlers": { + "console": { + "class": "logging.StreamHandler", + "filters": ["request_id"], + "formatter": formatter, + }, + }, + "root": {"handlers": ["console"], "level": level}, + "loggers": { + # Django's own request/security loggers are noisy at DEBUG; hold them at the + # configured level but never below WARNING for the request logger, which + # otherwise duplicates every 4xx the view already reported. + "django": {"level": level, "propagate": True}, + "django.request": {"level": "WARNING", "propagate": True}, + "django.security": {"level": "WARNING", "propagate": True}, + "celery": {"level": level, "propagate": True}, + "apps": {"level": level, "propagate": True}, + }, + } diff --git a/backend/config/request_id.py b/backend/config/request_id.py new file mode 100644 index 00000000..5d6c217c --- /dev/null +++ b/backend/config/request_id.py @@ -0,0 +1,63 @@ +"""Per-request correlation id. + +One id follows a request through every log line it emits and into every Celery task it +enqueues, so a support question ("what happened when I pressed Issue at 14:02?") can be +answered by one grep instead of by guessing at timestamps. The id is stored in a +``contextvars.ContextVar`` rather than on the request object because the log formatter and +Celery signal handlers have no request to hand. + +The incoming ``X-Request-ID`` header is honoured only when it is short and plain ASCII: a +reverse proxy that already assigns ids should win, but a caller must not be able to inject +newlines or a kilobyte of junk into every log line. +""" +import contextvars +import re +import uuid + +REQUEST_ID_HEADER = "X-Request-ID" +_VALID_REQUEST_ID = re.compile(r"^[A-Za-z0-9_.:-]{1,64}$") + +_request_id: contextvars.ContextVar[str | None] = contextvars.ContextVar( + "spaceworks_request_id", default=None +) + + +def get_request_id() -> str | None: + return _request_id.get() + + +def set_request_id(value: str) -> contextvars.Token: + return _request_id.set(value) + + +def reset_request_id(token: contextvars.Token) -> None: + _request_id.reset(token) + + +def new_request_id() -> str: + return uuid.uuid4().hex + + +def normalize_request_id(candidate: str | None) -> str: + """Return the caller's id when it is safe to log, otherwise mint a fresh one.""" + if candidate and _VALID_REQUEST_ID.match(candidate): + return candidate + return new_request_id() + + +class RequestIdMiddleware: + """Bind a request id for the duration of the request and echo it on the response.""" + + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + request_id = normalize_request_id(request.headers.get(REQUEST_ID_HEADER)) + request.request_id = request_id + token = set_request_id(request_id) + try: + response = self.get_response(request) + finally: + reset_request_id(token) + response[REQUEST_ID_HEADER] = request_id + return response diff --git a/backend/config/settings.py b/backend/config/settings.py index 13f65423..4a226512 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -7,6 +7,7 @@ from corsheaders.defaults import default_headers from django.core.exceptions import ImproperlyConfigured +from config.log_setup import build_logging from config.storage_validation import assert_distinct_storage_buckets BASE_DIR = Path(__file__).resolve().parent.parent @@ -149,6 +150,8 @@ def normalize_platform_domain_suffix(raw): "apps.makerspaces", "apps.organizations", "apps.payments", + # The provider rail's surfaces, separable from the ledger above. + "apps.payments_rail", "apps.presence", "apps.encryption", "apps.apiclients", @@ -193,6 +196,9 @@ def normalize_platform_domain_suffix(raw): "apps.backup.middleware.DeploymentRecoveryGateMiddleware", # Second, so it still wraps every view that could log a calendar-feed bearer token. "apps.events.middleware.CalendarFeedLogRedactionMiddleware", + # Binds the per-request correlation id before any layer below can log. The two gates + # above refuse without logging through it; that is the accepted cost of their position. + "config.request_id.RequestIdMiddleware", "apps.tenant_migration.middleware.SourceMigrationGateMiddleware", "apps.makerspaces.middleware.TenantHostValidationMiddleware", "django.middleware.security.SecurityMiddleware", @@ -232,7 +238,12 @@ def normalize_platform_domain_suffix(raw): WSGI_APPLICATION = "config.wsgi.application" DATABASES = {"default": env.db()} -DATABASES["default"]["CONN_MAX_AGE"] = env.int("CONN_MAX_AGE", default=0) +# Persistent connections by default: gunicorn's worker processes otherwise open and close +# a Postgres connection per request. Transaction-mode poolers (Supabase :6543, PgBouncer) +# hand back a different server connection each time, so deployments on one set +# CONN_MAX_AGE=0 explicitly -- .env.production.example and docs/deploy-production.md do. +DATABASES["default"]["CONN_MAX_AGE"] = env.int("CONN_MAX_AGE", default=60) +DATABASES["default"]["CONN_HEALTH_CHECKS"] = env.bool("CONN_HEALTH_CHECKS", default=True) DATABASES["default"]["DISABLE_SERVER_SIDE_CURSORS"] = env.bool( "DISABLE_SERVER_SIDE_CURSORS", default=False ) @@ -330,6 +341,10 @@ def normalize_platform_domain_suffix(raw): } EVIDENCE_URL_TTL_SECONDS = env.int("EVIDENCE_URL_TTL_SECONDS", default=300) +# Signed download links for scheduled report deliveries; the stored file is swept once +# this window has passed. Longer than an evidence presign because the link lands in an +# inbox or a chat room and is read later, but still bounded and never a public URL. +REPORT_DELIVERY_URL_TTL_SECONDS = env.int("REPORT_DELIVERY_URL_TTL_SECONDS", default=6 * 60 * 60) EVIDENCE_MAX_BYTES = env.int("EVIDENCE_MAX_BYTES", default=10485760) EVIDENCE_ALLOWED_MIME = ["image/jpeg", "image/png", "image/webp"] EVIDENCE_OBJECT_RETENTION_DAYS = env.int( @@ -601,6 +616,10 @@ def cache_config(cache_url): "task": "apps.makerspaces.tasks.refresh_github_contributions_task", "schedule": crontab(hour=4, minute=15), }, + "membership-renewals": { + "task": "apps.makerspaces.tasks_membership.run_membership_renewals_task", + "schedule": crontab(minute=30), + }, "purge-expired-data-exports": { "task": "apps.data_export.tasks.purge_expired_exports_task", "schedule": crontab(hour=3, minute=45), @@ -609,6 +628,10 @@ def cache_config(cache_url): "task": "apps.operations.tasks.finalize_report_rollups_task", "schedule": crontab(hour=1, minute=0), }, + "report-schedules": { + "task": "apps.operations.tasks_report_schedules.run_report_schedules_task", + "schedule": crontab(minute="*/15"), + }, "scheduled-deployment-backup": { "task": "apps.backup.tasks.scheduled_deployment_backup_task", "schedule": crontab(hour=2, minute=0), @@ -840,6 +863,9 @@ def cache_config(cache_url): "THROTTLE_ANONYMOUS_REQUEST_EMAIL", default="3/day", ), + "public_invitation_request": env( + "THROTTLE_PUBLIC_INVITATION_REQUEST", default="10/hour" + ), "print_request_submit": env("THROTTLE_PRINT_REQUEST_SUBMIT", default="10/min"), "public_tool_checkout": env("THROTTLE_PUBLIC_TOOL_CHECKOUT", default="10/min"), "public_tool_return": env("THROTTLE_PUBLIC_TOOL_RETURN", default="10/min"), @@ -1079,3 +1105,44 @@ def cache_config(cache_url): {"name": "Notifications", "description": "Persistent staff inbox notifications."}, ], } + +# --- Observability ----------------------------------------------------------------------- +# JSON log lines in production (one object per line, request_id on every record); the plain +# single-line format when DEBUG, because a person is reading it. LOG_JSON overrides either. +LOG_LEVEL = env("LOG_LEVEL", default="INFO") +LOGGING = build_logging(LOG_LEVEL, json_output=env.bool("LOG_JSON", default=not DEBUG)) + +# Live-update stream (SSE over Redis pub/sub). Defaults to the Celery broker; empty means the +# stream answers 503 and browsers fall back to polling. Bounded stream length so a thread is +# never held forever -- the browser reconnects. +LIVE_REDIS_URL = env("LIVE_REDIS_URL", default="") +LIVE_MAX_STREAM_SECONDS = env.int("LIVE_MAX_STREAM_SECONDS", default=3600) + +# What this deployment is FOR (apps/makerspaces/editions.py): makerspace (default), events, +# bookings or organization. Hides surfaces and public routes; never changes core modules. +SPACEWORKS_EDITION = env("SPACEWORKS_EDITION", default="makerspace").strip().lower() +if SPACEWORKS_EDITION not in ("makerspace", "events", "bookings", "organization"): + raise ImproperlyConfigured( + f"SPACEWORKS_EDITION={SPACEWORKS_EDITION!r} is not an edition " + "(makerspace, events, bookings, organization)." + ) + +# Static bearer token for GET /api/v1/metrics/ (Prometheus text). Unset => the route is 404. +METRICS_TOKEN = env("METRICS_TOKEN", default="") + +# Error tracking is opt-in and only imported when a DSN is configured, so the SDK is never +# on the import path of a deployment that did not ask for it. PII stays off: scoped PII +# fields are encrypted at rest and must not leave the box through an error report. +SENTRY_DSN = env("SENTRY_DSN", default="") +if SENTRY_DSN: + import sentry_sdk + from sentry_sdk.integrations.celery import CeleryIntegration + from sentry_sdk.integrations.django import DjangoIntegration + + sentry_sdk.init( + dsn=SENTRY_DSN, + integrations=[DjangoIntegration(), CeleryIntegration()], + send_default_pii=False, + traces_sample_rate=env.float("SENTRY_TRACES_SAMPLE_RATE", default=0.0), + environment=env("SENTRY_ENVIRONMENT", default="production" if not DEBUG else "development"), + ) diff --git a/backend/config/unfold.py b/backend/config/unfold.py index 2cc256da..e481f1ae 100644 --- a/backend/config/unfold.py +++ b/backend/config/unfold.py @@ -179,8 +179,8 @@ def _managed_item(title, icon, route, app_label=None): _item("Subdomain requests", "dns", "admin:makerspaces_subdomainrequest_changelist"), _item("Platform email", "mail", "admin:integrations_platformemailsettings_changelist"), _item("Software updates", "system_update", "admin:updates_platformupdatesettings_changelist", app_label="updates"), - _item("Payments", "payments", "admin:payments_makerspacepaymentsettings_changelist", app_label="payments"), - _managed_item("Stripe Connect", "account_balance", "admin:payments_platformstripeconnectsettings_changelist", app_label="payments"), + _item("Payments", "payments", "admin:payments_makerspacepaymentsettings_changelist", app_label="payments_rail"), + _managed_item("Stripe Connect", "account_balance", "admin:payments_platformstripeconnectsettings_changelist", app_label="payments_rail"), _item("Email templates", "mail", "admin:integrations_emailtemplate_changelist"), _item("Email logs", "mark_email_read", "admin:integrations_emaillog_changelist"), _item("Email mutes", "notifications_off", "admin:integrations_emailnotificationmute_changelist"), diff --git a/backend/config/urls.py b/backend/config/urls.py index 7e164630..4df476f9 100644 --- a/backend/config/urls.py +++ b/backend/config/urls.py @@ -12,7 +12,7 @@ from apps.backup.views_archives import BackupArchiveDownloadView from apps.backup.views_recovery import RecoveryStateView from apps.payments.views import RazorpayWebhookView, StripeWebhookView -from apps.payments.views_connect import ( +from apps.payments_rail.views_connect import ( StripeConnectCallbackView, StripeConnectWebhookView, ) @@ -86,7 +86,7 @@ def docs_root(_request): # on the Stripe webhook either. An endpoint that accepts and verifies a charge for an # app whose console is gone would settle money nobody can see or reconcile. *separable_paths( - "payments", + "payments_rail", path( "api/v1/webhooks/stripe/connect", StripeConnectWebhookView.as_view(), @@ -116,7 +116,10 @@ def docs_root(_request): *separable("events", "api/v1/public/", "apps.events.urls_public"), *separable("bookings", "api/v1/public/", "apps.bookings.urls_public"), *separable("presence", "api/v1/public/", "apps.presence.urls"), - *separable("payments", "api/v1/", "apps.payments.urls"), + # The LEDGER is unconditional: money owed, its receipts and its reconciliation + # outlive any decision about shipping a provider integration. + path("api/v1/", include("apps.payments.urls")), + *separable("payments_rail", "api/v1/", "apps.payments_rail.urls"), path( "api/v1/internal/tls-check", TlsCheckView.as_view(), @@ -133,7 +136,8 @@ def docs_root(_request): # while /api/v1/public/... is reachable directly (and via "v1:public-inventory"). path("api/v1/", include(("apps.inventory.urls", "inventory"), namespace="v1")), path("api/v1/", include("apps.makerspaces.urls")), - *separable("payments", "api/v1/member/", "apps.payments.urls_member"), + path("api/v1/member/", include("apps.payments.urls_member")), + *separable("payments_rail", "api/v1/member/", "apps.payments_rail.urls_member"), path("api/v1/", include("apps.hardware_requests.urls")), path("api/v1/auth/", include("apps.accounts.urls")), # staff auth surface path("api/v1/admin/", include("apps.admin_api.urls")), diff --git a/backend/requirements.txt b/backend/requirements.txt index c06d7eac..f4f8aa1f 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -32,3 +32,5 @@ celery[redis]>=5.4,<6 redis>=5.0,<7 dnspython>=2.7,<3 stripe==15.3.1 +# Imported only when SENTRY_DSN is set (config/settings.py); pinned so pip-audit tracks it. +sentry-sdk[django,celery]>=2.30,<3 diff --git a/backend/tests/accounts/test_claim_acceptance_p7.py b/backend/tests/accounts/test_claim_acceptance_p7.py index b463b619..64c62374 100644 --- a/backend/tests/accounts/test_claim_acceptance_p7.py +++ b/backend/tests/accounts/test_claim_acceptance_p7.py @@ -16,6 +16,7 @@ from apps.machines.models import Machine, MachineServiceRequest, MachineType from apps.payments.models import MakerspacePaymentSettings, Payment from apps.presence.models import PresenceSession +from tests.return_helpers import enable_online_rail from tests.accounts.claim_helpers_p7 import redeemed_claim, start_claim_presence pytestmark = pytest.mark.django_db @@ -224,6 +225,9 @@ def test_claim_submits_machine_service_and_reads_it_in_member_activity(): def test_claim_reaches_member_area_and_checks_out_a_locally_owned_charge(monkeypatch): harness = redeemed_claim("accept-payment") + # Checking out needs a live rail, not just credentials: the member checkout endpoint + # now refuses to mint a link for a charge whose space has no online payment enabled. + enable_online_rail(harness.space, "membership") settings = MakerspacePaymentSettings(makerspace=harness.space) settings.set_stripe_secret_key("sk_test_claim") settings.set_stripe_webhook_secret("whsec_claim") @@ -238,7 +242,7 @@ def test_claim_reaches_member_area_and_checks_out_a_locally_owned_charge(monkeyp created_by=harness.staff, ) monkeypatch.setattr( - "apps.payments.services.stripe_client.create_checkout_session", + "apps.payments.services_checkout.stripe_client.create_checkout_session", lambda *_args, **_kwargs: { "id": "cs_claim_acceptance", "url": "https://checkout.stripe.test/claim", diff --git a/backend/tests/accounts/test_claim_routes.py b/backend/tests/accounts/test_claim_routes.py index f6c2521a..c92516f7 100644 --- a/backend/tests/accounts/test_claim_routes.py +++ b/backend/tests/accounts/test_claim_routes.py @@ -45,8 +45,10 @@ def test_current_all_active_tree_has_a_complete_claim_matrix(settings): # detail, public events, invitation redeem) + seven event artifact/post-event # routes (calendar, calendar feed, feedback, certificate download, check-in # station). The count is asserted so that adding a claim-reachable route is a - # visible decision here, not only inside the matrix. - assert len(validate_claim_route_matrix()) == 85 + # visible decision here, not only inside the matrix. Plus the three member ID card + # routes (card, photo, preview) from forward plan phase 5 — all Refused. Plus the + # public invitation-request route from forward plan phase 6 (Refused: 88 -> 89). + assert len(validate_claim_route_matrix()) == 89 def test_unclassified_runtime_lookup_fails_closed_and_middleware_stays_out(): diff --git a/backend/tests/accounts/test_claim_session_contract_p7.py b/backend/tests/accounts/test_claim_session_contract_p7.py index 5fe8badf..55c2706c 100644 --- a/backend/tests/accounts/test_claim_session_contract_p7.py +++ b/backend/tests/accounts/test_claim_session_contract_p7.py @@ -85,7 +85,11 @@ def test_refresh_rotation_preserves_one_absolute_expiry_forever(): "member-event-calendar-feed": {"makerspace_id": "claim"}, "member-event-feedback": {"makerspace_id": "claim", "pk": 1}, "member-event-certificate-download": {"makerspace_id": "claim", "pk": 1}, + "member-card": {"makerspace_id": "claim"}, + "member-card-photo": {"makerspace_id": "claim"}, + "member-card-preview": {"makerspace_id": "claim"}, "public-membership-request": {"makerspace_slug": "claim"}, + "public-invitation-request": {"makerspace_slug": "claim"}, } REFUSED_KEYS = sorted( (name, method) diff --git a/backend/tests/accounts/test_rbac_dual_read.py b/backend/tests/accounts/test_rbac_dual_read.py index 10ef87b7..af9da35e 100644 --- a/backend/tests/accounts/test_rbac_dual_read.py +++ b/backend/tests/accounts/test_rbac_dual_read.py @@ -50,8 +50,9 @@ def custom_role(makerspace, slug, actions): def test_action_registries_contain_the_frozen_action_vocabulary(): - # 19 since collect_service_request split job handover out of manage_machines. - assert len(rbac.ALL_ACTIONS) == 19 + # 19 since collect_service_request split job handover out of manage_machines; + # 21 with the member-card pair (scan_member_cards, manage_member_cards). + assert len(rbac.ALL_ACTIONS) == 21 assert rbac.ROLE_FORBIDDEN_ACTIONS == { rbac.Action.TRANSFER_STOCK, rbac.Action.MANAGE_STAFF, diff --git a/backend/tests/encryption/test_leak_sweep.py b/backend/tests/encryption/test_leak_sweep.py index 1470dcea..f9aebf01 100644 --- a/backend/tests/encryption/test_leak_sweep.py +++ b/backend/tests/encryption/test_leak_sweep.py @@ -23,7 +23,7 @@ from apps.hardware_requests.models import HardwareRequest from apps.integrations.admin_email_logs import EmailLogAdmin from apps.integrations.models import EmailLog -from apps.makerspaces.models import Makerspace +from apps.makerspaces.models import InvitationRequest, Makerspace, MemberCard from apps.machines.models import ( Machine, MachineServiceRequest, @@ -72,6 +72,8 @@ def _objects(): "bookings.Booking": Booking.objects.create(space=bookable, name="Base", email=f"booking-{stamp}@example.test", phone="1", starts_at=now + timedelta(days=1), ends_at=now + timedelta(days=1, hours=1)), "machines.MachineServiceRequest": MachineServiceRequest.objects.create(bucket=service_bucket, requester=user, title="Sweep service"), "machines.MachineUsageEntry": MachineUsageEntry.objects.create(machine=machine, logged_by=user), + "makerspaces.MemberCard": MemberCard.objects.create(makerspace=space, card_number=1, printed_name="Base"), + "makerspaces.InvitationRequest": InvitationRequest.objects.create(makerspace=space, name="Base", email=f"invite-{stamp}@example.test", phone="1"), "integrations.EmailLog": EmailLog.objects.create(makerspace=space, to_email=f"mail-{stamp}@example.test", subject="Base", text_body="", html_body=""), } diff --git a/backend/tests/encryption/test_mappers.py b/backend/tests/encryption/test_mappers.py index e05bfb84..7916154d 100644 --- a/backend/tests/encryption/test_mappers.py +++ b/backend/tests/encryption/test_mappers.py @@ -44,9 +44,11 @@ def test_registry_matches_the_post_b7c_source_and_secondary_allowlists(): # 18 at B7c, plus the two post-event source fields: # EventFeedbackResponse.answers_snapshot and EventAttendanceCertificate.recipient_name. # The counts are asserted so that encrypting a new column is a visible decision here. - assert len(SOURCE_FIELDS) == 20 + # 21 with makerspaces.MemberCard.printed_name (forward plan phase 5). + # 21 -> 24 with InvitationRequest name/email/phone (forward plan phase 6). + assert len(SOURCE_FIELDS) == 24 assert len(SECONDARY_FIELDS) == 4 - assert len(ALL_FIELDS) == 24 + assert len(ALL_FIELDS) == 28 assert {item.model_label for item in SECONDARY_FIELDS} == {"integrations.EmailLog"} diff --git a/backend/tests/events/test_collaborative_payments_p14.py b/backend/tests/events/test_collaborative_payments_p14.py index dfd9c077..cffd2c15 100644 --- a/backend/tests/events/test_collaborative_payments_p14.py +++ b/backend/tests/events/test_collaborative_payments_p14.py @@ -174,7 +174,7 @@ def test_the_real_creation_path_stamps_the_routing(): event = make_event(host, is_public=False) event.payment_amount = Decimal("8.00") event.save(update_fields=["payment_amount"]) - host.enabled_features = ["payments.enabled", "payments.events"] + host.enabled_features = ["payments.enabled", "payments.events", "charges.enabled", "charges.events"] host.save(update_fields=["enabled_features", "updated_at"]) settings = configured_settings(host) settings.default_currency = "usd" @@ -253,7 +253,7 @@ def test_a_waitlisted_registration_promoted_after_a_purge_is_still_payable(): event.capacity = 1 event.payment_amount = Decimal("8.00") event.save(update_fields=["capacity", "payment_amount"]) - host.enabled_features = ["payments.enabled", "payments.events"] + host.enabled_features = ["payments.enabled", "payments.events", "charges.enabled", "charges.events"] host.save(update_fields=["enabled_features", "updated_at"]) settings = configured_settings(host) settings.default_currency = "usd" diff --git a/backend/tests/events/test_series_organizer_admin.py b/backend/tests/events/test_series_organizer_admin.py new file mode 100644 index 00000000..7fc09567 --- /dev/null +++ b/backend/tests/events/test_series_organizer_admin.py @@ -0,0 +1,134 @@ +"""The superadmin series-organizer admin must route through the series service. + +Before phase 0 the admin wrote ``EventOrganizer`` rows directly, skipping the events module +lock, the series row lock and the authority check the service applies. +""" +from datetime import time, timedelta + +import pytest +from django.contrib.admin.sites import AdminSite +from django.test import RequestFactory +from django.utils import timezone +from rest_framework.exceptions import ValidationError + +from apps.accounts.models import User +from apps.audit.models import AuditLog +from apps.events import services_series, services_series_organizers +from apps.events.admin import EventSeriesOrganizerAdmin +from apps.events.models import EventOrganizer, EventSeriesOrganizer +from apps.makerspaces.models import Makerspace, MakerspaceMembership +from apps.organizations.models import Organization +from tests.module_helpers import disable_module + +pytestmark = pytest.mark.django_db + + +def _space(): + return Makerspace.objects.create(name="Series Space", slug="series-space") + + +def _manager(space): + user = User.objects.create_user( + username="series-manager", role=User.Role.SPACE_MANAGER, + access_status=User.AccessStatus.ACTIVE, + ) + MakerspaceMembership.objects.create( + user=user, makerspace=space, role=MakerspaceMembership.Role.SPACE_MANAGER + ) + return user + + +def _superadmin(): + return User.objects.create_user( + username="root", role=User.Role.SUPERADMIN, is_superuser=True, is_staff=True, + access_status=User.AccessStatus.ACTIVE, + ) + + +def _series(space, actor): + series, _occurrences = services_series.create_series( + makerspace=space, actor=actor, title="Weekly build night", + recurrence_timezone="UTC", + dtstart_local_date=(timezone.now() + timedelta(days=1)).date(), + dtstart_local_time=time(18), recurrence_rule="FREQ=DAILY", duration_minutes=90, + ) + return series + + +def _admin_request(user): + request = RequestFactory().post("/control/events/eventseriesorganizer/add/") + request.user = user + return request + + +def test_admin_add_projects_to_every_occurrence_through_the_service(): + space = _space() + series = _series(space, _manager(space)) + org = Organization.objects.create(name="Partner Org", slug="partner-org") + root = _superadmin() + admin = EventSeriesOrganizerAdmin(EventSeriesOrganizer, AdminSite()) + unsaved = EventSeriesOrganizer(series=series, organization=org) + + admin.save_model(_admin_request(root), unsaved, form=None, change=False) + + row = EventSeriesOrganizer.objects.get(series=series, organization=org) + assert row.created_by == root + occurrences = list(series.occurrences.all()) + assert occurrences, "create_series materializes at least one occurrence" + projected = EventOrganizer.objects.filter(source_series_organizer=row) + assert projected.count() == len(occurrences) + assert set(projected.values_list("event_id", flat=True)) == {e.pk for e in occurrences} + log = AuditLog.objects.get(action="event.series_organizer_created") + assert log.actor == root and log.makerspace == space + assert log.meta["series_id"] == series.pk + assert log.meta["organization_slug"] == "partner-org" + + +def test_admin_delete_removes_projection_and_audits(): + space = _space() + series = _series(space, _manager(space)) + org = Organization.objects.create(name="Partner Org", slug="partner-org") + root = _superadmin() + row = services_series_organizers.add_series_organizer(series, actor=root, organization=org) + assert EventOrganizer.objects.filter(source_series_organizer=row).exists() + + admin = EventSeriesOrganizerAdmin(EventSeriesOrganizer, AdminSite()) + admin.delete_model(_admin_request(root), row) + + assert not EventSeriesOrganizer.objects.filter(pk=row.pk).exists() + assert not EventOrganizer.objects.filter(organization=org).exists() + assert AuditLog.objects.filter(action="event.series_organizer_deleted").count() == 1 + + +def test_service_refuses_when_the_events_module_is_off(): + space = _space() + manager = _manager(space) + series = _series(space, manager) + org = Organization.objects.create(name="Partner Org", slug="partner-org") + disable_module(space, "events") + with pytest.raises(ValidationError): + services_series_organizers.add_series_organizer(series, actor=_superadmin(), organization=org) + assert not EventSeriesOrganizer.objects.exists() + + +def test_service_refuses_an_actor_without_series_authority(): + space = _space() + series = _series(space, _manager(space)) + org = Organization.objects.create(name="Partner Org", slug="partner-org") + outsider = User.objects.create_user( + username="outsider", role=User.Role.REQUESTER, access_status=User.AccessStatus.ACTIVE + ) + from django.core.exceptions import PermissionDenied + + with pytest.raises(PermissionDenied): + services_series_organizers.add_series_organizer(series, actor=outsider, organization=org) + + +def test_admin_no_longer_touches_organizer_rows_directly(): + import inspect + + from apps.events import admin as events_admin + + source = inspect.getsource(events_admin.EventSeriesOrganizerAdmin) + assert "EventOrganizer.objects" not in source + assert "audit.record" not in source diff --git a/backend/tests/hardware_requests/__init__.py b/backend/tests/hardware_requests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/tests/hardware_requests/test_loan_payments.py b/backend/tests/hardware_requests/test_loan_payments.py new file mode 100644 index 00000000..12abb5d2 --- /dev/null +++ b/backend/tests/hardware_requests/test_loan_payments.py @@ -0,0 +1,272 @@ +"""`payments.loans`: deposits raised by the workflow at issue, late fees at close.""" + +import uuid +from datetime import timedelta +from decimal import Decimal + +import pytest +from django.utils import timezone + +from apps.audit.models import AuditLog +from apps.boxes.models import BoxScan +from apps.evidence.storage import EvidenceValidationResult +from apps.hardware_requests import loan_payments, workflow +from apps.hardware_requests.models import HardwareRequest +from apps.payments.models import MakerspacePaymentSettings, Payment +from apps.payments.subjects import resolve_subject_labels, subject_label +from tests.payments.test_models import configured_settings +from tests.return_helpers import ( + authenticated_client, + make_accepted_request, + make_box, + make_issue_evidence, + make_issued_request, + make_member, + make_product, + make_return_evidence, + make_space, + return_payload, +) + +pytestmark = pytest.mark.django_db + + +@pytest.fixture(autouse=True) +def quiet_storage_and_stripe(monkeypatch): + monkeypatch.setattr( + "apps.evidence.storage.finalize_upload", + lambda evidence, max_bytes: EvidenceValidationResult(size=123, content_type="image/png"), + ) + # Checkout creation is post-commit and best-effort; keep the test off the network. + monkeypatch.setattr("apps.payments.services_checkout.create_checkout_url", lambda *args, **kwargs: "") + + +def loan_space(slug, *, feature_on=True, **overrides): + makerspace = make_space(slug) + staff = make_member(f"{slug}-staff", makerspace) + makerspace.enabled_features = ["payments.enabled", "charges.enabled"] + ( + ["payments.loans", "charges.loans"] if feature_on else [] + ) + makerspace.save(update_fields=["enabled_features", "updated_at"]) + settings_row = configured_settings(makerspace) + for name, value in overrides.items(): + setattr(settings_row, name, value) + settings_row.save() + return makerspace, staff + + +def accepted_loan(makerspace, staff, product=None, quantity=1): + product = product or make_product(makerspace) + request = make_accepted_request(makerspace, product, quantity) + product.available_quantity -= quantity + product.reserved_quantity += quantity + product.save(update_fields=["available_quantity", "reserved_quantity", "updated_at"]) + box = make_box(makerspace) + request.assigned_box = box + request.save(update_fields=["assigned_box", "updated_at"]) + BoxScan.objects.create( + makerspace=makerspace, box=box, request=request, actor=staff, context=BoxScan.Context.ISSUE + ) + return request + + +def issue(staff, request, django_capture_on_commit_callbacks, evidence_id=None): + evidence_id = evidence_id or make_issue_evidence(request.makerspace, staff).pk + with django_capture_on_commit_callbacks(execute=True): + return workflow.issue_request(staff, request, evidence_id, "Handed out.") + + +def close(staff, request, django_capture_on_commit_callbacks): + payload = return_payload(request, make_return_evidence(request.makerspace, staff)) + with django_capture_on_commit_callbacks(execute=True): + return workflow.return_items( + staff, request, payload["evidence_id"], payload["remark"], + payload["box_code"], payload["resolutions"], + ) + + +def loan_payment(request, subject_type): + return Payment.objects.filter( + makerspace=request.makerspace, subject_type=subject_type, subject_id=request.pk + ).first() + + +def test_feature_off_raises_nothing(django_capture_on_commit_callbacks): + makerspace, staff = loan_space( + "loans-off", feature_on=False, + loan_deposit_mode=MakerspacePaymentSettings.LoanDepositMode.FIXED, + loan_deposit_amount=Decimal("5.00"), + ) + request = accepted_loan(makerspace, staff) + + issued = issue(staff, request, django_capture_on_commit_callbacks) + + assert issued.status == HardwareRequest.Status.ISSUED + assert not Payment.objects.filter(makerspace=makerspace).exists() + assert not AuditLog.objects.filter(action__startswith="loan.").exists() + + +def test_deposit_is_raised_on_issue_with_a_receipt_label(django_capture_on_commit_callbacks): + makerspace, staff = loan_space( + "loans-deposit", + loan_deposit_mode=MakerspacePaymentSettings.LoanDepositMode.FIXED, + loan_deposit_amount=Decimal("5.00"), + ) + request = accepted_loan(makerspace, staff) + + issue(staff, request, django_capture_on_commit_callbacks) + + deposit = loan_payment(request, Payment.SubjectType.LOAN_DEPOSIT) + assert (deposit.status, deposit.amount, deposit.currency) == (Payment.Status.PENDING, Decimal("5.00"), "usd") + assert deposit.member_id == request.requester_id + assert subject_label(deposit, resolve_subject_labels([deposit])) == f"Loan deposit - request #{request.pk}" + entry = AuditLog.objects.get(action="loan.deposit_raised") + assert entry.meta == {"request_id": request.pk, "payment_id": deposit.pk} + + listed = authenticated_client(staff).get(f"/api/v1/admin/makerspace/{makerspace.pk}/payments") + assert listed.status_code == 200 + row = next(item for item in listed.data if item["id"] == deposit.pk) + assert row["subject_type"] == "loan_deposit" + assert row["subject_label"] == f"Loan deposit - request #{request.pk}" + assert Decimal(row["refunded_amount"]) == Decimal("0.00") and row["refunds"] == [] + + +def test_per_product_deposit_sums_accepted_units(django_capture_on_commit_callbacks): + makerspace, staff = loan_space( + "loans-per-product", + loan_deposit_mode=MakerspacePaymentSettings.LoanDepositMode.PER_PRODUCT, + ) + product = make_product(makerspace, deposit_amount=Decimal("2.50")) + request = accepted_loan(makerspace, staff, product=product, quantity=2) + + issue(staff, request, django_capture_on_commit_callbacks) + + assert loan_payment(request, Payment.SubjectType.LOAN_DEPOSIT).amount == Decimal("5.00") + + +def test_blocking_deposit_refuses_issue_before_evidence_is_checked(django_capture_on_commit_callbacks): + makerspace, staff = loan_space( + "loans-blocking", + loan_deposit_mode=MakerspacePaymentSettings.LoanDepositMode.FIXED, + loan_deposit_amount=Decimal("5.00"), + loan_deposit_blocks_issue=True, + ) + request = accepted_loan(makerspace, staff) + + # Evidence id 999999 does not exist: if evidence were validated first this would be + # a 400 validation error. The payment gate answers first, with a typed conflict. + response = authenticated_client(staff).post( + f"/api/v1/admin/requests/{request.pk}/issue", + {"evidence_id": 999999, "remark": "x"}, + format="json", + ) + assert response.status_code == 409 + assert response.data["code"] == "deposit_required" + deposit = loan_payment(request, Payment.SubjectType.LOAN_DEPOSIT) + assert deposit.status == Payment.Status.PENDING + request.refresh_from_db() + assert request.status == HardwareRequest.Status.ACCEPTED + + # A second attempt does not raise a second deposit; settling it opens the gate. + with pytest.raises(workflow.DepositRequired): + workflow.issue_request(staff, request, 999999, "x") + assert Payment.objects.filter(subject_id=request.pk).count() == 1 + Payment.objects.filter(pk=deposit.pk).update(status=Payment.Status.PAID_OFFLINE) + + issued = issue(staff, request, django_capture_on_commit_callbacks) + assert issued.status == HardwareRequest.Status.ISSUED + assert Payment.objects.filter(subject_id=request.pk).count() == 1 + + +def test_late_fee_is_capped_computed_once_and_respects_grace(django_capture_on_commit_callbacks): + makerspace, staff = loan_space( + "loans-late-fee", + loan_late_fee_per_day=Decimal("2.00"), + loan_late_fee_cap=Decimal("15.00"), + loan_grace_days=1, + ) + product = make_product(makerspace) + request = make_issued_request(makerspace, staff, [(product, 1)]) + request.return_due_at = timezone.now() - timedelta(days=10) + request.save(update_fields=["return_due_at"]) + + closed = close(staff, request, django_capture_on_commit_callbacks) + + assert closed.status == HardwareRequest.Status.RETURNED + fee = loan_payment(request, Payment.SubjectType.LOAN_LATE_FEE) + assert fee.amount == Decimal("15.00") # 9 days late x 2.00 = 18.00, capped + assert fee.status == Payment.Status.PENDING + assert AuditLog.objects.get(action="loan.late_fee_raised").meta == { + "request_id": request.pk, "payment_id": fee.pk, + } + + loan_payments.on_request_closed(request.pk, staff, now=timezone.now() + timedelta(days=30)) + fee.refresh_from_db() + assert fee.amount == Decimal("15.00") + assert Payment.objects.filter(subject_id=request.pk, subject_type="loan_late_fee").count() == 1 + + on_time = make_issued_request(makerspace, staff, [(product, 1)]) + on_time.return_due_at = timezone.now() - timedelta(hours=12) # inside the grace day + on_time.save(update_fields=["return_due_at"]) + close(staff, on_time, django_capture_on_commit_callbacks) + assert loan_payment(on_time, Payment.SubjectType.LOAN_LATE_FEE) is None + + +def test_an_uncollected_deposit_is_cancelled_when_the_loan_closes(django_capture_on_commit_callbacks): + makerspace, staff = loan_space( + "loans-release", + loan_deposit_mode=MakerspacePaymentSettings.LoanDepositMode.FIXED, + loan_deposit_amount=Decimal("5.00"), + ) + request = make_issued_request(makerspace, staff, [(make_product(makerspace), 1)]) + deposit = loan_payments.raise_deposit(request.pk, staff) + assert deposit.status == Payment.Status.PENDING + + close(staff, request, django_capture_on_commit_callbacks) + + deposit.refresh_from_db() + assert deposit.status == Payment.Status.CANCELED + assert AuditLog.objects.filter(action="payment.canceled").count() == 1 + + +def test_a_deposit_can_be_waived_by_handover_staff(django_capture_on_commit_callbacks): + makerspace, staff = loan_space( + "loans-waive", + loan_deposit_mode=MakerspacePaymentSettings.LoanDepositMode.FIXED, + loan_deposit_amount=Decimal("5.00"), + ) + request = accepted_loan(makerspace, staff) + issue(staff, request, django_capture_on_commit_callbacks) + deposit = loan_payment(request, Payment.SubjectType.LOAN_DEPOSIT) + + response = authenticated_client(staff).post( + f"/api/v1/admin/makerspace/{makerspace.pk}/payments/{deposit.pk}/waive" + ) + + assert response.status_code == 200 + assert response.data["status"] == Payment.Status.WAIVED + assert AuditLog.objects.filter(action="payment.waived").count() == 1 + + +def test_payment_failures_never_block_issue_or_return(monkeypatch, django_capture_on_commit_callbacks): + makerspace, staff = loan_space( + "loans-never-block", + loan_deposit_mode=MakerspacePaymentSettings.LoanDepositMode.FIXED, + loan_deposit_amount=Decimal("5.00"), + loan_deposit_blocks_issue=True, + loan_late_fee_per_day=Decimal("1.00"), + ) + monkeypatch.setattr( + loan_payments, "create_payment", + lambda **kwargs: (_ for _ in ()).throw(RuntimeError("provider exploded")), + ) + request = accepted_loan(makerspace, staff) + + issued = issue(staff, request, django_capture_on_commit_callbacks) + assert issued.status == HardwareRequest.Status.ISSUED + + issued.return_due_at = timezone.now() - timedelta(days=3) + issued.save(update_fields=["return_due_at"]) + closed = close(staff, issued, django_capture_on_commit_callbacks) + assert closed.status == HardwareRequest.Status.RETURNED + assert not Payment.objects.filter(makerspace=makerspace).exists() diff --git a/backend/tests/machines/certification_helpers.py b/backend/tests/machines/certification_helpers.py new file mode 100644 index 00000000..7a9dea1c --- /dev/null +++ b/backend/tests/machines/certification_helpers.py @@ -0,0 +1,111 @@ +"""Shared fixtures for the certification-gating tests. + +Split out so `test_certifications.py` (the gate) and `test_certifications_api.py` (the +staff surfaces) stay inside the file-size ceiling while building identical worlds. +""" + +from datetime import timedelta +from uuid import uuid4 + +from django.utils import timezone + +from apps.accounts.models import User +from apps.accounts.rbac import Action +from apps.bookings.models import BookableSpace +from apps.bookings.services_bookings import create_booking +from apps.machines import service_workflow_actions as service_workflow +from apps.machines.models import CertificationType, MachineType, RoleMachineTypeScope +from apps.makerspaces.models import Makerspace, MakerspaceMembership, MakerspaceRole +from tests.return_helpers import make_user + + +def _space(slug, *, modules=("machines", "machine_service", "bookings"), certifications=True): + space = Makerspace.objects.create(name=slug, slug=slug) + space.enabled_modules = sorted(set(space.enabled_modules) | set(modules)) + space.enabled_features = sorted( + set(space.enabled_features or []) + | ({"machines.certifications"} if certifications else set()) + ) + space.save(update_fields=["enabled_modules", "enabled_features"]) + return space + + +def _member(space, username): + """A plain member: a custom role granting nothing, so no authority masks the gate.""" + user = make_user(username, access_status=User.AccessStatus.ACTIVE) + # Member bookings copy the contact phone from the account, and the booking model + # requires one. + user.phone = "123" + user.save(update_fields=["phone"]) + role = MakerspaceRole.objects.create( + makerspace=space, name=f"{username}-member", slug=f"{username}-member", + granted_actions=[], + ) + membership = MakerspaceMembership.objects.create( + user=user, + makerspace=space, + role=MakerspaceMembership.Role.CUSTOM, + assigned_role=role, + ) + return user, membership + + +def _manager(space, username, machine_type=None, actions=(Action.MANAGE_MACHINES,)): + """A custom-role actor holding MANAGE_MACHINES, optionally linked to one type.""" + user = make_user(username, access_status=User.AccessStatus.ACTIVE) + role = MakerspaceRole.objects.create( + makerspace=space, name=username, slug=username, granted_actions=list(actions) + ) + MakerspaceMembership.objects.create( + user=user, + makerspace=space, + role=MakerspaceMembership.Role.CUSTOM, + assigned_role=role, + ) + if machine_type is not None: + RoleMachineTypeScope.objects.create(role=role, machine_type=machine_type) + return user, role + + +def _machine_type(space, label="Laser"): + return MachineType.objects.create( + makerspace=space, slug=f"cert-{uuid4().hex[:8]}", name=label + ) + + +def _certification(space, machine_type, **kwargs): + return CertificationType.objects.create( + makerspace=space, + machine_type=machine_type, + name=kwargs.pop("name", "Laser induction"), + is_required_for_service=kwargs.pop("is_required_for_service", True), + is_required_for_booking=kwargs.pop("is_required_for_booking", True), + **kwargs, + ) + + +def _bookable(space, machine_type=None): + return BookableSpace.objects.create( + makerspace=space, + name="Laser bench", + machine_type=machine_type, + approval_mode=BookableSpace.ApprovalMode.INSTANT, + ) + + +def _book(space_row, member, **kwargs): + start = timezone.now() + timedelta(days=1) + return create_booking( + space_row, starts_at=start, ends_at=start + timedelta(hours=1), + member=member, **kwargs + ) + + +def _submit(machine, member, **kwargs): + return service_workflow.submit( + machine, member, member=member, actor=member, + requester_name="Member", contact_email="member@example.test", + contact_phone="123", title="Cut this", **kwargs + ) + + diff --git a/backend/tests/machines/test_certification_extras.py b/backend/tests/machines/test_certification_extras.py new file mode 100644 index 00000000..aec859f5 --- /dev/null +++ b/backend/tests/machines/test_certification_extras.py @@ -0,0 +1,61 @@ +"""Certification extras: the card print field, the profile opt-in and the coverage report.""" +import pytest +from django.utils import timezone + +from apps.machines.certifications import live_certification_names +from apps.machines.models import CertificationGrant, CertificationType, MachineType +from apps.machines.reports_certifications import build_certification_coverage +from apps.makerspaces import member_card_printing, member_card_services, member_card_templates, profile_services +from apps.makerspaces.member_card_views import snapshot_for +from apps.makerspaces.models import MakerspaceMembership +from apps.operations.org_report_scope import EXCLUDED_ORGANIZATION_REPORT_KEYS +from apps.operations.report_registry import REPORT_REGISTRY +from tests.return_helpers import make_member, make_space, make_user + +pytestmark = pytest.mark.django_db + + +def _setup(slug): + space = make_space(slug) + manager = make_member(f"{slug}-manager", space) + user = make_user(f"{slug}-member", access_status="active") + membership = MakerspaceMembership.objects.create(user=user, makerspace=space, role=MakerspaceMembership.Role.INVENTORY_MANAGER) + machine_type = MachineType.objects.create(makerspace=space, slug=f"{slug}-laser", name="Laser") + live = CertificationType.objects.create(makerspace=space, machine_type=machine_type, name="Laser induction") + stale = CertificationType.objects.create(makerspace=space, machine_type=machine_type, name="Old induction") + CertificationGrant.objects.create(certification_type=live, membership=membership) + CertificationGrant.objects.create(certification_type=stale, membership=membership, revoked_at=timezone.now()) + return space, manager, membership + + +def test_live_names_and_card_print_field(): + space, manager, membership = _setup("cx-card") + assert live_certification_names(membership) == ("Laser induction",) + card = member_card_services.issue_card(manager, membership, printed_name="Cert Holder") + snapshot = snapshot_for(card) + assert snapshot.certifications == ("Laser induction",) + assert member_card_printing._field_value(snapshot, "certifications") == "Laser induction" + template = member_card_templates.normalize_template({"front_fields": ["printed_name", "certifications"]}) + assert member_card_printing.render_cards_pdf(template, [snapshot], title="t").startswith(b"%PDF") + + +def test_profile_publishes_certifications_only_after_opt_in(): + space, manager, membership = _setup("cx-profile") + before = profile_services.read_profile(membership, include_activity=False) + assert before["show_certifications"] is False and before["certifications"] == [] + profile_services.save_profile(membership, {"show_certifications": True}) + after = profile_services.read_profile(membership, include_activity=False) + assert after["show_certifications"] is True and after["certifications"] == ["Laser induction"] + + +def test_certification_coverage_report_row_and_org_exclusion(): + space, manager, membership = _setup("cx-report") + result = build_certification_coverage(space.id) + assert result.field_order == REPORT_REGISTRY["certification-coverage"].fields + rows = {row["certification_type"]: row for row in result.records} + assert rows["Laser induction"]["certified_members"] == 1 + assert rows["Laser induction"]["active_members"] == 2 # manager + member + assert rows["Laser induction"]["coverage_percent"] == 50.0 + assert rows["Old induction"] == {**rows["Old induction"], "certified_members": 0, "revoked_grants": 1} + assert rows["Laser induction"]["gating_enabled"] is False + assert "certification-coverage" in EXCLUDED_ORGANIZATION_REPORT_KEYS diff --git a/backend/tests/machines/test_certifications.py b/backend/tests/machines/test_certifications.py new file mode 100644 index 00000000..79e470a7 --- /dev/null +++ b/backend/tests/machines/test_certifications.py @@ -0,0 +1,229 @@ +"""Certification gating: off by default, fails closed when on, bypass is audited.""" + +from datetime import timedelta + +import pytest +from django.utils import timezone +from rest_framework.exceptions import PermissionDenied + +from apps.audit.models import AuditLog +from apps.bookings.models import Booking +from apps.machines import service_workflow_actions as service_workflow +from apps.machines.models import CertificationGrant, Machine +from tests.module_helpers import disable_module +from tests.machines.certification_helpers import ( + _bookable, + _book, + _certification, + _machine_type, + _manager, + _member, + _space, + _submit, +) + +pytestmark = pytest.mark.django_db + + +# --- Toggle off --------------------------------------------------------------- + +def test_feature_off_means_no_gate_at_all(): + space = _space("cert-off", certifications=False) + machine_type = _machine_type(space) + _certification(space, machine_type) + member, _ = _member(space, "cert-off-member") + machine = Machine.objects.create( + makerspace=space, machine_type=machine_type, name="Laser 1" + ) + + assert _submit(machine, member).pk is not None + booking = _book(_bookable(space, machine_type), member) + assert booking.status == Booking.Status.CONFIRMED + + +# --- Toggle on, no grant ------------------------------------------------------ + +def test_missing_grant_refuses_booking_with_the_typed_detail(): + space = _space("cert-on-booking") + machine_type = _machine_type(space) + _certification(space, machine_type, name="Laser induction") + member, _ = _member(space, "cert-on-booking-member") + + with pytest.raises(PermissionDenied) as excinfo: + _book(_bookable(space, machine_type), member) + + detail = excinfo.value.detail + assert detail["code"] == "certification_required" + assert [str(name) for name in detail["certifications"]] == ["Laser induction"] + assert not Booking.objects.exists() + + +def test_missing_grant_refuses_service_submit_with_the_typed_detail(): + space = _space("cert-on-service") + machine_type = _machine_type(space) + _certification(space, machine_type, name="Laser induction") + member, _ = _member(space, "cert-on-service-member") + machine = Machine.objects.create( + makerspace=space, machine_type=machine_type, name="Laser 1" + ) + + with pytest.raises(PermissionDenied) as excinfo: + _submit(machine, member) + + assert excinfo.value.detail["code"] == "certification_required" + assert [str(n) for n in excinfo.value.detail["certifications"]] == ["Laser induction"] + + +def test_purpose_flags_are_independent(): + """A booking-only requirement must not gate a service request, and vice versa.""" + space = _space("cert-purpose") + machine_type = _machine_type(space) + _certification( + space, machine_type, name="Booking only", + is_required_for_service=False, is_required_for_booking=True, + ) + member, _ = _member(space, "cert-purpose-member") + machine = Machine.objects.create( + makerspace=space, machine_type=machine_type, name="Laser 1" + ) + + assert _submit(machine, member).pk is not None + with pytest.raises(PermissionDenied): + _book(_bookable(space, machine_type), member) + + +# --- Grant states ------------------------------------------------------------- + +def test_live_grant_allows_both_surfaces(): + space = _space("cert-granted") + machine_type = _machine_type(space) + certification = _certification(space, machine_type) + member, membership = _member(space, "cert-granted-member") + CertificationGrant.objects.create( + certification_type=certification, membership=membership + ) + machine = Machine.objects.create( + makerspace=space, machine_type=machine_type, name="Laser 1" + ) + + assert _submit(machine, member).pk is not None + assert _book(_bookable(space, machine_type), member).pk is not None + + +def test_expired_grant_is_refused(): + space = _space("cert-expired") + machine_type = _machine_type(space) + certification = _certification(space, machine_type) + member, membership = _member(space, "cert-expired-member") + CertificationGrant.objects.create( + certification_type=certification, + membership=membership, + expires_at=timezone.now() - timedelta(days=1), + ) + + with pytest.raises(PermissionDenied) as excinfo: + _book(_bookable(space, machine_type), member) + assert excinfo.value.detail["code"] == "certification_required" + + +def test_revoked_grant_is_refused(): + space = _space("cert-revoked") + machine_type = _machine_type(space) + certification = _certification(space, machine_type) + member, membership = _member(space, "cert-revoked-member") + CertificationGrant.objects.create( + certification_type=certification, + membership=membership, + revoked_at=timezone.now() - timedelta(hours=1), + ) + machine = Machine.objects.create( + makerspace=space, machine_type=machine_type, name="Laser 1" + ) + + with pytest.raises(PermissionDenied): + _submit(machine, member) + + +def test_inactive_certification_type_stops_gating(): + space = _space("cert-inactive") + machine_type = _machine_type(space) + _certification(space, machine_type, is_active=False) + member, _ = _member(space, "cert-inactive-member") + + assert _book(_bookable(space, machine_type), member).pk is not None + + +# --- Override ----------------------------------------------------------------- + +def test_manager_override_passes_and_is_audited(): + space = _space("cert-override") + machine_type = _machine_type(space) + certification = _certification(space, machine_type) + member, _ = _member(space, "cert-override-member") + manager, _role = _manager(space, "cert-override-manager", machine_type) + machine = Machine.objects.create( + makerspace=space, machine_type=machine_type, name="Laser 1" + ) + + request = service_workflow.submit( + machine, member, member=member, actor=manager, + requester_name="Member", contact_email="member@example.test", + contact_phone="123", title="Cut this", + certification_override_reason="Trained today, paperwork pending", + ) + + assert request.pk is not None + entry = AuditLog.objects.filter(action="certification.override").get() + assert entry.actor_id == manager.pk + assert entry.makerspace_id == space.pk + assert entry.meta["reason"] == "Trained today, paperwork pending" + assert entry.meta["certification_type_id"] == certification.pk + + +def test_override_without_type_authority_is_still_refused(): + space = _space("cert-override-denied") + machine_type = _machine_type(space) + other_type = _machine_type(space, "Printer") + _certification(space, machine_type) + member, _ = _member(space, "cert-override-denied-member") + # Scoped to a DIFFERENT machine type: holding MANAGE_MACHINES is not enough. + outsider, _role = _manager(space, "cert-override-denied-mgr", other_type) + machine = Machine.objects.create( + makerspace=space, machine_type=machine_type, name="Laser 1" + ) + + with pytest.raises(PermissionDenied): + service_workflow.submit( + machine, member, member=member, actor=outsider, + requester_name="Member", contact_email="member@example.test", + contact_phone="123", title="Cut this", + certification_override_reason="Let me through", + ) + assert not AuditLog.objects.filter(action="certification.override").exists() + + +def test_override_needs_a_reason(): + space = _space("cert-override-noreason") + machine_type = _machine_type(space) + _certification(space, machine_type) + member, _ = _member(space, "cert-override-noreason-member") + manager, _role = _manager(space, "cert-override-noreason-mgr", machine_type) + + with pytest.raises(PermissionDenied): + _book(_bookable(space, machine_type), member, actor=manager) + + +# --- Bookings without the machines module ------------------------------------ + +def test_bookings_only_makerspace_still_books(): + """No `machines` module, no machine_type on the space: gating must not appear.""" + space = _space("cert-bookings-only", modules=("bookings",), certifications=False) + # A fresh makerspace starts with EVERY module on; turn machines off explicitly. + disable_module(space, "machines") + space.refresh_from_db() + member, _ = _member(space, "cert-bookings-only-member") + + booking = _book(_bookable(space, None), member) + + assert booking.status == Booking.Status.CONFIRMED + assert space.enabled_modules.count("machines") == 0 diff --git a/backend/tests/machines/test_certifications_api.py b/backend/tests/machines/test_certifications_api.py new file mode 100644 index 00000000..40acc1ba --- /dev/null +++ b/backend/tests/machines/test_certifications_api.py @@ -0,0 +1,137 @@ +"""Staff API for certification types and grants: scoping, soft-delete, audit.""" + +import pytest +from django.urls import reverse +from rest_framework.exceptions import PermissionDenied +from rest_framework.test import APIClient + +from apps.audit.models import AuditLog +from apps.machines.models import CertificationGrant, CertificationType +from tests.machines.certification_helpers import ( + _bookable, + _book, + _certification, + _machine_type, + _manager, + _member, + _space, +) + +pytestmark = pytest.mark.django_db + + +def _client(actor): + client = APIClient() + client.force_authenticate(user=actor) + return client + + +def test_staff_api_creates_grants_and_revokes_with_audit(): + space = _space("cert-api") + machine_type = _machine_type(space) + manager, _role = _manager(space, "cert-api-mgr", machine_type) + member, membership = _member(space, "cert-api-member") + client = _client(manager) + + created = client.post( + reverse("admin-certification-types", args=[space.pk]), + { + "machine_type": machine_type.pk, + "name": "Laser induction", + "validity_days": 30, + "is_required_for_booking": True, + }, + format="json", + ) + assert created.status_code == 201, created.data + type_id = created.data["id"] + + listed = client.get(reverse("admin-certification-types", args=[space.pk])) + assert [row["id"] for row in listed.data] == [type_id] + + granted = client.post( + reverse("admin-certification-type-grants", args=[type_id]), + {"membership_id": membership.pk, "notes": "Trained"}, + format="json", + ) + assert granted.status_code == 201, granted.data + assert granted.data["is_live"] is True + # validity_days seeded the expiry rather than leaving it open-ended. + assert granted.data["expires_at"] is not None + + assert _book(_bookable(space, machine_type), member).pk is not None + + revoked = client.post( + reverse("admin-certification-grant-revoke", args=[granted.data["id"]]), + {"notes": "Failed reassessment"}, + format="json", + ) + assert revoked.status_code == 200, revoked.data + assert revoked.data["is_live"] is False + + with pytest.raises(PermissionDenied): + _book(_bookable(space, machine_type), member) + + actions = set( + AuditLog.objects.filter(makerspace=space).values_list("action", flat=True) + ) + assert {"certification_type.created", "certification.granted", "certification.revoked"} <= actions + + +def test_staff_api_deactivate_is_a_soft_delete(): + space = _space("cert-api-delete") + machine_type = _machine_type(space) + manager, _role = _manager(space, "cert-api-del-mgr", machine_type) + certification = _certification(space, machine_type) + _member(space, "cert-api-del-member") + + response = _client(manager).delete( + reverse("admin-certification-type-detail", args=[certification.pk]) + ) + + assert response.status_code == 200 + certification.refresh_from_db() + assert certification.is_active is False + assert CertificationType.objects.filter(pk=certification.pk).exists() + assert AuditLog.objects.filter(action="certification_type.deactivated").exists() + + +def test_staff_api_hides_and_refuses_types_for_unlinked_machine_types(): + space = _space("cert-api-scope") + laser = _machine_type(space, "Laser") + printer = _machine_type(space, "Printer") + laser_certification = _certification(space, laser, name="Laser induction") + _certification(space, printer, name="Printer induction") + manager, _role = _manager(space, "cert-api-scope-mgr", laser) + client = _client(manager) + + listed = client.get(reverse("admin-certification-types", args=[space.pk])) + assert [row["id"] for row in listed.data] == [laser_certification.pk] + + refused = client.patch( + reverse( + "admin-certification-type-detail", + args=[CertificationType.objects.get(name="Printer induction").pk], + ), + {"name": "Renamed"}, + format="json", + ) + assert refused.status_code == 403 + + +def test_staff_api_refuses_a_membership_from_another_makerspace(): + space = _space("cert-api-tenant") + other = _space("cert-api-tenant-other") + machine_type = _machine_type(space) + certification = _certification(space, machine_type) + manager, _role = _manager(space, "cert-api-tenant-mgr", machine_type) + _outsider, foreign_membership = _member(other, "cert-api-tenant-outsider") + + response = _client(manager).post( + reverse("admin-certification-type-grants", args=[certification.pk]), + {"membership_id": foreign_membership.pk}, + format="json", + ) + + assert response.status_code == 400 + assert not CertificationGrant.objects.exists() diff --git a/backend/tests/makerspaces/test_custom_roles_l1.py b/backend/tests/makerspaces/test_custom_roles_l1.py index c91e2b05..53309297 100644 --- a/backend/tests/makerspaces/test_custom_roles_l1.py +++ b/backend/tests/makerspaces/test_custom_roles_l1.py @@ -1,3 +1,5 @@ +from importlib import import_module + import pytest from django.core.exceptions import ValidationError from django.db import IntegrityError, connection, transaction @@ -113,7 +115,11 @@ def test_seed_and_backfill_migration_round_trip(): } assert set(seeded) == set(HISTORICAL_ROLE_VALUES) assert seeded[MakerspaceMembership.Role.PRINT_MANAGER].granted_actions == ["manage_printing"] - for legacy_role, name, granted_actions in roles.DEFAULT_ROLE_DEFINITIONS: + # 0039 seeds ITS OWN frozen action lists; actions added to a protected default later + # arrive through additive backfills (0069 `manage_member_cards`), never by editing + # the historical seed. + seed_module = import_module("apps.makerspaces.migrations.0039_seed_and_backfill_roles") + for legacy_role, name, granted_actions in seed_module.DEFAULT_ROLE_DEFINITIONS: role = seeded[legacy_role] assert role.name == name assert role.slug == legacy_role @@ -122,6 +128,11 @@ def test_seed_and_backfill_migration_round_trip(): assert role.is_protected is True membership = NewMembership.objects.get(id=memberships[legacy_role]) assert membership.assigned_role_id == role.id + # Seed + every later backfill must land exactly on today's definitions. + import_module("apps.makerspaces.migrations.0069_member_card_actions").forwards(new_apps, None) + for legacy_role, name, granted_actions in roles.DEFAULT_ROLE_DEFINITIONS: + role = NewMakerspaceRole.objects.get(makerspace_id=makerspace.id, legacy_role=legacy_role) + assert role.granted_actions == sorted(granted_actions) executor = MigrationExecutor(connection) executor.migrate(schema_target) @@ -150,6 +161,10 @@ def test_seed_and_backfill_migration_round_trip(): "DELETE FROM makerspaces_makerspacemembership WHERE makerspace_id = %s", [makerspace_id], ) + cursor.execute( + "DELETE FROM makerspaces_makerspacerole WHERE makerspace_id = %s", + [makerspace_id], + ) cursor.execute( "DELETE FROM makerspaces_makerspace WHERE id = %s", [makerspace_id], diff --git a/backend/tests/makerspaces/test_invitation_requests.py b/backend/tests/makerspaces/test_invitation_requests.py new file mode 100644 index 00000000..75d6d647 --- /dev/null +++ b/backend/tests/makerspaces/test_invitation_requests.py @@ -0,0 +1,132 @@ +"""Public invitation requests: throttled, honeypotted, PII-encrypted, and the staff queue.""" +import pytest +from django.core.cache import cache +from django.db import connection +from django.urls import reverse +from rest_framework.test import APIClient +from rest_framework.throttling import ScopedRateThrottle + +from apps.audit.models import AuditLog +from apps.encryption.crypto import is_envelope +from apps.makerspaces.models import InvitationRequest, MakerspaceRole, MembershipRequest +from tests.encryption.conftest import enabled_encryption +from tests.module_helpers import disable_module +from tests.return_helpers import authenticated_client, make_member, make_space + +pytestmark = pytest.mark.django_db + +PAYLOAD = { + "name": "Ada Lovelace", + "email": "Ada@Example.test", + "phone": "+15550100", + "message": "I would love to join the electronics bench.", +} + + +def submit(space, payload=PAYLOAD, client=None): + return (client or APIClient()).post( + reverse("public-invitation-request", args=[space.slug]), payload, format="json" + ) + + +def test_public_submission_stores_a_pending_lead_and_audits_it(): + space = make_space("invite-req") + + response = submit(space) + + assert response.status_code == 202 + row = InvitationRequest.objects.get(makerspace=space) + assert (row.name, row.email, row.phone) == ("Ada Lovelace", "ada@example.test", "+15550100") + assert row.status == "pending" + entry = AuditLog.objects.get(action="invitation_request.submitted") + assert entry.meta == {"invitation_request_id": row.pk} + assert entry.target_id == str(row.pk) and entry.makerspace_id == space.pk + + +def test_honeypot_gets_the_same_ack_and_stores_nothing(): + space = make_space("invite-honeypot") + + real = submit(space) + trapped = submit(space, {**PAYLOAD, "website": "https://spam.example"}) + + assert trapped.status_code == real.status_code == 202 + assert trapped.data == real.data + assert InvitationRequest.objects.filter(makerspace=space).count() == 1 + assert AuditLog.objects.filter(action="invitation_request.submitted").count() == 1 + + +def test_module_off_answers_404_and_bad_input_400(): + space = make_space("invite-off") + assert submit(space, {"name": "", "email": "nope"}).status_code == 400 + disable_module(space, "membership") + assert submit(space).status_code == 404 + assert not InvitationRequest.objects.exists() + + +def test_submission_is_throttled(settings, monkeypatch): + space = make_space("invite-throttle") + cache.clear() + rest_settings = dict(settings.REST_FRAMEWORK) + rates = {**rest_settings["DEFAULT_THROTTLE_RATES"], "public_invitation_request": "1/hour"} + rest_settings["DEFAULT_THROTTLE_RATES"] = rates + settings.REST_FRAMEWORK = rest_settings + monkeypatch.setattr(ScopedRateThrottle, "THROTTLE_RATES", rates) + client = APIClient() + + assert submit(space, client=client).status_code == 202 + assert submit(space, client=client).status_code == 429 + assert InvitationRequest.objects.count() == 1 + + +def test_pii_columns_are_envelopes_when_encryption_is_enabled(): + space = make_space("invite-encrypted") + with enabled_encryption(): + assert submit(space).status_code == 202 + row = InvitationRequest.objects.get(makerspace=space) + assert row.email == "ada@example.test" + with connection.cursor() as cursor: + cursor.execute( + "SELECT name, email, phone, message FROM makerspaces_invitationrequest WHERE id = %s", + [row.pk], + ) + name, email, phone, message = cursor.fetchone() + assert all(is_envelope(value) for value in (name, email, phone)) + assert message == PAYLOAD["message"] + + +def test_staff_queue_invites_declines_and_is_scoped(): + space, other = make_space("invite-staff"), make_space("invite-staff-other") + manager = make_member("invite-staff-mgr", space) + client = authenticated_client(manager) + stranger = authenticated_client(make_member("invite-staff-stranger", other)) + submit(space) + submit(space, {**PAYLOAD, "email": "second@example.test"}) + first, second = InvitationRequest.objects.filter(makerspace=space).order_by("pk") + role = MakerspaceRole.objects.get(makerspace=space, slug="member") + list_url = reverse("admin-invitation-requests", args=[space.pk]) + + assert stranger.get(list_url).status_code == 404 + listed = client.get(list_url, {"status": "pending"}) + assert listed.status_code == 200 and {row["id"] for row in listed.data} == {first.pk, second.pk} + assert listed.data[0]["email"] in {"ada@example.test", "second@example.test"} + + invite_url = reverse("admin-invitation-request-invite", args=[first.pk]) + assert stranger.post(invite_url, {"role_id": role.pk}, format="json").status_code == 404 + invited = client.post(invite_url, {"role_id": role.pk}, format="json") + assert invited.status_code == 200 and invited.data["status"] == "invited" + invitation = MembershipRequest.objects.get( + makerspace=space, invite_email="ada@example.test", state=MembershipRequest.State.INVITED + ) + entry = AuditLog.objects.get(action="invitation_request.invited") + assert entry.meta == { + "invitation_request_id": first.pk, + "membership_request_id": invitation.pk, + "role_id": role.pk, + } + assert client.post(invite_url, {"role_id": role.pk}, format="json").status_code == 400 + + declined = client.post(reverse("admin-invitation-request-decline", args=[second.pk])) + assert declined.status_code == 200 and declined.data["status"] == "declined" + second.refresh_from_db() + assert second.handled_by == manager and second.handled_at is not None + assert AuditLog.objects.filter(action="invitation_request.declined").count() == 1 diff --git a/backend/tests/makerspaces/test_membership_plans.py b/backend/tests/makerspaces/test_membership_plans.py new file mode 100644 index 00000000..ed67736a --- /dev/null +++ b/backend/tests/makerspaces/test_membership_plans.py @@ -0,0 +1,281 @@ +"""Membership plans, terms, the renewal sweep and the optional lapsed-cannot-borrow rule.""" +from datetime import timedelta +from decimal import Decimal + +import pytest +from django.urls import reverse +from django.utils import timezone +from rest_framework.exceptions import ValidationError + +from apps.audit.models import AuditLog +from apps.inventory.models import InventoryProduct +from apps.makerspaces import membership_services +from apps.makerspaces.membership_plan_services import ( + cancel_term, + create_term, + run_membership_renewals, + term_end, +) +from apps.makerspaces.models import ( + MakerspaceMembership, + MakerspaceRole, + MembershipPlan, + MembershipTerm, +) +from apps.payments.models import Payment +from apps.presence.models import PresenceSession +from tests.module_helpers import disable_module +from tests.payments.test_models import configured_settings +from tests.return_helpers import authenticated_client, make_member, make_space, make_user + +pytestmark = pytest.mark.django_db + + +def plan(space, name="Monthly", interval="monthly", days=None, amount="10.00"): + return MembershipPlan.objects.create( + makerspace=space, name=name, interval=interval, custom_days=days, + amount=Decimal(amount), currency="usd", + ) + + +def member(slug, space): + user = make_user(f"{slug}-member", access_status="active") + role = MakerspaceRole.objects.get(makerspace=space, slug="member") + return MakerspaceMembership.objects.create( + makerspace=space, user=user, assigned_role=role, role="custom", status="active" + ) + + +def payments_on(space): + space.enabled_features = ["payments.enabled", "payments.membership", "charges.enabled", "charges.membership"] + space.save(update_fields=["enabled_features", "updated_at"]) + configured_settings(space) + + +# ------------------------------------------------------------------ term dates + + +def test_term_end_per_interval(): + space = make_space("plan-dates") + start = timezone.now().replace(month=1, day=31) + assert term_end(plan(space, "M"), start) == start.replace(month=2, day=28 + int( + start.year % 4 == 0 and (start.year % 100 != 0 or start.year % 400 == 0) + )) + assert term_end(plan(space, "Y", "yearly"), start).year == start.year + 1 + assert term_end(plan(space, "D", "custom_days", 10), start) == start + timedelta(days=10) + + +def test_create_term_audits_and_chains_after_the_current_term(): + space = make_space("plan-create") + manager = make_member("plan-create-mgr", space) + membership = member("plan-create", space) + monthly = plan(space) + + first = create_term(manager, membership, monthly) + second = create_term(manager, membership, monthly) + + assert first.ends_at == term_end(monthly, first.starts_at) + assert second.starts_at == first.ends_at + entry = AuditLog.objects.get(action="membership.term_created", target_id=str(first.pk)) + assert entry.meta == { + "membership_id": membership.pk, "plan_id": monthly.pk, "term_id": first.pk, + } + + +def test_create_term_refuses_foreign_or_inactive_plans_and_off_module(): + space, other = make_space("plan-refuse"), make_space("plan-refuse-other") + manager = make_member("plan-refuse-mgr", space) + membership = member("plan-refuse", space) + with pytest.raises(ValidationError): + create_term(manager, membership, plan(other)) + inactive = plan(space, "Old") + inactive.is_active = False + inactive.save(update_fields=["is_active"]) + with pytest.raises(ValidationError): + create_term(manager, membership, inactive) + disable_module(space, "membership") + with pytest.raises(ValidationError): + create_term(manager, membership, plan(space, "New")) + + +def test_approve_request_with_a_plan_opens_the_first_term(): + space = make_space("plan-approve") + manager = make_member("plan-approve-mgr", space) + joiner = make_user("plan-approve-joiner", access_status="active") + joiner.email_verified_at = timezone.now() + joiner.save(update_fields=["email_verified_at"]) + role = MakerspaceRole.objects.get(makerspace=space, slug="member") + request = membership_services.request_membership(joiner, space) + + membership = membership_services.approve_request(manager, request, role, plan=plan(space)) + + assert membership.terms.filter(status="active").count() == 1 + + +# ---------------------------------------------------------------- renewal sweep + + +def test_renewal_sweep_raises_exactly_one_charge_inside_the_window(): + space = make_space("plan-renew") + payments_on(space) + manager = make_member("plan-renew-mgr", space) + membership = member("plan-renew", space) + monthly = plan(space) + now = timezone.now() + term = create_term(manager, membership, monthly, starts_at=now - timedelta(days=27)) + outside = create_term(manager, membership, monthly, starts_at=now + timedelta(days=20)) + + first = run_membership_renewals(now=now) + second = run_membership_renewals(now=now) + + assert first["charged"] == 1 and second["charged"] == 0 + payment = Payment.objects.get( + makerspace=space, subject_type=Payment.SubjectType.MEMBERSHIP_TERM, subject_id=term.pk + ) + assert payment.amount == Decimal("10.00") and payment.member == membership.user + term.refresh_from_db() + outside.refresh_from_db() + assert term.renewal_payment == payment and outside.renewal_payment is None + assert AuditLog.objects.filter(action="membership.renewal_raised").count() == 1 + + +def test_renewal_sweep_charges_nothing_with_tracking_off_and_expires_past_terms(): + """Tracking OFF is what stops a renewal charge existing at all. + + The online payments feature no longer decides this: with charge tracking on and no + rail, the sweep still records the debt for staff to collect by hand. Switching + `charges.membership` off is the "this space charges nothing" case, and it is what + this test now pins -- expiry of past terms must keep working either way. + """ + space = make_space("plan-renew-off") + configured_settings(space) # credentials alone are not consent to charge + space.enabled_features = [ + key for key in space.enabled_features if key != "charges.membership" + ] + space.save(update_fields=["enabled_features", "updated_at"]) + manager = make_member("plan-renew-off-mgr", space) + membership = member("plan-renew-off", space) + monthly = plan(space) + now = timezone.now() + ending = create_term(manager, membership, monthly, starts_at=now - timedelta(days=28)) + ended = create_term(manager, membership, monthly, starts_at=now - timedelta(days=70)) + + counts = run_membership_renewals(now=now) + + assert counts["charged"] == 0 and counts["expired"] == 1 and counts["skipped"] == 1 + assert not Payment.objects.filter(subject_type=Payment.SubjectType.MEMBERSHIP_TERM).exists() + ended.refresh_from_db() + ending.refresh_from_db() + assert ended.status == "expired" and ending.status == "active" + assert AuditLog.objects.filter( + action="membership.term_expired", target_id=str(ended.pk) + ).exists() + + +def test_cancel_term_cancels_its_pending_renewal_charge(): + space = make_space("plan-cancel") + payments_on(space) + manager = make_member("plan-cancel-mgr", space) + membership = member("plan-cancel", space) + now = timezone.now() + term = create_term(manager, membership, plan(space), starts_at=now - timedelta(days=27)) + run_membership_renewals(now=now) + + cancel_term(manager, term) + + term.refresh_from_db() + assert term.status == "cancelled" + assert term.renewal_payment.status == Payment.Status.CANCELED + with pytest.raises(ValidationError): + cancel_term(manager, term) + + +# ---------------------------------------------------------- lapsed cannot borrow + + +def _submit(space, membership): + now = timezone.now() + PresenceSession.objects.create( + member=membership.user, makerspace=space, membership=membership, + started_at=now, expires_at=now + timedelta(hours=2), + ) + product = InventoryProduct.objects.create( + makerspace=space, name="Scope", total_quantity=2, available_quantity=2, is_public=True, + ) + return authenticated_client(membership.user).post( + reverse("hardware_requests:request-submit", args=[space.slug]), + {"requested_for": "Bench", "items": [{"product_id": product.pk, "quantity": 1}]}, + format="json", + ) + + +@pytest.mark.parametrize( + ("flag", "term_state", "expected"), + [ + (False, "expired", 201), # flag off: an expired term changes nothing + (True, None, 201), # flag on, never held a term: plans are optional + (True, "active", 201), # flag on, current term + (True, "expired", 403), # flag on, lapsed: refused like a non-member + (True, "cancelled", 403), + ], +) +def test_lapsed_members_cannot_borrow_only_when_set_and_only_when_lapsed(flag, term_state, expected): + space = make_space(f"lapsed-{int(flag)}-{term_state}") + space.lapsed_members_cannot_borrow = flag + space.save(update_fields=["lapsed_members_cannot_borrow"]) + membership = member(space.slug, space) + if term_state is not None: + now = timezone.now() + MembershipTerm.objects.create( + membership=membership, plan=plan(space), status=term_state, + starts_at=now - timedelta(days=40), + ends_at=now + timedelta(days=10) if term_state == "active" else now - timedelta(days=5), + ) + + response = _submit(space, membership) + + assert response.status_code == expected, response.data + if expected == 403: + assert response.data["code"] == "membership_required" + + +# ------------------------------------------------------------------- staff API + + +def test_staff_plan_and_term_api_is_scoped_and_module_gated(): + space, other = make_space("plan-api"), make_space("plan-api-other") + client = authenticated_client(make_member("plan-api-mgr", space)) + stranger = authenticated_client(make_member("plan-api-stranger", other)) + membership = member("plan-api", space) + plans_url = reverse("admin-membership-plans", args=[space.pk]) + + assert stranger.get(plans_url).status_code == 404 + created = client.post( + plans_url, + {"name": "Yearly", "interval": "yearly", "amount": "99.00", "currency": "EUR"}, + format="json", + ) + assert created.status_code == 201 and created.data["currency"] == "eur" + assert client.post( + plans_url, {"name": "Bad", "interval": "monthly", "custom_days": 3, "amount": "1"}, + format="json", + ).status_code == 400 + assert client.post(plans_url, {"name": "Yearly", "interval": "yearly", "amount": "1"}, format="json").status_code == 400 + assert stranger.patch( + reverse("admin-membership-plan-detail", args=[created.data["id"]]), {"is_active": False}, format="json" + ).status_code == 404 + assert client.patch( + reverse("admin-membership-plan-detail", args=[created.data["id"]]), {"amount": "120.00"}, format="json" + ).data["amount"] == "120.00" + + terms_url = reverse("admin-membership-terms", args=[membership.pk]) + assert stranger.post(terms_url, {"plan_id": created.data["id"]}, format="json").status_code == 404 + term = client.post(terms_url, {"plan_id": created.data["id"]}, format="json") + assert term.status_code == 201 and term.data["plan_name"] == "Yearly" + assert [row["id"] for row in client.get(terms_url).data] == [term.data["id"]] + cancel_url = reverse("admin-membership-term-cancel", args=[term.data["id"]]) + assert stranger.post(cancel_url).status_code == 404 + assert client.post(cancel_url).data["status"] == "cancelled" + + disable_module(space, "membership") + assert client.get(plans_url).status_code == 400 diff --git a/backend/tests/makerspaces/test_module_registry.py b/backend/tests/makerspaces/test_module_registry.py index 1ed52337..27aaa87d 100644 --- a/backend/tests/makerspaces/test_module_registry.py +++ b/backend/tests/makerspaces/test_module_registry.py @@ -152,15 +152,17 @@ def test_modules_are_opt_in_and_a_new_makerspace_gets_approved_defaults(): # first two were previously ungated (webhook presence alone decided), which is why # makerspaces/0056 backfills them onto existing rows; `discord` is genuinely new and # stays opt-in. The final four keys are the other registry growth since the legacy - # baseline; only payments and updates remain default-enabled. + # baseline; only updates remains default-enabled. POST_LEGACY_KEYS = { - "notifications", "email", "slack", "mattermost", "discord", + "notifications", "email", "slack", "mattermost", "discord", "webhook", "payments", "member_accounts", "mobile", "updates", } - # Only payments and updates remain default-on. Member accounts and mobile are - # opt-in together because mobile depends on member accounts. - DEFAULT_ON_KEYS = {"payments", "updates"} + # Only updates remains default-on. Member accounts and mobile are opt-in together + # because mobile depends on member accounts. `payments` joined them when charge + # TRACKING moved out from under it: the module now buys the online rail alone, so a + # cash-taking space needs none of it and still keeps a full ledger of money owed. + DEFAULT_ON_KEYS = {"updates"} assert set(DEFAULT_ENABLED_MODULES) == module_registry.core_module_keys() | DEFAULT_ON_KEYS assert default_enabled_modules() == DEFAULT_ENABLED_MODULES @@ -225,7 +227,7 @@ def test_registry_is_internally_consistent(): # per-channel notification keys `slack`/`mattermost`/`discord`, plus the four # phase-3 keys placed in front of previously ungated substrate: `payments`, # `member_accounts`, `mobile` and `updates`. - assert len(module_registry.MODULES) == 32 + assert len(module_registry.MODULES) == 33 assert len(module_registry.BY_KEY) == len(module_registry.MODULES) for definition in module_registry.MODULES: assert definition.label and definition.description and definition.app_label diff --git a/backend/tests/member_activity/test_member_dashboard_d8.py b/backend/tests/member_activity/test_member_dashboard_d8.py new file mode 100644 index 00000000..c9faa7a9 --- /dev/null +++ b/backend/tests/member_activity/test_member_dashboard_d8.py @@ -0,0 +1,230 @@ +"""The member dashboard's history, money and notices (owner decision D8). + +Gated on the `membership` module, so it is off by default: managed hosting installs the +module, a self-host deployment may install it as an add-on, and there is one code path. + +Notices are DERIVED from the member's own rows. `notifications.Notification` is +makerspace-wide -- no recipient column, one shared `read_at` -- so serving it to members +would hand them staff alerts and let one member's read mark speak for everyone. +""" + +from datetime import timedelta +from decimal import Decimal + +import pytest +from django.utils import timezone +from rest_framework.test import APIClient + +from apps.accounts.models import User +from apps.hardware_requests.models import ( + HardwareRequest, + HardwareRequestItem, + PublicToolLoan, +) +from apps.inventory.models import InventoryProduct +from apps.makerspaces.models import Makerspace, MakerspaceMembership, MakerspaceRole +from apps.payments.models import Payment + +pytestmark = pytest.mark.django_db + + +def _space(slug, *, membership_module=True): + modules = ["public_inventory", "request_workflow", "staff_admin", "scanner", + "evidence_uploads", "qr_management"] + if membership_module: + modules.append("membership") + return Makerspace.objects.create( + name=slug, slug=slug, enabled_modules=sorted(set(modules)) + ) + + +def _member(space, username): + user = User.objects.create_user( + username=username, display_name=username, + email=f"{username}@example.test", phone="9999999999", + access_status=User.AccessStatus.ACTIVE, + ) + MakerspaceMembership.objects.create( + makerspace=space, user=user, + assigned_role=MakerspaceRole.objects.get(makerspace=space, slug="member"), + role="custom", + ) + return user + + +def _client(user): + api = APIClient() + api.force_authenticate(user) + return api + + +def _url(space): + return f"/api/v1/member/makerspaces/{space.id}/activity" + + +def _returned_loan(space, user, label, *, late): + request = HardwareRequest.objects.create( + makerspace=space, requester=user, requester_username=user.username, + ) + due = timezone.now() - timedelta(days=2) + return PublicToolLoan.objects.create( + makerspace=space, request=request, requester=user, target_type="product", + target_id=1, target_label=label, due_at=due, + status=PublicToolLoan.Status.RETURNED, + returned_at=due + timedelta(days=1 if late else -1), + ) + + +def test_history_is_member_owned_and_records_what_came_back(): + space = _space("dash-history") + user, other = _member(space, "dash-owner"), _member(space, "dash-other") + _returned_loan(space, user, "My drill", late=True) + _returned_loan(space, other, "Their drill", late=False) + product = InventoryProduct.objects.create( + makerspace=space, name="Clamp", total_quantity=3, available_quantity=3 + ) + request = HardwareRequest.objects.create( + makerspace=space, requester=user, requester_username=user.username, + status=HardwareRequest.Status.RETURNED, + ) + HardwareRequestItem.objects.create( + request=request, product=product, requested_quantity=2, + accepted_quantity=2, returned_quantity=1, damaged_quantity=1, + ) + + payload = _client(user).get(_url(space)).data + + assert [row["label"] for row in payload["loan_history"]] == ["My drill"] + assert payload["loan_history"][0]["returned_late"] is True + # Two requests: the reviewed one above, plus the one the self-checkout loan carries. + # Both are the member's own, and neither is the other member's. + assert len(payload["request_history"]) == 2 + returned = next( + row for row in payload["request_history"] if row["status"] == "returned" + ) + assert returned["returned_quantity"] == 1 + assert returned["damaged_quantity"] == 1 + + +def test_dues_are_grouped_by_currency_and_never_summed_across_them(): + space = _space("dash-dues") + space.membership_dues_amount = Decimal("15.00") + space.save(update_fields=["membership_dues_amount", "updated_at"]) + user = _member(space, "dash-dues-member") + membership = MakerspaceMembership.objects.get(makerspace=space, user=user) + for currency, amount, subject in (("usd", "10.00", membership.pk),): + Payment.objects.create( + makerspace=space, + subject_type=Payment.SubjectType.MAKERSPACE_MEMBERSHIP, + subject_id=subject, member=user, amount=Decimal(amount), + currency=currency, created_by=user, + ) + + payload = _client(user).get(_url(space)).data + + assert payload["membership_dues"]["dues_amount"] == "15.00" + assert payload["membership_dues"]["outstanding_by_currency"] == {"usd": "10.00"} + + +def test_notices_surface_the_members_own_overdue_loan_and_debt(): + space = _space("dash-notices") + user = _member(space, "dash-notice-member") + request = HardwareRequest.objects.create( + makerspace=space, requester=user, requester_username=user.username, + ) + PublicToolLoan.objects.create( + makerspace=space, request=request, requester=user, target_type="product", + target_id=1, target_label="Overdue saw", + due_at=timezone.now() - timedelta(days=1), + ) + membership = MakerspaceMembership.objects.get(makerspace=space, user=user) + Payment.objects.create( + makerspace=space, + subject_type=Payment.SubjectType.MAKERSPACE_MEMBERSHIP, + subject_id=membership.pk, member=user, amount=Decimal("7.50"), + currency="inr", created_by=user, + ) + + payload = _client(user).get(_url(space)).data + events = [notice["event"] for notice in payload["notices"]] + + assert "loan_overdue" in events + assert "payment_due" in events + # Most urgent first: an overdue item outranks an unpaid charge. + assert events.index("loan_overdue") < events.index("payment_due") + + +def test_a_quiet_member_gets_no_notices(): + """The feed must stay empty rather than inventing reassurance to display.""" + space = _space("dash-quiet") + user = _member(space, "dash-quiet-member") + + payload = _client(user).get(_url(space)).data + + assert payload["notices"] == [] + + +def test_the_dashboard_does_not_exist_without_the_membership_module(): + space = _space("dash-no-module", membership_module=False) + user = _member(space, "dash-no-module-member") + + response = _client(user).get(_url(space)) + + assert response.status_code == 400 + + +def _active_loan(space, user, label, *, due_in): + request = HardwareRequest.objects.create( + makerspace=space, requester=user, requester_username=user.username, + ) + return PublicToolLoan.objects.create( + makerspace=space, request=request, requester=user, target_type="product", + target_id=1, target_label=label, due_at=timezone.now() + due_in, + ) + + +def test_due_soon_means_within_a_day_not_within_two(): + """`timedelta.days` floors, so 47 hours read as 1 and got announced as due within a + day. The notice is a deadline claim; it has to be measured, not rounded down.""" + space = _space("dash-due-soon") + user = _member(space, "dash-due-soon-member") + _active_loan(space, user, "Due in 47 hours", due_in=timedelta(hours=47)) + + payload = _client(user).get(_url(space)).data + + assert [n["event"] for n in payload["notices"]] == [] + + +def test_a_loan_due_inside_a_day_still_raises_the_notice(): + space = _space("dash-due-inside") + user = _member(space, "dash-due-inside-member") + _active_loan(space, user, "Due in 5 hours", due_in=timedelta(hours=5)) + + payload = _client(user).get(_url(space)).data + + assert "loan_due_soon" in [n["event"] for n in payload["notices"]] + + +def test_an_unreadable_ledger_is_not_reported_as_nothing_outstanding(monkeypatch): + """A failed query must never render as "Nothing outstanding". + + `None` says the amount could not be read; `{}` says the member is clear. Collapsing + the two tells someone they owe nothing on the strength of a database failure. + """ + from apps.makerspaces import member_dashboard_service + + space = _space("dash-dues-unreadable") + user = _member(space, "dash-dues-unreadable-member") + + def _explode(*args, **kwargs): + raise RuntimeError("ledger unavailable") + + # Stands in for any failure reaching the ledger -- a transient database error, or a + # deployment mid-migration -- without needing to break the database itself. + monkeypatch.setattr(member_dashboard_service, "Sum", _explode) + + payload = _client(user).get(_url(space)).data + + assert payload["membership_dues"]["outstanding_by_currency"] is None + # And no debt notice invented from a figure nobody could read. + assert "payment_due" not in [n["event"] for n in payload["notices"]] diff --git a/backend/tests/modules/test_editions.py b/backend/tests/modules/test_editions.py new file mode 100644 index 00000000..31738a78 --- /dev/null +++ b/backend/tests/modules/test_editions.py @@ -0,0 +1,71 @@ +"""Editions hide surfaces and public routes; they never change what a makerspace can do.""" +import pytest +from django.urls import reverse +from rest_framework.test import APIClient + +from apps.makerspaces import editions +from apps.makerspaces.module_profiles import EVENTS_ONLY, BOOKINGS_ONLY, EVERYTHING, profile_modules +from apps.makerspaces.module_registry import MODULE_KEYS +from apps.makerspaces.module_registry_helpers import core_module_keys +from apps.makerspaces.platform import available_modules, bootstrap_payload, module_enabled +from tests.return_helpers import authenticated_client, make_member, make_product, make_space + +pytestmark = pytest.mark.django_db + + +def test_every_hidden_key_is_a_real_module_and_core_is_never_uninstalled(): + for edition in editions.EDITIONS.values(): + assert edition.hidden_module_keys <= MODULE_KEYS, edition.key + assert core_module_keys() <= set(profile_modules(EVENTS_ONLY)) + assert core_module_keys() <= set(profile_modules(BOOKINGS_ONLY)) + assert "events" in profile_modules(EVENTS_ONLY) and "bookings" not in profile_modules(EVENTS_ONLY) + assert "bookings" in profile_modules(BOOKINGS_ONLY) and "events" not in profile_modules(BOOKINGS_ONLY) + assert set(profile_modules(EVENTS_ONLY)) <= set(profile_modules(EVERYTHING)) + + +def test_unknown_edition_is_refused(settings): + settings.SPACEWORKS_EDITION = "kitchen" + with pytest.raises(Exception): + editions.current_edition() + + +def test_events_edition_hides_the_loan_spine_from_clients_but_not_from_capability(settings): + space = make_space("edition-events") + settings.SPACEWORKS_EDITION = "makerspace" + assert "public_inventory" in available_modules(space) + settings.SPACEWORKS_EDITION = "events" + listed = available_modules(space) + assert "public_inventory" not in listed and "scanner" not in listed and "bookings" not in listed + assert "events" in listed + # Capability is untouched: workflows, gates and staff endpoints keep working. + assert module_enabled(space, "public_inventory") is True + assert core_module_keys() <= set(space.enabled_modules) + payload = bootstrap_payload(space) + assert payload["edition"] == "events" + assert "request_workflow" not in payload["modules"] + + +def test_events_edition_makes_public_loan_routes_404_but_staff_routes_answer(settings): + space = make_space("edition-public") + make_product(space, name="Hidden Drill") + manager = make_member("edition-manager", space) + public_url = reverse("public-inventory", kwargs={"makerspace_slug": space.slug}) + staff_url = reverse("admin-inventory", kwargs={"makerspace_id": space.pk}) + + settings.SPACEWORKS_EDITION = "makerspace" + assert APIClient().get(public_url).status_code == 200 + settings.SPACEWORKS_EDITION = "events" + assert APIClient().get(public_url).status_code == 404 + assert authenticated_client(manager).get(staff_url).status_code == 200 + assert APIClient().get(reverse("public-machines", kwargs={"makerspace_slug": space.slug})).status_code == 404 + + +def test_bookings_edition_hides_events_and_organization_edition_hides_nothing(settings): + space = make_space("edition-bookings") + settings.SPACEWORKS_EDITION = "bookings" + listed = available_modules(space) + assert "events" not in listed and "bookings" in listed + settings.SPACEWORKS_EDITION = "organization" + assert set(available_modules(space)) >= core_module_keys() + assert editions.current_edition().organization_label is True + assert bootstrap_payload(space)["edition"] == "organization" diff --git a/backend/tests/modules/test_module_contract_matrix.py b/backend/tests/modules/test_module_contract_matrix.py index 25893b86..9b26e1c7 100644 --- a/backend/tests/modules/test_module_contract_matrix.py +++ b/backend/tests/modules/test_module_contract_matrix.py @@ -160,6 +160,7 @@ def _telegram(space, client): "stocktake": _inventory_probe("stocktake"), "telegram": _telegram, "updates": None, + "webhook": None, } diff --git a/backend/tests/modules/test_offstate_money.py b/backend/tests/modules/test_offstate_money.py index 778a1676..954b763d 100644 --- a/backend/tests/modules/test_offstate_money.py +++ b/backend/tests/modules/test_offstate_money.py @@ -25,9 +25,9 @@ from apps.makerspaces.models import Makerspace, MakerspaceMembership from apps.makerspaces.module_install import install_module, uninstall_module from apps.makerspaces.module_registry import core_module_keys, with_dependencies -from apps.payments.availability import online_payments_enabled +from apps.payments.availability import charge_tracking_enabled, online_payments_enabled from apps.payments.models import MakerspacePaymentSettings, Payment -from tests.return_helpers import authenticated_client, make_member +from tests.return_helpers import authenticated_client, make_member, settlement_payload pytestmark = pytest.mark.django_db @@ -49,7 +49,10 @@ def _configured_space(slug, domain, *, payments_on): name=slug, slug=slug, enabled_modules=modules, - enabled_features=["payments.enabled", f"payments.{domain}"], + enabled_features=[ + "payments.enabled", f"payments.{domain}", + "charges.enabled", f"charges.{domain}", + ], public_inventory_enabled=True, ) settings = MakerspacePaymentSettings(makerspace=space) @@ -136,14 +139,49 @@ def _invoke_charge_caller(domain, space, actor): @pytest.mark.parametrize("domain", tuple(DOMAIN_MODULES)) -def test_payments_off_makes_each_online_charge_caller_degrade_without_raising(domain): - """Domain success cannot depend on billing: OFF returns no charge, not an error.""" +def test_payments_off_still_records_the_debt_without_an_online_rail(domain): + """Uninstalling `payments` removes the RAIL, not the money owed. + + This used to assert the opposite -- no row at all -- which is exactly the bug the + charge-tracking split fixes: a space that takes cash lost every debt, so nothing was + pending, nothing was settleable and nothing reached the reports. The charge is now + recorded and reconciled by hand; only the Stripe/Razorpay rail disappears. + + `charges.*` surviving the uninstall is the point: those keys carry no `payments` + dependency, so `uninstall_module`'s dependent-feature pruning must not take them. + """ space = _configured_space(f"money-off-{domain}", domain, payments_on=False) actor = make_member(f"money-off-{domain}-member", space) subject, result = _invoke_charge_caller(domain, space, actor) assert online_payments_enabled(space, domain) is False + assert charge_tracking_enabled(space, domain) is True + assert result is not None + assert result.status == Payment.Status.PENDING + # No rail was raised. The row still carries this space's vendor provenance, because + # its credentials do resolve -- the MODULE is what is off. `unclaimed` is the state + # for a space with no credentials at all, covered in tests/payments/test_connect.py. + assert result.checkout_url == "" + assert result.stripe_checkout_url == "" + assert result.online_rail is None + assert type(subject).objects.filter(pk=subject.pk).exists() + assert Payment.objects.filter(makerspace=space).count() == 1 + + +@pytest.mark.parametrize("domain", tuple(DOMAIN_MODULES)) +def test_charge_tracking_off_records_nothing_at_all(domain): + """The "everything free" space: no rail AND no debt.""" + space = _configured_space(f"money-untracked-{domain}", domain, payments_on=False) + space.enabled_features = [ + key for key in space.enabled_features if not key.startswith("charges.") + ] + space.save(update_fields=["enabled_features", "updated_at"]) + actor = make_member(f"money-untracked-{domain}-member", space) + + subject, result = _invoke_charge_caller(domain, space, actor) + + assert charge_tracking_enabled(space, domain) is False assert result is None assert type(subject).objects.filter(pk=subject.pk).exists() assert not Payment.objects.filter(makerspace=space).exists() @@ -224,7 +262,9 @@ def test_payments_off_still_lets_staff_record_offline_money(): reverse( "payment-reconciliation-mark-offline", args=[space.pk, payment.pk], - ) + ), + settlement_payload(), + format="json", ) payment.refresh_from_db() diff --git a/backend/tests/operations/test_member_activity_report.py b/backend/tests/operations/test_member_activity_report.py index 4ac6cf9f..bacec65b 100644 --- a/backend/tests/operations/test_member_activity_report.py +++ b/backend/tests/operations/test_member_activity_report.py @@ -171,5 +171,6 @@ def test_member_activity_api_is_scoped_and_generic_exports_keep_the_registered_s def _header(response, fmt): if fmt == "csv": - return response.content.decode().splitlines()[0].split(",") + # Line 0 is the provenance row; the column header is line 1. + return response.content.decode().splitlines()[1].split(",") return [cell.value for cell in load_workbook(BytesIO(response.content)).active[1]] diff --git a/backend/tests/operations/test_report_exports_fablab.py b/backend/tests/operations/test_report_exports_fablab.py index e3395385..1ddd7723 100644 --- a/backend/tests/operations/test_report_exports_fablab.py +++ b/backend/tests/operations/test_report_exports_fablab.py @@ -142,8 +142,9 @@ def test_export_helper_compatibility_reexports_remain_live(): def _header(response, fmt): + # Line 0 of every CSV export is the provenance row; the column header follows it. if fmt == "csv": - return response.content.decode().splitlines()[0].split(",") + return response.content.decode().splitlines()[1].split(",") return [cell.value for cell in load_workbook(BytesIO(response.content)).active[1]] @@ -155,6 +156,6 @@ def _values(response, fmt): def _makerspace_ids(response, fmt): if fmt == "csv": - return {int(line.split(",", 1)[0]) for line in response.content.decode().splitlines()[1:]} + return {int(line.split(",", 1)[0]) for line in response.content.decode().splitlines()[2:]} rows = load_workbook(BytesIO(response.content)).active.iter_rows(min_row=2, values_only=True) return {int(row[0]) for row in rows} diff --git a/backend/tests/operations/test_report_provenance.py b/backend/tests/operations/test_report_provenance.py new file mode 100644 index 00000000..e1e91c00 --- /dev/null +++ b/backend/tests/operations/test_report_provenance.py @@ -0,0 +1,111 @@ +"""Every CSV/XLSX export carries a provenance row/sheet and CSV streams.""" + +from io import BytesIO + +import pytest +from django.http import StreamingHttpResponse +from openpyxl import load_workbook + +from apps.accounts.models import User +from apps.operations.report_exports import csv_bytes, xlsx_bytes +from apps.operations.report_exports_provenance import build_provenance +from tests.return_helpers import ( + authenticated_client, + make_issued_request, + make_member, + make_product, + make_space, + make_user, +) + +pytestmark = pytest.mark.django_db + + +def _provenance_fields(line): + assert line.startswith("# generated_at="), line + pairs = line[2:].split(" filters=", 1) + fields = dict(item.split("=", 1) for item in pairs[0].split(" ")) + fields["filters"] = pairs[1] + return fields + + +def test_csv_export_streams_and_starts_with_the_provenance_row(): + space = make_space("prov-csv") + manager = make_member("prov-csv-manager", space) + product = make_product(space, name="Prov Scope") + make_issued_request(space, manager, [(product, 1)]) + + response = authenticated_client(manager).get( + f"/api/v1/admin/makerspace/{space.id}/reports/most-lent/export" + ) + + assert response.status_code == 200 + assert isinstance(response, StreamingHttpResponse) + lines = b"".join(response.streaming_content).decode().splitlines() + fields = _provenance_fields(lines[0]) + assert fields["generated_by"] == manager.username + assert fields["makerspace_id"] == str(space.id) + assert fields["report_key"] == "most-lent" + assert fields["report_version"] == "1" + assert fields["filters"] == '{"grain":"day"}' + assert lines[1] == "product_name,times_lent,total_quantity_lent" + assert lines[2] == "Prov Scope,1,1" + + +def test_aggregate_and_ledger_exports_carry_provenance_too(): + space = make_space("prov-aggregate") + manager = make_member("prov-aggregate-manager", space) + superadmin = make_user( + "prov-super", role=User.Role.SUPERADMIN, access_status=User.AccessStatus.ACTIVE, + ) + + aggregate = authenticated_client(superadmin).get( + "/api/v1/admin/reports/most-lent/export?start=2026-01-01&end=2026-12-31" + ) + ledger = authenticated_client(manager).get( + f"/api/v1/admin/makerspace/{space.id}/ledger/export?format=csv&overdue=true" + ) + + aggregate_fields = _provenance_fields(aggregate.content.decode().splitlines()[0]) + assert aggregate_fields["makerspace_id"] == "all" + assert aggregate_fields["generated_by"] == superadmin.username + assert '"start":"2026-01-01T00:00:00' in aggregate_fields["filters"] + assert '"end":"2027-01-01T00:00:00' in aggregate_fields["filters"] + ledger_fields = _provenance_fields(ledger.content.decode().splitlines()[0]) + assert ledger_fields["report_key"] == "ledger" + assert ledger_fields["makerspace_id"] == str(space.id) + assert '"overdue":true' in ledger_fields["filters"] + + +def test_xlsx_export_has_a_provenance_sheet_after_the_report_sheet(): + space = make_space("prov-xlsx") + manager = make_member("prov-xlsx-manager", space) + make_product(space, name="Prov Meter", available_quantity=9, damaged_quantity=1, lost_quantity=0) + + response = authenticated_client(manager).get( + f"/api/v1/admin/makerspace/{space.id}/reports/damaged-lost/export?format=xlsx" + ) + + workbook = load_workbook(BytesIO(response.content)) + assert workbook.sheetnames == ["Report", "Provenance"] + assert [cell.value for cell in workbook["Report"][1]] == ["product_name", "damaged_quantity", "lost_quantity"] + provenance = {row[0]: row[1] for row in workbook["Provenance"].iter_rows(values_only=True)} + assert provenance["report_key"] == "damaged-lost" + assert provenance["generated_by"] == manager.username + assert provenance["makerspace_id"] == str(space.id) + assert provenance["report_version"] == "1" + + +def test_byte_renderers_share_the_provenance_with_the_http_helpers(): + provenance = build_provenance( + "most-lent", version=3, makerspace_id=None, generated_by="schedule:7", filters={"window_days": 7}, + ) + rows = [["product_name", "times_lent"], ["=SUM(A1)", 2]] + + csv_lines = csv_bytes(rows, provenance=provenance).decode().splitlines() + assert csv_lines[0] == provenance.header_line() + assert "makerspace_id=all report_key=most-lent report_version=3" in csv_lines[0] + assert csv_lines[2] == "'=SUM(A1),2" + workbook = load_workbook(BytesIO(xlsx_bytes(rows, provenance=provenance))) + assert workbook["Report"]["A2"].value == "'=SUM(A1)" + assert dict(workbook["Provenance"].iter_rows(values_only=True))["generated_by"] == "schedule:7" diff --git a/backend/tests/operations/test_report_schedules.py b/backend/tests/operations/test_report_schedules.py new file mode 100644 index 00000000..4963847b --- /dev/null +++ b/backend/tests/operations/test_report_schedules.py @@ -0,0 +1,230 @@ +"""Scheduled report delivery: API scoping, the beat-less run, skips and fail-safety.""" + +from datetime import timedelta + +import pytest +from django.utils import timezone + +from apps.audit.models import AuditLog +from apps.evidence.storage import StorageUnavailable +from apps.integrations.models_destinations import NotificationDestination +from apps.makerspaces.models import MakerspaceMembership +from apps.operations import report_schedule_services as services +from apps.operations.models import ReportDelivery, ReportSchedule +from apps.operations.report_schedule_services import run_report_schedules +from tests.module_helpers import disable_module +from tests.return_helpers import authenticated_client, make_member, make_product, make_space + +pytestmark = pytest.mark.django_db + + +@pytest.fixture(autouse=True) +def fake_storage(monkeypatch): + stored, deleted = {}, [] + monkeypatch.setattr(services, "store_report_object", lambda key, payload, fmt: stored.__setitem__(key, payload)) + monkeypatch.setattr(services, "signed_download_url", lambda key: f"https://bucket.test/{key}?sig=1") + monkeypatch.setattr(services, "delete_report_object", deleted.append) + return {"stored": stored, "deleted": deleted} + + +def _schedule(space, creator, **overrides): + values = { + "makerspace": space, "report_key": "most-lent", "format": "csv", "cadence": "daily", + "next_run_at": timezone.now() - timedelta(minutes=1), "created_by": creator, + "recipient_emails": ["ops@example.test"], + } + values.update(overrides) + return ReportSchedule.objects.create(**values) + + +def _url(space): + return f"/api/v1/admin/makerspaces/{space.id}/report-schedules" + + +def test_staff_api_creates_lists_updates_and_deletes_a_schedule(fake_storage): + space = make_space("sched-api") + manager = make_member("sched-api-manager", space) + destination = NotificationDestination.objects.create( + makerspace=space, channel="telegram", label="Ops room", telegram_chat_id="-100", + ) + client = authenticated_client(manager) + + created = client.post(_url(space), { + "report_key": "most-lent", "format": "xlsx", "cadence": "weekly", + "filters": {"window_days": 7}, "destination": destination.id, + "recipient_emails": ["a@example.test"], + }, format="json") + assert created.status_code == 201, created.data + assert created.data["filters"] == {"window_days": 7} + assert created.data["created_by"] == manager.id + schedule_id = created.data["id"] + assert AuditLog.objects.filter(action="report_schedule.created", meta__schedule_id=schedule_id).exists() + + listed = client.get(_url(space)) + assert [row["id"] for row in listed.data["results"]] == [schedule_id] + + patched = client.patch(f"/api/v1/admin/report-schedules/{schedule_id}", { + "filters": {"start": "2026-02-01", "end": "2026-01-01"}, + }, format="json") + assert patched.status_code == 400 + patched = client.patch(f"/api/v1/admin/report-schedules/{schedule_id}", {"cadence": "monthly"}, format="json") + assert patched.status_code == 200 and patched.data["cadence"] == "monthly" + + deleted = client.delete(f"/api/v1/admin/report-schedules/{schedule_id}") + assert deleted.status_code == 204 + assert not ReportSchedule.objects.filter(pk=schedule_id).exists() + + +def test_schedule_needs_a_recipient_and_a_destination_from_the_same_makerspace(): + space = make_space("sched-validate") + other = make_space("sched-validate-other") + manager = make_member("sched-validate-manager", space) + foreign = NotificationDestination.objects.create( + makerspace=other, channel="slack", label="Theirs", webhook_url="x", + ) + client = authenticated_client(manager) + + no_recipients = client.post(_url(space), {"report_key": "most-lent"}, format="json") + foreign_room = client.post(_url(space), {"report_key": "most-lent", "destination": foreign.id}, format="json") + not_exportable = client.post(_url(space), {"report_key": "summary", "recipient_emails": ["a@b.test"]}, format="json") + + assert no_recipients.status_code == 400 and "recipient_emails" in no_recipients.data + assert foreign_room.status_code == 400 and "destination" in foreign_room.data + assert not_exportable.status_code == 400 and "report_key" in not_exportable.data + + +def test_other_makerspace_manager_and_module_off_are_refused(): + space = make_space("sched-scope") + stranger_space = make_space("sched-scope-other") + manager = make_member("sched-scope-manager", space) + stranger = make_member("sched-scope-stranger", stranger_space) + schedule = _schedule(space, manager) + + stranger_client = authenticated_client(stranger) + assert stranger_client.get(_url(space)).status_code == 404 + assert stranger_client.patch( + f"/api/v1/admin/report-schedules/{schedule.id}", {"cadence": "weekly"}, format="json" + ).status_code == 404 + assert stranger_client.post(f"/api/v1/admin/report-schedules/{schedule.id}/run-now").status_code == 404 + + disable_module(space, "reports") + own_client = authenticated_client(manager) + assert own_client.get(_url(space)).status_code == 400 + assert own_client.post(f"/api/v1/admin/report-schedules/{schedule.id}/run-now").status_code == 400 + + +def test_due_schedule_delivers_once_with_a_signed_link_and_advances(fake_storage): + space = make_space("sched-run") + manager = make_member("sched-run-manager", space) + make_product(space, name="Sched Scope") + schedule = _schedule(space, manager, filters={"window_days": 7}) + now = timezone.now() + + counts = run_report_schedules(now=now) + again = run_report_schedules(now=now) + + assert counts == {"delivered": 1, "failed": 0, "skipped": 0} + assert again == {"delivered": 0, "failed": 0, "skipped": 0} + schedule.refresh_from_db() + assert schedule.last_run_at == now + assert schedule.next_run_at > now + delivery = ReportDelivery.objects.get(schedule=schedule) + assert delivery.status == "sent" and delivery.error == "" + assert delivery.object_key.startswith(f"reports/{space.id}/most-lent/") + assert delivery.expires_at > now + payload = fake_storage["stored"][delivery.object_key].decode() + assert payload.splitlines()[0].startswith(f"# generated_at={now.isoformat()} generated_by=schedule:{schedule.id} ") + assert "product_name,times_lent,total_quantity_lent" in payload + assert AuditLog.objects.filter( + action="report_schedule.delivered", meta__schedule_id=schedule.id, meta__delivery_id=delivery.id, + ).exists() + + +def test_archived_reports_disabled_and_creator_without_action_do_not_run(): + archived_space = make_space("sched-archived") + archived_manager = make_member("sched-archived-manager", archived_space) + _schedule(archived_space, archived_manager) + archived_space.archived_at = timezone.now() + archived_space.save(update_fields=["archived_at"]) + + disabled_space = make_space("sched-disabled") + disabled_manager = make_member("sched-disabled-manager", disabled_space) + _schedule(disabled_space, disabled_manager) + disable_module(disabled_space, "reports") + + lost_space = make_space("sched-lost") + lost_manager = make_member("sched-lost-manager", lost_space) + lost = _schedule(lost_space, lost_manager) + MakerspaceMembership.objects.filter(user=lost_manager).delete() + + counts = run_report_schedules() + + assert counts == {"delivered": 0, "failed": 0, "skipped": 1} + assert not ReportDelivery.objects.exists() + lost.refresh_from_db() + assert lost.next_run_at > timezone.now() + assert AuditLog.objects.filter(action="report_schedule.skipped", meta__schedule_id=lost.id).exists() + + +def test_storage_or_provider_failure_records_failed_and_never_raises(monkeypatch, fake_storage): + space = make_space("sched-fail") + manager = make_member("sched-fail-manager", space) + room = NotificationDestination.objects.create( + makerspace=space, channel="telegram", label="Room", telegram_chat_id="-1", + ) + space.set_telegram_bot_token("123:abc") + space.save(update_fields=["telegram_bot_token"]) + storage_broken = _schedule(space, manager) + room_broken = _schedule(space, manager, destination=room, recipient_emails=[]) + + def failing_store(key, payload, fmt): + if key.startswith(f"reports/{space.id}/") and storage_broken.deliveries.count() == 0 and not room_broken.deliveries.exists(): + raise StorageUnavailable() + + monkeypatch.setattr(services, "store_report_object", failing_store) + import apps.integrations.telegram as telegram + + monkeypatch.setattr(telegram, "send_message", lambda *args, **kwargs: (_ for _ in ()).throw(RuntimeError("boom"))) + + counts = run_report_schedules() + + assert counts["failed"] == 2 and counts["delivered"] == 0 + first = ReportDelivery.objects.get(schedule=storage_broken) + assert first.status == "failed" and first.error == "build:StorageUnavailable" and first.object_key == "" + second = ReportDelivery.objects.get(schedule=room_broken) + assert second.status == "failed" and second.error.startswith("telegram:notification_delivery_failed") + assert AuditLog.objects.filter(action="report_schedule.failed").count() == 2 + + +def test_email_module_off_marks_the_email_leg_failed_and_run_now_returns_the_delivery(): + space = make_space("sched-email-off") + manager = make_member("sched-email-off-manager", space) + schedule = _schedule(space, manager, next_run_at=timezone.now() + timedelta(days=1)) + disable_module(space, "email") + + response = authenticated_client(manager).post(f"/api/v1/admin/report-schedules/{schedule.id}/run-now") + + assert response.status_code == 200, response.data + assert response.data["status"] == "failed" + assert response.data["error"] == "email:skipped" + # The serializer presigns freshly (offline signature, no bucket round-trip) from the + # stored key, so the URL names the private `reports//` object. + assert f"/reports/{space.id}/most-lent/" in response.data["download_url"] + schedule.refresh_from_db() + assert schedule.last_run_at is not None + + +def test_expired_delivery_objects_are_swept_on_the_next_run(fake_storage): + space = make_space("sched-sweep") + manager = make_member("sched-sweep-manager", space) + schedule = _schedule(space, manager) + stale = ReportDelivery.objects.create( + schedule=schedule, object_key=f"reports/{space.id}/most-lent/old.csv", status="sent", + expires_at=timezone.now() - timedelta(hours=1), + ) + + run_report_schedules() + + stale.refresh_from_db() + assert stale.object_key == "" + assert fake_storage["deleted"] == [f"reports/{space.id}/most-lent/old.csv"] diff --git a/backend/tests/payments/test_archived_space_payments.py b/backend/tests/payments/test_archived_space_payments.py index 829639f8..ed954040 100644 --- a/backend/tests/payments/test_archived_space_payments.py +++ b/backend/tests/payments/test_archived_space_payments.py @@ -12,6 +12,7 @@ from apps.payments.models import MakerspacePaymentSettings, Payment from tests.payments.test_models import configured_settings from tests.return_helpers import ( + enable_online_rail, authenticated_client, make_member, make_space, @@ -95,11 +96,12 @@ def test_member_payment_history_survives_archival_without_leaking_other_rows(): def test_web_checkout_tolerates_archival_for_an_active_member(monkeypatch): space = make_space("archived-payment-checkout") member = make_member("archived-payment-checkout-member", space) + enable_online_rail(space, "membership") configured_settings(space) payment = _membership_payment(space, member) _archive(space) monkeypatch.setattr( - "apps.payments.services.stripe_client.create_checkout_session", + "apps.payments.services_checkout.stripe_client.create_checkout_session", lambda *_args, **_kwargs: { "id": "cs_archived", "url": "https://checkout.stripe.test/cs_archived", @@ -207,7 +209,7 @@ def test_revoked_membership_cannot_read_archived_payment_history(): ) assert response.status_code == 403 - assert response.data == {"detail": "An active membership is required."} + assert response.data == {"detail": "An active membership or an existing charge is required."} @pytest.mark.parametrize( @@ -228,4 +230,4 @@ def test_blocked_account_cannot_read_archived_payment_history(access_status): ) assert response.status_code == 403 - assert response.data == {"detail": "An active membership is required."} + assert response.data == {"detail": "An active membership or an existing charge is required."} diff --git a/backend/tests/payments/test_borrower_charge_visibility.py b/backend/tests/payments/test_borrower_charge_visibility.py new file mode 100644 index 00000000..1afba4a8 --- /dev/null +++ b/backend/tests/payments/test_borrower_charge_visibility.py @@ -0,0 +1,110 @@ +"""The admit path of the widened member payment-visibility check (D8). + +`member_may_see_own_charges` was widened so an account-only BORROWER can read a debt +raised in their own name: a loan deposit or late fee is charged to whoever borrowed the +hardware, and borrowing needs an active account rather than a membership. Before the +widening those people were told "an active membership is required" about their own money. + +The refusal paths (revoked membership, blocked account) are covered in +`test_archived_space_payments.py`. This file covers the ADMIT path and its edges, because +widening a money surface is where a scoping check gets quietly lost. +""" + +from decimal import Decimal + +import pytest + +from apps.accounts.models import User +from apps.makerspaces.models import MakerspaceMembership +from apps.payments.models import Payment +from tests.return_helpers import ( + authenticated_client, + make_accepted_request, + make_product, + make_space, + make_user, +) + + +pytestmark = pytest.mark.django_db + + +def _loan_deposit(space, borrower, *, amount="25.00"): + """A deposit charged against a real hardware request, as the model requires.""" + request = make_accepted_request(space, make_product(space), 1) + return Payment.objects.create( + makerspace=space, + subject_type=Payment.SubjectType.LOAN_DEPOSIT, + subject_id=request.pk, + member=borrower, + amount=Decimal(amount), + currency="usd", + created_by=borrower, + provider=Payment.Provider.UNCLAIMED, + subject_label="Loan deposit", + ) + + +def _borrower(space, username): + """An active account with NO membership in the space -- the whole point here.""" + user = make_user(username, access_status=User.AccessStatus.ACTIVE) + assert not MakerspaceMembership.objects.filter(makerspace=space, user=user).exists() + return user + + +def test_account_only_borrower_reads_the_debt_in_their_own_name(): + space = make_space("borrower-charge-visible") + borrower = _borrower(space, "borrower-charge-visible-user") + payment = _loan_deposit(space, borrower) + + response = authenticated_client(borrower).get( + f"/api/v1/member/makerspaces/{space.pk}/payments" + ) + + assert response.status_code == 200 + assert [row["id"] for row in response.data] == [payment.pk] + assert response.data[0]["amount"] == "25.00" + + +def test_an_account_with_no_charge_here_is_still_refused(): + """Owning a charge is what admits; being signed in is not.""" + space = make_space("borrower-charge-none") + stranger = _borrower(space, "borrower-charge-none-user") + + response = authenticated_client(stranger).get( + f"/api/v1/member/makerspaces/{space.pk}/payments" + ) + + assert response.status_code == 403 + assert response.data == { + "detail": "An active membership or an existing charge is required." + } + + +def test_one_borrowers_charge_never_admits_another_borrower(): + """The admit path is ownership-scoped, so it can only ever show the caller's own.""" + space = make_space("borrower-charge-scoped") + owner = _borrower(space, "borrower-charge-scoped-owner") + other = _borrower(space, "borrower-charge-scoped-other") + _loan_deposit(space, owner) + + response = authenticated_client(other).get( + f"/api/v1/member/makerspaces/{space.pk}/payments" + ) + + assert response.status_code == 403 + + +def test_a_restricted_borrower_is_refused_their_own_charge(): + """The widening kept every account-status clause: restriction still shuts the door.""" + space = make_space("borrower-charge-restricted") + borrower = _borrower(space, "borrower-charge-restricted-user") + _loan_deposit(space, borrower) + borrower.access_status = User.AccessStatus.RESTRICTED + borrower.save(update_fields=["access_status"]) + + response = authenticated_client(borrower).get( + f"/api/v1/member/makerspaces/{space.pk}/payments" + ) + + assert response.status_code == 403 diff --git a/backend/tests/payments/test_connect.py b/backend/tests/payments/test_connect.py index d973b9cf..aa633fb8 100644 --- a/backend/tests/payments/test_connect.py +++ b/backend/tests/payments/test_connect.py @@ -16,13 +16,25 @@ from apps.payments.services import create_checkout_url, create_payment from apps.payments.stripe_client import PaymentsUnavailable from tests.payments.test_machine_payments import service_request -from tests.return_helpers import make_member, make_space +from tests.return_helpers import ( + enable_online_rail, + make_member, + make_space, + settlement_payload, +) pytestmark = pytest.mark.django_db -def test_create_payment_fails_closed_when_no_payment_source_resolves(monkeypatch): +def test_create_payment_records_an_unclaimed_debt_when_no_payment_source_resolves(monkeypatch): + """No gateway is no longer a reason to lose the debt. + + This used to fail closed and record nothing, which made money owed invisible for any + space without credentials -- no pending row, nothing to reconcile, nothing in reports. + Recording the charge and collecting it online are now separate concerns: the row is + raised as `unclaimed` and the first checkout that reaches a provider claims it. + """ makerspace = make_space("connect-source-disappeared") actor = make_member("connect-source-disappeared-member", makerspace) subject = service_request(makerspace, actor) @@ -31,18 +43,23 @@ def test_create_payment_fails_closed_when_no_payment_source_resolves(monkeypatch lambda _makerspace: None, ) - with pytest.raises(PaymentsUnavailable): - create_payment( - makerspace=makerspace, - subject_type="machine_service_request", - subject_id=subject.id, - member=actor, - amount=Decimal("10.00"), - currency="usd", - created_by=actor, - ) - - assert not Payment.objects.filter( + payment = create_payment( + makerspace=makerspace, + subject_type="machine_service_request", + subject_id=subject.id, + member=actor, + amount=Decimal("10.00"), + currency="usd", + created_by=actor, + ) + + assert payment.status == Payment.Status.PENDING + assert payment.provider == Payment.Provider.UNCLAIMED + # No rail was reached, so nothing may look like a provider handle. + assert payment.stripe_connected_account_id is None + assert payment.stripe_application_fee_amount == 0 + assert payment.checkout_url == "" + assert Payment.objects.filter( subject_type="machine_service_request", subject_id=subject.id ).exists() @@ -131,7 +148,7 @@ def test_connect_callback_consumes_state_stores_account_and_rejects_replay( ) raw_state = parse_qs(urlparse(started.data["authorize_url"]).query)["state"][0] monkeypatch.setattr( - "apps.payments.views_connect.exchange_oauth_code", + "apps.payments_rail.views_connect.exchange_oauth_code", lambda code: "acct_callback", ) fetches = [] @@ -151,7 +168,7 @@ def fetch_mapped_account(account_id): } monkeypatch.setattr( - "apps.payments.views_connect.fetch_account", + "apps.payments_rail.views_connect.fetch_account", fetch_mapped_account, ) @@ -236,10 +253,10 @@ def test_connect_callback_replacement_revokes_previous_account(settings, monkeyp ) raw_state = parse_qs(urlparse(started.data["authorize_url"]).query)["state"][0] monkeypatch.setattr( - "apps.payments.views_connect.exchange_oauth_code", lambda _code: "acct_new" + "apps.payments_rail.views_connect.exchange_oauth_code", lambda _code: "acct_new" ) monkeypatch.setattr( - "apps.payments.views_connect.fetch_account", + "apps.payments_rail.views_connect.fetch_account", lambda account_id: { "id": account_id, "charges_enabled": True, @@ -249,7 +266,7 @@ def test_connect_callback_replacement_revokes_previous_account(settings, monkeyp ) revoked = [] monkeypatch.setattr( - "apps.payments.views_connect.deauthorize_account", revoked.append + "apps.payments_rail.views_connect.deauthorize_account", revoked.append ) response = APIClient().get( @@ -298,11 +315,11 @@ def test_older_oauth_callback_cannot_overwrite_newer_onboarding(settings, monkey second_state = parse_qs(urlparse(second.data["authorize_url"]).query)["state"][0] exchanged = [] monkeypatch.setattr( - "apps.payments.views_connect.exchange_oauth_code", + "apps.payments_rail.views_connect.exchange_oauth_code", lambda code: exchanged.append(code) or f"acct_{code}", ) monkeypatch.setattr( - "apps.payments.views_connect.fetch_account", + "apps.payments_rail.views_connect.fetch_account", lambda account_id: { "id": account_id, "charges_enabled": True, @@ -358,7 +375,7 @@ def test_connect_checkout_uses_direct_charge_and_snapshots_fee(settings, monkeyp created_by=actor, ) monkeypatch.setattr( - "apps.payments.services.refresh_connected_account", + "apps.payments.services_checkout.refresh_connected_account", lambda _merchant: merchant, ) calls = [] @@ -448,6 +465,7 @@ def test_raw_payment_fails_closed_after_provider_switch_to_connect( platform.stripe_connect_client_id = "ca_platform" platform.save() makerspace = make_space("raw-snapshot-provider-switch") + enable_online_rail(makerspace, "machines") merchant = MakerspacePaymentSettings.objects.create( makerspace=makerspace, connect_account_id="acct_rawswitch", @@ -476,11 +494,11 @@ def test_raw_payment_fails_closed_after_provider_switch_to_connect( checkout_calls = [] expiry_calls = [] monkeypatch.setattr( - "apps.payments.services.stripe_client.create_checkout_session", + "apps.payments.services_checkout.stripe_client.create_checkout_session", lambda source, **params: checkout_calls.append((source, params)), ) monkeypatch.setattr( - "apps.payments.services.stripe_client.expire_checkout_session", + "apps.payments.services_checkout.stripe_client.expire_checkout_session", lambda source, session_id: expiry_calls.append((source, session_id)), ) client = APIClient() @@ -500,6 +518,8 @@ def test_raw_payment_fails_closed_after_provider_switch_to_connect( ) reconciled = client.post( f"/api/v1/admin/machine-service/payments/{payment.pk}/mark-offline", + settlement_payload(), + format="json", HTTP_HOST="localhost", ) @@ -519,6 +539,7 @@ def test_connect_payment_keeps_snapshot_after_provider_switch_to_raw( platform.stripe_connect_client_id = "ca_platform" platform.save() makerspace = make_space("connect-snapshot-provider-switch") + enable_online_rail(makerspace, "machines") merchant = MakerspacePaymentSettings.objects.create( makerspace=makerspace, connect_account_id="acct_connectswitch", @@ -543,7 +564,7 @@ def test_connect_payment_keeps_snapshot_after_provider_switch_to_raw( merchant.set_stripe_webhook_secret("whsec_raw") merchant.save() monkeypatch.setattr( - "apps.payments.services.refresh_connected_account", lambda _merchant: merchant + "apps.payments.services_checkout.refresh_connected_account", lambda _merchant: merchant ) checkout_sources = [] expiry_sources = [] @@ -556,10 +577,10 @@ def create_session(source, **_params): } monkeypatch.setattr( - "apps.payments.services.stripe_client.create_checkout_session", create_session + "apps.payments.services_checkout.stripe_client.create_checkout_session", create_session ) monkeypatch.setattr( - "apps.payments.services.stripe_client.expire_checkout_session", + "apps.payments.services_checkout.stripe_client.expire_checkout_session", lambda source, _session_id: expiry_sources.append(source), ) client = APIClient() @@ -571,6 +592,8 @@ def create_session(source, **_params): ) reconciled = client.post( f"/api/v1/admin/machine-service/payments/{payment.pk}/mark-offline", + settlement_payload(), + format="json", HTTP_HOST="localhost", ) @@ -625,7 +648,7 @@ def test_connect_webhook_verifies_platform_secret_and_routes_snapshot( }, } construct = Mock(return_value=event) - monkeypatch.setattr("apps.payments.views_connect.construct_event", construct) + monkeypatch.setattr("apps.payments_rail.views_connect.construct_event", construct) response = APIClient().generic( "POST", @@ -691,7 +714,7 @@ def test_connect_expired_webhook_confirms_checkout_session_is_closed( "data": {"object": {"id": "cs_connect_expired"}}, } monkeypatch.setattr( - "apps.payments.views_connect.construct_event", Mock(return_value=event) + "apps.payments_rail.views_connect.construct_event", Mock(return_value=event) ) response = APIClient().generic( @@ -712,15 +735,15 @@ def test_connect_expired_webhook_confirms_checkout_session_is_closed( created = [] monkeypatch.setattr( - "apps.payments.services.member_payment_return_url", + "apps.payments.services_checkout.member_payment_return_url", lambda _makerspace: "https://space.example/member", ) monkeypatch.setattr( - "apps.payments.services.refresh_connected_account", + "apps.payments.services_checkout.refresh_connected_account", lambda _merchant: merchant, ) monkeypatch.setattr( - "apps.payments.services.stripe_client.create_checkout_session", + "apps.payments.services_checkout.stripe_client.create_checkout_session", lambda _source, **params: created.append(params) or { "id": "cs_connect_replacement", diff --git a/backend/tests/payments/test_connect_corrective.py b/backend/tests/payments/test_connect_corrective.py index af201e38..b0ca7ff8 100644 --- a/backend/tests/payments/test_connect_corrective.py +++ b/backend/tests/payments/test_connect_corrective.py @@ -68,7 +68,7 @@ def test_callback_rejects_actor_who_lost_account_authority( actor.save(update_fields=["access_status"]) exchanges = [] monkeypatch.setattr( - "apps.payments.views_connect.exchange_oauth_code", + "apps.payments_rail.views_connect.exchange_oauth_code", lambda code: exchanges.append(code), ) @@ -107,7 +107,7 @@ def test_callback_rejects_space_that_is_no_longer_authorized( makerspace.save(update_fields=["superadmin_access_enabled"]) exchanges = [] monkeypatch.setattr( - "apps.payments.views_connect.exchange_oauth_code", + "apps.payments_rail.views_connect.exchange_oauth_code", lambda code: exchanges.append(code), ) @@ -140,7 +140,7 @@ def test_callback_rejects_actor_after_manage_action_is_removed(settings, monkeyp membership.save(update_fields=["role", "assigned_role"]) exchanges = [] monkeypatch.setattr( - "apps.payments.views_connect.exchange_oauth_code", + "apps.payments_rail.views_connect.exchange_oauth_code", lambda code: exchanges.append(code), ) @@ -160,7 +160,7 @@ def test_callback_rejects_actor_after_membership_is_revoked(settings, monkeypatc actor.makerspace_memberships.filter(makerspace=makerspace).update(status="revoked") exchanges = [] monkeypatch.setattr( - "apps.payments.views_connect.exchange_oauth_code", + "apps.payments_rail.views_connect.exchange_oauth_code", lambda code: exchanges.append(code), ) @@ -189,14 +189,14 @@ def exchange(_code): ) return "acct_laterevocation" - monkeypatch.setattr("apps.payments.views_connect.exchange_oauth_code", exchange) + monkeypatch.setattr("apps.payments_rail.views_connect.exchange_oauth_code", exchange) revoked = [] monkeypatch.setattr( "apps.payments.connect.deauthorize_account", lambda account_id: revoked.append(account_id), ) monkeypatch.setattr( - "apps.payments.views_connect.fetch_account", + "apps.payments_rail.views_connect.fetch_account", lambda account_id: { "id": account_id, "charges_enabled": True, @@ -237,12 +237,12 @@ def test_callback_cannot_replace_account_with_pending_payments(settings, monkeyp created_by=actor, ) monkeypatch.setattr( - "apps.payments.views_connect.exchange_oauth_code", + "apps.payments_rail.views_connect.exchange_oauth_code", lambda _code: "acct_replacement", ) fetched = [] monkeypatch.setattr( - "apps.payments.views_connect.fetch_account", + "apps.payments_rail.views_connect.fetch_account", lambda account_id: fetched.append(account_id), ) revoked = [] @@ -271,11 +271,11 @@ def test_callback_redirects_verified_domain_to_single_tenant_staff_path( makerspace.frontend_domain_status = makerspace.DomainStatus.VERIFIED makerspace.save(update_fields=["frontend_domain", "frontend_domain_status"]) monkeypatch.setattr( - "apps.payments.views_connect.exchange_oauth_code", + "apps.payments_rail.views_connect.exchange_oauth_code", lambda _code: "acct_verifieddomain", ) monkeypatch.setattr( - "apps.payments.views_connect.fetch_account", + "apps.payments_rail.views_connect.fetch_account", lambda account_id: { "id": account_id, "charges_enabled": True, diff --git a/backend/tests/payments/test_domain_charges.py b/backend/tests/payments/test_domain_charges.py index 9d9e2317..ff80ceb3 100644 --- a/backend/tests/payments/test_domain_charges.py +++ b/backend/tests/payments/test_domain_charges.py @@ -29,7 +29,7 @@ def enable_payments(makerspace, domain, *, currency="usd"): # The A6 master switch is an additive AND, so a per-domain feature alone is # no longer enough to charge. - makerspace.enabled_features = ["payments.enabled", f"payments.{domain}"] + makerspace.enabled_features = ["payments.enabled", f"payments.{domain}", "charges.enabled", f"charges.{domain}"] makerspace.save(update_fields=["enabled_features", "updated_at"]) settings = configured_settings(makerspace) settings.default_currency = currency @@ -162,7 +162,15 @@ def test_membership_activation_and_legacy_reactivation_reuse_one_payment(monkeyp assert Payment.objects.get(pk=payment.pk).status == Payment.Status.PENDING -def test_zero_feature_credentials_and_membership_module_disable_charging(monkeypatch): +def test_zero_amount_and_membership_module_disable_charging(monkeypatch): + """What still suppresses a charge entirely, and what only suppresses the RAIL. + + Rewritten with the charge-tracking split. A missing online feature or missing + credentials no longer destroys the debt -- that was the bug: a cash-only space lost + every charge. Those two cases now record a PENDING row with no checkout behind it. + Only a zero amount (nothing is owed) and a missing domain module (the space does not + run that domain at all) still mean no row exists. + """ monkeypatch.setattr(booking_payments, "create_checkout", lambda _payment: None) actor_and_spaces = [] for suffix in ("zero", "feature", "credentials"): @@ -172,7 +180,7 @@ def test_zero_feature_credentials_and_membership_module_disable_charging(monkeyp zero, feature_off, credentials_off = actor_and_spaces enable_payments(zero[0], "bookings") configured_settings(feature_off[0]) - credentials_off[0].enabled_features = ["payments.enabled", "payments.bookings"] + credentials_off[0].enabled_features = ["payments.enabled", "payments.bookings", "charges.enabled", "charges.bookings"] credentials_off[0].save(update_fields=["enabled_features", "updated_at"]) for makerspace, actor in actor_and_spaces: @@ -188,7 +196,18 @@ def test_zero_feature_credentials_and_membership_module_disable_charging(monkeyp member=actor, actor=actor, ) - assert not Payment.objects.filter(subject_id=booking.pk).exists() + charge = Payment.objects.filter(subject_id=booking.pk).first() + if makerspace == zero[0]: + assert charge is None + continue + # The debt is on the books; only the rail is missing. + assert charge is not None + assert charge.status == Payment.Status.PENDING + assert charge.checkout_url == "" + assert charge.stripe_checkout_url == "" + # Nothing resolved for the credentials-off space, so its row has no vendor at all. + credentials_off_charge = Payment.objects.get(makerspace=credentials_off[0]) + assert credentials_off_charge.provider == Payment.Provider.UNCLAIMED membership_space = make_space("payments-off-membership-module") membership_actor = make_member( diff --git a/backend/tests/payments/test_machine_payments.py b/backend/tests/payments/test_machine_payments.py index 7826b8e7..de1a9226 100644 --- a/backend/tests/payments/test_machine_payments.py +++ b/backend/tests/payments/test_machine_payments.py @@ -10,7 +10,7 @@ from apps.payments.models import Payment from apps.payments.services import apply_webhook_event, mark_offline, waive from tests.payments.test_models import configured_settings -from tests.return_helpers import make_member, make_space +from tests.return_helpers import enable_online_rail, make_member, make_space, settlement_details pytestmark = pytest.mark.django_db @@ -41,7 +41,7 @@ def test_terminal_payment_is_immutable_and_reconciliation_is_audited(): space = make_space("c3-payment-transition") actor = make_member("c3-payment-transition-user", space) payment = payment_for(service_request(space, actor), actor) - assert mark_offline(payment, actor).status == Payment.Status.PAID_OFFLINE + assert mark_offline(payment, actor, settlement_details()).status == Payment.Status.PAID_OFFLINE payment.amount = Decimal("9.00") with pytest.raises(ValidationError): payment.save() @@ -53,7 +53,7 @@ def test_payment_delete_is_immutable_outside_purge(): actor = make_member("c3-payment-delete-user", space) paid = payment_for(service_request(space, actor), actor) pending = payment_for(service_request(space, actor), actor) - mark_offline(paid, actor) + mark_offline(paid, actor, settlement_details()) for payment in (paid, pending): with pytest.raises(InternalError): @@ -63,7 +63,7 @@ def test_payment_delete_is_immutable_outside_purge(): def test_verified_webhook_is_idempotent_and_marks_matching_checkout_paid(): space = make_space("c3-payment-webhook") - space.enabled_features = ["payments.enabled", "payments.machines"] + space.enabled_features = ["payments.enabled", "payments.machines", "charges.enabled", "charges.machines"] space.save(update_fields=["enabled_features", "updated_at"]) configured_settings(space) actor = make_member("c3-payment-webhook-user", space) @@ -93,7 +93,7 @@ def test_async_checkout_webhook_settles_matching_pending_payment(): def test_completion_creates_payment_and_checkout_failure_never_blocks(monkeypatch): space = make_space("c3-payment-complete") - space.enabled_features = ["payments.enabled", "payments.machines"] + space.enabled_features = ["payments.enabled", "payments.machines", "charges.enabled", "charges.machines"] space.save(update_fields=["enabled_features", "updated_at"]) configured_settings(space) actor = make_member("c3-payment-complete-user", space) @@ -119,8 +119,8 @@ def test_reconciliation_expires_an_open_checkout_session(monkeypatch): expired = [] def expire(source, session_id): expired.append((source, session_id)) - monkeypatch.setattr("apps.payments.services.stripe_client.expire_checkout_session", expire) - mark_offline(payment, actor) + monkeypatch.setattr("apps.payments.services_checkout.stripe_client.expire_checkout_session", expire) + mark_offline(payment, actor, settlement_details()) assert expired[0][0].provider == Payment.StripeProvider.RAW assert expired[0][0].connected_account_id is None assert expired[0][1] == "cs_expire" @@ -133,8 +133,8 @@ def test_terminal_payment_webhook_is_audited_as_an_anomaly(monkeypatch): actor = make_member("c3-payment-terminal-webhook-user", space) payment = payment_for(service_request(space, actor), actor) Payment.objects.filter(pk=payment.pk).update(stripe_checkout_session_id="cs_terminal") - monkeypatch.setattr("apps.payments.services.stripe_client.expire_checkout_session", lambda *_: None) - mark_offline(payment, actor) + monkeypatch.setattr("apps.payments.services_checkout.stripe_client.expire_checkout_session", lambda *_: None) + mark_offline(payment, actor, settlement_details()) event = {"id": "evt_terminal", "type": "checkout.session.completed", "data": {"object": {"id": "cs_terminal", "payment_status": "paid"}}} result = apply_webhook_event(space, event) assert result.status == Payment.Status.PAID_OFFLINE @@ -148,11 +148,12 @@ def test_member_can_generate_a_missing_checkout_url(monkeypatch): from apps.audit.models import AuditLog from rest_framework.test import APIClient space = make_space("c3-payment-regenerate") + enable_online_rail(space, "machines") configured_settings(space) actor = make_member("c3-payment-regenerate-user", space) payment = payment_for(service_request(space, actor), actor) - monkeypatch.setattr("apps.payments.services.member_payment_return_url", lambda _: "https://space.example/member") - monkeypatch.setattr("apps.payments.services.stripe_client.create_checkout_session", lambda *_args, **_kwargs: {"id": "cs_regenerated", "url": "https://checkout.stripe.test/cs_regenerated"}) + monkeypatch.setattr("apps.payments.services_checkout.member_payment_return_url", lambda _: "https://space.example/member") + monkeypatch.setattr("apps.payments.services_checkout.stripe_client.create_checkout_session", lambda *_args, **_kwargs: {"id": "cs_regenerated", "url": "https://checkout.stripe.test/cs_regenerated"}) client = APIClient() client.force_authenticate(actor) response = client.post(f"/api/v1/member/makerspaces/{space.pk}/payments/{payment.pk}/checkout") diff --git a/backend/tests/payments/test_mobile_payments.py b/backend/tests/payments/test_mobile_payments.py index c8aaae57..69df49ae 100644 --- a/backend/tests/payments/test_mobile_payments.py +++ b/backend/tests/payments/test_mobile_payments.py @@ -13,13 +13,15 @@ from apps.payments.services_mobile import create_mobile_intent from tests.device_helpers import make_native_app_registration from tests.payments.test_machine_payments import service_request -from tests.return_helpers import make_member, make_space +from tests.return_helpers import enable_online_rail, make_member, make_space pytestmark = pytest.mark.django_db def configured_payment(makerspace, member): + # A native payment intent is an ONLINE rail, so the space must actually have one. + enable_online_rail(makerspace, "machines") payment_settings, _ = MakerspacePaymentSettings.objects.get_or_create( makerspace=makerspace ) @@ -161,7 +163,7 @@ def test_mobile_endpoint_sanitizes_provider_failure(monkeypatch): payment = configured_payment(makerspace, member) client = device_client(member, makerspace) monkeypatch.setattr( - "apps.payments.views_member_mobile.create_mobile_intent", + "apps.payments_rail.views_member_mobile.create_mobile_intent", lambda *args, **kwargs: (_ for _ in ()).throw( RuntimeError("provider secret diagnostic") ), diff --git a/backend/tests/payments/test_payment_locking.py b/backend/tests/payments/test_payment_locking.py index ea4818c3..8a348181 100644 --- a/backend/tests/payments/test_payment_locking.py +++ b/backend/tests/payments/test_payment_locking.py @@ -51,7 +51,7 @@ def create_session(source, **_params): return {"id": "cs_rotation_lock", "url": "https://checkout.test/locked"} monkeypatch.setattr( - "apps.payments.services.stripe_client.create_checkout_session", create_session + "apps.payments.services_checkout.stripe_client.create_checkout_session", create_session ) update_settings = serializers_payments.update_payment_settings @@ -165,10 +165,10 @@ def create_session(source, **_params): paused_validation, ) monkeypatch.setattr( - "apps.payments.services.refresh_connected_account", lambda _merchant: merchant + "apps.payments.services_checkout.refresh_connected_account", lambda _merchant: merchant ) monkeypatch.setattr( - "apps.payments.services.stripe_client.create_checkout_session", create_session + "apps.payments.services_checkout.stripe_client.create_checkout_session", create_session ) def rotation_worker(): diff --git a/backend/tests/payments/test_platform_payment_settings_validation.py b/backend/tests/payments/test_platform_payment_settings_validation.py index 54df5ab2..265d8cff 100644 --- a/backend/tests/payments/test_platform_payment_settings_validation.py +++ b/backend/tests/payments/test_platform_payment_settings_validation.py @@ -10,7 +10,7 @@ from apps.payments.models import Payment, PlatformStripeConnectSettings from apps.payments.services import mark_offline from tests.payments.test_machine_payments import service_request -from tests.return_helpers import make_member, make_space, make_user +from tests.return_helpers import make_member, make_space, make_user, settlement_details pytestmark = pytest.mark.django_db @@ -117,11 +117,11 @@ def test_failed_connect_expiry_keeps_platform_secret_rotation_and_clear_blocked( ) payment = Payment.objects.get(stripe_provider=Payment.StripeProvider.CONNECT) monkeypatch.setattr( - "apps.payments.services.stripe_client.expire_checkout_session", + "apps.payments.services_checkout.stripe_client.expire_checkout_session", lambda *_args: False, ) - mark_offline(payment, payment.member) + mark_offline(payment, payment.member, settlement_details()) client = APIClient() client.force_authenticate(superadmin) response = client.patch( @@ -143,11 +143,11 @@ def test_successful_connect_expiry_allows_platform_secret_rotation( platform, superadmin = pending_connect_settings("platform-expiry-succeeded") payment = Payment.objects.get(stripe_provider=Payment.StripeProvider.CONNECT) monkeypatch.setattr( - "apps.payments.services.stripe_client.expire_checkout_session", + "apps.payments.services_checkout.stripe_client.expire_checkout_session", lambda *_args: True, ) - mark_offline(payment, payment.member) + mark_offline(payment, payment.member, settlement_details()) client = APIClient() client.force_authenticate(superadmin) response = client.patch( diff --git a/backend/tests/payments/test_purged_machine_charge.py b/backend/tests/payments/test_purged_machine_charge.py index 1b202580..4153a6e9 100644 --- a/backend/tests/payments/test_purged_machine_charge.py +++ b/backend/tests/payments/test_purged_machine_charge.py @@ -50,7 +50,7 @@ def test_a_machine_service_charge_snapshots_no_member_typed_text(): while the PII contract was broken. Same trap `CLAUDE.md` records for event charging. """ space = make_space("machine-label-pii") - space.enabled_features = ["payments.enabled", "payments.machines"] + space.enabled_features = ["payments.enabled", "payments.machines", "charges.enabled", "charges.machines"] space.save(update_fields=["enabled_features", "updated_at"]) configured_settings(space) actor = make_member("machine-label-pii-user", space) diff --git a/backend/tests/payments/test_raw_credential_session_liveness.py b/backend/tests/payments/test_raw_credential_session_liveness.py index fe7a92ae..b63292fd 100644 --- a/backend/tests/payments/test_raw_credential_session_liveness.py +++ b/backend/tests/payments/test_raw_credential_session_liveness.py @@ -1,3 +1,4 @@ +from tests.return_helpers import settlement_details import pytest from rest_framework.test import APIClient @@ -21,10 +22,14 @@ def test_raw_rotation_blocks_terminal_session_when_remote_closure_is_unconfirmed stripe_checkout_url="https://checkout.stripe.test/raw-abandoned" ) monkeypatch.setattr( - "apps.payments.services.stripe_client.expire_checkout_session", + "apps.payments.services_checkout.stripe_client.expire_checkout_session", lambda *_args: False, ) - reconcile(payment, manager) + # `mark_offline` requires the receipt; `waive` takes none, since no money moved. + if reconcile is mark_offline: + reconcile(payment, manager, settlement_details()) + else: + reconcile(payment, manager) payment.refresh_from_db() assert payment.status in {Payment.Status.PAID_OFFLINE, Payment.Status.WAIVED} assert payment.stripe_checkout_session_expired_at is None @@ -52,10 +57,10 @@ def test_raw_rotation_persists_authoritatively_closed_terminal_session(monkeypat ) payment = Payment.objects.get(makerspace=makerspace) monkeypatch.setattr( - "apps.payments.services.stripe_client.expire_checkout_session", + "apps.payments.services_checkout.stripe_client.expire_checkout_session", lambda *_args: False, ) - mark_offline(payment, manager) + mark_offline(payment, manager, settlement_details()) monkeypatch.setattr( "apps.payments.stripe_client.checkout_session_is_closed", lambda *_args: True, diff --git a/backend/tests/payments/test_reconciliation.py b/backend/tests/payments/test_reconciliation.py index 420dbdbd..36a9d3bc 100644 --- a/backend/tests/payments/test_reconciliation.py +++ b/backend/tests/payments/test_reconciliation.py @@ -15,6 +15,7 @@ make_member, make_space, make_user, + settlement_payload, ) pytestmark = pytest.mark.django_db @@ -124,7 +125,7 @@ def test_subject_specific_authority_and_makerspace_mismatch(): ) assert authenticated_client(booking_manager).post( - action_url(space, booking, "mark-offline") + action_url(space, booking, "mark-offline"), settlement_payload(), format="json" ).status_code == 200 assert authenticated_client(booking_manager).post( action_url(space, event, "waive") @@ -146,7 +147,9 @@ def test_bulk_is_all_or_nothing_preserves_input_order_and_audits_each_change(): bulk_url = f"{list_url(space)}/bulk/mark-offline" conflict = authenticated_client(manager).post( - bulk_url, {"ids": [first.id, terminal.id]}, format="json" + bulk_url, + {"ids": [first.id, terminal.id], **settlement_payload()}, + format="json", ) assert conflict.status_code == 409 first.refresh_from_db() @@ -154,7 +157,9 @@ def test_bulk_is_all_or_nothing_preserves_input_order_and_audits_each_change(): assert AuditLog.objects.filter(target_id=str(first.id)).count() == 0 response = authenticated_client(manager).post( - bulk_url, {"ids": [pending.id, first.id]}, format="json" + bulk_url, + {"ids": [pending.id, first.id], **settlement_payload()}, + format="json", ) assert response.status_code == 200 assert [row["id"] for row in response.data] == [pending.id, first.id] @@ -186,7 +191,7 @@ def test_checkout_expiry_failure_is_best_effort(monkeypatch): row = payment(space, manager, Payment.SubjectType.BOOKING, 40) Payment.objects.filter(pk=row.pk).update(stripe_checkout_session_id="cs_live") monkeypatch.setattr( - "apps.payments.reconciliation.stripe_client.expire_checkout_session", + "apps.payments.reconciliation_rail.stripe_client.expire_checkout_session", lambda *_args: (_ for _ in ()).throw(RuntimeError("Stripe down")), ) diff --git a/backend/tests/payments/test_refunds.py b/backend/tests/payments/test_refunds.py new file mode 100644 index 00000000..28c6a5e2 --- /dev/null +++ b/backend/tests/payments/test_refunds.py @@ -0,0 +1,272 @@ +"""Refunds: a separate ledger line on an immutable, online-paid Payment.""" + +import hashlib +import hmac +import json +from decimal import Decimal + +import pytest +from django.core.exceptions import ValidationError +from django.urls import reverse + +from apps.audit.models import AuditLog +from apps.payments import stripe_client +from apps.payments.models import MakerspacePaymentSettings, Payment, ProcessedStripeEvent, Refund +from apps.payments.services import apply_webhook_event +from tests.payments.test_models import configured_settings +from tests.return_helpers import ( + authenticated_client, + make_accepted_request, + make_member, + make_product, + make_space, + make_user, +) + +pytestmark = pytest.mark.django_db + + +class FakeStripe: + """`v1.refunds.create` double. `responses` are popped in order; an Exception is raised.""" + + calls = [] + responses = [] + + class StripeClient: + def __init__(self, *, api_key): + outer = FakeStripe + + class Refunds: + def create(self, *, params, options): + outer.calls.append((params, options)) + response = outer.responses.pop(0) + if isinstance(response, Exception): + raise response + return response + + self.v1 = type("V1", (), {"refunds": Refunds()})() + + +def stripe_space(slug, monkeypatch, responses): + makerspace = make_space(slug) + staff = make_member(f"{slug}-staff", makerspace) + configured_settings(makerspace) + FakeStripe.calls, FakeStripe.responses = [], list(responses) + monkeypatch.setattr(stripe_client, "_stripe_module", lambda: FakeStripe) + return makerspace, staff + + +def paid_deposit(makerspace, staff, *, status=Payment.Status.PAID_ONLINE, amount="10.00"): + request = make_accepted_request(makerspace, make_product(makerspace), 1) + payment = Payment.objects.create( + makerspace=makerspace, + subject_type=Payment.SubjectType.LOAN_DEPOSIT, + subject_id=request.pk, + member=request.requester, + amount=Decimal(amount), + currency="usd", + created_by=staff, + subject_label="Loan deposit", + ) + Payment.objects.filter(pk=payment.pk).update(status=status, stripe_payment_intent_id="pi_1") + payment.refresh_from_db() + return payment + + +def refund_url(payment): + return reverse("payment-reconciliation-refund", args=[payment.makerspace_id, payment.pk]) + + +def test_only_online_paid_payments_can_be_refunded(monkeypatch): + makerspace, staff = stripe_space("refund-offline", monkeypatch, []) + payment = paid_deposit(makerspace, staff, status=Payment.Status.PAID_OFFLINE) + + response = authenticated_client(staff).post( + refund_url(payment), {"amount": "5.00", "reason": "cash back"}, format="json" + ) + + assert response.status_code == 400 + assert response.data["code"] == "refund_not_online" + assert not Refund.objects.exists() + assert FakeStripe.calls == [] + + +def test_partial_refunds_sum_and_over_refund_is_refused(monkeypatch): + makerspace, staff = stripe_space( + "refund-partial", monkeypatch, + [{"id": "re_1", "status": "succeeded"}, {"id": "re_2", "status": "succeeded"}], + ) + payment = paid_deposit(makerspace, staff) + client = authenticated_client(staff) + + first = client.post(refund_url(payment), {"amount": "4.00", "reason": "Returned early"}, format="json") + assert first.status_code == 200 + assert Decimal(first.data["refunded_amount"]) == Decimal("4.00") + assert [row["status"] for row in first.data["refunds"]] == ["succeeded"] + params, options = FakeStripe.calls[0] + assert params["payment_intent"] == "pi_1" and params["amount"] == 400 + assert options["idempotency_key"].startswith("payment-refund-") + + second = client.post(refund_url(payment), {"amount": "6.00"}, format="json") + assert second.status_code == 200 + assert Decimal(second.data["refunded_amount"]) == Decimal("10.00") + + third = client.post(refund_url(payment), {"amount": "0.01"}, format="json") + assert third.status_code == 400 + assert third.data["code"] == "refund_exceeds_balance" + assert Refund.objects.filter(payment=payment).count() == 2 + payment.refresh_from_db() + assert payment.status == Payment.Status.PAID_ONLINE # the Payment row never moved + + entry = AuditLog.objects.filter(action="payment.refunded").order_by("pk").first() + refund = Refund.objects.filter(payment=payment).order_by("pk").first() + assert entry.meta == {"payment_id": payment.pk, "refund_id": refund.pk, "amount": "4.00"} + + +def test_pending_refund_holds_the_balance_and_the_webhook_settles_it_once(monkeypatch): + makerspace, staff = stripe_space( + "refund-pending", monkeypatch, [{"id": "re_1", "status": "pending"}] + ) + payment = paid_deposit(makerspace, staff) + client = authenticated_client(staff) + + response = client.post(refund_url(payment), {"amount": "4.00"}, format="json") + assert response.status_code == 200 + assert Decimal(response.data["refunded_amount"]) == Decimal("0.00") + refund = Refund.objects.get(payment=payment) + assert (refund.status, refund.external_refund_id) == (Refund.Status.PENDING, "re_1") + assert AuditLog.objects.filter(action="payment.refund_requested").count() == 1 + + over = client.post(refund_url(payment), {"amount": "6.01"}, format="json") + assert over.status_code == 400 # pending money is not refundable twice + + event = { + "id": "evt_refund_1", + "type": "refund.updated", + "data": {"object": {"id": "re_1", "payment_intent": "pi_1", "status": "succeeded", "amount": 400}}, + } + settled = apply_webhook_event(makerspace, event) + assert [row.status for row in settled] == [Refund.Status.SUCCEEDED] + assert apply_webhook_event(makerspace, event) is None + refund.refresh_from_db() + assert refund.status == Refund.Status.SUCCEEDED and refund.settled_at is not None + assert ProcessedStripeEvent.objects.filter(makerspace=makerspace).count() == 1 + assert AuditLog.objects.filter(action="payment.refunded").count() == 1 + + +def test_charge_refunded_binds_an_unbound_pending_refund_by_amount(monkeypatch): + makerspace, staff = stripe_space("refund-charge-event", monkeypatch, []) + payment = paid_deposit(makerspace, staff) + refund = Refund.objects.create( + payment=payment, amount=Decimal("3.00"), currency="usd", + provider=Payment.Provider.STRIPE, created_by=staff, + ) + + apply_webhook_event( + makerspace, + { + "id": "evt_charge_1", + "type": "charge.refunded", + "data": {"object": { + "id": "ch_1", + "payment_intent": "pi_1", + "refunds": {"data": [{"id": "re_dash", "status": "succeeded", "amount": 300}]}, + }}, + }, + ) + + refund.refresh_from_db() + assert (refund.status, refund.external_refund_id) == (Refund.Status.SUCCEEDED, "re_dash") + + +def test_provider_failure_marks_the_refund_failed_and_frees_the_balance(monkeypatch): + makerspace, staff = stripe_space( + "refund-failure", monkeypatch, + [RuntimeError("stripe down"), {"id": "re_ok", "status": "succeeded"}], + ) + payment = paid_deposit(makerspace, staff) + client = authenticated_client(staff) + + failed = client.post(refund_url(payment), {"amount": "10.00"}, format="json") + assert failed.status_code == 502 + assert failed.data["code"] == "refund_provider_failed" + refund = Refund.objects.get(payment=payment) + assert refund.status == Refund.Status.FAILED + assert AuditLog.objects.filter(action="payment.refund_failed").count() == 1 + + retry = client.post(refund_url(payment), {"amount": "10.00"}, format="json") + assert retry.status_code == 200 + assert Decimal(retry.data["refunded_amount"]) == Decimal("10.00") + + +def test_refund_needs_the_subject_authority_and_the_right_makerspace(monkeypatch): + makerspace, staff = stripe_space("refund-authz", monkeypatch, [{"id": "re_1", "status": "succeeded"}]) + payment = paid_deposit(makerspace, staff) + outsider = make_user("refund-outsider") + + assert authenticated_client(outsider).post(refund_url(payment), {"amount": "1.00"}, format="json").status_code == 403 + other = make_space("refund-other-space") + wrong_space = reverse("payment-reconciliation-refund", args=[other.pk, payment.pk]) + assert authenticated_client(staff).post(wrong_space, {"amount": "1.00"}, format="json").status_code == 404 + assert not Refund.objects.exists() + + +def test_settled_refunds_are_immutable(): + makerspace = make_space("refund-immutable") + staff = make_member("refund-immutable-staff", makerspace) + payment = paid_deposit(makerspace, staff) + refund = Refund.objects.create( + payment=payment, amount=Decimal("1.00"), currency="usd", + provider=Payment.Provider.STRIPE, created_by=staff, status=Refund.Status.SUCCEEDED, + ) + + refund.status = Refund.Status.FAILED + with pytest.raises(ValidationError): + refund.save() + with pytest.raises(ValidationError): + Refund( + payment=payment, amount=Decimal("9.50"), currency="usd", + provider=Payment.Provider.STRIPE, created_by=staff, + ).save() + + +def test_razorpay_refund_processed_settles_a_pending_refund_idempotently(client): + makerspace = make_space("refund-razorpay") + staff = make_member("refund-razorpay-staff", makerspace) + row = MakerspacePaymentSettings(makerspace=makerspace, provider="razorpay") + row.razorpay_key_id = "rzp_test_key" + row.set_razorpay_key_secret("rzp_test_secret") + row.set_razorpay_webhook_secret("rzp_whsec") + row.save() + request = make_accepted_request(makerspace, make_product(makerspace), 1) + payment = Payment.objects.create( + makerspace=makerspace, subject_type=Payment.SubjectType.LOAN_DEPOSIT, + subject_id=request.pk, member=request.requester, amount=Decimal("10.00"), + currency="inr", created_by=staff, provider=Payment.Provider.RAZORPAY, + external_payment_id="pay_1", + ) + Payment.objects.filter(pk=payment.pk).update(status=Payment.Status.PAID_ONLINE) + refund = Refund.objects.create( + payment=payment, amount=Decimal("4.00"), currency="inr", + provider=Payment.Provider.RAZORPAY, created_by=staff, external_refund_id="rfnd_1", + ) + body = json.dumps({ + "event": "refund.processed", + "payload": {"refund": {"entity": { + "id": "rfnd_1", "payment_id": "pay_1", "amount": 400, "status": "processed", + }}}, + }).encode() + signature = hmac.new(b"rzp_whsec", body, hashlib.sha256).hexdigest() + post = lambda: client.post( # noqa: E731 + reverse("razorpay-webhook", args=[makerspace.public_code]), + data=body, content_type="application/json", + HTTP_X_RAZORPAY_SIGNATURE=signature, HTTP_X_RAZORPAY_EVENT_ID="evt_rf_1", + ) + + assert post().status_code == 200 + assert post().status_code == 200 + + refund.refresh_from_db() + assert refund.status == Refund.Status.SUCCEEDED + assert ProcessedStripeEvent.objects.filter(makerspace=makerspace).count() == 1 + assert AuditLog.objects.filter(action="payment.refunded").count() == 1 diff --git a/backend/tests/payments/test_terminal_payment_remediation.py b/backend/tests/payments/test_terminal_payment_remediation.py index 43d0bb55..5c10dc1d 100644 --- a/backend/tests/payments/test_terminal_payment_remediation.py +++ b/backend/tests/payments/test_terminal_payment_remediation.py @@ -5,7 +5,7 @@ from apps.payments.services import apply_webhook_event from tests.payments.test_models import configured_settings from tests.payments.test_reconciliation import action_url, payment -from tests.return_helpers import authenticated_client, make_member, make_space +from tests.return_helpers import authenticated_client, make_member, make_space, settlement_payload pytestmark = pytest.mark.django_db @@ -42,10 +42,14 @@ def cancel(source, intent_id): return True monkeypatch.setattr( - "apps.payments.reconciliation.stripe_client.cancel_payment_intent", cancel + "apps.payments.reconciliation_rail.stripe_client.cancel_payment_intent", cancel ) - response = authenticated_client(manager).post(action_url(space, row, action)) + # Waiving takes no receipt (no money moved); marking offline requires one. + body = settlement_payload() if action == "mark-offline" else {} + response = authenticated_client(manager).post( + action_url(space, row, action), body, format="json" + ) assert response.status_code == 200 row.refresh_from_db() @@ -57,7 +61,7 @@ def cancel(source, intent_id): def test_native_intent_cancellation_failure_does_not_block_reconciliation(monkeypatch): space, manager, row = native_payment("native-cancel-failure") monkeypatch.setattr( - "apps.payments.reconciliation.stripe_client.cancel_payment_intent", + "apps.payments.reconciliation_rail.stripe_client.cancel_payment_intent", lambda *_args: (_ for _ in ()).throw(RuntimeError("Stripe down")), ) diff --git a/backend/tests/payments/test_track3_booking_event_charging.py b/backend/tests/payments/test_track3_booking_event_charging.py index 4d05cd5b..b4239d3d 100644 --- a/backend/tests/payments/test_track3_booking_event_charging.py +++ b/backend/tests/payments/test_track3_booking_event_charging.py @@ -25,7 +25,7 @@ def member_with_phone(username, makerspace): def enable_payments(makerspace, domain, *, currency="usd"): - makerspace.enabled_features = ["payments.enabled", f"payments.{domain}"] + makerspace.enabled_features = ["payments.enabled", f"payments.{domain}", "charges.enabled", f"charges.{domain}"] makerspace.save(update_fields=["enabled_features", "updated_at"]) settings = configured_settings(makerspace) settings.default_currency = currency @@ -183,7 +183,7 @@ def test_booking_currency_is_snapshotted_and_cancellation_is_best_effort(monkeyp settings.save(update_fields=["default_currency"]) Payment.objects.filter(pk=payment.pk).update(stripe_checkout_session_id="cs_cancel") monkeypatch.setattr( - "apps.payments.reconciliation.stripe_client.expire_checkout_session", + "apps.payments.reconciliation_rail.stripe_client.expire_checkout_session", lambda *_args: (_ for _ in ()).throw(RuntimeError("stripe down")), ) diff --git a/backend/tests/perf/__init__.py b/backend/tests/perf/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/tests/perf/test_list_query_budgets.py b/backend/tests/perf/test_list_query_budgets.py new file mode 100644 index 00000000..85d710a0 --- /dev/null +++ b/backend/tests/perf/test_list_query_budgets.py @@ -0,0 +1,136 @@ +"""Query budgets for the hot list endpoints. + +Each test seeds more rows than one page holds and asserts the request stays under a fixed +number of queries. A budget is a ceiling, not a target: the point is that adding a +``SerializerMethodField`` that hits the database per row turns a 6-query page into a +30-query page, and this file is what turns that into a red build instead of a slow queue. +Budgets are generous on purpose so a legitimate extra lookup does not need a ritual edit. +""" +import pytest +from django.urls import reverse + +from apps.hardware_requests.models import HardwareRequest +from apps.makerspaces.models import MakerspaceMembership +from apps.operations.models import StockTransfer +from tests.return_helpers import ( + authenticated_client, + make_accepted_request, + make_issued_request, + make_member, + make_product, + make_space, + make_user, +) + +pytestmark = pytest.mark.django_db + +ROWS = 30 # more than one PageNumberPagination page (24) +# Measured 2026-09-03: the staff endpoints cost a constant ~20 queries (session/JWT auth, +# membership + role resolution, module and servability checks, count + page) regardless of +# row count. An N+1 on a 30-row seed would put them at 50+, so 24 catches it with headroom. +BUDGET = 24 + + +@pytest.fixture +def space(): + return make_space("budget-space") + + +@pytest.fixture +def manager(space): + return make_member("budget-manager", space) + + +@pytest.fixture +def client(manager): + return authenticated_client(manager) + + +def _products(space, n=ROWS): + return [ + make_product(space, name=f"Tool {i:02d}", total_quantity=5, available_quantity=5) + for i in range(n) + ] + + +def _assert_page(response): + assert response.status_code == 200, response.content[:300] + payload = response.json() + if isinstance(payload, dict) and "results" in payload: + assert payload["count"] >= 1 + return payload + + +def test_public_inventory_list(space, django_assert_max_num_queries): + _products(space) + url = reverse("public-inventory", kwargs={"makerspace_slug": space.slug}) + from rest_framework.test import APIClient + + with django_assert_max_num_queries(BUDGET): + _assert_page(APIClient().get(url)) + + +def test_admin_inventory_list(space, client, django_assert_max_num_queries): + _products(space) + url = reverse("admin-inventory", kwargs={"makerspace_id": space.pk}) + with django_assert_max_num_queries(BUDGET): + _assert_page(client.get(url)) + + +def test_pending_requests_queue(space, client, django_assert_max_num_queries): + for product in _products(space): + request = make_accepted_request(space, product, 1) + HardwareRequest.objects.filter(pk=request.pk).update( + status=HardwareRequest.Status.PENDING_APPROVAL + ) + url = reverse("hardware_requests:pending-requests", kwargs={"makerspace_id": space.pk}) + with django_assert_max_num_queries(BUDGET): + _assert_page(client.get(url)) + + +def test_accepted_requests_queue(space, client, django_assert_max_num_queries): + for product in _products(space): + make_accepted_request(space, product, 1) + url = reverse("hardware_requests:accepted-requests", kwargs={"makerspace_id": space.pk}) + with django_assert_max_num_queries(BUDGET): + _assert_page(client.get(url)) + + +def test_active_loans_list(space, manager, client, django_assert_max_num_queries): + for product in _products(space, n=12): + make_issued_request(space, manager, [(product, 1)]) + url = reverse("hardware_requests:active-loans", kwargs={"makerspace_id": space.pk}) + with django_assert_max_num_queries(BUDGET): + _assert_page(client.get(url)) + + +def test_containers_list(space, client, django_assert_max_num_queries): + from tests.return_helpers import make_box + + for i in range(ROWS): + make_box(space, label=f"Box {i:02d}") + url = reverse("containers", kwargs={"makerspace_id": space.pk}) + with django_assert_max_num_queries(BUDGET): + _assert_page(client.get(url)) + + +def test_stock_transfers_list(space, manager, client, django_assert_max_num_queries): + for i in range(ROWS): + StockTransfer.objects.create( + makerspace=space, source_makerspace=space, created_by=manager, + reason=f"transfer {i}" + ) + url = reverse("stock-transfers", kwargs={"makerspace_id": space.pk}) + with django_assert_max_num_queries(BUDGET): + _assert_page(client.get(url)) + + +def test_membership_list(space, client, django_assert_max_num_queries): + for i in range(ROWS): + MakerspaceMembership.objects.create( + user=make_user(f"member-{i:02d}"), makerspace=space, + role=MakerspaceMembership.Role.INVENTORY_MANAGER, + ) + url = reverse("admin-membership-list-create", kwargs={"makerspace_id": space.pk}) + with django_assert_max_num_queries(BUDGET): + _assert_page(client.get(url)) diff --git a/backend/tests/return_helpers.py b/backend/tests/return_helpers.py index db91ebb5..c056d8f3 100644 --- a/backend/tests/return_helpers.py +++ b/backend/tests/return_helpers.py @@ -25,6 +25,53 @@ def make_space(slug): return Makerspace.objects.create(name=slug, slug=slug) +def enable_online_rail(makerspace, domain): + """Switch the ONLINE rail on for one domain, alongside charge tracking. + + Charge tracking (`charges.*`) decides whether a debt is recorded; the payments + feature set decides whether a Stripe/Razorpay rail may be raised for it. A test that + exercises checkout, a payment intent, or provider provenance needs both, because the + member-facing checkout and mobile-intent endpoints now refuse to mint a provider + object for a charge whose space has no live rail. + """ + features = set(makerspace.enabled_features or []) + features |= { + "payments.enabled", + f"payments.{domain}", + "charges.enabled", + f"charges.{domain}", + } + makerspace.enabled_features = sorted(features) + makerspace.save(update_fields=["enabled_features", "updated_at"]) + return makerspace + + +def settlement_details(**overrides): + """The receipt dict the reconciliation SERVICE requires (not the HTTP body). + + `mark_offline`/`reconcile_payments` refuse a paid-offline transition without it, so a + settled charge always records how the money arrived. + """ + from django.utils import timezone + + details = {"method": "cash", "reference": "", "received_at": timezone.now()} + details.update(overrides) + return details + + +def settlement_payload(**overrides): + """The receipt body every mark-offline endpoint now requires. + + A charge marked paid offline must be able to say how and when the money arrived, so + method and received_at are mandatory at the API boundary. + """ + from django.utils import timezone + + payload = {"method": "cash", "reference": "", "received_at": timezone.now().isoformat()} + payload.update(overrides) + return {"settlement": payload} + + def make_member( username, makerspace, diff --git a/backend/tests/tenant_migration/test_authority_dispositions_d8.py b/backend/tests/tenant_migration/test_authority_dispositions_d8.py index b0378d9d..7e1d6984 100644 --- a/backend/tests/tenant_migration/test_authority_dispositions_d8.py +++ b/backend/tests/tenant_migration/test_authority_dispositions_d8.py @@ -108,11 +108,23 @@ def _fields(label, names, dispositions): ), *_fields( "makerspaces.MemberProfile", - "is_visible show_attended_events headline institution bio avatar_key " + "is_visible show_attended_events show_certifications headline institution bio avatar_key " "interests languages education github_username github_contributions " "github_synced_at", (D.PRESERVE, D.DROP), ), + *_fields( + "makerspaces.MemberCard", + "printed_name photo_object_key photo_content_type photo_size_bytes photo_consent_at " + "photo_consent_version", + (D.PRESERVE, D.DROP), + ), + # Membership plans, terms and invitation requests (forward plan phase 6). + *_fields("makerspaces.InvitationRequest", "name email phone message", (D.PRESERVE, D.DROP)), + *_fields("makerspaces.InvitationRequest", "status", D.PRESERVE), + *_fields("makerspaces.MembershipPlan", "is_active", D.PRESERVE), + *_fields("makerspaces.MembershipTerm", "status", D.PRESERVE), + *_fields("makerspaces.Makerspace", "lapsed_members_cannot_borrow", D.PRESERVE), *_fields( "makerspaces.MemberProject", "id profile title description image_key links position created_at updated_at", @@ -186,6 +198,17 @@ def _fields(label, names, dispositions): "stripe_checkout_session_expired_at stripe_payment_intent_id", D.RESET, ), + *_fields( + "payments.Refund", + "status amount currency provider reason", + D.PRESERVE, + ), + *_fields("payments.Refund", "external_refund_id", D.RESET), + *_fields( + "payments.ManualSettlement", + "method reference received_at amount currency", + D.PRESERVE, + ), *_fields("audit.AuditLog", "event_uuid row_mac", D.RESET), ] ) diff --git a/backend/tests/tenant_migration/test_money_drift_d5.py b/backend/tests/tenant_migration/test_money_drift_d5.py new file mode 100644 index 00000000..54922e01 --- /dev/null +++ b/backend/tests/tenant_migration/test_money_drift_d5.py @@ -0,0 +1,167 @@ +"""The capture-bound money fingerprint (owner decision D5). + +Pending charges travel in a portable dump now. The capture freezes the database under +an exclusive gate and then REOPENS the source, so between the freeze and publication the +space can settle a captured debt or raise a new one. Publishing then would hand out an +artifact that bills a member for money the source has already taken. +""" + +from decimal import Decimal + +import pytest +from django.utils import timezone + +from apps.accounts.models import User +from apps.makerspaces.models import Makerspace +from apps.payments.models import ManualSettlement, Payment +from apps.tenant_migration.money_digest import ( + MoneyDriftRefused, + assert_money_unchanged, + money_fingerprint, +) + +pytestmark = pytest.mark.django_db + + +class _Capture: + """The two fields `assert_money_unchanged` reads, without a full capture row.""" + + def __init__(self, makerspace_id, digest): + self.source_makerspace_id = makerspace_id + self.money_fingerprint_sha256 = digest + + +def _space(slug): + return Makerspace.objects.create(name=slug, slug=slug) + + +def _member(name, space): + return User.objects.create_user(username=name, email=f"{name}@example.test") + + +def _pending(space, actor, subject_id): + # bulk_create to skip `Payment.clean()`, which insists the BOOKING subject really + # exists in this makerspace. The fingerprint reads status/amount/currency/provider + # and never resolves a subject, so a synthetic id is faithful here. + row = Payment( + makerspace=space, + subject_type=Payment.SubjectType.BOOKING, + subject_id=subject_id, + subject_label=f"Synthetic {subject_id}", + member=actor, + amount=Decimal("20.00"), + currency="usd", + created_by=actor, + ) + Payment.objects.bulk_create([row]) + return Payment.objects.get(makerspace=space, subject_id=subject_id) + + +def test_an_untouched_source_matches_its_capture(): + space = _space("money-stable") + actor = _member("money-stable-actor", space) + _pending(space, actor, 1) + capture = _Capture(space.pk, money_fingerprint(space.pk)) + + assert assert_money_unchanged(capture) == capture.money_fingerprint_sha256 + + +def test_settling_a_captured_debt_after_the_freeze_refuses_publication(): + """The case that matters: the member already paid at the desk.""" + space = _space("money-settled-after") + actor = _member("money-settled-after-actor", space) + payment = _pending(space, actor, 2) + capture = _Capture(space.pk, money_fingerprint(space.pk)) + + Payment.objects.filter(pk=payment.pk).update(status=Payment.Status.PAID_OFFLINE) + ManualSettlement.objects.create( + payment=payment, + method=ManualSettlement.Method.CASH, + received_at=timezone.now(), + amount=payment.amount, + currency=payment.currency, + recorded_by=actor, + ) + + with pytest.raises(MoneyDriftRefused): + assert_money_unchanged(capture) + + +def test_raising_a_new_debt_after_the_freeze_refuses_publication(): + space = _space("money-new-after") + actor = _member("money-new-after-actor", space) + _pending(space, actor, 3) + capture = _Capture(space.pk, money_fingerprint(space.pk)) + + _pending(space, actor, 4) + + with pytest.raises(MoneyDriftRefused): + assert_money_unchanged(capture) + + +def test_a_capture_predating_the_fingerprint_is_not_revalidated(): + """A blank digest means "not recorded", not "nothing was owed". + + Comparing against it would refuse every capture taken before this field existed. + """ + space = _space("money-legacy-capture") + actor = _member("money-legacy-actor", space) + _pending(space, actor, 5) + + assert assert_money_unchanged(_Capture(space.pk, "")) is None + + +def test_terminal_history_alone_does_not_look_like_drift(): + """Settled charges are immutable, so they cannot move; including them would make + every ordinary settlement in an unrelated space read as a changed source.""" + space = _space("money-terminal-only") + actor = _member("money-terminal-actor", space) + payment = _pending(space, actor, 6) + Payment.objects.filter(pk=payment.pk).update(status=Payment.Status.WAIVED) + capture = _Capture(space.pk, money_fingerprint(space.pk)) + + assert assert_money_unchanged(capture) == capture.money_fingerprint_sha256 + + +def test_a_rail_opened_after_the_freeze_refuses_publication(): + """The hole the first version of the digest left open. + + A pending row can already carry a provider while holding no live handle -- a checkout + whose creation failed, or one that expired. `status`, `amount` and `provider` all sit + still when the reopened source then mints a session for it, so a digest over those + alone still matched and publication shipped an artifact whose handles were stripped + while the source stayed payable. Both deployments could then collect the same debt, + which is precisely what the preflight refuses before the freeze. + """ + space = _space("money-rail-after") + actor = _member("money-rail-after-actor", space) + payment = _pending(space, actor, 7) + Payment.objects.filter(pk=payment.pk).update(provider=Payment.Provider.STRIPE) + capture = _Capture(space.pk, money_fingerprint(space.pk)) + + # No status, amount or provider change -- only a rail appearing. + Payment.objects.filter(pk=payment.pk).update( + online_rail=Payment.OnlineRail.CHECKOUT, + stripe_checkout_session_id="cs_test_after_freeze", + stripe_checkout_url="https://checkout.stripe.test/cs_test_after_freeze", + ) + + with pytest.raises(MoneyDriftRefused): + assert_money_unchanged(capture) + + +def test_a_native_intent_opened_after_the_freeze_refuses_publication(): + """The native payment sheet is a live rail exactly like a hosted page.""" + space = _space("money-intent-after") + actor = _member("money-intent-after-actor", space) + payment = _pending(space, actor, 8) + Payment.objects.filter(pk=payment.pk).update(provider=Payment.Provider.STRIPE) + capture = _Capture(space.pk, money_fingerprint(space.pk)) + + Payment.objects.filter(pk=payment.pk).update( + online_rail=Payment.OnlineRail.NATIVE_PAYMENT_INTENT, + stripe_payment_intent_id="pi_test_after_freeze", + ) + + with pytest.raises(MoneyDriftRefused): + assert_money_unchanged(capture) diff --git a/backend/tests/tenant_migration/test_tenant_dump_cross_tenant_d6.py b/backend/tests/tenant_migration/test_tenant_dump_cross_tenant_d6.py index 6600bc63..381c5a1f 100644 --- a/backend/tests/tenant_migration/test_tenant_dump_cross_tenant_d6.py +++ b/backend/tests/tenant_migration/test_tenant_dump_cross_tenant_d6.py @@ -93,18 +93,52 @@ def test_terminal_payment_preserves_history_and_clears_every_live_handle(status) assert projected[column] == value -def test_one_pending_payment_refuses_the_entire_source_projection(): +def test_a_pending_payment_also_clears_every_live_handle(): + """The projection rule that makes carrying an unsettled debt safe. + + A pending row is held to the SAME cleared-handle standard as a terminal one: no + order id, session, intent, checkout URL, connected account or routing survives. That + is what stops an imported debt resuming the source's rail and collecting twice. + """ + space = _space("payment-pending-handles") + actor = _user("payment-pending-handles-actor") + payment = _payment(space, actor, Payment.Status.PENDING) + source = raw_records(Payment.objects.filter(pk=payment.pk), Payment)[0] + + projected = sanitize_record(Payment, source).values + + assert projected["status"] == Payment.Status.PENDING + assert projected["amount"] == Decimal("42.75") + for field_name, value in PAYMENT_CLEARED_VALUES.items(): + column = Payment._meta.get_field( + field_name.removesuffix("_id") + if field_name == "via_makerspace_id" + else field_name + ).column + assert projected[column] == value + + +def test_a_pending_payment_no_longer_refuses_the_source_projection(): + """Pending charges travel now (D5). + + One pending row used to refuse the whole dump. That was tenable while a charge could + only exist where a gateway was configured; once money owed is tracked by default and + without one, it made a portable dump impossible for any space that keeps a ledger. + + What keeps it safe is elsewhere: the preflight refuses a pending row with a LIVE + rail, the projection clears every provider handle, and the capture's money + fingerprint refuses publication if the source settles the debt in the meantime. + """ space = _space("pending-payment") actor = _user("pending-payment-actor") _payment(space, actor, Payment.Status.PENDING) - with pytest.raises(TenantDumpDispositionRefused) as refused: - inspect_cross_tenant_source(space.pk) + facts = inspect_cross_tenant_source(space.pk) - assert refused.value.reason_code == "pending_payment" + assert facts is not None -def test_foreign_registration_payment_route_refuses_while_payment_is_pending(): +def test_foreign_registration_payment_route_travels_with_a_pending_payment(): host = _space("pending-registration-host") via = _space("pending-registration-via") actor = _user("pending-registration-actor") @@ -131,10 +165,11 @@ def test_foreign_registration_payment_route_refuses_while_payment_is_pending(): via_makerspace=via, ) - with pytest.raises(TenantDumpDispositionRefused) as refused: - inspect_cross_tenant_source(host.pk) + facts = inspect_cross_tenant_source(host.pk) - assert refused.value.reason_code == "pending_payment" + # The foreign routing is still cleared -- that rule is about cross-tenant leakage, + # not about the charge being unsettled, and it is unaffected by D5. + assert facts is not None def test_collaboration_and_stock_transfer_losses_are_recorded_without_foreign_ids(): diff --git a/backend/tests/tenant_migration/test_terminal_payments_d8.py b/backend/tests/tenant_migration/test_terminal_payments_d8.py index f9fe9408..46dd35e6 100644 --- a/backend/tests/tenant_migration/test_terminal_payments_d8.py +++ b/backend/tests/tenant_migration/test_terminal_payments_d8.py @@ -4,7 +4,7 @@ from apps.payments.models import Payment from apps.tenant_migration.tenant_dump_cross_tenant import PAYMENT_CLEARED_VALUES -from tests.return_helpers import authenticated_client, make_member, make_space +from tests.return_helpers import authenticated_client, make_member, make_space, settlement_payload pytestmark = pytest.mark.django_db @@ -63,14 +63,14 @@ def provider_called(*args, **kwargs): raise AssertionError("a restored terminal payment reached a provider") monkeypatch.setattr( - "apps.payments.reconciliation.source_for_payment", provider_called + "apps.payments.reconciliation_rail.source_for_payment", provider_called ) monkeypatch.setattr( - "apps.payments.reconciliation.stripe_client.expire_checkout_session", + "apps.payments.reconciliation_rail.stripe_client.expire_checkout_session", provider_called, ) monkeypatch.setattr( - "apps.payments.reconciliation.stripe_client.cancel_payment_intent", + "apps.payments.reconciliation_rail.stripe_client.cancel_payment_intent", provider_called, ) monkeypatch.setattr( @@ -80,7 +80,12 @@ def provider_called(*args, **kwargs): base = f"/api/v1/admin/makerspace/{space.pk}/payments" listed = client.get(base) - reconciled = client.post(f"{base}/{payment.pk}/mark-offline") + # The receipt body is required now, and validation runs before the terminal check -- + # without it the response would be a 400 about the missing receipt, masking the 409 + # this test exists to pin. + reconciled = client.post( + f"{base}/{payment.pk}/mark-offline", settlement_payload(), format="json" + ) assert listed.status_code == 200 row = next(item for item in listed.data if item["id"] == payment.pk) diff --git a/backend/tests/test_capabilities.py b/backend/tests/test_capabilities.py index 70b3223c..2624363f 100644 --- a/backend/tests/test_capabilities.py +++ b/backend/tests/test_capabilities.py @@ -24,6 +24,12 @@ def test_feature_defaults_are_dormant_except_legacy_compatible_self_checkout(): # enables nothing by itself: every per-DOMAIN payments feature must still be off by # default, which is what the second assertion pins. assert default_enabled_features() == [ + "charges.enabled", + "charges.bookings", + "charges.events", + "charges.machines", + "charges.membership", + "charges.loans", "inventory.self_checkout", "payments.enabled", "mobile.push", @@ -33,6 +39,14 @@ def test_feature_defaults_are_dormant_except_legacy_compatible_self_checkout(): key.startswith("payments.") and key != "payments.enabled" for key in default_enabled_features() ) + # The `charges.*` family is the deliberate exception to "dormant by default". It + # decides whether money owed is RECORDED, not whether it can be collected, so it + # cannot make an unconfigured space start charging anyone: the real trigger stays a + # configured amount. Defaulting it off would reproduce the bug it exists to fix -- + # a space with no gateway silently losing every debt. + assert all( + key.startswith("charges.") for key in default_enabled_features()[:6] + ) def test_delegated_notification_recipients_default_off_and_match_the_frontend(): diff --git a/backend/tests/test_live_stream.py b/backend/tests/test_live_stream.py new file mode 100644 index 00000000..4d9e85c9 --- /dev/null +++ b/backend/tests/test_live_stream.py @@ -0,0 +1,136 @@ +"""Live-update hints: channel scoping, on-commit publishing, and the SSE endpoint's failure modes.""" +import json + +import pytest +from django.db import transaction +from django.urls import reverse +from rest_framework.test import APIClient + +from apps.accounts.models import User +from apps.audit import services as audit +from apps.operations import live +from apps.operations.views_live import subscribed_channels +from tests.return_helpers import authenticated_client, make_member, make_space, make_user + +pytestmark = pytest.mark.django_db + + +class FakeRedis: + def __init__(self): + self.published = [] + + def publish(self, channel, message): + self.published.append((channel, json.loads(message))) + + +@pytest.fixture +def fake_redis(monkeypatch, settings): + settings.LIVE_REDIS_URL = "redis://fake:6379/0" + client = FakeRedis() + monkeypatch.setattr(live, "redis_client", lambda: client) + return client + + +def test_channels_follow_rbac_scope(): + alpha, beta = make_space("live-alpha"), make_space("live-beta") + manager = make_member("live-manager", alpha) + member = make_user("live-member", access_status=User.AccessStatus.ACTIVE) + root = make_user("live-root", role=User.Role.SUPERADMIN, is_superuser=True) + + assert subscribed_channels(manager) == [ + live.user_channel(manager.pk), + live.makerspace_channel(alpha.pk), + ] + assert subscribed_channels(member) == [live.user_channel(member.pk)] + root_channels = subscribed_channels(root) + assert live.makerspace_channel(alpha.pk) in root_channels + assert live.makerspace_channel(beta.pk) in root_channels + + +def test_audit_record_publishes_after_commit_only(fake_redis): + space = make_space("live-commit") + actor = make_member("live-actor", space) + with transaction.atomic(): + audit.record(actor, "request.accepted", makerspace=space, target=space) + assert fake_redis.published == [] # nothing until the transaction commits + # pytest's django_db wraps the test in a transaction that never commits, so on_commit + # callbacks are only observable through the captured-on-commit API. + + +def test_audit_record_publish_payload_carries_no_content(fake_redis, django_capture_on_commit_callbacks): + space = make_space("live-payload") + actor = make_member("live-payload-actor", space) + with django_capture_on_commit_callbacks(execute=True): + audit.record( + actor, "request.issued", makerspace=space, target=space, + meta={"requester_name": "Someone Private", "email": "p@example.com"}, + ) + channels = [channel for channel, _ in fake_redis.published] + assert live.makerspace_channel(space.pk) in channels + assert live.user_channel(actor.pk) in channels + _, payload = fake_redis.published[0] + assert payload["kind"] == "request.issued" + assert payload["makerspace_id"] == space.pk + assert payload["target_type"] == "makerspaces.makerspace" + assert "Someone Private" not in json.dumps(payload) + assert set(payload) == {"kind", "makerspace_id", "target_type", "target_id", "actor_id", "ts"} + + +def test_publish_is_a_no_op_without_redis(settings, django_capture_on_commit_callbacks): + settings.LIVE_REDIS_URL = "" + settings.CELERY_TASK_ALWAYS_EAGER = True + space = make_space("live-none") + actor = make_member("live-none-actor", space) + with django_capture_on_commit_callbacks(execute=True) as callbacks: + audit.record(actor, "request.rejected", makerspace=space, target=space) + assert callbacks == [] + + +def test_stream_requires_auth_and_reports_503_without_redis(settings, monkeypatch): + settings.LIVE_REDIS_URL = "" + settings.CELERY_TASK_ALWAYS_EAGER = True + url = reverse("live") + assert APIClient().get(url).status_code in (401, 403) + space = make_space("live-503") + manager = make_member("live-503-manager", space) + response = authenticated_client(manager).get(url, HTTP_ACCEPT="text/event-stream") + assert response.status_code == 503 + assert response["Content-Type"].startswith("application/json") + + +def test_stream_emits_events_from_the_subscribed_channels(settings, monkeypatch): + settings.LIVE_REDIS_URL = "redis://fake:6379/0" + settings.LIVE_MAX_STREAM_SECONDS = 1 + + class FakePubSub: + def __init__(self): + self.channels = () + self.messages = [] + + def subscribe(self, *channels): + self.channels = channels + self.messages = [{"type": "message", "data": b'{"kind":"request.accepted"}'}] + + def get_message(self, ignore_subscribe_messages=True, timeout=1.0): + return self.messages.pop(0) if self.messages else None + + class FakeClient: + pubsub_instance = FakePubSub() + + def pubsub(self): + return self.pubsub_instance + + monkeypatch.setattr(live, "redis_client", lambda: FakeClient()) + space = make_space("live-stream") + manager = make_member("live-stream-manager", space) + # Browsers ask for text/event-stream explicitly; DRF negotiation must not answer 406. + response = authenticated_client(manager).get(reverse("live"), HTTP_ACCEPT="text/event-stream") + assert response.status_code == 200 + assert response["Content-Type"].startswith("text/event-stream") + assert response["X-Accel-Buffering"] == "no" + body = b"".join(response.streaming_content).decode() + assert body.startswith("retry: 5000\n\n") + assert 'event: change\ndata: {"kind":"request.accepted"}\n\n' in body + assert set(FakeClient.pubsub_instance.channels) == { + live.user_channel(manager.pk), live.makerspace_channel(space.pk), + } diff --git a/backend/tests/test_log_setup.py b/backend/tests/test_log_setup.py new file mode 100644 index 00000000..60c279dc --- /dev/null +++ b/backend/tests/test_log_setup.py @@ -0,0 +1,60 @@ +import json +import logging + +from django.conf import settings + +from config.log_setup import JsonFormatter, RequestIdFilter, build_logging + + +def _record(**extra): + record = logging.LogRecord( + name="apps.test", level=logging.INFO, pathname=__file__, lineno=1, + msg="issued %s", args=("drill",), exc_info=None, + ) + for key, value in extra.items(): + setattr(record, key, value) + RequestIdFilter().filter(record) + return record + + +def test_json_formatter_emits_one_object_with_extras_and_request_id(): + line = JsonFormatter().format(_record(audit_event_uuid="u-1", makerspace_id=7)) + payload = json.loads(line) + assert payload["message"] == "issued drill" + assert payload["level"] == "INFO" + assert payload["logger"] == "apps.test" + assert payload["request_id"] == "-" + assert payload["audit_event_uuid"] == "u-1" + assert payload["makerspace_id"] == 7 + assert payload["ts"].endswith("+00:00") + assert "\n" not in line + + +def test_json_formatter_serialises_non_json_extras_and_exceptions(): + try: + raise ValueError("bad") + except ValueError: + import sys + record = _record(weird=object()) + record.exc_info = sys.exc_info() + payload = json.loads(JsonFormatter().format(record)) + assert payload["weird"].startswith("= 8 # a Letter sheet holds a full run of CR80 cards + + +def test_membership_purge_revokes_qrs_and_deletes_cards(monkeypatch): + from apps.makerspaces.module_purge_plans import PLANS + + space = make_space("cards-purge") + manager = make_member("cards-purge-manager", space) + card = services.issue_card(manager, _member(space)) + plan = next(plan for plan in PLANS if plan.key == "membership") + assert "makerspaces.MemberCard" in plan.pii_labels + collected = [] + plan.private_keys(space, collected.append) + assert collected == [] + plan.delete(space, None) + assert not MemberCard.objects.filter(pk=card.pk).exists() + assert QrCode.objects.filter(target_type="member_card", target_id=card.pk, status="revoked").exists() diff --git a/backend/tests/test_metrics_endpoint.py b/backend/tests/test_metrics_endpoint.py new file mode 100644 index 00000000..32de92ed --- /dev/null +++ b/backend/tests/test_metrics_endpoint.py @@ -0,0 +1,62 @@ +import pytest +from django.urls import reverse +from rest_framework.test import APIClient + +from apps.hardware_requests.models import HardwareRequest +from tests.return_helpers import make_accepted_request, make_product, make_space, make_user + +pytestmark = pytest.mark.django_db + +URL = "/api/v1/metrics/" + + +def test_route_is_invisible_when_no_token_is_configured(settings): + settings.METRICS_TOKEN = "" + assert reverse("metrics") == URL + response = APIClient().get(URL, HTTP_AUTHORIZATION="Bearer anything") + assert response.status_code == 404 + + +def test_wrong_or_missing_token_is_unauthorized(settings): + settings.METRICS_TOKEN = "s3cret" + client = APIClient() + assert client.get(URL).status_code == 401 + response = client.get(URL, HTTP_AUTHORIZATION="Bearer nope") + assert response.status_code == 401 + assert response["WWW-Authenticate"] == "Bearer" + + +def test_exposition_lists_request_states_and_storage(settings): + settings.METRICS_TOKEN = "s3cret" + settings.CELERY_TASK_ALWAYS_EAGER = True + space = make_space("metrics-space") + space.storage_bytes_used = 1234 + space.save(update_fields=["storage_bytes_used"]) + product = make_product(space, name="Metrics Widget") + request = make_accepted_request(space, product, 1) + HardwareRequest.objects.filter(pk=request.pk).update( + status=HardwareRequest.Status.PENDING_APPROVAL + ) + make_user("metrics-viewer") + response = APIClient().get(URL, HTTP_X_METRICS_TOKEN="s3cret") + assert response.status_code == 200 + assert response["Content-Type"].startswith("text/plain; version=0.0.4") + body = response.content.decode() + assert "# TYPE spaceworks_hardware_requests gauge" in body + assert 'spaceworks_hardware_requests{status="pending_approval"} 1' in body + assert f'spaceworks_storage_bytes_used{{makerspace_id="{space.pk}"}} 1234' in body + assert "spaceworks_storage_bytes_used_total 1234" in body + assert "spaceworks_evidence_retention_last_expiry_timestamp_seconds 0" in body + # Eager mode has no broker to read, so the queue gauge is declared but sample-less. + assert "# TYPE spaceworks_celery_queue_length gauge" in body + assert "spaceworks_celery_queue_length{" not in body + assert product.name not in body # no tenant content, only counts and ids + + +def test_metrics_view_is_documented_in_the_schema(): + from drf_spectacular.generators import SchemaGenerator + + schema = SchemaGenerator().get_schema(request=None, public=True) + operation = schema["paths"][URL]["get"] + assert operation["tags"] == ["Health"] + assert "200" in operation["responses"] diff --git a/backend/tests/test_notification_fanout.py b/backend/tests/test_notification_fanout.py index b590f3d5..3aaeeb95 100644 --- a/backend/tests/test_notification_fanout.py +++ b/backend/tests/test_notification_fanout.py @@ -178,6 +178,7 @@ def test_raising_build_and_dispatch_never_escape(monkeypatch): NotificationChannel.SLACK: 1, NotificationChannel.MATTERMOST: 1, NotificationChannel.DISCORD: 1, + NotificationChannel.WEBHOOK: 1, NotificationChannel.NATIVE_PUSH: 1, } diff --git a/backend/tests/test_ops_dashboard.py b/backend/tests/test_ops_dashboard.py index 34969317..e4e9c424 100644 --- a/backend/tests/test_ops_dashboard.py +++ b/backend/tests/test_ops_dashboard.py @@ -33,11 +33,15 @@ def test_space_manager_gets_dashboard_with_all_count_keys(): assert response.status_code == 200 assert set(response.data) == set(views_dashboard.DashboardSerializer().fields) assert response.data["scope_mode"] == "full" + # Every count is an integer. `outstanding_by_currency` is deliberately not one: it + # is a map of currency -> amount string, because outstanding money cannot be summed + # across currencies into a single number. assert all( isinstance(value, int) for key, value in response.data.items() - if key != "scope_mode" + if key not in {"scope_mode", "outstanding_by_currency"} ) + assert isinstance(response.data["outstanding_by_currency"], dict) def test_pending_payments_are_manager_only_and_ignore_dates_or_modules(): diff --git a/backend/tests/test_reports_payments.py b/backend/tests/test_reports_payments.py index 4ee5ffd2..2837a7b3 100644 --- a/backend/tests/test_reports_payments.py +++ b/backend/tests/test_reports_payments.py @@ -7,7 +7,13 @@ from apps.accounts import rbac from apps.bookings.models import BookableSpace, Booking from apps.events.models import Event, EventRegistration -from apps.makerspaces.models import MakerspaceMembership, MakerspaceRole +from apps.hardware_requests.models import HardwareRequest +from apps.makerspaces.models import ( + MakerspaceMembership, + MakerspaceRole, + MembershipPlan, + MembershipTerm, +) from apps.operations.reports_payments import build_payment_reconciliation from apps.payments.models import Payment from tests.return_helpers import authenticated_client, make_member, make_space, make_user @@ -40,6 +46,18 @@ def add_payment(space, actor, subject_type, subject_id, amount, currency="usd", subject_id = MakerspaceMembership.objects.get( makerspace=space, user=actor ).pk + elif subject_type == Payment.SubjectType.MEMBERSHIP_TERM: + plan = MembershipPlan.objects.create( + makerspace=space, name=f"Payment report plan {subject_id}", interval="monthly", + ) + subject_id = MembershipTerm.objects.create( + membership=MakerspaceMembership.objects.get(makerspace=space, user=actor), + plan=plan, starts_at=now - timedelta(days=30), ends_at=now + timedelta(days=1), + ).pk + elif subject_type in Payment.LOAN_SUBJECT_TYPES: + subject_id = HardwareRequest.objects.create( + makerspace=space, requester=actor, requester_username=actor.username, + ).pk row = Payment.objects.create( makerspace=space, subject_type=subject_type, subject_id=subject_id, member=actor, amount=Decimal(amount), currency=currency, status=status, diff --git a/backend/tests/test_reports_typed.py b/backend/tests/test_reports_typed.py index 6f9074e6..36438ae1 100644 --- a/backend/tests/test_reports_typed.py +++ b/backend/tests/test_reports_typed.py @@ -42,7 +42,10 @@ def test_report_json_adds_typed_rows_without_changing_raw_rows_or_csv_export(): ) assert export.status_code == 200 - assert export.content.decode().splitlines() == [ + lines = export.content.decode().splitlines() + # Line 0 is the per-file provenance row (who/when/which report); the data follows. + assert lines[0].startswith("# generated_at=") + assert lines[1:] == [ "product_name,times_lent,total_quantity_lent", "Typed Scope,1,2", ] diff --git a/backend/tests/test_request_id.py b/backend/tests/test_request_id.py new file mode 100644 index 00000000..85c37dd3 --- /dev/null +++ b/backend/tests/test_request_id.py @@ -0,0 +1,115 @@ +"""Request-id correlation: middleware, contextvar, Celery propagation.""" +import logging + +import pytest +from django.test import RequestFactory +from django.http import HttpResponse + +from config import celery_signals +from config.request_id import ( + REQUEST_ID_HEADER, + RequestIdMiddleware, + get_request_id, + normalize_request_id, + reset_request_id, + set_request_id, +) + + +def _run(request): + seen = {} + + def view(_request): + seen["during"] = get_request_id() + seen["attr"] = getattr(_request, "request_id", None) + return HttpResponse("ok") + + response = RequestIdMiddleware(view)(request) + return response, seen + + +def test_incoming_header_is_honoured_and_echoed(): + request = RequestFactory().get("/api/v1/health/", HTTP_X_REQUEST_ID="proxy-abc.123") + response, seen = _run(request) + assert seen["during"] == "proxy-abc.123" + assert seen["attr"] == "proxy-abc.123" + assert response[REQUEST_ID_HEADER] == "proxy-abc.123" + # The binding is scoped to the request; nothing leaks into the caller's context. + assert get_request_id() is None + + +@pytest.mark.parametrize( + "bad", + ["", "has space", "new\nline", "x" * 65, "quote\"", "semi;colon"], +) +def test_unsafe_header_values_are_replaced_with_a_fresh_id(bad): + request = RequestFactory().get("/api/v1/health/", HTTP_X_REQUEST_ID=bad) + response, seen = _run(request) + minted = response[REQUEST_ID_HEADER] + assert minted != bad + assert len(minted) == 32 and all(c in "0123456789abcdef" for c in minted) + assert seen["during"] == minted + + +def test_missing_header_mints_an_id(): + response, seen = _run(RequestFactory().get("/api/v1/health/")) + assert response[REQUEST_ID_HEADER] == seen["during"] + assert normalize_request_id(None) != normalize_request_id(None) + + +def test_middleware_unbinds_even_when_the_view_raises(): + def view(_request): + raise RuntimeError("boom") + + with pytest.raises(RuntimeError): + RequestIdMiddleware(view)(RequestFactory().get("/")) + assert get_request_id() is None + + +def test_celery_publish_header_and_worker_rebind(): + token = set_request_id("req-42") + try: + headers = {} + celery_signals.propagate_request_id(headers=headers) + assert headers[celery_signals.HEADER] == "req-42" + # An explicit header already present wins (a retry re-publishes its own id). + headers = {celery_signals.HEADER: "earlier"} + celery_signals.propagate_request_id(headers=headers) + assert headers[celery_signals.HEADER] == "earlier" + finally: + reset_request_id(token) + + class FakeRequest: + headers = {celery_signals.HEADER: "worker-side"} + + class FakeTask: + request = FakeRequest() + + assert get_request_id() is None + celery_signals.bind_request_id(task=FakeTask) + assert get_request_id() == "worker-side" + celery_signals.unbind_request_id(task=FakeTask) + assert get_request_id() is None + + +def test_publish_without_a_bound_id_adds_nothing(): + headers = {} + celery_signals.propagate_request_id(headers=headers) + assert headers == {} + + +def test_log_records_carry_the_bound_request_id(caplog): + from config.log_setup import RequestIdFilter + + logger = logging.getLogger("tests.request_id") + logger.addFilter(RequestIdFilter()) + try: + token = set_request_id("rid-log") + try: + with caplog.at_level(logging.INFO, logger="tests.request_id"): + logger.info("hello") + finally: + reset_request_id(token) + assert caplog.records[-1].request_id == "rid-log" + finally: + logger.filters.clear() diff --git a/backend/tests/test_search_q.py b/backend/tests/test_search_q.py new file mode 100644 index 00000000..fb2ee66b --- /dev/null +++ b/backend/tests/test_search_q.py @@ -0,0 +1,95 @@ +"""The one `?q=` contract: full-text + trigram over trigger-maintained vectors, tenant-scoped.""" +from datetime import timedelta + +import pytest +from django.urls import reverse +from django.utils import timezone +from rest_framework.test import APIClient + +from apps.events.models import Event +from apps.inventory.models import InventoryProduct +from apps.inventory.search import apply_q, clean_query +from apps.machines.models import Machine, MachineType +from tests.return_helpers import authenticated_client, make_member, make_product, make_space + +pytestmark = pytest.mark.django_db + + +def test_trigger_fills_the_vector_on_insert_and_update(): + space = make_space("fts-space") + product = make_product(space, name="Cordless Drill", description="18V hammer drill") + product.refresh_from_db() + assert product.search_vector is not None + assert "drill" in str(product.search_vector) + InventoryProduct.objects.filter(pk=product.pk).update(name="Angle Grinder") + product.refresh_from_db() + assert "grinder" in str(product.search_vector) + assert "cordless" not in str(product.search_vector) + + +def test_apply_q_matches_words_phrases_and_typos_and_ranks_the_label_first(): + space = make_space("fts-rank") + make_product(space, name="Soldering Iron", description="temperature controlled") + make_product(space, name="Heat Gun", description="for soldering heat shrink") + make_product(space, name="Multimeter") + names = lambda q: list(apply_q(InventoryProduct.objects.filter(makerspace=space), q).values_list("name", flat=True)) + assert names("soldering") == ["Soldering Iron", "Heat Gun"] # label (A) outranks description (D) + assert names("soldring") == ["Soldering Iron"] # trigram catches the typo on the label + assert names('"heat shrink"') == ["Heat Gun"] + assert names("multimeter") == ["Multimeter"] + assert names("solder") == ["Soldering Iron", "Heat Gun"] # plain words match as prefixes + assert names("temp contr") == ["Soldering Iron"] + # A blank query leaves the queryset untouched (and unordered by rank). + assert set(names(" ")) == {"Heat Gun", "Multimeter", "Soldering Iron"} + assert len(clean_query("x" * 500)) == 200 + + +def test_public_inventory_search_is_tenant_scoped(): + alpha, beta = make_space("fts-alpha"), make_space("fts-beta") + make_product(alpha, name="Laser Cutter Goggles") + make_product(beta, name="Laser Cutter Goggles") + make_product(alpha, name="Bandsaw") + url = reverse("public-inventory", kwargs={"makerspace_slug": alpha.slug}) + payload = APIClient().get(url, {"q": "laser goggles"}).json() + assert payload["count"] == 1 + assert payload["results"][0]["name"] == "Laser Cutter Goggles" + payload = APIClient().get(url, {"q": "bandsaw"}).json() + assert [row["name"] for row in payload["results"]] == ["Bandsaw"] + + +def test_admin_inventory_search_still_matches_category_name(): + from apps.inventory.models import Category + + space = make_space("fts-admin") + manager = make_member("fts-admin-manager", space) + category = Category.objects.create(makerspace=space, name="Woodworking", slug="woodworking") + make_product(space, name="Chisel Set", category=category) + make_product(space, name="Oscilloscope") + client = authenticated_client(manager) + url = reverse("admin-inventory", kwargs={"makerspace_id": space.pk}) + by_category = client.get(url, {"q": "woodworking"}).json() + assert [row["name"] for row in by_category["results"]] == ["Chisel Set"] + by_name = client.get(url, {"q": "chisel"}).json() + assert [row["name"] for row in by_name["results"]] == ["Chisel Set"] + + +def test_machine_and_event_search(): + space = make_space("fts-machines") + manager = make_member("fts-machine-manager", space) + mtype = MachineType.objects.create(makerspace=space, name="Printers", slug="printers") + Machine.objects.create(makerspace=space, machine_type=mtype, name="Prusa MK4", location="Bench 2", is_public=True) + Machine.objects.create(makerspace=space, machine_type=mtype, name="Bambu X1", location="Bench 3", is_public=True) + machines = apply_q(Machine.objects.filter(makerspace=space), "prusa") + assert list(machines.values_list("name", flat=True)) == ["Prusa MK4"] + assert list(apply_q(Machine.objects.filter(makerspace=space), "bench 3").values_list("name", flat=True)) == ["Bambu X1"] + + start = timezone.now() + timedelta(days=2) + Event.objects.create(makerspace=space, title="Intro to Laser Cutting", starts_at=start, ends_at=start + timedelta(hours=2), is_public=True, status=Event.Status.PUBLISHED) + Event.objects.create(makerspace=space, title="Sewing Circle", starts_at=start, ends_at=start + timedelta(hours=2), is_public=True, status=Event.Status.PUBLISHED) + events = apply_q(Event.objects.filter(makerspace=space), "laser", label_field="title") + assert list(events.values_list("title", flat=True)) == ["Intro to Laser Cutting"] + client = authenticated_client(manager) + url = reverse("admin-event-list-create", kwargs={"makerspace_id": space.pk}) + payload = client.get(url, {"q": "sewing"}).json() + titles = [row["title"] for row in payload["results"]] + assert titles == ["Sewing Circle"] diff --git a/backend/tests/test_seed_e2e.py b/backend/tests/test_seed_e2e.py new file mode 100644 index 00000000..dd153c99 --- /dev/null +++ b/backend/tests/test_seed_e2e.py @@ -0,0 +1,43 @@ +import json + +import pytest +from django.core.management import CommandError, call_command + +from apps.hardware_requests.models import HardwareRequest +from apps.makerspaces.models import Makerspace + +pytestmark = pytest.mark.django_db + + +def test_seed_e2e_creates_a_fresh_makerspace_with_both_requests(settings, tmp_path, monkeypatch): + settings.DEBUG = True + monkeypatch.delenv("E2E_SEED_ALLOWED", raising=False) + out = tmp_path / "seed.json" + call_command("seed_e2e", "--write-json", str(out), "--password", "pw-for-test") + seeded = json.loads(out.read_text()) + space = Makerspace.objects.get(slug=seeded["slug"]) + assert space.public_inventory_enabled + assert space.products.filter(name=seeded["product"], storage_location=seeded["secret_location"]).exists() + pending = HardwareRequest.objects.get(pk=seeded["pending_request_id"]) + probe = HardwareRequest.objects.get(pk=seeded["probe_request_id"]) + assert pending.status == HardwareRequest.Status.PENDING_APPROVAL + assert probe.status == HardwareRequest.Status.ACCEPTED + assert seeded["box_code"] == space.boxes.get().code if hasattr(space, "boxes") else True + # Two runs never collide: fresh slug, fresh users, fresh box code. + out2 = tmp_path / "seed2.json" + call_command("seed_e2e", "--write-json", str(out2), "--password", "pw-for-test") + second = json.loads(out2.read_text()) + assert second["slug"] != seeded["slug"] + assert second["manager"]["username"] != seeded["manager"]["username"] + + +def test_seed_e2e_refuses_outside_development(settings, monkeypatch): + settings.DEBUG = False + monkeypatch.delenv("E2E_SEED_ALLOWED", raising=False) + before = Makerspace.objects.count() + with pytest.raises(CommandError): + call_command("seed_e2e") + assert Makerspace.objects.count() == before + monkeypatch.setenv("E2E_SEED_ALLOWED", "1") + call_command("seed_e2e", "--password", "pw-for-test") + assert Makerspace.objects.count() == before + 1 diff --git a/backend/tests/test_webhook_channel.py b/backend/tests/test_webhook_channel.py new file mode 100644 index 00000000..58793579 --- /dev/null +++ b/backend/tests/test_webhook_channel.py @@ -0,0 +1,173 @@ +"""The `webhook` notification channel: signed JSON to the makerspace's own endpoint.""" +import hashlib +import hmac +import json + +import pytest +from django.urls import reverse + +from apps.integrations import webhooks +from apps.integrations.dispatch_channels import _channel_configured, _deliver_notification +from apps.integrations.models import NotificationDeliveryLog, NotificationDestination +from apps.integrations.notification_enums import ( + NotificationDeliveryStatus, + NotificationFeature, + NonEmailNotificationChannel, +) +from apps.makerspaces.module_install import install_module +from apps.makerspaces.module_purge_plans import PLANS +from tests.return_helpers import authenticated_client, make_member, make_space + +pytestmark = pytest.mark.django_db + +SECRET = "correct-horse-battery-staple-32" + + +def _destination(space, **overrides): + dest = NotificationDestination( + makerspace=space, channel="webhook", label=overrides.pop("label", "ERP"), + is_active=True, + ) + dest.set_webhook_url(overrides.pop("url", "https://hooks.example.org/spaceworks")) + if overrides.pop("secret", SECRET): + dest.set_signing_secret(SECRET) + dest.save() + return dest + + +def _log(space, dest, **fields): + return NotificationDeliveryLog.objects.create( + makerspace=space, channel="webhook", destination=dest, destination_label=dest.label, + feature=NotificationFeature.HARDWARE_REQUESTS, event="request.accepted", + text_body="Request #12 accepted", payload={"request_id": 12}, **fields, + ) + + +def test_signature_is_hmac_over_timestamp_dot_body(): + body = b'{"a":1}' + header = webhooks.sign_webhook_body(SECRET, body, 1_700_000_000) + assert header.startswith("t=1700000000,v1=") + expected = hmac.new(SECRET.encode(), b"1700000000." + body, hashlib.sha256).hexdigest() + assert header == f"t=1700000000,v1={expected}" + + +def test_delivery_posts_signed_json_and_marks_sent(monkeypatch): + space = make_space("wh-space") + if _needs_install(space): + install_module(space, "webhook", actor=None) + dest = _destination(space) + log = _log(space, dest) + sent = {} + + def fake_deliver(url, payload, extra_headers=None): + sent["url"], sent["payload"], sent["headers"] = url, payload, extra_headers + + monkeypatch.setattr(webhooks, "_deliver", fake_deliver) + _deliver_notification(log) + log.refresh_from_db() + assert log.status == NotificationDeliveryStatus.SENT + assert sent["url"] == "https://hooks.example.org/spaceworks" + body = json.loads(sent["payload"]) + assert body == { + "id": log.pk, "event": "request.accepted", "feature": "hardware_requests", + "makerspace_id": space.pk, "text": "Request #12 accepted", "data": {"request_id": 12}, + "created_at": log.created_at.isoformat(), + } + header = sent["headers"][webhooks.SIGNATURE_HEADER] + t, v1 = header.split(",") + ts = int(t[2:]) + assert v1 == "v1=" + hmac.new(SECRET.encode(), f"{ts}.".encode() + sent["payload"], hashlib.sha256).hexdigest() + assert sent["headers"][webhooks.EVENT_HEADER] == "request.accepted" + assert sent["headers"][webhooks.DELIVERY_HEADER] == str(log.pk) + + +def _needs_install(space): + from apps.makerspaces.platform import module_enabled + + return not module_enabled(space, "webhook") + + +def test_without_a_secret_or_destination_the_channel_is_not_configured(): + space = make_space("wh-unconfigured") + assert _channel_configured(space, NonEmailNotificationChannel.WEBHOOK) is False + dest = _destination(space, secret=None) + assert _channel_configured(space, NonEmailNotificationChannel.WEBHOOK, dest) is False + dest.set_signing_secret(SECRET) + dest.save() + assert _channel_configured(space, NonEmailNotificationChannel.WEBHOOK, dest) is True + + +def test_failed_delivery_is_recorded_not_raised(monkeypatch): + space = make_space("wh-fail") + if _needs_install(space): + install_module(space, "webhook", actor=None) + dest = _destination(space) + log = _log(space, dest) + + def boom(url, payload, extra_headers=None): + raise webhooks.WebhookDeliveryError("Webhook delivery failed.") + + monkeypatch.setattr(webhooks, "_deliver", boom) + _deliver_notification(log) + log.refresh_from_db() + assert log.status == NotificationDeliveryStatus.FAILED + assert log.attempts == 1 + assert "hooks.example.org" not in log.error # the URL is a secret; never in the error text + + +def test_module_off_skips_instead_of_sending(monkeypatch): + from apps.integrations.dispatch_channels import channel_module_blocks + from apps.makerspaces.platform import module_enabled + + from tests.module_helpers import disable_module + + # The test fixtures give a new makerspace every module; uninstall this one explicitly. + space = make_space("wh-off") + disable_module(space, "webhook") + space.refresh_from_db() + assert module_enabled(space, "webhook") is False + assert channel_module_blocks(space, "webhook") is True + dest = _destination(space) + log = _log(space, dest) + monkeypatch.setattr(webhooks, "_deliver", lambda *a, **k: pytest.fail("must not send")) + _deliver_notification(log) + log.refresh_from_db() + assert log.status == NotificationDeliveryStatus.SKIPPED + assert log.error == "notification_channel_module_disabled" + + +def test_staff_api_requires_a_secret_and_never_echoes_it(monkeypatch): + # The serializer resolves the hostname to refuse private ranges; no DNS in unit tests. + monkeypatch.setattr( + "apps.admin_api.serializers_notification_destinations.validate_webhook_url", lambda value: value + ) + space = make_space("wh-api") + if _needs_install(space): + install_module(space, "webhook", actor=None) + manager = make_member("wh-manager", space) + client = authenticated_client(manager) + url = reverse("admin-notification-destinations", kwargs={"makerspace_id": space.pk}) + base = {"channel": "webhook", "label": "ERP", "webhook_url": "https://hooks.example.org/x"} + refused = client.post(url, base, format="json") + assert refused.status_code == 400 and "signing_secret" in refused.json() + created = client.post(url, {**base, "signing_secret": SECRET}, format="json") + assert created.status_code == 201, created.content + body = created.json() + assert body["signing_secret_set"] is True and body["credential_set"] is True + assert SECRET not in created.content.decode() + assert "hooks.example.org" not in created.content.decode() + # A Slack room must not carry a signing secret. + slack = client.post(url, {"channel": "slack", "label": "Ops", "webhook_url": "https://hooks.slack.com/services/x", "signing_secret": SECRET}, format="json") + assert slack.status_code == 400 and "signing_secret" in slack.json() + + +def test_purge_plan_deletes_only_webhook_destinations(): + space = make_space("wh-purge") + dest = _destination(space) + other = NotificationDestination(makerspace=space, channel="slack", label="Ops") + other.set_webhook_url("https://hooks.slack.com/services/x") + other.save() + plan = next(plan for plan in PLANS if plan.key == "webhook") + plan.delete(space, None) + assert not NotificationDestination.objects.filter(pk=dest.pk).exists() + assert NotificationDestination.objects.filter(pk=other.pk).exists() diff --git a/backend/tests/tombstone/conftest.py b/backend/tests/tombstone/conftest.py index 5e96bf05..a2c4d2ab 100644 --- a/backend/tests/tombstone/conftest.py +++ b/backend/tests/tombstone/conftest.py @@ -26,9 +26,12 @@ from apps.separability.tombstones import tombstoned_app_labels # The apps these tests expect to be tombstoned. Grows by one per phase of plan B6. +# Compared against `tombstoned_app_labels()`, which TRANSLATES renamed labels -- so the +# env may still be written `payments` (as every existing deployment and the docs have it) +# while the profile resolves to `payments_rail`. TOMBSTONE_PROFILE_APPS = frozenset({ "procurement", "notifications", "warranty", "maintenance", "presence", "events", "bookings", - "payments", "tenant_migration", "updates", + "payments_rail", "tenant_migration", "updates", }) _PROFILE_ACTIVE = tombstoned_app_labels() == TOMBSTONE_PROFILE_APPS @@ -45,7 +48,8 @@ def pytest_configure(config): return raise pytest.UsageError( "tests/tombstone must run under the tombstone profile. Expected " - f"TOMBSTONED_APPS={','.join(sorted(TOMBSTONE_PROFILE_APPS))}, got " + f"TOMBSTONED_APPS={','.join(sorted(TOMBSTONE_PROFILE_APPS))} " + "(`payments` is accepted for `payments_rail`), got " f"{','.join(sorted(tombstoned_app_labels())) or '(unset)'}. Set the variable " "in the environment before pytest starts -- it is read while Django settings " "are imported, so an override_settings is too late." diff --git a/backend/tests/tombstone/test_payments_removed_surfaces.py b/backend/tests/tombstone/test_payments_removed_surfaces.py index bbd3e447..0f4ef902 100644 --- a/backend/tests/tombstone/test_payments_removed_surfaces.py +++ b/backend/tests/tombstone/test_payments_removed_surfaces.py @@ -1,12 +1,17 @@ -"""apps/payments under the tombstone profile. +"""apps/payments_rail under the tombstone profile. -A makerspace that takes no money online ships no Stripe surfaces at all. The models stay, -because historic charges must remain readable, purgeable and nameable by the retention -registry long after the deployment stops selling anything. +A deployment that takes no money online ships no provider surfaces: checkout, the native +payment sheet, Connect, refunds and every webhook. -The most important assertion here is the webhook one: an endpoint that still accepted and -verified a Stripe event would settle money into a system whose reconciliation console no -longer exists, and nobody would ever see it. +The contract INVERTED when the rail was split out of the ledger. It used to be that +tombstoning `payments` removed the reconciliation console and the member's payment +history too -- which, once money owed could be recorded without any gateway, meant such a +deployment accrued debts nobody could read or settle. The ledger is permanently core now, +so this file asserts both halves: the rail is gone, and the ledger is emphatically not. + +The most important assertion is still the webhook one: an endpoint that accepted and +verified a Stripe event on a deployment shipping no provider code would settle money +through a rail that is not there. """ import pytest @@ -27,11 +32,24 @@ # -------------------------------------------------------------------------- -# Surfaces: gone. +# The rail: gone. # -------------------------------------------------------------------------- -def test_the_app_is_registered_as_inactive(): - assert runtime_active("payments") is False +def test_the_rail_is_registered_as_inactive_and_the_ledger_is_not(): + assert runtime_active("payments_rail") is False + # The ledger app is not separable at all, so it never registers a tombstone and + # `runtime_active` answers True for it. + assert runtime_active("payments") is True + + +def test_the_legacy_payments_label_still_tombstones_the_rail(): + """`TOMBSTONED_APPS=payments` is what existing deployments have written down. + + It must keep meaning "ship no online payments" rather than failing startup with + separability.E007 on upgrade -- this whole profile runs under that spelling. + """ + assert "payments_rail" in unavailable_apps() + assert "payments" not in unavailable_apps() @pytest.mark.parametrize( @@ -40,36 +58,18 @@ def test_the_app_is_registered_as_inactive(): "/api/v1/webhooks/stripe/connect", "/api/v1/webhooks/stripe/abc123", "/api/v1/payments/connect/callback", - "/api/v1/admin/makerspace/1/payments", - "/api/v1/admin/makerspace/1/payments/bulk/mark-offline", - ], -) -def test_no_payment_route_resolves(path): - with pytest.raises(Resolver404): - resolve(path) - - -@pytest.mark.parametrize( - "path", - [ - "/api/v1/member/makerspaces/1/payments", + "/api/v1/admin/makerspace/1/payments/2/refund", "/api/v1/member/makerspaces/1/payments/2/checkout", "/api/v1/member/makerspaces/1/payments/2/mobile-intent", ], ) -def test_no_member_payment_route_resolves(path): +def test_no_rail_route_resolves(path): with pytest.raises(Resolver404): resolve(path) -def test_neighbouring_member_route_still_resolves(): - match = resolve("/api/v1/member/makerspaces/1/referrals") - - assert match.url_name == "member-referrals" - - def test_the_stripe_webhook_does_not_answer(): - """The one that matters: a live webhook would settle charges nothing can reconcile.""" + """The one that matters: no provider code is shipped to settle against.""" response = APIClient().post("/api/v1/webhooks/stripe/abc123", {}, format="json") assert response.status_code == 404 @@ -79,22 +79,16 @@ def test_the_stripe_webhook_does_not_answer(): [ "/api/v1/admin/platform/payment-settings", "/api/v1/admin/makerspace/1/payment-settings", - "/api/v1/admin/machine-service/payments/1/waive", ], ) -def test_no_staff_payment_route_resolves(path): +def test_no_credential_route_resolves(path): # These live in `admin_api`'s urlconf rather than the app's own, so they need the # in-place `_separable` gate instead of a dropped include(). with pytest.raises(Resolver404): resolve(path) -def test_the_neighbours_in_the_same_urlconf_still_resolve(): - """The splice must remove the payment routes only, not the block around them.""" - assert resolve("/api/v1/admin/memberships").url_name == "admin-memberships-roster" - - -def test_the_admin_does_not_register_the_models(): +def test_the_admin_does_not_register_the_credential_models(): assert MakerspacePaymentSettings not in admin.site._registry assert PlatformStripeConnectSettings not in admin.site._registry @@ -110,10 +104,35 @@ def test_the_sidebar_offers_no_payment_entry(): assert "Stripe Connect" not in titles -def test_the_frontend_is_told_the_app_is_unavailable(): - # Payments owns feature keys, not a module key, so there is no key for - # `available_modules` to drop -- this list is how the console hides the tab. - assert "payments" in unavailable_apps() +# -------------------------------------------------------------------------- +# The ledger: emphatically still here. +# -------------------------------------------------------------------------- + +@pytest.mark.parametrize( + "path", + [ + "/api/v1/admin/makerspace/1/payments", + "/api/v1/admin/makerspace/1/payments/2/mark-offline", + "/api/v1/admin/makerspace/1/payments/2/waive", + "/api/v1/admin/makerspace/1/payments/2/amend-settlement", + "/api/v1/admin/makerspace/1/payments/bulk/mark-offline", + "/api/v1/member/makerspaces/1/payments", + "/api/v1/member/archived-payments", + ], +) +def test_every_ledger_route_still_resolves(path): + """Money owed must stay readable and settleable with no gateway anywhere in sight. + + This is the whole point of the split: a cash-only deployment records debts, shows + members what they owe, and lets staff settle them at the desk. + """ + assert resolve(path) is not None + + +def test_the_neighbours_in_the_same_urlconf_still_resolve(): + """The splice must remove the rail routes only, not the block around them.""" + assert resolve("/api/v1/admin/memberships").url_name == "admin-memberships-roster" + assert resolve("/api/v1/member/makerspaces/1/referrals").url_name == "member-referrals" # -------------------------------------------------------------------------- diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index f37d9aaa..1e29f783 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -119,7 +119,7 @@ services: DATABASE_URL: ${DATABASE_URL:?use scripts/spaceworks-compose.sh} SPACEWORKS_DB_POINTER_GENERATION: ${SPACEWORKS_DB_POINTER_GENERATION:?use scripts/spaceworks-compose.sh} MANAGED_POSTGRES: ${MANAGED_POSTGRES:-False} - CONN_MAX_AGE: ${CONN_MAX_AGE:-0} + CONN_MAX_AGE: ${CONN_MAX_AGE:-60} DISABLE_SERVER_SIDE_CURSORS: ${DISABLE_SERVER_SIDE_CURSORS:-False} CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-} # Absolute base for links in outbound email. Unset => links are emitted as bare @@ -200,6 +200,55 @@ services: - ${SPACEWORKS_OPS_HOST_DIR:-/var/lib/spaceworks/ops}/operation.lock:/var/lib/spaceworks/ops/operation.lock - ${SPACEWORKS_HOST_STATE_DIR:-/var/lib/spaceworks/host}/public:/run/spaceworks-host:ro - ./scripts:/run/spaceworks-privileged-scripts:ro + # Server-Sent Events stream (/api/v1/live/): dedicated process so gunicorn's worker + # recycling (--max-requests on `backend`) never severs an open stream. See frontend/nginx.conf. + live: + image: ${MAKERSPACE_BACKEND_IMAGE:-ghcr.io/spaceworks-hq/spaceworks-backend}:${MAKERSPACE_IMAGE_TAG:-latest} + restart: unless-stopped + environment: *backend-env + command: ["--role", "backend", "gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000", "-k", "gthread", "--threads", "${LIVE_THREADS:-32}", "--timeout", "0", "--access-logfile", "-", "--error-logfile", "-"] + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + orchestration-ready: + condition: service_completed_successfully + volumes: + - ${SPACEWORKS_HOST_STATE_DIR:-/var/lib/spaceworks/host}/public:/run/spaceworks-host:ro + + # Single-box shape, published as its own image (see Dockerfile.allinone). Activated only + # by SPACEWORKS_COMPOSE_LAYER=single through scripts/spaceworks-compose.sh. + app: + profiles: ["single"] + image: ${MAKERSPACE_ALLINONE_IMAGE:-ghcr.io/spaceworks-hq/spaceworks-allinone}:${MAKERSPACE_IMAGE_TAG:-latest} + restart: unless-stopped + environment: + <<: *backend-env + TENANT_API_URL: ${TENANT_API_URL:-/api} + TENANT_TOKEN: ${TENANT_TOKEN:-} + ports: + - "${HTTP_PORT:-80}:8080" + healthcheck: + test: ["CMD-SHELL", "curl -sf http://127.0.0.1:8080/api/v1/health/readiness/ >/dev/null"] + interval: 15s + timeout: 5s + retries: 8 + start_period: 90s + depends_on: + db: + condition: service_healthy + createbuckets: + condition: service_completed_successfully + orchestration-ready: + condition: service_completed_successfully + volumes: + - ${SPACEWORKS_OPS_HOST_DIR:-/var/lib/spaceworks/ops}/work:/var/lib/spaceworks/ops/work + - ${SPACEWORKS_OPS_HOST_DIR:-/var/lib/spaceworks/ops}/operation.lock:/var/lib/spaceworks/ops/operation.lock + - ${SPACEWORKS_HOST_STATE_DIR:-/var/lib/spaceworks/host}/public:/run/spaceworks-host:ro + - ./scripts:/run/spaceworks-privileged-scripts:ro + - allinone_state:/var/lib/spaceworks/redis + orchestration-init: <<: *host-bootstrap depends_on: @@ -305,3 +354,4 @@ volumes: makerspace_manager_pgdata: minio_data: celerybeat_data: + allinone_state: diff --git a/docker-compose.yml b/docker-compose.yml index 7b2a571a..a24aec59 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -92,7 +92,7 @@ services: DATABASE_URL: ${DATABASE_URL:-postgres://${POSTGRES_APP_USER:-spaceworks_app}:${POSTGRES_APP_PASSWORD:-spaceworks-app-dev}@db:5432/makerspace_manager} SPACEWORKS_DB_POINTER_GENERATION: ${SPACEWORKS_DB_POINTER_GENERATION:-1} MANAGED_POSTGRES: ${MANAGED_POSTGRES:-False} - CONN_MAX_AGE: ${CONN_MAX_AGE:-0} + CONN_MAX_AGE: ${CONN_MAX_AGE:-60} DISABLE_SERVER_SIDE_CURSORS: ${DISABLE_SERVER_SIDE_CURSORS:-False} CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-http://localhost,http://localhost:5173} API_CLIENT_ENC_KEY: ${API_CLIENT_ENC_KEY:-} @@ -173,6 +173,60 @@ services: orchestration-ready: condition: service_completed_successfully + # Server-Sent Events stream (/api/v1/live/). Its own process because SSE holds a connection + # open: thread workers, no request timeout, and NO --max-requests recycling, which would + # sever every open stream. Same image, same env, same fail-closed entrypoint role. + live: + build: + context: ./backend + container_name: spaceworks-live + environment: *backend-env + command: ["--role", "backend", "gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000", "-k", "gthread", "--threads", "32", "--timeout", "0"] + volumes: + - ${SPACEWORKS_HOST_STATE_DIR:-/var/lib/spaceworks/host}/public:/run/spaceworks-host:ro + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + orchestration-ready: + condition: service_completed_successfully + + # The single-box shape (docker/compose.single.yml + `--profile single`): nginx, the built + # frontend, gunicorn, the SSE process, a worker, the scheduler loop and Redis in one + # container. Not started by a plain `up`; the overlay parks the multi-service processes. + app: + profiles: ["single"] + build: + context: . + dockerfile: Dockerfile.allinone + container_name: spaceworks-app + environment: + <<: *backend-env + TRUSTED_PROXY_COUNT: ${TRUSTED_PROXY_COUNT:-1} + TENANT_API_URL: ${TENANT_API_URL:-/api} + TENANT_TOKEN: ${TENANT_TOKEN:-} + ports: + - "${HTTP_PORT:-80}:8080" + healthcheck: + test: ["CMD-SHELL", "curl -sf http://127.0.0.1:8080/api/v1/health/ >/dev/null"] + interval: 15s + timeout: 5s + retries: 8 + start_period: 60s + volumes: + - ${SPACEWORKS_OPS_HOST_DIR:-/var/lib/spaceworks/ops}:/var/lib/spaceworks/ops + - ${SPACEWORKS_HOST_STATE_DIR:-/var/lib/spaceworks/host}/public:/run/spaceworks-host:ro + - ./scripts:/run/spaceworks-privileged-scripts:ro + - allinone_state:/var/lib/spaceworks/redis + depends_on: + db: + condition: service_healthy + createbuckets: + condition: service_completed_successfully + orchestration-ready: + condition: service_completed_successfully + orchestration-init: build: context: ./backend @@ -305,3 +359,4 @@ volumes: makerspace_manager_pgdata: minio_data: celerybeat_data: + allinone_state: diff --git a/docker/allinone/nginx.conf b/docker/allinone/nginx.conf new file mode 100644 index 00000000..bdef048d --- /dev/null +++ b/docker/allinone/nginx.conf @@ -0,0 +1,88 @@ +# nginx for the single-box image: same routing as frontend/nginx.conf, but every upstream is +# a process in this container and nginx runs unprivileged (port 8080, writable paths under +# /var/lib/spaceworks/nginx). Keep the two files in step when a location changes. +pid /var/lib/spaceworks/nginx/nginx.pid; +error_log /dev/stderr warn; +worker_processes 1; +daemon off; + +events { worker_connections 512; } + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + access_log /dev/stdout; + client_body_temp_path /var/lib/spaceworks/nginx/body; + proxy_temp_path /var/lib/spaceworks/nginx/proxy; + fastcgi_temp_path /var/lib/spaceworks/nginx/fastcgi; + uwsgi_temp_path /var/lib/spaceworks/nginx/uwsgi; + scgi_temp_path /var/lib/spaceworks/nginx/scgi; + sendfile on; + client_max_body_size 110m; + + server { + listen 8080; + server_name _; + root /srv/www; + index index.html; + + location = /config.js { + add_header Cache-Control "no-store"; + try_files /config.js =404; + } + + location /api/v1/internal/ { return 403; } + + location ~ ^/api/v1/public/[^/]+/event-calendar/[^/]+\.ics$ { + access_log off; + proxy_pass http://127.0.0.1:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # SSE: the dedicated gthread gunicorn on 8001, no buffering, no recycling. + location /api/v1/live/ { + proxy_pass http://127.0.0.1:8001/api/v1/live/; + proxy_http_version 1.1; + proxy_set_header Connection ""; + proxy_buffering off; + proxy_cache off; + proxy_read_timeout 3600s; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /api/ { + proxy_pass http://127.0.0.1:8000/api/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # /control/ is deliberately NOT proxied: the Django control plane is reached only on + # the backend port itself, exactly as in the multi-service image. + + location /static/ { + proxy_pass http://127.0.0.1:8000/static/; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location ~ ^/(docs|redoc|schema)/ { + proxy_pass http://127.0.0.1:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location / { + try_files $uri $uri/ /index.html; + } + } +} diff --git a/docker/allinone/run.sh b/docker/allinone/run.sh new file mode 100644 index 00000000..f8284a45 --- /dev/null +++ b/docker/allinone/run.sh @@ -0,0 +1,56 @@ +#!/bin/sh +# Process supervisor for the single-box image. Deliberately a shell script and not a +# supervisor daemon: the container must die when any essential process dies, so the +# orchestrator restarts the whole unit and no half-alive box serves requests without a +# worker or without its stream. Every Django process is launched through the fail-closed +# entrypoint with its own role, exactly as the multi-service compose files do. +set -eu + +ENTRYPOINT="python /app/scripts/spaceworks_entrypoint.py" +export CELERY_BROKER_URL="${CELERY_BROKER_URL:-redis://127.0.0.1:6379/0}" +export CACHE_URL="${CACHE_URL:-redis://127.0.0.1:6379/1}" +export LIVE_REDIS_URL="${LIVE_REDIS_URL:-$CELERY_BROKER_URL}" + +# Runtime config for the SPA (TENANT_API_URL / TENANT_TOKEN), same script as the nginx image. +TENANT_CONFIG_PATH=/srv/www/config.js /app/allinone/write-config.sh + +mkdir -p /var/lib/spaceworks/nginx/body /var/lib/spaceworks/nginx/proxy \ + /var/lib/spaceworks/nginx/fastcgi /var/lib/spaceworks/nginx/uwsgi /var/lib/spaceworks/nginx/scgi + +pids="" +launch() { + name="$1"; shift + "$@" & + pid=$! + pids="$pids $pid" + echo "[allinone] started $name (pid $pid)" >&2 +} + +# Redis first (broker, cache, live pub/sub): bound to loopback only, persisted under +# /var/lib/spaceworks/redis so rate-limit counters survive a restart. +launch redis redis-server --bind 127.0.0.1 --port 6379 --dir /var/lib/spaceworks/redis \ + --save 60 1 --appendonly no --loglevel warning +until redis-cli -h 127.0.0.1 ping >/dev/null 2>&1; do sleep 0.5; done + +# Static files once, then the API, the SSE process, the worker and the scheduler loop. +$ENTRYPOINT --role management python manage.py collectstatic --noinput >/dev/null + +launch backend $ENTRYPOINT --role backend gunicorn config.wsgi:application \ + --bind 127.0.0.1:8000 --workers "${GUNICORN_WORKERS:-3}" --timeout "${GUNICORN_TIMEOUT:-60}" \ + --graceful-timeout 30 --max-requests 1000 --max-requests-jitter 100 \ + --access-logfile - --error-logfile - +launch live $ENTRYPOINT --role backend gunicorn config.wsgi:application \ + --bind 127.0.0.1:8001 -k gthread --threads "${LIVE_THREADS:-32}" --timeout 0 \ + --access-logfile - --error-logfile - +launch worker $ENTRYPOINT --role worker celery -A config worker -l info \ + --concurrency "${CELERY_CONCURRENCY:-2}" +launch cron $ENTRYPOINT --role cron sh -c \ + "while true; do python manage.py run_scheduled_tasks --due-only || true; python manage.py flush_email_outbox || true; sleep ${CRON_INTERVAL_SECONDS:-900}; done" +launch nginx nginx -c /app/allinone/nginx.conf + +# Exit as soon as any child exits; the container restart policy brings the box back whole. +wait -n $pids +status=$? +echo "[allinone] a process exited with status $status; stopping the box" >&2 +kill $pids 2>/dev/null || true +exit "$status" diff --git a/docker/compose.single.yml b/docker/compose.single.yml new file mode 100644 index 00000000..ccbb77bf --- /dev/null +++ b/docker/compose.single.yml @@ -0,0 +1,25 @@ +# Single-box overlay: ONE application container (Dockerfile.allinone: nginx + frontend + +# gunicorn + SSE + worker + scheduler + Redis) beside Postgres and MinIO. +# +# scripts/spaceworks-compose.sh bundled up -d with SPACEWORKS_COMPOSE_LAYER=single +# docker compose -f docker-compose.yml -f docker/compose.single.yml --profile single up -d +# +# The multi-service processes are parked behind a profile nobody activates, so this file +# removes nothing from the base and adds nothing that the base does not already declare: +# the `app` service itself lives in the base files under `profiles: ["single"]` so it can +# share the backend environment anchor (anchors do not cross files). +services: + backend: + profiles: ["multi"] + worker: + profiles: ["multi"] + beat: + profiles: ["multi"] + live: + profiles: ["multi"] + frontend: + profiles: ["multi"] + candidate-backend: + profiles: ["multi"] + # The base `redis` service stays up: `migrate` and the orchestration services inherit a Redis + # cache URL from the shared environment. The app container talks to its own Redis. diff --git a/docs/DEV-WORKFLOW.md b/docs/DEV-WORKFLOW.md index 3df07531..79847a2e 100644 --- a/docs/DEV-WORKFLOW.md +++ b/docs/DEV-WORKFLOW.md @@ -180,3 +180,27 @@ Host fallback (faster `pytest` / one-off `manage.py`; needs `backend/.venv` + `n - Public inventory page: `http://localhost:5000/m/makerspace` - API: `http://localhost:8000/api` — Swagger UI at `/docs/`, ReDoc at `/redoc/`, schema at `/schema/`. + +## The `live` service (SSE) + +`docker-compose.yml` and `docker-compose.prod.yml` run a second backend process, `live` +(`spaceworks-live`), that serves only `/api/v1/live/`: gunicorn `-k gthread --timeout 0` and **no** +`--max-requests`, because recycling a worker severs every open stream. `frontend/nginx.conf` routes +`/api/v1/live/` to it above the `/api/` block with buffering off. The host-run fallback (mode 3) has no +`live` process; the Vite proxy sends `/api/v1/live/` to the single runserver, which works for development +but holds a runserver thread per open tab. `LIVE_REDIS_URL` defaults to the Celery broker; empty (or the +eager, broker-less local flow) makes the endpoint answer 503 and the console keeps polling. + +## End-to-end tests (Playwright) + +`frontend/e2e/` drives a REAL stack — never mocks — and pins the Hard Rules through a browser and a real +presigned upload to object storage. `./scripts/e2e-local.sh` is the one recipe for a laptop and CI: it +migrates, runs `manage.py seed_e2e --reset` (the `e2e-space` makerspace only; refuses unless `DEBUG` or +`E2E_SEED_ALLOWED=1`), starts Django on :8100 and Vite on :5100 (so it can run beside a dev stack on +8000/5000), and runs `npx playwright test`. Same infrastructure overrides as `dev-local.sh test` +(`PG_PORT`, `MINIO_PORT`, `DATABASE_URL`); `PYTHON=python` when there is no `backend/.venv` (CI). +`E2E_KEEP=1` leaves the servers up for debugging. The seed constants live in +`frontend/e2e/helpers.ts` and `backend/apps/operations/management/commands/seed_e2e.py` — change one, +change the other. Unit-level accessibility checks use `frontend/src/test/axe.ts` (jsdom cannot compute +colour contrast; `e2e/a11y.spec.ts` covers that in Chromium). + diff --git a/docs/INVARIANTS.md b/docs/INVARIANTS.md index 9494fabd..1b10f2c1 100644 --- a/docs/INVARIANTS.md +++ b/docs/INVARIANTS.md @@ -344,6 +344,22 @@ per-makerspace breakdown is always present in the same response; (4) `ReportScop mode permitted to flatten, and only for that resolved set. Anything wider is still a regression. Deployment -wide aggregates are unchanged: they stay grouped by `makerspace_id`. + +**Report provenance and scheduled delivery (forward plan phase 6, 2026-09-04).** Every CSV/XLSX export +leaves through `apps/operations/report_exports.py` with an `ExportProvenance`: CSV line 1 is a raw +`# generated_at=… generated_by=… makerspace_id= report_key=… report_version=… filters=` row +and XLSX carries a second `Provenance` sheet; the data columns stay exactly the registry fields, CSV is +streamed and XLSX is written write-only, and `ReportDefinition.version` is bumped whenever a report's shape +changes. A `ReportSchedule` runs only through `run_report_schedules` → the same `report_rows` builder as the +manual export (`generated_by="schedule:"`), inside a per-tenant `fanout_tenant_write`, after a +`skip_locked` claim that advances `next_run_at` before any work (idempotent under a coarse cron); it is +skipped and audited (`report_schedule.skipped`) when the makerspace is not report-eligible or the creator no +longer holds the definition's `required_action`. Deliveries never carry bytes into chat: the file lives in +the private bucket under `reports//…`, recipients get a signed URL bounded by +`REPORT_DELIVERY_URL_TTL_SECONDS` (default 6h — the one presign that deliberately outlives a quiesce drain, +read-only), the object is swept after expiry, email delivery is link-only, and `ReportSchedule` is a Lane D +DROP like every other live disclosure rule. `recipient_emails` is staff-entered contact data and is NOT in +the encryption registry (no existing JSON-list email column to mirror) — an owner call if that should change. **Scoped PII encryption (Part H, `apps/encryption/`; dormant unless enabled).** Per-makerspace DEK via a key broker (local/AWS-KMS), AAD-authenticated envelope crypto, `ScopedPiiModelMixin` on the 6 PII-holding models with a save-boundary that single-INSERTs envelopes + dual-read cache. Blind-index search @@ -354,6 +370,16 @@ during maintenance; mapped services acquire the fence **before** their domain ro staged dual-read rollout; `decrypt_scoped_pii` is the fenced rollback. **Encryption is never enabled before H3 (search) ships.** +**`warranty.Warranty.vendor_contact` is NOT registered as PII — by decision, not omission (forward plan +phase 6, owner decision 6, 2026-09-04).** It is a free-text vendor/business contact (a support desk, a +reseller's sales line), and no vendor or business contact anywhere in `warranty` or `procurement` is +classified as scoped PII; the registries are self-consistent on that reading. The alternative — a +`procurement.Vendor` record with `ScopedPiiModelMixin` contact fields, a data migration moving each +distinct `vendor_contact` value into an encrypted vendor row, and a sweep test over contact-like columns +— is written up in `docs/plans/2026-09-03-forward-plan/phase-6-money-and-membership.md` ("Vendor record") +and should be built only if the owner decides vendors can be named individuals. Until then, do not +re-raise the question from a grep for "contact" columns; this entry is the answer. + **Custom editable per-makerspace roles (Part L).** The 5 legacy roles are now editable protected default `Role` rows; authority is **action-based** via the assigned role (dual-read with legacy fallback: `rbac.actions_for_membership` resolves assigned-role-first, tenant-match-else-fail-closed, strips @@ -521,6 +547,56 @@ and catastrophic for an existing one; migration `makerspaces/0050` is the one-ti reverse) that keeps every pre-existing space sending mail across the upgrade. Any future default-on module key needs the same treatment. +**The member dashboard is membership-module-gated; money is not (2026-09-06, D8).** +`member_activity_views` already calls `require_module(makerspace, "membership")`, so the +dashboard simply does not exist for a space that runs no memberships — and every frontend query +feeding it must include `membershipModuleOn` in its `enabled`, or it calls a gated endpoint and +errors. `member_dashboard_service` adds loan history, request history (previously only ACTIVE +self-checkout loans were visible, so a member could not see a request they submitted), dues, and +notices. Notices are DERIVED from member-owned rows: `notifications.Notification` is +makerspace-wide, has no recipient column and shares one `read_at`, so serving it would leak staff +alerts and let one member's read mark speak for everyone. Payment/receipt visibility sits OUTSIDE +the gate — `member_may_see_own_charges` admits an account with no membership that owns a charge +here, because a loan deposit is raised against a BORROWER who needs an active account rather than +a membership. It preserves every account-status clause and still refuses a revoked member, which +is a deliberate pre-existing contract. + +**A pending payment no longer refuses a tenant dump (2026-09-06, owner decision D5).** Both +refusals are gone — `tenant_dump_cross_tenant` and `_source_row_allowed`. What makes carrying an +unsettled debt safe: (1) `preflight._check_live_checkouts` refuses any pending row with a live +rail, and it now counts `stripe_payment_intent_id` as live, not just hosted sessions; (2) pending +rows are held to the same `PAYMENT_CLEARED_VALUES` projection as terminal ones, so no order id, +session, intent, checkout URL, connected account or routing survives to be resumed on the target; +(3) `money_digest.money_fingerprint` is taken INSIDE the capture gate over pending payments plus +the settlement chain, and `publish_tenant_dump` revalidates it under the custody lock, refusing +with `money_drift` when the source has moved on. Terminal rows are deliberately excluded from the +digest: they are immutable, so they cannot drift, and including them would make every ordinary +settlement read as drift. A blank digest (captures predating the field) is not revalidated — +blank means "not recorded", not "nothing owed". `provider` stays PRESERVE on pending rows: every +actual handle is cleared, so it is inert, and a target whose vendor differs simply resolves no +source and settles the charge offline. + +**The payment ledger cannot be tombstoned; the rail can (2026-09-06).** `apps.payments` holds +the models and every ledger surface and is NOT in `SEPARABLE_APPS`; `apps.payments_rail` holds +checkout, the native intent, Connect, refunds, credential settings and the webhooks, and is what +a tombstone removes. Before the split, `TOMBSTONED_APPS=payments` withdrew the reconciliation +console and the member's own payment history — which, once debts could be recorded with no +gateway, meant such a deployment accrued money owed that nobody could read or settle. Existing +deployments keep writing `payments` in their env: `RENAMED_LABELS` translates it, so upgrading +does not fail `separability.E007`. The machine-service mark-offline/waive routes became +unconditional for the same reason; only the credential routes moved to the rail gate. + +**Charge tracking is separate from the online rail (2026-09-06).** `charge_tracking_enabled` decides +whether a debt is RECORDED; `online_payments_enabled` decides only whether a Stripe/Razorpay rail may be +raised for it, and keeps all four of its clauses. The `charges.*` family carries tracking and is +deliberately free of any `payments` dependency, so uninstalling the module cannot prune it; `payments` +became **opt-in** at the same time. A charge raised with no gateway carries `provider=unclaimed` and is +claimed exactly once by the first checkout that reaches a provider, enforced by the payment +terminal-guard trigger. Settling one offline requires an append-only `ManualSettlement` receipt written +in the same transaction as the status flip. NOTE: the line below calling `payments.enabled` standalone is +stale — it is parented to the `payments` module, and the per-domain `payments.*` keys are parented to +their own domain modules with `payments` in `requires_modules`. + **A6 master switches are additive `AND`s, never replacements.** `payments.enabled`, `mobile.push` and `presence.geofence` are standalone (`parent_module=None`) features that sit **in front of** the readiness check each capability already had — `online_payments_enabled` still requires the per-domain @@ -588,6 +664,21 @@ intent down **one** path in `membership_services.invite_membership`, discriminat role granting actions is a staff invitation and must keep working with the module off. Module gates are **additive `AND`s** — `refer_membership` still checks `referrals_enabled` and `can_refer`. +**Membership plans, terms and invitation requests (forward plan phase 6, 2026-09-04).** Plans are optional +and never an access state: a `MembershipTerm` expiring changes neither `MakerspaceMembership.status` nor +`User.access_status`; the only effect is the per-makerspace opt-in `lapsed_members_cannot_borrow`, enforced +solely by `request_access.require_current_term` AFTER the who-may-submit rule has admitted the member +("active" = `status=active AND ends_at > now`, so it does not depend on the hourly sweep), and a member who +has never held a term is untouched. One renewal charge per term, raised only by +`membership_plan_services.run_membership_renewals` as `Payment(subject_type=membership_term, +subject_id=term.pk)` inside the 7-day window and only when `online_payments_enabled(ms, "membership")` — +the Payment unique constraint is the idempotency, a payment failure is logged and never touches the term, +and settlement does not auto-open the next term (staff do). An `InvitationRequest` is a lead, not authority: +name/email/phone are scoped source PII (encrypted at rest, purged with `membership`, `(PRESERVE, DROP)` in +tenant dumps), the public endpoint answers the same 202 to real and honeypotted submissions, and "Invite" +only ever calls `membership_services.invite_membership`, inheriting the role non-escalation and the +community/staff discrimination of a hand-typed invitation. + **Payments (Stripe, C.2/C.3; dormant until configured).** `apps/payments.Payment` is the **single payment authority** (one row per subject via unique `(makerspace, subject_type, subject_id)`; positive amount; statuses pending/paid_online/paid_offline/waived/canceled; terminal rows immutable — **enforced by a Postgres @@ -624,6 +715,23 @@ charges all create the same immutable `Payment` subject rows. Reconciliation is RBAC, reports/dashboard aggregates never flatten tenants, and offline/waive actions audit the actor and best-effort expire live online sessions. +**Refunds and loan charges (forward plan phase 6, 2026-09-04; owner decision 7 defaulted to "charging +allowed, off by default").** A `Payment` row never changes for a refund: refunds are `payments.Refund` +ledger lines on a `paid_online` Payment, the sum of PENDING+SUCCEEDED refunds never exceeds +`payment.amount` (enforced under the Payment row lock and in `Refund.clean()`), a settled refund is +immutable, provider I/O happens outside every row lock, and webhooks (`charge.refunded`/`refund.*`, +`refund.processed`) only ever settle a locally raised PENDING row through `_record_once` — a dashboard +refund this deployment never raised is logged, never invented. Loan charges (`LOAN_DEPOSIT`, +`LOAN_LATE_FEE`, one each per request by the subject uniqueness) are raised only by the workflow module, +post-commit, through `apps/hardware_requests/loan_payments.py`, and never block a handover or return — +except the one opt-in gate: with `loan_deposit_blocks_issue` on, `issue_request` refuses with +`deposit_required` (409) **before** the QR/evidence Hard Rules, fails OPEN on payment-system errors, and +the deposit it raised is the row the later issue accepts. A late fee is computed exactly once at close +(`ceil(days past due + grace) × per_day`, capped when the cap is > 0) and never recomputed; an uncollected +deposit is cancelled at close, a collected one is released only by a staff refund. `payments.loans` is an +additive AND behind `payments.enabled`, the `payments` module and resolvable credentials; off means nothing +is raised and lateness is only recorded. + **Native clients use attested device grants, never browser-token shortcuts.** Device login starts with a short-lived attestation challenge and creates a revocable `DeviceGrant`; access tokens carry `device_grant_id`, refresh tokens rotate in a grant family, and replay revokes that family. Native @@ -1149,6 +1257,88 @@ loan shows the contact the borrower gave rather than the principal's internal `m the principal is refused at the write side by `accounts.principal_guards.refuse_anonymous_requester_access_mutation` — it would restrict every future account-less requester at once — and the read-side exclusion is the backstop for rows predating that guard. +**Observability (forward plan phase 0, 2026-09-03).** Every request carries an `X-Request-ID`: honoured +from the caller only when it matches `^[A-Za-z0-9_.:-]{1,64}$`, minted otherwise, bound in a +`contextvars.ContextVar` by `config.request_id.RequestIdMiddleware` (third in `MIDDLEWARE`, after the +recovery gate pinned first and the calendar-feed log redactor second) and echoed on the response. Log +records carry it through `config.log_setup.RequestIdFilter`; Celery messages carry it in a +`spaceworks_request_id` header (`config/celery_signals.py`). **The id never enters audit `meta`** — the +row MAC covers `meta`, and correlation is done from the `audit_recorded` log line that `record()` emits +with the row's `event_uuid`. `GET /api/v1/metrics/` (Prometheus text) fails closed: 404 when +`METRICS_TOKEN` is unset, 401 on a wrong bearer, and it exposes counts and ids only, never tenant +content. It is outside `HMAC_PROTECTED_PATH_PREFIXES`, so it must NOT be added to the API-client scope +registry (an entry there would be stale). Any new `env(...)` read in `settings.py` must be listed in +`apps/backup/settings_policy.py::ENV_SURFACE`, or the env-surface drift guard fails. + +**Search and live updates (forward plan phase 1, 2026-09-03).** `?q=` is one contract, implemented once in +`apps/inventory/search.py::apply_q`: a `websearch`-syntax full-text match on a trigger-maintained +`search_vector` column ORed with trigram similarity on the primary label, ordered by rank. The vectors on +`inventory.InventoryProduct`, `machines.Machine` and `events.Event` are **derived** columns: filled by a +Postgres trigger (never by `save()`), declared in all three registries (`data_export` classification + +`ALWAYS_OMITTED`, `tenant_migration.OMITTED_FIELD_RECONSTRUCTIONS` DERIVED) and rebuilt on the target +after a tenant move. **A trigger may only concatenate plain columns — scoped-PII fields are never +indexed**, which is why `HardwareRequest` has no vector and its queue keeps the blind-index search, and +why the member directory matches username/display name/headline/institution only. Live updates ride the +audit log: `audit.services.record` schedules `operations.live.publish_audit_event` on commit, and the +payload is `{kind, makerspace_id, target_type, target_id, actor_id, ts}` — never `meta`, never content. +`GET /api/v1/live/` subscribes a session to its own user channel plus every makerspace +`rbac.scope_by_makerspace` returns; it is served by the separate `live` compose service (thread workers, +no timeout, no `--max-requests`) behind an nginx `location /api/v1/live/` that sits above `/api/` with +buffering off; it answers 503 when the deployment has no Redis and browsers keep polling. Neither +`/api/v1/metrics/` nor `/api/v1/live/` belongs in the API-client scope registry. + +**Browser and accessibility gates (forward plan phase 2, 2026-09-03).** The Hard Rules are pinned by +`frontend/e2e/` against a real stack (`scripts/e2e-local.sh`, CI job `e2e`): a request is accepted, +issued with a container code and a real presigned photo upload, and returned with container code, photo +and remark, and the API's 400s for a missing photo, box or remark are asserted directly. The public +catalogue spec asserts the seeded storage location, box label and box code never appear in the DOM. The +accessibility floor is enforced, not documented: `frontend/src/test/axe.ts` in panel tests (jsdom, no +colour contrast) and `e2e/a11y.spec.ts` in Chromium (WCAG 2.1 AA including contrast) — a violation is a +red build, and there is no allow-list. `text-ink/` for body copy is therefore off-limits; use the +`muted` token, which is tuned to clear 4.5:1 on every surface it sits on. + +**Signed webhooks (forward plan phase 3, 2026-09-03).** `webhook` is a notification channel, not a second +integration system: a `NotificationDestination` with `channel="webhook"` carries an encrypted endpoint URL +**and** an encrypted `signing_secret`, is routed by the same feature × channel matrix, and is written to the +same `NotificationDeliveryLog` with the same Celery retries. A destination without a signing secret is +*not-configured* (terminal FAILED), never sent unsigned. The body is `webhooks.webhook_event_body(log)` — +the notification's text, event, feature, ids and the matrix `payload` — i.e. exactly what a chat room +would receive; nothing from audit `meta` and no contact fields travel. The signature is +`t=,v1=." + body)>` over the exact bytes sent, mirroring the API-client +HMAC discipline. Endpoint URLs pass `webhook_validation.validate_webhook_url` (https, no private or +loopback ranges) at save time and are re-resolved and pinned at send time; a delivery error never stores +the URL. Both secrets are `ALWAYS_OMITTED` from export and DERIVED-omitted from tenant migration. The +superadmin "Re-queue failed deliveries" admin action is the dead-letter path and audits each row. + +**Editions and the single box (forward plan phase 4, 2026-09-03).** `SPACEWORKS_EDITION` is a +**deployment**-level setting (`apps/makerspaces/editions.py`), never per makerspace, exactly like +`member_accounts`/`updates` are read deployment-wide. An edition hides a fixed set of module keys: +`platform.available_modules()` (and therefore every bootstrap `modules` list and staff `enabled_modules` +payload) omits them, and the public loan/machine routes check `editions.public_surface_available(key)` +and answer 404 when hidden. It does **not** change `core_module_keys()`, `module_enabled()` or any +`require_module` gate: staff endpoints, workflows, migrations, purge plans and backups behave identically in +every edition, so a hidden surface is recoverable and nothing that was recorded becomes unreachable to +staff. `Event.makerspace` remains the tenancy anchor in all editions. The single-box image +(`Dockerfile.allinone`) launches every Django process through `scripts/spaceworks_entrypoint.py` with its +own `--role`, runs unprivileged, keeps `/control/` unproxied, and dies as a unit when any process dies +(`docker/allinone/run.sh`); the install shape persists in `.spaceworks-layer`. + +**Member ID cards (forward plan phase 5, 2026-09-03).** `makerspaces.MemberCard` is `membership`-module +behaviour with no module key: its QR is a core `boxes.QrCode` with `target_type=member_card`, so +revocation, active-target uniqueness and the immutable `QrScanEvent` history are the ones every QR has. +**Identity never travels through the inventory scanner**: `qr_target_payload` and `QrResolveView` raise +404 for a member-card target; only `member_card_services.resolve` (action `scan_member_cards`) may resolve +one, it records a `member_lookup` scan even when refused, and it refuses uniformly (a foreign tenant's +card, a box QR and garbage all 404). Authority is by action, never role name: `manage_member_cards` +implies `scan_member_cards`, is granted to the protected Space Manager default (migration 0069 backfills +existing rows), and custom roles receive nothing until granted. `printed_name` is scoped source PII +(`encryption/registry.py`); `photo_object_key` is a PRIVATE object (`backup/object_ownership_registry.py`) +whose final key is never client-writable (staging presign → single promotion in +`member_card_storage.finalize_photo`) and whose bytes need recorded consent. **Revoke redacts +immediately** (photo bytes deleted, quota freed, name blanked); reissue rotates the QR and the old payload +resolves as `revoked` forever; reprint never rotates. No rendered PDF is written to storage. The +`membership` purge revokes the QRs and deletes the cards and photo bytes. + ## Handover roles and the retired Guest Admin **Guest Admin is no longer a built-in role** (migration `makerspaces/0052`); handover staff get a **custom @@ -1724,6 +1914,23 @@ Load-bearing details that carried over unchanged: table; Django applies field defaults in Python rather than DDL, so the INSERT omits newer columns and Postgres rejects the NOT NULL. Rewind the full graph forward in `finally`. +- **Certification gating (forward plan phase 5, 2026-09-04) is one function and fails CLOSED.** + `machines/certifications.py::require_certification` is the only place that decides "is this member + trained"; `service_workflow_actions.submit` and `services_bookings.create_booking` call it and let it + raise. It is a no-op while the `machines.certifications` feature is off or the `machines` module is + uninstalled (a bookings-only space is never gated by a module it lacks — `BookableSpace.machine_type` + is nullable and the import is lazy), and once on, no resolvable membership or no live grant is a + refusal, never a pass. Requirements live on the **machine type** (`CertificationType`, flags + `is_required_for_service` / `is_required_for_booking`), so adding a machine cannot un-gate it; a grant's + expiry is the **stored** `expires_at`, so tightening `validity_days` later cannot retroactively + invalidate correctly-issued training. Authority to define types, grant, revoke or **override** is + `access.can_create_machine` — machine-TYPE authority, never a per-machine link — and every honoured + override writes `certification.override` naming the type it skipped. Types are deactivated, never + deleted (grants are evidence); grants are revoked, never edited. Publication is consent, not + configuration: certifications reach the maker profile only through `MemberProfile.show_certifications`, + a separate opt-in from `is_visible`, and reach a printed card only when the template lists the + `certifications` field. + ## Events program invariants (four phases, `f16896f`..`dab0354`) - **Registering for an event does NOT require a `PresenceSession`; check-in does.** diff --git a/docs/MODULES.md b/docs/MODULES.md index f242757c..c9a0b561 100644 --- a/docs/MODULES.md +++ b/docs/MODULES.md @@ -52,7 +52,7 @@ core modules cannot be uninstalled at all. **Events**: [events](#events) — **Bookings**: [bookings](#bookings) — **Membership**: [membership](#membership) **Notifications**: [notifications](#notifications) · [email](#email) · [telegram](#telegram) · -[slack](#slack) · [mattermost](#mattermost) · [discord](#discord) — **Reports**: [reports](#reports) — +[slack](#slack) · [mattermost](#mattermost) · [discord](#discord) · [webhook](#webhook) — **Reports**: [reports](#reports) — **Payments**: [payments](#payments) — **Accounts**: [member_accounts](#member_accounts) — **Mobile apps**: [mobile](#mobile) — **Updates**: [updates](#updates) @@ -235,13 +235,18 @@ thirteen modules under it are optional. colour can be shared across every printer of a type instead of being re-entered per machine. Each pool also carries a hex swatch for the staff console and its own public/private flag. A public pool appears in the public printing form as material and colour name only — never the hex value, the lot code or - the remaining grams — and only while it is active with stock left. + the remaining grams — and only while it is active with stock left. With the `machines.certifications` + feature switched on, a makerspace defines certification types per machine type and issues grants to + memberships; an uncertified member is refused when booking a linked space or requesting work, and an + override needs machine-type authority plus a recorded, audited reason. The `certification-coverage` + report shows trained members per type. - **Without it** — the whole machine side of the product disappears: no registry, no service queue (which needs machines to point at), no maintenance schedules, no per-machine consumables. A pure tool library runs exactly like this — see the `lending` profile. - **Data** — **not separately purgeable**, and the command says why: machine rows host warranty records, inventory-backed consumables and service history, so deleting them piecemeal would orphan other - modules. Purge `machine_service` first, then archive and purge the makerspace. + modules. Purge `machine_service` first, then archive and purge the makerspace. Certification grants + are training evidence: a type is deactivated, never deleted, and a grant is revoked, never edited. ### machine_service @@ -321,6 +326,11 @@ Required by `printing`. the member directory and member activity. - **What it adds** — the join-request queue, member capabilities and memberships in the console, the opt-in maker profile and directory, and per-member activity history. + Plans and terms (monthly, yearly or custom-length, priced or free) can be attached to a membership; + a beat-less sweep raises one renewal charge per term before it ends when `payments.membership` is on, + and an optional per-makerspace rule can stop lapsed members from borrowing. Prospective members can + send an invitation request from the public site (throttled, honeypot-guarded, PII encrypted at rest); + staff invite or decline it from the same queue as join requests. - **Without it** — people can still exist as members and still borrow: staff create walk-in member records, and identity can come from `member_accounts` or an external OIDC provider. What goes is the *enrolment and community* layer — no join requests to approve, no waivers, no referrals, no profiles, @@ -335,8 +345,16 @@ Required by `printing`. a stranger walk past the requirement you just switched on. Turning it back off does **not** re-open account-less requests — that is an explicit choice, made with `manage.py set_request_access --mode anyone`. -- **Data** — purgeable: join requests and member profiles with their projects and imagery. Memberships, - waivers and acceptance evidence **stay** — they are core RBAC and liability state. +- **Member ID cards live here.** A card is a revocable QR credential over one membership (`MemberCard`), + issued, reissued, revoked and printed by staff holding `manage_member_cards` (Space Managers by default) + and resolvable at a scanner only by `scan_member_cards` — never through the inventory scanner, so an + inventory-only role can never turn a QR into a person. The printed name is scoped PII, the photo is a + private object stored only with the member's recorded consent, and revoking a card deletes the photo + and blanks the name immediately while the redacted row, the revoked QR, the scans and the audit entries + stay. Cards print as one CR80 card or an A4/Letter sheet; nothing rendered is stored. +- **Data** — purgeable: join requests, member profiles with their projects and imagery, and member cards + with their photos (their QR codes are revoked, never deleted). Memberships, waivers and acceptance + evidence **stay** — they are core RBAC and liability state. --- @@ -417,6 +435,22 @@ stored credential**, so re-enabling needs no re-entry. - **Without it** — no Discord surface ships at all for this space. - **Data** — purgeable: Discord destinations and their stored webhooks (delivery logs survive). +### webhook + +- **What it is** — per-makerspace **signed JSON webhooks** to systems of your own (an ERP, a Slack bot you + wrote, a spreadsheet bridge). Not a chat room, but it sits in the same matrix: a webhook destination + receives exactly the notification a room would, as JSON, signed with a per-destination secret. +- **What it adds** — `webhook` as a destination channel in the notification matrix, with an HTTPS endpoint + and a signing secret per destination; `X-SpaceWorks-Signature: t=,v1=` is HMAC-SHA256 over + `"."`, plus `X-SpaceWorks-Event` and `X-SpaceWorks-Delivery` headers. Retries and the durable + delivery log are the ones every non-email channel already has; a superadmin can re-queue failed + deliveries from `/control/`. Verification recipe: `docs/api-client-protocol.md` → "Outbound webhooks". +- **Without it** — no signed-webhook surface ships for this space. The inbound API-client protocol is + unaffected; other systems poll instead of being told. +- **Data** — purgeable: webhook destinations with their endpoint URLs and signing secrets (delivery logs + survive). Endpoint URLs are validated against private and loopback ranges when saved and re-resolved at + send time, like every other webhook channel. + --- ## Reports @@ -427,14 +461,18 @@ stored credential**, so re-enabling needs no re-entry. - **What it adds** — the `analytics` and `report_export` workflows: a server-provided report catalog, dashboards, accessible charts with table fallbacks, the ledger, problem reports and every registered report. The catalog covers every module either with a substantive report or an explicitly gated row in - a composite operational-health report. -- **Without it** — no analytics screens and no exports from the console. It is a **standalone area + a composite operational-health report. Every CSV/XLSX export carries provenance (who generated it, + when, for which makerspace, which report version and filters) as the first CSV line or a `Provenance` + sheet, and staff can schedule a report to be delivered daily, weekly or monthly to a notification + destination or a list of email recipients as a short-lived signed download link. +- **Without it** — no analytics screens, no exports and no scheduled deliveries from the console. It is a **standalone area rather than part of Inventory** on purpose: switching Inventory off would otherwise take the machine and event reports with it. - **Data** — closed historical buckets are stored as append-only, non-PII metric rollups; corrections add a revision rather than rewriting history. Automatic evidence retention must finalize its rollup fence first, so it cannot change historical figures. Whole-tenant purge removes the rollups through tenant - ownership, and an explicit source-module purge removes that module's derived rollups too. + ownership, and an explicit source-module purge removes that module's derived rollups too. Purging + `reports` deletes report schedules and their delivered files (private objects); the rollups stay. --- @@ -442,12 +480,18 @@ stored credential**, so re-enabling needs no re-entry. ### payments -**On by default.** - -- **What it is** — taking money online, through Stripe or Razorpay behind one provider seam. -- **What it adds** — the payment surfaces, charges, receipts, reconciliation and (with `mobile`) the - in-app payment sheet. -- **Without it** — no online payment surfaces exist. Money is handled outside the system. +**Opt-in.** It was on by default until charge *tracking* moved out from under it: a space that takes +cash needs none of this module and still keeps a full ledger of what members owe. + +- **What it is** — taking money **online**, through Stripe or Razorpay behind one provider seam. +- **What it adds** — the online rail: checkout, Connect, webhooks, refunds (full or partial, through + the same provider seam, as ledger lines that never edit the charge) and (with `mobile`) the in-app + payment sheet. With `payments.loans` on, a loan deposit and a capped late fee can be collected + online too. +- **Without it** — money owed is still tracked, listed and reported: the `charges.*` features raise + the charge, members see what they owe, and staff settle it in person and record how (cash, UPI, + bank transfer, card machine, cheque) in the append-only manual-settlement ledger. What disappears + is only the ability to pay by card online. - **Installed ≠ charging.** The module being on means the *surfaces* exist. No charge can be created until a Space Manager turns on a `payments.` feature **and** valid credentials resolve. - **Data** — **payments are never purged by a module purge.** A charge is the record of money that really @@ -501,8 +545,10 @@ in the console rather than a superadmin. A feature is inert while its parent mod | `payments.bookings` | `bookings` | | Charge for bookings | Bookings are free in-app | | `payments.events` | `events` | | Charge for event registration | Registration is free in-app | | `payments.membership` | `membership` | | Charge membership dues | Dues are collected out of band | +| `payments.loans` | `payments` | | Raise a deposit when a loan is issued and a capped late fee when it comes back late | Lateness is recorded (due dates, reminders) but never charged | | `mobile.push` | `mobile` | ● | Native push notifications | Apps rely on in-app/inbox notifications | | `events.offline_checkin` | `events` | | Expiring on-device roster plus event-scoped PIN check-in stations | Check-in needs a live connection and an authenticated staff actor | +| `machines.certifications` | `machines` | | Members need an unexpired certification per machine type to book a linked space or request work; overrides need machine-type authority and are audited | Training is tracked out of band; nothing gates a request or booking | | `notifications.delegated_recipients` | `notifications` | | Machine-scoped maintainers manage maintenance alert recipients for their own machines. Needs `maintenance` and `machines` too | Only makerspace-level staff manage recipients | | `inventory.self_checkout` | — | ● | Member self-checkout and staff direct handouts | Every handover goes through a staff-issued request | | `presence.geofence` | — | ● | Advisory location check at check-in | Check-in records no location. It is advisory either way — it never blocks | @@ -511,6 +557,29 @@ The four `payments.` switches are **off by default and stay inert until cr turning one on cannot start charging anyone by itself. `inventory.self_checkout` and `presence.geofence` belong to no module: they are standalone capabilities that apply whenever you enable them. +## Editions + +A deployment has one **edition**, set by `SPACEWORKS_EDITION` (setup asks; default `makerspace`). It answers +"what is this box for" one level above modules: the six core modules are the hardware loan spine and cannot +be uninstalled, so an events-only or bookings-only installation would otherwise still show a catalogue, a +borrow flow and a scanner it never uses. The edition **hides** those surfaces and makes their public routes +answer 404; it does not delete tables, endpoints or data, and every staff endpoint keeps answering so data +stays recoverable. Frontend bootstrap payloads carry `edition`, and edition-hidden module keys are already +removed from the `modules` list they return, so the console's tabs and the public site's routes need no +separate switch. + +| Edition | Hidden module keys | Public home | +|---|---|---| +| `makerspace` (default) | none | the catalogue | +| `events` | `public_inventory request_workflow scanner asset_units containers bulk_import stock_transfers qr_print_batches guest_handover procurement stocktake machines machine_service printing maintenance bookings` | the events page | +| `bookings` | the same set with `bookings` visible and `events` hidden | the bookings page | +| `organization` | none — a labelling edition: one makerspace row is presented as "the organization" (`branding.display_name`) | the catalogue | + +`Event.makerspace` stays the tenancy anchor in every edition (locked 2026-08-19); an organization-first +install is one `Makerspace` row with an organization label, never a re-anchoring. Turning an edition into a +genuinely smaller schema (removing the loan apps) is the separate `TOMBSTONED_APPS` axis and a future +"Option A"; see `docs/INVARIANTS.md` → Editions. + ## Install profiles `setup.sh` asks, or pass `--profile`. Every profile is dependency-closed and always includes the six core @@ -523,7 +592,9 @@ modules. | `lending` | 17 | A tool library: the full lending lifecycle, no machines | | `recommended` | 20 | Core plus the inventory lifecycle, reports and machines (the default) | | `cloud` | 24 | A managed box: everything that runs on a single Django process, no worker or beat | -| `everything` / `full` | 32 | All modules | +| `everything` / `full` | 33 | All modules | +| `events` | 12 | An events programme: events, notifications, email, member accounts, membership, payments, reports (core is present but hidden by the `events` edition) | +| `bookings` | 12 | Bookable rooms and resources: bookings, notifications, email, member accounts, membership, payments, reports (core hidden by the `bookings` edition) | **Installing without a profile** gives you **8 modules**: the six core ones plus `payments` and `updates`. Member accounts and mobile apps are opt-in; installing `mobile` also installs its diff --git a/docs/PROJECT-HISTORY.md b/docs/PROJECT-HISTORY.md index ef199fef..39f906b1 100644 --- a/docs/PROJECT-HISTORY.md +++ b/docs/PROJECT-HISTORY.md @@ -6,6 +6,70 @@ ## Condensed changelog (newest first — full detail in `git log`) +- **2026-09-04 — forward plan phase 6: money and membership depth.** Refunds as immutable ledger lines + over `paid_online` charges, settled through the provider seam and its webhooks; `payments.loans` raises a + deposit on issue and a capped late fee once at close, from the workflow module, never blocking unless the + opt-in gate is on; membership plans and terms with a beat-less renewal charge and an optional + lapsed-members-cannot-borrow rule; public invitation requests (throttled, honeypot, PII-encrypted) that + become staff invitations; provenance on every CSV/XLSX export and scheduled deliveries as signed links. + Owner decisions 6 and 7 were taken by their plan defaults (vendor contact not PII; charging allowed, off + by default). Work paused after this phase by owner instruction; phases 7–11 remain plans. +- **2026-09-04 — forward plan phase 5: member ID cards and certification gating.** Member cards + (`makerspaces.MemberCard`) are membership-module behaviour over a core QR target type: issued, reissued + (rotating the QR; the old payload scans as revoked forever), revoked (redacting name and photo at once) + and printed as one CR80 card or an A4/Letter sheet by staff holding `manage_member_cards`, and resolved + only through a dedicated `scan_member_cards` path — the inventory scanner refuses to turn a QR into a + person. Members set their printed name, upload a private photo behind an explicit consent, and preview + a watermarked card. Certification types per machine type and grants per membership gate machine-service + requests and bookings of linked spaces behind the `machines.certifications` feature switch; an override + needs machine-type authority and a recorded reason. Held certifications print as an optional card field, + reach the maker profile only after a separate opt-in, and roll up in the `certification-coverage` report. +- **2026-09-03 — forward plan phase 4: editions and the single-box install.** A deployment-level + `SPACEWORKS_EDITION` hides the loan and machine surfaces on an events-only or bookings-only box (or + events on a bookings box) by removing their keys from every module list clients receive and answering + 404 on their public routes, while every capability, staff endpoint, migration and backup stays + identical — Option B from the vertical-deployments plan, built where Option A would need it. `events` + and `bookings` install profiles, an edition-aware public home, and an `organization` labelling edition + that keeps `Event.makerspace` as the anchor. The single-box image runs nginx, the frontend, gunicorn, + the live stream, a worker, the scheduler loop and Redis in one unprivileged container beside Postgres + and MinIO; the installer asks for the shape and persists it in `.spaceworks-layer`. +- **2026-09-03 — forward plan phase 3: outbound signed webhooks.** A `webhook` notification channel + module: a destination carries an encrypted endpoint URL and signing secret, the feature × channel matrix + routes to it like any room, and each notification arrives as JSON with an HMAC-SHA256 signature over the + exact body plus event and delivery headers — through the existing delivery log, retries, SSRF validation + and pinned HTTPS. Superadmins can re-queue failed deliveries. The verification recipe is in + `docs/api-client-protocol.md`. The generated Python client was deferred. +- **2026-09-03 — forward plan phase 2: the Hard Rules pinned in a real browser.** A Playwright suite + (`frontend/e2e/`, `scripts/e2e-local.sh`, CI job `e2e`) seeds a disposable makerspace and walks a + request from accept to issue (container code + real presigned photo upload) to return (container code, + photo, remark), asserts the console's refusal copy and the API's 400s when any of those is missing, + proves the public catalogue never leaks the storage location or box code, and runs axe (WCAG 2.1 AA, + contrast included) on the catalogue, login and requests console. Writing it found and fixed two + defects — the SSE endpoint answered 406 to `Accept: text/event-stream`, and the frontend's API base + URL bypassed the Vite proxy — plus a real contrast failure in the request queue. +- **2026-09-03 — forward plan phase 1: live updates, one search contract, frontend delivery.** Every + committed audit row now publishes a PII-free hint over Redis pub/sub, and the staff console holds one + Server-Sent Events stream (`/api/v1/live/`, its own `live` gunicorn service so worker recycling never + severs it) that invalidates the matching TanStack queries — queues update without polling, and a box + without Redis simply keeps polling. `?q=` became one contract: trigger-maintained Postgres search + vectors on products, machines and events with websearch syntax plus trigram typo tolerance, declared + as derived columns in all three export/migration registries; the member directory searches identity + fields only and requester PII stays out of every index. Public routes are lazy chunks with stable + react/tanstack vendor chunks, and the eleven frontend files over the 300-line ceiling were split with + no behaviour change (`lib/api.ts` is now a barrel over `lib/api/`). +- **2026-09-03 — forward plan phase 0: CI that runs the suite, observability, performance close-out.** + `.github/workflows/tests.yml` runs the host-topology backend suite, the pg-client-16 backup and + tenant-migration suites, the frontend typecheck/tests/build and the CLAUDE.md/AGENTS.md drift check on + every pull request; the release workflow now depends on it, so an image is never published from a red + tree. Requests carry an `X-Request-ID` bound in a contextvar, stamped on every log line (JSON in + production), propagated into Celery, and correlated with audit rows through an `audit_recorded` log line + rather than by writing into attested `meta`. `GET /api/v1/metrics/` serves Prometheus text behind + `METRICS_TOKEN`; `SENTRY_DSN` opts into error tracking with PII off. `CONN_MAX_AGE` defaults to 60 with + health checks (pooler deployments keep 0). The June performance audit was re-measured item by item and + closed in its report; `tests/perf/` puts a query ceiling on the hot list endpoints. The superadmin + series-organizer admin, which wrote occurrence organizers straight to the ORM, now routes through a + series-level service with the module lock and authority check. The stale file-ceiling sentence in + `CLAUDE.md` was corrected (five `apps/` files over 300 lines, not 37). - **2026-09-03 — 0.8.2: GitHub Release history became permanent.** The release workflow had kept only the current and immediately previous release, deleting every older release **and its Git tag** on each run, which is why the Releases page never showed history. Neither is deleted any more. Container diff --git a/docs/PROJECT-STATUS.md b/docs/PROJECT-STATUS.md index bea08440..c5e7ce37 100644 --- a/docs/PROJECT-STATUS.md +++ b/docs/PROJECT-STATUS.md @@ -67,6 +67,57 @@ analytics/ledger/exports, Users CRUD, and the FabLab modules). The detailed PRDs **internal planning docs kept local only** (gitignored); "PRD §N" references point to those. Google Sheets OAuth publishing, native apps, and physical label-printer control remain out of scope. +## Observability (phase 0 of the 2026-09-03 forward plan) + +Every request carries an `X-Request-ID` (honoured from a proxy when it is short plain ASCII, minted +otherwise), bound in `config/request_id.py` and echoed on the response. Log lines are JSON in production +(`config/log_setup.py`; `LOG_JSON` overrides), each stamped with that id, and Celery messages carry it into +the worker (`config/celery_signals.py`). Audit rows are correlated **through the log line** +(`audit_recorded` with the row's `event_uuid`), not by writing the id into `meta`, so the attested record +is unchanged. `GET /api/v1/metrics/` serves Prometheus text behind `METRICS_TOKEN` (404 when unset); +`SENTRY_DSN` opts into error tracking with PII off. CI: `.github/workflows/tests.yml` runs both test +topologies, the frontend build and the CLAUDE.md/AGENTS.md drift check, and the release workflow depends on +it. The forward plan itself is local-only under `docs/plans/2026-09-03-forward-plan/`. + +**Search and live updates (phase 1).** Every inventory, machine and event list accepts `?q=` (full-text with +phrase and exclusion syntax, typo-tolerant on the name) through `apps/inventory/search.py`; the member +directory matches identity fields only. The staff console holds one Server-Sent Events stream +(`/api/v1/live/`, `frontend/src/lib/live.ts`) and invalidates the matching TanStack queries when an audit +row commits, so queues update without polling; the stream runs on the dedicated `live` compose service and +falls back to polling (503) on a deployment without Redis. Public routes are lazy chunks and the eleven +over-ceiling frontend files were split. + +**Browser and accessibility gates (phase 2).** `frontend/e2e/` (Playwright) drives the loan spine through a +real browser and object storage — `scripts/e2e-local.sh` locally, the `e2e` job in CI — and axe runs in +both jsdom panel tests and Chromium. `manage.py seed_e2e` seeds a disposable makerspace per run. + +**Signed webhooks (phase 3).** `webhook` is a notification channel module: a destination with an HTTPS +endpoint and a signing secret receives every matrix-routed notification as signed JSON +(`docs/api-client-protocol.md` → "Outbound webhooks"), through the same delivery log and retries as the +chat channels; failed deliveries can be re-queued from `/control/`. + +**Editions and the single box (phase 4).** `SPACEWORKS_EDITION` (`makerspace`, `events`, `bookings`, +`organization`) hides the loan or events surfaces and 404s their public routes without changing any +capability (`docs/MODULES.md` → Editions); `events` and `bookings` install profiles match. The single-box +shape (`Dockerfile.allinone`, `docker/compose.single.yml`, `.spaceworks-layer`) runs everything but +Postgres and MinIO in one container; setup asks for both the edition and the shape. + +**Member ID cards and certification gating (phase 5).** A member card is a revocable QR credential over one +membership, printed as a CR80 card or a sheet, resolvable only by staff holding `scan_member_cards`, with a +consent-gated private photo that is deleted the moment the card is revoked (`docs/INVARIANTS.md` → Member ID +cards). `machines.certifications` (a Space-Manager feature switch) requires an unexpired certification per +machine type before a member may book a linked space or request work; an override needs machine-type +authority and a recorded reason. Held certifications print as an optional card field, appear on the maker +profile only after a separate opt-in, and roll up in the `certification-coverage` report row. + +**Money and membership depth (phase 6).** Refunds (full or partial, Stripe and Razorpay, as immutable +`payments.Refund` ledger lines), the `payments.loans` feature (deposit raised on issue, capped late fee +raised once at close, optional deposit-blocks-issue gate), membership plans and terms with a beat-less +renewal charge and an optional lapsed-members-cannot-borrow rule, public invitation requests (throttled, +honeypot, PII-encrypted) feeding the existing invitation path, provenance on every report export and +scheduled report delivery to a destination or email list as a signed link. `Warranty.vendor_contact` stays +non-PII by decision 6 (`docs/INVARIANTS.md`). Phases 7–11 are planned and PAUSED by owner instruction. + Stack (in use): - **Backend:** Django 6 + Django REST Framework (`backend/`). Requires Python 3.12+. diff --git a/docs/api-client-protocol.md b/docs/api-client-protocol.md index c4f9227b..43bf4479 100644 --- a/docs/api-client-protocol.md +++ b/docs/api-client-protocol.md @@ -115,3 +115,40 @@ A misconfigured deployment fails at startup rather than at runtime: if `HMAC_PROTECTED_PATH_PREFIXES` is widened to a prefix whose routes are not registered, `manage.py check` reports it as an error instead of the deployment silently 401-ing that whole prefix. + +## Outbound webhooks + +The inbound protocol above lets your system call SpaceWorks. The `webhook` notification module is the +other direction: SpaceWorks calls **your** HTTPS endpoint whenever the notification matrix routes an event +to a signed-webhook destination. + +**Request.** `POST` with `Content-Type: application/json`, `User-Agent: SpaceWorks-Webhook/1`, and: + +| Header | Value | +|---|---| +| `X-SpaceWorks-Event` | the event key, e.g. `request.accepted` | +| `X-SpaceWorks-Delivery` | the delivery id (stable across retries — use it to de-duplicate) | +| `X-SpaceWorks-Signature` | `t=,v1=` | + +**Body.** `{"id", "event", "feature", "makerspace_id", "text", "data", "created_at"}` — `text` is the +same message a Slack or Telegram room would receive; `data` holds the ids the notification carried. + +**Verify.** Compute `HMAC-SHA256(secret, "." + raw_body_bytes)` with the signing secret you entered +when creating the destination, compare it to `v1` with a constant-time comparison, and reject if `t` is +older than your tolerance (five minutes is reasonable): + +```python +import hashlib, hmac, time + +def verify(headers, raw_body: bytes, secret: str, tolerance=300) -> bool: + parts = dict(item.split("=", 1) for item in headers["X-SpaceWorks-Signature"].split(",")) + if abs(time.time() - int(parts["t"])) > tolerance: + return False + expected = hmac.new(secret.encode(), f"{parts['t']}.".encode() + raw_body, hashlib.sha256).hexdigest() + return hmac.compare_digest(expected, parts["v1"]) +``` + +Respond with any 2xx. Anything else, or a timeout, is retried by the delivery worker; after the retries a +superadmin can re-queue the failed delivery from `/control/`. Endpoints must be public HTTPS — private and +loopback addresses are refused when the destination is saved and again at send time. + diff --git a/docs/performance-and-supabase-report.md b/docs/performance-and-supabase-report.md index 648d9f4d..160ab2bb 100644 --- a/docs/performance-and-supabase-report.md +++ b/docs/performance-and-supabase-report.md @@ -83,6 +83,37 @@ Email API free tier (or host SMTP) → notifications ``` **Final call:** fine for a demo or a very small makerspace with disciplined file cleanup; not a dependable "completely free" production deployment. The required code changes are: remove the purge superuser SQL (pick archive-only or trigger-relax), rework presigned uploads (POST→PUT or Supabase signed URLs), add a cron HTTP endpoint, and tighten upload caps + DB retention. + +## Status 2026-09-03 (phase 0 close-out) + +Re-measured on `dev` at `0039f2f9`. The report's `file:line` citations are from June and many files have +since moved or been retired; each verdict below names where the code is today. + +| # | Finding | Verdict | Where / why | +|---|---|---|---| +| 1 | `HardwareRequest` composite indexes | **Done** | `hardware_requests/models.py` carries `hwreq_ms_status_{created,issued,updated,closed}_idx` | +| 2 | `PrintPrinterSerializer` N+1 | **Superseded** | the printing kernel was tombstoned (`911f4589`); `serializers_printers.py` no longer exists | +| 3 | `PrintRequest` indexes | **Superseded** | same; print jobs are `machine_service` requests | +| 4 | Out-of-band work off the request thread | **Done for notifications**, open for exports | email + non-email channels run in `deliver_email_task` / `deliver_notification_task`; QR ZIP and XLSX exports remain synchronous → phase 6 (streamed exports) | +| 5 | Ledger in-memory sort | **Done** | `operations/ledger_query*.py` filters and paginates in SQL | +| 6 | Direct-loan `items` N+1 | **Done** | `direct_loan_views.py` uses `Prefetch("request__items", …)` | +| 7 | Operations list indexes | **Done** | `operations/models.py` indexes on transfers, stocktake, print batches | +| 8 | RBAC hidden/archived cache | **Declined for now** | `servability.unservable_makerspace_ids()` is two indexed queries; caching adds an invalidation surface for a cost not measured in production. Revisit with metrics from phase 0 | +| 9 | `BoxSerializer.get_qr_code_id` N+1 | **Done** | `views_containers.py` annotates `_active_qr_code_id` | +| 10 | `CONN_MAX_AGE` | **Done (this phase)** | default 60 + `CONN_HEALTH_CHECKS`; pooler deployments keep `0` | +| 11 | `QrScanEvent` indexes | **Done** | `qrscan_ms_qrcode_created_idx`, `qrscan_ms_context_idx` | +| 12 | `_summary` per-metric queries | **Superseded** | reports moved to the report registry (`operations/report_registry.py`) | +| 13 | Filament reports loop | **Superseded** | printing retired | +| 14 | Exports materialize everything | **Open → phase 6** | streamed CSV, write-only XLSX | +| 15 | Public print submit S3 HEAD in txn | **Superseded** | public printing intake replaced by machine-service intake | +| 16 | `procurement` unpaginated list | **Open → phase 1** | `procurement/views_items.py` still `pagination_class = None`; `select_related` is in place. Pagination changes the response shape, so it lands with the frontend list work | +| 17 | `require_module` double fetch | **Declined** | one indexed PK lookup; callers may pass the object | +| 18 | `staff_origin_scope` Python scan | **Open, low** | `makerspaces/origin_scope.py` still iterates servable makerspaces per request; bounded by makerspace count | +| 19 | Middleware re-resolves client | **Done** | `inventory/middleware.py` attaches `request.api_client` | + +Guard added: `tests/perf/test_list_query_budgets.py` puts a fixed query ceiling on the hot list endpoints +so an N+1 regression fails CI instead of slowing a queue. + --- ## Source agents diff --git a/docs/self-hosting.md b/docs/self-hosting.md index 68fb42d5..37fc551e 100644 --- a/docs/self-hosting.md +++ b/docs/self-hosting.md @@ -327,7 +327,12 @@ If an instance flips from managed → self-host after deploy, run | `HTTP_PORT` | no (default 80) | Published frontend port | | `EMAIL_*`, `DEFAULT_FROM_EMAIL` | no | Global fallback SMTP (per-makerspace SMTP overrides it) | | `MANAGED_POSTGRES` | no (default `False`) | `True` on managed Postgres (Supabase): purge suspends immutability triggers via a custom GUC instead of `session_replication_role` (which needs superuser) | -| `CONN_MAX_AGE` | no (default `0`) | Persistent DB connection lifetime; keep `0` on the Supabase transaction pooler | +| `CONN_MAX_AGE` | no (default `60`) | Persistent DB connection lifetime in seconds; set `0` on the Supabase transaction pooler (port 6543), which hands back a different server connection per transaction | +| `CONN_HEALTH_CHECKS` | no (default `True`) | Verify a persistent connection before reuse so a restarted Postgres does not surface as a request error | +| `LOG_LEVEL`, `LOG_JSON` | no (`INFO`; JSON when `DEBUG` is off) | Log verbosity and format. Every line carries the `X-Request-ID` of the request that produced it | +| `LIVE_REDIS_URL`, `LIVE_MAX_STREAM_SECONDS` | no (Celery broker; `3600`) | Redis the live-update stream (`/api/v1/live/`) publishes and subscribes on, and the longest a single stream stays open before the browser reconnects. Empty makes the stream answer 503 and the console keeps polling | +| `METRICS_TOKEN` | no (unset) | Bearer token for `GET /api/v1/metrics/` (Prometheus text). Unset means the route answers 404 | +| `SENTRY_DSN` | no (unset) | Opt-in error tracking; the SDK is only imported when set, and PII is never sent | | `DISABLE_SERVER_SIDE_CURSORS` | no (default `False`) | Set `True` on the Supabase transaction pooler (no server-side cursors) | | `STORAGE_PRESIGN_METHOD` | no (default `post`) | `put` for Supabase Storage presigned PUT uploads (server re-validates size at attach) | | `CRON_SECRET` | no (default empty) | Enables `POST /api/v1/internal/cron/return-reminders` (header `X-Cron-Secret`); 404s while unset | @@ -410,3 +415,17 @@ appear for `reports` or printing-related workflows. A makerspace's `frontend_domain` and its `cors_allowed_origins` (API-client origins) are used for per-tenant browser access; only the `frontend_domain` origin may hold a staff session. + +## The single-box install + +`setup.sh` asks "How should it run?". **Single box** runs nginx, the built frontend, gunicorn, the live +update stream, a Celery worker, the scheduler loop and Redis in ONE container (`spaceworks-allinone`, +built from `Dockerfile.allinone`) beside Postgres and MinIO. It is the same code and the same fail-closed +process entrypoint per role; it only reduces what an operator has to run. The choice is persisted in +`.spaceworks-layer`, which `scripts/spaceworks-compose.sh` reads so updates keep the shape. To switch by +hand: `SPACEWORKS_COMPOSE_LAYER=single scripts/spaceworks-compose.sh bundled up -d` (or write `single` +to `.spaceworks-layer`). The container listens on 8080 internally and is published on `HTTP_PORT`; the +Django control plane (`/control/`) is still not proxied through it. Redis state lives in the +`allinone_state` volume so rate-limit counters survive a restart; queued deliveries do not need to (tasks +acknowledge late and are re-enqueued on commit). + diff --git a/frontend/e2e/a11y.spec.ts b/frontend/e2e/a11y.spec.ts new file mode 100644 index 00000000..81b1384a --- /dev/null +++ b/frontend/e2e/a11y.spec.ts @@ -0,0 +1,31 @@ +import AxeBuilder from "@axe-core/playwright"; +import { expect, test } from "@playwright/test"; + +import { SEED, openRequestsTab, staffLogin } from "./helpers"; + +// WCAG 2.1 AA in a real browser (colour contrast included, which jsdom cannot compute). +const TAGS = ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]; + +async function expectClean(page: import("@playwright/test").Page) { + const results = await new AxeBuilder({ page }).withTags(TAGS).analyze(); + const summary = results.violations.map((v) => `${v.id}: ${v.help} (${v.nodes.length} nodes)`).join("\n"); + expect(results.violations, summary).toEqual([]); +} + +test("public catalogue", async ({ page }) => { + await page.goto(`/m/${SEED.slug}`); + await expect(page.getByText(SEED.product).first()).toBeVisible(); + await expectClean(page); +}); + +test("staff login", async ({ page }) => { + await page.goto(`/m/${SEED.slug}/admin`); + await expect(page.getByLabel("Username")).toBeVisible(); + await expectClean(page); +}); + +test("staff requests console", async ({ page }) => { + await staffLogin(page); + await openRequestsTab(page); + await expectClean(page); +}); diff --git a/frontend/e2e/auth.spec.ts b/frontend/e2e/auth.spec.ts new file mode 100644 index 00000000..f1d66d46 --- /dev/null +++ b/frontend/e2e/auth.spec.ts @@ -0,0 +1,20 @@ +import { expect, test } from "@playwright/test"; + +import { SEED, staffLogin } from "./helpers"; + +test.describe("staff authentication", () => { + test("a wrong password is refused with a visible error", async ({ page }) => { + await page.goto(`/m/${SEED.slug}/admin`); + await page.getByLabel("Username").fill(SEED.manager.username); + await page.getByLabel("Password").fill("not-the-password"); + await page.getByRole("button", { name: /sign in|log in/i }).click(); + await expect(page.getByRole("alert")).toBeVisible(); + await expect(page.getByLabel("Password")).toBeVisible(); + }); + + test("the seeded Space Manager signs in and lands on the console", async ({ page }) => { + await staffLogin(page); + await expect(page).toHaveURL(new RegExp(`/m/${SEED.slug}/admin`)); + await expect(page.getByText(SEED.makerspaceName).first()).toBeVisible(); + }); +}); diff --git a/frontend/e2e/fixtures/evidence.jpg b/frontend/e2e/fixtures/evidence.jpg new file mode 100644 index 00000000..4e960ece Binary files /dev/null and b/frontend/e2e/fixtures/evidence.jpg differ diff --git a/frontend/e2e/hard-rules.spec.ts b/frontend/e2e/hard-rules.spec.ts new file mode 100644 index 00000000..ba4bffe6 --- /dev/null +++ b/frontend/e2e/hard-rules.spec.ts @@ -0,0 +1,54 @@ +import { expect, test } from "@playwright/test"; + +import { SEED, apiGet, apiLogin, apiPost, makerspaceId, openRequestsTab, staffLogin } from "./helpers"; + +// The Hard Rules from CLAUDE.md, pinned where they are enforced: the API refuses, and the +// console refuses before it even asks the API. +test.describe("hard rules", () => { + test("the console will not submit an issue without a container code or photo", async ({ page }) => { + await staffLogin(page); + await openRequestsTab(page); + // The seed leaves a second, already-accepted request for this probe so the lifecycle spec + // and this one never compete for the same row. + const row = page.locator("article").filter({ hasText: SEED.probeRequestFor }).first(); + await expect(row).toBeVisible(); + await row.getByRole("button", { name: "Assign + issue" }).click(); + const dialog = page.getByRole("dialog"); + await dialog.getByRole("button", { name: "Assign + issue" }).click(); + await expect(dialog.getByText(/Box QR code is required\.|Upload an issue photo before issuing\./)).toBeVisible(); + await expect(dialog).toBeVisible(); + }); + + test("the API refuses an issue without evidence and a return without a remark", async ({ request }) => { + const token = await apiLogin(request, SEED.manager.username, SEED.manager.password); + const id = await makerspaceId(request, token); + const accepted = await apiGet<{ results: Array<{ id: number }> }>(request, token, `/admin/makerspace/${id}/accepted-requests`); + const pending = await apiGet<{ results: Array<{ id: number }> }>(request, token, `/admin/makerspace/${id}/pending-requests`); + const target = accepted.results[0] ?? pending.results[0]; + test.skip(!target, "no seeded request left to exercise"); + + const noEvidence = await apiPost(request, token, `/admin/requests/${target.id}/issue`, { + box_code: SEED.boxCode, + remark: "attempting without a photo", + }); + expect(noEvidence.status()).toBe(400); + const body = await noEvidence.text(); + expect(body).toMatch(/evidence/i); + + const noBox = await apiPost(request, token, `/admin/requests/${target.id}/issue`, { + evidence_id: 999_999, + remark: "attempting without a box", + }); + expect(noBox.status()).toBe(400); + + const active = await apiGet<{ results: Array<{ id: number }> }>(request, token, `/admin/makerspace/${id}/active-loans`); + if (active.results[0]) { + const noRemark = await apiPost(request, token, `/admin/requests/${active.results[0].id}/return`, { + evidence_id: 999_999, + remark: "", + resolutions: [], + }); + expect(noRemark.status()).toBe(400); + } + }); +}); diff --git a/frontend/e2e/helpers.ts b/frontend/e2e/helpers.ts new file mode 100644 index 00000000..c28cf532 --- /dev/null +++ b/frontend/e2e/helpers.ts @@ -0,0 +1,96 @@ +import { readFileSync } from "node:fs"; + +import { expect, type APIRequestContext, type Page } from "@playwright/test"; + +declare const process: { env: Record }; + +// The seed command (backend/apps/operations/management/commands/seed_e2e.py) creates a FRESH +// makerspace per run and writes its identifiers to e2e/.seed.json (scripts/e2e-local.sh passes +// --write-json). The constants below are the fallbacks and the fixed strings the seed uses. +export const API_URL = process.env.E2E_API_URL ?? "http://localhost:8100"; + +type SeedFile = { + slug: string; + makerspace_id: number; + makerspace_name: string; + manager: { username: string; password: string }; + member: { username: string; password: string }; + box_code: string; + box_label: string; + product: string; + secret_location: string; + pending_request_for: string; + probe_request_for: string; +}; + +function loadSeed(): SeedFile | null { + const candidate = process.env.E2E_SEED_FILE ?? new URL("./.seed.json", import.meta.url).pathname; + try { + return JSON.parse(readFileSync(candidate, "utf8")) as SeedFile; + } catch { + return null; + } +} + +const seedFile = loadSeed(); + +export const SEED = { + slug: seedFile?.slug ?? "e2e-space", + makerspaceId: seedFile?.makerspace_id ?? 0, + makerspaceName: seedFile?.makerspace_name ?? "E2E Makerspace", + manager: seedFile?.manager ?? { username: "e2e_manager", password: process.env.E2E_PASSWORD ?? "e2e-pass-12345" }, + member: seedFile?.member ?? { username: "e2e_member", password: process.env.E2E_PASSWORD ?? "e2e-pass-12345" }, + boxCode: seedFile?.box_code ?? "e2e-box-0001", + boxLabel: seedFile?.box_label ?? "E2E Shelf A", + product: seedFile?.product ?? "E2E Cordless Drill", + secretLocation: seedFile?.secret_location ?? "E2E SECRET SHELF 42", + pendingRequestFor: seedFile?.pending_request_for ?? "E2E robotics workshop", + probeRequestFor: seedFile?.probe_request_for ?? "E2E hard-rules probe", +}; + +export async function apiLogin(request: APIRequestContext, username: string, password: string): Promise { + const response = await request.post(`${API_URL}/api/v1/auth/login`, { data: { username, password } }); + expect(response.ok(), `login failed: ${response.status()} ${await response.text()}`).toBeTruthy(); + const body = (await response.json()) as { access: string }; + return body.access; +} + +export async function apiGet(request: APIRequestContext, token: string, path: string): Promise { + const response = await request.get(`${API_URL}/api/v1${path}`, { headers: { Authorization: `Bearer ${token}` } }); + expect(response.ok(), `${path} -> ${response.status()} ${await response.text()}`).toBeTruthy(); + return (await response.json()) as T; +} + +export async function apiPost(request: APIRequestContext, token: string, path: string, data: unknown) { + return request.post(`${API_URL}/api/v1${path}`, { headers: { Authorization: `Bearer ${token}` }, data }); +} + +export async function makerspaceId(request: APIRequestContext, token: string): Promise { + if (SEED.makerspaceId) return SEED.makerspaceId; + const spaces = await apiGet | { results: Array<{ id: number; slug: string }> }>( + request, token, "/admin/makerspaces", + ); + const rows = Array.isArray(spaces) ? spaces : spaces.results; + const space = rows.find((row) => row.slug === SEED.slug); + expect(space, "seeded makerspace missing; run `manage.py seed_e2e --reset`").toBeTruthy(); + return space!.id; +} + +export async function staffLogin(page: Page, username = SEED.manager.username, password = SEED.manager.password) { + await page.goto(`/m/${SEED.slug}/admin`); + await page.getByLabel("Username").fill(username); + await page.getByLabel("Password").fill(password); + await page.getByRole("button", { name: /sign in|log in/i }).click(); + // Login lands on the dashboard; wait for that navigation before doing anything else, or a + // goto() races the post-login redirect and the refresh cookie. + await page.waitForURL(/\/admin(\/|$)/); + await expect(page.getByRole("button", { name: /sign in|log in/i })).toHaveCount(0); + await expect(page.getByRole("button", { name: /sign out/i })).toBeVisible(); +} + +export async function openRequestsTab(page: Page) { + await page.goto(`/m/${SEED.slug}/admin/requests`); + await expect(page.getByText("Pending review")).toBeVisible({ timeout: 15_000 }); +} + +export const EVIDENCE_FIXTURE = new URL("./fixtures/evidence.jpg", import.meta.url).pathname; diff --git a/frontend/e2e/public-visibility.spec.ts b/frontend/e2e/public-visibility.spec.ts new file mode 100644 index 00000000..c974650e --- /dev/null +++ b/frontend/e2e/public-visibility.spec.ts @@ -0,0 +1,27 @@ +import { expect, test } from "@playwright/test"; + +import { SEED } from "./helpers"; + +// The public catalogue must never expose storage locations, box codes or QR payloads +// (CLAUDE.md, "Public inventory must never expose"). The seed puts an unmistakable marker +// in the product's storage location and the box code is known, so the DOM can be searched. +test("the public catalogue shows the product but leaks nothing about where it lives", async ({ page }) => { + await page.goto(`/m/${SEED.slug}`); + await expect(page.getByText(SEED.product).first()).toBeVisible(); + const html = await page.content(); + expect(html).not.toContain(SEED.secretLocation); + expect(html).not.toContain(SEED.boxCode); + expect(html).not.toContain(SEED.boxLabel); +}); + +test("search narrows the catalogue and tolerates a typo", async ({ page }) => { + await page.goto(`/m/${SEED.slug}`); + // The catalogue applies the search on submit (Enter), not on every keystroke. + const search = page.getByLabel("Search inventory"); + await search.fill("cordles dril"); + await search.press("Enter"); + await expect(page.getByText(SEED.product).first()).toBeVisible(); + await search.fill("zzzz-nothing-here"); + await search.press("Enter"); + await expect(page.getByText(SEED.product)).toHaveCount(0); +}); diff --git a/frontend/e2e/request-lifecycle.spec.ts b/frontend/e2e/request-lifecycle.spec.ts new file mode 100644 index 00000000..e64562f7 --- /dev/null +++ b/frontend/e2e/request-lifecycle.spec.ts @@ -0,0 +1,51 @@ +import { expect, test } from "@playwright/test"; + +import { EVIDENCE_FIXTURE, SEED, apiGet, apiLogin, makerspaceId, openRequestsTab, staffLogin } from "./helpers"; + +// The whole loan spine through the real UI, real presigned upload to object storage and the +// real state machine: pending -> accepted -> issued (box code + photo) -> returned (photo + +// remark). Runs on a freshly seeded makerspace (`manage.py seed_e2e --reset`). +test("accept, issue with box code and photo, then return with photo and remark", async ({ page, request }) => { + await staffLogin(page); + await openRequestsTab(page); + + const pending = page.locator("article").filter({ hasText: SEED.pendingRequestFor }).first(); + await expect(pending).toBeVisible(); + await pending.getByRole("button", { name: "Accept" }).click(); + const acceptDialog = page.getByRole("dialog"); + await acceptDialog.getByRole("button", { name: /^Accept/ }).click(); + await expect(acceptDialog).toBeHidden(); + + const accepted = page.locator("article").filter({ hasText: SEED.pendingRequestFor }).first(); + await accepted.getByRole("button", { name: "Assign + issue" }).click(); + const issueDialog = page.getByRole("dialog"); + await issueDialog.getByRole("button", { name: "Enter code manually" }).click(); + await issueDialog.getByLabel("Container code").fill(SEED.boxCode); + // Two controls carry "issue photo" in their name (the file input and the camera button). + await issueDialog.locator('input[type="file"]').setInputFiles(EVIDENCE_FIXTURE); + // The uploader finishes the presigned POST before the id is attached; wait for its done state. + await expect(issueDialog.getByRole("status").filter({ hasText: "Photo uploaded" })).toBeVisible({ timeout: 20_000 }); + await issueDialog.getByRole("button", { name: "Assign + issue" }).click(); + await expect(issueDialog).toBeHidden({ timeout: 20_000 }); + + const active = page.locator("section").filter({ hasText: "Active loans" }); + await expect(active.getByText(SEED.pendingRequestFor)).toBeVisible(); + await active.getByRole("button", { name: "Return" }).first().click(); + const returnDialog = page.getByRole("dialog"); + // A return re-identifies the container too, so the box code is entered again. + await returnDialog.getByRole("button", { name: "Enter code manually" }).click(); + await returnDialog.getByLabel("Container code").fill(SEED.boxCode); + await returnDialog.locator('input[type="file"]').setInputFiles(EVIDENCE_FIXTURE); + await expect(returnDialog.getByRole("status").filter({ hasText: "Photo uploaded" })).toBeVisible({ timeout: 20_000 }); + await returnDialog.getByLabel("Remark").fill("All items back, checked by e2e."); + await returnDialog.getByRole("button", { name: "Submit return" }).click(); + await expect(returnDialog).toBeHidden({ timeout: 20_000 }); + + // The API is the source of truth for the transition, not the screen. + const token = await apiLogin(request, SEED.manager.username, SEED.manager.password); + const id = await makerspaceId(request, token); + const loans = await apiGet<{ results: Array<{ requested_for: string }> }>(request, token, `/admin/makerspace/${id}/active-loans`); + expect(loans.results.some((row) => row.requested_for === SEED.pendingRequestFor)).toBeFalsy(); + const acceptedQueue = await apiGet<{ results: Array<{ requested_for: string }> }>(request, token, `/admin/makerspace/${id}/accepted-requests`); + expect(acceptedQueue.results.some((row) => row.requested_for === SEED.pendingRequestFor)).toBeFalsy(); +}); diff --git a/frontend/e2e/tsconfig.json b/frontend/e2e/tsconfig.json new file mode 100644 index 00000000..f853b6ce --- /dev/null +++ b/frontend/e2e/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "types": ["node"], + "esModuleInterop": true, + "allowSyntheticDefaultImports": true + }, + "include": ["./**/*.ts", "../playwright.config.ts"] +} diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 9b61afbe..99050d7f 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -29,6 +29,21 @@ server { proxy_set_header X-Forwarded-Proto $scheme; } + # Server-Sent Events: a separate upstream (the `live` service) and no buffering, so + # events reach the browser as they are published. Must sit above /api/ (longest prefix). + location /api/v1/live/ { + proxy_pass http://live:8000/api/v1/live/; + proxy_http_version 1.1; + proxy_set_header Connection ""; + proxy_buffering off; + proxy_cache off; + proxy_read_timeout 3600s; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + location /api/ { proxy_pass http://backend:8000/api/; proxy_set_header Host $host; diff --git a/frontend/openapi-schema.json b/frontend/openapi-schema.json index 1ca116c3..e8d813a2 100644 --- a/frontend/openapi-schema.json +++ b/frontend/openapi-schema.json @@ -60,7 +60,7 @@ "schema": { "type": "string" }, - "description": "Search public products by name or description." + "description": "Full-text search over name, storage location, tracking mode and description; supports \"phrases\" and -exclusions, tolerates typos in the name." }, { "in": "query", @@ -359,6 +359,7 @@ "enum": [ "active-loans", "booking-utilization", + "certification-coverage", "communications-health", "community-engagement", "damaged-lost", @@ -418,8 +419,11 @@ "enum": [ "booking", "event_registration", + "loan_deposit", + "loan_late_fee", "machine_service_request", - "makerspace_membership" + "makerspace_membership", + "membership_term" ] } } @@ -1800,6 +1804,264 @@ } } }, + "/api/v1/admin/certification-grants/{id}/revoke": { + "post": { + "operationId": "api_v1_admin_certification_grants_revoke_create", + "summary": "Revoke a certification grant", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Admin machines" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CertificationGrantRevoke" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/CertificationGrantRevoke" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/CertificationGrantRevoke" + } + } + } + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CertificationGrant" + } + } + }, + "description": "" + }, + "400": { + "description": "Already revoked." + }, + "403": { + "description": "Machine type authority required." + } + } + } + }, + "/api/v1/admin/certification-types/{id}": { + "patch": { + "operationId": "api_v1_admin_certification_types_partial_update", + "summary": "Update a certification type", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Admin machines" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PatchedCertificationTypeUpdate" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/PatchedCertificationTypeUpdate" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/PatchedCertificationTypeUpdate" + } + } + } + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CertificationType" + } + } + }, + "description": "" + }, + "400": { + "description": "Invalid certification type." + }, + "403": { + "description": "Machine type authority required." + } + } + }, + "delete": { + "operationId": "api_v1_admin_certification_types_destroy", + "summary": "Deactivate a certification type", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Admin machines" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CertificationType" + } + } + }, + "description": "" + }, + "403": { + "description": "Machine type authority required." + } + } + } + }, + "/api/v1/admin/certification-types/{id}/grants": { + "get": { + "operationId": "api_v1_admin_certification_types_grants_list", + "summary": "List grants for a certification type", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Admin machines" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CertificationGrant" + } + } + } + }, + "description": "" + } + } + }, + "post": { + "operationId": "api_v1_admin_certification_types_grants_create", + "summary": "Grant a certification to a membership", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Admin machines" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CertificationGrantCreate" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/CertificationGrantCreate" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/CertificationGrantCreate" + } + } + }, + "required": true + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CertificationGrant" + } + } + }, + "description": "" + }, + "400": { + "description": "Invalid grant." + }, + "403": { + "description": "Machine type authority required." + } + } + } + }, "/api/v1/admin/containers/{id}": { "get": { "operationId": "api_v1_admin_containers_retrieve", @@ -7075,6 +7337,104 @@ } } }, + "/api/v1/admin/invitation-requests/{id}/decline": { + "post": { + "operationId": "api_v1_admin_invitation_requests_decline_create", + "summary": "Decline an invitation request", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Admin memberships" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvitationRequest" + } + } + }, + "description": "" + }, + "400": { + "description": "Invalid input or request already handled." + } + } + } + }, + "/api/v1/admin/invitation-requests/{id}/invite": { + "post": { + "operationId": "api_v1_admin_invitation_requests_invite_create", + "summary": "Turn an invitation request into a membership invitation", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Admin memberships" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RoleId" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/RoleId" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/RoleId" + } + } + }, + "required": true + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvitationRequest" + } + } + }, + "description": "" + }, + "400": { + "description": "Invalid input or request already handled." + } + } + } + }, "/api/v1/admin/ledger": { "get": { "operationId": "api_v1_admin_ledger_retrieve", @@ -7632,6 +7992,26 @@ "tags": [ "Payments" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaymentOffline" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/PaymentOffline" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/PaymentOffline" + } + } + }, + "required": true + }, "security": [ { "jwtAuth": [] @@ -10939,8 +11319,11 @@ "enum": [ "booking", "event_registration", + "loan_deposit", + "loan_late_fee", "machine_service_request", - "makerspace_membership" + "makerspace_membership", + "membership_term" ] } } @@ -11076,8 +11459,11 @@ "enum": [ "booking", "event_registration", + "loan_deposit", + "loan_late_fee", "machine_service_request", - "makerspace_membership" + "makerspace_membership", + "membership_term" ] } } @@ -11213,8 +11599,11 @@ "enum": [ "booking", "event_registration", + "loan_deposit", + "loan_late_fee", "machine_service_request", - "makerspace_membership" + "makerspace_membership", + "membership_term" ] } } @@ -11350,8 +11739,11 @@ "enum": [ "booking", "event_registration", + "loan_deposit", + "loan_late_fee", "machine_service_request", - "makerspace_membership" + "makerspace_membership", + "membership_term" ] } } @@ -11487,8 +11879,11 @@ "enum": [ "booking", "event_registration", + "loan_deposit", + "loan_late_fee", "machine_service_request", - "makerspace_membership" + "makerspace_membership", + "membership_term" ] } } @@ -11624,8 +12019,11 @@ "enum": [ "booking", "event_registration", + "loan_deposit", + "loan_late_fee", "machine_service_request", - "makerspace_membership" + "makerspace_membership", + "membership_term" ] } } @@ -11761,8 +12159,11 @@ "enum": [ "booking", "event_registration", + "loan_deposit", + "loan_late_fee", "machine_service_request", - "makerspace_membership" + "makerspace_membership", + "membership_term" ] } } @@ -11898,8 +12299,11 @@ "enum": [ "booking", "event_registration", + "loan_deposit", + "loan_late_fee", "machine_service_request", - "makerspace_membership" + "makerspace_membership", + "membership_term" ] } } @@ -12035,8 +12439,11 @@ "enum": [ "booking", "event_registration", + "loan_deposit", + "loan_late_fee", "machine_service_request", - "makerspace_membership" + "makerspace_membership", + "membership_term" ] } } @@ -12172,8 +12579,11 @@ "enum": [ "booking", "event_registration", + "loan_deposit", + "loan_late_fee", "machine_service_request", - "makerspace_membership" + "makerspace_membership", + "membership_term" ] } } @@ -12309,8 +12719,11 @@ "enum": [ "booking", "event_registration", + "loan_deposit", + "loan_late_fee", "machine_service_request", - "makerspace_membership" + "makerspace_membership", + "membership_term" ] } } @@ -12446,8 +12859,11 @@ "enum": [ "booking", "event_registration", + "loan_deposit", + "loan_late_fee", "machine_service_request", - "makerspace_membership" + "makerspace_membership", + "membership_term" ] } } @@ -12583,8 +12999,11 @@ "enum": [ "booking", "event_registration", + "loan_deposit", + "loan_late_fee", "machine_service_request", - "makerspace_membership" + "makerspace_membership", + "membership_term" ] } } @@ -12720,8 +13139,11 @@ "enum": [ "booking", "event_registration", + "loan_deposit", + "loan_late_fee", "machine_service_request", - "makerspace_membership" + "makerspace_membership", + "membership_term" ] } } @@ -12857,8 +13279,11 @@ "enum": [ "booking", "event_registration", + "loan_deposit", + "loan_late_fee", "machine_service_request", - "makerspace_membership" + "makerspace_membership", + "membership_term" ] } } @@ -12994,8 +13419,11 @@ "enum": [ "booking", "event_registration", + "loan_deposit", + "loan_late_fee", "machine_service_request", - "makerspace_membership" + "makerspace_membership", + "membership_term" ] } } @@ -13131,8 +13559,11 @@ "enum": [ "booking", "event_registration", + "loan_deposit", + "loan_late_fee", "machine_service_request", - "makerspace_membership" + "makerspace_membership", + "membership_term" ] } } @@ -13268,8 +13699,11 @@ "enum": [ "booking", "event_registration", + "loan_deposit", + "loan_late_fee", "machine_service_request", - "makerspace_membership" + "makerspace_membership", + "membership_term" ] } } @@ -14726,6 +15160,105 @@ } } }, + "/api/v1/admin/makerspace/{makerspace_id}/certification-types": { + "get": { + "operationId": "api_v1_admin_makerspace_certification_types_list", + "summary": "List certification types in a makerspace", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Admin machines" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CertificationType" + } + } + } + }, + "description": "" + } + } + }, + "post": { + "operationId": "api_v1_admin_makerspace_certification_types_create", + "summary": "Create a certification type", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Admin machines" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CertificationType" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/CertificationType" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/CertificationType" + } + } + }, + "required": true + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CertificationType" + } + } + }, + "description": "" + }, + "400": { + "description": "Invalid certification type." + }, + "403": { + "description": "Machine type authority required." + } + } + } + }, "/api/v1/admin/makerspace/{makerspace_id}/containers": { "get": { "operationId": "api_v1_admin_makerspace_containers_list", @@ -16124,6 +16657,22 @@ "operationId": "api_v1_admin_makerspace_inventory_list", "summary": "List or create inventory products", "parameters": [ + { + "in": "query", + "name": "archived", + "schema": { + "type": "string" + }, + "description": "`true` or `false`." + }, + { + "in": "query", + "name": "low_stock", + "schema": { + "type": "string" + }, + "description": "`true` to list items at or below 20% of total." + }, { "in": "path", "name": "makerspace_id", @@ -16149,6 +16698,14 @@ "schema": { "type": "integer" } + }, + { + "in": "query", + "name": "q", + "schema": { + "type": "string" + }, + "description": "Full-text search over name, storage location, tracking mode and description (\"phrases\", -exclusions, typo-tolerant on the name), or a category name." } ], "tags": [ @@ -17281,6 +17838,14 @@ "type": "integer" }, "required": true + }, + { + "in": "query", + "name": "q", + "schema": { + "type": "string" + }, + "description": "Full-text search over name, location, firmware version and notes." } ], "tags": [ @@ -18320,8 +18885,11 @@ "enum": [ "booking", "event_registration", + "loan_deposit", + "loan_late_fee", "machine_service_request", - "makerspace_membership" + "makerspace_membership", + "membership_term" ] } } @@ -18391,10 +18959,11 @@ } } }, - "/api/v1/admin/makerspace/{makerspace_id}/payments/{payment_id}/mark-offline": { + "/api/v1/admin/makerspace/{makerspace_id}/payments/{payment_id}/amend-settlement": { "post": { - "operationId": "api_v1_admin_makerspace_payments_mark_offline_create", - "summary": "Mark a payment paid offline", + "operationId": "api_v1_admin_makerspace_payments_amend_settlement_create", + "description": "Append a corrected receipt to an already-settled charge.", + "summary": "Correct how a settled payment was received", "parameters": [ { "in": "path", @@ -18416,6 +18985,26 @@ "tags": [ "Payments" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaymentOffline" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/PaymentOffline" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/PaymentOffline" + } + } + }, + "required": true + }, "security": [ { "jwtAuth": [] @@ -18475,10 +19064,10 @@ } } }, - "/api/v1/admin/makerspace/{makerspace_id}/payments/{payment_id}/waive": { + "/api/v1/admin/makerspace/{makerspace_id}/payments/{payment_id}/mark-offline": { "post": { - "operationId": "api_v1_admin_makerspace_payments_waive_create", - "summary": "Waive a payment", + "operationId": "api_v1_admin_makerspace_payments_mark_offline_create", + "summary": "Mark a payment paid offline", "parameters": [ { "in": "path", @@ -18500,97 +19089,21 @@ "tags": [ "Payments" ], - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PaymentReconciliation" - } - } - }, - "description": "" - }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Invalid payment request." - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Permission denied." - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Payment or makerspace not found." - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Payment is already terminal." - } - } - } - }, - "/api/v1/admin/makerspace/{makerspace_id}/payments/bulk/mark-offline": { - "post": { - "operationId": "api_v1_admin_makerspace_payments_bulk_mark_offline_create", - "summary": "Mark payments paid offline in one transaction", - "parameters": [ - { - "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" - }, - "required": true - } - ], - "tags": [ - "Payments" - ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaymentBulkAction" + "$ref": "#/components/schemas/PaymentOffline" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/PaymentBulkAction" + "$ref": "#/components/schemas/PaymentOffline" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/PaymentBulkAction" + "$ref": "#/components/schemas/PaymentOffline" } } }, @@ -18606,10 +19119,7 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PaymentReconciliation" - } + "$ref": "#/components/schemas/PaymentReconciliation" } } }, @@ -18658,10 +19168,11 @@ } } }, - "/api/v1/admin/makerspace/{makerspace_id}/payments/bulk/waive": { + "/api/v1/admin/makerspace/{makerspace_id}/payments/{payment_id}/refund": { "post": { - "operationId": "api_v1_admin_makerspace_payments_bulk_waive_create", - "summary": "Waive payments in one transaction", + "operationId": "api_v1_admin_makerspace_payments_refund_create", + "description": "Sends money back through the provider that took it and records a Refund line on the payment. Only `paid_online` payments qualify; the sum of pending and succeeded refunds can never exceed the payment amount. The Payment row itself is immutable -- refund state lives on the Refund rows returned in `refunds`.", + "summary": "Refund a payment settled online, fully or partially", "parameters": [ { "in": "path", @@ -18670,6 +19181,14 @@ "type": "integer" }, "required": true + }, + { + "in": "path", + "name": "payment_id", + "schema": { + "type": "integer" + }, + "required": true } ], "tags": [ @@ -18679,17 +19198,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaymentBulkAction" + "$ref": "#/components/schemas/PaymentRefundRequest" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/PaymentBulkAction" + "$ref": "#/components/schemas/PaymentRefundRequest" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/PaymentBulkAction" + "$ref": "#/components/schemas/PaymentRefundRequest" } } }, @@ -18705,10 +19224,7 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PaymentReconciliation" - } + "$ref": "#/components/schemas/PaymentReconciliation" } } }, @@ -18722,7 +19238,289 @@ } } }, - "description": "Invalid payment request." + "description": "Refund not allowed or over the balance." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Permission denied." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Payment or makerspace not found." + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "The provider rejected the refund." + } + } + } + }, + "/api/v1/admin/makerspace/{makerspace_id}/payments/{payment_id}/waive": { + "post": { + "operationId": "api_v1_admin_makerspace_payments_waive_create", + "summary": "Waive a payment", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + }, + { + "in": "path", + "name": "payment_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Payments" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaymentReconciliation" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Invalid payment request." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Permission denied." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Payment or makerspace not found." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Payment is already terminal." + } + } + } + }, + "/api/v1/admin/makerspace/{makerspace_id}/payments/bulk/mark-offline": { + "post": { + "operationId": "api_v1_admin_makerspace_payments_bulk_mark_offline_create", + "summary": "Mark payments paid offline in one transaction", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Payments" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaymentBulkOffline" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/PaymentBulkOffline" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/PaymentBulkOffline" + } + } + }, + "required": true + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PaymentReconciliation" + } + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Invalid payment request." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Permission denied." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Payment or makerspace not found." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Payment is already terminal." + } + } + } + }, + "/api/v1/admin/makerspace/{makerspace_id}/payments/bulk/waive": { + "post": { + "operationId": "api_v1_admin_makerspace_payments_bulk_waive_create", + "summary": "Waive payments in one transaction", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Payments" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaymentBulkAction" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/PaymentBulkAction" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/PaymentBulkAction" + } + } + }, + "required": true + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PaymentReconciliation" + } + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Invalid payment request." }, "403": { "content": { @@ -19243,6 +20041,7 @@ "enum": [ "active-loans", "booking-utilization", + "certification-coverage", "communications-health", "community-engagement", "damaged-lost", @@ -19302,8 +20101,11 @@ "enum": [ "booking", "event_registration", + "loan_deposit", + "loan_late_fee", "machine_service_request", - "makerspace_membership" + "makerspace_membership", + "membership_term" ] } } @@ -21761,6 +22563,14 @@ "type": "integer" }, "required": true + }, + { + "in": "query", + "name": "q", + "schema": { + "type": "string" + }, + "description": "Full-text search over title, location and description." } ], "tags": [ @@ -22036,6 +22846,45 @@ } } }, + "/api/v1/admin/makerspaces/{makerspace_id}/invitation-requests": { + "get": { + "operationId": "api_v1_admin_makerspaces_invitation_requests_list", + "summary": "List invitation requests (optionally filtered by ?status=)", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Admin memberships" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/InvitationRequest" + } + } + } + }, + "description": "" + } + } + } + }, "/api/v1/admin/makerspaces/{makerspace_id}/machine-service/consumable-pools": { "get": { "operationId": "api_v1_admin_makerspaces_machine_service_consumable_pools_list", @@ -22777,10 +23626,10 @@ } } }, - "/api/v1/admin/makerspaces/{makerspace_id}/member-claim-codes": { + "/api/v1/admin/makerspaces/{makerspace_id}/member-card-template": { "get": { - "operationId": "api_v1_admin_makerspaces_member_claim_codes_list", - "summary": "List active physically handed member claim codes", + "operationId": "api_v1_admin_makerspaces_member_card_template_retrieve", + "summary": "Read the card layout template", "parameters": [ { "in": "path", @@ -22804,70 +23653,268 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/MemberClaimCode" - } + "$ref": "#/components/schemas/MemberCardTemplate" } } }, "description": "" }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } + "403": { + "description": "Missing member-card action." + }, + "404": { + "description": "Not found." + } + } + }, + "put": { + "operationId": "api_v1_admin_makerspaces_member_card_template_update", + "summary": "Replace the card layout template", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Admin memberships" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MemberCardTemplate" } }, - "description": "" - }, - "401": { + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/MemberCardTemplate" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/MemberCardTemplate" + } + } + } + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/MemberCardTemplate" } } }, "description": "" }, "403": { + "description": "Missing member-card action." + }, + "404": { + "description": "Not found." + } + } + } + }, + "/api/v1/admin/makerspaces/{makerspace_id}/member-cards": { + "get": { + "operationId": "api_v1_admin_makerspaces_member_cards_list", + "summary": "List member cards", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + }, + { + "in": "query", + "name": "status", + "schema": { + "type": "string" + }, + "description": "active | revoked" + } + ], + "tags": [ + "Admin memberships" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "type": "array", + "items": { + "$ref": "#/components/schemas/MemberCard" + } } } }, "description": "" }, + "403": { + "description": "Missing member-card action." + }, "404": { + "description": "Not found." + } + } + } + }, + "/api/v1/admin/makerspaces/{makerspace_id}/member-cards.pdf": { + "post": { + "operationId": "api_v1_admin_makerspaces_member_cards.pdf_create", + "summary": "Print a sheet of cards", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Admin memberships" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MemberCardPrint" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/MemberCardPrint" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/MemberCardPrint" + } + } + } + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { "content": { - "application/json": { + "application/pdf": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "type": "string", + "format": "binary" } } }, "description": "" }, - "409": { + "403": { + "description": "Missing member-card action." + }, + "404": { + "description": "Not found." + } + } + } + }, + "/api/v1/admin/makerspaces/{makerspace_id}/member-cards/{membership_id}/issue": { + "post": { + "operationId": "api_v1_admin_makerspaces_member_cards_issue_create", + "summary": "Issue a card to a membership", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + }, + { + "in": "path", + "name": "membership_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Admin memberships" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MemberCardIssue" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/MemberCardIssue" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/MemberCardIssue" + } + } + } + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/MemberCard" } } }, "description": "" + }, + "403": { + "description": "Missing member-card action." + }, + "404": { + "description": "Not found." } } - }, + } + }, + "/api/v1/admin/makerspaces/{makerspace_id}/member-cards/resolve": { "post": { - "operationId": "api_v1_admin_makerspaces_member_claim_codes_create", - "summary": "Issue a claim code to an eligible walk-in member", + "operationId": "api_v1_admin_makerspaces_member_cards_resolve_create", + "summary": "Resolve a scanned member card to minimal identity", "parameters": [ { "in": "path", @@ -22885,17 +23932,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MemberClaimCodeIssueRequest" + "$ref": "#/components/schemas/MemberCardResolve" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/MemberClaimCodeIssueRequest" + "$ref": "#/components/schemas/MemberCardResolve" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/MemberClaimCodeIssueRequest" + "$ref": "#/components/schemas/MemberCardResolve" } } }, @@ -22907,11 +23954,56 @@ } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MemberClaimCodeIssueResponse" + "$ref": "#/components/schemas/MemberCardResolveResult" + } + } + }, + "description": "" + }, + "403": { + "description": "Missing member-card action." + }, + "404": { + "description": "Not found." + } + } + } + }, + "/api/v1/admin/makerspaces/{makerspace_id}/member-claim-codes": { + "get": { + "operationId": "api_v1_admin_makerspaces_member_claim_codes_list", + "summary": "List active physically handed member claim codes", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Admin memberships" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MemberClaimCode" + } } } }, @@ -22966,33 +24058,13 @@ } }, "description": "" - }, - "429": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Issue rate limit exceeded." } } - } - }, - "/api/v1/admin/makerspaces/{makerspace_id}/member-claim-codes/{claim_id}/revoke": { + }, "post": { - "operationId": "api_v1_admin_makerspaces_member_claim_codes_revoke_create", - "summary": "Revoke a member claim code and its bound session", + "operationId": "api_v1_admin_makerspaces_member_claim_codes_create", + "summary": "Issue a claim code to an eligible walk-in member", "parameters": [ - { - "in": "path", - "name": "claim_id", - "schema": { - "type": "integer" - }, - "required": true - }, { "in": "path", "name": "makerspace_id", @@ -23005,17 +24077,37 @@ "tags": [ "Admin memberships" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MemberClaimCodeIssueRequest" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/MemberClaimCodeIssueRequest" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/MemberClaimCodeIssueRequest" + } + } + }, + "required": true + }, "security": [ { "jwtAuth": [] } ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MemberClaimCode" + "$ref": "#/components/schemas/MemberClaimCodeIssueResponse" } } }, @@ -23070,14 +24162,33 @@ } }, "description": "" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Issue rate limit exceeded." } } } }, - "/api/v1/admin/makerspaces/{makerspace_id}/memberships": { - "get": { - "operationId": "api_v1_admin_makerspaces_memberships_list", + "/api/v1/admin/makerspaces/{makerspace_id}/member-claim-codes/{claim_id}/revoke": { + "post": { + "operationId": "api_v1_admin_makerspaces_member_claim_codes_revoke_create", + "summary": "Revoke a member claim code and its bound session", "parameters": [ + { + "in": "path", + "name": "claim_id", + "schema": { + "type": "integer" + }, + "required": true + }, { "in": "path", "name": "makerspace_id", @@ -23100,10 +24211,7 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/MembershipList" - } + "$ref": "#/components/schemas/MemberClaimCode" } } }, @@ -23160,9 +24268,49 @@ "description": "" } } + } + }, + "/api/v1/admin/makerspaces/{makerspace_id}/membership-plans": { + "get": { + "operationId": "api_v1_admin_makerspaces_membership_plans_list", + "summary": "List membership plans", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Admin memberships" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MembershipPlan" + } + } + } + }, + "description": "" + } + } }, "post": { - "operationId": "api_v1_admin_makerspaces_memberships_create", + "operationId": "api_v1_admin_makerspaces_membership_plans_create", + "summary": "Create a membership plan", "parameters": [ { "in": "path", @@ -23180,17 +24328,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MembershipCreate" + "$ref": "#/components/schemas/MembershipPlan" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/MembershipCreate" + "$ref": "#/components/schemas/MembershipPlan" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/MembershipCreate" + "$ref": "#/components/schemas/MembershipPlan" } } }, @@ -23206,68 +24354,21 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MembershipList" + "$ref": "#/components/schemas/MembershipPlan" } } }, "description": "" }, "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "description": "Invalid input." } } } }, - "/api/v1/admin/makerspaces/{makerspace_id}/memberships/{membership_id}/role": { - "patch": { - "operationId": "api_v1_admin_makerspaces_memberships_role_partial_update", + "/api/v1/admin/makerspaces/{makerspace_id}/memberships": { + "get": { + "operationId": "api_v1_admin_makerspaces_memberships_list", "parameters": [ { "in": "path", @@ -23276,38 +24377,11 @@ "type": "integer" }, "required": true - }, - { - "in": "path", - "name": "membership_id", - "schema": { - "type": "integer" - }, - "required": true } ], "tags": [ "Admin memberships" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PatchedMembershipRoleAssign" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/PatchedMembershipRoleAssign" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/PatchedMembershipRoleAssign" - } - } - } - }, "security": [ { "jwtAuth": [] @@ -23318,7 +24392,10 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MembershipList" + "type": "array", + "items": { + "$ref": "#/components/schemas/MembershipList" + } } } }, @@ -23375,11 +24452,9 @@ "description": "" } } - } - }, - "/api/v1/admin/makerspaces/{makerspace_id}/roles": { - "get": { - "operationId": "api_v1_admin_makerspaces_roles_list", + }, + "post": { + "operationId": "api_v1_admin_makerspaces_memberships_create", "parameters": [ { "in": "path", @@ -23391,22 +24466,39 @@ } ], "tags": [ - "Admin roles" + "Admin memberships" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MembershipCreate" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/MembershipCreate" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/MembershipCreate" + } + } + }, + "required": true + }, "security": [ { "jwtAuth": [] } ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Role" - } + "$ref": "#/components/schemas/MembershipList" } } }, @@ -23463,9 +24555,11 @@ "description": "" } } - }, - "post": { - "operationId": "api_v1_admin_makerspaces_roles_create", + } + }, + "/api/v1/admin/makerspaces/{makerspace_id}/memberships/{membership_id}/role": { + "patch": { + "operationId": "api_v1_admin_makerspaces_memberships_role_partial_update", "parameters": [ { "in": "path", @@ -23474,30 +24568,37 @@ "type": "integer" }, "required": true + }, + { + "in": "path", + "name": "membership_id", + "schema": { + "type": "integer" + }, + "required": true } ], "tags": [ - "Admin roles" + "Admin memberships" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RoleCreate" + "$ref": "#/components/schemas/PatchedMembershipRoleAssign" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/RoleCreate" + "$ref": "#/components/schemas/PatchedMembershipRoleAssign" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/RoleCreate" + "$ref": "#/components/schemas/PatchedMembershipRoleAssign" } } - }, - "required": true + } }, "security": [ { @@ -23505,11 +24606,11 @@ } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Role" + "$ref": "#/components/schemas/MembershipList" } } }, @@ -23568,9 +24669,10 @@ } } }, - "/api/v1/admin/makerspaces/{makerspace_id}/roles/{role_id}": { + "/api/v1/admin/makerspaces/{makerspace_id}/report-schedules": { "get": { - "operationId": "api_v1_admin_makerspaces_roles_retrieve", + "operationId": "api_v1_admin_makerspaces_report_schedules_list", + "summary": "List report schedules", "parameters": [ { "in": "path", @@ -23580,9 +24682,159 @@ }, "required": true }, + { + "name": "page", + "required": false, + "in": "query", + "description": "A page number within the paginated result set.", + "schema": { + "type": "integer" + } + } + ], + "tags": [ + "Reports" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaginatedReportScheduleList" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Invalid schedule or module disabled." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Permission denied." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Makerspace or schedule not found." + } + } + }, + "post": { + "operationId": "api_v1_admin_makerspaces_report_schedules_create", + "summary": "Create report schedule", + "parameters": [ { "in": "path", - "name": "role_id", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Reports" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportSchedule" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/ReportSchedule" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/ReportSchedule" + } + } + }, + "required": true + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportSchedule" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Invalid schedule or module disabled." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Permission denied." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Makerspace or schedule not found." + } + } + } + }, + "/api/v1/admin/makerspaces/{makerspace_id}/roles": { + "get": { + "operationId": "api_v1_admin_makerspaces_roles_list", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", "schema": { "type": "integer" }, @@ -23602,7 +24854,10 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Role" + "type": "array", + "items": { + "$ref": "#/components/schemas/Role" + } } } }, @@ -23660,8 +24915,8 @@ } } }, - "patch": { - "operationId": "api_v1_admin_makerspaces_roles_partial_update", + "post": { + "operationId": "api_v1_admin_makerspaces_roles_create", "parameters": [ { "in": "path", @@ -23670,14 +24925,6 @@ "type": "integer" }, "required": true - }, - { - "in": "path", - "name": "role_id", - "schema": { - "type": "integer" - }, - "required": true } ], "tags": [ @@ -23687,20 +24934,21 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PatchedRoleWrite" + "$ref": "#/components/schemas/RoleCreate" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/PatchedRoleWrite" + "$ref": "#/components/schemas/RoleCreate" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/PatchedRoleWrite" + "$ref": "#/components/schemas/RoleCreate" } } - } + }, + "required": true }, "security": [ { @@ -23708,7 +24956,7 @@ } ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { @@ -23769,9 +25017,11 @@ "description": "" } } - }, - "delete": { - "operationId": "api_v1_admin_makerspaces_roles_destroy", + } + }, + "/api/v1/admin/makerspaces/{makerspace_id}/roles/{role_id}": { + "get": { + "operationId": "api_v1_admin_makerspaces_roles_retrieve", "parameters": [ { "in": "path", @@ -23799,8 +25049,15 @@ } ], "responses": { - "204": { - "description": "No response body" + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Role" + } + } + }, + "description": "" }, "400": { "content": { @@ -23853,12 +25110,9 @@ "description": "" } } - } - }, - "/api/v1/admin/makerspaces/{makerspace_id}/roles/{role_id}/machine-scope": { - "get": { - "operationId": "api_v1_admin_makerspaces_roles_machine_scope_retrieve", - "description": "Which machines a role's MANAGE_MACHINES grant reaches.\n\nConsole parity: machine scoping fails closed, so without this surface a Space Manager\ncould create a machine-managing role and have no way to make it able to manage\nanything — the capability would exist only in `/control/` and the shell.", + }, + "patch": { + "operationId": "api_v1_admin_makerspaces_roles_partial_update", "parameters": [ { "in": "path", @@ -23880,6 +25134,25 @@ "tags": [ "Admin roles" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PatchedRoleWrite" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/PatchedRoleWrite" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/PatchedRoleWrite" + } + } + } + }, "security": [ { "jwtAuth": [] @@ -23890,7 +25163,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RoleMachineScope" + "$ref": "#/components/schemas/Role" } } }, @@ -23948,9 +25221,8 @@ } } }, - "put": { - "operationId": "api_v1_admin_makerspaces_roles_machine_scope_update", - "description": "Which machines a role's MANAGE_MACHINES grant reaches.\n\nConsole parity: machine scoping fails closed, so without this surface a Space Manager\ncould create a machine-managing role and have no way to make it able to manage\nanything — the capability would exist only in `/control/` and the shell.", + "delete": { + "operationId": "api_v1_admin_makerspaces_roles_destroy", "parameters": [ { "in": "path", @@ -23972,41 +25244,14 @@ "tags": [ "Admin roles" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RoleMachineScopeWrite" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/RoleMachineScopeWrite" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/RoleMachineScopeWrite" - } - } - }, - "required": true - }, "security": [ { "jwtAuth": [] } ], "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RoleMachineScope" - } - } - }, - "description": "" + "204": { + "description": "No response body" }, "400": { "content": { @@ -24061,9 +25306,10 @@ } } }, - "/api/v1/admin/makerspaces/{makerspace_id}/roles/capabilities": { + "/api/v1/admin/makerspaces/{makerspace_id}/roles/{role_id}/machine-scope": { "get": { - "operationId": "api_v1_admin_makerspaces_roles_capabilities_list", + "operationId": "api_v1_admin_makerspaces_roles_machine_scope_retrieve", + "description": "Which machines a role's MANAGE_MACHINES grant reaches.\n\nConsole parity: machine scoping fails closed, so without this surface a Space Manager\ncould create a machine-managing role and have no way to make it able to manage\nanything — the capability would exist only in `/control/` and the shell.", "parameters": [ { "in": "path", @@ -24072,6 +25318,14 @@ "type": "integer" }, "required": true + }, + { + "in": "path", + "name": "role_id", + "schema": { + "type": "integer" + }, + "required": true } ], "tags": [ @@ -24087,10 +25341,207 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Capability" - } + "$ref": "#/components/schemas/RoleMachineScope" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + } + } + }, + "put": { + "operationId": "api_v1_admin_makerspaces_roles_machine_scope_update", + "description": "Which machines a role's MANAGE_MACHINES grant reaches.\n\nConsole parity: machine scoping fails closed, so without this surface a Space Manager\ncould create a machine-managing role and have no way to make it able to manage\nanything — the capability would exist only in `/control/` and the shell.", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + }, + { + "in": "path", + "name": "role_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Admin roles" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RoleMachineScopeWrite" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/RoleMachineScopeWrite" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/RoleMachineScopeWrite" + } + } + }, + "required": true + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RoleMachineScope" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + } + } + } + }, + "/api/v1/admin/makerspaces/{makerspace_id}/roles/capabilities": { + "get": { + "operationId": "api_v1_admin_makerspaces_roles_capabilities_list", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Admin roles" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Capability" + } } } }, @@ -24653,18 +26104,18 @@ } } }, - "/api/v1/admin/membership-requests": { - "get": { - "operationId": "api_v1_admin_membership_requests_list", + "/api/v1/admin/member-cards/{id}/print.pdf": { + "post": { + "operationId": "api_v1_admin_member_cards_print.pdf_create", + "summary": "Print one card (CR80 PDF)", "parameters": [ { - "name": "page", - "required": false, - "in": "query", - "description": "A page number within the paginated result set.", + "in": "path", + "name": "id", "schema": { "type": "integer" - } + }, + "required": true } ], "tags": [ @@ -24678,70 +26129,151 @@ "responses": { "200": { "content": { - "application/json": { + "application/pdf": { "schema": { - "$ref": "#/components/schemas/PaginatedMembershipRequestList" + "type": "string", + "format": "binary" } } }, "description": "" }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } + "403": { + "description": "Missing member-card action." + }, + "404": { + "description": "Not found." + } + } + } + }, + "/api/v1/admin/member-cards/{id}/reissue": { + "post": { + "operationId": "api_v1_admin_member_cards_reissue_create", + "summary": "Reissue (rotate the QR of) a card", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Admin memberships" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MemberCardReissue" } }, - "description": "" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/MemberCardReissue" } }, - "description": "" + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/MemberCardReissue" + } + } }, - "403": { + "required": true + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/MemberCard" } } }, "description": "" }, + "403": { + "description": "Missing member-card action." + }, "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } + "description": "Not found." + } + } + } + }, + "/api/v1/admin/member-cards/{id}/revoke": { + "post": { + "operationId": "api_v1_admin_member_cards_revoke_create", + "summary": "Revoke a card (redacts name and photo)", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Admin memberships" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MemberCardRevoke" } }, - "description": "" - }, - "409": { + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/MemberCardRevoke" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/MemberCardRevoke" + } + } + } + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/MemberCard" } } }, "description": "" + }, + "403": { + "description": "Missing member-card action." + }, + "404": { + "description": "Not found." } } } }, - "/api/v1/admin/membership-requests/{id}/approve": { - "post": { - "operationId": "api_v1_admin_membership_requests_approve_create", + "/api/v1/admin/membership-plans/{id}": { + "patch": { + "operationId": "api_v1_admin_membership_plans_partial_update", + "summary": "Update a membership plan", "parameters": [ { "in": "path", @@ -24759,21 +26291,20 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RoleId" + "$ref": "#/components/schemas/PatchedMembershipPlan" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/RoleId" + "$ref": "#/components/schemas/PatchedMembershipPlan" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/RoleId" + "$ref": "#/components/schemas/PatchedMembershipPlan" } } - }, - "required": true + } }, "security": [ { @@ -24785,7 +26316,46 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminMembership" + "$ref": "#/components/schemas/MembershipPlan" + } + } + }, + "description": "" + }, + "400": { + "description": "Invalid input." + } + } + } + }, + "/api/v1/admin/membership-requests": { + "get": { + "operationId": "api_v1_admin_membership_requests_list", + "parameters": [ + { + "name": "page", + "required": false, + "in": "query", + "description": "A page number within the paginated result set.", + "schema": { + "type": "integer" + } + } + ], + "tags": [ + "Admin memberships" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaginatedMembershipRequestList" } } }, @@ -24844,9 +26414,9 @@ } } }, - "/api/v1/admin/membership-requests/{id}/revoke": { + "/api/v1/admin/membership-requests/{id}/approve": { "post": { - "operationId": "api_v1_admin_membership_requests_revoke_create", + "operationId": "api_v1_admin_membership_requests_approve_create", "parameters": [ { "in": "path", @@ -24864,20 +26434,21 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Revoke" + "$ref": "#/components/schemas/ApproveRequest" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/Revoke" + "$ref": "#/components/schemas/ApproveRequest" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/Revoke" + "$ref": "#/components/schemas/ApproveRequest" } } - } + }, + "required": true }, "security": [ { @@ -24889,7 +26460,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MembershipRequest" + "$ref": "#/components/schemas/AdminMembership" } } }, @@ -24948,23 +26519,41 @@ } } }, - "/api/v1/admin/memberships": { - "get": { - "operationId": "api_v1_admin_memberships_list", + "/api/v1/admin/membership-requests/{id}/revoke": { + "post": { + "operationId": "api_v1_admin_membership_requests_revoke_create", "parameters": [ { - "name": "page", - "required": false, - "in": "query", - "description": "A page number within the paginated result set.", + "in": "path", + "name": "id", "schema": { "type": "integer" - } + }, + "required": true } ], "tags": [ "Admin memberships" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Revoke" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/Revoke" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/Revoke" + } + } + } + }, "security": [ { "jwtAuth": [] @@ -24975,7 +26564,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaginatedAdminMembershipList" + "$ref": "#/components/schemas/MembershipRequest" } } }, @@ -25034,11 +26623,10 @@ } } }, - "/api/v1/admin/memberships/{id}": { - "delete": { - "operationId": "api_v1_admin_memberships_destroy", - "description": "Remove a single makerspace membership (un-assign a delegable role).\n\nScope contract (mirrors the create path's non-escalation model): a Space Manager may\nrevoke ONLY delegable-role memberships within their MANAGE_MAKERSPACE scope; a superadmin\nmay revoke any, except inside a superadmin-hidden makerspace (governance hard-block ->\n404). 404-before-403: out-of-scope existence is hidden as 404, a delegable-scope actor\naiming at a SPACE_MANAGER gets 403.", - "summary": "Revoke a staff membership", + "/api/v1/admin/membership-terms/{id}/cancel": { + "post": { + "operationId": "api_v1_admin_membership_terms_cancel_create", + "summary": "Cancel an active term", "parameters": [ { "in": "path", @@ -25050,7 +26638,7 @@ } ], "tags": [ - "Admin users" + "Admin memberships" ], "security": [ { @@ -25058,47 +26646,39 @@ } ], "responses": { - "204": { - "description": "No response body" + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MembershipTerm" + } + } + }, + "description": "" + }, + "400": { + "description": "Invalid input." } } } }, - "/api/v1/admin/memberships/{id}/capabilities": { - "patch": { - "operationId": "api_v1_admin_memberships_capabilities_partial_update", + "/api/v1/admin/memberships": { + "get": { + "operationId": "api_v1_admin_memberships_list", "parameters": [ { - "in": "path", - "name": "id", + "name": "page", + "required": false, + "in": "query", + "description": "A page number within the paginated result set.", "schema": { "type": "integer" - }, - "required": true + } } ], "tags": [ "Admin memberships" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PatchedMembershipCapabilities" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/PatchedMembershipCapabilities" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/PatchedMembershipCapabilities" - } - } - } - }, "security": [ { "jwtAuth": [] @@ -25109,7 +26689,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminMembership" + "$ref": "#/components/schemas/PaginatedAdminMembershipList" } } }, @@ -25168,9 +26748,39 @@ } } }, - "/api/v1/admin/memberships/{id}/revoke": { - "post": { - "operationId": "api_v1_admin_memberships_revoke_create", + "/api/v1/admin/memberships/{id}": { + "delete": { + "operationId": "api_v1_admin_memberships_destroy", + "description": "Remove a single makerspace membership (un-assign a delegable role).\n\nScope contract (mirrors the create path's non-escalation model): a Space Manager may\nrevoke ONLY delegable-role memberships within their MANAGE_MAKERSPACE scope; a superadmin\nmay revoke any, except inside a superadmin-hidden makerspace (governance hard-block ->\n404). 404-before-403: out-of-scope existence is hidden as 404, a delegable-scope actor\naiming at a SPACE_MANAGER gets 403.", + "summary": "Revoke a staff membership", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Admin users" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "204": { + "description": "No response body" + } + } + } + }, + "/api/v1/admin/memberships/{id}/capabilities": { + "patch": { + "operationId": "api_v1_admin_memberships_capabilities_partial_update", "parameters": [ { "in": "path", @@ -25188,17 +26798,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Revoke" + "$ref": "#/components/schemas/PatchedMembershipCapabilities" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/Revoke" + "$ref": "#/components/schemas/PatchedMembershipCapabilities" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/Revoke" + "$ref": "#/components/schemas/PatchedMembershipCapabilities" } } } @@ -25272,9 +26882,9 @@ } } }, - "/api/v1/admin/memberships/{id}/role": { - "patch": { - "operationId": "api_v1_admin_memberships_role_partial_update", + "/api/v1/admin/memberships/{id}/revoke": { + "post": { + "operationId": "api_v1_admin_memberships_revoke_create", "parameters": [ { "in": "path", @@ -25292,17 +26902,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PatchedRoleId" + "$ref": "#/components/schemas/Revoke" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/PatchedRoleId" + "$ref": "#/components/schemas/Revoke" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/PatchedRoleId" + "$ref": "#/components/schemas/Revoke" } } } @@ -25376,9 +26986,9 @@ } } }, - "/api/v1/admin/memberships/{id}/unverify": { - "post": { - "operationId": "api_v1_admin_memberships_unverify_create", + "/api/v1/admin/memberships/{id}/role": { + "patch": { + "operationId": "api_v1_admin_memberships_role_partial_update", "parameters": [ { "in": "path", @@ -25392,6 +27002,25 @@ "tags": [ "Admin memberships" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PatchedRoleId" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/PatchedRoleId" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/PatchedRoleId" + } + } + } + }, "security": [ { "jwtAuth": [] @@ -25461,9 +27090,10 @@ } } }, - "/api/v1/admin/memberships/{id}/verify": { - "post": { - "operationId": "api_v1_admin_memberships_verify_create", + "/api/v1/admin/memberships/{id}/terms": { + "get": { + "operationId": "api_v1_admin_memberships_terms_list", + "summary": "List the terms of a membership", "parameters": [ { "in": "path", @@ -25487,68 +27117,248 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminMembership" + "type": "array", + "items": { + "$ref": "#/components/schemas/MembershipTerm" + } } } }, "description": "" - }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } + } + } + }, + "post": { + "operationId": "api_v1_admin_memberships_terms_create", + "summary": "Open a term for a membership", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" }, - "description": "" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } + "required": true + } + ], + "tags": [ + "Admin memberships" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MembershipTermCreate" } }, - "description": "" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/MembershipTermCreate" } }, - "description": "" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/MembershipTermCreate" } - }, - "description": "" + } }, - "409": { + "required": true + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/MembershipTerm" } } }, "description": "" + }, + "400": { + "description": "Invalid input." } } } }, - "/api/v1/admin/memberships/{id}/waiver/witness": { + "/api/v1/admin/memberships/{id}/unverify": { "post": { - "operationId": "api_v1_admin_memberships_waiver_witness_create", + "operationId": "api_v1_admin_memberships_unverify_create", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Admin memberships" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminMembership" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + } + } + } + }, + "/api/v1/admin/memberships/{id}/verify": { + "post": { + "operationId": "api_v1_admin_memberships_verify_create", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Admin memberships" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminMembership" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + } + } + } + }, + "/api/v1/admin/memberships/{id}/waiver/witness": { + "post": { + "operationId": "api_v1_admin_memberships_waiver_witness_create", "parameters": [ { "in": "path", @@ -25846,8 +27656,11 @@ "enum": [ "booking", "event_registration", + "loan_deposit", + "loan_late_fee", "machine_service_request", - "makerspace_membership" + "makerspace_membership", + "membership_term" ] } } @@ -28822,6 +30635,224 @@ } } }, + "/api/v1/admin/report-schedules/{id}": { + "patch": { + "operationId": "api_v1_admin_report_schedules_partial_update", + "summary": "Update report schedule", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Reports" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PatchedReportSchedule" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/PatchedReportSchedule" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/PatchedReportSchedule" + } + } + } + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportSchedule" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Invalid schedule or module disabled." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Permission denied." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Makerspace or schedule not found." + } + } + }, + "delete": { + "operationId": "api_v1_admin_report_schedules_destroy", + "summary": "Delete report schedule", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Reports" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "204": { + "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Invalid schedule or module disabled." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Permission denied." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Makerspace or schedule not found." + } + } + } + }, + "/api/v1/admin/report-schedules/{id}/run-now": { + "post": { + "operationId": "api_v1_admin_report_schedules_run_now_create", + "summary": "Run a report schedule now", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Reports" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportDelivery" + } + } + }, + "description": "" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Schedule inactive or skipped." + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Invalid schedule or module disabled." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Permission denied." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Makerspace or schedule not found." + } + } + } + }, "/api/v1/admin/reports/{report_key}/export": { "get": { "operationId": "api_v1_admin_reports_export_retrieve", @@ -28865,6 +30896,7 @@ "enum": [ "active-loans", "booking-utilization", + "certification-coverage", "communications-health", "community-engagement", "damaged-lost", @@ -28924,8 +30956,11 @@ "enum": [ "booking", "event_registration", + "loan_deposit", + "loan_late_fee", "machine_service_request", - "makerspace_membership" + "makerspace_membership", + "membership_term" ] } } @@ -34511,6 +36546,36 @@ } } }, + "/api/v1/live/": { + "get": { + "operationId": "api_v1_live_retrieve", + "description": "text/event-stream of `change` events `{kind, makerspace_id, target_type, target_id, actor_id, ts}` for every makerspace the session may see. Clients invalidate cached queries on an event and refetch; no tenant content travels on the stream. 503 when the deployment has no Redis.", + "summary": "Live update stream (Server-Sent Events)", + "tags": [ + "Health" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "text/event-stream": { + "schema": { + "type": "string" + } + } + }, + "description": "" + }, + "503": { + "description": "No response body" + } + } + } + }, "/api/v1/member/archived-payments": { "get": { "operationId": "api_v1_member_archived_payments_list", @@ -34804,6 +36869,14 @@ "type": "integer" }, "required": true + }, + { + "in": "query", + "name": "q", + "schema": { + "type": "string" + }, + "description": "Match on username, display name, headline or institution. Contact details are never searched." } ], "tags": [ @@ -35619,10 +37692,10 @@ } } }, - "/api/v1/member/makerspaces/{makerspace_id}/payments": { + "/api/v1/member/makerspaces/{makerspace_id}/member-card": { "get": { - "operationId": "api_v1_member_makerspaces_payments_list", - "summary": "List the caller's payment history", + "operationId": "api_v1_member_makerspaces_member_card_retrieve", + "summary": "Read my member card", "parameters": [ { "in": "path", @@ -35634,7 +37707,7 @@ } ], "tags": [ - "Payments" + "Member profile" ], "security": [ { @@ -35646,32 +37719,84 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/MemberPayment" - } + "$ref": "#/components/schemas/MemberCard" } } }, "description": "" }, "403": { + "description": "An active membership is required." + }, + "404": { + "description": "No card has been issued to this membership." + } + } + }, + "patch": { + "operationId": "api_v1_member_makerspaces_member_card_partial_update", + "summary": "Set the name printed on my card", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Member profile" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PatchedMemberCardOwnUpdate" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/PatchedMemberCardOwnUpdate" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/PatchedMemberCardOwnUpdate" + } + } + } + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/MemberCard" } } }, "description": "" + }, + "403": { + "description": "An active membership is required." + }, + "404": { + "description": "No card has been issued to this membership." } } } }, - "/api/v1/member/makerspaces/{makerspace_id}/payments/{payment_id}/checkout": { + "/api/v1/member/makerspaces/{makerspace_id}/member-card/photo": { "post": { - "operationId": "api_v1_member_makerspaces_payments_checkout_create", - "summary": "Generate a Checkout link for the caller's pending payment", + "operationId": "api_v1_member_makerspaces_member_card_photo_create", + "summary": "Create a card photo upload URL (staging only)", "parameters": [ { "in": "path", @@ -35680,10 +37805,66 @@ "type": "integer" }, "required": true + } + ], + "tags": [ + "Member profile" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MemberCardPhotoPresign" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/MemberCardPhotoPresign" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/MemberCardPhotoPresign" + } + } }, + "required": true + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": {} + } + } + }, + "description": "" + }, + "403": { + "description": "An active membership is required." + }, + "404": { + "description": "No card has been issued to this membership." + }, + "503": { + "description": "Storage unavailable." + } + } + }, + "put": { + "operationId": "api_v1_member_makerspaces_member_card_photo_update", + "summary": "Attach an uploaded card photo (requires consent)", + "parameters": [ { "in": "path", - "name": "payment_id", + "name": "makerspace_id", "schema": { "type": "integer" }, @@ -35691,8 +37872,28 @@ } ], "tags": [ - "Payments" + "Member profile" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MemberCardPhotoFinalize" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/MemberCardPhotoFinalize" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/MemberCardPhotoFinalize" + } + } + }, + "required": true + }, "security": [ { "jwtAuth": [] @@ -35703,23 +37904,141 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CheckoutUrl" + "$ref": "#/components/schemas/MemberCard" } } }, "description": "" }, + "403": { + "description": "An active membership is required." + }, "404": { + "description": "No card has been issued to this membership." + } + } + }, + "delete": { + "operationId": "api_v1_member_makerspaces_member_card_photo_destroy", + "summary": "Remove my card photo", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Member profile" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/MemberCard" } } }, "description": "" }, - "503": { + "403": { + "description": "An active membership is required." + }, + "404": { + "description": "No card has been issued to this membership." + } + } + } + }, + "/api/v1/member/makerspaces/{makerspace_id}/member-card/preview.pdf": { + "get": { + "operationId": "api_v1_member_makerspaces_member_card_preview.pdf_retrieve", + "summary": "Preview my card as a watermarked PDF", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Member profile" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/pdf": { + "schema": { + "type": "string", + "format": "binary" + } + } + }, + "description": "" + }, + "403": { + "description": "An active membership is required." + }, + "404": { + "description": "No card has been issued to this membership." + } + } + } + }, + "/api/v1/member/makerspaces/{makerspace_id}/payments": { + "get": { + "operationId": "api_v1_member_makerspaces_payments_list", + "summary": "List the caller's payment history", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Payments" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MemberPayment" + } + } + } + }, + "description": "" + }, + "403": { "content": { "application/json": { "schema": { @@ -35732,10 +38051,74 @@ } } }, - "/api/v1/member/makerspaces/{makerspace_id}/payments/{payment_id}/mobile-intent": { + "/api/v1/member/makerspaces/{makerspace_id}/payments/{payment_id}/checkout": { "post": { - "operationId": "api_v1_member_makerspaces_payments_mobile_intent_create", - "summary": "Create or retrieve a native mobile payment intent", + "operationId": "api_v1_member_makerspaces_payments_checkout_create", + "summary": "Generate a Checkout link for the caller's pending payment", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + }, + { + "in": "path", + "name": "payment_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Payments" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CheckoutUrl" + } + } + }, + "description": "" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + } + } + } + }, + "/api/v1/member/makerspaces/{makerspace_id}/payments/{payment_id}/mobile-intent": { + "post": { + "operationId": "api_v1_member_makerspaces_payments_mobile_intent_create", + "summary": "Create or retrieve a native mobile payment intent", "parameters": [ { "in": "path", @@ -36727,6 +39110,37 @@ } } }, + "/api/v1/metrics/": { + "get": { + "operationId": "api_v1_metrics_retrieve", + "description": "Prometheus text exposition of queue depth, request states, notification delivery outcomes, storage accounting and evidence retention. Requires the deployment's METRICS_TOKEN as a bearer token; 404 when no token is configured.", + "summary": "Prometheus metrics", + "tags": [ + "Health" + ], + "security": [ + {} + ], + "responses": { + "200": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "" + }, + "401": { + "description": "No response body" + }, + "404": { + "description": "No response body" + } + } + } + }, "/api/v1/notifications/makerspace/{makerspace_id}": { "get": { "operationId": "api_v1_notifications_makerspace_list", @@ -37998,6 +40412,14 @@ "type": "string" }, "required": true + }, + { + "in": "query", + "name": "q", + "schema": { + "type": "string" + }, + "description": "Full-text search over title, location and description; supports \"phrases\" and -exclusions, tolerates typos in the title." } ], "tags": [ @@ -38504,7 +40926,7 @@ "schema": { "type": "string" }, - "description": "Search public products by name or description." + "description": "Full-text search over name, storage location, tracking mode and description; supports \"phrases\" and -exclusions, tolerates typos in the name." }, { "in": "query", @@ -38660,6 +41082,86 @@ } } }, + "/api/v1/public/{makerspace_slug}/invitation-requests": { + "post": { + "operationId": "api_v1_public_invitation_requests_create", + "summary": "Ask a makerspace to invite you", + "parameters": [ + { + "in": "path", + "name": "makerspace_slug", + "schema": { + "type": "string" + }, + "required": true + } + ], + "tags": [ + "Memberships" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvitationRequestCreate" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/InvitationRequestCreate" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/InvitationRequestCreate" + } + } + }, + "required": true + }, + "security": [ + { + "jwtAuth": [] + }, + {} + ], + "responses": { + "202": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvitationRequestAck" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "404": { + "description": "Unknown makerspace or membership disabled." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + } + } + } + }, "/api/v1/public/{makerspace_slug}/machine-service-requests": { "post": { "operationId": "api_v1_public_machine_service_requests_create", @@ -38991,6 +41493,14 @@ "schema": { "type": "integer" } + }, + { + "in": "query", + "name": "q", + "schema": { + "type": "string" + }, + "description": "Full-text search over name, location, firmware version and notes; tolerates typos in the name." } ], "tags": [ @@ -41688,6 +44198,21 @@ "type": "string", "description": "* `instant` - Instant confirmation\n* `approve` - Staff approval required" }, + "ApproveRequest": { + "type": "object", + "properties": { + "role_id": { + "type": "integer" + }, + "plan_id": { + "type": "integer", + "nullable": true + } + }, + "required": [ + "role_id" + ] + }, "ArchiveCustodyReadiness": { "type": "object", "properties": { @@ -43183,6 +45708,15 @@ "mapping": {} } }, + "CadenceEnum": { + "enum": [ + "daily", + "weekly", + "monthly" + ], + "type": "string", + "description": "* `daily` - Daily\n* `weekly` - Weekly\n* `monthly` - Monthly" + }, "Capability": { "type": "object", "properties": { @@ -43297,13 +45831,20 @@ "type": "object", "properties": { "reason": { - "$ref": "#/components/schemas/ReasonEnum" + "$ref": "#/components/schemas/CertificateRevokeReasonEnum" } }, "required": [ "reason" ] }, + "CertificateRevokeReasonEnum": { + "enum": [ + "staff_revoked" + ], + "type": "string", + "description": "* `staff_revoked` - staff_revoked" + }, "CertificateSummary": { "type": "object", "properties": { @@ -43361,6 +45902,157 @@ "type": "string", "description": "* `pending` - Pending\n* `rendering` - Rendering\n* `active` - Active\n* `failed` - Failed\n* `revoked` - Revoked" }, + "CertificationGrant": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "readOnly": true + }, + "certification_type": { + "type": "integer", + "readOnly": true + }, + "membership": { + "type": "integer", + "readOnly": true + }, + "member_name": { + "type": "string", + "readOnly": true + }, + "granted_by": { + "type": "integer", + "readOnly": true, + "nullable": true + }, + "granted_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "expires_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true + }, + "revoked_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true + }, + "revoked_by": { + "type": "integer", + "readOnly": true, + "nullable": true + }, + "notes": { + "type": "string", + "readOnly": true + }, + "is_live": { + "type": "string", + "readOnly": true + } + }, + "required": [ + "certification_type", + "expires_at", + "granted_at", + "granted_by", + "id", + "is_live", + "member_name", + "membership", + "notes", + "revoked_at", + "revoked_by" + ] + }, + "CertificationGrantCreate": { + "type": "object", + "properties": { + "membership_id": { + "type": "integer" + }, + "expires_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "notes": { + "type": "string", + "default": "" + } + }, + "required": [ + "membership_id" + ] + }, + "CertificationGrantRevoke": { + "type": "object", + "properties": { + "notes": { + "type": "string", + "default": "" + } + } + }, + "CertificationType": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "readOnly": true + }, + "machine_type": { + "type": "integer" + }, + "name": { + "type": "string", + "maxLength": 200 + }, + "description": { + "type": "string" + }, + "validity_days": { + "type": "integer", + "maximum": 2147483647, + "minimum": 0, + "nullable": true + }, + "is_required_for_service": { + "type": "boolean" + }, + "is_required_for_booking": { + "type": "boolean" + }, + "is_active": { + "type": "boolean", + "readOnly": true + }, + "created_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "updated_at": { + "type": "string", + "format": "date-time", + "readOnly": true + } + }, + "required": [ + "created_at", + "id", + "is_active", + "machine_type", + "name", + "updated_at" + ] + }, "ChangePassword": { "type": "object", "properties": { @@ -43389,27 +46081,29 @@ "detail" ] }, - "Channel7a7Enum": { + "Channel59dEnum": { "enum": [ "telegram", "slack", "mattermost", - "discord" + "discord", + "webhook" ], "type": "string", - "description": "* `telegram` - Telegram\n* `slack` - Slack\n* `mattermost` - Mattermost\n* `discord` - Discord" + "description": "* `telegram` - Telegram\n* `slack` - Slack\n* `mattermost` - Mattermost\n* `discord` - Discord\n* `webhook` - Signed webhook" }, - "ChannelCbbEnum": { + "ChannelC17Enum": { "enum": [ "email", "telegram", "slack", "mattermost", "discord", + "webhook", "native_push" ], "type": "string", - "description": "* `email` - Email\n* `telegram` - Telegram\n* `slack` - Slack\n* `mattermost` - Mattermost\n* `discord` - Discord\n* `native_push` - Native push" + "description": "* `email` - Email\n* `telegram` - Telegram\n* `slack` - Slack\n* `mattermost` - Mattermost\n* `discord` - Discord\n* `webhook` - Signed webhook\n* `native_push` - Native push" }, "CheckoutUrl": { "type": "object", @@ -44173,6 +46867,12 @@ "pending_payments": { "type": "integer", "default": 0 + }, + "outstanding_by_currency": { + "type": "object", + "additionalProperties": { + "type": "string" + } } }, "required": [ @@ -47229,6 +49929,14 @@ "email" ] }, + "FormatEnum": { + "enum": [ + "csv", + "xlsx" + ], + "type": "string", + "description": "* `csv` - CSV\n* `xlsx` - XLSX" + }, "FrontendDomainStatusEnum": { "enum": [ "pending", @@ -47717,6 +50425,15 @@ } } }, + "IntervalEnum": { + "enum": [ + "monthly", + "yearly", + "custom_days" + ], + "type": "string", + "description": "* `monthly` - Monthly\n* `yearly` - Yearly\n* `custom_days` - Custom number of days" + }, "InventoryAssetAdmin": { "type": "object", "properties": { @@ -48304,6 +51021,118 @@ "invitations" ] }, + "InvitationRequest": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "readOnly": true + }, + "name": { + "type": "string", + "readOnly": true + }, + "email": { + "type": "string", + "readOnly": true + }, + "phone": { + "type": "string", + "readOnly": true + }, + "message": { + "type": "string", + "readOnly": true + }, + "status": { + "allOf": [ + { + "$ref": "#/components/schemas/InvitationRequestStatusEnum" + } + ], + "readOnly": true + }, + "handled_by": { + "type": "integer", + "readOnly": true, + "nullable": true + }, + "handled_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true + }, + "created_at": { + "type": "string", + "format": "date-time", + "readOnly": true + } + }, + "required": [ + "created_at", + "email", + "handled_at", + "handled_by", + "id", + "message", + "name", + "phone", + "status" + ] + }, + "InvitationRequestAck": { + "type": "object", + "properties": { + "detail": { + "type": "string" + } + }, + "required": [ + "detail" + ] + }, + "InvitationRequestCreate": { + "type": "object", + "properties": { + "name": { + "type": "string", + "maxLength": 200 + }, + "email": { + "type": "string", + "format": "email", + "maxLength": 254 + }, + "phone": { + "type": "string", + "default": "", + "maxLength": 32 + }, + "message": { + "type": "string", + "default": "", + "maxLength": 1000 + }, + "website": { + "type": "string", + "writeOnly": true + } + }, + "required": [ + "email", + "name" + ] + }, + "InvitationRequestStatusEnum": { + "enum": [ + "pending", + "invited", + "declined" + ], + "type": "string", + "description": "* `pending` - Pending\n* `invited` - Invited\n* `declined` - Declined" + }, "IssueReject": { "type": "object", "properties": { @@ -48355,17 +51184,18 @@ "evidence_id" ] }, - "KeyCbbEnum": { + "KeyC17Enum": { "enum": [ "email", "telegram", "slack", "mattermost", "discord", + "webhook", "native_push" ], "type": "string", - "description": "* `email` - Email\n* `telegram` - Telegram\n* `slack` - Slack\n* `mattermost` - Mattermost\n* `discord` - Discord\n* `native_push` - Native push" + "description": "* `email` - Email\n* `telegram` - Telegram\n* `slack` - Slack\n* `mattermost` - Mattermost\n* `discord` - Discord\n* `webhook` - Signed webhook\n* `native_push` - Native push" }, "KeyD07Enum": { "enum": [ @@ -48677,6 +51507,15 @@ "measurement" ] }, + "LoanDepositModeEnum": { + "enum": [ + "none", + "fixed", + "per_product" + ], + "type": "string", + "description": "* `none` - No deposit\n* `fixed` - Fixed amount per loan\n* `per_product` - Sum of product deposits" + }, "LocationKindEnum": { "enum": [ "indoor", @@ -50994,6 +53833,32 @@ "effective_mode": { "type": "string", "readOnly": true + }, + "loan_deposit_mode": { + "$ref": "#/components/schemas/LoanDepositModeEnum" + }, + "loan_deposit_amount": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" + }, + "loan_late_fee_per_day": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" + }, + "loan_late_fee_cap": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" + }, + "loan_grace_days": { + "type": "integer", + "maximum": 32767, + "minimum": 0 + }, + "loan_deposit_blocks_issue": { + "type": "boolean" } }, "required": [ @@ -51028,6 +53893,40 @@ "feature" ] }, + "ManualSettlement": { + "type": "object", + "description": "The receipt required to mark a charge paid offline.\n\nMethod and received date are REQUIRED: the point of the ledger is that a settled\ncharge can always say how and when the money arrived, and an optional field would\nquietly reproduce the bare boolean it replaces. `reference` stays optional because\ncash genuinely has none.", + "properties": { + "method": { + "$ref": "#/components/schemas/ManualSettlementMethodEnum" + }, + "reference": { + "type": "string", + "default": "", + "maxLength": 64 + }, + "received_at": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "method", + "received_at" + ] + }, + "ManualSettlementMethodEnum": { + "enum": [ + "cash", + "upi", + "bank_transfer", + "card_machine", + "cheque", + "other" + ], + "type": "string", + "description": "* `cash` - Cash\n* `upi` - UPI\n* `bank_transfer` - Bank transfer\n* `card_machine` - Card machine\n* `cheque` - Cheque\n* `other` - Other" + }, "Measurement883Enum": { "enum": [ "count", @@ -51101,6 +54000,27 @@ }, "accountability": { "$ref": "#/components/schemas/MemberAccountability" + }, + "loan_history": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MemberLoanHistory" + } + }, + "request_history": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MemberRequestHistory" + } + }, + "membership_dues": { + "$ref": "#/components/schemas/MemberDues" + }, + "notices": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MemberNotice" + } } }, "required": [ @@ -51256,6 +54176,295 @@ "token_hint" ] }, + "MemberCard": { + "type": "object", + "description": "Never carries the photo key or a URL that outlives the response.", + "properties": { + "id": { + "type": "integer", + "readOnly": true + }, + "card_number": { + "type": "integer", + "readOnly": true + }, + "printed_name": { + "type": "string", + "readOnly": true + }, + "membership_id": { + "type": "integer", + "readOnly": true, + "nullable": true + }, + "is_active": { + "type": "boolean", + "readOnly": true + }, + "photo_set": { + "type": "boolean", + "readOnly": true + }, + "photo_consent_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true + }, + "qr_active": { + "type": "boolean", + "readOnly": true + }, + "print_count": { + "type": "integer", + "readOnly": true + }, + "last_printed_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true + }, + "issued_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "revoked_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true + }, + "revoked_reason": { + "type": "string", + "readOnly": true + }, + "created_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "updated_at": { + "type": "string", + "format": "date-time", + "readOnly": true + } + }, + "required": [ + "card_number", + "created_at", + "id", + "is_active", + "issued_at", + "last_printed_at", + "membership_id", + "photo_consent_at", + "photo_set", + "print_count", + "printed_name", + "qr_active", + "revoked_at", + "revoked_reason", + "updated_at" + ] + }, + "MemberCardIssue": { + "type": "object", + "properties": { + "printed_name": { + "type": "string", + "maxLength": 200 + } + } + }, + "MemberCardPhotoFinalize": { + "type": "object", + "properties": { + "object_key": { + "type": "string", + "maxLength": 300 + }, + "content_type": { + "type": "string", + "maxLength": 64 + }, + "consent": { + "type": "boolean" + } + }, + "required": [ + "consent", + "content_type", + "object_key" + ] + }, + "MemberCardPhotoPresign": { + "type": "object", + "properties": { + "content_type": { + "type": "string", + "maxLength": 64 + } + }, + "required": [ + "content_type" + ] + }, + "MemberCardPrint": { + "type": "object", + "properties": { + "card_ids": { + "type": "array", + "items": { + "type": "integer" + } + }, + "preset": { + "allOf": [ + { + "$ref": "#/components/schemas/PresetEnum" + } + ], + "default": "sheet" + } + } + }, + "MemberCardReissue": { + "type": "object", + "properties": { + "reason": { + "$ref": "#/components/schemas/MemberCardReissueReasonEnum" + } + }, + "required": [ + "reason" + ] + }, + "MemberCardReissueReasonEnum": { + "enum": [ + "lost", + "stolen", + "damaged", + "renewed" + ], + "type": "string", + "description": "* `lost` - lost\n* `stolen` - stolen\n* `damaged` - damaged\n* `renewed` - renewed" + }, + "MemberCardResolve": { + "type": "object", + "properties": { + "payload": { + "type": "string", + "maxLength": 64 + } + }, + "required": [ + "payload" + ] + }, + "MemberCardResolveResult": { + "type": "object", + "properties": { + "outcome": { + "$ref": "#/components/schemas/MemberCardResolveResultOutcomeEnum" + }, + "card_id": { + "type": "integer" + }, + "card_number": { + "type": "integer" + }, + "printed_name": { + "type": "string" + }, + "membership_id": { + "type": "integer" + }, + "membership_status": { + "type": "string" + }, + "photo_url": { + "type": "string", + "nullable": true + } + }, + "required": [ + "outcome" + ] + }, + "MemberCardResolveResultOutcomeEnum": { + "enum": [ + "ok", + "revoked", + "inactive" + ], + "type": "string", + "description": "* `ok` - ok\n* `revoked` - revoked\n* `inactive` - inactive" + }, + "MemberCardRevoke": { + "type": "object", + "properties": { + "reason": { + "type": "string", + "maxLength": 32 + } + } + }, + "MemberCardTemplate": { + "type": "object", + "properties": { + "version": { + "type": "integer" + }, + "page": { + "type": "string" + }, + "orientation": { + "type": "string" + }, + "card_width_mm": { + "type": "number", + "format": "double" + }, + "card_height_mm": { + "type": "number", + "format": "double" + }, + "margin_mm": { + "type": "number", + "format": "double" + }, + "gap_mm": { + "type": "number", + "format": "double" + }, + "front_fields": { + "type": "array", + "items": { + "type": "string" + } + }, + "back_text": { + "type": "string" + }, + "include_photo": { + "type": "boolean" + }, + "include_qr": { + "type": "boolean" + }, + "name_font_size_pt": { + "type": "integer" + }, + "font_size_pt": { + "type": "integer" + }, + "crop_marks": { + "type": "boolean" + } + } + }, "MemberClaimCode": { "type": "object", "properties": { @@ -51396,6 +54605,25 @@ "status" ] }, + "MemberDues": { + "type": "object", + "properties": { + "dues_amount": { + "type": "string" + }, + "outstanding_by_currency": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "nullable": true + } + }, + "required": [ + "dues_amount", + "outstanding_by_currency" + ] + }, "MemberEventRegistrationActivity": { "type": "object", "properties": { @@ -51477,6 +54705,38 @@ "overdue" ] }, + "MemberLoanHistory": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "checked_out_at": { + "type": "string", + "format": "date-time" + }, + "returned_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "due_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "returned_late": { + "type": "boolean" + } + }, + "required": [ + "checked_out_at", + "due_at", + "label", + "returned_at", + "returned_late" + ] + }, "MemberMachineServiceActivity": { "type": "object", "properties": { @@ -51505,6 +54765,30 @@ "title" ] }, + "MemberNotice": { + "type": "object", + "description": "A fact about the reader, derived from their own rows.\n\nNot `notifications.Notification`: that table is makerspace-wide, has no recipient\nand shares one `read_at`, so serving it would leak staff alerts.", + "properties": { + "level": { + "type": "string" + }, + "event": { + "type": "string" + }, + "title": { + "type": "string" + }, + "body": { + "type": "string" + } + }, + "required": [ + "body", + "event", + "level", + "title" + ] + }, "MemberPayment": { "type": "object", "properties": { @@ -51522,10 +54806,34 @@ "status": { "$ref": "#/components/schemas/Status66aEnum" }, + "amount": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" + }, + "currency": { + "type": "string", + "pattern": "^[a-z]{3}$", + "maxLength": 3 + }, "checkout_url": { "type": "string", "readOnly": true }, + "online_payment_available": { + "type": "boolean", + "description": "Whether offering to pay online is honest for this charge.\n\nResolved once per makerspace by the view and passed in: doing it per row would\nre-read settings and credentials for every charge in the list. Falls back to a\nlive check only when a caller has not supplied it.", + "readOnly": true + }, + "settlement": { + "allOf": [ + { + "$ref": "#/components/schemas/MemberSettlement" + } + ], + "nullable": true, + "readOnly": true + }, "created_at": { "type": "string", "format": "date-time", @@ -51533,9 +54841,13 @@ } }, "required": [ + "amount", "checkout_url", "created_at", + "currency", "id", + "online_payment_available", + "settlement", "subject_label", "subject_type" ] @@ -51633,6 +54945,59 @@ "title" ] }, + "MemberRequestHistory": { + "type": "object", + "properties": { + "status": { + "type": "string" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "item_count": { + "type": "integer" + }, + "returned_quantity": { + "type": "integer" + }, + "damaged_quantity": { + "type": "integer" + }, + "missing_quantity": { + "type": "integer" + } + }, + "required": [ + "created_at", + "damaged_quantity", + "item_count", + "missing_quantity", + "returned_quantity", + "status" + ] + }, + "MemberSettlement": { + "type": "object", + "description": "The receipt for a charge staff settled in person, shown back to the payer.", + "properties": { + "method": { + "type": "string" + }, + "received_at": { + "type": "string", + "format": "date-time" + }, + "reference": { + "type": "string" + } + }, + "required": [ + "method", + "received_at", + "reference" + ] + }, "MemberSignUp": { "type": "object", "properties": { @@ -51784,6 +55149,14 @@ "readOnly": true, "nullable": true }, + "status": { + "allOf": [ + { + "$ref": "#/components/schemas/Status37fEnum" + } + ], + "readOnly": true + }, "created_at": { "type": "string", "format": "date-time", @@ -51807,6 +55180,7 @@ "makerspace_slug", "payment", "role", + "status", "user" ] }, @@ -51839,6 +55213,57 @@ "type": "string", "description": "* `joined` - joined\n* `requested` - requested" }, + "MembershipPlan": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "readOnly": true + }, + "name": { + "type": "string", + "maxLength": 120 + }, + "interval": { + "$ref": "#/components/schemas/IntervalEnum" + }, + "custom_days": { + "type": "integer", + "maximum": 2147483647, + "minimum": 0, + "nullable": true + }, + "amount": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" + }, + "currency": { + "type": "string", + "maxLength": 3 + }, + "is_active": { + "type": "boolean" + }, + "created_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "updated_at": { + "type": "string", + "format": "date-time", + "readOnly": true + } + }, + "required": [ + "created_at", + "id", + "interval", + "name", + "updated_at" + ] + }, "MembershipPolicyEnum": { "enum": [ "request", @@ -52014,12 +55439,96 @@ "slug" ] }, - "MethodEnum": { + "MembershipTerm": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "readOnly": true + }, + "membership": { + "type": "integer", + "readOnly": true + }, + "plan": { + "type": "integer", + "readOnly": true + }, + "plan_name": { + "type": "string", + "readOnly": true + }, + "starts_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "ends_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "status": { + "allOf": [ + { + "$ref": "#/components/schemas/MembershipTermStatusEnum" + } + ], + "readOnly": true + }, + "renewal_payment": { + "type": "integer", + "readOnly": true, + "nullable": true + }, + "created_by": { + "type": "integer", + "readOnly": true, + "nullable": true + }, + "created_at": { + "type": "string", + "format": "date-time", + "readOnly": true + } + }, + "required": [ + "created_at", + "created_by", + "ends_at", + "id", + "membership", + "plan", + "plan_name", + "renewal_payment", + "starts_at", + "status" + ] + }, + "MembershipTermCreate": { + "type": "object", + "properties": { + "plan_id": { + "type": "integer" + }, + "starts_at": { + "type": "string", + "format": "date-time", + "nullable": true + } + }, + "required": [ + "plan_id" + ] + }, + "MembershipTermStatusEnum": { "enum": [ - "PUT" + "active", + "expired", + "cancelled" ], "type": "string", - "description": "* `PUT` - PUT" + "description": "* `active` - Active\n* `expired` - Expired\n* `cancelled` - Cancelled" }, "MigrationExportCreate": { "type": "object", @@ -52537,7 +56046,7 @@ "type": "object", "properties": { "key": { - "$ref": "#/components/schemas/KeyCbbEnum" + "$ref": "#/components/schemas/KeyC17Enum" }, "label": { "type": "string" @@ -52558,7 +56067,7 @@ "channel": { "allOf": [ { - "$ref": "#/components/schemas/Channel7a7Enum" + "$ref": "#/components/schemas/Channel59dEnum" } ], "readOnly": true @@ -52579,6 +56088,10 @@ "type": "string", "readOnly": true }, + "signing_secret_set": { + "type": "string", + "readOnly": true + }, "scope": { "type": "string", "readOnly": true @@ -52602,6 +56115,7 @@ "is_active", "label", "scope", + "signing_secret_set", "telegram_chat_id", "updated_at" ] @@ -52610,7 +56124,7 @@ "type": "object", "properties": { "channel": { - "$ref": "#/components/schemas/Channel7a7Enum" + "$ref": "#/components/schemas/Channel59dEnum" }, "label": { "type": "string", @@ -52625,6 +56139,20 @@ "type": "string", "maxLength": 64 }, + "signing_secret": { + "writeOnly": true, + "oneOf": [ + { + "type": "string", + "maxLength": 512, + "minLength": 16 + }, + { + "type": "string", + "maxLength": 0 + } + ] + }, "is_active": { "type": "boolean", "default": true @@ -52678,7 +56206,7 @@ "$ref": "#/components/schemas/FeatureEnum" }, "channel": { - "$ref": "#/components/schemas/ChannelCbbEnum" + "$ref": "#/components/schemas/ChannelC17Enum" }, "enabled": { "type": "boolean" @@ -52709,7 +56237,7 @@ "$ref": "#/components/schemas/FeatureEnum" }, "channel": { - "$ref": "#/components/schemas/ChannelCbbEnum" + "$ref": "#/components/schemas/ChannelC17Enum" }, "enabled": { "type": "boolean" @@ -54370,6 +57898,37 @@ } } }, + "PaginatedReportScheduleList": { + "type": "object", + "required": [ + "count", + "results" + ], + "properties": { + "count": { + "type": "integer", + "example": 123 + }, + "next": { + "type": "string", + "nullable": true, + "format": "uri", + "example": "http://api.example.org/accounts/?page=4" + }, + "previous": { + "type": "string", + "nullable": true, + "format": "uri", + "example": "http://api.example.org/accounts/?page=2" + }, + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ReportSchedule" + } + } + } + }, "PaginatedStaffMembershipList": { "type": "object", "required": [ @@ -55045,6 +58604,52 @@ } } }, + "PatchedCertificationTypeUpdate": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "readOnly": true + }, + "machine_type": { + "type": "integer", + "readOnly": true + }, + "name": { + "type": "string", + "maxLength": 200 + }, + "description": { + "type": "string" + }, + "validity_days": { + "type": "integer", + "maximum": 2147483647, + "minimum": 0, + "nullable": true + }, + "is_required_for_service": { + "type": "boolean" + }, + "is_required_for_booking": { + "type": "boolean" + }, + "is_active": { + "type": "boolean", + "readOnly": true + }, + "created_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "updated_at": { + "type": "string", + "format": "date-time", + "readOnly": true + } + } + }, "PatchedEmailTemplateUpdate": { "type": "object", "properties": { @@ -55868,6 +59473,41 @@ "effective_mode": { "type": "string", "readOnly": true + }, + "loan_deposit_mode": { + "$ref": "#/components/schemas/LoanDepositModeEnum" + }, + "loan_deposit_amount": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" + }, + "loan_late_fee_per_day": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" + }, + "loan_late_fee_cap": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" + }, + "loan_grace_days": { + "type": "integer", + "maximum": 32767, + "minimum": 0 + }, + "loan_deposit_blocks_issue": { + "type": "boolean" + } + } + }, + "PatchedMemberCardOwnUpdate": { + "type": "object", + "properties": { + "printed_name": { + "type": "string", + "maxLength": 200 } } }, @@ -55882,6 +59522,50 @@ } } }, + "PatchedMembershipPlan": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "readOnly": true + }, + "name": { + "type": "string", + "maxLength": 120 + }, + "interval": { + "$ref": "#/components/schemas/IntervalEnum" + }, + "custom_days": { + "type": "integer", + "maximum": 2147483647, + "minimum": 0, + "nullable": true + }, + "amount": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" + }, + "currency": { + "type": "string", + "maxLength": 3 + }, + "is_active": { + "type": "boolean" + }, + "created_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "updated_at": { + "type": "string", + "format": "date-time", + "readOnly": true + } + } + }, "PatchedMembershipRoleAssign": { "type": "object", "properties": { @@ -56203,6 +59887,82 @@ } } }, + "PatchedReportSchedule": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "readOnly": true + }, + "makerspace": { + "type": "integer", + "readOnly": true + }, + "report_key": { + "$ref": "#/components/schemas/ReportKeyEnum" + }, + "filters": {}, + "grain": { + "type": "string", + "maxLength": 8 + }, + "format": { + "$ref": "#/components/schemas/FormatEnum" + }, + "cadence": { + "$ref": "#/components/schemas/CadenceEnum" + }, + "next_run_at": { + "type": "string", + "format": "date-time" + }, + "last_run_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true + }, + "is_active": { + "type": "boolean" + }, + "destination": { + "type": "integer", + "nullable": true + }, + "recipient_emails": { + "type": "array", + "items": { + "type": "string", + "format": "email" + }, + "maxItems": 10 + }, + "created_by": { + "type": "integer", + "readOnly": true, + "nullable": true + }, + "created_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "updated_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "last_delivery": { + "allOf": [ + { + "$ref": "#/components/schemas/ReportDelivery" + } + ], + "nullable": true, + "readOnly": true + } + } + }, "PatchedReturnPolicy": { "type": "object", "properties": { @@ -56398,6 +60158,36 @@ "ids" ] }, + "PaymentBulkOffline": { + "type": "object", + "properties": { + "ids": { + "type": "array", + "items": { + "type": "integer", + "minimum": 1 + } + }, + "settlement": { + "$ref": "#/components/schemas/ManualSettlement" + } + }, + "required": [ + "ids", + "settlement" + ] + }, + "PaymentOffline": { + "type": "object", + "properties": { + "settlement": { + "$ref": "#/components/schemas/ManualSettlement" + } + }, + "required": [ + "settlement" + ] + }, "PaymentReconciliation": { "type": "object", "properties": { @@ -56439,6 +60229,18 @@ "type": "string", "readOnly": true }, + "refunded_amount": { + "type": "number", + "format": "double", + "readOnly": true + }, + "refunds": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Refund" + }, + "readOnly": true + }, "created_at": { "type": "string", "format": "date-time", @@ -56455,6 +60257,8 @@ "created_at", "currency", "id", + "refunded_amount", + "refunds", "status", "subject_id", "subject_label", @@ -56522,6 +60326,24 @@ "subject_type" ] }, + "PaymentRefundRequest": { + "type": "object", + "properties": { + "amount": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" + }, + "reason": { + "type": "string", + "default": "", + "maxLength": 255 + } + }, + "required": [ + "amount" + ] + }, "PaymentSettingsError": { "type": "object", "properties": { @@ -57018,6 +60840,14 @@ "duration_minutes" ] }, + "PresetEnum": { + "enum": [ + "sheet", + "single" + ], + "type": "string", + "description": "* `sheet` - sheet\n* `single` - single" + }, "PrinterPool": { "type": "object", "properties": { @@ -57436,6 +61266,15 @@ "show_attended_events": { "type": "boolean" }, + "show_certifications": { + "type": "boolean" + }, + "certifications": { + "type": "array", + "items": { + "type": "string" + } + }, "headline": { "type": "string" }, @@ -57473,6 +61312,7 @@ "activity", "avatar_url", "bio", + "certifications", "display_name", "education", "github_contributions", @@ -57484,7 +61324,8 @@ "languages", "membership_id", "projects", - "show_attended_events" + "show_attended_events", + "show_certifications" ] }, "ProfileWrite": { @@ -57496,6 +61337,9 @@ "show_attended_events": { "type": "boolean" }, + "show_certifications": { + "type": "boolean" + }, "headline": { "type": "string", "maxLength": 200 @@ -59562,10 +63406,11 @@ "enum": [ "box", "product", - "asset" + "asset", + "member_card" ], "type": "string", - "description": "* `box` - Box\n* `product` - Product\n* `asset` - Asset" + "description": "* `box` - Box\n* `product` - Product\n* `asset` - Asset\n* `member_card` - Member card" }, "QrHistoryItem": { "type": "object", @@ -60000,13 +63845,6 @@ "status" ] }, - "ReasonEnum": { - "enum": [ - "staff_revoked" - ], - "type": "string", - "description": "* `staff_revoked` - staff_revoked" - }, "ReceiptEnvelope": { "type": "object", "properties": { @@ -60483,6 +64321,66 @@ "access" ] }, + "Refund": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "readOnly": true + }, + "amount": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", + "readOnly": true + }, + "currency": { + "type": "string", + "readOnly": true + }, + "status": { + "allOf": [ + { + "$ref": "#/components/schemas/RefundStatusEnum" + } + ], + "readOnly": true + }, + "reason": { + "type": "string", + "readOnly": true + }, + "created_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "settled_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true + } + }, + "required": [ + "amount", + "created_at", + "currency", + "id", + "reason", + "settled_at", + "status" + ] + }, + "RefundStatusEnum": { + "enum": [ + "pending", + "succeeded", + "failed" + ], + "type": "string", + "description": "* `pending` - Pending\n* `succeeded` - Succeeded\n* `failed` - Failed" + }, "RejectRequest": { "type": "object", "properties": { @@ -60570,6 +64468,59 @@ "unavailable_reason" ] }, + "ReportDelivery": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "readOnly": true + }, + "status": { + "allOf": [ + { + "$ref": "#/components/schemas/ReportDeliveryStatusEnum" + } + ], + "readOnly": true + }, + "error": { + "type": "string", + "readOnly": true + }, + "created_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "expires_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true + }, + "download_url": { + "type": "string", + "format": "uri", + "readOnly": true + } + }, + "required": [ + "created_at", + "download_url", + "error", + "expires_at", + "id", + "status" + ] + }, + "ReportDeliveryStatusEnum": { + "enum": [ + "sent", + "failed" + ], + "type": "string", + "description": "* `sent` - Sent\n* `failed` - Failed" + }, "ReportError": { "type": "object", "properties": { @@ -60584,6 +64535,126 @@ "detail" ] }, + "ReportKeyEnum": { + "enum": [ + "summary", + "taken-items", + "active-loans", + "returns", + "damaged-missing", + "damaged-lost", + "qr-scans", + "most-lent", + "top-borrowers", + "recently-added", + "machine-usage", + "event-attendance", + "booking-utilization", + "maintenance-activity", + "member-activity", + "machine-service", + "printer-service", + "fablab-health", + "payment-reconciliation", + "loan-throughput", + "inventory-control", + "evidence-compliance", + "import-quality", + "procurement-performance", + "communications-health", + "community-engagement", + "certification-coverage", + "module-operational-health" + ], + "type": "string", + "description": "* `summary` - summary\n* `taken-items` - taken-items\n* `active-loans` - active-loans\n* `returns` - returns\n* `damaged-missing` - damaged-missing\n* `damaged-lost` - damaged-lost\n* `qr-scans` - qr-scans\n* `most-lent` - most-lent\n* `top-borrowers` - top-borrowers\n* `recently-added` - recently-added\n* `machine-usage` - machine-usage\n* `event-attendance` - event-attendance\n* `booking-utilization` - booking-utilization\n* `maintenance-activity` - maintenance-activity\n* `member-activity` - member-activity\n* `machine-service` - machine-service\n* `printer-service` - printer-service\n* `fablab-health` - fablab-health\n* `payment-reconciliation` - payment-reconciliation\n* `loan-throughput` - loan-throughput\n* `inventory-control` - inventory-control\n* `evidence-compliance` - evidence-compliance\n* `import-quality` - import-quality\n* `procurement-performance` - procurement-performance\n* `communications-health` - communications-health\n* `community-engagement` - community-engagement\n* `certification-coverage` - certification-coverage\n* `module-operational-health` - module-operational-health" + }, + "ReportSchedule": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "readOnly": true + }, + "makerspace": { + "type": "integer", + "readOnly": true + }, + "report_key": { + "$ref": "#/components/schemas/ReportKeyEnum" + }, + "filters": {}, + "grain": { + "type": "string", + "maxLength": 8 + }, + "format": { + "$ref": "#/components/schemas/FormatEnum" + }, + "cadence": { + "$ref": "#/components/schemas/CadenceEnum" + }, + "next_run_at": { + "type": "string", + "format": "date-time" + }, + "last_run_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true + }, + "is_active": { + "type": "boolean" + }, + "destination": { + "type": "integer", + "nullable": true + }, + "recipient_emails": { + "type": "array", + "items": { + "type": "string", + "format": "email" + }, + "maxItems": 10 + }, + "created_by": { + "type": "integer", + "readOnly": true, + "nullable": true + }, + "created_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "updated_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "last_delivery": { + "allOf": [ + { + "$ref": "#/components/schemas/ReportDelivery" + } + ], + "nullable": true, + "readOnly": true + } + }, + "required": [ + "created_at", + "created_by", + "id", + "last_delivery", + "last_run_at", + "makerspace", + "report_key", + "updated_at" + ] + }, "RequestAccessEnum": { "enum": [ "anyone" @@ -62060,7 +66131,7 @@ "format": "uri" }, "method": { - "$ref": "#/components/schemas/MethodEnum" + "$ref": "#/components/schemas/SpaceImageUploadMethodEnum" }, "fields": { "type": "object", @@ -62075,6 +66146,13 @@ "url" ] }, + "SpaceImageUploadMethodEnum": { + "enum": [ + "PUT" + ], + "type": "string", + "description": "* `PUT` - PUT" + }, "StaffMembership": { "type": "object", "properties": { @@ -62126,15 +66204,6 @@ "status": { "$ref": "#/components/schemas/Status66aEnum" }, - "checkout_url": { - "type": "string", - "readOnly": true - }, - "created_at": { - "type": "string", - "format": "date-time", - "readOnly": true - }, "amount": { "type": "string", "format": "decimal", @@ -62144,6 +66213,29 @@ "type": "string", "pattern": "^[a-z]{3}$", "maxLength": 3 + }, + "checkout_url": { + "type": "string", + "readOnly": true + }, + "online_payment_available": { + "type": "boolean", + "description": "Whether offering to pay online is honest for this charge.\n\nResolved once per makerspace by the view and passed in: doing it per row would\nre-read settings and credentials for every charge in the list. Falls back to a\nlive check only when a caller has not supplied it.", + "readOnly": true + }, + "settlement": { + "allOf": [ + { + "$ref": "#/components/schemas/MemberSettlement" + } + ], + "nullable": true, + "readOnly": true + }, + "created_at": { + "type": "string", + "format": "date-time", + "readOnly": true } }, "required": [ @@ -62152,6 +66244,8 @@ "created_at", "currency", "id", + "online_payment_available", + "settlement", "subject_label", "subject_type" ] @@ -62863,10 +66957,13 @@ "machine_service_request", "booking", "event_registration", - "makerspace_membership" + "makerspace_membership", + "membership_term", + "loan_deposit", + "loan_late_fee" ], "type": "string", - "description": "* `machine_service_request` - Machine service request\n* `booking` - Booking\n* `event_registration` - Event registration\n* `makerspace_membership` - Makerspace membership" + "description": "* `machine_service_request` - Machine service request\n* `booking` - Booking\n* `event_registration` - Event registration\n* `makerspace_membership` - Makerspace membership\n* `membership_term` - Membership renewal\n* `loan_deposit` - Loan deposit\n* `loan_late_fee` - Loan late fee" }, "Surface356Enum": { "enum": [ @@ -62966,6 +67063,9 @@ "type": "string" } }, + "edition": { + "type": "string" + }, "features": { "type": "array", "items": { @@ -62989,6 +67089,7 @@ }, "required": [ "branding", + "edition", "email_enabled", "features", "frontend", diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 48762edc..5f4ffef0 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -15,12 +15,16 @@ "zxing-wasm": "^3.1.0" }, "devDependencies": { + "@axe-core/playwright": "^4.13.0", + "@playwright/test": "^1.62.1", "@tailwindcss/postcss": "^4.3.2", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", + "@types/node": "^22.20.1", "@types/react": "^19.2.0", "@types/react-dom": "^19.2.0", "@vitejs/plugin-react": "^6.0.3", + "axe-core": "^4.13.0", "jsdom": "^29.1.1", "postcss": "^8.5.16", "tailwindcss": "^4.3.2", @@ -98,6 +102,19 @@ "dev": true, "license": "MIT" }, + "node_modules/@axe-core/playwright": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.13.0.tgz", + "integrity": "sha512-6YLx+kxXu5GJceG4ozFg+33a2EMTdjYwWGloJ3sb9Kta5pp+ZNS53uxGVog5JetIY8s++P5UrtX+cri+u0VAVg==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "axe-core": "~4.13.0" + }, + "peerDependencies": { + "playwright-core": ">= 1.0.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -409,6 +426,22 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@playwright/test": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", + "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz", @@ -1174,6 +1207,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, "node_modules/@types/react": { "version": "19.2.17", "dev": true, @@ -1379,6 +1422,16 @@ "node": ">=12" } }, + "node_modules/axe-core": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.13.0.tgz", + "integrity": "sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, "node_modules/bidi-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", @@ -2054,6 +2107,53 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.16", "dev": true, @@ -2476,6 +2576,13 @@ "node": ">=20.18.1" } }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, "node_modules/vite": { "version": "8.1.3", "dev": true, diff --git a/frontend/package.json b/frontend/package.json index 08652163..6838fd8d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,7 +7,9 @@ "generate:api": "node scripts/generate-api-client.mjs", "test": "vitest run", "build": "tsc -b && vite build", - "preview": "vite preview" + "preview": "vite preview", + "test:e2e": "playwright test", + "typecheck:e2e": "tsc -p e2e/tsconfig.json" }, "dependencies": { "@tanstack/react-query": "^5.101.2", @@ -18,12 +20,16 @@ "zxing-wasm": "^3.1.0" }, "devDependencies": { + "@axe-core/playwright": "^4.13.0", + "@playwright/test": "^1.62.1", "@tailwindcss/postcss": "^4.3.2", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", + "@types/node": "^22.20.1", "@types/react": "^19.2.0", "@types/react-dom": "^19.2.0", "@vitejs/plugin-react": "^6.0.3", + "axe-core": "^4.13.0", "jsdom": "^29.1.1", "postcss": "^8.5.16", "tailwindcss": "^4.3.2", diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 00000000..c2c5800e --- /dev/null +++ b/frontend/playwright.config.ts @@ -0,0 +1,23 @@ +import { defineConfig, devices } from "@playwright/test"; + +// End-to-end suite. It drives a REAL stack (Django + Postgres + MinIO + the frontend), never +// mocks: the point is to pin the Hard Rules where they are enforced. Point it at a running +// stack with E2E_BASE_URL / E2E_API_URL; `manage.py seed_e2e --reset` must have run first. +// Locally: scripts/e2e-local.sh; in CI: the `e2e` job in .github/workflows/tests.yml. +declare const process: { env: Record }; + +export default defineConfig({ + testDir: "./e2e", + fullyParallel: false, + workers: 1, + retries: process.env.CI ? 1 : 0, + timeout: 60_000, + expect: { timeout: 10_000 }, + reporter: process.env.CI ? [["html", { open: "never" }], ["list"]] : "list", + use: { + baseURL: process.env.E2E_BASE_URL ?? "http://localhost:5100", + trace: "retain-on-failure", + video: "off", + }, + projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }], +}); diff --git a/frontend/src/AppRoutes.tsx b/frontend/src/AppRoutes.tsx index 6888760e..4b93aa40 100644 --- a/frontend/src/AppRoutes.tsx +++ b/frontend/src/AppRoutes.tsx @@ -1,76 +1,104 @@ -import type { ReactNode } from "react"; -import { Route, Routes, useLocation } from "react-router-dom"; +import { lazy, Suspense, type ReactNode } from "react"; +import { Route, Routes, useLocation, useParams } from "react-router-dom"; -import { AboutPage } from "./features/AboutPage"; -import { PublicBookingsPage } from "./features/bookings/PublicBookingsPage"; -import { PublicEventFeedbackPage } from "./features/inventory/PublicEventFeedbackPage"; -import { PublicEventsPage } from "./features/inventory/PublicEventsPage"; import { PublicInventoryPage } from "./features/inventory/PublicInventoryPage"; -import { PublicMachinesPage } from "./features/inventory/PublicMachinesPage"; -import { PublicSelfCheckoutPage } from "./features/inventory/PublicSelfCheckoutPage"; -import { ArchivedPayments } from "./features/members/ArchivedPayments"; -import { MemberArea } from "./features/members/MemberArea"; -import { PublicPrintRequestPage } from "./features/printing/PublicPrintRequestPage"; -import { PublicOrganizationPage } from "./features/organizations/PublicOrganizationPage"; -import { OrganizationInvitationRedeemPage } from "./features/organizations/OrganizationInvitationRedeemPage"; +import { useTenantBootstrap } from "./features/inventory/usePublicInventory"; import { KioskPage, ScannerPage, SuperadminPage } from "./features/staff/PlatformApps"; -import { ResetPasswordPage } from "./features/staff/ResetPasswordPage"; import { StaffApp } from "./features/staff/StaffApp"; -import { EventCheckInStationPage } from "./features/events/EventCheckInStationPage"; -import { PublicStatsPage } from "./features/stats/PublicStatsPage"; +import { useTenant } from "./lib/tenant"; + +// Every public surface below is its own chunk. The catalogue and the staff shell stay eager +// because they are the two pages almost every visit lands on; everything else loads when its +// route is first visited, so a lending-only install never downloads events, bookings or +// machines code. The staff panels inside StaffApp are already lazy (StaffTabContent.tsx). +const AboutPage = lazy(() => import("./features/AboutPage").then((m) => ({ default: m.AboutPage }))); +const PublicBookingsPage = lazy(() => import("./features/bookings/PublicBookingsPage").then((m) => ({ default: m.PublicBookingsPage }))); +const PublicEventFeedbackPage = lazy(() => import("./features/inventory/PublicEventFeedbackPage").then((m) => ({ default: m.PublicEventFeedbackPage }))); +const PublicEventsPage = lazy(() => import("./features/inventory/PublicEventsPage").then((m) => ({ default: m.PublicEventsPage }))); +const PublicMachinesPage = lazy(() => import("./features/inventory/PublicMachinesPage").then((m) => ({ default: m.PublicMachinesPage }))); +const PublicSelfCheckoutPage = lazy(() => import("./features/inventory/PublicSelfCheckoutPage").then((m) => ({ default: m.PublicSelfCheckoutPage }))); +const ArchivedPayments = lazy(() => import("./features/members/ArchivedPayments").then((m) => ({ default: m.ArchivedPayments }))); +const MemberArea = lazy(() => import("./features/members/MemberArea").then((m) => ({ default: m.MemberArea }))); +const PublicPrintRequestPage = lazy(() => import("./features/printing/PublicPrintRequestPage").then((m) => ({ default: m.PublicPrintRequestPage }))); +const PublicOrganizationPage = lazy(() => import("./features/organizations/PublicOrganizationPage").then((m) => ({ default: m.PublicOrganizationPage }))); +const OrganizationInvitationRedeemPage = lazy(() => import("./features/organizations/OrganizationInvitationRedeemPage").then((m) => ({ default: m.OrganizationInvitationRedeemPage }))); +const ResetPasswordPage = lazy(() => import("./features/staff/ResetPasswordPage").then((m) => ({ default: m.ResetPasswordPage }))); +const EventCheckInStationPage = lazy(() => import("./features/events/EventCheckInStationPage").then((m) => ({ default: m.EventCheckInStationPage }))); +const PublicStatsPage = lazy(() => import("./features/stats/PublicStatsPage").then((m) => ({ default: m.PublicStatsPage }))); function NotFoundPage() { return

404

Page not found

; } +// The public home of a makerspace depends on the deployment edition (phase 4): an +// events-only or bookings-only box lands on that programme, not on a loan catalogue it +// does not run. The backend already strips edition-hidden module keys from `modules`. +function EditionHome() { + const tenant = useTenant(); + const { slug = "" } = useParams(); + const bootstrapQuery = useTenantBootstrap(slug, tenant.mode === "central" && Boolean(slug)); + const bootstrap = tenant.mode === "single" ? tenant.bootstrap : bootstrapQuery.data; + const edition = bootstrap?.edition ?? "makerspace"; + if (edition === "events") return ; + if (edition === "bookings") return ; + return ; +} + +function RouteFallback() { + return

Loading...

; +} + export function AppRoutes({ mode, landing }: { mode: "single" | "central"; landing: ReactNode }) { const location = useLocation(); + let routes: ReactNode; if (location.pathname === "/member/archived") { - return } />; - } - if (location.pathname.startsWith("/organization-invitations/redeem/")) { - return } />; + routes = } />; + } else if (location.pathname.startsWith("/organization-invitations/redeem/")) { + routes = } />; + } else if (mode === "single") { + routes = + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + ; + } else { + routes = + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + ; } - if (mode === "single") return - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - ; - return - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - ; + return }>{routes}; } diff --git a/frontend/src/features/inventory/PublicInventoryPage.tsx b/frontend/src/features/inventory/PublicInventoryPage.tsx index 3ad148c9..d5e4dfa1 100644 --- a/frontend/src/features/inventory/PublicInventoryPage.tsx +++ b/frontend/src/features/inventory/PublicInventoryPage.tsx @@ -1,15 +1,10 @@ -import { useMemo, useState } from "react"; +import { useState } from "react"; import type { FormEvent } from "react"; -import { Link, useParams } from "react-router-dom"; +import { useParams } from "react-router-dom"; -import { MakerspaceBrand } from "../../components/MakerspaceBrand"; -import { MakerspaceMapLink } from "../../components/MakerspaceMapLink"; -import { SpaceWorksBadge } from "../../components/SpaceWorksLogo"; -import { ThemeToggle } from "../../components/ThemeToggle"; -import { ChartIcon, UserIcon } from "../../components/icons"; -import { Card, Field, IconLink } from "../../components/ui"; +import { Card, Field } from "../../components/ui"; import { useTenant, useTenantPath } from "../../lib/tenant"; -import type { Product, RequestCartItem } from "../../types/inventory"; +import type { Product } from "../../types/inventory"; import { ProductCard } from "./ProductCard"; import { ProductQuickViewModal } from "./ProductQuickViewModal"; import { @@ -22,6 +17,8 @@ import { } from "./PublicInventoryParts"; import { PublicRequestPanel } from "./PublicRequestPanel"; import { SkipLink } from "../../components/SkipLink"; +import { PublicInventoryHeader } from "./publicInventory/PublicInventoryHeader"; +import { useInventoryCart } from "./publicInventory/useInventoryCart"; import { usePublicCategories, usePublicInventory, useTenantBootstrap } from "./usePublicInventory"; const PAGE_SIZE = 24; @@ -35,7 +32,7 @@ export function PublicInventoryPage() { const [searchInput, setSearchInput] = useState(""); const [query, setQuery] = useState(""); const [view, setView] = useState({ kind: "all" }); - const [cart, setCart] = useState>({}); + const { cart, selectedItems, incrementItem, decrementItem, clearCart } = useInventoryCart(); const [selectedProduct, setSelectedProduct] = useState(null); const categoryParam = view.kind === "category" ? view.slug : ""; const sortParam = view.kind === "sort" ? view.sort : "name"; @@ -62,56 +59,6 @@ export function PublicInventoryPage() { 1, Math.ceil((inventoryQuery.data?.count ?? 0) / PAGE_SIZE), ); - const selectedItems = useMemo(() => Object.values(cart), [cart]); - - function maxQuantity(product: Product): number { - if ( - product.availability?.mode === "exact_count" && - typeof product.availability.count === "number" - ) { - return product.availability.count; - } - - return 99; - } - - function incrementItem(product: Product) { - if (product.availability?.label === "Unavailable") { - return; - } - - setCart((current) => { - const existing = current[product.id]; - const quantity = Math.min((existing?.quantity ?? 0) + 1, maxQuantity(product)); - return { - ...current, - [product.id]: { - productId: product.id, - name: product.name, - quantity, - }, - }; - }); - } - - function decrementItem(product: Product) { - setCart((current) => { - const existing = current[product.id]; - if (!existing || existing.quantity <= 1) { - const next = { ...current }; - delete next[product.id]; - return next; - } - - return { - ...current, - [product.id]: { - ...existing, - quantity: existing.quantity - 1, - }, - }; - }); - } function submitSearch(event: FormEvent) { event.preventDefault(); @@ -127,70 +74,13 @@ export function PublicInventoryPage() { return (
-
-
-

- Public Inventory -

-
-
-

- -

-

- Shared tools and equipment published by this makerspace. -

- -
-
-
- {bootstrap?.makerspace.public_stats_enabled ? ( - - - - ) : null} - - - - -
-
- -
- {inventoryQuery.data?.count ?? "-"} listed items -
- {modules.has("printing") ? ( - - Request a 3D print - - ) : null} - {modules.has("events") ? ( - - Events - - ) : null} - {modules.has("machines") ? ( - - Machines - - ) : null} - {modules.has("bookings") ? ( - - Book a space - - ) : null} -
-
-
-
-
+
setCart({})} + onClear={clearCart} disabled={!requestEnabled} requestAccess={bootstrap?.makerspace.request_access} /> diff --git a/frontend/src/features/inventory/publicInventory/PublicInventoryHeader.tsx b/frontend/src/features/inventory/publicInventory/PublicInventoryHeader.tsx new file mode 100644 index 00000000..48e342d8 --- /dev/null +++ b/frontend/src/features/inventory/publicInventory/PublicInventoryHeader.tsx @@ -0,0 +1,92 @@ +import { Link } from "react-router-dom"; + +import { MakerspaceBrand } from "../../../components/MakerspaceBrand"; +import { MakerspaceMapLink } from "../../../components/MakerspaceMapLink"; +import { SpaceWorksBadge } from "../../../components/SpaceWorksLogo"; +import { ThemeToggle } from "../../../components/ThemeToggle"; +import { ChartIcon, UserIcon } from "../../../components/icons"; +import { IconLink } from "../../../components/ui"; +import type { TenantBootstrap } from "../../../lib/api"; + +type PublicInventoryHeaderProps = { + displayName: string; + makerspace: TenantBootstrap["makerspace"] | undefined; + modules: Set; + tenantPath: (subpath?: string) => string; + listedCount: number | undefined; +}; + +export function PublicInventoryHeader({ + displayName, + makerspace, + modules, + tenantPath, + listedCount, +}: PublicInventoryHeaderProps) { + return ( +
+
+

+ Public Inventory +

+
+
+

+ +

+

+ Shared tools and equipment published by this makerspace. +

+ +
+
+
+ {makerspace?.public_stats_enabled ? ( + + + + ) : null} + + + + +
+
+ +
+ {listedCount ?? "-"} listed items +
+ {modules.has("printing") ? ( + + Request a 3D print + + ) : null} + {modules.has("events") ? ( + + Events + + ) : null} + {modules.has("machines") ? ( + + Machines + + ) : null} + {modules.has("bookings") ? ( + + Book a space + + ) : null} +
+
+
+
+
+ ); +} diff --git a/frontend/src/features/inventory/publicInventory/useInventoryCart.ts b/frontend/src/features/inventory/publicInventory/useInventoryCart.ts new file mode 100644 index 00000000..b74a4037 --- /dev/null +++ b/frontend/src/features/inventory/publicInventory/useInventoryCart.ts @@ -0,0 +1,64 @@ +import { useMemo, useState } from "react"; + +import type { Product, RequestCartItem } from "../../../types/inventory"; + +function maxQuantity(product: Product): number { + if ( + product.availability?.mode === "exact_count" && + typeof product.availability.count === "number" + ) { + return product.availability.count; + } + + return 99; +} + +/** Cart state for the public inventory page: a product-id keyed map plus its two mutators. */ +export function useInventoryCart() { + const [cart, setCart] = useState>({}); + const selectedItems = useMemo(() => Object.values(cart), [cart]); + + function incrementItem(product: Product) { + if (product.availability?.label === "Unavailable") { + return; + } + + setCart((current) => { + const existing = current[product.id]; + const quantity = Math.min((existing?.quantity ?? 0) + 1, maxQuantity(product)); + return { + ...current, + [product.id]: { + productId: product.id, + name: product.name, + quantity, + }, + }; + }); + } + + function decrementItem(product: Product) { + setCart((current) => { + const existing = current[product.id]; + if (!existing || existing.quantity <= 1) { + const next = { ...current }; + delete next[product.id]; + return next; + } + + return { + ...current, + [product.id]: { + ...existing, + quantity: existing.quantity - 1, + }, + }; + }); + } + + function clearCart() { + setCart({}); + } + + return { cart, selectedItems, incrementItem, decrementItem, clearCart }; +} diff --git a/frontend/src/features/members/ArchivedPayments.test.tsx b/frontend/src/features/members/ArchivedPayments.test.tsx index 6272593f..58a01226 100644 --- a/frontend/src/features/members/ArchivedPayments.test.tsx +++ b/frontend/src/features/members/ArchivedPayments.test.tsx @@ -63,6 +63,10 @@ describe("ArchivedPayments", () => { subject_label: "Laser cutter booking", status: "pending", checkout_url: "", + // The space still has a live rail, so the regenerate action is honest here. + // Without a rail the row now offers "Pay at the space" instead of a button + // whose endpoint could not succeed. + online_payment_available: true, created_at: "2026-08-11T08:00:00Z", }, ]); diff --git a/frontend/src/features/members/InvitationRequestForm.test.tsx b/frontend/src/features/members/InvitationRequestForm.test.tsx new file mode 100644 index 00000000..7947893a --- /dev/null +++ b/frontend/src/features/members/InvitationRequestForm.test.tsx @@ -0,0 +1,69 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { StructuredApiError } from "../../lib/api"; +import { expectNoA11yViolations } from "../../test/axe"; +import { InvitationRequestForm } from "./InvitationRequestForm"; + +const { tenantPublicRequest } = vi.hoisted(() => ({ tenantPublicRequest: vi.fn() })); + +vi.mock("../../lib/api", async () => { + const actual = await vi.importActual("../../lib/api"); + return { ...actual, tenantPublicRequest }; +}); + +function renderForm() { + const client = new QueryClient({ defaultOptions: { mutations: { retry: false } } }); + return render( + + + , + ); +} + +beforeEach(() => { + tenantPublicRequest.mockReset(); + tenantPublicRequest.mockResolvedValue({ detail: "Thanks - the makerspace will be in touch." }); +}); + +describe("InvitationRequestForm", () => { + it("posts the visitor's details with an empty honeypot and shows the backend acknowledgement", async () => { + const { container } = renderForm(); + + // The honeypot is present for bots but never exposed to people. + const honeypot = container.querySelector('input[name="website"]'); + expect(honeypot).not.toBeNull(); + expect(honeypot?.closest(".hidden")).not.toBeNull(); + expect(screen.queryByRole("textbox", { name: "Website" })).not.toBeInTheDocument(); + + expect(screen.getByRole("button", { name: "Send request" })).toBeDisabled(); + fireEvent.change(screen.getByRole("textbox", { name: "Name" }), { target: { value: "Ada Lovelace" } }); + fireEvent.change(screen.getByRole("textbox", { name: "Email" }), { target: { value: "ada@example.com" } }); + fireEvent.change(screen.getByRole("textbox", { name: "Message (optional)" }), { target: { value: "Robotics club" } }); + fireEvent.click(screen.getByRole("button", { name: "Send request" })); + + await waitFor(() => expect(tenantPublicRequest).toHaveBeenCalledWith( + "community-lab", + "/public/community-lab/invitation-requests", + { + method: "POST", + body: JSON.stringify({ name: "Ada Lovelace", email: "ada@example.com", phone: "", message: "Robotics club", website: "" }), + }, + )); + expect(await screen.findByRole("status")).toHaveTextContent("Thanks - the makerspace will be in touch."); + + await expectNoA11yViolations(container); + }); + + it("explains a throttle or a closed queue instead of a raw status", async () => { + tenantPublicRequest.mockRejectedValue(new StructuredApiError(429, { detail: "Request was throttled." })); + renderForm(); + + fireEvent.change(screen.getByRole("textbox", { name: "Name" }), { target: { value: "Ada" } }); + fireEvent.change(screen.getByRole("textbox", { name: "Email" }), { target: { value: "ada@example.com" } }); + fireEvent.click(screen.getByRole("button", { name: "Send request" })); + + expect(await screen.findByRole("alert")).toHaveTextContent("Too many requests from this connection."); + }); +}); diff --git a/frontend/src/features/members/InvitationRequestForm.tsx b/frontend/src/features/members/InvitationRequestForm.tsx new file mode 100644 index 00000000..83534495 --- /dev/null +++ b/frontend/src/features/members/InvitationRequestForm.tsx @@ -0,0 +1,91 @@ +import { useState } from "react"; +import { useMutation } from "@tanstack/react-query"; + +import { Field } from "../../components/ui"; +import { StructuredApiError, tenantPublicRequest } from "../../lib/api"; + +type Ack = { detail: string }; + +const EMPTY = { name: "", email: "", phone: "", message: "", website: "" }; + +/** + * Public "ask to be invited" form (`POST /public//invitation-requests`). `website` is + * the honeypot the backend checks first: a filled value gets the same 202 acknowledgement + * as a real submission and stores nothing, so the field must be present but never seen by a + * person -- it is rendered display:none, out of the tab order and out of autofill. + */ +export function InvitationRequestForm({ slug }: { slug: string }) { + const [form, setForm] = useState(EMPTY); + const submit = useMutation({ + mutationFn: () => + tenantPublicRequest(slug, `/public/${slug}/invitation-requests`, { + method: "POST", + body: JSON.stringify({ + name: form.name.trim(), + email: form.email.trim(), + phone: form.phone.trim(), + message: form.message.trim(), + website: form.website, + }), + }), + onSuccess: () => setForm(EMPTY), + }); + const set = (key: keyof typeof EMPTY, value: string) => setForm((current) => ({ ...current, [key]: value })); + const canSubmit = form.name.trim().length > 0 && form.email.trim().length > 0 && !submit.isPending; + + return ( +
+

Ask for an invitation

+

Leave your details and the makerspace will get back to you.

+ {submit.isSuccess ? ( +

+ {submit.data.detail} +

+ ) : ( +
{ + event.preventDefault(); + if (canSubmit) submit.mutate(); + }} + > +
+ + set("name", event.target.value)} /> + + + set("email", event.target.value)} /> + + + set("phone", event.target.value)} /> + +
+ +