diff --git a/.env.example b/.env.example index 552c494c..beca2696 100644 --- a/.env.example +++ b/.env.example @@ -79,6 +79,11 @@ DISABLE_SERVER_SIDE_CURSORS=False # upload-time content-length-range). "put" = Supabase Storage S3 presigned PUT (no # upload-time size policy; the backend re-validates size server-side at attach time). STORAGE_PRESIGN_METHOD=post +# Evidence photo metadata remains immutable; this independently bounds live object +# bytes. Preview policies before enabling. Range: 30-3650 days, batch: 1-1000. +EVIDENCE_OBJECT_RETENTION_DAYS=365 +EVIDENCE_OBJECT_EXPIRY_ENABLED=False +EVIDENCE_RETENTION_BATCH_SIZE=100 # Shared secret for the cron return-reminder endpoint # (POST /api/v1/internal/cron/return-reminders, header X-Cron-Secret). The endpoint # 404s while this is unset. Use it when you can't run `manage.py send_return_reminders` diff --git a/AGENTS.md b/AGENTS.md index 9da7144b..f214858a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,7 +32,8 @@ Guidance for Claude Code (claude.ai/code) when working with code in this reposit A multi-tenant system for managing community hardware loans across makerspaces. The central concern is **traceability of physical handovers**: every issue and return must produce evidence (QR scans + photos + -remarks + audit log) so that accountability for lost/damaged hardware is never ambiguous. Public users +remarks + audit log). Photo bytes may expire under the configured retention policy, while immutable photo +metadata, remarks, scans and audit history preserve the durable accountability trail. Public users browse and request; when self-checkout is enabled they may also issue/return eligible QR tools after authentication and evidence upload. Staff physically issue reviewed requests and direct handouts according to action scope. @@ -70,8 +71,8 @@ channel only. Two architectural rules are load-bearing and easy to violate if yo inventory reservation/issue/return. - **Inventory Availability** — quantity math + asset status for QR-tracked tools. - **QR Code & Box** — generates/resolves/revokes QR codes, assigns boxes to requests, tracks scan history. -- **Evidence Photo** — immutable issue/return photo storage linked to actor + request + QR scans; object - storage, never public. +- **Evidence Photo** — immutable issue/return photo metadata linked to actor + request + QR scans; private + object bytes may expire under the evidence-retention policy and are never public. - **Check-In API Client** — **RETIRED** (`73a480c`, Part M7). `apps/checkin/` no longer exists and there is no `CHECKIN_MODE` setting. Requester identity now comes from authenticated member accounts, so there is no external verify dependency left to fail safe on. @@ -120,8 +121,8 @@ the Auth module** — forgetting this is a cross-tenant data leak, not just a bu makerspace settings. - 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. 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. -- Evidence photos and QR scan records are **immutable**; audit logs are **append-only**. +- **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. +- 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`, `show_public_count`, and `public_availability_mode` (`exact_count | status_only | hidden`). diff --git a/CLAUDE.md b/CLAUDE.md index 9da7144b..f214858a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -32,7 +32,8 @@ Guidance for Claude Code (claude.ai/code) when working with code in this reposit A multi-tenant system for managing community hardware loans across makerspaces. The central concern is **traceability of physical handovers**: every issue and return must produce evidence (QR scans + photos + -remarks + audit log) so that accountability for lost/damaged hardware is never ambiguous. Public users +remarks + audit log). Photo bytes may expire under the configured retention policy, while immutable photo +metadata, remarks, scans and audit history preserve the durable accountability trail. Public users browse and request; when self-checkout is enabled they may also issue/return eligible QR tools after authentication and evidence upload. Staff physically issue reviewed requests and direct handouts according to action scope. @@ -70,8 +71,8 @@ channel only. Two architectural rules are load-bearing and easy to violate if yo inventory reservation/issue/return. - **Inventory Availability** — quantity math + asset status for QR-tracked tools. - **QR Code & Box** — generates/resolves/revokes QR codes, assigns boxes to requests, tracks scan history. -- **Evidence Photo** — immutable issue/return photo storage linked to actor + request + QR scans; object - storage, never public. +- **Evidence Photo** — immutable issue/return photo metadata linked to actor + request + QR scans; private + object bytes may expire under the evidence-retention policy and are never public. - **Check-In API Client** — **RETIRED** (`73a480c`, Part M7). `apps/checkin/` no longer exists and there is no `CHECKIN_MODE` setting. Requester identity now comes from authenticated member accounts, so there is no external verify dependency left to fail safe on. @@ -120,8 +121,8 @@ the Auth module** — forgetting this is a cross-tenant data leak, not just a bu makerspace settings. - 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. 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. -- Evidence photos and QR scan records are **immutable**; audit logs are **append-only**. +- **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. +- 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`, `show_public_count`, and `public_availability_mode` (`exact_count | status_only | hidden`). diff --git a/README.md b/README.md index 22385163..64309ebb 100644 --- a/README.md +++ b/README.md @@ -38,10 +38,20 @@ Telegram group, QR namespace, and audit scope — fully isolated from the others remark) → accountability, all audited. Direct staff handouts too. - **3D-printing manager** — public print requests, printer/spool management, filament tracking, slicer estimates, and an optional (staff-private) cash charge at collection. +- **Events & bookings** — one-off events or recurring series, registration with optional approval and + waitlists, QR check-in at the door (plus an expiring offline roster and event-scoped PIN stations for + a desk with no signal), post-event feedback, attendance certificates, printable badges, member + calendar feeds, and bookable spaces. +- **Organizations across makerspaces** — a network, university or chain registered as an organization + 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. - **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. +- **Reports & ledger** — what's out, who has it, overdue tracking, CSV/XLSX export, plus accessible + charts with table fallbacks and append-only metric rollups where a correction adds a revision rather + than rewriting history. Every module is covered either by a substantive report or an explicitly + gated row. - **Notifications** — per-makerspace **Telegram, Slack, Mattermost and Discord** alerts plus async (Celery) email, with a per-feature × per-channel matrix. Each channel is its own module. - **Modular by install** — turn whole modules on and off per makerspace; uninstalling hides surfaces @@ -52,7 +62,10 @@ Telegram group, QR namespace, and audit scope — fully isolated from the others - **Maker profiles** — an opt-in per-makerspace profile with projects, interests, education and an optional GitHub contribution count, plus a member directory that lists only the people who chose to be listed. -- **Traceable by design** — append-only audit log; immutable evidence photos and scan records. +- **Traceable by design** — append-only audit log; immutable evidence photo records and scan history. + Photo *bytes* can expire under a per-makerspace retention policy, while the photo metadata, remarks, + scans and audit trail are kept — so the accountability record outlives the image itself, and an + expired photo reads as a truthful expired state rather than a missing one. > **What works out of the box:** username/password. Google, Apple and OIDC need credentials you > create with that provider, and phone sign-in needs an SMS account — none of them can ship @@ -154,7 +167,7 @@ console shows you: | **Inventory** *(always on)* | The catalogue, request workflow, QR/evidence spine, asset units, containers, transfers, QR print batches, front-desk handover and purchasing | | **Stocktake** | Scan-first stock counts and variance reporting | | **Machines** | Machine registry, the service/print queue, maintenance and warranty | -| **Events** | Event scheduling and registrations, QR check-in at the door, and cross-makerspace collaborative events | +| **Events** | Event scheduling and registrations — recurring series, approval and waitlists, QR check-in at the door, post-event feedback and attendance certificates, member calendar feeds, printable attendee badges, and cross-makerspace collaborative events | | **Bookings** | Resource booking and public self-booking | | **Membership** | Join requests, waivers, referrals, member activity, maker profiles, presence — and the member-facing identity ecosystem | | **Notifications** | The in-app inbox and every outbound channel | @@ -237,7 +250,7 @@ cannot be removed. **Default** means it is on when you install without choosing | | [`machine_service`](docs/MODULES.md#machine_service) | | | The service/job queue | | | [`printing`](docs/MODULES.md#printing) | | | 3D printing on top of `machine_service` | | | [`maintenance`](docs/MODULES.md#maintenance) | | | Scheduled and reactive maintenance | -| **Events** | [`events`](docs/MODULES.md#events) | | | Scheduling, registrations, QR check-in, collaborative events | +| **Events** | [`events`](docs/MODULES.md#events) | | | Scheduling, recurring series, registrations and waitlists, QR check-in, feedback and certificates, collaborative events | | **Bookings** | [`bookings`](docs/MODULES.md#bookings) | | | Resource booking and public self-booking | | **Membership** | [`membership`](docs/MODULES.md#membership) | | | Join requests, waivers, referrals, maker profiles | | | [`member_accounts`](docs/MODULES.md#member_accounts) | | | Member sign-up and member sign-in | @@ -274,6 +287,8 @@ Manager** in the console rather than a superadmin. | `payments.events` | `events` | | Charge for event registration | | `payments.membership` | `membership` | | Charge membership dues | | `mobile.push` | `mobile` | ● | Native push notifications | +| `events.offline_checkin` | `events` | | Expiring on-device roster and event-scoped PIN check-in stations | +| `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) | @@ -551,6 +566,45 @@ pointer/CAS adapter, never ambient shell state. The Cloud static-environment ini callbacks are not implemented yet, so the older Supabase path is suitable only for a non-H1 demo—not a supported restore topology. +### Split deployment: backend on your server, frontend on Netlify + +Supported, and `netlify.toml` in the repo root configures it. Netlify builds **only** the React app — +it never touches the Dockerfiles or compose files, and because `frontend/src/generated/api.ts` is +committed, the build never has to reach your server. + +Netlify picks up `base = frontend`, `npm run build`, `publish = dist` and `NODE_VERSION = 22` from +that file (Vite 8 needs Node 20.19+/22.12+, and Netlify's default image can be older). It also adds +the catch-all rewrite to `index.html`, without which every deep link — `/m/`, `/admin/*`, +`/event-check-in/` — 404s on refresh. Set **`VITE_API_URL`** in the Netlify UI to your API, +e.g. `https://api.example.org/api`. + +The backend then has to accept a browser on a different origin. Auth already defaults to cross-site +cookies (`AUTH_COOKIE_SAMESITE=None`, `AUTH_COOKIE_SECURE=True`), **which only works if both sides +are HTTPS**: + +```env +ENABLE_HTTPS=True +ALLOWED_HOSTS=api.example.org +CORS_ALLOWED_ORIGINS=https://your-site.netlify.app +CSRF_TRUSTED_ORIGINS=https://your-site.netlify.app +``` + +Three more that are easy to miss: + +- **Set each makerspace's `frontend_domain`** to the site's domain. Origin scoping validates it, so + tenant-scoped routes are rejected without it. +- **Object storage must be addressed publicly.** `AWS_S3_PUBLIC_ENDPOINT_URL` and + `PUBLIC_IMAGE_BASE_URL` are baked into presigned URLs and every public image `src`, so a + `localhost` value yields a site that works only from the server console and shows broken images to + everyone else. On R2 or S3 also set `STORAGE_PRESIGN_METHOD=put`, and allow your site's origin in + the bucket's CORS rules **in the provider dashboard**. +- **Keep a scheduler.** Hosting the frontend elsewhere does not affect scheduled work, but the + backend profile you choose does: `docker-compose.prod.yml` runs Celery `beat`, while + `docker-compose.cloud.yml` has no beat and relies on its `cron` service instead. With neither, + `.delay()` still runs inline so nothing looks broken, yet return reminders, the evidence-retention + sweep and event-series extension silently never fire. If you use the cloud profile, drop only its + `frontend` service — never `cron`. + ### Moving a makerspace onto its own server A space that started as a tenant on someone else's instance can take its data with it. A superadmin diff --git a/VERSION b/VERSION index a3df0a69..6f4eebdf 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.8.0 +0.8.1 diff --git a/backend/.env.example b/backend/.env.example index 456c995f..03e31bd7 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -45,6 +45,8 @@ APICLIENT_REQUIRE_NONCE=False # registry; `manage.py check` fails with the list if any is missing, rather than the # deployment silently 401-ing that whole prefix. HMAC_PROTECTED_PATH_PREFIXES=/api/public/,/api/v1/public/ +# Independent high-entropy HMAC key for event PIN verification; do not reuse API_CLIENT_ENC_KEY. +EVENT_STATION_PIN_PEPPER= # Fernet key for ApiClient, Telegram bot token, and makerspace SMTP password # encryption. Generate with: # python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" @@ -111,6 +113,9 @@ THROTTLE_PUBLIC_REQUEST_SUBMIT=10/min THROTTLE_ANONYMOUS_REQUEST_IP_BURST=2/min THROTTLE_ANONYMOUS_REQUEST_IP_HOUR=10/hour THROTTLE_ANONYMOUS_REQUEST_EMAIL=3/day +# Member calendar subscription endpoints use both token- and IP-shaped buckets. +THROTTLE_EVENT_CALENDAR_FEED_TOKEN=120/hour +THROTTLE_EVENT_CALENDAR_FEED_IP=300/hour ANONYMOUS_REQUEST_OUTSTANDING_LIMIT=50 ANONYMOUS_REQUEST_IDEMPOTENCY_KEY_MAX_LENGTH=128 diff --git a/backend/apps/accounts/claim_pre_auth_guard.py b/backend/apps/accounts/claim_pre_auth_guard.py index a5482dad..bada499f 100644 --- a/backend/apps/accounts/claim_pre_auth_guard.py +++ b/backend/apps/accounts/claim_pre_auth_guard.py @@ -47,12 +47,23 @@ "apps.events.throttles.CollaborativeRegistrationThrottle": { "_tier", "allow_request", "get_cache_key" }, + # Public feedback reads and submissions take separate budgets. The scope is chosen in + # `allow_request` rather than a view-level `get_throttles()` override precisely BECAUSE + # this guard forbids a lifecycle-hook override on a pre-auth route; the override set is + # therefore identical to its ClientTierRateThrottle base. + "apps.events.throttles.PublicFeedbackRateThrottle": { + "_tier", "allow_request", "get_cache_key" + }, # The account-less borrow-request budgets. Both override `get_cache_key` only, to # return None for an authenticated caller: they sit in `throttle_classes` beside the # member throttle, and DRF applies every class on every request, so without the skip a # makerspace behind one NAT would rate-limit its own signed-in members by egress IP. "apps.hardware_requests.throttles.AnonymousRequestIpBurstThrottle": {"get_cache_key"}, "apps.hardware_requests.throttles.AnonymousRequestIpHourThrottle": {"get_cache_key"}, + # The subscribable calendar feed is fetched by a calendar client with only its + # bearer token, so the budget keys on that token rather than the caller. + "apps.events.throttles.EventCalendarFeedTokenThrottle": {"get_cache_key"}, + "apps.events.throttles.EventCalendarFeedIpThrottle": {"get_cache_key"}, "apps.machines.permissions.IsActiveRequester": {"has_permission"}, "apps.accounts.views_device.IsDeviceAccessToken": {"has_permission"}, "apps.makerspaces.throttles.MemberImagePresignThrottle": {"get_cache_key"}, diff --git a/backend/apps/accounts/claim_routes_auth.py b/backend/apps/accounts/claim_routes_auth.py index 7c831fa0..6bcabc74 100644 --- a/backend/apps/accounts/claim_routes_auth.py +++ b/backend/apps/accounts/claim_routes_auth.py @@ -60,6 +60,11 @@ def _refused(name, methods, reason): **_refused("auth-forgot-password", ("POST",), "walk-ins have no password credential"), **_refused("auth-reset-password", ("POST",), "walk-ins have no password credential"), **_refused("auth-member-sign-up", ("POST",), "claim sessions cannot create another account"), + **_refused( + "auth-organization-invitation-redeem", + ("POST",), + "claim sessions cannot take on organization authority", + ), **_refused("auth-email-verification-resend", ("POST",), "walk-ins cannot verify email"), **_refused("auth-email-verification-confirm", ("POST",), "walk-ins cannot verify email"), } diff --git a/backend/apps/accounts/claim_routes_member.py b/backend/apps/accounts/claim_routes_member.py index b6f8013b..d8d27d2d 100644 --- a/backend/apps/accounts/claim_routes_member.py +++ b/backend/apps/accounts/claim_routes_member.py @@ -107,4 +107,50 @@ def _options(name): ownership=RowOwnership.MIXED_REFUSED, ), **_options("member-event-checkin-qr"), + ("member-event-calendar", "GET"): Refused( + "event calendars can contain foreign-hosted registration rows", + ownership=RowOwnership.MIXED_REFUSED, + ), + ("member-event-calendar", "HEAD"): Refused( + "event calendars can contain foreign-hosted registration rows", + ownership=RowOwnership.MIXED_REFUSED, + ), + **_options("member-event-calendar"), + ("member-event-calendar-feed", "GET"): Refused( + "claim sessions cannot manage durable bearer credentials" + ), + # DRF derives HEAD from GET, so the pair must be declared together or the route is + # half-classified and the matrix guard fails. + ("member-event-calendar-feed", "HEAD"): Refused( + "claim sessions cannot manage durable bearer credentials" + ), + ("member-event-calendar-feed", "POST"): Refused( + "claim sessions cannot manage durable bearer credentials" + ), + ("member-event-calendar-feed", "DELETE"): Refused( + "claim sessions cannot manage durable bearer credentials" + ), + **_options("member-event-calendar-feed"), + ("member-event-feedback", "GET"): Refused( + "event feedback can return foreign-hosted registration rows", + ownership=RowOwnership.MIXED_REFUSED, + ), + ("member-event-feedback", "HEAD"): Refused( + "event feedback can return foreign-hosted registration rows", + ownership=RowOwnership.MIXED_REFUSED, + ), + ("member-event-feedback", "POST"): Refused( + "event feedback can write a foreign-hosted response", + ownership=RowOwnership.MIXED_REFUSED, + ), + **_options("member-event-feedback"), + ("member-event-certificate-download", "GET"): Refused( + "event certificates can belong to foreign-hosted registrations", + ownership=RowOwnership.MIXED_REFUSED, + ), + ("member-event-certificate-download", "HEAD"): Refused( + "event certificates can belong to foreign-hosted registrations", + ownership=RowOwnership.MIXED_REFUSED, + ), + **_options("member-event-certificate-download"), } diff --git a/backend/apps/accounts/claim_routes_public.py b/backend/apps/accounts/claim_routes_public.py index 38b36bf9..720fc0a2 100644 --- a/backend/apps/accounts/claim_routes_public.py +++ b/backend/apps/accounts/claim_routes_public.py @@ -33,10 +33,20 @@ def _anonymous(name): ("public-printer-service-request", "OPTIONS"): AnonymousRead(), **_anonymous("public-printer-service-status"), **_anonymous("public-event-list"), + # The organization directory is deployment-global read-only presentation; a walk-in + # claim session reads it exactly like any anonymous visitor. + **_anonymous("public-organization-detail"), + **_anonymous("public-organization-events"), + **_anonymous("public-event-calendar"), + **_anonymous("public-event-calendar-feed"), ("public-event-register", "POST"): Allowed( tenant=PUBLIC_TOKEN, audited=True ), ("public-event-register", "OPTIONS"): AnonymousRead(), + **_anonymous("public-event-feedback"), + ("public-event-feedback", "POST"): Allowed( + tenant=PUBLIC_TOKEN, audited=True + ), **_anonymous("public-bookable-space-list"), **_anonymous("public-space-availability"), ("public-booking-submit", "POST"): Allowed( diff --git a/backend/apps/accounts/claim_tenants.py b/backend/apps/accounts/claim_tenants.py index 562b5ede..b2378e65 100644 --- a/backend/apps/accounts/claim_tenants.py +++ b/backend/apps/accounts/claim_tenants.py @@ -20,6 +20,7 @@ class ClaimTenantResolutionError(ValueError): # Present for completeness even though the matrix refuses registration: changing # that disposition later must still resolve the exact event row, never trust slug. "public-event-register": ("events.Event", "public_token"), + "public-event-feedback": ("events.Event", "public_token"), } BODY_OBJECT_TARGETS = { "public-machine-service-request-submit": ("machines.Machine", "machine_id"), diff --git a/backend/apps/accounts/services_social_login.py b/backend/apps/accounts/services_social_login.py index cc767a5b..17a0165e 100644 --- a/backend/apps/accounts/services_social_login.py +++ b/backend/apps/accounts/services_social_login.py @@ -53,6 +53,10 @@ def assert_staff_authority(user, request): if scope is NO_STAFF_ORIGIN_SCOPE: if rbac.has_any_org_authority(user): return + from apps.organizations.governance import has_any_governance + + if has_any_governance(user): + return elif rbac.effective_actions(user, scope): return raise SocialResolutionError("staff_access_required", 403) diff --git a/backend/apps/accounts/urls.py b/backend/apps/accounts/urls.py index f94f8d6a..fa14310f 100644 --- a/backend/apps/accounts/urls.py +++ b/backend/apps/accounts/urls.py @@ -42,6 +42,7 @@ OidcBrowserStartView, ) from apps.accounts.views_claim import ClaimRedemptionView +from apps.organizations.views_redeem import OrganizationInvitationRedeemView urlpatterns = [ path("social/nonce", SocialNonceView.as_view(), name="social-nonce"), @@ -75,6 +76,11 @@ path("phone", PhoneUnlinkView.as_view(), name="auth-phone-unlink"), path("login", LoginView.as_view(), name="auth-login"), path("claim/redeem", ClaimRedemptionView.as_view(), name="auth-claim-redeem"), + path( + "organization-invitations/redeem/", + OrganizationInvitationRedeemView.as_view(), + name="auth-organization-invitation-redeem", + ), path("refresh", RefreshView.as_view(), name="auth-refresh"), path("logout", LogoutView.as_view(), name="auth-logout"), path("me", MeView.as_view(), name="auth-me"), diff --git a/backend/apps/admin_api/reports_imports.py b/backend/apps/admin_api/reports_imports.py new file mode 100644 index 00000000..7b01adc7 --- /dev/null +++ b/backend/apps/admin_api/reports_imports.py @@ -0,0 +1,37 @@ +from django.db.models import Count, Max, Sum + +from apps.admin_api.models import BulkImportJob +from apps.operations.report_types import ReportResult +from apps.operations.reports_common import apply_range, limited, period_expression +from apps.operations.report_scope import scoped_ids + + +FIELDS = ( + "period", "mode", "status", "jobs", "total_rows", "processed_rows", + "created_rows", "updated_rows", "error_rows", "warning_rows", + "success_rate_percent", "last_activity_at", +) + + +def build_import_quality(makerspace_id, *, limit=None, date_range=None, grain="day"): + aggregate = makerspace_id is None + group = ["period", "mode", "status"] + if aggregate: + group.insert(0, "makerspace_id") + rows = apply_range(BulkImportJob.objects.filter( + makerspace_id__in=scoped_ids(makerspace_id, "bulk_import") + ), "created_at", date_range).annotate( + period=period_expression("created_at", grain) + ).values(*group).annotate( + jobs=Count("id"), total_rows=Sum("total_rows"), processed_rows=Sum("processed_rows"), + created_rows=Sum("created_count"), updated_rows=Sum("updated_count"), + error_rows=Sum("error_count"), warning_rows=Sum("warning_count"), + last_activity_at=Max("updated_at"), + ).order_by(*group) + records = [] + for row in rows: + successful = (row["created_rows"] or 0) + (row["updated_rows"] or 0) + processed = row["processed_rows"] or 0 + records.append({**row, "success_rate_percent": round(successful / processed * 100, 2) if processed else None}) + fields = (("makerspace_id",) + FIELDS) if aggregate else FIELDS + return ReportResult(fields, limited(records, limit)) diff --git a/backend/apps/admin_api/views_makerspaces.py b/backend/apps/admin_api/views_makerspaces.py index b69ae82b..4e68f99e 100644 --- a/backend/apps/admin_api/views_makerspaces.py +++ b/backend/apps/admin_api/views_makerspaces.py @@ -1,4 +1,5 @@ from django.shortcuts import get_object_or_404 +from django.db import transaction from drf_spectacular.utils import extend_schema from rest_framework import generics from rest_framework.exceptions import PermissionDenied @@ -111,44 +112,50 @@ def get_object(self): return self._makerspace_object def perform_update(self, serializer): - previous_features = list(serializer.instance.enabled_features) - previous_geofence = { - "enabled": serializer.instance.geofence_enabled, - "configured": serializer.instance.geofence_effective, - "radius_m": serializer.instance.geofence_radius_m, - "latitude": str(serializer.instance.geofence_latitude), - "longitude": str(serializer.instance.geofence_longitude), - } - instance = serializer.save() - audit.record( - self.request.user, - "makerspace.updated", - makerspace=instance, - target=instance, - ) - if previous_features != instance.enabled_features: - audit.record( - self.request.user, - "makerspace.features_changed", - makerspace=instance, - target=instance, - meta={"before": previous_features, "after": instance.enabled_features}, + # Feature OFF must serialize with services that re-check the same row under + # lock. Otherwise a station sync can pass its gate while this PATCH commits OFF. + with transaction.atomic(): + serializer.instance = Makerspace.objects.select_for_update().get( + pk=serializer.instance.pk ) - current_geofence = { - "enabled": instance.geofence_enabled, - "configured": instance.geofence_effective, - "radius_m": instance.geofence_radius_m, - "latitude": str(instance.geofence_latitude), - "longitude": str(instance.geofence_longitude), - } - if previous_geofence != current_geofence: + previous_features = list(serializer.instance.enabled_features) + previous_geofence = { + "enabled": serializer.instance.geofence_enabled, + "configured": serializer.instance.geofence_effective, + "radius_m": serializer.instance.geofence_radius_m, + "latitude": str(serializer.instance.geofence_latitude), + "longitude": str(serializer.instance.geofence_longitude), + } + instance = serializer.save() audit.record( self.request.user, - "makerspace.geofence_updated", + "makerspace.updated", makerspace=instance, target=instance, - meta=current_geofence, ) + if previous_features != instance.enabled_features: + audit.record( + self.request.user, + "makerspace.features_changed", + makerspace=instance, + target=instance, + meta={"before": previous_features, "after": instance.enabled_features}, + ) + current_geofence = { + "enabled": instance.geofence_enabled, + "configured": instance.geofence_effective, + "radius_m": instance.geofence_radius_m, + "latitude": str(instance.geofence_latitude), + "longitude": str(instance.geofence_longitude), + } + if previous_geofence != current_geofence: + audit.record( + self.request.user, + "makerspace.geofence_updated", + makerspace=instance, + target=instance, + meta=current_geofence, + ) @extend_schema(tags=["Admin makerspaces"], summary="Retrieve or update return policy") diff --git a/backend/apps/apiclients/scope_registry_routes.py b/backend/apps/apiclients/scope_registry_routes.py index 991898a5..baa6ad4f 100644 --- a/backend/apps/apiclients/scope_registry_routes.py +++ b/backend/apps/apiclients/scope_registry_routes.py @@ -57,7 +57,13 @@ class ScopeRegistryEntry: TARGET_TENANT_SLUG, False, True, ), ("public-event-list", _READ, PUBLIC_READ_SCOPES, TARGET_TENANT_SLUG, False, True), + ("public-organization-detail", _READ, PUBLIC_READ_SCOPES, TARGET_GLOBAL, True, True), + ("public-organization-events", _READ, PUBLIC_READ_SCOPES, TARGET_GLOBAL, True, True), + ("public-event-calendar", _READ, PUBLIC_READ_SCOPES, TARGET_TENANT_SLUG, False, True), + ("public-event-calendar-feed", _READ, PUBLIC_READ_SCOPES, TARGET_TENANT_SLUG, False, True), ("public-event-register", _WRITE, PUBLIC_WRITE_SCOPES, TARGET_TENANT_SLUG, False, True), + ("public-event-feedback", _READ, PUBLIC_READ_SCOPES, TARGET_TENANT_TOKEN, False, True), + ("public-event-feedback", _WRITE, PUBLIC_WRITE_SCOPES, TARGET_TENANT_TOKEN, False, True), ("public-bookable-space-list", _READ, PUBLIC_READ_SCOPES, TARGET_TENANT_SLUG, False, True), ("public-space-availability", _READ, PUBLIC_READ_SCOPES, TARGET_TENANT_SLUG, False, True), ("public-booking-submit", _WRITE, PUBLIC_WRITE_SCOPES, TARGET_TENANT_SLUG, False, True), diff --git a/backend/apps/backup/archive_objects.py b/backend/apps/backup/archive_objects.py index 7f3e0f48..e02965d2 100644 --- a/backend/apps/backup/archive_objects.py +++ b/backend/apps/backup/archive_objects.py @@ -21,6 +21,13 @@ ("audit.AuditSigningKeyRotation", "new_key"), ("audit.AuditSigningKeyRotation", "old_key"), ("backup.RestoreRollbackObject", "module_key"), + # Logical identity strings, not object-store pointers: the occurrence key names a + # position within a recurring series, and the rollup keys name which metric a row + # holds. Nothing is stored in a bucket for any of them. + ("events.Event", "series_occurrence_key"), + ("operations.ReportMetricRollup", "dimension_key"), + ("operations.ReportMetricRollup", "metric_key"), + ("operations.ReportMetricRollup", "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. @@ -63,6 +70,9 @@ def collect_model_objects(queryset, model, result, fixed_makerspace_id=None): for field in model._meta.concrete_fields: if field.name not in OBJECT_FIELD_NAMES: continue + if model._meta.label == "evidence.EvidencePhoto" and field.name == "object_key": + _collect_evidence_objects(queryset, result, fixed_makerspace_id) + continue if field.name == "copy_key": rows = queryset.exclude(copy_key="").values_list( "copy_key", "bucket_kind", "makerspace_id", "module_key" @@ -92,6 +102,40 @@ def collect_model_objects(queryset, model, result, fixed_makerspace_id=None): } +def _collect_evidence_objects(queryset, result, fixed_makerspace_id): + rows = queryset.exclude(object_key="").values( + "object_key", + "makerspace_id", + "object_retention_state__status", + "object_retention_state__object_expired_at", + "object_retention_state__expired_size_bytes", + ) + for row in rows: + status = row["object_retention_state__status"] + if status == "expiring": + raise storage.BackupStorageError( + "Evidence expiry is in progress; retry the archive after it completes." + ) + ownership = { + "makerspace_id": fixed_makerspace_id or row["makerspace_id"], + "module_key": "", + } + if status == "expired": + expired_at = row["object_retention_state__object_expired_at"] + if expired_at is None: + raise storage.BackupStorageError( + "Expired evidence is missing its terminal timestamp." + ) + ownership.update( + retention_state="expired", + object_expired_at=expired_at.isoformat(), + expired_size_bytes=row[ + "object_retention_state__expired_size_bytes" + ], + ) + result["private"][str(row["object_key"])] = ownership + + def capture_objects(root, object_keys, modes): manifest = [] buckets = { @@ -102,6 +146,23 @@ def capture_objects(root, object_keys, modes): if kind not in buckets: raise ValueError(f"Unsupported backup bucket kind: {kind!r}.") for key, ownership in sorted(keys.items()): + if ownership.get("retention_state") == "expired": + storage.assert_object_absent(buckets[kind], key) + storage.assert_object_absent(buckets[kind], f"staging/{key}") + manifest.append( + { + "bucket_kind": kind, + **ownership, + "key": key, + "version_id": None, + "size": 0, + "sha256": "", + "metadata": {}, + "content_type": "", + "headers": {}, + } + ) + continue destination = root / kind / key item = storage.download_object( buckets[kind], key, destination, versioned=modes[kind] == "versioned" @@ -113,6 +174,8 @@ def capture_objects(root, object_keys, modes): def module_for_model(label): return { "events.Event": "events", + "events.EventSeries": "events", + "events.EventAttendanceCertificate": "events", "bookings.BookableSpace": "bookings", "maintenance.MaintenanceLogDocument": "maintenance", "procurement.ToBuyReceipt": "procurement", diff --git a/backend/apps/backup/main_projection.py b/backend/apps/backup/main_projection.py index 8d45e7b2..362faf04 100644 --- a/backend/apps/backup/main_projection.py +++ b/backend/apps/backup/main_projection.py @@ -2,6 +2,7 @@ from pathlib import Path +from django.core.exceptions import EmptyResultSet from django.db import connections, transaction from apps.backup.main_projection_registry import ( @@ -123,8 +124,23 @@ def _mark_queryset(cursor, using, queryset, name): than on the model's own column. """ query = queryset.order_by().values("pk").query - sql, params = query.get_compiler(using=using).as_sql() marker = f"lane_e_{name}" + try: + sql, params = query.get_compiler(using=using).as_sql() + except EmptyResultSet: + # Django refuses to compile a WHERE that can never match. Every boundary is + # unsatisfiable (`__in=()`) on a deployment with no sovereign tenant, which is + # the DEFAULT -- superadmin_access_enabled starts True, so a deployment where + # nobody has taken custody freezes zero slices. The marker must still exist and + # be empty, because _apply_marker joins it unconditionally; build it from the + # model's own primary key so the join keeps its exact type. + quote = cursor.db.ops.quote_name + model = queryset.model + sql = ( + f'SELECT {quote(model._meta.pk.column)} AS "pk" ' + f'FROM {quote(model._meta.db_table)} WHERE false' + ) + params = () cursor.execute(f'CREATE TEMP TABLE "{marker}" ON COMMIT DROP AS {sql}', params) return marker diff --git a/backend/apps/backup/object_ownership.py b/backend/apps/backup/object_ownership.py index 2e970974..8c6ec513 100644 --- a/backend/apps/backup/object_ownership.py +++ b/backend/apps/backup/object_ownership.py @@ -26,12 +26,18 @@ class ObjectReference: module_key: str coordination_policy: str coordination_makerspace_id: int | None + retention_state: str = "live" + object_expired_at: str = "" + expired_size_bytes: int | None = None @dataclass(frozen=True) class CapturedObject: size: int sha256: str + retention_state: str = "live" + object_expired_at: str = "" + expired_size_bytes: int | None = None class ObjectOwnershipPlan: @@ -51,6 +57,11 @@ def __init__(self, references, sovereign_makerspace_ids): def _validate_candidates(self): for identity, references in self.multimap.items(): + states = {item.retention_state for item in references} + if states - {"live", "expired"} or len(states) != 1: + raise BackupBuildError( + "One object reference has inconsistent retention state." + ) candidates = { item.candidate_owner for item in references if item.candidate_owner } @@ -84,6 +95,12 @@ def closure(self, component): "makerspace_id": makerspaces[0] if len(makerspaces) == 1 else None, "module_key": modules[0] if len(modules) == 1 else "", } + if references[0].retention_state == "expired": + result[bucket_kind][key].update( + retention_state="expired", + object_expired_at=references[0].object_expired_at, + expired_size_bytes=references[0].expired_size_bytes, + ) return result @staticmethod @@ -126,18 +143,42 @@ def bind_component(self, component, root, manifest): previous = self._packaged_owner.get(identity) if previous is not None and previous != component: raise BackupBuildError("A physical object byte was packaged by two components.") + references = self.multimap[identity] + retention_state = references[0].retention_state path = root / identity[0] / identity[1] - try: - size = path.stat().st_size - digest = sha256_file(path) - except OSError as exc: - raise BackupBuildError("A captured object byte is missing.") from exc - if size != item.get("size") or digest != item.get("sha256"): - raise BackupBuildError( - "A packaged object differs from its immutable capture ledger." - ) + if retention_state == "expired": + if ( + item.get("retention_state") != "expired" + or item.get("object_expired_at") + != references[0].object_expired_at + or item.get("expired_size_bytes") + != references[0].expired_size_bytes + or path.exists() + ): + raise BackupBuildError( + "An expired object tombstone is inconsistent with its source state." + ) + size, digest = 0, "" + else: + if item.get("retention_state") is not None: + raise BackupBuildError("A live object was replaced by a tombstone.") + try: + size = path.stat().st_size + digest = sha256_file(path) + except OSError as exc: + raise BackupBuildError("A captured object byte is missing.") from exc + if size != item.get("size") or digest != item.get("sha256"): + raise BackupBuildError( + "A packaged object differs from its immutable capture ledger." + ) self._packaged_owner[identity] = component - captured[identity] = CapturedObject(size=size, sha256=digest) + captured[identity] = CapturedObject( + size=size, + sha256=digest, + retention_state=retention_state, + object_expired_at=references[0].object_expired_at, + expired_size_bytes=references[0].expired_size_bytes, + ) if component in self._captured: raise BackupBuildError("An object component was bound more than once.") self._captured[component] = captured @@ -158,16 +199,22 @@ def verify_component(self, component, manifest): for identity, fact in captured.items(): item = actual[identity] path = self._roots[component] / identity[0] / identity[1] - try: - byte_mismatch = ( - path.stat().st_size != fact.size - or sha256_file(path) != fact.sha256 - ) - except OSError: - byte_mismatch = True + if fact.retention_state == "expired": + byte_mismatch = path.exists() + else: + try: + byte_mismatch = ( + path.stat().st_size != fact.size + or sha256_file(path) != fact.sha256 + ) + except OSError: + byte_mismatch = True if ( item.get("size") != fact.size or item.get("sha256") != fact.sha256 + or item.get("retention_state", "live") != fact.retention_state + or item.get("object_expired_at", "") != fact.object_expired_at + or item.get("expired_size_bytes") != fact.expired_size_bytes or byte_mismatch ): raise BackupBuildError( @@ -183,7 +230,7 @@ def assert_complete(self): for component in expected_components: immutable_manifest = [ {"bucket_kind": kind, "key": key, "size": fact.size, - "sha256": fact.sha256} + "sha256": fact.sha256, "retention_state": fact.retention_state} for (kind, key), fact in self._captured[component].items() ] # Re-read bytes against facts even though the caller no longer holds a @@ -191,13 +238,16 @@ def assert_complete(self): for item in immutable_manifest: fact = self._captured[component][(item["bucket_kind"], item["key"])] path = self._roots[component] / item["bucket_kind"] / item["key"] - try: - mismatch = ( - path.stat().st_size != fact.size - or sha256_file(path) != fact.sha256 - ) - except OSError: - mismatch = True + if fact.retention_state == "expired": + mismatch = path.exists() + else: + try: + mismatch = ( + path.stat().st_size != fact.size + or sha256_file(path) != fact.sha256 + ) + except OSError: + mismatch = True if mismatch: raise BackupBuildError( "A packaged object changed after immutable capture." diff --git a/backend/apps/backup/object_ownership_registry.py b/backend/apps/backup/object_ownership_registry.py index a8dc6d83..2dabaa04 100644 --- a/backend/apps/backup/object_ownership_registry.py +++ b/backend/apps/backup/object_ownership_registry.py @@ -29,6 +29,7 @@ class FieldObjectRule: policy: ReferencePolicy = ReferencePolicy.CANONICAL coordination_path: str | None = None coordination_reason: str = "" + retention_aware: bool = False # Literal, field-specific declarations are intentional. The equality guard below @@ -50,7 +51,16 @@ class FieldObjectRule: coordination_path="makerspace_id", coordination_reason="data_export_coordination"), FieldObjectRule("events.Event", "image_key", BucketRule.PUBLIC_IMAGE), - FieldObjectRule("evidence.EvidencePhoto", "object_key", BucketRule.PRIVATE), + FieldObjectRule("events.EventSeries", "image_key", BucketRule.PUBLIC_IMAGE), + FieldObjectRule("events.EventAttendanceCertificate", "object_key", BucketRule.PRIVATE), + # retention_aware: phase 9 may expire these bytes while the row survives, so capture + # must tolerate a missing object rather than treat it as corruption. + FieldObjectRule( + "evidence.EvidencePhoto", + "object_key", + BucketRule.PRIVATE, + retention_aware=True, + ), FieldObjectRule("inventory.InventoryProduct", "image_key", BucketRule.PUBLIC_IMAGE), FieldObjectRule("machines.Machine", "image_key", BucketRule.PUBLIC_IMAGE), FieldObjectRule("machines.MachineDocument", "object_key", BucketRule.PRIVATE), diff --git a/backend/apps/backup/object_reference_capture.py b/backend/apps/backup/object_reference_capture.py index 16a07795..14082c43 100644 --- a/backend/apps/backup/object_reference_capture.py +++ b/backend/apps/backup/object_reference_capture.py @@ -34,9 +34,25 @@ def build_object_ownership_plan(sovereign_makerspace_ids): query_fields.append(owner_lookup) if rule.coordination_path and rule.coordination_path not in query_fields: query_fields.append(rule.coordination_path) + if rule.retention_aware: + query_fields.extend( + [ + "object_retention_state__status", + "object_retention_state__object_expired_at", + "object_retention_state__expired_size_bytes", + ] + ) rows = model._base_manager.exclude(**{rule.field_name: ""}).values(*query_fields) for row in rows.iterator(chunk_size=500): - key = str(row[rule.field_name]) + # A NULL key column survives the exclude() above -- Django keeps NULL rows + # out of an `exclude(field="")` -- and str(None) would enter the closure as + # an object literally named "None" that no bucket holds. Only + # bookings.BookableSpace.image_key is nullable today; the legacy closure in + # archive_objects.collect_model_objects tests the raw value and skips it. + raw_key = row[rule.field_name] + if raw_key is None: + continue + key = str(raw_key) if not key: continue bucket = row["bucket_kind"] if rule.bucket == BucketRule.FROM_ROW else rule.bucket @@ -47,6 +63,14 @@ def build_object_ownership_plan(sovereign_makerspace_ids): if rule.policy == ReferencePolicy.COORDINATION_ONLY: component = None coordination_id = row.get(rule.coordination_path) if rule.coordination_path else None + retention_state = row.get("object_retention_state__status") or "live" + if retention_state == "expiring": + raise BackupBuildError( + "Evidence expiry is in progress; retry archive capture later." + ) + expired_at = row.get("object_retention_state__object_expired_at") + if retention_state == "expired" and expired_at is None: + raise BackupBuildError("Expired evidence lacks terminal state.") references.append(ObjectReference( bucket_kind=str(bucket), object_key=key, site=f"{rule.model_label}:{row['pk']}:{rule.field_name}", @@ -59,6 +83,11 @@ def build_object_ownership_plan(sovereign_makerspace_ids): else (rule.coordination_reason if coordination_id else "") ), coordination_makerspace_id=coordination_id, + retention_state=retention_state, + object_expired_at=(expired_at.isoformat() if expired_at else ""), + expired_size_bytes=row.get( + "object_retention_state__expired_size_bytes" + ), )) references.extend(_audit_meta_references()) return ObjectOwnershipPlan(references, sovereign) diff --git a/backend/apps/backup/object_restore.py b/backend/apps/backup/object_restore.py index 96de2645..d8a8a42b 100644 --- a/backend/apps/backup/object_restore.py +++ b/backend/apps/backup/object_restore.py @@ -16,8 +16,6 @@ from apps.makerspaces import limits from apps.makerspaces.models import Makerspace from apps.object_storage import delete_all_versions - - def restore_objects(restore, bundle_root, manifest, journal_path): root = Path(bundle_root).resolve() journal = Path(journal_path) @@ -25,6 +23,10 @@ def restore_objects(restore, bundle_root, manifest, journal_path): for item in manifest.get("storage", {}).get("objects", []): key = _safe_key(item["key"]) kind = item["bucket_kind"] + if item.get("retention_state") == "expired": + storage.assert_object_absent(_bucket(kind), key) + storage.assert_object_absent(_bucket(kind), f"staging/{key}") + continue source = (root / "objects" / kind / key).resolve() if root not in source.parents or not source.is_file(): raise ObjectRestoreError(f"Archive object is missing or unsafe: {key}") @@ -40,7 +42,6 @@ def restore_objects(restore, bundle_root, manifest, journal_path): restored.append(rollback) return restored - def _prepare_rollback(restore, item, journal): key = _safe_key(item["key"]) kind = item["bucket_kind"] diff --git a/backend/apps/backup/reservation_registry.py b/backend/apps/backup/reservation_registry.py index 294df3db..4f3c7ebb 100644 --- a/backend/apps/backup/reservation_registry.py +++ b/backend/apps/backup/reservation_registry.py @@ -91,6 +91,7 @@ def _proof(table, column, identity, callable_identity, field_type): ("bookings_bookablespace", "public_token"), ("bookings_booking", "public_token"), ("events_event", "public_token"), + ("events_eventseries", "public_token"), ("events_eventregistration", "checkin_token"), ("hardware_requests_hardwarerequest", "public_token"), ("machines_machineservicerequest", "public_token"), diff --git a/backend/apps/backup/settings_policy.py b/backend/apps/backup/settings_policy.py index 143a5f66..195b1cd1 100644 --- a/backend/apps/backup/settings_policy.py +++ b/backend/apps/backup/settings_policy.py @@ -50,7 +50,13 @@ class SettingPolicy: DEVICE_ATTESTATION_CHALLENGE_TTL_SECONDS DEVICE_ATTESTATION_PROVIDER_TIMEOUT_SECONDS DISABLE_SERVER_SIDE_CURSORS DOMAIN_CHANGE_COOLDOWN_SECONDS EMAIL_BACKEND EMAIL_HOST EMAIL_HOST_PASSWORD EMAIL_HOST_USER EMAIL_PORT EMAIL_USE_TLS ENABLE_HTTPS -EVIDENCE_MAX_BYTES EVIDENCE_URL_TTL_SECONDS GITHUB_API_TOKEN HMAC_CLIENT_ID +EVENT_CHECKIN_CLOCK_SKEW_SECONDS EVENT_CHECKIN_ROSTER_LIFETIME_HOURS +EVENT_CHECKIN_ROSTER_MAX EVENT_CHECKIN_SYNC_GRACE_HOURS +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 +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 MACHINE_DOC_ALLOWED_EXT MACHINE_DOC_ALLOWED_MIME MACHINE_DOC_MAX_BYTES MANAGED_POSTGRES MEMBER_CLAIM_CODE_TTL_SECONDS MEMBER_CLAIM_SESSION_TTL_SECONDS OIDC_ATTEMPT_TTL_SECONDS @@ -70,6 +76,10 @@ class SettingPolicy: TELEGRAM_WEBHOOK_SECRET THROTTLE_ANONYMOUS_REQUEST_EMAIL THROTTLE_ANONYMOUS_REQUEST_IP_BURST THROTTLE_ANONYMOUS_REQUEST_IP_HOUR THROTTLE_ARCHIVE_RECIPIENT_VERIFY THROTTLE_BOOKING_SUBMIT THROTTLE_CLIENT_PUBLIC +THROTTLE_EVENT_CALENDAR_FEED_IP THROTTLE_EVENT_CALENDAR_FEED_TOKEN +THROTTLE_EVENT_OFFLINE_ROSTER THROTTLE_EVENT_OFFLINE_SYNC +THROTTLE_EVENT_STATION_PIN_IP THROTTLE_EVENT_STATION_PIN_TOKEN +THROTTLE_EVENT_STATION_REVEAL THROTTLE_EVENT_STATION_SESSION THROTTLE_CLIENT_STANDARD THROTTLE_CLIENT_TRUSTED THROTTLE_DATA_EXPORT_CREATE THROTTLE_DEVICE_ATTESTATION_CHALLENGE THROTTLE_DEVICE_LOGIN THROTTLE_DEVICE_LOGIN_USER THROTTLE_DEVICE_REFRESH THROTTLE_EMAIL_VERIFICATION_CONFIRM @@ -105,6 +115,7 @@ class SettingPolicy: "AUDIT_MAC_MASTER_KEY", "AUDIT_ATTESTATION_HTTP_BEARER_TOKEN", "AUDIT_ATTESTATION_S3_SECRET_ACCESS_KEY", + "EVENT_STATION_PIN_PEPPER", }) CAPABILITY = frozenset({ "DATABASE_URL", "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", diff --git a/backend/apps/backup/slice_merge_validation.py b/backend/apps/backup/slice_merge_validation.py index 51440891..a57a2e29 100644 --- a/backend/apps/backup/slice_merge_validation.py +++ b/backend/apps/backup/slice_merge_validation.py @@ -171,9 +171,11 @@ def _validate_objects(root, objects): "bucket_kind", "key", "version_id", "size", "sha256", "metadata", "content_type", "headers", } - if not required.issubset(item) or set(item) - required > { - "makerspace_id", "module_key", - }: + optional = { + "makerspace_id", "module_key", "retention_state", + "object_expired_at", "expired_size_bytes", + } + if not required.issubset(item) or set(item) - required > optional: raise SliceMergeError("The slice object manifest is malformed.") key = PurePosixPath(str(item["key"])) if ( @@ -186,6 +188,19 @@ def _validate_objects(root, objects): raise SliceMergeError("The slice object manifest contains a duplicate object.") seen.add(identity) path = root.joinpath(item["bucket_kind"], *key.parts) + if item.get("retention_state") == "expired": + valid = ( + isinstance(item.get("object_expired_at"), str) + and bool(item["object_expired_at"]) + and item["size"] == 0 + and item["sha256"] == "" + and not path.exists() + ) + if not valid: + raise SliceMergeError( + "A slice expiry tombstone is malformed or has bytes." + ) + continue try: valid = path.is_file() and path.stat().st_size == item["size"] and sha256_file(path) == item["sha256"] except OSError: diff --git a/backend/apps/backup/slice_verification.py b/backend/apps/backup/slice_verification.py index 09c0f3fa..db317be5 100644 --- a/backend/apps/backup/slice_verification.py +++ b/backend/apps/backup/slice_verification.py @@ -97,6 +97,12 @@ def _verify_json(path, expected, label): def _verify_objects(root, manifest): for item in manifest: path = root / item["bucket_kind"] / item["key"] + if item.get("retention_state") == "expired": + if path.exists() or item.get("size") != 0 or item.get("sha256") != "": + raise BackupBuildError( + "Sovereign slice expiry tombstone verification failed." + ) + continue try: size = path.stat().st_size digest = sha256_file(path) diff --git a/backend/apps/backup/storage.py b/backend/apps/backup/storage.py index b17691dc..41ec3c36 100644 --- a/backend/apps/backup/storage.py +++ b/backend/apps/backup/storage.py @@ -11,7 +11,6 @@ from apps.object_storage import delete_all_versions logger = logging.getLogger(__name__) - # Shared by every uploader: apps.*.storage.staging_key() is f"staging/{final_key}". STAGING_PREFIX = "staging/" @@ -28,7 +27,6 @@ class BackupStorageError(RuntimeError): class BackupVerificationError(BackupStorageError): pass - def client(*, public_endpoint=False): endpoint = settings.AWS_S3_PUBLIC_ENDPOINT_URL if public_endpoint else settings.AWS_S3_ENDPOINT_URL return boto3.client( @@ -189,6 +187,52 @@ def download_object(bucket, key, destination, *, versioned): return {**staged, "key": key} +def assert_object_absent(bucket, key): + """Fail closed if a terminal retention tombstone still has any stored bytes.""" + s3 = client() + try: + page = s3.list_object_versions(Bucket=bucket, Prefix=key) + while True: + for group in (page.get("Versions", ()), page.get("DeleteMarkers", ())): + if any(item.get("Key") == key for item in group): + raise BackupStorageError( + f"Expired storage object {key} still has retained versions." + ) + if not page.get("IsTruncated"): + break + params = { + "Bucket": bucket, + "Prefix": key, + "KeyMarker": page.get("NextKeyMarker"), + "VersionIdMarker": page.get("NextVersionIdMarker"), + } + page = s3.list_object_versions( + **{name: value for name, value in params.items() if value is not None} + ) + except ClientError as exc: + raise BackupStorageError( + f"Expired storage object {key} could not be inspected." + ) from exc + except BotoCoreError as exc: + raise BackupStorageError( + f"Expired storage object {key} could not be inspected." + ) from exc + try: + s3.head_object(Bucket=bucket, Key=key) + except ClientError as exc: + code = exc.response.get("Error", {}).get("Code") + status = exc.response.get("ResponseMetadata", {}).get("HTTPStatusCode") + if code in {"404", "NoSuchKey", "NotFound"} or status == 404: + return + raise BackupStorageError( + f"Expired storage object {key} could not be inspected." + ) from exc + except BotoCoreError as exc: + raise BackupStorageError( + f"Expired storage object {key} could not be inspected." + ) from exc + raise BackupStorageError(f"Expired storage object {key} is still present.") + def _download_object(bucket, key, destination, *, versioned): s3 = client() params = {"Bucket": bucket, "Key": key} diff --git a/backend/apps/data_export/classification.py b/backend/apps/data_export/classification.py index 1f7691b5..7a0ca657 100644 --- a/backend/apps/data_export/classification.py +++ b/backend/apps/data_export/classification.py @@ -17,10 +17,18 @@ "boxes.BoxScan": "id makerspace box request actor context created_at", "boxes.QrCode": "id makerspace payload target_type target_id status created_by revoked_at created_at updated_at", "boxes.QrScanEvent": "id makerspace qr_code request actor context created_at", - "events.Event": "id public_token makerspace title description starts_at ends_at location location_kind custom_form capacity payment_amount is_public image_key status created_by created_at updated_at", - "events.EventCollaborator": "id event makerspace status invited_by responded_by created_at responded_at", - "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 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.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", + "events.EventFeedbackSurvey": "id event title thank_you_text questions is_open certificate_enabled answered_question_ids opened_at closed_at created_at updated_at", + "events.EventFeedbackResponse": "id survey registration answers_snapshot certificate_requested created_at", + "events.EventAttendanceCertificate": "id response registration serial revision recipient_name event_title event_starts_at event_ends_at issuer_name object_key content_type size_bytes sha256 status issued_at rendered_at revoked_at revoked_by revocation_reason", "evidence.EvidencePhoto": "id makerspace evidence_type object_key content_type size_bytes uploaded_by created_at", + "evidence.EvidenceObjectRetentionState": "id evidence status claim_token claimed_at object_expired_at expired_size_bytes last_error updated_at", + "evidence.EvidenceRetentionPolicy": "id makerspace object_retention_days updated_at", "hardware_requests.HardwareRequest": "id makerspace requester requester_username requester_name requester_contact_email requester_contact_phone requester_contact_verified anonymous_idempotency_key_fingerprint anonymous_payload_fingerprint status requested_for rejection_reason accepted_by accepted_at assigned_box issued_by issued_at return_due_at return_reminder_sent_at issue_evidence issue_remark closed_by closed_at public_token created_at updated_at", "hardware_requests.HardwareRequestItem": "id request product requested_quantity accepted_quantity issued_quantity returned_quantity damaged_quantity missing_quantity needs_fix_quantity", "hardware_requests.HardwareRequestItemAsset": "id request_item asset outcome issued_at returned_at return_event", @@ -75,6 +83,7 @@ "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.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", @@ -102,6 +111,12 @@ } OMITTED_MODELS = { + "events.EventCheckInStationCredential": ( + "Live event-station authentication authority; a restored tenant must rotate a new PIN." + ), + "events.MemberCalendarFeed": ( + "Deployment-local bearer credential over member registration history; restored tenants must reissue it." + ), "apiclients.ApiClientImportApproval": "Artifact-bound target authority approval is deployment-local coordination state.", "accounts.DailyOtpEmailCounter": "Platform authentication telemetry.", "accounts.DeviceAttestationChallenge": "Transient authentication state.", @@ -169,16 +184,24 @@ "makerspaces.ImportedUserReconciliation": "Target-side operator reconciliation input.", "makerspaces.SubdomainRequest": "Source-deployment routing request.", "operations.PeriodicTaskRun": "Deployment scheduler state.", + "operations.ReportRollupCursor": "Rebuildable report rollup coordination and retention-fence state.", "events.EventOrganizer": ( "It references a deployment-global organization that does not travel with " "a tenant export." ), + "events.EventSeriesOrganizer": ( + "It references a deployment-global organization that does not travel with " + "a tenant export." + ), "organizations.OrganizationMakerspace": ( "Source-deployment organization links do not travel with tenant exports." ), "organizations.OrganizationMembership": ( "Live cross-tenant organization authorization." ), + "organizations.OrganizationInvitation": ( + "Deployment-local bearer authorization state for a global organization." + ), "tenant_migration.TenantImportJob": "Target-side tenant import coordination state.", "tenant_migration.TenantImportObject": ( "Deployment-scoped import promotion journal; it names a target makerspace " diff --git a/backend/apps/data_export/datasets.py b/backend/apps/data_export/datasets.py index 1d550e4a..3653f5bf 100644 --- a/backend/apps/data_export/datasets.py +++ b/backend/apps/data_export/datasets.py @@ -25,9 +25,23 @@ "boxes.QrCode": ("inventory/qr_mappings.csv", P(("makerspace",))), "boxes.QrScanEvent": ("lending/qr_scan_events.csv", P(("makerspace",), ("qr_code__makerspace",))), "events.Event": ("events/events.csv", P(("makerspace",))), + "events.EventSeries": ("events/series.csv", P(("makerspace",))), + "events.EventSeriesCollaborator": ("events/series_collaborators.csv", P(("series__makerspace", "makerspace"))), "events.EventCollaborator": ("events/collaborators.csv", P(("event__makerspace", "makerspace"))), "events.EventRegistration": ("events/registrations.csv", P(("event__makerspace",))), + "events.EventCheckInEvent": ("events/check_in_history.csv", P(("makerspace",))), + "events.EventFeedbackSurvey": ("events/feedback_surveys.csv", P(("event__makerspace",))), + "events.EventFeedbackResponse": ("events/feedback_responses.csv", P(("survey__event__makerspace",))), + "events.EventAttendanceCertificate": ("events/attendance_certificates.csv", P(("registration__event__makerspace",))), "evidence.EvidencePhoto": ("evidence/photos.csv", P(("makerspace",))), + "evidence.EvidenceObjectRetentionState": ( + "evidence/object_retention.csv", + P(("evidence__makerspace",)), + ), + "evidence.EvidenceRetentionPolicy": ( + "evidence/retention_policy.csv", + P(("makerspace",)), + ), "hardware_requests.HardwareRequest": ("lending/requests.csv", P(("makerspace",))), "hardware_requests.HardwareRequestItem": ("lending/request_items.csv", P(("request__makerspace",))), "hardware_requests.HardwareRequestItemAsset": ("lending/request_item_assets.csv", P(("request_item__request__makerspace",))), @@ -82,6 +96,7 @@ "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.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",))), @@ -122,6 +137,10 @@ def _columns(fidelity, label): DATASETS = {} +_MODEL_KEYSETS = { + "evidence.EvidenceObjectRetentionState": ("evidence_id",), + "evidence.EvidenceRetentionPolicy": ("makerspace_id",), +} for _fidelity in Fidelity: for _label, (_path, _predicate) in DATASET_SPECS.items(): _columns_for_dataset, _omissions = _columns(_fidelity, _label) @@ -130,7 +149,7 @@ def _columns(fidelity, label): path=_path, model=_label, predicate=_predicate, - keyset=("id",), + keyset=_MODEL_KEYSETS.get(_label, ("id",)), columns=_columns_for_dataset, explicit_omissions=_omissions, ) diff --git a/backend/apps/data_export/external_refs.py b/backend/apps/data_export/external_refs.py index 5ce38f2d..c95207fc 100644 --- a/backend/apps/data_export/external_refs.py +++ b/backend/apps/data_export/external_refs.py @@ -16,6 +16,7 @@ ("operations.StockTransfer", "destination_container"), }) _EVENT_EDGES = frozenset({("events.EventCollaborator", "event")}) +_SERIES_EDGES = frozenset({("events.EventSeriesCollaborator", "series")}) def select_related_paths(model_label): @@ -32,6 +33,8 @@ def select_related_paths(model_label): # The hosted-collaboration anchor is the local Event, so it is needed even # when the projected field is the collaborator's own foreign makerspace. paths.add("event") + if model_label == "events.EventSeriesCollaborator": + paths.add("series") paths.update(closure_paths(model_label, MOVABLE_ROW_REFERENCES)) return paths @@ -148,7 +151,7 @@ def withhold_identity(self, row, field_name): def _owner_makerspace_id(edge, related): - if edge in _CONTAINER_EDGES or edge in _EVENT_EDGES: + if edge in _CONTAINER_EDGES or edge in _EVENT_EDGES or edge in _SERIES_EDGES: return related.makerspace_id return related.pk @@ -160,6 +163,8 @@ def _snapshot(edge, related): "starts_at": related.starts_at.isoformat(), "ends_at": related.ends_at.isoformat(), } + if edge in _SERIES_EDGES: + return {"title": related.title} if edge in _CONTAINER_EDGES: return { "label": related.label, @@ -179,4 +184,6 @@ def _anchor(row, edge): # it belongs to is the Event. Several foreign collaborators of one event all # anchor here, which is why the anchor index is not unique. return row.event._meta.label, str(row.event_id) + if edge == ("events.EventSeriesCollaborator", "makerspace"): + return row.series._meta.label, str(row.series_id) return row._meta.label, str(row.pk) diff --git a/backend/apps/data_export/fields.py b/backend/apps/data_export/fields.py index 79de4c48..665f28aa 100644 --- a/backend/apps/data_export/fields.py +++ b/backend/apps/data_export/fields.py @@ -47,9 +47,16 @@ ("bookings.BookableSpace", "public_token"): "Source bearer/status token.", ("bookings.Booking", "public_token"): "Source bearer/status token.", ("events.Event", "public_token"): "Source bearer/status token.", + ("events.EventSeries", "public_token"): "Source bearer/status token.", ("events.EventRegistration", "checkin_token"): "Source check-in bearer token.", ("events.EventRegistration", "email_exact_hash"): "Deployment-local blind index.", ("events.EventRegistration", "email_hash_generation"): "Deployment-local key generation.", + ("evidence.EvidenceObjectRetentionState", "claim_token"): ( + "Transient object-expiry claim credential." + ), + ("events.EventCollaborator", "source_series_collaboration"): ( + "Projection provenance is rebuilt from the canonical series collaboration." + ), ("hardware_requests.HardwareRequest", "public_token"): "Source bearer/status token.", ("integrations.NotificationDestination", "webhook_url"): "Encrypted webhook credential.", ("machines.Machine", "camera_feed_url"): "May embed camera credentials.", @@ -106,6 +113,8 @@ } EXTERNAL_REFERENCES = { + ("events.EventSeriesCollaborator", "series"), + ("events.EventSeriesCollaborator", "makerspace"), ("events.EventCollaborator", "event"), # Both directions of a collaboration are exported (the predicate matches on # `event__makerspace` OR `makerspace`), and each direction has a different foreign diff --git a/backend/apps/data_export/guards.py b/backend/apps/data_export/guards.py index 910e087d..3ec2a483 100644 --- a/backend/apps/data_export/guards.py +++ b/backend/apps/data_export/guards.py @@ -225,6 +225,16 @@ def validate_user_edges(user_edges=USER_EDGES): _equal(f"{fidelity} user-edge decisions", declared, expected) +# JSON fields that hold NO object or user references and therefore have no reference +# schema. Declared here rather than inline so that exempting a field stays a visible, +# reviewable act: the guard below still demands EXACT equality, so any JSON field that is +# neither declared in JSON_FIELDS nor listed here still fails discovery. +NON_REFERENCE_JSON_FIELDS = frozenset({ + # Controlled metric dimension labels (module key, period, report key) -- never an id. + ("operations.ReportMetricRollup", "dimensions"), +}) + + def validate_semantic_references(semantic_references=SEMANTIC_REFERENCES): actual_polymorphic = set() actual_json = set() @@ -237,7 +247,11 @@ def validate_semantic_references(semantic_references=SEMANTIC_REFERENCES): if isinstance(field, models.JSONField): actual_json.add((label, field.name)) _equal("polymorphic reference pairs", actual_polymorphic, set(POLYMORPHIC_PAIRS)) - _equal("JSON reference schemas", actual_json, set(JSON_FIELDS)) + _equal( + "JSON reference schemas", + actual_json, + set(JSON_FIELDS) | NON_REFERENCE_JSON_FIELDS, + ) for fidelity in Fidelity: expected = { (label, location) for label, location in POLYMORPHIC_PAIRS diff --git a/backend/apps/data_export/references.py b/backend/apps/data_export/references.py index 7350f7d9..419da68a 100644 --- a/backend/apps/data_export/references.py +++ b/backend/apps/data_export/references.py @@ -1,7 +1,7 @@ """Relational and semantic reference registries for the global User closure.""" - from .fields import FIELDS from .models import EXPORTED_MODELS +from .references_json_fields import JSON_REFERENCE_FIELDS from .types import ( Fidelity, Omitted, @@ -9,12 +9,8 @@ SourceLocalProvenance, UserEdge, ) - - class DanglingUserReferenceError(RuntimeError): """A raw user ID cannot be bound safely in a portable archive.""" - - def require_raw_user(fidelity, *, model, row_pk, field, user_id, existing_user_ids): """Enforce the declared no-dangling contract before a raw ID is remapped.""" if fidelity is Fidelity.PORTABLE and user_id not in existing_user_ids: @@ -22,7 +18,6 @@ def require_raw_user(fidelity, *, model, row_pk, field, user_id, existing_user_i f"{model} row {row_pk} has dangling {field}={user_id}" ) return user_id - RAW_USER_REFERENCE_FIELDS = frozenset( # Raw integers are not discoverable as FKs. { ("encryption.PiiGlobalWriteFence", "actor_id"), @@ -30,7 +25,6 @@ def require_raw_user(fidelity, *, model, row_pk, field, user_id, existing_user_i ("machines.ServiceRequestFile", "owner_user_id"), } ) - # Every forward relation to accounts.User in the internal model graph, including M2M. RELATIONAL_USER_FIELDS = frozenset( { @@ -62,10 +56,16 @@ def require_raw_user(fidelity, *, model, row_pk, field, user_id, existing_user_i ("boxes.QrCode", "created_by"), ("boxes.QrScanEvent", "actor"), ("events.Event", "created_by"), + ("events.EventSeries", "created_by"), + ("events.EventSeriesCollaborator", "invited_by"), + ("events.EventSeriesCollaborator", "responded_by"), + ("events.EventSeriesOrganizer", "created_by"), ("events.EventCollaborator", "invited_by"), ("events.EventCollaborator", "responded_by"), ("events.EventOrganizer", "created_by"), ("events.EventRegistration", "member"), + ("events.EventCheckInEvent", "actor"), + ("events.EventAttendanceCertificate", "revoked_by"), ("evidence.EvidencePhoto", "uploaded_by"), ("hardware_requests.HardwareRequest", "requester"), ("hardware_requests.HardwareRequest", "accepted_by"), @@ -151,6 +151,8 @@ def require_raw_user(fidelity, *, model, row_pk, field, user_id, existing_user_i ("organizations.OrganizationMakerspace", "created_by"), ("organizations.OrganizationMembership", "user"), ("organizations.OrganizationMembership", "created_by"), + ("organizations.OrganizationInvitation", "created_by"), + ("organizations.OrganizationInvitation", "redeemed_by"), ("payments.Payment", "member"), ("payments.Payment", "created_by"), ("payments.StripeConnectOAuthState", "initiated_by"), @@ -201,43 +203,7 @@ def require_raw_user(fidelity, *, model, row_pk, field, user_id, existing_user_i } ) -JSON_FIELDS = frozenset( - { - ("apiclients.ApiClient", "scopes"), - ("apiclients.ApiClient", "allowed_origins"), - ("apiclients.ApiKeyRequest", "allowed_origins"), - ("audit.AuditLog", "meta"), - # Phase 7 imported-actor provenance. Each holds actor_username, - # actor_display, source_user_id and recorded_at. - ("makerspaces.MakerspaceMembership", "witnessed_actor_snapshot"), - ("makerspaces.MakerspaceMembership", "verified_actor_snapshot"), - ("makerspaces.MakerspaceMembership", "activated_actor_snapshot"), - ("makerspaces.MakerspaceMembership", "revoked_actor_snapshot"), - ("bookings.BookableSpace", "custom_form"), - ("bookings.Booking", "custom_answers"), - ("events.Event", "custom_form"), - ("events.EventRegistration", "custom_answers"), - ("hardware_requests.PublicToolLoan", "asset_ids"), - ("hardware_requests.PublicToolLoan", "qr_ids"), - ("machines.Machine", "service_file_policy"), - ("machines.Machine", "type_payload"), - ("machines.MachineServiceRequest", "capability_payload"), - ("machines.MachineType", "capability_config"), - ("makerspaces.Makerspace", "cors_allowed_origins"), - ("makerspaces.Makerspace", "enabled_modules"), - ("makerspaces.Makerspace", "enabled_features"), - ("makerspaces.Makerspace", "resource_limit_overrides"), - ("makerspaces.Makerspace", "theme_config"), - ("makerspaces.Makerspace", "branding_config"), - ("makerspaces.Makerspace", "presence_preset_minutes"), - ("makerspaces.MakerspaceRole", "granted_actions"), - ("makerspaces.MemberProfile", "interests"), - ("makerspaces.MemberProfile", "languages"), - ("makerspaces.MemberProfile", "education"), - ("makerspaces.MemberProject", "links"), - ("tenant_migration.ExternalTenantReference", "snapshot"), - } -) +JSON_FIELDS = JSON_REFERENCE_FIELDS SEMANTIC_REFERENCES = {} for _fidelity in Fidelity: diff --git a/backend/apps/data_export/references_json_fields.py b/backend/apps/data_export/references_json_fields.py new file mode 100644 index 00000000..6d658652 --- /dev/null +++ b/backend/apps/data_export/references_json_fields.py @@ -0,0 +1,42 @@ +"""Reviewed JSON fields whose embedded reference semantics need declarations.""" + +JSON_REFERENCE_FIELDS = frozenset( + { + ("apiclients.ApiClient", "scopes"), + ("apiclients.ApiClient", "allowed_origins"), + ("apiclients.ApiKeyRequest", "allowed_origins"), + ("audit.AuditLog", "meta"), + ("makerspaces.MakerspaceMembership", "witnessed_actor_snapshot"), + ("makerspaces.MakerspaceMembership", "verified_actor_snapshot"), + ("makerspaces.MakerspaceMembership", "activated_actor_snapshot"), + ("makerspaces.MakerspaceMembership", "revoked_actor_snapshot"), + ("bookings.BookableSpace", "custom_form"), + ("bookings.Booking", "custom_answers"), + ("events.Event", "custom_form"), + ("events.Event", "badge_template"), + ("events.Event", "series_override_fields"), + ("events.EventSeries", "custom_form"), + ("events.EventRegistration", "custom_answers"), + ("events.EventFeedbackSurvey", "questions"), + ("events.EventFeedbackSurvey", "answered_question_ids"), + ("hardware_requests.PublicToolLoan", "asset_ids"), + ("hardware_requests.PublicToolLoan", "qr_ids"), + ("machines.Machine", "service_file_policy"), + ("machines.Machine", "type_payload"), + ("machines.MachineServiceRequest", "capability_payload"), + ("machines.MachineType", "capability_config"), + ("makerspaces.Makerspace", "cors_allowed_origins"), + ("makerspaces.Makerspace", "enabled_modules"), + ("makerspaces.Makerspace", "enabled_features"), + ("makerspaces.Makerspace", "resource_limit_overrides"), + ("makerspaces.Makerspace", "theme_config"), + ("makerspaces.Makerspace", "branding_config"), + ("makerspaces.Makerspace", "presence_preset_minutes"), + ("makerspaces.MakerspaceRole", "granted_actions"), + ("makerspaces.MemberProfile", "interests"), + ("makerspaces.MemberProfile", "languages"), + ("makerspaces.MemberProfile", "education"), + ("makerspaces.MemberProject", "links"), + ("tenant_migration.ExternalTenantReference", "snapshot"), + } +) diff --git a/backend/apps/data_export/traversals.py b/backend/apps/data_export/traversals.py index ebb9cc85..1a63a734 100644 --- a/backend/apps/data_export/traversals.py +++ b/backend/apps/data_export/traversals.py @@ -19,5 +19,6 @@ ("operations.StockTransfer", "source_container"), ("operations.StockTransfer", "destination_container"), ("events.EventCollaborator", "event"), + ("events.EventSeriesCollaborator", "series"), } ) diff --git a/backend/apps/encryption/registry.py b/backend/apps/encryption/registry.py index 15afdf6a..50c66f3a 100644 --- a/backend/apps/encryption/registry.py +++ b/backend/apps/encryption/registry.py @@ -23,6 +23,8 @@ def _fields(label, names, path, classification, indexes): SOURCE_FIELDS = ( *_fields("hardware_requests.HardwareRequest", ("requester_username", "requester_name", "requester_contact_email", "requester_contact_phone"), "makerspace_id", "source", ((150, 200, 254, 32), ("none", "bloom", "bloom_exact", "none"))), *_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("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 58ade596..f2a1f66b 100644 --- a/backend/apps/events/admin.py +++ b/backend/apps/events/admin.py @@ -3,7 +3,7 @@ from apps.accounts import rbac from apps.audit import services as audit -from apps.events.models import Event, EventOrganizer +from apps.events.models import Event, EventOrganizer, EventSeries, EventSeriesOrganizer from apps.separability.tombstones import app_is_tombstoned from config.admin_access import SuperuserOnlyModelAdmin @@ -19,6 +19,18 @@ class EventOrganizerAdmin(SuperuserOnlyModelAdmin, ModelAdmin): ) readonly_fields = ("created_by", "created_at") + # Organizer mutations now have one transaction boundary in + # services_organizers.replace_organizers. Keeping the old per-row admin writer would + # bypass its event/module locks and its single replacement audit record. + 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 + def resolve_hidden_lookup(self): return "event__makerspace_id" @@ -29,43 +41,68 @@ def formfield_for_foreignkey(self, db_field, request, **kwargs): ) return super().formfield_for_foreignkey(db_field, request, **kwargs) + +# NOTE: the events side also re-added save_model/delete_model to EventOrganizerAdmin. +# They are dropped here: phase 7 turned that admin read-only on purpose, so those +# methods are unreachable and would only mislead a future reader. +class EventSeriesOrganizerAdmin(SuperuserOnlyModelAdmin, ModelAdmin): + list_display = ("series", "organization", "created_by", "created_at") + list_filter = ("series__makerspace", "organization") + search_fields = ("series__title", "series__makerspace__name", "organization__name") + readonly_fields = ("created_by", "created_at") + + def resolve_hidden_lookup(self): + return "series__makerspace_id" + + def get_readonly_fields(self, request, obj=None): + fields = super().get_readonly_fields(request, obj) + return (*fields, "series", "organization") if obj else fields + + def formfield_for_foreignkey(self, db_field, request, **kwargs): + if db_field.name == "series": + kwargs["queryset"] = EventSeries.objects.exclude( + makerspace_id__in=rbac.superadmin_hidden_makerspace_ids() + ) + return super().formfield_for_foreignkey(db_field, request, **kwargs) + 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.organizer_updated" if change else "event.organizer_created", - makerspace=obj.event.makerspace, + "event.series_organizer_created" if not change else "event.series_organizer_updated", + makerspace=obj.series.makerspace, target=obj, - meta={ - "event_id": obj.event_id, - "organization_slug": obj.organization.slug, - }, + meta={"series_id": obj.series_id, "organization_slug": obj.organization.slug}, ) def delete_model(self, request, obj): - self._record_deletion(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) def delete_queryset(self, request, queryset): - for obj in queryset.select_related("event__makerspace", "organization"): - self._record_deletion(request, obj) + 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) - @staticmethod - def _record_deletion(request, obj): - audit.record( - request.user, - "event.organizer_deleted", - makerspace=obj.event.makerspace, - target=obj, - meta={ - "event_id": obj.event_id, - "organization_slug": obj.organization.slug, - }, - ) - if not app_is_tombstoned("events"): admin.site.register(EventOrganizer, EventOrganizerAdmin) + admin.site.register(EventSeriesOrganizer, EventSeriesOrganizerAdmin) diff --git a/backend/apps/events/apps.py b/backend/apps/events/apps.py index a09662a2..4e9c3de4 100644 --- a/backend/apps/events/apps.py +++ b/backend/apps/events/apps.py @@ -6,6 +6,7 @@ class EventsConfig(AppConfig): name = "apps.events" def ready(self): + from apps.events import station_auth # noqa: F401 from apps.separability.tombstones import register_separable_app register_separable_app("events") diff --git a/backend/apps/events/badge_rendering.py b/backend/apps/events/badge_rendering.py new file mode 100644 index 00000000..ecd2b8ec --- /dev/null +++ b/backend/apps/events/badge_rendering.py @@ -0,0 +1,114 @@ +from io import BytesIO +from pathlib import Path + +import segno + +from apps.events.badge_templates import page_layout + + +def _register_fonts(): + import reportlab + from reportlab.pdfbase import pdfmetrics + from reportlab.pdfbase.ttfonts import TTFont + + fonts = Path(reportlab.__file__).parent / "fonts" + if "BadgeVera" not in pdfmetrics.getRegisteredFontNames(): + pdfmetrics.registerFont(TTFont("BadgeVera", fonts / "Vera.ttf")) + pdfmetrics.registerFont(TTFont("BadgeVeraBold", fonts / "VeraBd.ttf")) + + +def _fit_text(value, font_name, font_size, width): + from reportlab.pdfbase import pdfmetrics + + text = " ".join(value.split()) + if pdfmetrics.stringWidth(text, font_name, font_size) <= width: + return text + suffix = "..." + while text and pdfmetrics.stringWidth(text + suffix, font_name, font_size) > width: + text = text[:-1] + return text.rstrip() + suffix + + +def _qr_image(payload): + from reportlab.lib.utils import ImageReader + + stream = BytesIO() + segno.make(payload, error="M").save(stream, kind="png", scale=6, border=2) + stream.seek(0) + return ImageReader(stream), stream + + +def _draw_badge(canvas, snapshot, template, x, y, width, height): + from reportlab.lib.colors import HexColor + from reportlab.lib.units import mm + + padding = 4 * mm + canvas.setStrokeColor(HexColor("#CBD5E1")) + canvas.setFillColor(HexColor("#FFFFFF")) + canvas.roundRect(x, y, width, height, 2.5 * mm, stroke=1, fill=1) + qr_width = min(30 * mm, height - 2 * padding) if template["include_qr"] else 0 + text_width = width - 2 * padding - (qr_width + 3 * mm if qr_width else 0) + text_x = x + padding + cursor = y + height - padding + align = template["text_align"] + for index, (label, value) in enumerate(snapshot.fields): + is_name = index == 0 and template["fields"][0] == "name" + font = "BadgeVeraBold" if is_name else "BadgeVera" + size = template["name_font_size_pt"] if is_name else template["font_size_pt"] + label_size = max(6, template["font_size_pt"] - 2) + if not is_name: + cursor -= label_size + 1 + canvas.setFillColor(HexColor("#64748B")) + canvas.setFont("BadgeVeraBold", label_size) + label_text = _fit_text(label.upper(), "BadgeVeraBold", label_size, text_width) + if align == "center": + canvas.drawCentredString(text_x + text_width / 2, cursor, label_text) + else: + canvas.drawString(text_x, cursor, label_text) + cursor -= size + 2 + if cursor < y + padding: + break + canvas.setFillColor(HexColor("#0F172A")) + canvas.setFont(font, size) + fitted = _fit_text(value or "-", font, size, text_width) + if align == "center": + canvas.drawCentredString(text_x + text_width / 2, cursor, fitted) + else: + canvas.drawString(text_x, cursor, fitted) + cursor -= 3 + if qr_width: + image, stream = _qr_image(snapshot.checkin_token) + canvas.drawImage( + image, x + width - padding - qr_width, y + (height - qr_width) / 2, + width=qr_width, height=qr_width, preserveAspectRatio=True, mask="auto", + ) + stream.close() + + +def render_badges_pdf(template, snapshots, *, title): + from reportlab.lib.units import mm + from reportlab.pdfgen.canvas import Canvas + + _register_fonts() + page_width_mm, page_height_mm, columns, rows = page_layout(template) + page_size = (page_width_mm * mm, page_height_mm * mm) + card_width = template["card_width_mm"] * mm + card_height = template["card_height_mm"] * mm + margin = template["margin_mm"] * mm + gap = template["gap_mm"] * mm + output = BytesIO() + canvas = Canvas(output, pagesize=page_size, 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 = slot % columns + row = slot // columns + x = margin + column * (card_width + gap) + y = page_size[1] - margin - (row + 1) * card_height - row * gap + _draw_badge(canvas, snapshot, template, x, y, card_width, card_height) + canvas.showPage() + canvas.save() + return output.getvalue() diff --git a/backend/apps/events/badge_templates.py b/backend/apps/events/badge_templates.py new file mode 100644 index 00000000..7a993291 --- /dev/null +++ b/backend/apps/events/badge_templates.py @@ -0,0 +1,117 @@ +from math import floor + +from rest_framework import serializers + + +DEFAULT_BADGE_TEMPLATE = { + "version": 1, + "paper_size": "A4", + "orientation": "portrait", + "page_width_mm": None, + "page_height_mm": None, + "card_width_mm": 90.0, + "card_height_mm": 55.0, + "margin_mm": 10.0, + "gap_mm": 5.0, + "template": "standard", + "fields": ["name", "event_title", "date_time", "location", "registration_number"], + "font_size_pt": 9, + "name_font_size_pt": 15, + "text_align": "left", + "include_qr": True, +} +SAFE_FIELDS = frozenset({ + "name", "event_title", "date_time", "location", "registration_number", +}) +SENSITIVE_FIELDS = frozenset({"email", "phone"}) +MAX_BADGES = 200 +MAX_PAGES = 50 +MAX_TEXT_LENGTH = 500 + + +def _page_dimensions(template): + sizes = {"A4": (210.0, 297.0), "LETTER": (215.9, 279.4)} + if template["paper_size"] == "custom": + width, height = template["page_width_mm"], template["page_height_mm"] + else: + width, height = sizes[template["paper_size"]] + if template["orientation"] == "landscape": + width, height = height, width + return width, height + + +def _custom_ids(event): + return {str(question["id"]) for question in (event.custom_form or [])} + + +def normalize_badge_template(value, event): + if value in (None, {}): + value = DEFAULT_BADGE_TEMPLATE + if not isinstance(value, dict): + raise serializers.ValidationError({"badge_template": "Expected an object."}) + unknown = set(value) - set(DEFAULT_BADGE_TEMPLATE) + if unknown: + raise serializers.ValidationError({key: "Unknown template field." for key in unknown}) + template = {**DEFAULT_BADGE_TEMPLATE, **value} + if template["version"] != 1: + raise serializers.ValidationError({"version": "Only badge template version 1 is supported."}) + if template["paper_size"] not in {"A4", "LETTER", "custom"}: + raise serializers.ValidationError({"paper_size": "Use A4, LETTER, or custom."}) + if template["orientation"] not in {"portrait", "landscape"}: + raise serializers.ValidationError({"orientation": "Use portrait or landscape."}) + if template["template"] != "standard": + raise serializers.ValidationError({"template": "Unknown badge template."}) + if template["text_align"] not in {"left", "center"}: + raise serializers.ValidationError({"text_align": "Use left or center."}) + if type(template["include_qr"]) is not bool: + raise serializers.ValidationError({"include_qr": "Expected a boolean."}) + numeric_bounds = { + "card_width_mm": (40, 150), "card_height_mm": (30, 120), + "margin_mm": (0, 40), "gap_mm": (0, 30), + "font_size_pt": (6, 18), "name_font_size_pt": (8, 28), + } + if template["paper_size"] == "custom": + numeric_bounds.update({"page_width_mm": (100, 500), "page_height_mm": (100, 500)}) + for field, (minimum, maximum) in numeric_bounds.items(): + value = template[field] + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise serializers.ValidationError({field: "Expected a number."}) + if not minimum <= value <= maximum: + raise serializers.ValidationError({field: f"Must be between {minimum} and {maximum}."}) + template[field] = float(value) + fields = template["fields"] + if not isinstance(fields, list) or not fields or len(fields) > 12: + raise serializers.ValidationError({"fields": "Choose between 1 and 12 fields."}) + if len(fields) != len(set(fields)) or any(not isinstance(item, str) for item in fields): + raise serializers.ValidationError({"fields": "Field selectors must be unique strings."}) + custom_ids = _custom_ids(event) + invalid = [item for item in fields if ( + item not in SAFE_FIELDS | SENSITIVE_FIELDS + and not (item.startswith("custom:") and item[7:] in custom_ids) + )] + if invalid: + raise serializers.ValidationError({"fields": f"Unknown selectors: {', '.join(invalid)}."}) + width, height = _page_dimensions(template) + usable_width = width - 2 * template["margin_mm"] + usable_height = height - 2 * template["margin_mm"] + columns = floor((usable_width + template["gap_mm"]) / ( + template["card_width_mm"] + template["gap_mm"] + )) + rows = floor((usable_height + template["gap_mm"]) / ( + template["card_height_mm"] + template["gap_mm"] + )) + if columns < 1 or rows < 1: + raise serializers.ValidationError({"badge_template": "No badge fits on the configured page."}) + template["fields"] = fields + return template + + +def page_layout(template): + width, height = _page_dimensions(template) + columns = floor((width - 2 * template["margin_mm"] + template["gap_mm"]) / ( + template["card_width_mm"] + template["gap_mm"] + )) + rows = floor((height - 2 * template["margin_mm"] + template["gap_mm"]) / ( + template["card_height_mm"] + template["gap_mm"] + )) + return width, height, columns, rows diff --git a/backend/apps/events/capacity.py b/backend/apps/events/capacity.py index 161fc674..9b83217b 100644 --- a/backend/apps/events/capacity.py +++ b/backend/apps/events/capacity.py @@ -1,4 +1,5 @@ import math +from datetime import timedelta from apps.events.models import EventRegistration @@ -35,7 +36,26 @@ def availability_label(event): return 'Available' +def effective_registration_cutoff(event): + if event.registration_cutoff_at is not None: + return event.registration_cutoff_at + if event.registration_cutoff_lead_minutes is not None: + return event.starts_at - timedelta( + minutes=event.registration_cutoff_lead_minutes + ) + return None + + +def registration_is_open(event, now): + if event.status != event.Status.PUBLISHED or now >= event.ends_at: + return False + cutoff = effective_registration_cutoff(event) + return cutoff is None or now < cutoff + + def fresh_registration_status(event): + if event.registration_requires_approval: + return EventRegistration.Status.PENDING_APPROVAL available = spots_left(event) if available is None or available > 0: return EventRegistration.Status.REGISTERED diff --git a/backend/apps/events/certificate_rendering.py b/backend/apps/events/certificate_rendering.py new file mode 100644 index 00000000..fae35e9e --- /dev/null +++ b/backend/apps/events/certificate_rendering.py @@ -0,0 +1,56 @@ +from io import BytesIO +from pathlib import Path + + +def _register_fonts(): + import reportlab + from reportlab.pdfbase import pdfmetrics + from reportlab.pdfbase.ttfonts import TTFont + + fonts = Path(reportlab.__file__).parent / "fonts" + pdfmetrics.registerFont(TTFont("CertificateVera", fonts / "Vera.ttf")) + pdfmetrics.registerFont(TTFont("CertificateVeraBold", fonts / "VeraBd.ttf")) + +def render_certificate_pdf(certificate): + """Render snapshots with ReportLab's bundled, permissively licensed Vera font.""" + from reportlab.lib.pagesizes import A4, landscape + from reportlab.lib.units import mm + from reportlab.pdfgen.canvas import Canvas + + _register_fonts() + output = BytesIO() + width, height = landscape(A4) + # ``invariant`` removes volatile timestamps/IDs so a retry can prove that an + # already-promoted final object contains the exact intended immutable PDF. + canvas = Canvas( + output, + pagesize=(width, height), + pageCompression=1, + invariant=1, + ) + canvas.setTitle(f"Attendance certificate {certificate.serial}") + canvas.setLineWidth(1.5) + canvas.rect(12 * mm, 12 * mm, width - 24 * mm, height - 24 * mm) + canvas.setFont("CertificateVeraBold", 28) + canvas.drawCentredString(width / 2, height - 48 * mm, "Certificate of Attendance") + canvas.setFont("CertificateVera", 14) + canvas.drawCentredString(width / 2, height - 70 * mm, "This certifies that") + canvas.setFont("CertificateVeraBold", 24) + canvas.drawCentredString(width / 2, height - 91 * mm, certificate.recipient_name) + canvas.setFont("CertificateVera", 14) + canvas.drawCentredString(width / 2, height - 111 * mm, "attended") + canvas.setFont("CertificateVeraBold", 20) + canvas.drawCentredString(width / 2, height - 130 * mm, certificate.event_title) + date_label = certificate.event_starts_at.strftime("%d %B %Y") + canvas.setFont("CertificateVera", 13) + canvas.drawCentredString(width / 2, height - 149 * mm, date_label) + canvas.drawString(22 * mm, 24 * mm, certificate.issuer_name) + canvas.setFont("CertificateVera", 8) + canvas.drawRightString( + width - 22 * mm, + 24 * mm, + f"Serial {certificate.serial} · Revision {certificate.revision}", + ) + canvas.showPage() + canvas.save() + return output.getvalue() diff --git a/backend/apps/events/certificate_storage.py b/backend/apps/events/certificate_storage.py new file mode 100644 index 00000000..0c0f77ec --- /dev/null +++ b/backend/apps/events/certificate_storage.py @@ -0,0 +1,96 @@ +import hashlib + +import boto3 +from botocore.client import Config +from botocore.exceptions import BotoCoreError, ClientError +from django.conf import settings + +from apps.object_storage import delete_all_versions + + +class CertificateStorageUnavailable(Exception): + pass + + +def _client(endpoint): + return boto3.client( + "s3", + endpoint_url=endpoint, + aws_access_key_id=settings.AWS_ACCESS_KEY_ID, + aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY, + region_name=settings.AWS_S3_REGION_NAME, + config=Config( + signature_version=settings.AWS_S3_SIGNATURE_VERSION, + s3={"addressing_style": settings.AWS_S3_ADDRESSING_STYLE}, + ), + ) + + +def staging_key(object_key): + return f"staging/{object_key}" + + +def store_immutable_pdf(object_key, content): + digest = hashlib.sha256(content).hexdigest() + size = len(content) + client = _client(settings.AWS_S3_ENDPOINT_URL) + try: + existing = _digest_or_none(client, object_key) + if existing is not None: + if existing != (size, digest): + raise CertificateStorageUnavailable( + "The certificate key already contains different bytes." + ) + return size, digest + staged = staging_key(object_key) + client.put_object( + Bucket=settings.AWS_STORAGE_BUCKET_NAME, + Key=staged, + Body=content, + ContentType="application/pdf", + ) + client.copy_object( + Bucket=settings.AWS_STORAGE_BUCKET_NAME, + CopySource={"Bucket": settings.AWS_STORAGE_BUCKET_NAME, "Key": staged}, + Key=object_key, + ContentType="application/pdf", + MetadataDirective="REPLACE", + ) + if _digest_or_none(client, object_key) != (size, digest): + raise CertificateStorageUnavailable("Certificate promotion verification failed.") + delete_all_versions(client, bucket=settings.AWS_STORAGE_BUCKET_NAME, key=staged) + return size, digest + except (BotoCoreError, ClientError, OSError) as exc: + raise CertificateStorageUnavailable from exc + + +def presigned_download(object_key): + try: + return _client(settings.AWS_S3_PUBLIC_ENDPOINT_URL).generate_presigned_url( + "get_object", + Params={ + "Bucket": settings.AWS_STORAGE_BUCKET_NAME, + "Key": object_key, + "ResponseContentType": "application/pdf", + }, + ExpiresIn=settings.EVIDENCE_URL_TTL_SECONDS, + ) + except (BotoCoreError, ClientError) as exc: + raise CertificateStorageUnavailable from exc + + +def _digest_or_none(client, key): + try: + response = client.get_object(Bucket=settings.AWS_STORAGE_BUCKET_NAME, Key=key) + except ClientError as exc: + code = exc.response.get("Error", {}).get("Code") + status = exc.response.get("ResponseMetadata", {}).get("HTTPStatusCode") + if status == 404 or code in {"404", "NoSuchKey", "NotFound"}: + return None + raise + digest = hashlib.sha256() + size = 0 + for chunk in iter(lambda: response["Body"].read(64 * 1024), b""): + digest.update(chunk) + size += len(chunk) + return size, digest.hexdigest() diff --git a/backend/apps/events/checkin_policy.py b/backend/apps/events/checkin_policy.py new file mode 100644 index 00000000..78622aa7 --- /dev/null +++ b/backend/apps/events/checkin_policy.py @@ -0,0 +1,71 @@ +from dataclasses import dataclass +from datetime import datetime, timedelta + +from django.conf import settings +from django.utils import timezone + + +@dataclass(frozen=True) +class CheckInWindow: + opens_at: object + closes_at: object + sync_deadline: object + + +def window_for(event): + return CheckInWindow( + opens_at=event.starts_at - timedelta( + hours=settings.EVENT_CHECKIN_WINDOW_BEFORE_HOURS + ), + closes_at=event.ends_at + timedelta( + hours=settings.EVENT_CHECKIN_WINDOW_AFTER_HOURS + ), + sync_deadline=event.ends_at + + timedelta( + hours=( + settings.EVENT_CHECKIN_WINDOW_AFTER_HOURS + + settings.EVENT_CHECKIN_SYNC_GRACE_HOURS + ) + ), + ) + + +def roster_expiry(event, *, now=None): + now = now or timezone.now() + window = window_for(event) + return min( + now + timedelta(hours=settings.EVENT_CHECKIN_ROSTER_LIFETIME_HOURS), + window.sync_deadline, + ) + + +def download_is_open(event, *, now=None): + now = now or timezone.now() + window = window_for(event) + return window.opens_at <= now <= window.closes_at + + +def reported_time_is_valid(reported_at, lease, *, received_at=None): + received_at = received_at or timezone.now() + opens_at = _datetime(lease["scan_opens_at"]) + closes_at = _datetime(lease["scan_closes_at"]) + expires_at = _datetime(lease["expires_at"]) + future_limit = received_at + timedelta( + seconds=settings.EVENT_CHECKIN_CLOCK_SKEW_SECONDS + ) + return ( + opens_at <= reported_at <= closes_at + and reported_at <= expires_at + and reported_at <= future_limit + ) + + +def sync_is_open(lease, *, now=None): + return (now or timezone.now()) <= _datetime(lease["sync_deadline"]) + + +def _datetime(value): + if hasattr(value, "tzinfo"): + return value + parsed = datetime.fromisoformat(value) + return parsed if parsed.tzinfo else timezone.make_aware(parsed) diff --git a/backend/apps/events/checkin_roster.py b/backend/apps/events/checkin_roster.py new file mode 100644 index 00000000..09fa0388 --- /dev/null +++ b/backend/apps/events/checkin_roster.py @@ -0,0 +1,50 @@ +from django.conf import settings +from rest_framework.exceptions import APIException + +from apps.events.models import EventRegistration +from apps.makerspaces.models import MakerspaceMembership, MakerspaceWaiver +from apps.makerspaces.waiver_state import acceptance_on_file_q + + +class RosterTooLarge(APIException): + status_code = 413 + default_detail = "This roster is too large for offline storage." + default_code = "roster_too_large" + + +def host_waiver_state(registration): + if not MakerspaceWaiver.objects.filter( + makerspace_id=registration.event.makerspace_id, + is_active=True, + ).exists(): + return "not_required" + if registration.host_waiver_id: + return "on_file" + if registration.member_id and MakerspaceMembership.objects.filter( + user_id=registration.member_id, + makerspace_id=registration.event.makerspace_id, + ).filter(acceptance_on_file_q()).exists(): + return "on_file" + return "missing" + + +def minimum_roster(event): + rows = list( + EventRegistration.objects.filter( + event=event, + status=EventRegistration.Status.REGISTERED, + ) + .select_related("event") + .order_by("created_at", "id")[: settings.EVENT_CHECKIN_ROSTER_MAX + 1] + ) + if len(rows) > settings.EVENT_CHECKIN_ROSTER_MAX: + raise RosterTooLarge() + return [ + { + "registration_id": row.pk, + "checkin_token": row.checkin_token, + "name": row.name, + "host_waiver_state": host_waiver_state(row), + } + for row in rows + ] diff --git a/backend/apps/events/checkin_tokens.py b/backend/apps/events/checkin_tokens.py new file mode 100644 index 00000000..8685d723 --- /dev/null +++ b/backend/apps/events/checkin_tokens.py @@ -0,0 +1,51 @@ +from uuid import uuid4 + +from django.core import signing +from django.utils import timezone + +from apps.events.checkin_policy import roster_expiry, window_for + + +LEASE_SALT = "spaceworks.events.checkin-lease.v1" +STATION_COOKIE_SALT = "spaceworks.events.station-cookie.v1" + + +def build_lease(event, *, kind, actor_id=None, session_id=None, station_version=None): + now = timezone.now() + window = window_for(event) + session_id = session_id or uuid4() + payload = { + "kind": kind, + "lease_id": str(session_id), + "event_id": event.pk, + "makerspace_id": event.makerspace_id, + "actor_id": actor_id, + "station_version": station_version, + "issued_at": now.isoformat(), + "expires_at": roster_expiry(event, now=now).isoformat(), + "scan_opens_at": window.opens_at.isoformat(), + "scan_closes_at": window.closes_at.isoformat(), + "sync_deadline": window.sync_deadline.isoformat(), + } + return payload, signing.dumps(payload, salt=LEASE_SALT, compress=True) + + +def read_lease(token): + return signing.loads(token, salt=LEASE_SALT) + + +def sign_station_cookie(*, public_token, version, session_id, expires_at): + return signing.dumps( + { + "public_token": str(public_token), + "version": version, + "session_id": str(session_id), + "expires_at": expires_at.isoformat(), + }, + salt=STATION_COOKIE_SALT, + compress=True, + ) + + +def read_station_cookie(value): + return signing.loads(value, salt=STATION_COOKIE_SALT) diff --git a/backend/apps/events/exceptions.py b/backend/apps/events/exceptions.py index 8c887654..275d75c7 100644 --- a/backend/apps/events/exceptions.py +++ b/backend/apps/events/exceptions.py @@ -1,3 +1,6 @@ +from rest_framework.exceptions import APIException + + class EventInvalidTransition(Exception): pass @@ -6,7 +9,37 @@ class CapacityConflict(Exception): pass +class UseSeriesCollaborators(Exception): + pass + + +class RegistrationClosed(Exception): + pass + + +class RegistrationRejected(Exception): + pass + + +class FeedbackIneligible(Exception): + """Uniform public failure for every certificate eligibility mismatch.""" + + +class FeedbackConflict(Exception): + pass + + class DuplicateRegistration(Exception): def __init__(self, *args, fresh_status=None): super().__init__(*args) self.fresh_status = fresh_status + + +class DuplicateCheckInOperation(Exception): + pass + + +class CheckInLeaseExpired(APIException): + status_code = 410 + default_detail = "The check-in lease synchronization deadline passed." + default_code = "checkin_lease_expired" diff --git a/backend/apps/events/feedback_validation.py b/backend/apps/events/feedback_validation.py new file mode 100644 index 00000000..69a5f478 --- /dev/null +++ b/backend/apps/events/feedback_validation.py @@ -0,0 +1,42 @@ +from django.core.exceptions import ValidationError as DjangoValidationError +from rest_framework import serializers + +from apps.forms_schema.validation import validate_answers, validate_form_schema + + +FEEDBACK_QUESTION_TYPES = frozenset( + { + "short_text", + "paragraph", + "dropdown", + "multi_choice", + "single_choice", + "yes_no", + "number", + } +) + + +def validate_feedback_schema(value): + canonical = validate_form_schema(value) or [] + unsupported = sorted( + {question["type"] for question in canonical} - FEEDBACK_QUESTION_TYPES + ) + if unsupported: + raise DjangoValidationError( + f"Unsupported feedback question type: {', '.join(unsupported)}." + ) + return canonical + + +def validate_feedback_answers(schema, raw_answers): + try: + snapshot = validate_answers(schema, raw_answers) + except serializers.ValidationError as exc: + detail = exc.detail + if "custom_answers" in detail: + raise serializers.ValidationError( + {"answers": detail["custom_answers"]} + ) from exc + raise + return snapshot or {"version": 1, "answers": []} diff --git a/backend/apps/events/middleware.py b/backend/apps/events/middleware.py new file mode 100644 index 00000000..236af284 --- /dev/null +++ b/backend/apps/events/middleware.py @@ -0,0 +1,40 @@ +import re + + +_FEED_PATH = re.compile( + r"(/api/v1/public/[^/]+/event-calendar/)[^/?]+(\.ics(?:\?.*)?)$" +) +_FEED_BEARER_PATH = re.compile( + r"^/api/v1/public/[^/]+/event-calendar/[^/]+\.ics$" +) + + +def is_calendar_feed_bearer_path(value): + return bool(_FEED_BEARER_PATH.fullmatch(value or "")) + + +def redact_calendar_feed_uri(value): + return _FEED_PATH.sub(r"\1[redacted]\2", value or "") + + +class CalendarFeedLogRedactionMiddleware: + """Remove bearer feed tokens from WSGI/Gunicorn request-line logging.""" + + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + raw_uri = request.META.get("RAW_URI") + if raw_uri: + request.META["RAW_URI"] = redact_calendar_feed_uri(raw_uri) + feed_path = request.path_info if is_calendar_feed_bearer_path(request.path_info) else None + try: + return self.get_response(request) + finally: + # Resolution and the view need the real token. Access/error logging happens + # after the response, so replace every request-line source only on unwind. + if feed_path: + redacted = redact_calendar_feed_uri(feed_path) + request.path = redacted + request.path_info = redacted + request.META["PATH_INFO"] = redacted diff --git a/backend/apps/events/migrations/0014_event_registration_cutoff.py b/backend/apps/events/migrations/0014_event_registration_cutoff.py new file mode 100644 index 00000000..e1242019 --- /dev/null +++ b/backend/apps/events/migrations/0014_event_registration_cutoff.py @@ -0,0 +1,46 @@ +from django.db import migrations, models +from django.db.models import F, Q + + +class Migration(migrations.Migration): + dependencies = [ + ("events", "0013_eventorganizer"), + ] + + operations = [ + migrations.AddField( + model_name="event", + name="registration_requires_approval", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="event", + name="registration_cutoff_at", + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name="event", + name="registration_cutoff_lead_minutes", + field=models.PositiveIntegerField(blank=True, null=True), + ), + migrations.AddConstraint( + model_name="event", + constraint=models.CheckConstraint( + condition=( + Q(registration_cutoff_at__isnull=True) + | Q(registration_cutoff_lead_minutes__isnull=True) + ), + name="event_registration_cutoff_mode_exclusive", + ), + ), + migrations.AddConstraint( + model_name="event", + constraint=models.CheckConstraint( + condition=( + Q(registration_cutoff_at__isnull=True) + | Q(registration_cutoff_at__lte=F("starts_at")) + ), + name="event_registration_cutoff_not_after_start", + ), + ), + ] diff --git a/backend/apps/events/migrations/0015_event_registration_approval.py b/backend/apps/events/migrations/0015_event_registration_approval.py new file mode 100644 index 00000000..13a6e19b --- /dev/null +++ b/backend/apps/events/migrations/0015_event_registration_approval.py @@ -0,0 +1,42 @@ +from django.db import migrations, models +from django.db.models import Q + + +class Migration(migrations.Migration): + dependencies = [ + ("events", "0014_event_registration_cutoff"), + ] + + operations = [ + migrations.AlterField( + model_name="eventregistration", + name="status", + field=models.CharField( + choices=[ + ("pending_approval", "Pending approval"), + ("registered", "Registered"), + ("waitlisted", "Waitlisted"), + ("rejected", "Rejected"), + ("cancelled", "Cancelled"), + ("attended", "Attended"), + ], + default="registered", + max_length=20, + ), + ), + migrations.RemoveConstraint( + model_name="eventregistration", + name="uniq_active_event_registration_member", + ), + migrations.AddConstraint( + model_name="eventregistration", + constraint=models.UniqueConstraint( + fields=("event", "member"), + condition=Q( + member__isnull=False, + status__in=("pending_approval", "registered", "waitlisted"), + ), + name="uniq_active_event_registration_member", + ), + ), + ] diff --git a/backend/apps/events/migrations/0016_event_check_in_history.py b/backend/apps/events/migrations/0016_event_check_in_history.py new file mode 100644 index 00000000..16ec12e0 --- /dev/null +++ b/backend/apps/events/migrations/0016_event_check_in_history.py @@ -0,0 +1,51 @@ +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +IMMUTABLE_SQL = """ +CREATE OR REPLACE FUNCTION events_reject_checkin_mutation() +RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + IF TG_OP = 'DELETE' AND current_setting('app.allow_immutable_delete', true) = 'on' THEN + RETURN OLD; + END IF; + RAISE EXCEPTION 'append-only/immutable table: % not allowed', TG_OP; +END; +$$; +CREATE TRIGGER events_checkin_immutable +BEFORE UPDATE OR DELETE ON events_eventcheckinevent +FOR EACH ROW EXECUTE FUNCTION events_reject_checkin_mutation(); +""" + +REVERSE_SQL = """ +DROP TRIGGER IF EXISTS events_checkin_immutable ON events_eventcheckinevent; +DROP FUNCTION IF EXISTS events_reject_checkin_mutation(); +""" + + +class Migration(migrations.Migration): + dependencies = [ + ("events", "0015_event_registration_approval"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="EventCheckInEvent", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("source", models.CharField(choices=[("staff", "Staff confirmation"), ("qr", "QR check-in")], max_length=16)), + ("attended_at", models.DateTimeField(default=django.utils.timezone.now)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("recorded_by", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="+", to=settings.AUTH_USER_MODEL)), + ("registration", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="check_in_events", to="events.eventregistration")), + ], + options={ + "ordering": ["attended_at", "id"], + "indexes": [models.Index(fields=["registration", "attended_at"], name="event_checkin_history_idx")], + }, + ), + migrations.RunSQL(IMMUTABLE_SQL, REVERSE_SQL), + ] diff --git a/backend/apps/events/migrations/0017_event_feedback_certificates.py b/backend/apps/events/migrations/0017_event_feedback_certificates.py new file mode 100644 index 00000000..cc1c2014 --- /dev/null +++ b/backend/apps/events/migrations/0017_event_feedback_certificates.py @@ -0,0 +1,158 @@ +import uuid + +import apps.events.feedback_validation +from django.conf import settings +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion + + +RESPONSE_TRIGGER = """ +CREATE OR REPLACE FUNCTION events_reject_feedback_response_mutation() +RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + IF TG_OP = 'DELETE' AND current_setting('app.allow_immutable_delete', true) = 'on' THEN + RETURN OLD; + END IF; + RAISE EXCEPTION 'append-only/immutable table: % not allowed', TG_OP; +END; +$$; +CREATE TRIGGER events_feedback_response_immutable +BEFORE UPDATE OR DELETE ON events_eventfeedbackresponse +FOR EACH ROW EXECUTE FUNCTION events_reject_feedback_response_mutation(); +""" + +CERTIFICATE_TRIGGER = """ +CREATE OR REPLACE FUNCTION events_guard_certificate_mutation() +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 'immutable certificate delete not allowed'; + END IF; + IF NEW.response_id IS DISTINCT FROM OLD.response_id OR + NEW.registration_id IS DISTINCT FROM OLD.registration_id OR + NEW.serial IS DISTINCT FROM OLD.serial OR + NEW.revision IS DISTINCT FROM OLD.revision OR + NEW.recipient_name IS DISTINCT FROM OLD.recipient_name OR + NEW.event_title IS DISTINCT FROM OLD.event_title OR + NEW.event_starts_at IS DISTINCT FROM OLD.event_starts_at OR + NEW.event_ends_at IS DISTINCT FROM OLD.event_ends_at OR + NEW.issuer_name IS DISTINCT FROM OLD.issuer_name OR + NEW.object_key IS DISTINCT FROM OLD.object_key OR + NEW.content_type IS DISTINCT FROM OLD.content_type OR + NEW.issued_at IS DISTINCT FROM OLD.issued_at THEN + RAISE EXCEPTION 'certificate issuance snapshots are immutable'; + END IF; + IF OLD.status = 'active' AND ( + NEW.size_bytes IS DISTINCT FROM OLD.size_bytes OR + NEW.sha256 IS DISTINCT FROM OLD.sha256 OR + NEW.rendered_at IS DISTINCT FROM OLD.rendered_at + ) THEN + RAISE EXCEPTION 'active certificate artifact is immutable'; + END IF; + IF NEW.status <> OLD.status AND NOT ( + (OLD.status IN ('pending', 'failed') AND NEW.status = 'rendering') OR + (OLD.status = 'rendering' AND NEW.status IN ('active', 'failed')) OR + (OLD.status = 'active' AND NEW.status = 'revoked') + ) THEN + RAISE EXCEPTION 'invalid certificate status transition: % to %', OLD.status, NEW.status; + END IF; + RETURN NEW; +END; +$$; +CREATE TRIGGER events_certificate_guard +BEFORE UPDATE OR DELETE ON events_eventattendancecertificate +FOR EACH ROW EXECUTE FUNCTION events_guard_certificate_mutation(); +""" + +REVERSE_SQL = """ +DROP TRIGGER IF EXISTS events_certificate_guard ON events_eventattendancecertificate; +DROP FUNCTION IF EXISTS events_guard_certificate_mutation(); +DROP TRIGGER IF EXISTS events_feedback_response_immutable ON events_eventfeedbackresponse; +DROP FUNCTION IF EXISTS events_reject_feedback_response_mutation(); +""" + + +class Migration(migrations.Migration): + dependencies = [ + ("events", "0016_event_check_in_history"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="EventFeedbackSurvey", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("title", models.CharField(max_length=200)), + ("thank_you_text", models.TextField(blank=True, validators=[django.core.validators.MaxLengthValidator(2000)])), + ("questions", models.JSONField(default=list, validators=[apps.events.feedback_validation.validate_feedback_schema])), + ("is_open", models.BooleanField(default=False)), + ("certificate_enabled", models.BooleanField(default=False)), + ("answered_question_ids", models.JSONField(blank=True, default=list)), + ("opened_at", models.DateTimeField(blank=True, null=True)), + ("closed_at", models.DateTimeField(blank=True, null=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("event", models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name="feedback_survey", to="events.event")), + ], + options={ + "constraints": [models.CheckConstraint(condition=models.Q(("is_open", False), models.Q(("questions", []), _negated=True), _connector="OR"), name="event_feedback_open_has_questions")], + }, + ), + migrations.CreateModel( + name="EventFeedbackResponse", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("answers_snapshot", models.TextField()), + ("certificate_requested", models.BooleanField(default=False)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("registration", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name="feedback_responses", to="events.eventregistration")), + ("survey", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="responses", to="events.eventfeedbacksurvey")), + ], + options={ + "ordering": ["created_at", "id"], + "indexes": [models.Index(fields=["survey", "created_at", "id"], name="event_feedback_response_idx")], + "constraints": [ + models.CheckConstraint(condition=models.Q(models.Q(("certificate_requested", False), ("registration__isnull", True)), models.Q(("certificate_requested", True), ("registration__isnull", False)), _connector="OR"), name="event_feedback_response_mode_matches_identity"), + models.UniqueConstraint(condition=models.Q(("registration__isnull", False)), fields=("survey", "registration"), name="uniq_event_feedback_registration"), + ], + }, + ), + migrations.CreateModel( + name="EventAttendanceCertificate", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("serial", models.UUIDField(default=uuid.uuid4, editable=False, unique=True)), + ("revision", models.PositiveIntegerField()), + ("recipient_name", models.TextField()), + ("event_title", models.CharField(max_length=200)), + ("event_starts_at", models.DateTimeField()), + ("event_ends_at", models.DateTimeField()), + ("issuer_name", models.CharField(max_length=200)), + ("object_key", models.CharField(max_length=512, unique=True)), + ("content_type", models.CharField(default="application/pdf", max_length=64)), + ("size_bytes", models.PositiveBigIntegerField(blank=True, null=True)), + ("sha256", models.CharField(blank=True, max_length=64)), + ("status", models.CharField(choices=[("pending", "Pending"), ("rendering", "Rendering"), ("active", "Active"), ("failed", "Failed"), ("revoked", "Revoked")], default="pending", max_length=16)), + ("issued_at", models.DateTimeField(auto_now_add=True)), + ("rendered_at", models.DateTimeField(blank=True, null=True)), + ("revoked_at", models.DateTimeField(blank=True, null=True)), + ("revocation_reason", models.CharField(blank=True, choices=[("attendance_corrected", "Attendance corrected"), ("event_cancelled", "Event cancelled"), ("staff_revoked", "Staff revoked")], max_length=32)), + ("registration", models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name="attendance_certificates", to="events.eventregistration")), + ("response", models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name="certificates", to="events.eventfeedbackresponse")), + ("revoked_by", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="+", to=settings.AUTH_USER_MODEL)), + ], + options={ + "ordering": ["registration_id", "revision"], + "constraints": [ + models.UniqueConstraint(fields=("registration", "revision"), name="uniq_event_certificate_revision"), + models.UniqueConstraint(condition=models.Q(("status", "revoked"), _negated=True), fields=("registration",), name="uniq_live_event_certificate"), + ], + }, + ), + migrations.RunSQL(RESPONSE_TRIGGER + CERTIFICATE_TRIGGER, REVERSE_SQL), + ] diff --git a/backend/apps/events/migrations/0018_event_series.py b/backend/apps/events/migrations/0018_event_series.py new file mode 100644 index 00000000..b3867819 --- /dev/null +++ b/backend/apps/events/migrations/0018_event_series.py @@ -0,0 +1,96 @@ +import uuid + +import apps.forms_schema.validation +from django.conf import settings +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ("events", "0017_event_feedback_certificates"), + ("organizations", "0002_organizationmembership"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="EventSeries", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("public_token", models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, unique=True)), + ("title", models.CharField(max_length=200)), + ("description", models.TextField(blank=True)), + ("location", models.CharField(blank=True, max_length=255)), + ("location_kind", models.CharField(choices=[("indoor", "Indoor"), ("outdoor", "Outdoor"), ("other", "Other")], default="other", max_length=8)), + ("custom_form", models.JSONField(blank=True, default=None, null=True, validators=[apps.forms_schema.validation.validate_form_schema])), + ("capacity", models.PositiveIntegerField(default=0)), + ("payment_amount", models.DecimalField(decimal_places=2, default=0, max_digits=12, validators=[django.core.validators.MinValueValidator(0)])), + ("registration_requires_approval", models.BooleanField(default=False)), + ("registration_cutoff_lead_minutes", models.PositiveIntegerField(blank=True, null=True)), + ("is_public", models.BooleanField(default=False)), + ("image_key", models.CharField(blank=True, default="", max_length=300)), + ("recurrence_timezone", models.CharField(max_length=64)), + ("dtstart_local_date", models.DateField()), + ("dtstart_local_time", models.TimeField()), + ("recurrence_rule", models.CharField(max_length=500)), + ("duration_minutes", models.PositiveIntegerField()), + ("revision", models.PositiveIntegerField(default=1)), + ("status", models.CharField(choices=[("draft", "Draft"), ("published", "Published"), ("cancelled", "Cancelled"), ("completed", "Completed")], default="draft", max_length=16)), + ("last_materialized_at", models.DateTimeField(blank=True, null=True)), + ("last_generation_error_code", models.CharField(blank=True, default="", max_length=64)), + ("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)), + ("makerspace", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="event_series", to="makerspaces.makerspace")), + ], + options={"ordering": ("dtstart_local_date", "dtstart_local_time", "id")}, + ), + migrations.CreateModel( + name="EventSeriesCollaborator", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("status", models.CharField(choices=[("invited", "Invited"), ("accepted", "Accepted"), ("declined", "Declined")], default="invited", max_length=8)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("responded_at", models.DateTimeField(blank=True, null=True)), + ("invited_by", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="+", to=settings.AUTH_USER_MODEL)), + ("makerspace", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="event_series_collaborations", to="makerspaces.makerspace")), + ("responded_by", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="+", to=settings.AUTH_USER_MODEL)), + ("series", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="collaborators", to="events.eventseries")), + ], + ), + migrations.CreateModel( + name="EventSeriesOrganizer", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("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="+", to=settings.AUTH_USER_MODEL)), + ("organization", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="organized_event_series", to="organizations.organization")), + ("series", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="organizers", to="events.eventseries")), + ], + ), + migrations.AddField(model_name="event", name="series", field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name="occurrences", to="events.eventseries")), + migrations.AddField(model_name="event", name="series_occurrence_key", field=models.CharField(blank=True, max_length=48, null=True)), + migrations.AddField(model_name="event", name="series_override_fields", field=models.JSONField(blank=True, default=list)), + migrations.AddField(model_name="event", name="series_revision", field=models.PositiveIntegerField(blank=True, null=True)), + migrations.AddField(model_name="eventcollaborator", name="source_series_collaboration", field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="occurrence_collaborators", to="events.eventseriescollaborator")), + migrations.AddField(model_name="eventorganizer", name="source_series_organizer", field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="occurrence_organizers", to="events.eventseriesorganizer")), + migrations.AddConstraint(model_name="eventseries", constraint=models.CheckConstraint(condition=models.Q(("capacity__gte", 0)), name="series_capacity_nonnegative")), + migrations.AddConstraint(model_name="eventseries", constraint=models.CheckConstraint(condition=models.Q(("payment_amount__gte", 0)), name="series_payment_nonnegative")), + migrations.AddConstraint(model_name="eventseries", constraint=models.CheckConstraint(condition=models.Q(("duration_minutes__gt", 0)), name="series_duration_positive")), + migrations.AddConstraint(model_name="eventseries", constraint=models.CheckConstraint(condition=models.Q(("revision__gt", 0)), name="series_revision_positive")), + migrations.AddIndex(model_name="eventseries", index=models.Index(fields=["makerspace", "status", "dtstart_local_date"], name="series_ms_status_date_idx")), + migrations.AddConstraint(model_name="eventseriescollaborator", constraint=models.UniqueConstraint(fields=("series", "makerspace"), name="uniq_series_collaborator_space")), + migrations.AddConstraint(model_name="eventseriesorganizer", constraint=models.UniqueConstraint(fields=("series", "organization"), name="uniq_series_organizer_organization")), + migrations.AddConstraint( + model_name="event", + constraint=models.CheckConstraint( + condition=models.Q(("series__isnull", True), ("series_occurrence_key__isnull", True), ("series_revision__isnull", True)) + | models.Q(("series__isnull", False), ("series_occurrence_key__isnull", False), ("series_revision__isnull", False)), + name="event_series_identity_all_or_none", + ), + ), + migrations.AddConstraint(model_name="event", constraint=models.UniqueConstraint(fields=("series", "series_occurrence_key"), name="uniq_event_series_occurrence_key")), + migrations.AddIndex(model_name="event", index=models.Index(fields=["series", "starts_at"], name="event_series_start_idx")), + ] diff --git a/backend/apps/events/migrations/0019_event_calendar_and_badges.py b/backend/apps/events/migrations/0019_event_calendar_and_badges.py new file mode 100644 index 00000000..689822c2 --- /dev/null +++ b/backend/apps/events/migrations/0019_event_calendar_and_badges.py @@ -0,0 +1,113 @@ +import uuid + +from django.conf import settings +from django.db import migrations, models +import django.utils.timezone + + +def backfill_calendar_identity(apps, schema_editor): + Event = apps.get_model("events", "Event") + EventSeries = apps.get_model("events", "EventSeries") + EventRegistration = apps.get_model("events", "EventRegistration") + now = django.utils.timezone.now() + for event in Event.objects.filter(calendar_uid__isnull=True).iterator(chunk_size=500): + Event.objects.filter(pk=event.pk).update( + calendar_uid=uuid.uuid4(), + calendar_updated_at=now, + timezone_name=settings.TIME_ZONE, + ) + for series in EventSeries.objects.filter(calendar_uid__isnull=True).iterator(chunk_size=500): + EventSeries.objects.filter(pk=series.pk).update( + calendar_uid=uuid.uuid4(), calendar_updated_at=now + ) + EventRegistration.objects.filter(calendar_updated_at__isnull=True).update( + calendar_updated_at=now + ) + + +class Migration(migrations.Migration): + dependencies = [("events", "0018_event_series")] + + operations = [ + migrations.AddField( + model_name="event", + name="badge_template", + field=models.JSONField(blank=True, default=dict), + ), + migrations.AddField( + model_name="event", + name="calendar_sequence", + field=models.PositiveIntegerField(default=0), + ), + migrations.AddField( + model_name="event", + name="calendar_uid", + field=models.UUIDField(editable=False, null=True), + ), + migrations.AddField( + model_name="event", + name="calendar_updated_at", + field=models.DateTimeField(null=True), + ), + migrations.AddField( + model_name="event", + name="timezone_name", + field=models.CharField(max_length=64, null=True), + ), + migrations.AddField( + model_name="eventregistration", + name="calendar_sequence", + field=models.PositiveIntegerField(default=0), + ), + migrations.AddField( + model_name="eventregistration", + name="calendar_updated_at", + field=models.DateTimeField(null=True), + ), + migrations.AddField( + model_name="eventseries", + name="calendar_sequence", + field=models.PositiveIntegerField(default=0), + ), + migrations.AddField( + model_name="eventseries", + name="calendar_uid", + field=models.UUIDField(editable=False, null=True), + ), + migrations.AddField( + model_name="eventseries", + name="calendar_updated_at", + field=models.DateTimeField(null=True), + ), + migrations.RunPython(backfill_calendar_identity, migrations.RunPython.noop), + migrations.AlterField( + model_name="event", + name="calendar_uid", + field=models.UUIDField(default=uuid.uuid4, editable=False, unique=True), + ), + migrations.AlterField( + model_name="event", + name="calendar_updated_at", + field=models.DateTimeField(default=django.utils.timezone.now), + ), + migrations.AlterField( + model_name="event", + name="timezone_name", + field=models.CharField(default=settings.TIME_ZONE, max_length=64), + ), + migrations.AlterField( + model_name="eventregistration", + name="calendar_updated_at", + field=models.DateTimeField(default=django.utils.timezone.now), + ), + migrations.AlterField( + model_name="eventseries", + name="calendar_uid", + field=models.UUIDField(default=uuid.uuid4, editable=False, unique=True), + ), + migrations.AlterField( + model_name="eventseries", + name="calendar_updated_at", + field=models.DateTimeField(default=django.utils.timezone.now), + ), + ] diff --git a/backend/apps/events/migrations/0020_member_calendar_feed.py b/backend/apps/events/migrations/0020_member_calendar_feed.py new file mode 100644 index 00000000..7c9a2f42 --- /dev/null +++ b/backend/apps/events/migrations/0020_member_calendar_feed.py @@ -0,0 +1,40 @@ +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ("events", "0019_event_calendar_and_badges"), + ("makerspaces", "0067_reconcile_anonymous_requests_with_membership"), + ] + + operations = [ + migrations.CreateModel( + name="MemberCalendarFeed", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("token_digest", models.BinaryField(max_length=32, unique=True)), + ("token_hint", models.CharField(max_length=8)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("rotated_at", models.DateTimeField(blank=True, null=True)), + ("revoked_at", models.DateTimeField(blank=True, null=True)), + ( + "membership", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="event_calendar_feed", + to="makerspaces.makerspacemembership", + ), + ), + ], + options={"ordering": ("membership_id",)}, + ) + ] diff --git a/backend/apps/events/migrations/0021_offline_and_station_checkin.py b/backend/apps/events/migrations/0021_offline_and_station_checkin.py new file mode 100644 index 00000000..7dbb7f9f --- /dev/null +++ b/backend/apps/events/migrations/0021_offline_and_station_checkin.py @@ -0,0 +1,205 @@ +import uuid + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +DROP_IMMUTABLE_TRIGGER = """ +DROP TRIGGER IF EXISTS events_checkin_immutable ON events_eventcheckinevent; +""" + +CREATE_IMMUTABLE_TRIGGER = """ +CREATE TRIGGER events_checkin_immutable +BEFORE UPDATE OR DELETE ON events_eventcheckinevent +FOR EACH ROW EXECUTE FUNCTION events_reject_checkin_mutation(); +""" + + +def populate_checkin_scope(apps, schema_editor): + CheckIn = apps.get_model("events", "EventCheckInEvent") + for row in CheckIn.objects.select_related("registration__event").iterator(): + row.event_id = row.registration.event_id + row.makerspace_id = row.registration.event.makerspace_id + row.operation_id = uuid.uuid4() + if row.actor_id is None: + row.source = "legacy" + elif row.source == "staff": + row.source = "online" + row.save( + update_fields=["event", "makerspace", "operation_id", "source"] + ) + + +def restore_legacy_source_values(apps, schema_editor): + CheckIn = apps.get_model("events", "EventCheckInEvent") + CheckIn.objects.exclude(source="qr").update(source="staff") + + +class Migration(migrations.Migration): + dependencies = [ + ("events", "0020_member_calendar_feed"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.RunSQL(DROP_IMMUTABLE_TRIGGER, CREATE_IMMUTABLE_TRIGGER), + migrations.RenameField( + model_name="eventcheckinevent", + old_name="created_at", + new_name="recorded_at", + ), + migrations.RenameField( + model_name="eventcheckinevent", + old_name="recorded_by", + new_name="actor", + ), + migrations.AddField( + model_name="eventcheckinevent", + name="event", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="check_in_events", + to="events.event", + ), + ), + migrations.AddField( + model_name="eventcheckinevent", + name="makerspace", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="event_check_in_events", + to="makerspaces.makerspace", + ), + ), + migrations.AddField( + model_name="eventcheckinevent", + name="operation_id", + field=models.UUIDField(editable=False, null=True), + ), + migrations.AddField( + model_name="eventcheckinevent", + name="session_id", + field=models.UUIDField(blank=True, editable=False, null=True), + ), + migrations.AddField( + model_name="eventcheckinevent", + name="station_version", + field=models.PositiveIntegerField(blank=True, editable=False, null=True), + ), + migrations.RunPython(populate_checkin_scope, restore_legacy_source_values), + migrations.AlterField( + model_name="eventcheckinevent", + name="actor", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AlterField( + model_name="eventcheckinevent", + name="event", + field=models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="check_in_events", + to="events.event", + ), + ), + migrations.AlterField( + model_name="eventcheckinevent", + name="makerspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="event_check_in_events", + to="makerspaces.makerspace", + ), + ), + migrations.AlterField( + model_name="eventcheckinevent", + name="operation_id", + field=models.UUIDField(default=uuid.uuid4, editable=False, unique=True), + ), + migrations.AlterField( + model_name="eventcheckinevent", + name="registration", + field=models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="check_in_events", + to="events.eventregistration", + ), + ), + migrations.AlterField( + model_name="eventcheckinevent", + name="source", + field=models.CharField( + choices=[ + ("online", "Online staff confirmation"), + ("qr", "Online QR check-in"), + ("offline_sync", "Authenticated offline synchronization"), + ("venue_station", "PIN venue station"), + ("legacy", "Legacy attendance history"), + ], + max_length=16, + ), + ), + migrations.AddConstraint( + model_name="eventcheckinevent", + constraint=models.CheckConstraint( + condition=( + models.Q( + source__in=("online", "qr", "offline_sync"), + actor__isnull=False, + station_version__isnull=True, + ) + | models.Q( + source="venue_station", + actor__isnull=True, + station_version__isnull=False, + ) + | models.Q(source="legacy", station_version__isnull=True) + ), + name="event_checkin_source_actor_consistent", + ), + ), + migrations.CreateModel( + name="EventCheckInStationCredential", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "public_token", + models.UUIDField(default=uuid.uuid4, editable=False, unique=True), + ), + ("pin_digest", models.CharField(editable=False, max_length=128)), + ("pin_ciphertext", models.BinaryField(editable=False)), + ("version", models.PositiveIntegerField(default=1, editable=False)), + ("is_enabled", models.BooleanField(default=True)), + ("rotated_at", models.DateTimeField(default=django.utils.timezone.now)), + ("disabled_at", models.DateTimeField(blank=True, null=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "event", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="check_in_station", + to="events.event", + ), + ), + ], + ), + migrations.RunSQL(CREATE_IMMUTABLE_TRIGGER, DROP_IMMUTABLE_TRIGGER), + ] diff --git a/backend/apps/events/models.py b/backend/apps/events/models.py index 5280dd06..594d5534 100644 --- a/backend/apps/events/models.py +++ b/backend/apps/events/models.py @@ -1,300 +1,33 @@ -from uuid import uuid4 - -from django.conf import settings -from django.core.exceptions import ValidationError -from django.core.validators import MinValueValidator -from django.db import models -from django.db.models import F, Q -from apps.encryption.mappers import ScopedPiiModelMixin -from apps.events.organizer_models import EventOrganizer # noqa: F401 -from apps.forms_schema.validation import validate_form_schema - - -class Event(models.Model): - class Status(models.TextChoices): - DRAFT = "draft", "Draft" - PUBLISHED = "published", "Published" - CANCELLED = "cancelled", "Cancelled" - COMPLETED = "completed", "Completed" - - class LocationKind(models.TextChoices): - INDOOR = 'indoor', 'Indoor' - OUTDOOR = 'outdoor', 'Outdoor' - OTHER = 'other', 'Other' - - public_token = models.UUIDField( - default=uuid4, - editable=False, - unique=True, - db_index=True, - ) - makerspace = models.ForeignKey( - "makerspaces.Makerspace", - on_delete=models.CASCADE, - related_name="events", - ) - title = models.CharField(max_length=200) - description = models.TextField(blank=True) - starts_at = models.DateTimeField() - ends_at = models.DateTimeField() - location = models.CharField(max_length=255, blank=True) - location_kind = models.CharField( - max_length=8, - choices=LocationKind.choices, - default=LocationKind.OTHER, - ) - custom_form = models.JSONField( - null=True, - blank=True, - default=None, - validators=[validate_form_schema], - ) - capacity = models.PositiveIntegerField(default=0) - payment_amount = models.DecimalField( - max_digits=12, - decimal_places=2, - default=0, - validators=[MinValueValidator(0)], - ) - is_public = models.BooleanField(default=False) - # Public-bucket object key for the event cover image. Managed only by the - # dedicated image endpoints (never by the generic update path), so it is - # deliberately absent from services.EVENT_FIELDS. - image_key = models.CharField(max_length=300, blank=True, default="") - status = models.CharField( - max_length=16, - choices=Status.choices, - default=Status.DRAFT, - ) - created_by = models.ForeignKey( - settings.AUTH_USER_MODEL, - on_delete=models.SET_NULL, - null=True, - blank=True, - related_name="+", - ) - created_at = models.DateTimeField(auto_now_add=True) - updated_at = models.DateTimeField(auto_now=True) - - class Meta: - ordering = ["starts_at", "id"] - constraints = [ - models.CheckConstraint( - condition=Q(ends_at__gte=F("starts_at")), - name="event_ends_not_before_start", - ), - models.CheckConstraint( - condition=Q(capacity__gte=0), - name="event_capacity_nonnegative", - ), - models.CheckConstraint( - condition=Q(payment_amount__gte=0), - name="event_payment_nonnegative", - ), - ] - indexes = [ - models.Index( - fields=["makerspace", "starts_at"], - name="event_ms_starts_idx", - ), - models.Index( - fields=["makerspace", "status", "starts_at"], - name="event_ms_status_start_idx", - ), - models.Index( - fields=["makerspace", "is_public", "status", "ends_at"], - name="event_public_lookup_idx", - ), - ] - - def save(self, *args, **kwargs): - self.title = (self.title or "").strip() - if self.pk: - original = type(self).objects.only("public_token", "makerspace_id").get( - pk=self.pk - ) - self.public_token = original.public_token - self.makerspace_id = original.makerspace_id - super().save(*args, **kwargs) - - -# Collaboration is an invite-and-accept relationship rather than a bare M2M so a -# space cannot unilaterally attach itself to another space's event. Hosts invite by -# slug, which also avoids enumerating makerspaces they do not administer. -class EventCollaborator(models.Model): - class Status(models.TextChoices): - INVITED = "invited", "Invited" - ACCEPTED = "accepted", "Accepted" - DECLINED = "declined", "Declined" - - event = models.ForeignKey( - Event, - on_delete=models.CASCADE, - related_name="collaborators", - ) - makerspace = models.ForeignKey( - "makerspaces.Makerspace", - on_delete=models.CASCADE, - related_name="event_collaborations", - ) - status = models.CharField( - max_length=8, - choices=Status.choices, - default=Status.INVITED, - ) - invited_by = models.ForeignKey( - settings.AUTH_USER_MODEL, - null=True, - blank=True, - on_delete=models.SET_NULL, - related_name="+", - ) - responded_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) - responded_at = models.DateTimeField(null=True, blank=True) - - class Meta: - unique_together = (("event", "makerspace"),) - - def clean(self): - super().clean() - if self.event_id and self.makerspace_id == self.event.makerspace_id: - raise ValidationError( - {"makerspace": "An event's host makerspace cannot be a collaborator."} - ) - - -class EventRegistration(ScopedPiiModelMixin, models.Model): - class Status(models.TextChoices): - REGISTERED = "registered", "Registered" - WAITLISTED = "waitlisted", "Waitlisted" - CANCELLED = "cancelled", "Cancelled" - ATTENDED = "attended", "Attended" - - event = models.ForeignKey( - Event, - on_delete=models.CASCADE, - related_name="registrations", - ) - # editable=False keeps this out of ModelForms and admin. Do not re-read it in - # save(): register() uses save(update_fields=...) on a hot path, and no application - # code assigns the token after creation. - checkin_token = models.UUIDField(default=uuid4, unique=True, editable=False) - name = models.TextField() - email = models.TextField() - phone = models.TextField() - member = models.ForeignKey( - settings.AUTH_USER_MODEL, - on_delete=models.SET_NULL, - null=True, - blank=True, - related_name="event_registrations", - ) - # Accepted collaboration authorizes discovery and creation, while this durable - # provenance records where participation happened so member history and QR access - # survive removal of that collaboration. SET_NULL is intentional: this is routing - # convenience, not accountability evidence, and a purge should hide the activity - # from that space rather than be blocked. - registered_via_makerspace = models.ForeignKey( - "makerspaces.Makerspace", - null=True, - blank=True, - on_delete=models.SET_NULL, - related_name="event_registrations_via", - ) - # MONEY, not activity: NOT cleared by the collaborator's `events` purge. A waitlisted row - # is charged only at `_promote()`, so a purge in between would null the field above and - # route the charge to the host, which the visitor cannot reach -- and no `Payment` exists - # yet to carry it. Resurrects nothing: history/profile/QR read the field above, not this. - payment_via_makerspace = models.ForeignKey( - "makerspaces.Makerspace", - null=True, - blank=True, - on_delete=models.SET_NULL, - related_name="event_registration_payment_routes", - ) - # The version and timestamp are accountability evidence about a real person's - # agreement. SET_NULL would either violate all-or-none or silently erase that - # evidence, so the waiver itself is PROTECTed. - host_waiver = models.ForeignKey( - "makerspaces.MakerspaceWaiver", - null=True, - blank=True, - on_delete=models.PROTECT, - related_name="accepted_by_event_registrations", - ) - host_waiver_accepted_at = models.DateTimeField(null=True, blank=True) - host_waiver_version_accepted = models.CharField( - max_length=64, null=True, blank=True, - ) - email_exact_hash = models.BinaryField(max_length=32, null=True, editable=False) - email_hash_generation = models.ForeignKey( - "encryption.SearchKeyGeneration", on_delete=models.PROTECT, - null=True, editable=False, - ) - custom_answers = models.JSONField(null=True, blank=True, default=None) - status = models.CharField( - max_length=16, - choices=Status.choices, - default=Status.REGISTERED, - ) - created_at = models.DateTimeField(auto_now_add=True) - - class Meta: - ordering = ["created_at", "id"] - constraints = [ - models.UniqueConstraint( - fields=["event", "email"], - name="uniq_event_registration_email", - ), - models.UniqueConstraint( - fields=["event", "email_hash_generation", "email_exact_hash"], - condition=Q(email_hash_generation__isnull=False, email_exact_hash__isnull=False), - name="uniq_event_registration_email_hash", - ), - models.UniqueConstraint( - fields=["event", "member"], - condition=Q( - member__isnull=False, - status__in=("registered", "waitlisted"), - ), - name="uniq_active_event_registration_member", - ), - models.CheckConstraint( - condition=( - Q(host_waiver__isnull=True, host_waiver_accepted_at__isnull=True, - host_waiver_version_accepted__isnull=True) - | Q(host_waiver__isnull=False, host_waiver_accepted_at__isnull=False, - host_waiver_version_accepted__isnull=False) - ), - name="event_registration_host_waiver_all_or_none", - ), - ] - indexes = [ - models.Index( - fields=["event", "status", "created_at"], - name="eventreg_status_fifo_idx", - ), - ] - - def clean(self): - super().clean() - if ( - self.host_waiver_id and self.event_id - and self.host_waiver.makerspace_id != self.event.makerspace_id - ): - raise ValidationError( - {"host_waiver": "Waiver must belong to the event's host makerspace."} - ) - - def save(self, *args, **kwargs): - self.name = (self.name or "").strip() - self.email = (self.email or "").strip().lower() - self.phone = (self.phone or "").strip() - super().save(*args, **kwargs) +# Established model barrel pattern; split to keep focused model modules below the file ceiling. +from apps.events.models_event import Event +from apps.events.models_collaborators import EventCollaborator +from apps.events.models_registration import EventRegistration +from apps.events.models_attendance import ( + EventCheckInEvent, + EventCheckInStationCredential, +) +from apps.events.models_feedback import EventFeedbackResponse, EventFeedbackSurvey +from apps.events.models_certificates import EventAttendanceCertificate +from apps.events.organizer_models import EventOrganizer +from apps.events.models_series import ( + EventSeries, + EventSeriesCollaborator, + EventSeriesOrganizer, +) +from apps.events.models_calendar import MemberCalendarFeed + +__all__ = [ + "Event", + "EventAttendanceCertificate", + "EventCheckInEvent", + "EventCheckInStationCredential", + "EventCollaborator", + "EventFeedbackResponse", + "EventFeedbackSurvey", + "EventOrganizer", + "EventRegistration", + "EventSeries", + "EventSeriesCollaborator", + "EventSeriesOrganizer", + "MemberCalendarFeed", +] diff --git a/backend/apps/events/models_attendance.py b/backend/apps/events/models_attendance.py new file mode 100644 index 00000000..ec28fb42 --- /dev/null +++ b/backend/apps/events/models_attendance.py @@ -0,0 +1,116 @@ +from uuid import uuid4 + +from django.conf import settings +from django.core.exceptions import ValidationError +from django.db import models +from django.utils import timezone + +from apps.events.models_event import Event +from apps.events.models_registration import EventRegistration +from apps.makerspaces.models import Makerspace + + +class EventCheckInEvent(models.Model): + """Immutable evidence that an attended transition happened.""" + + class Source(models.TextChoices): + ONLINE = "online", "Online staff confirmation" + QR = "qr", "Online QR check-in" + OFFLINE_SYNC = "offline_sync", "Authenticated offline synchronization" + VENUE_STATION = "venue_station", "PIN venue station" + LEGACY = "legacy", "Legacy attendance history" + + makerspace = models.ForeignKey( + Makerspace, + on_delete=models.PROTECT, + related_name="event_check_in_events", + ) + event = models.ForeignKey( + Event, + on_delete=models.PROTECT, + related_name="check_in_events", + ) + + registration = models.ForeignKey( + EventRegistration, + on_delete=models.PROTECT, + related_name="check_in_events", + ) + operation_id = models.UUIDField(default=uuid4, unique=True, editable=False) + source = models.CharField(max_length=16, choices=Source.choices) + # The scan/confirmation time. Offline clients report it; recorded_at remains the + # server-controlled receipt time so delayed synchronization never rewrites history. + attended_at = models.DateTimeField(default=timezone.now) + recorded_at = models.DateTimeField(auto_now_add=True) + actor = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.PROTECT, + null=True, + blank=True, + related_name="+", + ) + session_id = models.UUIDField(null=True, blank=True, editable=False) + station_version = models.PositiveIntegerField(null=True, blank=True, editable=False) + + class Meta: + ordering = ["attended_at", "id"] + indexes = [ + models.Index( + fields=["registration", "attended_at"], + name="event_checkin_history_idx", + ), + ] + constraints = [ + models.CheckConstraint( + condition=( + models.Q( + source__in=("online", "qr", "offline_sync"), + actor__isnull=False, + station_version__isnull=True, + ) + | models.Q( + source="venue_station", + actor__isnull=True, + station_version__isnull=False, + ) + | models.Q(source="legacy", station_version__isnull=True) + ), + name="event_checkin_source_actor_consistent", + ), + ] + + def clean(self): + super().clean() + if self.registration_id and self.event_id: + if self.registration.event_id != self.event_id: + raise ValidationError("Registration must belong to the check-in event.") + if self.event_id and self.makerspace_id: + if self.event.makerspace_id != self.makerspace_id: + raise ValidationError("Check-in event must belong to the host makerspace.") + + def save(self, *args, **kwargs): + if self.pk is not None: + raise RuntimeError("EventCheckInEvent rows are immutable.") + return super().save(*args, **kwargs) + + def delete(self, *args, **kwargs): + raise RuntimeError("EventCheckInEvent rows are immutable.") + + +class EventCheckInStationCredential(models.Model): + """Mutable, event-scoped PIN authority; attendee data never lives here.""" + + event = models.OneToOneField( + Event, + on_delete=models.CASCADE, + related_name="check_in_station", + ) + public_token = models.UUIDField(default=uuid4, unique=True, editable=False) + pin_digest = models.CharField(max_length=128, editable=False) + pin_ciphertext = models.BinaryField(editable=False) + version = models.PositiveIntegerField(default=1, editable=False) + is_enabled = models.BooleanField(default=True) + rotated_at = models.DateTimeField(default=timezone.now) + disabled_at = models.DateTimeField(null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) diff --git a/backend/apps/events/models_calendar.py b/backend/apps/events/models_calendar.py new file mode 100644 index 00000000..80478fe8 --- /dev/null +++ b/backend/apps/events/models_calendar.py @@ -0,0 +1,23 @@ +from django.db import models + + +class MemberCalendarFeed(models.Model): + """Deployment-local bearer credential for one membership's event feed. + + Only the SHA-256 digest is persisted. The raw 256-bit token is returned once when + created or rotated and never enters tenant exports or audit metadata. + """ + + membership = models.OneToOneField( + "makerspaces.MakerspaceMembership", + on_delete=models.CASCADE, + related_name="event_calendar_feed", + ) + token_digest = models.BinaryField(max_length=32, unique=True) + token_hint = models.CharField(max_length=8) + created_at = models.DateTimeField(auto_now_add=True) + rotated_at = models.DateTimeField(null=True, blank=True) + revoked_at = models.DateTimeField(null=True, blank=True) + + class Meta: + ordering = ("membership_id",) diff --git a/backend/apps/events/models_certificates.py b/backend/apps/events/models_certificates.py new file mode 100644 index 00000000..c59ba6f5 --- /dev/null +++ b/backend/apps/events/models_certificates.py @@ -0,0 +1,120 @@ +from uuid import uuid4 + +from django.conf import settings +from django.core.exceptions import ValidationError +from django.db import models +from django.db.models import Q + +from apps.encryption.mappers import ScopedPiiModelMixin +from apps.events.models_feedback import EventFeedbackResponse +from apps.events.models_registration import EventRegistration + + +class EventAttendanceCertificate(ScopedPiiModelMixin, models.Model): + class Status(models.TextChoices): + PENDING = "pending", "Pending" + RENDERING = "rendering", "Rendering" + ACTIVE = "active", "Active" + FAILED = "failed", "Failed" + REVOKED = "revoked", "Revoked" + + class RevocationReason(models.TextChoices): + ATTENDANCE_CORRECTED = "attendance_corrected", "Attendance corrected" + EVENT_CANCELLED = "event_cancelled", "Event cancelled" + STAFF_REVOKED = "staff_revoked", "Staff revoked" + + response = models.ForeignKey( + EventFeedbackResponse, + on_delete=models.PROTECT, + related_name="certificates", + ) + registration = models.ForeignKey( + EventRegistration, + on_delete=models.PROTECT, + related_name="attendance_certificates", + ) + serial = models.UUIDField(default=uuid4, unique=True, editable=False) + revision = models.PositiveIntegerField() + recipient_name = models.TextField() + event_title = models.CharField(max_length=200) + event_starts_at = models.DateTimeField() + event_ends_at = models.DateTimeField() + issuer_name = models.CharField(max_length=200) + object_key = models.CharField(max_length=512, unique=True) + content_type = models.CharField(max_length=64, default="application/pdf") + size_bytes = models.PositiveBigIntegerField(null=True, blank=True) + sha256 = models.CharField(max_length=64, blank=True) + status = models.CharField( + max_length=16, + choices=Status.choices, + default=Status.PENDING, + ) + issued_at = models.DateTimeField(auto_now_add=True) + rendered_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="+", + ) + revocation_reason = models.CharField( + max_length=32, + choices=RevocationReason.choices, + blank=True, + ) + + class Meta: + ordering = ["registration_id", "revision"] + constraints = [ + models.UniqueConstraint( + fields=["registration", "revision"], + name="uniq_event_certificate_revision", + ), + models.UniqueConstraint( + fields=["registration"], + condition=~Q(status="revoked"), + name="uniq_live_event_certificate", + ), + ] + + def clean(self): + super().clean() + if self.response_id and self.registration_id: + if self.response.registration_id != self.registration_id: + raise ValidationError( + {"response": "Response and certificate registration must match."} + ) + if self.content_type != "application/pdf": + raise ValidationError({"content_type": "Certificates must be PDF files."}) + + def save(self, *args, **kwargs): + if self.pk: + original = type(self).objects.get(pk=self.pk) + issuance_fields = ( + "response_id", "registration_id", "serial", "revision", + "recipient_name", "event_title", "event_starts_at", + "event_ends_at", "issuer_name", "object_key", "content_type", + "issued_at", + ) + if any( + getattr(self, field) != getattr(original, field) + for field in issuance_fields + ): + raise ValidationError("Certificate issuance snapshots are immutable.") + allowed = { + self.Status.PENDING: {self.Status.RENDERING}, + self.Status.FAILED: {self.Status.RENDERING}, + self.Status.RENDERING: {self.Status.ACTIVE, self.Status.FAILED}, + self.Status.ACTIVE: {self.Status.REVOKED}, + self.Status.REVOKED: set(), + } + if self.status != original.status and self.status not in allowed[original.status]: + raise ValidationError({"status": "Invalid certificate transition."}) + if original.status == self.Status.ACTIVE: + frozen = ("size_bytes", "sha256", "rendered_at") + if any(getattr(self, field) != getattr(original, field) for field in frozen): + raise ValidationError("An active certificate artifact is immutable.") + self.full_clean(validate_unique=False, validate_constraints=False) + return super().save(*args, **kwargs) diff --git a/backend/apps/events/models_collaborators.py b/backend/apps/events/models_collaborators.py new file mode 100644 index 00000000..abccd7aa --- /dev/null +++ b/backend/apps/events/models_collaborators.py @@ -0,0 +1,63 @@ +from django.conf import settings +from django.core.exceptions import ValidationError +from django.db import models +from apps.events.models_event import Event + + +# Collaboration is an invite-and-accept relationship rather than a bare M2M so a +# space cannot unilaterally attach itself to another space's event. Hosts invite by +# slug, which also avoids enumerating makerspaces they do not administer. +class EventCollaborator(models.Model): + class Status(models.TextChoices): + INVITED = "invited", "Invited" + ACCEPTED = "accepted", "Accepted" + DECLINED = "declined", "Declined" + + event = models.ForeignKey( + Event, + on_delete=models.CASCADE, + related_name="collaborators", + ) + makerspace = models.ForeignKey( + "makerspaces.Makerspace", + on_delete=models.CASCADE, + related_name="event_collaborations", + ) + status = models.CharField( + max_length=8, + choices=Status.choices, + default=Status.INVITED, + ) + invited_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="+", + ) + responded_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) + responded_at = models.DateTimeField(null=True, blank=True) + source_series_collaboration = models.ForeignKey( + "events.EventSeriesCollaborator", + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="occurrence_collaborators", + ) + + class Meta: + unique_together = (("event", "makerspace"),) + + def clean(self): + super().clean() + if self.event_id and self.makerspace_id == self.event.makerspace_id: + raise ValidationError( + {"makerspace": "An event's host makerspace cannot be a collaborator."} + ) diff --git a/backend/apps/events/models_event.py b/backend/apps/events/models_event.py new file mode 100644 index 00000000..357b67be --- /dev/null +++ b/backend/apps/events/models_event.py @@ -0,0 +1,213 @@ +from uuid import uuid4 + +from django.conf import settings +from django.core.exceptions import ValidationError +from django.core.validators import MinValueValidator +from django.db import models +from django.db.models import F, Q +from django.utils import timezone +from apps.forms_schema.validation import validate_form_schema + + +class Event(models.Model): + class Status(models.TextChoices): + DRAFT = "draft", "Draft" + PUBLISHED = "published", "Published" + CANCELLED = "cancelled", "Cancelled" + COMPLETED = "completed", "Completed" + + class LocationKind(models.TextChoices): + INDOOR = 'indoor', 'Indoor' + OUTDOOR = 'outdoor', 'Outdoor' + OTHER = 'other', 'Other' + + public_token = models.UUIDField( + default=uuid4, + editable=False, + unique=True, + db_index=True, + ) + calendar_uid = models.UUIDField(default=uuid4, editable=False, unique=True) + calendar_sequence = models.PositiveIntegerField(default=0) + calendar_updated_at = models.DateTimeField(default=timezone.now) + timezone_name = models.CharField(max_length=64, default=settings.TIME_ZONE) + badge_template = models.JSONField(default=dict, blank=True) + makerspace = models.ForeignKey( + "makerspaces.Makerspace", + on_delete=models.CASCADE, + related_name="events", + ) + series = models.ForeignKey( + "events.EventSeries", + on_delete=models.PROTECT, + null=True, + blank=True, + related_name="occurrences", + ) + series_occurrence_key = models.CharField(max_length=48, null=True, blank=True) + series_revision = models.PositiveIntegerField(null=True, blank=True) + series_override_fields = models.JSONField(default=list, blank=True) + title = models.CharField(max_length=200) + description = models.TextField(blank=True) + starts_at = models.DateTimeField() + ends_at = models.DateTimeField() + location = models.CharField(max_length=255, blank=True) + location_kind = models.CharField( + max_length=8, + choices=LocationKind.choices, + default=LocationKind.OTHER, + ) + custom_form = models.JSONField( + null=True, + blank=True, + default=None, + validators=[validate_form_schema], + ) + capacity = models.PositiveIntegerField(default=0) + payment_amount = models.DecimalField( + max_digits=12, + decimal_places=2, + default=0, + validators=[MinValueValidator(0)], + ) + registration_requires_approval = models.BooleanField(default=False) + registration_cutoff_at = models.DateTimeField(null=True, blank=True) + registration_cutoff_lead_minutes = models.PositiveIntegerField( + null=True, + blank=True, + ) + is_public = models.BooleanField(default=False) + # Public-bucket object key for the event cover image. Managed only by the + # dedicated image endpoints (never by the generic update path), so it is + # deliberately absent from services.EVENT_FIELDS. + image_key = models.CharField(max_length=300, blank=True, default="") + status = models.CharField( + max_length=16, + choices=Status.choices, + default=Status.DRAFT, + ) + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="+", + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ["starts_at", "id"] + constraints = [ + models.CheckConstraint( + condition=Q(ends_at__gte=F("starts_at")), + name="event_ends_not_before_start", + ), + models.CheckConstraint( + condition=Q(capacity__gte=0), + name="event_capacity_nonnegative", + ), + models.CheckConstraint( + condition=Q(payment_amount__gte=0), + name="event_payment_nonnegative", + ), + models.CheckConstraint( + condition=( + Q(registration_cutoff_at__isnull=True) + | Q(registration_cutoff_lead_minutes__isnull=True) + ), + name="event_registration_cutoff_mode_exclusive", + ), + models.CheckConstraint( + condition=( + Q(registration_cutoff_at__isnull=True) + | Q(registration_cutoff_at__lte=F("starts_at")) + ), + name="event_registration_cutoff_not_after_start", + ), + models.CheckConstraint( + condition=( + Q( + series__isnull=True, + series_occurrence_key__isnull=True, + series_revision__isnull=True, + ) + | Q( + series__isnull=False, + series_occurrence_key__isnull=False, + series_revision__isnull=False, + ) + ), + name="event_series_identity_all_or_none", + ), + models.UniqueConstraint( + fields=("series", "series_occurrence_key"), + name="uniq_event_series_occurrence_key", + ), + ] + indexes = [ + models.Index( + fields=["makerspace", "starts_at"], + name="event_ms_starts_idx", + ), + models.Index( + fields=["makerspace", "status", "starts_at"], + name="event_ms_status_start_idx", + ), + models.Index( + fields=["makerspace", "is_public", "status", "ends_at"], + name="event_public_lookup_idx", + ), + models.Index( + fields=["series", "starts_at"], name="event_series_start_idx" + ), + ] + + def clean(self): + super().clean() + from zoneinfo import ZoneInfo, ZoneInfoNotFoundError + + try: + ZoneInfo(self.timezone_name) + except (ZoneInfoNotFoundError, ValueError, TypeError) as exc: + raise ValidationError({"timezone_name": "Use a valid IANA timezone name."}) from exc + if ( + self.registration_cutoff_at is not None + and self.registration_cutoff_lead_minutes is not None + ): + raise ValidationError( + "Choose either an absolute registration cutoff or lead minutes, not both." + ) + if ( + self.registration_cutoff_at is not None + and self.starts_at is not None + and self.registration_cutoff_at > self.starts_at + ): + raise ValidationError({ + "registration_cutoff_at": "Registration cutoff cannot be after the event starts." + }) + identity = ( + self.series_id, + self.series_occurrence_key, + self.series_revision, + ) + if any(value is None for value in identity) and any(value is not None for value in identity): + raise ValidationError("Series occurrence identity must be entirely set or entirely empty.") + if self.series_id and self.makerspace_id != self.series.makerspace_id: + raise ValidationError({"series": "Series and occurrence must share a makerspace."}) + if not isinstance(self.series_override_fields, list) or any( + not isinstance(value, str) for value in self.series_override_fields + ): + raise ValidationError({"series_override_fields": "Expected a list of field names."}) + def save(self, *args, **kwargs): + self.title = (self.title or "").strip() + if self.pk: + original = type(self).objects.only( + "public_token", "calendar_uid", "makerspace_id" + ).get( + pk=self.pk + ) + self.public_token = original.public_token + self.calendar_uid = original.calendar_uid + self.makerspace_id = original.makerspace_id + super().save(*args, **kwargs) diff --git a/backend/apps/events/models_feedback.py b/backend/apps/events/models_feedback.py new file mode 100644 index 00000000..85611b70 --- /dev/null +++ b/backend/apps/events/models_feedback.py @@ -0,0 +1,142 @@ +import json + +from django.core.exceptions import ValidationError +from django.core.validators import MaxLengthValidator +from django.db import models +from django.db.models import Q + +from apps.encryption.mappers import ScopedPiiModelMixin +from apps.events.feedback_validation import validate_feedback_schema +from apps.events.models_event import Event +from apps.events.models_registration import EventRegistration + + +def _question_signature(question): + return tuple( + json.dumps(question.get(key), sort_keys=True) + for key in ("id", "label", "type", "options", "required") + ) + + +class EventFeedbackSurvey(models.Model): + event = models.OneToOneField( + Event, + on_delete=models.CASCADE, + related_name="feedback_survey", + ) + title = models.CharField(max_length=200) + thank_you_text = models.TextField( + blank=True, + validators=[MaxLengthValidator(2_000)], + ) + questions = models.JSONField(default=list, validators=[validate_feedback_schema]) + is_open = models.BooleanField(default=False) + certificate_enabled = models.BooleanField(default=False) + answered_question_ids = models.JSONField(default=list, blank=True) + opened_at = models.DateTimeField(null=True, blank=True) + closed_at = models.DateTimeField(null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + constraints = [ + models.CheckConstraint( + condition=Q(is_open=False) | ~Q(questions=[]), + name="event_feedback_open_has_questions", + ), + ] + + def clean(self): + super().clean() + self.questions = validate_feedback_schema(self.questions) + ids = [question["id"] for question in self.questions] + if not isinstance(self.answered_question_ids, list) or any( + not isinstance(value, str) for value in self.answered_question_ids + ): + raise ValidationError({"answered_question_ids": "Must be a list of IDs."}) + if not set(self.answered_question_ids).issubset(ids): + raise ValidationError( + {"questions": "Answered questions cannot be removed."} + ) + if self.is_open and not self.questions: + raise ValidationError({"questions": "An open survey needs a question."}) + if not self.pk: + return + original = type(self).objects.filter(pk=self.pk).first() + if original is None or not original.responses.exists(): + return + if self.certificate_enabled != original.certificate_enabled: + raise ValidationError( + {"certificate_enabled": "Certificate mode is frozen after a response."} + ) + old = {question["id"]: question for question in original.questions} + new = {question["id"]: question for question in self.questions} + for question_id in original.answered_question_ids: + if question_id not in new or _question_signature(old[question_id]) != _question_signature(new[question_id]): + raise ValidationError( + {"questions": f"Answered question {question_id!r} is immutable."} + ) + + def save(self, *args, **kwargs): + self.title = (self.title or "").strip() + self.thank_you_text = (self.thank_you_text or "").strip() + self.full_clean(validate_unique=False, validate_constraints=False) + return super().save(*args, **kwargs) + + +class EventFeedbackResponse(ScopedPiiModelMixin, models.Model): + survey = models.ForeignKey( + EventFeedbackSurvey, + on_delete=models.CASCADE, + related_name="responses", + ) + registration = models.ForeignKey( + EventRegistration, + on_delete=models.PROTECT, + related_name="feedback_responses", + null=True, + blank=True, + ) + answers_snapshot = models.TextField() + certificate_requested = models.BooleanField(default=False) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ["created_at", "id"] + constraints = [ + models.CheckConstraint( + condition=( + Q(registration__isnull=True, certificate_requested=False) + | Q(registration__isnull=False, certificate_requested=True) + ), + name="event_feedback_response_mode_matches_identity", + ), + models.UniqueConstraint( + fields=["survey", "registration"], + condition=Q(registration__isnull=False), + name="uniq_event_feedback_registration", + ), + ] + indexes = [ + models.Index( + fields=["survey", "created_at", "id"], + name="event_feedback_response_idx", + ), + ] + + def clean(self): + super().clean() + if self.registration_id and self.survey_id: + if self.registration.event_id != self.survey.event_id: + raise ValidationError( + {"registration": "Registration must belong to the survey event."} + ) + + def save(self, *args, **kwargs): + if self.pk is not None: + raise RuntimeError("EventFeedbackResponse rows are immutable.") + self.full_clean(validate_unique=False, validate_constraints=False) + return super().save(*args, **kwargs) + + def delete(self, *args, **kwargs): + raise RuntimeError("EventFeedbackResponse rows are immutable.") diff --git a/backend/apps/events/models_registration.py b/backend/apps/events/models_registration.py new file mode 100644 index 00000000..59c7bfd7 --- /dev/null +++ b/backend/apps/events/models_registration.py @@ -0,0 +1,143 @@ +from uuid import uuid4 + +from django.conf import settings +from django.core.exceptions import ValidationError +from django.db import models +from django.db.models import Q +from django.utils import timezone +from apps.encryption.mappers import ScopedPiiModelMixin +from apps.events.models_event import Event + + +class EventRegistration(ScopedPiiModelMixin, models.Model): + class Status(models.TextChoices): + PENDING_APPROVAL = "pending_approval", "Pending approval" + REGISTERED = "registered", "Registered" + WAITLISTED = "waitlisted", "Waitlisted" + REJECTED = "rejected", "Rejected" + CANCELLED = "cancelled", "Cancelled" + ATTENDED = "attended", "Attended" + + event = models.ForeignKey( + Event, + on_delete=models.CASCADE, + related_name="registrations", + ) + # editable=False keeps this out of ModelForms and admin. Do not re-read it in + # save(): register() uses save(update_fields=...) on a hot path, and no application + # code assigns the token after creation. + checkin_token = models.UUIDField(default=uuid4, unique=True, editable=False) + name = models.TextField() + email = models.TextField() + phone = models.TextField() + member = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="event_registrations", + ) + # Accepted collaboration authorizes discovery and creation, while this durable + # provenance records where participation happened so member history and QR access + # survive removal of that collaboration. SET_NULL is intentional: this is routing + # convenience, not accountability evidence, and a purge should hide the activity + # from that space rather than be blocked. + registered_via_makerspace = models.ForeignKey( + "makerspaces.Makerspace", + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="event_registrations_via", + ) + # MONEY, not activity: NOT cleared by the collaborator's `events` purge. A waitlisted row + # is charged only at `_promote()`, so a purge in between would null the field above and + # route the charge to the host, which the visitor cannot reach -- and no `Payment` exists + # yet to carry it. Resurrects nothing: history/profile/QR read the field above, not this. + payment_via_makerspace = models.ForeignKey( + "makerspaces.Makerspace", + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="event_registration_payment_routes", + ) + # The version and timestamp are accountability evidence about a real person's + # agreement. SET_NULL would either violate all-or-none or silently erase that + # evidence, so the waiver itself is PROTECTed. + host_waiver = models.ForeignKey( + "makerspaces.MakerspaceWaiver", + null=True, + blank=True, + on_delete=models.PROTECT, + related_name="accepted_by_event_registrations", + ) + host_waiver_accepted_at = models.DateTimeField(null=True, blank=True) + host_waiver_version_accepted = models.CharField( + max_length=64, null=True, blank=True, + ) + email_exact_hash = models.BinaryField(max_length=32, null=True, editable=False) + email_hash_generation = models.ForeignKey( + "encryption.SearchKeyGeneration", on_delete=models.PROTECT, + null=True, editable=False, + ) + custom_answers = models.JSONField(null=True, blank=True, default=None) + status = models.CharField( + max_length=20, + choices=Status.choices, + default=Status.REGISTERED, + ) + calendar_sequence = models.PositiveIntegerField(default=0) + calendar_updated_at = models.DateTimeField(default=timezone.now) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ["created_at", "id"] + constraints = [ + models.UniqueConstraint( + fields=["event", "email"], + name="uniq_event_registration_email", + ), + models.UniqueConstraint( + fields=["event", "email_hash_generation", "email_exact_hash"], + condition=Q(email_hash_generation__isnull=False, email_exact_hash__isnull=False), + name="uniq_event_registration_email_hash", + ), + models.UniqueConstraint( + fields=["event", "member"], + condition=Q( + member__isnull=False, + status__in=("pending_approval", "registered", "waitlisted"), + ), + name="uniq_active_event_registration_member", + ), + models.CheckConstraint( + condition=( + Q(host_waiver__isnull=True, host_waiver_accepted_at__isnull=True, + host_waiver_version_accepted__isnull=True) + | Q(host_waiver__isnull=False, host_waiver_accepted_at__isnull=False, + host_waiver_version_accepted__isnull=False) + ), + name="event_registration_host_waiver_all_or_none", + ), + ] + indexes = [ + models.Index( + fields=["event", "status", "created_at"], + name="eventreg_status_fifo_idx", + ), + ] + + def clean(self): + super().clean() + if ( + self.host_waiver_id and self.event_id + and self.host_waiver.makerspace_id != self.event.makerspace_id + ): + raise ValidationError( + {"host_waiver": "Waiver must belong to the event's host makerspace."} + ) + + def save(self, *args, **kwargs): + self.name = (self.name or "").strip() + self.email = (self.email or "").strip().lower() + self.phone = (self.phone or "").strip() + super().save(*args, **kwargs) diff --git a/backend/apps/events/models_series.py b/backend/apps/events/models_series.py new file mode 100644 index 00000000..5af1ecf7 --- /dev/null +++ b/backend/apps/events/models_series.py @@ -0,0 +1,148 @@ +from uuid import uuid4 + +from django.conf import settings +from django.core.exceptions import ValidationError +from django.core.validators import MinValueValidator +from django.db import models +from django.db.models import Q +from django.utils import timezone + +from apps.forms_schema.validation import validate_form_schema + + +class EventSeries(models.Model): + class Status(models.TextChoices): + DRAFT = "draft", "Draft" + PUBLISHED = "published", "Published" + CANCELLED = "cancelled", "Cancelled" + COMPLETED = "completed", "Completed" + + public_token = models.UUIDField(default=uuid4, editable=False, unique=True, db_index=True) + calendar_uid = models.UUIDField(default=uuid4, editable=False, unique=True) + calendar_sequence = models.PositiveIntegerField(default=0) + calendar_updated_at = models.DateTimeField(default=timezone.now) + makerspace = models.ForeignKey( + "makerspaces.Makerspace", on_delete=models.CASCADE, related_name="event_series" + ) + title = models.CharField(max_length=200) + description = models.TextField(blank=True) + location = models.CharField(max_length=255, blank=True) + location_kind = models.CharField( + max_length=8, choices=(('indoor', 'Indoor'), ('outdoor', 'Outdoor'), ('other', 'Other')), + default="other", + ) + custom_form = models.JSONField( + null=True, blank=True, default=None, validators=[validate_form_schema] + ) + capacity = models.PositiveIntegerField(default=0) + payment_amount = models.DecimalField( + max_digits=12, decimal_places=2, default=0, validators=[MinValueValidator(0)] + ) + registration_requires_approval = models.BooleanField(default=False) + registration_cutoff_lead_minutes = models.PositiveIntegerField(null=True, blank=True) + is_public = models.BooleanField(default=False) + image_key = models.CharField(max_length=300, blank=True, default="") + + recurrence_timezone = models.CharField(max_length=64) + dtstart_local_date = models.DateField() + dtstart_local_time = models.TimeField() + recurrence_rule = models.CharField(max_length=500) + duration_minutes = models.PositiveIntegerField() + revision = models.PositiveIntegerField(default=1) + status = models.CharField(max_length=16, choices=Status.choices, default=Status.DRAFT) + last_materialized_at = models.DateTimeField(null=True, blank=True) + last_generation_error_code = models.CharField(max_length=64, blank=True, default="") + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, + related_name="+", + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ("dtstart_local_date", "dtstart_local_time", "id") + constraints = [ + models.CheckConstraint(condition=Q(capacity__gte=0), name="series_capacity_nonnegative"), + models.CheckConstraint( + condition=Q(payment_amount__gte=0), name="series_payment_nonnegative" + ), + models.CheckConstraint( + condition=Q(duration_minutes__gt=0), name="series_duration_positive" + ), + models.CheckConstraint(condition=Q(revision__gt=0), name="series_revision_positive"), + ] + indexes = [ + models.Index( + fields=("makerspace", "status", "dtstart_local_date"), + name="series_ms_status_date_idx", + ) + ] + + def save(self, *args, **kwargs): + self.title = (self.title or "").strip() + if self.pk: + original = type(self).objects.only( + "public_token", "calendar_uid", "makerspace_id" + ).get(pk=self.pk) + self.public_token = original.public_token + self.calendar_uid = original.calendar_uid + self.makerspace_id = original.makerspace_id + super().save(*args, **kwargs) + + +class EventSeriesCollaborator(models.Model): + class Status(models.TextChoices): + INVITED = "invited", "Invited" + ACCEPTED = "accepted", "Accepted" + DECLINED = "declined", "Declined" + + series = models.ForeignKey(EventSeries, on_delete=models.CASCADE, related_name="collaborators") + makerspace = models.ForeignKey( + "makerspaces.Makerspace", on_delete=models.CASCADE, + related_name="event_series_collaborations", + ) + status = models.CharField(max_length=8, choices=Status.choices, default=Status.INVITED) + invited_by = models.ForeignKey( + settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.SET_NULL, + related_name="+", + ) + responded_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) + responded_at = models.DateTimeField(null=True, blank=True) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=("series", "makerspace"), name="uniq_series_collaborator_space" + ) + ] + + def clean(self): + super().clean() + if self.series_id and self.makerspace_id == self.series.makerspace_id: + raise ValidationError( + {"makerspace": "A series host cannot also be its collaborator."} + ) + + +class EventSeriesOrganizer(models.Model): + series = models.ForeignKey(EventSeries, on_delete=models.CASCADE, related_name="organizers") + organization = models.ForeignKey( + "organizations.Organization", on_delete=models.CASCADE, + related_name="organized_event_series", + ) + 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) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=("series", "organization"), name="uniq_series_organizer_organization" + ) + ] diff --git a/backend/apps/events/notifications.py b/backend/apps/events/notifications.py index 90e6b11e..22a23602 100644 --- a/backend/apps/events/notifications.py +++ b/backend/apps/events/notifications.py @@ -117,3 +117,20 @@ def notify_event_lifecycle( extra={"event_id": event_id, "makerspace_id": link.makerspace_id}, ) return venue_result + + +def notify_series_lifecycle(series_obj, event_name, *, sync=False): + """Send one bounded lifecycle message for a series, never one per occurrence.""" + occurrence = series_obj.occurrences.order_by("starts_at", "pk").first() + if occurrence is None: + return None + try: + return _notify_makerspace( + occurrence.pk, series_obj.makerspace, event_name, None, sync=sync + ) + except Exception: + logger.warning( + "event_series_notification_failed", + extra={"series_id": series_obj.pk, "makerspace_id": series_obj.makerspace_id}, + ) + return None diff --git a/backend/apps/events/organizer_models.py b/backend/apps/events/organizer_models.py index 52e37c19..e5aa5687 100644 --- a/backend/apps/events/organizer_models.py +++ b/backend/apps/events/organizer_models.py @@ -21,6 +21,13 @@ class EventOrganizer(models.Model): related_name="+", ) created_at = models.DateTimeField(auto_now_add=True) + source_series_organizer = models.ForeignKey( + "events.EventSeriesOrganizer", + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="occurrence_organizers", + ) class Meta: constraints = [ diff --git a/backend/apps/events/serializers_admin.py b/backend/apps/events/serializers_admin.py index 92193d77..47ae649d 100644 --- a/backend/apps/events/serializers_admin.py +++ b/backend/apps/events/serializers_admin.py @@ -1,10 +1,13 @@ from django.db.models import Count, Q +from django.utils import timezone from drf_spectacular.utils import extend_schema_field from rest_framework import serializers from apps.events.models import Event, EventRegistration +from apps.events.capacity import effective_registration_cutoff, registration_is_open from apps.forms_schema.serializers import CustomFormSchemaField from apps.inventory import public_image_storage +from apps.makerspaces.platform import feature_enabled from apps.admin_api.serializers_payment_summary import PaymentSummaryMixin from apps.events.serializers_public import EventOrganizerSummarySerializer @@ -14,6 +17,7 @@ class EventWriteSerializer(serializers.Serializer): description = serializers.CharField(allow_blank=True, default='', required=False) starts_at = serializers.DateTimeField() ends_at = serializers.DateTimeField() + timezone_name = serializers.CharField(max_length=64, required=False) location = serializers.CharField( allow_blank=True, default='', @@ -35,6 +39,26 @@ class EventWriteSerializer(serializers.Serializer): required=False, ) is_public = serializers.BooleanField(default=False, required=False) + registration_requires_approval = serializers.BooleanField( + default=False, required=False, + ) + registration_cutoff_at = serializers.DateTimeField( + allow_null=True, default=None, required=False, + ) + registration_cutoff_lead_minutes = serializers.IntegerField( + allow_null=True, default=None, min_value=0, required=False, + ) + inherit_fields = serializers.ListField( + child=serializers.ChoiceField(choices=sorted(( + 'title', 'description', 'starts_at', 'ends_at', 'location', + 'location_kind', 'custom_form', 'capacity', 'is_public', 'payment_amount', + 'registration_requires_approval', 'registration_cutoff_at', + 'registration_cutoff_lead_minutes', + 'image_key', + ))), + required=False, + write_only=True, + ) def validate(self, attrs): starts_at = attrs.get('starts_at', getattr(self.instance, 'starts_at', None)) @@ -43,37 +67,84 @@ def validate(self, attrs): raise serializers.ValidationError( {'ends_at': 'End time must be at or after start time.'} ) + cutoff_at = attrs.get( + 'registration_cutoff_at', + getattr(self.instance, 'registration_cutoff_at', None), + ) + lead_minutes = attrs.get( + 'registration_cutoff_lead_minutes', + getattr(self.instance, 'registration_cutoff_lead_minutes', None), + ) + if cutoff_at is not None and lead_minutes is not None: + raise serializers.ValidationError({ + 'registration_cutoff_at': ( + 'Clear lead minutes before setting an absolute cutoff.' + ), + 'registration_cutoff_lead_minutes': ( + 'Clear the absolute cutoff before setting lead minutes.' + ), + }) + if cutoff_at is not None and starts_at is not None and cutoff_at > starts_at: + raise serializers.ValidationError({ + 'registration_cutoff_at': ( + 'Registration cutoff cannot be after the event starts.' + ) + }) return attrs class EventRegistrationCountsSerializer(serializers.Serializer): + pending_approval = serializers.IntegerField(read_only=True) registered = serializers.IntegerField(read_only=True) waitlisted = serializers.IntegerField(read_only=True) + rejected = serializers.IntegerField(read_only=True) cancelled = serializers.IntegerField(read_only=True) attended = serializers.IntegerField(read_only=True) +class EventAttendanceMarkSerializer(serializers.Serializer): + source = serializers.ChoiceField( + choices=("online", "qr"), + default="online", + required=False, + ) + + class EventAdminSerializer(serializers.ModelSerializer): makerspace_id = serializers.IntegerField(read_only=True) created_by_id = serializers.IntegerField(allow_null=True, read_only=True) registration_counts = serializers.SerializerMethodField() image_url = serializers.SerializerMethodField() organizers = EventOrganizerSummarySerializer(many=True, read_only=True) + effective_registration_cutoff_at = serializers.SerializerMethodField() + registration_open = serializers.SerializerMethodField() + series_summary = serializers.SerializerMethodField() + offline_checkin_enabled = serializers.SerializerMethodField() class Meta: model = Event fields = ( 'id', 'makerspace_id', + 'series_summary', + 'series_revision', + 'series_override_fields', 'title', 'description', 'starts_at', 'ends_at', + 'timezone_name', 'location', 'location_kind', 'custom_form', 'capacity', 'payment_amount', + 'registration_requires_approval', + 'registration_cutoff_at', + 'registration_cutoff_lead_minutes', + 'effective_registration_cutoff_at', + 'registration_open', + 'offline_checkin_enabled', 'is_public', 'image_url', 'status', @@ -89,7 +160,39 @@ class Meta: # URL, matching PublicMachineSerializer. @extend_schema_field(serializers.URLField(allow_null=True)) def get_image_url(self, obj): - return public_image_storage.public_url(obj.image_key) or None + key = obj.image_key + if obj.series_id and "image_key" not in (obj.series_override_fields or []): + key = obj.series.image_key + return public_image_storage.public_url(key) or None + + @extend_schema_field({ + 'type': 'object', 'nullable': True, + 'properties': { + 'id': {'type': 'integer'}, 'public_token': {'type': 'string', 'format': 'uuid'}, + 'title': {'type': 'string'}, 'timezone': {'type': 'string'}, + }, + }) + def get_series_summary(self, obj): + if not obj.series_id: + return None + return { + 'id': obj.series_id, + 'public_token': obj.series.public_token, + 'title': obj.series.title, + 'timezone': obj.series.recurrence_timezone, + } + + @extend_schema_field(serializers.DateTimeField(allow_null=True)) + def get_effective_registration_cutoff_at(self, obj): + return effective_registration_cutoff(obj) + + @extend_schema_field(serializers.BooleanField()) + def get_registration_open(self, obj): + return registration_is_open(obj, timezone.now()) + + @extend_schema_field(serializers.BooleanField()) + def get_offline_checkin_enabled(self, obj): + return feature_enabled(obj.makerspace, "events.offline_checkin") @extend_schema_field(EventRegistrationCountsSerializer) def get_registration_counts(self, obj): diff --git a/backend/apps/events/serializers_badges.py b/backend/apps/events/serializers_badges.py new file mode 100644 index 00000000..ecc3adb9 --- /dev/null +++ b/backend/apps/events/serializers_badges.py @@ -0,0 +1,38 @@ +from rest_framework import serializers + +from apps.events.badge_templates import MAX_BADGES + + +class BadgeTemplateSerializer(serializers.Serializer): + version = serializers.IntegerField(required=False) + paper_size = serializers.ChoiceField( + choices=("A4", "LETTER", "custom"), required=False + ) + orientation = serializers.ChoiceField( + choices=("portrait", "landscape"), required=False + ) + page_width_mm = serializers.FloatField(allow_null=True, required=False) + page_height_mm = serializers.FloatField(allow_null=True, 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) + template = serializers.CharField(required=False) + fields = serializers.ListField( + child=serializers.CharField(max_length=80), required=False + ) + font_size_pt = serializers.FloatField(required=False) + name_font_size_pt = serializers.FloatField(required=False) + text_align = serializers.ChoiceField(choices=("left", "center"), required=False) + include_qr = serializers.BooleanField(required=False) + + +class BadgePdfRequestSerializer(serializers.Serializer): + registration_ids = serializers.ListField( + child=serializers.IntegerField(min_value=1), + allow_empty=False, + max_length=MAX_BADGES, + ) + template_override = BadgeTemplateSerializer(allow_null=True, required=False) + include_attended = serializers.BooleanField(default=False, required=False) + diff --git a/backend/apps/events/serializers_calendar.py b/backend/apps/events/serializers_calendar.py new file mode 100644 index 00000000..1b5b3cf4 --- /dev/null +++ b/backend/apps/events/serializers_calendar.py @@ -0,0 +1,24 @@ +from rest_framework import serializers + + +class MemberCalendarFeedStateSerializer(serializers.Serializer): + enabled = serializers.BooleanField(read_only=True) + token_hint = serializers.CharField(allow_null=True, read_only=True) + created_at = serializers.DateTimeField(allow_null=True, read_only=True) + rotated_at = serializers.DateTimeField(allow_null=True, read_only=True) + + +class MemberCalendarFeedIssueSerializer(serializers.Serializer): + confirm_bearer_risk = serializers.BooleanField() + + def validate_confirm_bearer_risk(self, value): + if value is not True: + raise serializers.ValidationError("Confirm that anyone with the URL can read the feed.") + return value + + +class MemberCalendarFeedIssuedSerializer(serializers.Serializer): + feed_url = serializers.URLField(read_only=True) + token_hint = serializers.CharField(read_only=True) + created_at = serializers.DateTimeField(read_only=True) + diff --git a/backend/apps/events/serializers_checkin_offline.py b/backend/apps/events/serializers_checkin_offline.py new file mode 100644 index 00000000..8711ff1d --- /dev/null +++ b/backend/apps/events/serializers_checkin_offline.py @@ -0,0 +1,71 @@ +from rest_framework import serializers + + +class OfflineCheckInOperationSerializer(serializers.Serializer): + operation_id = serializers.UUIDField() + checkin_token = serializers.CharField(max_length=64) + reported_occurred_at = serializers.DateTimeField() + + +class OfflineCheckInSyncRequestSerializer(serializers.Serializer): + lease_token = serializers.CharField(max_length=8192) + operations = OfflineCheckInOperationSerializer(many=True, allow_empty=False) + + def validate_operations(self, value): + if len(value) > 200: + raise serializers.ValidationError("At most 200 operations may be synchronized.") + operation_ids = [item["operation_id"] for item in value] + if len(operation_ids) != len(set(operation_ids)): + raise serializers.ValidationError("Operation IDs must be unique within a batch.") + return value + + +class OfflineRosterRegistrationSerializer(serializers.Serializer): + registration_id = serializers.IntegerField() + checkin_token = serializers.UUIDField() + name = serializers.CharField() + host_waiver_state = serializers.ChoiceField( + choices=["not_required", "on_file", "missing"] + ) + + +class OfflineRosterEventSerializer(serializers.Serializer): + id = serializers.IntegerField() + title = serializers.CharField() + starts_at = serializers.DateTimeField() + ends_at = serializers.DateTimeField() + + +class OfflineRosterResponseSerializer(serializers.Serializer): + lease_token = serializers.CharField() + lease_id = serializers.UUIDField() + server_time = serializers.DateTimeField() + issued_at = serializers.DateTimeField() + expires_at = serializers.DateTimeField() + scan_opens_at = serializers.DateTimeField() + scan_closes_at = serializers.DateTimeField() + sync_deadline = serializers.DateTimeField() + event = OfflineRosterEventSerializer() + registrations = OfflineRosterRegistrationSerializer(many=True) + + +class OfflineCheckInResultSerializer(serializers.Serializer): + operation_id = serializers.UUIDField() + outcome = serializers.ChoiceField( + choices=[ + "applied", + "duplicate_operation", + "already_attended", + "registration_changed", + "event_unavailable", + "invalid_token", + "outside_window", + ] + ) + registration_id = serializers.IntegerField(required=False) + attended_at = serializers.DateTimeField(required=False) + + +class OfflineCheckInSyncResponseSerializer(serializers.Serializer): + recorded_at = serializers.DateTimeField() + results = OfflineCheckInResultSerializer(many=True) diff --git a/backend/apps/events/serializers_collaborators.py b/backend/apps/events/serializers_collaborators.py index 5365d57b..d1bddc47 100644 --- a/backend/apps/events/serializers_collaborators.py +++ b/backend/apps/events/serializers_collaborators.py @@ -1,7 +1,12 @@ from drf_spectacular.utils import extend_schema_field from rest_framework import serializers +from django.utils import timezone -from apps.events.capacity import availability_label +from apps.events.capacity import ( + availability_label, + effective_registration_cutoff, + registration_is_open, +) from apps.events.models import Event, EventCollaborator from apps.events.serializers_public import ( EventOrganizerSummarySerializer, @@ -94,11 +99,15 @@ class CollaborativeEventSerializer(serializers.Serializer): custom_form = serializers.JSONField(allow_null=True, read_only=True) capacity = serializers.IntegerField(min_value=0, read_only=True) availability = serializers.SerializerMethodField() + registration_requires_approval = serializers.BooleanField(read_only=True) + effective_registration_cutoff_at = serializers.SerializerMethodField() + registration_open = serializers.SerializerMethodField() image_url = serializers.SerializerMethodField() host_name = serializers.CharField(source="makerspace.name", read_only=True) host_slug = serializers.SlugField(source="makerspace.slug", read_only=True) host_waiver = serializers.SerializerMethodField() organizers = EventOrganizerSummarySerializer(many=True, read_only=True) + series = serializers.SerializerMethodField() @extend_schema_field( {"type": "string", "enum": ["Available", "Limited", "Full"]} @@ -106,9 +115,26 @@ class CollaborativeEventSerializer(serializers.Serializer): def get_availability(self, obj): return availability_label(obj) + @extend_schema_field(serializers.DateTimeField(allow_null=True)) + def get_effective_registration_cutoff_at(self, obj): + return effective_registration_cutoff(obj) + + @extend_schema_field(serializers.BooleanField()) + def get_registration_open(self, obj): + return registration_is_open(obj, timezone.now()) + @extend_schema_field(serializers.URLField(allow_null=True)) def get_image_url(self, obj): - return public_image_storage.public_url(obj.image_key) or None + key = obj.image_key + if obj.series_id and "image_key" not in (obj.series_override_fields or []): + key = obj.series.image_key + return public_image_storage.public_url(key) or None + + @extend_schema_field({'type': 'object', 'nullable': True}) + def get_series(self, obj): + if not obj.series_id: + return None + return {'public_token': obj.series.public_token, 'title': obj.series.title} @extend_schema_field(HostWaiverSerializer(allow_null=True)) def get_host_waiver(self, obj): diff --git a/backend/apps/events/serializers_feedback.py b/backend/apps/events/serializers_feedback.py new file mode 100644 index 00000000..33e6cf2e --- /dev/null +++ b/backend/apps/events/serializers_feedback.py @@ -0,0 +1,117 @@ +import json + +from drf_spectacular.utils import extend_schema_field +from rest_framework import serializers + +from apps.events.feedback_validation import validate_feedback_schema +from apps.events.models import EventAttendanceCertificate + + +class FeedbackSurveyWriteSerializer(serializers.Serializer): + title = serializers.CharField(max_length=200) + thank_you_text = serializers.CharField( + allow_blank=True, + default="", + max_length=2_000, + required=False, + ) + questions = serializers.JSONField() + certificate_enabled = serializers.BooleanField(default=False, required=False) + + def validate_questions(self, value): + return validate_feedback_schema(value) + + +class FeedbackSurveySerializer(serializers.Serializer): + id = serializers.IntegerField(read_only=True) + title = serializers.CharField(read_only=True) + thank_you_text = serializers.CharField(read_only=True) + questions = serializers.JSONField(read_only=True) + is_open = serializers.BooleanField(read_only=True) + certificate_enabled = serializers.BooleanField(read_only=True) + answered_question_ids = serializers.ListField( + child=serializers.CharField(), read_only=True, + ) + opened_at = serializers.DateTimeField(allow_null=True, read_only=True) + closed_at = serializers.DateTimeField(allow_null=True, read_only=True) + response_count = serializers.IntegerField(read_only=True, required=False) + + +class FeedbackSurveyAdminEnvelopeSerializer(serializers.Serializer): + survey = FeedbackSurveySerializer(allow_null=True) + + +class CertificateSummarySerializer(serializers.ModelSerializer): + class Meta: + model = EventAttendanceCertificate + fields = ("id", "status", "revision", "issued_at", "rendered_at", "revoked_at") + read_only_fields = fields + + +class FeedbackResponseSerializer(serializers.Serializer): + id = serializers.IntegerField(read_only=True) + answers = serializers.SerializerMethodField() + created_at = serializers.DateTimeField(read_only=True) + identity = serializers.SerializerMethodField() + certificate = serializers.SerializerMethodField() + + @extend_schema_field(serializers.JSONField()) + def get_answers(self, obj): + return json.loads(obj.answers_snapshot) + + @extend_schema_field(serializers.DictField(allow_null=True)) + def get_identity(self, obj): + if obj.registration_id is None: + return None + return { + "registration_id": obj.registration_id, + "name": obj.registration.name, + "email": obj.registration.email, + } + + @extend_schema_field(CertificateSummarySerializer(allow_null=True)) + def get_certificate(self, obj): + certificate = max(obj.certificates.all(), key=lambda item: item.revision, default=None) + return None if certificate is None else CertificateSummarySerializer(certificate).data + + +class FeedbackResponseListSerializer(serializers.Serializer): + count = serializers.IntegerField() + next = serializers.URLField(allow_null=True) + previous = serializers.URLField(allow_null=True) + results = FeedbackResponseSerializer(many=True) + + +class FeedbackFormSerializer(serializers.Serializer): + event = serializers.DictField(read_only=True) + survey = FeedbackSurveySerializer(read_only=True) + mode = serializers.ChoiceField(choices=("anonymous", "certificate"), read_only=True) + requires_auth = serializers.BooleanField(read_only=True) + certificate = CertificateSummarySerializer(allow_null=True, read_only=True, required=False) + + +class FeedbackSubmissionSerializer(serializers.Serializer): + answers = serializers.DictField(required=False, default=dict) + email = serializers.EmailField(required=False) + + +class FeedbackSubmissionResponseSerializer(serializers.Serializer): + thank_you_text = serializers.CharField() + certificate = CertificateSummarySerializer(allow_null=True) + + +class CertificateDownloadSerializer(serializers.Serializer): + url = serializers.URLField() + expires_at = serializers.DateTimeField() + + +class CertificateRevokeSerializer(serializers.Serializer): + reason = serializers.ChoiceField( + choices=(EventAttendanceCertificate.RevocationReason.STAFF_REVOKED,), + ) + + +class AttendanceCorrectionResponseSerializer(serializers.Serializer): + registration_id = serializers.IntegerField() + status = serializers.CharField() + revoked_certificates = serializers.IntegerField() diff --git a/backend/apps/events/serializers_organizers.py b/backend/apps/events/serializers_organizers.py new file mode 100644 index 00000000..c3a1ffbd --- /dev/null +++ b/backend/apps/events/serializers_organizers.py @@ -0,0 +1,15 @@ +from rest_framework import serializers + +from apps.events.serializers_public import EventOrganizerSummarySerializer + + +class EventOrganizerReplaceSerializer(serializers.Serializer): + organization_ids = serializers.ListField( + child=serializers.IntegerField(min_value=1), + max_length=50, + allow_empty=True, + ) + + +class EventOrganizerListSerializer(serializers.Serializer): + organizers = EventOrganizerSummarySerializer(many=True, read_only=True) diff --git a/backend/apps/events/serializers_public.py b/backend/apps/events/serializers_public.py index 02772563..db50bac8 100644 --- a/backend/apps/events/serializers_public.py +++ b/backend/apps/events/serializers_public.py @@ -1,7 +1,12 @@ from drf_spectacular.utils import extend_schema_field from rest_framework import serializers +from django.utils import timezone -from apps.events.capacity import availability_label +from apps.events.capacity import ( + availability_label, + effective_registration_cutoff, + registration_is_open, +) from apps.events.models import Event, EventRegistration from apps.forms_schema.serializers import CustomFormSubmissionMixin from apps.inventory import public_image_storage @@ -18,13 +23,18 @@ 'custom_form', 'capacity', 'availability', + 'registration_requires_approval', + 'effective_registration_cutoff_at', + 'registration_open', 'image_url', 'status', 'organizers', + 'series', ) class EventOrganizerSummarySerializer(serializers.Serializer): + id = serializers.IntegerField(source='organization.id', read_only=True) slug = serializers.SlugField(source='organization.slug', read_only=True) name = serializers.CharField(source='organization.name', read_only=True) @@ -43,12 +53,16 @@ class PublicEventSerializer(serializers.Serializer): custom_form = serializers.JSONField(allow_null=True, read_only=True) capacity = serializers.IntegerField(min_value=0, read_only=True) availability = serializers.SerializerMethodField() + registration_requires_approval = serializers.BooleanField(read_only=True) + effective_registration_cutoff_at = serializers.SerializerMethodField() + registration_open = serializers.SerializerMethodField() image_url = serializers.SerializerMethodField() status = serializers.ChoiceField( choices=[Event.Status.PUBLISHED], read_only=True, ) organizers = EventOrganizerSummarySerializer(many=True, read_only=True) + series = serializers.SerializerMethodField() @extend_schema_field( { @@ -59,11 +73,34 @@ class PublicEventSerializer(serializers.Serializer): def get_availability(self, obj): return availability_label(obj) + @extend_schema_field(serializers.DateTimeField(allow_null=True)) + def get_effective_registration_cutoff_at(self, obj): + return effective_registration_cutoff(obj) + + @extend_schema_field(serializers.BooleanField()) + def get_registration_open(self, obj): + return registration_is_open(obj, timezone.now()) + # The object key itself stays server-side; the public payload carries only the # resolved URL, exactly as PublicMachineSerializer does. @extend_schema_field(serializers.URLField(allow_null=True)) def get_image_url(self, obj): - return public_image_storage.public_url(obj.image_key) or None + key = obj.image_key + if obj.series_id and "image_key" not in (obj.series_override_fields or []): + key = obj.series.image_key + return public_image_storage.public_url(key) or None + + @extend_schema_field({ + 'type': 'object', 'nullable': True, + 'properties': { + 'public_token': {'type': 'string', 'format': 'uuid'}, + 'title': {'type': 'string'}, + }, + }) + def get_series(self, obj): + if not obj.series_id: + return None + return {'public_token': obj.series.public_token, 'title': obj.series.title} class PublicEventRegistrationInputSerializer( @@ -77,6 +114,7 @@ def custom_form_schema(self): class PublicEventRegistrationResponseSerializer(serializers.Serializer): status = serializers.ChoiceField( choices=( + EventRegistration.Status.PENDING_APPROVAL, EventRegistration.Status.REGISTERED, EventRegistration.Status.WAITLISTED, ), diff --git a/backend/apps/events/serializers_series.py b/backend/apps/events/serializers_series.py new file mode 100644 index 00000000..fe71cc84 --- /dev/null +++ b/backend/apps/events/serializers_series.py @@ -0,0 +1,96 @@ +from rest_framework import serializers +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import extend_schema_field + +from apps.events.models import EventSeries +from apps.forms_schema.serializers import CustomFormSchemaField +from apps.inventory import public_image_storage + + +class EventSeriesWriteSerializer(serializers.Serializer): + title = serializers.CharField(max_length=200) + description = serializers.CharField(allow_blank=True, default="", required=False) + location = serializers.CharField(allow_blank=True, default="", max_length=255, required=False) + location_kind = serializers.ChoiceField( + choices=(('indoor', 'Indoor'), ('outdoor', 'Outdoor'), ('other', 'Other')), + default="other", required=False, + ) + custom_form = CustomFormSchemaField(allow_null=True, required=False) + capacity = serializers.IntegerField(default=0, min_value=0, required=False) + payment_amount = serializers.DecimalField( + max_digits=12, decimal_places=2, min_value=0, default=0, required=False, + ) + registration_requires_approval = serializers.BooleanField(default=False, required=False) + registration_cutoff_lead_minutes = serializers.IntegerField( + allow_null=True, default=None, min_value=0, required=False, + ) + is_public = serializers.BooleanField(default=False, required=False) + recurrence_timezone = serializers.CharField(max_length=64) + dtstart_local_date = serializers.DateField() + dtstart_local_time = serializers.TimeField() + recurrence_rule = serializers.CharField(max_length=500) + duration_minutes = serializers.IntegerField(min_value=1) + effective_from = serializers.DateTimeField(required=False, write_only=True) + + +class EventSeriesSummarySerializer(serializers.ModelSerializer): + makerspace_id = serializers.IntegerField(read_only=True) + next_occurrence_at = serializers.SerializerMethodField() + future_occurrence_count = serializers.SerializerMethodField() + + class Meta: + model = EventSeries + fields = ( + "id", "public_token", "makerspace_id", "title", "status", + "recurrence_timezone", "dtstart_local_date", "dtstart_local_time", + "recurrence_rule", "duration_minutes", "revision", "next_occurrence_at", + "future_occurrence_count", "last_materialized_at", + "last_generation_error_code", "updated_at", + ) + read_only_fields = fields + + @extend_schema_field(OpenApiTypes.DATETIME) + def get_next_occurrence_at(self, obj): + value = getattr(obj, "next_occurrence_at", None) + if value is not None: + return value + row = obj.occurrences.filter(status__in=("draft", "published")).order_by("starts_at").first() + return row.starts_at if row else None + + @extend_schema_field(OpenApiTypes.INT) + def get_future_occurrence_count(self, obj): + value = getattr(obj, "future_occurrence_count", None) + if value is not None: + return value + return obj.occurrences.filter(status__in=("draft", "published")).count() + + +class EventSeriesDetailSerializer(EventSeriesSummarySerializer): + created_by_id = serializers.IntegerField(allow_null=True, read_only=True) + image_url = serializers.SerializerMethodField() + + class Meta(EventSeriesSummarySerializer.Meta): + fields = EventSeriesSummarySerializer.Meta.fields + ( + "description", "location", "location_kind", "custom_form", "capacity", + "payment_amount", "registration_requires_approval", + "registration_cutoff_lead_minutes", "is_public", "created_by_id", "created_at", + "image_url", + ) + + @extend_schema_field(OpenApiTypes.URI) + def get_image_url(self, obj): + return public_image_storage.public_url(obj.image_key) or None + + +class EventSeriesMutationResponseSerializer(serializers.Serializer): + series = EventSeriesDetailSerializer(read_only=True) + created_occurrence_ids = serializers.ListField(child=serializers.IntegerField(), read_only=True) + removed_occurrence_ids = serializers.ListField(child=serializers.IntegerField(), read_only=True) + affected_count = serializers.IntegerField(read_only=True) + + +class EventSeriesListResponseSerializer(serializers.Serializer): + count = serializers.IntegerField() + next = serializers.CharField(allow_null=True) + previous = serializers.CharField(allow_null=True) + results = EventSeriesSummarySerializer(many=True) diff --git a/backend/apps/events/serializers_series_collaboration.py b/backend/apps/events/serializers_series_collaboration.py new file mode 100644 index 00000000..ed1329ca --- /dev/null +++ b/backend/apps/events/serializers_series_collaboration.py @@ -0,0 +1,51 @@ +from rest_framework import serializers +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import extend_schema_field + +from apps.events.models import EventSeriesCollaborator + + +class SeriesCollaboratorReplaceSerializer(serializers.Serializer): + slugs = serializers.ListField(child=serializers.SlugField(), allow_empty=True) + + +class SeriesCollaborationRespondSerializer(serializers.Serializer): + accept = serializers.BooleanField() + + +class SeriesCollaboratorSerializer(serializers.ModelSerializer): + series_id = serializers.IntegerField(read_only=True) + makerspace_id = serializers.IntegerField(read_only=True) + makerspace_name = serializers.CharField(source="makerspace.name", read_only=True) + makerspace_slug = serializers.SlugField(source="makerspace.slug", read_only=True) + invited_by_id = serializers.IntegerField(allow_null=True, read_only=True) + responded_by_id = serializers.IntegerField(allow_null=True, read_only=True) + + class Meta: + model = EventSeriesCollaborator + fields = ( + "id", "series_id", "makerspace_id", "makerspace_name", "makerspace_slug", + "status", "invited_by_id", "responded_by_id", "created_at", "responded_at", + ) + read_only_fields = fields + + +class SeriesCollaborationInboxSerializer(serializers.ModelSerializer): + series_id = serializers.IntegerField(read_only=True) + series_title = serializers.CharField(source="series.title", read_only=True) + host_name = serializers.CharField(source="series.makerspace.name", read_only=True) + host_slug = serializers.SlugField(source="series.makerspace.slug", read_only=True) + next_occurrence_at = serializers.SerializerMethodField() + + class Meta: + model = EventSeriesCollaborator + fields = ( + "id", "series_id", "series_title", "host_name", "host_slug", "status", + "next_occurrence_at", "created_at", "responded_at", + ) + read_only_fields = fields + + @extend_schema_field(OpenApiTypes.DATETIME) + def get_next_occurrence_at(self, obj): + occurrence = obj.series.occurrences.filter(status="published").order_by("starts_at").first() + return occurrence.starts_at if occurrence else None diff --git a/backend/apps/events/serializers_station.py b/backend/apps/events/serializers_station.py new file mode 100644 index 00000000..5c65811d --- /dev/null +++ b/backend/apps/events/serializers_station.py @@ -0,0 +1,30 @@ +from rest_framework import serializers + + +class StationPinSerializer(serializers.Serializer): + pin = serializers.RegexField(r"^\d{8}$", write_only=True) + + +class StationRevealSerializer(serializers.Serializer): + current_password = serializers.CharField(trim_whitespace=False, write_only=True) + + +class StationStatusSerializer(serializers.Serializer): + configured = serializers.BooleanField() + enabled = serializers.BooleanField(required=False) + public_token = serializers.UUIDField(required=False) + version = serializers.IntegerField(required=False) + station_url = serializers.URLField(required=False) + rotated_at = serializers.DateTimeField(required=False) + + +class StationRotationSerializer(serializers.Serializer): + pin = serializers.CharField() + public_token = serializers.UUIDField() + version = serializers.IntegerField() + station_url = serializers.URLField() + + +class StationRevealResponseSerializer(serializers.Serializer): + pin = serializers.CharField() + version = serializers.IntegerField() diff --git a/backend/apps/events/series_authority.py b/backend/apps/events/series_authority.py new file mode 100644 index 00000000..c56f5e36 --- /dev/null +++ b/backend/apps/events/series_authority.py @@ -0,0 +1,51 @@ +from django.db.models import Q + +from apps.accounts import rbac +from apps.accounts.models import User +from apps.organizations.models import OrganizationMembership + + +def _is_superadmin(actor): + return bool( + actor and getattr(actor, "is_authenticated", False) + and (actor.is_superuser or actor.role == User.Role.SUPERADMIN) + ) + + +def organizer_series_q(actor, *, prefix=""): + if actor is None or not getattr(actor, "is_authenticated", False) or _is_superadmin(actor): + return Q(pk__in=[]) + organization = f"{prefix}organizers__organization" + membership = f"{organization}__memberships" + actions = Q() + for action in ( + rbac.actions_satisfying(rbac.Action.MANAGE_EVENTS) + & rbac.ORGANIZATION_GRANTABLE_ACTIONS + ): + actions |= Q(**{f"{membership}__granted_actions__contains": [action]}) + return ( + Q(**{ + f"{organization}__is_active": True, + f"{membership}__user": actor, + f"{membership}__status": OrganizationMembership.Status.ACTIVE, + }) + & actions + & ~Q(**{f"{prefix}makerspace_id__in": rbac.archived_makerspace_ids()}) + ) + + +def can_manage_series(actor, series): + if rbac.can(actor, rbac.Action.MANAGE_EVENTS, series.makerspace_id): + return True + if _is_superadmin(actor) or series.makerspace_id in rbac.archived_makerspace_ids(): + return False + memberships = OrganizationMembership.objects.filter( + user=actor, + status=OrganizationMembership.Status.ACTIVE, + organization__is_active=True, + organization__organized_event_series__series=series, + ) + return any( + rbac.Action.MANAGE_EVENTS in rbac.actions_for_organization_membership(row) + for row in memberships + ) diff --git a/backend/apps/events/services.py b/backend/apps/events/services.py index 24bc7605..5d931dc8 100644 --- a/backend/apps/events/services.py +++ b/backend/apps/events/services.py @@ -11,7 +11,10 @@ CapacityConflict, EventInvalidTransition, ) -from apps.events.models import Event, EventRegistration +from apps.events.models import ( + Event, + EventRegistration, +) from apps.events.notifications import notify_event_lifecycle from apps.forms_schema.validation import validate_form_schema from apps.makerspaces import limits @@ -20,8 +23,11 @@ EVENT_FIELDS = frozenset( {'title', 'description', 'starts_at', 'ends_at', 'location', - 'location_kind', 'custom_form', 'capacity', 'is_public', 'payment_amount'} + 'location_kind', 'custom_form', 'capacity', 'is_public', 'payment_amount', + 'registration_requires_approval', 'registration_cutoff_at', + 'registration_cutoff_lead_minutes', 'timezone_name'} ) +INHERITABLE_FIELDS = EVENT_FIELDS | {"image_key"} def _locked_event(event_id): @@ -60,34 +66,13 @@ def _may_promote(event, now): return event.status == Event.Status.PUBLISHED and event.ends_at >= now -def _lock_waiters(event): - return list( - EventRegistration.objects.select_for_update().filter( - event=event, status=EventRegistration.Status.WAITLISTED - ).order_by("created_at", "id") - ) - - -def _promote(event, actor, waiters, count=None): - selected = waiters if count is None else waiters[:count] - for registration in selected: - registration.event = event - registration.status = EventRegistration.Status.REGISTERED - registration.save(update_fields=["status"]) - from apps.events.service_payments import create_for_registered_registration - - create_for_registered_registration(registration, actor) - meta = {"registration_id": registration.pk} - _audit(event, actor, "event.registration_promoted", registration, meta) - notify_event_lifecycle(event, "registration_promoted", registration.pk) - return selected - - @transaction.atomic def create_event( *, makerspace, actor, title, description, starts_at, ends_at, location, capacity, is_public, location_kind=Event.LocationKind.OTHER, custom_form=None, - payment_amount=0, + payment_amount=0, registration_requires_approval=False, + registration_cutoff_at=None, registration_cutoff_lead_minutes=None, + timezone_name=None, ): locked_space = Makerspace.objects.select_for_update().get(pk=makerspace.pk) require_module_locked(locked_space, "events") @@ -103,7 +88,11 @@ def create_event( custom_form=_canonical_form(custom_form), capacity=capacity, payment_amount=payment_amount, + registration_requires_approval=registration_requires_approval, + registration_cutoff_at=registration_cutoff_at, + registration_cutoff_lead_minutes=registration_cutoff_lead_minutes, is_public=is_public, + **({"timezone_name": timezone_name} if timezone_name else {}), ) _validate(event) event.save() @@ -112,7 +101,9 @@ def create_event( @transaction.atomic -def update_event(event, *, actor, **changes): +def update_event(event, *, actor, inherit_fields=(), **changes): + from apps.events.services_calendar import CALENDAR_EVENT_FIELDS, calendar_event_changed + locked = _locked_event(event.pk) if locked.status not in (Event.Status.DRAFT, Event.Status.PUBLISHED): raise EventInvalidTransition("Terminal events cannot be updated.") @@ -121,12 +112,38 @@ def update_event(event, *, actor, **changes): raise serializers.ValidationError( {field: "This field cannot be updated." for field in sorted(unknown)} ) + inherit_fields = set(inherit_fields or ()) + invalid_inherit = inherit_fields - INHERITABLE_FIELDS + if invalid_inherit: + raise serializers.ValidationError( + {field: "This field cannot inherit from a series." for field in invalid_inherit} + ) + if inherit_fields & set(changes): + raise serializers.ValidationError( + {field: "A field cannot be changed and inherited together." for field in inherit_fields & set(changes)} + ) + if inherit_fields and locked.series_id is None: + raise serializers.ValidationError({"inherit_fields": "This event is not in a series."}) + if inherit_fields: + from apps.events.services_series import occurrence_inherited_value + + for field in inherit_fields: + changes[field] = occurrence_inherited_value(locked, field) if 'custom_form' in changes: changes['custom_form'] = _canonical_form(changes['custom_form']) + if ( + "registration_requires_approval" in changes + and changes["registration_requires_approval"] + != locked.registration_requires_approval + and locked.status != Event.Status.DRAFT + ): + raise EventInvalidTransition( + "Approval policy can only be changed while the event is a draft." + ) now = timezone.now() - old_capacity, old_ends_at = locked.capacity, locked.ends_at + old_capacity, old_ends_at, old_image_key = locked.capacity, locked.ends_at, locked.image_key for field, value in changes.items(): setattr(locked, field, value) _validate(locked) @@ -148,17 +165,33 @@ def update_event(event, *, actor, **changes): _may_promote(locked, now) and (locked.capacity == 0 or locked.capacity > confirmed) ): - waiters = _lock_waiters(locked) - if locked.capacity == 0: - promoted = _promote(locked, actor, waiters) - else: - promoted = _promote( - locked, actor, waiters, locked.capacity - confirmed + if not locked.registration_requires_approval: + promoted = promote_automatically( + locked, + actor, + None if locked.capacity == 0 else locked.capacity - confirmed, ) if changes: - locked.save(update_fields=[*sorted(changes), "updated_at"]) + update_fields = set(changes) + if locked.series_id: + overrides = set(locked.series_override_fields or []) + overrides.update(set(changes) - inherit_fields) + overrides.difference_update(inherit_fields) + locked.series_override_fields = sorted(overrides) + update_fields.add("series_override_fields") + locked.save(update_fields=[*sorted(update_fields), "updated_at"]) + if set(changes) & CALENDAR_EVENT_FIELDS: + calendar_event_changed(locked) + if "image_key" in inherit_fields and old_image_key: + from apps.inventory import public_image_storage + + public_image_storage.release_public_image_on_commit( + locked.makerspace, old_image_key + ) meta = {"changed_fields": sorted(changes)} + if inherit_fields: + meta["inherited_fields"] = sorted(inherit_fields) if capacity_changed: meta.update( old_capacity=old_capacity, @@ -170,94 +203,14 @@ def update_event(event, *, actor, **changes): return _refresh(locked) -def _transition(event, actor, expected, new_status, action): - locked = _locked_event(event.pk) - if locked.status != expected: - message = f"Cannot transition event from {locked.status} to {new_status}." - raise EventInvalidTransition(message) - locked.status = new_status - locked.save(update_fields=["status", "updated_at"]) - meta = {"old_status": expected, "new_status": new_status} - _audit(locked, actor, action, locked, meta) - notify_event_lifecycle(locked, new_status) - return _refresh(locked) - - -@transaction.atomic -def publish(event, *, actor): - locked = _locked_event(event.pk) - if locked.status != Event.Status.DRAFT: - raise EventInvalidTransition("Only draft events can be published.") - _validate(locked) - if locked.ends_at < timezone.now(): - raise EventInvalidTransition("Ended events cannot be published.") - require_module_locked(locked.makerspace, "events") - limits.check_quota(locked.makerspace, "events", adding=1) - locked.status = Event.Status.PUBLISHED - locked.save(update_fields=["status", "updated_at"]) - meta = {"old_status": Event.Status.DRAFT, "new_status": Event.Status.PUBLISHED} - _audit(locked, actor, "event.published", locked, meta) - notify_event_lifecycle(locked, "published") - return _refresh(locked) - - -@transaction.atomic -def cancel(event, *, actor): - return _transition(event, actor, Event.Status.PUBLISHED, Event.Status.CANCELLED, "event.cancelled") - - -@transaction.atomic -def complete(event, *, actor): - return _transition(event, actor, Event.Status.PUBLISHED, Event.Status.COMPLETED, "event.completed") - - -@transaction.atomic -def cancel_registration(registration, *, actor=None): - event = _locked_event(registration.event_id) - locked = EventRegistration.objects.select_for_update().get(pk=registration.pk) - if locked.event_id != event.pk or locked.status not in ( - EventRegistration.Status.REGISTERED, - EventRegistration.Status.WAITLISTED, - ): - raise EventInvalidTransition("This registration cannot be cancelled.") - old_status = locked.status - locked.status = EventRegistration.Status.CANCELLED - locked.save(update_fields=["status"]) - meta = {"registration_id": locked.pk, "old_status": old_status} - _audit(event, actor, "event.registration_cancelled", locked, meta) - notify_event_lifecycle(event, "registration_cancelled", locked.pk) - from apps.events.service_payments import cancel_for_registration - - cancel_for_registration(locked, actor) - if ( - old_status == EventRegistration.Status.REGISTERED - and event.capacity > 0 - and _may_promote(event, timezone.now()) - ): - waiters = _lock_waiters(event) - if waiters: - _promote(event, actor, waiters, 1) - return _refresh(locked) - - -@transaction.atomic -def mark_attended(registration, *, actor): - event = _locked_event(registration.event_id) - locked = EventRegistration.objects.select_for_update().get(pk=registration.pk) - if ( - locked.event_id != event.pk - or locked.status != EventRegistration.Status.REGISTERED - or event.status not in (Event.Status.PUBLISHED, Event.Status.COMPLETED) - ): - raise EventInvalidTransition("This registration cannot be marked attended.") - locked.status = EventRegistration.Status.ATTENDED - locked.save(update_fields=["status"]) - _audit( - event, actor, "event.registration_attended", locked, - {"registration_id": locked.pk}, - ) - notify_event_lifecycle(event, "registration_attended", locked.pk) - return _refresh(locked) - - from apps.events.services_registration import register # noqa: E402 +from apps.events.services_registration_state import ( # noqa: E402 + approve_registration, + cancel_registration, + correct_attendance, + promote_automatically, + promote_registration, + reject_registration, +) +from apps.events.services_checkin import mark_attended # noqa: E402 +from apps.events.services_lifecycle import cancel, complete, publish # noqa: E402 diff --git a/backend/apps/events/services_badges.py b/backend/apps/events/services_badges.py new file mode 100644 index 00000000..b9caa341 --- /dev/null +++ b/backend/apps/events/services_badges.py @@ -0,0 +1,141 @@ +from dataclasses import dataclass +import hashlib +import json +from zoneinfo import ZoneInfo + +from django.db import transaction +from django.utils import timezone +from rest_framework import serializers + +from apps.audit import services as audit +from apps.events.badge_templates import ( + MAX_BADGES, + MAX_PAGES, + MAX_TEXT_LENGTH, + normalize_badge_template, + page_layout, +) +from apps.events.exceptions import EventInvalidTransition +from apps.events.models import Event, EventRegistration +from apps.makerspaces.guards import require_module_locked + + +@dataclass(frozen=True) +class BadgeSnapshot: + registration_id: int + checkin_token: str + fields: tuple[tuple[str, str], ...] + + +def _answer_values(registration): + snapshot = registration.custom_answers or {} + answers = snapshot.get("answers", []) if isinstance(snapshot, dict) else [] + return { + str(answer.get("id")): answer.get("value") + for answer in answers + if isinstance(answer, dict) and answer.get("id") is not None + } + + +def _text(value): + if isinstance(value, list): + value = ", ".join(str(item) for item in value) + elif isinstance(value, bool): + value = "Yes" if value else "No" + elif value is None: + value = "" + value = str(value) + if len(value) > MAX_TEXT_LENGTH: + raise serializers.ValidationError( + {"fields": f"Selected badge text exceeds {MAX_TEXT_LENGTH} characters."} + ) + return value + + +def _field_values(event, registration, selectors): + labels = {str(row["id"]): row["label"] for row in (event.custom_form or [])} + answers = _answer_values(registration) + date_time = timezone.localtime(event.starts_at, timezone=ZoneInfo(event.timezone_name)) + values = { + "name": ("Name", registration.name), + "event_title": ("Event", event.title), + "date_time": ("When", date_time.strftime("%d %b %Y, %H:%M")), + "location": ("Location", event.location), + "registration_number": ("Registration", str(registration.pk)), + "email": ("Email", registration.email), + "phone": ("Phone", registration.phone), + } + output = [] + for selector in selectors: + if selector.startswith("custom:"): + key = selector[7:] + output.append((labels[key], _text(answers.get(key, "")))) + else: + label, value = values[selector] + output.append((label, _text(value))) + return tuple(output) + + +@transaction.atomic +def save_badge_template(event, template, *, actor): + locked = Event.objects.select_for_update().get(pk=event.pk) + require_module_locked(locked.makerspace_id, "events") + if locked.status == Event.Status.CANCELLED: + raise EventInvalidTransition("Cancelled events cannot change badge templates.") + normalized = normalize_badge_template(template, locked) + locked.badge_template = normalized + locked.save(update_fields=("badge_template", "updated_at")) + audit.record( + actor, "event.badge_template_updated", makerspace=locked.makerspace, + target=locked, meta={"version": normalized["version"], "fields": normalized["fields"]}, + ) + return normalized + + +@transaction.atomic +def prepare_badges(event, registration_ids, *, actor, template_override=None, + include_attended=False): + if not registration_ids or len(registration_ids) > MAX_BADGES: + raise serializers.ValidationError( + {"registration_ids": f"Choose between 1 and {MAX_BADGES} registrations."} + ) + if len(registration_ids) != len(set(registration_ids)): + raise serializers.ValidationError({"registration_ids": "Duplicate IDs are not allowed."}) + locked = Event.objects.select_for_update().get(pk=event.pk) + require_module_locked(locked.makerspace_id, "events") + if locked.status not in (Event.Status.PUBLISHED, Event.Status.COMPLETED): + raise EventInvalidTransition("Badges require a published or completed event.") + normalized = normalize_badge_template( + template_override if template_override is not None else locked.badge_template, + locked, + ) + _width, _height, columns, rows = page_layout(normalized) + pages = (len(registration_ids) + columns * rows - 1) // (columns * rows) + if pages > MAX_PAGES: + raise serializers.ValidationError({"registration_ids": "The badge PDF is too large."}) + registrations = list(EventRegistration.objects.select_for_update().filter( + event=locked, pk__in=registration_ids, + ).order_by("pk")) + if len(registrations) != len(registration_ids): + raise EventRegistration.DoesNotExist + allowed = {EventRegistration.Status.REGISTERED} + if include_attended: + allowed.add(EventRegistration.Status.ATTENDED) + if any(registration.status not in allowed for registration in registrations): + raise EventInvalidTransition("A selected registration is not eligible for a badge.") + snapshots = tuple(BadgeSnapshot( + registration_id=registration.pk, + checkin_token=str(registration.checkin_token), + fields=_field_values(locked, registration, normalized["fields"]), + ) for registration in registrations) + selected_digest = hashlib.sha256(json.dumps( + sorted(registration_ids), separators=(",", ":") + ).encode()).hexdigest() + audit.record( + actor, "event.badges_generated", makerspace=locked.makerspace, target=locked, + meta={ + "count": len(snapshots), "fields": normalized["fields"], + "template_version": normalized["version"], "selected_ids_sha256": selected_digest, + }, + ) + return normalized, snapshots diff --git a/backend/apps/events/services_calendar.py b/backend/apps/events/services_calendar.py new file mode 100644 index 00000000..2d19d289 --- /dev/null +++ b/backend/apps/events/services_calendar.py @@ -0,0 +1,236 @@ +from datetime import date, datetime, timedelta, timezone as dt_timezone +from zoneinfo import ZoneInfo + +from django.db.models import F +from django.utils import timezone + +from apps.events.models import Event, EventRegistration, EventSeries +from apps.events.services_recurrence import local_anchor + + +CALENDAR_EVENT_FIELDS = frozenset({ + "title", "description", "starts_at", "ends_at", "location", "timezone_name", "is_public", +}) +CALENDAR_SERIES_FIELDS = frozenset({ + "title", "description", "location", "recurrence_timezone", "dtstart_local_date", + "dtstart_local_time", "recurrence_rule", "duration_minutes", "is_public", +}) + + +def calendar_event_changed(event, *, now=None): + now = now or timezone.now() + Event.objects.filter(pk=event.pk).update( + calendar_sequence=F("calendar_sequence") + 1, + calendar_updated_at=now, + ) + event.refresh_from_db(fields=("calendar_sequence", "calendar_updated_at")) + return event + + +def calendar_registration_changed(registration, *, now=None): + now = now or timezone.now() + EventRegistration.objects.filter(pk=registration.pk).update( + calendar_sequence=F("calendar_sequence") + 1, + calendar_updated_at=now, + ) + registration.refresh_from_db(fields=("calendar_sequence", "calendar_updated_at")) + return registration + + +def calendar_series_changed(series, *, now=None): + now = now or timezone.now() + EventSeries.objects.filter(pk=series.pk).update( + calendar_sequence=F("calendar_sequence") + 1, + calendar_updated_at=now, + ) + series.refresh_from_db(fields=("calendar_sequence", "calendar_updated_at")) + return series + + +def _icalendar_types(): + from icalendar import Calendar, Event as ICalEvent, Timezone, vRecur + + return Calendar, ICalEvent, Timezone, vRecur + + +def _calendar(name, *, method="PUBLISH"): + Calendar, _ICalEvent, _Timezone, _vRecur = _icalendar_types() + calendar = Calendar() + calendar.add("prodid", "-//SpaceWorks//Events//EN") + calendar.add("version", "2.0") + calendar.add("calscale", "GREGORIAN") + calendar.add("method", method) + calendar.add("x-wr-calname", name) + return calendar + + +def _utc(value): + return value.astimezone(dt_timezone.utc) + + +def _add_common(component, *, uid, title, description, location, starts_at, + ends_at, status, sequence, updated_at): + component.add("uid", uid) + component.add("summary", title) + if description: + component.add("description", description) + if location: + component.add("location", location) + component.add("dtstart", starts_at) + component.add("dtend", ends_at) + component.add("dtstamp", _utc(updated_at)) + component.add("last-modified", _utc(updated_at)) + component.add("status", status) + component.add("sequence", sequence) + + +def _event_component(event, *, registration=None): + _Calendar, ICalEvent, _Timezone, _vRecur = _icalendar_types() + component = ICalEvent() + status = "CONFIRMED" + sequence = event.calendar_sequence + description = event.description + if event.status == Event.Status.CANCELLED: + status = "CANCELLED" + if registration is not None: + sequence += registration.calendar_sequence + updated = max(event.calendar_updated_at, registration.calendar_updated_at) + if event.status == Event.Status.CANCELLED: + status = "CANCELLED" + elif registration.status in ( + EventRegistration.Status.PENDING_APPROVAL, + EventRegistration.Status.WAITLISTED, + ): + status = "TENTATIVE" + elif registration.status in ( + EventRegistration.Status.REJECTED, + EventRegistration.Status.CANCELLED, + ): + status = "CANCELLED" + state = registration.get_status_display() + description = f"Registration status: {state}." + ( + f"\n\n{event.description}" if event.description else "" + ) + else: + updated = event.calendar_updated_at + _add_common( + component, + uid=f"event-{event.calendar_uid}@spaceworks", + title=event.title, + description=description, + location=event.location, + starts_at=_utc(event.starts_at), + ends_at=_utc(event.ends_at), + status=status, + sequence=sequence, + updated_at=updated, + ) + return component + + +def render_public_event_calendar(event): + if event.series_id and event.series.is_public: + return render_public_series_calendar(event.series) + method = "CANCEL" if event.status == Event.Status.CANCELLED else "PUBLISH" + calendar = _calendar(event.title, method=method) + calendar.add_component(_event_component(event)) + return calendar.to_ical() + + +def _series_local_start(series): + return local_anchor( + local_date=series.dtstart_local_date, + local_time=series.dtstart_local_time, + timezone_name=series.recurrence_timezone, + ) + + +def _add_vtimezone(calendar, series): + _Calendar, _ICalEvent, Timezone, _vRecur = _icalendar_types() + zone = ZoneInfo(series.recurrence_timezone) + first = series.dtstart_local_date - timedelta(days=366) + # Unbounded RRULEs outlive the currently materialized occurrence window. Carry a + # long transition horizon so calendar clients do not silently freeze DST rules at + # the library's historical 2038 default. + last = first + timedelta(days=366 * 50) + calendar.add_component( + Timezone.from_tzinfo(zone, tzid=series.recurrence_timezone, + first_date=first, last_date=last) + ) + + +def _series_exception(series, event): + _Calendar, ICalEvent, _Timezone, _vRecur = _icalendar_types() + component = ICalEvent() + local_text = event.series_occurrence_key.split(":", 1)[1] + recurrence_id = datetime.strptime(local_text, "%Y%m%dT%H%M%S").replace( + tzinfo=ZoneInfo(series.recurrence_timezone), fold=0 + ) + hidden = not event.is_public + status = "CANCELLED" if event.status == Event.Status.CANCELLED or hidden else "CONFIRMED" + _add_common( + component, + uid=f"event-series-{series.calendar_uid}@spaceworks", + # A private exception still has to cancel the public RRULE instance, but its + # private title/location/moved time must not hitchhike into the public feed. + title=series.title if hidden else event.title, + description=series.description if hidden else event.description, + location=series.location if hidden else event.location, + starts_at=recurrence_id if hidden else _utc(event.starts_at), + ends_at=( + recurrence_id + timedelta(minutes=series.duration_minutes) + if hidden else _utc(event.ends_at) + ), + status=status, + sequence=series.calendar_sequence + event.calendar_sequence, + updated_at=max(series.calendar_updated_at, event.calendar_updated_at), + ) + component.add("recurrence-id", recurrence_id) + return component + + +def render_public_series_calendar(series): + _Calendar, ICalEvent, _Timezone, vRecur = _icalendar_types() + method = "CANCEL" if series.status == EventSeries.Status.CANCELLED else "PUBLISH" + calendar = _calendar(series.title, method=method) + _add_vtimezone(calendar, series) + master = ICalEvent() + start = _series_local_start(series) + status = "CANCELLED" if series.status == EventSeries.Status.CANCELLED else "CONFIRMED" + _add_common( + master, + uid=f"event-series-{series.calendar_uid}@spaceworks", + title=series.title, + description=series.description, + location=series.location, + starts_at=start, + ends_at=start + timedelta(minutes=series.duration_minutes), + status=status, + sequence=series.calendar_sequence, + updated_at=series.calendar_updated_at, + ) + master.add("rrule", vRecur.from_ical(series.recurrence_rule)) + calendar.add_component(master) + exceptions = series.occurrences.filter(series_revision=series.revision).exclude( + series_override_fields=[] + ) | series.occurrences.filter( + series_revision=series.revision, + status=Event.Status.CANCELLED, + ) | series.occurrences.filter( + series_revision=series.revision, + is_public=False, + ) + for event in exceptions.distinct().order_by("starts_at", "pk"): + calendar.add_component(_series_exception(series, event)) + return calendar.to_ical() + + +def render_member_calendar(makerspace, registrations): + calendar = _calendar(f"{makerspace.name} - My events") + for registration in registrations.select_related("event").order_by( + "event__starts_at", "pk" + ): + calendar.add_component( + _event_component(registration.event, registration=registration) + ) + return calendar.to_ical() diff --git a/backend/apps/events/services_calendar_feeds.py b/backend/apps/events/services_calendar_feeds.py new file mode 100644 index 00000000..a90c103a --- /dev/null +++ b/backend/apps/events/services_calendar_feeds.py @@ -0,0 +1,92 @@ +import hashlib +import hmac +import re +import secrets + +from django.db import transaction +from django.utils import timezone + +from apps.audit import services as audit +from apps.events.models import MemberCalendarFeed +from apps.makerspaces.guards import require_module_locked +from apps.makerspaces.models import MakerspaceMembership + + +def token_digest(raw_token): + return hashlib.sha256(raw_token.encode("ascii")).digest() + + +def feed_state(membership): + feed = MemberCalendarFeed.objects.filter(membership=membership).first() + if feed is None or feed.revoked_at is not None: + return {"enabled": False, "token_hint": None, "created_at": None, "rotated_at": None} + return { + "enabled": True, + "token_hint": feed.token_hint, + "created_at": feed.created_at, + "rotated_at": feed.rotated_at, + } + + +@transaction.atomic +def issue_or_rotate_feed(membership, *, actor): + locked_membership = MakerspaceMembership.objects.select_for_update().get(pk=membership.pk) + if locked_membership.status != "active": + raise PermissionError("Active membership is required.") + require_module_locked(locked_membership.makerspace_id, "events") + feed = MemberCalendarFeed.objects.select_for_update().filter( + membership=locked_membership + ).first() + raw_token = secrets.token_urlsafe(32) + digest = token_digest(raw_token) + hint = raw_token[-8:] + now = timezone.now() + if feed is None: + feed = MemberCalendarFeed.objects.create( + membership=locked_membership, token_digest=digest, token_hint=hint + ) + action = "event.calendar_feed_created" + else: + feed.token_digest = digest + feed.token_hint = hint + feed.rotated_at = now + feed.revoked_at = None + feed.save(update_fields=("token_digest", "token_hint", "rotated_at", "revoked_at")) + action = "event.calendar_feed_rotated" + audit.record( + actor, action, makerspace=locked_membership.makerspace, target=feed, + meta={"membership_id": locked_membership.pk}, + ) + return feed, raw_token + + +@transaction.atomic +def revoke_feed(membership, *, actor): + locked_membership = MakerspaceMembership.objects.select_for_update().get(pk=membership.pk) + require_module_locked(locked_membership.makerspace_id, "events") + feed = MemberCalendarFeed.objects.select_for_update().filter( + membership=locked_membership, revoked_at__isnull=True + ).first() + if feed is None: + return False + feed.revoked_at = timezone.now() + feed.save(update_fields=("revoked_at",)) + audit.record( + actor, "event.calendar_feed_revoked", makerspace=locked_membership.makerspace, + target=feed, meta={"membership_id": locked_membership.pk}, + ) + return True + + +def resolve_feed(raw_token): + # token_urlsafe(32) is exactly 43 base64url characters. Reject malformed values + # before hashing so arbitrary Unicode paths cannot become a 500/error oracle. + if not re.fullmatch(r"[A-Za-z0-9_-]{43}", raw_token or ""): + return None + digest = token_digest(raw_token) + feed = MemberCalendarFeed.objects.select_related( + "membership__makerspace", "membership__user" + ).filter(token_digest=digest, revoked_at__isnull=True).first() + if feed is None or not hmac.compare_digest(bytes(feed.token_digest), digest): + return None + return feed diff --git a/backend/apps/events/services_certificates.py b/backend/apps/events/services_certificates.py new file mode 100644 index 00000000..dc51df07 --- /dev/null +++ b/backend/apps/events/services_certificates.py @@ -0,0 +1,209 @@ +import logging + +from django.db import transaction +from django.db.models import Max +from django.utils import timezone + +from apps.audit import services as audit +from apps.events.certificate_rendering import render_certificate_pdf +from apps.events.certificate_storage import ( + CertificateStorageUnavailable, + presigned_download, + store_immutable_pdf, +) +from apps.events.exceptions import EventInvalidTransition +from apps.events.models import Event, EventAttendanceCertificate, EventRegistration +from apps.makerspaces import limits + + +logger = logging.getLogger(__name__) + + +def create_pending(response): + registration = response.registration + if ( + registration is None + or registration.status != EventRegistration.Status.ATTENDED + ): + raise EventInvalidTransition( + "Attendance is required before a certificate can be created." + ) + event = registration.event + revision = ( + EventAttendanceCertificate.objects.filter(registration=registration) + .aggregate(value=Max("revision"))["value"] + or 0 + ) + 1 + certificate = EventAttendanceCertificate( + response=response, + registration=registration, + revision=revision, + recipient_name=registration.name, + event_title=event.title, + event_starts_at=event.starts_at, + event_ends_at=event.ends_at, + issuer_name=event.makerspace.name, + ) + certificate.object_key = ( + f"event-certificates/{event.makerspace_id}/{certificate.serial}.pdf" + ) + certificate.save() + return certificate + + +def render_certificate(certificate): + claim = _claim_render(certificate.pk) + if claim.status == EventAttendanceCertificate.Status.ACTIVE: + return claim + try: + payload = render_certificate_pdf(claim) + size, digest = store_immutable_pdf(claim.object_key, payload) + return _activate(claim.pk, size, digest) + except EventInvalidTransition: + _fail_render(claim.pk) + raise + except Exception as exc: + _fail_render(claim.pk) + if isinstance(exc, CertificateStorageUnavailable): + raise + logger.exception("event_certificate_render_failed", extra={"certificate_id": claim.pk}) + raise CertificateStorageUnavailable from exc + + +def download_url(certificate): + if certificate.status == EventAttendanceCertificate.Status.REVOKED: + raise EventInvalidTransition("Revoked certificates cannot be downloaded.") + if certificate.status in { + EventAttendanceCertificate.Status.PENDING, + EventAttendanceCertificate.Status.FAILED, + }: + certificate = render_certificate(certificate) + if certificate.status != EventAttendanceCertificate.Status.ACTIVE: + raise EventInvalidTransition("Certificate rendering is still in progress.") + return certificate, presigned_download(certificate.object_key) + + +@transaction.atomic +def revoke(certificate, *, actor, reason): + locked = EventAttendanceCertificate.objects.select_for_update().select_related( + "registration__event__makerspace" + ).get(pk=certificate.pk) + if locked.status != EventAttendanceCertificate.Status.ACTIVE: + raise EventInvalidTransition("Only an active certificate can be revoked.") + locked.status = EventAttendanceCertificate.Status.REVOKED + locked.revoked_at = timezone.now() + locked.revoked_by = actor + locked.revocation_reason = reason + locked.save(update_fields=["status", "revoked_at", "revoked_by", "revocation_reason"]) + audit.record( + actor, + "event.certificate_revoked", + makerspace=locked.registration.event.makerspace, + target=locked, + meta={"reason": reason, "revision": locked.revision}, + ) + return locked + + +@transaction.atomic +def reissue(registration, *, actor): + locked = EventRegistration.objects.select_for_update().select_related( + "event__makerspace" + ).get(pk=registration.pk) + if locked.status != EventRegistration.Status.ATTENDED: + raise EventInvalidTransition("Attendance is required for certificate reissue.") + if locked.attendance_certificates.exclude( + status=EventAttendanceCertificate.Status.REVOKED + ).exists(): + raise EventInvalidTransition("A live certificate already exists.") + response = locked.feedback_responses.order_by("-created_at", "-id").first() + if response is None: + raise EventInvalidTransition("Feedback is required for certificate reissue.") + certificate = create_pending(response) + audit.record( + actor, + "event.certificate_reissued", + makerspace=locked.event.makerspace, + target=certificate, + meta={"revision": certificate.revision}, + ) + return certificate + + +@transaction.atomic +def _claim_render(certificate_id): + identity = EventAttendanceCertificate.objects.values( + "registration_id", "registration__event_id" + ).get(pk=certificate_id) + Event.objects.select_for_update().get(pk=identity["registration__event_id"]) + registration = EventRegistration.objects.select_for_update().get( + pk=identity["registration_id"] + ) + row = EventAttendanceCertificate.objects.select_for_update().select_related( + "registration__event__makerspace" + ).get(pk=certificate_id) + if registration.status != EventRegistration.Status.ATTENDED: + raise EventInvalidTransition("Attendance is required to render a certificate.") + if row.status == EventAttendanceCertificate.Status.ACTIVE: + return row + if row.status == EventAttendanceCertificate.Status.RENDERING: + raise EventInvalidTransition("Certificate rendering is already in progress.") + if row.status == EventAttendanceCertificate.Status.REVOKED: + raise EventInvalidTransition("Revoked certificates cannot be rendered.") + row.status = EventAttendanceCertificate.Status.RENDERING + row.save(update_fields=["status"]) + return row + + +@transaction.atomic +def _activate(certificate_id, size, digest): + identity = EventAttendanceCertificate.objects.values( + "registration_id", "registration__event_id" + ).get(pk=certificate_id) + Event.objects.select_for_update().get(pk=identity["registration__event_id"]) + registration = EventRegistration.objects.select_for_update().get( + pk=identity["registration_id"] + ) + row = EventAttendanceCertificate.objects.select_for_update().select_related( + "registration__event__makerspace" + ).get(pk=certificate_id) + if row.status == EventAttendanceCertificate.Status.ACTIVE: + return row + if row.status != EventAttendanceCertificate.Status.RENDERING: + raise EventInvalidTransition("Certificate render claim was lost.") + if registration.status != EventRegistration.Status.ATTENDED: + row.status = EventAttendanceCertificate.Status.FAILED + row.save(update_fields=["status"]) + raise EventInvalidTransition("Attendance changed while rendering the certificate.") + limits.add_storage(row.registration.event.makerspace, size) + row.size_bytes = size + row.sha256 = digest + row.rendered_at = timezone.now() + row.status = EventAttendanceCertificate.Status.ACTIVE + row.save(update_fields=["size_bytes", "sha256", "rendered_at", "status"]) + audit.record( + None, + "event.certificate_rendered", + makerspace=row.registration.event.makerspace, + target=row, + meta={"revision": row.revision, "size_bytes": size}, + ) + return row + + +@transaction.atomic +def _fail_render(certificate_id): + row = EventAttendanceCertificate.objects.select_for_update().select_related( + "registration__event__makerspace" + ).get(pk=certificate_id) + if row.status != EventAttendanceCertificate.Status.RENDERING: + return + row.status = EventAttendanceCertificate.Status.FAILED + row.save(update_fields=["status"]) + audit.record( + None, + "event.certificate_render_failed", + makerspace=row.registration.event.makerspace, + target=row, + meta={"revision": row.revision}, + ) diff --git a/backend/apps/events/services_checkin.py b/backend/apps/events/services_checkin.py new file mode 100644 index 00000000..d7321d04 --- /dev/null +++ b/backend/apps/events/services_checkin.py @@ -0,0 +1,121 @@ +from uuid import uuid4 + +from django.db import transaction +from django.utils import timezone +from rest_framework.exceptions import PermissionDenied + +from apps.events.exceptions import DuplicateCheckInOperation, EventInvalidTransition +from apps.events.models import ( + Event, + EventCheckInEvent, + EventCheckInStationCredential, + EventRegistration, +) +from apps.makerspaces.guards import require_feature_locked + + +FEATURE_SOURCES = { + EventCheckInEvent.Source.OFFLINE_SYNC, + EventCheckInEvent.Source.VENUE_STATION, +} + + +def _boundary(): + from apps.events import services + + return services + + +def _lock_registration(event, registration_id): + registration = EventRegistration.objects.select_for_update().get(pk=registration_id) + if registration.event_id != event.pk: + raise EventInvalidTransition("Registration does not belong to this event.") + registration.event = event + return registration + + +@transaction.atomic +def mark_attended_with_event( + registration, + *, + actor, + source=EventCheckInEvent.Source.ONLINE, + operation_id=None, + attended_at=None, + session_id=None, + station_version=None, +): + services = _boundary() + event = services._locked_event(registration.event_id) + if source in FEATURE_SOURCES: + event.makerspace = require_feature_locked( + event.makerspace_id, "events.offline_checkin" + ) + if source == EventCheckInEvent.Source.VENUE_STATION: + credential = ( + EventCheckInStationCredential.objects.select_for_update() + .filter( + event=event, + is_enabled=True, + version=station_version, + ) + .only("pk") + .first() + ) + if credential is None: + raise PermissionDenied("Invalid station session.") + operation_id = operation_id or uuid4() + if EventCheckInEvent.objects.filter( + makerspace_id=event.makerspace_id, + operation_id=operation_id, + ).exists(): + raise DuplicateCheckInOperation() + locked = _lock_registration(event, registration.pk) + if ( + locked.status != EventRegistration.Status.REGISTERED + or event.status not in (Event.Status.PUBLISHED, Event.Status.COMPLETED) + ): + raise EventInvalidTransition("This registration cannot be marked attended.") + + occurred_at = attended_at or timezone.now() + check_in = EventCheckInEvent( + makerspace=event.makerspace, + event=event, + registration=locked, + operation_id=operation_id, + source=source, + attended_at=occurred_at, + actor=actor, + session_id=session_id, + station_version=station_version, + ) + # The operation UUID is deployment-global, but validation must not query another + # tenant to explain a collision. The database enforces uniqueness; the service maps + # that race to the uniform idempotent outcome. + check_in.full_clean(validate_unique=False) + check_in.save() + locked.status = EventRegistration.Status.ATTENDED + locked.save(update_fields=["status"]) + services._audit( + event, + actor, + "event.registration_attended", + locked, + { + "registration_id": locked.pk, + "check_in_event_id": check_in.pk, + "source": source, + "operation_id": str(operation_id), + "reported_occurred_at": occurred_at.isoformat(), + "recorded_at": check_in.recorded_at.isoformat(), + "session_id": str(session_id) if session_id else None, + "station_version": station_version, + }, + ) + services.notify_event_lifecycle(event, "registration_attended", locked.pk) + return services._refresh(locked), check_in + + +def mark_attended(registration, **kwargs): + updated, _check_in = mark_attended_with_event(registration, **kwargs) + return updated diff --git a/backend/apps/events/services_checkin_roster.py b/backend/apps/events/services_checkin_roster.py new file mode 100644 index 00000000..c7c1f270 --- /dev/null +++ b/backend/apps/events/services_checkin_roster.py @@ -0,0 +1,82 @@ +from django.db import transaction +from django.utils import timezone +from rest_framework.exceptions import APIException + +from apps.events.checkin_policy import download_is_open +from apps.events.checkin_roster import minimum_roster +from apps.events.checkin_tokens import build_lease +from apps.events.models import Event, EventCheckInStationCredential +from apps.makerspaces.guards import require_feature_locked + + +class RosterWindowClosed(APIException): + status_code = 409 + default_detail = "The offline roster is unavailable outside the event check-in window." + default_code = "outside_window" + + +@transaction.atomic +def issue_roster( + event, + *, + actor, + kind, + session_id=None, + station_version=None, +): + from apps.events import services + + locked = services._locked_event(event.pk) + locked.makerspace = require_feature_locked( + locked.makerspace_id, "events.offline_checkin" + ) + if locked.status not in (Event.Status.PUBLISHED, Event.Status.COMPLETED): + raise RosterWindowClosed() + if not download_is_open(locked): + raise RosterWindowClosed() + if kind == "station": + credential = EventCheckInStationCredential.objects.select_for_update().filter( + event=locked, + is_enabled=True, + version=station_version, + ).first() + if credential is None: + raise RosterWindowClosed() + + lease, lease_token = build_lease( + locked, + kind=kind, + actor_id=actor.pk if actor is not None else None, + session_id=session_id, + station_version=station_version, + ) + rows = minimum_roster(locked) + services._audit( + locked, + actor, + "event.checkin_roster_downloaded", + locked, + { + "lease_id": lease["lease_id"], + "station_version": station_version, + "registration_count": len(rows), + "expires_at": lease["expires_at"], + }, + ) + return { + "lease_token": lease_token, + "lease_id": lease["lease_id"], + "server_time": timezone.now(), + "issued_at": lease["issued_at"], + "expires_at": lease["expires_at"], + "scan_opens_at": lease["scan_opens_at"], + "scan_closes_at": lease["scan_closes_at"], + "sync_deadline": lease["sync_deadline"], + "event": { + "id": locked.pk, + "title": locked.title, + "starts_at": locked.starts_at, + "ends_at": locked.ends_at, + }, + "registrations": rows, + } diff --git a/backend/apps/events/services_checkin_sync.py b/backend/apps/events/services_checkin_sync.py new file mode 100644 index 00000000..896cc5bb --- /dev/null +++ b/backend/apps/events/services_checkin_sync.py @@ -0,0 +1,144 @@ +from collections import Counter +from uuid import UUID + +from django.core import signing +from django.db import IntegrityError +from django.utils import timezone +from rest_framework.exceptions import AuthenticationFailed, PermissionDenied + +from apps.events.checkin_policy import reported_time_is_valid, sync_is_open +from apps.events.checkin_tokens import read_lease +from apps.events.exceptions import ( + CheckInLeaseExpired, + DuplicateCheckInOperation, + EventInvalidTransition, +) +from apps.events.models import Event, EventCheckInEvent, EventRegistration +from apps.events.services_checkin import mark_attended_with_event + + +def validated_lease( + token, + event, + *, + kind, + actor=None, + session_id=None, + station_version=None, +): + try: + lease = read_lease(token) + except (signing.BadSignature, TypeError, ValueError, KeyError): + raise AuthenticationFailed("Invalid check-in lease.") from None + expected = ( + lease.get("kind") == kind + and lease.get("event_id") == event.pk + and lease.get("makerspace_id") == event.makerspace_id + and lease.get("actor_id") == (actor.pk if actor is not None else None) + and lease.get("station_version") == station_version + and (session_id is None or lease.get("lease_id") == str(session_id)) + ) + if not expected: + raise PermissionDenied("Check-in lease authority changed.") + if not sync_is_open(lease): + raise CheckInLeaseExpired() + return lease + + +def synchronize( + event, + operations, + *, + lease, + actor, + source, + session_id, + station_version=None, +): + results = [ + _process( + event, + item, + lease=lease, + actor=actor, + source=source, + session_id=session_id, + station_version=station_version, + ) + for item in operations + ] + from apps.events import services + + counts = Counter(result["outcome"] for result in results) + services._audit( + event, + actor, + "event.checkin_sync_processed", + event, + { + "lease_id": lease["lease_id"], + "station_version": station_version, + "operation_count": len(results), + "outcomes": dict(sorted(counts.items())), + }, + ) + return {"recorded_at": timezone.now(), "results": results} + + +def _process(event, item, *, lease, actor, source, session_id, station_version): + operation_id = item["operation_id"] + base = {"operation_id": operation_id} + if EventCheckInEvent.objects.filter( + makerspace_id=event.makerspace_id, + operation_id=operation_id, + ).exists(): + return {**base, "outcome": "duplicate_operation"} + if event.status not in (Event.Status.PUBLISHED, Event.Status.COMPLETED): + return {**base, "outcome": "event_unavailable"} + if not reported_time_is_valid(item["reported_occurred_at"], lease): + return {**base, "outcome": "outside_window"} + try: + token = UUID(str(item["checkin_token"])) + except (TypeError, ValueError, AttributeError): + return {**base, "outcome": "invalid_token"} + registration = EventRegistration.objects.filter( + event=event, + checkin_token=token, + ).first() + if registration is None: + return {**base, "outcome": "invalid_token"} + if registration.status == EventRegistration.Status.ATTENDED: + return {**base, "outcome": "already_attended"} + if registration.status != EventRegistration.Status.REGISTERED: + return {**base, "outcome": "registration_changed"} + try: + updated, check_in = mark_attended_with_event( + registration, + actor=actor, + source=source, + operation_id=operation_id, + attended_at=item["reported_occurred_at"], + session_id=session_id, + station_version=station_version, + ) + except DuplicateCheckInOperation: + return {**base, "outcome": "duplicate_operation"} + except IntegrityError: + # With the event and registration locked, the remaining expected race is the + # globally unique operation UUID. Do not query another tenant to prove that + # collision: the uniform idempotent outcome reveals no cross-tenant row. + return {**base, "outcome": "duplicate_operation"} + except EventInvalidTransition: + fresh_event = Event.objects.only("status").get(pk=event.pk) + if fresh_event.status not in (Event.Status.PUBLISHED, Event.Status.COMPLETED): + return {**base, "outcome": "event_unavailable"} + fresh = EventRegistration.objects.filter(pk=registration.pk).first() + if fresh and fresh.status == EventRegistration.Status.ATTENDED: + return {**base, "outcome": "already_attended"} + return {**base, "outcome": "registration_changed"} + return { + **base, + "outcome": "applied", + "registration_id": updated.pk, + "attended_at": check_in.attended_at, + } diff --git a/backend/apps/events/services_feedback.py b/backend/apps/events/services_feedback.py new file mode 100644 index 00000000..f39d9760 --- /dev/null +++ b/backend/apps/events/services_feedback.py @@ -0,0 +1,240 @@ +import hmac +import json + +from django.core.exceptions import ValidationError as DjangoValidationError +from django.db import transaction +from django.utils import timezone +from rest_framework import serializers + +from apps.audit import services as audit +from apps.events import services as event_services +from apps.events.exceptions import ( + EventInvalidTransition, + FeedbackConflict, + FeedbackIneligible, +) +from apps.events.feedback_validation import ( + validate_feedback_answers, + validate_feedback_schema, +) +from apps.events.models import ( + Event, + EventFeedbackResponse, + EventFeedbackSurvey, + EventRegistration, +) +from apps.events.services_certificates import create_pending +from apps.makerspaces.guards import require_module_locked +from apps.presence.guard import require_active_member + + +def _locked_event(event): + locked = event_services._locked_event(event.pk) + require_module_locked(locked.makerspace, "events") + return locked + + +def _locked_survey(event, *, required=True): + survey = EventFeedbackSurvey.objects.select_for_update().filter(event=event).first() + if survey is None and required: + raise EventInvalidTransition("This event has no feedback survey.") + return survey + + +def _canonical_questions(questions): + try: + return validate_feedback_schema(questions) + except DjangoValidationError as exc: + raise serializers.ValidationError({"questions": exc.messages}) from exc + + +def _submission_ready(event, survey): + return ( + survey.is_open + and timezone.now() >= event.ends_at + and event.status in (Event.Status.PUBLISHED, Event.Status.COMPLETED) + ) + + +def _snapshot(schema, answers): + value = validate_feedback_answers(schema, answers) + return value, json.dumps(value, sort_keys=True, separators=(",", ":")) + + +def _merge_answered_ids(survey, snapshot): + answered = {item["id"] for item in snapshot["answers"]} + merged = sorted(set(survey.answered_question_ids) | answered) + if merged != survey.answered_question_ids: + survey.answered_question_ids = merged + survey.save(update_fields=["answered_question_ids", "updated_at"]) + + +@transaction.atomic +def configure_survey( + event, + *, + actor, + title, + thank_you_text="", + questions=None, + certificate_enabled=False, +): + locked_event = _locked_event(event) + survey = _locked_survey(locked_event, required=False) + created = survey is None + if created: + survey = EventFeedbackSurvey(event=locked_event) + survey.title = title + survey.thank_you_text = thank_you_text + survey.questions = _canonical_questions(questions) + survey.certificate_enabled = certificate_enabled + survey.save() + audit.record( + actor, + "event.feedback_survey_configured", + makerspace=locked_event.makerspace, + target=survey, + meta={"created": created, "question_count": len(survey.questions)}, + ) + return survey + + +@transaction.atomic +def open_survey(event, *, actor): + locked_event = _locked_event(event) + survey = _locked_survey(locked_event) + if survey.is_open: + raise EventInvalidTransition("The survey is already open.") + if timezone.now() < locked_event.ends_at: + raise EventInvalidTransition("Feedback cannot open before the event ends.") + if locked_event.status not in (Event.Status.PUBLISHED, Event.Status.COMPLETED): + raise EventInvalidTransition("This event cannot accept feedback.") + if not survey.questions: + raise EventInvalidTransition("The survey needs at least one question.") + survey.is_open = True + survey.opened_at = timezone.now() + survey.closed_at = None + survey.save(update_fields=["is_open", "opened_at", "closed_at", "updated_at"]) + audit.record( + actor, + "event.feedback_survey_opened", + makerspace=locked_event.makerspace, + target=survey, + meta={}, + ) + return survey + + +@transaction.atomic +def close_survey(event, *, actor): + locked_event = _locked_event(event) + survey = _locked_survey(locked_event) + if not survey.is_open: + raise EventInvalidTransition("The survey is already closed.") + survey.is_open = False + survey.closed_at = timezone.now() + survey.save(update_fields=["is_open", "closed_at", "updated_at"]) + audit.record( + actor, + "event.feedback_survey_closed", + makerspace=locked_event.makerspace, + target=survey, + meta={}, + ) + return survey + + +@transaction.atomic +def submit_anonymous_feedback(event, answers): + locked_event = _locked_event(event) + survey = _locked_survey(locked_event) + if survey.certificate_enabled or not _submission_ready(locked_event, survey): + raise FeedbackIneligible() + snapshot, encoded = _snapshot(survey.questions, answers) + response = EventFeedbackResponse.objects.create( + survey=survey, + registration=None, + answers_snapshot=encoded, + certificate_requested=False, + ) + _merge_answered_ids(survey, snapshot) + # Deliberately no response target/id and no request principal. AuditLog is + # append-only, so even an encrypted or hashed identity would break anonymity. + audit.record( + None, + "event.feedback_submitted", + makerspace=locked_event.makerspace, + target=survey, + meta={"mode": "anonymous"}, + ) + return response, None + + +@transaction.atomic +def submit_identified_feedback( + event, + *, + actor, + email, + answers, + registration=None, +): + locked_event = _locked_event(event) + survey = _locked_survey(locked_event) + if not survey.certificate_enabled or not _submission_ready(locked_event, survey): + raise FeedbackIneligible() + registration_id = registration.pk if registration is not None else None + # No `select_related("registered_via_makerspace")` here: that FK is NULLABLE, so it + # joins LEFT OUTER, and Postgres refuses `FOR UPDATE` against the nullable side of an + # outer join outright. The attribute below loads lazily instead, which costs one extra + # query only when the registration actually travelled via another makerspace. + eligible = EventRegistration.objects.select_for_update().filter( + event=locked_event, + member=actor, + status=EventRegistration.Status.ATTENDED, + ) + if registration_id is not None: + eligible = eligible.filter(pk=registration_id) + locked_registration = eligible.first() + normalized = (email or "").strip().lower() + if locked_registration is None: + raise FeedbackIneligible() + via_space = ( + locked_registration.registered_via_makerspace + or locked_event.makerspace + ) + require_active_member(actor, via_space) + if not hmac.compare_digest(normalized, locked_registration.email): + raise FeedbackIneligible() + snapshot, encoded = _snapshot(survey.questions, answers) + existing = EventFeedbackResponse.objects.select_for_update().filter( + survey=survey, + registration=locked_registration, + ).first() + if existing is not None: + if not hmac.compare_digest(existing.answers_snapshot, encoded): + raise FeedbackConflict() + return existing, existing.certificates.order_by("-revision").first() + response = EventFeedbackResponse.objects.create( + survey=survey, + registration=locked_registration, + answers_snapshot=encoded, + certificate_requested=True, + ) + certificate = create_pending(response) + _merge_answered_ids(survey, snapshot) + audit.record( + actor, + "event.feedback_submitted", + makerspace=locked_event.makerspace, + target=response, + meta={"mode": "certificate"}, + ) + audit.record( + actor, + "event.certificate_requested", + makerspace=locked_event.makerspace, + target=certificate, + meta={"revision": certificate.revision}, + ) + return response, certificate diff --git a/backend/apps/events/services_images.py b/backend/apps/events/services_images.py index 3f950ff0..84232bb9 100644 --- a/backend/apps/events/services_images.py +++ b/backend/apps/events/services_images.py @@ -46,7 +46,13 @@ def update_image(event, actor, object_key): locked.makerspace, old_key ) locked.image_key = object_key - locked.save(update_fields=["image_key", "updated_at"]) + update_fields = ["image_key", "updated_at"] + if locked.series_id: + locked.series_override_fields = sorted( + set(locked.series_override_fields or []) | {"image_key"} + ) + update_fields.append("series_override_fields") + locked.save(update_fields=update_fields) audit.record( actor, "event.image_updated", @@ -62,10 +68,18 @@ def update_image(event, actor, object_key): def remove_image(event, actor): locked = _locked_event(event.pk) old_key = locked.image_key - if not old_key: + if not old_key and ( + locked.series_id is None or "image_key" in (locked.series_override_fields or []) + ): return locked locked.image_key = "" - locked.save(update_fields=["image_key", "updated_at"]) + update_fields = ["image_key", "updated_at"] + if locked.series_id: + locked.series_override_fields = sorted( + set(locked.series_override_fields or []) | {"image_key"} + ) + update_fields.append("series_override_fields") + locked.save(update_fields=update_fields) audit.record( actor, "event.image_removed", diff --git a/backend/apps/events/services_lifecycle.py b/backend/apps/events/services_lifecycle.py new file mode 100644 index 00000000..6f2f2d07 --- /dev/null +++ b/backend/apps/events/services_lifecycle.py @@ -0,0 +1,107 @@ +from django.db import transaction +from django.utils import timezone + +from apps.events.exceptions import EventInvalidTransition +from apps.events.models import ( + Event, + EventAttendanceCertificate, + EventFeedbackSurvey, +) +from apps.events.services_calendar import calendar_event_changed +from apps.makerspaces import limits +from apps.makerspaces.guards import require_module_locked + + +def _boundary(): + from apps.events import services + + return services + + +def _transition(event, actor, expected, new_status, action): + services = _boundary() + locked = services._locked_event(event.pk) + if locked.status != expected: + message = f"Cannot transition event from {locked.status} to {new_status}." + raise EventInvalidTransition(message) + locked.status = new_status + locked.save(update_fields=["status", "updated_at"]) + calendar_event_changed(locked) + meta = {"old_status": expected, "new_status": new_status} + services._audit(locked, actor, action, locked, meta) + services.notify_event_lifecycle(locked, new_status) + return services._refresh(locked) + + +@transaction.atomic +def publish(event, *, actor): + services = _boundary() + locked = services._locked_event(event.pk) + if locked.status != Event.Status.DRAFT: + raise EventInvalidTransition("Only draft events can be published.") + services._validate(locked) + if locked.ends_at < timezone.now(): + raise EventInvalidTransition("Ended events cannot be published.") + require_module_locked(locked.makerspace, "events") + limits.check_quota(locked.makerspace, "events", adding=1) + locked.status = Event.Status.PUBLISHED + locked.save(update_fields=["status", "updated_at"]) + calendar_event_changed(locked) + meta = {"old_status": Event.Status.DRAFT, "new_status": Event.Status.PUBLISHED} + services._audit(locked, actor, "event.published", locked, meta) + services.notify_event_lifecycle(locked, "published") + return services._refresh(locked) + + +@transaction.atomic +def cancel(event, *, actor, notify=True): + services = _boundary() + locked = services._locked_event(event.pk) + if locked.status != Event.Status.PUBLISHED: + raise EventInvalidTransition( + f"Cannot transition event from {locked.status} to {Event.Status.CANCELLED}." + ) + survey = EventFeedbackSurvey.objects.select_for_update().filter(event=locked).first() + certificates = list(EventAttendanceCertificate.objects.select_for_update().filter( + registration__event=locked, status=EventAttendanceCertificate.Status.ACTIVE, + )) + now = timezone.now() + if survey is not None and survey.is_open: + survey.is_open = False + survey.closed_at = now + survey.save(update_fields=["is_open", "closed_at", "updated_at"]) + services._audit( + locked, actor, "event.feedback_survey_closed", survey, + {"reason": "event_cancelled"}, + ) + for certificate in certificates: + certificate.status = EventAttendanceCertificate.Status.REVOKED + certificate.revoked_at = now + certificate.revoked_by = actor + certificate.revocation_reason = ( + EventAttendanceCertificate.RevocationReason.EVENT_CANCELLED + ) + certificate.save(update_fields=[ + "status", "revoked_at", "revoked_by", "revocation_reason", + ]) + services._audit( + locked, actor, "event.certificate_revoked", certificate, + {"reason": certificate.revocation_reason, "revision": certificate.revision}, + ) + locked.status = Event.Status.CANCELLED + locked.save(update_fields=["status", "updated_at"]) + calendar_event_changed(locked, now=now) + services._audit( + locked, actor, "event.cancelled", locked, + {"old_status": Event.Status.PUBLISHED, "new_status": Event.Status.CANCELLED}, + ) + if notify: + services.notify_event_lifecycle(locked, "cancelled") + return services._refresh(locked) + + +@transaction.atomic +def complete(event, *, actor): + return _transition( + event, actor, Event.Status.PUBLISHED, Event.Status.COMPLETED, "event.completed" + ) diff --git a/backend/apps/events/services_organizers.py b/backend/apps/events/services_organizers.py new file mode 100644 index 00000000..052ce58e --- /dev/null +++ b/backend/apps/events/services_organizers.py @@ -0,0 +1,82 @@ +from django.db import transaction +from rest_framework.exceptions import PermissionDenied, ValidationError + +from apps.accounts.models import User +from apps.audit import services as audit +from apps.events.models import Event +from apps.events.organizer_authority import can_manage_event +from apps.events.organizer_models import EventOrganizer +from apps.makerspaces.guards import require_module_locked +from apps.organizations.models import Organization, OrganizationMembership + + +MAX_ORGANIZERS = 50 + + +def _is_superadmin(actor): + return bool(actor.is_superuser or actor.role == User.Role.SUPERADMIN) + + +@transaction.atomic +def replace_organizers(event, *, actor, organization_ids): + requested = list(organization_ids) + if len(requested) != len(set(requested)): + raise ValidationError({"organization_ids": "Organization IDs must be unique."}) + if len(requested) > MAX_ORGANIZERS: + raise ValidationError( + {"organization_ids": f"At most {MAX_ORGANIZERS} organizers are allowed."} + ) + + locked_event = Event.objects.select_for_update().get(pk=event.pk) + require_module_locked(locked_event.makerspace_id, "events") + if not can_manage_event(actor, locked_event): + raise PermissionDenied() + + existing = list( + EventOrganizer.objects.select_for_update() + .filter(event=locked_event) + .order_by("pk") + ) + existing_ids = {link.organization_id for link in existing} + organizations = list( + Organization.objects.select_for_update() + .filter(pk__in=requested, is_active=True) + .order_by("pk") + ) + if {organization.pk for organization in organizations} != set(requested): + raise ValidationError({"organization_ids": "An organization is unavailable."}) + + newly_assigned = set(requested) - existing_ids + if not _is_superadmin(actor) and newly_assigned: + memberships = OrganizationMembership.objects.select_for_update().filter( + organization_id__in=newly_assigned, + user=actor, + status=OrganizationMembership.Status.ACTIVE, + ) + if set(memberships.values_list("organization_id", flat=True)) != newly_assigned: + raise PermissionDenied( + "You need an active membership in every assigned organization." + ) + + old_ids = sorted(link.organization_id for link in existing) + if old_ids != sorted(requested): + EventOrganizer.objects.filter(pk__in=[link.pk for link in existing]).delete() + EventOrganizer.objects.bulk_create( + [ + EventOrganizer( + event=locked_event, + organization=organization, + created_by=actor, + ) + for organization in organizations + ] + ) + audit.record( + actor, + "event.organizers_updated", + makerspace=locked_event.makerspace, + target=locked_event, + meta={"old_organization_ids": old_ids, "organization_ids": sorted(requested)}, + ) + + return Event.objects.prefetch_related("organizers__organization").get(pk=locked_event.pk) diff --git a/backend/apps/events/services_recurrence.py b/backend/apps/events/services_recurrence.py new file mode 100644 index 00000000..b67181a9 --- /dev/null +++ b/backend/apps/events/services_recurrence.py @@ -0,0 +1,149 @@ +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone as dt_timezone +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError + +from dateutil.rrule import rrulestr +from rest_framework import serializers + + +MAX_CANDIDATES = 500 +MAX_FUTURE_OCCURRENCES = 48 +HORIZON_DAYS = 366 +MIN_EXTENSION_BUFFER = timedelta(hours=1) +FORBIDDEN_PARTS = frozenset({"DTSTART", "RDATE", "EXDATE"}) + + +@dataclass(frozen=True) +class Occurrence: + key: str + local_start: datetime + starts_at: datetime + ends_at: datetime + + +def _timezone(name): + try: + return ZoneInfo(name) + except (ZoneInfoNotFoundError, ValueError, TypeError) as exc: + raise serializers.ValidationError( + {"recurrence_timezone": "Use a valid IANA timezone name."}, + code="invalid_recurrence_timezone", + ) from exc + + +def normalize_rule(value): + text = str(value or "").strip().upper() + if text.startswith("RRULE:"): + text = text[6:] + if not text or "\n" in text or "\r" in text: + raise serializers.ValidationError( + {"recurrence_rule": "Provide one RFC 5545 RRULE body."}, + code="invalid_recurrence_rule", + ) + names = {part.partition("=")[0] for part in text.split(";")} + if names & FORBIDDEN_PARTS or any("=" not in part for part in text.split(";")): + raise serializers.ValidationError( + {"recurrence_rule": "DTSTART, RDATE, and EXDATE are not allowed."}, + code="invalid_recurrence_rule", + ) + return text + + +def local_anchor(*, local_date, local_time, timezone_name): + zone = _timezone(timezone_name) + naive = datetime.combine(local_date, local_time.replace(tzinfo=None)) + return naive.replace(tzinfo=zone, fold=0) + + +def parsed_rule(*, rule, local_date, local_time, timezone_name): + normalized = normalize_rule(rule) + anchor = local_anchor( + local_date=local_date, local_time=local_time, timezone_name=timezone_name + ) + try: + parsed = rrulestr(normalized, dtstart=anchor, forceset=False) + except (TypeError, ValueError, OverflowError) as exc: + raise serializers.ValidationError( + {"recurrence_rule": "This recurrence rule is invalid."}, + code="invalid_recurrence_rule", + ) from exc + return normalized, parsed, anchor + + +def validate_series_recurrence(series): + normalized, parsed, anchor = parsed_rule( + rule=series.recurrence_rule, + local_date=series.dtstart_local_date, + local_time=series.dtstart_local_time, + timezone_name=series.recurrence_timezone, + ) + first = parsed.after(anchor, inc=True) + if first is None: + raise serializers.ValidationError( + {"recurrence_rule": "The rule does not produce an occurrence."}, + code="empty_recurrence_rule", + ) + buffer = list(parsed.xafter(anchor, count=MAX_FUTURE_OCCURRENCES + 1, inc=True)) + if len(buffer) > MAX_FUTURE_OCCURRENCES and buffer[-1] - buffer[0] < MIN_EXTENSION_BUFFER: + raise serializers.ValidationError( + {"recurrence_rule": "This rule is too dense for the hourly extension window."}, + code="recurrence_too_dense", + ) + return normalized + + +def _is_real_wall_time(value, zone): + round_trip = value.astimezone(dt_timezone.utc).astimezone(zone) + return round_trip.replace(tzinfo=None) == value.replace(tzinfo=None) + + +def occurrences(series, *, now): + normalized, rule, _anchor = parsed_rule( + rule=series.recurrence_rule, + local_date=series.dtstart_local_date, + local_time=series.dtstart_local_time, + timezone_name=series.recurrence_timezone, + ) + zone = _timezone(series.recurrence_timezone) + duration = timedelta(minutes=series.duration_minutes) + lower_utc = now - duration + upper_utc = now + timedelta(days=HORIZON_DAYS) + lower_local = lower_utc.astimezone(zone) + results = [] + candidates = 0 + for local_value in rule.xafter(lower_local, count=MAX_CANDIDATES + 1, inc=True): + candidates += 1 + if candidates > MAX_CANDIDATES: + raise serializers.ValidationError( + {"recurrence_rule": "The recurrence is too dense to expand safely."}, + code="recurrence_candidate_limit", + ) + local_value = local_value.astimezone(zone).replace(fold=0) + start_utc = local_value.astimezone(dt_timezone.utc) + if start_utc > upper_utc: + break + if not _is_real_wall_time(local_value, zone): + continue + if start_utc >= now and sum(row.starts_at >= now for row in results) >= MAX_FUTURE_OCCURRENCES: + break + key = f"{series.revision}:{local_value.strftime('%Y%m%dT%H%M%S')}" + results.append(Occurrence(key, local_value, start_utc, start_utc + duration)) + series.recurrence_rule = normalized + return results + + +def rule_is_finite(rule): + parts = {part.partition("=")[0] for part in normalize_rule(rule).split(";")} + return bool(parts & {"COUNT", "UNTIL"}) + + +def recurrence_exhausted(series, *, now): + if not rule_is_finite(series.recurrence_rule): + return False + _normalized, rule, _anchor = parsed_rule( + rule=series.recurrence_rule, + local_date=series.dtstart_local_date, + local_time=series.dtstart_local_time, + timezone_name=series.recurrence_timezone, + ) + return rule.after(now.astimezone(_timezone(series.recurrence_timezone)), inc=False) is None diff --git a/backend/apps/events/services_registration.py b/backend/apps/events/services_registration.py index d7a4b7f1..27406899 100644 --- a/backend/apps/events/services_registration.py +++ b/backend/apps/events/services_registration.py @@ -5,11 +5,21 @@ from django.utils import timezone from apps.encryption.write_fence import assert_mapped_write_allowed -from apps.events.capacity import fresh_registration_status -from apps.events.exceptions import DuplicateRegistration, EventInvalidTransition +from apps.events.capacity import ( + effective_registration_cutoff, + fresh_registration_status, + registration_is_open, +) +from apps.events.exceptions import ( + DuplicateRegistration, + EventInvalidTransition, + RegistrationClosed, + RegistrationRejected, +) from apps.events.models import EventRegistration from apps.forms_schema.validation import validate_answers from apps.makerspaces.guards import require_module_locked +from apps.events.services_calendar import calendar_registration_changed @transaction.atomic @@ -62,11 +72,18 @@ def register( # ordering: publish() takes the event lock first, so taking the makerspace first # here would create a deadlock pair with it. require_module_locked(locked.makerspace, "events") - if ( - (not locked.is_public and not staff_registration and not collaborative) - or locked.status != locked.Status.PUBLISHED - or locked.ends_at < timezone.now() - ): + now = timezone.now() + if not locked.is_public and not staff_registration and not collaborative: + raise EventInvalidTransition("This event is not open for registration.") + if not registration_is_open(locked, now): + cutoff = effective_registration_cutoff(locked) + if ( + locked.status == locked.Status.PUBLISHED + and now < locked.ends_at + and cutoff is not None + and now >= cutoff + ): + raise RegistrationClosed("Registration for this event is closed.") raise EventInvalidTransition("This event is not open for registration.") custom_answers = validate_answers(locked.custom_form, custom_answers) status = fresh_registration_status(locked) @@ -86,9 +103,17 @@ def register( "member", "registered_via_makerspace", "payment_via_makerspace", "name", "email", "phone", "custom_answers", "status", "created_at", ]) + calendar_registration_changed(existing) return _record_registration(locked, actor, existing, status) if existing: - raise DuplicateRegistration("A registration already exists for this email.", fresh_status=status) + if existing.status == EventRegistration.Status.REJECTED: + raise RegistrationRejected( + "This registration application was rejected." + ) + raise DuplicateRegistration( + "A registration already exists for this email.", + fresh_status=existing.status, + ) registration = EventRegistration( event=locked, member=member, name=name, email=normalized_email, phone=phone, custom_answers=custom_answers, status=status, @@ -127,8 +152,26 @@ def _existing_registration(event, member, normalized_email, generation, event_ha def _record_registration(event, actor, registration, status): from apps.events import services - services._audit(event, actor, "event.registration_created", registration, {"registration_id": registration.pk, "status": status}) - services.notify_event_lifecycle(event, "registration_created", registration.pk) + services._audit( + event, + actor, + "event.registration_created", + registration, + {"registration_id": registration.pk, "status": status}, + ) + lifecycle_event = "registration_created" + if status == EventRegistration.Status.PENDING_APPROVAL: + services._audit( + event, + actor, + "event.registration_approval_requested", + registration, + {"registration_id": registration.pk}, + ) + lifecycle_event = "registration_pending_approval" + elif status == EventRegistration.Status.WAITLISTED: + lifecycle_event = "registration_waitlisted" + services.notify_event_lifecycle(event, lifecycle_event, registration.pk) if status == EventRegistration.Status.REGISTERED: from apps.events.service_payments import create_for_registered_registration diff --git a/backend/apps/events/services_registration_state.py b/backend/apps/events/services_registration_state.py new file mode 100644 index 00000000..fc1bdd54 --- /dev/null +++ b/backend/apps/events/services_registration_state.py @@ -0,0 +1,235 @@ +"""Transactional registration lifecycle transitions.""" + +from django.db import transaction +from django.utils import timezone + +from apps.events.capacity import spots_left +from apps.events.exceptions import CapacityConflict, EventInvalidTransition +from apps.events.models import Event, EventAttendanceCertificate, EventRegistration +from apps.events.service_payments import ( + cancel_for_registration, + create_for_registered_registration, +) +from apps.events.services_calendar import calendar_registration_changed + + +def _boundary(): + # Imported lazily so services.py can retain the established public import boundary. + from apps.events import services + + return services + + +def _lock_registration(event, registration_id): + registration = EventRegistration.objects.select_for_update().get(pk=registration_id) + if registration.event_id != event.pk: + raise EventInvalidTransition("Registration does not belong to this event.") + registration.event = event + return registration + + +def _decision_is_open(event): + return event.status == Event.Status.PUBLISHED and timezone.now() < event.ends_at + + +def _lock_waiters(event): + return list( + EventRegistration.objects.select_for_update() + .filter(event=event, status=EventRegistration.Status.WAITLISTED) + .order_by("created_at", "id") + ) + + +def _promote(event, actor, waiters, count=None, *, mode): + services = _boundary() + selected = waiters if count is None else waiters[:count] + for registration in selected: + registration.event = event + registration.status = EventRegistration.Status.REGISTERED + registration.save(update_fields=["status"]) + calendar_registration_changed(registration) + create_for_registered_registration(registration, actor) + meta = {"registration_id": registration.pk, "promotion_mode": mode} + services._audit( + event, actor, "event.registration_promoted", registration, meta + ) + services.notify_event_lifecycle( + event, "registration_promoted", registration.pk + ) + return selected + + +def promote_automatically(event, actor, count=None): + if event.registration_requires_approval: + return [] + return _promote( + event, actor, _lock_waiters(event), count, mode="automatic_fifo" + ) + + +@transaction.atomic +def approve_registration(registration, *, actor): + services = _boundary() + event = services._locked_event(registration.event_id) + locked = _lock_registration(event, registration.pk) + if ( + not event.registration_requires_approval + or locked.status != EventRegistration.Status.PENDING_APPROVAL + or not _decision_is_open(event) + ): + raise EventInvalidTransition("This registration cannot be approved.") + available = spots_left(event) + new_status = ( + EventRegistration.Status.REGISTERED + if available is None or available > 0 + else EventRegistration.Status.WAITLISTED + ) + locked.status = new_status + locked.save(update_fields=["status"]) + calendar_registration_changed(locked) + if new_status == EventRegistration.Status.REGISTERED: + create_for_registered_registration(locked, actor) + services._audit( + event, + actor, + "event.registration_approved", + locked, + { + "registration_id": locked.pk, + "old_status": EventRegistration.Status.PENDING_APPROVAL, + "new_status": new_status, + "capacity_result": ( + "confirmed" if new_status == EventRegistration.Status.REGISTERED + else "waitlisted_full" + ), + }, + ) + services.notify_event_lifecycle(event, "registration_approved", locked.pk) + return services._refresh(locked) + + +@transaction.atomic +def reject_registration(registration, *, actor): + services = _boundary() + event = services._locked_event(registration.event_id) + locked = _lock_registration(event, registration.pk) + if locked.status not in ( + EventRegistration.Status.PENDING_APPROVAL, + EventRegistration.Status.WAITLISTED, + ): + raise EventInvalidTransition("This registration cannot be rejected.") + old_status = locked.status + locked.status = EventRegistration.Status.REJECTED + locked.save(update_fields=["status"]) + calendar_registration_changed(locked) + services._audit( + event, + actor, + "event.registration_rejected", + locked, + { + "registration_id": locked.pk, + "old_status": old_status, + "new_status": EventRegistration.Status.REJECTED, + }, + ) + services.notify_event_lifecycle(event, "registration_rejected", locked.pk) + return services._refresh(locked) + + +@transaction.atomic +def promote_registration(registration, *, actor): + services = _boundary() + event = services._locked_event(registration.event_id) + locked = _lock_registration(event, registration.pk) + if ( + not event.registration_requires_approval + or locked.status != EventRegistration.Status.WAITLISTED + or not _decision_is_open(event) + ): + raise EventInvalidTransition("This registration cannot be promoted manually.") + available = spots_left(event) + if available is not None and available <= 0: + raise CapacityConflict("No event capacity is available.") + return services._refresh( + _promote(event, actor, [locked], 1, mode="manual")[0] + ) + + +@transaction.atomic +def cancel_registration(registration, *, actor=None): + services = _boundary() + event = services._locked_event(registration.event_id) + locked = _lock_registration(event, registration.pk) + if locked.status not in ( + EventRegistration.Status.PENDING_APPROVAL, + EventRegistration.Status.REGISTERED, + EventRegistration.Status.WAITLISTED, + ): + raise EventInvalidTransition("This registration cannot be cancelled.") + old_status = locked.status + locked.status = EventRegistration.Status.CANCELLED + locked.save(update_fields=["status"]) + calendar_registration_changed(locked) + services._audit( + event, + actor, + "event.registration_cancelled", + locked, + {"registration_id": locked.pk, "old_status": old_status}, + ) + services.notify_event_lifecycle(event, "registration_cancelled", locked.pk) + cancel_for_registration(locked, actor) + if ( + old_status == EventRegistration.Status.REGISTERED + and not event.registration_requires_approval + and event.capacity > 0 + and services._may_promote(event, timezone.now()) + ): + promote_automatically(event, actor, 1) + return services._refresh(locked) + + +@transaction.atomic +def correct_attendance(registration, *, actor): + services = _boundary() + event = services._locked_event(registration.event_id) + locked = _lock_registration(event, registration.pk) + if locked.status != EventRegistration.Status.ATTENDED: + raise EventInvalidTransition("Only attended registrations can be corrected.") + certificates = list( + EventAttendanceCertificate.objects.select_for_update().filter( + registration=locked, + status=EventAttendanceCertificate.Status.ACTIVE, + ) + ) + locked.status = EventRegistration.Status.REGISTERED + locked.save(update_fields=["status"]) + now = timezone.now() + for certificate in certificates: + certificate.status = EventAttendanceCertificate.Status.REVOKED + certificate.revoked_at = now + certificate.revoked_by = actor + certificate.revocation_reason = ( + EventAttendanceCertificate.RevocationReason.ATTENDANCE_CORRECTED + ) + certificate.save( + update_fields=[ + "status", "revoked_at", "revoked_by", "revocation_reason", + ] + ) + services._audit( + event, + actor, + "event.certificate_revoked", + certificate, + {"reason": certificate.revocation_reason, "revision": certificate.revision}, + ) + services._audit( + event, + actor, + "event.registration_attendance_corrected", + locked, + {"registration_id": locked.pk, "revoked_certificates": len(certificates)}, + ) + return services._refresh(locked), certificates diff --git a/backend/apps/events/services_series.py b/backend/apps/events/services_series.py new file mode 100644 index 00000000..7473ef47 --- /dev/null +++ b/backend/apps/events/services_series.py @@ -0,0 +1,257 @@ +from django.core.exceptions import ValidationError as DjangoValidationError +from django.db import transaction +from datetime import datetime, timedelta, timezone as dt_timezone +from zoneinfo import ZoneInfo + +from django.utils import timezone +from rest_framework import serializers + +from apps.audit import services as audit +from apps.events import services +from apps.events.exceptions import EventInvalidTransition +from apps.events.models import ( + Event, + EventCollaborator, + EventOrganizer, + EventSeries, +) +from apps.events.services_recurrence import ( + occurrences, + validate_series_recurrence, +) +from apps.events.services_calendar import ( + CALENDAR_SERIES_FIELDS, + calendar_event_changed, + calendar_series_changed, +) +from apps.forms_schema.validation import validate_form_schema +from apps.makerspaces import limits +from apps.makerspaces.guards import require_module_locked +from apps.makerspaces.models import Makerspace + + +TEMPLATE_FIELDS = frozenset({ + "title", "description", "location", "location_kind", "custom_form", "capacity", + "payment_amount", "registration_requires_approval", + "registration_cutoff_lead_minutes", "is_public", +}) +RECURRENCE_FIELDS = frozenset({ + "recurrence_timezone", "dtstart_local_date", "dtstart_local_time", + "recurrence_rule", "duration_minutes", +}) +SERIES_FIELDS = TEMPLATE_FIELDS | RECURRENCE_FIELDS + + +def occurrence_inherited_value(event, field): + series = event.series + if field == "starts_at": + local_text = event.series_occurrence_key.split(":", 1)[1] + local = datetime.strptime(local_text, "%Y%m%dT%H%M%S").replace( + tzinfo=ZoneInfo(series.recurrence_timezone), fold=0 + ) + return local.astimezone(dt_timezone.utc) + if field == "ends_at": + start = occurrence_inherited_value(event, "starts_at") + return start + timedelta(minutes=series.duration_minutes) + if field == "registration_cutoff_at": + return None + if field == "timezone_name": + return series.recurrence_timezone + if field == "image_key": + return "" + return getattr(series, field) + + +def _validate(series): + if "custom_form" in series.__dict__: + try: + series.custom_form = validate_form_schema(series.custom_form) + except DjangoValidationError as exc: + raise serializers.ValidationError({"custom_form": exc.messages}) from exc + series.recurrence_rule = validate_series_recurrence(series) + try: + series.full_clean(validate_unique=False, validate_constraints=False) + except DjangoValidationError as exc: + detail = exc.message_dict if hasattr(exc, "message_dict") else exc.messages + raise serializers.ValidationError(detail) from exc + + +def _event_values(series, occurrence): + return { + "makerspace": series.makerspace, + "series": series, + "series_occurrence_key": occurrence.key, + "series_revision": series.revision, + "title": series.title, + "description": series.description, + "starts_at": occurrence.starts_at, + "ends_at": occurrence.ends_at, + "location": series.location, + "location_kind": series.location_kind, + "custom_form": series.custom_form, + "capacity": series.capacity, + "payment_amount": series.payment_amount, + "registration_requires_approval": series.registration_requires_approval, + "registration_cutoff_lead_minutes": series.registration_cutoff_lead_minutes, + "is_public": series.is_public, + "status": ( + Event.Status.PUBLISHED + if series.status == EventSeries.Status.PUBLISHED + else Event.Status.DRAFT + ), + "timezone_name": series.recurrence_timezone, + "created_by": series.created_by, + } + + +def _project_authority(series, event): + for source in series.collaborators.filter(status="accepted"): + EventCollaborator.objects.get_or_create( + event=event, + makerspace=source.makerspace, + defaults={ + "status": EventCollaborator.Status.ACCEPTED, + "invited_by": source.invited_by, + "responded_by": source.responded_by, + "responded_at": source.responded_at, + "source_series_collaboration": source, + }, + ) + 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}, + ) + + +def _materialize_locked(series, *, actor, now): + generated = occurrences(series, now=now) + existing = set( + Event.objects.filter(series=series).values_list("series_occurrence_key", flat=True) + ) + pending = [item for item in generated if item.key not in existing] + if series.status == EventSeries.Status.PUBLISHED and pending: + limits.check_quota(series.makerspace, "events", adding=len(pending)) + created = [] + for item in pending: + event = Event.objects.create(**_event_values(series, item)) + _project_authority(series, event) + audit.record( + actor, "event.series_occurrence_created", makerspace=series.makerspace, + target=event, meta={"series_id": series.pk, "occurrence_key": item.key}, + ) + created.append(event) + series.last_materialized_at = now + series.last_generation_error_code = "" + series.save(update_fields=( + "recurrence_rule", "last_materialized_at", "last_generation_error_code", "updated_at" + )) + return created + + +@transaction.atomic +def create_series(*, makerspace, actor, **values): + locked_space = Makerspace.objects.select_for_update().get(pk=makerspace.pk) + require_module_locked(locked_space, "events") + unknown = set(values) - SERIES_FIELDS + if unknown: + raise serializers.ValidationError({field: "Unknown field." for field in unknown}) + series = EventSeries(makerspace=locked_space, created_by=actor, **values) + _validate(series) + series.save() + created = _materialize_locked(series, actor=actor, now=timezone.now()) + audit.record( + actor, "event.series_created", makerspace=locked_space, target=series, + meta={"occurrence_ids": [event.pk for event in created]}, + ) + return series, created + + +@transaction.atomic +def extend_series(series, *, actor=None): + locked = EventSeries.objects.select_for_update().get(pk=series.pk) + if locked.status in (EventSeries.Status.CANCELLED, EventSeries.Status.COMPLETED): + raise EventInvalidTransition("Terminal series cannot be extended.") + require_module_locked(locked.makerspace_id, "events") + created = _materialize_locked(locked, actor=actor, now=timezone.now()) + audit.record( + actor, "event.series_extended", makerspace=locked.makerspace, target=locked, + meta={"created_ids": [event.pk for event in created]}, + ) + return locked, created + + +def _apply_template(series, event, changed_fields): + overrides = set(event.series_override_fields or []) + applied = [] + for field in changed_fields & TEMPLATE_FIELDS: + if field not in overrides: + setattr(event, field, getattr(series, field)) + applied.append(field) + if applied: + event.save(update_fields=(*sorted(applied), "updated_at")) + if set(applied) & {"title", "description", "location", "is_public"}: + calendar_event_changed(event) + + +@transaction.atomic +def update_series(series, *, actor, effective_from=None, **changes): + locked = EventSeries.objects.select_for_update().get(pk=series.pk) + if locked.status not in (EventSeries.Status.DRAFT, EventSeries.Status.PUBLISHED): + raise EventInvalidTransition("Terminal series cannot be updated.") + unknown = set(changes) - SERIES_FIELDS + if unknown: + raise serializers.ValidationError({field: "This field cannot be updated." for field in unknown}) + recurrence_changed = bool(set(changes) & RECURRENCE_FIELDS) + if recurrence_changed and locked.status == EventSeries.Status.PUBLISHED and effective_from is None: + raise serializers.ValidationError({"effective_from": "Required for a published schedule change."}) + cutoff = effective_from or timezone.now() + old_revision = locked.revision + for field, value in changes.items(): + setattr(locked, field, value) + if recurrence_changed: + locked.revision += 1 + _validate(locked) + locked.save(update_fields=(*sorted(changes), "revision", "updated_at")) + if set(changes) & CALENDAR_SERIES_FIELDS: + calendar_series_changed(locked) + + future = list(Event.objects.select_for_update().filter( + series=locked, starts_at__gte=cutoff, + status__in=(Event.Status.DRAFT, Event.Status.PUBLISHED), + ).order_by("pk")) + require_module_locked(locked.makerspace_id, "events") + removed = [] + if recurrence_changed: + for event in future: + if event.status == Event.Status.PUBLISHED: + services.cancel(event, actor=actor, notify=False) + else: + event_id = event.pk + event.delete() + removed.append(event_id) + audit.record( + actor, "event.series_occurrence_removed", makerspace=locked.makerspace, + target=locked, meta={"event_id": event_id, "old_revision": old_revision}, + ) + else: + for event in future: + _apply_template(locked, event, set(changes)) + created = _materialize_locked(locked, actor=actor, now=timezone.now()) + audit.record( + actor, "event.series_updated", makerspace=locked.makerspace, target=locked, + meta={ + "changed_fields": sorted(changes), "old_revision": old_revision, + "new_revision": locked.revision, "removed_ids": removed, + "created_ids": [event.pk for event in created], + }, + ) + return locked, created, removed + + +from apps.events.services_series_lifecycle import ( # noqa: E402 + cancel_series, + complete_series, + publish_series, +) diff --git a/backend/apps/events/services_series_collaboration.py b/backend/apps/events/services_series_collaboration.py new file mode 100644 index 00000000..5b06ff2b --- /dev/null +++ b/backend/apps/events/services_series_collaboration.py @@ -0,0 +1,137 @@ +from django.db import transaction +from django.utils import timezone +from rest_framework import serializers +from rest_framework.exceptions import NotFound + +from apps.audit import services as audit +from apps.events.models import Event, EventCollaborator, EventSeries, EventSeriesCollaborator +from apps.makerspaces.guards import require_module_locked +from apps.makerspaces.models import Makerspace +from apps.makerspaces.servability import servable_queryset + + +def _rows(series): + return series.collaborators.select_related("makerspace").order_by("makerspace__slug", "pk") + + +def _lock_spaces(space_ids): + expected = set(space_ids) + spaces = list(Makerspace.objects.select_for_update().filter( + pk__in=expected + ).order_by("pk")) + if {space.pk for space in spaces} != expected: + raise NotFound("A series makerspace no longer exists.") + for space in spaces: + require_module_locked(space, "events") + return {space.pk: space for space in spaces} + + +def _project(source): + if source.status != EventSeriesCollaborator.Status.ACCEPTED: + return + for event in Event.objects.filter( + series=source.series, + ends_at__gte=timezone.now(), + ).order_by("pk"): + EventCollaborator.objects.update_or_create( + event=event, + makerspace=source.makerspace, + defaults={ + "status": EventCollaborator.Status.ACCEPTED, + "invited_by": source.invited_by, + "responded_by": source.responded_by, + "responded_at": source.responded_at, + "source_series_collaboration": source, + }, + ) + + +@transaction.atomic +def invite_collaborators(series, *, actor, slugs): + locked = EventSeries.objects.select_for_update().get(pk=series.pk) + normalized = sorted({str(slug).strip().lower() for slug in slugs if str(slug).strip()}) + spaces = list(servable_queryset( + Makerspace.objects.filter(slug__in=normalized), relation=None + ).order_by("pk")) + by_slug = {space.slug: space for space in spaces} + invalid = sorted(set(normalized) - set(by_slug)) + if locked.makerspace.slug in normalized: + invalid.append(locked.makerspace.slug) + if invalid: + raise serializers.ValidationError({"slugs": f"Unknown or host slug(s): {', '.join(invalid)}."}) + _lock_spaces({locked.makerspace_id, *(space.pk for space in spaces)}) + requested_ids = {space.pk for space in spaces} + removed = list(EventSeriesCollaborator.objects.filter(series=locked).exclude( + makerspace_id__in=requested_ids + ).values_list("pk", flat=True)) + if removed: + EventCollaborator.objects.filter(source_series_collaboration_id__in=removed).delete() + EventSeriesCollaborator.objects.filter(pk__in=removed).delete() + existing = {row.makerspace_id: row for row in locked.collaborators.all()} + for space in spaces: + if space.pk not in existing: + EventSeriesCollaborator.objects.create( + series=locked, makerspace=space, invited_by=actor + ) + audit.record( + actor, "event.series_collaborators_changed", makerspace=locked.makerspace, + target=locked, meta={"slugs": normalized}, + ) + return _rows(locked) + + +@transaction.atomic +def remove_collaborator(collaborator_id, *, actor): + series_id = EventSeriesCollaborator.objects.filter(pk=collaborator_id).values_list( + "series_id", flat=True + ).first() + if series_id is None: + raise NotFound("This series collaboration no longer exists.") + series = EventSeries.objects.select_for_update().get(pk=series_id) + collaborator_space_id = EventSeriesCollaborator.objects.filter( + pk=collaborator_id, series=series + ).values_list("makerspace_id", flat=True).first() + if collaborator_space_id is None: + raise NotFound("This series collaboration no longer exists.") + _lock_spaces((series.makerspace_id, collaborator_space_id)) + row = EventSeriesCollaborator.objects.select_for_update().filter( + pk=collaborator_id, series=series + ).first() + if row is None: + raise NotFound("This series collaboration no longer exists.") + slug = row.makerspace.slug + EventCollaborator.objects.filter(source_series_collaboration=row).delete() + row.delete() + audit.record( + actor, "event.series_collaborators_changed", makerspace=series.makerspace, + target=series, meta={"removed": slug}, + ) + + +@transaction.atomic +def respond(collaborator, *, actor, accept): + series = EventSeries.objects.select_for_update().get(pk=collaborator.series_id) + spaces = _lock_spaces((series.makerspace_id, collaborator.makerspace_id)) + space = spaces[collaborator.makerspace_id] + row = EventSeriesCollaborator.objects.select_for_update().filter( + pk=collaborator.pk, series=series + ).first() + if row is None: + raise NotFound("This series collaboration invitation no longer exists.") + row.status = ( + EventSeriesCollaborator.Status.ACCEPTED + if accept else EventSeriesCollaborator.Status.DECLINED + ) + row.responded_by = actor + row.responded_at = timezone.now() + row.save(update_fields=("status", "responded_by", "responded_at")) + if accept: + _project(row) + else: + EventCollaborator.objects.filter(source_series_collaboration=row).delete() + audit.record( + actor, + "event.series_collaboration_accepted" if accept else "event.series_collaboration_declined", + makerspace=space, target=row, meta={"series_id": series.pk}, + ) + return row diff --git a/backend/apps/events/services_series_images.py b/backend/apps/events/services_series_images.py new file mode 100644 index 00000000..2e6f5208 --- /dev/null +++ b/backend/apps/events/services_series_images.py @@ -0,0 +1,48 @@ +from django.db import transaction +from rest_framework.exceptions import ValidationError + +from apps.audit import services as audit +from apps.events.models import EventSeries +from apps.inventory import public_image_storage +from apps.makerspaces import limits +from apps.makerspaces.guards import require_module_locked + + +@transaction.atomic +def update_image(series, actor, object_key): + new_size = public_image_storage.object_size(object_key) + locked = EventSeries.objects.select_for_update().get(pk=series.pk) + require_module_locked(locked.makerspace_id, "events") + old_key = locked.image_key + if object_key == old_key: + return locked + if public_image_storage.public_image_key_in_use( + locked.makerspace_id, object_key, series_id=locked.pk + ): + raise ValidationError({"object_key": "This image is already in use."}) + limits.add_storage(locked.makerspace, new_size or 0) + if old_key: + public_image_storage.release_public_image_on_commit(locked.makerspace, old_key) + locked.image_key = object_key + locked.save(update_fields=("image_key", "updated_at")) + audit.record( + actor, "event.series_image_updated", makerspace=locked.makerspace, + target=locked, meta={"replaced_image": bool(old_key)}, + ) + return locked + + +@transaction.atomic +def remove_image(series, actor): + locked = EventSeries.objects.select_for_update().get(pk=series.pk) + require_module_locked(locked.makerspace_id, "events") + old_key = locked.image_key + if not old_key: + return locked + locked.image_key = "" + locked.save(update_fields=("image_key", "updated_at")) + audit.record( + actor, "event.series_image_removed", makerspace=locked.makerspace, target=locked + ) + public_image_storage.release_public_image_on_commit(locked.makerspace, old_key) + return locked diff --git a/backend/apps/events/services_series_lifecycle.py b/backend/apps/events/services_series_lifecycle.py new file mode 100644 index 00000000..c614512e --- /dev/null +++ b/backend/apps/events/services_series_lifecycle.py @@ -0,0 +1,86 @@ +from django.db import transaction +from django.db.models import F +from django.utils import timezone + +from apps.audit import services as audit +from apps.events import services +from apps.events.exceptions import EventInvalidTransition +from apps.events.models import Event, EventSeries +from apps.events.notifications import notify_series_lifecycle +from apps.events.services_recurrence import recurrence_exhausted +from apps.events.services_calendar import calendar_series_changed +from apps.makerspaces import limits +from apps.makerspaces.guards import require_module_locked + + +@transaction.atomic +def publish_series(series, *, actor): + from apps.events.services_series import _materialize_locked, _validate + + locked = EventSeries.objects.select_for_update().get(pk=series.pk) + if locked.status != EventSeries.Status.DRAFT: + raise EventInvalidTransition("Only draft series can be published.") + _validate(locked) + list(Event.objects.select_for_update().filter(series=locked).order_by("pk")) + require_module_locked(locked.makerspace_id, "events") + _materialize_locked(locked, actor=actor, now=timezone.now()) + events = list(Event.objects.filter( + series=locked, status=Event.Status.DRAFT, ends_at__gte=timezone.now() + ).order_by("pk")) + limits.check_quota(locked.makerspace, "events", adding=len(events)) + now = timezone.now() + Event.objects.filter(pk__in=[event.pk for event in events]).update( + status=Event.Status.PUBLISHED, + calendar_sequence=F("calendar_sequence") + 1, + calendar_updated_at=now, + ) + locked.status = EventSeries.Status.PUBLISHED + locked.save(update_fields=("status", "updated_at")) + calendar_series_changed(locked, now=now) + audit.record( + actor, "event.series_published", makerspace=locked.makerspace, target=locked, + meta={"published_count": len(events)}, + ) + notify_series_lifecycle(locked, "series_published") + return locked, len(events) + + +@transaction.atomic +def cancel_series(series, *, actor): + locked = EventSeries.objects.select_for_update().get(pk=series.pk) + if locked.status != EventSeries.Status.PUBLISHED: + raise EventInvalidTransition("Only a published series can be cancelled.") + events = list(Event.objects.select_for_update().filter( + series=locked, status=Event.Status.PUBLISHED, ends_at__gte=timezone.now() + ).order_by("pk")) + require_module_locked(locked.makerspace_id, "events") + for event in events: + services.cancel(event, actor=actor, notify=False) + locked.status = EventSeries.Status.CANCELLED + locked.save(update_fields=("status", "updated_at")) + calendar_series_changed(locked) + audit.record( + actor, "event.series_cancelled", makerspace=locked.makerspace, target=locked, + meta={"cancelled_count": len(events)}, + ) + notify_series_lifecycle(locked, "series_cancelled") + return locked, len(events) + + +@transaction.atomic +def complete_series(series, *, actor): + locked = EventSeries.objects.select_for_update().get(pk=series.pk) + if locked.status != EventSeries.Status.PUBLISHED: + raise EventInvalidTransition("Only a published series can be completed.") + events = list(Event.objects.select_for_update().filter(series=locked).order_by("pk")) + require_module_locked(locked.makerspace_id, "events") + if not recurrence_exhausted(locked, now=timezone.now()): + raise EventInvalidTransition("The recurrence is unbounded or not yet exhausted.") + if any(event.status == Event.Status.PUBLISHED for event in events): + raise EventInvalidTransition("Complete or cancel every occurrence first.") + locked.status = EventSeries.Status.COMPLETED + locked.save(update_fields=("status", "updated_at")) + calendar_series_changed(locked) + audit.record(actor, "event.series_completed", makerspace=locked.makerspace, target=locked) + notify_series_lifecycle(locked, "series_completed") + return locked diff --git a/backend/apps/events/services_station.py b/backend/apps/events/services_station.py new file mode 100644 index 00000000..ef8eac29 --- /dev/null +++ b/backend/apps/events/services_station.py @@ -0,0 +1,279 @@ +from datetime import datetime +import hashlib +import hmac +import secrets +from urllib.parse import quote +from uuid import UUID, uuid4 + +from django.conf import settings +from django.contrib.auth.hashers import check_password, make_password +from django.core.exceptions import ImproperlyConfigured +from django.db import transaction +from django.utils import timezone +from rest_framework.exceptions import APIException, PermissionDenied + +# EVERY public station refusal uses this one string. Distinguishing "bad credential" +# from "bad request" tells an attacker which check they passed, which turns the +# station into an oracle for whether a PIN, session or event window is valid. +STATION_REFUSAL = "Invalid station request." + +from apps.accounts.models import User +from apps.apiclients.crypto import decrypt_secret, encrypt_secret +from apps.events.checkin_policy import window_for +from apps.events.checkin_tokens import read_station_cookie, sign_station_cookie +from apps.events.models import Event, EventCheckInStationCredential +from apps.makerspaces.guards import require_feature, require_feature_locked + + +STATION_COOKIE_NAME = "sw_event_station" + + +class StationSecretUnavailable(APIException): + status_code = 503 + default_detail = "Station credential encryption is not configured." + default_code = "station_secret_unavailable" + + +class PasswordStepUpUnavailable(APIException): + status_code = 409 + default_detail = "This account cannot use password reveal; rotate the PIN instead." + default_code = "password_step_up_unavailable" + + +def station_url(event, public_token): + if event.makerspace.frontend_domain and event.makerspace.frontend_domain_status == "verified": + return f"https://{event.makerspace.frontend_domain}/event-check-in/{quote(str(public_token))}" + base = (settings.PUBLIC_APP_BASE_URL or "http://localhost:5000").rstrip("/") + return f"{base}/m/{quote(event.makerspace.slug)}/event-check-in/{quote(str(public_token))}" + + +def status_payload(event, credential=None): + if credential is None: + credential = EventCheckInStationCredential.objects.filter(event=event).first() + if credential is None: + return {"configured": False} + return { + "configured": True, + "enabled": credential.is_enabled, + "public_token": credential.public_token, + "version": credential.version, + "station_url": station_url(event, credential.public_token), + "rotated_at": credential.rotated_at, + } + + +@transaction.atomic +def rotate(event, *, actor): + from apps.events import services + + locked = services._locked_event(event.pk) + locked.makerspace = require_feature_locked( + locked.makerspace_id, "events.offline_checkin" + ) + credential = EventCheckInStationCredential.objects.select_for_update().filter( + event=locked + ).first() + version = credential.version + 1 if credential else 1 + pin = f"{secrets.randbelow(100_000_000):08d}" + while credential is not None and check_password( + _pin_material(locked.pk, credential.version, pin), + credential.pin_digest, + ): + pin = f"{secrets.randbelow(100_000_000):08d}" + digest = make_password(_pin_material(locked.pk, version, pin)) + try: + ciphertext = encrypt_secret(pin) + except (ImproperlyConfigured, ValueError, TypeError) as exc: + raise StationSecretUnavailable() from exc + now = timezone.now() + if credential is None: + credential = EventCheckInStationCredential.objects.create( + event=locked, + pin_digest=digest, + pin_ciphertext=ciphertext, + version=version, + is_enabled=True, + rotated_at=now, + ) + else: + credential.pin_digest = digest + credential.pin_ciphertext = ciphertext + credential.version = version + credential.is_enabled = True + credential.rotated_at = now + credential.disabled_at = None + credential.save( + update_fields=[ + "pin_digest", "pin_ciphertext", "version", "is_enabled", + "rotated_at", "disabled_at", "updated_at", + ] + ) + services._audit( + locked, + actor, + "event.station_pin_rotated", + locked, + {"event_id": locked.pk, "station_version": credential.version}, + ) + return credential, pin + + +@transaction.atomic +def reveal(event, *, actor, current_password): + from apps.events import services + + locked = services._locked_event(event.pk) + locked.makerspace = require_feature_locked( + locked.makerspace_id, "events.offline_checkin" + ) + credential = EventCheckInStationCredential.objects.select_for_update().filter( + event=locked, + is_enabled=True, + ).first() + if credential is None: + raise PasswordStepUpUnavailable("Rotate a station PIN before revealing it.") + principal = User.objects.select_for_update().get(pk=actor.pk) + if not principal.has_usable_password(): + raise PasswordStepUpUnavailable() + if not principal.check_password(current_password): + raise PermissionDenied("Current password is incorrect.") + try: + pin = decrypt_secret(credential.pin_ciphertext) + except (ImproperlyConfigured, ValueError, TypeError) as exc: + raise StationSecretUnavailable() from exc + services._audit( + locked, + actor, + "event.station_pin_revealed", + locked, + {"event_id": locked.pk, "station_version": credential.version}, + ) + return credential, pin + + +@transaction.atomic +def disable(event, *, actor): + from apps.events import services + + locked = services._locked_event(event.pk) + locked.makerspace = require_feature_locked( + locked.makerspace_id, "events.offline_checkin" + ) + credential = EventCheckInStationCredential.objects.select_for_update().filter( + event=locked + ).first() + if credential is None: + return None + credential.is_enabled = False + credential.disabled_at = timezone.now() + credential.save(update_fields=["is_enabled", "disabled_at", "updated_at"]) + services._audit( + locked, + actor, + "event.station_disabled", + locked, + {"event_id": locked.pk, "station_version": credential.version}, + ) + return credential + + +def start_session(public_token, *, pin): + from apps.events import services + + try: + token = UUID(str(public_token)) + except (TypeError, ValueError, AttributeError): + raise PermissionDenied(STATION_REFUSAL) from None + observed = EventCheckInStationCredential.objects.filter( + public_token=token + ).values_list("event_id", flat=True).first() + if observed is None: + raise PermissionDenied(STATION_REFUSAL) + failed = False + with transaction.atomic(): + event = services._locked_event(observed) + event.makerspace = require_feature_locked( + event.makerspace_id, "events.offline_checkin" + ) + credential = EventCheckInStationCredential.objects.select_for_update().filter( + event=event, + public_token=token, + ).first() + now = timezone.now() + window = window_for(event) + valid = ( + credential is not None + and credential.is_enabled + and event.status in (Event.Status.PUBLISHED, Event.Status.COMPLETED) + and window.opens_at <= now <= window.closes_at + and check_password( + _pin_material(event.pk, credential.version, pin), + credential.pin_digest, + ) + ) + if not valid: + if credential is not None: + services._audit( + event, + None, + "event.station_pin_failed", + event, + {"event_id": event.pk, "station_version": credential.version}, + ) + failed = True + else: + session_id = uuid4() + services._audit( + event, + None, + "event.station_session_started", + event, + { + "event_id": event.pk, + "session_id": str(session_id), + "station_version": credential.version, + }, + ) + cookie = sign_station_cookie( + public_token=credential.public_token, + version=credential.version, + session_id=session_id, + expires_at=window.sync_deadline, + ) + # Raise only after the atomic block commits so bounded failed-PIN audit entries survive. + if failed: + raise PermissionDenied(STATION_REFUSAL) + return event, credential, session_id, cookie, window.sync_deadline + + +def resolve_session(public_token, cookie_value): + try: + token = UUID(str(public_token)) + payload = read_station_cookie(cookie_value) + session_id = UUID(payload["session_id"]) + expires_at = datetime.fromisoformat(payload["expires_at"]) + except Exception: + raise PermissionDenied("Invalid station session.") from None + credential = EventCheckInStationCredential.objects.select_related( + "event__makerspace" + ).filter( + public_token=token, + is_enabled=True, + version=payload.get("version"), + ).first() + if ( + credential is None + or payload.get("public_token") != str(token) + or timezone.now() > expires_at + ): + raise PermissionDenied("Invalid station session.") + require_feature(credential.event.makerspace, "events.offline_checkin") + return credential.event, credential, session_id + + +def _pin_material(event_id, version, pin): + pepper = str(settings.EVENT_STATION_PIN_PEPPER or "").encode("utf-8") + if len(pepper) < 32: + raise StationSecretUnavailable() + message = f"spaceworks:event-station-pin:v1:{event_id}:{version}:{pin}".encode() + return hmac.new(pepper, message, hashlib.sha256).hexdigest() diff --git a/backend/apps/events/station_auth.py b/backend/apps/events/station_auth.py new file mode 100644 index 00000000..3488c3b4 --- /dev/null +++ b/backend/apps/events/station_auth.py @@ -0,0 +1,22 @@ +from drf_spectacular.extensions import OpenApiAuthenticationExtension +from rest_framework.authentication import BaseAuthentication + + +class EventStationCookieAuthentication(BaseAuthentication): + """Schema marker; station views validate the path-bound cookie explicitly.""" + + def authenticate(self, request): + return None + + +class EventStationCookieAuthenticationScheme(OpenApiAuthenticationExtension): + target_class = EventStationCookieAuthentication + name = "EventStationCookie" + + def get_security_definition(self, auto_schema): + return { + "type": "apiKey", + "in": "cookie", + "name": "sw_event_station", + "description": "Signed, event/version-bound venue-station session cookie.", + } diff --git a/backend/apps/events/tasks.py b/backend/apps/events/tasks.py new file mode 100644 index 00000000..355ddadf --- /dev/null +++ b/backend/apps/events/tasks.py @@ -0,0 +1,61 @@ +import logging + +from celery import shared_task +from django.db import transaction +from django.utils import timezone + +from apps.audit import services as audit +from apps.events.models import EventSeries +from apps.events.services_series import _materialize_locked +from apps.events.notifications import notify_series_lifecycle +from apps.makerspaces.guards import require_module_locked +from apps.makerspaces.models import Makerspace +from apps.makerspaces.servability import servable_queryset + + +logger = logging.getLogger(__name__) + + +def extend_published_series(): + series_ids = servable_queryset( + EventSeries.objects.filter( + status=EventSeries.Status.PUBLISHED, + makerspace__enabled_modules__contains=["events"], + ), + relation="makerspace", + ).order_by("pk").values_list("pk", flat=True) + for series_id in series_ids.iterator(chunk_size=100): + try: + with transaction.atomic(): + series = EventSeries.objects.select_for_update().get(pk=series_id) + locked_space = Makerspace.objects.select_for_update().get( + pk=series.makerspace_id + ) + require_module_locked(locked_space, "events") + series.makerspace = locked_space + created = _materialize_locked(series, actor=None, now=timezone.now()) + if created: + audit.record( + None, "event.series_extended", makerspace=series.makerspace, + target=series, meta={"created_ids": [row.pk for row in created]}, + ) + except Exception as exc: # noqa: BLE001 - one bad legacy rule must not stop others + code = getattr(exc, "default_code", exc.__class__.__name__) + code = str(code)[:64] + logger.exception("event series extension failed", extra={"series_id": series_id}) + with transaction.atomic(): + series = EventSeries.objects.select_for_update().filter(pk=series_id).first() + if series is None: + continue + series.last_generation_error_code = code + series.save(update_fields=("last_generation_error_code", "updated_at")) + audit.record( + None, "event.series_generation_failed", makerspace=series.makerspace, + target=series, meta={"error_code": code}, + ) + notify_series_lifecycle(series, "series_generation_failed") + + +@shared_task +def extend_event_series_task(): + extend_published_series() diff --git a/backend/apps/events/throttles.py b/backend/apps/events/throttles.py index 36b33c84..f250b9a5 100644 --- a/backend/apps/events/throttles.py +++ b/backend/apps/events/throttles.py @@ -1,3 +1,5 @@ +import hashlib + from rest_framework.throttling import SimpleRateThrottle from apps.apiclients.throttling import ClientTierRateThrottle @@ -63,3 +65,77 @@ def get_cache_key(self, request, view): if user is None or not user.is_authenticated: return None return self.cache_format % {"scope": self.scope, "ident": user.pk} + + +class EventCalendarFeedTokenThrottle(SimpleRateThrottle): + scope = "event_calendar_feed_token" + + def get_cache_key(self, request, view): + raw_token = view.kwargs.get("raw_token", "") + if not raw_token: + return None + digest = hashlib.sha256(raw_token.encode("utf-8")).hexdigest() + return self.cache_format % {"scope": self.scope, "ident": digest} + + +class EventCalendarFeedIpThrottle(SimpleRateThrottle): + scope = "event_calendar_feed_ip" + + def get_cache_key(self, request, view): + return self.cache_format % {"scope": self.scope, "ident": self.get_ident(request)} + + +class EventOfflineRosterThrottle(SimpleRateThrottle): + scope = "event_offline_roster" + + def get_cache_key(self, request, view): + user = getattr(request, "user", None) + if user is None or not user.is_authenticated: + return None + return self.cache_format % {"scope": self.scope, "ident": user.pk} + + +class EventOfflineSyncThrottle(EventOfflineRosterThrottle): + scope = "event_offline_sync" + + +class EventStationPinTokenThrottle(SimpleRateThrottle): + scope = "event_station_pin_token" + + def get_cache_key(self, request, view): + token = str(view.kwargs.get("public_token", "")) + if not token: + return None + ident = hashlib.sha256(token.encode()).hexdigest() + return self.cache_format % {"scope": self.scope, "ident": ident} + + +class EventStationPinIpThrottle(SimpleRateThrottle): + scope = "event_station_pin_ip" + + def get_cache_key(self, request, view): + return self.cache_format % {"scope": self.scope, "ident": self.get_ident(request)} + + +class EventStationSessionThrottle(EventStationPinTokenThrottle): + scope = "event_station_session" + + +class EventStationRevealThrottle(EventOfflineRosterThrottle): + scope = "event_station_reveal" + + +class PublicFeedbackRateThrottle(ClientTierRateThrottle): + """Reads and submissions get separate budgets on the public feedback route. + + The scope is chosen HERE rather than in a view-level `get_throttles()` override, + because this is a pre-auth claim route: `claim_pre_auth_guard.validate_pre_auth_route` + forbids overriding any DRF lifecycle hook on a route that runs before claim-token + authentication, so that no per-request view mutation can happen ahead of the + authenticator. `ScopedRateThrottle` reads `view.throttle_scope` inside `allow_request`, + so setting it from the throttle keeps the exact same two budgets without that override. + """ + + def allow_request(self, request, view): + view.throttle_scope = "public_read" if request.method == "GET" else "event_register" + return super().allow_request(request, view) diff --git a/backend/apps/events/urls_admin.py b/backend/apps/events/urls_admin.py index 40ed6a4d..cc342ac4 100644 --- a/backend/apps/events/urls_admin.py +++ b/backend/apps/events/urls_admin.py @@ -8,7 +8,7 @@ every `reverse()` are unaffected. No `app_name`, matching `admin_api`. """ -from django.urls import path +from django.urls import include, path from apps.events.views_admin import ( EventCancelView, @@ -17,20 +17,112 @@ EventDetailView, EventListCreateView, EventPublishView, + EventRegistrationApproveView, EventRegistrationListView, EventRegistrationMarkAttendedView, + EventRegistrationPromoteView, + EventRegistrationRejectView, ) from apps.events.views_admin_organized import OrganizedEventListView from apps.events.views_admin_image import EventImageView +from apps.events.views_admin_organizers import EventOrganizerView from apps.events.views_checkin import EventCheckInResolveView +from apps.events.views_feedback_admin import ( + EventCertificateDownloadView, + EventCertificateReissueView, + EventCertificateRevokeView, + EventFeedbackResponseListView, + EventFeedbackSurveyCloseView, + EventFeedbackSurveyOpenView, + EventFeedbackSurveyView, + EventRegistrationCorrectAttendanceView, +) from apps.events.views_collaborators import ( EventCollaborationInboxView, EventCollaborationRemoveView, EventCollaborationRespondView, EventCollaboratorListView, ) +from apps.events.views_series import ( + EventSeriesCancelView, + EventSeriesCompleteView, + EventSeriesDetailView, + EventSeriesExtendView, + EventSeriesListCreateView, + EventSeriesOccurrenceListView, + EventSeriesPublishView, +) +from apps.events.views_series_collaboration import ( + EventSeriesCollaborationInboxView, + EventSeriesCollaborationRemoveView, + EventSeriesCollaborationRespondView, + EventSeriesCollaboratorListView, +) +from apps.events.views_series_image import EventSeriesImageView +from apps.events.views_badges import EventBadgePdfView, EventBadgeTemplateView urlpatterns = [ + path("", include("apps.events.urls_checkin_admin")), + path( + 'makerspaces//event-series/', + EventSeriesListCreateView.as_view(), + name='admin-event-series-list-create', + ), + path( + 'event-series//', + EventSeriesDetailView.as_view(), + name='admin-event-series-detail', + ), + path( + 'event-series//occurrences/', + EventSeriesOccurrenceListView.as_view(), + name='admin-event-series-occurrences', + ), + path( + 'event-series//publish/', + EventSeriesPublishView.as_view(), + name='admin-event-series-publish', + ), + path( + 'event-series//cancel/', + EventSeriesCancelView.as_view(), + name='admin-event-series-cancel', + ), + path( + 'event-series//complete/', + EventSeriesCompleteView.as_view(), + name='admin-event-series-complete', + ), + path( + 'event-series//extend/', + EventSeriesExtendView.as_view(), + name='admin-event-series-extend', + ), + path( + 'event-series//image', + EventSeriesImageView.as_view(), + name='admin-event-series-image', + ), + path( + 'event-series//collaborators/', + EventSeriesCollaboratorListView.as_view(), + name='admin-event-series-collaborators', + ), + path( + 'event-series-collaborations//remove/', + EventSeriesCollaborationRemoveView.as_view(), + name='admin-event-series-collaboration-remove', + ), + path( + 'makerspaces//event-series-collaborations/', + EventSeriesCollaborationInboxView.as_view(), + name='admin-event-series-collaboration-inbox', + ), + path( + 'event-series-collaborations//respond/', + EventSeriesCollaborationRespondView.as_view(), + name='admin-event-series-collaboration-respond', + ), path( 'makerspaces//events/', EventListCreateView.as_view(), @@ -66,6 +158,41 @@ EventRegistrationListView.as_view(), name='admin-event-registration-list', ), + path( + 'events//organizers/', + EventOrganizerView.as_view(), + name='admin-event-organizers', + ), + path( + 'events//badge-template/', + EventBadgeTemplateView.as_view(), + name='admin-event-badge-template', + ), + path( + 'events//badges.pdf', + EventBadgePdfView.as_view(), + name='admin-event-badges-pdf', + ), + path( + 'events//feedback-survey/', + EventFeedbackSurveyView.as_view(), + name='admin-event-feedback-survey', + ), + path( + 'events//feedback-survey/open/', + EventFeedbackSurveyOpenView.as_view(), + name='admin-event-feedback-survey-open', + ), + path( + 'events//feedback-survey/close/', + EventFeedbackSurveyCloseView.as_view(), + name='admin-event-feedback-survey-close', + ), + path( + 'events//feedback-responses/', + EventFeedbackResponseListView.as_view(), + name='admin-event-feedback-responses', + ), path( 'events//collaborators/', EventCollaboratorListView.as_view(), @@ -86,13 +213,6 @@ EventCollaborationRespondView.as_view(), name='admin-event-collaboration-respond', ), - # Keep the kwarg named `pk`: origin scope resolves MODEL_LOOKUPS from - # kwargs.get('pk'), so another name would deny every custom-domain request. - path( - 'events//check-in/resolve/', - EventCheckInResolveView.as_view(), - name='admin-event-check-in-resolve', - ), path( 'events//eligible-members/', EventEligibleMemberListView.as_view(), @@ -103,6 +223,41 @@ EventRegistrationMarkAttendedView.as_view(), name='admin-event-registration-mark-attended', ), + path( + 'event-registrations//correct-attendance/', + EventRegistrationCorrectAttendanceView.as_view(), + name='admin-event-registration-correct-attendance', + ), + path( + 'event-certificates//download/', + EventCertificateDownloadView.as_view(), + name='admin-event-certificate-download', + ), + path( + 'event-certificates//revoke/', + EventCertificateRevokeView.as_view(), + name='admin-event-certificate-revoke', + ), + path( + 'event-certificates//reissue/', + EventCertificateReissueView.as_view(), + name='admin-event-certificate-reissue', + ), + path( + 'event-registrations//approve/', + EventRegistrationApproveView.as_view(), + name='admin-event-registration-approve', + ), + path( + 'event-registrations//reject/', + EventRegistrationRejectView.as_view(), + name='admin-event-registration-reject', + ), + path( + 'event-registrations//promote/', + EventRegistrationPromoteView.as_view(), + name='admin-event-registration-promote', + ), path( 'organized-events/', OrganizedEventListView.as_view(), diff --git a/backend/apps/events/urls_checkin_admin.py b/backend/apps/events/urls_checkin_admin.py new file mode 100644 index 00000000..4cbd18b9 --- /dev/null +++ b/backend/apps/events/urls_checkin_admin.py @@ -0,0 +1,43 @@ +from django.urls import path + +from apps.events.views_checkin import EventCheckInResolveView +from apps.events.views_checkin_offline import EventOfflineRosterView, EventOfflineSyncView +from apps.events.views_checkin_station_admin import ( + EventStationRevealView, + EventStationRotateView, + EventStationStatusView, +) + + +urlpatterns = [ + path( + "events//check-in/resolve/", + EventCheckInResolveView.as_view(), + name="admin-event-check-in-resolve", + ), + path( + "events//check-in/offline-roster/", + EventOfflineRosterView.as_view(), + name="admin-event-check-in-offline-roster", + ), + path( + "events//check-in/offline-sync/", + EventOfflineSyncView.as_view(), + name="admin-event-check-in-offline-sync", + ), + path( + "events//check-in/station/", + EventStationStatusView.as_view(), + name="admin-event-check-in-station", + ), + path( + "events//check-in/station/rotate/", + EventStationRotateView.as_view(), + name="admin-event-check-in-station-rotate", + ), + path( + "events//check-in/station/reveal/", + EventStationRevealView.as_view(), + name="admin-event-check-in-station-reveal", + ), +] diff --git a/backend/apps/events/urls_member.py b/backend/apps/events/urls_member.py index 5e813945..d093a75c 100644 --- a/backend/apps/events/urls_member.py +++ b/backend/apps/events/urls_member.py @@ -11,12 +11,27 @@ from django.urls import path from apps.events.views_checkin import EventCheckInQrView +from apps.events.views_feedback_member import ( + MemberEventCertificateDownloadView, + MemberEventFeedbackView, +) from apps.events.views_member_events import ( MemberCollaborativeEventListView, MemberCollaborativeEventRegistrationView, ) +from apps.events.views_calendar import MemberEventCalendarFeedView, MemberEventCalendarView urlpatterns = [ + path( + 'makerspaces//event-registrations/calendar.ics', + MemberEventCalendarView.as_view(), + name='member-event-calendar', + ), + path( + 'makerspaces//event-calendar-feed/', + MemberEventCalendarFeedView.as_view(), + name='member-event-calendar-feed', + ), path( 'makerspaces//collaborative-events/', MemberCollaborativeEventListView.as_view(), @@ -32,4 +47,14 @@ EventCheckInQrView.as_view(), name='member-event-checkin-qr', ), + path( + 'makerspaces//event-registrations//feedback/', + MemberEventFeedbackView.as_view(), + name='member-event-feedback', + ), + path( + 'makerspaces//event-certificates//download/', + MemberEventCertificateDownloadView.as_view(), + name='member-event-certificate-download', + ), ] diff --git a/backend/apps/events/urls_public.py b/backend/apps/events/urls_public.py index dfd854db..f417982c 100644 --- a/backend/apps/events/urls_public.py +++ b/backend/apps/events/urls_public.py @@ -4,6 +4,11 @@ PublicEventListView, PublicEventRegistrationView, ) +from apps.events.views_feedback_public import PublicEventFeedbackView +from apps.events.views_calendar import ( + PublicEventCalendarView, + PublicMemberEventCalendarFeedView, +) urlpatterns = [ @@ -17,4 +22,19 @@ PublicEventRegistrationView.as_view(), name='public-event-register', ), + path( + '/events//feedback/', + PublicEventFeedbackView.as_view(), + name='public-event-feedback', + ), + path( + '/events//calendar.ics', + PublicEventCalendarView.as_view(), + name='public-event-calendar', + ), + path( + '/event-calendar/.ics', + PublicMemberEventCalendarFeedView.as_view(), + name='public-event-calendar-feed', + ), ] diff --git a/backend/apps/events/urls_station.py b/backend/apps/events/urls_station.py new file mode 100644 index 00000000..2d0d79cb --- /dev/null +++ b/backend/apps/events/urls_station.py @@ -0,0 +1,26 @@ +from django.urls import path + +from apps.events.views_checkin_station import ( + EventStationRosterView, + EventStationSessionView, + EventStationSyncView, +) + + +urlpatterns = [ + path( + "event-checkin-stations//session/", + EventStationSessionView.as_view(), + name="event-check-in-station-session", + ), + path( + "event-checkin-stations//roster/", + EventStationRosterView.as_view(), + name="event-check-in-station-roster", + ), + path( + "event-checkin-stations//sync/", + EventStationSyncView.as_view(), + name="event-check-in-station-sync", + ), +] diff --git a/backend/apps/events/views_admin.py b/backend/apps/events/views_admin.py index 162a003c..d4a402de 100644 --- a/backend/apps/events/views_admin.py +++ b/backend/apps/events/views_admin.py @@ -19,6 +19,9 @@ ) from apps.events.views_admin_registrations import ( EventEligibleMemberListView, + EventRegistrationApproveView, EventRegistrationListView, EventRegistrationMarkAttendedView, + EventRegistrationPromoteView, + EventRegistrationRejectView, ) diff --git a/backend/apps/events/views_admin_events.py b/backend/apps/events/views_admin_events.py index 9bd80c0d..af15ae5a 100644 --- a/backend/apps/events/views_admin_events.py +++ b/backend/apps/events/views_admin_events.py @@ -77,7 +77,7 @@ def _manageable_event(actor, pk): field='makerspace_id', ) event = get_object_or_404( - Event.objects.select_related('makerspace') + Event.objects.select_related('makerspace', 'series') .prefetch_related('organizers__organization') .filter( Q(pk__in=venue_scoped.values('pk')) @@ -161,6 +161,7 @@ def get(self, request, makerspace_id, *args, **kwargs): ) queryset = ( _annotate_registration_counts(queryset) + .select_related('series') .prefetch_related('organizers__organization') .order_by('starts_at', 'id') ) @@ -181,10 +182,12 @@ def post(self, request, makerspace_id, *args, **kwargs): makerspace = _visible_makerspace(request.user, makerspace_id) serializer = EventWriteSerializer(data=request.data) serializer.is_valid(raise_exception=True) + values = dict(serializer.validated_data) + values.pop('inherit_fields', None) event = services.create_event( makerspace=makerspace, actor=request.user, - **serializer.validated_data, + **values, ) return Response( EventAdminSerializer(event).data, @@ -274,4 +277,3 @@ class EventCompleteView(_EventActionView): ) def post(self, request, pk, *args, **kwargs): return self.execute(request, pk) - diff --git a/backend/apps/events/views_admin_organizers.py b/backend/apps/events/views_admin_organizers.py new file mode 100644 index 00000000..df043175 --- /dev/null +++ b/backend/apps/events/views_admin_organizers.py @@ -0,0 +1,40 @@ +from drf_spectacular.utils import OpenApiResponse, extend_schema +from rest_framework.response import Response +from rest_framework.views import APIView + +from apps.admin_api.permissions import IsActiveStaff +from apps.events import services_organizers +from apps.events.serializers_organizers import ( + EventOrganizerListSerializer, + EventOrganizerReplaceSerializer, +) +from apps.events.views_admin import _manageable_event +from apps.hardware_requests.exceptions import ErrorSerializer + + +class EventOrganizerView(APIView): + permission_classes = [IsActiveStaff] + + @extend_schema( + tags=["Admin events"], + summary="Replace an event's organization organizers", + request=EventOrganizerReplaceSerializer, + responses={ + 200: EventOrganizerListSerializer, + 400: OpenApiResponse(description="Invalid or unavailable organization."), + 401: ErrorSerializer, + 403: ErrorSerializer, + 404: ErrorSerializer, + 409: OpenApiResponse(description="Concurrent event state conflict."), + }, + ) + def put(self, request, pk): + event = _manageable_event(request.user, pk) + serializer = EventOrganizerReplaceSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + event = services_organizers.replace_organizers( + event, + actor=request.user, + organization_ids=serializer.validated_data["organization_ids"], + ) + return Response(EventOrganizerListSerializer(event).data) diff --git a/backend/apps/events/views_admin_registrations.py b/backend/apps/events/views_admin_registrations.py index 8bdd3538..acdbfd71 100644 --- a/backend/apps/events/views_admin_registrations.py +++ b/backend/apps/events/views_admin_registrations.py @@ -11,6 +11,7 @@ from apps.events import services from apps.events.models import EventRegistration from apps.events.serializers_admin import ( + EventAttendanceMarkSerializer, EmptyActionSerializer, EventEligibleMemberSerializer, EventRegistrationAdminSerializer, @@ -30,6 +31,15 @@ from apps.payments.models import Payment +REGISTRATION_ACTION_ERRORS = { + 400: OpenApiResponse(ErrorSerializer, description='Unexpected request body.'), + 401: OpenApiResponse(ErrorSerializer, description='Authentication is required.'), + 403: OpenApiResponse(ErrorSerializer, description='Event management is required.'), + 404: OpenApiResponse(ErrorSerializer, description='Registration not found.'), + 409: EVENT_ERROR_409, +} + + class EventRegistrationListView(APIView): permission_classes = [IsActiveStaff] @@ -124,13 +134,18 @@ class EventRegistrationMarkAttendedView(APIView): @extend_schema( tags=['Admin events'], summary='Mark an event registration attended', - request=EmptyActionSerializer, + request=EventAttendanceMarkSerializer, responses={200: EventRegistrationAdminSerializer, 409: EVENT_ERROR_409}, ) def post(self, request, pk, *args, **kwargs): registration = _manageable_registration(request.user, pk) - _validate_empty_action(request) - registration = services.mark_attended(registration, actor=request.user) + serializer = EventAttendanceMarkSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + registration = services.mark_attended( + registration, + actor=request.user, + source=serializer.validated_data["source"], + ) context = scoped_payment_context( request.user, rbac.Action.MANAGE_EVENTS, @@ -145,6 +160,64 @@ def post(self, request, pk, *args, **kwargs): ) +class _RegistrationActionView(APIView): + permission_classes = [IsActiveStaff] + operation = None + + def execute(self, request, pk): + registration = _manageable_registration(request.user, pk) + _validate_empty_action(request) + registration = self.operation(registration, actor=request.user) + context = scoped_payment_context( + request.user, + rbac.Action.MANAGE_EVENTS, + Payment.SubjectType.EVENT_REGISTRATION, + [registration.pk], + ) + return Response( + EventRegistrationAdminSerializer(registration, context=context).data + ) + + +class EventRegistrationApproveView(_RegistrationActionView): + operation = staticmethod(services.approve_registration) + + @extend_schema( + tags=['Admin events'], + summary='Approve a pending event registration', + request=EmptyActionSerializer, + responses={200: EventRegistrationAdminSerializer, **REGISTRATION_ACTION_ERRORS}, + ) + def post(self, request, pk, *args, **kwargs): + return self.execute(request, pk) + + +class EventRegistrationRejectView(_RegistrationActionView): + operation = staticmethod(services.reject_registration) + + @extend_schema( + tags=['Admin events'], + summary='Reject a pending or waitlisted event registration', + request=EmptyActionSerializer, + responses={200: EventRegistrationAdminSerializer, **REGISTRATION_ACTION_ERRORS}, + ) + def post(self, request, pk, *args, **kwargs): + return self.execute(request, pk) + + +class EventRegistrationPromoteView(_RegistrationActionView): + operation = staticmethod(services.promote_registration) + + @extend_schema( + tags=['Admin events'], + summary='Manually promote an approved waitlisted registration', + request=EmptyActionSerializer, + responses={200: EventRegistrationAdminSerializer, **REGISTRATION_ACTION_ERRORS}, + ) + def post(self, request, pk, *args, **kwargs): + return self.execute(request, pk) + + class EventEligibleMemberListView(APIView): """The roster the staff registration picker reads. diff --git a/backend/apps/events/views_badges.py b/backend/apps/events/views_badges.py new file mode 100644 index 00000000..23e6c0bc --- /dev/null +++ b/backend/apps/events/views_badges.py @@ -0,0 +1,78 @@ +import re + +from django.http import HttpResponse +from drf_spectacular.types import OpenApiTypes +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.events.badge_rendering import render_badges_pdf +from apps.events.exceptions import EventInvalidTransition +from apps.events.models import EventRegistration +from apps.events.serializers_badges import BadgePdfRequestSerializer, BadgeTemplateSerializer +from apps.events.services_badges import prepare_badges, save_badge_template +from apps.events.views_admin_events import EVENT_ERROR_400, EVENT_ERROR_409, _manageable_event +from apps.hardware_requests.exceptions import ErrorSerializer + + +def _filename(title): + value = re.sub(r"[^A-Za-z0-9._-]+", "-", title).strip("-")[:80] + return f"{value or 'event'}-badges.pdf" + + +class EventBadgeTemplateView(APIView): + permission_classes = [IsActiveStaff] + + @extend_schema( + tags=["Admin events"], request=None, + responses={200: BadgeTemplateSerializer, 403: ErrorSerializer, 404: ErrorSerializer}, + ) + def get(self, request, pk): + event = _manageable_event(request.user, pk) + from apps.events.badge_templates import normalize_badge_template + + return Response(normalize_badge_template(event.badge_template, event)) + + @extend_schema( + tags=["Admin events"], request=BadgeTemplateSerializer, + responses={200: BadgeTemplateSerializer, 400: EVENT_ERROR_400, + 403: ErrorSerializer, 404: ErrorSerializer, 409: EVENT_ERROR_409}, + ) + def put(self, request, pk): + event = _manageable_event(request.user, pk) + serializer = BadgeTemplateSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + normalized = save_badge_template(event, serializer.validated_data, actor=request.user) + return Response(normalized) + + +class EventBadgePdfView(APIView): + permission_classes = [IsActiveStaff] + + @extend_schema( + tags=["Admin events"], request=BadgePdfRequestSerializer, + responses={ + (200, "application/pdf"): OpenApiResponse( + OpenApiTypes.BINARY, description="Print-ready badge PDF." + ), + 400: EVENT_ERROR_400, 403: ErrorSerializer, 404: ErrorSerializer, 409: EVENT_ERROR_409, + }, + ) + def post(self, request, pk): + event = _manageable_event(request.user, pk) + serializer = BadgePdfRequestSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + try: + template, snapshots = prepare_badges( + event, actor=request.user, **serializer.validated_data + ) + except EventRegistration.DoesNotExist as exc: + raise NotFound("A selected registration was not found for this event.") from exc + payload = render_badges_pdf(template, snapshots, title=f"{event.title} attendee badges") + response = HttpResponse(payload, content_type="application/pdf") + response["Content-Disposition"] = f'attachment; filename="{_filename(event.title)}"' + response["Cache-Control"] = "private, no-store" + response["X-Content-Type-Options"] = "nosniff" + return response diff --git a/backend/apps/events/views_calendar.py b/backend/apps/events/views_calendar.py new file mode 100644 index 00000000..fb1f401d --- /dev/null +++ b/backend/apps/events/views_calendar.py @@ -0,0 +1,199 @@ +import hashlib + +from django.http import HttpResponse +from django.http import Http404 +from django.shortcuts import get_object_or_404 +from django.urls import reverse +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiResponse, extend_schema +from rest_framework import status +from rest_framework.exceptions import NotFound, PermissionDenied +from rest_framework.permissions import AllowAny, IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from apps.apiclients.throttling import ClientTierRateThrottle +from apps.events.member_history import registrations_for_space +from apps.events.models import Event +from apps.events.serializers_calendar import ( + MemberCalendarFeedIssuedSerializer, + MemberCalendarFeedIssueSerializer, + MemberCalendarFeedStateSerializer, +) +from apps.events.services_calendar import ( + render_member_calendar, + render_public_event_calendar, +) +from apps.events.services_calendar_feeds import ( + feed_state, + issue_or_rotate_feed, + resolve_feed, + revoke_feed, +) +from apps.events.throttles import ( + EventCalendarFeedIpThrottle, + EventCalendarFeedTokenThrottle, +) +from apps.hardware_requests.exceptions import ErrorSerializer +from apps.makerspaces.guards import require_module, require_module_for_servable +from apps.makerspaces.lookup import get_public_makerspace +from apps.makerspaces.member_activity_service import active_membership +from apps.makerspaces.platform import module_enabled + + +CALENDAR_RESPONSE = OpenApiResponse( + response=OpenApiTypes.BINARY, + description="RFC 5545 calendar (`text/calendar; charset=utf-8`).", +) +CALENDAR_SUCCESS = {(200, "text/calendar"): CALENDAR_RESPONSE} +CALENDAR_ERRORS = { + 400: OpenApiResponse(ErrorSerializer, description="Events module unavailable."), + 404: OpenApiResponse(ErrorSerializer, description="Calendar not found."), + 429: OpenApiResponse(ErrorSerializer, description="Rate limit exceeded."), +} + + +def _calendar_response(payload, filename, request, *, private=False): + etag = '"' + hashlib.sha256(payload).hexdigest() + '"' + if request.headers.get("If-None-Match") == etag: + response = HttpResponse(status=304) + else: + response = HttpResponse(payload, content_type="text/calendar; charset=utf-8") + response["Content-Disposition"] = f'attachment; filename="{filename}"' + response["ETag"] = etag + response["Cache-Control"] = ( + "private, max-age=300, must-revalidate" if private else "public, max-age=300" + ) + response["Referrer-Policy"] = "no-referrer" + response["X-Robots-Tag"] = "noindex" + return response + + +def _member_membership(request, makerspace_id): + membership = active_membership(request.user, makerspace_id) + if membership is None: + raise PermissionDenied("Active membership is required.") + require_module(membership.makerspace, "events") + return membership + + +class PublicEventCalendarView(APIView): + permission_classes = [AllowAny] + throttle_classes = [ClientTierRateThrottle] + throttle_scope = "public_read" + + @extend_schema( + tags=["Public events"], auth=[], request=None, + responses={**CALENDAR_SUCCESS, **CALENDAR_ERRORS}, + ) + def get(self, request, makerspace_slug, public_token): + makerspace = get_public_makerspace(makerspace_slug) + require_module_for_servable(makerspace, "events") + event = get_object_or_404( + Event.objects.select_related("series").filter( + makerspace=makerspace, + public_token=public_token, + is_public=True, + status__in=( + Event.Status.PUBLISHED, Event.Status.COMPLETED, Event.Status.CANCELLED, + ), + ) + ) + return _calendar_response( + render_public_event_calendar(event), f"event-{event.public_token}.ics", request + ) + + +class MemberEventCalendarView(APIView): + permission_classes = [IsAuthenticated] + + @extend_schema( + tags=["Member events"], request=None, + responses={ + **CALENDAR_SUCCESS, + 401: OpenApiResponse(ErrorSerializer, description="Authentication required."), + 403: OpenApiResponse(ErrorSerializer, description="Active membership required."), + 404: OpenApiResponse(ErrorSerializer, description="Makerspace not found."), + 400: OpenApiResponse(ErrorSerializer, description="Events module unavailable."), + }, + ) + def get(self, request, makerspace_id): + membership = _member_membership(request, makerspace_id) + rows = registrations_for_space(membership.makerspace, request.user) + payload = render_member_calendar(membership.makerspace, rows) + return _calendar_response(payload, "my-events.ics", request, private=True) + + +class MemberEventCalendarFeedView(APIView): + permission_classes = [IsAuthenticated] + + @extend_schema( + tags=["Member events"], request=None, + responses={200: MemberCalendarFeedStateSerializer, 401: ErrorSerializer, + 403: ErrorSerializer, 404: ErrorSerializer, 400: ErrorSerializer}, + ) + def get(self, request, makerspace_id): + membership = _member_membership(request, makerspace_id) + return Response(MemberCalendarFeedStateSerializer(feed_state(membership)).data) + + @extend_schema( + tags=["Member events"], request=MemberCalendarFeedIssueSerializer, + responses={200: MemberCalendarFeedIssuedSerializer, 400: ErrorSerializer, + 401: ErrorSerializer, 403: ErrorSerializer, 404: ErrorSerializer, + 409: OpenApiResponse(ErrorSerializer, description="Concurrent rotation conflict.")}, + ) + def post(self, request, makerspace_id): + membership = _member_membership(request, makerspace_id) + serializer = MemberCalendarFeedIssueSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + feed, raw_token = issue_or_rotate_feed(membership, actor=request.user) + path = reverse("public-event-calendar-feed", kwargs={ + "makerspace_slug": membership.makerspace.slug, "raw_token": raw_token, + }) + result = { + "feed_url": request.build_absolute_uri(path), + "token_hint": feed.token_hint, + "created_at": feed.created_at, + } + return Response(MemberCalendarFeedIssuedSerializer(result).data) + + @extend_schema( + tags=["Member events"], request=None, + responses={204: None, 401: ErrorSerializer, 403: ErrorSerializer, + 404: ErrorSerializer, 400: ErrorSerializer}, + ) + def delete(self, request, makerspace_id): + membership = _member_membership(request, makerspace_id) + revoke_feed(membership, actor=request.user) + return Response(status=status.HTTP_204_NO_CONTENT) + + +class PublicMemberEventCalendarFeedView(APIView): + permission_classes = [AllowAny] + throttle_classes = [EventCalendarFeedTokenThrottle, EventCalendarFeedIpThrottle] + + @extend_schema( + tags=["Public events"], auth=[], request=None, + responses={**CALENDAR_SUCCESS, 404: CALENDAR_ERRORS[404], 429: CALENDAR_ERRORS[429]}, + ) + def get(self, request, makerspace_slug, raw_token): + feed = resolve_feed(raw_token) + if feed is None: + raise NotFound() + membership = feed.membership + if ( + membership.status != "active" + or not membership.user.is_active + or membership.user.access_status != "active" + or membership.makerspace.slug != makerspace_slug + ): + raise NotFound() + try: + makerspace = get_public_makerspace(makerspace_slug) + except Http404 as exc: + raise NotFound() from exc + if makerspace.pk != membership.makerspace_id or not module_enabled(makerspace, "events"): + raise NotFound() + rows = registrations_for_space(makerspace, membership.user) + payload = render_member_calendar(makerspace, rows) + return _calendar_response(payload, "my-events.ics", request, private=True) diff --git a/backend/apps/events/views_checkin.py b/backend/apps/events/views_checkin.py index 0a8807d6..cd6edbb4 100644 --- a/backend/apps/events/views_checkin.py +++ b/backend/apps/events/views_checkin.py @@ -14,6 +14,7 @@ from apps.boxes.qr_render import render_qr_label_svg from apps.events.member_history import registrations_for_space from apps.events.models import Event, EventRegistration +from apps.events.checkin_roster import host_waiver_state from apps.events.serializers_checkin import ( EventCheckInResolveRequestSerializer, EventCheckInResolveResponseSerializer, @@ -23,8 +24,7 @@ from apps.hardware_requests.exceptions import ErrorSerializer from apps.makerspaces.guards import require_module from apps.makerspaces.member_activity_service import active_membership -from apps.makerspaces.models import MakerspaceMembership, MakerspaceWaiver -from apps.makerspaces.waiver_state import acceptance_on_file_q +from apps.makerspaces.models import MakerspaceWaiver from apps.payments.models import Payment from apps.presence.guard import MemberPresenceRequired @@ -35,33 +35,6 @@ CHECKABLE_EVENT_STATUSES = (Event.Status.PUBLISHED, Event.Status.COMPLETED) -def host_waiver_state(registration): - """Which waiver evidence exists for this registration, across BOTH locations. - - A visitor's acceptance is stamped on the registration; a host member's lives on their - MakerspaceMembership. Reading only the first is why every host member was told to take - a waiver at the desk. Deliberately NOT compared against the waiver's current version: - a host revising its terms must not retroactively invalidate an acceptance somebody - already gave, the same rule the QR gate follows. - """ - if not MakerspaceWaiver.objects.filter( - makerspace_id=registration.event.makerspace_id, - is_active=True, - ).exists(): - return "not_required" - - if registration.host_waiver_id: - return "on_file" - - if registration.member_id and MakerspaceMembership.objects.filter( - user_id=registration.member_id, - makerspace_id=registration.event.makerspace_id, - ).filter(acceptance_on_file_q()).exists(): - return "on_file" - - return "missing" - - class EventCheckInResolveView(APIView): permission_classes = [IsActiveStaff] throttle_classes = [EventCheckInResolveThrottle] diff --git a/backend/apps/events/views_checkin_offline.py b/backend/apps/events/views_checkin_offline.py new file mode 100644 index 00000000..f2437178 --- /dev/null +++ b/backend/apps/events/views_checkin_offline.py @@ -0,0 +1,82 @@ +from drf_spectacular.utils import OpenApiResponse, extend_schema +from rest_framework.response import Response +from rest_framework.views import APIView + +from apps.admin_api.permissions import IsActiveStaff +from apps.events.models import EventCheckInEvent +from apps.events.serializers_checkin_offline import ( + OfflineCheckInSyncRequestSerializer, + OfflineCheckInSyncResponseSerializer, + OfflineRosterResponseSerializer, +) +from apps.events.services_checkin_roster import issue_roster +from apps.events.services_checkin_sync import synchronize, validated_lease +from apps.events.throttles import EventOfflineRosterThrottle, EventOfflineSyncThrottle +from apps.events.views_admin import _manageable_event +from apps.hardware_requests.exceptions import ErrorSerializer + + +ERRORS = { + 400: OpenApiResponse(ErrorSerializer, description="Feature disabled or invalid batch."), + 401: OpenApiResponse(ErrorSerializer, description="Authentication or lease failed."), + 403: OpenApiResponse(ErrorSerializer, description="Event authority changed."), + 404: OpenApiResponse(ErrorSerializer, description="Event not found."), + 409: OpenApiResponse(ErrorSerializer, description="Roster window closed."), + 410: OpenApiResponse(ErrorSerializer, description="Synchronization deadline passed."), + 413: OpenApiResponse(ErrorSerializer, description="Roster exceeds the offline limit."), + 429: OpenApiResponse(ErrorSerializer, description="Request rate limit exceeded."), +} + + +class EventOfflineRosterView(APIView): + permission_classes = [IsActiveStaff] + throttle_classes = [EventOfflineRosterThrottle] + + @extend_schema( + tags=["Admin events"], + summary="Download a minimal expiring offline check-in roster", + request=None, + responses={200: OfflineRosterResponseSerializer, **ERRORS}, + ) + def get(self, request, pk, *args, **kwargs): + payload = issue_roster( + _manageable_event(request.user, pk), + actor=request.user, + kind="staff", + ) + response = Response(OfflineRosterResponseSerializer(payload).data) + response["Cache-Control"] = "private, no-store" + return response + + +class EventOfflineSyncView(APIView): + permission_classes = [IsActiveStaff] + throttle_classes = [EventOfflineSyncThrottle] + + @extend_schema( + tags=["Admin events"], + summary="Synchronize queued offline event check-ins", + request=OfflineCheckInSyncRequestSerializer, + responses={200: OfflineCheckInSyncResponseSerializer, **ERRORS}, + ) + def post(self, request, pk, *args, **kwargs): + event = _manageable_event(request.user, pk) + serializer = OfflineCheckInSyncRequestSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + lease = validated_lease( + serializer.validated_data["lease_token"], + event, + kind="staff", + actor=request.user, + ) + payload = synchronize( + event, + serializer.validated_data["operations"], + lease=lease, + actor=request.user, + source=EventCheckInEvent.Source.OFFLINE_SYNC, + session_id=lease["lease_id"], + ) + response = Response(OfflineCheckInSyncResponseSerializer(payload).data) + response["Cache-Control"] = "private, no-store" + return response diff --git a/backend/apps/events/views_checkin_station.py b/backend/apps/events/views_checkin_station.py new file mode 100644 index 00000000..2ed68dc1 --- /dev/null +++ b/backend/apps/events/views_checkin_station.py @@ -0,0 +1,226 @@ +from urllib.parse import urlsplit + +from django.conf import settings +from django.core.exceptions import ValidationError as DjangoValidationError +from django.utils import timezone +from drf_spectacular.utils import OpenApiResponse, extend_schema +from rest_framework import status +from rest_framework.exceptions import APIException, PermissionDenied, ValidationError +from rest_framework.permissions import AllowAny +from rest_framework.response import Response +from rest_framework.views import APIView + +from apps.events.models import EventCheckInEvent, EventCheckInStationCredential +from apps.events.serializers_checkin_offline import ( + OfflineCheckInSyncRequestSerializer, + OfflineCheckInSyncResponseSerializer, + OfflineRosterResponseSerializer, +) +from apps.events.serializers_station import StationPinSerializer +from apps.events.services_checkin_roster import issue_roster +from apps.events.services_checkin_sync import synchronize, validated_lease +from apps.events.services_station import ( + STATION_COOKIE_NAME, + STATION_REFUSAL, + resolve_session, + start_session, +) +from apps.events.station_auth import EventStationCookieAuthentication +from apps.tenant_migration.gate_runtime import assert_write_allowed +from apps.events.throttles import ( + EventStationPinIpThrottle, + EventStationPinTokenThrottle, + EventStationSessionThrottle, +) +from apps.hardware_requests.exceptions import ErrorSerializer +from apps.makerspaces.platform import makerspace_public_origins + + +GENERIC_ERROR = OpenApiResponse( + ErrorSerializer, + description="Invalid station credential, session, origin, feature, or time window.", +) + + +def _cookie_path(public_token): + return f"/api/v1/event-checkin-stations/{public_token}/" + + +def _assert_station_csrf(request, event): + if "X-Station-CSRF" not in request.headers: + raise PermissionDenied(STATION_REFUSAL) + raw = request.headers.get("Origin") or request.headers.get("Referer", "") + parts = urlsplit(raw) + if not parts.scheme or not parts.netloc: + raise PermissionDenied(STATION_REFUSAL) + candidate = f"{parts.scheme}://{parts.netloc}" + allowed = ( + makerspace_public_origins(event.makerspace) + | set(settings.CORS_ALLOWED_ORIGINS) + ) + if candidate not in allowed: + raise PermissionDenied(STATION_REFUSAL) + + +def _observed_event(public_token): + credential = EventCheckInStationCredential.objects.select_related( + "event__makerspace" + ).filter(public_token=public_token).first() + return credential.event if credential is not None else None + + +class EventStationSessionView(APIView): + permission_classes = [AllowAny] + authentication_classes = [] + throttle_classes = [EventStationPinTokenThrottle, EventStationPinIpThrottle] + + @extend_schema( + tags=["Event check-in stations"], + summary="Exchange an event-scoped PIN for a station session", + auth=[], + request=StationPinSerializer, + responses={204: None, 400: GENERIC_ERROR, 403: GENERIC_ERROR, 429: GENERIC_ERROR}, + ) + def post(self, request, public_token, *args, **kwargs): + serializer = StationPinSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + observed = _observed_event(public_token) + if observed is None: + raise PermissionDenied(STATION_REFUSAL) + _assert_station_csrf(request, observed) + # A closed source gate must refuse a new station session too: this is anonymous + # but it is TENANT state, and a station that keeps minting sessions after the + # gate closes would write attendance into a migrating makerspace. + assert_write_allowed(observed.makerspace_id) + try: + event, credential, _session_id, cookie, expires_at = start_session( + public_token, + pin=serializer.validated_data["pin"], + ) + except (APIException, DjangoValidationError): + raise PermissionDenied(STATION_REFUSAL) from None + response = Response(status=status.HTTP_204_NO_CONTENT) + response.set_cookie( + STATION_COOKIE_NAME, + cookie, + max_age=max(0, int((expires_at - timezone.now()).total_seconds())), + httponly=True, + secure=settings.AUTH_COOKIE_SECURE, + samesite=settings.AUTH_COOKIE_SAMESITE, + path=_cookie_path(credential.public_token), + ) + response["Cache-Control"] = "private, no-store" + return response + + @extend_schema( + tags=["Event check-in stations"], + summary="Clear the local station session cookie", + auth=[{"EventStationCookie": []}], + request=None, + responses={204: None, 403: GENERIC_ERROR, 429: GENERIC_ERROR}, + ) + def delete(self, request, public_token, *args, **kwargs): + try: + event, _credential, _session_id = resolve_session( + public_token, + request.COOKIES.get(STATION_COOKIE_NAME, ""), + ) + _assert_station_csrf(request, event) + assert_write_allowed(event.makerspace_id) + except (APIException, DjangoValidationError): + raise PermissionDenied(STATION_REFUSAL) from None + response = Response(status=status.HTTP_204_NO_CONTENT) + response.delete_cookie( + STATION_COOKIE_NAME, + path=_cookie_path(public_token), + ) + response["Cache-Control"] = "private, no-store" + return response + + +class _StationCookieView(APIView): + permission_classes = [AllowAny] + authentication_classes = [EventStationCookieAuthentication] + throttle_classes = [EventStationSessionThrottle] + + def station_session(self, request, public_token): + try: + event, credential, session_id = resolve_session( + public_token, + request.COOKIES.get(STATION_COOKIE_NAME, ""), + ) + except (APIException, DjangoValidationError): + raise PermissionDenied(STATION_REFUSAL) from None + _assert_station_csrf(request, event) + return event, credential, session_id + + +class EventStationRosterView(_StationCookieView): + @extend_schema( + tags=["Event check-in stations"], + summary="Download the station's minimal expiring attendee roster", + request=None, + responses={ + 200: OfflineRosterResponseSerializer, + 403: GENERIC_ERROR, + 409: GENERIC_ERROR, + 413: GENERIC_ERROR, + 429: GENERIC_ERROR, + }, + ) + def get(self, request, public_token, *args, **kwargs): + event, credential, session_id = self.station_session(request, public_token) + try: + payload = issue_roster( + event, + actor=None, + kind="station", + session_id=session_id, + station_version=credential.version, + ) + except ValidationError: + raise PermissionDenied(STATION_REFUSAL) from None + response = Response(OfflineRosterResponseSerializer(payload).data) + response["Cache-Control"] = "private, no-store" + return response + + +class EventStationSyncView(_StationCookieView): + @extend_schema( + tags=["Event check-in stations"], + summary="Synchronize queued PIN-station check-ins", + request=OfflineCheckInSyncRequestSerializer, + responses={ + 200: OfflineCheckInSyncResponseSerializer, + 400: GENERIC_ERROR, + 403: GENERIC_ERROR, + 410: GENERIC_ERROR, + 429: GENERIC_ERROR, + }, + ) + def post(self, request, public_token, *args, **kwargs): + event, credential, session_id = self.station_session(request, public_token) + serializer = OfflineCheckInSyncRequestSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + lease = validated_lease( + serializer.validated_data["lease_token"], + event, + kind="station", + session_id=session_id, + station_version=credential.version, + ) + try: + payload = synchronize( + event, + serializer.validated_data["operations"], + lease=lease, + actor=None, + source=EventCheckInEvent.Source.VENUE_STATION, + session_id=session_id, + station_version=credential.version, + ) + except ValidationError: + raise PermissionDenied(STATION_REFUSAL) from None + response = Response(OfflineCheckInSyncResponseSerializer(payload).data) + response["Cache-Control"] = "private, no-store" + return response diff --git a/backend/apps/events/views_checkin_station_admin.py b/backend/apps/events/views_checkin_station_admin.py new file mode 100644 index 00000000..6458fd7e --- /dev/null +++ b/backend/apps/events/views_checkin_station_admin.py @@ -0,0 +1,110 @@ +from drf_spectacular.utils import OpenApiResponse, extend_schema +from rest_framework.response import Response +from rest_framework.views import APIView + +from apps.admin_api.permissions import IsActiveStaff +from apps.events.serializers_admin import EmptyActionSerializer +from apps.events.serializers_station import ( + StationRevealResponseSerializer, + StationRevealSerializer, + StationRotationSerializer, + StationStatusSerializer, +) +from apps.events.services_station import disable, reveal, rotate, station_url, status_payload +from apps.events.throttles import EventStationRevealThrottle +from apps.events.views_admin import _manageable_event +from apps.hardware_requests.exceptions import ErrorSerializer +from apps.makerspaces.guards import require_feature + + +ERRORS = { + 400: OpenApiResponse(ErrorSerializer, description="Feature disabled or invalid request."), + 403: OpenApiResponse(ErrorSerializer, description="Event access or step-up denied."), + 404: OpenApiResponse(ErrorSerializer, description="Event not found."), + 409: OpenApiResponse(ErrorSerializer, description="Rotate the PIN instead of revealing."), + 429: OpenApiResponse(ErrorSerializer, description="Request rate limit exceeded."), + 503: OpenApiResponse(ErrorSerializer, description="Credential secrets are unavailable."), +} + + +def _credential_response(serializer_class, payload): + response = Response(serializer_class(payload).data) + response["Cache-Control"] = "private, no-store" + return response + + +class EventStationStatusView(APIView): + permission_classes = [IsActiveStaff] + + @extend_schema( + tags=["Admin events"], + summary="Read venue-station configuration without revealing its PIN", + request=None, + responses={200: StationStatusSerializer, **ERRORS}, + ) + def get(self, request, pk, *args, **kwargs): + event = _manageable_event(request.user, pk) + require_feature(event.makerspace, "events.offline_checkin") + return _credential_response(StationStatusSerializer, status_payload(event)) + + @extend_schema( + tags=["Admin events"], + summary="Disable a venue check-in station", + request=None, + responses={200: StationStatusSerializer, **ERRORS}, + ) + def delete(self, request, pk, *args, **kwargs): + event = _manageable_event(request.user, pk) + credential = disable(event, actor=request.user) + return _credential_response( + StationStatusSerializer, + status_payload(event, credential), + ) + + +class EventStationRotateView(APIView): + permission_classes = [IsActiveStaff] + + @extend_schema( + tags=["Admin events"], + summary="Create or rotate an event-scoped eight-digit station PIN", + request=EmptyActionSerializer, + responses={200: StationRotationSerializer, **ERRORS}, + ) + def post(self, request, pk, *args, **kwargs): + event = _manageable_event(request.user, pk) + serializer = EmptyActionSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + credential, pin = rotate(event, actor=request.user) + payload = { + "pin": pin, + "public_token": credential.public_token, + "version": credential.version, + "station_url": station_url(event, credential.public_token), + } + return _credential_response(StationRotationSerializer, payload) + + +class EventStationRevealView(APIView): + permission_classes = [IsActiveStaff] + throttle_classes = [EventStationRevealThrottle] + + @extend_schema( + tags=["Admin events"], + summary="Reveal the current station PIN after password step-up", + request=StationRevealSerializer, + responses={200: StationRevealResponseSerializer, **ERRORS}, + ) + def post(self, request, pk, *args, **kwargs): + event = _manageable_event(request.user, pk) + serializer = StationRevealSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + credential, pin = reveal( + event, + actor=request.user, + current_password=serializer.validated_data["current_password"], + ) + return _credential_response( + StationRevealResponseSerializer, + {"pin": pin, "version": credential.version}, + ) diff --git a/backend/apps/events/views_collaborators.py b/backend/apps/events/views_collaborators.py index efef4921..00714a63 100644 --- a/backend/apps/events/views_collaborators.py +++ b/backend/apps/events/views_collaborators.py @@ -10,6 +10,7 @@ from apps.accounts import rbac from apps.admin_api.permissions import IsActiveStaff from apps.events import collaborator_services +from apps.events.exceptions import UseSeriesCollaborators from apps.events.organizer_authority import organizer_event_q from apps.events.models import Event, EventCollaborator from apps.events.serializers_collaborators import ( @@ -108,6 +109,8 @@ def get(self, request, pk, *args, **kwargs): ) def put(self, request, pk, *args, **kwargs): event = _manageable_event(request.user, pk) + if event.series_id: + raise UseSeriesCollaborators() serializer = EventCollaboratorReplaceSerializer(data=request.data) serializer.is_valid(raise_exception=True) collaborators = collaborator_services.invite_collaborators( @@ -147,6 +150,8 @@ def post(self, request, pk, *args, **kwargs): .distinct(), pk=pk, ) + if collaboration.source_series_collaboration_id: + raise UseSeriesCollaborators() _manageable_event(request.user, collaboration.event_id) collaborator_services.remove_collaborator(pk, actor=request.user) return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/backend/apps/events/views_feedback_admin.py b/backend/apps/events/views_feedback_admin.py new file mode 100644 index 00000000..53fafbb2 --- /dev/null +++ b/backend/apps/events/views_feedback_admin.py @@ -0,0 +1,176 @@ +from datetime import timedelta + +from django.conf import settings +from django.db.models import Q +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 +from rest_framework.pagination import PageNumberPagination +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.events import services +from apps.events.certificate_storage import CertificateStorageUnavailable +from apps.events.models import EventAttendanceCertificate, EventFeedbackSurvey +from apps.events.organizer_authority import can_manage_event, organizer_event_q +from apps.events.serializers_admin import EmptyActionSerializer +from apps.events.serializers_feedback import ( + AttendanceCorrectionResponseSerializer, + CertificateDownloadSerializer, + CertificateRevokeSerializer, + CertificateSummarySerializer, + FeedbackResponseListSerializer, + FeedbackResponseSerializer, + FeedbackSurveyAdminEnvelopeSerializer, + FeedbackSurveySerializer, + FeedbackSurveyWriteSerializer, +) +from apps.events.services_certificates import download_url, reissue, revoke +from apps.events.services_feedback import close_survey, configure_survey, open_survey +from apps.events.views_admin_events import _manageable_event, _manageable_registration +from apps.hardware_requests.exceptions import ErrorSerializer +from apps.makerspaces.guards import require_module + + +ERRORS = { + 400: OpenApiResponse(ErrorSerializer, description="Invalid request."), + 403: OpenApiResponse(ErrorSerializer, description="Event management is required."), + 404: OpenApiResponse(ErrorSerializer, description="Event resource not found."), + 409: OpenApiResponse(ErrorSerializer, description="Event state conflict."), +} + + +def _manageable_certificate(actor, pk): + scoped = rbac.scope_by_visibility_or_action( + actor, + rbac.Action.MANAGE_EVENTS, + EventAttendanceCertificate.objects.all(), + field="registration__event__makerspace_id", + ) + certificate = get_object_or_404( + EventAttendanceCertificate.objects.select_related( + "registration__event__makerspace", "response", + ).filter( + Q(pk__in=scoped.values("pk")) + | organizer_event_q(actor, event_prefix="registration__event__") + ).distinct(), + pk=pk, + ) + require_module(certificate.registration.event.makerspace, "events") + if not can_manage_event(actor, certificate.registration.event): + raise PermissionDenied() + return certificate + + +class EventFeedbackSurveyView(APIView): + permission_classes = [IsActiveStaff] + + @extend_schema(tags=["Admin events"], responses={200: FeedbackSurveyAdminEnvelopeSerializer, **ERRORS}) + def get(self, request, pk): + event = _manageable_event(request.user, pk) + survey = getattr(event, "feedback_survey", None) + if survey is not None: + survey.response_count = survey.responses.count() + return Response({"survey": None if survey is None else FeedbackSurveySerializer(survey).data}) + + @extend_schema(tags=["Admin events"], request=FeedbackSurveyWriteSerializer, responses={200: FeedbackSurveySerializer, **ERRORS}) + def put(self, request, pk): + event = _manageable_event(request.user, pk) + serializer = FeedbackSurveyWriteSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + survey = configure_survey(event, actor=request.user, **serializer.validated_data) + survey.response_count = survey.responses.count() + return Response(FeedbackSurveySerializer(survey).data) + + +class _SurveyActionView(APIView): + permission_classes = [IsActiveStaff] + operation = None + + def execute(self, request, pk): + event = _manageable_event(request.user, pk) + EmptyActionSerializer(data=request.data).is_valid(raise_exception=True) + survey = self.operation(event, actor=request.user) + survey.response_count = survey.responses.count() + return Response(FeedbackSurveySerializer(survey).data) + + +class EventFeedbackSurveyOpenView(_SurveyActionView): + operation = staticmethod(open_survey) + + @extend_schema(tags=["Admin events"], request=EmptyActionSerializer, responses={200: FeedbackSurveySerializer, **ERRORS}) + def post(self, request, pk): + return self.execute(request, pk) + + +class EventFeedbackSurveyCloseView(_SurveyActionView): + operation = staticmethod(close_survey) + + @extend_schema(tags=["Admin events"], request=EmptyActionSerializer, responses={200: FeedbackSurveySerializer, **ERRORS}) + def post(self, request, pk): + return self.execute(request, pk) + + +class EventFeedbackResponseListView(APIView): + permission_classes = [IsActiveStaff] + + @extend_schema(tags=["Admin events"], responses={200: FeedbackResponseListSerializer, **ERRORS}) + def get(self, request, pk): + event = _manageable_event(request.user, pk) + survey = get_object_or_404(EventFeedbackSurvey, event=event) + queryset = survey.responses.select_related("registration").prefetch_related("certificates").order_by("created_at", "id") + paginator = PageNumberPagination() + page = paginator.paginate_queryset(queryset, request, view=self) + return paginator.get_paginated_response(FeedbackResponseSerializer(page, many=True).data) + + +class EventRegistrationCorrectAttendanceView(APIView): + permission_classes = [IsActiveStaff] + + @extend_schema(tags=["Admin events"], request=EmptyActionSerializer, responses={200: AttendanceCorrectionResponseSerializer, **ERRORS}) + def post(self, request, pk): + registration = _manageable_registration(request.user, pk) + EmptyActionSerializer(data=request.data).is_valid(raise_exception=True) + corrected, revoked = services.correct_attendance(registration, actor=request.user) + return Response({"registration_id": corrected.pk, "status": corrected.status, "revoked_certificates": len(revoked)}) + + +class EventCertificateDownloadView(APIView): + permission_classes = [IsActiveStaff] + + @extend_schema(tags=["Admin events"], responses={200: CertificateDownloadSerializer, 410: OpenApiResponse(ErrorSerializer, description="Certificate revoked."), 503: OpenApiResponse(ErrorSerializer, description="Certificate storage unavailable."), **ERRORS}) + def get(self, request, pk): + certificate = _manageable_certificate(request.user, pk) + if certificate.status == EventAttendanceCertificate.Status.REVOKED: + return Response({"detail": "Certificate revoked.", "code": "certificate_revoked"}, status=status.HTTP_410_GONE) + try: + certificate, url = download_url(certificate) + except CertificateStorageUnavailable: + return Response({"detail": "Certificate storage is unavailable.", "code": "storage_unavailable"}, status=status.HTTP_503_SERVICE_UNAVAILABLE) + return Response({"url": url, "expires_at": timezone.now() + timedelta(seconds=settings.EVIDENCE_URL_TTL_SECONDS)}) + + +class EventCertificateRevokeView(APIView): + permission_classes = [IsActiveStaff] + + @extend_schema(tags=["Admin events"], request=CertificateRevokeSerializer, responses={200: CertificateSummarySerializer, **ERRORS}) + def post(self, request, pk): + certificate = _manageable_certificate(request.user, pk) + serializer = CertificateRevokeSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + return Response(CertificateSummarySerializer(revoke(certificate, actor=request.user, **serializer.validated_data)).data) + + +class EventCertificateReissueView(APIView): + permission_classes = [IsActiveStaff] + + @extend_schema(tags=["Admin events"], request=EmptyActionSerializer, responses={201: CertificateSummarySerializer, **ERRORS}) + def post(self, request, pk): + certificate = _manageable_certificate(request.user, pk) + EmptyActionSerializer(data=request.data).is_valid(raise_exception=True) + created = reissue(certificate.registration, actor=request.user) + return Response(CertificateSummarySerializer(created).data, status=status.HTTP_201_CREATED) diff --git a/backend/apps/events/views_feedback_member.py b/backend/apps/events/views_feedback_member.py new file mode 100644 index 00000000..94ae6cb0 --- /dev/null +++ b/backend/apps/events/views_feedback_member.py @@ -0,0 +1,133 @@ +from datetime import timedelta + +from django.conf import settings +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 NotFound, ValidationError +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from apps.apiclients.throttling import MemberPrincipalRateThrottle +from apps.events.certificate_storage import CertificateStorageUnavailable +from apps.events.member_history import registrations_for_space +from apps.events.models import EventAttendanceCertificate, EventFeedbackSurvey +from apps.events.serializers_feedback import ( + CertificateDownloadSerializer, + FeedbackFormSerializer, + FeedbackSubmissionResponseSerializer, + FeedbackSubmissionSerializer, +) +from apps.events.services_certificates import download_url +from apps.events.services_feedback import ( + submit_anonymous_feedback, + submit_identified_feedback, +) +from apps.events.views_feedback_public import feedback_form_payload +from apps.hardware_requests.exceptions import ErrorSerializer +from apps.makerspaces.guards import require_module +from apps.makerspaces.member_activity_service import active_membership +from apps.presence.guard import MemberPresenceRequired, require_active_member + + +ERRORS = { + 400: OpenApiResponse(ErrorSerializer, description="Invalid feedback answers."), + 403: OpenApiResponse(ErrorSerializer, description="Active membership is required."), + 404: OpenApiResponse(ErrorSerializer, description="Feedback resource not found."), + 409: OpenApiResponse(ErrorSerializer, description="Feedback or certificate state conflict."), + 429: OpenApiResponse(ErrorSerializer, description="Rate limit exceeded."), +} + + +def _owned_registration(request, makerspace_id, pk): + membership = active_membership(request.user, makerspace_id) + if membership is None: + raise MemberPresenceRequired() + require_module(membership.makerspace, "events") + require_active_member(request.user, membership.makerspace) + registration = registrations_for_space(membership.makerspace, request.user).select_related( + "event__makerspace", "registered_via_makerspace", + ).filter(pk=pk).first() + if registration is None: + raise NotFound() + return registration + + +def _survey_for_registration(registration, *, allow_existing=False): + survey = EventFeedbackSurvey.objects.filter(event=registration.event).first() + if survey is None or timezone.now() < registration.event.ends_at: + raise NotFound() + existing = registration.feedback_responses.order_by("-created_at", "-id").first() + if not survey.is_open and not (allow_existing and existing is not None): + raise NotFound() + certificate = None if existing is None else existing.certificates.order_by("-revision").first() + return survey, certificate + + +class MemberEventFeedbackView(APIView): + permission_classes = [IsAuthenticated] + throttle_classes = [MemberPrincipalRateThrottle] + throttle_scope = "event_register" + + @extend_schema(tags=["Member events"], responses={200: FeedbackFormSerializer, **ERRORS}) + def get(self, request, makerspace_id, pk): + registration = _owned_registration(request, makerspace_id, pk) + survey, certificate = _survey_for_registration(registration, allow_existing=True) + payload = feedback_form_payload(registration.event, survey, certificate=certificate) + return Response(FeedbackFormSerializer(payload).data) + + @extend_schema(tags=["Member events"], request=FeedbackSubmissionSerializer, responses={201: FeedbackSubmissionResponseSerializer, **ERRORS}) + def post(self, request, makerspace_id, pk): + registration = _owned_registration(request, makerspace_id, pk) + survey, _certificate = _survey_for_registration(registration) + serializer = FeedbackSubmissionSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + data = serializer.validated_data + if survey.certificate_enabled: + if "email" not in data: + raise ValidationError({"email": "This field is required."}) + _response, certificate = submit_identified_feedback( + registration.event, + actor=request.user, + email=data["email"], + answers=data["answers"], + registration=registration, + ) + else: + if "email" in data: + raise ValidationError({"email": "Email is not accepted for anonymous feedback."}) + _response, certificate = submit_anonymous_feedback( + registration.event, data["answers"], + ) + payload = {"thank_you_text": survey.thank_you_text, "certificate": certificate} + return Response(FeedbackSubmissionResponseSerializer(payload).data, status=201) + + +class MemberEventCertificateDownloadView(APIView): + permission_classes = [IsAuthenticated] + + @extend_schema(tags=["Member events"], responses={200: CertificateDownloadSerializer, 410: OpenApiResponse(ErrorSerializer, description="Certificate revoked."), 503: OpenApiResponse(ErrorSerializer, description="Certificate storage unavailable."), **ERRORS}) + def get(self, request, makerspace_id, pk): + membership = active_membership(request.user, makerspace_id) + if membership is None: + raise MemberPresenceRequired() + require_module(membership.makerspace, "events") + certificate = get_object_or_404( + EventAttendanceCertificate.objects.select_related( + "registration__event__makerspace", + ).filter( + pk=pk, + registration__in=registrations_for_space( + membership.makerspace, request.user, + ), + ) + ) + if certificate.status == EventAttendanceCertificate.Status.REVOKED: + return Response({"detail": "Certificate revoked.", "code": "certificate_revoked"}, status=status.HTTP_410_GONE) + try: + certificate, url = download_url(certificate) + except CertificateStorageUnavailable: + return Response({"detail": "Certificate storage is unavailable.", "code": "storage_unavailable"}, status=status.HTTP_503_SERVICE_UNAVAILABLE) + return Response({"url": url, "expires_at": timezone.now() + timedelta(seconds=settings.EVIDENCE_URL_TTL_SECONDS)}) diff --git a/backend/apps/events/views_feedback_public.py b/backend/apps/events/views_feedback_public.py new file mode 100644 index 00000000..089053a6 --- /dev/null +++ b/backend/apps/events/views_feedback_public.py @@ -0,0 +1,100 @@ +from django.shortcuts import get_object_or_404 +from django.utils import timezone +from drf_spectacular.utils import OpenApiResponse, extend_schema +from rest_framework.exceptions import NotAuthenticated, NotFound, ValidationError +from rest_framework.permissions import AllowAny +from rest_framework.response import Response +from rest_framework.views import APIView + +from apps.events.throttles import PublicFeedbackRateThrottle +from apps.events.models import Event, EventFeedbackSurvey +from apps.events.serializers_feedback import ( + FeedbackFormSerializer, + FeedbackSubmissionResponseSerializer, + FeedbackSubmissionSerializer, +) +from apps.events.services_feedback import ( + submit_anonymous_feedback, + submit_identified_feedback, +) +from apps.hardware_requests.exceptions import ErrorSerializer +from apps.makerspaces.guards import require_module_for_servable +from apps.makerspaces.lookup import get_public_makerspace + + +ERRORS = { + 400: OpenApiResponse(ErrorSerializer, description="Invalid feedback answers."), + 401: OpenApiResponse(ErrorSerializer, description="Authentication is required for a certificate."), + 404: OpenApiResponse(ErrorSerializer, description="Feedback form not found."), + 409: OpenApiResponse(ErrorSerializer, description="Feedback retry conflict."), + 429: OpenApiResponse(ErrorSerializer, description="Rate limit exceeded."), +} + + +def _public_feedback_event(makerspace, token): + event = get_object_or_404( + Event.objects.select_related("makerspace").filter( + makerspace=makerspace, + public_token=token, + is_public=True, + status__in=(Event.Status.PUBLISHED, Event.Status.COMPLETED), + ends_at__lte=timezone.now(), + ) + ) + survey = EventFeedbackSurvey.objects.filter(event=event, is_open=True).first() + if survey is None: + raise NotFound() + return event, survey + + +def feedback_form_payload(event, survey, *, certificate=None): + return { + "event": { + "public_token": str(event.public_token), + "title": event.title, + "starts_at": event.starts_at, + "ends_at": event.ends_at, + }, + "survey": survey, + "mode": "certificate" if survey.certificate_enabled else "anonymous", + "requires_auth": survey.certificate_enabled, + "certificate": certificate, + } + + +class PublicEventFeedbackView(APIView): + permission_classes = [AllowAny] + throttle_classes = [PublicFeedbackRateThrottle] + + @extend_schema(tags=["Public events"], auth=[], responses={200: FeedbackFormSerializer, **ERRORS}) + def get(self, request, makerspace_slug, public_token): + makerspace = get_public_makerspace(makerspace_slug) + require_module_for_servable(makerspace, "events") + event, survey = _public_feedback_event(makerspace, public_token) + return Response(FeedbackFormSerializer(feedback_form_payload(event, survey)).data) + + @extend_schema(tags=["Public events"], auth=[{"jwtAuth": []}, {}], request=FeedbackSubmissionSerializer, responses={201: FeedbackSubmissionResponseSerializer, **ERRORS}) + def post(self, request, makerspace_slug, public_token): + makerspace = get_public_makerspace(makerspace_slug) + require_module_for_servable(makerspace, "events") + event, survey = _public_feedback_event(makerspace, public_token) + serializer = FeedbackSubmissionSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + data = serializer.validated_data + if survey.certificate_enabled: + if not request.user.is_authenticated: + raise NotAuthenticated() + if "email" not in data: + raise ValidationError({"email": "This field is required."}) + _response, certificate = submit_identified_feedback( + event, + actor=request.user, + email=data["email"], + answers=data["answers"], + ) + else: + if "email" in data: + raise ValidationError({"email": "Email is not accepted for anonymous feedback."}) + _response, certificate = submit_anonymous_feedback(event, data["answers"]) + payload = {"thank_you_text": survey.thank_you_text, "certificate": certificate} + return Response(FeedbackSubmissionResponseSerializer(payload).data, status=201) diff --git a/backend/apps/events/views_member_events.py b/backend/apps/events/views_member_events.py index 38b6a739..75a5c98a 100644 --- a/backend/apps/events/views_member_events.py +++ b/backend/apps/events/views_member_events.py @@ -89,7 +89,7 @@ def get(self, request, makerspace_id, *args, **kwargs): membership = _active_membership(request, makerspace_id) events = ( _collaborative_events(membership.makerspace) - .select_related("makerspace") + .select_related("makerspace", "series") .prefetch_related("organizers__organization") .annotate( confirmed_count=Count( diff --git a/backend/apps/events/views_public.py b/backend/apps/events/views_public.py index 5f3173ae..b161f1eb 100644 --- a/backend/apps/events/views_public.py +++ b/backend/apps/events/views_public.py @@ -63,6 +63,7 @@ def get(self, request, makerspace_slug): require_module_for_servable(makerspace, 'events') events = ( _public_events(makerspace) + .select_related('series') .prefetch_related( Prefetch( 'organizers', diff --git a/backend/apps/events/views_series.py b/backend/apps/events/views_series.py new file mode 100644 index 00000000..21324b27 --- /dev/null +++ b/backend/apps/events/views_series.py @@ -0,0 +1,204 @@ +from django.db.models import Count, Min, Q +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 +from rest_framework.pagination import PageNumberPagination +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.events.models import Event, EventSeries +from apps.events.serializers_admin import EmptyActionSerializer, EventAdminSerializer, EventListResponseSerializer +from apps.events.serializers_series import ( + EventSeriesDetailSerializer, + EventSeriesListResponseSerializer, + EventSeriesMutationResponseSerializer, + EventSeriesSummarySerializer, + EventSeriesWriteSerializer, +) +from apps.events.series_authority import can_manage_series, organizer_series_q +from apps.events import services_series +from apps.hardware_requests.exceptions import ErrorSerializer +from apps.makerspaces.guards import require_module +from apps.makerspaces.models import Makerspace + + +SERIES_ERRORS = { + 400: OpenApiResponse(ErrorSerializer, description="Invalid recurring event series."), + 401: OpenApiResponse(ErrorSerializer, description="Authentication required."), + 403: OpenApiResponse(ErrorSerializer, description="Event management access denied."), + 404: OpenApiResponse(ErrorSerializer, description="Event series not found."), + 409: OpenApiResponse(ErrorSerializer, description="Series state conflict."), + 429: OpenApiResponse(ErrorSerializer, description="Rate limit exceeded."), +} + + +class _Pagination(PageNumberPagination): + page_size = 50 + page_size_query_param = "page_size" + max_page_size = 200 + + +def _visible_makerspace(actor, makerspace_id): + space = get_object_or_404( + rbac.scope_by_visibility_or_action( + actor, rbac.Action.MANAGE_EVENTS, Makerspace.objects.all(), field="id" + ), + pk=makerspace_id, + ) + require_module(space, "events") + if not rbac.can(actor, rbac.Action.MANAGE_EVENTS, space.pk): + raise PermissionDenied() + return space + + +def manageable_series(actor, pk): + venue = rbac.scope_by_visibility_or_action( + actor, rbac.Action.MANAGE_EVENTS, EventSeries.objects.all(), field="makerspace_id" + ) + series = get_object_or_404( + EventSeries.objects.select_related("makerspace").filter( + Q(pk__in=venue.values("pk")) | organizer_series_q(actor) + ).distinct(), + pk=pk, + ) + require_module(series.makerspace, "events") + if not can_manage_series(actor, series): + raise PermissionDenied() + return series + + +def _mutation(series, *, created=(), removed=(), affected=0): + return Response({ + "series": EventSeriesDetailSerializer(series).data, + "created_occurrence_ids": [row.pk for row in created], + "removed_occurrence_ids": list(removed), + "affected_count": affected, + }) + + +class EventSeriesListCreateView(APIView): + permission_classes = [IsActiveStaff] + + @extend_schema( + tags=["Admin event series"], summary="List recurring event series", request=None, + responses={200: EventSeriesListResponseSerializer, **SERIES_ERRORS}, + ) + def get(self, request, makerspace_id): + space = _visible_makerspace(request.user, makerspace_id) + queryset = rbac.scope_by_action( + request.user, rbac.Action.MANAGE_EVENTS, + EventSeries.objects.filter(makerspace=space), field="makerspace_id", + ).annotate( + next_occurrence_at=Min( + "occurrences__starts_at", + filter=Q(occurrences__status__in=(Event.Status.DRAFT, Event.Status.PUBLISHED)), + ), + future_occurrence_count=Count( + "occurrences", + filter=Q(occurrences__status__in=(Event.Status.DRAFT, Event.Status.PUBLISHED)), + distinct=True, + ), + ).order_by("dtstart_local_date", "dtstart_local_time", "pk") + paginator = _Pagination() + page = paginator.paginate_queryset(queryset, request, view=self) + return paginator.get_paginated_response(EventSeriesSummarySerializer(page, many=True).data) + + @extend_schema( + tags=["Admin event series"], summary="Create a recurring event series", + request=EventSeriesWriteSerializer, + responses={201: EventSeriesMutationResponseSerializer, **SERIES_ERRORS}, + ) + def post(self, request, makerspace_id): + space = _visible_makerspace(request.user, makerspace_id) + serializer = EventSeriesWriteSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + values = dict(serializer.validated_data) + values.pop("effective_from", None) + series, created = services_series.create_series( + makerspace=space, actor=request.user, **values + ) + response = _mutation(series, created=created, affected=len(created)) + response.status_code = status.HTTP_201_CREATED + return response + + +class EventSeriesDetailView(APIView): + permission_classes = [IsActiveStaff] + + @extend_schema(tags=["Admin event series"], request=None, responses={200: EventSeriesDetailSerializer, **SERIES_ERRORS}) + def get(self, request, pk): + return Response(EventSeriesDetailSerializer(manageable_series(request.user, pk)).data) + + @extend_schema(tags=["Admin event series"], request=EventSeriesWriteSerializer, responses={200: EventSeriesMutationResponseSerializer, **SERIES_ERRORS}) + def patch(self, request, pk): + series = manageable_series(request.user, pk) + serializer = EventSeriesWriteSerializer(series, data=request.data, partial=True) + serializer.is_valid(raise_exception=True) + changes = dict(serializer.validated_data) + effective_from = changes.pop("effective_from", None) + series, created, removed = services_series.update_series( + series, actor=request.user, effective_from=effective_from, **changes + ) + return _mutation(series, created=created, removed=removed, affected=len(created) + len(removed)) + + +class EventSeriesOccurrenceListView(APIView): + permission_classes = [IsActiveStaff] + + @extend_schema(tags=["Admin event series"], request=None, responses={200: EventListResponseSerializer, **SERIES_ERRORS}) + def get(self, request, pk): + series = manageable_series(request.user, pk) + queryset = Event.objects.filter(series=series).select_related("series").order_by("starts_at", "pk") + paginator = _Pagination() + page = paginator.paginate_queryset(queryset, request, view=self) + return paginator.get_paginated_response(EventAdminSerializer(page, many=True).data) + + +class _Action(APIView): + permission_classes = [IsActiveStaff] + operation = None + + def execute(self, request, pk): + EmptyActionSerializer(data=request.data).is_valid(raise_exception=True) + result = self.operation(manageable_series(request.user, pk), actor=request.user) + if isinstance(result, tuple): + series, value = result + created = value if isinstance(value, list) else () + return _mutation(series, created=created, affected=len(value) if isinstance(value, list) else value) + return _mutation(result) + + +class EventSeriesPublishView(_Action): + operation = staticmethod(services_series.publish_series) + + @extend_schema(tags=["Admin event series"], request=EmptyActionSerializer, responses={200: EventSeriesMutationResponseSerializer, **SERIES_ERRORS}) + def post(self, request, pk): + return self.execute(request, pk) + + +class EventSeriesCancelView(_Action): + operation = staticmethod(services_series.cancel_series) + + @extend_schema(tags=["Admin event series"], request=EmptyActionSerializer, responses={200: EventSeriesMutationResponseSerializer, **SERIES_ERRORS}) + def post(self, request, pk): + return self.execute(request, pk) + + +class EventSeriesCompleteView(_Action): + operation = staticmethod(services_series.complete_series) + + @extend_schema(tags=["Admin event series"], request=EmptyActionSerializer, responses={200: EventSeriesMutationResponseSerializer, **SERIES_ERRORS}) + def post(self, request, pk): + return self.execute(request, pk) + + +class EventSeriesExtendView(_Action): + operation = staticmethod(services_series.extend_series) + + @extend_schema(tags=["Admin event series"], request=EmptyActionSerializer, responses={200: EventSeriesMutationResponseSerializer, **SERIES_ERRORS}) + def post(self, request, pk): + return self.execute(request, pk) diff --git a/backend/apps/events/views_series_collaboration.py b/backend/apps/events/views_series_collaboration.py new file mode 100644 index 00000000..ceaa5e82 --- /dev/null +++ b/backend/apps/events/views_series_collaboration.py @@ -0,0 +1,134 @@ +from django.db.models import Q +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 PermissionDenied +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.events.models import EventSeriesCollaborator +from apps.events.serializers_series_collaboration import ( + SeriesCollaborationInboxSerializer, + SeriesCollaborationRespondSerializer, + SeriesCollaboratorReplaceSerializer, + SeriesCollaboratorSerializer, +) +from apps.events import services_series_collaboration +from apps.events.series_authority import organizer_series_q +from apps.events.views_series import manageable_series +from apps.hardware_requests.exceptions import ErrorSerializer +from apps.makerspaces.guards import require_module +from apps.makerspaces.models import Makerspace +from apps.makerspaces.servability import servable_queryset + + +ERRORS = { + 400: OpenApiResponse(ErrorSerializer, description="Invalid collaboration request."), + 401: OpenApiResponse(ErrorSerializer, description="Authentication required."), + 403: OpenApiResponse(ErrorSerializer, description="Event management access denied."), + 404: OpenApiResponse(ErrorSerializer, description="Series collaboration not found."), + 409: OpenApiResponse(ErrorSerializer, description="Collaboration state conflict."), + 429: OpenApiResponse(ErrorSerializer, description="Rate limit exceeded."), +} + + +def _collaborator_space(actor, makerspace_id): + space = get_object_or_404( + rbac.scope_by_visibility_or_action( + actor, rbac.Action.MANAGE_EVENTS, Makerspace.objects.all(), field="id" + ), pk=makerspace_id, + ) + require_module(space, "events") + if not rbac.can(actor, rbac.Action.MANAGE_EVENTS, space.pk): + raise PermissionDenied() + return space + + +def _manageable_invitation(actor, pk): + row = get_object_or_404( + rbac.scope_by_visibility_or_action( + actor, rbac.Action.MANAGE_EVENTS, + servable_queryset(EventSeriesCollaborator.objects.filter( + series__makerspace__enabled_modules__contains=["events"] + ), relation="series__makerspace").select_related("makerspace", "series__makerspace"), + field="makerspace_id", + ), pk=pk, + ) + require_module(row.makerspace, "events") + if not rbac.can(actor, rbac.Action.MANAGE_EVENTS, row.makerspace_id): + raise PermissionDenied() + return row + + +class EventSeriesCollaboratorListView(APIView): + permission_classes = [IsActiveStaff] + + @extend_schema(tags=["Admin event series"], request=None, responses={200: SeriesCollaboratorSerializer(many=True), **ERRORS}) + def get(self, request, pk): + series = manageable_series(request.user, pk) + rows = servable_queryset( + series.collaborators.select_related("makerspace"), relation="makerspace" + ).order_by("makerspace__slug", "pk") + return Response(SeriesCollaboratorSerializer(rows, many=True).data) + + @extend_schema(tags=["Admin event series"], request=SeriesCollaboratorReplaceSerializer, responses={200: SeriesCollaboratorSerializer(many=True), **ERRORS}) + def put(self, request, pk): + series = manageable_series(request.user, pk) + serializer = SeriesCollaboratorReplaceSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + rows = services_series_collaboration.invite_collaborators( + series, actor=request.user, slugs=serializer.validated_data["slugs"] + ) + return Response(SeriesCollaboratorSerializer(rows, many=True).data) + + +class EventSeriesCollaborationRemoveView(APIView): + permission_classes = [IsActiveStaff] + + @extend_schema(tags=["Admin event series"], request=None, responses={204: None, **ERRORS}) + def post(self, request, pk): + venue = rbac.scope_by_visibility_or_action( + request.user, rbac.Action.MANAGE_EVENTS, + EventSeriesCollaborator.objects.only("id", "series_id"), + field="series__makerspace_id", + ) + row = get_object_or_404( + EventSeriesCollaborator.objects.only("id", "series_id").filter( + Q(pk__in=venue.values("pk")) | organizer_series_q(request.user, prefix="series__") + ).distinct(), pk=pk, + ) + manageable_series(request.user, row.series_id) + services_series_collaboration.remove_collaborator(pk, actor=request.user) + return Response(status=status.HTTP_204_NO_CONTENT) + + +class EventSeriesCollaborationInboxView(APIView): + permission_classes = [IsActiveStaff] + + @extend_schema(tags=["Admin event series"], request=None, responses={200: SeriesCollaborationInboxSerializer(many=True), **ERRORS}) + def get(self, request, makerspace_id): + space = _collaborator_space(request.user, makerspace_id) + rows = rbac.scope_by_action( + request.user, rbac.Action.MANAGE_EVENTS, + servable_queryset(EventSeriesCollaborator.objects.filter( + makerspace=space, series__makerspace__enabled_modules__contains=["events"] + ), relation="series__makerspace").select_related("series__makerspace"), + field="makerspace_id", + ).order_by("-created_at", "-pk") + return Response(SeriesCollaborationInboxSerializer(rows, many=True).data) + + +class EventSeriesCollaborationRespondView(APIView): + permission_classes = [IsActiveStaff] + + @extend_schema(tags=["Admin event series"], request=SeriesCollaborationRespondSerializer, responses={200: SeriesCollaboratorSerializer, **ERRORS}) + def post(self, request, pk): + row = _manageable_invitation(request.user, pk) + serializer = SeriesCollaborationRespondSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + row = services_series_collaboration.respond( + row, actor=request.user, accept=serializer.validated_data["accept"] + ) + return Response(SeriesCollaboratorSerializer(row).data) diff --git a/backend/apps/events/views_series_image.py b/backend/apps/events/views_series_image.py new file mode 100644 index 00000000..24912fd1 --- /dev/null +++ b/backend/apps/events/views_series_image.py @@ -0,0 +1,100 @@ +from django.db import transaction +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.admin_api.permissions import IsActiveStaff +from apps.admin_api.serializers_inventory import ( + PublicImageAttachRequestSerializer, + PublicImageUploadRequestSerializer, + PublicImageUploadResponseSerializer, +) +from apps.events import services_series_images +from apps.events.serializers_series import EventSeriesDetailSerializer +from apps.events.views_series import manageable_series +from apps.evidence.responses import storage_unavailable_response +from apps.evidence.storage import StorageUnavailable +from apps.inventory import public_image_storage + + +PREFIX = "event-series" +ERRORS = { + 400: OpenApiResponse(description="Invalid image upload request."), + 401: OpenApiResponse(description="Authentication required."), + 403: OpenApiResponse(description="Event management access is required."), + 404: OpenApiResponse(description="Event series not found."), + 429: OpenApiResponse(description="Rate limit exceeded."), + 503: OpenApiResponse(description="Public image storage is unavailable."), +} + + +class EventSeriesImageView(APIView): + permission_classes = [IsActiveStaff] + + @extend_schema( + tags=["Admin event series"], summary="Create a series image upload URL", + request=PublicImageUploadRequestSerializer, + responses={201: PublicImageUploadResponseSerializer, **ERRORS}, + ) + def post(self, request, pk): + series = manageable_series(request.user, pk) + serializer = PublicImageUploadRequestSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + content_type = serializer.validated_data["content_type"] + ext = public_image_storage.ext_for(content_type, serializer.validated_data["filename"]) + object_key = public_image_storage.build_object_key(PREFIX, series.makerspace_id, ext) + try: + upload = public_image_storage.presigned_upload(object_key, content_type) + except StorageUnavailable: + return storage_unavailable_response() + return Response( + PublicImageUploadResponseSerializer({"object_key": object_key, **upload}).data, + status=status.HTTP_201_CREATED, + ) + + @extend_schema( + tags=["Admin event series"], summary="Attach an uploaded series image", + request=PublicImageAttachRequestSerializer, + responses={200: EventSeriesDetailSerializer, **ERRORS}, + ) + @transaction.atomic + def put(self, request, pk): + series = manageable_series(request.user, pk) + serializer = PublicImageAttachRequestSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + object_key = serializer.validated_data["object_key"] + if not object_key.startswith(f"{PREFIX}/{series.makerspace_id}/"): + raise ValidationError({"object_key": "Image object key is outside this makerspace."}) + if not public_image_storage.is_safe_object_key(object_key): + raise ValidationError({"object_key": "Invalid image object key."}) + if public_image_storage.public_image_key_in_use( + series.makerspace_id, object_key, series_id=series.pk + ): + raise ValidationError({"object_key": "This image is already in use."}) + try: + result = public_image_storage.finalize_upload(object_key) + valid = result.status == "ok" and public_image_storage.sniff_is_valid_image(object_key) + except StorageUnavailable: + return storage_unavailable_response() + if not valid: + public_image_storage.delete_object(object_key) + public_image_storage.delete_object(public_image_storage.staging_key(object_key)) + message = ( + "Uploaded file is not a valid image." + if result.status == "ok" + else public_image_storage.finalize_error_message(result) + ) + raise ValidationError({"object_key": message}) + series = services_series_images.update_image(series, request.user, object_key) + return Response(EventSeriesDetailSerializer(series).data) + + @extend_schema( + tags=["Admin event series"], summary="Clear a series image", + request=None, responses={200: EventSeriesDetailSerializer, **ERRORS}, + ) + def delete(self, request, pk): + series = manageable_series(request.user, pk) + series = services_series_images.remove_image(series, request.user) + return Response(EventSeriesDetailSerializer(series).data) diff --git a/backend/apps/evidence/admin.py b/backend/apps/evidence/admin.py index 3d79a928..cd7e3eae 100644 --- a/backend/apps/evidence/admin.py +++ b/backend/apps/evidence/admin.py @@ -2,7 +2,7 @@ from django.utils.html import format_html from unfold.admin import ModelAdmin -from apps.evidence.models import EvidencePhoto +from apps.evidence.models import EvidenceObjectRetentionState, EvidencePhoto from apps.evidence.storage import object_exists, presigned_get_url, staging_key from config.admin_access import SuperuserOnlyModelAdmin @@ -35,9 +35,21 @@ class EvidencePhotoAdmin(SuperuserOnlyModelAdmin, ModelAdmin): "created_at", ) + def get_queryset(self, request): + return super().get_queryset(request).select_related("object_retention_state") + + @staticmethod + def _expired_label(obj): + state = getattr(obj, "object_retention_state", None) + if state is None or state.status != EvidenceObjectRetentionState.Status.EXPIRED: + return None + return f"Expired at {state.object_expired_at:%Y-%m-%d %H:%M:%S %Z}" + def photo_preview(self, obj): if not obj or not getattr(obj, "object_key", ""): return "(no image)" + if expired := self._expired_label(obj): + return expired try: # Same staging fallback as the API read path: evidence that has been # uploaded but not yet promoted by a workflow still lives in staging. @@ -65,6 +77,8 @@ def photo_preview(self, obj): def thumb(self, obj): if not obj or not getattr(obj, "object_key", ""): return "—" + if self._expired_label(obj): + return "Expired" try: url = presigned_get_url(obj.object_key) except Exception: diff --git a/backend/apps/evidence/checks.py b/backend/apps/evidence/checks.py index 5835c780..39080b2b 100644 --- a/backend/apps/evidence/checks.py +++ b/backend/apps/evidence/checks.py @@ -5,6 +5,8 @@ from config.storage_validation import BUCKET_COLLISION_MESSAGE, bucket_names_collide +from apps.evidence.retention_models import MAX_RETENTION_DAYS, MIN_RETENTION_DAYS + @register() def check_storage_bucket_separation(app_configs, **kwargs): @@ -19,3 +21,23 @@ def check_storage_bucket_separation(app_configs, **kwargs): id="evidence.E001", ) ] + + +@register() +def check_evidence_retention_settings(app_configs, **kwargs): + errors = [] + if not MIN_RETENTION_DAYS <= settings.EVIDENCE_OBJECT_RETENTION_DAYS <= MAX_RETENTION_DAYS: + errors.append( + Error( + "EVIDENCE_OBJECT_RETENTION_DAYS must be between 30 and 3650.", + id="evidence.E002", + ) + ) + if not 1 <= settings.EVIDENCE_RETENTION_BATCH_SIZE <= 1000: + errors.append( + Error( + "EVIDENCE_RETENTION_BATCH_SIZE must be between 1 and 1000.", + id="evidence.E003", + ) + ) + return errors diff --git a/backend/apps/evidence/finalization.py b/backend/apps/evidence/finalization.py index f83e25b7..453f41f9 100644 --- a/backend/apps/evidence/finalization.py +++ b/backend/apps/evidence/finalization.py @@ -83,6 +83,13 @@ def _claim(evidence_id): state, _ = EvidenceUploadFinalization.objects.select_for_update().get_or_create( evidence_id=evidence_id ) + if not lock_evidence_for_attachment(evidence_id, photo_already_locked=True): + from apps.evidence.storage import EvidenceObjectValidationError + + raise EvidenceObjectValidationError( + "expired", + "Evidence is expiring or has expired under the retention policy.", + ) if state.status == EvidenceUploadFinalization.Status.FINALIZED: return None, _result(state) if state.status == EvidenceUploadFinalization.Status.PROMOTING: @@ -96,6 +103,21 @@ def _claim(evidence_id): return token, None +def lock_evidence_for_attachment(evidence_id, *, photo_already_locked=False): + """Use the retention lock order and reject evidence already claimed for expiry.""" + from apps.evidence.models import EvidenceObjectRetentionState + + if not photo_already_locked: + EvidencePhoto.objects.select_for_update().get(pk=evidence_id) + EvidenceUploadFinalization.objects.select_for_update().filter( + evidence_id=evidence_id + ).first() + state = EvidenceObjectRetentionState.objects.select_for_update().filter( + evidence_id=evidence_id + ).first() + return state is None + + def _recover_or_wait(evidence, max_bytes): from apps.evidence import storage diff --git a/backend/apps/evidence/migrations/0006_evidence_retention.py b/backend/apps/evidence/migrations/0006_evidence_retention.py new file mode 100644 index 00000000..18951060 --- /dev/null +++ b/backend/apps/evidence/migrations/0006_evidence_retention.py @@ -0,0 +1,109 @@ +import django.core.validators +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("evidence", "0005_evidenceuploadfinalization"), + ("makerspaces", "0067_reconcile_anonymous_requests_with_membership"), + ] + + operations = [ + migrations.CreateModel( + name="EvidenceRetentionPolicy", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "makerspace", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="evidence_retention_policy", + to="makerspaces.makerspace", + ), + ), + ( + "object_retention_days", + models.PositiveIntegerField( + validators=[ + django.core.validators.MinValueValidator(30), + django.core.validators.MaxValueValidator(3650), + ] + ), + ), + ("updated_at", models.DateTimeField(auto_now=True)), + ], + ), + migrations.CreateModel( + name="EvidenceObjectRetentionState", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "evidence", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="object_retention_state", + to="evidence.evidencephoto", + ), + ), + ( + "status", + models.CharField( + choices=[("expiring", "Expiring"), ("expired", "Expired")], + default="expiring", + max_length=16, + ), + ), + ("claim_token", models.UUIDField(blank=True, null=True)), + ("claimed_at", models.DateTimeField(blank=True, null=True)), + ("object_expired_at", models.DateTimeField(blank=True, null=True)), + ("expired_size_bytes", models.PositiveBigIntegerField(blank=True, null=True)), + ("last_error", models.CharField(blank=True, max_length=500)), + ("updated_at", models.DateTimeField(auto_now=True)), + ], + ), + migrations.AddConstraint( + model_name="evidenceretentionpolicy", + constraint=models.CheckConstraint( + condition=models.Q( + ("object_retention_days__gte", 30), + ("object_retention_days__lte", 3650), + ), + name="ck_evidence_retention_days_range", + ), + ), + migrations.AddConstraint( + model_name="evidenceobjectretentionstate", + constraint=models.CheckConstraint( + condition=( + models.Q( + ("claim_token__isnull", True), + ("claimed_at__isnull", True), + ("object_expired_at__isnull", False), + ("status", "expired"), + ) + | models.Q( + ("object_expired_at__isnull", True), + ("status", "expiring"), + ) + ), + name="ck_evidence_retention_terminal_state", + ), + ), + ] diff --git a/backend/apps/evidence/models.py b/backend/apps/evidence/models.py index f76d7f16..b000a10f 100644 --- a/backend/apps/evidence/models.py +++ b/backend/apps/evidence/models.py @@ -61,3 +61,9 @@ class Status(models.TextChoices): content_type = models.CharField(max_length=128, blank=True) quota_charged = models.BooleanField(default=False) updated_at = models.DateTimeField(auto_now=True) + + +from apps.evidence.retention_models import ( # noqa: E402,F401 + EvidenceObjectRetentionState, + EvidenceRetentionPolicy, +) diff --git a/backend/apps/evidence/reports.py b/backend/apps/evidence/reports.py new file mode 100644 index 00000000..30f40de2 --- /dev/null +++ b/backend/apps/evidence/reports.py @@ -0,0 +1,115 @@ +from collections import defaultdict +from datetime import timedelta + +from django.db.models import Count, Sum +from django.utils import timezone + +from apps.evidence.models import EvidencePhoto +from apps.operations.models import ReportMetricRollup, ReportRollupCursor +from apps.operations.report_rollups import METRICS, REPORT_KEY, SOURCE_MODULE, _attached_ids +from apps.operations.report_types import ReportResult +from apps.operations.reports_common import limited, report_spaces + + +FIELDS = ( + "period", "evidence_type", "created_count", "attached_count", + "unattached_count", "object_live_count", "object_expired_count", + "metadata_retained_count", "bytes", "attachment_rate_percent", +) + + +def build_evidence_compliance(makerspace_id, *, limit=None, date_range=None, grain="day"): + aggregate = makerspace_id is None + records = [] + sources = set() + watermarks = [] + for space in report_spaces(makerspace_id, SOURCE_MODULE): + cursor = ReportRollupCursor.objects.filter(makerspace=space, source_module=SOURCE_MODULE).first() + if cursor and cursor.rolled_through: + watermarks.append(cursor.rolled_through) + buckets = _rolled_buckets(space.id, date_range, cursor) + if buckets: + sources.add("rollup") + live_start = cursor.rolled_through if cursor and cursor.rolled_through else None + live = _live_buckets(space.id, date_range, live_start) + if live: + sources.add("live") + _merge(records, space.id, aggregate, buckets, live, grain=grain) + source = "hybrid" if len(sources) > 1 else next(iter(sources), "live") + fields = (("makerspace_id",) + FIELDS) if aggregate else FIELDS + return ReportResult(fields, limited(records, limit), { + "source": source, "grain": grain, + "rollup_through": min(watermarks) if watermarks else None, + }) + + +def _rolled_buckets(space_id, date_range, cursor): + if cursor is None or cursor.rolled_through is None: + return {} + qs = ReportMetricRollup.objects.filter( + makerspace_id=space_id, report_key=REPORT_KEY, + bucket_start__lt=cursor.rolled_through, + ).order_by("bucket_start", "dimension_key", "metric_key", "-revision") + if date_range: + start, end = date_range + if start: + qs = qs.filter(bucket_start__gte=start) + if end: + qs = qs.filter(bucket_start__lt=end) + latest = {} + for row in qs: + key = (row.bucket_start, row.dimension_key, row.metric_key) + latest.setdefault(key, row) + buckets = defaultdict(dict) + for (bucket, _dimension, metric), row in latest.items(): + evidence_type = row.dimensions["evidence_type"] + buckets[(bucket.date(), evidence_type)][metric] = row.value + return buckets + + +def _live_buckets(space_id, date_range, live_start): + qs = EvidencePhoto.objects.filter(makerspace_id=space_id) + if live_start: + qs = qs.filter(created_at__gte=live_start) + if date_range: + start, end = date_range + if start: + qs = qs.filter(created_at__gte=start) + if end: + qs = qs.filter(created_at__lt=end) + rows = defaultdict(lambda: defaultdict(int)) + for evidence in qs.only("id", "evidence_type", "created_at", "size_bytes").iterator(chunk_size=200): + key = (evidence.created_at.date(), evidence.evidence_type) + rows[key]["created_count"] += 1 + rows[key]["object_live_count"] += 1 + rows[key]["metadata_retained_count"] += 1 + rows[key]["bytes"] += evidence.size_bytes or 0 + ids = qs.values_list("id", flat=True) + attached = _attached_ids(space_id, ids) + for evidence_type, attached_ids in attached.items(): + dates = dict(EvidencePhoto.objects.filter(id__in=attached_ids).values_list("id", "created_at")) + for created_at in dates.values(): + rows[(created_at.date(), evidence_type)]["attached_count"] += 1 + for values in rows.values(): + values["unattached_count"] = values["created_count"] - values["attached_count"] + values["object_expired_count"] = 0 + return rows + + +def _merge(records, space_id, aggregate, *sources, grain): + merged = defaultdict(lambda: defaultdict(int)) + for source in sources: + for (period, evidence_type), metrics in source.items(): + period = period.replace(day=1) if grain == "month" else period + target = merged[(period, evidence_type)] + for metric in METRICS: + target[metric] += metrics.get(metric, 0) + for (period, evidence_type), metrics in sorted(merged.items()): + created = metrics["created_count"] + row = { + "period": period, "evidence_type": evidence_type, **metrics, + "attachment_rate_percent": round(float(metrics["attached_count"] / created * 100), 2) if created else None, + } + if aggregate: + row["makerspace_id"] = space_id + records.append(row) diff --git a/backend/apps/evidence/retention_models.py b/backend/apps/evidence/retention_models.py new file mode 100644 index 00000000..e20784f4 --- /dev/null +++ b/backend/apps/evidence/retention_models.py @@ -0,0 +1,85 @@ +from django.core.validators import MaxValueValidator, MinValueValidator +from django.db import models +from django.db.models import Q + + +MIN_RETENTION_DAYS = 30 +MAX_RETENTION_DAYS = 3650 + + +class EvidenceRetentionPolicy(models.Model): + """Optional tenant override; absence means use the deployment default.""" + + # NOT primary_key=True, for the same reason as the retention state below: tenant + # migration supports only an auto-integer or UUID primary key. + makerspace = models.OneToOneField( + "makerspaces.Makerspace", + on_delete=models.CASCADE, + related_name="evidence_retention_policy", + ) + object_retention_days = models.PositiveIntegerField( + validators=[ + MinValueValidator(MIN_RETENTION_DAYS), + MaxValueValidator(MAX_RETENTION_DAYS), + ] + ) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + constraints = [ + models.CheckConstraint( + condition=Q( + object_retention_days__gte=MIN_RETENTION_DAYS, + object_retention_days__lte=MAX_RETENTION_DAYS, + ), + name="ck_evidence_retention_days_range", + ) + ] + + +class EvidenceObjectRetentionState(models.Model): + """Mutable expiry coordination kept away from immutable photo metadata.""" + + class Status(models.TextChoices): + EXPIRING = "expiring", "Expiring" + EXPIRED = "expired", "Expired" + + # NOT primary_key=True: tenant migration only supports an auto-integer or UUID + # primary key, so a OneToOneField PK made this model unable to travel at all -- a + # tenant that had run the retention sweep could not be migrated. OneToOneField is + # already unique, so one state row per photo still holds. + evidence = models.OneToOneField( + "evidence.EvidencePhoto", + on_delete=models.CASCADE, + related_name="object_retention_state", + ) + status = models.CharField( + max_length=16, + choices=Status.choices, + default=Status.EXPIRING, + ) + claim_token = models.UUIDField(null=True, blank=True) + claimed_at = models.DateTimeField(null=True, blank=True) + object_expired_at = models.DateTimeField(null=True, blank=True) + expired_size_bytes = models.PositiveBigIntegerField(null=True, blank=True) + last_error = models.CharField(max_length=500, blank=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + constraints = [ + models.CheckConstraint( + condition=( + Q( + status="expired", + object_expired_at__isnull=False, + claim_token__isnull=True, + claimed_at__isnull=True, + ) + | Q( + status="expiring", + object_expired_at__isnull=True, + ) + ), + name="ck_evidence_retention_terminal_state", + ) + ] diff --git a/backend/apps/evidence/retention_objects.py b/backend/apps/evidence/retention_objects.py new file mode 100644 index 00000000..e207577a --- /dev/null +++ b/backend/apps/evidence/retention_objects.py @@ -0,0 +1,21 @@ +from apps.evidence.models import EvidenceObjectRetentionState + + +def retention_object_states(makerspace): + """Return state keyed by the immutable photo's final object key.""" + rows = EvidenceObjectRetentionState.objects.filter( + evidence__makerspace=makerspace + ).values( + "evidence__object_key", + "status", + "object_expired_at", + "expired_size_bytes", + ) + return { + row["evidence__object_key"]: { + "status": row["status"], + "object_expired_at": row["object_expired_at"], + "expired_size_bytes": row["expired_size_bytes"], + } + for row in rows + } diff --git a/backend/apps/evidence/retention_policy.py b/backend/apps/evidence/retention_policy.py new file mode 100644 index 00000000..4cf1c01c --- /dev/null +++ b/backend/apps/evidence/retention_policy.py @@ -0,0 +1,109 @@ +from datetime import timedelta + +from django.conf import settings +from django.db import transaction +from django.db.models import BigIntegerField, Sum, Value +from django.db.models.functions import Coalesce +from django.utils import timezone + +from apps.audit import services as audit +from apps.evidence.models import ( + EvidenceObjectRetentionState, + EvidencePhoto, + EvidenceRetentionPolicy, +) +from apps.makerspaces.models import Makerspace + + +def effective_retention_days(makerspace_id): + override = EvidenceRetentionPolicy.objects.filter( + makerspace_id=makerspace_id + ).values_list("object_retention_days", flat=True).first() + return override or settings.EVIDENCE_OBJECT_RETENTION_DAYS + + +def policy_payload(makerspace): + override = EvidenceRetentionPolicy.objects.filter( + makerspace=makerspace + ).values_list("object_retention_days", flat=True).first() + return { + "makerspace_id": makerspace.pk, + "platform_default_days": settings.EVIDENCE_OBJECT_RETENTION_DAYS, + "override_days": override, + "effective_days": override or settings.EVIDENCE_OBJECT_RETENTION_DAYS, + "object_expiry_enabled": settings.EVIDENCE_OBJECT_EXPIRY_ENABLED, + } + + +def update_policy(makerspace, actor, object_retention_days): + with transaction.atomic(): + locked = Makerspace.objects.select_for_update().get(pk=makerspace.pk) + policy = EvidenceRetentionPolicy.objects.select_for_update().filter( + makerspace=locked + ).first() + old_effective = ( + policy.object_retention_days + if policy is not None + else settings.EVIDENCE_OBJECT_RETENTION_DAYS + ) + if object_retention_days is None: + if policy is not None: + policy.delete() + elif policy is None: + EvidenceRetentionPolicy.objects.create( + makerspace=locked, + object_retention_days=object_retention_days, + ) + else: + policy.object_retention_days = object_retention_days + policy.save(update_fields=("object_retention_days", "updated_at")) + new_effective = object_retention_days or settings.EVIDENCE_OBJECT_RETENTION_DAYS + audit.record( + actor, + "evidence.retention_policy_updated", + makerspace=locked, + target=locked, + meta={ + "old_effective_days": old_effective, + "new_effective_days": new_effective, + "override_cleared": object_retention_days is None, + }, + ) + return policy_payload(locked) + + +def object_candidates(makerspace, *, as_of=None): + as_of = as_of or timezone.now() + days = effective_retention_days(makerspace.pk) + cutoff = as_of - timedelta(days=days) + return ( + EvidencePhoto.objects.filter(makerspace=makerspace, created_at__lte=cutoff) + .exclude( + object_retention_state__status=EvidenceObjectRetentionState.Status.EXPIRED + ) + .order_by("created_at", "pk") + ), days, cutoff + + +def preview_object_expiry(makerspace, *, limit, as_of=None): + as_of = as_of or timezone.now() + queryset, days, cutoff = object_candidates(makerspace, as_of=as_of) + totals = queryset.aggregate( + candidate_bytes=Coalesce( + Sum( + Coalesce("upload_finalization__size_bytes", "size_bytes"), + output_field=BigIntegerField(), + ), + Value(0), + output_field=BigIntegerField(), + ) + ) + count = queryset.count() + return { + "as_of": as_of, + "policy_days": days, + "cutoff": cutoff, + "object_candidates": count, + "candidate_bytes": totals["candidate_bytes"], + "has_more": count > limit, + } diff --git a/backend/apps/evidence/serializers.py b/backend/apps/evidence/serializers.py index fd1099ae..580a6043 100644 --- a/backend/apps/evidence/serializers.py +++ b/backend/apps/evidence/serializers.py @@ -27,3 +27,38 @@ class EvidenceUrlResponseSerializer(serializers.Serializer): class EvidenceGetResponseSerializer(serializers.Serializer): url = serializers.URLField() expires_in = serializers.IntegerField() + + +class EvidenceExpiredResponseSerializer(serializers.Serializer): + code = serializers.CharField() + detail = serializers.CharField() + object_expired_at = serializers.DateTimeField() + + +class EvidenceRetentionPolicySerializer(serializers.Serializer): + makerspace_id = serializers.IntegerField() + platform_default_days = serializers.IntegerField() + override_days = serializers.IntegerField(allow_null=True) + effective_days = serializers.IntegerField() + object_expiry_enabled = serializers.BooleanField() + + +class EvidenceRetentionPatchSerializer(serializers.Serializer): + object_retention_days = serializers.IntegerField( + allow_null=True, + min_value=30, + max_value=3650, + ) + + +class EvidenceRetentionPreviewRequestSerializer(serializers.Serializer): + limit = serializers.IntegerField(default=100, min_value=1, max_value=1000) + + +class EvidenceRetentionPreviewResponseSerializer(serializers.Serializer): + as_of = serializers.DateTimeField() + policy_days = serializers.IntegerField() + cutoff = serializers.DateTimeField() + object_candidates = serializers.IntegerField() + candidate_bytes = serializers.IntegerField() + has_more = serializers.BooleanField() diff --git a/backend/apps/evidence/services_retention.py b/backend/apps/evidence/services_retention.py new file mode 100644 index 00000000..fcf4b87d --- /dev/null +++ b/backend/apps/evidence/services_retention.py @@ -0,0 +1,255 @@ +"""Bounded, idempotent expiry of evidence bytes while metadata stays immutable.""" + +from dataclasses import dataclass +from datetime import timedelta +import logging +import uuid + +from django.conf import settings +from django.db import transaction +from django.utils import timezone + +from apps.audit import services as audit +from apps.backup.models import DeploymentRecoveryState +from apps.evidence import storage +from apps.evidence.models import ( + EvidenceObjectRetentionState, + EvidencePhoto, + EvidenceUploadFinalization, +) +from apps.evidence.retention_policy import object_candidates, preview_object_expiry +from apps.makerspaces import limits +from apps.makerspaces.servability import servable_queryset +from apps.tenant_migration.gate_runtime import fanout_tenant_write + + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class ExpiryClaim: + evidence_id: int + makerspace_id: int + object_key: str + token: uuid.UUID + recorded_size: int + + +def sweep_evidence_retention(*, dry_run=False, now=None, batch_size=None): + now = now or timezone.now() + summary = { + "makerspaces_scanned": 0, + "makerspaces_skipped": 0, + "photos_eligible": 0, + "photos_claimed": 0, + "photos_expired": 0, + "photos_failed": 0, + "bytes_removed": 0, + "continuation_required": False, + } + if not settings.EVIDENCE_OBJECT_EXPIRY_ENABLED and not dry_run: + return _logged_summary(summary, dry_run=dry_run, disabled=True) + if not DeploymentRecoveryState.objects.filter( + pk=1, mode=DeploymentRecoveryState.Mode.NORMAL + ).exists(): + summary["makerspaces_skipped"] = servable_queryset().count() + return _logged_summary(summary, dry_run=dry_run, recovery_blocked=True) + + run_id = uuid.uuid4() + configured_batch_size = ( + settings.EVIDENCE_RETENTION_BATCH_SIZE if batch_size is None else batch_size + ) + batch_size = min(max(int(configured_batch_size), 1), 1000) + gate_counts = {"skipped": 0} + for makerspace in servable_queryset().order_by("pk").iterator(chunk_size=100): + summary["makerspaces_scanned"] += 1 + if dry_run: + preview = preview_object_expiry(makerspace, limit=batch_size, as_of=now) + summary["photos_eligible"] += preview["object_candidates"] + summary["continuation_required"] |= preview["has_more"] + continue + with fanout_tenant_write( + makerspace.pk, + operation="evidence_object_expiry", + counts=gate_counts, + ) as should_process: + if not should_process: + continue + _sweep_makerspace( + makerspace, + now=now, + run_id=run_id, + batch_size=batch_size, + summary=summary, + ) + summary["makerspaces_skipped"] += gate_counts["skipped"] + return _logged_summary( + summary, dry_run=dry_run, batch_size=batch_size, + ) + + +def _logged_summary(summary, *, dry_run, **context): + logger.info( + "evidence_object_expiry_sweep_completed", + extra={"dry_run": dry_run, **context, **summary}, + ) + return summary + + +def _sweep_makerspace(makerspace, *, now, run_id, batch_size, summary): + queryset, policy_days, cutoff = object_candidates(makerspace, as_of=now) + candidate_ids = list(queryset.values_list("pk", flat=True)[: batch_size + 1]) + summary["photos_eligible"] += min(len(candidate_ids), batch_size) + if len(candidate_ids) > batch_size: + summary["continuation_required"] = True + for evidence_id in candidate_ids[:batch_size]: + claim = _claim(evidence_id, now=now) + if claim is None: + continue + summary["photos_claimed"] += 1 + try: + final_size = storage.object_size(claim.object_key) + staged_key = storage.staging_key(claim.object_key) + staged_size = storage.object_size(staged_key) + outcomes = { + "final": storage.delete_object_strict(claim.object_key), + "staging": storage.delete_object_strict(staged_key), + } + size = final_size or staged_size or claim.recorded_size + removed = _complete( + claim, + size=size, + policy_days=policy_days, + cutoff=cutoff, + outcomes=outcomes, + run_id=run_id, + ) + summary["photos_expired"] += int(removed is not None) + summary["bytes_removed"] += removed or 0 + if removed is not None: + logger.info( + "evidence_object_expired", + extra={ + "evidence_id": claim.evidence_id, + "makerspace_id": claim.makerspace_id, + "final_outcome": outcomes["final"], + "staging_outcome": outcomes["staging"], + "expired_size_bytes": removed, + }, + ) + except Exception as exc: # per-object isolation keeps the bounded sweep moving + _release(claim, exc) + summary["photos_failed"] += 1 + logger.warning( + "evidence_object_expiry_failed", + extra={ + "evidence_id": claim.evidence_id, + "makerspace_id": claim.makerspace_id, + "error_type": type(exc).__name__, + }, + exc_info=True, + ) + + +def _claim(evidence_id, *, now): + stale_before = now - timedelta( + seconds=max(settings.EVIDENCE_URL_TTL_SECONDS, 60) + ) + with transaction.atomic(): + photo = EvidencePhoto.objects.select_for_update().get(pk=evidence_id) + finalization = EvidenceUploadFinalization.objects.select_for_update().filter( + evidence_id=evidence_id + ).first() + state, _ = EvidenceObjectRetentionState.objects.select_for_update().get_or_create( + evidence_id=evidence_id + ) + if state.status == EvidenceObjectRetentionState.Status.EXPIRED: + return None + if state.claim_token and state.claimed_at and state.claimed_at > stale_before: + return None + if ( + finalization is not None + and finalization.status == EvidenceUploadFinalization.Status.PROMOTING + and finalization.updated_at > stale_before + ): + return None + token = uuid.uuid4() + state.status = EvidenceObjectRetentionState.Status.EXPIRING + state.claim_token = token + state.claimed_at = now + state.last_error = "" + state.save( + update_fields=( + "status", "claim_token", "claimed_at", "last_error", "updated_at", + ) + ) + recorded_size = ( + getattr(finalization, "size_bytes", None) or photo.size_bytes or 0 + ) + return ExpiryClaim( + photo.pk, photo.makerspace_id, photo.object_key, token, recorded_size + ) + + +def _complete(claim, *, size, policy_days, cutoff, outcomes, run_id): + with transaction.atomic(): + photo = EvidencePhoto.objects.select_for_update().get(pk=claim.evidence_id) + finalization = EvidenceUploadFinalization.objects.select_for_update().filter( + evidence_id=claim.evidence_id + ).first() + state = EvidenceObjectRetentionState.objects.select_for_update().get( + evidence_id=claim.evidence_id + ) + if state.status == EvidenceObjectRetentionState.Status.EXPIRED: + return None + if state.claim_token != claim.token: + return None + expired_at = timezone.now() + state.status = EvidenceObjectRetentionState.Status.EXPIRED + state.claim_token = None + state.claimed_at = None + state.object_expired_at = expired_at + state.expired_size_bytes = size + state.last_error = "" + state.save() + quota_was_charged = ( + settings.STORAGE_PRESIGN_METHOD != "put" + or finalization is None + or finalization.quota_charged + ) + if quota_was_charged: + limits.free_storage(photo.makerspace, size) + audit.record( + None, + "evidence.object_expired", + makerspace=photo.makerspace, + target=photo, + meta={ + "policy_days": policy_days, + "cutoff": cutoff.isoformat(), + "final_outcome": outcomes["final"], + "staging_outcome": outcomes["staging"], + "expired_size_bytes": size, + "sweep_run_id": str(run_id), + }, + ) + return size + + +def _release(claim, exc): + with transaction.atomic(): + EvidencePhoto.objects.select_for_update().filter(pk=claim.evidence_id).first() + EvidenceUploadFinalization.objects.select_for_update().filter( + evidence_id=claim.evidence_id + ).first() + state = EvidenceObjectRetentionState.objects.select_for_update().filter( + evidence_id=claim.evidence_id, + claim_token=claim.token, + status=EvidenceObjectRetentionState.Status.EXPIRING, + ).first() + if state is None: + return + state.claim_token = None + state.claimed_at = None + state.last_error = f"{type(exc).__name__}: {exc}"[:500] + state.save(update_fields=("claim_token", "claimed_at", "last_error", "updated_at")) diff --git a/backend/apps/evidence/storage.py b/backend/apps/evidence/storage.py index 3c97c3a5..34ab5a4f 100644 --- a/backend/apps/evidence/storage.py +++ b/backend/apps/evidence/storage.py @@ -68,6 +68,21 @@ def delete_object(object_key): logger.exception("Failed to delete storage object %s.", object_key) +def delete_object_strict(object_key): + """Delete every version and report whether a current object was visible.""" + existed = object_exists(object_key) + try: + delete_all_versions( + _client(), + bucket=settings.AWS_STORAGE_BUCKET_NAME, + key=object_key, + require_version_listing=True, + ) + except (BotoCoreError, ClientError) as exc: + raise StorageUnavailable from exc + return "deleted" if existed else "absent_or_version_only" + + def copy_object(source_key, dest_key): try: _client().copy_object( diff --git a/backend/apps/evidence/tasks.py b/backend/apps/evidence/tasks.py new file mode 100644 index 00000000..4fdfb530 --- /dev/null +++ b/backend/apps/evidence/tasks.py @@ -0,0 +1,11 @@ +from celery import shared_task + +from apps.evidence.services_retention import sweep_evidence_retention + + +@shared_task(name="apps.evidence.tasks.sweep_evidence_retention_task") +def sweep_evidence_retention_task(dry_run=False, batch_size=None): + return sweep_evidence_retention( + dry_run=bool(dry_run), + batch_size=batch_size, + ) diff --git a/backend/apps/evidence/urls.py b/backend/apps/evidence/urls.py index cf1a9372..695ccb68 100644 --- a/backend/apps/evidence/urls.py +++ b/backend/apps/evidence/urls.py @@ -1,10 +1,24 @@ from django.urls import path from apps.evidence.views import EvidenceDetailView, EvidenceUploadUrlView +from apps.evidence.views_retention import ( + EvidenceRetentionPolicyView, + EvidenceRetentionPreviewView, +) app_name = "evidence_admin" urlpatterns = [ + path( + "makerspaces//evidence-retention", + EvidenceRetentionPolicyView.as_view(), + name="evidence-retention-policy", + ), + path( + "makerspaces//evidence-retention/preview", + EvidenceRetentionPreviewView.as_view(), + name="evidence-retention-preview", + ), path( "makerspaces//uploads/evidence-url", EvidenceUploadUrlView.as_view(), diff --git a/backend/apps/evidence/views.py b/backend/apps/evidence/views.py index 692b66e1..c99c9457 100644 --- a/backend/apps/evidence/views.py +++ b/backend/apps/evidence/views.py @@ -4,7 +4,7 @@ from django.db import transaction from django.shortcuts import get_object_or_404 from drf_spectacular.utils import OpenApiResponse, extend_schema -from rest_framework import generics +from rest_framework import generics, status from rest_framework.permissions import BasePermission, IsAuthenticated from rest_framework.response import Response @@ -15,9 +15,10 @@ ) from apps.accounts.models import User from apps.audit import services as audit -from apps.evidence.models import EvidencePhoto +from apps.evidence.models import EvidenceObjectRetentionState, EvidencePhoto from apps.evidence.serializers import ( EvidenceGetResponseSerializer, + EvidenceExpiredResponseSerializer, EvidenceUrlRequestSerializer, EvidenceUrlResponseSerializer, ) @@ -135,6 +136,7 @@ def get_queryset(self): 200: EvidenceGetResponseSerializer, 404: OpenApiResponse(description="Evidence was not found."), 409: OpenApiResponse(description="Evidence object has not been uploaded."), + 410: EvidenceExpiredResponseSerializer, 503: OpenApiResponse(description="Evidence storage is unavailable."), }, ) @@ -142,6 +144,25 @@ def retrieve(self, request, *args, **kwargs): photo = self.get_object() require_module(photo.makerspace, "evidence_uploads") + retention_state = EvidenceObjectRetentionState.objects.filter( + evidence=photo, + status=EvidenceObjectRetentionState.Status.EXPIRED, + ).first() + if retention_state is not None: + return Response( + EvidenceExpiredResponseSerializer( + { + "code": "evidence_expired", + "detail": ( + "The evidence object expired under the makerspace " + "retention policy." + ), + "object_expired_at": retention_state.object_expired_at, + } + ).data, + status=status.HTTP_410_GONE, + ) + # A presigned upload lands on the staging key and only becomes the final, # never-client-writable object when a workflow promotes it. So an uploaded but # not-yet-consumed photo lives in staging, and a read that only ever HEADs the diff --git a/backend/apps/evidence/views_retention.py b/backend/apps/evidence/views_retention.py new file mode 100644 index 00000000..b084c473 --- /dev/null +++ b/backend/apps/evidence/views_retention.py @@ -0,0 +1,103 @@ +from django.shortcuts import get_object_or_404 +from drf_spectacular.utils import OpenApiResponse, extend_schema +from rest_framework.exceptions import PermissionDenied +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.admin_api.permissions import IsActiveStaff +from apps.evidence.retention_policy import ( + policy_payload, + preview_object_expiry, + update_policy, +) +from apps.evidence.serializers import ( + EvidenceRetentionPatchSerializer, + EvidenceRetentionPolicySerializer, + EvidenceRetentionPreviewRequestSerializer, + EvidenceRetentionPreviewResponseSerializer, +) +from apps.makerspaces.guards import require_module +from apps.makerspaces.models import Makerspace + + +AUTH_ERRORS = { + 401: OpenApiResponse(description="Authentication is required."), + 403: OpenApiResponse(description="Active manage-events permission is required."), + 404: OpenApiResponse(description="Makerspace was not found in the actor's scope."), + 503: OpenApiResponse(description="Deployment recovery is active."), +} + + +def _manageable_makerspace(actor, makerspace_id): + queryset = rbac.scope_by_visibility_or_action( + actor, + rbac.Action.MANAGE_EVENTS, + Makerspace.objects.all(), + field="id", + ) + queryset = rbac.hide_from_superadmin(actor, queryset, field="id") + makerspace = get_object_or_404(queryset, pk=makerspace_id) + require_module(makerspace, "evidence_uploads") + if not rbac.can(actor, rbac.Action.MANAGE_EVENTS, makerspace.pk): + raise PermissionDenied() + return makerspace + + +class EvidenceRetentionPolicyView(APIView): + permission_classes = [IsAuthenticated, IsActiveStaff] + + @extend_schema( + tags=["Evidence retention"], + summary="Get a makerspace evidence object-retention policy", + responses={200: EvidenceRetentionPolicySerializer, **AUTH_ERRORS}, + ) + def get(self, request, makerspace_id): + makerspace = _manageable_makerspace(request.user, makerspace_id) + return Response(EvidenceRetentionPolicySerializer(policy_payload(makerspace)).data) + + @extend_schema( + tags=["Evidence retention"], + summary="Set or clear a makerspace evidence object-retention override", + request=EvidenceRetentionPatchSerializer, + responses={ + 200: EvidenceRetentionPolicySerializer, + 400: OpenApiResponse(description="Retention days are invalid."), + **AUTH_ERRORS, + }, + ) + def patch(self, request, makerspace_id): + makerspace = _manageable_makerspace(request.user, makerspace_id) + serializer = EvidenceRetentionPatchSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + payload = update_policy( + makerspace, + request.user, + serializer.validated_data["object_retention_days"], + ) + return Response(EvidenceRetentionPolicySerializer(payload).data) + + +class EvidenceRetentionPreviewView(APIView): + permission_classes = [IsAuthenticated, IsActiveStaff] + + @extend_schema( + tags=["Evidence retention"], + summary="Preview evidence objects eligible for expiry", + request=EvidenceRetentionPreviewRequestSerializer, + responses={ + 200: EvidenceRetentionPreviewResponseSerializer, + 400: OpenApiResponse(description="Preview limit is invalid."), + **AUTH_ERRORS, + }, + ) + def post(self, request, makerspace_id): + makerspace = _manageable_makerspace(request.user, makerspace_id) + serializer = EvidenceRetentionPreviewRequestSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + payload = preview_object_expiry( + makerspace, + limit=serializer.validated_data["limit"], + ) + return Response(EvidenceRetentionPreviewResponseSerializer(payload).data) diff --git a/backend/apps/hardware_requests/direct_loan_returns.py b/backend/apps/hardware_requests/direct_loan_returns.py index 9c4779cd..af9ac500 100644 --- a/backend/apps/hardware_requests/direct_loan_returns.py +++ b/backend/apps/hardware_requests/direct_loan_returns.py @@ -5,7 +5,7 @@ from apps.audit import services as audit from apps.boxes.models import QrCode, QrScanEvent from apps.evidence import storage -from apps.evidence.finalization import charge_storage_once +from apps.evidence.finalization import charge_storage_once, lock_evidence_for_attachment from apps.evidence.models import EvidencePhoto from apps.hardware_requests.direct_loan_audit import record_item_logs from apps.hardware_requests.models import PublicToolLoan, ReturnEvent @@ -58,7 +58,8 @@ def return_direct_loan( ) if locked.status != PublicToolLoan.Status.CHECKED_OUT: raise InvalidTransition("Direct loan is not currently checked out.") - EvidencePhoto.objects.select_for_update().get(pk=evidence.pk) + if not lock_evidence_for_attachment(evidence.pk): + raise ReturnValidationError("Evidence has expired under the retention policy.") if ( PublicToolLoan.objects.filter(return_evidence=evidence).exists() or ReturnEvent.objects.filter(evidence=evidence).exists() diff --git a/backend/apps/hardware_requests/direct_loan_workflow.py b/backend/apps/hardware_requests/direct_loan_workflow.py index 61cc8a27..69ec26c7 100644 --- a/backend/apps/hardware_requests/direct_loan_workflow.py +++ b/backend/apps/hardware_requests/direct_loan_workflow.py @@ -7,7 +7,7 @@ from apps.audit import services as audit from apps.boxes.models import Box, QrCode, QrScanEvent from apps.evidence.models import EvidencePhoto -from apps.evidence.finalization import charge_storage_once +from apps.evidence.finalization import charge_storage_once, lock_evidence_for_attachment from apps.hardware_requests.models import HardwareRequest, PublicToolLoan from apps.hardware_requests.direct_loan_audit import record_item_logs from apps.hardware_requests.direct_loan_returns import return_direct_loan, validate_evidence_upload @@ -49,7 +49,8 @@ def issue_direct_loan( finalized = validate_evidence_upload(evidence, label="Issue") due_at = timezone.now() + timedelta(days=(makerspace.default_loan_days or 7)) with transaction.atomic(): - EvidencePhoto.objects.select_for_update().get(pk=evidence.pk) + if not lock_evidence_for_attachment(evidence.pk): + raise RequestValidationError("Evidence has expired under the retention policy.") if HardwareRequest.objects.filter(issue_evidence=evidence).exists(): raise RequestValidationError("Evidence already used.") charge_storage_once(evidence, finalized.size) diff --git a/backend/apps/hardware_requests/exceptions.py b/backend/apps/hardware_requests/exceptions.py index 15aff5af..8db86ccd 100644 --- a/backend/apps/hardware_requests/exceptions.py +++ b/backend/apps/hardware_requests/exceptions.py @@ -10,6 +10,11 @@ CapacityConflict, DuplicateRegistration, EventInvalidTransition, + UseSeriesCollaborators, + FeedbackConflict, + FeedbackIneligible, + RegistrationClosed, + RegistrationRejected, ) from apps.hardware_requests.workflow import ( AnonymousRequestIdempotencyConflict, @@ -127,6 +132,21 @@ class ErrorSerializer(serializers.Serializer): "invalid_transition", "Invalid event transition.", ), + UseSeriesCollaborators: ( + status.HTTP_409_CONFLICT, + "use_series_collaborators", + "Manage projected collaborators on the event series.", + ), + FeedbackConflict: ( + status.HTTP_409_CONFLICT, + "feedback_conflict", + "Feedback was already submitted with different answers.", + ), + FeedbackIneligible: ( + status.HTTP_404_NOT_FOUND, + "feedback_not_found", + "Feedback eligibility could not be verified.", + ), BookingInvalidTransition: ( status.HTTP_409_CONFLICT, "invalid_transition", @@ -142,6 +162,16 @@ class ErrorSerializer(serializers.Serializer): "capacity_conflict", "Event capacity conflicts with confirmed registrations.", ), + RegistrationClosed: ( + status.HTTP_409_CONFLICT, + "registration_closed", + "Registration for this event is closed.", + ), + RegistrationRejected: ( + status.HTTP_409_CONFLICT, + "registration_rejected", + "This registration application was rejected.", + ), DuplicateRegistration: ( status.HTTP_400_BAD_REQUEST, "duplicate_registration", diff --git a/backend/apps/hardware_requests/handover_views.py b/backend/apps/hardware_requests/handover_views.py index c381b1ef..129c485e 100644 --- a/backend/apps/hardware_requests/handover_views.py +++ b/backend/apps/hardware_requests/handover_views.py @@ -21,6 +21,7 @@ from apps.hardware_requests.view_helpers import ( ACTION_ERROR_RESPONSES, ERROR_503, + handover_surface_module, request_queryset, ) from apps.makerspaces.guards import require_module @@ -37,7 +38,7 @@ class AssignBoxView(APIView): ) def post(self, request, pk, *args, **kwargs): hardware_request = _scoped_action_request( - request.user, + request, pk, rbac.Action.ASSIGN_BOX, ) @@ -62,7 +63,7 @@ class IssueRequestView(APIView): ) def post(self, request, pk, *args, **kwargs): hardware_request = _scoped_action_request( - request.user, + request, pk, rbac.Action.ISSUE_REQUEST, ) @@ -90,7 +91,7 @@ class ReturnRequestView(APIView): ) def post(self, request, pk, *args, **kwargs): hardware_request = _scoped_action_request( - request.user, + request, pk, rbac.Action.RETURN_REQUEST, ) @@ -118,7 +119,7 @@ class SetReturnDueView(APIView): ) def post(self, request, pk, *args, **kwargs): hardware_request = _scoped_action_request( - request.user, + request, pk, rbac.Action.ACCEPT_REQUEST, ) @@ -132,13 +133,15 @@ def post(self, request, pk, *args, **kwargs): return Response(AdminRequestSerializer(updated).data) -def _scoped_action_request(user, pk, action): - scoped = rbac.scope_by_action(user, action, request_queryset()) +def _scoped_action_request(request, pk, action): + """Scope by RBAC action, then gate on the module that owns THIS URL surface. + + The action is the authority; the module is only the surface. Keying the module on the + action instead made `guest_handover` -- an optional module -- refuse `assign_box`, + `issue` and `return` for every actor including a full Space Manager, so a core-only + install could accept a request and then never fulfil it. + """ + scoped = rbac.scope_by_action(request.user, action, request_queryset()) hardware_request = get_object_or_404(scoped, pk=pk) - module = "guest_handover" if action in { - rbac.Action.ASSIGN_BOX, - rbac.Action.ISSUE_REQUEST, - rbac.Action.RETURN_REQUEST, - } else "request_workflow" - require_module(hardware_request.makerspace, module) + require_module(hardware_request.makerspace, handover_surface_module(request)) return hardware_request diff --git a/backend/apps/hardware_requests/handover_workflow.py b/backend/apps/hardware_requests/handover_workflow.py index 3a9e910e..bac6074e 100644 --- a/backend/apps/hardware_requests/handover_workflow.py +++ b/backend/apps/hardware_requests/handover_workflow.py @@ -7,7 +7,7 @@ from apps.audit import services as audit from apps.boxes.models import Box, BoxScan from apps.evidence import storage -from apps.evidence.finalization import charge_storage_once +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.handover_issue_helpers import ( @@ -120,6 +120,8 @@ def issue_request(actor, request, evidence_id, remark="", asset_qr_payloads=None raise InvalidTransition( f"Cannot issue hardware request with status {locked.status}." ) + if not lock_evidence_for_attachment(evidence.pk): + raise RequestValidationError("Evidence has expired under the retention policy.") # Promotion and byte validation happen before this domain transaction so its # request/evidence row locks never span S3 I/O. Quota remains charged at the # consuming workflow boundary and only for PUT-backed managed storage. diff --git a/backend/apps/hardware_requests/problem_report_workflow.py b/backend/apps/hardware_requests/problem_report_workflow.py index e0c051f3..85dd8ccb 100644 --- a/backend/apps/hardware_requests/problem_report_workflow.py +++ b/backend/apps/hardware_requests/problem_report_workflow.py @@ -3,7 +3,7 @@ from apps.audit import services as audit from apps.evidence.models import EvidencePhoto -from apps.evidence.finalization import charge_storage_once +from apps.evidence.finalization import charge_storage_once, lock_evidence_for_attachment from apps.hardware_requests.direct_loan_returns import validate_evidence_upload from apps.hardware_requests.models import PublicProblemReport, RequesterAccountability from apps.hardware_requests.workflow_errors import InvalidTransition, RequestValidationError, ReturnValidationError @@ -41,6 +41,10 @@ def triage_problem_report(report, actor, *, outcome, resolutions, note, evidence raise InvalidTransition("Problem report has already been triaged.") if finalized is not None: + if not lock_evidence_for_attachment(evidence.pk): + raise RequestValidationError( + "Evidence has expired under the retention policy." + ) charge_storage_once(evidence, finalized.size) quantities = [] if outcome != PublicProblemReport.Outcome.NO_ISSUE: diff --git a/backend/apps/hardware_requests/queue_views.py b/backend/apps/hardware_requests/queue_views.py index 966a9d8b..47e255b1 100644 --- a/backend/apps/hardware_requests/queue_views.py +++ b/backend/apps/hardware_requests/queue_views.py @@ -12,6 +12,7 @@ from apps.hardware_requests.view_helpers import ( ADMIN_LIST_ERROR_RESPONSES, request_queryset, + handover_surface_module, ) from apps.makerspaces.models import Makerspace from apps.makerspaces.guards import require_module @@ -92,7 +93,7 @@ class AcceptedRequestsView(generics.ListAPIView): def get_queryset(self): makerspace_id = self.kwargs["makerspace_id"] - require_module(makerspace_id, "guest_handover") + require_module(makerspace_id, handover_surface_module(self.request)) _require_action(self.request.user, rbac.Action.ISSUE_REQUEST, makerspace_id) return ( request_queryset() @@ -120,7 +121,7 @@ class ActiveLoansView(generics.ListAPIView): def get_queryset(self): makerspace_id = self.kwargs["makerspace_id"] - require_module(makerspace_id, "guest_handover") + require_module(makerspace_id, handover_surface_module(self.request)) _require_action(self.request.user, rbac.Action.ISSUE_REQUEST, makerspace_id) return ( request_queryset() @@ -155,7 +156,7 @@ class RequestHistoryView(generics.ListAPIView): def get_queryset(self): makerspace_id = self.kwargs["makerspace_id"] - require_module(makerspace_id, "guest_handover") + require_module(makerspace_id, handover_surface_module(self.request)) _require_action(self.request.user, rbac.Action.ISSUE_REQUEST, makerspace_id) return ( request_queryset() diff --git a/backend/apps/hardware_requests/return_workflow.py b/backend/apps/hardware_requests/return_workflow.py index 6bde8e3a..04f3abfd 100644 --- a/backend/apps/hardware_requests/return_workflow.py +++ b/backend/apps/hardware_requests/return_workflow.py @@ -4,7 +4,7 @@ from apps.audit import services as audit from apps.boxes.models import Box, BoxScan from apps.evidence import storage -from apps.evidence.finalization import charge_storage_once +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.models import ( @@ -52,7 +52,8 @@ def return_items(actor, request, evidence_id, remark, box_code, resolutions): # return. ReturnEvent.evidence (OneToOne) already blocks reviewed-request # reuse; this shared row lock serializes against the direct-loan return # path, which has no DB constraint spanning the two tables. - EvidencePhoto.objects.select_for_update().get(pk=evidence.pk) + if not lock_evidence_for_attachment(evidence.pk): + raise ReturnValidationError("Evidence has expired under the retention policy.") if PublicToolLoan.objects.filter(return_evidence=evidence).exists(): raise ReturnValidationError("Return evidence has already been used.") charge_storage_once(evidence, finalized.size) diff --git a/backend/apps/hardware_requests/self_checkout_workflow.py b/backend/apps/hardware_requests/self_checkout_workflow.py index db30940b..927eb117 100644 --- a/backend/apps/hardware_requests/self_checkout_workflow.py +++ b/backend/apps/hardware_requests/self_checkout_workflow.py @@ -8,7 +8,7 @@ from apps.audit import services as audit from apps.boxes.models import Box, QrCode, QrScanEvent from apps.evidence.models import EvidencePhoto -from apps.evidence.finalization import charge_storage_once +from apps.evidence.finalization import charge_storage_once, lock_evidence_for_attachment from apps.hardware_requests.models import ( HardwareRequest, HardwareRequestItem, @@ -223,7 +223,9 @@ def _public_evidence(makerspace, requester, evidence_id, evidence_type): def _lock_unused_evidence(evidence, *, issue): - EvidencePhoto.objects.select_for_update().get(pk=evidence.pk) + if not lock_evidence_for_attachment(evidence.pk): + error = RequestValidationError if issue else ReturnValidationError + raise error("Evidence has expired under the retention policy.") if issue: if HardwareRequest.objects.filter(issue_evidence=evidence).exists(): raise RequestValidationError("Evidence already used.") diff --git a/backend/apps/hardware_requests/view_helpers.py b/backend/apps/hardware_requests/view_helpers.py index 71faca56..1baaea25 100644 --- a/backend/apps/hardware_requests/view_helpers.py +++ b/backend/apps/hardware_requests/view_helpers.py @@ -41,3 +41,28 @@ def request_queryset(): "issued_by", "issue_evidence", ).prefetch_related("items__product", "items__asset_links__asset", "returnevent_set") + + +# The `guest-admin/` routes REUSE the admin view classes (`ActiveLoansView`, +# `ReturnRequestView`), so the module a request must satisfy depends on the URL SURFACE it +# arrived through, not on the view class. Gating the shared view on `guest_handover` +# instead let an OPTIONAL module block the CORE reviewed-request transitions for every +# actor -- a core-only install reached `accepted` and could never issue or return the +# hardware, which is the `9e496997` bug class one module over. `guest_handover` is the +# narrow guest-admin SURFACE, never the underlying authority: that is `rbac.Action`. +# +# Declared as a table for the same reason as `CHANNEL_MODULE_KEYS` -- one gate shared by +# several call sites, where the table IS the enforcement declaration the registry drift +# guard reads. +HANDOVER_SURFACE_MODULE_KEYS = { + "guest-admin": "guest_handover", + "admin": "request_workflow", +} + + +def handover_surface_module(request): + """Which module key gates this reviewed-request call, by the URL it came through.""" + match = getattr(request, "resolver_match", None) + url_name = getattr(match, "url_name", None) or "" + surface = "guest-admin" if url_name.startswith("guest-admin-") else "admin" + return HANDOVER_SURFACE_MODULE_KEYS[surface] diff --git a/backend/apps/integrations/email_templates_registry_fablab.py b/backend/apps/integrations/email_templates_registry_fablab.py index f5f9c15e..de90f9dd 100644 --- a/backend/apps/integrations/email_templates_registry_fablab.py +++ b/backend/apps/integrations/email_templates_registry_fablab.py @@ -44,6 +44,10 @@ "registration_cancelled", "registration_promoted", "registration_attended", + "series_published", + "series_cancelled", + "series_completed", + "series_generation_failed", ) BOOKINGS_KEYS = ("created", "confirmed", "rejected", "cancelled", "completed", "no_show") MAINTENANCE_KEYS = ( diff --git a/backend/apps/integrations/email_templates_registry_fablab_defaults.py b/backend/apps/integrations/email_templates_registry_fablab_defaults.py index 7abf500c..6b4264a2 100644 --- a/backend/apps/integrations/email_templates_registry_fablab_defaults.py +++ b/backend/apps/integrations/email_templates_registry_fablab_defaults.py @@ -31,9 +31,17 @@ "cancelled": "{{ event.title }} has been cancelled", "completed": "Thank you for attending {{ event.title }}", "registration_created": "You are registered for {{ event.title }}", + "registration_pending_approval": "Your application for {{ event.title }} is awaiting approval", + "registration_waitlisted": "You are on the waitlist for {{ event.title }}", + "registration_approved": "Your application for {{ event.title }} was approved", + "registration_rejected": "Your application for {{ event.title }} was not approved", "registration_cancelled": "Your registration for {{ event.title }} was cancelled", "registration_promoted": "A place has opened up for {{ event.title }}", "registration_attended": "Your attendance at {{ event.title }} is recorded", + "series_published": "{{ event.title }} recurring schedule published", + "series_cancelled": "{{ event.title }} recurring schedule cancelled", + "series_completed": "{{ event.title }} recurring schedule completed", + "series_generation_failed": "{{ event.title }} needs schedule attention", } EVENTS_REQUESTER_BODIES = { @@ -44,6 +52,21 @@ ), "completed": "This event has finished. Thank you for taking part.", "registration_created": "Your place is booked. We look forward to seeing you.", + "registration_pending_approval": ( + "Your application was received. No payment is due unless it is approved into " + "a confirmed place." + ), + "registration_waitlisted": ( + "Your registration is on the waiting list. No payment is due unless a place " + "is confirmed." + ), + "registration_approved": ( + "Your application was approved. The registration status above shows whether " + "your place is confirmed or waitlisted." + ), + "registration_rejected": ( + "Your application was not approved. You were not charged." + ), "registration_cancelled": ( "Your registration has been cancelled. You can register again while places " "remain." @@ -53,6 +76,10 @@ "now confirmed." ), "registration_attended": "Your attendance has been recorded.", + "series_published": "The recurring schedule is now published.", + "series_cancelled": "The recurring schedule has been cancelled.", + "series_completed": "The recurring schedule is complete.", + "series_generation_failed": "Automatic occurrence generation needs staff attention.", } EVENTS_REQUESTER_TEXT = """Hello {{ registration.name|default:"there" }}, diff --git a/backend/apps/integrations/notification_catalog.py b/backend/apps/integrations/notification_catalog.py index 8b013c1c..a7134138 100644 --- a/backend/apps/integrations/notification_catalog.py +++ b/backend/apps/integrations/notification_catalog.py @@ -30,7 +30,11 @@ ), F.EVENTS: ( "published", "cancelled", "completed", "registration_created", + "registration_pending_approval", "registration_waitlisted", + "registration_approved", "registration_rejected", "registration_cancelled", "registration_promoted", "registration_attended", + "series_published", "series_cancelled", "series_completed", + "series_generation_failed", ), F.BOOKINGS: ( "created", "confirmed", "rejected", "cancelled", "completed", "no_show", diff --git a/backend/apps/integrations/reports_communications.py b/backend/apps/integrations/reports_communications.py new file mode 100644 index 00000000..a69f2929 --- /dev/null +++ b/backend/apps/integrations/reports_communications.py @@ -0,0 +1,69 @@ +from django.db.models import Count, Max, Sum + +from apps.integrations.models import EmailLog, NotificationDeliveryLog, NotificationDestination +from apps.makerspaces.platform import module_enabled +from apps.notifications.models import Notification +from apps.operations.report_types import ReportResult +from apps.operations.reports_common import apply_range, limited, report_spaces + + +FIELDS = ( + "module_key", "channel", "feature", "status", "delivery_count", + "attempt_count", "destination_count", "success_rate_percent", "unread_count", + "last_activity_at", +) + + +def build_communications_health(makerspace_id, *, limit=None, date_range=None): + aggregate = makerspace_id is None + records = [] + for space in report_spaces(makerspace_id): + if module_enabled(space, "notifications"): + _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"): + if module_enabled(space, channel): + _channel_rows(space.id, channel, records, aggregate, date_range) + fields = (("makerspace_id",) + FIELDS) if aggregate else FIELDS + return ReportResult(fields, limited(records, limit)) + + +def _notification_rows(space_id, records, aggregate, date_range): + qs = apply_range(Notification.objects.filter(makerspace_id=space_id), "created_at", date_range) + values = qs.aggregate(count=Count("id"), unread=Count("id", filter=_q(read_at__isnull=True)), last=Max("created_at")) + _add(records, space_id, aggregate, module_key="notifications", channel="in_app", feature="inbox", + status="generated", delivery_count=values["count"], attempt_count=values["count"], + destination_count=1, success_rate_percent=100 if values["count"] else None, + unread_count=values["unread"], last_activity_at=values["last"]) + + +def _email_rows(space_id, records, aggregate, date_range): + qs = apply_range(EmailLog.objects.filter(makerspace_id=space_id), "created_at", date_range) + for row in qs.values("stream", "status").annotate(count=Count("id"), attempts=Sum("attempts"), last=Max("updated_at")): + _add(records, space_id, aggregate, module_key="email", channel="email", feature=row["stream"] or "general", + status=row["status"], delivery_count=row["count"], attempt_count=row["attempts"] or 0, + destination_count=None, success_rate_percent=100 if row["status"] == EmailLog.Status.SENT else 0, + unread_count=None, last_activity_at=row["last"]) + + +def _channel_rows(space_id, channel, records, aggregate, date_range): + destinations = NotificationDestination.objects.filter(makerspace_id=space_id, channel=channel, is_active=True).count() + qs = apply_range(NotificationDeliveryLog.objects.filter(makerspace_id=space_id, channel=channel), "created_at", date_range) + for row in qs.values("feature", "status").annotate(count=Count("id"), attempts=Sum("attempts"), last=Max("updated_at")): + _add(records, space_id, aggregate, module_key=channel, channel=channel, feature=row["feature"], + status=row["status"], delivery_count=row["count"], attempt_count=row["attempts"] or 0, + destination_count=destinations, success_rate_percent=100 if row["status"] == "sent" else 0, + unread_count=None, last_activity_at=row["last"]) + + +def _add(records, space_id, aggregate, **values): + row = {field: values.get(field) for field in FIELDS} + if aggregate: + row["makerspace_id"] = space_id + records.append(row) + + +def _q(**kwargs): + from django.db.models import Q + return Q(**kwargs) diff --git a/backend/apps/inventory/middleware.py b/backend/apps/inventory/middleware.py index 24c81fcb..337d3420 100644 --- a/backend/apps/inventory/middleware.py +++ b/backend/apps/inventory/middleware.py @@ -72,6 +72,14 @@ def _has_hmac_credentials(self, request): ) def _is_protected_path(self, request): + # A subscribable calendar client cannot produce SpaceWorks HMAC headers. The + # 256-bit, independently rate-limited feed token is the authentication for this + # one route, so applying the frontend-client gate would make every subscription + # fail when API_CLIENT_AUTH_REQUIRED is enabled. + from apps.events.middleware import is_calendar_feed_bearer_path + + if is_calendar_feed_bearer_path(request.path_info): + return False return any( request.path.startswith(p) for p in settings.HMAC_PROTECTED_PATH_PREFIXES ) diff --git a/backend/apps/inventory/public_image_metadata.py b/backend/apps/inventory/public_image_metadata.py index a68e3099..e06e12ca 100644 --- a/backend/apps/inventory/public_image_metadata.py +++ b/backend/apps/inventory/public_image_metadata.py @@ -7,9 +7,10 @@ def public_image_key_in_use( makerspace_id, object_key, *, product_id=None, machine_id=None, event_id=None, + series_id=None, profile_id=None, project_id=None, makerspace_field="", ): - from apps.events.models import Event + from apps.events.models import Event, EventSeries from apps.inventory.models import InventoryProduct from apps.machines.models import Machine from apps.makerspaces.models import Makerspace, MemberProfile, MemberProject @@ -33,6 +34,11 @@ def public_image_key_in_use( events = events.exclude(pk=event_id) if events.exists(): return True + series = EventSeries.objects.filter(makerspace_id=makerspace_id, image_key=object_key) + if series_id is not None: + series = series.exclude(pk=series_id) + if series.exists(): + return True profiles = MemberProfile.objects.filter( membership__makerspace_id=makerspace_id, avatar_key=object_key ) diff --git a/backend/apps/machines/service_reports.py b/backend/apps/machines/service_reports.py index fa39f0fa..7e7d7891 100644 --- a/backend/apps/machines/service_reports.py +++ b/backend/apps/machines/service_reports.py @@ -74,7 +74,7 @@ def build_printer_service_report(makerspace_id, *, limit=None, date_range=None, It is intentionally a report-registry builder seam, not a printer endpoint: the generic service report remains unchanged for non-printer machines. """ - ids = scoped_ids(makerspace_id, "machine_service") + ids = scoped_ids(makerspace_id, "printing") aggregate = makerspace_id is None terminal = Q(status__in=COMPLETED) | Q(status=MachineServiceRequest.Status.FAILED) printer_type = resolve_global_printer_type() diff --git a/backend/apps/machines/service_reports_views.py b/backend/apps/machines/service_reports_views.py index 68601877..b245dd80 100644 --- a/backend/apps/machines/service_reports_views.py +++ b/backend/apps/machines/service_reports_views.py @@ -46,12 +46,14 @@ def get(self, request, makerspace_id, *args, **kwargs): # uniform 403 and never leaks its module state via a 400-vs-403 difference. if not rbac.can(request.user, rbac.Action.MANAGE_MACHINES, makerspace_id): raise PermissionDenied() + require_module(makerspace_id, "reports") require_module(makerspace_id, "machine_service") # The report carries per-machine hours, consumption and payment snapshots, so it # is narrowed to the machines this role actually runs. An exempt actor (space # manager, superadmin) resolves to an empty filter and sees the whole lab. machine_scope = role_scope.manage_scope_for(request.user, makerspace_id) if request.query_params.get("machine_type") == "3d_printer": + require_module(makerspace_id, "printing") result = build_printer_service_report( makerspace_id, date_range=_date_range(request), machine_scope=machine_scope ) @@ -67,6 +69,8 @@ def get(self, request, *args, **kwargs): if not _is_superadmin(request.user): raise PermissionDenied() if request.query_params.get("machine_type") == "3d_printer": + # This aggregate cannot use a per-makerspace module gate; the builder scopes + # its rows to makerspaces with printing enabled instead. result = build_printer_service_report(None, date_range=_date_range(request)) return Response(PrinterServiceReportSerializer({"printer_metrics": result.records}).data) return Response(MachineServiceReportSerializer(report_sections(build_machine_service_report(None, date_range=_date_range(request)))).data) diff --git a/backend/apps/machines/views_public_printer_service.py b/backend/apps/machines/views_public_printer_service.py index 4086e15c..da9dee3c 100644 --- a/backend/apps/machines/views_public_printer_service.py +++ b/backend/apps/machines/views_public_printer_service.py @@ -13,14 +13,22 @@ from apps.machines.public_printer_service_serializers import PublicPrinterPoolSerializer, PublicPrinterQueueSerializer, PublicPrinterStatusSerializer, PublicPrinterSubmitResponseSerializer, PublicPrinterSubmitSerializer, PublicPrinterUploadSerializer from apps.makerspaces.lookup import get_public_makerspace from apps.makerspaces.platform import module_enabled -from apps.presence.guard import require_active_member_presence +from apps.machines.views_public_service import require_public_machine_requester from apps.machines.permissions import IsActiveRequester def _require_printer_module(makerspace): - if not module_enabled(makerspace, "machine_service"): + """The public printer surface belongs to `printing`, which is separately optional. + + This gated on `machine_service` instead, so the RECOMMENDED profile -- which ships + `machine_service` ON and `printing` OFF -- exposed the entire public printer API for a + module the operator had explicitly left off. `printing` already declares + `requires_modules=("machine_service",)`, so the dependency runs the other way and + checking `printing` alone is both necessary and sufficient. + """ + if not module_enabled(makerspace, "printing"): from rest_framework.exceptions import ValidationError - raise ValidationError({"module": "machine service is disabled for this makerspace."}) + raise ValidationError({"module": "printing is disabled for this makerspace."}) class PublicPrinterQueuesView(APIView): @@ -56,7 +64,7 @@ class PublicPrinterUploadView(APIView): def post(self, request, makerspace_slug): makerspace = get_public_makerspace(makerspace_slug) _require_printer_module(makerspace) - require_active_member_presence(request.user, makerspace) + require_public_machine_requester(request.user, makerspace) serializer = PublicPrinterUploadSerializer(data=request.data) serializer.is_valid(raise_exception=True) return Response(stage_upload(makerspace, serializer.validated_data, request.user), status=status.HTTP_201_CREATED) @@ -74,7 +82,7 @@ def post(self, request, makerspace_slug): if str(request.data.get("website", "")).strip(): decoy = SimpleNamespace(public_token=uuid.uuid4(), status=MachineServiceRequest.Status.PENDING) return Response(PublicPrinterSubmitResponseSerializer(decoy).data, status=status.HTTP_201_CREATED) - require_active_member_presence(request.user, makerspace) + require_public_machine_requester(request.user, makerspace) serializer = PublicPrinterSubmitSerializer(data=request.data) serializer.is_valid(raise_exception=True) row = submit_request(makerspace, serializer.validated_data, request.user) diff --git a/backend/apps/machines/views_public_service.py b/backend/apps/machines/views_public_service.py index de37242d..13e7abd1 100644 --- a/backend/apps/machines/views_public_service.py +++ b/backend/apps/machines/views_public_service.py @@ -18,7 +18,8 @@ ) from apps.makerspaces.guards import require_module from apps.makerspaces.lookup import get_public_makerspace -from apps.presence.guard import require_active_member_presence +from apps.makerspaces.platform import module_enabled +from apps.presence.guard import require_active_account, require_active_member_presence SERVICE_SUBMIT_ERRORS = { @@ -31,6 +32,23 @@ } +def require_public_machine_requester(user, makerspace): + """Same identity contract as the public BORROW request, for the same reason. + + A machine-service or print request is a PROPOSAL that staff act on -- the requester is + asking for a job to be run, not operating the machine themselves -- so it must not + hard-require a MakerspaceMembership row. Doing so made the surface dead for every + ordinary account on the default `recommended` profile, which ships `machine_service` + with `membership` off. + + Mirrors `apps/hardware_requests/public_views.py`: membership, waiver and presence when + the community module is installed; an active account when it is not, because waiver + acceptance lives on MakerspaceMembership and cannot be recorded at all without it. + """ + if module_enabled(makerspace, "membership"): + return require_active_member_presence(user, makerspace) + return require_active_account(user, makerspace) + class PublicMachineServiceSubmitView(APIView): permission_classes = [IsAuthenticated] throttle_classes = [MemberPrincipalRateThrottle] @@ -45,7 +63,7 @@ class PublicMachineServiceSubmitView(APIView): def post(self, request, makerspace_slug): makerspace = get_public_makerspace(makerspace_slug) require_module(makerspace, "machine_service") - require_active_member_presence(request.user, makerspace) + require_public_machine_requester(request.user, makerspace) if _honeypot_filled(request.data): decoy = SimpleNamespace( public_token=uuid.uuid4(), status=MachineServiceRequest.Status.PENDING, diff --git a/backend/apps/makerspaces/capabilities.py b/backend/apps/makerspaces/capabilities.py index 504969f5..f864c4ae 100644 --- a/backend/apps/makerspaces/capabilities.py +++ b/backend/apps/makerspaces/capabilities.py @@ -94,6 +94,12 @@ class FeatureDefinition: # reactivated delegated access without anybody opting in again. requires_modules=("maintenance", "machines"), ), + FeatureDefinition( + "events.offline_checkin", "events", "Offline & station check-in", + "Store a minimal expiring roster on event devices and enable event-scoped " + "PIN stations.", + default_enabled=False, + ), ) FEATURES = {definition.key: definition for definition in FEATURE_DEFINITIONS} # A feature's parent/required modules are validated against the module registry diff --git a/backend/apps/makerspaces/lifecycle_purge.py b/backend/apps/makerspaces/lifecycle_purge.py index 63fdfcb2..aa5d45ec 100644 --- a/backend/apps/makerspaces/lifecycle_purge.py +++ b/backend/apps/makerspaces/lifecycle_purge.py @@ -66,8 +66,20 @@ def _delete_object_graph(makerspace): AuditSigningKeyRotationEvent, ) from apps.boxes.models import Box, BoxScan, QrCode, QrScanEvent - from apps.evidence.models import EvidencePhoto - from apps.events.models import EventOrganizer, EventRegistration + from apps.evidence.models import EvidencePhoto, EvidenceRetentionPolicy + from apps.events.models import ( + Event, + EventAttendanceCertificate, + EventCheckInEvent, + EventCheckInStationCredential, + EventCollaborator, + EventFeedbackResponse, + EventFeedbackSurvey, + EventOrganizer, + EventRegistration, + EventSeries, + EventSeriesCollaborator, + ) from apps.hardware_requests.models import HardwareRequest from apps.hardware_requests.models import PublicToolLoan, RequesterAccountability from apps.hardware_requests.models import ReturnEvent @@ -128,6 +140,7 @@ def _delete_object_graph(makerspace): ReturnEvent.objects.filter(makerspace=makerspace).delete() HardwareRequest.objects.filter(makerspace=makerspace).delete() EvidencePhoto.objects.filter(makerspace=makerspace).delete() + EvidenceRetentionPolicy.objects.filter(makerspace=makerspace).delete() QrCode.objects.filter(makerspace=makerspace).delete() # Machines + their maintenance/children/consumables cascade from the Machine @@ -192,10 +205,27 @@ def _delete_object_graph(makerspace): # Organizer attribution is owned by the hosted event, never by whichever # makerspaces happen to be linked to its deployment-global organization. EventOrganizer.objects.filter(event__makerspace=makerspace).delete() + EventAttendanceCertificate.objects.filter( + registration__event__makerspace=makerspace + ).delete() + EventFeedbackResponse.objects.filter( + survey__event__makerspace=makerspace + ).delete() + EventFeedbackSurvey.objects.filter(event__makerspace=makerspace).delete() + EventCheckInStationCredential.objects.filter( + event__makerspace=makerspace + ).delete() + EventCheckInEvent.objects.filter(makerspace=makerspace).delete() # Registrations otherwise survive until the final makerspace cascade. Delete # hosted rows explicitly so any PROTECT FK they gain cannot fail that cascade; # collaborator provenance must not remove another host's registration. EventRegistration.objects.filter(event__makerspace=makerspace).delete() + EventCollaborator.objects.filter( + source_series_collaboration__makerspace=makerspace + ).exclude(event__makerspace=makerspace).delete() + EventSeriesCollaborator.objects.filter(makerspace=makerspace).delete() + Event.objects.filter(makerspace=makerspace).delete() + EventSeries.objects.filter(makerspace=makerspace).delete() # Encryption key rows carry a PROTECT FK + a no-delete ORM guard/trigger, so # raw-delete them inside this authorized purge context (session_replication_role # =replica self-host, or app.allow_immutable_delete GUC managed) before the diff --git a/backend/apps/makerspaces/management/commands/recompute_storage.py b/backend/apps/makerspaces/management/commands/recompute_storage.py index dd91f3ea..4fec7135 100644 --- a/backend/apps/makerspaces/management/commands/recompute_storage.py +++ b/backend/apps/makerspaces/management/commands/recompute_storage.py @@ -6,7 +6,7 @@ from apps.data_export.models import DataExportJob from apps.events.models import Event from apps.evidence import storage as evidence_storage -from apps.evidence.models import EvidencePhoto +from apps.evidence.models import EvidenceObjectRetentionState, EvidencePhoto from apps.inventory import public_image_storage from apps.inventory.models import InventoryProduct from apps.machines import storage as machine_storage @@ -45,7 +45,16 @@ def handle(self, *args, **options): for makerspace in makerspaces.order_by("pk"): try: values = { - "evidence": self._sum(EvidencePhoto.objects.filter(makerspace=makerspace).values_list("object_key", "size_bytes"), evidence_storage.object_size, True), + "evidence": self._sum( + EvidencePhoto.objects.filter(makerspace=makerspace) + .exclude( + object_retention_state__status= + EvidenceObjectRetentionState.Status.EXPIRED + ) + .values_list("object_key", "size_bytes"), + evidence_storage.object_size, + True, + ), "print_files": self._sum(ServiceRequestFile.objects.filter(makerspace=makerspace).values_list("object_key", "size_bytes"), machine_storage.object_size, True), "public_images": self._sum(((key, None) for key in self._public_image_keys(makerspace)), public_image_storage.object_size), "machine_documents": self._sum(((key, None) for key in MachineDocument.objects.filter(machine__makerspace=makerspace).values_list("object_key", flat=True)), machine_storage.object_size), diff --git a/backend/apps/makerspaces/member_activity_serializers.py b/backend/apps/makerspaces/member_activity_serializers.py index 79a93bac..a63ce7dc 100644 --- a/backend/apps/makerspaces/member_activity_serializers.py +++ b/backend/apps/makerspaces/member_activity_serializers.py @@ -36,6 +36,9 @@ class MemberEventRegistrationActivitySerializer(serializers.Serializer): ends_at = serializers.DateTimeField() status = serializers.CharField() waitlist_position = serializers.IntegerField(allow_null=True) + feedback_available = serializers.BooleanField() + feedback_path = serializers.CharField(allow_null=True) + certificate = serializers.DictField(allow_null=True) class MemberMachineServiceActivitySerializer(serializers.Serializer): diff --git a/backend/apps/makerspaces/member_activity_service.py b/backend/apps/makerspaces/member_activity_service.py index 19996b4f..bdb5a391 100644 --- a/backend/apps/makerspaces/member_activity_service.py +++ b/backend/apps/makerspaces/member_activity_service.py @@ -108,6 +108,8 @@ def _event_registrations(makerspace, member): from apps.events.member_history import registrations_for_space from apps.events.models import EventRegistration + now = timezone.now() + waitlisted_before = EventRegistration.objects.filter( event_id=OuterRef("event_id"), status=EventRegistration.Status.WAITLISTED, ).filter( @@ -119,7 +121,9 @@ def _event_registrations(makerspace, member): # "which registrations does this member hold here" must have exactly one answer: # when that predicate widens, a second copy here would make this endpoint and the # profile disagree about the same member. - rows = registrations_for_space(makerspace, member).select_related("event").annotate( + rows = registrations_for_space(makerspace, member).select_related("event").prefetch_related( + "event__feedback_survey", "feedback_responses__certificates", + ).annotate( waitlist_position=Subquery(waitlisted_before, output_field=IntegerField()) ).only( "id", "checkin_token", "status", "created_at", "event__title", @@ -165,13 +169,46 @@ def usable_token(row): and row.event.status in CHECKABLE_EVENT_STATUSES ) - return [{ - "registration_id": row.id, - "checkin_token": str(row.checkin_token) if usable_token(row) else None, - "event_title": row.event.title, "starts_at": row.event.starts_at, - "ends_at": row.event.ends_at, "status": row.status, - "waitlist_position": row.waitlist_position if row.status == EventRegistration.Status.WAITLISTED else None, - } for row in ordered] + result = [] + for row in ordered: + survey = getattr(row.event, "feedback_survey", None) + response = next(iter(row.feedback_responses.all()), None) + certificate = None + if response is not None: + certificate = max( + response.certificates.all(), key=lambda item: item.revision, default=None, + ) + feedback_available = bool( + survey + and survey.is_open + and row.event.ends_at <= now + and row.event.status in ("published", "completed") + and ( + not survey.certificate_enabled + or row.status == EventRegistration.Status.ATTENDED + ) + ) + result.append({ + "registration_id": row.id, + "checkin_token": str(row.checkin_token) if usable_token(row) else None, + "event_title": row.event.title, "starts_at": row.event.starts_at, + "ends_at": row.event.ends_at, "status": row.status, + "waitlist_position": row.waitlist_position if row.status == EventRegistration.Status.WAITLISTED else None, + "feedback_available": feedback_available, + "feedback_path": ( + f"/member/makerspaces/{makerspace.pk}/" + f"event-registrations/{row.pk}/feedback/" + if feedback_available else None + ), + "certificate": ( + None if certificate is None else { + "id": certificate.pk, + "status": certificate.status, + "revision": certificate.revision, + } + ), + }) + return result def _machine_service_requests(makerspace_id, member): diff --git a/backend/apps/makerspaces/module_profiles.py b/backend/apps/makerspaces/module_profiles.py index 9442f33a..c27e306f 100644 --- a/backend/apps/makerspaces/module_profiles.py +++ b/backend/apps/makerspaces/module_profiles.py @@ -18,6 +18,11 @@ # Core plus what a makerspace lending hardware realistically needs on day one: # the inventory lifecycle, reporting, and machines. +# +# It ships `machine_service` WITHOUT `membership`, which is only coherent because that +# module's public submit falls back to an account-only guard exactly as the public borrow +# request does. If you ever make it hard-require a membership row again, this profile +# breaks silently -- the surface stays enabled and refuses every ordinary account. _RECOMMENDED_EXTRAS = frozenset({ "guest_handover", "bulk_import", "containers", "stock_transfers", "stocktake", "reports", "qr_print_batches", "asset_units", "machines", "machine_service", diff --git a/backend/apps/makerspaces/module_purge.py b/backend/apps/makerspaces/module_purge.py index b518c61a..07120862 100644 --- a/backend/apps/makerspaces/module_purge.py +++ b/backend/apps/makerspaces/module_purge.py @@ -129,6 +129,22 @@ def _purge(makerspace, plan, cursor): counts["pii_blind_index"] = deleted outcome = plan.delete(makerspace, cursor) counts.update(outcome) + # Derived facts follow their source module during an explicit destructive purge. + # Automatic retention never reaches this path; preserving rollups there is the + # historical-report guarantee. A module purge is different: retaining its derived + # tenant data would violate the operator's explicit deletion boundary. + from apps.operations.models import ReportMetricRollup, ReportRollupCursor + + rollups = ReportMetricRollup.objects.filter( + makerspace=makerspace, source_module=plan.key + ).delete()[0] + cursors = ReportRollupCursor.objects.filter( + makerspace=makerspace, source_module=plan.key + ).delete()[0] + if rollups: + counts["report_metric_rollups"] = rollups + if cursors: + counts["report_rollup_cursors"] = cursors # Read the attribute directly. Every collector returns a ``PurgeResult``, so a # ``getattr`` default would only ever hide a future collector that forgot to report # its labels -- and the symptom of that is provenance silently outliving the rows it diff --git a/backend/apps/makerspaces/module_purge_collectors.py b/backend/apps/makerspaces/module_purge_collectors.py index d7ea8f50..bfa6a4d4 100644 --- a/backend/apps/makerspaces/module_purge_collectors.py +++ b/backend/apps/makerspaces/module_purge_collectors.py @@ -15,7 +15,6 @@ which the management command imports at load time; importing app models at module 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_single_model import ( _counts, @@ -31,8 +30,31 @@ def events_delete(makerspace, cursor): - from apps.events.models import Event, EventCollaborator, EventRegistration + from apps.events.models import ( + Event, + EventAttendanceCertificate, + EventCheckInEvent, + EventCheckInStationCredential, + EventCollaborator, + EventFeedbackResponse, + EventFeedbackSurvey, + EventRegistration, + EventSeries, + EventSeriesCollaborator, + MemberCalendarFeed, + ) + feeds, feed_labels = _delete( + MemberCalendarFeed.objects.filter(membership__makerspace=makerspace) + ) + projected_collaborations, projected_collaboration_labels = _delete( + EventCollaborator.objects.filter( + source_series_collaboration__makerspace=makerspace + ).exclude(event__makerspace=makerspace) + ) + series_collaborations, series_collaboration_labels = _delete( + EventSeriesCollaborator.objects.filter(makerspace=makerspace) + ) collaborations, collaboration_labels = _delete( EventCollaborator.objects.filter(makerspace=makerspace) ) @@ -42,25 +64,79 @@ def events_delete(makerspace, cursor): provenance_cleared = EventRegistration.objects.filter( registered_via_makerspace=makerspace, ).exclude(event__makerspace=makerspace).update(registered_via_makerspace=None) + station_credentials, station_credential_labels = _delete( + EventCheckInStationCredential.objects.filter(event__makerspace=makerspace) + ) + certificates, certificate_labels = _delete( + EventAttendanceCertificate.objects.filter( + registration__event__makerspace=makerspace + ) + ) + responses, response_labels = _delete( + EventFeedbackResponse.objects.filter(survey__event__makerspace=makerspace) + ) + surveys, survey_labels = _delete( + EventFeedbackSurvey.objects.filter(event__makerspace=makerspace) + ) + checkins, checkin_labels = _delete( + EventCheckInEvent.objects.filter(makerspace=makerspace) + ) registrations, registration_labels = _delete( EventRegistration.objects.filter(event__makerspace=makerspace) ) events, event_labels = _delete(Event.objects.filter(makerspace=makerspace)) + series, series_labels = _delete(EventSeries.objects.filter(makerspace=makerspace)) return _counts( - model_labels=collaboration_labels | registration_labels | event_labels, + model_labels=( + collaboration_labels | certificate_labels | response_labels + | survey_labels | checkin_labels | registration_labels | event_labels + | projected_collaboration_labels | series_collaboration_labels | series_labels + | feed_labels | station_credential_labels + ), + event_series_collaboration_projections=projected_collaborations, + event_series_collaborations=series_collaborations, event_collaborations=collaborations, + event_certificates=certificates, + event_feedback_responses=responses, + event_feedback_surveys=surveys, + event_check_in_events=checkins, + event_check_in_station_credentials=station_credentials, event_registration_provenance_cleared=provenance_cleared, event_registrations=registrations, events=events, + event_series=series, + event_calendar_feeds=feeds, ) def events_public_images(makerspace): - from apps.events.models import Event + from apps.events.models import Event, EventSeries - return list( - Event.objects.filter(makerspace=makerspace).values_list("image_key", flat=True) - ) + return [ + *Event.objects.filter(makerspace=makerspace).values_list("image_key", flat=True), + *EventSeries.objects.filter(makerspace=makerspace).values_list("image_key", flat=True), + ] + + +def events_private_keys(makerspace, add): + from apps.events.models import EventAttendanceCertificate + + for key in EventAttendanceCertificate.objects.filter( + registration__event__makerspace=makerspace + ).values_list("object_key", flat=True): + add(key) + + +def events_private_key_sizes(makerspace): + from apps.events.models import EventAttendanceCertificate + + return { + key: size + for key, size in EventAttendanceCertificate.objects.filter( + registration__event__makerspace=makerspace + ).values_list("object_key", "size_bytes") + if key + } def maintenance_delete(makerspace, cursor): diff --git a/backend/apps/makerspaces/module_purge_plans.py b/backend/apps/makerspaces/module_purge_plans.py index b5e8edbc..9f8bd92a 100644 --- a/backend/apps/makerspaces/module_purge_plans.py +++ b/backend/apps/makerspaces/module_purge_plans.py @@ -25,6 +25,8 @@ bookings_public_images, discord_destinations_delete, events_delete, + events_private_key_sizes, + events_private_keys, events_public_images, mattermost_destinations_delete, machine_service_delete, @@ -89,8 +91,19 @@ class ModulePurgePlan: PLANS = ( ModulePurgePlan( - "events", "Events and their registrations.", events_delete, - pii_labels=("events.EventRegistration",), + # The one-line description is what the purge confirmation shows an operator, so it + # has to name what actually goes: registrations are only the first layer. + "events", + "Events, series, registrations, check-in history, feedback, certificates and " + "calendar feeds.", + events_delete, + pii_labels=( + "events.EventRegistration", + "events.EventFeedbackResponse", + "events.EventAttendanceCertificate", + ), + private_keys=events_private_keys, + private_key_sizes=events_private_key_sizes, public_image_keys=events_public_images, ), ModulePurgePlan( diff --git a/backend/apps/makerspaces/module_registry.py b/backend/apps/makerspaces/module_registry.py index 1fd814f8..78ef8691 100644 --- a/backend/apps/makerspaces/module_registry.py +++ b/backend/apps/makerspaces/module_registry.py @@ -110,15 +110,28 @@ "machines", "Machines", "Machine registry, operators, usage and documents.", "machines", GUARD, group=GROUP_MACHINES, ), + # NOT dependent on `membership`, deliberately. Its public submit is a PROPOSAL that + # staff act on -- "please make this for me", not the requester operating the machine -- + # so it takes the same shape as the public borrow request: membership when that module + # is installed, an active account otherwise. Declaring the dependency instead would + # have dragged `membership` into the default profile and silently made ordinary + # borrowing require a membership, waiver and presence session. ModuleDefinition( "machine_service", "Machine service", "Machine service requests and consoles.", "machines", GUARD, group=GROUP_MACHINES, frontend_workflows=("machine_service_requests",), ), + # Requires `membership`: registration resolves through `require_active_member`, and the + # host waiver it records lives on MakerspaceMembership. Without that module the public + # listing renders and every registration refuses. ModuleDefinition( "events", "Events", "Event scheduling and registrations.", "events", GUARD, group=GROUP_EVENTS, + requires_modules=("membership",), ), + # Requires `membership`: public self-booking calls `require_active_member_presence`, so + # without a MakerspaceMembership row the catalogue lists and the booking mutation dies. ModuleDefinition( "bookings", "Bookings", "Resource booking and public self-booking.", "bookings", GUARD, group=GROUP_BOOKINGS, + requires_modules=("membership",), ), ModuleDefinition( "maintenance", "Maintenance", "Maintenance schedules and work orders.", diff --git a/backend/apps/makerspaces/origin_scope_model_lookups.py b/backend/apps/makerspaces/origin_scope_model_lookups.py new file mode 100644 index 00000000..666ed22a --- /dev/null +++ b/backend/apps/makerspaces/origin_scope_model_lookups.py @@ -0,0 +1,138 @@ +"""Static model-backed origin scopes, split from request resolution logic.""" + +BASE_MODEL_LOOKUPS = { + 'admin-membership-request-approve': ('makerspaces.MembershipRequest', 'makerspace_id'), + 'admin-membership-request-revoke': ('makerspaces.MembershipRequest', 'makerspace_id'), + 'admin-membership-revoke-m2': ('makerspaces.MakerspaceMembership', 'makerspace_id'), + 'admin-membership-role-m2': ('makerspaces.MakerspaceMembership', 'makerspace_id'), + 'admin-membership-capabilities': ('makerspaces.MakerspaceMembership', 'makerspace_id'), + 'admin-membership-revoke': ('makerspaces.MakerspaceMembership', 'makerspace_id'), + 'admin-presence-sessions-current': ('makerspaces.Makerspace', 'id'), + 'admin-maintenance-schedule-detail': ('maintenance.MaintenanceSchedule', 'machine__makerspace_id'), + 'admin-maintenance-schedule-deactivate': ('maintenance.MaintenanceSchedule', 'machine__makerspace_id'), + 'admin-maintenance-log-document-presign': ('maintenance.MaintenanceLog', 'machine__makerspace_id'), + 'admin-maintenance-log-document-finalize': ('maintenance.MaintenanceLog', 'machine__makerspace_id'), + 'admin-maintenance-log-document-url': ('maintenance.MaintenanceLogDocument', 'log__machine__makerspace_id'), + 'admin-maintenance-log-document-detail': ('maintenance.MaintenanceLogDocument', 'log__machine__makerspace_id'), + 'admin-bookable-space-detail': ('bookings.BookableSpace', 'makerspace_id'), + 'admin-bookable-space-booking-rules': ('bookings.BookableSpace', 'makerspace_id'), + 'admin-bookable-space-deactivate': ('bookings.BookableSpace', 'makerspace_id'), + 'admin-bookable-space-image-presign': ('bookings.BookableSpace', 'makerspace_id'), + 'admin-bookable-space-image-finalize': ('bookings.BookableSpace', 'makerspace_id'), + 'admin-bookable-space-image-delete': ('bookings.BookableSpace', 'makerspace_id'), + 'admin-space-booking-list': ('bookings.BookableSpace', 'makerspace_id'), + 'admin-booking-approve': ('bookings.Booking', 'space__makerspace_id'), + 'admin-booking-reject': ('bookings.Booking', 'space__makerspace_id'), + 'admin-booking-cancel': ('bookings.Booking', 'space__makerspace_id'), + 'admin-booking-complete': ('bookings.Booking', 'space__makerspace_id'), + 'admin-booking-no-show': ('bookings.Booking', 'space__makerspace_id'), + 'admin-event-detail': ('events.Event', 'makerspace_id'), + 'admin-event-series-detail': ('events.EventSeries', 'makerspace_id'), + 'admin-event-series-occurrences': ('events.EventSeries', 'makerspace_id'), + 'admin-event-series-publish': ('events.EventSeries', 'makerspace_id'), + 'admin-event-series-cancel': ('events.EventSeries', 'makerspace_id'), + 'admin-event-series-complete': ('events.EventSeries', 'makerspace_id'), + 'admin-event-series-extend': ('events.EventSeries', 'makerspace_id'), + 'admin-event-series-image': ('events.EventSeries', 'makerspace_id'), + 'admin-event-series-collaborators': ('events.EventSeries', 'makerspace_id'), + 'admin-event-series-collaboration-remove': ('events.EventSeriesCollaborator', 'series__makerspace_id'), + 'admin-event-series-collaboration-respond': ('events.EventSeriesCollaborator', 'makerspace_id'), + 'admin-event-organizers': ('events.Event', 'makerspace_id'), + 'admin-event-publish': ('events.Event', 'makerspace_id'), + 'admin-event-cancel': ('events.Event', 'makerspace_id'), + 'admin-event-complete': ('events.Event', 'makerspace_id'), + 'admin-event-registration-list': ('events.Event', 'makerspace_id'), + 'admin-event-badge-template': ('events.Event', 'makerspace_id'), + 'admin-event-badges-pdf': ('events.Event', 'makerspace_id'), + 'admin-event-feedback-survey': ('events.Event', 'makerspace_id'), + 'admin-event-feedback-survey-open': ('events.Event', 'makerspace_id'), + 'admin-event-feedback-survey-close': ('events.Event', 'makerspace_id'), + 'admin-event-feedback-responses': ('events.Event', 'makerspace_id'), + 'admin-event-check-in-resolve': ('events.Event', 'makerspace_id'), + 'admin-event-check-in-offline-roster': ('events.Event', 'makerspace_id'), + 'admin-event-check-in-offline-sync': ('events.Event', 'makerspace_id'), + 'admin-event-check-in-station': ('events.Event', 'makerspace_id'), + 'admin-event-check-in-station-rotate': ('events.Event', 'makerspace_id'), + 'admin-event-check-in-station-reveal': ('events.Event', 'makerspace_id'), + 'admin-event-registration-mark-attended': ('events.EventRegistration', 'event__makerspace_id'), + 'admin-event-registration-correct-attendance': ('events.EventRegistration', 'event__makerspace_id'), + 'admin-event-certificate-download': ('events.EventAttendanceCertificate', 'registration__event__makerspace_id'), + 'admin-event-certificate-revoke': ('events.EventAttendanceCertificate', 'registration__event__makerspace_id'), + 'admin-event-certificate-reissue': ('events.EventAttendanceCertificate', 'registration__event__makerspace_id'), + 'admin-event-registration-approve': ('events.EventRegistration', 'event__makerspace_id'), + 'admin-event-registration-reject': ('events.EventRegistration', 'event__makerspace_id'), + 'admin-event-registration-promote': ('events.EventRegistration', 'event__makerspace_id'), + 'admin-event-collaborators': ('events.Event', 'makerspace_id'), + 'admin-event-collaboration-remove': ('events.EventCollaborator', 'event__makerspace_id'), + 'admin-event-collaboration-respond': ('events.EventCollaborator', 'makerspace_id'), + 'admin-machine-operator-candidates': ('machines.Machine', 'makerspace_id'), + 'admin-machine-publicity': ('machines.Machine', 'makerspace_id'), + 'makerspace-verify-domain': ('makerspaces.Makerspace', 'id'), + 'admin-inventory-detail': ('inventory.InventoryProduct', 'makerspace_id'), + 'admin-inventory-image': ('inventory.InventoryProduct', 'makerspace_id'), + 'admin-inventory-asset-detail': ('inventory.InventoryAsset', 'makerspace_id'), + 'admin-machine-warranty': ('machines.Machine', 'makerspace_id'), + 'admin-warranty-document-presign': ('warranty.Warranty', 'makerspace_id'), + 'admin-warranty-documents': ('warranty.Warranty', 'makerspace_id'), + 'admin-warranty-document-url': ('warranty.WarrantyDocument', 'warranty__makerspace_id'), + 'admin-warranty-document-detail': ('warranty.WarrantyDocument', 'warranty__makerspace_id'), + 'admin-inventory-adjust-quantity': ('inventory.InventoryProduct', 'makerspace_id'), + 'admin-inventory-lending-history': ('inventory.InventoryProduct', 'makerspace_id'), + 'admin-inventory-chain-of-custody': ('inventory.InventoryProduct', 'makerspace_id'), + 'admin-needs-fix-action': ('inventory.InventoryProduct', 'makerspace_id'), + 'admin-category-detail': ('inventory.Category', 'makerspace_id'), + 'container-detail': ('boxes.Box', 'makerspace_id'), + 'container-move': ('boxes.Box', 'makerspace_id'), + 'container-contents': ('boxes.Box', 'makerspace_id'), + 'container-history': ('boxes.Box', 'makerspace_id'), + 'qr-print': ('boxes.QrCode', 'makerspace_id'), + 'qr-revoke': ('boxes.QrCode', 'makerspace_id'), + 'qr-rebind-target': ('boxes.QrCode', 'makerspace_id'), + 'evidence-detail': ('evidence.EvidencePhoto', 'makerspace_id'), + 'stock-transfer-detail': ('operations.StockTransfer', 'makerspace_id'), + 'stocktake-detail': ('operations.StocktakeSession', 'makerspace_id'), + 'stocktake-count-lines': ('operations.StocktakeSession', 'makerspace_id'), + 'stocktake-resolve-scan': ('operations.StocktakeSession', 'makerspace_id'), + 'stocktake-complete': ('operations.StocktakeSession', 'makerspace_id'), + 'stocktake-approve': ('operations.StocktakeSession', 'makerspace_id'), + 'stocktake-apply-adjustments': ('operations.StocktakeSession', 'makerspace_id'), + 'qr-print-batch-detail': ('operations.QrPrintBatch', 'makerspace_id'), + 'qr-print-batch-items': ('operations.QrPrintBatch', 'makerspace_id'), + 'qr-print-batch-download': ('operations.QrPrintBatch', 'makerspace_id'), + 'direct-loan-return': ('hardware_requests.PublicToolLoan', 'makerspace_id'), + 'problem-report-triage': ('hardware_requests.PublicProblemReport', 'makerspace_id'), + 'to-buy-detail': ('procurement.ToBuyItem', 'makerspace_id'), + 'to-buy-move-to-inventory': ('procurement.ToBuyItem', 'makerspace_id'), + 'to-buy-move-to-printing': ('procurement.ToBuyItem', 'makerspace_id'), + 'to-buy-receipt-presign': ('procurement.ToBuyItem', 'makerspace_id'), + 'to-buy-receipt-list': ('procurement.ToBuyItem', 'makerspace_id'), + 'to-buy-receipt-url': ('procurement.ToBuyReceipt', 'to_buy_item__makerspace_id'), + 'to-buy-receipt-detail': ('procurement.ToBuyReceipt', 'to_buy_item__makerspace_id'), + 'admin-machine-detail': ('machines.Machine', 'makerspace_id'), + 'admin-machine-image': ('machines.Machine', 'makerspace_id'), + 'admin-machine-set-status': ('machines.Machine', 'makerspace_id'), + 'admin-machine-retire': ('machines.Machine', 'makerspace_id'), + 'admin-machine-unretire': ('machines.Machine', 'makerspace_id'), + 'admin-machine-usage': ('machines.Machine', 'makerspace_id'), + 'admin-machine-consumables': ('machines.Machine', 'makerspace_id'), + 'admin-machine-consumable-detail': ('machines.Machine', 'makerspace_id'), + 'admin-machine-consumption-log': ('machines.Machine', 'makerspace_id'), + 'admin-machine-consumable-candidates': ('machines.Machine', 'makerspace_id'), + 'admin-machine-operators': ('machines.Machine', 'makerspace_id'), + 'admin-machine-operator-detail': ('machines.Machine', 'makerspace_id'), + 'admin-machine-document-presign': ('machines.Machine', 'makerspace_id'), + 'admin-machine-documents': ('machines.Machine', 'makerspace_id'), + 'admin-machine-error-logs': ('machines.Machine', 'makerspace_id'), + 'admin-machine-document-url': ('machines.MachineDocument', 'machine__makerspace_id'), + 'admin-machine-document-detail': ('machines.MachineDocument', 'machine__makerspace_id'), + 'admin-machine-service-file-url': ('machines.ServiceRequestFile', 'makerspace_id'), + 'admin-machine-service-file-detail': ('machines.ServiceRequestFile', 'makerspace_id'), + 'admin-machine-service-request-reprint': ('machines.MachineServiceRequest', 'makerspace_id'), + 'admin-machine-service-payment-mark-offline': ('payments.Payment', 'makerspace_id'), + 'admin-machine-service-payment-waive': ('payments.Payment', 'makerspace_id'), +} + +TARGET_SET_LOOKUPS = { + 'admin-user-reset-password': ( + 'makerspaces.MakerspaceMembership', 'user_id', 'makerspace_id'), +} diff --git a/backend/apps/makerspaces/origin_scope_routes.py b/backend/apps/makerspaces/origin_scope_routes.py index 9bbd3e94..a1eaf2bc 100644 --- a/backend/apps/makerspaces/origin_scope_routes.py +++ b/backend/apps/makerspaces/origin_scope_routes.py @@ -1,10 +1,17 @@ from django.apps import apps +from apps.makerspaces.origin_scope_model_lookups import ( + BASE_MODEL_LOOKUPS, + TARGET_SET_LOOKUPS, +) + MAKERSPACE_KWARG_ROUTES = { 'admin-maintenance-schedule-list-create': 'makerspace_id', 'admin-maintenance-log-list-create': 'makerspace_id', 'admin-bookable-space-list-create': 'makerspace_id', 'admin-event-list-create': 'makerspace_id', + 'admin-event-series-list-create': 'makerspace_id', + 'admin-event-series-collaboration-inbox': 'makerspace_id', 'admin-role-capabilities': 'makerspace_id', 'admin-role-list-create': 'makerspace_id', 'admin-role-detail': 'makerspace_id', @@ -57,131 +64,16 @@ 'admin-machine-service-file-finalize', } MODEL_LOOKUPS = { - 'admin-membership-request-approve': ('makerspaces.MembershipRequest', 'makerspace_id'), - 'admin-membership-request-revoke': ('makerspaces.MembershipRequest', 'makerspace_id'), - 'admin-membership-revoke-m2': ('makerspaces.MakerspaceMembership', 'makerspace_id'), - 'admin-membership-role-m2': ('makerspaces.MakerspaceMembership', 'makerspace_id'), - 'admin-membership-capabilities': ('makerspaces.MakerspaceMembership', 'makerspace_id'), - 'admin-membership-revoke': ('makerspaces.MakerspaceMembership', 'makerspace_id'), - 'admin-presence-sessions-current': ('makerspaces.Makerspace', 'id'), - 'admin-maintenance-schedule-detail': ('maintenance.MaintenanceSchedule', 'machine__makerspace_id'), - 'admin-maintenance-schedule-deactivate': ('maintenance.MaintenanceSchedule', 'machine__makerspace_id'), - 'admin-maintenance-log-document-presign': ('maintenance.MaintenanceLog', 'machine__makerspace_id'), - 'admin-maintenance-log-document-finalize': ('maintenance.MaintenanceLog', 'machine__makerspace_id'), - 'admin-maintenance-log-document-url': ('maintenance.MaintenanceLogDocument', 'log__machine__makerspace_id'), - 'admin-maintenance-log-document-detail': ('maintenance.MaintenanceLogDocument', 'log__machine__makerspace_id'), - 'admin-bookable-space-detail': ('bookings.BookableSpace', 'makerspace_id'), - 'admin-bookable-space-booking-rules': ('bookings.BookableSpace', 'makerspace_id'), - 'admin-bookable-space-deactivate': ('bookings.BookableSpace', 'makerspace_id'), - 'admin-bookable-space-image-presign': ('bookings.BookableSpace', 'makerspace_id'), - 'admin-bookable-space-image-finalize': ('bookings.BookableSpace', 'makerspace_id'), - 'admin-bookable-space-image-delete': ('bookings.BookableSpace', 'makerspace_id'), - 'admin-space-booking-list': ('bookings.BookableSpace', 'makerspace_id'), - 'admin-booking-approve': ('bookings.Booking', 'space__makerspace_id'), - 'admin-booking-reject': ('bookings.Booking', 'space__makerspace_id'), - 'admin-booking-cancel': ('bookings.Booking', 'space__makerspace_id'), - 'admin-booking-complete': ('bookings.Booking', 'space__makerspace_id'), - 'admin-booking-no-show': ('bookings.Booking', 'space__makerspace_id'), - 'admin-event-detail': ('events.Event', 'makerspace_id'), - 'admin-event-publish': ('events.Event', 'makerspace_id'), - 'admin-event-cancel': ('events.Event', 'makerspace_id'), - 'admin-event-complete': ('events.Event', 'makerspace_id'), - 'admin-event-registration-list': ('events.Event', 'makerspace_id'), - 'admin-event-check-in-resolve': ('events.Event', 'makerspace_id'), - 'admin-event-registration-mark-attended': ('events.EventRegistration', 'event__makerspace_id'), - 'admin-event-collaborators': ('events.Event', 'makerspace_id'), - # Respond belongs to the collaborator's domain; removal belongs to the host's. - # Resolving respond through the host would make the feature unreachable from the - # collaborator's custom domain, while resolving removal through the collaborator - # would give the host route the wrong origin scope. - 'admin-event-collaboration-remove': ( - 'events.EventCollaborator', 'event__makerspace_id' - ), - 'admin-event-collaboration-respond': ( - 'events.EventCollaborator', 'makerspace_id' - ), - 'admin-machine-operator-candidates': ('machines.Machine', 'makerspace_id'), - 'admin-machine-publicity': ('machines.Machine', 'makerspace_id'), - 'makerspace-verify-domain': ('makerspaces.Makerspace', 'id'), - 'admin-inventory-detail': ('inventory.InventoryProduct', 'makerspace_id'), - 'admin-inventory-image': ('inventory.InventoryProduct', 'makerspace_id'), - 'admin-inventory-asset-detail': ('inventory.InventoryAsset', 'makerspace_id'), - 'admin-machine-warranty': ('machines.Machine', 'makerspace_id'), - 'admin-warranty-document-presign': ('warranty.Warranty', 'makerspace_id'), - 'admin-warranty-documents': ('warranty.Warranty', 'makerspace_id'), - 'admin-warranty-document-url': ('warranty.WarrantyDocument', 'warranty__makerspace_id'), - 'admin-warranty-document-detail': ('warranty.WarrantyDocument', 'warranty__makerspace_id'), - 'admin-inventory-adjust-quantity': ('inventory.InventoryProduct', 'makerspace_id'), - 'admin-inventory-lending-history': ('inventory.InventoryProduct', 'makerspace_id'), - 'admin-inventory-chain-of-custody': ('inventory.InventoryProduct', 'makerspace_id'), - 'admin-needs-fix-action': ('inventory.InventoryProduct', 'makerspace_id'), - 'admin-category-detail': ('inventory.Category', 'makerspace_id'), - 'container-detail': ('boxes.Box', 'makerspace_id'), - 'container-move': ('boxes.Box', 'makerspace_id'), - 'container-contents': ('boxes.Box', 'makerspace_id'), - 'container-history': ('boxes.Box', 'makerspace_id'), - 'qr-print': ('boxes.QrCode', 'makerspace_id'), - 'qr-revoke': ('boxes.QrCode', 'makerspace_id'), - 'qr-rebind-target': ('boxes.QrCode', 'makerspace_id'), - 'evidence-detail': ('evidence.EvidencePhoto', 'makerspace_id'), - 'stock-transfer-detail': ('operations.StockTransfer', 'makerspace_id'), - 'stocktake-detail': ('operations.StocktakeSession', 'makerspace_id'), - 'stocktake-count-lines': ('operations.StocktakeSession', 'makerspace_id'), - 'stocktake-resolve-scan': ('operations.StocktakeSession', 'makerspace_id'), - 'stocktake-complete': ('operations.StocktakeSession', 'makerspace_id'), - 'stocktake-approve': ('operations.StocktakeSession', 'makerspace_id'), - 'stocktake-apply-adjustments': ('operations.StocktakeSession', 'makerspace_id'), - 'qr-print-batch-detail': ('operations.QrPrintBatch', 'makerspace_id'), - 'qr-print-batch-items': ('operations.QrPrintBatch', 'makerspace_id'), - 'qr-print-batch-download': ('operations.QrPrintBatch', 'makerspace_id'), - 'direct-loan-return': ('hardware_requests.PublicToolLoan', 'makerspace_id'), - 'problem-report-triage': ('hardware_requests.PublicProblemReport', 'makerspace_id'), - 'to-buy-detail': ('procurement.ToBuyItem', 'makerspace_id'), - 'to-buy-move-to-inventory': ('procurement.ToBuyItem', 'makerspace_id'), - 'to-buy-move-to-printing': ('procurement.ToBuyItem', 'makerspace_id'), - 'to-buy-receipt-presign': ('procurement.ToBuyItem', 'makerspace_id'), - 'to-buy-receipt-list': ('procurement.ToBuyItem', 'makerspace_id'), - 'to-buy-receipt-url': ('procurement.ToBuyReceipt', 'to_buy_item__makerspace_id'), - 'to-buy-receipt-detail': ('procurement.ToBuyReceipt', 'to_buy_item__makerspace_id'), - 'admin-machine-detail': ('machines.Machine', 'makerspace_id'), - 'admin-machine-image': ('machines.Machine', 'makerspace_id'), - 'admin-machine-set-status': ('machines.Machine', 'makerspace_id'), - 'admin-machine-retire': ('machines.Machine', 'makerspace_id'), - 'admin-machine-unretire': ('machines.Machine', 'makerspace_id'), - 'admin-machine-usage': ('machines.Machine', 'makerspace_id'), - 'admin-machine-consumables': ('machines.Machine', 'makerspace_id'), - 'admin-machine-consumable-detail': ('machines.Machine', 'makerspace_id'), - 'admin-machine-consumption-log': ('machines.Machine', 'makerspace_id'), - 'admin-machine-consumable-candidates': ('machines.Machine', 'makerspace_id'), - 'admin-machine-operators': ('machines.Machine', 'makerspace_id'), - 'admin-machine-operator-detail': ('machines.Machine', 'makerspace_id'), - 'admin-machine-document-presign': ('machines.Machine', 'makerspace_id'), - 'admin-machine-documents': ('machines.Machine', 'makerspace_id'), - 'admin-machine-error-logs': ('machines.Machine', 'makerspace_id'), - 'admin-machine-document-url': ('machines.MachineDocument', 'machine__makerspace_id'), - 'admin-machine-document-detail': ('machines.MachineDocument', 'machine__makerspace_id'), - 'admin-machine-service-file-url': ('machines.ServiceRequestFile', 'makerspace_id'), - 'admin-machine-service-file-detail': ('machines.ServiceRequestFile', 'makerspace_id'), - 'admin-machine-service-request-reprint': ('machines.MachineServiceRequest', 'makerspace_id'), - 'admin-machine-service-payment-mark-offline': ('payments.Payment', 'makerspace_id'), - 'admin-machine-service-payment-waive': ('payments.Payment', 'makerspace_id'), + **BASE_MODEL_LOOKUPS, **{name: ('hardware_requests.HardwareRequest', 'makerspace_id') for name in REQUEST_ACTIONS}, **{name: ('machines.MachineServiceRequest', 'makerspace_id') for name in MACHINE_SERVICE_ACTIONS}, } -# A password belongs to a User, not one membership. Keep this set-valued lookup out of -# MODEL_LOOKUPS so a multi-membership user can never be reduced to one arbitrary tenant. -TARGET_SET_LOOKUPS = { - 'admin-user-reset-password': ( - 'makerspaces.MakerspaceMembership', 'user_id', 'makerspace_id'), -} - def request_route_targets(request, view=None): url_name, targets, invalid, route_recognized = _authoritative_route_targets( request, view ) hints = [] - query = getattr(request, 'query_params', None) if query is None: query = getattr(request, 'GET', {}) @@ -193,7 +85,6 @@ def request_route_targets(request, view=None): invalid = invalid or parsed is None if parsed is not None: hints.append(parsed) - if getattr(request, "method", "GET") not in {"GET", "HEAD", "OPTIONS", "TRACE"}: body = getattr(request, "data", {}) if hasattr(body, "get"): diff --git a/backend/apps/makerspaces/reports_community.py b/backend/apps/makerspaces/reports_community.py new file mode 100644 index 00000000..26689816 --- /dev/null +++ b/backend/apps/makerspaces/reports_community.py @@ -0,0 +1,65 @@ +from django.db.models import Count, Q + +from apps.accounts.models import DeviceGrant, DeviceRefreshFamily, NativeAppRegistration +from apps.makerspaces.models import MakerspaceMembership +from apps.makerspaces.platform import module_enabled +from apps.operations.report_types import ReportResult +from apps.operations.reports_common import apply_range, limited, report_spaces + + +FIELDS = ( + "period", "module_key", "enabled", "activations", "revocations", + "active_accounts", "approved_apps", "active_grants", "revoked_grants", + "reuse_detected", +) + + +def build_community_engagement(makerspace_id, *, limit=None, date_range=None, grain="day"): + aggregate = makerspace_id is None + records = [] + for space in report_spaces(makerspace_id): + membership_enabled = module_enabled(space, "membership") + if membership_enabled: + memberships = MakerspaceMembership.objects.filter(makerspace=space) + activated = apply_range(memberships, "activated_at", date_range).filter(activated_at__isnull=False).count() + revoked = apply_range(memberships, "revoked_at", date_range).filter(revoked_at__isnull=False).count() + _add(records, space.id, aggregate, module_key="membership", enabled=True, + period=_period(date_range, grain), activations=activated, revocations=revoked, + active_accounts=memberships.filter(status="active", user__is_active=True).values("user_id").distinct().count()) + else: + _add(records, space.id, aggregate, module_key="membership", enabled=False, period=_period(date_range, grain)) + accounts_enabled = module_enabled(space, "member_accounts") + if accounts_enabled: + active = MakerspaceMembership.objects.filter(makerspace=space, status="active", user__is_active=True, user__is_walk_in=False).values("user_id").distinct().count() + _add(records, space.id, aggregate, module_key="member_accounts", enabled=True, + period=_period(date_range, grain), active_accounts=active) + else: + _add(records, space.id, aggregate, module_key="member_accounts", enabled=False, period=_period(date_range, grain)) + mobile_enabled = module_enabled(space, "mobile") + if mobile_enabled: + registrations = NativeAppRegistration.objects.filter(makerspace=space) + grants = DeviceGrant.objects.filter(registration__makerspace=space) + reuse = DeviceRefreshFamily.objects.filter(grant__registration__makerspace=space, reuse_detected_at__isnull=False).count() + _add(records, space.id, aggregate, module_key="mobile", enabled=True, + period=_period(date_range, grain), approved_apps=registrations.filter(status="approved").count(), + active_grants=grants.filter(status="active").count(), revoked_grants=grants.filter(status="revoked").count(), + reuse_detected=reuse) + else: + _add(records, space.id, aggregate, module_key="mobile", enabled=False, period=_period(date_range, grain)) + fields = (("makerspace_id",) + FIELDS) if aggregate else FIELDS + return ReportResult(fields, limited(records, limit)) + + +def _period(date_range, grain): + if not date_range or date_range[0] is None: + return None + value = date_range[0].date() + return value.replace(day=1) if grain == "month" else value + + +def _add(records, space_id, aggregate, **values): + row = {field: values.get(field, 0) for field in FIELDS} + row["enabled"] = values.get("enabled", False) + if aggregate: + row["makerspace_id"] = space_id + records.append(row) diff --git a/backend/apps/makerspaces/storage_key_collectors.py b/backend/apps/makerspaces/storage_key_collectors.py index b5d592b2..8fb114fd 100644 --- a/backend/apps/makerspaces/storage_key_collectors.py +++ b/backend/apps/makerspaces/storage_key_collectors.py @@ -3,6 +3,7 @@ def collect_private_object_keys(makerspace, *, include_coordination=True): from apps.evidence.models import EvidencePhoto + from apps.events.models import EventAttendanceCertificate from apps.maintenance.models import MaintenanceLogDocument from apps.machines.models import MachineDocument from apps.machines.service_lifecycle import collect_private_object_keys as collect_service @@ -18,6 +19,7 @@ def add(key): for model, lookup in ( (EvidencePhoto, {"makerspace": makerspace}), + (EventAttendanceCertificate, {"registration__event__makerspace": makerspace}), (WarrantyDocument, {"warranty__makerspace": makerspace}), (ToBuyReceipt, {"to_buy_item__makerspace": makerspace}), (MaintenanceLogDocument, {"log__machine__makerspace": makerspace}), @@ -33,13 +35,13 @@ def add(key): def collect_public_image_keys(makerspace, *, include_coordination=True): from apps.bookings.models import BookableSpace - from apps.events.models import Event + from apps.events.models import Event, EventSeries from apps.inventory.models import InventoryProduct from apps.machines.models import Machine from apps.makerspaces.models import MemberProfile, MemberProject keys = [makerspace.logo_key, makerspace.cover_image_key] - for model in (BookableSpace, Event, InventoryProduct, Machine): + for model in (BookableSpace, Event, EventSeries, InventoryProduct, Machine): keys.extend( model.objects.filter(makerspace=makerspace).values_list("image_key", flat=True) ) diff --git a/backend/apps/object_storage.py b/backend/apps/object_storage.py index 47baba65..d7c0d2c7 100644 --- a/backend/apps/object_storage.py +++ b/backend/apps/object_storage.py @@ -18,7 +18,7 @@ ) -def delete_all_versions(client, *, bucket, key): +def delete_all_versions(client, *, bucket, key, require_version_listing=False): """Delete every retained version and delete marker for one exact key. Some S3-compatible providers do not implement ``ListObjectVersions``. In that @@ -30,7 +30,10 @@ def delete_all_versions(client, *, bucket, key): first_page = client.list_object_versions(**params) except ClientError as exc: code = exc.response.get("Error", {}).get("Code") - if code in UNSUPPORTED_LIST_OBJECT_VERSIONS_ERROR_CODES: + if ( + code in UNSUPPORTED_LIST_OBJECT_VERSIONS_ERROR_CODES + and not require_version_listing + ): logger.warning( "object_version_listing_failed_falling_back", extra={"bucket": bucket, "object_key": key, "error_code": code}, diff --git a/backend/apps/operations/apps.py b/backend/apps/operations/apps.py index a1de8674..f6162546 100644 --- a/backend/apps/operations/apps.py +++ b/backend/apps/operations/apps.py @@ -4,3 +4,6 @@ class OperationsConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" name = "apps.operations" + + def ready(self): + from apps.operations import report_coverage # noqa: F401 diff --git a/backend/apps/operations/management/commands/backfill_report_rollups.py b/backend/apps/operations/management/commands/backfill_report_rollups.py new file mode 100644 index 00000000..678241ed --- /dev/null +++ b/backend/apps/operations/management/commands/backfill_report_rollups.py @@ -0,0 +1,42 @@ +from django.core.management.base import BaseCommand, CommandError +from django.utils.dateparse import parse_datetime + +from apps.audit import services as audit +from apps.makerspaces.models import Makerspace +from apps.operations.report_rollups import finalize_evidence_rollups + + +class Command(BaseCommand): + help = "Backfill append-only report rollups in resumable tenant/day partitions." + + def add_arguments(self, parser): + parser.add_argument("--makerspace", type=int) + parser.add_argument("--start") + parser.add_argument("--through") + + def handle(self, *args, **options): + start = _datetime(options.get("start"), "start") + through = _datetime(options.get("through"), "through") + queryset = Makerspace.objects.order_by("id") + if options.get("makerspace"): + queryset = queryset.filter(pk=options["makerspace"]) + for makerspace in queryset.iterator(chunk_size=50): + changed = finalize_evidence_rollups( + makerspace, start_at=start, through=through, actor=None + ) + audit.record(None, "report.rollup_backfill_completed", makerspace=makerspace, meta={ + "source_module": "evidence_uploads", "report_key": "evidence-compliance", + "start": start.isoformat() if start else None, + "through": through.isoformat() if through else None, + "row_count": changed, + }) + self.stdout.write(f"{makerspace.id}: appended {changed} rollup revisions") + + +def _datetime(value, label): + if not value: + return None + parsed = parse_datetime(value) + if parsed is None: + raise CommandError(f"--{label} must be an ISO-8601 timestamp.") + return parsed diff --git a/backend/apps/operations/management/commands/run_scheduled_tasks.py b/backend/apps/operations/management/commands/run_scheduled_tasks.py index 18ecbed2..5cb59424 100644 --- a/backend/apps/operations/management/commands/run_scheduled_tasks.py +++ b/backend/apps/operations/management/commands/run_scheduled_tasks.py @@ -23,7 +23,7 @@ """ from django.conf import settings -from django.core.management.base import BaseCommand +from django.core.management.base import BaseCommand, CommandError from django.db import transaction from django.utils import timezone @@ -48,6 +48,12 @@ 1, ), ("return-reminders", "apps.hardware_requests.tasks.send_return_reminders_task", 60), + ( + "evidence-object-expiry", + "apps.evidence.tasks.sweep_evidence_retention_task", + 360, + ), + ("extend-event-series", "apps.events.tasks.extend_event_series_task", 60), ("purge-auth-challenges", "apps.accounts.tasks.purge_auth_challenges_task", 24 * 60), # Beat runs this at a fixed hour; the beat-less runner has no wall-clock schedule, so # the cadence is expressed as the interval instead. Daily either way. @@ -64,6 +70,11 @@ "apps.data_export.tasks.purge_expired_exports_task", 24 * 60, ), + ( + "finalize-report-rollups", + "apps.operations.tasks.finalize_report_rollups_task", + 24 * 60, + ), ( "scheduled-deployment-backup", "apps.backup.tasks.scheduled_deployment_backup_task", @@ -119,6 +130,8 @@ SCHEDULED_TASKS = tuple( task for task in SCHEDULED_TASKS if ".tenant_migration." not in task[1] ) +if "events" in settings.TOMBSTONED_APPS: + SCHEDULED_TASKS = tuple(task for task in SCHEDULED_TASKS if ".events." not in task[1]) def _import_task(dotted_path): @@ -138,11 +151,25 @@ def add_arguments(self, parser): help="Skip tasks run more recently than their declared interval.", ) parser.add_argument("--task", help="Run only this task name.") + parser.add_argument( + "--dry-run", + action="store_true", + help="Preview evidence expiry without deleting objects.", + ) + parser.add_argument( + "--batch-size", + type=int, + help="Evidence rows per makerspace (clamped to 1..1000).", + ) def handle(self, *args, **options): from apps.operations.models_scheduling import PeriodicTaskRun only = options.get("task") + if (options["dry_run"] or options["batch_size"] is not None) and only != "evidence-object-expiry": + raise CommandError( + "--dry-run and --batch-size require --task evidence-object-expiry." + ) now = timezone.now() for name, dotted_path, interval_minutes in SCHEDULED_TASKS: if only and name != only: @@ -157,14 +184,22 @@ def handle(self, *args, **options): if options["due_only"] and not row.is_due(now, interval_minutes): self.stdout.write(f"skip {name} (last run {row.last_run_at:%Y-%m-%d %H:%M})") continue - row.last_run_at = now - row.save(update_fields=["last_run_at"]) + # A preview must not postpone the next real sweep under --due-only. + if not options["dry_run"]: + row.last_run_at = now + row.save(update_fields=["last_run_at"]) try: # Called directly, not via .delay(): under eager mode they are the # same thing, and with a broker configured this command should still # do the work rather than queue it behind a worker that may not exist. - _import_task(dotted_path)() + task_options = {} + if name == "evidence-object-expiry": + task_options = { + "dry_run": options["dry_run"], + "batch_size": options["batch_size"], + } + _import_task(dotted_path)(**task_options) except Exception as exc: # noqa: BLE001 - one failing task must not stop the rest with transaction.atomic(): row = PeriodicTaskRun.objects.select_for_update().get(name=name) diff --git a/backend/apps/operations/migrations/0008_report_rollups.py b/backend/apps/operations/migrations/0008_report_rollups.py new file mode 100644 index 00000000..603e163e --- /dev/null +++ b/backend/apps/operations/migrations/0008_report_rollups.py @@ -0,0 +1,78 @@ +from django.db import migrations, models +import django.db.models.deletion +import apps.operations.models_rollups + + +APPEND_ONLY_SQL = """ +CREATE OR REPLACE FUNCTION operations_report_rollup_reject_mutation() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + IF TG_OP = 'DELETE' AND current_setting('app.allow_immutable_delete', true) = 'on' THEN + RETURN OLD; + END IF; + RAISE EXCEPTION 'append-only report metric rollup: % not allowed', TG_OP; +END; +$$; +CREATE TRIGGER operations_report_rollup_no_update +BEFORE UPDATE ON operations_reportmetricrollup +FOR EACH ROW EXECUTE FUNCTION operations_report_rollup_reject_mutation(); +CREATE TRIGGER operations_report_rollup_no_delete +BEFORE DELETE ON operations_reportmetricrollup +FOR EACH ROW EXECUTE FUNCTION operations_report_rollup_reject_mutation(); +""" + +REVERSE_SQL = """ +DROP TRIGGER IF EXISTS operations_report_rollup_no_update ON operations_reportmetricrollup; +DROP TRIGGER IF EXISTS operations_report_rollup_no_delete ON operations_reportmetricrollup; +DROP FUNCTION IF EXISTS operations_report_rollup_reject_mutation(); +""" + + +class Migration(migrations.Migration): + dependencies = [("operations", "0007_spaceworks_cache")] + + operations = [ + migrations.CreateModel( + name="ReportRollupCursor", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("source_module", models.CharField(max_length=64)), + ("rolled_through", models.DateTimeField(blank=True, null=True)), + ("last_success_at", models.DateTimeField(blank=True, null=True)), + ("last_error_code", models.CharField(blank=True, default="", max_length=64)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("makerspace", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="report_rollup_cursors", to="makerspaces.makerspace")), + ], + options={"constraints": [models.UniqueConstraint(fields=("makerspace", "source_module"), name="uniq_report_rollup_cursor")]}, + ), + migrations.CreateModel( + name="ReportMetricRollup", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("source_module", models.CharField(max_length=64)), + ("report_key", models.CharField(max_length=80)), + ("metric_key", models.CharField(max_length=80)), + ("bucket_start", models.DateTimeField()), + ("grain", models.CharField(choices=[("day", "Day"), ("month", "Month")], max_length=8)), + ("dimension_key", models.CharField(max_length=128)), + ("dimensions", models.JSONField(default=dict, validators=[apps.operations.models_rollups.validate_rollup_dimensions])), + ("value", models.DecimalField(decimal_places=6, max_digits=28)), + ("sample_count", models.PositiveBigIntegerField(default=0)), + ("revision", models.PositiveIntegerField(default=1)), + ("source_cutoff", models.DateTimeField()), + ("computed_at", models.DateTimeField(auto_now_add=True)), + ("checksum", models.CharField(max_length=64)), + ("makerspace", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="report_metric_rollups", to="makerspaces.makerspace")), + ], + options={ + "indexes": [ + models.Index(fields=["makerspace", "report_key", "bucket_start"], name="report_rollup_space_report_idx"), + models.Index(fields=["source_module", "bucket_start"], name="report_rollup_source_bucket_idx"), + ], + "constraints": [models.UniqueConstraint(fields=("makerspace", "report_key", "metric_key", "bucket_start", "grain", "dimension_key", "revision"), name="uniq_report_metric_rollup_revision")], + }, + ), + migrations.RunSQL(APPEND_ONLY_SQL, REVERSE_SQL), + ] diff --git a/backend/apps/operations/models.py b/backend/apps/operations/models.py index e50549f5..0b610ce5 100644 --- a/backend/apps/operations/models.py +++ b/backend/apps/operations/models.py @@ -9,6 +9,10 @@ # Re-exported so `apps.operations.models.PeriodicTaskRun` resolves and Django registers # the model, matching the barrel pattern the rest of this app uses. from apps.operations.models_scheduling import PeriodicTaskRun # noqa: F401,E402 +from apps.operations.models_rollups import ( # noqa: F401,E402 + ReportMetricRollup, + ReportRollupCursor, +) class StockTransfer(models.Model): diff --git a/backend/apps/operations/models_rollups.py b/backend/apps/operations/models_rollups.py new file mode 100644 index 00000000..72077db5 --- /dev/null +++ b/backend/apps/operations/models_rollups.py @@ -0,0 +1,83 @@ +from django.core.exceptions import ValidationError +from django.db import models + + +ALLOWED_DIMENSION_KEYS = frozenset({ + "channel", "evidence_type", "feature", "kind", "mode", "module_key", + "outcome", "source", "status", "subject_type", "currency", +}) + + +def validate_rollup_dimensions(value): + if not isinstance(value, dict): + raise ValidationError("Rollup dimensions must be an object.") + unknown = set(value) - ALLOWED_DIMENSION_KEYS + if unknown: + raise ValidationError(f"Unsupported rollup dimension keys: {sorted(unknown)}.") + for key, item in value.items(): + if isinstance(item, (dict, list)): + raise ValidationError(f"Rollup dimension {key!r} must be scalar.") + if isinstance(item, str) and len(item) > 64: + raise ValidationError(f"Rollup dimension {key!r} is too long.") + + +class ReportMetricRollup(models.Model): + class Grain(models.TextChoices): + DAY = "day", "Day" + MONTH = "month", "Month" + + makerspace = models.ForeignKey( + "makerspaces.Makerspace", on_delete=models.CASCADE, related_name="report_metric_rollups" + ) + source_module = models.CharField(max_length=64) + report_key = models.CharField(max_length=80) + metric_key = models.CharField(max_length=80) + bucket_start = models.DateTimeField() + grain = models.CharField(max_length=8, choices=Grain.choices) + dimension_key = models.CharField(max_length=128) + dimensions = models.JSONField(default=dict, validators=[validate_rollup_dimensions]) + value = models.DecimalField(max_digits=28, decimal_places=6) + sample_count = models.PositiveBigIntegerField(default=0) + revision = models.PositiveIntegerField(default=1) + source_cutoff = models.DateTimeField() + computed_at = models.DateTimeField(auto_now_add=True) + checksum = models.CharField(max_length=64) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=("makerspace", "report_key", "metric_key", "bucket_start", "grain", "dimension_key", "revision"), + name="uniq_report_metric_rollup_revision", + ), + ] + indexes = [ + models.Index(fields=("makerspace", "report_key", "bucket_start"), name="report_rollup_space_report_idx"), + models.Index(fields=("source_module", "bucket_start"), name="report_rollup_source_bucket_idx"), + ] + + def save(self, *args, **kwargs): + if self.pk is not None: + raise RuntimeError("ReportMetricRollup rows are append-only.") + self.full_clean() + return super().save(*args, **kwargs) + + def delete(self, *args, **kwargs): + raise RuntimeError("ReportMetricRollup rows are append-only.") + + +class ReportRollupCursor(models.Model): + makerspace = models.ForeignKey( + "makerspaces.Makerspace", on_delete=models.CASCADE, related_name="report_rollup_cursors" + ) + source_module = models.CharField(max_length=64) + rolled_through = models.DateTimeField(null=True, blank=True) + last_success_at = models.DateTimeField(null=True, blank=True) + last_error_code = models.CharField(max_length=64, blank=True, default="") + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=("makerspace", "source_module"), name="uniq_report_rollup_cursor" + ), + ] diff --git a/backend/apps/operations/org_report_aggregate.py b/backend/apps/operations/org_report_aggregate.py index 243e45a6..ac663ffc 100644 --- a/backend/apps/operations/org_report_aggregate.py +++ b/backend/apps/operations/org_report_aggregate.py @@ -31,6 +31,8 @@ def aggregate_rows(report_key, rows_by_space, *, limit): return _maintenance_total(rows) if report_key == "fablab-health": return _health_total(rows) + if report_key == "evidence-compliance": + return _evidence_totals(rows, limit) return _ordered_rows(report_key, rows)[:limit] @@ -57,8 +59,8 @@ def _event_total(rows): if not rows: return [] fields = ( - "capacity", "registrations", "confirmed", "registered", "waitlisted", - "cancelled", "attended", + "capacity", "registrations", "confirmed", "pending_approval", "registered", + "waitlisted", "rejected", "cancelled", "attended", ) total = _summed(rows, fields) completed = [row for row in rows if row.get("status") == "completed"] @@ -132,6 +134,31 @@ def _health_total(rows): return [total] +def _evidence_totals(rows, limit): + groups = {} + additive = ( + "created_count", "attached_count", "unattached_count", + "object_live_count", "object_expired_count", "metadata_retained_count", + "bytes", + ) + for row in rows: + key = (row.get("period"), row.get("evidence_type")) + target = groups.setdefault( + key, + {"period": key[0], "evidence_type": key[1]}, + ) + for field in additive: + target[field] = target.get(field, 0) + (row.get(field) or 0) + for total in groups.values(): + total["attachment_rate_percent"] = _percent( + total["attached_count"], total["created_count"] + ) + return [ + groups[key] + for key in sorted(groups, key=lambda item: tuple(str(value or "") for value in item)) + ][:limit] + + def _available_sum(rows, field): values = [row.get(field) for row in rows if row.get(field) is not None] if not values: diff --git a/backend/apps/operations/org_report_scope.py b/backend/apps/operations/org_report_scope.py index 080c540d..b3997336 100644 --- a/backend/apps/operations/org_report_scope.py +++ b/backend/apps/operations/org_report_scope.py @@ -30,6 +30,27 @@ rbac.Action.MANAGE_MAKERSPACE, }) EXCLUDED_ORGANIZATION_REPORT_KEYS = frozenset({ + # Unique borrowers can span makerspaces, while the duration averages do not + # expose the sample counts needed to combine them without bias. + "loan-throughput", + # Its rate_percent rows use heterogeneous, unexposed denominators (for example + # public products versus active containers), so one weighted total is undefined. + "inventory-control", + # Import diagnostics intentionally retain their tenant/job context; flattening + # failures and warning rates would hide which makerspace needs remediation. + "import-quality", + # The average order/receipt durations do not expose their qualifying sample + # counts, so an organization average cannot be reconstructed correctly. + "procurement-performance", + # Delivery configuration and health are tenant-local, and repeated destination + # counts plus per-status rates do not form one truthful organization total. + "communications-health", + # 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", + # 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", "machine-service", "printer-service", }) diff --git a/backend/apps/operations/org_report_strategies.py b/backend/apps/operations/org_report_strategies.py index 6bb1aac0..8d3879c1 100644 --- a/backend/apps/operations/org_report_strategies.py +++ b/backend/apps/operations/org_report_strategies.py @@ -114,11 +114,16 @@ def _strategy(kind, groups, totals, *, breakdown=(), ordering=(), limit=None, te ), "event-attendance": _strategy( AggregationKind.WEIGHTED_RATE, (), - ("capacity", "registrations", "confirmed", "registered", "waitlisted", "cancelled", "attended", "attendance_rate_percent"), - breakdown=("event_id", "title", "starts_at", "status", "organizers"), + ("capacity", "registrations", "confirmed", "pending_approval", "registered", "waitlisted", "rejected", "cancelled", "attended", + "feedback_responses", "active_certificates", "revoked_certificates", "attendance_rate_percent"), + breakdown=("event_id", "title", "starts_at", "status", "organizers", + "series_id", "series_title", "series_occurrence_key"), ordering=(), text=( ("title", "breakdown-only"), ("starts_at", "breakdown-only"), ("status", "breakdown-only"), ("organizers", "breakdown-only"), + # Recurrence provenance describes WHICH occurrence a row is; summing it + # across makerspaces would be meaningless. + ("series_title", "breakdown-only"), ("series_occurrence_key", "breakdown-only"), ), ), "booking-utilization": _strategy( @@ -143,6 +148,20 @@ def _strategy(kind, groups, totals, *, breakdown=(), ordering=(), limit=None, te ), ordering=(), text=(("*_enabled", "ANY"), ("*_available", "ALL among enabled makerspaces; false when none enabled")), ), + "evidence-compliance": _strategy( + AggregationKind.WEIGHTED_RATE, ("period", "evidence_type"), + ( + "period", "evidence_type", "created_count", "attached_count", + "unattached_count", "object_live_count", "object_expired_count", + "metadata_retained_count", "bytes", "attachment_rate_percent", + ), + ordering=("period", "evidence_type"), + text=( + ("period", "group and carry"), + ("evidence_type", "group and carry"), + ("attachment_rate_percent", "recompute from summed attached and created counts"), + ), + ), } diff --git a/backend/apps/operations/report_coverage.py b/backend/apps/operations/report_coverage.py new file mode 100644 index 00000000..195de61f --- /dev/null +++ b/backend/apps/operations/report_coverage.py @@ -0,0 +1,81 @@ +from dataclasses import dataclass + +from django.core import checks + +from apps.makerspaces.module_registry import MODULE_KEYS +from apps.operations.report_registry import REPORT_REGISTRY + + +@dataclass(frozen=True) +class ModuleReportCoverage: + kind: str + reports: tuple[str, ...] + + +REPORT_MODULE_COVERAGE = { + "public_inventory": ModuleReportCoverage("composite", ("inventory-control",)), + "request_workflow": ModuleReportCoverage("substantive", ("loan-throughput",)), + "staff_admin": ModuleReportCoverage("health_row", ("module-operational-health",)), + "guest_handover": ModuleReportCoverage("composite", ("loan-throughput",)), + "scanner": ModuleReportCoverage("substantive", ("qr-scans", "module-operational-health")), + "printing": ModuleReportCoverage("substantive", ("printer-service",)), + "telegram": ModuleReportCoverage("composite", ("communications-health",)), + "evidence_uploads": ModuleReportCoverage("substantive", ("evidence-compliance",)), + "qr_management": ModuleReportCoverage("composite", ("inventory-control",)), + "bulk_import": ModuleReportCoverage("substantive", ("import-quality",)), + "containers": ModuleReportCoverage("composite", ("inventory-control",)), + "stock_transfers": ModuleReportCoverage("composite", ("inventory-control",)), + "stocktake": ModuleReportCoverage("composite", ("inventory-control",)), + "reports": ModuleReportCoverage("health_row", ("module-operational-health",)), + "qr_print_batches": ModuleReportCoverage("composite", ("inventory-control",)), + "asset_units": ModuleReportCoverage("composite", ("inventory-control",)), + "procurement": ModuleReportCoverage("substantive", ("procurement-performance",)), + "machines": ModuleReportCoverage("substantive", ("machine-usage", "module-operational-health")), + "machine_service": ModuleReportCoverage("substantive", ("machine-service",)), + "events": ModuleReportCoverage("substantive", ("event-attendance",)), + "bookings": ModuleReportCoverage("substantive", ("booking-utilization",)), + "maintenance": ModuleReportCoverage("substantive", ("maintenance-activity",)), + "membership": ModuleReportCoverage("substantive", ("member-activity", "community-engagement")), + "notifications": ModuleReportCoverage("composite", ("communications-health",)), + "email": ModuleReportCoverage("composite", ("communications-health",)), + "slack": ModuleReportCoverage("composite", ("communications-health",)), + "mattermost": ModuleReportCoverage("composite", ("communications-health",)), + "discord": ModuleReportCoverage("composite", ("communications-health",)), + "payments": ModuleReportCoverage("substantive", ("payment-reconciliation",)), + "member_accounts": ModuleReportCoverage("composite", ("community-engagement",)), + "mobile": ModuleReportCoverage("composite", ("community-engagement",)), + "updates": ModuleReportCoverage("health_row", ("module-operational-health",)), +} + + +@checks.register(checks.Tags.models) +def check_report_module_coverage(app_configs=None, **kwargs): + errors = [] + missing = MODULE_KEYS - REPORT_MODULE_COVERAGE.keys() + extra = REPORT_MODULE_COVERAGE.keys() - MODULE_KEYS + if missing or extra: + errors.append(checks.Error( + f"Report coverage differs from module registry; missing={sorted(missing)}, extra={sorted(extra)}.", + id="operations.E001", + )) + for module_key, coverage in REPORT_MODULE_COVERAGE.items(): + for report_key in coverage.reports: + definition = REPORT_REGISTRY.get(report_key) + if definition is None: + errors.append(checks.Error( + f"Module {module_key!r} references unknown report {report_key!r}.", + id="operations.E002", + )) + elif coverage.kind == "composite" and module_key not in definition.section_modules: + errors.append(checks.Error( + f"Composite report {report_key!r} omits the {module_key!r} section gate.", + id="operations.E003", + )) + for definition in REPORT_REGISTRY.values(): + unknown = (set(definition.required_modules) | set(definition.section_modules)) - MODULE_KEYS + if unknown: + errors.append(checks.Error( + f"Report {definition.key!r} references unknown modules {sorted(unknown)}.", + id="operations.E004", + )) + return errors diff --git a/backend/apps/operations/report_definitions_coverage.py b/backend/apps/operations/report_definitions_coverage.py new file mode 100644 index 00000000..aa56721d --- /dev/null +++ b/backend/apps/operations/report_definitions_coverage.py @@ -0,0 +1,54 @@ +from apps.accounts import rbac +from apps.operations.report_types import ReportDefinition + + +COVERAGE_REPORT_DEFINITIONS = ( + ReportDefinition( + "loan-throughput", "apps.operations.reports_workflow.build_loan_throughput", + ("period", "source", "request_status", "request_count", "unique_borrowers", "anonymous_requests", "requested_units", "accepted_units", "issued_units", "returned_units", "damaged_units", "missing_units", "average_approval_hours", "average_loan_hours"), + title="Loan throughput", chart_hint="stacked_line", grains=("day", "month"), section_modules=("guest_handover",), + ), + ReportDefinition( + "inventory-control", "apps.operations.reports_inventory_control.build_inventory_control", + ("module_key", "metric_key", "dimension", "count", "quantity", "rate_percent", "last_activity_at"), + title="Inventory control", chart_hint="grouped_bar", + section_modules=("public_inventory", "qr_management", "containers", "stock_transfers", "stocktake", "qr_print_batches", "asset_units"), + ), + ReportDefinition( + "evidence-compliance", "apps.evidence.reports.build_evidence_compliance", + ("period", "evidence_type", "created_count", "attached_count", "unattached_count", "object_live_count", "object_expired_count", "metadata_retained_count", "bytes", "attachment_rate_percent"), + ("evidence_uploads",), title="Evidence compliance", chart_hint="line", grains=("day", "month"), + ), + ReportDefinition( + "import-quality", "apps.admin_api.reports_imports.build_import_quality", + ("period", "mode", "status", "jobs", "total_rows", "processed_rows", "created_rows", "updated_rows", "error_rows", "warning_rows", "success_rate_percent", "last_activity_at"), + ("bulk_import",), required_action=rbac.Action.EDIT_INVENTORY, + title="Import quality", chart_hint="stacked_bar", grains=("day", "month"), + ), + ReportDefinition( + "procurement-performance", "apps.procurement.reports.build_procurement_performance", + ("period", "kind", "status", "items", "units", "estimated_total", "actual_total", "received_items", "inventoried_items", "average_order_hours", "average_receive_hours", "last_activity_at"), + ("procurement",), required_action=rbac.Action.EDIT_INVENTORY, + title="Procurement performance", chart_hint="stacked_bar", grains=("day", "month"), + ), + ReportDefinition( + "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"), + ), + ReportDefinition( + "community-engagement", "apps.makerspaces.reports_community.build_community_engagement", + ("period", "module_key", "enabled", "activations", "revocations", "active_accounts", "approved_apps", "active_grants", "revoked_grants", "reuse_detected"), + title="Community engagement", chart_hint="line", grains=("day", "month"), + section_modules=("membership", "member_accounts", "mobile"), + ), + 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", + ), + ), +) diff --git a/backend/apps/operations/report_definitions_existing.py b/backend/apps/operations/report_definitions_existing.py new file mode 100644 index 00000000..cce640d2 --- /dev/null +++ b/backend/apps/operations/report_definitions_existing.py @@ -0,0 +1,43 @@ +from apps.accounts import rbac +from apps.operations.report_types import ReportDefinition + + +def _legacy(key, fields, *, exportable=True, summary=False, chart_hint="bar"): + path = f"apps.operations.reports_inventory.build_{key.replace('-', '_')}" + return ReportDefinition( + key, path, fields, exportable=exportable, summary=summary, + title=key.replace("-", " ").title(), chart_hint=chart_hint, + ) + + +EXISTING_REPORT_DEFINITIONS = ( + _legacy("summary", (), exportable=False, summary=True, chart_hint="stats"), + _legacy("taken-items", ("product", "issued_quantity")), + _legacy("active-loans", ("id", "requester", "status", "issued_at"), chart_hint="line"), + _legacy("returns", ("id", "requester", "status", "closed_at"), chart_hint="line"), + _legacy("damaged-missing", ("product", "damaged_quantity", "missing_quantity"), chart_hint="grouped_bar"), + _legacy("damaged-lost", ("product_name", "damaged_quantity", "lost_quantity"), chart_hint="grouped_bar"), + _legacy("qr-scans", ("context", "count"), chart_hint="donut"), + _legacy("most-lent", ("product_name", "times_lent", "total_quantity_lent")), + _legacy("top-borrowers", ("holder", "requests", "items_borrowed"), chart_hint="grouped_bar"), + _legacy("recently-added", ("product_name", "created_at", "total_quantity"), chart_hint="line"), + ReportDefinition("machine-usage", "apps.operations.reports_machine_usage.build_machine_usage", ("machine_id", "machine_name", "machine_type", "is_active", "usage_entries", "usage_hours"), ("machines",), title="Machine usage", chart_hint="bar"), + ReportDefinition("event-attendance", "apps.operations.reports_events.build_event_attendance", ("event_id", "series_id", "series_title", "series_occurrence_key", "title", "starts_at", "status", "capacity", "registrations", "confirmed", "pending_approval", "registered", "waitlisted", "rejected", "cancelled", "attended", "attendance_rate_percent", "feedback_responses", "active_certificates", "revoked_certificates", "organizers"), ("events",), title="Event attendance", chart_hint="line"), + ReportDefinition("booking-utilization", "apps.operations.reports_bookings.build_booking_utilization", ("space_id", "space_name", "kind", "is_active", "booked", "completed", "no_show", "cancelled", "upcoming", "reserved_hours", "completed_hours", "window_hours", "reservation_utilization_percent", "no_show_rate_percent"), ("bookings",), title="Booking utilization", chart_hint="line"), + ReportDefinition("maintenance-activity", "apps.operations.reports_maintenance.build_maintenance_activity", ("machine_id", "machine_name", "machine_type", "is_active", "log_count", "costed_log_count", "total_cost", "average_cost", "last_performed_at", "average_interval_days", "active_schedules", "overdue_schedules"), ("machines", "maintenance"), title="Maintenance activity", chart_hint="line"), + ReportDefinition("member-activity", "apps.operations.reports_members.build_member_activity", ("makerspace_name", "membership_policy", "referrals_enabled", "new_members", "active_members", "revoked_members", "pending_requests", "open_invites", "referred_joins", "verified_members"), ("membership",), title="Member activity", chart_hint="bar"), + ReportDefinition("machine-service", "apps.machines.service_reports.build_machine_service_report", ("row_kind", "submitted", "accepted", "in_progress", "completed", "collected", "rejected", "failed", "machine_id", "machine_name", "machine_type", "request_count", "completed_count", "failed_count", "completed_hours", "failed_partial_hours", "total_recorded_service_hours", "failure_rate", "measurement", "product_id", "product_label", "completed_amount", "failed_partial_amount", "total_used", "outcome", "failed_count_amount", "failed_grams_amount"), ("machine_service",), title="Machine service", chart_hint="grouped_bar"), + ReportDefinition("printer-service", "apps.machines.service_reports.build_printer_service_report", ("machine_id", "machine_name", "model", "completed_hours", "failed_partial_hours", "manual_hours", "consumed_grams", "payment_due", "payment_paid"), ("printing",), title="Printer service", chart_hint="grouped_bar"), + ReportDefinition("fablab-health", "apps.operations.reports_health.build_fablab_health", ( + "events_enabled", "events_available", "events_in_period", "events_registrations", "events_attended", "events_completed_attendance_rate_percent", + "bookings_enabled", "bookings_available", "bookings_active_spaces", "bookings_non_cancelled", "bookings_reserved_hours", "bookings_upcoming", "bookings_no_shows", "bookings_reservation_utilization_percent", + "machines_enabled", "machines_available", "machines_active", "machines_usage_hours", + "maintenance_enabled", "maintenance_available", "maintenance_logs", "maintenance_total_cost", "maintenance_overdue_schedules", + ), 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"), + required_action=rbac.Action.MANAGE_MAKERSPACE, title="Payment reconciliation", + chart_hint="stacked_bar", + ), +) diff --git a/backend/apps/operations/report_registry.py b/backend/apps/operations/report_registry.py index 9c29cabd..8c002b8e 100644 --- a/backend/apps/operations/report_registry.py +++ b/backend/apps/operations/report_registry.py @@ -1,83 +1,16 @@ -from dataclasses import dataclass -from typing import Callable - -from django.utils.module_loading import import_string -from rest_framework.exceptions import APIException - -from apps.accounts import rbac - - -@dataclass(frozen=True) -class ReportResult: - field_order: tuple[str, ...] - records: list[dict[str, object]] - - -@dataclass(frozen=True) -class ReportDefinition: - key: str - builder_path: str - fields: tuple[str, ...] - required_modules: tuple[str, ...] = () - exportable: bool = True - summary: bool = False - required_action: str = rbac.Action.VIEW_AUDIT - - def builder(self) -> Callable: - return import_string(self.builder_path) - - -class ReportNotFound(APIException): - status_code = 404 - - def __init__(self): - self.detail = {"detail": "Unknown report key.", "code": "report_not_found"} - - -class ReportNotExportable(APIException): - status_code = 400 - - def __init__(self): - self.detail = {"detail": "Report is not exportable.", "code": "report_not_exportable"} - - -def _legacy(key, fields, *, exportable=True, summary=False): - path = f"apps.operations.reports_inventory.build_{key.replace('-', '_')}" - return ReportDefinition(key, path, fields, exportable=exportable, summary=summary) - - -REPORT_DEFINITIONS = ( - _legacy("summary", (), exportable=False, summary=True), - _legacy("taken-items", ("product", "issued_quantity")), - _legacy("active-loans", ("id", "requester", "status", "issued_at")), - _legacy("returns", ("id", "requester", "status", "closed_at")), - _legacy("damaged-missing", ("product", "damaged_quantity", "missing_quantity")), - _legacy("damaged-lost", ("product_name", "damaged_quantity", "lost_quantity")), - _legacy("qr-scans", ("context", "count")), - _legacy("most-lent", ("product_name", "times_lent", "total_quantity_lent")), - _legacy("top-borrowers", ("holder", "requests", "items_borrowed")), - _legacy("recently-added", ("product_name", "created_at", "total_quantity")), - ReportDefinition("machine-usage", "apps.operations.reports_machine_usage.build_machine_usage", ("machine_id", "machine_name", "machine_type", "is_active", "usage_entries", "usage_hours"), ("machines",)), - ReportDefinition("event-attendance", "apps.operations.reports_events.build_event_attendance", ("event_id", "title", "starts_at", "status", "capacity", "registrations", "confirmed", "registered", "waitlisted", "cancelled", "attended", "attendance_rate_percent", "organizers"), ("events",)), - ReportDefinition("booking-utilization", "apps.operations.reports_bookings.build_booking_utilization", ("space_id", "space_name", "kind", "is_active", "booked", "completed", "no_show", "cancelled", "upcoming", "reserved_hours", "completed_hours", "window_hours", "reservation_utilization_percent", "no_show_rate_percent"), ("bookings",)), - ReportDefinition("maintenance-activity", "apps.operations.reports_maintenance.build_maintenance_activity", ("machine_id", "machine_name", "machine_type", "is_active", "log_count", "costed_log_count", "total_cost", "average_cost", "last_performed_at", "average_interval_days", "active_schedules", "overdue_schedules"), ("machines", "maintenance")), - ReportDefinition("member-activity", "apps.operations.reports_members.build_member_activity", ("makerspace_name", "membership_policy", "referrals_enabled", "new_members", "active_members", "revoked_members", "pending_requests", "open_invites", "referred_joins", "verified_members")), - ReportDefinition("machine-service", "apps.machines.service_reports.build_machine_service_report", ("row_kind", "submitted", "accepted", "in_progress", "completed", "collected", "rejected", "failed", "machine_id", "machine_name", "machine_type", "request_count", "completed_count", "failed_count", "completed_hours", "failed_partial_hours", "total_recorded_service_hours", "failure_rate", "measurement", "product_id", "product_label", "completed_amount", "failed_partial_amount", "total_used", "outcome", "failed_count_amount", "failed_grams_amount"), ("machine_service",)), - ReportDefinition("printer-service", "apps.machines.service_reports.build_printer_service_report", ("machine_id", "machine_name", "model", "completed_hours", "failed_partial_hours", "manual_hours", "consumed_grams", "payment_due", "payment_paid"), ("machine_service",)), - ReportDefinition("fablab-health", "apps.operations.reports_health.build_fablab_health", ( - "events_enabled", "events_available", "events_in_period", "events_registrations", "events_attended", "events_completed_attendance_rate_percent", - "bookings_enabled", "bookings_available", "bookings_active_spaces", "bookings_non_cancelled", "bookings_reserved_hours", "bookings_upcoming", "bookings_no_shows", "bookings_reservation_utilization_percent", - "machines_enabled", "machines_available", "machines_active", "machines_usage_hours", - "maintenance_enabled", "maintenance_available", "maintenance_logs", "maintenance_total_cost", "maintenance_overdue_schedules", - )), - ReportDefinition( - "payment-reconciliation", - "apps.operations.reports_payments.build_payment_reconciliation", - ("currency", "subject_type", "status", "payment_count", "amount_total", "outstanding_amount"), - required_action=rbac.Action.MANAGE_MAKERSPACE, - ), +"""Canonical composition point for every report definition.""" + +from apps.operations.report_definitions_coverage import COVERAGE_REPORT_DEFINITIONS +from apps.operations.report_definitions_existing import EXISTING_REPORT_DEFINITIONS +from apps.operations.report_types import ( + ReportDefinition, + ReportNotExportable, + ReportNotFound, + ReportResult, ) + +REPORT_DEFINITIONS = (*EXISTING_REPORT_DEFINITIONS, *COVERAGE_REPORT_DEFINITIONS) REPORT_REGISTRY = {definition.key: definition for definition in REPORT_DEFINITIONS} REPORT_KEYS = [definition.key for definition in REPORT_DEFINITIONS] @@ -89,3 +22,9 @@ def report_definition(report_key, *, for_export=False): if for_export and not definition.exportable: raise ReportNotExportable() return definition + + +__all__ = [ + "REPORT_DEFINITIONS", "REPORT_KEYS", "REPORT_REGISTRY", "ReportDefinition", + "ReportNotExportable", "ReportNotFound", "ReportResult", "report_definition", +] diff --git a/backend/apps/operations/report_rollups.py b/backend/apps/operations/report_rollups.py new file mode 100644 index 00000000..5e4800a5 --- /dev/null +++ b/backend/apps/operations/report_rollups.py @@ -0,0 +1,146 @@ +import hashlib +import json +from datetime import timedelta +from decimal import Decimal + +from django.db import transaction +from django.db.models import Count, Sum +from django.utils import timezone + +from apps.audit import services as audit +from apps.evidence.models import EvidencePhoto +from apps.hardware_requests.models import HardwareRequest, RequesterAccountability, ReturnEvent +from apps.hardware_requests.self_checkout_models import PublicToolLoan +from apps.operations.models import ReportMetricRollup, ReportRollupCursor + + +SOURCE_MODULE = "evidence_uploads" +REPORT_KEY = "evidence-compliance" +METRICS = ( + "created_count", "attached_count", "unattached_count", "object_live_count", + "object_expired_count", "metadata_retained_count", "bytes", +) + + +def finalize_evidence_rollups(makerspace, *, through=None, start_at=None, actor=None): + through = _day_start(through or timezone.now()) + with transaction.atomic(): + cursor, _ = ReportRollupCursor.objects.select_for_update().get_or_create( + makerspace=makerspace, source_module=SOURCE_MODULE + ) + start = _rollup_start(makerspace, cursor, through, start_at) + changed = 0 + bucket = start + while bucket < through: + changed += _finalize_bucket(makerspace, bucket, bucket + timedelta(days=1), actor) + bucket += timedelta(days=1) + if cursor.rolled_through is None or through > cursor.rolled_through: + cursor.rolled_through = through + cursor.last_success_at = timezone.now() + cursor.last_error_code = "" + cursor.save(update_fields=("rolled_through", "last_success_at", "last_error_code", "updated_at")) + return changed + + +def satisfy_retention_fence(makerspace, cutoff, *, actor=None): + cutoff = _day_start(cutoff) + finalize_evidence_rollups(makerspace, through=cutoff, actor=actor) + cursor = ReportRollupCursor.objects.get(makerspace=makerspace, source_module=SOURCE_MODULE) + if cursor.rolled_through is None or cursor.rolled_through < cutoff or cursor.last_error_code: + raise RuntimeError("Evidence retention is blocked by an incomplete report rollup fence.") + audit.record(actor, "report.retention_fence_satisfied", makerspace=makerspace, meta={ + "source_module": SOURCE_MODULE, "cutoff": cutoff.isoformat(), + }) + return cursor + + +def _rollup_start(makerspace, cursor, through, requested): + if requested is not None: + return min(_day_start(requested), through) + earliest = EvidencePhoto.objects.filter(makerspace=makerspace).order_by("created_at").values_list("created_at", flat=True).first() + if earliest is None: + return through + earliest = _day_start(earliest) + if cursor.rolled_through is None: + return earliest + return max(earliest, min(cursor.rolled_through - timedelta(days=7), through)) + + +def _finalize_bucket(makerspace, start, end, actor): + evidence = EvidencePhoto.objects.filter(makerspace=makerspace, created_at__gte=start, created_at__lt=end) + attached_ids = _attached_ids(makerspace.id, evidence.values_list("id", flat=True)) + facts = {} + for row in evidence.values("evidence_type").annotate(created=Count("id"), bytes=Sum("size_bytes")): + evidence_type = row["evidence_type"] + created = row["created"] + attached = len(attached_ids.get(evidence_type, set())) + facts[evidence_type] = { + "created_count": created, "attached_count": attached, + "unattached_count": created - attached, "object_live_count": created, + "object_expired_count": 0, "metadata_retained_count": created, + "bytes": row["bytes"] or 0, + } + changed = 0 + checksums = [] + for evidence_type, metrics in sorted(facts.items()): + dimensions = {"evidence_type": evidence_type} + dimension_key = f"evidence_type={evidence_type}" + for metric_key in METRICS: + checksum = _checksum(metric_key, dimensions, metrics[metric_key], metrics["created_count"]) + checksums.append(checksum) + changed += _append_revision( + makerspace, metric_key, start, end, dimension_key, dimensions, + metrics[metric_key], metrics["created_count"], checksum, actor, + ) + bucket_checksum = hashlib.sha256("".join(sorted(checksums)).encode()).hexdigest() + audit.record(actor, "report.rollup_finalized", makerspace=makerspace, meta={ + "source_module": SOURCE_MODULE, "report_key": REPORT_KEY, + "bucket": start.isoformat(), "row_count": len(checksums), "checksum": bucket_checksum, + }) + return changed + + +def _append_revision(makerspace, metric, start, cutoff, dimension_key, dimensions, value, samples, checksum, actor): + previous = ReportMetricRollup.objects.filter( + makerspace=makerspace, report_key=REPORT_KEY, metric_key=metric, + bucket_start=start, grain=ReportMetricRollup.Grain.DAY, + dimension_key=dimension_key, + ).order_by("-revision").first() + if previous and previous.checksum == checksum: + return 0 + revision = previous.revision + 1 if previous else 1 + rollup = ReportMetricRollup.objects.create( + makerspace=makerspace, source_module=SOURCE_MODULE, report_key=REPORT_KEY, + metric_key=metric, bucket_start=start, grain=ReportMetricRollup.Grain.DAY, + dimension_key=dimension_key, dimensions=dimensions, value=Decimal(value), + sample_count=samples, revision=revision, source_cutoff=cutoff, checksum=checksum, + ) + audit.record(actor, "report.rollup_revision_appended", makerspace=makerspace, target=rollup, meta={ + "source_module": SOURCE_MODULE, "report_key": REPORT_KEY, + "metric_key": metric, "bucket": start.isoformat(), + "revision": revision, "row_count": 1, "checksum": checksum, + }) + return 1 + + +def _attached_ids(makerspace_id, evidence_ids): + ids = set(evidence_ids) + attached = set(HardwareRequest.objects.filter(makerspace_id=makerspace_id, issue_evidence_id__in=ids).values_list("issue_evidence_id", flat=True)) + attached.update(ReturnEvent.objects.filter(makerspace_id=makerspace_id, evidence_id__in=ids).values_list("evidence_id", flat=True)) + attached.update(PublicToolLoan.objects.filter(makerspace_id=makerspace_id, return_evidence_id__in=ids).values_list("return_evidence_id", flat=True)) + attached.update(RequesterAccountability.objects.filter(makerspace_id=makerspace_id, evidence_photo_id__in=ids).values_list("evidence_photo_id", flat=True)) + by_type = {} + for evidence_type, evidence_id in EvidencePhoto.objects.filter(id__in=attached).values_list("evidence_type", "id"): + by_type.setdefault(evidence_type, set()).add(evidence_id) + return by_type + + +def _checksum(metric, dimensions, value, samples): + payload = json.dumps([metric, dimensions, str(value), samples], sort_keys=True, separators=(",", ":")) + return hashlib.sha256(payload.encode()).hexdigest() + + +def _day_start(value): + if timezone.is_naive(value): + value = timezone.make_aware(value) + return value.astimezone(timezone.get_current_timezone()).replace(hour=0, minute=0, second=0, microsecond=0) diff --git a/backend/apps/operations/report_types.py b/backend/apps/operations/report_types.py new file mode 100644 index 00000000..0f2cb4d0 --- /dev/null +++ b/backend/apps/operations/report_types.py @@ -0,0 +1,46 @@ +from dataclasses import dataclass, field +from typing import Callable + +from django.utils.module_loading import import_string +from rest_framework.exceptions import APIException + +from apps.accounts import rbac + + +@dataclass(frozen=True) +class ReportResult: + field_order: tuple[str, ...] + records: list[dict[str, object]] + meta: dict[str, object] = field(default_factory=dict) + + +@dataclass(frozen=True) +class ReportDefinition: + key: str + builder_path: str + fields: tuple[str, ...] + required_modules: tuple[str, ...] = () + exportable: bool = True + summary: bool = False + required_action: str = rbac.Action.VIEW_AUDIT + title: str = "" + chart_hint: str = "table" + grains: tuple[str, ...] = () + section_modules: tuple[str, ...] = () + + def builder(self) -> Callable: + return import_string(self.builder_path) + + +class ReportNotFound(APIException): + status_code = 404 + + def __init__(self): + self.detail = {"detail": "Unknown report key.", "code": "report_not_found"} + + +class ReportNotExportable(APIException): + status_code = 400 + + def __init__(self): + self.detail = {"detail": "Report is not exportable.", "code": "report_not_exportable"} diff --git a/backend/apps/operations/reports.py b/backend/apps/operations/reports.py index cc81fc33..ab213610 100644 --- a/backend/apps/operations/reports.py +++ b/backend/apps/operations/reports.py @@ -19,33 +19,37 @@ def report_data( report_key="summary", makerspace_id=None, *, limit=None, date_range=None, - report_filters=None, + report_filters=None, grain="day", ): definition = report_definition(report_key) - result = definition.builder()( - makerspace_id, - limit=_normalized_limit(limit), - date_range=date_range, - **(report_filters or {}), - ) + kwargs = dict(limit=_normalized_limit(limit), date_range=date_range, **(report_filters or {})) + if definition.grains: + kwargs["grain"] = grain + result = definition.builder()(makerspace_id, **kwargs) if definition.summary: return result if not isinstance(result, ReportResult): return {"rows": result, "typed_rows": typed_report_rows(report_key, result)} rows = _matrix(result, json=True) - return {"rows": rows, "typed_rows": typed_result_rows(result, json_value)} + return { + "report_key": report_key, + "rows": rows, + "typed_rows": typed_result_rows(result, json_value), + "meta": {"source": "live", "grain": grain, "rollup_through": None, **result.meta}, + } def report_rows( report_key, makerspace_id=None, *, limit=None, date_range=None, - report_filters=None, + report_filters=None, grain="day", ): definition = report_definition(report_key, for_export=True) - result = definition.builder()( - makerspace_id, limit=limit, date_range=date_range, **(report_filters or {}) - ) + kwargs = dict(limit=limit, date_range=date_range, **(report_filters or {})) + if definition.grains: + kwargs["grain"] = grain + result = definition.builder()(makerspace_id, **kwargs) if isinstance(result, ReportResult): - return _matrix(result, json=False) + return _export_matrix(result, grain) return result @@ -74,6 +78,16 @@ def _matrix(result, *, json): ] +def _export_matrix(result, grain): + # Provenance (source / grain / rollup_through) stays in the JSON response `meta` and is + # deliberately NOT appended as export columns. It is one value per REPORT, not per row, + # so appending it repeats itself on every line, and the export header of each report is + # pinned to the fields the report registry declares -- the registry is the single source + # of truth for a report's shape. Adding provenance to the file is a real product decision + # about five already-shipped exports, and belongs to the owner rather than to this phase. + return _matrix(result, json=False) + + def json_value(value): if isinstance(value, Decimal): return format(value.quantize(Decimal("0.01")), ".2f") diff --git a/backend/apps/operations/reports_common.py b/backend/apps/operations/reports_common.py new file mode 100644 index 00000000..04dbbef5 --- /dev/null +++ b/backend/apps/operations/reports_common.py @@ -0,0 +1,30 @@ +from django.db.models import QuerySet +from django.db.models.functions import TruncDay, TruncMonth + +from apps.makerspaces.models import Makerspace +from apps.operations.report_scope import scoped_ids + + +def report_spaces(makerspace_id, *required_modules) -> QuerySet: + return Makerspace.objects.filter( + id__in=scoped_ids(makerspace_id, *required_modules) + ).order_by("id") + + +def apply_range(queryset, field, date_range): + if not date_range: + return queryset + start, end = date_range + if start is not None: + queryset = queryset.filter(**{f"{field}__gte": start}) + if end is not None: + queryset = queryset.filter(**{f"{field}__lt": end}) + return queryset + + +def period_expression(field, grain): + return TruncMonth(field) if grain == "month" else TruncDay(field) + + +def limited(records, limit): + return records if limit is None else records[:limit] diff --git a/backend/apps/operations/reports_events.py b/backend/apps/operations/reports_events.py index ece6073f..a24b2683 100644 --- a/backend/apps/operations/reports_events.py +++ b/backend/apps/operations/reports_events.py @@ -6,9 +6,12 @@ FIELDS = ( - "event_id", "title", "starts_at", "status", "capacity", "registrations", - "confirmed", "registered", "waitlisted", "cancelled", "attended", - "attendance_rate_percent", "organizers", + "event_id", "series_id", "series_title", "series_occurrence_key", "title", + "starts_at", "status", "capacity", "registrations", + "confirmed", "pending_approval", "registered", "waitlisted", "rejected", + "cancelled", "attended", + "attendance_rate_percent", "feedback_responses", "active_certificates", + "revoked_certificates", "organizers", ) @@ -25,13 +28,27 @@ def build_event_attendance(makerspace_id, *, limit=None, date_range=None): queryset = queryset.filter(starts_at__lt=end) statuses = EventRegistration.Status queryset = queryset.values( - "id", "makerspace_id", "title", "starts_at", "status", "capacity" + "id", "makerspace_id", "series_id", "series__title", + "series_occurrence_key", "title", "starts_at", "status", "capacity" ).annotate( - total=Count("registrations"), - registered_count=Count("registrations", filter=Q(registrations__status=statuses.REGISTERED)), - waitlisted_count=Count("registrations", filter=Q(registrations__status=statuses.WAITLISTED)), - cancelled_count=Count("registrations", filter=Q(registrations__status=statuses.CANCELLED)), - attended_count=Count("registrations", filter=Q(registrations__status=statuses.ATTENDED)), + total=Count("registrations", distinct=True), + pending_approval_count=Count("registrations", filter=Q(registrations__status=statuses.PENDING_APPROVAL), distinct=True), + registered_count=Count("registrations", filter=Q(registrations__status=statuses.REGISTERED), distinct=True), + waitlisted_count=Count("registrations", filter=Q(registrations__status=statuses.WAITLISTED), distinct=True), + rejected_count=Count("registrations", filter=Q(registrations__status=statuses.REJECTED), distinct=True), + cancelled_count=Count("registrations", filter=Q(registrations__status=statuses.CANCELLED), distinct=True), + attended_count=Count("registrations", filter=Q(registrations__status=statuses.ATTENDED), distinct=True), + feedback_response_count=Count("feedback_survey__responses", distinct=True), + active_certificate_count=Count( + "registrations__attendance_certificates", + filter=Q(registrations__attendance_certificates__status="active"), + distinct=True, + ), + revoked_certificate_count=Count( + "registrations__attendance_certificates", + filter=Q(registrations__attendance_certificates__status="revoked"), + distinct=True, + ), ) ordering = ("makerspace_id", "-starts_at", "id") if aggregate else ("-starts_at", "id") rows = list(queryset.order_by(*ordering)[:limit] if limit is not None else queryset.order_by(*ordering)) @@ -54,12 +71,22 @@ def build_event_attendance(makerspace_id, *, limit=None, date_range=None): if row["status"] == Event.Status.COMPLETED and denominator: rate = round(row["attended_count"] / denominator * 100, 2) record = { - "event_id": row["id"], "title": row["title"], + "event_id": row["id"], "series_id": row["series_id"], + "series_title": row["series__title"] or "", + "series_occurrence_key": row["series_occurrence_key"] or "", + "title": row["title"], "starts_at": row["starts_at"], "status": row["status"], "capacity": row["capacity"], "registrations": row["total"], - "confirmed": denominator, "registered": row["registered_count"], - "waitlisted": row["waitlisted_count"], "cancelled": row["cancelled_count"], + "confirmed": denominator, + "pending_approval": row["pending_approval_count"], + "registered": row["registered_count"], + "waitlisted": row["waitlisted_count"], + "rejected": row["rejected_count"], + "cancelled": row["cancelled_count"], "attended": row["attended_count"], "attendance_rate_percent": rate, + "feedback_responses": row["feedback_response_count"], + "active_certificates": row["active_certificate_count"], + "revoked_certificates": row["revoked_certificate_count"], "organizers": "; ".join(organizers_by_event.get(row["id"], [])), } if aggregate: diff --git a/backend/apps/operations/reports_inventory.py b/backend/apps/operations/reports_inventory.py index 0e388583..4ccbe28c 100644 --- a/backend/apps/operations/reports_inventory.py +++ b/backend/apps/operations/reports_inventory.py @@ -5,6 +5,9 @@ from apps.hardware_requests.display import label_from_candidates, requester_label from apps.hardware_requests.models import HardwareRequest, HardwareRequestItem from apps.inventory.models import InventoryAsset, InventoryProduct +from apps.makerspaces.models import Makerspace +from apps.makerspaces.module_registry import module_available +from apps.makerspaces.platform import module_enabled from apps.operations.report_scope import eligible_makerspace_ids @@ -243,7 +246,11 @@ def _assets(makerspace_id): # consistent with the archived-excluded product/quantity figures. qs = InventoryAsset.objects.exclude(product__is_archived=True) if makerspace_id is None: - return qs.filter(makerspace_id__in=eligible_makerspace_ids()) + ids = eligible_makerspace_ids("asset_units") if module_available("asset_units") else [] + return qs.filter(makerspace_id__in=ids) + makerspace = Makerspace.objects.filter(id=makerspace_id).first() + if makerspace is None or not module_enabled(makerspace, "asset_units"): + return qs.none() return qs.filter(makerspace_id=makerspace_id) diff --git a/backend/apps/operations/reports_inventory_control.py b/backend/apps/operations/reports_inventory_control.py new file mode 100644 index 00000000..94feff0c --- /dev/null +++ b/backend/apps/operations/reports_inventory_control.py @@ -0,0 +1,101 @@ +from django.db.models import Count, Max, Sum + +from apps.boxes.models import Box, QrCode +from apps.inventory.models import InventoryAsset, InventoryProduct +from apps.makerspaces.platform import module_enabled +from apps.operations.models import QrPrintBatch, StockTransfer, StocktakeSession +from apps.operations.report_types import ReportResult +from apps.operations.reports_common import limited, report_spaces + + +FIELDS = ( + "module_key", "metric_key", "dimension", "count", "quantity", + "rate_percent", "last_activity_at", +) + + +def build_inventory_control(makerspace_id, *, limit=None, date_range=None): + aggregate = makerspace_id is None + records = [] + for space in report_spaces(makerspace_id): + add = lambda **values: _add(records, space.id, aggregate, **values) + if module_enabled(space, "public_inventory"): + _products(space.id, add) + if module_enabled(space, "qr_management"): + _grouped(QrCode.objects.filter(makerspace=space), "qr_management", "qr_status", "status", add) + if module_enabled(space, "containers"): + _containers(space.id, add) + if module_enabled(space, "stock_transfers"): + _transfers(space.id, add) + if module_enabled(space, "stocktake"): + _stocktakes(space.id, add) + if module_enabled(space, "qr_print_batches"): + _qr_batches(space.id, add) + if module_enabled(space, "asset_units"): + _grouped(InventoryAsset.objects.filter(makerspace=space), "asset_units", "asset_status", "status", add) + fields = (("makerspace_id",) + FIELDS) if aggregate else FIELDS + return ReportResult(fields, limited(records, limit)) + + +def _products(space_id, add): + qs = InventoryProduct.objects.filter(makerspace_id=space_id, is_archived=False) + total = qs.count() + public = qs.filter(is_public=True).count() + add(module_key="public_inventory", metric_key="visibility", dimension="public", count=public, + rate_percent=round(public / total * 100, 2) if total else None, last_activity_at=_latest(qs)) + for row in qs.values("public_availability_mode").annotate(count=Count("id")): + add(module_key="public_inventory", metric_key="availability_mode", + dimension=row["public_availability_mode"], count=row["count"]) + + +def _containers(space_id, add): + qs = Box.objects.filter(makerspace_id=space_id) + totals = qs.aggregate(count=Count("id"), active=Count("id", filter=models_q(is_active=True)), last=Max("updated_at")) + assigned = InventoryProduct.objects.filter(makerspace_id=space_id, box__isnull=False).count() + assigned += InventoryAsset.objects.filter(makerspace_id=space_id, box__isnull=False).count() + add(module_key="containers", metric_key="containers", dimension="all", count=totals["count"], + quantity=assigned, rate_percent=round(totals["active"] / totals["count"] * 100, 2) if totals["count"] else None, + last_activity_at=totals["last"]) + + +def _transfers(space_id, add): + qs = StockTransfer.objects.filter(makerspace_id=space_id) + for row in qs.values("status").annotate(count=Count("id", distinct=True), quantity=Sum("lines__quantity"), last=Max("created_at")): + add(module_key="stock_transfers", metric_key="transfer_status", dimension=row["status"], + count=row["count"], quantity=row["quantity"] or 0, last_activity_at=row["last"]) + + +def _stocktakes(space_id, add): + qs = StocktakeSession.objects.filter(makerspace_id=space_id) + for row in qs.values("status").annotate(count=Count("id", distinct=True), quantity=Sum("lines__variance_quantity"), last=Max("started_at")): + add(module_key="stocktake", metric_key="session_status", dimension=row["status"], + count=row["count"], quantity=row["quantity"] or 0, last_activity_at=row["last"]) + + +def _qr_batches(space_id, add): + qs = QrPrintBatch.objects.filter(makerspace_id=space_id) + for row in qs.values("status").annotate(count=Count("id", distinct=True), quantity=Count("items"), last=Max("created_at")): + add(module_key="qr_print_batches", metric_key="batch_status", dimension=row["status"], + count=row["count"], quantity=row["quantity"], last_activity_at=row["last"]) + + +def _grouped(qs, module_key, metric_key, field, add): + for row in qs.values(field).annotate(count=Count("id"), last=Max("updated_at")): + add(module_key=module_key, metric_key=metric_key, dimension=row[field], + count=row["count"], last_activity_at=row["last"]) + + +def _latest(qs): + return qs.aggregate(value=Max("updated_at"))["value"] + + +def _add(records, space_id, aggregate, **values): + row = {field: values.get(field) for field in FIELDS} + if aggregate: + row["makerspace_id"] = space_id + records.append(row) + + +def models_q(**kwargs): + from django.db.models import Q + return Q(**kwargs) diff --git a/backend/apps/operations/reports_members.py b/backend/apps/operations/reports_members.py index 2766e552..5bd62778 100644 --- a/backend/apps/operations/reports_members.py +++ b/backend/apps/operations/reports_members.py @@ -18,7 +18,7 @@ def build_member_activity(makerspace_id, *, limit=None, date_range=None): aggregate = makerspace_id is None memberships = MakerspaceMembership.objects.filter(makerspace_id=OuterRef("pk")) requests = MembershipRequest.objects.filter(makerspace_id=OuterRef("pk")) - queryset = Makerspace.objects.filter(id__in=scoped_ids(makerspace_id)).annotate( + queryset = Makerspace.objects.filter(id__in=scoped_ids(makerspace_id, "membership")).annotate( makerspace_id=F("id"), makerspace_name=F("name"), new_members=_count(memberships.filter(_in_range("activated_at", date_range))), diff --git a/backend/apps/operations/reports_module_health.py b/backend/apps/operations/reports_module_health.py new file mode 100644 index 00000000..99d2a90b --- /dev/null +++ b/backend/apps/operations/reports_module_health.py @@ -0,0 +1,88 @@ +from collections import defaultdict + +from django.db.models import Count, Max + +from apps.audit.models import AuditLog +from apps.boxes.models import QrScanEvent +from apps.makerspaces.module_registry import MODULES, module_available +from apps.makerspaces.platform import module_enabled +from apps.operations.report_coverage import REPORT_MODULE_COVERAGE +from apps.operations.report_types import ReportResult +from apps.operations.reports_common import limited, report_spaces + + +FIELDS = ( + "module_key", "enabled", "runtime_available", "coverage_kind", + "activity_count", "failure_count", "last_activity_at", "rollup_watermark", + "rollup_state", +) + +ACTION_MODULES = { + "hardware": "request_workflow", "request": "request_workflow", + "admin_direct": "guest_handover", "evidence": "evidence_uploads", + "qr": "qr_management", "bulk_import": "bulk_import", "container": "containers", + "stock_transfer": "stock_transfers", "stocktake": "stocktake", + "procurement": "procurement", "machine": "machines", "service": "machine_service", + "event": "events", "booking": "bookings", "maintenance": "maintenance", + "membership": "membership", "notification": "notifications", "email": "email", + "payment": "payments", "device": "mobile", "report": "reports", +} + + +def build_module_operational_health(makerspace_id, *, limit=None, date_range=None): + aggregate = makerspace_id is None + records = [] + for space in report_spaces(makerspace_id): + activity = _audit_activity(space.id, date_range) + if module_enabled(space, "scanner"): + scanner = QrScanEvent.objects.filter(makerspace=space).aggregate( + count=Count("id"), last=Max("created_at") + ) + activity["scanner"]["count"] += scanner["count"] + activity["scanner"]["last"] = scanner["last"] or activity["scanner"]["last"] + cursors = _rollup_cursors(space.id) + for definition in MODULES: + enabled = module_enabled(space, definition.key) + state = activity[definition.key] if enabled else {"count": 0, "failures": 0, "last": None} + cursor = cursors.get(definition.key) if enabled else None + row = { + "module_key": definition.key, + "enabled": enabled, + "runtime_available": module_available(definition.key), + "coverage_kind": REPORT_MODULE_COVERAGE[definition.key].kind, + "activity_count": state["count"], "failure_count": state["failures"], + "last_activity_at": state["last"], + "rollup_watermark": cursor.rolled_through if cursor else None, + "rollup_state": "failed" if cursor and cursor.last_error_code else "current" if cursor else "not_started", + } + if aggregate: + row["makerspace_id"] = space.id + records.append(row) + fields = (("makerspace_id",) + FIELDS) if aggregate else FIELDS + return ReportResult(fields, limited(records, limit)) + + +def _audit_activity(space_id, date_range): + states = defaultdict(lambda: {"count": 0, "failures": 0, "last": None}) + qs = AuditLog.objects.filter(makerspace_id=space_id) + if date_range: + start, end = date_range + if start: + qs = qs.filter(created_at__gte=start) + if end: + qs = qs.filter(created_at__lt=end) + for row in qs.values("action").annotate(count=Count("id"), last=Max("created_at")): + prefix = row["action"].split(".", 1)[0] + module = ACTION_MODULES.get(prefix) + if not module: + continue + states[module]["count"] += row["count"] + states[module]["last"] = max(filter(None, (states[module]["last"], row["last"])), default=None) + if any(token in row["action"] for token in ("failed", "rejected", "denied")): + states[module]["failures"] += row["count"] + return states + + +def _rollup_cursors(space_id): + from apps.operations.models import ReportRollupCursor + return {row.source_module: row for row in ReportRollupCursor.objects.filter(makerspace_id=space_id)} diff --git a/backend/apps/operations/reports_workflow.py b/backend/apps/operations/reports_workflow.py new file mode 100644 index 00000000..73dbd02c --- /dev/null +++ b/backend/apps/operations/reports_workflow.py @@ -0,0 +1,89 @@ +from collections import defaultdict + +from apps.hardware_requests.models import HardwareRequest +from apps.hardware_requests.self_checkout_models import PublicToolLoan +from apps.makerspaces.anonymous_requesters import anonymous_requester_ids +from apps.makerspaces.platform import module_enabled +from apps.operations.report_scope import scoped_ids +from apps.operations.report_types import ReportResult +from apps.operations.reports_common import apply_range, limited, report_spaces + + +FIELDS = ( + "period", "source", "request_status", "request_count", "unique_borrowers", + "anonymous_requests", "requested_units", "accepted_units", "issued_units", + "returned_units", "damaged_units", "missing_units", "average_approval_hours", + "average_loan_hours", +) + + +def build_loan_throughput(makerspace_id, *, limit=None, date_range=None, grain="day"): + aggregate = makerspace_id is None + spaces = list(report_spaces(makerspace_id)) + space_ids = [space.id for space in spaces] + direct_enabled = {space.id for space in spaces if module_enabled(space, "guest_handover")} + sources = dict(PublicToolLoan.objects.filter( + makerspace_id__in=direct_enabled + ).values_list("request_id", "source")) + requests = apply_range( + HardwareRequest.objects.filter(makerspace_id__in=space_ids).prefetch_related("items"), + "created_at", date_range, + ).order_by("created_at", "id") + sentinels = anonymous_requester_ids(space_ids) + groups = defaultdict(_empty_group) + for request in requests.iterator(chunk_size=200): + period = request.created_at.date().replace(day=1) if grain == "month" else request.created_at.date() + source = sources.get(request.id, "request_workflow") + key = (request.makerspace_id if aggregate else None, period, source, request.status) + row = groups[key] + row["request_count"] += 1 + if request.requester_id in sentinels: + row["anonymous_requests"] += 1 + else: + row["_borrowers"].add(request.requester_id) + for item in request.items.all(): + for source_field, target_field in ( + ("requested_quantity", "requested_units"), + ("accepted_quantity", "accepted_units"), + ("issued_quantity", "issued_units"), + ("returned_quantity", "returned_units"), + ("damaged_quantity", "damaged_units"), + ("missing_quantity", "missing_units"), + ): + row[target_field] += getattr(item, source_field) + if request.accepted_at: + row["_approval_seconds"].append((request.accepted_at - request.created_at).total_seconds()) + if request.issued_at and request.closed_at: + row["_loan_seconds"].append((request.closed_at - request.issued_at).total_seconds()) + records = [] + for key, row in sorted(groups.items(), key=lambda item: item[0]): + space_id, period, source, status = key + record = { + "period": period, "source": source, "request_status": status, + **{field: row[field] for field in ( + "request_count", "anonymous_requests", "requested_units", + "accepted_units", "issued_units", "returned_units", + "damaged_units", "missing_units", + )}, + "unique_borrowers": len(row["_borrowers"]), + "average_approval_hours": _average_hours(row["_approval_seconds"]), + "average_loan_hours": _average_hours(row["_loan_seconds"]), + } + if aggregate: + record["makerspace_id"] = space_id + records.append(record) + fields = (("makerspace_id",) + FIELDS) if aggregate else FIELDS + return ReportResult(fields, limited(records, limit)) + + +def _empty_group(): + return { + "request_count": 0, "anonymous_requests": 0, "requested_units": 0, + "accepted_units": 0, "issued_units": 0, "returned_units": 0, + "damaged_units": 0, "missing_units": 0, "_borrowers": set(), + "_approval_seconds": [], "_loan_seconds": [], + } + + +def _average_hours(values): + return round(sum(values) / len(values) / 3600, 2) if values else None diff --git a/backend/apps/operations/schemas_reports.py b/backend/apps/operations/schemas_reports.py index 25691d95..59163987 100644 --- a/backend/apps/operations/schemas_reports.py +++ b/backend/apps/operations/schemas_reports.py @@ -19,6 +19,7 @@ MemberActivityReportSerializer, PaymentReconciliationReportSerializer, ) +from apps.operations.serializers_report_catalog import GenericAnalyticsReportSerializer ANALYTICS_REPORT_RESPONSE = PolymorphicProxySerializer( component_name="AnalyticsReportResponse", @@ -40,6 +41,7 @@ MemberActivityReportSerializer, FabLabHealthReportSerializer, PaymentReconciliationReportSerializer, + GenericAnalyticsReportSerializer, ], resource_type_field_name=None, ) diff --git a/backend/apps/operations/serializers_report_catalog.py b/backend/apps/operations/serializers_report_catalog.py new file mode 100644 index 00000000..30212b0d --- /dev/null +++ b/backend/apps/operations/serializers_report_catalog.py @@ -0,0 +1,26 @@ +from rest_framework import serializers + + +class ReportCatalogItemSerializer(serializers.Serializer): + key = serializers.CharField() + title = serializers.CharField() + fields = serializers.ListField(child=serializers.CharField()) + exportable = serializers.BooleanField() + summary = serializers.BooleanField() + required_modules = serializers.ListField(child=serializers.CharField()) + available = serializers.BooleanField(allow_null=True) + unavailable_reason = serializers.CharField(allow_null=True) + grains = serializers.ListField(child=serializers.CharField()) + chart_hint = serializers.CharField() + aggregate_supported = serializers.BooleanField() + + +class ReportCatalogSerializer(serializers.Serializer): + results = ReportCatalogItemSerializer(many=True) + + +class GenericAnalyticsReportSerializer(serializers.Serializer): + report_key = serializers.CharField(required=False) + rows = serializers.ListField(child=serializers.ListField(child=serializers.JSONField())) + typed_rows = serializers.ListField(child=serializers.DictField(), required=False) + meta = serializers.DictField(required=False) diff --git a/backend/apps/operations/serializers_reports_fablab.py b/backend/apps/operations/serializers_reports_fablab.py index fc3d7abb..5db43151 100644 --- a/backend/apps/operations/serializers_reports_fablab.py +++ b/backend/apps/operations/serializers_reports_fablab.py @@ -14,14 +14,19 @@ class MachineUsageRowSerializer(TypedReportBaseSerializer): class EventAttendanceRowSerializer(TypedReportBaseSerializer): event_id = serializers.IntegerField() + series_id = serializers.IntegerField(allow_null=True) + series_title = serializers.CharField(allow_blank=True) + series_occurrence_key = serializers.CharField(allow_blank=True) title = serializers.CharField() starts_at = serializers.DateTimeField() status = serializers.CharField() capacity = serializers.IntegerField() registrations = serializers.IntegerField() confirmed = serializers.IntegerField() + pending_approval = serializers.IntegerField() registered = serializers.IntegerField() waitlisted = serializers.IntegerField() + rejected = serializers.IntegerField() cancelled = serializers.IntegerField() attended = serializers.IntegerField() attendance_rate_percent = serializers.FloatField(allow_null=True) diff --git a/backend/apps/operations/tasks.py b/backend/apps/operations/tasks.py new file mode 100644 index 00000000..d9cc1595 --- /dev/null +++ b/backend/apps/operations/tasks.py @@ -0,0 +1,11 @@ +from celery import shared_task + +from apps.makerspaces.models import Makerspace +from apps.makerspaces.servability import servable_queryset +from apps.operations.report_rollups import finalize_evidence_rollups + + +@shared_task(bind=True, autoretry_for=(Exception,), retry_backoff=True, max_retries=3) +def finalize_report_rollups_task(self): + for makerspace in servable_queryset(Makerspace.objects.all()).iterator(chunk_size=100): + finalize_evidence_rollups(makerspace) diff --git a/backend/apps/operations/urls.py b/backend/apps/operations/urls.py index 01ace3e6..0b37ad75 100644 --- a/backend/apps/operations/urls.py +++ b/backend/apps/operations/urls.py @@ -25,11 +25,13 @@ path("admin/ledger/export", views.AggregateLedgerExportView.as_view(), name="ledger-export-aggregate"), path("admin/ledger", views.AggregateLedgerView.as_view(), name="ledger-aggregate"), path("admin/analytics/", views.AggregateAnalyticsView.as_view(), name="analytics-aggregate"), + path("admin/reports/catalog", views.AggregateReportCatalogView.as_view(), name="report-catalog-aggregate"), path("admin/organizations//analytics/", views.OrganizationAnalyticsView.as_view(), name="organization-analytics"), path("admin/reports//export", views.AggregateReportExportView.as_view(), name="report-export-aggregate"), path("admin/makerspace//ledger/export", views.LedgerExportView.as_view(), name="ledger-export"), path("admin/makerspace//ledger", views.LedgerView.as_view(), name="ledger"), path("admin/makerspace//accountability", views.AccountabilityReportView.as_view(), name="accountability-dashboard"), + path("admin/makerspace//reports/catalog", views.ReportCatalogView.as_view(), name="report-catalog"), path("admin/makerspace//problem-reports//resolve", views.ProblemReportResolveView.as_view(), name="problem-report-resolve"), path("admin/makerspace//problem-reports//triage", views.ProblemReportTriageView.as_view(), name="problem-report-triage"), path("admin/makerspace//analytics/summary", views.AnalyticsView.as_view(), {"report_key": "summary"}, name="analytics-summary"), @@ -49,6 +51,7 @@ path("admin/makerspace//analytics/member-activity", views.AnalyticsView.as_view(), {"report_key": "member-activity"}, name="analytics-member-activity"), path("admin/makerspace//analytics/fablab-health", views.AnalyticsView.as_view(), {"report_key": "fablab-health"}, name="analytics-fablab-health"), path("admin/makerspace//analytics/payment-reconciliation", views.AnalyticsView.as_view(), {"report_key": "payment-reconciliation"}, name="analytics-payment-reconciliation"), + path("admin/makerspace//analytics/", views.AnalyticsView.as_view(), name="analytics-generic"), path("admin/makerspace//reports//export", views.ReportExportView.as_view(), name="report-export"), path("admin/makerspace//qr-print-batches", views.QrPrintBatchListCreateView.as_view(), name="qr-print-batches"), path("admin/qr-print-batches/", views.QrPrintBatchDetailView.as_view(), name="qr-print-batch-detail"), diff --git a/backend/apps/operations/views.py b/backend/apps/operations/views.py index 5b89509a..8b7ddb6c 100644 --- a/backend/apps/operations/views.py +++ b/backend/apps/operations/views.py @@ -79,6 +79,8 @@ AggregateAnalyticsView, AggregateReportExportView, AnalyticsView, + AggregateReportCatalogView, + ReportCatalogView, ReportExportView, _csv_response, _makerspace_for_inventory_view, diff --git a/backend/apps/operations/views_report_helpers.py b/backend/apps/operations/views_report_helpers.py new file mode 100644 index 00000000..6164f8b3 --- /dev/null +++ b/backend/apps/operations/views_report_helpers.py @@ -0,0 +1,84 @@ +from datetime import datetime, time, timedelta + +from django.shortcuts import get_object_or_404 +from django.utils import timezone +from django.utils.dateparse import parse_date +from rest_framework.exceptions import PermissionDenied, ValidationError + +from apps.accounts import rbac +from apps.makerspaces.guards import require_module +from apps.makerspaces.models import Makerspace +from apps.operations import reports +from apps.operations.report_registry import REPORT_DEFINITIONS + + +def _require_source_modules(makerspace, modules): + for module in modules: + require_module(makerspace, module) + + +def _date_range(request): + start = _date_param(request, "start") + end = _date_param(request, "end") + 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 + end_dt = timezone.make_aware(datetime.combine(end + timedelta(days=1), time.min)) if end else None + return (start_dt, end_dt) if start_dt or end_dt else None + + +def _date_param(request, name): + raw = (request.query_params.get(name) or "").strip() + if not raw: + return None + parsed = parse_date(raw) + if parsed is None: + raise ValidationError({name: "Use YYYY-MM-DD."}) + return parsed + + +def _makerspace_for_inventory_view(user, makerspace_id): + queryset = rbac.scope_by_action( + user, rbac.Action.VIEW_INVENTORY, Makerspace.objects.all(), field="id" + ) + queryset = rbac.hide_from_superadmin(user, queryset, field="id") + return get_object_or_404(queryset, pk=makerspace_id) + + +def _makerspace_for_catalog(user, makerspace_id): + queryset = Makerspace.objects.none() + for action in {definition.required_action for definition in REPORT_DEFINITIONS}: + queryset = queryset | rbac.scope_by_action( + user, action, Makerspace.objects.all(), field="id" + ) + queryset = rbac.hide_from_superadmin(user, queryset, field="id") + return get_object_or_404(queryset.distinct(), pk=makerspace_id) + + +def _require_superadmin(user): + if not (user.is_superuser or user.role == user.Role.SUPERADMIN): + raise PermissionDenied() + + +def _positive_int_param(request, name, default, maximum): + raw = request.query_params.get(name, default) + try: + value = int(raw) + except (TypeError, ValueError) as exc: + raise ValidationError({name: "Enter a positive integer."}) from exc + if value < 1: + raise ValidationError({name: "Enter a positive integer."}) + return min(value, maximum) + + +def _page_params(request): + return ( + _positive_int_param(request, "page", 1, 1000000), + _positive_int_param(request, "page_size", 100, 500), + ) + + +def _limit_param(request): + return _positive_int_param( + request, "limit", reports.DEFAULT_REPORT_LIMIT, reports.MAX_REPORT_LIMIT + ) diff --git a/backend/apps/operations/views_reports.py b/backend/apps/operations/views_reports.py index 098062f9..93a1a849 100644 --- a/backend/apps/operations/views_reports.py +++ b/backend/apps/operations/views_reports.py @@ -1,25 +1,32 @@ -from datetime import datetime, time, timedelta - -from django.shortcuts import get_object_or_404 -from django.utils import timezone -from django.utils.dateparse import parse_date from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema -from rest_framework.exceptions import PermissionDenied, ValidationError +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, require_action from apps.makerspaces.guards import require_module -from apps.makerspaces.models import Makerspace +from apps.makerspaces.platform import module_enabled from apps.operations import accountability, reports +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.schemas_reports import ANALYTICS_REPORT_RESPONSE from apps.operations.serializers import EmptySerializer, GenericObjectSerializer from apps.operations.serializers_reports import ReportErrorSerializer +from apps.operations.serializers_report_catalog import ReportCatalogSerializer from apps.operations.serializers_reports_payments import PaymentReportFilterSerializer from apps.payments.models import Payment +from apps.operations.views_report_helpers import ( + _date_range, + _limit_param, + _makerspace_for_catalog, + _makerspace_for_inventory_view, + _page_params, + _require_source_modules, + _require_superadmin, +) DATE_RANGE_PARAMETERS = [ @@ -34,6 +41,7 @@ OpenApiParameter("limit", OpenApiTypes.INT, OpenApiParameter.QUERY), *DATE_RANGE_PARAMETERS, *PAYMENT_FILTER_PARAMETERS, + OpenApiParameter("grain", OpenApiTypes.STR, OpenApiParameter.QUERY, enum=["day", "month"]), ] ERROR_RESPONSES = { 400: OpenApiResponse(ReportErrorSerializer, description="Invalid report request."), @@ -58,6 +66,10 @@ class AnalyticsView(APIView): responses={200: ANALYTICS_REPORT_RESPONSE, **ERROR_RESPONSES}, ) def get(self, request, makerspace_id, report_key="summary", *args, **kwargs): + # Inventory-first resolution, deliberately: scoping the queryset by the REPORT's + # own action would turn "you may not run this report" into a 404 instead of a + # 403, which is the contract pinned by + # test_report_rbac_status_codes_match_inventory_first_resolution. makerspace = _makerspace_for_inventory_view(request.user, makerspace_id) definition = reports.validate_report_key(report_key) require_action(request.user, definition.required_action, makerspace.id) @@ -66,10 +78,49 @@ def get(self, request, makerspace_id, report_key="summary", *args, **kwargs): return Response(reports.report_data( report_key, makerspace.id, limit=_limit_param(request), date_range=_date_range(request), - report_filters=_report_filters(request, report_key), + report_filters=_report_filters(request, report_key), grain=_grain_param(request, definition), )) +class ReportCatalogView(APIView): + permission_classes = [IsActiveStaff] + serializer_class = ReportCatalogSerializer + + @extend_schema( + tags=["Reports"], summary="List makerspace report catalog", request=None, + responses={200: ReportCatalogSerializer, **ERROR_RESPONSES}, + ) + def get(self, request, makerspace_id, *args, **kwargs): + makerspace = _makerspace_for_catalog(request.user, makerspace_id) + require_module(makerspace, "reports") + entries = [] + for definition in REPORT_DEFINITIONS: + if not rbac.can(request.user, definition.required_action, makerspace.id): + continue + missing = [key for key in definition.required_modules if not module_enabled(makerspace, key)] + entries.append(_catalog_entry( + definition, available=not missing, + reason=f"Required module disabled: {', '.join(missing)}" if missing else None, + )) + return Response({"results": entries}) + + +class AggregateReportCatalogView(APIView): + permission_classes = [IsActiveStaff] + serializer_class = ReportCatalogSerializer + + @extend_schema( + tags=["Reports"], summary="List deployment report catalog", request=None, + responses={200: ReportCatalogSerializer, **ERROR_RESPONSES}, + ) + def get(self, request, *args, **kwargs): + _require_superadmin(request.user) + return Response({"results": [ + _catalog_entry(definition, available=None, reason=None) + for definition in REPORT_DEFINITIONS + ]}) + + class AccountabilityReportView(APIView): permission_classes = [IsActiveStaff] serializer_class = GenericObjectSerializer @@ -103,6 +154,7 @@ def get(self, request, report_key="summary", *args, **kwargs): return Response(reports.report_data( report_key, limit=_limit_param(request), date_range=_date_range(request), report_filters=_report_filters(request, report_key), + grain=_grain_param(request, reports.validate_report_key(report_key)), )) @@ -117,10 +169,15 @@ class ReportExportView(APIView): OpenApiParameter("format", OpenApiTypes.STR, OpenApiParameter.QUERY, enum=["csv", "xlsx"]), *DATE_RANGE_PARAMETERS, *PAYMENT_FILTER_PARAMETERS, + OpenApiParameter("grain", OpenApiTypes.STR, OpenApiParameter.QUERY, enum=["day", "month"]), ], responses=EXPORT_RESPONSES, ) def get(self, request, makerspace_id, report_key, *args, **kwargs): + # Inventory-first resolution, deliberately: scoping the queryset by the REPORT's + # own action would turn "you may not run this report" into a 404 instead of a + # 403, which is the contract pinned by + # test_report_rbac_status_codes_match_inventory_first_resolution. makerspace = _makerspace_for_inventory_view(request.user, makerspace_id) definition = reports.validate_report_key(report_key, for_export=True) require_action(request.user, definition.required_action, makerspace.id) @@ -130,6 +187,7 @@ def get(self, request, makerspace_id, report_key, *args, **kwargs): 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") @@ -145,6 +203,7 @@ class AggregateReportExportView(APIView): OpenApiParameter("format", OpenApiTypes.STR, OpenApiParameter.QUERY, enum=["csv", "xlsx"]), *DATE_RANGE_PARAMETERS, *PAYMENT_FILTER_PARAMETERS, + OpenApiParameter("grain", OpenApiTypes.STR, OpenApiParameter.QUERY, enum=["day", "month"]), ], responses=EXPORT_RESPONSES, ) @@ -155,6 +214,7 @@ def get(self, request, report_key, *args, **kwargs): 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") @@ -167,44 +227,6 @@ def report_rows(makerspace_id, report_key): return reports.report_rows(report_key, makerspace_id) -def _require_source_modules(makerspace, modules): - for module in modules: - require_module(makerspace, module) - - -def _date_range(request): - start = _date_param(request, "start") - end = _date_param(request, "end") - 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 - end_dt = timezone.make_aware(datetime.combine(end + timedelta(days=1), time.min)) if end else None - return (start_dt, end_dt) if start_dt or end_dt else None - - -def _date_param(request, name): - raw = (request.query_params.get(name) or "").strip() - if not raw: - return None - parsed = parse_date(raw) - if parsed is None: - raise ValidationError({name: "Use YYYY-MM-DD."}) - return parsed - - -def _makerspace_for_inventory_view(user, makerspace_id): - queryset = rbac.scope_by_action( - user, rbac.Action.VIEW_INVENTORY, Makerspace.objects.all(), field="id" - ) - queryset = rbac.hide_from_superadmin(user, queryset, field="id") - return get_object_or_404(queryset, pk=makerspace_id) - - -def _require_superadmin(user): - if not (user.is_superuser or user.role == user.Role.SUPERADMIN): - raise PermissionDenied() - - def _export_format(request): fmt = (request.query_params.get("format") or "csv").strip().lower() if fmt not in {"csv", "xlsx"}: @@ -212,28 +234,28 @@ def _export_format(request): return fmt -def _positive_int_param(request, name, default, maximum): - raw = request.query_params.get(name, default) - try: - value = int(raw) - except (TypeError, ValueError) as exc: - raise ValidationError({name: "Enter a positive integer."}) from exc - if value < 1: - raise ValidationError({name: "Enter a positive integer."}) - return min(value, maximum) - - -def _page_params(request): - return (_positive_int_param(request, "page", 1, 1000000), _positive_int_param(request, "page_size", 100, 500)) - - -def _limit_param(request): - return _positive_int_param(request, "limit", reports.DEFAULT_REPORT_LIMIT, reports.MAX_REPORT_LIMIT) - - def _report_filters(request, report_key): if report_key != "payment-reconciliation": return {} serializer = PaymentReportFilterSerializer(data=request.query_params) serializer.is_valid(raise_exception=True) return serializer.validated_data + + +def _grain_param(request, definition): + value = (request.query_params.get("grain") or "day").strip().lower() + allowed = definition.grains or ("day",) + if value not in allowed: + raise ValidationError({"grain": f"Use one of: {', '.join(allowed)}."}) + return value + + +def _catalog_entry(definition, *, available, reason): + return { + "key": definition.key, "title": definition.title or definition.key.replace("-", " ").title(), + "fields": list(definition.fields), "exportable": definition.exportable, + "summary": definition.summary, "required_modules": list(definition.required_modules), + "available": available, "unavailable_reason": reason, + "grains": list(definition.grains or ("day",)), "chart_hint": definition.chart_hint, + "aggregate_supported": definition.key in STRATEGIES, + } diff --git a/backend/apps/organizations/access.py b/backend/apps/organizations/access.py new file mode 100644 index 00000000..5d840cd0 --- /dev/null +++ b/backend/apps/organizations/access.py @@ -0,0 +1,69 @@ +"""Read and locked authorization helpers for global organizations.""" + +from django.db.models import Q +from rest_framework.exceptions import PermissionDenied + +from apps.accounts.models import User +from apps.organizations import governance +from apps.organizations.models import Organization, OrganizationMembership + + +def is_superadmin(actor) -> bool: + return bool( + actor + and getattr(actor, "is_authenticated", False) + and (actor.is_superuser or actor.role == User.Role.SUPERADMIN) + ) + + +def visible_organizations(actor): + queryset = Organization.objects.all() + if is_superadmin(actor): + return queryset + return queryset.filter( + is_active=True, + memberships__user=actor, + memberships__status=OrganizationMembership.Status.ACTIVE, + ).distinct() + + +def active_membership(actor, organization): + if is_superadmin(actor): + return None + return OrganizationMembership.objects.filter( + organization=organization, + user=actor, + status=OrganizationMembership.Status.ACTIVE, + organization__is_active=True, + ).first() + + +def require_governance(actor, organization, action): + if action not in governance.actions_for(actor, organization): + raise PermissionDenied() + + +def lock_governance_membership(actor, organization, action): + """Recheck organization authority under the row changed by governance edits.""" + if is_superadmin(actor): + return None + membership = OrganizationMembership.objects.select_for_update().filter( + organization=organization, + user=actor, + status=OrganizationMembership.Status.ACTIVE, + organization__is_active=True, + ).first() + if action not in governance.actions_for_membership(membership): + raise PermissionDenied() + return membership + + +def organization_membership_q(actor): + """Reusable active-membership predicate for assignable organization lists.""" + if is_superadmin(actor): + return Q(is_active=True) + return Q( + is_active=True, + memberships__user=actor, + memberships__status=OrganizationMembership.Status.ACTIVE, + ) diff --git a/backend/apps/organizations/admin.py b/backend/apps/organizations/admin.py index 25ce4a54..718d872b 100644 --- a/backend/apps/organizations/admin.py +++ b/backend/apps/organizations/admin.py @@ -10,6 +10,7 @@ OrganizationMakerspace, OrganizationMembership, ) +from apps.organizations.governance import GOVERNANCE_ACTIONS from config.admin_access import SuperuserOnlyModelAdmin @@ -217,6 +218,21 @@ def clean_granted_actions(self): ) return sorted(values) + def clean_governance_actions(self): + actions = self.cleaned_data.get("governance_actions") + if actions in (None, ""): + return [] + if not isinstance(actions, list) or any( + not isinstance(item, str) for item in actions + ): + raise forms.ValidationError("Use a list of governance action values.") + unknown = set(actions) - GOVERNANCE_ACTIONS + if unknown: + raise forms.ValidationError( + f"Unknown governance action: {sorted(unknown)[0]}." + ) + return sorted(set(actions)) + @admin.register(OrganizationMembership) class OrganizationMembershipAdmin(SuperuserOnlyModelAdmin, ModelAdmin): diff --git a/backend/apps/organizations/exceptions.py b/backend/apps/organizations/exceptions.py new file mode 100644 index 00000000..bd67d7c5 --- /dev/null +++ b/backend/apps/organizations/exceptions.py @@ -0,0 +1,40 @@ +from rest_framework.exceptions import APIException + + +class OrganizationConflict(APIException): + status_code = 409 + default_detail = "The organization state changed. Refresh and try again." + default_code = "organization_conflict" + + def __init__(self, detail=None, code=None): + super().__init__( + { + "detail": detail or self.default_detail, + "code": code or self.default_code, + } + ) + + +class InvitationExpired(OrganizationConflict): + default_detail = "This invitation has expired." + default_code = "invitation_expired" + + +class InvitationRevoked(OrganizationConflict): + default_detail = "This invitation has been revoked." + default_code = "invitation_revoked" + + +class InvitationRedeemed(OrganizationConflict): + default_detail = "This invitation has already been redeemed." + default_code = "invitation_redeemed" + + +class MembershipSuspended(OrganizationConflict): + default_detail = "The existing organization membership is suspended." + default_code = "organization_membership_suspended" + + +class InvitationGrantChanged(OrganizationConflict): + default_detail = "The inviter no longer holds every proposed action." + default_code = "invitation_grant_changed" diff --git a/backend/apps/organizations/governance.py b/backend/apps/organizations/governance.py new file mode 100644 index 00000000..7d4eb626 --- /dev/null +++ b/backend/apps/organizations/governance.py @@ -0,0 +1,57 @@ +"""Organization-only authority, kept separate from makerspace RBAC grants.""" + +from apps.accounts.models import User + + +MANAGE_ORGANIZATION_PROFILE = "manage_organization_profile" +MANAGE_ORGANIZATION_MEMBERS = "manage_organization_members" +GOVERNANCE_ACTIONS = frozenset( + {MANAGE_ORGANIZATION_PROFILE, MANAGE_ORGANIZATION_MEMBERS} +) + + +def actions_for_membership(membership) -> set[str]: + if membership is None or membership.status != membership.Status.ACTIVE: + return set() + value = membership.governance_actions + if not isinstance(value, list): + return set() + return { + action + for action in value + if isinstance(action, str) and action in GOVERNANCE_ACTIONS + } + + +def actions_for(actor, organization) -> set[str]: + if actor is None or not getattr(actor, "is_authenticated", False): + return set() + if actor.is_superuser or actor.role == User.Role.SUPERADMIN: + return set(GOVERNANCE_ACTIONS) + if not organization.is_active: + return set() + membership = organization.memberships.filter( + user=actor, + status="active", + ).first() + return actions_for_membership(membership) + + +def has_any_governance(actor) -> bool: + if actor is None or not getattr(actor, "is_authenticated", False): + return False + if actor.is_superuser or actor.role == User.Role.SUPERADMIN: + return True + from apps.organizations.models import OrganizationMembership + + queryset = OrganizationMembership.objects.filter( + user=actor, + status=OrganizationMembership.Status.ACTIVE, + organization__is_active=True, + ) + from django.db.models import Q + + action_filter = Q() + for action in GOVERNANCE_ACTIONS: + action_filter |= Q(governance_actions__contains=[action]) + return queryset.filter(action_filter).exists() diff --git a/backend/apps/organizations/migrations/0003_organization_presentation_and_governance.py b/backend/apps/organizations/migrations/0003_organization_presentation_and_governance.py new file mode 100644 index 00000000..f4a9f251 --- /dev/null +++ b/backend/apps/organizations/migrations/0003_organization_presentation_and_governance.py @@ -0,0 +1,48 @@ +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ("organizations", "0002_organizationmembership"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name="organization", + name="public_profile_enabled", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="organizationmembership", + name="governance_actions", + field=models.JSONField(blank=True, default=list), + ), + migrations.CreateModel( + name="OrganizationInvitation", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("token_digest", models.CharField(editable=False, max_length=64, unique=True)), + ("granted_actions", models.JSONField(blank=True, default=list)), + ("governance_actions", models.JSONField(blank=True, default=list)), + ("expires_at", models.DateTimeField()), + ("redeemed_at", models.DateTimeField(blank=True, null=True)), + ("revoked_at", models.DateTimeField(blank=True, null=True)), + ("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="created_organization_invitations", to=settings.AUTH_USER_MODEL)), + ("organization", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="invitations", to="organizations.organization")), + ("redeemed_by", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="redeemed_organization_invitations", to=settings.AUTH_USER_MODEL)), + ], + options={ + "ordering": ("-created_at", "-id"), + "indexes": [models.Index(condition=models.Q(("redeemed_at__isnull", True), ("revoked_at__isnull", True)), fields=["organization", "expires_at"], name="org_invitation_active_idx")], + "constraints": [ + models.CheckConstraint(condition=models.Q(models.Q(("redeemed_at__isnull", True), ("redeemed_by__isnull", True)), models.Q(("redeemed_at__isnull", False), ("redeemed_by__isnull", False)), _connector="OR"), name="org_invitation_redemption_complete"), + models.CheckConstraint(condition=models.Q(("redeemed_at__isnull", True), ("revoked_at__isnull", True), _connector="OR"), name="org_invitation_not_redeemed_revoked"), + ], + }, + ), + ] diff --git a/backend/apps/organizations/models.py b/backend/apps/organizations/models.py index 76d63d03..cfeac135 100644 --- a/backend/apps/organizations/models.py +++ b/backend/apps/organizations/models.py @@ -15,6 +15,7 @@ class Organization(models.Model): website = models.URLField(blank=True) description = models.TextField(blank=True) is_active = models.BooleanField(default=True) + public_profile_enabled = models.BooleanField(default=False) makerspaces = models.ManyToManyField( "makerspaces.Makerspace", through="OrganizationMakerspace", @@ -104,6 +105,7 @@ class Status(models.TextChoices): related_name="organization_memberships", ) granted_actions = models.JSONField(default=list, blank=True) + governance_actions = models.JSONField(default=list, blank=True) status = models.CharField( max_length=16, choices=Status.choices, @@ -135,3 +137,59 @@ class Meta: def __str__(self): return f"{self.user} in {self.organization}" + + +class OrganizationInvitation(models.Model): + organization = models.ForeignKey( + Organization, + on_delete=models.CASCADE, + related_name="invitations", + ) + token_digest = models.CharField(max_length=64, unique=True, editable=False) + granted_actions = models.JSONField(default=list, blank=True) + governance_actions = models.JSONField(default=list, blank=True) + expires_at = models.DateTimeField() + redeemed_at = models.DateTimeField(null=True, blank=True) + revoked_at = models.DateTimeField(null=True, blank=True) + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="created_organization_invitations", + ) + redeemed_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="redeemed_organization_invitations", + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ("-created_at", "-id") + constraints = [ + models.CheckConstraint( + condition=( + Q(redeemed_at__isnull=True, redeemed_by__isnull=True) + | Q(redeemed_at__isnull=False, redeemed_by__isnull=False) + ), + name="org_invitation_redemption_complete", + ), + models.CheckConstraint( + condition=Q(redeemed_at__isnull=True) | Q(revoked_at__isnull=True), + name="org_invitation_not_redeemed_revoked", + ), + ] + indexes = [ + models.Index( + fields=("organization", "expires_at"), + condition=Q(redeemed_at__isnull=True, revoked_at__isnull=True), + name="org_invitation_active_idx", + ), + ] + + def __str__(self): + return f"Invitation to {self.organization}" diff --git a/backend/apps/organizations/public_catalog.py b/backend/apps/organizations/public_catalog.py new file mode 100644 index 00000000..7a42b901 --- /dev/null +++ b/backend/apps/organizations/public_catalog.py @@ -0,0 +1,43 @@ +from django.db.models import Count, Prefetch, Q +from django.utils import timezone + +from apps.events.models import Event, EventRegistration +from apps.events.organizer_models import EventOrganizer +from apps.makerspaces.servability import servable_q +from apps.separability.registry import runtime_active + + +def public_events_for(organization): + if not runtime_active("events"): + return Event.objects.none() + return ( + Event.objects.filter( + organizers__organization=organization, + makerspace__hidden_from_central_directory=False, + makerspace__enabled_modules__contains=["events"], + is_public=True, + status=Event.Status.PUBLISHED, + ends_at__gte=timezone.now(), + ) + .filter(servable_q("makerspace")) + .select_related("makerspace") + .prefetch_related( + Prefetch( + "organizers", + queryset=EventOrganizer.objects.select_related("organization"), + ) + ) + .annotate( + confirmed_count=Count( + "registrations", + filter=Q( + registrations__status__in=( + EventRegistration.Status.REGISTERED, + EventRegistration.Status.ATTENDED, + ) + ), + ) + ) + .distinct() + .order_by("starts_at", "id") + ) diff --git a/backend/apps/organizations/serializers_admin.py b/backend/apps/organizations/serializers_admin.py new file mode 100644 index 00000000..5679cfad --- /dev/null +++ b/backend/apps/organizations/serializers_admin.py @@ -0,0 +1,169 @@ +from drf_spectacular.utils import extend_schema_field +from rest_framework import serializers + +from apps.accounts import rbac +from apps.accounts.schemas_auth import UserPayloadSerializer +from apps.inventory import public_image_storage +from apps.organizations import governance +from apps.organizations.access import is_superadmin +from apps.organizations.models import ( + Organization, + OrganizationInvitation, + OrganizationMembership, +) + + +def _actor_membership(obj): + rows = getattr(obj, "actor_memberships", ()) + return rows[0] if rows else None + + +class OrganizationSummarySerializer(serializers.ModelSerializer): + governance_actions = serializers.SerializerMethodField() + granted_actions = serializers.SerializerMethodField() + + class Meta: + model = Organization + fields = ("id", "slug", "name", "governance_actions", "granted_actions") + read_only_fields = fields + + @extend_schema_field(serializers.ListField(child=serializers.CharField())) + def get_governance_actions(self, obj): + actor = self.context["actor"] + if is_superadmin(actor): + return sorted(governance.GOVERNANCE_ACTIONS) + return sorted(governance.actions_for_membership(_actor_membership(obj))) + + @extend_schema_field(serializers.ListField(child=serializers.CharField())) + def get_granted_actions(self, obj): + actor = self.context["actor"] + if is_superadmin(actor): + return sorted(rbac.ORGANIZATION_GRANTABLE_ACTIONS) + return sorted(rbac.actions_for_organization_membership(_actor_membership(obj))) + + +class OrganizationDetailSerializer(OrganizationSummarySerializer): + logo_url = serializers.SerializerMethodField() + + class Meta: + model = Organization + fields = OrganizationSummarySerializer.Meta.fields + ( + "description", + "website", + "logo_url", + "public_profile_enabled", + "is_active", + "legal_name", + "registration_number", + "contact_email", + "billing_email", + ) + read_only_fields = fields + + @extend_schema_field(serializers.URLField(allow_null=True)) + def get_logo_url(self, obj): + return public_image_storage.public_url(obj.logo_key) or None + + def to_representation(self, instance): + data = super().to_representation(instance) + actor_actions = set(data["governance_actions"]) + if governance.MANAGE_ORGANIZATION_PROFILE not in actor_actions: + for field in ("legal_name", "registration_number", "contact_email", "billing_email"): + data.pop(field, None) + return data + + +class OrganizationProfileUpdateSerializer(serializers.ModelSerializer): + class Meta: + model = Organization + fields = ("name", "slug", "description", "website", "public_profile_enabled") + extra_kwargs = {field: {"required": False} for field in fields} + + +class OrganizationMembershipSerializer(serializers.ModelSerializer): + user_id = serializers.IntegerField(read_only=True) + username = serializers.CharField(source="user.username", read_only=True) + display_name = serializers.CharField(source="user.display_name", read_only=True) + email = serializers.EmailField(source="user.email", read_only=True) + + class Meta: + model = OrganizationMembership + fields = ( + "id", "user_id", "username", "display_name", "email", "status", + "governance_actions", "granted_actions", "created_at", "updated_at", + ) + read_only_fields = fields + + +class OrganizationMembershipListSerializer(serializers.Serializer): + count = serializers.IntegerField() + next = serializers.CharField(allow_null=True) + previous = serializers.CharField(allow_null=True) + results = OrganizationMembershipSerializer(many=True) + + +class OrganizationInvitationSerializer(serializers.ModelSerializer): + organization_id = serializers.IntegerField(read_only=True) + created_by_id = serializers.IntegerField(allow_null=True, read_only=True) + redeemed_by_id = serializers.IntegerField(allow_null=True, read_only=True) + state = serializers.SerializerMethodField() + + class Meta: + model = OrganizationInvitation + fields = ( + "id", "organization_id", "governance_actions", "granted_actions", + "expires_at", "redeemed_at", "revoked_at", "created_by_id", + "redeemed_by_id", "created_at", "state", + ) + read_only_fields = fields + + @extend_schema_field( + serializers.ChoiceField(choices=("active", "expired", "revoked", "redeemed")) + ) + def get_state(self, obj): + from django.utils import timezone + + if obj.redeemed_at is not None: + return "redeemed" + if obj.revoked_at is not None: + return "revoked" + if obj.expires_at <= timezone.now(): + return "expired" + return "active" + + +class OrganizationInvitationListSerializer(serializers.Serializer): + count = serializers.IntegerField() + next = serializers.CharField(allow_null=True) + previous = serializers.CharField(allow_null=True) + results = OrganizationInvitationSerializer(many=True) + + +class OrganizationInvitationCreateSerializer(serializers.Serializer): + governance_actions = serializers.ListField(child=serializers.CharField(), default=list) + granted_actions = serializers.ListField(child=serializers.CharField(), default=list) + expires_in_days = serializers.IntegerField(min_value=1, max_value=30, default=7) + + +class OrganizationInvitationCreatedSerializer(OrganizationInvitationSerializer): + token = serializers.CharField(read_only=True) + redeem_path = serializers.CharField(read_only=True) + + class Meta(OrganizationInvitationSerializer.Meta): + fields = OrganizationInvitationSerializer.Meta.fields + ("token", "redeem_path") + + +class OrganizationInvitationRedeemSerializer(serializers.Serializer): + token = serializers.CharField(min_length=20, max_length=200, trim_whitespace=True) + + +class OrganizationInvitationRedeemedSerializer(serializers.Serializer): + membership = OrganizationMembershipSerializer() + user = UserPayloadSerializer + + +class OrganizationListSerializer(serializers.Serializer): + count = serializers.IntegerField() + next = serializers.CharField(allow_null=True) + previous = serializers.CharField(allow_null=True) + results = OrganizationSummarySerializer(many=True) diff --git a/backend/apps/organizations/serializers_public.py b/backend/apps/organizations/serializers_public.py new file mode 100644 index 00000000..bc52f6bb --- /dev/null +++ b/backend/apps/organizations/serializers_public.py @@ -0,0 +1,56 @@ +from drf_spectacular.utils import extend_schema_field +from rest_framework import serializers + +from apps.events.serializers_public import PublicEventSerializer +from apps.inventory import public_image_storage +from apps.organizations.models import Organization + + +class PublicOrganizationSerializer(serializers.ModelSerializer): + logo_url = serializers.SerializerMethodField() + catalogue_links = serializers.SerializerMethodField() + + class Meta: + model = Organization + fields = ( + "slug", + "name", + "description", + "website", + "logo_url", + "catalogue_links", + ) + read_only_fields = fields + + @extend_schema_field(serializers.URLField(allow_null=True)) + def get_logo_url(self, obj): + return public_image_storage.public_url(obj.logo_key) or None + + @extend_schema_field( + serializers.DictField(child=serializers.CharField()) + ) + def get_catalogue_links(self, obj): + return { + "events": f"/api/v1/public/organizations/{obj.slug}/events/", + } + + +class OrganizationEventHostSerializer(serializers.Serializer): + slug = serializers.SlugField(read_only=True) + name = serializers.CharField(read_only=True) + logo_url = serializers.SerializerMethodField() + + @extend_schema_field(serializers.URLField(allow_null=True)) + def get_logo_url(self, obj): + return public_image_storage.public_url(obj.logo_key) or None + + +class PublicOrganizationEventSerializer(PublicEventSerializer): + host = OrganizationEventHostSerializer(source="makerspace", read_only=True) + + +class PublicOrganizationEventListSerializer(serializers.Serializer): + count = serializers.IntegerField() + next = serializers.CharField(allow_null=True) + previous = serializers.CharField(allow_null=True) + results = PublicOrganizationEventSerializer(many=True) diff --git a/backend/apps/organizations/services_invitations.py b/backend/apps/organizations/services_invitations.py new file mode 100644 index 00000000..a35e532c --- /dev/null +++ b/backend/apps/organizations/services_invitations.py @@ -0,0 +1,212 @@ +import hashlib +import secrets +from datetime import timedelta + +from django.db import transaction +from django.http import Http404 +from django.utils import timezone +from rest_framework.exceptions import PermissionDenied, ValidationError + +from apps.accounts import rbac +from apps.accounts.models import User +from apps.audit import services as audit +from apps.organizations import governance +from apps.organizations.access import is_superadmin, lock_governance_membership +from apps.organizations.exceptions import ( + InvitationExpired, + InvitationGrantChanged, + InvitationRedeemed, + InvitationRevoked, + MembershipSuspended, +) +from apps.organizations.models import ( + Organization, + OrganizationInvitation, + OrganizationMembership, +) + + +DEFAULT_EXPIRY_DAYS = 7 +MAX_EXPIRY_DAYS = 30 + + +def _clean_actions(values, allowed, field): + if not isinstance(values, list) or any(not isinstance(item, str) for item in values): + raise ValidationError({field: "Use a list of action values."}) + unknown = set(values) - set(allowed) + if unknown: + raise ValidationError({field: f"Unknown action value: {sorted(unknown)[0]}."}) + return sorted(set(values)) + + +def _digest(token): + value = str(token or "").strip() + if not 20 <= len(value) <= 200: + raise Http404() + return hashlib.sha256(value.encode("utf-8")).hexdigest() + + +def _authority_actions(actor, membership): + if is_superadmin(actor): + return set(governance.GOVERNANCE_ACTIONS), set(rbac.ORGANIZATION_GRANTABLE_ACTIONS) + return ( + governance.actions_for_membership(membership), + rbac.actions_for_organization_membership(membership), + ) + + +@transaction.atomic +def create_invitation( + organization, + *, + actor, + governance_actions, + granted_actions, + expires_in_days=DEFAULT_EXPIRY_DAYS, +): + proposed_governance = _clean_actions( + governance_actions, governance.GOVERNANCE_ACTIONS, "governance_actions" + ) + proposed_grants = _clean_actions( + granted_actions, rbac.ORGANIZATION_GRANTABLE_ACTIONS, "granted_actions" + ) + if not 1 <= expires_in_days <= MAX_EXPIRY_DAYS: + raise ValidationError( + {"expires_in_days": f"Use a value from 1 to {MAX_EXPIRY_DAYS}."} + ) + + locked_org = Organization.objects.select_for_update().get(pk=organization.pk) + membership = lock_governance_membership( + actor, locked_org, governance.MANAGE_ORGANIZATION_MEMBERS + ) + held_governance, held_grants = _authority_actions(actor, membership) + if not set(proposed_governance).issubset(held_governance): + raise PermissionDenied("You cannot grant organization authority you do not hold.") + if not set(proposed_grants).issubset(held_grants): + raise PermissionDenied("You cannot grant makerspace actions you do not hold.") + + raw_token = secrets.token_urlsafe(32) + invitation = OrganizationInvitation.objects.create( + organization=locked_org, + token_digest=_digest(raw_token), + governance_actions=proposed_governance, + granted_actions=proposed_grants, + expires_at=timezone.now() + timedelta(days=expires_in_days), + created_by=actor, + ) + audit.record( + actor, + "organization.invitation_created", + target=invitation, + meta={ + "organization_id": locked_org.pk, + "governance_actions": proposed_governance, + "granted_actions": proposed_grants, + "expires_at": invitation.expires_at.isoformat(), + }, + ) + return invitation, raw_token + + +@transaction.atomic +def revoke_invitation(invitation, *, actor): + locked = OrganizationInvitation.objects.select_for_update().get(pk=invitation.pk) + organization = Organization.objects.select_for_update().get(pk=locked.organization_id) + lock_governance_membership( + actor, organization, governance.MANAGE_ORGANIZATION_MEMBERS + ) + if locked.redeemed_at is not None: + raise InvitationRedeemed() + if locked.revoked_at is None: + locked.revoked_at = timezone.now() + locked.save(update_fields=["revoked_at", "updated_at"]) + audit.record( + actor, + "organization.invitation_revoked", + target=locked, + meta={"organization_id": organization.pk}, + ) + return locked + + +@transaction.atomic +def redeem_invitation(token, *, actor): + invitation = OrganizationInvitation.objects.select_for_update().filter( + token_digest=_digest(token) + ).first() + if invitation is None: + raise Http404() + organization = Organization.objects.select_for_update().get(pk=invitation.organization_id) + locked_actor = User.objects.select_for_update().get(pk=actor.pk) + if ( + not locked_actor.is_active + or locked_actor.access_status != User.AccessStatus.ACTIVE + or locked_actor.must_change_password + or not organization.is_active + ): + raise PermissionDenied() + if invitation.redeemed_at is not None: + raise InvitationRedeemed() + if invitation.revoked_at is not None: + raise InvitationRevoked() + if invitation.expires_at <= timezone.now(): + raise InvitationExpired() + + memberships = list( + OrganizationMembership.objects.select_for_update() + .filter( + organization=organization, + user_id__in={invitation.created_by_id, locked_actor.pk} - {None}, + ) + .order_by("pk") + ) + by_user = {membership.user_id: membership for membership in memberships} + creator_membership = by_user.get(invitation.created_by_id) + if invitation.created_by_id is None: + raise InvitationGrantChanged() + creator = User.objects.select_for_update().filter(pk=invitation.created_by_id).first() + if ( + creator is None + or not creator.is_active + or creator.access_status != User.AccessStatus.ACTIVE + or creator.must_change_password + ): + raise InvitationGrantChanged() + held_governance, held_grants = _authority_actions(creator, creator_membership) + if not set(invitation.governance_actions).issubset(held_governance): + raise InvitationGrantChanged() + if not set(invitation.granted_actions).issubset(held_grants): + raise InvitationGrantChanged() + + membership = by_user.get(locked_actor.pk) + if membership is not None and membership.status == OrganizationMembership.Status.SUSPENDED: + raise MembershipSuspended() + if membership is None: + membership = OrganizationMembership.objects.create( + organization=organization, + user=locked_actor, + governance_actions=invitation.governance_actions, + granted_actions=invitation.granted_actions, + created_by=creator, + ) + else: + membership.governance_actions = sorted( + set(membership.governance_actions or []) | set(invitation.governance_actions) + ) + membership.granted_actions = sorted( + set(membership.granted_actions or []) | set(invitation.granted_actions) + ) + membership.save( + update_fields=["governance_actions", "granted_actions", "updated_at"] + ) + + invitation.redeemed_at = timezone.now() + invitation.redeemed_by = locked_actor + invitation.save(update_fields=["redeemed_at", "redeemed_by", "updated_at"]) + audit.record( + locked_actor, + "organization.invitation_redeemed", + target=invitation, + meta={"organization_id": organization.pk, "membership_id": membership.pk}, + ) + return membership diff --git a/backend/apps/organizations/services_profiles.py b/backend/apps/organizations/services_profiles.py new file mode 100644 index 00000000..0d5f7024 --- /dev/null +++ b/backend/apps/organizations/services_profiles.py @@ -0,0 +1,40 @@ +from django.db import transaction +from rest_framework.exceptions import ValidationError + +from apps.audit import services as audit +from apps.organizations import governance +from apps.organizations.access import lock_governance_membership +from apps.organizations.models import Organization + + +PUBLIC_PROFILE_FIELDS = frozenset( + {"name", "slug", "description", "website", "public_profile_enabled"} +) + + +@transaction.atomic +def update_profile(organization, *, actor, **changes): + unknown = set(changes) - PUBLIC_PROFILE_FIELDS + if unknown: + raise ValidationError({field: "This field cannot be edited here." for field in unknown}) + + locked = Organization.objects.select_for_update().get(pk=organization.pk) + lock_governance_membership( + actor, + locked, + governance.MANAGE_ORGANIZATION_PROFILE, + ) + changed_fields = [] + for field, value in changes.items(): + if getattr(locked, field) != value: + setattr(locked, field, value) + changed_fields.append(field) + if changed_fields: + locked.save(update_fields=[*changed_fields, "updated_at"]) + audit.record( + actor, + "organization.profile_updated", + target=locked, + meta={"fields": sorted(changed_fields), "slug": locked.slug}, + ) + return locked diff --git a/backend/apps/organizations/urls_admin.py b/backend/apps/organizations/urls_admin.py new file mode 100644 index 00000000..8f15add9 --- /dev/null +++ b/backend/apps/organizations/urls_admin.py @@ -0,0 +1,30 @@ +from django.urls import path + +from apps.organizations.views_admin import ( + OrganizationDetailView, + OrganizationInvitationListCreateView, + OrganizationInvitationRevokeView, + OrganizationListView, + OrganizationMembershipListView, +) + + +urlpatterns = [ + path("organizations/", OrganizationListView.as_view(), name="admin-organization-list"), + path("organizations//", OrganizationDetailView.as_view(), name="admin-organization-detail"), + path( + "organizations//memberships/", + OrganizationMembershipListView.as_view(), + name="admin-organization-memberships", + ), + path( + "organizations//invitations/", + OrganizationInvitationListCreateView.as_view(), + name="admin-organization-invitations", + ), + path( + "organization-invitations//", + OrganizationInvitationRevokeView.as_view(), + name="admin-organization-invitation-revoke", + ), +] diff --git a/backend/apps/organizations/urls_public.py b/backend/apps/organizations/urls_public.py new file mode 100644 index 00000000..3c839352 --- /dev/null +++ b/backend/apps/organizations/urls_public.py @@ -0,0 +1,32 @@ +from django.urls import path + +from apps.organizations.views_public import ( + PublicOrganizationDetailView, + PublicOrganizationEventListView, +) +from apps.separability.registry import runtime_active + + +def _events_routes(): + """The one route in this urlconf that serves a SEPARABLE app. + + `organizations` is not separable, so this module is included unconditionally -- but + a tombstone has to remove the surface, not merely empty its response. Without this + gate the OpenAPI schema kept advertising `/events/` on a deployment with the events + app tombstoned. `public_events_for` keeps its own `runtime_active` guard as depth. + """ + if not runtime_active("events"): + return [] + return [ + path( + "/events/", + PublicOrganizationEventListView.as_view(), + name="public-organization-events", + ) + ] + + +urlpatterns = [ + path("/", PublicOrganizationDetailView.as_view(), name="public-organization-detail"), + *_events_routes(), +] diff --git a/backend/apps/organizations/views_admin.py b/backend/apps/organizations/views_admin.py new file mode 100644 index 00000000..e605ecc5 --- /dev/null +++ b/backend/apps/organizations/views_admin.py @@ -0,0 +1,197 @@ +from django.db.models import Prefetch +from django.shortcuts import get_object_or_404 +from drf_spectacular.utils import OpenApiResponse, extend_schema +from rest_framework import status +from rest_framework.pagination import PageNumberPagination +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.organizations import governance, services_invitations, services_profiles +from apps.organizations.access import require_governance, visible_organizations +from apps.organizations.models import OrganizationInvitation, OrganizationMembership +from apps.organizations.serializers_admin import ( + OrganizationDetailSerializer, + OrganizationInvitationCreateSerializer, + OrganizationInvitationCreatedSerializer, + OrganizationInvitationListSerializer, + OrganizationInvitationSerializer, + OrganizationListSerializer, + OrganizationMembershipListSerializer, + OrganizationMembershipSerializer, + OrganizationProfileUpdateSerializer, + OrganizationSummarySerializer, +) + + +ERRORS = { + 401: OpenApiResponse(ErrorSerializer, description="Authentication is required."), + 403: OpenApiResponse(ErrorSerializer, description="Organization authority is required."), + 404: OpenApiResponse(ErrorSerializer, description="Organization not found."), +} +WRITE_ERRORS = { + **ERRORS, + 400: OpenApiResponse(description="Invalid organization data."), + 409: OpenApiResponse(ErrorSerializer, description="Organization state conflict."), +} + + +def _organizations_for(actor): + actor_memberships = OrganizationMembership.objects.filter( + user=actor, + status=OrganizationMembership.Status.ACTIVE, + ) + return visible_organizations(actor).prefetch_related( + Prefetch("memberships", queryset=actor_memberships, to_attr="actor_memberships") + ) + + +def _organization(actor, pk): + return get_object_or_404(_organizations_for(actor), pk=pk) + + +def _page(queryset, request, view, serializer, **context): + paginator = PageNumberPagination() + rows = paginator.paginate_queryset(queryset, request, view=view) + return paginator.get_paginated_response(serializer(rows, many=True, context=context).data) + + +class OrganizationListView(APIView): + permission_classes = [IsActiveStaff] + + @extend_schema( + operation_id="admin_organizations_list", + tags=["Admin organizations"], + summary="List organizations visible to the actor", + request=None, + responses={200: OrganizationListSerializer, **ERRORS}, + ) + def get(self, request): + return _page( + _organizations_for(request.user).order_by("name", "id"), + request, + self, + OrganizationSummarySerializer, + actor=request.user, + ) + + +class OrganizationDetailView(APIView): + permission_classes = [IsActiveStaff] + + @extend_schema( + operation_id="admin_organizations_retrieve", + tags=["Admin organizations"], + summary="Retrieve organization governance details", + request=None, + responses={200: OrganizationDetailSerializer, **ERRORS}, + ) + def get(self, request, pk): + organization = _organization(request.user, pk) + return Response( + OrganizationDetailSerializer(organization, context={"actor": request.user}).data + ) + + @extend_schema( + tags=["Admin organizations"], + summary="Update an organization public profile", + request=OrganizationProfileUpdateSerializer, + responses={200: OrganizationDetailSerializer, **WRITE_ERRORS}, + ) + def patch(self, request, pk): + organization = _organization(request.user, pk) + serializer = OrganizationProfileUpdateSerializer( + organization, data=request.data, partial=True + ) + serializer.is_valid(raise_exception=True) + updated = services_profiles.update_profile( + organization, actor=request.user, **serializer.validated_data + ) + updated.actor_memberships = list( + OrganizationMembership.objects.filter( + organization=updated, + user=request.user, + status=OrganizationMembership.Status.ACTIVE, + ) + ) + return Response( + OrganizationDetailSerializer(updated, context={"actor": request.user}).data + ) + + +class OrganizationMembershipListView(APIView): + permission_classes = [IsActiveStaff] + + @extend_schema( + tags=["Admin organizations"], + summary="List organization memberships", + request=None, + responses={200: OrganizationMembershipListSerializer, **ERRORS}, + ) + def get(self, request, pk): + organization = _organization(request.user, pk) + require_governance( + request.user, organization, governance.MANAGE_ORGANIZATION_MEMBERS + ) + queryset = organization.memberships.select_related("user").order_by("user__username", "id") + return _page(queryset, request, self, OrganizationMembershipSerializer) + + +class OrganizationInvitationListCreateView(APIView): + permission_classes = [IsActiveStaff] + + @extend_schema( + tags=["Admin organizations"], + summary="List organization invitations without bearer tokens", + request=None, + responses={200: OrganizationInvitationListSerializer, **ERRORS}, + ) + def get(self, request, pk): + organization = _organization(request.user, pk) + require_governance( + request.user, organization, governance.MANAGE_ORGANIZATION_MEMBERS + ) + return _page(organization.invitations.all(), request, self, OrganizationInvitationSerializer) + + @extend_schema( + tags=["Admin organizations"], + summary="Create a single-use organization invitation", + request=OrganizationInvitationCreateSerializer, + responses={201: OrganizationInvitationCreatedSerializer, **WRITE_ERRORS}, + ) + def post(self, request, pk): + organization = _organization(request.user, pk) + serializer = OrganizationInvitationCreateSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + invitation, token = services_invitations.create_invitation( + organization, actor=request.user, **serializer.validated_data + ) + payload = OrganizationInvitationSerializer(invitation).data + payload.update( + { + "token": token, + "redeem_path": "/api/v1/auth/organization-invitations/redeem/", + } + ) + return Response(payload, status=status.HTTP_201_CREATED) + + +class OrganizationInvitationRevokeView(APIView): + permission_classes = [IsActiveStaff] + + @extend_schema( + tags=["Admin organizations"], + summary="Revoke an unused organization invitation", + request=None, + responses={204: None, **WRITE_ERRORS}, + ) + def delete(self, request, pk): + invitation = get_object_or_404( + OrganizationInvitation.objects.select_related("organization").filter( + organization__in=visible_organizations(request.user) + ), + pk=pk, + ) + services_invitations.revoke_invitation(invitation, actor=request.user) + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/backend/apps/organizations/views_public.py b/backend/apps/organizations/views_public.py new file mode 100644 index 00000000..2193e499 --- /dev/null +++ b/backend/apps/organizations/views_public.py @@ -0,0 +1,65 @@ +from django.shortcuts import get_object_or_404 +from drf_spectacular.utils import OpenApiResponse, extend_schema +from rest_framework.pagination import PageNumberPagination +from rest_framework.permissions import AllowAny +from rest_framework.response import Response +from rest_framework.views import APIView + +from apps.apiclients.throttling import ClientTierRateThrottle +from apps.organizations.models import Organization +from apps.organizations.public_catalog import public_events_for +from apps.organizations.serializers_public import ( + PublicOrganizationEventListSerializer, + PublicOrganizationEventSerializer, + PublicOrganizationSerializer, +) + + +PUBLIC_ERRORS = { + 404: OpenApiResponse(description="Organization not found."), + 429: OpenApiResponse(description="Rate limit exceeded."), +} + + +def _public_organization(slug): + return get_object_or_404( + Organization.objects.filter(is_active=True, public_profile_enabled=True), + slug=slug, + ) + + +class PublicOrganizationDetailView(APIView): + permission_classes = [AllowAny] + throttle_classes = [ClientTierRateThrottle] + throttle_scope = "public_read" + + @extend_schema( + tags=["Public organizations"], + summary="Retrieve a public organization profile", + auth=[], + request=None, + responses={200: PublicOrganizationSerializer, **PUBLIC_ERRORS}, + ) + def get(self, request, slug): + return Response(PublicOrganizationSerializer(_public_organization(slug)).data) + + +class PublicOrganizationEventListView(APIView): + permission_classes = [AllowAny] + throttle_classes = [ClientTierRateThrottle] + throttle_scope = "public_read" + + @extend_schema( + tags=["Public organizations"], + summary="List public events organized across makerspaces", + auth=[], + request=None, + responses={200: PublicOrganizationEventListSerializer, **PUBLIC_ERRORS}, + ) + def get(self, request, slug): + organization = _public_organization(slug) + paginator = PageNumberPagination() + page = paginator.paginate_queryset(public_events_for(organization), request, view=self) + return paginator.get_paginated_response( + PublicOrganizationEventSerializer(page, many=True).data + ) diff --git a/backend/apps/organizations/views_redeem.py b/backend/apps/organizations/views_redeem.py new file mode 100644 index 00000000..37d33cfd --- /dev/null +++ b/backend/apps/organizations/views_redeem.py @@ -0,0 +1,45 @@ +from drf_spectacular.utils import OpenApiResponse, extend_schema +from rest_framework import status +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from apps.accounts.serializers import user_payload +from apps.hardware_requests.exceptions import ErrorSerializer +from apps.organizations import services_invitations +from apps.organizations.serializers_admin import ( + OrganizationInvitationRedeemedSerializer, + OrganizationInvitationRedeemSerializer, + OrganizationMembershipSerializer, +) + + +class OrganizationInvitationRedeemView(APIView): + permission_classes = [IsAuthenticated] + + @extend_schema( + tags=["Auth"], + summary="Redeem a single-use organization invitation", + request=OrganizationInvitationRedeemSerializer, + responses={ + 200: OrganizationInvitationRedeemedSerializer, + 400: OpenApiResponse(description="Malformed token."), + 401: ErrorSerializer, + 403: ErrorSerializer, + 404: OpenApiResponse(description="Invitation not found."), + 409: ErrorSerializer, + }, + ) + def post(self, request): + serializer = OrganizationInvitationRedeemSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + membership = services_invitations.redeem_invitation( + serializer.validated_data["token"], actor=request.user + ) + return Response( + { + "membership": OrganizationMembershipSerializer(membership).data, + "user": user_payload(request.user, request=request), + }, + status=status.HTTP_200_OK, + ) diff --git a/backend/apps/procurement/reports.py b/backend/apps/procurement/reports.py new file mode 100644 index 00000000..6ed05115 --- /dev/null +++ b/backend/apps/procurement/reports.py @@ -0,0 +1,46 @@ +from django.db.models import Avg, Count, DurationField, ExpressionWrapper, F, Max, Q, Sum + +from apps.operations.report_scope import scoped_ids +from apps.operations.report_types import ReportResult +from apps.operations.reports_common import apply_range, limited, period_expression +from apps.procurement.models import ToBuyItem + + +FIELDS = ( + "period", "kind", "status", "items", "units", "estimated_total", "actual_total", + "received_items", "inventoried_items", "average_order_hours", "average_receive_hours", + "last_activity_at", +) + + +def build_procurement_performance(makerspace_id, *, limit=None, date_range=None, grain="day"): + aggregate = makerspace_id is None + group = ["period", "kind", "status"] + if aggregate: + group.insert(0, "makerspace_id") + qs = apply_range(ToBuyItem.objects.filter( + makerspace_id__in=scoped_ids(makerspace_id, "procurement") + ), "created_at", date_range).annotate( + period=period_expression("created_at", grain), + estimated_line=F("estimated_unit_cost") * F("quantity"), + actual_line=F("actual_unit_cost") * F("quantity"), + order_duration=ExpressionWrapper(F("ordered_at") - F("created_at"), output_field=DurationField()), + receive_duration=ExpressionWrapper(F("received_at") - F("ordered_at"), output_field=DurationField()), + ).values(*group).annotate( + items=Count("id"), units=Sum("quantity"), estimated_total=Sum("estimated_line"), + actual_total=Sum("actual_line"), received_items=Count("id", filter=Q(received_at__isnull=False)), + inventoried_items=Count("id", filter=Q(moved_to_inventory_at__isnull=False)), + average_order=Avg("order_duration"), average_receive=Avg("receive_duration"), + last_activity_at=Max("updated_at"), + ).order_by(*group) + records = [] + for row in qs: + row["average_order_hours"] = _hours(row.pop("average_order")) + row["average_receive_hours"] = _hours(row.pop("average_receive")) + records.append(row) + fields = (("makerspace_id",) + FIELDS) if aggregate else FIELDS + return ReportResult(fields, limited(records, limit)) + + +def _hours(value): + return round(value.total_seconds() / 3600, 2) if value else None diff --git a/backend/apps/tenant_migration/audit_references_meta.py b/backend/apps/tenant_migration/audit_references_meta.py index 6b553a9d..b866a843 100644 --- a/backend/apps/tenant_migration/audit_references_meta.py +++ b/backend/apps/tenant_migration/audit_references_meta.py @@ -4,6 +4,9 @@ AuditReference, AuditReferenceDisposition, ) +from .audit_references_meta_source_local import SOURCE_LOCAL_AUDIT_EDGES + +_SOURCE_LOCAL_EDGES = SOURCE_LOCAL_AUDIT_EDGES def _reference(disposition, model, *edges): @@ -49,7 +52,23 @@ def _reference(disposition, model, *edges): ("request.issued", "evidence_id"), ), **_reference(R, "hardware_requests.ReturnEvent", ("evidence.attached", "return_event_id")), - **_reference(R, "events.EventRegistration", ("event.host_waiver_accepted", "registration_id")), + **_reference( + R, + "events.EventRegistration", + ("event.host_waiver_accepted", "registration_id"), + ("event.registration_created", "registration_id"), + ("event.registration_approval_requested", "registration_id"), + ("event.registration_approved", "registration_id"), + ("event.registration_rejected", "registration_id"), + ("event.registration_promoted", "registration_id"), + ("event.registration_cancelled", "registration_id"), + ("event.registration_attended", "registration_id"), + ), + **_reference( + R, + "events.EventCheckInEvent", + ("event.registration_attended", "check_in_event_id"), + ), # Removing an organizer names the event it was removed from. REMAP, matching the # sibling above: an Event is tenant-owned and travels with the export, so the id is # remappable. The created/updated siblings pick their action with a conditional, so @@ -64,6 +83,25 @@ def _reference(disposition, model, *edges): ("event.organizer_created", "event_id"), ("event.organizer_updated", "event_id"), ("event.organizer_deleted", "event_id"), + ("event.series_created", "occurrence_ids"), + ("event.series_extended", "created_ids"), + ("event.series_occurrence_removed", "event_id"), + ("event.series_updated", "created_ids"), + ("event.series_updated", "removed_ids"), + ("event.station_pin_rotated", "event_id"), + ("event.station_pin_revealed", "event_id"), + ("event.station_disabled", "event_id"), + ("event.station_pin_failed", "event_id"), + ("event.station_session_started", "event_id"), + ), + **_reference( + R, "events.EventSeries", + ("event.series_occurrence_created", "series_id"), + ("event.series_organizer_created", "series_id"), + ("event.series_organizer_updated", "series_id"), + ("event.series_organizer_deleted", "series_id"), + ("event.series_collaboration_accepted", "series_id"), + ("event.series_collaboration_declined", "series_id"), ), **_reference( R, "makerspaces.MakerspaceWaiver", @@ -118,6 +156,7 @@ def _reference(disposition, model, *edges): R, "makerspaces.MakerspaceMembership", ("membership.waiver_witnessed", "membership_id"), ("staff.role_assigned", "membership_id"), + ("event.calendar_feed_revoked", "membership_id"), ), **_reference( R, "integrations.NotificationDestination", @@ -143,72 +182,6 @@ def _reference(disposition, model, *edges): **_reference(R, "operations.StocktakeLine", ("stocktake.line_counted", "line_id")), } -_SOURCE_LOCAL_EDGES = { - ("audit.signing_key_rotation_aborted", "rotation_id"), - ("audit.signing_key_rotation_started", "rotation_id"), - ("audit.signing_key_rotation_completed", "rotation_id"), - ("audit.signing_key_rotation_failed", "rotation_id"), - ("encryption.write_fence_closed", "operation_id"), - ("encryption.write_fence_opened", "operation_id"), - ("payment.checkout_created", "subject_id"), - ("payment.created", "subject_id"), - ("payment.double_paid_refund_required", "event_id"), - ("payment.paid_after_terminal", "event_id"), - ("payment.paid_online", "event_id"), - ("payments.connect_authorization_revoked", "connect_account_id"), - ("payments.connect_previous_authorization_revoked", "connect_account_id"), - ("procurement.moved_to_printing", "result_id"), - ("qr.rebound", "new_target_id"), - ("tenant_migration.pairing_approved", "migration_id"), - ("tenant_migration.pairing_approved", "source_deployment_id"), - ("tenant_migration.pairing_approved", "target_deployment_id"), - ("tenant_migration.source_migrated_out", "migration_id"), - ("tenant_migration.source_migrated_out", "receipt_id"), - ("tenant_migration.source_migrated_out", "source_deployment_id"), - ("tenant_migration.source_migrated_out", "target_deployment_id"), - ("tenant_migration.target_activated", "migration_id"), - ("tenant_migration.target_activated", "receipt_id"), - ("tenant_migration.target_activated", "source_deployment_id"), - ("tenant_migration.target_activated", "target_deployment_id"), - ("tenant_migration.target_aborted", "migration_id"), - ("tenant_migration.target_aborted", "receipt_id"), - ("tenant_migration.target_aborted", "source_deployment_id"), - ("tenant_migration.target_aborted", "target_deployment_id"), - ("tenant_migration.source_reopened", "migration_id"), - ("tenant_migration.source_reopened", "receipt_id"), - ("tenant_migration.source_reopened", "source_deployment_id"), - ("tenant_migration.source_reopened", "target_deployment_id"), - ("tenant_migration.source_gate_closed", "owner_id"), - ("tenant_migration.source_gate_capture_released", "owner_id"), - ("tenant_migration.source_gate_recovered", "owner_id"), - ("tenant_migration.source_gate_reopened", "owner_id"), - ("tenant_migration.source_gate_recovery_command", "makerspace_id"), - ("tenant_migration.source_gate_recovery_command", "owner_id"), - ("tenant_migration.source_gate_migrated_out", "owner_id"), - ("tenant_migration.source_quiesced", "owner_id"), - ("tenant_migration.tenant_dump_captured", "gate_owner_id"), - ("tenant_migration.tenant_dump_derived", "artifact_id"), - ("tenant_migration.tenant_dump_derived", "capture_id"), - ("tenant_migration.objects_staged", "job_id"), - ("tenant_migration.objects_promoted", "job_id"), - ("tenant_migration.objects_rolled_back", "job_id"), - ("tenant_migration.export_requested", "export_id"), - ("tenant_migration.export_read", "export_id"), - ("data_export.download_url_issued", "export_id"), - ("data_export.downloaded", "export_id"), - ("tenant_migration.import_created", "import_id"), - ("tenant_migration.import_read", "import_id"), - ("tenant_migration.import_run_requested", "import_id"), - ("tenant_migration.identity_decisions_submitted", "import_id"), - ("tenant_migration.identity_decisions_read", "import_id"), - ("tenant_migration.import_completed", "import_id"), - ("tenant_migration.verification_read", "import_id"), - # Lane E compound-artifact identifiers. Deployment-operational: the artifact - # and component ledger rows never travel, so there is no PK to remap and the - # live binding must not be asserted on a target. - ("backup.archive_exclusion_activated", "artifact_id"), - ("backup.archive_exclusion_activated", "capture_id"), -} AUDIT_META_REFERENCES.update(_reference(S, None, *_SOURCE_LOCAL_EDGES)) AUDIT_META_REFERENCES.update( _reference( @@ -236,6 +209,22 @@ def _reference(disposition, model, *edges): S, "accounts.User", ("organization.membership_deleted", "user_id"), ) ) +AUDIT_META_REFERENCES.update( + _reference( + S, "organizations.Organization", + ("event.organizers_updated", "old_organization_ids"), + ("event.organizers_updated", "organization_ids"), + ("organization.invitation_created", "organization_id"), + ("organization.invitation_redeemed", "organization_id"), + ("organization.invitation_revoked", "organization_id"), + ) +) +AUDIT_META_REFERENCES.update( + _reference( + S, "organizations.OrganizationMembership", + ("organization.invitation_redeemed", "membership_id"), + ) +) AUDIT_META_REFERENCES.update( _reference( S, "makerspaces.Makerspace", diff --git a/backend/apps/tenant_migration/audit_references_meta_source_local.py b/backend/apps/tenant_migration/audit_references_meta_source_local.py new file mode 100644 index 00000000..1ebb5e00 --- /dev/null +++ b/backend/apps/tenant_migration/audit_references_meta_source_local.py @@ -0,0 +1,74 @@ +"""Source-deployment audit identifiers that are snapshots, never target bindings.""" + +SOURCE_LOCAL_AUDIT_EDGES = frozenset( + { + ("event.checkin_roster_downloaded", "lease_id"), + ("event.checkin_sync_processed", "lease_id"), + ("event.registration_attended", "operation_id"), + ("event.registration_attended", "session_id"), + ("event.station_session_started", "session_id"), + ("audit.signing_key_rotation_aborted", "rotation_id"), + ("audit.signing_key_rotation_started", "rotation_id"), + ("audit.signing_key_rotation_completed", "rotation_id"), + ("audit.signing_key_rotation_failed", "rotation_id"), + ("encryption.write_fence_closed", "operation_id"), + ("encryption.write_fence_opened", "operation_id"), + ("payment.checkout_created", "subject_id"), + ("payment.created", "subject_id"), + ("payment.double_paid_refund_required", "event_id"), + ("payment.paid_after_terminal", "event_id"), + ("payment.paid_online", "event_id"), + ("payments.connect_authorization_revoked", "connect_account_id"), + ("payments.connect_previous_authorization_revoked", "connect_account_id"), + ("procurement.moved_to_printing", "result_id"), + ("qr.rebound", "new_target_id"), + ("tenant_migration.pairing_approved", "migration_id"), + ("tenant_migration.pairing_approved", "source_deployment_id"), + ("tenant_migration.pairing_approved", "target_deployment_id"), + ("tenant_migration.source_migrated_out", "migration_id"), + ("tenant_migration.source_migrated_out", "receipt_id"), + ("tenant_migration.source_migrated_out", "source_deployment_id"), + ("tenant_migration.source_migrated_out", "target_deployment_id"), + ("tenant_migration.target_activated", "migration_id"), + ("tenant_migration.target_activated", "receipt_id"), + ("tenant_migration.target_activated", "source_deployment_id"), + ("tenant_migration.target_activated", "target_deployment_id"), + ("tenant_migration.target_aborted", "migration_id"), + ("tenant_migration.target_aborted", "receipt_id"), + ("tenant_migration.target_aborted", "source_deployment_id"), + ("tenant_migration.target_aborted", "target_deployment_id"), + ("tenant_migration.source_reopened", "migration_id"), + ("tenant_migration.source_reopened", "receipt_id"), + ("tenant_migration.source_reopened", "source_deployment_id"), + ("tenant_migration.source_reopened", "target_deployment_id"), + ("tenant_migration.source_gate_closed", "owner_id"), + ("tenant_migration.source_gate_capture_released", "owner_id"), + ("tenant_migration.source_gate_recovered", "owner_id"), + ("tenant_migration.source_gate_reopened", "owner_id"), + ("tenant_migration.source_gate_recovery_command", "makerspace_id"), + ("tenant_migration.source_gate_recovery_command", "owner_id"), + ("tenant_migration.source_gate_migrated_out", "owner_id"), + ("tenant_migration.source_quiesced", "owner_id"), + ("tenant_migration.tenant_dump_captured", "gate_owner_id"), + ("tenant_migration.tenant_dump_derived", "artifact_id"), + ("tenant_migration.tenant_dump_derived", "capture_id"), + ("tenant_migration.objects_staged", "job_id"), + ("tenant_migration.objects_promoted", "job_id"), + ("tenant_migration.objects_rolled_back", "job_id"), + ("tenant_migration.export_requested", "export_id"), + ("tenant_migration.export_read", "export_id"), + ("data_export.download_url_issued", "export_id"), + ("data_export.downloaded", "export_id"), + ("evidence.object_expired", "sweep_run_id"), + ("tenant_migration.import_created", "import_id"), + ("tenant_migration.import_read", "import_id"), + ("tenant_migration.import_run_requested", "import_id"), + ("tenant_migration.identity_decisions_submitted", "import_id"), + ("tenant_migration.identity_decisions_read", "import_id"), + ("tenant_migration.import_completed", "import_id"), + ("tenant_migration.verification_read", "import_id"), + # Lane E compound-artifact rows are deployment-operational and never travel. + ("backup.archive_exclusion_activated", "artifact_id"), + ("backup.archive_exclusion_activated", "capture_id"), + } +) diff --git a/backend/apps/tenant_migration/gate_policy.py b/backend/apps/tenant_migration/gate_policy.py index cd134e60..e1004f0b 100644 --- a/backend/apps/tenant_migration/gate_policy.py +++ b/backend/apps/tenant_migration/gate_policy.py @@ -166,6 +166,17 @@ "apps.hardware_requests.tasks.send_return_reminders_task": ( "Each reminder lifecycle uses the skip-and-count tenant boundary." ), + "apps.evidence.tasks.sweep_evidence_retention_task": ( + "Each evidence expiry uses the skip-and-count tenant boundary." + ), + "apps.events.tasks.extend_event_series_task": ( + "Each series extension locks its own makerspace and uses the skip-and-count " + "tenant boundary; the queryset is servable-filtered before iteration." + ), + "apps.operations.tasks.finalize_report_rollups_task": ( + "Each rollup finalisation uses the skip-and-count tenant boundary; the " + "makerspace queryset is servable-filtered before iteration." + ), "apps.makerspaces.tasks.refresh_github_contributions_task": ( "Each profile refresh uses the skip-and-count tenant boundary." ), @@ -180,6 +191,9 @@ # a time. The AST guard requires every owner to use the shared skip-and-count boundary. FANOUT_GATE_PARTICIPANTS = { "apps.data_export.tasks.purge_expired_exports_task": "Expired export cleanup.", + "apps.evidence.services_retention.sweep_evidence_retention": ( + "Evidence object expiry." + ), "apps.hardware_requests.services_return_reminders.run_return_reminders": ( "Overdue loan reminders." ), @@ -207,8 +221,17 @@ "The export task resolves and holds the tenant gate for this lifecycle." ), "apps.evidence.finalization.finalize_upload": "Reached only through the already-declared handover, return and direct-loan entry points.", + "apps.evidence.services_retention._sweep_makerspace": ( + "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.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.", + "apps.events.services.update_event": ( + "Runs inside the tenant-resolved admin event route's transaction; it can clear " + "image_key when an occurrence stops overriding its series template." + ), "apps.hardware_requests.direct_loan_returns.validate_evidence_upload": "Runs inside the guarded direct-loan return transaction.", "apps.hardware_requests.handover_workflow.issue_request": "Runs inside the request route's tenant transaction.", "apps.hardware_requests.return_workflow.return_items": "Runs inside the request route's tenant transaction.", diff --git a/backend/apps/tenant_migration/membership_dependencies.py b/backend/apps/tenant_migration/membership_dependencies.py index a0659a1b..bccb20dd 100644 --- a/backend/apps/tenant_migration/membership_dependencies.py +++ b/backend/apps/tenant_migration/membership_dependencies.py @@ -13,6 +13,14 @@ class MembershipDependency: retained_by_import=False, reason="Transient claim credentials are omitted from tenant archives.", ), + "events.MemberCalendarFeed": MembershipDependency( + retained_by_import=False, + reason=( + "A deployment-local bearer credential over the member's registration history. " + "It is already omitted from tenant archives, so a restored tenant reissues it " + "rather than carrying a live subscribable token across deployments." + ), + ), "makerspaces.MemberProfile": MembershipDependency( retained_by_import=True, reason="A member profile is owned by one non-null makerspace membership.", diff --git a/backend/apps/tenant_migration/object_export.py b/backend/apps/tenant_migration/object_export.py index 1be11761..1d3049d3 100644 --- a/backend/apps/tenant_migration/object_export.py +++ b/backend/apps/tenant_migration/object_export.py @@ -11,6 +11,9 @@ collect_private_object_keys, collect_public_image_keys, ) +from apps.evidence.models import EvidenceObjectRetentionState +from apps.evidence.retention_objects import retention_object_states +from apps.evidence.storage import staging_key class SourceMigrationObjectError(RuntimeError): @@ -35,6 +38,7 @@ def capture_tenant_objects(root, makerspace, storage_modes): ) records = [] source_keys = set() + evidence_states = retention_object_states(makerspace) for bucket_kind, bucket, keys in closures: for source_key in sorted(keys): if source_key in source_keys: @@ -42,6 +46,33 @@ def capture_tenant_objects(root, makerspace, storage_modes): source_key, "is referenced from more than one bucket" ) source_keys.add(source_key) + retention = evidence_states.get(source_key) + if retention and retention["status"] == EvidenceObjectRetentionState.Status.EXPIRING: + raise SourceMigrationObjectError( + source_key, "is currently being expired; retry after the sweep" + ) + if retention and retention["status"] == EvidenceObjectRetentionState.Status.EXPIRED: + try: + storage.assert_object_absent(bucket, source_key) + storage.assert_object_absent(bucket, staging_key(source_key)) + except Exception as exc: + raise SourceMigrationObjectError( + source_key, "is marked expired but bytes still exist" + ) from exc + records.append( + { + "bucket_kind": bucket_kind, + "source_key": source_key, + "size": 0, + "sha256": "", + "version_id": None, + "content_type": "", + "retention_state": "expired", + "object_expired_at": retention["object_expired_at"].isoformat(), + "expired_size_bytes": retention["expired_size_bytes"], + } + ) + continue destination = root / object_member_path(bucket_kind, source_key) try: # There is no ObjectVersion ledger yet. Even for a versioned bucket, diff --git a/backend/apps/tenant_migration/object_import.py b/backend/apps/tenant_migration/object_import.py index 8dbecd7d..1c810b93 100644 --- a/backend/apps/tenant_migration/object_import.py +++ b/backend/apps/tenant_migration/object_import.py @@ -39,6 +39,15 @@ def prepare_import_objects(archive, job): regenerated = 0 regenerated_keys = {} for record in records: + if record.get("retention_state") == "expired": + target_key, changed = object_storage.choose_target_key( + record["bucket_kind"], record["source_key"], job.pk + ) + target_keys[record["source_key"]] = target_key + regenerated += changed + if changed: + regenerated_keys[record["source_key"]] = target_key + continue existing = TenantImportObject.objects.filter( job=job, source_key=record["source_key"] ).first() @@ -80,9 +89,10 @@ def prepare_import_objects(archive, job): regenerated += changed if changed: regenerated_keys[record["source_key"]] = target_key - _audit_staged(job, len(records), records) + live_records = [row for row in records if row.get("retention_state") != "expired"] + _audit_staged(job, len(live_records), live_records) return ObjectImportPlan( - target_keys, len(records), regenerated, regenerated_keys + target_keys, len(live_records), regenerated, regenerated_keys ) @@ -143,7 +153,10 @@ def _manifest_records(root): def _validate_record(record, line_number): required = {"bucket_kind", "source_key", "size", "sha256", "version_id"} - allowed = required | {"content_type"} + tombstone_fields = { + "retention_state", "object_expired_at", "expired_size_bytes" + } + allowed = required | {"content_type"} | tombstone_fields if ( not isinstance(record, dict) or not required.issubset(record) @@ -154,9 +167,19 @@ def _validate_record(record, line_number): raise ArchiveFormatError(f"Invalid object bucket at line {line_number}.") if not isinstance(record["source_key"], str) or not record["source_key"]: raise ArchiveFormatError(f"Invalid object key at line {line_number}.") + expired = record.get("retention_state") == "expired" + if expired and not tombstone_fields.issubset(record): + raise ArchiveFormatError(f"Incomplete expiry tombstone at line {line_number}.") + if not expired and tombstone_fields & set(record): + raise ArchiveFormatError(f"Unexpected expiry fields at line {line_number}.") if type(record["size"]) is not int or record["size"] < 0: raise ArchiveFormatError(f"Invalid object size at line {line_number}.") - if not isinstance(record["sha256"], str) or not SHA256_RE.fullmatch(record["sha256"]): + if expired and (record["size"] != 0 or record["sha256"] != ""): + raise ArchiveFormatError(f"Invalid expiry tombstone at line {line_number}.") + if not expired and ( + not isinstance(record["sha256"], str) + or not SHA256_RE.fullmatch(record["sha256"]) + ): raise ArchiveFormatError(f"Invalid object checksum at line {line_number}.") if record["version_id"] is not None and not isinstance(record["version_id"], str): raise ArchiveFormatError(f"Invalid object version at line {line_number}.") @@ -165,6 +188,14 @@ def _validate_record(record, line_number): not isinstance(content_type, str) or len(content_type) > 255 ): raise ArchiveFormatError(f"Invalid object content type at line {line_number}.") + if expired: + if not isinstance(record["object_expired_at"], str): + raise ArchiveFormatError(f"Invalid expiry timestamp at line {line_number}.") + expired_size = record["expired_size_bytes"] + if expired_size is not None and ( + type(expired_size) is not int or expired_size < 0 + ): + raise ArchiveFormatError(f"Invalid expired size at line {line_number}.") def _verify_local_member(path, record): diff --git a/backend/apps/tenant_migration/object_verification.py b/backend/apps/tenant_migration/object_verification.py index b106d5d8..5dea08a9 100644 --- a/backend/apps/tenant_migration/object_verification.py +++ b/backend/apps/tenant_migration/object_verification.py @@ -43,6 +43,13 @@ def verify_import_object_ownership(job, *, rows=None): (TenantImportObject.BucketKind.PUBLIC_IMAGE, key) for key in collect_public_image_keys(target, include_coordination=False) } + expired_private = { + (TenantImportObject.BucketKind.PRIVATE, key) + for key in target.evidence_photos.filter( + object_retention_state__status="expired" + ).values_list("object_key", flat=True) + } + owned -= expired_private journal = {(row.bucket_kind, row.target_key) for row in rows} unowned = journal - owned missing = owned - journal diff --git a/backend/apps/tenant_migration/omitted_fields.py b/backend/apps/tenant_migration/omitted_fields.py index 0114c729..95afc25c 100644 --- a/backend/apps/tenant_migration/omitted_fields.py +++ b/backend/apps/tenant_migration/omitted_fields.py @@ -50,6 +50,7 @@ def _rules(disposition, *fields): ("bookings.BookableSpace", "public_token"), ("bookings.Booking", "public_token"), ("events.Event", "public_token"), + ("events.EventSeries", "public_token"), # Deliberately invalidate source event check-in QR codes at the trust boundary. ("events.EventRegistration", "checkin_token"), ("hardware_requests.HardwareRequest", "public_token"), @@ -92,6 +93,13 @@ def _rules(disposition, *fields): **_rules( NULL, ("backup.MakerspaceArchiveRecipient", "verified_at"), + # Projection provenance only: the target rebuilds the link from the canonical + # series collaboration, and a carried-over id would point at a source row. + ("events.EventCollaborator", "source_series_collaboration"), + # A transient object-expiry claim credential. Nullable, and the sweep reissues + # one when it next claims the row; carrying it would hand the target a live + # claim it never issued. + ("evidence.EvidenceObjectRetentionState", "claim_token"), ("backup.MakerspaceArchiveRecipient", "challenge_issued_at"), # Nullable AND globally unique, so the guard requires NULL: a freshly # generated identity would claim provenance the target has not attested. The diff --git a/backend/apps/tenant_migration/pk_maps.py b/backend/apps/tenant_migration/pk_maps.py index 274cee07..9f016e2c 100644 --- a/backend/apps/tenant_migration/pk_maps.py +++ b/backend/apps/tenant_migration/pk_maps.py @@ -12,6 +12,34 @@ TABLE_NAME = "tenant_import_pk_map" DEFAULT_BATCH_SIZE = 1_000 +# The only primary-key shapes an import can reserve target values for. +AUTO_PK_FIELD_TYPES = (models.AutoField, models.BigAutoField, models.SmallAutoField) +SUPPORTED_PK_FIELD_TYPES = (*AUTO_PK_FIELD_TYPES, models.UUIDField) + +# How many defaults to draw when checking that a UUID primary key can actually mint +# distinct values. Two is enough to catch a missing or constant default. +_UUID_DEFAULT_SAMPLE = 2 + + +def unsupported_primary_key_reason(model, sample=_UUID_DEFAULT_SAMPLE): + """Why an import cannot reserve target primary keys for ``model``, else ``None``. + + Exported so the projected-catalog guard enforces what reservation actually + requires rather than a weaker approximation of it. Being a ``UUIDField`` is not + sufficient: the field also has to supply a default that mints distinct UUIDs, so + a ``UUIDField(primary_key=True)`` declared without ``default=uuid.uuid4`` is + still unreservable and must be reported as such. + """ + pk_field = model._meta.pk + if isinstance(pk_field, AUTO_PK_FIELD_TYPES): + return None + if not isinstance(pk_field, models.UUIDField): + return f"{model._meta.label} does not use an auto-integer or UUID primary key." + values = [pk_field.get_default() for _index in range(sample)] + if any(not isinstance(value, UUID) for value in values) or len(set(values)) != sample: + return f"{model._meta.label} does not provide unique UUID primary keys." + return None + def _batches(values: Iterable, size: int) -> Iterator[list]: iterator = iter(values) @@ -125,9 +153,7 @@ def existing_target_count(self, model): def _reserve_target_pks(self, model, count): pk_field = model._meta.pk - if isinstance( - pk_field, (models.AutoField, models.BigAutoField, models.SmallAutoField) - ): + if isinstance(pk_field, AUTO_PK_FIELD_TYPES): with self.connection.cursor() as cursor: cursor.execute( """ @@ -139,9 +165,7 @@ def _reserve_target_pks(self, model, count): return [row[0] for row in cursor.fetchall()] if isinstance(pk_field, models.UUIDField): return self._unused_uuids(model, count) - raise UnsupportedPrimaryKey( - f"{model._meta.label} does not use an auto-integer or UUID primary key." - ) + raise UnsupportedPrimaryKey(unsupported_primary_key_reason(model)) def _unused_uuids(self, model, count): pk_field = model._meta.pk diff --git a/backend/apps/tenant_migration/row_dispositions.py b/backend/apps/tenant_migration/row_dispositions.py index 0a9a1bfb..b5ad6bca 100644 --- a/backend/apps/tenant_migration/row_dispositions.py +++ b/backend/apps/tenant_migration/row_dispositions.py @@ -35,6 +35,22 @@ def increment_field(self, bucket, label, field_name, amount=1): other.setdefault(key, 0) +def source_pk(model_or_label, row): + """The row's primary key AS EXPORTED, which is not always ``id``. + + ``EvidenceObjectRetentionState``'s primary key is a ``OneToOneField`` to the photo, + so its exported column is ``evidence_id`` and the table has no ``id`` at all. + Reading ``row["id"]`` unconditionally made a tenant move raise KeyError for any + tenant that had ever run the evidence retention sweep. + """ + model = ( + apps.get_model(model_or_label) + if isinstance(model_or_label, str) + else model_or_label + ) + return row[model._meta.pk.attname] + + def row_disposition(model_label, row, references): policy = ROW_POLICIES.get(model_label) if policy is not None and condition_matches(policy.condition, row): @@ -60,7 +76,7 @@ def row_disposition(model_label, row, references): if ( label == model_label and rule.disposition is MissingReferenceDisposition.DROP_WITH_PROVENANCE - and references.get(model_label, row["id"], field_name) + and references.get(model_label, source_pk(model_label, row), field_name) ): return "drop" for edge, typed_rules in MOVABLE_DISCRIMINATOR_REFERENCES.items(): @@ -73,31 +89,31 @@ def row_disposition(model_label, row, references): if ( rule is not None and rule.disposition is MissingReferenceDisposition.DROP_WITH_PROVENANCE - and references.get(model_label, row["id"], "target_type+target_id") + and references.get(model_label, source_pk(model_label, row), "target_type+target_id") ): return "drop" for (label, field_name), disposition in CROSS_TENANT_DEPENDENT_REFERENCES.items(): if ( label == model_label and disposition is MissingReferenceDisposition.DROP_WITH_PROVENANCE - and references.get(model_label, row["id"], field_name) + and references.get(model_label, source_pk(model_label, row), field_name) ): return "drop" # Both foreign-collaboration shapes have a non-null FK to a row that is not # imported. Their typed snapshot survives separately, anchored where possible. if model_label == "events.EventCollaborator" and ( - references.get(model_label, row["id"], "event") - or references.get(model_label, row["id"], "makerspace") + references.get(model_label, source_pk(model_label, row), "event") + or references.get(model_label, source_pk(model_label, row), "makerspace") ): return "drop" # An inbound transfer is foreign-owned. It is provenance, not a live transfer. if model_label == "operations.StockTransfer" and references.get( - model_label, row["id"], "source_makerspace" + model_label, source_pk(model_label, row), "source_makerspace" ): return "drop" if model_label == "payments.Payment" and references.get( - model_label, row["id"], "subject_id" + model_label, source_pk(model_label, row), "subject_id" ): return "drop" return "insert" @@ -109,7 +125,7 @@ def preallocate_model( label = model._meta.label if label == "makerspaces.Makerspace": source = next(archive.rows(label)) - pk_map.add_many(model, [(source["id"], target.pk)]) + pk_map.add_many(model, [(source_pk(model, source), target.pk)]) accounting.increment("resolved", label) return if label == "accounts.User": @@ -131,12 +147,12 @@ def preallocate_model( required_identities.add_row(model, row) resolved_pk = _seeded_target_pk(label, row, target) if resolved_pk is not None: - pk_map.add_many(model, [(row["id"], resolved_pk)]) + pk_map.add_many(model, [(source_pk(model, row), resolved_pk)]) accounting.increment("resolved", label) elif disposition == "resolve": accounting.increment("dropped", label) else: - source_ids.append(row["id"]) + source_ids.append(source_pk(model, row)) if len(source_ids) == 1_000: pk_map.reserve(model, source_ids) source_ids.clear() diff --git a/backend/apps/tenant_migration/schemas.py b/backend/apps/tenant_migration/schemas.py index 52d6bd77..5c2661bf 100644 --- a/backend/apps/tenant_migration/schemas.py +++ b/backend/apps/tenant_migration/schemas.py @@ -27,6 +27,7 @@ def array_schema(items): # silently produced nothing. MAKERSPACE = object_schema(name=STRING, slug=STRING) EVENT = object_schema(title=STRING, starts_at=TIMESTAMP, ends_at=TIMESTAMP) +EVENT_SERIES = object_schema(title=STRING) CONTAINER = object_schema(label=STRING, makerspace=MAKERSPACE) SOURCE_REFERENCE = object_schema( source_id=INTEGER, @@ -65,6 +66,8 @@ def array_schema(items): EDGE_SCHEMAS = { ("events.EventCollaborator", "event"): EVENT, ("events.EventCollaborator", "makerspace"): MAKERSPACE, + ("events.EventSeriesCollaborator", "series"): EVENT_SERIES, + ("events.EventSeriesCollaborator", "makerspace"): MAKERSPACE, ("events.EventRegistration", "registered_via_makerspace"): MAKERSPACE, ("events.EventRegistration", "payment_via_makerspace"): MAKERSPACE, ("operations.StockTransfer", "source_container"): CONTAINER, diff --git a/backend/apps/tenant_migration/source_gate_ast.py b/backend/apps/tenant_migration/source_gate_ast.py index 817eb903..59625ea2 100644 --- a/backend/apps/tenant_migration/source_gate_ast.py +++ b/backend/apps/tenant_migration/source_gate_ast.py @@ -9,7 +9,8 @@ APPS_DIR = Path(apps.__file__).resolve().parent OBJECT_MUTATION_NAMES = frozenset({ - "copy_object", "delete_archive", "delete_object", "delete_staged_file", "finalize_file", + "copy_object", "delete_archive", "delete_object", "delete_object_strict", + "delete_staged_file", "finalize_file", "finalize_receipt_upload", "finalize_upload", "presigned_upload", "put_bytes", "release_public_image_on_commit", "upload_archive", }) diff --git a/backend/apps/tenant_migration/tenant_dump_authority.py b/backend/apps/tenant_migration/tenant_dump_authority.py index 4174d441..841b9c71 100644 --- a/backend/apps/tenant_migration/tenant_dump_authority.py +++ b/backend/apps/tenant_migration/tenant_dump_authority.py @@ -172,6 +172,18 @@ def _build(entries): D.PRESERVE, "The event publication controls are tenant-owned content.", ), + *_same( + "events.EventSeries", + "is_public status", + D.PRESERVE, + "The series publication controls are tenant-owned content.", + ), + *_same( + "events.EventSeries", + "public_token", + D.RESET, + "Source series bearer tokens are regenerated.", + ), *_same( "events.Event", "public_token", diff --git a/backend/apps/tenant_migration/tenant_dump_catalog.py b/backend/apps/tenant_migration/tenant_dump_catalog.py index 646fe59c..43e551e8 100644 --- a/backend/apps/tenant_migration/tenant_dump_catalog.py +++ b/backend/apps/tenant_migration/tenant_dump_catalog.py @@ -38,7 +38,13 @@ class TenantDumpCatalogError(AssertionError): # SHA-256 of the ordered model/table/field graph produced by ``catalog_schema``. # Updating it is an explicit review act; runtime introspection never blesses drift. -CATALOG_SCHEMA_SHA256 = "2f17b431d479fbbb508361dae0ebd465cc87163946bf64e2efeead65557d0188" +# Re-blessed once for the WHOLE merged model graph: the events programme (series, +# check-in ledger, feedback, certificates, station credential), the organization layer +# and the evidence retention state. Neither branch's pinned value describes the merge, +# 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" def catalog_models(apps_registry=apps): diff --git a/backend/apps/tenant_migration/tenant_dump_cross_tenant_rules.py b/backend/apps/tenant_migration/tenant_dump_cross_tenant_rules.py index 6c15d01c..2c16333f 100644 --- a/backend/apps/tenant_migration/tenant_dump_cross_tenant_rules.py +++ b/backend/apps/tenant_migration/tenant_dump_cross_tenant_rules.py @@ -24,6 +24,14 @@ class CrossTenantRule: ("events.EventCollaborator", "makerspace"): CrossTenantRule( CrossTenantDisposition.DROP_ROW, "A foreign collaborator grant is dropped." ), + # A series collaboration mirrors the per-occurrence one: the series and the invited + # makerspace can sit in different tenants, and a half-owned grant must not travel. + ("events.EventSeriesCollaborator", "series"): CrossTenantRule( + CrossTenantDisposition.DROP_ROW, "A foreign-hosted series collaboration is dropped." + ), + ("events.EventSeriesCollaborator", "makerspace"): CrossTenantRule( + CrossTenantDisposition.DROP_ROW, "A foreign series-collaborator grant is dropped." + ), ("operations.StockTransfer", "source_makerspace"): CrossTenantRule( CrossTenantDisposition.NULL_WITH_PAIRED_CONTAINER, "A foreign source and its source container are nulled together.", diff --git a/backend/apps/tenant_migration/tenant_dump_derivation.py b/backend/apps/tenant_migration/tenant_dump_derivation.py index d4e0ba48..ae893c78 100644 --- a/backend/apps/tenant_migration/tenant_dump_derivation.py +++ b/backend/apps/tenant_migration/tenant_dump_derivation.py @@ -81,7 +81,8 @@ def derive_tenant_dump(capture_id, *, database=None): object_entries = tuple( item for item in capture.object_ledger - if item.get("source_key") not in projection.excluded_object_keys + if item.get("source_key", item.get("key")) + not in projection.excluded_object_keys ) objects = package_staged_objects(root, bundle, object_entries) key_envelope = None diff --git a/backend/apps/tenant_migration/tenant_dump_field_snapshot.py b/backend/apps/tenant_migration/tenant_dump_field_snapshot.py index d754a9c4..f2e00b92 100644 --- a/backend/apps/tenant_migration/tenant_dump_field_snapshot.py +++ b/backend/apps/tenant_migration/tenant_dump_field_snapshot.py @@ -34,9 +34,10 @@ '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()), - 'organizations.Organization': frozenset('billing_email contact_email created_at created_by description id is_active legal_name logo_key makerspaces name registration_number slug updated_at website'.split()), + 'organizations.Organization': frozenset('billing_email contact_email created_at created_by description id is_active legal_name logo_key makerspaces name public_profile_enabled registration_number slug updated_at website'.split()), 'organizations.OrganizationMakerspace': frozenset('created_at created_by id makerspace organization relationship updated_at'.split()), - 'organizations.OrganizationMembership': frozenset('created_at created_by granted_actions id organization status updated_at user'.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.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()), @@ -73,6 +74,8 @@ 'audit.AuditBatch': frozenset('batch_seq created_at id leaf_count makerspace merkle_root prev_batch_root signature signer_fingerprint'.split()), 'audit.AuditBatchLeaf': frozenset('audit_log batch id leaf_position'.split()), 'evidence.EvidencePhoto': frozenset('content_type created_at evidence_type id makerspace object_key size_bytes uploaded_by'.split()), + 'evidence.EvidenceObjectRetentionState': frozenset('claim_token claimed_at evidence expired_size_bytes id last_error object_expired_at status updated_at'.split()), + 'evidence.EvidenceRetentionPolicy': frozenset('id makerspace object_retention_days updated_at'.split()), 'evidence.EvidenceUploadFinalization': frozenset('claim_token content_type evidence quota_charged size_bytes status updated_at'.split()), 'warranty.Warranty': frozenset('asset created_at id machine makerspace purchased_on updated_at vendor_contact vendor_name warranty_expires_on'.split()), 'warranty.WarrantyDocument': frozenset('content_type created_at id object_key original_filename size_bytes uploaded_by warranty'.split()), @@ -101,6 +104,8 @@ 'integrations.PlatformSmsSettings': frozenset('account_sid auth_token from_number id is_enabled provider updated_at'.split()), 'integrations.DailyOtpSmsCounter': frozenset('count day id'.split()), '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.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()), @@ -155,10 +160,19 @@ 'machines.MachineDocument': frozenset('content_type created_at doc_type id machine object_key original_filename size_bytes uploaded_by'.split()), 'machines.MachineErrorLog': frozenset('created_at id logged_by machine message severity'.split()), 'machines.MachineConsumable': frozenset('created_at created_by id label low_threshold machine measurement note product remaining'.split()), - 'events.EventOrganizer': frozenset('created_at created_by event id organization'.split()), - 'events.Event': frozenset('capacity created_at created_by custom_form description ends_at id image_key is_public location location_kind makerspace payment_amount public_token starts_at status title updated_at'.split()), - 'events.EventCollaborator': frozenset('created_at event id invited_by makerspace responded_at responded_by status'.split()), - 'events.EventRegistration': frozenset('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.EventSeries': frozenset('calendar_sequence calendar_uid calendar_updated_at capacity created_at created_by custom_form description dtstart_local_date dtstart_local_time duration_minutes id image_key is_public last_generation_error_code last_materialized_at location location_kind makerspace payment_amount public_token recurrence_rule recurrence_timezone registration_cutoff_lead_minutes registration_requires_approval revision status title updated_at'.split()), + '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.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()), + 'events.EventCheckInEvent': frozenset('actor attended_at event id makerspace operation_id recorded_at registration session_id source station_version'.split()), + 'events.EventCheckInStationCredential': frozenset('created_at disabled_at event id is_enabled pin_ciphertext pin_digest public_token rotated_at updated_at version'.split()), + '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.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()), diff --git a/backend/apps/tenant_migration/tenant_dump_lineage.py b/backend/apps/tenant_migration/tenant_dump_lineage.py index 5d1f194a..e63e8e32 100644 --- a/backend/apps/tenant_migration/tenant_dump_lineage.py +++ b/backend/apps/tenant_migration/tenant_dump_lineage.py @@ -9,7 +9,7 @@ FORMAT = "spaceworks-tenant-dump-v1" -DERIVATION_POLICY_VERSION = 4 +DERIVATION_POLICY_VERSION = 5 def canonical_digest(value): @@ -25,8 +25,7 @@ def canonical_digest(value): def object_ledger(entries): normalized = [] for entry in entries: - normalized.append( - { + item = { "bucket_kind": entry["bucket_kind"], "key": entry.get("original_key", entry.get("source_key")), "version_id": entry.get("version_id") or None, @@ -34,7 +33,13 @@ def object_ledger(entries): "sha256": entry["sha256"], "content_type": entry.get("content_type") or "", } - ) + if entry.get("retention_state") == "expired": + item.update( + retention_state="expired", + object_expired_at=entry.get("object_expired_at"), + expired_size_bytes=entry.get("expired_size_bytes"), + ) + normalized.append(item) return tuple(sorted(normalized, key=lambda item: (item["bucket_kind"], item["key"]))) diff --git a/backend/apps/tenant_migration/tenant_dump_model_catalog.py b/backend/apps/tenant_migration/tenant_dump_model_catalog.py index 994c475f..50d6eb63 100644 --- a/backend/apps/tenant_migration/tenant_dump_model_catalog.py +++ b/backend/apps/tenant_migration/tenant_dump_model_catalog.py @@ -26,8 +26,11 @@ PROJECTED_MODEL_LABELS = frozenset( """accounts.User apiclients.ApiKeyRequest audit.AuditLog backup.MakerspaceArchiveRecipient bookings.BookableSpace bookings.Booking boxes.Box - boxes.BoxScan boxes.QrCode boxes.QrScanEvent events.Event events.EventRegistration - evidence.EvidencePhoto hardware_requests.HardwareRequest + boxes.BoxScan boxes.QrCode boxes.QrScanEvent events.EventSeries events.Event events.EventRegistration + events.EventCheckInEvent events.EventFeedbackSurvey events.EventFeedbackResponse + events.EventAttendanceCertificate + evidence.EvidencePhoto evidence.EvidenceObjectRetentionState + evidence.EvidenceRetentionPolicy hardware_requests.HardwareRequest hardware_requests.HardwareRequestItem hardware_requests.HardwareRequestItemAsset hardware_requests.PublicProblemReport hardware_requests.PublicToolLoan hardware_requests.RequesterAccountability hardware_requests.ReturnEvent @@ -46,7 +49,8 @@ makerspaces.MakerspaceMembership makerspaces.MakerspaceWaiver makerspaces.MemberProfile makerspaces.MemberProject makerspaces.MembershipRequest notifications.Notification operations.InventoryAdjustment operations.QrPrintBatch - operations.QrPrintBatchItem operations.StockTransfer operations.StockTransferLine + operations.QrPrintBatchItem operations.ReportMetricRollup operations.StockTransfer + operations.StockTransferLine operations.StocktakeLedgerEntry operations.StocktakeLine operations.StocktakeSession payments.MakerspacePaymentSettings payments.Payment presence.PresenceSession procurement.ToBuyItem procurement.ToBuyReceipt @@ -59,6 +63,7 @@ EXPLICIT_DROP_MODEL_REASONS = { "apiclients.ApiClient": "Source clients and their bearer secrets never become target authority.", "events.EventCollaborator": "Cross-tenant collaboration grants have no target counterpart.", + "events.EventSeriesCollaborator": "Cross-tenant series collaboration grants have no target counterpart.", "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.", diff --git a/backend/apps/tenant_migration/tenant_dump_objects.py b/backend/apps/tenant_migration/tenant_dump_objects.py index 843e93d9..454d4149 100644 --- a/backend/apps/tenant_migration/tenant_dump_objects.py +++ b/backend/apps/tenant_migration/tenant_dump_objects.py @@ -27,6 +27,26 @@ def package_staged_objects(staging_root, bundle_root, entries): member = PurePosixPath( "objects", _BUCKET_DIRECTORIES[bucket_kind], opaque ) + if entry.get("retention_state") == "expired": + if not isinstance(entry.get("object_expired_at"), str): + raise TenantDumpVerificationError( + "Lane D expiry tombstone has no terminal timestamp." + ) + manifest.append( + { + "bucket_kind": bucket_kind, + "member_path": None, + "original_key": original_key, + "version_id": None, + "size": 0, + "content_type": "", + "sha256": "", + "retention_state": "expired", + "object_expired_at": entry.get("object_expired_at"), + "expired_size_bytes": entry.get("expired_size_bytes"), + } + ) + continue source_member = PurePosixPath(entry.get("member_path", str(member))) if source_member.is_absolute() or ".." in source_member.parts: raise TenantDumpVerificationError("Unsafe immutable object member path.") @@ -58,7 +78,7 @@ def package_staged_objects(staging_root, bundle_root, entries): "sha256": digest, } ) - return tuple(sorted(manifest, key=lambda item: item["member_path"])) + return tuple(sorted(manifest, key=lambda item: item["member_path"] or "")) def _file_digest(path): diff --git a/backend/apps/tenant_migration/unique_value_generators.py b/backend/apps/tenant_migration/unique_value_generators.py new file mode 100644 index 00000000..446e3b35 --- /dev/null +++ b/backend/apps/tenant_migration/unique_value_generators.py @@ -0,0 +1,80 @@ +"""Collision handlers and target-key generators for tenant materialization.""" + +from pathlib import PurePosixPath + + +def _extension(value): + suffix = PurePosixPath(str(value)).name + return suffix.rsplit(".", 1)[1] if "." in suffix else "" + + +def _refuses_collision(handler): + """Mark a handler that stops the import instead of minting a replacement value. + + A collision on one of these identities cannot happen between two deployments that + pairing would allow to trade a tenant, so reaching one means an assumption broke. + The marker lets a caller enumerate them without matching on function names. + """ + handler.refuses_collision = True + return handler + + +def evidence_key(row, target, _source_value): + from apps.evidence.storage import evidence_object_key + + return evidence_object_key(target.pk, row["evidence_type"]) + + +def certificate_key(row, target, _source_value): + return f"event-certificates/{target.pk}/{row['serial']}.pdf" + + +@_refuses_collision +def refuse_certificate_serial_collision(row, target, source_value): + raise RuntimeError( + "An attendance-certificate serial collision cannot be regenerated because " + "the serial is printed inside the immutable PDF." + ) + + +@_refuses_collision +def refuse_checkin_operation_collision(row, target, source_value): + raise RuntimeError( + "An immutable check-in operation UUID collision cannot be regenerated without " + "breaking its audit provenance." + ) + + +def machine_document_key(row, target, source_value): + from apps.machines.storage import machine_object_key + + return machine_object_key(target.pk, _extension(source_value)) + + +def service_file_key(row, target, _source_value): + from apps.machines.service_storage import service_object_key + + context = row.get("service_request_id") or row.get("queue_id") or row["id"] + return service_object_key(target.pk, context) + + +def maintenance_document_key(row, target, source_value): + from apps.maintenance.models import MaintenanceLog + from apps.maintenance.storage import log_document_object_key + + machine_id = MaintenanceLog.objects.values_list("machine_id", flat=True).get( + pk=row["log_id"] + ) + return log_document_object_key(target.pk, machine_id, _extension(source_value)) + + +def receipt_key(row, target, source_value): + from apps.procurement.storage import receipt_object_key + + return receipt_object_key(target.pk, _extension(source_value)) + + +def warranty_document_key(row, target, source_value): + from apps.warranty.storage import warranty_object_key + + return warranty_object_key(target.pk, _extension(source_value)) diff --git a/backend/apps/tenant_migration/unique_values.py b/backend/apps/tenant_migration/unique_values.py index cfcd8b0d..1691bcfb 100644 --- a/backend/apps/tenant_migration/unique_values.py +++ b/backend/apps/tenant_migration/unique_values.py @@ -1,10 +1,19 @@ """Declared handling for deployment-global uniqueness during materialization.""" - from dataclasses import dataclass from enum import StrEnum -from pathlib import PurePosixPath from typing import Callable +from .unique_value_generators import ( + certificate_key as _certificate_key, + evidence_key as _evidence_key, + machine_document_key as _machine_document_key, + maintenance_document_key as _maintenance_document_key, + receipt_key as _receipt_key, + refuse_certificate_serial_collision as _refuse_certificate_serial_collision, + refuse_checkin_operation_collision as _refuse_checkin_operation_collision, + service_file_key as _service_file_key, + warranty_document_key as _warranty_document_key, +) class UniqueValueDisposition(StrEnum): DROP_ROW = "drop_row" @@ -31,52 +40,6 @@ def _policy(disposition, reason, *, field=None, generator=None): return UniqueValuePolicy(disposition, reason, field, generator) -def _extension(value): - suffix = PurePosixPath(str(value)).name - return suffix.rsplit(".", 1)[1] if "." in suffix else "" - - -def _evidence_key(row, target, _source_value): - from apps.evidence.storage import evidence_object_key - - return evidence_object_key(target.pk, row["evidence_type"]) - - -def _machine_document_key(row, target, source_value): - from apps.machines.storage import machine_object_key - - return machine_object_key(target.pk, _extension(source_value)) - - -def _service_file_key(row, target, _source_value): - from apps.machines.service_storage import service_object_key - - context = row.get("service_request_id") or row.get("queue_id") or row["id"] - return service_object_key(target.pk, context) - - -def _maintenance_document_key(row, target, source_value): - from apps.maintenance.models import MaintenanceLog - from apps.maintenance.storage import log_document_object_key - - machine_id = MaintenanceLog.objects.values_list("machine_id", flat=True).get( - pk=row["log_id"] - ) - return log_document_object_key(target.pk, machine_id, _extension(source_value)) - - -def _receipt_key(row, target, source_value): - from apps.procurement.storage import receipt_object_key - - return receipt_object_key(target.pk, _extension(source_value)) - - -def _warranty_document_key(row, target, source_value): - from apps.warranty.storage import warranty_object_key - - return warranty_object_key(target.pk, _extension(source_value)) - - FRESH = UniqueValueDisposition.OMITTED_FRESH NULL = UniqueValueDisposition.OMITTED_NULL PRESERVE = UniqueValueDisposition.PRESERVE_OR_REGENERATE @@ -118,9 +81,43 @@ def _warranty_document_key(row, target, source_value): ("events.Event", "field:public_token"): _policy( FRESH, "Source bearer tokens are replaced." ), + ("events.Event", "field:calendar_uid"): _policy( + PRESERVE, + "Keep stable calendar identity unless the target already uses it.", + field="calendar_uid", + ), + ("events.EventSeries", "field:public_token"): _policy( + FRESH, "Source series bearer tokens are replaced." + ), + ("events.EventSeries", "field:calendar_uid"): _policy( + PRESERVE, + "Keep stable series calendar identity unless the target already uses it.", + field="calendar_uid", + ), + ("events.Event", "uniq_event_series_occurrence_key"): _policy( + REMAP, "The series reference is remapped with its occurrence identity." + ), ("events.EventRegistration", "field:checkin_token"): _policy( FRESH, "Source check-in credentials are replaced." ), + ("events.EventCheckInEvent", "field:operation_id"): _policy( + PRESERVE, + "Preserve immutable synchronization identity; refuse a target collision.", + field="operation_id", + generator=_refuse_checkin_operation_collision, + ), + ("events.EventAttendanceCertificate", "field:serial"): _policy( + PRESERVE, + "Preserve the serial printed inside the immutable PDF; refuse a collision.", + field="serial", + generator=_refuse_certificate_serial_collision, + ), + ("events.EventAttendanceCertificate", "field:object_key"): _policy( + PRESERVE, + "Keep the archived private certificate key unless it collides on the target.", + field="object_key", + generator=_certificate_key, + ), ("evidence.EvidencePhoto", "field:object_key"): _policy( PRESERVE, "Keep the archived evidence key unless it already names a target object.", diff --git a/backend/config/settings.py b/backend/config/settings.py index 46e4c8e1..0e41dc63 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -188,7 +188,11 @@ def normalize_platform_domain_suffix(raw): TOMBSTONED_APPS = tombstoned_app_labels() MIDDLEWARE = [ + # The recovery gate stays FIRST -- it must refuse a request before any other layer + # can act on it, and tests/backup/test_recovery_gate.py pins that position. "apps.backup.middleware.DeploymentRecoveryGateMiddleware", + # Second, so it still wraps every view that could log a calendar-feed bearer token. + "apps.events.middleware.CalendarFeedLogRedactionMiddleware", "apps.tenant_migration.middleware.SourceMigrationGateMiddleware", "apps.makerspaces.middleware.TenantHostValidationMiddleware", "django.middleware.security.SecurityMiddleware", @@ -328,6 +332,13 @@ def normalize_platform_domain_suffix(raw): EVIDENCE_URL_TTL_SECONDS = env.int("EVIDENCE_URL_TTL_SECONDS", default=300) 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( + "EVIDENCE_OBJECT_RETENTION_DAYS", default=365 +) +EVIDENCE_OBJECT_EXPIRY_ENABLED = env.bool( + "EVIDENCE_OBJECT_EXPIRY_ENABLED", default=False +) +EVIDENCE_RETENTION_BATCH_SIZE = env.int("EVIDENCE_RETENTION_BATCH_SIZE", default=100) WARRANTY_DOC_MAX_BYTES = env.int("WARRANTY_DOC_MAX_BYTES", default=10485760) WARRANTY_DOC_ALLOWED_MIME = env.list( "WARRANTY_DOC_ALLOWED_MIME", @@ -569,6 +580,14 @@ def cache_config(cache_url): "task": "apps.hardware_requests.tasks.send_return_reminders_task", "schedule": crontab(minute=0), }, + "evidence-object-expiry": { + "task": "apps.evidence.tasks.sweep_evidence_retention_task", + "schedule": crontab(minute=10, hour="*/6"), + }, + "extend-event-series": { + "task": "apps.events.tasks.extend_event_series_task", + "schedule": crontab(minute=10), + }, # Spent email/phone verification challenges hold an address or a number and nothing # deleted them. Off-peak because it is a pure delete nobody is waiting on. "purge-auth-challenges": { @@ -586,6 +605,10 @@ def cache_config(cache_url): "task": "apps.data_export.tasks.purge_expired_exports_task", "schedule": crontab(hour=3, minute=45), }, + "finalize-report-rollups": { + "task": "apps.operations.tasks.finalize_report_rollups_task", + "schedule": crontab(hour=1, minute=0), + }, "scheduled-deployment-backup": { "task": "apps.backup.tasks.scheduled_deployment_backup_task", "schedule": crontab(hour=2, minute=0), @@ -633,6 +656,12 @@ def cache_config(cache_url): for name, entry in CELERY_BEAT_SCHEDULE.items() if ".tenant_migration." not in entry["task"] } +if "events" in TOMBSTONED_APPS: + CELERY_BEAT_SCHEDULE = { + name: entry + for name, entry in CELERY_BEAT_SCHEDULE.items() + if ".events." not in entry["task"] + } CORS_ALLOWED_ORIGINS = env.list( "CORS_ALLOWED_ORIGINS", @@ -645,6 +674,7 @@ def cache_config(cache_url): "x-signature", "x-timestamp", "x-refresh-csrf", + "x-station-csrf", "x-publishable-key", ) CORS_ALLOW_CREDENTIALS = True @@ -663,6 +693,26 @@ def cache_config(cache_url): # _fernet() raises ImproperlyConfigured only when a key is actually needed. Tests/CI get a # real key from .env / docker-compose (added below). API_CLIENT_ENC_KEY = env("API_CLIENT_ENC_KEY", default="") +# Dedicated domain-separation secret for event-station PIN verification. The raw PIN +# is encrypted with API_CLIENT_ENC_KEY only because staff reveal is an explicit product +# requirement; the slow hash plus this independent pepper remains the verifier. +EVENT_STATION_PIN_PEPPER = env("EVENT_STATION_PIN_PEPPER", default="") +EVENT_CHECKIN_WINDOW_BEFORE_HOURS = env.int( + "EVENT_CHECKIN_WINDOW_BEFORE_HOURS", default=24 +) +EVENT_CHECKIN_WINDOW_AFTER_HOURS = env.int( + "EVENT_CHECKIN_WINDOW_AFTER_HOURS", default=2 +) +EVENT_CHECKIN_SYNC_GRACE_HOURS = env.int( + "EVENT_CHECKIN_SYNC_GRACE_HOURS", default=24 +) +EVENT_CHECKIN_ROSTER_LIFETIME_HOURS = env.int( + "EVENT_CHECKIN_ROSTER_LIFETIME_HOURS", default=24 +) +EVENT_CHECKIN_CLOCK_SKEW_SECONDS = env.int( + "EVENT_CHECKIN_CLOCK_SKEW_SECONDS", default=300 +) +EVENT_CHECKIN_ROSTER_MAX = env.int("EVENT_CHECKIN_ROSTER_MAX", default=1000) # Wraps the per-scope audit row-MAC keys. Independent of PII_MASTER_KEY on purpose: the # audit domain gets its own key so a PII key rotation cannot invalidate integrity # evidence. Empty means row-MAC attestation is OFF and new audit rows are stored @@ -803,6 +853,30 @@ def cache_config(cache_url): "event_checkin_resolve": env( "THROTTLE_EVENT_CHECKIN_RESOLVE", default="60/min" ), + "event_offline_roster": env( + "THROTTLE_EVENT_OFFLINE_ROSTER", default="10/hour" + ), + "event_offline_sync": env( + "THROTTLE_EVENT_OFFLINE_SYNC", default="60/hour" + ), + "event_station_pin_token": env( + "THROTTLE_EVENT_STATION_PIN_TOKEN", default="10/hour" + ), + "event_station_pin_ip": env( + "THROTTLE_EVENT_STATION_PIN_IP", default="30/hour" + ), + "event_station_session": env( + "THROTTLE_EVENT_STATION_SESSION", default="120/hour" + ), + "event_station_reveal": env( + "THROTTLE_EVENT_STATION_REVEAL", default="5/hour" + ), + "event_calendar_feed_token": env( + "THROTTLE_EVENT_CALENDAR_FEED_TOKEN", default="120/hour" + ), + "event_calendar_feed_ip": env( + "THROTTLE_EVENT_CALENDAR_FEED_IP", default="300/hour" + ), "public_stats": env("THROTTLE_PUBLIC_STATS", default="30/min"), "client_public": env("THROTTLE_CLIENT_PUBLIC", default="30/min"), "client_standard": env("THROTTLE_CLIENT_STANDARD", default="120/min"), @@ -959,7 +1033,7 @@ def cache_config(cache_url): # `tests/test_version_consistency.py`. It cannot simply READ that file: the backend # image is built with `context: ./backend`, so the repo root is outside the build # context and the file does not exist inside the container. - "VERSION": "0.8.0", + "VERSION": "0.8.1", "ENUM_NAME_OVERRIDES": { "QrPrintBatchStatusEnum": [ ("draft", "Draft"), diff --git a/backend/config/urls.py b/backend/config/urls.py index 6b5c215a..7e164630 100644 --- a/backend/config/urls.py +++ b/backend/config/urls.py @@ -109,6 +109,10 @@ def docs_root(_request): ), ), path('api/v1/', include('apps.machines.urls')), + path( + "api/v1/public/organizations/", + include("apps.organizations.urls_public"), + ), *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"), @@ -133,6 +137,7 @@ def docs_root(_request): 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")), + path("api/v1/admin/", include("apps.organizations.urls_admin")), # Mounted at admin_api's own prefix so the paths and route names are unchanged by # the relocation, and *after* it so a relocated route can never shadow one that # stayed behind. Every warranty pattern is a distinct literal, so ordering is @@ -142,6 +147,7 @@ def docs_root(_request): *separable("presence", "api/v1/admin/", "apps.presence.urls_admin"), *separable("events", "api/v1/admin/", "apps.events.urls_admin"), *separable("events", "api/v1/member/", "apps.events.urls_member"), + *separable("events", "api/v1/", "apps.events.urls_station"), *separable("bookings", "api/v1/admin/", "apps.bookings.urls_admin"), path("api/v1/admin/", include("apps.boxes.urls")), path("api/v1/admin/", include("apps.evidence.urls")), diff --git a/backend/requirements.txt b/backend/requirements.txt index 829b6821..c06d7eac 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -25,6 +25,9 @@ requests>=2.32,<3 httpx[http2]==0.28.1 openpyxl>=3.1,<4 Pillow +reportlab>=4.2,<5 +python-dateutil>=2.9,<3 +icalendar>=7.0,<8 celery[redis]>=5.4,<6 redis>=5.0,<7 dnspython>=2.7,<3 diff --git a/backend/tests/accounts/test_claim_routes.py b/backend/tests/accounts/test_claim_routes.py index 505057bf..f6c2521a 100644 --- a/backend/tests/accounts/test_claim_routes.py +++ b/backend/tests/accounts/test_claim_routes.py @@ -37,10 +37,16 @@ def assert_guard_fails(patterns, matrix, expected): def test_current_all_active_tree_has_a_complete_claim_matrix(settings): assert settings.TOMBSTONED_APPS == frozenset() - # 72 claim-reachable patterns at D3, plus the D5 claim-redemption endpoint. The count + # 72 claim-reachable patterns at D3, plus the D5 claim-redemption endpoint, plus the + # three organization routes (public detail, public events, invitation redeem). The count # is asserted so that adding a member-reachable route is a visible decision here, not # only inside the matrix. - assert len(validate_claim_route_matrix()) == 75 + # 72 at D3 + the D5 claim-redemption endpoint + three organization routes (public + # 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 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 04b785c2..5fe8badf 100644 --- a/backend/tests/accounts/test_claim_session_contract_p7.py +++ b/backend/tests/accounts/test_claim_session_contract_p7.py @@ -81,6 +81,10 @@ def test_refresh_rotation_preserves_one_absolute_expiry_forever(): "pk": 1, }, "member-event-checkin-qr": {"makerspace_id": "claim", "pk": 1}, + "member-event-calendar": {"makerspace_id": "claim"}, + "member-event-calendar-feed": {"makerspace_id": "claim"}, + "member-event-feedback": {"makerspace_id": "claim", "pk": 1}, + "member-event-certificate-download": {"makerspace_id": "claim", "pk": 1}, "public-membership-request": {"makerspace_slug": "claim"}, } REFUSED_KEYS = sorted( diff --git a/backend/tests/accounts/test_device_auth.py b/backend/tests/accounts/test_device_auth.py index 8cbd197f..a8d9b2a8 100644 --- a/backend/tests/accounts/test_device_auth.py +++ b/backend/tests/accounts/test_device_auth.py @@ -55,7 +55,7 @@ def json(self): ) -def attested_login(client, user, settings, monkeypatch): +def attested_login(client, user, settings, monkeypatch, *, password="strong-device-password"): configure_apple(settings) challenge_response = client.post( CHALLENGE, @@ -72,7 +72,7 @@ def attested_login(client, user, settings, monkeypatch): mock_apple_provider(monkeypatch, challenge) payload = { "username": user.username, - "password": "strong-device-password", + "password": password, "platform": "apple", "app_id": "org.spaceworks.app", "environment": "development", diff --git a/backend/tests/accounts/test_member_identity_p9.py b/backend/tests/accounts/test_member_identity_p9.py index 8f9f355a..10cb0d44 100644 --- a/backend/tests/accounts/test_member_identity_p9.py +++ b/backend/tests/accounts/test_member_identity_p9.py @@ -13,6 +13,7 @@ from apps.accounts import member_identity from apps.makerspaces.models import Makerspace, MakerspaceMembership, MakerspaceRole from apps.makerspaces.module_install import uninstall_module +from tests.module_helpers import disable_module from apps.makerspaces.walk_in_services import create_walk_in_member pytestmark = pytest.mark.django_db @@ -31,7 +32,7 @@ def make_space(slug="identity-space"): def accounts_off(makerspace, actor=None): """Uninstall through the real service, so dependency and feature pruning run.""" uninstall_module(makerspace, "mobile", actor=actor) - uninstall_module(makerspace, "membership", actor=actor) + disable_module(makerspace, "membership", actor=actor) uninstall_module(makerspace, "member_accounts", actor=actor) diff --git a/backend/tests/backup/test_events_programme_deployment_roundtrip.py b/backend/tests/backup/test_events_programme_deployment_roundtrip.py new file mode 100644 index 00000000..e4c79e1d --- /dev/null +++ b/backend/tests/backup/test_events_programme_deployment_roundtrip.py @@ -0,0 +1,255 @@ +"""Deployment backup/restore coverage for the phase 1-9 programme graph.""" + +import hashlib +from datetime import timedelta +from pathlib import Path + +import pytest +from django.contrib.auth import get_user_model +from django.utils import timezone + +from apps.backup import archive_builder, storage as backup_storage +from apps.backup.projection_databases import restore_dump, temporary_database +from apps.bookings.models import BookableSpace, Booking +from apps.events.models import Event, EventRegistration +from apps.evidence.models import EvidenceObjectRetentionState +from apps.hardware_requests.models import HardwareRequest +from apps.makerspaces import archive_requests, module_purge +from apps.makerspaces.models import ( + Makerspace, MakerspaceArchiveRequest, MakerspaceMembership, MemberProfile, +) +from tests.backup.test_compound_archive_e3 import ( + _archive, + _prepare, + _sovereign, + allow_projection_databases, +) +from tests.encryption.conftest import enabled_encryption +from tests.tenant_migration.programme_graph import create_programme_graph + + +pytestmark = pytest.mark.django_db(transaction=True) +OBJECT_BYTES = b"%PDF-1.7\nprogramme deployment artifact\n%%EOF\n" + +PHASE_LABELS = ( + "events.EventSeries", "events.Event", "events.EventRegistration", + "events.EventCheckInEvent", "events.EventFeedbackSurvey", + "events.EventFeedbackResponse", "events.EventAttendanceCertificate", + "events.EventSeriesOrganizer", "events.EventOrganizer", + "events.MemberCalendarFeed", "events.EventCheckInStationCredential", + "organizations.Organization", "organizations.OrganizationMakerspace", + "organizations.OrganizationMembership", "organizations.OrganizationInvitation", + "operations.ReportMetricRollup", "operations.ReportRollupCursor", + "evidence.EvidencePhoto", "evidence.EvidenceRetentionPolicy", + "evidence.EvidenceObjectRetentionState", "payments.Payment", "audit.AuditLog", + "bookings.BookableSpace", "bookings.Booking", "makerspaces.MemberProfile", +) + + +def _source_graph(slug): + user = get_user_model().objects.create_user( + username=f"{slug}-manager", email=f"{slug}@example.test", + role=get_user_model().Role.SPACE_MANAGER, is_staff=True, + ) + space = Makerspace.objects.create( + name=slug, slug=slug, superadmin_access_enabled=True, + enabled_modules=["membership", "events", "bookings", "reports"], + ) + membership = MakerspaceMembership.objects.create( + makerspace=space, user=user, role=MakerspaceMembership.Role.SPACE_MANAGER, + ) + MemberProfile.objects.create( + membership=membership, is_visible=True, show_attended_events=True, + headline="Programme mentor", + ) + bookable = BookableSpace.objects.create( + makerspace=space, name="Training bench", capacity=4, created_by=user, + ) + Booking.objects.create( + space=bookable, name="Archive Member", email=user.email, + phone="+15550001111", member=user, + starts_at=timezone.now() + timedelta(days=2), + ends_at=timezone.now() + timedelta(days=2, hours=1), + ) + request = HardwareRequest.objects.create( + makerspace=space, requester=user, requester_username=user.username, + requester_name="Archive Member", requester_contact_email=user.email, + ) + Event.objects.create( + makerspace=space, title="Portable workshop", + starts_at=timezone.now() + timedelta(days=1), + ends_at=timezone.now() + timedelta(days=1, hours=2), created_by=user, + ) + EventRegistration.objects.create( + event=space.events.get(), name="Archive Member", email=user.email, + phone="+15550001111", member=user, + registered_via_makerspace=space, payment_via_makerspace=space, + ) + return space, user, create_programme_graph(space, user, request) + + +def _rows(space_id): + from django.apps import apps + + rows = {} + for label in PHASE_LABELS: + model = apps.get_model(label) + if label == "organizations.Organization": + queryset = model.objects.filter(makerspace_links__makerspace_id=space_id) + elif label.startswith("organizations."): + lookup = { + "organizations.OrganizationMakerspace": "makerspace_id", + "organizations.OrganizationMembership": "organization__makerspace_links__makerspace_id", + "organizations.OrganizationInvitation": "organization__makerspace_links__makerspace_id", + }[label] + queryset = model.objects.filter(**{lookup: space_id}) + elif label == "events.EventOrganizer": + queryset = model.objects.filter(event__makerspace_id=space_id) + elif label == "events.EventSeriesOrganizer": + queryset = model.objects.filter(series__makerspace_id=space_id) + elif label == "events.MemberCalendarFeed": + queryset = model.objects.filter(membership__makerspace_id=space_id) + elif label == "events.EventCheckInStationCredential": + queryset = model.objects.filter(event__makerspace_id=space_id) + elif label.startswith("events.EventFeedback"): + lookup = { + "events.EventFeedbackSurvey": "event__makerspace_id", + "events.EventFeedbackResponse": "survey__event__makerspace_id", + }[label] + queryset = model.objects.filter(**{lookup: space_id}) + elif label == "events.EventAttendanceCertificate": + queryset = model.objects.filter(registration__event__makerspace_id=space_id) + elif label == "bookings.Booking": + queryset = model.objects.filter(space__makerspace_id=space_id) + elif label == "makerspaces.MemberProfile": + queryset = model.objects.filter(membership__makerspace_id=space_id) + elif label == "events.EventRegistration": + queryset = model.objects.filter(event__makerspace_id=space_id) + elif label == "events.EventSeries": + queryset = model.objects.filter(makerspace_id=space_id) + elif label == "evidence.EvidenceObjectRetentionState": + queryset = model.objects.filter(evidence__makerspace_id=space_id) + else: + queryset = model.objects.filter(makerspace_id=space_id) + rows[label] = list(queryset.order_by(model._meta.pk.name).values()) + return rows + + +def test_deployment_restore_preserves_disabled_archived_graph_and_two_key_request( + allow_projection_databases, monkeypatch, settings +): + with enabled_encryption(): + space, manager, _graph = _source_graph("programme-deployment") + # Uninstall is retention-only: the data remains but the module state stays OFF. + space.enabled_modules = [] + space.save(update_fields=("enabled_modules",)) + resolver = get_user_model().objects.create_superuser( + username="programme-resolver", email="resolver@example.test", password="pw" + ) + monkeypatch.setattr(archive_requests, "schedule_created", lambda _pk: None) + monkeypatch.setattr(archive_requests, "schedule_resolved", lambda _pk: None) + request = archive_requests.create(space, manager, "Lease ended.") + archive_requests.approve(request, resolver, "Independent approval.") + # approve() stamps archived_at in the database; the local object is stale. + space.refresh_from_db() + expected = _rows(space.pk) + + # A second tenant takes the destructive path. Its archive must not recreate + # the event graph (or the rollup derived from it) after restore. + purged_space, _purged_manager, _ = _source_graph("programme-purged") + purged_space.enabled_modules = [] + purged_space.save(update_fields=("enabled_modules",)) + settings.MANAGED_POSTGRES = True + monkeypatch.setattr(module_purge, "_delete_private_keys", lambda keys: keys) + monkeypatch.setattr(module_purge, "_free_private_storage", lambda *_args: None) + monkeypatch.setattr( + module_purge, "_delete_public_images_and_free_storage", lambda *_args: None + ) + module_purge.purge_module(purged_space, "events", resolver) + module_purge.purge_module(purged_space, "bookings", resolver) + module_purge.purge_module(purged_space, "membership", resolver) + assert not Event.objects.filter(makerspace=purged_space).exists() + assert not BookableSpace.objects.filter(makerspace=purged_space).exists() + assert not MemberProfile.objects.filter( + membership__makerspace=purged_space + ).exists() + + # A deployment archive is a COMPOUND archive: the readable main is derived by + # excluding tenants that hold their own custody, and the source verifier proves + # main + slices == the full dump. Give the run one sovereign tenant so this test + # exercises that supported shape. (A deployment with NO sovereign tenant — the + # default, since superadmin_access_enabled starts True — cannot build a + # deployment archive at all today; that gap is tracked separately and is not + # what this test is for.) + _sovereign() + _prepare(monkeypatch, settings) + # Capture the object bytes for real against a fake bucket. Stubbing capture out + # cannot work on the compound path: the ownership plan is built from the rows + # themselves, and bind_component proves the manifest EQUALS that closure, so an + # empty manifest is a mismatch rather than a shortcut. Expired evidence needs no + # bytes -- it is captured as a tombstone and asserted absent from the bucket. + def _download(_bucket, key, destination, *, versioned): + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_bytes(OBJECT_BYTES) + return { + "key": key, + "version_id": "programme-v1", + "size": len(OBJECT_BYTES), + "sha256": hashlib.sha256(OBJECT_BYTES).hexdigest(), + "metadata": {}, + "content_type": "application/octet-stream", + "headers": {}, + } + + absent = [] + monkeypatch.setattr(backup_storage, "download_object", _download) + monkeypatch.setattr( + backup_storage, + "assert_object_absent", + lambda _bucket, key: absent.append(key), + ) + _sealed, _manifest, tempdir, _digest = archive_builder.build_archive(_archive()) + # Expired evidence travels as a tombstone, and capture has to PROVE no bytes + # survive it -- under the final key and under the staging key a presign writes. + expired_keys = list( + EvidenceObjectRetentionState.objects.filter( + status=EvidenceObjectRetentionState.Status.EXPIRED + ).values_list("evidence__object_key", flat=True) + ) + assert len(expired_keys) == 2 + assert sorted(absent) == sorted( + key for base in expired_keys for key in (base, f"staging/{base}") + ) + try: + dump = Path(tempdir.name, "bundle", "database.dump") + with temporary_database("programme_restore") as (using, database_name): + restore_dump(dump, database_name) + restored = Makerspace.objects.using(using).get(pk=space.pk) + assert restored.enabled_modules == [] + assert restored.archived_at == space.archived_at + archived_request = MakerspaceArchiveRequest.objects.using(using).get(pk=request.pk) + assert archived_request.status == MakerspaceArchiveRequest.Status.APPROVED + assert archived_request.requested_by_id == manager.pk + assert archived_request.resolved_by_id == resolver.pk + assert not Event.objects.using(using).filter( + makerspace_id=purged_space.pk + ).exists() + assert not BookableSpace.objects.using(using).filter( + makerspace_id=purged_space.pk + ).exists() + assert not MemberProfile.objects.using(using).filter( + membership__makerspace_id=purged_space.pk + ).exists() + from apps.operations.models import ReportMetricRollup + assert not ReportMetricRollup.objects.using(using).filter( + makerspace_id=purged_space.pk, source_module="events" + ).exists() + from django.apps import apps + for label, source_rows in expected.items(): + model = apps.get_model(label) + target_rows = list(model.objects.using(using).filter( + pk__in=[row[model._meta.pk.attname] for row in source_rows] + ).order_by(model._meta.pk.name).values()) + assert target_rows == source_rows, label + finally: + tempdir.cleanup() diff --git a/backend/tests/backup/test_evidence_retention_objects.py b/backend/tests/backup/test_evidence_retention_objects.py new file mode 100644 index 00000000..b868778c --- /dev/null +++ b/backend/tests/backup/test_evidence_retention_objects.py @@ -0,0 +1,91 @@ +from datetime import timedelta + +from botocore.exceptions import ClientError +import pytest +from django.contrib.auth import get_user_model +from django.utils import timezone + +from apps.backup import archive_objects, storage +from apps.evidence.models import EvidenceObjectRetentionState, EvidencePhoto +from apps.makerspaces.models import Makerspace + + +pytestmark = pytest.mark.django_db + + +def make_photo(status): + makerspace = Makerspace.objects.create(name=f"backup-{status}", slug=f"backup-{status}") + user = get_user_model().objects.create_user( + username=f"backup-{status}", email=f"backup-{status}@example.test" + ) + photo = EvidencePhoto.objects.create( + makerspace=makerspace, + evidence_type=EvidencePhoto.EvidenceType.ISSUE, + object_key=f"evidence/{makerspace.pk}/photo.jpg", + uploaded_by=user, + ) + kwargs = {"evidence": photo, "status": status} + if status == EvidenceObjectRetentionState.Status.EXPIRED: + kwargs.update( + object_expired_at=timezone.now() - timedelta(minutes=1), + expired_size_bytes=456, + ) + EvidenceObjectRetentionState.objects.create(**kwargs) + return photo + + +def test_expired_evidence_is_captured_as_intentional_absence(tmp_path, monkeypatch): + photo = make_photo(EvidenceObjectRetentionState.Status.EXPIRED) + closure = {"private": {}, "public_image": {}} + archive_objects.collect_model_objects( + EvidencePhoto.objects.filter(pk=photo.pk), EvidencePhoto, closure + ) + absent = [] + monkeypatch.setattr( + storage, "assert_object_absent", lambda bucket, key: absent.append((bucket, key)) + ) + monkeypatch.setattr( + storage, + "download_object", + lambda *_args, **_kwargs: pytest.fail("expired evidence attempted byte capture"), + ) + + manifest = archive_objects.capture_objects( + tmp_path, + closure, + {"private": "versioned", "public_image": "versioned"}, + ) + + assert absent == [ + (storage.settings.AWS_STORAGE_BUCKET_NAME, photo.object_key), + (storage.settings.AWS_STORAGE_BUCKET_NAME, f"staging/{photo.object_key}"), + ] + assert manifest[0]["retention_state"] == "expired" + assert manifest[0]["expired_size_bytes"] == 456 + assert manifest[0]["size"] == 0 + assert not (tmp_path / "private" / photo.object_key).exists() + + +def test_expiring_evidence_refuses_backup_capture(): + photo = make_photo(EvidenceObjectRetentionState.Status.EXPIRING) + + with pytest.raises(storage.BackupStorageError, match="in progress"): + archive_objects.collect_model_objects( + EvidencePhoto.objects.filter(pk=photo.pk), + EvidencePhoto, + {"private": {}, "public_image": {}}, + ) + + +def test_intentional_absence_requires_version_listing(monkeypatch): + class Client: + def list_object_versions(self, **_kwargs): + raise ClientError( + {"Error": {"Code": "NotImplemented", "Message": "unsupported"}}, + "ListObjectVersions", + ) + + monkeypatch.setattr(storage, "client", lambda: Client()) + + with pytest.raises(storage.BackupStorageError, match="could not be inspected"): + storage.assert_object_absent("private", "evidence/1/photo.jpg") diff --git a/backend/tests/backup/test_tenant_projection.py b/backend/tests/backup/test_tenant_projection.py index 7dd89b42..b0ce42d4 100644 --- a/backend/tests/backup/test_tenant_projection.py +++ b/backend/tests/backup/test_tenant_projection.py @@ -61,6 +61,29 @@ def test_event_collaboration_is_snapshot_only_in_both_directions(): } +def test_event_registration_policy_is_preserved_in_backup_projection(): + own, _foreign = spaces() + start = timezone.now() + timedelta(days=1) + event = Event.objects.create( + makerspace=own, + title="Policy event", + starts_at=start, + ends_at=start + timedelta(hours=1), + registration_requires_approval=True, + registration_cutoff_lead_minutes=60, + ) + + payload, _references, included = project( + "events.Event", Event.objects.filter(pk=event.pk), own.pk, + ) + fields = json.loads(payload)[0]["fields"] + + assert included == [event.pk] + assert fields["registration_requires_approval"] is True + assert fields["registration_cutoff_at"] is None + assert fields["registration_cutoff_lead_minutes"] == 60 + + def test_cross_tenant_fields_are_nulled_and_preserved_as_provenance(): own, foreign = spaces() actor = get_user_model().objects.create_user(username="operator") diff --git a/backend/tests/data_export/test_event_registration_policy_export.py b/backend/tests/data_export/test_event_registration_policy_export.py new file mode 100644 index 00000000..a5f6874a --- /dev/null +++ b/backend/tests/data_export/test_event_registration_policy_export.py @@ -0,0 +1,39 @@ +from datetime import timedelta + +import pytest +from django.utils import timezone + +from apps.events.models import Event +from tests.data_export.portable_helpers import ( + archive_files, + csv_rows, + make_job, + make_space, + make_user, +) + + +pytestmark = pytest.mark.django_db(transaction=True) + + +def test_event_registration_policy_survives_portable_export(): + actor = make_user("event-policy-exporter") + makerspace = make_space("event-policy-export") + start = timezone.now() + timedelta(days=1) + cutoff = start - timedelta(minutes=45) + event = Event.objects.create( + makerspace=makerspace, + title="Approval workshop", + starts_at=start, + ends_at=start + timedelta(hours=2), + registration_requires_approval=True, + registration_cutoff_at=cutoff, + ) + + files, _archive_bytes, _manifest = archive_files(make_job(makerspace, actor)) + row = csv_rows(files, "events/events.csv")[0] + + assert row["id"] == str(event.pk) + assert row["registration_requires_approval"] == "true" + assert row["registration_cutoff_at"] == cutoff.isoformat() + assert row["registration_cutoff_lead_minutes"] == "" diff --git a/backend/tests/encryption/test_leak_sweep.py b/backend/tests/encryption/test_leak_sweep.py index 87d8898e..1470dcea 100644 --- a/backend/tests/encryption/test_leak_sweep.py +++ b/backend/tests/encryption/test_leak_sweep.py @@ -13,7 +13,13 @@ from apps.bookings.models import BookableSpace, Booking from apps.encryption.crypto import is_envelope from apps.encryption.registry import ALL_FIELDS -from apps.events.models import Event, EventRegistration +from apps.events.models import ( + Event, + EventAttendanceCertificate, + EventFeedbackResponse, + EventFeedbackSurvey, + EventRegistration, +) from apps.hardware_requests.models import HardwareRequest from apps.integrations.admin_email_logs import EmailLogAdmin from apps.integrations.models import EmailLog @@ -40,9 +46,29 @@ def _objects(): machine_type = MachineType.objects.create(makerspace=space, slug=f"sweep-{stamp}", name="Sweep machine") machine = Machine.objects.create(makerspace=space, machine_type=machine_type, name="Sweep machine") service_bucket = ServiceBucket.objects.create(machine=machine, name="Sweep service") + registration = EventRegistration.objects.create( + event=event, name="Base", email=f"base-{stamp}@example.test", phone="1", + ) + survey = EventFeedbackSurvey.objects.create( + event=event, title="Sweep survey", + questions=[{"id": "q1", "label": "Rating", "type": "number", "options": [], "required": True}], + ) + # event_feedback_response_mode_matches_identity: a response carries its registration + # only when a certificate was requested; an anonymous one must stay unattributed. + response = EventFeedbackResponse.objects.create( + survey=survey, registration=registration, answers_snapshot="{}", + certificate_requested=True, + ) return { "hardware_requests.HardwareRequest": HardwareRequest.objects.create(makerspace=space, requester=user, requester_username=user.username), - "events.EventRegistration": EventRegistration.objects.create(event=event, name="Base", email=f"base-{stamp}@example.test", phone="1"), + "events.EventRegistration": registration, + "events.EventFeedbackResponse": response, + "events.EventAttendanceCertificate": EventAttendanceCertificate.objects.create( + response=response, registration=registration, revision=1, + recipient_name="Base", event_title="Sweep event", + event_starts_at=now, event_ends_at=now + timedelta(hours=1), + issuer_name="Sweep", object_key=f"certificates/sweep-{stamp}.pdf", + ), "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), @@ -68,6 +94,44 @@ def test_every_mapped_value_is_an_envelope_and_not_a_raw_database_leak(item, cap logged_by=row.logged_by, **{item.field_name: value}, ) + elif item.model_label == "events.EventFeedbackResponse": + # Feedback answers are an immutable snapshot. Written anonymously here so the + # identity-mode check constraint holds without a second registration. + row = EventFeedbackResponse.objects.create( + survey=row.survey, + registration=None, + certificate_requested=False, + **{item.field_name: value}, + ) + elif item.model_label == "events.EventAttendanceCertificate": + # Certificate issuance snapshots are immutable, uniq_live_event_certificate + # allows one non-revoked certificate per registration, and a database trigger + # refuses a pending -> revoked shortcut. So the sentinel certificate is issued + # for a FRESH registration rather than contorting the base row. + stamp = uuid4().hex[:8] + fresh = EventRegistration.objects.create( + event=row.registration.event, + name="Sweep", + email=f"sweep-cert-{stamp}@example.test", + phone="1", + ) + fresh_response = EventFeedbackResponse.objects.create( + survey=row.response.survey, + registration=fresh, + certificate_requested=True, + answers_snapshot="{}", + ) + row = EventAttendanceCertificate.objects.create( + response=fresh_response, + registration=fresh, + revision=1, + event_title=row.event_title, + event_starts_at=row.event_starts_at, + event_ends_at=row.event_ends_at, + issuer_name=row.issuer_name, + object_key=f"certificates/sweep-{stamp}.pdf", + **{item.field_name: value}, + ) else: setattr(row, item.field_name, value) row.save(update_fields=[item.field_name]) diff --git a/backend/tests/encryption/test_mappers.py b/backend/tests/encryption/test_mappers.py index a78f82aa..e05bfb84 100644 --- a/backend/tests/encryption/test_mappers.py +++ b/backend/tests/encryption/test_mappers.py @@ -41,9 +41,12 @@ def make_request(space, requester): def test_registry_matches_the_post_b7c_source_and_secondary_allowlists(): - assert len(SOURCE_FIELDS) == 18 + # 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 assert len(SECONDARY_FIELDS) == 4 - assert len(ALL_FIELDS) == 22 + assert len(ALL_FIELDS) == 24 assert {item.model_label for item in SECONDARY_FIELDS} == {"integrations.EmailLog"} diff --git a/backend/tests/events/test_admin_api.py b/backend/tests/events/test_admin_api.py index 8b3d7cb5..0e5475c7 100644 --- a/backend/tests/events/test_admin_api.py +++ b/backend/tests/events/test_admin_api.py @@ -53,6 +53,9 @@ def endpoint_calls(space, event, registration): ('post', reverse('admin-event-complete', kwargs={'pk': event.pk}), {}), ('get', reverse('admin-event-registration-list', kwargs={'pk': event.pk}), None), ('post', reverse('admin-event-registration-mark-attended', kwargs={'pk': registration.pk}), {}), + ('post', reverse('admin-event-registration-approve', kwargs={'pk': registration.pk}), {}), + ('post', reverse('admin-event-registration-reject', kwargs={'pk': registration.pk}), {}), + ('post', reverse('admin-event-registration-promote', kwargs={'pk': registration.pk}), {}), ] def call(client, method, url, data): return getattr(client, method)(url, data=data, format='json') @@ -256,7 +259,7 @@ def test_staff_urls_reverse_and_origin_registry_resolves_owner(): event = make_event(space) registration = make_registration(event) urls = [url for _method, url, _data in endpoint_calls(space, event, registration)] - assert len(set(urls)) == 7 + assert len(set(urls)) == 10 factory = APIRequestFactory() for url in urls: match = resolve(url) @@ -278,8 +281,11 @@ def test_openapi_contains_nine_operations_and_typed_components(): '/api/v1/admin/events/{id}/complete/': {'post'}, '/api/v1/admin/events/{id}/registrations/': {'get'}, '/api/v1/admin/event-registrations/{id}/mark-attended/': {'post'}, + '/api/v1/admin/event-registrations/{id}/approve/': {'post'}, + '/api/v1/admin/event-registrations/{id}/reject/': {'post'}, + '/api/v1/admin/event-registrations/{id}/promote/': {'post'}, } - assert sum(len(methods) for methods in paths.values()) == 9 + assert sum(len(methods) for methods in paths.values()) == 12 assert all(methods <= schema['paths'][path].keys() for path, methods in paths.items()) components = schema['components']['schemas'] assert {'EventWrite', 'EventAdmin', 'EventRegistrationAdmin'} <= components.keys() diff --git a/backend/tests/events/test_event_badges.py b/backend/tests/events/test_event_badges.py new file mode 100644 index 00000000..1dac2f48 --- /dev/null +++ b/backend/tests/events/test_event_badges.py @@ -0,0 +1,119 @@ +import pytest +from django.urls import reverse +from rest_framework.test import APIClient + +from apps.audit.models import AuditLog +from apps.boxes.models import QrCode +from apps.events.models import Event, EventRegistration +from tests.events.checkin_helpers import ( + client_for, make_event, make_member, make_space, make_staff, register, +) + + +pytestmark = pytest.mark.django_db + + +def template_url(event): + return reverse("admin-event-badge-template", kwargs={"pk": event.pk}) + + +def pdf_url(event): + return reverse("admin-event-badges-pdf", kwargs={"pk": event.pk}) + + +def test_badge_pdf_uses_the_existing_registration_checkin_token(monkeypatch): + space = make_space("event-badge-token") + staff = make_staff(space, "badge-staff") + member = make_member(space, "badge-member", display_name="Badge Person") + registration = register(make_event(space, "Badge workshop"), member) + original = registration.checkin_token + qr_count = QrCode.objects.count() + captured = {} + + def fake_render(template, snapshots, *, title): + captured["template"] = template + captured["snapshots"] = snapshots + captured["title"] = title + return b"%PDF-1.4\n% test" + + monkeypatch.setattr("apps.events.views_badges.render_badges_pdf", fake_render) + response = client_for(staff).post( + pdf_url(registration.event), {"registration_ids": [registration.pk]}, format="json", + ) + registration.refresh_from_db() + + assert response.status_code == 200 + assert response["Content-Type"] == "application/pdf" + assert response["Cache-Control"] == "private, no-store" + assert captured["snapshots"][0].checkin_token == str(original) + assert registration.checkin_token == original + assert QrCode.objects.count() == qr_count + assert not hasattr(registration, "badge_token") + assert AuditLog.objects.filter(action="event.badges_generated").exists() + + +def test_badge_eligibility_requires_explicit_attended_opt_in(): + space = make_space("event-badge-status") + staff = make_staff(space, "badge-status-staff") + event = make_event(space) + attended = register(event, make_member(space, "badge-attended"), status=EventRegistration.Status.ATTENDED) + waitlisted = register(event, make_member(space, "badge-waitlisted"), status=EventRegistration.Status.WAITLISTED) + client = client_for(staff) + + assert client.post(pdf_url(event), {"registration_ids": [attended.pk]}, format="json").status_code == 409 + assert client.post(pdf_url(event), { + "registration_ids": [attended.pk], "include_attended": True, + }, format="json").status_code == 200 + assert client.post(pdf_url(event), { + "registration_ids": [waitlisted.pk], "include_attended": True, + }, format="json").status_code == 409 + + +def test_badge_template_is_validated_saved_and_audited(): + space = make_space("event-badge-template") + staff = make_staff(space, "badge-template-staff") + event = make_event(space, custom_form=[{ + "id": "diet", "label": "Diet", "type": "short_text", "options": [], + "required": False, + }]) + client = client_for(staff) + initial = client.get(template_url(event)) + saved = client.put(template_url(event), { + **initial.data, "fields": ["name", "custom:diet", "email"], + }, format="json") + invalid = client.put(template_url(event), { + **initial.data, "fields": ["name", "custom:unknown"], + }, format="json") + + assert initial.status_code == saved.status_code == 200 + assert saved.data["fields"] == ["name", "custom:diet", "email"] + assert invalid.status_code == 400 + event.refresh_from_db() + assert event.badge_template["fields"] == ["name", "custom:diet", "email"] + assert AuditLog.objects.filter(action="event.badge_template_updated").exists() + + +def test_badge_endpoints_enforce_tenant_rbac_and_module_on_off(): + space = make_space("event-badge-scope") + other = make_space("event-badge-outsider") + staff = make_staff(space, "badge-scope-staff") + event = make_event(space) + registration = register(event, make_member(space, "badge-scope-member")) + other_registration = register( + make_event(other), make_member(other, "badge-other-member") + ) + + assert client_for(staff).post( + pdf_url(event), {"registration_ids": [registration.pk]}, format="json", + ).status_code == 200 + assert client_for(staff).post( + pdf_url(event), {"registration_ids": [other_registration.pk]}, format="json", + ).status_code == 404 + assert client_for(make_staff(other, "other-staff")).get(template_url(event)).status_code == 404 + assert APIClient().get(template_url(event)).status_code in (401, 403) + space.enabled_modules = [key for key in space.enabled_modules if key != "events"] + space.save(update_fields=("enabled_modules",)) + assert client_for(staff).get(template_url(event)).status_code == 400 + assert client_for(staff).post( + pdf_url(event), {"registration_ids": [registration.pk]}, format="json", + ).status_code == 400 diff --git a/backend/tests/events/test_event_calendar_feeds.py b/backend/tests/events/test_event_calendar_feeds.py new file mode 100644 index 00000000..eb750a61 --- /dev/null +++ b/backend/tests/events/test_event_calendar_feeds.py @@ -0,0 +1,104 @@ +from urllib.parse import urlsplit + +import pytest +from django.test import override_settings +from django.urls import reverse +from rest_framework.test import APIClient + +from apps.audit.models import AuditLog +from apps.data_export.classification import OMITTED_MODELS +from apps.events.middleware import redact_calendar_feed_uri +from apps.events.models import MemberCalendarFeed +from tests.events.checkin_helpers import client_for, make_event, make_member, make_space, register + + +pytestmark = pytest.mark.django_db + + +def manage_url(space): + return reverse("member-event-calendar-feed", kwargs={"makerspace_id": space.pk}) + + +def path_from_absolute(value): + return urlsplit(value).path + + +@override_settings(API_CLIENT_AUTH_REQUIRED=True) +def test_feed_is_one_time_bearer_and_rotation_and_revocation_are_immediate(): + space = make_space("calendar-feed") + member = make_member(space, "feed-member") + register(make_event(space, "Private feed event", is_public=False), member) + client = client_for(member) + + assert client.post( + manage_url(space), {"confirm_bearer_risk": False}, format="json" + ).status_code == 400 + issued = client.post(manage_url(space), {"confirm_bearer_risk": True}, format="json") + first_path = path_from_absolute(issued.data["feed_url"]) + state = client.get(manage_url(space)) + audit_count = AuditLog.objects.count() + first = APIClient().get(first_path) + assert AuditLog.objects.count() == audit_count + rotated = client.post(manage_url(space), {"confirm_bearer_risk": True}, format="json") + second_path = path_from_absolute(rotated.data["feed_url"]) + + assert issued.status_code == 200 + assert state.data["enabled"] is True and "feed_url" not in state.data + assert first.status_code == 200 and b"Private feed event" in first.content + assert first_path != second_path + assert APIClient().get(first_path).status_code == 404 + assert APIClient().get(second_path).status_code == 200 + assert client.delete(manage_url(space)).status_code == 204 + assert APIClient().get(second_path).status_code == 404 + assert set(AuditLog.objects.values_list("action", flat=True)) >= { + "event.calendar_feed_created", "event.calendar_feed_rotated", + "event.calendar_feed_revoked", + } + + +def test_feed_persists_only_a_digest_and_never_puts_raw_token_in_audit(): + space = make_space("calendar-feed-storage") + member = make_member(space, "feed-storage-member") + response = client_for(member).post( + manage_url(space), {"confirm_bearer_risk": True}, format="json" + ) + raw_token = path_from_absolute(response.data["feed_url"]).rsplit("/", 1)[-1][:-4] + feed = MemberCalendarFeed.objects.get(membership__user=member) + + assert len(bytes(feed.token_digest)) == 32 + assert raw_token not in bytes(feed.token_digest).hex() + assert raw_token not in str(AuditLog.objects.filter(target_id=str(feed.pk)).values("meta")) + assert "events.MemberCalendarFeed" in OMITTED_MODELS + + +def test_feed_is_tenant_bound_module_gated_and_malformed_tokens_are_uniform_404(): + space = make_space("calendar-feed-gates") + other = make_space("calendar-feed-other") + member = make_member(space, "feed-gate-member") + issued = client_for(member).post( + manage_url(space), {"confirm_bearer_risk": True}, format="json" + ) + path = path_from_absolute(issued.data["feed_url"]) + raw_token = path.rsplit("/", 1)[-1][:-4] + wrong_tenant = reverse("public-event-calendar-feed", kwargs={ + "makerspace_slug": other.slug, "raw_token": raw_token, + }) + + assert APIClient().get(wrong_tenant).status_code == 404 + assert APIClient().get(path.replace(raw_token, "not-a-token")).status_code == 404 + member.access_status = "suspended" + member.save(update_fields=("access_status",)) + assert APIClient().get(path).status_code == 404 + member.access_status = "active" + member.save(update_fields=("access_status",)) + space.enabled_modules = [key for key in space.enabled_modules if key != "events"] + space.save(update_fields=("enabled_modules",)) + assert APIClient().get(path).status_code == 404 + assert client_for(member).get(manage_url(space)).status_code == 400 + + +def test_bearer_token_is_redacted_from_request_line_text(): + value = "/api/v1/public/space/event-calendar/secret-token.ics?refresh=1" + redacted = redact_calendar_feed_uri(value) + assert redacted == "/api/v1/public/space/event-calendar/[redacted].ics?refresh=1" + assert "secret-token" not in redacted diff --git a/backend/tests/events/test_event_calendars.py b/backend/tests/events/test_event_calendars.py new file mode 100644 index 00000000..b48f0efe --- /dev/null +++ b/backend/tests/events/test_event_calendars.py @@ -0,0 +1,149 @@ +from datetime import time, timedelta + +import pytest +from django.urls import reverse +from django.utils import timezone +from drf_spectacular.generators import SchemaGenerator +from icalendar import Calendar +from rest_framework.test import APIClient + +from apps.events import services +from apps.events.models import Event, EventRegistration, EventSeries +from tests.events.checkin_helpers import ( + client_for, make_event, make_member, make_space, make_staff, register, +) + + +pytestmark = pytest.mark.django_db + + +def public_url(space, event): + return reverse("public-event-calendar", kwargs={ + "makerspace_slug": space.slug, "public_token": event.public_token, + }) + + +def event_components(response): + return [row for row in Calendar.from_ical(response.content).walk() if row.name == "VEVENT"] + + +def test_public_calendar_has_stable_uid_sequence_and_no_registration_pii(): + space = make_space("calendar-public") + event = make_event(space, title="Safe workshop", description="Public description") + staff = make_staff(space, "calendar-public-staff") + member = make_member(space, "calendar-public-member", display_name="Private Person") + register(event, member, email="private-calendar@example.test") + first = APIClient().get(public_url(space, event)) + component = event_components(first)[0] + + services.update_event(event, actor=staff, location="New room") + second = APIClient().get(public_url(space, event)) + changed = event_components(second)[0] + + assert first.status_code == second.status_code == 200 + assert component["uid"] == changed["uid"] == f"event-{event.calendar_uid}@spaceworks" + assert int(changed["sequence"]) == int(component["sequence"]) + 1 + assert changed["location"] == "New room" + assert b"Private Person" not in second.content + assert b"private-calendar@example.test" not in second.content + assert second["Content-Type"].startswith("text/calendar") + + +def test_public_calendar_is_module_gated_on_both_sides(): + space = make_space("calendar-module") + event = make_event(space) + assert APIClient().get(public_url(space, event)).status_code == 200 + + space.enabled_modules = [key for key in space.enabled_modules if key != "events"] + space.save(update_fields=("enabled_modules",)) + assert APIClient().get(public_url(space, event)).status_code == 400 + + +def test_member_calendar_is_private_and_scoped_to_the_authenticated_member(): + space = make_space("calendar-member") + mine = make_member(space, "calendar-mine", display_name="Mine") + other = make_member(space, "calendar-other", display_name="Other") + own_registration = register(make_event(space, "My private title", is_public=False), mine) + register(make_event(space, "Someone else's title"), other) + response = client_for(mine).get(reverse( + "member-event-calendar", kwargs={"makerspace_id": space.pk}, + )) + component = event_components(response)[0] + + assert response.status_code == 200 + assert response["Cache-Control"].startswith("private") + assert component["uid"] == f"event-{own_registration.event.calendar_uid}@spaceworks" + assert "Registration status: Registered" in str(component["description"]) + assert b"Someone else's title" not in response.content + assert str(own_registration.checkin_token).encode() not in response.content + assert APIClient().get(response.request["PATH_INFO"]).status_code in (401, 403) + + +def test_event_cancellation_overrides_a_waitlisted_registration_to_cancelled(): + space = make_space("calendar-cancelled-waitlist") + member = make_member(space, "calendar-cancelled-member") + event = make_event(space, status=Event.Status.CANCELLED) + register(event, member, status=EventRegistration.Status.WAITLISTED) + response = client_for(member).get(reverse( + "member-event-calendar", kwargs={"makerspace_id": space.pk}, + )) + assert str(event_components(response)[0]["status"]) == "CANCELLED" + + +def test_series_calendar_uses_one_rrule_uid_with_override_exceptions(): + space = make_space("calendar-series") + tomorrow = (timezone.now() + timedelta(days=1)).date() + series = EventSeries.objects.create( + makerspace=space, title="Weekly studio", description="Series description", + recurrence_timezone="Asia/Kolkata", dtstart_local_date=tomorrow, + dtstart_local_time=time(18, 30), recurrence_rule="FREQ=WEEKLY;COUNT=3", + duration_minutes=60, is_public=True, status=EventSeries.Status.PUBLISHED, + ) + start = timezone.now() + timedelta(days=1) + event = Event.objects.create( + makerspace=space, series=series, series_occurrence_key=f"local:{tomorrow:%Y%m%d}T183000", + series_revision=series.revision, series_override_fields=["location"], + title=series.title, description=series.description, location="Special room", + starts_at=start, ends_at=start + timedelta(hours=1), timezone_name="Asia/Kolkata", + is_public=True, status=Event.Status.PUBLISHED, + ) + hidden_date = tomorrow + timedelta(days=7) + Event.objects.create( + makerspace=space, series=series, + series_occurrence_key=f"local:{hidden_date:%Y%m%d}T183000", + series_revision=series.revision, series_override_fields=["title", "is_public"], + title="Private planning sentinel", description="Do not publish", location="Secret room", + starts_at=start + timedelta(days=7), ends_at=start + timedelta(days=7, hours=1), + timezone_name="Asia/Kolkata", is_public=False, status=Event.Status.PUBLISHED, + ) + response = APIClient().get(public_url(space, event)) + components = event_components(response) + + assert response.status_code == 200 + assert len(components) == 3 + assert components[0]["rrule"]["FREQ"] == ["WEEKLY"] + assert components[0]["uid"] == components[1]["uid"] + assert components[1]["recurrence-id"] is not None + assert b"TZID:Asia/Kolkata" in response.content + assert b"Private planning sentinel" not in response.content + hidden_component = next(row for row in components if str(row["status"]) == "CANCELLED") + assert hidden_component["summary"] == series.title + + +def test_calendar_and_badge_openapi_operations_declare_binary_responses(): + schema = SchemaGenerator().get_schema(request=None, public=True) + operations = { + "/api/v1/public/{makerspace_slug}/events/{public_token}/calendar.ics": ("get", "text/calendar"), + "/api/v1/member/makerspaces/{makerspace_id}/event-registrations/calendar.ics": ("get", "text/calendar"), + "/api/v1/public/{makerspace_slug}/event-calendar/{raw_token}.ics": ("get", "text/calendar"), + "/api/v1/admin/events/{id}/badges.pdf": ("post", "application/pdf"), + } + for path, (method, media_type) in operations.items(): + assert media_type in schema["paths"][path][method]["responses"]["200"]["content"] + feed = schema["paths"][ + "/api/v1/member/makerspaces/{makerspace_id}/event-calendar-feed/" + ] + assert {"get", "post", "delete"} <= set(feed) + assert {"get", "put"} <= set(schema["paths"][ + "/api/v1/admin/events/{id}/badge-template/" + ]) diff --git a/backend/tests/events/test_event_checkin_offline.py b/backend/tests/events/test_event_checkin_offline.py new file mode 100644 index 00000000..9276461c --- /dev/null +++ b/backend/tests/events/test_event_checkin_offline.py @@ -0,0 +1,188 @@ +from datetime import timedelta +from uuid import uuid4 + +import pytest +from django.urls import reverse +from django.utils import timezone + +from apps.audit.models import AuditLog +from apps.events import services +from apps.events.models import EventCheckInEvent +from tests.events.checkin_helpers import ( + client_for, + make_event, + make_member, + make_space, + make_staff, + register, +) + +pytestmark = pytest.mark.django_db + + +def enable_offline(space): + space.enabled_features = [*space.enabled_features, "events.offline_checkin"] + space.save(update_fields=["enabled_features"]) + + +def roster_url(event): + return reverse("admin-event-check-in-offline-roster", kwargs={"pk": event.pk}) + + +def sync_url(event): + return reverse("admin-event-check-in-offline-sync", kwargs={"pk": event.pk}) + + +def test_feature_defaults_off_but_online_confirmation_still_works(): + space = make_space() + event = make_event(space) + registration = register(event, make_member(space)) + client = client_for(make_staff(space)) + + assert client.get(roster_url(event)).status_code == 400 + confirmed = client.post( + reverse( + "admin-event-registration-mark-attended", + kwargs={"pk": registration.pk}, + ), + {}, + format="json", + ) + + assert confirmed.status_code == 200 + history = EventCheckInEvent.objects.get(registration=registration) + assert history.source == EventCheckInEvent.Source.ONLINE + + +def test_enabled_roster_is_minimal_expiring_and_not_cacheable(): + space = make_space() + enable_offline(space) + event = make_event(space) + member = make_member(space) + registration = register(event, member) + registration.custom_answers = {"diet": "private"} + registration.save(update_fields=["custom_answers"]) + + response = client_for(make_staff(space)).get(roster_url(event)) + + assert response.status_code == 200 + assert response["Cache-Control"] == "private, no-store" + assert set(response.data) == { + "lease_token", "lease_id", "server_time", "issued_at", "expires_at", + "scan_opens_at", "scan_closes_at", "sync_deadline", "event", + "registrations", + } + assert set(response.data["event"]) == {"id", "title", "starts_at", "ends_at"} + assert response.data["registrations"] == [ + { + "registration_id": registration.pk, + "checkin_token": str(registration.checkin_token), + "name": member.display_name, + "host_waiver_state": "not_required", + } + ] + rendered = str(response.data) + assert member.email not in rendered + assert member.phone not in rendered + assert "private" not in rendered + assert response.data["expires_at"] <= response.data["sync_deadline"] + + +def test_sync_records_reported_and_server_times_without_attendee_pii_in_audit(): + space = make_space() + enable_offline(space) + event = make_event(space) + member = make_member(space) + registration = register(event, member) + staff = make_staff(space) + client = client_for(staff) + roster = client.get(roster_url(event)).data + operation_id = uuid4() + occurred_at = timezone.now() - timedelta(minutes=8) + + response = client.post( + sync_url(event), + { + "lease_token": roster["lease_token"], + "operations": [{ + "operation_id": str(operation_id), + "checkin_token": str(registration.checkin_token), + "reported_occurred_at": occurred_at.isoformat(), + }], + }, + format="json", + ) + + assert response.status_code == 200 + assert response.data["results"][0]["outcome"] == "applied" + history = EventCheckInEvent.objects.get(operation_id=operation_id) + assert history.source == EventCheckInEvent.Source.OFFLINE_SYNC + assert history.actor == staff + assert history.attended_at == occurred_at + assert history.recorded_at > history.attended_at + audit = AuditLog.objects.get( + action="event.registration_attended", target_id=str(registration.pk) + ) + assert audit.meta["reported_occurred_at"] == occurred_at.isoformat() + assert audit.meta["recorded_at"] == history.recorded_at.isoformat() + assert member.email not in str(audit.meta) + assert member.phone not in str(audit.meta) + + +def test_sync_is_idempotent_and_rejects_stale_registration_and_event_state(): + space = make_space() + enable_offline(space) + staff = make_staff(space) + client = client_for(staff) + cancelled_event = make_event(space, title="Cancelled event") + cancelled_event_row = register( + cancelled_event, make_member(space, "cancelled-event-member") + ) + cancelled_event_roster = client.get(roster_url(cancelled_event)).data + services.cancel(cancelled_event, actor=staff) + + def operation(row, value): + return { + "operation_id": str(value), + "checkin_token": str(row.checkin_token), + "reported_occurred_at": timezone.now().isoformat(), + } + + cancelled_response = client.post( + sync_url(cancelled_event), + { + "lease_token": cancelled_event_roster["lease_token"], + "operations": [operation(cancelled_event_row, uuid4())], + }, + format="json", + ) + assert cancelled_response.data["results"][0]["outcome"] == "event_unavailable" + + event = make_event(space, title="Active event") + rows = [register(event, make_member(space, f"member-{i}")) for i in range(2)] + roster = client.get(roster_url(event)).data + operation_id = uuid4() + services.cancel_registration(rows[1], actor=staff) + stale = client.post( + sync_url(event), + {"lease_token": roster["lease_token"], "operations": [operation(rows[1], uuid4())]}, + format="json", + ) + assert stale.data["results"][0]["outcome"] == "registration_changed" + + payload = {"lease_token": roster["lease_token"], "operations": [operation(rows[0], operation_id)]} + first = client.post(sync_url(event), payload, format="json") + second = client.post(sync_url(event), payload, format="json") + assert first.data["results"][0]["outcome"] == "applied" + assert second.data["results"][0]["outcome"] == "duplicate_operation" + assert EventCheckInEvent.objects.filter(operation_id=operation_id).count() == 1 + + +def test_staff_from_another_makerspace_cannot_download_the_roster(): + host, other = make_space("offline-host"), make_space("offline-other") + enable_offline(host) + event = make_event(host) + + response = client_for(make_staff(other)).get(roster_url(event)) + + assert response.status_code in (403, 404) diff --git a/backend/tests/events/test_event_checkin_station.py b/backend/tests/events/test_event_checkin_station.py new file mode 100644 index 00000000..6061a7e7 --- /dev/null +++ b/backend/tests/events/test_event_checkin_station.py @@ -0,0 +1,287 @@ +from datetime import timedelta +from uuid import uuid4 + +import pytest +from cryptography.fernet import Fernet +from django.core.cache import cache +from django.urls import reverse +from django.utils import timezone +from rest_framework.exceptions import PermissionDenied +from rest_framework.test import APIClient + +from apps.accounts.models import User +from apps.audit.models import AuditLog +from apps.events.checkin_tokens import read_lease +from apps.events.models import EventCheckInEvent, EventCheckInStationCredential +from apps.events.services_checkin_roster import issue_roster +from apps.events.services_checkin_sync import synchronize +from tests.events.checkin_helpers import ( + client_for, + make_event, + make_member, + make_space, + make_staff, + register, +) + +pytestmark = pytest.mark.django_db +ORIGIN = "http://localhost:5000" +STATION_HEADERS = {"HTTP_ORIGIN": ORIGIN, "HTTP_X_STATION_CSRF": "present"} + + +@pytest.fixture(autouse=True) +def station_settings(settings): + settings.API_CLIENT_ENC_KEY = Fernet.generate_key().decode("ascii") + settings.EVENT_STATION_PIN_PEPPER = "test-only-independent-station-pepper" + # The PIN exchange requires an exact allowed Origin, so the station origin has to be + # registered or every SUCCESS path fails the CSRF gate -- and the 403-expecting tests + # would still pass, for the wrong reason. + settings.CORS_ALLOWED_ORIGINS = [ORIGIN] + cache.clear() + yield + cache.clear() + + +def enable_offline(space): + space.enabled_features = [*space.enabled_features, "events.offline_checkin"] + space.save(update_fields=["enabled_features"]) + + +def rotate_url(event): + return reverse("admin-event-check-in-station-rotate", kwargs={"pk": event.pk}) + + +def station_url(name, public_token): + return reverse(name, kwargs={"public_token": public_token}) + + +def rotate_station(event, staff): + response = client_for(staff).post(rotate_url(event), {}, format="json") + assert response.status_code == 200 + assert response["Cache-Control"] == "private, no-store" + return response.data + + +def start_station(payload): + client = APIClient() + response = client.post( + station_url("event-check-in-station-session", payload["public_token"]), + {"pin": payload["pin"]}, + format="json", + **STATION_HEADERS, + ) + return client, response + + +def test_station_controls_are_feature_gated_on_both_sides(): + space = make_space() + event = make_event(space) + staff = make_staff(space) + + assert client_for(staff).post(rotate_url(event), {}, format="json").status_code == 400 + assert not EventCheckInStationCredential.objects.exists() + + enable_offline(space) + payload = rotate_station(event, staff) + assert payload["pin"].isdigit() and len(payload["pin"]) == 8 + space.enabled_features = [ + key for key in space.enabled_features if key != "events.offline_checkin" + ] + space.save(update_fields=["enabled_features"]) + _station, rejected = start_station(payload) + assert rejected.status_code == 403 + assert not EventCheckInEvent.objects.exists() + + +def test_pin_is_hashed_encrypted_rotatable_and_reveal_is_step_up_audited(): + space = make_space() + enable_offline(space) + event = make_event(space) + staff = make_staff(space) + staff.set_password("correct horse battery staple") + staff.save(update_fields=["password"]) + + first = rotate_station(event, staff) + credential = EventCheckInStationCredential.objects.get(event=event) + assert first["pin"] not in credential.pin_digest + assert first["pin"].encode() not in bytes(credential.pin_ciphertext) + + revealed = client_for(staff).post( + reverse("admin-event-check-in-station-reveal", kwargs={"pk": event.pk}), + {"current_password": "correct horse battery staple"}, + format="json", + ) + assert revealed.status_code == 200 + assert revealed["Cache-Control"] == "private, no-store" + assert revealed.data["pin"] == first["pin"] + + second = rotate_station(event, staff) + credential.refresh_from_db() + assert second["pin"] != first["pin"] + assert credential.version == 2 + assert first["pin"] not in str( + list(AuditLog.objects.filter(makerspace=space).values_list("meta", flat=True)) + ) + assert AuditLog.objects.filter(action="event.station_pin_revealed").exists() + + +def test_station_session_cookie_is_scoped_and_rotation_invalidates_it(settings): + settings.AUTH_COOKIE_SECURE = True + space = make_space() + enable_offline(space) + event = make_event(space) + staff = make_staff(space) + first = rotate_station(event, staff) + station, response = start_station(first) + + assert response.status_code == 204 + cookie = response.cookies["sw_event_station"] + assert cookie["httponly"] is True + assert cookie["secure"] is True + assert cookie["path"] == ( + f"/api/v1/event-checkin-stations/{first['public_token']}/" + ) + assert int(cookie["max-age"]) > 0 + + rotate_station(event, staff) + stale = station.get( + station_url("event-check-in-station-roster", first["public_token"]), + **STATION_HEADERS, + ) + assert stale.status_code == 403 + + +def test_rotation_is_rechecked_inside_the_attendance_transaction(): + space = make_space() + enable_offline(space) + event = make_event(space) + registration = register(event, make_member(space)) + staff = make_staff(space) + first = rotate_station(event, staff) + session_id = uuid4() + roster = issue_roster( + event, + actor=None, + kind="station", + session_id=session_id, + station_version=first["version"], + ) + rotate_station(event, staff) + + with pytest.raises(PermissionDenied): + synchronize( + event, + [{ + "operation_id": uuid4(), + "checkin_token": str(registration.checkin_token), + "reported_occurred_at": timezone.now(), + }], + lease=read_lease(roster["lease_token"]), + actor=None, + source=EventCheckInEvent.Source.VENUE_STATION, + session_id=session_id, + station_version=first["version"], + ) + assert not EventCheckInEvent.objects.filter(registration=registration).exists() + + +def test_uniform_public_failure_does_not_enumerate_station_state(): + space = make_space() + enable_offline(space) + staff = make_staff(space) + open_event = make_event(space) + disabled_event = make_event(space, title="Disabled") + closed_event = make_event( + space, + title="Future", + starts_at=timezone.now() + timedelta(days=4), + ) + valid = rotate_station(open_event, staff) + disabled = rotate_station(disabled_event, staff) + closed = rotate_station(closed_event, staff) + client_for(staff).delete( + reverse("admin-event-check-in-station", kwargs={"pk": disabled_event.pk}) + ) + + attempts = [ + (valid["public_token"], "00000000"), + (disabled["public_token"], disabled["pin"]), + (closed["public_token"], closed["pin"]), + (uuid4(), "00000000"), + ] + responses = [] + for token, pin in attempts: + responses.append( + APIClient().post( + station_url("event-check-in-station-session", token), + {"pin": pin}, + format="json", + **STATION_HEADERS, + ) + ) + + assert {response.status_code for response in responses} == {403} + assert len({str(response.data) for response in responses}) == 1 + assert AuditLog.objects.filter( + action="event.station_pin_failed", target_id=str(open_event.pk) + ).exists() + + +def test_pin_exchange_requires_csrf_header_and_an_exact_allowed_origin(): + space = make_space() + enable_offline(space) + event = make_event(space) + payload = rotate_station(event, make_staff(space)) + url = station_url("event-check-in-station-session", payload["public_token"]) + + missing_header = APIClient().post( + url, {"pin": payload["pin"]}, format="json", HTTP_ORIGIN=ORIGIN + ) + wrong_origin = APIClient().post( + url, + {"pin": payload["pin"]}, + format="json", + HTTP_ORIGIN="https://localhost.attacker.example", + HTTP_X_STATION_CSRF="present", + ) + + assert missing_header.status_code == wrong_origin.status_code == 403 + assert missing_header.data == wrong_origin.data + + +def test_anonymous_station_roster_and_sync_create_no_user(): + space = make_space() + enable_offline(space) + event = make_event(space) + registration = register(event, make_member(space)) + payload = rotate_station(event, make_staff(space)) + user_count = User.objects.count() + station, response = start_station(payload) + assert response.status_code == 204 + + roster = station.get( + station_url("event-check-in-station-roster", payload["public_token"]), + **STATION_HEADERS, + ) + assert roster.status_code == 200 + sync = station.post( + station_url("event-check-in-station-sync", payload["public_token"]), + { + "lease_token": roster.data["lease_token"], + "operations": [{ + "operation_id": str(uuid4()), + "checkin_token": str(registration.checkin_token), + "reported_occurred_at": timezone.now().isoformat(), + }], + }, + format="json", + **STATION_HEADERS, + ) + + assert sync.status_code == 200 + assert sync.data["results"][0]["outcome"] == "applied" + history = EventCheckInEvent.objects.get(registration=registration) + assert history.source == EventCheckInEvent.Source.VENUE_STATION + assert history.actor_id is None + assert history.station_version == payload["version"] + assert User.objects.count() == user_count diff --git a/backend/tests/events/test_event_collaboration_p14.py b/backend/tests/events/test_event_collaboration_p14.py index f8d3fb29..8a0a4ee3 100644 --- a/backend/tests/events/test_event_collaboration_p14.py +++ b/backend/tests/events/test_event_collaboration_p14.py @@ -12,8 +12,11 @@ asserted here directly. """ +from datetime import timedelta + import pytest from django.urls import resolve as resolve_url, reverse +from django.utils import timezone from rest_framework.test import APIRequestFactory from apps.events.models import EventCollaborator, EventRegistration @@ -138,6 +141,29 @@ def test_capacity_still_applies_to_a_collaborative_registration(): } +def test_collaborative_registration_obeys_cutoff_and_approval_policy(): + host, partner = make_space("policy-host"), make_space("policy-partner") + event = make_event(host, is_public=False) + event.registration_requires_approval = True + event.registration_cutoff_at = timezone.now() - timedelta(seconds=1) + event.save(update_fields=[ + "registration_requires_approval", "registration_cutoff_at", + ]) + collaborate(event, partner) + member = make_member(partner, "policy-visitor") + client = client_for(member) + + closed = client.post(register_url(partner, event), {}, format="json") + assert closed.status_code == 409 + assert closed.data["code"] == "registration_closed" + + event.registration_cutoff_at = None + event.save(update_fields=["registration_cutoff_at"]) + pending = client.post(register_url(partner, event), {}, format="json") + assert pending.status_code == 201 + assert pending.data["status"] == EventRegistration.Status.PENDING_APPROVAL + + # --- discovery ---------------------------------------------------------------------- diff --git a/backend/tests/events/test_event_organizer_management.py b/backend/tests/events/test_event_organizer_management.py new file mode 100644 index 00000000..e35478d2 --- /dev/null +++ b/backend/tests/events/test_event_organizer_management.py @@ -0,0 +1,168 @@ +from concurrent.futures import ThreadPoolExecutor +from datetime import timedelta +from threading import Barrier + +import pytest +from django.db import close_old_connections +from django.urls import reverse +from django.utils import timezone +from rest_framework.test import APIClient + +from apps.accounts import rbac +from apps.accounts.models import User +from apps.audit.models import AuditLog +from apps.events.models import Event, EventOrganizer +from apps.events.services_organizers import replace_organizers +from apps.makerspaces.models import Makerspace, MakerspaceMembership, MakerspaceRole +from apps.organizations.models import Organization, OrganizationMembership + + +pytestmark = pytest.mark.django_db + + +def user(slug): + return User.objects.create_user( + username=slug, + email=f"{slug}@example.test", + access_status=User.AccessStatus.ACTIVE, + ) + + +def client(actor, **headers): + result = APIClient() + result.force_authenticate(actor) + result.defaults.update(headers) + return result + + +def setup_event(): + space = Makerspace.objects.create( + name="Organizer Venue", + slug="organizer-venue", + enabled_modules=["events"], + ) + actor = user("event-manager") + role = MakerspaceRole.objects.create( + makerspace=space, + name="Event manager", + slug="event-manager", + granted_actions=[rbac.Action.MANAGE_EVENTS], + ) + MakerspaceMembership.objects.create( + makerspace=space, + user=actor, + role=MakerspaceMembership.Role.CUSTOM, + assigned_role=role, + ) + start = timezone.now() + timedelta(days=1) + event = Event.objects.create( + makerspace=space, + title="Managed event", + starts_at=start, + ends_at=start + timedelta(hours=1), + ) + return space, event, actor + + +def test_replace_organizers_is_action_scoped_atomic_and_audited(): + space, event, actor = setup_event() + organization = Organization.objects.create(name="Event Guild", slug="event-guild") + OrganizationMembership.objects.create(organization=organization, user=actor) + url = reverse("admin-event-organizers", kwargs={"pk": event.pk}) + + response = client(actor).put( + url, {"organization_ids": [organization.pk]}, format="json" + ) + + assert response.status_code == 200 + assert response.data["organizers"] == [ + {"id": organization.pk, "slug": organization.slug, "name": organization.name} + ] + link = EventOrganizer.objects.get(event=event, organization=organization) + assert link.created_by == actor + audit = AuditLog.objects.get(action="event.organizers_updated") + assert audit.makerspace_id == space.pk + assert audit.meta["organization_ids"] == [organization.pk] + event.refresh_from_db() + assert event.makerspace_id == space.pk + + +def test_assignment_requires_active_membership_in_each_new_organization(): + _space, event, actor = setup_event() + organization = Organization.objects.create(name="Unrelated Org", slug="unrelated-org") + + response = client(actor).put( + reverse("admin-event-organizers", kwargs={"pk": event.pk}), + {"organization_ids": [organization.pk]}, + format="json", + ) + + assert response.status_code == 403 + assert not EventOrganizer.objects.filter(event=event).exists() + assert not AuditLog.objects.filter(action="event.organizers_updated").exists() + + +def test_module_off_refuses_mutation_but_retains_bridge(): + space, event, actor = setup_event() + organization = Organization.objects.create(name="Retained Org", slug="retained-org") + OrganizationMembership.objects.create(organization=organization, user=actor) + link = EventOrganizer.objects.create(event=event, organization=organization) + space.enabled_modules = [] + space.save(update_fields=["enabled_modules"]) + + response = client(actor).put( + reverse("admin-event-organizers", kwargs={"pk": event.pk}), + {"organization_ids": []}, + format="json", + ) + + assert response.status_code == 400 + assert EventOrganizer.objects.filter(pk=link.pk).exists() + + +def test_makerspace_custom_origin_cannot_call_global_organization_admin(): + space, _event, actor = setup_event() + organization = Organization.objects.create(name="Origin Org", slug="origin-org") + OrganizationMembership.objects.create(organization=organization, user=actor) + space.frontend_domain = "organizer-venue.example.test" + space.frontend_domain_status = Makerspace.DomainStatus.VERIFIED + space.save(update_fields=["frontend_domain", "frontend_domain_status"]) + + response = client(actor, HTTP_ORIGIN="https://organizer-venue.example.test").get( + reverse("admin-organization-list") + ) + + assert response.status_code == 403 + + +@pytest.mark.django_db(transaction=True) +def test_concurrent_replacements_leave_one_complete_organizer_set(): + _space, event, actor = setup_event() + organizations = [ + Organization.objects.create(name=f"Race Org {number}", slug=f"race-org-{number}") + for number in (1, 2) + ] + for organization in organizations: + OrganizationMembership.objects.create(organization=organization, user=actor) + gate = Barrier(2) + + def replace(organization_id): + close_old_connections() + gate.wait() + try: + replace_organizers( + Event.objects.get(pk=event.pk), + actor=User.objects.get(pk=actor.pk), + organization_ids=[organization_id], + ) + finally: + close_old_connections() + + with ThreadPoolExecutor(max_workers=2) as pool: + list(pool.map(replace, [organization.pk for organization in organizations])) + + final_ids = set( + EventOrganizer.objects.filter(event=event).values_list("organization_id", flat=True) + ) + assert final_ids in ({organizations[0].pk}, {organizations[1].pk}) + assert AuditLog.objects.filter(action="event.organizers_updated").count() == 2 diff --git a/backend/tests/events/test_event_organizers.py b/backend/tests/events/test_event_organizers.py index 2794a74e..96d7e130 100644 --- a/backend/tests/events/test_event_organizers.py +++ b/backend/tests/events/test_event_organizers.py @@ -90,6 +90,12 @@ def test_unlinked_organizer_can_manage_only_its_exact_event(): registration = EventRegistration.objects.create( event=event, name="Organizer guest", email="organizer@example.test", phone="1" ) + event.registration_requires_approval = True + event.save(update_fields=["registration_requires_approval"]) + pending = EventRegistration.objects.create( + event=event, name="Pending guest", email="pending-organizer@example.test", + phone="1", status=EventRegistration.Status.PENDING_APPROVAL, + ) other_registration = EventRegistration.objects.create( event=other, name="Other guest", email="other@example.test", phone="1" ) @@ -106,10 +112,16 @@ def test_unlinked_organizer_can_manage_only_its_exact_event(): {}, format="json", ) + approved = client_for(actor).post( + reverse("admin-event-registration-approve", kwargs={"pk": pending.pk}), + {}, + format="json", + ) assert loaded.status_code == 200 assert edited.status_code == 200 assert attended.status_code == 200 + assert approved.status_code == 200 assert client_for(actor).get(detail_url(other)).status_code == 404 assert client_for(actor).post( reverse( diff --git a/backend/tests/events/test_event_registration_presence_p14.py b/backend/tests/events/test_event_registration_presence_p14.py index 9f981222..b56d86bb 100644 --- a/backend/tests/events/test_event_registration_presence_p14.py +++ b/backend/tests/events/test_event_registration_presence_p14.py @@ -237,8 +237,6 @@ def test_both_guards_share_one_waiver_rule(guard): "apps.hardware_requests.direct_loan_workflow", "apps.hardware_requests.public_views", "apps.bookings.views_public", - "apps.machines.views_public_service", - "apps.machines.views_public_printer_service", ], ) def test_hardware_and_facility_surfaces_still_bind_the_presence_guard(module_path): @@ -246,8 +244,11 @@ def test_hardware_and_facility_surfaces_still_bind_the_presence_guard(module_pat Paired with `test_require_active_member_presence_still_requires_a_session`, which proves that object still demands a session, this is what stops the refactor from - silently relaxing self-checkout, direct handout, hardware requests, bookings or the - machine-service surfaces. + silently relaxing self-checkout, direct handout, hardware requests or bookings. + + **The two machine-service surfaces deliberately left this list** -- see + `test_machine_request_surfaces_bind_the_membership_aware_guard` below, which holds them + to the equivalent contract through the helper they now share. """ import importlib @@ -259,6 +260,75 @@ def test_hardware_and_facility_surfaces_still_bind_the_presence_guard(module_pat assert not hasattr(module, "require_active_member") +@pytest.mark.parametrize( + "module_path", + [ + "apps.machines.views_public_service", + "apps.machines.views_public_printer_service", + ], +) +def test_machine_request_surfaces_bind_the_membership_aware_guard(module_path): + """Machine-service and printer submissions are PROPOSALS staff act on. + + They are not the requester operating the machine, so they take the same identity + contract as the public borrow request rather than a hard presence requirement: + membership when that module is installed, an active account when it is not. Requiring + a MakerspaceMembership row unconditionally made both surfaces dead for every ordinary + account on the default `recommended` profile, which ships `machine_service` with + `membership` off. + + This is still a binding assertion -- it just binds the shared helper instead, and + `test_membership_aware_guard_still_demands_presence_when_membership_is_on` proves that + helper has not been relaxed. + """ + import importlib + + from apps.machines.views_public_service import require_public_machine_requester + + module = importlib.import_module(module_path) + + assert getattr(module, "require_public_machine_requester", None) is ( + require_public_machine_requester + ) + assert not hasattr(module, "require_active_member") + + +@pytest.mark.django_db +def test_membership_aware_guard_still_demands_presence_when_membership_is_on(monkeypatch): + """The helper must not become an account-only guard by accident. + + With `membership` installed it must delegate to the unmodified presence guard; with the + module absent it must fall back to the account guard and nothing weaker. + """ + from apps.machines import views_public_service + from apps.makerspaces.models import Makerspace + from apps.makerspaces.module_registry import core_module_keys + + calls = [] + monkeypatch.setattr( + views_public_service, "require_active_member_presence", + lambda user, space: calls.append("presence"), + ) + monkeypatch.setattr( + views_public_service, "require_active_account", + lambda user, space: calls.append("account"), + ) + + with_membership = Makerspace.objects.create( + name="guard-membership-on", slug="guard-membership-on", + enabled_modules=sorted(set(core_module_keys()) | {"membership"}), + ) + without_membership = Makerspace.objects.create( + name="guard-membership-off", slug="guard-membership-off", + enabled_modules=sorted(core_module_keys()), + ) + + views_public_service.require_public_machine_requester(None, with_membership) + views_public_service.require_public_machine_requester(None, without_membership) + + assert calls == ["presence", "account"] + + def test_event_registration_binds_the_membership_only_guard(): from apps.events import views_public diff --git a/backend/tests/events/test_event_series.py b/backend/tests/events/test_event_series.py new file mode 100644 index 00000000..1685022b --- /dev/null +++ b/backend/tests/events/test_event_series.py @@ -0,0 +1,288 @@ +from concurrent.futures import ThreadPoolExecutor +from datetime import date, datetime, time, timedelta, timezone as dt_timezone +from threading import Barrier +from types import SimpleNamespace + +import pytest +from django.urls import reverse +from django.db import close_old_connections +from django.utils import timezone +from rest_framework import serializers +from rest_framework.test import APIClient + +from apps.accounts.models import User +from apps.audit.models import AuditLog +from apps.events import services, services_series, services_series_lifecycle +from apps.events.models import Event, EventSeries +from apps.events.services_recurrence import occurrences, validate_series_recurrence +from apps.events.tasks import extend_published_series +from apps.makerspaces.models import ( + DEFAULT_ENABLED_MODULES, + Makerspace, + MakerspaceMembership, +) + +pytestmark = pytest.mark.django_db + + +def make_space(slug, *, events=True): + modules = set(DEFAULT_ENABLED_MODULES) + if events: + modules.add("events") + else: + modules.discard("events") + return Makerspace.objects.create( + name=slug, slug=slug, enabled_modules=sorted(modules) + ) + + +def make_manager(space, username="series-manager"): + actor = User.objects.create_user(username=username) + MakerspaceMembership.objects.create( + user=actor, + makerspace=space, + role=MakerspaceMembership.Role.SPACE_MANAGER, + ) + return actor + + +def recurrence_fixture(**overrides): + values = { + "recurrence_timezone": "America/New_York", + "dtstart_local_date": date(2026, 3, 1), + "dtstart_local_time": time(18), + "recurrence_rule": "FREQ=WEEKLY;COUNT=4", + "duration_minutes": 60, + "revision": 1, + } + values.update(overrides) + return SimpleNamespace(**values) + + +def create_daily_series(space, actor, *, title="Open studio"): + tomorrow = (timezone.now() + timedelta(days=1)).date() + series, created = services_series.create_series( + makerspace=space, + actor=actor, + title=title, + recurrence_timezone="UTC", + dtstart_local_date=tomorrow, + dtstart_local_time=time(10), + recurrence_rule="FREQ=DAILY", + duration_minutes=60, + ) + return series, created + + +def client_for(user): + client = APIClient() + client.force_authenticate(user) + return client + + +def test_weekly_occurrences_keep_local_wall_time_across_dst(): + expanded = occurrences( + recurrence_fixture(), + now=datetime(2026, 2, 28, tzinfo=dt_timezone.utc), + ) + + assert [row.local_start.hour for row in expanded] == [18, 18, 18, 18] + assert [row.starts_at.hour for row in expanded] == [23, 22, 22, 22] + + +def test_nonexistent_spring_forward_wall_time_is_skipped(): + expanded = occurrences( + recurrence_fixture( + dtstart_local_time=time(2, 30), + recurrence_rule="FREQ=WEEKLY;COUNT=3", + ), + now=datetime(2026, 2, 28, tzinfo=dt_timezone.utc), + ) + + assert [row.local_start.day for row in expanded] == [1, 15] + + +def test_autumn_dst_and_half_hour_zone_keep_the_wall_clock_anchor(): + autumn = occurrences( + recurrence_fixture( + dtstart_local_date=date(2026, 10, 25), + recurrence_rule="FREQ=WEEKLY;COUNT=4", + ), + now=datetime(2026, 10, 24, tzinfo=dt_timezone.utc), + ) + india = occurrences( + recurrence_fixture( + recurrence_timezone="Asia/Kolkata", + dtstart_local_time=time(18, 30), + ), + now=datetime(2026, 2, 28, tzinfo=dt_timezone.utc), + ) + + assert [row.local_start.hour for row in autumn] == [18, 18, 18, 18] + assert [row.starts_at.hour for row in autumn] == [22, 23, 23, 23] + assert {(row.local_start.hour, row.local_start.minute) for row in india} == {(18, 30)} + + +def test_recurrence_too_dense_for_hourly_extension_is_rejected(): + with pytest.raises(serializers.ValidationError) as caught: + validate_series_recurrence( + recurrence_fixture(recurrence_rule="FREQ=MINUTELY") + ) + + assert caught.value.get_codes() == {"recurrence_rule": "recurrence_too_dense"} + + +def test_manual_extension_refills_the_bounded_window_idempotently(monkeypatch): + space = make_space("series-extension") + actor = make_manager(space) + series, initial = create_daily_series(space, actor) + assert len(initial) == 48 + + advanced = timezone.now() + timedelta(days=30) + monkeypatch.setattr(services_series.timezone, "now", lambda: advanced) + _series, added = services_series.extend_series(series, actor=actor) + _series, repeated = services_series.extend_series(series, actor=actor) + + assert len(added) > 0 + assert repeated == [] + assert Event.objects.filter(series=series).count() == len(initial) + len(added) + assert AuditLog.objects.filter(action="event.series_extended").count() == 2 + + +def test_occurrence_override_survives_template_update_and_can_be_reset(): + space = make_space("series-overrides") + actor = make_manager(space) + series, created = create_daily_series(space, actor) + occurrence = created[0] + + services.update_event(occurrence, actor=actor, title="Special session") + services_series.update_series( + series, actor=actor, title="New default", description="Shared description" + ) + occurrence.refresh_from_db() + assert occurrence.title == "Special session" + assert occurrence.description == "Shared description" + assert occurrence.series_override_fields == ["title"] + + services.update_event(occurrence, actor=actor, inherit_fields=["title"]) + occurrence.refresh_from_db() + assert occurrence.title == "New default" + assert occurrence.series_override_fields == [] + + +def test_series_create_api_has_positive_and_module_off_sides(): + enabled = make_space("series-api-on") + disabled = make_space("series-api-off", events=False) + actor = make_manager(enabled, "series-api-manager") + MakerspaceMembership.objects.create( + user=actor, + makerspace=disabled, + role=MakerspaceMembership.Role.SPACE_MANAGER, + ) + tomorrow = (timezone.now() + timedelta(days=1)).date().isoformat() + payload = { + "title": "Weekly class", + "recurrence_timezone": "Asia/Kolkata", + "dtstart_local_date": tomorrow, + "dtstart_local_time": "18:30:00", + "recurrence_rule": "FREQ=WEEKLY", + "duration_minutes": 90, + } + + created = client_for(actor).post( + reverse("admin-event-series-list-create", kwargs={"makerspace_id": enabled.pk}), + payload, + format="json", + ) + blocked = client_for(actor).post( + reverse("admin-event-series-list-create", kwargs={"makerspace_id": disabled.pk}), + payload, + format="json", + ) + invalid = client_for(actor).post( + reverse("admin-event-series-list-create", kwargs={"makerspace_id": enabled.pk}), + {**payload, "recurrence_timezone": "Mars/Olympus_Mons"}, + format="json", + ) + + assert created.status_code == 201 + assert created.data["affected_count"] == 48 + assert blocked.status_code == 400 + assert invalid.status_code == 400 + assert EventSeries.objects.filter(makerspace=enabled).count() == 1 + assert not EventSeries.objects.filter(makerspace=disabled).exists() + + +def test_scheduled_extension_runs_for_enabled_series_and_skips_module_off(monkeypatch): + enabled, disabled = make_space("series-task-on"), make_space("series-task-off") + actor = make_manager(enabled, "series-task-manager") + enabled_series, enabled_initial = create_daily_series(enabled, actor, title="Enabled") + disabled_series, disabled_initial = create_daily_series(disabled, actor, title="Disabled") + EventSeries.objects.filter(pk__in=[enabled_series.pk, disabled_series.pk]).update( + status=EventSeries.Status.PUBLISHED + ) + disabled.enabled_modules = [key for key in disabled.enabled_modules if key != "events"] + disabled.save(update_fields=["enabled_modules"]) + advanced = timezone.now() + timedelta(days=30) + monkeypatch.setattr("apps.events.tasks.timezone.now", lambda: advanced) + + extend_published_series() + + assert Event.objects.filter(series=enabled_series).count() > len(enabled_initial) + assert Event.objects.filter(series=disabled_series).count() == len(disabled_initial) + + +def test_series_cancellation_suppresses_occurrence_notification_fanout(monkeypatch): + space = make_space("series-notifications") + actor = make_manager(space) + series, created = create_daily_series(space, actor) + series.status = EventSeries.Status.PUBLISHED + series.save(update_fields=["status"]) + Event.objects.filter(pk__in=[row.pk for row in created]).update( + status=Event.Status.PUBLISHED + ) + event_notifications = [] + series_notifications = [] + monkeypatch.setattr( + services, "notify_event_lifecycle", lambda *args: event_notifications.append(args) + ) + monkeypatch.setattr( + services_series_lifecycle, + "notify_series_lifecycle", + lambda *args: series_notifications.append(args), + ) + + services_series.cancel_series(series, actor=actor) + + assert event_notifications == [] + assert len(series_notifications) == 1 + + +@pytest.mark.django_db(transaction=True) +def test_concurrent_extensions_materialize_each_occurrence_once(monkeypatch): + space = make_space("series-concurrent-extension") + actor = make_manager(space, "series-concurrent-manager") + series, initial = create_daily_series(space, actor) + advanced = timezone.now() + timedelta(days=30) + monkeypatch.setattr(services_series.timezone, "now", lambda: advanced) + barrier = Barrier(2) + + def extend(): + close_old_connections() + try: + barrier.wait() + _series, created = services_series.extend_series( + EventSeries.objects.get(pk=series.pk), + actor=User.objects.get(pk=actor.pk), + ) + return len(created) + finally: + close_old_connections() + + with ThreadPoolExecutor(max_workers=2) as pool: + counts = list(pool.map(lambda _index: extend(), range(2))) + + keys = Event.objects.filter(series=series).values_list("series_occurrence_key", flat=True) + assert min(counts) == 0 < max(counts) + assert len(keys) == len(set(keys)) + assert len(keys) == len(initial) + sum(counts) diff --git a/backend/tests/events/test_event_series_collaboration.py b/backend/tests/events/test_event_series_collaboration.py new file mode 100644 index 00000000..213ed2dc --- /dev/null +++ b/backend/tests/events/test_event_series_collaboration.py @@ -0,0 +1,160 @@ +from datetime import time, timedelta + +import pytest +from django.urls import reverse +from django.utils import timezone +from rest_framework.test import APIClient + +from apps.accounts.models import User +from apps.events import services_series, services_series_collaboration +from apps.events.models import ( + EventCollaborator, + EventRegistration, + EventSeries, + EventSeriesCollaborator, +) +from apps.makerspaces.models import ( + DEFAULT_ENABLED_MODULES, + Makerspace, + MakerspaceMembership, +) + +pytestmark = pytest.mark.django_db + + +def make_space(slug): + return Makerspace.objects.create( + name=slug, + slug=slug, + enabled_modules=sorted({*DEFAULT_ENABLED_MODULES, "events"}), + ) + + +def make_manager(space, username): + actor = User.objects.create_user(username=username) + MakerspaceMembership.objects.create( + user=actor, + makerspace=space, + role=MakerspaceMembership.Role.SPACE_MANAGER, + ) + return actor + + +def client_for(user): + client = APIClient() + client.force_authenticate(user) + return client + + +def make_series(space, actor): + return services_series.create_series( + makerspace=space, + actor=actor, + title="Partner workshop", + recurrence_timezone="UTC", + dtstart_local_date=(timezone.now() + timedelta(days=1)).date(), + dtstart_local_time=time(10), + recurrence_rule="FREQ=DAILY", + duration_minutes=60, + ) + + +def test_acceptance_projects_once_to_current_and_future_occurrences(monkeypatch): + host, visitor = make_space("series-host"), make_space("series-visitor") + host_manager = make_manager(host, "series-host-manager") + visitor_manager = make_manager(visitor, "series-visitor-manager") + series, initial = make_series(host, host_manager) + rows = services_series_collaboration.invite_collaborators( + series, actor=host_manager, slugs=[visitor.slug, visitor.slug] + ) + invitation = rows.get() + + inbox = client_for(visitor_manager).get( + reverse( + "admin-event-series-collaboration-inbox", + kwargs={"makerspace_id": visitor.pk}, + ) + ) + services_series_collaboration.respond( + invitation, actor=visitor_manager, accept=True + ) + + assert inbox.status_code == 200 + assert [row["id"] for row in inbox.data] == [invitation.pk] + assert EventCollaborator.objects.filter( + source_series_collaboration=invitation, + status=EventCollaborator.Status.ACCEPTED, + ).count() == len(initial) + + advanced = timezone.now() + timedelta(days=30) + monkeypatch.setattr(services_series.timezone, "now", lambda: advanced) + _series, added = services_series.extend_series(series, actor=host_manager) + assert added + assert EventCollaborator.objects.filter( + source_series_collaboration=invitation + ).count() == len(initial) + len(added) + + +def test_removal_drops_projections_without_erasing_registration_history(): + host, visitor = make_space("series-remove-host"), make_space("series-remove-visitor") + host_manager = make_manager(host, "series-remove-host-manager") + visitor_manager = make_manager(visitor, "series-remove-visitor-manager") + series, occurrences = make_series(host, host_manager) + invitation = services_series_collaboration.invite_collaborators( + series, actor=host_manager, slugs=[visitor.slug] + ).get() + services_series_collaboration.respond( + invitation, actor=visitor_manager, accept=True + ) + registration = EventRegistration.objects.create( + event=occurrences[0], + name="Visitor", + email="series-visitor@example.test", + phone="123", + registered_via_makerspace=visitor, + ) + + services_series_collaboration.remove_collaborator( + invitation.pk, actor=host_manager + ) + + assert not EventCollaborator.objects.filter( + source_series_collaboration_id=invitation.pk + ).exists() + assert EventRegistration.objects.filter(pk=registration.pk).exists() + + +def test_projected_occurrence_collaborators_must_be_managed_on_series(): + host, visitor = make_space("series-projected-host"), make_space("series-projected-visitor") + host_manager = make_manager(host, "series-projected-host-manager") + visitor_manager = make_manager(visitor, "series-projected-visitor-manager") + series, occurrences = make_series(host, host_manager) + invitation = services_series_collaboration.invite_collaborators( + series, actor=host_manager, slugs=[visitor.slug] + ).get() + services_series_collaboration.respond( + invitation, actor=visitor_manager, accept=True + ) + + response = client_for(host_manager).put( + reverse("admin-event-collaborators", kwargs={"pk": occurrences[0].pk}), + {"slugs": []}, + format="json", + ) + + assert response.status_code == 409 + assert response.data["code"] == "use_series_collaborators" + + +def test_series_detail_does_not_leak_across_tenants(): + host, outsider = make_space("series-private-host"), make_space("series-outsider") + host_manager = make_manager(host, "series-private-host-manager") + outsider_manager = make_manager(outsider, "series-outsider-manager") + series, _occurrences = make_series(host, host_manager) + + response = client_for(outsider_manager).get( + reverse("admin-event-series-detail", kwargs={"pk": series.pk}) + ) + + assert response.status_code == 404 + assert EventSeriesCollaborator.objects.count() == 0 diff --git a/backend/tests/events/test_models.py b/backend/tests/events/test_models.py index dff5160e..56b7187b 100644 --- a/backend/tests/events/test_models.py +++ b/backend/tests/events/test_models.py @@ -49,6 +49,9 @@ def test_event_defaults_are_draft_private_and_unlimited(): assert event.capacity == 0 assert event.location_kind == Event.LocationKind.OTHER assert event.custom_form is None + assert event.registration_requires_approval is False + assert event.registration_cutoff_at is None + assert event.registration_cutoff_lead_minutes is None def test_registration_custom_answers_default_to_null(): diff --git a/backend/tests/events/test_notifications.py b/backend/tests/events/test_notifications.py index 30dec5f9..27c4fff9 100644 --- a/backend/tests/events/test_notifications.py +++ b/backend/tests/events/test_notifications.py @@ -4,6 +4,7 @@ from apps.events import notifications, services from apps.events.models import Event, EventRegistration from apps.integrations.models import NotificationPreference +from apps.integrations.notification_catalog import FEATURE_EVENTS from tests.events.test_services import ( make_actor, make_event, @@ -82,3 +83,35 @@ def test_event_notifications_are_silent_until_email_cell_enabled(monkeypatch): ) delivered = notifications.notify_event_lifecycle(event, "published", sync=True) assert delivered.delivered_counts == {"email": 1} + + +def test_approval_lifecycle_uses_catalogued_notification_events(monkeypatch): + calls = [] + monkeypatch.setattr( + services, + "notify_event_lifecycle", + lambda event, name, registration_id=None: calls.append(name), + ) + event = make_event( + make_space("event-approval-fanout"), + capacity=1, + registration_requires_approval=True, + ) + first = services.register( + event, name="First", email="approval-first@example.test", phone="1" + ) + second = services.register( + event, name="Second", email="approval-second@example.test", phone="1" + ) + services.approve_registration(first, actor=None) + second = services.approve_registration(second, actor=None) + services.reject_registration(second, actor=None) + + assert calls == [ + "registration_pending_approval", + "registration_pending_approval", + "registration_approved", + "registration_approved", + "registration_rejected", + ] + assert set(calls) <= set(FEATURE_EVENTS["events"]) diff --git a/backend/tests/events/test_postevent_api.py b/backend/tests/events/test_postevent_api.py new file mode 100644 index 00000000..9938b6da --- /dev/null +++ b/backend/tests/events/test_postevent_api.py @@ -0,0 +1,107 @@ +from datetime import timedelta + +import pytest +from django.urls import reverse +from django.utils import timezone + +from apps.events.models import Event, EventFeedbackResponse, EventRegistration +from apps.events.services_feedback import configure_survey, open_survey +from apps.makerspaces.models import Makerspace +from tests.member_submission import active_member_client + + +pytestmark = pytest.mark.django_db + +QUESTIONS = [{ + "id": "comment", + "label": "Comment", + "type": "paragraph", + "options": [], + "required": True, +}] + + +def setup_event(slug="feedback-api", *, public=True, certificate=False): + space = Makerspace.objects.create(name=slug, slug=slug) + event = Event.objects.create( + makerspace=space, + title="Ended event", + starts_at=timezone.now() - timedelta(hours=2), + ends_at=timezone.now() - timedelta(hours=1), + status=Event.Status.PUBLISHED, + is_public=public, + ) + configure_survey( + event, + actor=None, + title="Feedback", + questions=QUESTIONS, + certificate_enabled=certificate, + ) + open_survey(event, actor=None) + return space, event + + +def public_url(space, event): + return reverse( + "public-event-feedback", + kwargs={"makerspace_slug": space.slug, "public_token": event.public_token}, + ) + + +def test_public_anonymous_feedback_get_and_post_when_events_module_is_on(): + space, event = setup_event() + _member, client = active_member_client(space, "feedback-api-member") + + form = client.get(public_url(space, event)) + submitted = client.post( + public_url(space, event), + {"answers": {"comment": " Useful "}}, + format="json", + ) + + assert form.status_code == 200 + assert form.data["mode"] == "anonymous" + assert submitted.status_code == 201 + assert submitted.data["certificate"] is None + assert EventFeedbackResponse.objects.get().registration_id is None + + +def test_public_feedback_is_withdrawn_when_events_module_is_off(): + space, event = setup_event("feedback-module-off") + _member, client = active_member_client(space, "feedback-off-member") + space.enabled_modules.remove("events") + space.save(update_fields=["enabled_modules"]) + + response = client.get(public_url(space, event)) + + assert response.status_code == 400 + + +def test_private_event_does_not_disclose_feedback_by_public_token(): + space, event = setup_event("private-feedback", public=False) + _member, client = active_member_client(space, "private-feedback-member") + + assert client.get(public_url(space, event)).status_code == 404 + + +def test_public_certificate_endpoint_rejects_registered_no_show(): + space, event = setup_event("api-no-show", certificate=True) + member, client = active_member_client(space, "api-no-show-member") + EventRegistration.objects.create( + event=event, + member=member, + name=member.display_name, + email=member.email, + phone=member.phone, + status=EventRegistration.Status.REGISTERED, + ) + + response = client.post( + public_url(space, event), + {"email": member.email, "answers": {"comment": "Good"}}, + format="json", + ) + + assert response.status_code == 404 + assert response.data["code"] == "feedback_not_found" diff --git a/backend/tests/events/test_postevent_feedback.py b/backend/tests/events/test_postevent_feedback.py new file mode 100644 index 00000000..cfe230a0 --- /dev/null +++ b/backend/tests/events/test_postevent_feedback.py @@ -0,0 +1,251 @@ +from datetime import timedelta + +import pytest +from django.db import connection +from django.utils import timezone + +from apps.accounts.models import User +from apps.audit.models import AuditLog +from apps.encryption.crypto import is_envelope +from apps.events import services +from apps.events.exceptions import EventInvalidTransition, FeedbackConflict, FeedbackIneligible +from apps.events.feedback_validation import validate_feedback_schema +from apps.events.models import ( + Event, + EventAttendanceCertificate, + EventCheckInEvent, + EventFeedbackResponse, + EventRegistration, +) +from apps.events.services_certificates import create_pending +from apps.events.services_certificates import download_url +from apps.events.services_feedback import ( + configure_survey, + open_survey, + submit_anonymous_feedback, + submit_identified_feedback, +) +from apps.makerspaces.models import Makerspace +from tests.encryption.conftest import enabled_encryption +from tests.member_submission import active_member_client + + +pytestmark = pytest.mark.django_db + +QUESTION = { + "id": "rating", + "label": "Rating", + "type": "number", + "options": [], + "required": True, +} + + +def ended_event(slug="postevent", **values): + space = Makerspace.objects.create(name=slug, slug=slug) + defaults = { + "makerspace": space, + "title": "Safety workshop", + "starts_at": timezone.now() - timedelta(hours=2), + "ends_at": timezone.now() - timedelta(hours=1), + "status": Event.Status.PUBLISHED, + "is_public": True, + } + defaults.update(values) + return Event.objects.create(**defaults) + + +def opened_survey(event, *, certificate=False): + survey = configure_survey( + event, + actor=None, + title="How was it?", + thank_you_text="Thank you", + questions=[QUESTION], + certificate_enabled=certificate, + ) + return open_survey(event, actor=None) + + +def registration(event, member, status): + return EventRegistration.objects.create( + event=event, + member=member, + name=member.display_name, + email=member.email, + phone=member.phone, + status=status, + ) + + +def test_feedback_schema_uses_all_seven_canonical_question_types(): + types = ( + "short_text", "paragraph", "dropdown", "multi_choice", + "single_choice", "yes_no", "number", + ) + schema = [ + { + "id": f"q{index}", + "label": value, + "type": value, + "options": ["A"] if value in {"dropdown", "multi_choice", "single_choice"} else [], + "required": False, + } + for index, value in enumerate(types) + ] + assert [item["type"] for item in validate_feedback_schema(schema)] == list(types) + + +def test_anonymous_feedback_is_repeatable_encrypted_and_unidentifiable_in_audit(): + event = ended_event() + opened_survey(event) + actor, _client = active_member_client(event.makerspace, "anonymous-browser") + + with enabled_encryption(): + first, certificate = submit_anonymous_feedback(event, {"rating": 5}) + second, _ = submit_anonymous_feedback(event, {"rating": 4}) + with connection.cursor() as cursor: + cursor.execute( + "SELECT answers_snapshot FROM events_eventfeedbackresponse WHERE id = %s", + [first.pk], + ) + raw = cursor.fetchone()[0] + + assert certificate is None + assert first.pk != second.pk + assert first.registration_id is None + assert is_envelope(raw) + audits = AuditLog.objects.filter(action="event.feedback_submitted") + assert audits.count() == 2 + assert all(row.actor_id is None and row.meta == {"mode": "anonymous"} for row in audits) + assert actor.pk not in [row.actor_id for row in audits] + + +@pytest.mark.parametrize( + "registration_status", + [ + EventRegistration.Status.PENDING_APPROVAL, + EventRegistration.Status.REGISTERED, + EventRegistration.Status.WAITLISTED, + EventRegistration.Status.REJECTED, + EventRegistration.Status.CANCELLED, + ], +) +def test_certificate_feedback_rejects_every_non_attended_status(registration_status): + event = ended_event(f"no-show-{registration_status}") + opened_survey(event, certificate=True) + member, _client = active_member_client(event.makerspace, f"member-{registration_status}") + row = registration(event, member, registration_status) + + with pytest.raises(FeedbackIneligible): + submit_identified_feedback( + event, + actor=member, + registration=row, + email=member.email, + answers={"rating": 5}, + ) + + assert not EventAttendanceCertificate.objects.exists() + + +def test_only_attended_registration_gets_certificate_and_exact_retry_is_idempotent(): + event = ended_event("attended-certificate") + opened_survey(event, certificate=True) + member, _client = active_member_client(event.makerspace, "attended-member") + row = registration(event, member, EventRegistration.Status.ATTENDED) + + response, certificate = submit_identified_feedback( + event, actor=member, registration=row, email=member.email, + answers={"rating": 5}, + ) + retried_response, retried_certificate = submit_identified_feedback( + event, actor=member, registration=row, email=member.email, + answers={"rating": 5}, + ) + + assert response.pk == retried_response.pk + assert certificate.pk == retried_certificate.pk + assert certificate.status == EventAttendanceCertificate.Status.PENDING + with pytest.raises(FeedbackConflict): + submit_identified_feedback( + event, actor=member, registration=row, email=member.email, + answers={"rating": 3}, + ) + + +def test_attended_visitor_uses_durable_registration_makerspace_membership(): + event = ended_event("visitor-certificate") + opened_survey(event, certificate=True) + source = Makerspace.objects.create(name="Visitor source", slug="visitor-source") + member, _client = active_member_client(source, "visiting-attendee") + row = registration(event, member, EventRegistration.Status.ATTENDED) + row.registered_via_makerspace = source + row.save(update_fields=["registered_via_makerspace"]) + + response, certificate = submit_identified_feedback( + event, + actor=member, + registration=row, + email=member.email, + answers={"rating": 5}, + ) + + assert response.registration_id == row.pk + assert certificate.registration_id == row.pk + + +def test_mark_attended_writes_history_and_correction_revokes_certificate(): + event = ended_event("attendance-history") + opened_survey(event, certificate=True) + member, _client = active_member_client(event.makerspace, "history-member") + actor = User.objects.create_user(username="attendance-staff") + row = registration(event, member, EventRegistration.Status.REGISTERED) + + attended = services.mark_attended(row, actor=actor) + response = EventFeedbackResponse.objects.create( + survey=event.feedback_survey, + registration=attended, + answers_snapshot='{"version":1,"answers":[]}', + certificate_requested=True, + ) + certificate = create_pending(response) + certificate.status = EventAttendanceCertificate.Status.RENDERING + certificate.save(update_fields=["status"]) + certificate.status = EventAttendanceCertificate.Status.ACTIVE + certificate.size_bytes = 10 + certificate.sha256 = "a" * 64 + certificate.rendered_at = timezone.now() + certificate.save(update_fields=["status", "size_bytes", "sha256", "rendered_at"]) + + corrected, revoked = services.correct_attendance(attended, actor=actor) + + history = EventCheckInEvent.objects.get(registration=row) + assert history.source == EventCheckInEvent.Source.ONLINE + assert history.attended_at <= timezone.now() + assert corrected.status == EventRegistration.Status.REGISTERED + assert [item.pk for item in revoked] == [certificate.pk] + certificate.refresh_from_db() + assert certificate.status == EventAttendanceCertificate.Status.REVOKED + assert certificate.revocation_reason == "attendance_corrected" + + +def test_pending_certificate_cannot_render_after_attendance_is_corrected(): + event = ended_event("pending-attendance-correction") + opened_survey(event, certificate=True) + member, _client = active_member_client(event.makerspace, "pending-history-member") + actor = User.objects.create_user(username="pending-attendance-staff") + row = registration(event, member, EventRegistration.Status.ATTENDED) + response, certificate = submit_identified_feedback( + event, + actor=member, + registration=row, + email=member.email, + answers={"rating": 5}, + ) + services.correct_attendance(row, actor=actor) + + with pytest.raises(EventInvalidTransition, match="Attendance is required"): + download_url(certificate) + + assert response.registration_id == row.pk diff --git a/backend/tests/events/test_public_api.py b/backend/tests/events/test_public_api.py index e11dcc3b..02e05afa 100644 --- a/backend/tests/events/test_public_api.py +++ b/backend/tests/events/test_public_api.py @@ -358,7 +358,7 @@ def test_cancelled_email_reregisters_and_active_duplicate_matches_generic_respon assert 'guest@example.com' not in str(duplicate.data).lower() -def test_duplicate_on_full_event_matches_fresh_waitlisted_response(): +def test_duplicate_on_full_event_reports_the_existing_confirmed_status(): space = make_space() event = make_event(space, capacity=1) existing_user, existing_client = active_member_client( @@ -381,9 +381,8 @@ def test_duplicate_on_full_event_matches_fresh_waitlisted_response(): ) assert duplicate.status_code == fresh.status_code == 201 - assert duplicate.data == fresh.data == { - 'status': EventRegistration.Status.WAITLISTED, - } + assert fresh.data == {'status': EventRegistration.Status.WAITLISTED} + assert duplicate.data == {'status': EventRegistration.Status.REGISTERED} assert set(duplicate.data) == {'status'} assert 'guest@example.com' not in str(duplicate.data).lower() diff --git a/backend/tests/events/test_public_event_leaks.py b/backend/tests/events/test_public_event_leaks.py index caeca64f..137b555a 100644 --- a/backend/tests/events/test_public_event_leaks.py +++ b/backend/tests/events/test_public_event_leaks.py @@ -26,9 +26,17 @@ 'custom_form', 'capacity', 'availability', + 'registration_requires_approval', + 'effective_registration_cutoff_at', + 'registration_open', 'image_url', 'status', 'organizers', + # Recurrence grouping only: {public_token, title}. The series public_token is an + # opaque public identifier -- deliberately NOT the sequential internal series id -- + # and no public route consumes it, so it grants nothing. The title is already public + # via the occurrence itself. + 'series', } FORBIDDEN_KEYS = { 'id', @@ -199,6 +207,9 @@ def test_openapi_has_exact_public_contracts_and_documented_errors(): field['writeOnly'] for field in input_schema['properties'].values() ) assert set(response_schema['properties']) == {'status'} + status_property = response_schema['properties']['status'] + status_ref = status_property.get('$ref') or status_property['allOf'][0]['$ref'] + assert 'pending_approval' in components[status_ref.rsplit('/', 1)[-1]]['enum'] list_operation = schema['paths'][ '/api/v1/public/{makerspace_slug}/events/' diff --git a/backend/tests/events/test_registration_policy.py b/backend/tests/events/test_registration_policy.py new file mode 100644 index 00000000..318d98b4 --- /dev/null +++ b/backend/tests/events/test_registration_policy.py @@ -0,0 +1,293 @@ +from concurrent.futures import ThreadPoolExecutor +from datetime import timedelta +from threading import Barrier + +import pytest +from django.db import IntegrityError, close_old_connections, transaction +from django.urls import resolve, reverse +from django.utils import timezone +from drf_spectacular.generators import SchemaGenerator +from rest_framework.test import APIRequestFactory + +from apps.audit.models import AuditLog +from apps.events import services +from apps.events.exceptions import ( + CapacityConflict, + EventInvalidTransition, + RegistrationClosed, + RegistrationRejected, +) +from apps.events.models import Event, EventRegistration +from apps.events.serializers_admin import EventWriteSerializer +from apps.makerspaces import origin_scope +from tests.events.test_admin_api import ( + client_for, + grant, + make_space, + make_user, +) +from tests.events.test_services import make_event, make_registration, register +from tests.member_submission import active_member_client + + +pytestmark = pytest.mark.django_db + + +def test_cutoff_defaults_and_validation_preserve_existing_behavior(): + event = make_event(make_space("cutoff-default")) + assert event.registration_requires_approval is False + assert event.registration_cutoff_at is None + assert event.registration_cutoff_lead_minutes is None + assert register(event, "default@example.test").status == "registered" + + event.registration_cutoff_lead_minutes = 30 + serializer = EventWriteSerializer( + event, + data={"registration_cutoff_at": event.starts_at.isoformat()}, + partial=True, + ) + assert not serializer.is_valid() + assert set(serializer.errors) == { + "registration_cutoff_at", "registration_cutoff_lead_minutes" + } + + +def test_cutoff_constraints_reject_two_modes_and_after_start(): + space = make_space("cutoff-constraints") + start = timezone.now() + timedelta(hours=2) + with pytest.raises(IntegrityError), transaction.atomic(): + Event.objects.create( + makerspace=space, title="Both", starts_at=start, + ends_at=start + timedelta(hours=1), registration_cutoff_at=start, + registration_cutoff_lead_minutes=0, + ) + with pytest.raises(IntegrityError), transaction.atomic(): + Event.objects.create( + makerspace=space, title="Late", starts_at=start, + ends_at=start + timedelta(hours=1), + registration_cutoff_at=start + timedelta(seconds=1), + ) + + +def test_pending_member_is_active_for_uniqueness_but_rejected_is_terminal(): + space = make_space("approval-uniqueness") + member = make_user("approval-unique-member") + event = make_event(space, registration_requires_approval=True) + first = make_registration( + event, "first-identity@example.test", "pending_approval" + ) + first.member = member + first.save(update_fields=["member"]) + with pytest.raises(IntegrityError), transaction.atomic(): + EventRegistration.objects.create( + event=event, member=member, name="Same member", + email="second-identity@example.test", phone="1", + status=EventRegistration.Status.PENDING_APPROVAL, + ) + + +def test_approval_policy_changes_only_while_draft(): + actor = make_user("approval-policy-actor") + published = make_event(make_space("approval-policy-published")) + with pytest.raises(EventInvalidTransition): + services.update_event( + published, actor=actor, registration_requires_approval=True + ) + draft = make_event( + make_space("approval-policy-draft"), status=Event.Status.DRAFT + ) + updated = services.update_event( + draft, actor=actor, registration_requires_approval=True + ) + assert updated.registration_requires_approval is True + + +def test_registration_cutoff_is_closed_at_equality(monkeypatch): + fixed = timezone.now() + timedelta(minutes=30) + event = make_event( + make_space("cutoff-equality"), + starts_at=fixed + timedelta(hours=1), + ends_at=fixed + timedelta(hours=2), + registration_cutoff_at=fixed, + ) + monkeypatch.setattr("apps.events.services_registration.timezone.now", lambda: fixed) + with pytest.raises(RegistrationClosed): + register(event, "equal@example.test") + + event.registration_cutoff_at = fixed + timedelta(seconds=1) + event.save(update_fields=["registration_cutoff_at"]) + assert register(event, "before@example.test").status == "registered" + + +def test_pending_approval_consumes_no_capacity_and_charges_only_on_confirmation( + monkeypatch, +): + event = make_event( + make_space("approval-payment"), + capacity=1, + registration_requires_approval=True, + ) + payment_calls = [] + monkeypatch.setattr( + "apps.events.service_payments.create_for_registered_registration", + lambda registration, actor: payment_calls.append(registration.pk), + ) + monkeypatch.setattr( + "apps.events.services_registration_state.create_for_registered_registration", + lambda registration, actor: payment_calls.append(registration.pk), + ) + + first = register(event, "first@example.test") + second = register(event, "second@example.test") + assert first.status == second.status == EventRegistration.Status.PENDING_APPROVAL + assert payment_calls == [] + + first = services.approve_registration(first, actor=None) + second = services.approve_registration(second, actor=None) + assert (first.status, second.status) == ("registered", "waitlisted") + assert payment_calls == [first.pk] + with pytest.raises(EventInvalidTransition): + services.approve_registration(first, actor=None) + assert payment_calls == [first.pk] + + second = services.reject_registration(second, actor=None) + assert second.status == EventRegistration.Status.REJECTED + assert payment_calls == [first.pk] + assert set(AuditLog.objects.values_list("action", flat=True)) >= { + "event.registration_approval_requested", + "event.registration_approved", + "event.registration_rejected", + } + + +def test_rejected_registration_is_terminal_and_not_refunded(): + event = make_event( + make_space("approval-rejected"), registration_requires_approval=True + ) + registration = services.reject_registration( + register(event, "rejected@example.test"), actor=None + ) + with pytest.raises(RegistrationRejected): + register(event, "rejected@example.test") + with pytest.raises(EventInvalidTransition): + services.cancel_registration(registration) + + +def test_approval_events_never_auto_promote_and_manual_promotion_rechecks_capacity( + monkeypatch, +): + event = make_event( + make_space("approval-promotion"), + capacity=1, + registration_requires_approval=True, + ) + confirmed = make_registration(event, "held@example.test") + oldest = make_registration(event, "old@example.test", "waitlisted") + selected = make_registration(event, "selected@example.test", "waitlisted") + payment_calls = [] + monkeypatch.setattr( + "apps.events.services_registration_state.create_for_registered_registration", + lambda registration, actor: payment_calls.append(registration.pk), + ) + services.cancel_registration(confirmed) + oldest.refresh_from_db() + assert oldest.status == EventRegistration.Status.WAITLISTED + + promoted = services.promote_registration(selected, actor=None) + assert promoted.status == EventRegistration.Status.REGISTERED + assert payment_calls == [selected.pk] + with pytest.raises(CapacityConflict): + services.promote_registration(oldest, actor=None) + log = AuditLog.objects.get(action="event.registration_promoted") + assert log.meta["promotion_mode"] == "manual" + + +@pytest.mark.django_db(transaction=True) +def test_two_approvals_for_last_place_serialize(): + event = make_event( + make_space("approval-concurrency"), + capacity=1, + registration_requires_approval=True, + ) + registrations = [ + make_registration(event, f"pending-{index}@example.test", "pending_approval") + for index in range(2) + ] + barrier = Barrier(2) + + def approve(registration_id): + close_old_connections() + barrier.wait() + try: + row = EventRegistration.objects.get(pk=registration_id) + return services.approve_registration(row, actor=None).status + finally: + close_old_connections() + + with ThreadPoolExecutor(max_workers=2) as pool: + statuses = list(pool.map(approve, [row.pk for row in registrations])) + assert sorted(statuses) == ["registered", "waitlisted"] + + +def test_approval_endpoints_are_scoped_and_documented(): + space = make_space("approval-api") + manager = make_user("approval-api-manager") + grant(manager, space) + event = make_event(space, registration_requires_approval=True) + registration = make_registration(event, "api@example.test", "pending_approval") + client = client_for(manager) + response = client.post( + reverse("admin-event-registration-approve", kwargs={"pk": registration.pk}), + {}, + format="json", + ) + assert response.status_code == 200 + assert response.data["status"] == EventRegistration.Status.REGISTERED + + outsider = client_for(make_user("approval-api-outsider")) + assert outsider.post( + reverse("admin-event-registration-reject", kwargs={"pk": registration.pk}), + {}, format="json", + ).status_code == 404 + + factory = APIRequestFactory() + for name in ( + "admin-event-registration-approve", + "admin-event-registration-reject", + "admin-event-registration-promote", + ): + url = reverse(name, kwargs={"pk": registration.pk}) + match = resolve(url) + request = factory.post(url) + request.resolver_match = match + view = match.func.view_class(**match.func.view_initkwargs) + view.kwargs = match.kwargs + assert origin_scope._target_makerspace_id(request, view) == space.pk + + schema = SchemaGenerator().get_schema(request=None, public=True) + for action in ("approve", "reject", "promote"): + path = f"/api/v1/admin/event-registrations/{{id}}/{action}/" + assert "post" in schema["paths"][path] + + +def test_public_cutoff_and_pending_status_are_typed(): + space = make_space("approval-public") + event = make_event( + space, + registration_requires_approval=True, + registration_cutoff_at=timezone.now() - timedelta(seconds=1), + ) + _member, client = active_member_client(space, "approval-public-member") + url = reverse( + "public-event-register", + kwargs={"makerspace_slug": space.slug, "public_token": event.public_token}, + ) + closed = client.post(url, {}, format="json") + assert closed.status_code == 409 + assert closed.data["code"] == "registration_closed" + + event.registration_cutoff_at = None + event.save(update_fields=["registration_cutoff_at"]) + pending = client.post(url, {}, format="json") + assert pending.status_code == 201 + assert pending.data == {"status": EventRegistration.Status.PENDING_APPROVAL} diff --git a/backend/tests/events/test_staff_registration_p13.py b/backend/tests/events/test_staff_registration_p13.py index 463d1624..5c221699 100644 --- a/backend/tests/events/test_staff_registration_p13.py +++ b/backend/tests/events/test_staff_registration_p13.py @@ -139,6 +139,32 @@ def test_capacity_still_waitlists_through_the_staff_path(): ] == EventRegistration.Status.WAITLISTED +def test_staff_registration_obeys_cutoff_and_approval_policy(): + space = make_space("event-staff-policy") + staff, attendee = manager(space), member(space) + event = make_event(space) + event.registration_requires_approval = True + event.registration_cutoff_at = timezone.now() - timedelta(seconds=1) + event.save(update_fields=[ + "registration_requires_approval", "registration_cutoff_at", + ]) + client = authed(staff) + + closed = client.post( + register_url(event), {"member_id": attendee.pk}, format="json" + ) + assert closed.status_code == 409 + assert closed.data["code"] == "registration_closed" + + event.registration_cutoff_at = None + event.save(update_fields=["registration_cutoff_at"]) + pending = client.post( + register_url(event), {"member_id": attendee.pk}, format="json" + ) + assert pending.status_code == 201 + assert pending.data["status"] == EventRegistration.Status.PENDING_APPROVAL + + def test_registering_twice_is_a_conflict(): space = make_space() staff, attendee = manager(space), member(space) diff --git a/backend/tests/evidence/__init__.py b/backend/tests/evidence/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/tests/evidence/test_object_retention.py b/backend/tests/evidence/test_object_retention.py new file mode 100644 index 00000000..152595db --- /dev/null +++ b/backend/tests/evidence/test_object_retention.py @@ -0,0 +1,290 @@ +from datetime import timedelta + +import pytest +from django.contrib.auth import get_user_model +from django.urls import reverse +from django.utils import timezone +from rest_framework.test import APIClient + +from apps.accounts.models import User +from apps.audit.models import AuditLog +from apps.evidence import finalization, storage +from apps.evidence.models import ( + EvidenceObjectRetentionState, + EvidencePhoto, + EvidenceRetentionPolicy, + EvidenceUploadFinalization, +) +from apps.evidence.services_retention import sweep_evidence_retention +from apps.makerspaces.models import Makerspace, MakerspaceMembership + + +pytestmark = pytest.mark.django_db + + +def make_space(slug): + return Makerspace.objects.create(name=slug, slug=slug) + + +def make_manager(slug, makerspace): + user = get_user_model().objects.create_user( + username=slug, + email=f"{slug}@example.test", + role=User.Role.SPACE_MANAGER, + access_status=User.AccessStatus.ACTIVE, + ) + MakerspaceMembership.objects.create( + makerspace=makerspace, + user=user, + role=MakerspaceMembership.Role.SPACE_MANAGER, + ) + return user + + +def make_photo(makerspace, uploader, *, key="evidence/retention/photo.jpg", size=123): + return EvidencePhoto.objects.create( + makerspace=makerspace, + evidence_type=EvidencePhoto.EvidenceType.ISSUE, + object_key=key, + content_type="image/jpeg", + size_bytes=size, + uploaded_by=uploader, + ) + + +def client_for(user): + client = APIClient() + client.force_authenticate(user=user) + return client + + +def policy_url(makerspace): + return reverse( + "evidence_admin:evidence-retention-policy", + kwargs={"makerspace_id": makerspace.pk}, + ) + + +def preview_url(makerspace): + return reverse( + "evidence_admin:evidence-retention-preview", + kwargs={"makerspace_id": makerspace.pk}, + ) + + +def test_manager_can_set_preview_and_clear_object_retention(settings): + settings.EVIDENCE_OBJECT_RETENTION_DAYS = 365 + settings.EVIDENCE_OBJECT_EXPIRY_ENABLED = False + makerspace = make_space("retention-policy") + manager = make_manager("retention-policy-manager", makerspace) + photo = make_photo(makerspace, manager) + as_of = timezone.now() + timedelta(days=90) + EvidenceRetentionPolicy.objects.create( + makerspace=makerspace, object_retention_days=60 + ) + + response = client_for(manager).get(policy_url(makerspace)) + assert response.status_code == 200 + assert response.data == { + "makerspace_id": makerspace.pk, + "platform_default_days": 365, + "override_days": 60, + "effective_days": 60, + "object_expiry_enabled": False, + } + + response = client_for(manager).post( + preview_url(makerspace), {"limit": 100}, format="json" + ) + assert response.status_code == 200 + assert response.data["policy_days"] == 60 + assert response.data["object_candidates"] == 0 + + # Exercise the exact boundary independently of wall-clock API time. + from apps.evidence.retention_policy import preview_object_expiry + + preview = preview_object_expiry(makerspace, limit=100, as_of=as_of) + assert preview["object_candidates"] == 1 + assert preview["candidate_bytes"] == photo.size_bytes + + response = client_for(manager).patch( + policy_url(makerspace), {"object_retention_days": None}, format="json" + ) + assert response.status_code == 200 + assert response.data["override_days"] is None + assert response.data["effective_days"] == 365 + assert not EvidenceRetentionPolicy.objects.filter(makerspace=makerspace).exists() + assert AuditLog.objects.filter(action="evidence.retention_policy_updated").count() == 1 + + +def test_non_event_manager_cannot_change_policy(): + makerspace = make_space("retention-rbac") + user = get_user_model().objects.create_user( + username="retention-inventory", + role=User.Role.REQUESTER, + access_status=User.AccessStatus.ACTIVE, + ) + MakerspaceMembership.objects.create( + makerspace=makerspace, + user=user, + role=MakerspaceMembership.Role.INVENTORY_MANAGER, + ) + + response = client_for(user).patch( + policy_url(makerspace), {"object_retention_days": 90}, format="json" + ) + + assert response.status_code == 403 + assert not EvidenceRetentionPolicy.objects.exists() + + +def test_disabled_sweep_has_no_side_effects(settings, monkeypatch): + settings.EVIDENCE_OBJECT_EXPIRY_ENABLED = False + makerspace = make_space("retention-disabled") + manager = make_manager("retention-disabled-manager", makerspace) + make_photo(makerspace, manager) + monkeypatch.setattr( + "apps.evidence.storage.object_size", + lambda _key: pytest.fail("disabled expiry touched storage"), + ) + + summary = sweep_evidence_retention(now=timezone.now() + timedelta(days=400)) + + assert summary["photos_expired"] == 0 + assert not EvidenceObjectRetentionState.objects.exists() + assert not AuditLog.objects.filter(action="evidence.object_expired").exists() + + +def test_dry_run_selects_candidates_without_mutation(settings, monkeypatch): + settings.EVIDENCE_OBJECT_EXPIRY_ENABLED = True + settings.EVIDENCE_OBJECT_RETENTION_DAYS = 30 + makerspace = make_space("retention-dry-run") + manager = make_manager("retention-dry-run-manager", makerspace) + make_photo(makerspace, manager) + monkeypatch.setattr( + "apps.evidence.storage.object_size", + lambda _key: pytest.fail("dry run touched storage"), + ) + + summary = sweep_evidence_retention( + dry_run=True, now=timezone.now() + timedelta(days=31) + ) + + assert summary["photos_eligible"] == 1 + assert summary["bytes_removed"] == 0 + assert not EvidenceObjectRetentionState.objects.exists() + assert not AuditLog.objects.filter(action="evidence.object_expired").exists() + + +def test_enabled_sweep_deletes_both_keys_retains_row_and_releases_quota( + settings, monkeypatch +): + settings.EVIDENCE_OBJECT_EXPIRY_ENABLED = True + settings.EVIDENCE_OBJECT_RETENTION_DAYS = 365 + settings.EVIDENCE_RETENTION_BATCH_SIZE = 100 + settings.STORAGE_PRESIGN_METHOD = "put" + makerspace = make_space("retention-enabled") + manager = make_manager("retention-enabled-manager", makerspace) + photo = make_photo(makerspace, manager, size=321) + EvidenceUploadFinalization.objects.create( + evidence=photo, + status=EvidenceUploadFinalization.Status.FINALIZED, + size_bytes=321, + content_type="image/jpeg", + quota_charged=True, + ) + Makerspace.objects.filter(pk=makerspace.pk).update(storage_bytes_used=321) + monkeypatch.setattr("apps.makerspaces.limits.is_self_host", lambda: False) + monkeypatch.setattr(storage, "object_size", lambda _key: 321) + deleted = [] + monkeypatch.setattr( + storage, + "delete_object_strict", + lambda key: deleted.append(key) or "deleted", + ) + + summary = sweep_evidence_retention(now=timezone.now() + timedelta(days=366)) + + assert deleted == [photo.object_key, storage.staging_key(photo.object_key)] + assert summary["photos_expired"] == 1 + assert summary["bytes_removed"] == 321 + assert EvidencePhoto.objects.filter(pk=photo.pk).exists() + state = EvidenceObjectRetentionState.objects.get(evidence=photo) + assert state.status == EvidenceObjectRetentionState.Status.EXPIRED + assert state.expired_size_bytes == 321 + makerspace.refresh_from_db() + assert makerspace.storage_bytes_used == 0 + assert AuditLog.objects.filter( + action="evidence.object_expired", target_id=str(photo.pk) + ).count() == 1 + + +def test_storage_failure_is_retryable_and_does_not_release_quota(settings, monkeypatch): + settings.EVIDENCE_OBJECT_EXPIRY_ENABLED = True + settings.EVIDENCE_OBJECT_RETENTION_DAYS = 30 + settings.STORAGE_PRESIGN_METHOD = "put" + makerspace = make_space("retention-retry") + manager = make_manager("retention-retry-manager", makerspace) + photo = make_photo(makerspace, manager, size=75) + EvidenceUploadFinalization.objects.create( + evidence=photo, + status=EvidenceUploadFinalization.Status.FINALIZED, + size_bytes=75, + quota_charged=True, + ) + Makerspace.objects.filter(pk=makerspace.pk).update(storage_bytes_used=75) + monkeypatch.setattr("apps.makerspaces.limits.is_self_host", lambda: False) + monkeypatch.setattr(storage, "object_size", lambda _key: 75) + monkeypatch.setattr( + storage, + "delete_object_strict", + lambda _key: (_ for _ in ()).throw(storage.StorageUnavailable()), + ) + + summary = sweep_evidence_retention(now=timezone.now() + timedelta(days=31)) + + assert summary["photos_failed"] == 1 + state = EvidenceObjectRetentionState.objects.get(evidence=photo) + assert state.status == EvidenceObjectRetentionState.Status.EXPIRING + assert state.claim_token is None + assert state.last_error.startswith("StorageUnavailable") + makerspace.refresh_from_db() + assert makerspace.storage_bytes_used == 75 + assert not AuditLog.objects.filter(action="evidence.object_expired").exists() + + +def test_expired_detail_returns_410_without_storage_access(monkeypatch): + makerspace = make_space("retention-gone") + manager = make_manager("retention-gone-manager", makerspace) + photo = make_photo(makerspace, manager) + expired_at = timezone.now() + EvidenceObjectRetentionState.objects.create( + evidence=photo, + status=EvidenceObjectRetentionState.Status.EXPIRED, + object_expired_at=expired_at, + expired_size_bytes=123, + ) + monkeypatch.setattr( + "apps.evidence.views.object_exists", + lambda _key: pytest.fail("expired detail touched storage"), + ) + + response = client_for(manager).get( + reverse("evidence_admin:evidence-detail", kwargs={"pk": photo.pk}) + ) + + assert response.status_code == 410 + assert response.data["code"] == "evidence_expired" + assert response.data["object_expired_at"] is not None + + +def test_finalization_rejects_an_expiring_photo(): + makerspace = make_space("retention-finalize") + manager = make_manager("retention-finalize-manager", makerspace) + photo = make_photo(makerspace, manager) + EvidenceObjectRetentionState.objects.create(evidence=photo) + + with pytest.raises(storage.EvidenceObjectValidationError) as exc: + finalization._claim(photo.pk) + + assert exc.value.code == "expired" diff --git a/backend/tests/evidence/test_retention_rollup_boundaries.py b/backend/tests/evidence/test_retention_rollup_boundaries.py new file mode 100644 index 00000000..eeba4fed --- /dev/null +++ b/backend/tests/evidence/test_retention_rollup_boundaries.py @@ -0,0 +1,82 @@ +"""Retention removes evidence bytes, never historical report facts.""" + +from datetime import timedelta + +import pytest +from django.contrib.auth import get_user_model +from django.utils import timezone + +from apps.evidence import storage +from apps.evidence.models import EvidencePhoto +from apps.evidence.services_retention import sweep_evidence_retention +from apps.makerspaces import lifecycle +from apps.makerspaces.models import Makerspace +from apps.operations.models import ReportMetricRollup + + +pytestmark = pytest.mark.django_db(transaction=True) + + +def _rollup(space): + now = timezone.now() + return ReportMetricRollup.objects.create( + makerspace=space, + source_module="events", + report_key="attendance", + metric_key="attended", + bucket_start=now.replace(hour=0, minute=0, second=0, microsecond=0), + grain=ReportMetricRollup.Grain.DAY, + dimension_key="status=attended", + dimensions={"status": "attended"}, + value="9.000000", + sample_count=9, + source_cutoff=now, + checksum="b" * 64, + ) + + +def test_automatic_evidence_retention_does_not_rewrite_rollups(settings, monkeypatch): + settings.EVIDENCE_OBJECT_EXPIRY_ENABLED = True + settings.EVIDENCE_OBJECT_RETENTION_DAYS = 30 + space = Makerspace.objects.create(name="Retention reports", slug="retention-reports") + user = get_user_model().objects.create_user(username="retention-reports") + photo = EvidencePhoto.objects.create( + makerspace=space, + evidence_type=EvidencePhoto.EvidenceType.ISSUE, + object_key=f"evidence/{space.pk}/old.jpg", + size_bytes=12, + uploaded_by=user, + ) + rollup = _rollup(space) + before = ReportMetricRollup.objects.values().get(pk=rollup.pk) + monkeypatch.setattr(storage, "object_size", lambda _key: 12) + monkeypatch.setattr(storage, "delete_object_strict", lambda _key: "deleted") + + summary = sweep_evidence_retention( + now=photo.created_at + timedelta(days=31), batch_size=1 + ) + + assert summary["photos_expired"] == 1 + assert ReportMetricRollup.objects.values().get(pk=rollup.pk) == before + + +def test_explicit_legal_tenant_purge_deletes_rollups(settings, monkeypatch): + settings.MANAGED_POSTGRES = True + actor = get_user_model().objects.create_superuser( + username="legal-purge", email="legal-purge@example.test", password="pw" + ) + space = Makerspace.objects.create( + name="Legal purge reports", + slug="legal-purge-reports", + archived_at=timezone.now(), + archived_by=actor, + superadmin_access_enabled=True, + ) + rollup = _rollup(space) + monkeypatch.setattr(lifecycle, "_delete_storage_keys", lambda _keys: None) + monkeypatch.setattr(lifecycle, "_delete_public_image_keys", lambda _keys: None) + + lifecycle.purge(space, actor) + + assert not Makerspace.objects.filter(pk=space.pk).exists() + assert not ReportMetricRollup.objects.filter(pk=rollup.pk).exists() diff --git a/backend/tests/makerspaces/test_core_module_independence.py b/backend/tests/makerspaces/test_core_module_independence.py index e1164eb8..1dd50dba 100644 --- a/backend/tests/makerspaces/test_core_module_independence.py +++ b/backend/tests/makerspaces/test_core_module_independence.py @@ -2,8 +2,10 @@ **What this proves, precisely.** For every optional module `M`, a makerspace built from `core + (every other optional module)` can still run the loan spine end to end: browse the -public catalogue, submit a borrow request, see it in the staff queue, accept it, and read -its public status. Plus the strongest single case — `core` and nothing else. +public catalogue, submit a borrow request, see it in the staff queue, accept it, read its +public status, scan and assign a box, attach issue evidence, issue it, then attach return +evidence and a remark and complete the return. Plus the strongest single case — `core` +and nothing else. **What it does NOT prove.** It is not a general proof that "no core module hard-depends on an optional module's data". A true static proof is not available in this codebase: an app @@ -34,10 +36,18 @@ from rest_framework.test import APIClient from apps.accounts.models import User +from apps.evidence.storage import EvidenceValidationResult +from apps.hardware_requests.models import HardwareRequest from apps.inventory.models import InventoryProduct from apps.makerspaces.models import Makerspace, MakerspaceMembership from apps.makerspaces.module_registry import BY_KEY, MODULES, core_module_keys from apps.presence.models import PresenceSession +from tests.return_helpers import ( + make_box, + make_issue_evidence, + make_return_evidence, + return_payload, +) pytestmark = pytest.mark.django_db @@ -124,9 +134,13 @@ def _client(user=None): return client -def run_loan_spine(slug, modules): - """Browse -> submit -> staff queue -> accept -> public status. Returns nothing; - every step asserts, so the failing step names itself.""" +def run_loan_spine(slug, modules, monkeypatch): + """Browse -> submit -> queue -> accept -> public status -> box -> issue -> return. + + Returns nothing; every step asserts, so the failing endpoint names itself. The + post-acceptance steps satisfy the production handover rules with a real box scan, + distinct issue/return evidence, and a complete return remark + resolution. + """ space = _space(slug, modules) product = InventoryProduct.objects.create( makerspace=space, @@ -147,7 +161,8 @@ def run_loan_spine(slug, modules): assert submit.status_code == 201, f"submit: {submit.status_code} {submit.data}" public_token = submit.data["public_token"] - staff = _client(_staff(slug)) + staff_user = _staff(slug) + staff = _client(staff_user) pending = staff.get(reverse("hardware_requests:pending-requests", args=[space.id])) assert pending.status_code == 200, f"queue: {pending.status_code} {pending.data}" assert pending.data["count"] == 1, pending.data @@ -162,15 +177,58 @@ def run_loan_spine(slug, modules): ) assert status_response.status_code == 200, f"status: {status_response.status_code}" + monkeypatch.setattr( + "apps.evidence.storage.finalize_upload", + lambda *_args: EvidenceValidationResult(size=123, content_type="image/jpeg"), + ) + box = make_box(space, label=f"{slug} loan box") + assigned = staff.post( + reverse("hardware_requests:request-assign-box", args=[request_id]), + {"box_code": box.code}, + format="json", + ) + assert assigned.status_code == 200, f"assign-box: {assigned.status_code} {assigned.data}" + + issued = staff.post( + reverse("hardware_requests:request-issue", args=[request_id]), + { + "evidence_id": make_issue_evidence(space, staff_user).pk, + "remark": "Issued after box and evidence verification.", + }, + format="json", + ) + assert issued.status_code == 200, f"issue: {issued.status_code} {issued.data}" + assert issued.data["status"] == HardwareRequest.Status.ISSUED + + hardware_request = HardwareRequest.objects.get(pk=request_id) + returned = staff.post( + reverse("hardware_requests:request-return", args=[request_id]), + return_payload( + hardware_request, + make_return_evidence(space, staff_user), + remark="Returned complete and inspected.", + ), + format="json", + ) + assert returned.status_code == 200, f"return: {returned.status_code} {returned.data}" + hardware_request.refresh_from_db() + assert hardware_request.status in { + HardwareRequest.Status.RETURNED, + HardwareRequest.Status.CLOSED_WITH_ISSUE, + } + assert returned.data["status"] == hardware_request.status -def test_the_loan_spine_runs_on_a_core_only_makerspace(): + +def test_the_loan_spine_runs_on_a_core_only_makerspace(monkeypatch): """The strongest single case: every optional module uninstalled at once.""" - run_loan_spine("core-only", sorted(CORE)) + run_loan_spine("core-only", sorted(CORE), monkeypatch) @pytest.mark.parametrize("missing", OPTIONAL) -def test_the_loan_spine_survives_each_optional_module_being_uninstalled(missing): - run_loan_spine(f"no-{missing.replace('_', '-')}", configuration_without(missing)) +def test_the_loan_spine_survives_each_optional_module_being_uninstalled(missing, monkeypatch): + run_loan_spine( + f"no-{missing.replace('_', '-')}", configuration_without(missing), monkeypatch + ) def test_every_optional_module_is_actually_covered_by_the_matrix(): diff --git a/backend/tests/makerspaces/test_member_profiles_p12.py b/backend/tests/makerspaces/test_member_profiles_p12.py index 8d9b19f4..d08b32ad 100644 --- a/backend/tests/makerspaces/test_member_profiles_p12.py +++ b/backend/tests/makerspaces/test_member_profiles_p12.py @@ -222,10 +222,11 @@ def test_a_non_member_reaches_nothing(): def test_the_directory_needs_the_membership_module(): from apps.makerspaces.module_install import uninstall_module + from tests.module_helpers import disable_module space = make_space() viewer = member(space, username="viewer") - uninstall_module(space, "membership") + disable_module(space, "membership") # The typed `module` 400 every other module gate raises, not a permission error: # the caller is allowed here, the space just does not run the module. diff --git a/backend/tests/makerspaces/test_membership_module_gating.py b/backend/tests/makerspaces/test_membership_module_gating.py index b6affbf1..07575f2a 100644 --- a/backend/tests/makerspaces/test_membership_module_gating.py +++ b/backend/tests/makerspaces/test_membership_module_gating.py @@ -20,6 +20,7 @@ MakerspaceWaiver, ) from apps.makerspaces.module_install import install_module, uninstall_module +from tests.module_helpers import disable_module pytestmark = pytest.mark.django_db(transaction=True) @@ -38,7 +39,7 @@ def space(slug, *, membership_enabled): if membership_enabled: install_module(item, "membership") else: - uninstall_module(item, "membership") + disable_module(item, "membership") item.refresh_from_db() return item diff --git a/backend/tests/makerspaces/test_module_purge.py b/backend/tests/makerspaces/test_module_purge.py index 044dca65..04473896 100644 --- a/backend/tests/makerspaces/test_module_purge.py +++ b/backend/tests/makerspaces/test_module_purge.py @@ -22,6 +22,7 @@ MembershipRequest, ) from apps.makerspaces.module_install import install_module, uninstall_module +from tests.module_helpers import disable_module from apps.makerspaces.module_purge import purge_module, purgeable_modules from apps.payments.models import Payment @@ -200,7 +201,7 @@ def test_purging_membership_keeps_waiver_evidence_but_removes_community_data(): kind=MembershipRequest.Kind.REQUEST, state=MembershipRequest.State.REVOKED, ) - uninstall_module(makerspace, "membership") + disable_module(makerspace, "membership") purge_module(makerspace, "membership", superadmin("membership-admin")) diff --git a/backend/tests/makerspaces/test_request_access.py b/backend/tests/makerspaces/test_request_access.py index ac8fb6f6..5d9c8771 100644 --- a/backend/tests/makerspaces/test_request_access.py +++ b/backend/tests/makerspaces/test_request_access.py @@ -21,6 +21,7 @@ from apps.inventory.models import InventoryProduct from apps.makerspaces.models import Makerspace from apps.makerspaces.module_install import install_module, uninstall_module +from tests.module_helpers import disable_module from apps.makerspaces.request_access import ( ACCOUNTS, ANYONE, @@ -136,7 +137,7 @@ def test_uninstalling_membership_does_not_reopen_account_less_requests(): explicit operator act, never a side effect of removing an unrelated module.""" space = _space("ra-uninstall", modules=[*CORE_PLUS, "membership", "member_accounts"]) - uninstall_module(space, "membership") + disable_module(space, "membership") space.refresh_from_db() assert space.anonymous_requests_enabled is False diff --git a/backend/tests/module_helpers.py b/backend/tests/module_helpers.py new file mode 100644 index 00000000..acadb9b7 --- /dev/null +++ b/backend/tests/module_helpers.py @@ -0,0 +1,24 @@ +"""Helpers for turning modules off in tests. + +`uninstall_module` deliberately REFUSES to remove a module that another enabled module +declares a dependency on: disabling `membership` underneath an enabled `events` would +leave every registration surface mounted and refusing, which is the failure mode the +dependency was declared to prevent. A test that wants a membership-off makerspace +therefore has to remove the dependents too. This does that transitively so each test can +state its intent ("no membership") instead of hand-maintaining a dependency closure that +changes whenever the registry does. +""" + +from apps.makerspaces.module_install import uninstall_module +from apps.makerspaces.module_registry_helpers import dependents_of + + +def disable_module(makerspace, key, actor=None): + """Uninstall `key`, uninstalling whatever depends on it first, deepest dependent first.""" + makerspace.refresh_from_db() + enabled = set(makerspace.enabled_modules or []) + if key not in enabled: + return [] + for dependent in dependents_of(key, enabled - {key}): + disable_module(makerspace, dependent, actor=actor) + return uninstall_module(makerspace, key, actor=actor) diff --git a/backend/tests/modules/__init__.py b/backend/tests/modules/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/tests/modules/test_module_contract_matrix.py b/backend/tests/modules/test_module_contract_matrix.py new file mode 100644 index 00000000..25893b86 --- /dev/null +++ b/backend/tests/modules/test_module_contract_matrix.py @@ -0,0 +1,203 @@ +"""One positive and one own-key negative contract for every optional module. + +Each positive case installs only core, the module under test, and its declared +dependencies. Each negative case keeps those dependencies but removes the module +itself, so a refusal cannot be blamed on an unrelated optional prerequisite. +""" + +import pytest +from django.urls import reverse +from django.utils import timezone +from rest_framework.test import APIClient + +from apps.accounts.models import User +from apps.makerspaces.models import Makerspace +from apps.makerspaces.module_registry import ( + BY_KEY, + core_module_keys, + with_dependencies, +) +from tests.modules.test_offstate_inventory import _exercise_surface +from tests.modules.test_offstate_machines import _machine, _printer_queue + +pytestmark = pytest.mark.django_db + +CORE = frozenset(core_module_keys()) +OPTIONAL = tuple(sorted(set(BY_KEY) - CORE)) + + +def _space(module_key, state, modules): + label = module_key.replace("_", "-") + return Makerspace.objects.create( + name=f"contract-{label}-{state}", + slug=f"contract-{label}-{state}", + enabled_modules=sorted(modules), + public_inventory_enabled=True, + ) + + +def _actor(module_key, state): + username = f"contract-{module_key}-{state}" + return User.objects.create_user( + username=username, + email=f"{username}@example.test", + display_name="Module contract probe", + role=User.Role.SUPERADMIN, + is_staff=True, + is_superuser=True, + access_status=User.AccessStatus.ACTIVE, + email_verified_at=timezone.now(), + ) + + +def _client(actor): + client = APIClient() + client.force_authenticate(actor) + return client + + +def _inventory_probe(module_key): + return lambda space, client: _exercise_surface(module_key, space, client) + + +def _guest_handover(space, client): + return client.get( + reverse("hardware_requests:guest-admin-active-loans", args=[space.pk]) + ) + + +def _reports(space, client): + return client.get(reverse("analytics-summary", args=[space.pk])) + + +def _qr_print_batches(space, client): + return client.post( + reverse("qr-print-batches", args=[space.pk]), + {"title": "Contract labels"}, + format="json", + ) + + +def _machines(space, client): + return client.get(reverse("admin-machine-types", args=[space.pk])) + + +def _machine_service(space, client): + return client.get( + reverse("admin-machine-service-request-list-create", args=[space.pk]) + ) + + +def _printing(space, client): + _printer_queue(space) + return client.get(reverse("public-printer-service-queues", args=[space.slug])) + + +def _events(space, client): + return client.get(reverse("admin-event-list-create", args=[space.pk])) + + +def _bookings(space, client): + return client.get(reverse("admin-bookable-space-list-create", args=[space.pk])) + + +def _maintenance(space, client): + machine = _machine(space, suffix="contract") + return client.get( + reverse( + "admin-maintenance-log-list-create", + kwargs={"makerspace_id": space.pk, "machine_id": machine.pk}, + ) + ) + + +def _membership(space, client): + return client.post( + reverse("public-membership-request", args=[space.slug]), {}, format="json" + ) + + +def _notifications(space, client): + return client.get( + reverse("notifications:notifications-list", args=[space.pk]) + ) + + +def _telegram(space, client): + return client.post( + reverse("telegram-test-alert"), + {"makerspace_id": space.pk, "message": "Contract probe"}, + format="json", + ) + + +# A None value is intentional: it keeps an unverified surface visible as a per-module +# skip instead of silently pretending the registry entry has a contract probe. +PROBES = { + "asset_units": _inventory_probe("asset_units"), + "bookings": _bookings, + "bulk_import": _inventory_probe("bulk_import"), + "containers": _inventory_probe("containers"), + "discord": None, + "email": None, + "events": _events, + "guest_handover": _guest_handover, + "machine_service": _machine_service, + "machines": _machines, + "maintenance": _maintenance, + "mattermost": None, + "member_accounts": None, + "membership": _membership, + "mobile": None, + "notifications": _notifications, + "payments": None, + "printing": _printing, + "procurement": _inventory_probe("procurement"), + "qr_print_batches": _qr_print_batches, + "reports": _reports, + "slack": None, + "stock_transfers": _inventory_probe("stock_transfers"), + "stocktake": _inventory_probe("stocktake"), + "telegram": _telegram, + "updates": None, +} + + +def _probe_or_skip(module_key): + probe = PROBES[module_key] + if probe is None: + pytest.skip(f"{module_key}: no cheap verified primary-surface probe yet") + return probe + + +@pytest.mark.parametrize("module_key", OPTIONAL) +def test_optional_module_primary_surface_works_with_only_its_dependencies(module_key): + probe = _probe_or_skip(module_key) + modules = CORE | with_dependencies({module_key}) + space = _space(module_key, "on", modules) + + response = probe(space, _client(_actor(module_key, "on"))) + + assert 200 <= response.status_code < 300, ( + f"{module_key} positive probe: {response.status_code} {response.data}" + ) + + +@pytest.mark.parametrize("module_key", OPTIONAL) +def test_optional_module_primary_surface_refuses_when_only_its_key_is_absent(module_key): + probe = _probe_or_skip(module_key) + modules = CORE | (with_dependencies({module_key}) - {module_key}) + space = _space(module_key, "off", modules) + + response = probe(space, _client(_actor(module_key, "off"))) + + assert response.status_code >= 400, ( + f"{module_key} negative probe unexpectedly returned {response.status_code}" + ) + assert module_key in str(response.data), ( + f"{module_key} surface refused without naming its own key: {response.data}" + ) + + +def test_probe_registry_covers_every_optional_module_key(): + assert set(PROBES) == set(OPTIONAL) diff --git a/backend/tests/modules/test_offstate_facilities.py b/backend/tests/modules/test_offstate_facilities.py new file mode 100644 index 00000000..f2604a23 --- /dev/null +++ b/backend/tests/modules/test_offstate_facilities.py @@ -0,0 +1,269 @@ +"""Off-state contracts for the optional bookings and events modules.""" + +from datetime import timedelta + +import pytest +from django.core.cache import cache +from django.urls import reverse +from django.utils import timezone +from rest_framework.test import APIClient + +from apps.accounts.models import User +from apps.bookings.models import BookableSpace +from apps.events.models import Event +from apps.inventory.models import InventoryProduct +from apps.makerspaces.models import Makerspace, MakerspaceMembership +from apps.makerspaces.module_registry import BY_KEY, core_module_keys +from apps.presence.models import PresenceSession + + +pytestmark = pytest.mark.django_db + +CORE = frozenset(core_module_keys()) + + +@pytest.fixture(autouse=True) +def clear_throttles(): + cache.clear() + yield + cache.clear() + + +def _space(slug, *optional_modules): + return Makerspace.objects.create( + name=slug, + slug=slug, + enabled_modules=sorted(CORE | set(optional_modules)), + public_inventory_enabled=True, + ) + + +def _account(slug): + return User.objects.create_user( + username=slug, + email=f"{slug}@example.test", + display_name="Facility Member", + phone="1234567890", + access_status=User.AccessStatus.ACTIVE, + ) + + +def _client(user=None): + client = APIClient() + if user is not None: + client.force_authenticate(user) + return client + + +def _active_member_client(space, slug): + user = _account(slug) + membership = MakerspaceMembership.objects.create( + makerspace=space, + user=user, + status="active", + ) + now = timezone.now() + PresenceSession.objects.create( + member=user, + makerspace=space, + membership=membership, + started_at=now, + expires_at=now + timedelta(hours=2), + ) + return user, _client(user) + + +def _post_booking(space, client): + bookable = BookableSpace.objects.create( + makerspace=space, + name="Project room", + is_public=True, + is_active=True, + ) + starts_at = timezone.now() + timedelta(days=1) + response = client.post( + reverse( + "public-booking-submit", + kwargs={ + "makerspace_slug": space.slug, + "public_token": bookable.public_token, + }, + ), + { + "starts_at": starts_at.isoformat(), + "ends_at": (starts_at + timedelta(hours=1)).isoformat(), + }, + format="json", + ) + return bookable, response + + +def _post_event_registration(space, client): + starts_at = timezone.now() + timedelta(days=1) + event = Event.objects.create( + makerspace=space, + title="Open workshop", + starts_at=starts_at, + ends_at=starts_at + timedelta(hours=2), + is_public=True, + status=Event.Status.PUBLISHED, + ) + response = client.post( + reverse( + "public-event-register", + kwargs={ + "makerspace_slug": space.slug, + "public_token": event.public_token, + }, + ), + {}, + format="json", + ) + return event, response + + +def _run_loan_spine(slug, enabled_modules): + """Exercise public browse through accepted public status, not just a core read.""" + space = Makerspace.objects.create( + name=slug, + slug=slug, + enabled_modules=sorted(enabled_modules), + public_inventory_enabled=True, + ) + product = InventoryProduct.objects.create( + makerspace=space, + name="Torque wrench", + total_quantity=3, + available_quantity=3, + is_public=True, + ) + + catalogue = _client().get( + reverse("inventory:public-inventory", args=[space.slug]) + ) + assert catalogue.status_code == 200, catalogue.data + + requester = _account(f"{slug}-requester") + submitted = _client(requester).post( + reverse("hardware_requests:request-submit", args=[space.slug]), + { + "requested_for": "Facility off-state check", + "items": [{"product_id": product.pk, "quantity": 1}], + }, + format="json", + ) + assert submitted.status_code == 201, submitted.data + + staff = User.objects.create_user( + username=f"{slug}-staff", + email=f"{slug}-staff@example.test", + role=User.Role.SUPERADMIN, + is_staff=True, + is_superuser=True, + access_status=User.AccessStatus.ACTIVE, + ) + staff_client = _client(staff) + queued = staff_client.get( + reverse("hardware_requests:pending-requests", args=[space.pk]) + ) + assert queued.status_code == 200, queued.data + assert queued.data["count"] == 1 + + accepted = staff_client.post( + reverse( + "hardware_requests:request-accept", + args=[queued.data["results"][0]["id"]], + ), + {}, + format="json", + ) + assert accepted.status_code == 200, accepted.data + assert accepted.data["status"] == "accepted" + + public_status = _client().get( + reverse( + "hardware_requests:request-status", + args=[submitted.data["public_token"]], + ) + ) + assert public_status.status_code == 200 + + +def test_bookings_off_refuses_the_public_submit(): + space = _space("facilities-bookings-off") + _, response = _post_booking( + space, + _client(_account("facilities-bookings-off-account")), + ) + + assert response.status_code == 400 + assert response.data == {"module": "bookings is disabled for this makerspace."} + + +def test_events_off_refuses_the_public_registration(): + space = _space("facilities-events-off") + _, response = _post_event_registration( + space, + _client(_account("facilities-events-off-account")), + ) + + assert response.status_code == 400 + assert response.data == {"module": "events is disabled for this makerspace."} + + +def test_bookings_on_accepts_an_active_present_member(): + space = _space("facilities-bookings-on", "bookings", "membership") + user, client = _active_member_client(space, "facilities-bookings-on-member") + + bookable, response = _post_booking(space, client) + + assert response.status_code == 201, response.data + assert bookable.bookings.get().member == user + + +def test_events_on_accepts_an_active_member(): + space = _space("facilities-events-on", "events", "membership") + user, client = _active_member_client(space, "facilities-events-on-member") + + event, response = _post_event_registration(space, client) + + assert response.status_code == 201, response.data + assert event.registrations.get().member == user + + +def test_bookings_off_leaves_the_core_loan_spine_working(): + """Turning bookings off must not damage request_workflow's account-only fallback.""" + _run_loan_spine("facilities-no-bookings", CORE | {"events"}) + + +def test_events_off_leaves_the_core_loan_spine_working(): + """Turning events off must not damage request_workflow's account-only fallback.""" + _run_loan_spine("facilities-no-events", CORE | {"bookings"}) + + +def test_bookings_either_declares_membership_or_works_when_membership_is_off(): + """A standalone module must remain usable unless its dependency is made explicit.""" + space = _space("facilities-bookings-no-membership", "bookings") + bookable, response = _post_booking( + space, + _client(_account("facilities-bookings-no-membership-account")), + ) + + standalone = "membership" not in BY_KEY["bookings"].requires_modules + assert not standalone or response.status_code == 201, response.data + if standalone: + assert bookable.bookings.count() == 1 + + +def test_events_either_declares_membership_or_works_when_membership_is_off(): + """The events switch cannot promise registration while its only identity path is absent.""" + space = _space("facilities-events-no-membership", "events") + event, response = _post_event_registration( + space, + _client(_account("facilities-events-no-membership-account")), + ) + + standalone = "membership" not in BY_KEY["events"].requires_modules + assert not standalone or response.status_code == 201, response.data + if standalone: + assert event.registrations.count() == 1 diff --git a/backend/tests/modules/test_offstate_handover_spine.py b/backend/tests/modules/test_offstate_handover_spine.py new file mode 100644 index 00000000..7a80f670 --- /dev/null +++ b/backend/tests/modules/test_offstate_handover_spine.py @@ -0,0 +1,77 @@ +"""Focused route-gate checks for the reviewed-request spine past `accepted`. + +The full core-independence spine now performs a legitimate assign, issue, and return. +These smaller checks keep the original defect's boundary explicit: admin handover URLs +must reach workflow validation even when the optional guest console is absent. +""" + +import pytest +from django.urls import reverse + +from apps.hardware_requests.models import HardwareRequest +from apps.inventory.models import InventoryProduct +from apps.makerspaces.models import Makerspace +from apps.makerspaces.module_registry import core_module_keys +from tests.makerspaces.test_core_module_independence import _client, _requester, _staff + +pytestmark = pytest.mark.django_db +CORE = sorted(core_module_keys()) + +# The three transitions that move a reviewed request past `accepted`. +POST_ACCEPT_ROUTES = ("request-assign-box", "request-issue", "request-return") + + +def _accepted_request(slug, modules): + space = Makerspace.objects.create( + name=slug, slug=slug, enabled_modules=sorted(modules), + public_inventory_enabled=True, + ) + product = InventoryProduct.objects.create( + makerspace=space, name="Torque wrench", total_quantity=3, + available_quantity=3, is_public=True, + ) + submit = _client(_requester(slug, space)).post( + reverse("hardware_requests:request-submit", args=[space.slug]), + {"requested_for": "Spine", "items": [{"product_id": product.pk, "quantity": 1}]}, + format="json", + ) + assert submit.status_code == 201, submit.data + staff = _client(_staff(slug)) + pending = staff.get(reverse("hardware_requests:pending-requests", args=[space.id])) + request_id = pending.data["results"][0]["id"] + accept = staff.post( + reverse("hardware_requests:request-accept", args=[request_id]), {}, format="json" + ) + assert accept.status_code == 200, accept.data + return space, staff, request_id + + +def test_core_only_install_can_move_a_reviewed_request_past_accepted(): + """`request_workflow` is CORE; no optional module may gate its transitions.""" + space, staff, request_id = _accepted_request("handover-spine-core-only", CORE) + assert "guest_handover" not in space.enabled_modules + + for name in POST_ACCEPT_ROUTES: + response = staff.post( + reverse(f"hardware_requests:{name}", args=[request_id]), {}, format="json" + ) + # The Hard Rules may still refuse (a box QR scan and an issue photo are + # required) -- but the refusal must never be an optional module's gate. + assert "guest_handover" not in str(response.data), (name, response.data) + + +def test_guest_handover_on_lets_the_same_request_reach_the_evidence_rules(): + """Control: with the module on, the next refusal is the Hard Rule, not the gate.""" + _space, staff, request_id = _accepted_request( + "handover-spine-module-on", CORE + ["guest_handover"] + ) + + response = staff.post( + reverse("hardware_requests:request-issue", args=[request_id]), {}, format="json" + ) + + assert "guest_handover" not in str(response.data) + assert "evidence_id" in str(response.data), response.data + assert HardwareRequest.objects.get(pk=request_id).status == ( + HardwareRequest.Status.ACCEPTED + ) diff --git a/backend/tests/modules/test_offstate_identity.py b/backend/tests/modules/test_offstate_identity.py new file mode 100644 index 00000000..33b24797 --- /dev/null +++ b/backend/tests/modules/test_offstate_identity.py @@ -0,0 +1,292 @@ +"""OFF-state contracts for identity, community, handover, and native sessions.""" + +import pytest +from django.core.cache import cache +from django.test import override_settings +from django.urls import reverse +from django.utils import timezone +from rest_framework.test import APIClient + +from apps.accounts.models import DeviceGrant, User +from apps.hardware_requests.models import HardwareRequest, PublicToolLoan +from apps.inventory.models import InventoryProduct +from apps.makerspaces.models import Makerspace +from apps.makerspaces.module_registry import core_module_keys +from tests.accounts.oidc_browser_helpers import ORIGIN, make_provider, metadata, start +from tests.accounts.test_device_auth import attested_login +from tests.handout_roles import make_handout_member +from tests.makerspaces.test_core_module_independence import ( + configuration_without, + run_loan_spine, +) +from tests.test_admin_direct_loans import ( + authed as handout_client, + direct_payload, + direct_url, + make_product as direct_product, +) + + +pytestmark = pytest.mark.django_db + +CORE = frozenset(core_module_keys()) +IDENTITY_MODULES = ("member_accounts", "membership", "guest_handover", "mobile") +PHONE_START = "/api/v1/auth/phone/login/start" +PASSWORD = "Safe identity password 947!" + + +@pytest.fixture(autouse=True) +def clear_capability_and_throttle_cache(): + """Deployment gates and anonymous throttles share cache across test requests.""" + cache.clear() + yield + cache.clear() + + +def _space(slug, *optional, anonymous=False): + return Makerspace.objects.create( + name=slug, + slug=slug, + enabled_modules=sorted(CORE | set(optional)), + anonymous_requests_enabled=anonymous, + public_inventory_enabled=True, + ) + + +def _account(slug, *, superadmin=False): + return User.objects.create_user( + username=slug, + email=f"{slug}@example.test", + password=PASSWORD, + display_name="Identity Contract User", + access_status=User.AccessStatus.ACTIVE, + email_verified_at=timezone.now(), + role=User.Role.SUPERADMIN if superadmin else User.Role.REQUESTER, + is_staff=superadmin, + is_superuser=superadmin, + ) + + +def _client(user=None): + client = APIClient() + if user is not None: + client.force_authenticate(user) + return client + + +def _product(space): + return InventoryProduct.objects.create( + makerspace=space, + name="Identity test multimeter", + total_quantity=2, + available_quantity=2, + is_public=True, + ) + + +def _request(space, client, *, anonymous=False): + product = _product(space) + payload = { + "requested_for": "Identity off-state check", + "items": [{"product_id": product.pk, "quantity": 1}], + } + headers = {} + if anonymous: + payload.update( + contact_name="Account-less Borrower", + contact_email=f"{space.slug}@example.test", + ) + headers["HTTP_IDEMPOTENCY_KEY"] = f"{space.slug}-request" + return client.post( + reverse("hardware_requests:request-submit", args=[space.slug]), + payload, + format="json", + **headers, + ) + + +def test_member_accounts_off_refuses_phone_login_but_keeps_walk_ins_and_oidc( + monkeypatch, settings +): + """Removing self-service identity must not remove either replacement identity path.""" + # The OIDC browser start refuses any origin that is not registered, so the member + # origin has to be trusted here or the 403 reads as a module gate rather than the + # CORS check it actually is. + settings.CORS_ALLOWED_ORIGINS = [ORIGIN] + space = _space("identity-member-accounts-off") + staff = make_handout_member("identity-front-desk", space) + + phone = _client().post( + PHONE_START, {"phone": "+15550100200"}, format="json" + ) + walk_in = _client(staff).post( + reverse("admin-walk-in-member-create", args=[space.pk]), + {"display_name": "Counter Borrower"}, + format="json", + ) + provider = make_provider() + monkeypatch.setattr( + "apps.accounts.views_oidc_browser.discover", lambda _: metadata(provider) + ) + oidc = start(_client()) + + assert phone.status_code == 404 + assert walk_in.status_code == 201, walk_in.data + assert User.objects.get(pk=walk_in.data["user_id"]).is_walk_in is True + assert oidc.status_code == 200, oidc.data + + +def test_member_accounts_on_allows_the_phone_login_surface(monkeypatch): + _space("identity-member-accounts-on", "member_accounts") + started = [] + monkeypatch.setattr( + "apps.accounts.views_phone.start_login", lambda phone: started.append(phone) + ) + + response = _client().post( + PHONE_START, {"phone": "+15550100201"}, format="json" + ) + + assert response.status_code == 200, response.data + assert started == ["+15550100201"] + + +def test_membership_off_refuses_join_requests_and_on_accepts_them(): + applicant = _account("identity-join-applicant") + off = _space("identity-membership-off") + on = _space("identity-membership-on", "membership") + + refused = _client(applicant).post( + reverse("public-membership-request", args=[off.slug]), {}, format="json" + ) + accepted = _client(applicant).post( + reverse("public-membership-request", args=[on.slug]), {}, format="json" + ) + + assert refused.status_code == 400 + assert "membership is disabled" in str(refused.data) + assert accepted.status_code == 201, accepted.data + + +def test_membership_off_supports_account_and_anyone_request_policies_but_on_requires_members(): + """Only reviewed proposals downgrade; installing membership closes the anonymous path.""" + account_space = _space("identity-account-policy") + account_response = _request( + account_space, _client(_account("identity-account-borrower")) + ) + + anyone_space = _space("identity-anyone-policy", anonymous=True) + anyone_response = _request(anyone_space, _client(), anonymous=True) + + members_space = _space("identity-members-policy", "membership", anonymous=True) + members_space.refresh_from_db() + member_required = _request( + members_space, _client(_account("identity-non-member")) + ) + anonymous_closed = _request(members_space, _client(), anonymous=True) + + assert account_response.status_code == 201, account_response.data + assert anyone_response.status_code == 201, anyone_response.data + assert members_space.anonymous_requests_enabled is False + assert member_required.status_code == 403 + assert member_required.data["code"] == "membership_required" + assert anonymous_closed.status_code == 401 + assert HardwareRequest.objects.filter(makerspace=account_space).count() == 1 + assert HardwareRequest.objects.filter(makerspace=anyone_space).count() == 1 + assert not HardwareRequest.objects.filter(makerspace=members_space).exists() + + +def test_guest_handover_off_refuses_its_own_url_surface_and_on_restores_it(): + """The module owns the `guest-admin/` URL surface, nothing behind it.""" + actor = _account("identity-handover-superadmin", superadmin=True) + off = _space("identity-guest-handover-off") + on = _space("identity-guest-handover-on", "guest_handover") + + refused = _client(actor).get( + reverse("hardware_requests:guest-admin-active-loans", args=[off.pk]) + ) + enabled = _client(actor).get( + reverse("hardware_requests:guest-admin-active-loans", args=[on.pk]) + ) + + assert refused.status_code == 400 + assert "guest_handover is disabled" in str(refused.data) + assert enabled.status_code == 200, enabled.data + + +def test_guest_handover_off_leaves_the_admin_reviewed_request_queues_working(): + """The admin queues are `request_workflow`'s, and the SAME view class serves both URLs. + + Gating the shared view on `guest_handover` let an optional module strand every accepted + request on a core-only install. These three admin routes have no guest-admin twin at + all, so they must never have been gated on it. + """ + actor = _account("identity-handover-admin-queues", superadmin=True) + off = _space("identity-guest-handover-admin-queues") + assert "guest_handover" not in off.enabled_modules + + for name in ("accepted-requests", "active-loans", "request-history"): + response = _client(actor).get( + reverse(f"hardware_requests:{name}", args=[off.pk]) + ) + assert response.status_code == 200, (name, response.status_code, response.data) + + +@override_settings(API_CLIENT_AUTH_REQUIRED=False) +def test_guest_handover_off_keeps_the_action_scoped_staff_handout_path(): + """The module owns the narrow console, not the underlying handout authority.""" + space = _space("identity-staff-handout-substitute") + actor = make_handout_member("identity-handout-actor", space) + product = direct_product(space) + + response = handout_client(actor).post( + direct_url(space), + direct_payload(items=[{"product_id": product.pk, "quantity": 1}]), + format="json", + ) + + assert "guest_handover" not in space.enabled_modules + assert response.status_code == 201, response.data + assert PublicToolLoan.objects.filter(makerspace=space).count() == 1 + + +def test_mobile_off_refuses_a_new_device_grant_but_keeps_web_login( + settings, monkeypatch +): + """Native pairing is optional; the same account must remain usable in a browser.""" + _space("identity-mobile-off", "member_accounts") + user = _account("identity-mobile-off-user") + + device, _ = attested_login(_client(), user, settings, monkeypatch, password=PASSWORD) + browser = _client().post( + "/api/v1/auth/login", + {"username": user.username, "password": PASSWORD, "surface": "member"}, + format="json", + ) + + assert device.status_code == 401 + assert not DeviceGrant.objects.filter(user=user).exists() + assert browser.status_code == 200, browser.data + assert browser.data["surface"] == "member" + + +def test_mobile_on_allows_a_new_attested_device_grant(settings, monkeypatch): + _space("identity-mobile-on", "member_accounts", "mobile") + user = _account("identity-mobile-on-user") + + response, _ = attested_login(_client(), user, settings, monkeypatch, password=PASSWORD) + + assert response.status_code == 200, response.data + assert DeviceGrant.objects.filter(user=user).count() == 1 + + +@pytest.mark.parametrize("missing", IDENTITY_MODULES) +def test_each_identity_module_off_leaves_the_complete_loan_spine_working( + missing, monkeypatch, +): + """Optional identity conveniences cannot become undeclared core-loan dependencies.""" + modules = configuration_without(missing) + assert missing not in modules + run_loan_spine( + f"identity-no-{missing.replace('_', '-')}", modules, monkeypatch + ) diff --git a/backend/tests/modules/test_offstate_inventory.py b/backend/tests/modules/test_offstate_inventory.py new file mode 100644 index 00000000..67ce7013 --- /dev/null +++ b/backend/tests/modules/test_offstate_inventory.py @@ -0,0 +1,207 @@ +"""ON/OFF contracts for optional inventory-lifecycle modules. + +Each add-on owns a staff surface that must disappear when its key is absent, while +the core catalogue and reviewed-request workflow remain usable. Asset units has one +additional contract: quantity-tracked products are the supported off-state path. +""" + +import pytest +from django.urls import reverse +from rest_framework.test import APIClient + +from apps.accounts.models import User +from apps.inventory.models import InventoryAsset, InventoryProduct, TrackingMode +from apps.makerspaces.models import Makerspace +from apps.makerspaces.module_registry import core_module_keys + + +pytestmark = pytest.mark.django_db + +INVENTORY_ADD_ONS = ( + "containers", + "asset_units", + "stock_transfers", + "stocktake", + "procurement", + "bulk_import", +) +CORE_MODULES = frozenset(core_module_keys()) + + +def _space(slug, modules): + return Makerspace.objects.create( + name=slug, + slug=slug, + enabled_modules=sorted(modules), + public_inventory_enabled=True, + ) + + +def _user(slug, *, staff=False): + return User.objects.create_user( + username=slug, + email=f"{slug}@example.test", + display_name="Inventory module test user", + role=User.Role.SUPERADMIN if staff else User.Role.REQUESTER, + is_staff=staff, + is_superuser=staff, + access_status=User.AccessStatus.ACTIVE, + ) + + +def _client(user=None): + client = APIClient() + if user is not None: + client.force_authenticate(user) + return client + + +def _product(space, name="Torque wrench", *, quantity=3): + return InventoryProduct.objects.create( + makerspace=space, + name=name, + total_quantity=quantity, + available_quantity=quantity, + is_public=True, + ) + + +def _exercise_surface(module, space, client): + """Call the smallest real HTTP surface that proves this module is usable.""" + if module == "containers": + return client.get(f"/api/v1/admin/makerspace/{space.id}/containers") + if module == "stock_transfers": + return client.get(f"/api/v1/admin/makerspace/{space.id}/stock-transfers") + if module == "stocktake": + return client.get(f"/api/v1/admin/makerspace/{space.id}/stocktakes") + if module == "procurement": + return client.get(f"/api/v1/procurement/makerspace/{space.id}/to-buy") + if module == "bulk_import": + return client.post( + f"/api/v1/admin/makerspace/{space.id}/inventory/import/preview", + { + "rows": [ + { + "name": "Safety glasses", + "total_quantity": "2", + "available_quantity": "2", + } + ] + }, + format="json", + ) + if module == "asset_units": + product = _product(space, name="Unit-tracked drill", quantity=0) + return client.post( + f"/api/v1/admin/products/{product.id}/assets/generate", + {"count": 1}, + format="json", + ) + raise AssertionError(f"No surface configured for {module}") + + +@pytest.mark.parametrize("module", INVENTORY_ADD_ONS) +def test_each_inventory_add_on_off_refuses_its_surface_and_on_allows_it(module): + """The module error must win before business validation or mutation. + + These views use DRF ValidationError for module gates, whose established API shape + is HTTP 400 with a ``module`` field. The asset assertion additionally proves that + the rejected mutation did not create hidden unit data. + """ + off_space = _space(f"inv-off-{module.replace('_', '-')}", CORE_MODULES) + off_response = _exercise_surface( + module, + off_space, + _client(_user(f"inv-off-staff-{module}", staff=True)), + ) + + assert off_response.status_code == 400 + assert set(off_response.data) == {"module"} + assert module in str(off_response.data["module"]) + if module == "asset_units": + assert not InventoryAsset.objects.filter(makerspace=off_space).exists() + + on_space = _space( + f"inv-on-{module.replace('_', '-')}", + CORE_MODULES | {module}, + ) + on_response = _exercise_surface( + module, + on_space, + _client(_user(f"inv-on-staff-{module}", staff=True)), + ) + + assert on_response.status_code == (201 if module == "asset_units" else 200) + if module == "asset_units": + assert InventoryAsset.objects.filter(makerspace=on_space).count() == 1 + + +def _run_loan_spine(slug, modules): + """Browse -> submit -> staff queue -> accept -> public status.""" + space = _space(slug, modules) + product = _product(space) + requester = _user(f"{slug}-requester") + + catalog = _client().get(reverse("inventory:public-inventory", args=[space.slug])) + assert catalog.status_code == 200, f"catalog: {catalog.status_code} {catalog.data}" + + submitted = _client(requester).post( + reverse("hardware_requests:request-submit", args=[space.slug]), + { + "requested_for": "Inventory module independence", + "items": [{"product_id": product.id, "quantity": 1}], + }, + format="json", + ) + assert submitted.status_code == 201, ( + f"submit: {submitted.status_code} {submitted.data}" + ) + + staff = _client(_user(f"{slug}-staff", staff=True)) + pending = staff.get( + reverse("hardware_requests:pending-requests", args=[space.id]) + ) + assert pending.status_code == 200, f"queue: {pending.status_code} {pending.data}" + assert pending.data["count"] == 1 + + accepted = staff.post( + reverse( + "hardware_requests:request-accept", + args=[pending.data["results"][0]["id"]], + ), + {}, + format="json", + ) + assert accepted.status_code == 200, f"accept: {accepted.status_code} {accepted.data}" + assert accepted.data["status"] == "accepted" + + public_status = _client().get( + reverse( + "hardware_requests:request-status", + args=[submitted.data["public_token"]], + ) + ) + assert public_status.status_code == 200, ( + f"status: {public_status.status_code} {public_status.data}" + ) + return product + + +@pytest.mark.parametrize("missing", INVENTORY_ADD_ONS) +def test_each_inventory_add_on_off_leaves_the_core_loan_spine_working(missing): + """Removing one inventory add-on cannot leak into the core request workflow.""" + enabled = CORE_MODULES | (set(INVENTORY_ADD_ONS) - {missing}) + + _run_loan_spine(f"inv-spine-no-{missing.replace('_', '-')}", enabled) + + +def test_asset_units_off_uses_plain_quantity_tracking_as_the_substitute(): + """Individual QR units are optional; aggregate stock must remain fully lendable.""" + product = _run_loan_spine("inv-quantity-substitute", CORE_MODULES) + + product.refresh_from_db() + assert product.tracking_mode == TrackingMode.QUANTITY + assert product.total_quantity == 3 + assert product.available_quantity == 2 + assert product.reserved_quantity == 1 + assert not product.assets.exists() diff --git a/backend/tests/modules/test_offstate_machines.py b/backend/tests/modules/test_offstate_machines.py new file mode 100644 index 00000000..f7563300 --- /dev/null +++ b/backend/tests/modules/test_offstate_machines.py @@ -0,0 +1,261 @@ +"""OFF-state contracts for machines and the services layered on them.""" + +import pytest +from django.urls import reverse +from rest_framework.test import APIClient + +from apps.accounts.models import User +from apps.inventory.models import InventoryProduct +from apps.machines.models import Machine, MachineServiceRequest, MachineType, ServiceQueue +from apps.machines.printer_capabilities import PRINTER_CONFIG +from apps.makerspaces.models import Makerspace +from apps.makerspaces.module_profiles import RECOMMENDED, profile_modules +from tests.member_submission import active_member_client + + +pytestmark = pytest.mark.django_db + +MACHINE_MODULES = ("machines", "machine_service", "printing", "maintenance") + + +def _space(slug, *, without=None): + modules = set(profile_modules(RECOMMENDED)) + if without is not None: + modules.discard(without) + return Makerspace.objects.create( + name=slug, + slug=slug, + enabled_modules=sorted(modules), + public_inventory_enabled=True, + ) + + +def _enable(space, module_key): + space.enabled_modules = sorted({*(space.enabled_modules or []), module_key}) + space.save(update_fields=["enabled_modules"]) + + +def _account(username, *, superadmin=False): + return User.objects.create_user( + username=username, + email=f"{username}@example.test", + display_name="Machine Module User", + role=User.Role.SUPERADMIN if superadmin else User.Role.REQUESTER, + is_staff=superadmin, + is_superuser=superadmin, + access_status=User.AccessStatus.ACTIVE, + ) + + +def _client(user=None): + client = APIClient() + if user is not None: + client.force_authenticate(user) + return client + + +def _machine(space, suffix="machine", *, public=True): + machine_type = MachineType.objects.create( + makerspace=space, + slug=f"{space.slug}-{suffix}", + name="General machine", + ) + return Machine.objects.create( + makerspace=space, + machine_type=machine_type, + name="Bench machine", + is_public=public, + ) + + +def _printer_queue(space): + printer_type, _ = MachineType.objects.get_or_create( + makerspace=None, + slug="3d_printer", + defaults={ + "name": "3D Printer", + "is_builtin": True, + "capability_config": PRINTER_CONFIG, + }, + ) + return ServiceQueue.objects.create( + makerspace=space, + machine_type=printer_type, + name=f"{space.slug} public prints", + ) + + +def _run_loan_spine(module_key): + """Browse -> propose -> queue -> accept -> status with one assigned key OFF.""" + label = module_key.replace("_", "-") + space = _space(f"machines-off-{label}", without=module_key) + assert module_key not in space.enabled_modules + product = InventoryProduct.objects.create( + makerspace=space, + name="Torque wrench", + total_quantity=3, + available_quantity=3, + is_public=True, + ) + + catalog = _client().get( + reverse("inventory:public-inventory", args=[space.slug]) + ) + assert catalog.status_code == 200, catalog.data + + requester = _account(f"machines-off-{label}-requester") + submitted = _client(requester).post( + reverse("hardware_requests:request-submit", args=[space.slug]), + { + "requested_for": "Module independence check", + "items": [{"product_id": product.pk, "quantity": 1}], + }, + format="json", + ) + assert submitted.status_code == 201, submitted.data + + staff = _client(_account(f"machines-off-{label}-staff", superadmin=True)) + pending = staff.get( + reverse("hardware_requests:pending-requests", args=[space.pk]) + ) + assert pending.status_code == 200, pending.data + assert pending.data["count"] == 1 + + accepted = staff.post( + reverse( + "hardware_requests:request-accept", + args=[pending.data["results"][0]["id"]], + ), + {}, + format="json", + ) + assert accepted.status_code == 200, accepted.data + assert accepted.data["status"] == "accepted" + + public_status = _client().get( + reverse( + "hardware_requests:request-status", + args=[submitted.data["public_token"]], + ) + ) + assert public_status.status_code == 200, public_status.data + + +@pytest.mark.parametrize("module_key", MACHINE_MODULES) +def test_each_machine_optional_module_off_leaves_the_complete_loan_spine_working( + module_key, +): + """Machine capabilities are optional and cannot become loan dependencies.""" + _run_loan_spine(module_key) + + +def test_machines_off_hides_the_public_catalogue_and_on_restores_it(): + space = _space("machines-gate", without="machines") + _machine(space) + url = reverse("public-machines", args=[space.slug]) + + refused = _client().get(url) + assert refused.status_code == 404 + + _enable(space, "machines") + enabled = _client().get(url) + assert enabled.status_code == 200, enabled.data + + +def test_machine_service_off_refuses_submission_and_on_accepts_an_active_member(): + """The member row isolates the module gate from the separate presence policy.""" + space = _space("machine-service-gate", without="machine_service") + _enable(space, "membership") + machine = _machine(space) + _, client = active_member_client(space, "machine-service-gate-member") + url = reverse("public-machine-service-request-submit", args=[space.slug]) + payload = {"machine_id": machine.pk, "title": "Cut acrylic"} + + refused = client.post(url, payload, format="json") + assert refused.status_code == 400 + assert "machine_service is disabled" in str(refused.data) + + _enable(space, "machine_service") + enabled = client.post(url, payload, format="json") + assert enabled.status_code == 201, enabled.data + assert MachineServiceRequest.objects.filter(makerspace=space).count() == 1 + + +def test_printing_off_refuses_the_public_queue_instead_of_leaking_the_surface(): + """RECOMMENDED enables the substrate, not the separately optional printer pack.""" + space = _space("printing-off-gate") + assert "machine_service" in space.enabled_modules + assert "printing" not in space.enabled_modules + _printer_queue(space) + + refused = _client().get( + reverse("public-printer-service-queues", args=[space.slug]) + ) + + assert refused.status_code == 400 + assert "printing" in str(refused.data) + + +def test_printing_on_exposes_the_public_printer_queue(): + space = _space("printing-on-gate") + _enable(space, "printing") + queue = _printer_queue(space) + + enabled = _client().get( + reverse("public-printer-service-queues", args=[space.slug]) + ) + + assert enabled.status_code == 200, enabled.data + assert [row["id"] for row in enabled.data] == [queue.pk] + + +def test_maintenance_off_refuses_logs_and_on_restores_them(): + space = _space("maintenance-gate") + machine = _machine(space) + client = _client(_account("maintenance-gate-staff", superadmin=True)) + url = reverse( + "admin-maintenance-log-list-create", + kwargs={"makerspace_id": space.pk, "machine_id": machine.pk}, + ) + + refused = client.get(url) + assert refused.status_code == 400 + assert "maintenance is disabled" in str(refused.data) + + _enable(space, "maintenance") + enabled = client.get(url) + assert enabled.status_code == 200, enabled.data + + +def test_recommended_machine_service_accepts_an_active_account_without_membership(): + """A default-installed public workflow must not be dead for every ordinary account.""" + space = _space("recommended-machine-service") + assert "machine_service" in space.enabled_modules + assert "membership" not in space.enabled_modules + machine = _machine(space) + + response = _client(_account("recommended-machine-service-user")).post( + reverse("public-machine-service-request-submit", args=[space.slug]), + {"machine_id": machine.pk, "title": "Default profile job"}, + format="json", + ) + + assert response.status_code == 201, response.data + + +def test_recommended_printer_submit_refuses_as_printing_off_before_membership_is_considered(): + """An OFF module must report its own gate, not an unrelated identity requirement.""" + space = _space("recommended-printer-service") + assert "machine_service" in space.enabled_modules + assert "membership" not in space.enabled_modules + assert "printing" not in space.enabled_modules + queue = _printer_queue(space) + + response = _client(_account("recommended-printer-service-user")).post( + reverse("public-printer-service-request", args=[space.slug]), + {"queue_id": queue.pk, "title": "Default profile print"}, + format="json", + ) + + assert response.status_code == 400 + assert "printing" in str(response.data) diff --git a/backend/tests/modules/test_offstate_money.py b/backend/tests/modules/test_offstate_money.py new file mode 100644 index 00000000..778a1676 --- /dev/null +++ b/backend/tests/modules/test_offstate_money.py @@ -0,0 +1,300 @@ +"""ON/OFF contracts for the optional payments module. + +Payments is deliberately asymmetric: OFF suppresses new online charges, but existing +financial rows, provider callbacks, and staff cash reconciliation must remain usable. +""" + +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 as DrfValidationError +from rest_framework.test import APIClient + +from apps.accounts.models import User +from apps.bookings.models import BookableSpace, Booking +from apps.events.models import Event, EventRegistration +from apps.inventory.models import InventoryProduct +from apps.machines.models import ( + Machine, MachineServiceRequest, MachineType, MakerspaceMachineTypePricing, + ServiceBucket, +) +from apps.makerspaces.guards import require_module +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.models import MakerspacePaymentSettings, Payment +from tests.return_helpers import authenticated_client, make_member + + +pytestmark = pytest.mark.django_db + +CORE = frozenset(core_module_keys()) +DOMAIN_MODULES = { + "bookings": {"bookings"}, + "events": {"events"}, + "machines": {"machines", "machine_service"}, + "membership": {"membership"}, +} + + +def _configured_space(slug, domain, *, payments_on): + # Dependency-closed, exactly as `profile_modules` and `install_module` build a set. + # A raw union can express `bookings` without `membership`, which validation rejects. + modules = sorted(with_dependencies(CORE | DOMAIN_MODULES[domain] | {"payments"})) + space = Makerspace.objects.create( + name=slug, + slug=slug, + enabled_modules=modules, + enabled_features=["payments.enabled", f"payments.{domain}"], + public_inventory_enabled=True, + ) + settings = MakerspacePaymentSettings(makerspace=space) + settings.set_stripe_secret_key("sk_test_offstate") + settings.set_stripe_webhook_secret("whsec_test_offstate") + settings.save() + if not payments_on: + # Exercise the production uninstall path, including dependent-feature pruning. + uninstall_module(space, "payments") + return space + + +def _invoke_charge_caller(domain, space, actor): + """Create a valid domain subject, then call its real best-effort payment seam.""" + now = timezone.now() + timedelta(days=1) + if domain == "bookings": + from apps.bookings.service_payments import create_for_confirmed_booking + + bookable = BookableSpace.objects.create( + makerspace=space, name="Paid room", payment_amount=Decimal("10.00") + ) + subject = Booking.objects.create( + space=bookable, + member=actor, + name=actor.username, + email=actor.email, + phone="1", + starts_at=now, + ends_at=now + timedelta(hours=1), + ) + return subject, create_for_confirmed_booking(subject, actor) + if domain == "events": + from apps.events.service_payments import create_for_registered_registration + + event = Event.objects.create( + makerspace=space, + title="Paid event", + starts_at=now, + ends_at=now + timedelta(hours=1), + payment_amount=Decimal("10.00"), + ) + subject = EventRegistration.objects.create( + event=event, + member=actor, + name=actor.username, + email=actor.email, + phone="1", + ) + return subject, create_for_registered_registration(subject, actor) + if domain == "membership": + from apps.makerspaces.membership_payments import create_for_active_membership + + space.membership_dues_amount = Decimal("10.00") + space.save(update_fields=["membership_dues_amount", "updated_at"]) + subject = MakerspaceMembership.objects.get(makerspace=space, user=actor) + return subject, create_for_active_membership(subject, actor) + + from apps.machines.service_payments import create_for_completed_request + + machine_type = MachineType.objects.create( + makerspace=space, slug=f"paid-{space.pk}", name="Paid machine type" + ) + machine = Machine.objects.create( + makerspace=space, machine_type=machine_type, name="Paid machine" + ) + bucket = ServiceBucket.objects.create(machine=machine, name="Service Requests") + subject = MachineServiceRequest.objects.create( + bucket=bucket, + makerspace=space, + requester=actor, + member=actor, + assigned_machine=machine, + title="Paid machine job", + actual_minutes=2, + ) + MakerspaceMachineTypePricing.objects.create( + makerspace=space, + machine_type=machine_type, + rate_per_unit=Decimal("4.00"), + flat_fee=Decimal("2.00"), + payment_enabled=True, + ) + return subject, create_for_completed_request(subject, 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.""" + 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 result is None + assert type(subject).objects.filter(pk=subject.pk).exists() + assert not Payment.objects.filter(makerspace=space).exists() + + +@pytest.mark.parametrize("domain", tuple(DOMAIN_MODULES)) +def test_payments_on_allows_each_online_charge_caller_to_create_its_payment(domain): + """The OFF assertion is meaningful only if the same configured seam works when ON.""" + space = _configured_space(f"money-on-{domain}", domain, payments_on=True) + actor = make_member(f"money-on-{domain}-member", space) + + subject, result = _invoke_charge_caller(domain, space, actor) + + assert online_payments_enabled(space, domain) is True + assert result is not None + assert result.subject_id == subject.pk + assert result.status == Payment.Status.PENDING + + +def test_payments_module_gate_refuses_off_and_accepts_on(): + """Payments uses this gate as a silent new-charge predicate, not an HTTP deletion.""" + space = _configured_space("money-explicit-gate", "bookings", payments_on=False) + + with pytest.raises(DrfValidationError, match="payments is disabled"): + require_module(space, "payments") + + install_module(space, "payments") + assert require_module(space, "payments") == space + + +def _existing_membership_payment(space, actor, *, session_id): + membership = MakerspaceMembership.objects.get(makerspace=space, user=actor) + return Payment.objects.create( + makerspace=space, + subject_type=Payment.SubjectType.MAKERSPACE_MEMBERSHIP, + subject_id=membership.pk, + member=actor, + amount="12.50", + currency="usd", + created_by=actor, + stripe_checkout_session_id=session_id, + ) + + +def test_payments_off_still_lets_the_webhook_settle_an_existing_charge(monkeypatch): + """The provider may have taken money before uninstall; its callback must still win.""" + space = _configured_space("money-webhook-off", "membership", payments_on=True) + actor = make_member("money-webhook-off-member", space) + payment = _existing_membership_payment(space, actor, session_id="cs_offstate") + event = { + "id": "evt_offstate", + "type": "checkout.session.completed", + "data": {"object": {"id": "cs_offstate", "payment_status": "paid"}}, + } + uninstall_module(space, "payments") + monkeypatch.setattr("apps.payments.views.construct_event", lambda *_args: event) + + response = APIClient().post( + reverse("stripe-webhook", args=[space.public_code]), + b"{}", + content_type="application/json", + HTTP_STRIPE_SIGNATURE="verified-by-test-double", + ) + + payment.refresh_from_db() + assert response.status_code == 200 + assert payment.status == Payment.Status.PAID_ONLINE + + +def test_payments_off_still_lets_staff_record_offline_money(): + """Cash reconciliation is the documented substitute when online charging is OFF.""" + space = _configured_space("money-offline-off", "membership", payments_on=True) + manager = make_member("money-offline-off-manager", space) + payment = _existing_membership_payment(space, manager, session_id=None) + uninstall_module(space, "payments") + + response = authenticated_client(manager).post( + reverse( + "payment-reconciliation-mark-offline", + args=[space.pk, payment.pk], + ) + ) + + payment.refresh_from_db() + assert response.status_code == 200 + assert payment.status == Payment.Status.PAID_OFFLINE + + +def test_payments_off_leaves_the_complete_core_loan_spine_working(): + """Billing is optional and must never become an undeclared dependency of lending.""" + space = Makerspace.objects.create( + name="Money off loan spine", + slug="money-off-loan-spine", + enabled_modules=sorted(CORE), + public_inventory_enabled=True, + ) + product = InventoryProduct.objects.create( + makerspace=space, + name="Torque wrench", + total_quantity=3, + available_quantity=3, + is_public=True, + ) + requester = User.objects.create_user( + username="money-off-requester", + email="money-off-requester@example.test", + access_status=User.AccessStatus.ACTIVE, + ) + client = authenticated_client(requester) + + catalog = APIClient().get(reverse("inventory:public-inventory", args=[space.slug])) + assert catalog.status_code == 200, catalog.data + submitted = client.post( + reverse("hardware_requests:request-submit", args=[space.slug]), + { + "requested_for": "Module independence check", + "items": [{"product_id": product.pk, "quantity": 1}], + }, + format="json", + ) + assert submitted.status_code == 201, submitted.data + staff = User.objects.create_user( + username="money-off-staff", + email="money-off-staff@example.test", + role=User.Role.SUPERADMIN, + is_staff=True, + is_superuser=True, + access_status=User.AccessStatus.ACTIVE, + ) + staff_client = authenticated_client(staff) + pending = staff_client.get( + reverse("hardware_requests:pending-requests", args=[space.pk]) + ) + assert pending.status_code == 200, pending.data + assert pending.data["count"] == 1 + accepted = staff_client.post( + reverse( + "hardware_requests:request-accept", + args=[pending.data["results"][0]["id"]], + ), + {}, + format="json", + ) + assert accepted.status_code == 200, accepted.data + assert accepted.data["status"] == "accepted" + public_status = APIClient().get( + reverse( + "hardware_requests:request-status", + args=[submitted.data["public_token"]], + ) + ) + + assert "payments" not in space.enabled_modules + assert public_status.status_code == 200, public_status.data diff --git a/backend/tests/modules/test_offstate_notifications.py b/backend/tests/modules/test_offstate_notifications.py new file mode 100644 index 00000000..c6c8207c --- /dev/null +++ b/backend/tests/modules/test_offstate_notifications.py @@ -0,0 +1,297 @@ +"""Off-state contracts for the notification inbox and outbound channels.""" + +from unittest.mock import Mock + +import pytest +from django.core import mail +from django.test import override_settings +from django.urls import reverse +from rest_framework.test import APIClient + +from apps.accounts.models import User +from apps.boxes.models import Box +from apps.evidence.storage import EvidenceValidationResult +from apps.hardware_requests.models import HardwareRequest +from apps.integrations.dispatch import dispatch_email +from apps.integrations.dispatch_channels import dispatch_channel +from apps.integrations.models import ( + EmailLog, + NotificationChannel, + NotificationDeliveryLog, + NotificationDeliveryStatus, + NotificationFeature, + NotificationPreference, +) +from apps.inventory.models import InventoryProduct +from apps.makerspaces.models import Makerspace +from apps.makerspaces.module_registry import core_module_keys +from apps.notifications.emit import emit_notification +from apps.notifications.models import Notification +from tests.return_helpers import make_issue_evidence, make_return_evidence, return_payload + +pytestmark = pytest.mark.django_db + +CORE = sorted(core_module_keys()) +MODULE_KEYS = frozenset({"notifications", "email", "telegram", "slack", "discord", "mattermost"}) +CHAT_CHANNELS = ("telegram", "slack", "discord", "mattermost") + + +def space(slug, *extra_modules): + return Makerspace.objects.create( + name=slug, slug=slug, enabled_modules=[*CORE, *extra_modules], + public_inventory_enabled=True, + ) + + +def active_user(username, **overrides): + values = { + "email": f"{username}@example.test", + "display_name": username, + "access_status": User.AccessStatus.ACTIVE, + } + values.update(overrides) + return User.objects.create_user(username=username, **values) + + +def client_for(user=None): + client = APIClient() + if user is not None: + client.force_authenticate(user) + return client + + +def staff_user(slug): + return active_user( + f"{slug}-staff", + role=User.Role.SUPERADMIN, + is_staff=True, + is_superuser=True, + ) + + +def enable_only_notification_module(makerspace, module): + makerspace.enabled_modules = [*CORE, module] + makerspace.save(update_fields=["enabled_modules"]) + + +def channel_dispatch(makerspace, channel): + return dispatch_channel( + makerspace=makerspace, + channel=channel, + feature=NotificationFeature.HARDWARE_REQUESTS, + event="submitted", + text_body="A request was submitted.", + sync=True, + )[0] + + +def email_dispatch(makerspace, event="submitted"): + return dispatch_email( + makerspace=makerspace, + to_email="recipient@example.test", + subject="Request submitted", + text_body="A request was submitted.", + stream="hardware", + event=event, + audience="staff", + sync=True, + ) + + +def test_notifications_off_refuses_the_inbox_and_on_alone_exposes_emitted_rows( + django_capture_on_commit_callbacks, +): + """The inbox is a real API surface, while its emitter is a fail-safe no-op off.""" + makerspace = space("offstate-inbox") + staff = staff_user("offstate-inbox") + client = client_for(staff) + url = reverse("notifications:notifications-list", args=[makerspace.pk]) + + with django_capture_on_commit_callbacks(execute=True): + emit_notification(makerspace, title="Suppressed", event="request.submitted") + refused = client.get(url) + + assert refused.status_code == 400 + assert "notifications is disabled" in str(refused.data) + assert not Notification.objects.filter(makerspace=makerspace).exists() + + enable_only_notification_module(makerspace, "notifications") + with django_capture_on_commit_callbacks(execute=True): + emit_notification(makerspace, title="Visible", event="request.submitted") + allowed = client.get(url) + + assert allowed.status_code == 200 + assert allowed.data["count"] == 1 + assert allowed.data["results"][0]["title"] == "Visible" + assert set(makerspace.enabled_modules) & MODULE_KEYS == {"notifications"} + + +@override_settings(EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend") +@pytest.mark.parametrize("module", ["email", *CHAT_CHANNELS]) +def test_each_outbound_channel_off_skips_its_send_and_on_works_without_the_others( + module, monkeypatch, +): + """Channel keys are additive AND gates and have no sibling-channel dependency.""" + makerspace = space(f"offstate-{module}") + + if module == "email": + skipped = email_dispatch(makerspace) + assert skipped.status == EmailLog.Status.SKIPPED + assert mail.outbox == [] + # Return reminders are a duty-of-care exception, not optional tenant mail. + assert email_dispatch(makerspace, "return_reminder").status == EmailLog.Status.SENT + else: + sender = Mock(return_value=True) + monkeypatch.setattr("apps.integrations.dispatch_channels._channel_configured", lambda *args: True) + monkeypatch.setattr( + "apps.integrations.dispatch_channels.limits.reserve_notification_quota", + lambda *args: True, + ) + target = ("apps.integrations.telegram.send_message" if module == "telegram" + else "apps.integrations.webhooks.send_webhook") + monkeypatch.setattr(target, sender) + skipped = channel_dispatch(makerspace, module) + assert skipped.status == NotificationDeliveryStatus.SKIPPED + assert skipped.error == "notification_channel_module_disabled" + sender.assert_not_called() + + enable_only_notification_module(makerspace, module) + + if module == "email": + delivered = email_dispatch(makerspace) + assert delivered.status == EmailLog.Status.SENT + assert len(mail.outbox) == 2 + else: + delivered = channel_dispatch(makerspace, module) + assert delivered.status == NotificationDeliveryStatus.SENT + sender.assert_called_once() + if module == "telegram": + # Telegram is outbound-only: a send cannot smuggle an action keyboard in. + assert "reply_markup" not in sender.call_args.kwargs + + assert set(makerspace.enabled_modules) & MODULE_KEYS == {module} + + +@override_settings(TELEGRAM_WEBHOOK_SECRET="offstate-secret") +def test_telegram_off_still_acknowledges_and_discards_legacy_callbacks(): + """The retained webhook is a compatibility sink, not a Telegram-owned action API.""" + makerspace = space("offstate-telegram-webhook") + request = HardwareRequest.objects.create( + makerspace=makerspace, + requester=active_user("offstate-callback-requester"), + requester_username="offstate-callback-requester", + status=HardwareRequest.Status.PENDING_APPROVAL, + ) + + response = APIClient().post( + reverse("telegram-webhook"), + {"callback_query": {"from": {"id": 42}, "data": f"accept:{request.pk}"}}, + format="json", + HTTP_X_TELEGRAM_BOT_API_SECRET_TOKEN="offstate-secret", + ) + + assert response.status_code == 200 + assert response.data["detail"] == "Ignored." + request.refresh_from_db() + assert request.status == HardwareRequest.Status.PENDING_APPROVAL + + +def test_all_notification_modules_off_leave_submit_issue_and_return_working( + monkeypatch, django_capture_on_commit_callbacks, +): + """Exercise the core spine plus handover with every alert sink deliberately absent. + + Preferences are forced on so each lifecycle event reaches every outbound dispatch + gate. The resulting SKIPPED rows prove suppression happened downstream rather than + by avoiding notification code entirely. + """ + makerspace = space("offstate-loan-spine", "guest_handover") + for channel in ( + NotificationChannel.EMAIL, + NotificationChannel.TELEGRAM, + NotificationChannel.SLACK, + NotificationChannel.DISCORD, + NotificationChannel.MATTERMOST, + ): + NotificationPreference.objects.create( + makerspace=makerspace, + feature=NotificationFeature.HARDWARE_REQUESTS, + channel=channel, + enabled=True, + ) + product = InventoryProduct.objects.create( + makerspace=makerspace, + name="Torque wrench", + total_quantity=2, + available_quantity=2, + is_public=True, + ) + requester = active_user("offstate-spine-requester") + staff = staff_user("offstate-spine") + staff_client = client_for(staff) + monkeypatch.setattr( + "apps.evidence.storage.finalize_upload", + Mock(return_value=EvidenceValidationResult(size=123, content_type="image/jpeg")), + ) + + with django_capture_on_commit_callbacks(execute=True): + catalog = APIClient().get(reverse("inventory:public-inventory", args=[makerspace.slug])) + assert catalog.status_code == 200 + submitted = client_for(requester).post( + reverse("hardware_requests:request-submit", args=[makerspace.slug]), + { + "requested_for": "Off-state contract", + "items": [{"product_id": product.pk, "quantity": 1}], + }, + format="json", + ) + assert submitted.status_code == 201 + queue = staff_client.get(reverse("hardware_requests:pending-requests", args=[makerspace.pk])) + assert queue.status_code == 200 and queue.data["count"] == 1 + request_id = queue.data["results"][0]["id"] + accepted = staff_client.post( + reverse("hardware_requests:request-accept", args=[request_id]), {}, format="json" + ) + assert accepted.status_code == 200 + public_status = APIClient().get( + reverse("hardware_requests:request-status", args=[submitted.data["public_token"]]) + ) + assert public_status.status_code == 200 + assert public_status.data["status"] == HardwareRequest.Status.ACCEPTED + box = Box.objects.create(makerspace=makerspace, label="Off-state box") + assigned = staff_client.post( + reverse("hardware_requests:request-assign-box", args=[request_id]), + {"box_code": box.code}, + format="json", + ) + assert assigned.status_code == 200 + issued = staff_client.post( + reverse("hardware_requests:request-issue", args=[request_id]), + { + "evidence_id": make_issue_evidence(makerspace, staff).pk, + "remark": "Issued with all channels off.", + }, + format="json", + ) + assert issued.status_code == 200 + request = HardwareRequest.objects.get(pk=request_id) + returned = staff_client.post( + reverse("hardware_requests:request-return", args=[request_id]), + return_payload(request, make_return_evidence(makerspace, staff)), + format="json", + ) + assert returned.status_code == 200 + + assert returned.data["status"] == HardwareRequest.Status.RETURNED + assert not Notification.objects.filter(makerspace=makerspace).exists() + assert set(makerspace.enabled_modules) & MODULE_KEYS == set() + assert set(NotificationDeliveryLog.objects.filter(makerspace=makerspace).values_list( + "channel", flat=True + )) == set(CHAT_CHANNELS) + assert not NotificationDeliveryLog.objects.filter( + makerspace=makerspace + ).exclude(status=NotificationDeliveryStatus.SKIPPED).exists() + assert EmailLog.objects.filter(makerspace=makerspace).exists() + assert not EmailLog.objects.filter(makerspace=makerspace).exclude( + status=EmailLog.Status.SKIPPED + ).exists() diff --git a/backend/tests/modules/test_offstate_platform.py b/backend/tests/modules/test_offstate_platform.py new file mode 100644 index 00000000..bbf196dd --- /dev/null +++ b/backend/tests/modules/test_offstate_platform.py @@ -0,0 +1,210 @@ +"""OFF-state contracts for the platform-facing optional modules. + +These modules share infrastructure with core or ungated surfaces. Their switches must +remove only their own capability, never the lending workflow or the neighbouring API. +""" + +import pytest +from django.urls import reverse +from rest_framework.test import APIClient + +from apps.accounts.models import User +from apps.inventory.models import InventoryProduct +from apps.makerspaces.models import Makerspace +from apps.makerspaces.module_profiles import RECOMMENDED, profile_modules +from apps.operations.models import QrPrintBatch + + +pytestmark = pytest.mark.django_db + +PLATFORM_MODULES = ("reports", "qr_print_batches", "updates") + + +def _space(slug, *, without=None): + modules = set(profile_modules(RECOMMENDED)) + if without is not None: + modules.remove(without) + return Makerspace.objects.create( + name=slug, + slug=slug, + enabled_modules=sorted(modules), + public_inventory_enabled=True, + ) + + +def _product(space, name="Torque wrench"): + return InventoryProduct.objects.create( + makerspace=space, + name=name, + total_quantity=3, + available_quantity=3, + is_public=True, + ) + + +def _user(slug, *, superadmin=False): + return User.objects.create_user( + username=slug, + email=f"{slug}@example.test", + display_name="Module Contract User", + role=User.Role.SUPERADMIN if superadmin else User.Role.REQUESTER, + is_staff=superadmin, + is_superuser=superadmin, + access_status=User.AccessStatus.ACTIVE, + ) + + +def _client(user=None): + client = APIClient() + if user is not None: + client.force_authenticate(user) + return client + + +def _enable(space, module_key): + space.enabled_modules = sorted({*(space.enabled_modules or []), module_key}) + space.save(update_fields=["enabled_modules"]) + + +def _run_loan_spine(module_key): + """Browse -> submit -> staff queue -> accept -> public status with one key OFF.""" + label = module_key.replace("_", "-") + space = _space(f"platform-off-{label}", without=module_key) + assert module_key not in space.enabled_modules + product = _product(space) + + catalog = _client().get( + reverse("inventory:public-inventory", args=[space.slug]) + ) + assert catalog.status_code == 200, catalog.data + + requester = _user(f"platform-off-{label}-requester") + submitted = _client(requester).post( + reverse("hardware_requests:request-submit", args=[space.slug]), + { + "requested_for": "Module independence check", + "items": [{"product_id": product.pk, "quantity": 1}], + }, + format="json", + ) + assert submitted.status_code == 201, submitted.data + + staff = _client(_user(f"platform-off-{label}-staff", superadmin=True)) + pending = staff.get( + reverse("hardware_requests:pending-requests", args=[space.pk]) + ) + assert pending.status_code == 200, pending.data + assert pending.data["count"] == 1 + + accepted = staff.post( + reverse( + "hardware_requests:request-accept", + args=[pending.data["results"][0]["id"]], + ), + {}, + format="json", + ) + assert accepted.status_code == 200, accepted.data + assert accepted.data["status"] == "accepted" + + public_status = _client().get( + reverse( + "hardware_requests:request-status", + args=[submitted.data["public_token"]], + ) + ) + assert public_status.status_code == 200, public_status.data + + +@pytest.mark.parametrize("module_key", PLATFORM_MODULES) +def test_each_platform_optional_module_off_leaves_the_complete_loan_spine_working( + module_key, +): + """An optional platform tool must not become an undeclared loan dependency.""" + _run_loan_spine(module_key) + + +def test_reports_off_refuses_analytics_but_leaves_operations_queries_working_and_on_restores_it(): + """The dashboard is an operations surface, not a substitute reports endpoint. + + It deliberately remains useful on lean installs even though analytics and exports + disappear with the reports module. + """ + space = _space("platform-reports-gate", without="reports") + client = _client(_user("platform-reports-manager", superadmin=True)) + analytics_url = reverse("analytics-summary", args=[space.pk]) + export_url = reverse("report-export", args=[space.pk, "damaged-missing"]) + + refused_analytics = client.get(analytics_url) + refused_export = client.get(export_url) + dashboard = client.get(reverse("operations-dashboard", args=[space.pk])) + + assert refused_analytics.status_code == 400 + assert refused_export.status_code == 400 + assert "reports is disabled" in str(refused_analytics.data) + assert "reports is disabled" in str(refused_export.data) + assert dashboard.status_code == 200, dashboard.data + assert dashboard.data["scope_mode"] == "full" + + _enable(space, "reports") + enabled_analytics = client.get(analytics_url) + enabled_export = client.get(export_url) + + assert enabled_analytics.status_code == 200, enabled_analytics.data + assert enabled_export.status_code == 200 + assert enabled_export["Content-Type"].startswith("text/csv") + + +def test_qr_print_batches_off_refuses_batches_but_core_qr_management_works_and_on_restores_batches(): + """Batch ZIP generation is optional; creating a core tool QR can never require it.""" + space = _space("platform-qr-batches-gate", without="qr_print_batches") + product = _product(space, "Multimeter") + client = _client(_user("platform-qr-batches-manager", superadmin=True)) + batch_url = reverse("qr-print-batches", args=[space.pk]) + + refused = client.post(batch_url, {"title": "Bench labels"}, format="json") + core_qr = client.post( + reverse("qr-tools"), + {"makerspace_id": space.pk, "product_id": product.pk}, + format="json", + ) + + assert refused.status_code == 400 + assert "qr_print_batches is disabled" in str(refused.data) + assert not QrPrintBatch.objects.filter(makerspace=space).exists() + assert core_qr.status_code == 201, core_qr.data + + _enable(space, "qr_print_batches") + enabled = client.post(batch_url, {"title": "Bench labels"}, format="json") + + assert enabled.status_code == 201, enabled.data + assert QrPrintBatch.objects.filter(makerspace=space).count() == 1 + + +def test_updates_off_hides_the_updater_but_platform_settings_work_and_on_restores_it(): + """Updates is deployment-wide because its singleton has no tenant foreign key. + + With at least one live space and none enabling updates, only the updater should be + absent; the independent email and login-provider settings must remain reachable. + """ + space = _space("platform-updates-gate", without="updates") + client = _client(_user("platform-updates-superadmin", superadmin=True)) + updater_url = reverse("admin-platform-update-settings") + update_now_url = reverse("admin-platform-update-now") + + refused_settings = client.get(updater_url) + refused_update = client.post(update_now_url) + email_settings = client.get(reverse("admin-platform-email-settings")) + social_settings = client.get(reverse("admin-platform-social-auth-settings")) + + assert refused_settings.status_code == 404 + assert refused_update.status_code == 404 + assert email_settings.status_code == 200, email_settings.data + assert social_settings.status_code == 200, social_settings.data + + _enable(space, "updates") + enabled_settings = client.get(updater_url) + enabled_update = client.post(update_now_url) + + assert enabled_settings.status_code == 200, enabled_settings.data + assert enabled_update.status_code == 202, enabled_update.data diff --git a/backend/tests/modules/test_offstate_reports.py b/backend/tests/modules/test_offstate_reports.py new file mode 100644 index 00000000..54471a82 --- /dev/null +++ b/backend/tests/modules/test_offstate_reports.py @@ -0,0 +1,172 @@ +"""OFF-state contracts for module-owned reporting data.""" + +from decimal import Decimal + +import pytest +from django.urls import reverse +from django.utils import timezone +from rest_framework.test import APIClient + +from apps.accounts.models import User +from apps.inventory.models import InventoryAsset, InventoryProduct +from apps.machines.models import Machine, MachineServiceRequest, MachineType, ServiceQueue +from apps.makerspaces.models import Makerspace +from apps.operations.report_registry import report_definition + + +pytestmark = pytest.mark.django_db + + +def _space(slug, *, without): + space = Makerspace.objects.create(name=slug, slug=slug) + space.enabled_modules.remove(without) + space.save(update_fields=["enabled_modules"]) + return space + + +def _enable(space, module_key): + space.enabled_modules = sorted({*(space.enabled_modules or []), module_key}) + space.save(update_fields=["enabled_modules"]) + + +def _superadmin_client(slug): + user = User.objects.create_user( + username=slug, + email=f"{slug}@example.test", + role=User.Role.SUPERADMIN, + is_staff=True, + is_superuser=True, + access_status=User.AccessStatus.ACTIVE, + ) + client = APIClient() + client.force_authenticate(user) + return client + + +def _completed_print(space): + printer_type = MachineType.objects.get(makerspace__isnull=True, slug="3d_printer") + machine = Machine.objects.create( + makerspace=space, + machine_type=printer_type, + name="Retained printer", + type_payload={"model": "MK4"}, + ) + queue = ServiceQueue.objects.create( + makerspace=space, + machine_type=printer_type, + name="Retained print queue", + ) + requester = User.objects.create_user( + username="offstate-printer-requester", + email="offstate-printer-requester@example.test", + ) + MachineServiceRequest.objects.create( + makerspace=space, + queue=queue, + requester=requester, + requester_name=requester.username, + assigned_machine=machine, + title="Retained print job", + status=MachineServiceRequest.Status.COMPLETED, + actual_minutes=60, + actual_consumed_grams=Decimal("10.00"), + completed_at=timezone.now(), + run_machine_model="MK4", + ) + return machine + + +def test_printing_off_hides_printer_reports_and_on_restores_them(): + space = _space("reports-printing-gate", without="printing") + assert "machine_service" in space.enabled_modules + machine = _completed_print(space) + client = _superadmin_client("reports-printing-superadmin") + export_url = reverse( + "report-export", + kwargs={"makerspace_id": space.id, "report_key": "printer-service"}, + ) + makerspace_url = reverse("admin-makerspace-machine-service-report", args=[space.id]) + aggregate_url = reverse("admin-machine-service-report") + + assert report_definition("printer-service").required_modules == ("printing",) + refused_definition = client.get(export_url, {"format": "csv"}) + refused_branch = client.get(makerspace_url, {"machine_type": "3d_printer"}) + aggregate_off = client.get(aggregate_url, {"machine_type": "3d_printer"}) + + assert refused_definition.status_code == 400 + assert "printing" in str(refused_definition.data) + assert refused_branch.status_code == 400 + assert "printing" in str(refused_branch.data) + assert aggregate_off.status_code == 200, aggregate_off.data + assert aggregate_off.data["printer_metrics"] == [] + + _enable(space, "printing") + enabled_definition = client.get(export_url, {"format": "csv"}) + enabled_branch = client.get(makerspace_url, {"machine_type": "3d_printer"}) + aggregate_on = client.get(aggregate_url, {"machine_type": "3d_printer"}) + + assert enabled_definition.status_code == 200 + assert enabled_branch.status_code == 200, enabled_branch.data + assert [row["machine_id"] for row in enabled_branch.data["printer_metrics"]] == [machine.id] + assert aggregate_on.status_code == 200, aggregate_on.data + assert [row["machine_id"] for row in aggregate_on.data["printer_metrics"]] == [machine.id] + + +def test_membership_off_hides_member_activity_and_on_restores_it(): + space = _space("reports-membership-gate", without="membership") + client = _superadmin_client("reports-membership-superadmin") + makerspace_url = reverse("analytics-member-activity", args=[space.id]) + aggregate_url = reverse("analytics-aggregate", args=["member-activity"]) + + assert report_definition("member-activity").required_modules == ("membership",) + refused = client.get(makerspace_url) + aggregate_off = client.get(aggregate_url) + + assert refused.status_code == 400 + assert "membership" in str(refused.data) + assert aggregate_off.status_code == 200, aggregate_off.data + assert aggregate_off.data["typed_rows"] == [] + + _enable(space, "membership") + enabled = client.get(makerspace_url) + aggregate_on = client.get(aggregate_url) + + assert enabled.status_code == 200, enabled.data + assert enabled.data["typed_rows"][0]["makerspace_name"] == space.name + assert aggregate_on.status_code == 200, aggregate_on.data + assert [row["makerspace_id"] for row in aggregate_on.data["typed_rows"]] == [space.id] + + +def test_asset_units_off_hides_retained_assets_and_on_restores_them(): + space = _space("reports-asset-units-gate", without="asset_units") + product = InventoryProduct.objects.create( + makerspace=space, + name="Retained drill", + total_quantity=0, + available_quantity=0, + ) + InventoryAsset.objects.create( + makerspace=space, + product=product, + asset_tag="RETAINED-1", + ) + client = _superadmin_client("reports-asset-units-superadmin") + makerspace_url = reverse("analytics-summary", args=[space.id]) + aggregate_url = reverse("analytics-aggregate", args=["summary"]) + + hidden = client.get(makerspace_url) + aggregate_off = client.get(aggregate_url) + + assert hidden.status_code == 200, hidden.data + assert hidden.data["assets"] == 0 + assert aggregate_off.status_code == 200, aggregate_off.data + assert aggregate_off.data["assets"] == 0 + + _enable(space, "asset_units") + visible = client.get(makerspace_url) + aggregate_on = client.get(aggregate_url) + + assert visible.status_code == 200, visible.data + assert visible.data["assets"] == 1 + assert aggregate_on.status_code == 200, aggregate_on.data + assert aggregate_on.data["assets"] == 1 diff --git a/backend/tests/operations/test_member_activity_report.py b/backend/tests/operations/test_member_activity_report.py index a3978c6d..4ac6cf9f 100644 --- a/backend/tests/operations/test_member_activity_report.py +++ b/backend/tests/operations/test_member_activity_report.py @@ -10,11 +10,15 @@ from apps.accounts.models import User from apps.makerspaces.models import MakerspaceMembership, MembershipRequest from apps.operations import reports -from apps.operations.report_registry import REPORT_REGISTRY from tests.return_helpers import authenticated_client, make_member, make_space, make_user pytestmark = pytest.mark.django_db +MEMBER_ACTIVITY_FIELDS = ( + "makerspace_name", "membership_policy", "referrals_enabled", "new_members", + "active_members", "revoked_members", "pending_requests", "open_invites", + "referred_joins", "verified_members", +) def test_member_activity_has_one_scoped_row_with_current_snapshot_metrics(): @@ -148,7 +152,7 @@ def test_member_activity_api_is_scoped_and_generic_exports_keep_the_registered_s aggregate = authenticated_client(superadmin).get("/api/v1/admin/analytics/member-activity") assert response.status_code == 200 - assert response.data["rows"][0] == list(REPORT_REGISTRY["member-activity"].fields) + assert response.data["rows"][0] == list(MEMBER_ACTIVITY_FIELDS) assert forbidden.status_code == 404 assert {row["makerspace_id"] for row in aggregate.data["typed_rows"]} == {space.id, other.id} assert all("makerspace_id" not in row for row in response.data["typed_rows"]) @@ -161,8 +165,8 @@ def test_member_activity_api_is_scoped_and_generic_exports_keep_the_registered_s f"/api/v1/admin/reports/member-activity/export?format={fmt}" ) assert per_space.status_code == aggregate_export.status_code == 200 - assert _header(per_space, fmt) == list(REPORT_REGISTRY["member-activity"].fields) - assert _header(aggregate_export, fmt) == ["makerspace_id", *REPORT_REGISTRY["member-activity"].fields] + assert _header(per_space, fmt) == list(MEMBER_ACTIVITY_FIELDS) + assert _header(aggregate_export, fmt) == ["makerspace_id", *MEMBER_ACTIVITY_FIELDS] def _header(response, fmt): diff --git a/backend/tests/operations/test_org_report_aggregation.py b/backend/tests/operations/test_org_report_aggregation.py index 2407a926..6804b666 100644 --- a/backend/tests/operations/test_org_report_aggregation.py +++ b/backend/tests/operations/test_org_report_aggregation.py @@ -82,10 +82,12 @@ def test_row_union_reranks_distinct_products_after_per_space_limit(): def test_weighted_rate_recomputes_and_is_not_mean_of_space_rates(): rows = [ (1, [{"status": "completed", "capacity": 10, "registrations": 1, - "confirmed": 1, "registered": 0, "waitlisted": 0, + "confirmed": 1, "pending_approval": 0, "registered": 0, + "waitlisted": 0, "rejected": 0, "cancelled": 0, "attended": 1, "attendance_rate_percent": 100.0}]), (2, [{"status": "completed", "capacity": 20, "registrations": 9, - "confirmed": 9, "registered": 9, "waitlisted": 0, + "confirmed": 9, "pending_approval": 2, "registered": 9, + "waitlisted": 0, "rejected": 1, "cancelled": 0, "attended": 0, "attendance_rate_percent": 0.0}]), ] @@ -93,6 +95,8 @@ def test_weighted_rate_recomputes_and_is_not_mean_of_space_rates(): assert total["attendance_rate_percent"] == 10.0 assert total["attendance_rate_percent"] != 50.0 + assert total["pending_approval"] == 2 + assert total["rejected"] == 1 def test_global_rank_regroups_same_requester_across_two_spaces(): diff --git a/backend/tests/operations/test_report_coverage_rollups.py b/backend/tests/operations/test_report_coverage_rollups.py new file mode 100644 index 00000000..4d0af1ad --- /dev/null +++ b/backend/tests/operations/test_report_coverage_rollups.py @@ -0,0 +1,174 @@ +from datetime import timedelta + +import pytest +from django.core.exceptions import ValidationError +from django.db import DatabaseError, connection, transaction +from django.urls import reverse +from django.utils import timezone + +from apps.evidence.models import EvidencePhoto +from apps.inventory.models import InventoryAsset, InventoryProduct +from apps.operations.models import ReportMetricRollup, ReportRollupCursor +from apps.operations.report_coverage import REPORT_MODULE_COVERAGE, check_report_module_coverage +from apps.operations.report_registry import report_definition +from apps.operations.report_rollups import finalize_evidence_rollups +from apps.operations.reports_inventory_control import build_inventory_control +from apps.evidence.reports import build_evidence_compliance +from apps.makerspaces.module_registry import MODULE_KEYS +from tests.return_helpers import authenticated_client, make_member, make_space + + +pytestmark = pytest.mark.django_db + + +def test_every_module_has_valid_report_coverage(): + assert set(REPORT_MODULE_COVERAGE) == set(MODULE_KEYS) + assert check_report_module_coverage() == [] + assert report_definition("import-quality").required_action == "edit_inventory" + assert report_definition("communications-health").required_action == "manage_makerspace" + assert report_definition("evidence-compliance").required_modules == ("evidence_uploads",) + assert report_definition("loan-throughput").grains == ("day", "month") + + +def test_rollup_dimensions_reject_person_identifiers(): + space = make_space("report-rollup-dimensions") + rollup = ReportMetricRollup( + makerspace=space, source_module="evidence_uploads", + report_key="evidence-compliance", metric_key="created_count", + bucket_start=timezone.now(), grain=ReportMetricRollup.Grain.DAY, + dimension_key="requester_id=42", dimensions={"requester_id": 42}, + value=1, sample_count=1, revision=1, source_cutoff=timezone.now(), + checksum="a" * 64, + ) + with pytest.raises(ValidationError): + rollup.full_clean() + + +def test_catalog_exposes_disabled_source_without_reading_its_rows(): + space = make_space("report-catalog-modules") + manager = make_member("report-catalog-manager", space) + space.enabled_modules.remove("bulk_import") + space.save(update_fields=["enabled_modules"]) + + response = authenticated_client(manager).get( + reverse("report-catalog", args=[space.id]) + ) + + assert response.status_code == 200, response.data + definitions = {row["key"]: row for row in response.data["results"]} + assert definitions["import-quality"]["available"] is False + assert definitions["loan-throughput"]["available"] is True + + space.enabled_modules.append("bulk_import") + space.save(update_fields=["enabled_modules"]) + enabled = authenticated_client(manager).get( + reverse("report-catalog", args=[space.id]) + ) + assert enabled.status_code == 200, enabled.data + enabled_definitions = {row["key"]: row for row in enabled.data["results"]} + assert enabled_definitions["import-quality"]["available"] is True + + +def test_reports_module_controls_catalog_read_and_export_on_both_sides(): + space = make_space("reports-off-contract") + manager = make_member("reports-off-manager", space) + client = authenticated_client(manager) + catalog_url = reverse("report-catalog", args=[space.id]) + read_url = reverse("analytics-generic", args=[space.id, "loan-throughput"]) + export_url = reverse("report-export", args=[space.id, "loan-throughput"]) + machine_report_url = reverse("admin-makerspace-machine-service-report", args=[space.id]) + + space.enabled_modules.remove("reports") + space.save(update_fields=["enabled_modules"]) + assert client.get(catalog_url).status_code == 400 + assert client.get(read_url).status_code == 400 + assert client.get(export_url, {"format": "csv"}).status_code == 400 + assert client.get(machine_report_url).status_code == 400 + + space.enabled_modules.append("reports") + space.save(update_fields=["enabled_modules"]) + assert client.get(catalog_url).status_code == 200 + assert client.get(read_url).status_code == 200 + assert client.get(export_url, {"format": "csv"}).status_code == 200 + assert client.get(machine_report_url).status_code == 200 + + +def test_composite_inventory_report_gates_retained_asset_rows(): + space = make_space("report-inventory-composite") + product = InventoryProduct.objects.create( + makerspace=space, name="Retained unit", total_quantity=1, available_quantity=1 + ) + InventoryAsset.objects.create( + makerspace=space, product=product, asset_tag="RETAINED-REPORT-1" + ) + space.enabled_modules.remove("asset_units") + space.save(update_fields=["enabled_modules"]) + + hidden = build_inventory_control(space.id) + assert not [row for row in hidden.records if row["module_key"] == "asset_units"] + + space.enabled_modules.append("asset_units") + space.save(update_fields=["enabled_modules"]) + visible = build_inventory_control(space.id) + assert sum(row["count"] for row in visible.records if row["module_key"] == "asset_units") == 1 + + +def test_evidence_rollup_revisions_are_append_only_and_retention_safe(): + space = make_space("report-rollup-history") + manager = make_member("report-rollup-manager", space) + for index in range(4): + EvidencePhoto.objects.create( + makerspace=space, evidence_type=EvidencePhoto.EvidenceType.ISSUE, + object_key=f"evidence/report-rollup-history/issue-{index}.jpg", + content_type="image/jpeg", size_bytes=321, uploaded_by=manager, + ) + through = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0) + timedelta(days=1) + + finalize_evidence_rollups(space, through=through, actor=manager) + before = build_evidence_compliance(space.id) + created = ReportMetricRollup.objects.get( + makerspace=space, metric_key="created_count", revision=1 + ) + cursor = ReportRollupCursor.objects.get( + makerspace=space, source_module="evidence_uploads" + ) + assert cursor.rolled_through == through + assert before.records[0]["created_count"] == 4 + + with pytest.raises(DatabaseError), transaction.atomic(): + ReportMetricRollup.objects.filter(pk=created.pk).update(value=99) + with pytest.raises(DatabaseError), transaction.atomic(): + ReportMetricRollup.objects.filter(pk=created.pk).delete() + + with transaction.atomic(): + with connection.cursor() as cursor_handle: + cursor_handle.execute("SET LOCAL app.allow_immutable_delete = 'on'") + EvidencePhoto.objects.filter(makerspace=space).delete() + + after = build_evidence_compliance(space.id) + assert after.records == before.records + + +def test_late_evidence_attachment_appends_a_higher_revision(): + space = make_space("report-rollup-revision") + manager = make_member("report-rollup-revision-manager", space) + evidence = EvidencePhoto.objects.create( + makerspace=space, evidence_type=EvidencePhoto.EvidenceType.ISSUE, + object_key="evidence/report-rollup-revision/issue.jpg", uploaded_by=manager, + ) + through = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0) + timedelta(days=1) + finalize_evidence_rollups(space, through=through, actor=manager) + + from apps.hardware_requests.models import HardwareRequest + + request = HardwareRequest.objects.create( + makerspace=space, requester=manager, requester_username=manager.username, + issue_evidence=evidence, + ) + assert request.issue_evidence_id == evidence.id + finalize_evidence_rollups(space, through=through, actor=manager) + + revisions = list(ReportMetricRollup.objects.filter( + makerspace=space, metric_key="attached_count" + ).order_by("revision").values_list("revision", "value")) + assert revisions == [(1, 0), (2, 1)] diff --git a/backend/tests/operations/test_report_exports_fablab.py b/backend/tests/operations/test_report_exports_fablab.py index 9005e1f0..e3395385 100644 --- a/backend/tests/operations/test_report_exports_fablab.py +++ b/backend/tests/operations/test_report_exports_fablab.py @@ -10,12 +10,48 @@ from apps.bookings.models import BookableSpace from apps.events.models import Event from apps.machines.models import Machine, MachineType, MachineUsageEntry -from apps.operations.report_registry import REPORT_REGISTRY from tests.return_helpers import authenticated_client, make_member, make_space, make_user pytestmark = pytest.mark.django_db KEYS = ("machine-usage", "event-attendance", "booking-utilization", "maintenance-activity", "fablab-health") +EXPECTED_FIELDS = { + "machine-usage": ( + "machine_id", "machine_name", "machine_type", "is_active", + "usage_entries", "usage_hours", + ), + # Order mirrors the report definition exactly: recurrence provenance, the approval + # lifecycle statuses, then the post-event feedback and certificate counts. + "event-attendance": ( + "event_id", "series_id", "series_title", "series_occurrence_key", + "title", "starts_at", "status", "capacity", + "registrations", "confirmed", "pending_approval", "registered", "waitlisted", + "rejected", "cancelled", "attended", "attendance_rate_percent", + "feedback_responses", "active_certificates", "revoked_certificates", + "organizers", + ), + "booking-utilization": ( + "space_id", "space_name", "kind", "is_active", "booked", "completed", + "no_show", "cancelled", "upcoming", "reserved_hours", "completed_hours", + "window_hours", "reservation_utilization_percent", "no_show_rate_percent", + ), + "maintenance-activity": ( + "machine_id", "machine_name", "machine_type", "is_active", "log_count", + "costed_log_count", "total_cost", "average_cost", "last_performed_at", + "average_interval_days", "active_schedules", "overdue_schedules", + ), + "fablab-health": ( + "events_enabled", "events_available", "events_in_period", + "events_registrations", "events_attended", + "events_completed_attendance_rate_percent", "bookings_enabled", + "bookings_available", "bookings_active_spaces", "bookings_non_cancelled", + "bookings_reserved_hours", "bookings_upcoming", "bookings_no_shows", + "bookings_reservation_utilization_percent", "machines_enabled", + "machines_available", "machines_active", "machines_usage_hours", + "maintenance_enabled", "maintenance_available", "maintenance_logs", + "maintenance_total_cost", "maintenance_overdue_schedules", + ), +} def _seed(slug): @@ -37,7 +73,7 @@ def test_each_new_key_exports_exact_headers_per_space_and_aggregate(key, fmt): f"/api/v1/admin/makerspace/{space.id}/reports/{key}/export?format={fmt}&limit=1" ) assert per.status_code == 200 - assert _header(per, fmt) == list(REPORT_REGISTRY[key].fields) + assert _header(per, fmt) == list(EXPECTED_FIELDS[key]) disabled, _ = _seed(f"export-disabled-{key}-{fmt}") disabled.enabled_modules = [module for module in disabled.enabled_modules if module != "reports"] @@ -51,7 +87,7 @@ def test_each_new_key_exports_exact_headers_per_space_and_aggregate(key, fmt): f"/api/v1/admin/reports/{key}/export?format={fmt}" ) assert aggregate.status_code == 200 - assert _header(aggregate, fmt) == ["makerspace_id", *REPORT_REGISTRY[key].fields] + assert _header(aggregate, fmt) == ["makerspace_id", *EXPECTED_FIELDS[key]] ids = _makerspace_ids(aggregate, fmt) assert space.id in ids assert disabled.id not in ids diff --git a/backend/tests/operations/test_reports_fablab.py b/backend/tests/operations/test_reports_fablab.py index f8abce4f..c7df85a3 100644 --- a/backend/tests/operations/test_reports_fablab.py +++ b/backend/tests/operations/test_reports_fablab.py @@ -49,7 +49,8 @@ def test_detailed_fablab_metrics_and_decimal_contract(): assert usage[0]["usage_hours"] == "2.50" assert usage[0]["is_active"] is False attendance = reports.report_data("event-attendance", space.id)["typed_rows"][0] - assert (attendance["registrations"], attendance["confirmed"], attendance["attendance_rate_percent"]) == (4, 2, 50.0) + assert (attendance["registrations"], attendance["confirmed"], attendance["attendance_rate_percent"]) == (6, 2, 50.0) + assert attendance["pending_approval"] == attendance["rejected"] == 1 booking = reports.report_data("booking-utilization", space.id, date_range=(start, end))["typed_rows"][0] assert booking["reserved_hours"] == "4.00" assert booking["completed_hours"] == "2.00" diff --git a/backend/tests/organizations/test_governance_invitations.py b/backend/tests/organizations/test_governance_invitations.py new file mode 100644 index 00000000..02e73b12 --- /dev/null +++ b/backend/tests/organizations/test_governance_invitations.py @@ -0,0 +1,270 @@ +from concurrent.futures import ThreadPoolExecutor +from datetime import timedelta +from threading import Barrier + +import pytest +from django.db import close_old_connections +from django.urls import reverse +from django.utils import timezone +from rest_framework.test import APIClient + +from apps.accounts import rbac +from apps.accounts.models import User +from apps.audit.models import AuditLog +from apps.organizations import governance +from apps.organizations import services_invitations +from apps.organizations.exceptions import InvitationRedeemed, InvitationRevoked +from apps.organizations.models import Organization, OrganizationInvitation, OrganizationMembership + + +pytestmark = pytest.mark.django_db + + +def user(slug): + return User.objects.create_user( + username=slug, + email=f"{slug}@example.test", + access_status=User.AccessStatus.ACTIVE, + ) + + +def client(actor): + result = APIClient() + result.force_authenticate(actor) + return result + + +def setup_manager(): + org = Organization.objects.create(name="Governed Org", slug="governed-org") + actor = user("org-governor") + OrganizationMembership.objects.create( + organization=org, + user=actor, + governance_actions=[ + governance.MANAGE_ORGANIZATION_MEMBERS, + governance.MANAGE_ORGANIZATION_PROFILE, + ], + granted_actions=[rbac.Action.MANAGE_EVENTS], + ) + return org, actor + + +def create_invitation(org, actor, **overrides): + payload = { + "governance_actions": [governance.MANAGE_ORGANIZATION_PROFILE], + "granted_actions": [rbac.Action.MANAGE_EVENTS], + "expires_in_days": 7, + **overrides, + } + return client(actor).post( + reverse("admin-organization-invitations", kwargs={"pk": org.pk}), + payload, + format="json", + ) + + +def test_invitation_token_is_returned_once_stored_as_digest_and_never_audited(): + org, actor = setup_manager() + response = create_invitation(org, actor) + + assert response.status_code == 201 + token = response.data["token"] + invitation = OrganizationInvitation.objects.get(pk=response.data["id"]) + assert invitation.token_digest != token + assert len(invitation.token_digest) == 64 + audit = AuditLog.objects.get(action="organization.invitation_created") + assert token not in str(audit.meta) + listed = client(actor).get( + reverse("admin-organization-invitations", kwargs={"pk": org.pk}) + ) + assert "token" not in listed.data["results"][0] + assert "token_digest" not in listed.data["results"][0] + + +def test_governance_only_account_can_open_central_staff_session(): + _org, actor = setup_manager() + actor.set_password("central-governance-password") + actor.save(update_fields=["password"]) + + response = APIClient().post( + reverse("auth-login"), + { + "username": actor.username, + "password": "central-governance-password", + "surface": "staff", + }, + format="json", + ) + + assert response.status_code == 200 + assert response.data["surface"] == "staff_api" + assert response.data["user"]["makerspaces"] == [] + + +def test_redeem_is_single_use_and_creates_exact_authority_projection(): + org, actor = setup_manager() + invite = create_invitation(org, actor) + recipient = user("org-recipient") + url = reverse("auth-organization-invitation-redeem") + + redeemed = client(recipient).post(url, {"token": invite.data["token"]}, format="json") + repeated = client(recipient).post(url, {"token": invite.data["token"]}, format="json") + + assert redeemed.status_code == 200 + membership = OrganizationMembership.objects.get(organization=org, user=recipient) + assert membership.governance_actions == [governance.MANAGE_ORGANIZATION_PROFILE] + assert membership.granted_actions == [rbac.Action.MANAGE_EVENTS] + assert repeated.status_code == 409 + assert AuditLog.objects.filter(action="organization.invitation_redeemed").count() == 1 + + +def test_malformed_unknown_and_expired_tokens_have_stable_statuses(): + org, actor = setup_manager() + url = reverse("auth-organization-invitation-redeem") + recipient_client = client(user("invalid-token-recipient")) + + assert recipient_client.post(url, {"token": "short"}, format="json").status_code == 400 + assert recipient_client.post(url, {"token": "x" * 32}, format="json").status_code == 404 + + created = create_invitation(org, actor) + OrganizationInvitation.objects.filter(pk=created.data["id"]).update( + expires_at=timezone.now() - timedelta(seconds=1) + ) + expired = recipient_client.post(url, {"token": created.data["token"]}, format="json") + assert expired.status_code == 409 + assert expired.data["code"] == "invitation_expired" + + +def test_inviter_cannot_escalate_and_redeem_rechecks_changed_grants(): + org, actor = setup_manager() + escalation = create_invitation(org, actor, granted_actions=[rbac.Action.EDIT_INVENTORY]) + assert escalation.status_code == 403 + + invite = create_invitation(org, actor) + manager = OrganizationMembership.objects.get(organization=org, user=actor) + manager.granted_actions = [] + manager.save(update_fields=["granted_actions"]) + response = client(user("late-recipient")).post( + reverse("auth-organization-invitation-redeem"), + {"token": invite.data["token"]}, + format="json", + ) + assert response.status_code == 409 + assert response.data["code"] == "invitation_grant_changed" + + +def test_suspended_membership_is_not_reactivated_and_revocation_wins_before_redeem(): + org, actor = setup_manager() + recipient = user("suspended-recipient") + suspended = OrganizationMembership.objects.create( + organization=org, + user=recipient, + status=OrganizationMembership.Status.SUSPENDED, + ) + first = create_invitation(org, actor) + assert client(recipient).post( + reverse("auth-organization-invitation-redeem"), + {"token": first.data["token"]}, + format="json", + ).status_code == 409 + suspended.refresh_from_db() + assert suspended.status == OrganizationMembership.Status.SUSPENDED + + second = create_invitation(org, actor) + assert client(actor).delete( + reverse("admin-organization-invitation-revoke", kwargs={"pk": second.data["id"]}) + ).status_code == 204 + assert client(user("revoked-recipient")).post( + reverse("auth-organization-invitation-redeem"), + {"token": second.data["token"]}, + format="json", + ).status_code == 409 + + +@pytest.mark.django_db(transaction=True) +def test_concurrent_redemption_consumes_token_and_applies_membership_once(): + org, actor = setup_manager() + invitation, token = services_invitations.create_invitation( + org, + actor=actor, + governance_actions=[governance.MANAGE_ORGANIZATION_PROFILE], + granted_actions=[rbac.Action.MANAGE_EVENTS], + ) + recipient = user("concurrent-recipient") + gate = Barrier(2) + + def redeem(): + close_old_connections() + gate.wait() + try: + services_invitations.redeem_invitation( + token, actor=User.objects.get(pk=recipient.pk) + ) + return "redeemed" + except InvitationRedeemed: + return "already-redeemed" + finally: + close_old_connections() + + with ThreadPoolExecutor(max_workers=2) as pool: + outcomes = sorted(pool.map(lambda _item: redeem(), range(2))) + + invitation.refresh_from_db() + assert outcomes == ["already-redeemed", "redeemed"] + assert invitation.redeemed_by_id == recipient.pk + assert OrganizationMembership.objects.filter( + organization=org, user=recipient + ).count() == 1 + assert AuditLog.objects.filter(action="organization.invitation_redeemed").count() == 1 + + +@pytest.mark.django_db(transaction=True) +def test_concurrent_revoke_and_redeem_have_one_coherent_winner(): + org, actor = setup_manager() + invitation, token = services_invitations.create_invitation( + org, + actor=actor, + governance_actions=[governance.MANAGE_ORGANIZATION_PROFILE], + granted_actions=[], + ) + recipient = user("race-recipient") + gate = Barrier(2) + + def revoke(): + close_old_connections() + gate.wait() + try: + services_invitations.revoke_invitation( + OrganizationInvitation.objects.get(pk=invitation.pk), + actor=User.objects.get(pk=actor.pk), + ) + return "revoked" + except InvitationRedeemed: + return "redeem-won" + finally: + close_old_connections() + + def redeem(): + close_old_connections() + gate.wait() + try: + services_invitations.redeem_invitation( + token, actor=User.objects.get(pk=recipient.pk) + ) + return "redeemed" + except InvitationRevoked: + return "revoke-won" + finally: + close_old_connections() + + with ThreadPoolExecutor(max_workers=2) as pool: + futures = (pool.submit(revoke), pool.submit(redeem)) + outcomes = {future.result() for future in futures} + + invitation.refresh_from_db() + membership_exists = OrganizationMembership.objects.filter( + organization=org, user=recipient + ).exists() + assert outcomes in ({"revoked", "revoke-won"}, {"redeemed", "redeem-won"}) + assert membership_exists is (invitation.redeemed_at is not None) + assert (invitation.revoked_at is None) != (invitation.redeemed_at is None) diff --git a/backend/tests/organizations/test_public_directory.py b/backend/tests/organizations/test_public_directory.py new file mode 100644 index 00000000..4b384187 --- /dev/null +++ b/backend/tests/organizations/test_public_directory.py @@ -0,0 +1,124 @@ +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, EventOrganizer +from apps.makerspaces.models import Makerspace +from apps.organizations.models import Organization + + +pytestmark = pytest.mark.django_db + + +def organization(public=True): + return Organization.objects.create( + name="Public Federation", + slug="public-federation", + description="Shared workshops", + website="https://federation.example.test", + contact_email="private@example.test", + billing_email="billing@example.test", + legal_name="Private Legal Name", + registration_number="SECRET-1", + public_profile_enabled=public, + ) + + +def host(slug, *, events_enabled=True, hidden=False): + return Makerspace.objects.create( + name=slug.title(), + slug=slug, + enabled_modules=["events"] if events_enabled else [], + hidden_from_central_directory=hidden, + # ck_makerspace_hidden_requires_domain: a makerspace hidden from the central + # directory must be reachable on its own domain, or it would be unreachable. + frontend_domain=f"{slug}.example.test" if hidden else None, + ) + + +def event(space, title, *, public=True, status=Event.Status.PUBLISHED, ended=False): + starts = timezone.now() + timedelta(hours=1) + if ended: + starts = timezone.now() - timedelta(hours=2) + return Event.objects.create( + makerspace=space, + title=title, + starts_at=starts, + ends_at=starts + timedelta(hours=1), + is_public=public, + status=status, + ) + + +def test_public_profile_flag_has_closed_and_open_sides_without_sensitive_fields(): + org = organization(public=False) + url = reverse("public-organization-detail", kwargs={"slug": org.slug}) + + assert APIClient().get(url).status_code == 404 + + org.public_profile_enabled = True + org.save(update_fields=["public_profile_enabled"]) + response = APIClient().get(url) + + assert response.status_code == 200 + assert response.data["name"] == org.name + assert response.data["catalogue_links"]["events"].endswith("/events/") + assert not { + "contact_email", "billing_email", "legal_name", "registration_number" + }.intersection(response.data) + + org.is_active = False + org.save(update_fields=["is_active"]) + assert APIClient().get(url).status_code == 404 + + +def test_public_event_catalogue_keeps_host_provenance_and_all_visibility_gates(): + org = organization() + visible_host = host("visible-host", events_enabled=True) + module_off = host("module-off", events_enabled=False) + hidden_host = host("hidden-host", hidden=True) + archived_host = host("archived-host") + archived_host.archived_at = timezone.now() + archived_host.save(update_fields=["archived_at"]) + + included = event(visible_host, "Included") + excluded = [ + event(visible_host, "Private", public=False), + event(visible_host, "Draft", status=Event.Status.DRAFT), + event(visible_host, "Ended", ended=True), + event(module_off, "Module off"), + event(hidden_host, "Hidden host"), + event(archived_host, "Archived host"), + ] + for row in [included, *excluded]: + EventOrganizer.objects.create(event=row, organization=org) + + response = APIClient().get( + reverse("public-organization-events", kwargs={"slug": org.slug}) + ) + + assert response.status_code == 200 + assert response.data["count"] == 1 + assert response.data["results"][0]["title"] == "Included" + assert response.data["results"][0]["host"] == { + "slug": visible_host.slug, + "name": visible_host.name, + "logo_url": None, + } + + +def test_event_module_toggle_hides_without_deleting_organized_event(): + org = organization() + space = host("toggle-host") + organized = event(space, "Retained") + EventOrganizer.objects.create(event=organized, organization=org) + url = reverse("public-organization-events", kwargs={"slug": org.slug}) + + assert APIClient().get(url).data["count"] == 1 + space.enabled_modules = [] + space.save(update_fields=["enabled_modules"]) + assert APIClient().get(url).data["count"] == 0 + assert Event.objects.filter(pk=organized.pk).exists() diff --git a/backend/tests/tenant_migration/materialization_helpers.py b/backend/tests/tenant_migration/materialization_helpers.py index 08c3455a..982563f9 100644 --- a/backend/tests/tenant_migration/materialization_helpers.py +++ b/backend/tests/tenant_migration/materialization_helpers.py @@ -1,6 +1,8 @@ from contextlib import contextmanager from datetime import timedelta +from django.apps import apps +from django.db import connection, transaction from django.utils import timezone from apps.data_export.runner import build_archive @@ -10,9 +12,19 @@ from apps.makerspaces.models import MakerspaceMembership from apps.tenant_migration.keys import collect_source_keys from apps.tenant_migration.models import ImportIdentityDecision, TenantImportJob +from apps.tenant_migration.unique_values import DEPLOYMENT_GLOBAL_UNIQUE_RULES from tests.data_export.portable_helpers import make_job +def _models_with_non_regenerable_identity(): + """Every exported model carrying a value the importer refuses to remint.""" + return [ + apps.get_model(label) + for (label, _rule), policy in DEPLOYMENT_GLOBAL_UNIQUE_RULES.items() + if getattr(policy.generator, "refuses_collision", False) + ] + + @contextmanager def portable_import_case(space, source_user, *, rotate=None, prepare_source=None): get_or_create_active_dek(space.pk) @@ -36,6 +48,8 @@ def portable_import_case(space, source_user, *, rotate=None, prepare_source=None starts_at=timezone.now() + timedelta(days=1), ends_at=timezone.now() + timedelta(days=1, hours=2), created_by=source_user, + registration_requires_approval=True, + registration_cutoff_lead_minutes=45, ) registration = EventRegistration.objects.create( event=event, @@ -87,6 +101,25 @@ class SimpleImportCase: def __init__(self, **values): self.__dict__.update(values) + def release_non_regenerable_identities(self): + """Make this database stand in for a target deployment, not its own source. + + The archive is built from rows that stay in this one test database and is then + imported back as if it came from the ``source-test`` deployment. A real import + never meets its own source — ``pairing`` refuses a same-deployment move — so a + preserved value the target already holds is a contradiction, and the importer + is right to stop rather than remint an immutable identity. Drop the local rows + that carry one, through the same append-only escape hatch a tenant purge uses, + and the import gets the clean target the protocol actually guarantees it. + + Call this after the source objects are captured and before materialization. + """ + with transaction.atomic(): + with connection.cursor() as cursor: + cursor.execute("SET LOCAL app.allow_immutable_delete = 'on'") + for model in _models_with_non_regenerable_identity(): + model._base_manager.all().delete() + def decide_walk_in(self, source_user): return ImportIdentityDecision.objects.create( job=self.job, diff --git a/backend/tests/tenant_migration/programme_graph.py b/backend/tests/tenant_migration/programme_graph.py new file mode 100644 index 00000000..2731294f --- /dev/null +++ b/backend/tests/tenant_migration/programme_graph.py @@ -0,0 +1,204 @@ +"""Realistic phase 1-9 graph shared by backup and tenant-move tests.""" + +from datetime import date, time, timedelta +import hashlib +import json + +from django.utils import timezone + +from apps.audit import services as audit +from apps.evidence.models import ( + EvidenceObjectRetentionState, + EvidencePhoto, + EvidenceRetentionPolicy, +) +from apps.events.models import ( + EventAttendanceCertificate, + EventCheckInEvent, + EventCheckInStationCredential, + EventFeedbackResponse, + EventFeedbackSurvey, + EventOrganizer, + EventSeries, + EventSeriesOrganizer, + MemberCalendarFeed, +) +from apps.operations.models import ReportMetricRollup, ReportRollupCursor +from apps.organizations.models import ( + Organization, + OrganizationInvitation, + OrganizationMakerspace, + OrganizationMembership, +) +from apps.payments.models import Payment + + +QUESTION = { + "id": "rating", + "label": "Rating", + "type": "number", + "options": [], + "required": True, +} + + +def create_programme_graph(space, user, _request): + """Extend ``portable_import_case`` with every phase 3-9 entity.""" + event = space.events.get() + registration = event.registrations.get() + now = timezone.now() + series = EventSeries.objects.create( + makerspace=space, + title="Monthly safety lab", + description="Materialised training series", + recurrence_timezone="UTC", + dtstart_local_date=date.today() + timedelta(days=1), + dtstart_local_time=time(10, 30), + recurrence_rule="FREQ=MONTHLY;COUNT=3", + duration_minutes=90, + capacity=12, + payment_amount="25.00", + registration_requires_approval=True, + registration_cutoff_lead_minutes=60, + is_public=True, + created_by=user, + ) + event.series = series + event.series_occurrence_key = "20260903T103000" + event.series_revision = 1 + event.series_override_fields = ["location"] + event.badge_template = {"label": "Safety graduate"} + event.save() + registration.status = registration.Status.ATTENDED + registration.custom_answers = {"experience": "beginner"} + registration.calendar_sequence = 2 + registration.save() + + check_in = EventCheckInEvent.objects.create( + makerspace=space, + event=event, + registration=registration, + source=EventCheckInEvent.Source.OFFLINE_SYNC, + actor=user, + session_id="55b66883-4e1e-44c3-8cec-5ef91e65d725", + ) + survey = EventFeedbackSurvey.objects.create( + event=event, + title="How was the lab?", + thank_you_text="Thank you", + questions=[QUESTION], + is_open=True, + certificate_enabled=True, + answered_question_ids=["rating"], + opened_at=now, + ) + response = EventFeedbackResponse.objects.create( + survey=survey, + registration=registration, + answers_snapshot=json.dumps({"rating": 5}, sort_keys=True), + certificate_requested=True, + ) + certificate = EventAttendanceCertificate.objects.create( + response=response, + registration=registration, + revision=1, + recipient_name="Archive Member", + event_title=event.title, + event_starts_at=event.starts_at, + event_ends_at=event.ends_at, + issuer_name=space.name, + object_key=f"event-certificates/{space.pk}/certificate.pdf", + ) + payment = Payment.objects.create( + makerspace=space, + subject_type=Payment.SubjectType.EVENT_REGISTRATION, + subject_id=registration.pk, + member=user, + via_makerspace=space, + subject_label=event.title, + amount="25.00", + currency="usd", + status=Payment.Status.PAID_OFFLINE, + created_by=user, + ) + photo = EvidencePhoto.objects.create( + makerspace=space, + evidence_type=EvidencePhoto.EvidenceType.ISSUE, + object_key=f"evidence/{space.pk}/expired.jpg", + uploaded_by=user, + ) + EvidenceRetentionPolicy.objects.create(makerspace=space, object_retention_days=30) + expired = EvidenceObjectRetentionState.objects.create( + evidence=photo, + status=EvidenceObjectRetentionState.Status.EXPIRED, + object_expired_at=now - timedelta(minutes=5), + expired_size_bytes=321, + ) + rollup = ReportMetricRollup.objects.create( + makerspace=space, + source_module="events", + report_key="event_attendance", + metric_key="attended", + bucket_start=now.replace(hour=0, minute=0, second=0, microsecond=0), + grain=ReportMetricRollup.Grain.DAY, + dimension_key="status=attended", + dimensions={"status": "attended"}, + value="1.000000", + sample_count=1, + source_cutoff=now, + checksum="a" * 64, + ) + ReportRollupCursor.objects.create( + makerspace=space, source_module="events", rolled_through=now + ) + + organization = Organization.objects.create( + name="Archive Guild", slug=f"archive-guild-{space.pk}", + public_profile_enabled=True, created_by=user, + ) + OrganizationMakerspace.objects.create( + organization=organization, makerspace=space, + relationship=OrganizationMakerspace.Relationship.OWNER, created_by=user, + ) + OrganizationMembership.objects.create( + organization=organization, user=user, + granted_actions=["events.manage"], governance_actions=["organizations.manage"], + created_by=user, + ) + OrganizationInvitation.objects.create( + organization=organization, + token_digest=hashlib.sha256( + f"one-time-invitation:{space.pk}".encode() + ).hexdigest(), + granted_actions=["events.manage"], + expires_at=now + timedelta(days=1), created_by=user, + ) + series_organizer = EventSeriesOrganizer.objects.create( + series=series, organization=organization, created_by=user + ) + EventOrganizer.objects.create( + event=event, organization=organization, created_by=user, + source_series_organizer=series_organizer, + ) + membership = space.memberships.get(user=user) + MemberCalendarFeed.objects.create( + membership=membership, + # token_digest is GLOBALLY unique, so it must be scoped per makerspace exactly + # like the invitation digest above -- the graph is built for more than one tenant. + token_digest=hashlib.sha256(f"calendar-token:{space.pk}".encode()).digest(), + token_hint="deadbeef", + ) + EventCheckInStationCredential.objects.create( + event=event, pin_digest="pbkdf2$fixture", pin_ciphertext=b"ciphertext", version=3 + ) + audit_row = audit.record( + user, "events.programme_fixture_created", makerspace=space, + target=check_in, + ) + return { + "series": series, "event": event, "registration": registration, + "check_in": check_in, "survey": survey, "response": response, + "certificate": certificate, "payment": payment, "photo": photo, + "expired": expired, "rollup": rollup, "organization": organization, + "audit": audit_row, + } diff --git a/backend/tests/tenant_migration/test_authority_dispositions_d8.py b/backend/tests/tenant_migration/test_authority_dispositions_d8.py index 9816b2a5..b0378d9d 100644 --- a/backend/tests/tenant_migration/test_authority_dispositions_d8.py +++ b/backend/tests/tenant_migration/test_authority_dispositions_d8.py @@ -99,6 +99,8 @@ def _fields(label, names, dispositions): *_fields("bookings.Booking", "public_token", D.RESET), *_fields("events.Event", "is_public status", D.PRESERVE), *_fields("events.Event", "public_token", D.RESET), + *_fields("events.EventSeries", "is_public status", D.PRESERVE), + *_fields("events.EventSeries", "public_token", D.RESET), *_fields( "events.EventRegistration", "checkin_token registered_via_makerspace payment_via_makerspace", diff --git a/backend/tests/tenant_migration/test_events_programme_registry_contract.py b/backend/tests/tenant_migration/test_events_programme_registry_contract.py new file mode 100644 index 00000000..6e2165bd --- /dev/null +++ b/backend/tests/tenant_migration/test_events_programme_registry_contract.py @@ -0,0 +1,71 @@ +"""Executable enumeration of every phase 1-9 model transport decision.""" + +from apps.data_export.classification import ( + EXPORTED_MODELS, + GLOBAL_MODELS, + OMITTED_MODELS, +) +from apps.data_export.datasets import DATASET_SPECS +from apps.data_export.guards import validate_all +from apps.tenant_migration.tenant_dump_catalog import validate_catalog +from apps.tenant_migration.tenant_dump_model_catalog import FIRST_PARTY_MODEL_RULES +from apps.tenant_migration.tenant_dump_types import ModelDisposition +from apps.makerspaces.module_registry import BY_KEY as MODULES + + +PROJECT = { + "events.EventSeries", + "events.Event", + "events.EventRegistration", + "events.EventCheckInEvent", + "events.EventFeedbackSurvey", + "events.EventFeedbackResponse", + "events.EventAttendanceCertificate", + "evidence.EvidencePhoto", + "evidence.EvidenceRetentionPolicy", + "evidence.EvidenceObjectRetentionState", + "operations.ReportMetricRollup", +} + +DROP = { + "events.EventSeriesCollaborator", + "events.EventCollaborator", + "events.EventSeriesOrganizer", + "events.EventOrganizer", + "events.MemberCalendarFeed", + "events.EventCheckInStationCredential", + "organizations.Organization", + "organizations.OrganizationMakerspace", + "organizations.OrganizationMembership", + "organizations.OrganizationInvitation", + "operations.ReportRollupCursor", +} + +EXPORTED_THEN_DROPPED = { + "events.EventSeriesCollaborator", + "events.EventCollaborator", +} + + +def test_every_programme_model_has_the_reviewed_export_and_lane_d_contract(): + validate_all() + validate_catalog() + + assert PROJECT <= EXPORTED_MODELS + assert PROJECT <= set(DATASET_SPECS) + assert EXPORTED_THEN_DROPPED <= EXPORTED_MODELS + assert EXPORTED_THEN_DROPPED <= set(DATASET_SPECS) + assert DROP - EXPORTED_THEN_DROPPED - {"organizations.Organization"} <= set(OMITTED_MODELS) + assert "organizations.Organization" in GLOBAL_MODELS + assert { + label + for label in PROJECT | DROP + if FIRST_PARTY_MODEL_RULES[label].disposition == ModelDisposition.PROJECT + } == PROJECT + assert { + label + for label in PROJECT | DROP + if FIRST_PARTY_MODEL_RULES[label].disposition == ModelDisposition.DROP + } == DROP + # Organizations are deployment-global authority, not a tenant-toggleable module. + assert "organizations" not in MODULES diff --git a/backend/tests/tenant_migration/test_events_programme_roundtrip.py b/backend/tests/tenant_migration/test_events_programme_roundtrip.py new file mode 100644 index 00000000..7175b825 --- /dev/null +++ b/backend/tests/tenant_migration/test_events_programme_roundtrip.py @@ -0,0 +1,175 @@ +"""End-to-end portable tenant move for the complete events programme graph.""" + +import hashlib +import json + +import pytest + +from apps.backup import storage as backup_storage +from apps.audit.models import AuditLog +from apps.evidence.models import EvidenceObjectRetentionState, EvidencePhoto, EvidenceRetentionPolicy +from apps.events.models import ( + EventAttendanceCertificate, + EventCheckInEvent, + EventCheckInStationCredential, + EventFeedbackResponse, + EventFeedbackSurvey, + EventOrganizer, + EventSeries, + EventSeriesOrganizer, + MemberCalendarFeed, +) +from apps.makerspaces.models import Makerspace, default_enabled_modules +from apps.operations.models import ReportMetricRollup, ReportRollupCursor +from apps.organizations.models import OrganizationMakerspace +from apps.payments.models import Payment +from apps.tenant_migration.materialization import materialize_tenant +from apps.tenant_migration.object_export import capture_tenant_objects +from tests.data_export.portable_helpers import make_space, make_user +from tests.encryption.conftest import enabled_encryption +from tests.tenant_migration.materialization_helpers import portable_import_case +from tests.tenant_migration.object_helpers import memory_objects +from tests.tenant_migration.programme_graph import QUESTION, create_programme_graph + + +pytestmark = pytest.mark.django_db(transaction=True) +CERTIFICATE_BYTES = b"%PDF-1.7\nprogramme certificate\n%%EOF\n" + + +def test_complete_programme_graph_round_trips_with_target_pii_and_expiry( + memory_objects, monkeypatch +): + with enabled_encryption(): + user = make_user("programme-roundtrip") + source = make_space("programme-roundtrip") + source.enabled_modules = [ + "membership", "events", "bookings", "reports", "evidence_uploads", + ] + source.save(update_fields=("enabled_modules",)) + with portable_import_case( + source, user, prepare_source=create_programme_graph + ) as case: + case.decide_walk_in(user) + graph = case.source_data + + def download(_bucket, key, destination, *, versioned): + assert key == graph["certificate"].object_key + assert versioned is True + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_bytes(CERTIFICATE_BYTES) + return { + "size": len(CERTIFICATE_BYTES), + "sha256": hashlib.sha256(CERTIFICATE_BYTES).hexdigest(), + "version_id": "certificate-v1", + "content_type": "application/pdf", + } + + absent = [] + monkeypatch.setattr(backup_storage, "download_object", download) + monkeypatch.setattr( + backup_storage, + "assert_object_absent", + lambda bucket, key: absent.append((bucket, key)), + ) + records = capture_tenant_objects( + case.root, source, + {"private": "versioned", "public_image": "versioned"}, + ) + tombstone = next(row for row in records if row.get("retention_state")) + assert tombstone["source_key"] == graph["photo"].object_key + assert tombstone["expired_size_bytes"] == 321 + assert len(absent) == 2 + case.release_non_regenerable_identities() + result = materialize_tenant( + case.root, + case.job, + case.carried, + target_identity={"name": "Moved Programme", "slug": "moved-programme"}, + batch_size=2, + ) + + target = Makerspace.objects.get(pk=result.target_makerspace_id) + series = EventSeries.objects.get(makerspace=target) + event = target.events.get() + registration = event.registrations.get() + check_in = EventCheckInEvent.objects.get(makerspace=target) + survey = EventFeedbackSurvey.objects.get(event=event) + response = EventFeedbackResponse.objects.get(survey=survey) + certificate = EventAttendanceCertificate.objects.get(registration=registration) + + # The programme's DATA travels; its module installation does not. Enabling a + # module is target superadmin policy (TARGET_FIELD_PROJECTION resolves + # enabled_modules from the registry), so the import lands on the target's own + # opt-in set and a superadmin installs `events` afterwards. + assert target.enabled_modules == default_enabled_modules() + assert target.enabled_modules != source.enabled_modules + assert (series.title, series.recurrence_rule, series.duration_minutes) == ( + "Monthly safety lab", "FREQ=MONTHLY;COUNT=3", 90, + ) + assert (event.series_id, event.series_occurrence_key, event.series_revision) == ( + series.pk, "20260903T103000", 1, + ) + assert registration.member_id == target.memberships.get().user_id + assert registration.registered_via_makerspace_id == target.pk + assert registration.payment_via_makerspace_id == target.pk + assert registration.checkin_token != graph["registration"].checkin_token + assert (registration.name, registration.email, registration.phone) == ( + "Archive Member", "member@example.test", "+15550001111", + ) + assert check_in.event_id == event.pk + assert check_in.registration_id == registration.pk + assert check_in.actor_id == registration.member_id + # Both identities are preserved rather than reminted: the operation UUID is the + # provenance of an immutable check-in, and the serial is printed inside the PDF. + assert check_in.operation_id == graph["check_in"].operation_id + assert certificate.serial == graph["certificate"].serial + assert survey.questions == [QUESTION] + assert json.loads(response.answers_snapshot) == {"rating": 5} + assert response.registration_id == registration.pk + assert certificate.response_id == response.pk + assert certificate.recipient_name == "Archive Member" + # The private certificate key is preserved, not reminted: the target deployment + # holds no object under it, and the PDF the key names is the immutable artifact + # the serial is printed inside. Only an actual target collision regenerates it. + assert certificate.object_key == graph["certificate"].object_key + assert memory_objects["private"][certificate.object_key] == CERTIFICATE_BYTES + + payment = Payment.objects.get(makerspace=target) + assert payment.subject_type == Payment.SubjectType.EVENT_REGISTRATION + assert payment.subject_id == registration.pk + assert payment.member_id == registration.member_id + assert payment.via_makerspace_id == target.pk + assert str(payment.amount) == "25.00" + + photo = EvidencePhoto.objects.get(makerspace=target) + state = EvidenceObjectRetentionState.objects.get(evidence=photo) + assert state.status == EvidenceObjectRetentionState.Status.EXPIRED + assert state.expired_size_bytes == 321 + assert state.object_expired_at == graph["expired"].object_expired_at + assert EvidenceRetentionPolicy.objects.get(makerspace=target).object_retention_days == 30 + assert photo.object_key not in memory_objects["private"] + + rollup = ReportMetricRollup.objects.get(makerspace=target) + assert (rollup.source_module, rollup.metric_key, rollup.sample_count) == ( + "events", "attended", 1, + ) + assert str(rollup.value) == "1.000000" + imported_audit = AuditLog.objects.get( + makerspace=target, action="events.programme_fixture_created" + ) + # An imported audit row never names a live target actor: attributing a source + # action to a target account would forge immutable attribution on a deployment + # that never saw it (semantic_remap._remap_audit). Its TARGET is still remapped, + # so the row keeps pointing at the check-in it describes. + assert imported_audit.actor_id is None + assert imported_audit.target_type == "events.eventcheckinevent" + assert imported_audit.target_id == str(check_in.pk) + assert imported_audit.meta == {} + + # Mutable/bearer/global authority is intentionally reissued, never resurrected. + assert not MemberCalendarFeed.objects.filter(membership__makerspace=target).exists() + assert not EventCheckInStationCredential.objects.filter(event=event).exists() + assert not EventSeriesOrganizer.objects.filter(series=series).exists() + assert not EventOrganizer.objects.filter(event=event).exists() + assert not OrganizationMakerspace.objects.filter(makerspace=target).exists() + assert not ReportRollupCursor.objects.filter(makerspace=target).exists() diff --git a/backend/tests/tenant_migration/test_evidence_retention_objects.py b/backend/tests/tenant_migration/test_evidence_retention_objects.py new file mode 100644 index 00000000..5d1fc3e6 --- /dev/null +++ b/backend/tests/tenant_migration/test_evidence_retention_objects.py @@ -0,0 +1,54 @@ +from pathlib import Path + +import pytest + +from apps.tenant_migration.object_import import _validate_record +from apps.tenant_migration.insertion_errors import ArchiveFormatError +from apps.tenant_migration.tenant_dump_objects import package_staged_objects + + +def tombstone(): + return { + "bucket_kind": "private", + "source_key": "evidence/1/photo.jpg", + "size": 0, + "sha256": "", + "version_id": None, + "content_type": "", + "retention_state": "expired", + "object_expired_at": "2026-09-02T10:00:00+00:00", + "expired_size_bytes": 789, + } + + +def test_portable_manifest_accepts_only_a_complete_expiry_tombstone(): + _validate_record(tombstone(), 1) + incomplete = tombstone() + incomplete.pop("object_expired_at") + + with pytest.raises(ArchiveFormatError, match="Incomplete expiry tombstone"): + _validate_record(incomplete, 1) + + +def test_lane_d_tombstone_packages_without_object_bytes(tmp_path): + entry = { + **tombstone(), + "original_key": "evidence/1/photo.jpg", + "member_path": None, + } + + manifest = package_staged_objects(tmp_path / "capture", tmp_path / "bundle", [entry]) + + assert manifest == ({ + "bucket_kind": "private", + "member_path": None, + "original_key": "evidence/1/photo.jpg", + "version_id": None, + "size": 0, + "content_type": "", + "sha256": "", + "retention_state": "expired", + "object_expired_at": "2026-09-02T10:00:00+00:00", + "expired_size_bytes": 789, + },) + assert not Path(tmp_path / "bundle" / "objects").exists() diff --git a/backend/tests/tenant_migration/test_materialization_roundtrip.py b/backend/tests/tenant_migration/test_materialization_roundtrip.py index 290de26e..403ea457 100644 --- a/backend/tests/tenant_migration/test_materialization_roundtrip.py +++ b/backend/tests/tenant_migration/test_materialization_roundtrip.py @@ -6,7 +6,7 @@ from apps.boxes.models import QrCode, QrScanEvent from apps.encryption.crypto import parse_envelope from apps.encryption.services import rotate_dek -from apps.events.models import EventRegistration +from apps.events.models import Event, EventRegistration from apps.hardware_requests.models import ( HardwareRequest, HardwareRequestItemAsset, @@ -72,6 +72,10 @@ def test_portable_archive_round_trips_into_a_new_tenant_with_target_aad(): assert imported.requester_name == "Archive Member" assert parse_envelope(target_envelope)[0] == parse_envelope(source_envelope)[0] == 1 assert target.encryption_keys.get(status="active").version == 2 + imported_event = Event.objects.get(makerspace=target) + assert imported_event.registration_requires_approval is True + assert imported_event.registration_cutoff_at is None + assert imported_event.registration_cutoff_lead_minutes == 45 assert EventRegistration.objects.get(event__makerspace=target).email == "member@example.test" membership = MakerspaceMembership.objects.get(makerspace=target) assert membership.assigned_role == target.roles.get(slug="member") diff --git a/backend/tests/tenant_migration/test_projected_catalog_travel_guard.py b/backend/tests/tenant_migration/test_projected_catalog_travel_guard.py new file mode 100644 index 00000000..8751e5da --- /dev/null +++ b/backend/tests/tenant_migration/test_projected_catalog_travel_guard.py @@ -0,0 +1,68 @@ +"""Catalog-driven guard on the SHAPE of every model that travels. + +The registry contracts next door prove a model is *classified*. They do not prove an +import can insert it. Two shapes break a move silently, and each was caught only by +running a full round-trip after the model had already shipped: + +* a primary key the importer cannot reserve target values for -- both evidence + retention models shipped with ``OneToOneField(primary_key=True)``, which exports no + ``id`` column at all and raises ``UnsupportedPrimaryKey`` mid-move; and +* a deployment-global unique column with no collision rule, which violates its own + constraint the first time a target deployment already holds the value. + +Both checks enumerate ``PROJECTED_MODEL_LABELS``, so a newly projected model inherits +them instead of waiting for somebody to write it a bespoke round-trip test. Neither +touches the database -- this is model metadata only. +""" + +from django.apps import apps + +from apps.tenant_migration.pk_maps import unsupported_primary_key_reason +from apps.tenant_migration.tenant_dump_model_catalog import PROJECTED_MODEL_LABELS +from apps.tenant_migration.unique_values import DEPLOYMENT_GLOBAL_UNIQUE_RULES + +# ``accounts.User`` is reconciled by ``identity_resolution.allocate_username``, which +# mints a fresh non-colliding username for every imported member. Its uniqueness is +# therefore resolved before insertion and deliberately carries no rule here. Anything +# else appearing in this set is an unhandled collision waiting for a real move. +UNIQUE_RESOLVED_BY_IDENTITY = {("accounts.User", "username")} + + +def test_every_projected_model_primary_key_is_importable(): + # The predicate comes from pk_maps itself, so this asserts what reservation + # really requires. A type-only check here would accept a UUID primary key that + # mints no default and still dies mid-move. + unsupported = {} + for label in sorted(PROJECTED_MODEL_LABELS): + reason = unsupported_primary_key_reason(apps.get_model(label)) + if reason is not None: + unsupported[label] = reason + + assert unsupported == {}, ( + "these models are projected but the importer cannot reserve their primary " + "keys, so a tenant move raises UnsupportedPrimaryKey; prefer a normal auto " + f"primary key plus a unique OneToOne: {unsupported}" + ) + + +def test_every_globally_unique_projected_column_has_a_collision_rule(): + unruled = set() + for label in sorted(PROJECTED_MODEL_LABELS): + for field in apps.get_model(label)._meta.get_fields(): + # Relational uniqueness is scoped by the row it points at, not by the + # deployment, so a OneToOne cannot collide the way a bare column does. + if field.is_relation or getattr(field, "primary_key", False): + continue + if not getattr(field, "unique", False): + continue + if (label, f"field:{field.name}") in DEPLOYMENT_GLOBAL_UNIQUE_RULES: + continue + unruled.add((label, field.name)) + + assert unruled == UNIQUE_RESOLVED_BY_IDENTITY, ( + "every deployment-global unique column on a projected model needs a " + "DEPLOYMENT_GLOBAL_UNIQUE_RULES entry deciding REGENERATE vs " + "PRESERVE-and-refuse, or an explicit identity-resolution exemption; " + f"unruled: {sorted(unruled - UNIQUE_RESOLVED_BY_IDENTITY)}, " + f"stale exemptions: {sorted(UNIQUE_RESOLVED_BY_IDENTITY - unruled)}" + ) diff --git a/backend/tests/test_email_templates_registry.py b/backend/tests/test_email_templates_registry.py index 3b83a763..d9e8ec8c 100644 --- a/backend/tests/test_email_templates_registry.py +++ b/backend/tests/test_email_templates_registry.py @@ -25,9 +25,10 @@ def test_registry_declares_all_send_path_keys(): *{("hardware", "staff", key) for key in HARDWARE_STAFF_KEYS}, *{("printing", "requester", key) for key in PRINTING_REQUESTER_KEYS}, *{("printing", "staff", key) for key in PRINTING_STAFF_KEYS}, - # The four FabLab streams cover both audiences for every event (20 x 2). Derived - # from the same table the registry builds from, so this stays a guard against a - # key existing with no send path rather than a number to bump. + # The four FabLab streams cover both audiences for every event (24 x 2 = 48; + # events 11, bookings 6, maintenance 5, membership 2). Derived from the same + # table the registry builds from, so this stays a guard against a key existing + # with no send path rather than a number to bump. *{ (stream, audience, key) for stream, keys in FABLAB_STREAM_KEYS.items() @@ -36,7 +37,7 @@ def test_registry_declares_all_send_path_keys(): }, } - assert len(REGISTRY) == 27 + 40 + assert len(REGISTRY) == 27 + 48 assert all_send_keys() == expected diff --git a/backend/tests/test_module_program_security_p11.py b/backend/tests/test_module_program_security_p11.py index d6a31a53..02bbc1c6 100644 --- a/backend/tests/test_module_program_security_p11.py +++ b/backend/tests/test_module_program_security_p11.py @@ -312,11 +312,12 @@ def test_the_switch_row_is_never_written_by_an_anonymous_request(): def test_uninstalling_membership_closes_the_profile_surfaces_but_keeps_the_data(): from apps.makerspaces.module_install import uninstall_module + from tests.module_helpers import disable_module space = make_space("profile-uninstall") membership = member_of(space, "author") profile_services.save_profile(membership, {"is_visible": True, "bio": "Kept"}) - uninstall_module(space, "membership") + disable_module(space, "membership") client = authed(membership.user) assert client.get(f"/api/v1/member/makerspaces/{space.pk}/profile").status_code == 400 diff --git a/backend/tests/test_notification_templates.py b/backend/tests/test_notification_templates.py index 242c4ff4..04b9b03a 100644 --- a/backend/tests/test_notification_templates.py +++ b/backend/tests/test_notification_templates.py @@ -2,7 +2,7 @@ The security assertion in this file is `test_chat_rendering_refuses_requester_content`: a chat channel is a ROOM, so member-facing wording must never be routable to one. The -rest is coverage — 20 events x 2 audiences must each render against their own declared +rest is coverage — 24 events x 2 audiences must each render against their own declared sample context, because a template that only fails at send time fails in production. """ @@ -40,12 +40,12 @@ def make_space(slug): ] -# --- D6: 20 events x both audiences, all rendering ----------------------------------- +# --- D6: 24 events x both audiences, all rendering ----------------------------------- -def test_the_registry_covers_twenty_events_in_both_audiences(): - assert sum(len(keys) for keys in FABLAB_STREAM_KEYS.values()) == 20 - assert len(FABLAB_ENTRIES) == 40 +def test_the_registry_covers_twenty_four_events_in_both_audiences(): + assert sum(len(keys) for keys in FABLAB_STREAM_KEYS.values()) == 24 + assert len(FABLAB_ENTRIES) == 48 for coordinates in FABLAB_ENTRIES: assert get_entry(*coordinates) is not None, coordinates diff --git a/backend/tests/test_object_storage.py b/backend/tests/test_object_storage.py index 6c8925a4..967a1a7a 100644 --- a/backend/tests/test_object_storage.py +++ b/backend/tests/test_object_storage.py @@ -79,6 +79,30 @@ def delete_object(self, **kwargs): assert deleted == [{"Bucket": "private", "Key": "exports/job.zip"}] +def test_delete_all_versions_can_require_provable_version_deletion(): + deleted = [] + + class Client: + def list_object_versions(self, **_kwargs): + raise ClientError( + {"Error": {"Code": "NotImplemented", "Message": "unsupported"}}, + "ListObjectVersions", + ) + + def delete_object(self, **kwargs): + deleted.append(kwargs) + + with pytest.raises(ClientError): + delete_all_versions( + Client(), + bucket="private", + key="evidence/photo.jpg", + require_version_listing=True, + ) + + assert deleted == [] + + def test_delete_all_versions_propagates_access_denied_without_bare_delete(): deleted = [] diff --git a/backend/tests/test_request_membership_module_b1.py b/backend/tests/test_request_membership_module_b1.py index fa67c2c3..1cf07277 100644 --- a/backend/tests/test_request_membership_module_b1.py +++ b/backend/tests/test_request_membership_module_b1.py @@ -120,19 +120,47 @@ def test_membership_off_does_not_relax_other_physical_action_surfaces(): modules = [key for key in profile_modules(EVERYTHING) if key != "membership"] space = _space("physical-actions-stay-member-only", modules) client = _client(_user("physical-action-outsider")) + # Self-checkout is the requester PHYSICALLY taking and returning a tool, so it stays + # member-only whether or not the community module is installed. urls = [ reverse("hardware_requests:public-tool-evidence-url", args=[space.slug]), reverse("hardware_requests:public-tool-checkout", args=[space.slug]), reverse("hardware_requests:public-tool-return", args=[space.slug]), + ] + + for url in urls: + response = client.post(url, {}, format="json") + assert response.status_code == 403, (url, response.data) + assert response.data["code"] == "membership_required", (url, response.data) + + +def test_membership_off_lets_an_account_propose_machine_and_printer_service(): + """The machine/printer service submits are PROPOSALS, not physical custody. + + They deliberately mirror the public borrow request: a membership when that module is + installed, an active account otherwise. Asserting `membership_required` here instead + is what let the `recommended` profile ship a surface that refused every ordinary + account -- `recommended` has `machine_service` and no `membership`. + """ + modules = [key for key in profile_modules(EVERYTHING) if key != "membership"] + space = _space("service-proposals-take-accounts", modules) + urls = [ reverse("public-machine-service-request-submit", args=[space.slug]), reverse("public-printer-service-upload", args=[space.slug]), reverse("public-printer-service-request", args=[space.slug]), ] + client = _client(_user("service-proposal-account")) for url in urls: response = client.post(url, {}, format="json") - assert response.status_code == 403, (url, response.data) - assert response.data["code"] == "membership_required", (url, response.data) + # Past the identity gate and into validation -- never a membership refusal. + assert response.status_code == 400, (url, response.data) + assert response.data.get("code") != "membership_required", (url, response.data) + + # ...but still not open to the public: no account, no proposal. + for url in urls: + response = _client().post(url, {}, format="json") + assert response.status_code in (401, 403), (url, response.data) def test_membership_off_does_not_relax_event_registration(): diff --git a/backend/tests/test_scheduled_tasks.py b/backend/tests/test_scheduled_tasks.py index ee7faa9a..f3b5a79f 100644 --- a/backend/tests/test_scheduled_tasks.py +++ b/backend/tests/test_scheduled_tasks.py @@ -55,6 +55,52 @@ def test_password_reset_drain_is_registered_in_both_schedulers(): ) in SCHEDULED_TASKS +def test_evidence_expiry_uses_the_same_six_hour_task_in_both_schedulers(): + from django.conf import settings + + task = "apps.evidence.tasks.sweep_evidence_retention_task" + entry = settings.CELERY_BEAT_SCHEDULE["evidence-object-expiry"] + + assert entry["task"] == task + assert entry["schedule"]._orig_minute == 10 + assert entry["schedule"]._orig_hour == "*/6" + assert ("evidence-object-expiry", task, 360) in SCHEDULED_TASKS + + +def test_evidence_expiry_controls_reach_the_beatless_task(monkeypatch): + import apps.operations.management.commands.run_scheduled_tasks as module + + calls = [] + monkeypatch.setattr( + module, + "_import_task", + lambda _path: lambda **kwargs: calls.append(kwargs), + ) + + old_run = timezone.now() - timedelta(days=1) + PeriodicTaskRun.objects.create( + name="evidence-object-expiry", last_run_at=old_run + ) + call_command( + "run_scheduled_tasks", + "--task", "evidence-object-expiry", + "--dry-run", "--batch-size", "17", + stdout=StringIO(), + ) + + assert calls == [{"dry_run": True, "batch_size": 17}] + assert PeriodicTaskRun.objects.get( + name="evidence-object-expiry" + ).last_run_at == old_run + + +def test_evidence_expiry_controls_cannot_leak_to_unrelated_tasks(): + from django.core.management.base import CommandError + + with pytest.raises(CommandError, match="require --task evidence-object-expiry"): + call_command("run_scheduled_tasks", "--dry-run", stdout=StringIO()) + + def test_running_records_a_row_per_task(): call_command("run_scheduled_tasks", stdout=StringIO()) diff --git a/backend/tests/tombstone/test_events_removed_surfaces.py b/backend/tests/tombstone/test_events_removed_surfaces.py index 35a9d13a..c201c126 100644 --- a/backend/tests/tombstone/test_events_removed_surfaces.py +++ b/backend/tests/tombstone/test_events_removed_surfaces.py @@ -14,6 +14,7 @@ """ import pytest +from django.conf import settings from django.urls import Resolver404, resolve from rest_framework.test import APIClient @@ -21,6 +22,7 @@ from apps.makerspaces.models import Makerspace from apps.makerspaces.module_registry import module_available from apps.makerspaces.platform import available_modules +from apps.operations.management.commands.run_scheduled_tasks import SCHEDULED_TASKS from apps.separability.registry import pii_fields_for, purge_plan_for, runtime_active pytestmark = pytest.mark.django_db @@ -39,7 +41,13 @@ def test_the_app_is_registered_as_inactive(): "path", [ "/api/v1/admin/makerspaces/1/events/", + "/api/v1/admin/makerspaces/1/event-series/", "/api/v1/admin/events/1/", + "/api/v1/admin/event-series/1/", + "/api/v1/admin/event-series/1/occurrences/", + "/api/v1/admin/event-series/1/extend/", + "/api/v1/admin/event-series/1/collaborators/", + "/api/v1/admin/makerspaces/1/event-series-collaborations/", "/api/v1/admin/events/1/publish/", "/api/v1/admin/events/1/cancel/", "/api/v1/admin/events/1/complete/", @@ -82,9 +90,19 @@ def test_the_openapi_schema_does_not_advertise_events(): assert response.status_code == 200 assert b"/events/" not in response.content + assert b"/event-series/" not in response.content assert b"/event-registrations/" not in response.content +def test_the_series_extension_task_is_not_scheduled(): + task = "apps.events.tasks.extend_event_series_task" + beat_tasks = {entry["task"] for entry in settings.CELERY_BEAT_SCHEDULE.values()} + runner_tasks = {dotted for _name, dotted, _minutes in SCHEDULED_TASKS} + + assert task not in beat_tasks + assert task not in runner_tasks + + def test_the_module_is_not_offered_to_the_console(): space = Makerspace.objects.create(name="tombstoned-events", slug="tombstoned-events") space.enabled_modules = sorted(set(space.enabled_modules) | {"events"}) diff --git a/docker-compose.cloud.yml b/docker-compose.cloud.yml index 6c39c584..83412b96 100644 --- a/docker-compose.cloud.yml +++ b/docker-compose.cloud.yml @@ -105,6 +105,9 @@ x-app-env: &app-env AWS_S3_PUBLIC_ENDPOINT_URL: ${AWS_S3_PUBLIC_ENDPOINT_URL:?set AWS_S3_PUBLIC_ENDPOINT_URL} # R2 and Supabase need PUT presigning; the POST-policy flow is MinIO-only. STORAGE_PRESIGN_METHOD: ${STORAGE_PRESIGN_METHOD:-put} + EVIDENCE_OBJECT_RETENTION_DAYS: ${EVIDENCE_OBJECT_RETENTION_DAYS:-365} + EVIDENCE_OBJECT_EXPIRY_ENABLED: ${EVIDENCE_OBJECT_EXPIRY_ENABLED:-False} + EVIDENCE_RETENTION_BATCH_SIZE: ${EVIDENCE_RETENTION_BATCH_SIZE:-100} BACKUP_AGE_RECIPIENT: ${BACKUP_AGE_RECIPIENT:-} BACKUP_ARCHIVE_SIGNING_PRIVATE_KEY: ${BACKUP_ARCHIVE_SIGNING_PRIVATE_KEY:-} BACKUP_ARCHIVE_VERIFY_PUBLIC_KEY: ${BACKUP_ARCHIVE_VERIFY_PUBLIC_KEY:-} diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index f7a00a3a..f37d9aaa 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -166,6 +166,9 @@ services: AWS_S3_ENDPOINT_URL: ${AWS_S3_ENDPOINT_URL:-http://minio:9000} AWS_S3_PUBLIC_ENDPOINT_URL: ${AWS_S3_PUBLIC_ENDPOINT_URL:-http://localhost:9000} STORAGE_PRESIGN_METHOD: ${STORAGE_PRESIGN_METHOD:-post} + EVIDENCE_OBJECT_RETENTION_DAYS: ${EVIDENCE_OBJECT_RETENTION_DAYS:-365} + EVIDENCE_OBJECT_EXPIRY_ENABLED: ${EVIDENCE_OBJECT_EXPIRY_ENABLED:-False} + EVIDENCE_RETENTION_BATCH_SIZE: ${EVIDENCE_RETENTION_BATCH_SIZE:-100} CRON_SECRET: ${CRON_SECRET:-} CELERY_BROKER_URL: ${CELERY_BROKER_URL:-redis://redis:6379/0} BACKUP_AGE_RECIPIENT: ${BACKUP_AGE_RECIPIENT:-} diff --git a/docker-compose.yml b/docker-compose.yml index d2fa8fe4..7b2a571a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -126,6 +126,9 @@ services: AWS_S3_ENDPOINT_URL: ${AWS_S3_ENDPOINT_URL:-http://minio:9000} AWS_S3_PUBLIC_ENDPOINT_URL: ${AWS_S3_PUBLIC_ENDPOINT_URL:-http://localhost:9000} STORAGE_PRESIGN_METHOD: ${STORAGE_PRESIGN_METHOD:-post} + EVIDENCE_OBJECT_RETENTION_DAYS: ${EVIDENCE_OBJECT_RETENTION_DAYS:-365} + EVIDENCE_OBJECT_EXPIRY_ENABLED: ${EVIDENCE_OBJECT_EXPIRY_ENABLED:-False} + EVIDENCE_RETENTION_BATCH_SIZE: ${EVIDENCE_RETENTION_BATCH_SIZE:-100} HMAC_CLIENT_ID: ${HMAC_CLIENT_ID:-} HMAC_SECRET: ${HMAC_SECRET:-} HMAC_MAX_CLOCK_SKEW_SECONDS: ${HMAC_MAX_CLOCK_SKEW_SECONDS:-300} diff --git a/docs/INVARIANTS.md b/docs/INVARIANTS.md index 48d0bb0d..7b013b85 100644 --- a/docs/INVARIANTS.md +++ b/docs/INVARIANTS.md @@ -300,6 +300,31 @@ URLs. Upload validation: strict magic-sniff for PDF/image; the private maker/CAD (`apps/maker_file_formats.py`) accepts STL/OBJ/3MF/STEP/etc. on ext+MIME (+signature for 3MF/STEP); public-image + evidence buckets stay strictly image-only. +**Evidence object retention bounds live bytes without weakening evidence-row immutability.** Evidence is +the deliberate exception to the generic POST wording above: both POST and PUT presigns target +`staging/`, and an attaching workflow promotes to the never-client-writable final key. The +deployment default is 365 days; a makerspace override must be between 30 and 3650 days. The global +`EVIDENCE_OBJECT_EXPIRY_ENABLED` switch ships false, and policy GET/PATCH plus preview remain available +while it is false. The six-hour sweep is bounded per tenant, enters one source-gate fan-out boundary per +makerspace, and refuses to run outside deployment recovery state `NORMAL`. + +Expiry locks only long enough to claim in the order `EvidencePhoto`, `EvidenceUploadFinalization`, then +`EvidenceObjectRetentionState`; all object-store HEAD/delete work happens outside that transaction. A +claim makes the photo unavailable to finalization and every issue/return attachment path. Success means +that **all versions and delete markers** of both the final key and its staging key are confirmed deleted; +an unsupported version-listing API or any transport/auth failure is retryable failure, not best-effort +success. Only a confirmed two-key deletion may write terminal `expired`, release managed quota once and +append `evidence.object_expired`. `recompute_storage` excludes only terminal expired rows. The immutable +`EvidencePhoto` row, its inbound request/return/accountability relationships and the PostgreSQL +immutability triggers remain unchanged; object expiry does not authorize row pruning. + +An expired evidence read returns 410 from the terminal state without contacting object storage. Backup, +restore and tenant migration treat `expiring` as a refusal and `expired` as a signed, state-backed +intentional-absence tombstone: both final and staging keys must be absent, while the row and retention +state remain in the database image. Live missing evidence is still an error. An archive captured before +expiry can contain and later resurrect the old photo bytes, so this mechanism bounds the live store; it +is not legal erasure from historical archives. + **Reports/analytics extend one registry** (never a parallel system). `apps/operations/report_registry.py` holds canonical `ReportDefinition`s (module-gated, `report_scope.eligible_makerspaces` excludes archived + reports-disabled + superadmin-hidden). FabLab domain builders (`reports_events`/`_bookings`/ @@ -1852,6 +1877,36 @@ Load-bearing details that carried over unchanged: ## Backup, restore and tenant migration (Phase 5A/5B and Lane D D1-D4/D6 built) +### The SHAPE a projected model must have (catalog-driven, 2026-09-03) + +Being classified is not the same as being importable. Two shapes break a tenant move +silently, and each was found only by running a full round-trip *after* the model had +already shipped, so both are now enumerated over the whole +`PROJECTED_MODEL_LABELS` catalog by +`tests/tenant_migration/test_projected_catalog_travel_guard.py` — a newly projected +model inherits the checks instead of waiting for a bespoke round-trip test: + +- **The primary key must be an auto-integer or a UUID.** `pk_maps` can only reserve + target values for those shapes and raises `UnsupportedPrimaryKey` otherwise, and a + `OneToOneField(primary_key=True)` additionally exports no `id` column at all, so the + materializer's read of the pk fails mid-move. **A `OneToOneField(primary_key=True)` + on any model that travels is a latent break — prefer a normal auto pk plus a unique + `OneToOne`.** Both evidence retention models shipped this way and had to be amended. + `pk_maps.SUPPORTED_PK_FIELD_TYPES` is the single source of truth; the guard asserts + against it rather than keeping a second copy. +- **Every deployment-global unique column needs a `DEPLOYMENT_GLOBAL_UNIQUE_RULES` + entry** deciding REGENERATE versus PRESERVE-and-refuse. An unruled unique column + violates its own constraint the first time a target already holds the value. The one + deliberate exemption is `accounts.User.username`, resolved before insertion by + `identity_resolution.allocate_username`; the guard holds that exemption as an exact + set, so adding a rule for it fails the test until the exemption is removed. + +Relational uniqueness is scoped by the row it points at, so a unique `OneToOne` is not +a deployment-global collision and is deliberately out of scope. Object pointers are a +separate, already-guarded axis: `tests/backup/test_object_field_coverage.py` requires +every `*_key` field to be either captured by name in `OBJECT_FIELD_NAMES` or exempted +with a reason in `NON_OBJECT_KEY_FIELDS`. + ### Lane D tenant-exclusive identity and payment closure (D6, 2026-08-23) **Lane D does not use the older per-identity `DisclosureClosureApproval` policy.** That @@ -1907,7 +1962,10 @@ may have only one canonical component candidate: a main/slice or slice/slice con archive; first ship never duplicates bytes and has no shared envelope. Historical audit object strings and recursive archive pointers are explicit coordination references, not generic JSON discoveries. Each component then proves reference/manifest equality, binds immutable captured size and SHA-256 facts, and -re-reads every packaged byte against them before the readable main can be projected. +re-reads every packaged byte against them before the readable main can be projected. The only no-byte +member is an evidence-retention tombstone backed by a terminal `EvidenceObjectRetentionState`: it remains +part of exact reference/manifest equality, records the expiry timestamp and prior size, and is rejected if +either final or staging bytes (including historical versions) still exist. `MakerspaceEncryptionKey` is tenant-owned for Lane E projection even though manager data export omits it. Its source-broker row must not survive in the readable main. Inside the same immutable snapshot, W8 freezes @@ -2231,6 +2289,9 @@ client from `backup/postgres_client.py` creates the custom `--no-owner --no-acl` restores it and repeats catalog, FK-closure, open-fence, cache and raw mapped-value digest checks before the candidate is atomically exposed. Object members are copied only from immutable capture staging and carry an opaque member path plus original key, version ID, size, content type and SHA-256; ETag is never a digest. +Terminal evidence expiry instead carries a typed tombstone with no member path or bytes, an empty digest, +the terminal timestamp and the recorded expired size; import allocates/remaps a collision-safe final key +but creates no staged or promoted object journal row. **Lane D D3 freezes custody and source bytes as one immutable capture lineage.** The request transaction reuses `backup/custody.py::with_makerspace_custody_lock`: makerspace first, every archive-recipient row in @@ -2430,6 +2491,17 @@ never seen the key looks like. The collision check itself consults the **object not the row table, because the constraint being protected is storage-key uniqueness in the target bucket; that also catches an orphaned object squatting a key. +The same rule governs a **non-regenerable** identity, and there the harness cannot simply let the +collision happen: `unique_values` marks two policies whose "generator" only raises — +`events.EventCheckInEvent.operation_id` and `events.EventAttendanceCertificate.serial`, both PRESERVE, +both meant to STOP an import rather than remint an immutable value. A collision cannot occur between +two real deployments (`pairing` refuses a same-deployment move outright), so a test that meets one has +modelled the world wrong. `SimpleImportCase.release_non_regenerable_identities()` drops the local rows +carrying such an identity — through the `app.allow_immutable_delete` hatch, because +`EventCheckInEvent` is trigger-immutable and cannot be re-stamped — and it is driven off a +`_refuses_collision` marker so a future refuse-policy is covered without touching the harness. Call it +after the source objects are captured and before materialization. + **A DROP disposition is enforced at BOTH ends, and they are not redundant.** PORTABLE export omits a drop-disposition row entirely (`admission.export_row_policy`) — `MembershipRequest.invite_email` is a stranger's email address, and a row that can never become live has no business travelling to a @@ -2531,6 +2603,24 @@ encoder always emits canonical output, so stored envelopes are unaffected. audit scope, storage quota and public venue routing all key on it. An organizer is attribution plus a narrow permission, never tenancy, and no organizer feature may move a number, a key, a quota or a route away from the venue. +- **Organization presentation is a global projection, not another tenant surface.** A public organization + profile is opt-in (`public_profile_enabled`) and exposes only the explicit public serializer fields. + Its event catalogue projects already-public, published, not-ended events and keeps each event's host + makerspace visible; the host must remain servable, visible in the central directory and have the events + module enabled. Switching either public-profile or host-module gate off hides the projection without + deleting the organization, event or organizer bridge. +- **Organization governance is separate from makerspace RBAC.** `governance_actions` may authorize profile + or membership administration, but never becomes a makerspace action and never changes a user's local + role identity. Invitations store only a SHA-256 digest of a high-entropy, single-use bearer token; the raw + token is returned once and must never enter list responses or audit metadata. Invitation creation and + redemption both enforce non-escalation against the creator's current active authority under row locks; + suspended memberships are never silently reactivated, and revoke/redeem races have one transactional + winner. +- **Event organizer mutation has one service path.** `services_organizers.replace_organizers()` locks in + `event -> makerspace` order, rechecks the events module and action-scoped event authority, requires an + active membership in each newly assigned organization (superadmin excepted), replaces the bridge set + atomically and emits one makerspace-scoped audit entry. It changes attribution only: the event's + `makerspace_id`, routing, PII custody and quotas remain untouched. ## API client scopes and the protected-route registry diff --git a/docs/MODULES.md b/docs/MODULES.md index f4a9c1bb..f242757c 100644 --- a/docs/MODULES.md +++ b/docs/MODULES.md @@ -116,7 +116,10 @@ thirteen modules under it are optional. - **Without it** — not an option: hardware cannot be issued without an issue photo, nor returned without a return photo and a remark. That is the accountability rule the product is built around. - **Data** — core; not separately purgeable. Evidence rows are immutable and only ever removed when the - whole makerspace is purged. + whole makerspace is purged. The *object bytes* are a separate question: an optional per-makerspace + retention policy deletes the stored image once its window passes, keeping the immutable row, the + remarks, the QR scans and the audit trail. An expired photo then reads as a truthful expired state + (410) rather than a broken link, so the accountability record survives the picture. ### qr_management @@ -280,13 +283,20 @@ Required by `printing`. ### events -- **What it is** — event scheduling and registration, including QR check-in at the door and - cross-makerspace collaborative events. -- **What it adds** — the events console, the public event list, member registration, staff-side - registration, QR check-in, collaborators and host waivers, and attended events on the maker profile. -- **Without it** — the space runs no events in-app: no public listing, no registrations, no check-in, and - no attended-event history on member profiles. `payments.events` becomes inert. -- **Data** — purgeable: events and their registrations (registrations hold PII and are handled as such). +- **What it is** — event scheduling and registration: one-off events or recurring series, registration + with optional approval and waitlists, QR check-in at the door, post-event feedback and attendance + certificates, and cross-makerspace collaborative events. +- **What it adds** — the events console, the public event list, member and staff-side registration, + registration approval and waitlist promotion, QR check-in, printable attendee badges, post-event + feedback surveys with the attendance certificates they issue, per-member calendar feeds, + organization-hosted events, collaborators and host waivers, and attended events on the maker profile. +- **Without it** — the space runs no events in-app: no public listing, no registrations, no check-in, no + feedback or certificates, no calendar feeds, and no attended-event history on member profiles. + `payments.events` and `events.offline_checkin` become inert. +- **Data** — purgeable: events and series, registrations (they hold PII and are handled as such), + check-in history, station credentials, feedback surveys and responses, attendance certificates and the + stored PDFs they name, calendar feeds, and collaboration records. Payment routing on a registration is + deliberately left intact, so a receipt stays readable and a charge raised later stays payable. --- @@ -414,13 +424,17 @@ stored credential**, so re-enabling needs no re-entry. ### reports - **What it is** — analytics, the report registry and CSV/XLSX exports. -- **What it adds** — the `analytics` and `report_export` workflows: dashboards, the ledger, problem - reports and every registered report. +- **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 rather than part of Inventory** on purpose: switching Inventory off would otherwise take the machine and event reports with it. -- **Data** — `purge_module_data` reports it stores no data of its own to purge separately; reports read - other modules' rows. +- **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. --- @@ -488,6 +502,8 @@ in the console rather than a superadmin. A feature is inert while its parent mod | `payments.events` | `events` | | Charge for event registration | Registration is free in-app | | `payments.membership` | `membership` | | Charge membership dues | Dues are collected out of band | | `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 | +| `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 | diff --git a/docs/PROJECT-HISTORY.md b/docs/PROJECT-HISTORY.md index 7806af6c..939b5725 100644 --- a/docs/PROJECT-HISTORY.md +++ b/docs/PROJECT-HISTORY.md @@ -6,6 +6,87 @@ ## Condensed changelog (newest first — full detail in `git log`) +- **2026-09-03 — 0.8.1: split-frontend deployment, and a verification round that finally ran.** + `netlify.toml` makes backend-on-your-server plus frontend-on-Netlify a configured topology rather + than a guess: Netlify builds only the React app (the generated API client is committed, so the + build never reaches the server), Node is pinned for Vite 8, and a catch-all rewrite stops every + deep link 404ing on refresh. README documents the cross-site cookie, CORS/CSRF, `frontend_domain` + and public-object-URL settings the split needs, plus the scheduler caveat that the cloud profile + relies on its `cron` service where prod runs Celery beat. The same batch executed phase 10's + backup/tenant-migration round-trips for the first time (1517 passed, 0 failed), which closed the + check-in `operation_id` collision question without a schema change; added a catalog-driven guard + asserting every projected model's primary key is importable and every deployment-global unique + column has a collision rule; and removed a duplicated `LandingPage` that left the extracted + module dead code. +- **2026-09-02 — Events-programme round-trip hardening after integration.** The phase-10 graph exposed + defects that declaration-only guards could not: `EventSeriesCollaborator.series` and `.makerspace` were + unclassified cross-tenant edges, so tenant projection refused them until the rules explicitly matched + occurrence collaboration and dropped a half-owned grant; the encryption plaintext-leak sweep had no + builders for the new PII in `EventFeedbackResponse.answers_snapshot` or + `EventAttendanceCertificate.recipient_name`, so both immutable models gained real sentinel builders. + Evidence retention then proved a more general migration trap: a `OneToOneField(primary_key=True)` leaves + no `id` column and is outside the importer’s supported auto-integer/UUID primary-key shapes. Both retention + models now have normal `BigAutoField` primary keys plus unique one-to-ones, and materialization reads the + model’s actual PK attname through `source_pk()` rather than assuming `row["id"]`; the multi-tenant fixture + also stopped reusing a globally unique calendar-feed digest. The final round-trip fixes made an empty + sovereign-row projection produce a correctly typed empty marker instead of raising Django’s + `EmptyResultSet`, skipped nullable object keys instead of capturing an object literally named `"None"`, + withdrew the organization-events URL when the separable events app is tombstoned, limited organization + analytics choices to reports with a server aggregation strategy, and made the migration harness release + source-only immutable operation UUIDs/certificate serials before modelling a clean target + (`abccb738`, `622aac8c`, `a674197f`). +- **2026-09-02 — Twelve-phase events, modules, organizations, reporting and evidence programme.** **Phase + 0** repaired six module-cascade defects: core staff request issue/return now gate by their own URL surface + instead of the optional `guest_handover` module, events/bookings declare their membership dependency, and + `backend/tests/modules/` now exercises module-OFF behaviour through the complete box→issue→return loan + spine. **Phase 0a** corrected five report gates/builders so disabled printing, machine-service, membership + and asset-unit data cannot leak through the wrong module key. **Phase 1** split the events schema into + focused `models_*` modules behind the stable `models.py` re-export barrel. **Phase 2** added an exclusive + absolute-or-lead-time registration cutoff, optional approval/rejection, approval-aware uniqueness and + FIFO waitlist promotion; paid applicants are charged only when they become registered. **Phase 3** added + immutable, source-aware check-in history, immutable feedback answer snapshots, and PDF attendance + certificates that require `attended` status and revoke on attendance correction. **Phase 4** materialised + recurring `Event` rows from an `EventSeries` so registrations, payments and audit targets stay concrete; + recurrence anchors to local wall-clock date/time plus an IANA zone across DST and can be extended by both + staff and the no-beat cloud scheduler. **Phase 5** shipped public/member ICS, rotatable digest-only bearer + feeds, RRULE/VTIMEZONE series export and printable badges whose QR reuses the registration check-in token. + **Phase 6** added minimal expiring offline rosters and idempotent late sync, plus an event/window-scoped, + rotated, hashed-and-peppered PIN station with uniform public failures. Its merge kept + `DeploymentRecoveryGateMiddleware` at `MIDDLEWARE[0]`, ahead of calendar-token log redaction; classified + `EVENT_STATION_PIN_PEPPER` under the **EXACT fingerprint** restore policy; and put the anonymous station + write points through `assert_write_allowed` so they cannot create tenant state after a tenant-migration + source gate closes. **Phase 7** kept makerspace as the tenancy anchor while adding organization public + profiles/catalogues, separate governance actions, single-use invitations and managed event organizers. + **Phase 8** added module-complete composite reports, charts, and append-only cursor/fence rollups, with an + explicit aggregation strategy or exclusion reason for every report key. **Phase 9** implemented evidence + retention mechanism A: delete final and staging object bytes after the effective window, retain immutable + `EvidencePhoto` metadata, record a truthful terminal expired state, return 410 on reads, and preserve the + tombstone through backup/migration. **Phase 10** built the real recurring-series→occurrence→registration→ + attendance→feedback→certificate object graph and proved field-by-field deployment-backup and tenant- + migration round trips across module-on/off, retained-disabled and archived tenants, expired evidence and + report rollups; it also made the retention sweep bounded, dry-runnable and observable through a structured + completion summary (`007e508a`, `2bb7a3d3`, `ef44d212`, `212eaeba`, `141f852f`, `cc02ad4b`). +- **2026-09-01 — SpaceWorks 0.8.0 release (PRs #15 and #16).** PR #15 first published the cumulative 0.7.5 + tree, then PR #16 advanced `VERSION` to `0.8.0`; `origin/main` landed at `b15c4e11`. The release completed + the post-Part-A backup/migration work: compound deployment archives gained tenant-recipient-only opaque + slices, a verified sovereign-row-free readable main, typed n-way object ownership, bounded DEK rewrap, + signed outer manifests, durable component/custody ledgers, create-only staged promotion, serialized + activation, pre-mutation import validation and an H1-supervised restore/cutover/rollback path. Tenant exit + gained a deny-by-default field/authority projection, constrained scratch-database materialization, frozen + capture and recipient revalidation, readable outer envelopes, target identity/readiness checks and broad + acceptance coverage; critically, the source gate moved its shared advisory lock to a dedicated connection + and verifies backend continuity so it remains effective behind a transaction pooler. Operations gained + coverage-proved scheduled backup runs and restore preflight, while the producer capability marker binds + installed privileged-script and entrypoint hashes. The same release added the curl-first pinned-image + installer, upgrade-time per-makerspace module selection and the native-Windows/WSL2 support boundary; + renamed the opt-in `accounts` module to `member_accounts`; added machine-type-scoped/public coloured + filament pools; and split over-ceiling modules behind compatibility barrels. The 0.8 increment then made + core public borrow proposals work when membership is off and added opt-in account-less requests using one + inert, credential-disabled makerspace principal, unverified contact snapshots, `actor=None` audits, required + idempotency and IP/email/outstanding limits. Telegram became outbound-only, the staff sidebar became a + tested dock, and release hardening preserved OCI child manifests, recorded executable bits in Git so + tarballs can restore, and fixed the compose-wrapper validator import (`5bf555b0`, `728fcbf6`, `32f4f306`, + `b15c4e11`). - **2026-08-22 — Archive-recipient custody, Part A (K1 landed + the two-recipient floor).** A tenant archive is encrypted to the makerspace's own verified `age` recipients, and the platform is added **only** when `superadmin_access_enabled` is true — so with the switch off the operator can *run* a tenant backup but diff --git a/docs/SOURCE-MAP.md b/docs/SOURCE-MAP.md index b1a996a9..3df24985 100644 --- a/docs/SOURCE-MAP.md +++ b/docs/SOURCE-MAP.md @@ -24,10 +24,18 @@ over `lifecycle_archive.py`, `lifecycle_purge.py` and `lifecycle_storage.py`), `origin_scope.py` (browser origin→tenant guard), `provisioning.py`/`hosting.py`, `secrets.py`. - `backend/apps/organizations/` — `Organization` (platform entity, creatable before any makerspace, NOT a - module_registry key), `OrganizationMakerspace` (the many-to-many link, at most one `owner` per space) and - `OrganizationMembership` (org-level `granted_actions`). Authority is resolved through - `accounts/rbac.py` with its organization layer in `accounts/rbac_organizations.py`, never mirrored into - `MakerspaceMembership`; `accounts/org_payload.py` projects it into the auth payload. + module_registry key), its opt-in public profile and cross-makerspace event catalogue, + `OrganizationMakerspace` (the many-to-many link, at most one `owner` per space), + `OrganizationMembership` (org-level makerspace grants plus separate organization-governance actions), + and digest-only single-use `OrganizationInvitation` grants. Organization profile/member governance + lives in `governance.py` + `services_profiles.py`/`services_invitations.py`; makerspace authority is + resolved through `accounts/rbac.py` with its organization layer in `accounts/rbac_organizations.py`, + never mirrored into `MakerspaceMembership`; `accounts/org_payload.py` projects it into the auth payload. + `models.py` is the schema source of truth; `governance.py` owns the fixed organization-only action + vocabulary, `access.py` the visible/locked authorization queries, and the two `services_*` modules the + transactional audited profile and invitation mutations. `public_catalog.py` is the canonical public + organization-event queryset; `urls_public.py` withdraws that separable events route when `apps.events` + is tombstoned, while `urls_admin.py` and the corresponding `views_*`/`serializers_*` expose governance. - `backend/apps/apiclients/` — `ApiClient` (client_id + Fernet-encrypted HMAC secret), `ApiKeyRequest`, and `scope_registry.py`/`scope_registry_routes.py` — the single source of truth for which protected route each scope authorizes, keyed on the versioned `view_name`. `checks.py` is the deployment-time guard that a @@ -40,6 +48,15 @@ object-store/HTTP collector protocols behind the `anchors.py` barrel. - `backend/apps/evidence/` — immutable evidence photos, S3 storage helpers, signed upload/view URLs gated by per-makerspace `UPLOAD_EVIDENCE` + active status. + `retention_models.py` owns the optional per-makerspace `EvidenceRetentionPolicy` override and per-photo + `EvidenceObjectRetentionState`, re-exported by + `models.py`; both use normal primary keys plus unique one-to-ones so they can travel through tenant + migration. `retention_policy.py` is the single source of truth for the effective window and candidate/ + preview query. `services_retention.py` owns the bounded idempotent sweep: it observes deployment recovery + and each tenant source gate, removes both final and staging bytes, credits confirmed storage and audits a + terminal expired state without mutating the `EvidencePhoto` row. `sweep_evidence_retention()` is the + single sweep entry point, `tasks.py` is its Celery/scheduled-task adapter, and `views_retention.py` exposes + the policy and preview API. - `backend/apps/boxes/` — `QrCode`/`Box` payloads, immutable `BoxScan`/`QrScanEvent`, `qr_render.py` (namespaced standalone SVG shared by QR-print + batch ZIP), QR rebind. Camera scanner at `frontend/src/components/ui/QrScanner.tsx` (native `BarcodeDetector` + `zxing-wasm` fallback). @@ -153,6 +170,18 @@ thin `role_scope.py` import surface over `role_scope_resolution.py` (including the identity-sensitive `EXEMPT`/`NOTHING` sentinels), `role_scope_grants.py`, and `role_scope_queries.py`; scope mutations remain in `role_scope_services.py`. +- `backend/apps/events/` — the separable events module. `models.py` is the stable explicit re-export barrel + over `models_event.py`, `models_registration.py`, `models_attendance.py`, `models_feedback.py`, + `models_certificates.py`, `models_series.py`, `models_calendar.py`, `models_collaborators.py` and + `organizer_models.py`; those focused files are the schema sources for events/occurrences, approval and + waitlist registrations, immutable check-in history and PIN credentials, feedback, certificate artifacts, + recurring series, member feed credentials, makerspace collaboration and organization attribution. + `services.py` remains the audited transactional boundary for one-off event and registration mutations, + delegating lifecycle and registration-state transitions to `services_lifecycle.py`, + `services_registration.py` and `services_registration_state.py`. The other `services_*` modules own the + corresponding series/recurrence/collaboration, calendar/feed, badge, check-in/offline-sync/station, + feedback/certificate, image and organizer workflows; `urls_admin.py`, `urls_member.py`, `urls_public.py` + and `urls_station.py` divide the staff, authenticated-member, public and anonymous-station surfaces. - `backend/apps/warranty/`, `apps/maintenance/`, `apps/events/`, `apps/bookings/`, `apps/forms_schema/`, `apps/encryption/`, `apps/procurement/`, `apps/notifications/`, `apps/operations/report_registry.py` — the remaining FabLab + governance modules. @@ -165,3 +194,12 @@ `frontend/src/features/auth/` + `members/MemberAuthPanel.tsx` — provider-config-driven social/member auth. `frontend/src/features/printing|bookings|forms|...` — feature slices. `frontend/src/lib/`, `components/ui/`, `types/`, `generated/api.ts`. +- `frontend/src/features/events/` — the standalone anonymous PIN-station route. Its + `EventCheckInStationPage.tsx` exchanges the event-scoped PIN, then reuses the offline roster/sync API, + IndexedDB state and operator UI owned by `features/staff/eventCheckInOfflineApi.ts`, + `eventCheckInOfflineStore.ts` and `OfflineCheckInOperator.tsx`. +- `frontend/src/features/organizations/` — public organization presentation and invitation redemption: + `PublicOrganizationPage.tsx` renders the public profile and paginated cross-makerspace event catalogue, + `OrganizationInvitationRedeemPage.tsx` binds a single-use invitation after member sign-in, and + `publicOrganizationsApi.ts` owns their TanStack Query keys and public API calls. Staff profile, + membership, invitation and event-organizer controls remain under `frontend/src/features/staff/`. diff --git a/frontend/nginx.conf b/frontend/nginx.conf index e6977794..9b61afbe 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -17,6 +17,18 @@ server { return 403; } + # A calendar subscription URL is a bearer credential over member activity. + # Do not write its raw path into nginx access logs; Django independently redacts + # Gunicorn's WSGI request-line value before the backend access log is emitted. + location ~ ^/api/v1/public/[^/]+/event-calendar/[^/]+\.ics$ { + access_log off; + proxy_pass http://backend: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 /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 6c7f72f8..f5318ef7 100644 --- a/frontend/openapi-schema.json +++ b/frontend/openapi-schema.json @@ -2,7 +2,7 @@ "openapi": "3.0.3", "info": { "title": "Space Works API", - "version": "0.8.0", + "version": "0.8.1", "description": "Multi-tenant makerspace hardware loan system.\n\nPublic flow: browse inventory, search with `q`, page with `page`, sign in as a member, submit a borrow request, then track it by public token.\n\nAdmin flow: authenticate with JWT, manage makerspaces, inventory, staff, QR labels, bulk imports, request review, issue, and return.\n\nAuthentication: staff/admin endpoints use `Authorization: Bearer `. Public browser endpoints can use `X-Publishable-Key` when public key hardening is enabled. Server API clients send `X-Client-Id`, `X-Timestamp`, `X-Nonce`, and `X-Signature`. Generate a unique, unpredictable `X-Nonce` for every request and sign the byte sequence `METHOD\\nFULL_PATH\\nTIMESTAMP\\nNONCE\\nBODY` with HMAC-SHA256. The nonce uses 1-128 characters from `A-Z`, `a-z`, `0-9`, `.`, `_`, `~`, and `-`. A deployment may temporarily accept the legacy nonce-less signed format while `APICLIENT_REQUIRE_NONCE` is disabled." }, "paths": { @@ -333,6 +333,17 @@ "format": "date" } }, + { + "in": "query", + "name": "grain", + "schema": { + "type": "string", + "enum": [ + "day", + "month" + ] + } + }, { "in": "query", "name": "limit", @@ -348,17 +359,25 @@ "enum": [ "active-loans", "booking-utilization", + "communications-health", + "community-engagement", "damaged-lost", "damaged-missing", "event-attendance", + "evidence-compliance", "fablab-health", + "import-quality", + "inventory-control", + "loan-throughput", "machine-service", "machine-usage", "maintenance-activity", "member-activity", + "module-operational-health", "most-lent", "payment-reconciliation", "printer-service", + "procurement-performance", "qr-scans", "recently-added", "returns", @@ -2093,10 +2112,9 @@ } } }, - "/api/v1/admin/event-collaborations/{id}/remove/": { - "post": { - "operationId": "api_v1_admin_event_collaborations_remove_create", - "summary": "Remove an event collaborator", + "/api/v1/admin/event-certificates/{id}/download/": { + "get": { + "operationId": "api_v1_admin_event_certificates_download_retrieve", "parameters": [ { "in": "path", @@ -2116,20 +2134,17 @@ } ], "responses": { - "204": { - "description": "No response body" - }, - "400": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/CertificateDownload" } } }, - "description": "Invalid collaboration request." + "description": "" }, - "403": { + "410": { "content": { "application/json": { "schema": { @@ -2137,9 +2152,9 @@ } } }, - "description": "Event management access denied." + "description": "Certificate revoked." }, - "404": { + "503": { "content": { "application/json": { "schema": { @@ -2147,63 +2162,7 @@ } } }, - "description": "Event collaboration not found." - } - } - } - }, - "/api/v1/admin/event-collaborations/{id}/respond/": { - "post": { - "operationId": "api_v1_admin_event_collaborations_respond_create", - "summary": "Accept or decline an event collaboration", - "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" - }, - "required": true - } - ], - "tags": [ - "Admin events" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/EventCollaborationRespond" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/EventCollaborationRespond" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/EventCollaborationRespond" - } - } - }, - "required": true - }, - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/EventCollaborator" - } - } - }, - "description": "" + "description": "Certificate storage unavailable." }, "400": { "content": { @@ -2213,7 +2172,7 @@ } } }, - "description": "Invalid collaboration request." + "description": "Invalid request." }, "403": { "content": { @@ -2223,7 +2182,7 @@ } } }, - "description": "Event management access denied." + "description": "Event management is required." }, "404": { "content": { @@ -2233,43 +2192,7 @@ } } }, - "description": "Event collaboration not found." - } - } - } - }, - "/api/v1/admin/event-registrations/{id}/mark-attended/": { - "post": { - "operationId": "api_v1_admin_event_registrations_mark_attended_create", - "summary": "Mark an event registration attended", - "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" - }, - "required": true - } - ], - "tags": [ - "Admin events" - ], - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/EventRegistrationAdmin" - } - } - }, - "description": "" + "description": "Event resource not found." }, "409": { "content": { @@ -2279,49 +2202,14 @@ } } }, - "description": "Event state or capacity conflict." + "description": "Event state conflict." } } } }, - "/api/v1/admin/events/{id}/": { - "get": { - "operationId": "api_v1_admin_events_retrieve", - "summary": "Retrieve an event", - "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" - }, - "required": true - } - ], - "tags": [ - "Admin events" - ], - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/EventAdmin" - } - } - }, - "description": "" - } - } - }, - "patch": { - "operationId": "api_v1_admin_events_partial_update", - "summary": "Update an event", + "/api/v1/admin/event-certificates/{id}/reissue/": { + "post": { + "operationId": "api_v1_admin_event_certificates_reissue_create", "parameters": [ { "in": "path", @@ -2335,36 +2223,17 @@ "tags": [ "Admin events" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PatchedEventWrite" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/PatchedEventWrite" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/PatchedEventWrite" - } - } - } - }, "security": [ { "jwtAuth": [] } ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EventAdmin" + "$ref": "#/components/schemas/CertificateSummary" } } }, @@ -2374,14 +2243,13 @@ "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": {} + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Invalid event details." + "description": "Invalid request." }, - "409": { + "403": { "content": { "application/json": { "schema": { @@ -2389,43 +2257,17 @@ } } }, - "description": "Event state or capacity conflict." - } - } - } - }, - "/api/v1/admin/events/{id}/cancel/": { - "post": { - "operationId": "api_v1_admin_events_cancel_create", - "summary": "Cancel an event", - "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" - }, - "required": true - } - ], - "tags": [ - "Admin events" - ], - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { + "description": "Event management is required." + }, + "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EventAdmin" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "" + "description": "Event resource not found." }, "409": { "content": { @@ -2435,15 +2277,14 @@ } } }, - "description": "Event state or capacity conflict." + "description": "Event state conflict." } } } }, - "/api/v1/admin/events/{id}/check-in/resolve/": { + "/api/v1/admin/event-certificates/{id}/revoke/": { "post": { - "operationId": "api_v1_admin_events_check_in_resolve_create", - "summary": "Resolve an event check-in token", + "operationId": "api_v1_admin_event_certificates_revoke_create", "parameters": [ { "in": "path", @@ -2461,17 +2302,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EventCheckInResolveRequest" + "$ref": "#/components/schemas/CertificateRevoke" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/EventCheckInResolveRequest" + "$ref": "#/components/schemas/CertificateRevoke" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/EventCheckInResolveRequest" + "$ref": "#/components/schemas/CertificateRevoke" } } }, @@ -2487,12 +2328,22 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EventCheckInResolveResponse" + "$ref": "#/components/schemas/CertificateSummary" } } }, "description": "" }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Invalid request." + }, "403": { "content": { "application/json": { @@ -2501,7 +2352,7 @@ } } }, - "description": "Event access denied." + "description": "Event management is required." }, "404": { "content": { @@ -2511,9 +2362,9 @@ } } }, - "description": "Registration not found." + "description": "Event resource not found." }, - "429": { + "409": { "content": { "application/json": { "schema": { @@ -2521,15 +2372,15 @@ } } }, - "description": "Request rate limit exceeded." + "description": "Event state conflict." } } } }, - "/api/v1/admin/events/{id}/collaborators/": { - "get": { - "operationId": "api_v1_admin_events_collaborators_list", - "summary": "List an event's collaborators", + "/api/v1/admin/event-collaborations/{id}/remove/": { + "post": { + "operationId": "api_v1_admin_event_collaborations_remove_create", + "summary": "Remove an event collaborator", "parameters": [ { "in": "path", @@ -2549,18 +2400,8 @@ } ], "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/EventCollaborator" - } - } - } - }, - "description": "" + "204": { + "description": "No response body" }, "400": { "content": { @@ -2593,10 +2434,12 @@ "description": "Event collaboration not found." } } - }, - "put": { - "operationId": "api_v1_admin_events_collaborators_update", - "summary": "Replace an event's collaborators", + } + }, + "/api/v1/admin/event-collaborations/{id}/respond/": { + "post": { + "operationId": "api_v1_admin_event_collaborations_respond_create", + "summary": "Accept or decline an event collaboration", "parameters": [ { "in": "path", @@ -2614,17 +2457,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EventCollaboratorReplace" + "$ref": "#/components/schemas/EventCollaborationRespond" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/EventCollaboratorReplace" + "$ref": "#/components/schemas/EventCollaborationRespond" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/EventCollaboratorReplace" + "$ref": "#/components/schemas/EventCollaborationRespond" } } }, @@ -2640,10 +2483,7 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/EventCollaborator" - } + "$ref": "#/components/schemas/EventCollaborator" } } }, @@ -2682,10 +2522,10 @@ } } }, - "/api/v1/admin/events/{id}/complete/": { + "/api/v1/admin/event-registrations/{id}/approve/": { "post": { - "operationId": "api_v1_admin_events_complete_create", - "summary": "Complete an event", + "operationId": "api_v1_admin_event_registrations_approve_create", + "summary": "Approve a pending event registration", "parameters": [ { "in": "path", @@ -2709,13 +2549,13 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EventAdmin" + "$ref": "#/components/schemas/EventRegistrationAdmin" } } }, "description": "" }, - "409": { + "400": { "content": { "application/json": { "schema": { @@ -2723,55 +2563,54 @@ } } }, - "description": "Event state or capacity conflict." - } - } - } - }, - "/api/v1/admin/events/{id}/eligible-members/": { - "get": { - "operationId": "api_v1_admin_events_eligible_members_list", - "description": "The roster the staff registration picker reads.\n\nHung off the EVENT rather than the makerspace, so it inherits `_manageable_event`\nand introduces no new authority question: whoever may manage this event may see who\nthey can register for it. A separate makerspace-level member list would have needed\nits own permission answer, and the obvious candidates were all wrong — the\ndirect-loan roster is gated on `ISSUE_DIRECT_LOAN` plus a self-checkout feature an\nevents manager need not hold, and the full membership list is `MANAGE_MAKERSPACE`.\n\nAlready-registered members are excluded: offering someone the picker can only reject\nas a duplicate is an error the interface should not have made available.", - "summary": "List members who can be registered for an event", - "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" + "description": "Unexpected request body." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } }, - "required": true - } - ], - "tags": [ - "Admin events" - ], - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { + "description": "Authentication is required." + }, + "403": { "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/EventEligibleMember" - } + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "" + "description": "Event management is required." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Registration not found." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Event state or capacity conflict." } } } }, - "/api/v1/admin/events/{id}/image": { + "/api/v1/admin/event-registrations/{id}/correct-attendance/": { "post": { - "operationId": "api_v1_admin_events_image_create", - "summary": "Create an event image upload URL", + "operationId": "api_v1_admin_event_registrations_correct_attendance_create", "parameters": [ { "in": "path", @@ -2785,59 +2624,69 @@ "tags": [ "Admin events" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PublicImageUploadRequest" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/PublicImageUploadRequest" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/PublicImageUploadRequest" - } - } - }, - "required": true - }, "security": [ { "jwtAuth": [] } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicImageUploadResponse" + "$ref": "#/components/schemas/AttendanceCorrectionResponse" } } }, "description": "" }, "400": { - "description": "Invalid image upload request." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Invalid request." }, "403": { - "description": "Event management access is required." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Event management is required." }, "404": { - "description": "Event not found." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Event resource not found." }, - "503": { - "description": "Public image storage is unavailable." + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Event state conflict." } } - }, - "put": { - "operationId": "api_v1_admin_events_image_update", - "summary": "Attach an uploaded image to an event", + } + }, + "/api/v1/admin/event-registrations/{id}/mark-attended/": { + "post": { + "operationId": "api_v1_admin_event_registrations_mark_attended_create", + "summary": "Mark an event registration attended", "parameters": [ { "in": "path", @@ -2855,21 +2704,20 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicImageAttachRequest" + "$ref": "#/components/schemas/EventAttendanceMark" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/PublicImageAttachRequest" + "$ref": "#/components/schemas/EventAttendanceMark" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/PublicImageAttachRequest" + "$ref": "#/components/schemas/EventAttendanceMark" } } - }, - "required": true + } }, "security": [ { @@ -2881,29 +2729,29 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EventAdmin" + "$ref": "#/components/schemas/EventRegistrationAdmin" } } }, "description": "" }, - "400": { - "description": "Invalid image object key or size." - }, - "403": { - "description": "Event management access is required." - }, - "404": { - "description": "Event not found." - }, - "503": { - "description": "Public image storage is unavailable." + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Event state or capacity conflict." } } - }, - "delete": { - "operationId": "api_v1_admin_events_image_destroy", - "summary": "Clear an event image", + } + }, + "/api/v1/admin/event-registrations/{id}/promote/": { + "post": { + "operationId": "api_v1_admin_event_registrations_promote_create", + "summary": "Manually promote an approved waitlisted registration", "parameters": [ { "in": "path", @@ -2927,25 +2775,69 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EventAdmin" + "$ref": "#/components/schemas/EventRegistrationAdmin" } } }, "description": "" }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Unexpected request body." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Authentication is required." + }, "403": { - "description": "Event management access is required." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Event management is required." }, "404": { - "description": "Event not found." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Registration not found." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Event state or capacity conflict." } } } }, - "/api/v1/admin/events/{id}/publish/": { + "/api/v1/admin/event-registrations/{id}/reject/": { "post": { - "operationId": "api_v1_admin_events_publish_create", - "summary": "Publish an event", + "operationId": "api_v1_admin_event_registrations_reject_create", + "summary": "Reject a pending or waitlisted event registration", "parameters": [ { "in": "path", @@ -2969,7 +2861,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EventAdmin" + "$ref": "#/components/schemas/EventRegistrationAdmin" } } }, @@ -2979,12 +2871,41 @@ "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": {} + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Invalid event details." + "description": "Unexpected request body." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Authentication is required." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Event management is required." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Registration not found." }, "409": { "content": { @@ -2999,10 +2920,9 @@ } } }, - "/api/v1/admin/events/{id}/registrations/": { - "get": { - "operationId": "api_v1_admin_events_registrations_retrieve", - "summary": "List event registrations", + "/api/v1/admin/event-series-collaborations/{id}/remove/": { + "post": { + "operationId": "api_v1_admin_event_series_collaborations_remove_create", "parameters": [ { "in": "path", @@ -3014,7 +2934,7 @@ } ], "tags": [ - "Admin events" + "Admin event series" ], "security": [ { @@ -3022,21 +2942,75 @@ } ], "responses": { - "200": { + "204": { + "description": "No response body" + }, + "400": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EventRegistrationListResponse" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "" + "description": "Invalid collaboration request." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Authentication required." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Event management access denied." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Series collaboration not found." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Collaboration state conflict." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Rate limit exceeded." } } - }, + } + }, + "/api/v1/admin/event-series-collaborations/{id}/respond/": { "post": { - "operationId": "api_v1_admin_events_registrations_create", - "summary": "Register a member for an event", + "operationId": "api_v1_admin_event_series_collaborations_respond_create", "parameters": [ { "in": "path", @@ -3048,23 +3022,23 @@ } ], "tags": [ - "Admin events" + "Admin event series" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EventStaffRegistration" + "$ref": "#/components/schemas/SeriesCollaborationRespond" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/EventStaffRegistration" + "$ref": "#/components/schemas/SeriesCollaborationRespond" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/EventStaffRegistration" + "$ref": "#/components/schemas/SeriesCollaborationRespond" } } }, @@ -3076,11 +3050,11 @@ } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EventRegistrationAdmin" + "$ref": "#/components/schemas/SeriesCollaborator" } } }, @@ -3094,7 +3068,37 @@ } } }, - "description": "Invalid registration." + "description": "Invalid collaboration request." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Authentication required." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Event management access denied." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Series collaboration not found." }, "409": { "content": { @@ -3104,14 +3108,24 @@ } } }, - "description": "Event state or capacity conflict." + "description": "Collaboration state conflict." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Rate limit exceeded." } } } }, - "/api/v1/admin/evidence/{id}": { + "/api/v1/admin/event-series/{id}/": { "get": { - "operationId": "api_v1_admin_evidence_retrieve", + "operationId": "api_v1_admin_event_series_retrieve", "parameters": [ { "in": "path", @@ -3123,7 +3137,7 @@ } ], "tags": [ - "api" + "Admin event series" ], "security": [ { @@ -3135,53 +3149,76 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EvidenceGetResponse" + "$ref": "#/components/schemas/EventSeriesDetail" } } }, "description": "" - } - } - } - }, - "/api/v1/admin/inventory/{id}": { - "get": { - "operationId": "api_v1_admin_inventory_retrieve", - "summary": "Retrieve or update inventory product", - "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } }, - "required": true - } - ], - "tags": [ - "Admin inventory" - ], - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { + "description": "Invalid recurring event series." + }, + "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/InventoryProductAdmin" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "" + "description": "Authentication required." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Event management access denied." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Event series not found." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Series state conflict." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Rate limit exceeded." } } }, "patch": { - "operationId": "api_v1_admin_inventory_partial_update", - "summary": "Retrieve or update inventory product", + "operationId": "api_v1_admin_event_series_partial_update", "parameters": [ { "in": "path", @@ -3193,23 +3230,23 @@ } ], "tags": [ - "Admin inventory" + "Admin event series" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PatchedInventoryProductAdminUpdate" + "$ref": "#/components/schemas/PatchedEventSeriesWrite" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/PatchedInventoryProductAdminUpdate" + "$ref": "#/components/schemas/PatchedEventSeriesWrite" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/PatchedInventoryProductAdminUpdate" + "$ref": "#/components/schemas/PatchedEventSeriesWrite" } } } @@ -3224,75 +3261,78 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/InventoryProductAdminUpdate" + "$ref": "#/components/schemas/EventSeriesMutationResponse" } } }, "description": "" - } - } - } - }, - "/api/v1/admin/inventory/{id}/adjust-quantity": { - "post": { - "operationId": "api_v1_admin_inventory_adjust_quantity_create", - "summary": "Adjust inventory quantity buckets", - "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" - }, - "required": true - } - ], - "tags": [ - "Admin inventory" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/InventoryQuantityAdjustment" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } } }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/InventoryQuantityAdjustment" + "description": "Invalid recurring event series." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } } }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/InventoryQuantityAdjustment" + "description": "Authentication required." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } } - } + }, + "description": "Event management access denied." }, - "required": true - }, - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { + "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/InventoryProductAdmin" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "" + "description": "Event series not found." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Series state conflict." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Rate limit exceeded." } } } }, - "/api/v1/admin/inventory/{id}/chain-of-custody": { - "get": { - "operationId": "api_v1_admin_inventory_chain_of_custody_retrieve", - "summary": "Read-only chain of custody for one inventory item", + "/api/v1/admin/event-series/{id}/cancel/": { + "post": { + "operationId": "api_v1_admin_event_series_cancel_create", "parameters": [ { "in": "path", @@ -3301,18 +3341,10 @@ "type": "integer" }, "required": true - }, - { - "in": "query", - "name": "limit", - "schema": { - "type": "integer" - }, - "description": "Maximum history events to return. Defaults to 200; capped at 500." } ], "tags": [ - "Admin inventory" + "Admin event series" ], "security": [ { @@ -3324,7 +3356,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/InventoryChainOfCustodyResponse" + "$ref": "#/components/schemas/EventSeriesMutationResponse" } } }, @@ -3338,7 +3370,17 @@ } } }, - "description": "Invalid request." + "description": "Invalid recurring event series." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Authentication required." }, "403": { "content": { @@ -3348,7 +3390,7 @@ } } }, - "description": "Permission denied." + "description": "Event management access denied." }, "404": { "content": { @@ -3358,7 +3400,7 @@ } } }, - "description": "Not found." + "description": "Event series not found." }, "409": { "content": { @@ -3368,15 +3410,24 @@ } } }, - "description": "Workflow conflict." + "description": "Series state conflict." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Rate limit exceeded." } } } }, - "/api/v1/admin/inventory/{id}/image": { - "post": { - "operationId": "api_v1_admin_inventory_image_create", - "summary": "Create an inventory product image upload URL", + "/api/v1/admin/event-series/{id}/collaborators/": { + "get": { + "operationId": "api_v1_admin_event_series_collaborators_list", "parameters": [ { "in": "path", @@ -3388,55 +3439,91 @@ } ], "tags": [ - "Admin inventory" + "Admin event series" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PublicImageUploadRequest" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/PublicImageUploadRequest" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/PublicImageUploadRequest" - } - } - }, - "required": true - }, "security": [ { "jwtAuth": [] } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicImageUploadResponse" + "type": "array", + "items": { + "$ref": "#/components/schemas/SeriesCollaborator" + } } } }, "description": "" }, "400": { - "description": "Invalid image upload request." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Invalid collaboration request." }, - "503": { - "description": "Public image storage is unavailable." + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Authentication required." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Event management access denied." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Series collaboration not found." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Collaboration state conflict." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Rate limit exceeded." } } }, "put": { - "operationId": "api_v1_admin_inventory_image_update", - "summary": "Attach an uploaded image to an inventory product", + "operationId": "api_v1_admin_event_series_collaborators_update", "parameters": [ { "in": "path", @@ -3448,23 +3535,23 @@ } ], "tags": [ - "Admin inventory" + "Admin event series" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicImageAttachRequest" + "$ref": "#/components/schemas/SeriesCollaboratorReplace" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/PublicImageAttachRequest" + "$ref": "#/components/schemas/SeriesCollaboratorReplace" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/PublicImageAttachRequest" + "$ref": "#/components/schemas/SeriesCollaboratorReplace" } } }, @@ -3480,23 +3567,81 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/InventoryProductAdmin" + "type": "array", + "items": { + "$ref": "#/components/schemas/SeriesCollaborator" + } } } }, "description": "" }, "400": { - "description": "Invalid image object key or size." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Invalid collaboration request." }, - "503": { - "description": "Public image storage is unavailable." + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Authentication required." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Event management access denied." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Series collaboration not found." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Collaboration state conflict." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Rate limit exceeded." } } - }, - "delete": { - "operationId": "api_v1_admin_inventory_image_destroy", - "summary": "Clear an inventory product image", + } + }, + "/api/v1/admin/event-series/{id}/complete/": { + "post": { + "operationId": "api_v1_admin_event_series_complete_create", "parameters": [ { "in": "path", @@ -3508,7 +3653,7 @@ } ], "tags": [ - "Admin inventory" + "Admin event series" ], "security": [ { @@ -3520,19 +3665,78 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/InventoryProductAdmin" + "$ref": "#/components/schemas/EventSeriesMutationResponse" } } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Invalid recurring event series." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Authentication required." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Event management access denied." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Event series not found." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Series state conflict." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Rate limit exceeded." } } } }, - "/api/v1/admin/inventory/{id}/lending-history": { - "get": { - "operationId": "api_v1_admin_inventory_lending_history_retrieve", - "summary": "Per-item lending history (last borrower + last 3 lends)", + "/api/v1/admin/event-series/{id}/extend/": { + "post": { + "operationId": "api_v1_admin_event_series_extend_create", "parameters": [ { "in": "path", @@ -3544,7 +3748,7 @@ } ], "tags": [ - "Admin inventory" + "Admin event series" ], "security": [ { @@ -3556,20 +3760,79 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/LendingHistoryResponse" + "$ref": "#/components/schemas/EventSeriesMutationResponse" } } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Invalid recurring event series." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Authentication required." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Event management access denied." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Event series not found." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Series state conflict." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Rate limit exceeded." } } } }, - "/api/v1/admin/inventory/{id}/needs-fix": { + "/api/v1/admin/event-series/{id}/image": { "post": { - "operationId": "api_v1_admin_inventory_needs_fix_create", - "description": "Move units onto the shelf, back to available, or out of inventory.", - "summary": "Move inventory units to, from, or out of the to-be-fixed shelf", + "operationId": "api_v1_admin_event_series_image_create", + "summary": "Create a series image upload URL", "parameters": [ { "in": "path", @@ -3581,23 +3844,23 @@ } ], "tags": [ - "Admin inventory" + "Admin event series" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/NeedsFixAction" + "$ref": "#/components/schemas/PublicImageUploadRequest" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/NeedsFixAction" + "$ref": "#/components/schemas/PublicImageUploadRequest" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/NeedsFixAction" + "$ref": "#/components/schemas/PublicImageUploadRequest" } } }, @@ -3609,23 +3872,39 @@ } ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/InventoryProductAdmin" + "$ref": "#/components/schemas/PublicImageUploadResponse" } } }, "description": "" + }, + "400": { + "description": "Invalid image upload request." + }, + "401": { + "description": "Authentication required." + }, + "403": { + "description": "Event management access is required." + }, + "404": { + "description": "Event series not found." + }, + "429": { + "description": "Rate limit exceeded." + }, + "503": { + "description": "Public image storage is unavailable." } } - } - }, - "/api/v1/admin/inventory/{id}/qr-history": { - "get": { - "operationId": "api_v1_admin_inventory_qr_history_retrieve", - "summary": "List QR scan history for an inventory product", + }, + "put": { + "operationId": "api_v1_admin_event_series_image_update", + "summary": "Attach an uploaded series image", "parameters": [ { "in": "path", @@ -3637,8 +3916,28 @@ } ], "tags": [ - "Admin inventory" + "Admin event series" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicImageAttachRequest" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/PublicImageAttachRequest" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/PublicImageAttachRequest" + } + } + }, + "required": true + }, "security": [ { "jwtAuth": [] @@ -3649,41 +3948,39 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ProductQrHistory" + "$ref": "#/components/schemas/EventSeriesDetail" } } }, "description": "" + }, + "400": { + "description": "Invalid image upload request." + }, + "401": { + "description": "Authentication required." + }, + "403": { + "description": "Event management access is required." + }, + "404": { + "description": "Event series not found." + }, + "429": { + "description": "Rate limit exceeded." + }, + "503": { + "description": "Public image storage is unavailable." } } - } - }, - "/api/v1/admin/inventory/{product_pk}/assets": { - "get": { - "operationId": "api_v1_admin_inventory_assets_list", - "summary": "List individual assets for an inventory product", + }, + "delete": { + "operationId": "api_v1_admin_event_series_image_destroy", + "summary": "Clear a series image", "parameters": [ - { - "name": "page", - "required": false, - "in": "query", - "description": "A page number within the paginated result set.", - "schema": { - "type": "integer" - } - }, - { - "name": "page_size", - "required": false, - "in": "query", - "description": "Number of results to return per page.", - "schema": { - "type": "integer" - } - }, { "in": "path", - "name": "product_pk", + "name": "id", "schema": { "type": "integer" }, @@ -3691,7 +3988,7 @@ } ], "tags": [ - "Admin inventory" + "Admin event series" ], "security": [ { @@ -3703,41 +4000,48 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaginatedInventoryAssetAdminList" + "$ref": "#/components/schemas/EventSeriesDetail" } } }, "description": "" - } - } - } - }, - "/api/v1/admin/inventory/needs-fix": { + }, + "400": { + "description": "Invalid image upload request." + }, + "401": { + "description": "Authentication required." + }, + "403": { + "description": "Event management access is required." + }, + "404": { + "description": "Event series not found." + }, + "429": { + "description": "Rate limit exceeded." + }, + "503": { + "description": "Public image storage is unavailable." + } + } + } + }, + "/api/v1/admin/event-series/{id}/occurrences/": { "get": { - "operationId": "api_v1_admin_inventory_needs_fix_list", - "description": "The to-be-fixed shelf: products that currently have units awaiting repair.", + "operationId": "api_v1_admin_event_series_occurrences_retrieve", "parameters": [ { - "name": "page", - "required": false, - "in": "query", - "description": "A page number within the paginated result set.", - "schema": { - "type": "integer" - } - }, - { - "name": "page_size", - "required": false, - "in": "query", - "description": "Number of results to return per page.", + "in": "path", + "name": "id", "schema": { "type": "integer" - } + }, + "required": true } ], "tags": [ - "api" + "Admin event series" ], "security": [ { @@ -3749,245 +4053,90 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaginatedInventoryProductAdminList" + "$ref": "#/components/schemas/EventListResponse" } } }, "description": "" - } - } - } - }, - "/api/v1/admin/ledger": { - "get": { - "operationId": "api_v1_admin_ledger_retrieve", - "summary": "List outstanding inventory loans across all makerspaces", - "parameters": [ - { - "in": "query", - "name": "makerspace", - "schema": { - "type": "integer" - } - }, - { - "in": "query", - "name": "overdue", - "schema": { - "type": "boolean" - } - }, - { - "in": "query", - "name": "page", - "schema": { - "type": "integer" - } - }, - { - "in": "query", - "name": "page_size", - "schema": { - "type": "integer" - } - }, - { - "in": "query", - "name": "search", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "sort", - "schema": { - "type": "string", - "enum": [ - "-due", - "-holder", - "-item_name", - "-makerspace_id", - "-quantity", - "-since", - "-source", - "due", - "holder", - "item_name", - "makerspace_id", - "quantity", - "since", - "source" - ] - } }, - { - "in": "query", - "name": "source", - "schema": { - "type": "string", - "enum": [ - "direct", - "reviewed", - "self_checkout" - ] - } - } - ], - "tags": [ - "Ledger" - ], - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { + "400": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/LedgerResponse" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "" - } - } - } - }, - "/api/v1/admin/ledger/export": { - "get": { - "operationId": "api_v1_admin_ledger_export_retrieve", - "summary": "Export outstanding inventory loans across all makerspaces", - "parameters": [ - { - "in": "query", - "name": "format", - "schema": { - "type": "string", - "enum": [ - "csv", - "xlsx" - ] - } - }, - { - "in": "query", - "name": "makerspace", - "schema": { - "type": "integer" - } + "description": "Invalid recurring event series." }, - { - "in": "query", - "name": "overdue", - "schema": { - "type": "boolean" - } + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Authentication required." }, - { - "in": "query", - "name": "search", - "schema": { - "type": "string" - } + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Event management access denied." }, - { - "in": "query", - "name": "sort", - "schema": { - "type": "string", - "enum": [ - "-due", - "-holder", - "-item_name", - "-makerspace_id", - "-quantity", - "-since", - "-source", - "due", - "holder", - "item_name", - "makerspace_id", - "quantity", - "since", - "source" - ] - } + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Event series not found." }, - { - "in": "query", - "name": "source", - "schema": { - "type": "string", - "enum": [ - "direct", - "reviewed", - "self_checkout" - ] - } - } - ], - "tags": [ - "Ledger" - ], - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { + "409": { "content": { - "text/csv": { + "application/json": { "schema": { - "type": "string" + "$ref": "#/components/schemas/HardwareRequestError" } - }, - "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": { + } + }, + "description": "Series state conflict." + }, + "429": { + "content": { + "application/json": { "schema": { - "type": "string", - "format": "binary" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "" + "description": "Rate limit exceeded." } } } }, - "/api/v1/admin/machine-service-report": { - "get": { - "operationId": "api_v1_admin_machine_service_report_retrieve", - "summary": "Retrieve aggregate machine-service report", + "/api/v1/admin/event-series/{id}/publish/": { + "post": { + "operationId": "api_v1_admin_event_series_publish_create", "parameters": [ { - "in": "query", - "name": "end", - "schema": { - "type": "string", - "format": "date" - } - }, - { - "in": "query", - "name": "machine_type", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "start", + "in": "path", + "name": "id", "schema": { - "type": "string", - "format": "date" - } + "type": "integer" + }, + "required": true } ], "tags": [ - "Admin machine service" + "Admin event series" ], "security": [ { @@ -3999,7 +4148,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MachineServiceReportResponse" + "$ref": "#/components/schemas/EventSeriesMutationResponse" } } }, @@ -4013,23 +4162,65 @@ } } }, - "description": "Invalid request." + "description": "Invalid recurring event series." }, "401": { - "description": "Authentication credentials were not provided." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Authentication required." }, "403": { - "description": "Machine management permission required." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Event management access denied." }, "404": { - "description": "Not found." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Event series not found." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Series state conflict." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Rate limit exceeded." } } } }, - "/api/v1/admin/machine-service/consumable-pools/{id}": { + "/api/v1/admin/events/{id}/": { "get": { - "operationId": "api_v1_admin_machine_service_consumable_pools_retrieve", + "operationId": "api_v1_admin_events_retrieve", + "summary": "Retrieve an event", "parameters": [ { "in": "path", @@ -4041,7 +4232,7 @@ } ], "tags": [ - "Admin machine service" + "Admin events" ], "security": [ { @@ -4053,7 +4244,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PrinterPool" + "$ref": "#/components/schemas/EventAdmin" } } }, @@ -4062,7 +4253,8 @@ } }, "patch": { - "operationId": "api_v1_admin_machine_service_consumable_pools_partial_update", + "operationId": "api_v1_admin_events_partial_update", + "summary": "Update an event", "parameters": [ { "in": "path", @@ -4074,23 +4266,23 @@ } ], "tags": [ - "Admin machine service" + "Admin events" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PatchedPrinterPoolVisibility" + "$ref": "#/components/schemas/PatchedEventWrite" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/PatchedPrinterPoolVisibility" + "$ref": "#/components/schemas/PatchedEventWrite" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/PatchedPrinterPoolVisibility" + "$ref": "#/components/schemas/PatchedEventWrite" } } } @@ -4105,74 +4297,39 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PrinterPool" + "$ref": "#/components/schemas/EventAdmin" } } }, "description": "" - } - } - } - }, - "/api/v1/admin/machine-service/consumable-pools/{id}/adjustments": { - "post": { - "operationId": "api_v1_admin_machine_service_consumable_pools_adjustments_create", - "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" - }, - "required": true - } - ], - "tags": [ - "Admin machine service" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PrinterPoolCorrection" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/PrinterPoolCorrection" + }, + "400": { + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": {} + } } }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/PrinterPoolCorrection" - } - } + "description": "Invalid event details." }, - "required": true - }, - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { + "409": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PrinterPool" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "" + "description": "Event state or capacity conflict." } } } }, - "/api/v1/admin/machine-service/files/{id}": { - "delete": { - "operationId": "api_v1_admin_machine_service_files_destroy", - "summary": "Delete a staged service attachment", + "/api/v1/admin/events/{id}/badge-template/": { + "get": { + "operationId": "api_v1_admin_events_badge_template_retrieve", "parameters": [ { "in": "path", @@ -4184,7 +4341,7 @@ } ], "tags": [ - "Admin machine service" + "Admin events" ], "security": [ { @@ -4192,29 +4349,27 @@ } ], "responses": { - "204": { - "description": "No response body" - }, - "400": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/BadgeTemplate" } } }, - "description": "Invalid attachment input." - }, - "401": { - "description": "Authentication required." + "description": "" }, "403": { - "description": "Machine management permission required." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" }, "404": { - "description": "Service request or file was not found." - }, - "409": { "content": { "application/json": { "schema": { @@ -4222,18 +4377,12 @@ } } }, - "description": "Attachment conflict." - }, - "503": { - "description": "Private storage is unavailable." + "description": "" } } - } - }, - "/api/v1/admin/machine-service/files/{id}/url": { - "get": { - "operationId": "api_v1_admin_machine_service_files_url_retrieve", - "summary": "Create a signed service attachment URL", + }, + "put": { + "operationId": "api_v1_admin_events_badge_template_update", "parameters": [ { "in": "path", @@ -4245,8 +4394,27 @@ } ], "tags": [ - "Admin machine service" + "Admin events" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadgeTemplate" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/BadgeTemplate" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/BadgeTemplate" + } + } + } + }, "security": [ { "jwtAuth": [] @@ -4257,7 +4425,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ServiceFileUrl" + "$ref": "#/components/schemas/BadgeTemplate" } } }, @@ -4267,20 +4435,32 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "type": "object", + "additionalProperties": {} } } }, - "description": "Invalid attachment input." - }, - "401": { - "description": "Authentication required." + "description": "Invalid event details." }, "403": { - "description": "Machine management permission required." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" }, "404": { - "description": "Service request or file was not found." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" }, "409": { "content": { @@ -4290,18 +4470,14 @@ } } }, - "description": "Attachment conflict." - }, - "503": { - "description": "Private storage is unavailable." + "description": "Event state or capacity conflict." } } } }, - "/api/v1/admin/machine-service/payments/{id}/mark-offline": { + "/api/v1/admin/events/{id}/badges.pdf": { "post": { - "operationId": "api_v1_admin_machine_service_payments_mark_offline_create", - "summary": "Mark a machine-service payment paid offline", + "operationId": "api_v1_admin_events_badges.pdf_create", "parameters": [ { "in": "path", @@ -4313,8 +4489,28 @@ } ], "tags": [ - "Payments" + "Admin events" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadgePdfRequest" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/BadgePdfRequest" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/BadgePdfRequest" + } + } + }, + "required": true + }, "security": [ { "jwtAuth": [] @@ -4322,14 +4518,26 @@ ], "responses": { "200": { + "content": { + "application/pdf": { + "schema": { + "type": "string", + "format": "binary" + } + } + }, + "description": "Print-ready badge PDF." + }, + "400": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/StaffPayment" + "type": "object", + "additionalProperties": {} } } }, - "description": "" + "description": "Invalid event details." }, "403": { "content": { @@ -4350,14 +4558,24 @@ } }, "description": "" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Event state or capacity conflict." } } } }, - "/api/v1/admin/machine-service/payments/{id}/waive": { + "/api/v1/admin/events/{id}/cancel/": { "post": { - "operationId": "api_v1_admin_machine_service_payments_waive_create", - "summary": "Waive a machine-service payment", + "operationId": "api_v1_admin_events_cancel_create", + "summary": "Cancel an event", "parameters": [ { "in": "path", @@ -4369,7 +4587,7 @@ } ], "tags": [ - "Payments" + "Admin events" ], "security": [ { @@ -4381,23 +4599,13 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/StaffPayment" - } - } - }, - "description": "" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/EventAdmin" } } }, "description": "" }, - "404": { + "409": { "content": { "application/json": { "schema": { @@ -4405,15 +4613,15 @@ } } }, - "description": "" + "description": "Event state or capacity conflict." } } } }, - "/api/v1/admin/machine-service/requests/{id}": { + "/api/v1/admin/events/{id}/check-in/offline-roster/": { "get": { - "operationId": "api_v1_admin_machine_service_requests_retrieve", - "summary": "Retrieve a machine service request", + "operationId": "api_v1_admin_events_check_in_offline_roster_retrieve", + "summary": "Download a minimal expiring offline check-in roster", "parameters": [ { "in": "path", @@ -4425,7 +4633,7 @@ } ], "tags": [ - "Admin machine service" + "Admin events" ], "security": [ { @@ -4437,7 +4645,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MachineServiceRequest" + "$ref": "#/components/schemas/OfflineRosterResponse" } } }, @@ -4451,16 +4659,37 @@ } } }, - "description": "Invalid service request input." + "description": "Feature disabled or invalid batch." }, "401": { - "description": "Authentication required." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Authentication or lease failed." }, "403": { - "description": "Machine management permission required." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Event authority changed." }, "404": { - "description": "Service request was not found." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Event not found." }, "409": { "content": { @@ -4470,15 +4699,45 @@ } } }, - "description": "Service workflow conflict." + "description": "Roster window closed." + }, + "410": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Synchronization deadline passed." + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Roster exceeds the offline limit." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Request rate limit exceeded." } } } }, - "/api/v1/admin/machine-service/requests/{id}/accept": { + "/api/v1/admin/events/{id}/check-in/offline-sync/": { "post": { - "operationId": "api_v1_admin_machine_service_requests_accept_create", - "summary": "Accept a machine service request", + "operationId": "api_v1_admin_events_check_in_offline_sync_create", + "summary": "Synchronize queued offline event check-ins", "parameters": [ { "in": "path", @@ -4490,26 +4749,27 @@ } ], "tags": [ - "Admin machine service" + "Admin events" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ServiceAccept" + "$ref": "#/components/schemas/OfflineCheckInSyncRequest" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/ServiceAccept" + "$ref": "#/components/schemas/OfflineCheckInSyncRequest" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/ServiceAccept" + "$ref": "#/components/schemas/OfflineCheckInSyncRequest" } } - } + }, + "required": true }, "security": [ { @@ -4521,7 +4781,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MachineServiceRequest" + "$ref": "#/components/schemas/OfflineCheckInSyncResponse" } } }, @@ -4535,16 +4795,37 @@ } } }, - "description": "Invalid service request input." + "description": "Feature disabled or invalid batch." }, "401": { - "description": "Authentication required." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Authentication or lease failed." }, "403": { - "description": "Machine management permission required." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Event authority changed." }, "404": { - "description": "Service request was not found." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Event not found." }, "409": { "content": { @@ -4554,45 +4835,19 @@ } } }, - "description": "Service workflow conflict." - } - } - } - }, - "/api/v1/admin/machine-service/requests/{id}/collect": { - "post": { - "operationId": "api_v1_admin_machine_service_requests_collect_create", - "summary": "Mark a machine service request collected", - "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" - }, - "required": true - } - ], - "tags": [ - "Admin machine service" - ], - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { + "description": "Roster window closed." + }, + "410": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MachineServiceRequest" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "" + "description": "Synchronization deadline passed." }, - "400": { + "413": { "content": { "application/json": { "schema": { @@ -4600,18 +4855,9 @@ } } }, - "description": "Invalid service request input." - }, - "401": { - "description": "Authentication required." - }, - "403": { - "description": "Machine management permission required." + "description": "Roster exceeds the offline limit." }, - "404": { - "description": "Service request was not found." - }, - "409": { + "429": { "content": { "application/json": { "schema": { @@ -4619,15 +4865,15 @@ } } }, - "description": "Service workflow conflict." + "description": "Request rate limit exceeded." } } } }, - "/api/v1/admin/machine-service/requests/{id}/complete": { + "/api/v1/admin/events/{id}/check-in/resolve/": { "post": { - "operationId": "api_v1_admin_machine_service_requests_complete_create", - "summary": "Complete machine service work", + "operationId": "api_v1_admin_events_check_in_resolve_create", + "summary": "Resolve an event check-in token", "parameters": [ { "in": "path", @@ -4639,23 +4885,23 @@ } ], "tags": [ - "Admin machine service" + "Admin events" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ServiceComplete" + "$ref": "#/components/schemas/EventCheckInResolveRequest" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/ServiceComplete" + "$ref": "#/components/schemas/EventCheckInResolveRequest" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/ServiceComplete" + "$ref": "#/components/schemas/EventCheckInResolveRequest" } } }, @@ -4671,13 +4917,13 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MachineServiceRequest" + "$ref": "#/components/schemas/EventCheckInResolveResponse" } } }, "description": "" }, - "400": { + "403": { "content": { "application/json": { "schema": { @@ -4685,18 +4931,19 @@ } } }, - "description": "Invalid service request input." - }, - "401": { - "description": "Authentication required." - }, - "403": { - "description": "Machine management permission required." + "description": "Event access denied." }, "404": { - "description": "Service request was not found." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Registration not found." }, - "409": { + "429": { "content": { "application/json": { "schema": { @@ -4704,15 +4951,15 @@ } } }, - "description": "Service workflow conflict." + "description": "Request rate limit exceeded." } } } }, - "/api/v1/admin/machine-service/requests/{id}/fail": { - "post": { - "operationId": "api_v1_admin_machine_service_requests_fail_create", - "summary": "Mark machine service work failed", + "/api/v1/admin/events/{id}/check-in/station/": { + "get": { + "operationId": "api_v1_admin_events_check_in_station_retrieve", + "summary": "Read venue-station configuration without revealing its PIN", "parameters": [ { "in": "path", @@ -4724,28 +4971,8 @@ } ], "tags": [ - "Admin machine service" + "Admin events" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ServiceFail" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/ServiceFail" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/ServiceFail" - } - } - }, - "required": true - }, "security": [ { "jwtAuth": [] @@ -4756,7 +4983,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MachineServiceRequest" + "$ref": "#/components/schemas/StationStatus" } } }, @@ -4770,18 +4997,9 @@ } } }, - "description": "Invalid service request input." - }, - "401": { - "description": "Authentication required." + "description": "Feature disabled or invalid request." }, "403": { - "description": "Machine management permission required." - }, - "404": { - "description": "Service request was not found." - }, - "409": { "content": { "application/json": { "schema": { @@ -4789,65 +5007,19 @@ } } }, - "description": "Service workflow conflict." - } - } - } - }, - "/api/v1/admin/machine-service/requests/{id}/files/finalize": { - "post": { - "operationId": "api_v1_admin_machine_service_requests_files_finalize_create", - "summary": "Finalize a service attachment upload", - "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" - }, - "required": true - } - ], - "tags": [ - "Admin machine service" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ServiceFileFinalize" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/ServiceFileFinalize" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/ServiceFileFinalize" - } - } + "description": "Event access or step-up denied." }, - "required": true - }, - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "201": { + "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ServiceFileFinalizeResponse" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "" + "description": "Event not found." }, - "400": { + "409": { "content": { "application/json": { "schema": { @@ -4855,18 +5027,9 @@ } } }, - "description": "Invalid attachment input." + "description": "Rotate the PIN instead of revealing." }, - "401": { - "description": "Authentication required." - }, - "403": { - "description": "Machine management permission required." - }, - "404": { - "description": "Service request or file was not found." - }, - "409": { + "429": { "content": { "application/json": { "schema": { @@ -4874,18 +5037,23 @@ } } }, - "description": "Attachment conflict." + "description": "Request rate limit exceeded." }, "503": { - "description": "Private storage is unavailable." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Credential secrets are unavailable." } } - } - }, - "/api/v1/admin/machine-service/requests/{id}/files/presign": { - "post": { - "operationId": "api_v1_admin_machine_service_requests_files_presign_create", - "summary": "Create a service attachment upload URL", + }, + "delete": { + "operationId": "api_v1_admin_events_check_in_station_destroy", + "summary": "Disable a venue check-in station", "parameters": [ { "in": "path", @@ -4897,39 +5065,19 @@ } ], "tags": [ - "Admin machine service" + "Admin events" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ServiceFilePresign" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/ServiceFilePresign" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/ServiceFilePresign" - } - } - }, - "required": true - }, "security": [ { "jwtAuth": [] } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ServiceFilePresignResponse" + "$ref": "#/components/schemas/StationStatus" } } }, @@ -4943,16 +5091,27 @@ } } }, - "description": "Invalid attachment input." - }, - "401": { - "description": "Authentication required." + "description": "Feature disabled or invalid request." }, "403": { - "description": "Machine management permission required." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Event access or step-up denied." }, "404": { - "description": "Service request or file was not found." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Event not found." }, "409": { "content": { @@ -4962,18 +5121,35 @@ } } }, - "description": "Attachment conflict." + "description": "Rotate the PIN instead of revealing." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Request rate limit exceeded." }, "503": { - "description": "Private storage is unavailable." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Credential secrets are unavailable." } } } }, - "/api/v1/admin/machine-service/requests/{id}/reject": { + "/api/v1/admin/events/{id}/check-in/station/reveal/": { "post": { - "operationId": "api_v1_admin_machine_service_requests_reject_create", - "summary": "Reject a machine service request", + "operationId": "api_v1_admin_events_check_in_station_reveal_create", + "summary": "Reveal the current station PIN after password step-up", "parameters": [ { "in": "path", @@ -4985,23 +5161,23 @@ } ], "tags": [ - "Admin machine service" + "Admin events" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ServiceReject" + "$ref": "#/components/schemas/StationReveal" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/ServiceReject" + "$ref": "#/components/schemas/StationReveal" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/ServiceReject" + "$ref": "#/components/schemas/StationReveal" } } }, @@ -5017,7 +5193,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MachineServiceRequest" + "$ref": "#/components/schemas/StationRevealResponse" } } }, @@ -5031,16 +5207,27 @@ } } }, - "description": "Invalid service request input." - }, - "401": { - "description": "Authentication required." + "description": "Feature disabled or invalid request." }, "403": { - "description": "Machine management permission required." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Event access or step-up denied." }, "404": { - "description": "Service request was not found." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Event not found." }, "409": { "content": { @@ -5050,15 +5237,35 @@ } } }, - "description": "Service workflow conflict." + "description": "Rotate the PIN instead of revealing." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Request rate limit exceeded." + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Credential secrets are unavailable." } } } }, - "/api/v1/admin/machine-service/requests/{id}/reprint": { + "/api/v1/admin/events/{id}/check-in/station/rotate/": { "post": { - "operationId": "api_v1_admin_machine_service_requests_reprint_create", - "summary": "Create a printer reprint", + "operationId": "api_v1_admin_events_check_in_station_rotate_create", + "summary": "Create or rotate an event-scoped eight-digit station PIN", "parameters": [ { "in": "path", @@ -5070,7 +5277,7 @@ } ], "tags": [ - "Admin machine service" + "Admin events" ], "security": [ { @@ -5082,7 +5289,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MachineServiceRequest" + "$ref": "#/components/schemas/StationRotation" } } }, @@ -5096,18 +5303,9 @@ } } }, - "description": "Invalid service request input." - }, - "401": { - "description": "Authentication required." + "description": "Feature disabled or invalid request." }, "403": { - "description": "Machine management permission required." - }, - "404": { - "description": "Service request was not found." - }, - "409": { "content": { "application/json": { "schema": { @@ -5115,15 +5313,55 @@ } } }, - "description": "Service workflow conflict." + "description": "Event access or step-up denied." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Event not found." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Rotate the PIN instead of revealing." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Request rate limit exceeded." + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Credential secrets are unavailable." } } } }, - "/api/v1/admin/machine-service/requests/{id}/start": { - "post": { - "operationId": "api_v1_admin_machine_service_requests_start_create", - "summary": "Start machine service work", + "/api/v1/admin/events/{id}/collaborators/": { + "get": { + "operationId": "api_v1_admin_events_collaborators_list", + "summary": "List an event's collaborators", "parameters": [ { "in": "path", @@ -5135,26 +5373,94 @@ } ], "tags": [ - "Admin machine service" + "Admin events" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EventCollaborator" + } + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Invalid collaboration request." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Event management access denied." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Event collaboration not found." + } + } + }, + "put": { + "operationId": "api_v1_admin_events_collaborators_update", + "summary": "Replace an event's collaborators", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Admin events" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ServiceStart" + "$ref": "#/components/schemas/EventCollaboratorReplace" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/ServiceStart" + "$ref": "#/components/schemas/EventCollaboratorReplace" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/ServiceStart" + "$ref": "#/components/schemas/EventCollaboratorReplace" } } - } + }, + "required": true }, "security": [ { @@ -5166,7 +5472,10 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MachineServiceRequest" + "type": "array", + "items": { + "$ref": "#/components/schemas/EventCollaborator" + } } } }, @@ -5180,18 +5489,19 @@ } } }, - "description": "Invalid service request input." - }, - "401": { - "description": "Authentication required." + "description": "Invalid collaboration request." }, "403": { - "description": "Machine management permission required." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Event management access denied." }, "404": { - "description": "Service request was not found." - }, - "409": { "content": { "application/json": { "schema": { @@ -5199,15 +5509,15 @@ } } }, - "description": "Service workflow conflict." + "description": "Event collaboration not found." } } } }, - "/api/v1/admin/machines/{id}": { - "get": { - "operationId": "api_v1_admin_machines_retrieve", - "summary": "Retrieve a machine", + "/api/v1/admin/events/{id}/complete/": { + "post": { + "operationId": "api_v1_admin_events_complete_create", + "summary": "Complete an event", "parameters": [ { "in": "path", @@ -5219,7 +5529,7 @@ } ], "tags": [ - "Admin machines" + "Admin events" ], "security": [ { @@ -5231,17 +5541,30 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Machine" + "$ref": "#/components/schemas/EventAdmin" } } }, "description": "" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Event state or capacity conflict." } } - }, - "patch": { - "operationId": "api_v1_admin_machines_partial_update", - "summary": "Update a machine", + } + }, + "/api/v1/admin/events/{id}/eligible-members/": { + "get": { + "operationId": "api_v1_admin_events_eligible_members_list", + "description": "The roster the staff registration picker reads.\n\nHung off the EVENT rather than the makerspace, so it inherits `_manageable_event`\nand introduces no new authority question: whoever may manage this event may see who\nthey can register for it. A separate makerspace-level member list would have needed\nits own permission answer, and the obvious candidates were all wrong — the\ndirect-loan roster is gated on `ISSUE_DIRECT_LOAN` plus a self-checkout feature an\nevents manager need not hold, and the full membership list is `MANAGE_MAKERSPACE`.\n\nAlready-registered members are excluded: offering someone the picker can only reject\nas a duplicate is an error the interface should not have made available.", + "summary": "List members who can be registered for an event", "parameters": [ { "in": "path", @@ -5253,27 +5576,8 @@ } ], "tags": [ - "Admin machines" + "Admin events" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PatchedMachine" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/PatchedMachine" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/PatchedMachine" - } - } - } - }, "security": [ { "jwtAuth": [] @@ -5284,22 +5588,21 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Machine" + "type": "array", + "items": { + "$ref": "#/components/schemas/EventEligibleMember" + } } } }, "description": "" - }, - "400": { - "description": "Invalid machine details." } } } }, - "/api/v1/admin/machines/{id}/consumable-candidates": { + "/api/v1/admin/events/{id}/feedback-responses/": { "get": { - "operationId": "api_v1_admin_machines_consumable_candidates_list", - "summary": "List inventory products eligible as count consumables", + "operationId": "api_v1_admin_events_feedback_responses_retrieve", "parameters": [ { "in": "path", @@ -5311,7 +5614,7 @@ } ], "tags": [ - "Admin machines" + "Admin events" ], "security": [ { @@ -5323,28 +5626,58 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ConsumableCandidate" - } + "$ref": "#/components/schemas/FeedbackResponseList" } } }, "description": "" }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Invalid request." + }, "403": { - "description": "Machine operation is not permitted." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Event management is required." }, "404": { - "description": "Machine not found." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Event resource not found." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Event state conflict." } } } }, - "/api/v1/admin/machines/{id}/consumables": { + "/api/v1/admin/events/{id}/feedback-survey/": { "get": { - "operationId": "api_v1_admin_machines_consumables_list", - "summary": "List machine consumables", + "operationId": "api_v1_admin_events_feedback_survey_retrieve", "parameters": [ { "in": "path", @@ -5356,7 +5689,7 @@ } ], "tags": [ - "Admin machines" + "Admin events" ], "security": [ { @@ -5368,26 +5701,56 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/MachineConsumable" - } + "$ref": "#/components/schemas/FeedbackSurveyAdminEnvelope" } } }, "description": "" }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Invalid request." + }, "403": { - "description": "Machine operation is not permitted." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Event management is required." }, "404": { - "description": "Machine not found." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Event resource not found." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Event state conflict." } } }, - "post": { - "operationId": "api_v1_admin_machines_consumables_create", - "summary": "Link a count or grams consumable to a machine", + "put": { + "operationId": "api_v1_admin_events_feedback_survey_update", "parameters": [ { "in": "path", @@ -5399,23 +5762,23 @@ } ], "tags": [ - "Admin machines" + "Admin events" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/LinkMachineConsumable" + "$ref": "#/components/schemas/FeedbackSurveyWrite" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/LinkMachineConsumable" + "$ref": "#/components/schemas/FeedbackSurveyWrite" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/LinkMachineConsumable" + "$ref": "#/components/schemas/FeedbackSurveyWrite" } } }, @@ -5427,41 +5790,63 @@ } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MachineConsumable" + "$ref": "#/components/schemas/FeedbackSurvey" } } }, "description": "" }, "400": { - "description": "Invalid consumable." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Invalid request." }, "403": { - "description": "Machine management is not permitted." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Event management is required." }, "404": { - "description": "Machine not found." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Event resource not found." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Event state conflict." } } } }, - "/api/v1/admin/machines/{id}/consumables/{cid}": { - "delete": { - "operationId": "api_v1_admin_machines_consumables_destroy", - "summary": "Unlink a machine consumable", + "/api/v1/admin/events/{id}/feedback-survey/close/": { + "post": { + "operationId": "api_v1_admin_events_feedback_survey_close_create", "parameters": [ - { - "in": "path", - "name": "cid", - "schema": { - "type": "integer" - }, - "required": true - }, { "in": "path", "name": "id", @@ -5472,7 +5857,7 @@ } ], "tags": [ - "Admin machines" + "Admin events" ], "security": [ { @@ -5480,34 +5865,139 @@ } ], "responses": { - "204": { - "description": "No response body" + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FeedbackSurvey" + } + } + }, + "description": "" }, "400": { - "description": "Consumable is not linked." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Invalid request." }, "403": { - "description": "Machine management is not permitted." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Event management is required." }, "404": { - "description": "Machine not found." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Event resource not found." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Event state conflict." } } } }, - "/api/v1/admin/machines/{id}/consumables/{cid}/log": { + "/api/v1/admin/events/{id}/feedback-survey/open/": { "post": { - "operationId": "api_v1_admin_machines_consumables_log_create", - "summary": "Log machine consumable usage", + "operationId": "api_v1_admin_events_feedback_survey_open_create", "parameters": [ { "in": "path", - "name": "cid", + "name": "id", "schema": { "type": "integer" }, "required": true + } + ], + "tags": [ + "Admin events" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FeedbackSurvey" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Invalid request." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Event management is required." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Event resource not found." }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Event state conflict." + } + } + } + }, + "/api/v1/admin/events/{id}/image": { + "post": { + "operationId": "api_v1_admin_events_image_create", + "summary": "Create an event image upload URL", + "parameters": [ { "in": "path", "name": "id", @@ -5518,23 +6008,23 @@ } ], "tags": [ - "Admin machines" + "Admin events" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/LogMachineConsumption" + "$ref": "#/components/schemas/PublicImageUploadRequest" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/LogMachineConsumption" + "$ref": "#/components/schemas/PublicImageUploadRequest" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/LogMachineConsumption" + "$ref": "#/components/schemas/PublicImageUploadRequest" } } }, @@ -5546,32 +6036,33 @@ } ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MachineConsumable" + "$ref": "#/components/schemas/PublicImageUploadResponse" } } }, "description": "" }, "400": { - "description": "Invalid quantity or insufficient stock." + "description": "Invalid image upload request." }, "403": { - "description": "Machine operation is not permitted." + "description": "Event management access is required." }, "404": { - "description": "Machine not found." + "description": "Event not found." + }, + "503": { + "description": "Public image storage is unavailable." } } - } - }, - "/api/v1/admin/machines/{id}/documents": { - "get": { - "operationId": "api_v1_admin_machines_documents_list", - "summary": "List machine documents", + }, + "put": { + "operationId": "api_v1_admin_events_image_update", + "summary": "Attach an uploaded image to an event", "parameters": [ { "in": "path", @@ -5583,9 +6074,29 @@ } ], "tags": [ - "Admin machines" + "Admin events" ], - "security": [ + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicImageAttachRequest" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/PublicImageAttachRequest" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/PublicImageAttachRequest" + } + } + }, + "required": true + }, + "security": [ { "jwtAuth": [] } @@ -5595,20 +6106,29 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/MachineDocument" - } + "$ref": "#/components/schemas/EventAdmin" } } }, "description": "" + }, + "400": { + "description": "Invalid image object key or size." + }, + "403": { + "description": "Event management access is required." + }, + "404": { + "description": "Event not found." + }, + "503": { + "description": "Public image storage is unavailable." } } }, - "post": { - "operationId": "api_v1_admin_machines_documents_create", - "summary": "Finalize a machine document upload", + "delete": { + "operationId": "api_v1_admin_events_image_destroy", + "summary": "Clear an event image", "parameters": [ { "in": "path", @@ -5620,57 +6140,37 @@ } ], "tags": [ - "Admin machines" + "Admin events" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DocumentFinalize" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/DocumentFinalize" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/DocumentFinalize" - } - } - }, - "required": true - }, "security": [ { "jwtAuth": [] } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MachineDocument" + "$ref": "#/components/schemas/EventAdmin" } } }, "description": "" }, - "400": { - "description": "Invalid machine document." + "403": { + "description": "Event management access is required." }, - "503": { - "description": "Machine document storage is unavailable." + "404": { + "description": "Event not found." } } } }, - "/api/v1/admin/machines/{id}/documents/presign": { - "post": { - "operationId": "api_v1_admin_machines_documents_presign_create", - "summary": "Create a machine document upload URL", + "/api/v1/admin/events/{id}/organizers/": { + "put": { + "operationId": "api_v1_admin_events_organizers_update", + "summary": "Replace an event's organization organizers", "parameters": [ { "in": "path", @@ -5682,23 +6182,23 @@ } ], "tags": [ - "Admin machines" + "Admin events" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DocumentPresign" + "$ref": "#/components/schemas/EventOrganizerReplace" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/DocumentPresign" + "$ref": "#/components/schemas/EventOrganizerReplace" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/DocumentPresign" + "$ref": "#/components/schemas/EventOrganizerReplace" } } }, @@ -5710,22 +6210,59 @@ } ], "responses": { - "201": { - "description": "Machine document upload details." + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EventOrganizerList" + } + } + }, + "description": "" }, "400": { - "description": "Invalid document upload request." + "description": "Invalid or unavailable organization." }, - "503": { - "description": "Machine document storage is unavailable." + "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": { + "description": "Concurrent event state conflict." } } } }, - "/api/v1/admin/machines/{id}/error-logs": { - "get": { - "operationId": "api_v1_admin_machines_error_logs_list", - "summary": "List machine error logs", + "/api/v1/admin/events/{id}/publish/": { + "post": { + "operationId": "api_v1_admin_events_publish_create", + "summary": "Publish an event", "parameters": [ { "in": "path", @@ -5737,7 +6274,7 @@ } ], "tags": [ - "Admin machines" + "Admin events" ], "security": [ { @@ -5749,79 +6286,40 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/MachineErrorLog" - } + "$ref": "#/components/schemas/EventAdmin" } } }, "description": "" - } - } - }, - "post": { - "operationId": "api_v1_admin_machines_error_logs_create", - "summary": "Log a machine error", - "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" - }, - "required": true - } - ], - "tags": [ - "Admin machines" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/LogError" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/LogError" + }, + "400": { + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": {} + } } }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/LogError" - } - } + "description": "Invalid event details." }, - "required": true - }, - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "201": { + "409": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MachineErrorLog" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "" - }, - "400": { - "description": "Invalid error log." + "description": "Event state or capacity conflict." } } } }, - "/api/v1/admin/machines/{id}/image": { - "post": { - "operationId": "api_v1_admin_machines_image_create", - "summary": "Create a machine image upload URL", + "/api/v1/admin/events/{id}/registrations/": { + "get": { + "operationId": "api_v1_admin_events_registrations_retrieve", + "summary": "List event registrations", "parameters": [ { "in": "path", @@ -5833,61 +6331,29 @@ } ], "tags": [ - "Admin machines" + "Admin events" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PublicImageUploadRequest" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/PublicImageUploadRequest" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/PublicImageUploadRequest" - } - } - }, - "required": true - }, "security": [ { "jwtAuth": [] } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicImageUploadResponse" + "$ref": "#/components/schemas/EventRegistrationListResponse" } } }, "description": "" - }, - "400": { - "description": "Invalid image upload request." - }, - "403": { - "description": "Machine management access is required." - }, - "404": { - "description": "Machine not found." - }, - "503": { - "description": "Public image storage is unavailable." } } }, - "put": { - "operationId": "api_v1_admin_machines_image_update", - "summary": "Attach an uploaded image to a machine", + "post": { + "operationId": "api_v1_admin_events_registrations_create", + "summary": "Register a member for an event", "parameters": [ { "in": "path", @@ -5899,23 +6365,23 @@ } ], "tags": [ - "Admin machines" + "Admin events" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicImageAttachRequest" + "$ref": "#/components/schemas/EventStaffRegistration" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/PublicImageAttachRequest" + "$ref": "#/components/schemas/EventStaffRegistration" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/PublicImageAttachRequest" + "$ref": "#/components/schemas/EventStaffRegistration" } } }, @@ -5927,75 +6393,42 @@ } ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Machine" + "$ref": "#/components/schemas/EventRegistrationAdmin" } } }, "description": "" }, "400": { - "description": "Invalid image object key or size." - }, - "403": { - "description": "Machine management access is required." - }, - "404": { - "description": "Machine not found." - }, - "503": { - "description": "Public image storage is unavailable." - } - } - }, - "delete": { - "operationId": "api_v1_admin_machines_image_destroy", - "summary": "Clear a machine image", - "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" - }, - "required": true - } - ], - "tags": [ - "Admin machines" - ], - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Machine" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "" - }, - "403": { - "description": "Machine management access is required." + "description": "Invalid registration." }, - "404": { - "description": "Machine not found." + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Event state or capacity conflict." } } } }, - "/api/v1/admin/machines/{id}/operator-candidates": { + "/api/v1/admin/evidence/{id}": { "get": { - "operationId": "api_v1_admin_machines_operator_candidates_list", - "summary": "List active members eligible for machine operator assignment", + "operationId": "api_v1_admin_evidence_retrieve", "parameters": [ { "in": "path", @@ -6007,7 +6440,7 @@ } ], "tags": [ - "Admin machines" + "api" ], "security": [ { @@ -6019,28 +6452,19 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/OperatorCandidate" - } + "$ref": "#/components/schemas/EvidenceGetResponse" } } }, "description": "" - }, - "403": { - "description": "Operator delegation is not permitted." - }, - "404": { - "description": "Machine not found." } } } }, - "/api/v1/admin/machines/{id}/operators": { + "/api/v1/admin/inventory/{id}": { "get": { - "operationId": "api_v1_admin_machines_operators_list", - "summary": "List machine operators", + "operationId": "api_v1_admin_inventory_retrieve", + "summary": "Retrieve or update inventory product", "parameters": [ { "in": "path", @@ -6052,7 +6476,7 @@ } ], "tags": [ - "Admin machines" + "Admin inventory" ], "security": [ { @@ -6064,10 +6488,7 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/MachineOperator" - } + "$ref": "#/components/schemas/InventoryProductAdmin" } } }, @@ -6075,9 +6496,9 @@ } } }, - "post": { - "operationId": "api_v1_admin_machines_operators_create", - "summary": "Assign a machine operator", + "patch": { + "operationId": "api_v1_admin_inventory_partial_update", + "summary": "Retrieve or update inventory product", "parameters": [ { "in": "path", @@ -6089,27 +6510,26 @@ } ], "tags": [ - "Admin machines" + "Admin inventory" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AssignOperator" + "$ref": "#/components/schemas/PatchedInventoryProductAdminUpdate" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/AssignOperator" + "$ref": "#/components/schemas/PatchedInventoryProductAdminUpdate" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/AssignOperator" + "$ref": "#/components/schemas/PatchedInventoryProductAdminUpdate" } } - }, - "required": true + } }, "security": [ { @@ -6117,26 +6537,23 @@ } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MachineOperator" + "$ref": "#/components/schemas/InventoryProductAdminUpdate" } } }, "description": "" - }, - "400": { - "description": "Invalid operator assignment." } } } }, - "/api/v1/admin/machines/{id}/operators/{user_pk}": { - "patch": { - "operationId": "api_v1_admin_machines_operators_partial_update", - "summary": "Update a machine operator", + "/api/v1/admin/inventory/{id}/adjust-quantity": { + "post": { + "operationId": "api_v1_admin_inventory_adjust_quantity_create", + "summary": "Adjust inventory quantity buckets", "parameters": [ { "in": "path", @@ -6145,37 +6562,30 @@ "type": "integer" }, "required": true - }, - { - "in": "path", - "name": "user_pk", - "schema": { - "type": "integer" - }, - "required": true } ], "tags": [ - "Admin machines" + "Admin inventory" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PatchedAssignOperator" + "$ref": "#/components/schemas/InventoryQuantityAdjustment" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/PatchedAssignOperator" + "$ref": "#/components/schemas/InventoryQuantityAdjustment" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/PatchedAssignOperator" + "$ref": "#/components/schemas/InventoryQuantityAdjustment" } } - } + }, + "required": true }, "security": [ { @@ -6187,20 +6597,19 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MachineOperator" + "$ref": "#/components/schemas/InventoryProductAdmin" } } }, "description": "" - }, - "400": { - "description": "Invalid operator assignment." } } - }, - "delete": { - "operationId": "api_v1_admin_machines_operators_destroy", - "summary": "Remove a machine operator", + } + }, + "/api/v1/admin/inventory/{id}/chain-of-custody": { + "get": { + "operationId": "api_v1_admin_inventory_chain_of_custody_retrieve", + "summary": "Read-only chain of custody for one inventory item", "parameters": [ { "in": "path", @@ -6211,16 +6620,16 @@ "required": true }, { - "in": "path", - "name": "user_pk", + "in": "query", + "name": "limit", "schema": { "type": "integer" }, - "required": true + "description": "Maximum history events to return. Defaults to 200; capped at 500." } ], "tags": [ - "Admin machines" + "Admin inventory" ], "security": [ { @@ -6228,16 +6637,63 @@ } ], "responses": { - "204": { - "description": "No response body" + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InventoryChainOfCustodyResponse" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Invalid request." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Permission denied." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Not found." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Workflow conflict." } } } }, - "/api/v1/admin/machines/{id}/publicity": { - "get": { - "operationId": "api_v1_admin_machines_publicity_retrieve", - "summary": "Preview a machine public listing", + "/api/v1/admin/inventory/{id}/image": { + "post": { + "operationId": "api_v1_admin_inventory_image_create", + "summary": "Create an inventory product image upload URL", "parameters": [ { "in": "path", @@ -6249,29 +6705,55 @@ } ], "tags": [ - "Admin machines" + "Admin inventory" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicImageUploadRequest" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/PublicImageUploadRequest" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/PublicImageUploadRequest" + } + } + }, + "required": true + }, "security": [ { "jwtAuth": [] } ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicMachine" + "$ref": "#/components/schemas/PublicImageUploadResponse" } } }, "description": "" + }, + "400": { + "description": "Invalid image upload request." + }, + "503": { + "description": "Public image storage is unavailable." } } }, - "patch": { - "operationId": "api_v1_admin_machines_publicity_partial_update", - "summary": "Set machine public visibility", + "put": { + "operationId": "api_v1_admin_inventory_image_update", + "summary": "Attach an uploaded image to an inventory product", "parameters": [ { "in": "path", @@ -6283,26 +6765,27 @@ } ], "tags": [ - "Admin machines" + "Admin inventory" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PatchedMachinePublicity" + "$ref": "#/components/schemas/PublicImageAttachRequest" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/PatchedMachinePublicity" + "$ref": "#/components/schemas/PublicImageAttachRequest" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/PatchedMachinePublicity" + "$ref": "#/components/schemas/PublicImageAttachRequest" } } - } + }, + "required": true }, "security": [ { @@ -6314,28 +6797,59 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicMachine" + "$ref": "#/components/schemas/InventoryProductAdmin" } } }, "description": "" }, "400": { - "description": "Invalid publicity setting." - }, - "403": { - "description": "MANAGE_MACHINES is required." + "description": "Invalid image object key or size." }, - "404": { - "description": "Machine not found." + "503": { + "description": "Public image storage is unavailable." + } + } + }, + "delete": { + "operationId": "api_v1_admin_inventory_image_destroy", + "summary": "Clear an inventory product image", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Admin inventory" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InventoryProductAdmin" + } + } + }, + "description": "" } } } }, - "/api/v1/admin/machines/{id}/retire": { - "post": { - "operationId": "api_v1_admin_machines_retire_create", - "summary": "Retire a machine", + "/api/v1/admin/inventory/{id}/lending-history": { + "get": { + "operationId": "api_v1_admin_inventory_lending_history_retrieve", + "summary": "Per-item lending history (last borrower + last 3 lends)", "parameters": [ { "in": "path", @@ -6347,7 +6861,7 @@ } ], "tags": [ - "Admin machines" + "Admin inventory" ], "security": [ { @@ -6359,7 +6873,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Machine" + "$ref": "#/components/schemas/LendingHistoryResponse" } } }, @@ -6368,10 +6882,11 @@ } } }, - "/api/v1/admin/machines/{id}/set-status": { + "/api/v1/admin/inventory/{id}/needs-fix": { "post": { - "operationId": "api_v1_admin_machines_set_status_create", - "summary": "Set a machine status", + "operationId": "api_v1_admin_inventory_needs_fix_create", + "description": "Move units onto the shelf, back to available, or out of inventory.", + "summary": "Move inventory units to, from, or out of the to-be-fixed shelf", "parameters": [ { "in": "path", @@ -6383,23 +6898,23 @@ } ], "tags": [ - "Admin machines" + "Admin inventory" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SetStatus" + "$ref": "#/components/schemas/NeedsFixAction" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/SetStatus" + "$ref": "#/components/schemas/NeedsFixAction" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/SetStatus" + "$ref": "#/components/schemas/NeedsFixAction" } } }, @@ -6415,22 +6930,19 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Machine" + "$ref": "#/components/schemas/InventoryProductAdmin" } } }, "description": "" - }, - "400": { - "description": "Invalid machine status." } } } }, - "/api/v1/admin/machines/{id}/unretire": { - "post": { - "operationId": "api_v1_admin_machines_unretire_create", - "summary": "Reactivate a retired machine", + "/api/v1/admin/inventory/{id}/qr-history": { + "get": { + "operationId": "api_v1_admin_inventory_qr_history_retrieve", + "summary": "List QR scan history for an inventory product", "parameters": [ { "in": "path", @@ -6442,7 +6954,7 @@ } ], "tags": [ - "Admin machines" + "Admin inventory" ], "security": [ { @@ -6454,7 +6966,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Machine" + "$ref": "#/components/schemas/ProductQrHistory" } } }, @@ -6463,14 +6975,32 @@ } } }, - "/api/v1/admin/machines/{id}/usage": { + "/api/v1/admin/inventory/{product_pk}/assets": { "get": { - "operationId": "api_v1_admin_machines_usage_list", - "summary": "List machine usage entries", + "operationId": "api_v1_admin_inventory_assets_list", + "summary": "List individual assets for an inventory product", "parameters": [ + { + "name": "page", + "required": false, + "in": "query", + "description": "A page number within the paginated result set.", + "schema": { + "type": "integer" + } + }, + { + "name": "page_size", + "required": false, + "in": "query", + "description": "Number of results to return per page.", + "schema": { + "type": "integer" + } + }, { "in": "path", - "name": "id", + "name": "product_pk", "schema": { "type": "integer" }, @@ -6478,7 +7008,7 @@ } ], "tags": [ - "Admin machines" + "Admin inventory" ], "security": [ { @@ -6490,91 +7020,139 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/MachineUsageEntry" - } + "$ref": "#/components/schemas/PaginatedInventoryAssetAdminList" } } }, "description": "" } } - }, - "post": { - "operationId": "api_v1_admin_machines_usage_create", - "summary": "Log machine usage", + } + }, + "/api/v1/admin/inventory/needs-fix": { + "get": { + "operationId": "api_v1_admin_inventory_needs_fix_list", + "description": "The to-be-fixed shelf: products that currently have units awaiting repair.", "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 + } + }, + { + "name": "page_size", + "required": false, + "in": "query", + "description": "Number of results to return per page.", + "schema": { + "type": "integer" + } } ], "tags": [ - "Admin machines" + "api" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/LogUsage" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/LogUsage" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/LogUsage" - } - } - }, - "required": true - }, "security": [ { "jwtAuth": [] } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MachineUsageEntry" + "$ref": "#/components/schemas/PaginatedInventoryProductAdminList" } } }, "description": "" - }, - "400": { - "description": "Invalid usage entry." } } } }, - "/api/v1/admin/machines/{id}/warranty": { + "/api/v1/admin/ledger": { "get": { - "operationId": "api_v1_admin_machines_warranty_retrieve", - "summary": "Retrieve warranty details for a machine", + "operationId": "api_v1_admin_ledger_retrieve", + "summary": "List outstanding inventory loans across all makerspaces", "parameters": [ { - "in": "path", - "name": "id", + "in": "query", + "name": "makerspace", "schema": { "type": "integer" - }, - "required": true + } + }, + { + "in": "query", + "name": "overdue", + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "page", + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "page_size", + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "search", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "sort", + "schema": { + "type": "string", + "enum": [ + "-due", + "-holder", + "-item_name", + "-makerspace_id", + "-quantity", + "-since", + "-source", + "due", + "holder", + "item_name", + "makerspace_id", + "quantity", + "since", + "source" + ] + } + }, + { + "in": "query", + "name": "source", + "schema": { + "type": "string", + "enum": [ + "direct", + "reviewed", + "self_checkout" + ] + } } ], "tags": [ - "Admin warranty" + "Ledger" ], "security": [ { @@ -6586,79 +7164,148 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Warranty" + "$ref": "#/components/schemas/LedgerResponse" } } }, "description": "" + } + } + } + }, + "/api/v1/admin/ledger/export": { + "get": { + "operationId": "api_v1_admin_ledger_export_retrieve", + "summary": "Export outstanding inventory loans across all makerspaces", + "parameters": [ + { + "in": "query", + "name": "format", + "schema": { + "type": "string", + "enum": [ + "csv", + "xlsx" + ] + } }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + { + "in": "query", + "name": "makerspace", + "schema": { + "type": "integer" + } }, - "403": { + { + "in": "query", + "name": "overdue", + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "search", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "sort", + "schema": { + "type": "string", + "enum": [ + "-due", + "-holder", + "-item_name", + "-makerspace_id", + "-quantity", + "-since", + "-source", + "due", + "holder", + "item_name", + "makerspace_id", + "quantity", + "since", + "source" + ] + } + }, + { + "in": "query", + "name": "source", + "schema": { + "type": "string", + "enum": [ + "direct", + "reviewed", + "self_checkout" + ] + } + } + ], + "tags": [ + "Ledger" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { "content": { - "application/json": { + "text/csv": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "type": "string" } - } - }, - "description": "" - }, - "404": { - "content": { - "application/json": { + }, + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "type": "string", + "format": "binary" } } }, "description": "" } } - }, - "put": { - "operationId": "api_v1_admin_machines_warranty_update", - "summary": "Create or update warranty details for a machine", + } + }, + "/api/v1/admin/machine-service-report": { + "get": { + "operationId": "api_v1_admin_machine_service_report_retrieve", + "summary": "Retrieve aggregate machine-service report", "parameters": [ { - "in": "path", - "name": "id", + "in": "query", + "name": "end", "schema": { - "type": "integer" - }, - "required": true + "type": "string", + "format": "date" + } + }, + { + "in": "query", + "name": "machine_type", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "start", + "schema": { + "type": "string", + "format": "date" + } } ], "tags": [ - "Admin warranty" + "Admin machine service" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/WarrantyUpsert" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/WarrantyUpsert" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/WarrantyUpsert" - } - } - } - }, "security": [ { "jwtAuth": [] @@ -6669,16 +7316,13 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Warranty" + "$ref": "#/components/schemas/MachineServiceReportResponse" } } }, "description": "" }, "400": { - "description": "Invalid warranty details." - }, - "401": { "content": { "application/json": { "schema": { @@ -6686,35 +7330,23 @@ } } }, - "description": "" + "description": "Invalid request." + }, + "401": { + "description": "Authentication credentials were not provided." }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "description": "Machine management permission required." }, "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "description": "Not found." } } } }, - "/api/v1/admin/machines/documents/{id}": { - "delete": { - "operationId": "api_v1_admin_machines_documents_destroy", - "summary": "Delete a machine document", + "/api/v1/admin/machine-service/consumable-pools/{id}": { + "get": { + "operationId": "api_v1_admin_machine_service_consumable_pools_retrieve", "parameters": [ { "in": "path", @@ -6726,7 +7358,7 @@ } ], "tags": [ - "Admin machines" + "Admin machine service" ], "security": [ { @@ -6734,16 +7366,20 @@ } ], "responses": { - "204": { - "description": "No response body" + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PrinterPool" + } + } + }, + "description": "" } } - } - }, - "/api/v1/admin/machines/documents/{id}/url": { - "get": { - "operationId": "api_v1_admin_machines_documents_url_retrieve", - "summary": "Create a signed machine document view URL", + }, + "patch": { + "operationId": "api_v1_admin_machine_service_consumable_pools_partial_update", "parameters": [ { "in": "path", @@ -6755,27 +7391,105 @@ } ], "tags": [ - "Admin machines" + "Admin machine service" ], - "security": [ + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PatchedPrinterPoolVisibility" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/PatchedPrinterPoolVisibility" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/PatchedPrinterPoolVisibility" + } + } + } + }, + "security": [ { "jwtAuth": [] } ], "responses": { "200": { - "description": "Signed machine document URL." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PrinterPool" + } + } + }, + "description": "" + } + } + } + }, + "/api/v1/admin/machine-service/consumable-pools/{id}/adjustments": { + "post": { + "operationId": "api_v1_admin_machine_service_consumable_pools_adjustments_create", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Admin machine service" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PrinterPoolCorrection" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/PrinterPoolCorrection" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/PrinterPoolCorrection" + } + } }, - "503": { - "description": "Machine document storage is unavailable." + "required": true + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PrinterPool" + } + } + }, + "description": "" } } } }, - "/api/v1/admin/maintenance/log-documents/{id}/": { + "/api/v1/admin/machine-service/files/{id}": { "delete": { - "operationId": "api_v1_admin_maintenance_log_documents_destroy", - "summary": "Delete a maintenance document", + "operationId": "api_v1_admin_machine_service_files_destroy", + "summary": "Delete a staged service attachment", "parameters": [ { "in": "path", @@ -6787,7 +7501,7 @@ } ], "tags": [ - "Admin maintenance" + "Admin machine service" ], "security": [ { @@ -6806,27 +7520,16 @@ } } }, - "description": "Invalid request." + "description": "Invalid attachment input." + }, + "401": { + "description": "Authentication required." }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Permission denied." + "description": "Machine management permission required." }, "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Not found." + "description": "Service request or file was not found." }, "409": { "content": { @@ -6836,25 +7539,18 @@ } } }, - "description": "Workflow conflict." + "description": "Attachment conflict." }, "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Service unavailable." + "description": "Private storage is unavailable." } } } }, - "/api/v1/admin/maintenance/log-documents/{id}/url/": { + "/api/v1/admin/machine-service/files/{id}/url": { "get": { - "operationId": "api_v1_admin_maintenance_log_documents_url_retrieve", - "summary": "Create a private maintenance document URL", + "operationId": "api_v1_admin_machine_service_files_url_retrieve", + "summary": "Create a signed service attachment URL", "parameters": [ { "in": "path", @@ -6866,7 +7562,7 @@ } ], "tags": [ - "Admin maintenance" + "Admin machine service" ], "security": [ { @@ -6878,7 +7574,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MaintenanceDocumentUrl" + "$ref": "#/components/schemas/ServiceFileUrl" } } }, @@ -6892,27 +7588,16 @@ } } }, - "description": "Invalid request." + "description": "Invalid attachment input." + }, + "401": { + "description": "Authentication required." }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Permission denied." + "description": "Machine management permission required." }, "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Not found." + "description": "Service request or file was not found." }, "409": { "content": { @@ -6922,25 +7607,18 @@ } } }, - "description": "Workflow conflict." + "description": "Attachment conflict." }, "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Service unavailable." + "description": "Private storage is unavailable." } } } }, - "/api/v1/admin/maintenance/logs/{id}/documents/": { + "/api/v1/admin/machine-service/payments/{id}/mark-offline": { "post": { - "operationId": "api_v1_admin_maintenance_logs_documents_create", - "summary": "Finalize a maintenance document upload", + "operationId": "api_v1_admin_machine_service_payments_mark_offline_create", + "summary": "Mark a machine-service payment paid offline", "parameters": [ { "in": "path", @@ -6952,45 +7630,25 @@ } ], "tags": [ - "Admin maintenance" + "Payments" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MaintenanceDocumentFinalize" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/MaintenanceDocumentFinalize" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/MaintenanceDocumentFinalize" - } - } - }, - "required": true - }, "security": [ { "jwtAuth": [] } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MaintenanceLogDocument" + "$ref": "#/components/schemas/StaffPayment" } } }, "description": "" }, - "400": { + "403": { "content": { "application/json": { "schema": { @@ -6998,9 +7656,9 @@ } } }, - "description": "Invalid request." + "description": "" }, - "403": { + "404": { "content": { "application/json": { "schema": { @@ -7008,19 +7666,45 @@ } } }, - "description": "Permission denied." - }, - "404": { + "description": "" + } + } + } + }, + "/api/v1/admin/machine-service/payments/{id}/waive": { + "post": { + "operationId": "api_v1_admin_machine_service_payments_waive_create", + "summary": "Waive a machine-service payment", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Payments" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/StaffPayment" } } }, - "description": "Not found." + "description": "" }, - "409": { + "403": { "content": { "application/json": { "schema": { @@ -7028,9 +7712,9 @@ } } }, - "description": "Workflow conflict." + "description": "" }, - "503": { + "404": { "content": { "application/json": { "schema": { @@ -7038,15 +7722,15 @@ } } }, - "description": "Service unavailable." + "description": "" } } } }, - "/api/v1/admin/maintenance/logs/{id}/documents/presign/": { - "post": { - "operationId": "api_v1_admin_maintenance_logs_documents_presign_create", - "summary": "Create a maintenance document upload URL", + "/api/v1/admin/machine-service/requests/{id}": { + "get": { + "operationId": "api_v1_admin_machine_service_requests_retrieve", + "summary": "Retrieve a machine service request", "parameters": [ { "in": "path", @@ -7058,28 +7742,8 @@ } ], "tags": [ - "Admin maintenance" + "Admin machine service" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MaintenanceDocumentPresign" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/MaintenanceDocumentPresign" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/MaintenanceDocumentPresign" - } - } - }, - "required": true - }, "security": [ { "jwtAuth": [] @@ -7090,7 +7754,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MaintenanceDocumentPresignResponse" + "$ref": "#/components/schemas/MachineServiceRequest" } } }, @@ -7104,27 +7768,16 @@ } } }, - "description": "Invalid request." + "description": "Invalid service request input." + }, + "401": { + "description": "Authentication required." }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Permission denied." + "description": "Machine management permission required." }, "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Not found." + "description": "Service request was not found." }, "409": { "content": { @@ -7134,25 +7787,15 @@ } } }, - "description": "Workflow conflict." - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Service unavailable." + "description": "Service workflow conflict." } } } }, - "/api/v1/admin/maintenance/schedules/{id}/": { - "patch": { - "operationId": "api_v1_admin_maintenance_schedules_partial_update", - "summary": "Update a maintenance schedule", + "/api/v1/admin/machine-service/requests/{id}/accept": { + "post": { + "operationId": "api_v1_admin_machine_service_requests_accept_create", + "summary": "Accept a machine service request", "parameters": [ { "in": "path", @@ -7164,23 +7807,23 @@ } ], "tags": [ - "Admin maintenance" + "Admin machine service" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PatchedMaintenanceScheduleWrite" + "$ref": "#/components/schemas/ServiceAccept" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/PatchedMaintenanceScheduleWrite" + "$ref": "#/components/schemas/ServiceAccept" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/PatchedMaintenanceScheduleWrite" + "$ref": "#/components/schemas/ServiceAccept" } } } @@ -7195,7 +7838,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MaintenanceSchedule" + "$ref": "#/components/schemas/MachineServiceRequest" } } }, @@ -7209,27 +7852,16 @@ } } }, - "description": "Invalid request." + "description": "Invalid service request input." + }, + "401": { + "description": "Authentication required." }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Permission denied." + "description": "Machine management permission required." }, "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Not found." + "description": "Service request was not found." }, "409": { "content": { @@ -7239,15 +7871,15 @@ } } }, - "description": "Workflow conflict." + "description": "Service workflow conflict." } } } }, - "/api/v1/admin/maintenance/schedules/{id}/deactivate/": { + "/api/v1/admin/machine-service/requests/{id}/collect": { "post": { - "operationId": "api_v1_admin_maintenance_schedules_deactivate_create", - "summary": "Deactivate a maintenance schedule", + "operationId": "api_v1_admin_machine_service_requests_collect_create", + "summary": "Mark a machine service request collected", "parameters": [ { "in": "path", @@ -7259,7 +7891,7 @@ } ], "tags": [ - "Admin maintenance" + "Admin machine service" ], "security": [ { @@ -7271,7 +7903,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MaintenanceSchedule" + "$ref": "#/components/schemas/MachineServiceRequest" } } }, @@ -7285,27 +7917,16 @@ } } }, - "description": "Invalid request." + "description": "Invalid service request input." + }, + "401": { + "description": "Authentication required." }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Permission denied." + "description": "Machine management permission required." }, "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Not found." + "description": "Service request was not found." }, "409": { "content": { @@ -7315,46 +7936,48 @@ } } }, - "description": "Workflow conflict." + "description": "Service workflow conflict." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/accepted-requests": { - "get": { - "operationId": "api_v1_admin_makerspace_accepted_requests_list", - "summary": "List accepted requests awaiting issue", + "/api/v1/admin/machine-service/requests/{id}/complete": { + "post": { + "operationId": "api_v1_admin_machine_service_requests_complete_create", + "summary": "Complete machine service work", "parameters": [ { "in": "path", - "name": "makerspace_id", + "name": "id", "schema": { "type": "integer" }, "required": true - }, - { - "name": "page", - "required": false, - "in": "query", - "description": "A page number within the paginated result set.", - "schema": { - "type": "integer" - } - }, - { - "name": "search", - "required": false, - "in": "query", - "description": "A search term (requested-for, requester name/email).", - "schema": { - "type": "string" - } } ], "tags": [ - "Admin requests" + "Admin machine service" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ServiceComplete" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/ServiceComplete" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/ServiceComplete" + } + } + }, + "required": true + }, "security": [ { "jwtAuth": [] @@ -7365,13 +7988,13 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaginatedAdminRequestList" + "$ref": "#/components/schemas/MachineServiceRequest" } } }, "description": "" }, - "403": { + "400": { "content": { "application/json": { "schema": { @@ -7379,9 +8002,18 @@ } } }, - "description": "Permission denied." + "description": "Invalid service request input." + }, + "401": { + "description": "Authentication required." + }, + "403": { + "description": "Machine management permission required." }, "404": { + "description": "Service request was not found." + }, + "409": { "content": { "application/json": { "schema": { @@ -7389,19 +8021,19 @@ } } }, - "description": "Not found." + "description": "Service workflow conflict." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/accountability": { - "get": { - "operationId": "api_v1_admin_makerspace_accountability_retrieve", - "summary": "Requester accountability dashboard", + "/api/v1/admin/machine-service/requests/{id}/fail": { + "post": { + "operationId": "api_v1_admin_machine_service_requests_fail_create", + "summary": "Mark machine service work failed", "parameters": [ { "in": "path", - "name": "makerspace_id", + "name": "id", "schema": { "type": "integer" }, @@ -7409,8 +8041,28 @@ } ], "tags": [ - "Analytics" + "Admin machine service" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ServiceFail" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/ServiceFail" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/ServiceFail" + } + } + }, + "required": true + }, "security": [ { "jwtAuth": [] @@ -7421,8 +8073,7 @@ "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": {} + "$ref": "#/components/schemas/MachineServiceRequest" } } }, @@ -7432,97 +8083,88 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Invalid report request." + "description": "Invalid service request input." }, "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } - } - }, "description": "Authentication required." }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } - } - }, - "description": "Permission denied." + "description": "Machine management permission required." }, "404": { + "description": "Service request was not found." + }, + "409": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Makerspace or report not found." + "description": "Service workflow conflict." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/active-loans": { - "get": { - "operationId": "api_v1_admin_makerspace_active_loans_list", - "summary": "List active loans awaiting return", + "/api/v1/admin/machine-service/requests/{id}/files/finalize": { + "post": { + "operationId": "api_v1_admin_machine_service_requests_files_finalize_create", + "summary": "Finalize a service attachment upload", "parameters": [ { "in": "path", - "name": "makerspace_id", + "name": "id", "schema": { "type": "integer" }, "required": true - }, - { - "name": "page", - "required": false, - "in": "query", - "description": "A page number within the paginated result set.", - "schema": { - "type": "integer" - } - }, - { - "name": "search", - "required": false, - "in": "query", - "description": "A search term (requested-for, requester name/email).", - "schema": { - "type": "string" - } } ], "tags": [ - "Admin requests" + "Admin machine service" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ServiceFileFinalize" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/ServiceFileFinalize" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/ServiceFileFinalize" + } + } + }, + "required": true + }, "security": [ { "jwtAuth": [] } ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaginatedAdminRequestList" + "$ref": "#/components/schemas/ServiceFileFinalizeResponse" } } }, "description": "" }, - "403": { + "400": { "content": { "application/json": { "schema": { @@ -7530,9 +8172,18 @@ } } }, - "description": "Permission denied." + "description": "Invalid attachment input." + }, + "401": { + "description": "Authentication required." + }, + "403": { + "description": "Machine management permission required." }, "404": { + "description": "Service request or file was not found." + }, + "409": { "content": { "application/json": { "schema": { @@ -7540,89 +8191,62 @@ } } }, - "description": "Not found." + "description": "Attachment conflict." + }, + "503": { + "description": "Private storage is unavailable." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/analytics/active-loans": { - "get": { - "operationId": "api_v1_admin_makerspace_analytics_active_loans_retrieve", - "summary": "Get analytics report", + "/api/v1/admin/machine-service/requests/{id}/files/presign": { + "post": { + "operationId": "api_v1_admin_machine_service_requests_files_presign_create", + "summary": "Create a service attachment upload URL", "parameters": [ - { - "in": "query", - "name": "end", - "schema": { - "type": "string", - "format": "date" - } - }, - { - "in": "query", - "name": "limit", - "schema": { - "type": "integer" - } - }, { "in": "path", - "name": "makerspace_id", + "name": "id", "schema": { "type": "integer" }, "required": true - }, - { - "in": "query", - "name": "start", - "schema": { - "type": "string", - "format": "date" - } - }, - { - "in": "query", - "name": "status", - "schema": { - "type": "string", - "enum": [ - "canceled", - "paid_offline", - "paid_online", - "pending", - "waived" - ] - } - }, - { - "in": "query", - "name": "subject_type", - "schema": { - "type": "string", - "enum": [ - "booking", - "event_registration", - "machine_service_request", - "makerspace_membership" - ] - } } ], "tags": [ - "Analytics" + "Admin machine service" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ServiceFilePresign" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/ServiceFilePresign" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/ServiceFilePresign" + } + } + }, + "required": true + }, "security": [ { "jwtAuth": [] } ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AnalyticsReportResponse" + "$ref": "#/components/schemas/ServiceFilePresignResponse" } } }, @@ -7632,112 +8256,74 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Invalid report request." + "description": "Invalid attachment input." }, "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } - } - }, "description": "Authentication required." }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } - } - }, - "description": "Permission denied." + "description": "Machine management permission required." }, "404": { + "description": "Service request or file was not found." + }, + "409": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Makerspace or report not found." + "description": "Attachment conflict." + }, + "503": { + "description": "Private storage is unavailable." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/analytics/booking-utilization": { - "get": { - "operationId": "api_v1_admin_makerspace_analytics_booking_utilization_retrieve", - "summary": "Get analytics report", + "/api/v1/admin/machine-service/requests/{id}/reject": { + "post": { + "operationId": "api_v1_admin_machine_service_requests_reject_create", + "summary": "Reject a machine service request", "parameters": [ - { - "in": "query", - "name": "end", - "schema": { - "type": "string", - "format": "date" - } - }, - { - "in": "query", - "name": "limit", - "schema": { - "type": "integer" - } - }, { "in": "path", - "name": "makerspace_id", + "name": "id", "schema": { "type": "integer" }, "required": true - }, - { - "in": "query", - "name": "start", - "schema": { - "type": "string", - "format": "date" - } - }, - { - "in": "query", - "name": "status", - "schema": { - "type": "string", - "enum": [ - "canceled", - "paid_offline", - "paid_online", - "pending", - "waived" - ] - } - }, - { - "in": "query", - "name": "subject_type", - "schema": { - "type": "string", - "enum": [ - "booking", - "event_registration", - "machine_service_request", - "makerspace_membership" - ] - } } ], "tags": [ - "Analytics" + "Admin machine service" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ServiceReject" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/ServiceReject" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/ServiceReject" + } + } + }, + "required": true + }, "security": [ { "jwtAuth": [] @@ -7748,7 +8334,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AnalyticsReportResponse" + "$ref": "#/components/schemas/MachineServiceRequest" } } }, @@ -7758,111 +8344,50 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Invalid report request." + "description": "Invalid service request input." }, "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } - } - }, "description": "Authentication required." }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } - } - }, - "description": "Permission denied." + "description": "Machine management permission required." }, "404": { + "description": "Service request was not found." + }, + "409": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Makerspace or report not found." + "description": "Service workflow conflict." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/analytics/damaged-lost": { - "get": { - "operationId": "api_v1_admin_makerspace_analytics_damaged_lost_retrieve", - "summary": "Get analytics report", + "/api/v1/admin/machine-service/requests/{id}/reprint": { + "post": { + "operationId": "api_v1_admin_machine_service_requests_reprint_create", + "summary": "Create a printer reprint", "parameters": [ - { - "in": "query", - "name": "end", - "schema": { - "type": "string", - "format": "date" - } - }, - { - "in": "query", - "name": "limit", - "schema": { - "type": "integer" - } - }, { "in": "path", - "name": "makerspace_id", + "name": "id", "schema": { "type": "integer" }, "required": true - }, - { - "in": "query", - "name": "start", - "schema": { - "type": "string", - "format": "date" - } - }, - { - "in": "query", - "name": "status", - "schema": { - "type": "string", - "enum": [ - "canceled", - "paid_offline", - "paid_online", - "pending", - "waived" - ] - } - }, - { - "in": "query", - "name": "subject_type", - "schema": { - "type": "string", - "enum": [ - "booking", - "event_registration", - "machine_service_request", - "makerspace_membership" - ] - } } ], "tags": [ - "Analytics" + "Admin machine service" ], "security": [ { @@ -7874,7 +8399,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AnalyticsReportResponse" + "$ref": "#/components/schemas/MachineServiceRequest" } } }, @@ -7884,112 +8409,70 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Invalid report request." + "description": "Invalid service request input." }, "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } - } - }, "description": "Authentication required." }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } - } - }, - "description": "Permission denied." + "description": "Machine management permission required." }, "404": { + "description": "Service request was not found." + }, + "409": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Makerspace or report not found." + "description": "Service workflow conflict." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/analytics/damaged-missing": { - "get": { - "operationId": "api_v1_admin_makerspace_analytics_damaged_missing_retrieve", - "summary": "Get analytics report", + "/api/v1/admin/machine-service/requests/{id}/start": { + "post": { + "operationId": "api_v1_admin_machine_service_requests_start_create", + "summary": "Start machine service work", "parameters": [ - { - "in": "query", - "name": "end", - "schema": { - "type": "string", - "format": "date" - } - }, - { - "in": "query", - "name": "limit", - "schema": { - "type": "integer" - } - }, { "in": "path", - "name": "makerspace_id", + "name": "id", "schema": { "type": "integer" }, "required": true - }, - { - "in": "query", - "name": "start", - "schema": { - "type": "string", - "format": "date" - } - }, - { - "in": "query", - "name": "status", - "schema": { - "type": "string", - "enum": [ - "canceled", - "paid_offline", - "paid_online", - "pending", - "waived" - ] - } - }, - { - "in": "query", - "name": "subject_type", - "schema": { - "type": "string", - "enum": [ - "booking", - "event_registration", - "machine_service_request", - "makerspace_membership" - ] - } } ], "tags": [ - "Analytics" + "Admin machine service" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ServiceStart" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/ServiceStart" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/ServiceStart" + } + } + } + }, "security": [ { "jwtAuth": [] @@ -8000,7 +8483,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AnalyticsReportResponse" + "$ref": "#/components/schemas/MachineServiceRequest" } } }, @@ -8010,111 +8493,50 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Invalid report request." + "description": "Invalid service request input." }, "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } - } - }, "description": "Authentication required." }, "403": { + "description": "Machine management permission required." + }, + "404": { + "description": "Service request was not found." + }, + "409": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Permission denied." - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } - } - }, - "description": "Makerspace or report not found." + "description": "Service workflow conflict." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/analytics/event-attendance": { + "/api/v1/admin/machines/{id}": { "get": { - "operationId": "api_v1_admin_makerspace_analytics_event_attendance_retrieve", - "summary": "Get analytics report", + "operationId": "api_v1_admin_machines_retrieve", + "summary": "Retrieve a machine", "parameters": [ - { - "in": "query", - "name": "end", - "schema": { - "type": "string", - "format": "date" - } - }, - { - "in": "query", - "name": "limit", - "schema": { - "type": "integer" - } - }, { "in": "path", - "name": "makerspace_id", + "name": "id", "schema": { "type": "integer" }, "required": true - }, - { - "in": "query", - "name": "start", - "schema": { - "type": "string", - "format": "date" - } - }, - { - "in": "query", - "name": "status", - "schema": { - "type": "string", - "enum": [ - "canceled", - "paid_offline", - "paid_online", - "pending", - "waived" - ] - } - }, - { - "in": "query", - "name": "subject_type", - "schema": { - "type": "string", - "enum": [ - "booking", - "event_registration", - "machine_service_request", - "makerspace_membership" - ] - } } ], "tags": [ - "Analytics" + "Admin machines" ], "security": [ { @@ -8126,121 +8548,87 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AnalyticsReportResponse" + "$ref": "#/components/schemas/Machine" } } }, "description": "" - }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } - } + } + } + }, + "patch": { + "operationId": "api_v1_admin_machines_partial_update", + "summary": "Update a machine", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" }, - "description": "Invalid report request." - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } + "required": true + } + ], + "tags": [ + "Admin machines" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PatchedMachine" } }, - "description": "Authentication required." - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/PatchedMachine" } }, - "description": "Permission denied." - }, - "404": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/PatchedMachine" + } + } + } + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/Machine" } } }, - "description": "Makerspace or report not found." + "description": "" + }, + "400": { + "description": "Invalid machine details." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/analytics/fablab-health": { + "/api/v1/admin/machines/{id}/consumable-candidates": { "get": { - "operationId": "api_v1_admin_makerspace_analytics_fablab_health_retrieve", - "summary": "Get analytics report", + "operationId": "api_v1_admin_machines_consumable_candidates_list", + "summary": "List inventory products eligible as count consumables", "parameters": [ - { - "in": "query", - "name": "end", - "schema": { - "type": "string", - "format": "date" - } - }, - { - "in": "query", - "name": "limit", - "schema": { - "type": "integer" - } - }, { "in": "path", - "name": "makerspace_id", + "name": "id", "schema": { "type": "integer" }, "required": true - }, - { - "in": "query", - "name": "start", - "schema": { - "type": "string", - "format": "date" - } - }, - { - "in": "query", - "name": "status", - "schema": { - "type": "string", - "enum": [ - "canceled", - "paid_offline", - "paid_online", - "pending", - "waived" - ] - } - }, - { - "in": "query", - "name": "subject_type", - "schema": { - "type": "string", - "enum": [ - "booking", - "event_registration", - "machine_service_request", - "makerspace_membership" - ] - } } ], "tags": [ - "Analytics" + "Admin machines" ], "security": [ { @@ -8252,121 +8640,40 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AnalyticsReportResponse" + "type": "array", + "items": { + "$ref": "#/components/schemas/ConsumableCandidate" + } } } }, "description": "" }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } - } - }, - "description": "Invalid report request." - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } - } - }, - "description": "Authentication required." - }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } - } - }, - "description": "Permission denied." + "description": "Machine operation is not permitted." }, "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } - } - }, - "description": "Makerspace or report not found." + "description": "Machine not found." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/analytics/machine-usage": { + "/api/v1/admin/machines/{id}/consumables": { "get": { - "operationId": "api_v1_admin_makerspace_analytics_machine_usage_retrieve", - "summary": "Get analytics report", + "operationId": "api_v1_admin_machines_consumables_list", + "summary": "List machine consumables", "parameters": [ - { - "in": "query", - "name": "end", - "schema": { - "type": "string", - "format": "date" - } - }, - { - "in": "query", - "name": "limit", - "schema": { - "type": "integer" - } - }, { "in": "path", - "name": "makerspace_id", + "name": "id", "schema": { "type": "integer" }, "required": true - }, - { - "in": "query", - "name": "start", - "schema": { - "type": "string", - "format": "date" - } - }, - { - "in": "query", - "name": "status", - "schema": { - "type": "string", - "enum": [ - "canceled", - "paid_offline", - "paid_online", - "pending", - "waived" - ] - } - }, - { - "in": "query", - "name": "subject_type", - "schema": { - "type": "string", - "enum": [ - "booking", - "event_registration", - "machine_service_request", - "makerspace_membership" - ] - } } ], "tags": [ - "Analytics" + "Admin machines" ], "security": [ { @@ -8378,122 +8685,178 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AnalyticsReportResponse" + "type": "array", + "items": { + "$ref": "#/components/schemas/MachineConsumable" + } } } }, "description": "" }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } + "403": { + "description": "Machine operation is not permitted." + }, + "404": { + "description": "Machine not found." + } + } + }, + "post": { + "operationId": "api_v1_admin_machines_consumables_create", + "summary": "Link a count or grams consumable to a machine", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Admin machines" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LinkMachineConsumable" } }, - "description": "Invalid report request." - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/LinkMachineConsumable" } }, - "description": "Authentication required." + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/LinkMachineConsumable" + } + } }, - "403": { + "required": true + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/MachineConsumable" } } }, - "description": "Permission denied." + "description": "" + }, + "400": { + "description": "Invalid consumable." + }, + "403": { + "description": "Machine management is not permitted." }, "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } - } - }, - "description": "Makerspace or report not found." + "description": "Machine not found." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/analytics/maintenance-activity": { - "get": { - "operationId": "api_v1_admin_makerspace_analytics_maintenance_activity_retrieve", - "summary": "Get analytics report", + "/api/v1/admin/machines/{id}/consumables/{cid}": { + "delete": { + "operationId": "api_v1_admin_machines_consumables_destroy", + "summary": "Unlink a machine consumable", "parameters": [ { - "in": "query", - "name": "end", - "schema": { - "type": "string", - "format": "date" - } - }, - { - "in": "query", - "name": "limit", + "in": "path", + "name": "cid", "schema": { "type": "integer" - } + }, + "required": true }, { "in": "path", - "name": "makerspace_id", + "name": "id", "schema": { "type": "integer" }, "required": true - }, + } + ], + "tags": [ + "Admin machines" + ], + "security": [ { - "in": "query", - "name": "start", - "schema": { - "type": "string", - "format": "date" - } + "jwtAuth": [] + } + ], + "responses": { + "204": { + "description": "No response body" + }, + "400": { + "description": "Consumable is not linked." + }, + "403": { + "description": "Machine management is not permitted." }, + "404": { + "description": "Machine not found." + } + } + } + }, + "/api/v1/admin/machines/{id}/consumables/{cid}/log": { + "post": { + "operationId": "api_v1_admin_machines_consumables_log_create", + "summary": "Log machine consumable usage", + "parameters": [ { - "in": "query", - "name": "status", + "in": "path", + "name": "cid", "schema": { - "type": "string", - "enum": [ - "canceled", - "paid_offline", - "paid_online", - "pending", - "waived" - ] - } + "type": "integer" + }, + "required": true }, { - "in": "query", - "name": "subject_type", + "in": "path", + "name": "id", "schema": { - "type": "string", - "enum": [ - "booking", - "event_registration", - "machine_service_request", - "makerspace_membership" - ] - } + "type": "integer" + }, + "required": true } ], "tags": [ - "Analytics" + "Admin machines" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LogMachineConsumption" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/LogMachineConsumption" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/LogMachineConsumption" + } + } + }, + "required": true + }, "security": [ { "jwtAuth": [] @@ -8504,121 +8867,40 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AnalyticsReportResponse" + "$ref": "#/components/schemas/MachineConsumable" } } }, "description": "" }, "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } - } - }, - "description": "Invalid report request." - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } - } - }, - "description": "Authentication required." + "description": "Invalid quantity or insufficient stock." }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } - } - }, - "description": "Permission denied." + "description": "Machine operation is not permitted." }, "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } - } - }, - "description": "Makerspace or report not found." + "description": "Machine not found." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/analytics/member-activity": { + "/api/v1/admin/machines/{id}/documents": { "get": { - "operationId": "api_v1_admin_makerspace_analytics_member_activity_retrieve", - "summary": "Get analytics report", + "operationId": "api_v1_admin_machines_documents_list", + "summary": "List machine documents", "parameters": [ - { - "in": "query", - "name": "end", - "schema": { - "type": "string", - "format": "date" - } - }, - { - "in": "query", - "name": "limit", - "schema": { - "type": "integer" - } - }, { "in": "path", - "name": "makerspace_id", + "name": "id", "schema": { "type": "integer" }, "required": true - }, - { - "in": "query", - "name": "start", - "schema": { - "type": "string", - "format": "date" - } - }, - { - "in": "query", - "name": "status", - "schema": { - "type": "string", - "enum": [ - "canceled", - "paid_offline", - "paid_online", - "pending", - "waived" - ] - } - }, - { - "in": "query", - "name": "subject_type", - "schema": { - "type": "string", - "enum": [ - "booking", - "event_registration", - "machine_service_request", - "makerspace_membership" - ] - } } ], "tags": [ - "Analytics" + "Admin machines" ], "security": [ { @@ -8630,121 +8912,149 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AnalyticsReportResponse" + "type": "array", + "items": { + "$ref": "#/components/schemas/MachineDocument" + } } } }, "description": "" - }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } - } + } + } + }, + "post": { + "operationId": "api_v1_admin_machines_documents_create", + "summary": "Finalize a machine document upload", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" }, - "description": "Invalid report request." - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } + "required": true + } + ], + "tags": [ + "Admin machines" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DocumentFinalize" } }, - "description": "Authentication required." - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/DocumentFinalize" } }, - "description": "Permission denied." + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/DocumentFinalize" + } + } }, - "404": { + "required": true + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/MachineDocument" } } }, - "description": "Makerspace or report not found." + "description": "" + }, + "400": { + "description": "Invalid machine document." + }, + "503": { + "description": "Machine document storage is unavailable." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/analytics/most-lent": { - "get": { - "operationId": "api_v1_admin_makerspace_analytics_most_lent_retrieve", - "summary": "Get analytics report", + "/api/v1/admin/machines/{id}/documents/presign": { + "post": { + "operationId": "api_v1_admin_machines_documents_presign_create", + "summary": "Create a machine document upload URL", "parameters": [ - { - "in": "query", - "name": "end", - "schema": { - "type": "string", - "format": "date" - } - }, - { - "in": "query", - "name": "limit", - "schema": { - "type": "integer" - } - }, { "in": "path", - "name": "makerspace_id", + "name": "id", "schema": { "type": "integer" }, "required": true - }, - { - "in": "query", - "name": "start", - "schema": { - "type": "string", - "format": "date" + } + ], + "tags": [ + "Admin machines" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DocumentPresign" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/DocumentPresign" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/DocumentPresign" + } } }, + "required": true + }, + "security": [ { - "in": "query", - "name": "status", - "schema": { - "type": "string", - "enum": [ - "canceled", - "paid_offline", - "paid_online", - "pending", - "waived" - ] - } + "jwtAuth": [] + } + ], + "responses": { + "201": { + "description": "Machine document upload details." }, + "400": { + "description": "Invalid document upload request." + }, + "503": { + "description": "Machine document storage is unavailable." + } + } + } + }, + "/api/v1/admin/machines/{id}/error-logs": { + "get": { + "operationId": "api_v1_admin_machines_error_logs_list", + "summary": "List machine error logs", + "parameters": [ { - "in": "query", - "name": "subject_type", + "in": "path", + "name": "id", "schema": { - "type": "string", - "enum": [ - "booking", - "event_registration", - "machine_service_request", - "makerspace_membership" - ] - } + "type": "integer" + }, + "required": true } ], "tags": [ - "Analytics" + "Admin machines" ], "security": [ { @@ -8756,248 +9066,178 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AnalyticsReportResponse" + "type": "array", + "items": { + "$ref": "#/components/schemas/MachineErrorLog" + } } } }, "description": "" - }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } - } + } + } + }, + "post": { + "operationId": "api_v1_admin_machines_error_logs_create", + "summary": "Log a machine error", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" }, - "description": "Invalid report request." - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } + "required": true + } + ], + "tags": [ + "Admin machines" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LogError" } }, - "description": "Authentication required." - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/LogError" } }, - "description": "Permission denied." + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/LogError" + } + } }, - "404": { + "required": true + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/MachineErrorLog" } } }, - "description": "Makerspace or report not found." + "description": "" + }, + "400": { + "description": "Invalid error log." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/analytics/payment-reconciliation": { - "get": { - "operationId": "api_v1_admin_makerspace_analytics_payment_reconciliation_retrieve", - "summary": "Get analytics report", + "/api/v1/admin/machines/{id}/image": { + "post": { + "operationId": "api_v1_admin_machines_image_create", + "summary": "Create a machine image upload URL", "parameters": [ - { - "in": "query", - "name": "end", - "schema": { - "type": "string", - "format": "date" - } - }, - { - "in": "query", - "name": "limit", - "schema": { - "type": "integer" - } - }, { "in": "path", - "name": "makerspace_id", + "name": "id", "schema": { "type": "integer" }, "required": true - }, - { - "in": "query", - "name": "start", - "schema": { - "type": "string", - "format": "date" - } - }, - { - "in": "query", - "name": "status", - "schema": { - "type": "string", - "enum": [ - "canceled", - "paid_offline", - "paid_online", - "pending", - "waived" - ] - } - }, - { - "in": "query", - "name": "subject_type", - "schema": { - "type": "string", - "enum": [ - "booking", - "event_registration", - "machine_service_request", - "makerspace_membership" - ] - } } ], "tags": [ - "Analytics" + "Admin machines" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicImageUploadRequest" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/PublicImageUploadRequest" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/PublicImageUploadRequest" + } + } + }, + "required": true + }, "security": [ { "jwtAuth": [] } ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AnalyticsReportResponse" + "$ref": "#/components/schemas/PublicImageUploadResponse" } } }, "description": "" }, "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } - } - }, - "description": "Invalid report request." - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } - } - }, - "description": "Authentication required." + "description": "Invalid image upload request." }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } - } - }, - "description": "Permission denied." + "description": "Machine management access is required." }, "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } - } - }, - "description": "Makerspace or report not found." + "description": "Machine not found." + }, + "503": { + "description": "Public image storage is unavailable." } } - } - }, - "/api/v1/admin/makerspace/{makerspace_id}/analytics/qr-scans": { - "get": { - "operationId": "api_v1_admin_makerspace_analytics_qr_scans_retrieve", - "summary": "Get analytics report", + }, + "put": { + "operationId": "api_v1_admin_machines_image_update", + "summary": "Attach an uploaded image to a machine", "parameters": [ - { - "in": "query", - "name": "end", - "schema": { - "type": "string", - "format": "date" - } - }, - { - "in": "query", - "name": "limit", - "schema": { - "type": "integer" - } - }, { "in": "path", - "name": "makerspace_id", + "name": "id", "schema": { "type": "integer" }, "required": true - }, - { - "in": "query", - "name": "start", - "schema": { - "type": "string", - "format": "date" - } - }, - { - "in": "query", - "name": "status", - "schema": { - "type": "string", - "enum": [ - "canceled", - "paid_offline", - "paid_online", - "pending", - "waived" - ] - } - }, - { - "in": "query", - "name": "subject_type", - "schema": { - "type": "string", - "enum": [ - "booking", - "event_registration", - "machine_service_request", - "makerspace_membership" - ] - } } ], "tags": [ - "Analytics" + "Admin machines" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicImageAttachRequest" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/PublicImageAttachRequest" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/PublicImageAttachRequest" + } + } + }, + "required": true + }, "security": [ { "jwtAuth": [] @@ -9008,121 +9248,83 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AnalyticsReportResponse" + "$ref": "#/components/schemas/Machine" } } }, "description": "" }, "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } - } - }, - "description": "Invalid report request." + "description": "Invalid image object key or size." }, - "401": { + "403": { + "description": "Machine management access is required." + }, + "404": { + "description": "Machine not found." + }, + "503": { + "description": "Public image storage is unavailable." + } + } + }, + "delete": { + "operationId": "api_v1_admin_machines_image_destroy", + "summary": "Clear a machine image", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Admin machines" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/Machine" } } }, - "description": "Authentication required." + "description": "" }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } - } - }, - "description": "Permission denied." + "description": "Machine management access is required." }, "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } - } - }, - "description": "Makerspace or report not found." + "description": "Machine not found." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/analytics/recently-added": { + "/api/v1/admin/machines/{id}/operator-candidates": { "get": { - "operationId": "api_v1_admin_makerspace_analytics_recently_added_retrieve", - "summary": "Get analytics report", + "operationId": "api_v1_admin_machines_operator_candidates_list", + "summary": "List active members eligible for machine operator assignment", "parameters": [ - { - "in": "query", - "name": "end", - "schema": { - "type": "string", - "format": "date" - } - }, - { - "in": "query", - "name": "limit", - "schema": { - "type": "integer" - } - }, { "in": "path", - "name": "makerspace_id", + "name": "id", "schema": { "type": "integer" }, "required": true - }, - { - "in": "query", - "name": "start", - "schema": { - "type": "string", - "format": "date" - } - }, - { - "in": "query", - "name": "status", - "schema": { - "type": "string", - "enum": [ - "canceled", - "paid_offline", - "paid_online", - "pending", - "waived" - ] - } - }, - { - "in": "query", - "name": "subject_type", - "schema": { - "type": "string", - "enum": [ - "booking", - "event_registration", - "machine_service_request", - "makerspace_membership" - ] - } } ], "tags": [ - "Analytics" + "Admin machines" ], "security": [ { @@ -9134,121 +9336,40 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AnalyticsReportResponse" + "type": "array", + "items": { + "$ref": "#/components/schemas/OperatorCandidate" + } } } }, "description": "" }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } - } - }, - "description": "Invalid report request." - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } - } - }, - "description": "Authentication required." - }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } - } - }, - "description": "Permission denied." + "description": "Operator delegation is not permitted." }, "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } - } - }, - "description": "Makerspace or report not found." + "description": "Machine not found." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/analytics/returns": { + "/api/v1/admin/machines/{id}/operators": { "get": { - "operationId": "api_v1_admin_makerspace_analytics_returns_retrieve", - "summary": "Get analytics report", + "operationId": "api_v1_admin_machines_operators_list", + "summary": "List machine operators", "parameters": [ - { - "in": "query", - "name": "end", - "schema": { - "type": "string", - "format": "date" - } - }, - { - "in": "query", - "name": "limit", - "schema": { - "type": "integer" - } - }, { "in": "path", - "name": "makerspace_id", + "name": "id", "schema": { "type": "integer" }, "required": true - }, - { - "in": "query", - "name": "start", - "schema": { - "type": "string", - "format": "date" - } - }, - { - "in": "query", - "name": "status", - "schema": { - "type": "string", - "enum": [ - "canceled", - "paid_offline", - "paid_online", - "pending", - "waived" - ] - } - }, - { - "in": "query", - "name": "subject_type", - "schema": { - "type": "string", - "enum": [ - "booking", - "event_registration", - "machine_service_request", - "makerspace_membership" - ] - } } ], "tags": [ - "Analytics" + "Admin machines" ], "security": [ { @@ -9260,122 +9381,119 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AnalyticsReportResponse" + "type": "array", + "items": { + "$ref": "#/components/schemas/MachineOperator" + } } } }, "description": "" - }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } - } + } + } + }, + "post": { + "operationId": "api_v1_admin_machines_operators_create", + "summary": "Assign a machine operator", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" }, - "description": "Invalid report request." - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } + "required": true + } + ], + "tags": [ + "Admin machines" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssignOperator" } }, - "description": "Authentication required." - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/AssignOperator" } }, - "description": "Permission denied." + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/AssignOperator" + } + } }, - "404": { + "required": true + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/MachineOperator" } } }, - "description": "Makerspace or report not found." + "description": "" + }, + "400": { + "description": "Invalid operator assignment." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/analytics/summary": { - "get": { - "operationId": "api_v1_admin_makerspace_analytics_summary_retrieve", - "summary": "Get analytics report", + "/api/v1/admin/machines/{id}/operators/{user_pk}": { + "patch": { + "operationId": "api_v1_admin_machines_operators_partial_update", + "summary": "Update a machine operator", "parameters": [ { - "in": "query", - "name": "end", - "schema": { - "type": "string", - "format": "date" - } - }, - { - "in": "query", - "name": "limit", + "in": "path", + "name": "id", "schema": { "type": "integer" - } + }, + "required": true }, { "in": "path", - "name": "makerspace_id", + "name": "user_pk", "schema": { "type": "integer" }, "required": true - }, - { - "in": "query", - "name": "start", - "schema": { - "type": "string", - "format": "date" - } - }, - { - "in": "query", - "name": "status", - "schema": { - "type": "string", - "enum": [ - "canceled", - "paid_offline", - "paid_online", - "pending", - "waived" - ] - } - }, - { - "in": "query", - "name": "subject_type", - "schema": { - "type": "string", - "enum": [ - "booking", - "event_registration", - "machine_service_request", - "makerspace_membership" - ] - } } ], "tags": [ - "Analytics" + "Admin machines" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PatchedAssignOperator" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/PatchedAssignOperator" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/PatchedAssignOperator" + } + } + } + }, "security": [ { "jwtAuth": [] @@ -9386,121 +9504,40 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AnalyticsReportResponse" + "$ref": "#/components/schemas/MachineOperator" } } }, "description": "" }, "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } - } - }, - "description": "Invalid report request." - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } - } - }, - "description": "Authentication required." - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } - } - }, - "description": "Permission denied." - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } - } - }, - "description": "Makerspace or report not found." + "description": "Invalid operator assignment." } } - } - }, - "/api/v1/admin/makerspace/{makerspace_id}/analytics/taken-items": { - "get": { - "operationId": "api_v1_admin_makerspace_analytics_taken_items_retrieve", - "summary": "Get analytics report", + }, + "delete": { + "operationId": "api_v1_admin_machines_operators_destroy", + "summary": "Remove a machine operator", "parameters": [ { - "in": "query", - "name": "end", - "schema": { - "type": "string", - "format": "date" - } - }, - { - "in": "query", - "name": "limit", + "in": "path", + "name": "id", "schema": { "type": "integer" - } + }, + "required": true }, { "in": "path", - "name": "makerspace_id", + "name": "user_pk", "schema": { "type": "integer" }, "required": true - }, - { - "in": "query", - "name": "start", - "schema": { - "type": "string", - "format": "date" - } - }, - { - "in": "query", - "name": "status", - "schema": { - "type": "string", - "enum": [ - "canceled", - "paid_offline", - "paid_online", - "pending", - "waived" - ] - } - }, - { - "in": "query", - "name": "subject_type", - "schema": { - "type": "string", - "enum": [ - "booking", - "event_registration", - "machine_service_request", - "makerspace_membership" - ] - } } ], "tags": [ - "Analytics" + "Admin machines" ], "security": [ { @@ -9508,125 +9545,28 @@ } ], "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AnalyticsReportResponse" - } - } - }, - "description": "" - }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } - } - }, - "description": "Invalid report request." - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } - } - }, - "description": "Authentication required." - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } - } - }, - "description": "Permission denied." - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } - } - }, - "description": "Makerspace or report not found." + "204": { + "description": "No response body" } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/analytics/top-borrowers": { + "/api/v1/admin/machines/{id}/publicity": { "get": { - "operationId": "api_v1_admin_makerspace_analytics_top_borrowers_retrieve", - "summary": "Get analytics report", + "operationId": "api_v1_admin_machines_publicity_retrieve", + "summary": "Preview a machine public listing", "parameters": [ - { - "in": "query", - "name": "end", - "schema": { - "type": "string", - "format": "date" - } - }, - { - "in": "query", - "name": "limit", - "schema": { - "type": "integer" - } - }, { "in": "path", - "name": "makerspace_id", + "name": "id", "schema": { "type": "integer" }, "required": true - }, - { - "in": "query", - "name": "start", - "schema": { - "type": "string", - "format": "date" - } - }, - { - "in": "query", - "name": "status", - "schema": { - "type": "string", - "enum": [ - "canceled", - "paid_offline", - "paid_online", - "pending", - "waived" - ] - } - }, - { - "in": "query", - "name": "subject_type", - "schema": { - "type": "string", - "enum": [ - "booking", - "event_registration", - "machine_service_request", - "makerspace_membership" - ] - } } ], "tags": [ - "Analytics" + "Admin machines" ], "security": [ { @@ -9638,63 +9578,21 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AnalyticsReportResponse" + "$ref": "#/components/schemas/PublicMachine" } } }, "description": "" - }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } - } - }, - "description": "Invalid report request." - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } - } - }, - "description": "Authentication required." - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } - } - }, - "description": "Permission denied." - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } - } - }, - "description": "Makerspace or report not found." } } - } - }, - "/api/v1/admin/makerspace/{makerspace_id}/api-client-scopes": { - "get": { - "operationId": "api_v1_admin_makerspace_api_client_scopes_retrieve", - "summary": "List API-client scope grant options", + }, + "patch": { + "operationId": "api_v1_admin_machines_publicity_partial_update", + "summary": "Set machine public visibility", "parameters": [ { "in": "path", - "name": "makerspace_id", + "name": "id", "schema": { "type": "integer" }, @@ -9702,8 +9600,27 @@ } ], "tags": [ - "API clients" + "Admin machines" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PatchedMachinePublicity" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/PatchedMachinePublicity" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/PatchedMachinePublicity" + } + } + } + }, "security": [ { "jwtAuth": [] @@ -9714,70 +9631,40 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ApiClientScopeCatalogResponse" + "$ref": "#/components/schemas/PublicMachine" } } }, "description": "" }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "400": { + "description": "Invalid publicity setting." }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "description": "MANAGE_MACHINES is required." }, "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "description": "Machine not found." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/api-clients": { - "get": { - "operationId": "api_v1_admin_makerspace_api_clients_list", - "summary": "List or create makerspace API clients", + "/api/v1/admin/machines/{id}/retire": { + "post": { + "operationId": "api_v1_admin_machines_retire_create", + "summary": "Retire a machine", "parameters": [ { "in": "path", - "name": "makerspace_id", + "name": "id", "schema": { "type": "integer" }, "required": true - }, - { - "name": "page", - "required": false, - "in": "query", - "description": "A page number within the paginated result set.", - "schema": { - "type": "integer" - } } ], "tags": [ - "API clients" + "Admin machines" ], "security": [ { @@ -9789,51 +9676,23 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaginatedApiClientList" - } - } - }, - "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" + "$ref": "#/components/schemas/Machine" } } }, "description": "" } } - }, + } + }, + "/api/v1/admin/machines/{id}/set-status": { "post": { - "operationId": "api_v1_admin_makerspace_api_clients_create", - "summary": "List or create makerspace API clients", + "operationId": "api_v1_admin_machines_set_status_create", + "summary": "Set a machine status", "parameters": [ { "in": "path", - "name": "makerspace_id", + "name": "id", "schema": { "type": "integer" }, @@ -9841,23 +9700,23 @@ } ], "tags": [ - "API clients" + "Admin machines" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ApiClient" + "$ref": "#/components/schemas/SetStatus" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/ApiClient" + "$ref": "#/components/schemas/SetStatus" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/ApiClient" + "$ref": "#/components/schemas/SetStatus" } } }, @@ -9869,41 +9728,50 @@ } ], "responses": { - "201": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ApiClientCreateResponse" - } - } - }, - "description": "" - }, - "401": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/Machine" } } }, "description": "" }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } + "400": { + "description": "Invalid machine status." + } + } + } + }, + "/api/v1/admin/machines/{id}/unretire": { + "post": { + "operationId": "api_v1_admin_machines_unretire_create", + "summary": "Reactivate a retired machine", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" }, - "description": "" - }, - "404": { + "required": true + } + ], + "tags": [ + "Admin machines" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/Machine" } } }, @@ -9912,14 +9780,14 @@ } } }, - "/api/v1/admin/makerspace/{makerspace_id}/api-settings": { + "/api/v1/admin/machines/{id}/usage": { "get": { - "operationId": "api_v1_admin_makerspace_api_settings_retrieve", - "summary": "Retrieve or update API integration settings", + "operationId": "api_v1_admin_machines_usage_list", + "summary": "List machine usage entries", "parameters": [ { "in": "path", - "name": "makerspace_id", + "name": "id", "schema": { "type": "integer" }, @@ -9927,7 +9795,7 @@ } ], "tags": [ - "API clients" + "Admin machines" ], "security": [ { @@ -9939,7 +9807,10 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ApiIntegrationSettings" + "type": "array", + "items": { + "$ref": "#/components/schemas/MachineUsageEntry" + } } } }, @@ -9947,13 +9818,13 @@ } } }, - "patch": { - "operationId": "api_v1_admin_makerspace_api_settings_partial_update", - "summary": "Retrieve or update API integration settings", + "post": { + "operationId": "api_v1_admin_machines_usage_create", + "summary": "Log machine usage", "parameters": [ { "in": "path", - "name": "makerspace_id", + "name": "id", "schema": { "type": "integer" }, @@ -9961,26 +9832,27 @@ } ], "tags": [ - "API clients" + "Admin machines" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PatchedApiIntegrationSettings" + "$ref": "#/components/schemas/LogUsage" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/PatchedApiIntegrationSettings" + "$ref": "#/components/schemas/LogUsage" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/PatchedApiIntegrationSettings" + "$ref": "#/components/schemas/LogUsage" } } - } + }, + "required": true }, "security": [ { @@ -9988,27 +9860,30 @@ } ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ApiIntegrationSettings" + "$ref": "#/components/schemas/MachineUsageEntry" } } }, "description": "" + }, + "400": { + "description": "Invalid usage entry." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/archive-recipients": { + "/api/v1/admin/machines/{id}/warranty": { "get": { - "operationId": "api_v1_admin_makerspace_archive_recipients_list", - "summary": "List archive recipients for a makerspace", + "operationId": "api_v1_admin_machines_warranty_retrieve", + "summary": "Retrieve warranty details for a machine", "parameters": [ { "in": "path", - "name": "makerspace_id", + "name": "id", "schema": { "type": "integer" }, @@ -10016,7 +9891,7 @@ } ], "tags": [ - "Backup recipients" + "Admin warranty" ], "security": [ { @@ -10028,55 +9903,51 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ArchiveRecipient" - } + "$ref": "#/components/schemas/Warranty" } } }, "description": "" }, - "404": { + "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ArchiveRecipientError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "The recipient does not exist in this makerspace." + "description": "" }, - "401": { + "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ArchiveRecipientError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Authentication is required." + "description": "" }, - "403": { + "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ArchiveRecipientError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Manage-makerspace permission is required." + "description": "" } } }, - "post": { - "operationId": "api_v1_admin_makerspace_archive_recipients_create", - "description": "The decrypted challenge is a 32-byte nonce exchanged as canonical, unpadded base64url. Only the SHA-256 digest of the raw 32 nonce bytes is persisted.", - "summary": "Enroll an archive recipient and issue a custody challenge", + "put": { + "operationId": "api_v1_admin_machines_warranty_update", + "summary": "Create or update warranty details for a machine", "parameters": [ { "in": "path", - "name": "makerspace_id", + "name": "id", "schema": { "type": "integer" }, @@ -10084,27 +9955,26 @@ } ], "tags": [ - "Backup recipients" + "Admin warranty" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ArchiveRecipientCreate" + "$ref": "#/components/schemas/WarrantyUpsert" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/ArchiveRecipientCreate" + "$ref": "#/components/schemas/WarrantyUpsert" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/ArchiveRecipientCreate" + "$ref": "#/components/schemas/WarrantyUpsert" } } - }, - "required": true + } }, "security": [ { @@ -10112,83 +9982,56 @@ } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ArchiveRecipientChallenge" + "$ref": "#/components/schemas/Warranty" } } }, "description": "" }, "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ArchiveRecipientError" - } - } - }, - "description": "The recipient or lifecycle request is invalid." + "description": "Invalid warranty details." }, - "404": { + "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ArchiveRecipientError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "The recipient does not exist in this makerspace." + "description": "" }, - "409": { + "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ArchiveRecipientError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "The recipient or lifecycle request is invalid." + "description": "" }, - "503": { + "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ArchiveRecipientError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "The age challenge could not be encrypted." - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ArchiveRecipientError" - } - } - }, - "description": "Authentication is required." - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ArchiveRecipientError" - } - } - }, - "description": "Manage-makerspace permission is required." + "description": "" } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/archive-recipients/{id}/compromise": { - "post": { - "operationId": "api_v1_admin_makerspace_archive_recipients_compromise_create", - "summary": "Mark an archive recipient as compromised", + "/api/v1/admin/machines/documents/{id}": { + "delete": { + "operationId": "api_v1_admin_machines_documents_destroy", + "summary": "Delete a machine document", "parameters": [ { "in": "path", @@ -10197,10 +10040,31 @@ "type": "integer" }, "required": true - }, + } + ], + "tags": [ + "Admin machines" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "204": { + "description": "No response body" + } + } + } + }, + "/api/v1/admin/machines/documents/{id}/url": { + "get": { + "operationId": "api_v1_admin_machines_documents_url_retrieve", + "summary": "Create a signed machine document view URL", + "parameters": [ { "in": "path", - "name": "makerspace_id", + "name": "id", "schema": { "type": "integer" }, @@ -10208,7 +10072,7 @@ } ], "tags": [ - "Backup recipients" + "Admin machines" ], "security": [ { @@ -10217,62 +10081,18 @@ ], "responses": { "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ArchiveRecipient" - } - } - }, - "description": "" - }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ArchiveRecipientError" - } - } - }, - "description": "The recipient or lifecycle request is invalid." - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ArchiveRecipientError" - } - } - }, - "description": "The recipient does not exist in this makerspace." - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ArchiveRecipientError" - } - } - }, - "description": "Authentication is required." + "description": "Signed machine document URL." }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ArchiveRecipientError" - } - } - }, - "description": "Manage-makerspace permission is required." + "503": { + "description": "Machine document storage is unavailable." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/archive-recipients/{id}/reactivate": { - "post": { - "operationId": "api_v1_admin_makerspace_archive_recipients_reactivate_create", - "summary": "Reactivate a revoked archive recipient", + "/api/v1/admin/maintenance/log-documents/{id}/": { + "delete": { + "operationId": "api_v1_admin_maintenance_log_documents_destroy", + "summary": "Delete a maintenance document", "parameters": [ { "in": "path", @@ -10281,18 +10101,10 @@ "type": "integer" }, "required": true - }, - { - "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" - }, - "required": true } ], "tags": [ - "Backup recipients" + "Admin maintenance" ], "security": [ { @@ -10300,64 +10112,66 @@ } ], "responses": { - "200": { + "204": { + "description": "No response body" + }, + "400": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ArchiveRecipient" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "" + "description": "Invalid request." }, - "400": { + "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ArchiveRecipientError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "The recipient or lifecycle request is invalid." + "description": "Permission denied." }, "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ArchiveRecipientError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "The recipient does not exist in this makerspace." + "description": "Not found." }, - "401": { + "409": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ArchiveRecipientError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Authentication is required." + "description": "Workflow conflict." }, - "403": { + "503": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ArchiveRecipientError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Manage-makerspace permission is required." + "description": "Service unavailable." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/archive-recipients/{id}/reissue-challenge": { - "post": { - "operationId": "api_v1_admin_makerspace_archive_recipients_reissue_challenge_create", - "description": "The decrypted 32-byte nonce is exchanged as canonical, unpadded base64url. The prior challenge stops verifying when this request commits.", - "summary": "Reissue an archive-recipient custody challenge", + "/api/v1/admin/maintenance/log-documents/{id}/url/": { + "get": { + "operationId": "api_v1_admin_maintenance_log_documents_url_retrieve", + "summary": "Create a private maintenance document URL", "parameters": [ { "in": "path", @@ -10366,18 +10180,10 @@ "type": "integer" }, "required": true - }, - { - "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" - }, - "required": true } ], "tags": [ - "Backup recipients" + "Admin maintenance" ], "security": [ { @@ -10389,7 +10195,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ArchiveRecipientChallenge" + "$ref": "#/components/schemas/MaintenanceDocumentUrl" } } }, @@ -10399,59 +10205,59 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ArchiveRecipientError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "The recipient or lifecycle request is invalid." + "description": "Invalid request." }, - "404": { + "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ArchiveRecipientError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "The recipient does not exist in this makerspace." + "description": "Permission denied." }, - "503": { + "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ArchiveRecipientError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "The age challenge could not be encrypted." + "description": "Not found." }, - "401": { + "409": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ArchiveRecipientError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Authentication is required." + "description": "Workflow conflict." }, - "403": { + "503": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ArchiveRecipientError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Manage-makerspace permission is required." + "description": "Service unavailable." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/archive-recipients/{id}/revoke": { + "/api/v1/admin/maintenance/logs/{id}/documents/": { "post": { - "operationId": "api_v1_admin_makerspace_archive_recipients_revoke_create", - "summary": "Revoke an archive recipient", + "operationId": "api_v1_admin_maintenance_logs_documents_create", + "summary": "Finalize a maintenance document upload", "parameters": [ { "in": "path", @@ -10460,30 +10266,42 @@ "type": "integer" }, "required": true - }, - { - "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" - }, - "required": true } ], "tags": [ - "Backup recipients" + "Admin maintenance" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MaintenanceDocumentFinalize" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/MaintenanceDocumentFinalize" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/MaintenanceDocumentFinalize" + } + } + }, + "required": true + }, "security": [ { "jwtAuth": [] } ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ArchiveRecipient" + "$ref": "#/components/schemas/MaintenanceLogDocument" } } }, @@ -10493,50 +10311,59 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ArchiveRecipientError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "The recipient or lifecycle request is invalid." + "description": "Invalid request." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Permission denied." }, "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ArchiveRecipientError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "The recipient does not exist in this makerspace." + "description": "Not found." }, - "401": { + "409": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ArchiveRecipientError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Authentication is required." + "description": "Workflow conflict." }, - "403": { + "503": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ArchiveRecipientError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Manage-makerspace permission is required." + "description": "Service unavailable." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/archive-recipients/{id}/verify": { + "/api/v1/admin/maintenance/logs/{id}/documents/presign/": { "post": { - "operationId": "api_v1_admin_makerspace_archive_recipients_verify_create", - "description": "Submit the decrypted nonce as canonical unpadded base64url. It must decode to exactly 32 bytes; padding and non-canonical forms are refused.", - "summary": "Verify possession of an archive recipient", + "operationId": "api_v1_admin_maintenance_logs_documents_presign_create", + "summary": "Create a maintenance document upload URL", "parameters": [ { "in": "path", @@ -10545,34 +10372,26 @@ "type": "integer" }, "required": true - }, - { - "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" - }, - "required": true } ], "tags": [ - "Backup recipients" + "Admin maintenance" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ArchiveRecipientVerify" + "$ref": "#/components/schemas/MaintenanceDocumentPresign" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/ArchiveRecipientVerify" + "$ref": "#/components/schemas/MaintenanceDocumentPresign" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/ArchiveRecipientVerify" + "$ref": "#/components/schemas/MaintenanceDocumentPresign" } } }, @@ -10588,7 +10407,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ArchiveRecipient" + "$ref": "#/components/schemas/MaintenanceDocumentPresignResponse" } } }, @@ -10598,73 +10417,63 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ArchiveRecipientError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "The recipient or lifecycle request is invalid." + "description": "Invalid request." }, - "409": { + "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ArchiveRecipientError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "The recipient or lifecycle request is invalid." + "description": "Permission denied." }, "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ArchiveRecipientError" - } - } - }, - "description": "The recipient does not exist in this makerspace." - }, - "429": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ArchiveRecipientError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Verification attempts for this recipient are throttled." + "description": "Not found." }, - "401": { + "409": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ArchiveRecipientError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Authentication is required." + "description": "Workflow conflict." }, - "403": { + "503": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ArchiveRecipientError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Manage-makerspace permission is required." + "description": "Service unavailable." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/archive-requests": { - "get": { - "operationId": "api_v1_admin_makerspace_archive_requests_list", - "summary": "List archive requests for a makerspace", + "/api/v1/admin/maintenance/schedules/{id}/": { + "patch": { + "operationId": "api_v1_admin_maintenance_schedules_partial_update", + "summary": "Update a maintenance schedule", "parameters": [ { "in": "path", - "name": "makerspace_id", + "name": "id", "schema": { "type": "integer" }, @@ -10672,8 +10481,27 @@ } ], "tags": [ - "Admin makerspaces" + "Admin maintenance" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PatchedMaintenanceScheduleWrite" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/PatchedMaintenanceScheduleWrite" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/PatchedMaintenanceScheduleWrite" + } + } + } + }, "security": [ { "jwtAuth": [] @@ -10684,44 +10512,63 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/MakerspaceArchiveRequest" - } + "$ref": "#/components/schemas/MaintenanceSchedule" } } }, "description": "" }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Invalid request." + }, "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ArchiveRequestError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "" + "description": "Permission denied." }, "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ArchiveRequestError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "" + "description": "Not found." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Workflow conflict." } } - }, + } + }, + "/api/v1/admin/maintenance/schedules/{id}/deactivate/": { "post": { - "operationId": "api_v1_admin_makerspace_archive_requests_create", - "summary": "Request makerspace archival", + "operationId": "api_v1_admin_maintenance_schedules_deactivate_create", + "summary": "Deactivate a maintenance schedule", "parameters": [ { "in": "path", - "name": "makerspace_id", + "name": "id", "schema": { "type": "integer" }, @@ -10729,39 +10576,19 @@ } ], "tags": [ - "Admin makerspaces" + "Admin maintenance" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MakerspaceArchiveRequest" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/MakerspaceArchiveRequest" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/MakerspaceArchiveRequest" - } - } - }, - "required": true - }, "security": [ { "jwtAuth": [] } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MakerspaceArchiveRequest" + "$ref": "#/components/schemas/MaintenanceSchedule" } } }, @@ -10771,69 +10598,79 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ArchiveRequestValidationError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "The reason was blank or too long." + "description": "Invalid request." }, "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ArchiveRequestError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "" + "description": "Permission denied." }, "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ArchiveRequestError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "" + "description": "Not found." }, "409": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ArchiveRequestError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "" + "description": "Workflow conflict." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/archive-requests/{id}/withdraw": { - "post": { - "operationId": "api_v1_admin_makerspace_archive_requests_withdraw_create", - "summary": "Withdraw a pending makerspace archive request", + "/api/v1/admin/makerspace/{makerspace_id}/accepted-requests": { + "get": { + "operationId": "api_v1_admin_makerspace_accepted_requests_list", + "summary": "List accepted requests awaiting issue", "parameters": [ { "in": "path", - "name": "id", + "name": "makerspace_id", "schema": { "type": "integer" }, "required": true }, { - "in": "path", - "name": "makerspace_id", + "name": "page", + "required": false, + "in": "query", + "description": "A page number within the paginated result set.", "schema": { "type": "integer" - }, - "required": true + } + }, + { + "name": "search", + "required": false, + "in": "query", + "description": "A search term (requested-for, requester name/email).", + "schema": { + "type": "string" + } } ], "tags": [ - "Admin makerspaces" + "Admin requests" ], "security": [ { @@ -10845,7 +10682,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MakerspaceArchiveRequest" + "$ref": "#/components/schemas/PaginatedAdminRequestList" } } }, @@ -10855,39 +10692,29 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ArchiveRequestError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "" + "description": "Permission denied." }, "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ArchiveRequestError" - } - } - }, - "description": "" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ArchiveRequestError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "" + "description": "Not found." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/backups": { + "/api/v1/admin/makerspace/{makerspace_id}/accountability": { "get": { - "operationId": "api_v1_admin_makerspace_backups_list", - "summary": "List makerspace backup archives", + "operationId": "api_v1_admin_makerspace_accountability_retrieve", + "summary": "Requester accountability dashboard", "parameters": [ { "in": "path", @@ -10899,7 +10726,7 @@ } ], "tags": [ - "Backup" + "Analytics" ], "security": [ { @@ -10911,77 +10738,60 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/BackupArchive" - } + "type": "object", + "additionalProperties": {} } } }, "description": "" }, - "404": { - "description": "The requested resource does not exist in the actor's scope." - }, - "401": { - "description": "Authentication is required." - }, - "403": { - "description": "The authenticated actor is not authorized." - } - } - }, - "post": { - "operationId": "api_v1_admin_makerspace_backups_create", - "summary": "Request an age-encrypted makerspace backup", - "parameters": [ - { - "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" - }, - "required": true - } - ], - "tags": [ - "Backup" - ], - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "202": { + "400": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BackupArchive" + "$ref": "#/components/schemas/ReportError" } } }, - "description": "" - }, - "404": { - "description": "The requested resource does not exist in the actor's scope." - }, - "503": { - "description": "The backup worker is unavailable." + "description": "Invalid report request." }, "401": { - "description": "Authentication is required." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Authentication required." }, "403": { - "description": "The authenticated actor is not authorized." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Permission denied." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Makerspace or report not found." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/categories": { + "/api/v1/admin/makerspace/{makerspace_id}/active-loans": { "get": { - "operationId": "api_v1_admin_makerspace_categories_list", - "summary": "List or create inventory categories", + "operationId": "api_v1_admin_makerspace_active_loans_list", + "summary": "List active loans awaiting return", "parameters": [ { "in": "path", @@ -10999,10 +10809,19 @@ "schema": { "type": "integer" } + }, + { + "name": "search", + "required": false, + "in": "query", + "description": "A search term (requested-for, requester name/email).", + "schema": { + "type": "string" + } } ], "tags": [ - "Admin inventory" + "Admin requests" ], "security": [ { @@ -11014,74 +10833,66 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaginatedCategoryAdminList" + "$ref": "#/components/schemas/PaginatedAdminRequestList" } } }, "description": "" - } - } - }, - "post": { - "operationId": "api_v1_admin_makerspace_categories_create", - "summary": "List or create inventory categories", - "parameters": [ - { - "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" - }, - "required": true - } - ], - "tags": [ - "Admin inventory" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CategoryAdmin" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/CategoryAdmin" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } } }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/CategoryAdmin" - } - } + "description": "Permission denied." }, - "required": true - }, - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "201": { + "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CategoryAdmin" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "" + "description": "Not found." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/containers": { + "/api/v1/admin/makerspace/{makerspace_id}/analytics/{report_key}": { "get": { - "operationId": "api_v1_admin_makerspace_containers_list", - "summary": "List containers", + "operationId": "api_v1_admin_makerspace_analytics_retrieve", + "summary": "Get analytics report", "parameters": [ + { + "in": "query", + "name": "end", + "schema": { + "type": "string", + "format": "date" + } + }, + { + "in": "query", + "name": "grain", + "schema": { + "type": "string", + "enum": [ + "day", + "month" + ] + } + }, + { + "in": "query", + "name": "limit", + "schema": { + "type": "integer" + } + }, { "in": "path", "name": "makerspace_id", @@ -11091,26 +10902,51 @@ "required": true }, { - "name": "page", - "required": false, + "in": "path", + "name": "report_key", + "schema": { + "type": "string" + }, + "required": true + }, + { "in": "query", - "description": "A page number within the paginated result set.", + "name": "start", "schema": { - "type": "integer" + "type": "string", + "format": "date" } }, { - "name": "page_size", - "required": false, "in": "query", - "description": "Number of results to return per page.", + "name": "status", "schema": { - "type": "integer" + "type": "string", + "enum": [ + "canceled", + "paid_offline", + "paid_online", + "pending", + "waived" + ] + } + }, + { + "in": "query", + "name": "subject_type", + "schema": { + "type": "string", + "enum": [ + "booking", + "event_registration", + "machine_service_request", + "makerspace_membership" + ] } } ], "tags": [ - "Containers" + "Analytics" ], "security": [ { @@ -11122,134 +10958,86 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaginatedBoxList" + "$ref": "#/components/schemas/AnalyticsReportResponse" } } }, "description": "" - } - } - }, - "post": { - "operationId": "api_v1_admin_makerspace_containers_create", - "summary": "Create container", - "parameters": [ - { - "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" - }, - "required": true - } - ], - "tags": [ - "Containers" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Box" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } } }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/Box" + "description": "Invalid report request." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } } }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/Box" + "description": "Authentication required." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } } - } + }, + "description": "Permission denied." }, - "required": true - }, - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "201": { + "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Box" + "$ref": "#/components/schemas/ReportError" } } }, - "description": "" + "description": "Makerspace or report not found." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/cover": { - "post": { - "operationId": "api_v1_admin_makerspace_cover_create", - "summary": "Create a makerspace public image upload URL", + "/api/v1/admin/makerspace/{makerspace_id}/analytics/active-loans": { + "get": { + "operationId": "api_v1_admin_makerspace_analytics_active_loans_retrieve", + "summary": "Get analytics report", "parameters": [ { - "in": "path", - "name": "makerspace_id", + "in": "query", + "name": "end", "schema": { - "type": "integer" - }, - "required": true - } - ], - "tags": [ - "Admin makerspaces" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PublicImageUploadRequest" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/PublicImageUploadRequest" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/PublicImageUploadRequest" - } + "type": "string", + "format": "date" } }, - "required": true - }, - "security": [ { - "jwtAuth": [] - } - ], - "responses": { - "201": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PublicImageUploadResponse" - } - } - }, - "description": "" + "in": "query", + "name": "grain", + "schema": { + "type": "string", + "enum": [ + "day", + "month" + ] + } }, - "400": { - "description": "Invalid image upload request." + { + "in": "query", + "name": "limit", + "schema": { + "type": "integer" + } }, - "503": { - "description": "Public image storage is unavailable." - } - } - }, - "put": { - "operationId": "api_v1_admin_makerspace_cover_update", - "summary": "Attach an uploaded public image to a makerspace", - "parameters": [ { "in": "path", "name": "makerspace_id", @@ -11257,31 +11045,46 @@ "type": "integer" }, "required": true + }, + { + "in": "query", + "name": "start", + "schema": { + "type": "string", + "format": "date" + } + }, + { + "in": "query", + "name": "status", + "schema": { + "type": "string", + "enum": [ + "canceled", + "paid_offline", + "paid_online", + "pending", + "waived" + ] + } + }, + { + "in": "query", + "name": "subject_type", + "schema": { + "type": "string", + "enum": [ + "booking", + "event_registration", + "machine_service_request", + "makerspace_membership" + ] + } } ], "tags": [ - "Admin makerspaces" + "Analytics" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PublicImageAttachRequest" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/PublicImageAttachRequest" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/PublicImageAttachRequest" - } - } - }, - "required": true - }, "security": [ { "jwtAuth": [] @@ -11292,102 +11095,86 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Makerspace" + "$ref": "#/components/schemas/AnalyticsReportResponse" } } }, "description": "" }, "400": { - "description": "Invalid image object key or size." - }, - "503": { - "description": "Public image storage is unavailable." - } - } - }, - "delete": { - "operationId": "api_v1_admin_makerspace_cover_destroy", - "summary": "Clear a makerspace public image", - "parameters": [ - { - "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" - }, - "required": true - } - ], - "tags": [ - "Admin makerspaces" - ], - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Makerspace" + "$ref": "#/components/schemas/ReportError" } } }, - "description": "" - } - } - } - }, - "/api/v1/admin/makerspace/{makerspace_id}/dashboard": { - "get": { - "operationId": "api_v1_admin_makerspace_dashboard_retrieve", - "summary": "Staff operations dashboard counts", - "parameters": [ - { - "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" - }, - "required": true - } - ], - "tags": [ - "Dashboard" - ], - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { + "description": "Invalid report request." + }, + "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Dashboard" + "$ref": "#/components/schemas/ReportError" } } }, - "description": "" + "description": "Authentication required." }, "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, "description": "Permission denied." }, "404": { - "description": "Makerspace not found." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Makerspace or report not found." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/data-exports": { + "/api/v1/admin/makerspace/{makerspace_id}/analytics/booking-utilization": { "get": { - "operationId": "api_v1_admin_makerspace_data_exports_list", - "summary": "List makerspace data-export jobs", + "operationId": "api_v1_admin_makerspace_analytics_booking_utilization_retrieve", + "summary": "Get analytics report", "parameters": [ + { + "in": "query", + "name": "end", + "schema": { + "type": "string", + "format": "date" + } + }, + { + "in": "query", + "name": "grain", + "schema": { + "type": "string", + "enum": [ + "day", + "month" + ] + } + }, + { + "in": "query", + "name": "limit", + "schema": { + "type": "integer" + } + }, { "in": "path", "name": "makerspace_id", @@ -11395,10 +11182,45 @@ "type": "integer" }, "required": true + }, + { + "in": "query", + "name": "start", + "schema": { + "type": "string", + "format": "date" + } + }, + { + "in": "query", + "name": "status", + "schema": { + "type": "string", + "enum": [ + "canceled", + "paid_offline", + "paid_online", + "pending", + "waived" + ] + } + }, + { + "in": "query", + "name": "subject_type", + "schema": { + "type": "string", + "enum": [ + "booking", + "event_registration", + "machine_service_request", + "makerspace_membership" + ] + } } ], "tags": [ - "Data exports" + "Analytics" ], "security": [ { @@ -11410,96 +11232,85 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/DataExportJob" - } + "$ref": "#/components/schemas/AnalyticsReportResponse" } } }, "description": "" }, - "403": { - "description": "MANAGE_MAKERSPACE is required." - } - } - }, - "post": { - "operationId": "api_v1_admin_makerspace_data_exports_create", - "summary": "Request a redacted makerspace data export", - "parameters": [ - { - "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" - }, - "required": true - } - ], - "tags": [ - "Data exports" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DataExportCreate" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/DataExportCreate" + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } } }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/DataExportCreate" - } - } - } - }, - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "201": { + "description": "Invalid report request." + }, + "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DataExportJob" + "$ref": "#/components/schemas/ReportError" } } }, - "description": "" - }, - "400": { - "description": "Invalid fidelity or quota/module policy failure." + "description": "Authentication required." }, "403": { - "description": "MANAGE_MAKERSPACE is required." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Permission denied." }, - "429": { - "description": "Export creation rate limit exceeded." + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Makerspace or report not found." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/data-exports/{job_id}": { + "/api/v1/admin/makerspace/{makerspace_id}/analytics/damaged-lost": { "get": { - "operationId": "api_v1_admin_makerspace_data_exports_retrieve", - "summary": "Poll a makerspace data-export job", + "operationId": "api_v1_admin_makerspace_analytics_damaged_lost_retrieve", + "summary": "Get analytics report", "parameters": [ { - "in": "path", - "name": "job_id", + "in": "query", + "name": "end", "schema": { "type": "string", - "format": "uuid" - }, - "required": true + "format": "date" + } + }, + { + "in": "query", + "name": "grain", + "schema": { + "type": "string", + "enum": [ + "day", + "month" + ] + } + }, + { + "in": "query", + "name": "limit", + "schema": { + "type": "integer" + } }, { "in": "path", @@ -11508,61 +11319,45 @@ "type": "integer" }, "required": true - } - ], - "tags": [ - "Data exports" - ], - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DataExportJob" - } - } - }, - "description": "" }, - "403": { - "description": "MANAGE_MAKERSPACE is required." + { + "in": "query", + "name": "start", + "schema": { + "type": "string", + "format": "date" + } }, - "404": { - "description": "Export job not found in this makerspace." - } - } - } - }, - "/api/v1/admin/makerspace/{makerspace_id}/data-exports/{job_id}/download-url": { - "post": { - "operationId": "api_v1_admin_makerspace_data_exports_download_url_create", - "summary": "Issue a short-lived one-use export download URL", - "parameters": [ { - "in": "path", - "name": "job_id", + "in": "query", + "name": "status", "schema": { "type": "string", - "format": "uuid" - }, - "required": true + "enum": [ + "canceled", + "paid_offline", + "paid_online", + "pending", + "waived" + ] + } }, { - "in": "path", - "name": "makerspace_id", + "in": "query", + "name": "subject_type", "schema": { - "type": "integer" - }, - "required": true + "type": "string", + "enum": [ + "booking", + "event_registration", + "machine_service_request", + "makerspace_membership" + ] + } } ], "tags": [ - "Data exports" + "Analytics" ], "security": [ { @@ -11574,73 +11369,86 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DataExportDownloadUrl" + "$ref": "#/components/schemas/AnalyticsReportResponse" } } }, "description": "" }, "400": { - "description": "Export is not available." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Invalid report request." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Authentication required." }, "403": { - "description": "MANAGE_MAKERSPACE is required; PORTABLE additionally requires source superadmin." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Permission denied." }, "404": { - "description": "Export job not found in this makerspace." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Makerspace or report not found." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/direct-loan-members": { + "/api/v1/admin/makerspace/{makerspace_id}/analytics/damaged-missing": { "get": { - "operationId": "api_v1_admin_makerspace_direct_loan_members_list", + "operationId": "api_v1_admin_makerspace_analytics_damaged_missing_retrieve", + "summary": "Get analytics report", "parameters": [ { - "in": "path", - "name": "makerspace_id", + "in": "query", + "name": "end", "schema": { - "type": "integer" - }, - "required": true + "type": "string", + "format": "date" + } }, { - "name": "page", - "required": false, "in": "query", - "description": "A page number within the paginated result set.", + "name": "grain", "schema": { - "type": "integer" + "type": "string", + "enum": [ + "day", + "month" + ] } - } - ], - "tags": [ - "api" - ], - "security": [ + }, { - "jwtAuth": [] - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PaginatedDirectLoanMemberList" - } - } - }, - "description": "" - } - } - } - }, - "/api/v1/admin/makerspace/{makerspace_id}/direct-loans": { - "get": { - "operationId": "api_v1_admin_makerspace_direct_loans_list", - "summary": "List direct handout loans", - "parameters": [ + "in": "query", + "name": "limit", + "schema": { + "type": "integer" + } + }, { "in": "path", "name": "makerspace_id", @@ -11650,17 +11458,43 @@ "required": true }, { - "name": "page", - "required": false, "in": "query", - "description": "A page number within the paginated result set.", + "name": "start", "schema": { - "type": "integer" + "type": "string", + "format": "date" + } + }, + { + "in": "query", + "name": "status", + "schema": { + "type": "string", + "enum": [ + "canceled", + "paid_offline", + "paid_online", + "pending", + "waived" + ] + } + }, + { + "in": "query", + "name": "subject_type", + "schema": { + "type": "string", + "enum": [ + "booking", + "event_registration", + "machine_service_request", + "makerspace_membership" + ] } } ], "tags": [ - "Admin requests" + "Analytics" ], "security": [ { @@ -11672,7 +11506,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaginatedDirectLoanList" + "$ref": "#/components/schemas/AnalyticsReportResponse" } } }, @@ -11682,48 +11516,76 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ReportError" } } }, - "description": "Invalid request." + "description": "Invalid report request." }, - "403": { + "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ReportError" } } }, - "description": "Permission denied." + "description": "Authentication required." }, - "404": { + "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ReportError" } } }, - "description": "Not found." + "description": "Permission denied." }, - "409": { + "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ReportError" } } }, - "description": "Workflow conflict." + "description": "Makerspace or report not found." } } - }, - "post": { - "operationId": "api_v1_admin_makerspace_direct_loans_create", - "summary": "Issue direct handout without public request", + } + }, + "/api/v1/admin/makerspace/{makerspace_id}/analytics/event-attendance": { + "get": { + "operationId": "api_v1_admin_makerspace_analytics_event_attendance_retrieve", + "summary": "Get analytics report", "parameters": [ + { + "in": "query", + "name": "end", + "schema": { + "type": "string", + "format": "date" + } + }, + { + "in": "query", + "name": "grain", + "schema": { + "type": "string", + "enum": [ + "day", + "month" + ] + } + }, + { + "in": "query", + "name": "limit", + "schema": { + "type": "integer" + } + }, { "in": "path", "name": "makerspace_id", @@ -11731,42 +11593,57 @@ "type": "integer" }, "required": true + }, + { + "in": "query", + "name": "start", + "schema": { + "type": "string", + "format": "date" + } + }, + { + "in": "query", + "name": "status", + "schema": { + "type": "string", + "enum": [ + "canceled", + "paid_offline", + "paid_online", + "pending", + "waived" + ] + } + }, + { + "in": "query", + "name": "subject_type", + "schema": { + "type": "string", + "enum": [ + "booking", + "event_registration", + "machine_service_request", + "makerspace_membership" + ] + } } ], "tags": [ - "Admin requests" + "Analytics" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DirectLoanIssue" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/DirectLoanIssue" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/DirectLoanIssue" - } - } - }, - "required": true - }, "security": [ { "jwtAuth": [] } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DirectLoan" + "$ref": "#/components/schemas/AnalyticsReportResponse" } } }, @@ -11776,50 +11653,76 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ReportError" } } }, - "description": "Invalid request." + "description": "Invalid report request." }, - "403": { + "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ReportError" } } }, - "description": "Permission denied." + "description": "Authentication required." }, - "404": { + "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ReportError" } } }, - "description": "Not found." + "description": "Permission denied." }, - "409": { + "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ReportError" } } }, - "description": "Workflow conflict." + "description": "Makerspace or report not found." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/email-logs": { + "/api/v1/admin/makerspace/{makerspace_id}/analytics/fablab-health": { "get": { - "operationId": "api_v1_admin_makerspace_email_logs_list", - "summary": "List makerspace email delivery logs", + "operationId": "api_v1_admin_makerspace_analytics_fablab_health_retrieve", + "summary": "Get analytics report", "parameters": [ + { + "in": "query", + "name": "end", + "schema": { + "type": "string", + "format": "date" + } + }, + { + "in": "query", + "name": "grain", + "schema": { + "type": "string", + "enum": [ + "day", + "month" + ] + } + }, + { + "in": "query", + "name": "limit", + "schema": { + "type": "integer" + } + }, { "in": "path", "name": "makerspace_id", @@ -11829,24 +11732,43 @@ "required": true }, { - "name": "page", - "required": false, "in": "query", - "description": "A page number within the paginated result set.", + "name": "start", "schema": { - "type": "integer" + "type": "string", + "format": "date" } }, { "in": "query", "name": "status", "schema": { - "type": "string" + "type": "string", + "enum": [ + "canceled", + "paid_offline", + "paid_online", + "pending", + "waived" + ] + } + }, + { + "in": "query", + "name": "subject_type", + "schema": { + "type": "string", + "enum": [ + "booking", + "event_registration", + "machine_service_request", + "makerspace_membership" + ] } } ], "tags": [ - "Email logs" + "Analytics" ], "security": [ { @@ -11858,33 +11780,85 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaginatedEmailLogList" + "$ref": "#/components/schemas/AnalyticsReportResponse" } } }, "description": "" }, "400": { - "description": "Invalid status filter." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Invalid report request." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Authentication required." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Permission denied." }, "404": { - "description": "Makerspace not found." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Makerspace or report not found." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/email-logs/{id}/retry": { - "post": { - "operationId": "api_v1_admin_makerspace_email_logs_retry_create", - "summary": "Retry a failed makerspace email", + "/api/v1/admin/makerspace/{makerspace_id}/analytics/machine-usage": { + "get": { + "operationId": "api_v1_admin_makerspace_analytics_machine_usage_retrieve", + "summary": "Get analytics report", "parameters": [ { - "in": "path", - "name": "id", + "in": "query", + "name": "end", + "schema": { + "type": "string", + "format": "date" + } + }, + { + "in": "query", + "name": "grain", + "schema": { + "type": "string", + "enum": [ + "day", + "month" + ] + } + }, + { + "in": "query", + "name": "limit", "schema": { "type": "integer" - }, - "required": true + } }, { "in": "path", @@ -11893,10 +11867,45 @@ "type": "integer" }, "required": true + }, + { + "in": "query", + "name": "start", + "schema": { + "type": "string", + "format": "date" + } + }, + { + "in": "query", + "name": "status", + "schema": { + "type": "string", + "enum": [ + "canceled", + "paid_offline", + "paid_online", + "pending", + "waived" + ] + } + }, + { + "in": "query", + "name": "subject_type", + "schema": { + "type": "string", + "enum": [ + "booking", + "event_registration", + "machine_service_request", + "makerspace_membership" + ] + } } ], "tags": [ - "Email logs" + "Analytics" ], "security": [ { @@ -11908,78 +11917,85 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EmailLog" + "$ref": "#/components/schemas/AnalyticsReportResponse" } } }, "description": "" }, "400": { - "description": "Email log cannot be retried." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Invalid report request." }, - "404": { - "description": "Email log not found." - } - } - } - }, - "/api/v1/admin/makerspace/{makerspace_id}/email-templates": { - "get": { - "operationId": "api_v1_admin_makerspace_email_templates_list", - "parameters": [ - { - "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } }, - "required": true - } - ], - "tags": [ - "Email templates" - ], - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { + "description": "Authentication required." + }, + "403": { "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/EmailTemplateListItem" - } + "$ref": "#/components/schemas/ReportError" } } }, - "description": "" + "description": "Permission denied." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Makerspace or report not found." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/email-templates/{stream}/{audience}/{key}": { + "/api/v1/admin/makerspace/{makerspace_id}/analytics/maintenance-activity": { "get": { - "operationId": "api_v1_admin_makerspace_email_templates_retrieve", + "operationId": "api_v1_admin_makerspace_analytics_maintenance_activity_retrieve", + "summary": "Get analytics report", "parameters": [ { - "in": "path", - "name": "audience", + "in": "query", + "name": "end", "schema": { - "type": "string" - }, - "required": true + "type": "string", + "format": "date" + } }, { - "in": "path", - "name": "key", + "in": "query", + "name": "grain", "schema": { - "type": "string" - }, - "required": true + "type": "string", + "enum": [ + "day", + "month" + ] + } + }, + { + "in": "query", + "name": "limit", + "schema": { + "type": "integer" + } }, { "in": "path", @@ -11990,16 +12006,43 @@ "required": true }, { - "in": "path", - "name": "stream", + "in": "query", + "name": "start", "schema": { - "type": "string" - }, - "required": true + "type": "string", + "format": "date" + } + }, + { + "in": "query", + "name": "status", + "schema": { + "type": "string", + "enum": [ + "canceled", + "paid_offline", + "paid_online", + "pending", + "waived" + ] + } + }, + { + "in": "query", + "name": "subject_type", + "schema": { + "type": "string", + "enum": [ + "booking", + "event_registration", + "machine_service_request", + "makerspace_membership" + ] + } } ], "tags": [ - "Email templates" + "Analytics" ], "security": [ { @@ -12011,110 +12054,85 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EmailTemplateDetail" + "$ref": "#/components/schemas/AnalyticsReportResponse" } } }, "description": "" - } - } - }, - "patch": { - "operationId": "api_v1_admin_makerspace_email_templates_partial_update", - "parameters": [ - { - "in": "path", - "name": "audience", - "schema": { - "type": "string" - }, - "required": true - }, - { - "in": "path", - "name": "key", - "schema": { - "type": "string" - }, - "required": true }, - { - "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } }, - "required": true + "description": "Invalid report request." }, - { - "in": "path", - "name": "stream", - "schema": { - "type": "string" - }, - "required": true - } - ], - "tags": [ - "Email templates" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PatchedEmailTemplateUpdate" + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } } }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/PatchedEmailTemplateUpdate" + "description": "Authentication required." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } } }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/PatchedEmailTemplateUpdate" - } - } - } - }, - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { + "description": "Permission denied." + }, + "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EmailTemplateDetail" + "$ref": "#/components/schemas/ReportError" } } }, - "description": "" + "description": "Makerspace or report not found." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/email-templates/{stream}/{audience}/{key}/reset": { - "post": { - "operationId": "api_v1_admin_makerspace_email_templates_reset_create", + "/api/v1/admin/makerspace/{makerspace_id}/analytics/member-activity": { + "get": { + "operationId": "api_v1_admin_makerspace_analytics_member_activity_retrieve", + "summary": "Get analytics report", "parameters": [ { - "in": "path", - "name": "audience", + "in": "query", + "name": "end", "schema": { - "type": "string" - }, - "required": true + "type": "string", + "format": "date" + } }, { - "in": "path", - "name": "key", + "in": "query", + "name": "grain", "schema": { - "type": "string" - }, - "required": true + "type": "string", + "enum": [ + "day", + "month" + ] + } + }, + { + "in": "query", + "name": "limit", + "schema": { + "type": "integer" + } }, { "in": "path", @@ -12125,16 +12143,43 @@ "required": true }, { - "in": "path", - "name": "stream", + "in": "query", + "name": "start", "schema": { - "type": "string" - }, - "required": true + "type": "string", + "format": "date" + } + }, + { + "in": "query", + "name": "status", + "schema": { + "type": "string", + "enum": [ + "canceled", + "paid_offline", + "paid_online", + "pending", + "waived" + ] + } + }, + { + "in": "query", + "name": "subject_type", + "schema": { + "type": "string", + "enum": [ + "booking", + "event_registration", + "machine_service_request", + "makerspace_membership" + ] + } } ], "tags": [ - "Email templates" + "Analytics" ], "security": [ { @@ -12146,193 +12191,85 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EmailTemplateDetail" + "$ref": "#/components/schemas/AnalyticsReportResponse" } } }, "description": "" - } - } - } - }, - "/api/v1/admin/makerspace/{makerspace_id}/email-templates/{stream}/{audience}/{key}/types/{machine_type_id}": { - "get": { - "operationId": "api_v1_admin_makerspace_email_templates_types_retrieve", - "parameters": [ - { - "in": "path", - "name": "audience", - "schema": { - "type": "string" - }, - "required": true }, - { - "in": "path", - "name": "key", - "schema": { - "type": "string" + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } }, - "required": true + "description": "Invalid report request." }, - { - "in": "path", - "name": "machine_type_id", - "schema": { - "type": "integer" - }, - "required": true - }, - { - "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" - }, - "required": true - }, - { - "in": "path", - "name": "stream", - "schema": { - "type": "string" - }, - "required": true - } - ], - "tags": [ - "Email templates" - ], - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { + "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EmailTemplateDetail" + "$ref": "#/components/schemas/ReportError" } } }, - "description": "" - } - } - }, - "patch": { - "operationId": "api_v1_admin_makerspace_email_templates_types_partial_update", - "parameters": [ - { - "in": "path", - "name": "audience", - "schema": { - "type": "string" - }, - "required": true - }, - { - "in": "path", - "name": "key", - "schema": { - "type": "string" - }, - "required": true - }, - { - "in": "path", - "name": "machine_type_id", - "schema": { - "type": "integer" - }, - "required": true - }, - { - "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" - }, - "required": true + "description": "Authentication required." }, - { - "in": "path", - "name": "stream", - "schema": { - "type": "string" - }, - "required": true - } - ], - "tags": [ - "Email templates" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PatchedEmailTemplateUpdate" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/PatchedEmailTemplateUpdate" + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } } }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/PatchedEmailTemplateUpdate" - } - } - } - }, - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { + "description": "Permission denied." + }, + "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EmailTemplateDetail" + "$ref": "#/components/schemas/ReportError" } } }, - "description": "" + "description": "Makerspace or report not found." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/email-templates/{stream}/{audience}/{key}/types/{machine_type_id}/reset": { - "post": { - "operationId": "api_v1_admin_makerspace_email_templates_types_reset_create", + "/api/v1/admin/makerspace/{makerspace_id}/analytics/most-lent": { + "get": { + "operationId": "api_v1_admin_makerspace_analytics_most_lent_retrieve", + "summary": "Get analytics report", "parameters": [ { - "in": "path", - "name": "audience", + "in": "query", + "name": "end", "schema": { - "type": "string" - }, - "required": true + "type": "string", + "format": "date" + } }, { - "in": "path", - "name": "key", + "in": "query", + "name": "grain", "schema": { - "type": "string" - }, - "required": true + "type": "string", + "enum": [ + "day", + "month" + ] + } }, { - "in": "path", - "name": "machine_type_id", + "in": "query", + "name": "limit", "schema": { "type": "integer" - }, - "required": true + } }, { "in": "path", @@ -12343,16 +12280,43 @@ "required": true }, { - "in": "path", - "name": "stream", + "in": "query", + "name": "start", "schema": { - "type": "string" - }, - "required": true + "type": "string", + "format": "date" + } + }, + { + "in": "query", + "name": "status", + "schema": { + "type": "string", + "enum": [ + "canceled", + "paid_offline", + "paid_online", + "pending", + "waived" + ] + } + }, + { + "in": "query", + "name": "subject_type", + "schema": { + "type": "string", + "enum": [ + "booking", + "event_registration", + "machine_service_request", + "makerspace_membership" + ] + } } ], "tags": [ - "Email templates" + "Analytics" ], "security": [ { @@ -12364,117 +12328,86 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EmailTemplateDetail" + "$ref": "#/components/schemas/AnalyticsReportResponse" } } }, "description": "" - } - } - } - }, - "/api/v1/admin/makerspace/{makerspace_id}/email-templates/preview": { - "post": { - "operationId": "api_v1_admin_makerspace_email_templates_preview_create", - "parameters": [ - { - "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" - }, - "required": true - } - ], - "tags": [ - "Email templates" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/EmailTemplatePreviewRequest" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/EmailTemplatePreviewRequest" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/EmailTemplatePreviewRequest" - } - } }, - "required": true - }, - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { + "400": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EmailTemplatePreviewResponse" + "$ref": "#/components/schemas/ReportError" } } }, - "description": "" - } - } - } - }, - "/api/v1/admin/makerspace/{makerspace_id}/integration-health": { - "get": { - "operationId": "api_v1_admin_makerspace_integration_health_retrieve", - "summary": "Get makerspace integration health", - "parameters": [ - { - "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" - }, - "required": true - } - ], - "tags": [ - "Integration health" - ], - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { + "description": "Invalid report request." + }, + "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/IntegrationHealth" + "$ref": "#/components/schemas/ReportError" } } }, - "description": "" + "description": "Authentication required." }, "403": { - "description": "Not allowed to manage this makerspace." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Permission denied." }, "404": { - "description": "Makerspace not found." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Makerspace or report not found." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/inventory": { + "/api/v1/admin/makerspace/{makerspace_id}/analytics/payment-reconciliation": { "get": { - "operationId": "api_v1_admin_makerspace_inventory_list", - "summary": "List or create inventory products", + "operationId": "api_v1_admin_makerspace_analytics_payment_reconciliation_retrieve", + "summary": "Get analytics report", "parameters": [ + { + "in": "query", + "name": "end", + "schema": { + "type": "string", + "format": "date" + } + }, + { + "in": "query", + "name": "grain", + "schema": { + "type": "string", + "enum": [ + "day", + "month" + ] + } + }, + { + "in": "query", + "name": "limit", + "schema": { + "type": "integer" + } + }, { "in": "path", "name": "makerspace_id", @@ -12484,26 +12417,43 @@ "required": true }, { - "name": "page", - "required": false, "in": "query", - "description": "A page number within the paginated result set.", + "name": "start", "schema": { - "type": "integer" + "type": "string", + "format": "date" } }, { - "name": "page_size", - "required": false, "in": "query", - "description": "Number of results to return per page.", + "name": "status", "schema": { - "type": "integer" + "type": "string", + "enum": [ + "canceled", + "paid_offline", + "paid_online", + "pending", + "waived" + ] + } + }, + { + "in": "query", + "name": "subject_type", + "schema": { + "type": "string", + "enum": [ + "booking", + "event_registration", + "machine_service_request", + "makerspace_membership" + ] } } ], "tags": [ - "Admin inventory" + "Analytics" ], "security": [ { @@ -12515,104 +12465,84 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaginatedInventoryProductAdminList" + "$ref": "#/components/schemas/AnalyticsReportResponse" } } }, "description": "" - } - } - }, - "post": { - "operationId": "api_v1_admin_makerspace_inventory_create", - "summary": "List or create inventory products", - "parameters": [ - { - "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" - }, - "required": true - } - ], - "tags": [ - "Admin inventory" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/InventoryProductAdminCreate" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } } }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/InventoryProductAdminCreate" + "description": "Invalid report request." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } } }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/InventoryProductAdminCreate" + "description": "Authentication required." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } } - } + }, + "description": "Permission denied." }, - "required": true - }, - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "201": { + "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/InventoryProductAdminCreate" + "$ref": "#/components/schemas/ReportError" } } }, - "description": "" + "description": "Makerspace or report not found." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/inventory/export": { + "/api/v1/admin/makerspace/{makerspace_id}/analytics/qr-scans": { "get": { - "operationId": "api_v1_admin_makerspace_inventory_export_retrieve", - "summary": "Export inventory products as CSV or XLSX", + "operationId": "api_v1_admin_makerspace_analytics_qr_scans_retrieve", + "summary": "Get analytics report", "parameters": [ { "in": "query", - "name": "archived", + "name": "end", "schema": { - "type": "boolean" + "type": "string", + "format": "date" } }, { "in": "query", - "name": "format", + "name": "grain", "schema": { "type": "string", "enum": [ - "csv", - "xlsx" + "day", + "month" ] } }, { "in": "query", - "name": "ids", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "low_stock", + "name": "limit", "schema": { - "type": "boolean" + "type": "integer" } }, { @@ -12625,14 +12555,42 @@ }, { "in": "query", - "name": "q", + "name": "start", "schema": { - "type": "string" + "type": "string", + "format": "date" + } + }, + { + "in": "query", + "name": "status", + "schema": { + "type": "string", + "enum": [ + "canceled", + "paid_offline", + "paid_online", + "pending", + "waived" + ] + } + }, + { + "in": "query", + "name": "subject_type", + "schema": { + "type": "string", + "enum": [ + "booking", + "event_registration", + "machine_service_request", + "makerspace_membership" + ] } } ], "tags": [ - "Admin inventory" + "Analytics" ], "security": [ { @@ -12642,170 +12600,87 @@ "responses": { "200": { "content": { - "text/csv": { - "schema": { - "type": "string" - } - }, - "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": { + "application/json": { "schema": { - "type": "string", - "format": "binary" + "$ref": "#/components/schemas/AnalyticsReportResponse" } } }, "description": "" }, "400": { - "description": "Invalid request." - }, - "401": { - "description": "Authentication credentials were not provided." - }, - "403": { - "description": "Permission denied." - }, - "404": { - "description": "Not found." - } - } - } - }, - "/api/v1/admin/makerspace/{makerspace_id}/inventory/import/apply": { - "post": { - "operationId": "api_v1_admin_makerspace_inventory_import_apply_create", - "summary": "Apply inventory bulk import", - "parameters": [ - { - "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" - }, - "required": true - } - ], - "tags": [ - "Bulk import" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BulkImportPreview" - }, - "examples": { - "PreviewInventoryRows": { - "value": { - "rows": [ - { - "name": "Soldering Iron", - "total_quantity": 10, - "available_quantity": 8, - "is_public": true - } - ], - "mapping": { - "name": "name", - "total_quantity": "total_quantity" - } - }, - "summary": "Preview inventory rows" + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" } } }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/BulkImportPreview" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/BulkImportPreview" - } - } - } - }, - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { - "description": "Import application result." - } - } - } - }, - "/api/v1/admin/makerspace/{makerspace_id}/inventory/import/jobs": { - "post": { - "operationId": "api_v1_admin_makerspace_inventory_import_jobs_create", - "summary": "Create an async inventory bulk import job", - "parameters": [ - { - "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" - }, - "required": true - } - ], - "tags": [ - "Bulk import" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BulkImportJobCreate" + "description": "Invalid report request." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } } }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/BulkImportJobCreate" + "description": "Authentication required." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } } }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/BulkImportJobCreate" - } - } + "description": "Permission denied." }, - "required": true - }, - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "201": { + "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BulkImportJob" + "$ref": "#/components/schemas/ReportError" } } }, - "description": "" + "description": "Makerspace or report not found." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/inventory/import/jobs/{job_id}": { + "/api/v1/admin/makerspace/{makerspace_id}/analytics/recently-added": { "get": { - "operationId": "api_v1_admin_makerspace_inventory_import_jobs_retrieve", - "summary": "Get async inventory bulk import job status", + "operationId": "api_v1_admin_makerspace_analytics_recently_added_retrieve", + "summary": "Get analytics report", "parameters": [ { - "in": "path", - "name": "job_id", + "in": "query", + "name": "end", + "schema": { + "type": "string", + "format": "date" + } + }, + { + "in": "query", + "name": "grain", + "schema": { + "type": "string", + "enum": [ + "day", + "month" + ] + } + }, + { + "in": "query", + "name": "limit", "schema": { "type": "integer" - }, - "required": true + } }, { "in": "path", @@ -12814,10 +12689,45 @@ "type": "integer" }, "required": true + }, + { + "in": "query", + "name": "start", + "schema": { + "type": "string", + "format": "date" + } + }, + { + "in": "query", + "name": "status", + "schema": { + "type": "string", + "enum": [ + "canceled", + "paid_offline", + "paid_online", + "pending", + "waived" + ] + } + }, + { + "in": "query", + "name": "subject_type", + "schema": { + "type": "string", + "enum": [ + "booking", + "event_registration", + "machine_service_request", + "makerspace_membership" + ] + } } ], "tags": [ - "Bulk import" + "Analytics" ], "security": [ { @@ -12829,161 +12739,132 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BulkImportJob" + "$ref": "#/components/schemas/AnalyticsReportResponse" } } }, "description": "" - } - } - } - }, - "/api/v1/admin/makerspace/{makerspace_id}/inventory/import/preview": { - "post": { - "operationId": "api_v1_admin_makerspace_inventory_import_preview_create", - "summary": "Preview inventory bulk import", - "parameters": [ - { - "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } }, - "required": true - } - ], - "tags": [ - "Bulk import" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BulkImportPreview" - }, - "examples": { - "PreviewInventoryRows": { - "value": { - "rows": [ - { - "name": "Soldering Iron", - "total_quantity": 10, - "available_quantity": 8, - "is_public": true - } - ], - "mapping": { - "name": "name", - "total_quantity": "total_quantity" - } - }, - "summary": "Preview inventory rows" + "description": "Invalid report request." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" } } }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/BulkImportPreview" + "description": "Authentication required." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } } }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/BulkImportPreview" + "description": "Permission denied." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } } - } - } - }, - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { - "description": "Import preview with row errors." + }, + "description": "Makerspace or report not found." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/ledger": { + "/api/v1/admin/makerspace/{makerspace_id}/analytics/returns": { "get": { - "operationId": "api_v1_admin_makerspace_ledger_retrieve", - "summary": "List outstanding inventory loans", + "operationId": "api_v1_admin_makerspace_analytics_returns_retrieve", + "summary": "Get analytics report", "parameters": [ { - "in": "path", - "name": "makerspace_id", + "in": "query", + "name": "end", "schema": { - "type": "integer" - }, - "required": true + "type": "string", + "format": "date" + } }, { "in": "query", - "name": "overdue", + "name": "grain", "schema": { - "type": "boolean" + "type": "string", + "enum": [ + "day", + "month" + ] } }, { "in": "query", - "name": "page", + "name": "limit", "schema": { "type": "integer" } }, { - "in": "query", - "name": "page_size", + "in": "path", + "name": "makerspace_id", "schema": { "type": "integer" - } + }, + "required": true }, { "in": "query", - "name": "search", + "name": "start", "schema": { - "type": "string" + "type": "string", + "format": "date" } }, { "in": "query", - "name": "sort", + "name": "status", "schema": { "type": "string", "enum": [ - "-due", - "-holder", - "-item_name", - "-makerspace_id", - "-quantity", - "-since", - "-source", - "due", - "holder", - "item_name", - "makerspace_id", - "quantity", - "since", - "source" + "canceled", + "paid_offline", + "paid_online", + "pending", + "waived" ] } }, { "in": "query", - "name": "source", + "name": "subject_type", "schema": { "type": "string", "enum": [ - "direct", - "reviewed", - "self_checkout" + "booking", + "event_registration", + "machine_service_request", + "makerspace_membership" ] } } ], "tags": [ - "Ledger" + "Analytics" ], "security": [ { @@ -12995,31 +12876,86 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/LedgerResponse" + "$ref": "#/components/schemas/AnalyticsReportResponse" } } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Invalid report request." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Authentication required." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Permission denied." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Makerspace or report not found." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/ledger/export": { + "/api/v1/admin/makerspace/{makerspace_id}/analytics/summary": { "get": { - "operationId": "api_v1_admin_makerspace_ledger_export_retrieve", - "summary": "Export outstanding inventory loans", + "operationId": "api_v1_admin_makerspace_analytics_summary_retrieve", + "summary": "Get analytics report", "parameters": [ { "in": "query", - "name": "format", + "name": "end", + "schema": { + "type": "string", + "format": "date" + } + }, + { + "in": "query", + "name": "grain", "schema": { "type": "string", "enum": [ - "csv", - "xlsx" + "day", + "month" ] } }, + { + "in": "query", + "name": "limit", + "schema": { + "type": "integer" + } + }, { "in": "path", "name": "makerspace_id", @@ -13030,56 +12966,42 @@ }, { "in": "query", - "name": "overdue", - "schema": { - "type": "boolean" - } - }, - { - "in": "query", - "name": "search", + "name": "start", "schema": { - "type": "string" + "type": "string", + "format": "date" } }, { "in": "query", - "name": "sort", + "name": "status", "schema": { "type": "string", "enum": [ - "-due", - "-holder", - "-item_name", - "-makerspace_id", - "-quantity", - "-since", - "-source", - "due", - "holder", - "item_name", - "makerspace_id", - "quantity", - "since", - "source" + "canceled", + "paid_offline", + "paid_online", + "pending", + "waived" ] } }, { "in": "query", - "name": "source", + "name": "subject_type", "schema": { "type": "string", "enum": [ - "direct", - "reviewed", - "self_checkout" + "booking", + "event_registration", + "machine_service_request", + "makerspace_membership" ] } } ], "tags": [ - "Ledger" + "Analytics" ], "security": [ { @@ -13089,88 +13011,88 @@ "responses": { "200": { "content": { - "text/csv": { - "schema": { - "type": "string" - } - }, - "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": { + "application/json": { "schema": { - "type": "string", - "format": "binary" + "$ref": "#/components/schemas/AnalyticsReportResponse" } } }, "description": "" - } - } - } - }, - "/api/v1/admin/makerspace/{makerspace_id}/logo": { - "post": { - "operationId": "api_v1_admin_makerspace_logo_create", - "summary": "Create a makerspace public image upload URL", - "parameters": [ - { - "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } }, - "required": true - } - ], - "tags": [ - "Admin makerspaces" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PublicImageUploadRequest" + "description": "Invalid report request." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } } }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/PublicImageUploadRequest" + "description": "Authentication required." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } } }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/PublicImageUploadRequest" - } - } + "description": "Permission denied." }, - "required": true - }, - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "201": { + "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicImageUploadResponse" + "$ref": "#/components/schemas/ReportError" } } }, - "description": "" - }, - "400": { - "description": "Invalid image upload request." - }, - "503": { - "description": "Public image storage is unavailable." + "description": "Makerspace or report not found." } } - }, - "put": { - "operationId": "api_v1_admin_makerspace_logo_update", - "summary": "Attach an uploaded public image to a makerspace", + } + }, + "/api/v1/admin/makerspace/{makerspace_id}/analytics/taken-items": { + "get": { + "operationId": "api_v1_admin_makerspace_analytics_taken_items_retrieve", + "summary": "Get analytics report", "parameters": [ + { + "in": "query", + "name": "end", + "schema": { + "type": "string", + "format": "date" + } + }, + { + "in": "query", + "name": "grain", + "schema": { + "type": "string", + "enum": [ + "day", + "month" + ] + } + }, + { + "in": "query", + "name": "limit", + "schema": { + "type": "integer" + } + }, { "in": "path", "name": "makerspace_id", @@ -13178,31 +13100,46 @@ "type": "integer" }, "required": true + }, + { + "in": "query", + "name": "start", + "schema": { + "type": "string", + "format": "date" + } + }, + { + "in": "query", + "name": "status", + "schema": { + "type": "string", + "enum": [ + "canceled", + "paid_offline", + "paid_online", + "pending", + "waived" + ] + } + }, + { + "in": "query", + "name": "subject_type", + "schema": { + "type": "string", + "enum": [ + "booking", + "event_registration", + "machine_service_request", + "makerspace_membership" + ] + } } ], "tags": [ - "Admin makerspaces" + "Analytics" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PublicImageAttachRequest" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/PublicImageAttachRequest" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/PublicImageAttachRequest" - } - } - }, - "required": true - }, "security": [ { "jwtAuth": [] @@ -13213,59 +13150,59 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Makerspace" + "$ref": "#/components/schemas/AnalyticsReportResponse" } } }, "description": "" }, "400": { - "description": "Invalid image object key or size." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Invalid report request." }, - "503": { - "description": "Public image storage is unavailable." - } - } - }, - "delete": { - "operationId": "api_v1_admin_makerspace_logo_destroy", - "summary": "Clear a makerspace public image", - "parameters": [ - { - "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } }, - "required": true - } - ], - "tags": [ - "Admin makerspaces" - ], - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { + "description": "Authentication required." + }, + "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Makerspace" + "$ref": "#/components/schemas/ReportError" } } }, - "description": "" + "description": "Permission denied." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Makerspace or report not found." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/machine-service-report": { + "/api/v1/admin/makerspace/{makerspace_id}/analytics/top-borrowers": { "get": { - "operationId": "api_v1_admin_makerspace_machine_service_report_retrieve", - "summary": "Retrieve makerspace machine-service report", + "operationId": "api_v1_admin_makerspace_analytics_top_borrowers_retrieve", + "summary": "Get analytics report", "parameters": [ { "in": "query", @@ -13277,9 +13214,20 @@ }, { "in": "query", - "name": "machine_type", + "name": "grain", "schema": { - "type": "string" + "type": "string", + "enum": [ + "day", + "month" + ] + } + }, + { + "in": "query", + "name": "limit", + "schema": { + "type": "integer" } }, { @@ -13297,10 +13245,37 @@ "type": "string", "format": "date" } + }, + { + "in": "query", + "name": "status", + "schema": { + "type": "string", + "enum": [ + "canceled", + "paid_offline", + "paid_online", + "pending", + "waived" + ] + } + }, + { + "in": "query", + "name": "subject_type", + "schema": { + "type": "string", + "enum": [ + "booking", + "event_registration", + "machine_service_request", + "makerspace_membership" + ] + } } ], "tags": [ - "Admin machine service" + "Analytics" ], "security": [ { @@ -13312,7 +13287,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MachineServiceReportResponse" + "$ref": "#/components/schemas/AnalyticsReportResponse" } } }, @@ -13322,28 +13297,49 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ReportError" } } }, - "description": "Invalid request." + "description": "Invalid report request." }, "401": { - "description": "Authentication credentials were not provided." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Authentication required." }, "403": { - "description": "Machine management permission required." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Permission denied." }, "404": { - "description": "Not found." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Makerspace or report not found." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/machine-type-pricing": { + "/api/v1/admin/makerspace/{makerspace_id}/api-client-scopes": { "get": { - "operationId": "api_v1_admin_makerspace_machine_type_pricing_retrieve", - "summary": "List makerspace machine-type pricing", + "operationId": "api_v1_admin_makerspace_api_client_scopes_retrieve", + "summary": "List API-client scope grant options", "parameters": [ { "in": "path", @@ -13355,7 +13351,7 @@ } ], "tags": [ - "Admin machines" + "API clients" ], "security": [ { @@ -13367,31 +13363,123 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MachineTypePricingList" + "$ref": "#/components/schemas/ApiClientScopeCatalogResponse" + } + } + }, + "description": "" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" } } }, "description": "" }, "403": { - "description": "Space-manager identity required." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/machine-type-pricing/{machine_type_id}": { - "put": { - "operationId": "api_v1_admin_makerspace_machine_type_pricing_update", - "summary": "Set makerspace machine-type pricing", + "/api/v1/admin/makerspace/{makerspace_id}/api-clients": { + "get": { + "operationId": "api_v1_admin_makerspace_api_clients_list", + "summary": "List or create makerspace API clients", "parameters": [ { "in": "path", - "name": "machine_type_id", + "name": "makerspace_id", "schema": { "type": "integer" }, "required": true }, + { + "name": "page", + "required": false, + "in": "query", + "description": "A page number within the paginated result set.", + "schema": { + "type": "integer" + } + } + ], + "tags": [ + "API clients" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaginatedApiClientList" + } + } + }, + "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": "" + } + } + }, + "post": { + "operationId": "api_v1_admin_makerspace_api_clients_create", + "summary": "List or create makerspace API clients", + "parameters": [ { "in": "path", "name": "makerspace_id", @@ -13402,23 +13490,23 @@ } ], "tags": [ - "Admin machines" + "API clients" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MachineTypePricingSet" + "$ref": "#/components/schemas/ApiClient" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/MachineTypePricingSet" + "$ref": "#/components/schemas/ApiClient" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/MachineTypePricingSet" + "$ref": "#/components/schemas/ApiClient" } } }, @@ -13430,32 +13518,53 @@ } ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MachineTypePricing" + "$ref": "#/components/schemas/ApiClientCreateResponse" } } }, "description": "" }, - "400": { - "description": "Invalid price." + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" }, "403": { - "description": "Space-manager identity required." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" }, "404": { - "description": "Machine type not found." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/machine-types": { + "/api/v1/admin/makerspace/{makerspace_id}/api-settings": { "get": { - "operationId": "api_v1_admin_makerspace_machine_types_list", - "summary": "List machine types for a makerspace", + "operationId": "api_v1_admin_makerspace_api_settings_retrieve", + "summary": "Retrieve or update API integration settings", "parameters": [ { "in": "path", @@ -13467,7 +13576,7 @@ } ], "tags": [ - "Admin machines" + "API clients" ], "security": [ { @@ -13479,10 +13588,7 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/MachineTypeAccess" - } + "$ref": "#/components/schemas/ApiIntegrationSettings" } } }, @@ -13490,9 +13596,9 @@ } } }, - "post": { - "operationId": "api_v1_admin_makerspace_machine_types_create", - "summary": "Create a custom machine type", + "patch": { + "operationId": "api_v1_admin_makerspace_api_settings_partial_update", + "summary": "Retrieve or update API integration settings", "parameters": [ { "in": "path", @@ -13504,27 +13610,26 @@ } ], "tags": [ - "Admin machines" + "API clients" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MachineTypeCreate" + "$ref": "#/components/schemas/PatchedApiIntegrationSettings" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/MachineTypeCreate" + "$ref": "#/components/schemas/PatchedApiIntegrationSettings" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/MachineTypeCreate" + "$ref": "#/components/schemas/PatchedApiIntegrationSettings" } } - }, - "required": true + } }, "security": [ { @@ -13532,35 +13637,24 @@ } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MachineType" + "$ref": "#/components/schemas/ApiIntegrationSettings" } } }, "description": "" - }, - "400": { - "description": "Invalid machine type." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/machine-types/{id}": { - "patch": { - "operationId": "api_v1_admin_makerspace_machine_types_partial_update", - "summary": "Update a custom machine type", + "/api/v1/admin/makerspace/{makerspace_id}/archive-recipients": { + "get": { + "operationId": "api_v1_admin_makerspace_archive_recipients_list", + "summary": "List archive recipients for a makerspace", "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" - }, - "required": true - }, { "in": "path", "name": "makerspace_id", @@ -13571,27 +13665,8 @@ } ], "tags": [ - "Admin machines" + "Backup recipients" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PatchedMachineTypeUpdate" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/PatchedMachineTypeUpdate" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/PatchedMachineTypeUpdate" - } - } - } - }, "security": [ { "jwtAuth": [] @@ -13602,120 +13677,51 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MachineType" + "type": "array", + "items": { + "$ref": "#/components/schemas/ArchiveRecipient" + } } } }, "description": "" }, - "400": { - "description": "Invalid or built-in machine type." - }, - "403": { - "description": "Machine management permission required." - }, "404": { - "description": "Machine type not found." - } - } - } - }, - "/api/v1/admin/makerspace/{makerspace_id}/machines": { - "get": { - "operationId": "api_v1_admin_makerspace_machines_retrieve", - "summary": "List machines 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": { - "$ref": "#/components/schemas/MachineListResponse" + "$ref": "#/components/schemas/ArchiveRecipientError" } } }, - "description": "" - } - } - }, - "post": { - "operationId": "api_v1_admin_makerspace_machines_create", - "summary": "Create a machine", - "parameters": [ - { - "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" - }, - "required": true - } - ], - "tags": [ - "Admin machines" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Machine" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/Machine" + "description": "The recipient does not exist in this makerspace." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArchiveRecipientError" + } } }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/Machine" - } - } + "description": "Authentication is required." }, - "required": true - }, - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "201": { + "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Machine" + "$ref": "#/components/schemas/ArchiveRecipientError" } } }, - "description": "" - }, - "400": { - "description": "Invalid machine details." + "description": "Manage-makerspace permission is required." } } - } - }, - "/api/v1/admin/makerspace/{makerspace_id}/membership-invitations": { + }, "post": { - "operationId": "api_v1_admin_makerspace_membership_invitations_create", + "operationId": "api_v1_admin_makerspace_archive_recipients_create", + "description": "The decrypted challenge is a 32-byte nonce exchanged as canonical, unpadded base64url. Only the SHA-256 digest of the raw 32 nonce bytes is persisted.", + "summary": "Enroll an archive recipient and issue a custody challenge", "parameters": [ { "in": "path", @@ -13727,23 +13733,23 @@ } ], "tags": [ - "Admin memberships" + "Backup recipients" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Invitation" + "$ref": "#/components/schemas/ArchiveRecipientCreate" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/Invitation" + "$ref": "#/components/schemas/ArchiveRecipientCreate" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/Invitation" + "$ref": "#/components/schemas/ArchiveRecipientCreate" } } }, @@ -13759,7 +13765,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MembershipRequest" + "$ref": "#/components/schemas/ArchiveRecipientChallenge" } } }, @@ -13769,60 +13775,78 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ArchiveRecipientError" } } }, - "description": "" + "description": "The recipient or lifecycle request is invalid." }, - "401": { + "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ArchiveRecipientError" } } }, - "description": "" + "description": "The recipient does not exist in this makerspace." }, - "403": { + "409": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ArchiveRecipientError" } } }, - "description": "" + "description": "The recipient or lifecycle request is invalid." }, - "404": { + "503": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ArchiveRecipientError" } } }, - "description": "" + "description": "The age challenge could not be encrypted." }, - "409": { + "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ArchiveRecipientError" } } }, - "description": "" + "description": "Authentication is required." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArchiveRecipientError" + } + } + }, + "description": "Manage-makerspace permission is required." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/modules": { - "get": { - "operationId": "api_v1_admin_makerspace_modules_retrieve", - "summary": "List module groups and their install state for a makerspace", + "/api/v1/admin/makerspace/{makerspace_id}/archive-recipients/{id}/compromise": { + "post": { + "operationId": "api_v1_admin_makerspace_archive_recipients_compromise_create", + "summary": "Mark an archive recipient as compromised", "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + }, { "in": "path", "name": "makerspace_id", @@ -13833,7 +13857,7 @@ } ], "tags": [ - "Platform" + "Backup recipients" ], "security": [ { @@ -13842,16 +13866,71 @@ ], "responses": { "200": { - "description": "Grouped module status." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArchiveRecipient" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArchiveRecipientError" + } + } + }, + "description": "The recipient or lifecycle request is invalid." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArchiveRecipientError" + } + } + }, + "description": "The recipient does not exist in this makerspace." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArchiveRecipientError" + } + } + }, + "description": "Authentication is required." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArchiveRecipientError" + } + } + }, + "description": "Manage-makerspace permission is required." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/modules/install": { + "/api/v1/admin/makerspace/{makerspace_id}/archive-recipients/{id}/reactivate": { "post": { - "operationId": "api_v1_admin_makerspace_modules_install_create", - "summary": "Install a module and everything it requires", + "operationId": "api_v1_admin_makerspace_archive_recipients_reactivate_create", + "summary": "Reactivate a revoked archive recipient", "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + }, { "in": "path", "name": "makerspace_id", @@ -13862,28 +13941,8 @@ } ], "tags": [ - "Platform" + "Backup recipients" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ModuleAction" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/ModuleAction" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/ModuleAction" - } - } - }, - "required": true - }, "security": [ { "jwtAuth": [] @@ -13891,20 +13950,72 @@ ], "responses": { "200": { - "description": "Keys newly installed." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArchiveRecipient" + } + } + }, + "description": "" }, "400": { - "description": "Unknown module, or not shipped by this deployment." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArchiveRecipientError" + } + } + }, + "description": "The recipient or lifecycle request is invalid." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArchiveRecipientError" + } + } + }, + "description": "The recipient does not exist in this makerspace." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArchiveRecipientError" + } + } + }, + "description": "Authentication is required." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArchiveRecipientError" + } + } + }, + "description": "Manage-makerspace permission is required." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/modules/uninstall": { + "/api/v1/admin/makerspace/{makerspace_id}/archive-recipients/{id}/reissue-challenge": { "post": { - "operationId": "api_v1_admin_makerspace_modules_uninstall_create", - "description": "Clears the capability key only. Rows, uploads and history are retained and reinstalling restores every surface. Destroying the data is a separate, irreversible step (`purge_module_data`) that is deliberately CLI-only.", - "summary": "Uninstall a module, keeping its data", + "operationId": "api_v1_admin_makerspace_archive_recipients_reissue_challenge_create", + "description": "The decrypted 32-byte nonce is exchanged as canonical, unpadded base64url. The prior challenge stops verifying when this request commits.", + "summary": "Reissue an archive-recipient custody challenge", "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + }, { "in": "path", "name": "makerspace_id", @@ -13915,28 +14026,8 @@ } ], "tags": [ - "Platform" + "Backup recipients" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ModuleAction" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/ModuleAction" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/ModuleAction" - } - } - }, - "required": true - }, "security": [ { "jwtAuth": [] @@ -13944,19 +14035,81 @@ ], "responses": { "200": { - "description": "Keys uninstalled." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArchiveRecipientChallenge" + } + } + }, + "description": "" }, "400": { - "description": "Core module, or required by an installed module." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArchiveRecipientError" + } + } + }, + "description": "The recipient or lifecycle request is invalid." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArchiveRecipientError" + } + } + }, + "description": "The recipient does not exist in this makerspace." + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArchiveRecipientError" + } + } + }, + "description": "The age challenge could not be encrypted." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArchiveRecipientError" + } + } + }, + "description": "Authentication is required." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArchiveRecipientError" + } + } + }, + "description": "Manage-makerspace permission is required." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/notification-destinations": { - "get": { - "operationId": "api_v1_admin_makerspace_notification_destinations_list", - "summary": "List or create chat notification destinations", + "/api/v1/admin/makerspace/{makerspace_id}/archive-recipients/{id}/revoke": { + "post": { + "operationId": "api_v1_admin_makerspace_archive_recipients_revoke_create", + "summary": "Revoke an archive recipient", "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + }, { "in": "path", "name": "makerspace_id", @@ -13967,7 +14120,7 @@ } ], "tags": [ - "Makerspaces" + "Backup recipients" ], "security": [ { @@ -13979,21 +14132,69 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationDestination" - } + "$ref": "#/components/schemas/ArchiveRecipient" } } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArchiveRecipientError" + } + } + }, + "description": "The recipient or lifecycle request is invalid." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArchiveRecipientError" + } + } + }, + "description": "The recipient does not exist in this makerspace." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArchiveRecipientError" + } + } + }, + "description": "Authentication is required." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArchiveRecipientError" + } + } + }, + "description": "Manage-makerspace permission is required." } } - }, + } + }, + "/api/v1/admin/makerspace/{makerspace_id}/archive-recipients/{id}/verify": { "post": { - "operationId": "api_v1_admin_makerspace_notification_destinations_create", - "summary": "List or create chat notification destinations", + "operationId": "api_v1_admin_makerspace_archive_recipients_verify_create", + "description": "Submit the decrypted nonce as canonical unpadded base64url. It must decode to exactly 32 bytes; padding and non-canonical forms are refused.", + "summary": "Verify possession of an archive recipient", "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + }, { "in": "path", "name": "makerspace_id", @@ -14004,23 +14205,23 @@ } ], "tags": [ - "Makerspaces" + "Backup recipients" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/NotificationDestinationWrite" + "$ref": "#/components/schemas/ArchiveRecipientVerify" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/NotificationDestinationWrite" + "$ref": "#/components/schemas/ArchiveRecipientVerify" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/NotificationDestinationWrite" + "$ref": "#/components/schemas/ArchiveRecipientVerify" } } }, @@ -14032,35 +14233,141 @@ } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/NotificationDestination" + "$ref": "#/components/schemas/ArchiveRecipient" } } }, "description": "" }, "400": { - "description": "Invalid destination." - } - } - } - }, - "/api/v1/admin/makerspace/{makerspace_id}/notification-destinations/{destination_id}": { - "put": { - "operationId": "api_v1_admin_makerspace_notification_destinations_update", - "summary": "Update or delete a chat notification destination", - "parameters": [ - { - "in": "path", - "name": "destination_id", - "schema": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArchiveRecipientError" + } + } + }, + "description": "The recipient or lifecycle request is invalid." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArchiveRecipientError" + } + } + }, + "description": "The recipient or lifecycle request is invalid." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArchiveRecipientError" + } + } + }, + "description": "The recipient does not exist in this makerspace." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArchiveRecipientError" + } + } + }, + "description": "Verification attempts for this recipient are throttled." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArchiveRecipientError" + } + } + }, + "description": "Authentication is required." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArchiveRecipientError" + } + } + }, + "description": "Manage-makerspace permission is required." + } + } + } + }, + "/api/v1/admin/makerspace/{makerspace_id}/archive-requests": { + "get": { + "operationId": "api_v1_admin_makerspace_archive_requests_list", + "summary": "List archive requests for a makerspace", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { "type": "integer" }, "required": true + } + ], + "tags": [ + "Admin makerspaces" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MakerspaceArchiveRequest" + } + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArchiveRequestError" + } + } + }, + "description": "" }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArchiveRequestError" + } + } + }, + "description": "" + } + } + }, + "post": { + "operationId": "api_v1_admin_makerspace_archive_requests_create", + "summary": "Request makerspace archival", + "parameters": [ { "in": "path", "name": "makerspace_id", @@ -14071,23 +14378,23 @@ } ], "tags": [ - "Makerspaces" + "Admin makerspaces" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/NotificationDestinationWrite" + "$ref": "#/components/schemas/MakerspaceArchiveRequest" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/NotificationDestinationWrite" + "$ref": "#/components/schemas/MakerspaceArchiveRequest" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/NotificationDestinationWrite" + "$ref": "#/components/schemas/MakerspaceArchiveRequest" } } }, @@ -14099,28 +14406,67 @@ } ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/NotificationDestination" + "$ref": "#/components/schemas/MakerspaceArchiveRequest" } } }, "description": "" }, "400": { - "description": "Invalid destination." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArchiveRequestValidationError" + } + } + }, + "description": "The reason was blank or too long." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArchiveRequestError" + } + } + }, + "description": "" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArchiveRequestError" + } + } + }, + "description": "" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArchiveRequestError" + } + } + }, + "description": "" } } - }, - "delete": { - "operationId": "api_v1_admin_makerspace_notification_destinations_destroy", - "summary": "Update or delete a chat notification destination", + } + }, + "/api/v1/admin/makerspace/{makerspace_id}/archive-requests/{id}/withdraw": { + "post": { + "operationId": "api_v1_admin_makerspace_archive_requests_withdraw_create", + "summary": "Withdraw a pending makerspace archive request", "parameters": [ { "in": "path", - "name": "destination_id", + "name": "id", "schema": { "type": "integer" }, @@ -14136,7 +14482,7 @@ } ], "tags": [ - "Makerspaces" + "Admin makerspaces" ], "security": [ { @@ -14144,16 +14490,53 @@ } ], "responses": { - "204": { - "description": "Destination removed." + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MakerspaceArchiveRequest" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArchiveRequestError" + } + } + }, + "description": "" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArchiveRequestError" + } + } + }, + "description": "" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArchiveRequestError" + } + } + }, + "description": "" } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/notification-recipient-rules": { + "/api/v1/admin/makerspace/{makerspace_id}/backups": { "get": { - "operationId": "api_v1_admin_makerspace_notification_recipient_rules_retrieve", - "summary": "Read or replace notification recipients", + "operationId": "api_v1_admin_makerspace_backups_list", + "summary": "List makerspace backup archives", "parameters": [ { "in": "path", @@ -14165,7 +14548,7 @@ } ], "tags": [ - "Makerspaces" + "Backup" ], "security": [ { @@ -14177,17 +14560,29 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RecipientRulesResponse" + "type": "array", + "items": { + "$ref": "#/components/schemas/BackupArchive" + } } } }, "description": "" + }, + "404": { + "description": "The requested resource does not exist in the actor's scope." + }, + "401": { + "description": "Authentication is required." + }, + "403": { + "description": "The authenticated actor is not authorized." } } }, - "put": { - "operationId": "api_v1_admin_makerspace_notification_recipient_rules_update", - "summary": "Read or replace notification recipients", + "post": { + "operationId": "api_v1_admin_makerspace_backups_create", + "summary": "Request an age-encrypted makerspace backup", "parameters": [ { "in": "path", @@ -14199,58 +14594,43 @@ } ], "tags": [ - "Makerspaces" + "Backup" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RecipientRulesPut" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/RecipientRulesPut" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/RecipientRulesPut" - } - } - }, - "required": true - }, "security": [ { "jwtAuth": [] } ], "responses": { - "200": { + "202": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RecipientRulesResponse" + "$ref": "#/components/schemas/BackupArchive" } } }, "description": "" }, - "400": { - "description": "Invalid recipient rule." + "404": { + "description": "The requested resource does not exist in the actor's scope." + }, + "503": { + "description": "The backup worker is unavailable." + }, + "401": { + "description": "Authentication is required." }, "403": { - "description": "Recipient-rule permission required." + "description": "The authenticated actor is not authorized." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/notification-recipients": { + "/api/v1/admin/makerspace/{makerspace_id}/categories": { "get": { - "operationId": "api_v1_admin_makerspace_notification_recipients_list", - "description": "Space-manager control over which of the makerspace's managers receive the staff\nlifecycle emails. Toggling a manager off clears `receives_notifications` without\ntouching their access/role.", - "summary": "List or toggle staff email notification recipients", + "operationId": "api_v1_admin_makerspace_categories_list", + "summary": "List or create inventory categories", "parameters": [ { "in": "path", @@ -14259,10 +14639,19 @@ "type": "integer" }, "required": true + }, + { + "name": "page", + "required": false, + "in": "query", + "description": "A page number within the paginated result set.", + "schema": { + "type": "integer" + } } ], "tags": [ - "Makerspaces" + "Admin inventory" ], "security": [ { @@ -14274,10 +14663,7 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationRecipient" - } + "$ref": "#/components/schemas/PaginatedCategoryAdminList" } } }, @@ -14285,10 +14671,9 @@ } } }, - "patch": { - "operationId": "api_v1_admin_makerspace_notification_recipients_partial_update", - "description": "Space-manager control over which of the makerspace's managers receive the staff\nlifecycle emails. Toggling a manager off clears `receives_notifications` without\ntouching their access/role.", - "summary": "List or toggle staff email notification recipients", + "post": { + "operationId": "api_v1_admin_makerspace_categories_create", + "summary": "List or create inventory categories", "parameters": [ { "in": "path", @@ -14300,26 +14685,27 @@ } ], "tags": [ - "Makerspaces" + "Admin inventory" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PatchedNotificationRecipientsPatch" + "$ref": "#/components/schemas/CategoryAdmin" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/PatchedNotificationRecipientsPatch" + "$ref": "#/components/schemas/CategoryAdmin" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/PatchedNotificationRecipientsPatch" + "$ref": "#/components/schemas/CategoryAdmin" } } - } + }, + "required": true }, "security": [ { @@ -14327,14 +14713,11 @@ } ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationRecipient" - } + "$ref": "#/components/schemas/CategoryAdmin" } } }, @@ -14343,10 +14726,10 @@ } } }, - "/api/v1/admin/makerspace/{makerspace_id}/notification-rules": { + "/api/v1/admin/makerspace/{makerspace_id}/containers": { "get": { - "operationId": "api_v1_admin_makerspace_notification_rules_retrieve", - "summary": "List or update makerspace notification rules", + "operationId": "api_v1_admin_makerspace_containers_list", + "summary": "List containers", "parameters": [ { "in": "path", @@ -14355,10 +14738,28 @@ "type": "integer" }, "required": true + }, + { + "name": "page", + "required": false, + "in": "query", + "description": "A page number within the paginated result set.", + "schema": { + "type": "integer" + } + }, + { + "name": "page_size", + "required": false, + "in": "query", + "description": "Number of results to return per page.", + "schema": { + "type": "integer" + } } ], "tags": [ - "Makerspaces" + "Containers" ], "security": [ { @@ -14370,7 +14771,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/NotificationRulesResponse" + "$ref": "#/components/schemas/PaginatedBoxList" } } }, @@ -14378,9 +14779,9 @@ } } }, - "patch": { - "operationId": "api_v1_admin_makerspace_notification_rules_partial_update", - "summary": "List or update makerspace notification rules", + "post": { + "operationId": "api_v1_admin_makerspace_containers_create", + "summary": "Create container", "parameters": [ { "in": "path", @@ -14392,26 +14793,27 @@ } ], "tags": [ - "Makerspaces" + "Containers" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PatchedNotificationRulesPatch" + "$ref": "#/components/schemas/Box" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/PatchedNotificationRulesPatch" + "$ref": "#/components/schemas/Box" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/PatchedNotificationRulesPatch" + "$ref": "#/components/schemas/Box" } } - } + }, + "required": true }, "security": [ { @@ -14419,29 +14821,23 @@ } ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/NotificationRulesResponse" + "$ref": "#/components/schemas/Box" } } }, "description": "" - }, - "400": { - "description": "Invalid notification rule or preference change." - }, - "404": { - "description": "Makerspace not found." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/payment-settings": { - "get": { - "operationId": "api_v1_admin_makerspace_payment_settings_retrieve", - "summary": "Retrieve makerspace payment settings", + "/api/v1/admin/makerspace/{makerspace_id}/cover": { + "post": { + "operationId": "api_v1_admin_makerspace_cover_create", + "summary": "Create a makerspace public image upload URL", "parameters": [ { "in": "path", @@ -14453,49 +14849,55 @@ } ], "tags": [ - "Admin payment settings" + "Admin makerspaces" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicImageUploadRequest" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/PublicImageUploadRequest" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/PublicImageUploadRequest" + } + } + }, + "required": true + }, "security": [ { "jwtAuth": [] } ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MakerspacePaymentSettings" + "$ref": "#/components/schemas/PublicImageUploadResponse" } } }, "description": "" }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PaymentSettingsError" - } - } - }, - "description": "" + "400": { + "description": "Invalid image upload request." }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PaymentSettingsError" - } - } - }, - "description": "" + "503": { + "description": "Public image storage is unavailable." } } }, - "patch": { - "operationId": "api_v1_admin_makerspace_payment_settings_partial_update", - "summary": "Update makerspace payment settings", + "put": { + "operationId": "api_v1_admin_makerspace_cover_update", + "summary": "Attach an uploaded public image to a makerspace", "parameters": [ { "in": "path", @@ -14507,26 +14909,27 @@ } ], "tags": [ - "Admin payment settings" + "Admin makerspaces" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PatchedMakerspacePaymentSettings" + "$ref": "#/components/schemas/PublicImageAttachRequest" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/PatchedMakerspacePaymentSettings" + "$ref": "#/components/schemas/PublicImageAttachRequest" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/PatchedMakerspacePaymentSettings" + "$ref": "#/components/schemas/PublicImageAttachRequest" } } - } + }, + "required": true }, "security": [ { @@ -14538,42 +14941,23 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MakerspacePaymentSettings" + "$ref": "#/components/schemas/Makerspace" } } }, "description": "" }, "400": { - "description": "Invalid payment settings." - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PaymentSettingsError" - } - } - }, - "description": "" + "description": "Invalid image object key or size." }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PaymentSettingsError" - } - } - }, - "description": "" + "503": { + "description": "Public image storage is unavailable." } } - } - }, - "/api/v1/admin/makerspace/{makerspace_id}/payment-settings/connect/onboard": { - "post": { - "operationId": "api_v1_admin_makerspace_payment_settings_connect_onboard_create", - "summary": "Start Stripe Connect onboarding", + }, + "delete": { + "operationId": "api_v1_admin_makerspace_cover_destroy", + "summary": "Clear a makerspace public image", "parameters": [ { "in": "path", @@ -14585,7 +14969,7 @@ } ], "tags": [ - "Admin payment settings" + "Admin makerspaces" ], "security": [ { @@ -14597,49 +14981,61 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/StripeConnectOnboarding" + "$ref": "#/components/schemas/Makerspace" } } }, "description": "" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PaymentSettingsError" - } - } + } + } + } + }, + "/api/v1/admin/makerspace/{makerspace_id}/dashboard": { + "get": { + "operationId": "api_v1_admin_makerspace_dashboard_retrieve", + "summary": "Staff operations dashboard counts", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" }, - "description": "" - }, - "404": { + "required": true + } + ], + "tags": [ + "Dashboard" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaymentSettingsError" + "$ref": "#/components/schemas/Dashboard" } } }, "description": "" }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PaymentSettingsError" - } - } - }, - "description": "" + "403": { + "description": "Permission denied." + }, + "404": { + "description": "Makerspace not found." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/payments": { + "/api/v1/admin/makerspace/{makerspace_id}/data-exports": { "get": { - "operationId": "api_v1_admin_makerspace_payments_list", - "summary": "List makerspace payments for reconciliation", + "operationId": "api_v1_admin_makerspace_data_exports_list", + "summary": "List makerspace data-export jobs", "parameters": [ { "in": "path", @@ -14648,37 +15044,10 @@ "type": "integer" }, "required": true - }, - { - "in": "query", - "name": "status", - "schema": { - "type": "string", - "enum": [ - "canceled", - "paid_offline", - "paid_online", - "pending", - "waived" - ] - } - }, - { - "in": "query", - "name": "subject_type", - "schema": { - "type": "string", - "enum": [ - "booking", - "event_registration", - "machine_service_request", - "makerspace_membership" - ] - } } ], "tags": [ - "Payments" + "Data exports" ], "security": [ { @@ -14692,72 +15061,98 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/PaymentReconciliation" + "$ref": "#/components/schemas/DataExportJob" } } } }, "description": "" }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Invalid payment request." - }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } + "description": "MANAGE_MAKERSPACE is required." + } + } + }, + "post": { + "operationId": "api_v1_admin_makerspace_data_exports_create", + "summary": "Request a redacted makerspace data export", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Data exports" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DataExportCreate" } }, - "description": "Permission denied." - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/DataExportCreate" } }, - "description": "Payment or makerspace not found." - }, - "409": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/DataExportCreate" + } + } + } + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/DataExportJob" } } }, - "description": "Payment is already terminal." + "description": "" + }, + "400": { + "description": "Invalid fidelity or quota/module policy failure." + }, + "403": { + "description": "MANAGE_MAKERSPACE is required." + }, + "429": { + "description": "Export creation rate limit exceeded." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/payments/{payment_id}/mark-offline": { - "post": { - "operationId": "api_v1_admin_makerspace_payments_mark_offline_create", - "summary": "Mark a payment paid offline", + "/api/v1/admin/makerspace/{makerspace_id}/data-exports/{job_id}": { + "get": { + "operationId": "api_v1_admin_makerspace_data_exports_retrieve", + "summary": "Poll a makerspace data-export job", "parameters": [ { "in": "path", - "name": "makerspace_id", + "name": "job_id", "schema": { - "type": "integer" + "type": "string", + "format": "uuid" }, "required": true }, { "in": "path", - "name": "payment_id", + "name": "makerspace_id", "schema": { "type": "integer" }, @@ -14765,7 +15160,7 @@ } ], "tags": [ - "Payments" + "Data exports" ], "security": [ { @@ -14777,71 +15172,38 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaymentReconciliation" + "$ref": "#/components/schemas/DataExportJob" } } }, "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." + "description": "MANAGE_MAKERSPACE is required." }, "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." + "description": "Export job not found in this makerspace." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/payments/{payment_id}/waive": { + "/api/v1/admin/makerspace/{makerspace_id}/data-exports/{job_id}/download-url": { "post": { - "operationId": "api_v1_admin_makerspace_payments_waive_create", - "summary": "Waive a payment", + "operationId": "api_v1_admin_makerspace_data_exports_download_url_create", + "summary": "Issue a short-lived one-use export download URL", "parameters": [ { "in": "path", - "name": "makerspace_id", + "name": "job_id", "schema": { - "type": "integer" + "type": "string", + "format": "uuid" }, "required": true }, { "in": "path", - "name": "payment_id", + "name": "makerspace_id", "schema": { "type": "integer" }, @@ -14849,7 +15211,7 @@ } ], "tags": [ - "Payments" + "Data exports" ], "security": [ { @@ -14861,59 +15223,72 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaymentReconciliation" + "$ref": "#/components/schemas/DataExportDownloadUrl" } } }, "description": "" }, "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Invalid payment request." + "description": "Export is not available." }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Permission denied." + "description": "MANAGE_MAKERSPACE is required; PORTABLE additionally requires source superadmin." }, "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } + "description": "Export job not found in this makerspace." + } + } + } + }, + "/api/v1/admin/makerspace/{makerspace_id}/direct-loan-members": { + "get": { + "operationId": "api_v1_admin_makerspace_direct_loan_members_list", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" }, - "description": "Payment or makerspace not found." + "required": true }, - "409": { + { + "name": "page", + "required": false, + "in": "query", + "description": "A page number within the paginated result set.", + "schema": { + "type": "integer" + } + } + ], + "tags": [ + "api" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/PaginatedDirectLoanMemberList" } } }, - "description": "Payment is already terminal." + "description": "" } } } }, - "/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", + "/api/v1/admin/makerspace/{makerspace_id}/direct-loans": { + "get": { + "operationId": "api_v1_admin_makerspace_direct_loans_list", + "summary": "List direct handout loans", "parameters": [ { "in": "path", @@ -14922,31 +15297,20 @@ "type": "integer" }, "required": true + }, + { + "name": "page", + "required": false, + "in": "query", + "description": "A page number within the paginated result set.", + "schema": { + "type": "integer" + } } ], "tags": [ - "Payments" + "Admin requests" ], - "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": [] @@ -14957,10 +15321,7 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PaymentReconciliation" - } + "$ref": "#/components/schemas/PaginatedDirectLoanList" } } }, @@ -14974,7 +15335,7 @@ } } }, - "description": "Invalid payment request." + "description": "Invalid request." }, "403": { "content": { @@ -14994,7 +15355,7 @@ } } }, - "description": "Payment or makerspace not found." + "description": "Not found." }, "409": { "content": { @@ -15004,15 +15365,13 @@ } } }, - "description": "Payment is already terminal." + "description": "Workflow conflict." } } - } - }, - "/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", + "operationId": "api_v1_admin_makerspace_direct_loans_create", + "summary": "Issue direct handout without public request", "parameters": [ { "in": "path", @@ -15024,23 +15383,23 @@ } ], "tags": [ - "Payments" + "Admin requests" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaymentBulkAction" + "$ref": "#/components/schemas/DirectLoanIssue" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/PaymentBulkAction" + "$ref": "#/components/schemas/DirectLoanIssue" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/PaymentBulkAction" + "$ref": "#/components/schemas/DirectLoanIssue" } } }, @@ -15052,14 +15411,11 @@ } ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PaymentReconciliation" - } + "$ref": "#/components/schemas/DirectLoan" } } }, @@ -15073,7 +15429,7 @@ } } }, - "description": "Invalid payment request." + "description": "Invalid request." }, "403": { "content": { @@ -15093,7 +15449,7 @@ } } }, - "description": "Payment or makerspace not found." + "description": "Not found." }, "409": { "content": { @@ -15103,15 +15459,15 @@ } } }, - "description": "Payment is already terminal." + "description": "Workflow conflict." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/pending-requests": { + "/api/v1/admin/makerspace/{makerspace_id}/email-logs": { "get": { - "operationId": "api_v1_admin_makerspace_pending_requests_list", - "summary": "List pending borrow requests", + "operationId": "api_v1_admin_makerspace_email_logs_list", + "summary": "List makerspace email delivery logs", "parameters": [ { "in": "path", @@ -15131,17 +15487,15 @@ } }, { - "name": "search", - "required": false, "in": "query", - "description": "A search term (requested-for, requester name/email).", + "name": "status", "schema": { "type": "string" } } ], "tags": [ - "Admin requests" + "Email logs" ], "security": [ { @@ -15153,39 +15507,34 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaginatedAdminRequestList" + "$ref": "#/components/schemas/PaginatedEmailLogList" } } }, "description": "" }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Permission denied." + "400": { + "description": "Invalid status filter." }, "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Not found." + "description": "Makerspace not found." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/presence-sessions/current": { - "get": { - "operationId": "api_v1_admin_makerspace_presence_sessions_current_list", + "/api/v1/admin/makerspace/{makerspace_id}/email-logs/{id}/retry": { + "post": { + "operationId": "api_v1_admin_makerspace_email_logs_retry_create", + "summary": "Retry a failed makerspace email", "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + }, { "in": "path", "name": "makerspace_id", @@ -15196,7 +15545,7 @@ } ], "tags": [ - "Admin makerspaces" + "Email logs" ], "security": [ { @@ -15208,60 +15557,25 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PresenceRoster" - } + "$ref": "#/components/schemas/EmailLog" } } }, "description": "" }, "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Invalid input." - }, - "401": { - "description": "Authentication required." - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Membership permission required." + "description": "Email log cannot be retried." }, "404": { - "description": "Makerspace not found." - }, - "429": { - "description": "Rate limit exceeded." + "description": "Email log not found." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/problem-reports/{id}/resolve": { - "post": { - "operationId": "api_v1_admin_makerspace_problem_reports_resolve_create", - "summary": "Resolve a public problem report", + "/api/v1/admin/makerspace/{makerspace_id}/email-templates": { + "get": { + "operationId": "api_v1_admin_makerspace_email_templates_list", "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" - }, - "required": true - }, { "in": "path", "name": "makerspace_id", @@ -15272,7 +15586,7 @@ } ], "tags": [ - "Analytics" + "Email templates" ], "security": [ { @@ -15284,8 +15598,10 @@ "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": {} + "type": "array", + "items": { + "$ref": "#/components/schemas/EmailTemplateListItem" + } } } }, @@ -15294,16 +15610,23 @@ } } }, - "/api/v1/admin/makerspace/{makerspace_id}/problem-reports/{id}/triage": { - "post": { - "operationId": "api_v1_admin_makerspace_problem_reports_triage_create", - "summary": "Triage a public problem report", + "/api/v1/admin/makerspace/{makerspace_id}/email-templates/{stream}/{audience}/{key}": { + "get": { + "operationId": "api_v1_admin_makerspace_email_templates_retrieve", "parameters": [ { "in": "path", - "name": "id", + "name": "audience", "schema": { - "type": "integer" + "type": "string" + }, + "required": true + }, + { + "in": "path", + "name": "key", + "schema": { + "type": "string" }, "required": true }, @@ -15314,31 +15637,19 @@ "type": "integer" }, "required": true - } - ], + }, + { + "in": "path", + "name": "stream", + "schema": { + "type": "string" + }, + "required": true + } + ], "tags": [ - "Analytics" + "Email templates" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemReportTriage" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/ProblemReportTriage" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/ProblemReportTriage" - } - } - }, - "required": true - }, "security": [ { "jwtAuth": [] @@ -15349,20 +15660,33 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ProblemReportTriageResponse" + "$ref": "#/components/schemas/EmailTemplateDetail" } } }, "description": "" } } - } - }, - "/api/v1/admin/makerspace/{makerspace_id}/provision-subdomain": { - "post": { - "operationId": "api_v1_admin_makerspace_provision_subdomain_create", - "summary": "Provision a platform subdomain for a makerspace", + }, + "patch": { + "operationId": "api_v1_admin_makerspace_email_templates_partial_update", "parameters": [ + { + "in": "path", + "name": "audience", + "schema": { + "type": "string" + }, + "required": true + }, + { + "in": "path", + "name": "key", + "schema": { + "type": "string" + }, + "required": true + }, { "in": "path", "name": "makerspace_id", @@ -15370,30 +15694,37 @@ "type": "integer" }, "required": true + }, + { + "in": "path", + "name": "stream", + "schema": { + "type": "string" + }, + "required": true } ], "tags": [ - "Admin makerspaces" + "Email templates" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ProvisionSubdomainRequest" + "$ref": "#/components/schemas/PatchedEmailTemplateUpdate" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/ProvisionSubdomainRequest" + "$ref": "#/components/schemas/PatchedEmailTemplateUpdate" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/ProvisionSubdomainRequest" + "$ref": "#/components/schemas/PatchedEmailTemplateUpdate" } } - }, - "required": true + } }, "security": [ { @@ -15405,70 +15736,121 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Makerspace" + "$ref": "#/components/schemas/EmailTemplateDetail" } } }, "description": "" + } + } + } + }, + "/api/v1/admin/makerspace/{makerspace_id}/email-templates/{stream}/{audience}/{key}/reset": { + "post": { + "operationId": "api_v1_admin_makerspace_email_templates_reset_create", + "parameters": [ + { + "in": "path", + "name": "audience", + "schema": { + "type": "string" + }, + "required": true }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProvisionSubdomainValidationError" - } - } + { + "in": "path", + "name": "key", + "schema": { + "type": "string" }, - "description": "Invalid or unavailable platform subdomain label." + "required": true }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HostingError" - } - } + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" }, - "description": "Active superadmin access is required." + "required": true }, - "404": { + { + "in": "path", + "name": "stream", + "schema": { + "type": "string" + }, + "required": true + } + ], + "tags": [ + "Email templates" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HostingError" + "$ref": "#/components/schemas/EmailTemplateDetail" } } }, - "description": "Makerspace not found." + "description": "" } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/qr-print-batches": { + "/api/v1/admin/makerspace/{makerspace_id}/email-templates/{stream}/{audience}/{key}/types/{machine_type_id}": { "get": { - "operationId": "api_v1_admin_makerspace_qr_print_batches_list", - "summary": "List QR print batches", + "operationId": "api_v1_admin_makerspace_email_templates_types_retrieve", "parameters": [ { "in": "path", - "name": "makerspace_id", + "name": "audience", + "schema": { + "type": "string" + }, + "required": true + }, + { + "in": "path", + "name": "key", + "schema": { + "type": "string" + }, + "required": true + }, + { + "in": "path", + "name": "machine_type_id", "schema": { "type": "integer" }, "required": true }, { - "name": "page", - "required": false, - "in": "query", - "description": "A page number within the paginated result set.", + "in": "path", + "name": "makerspace_id", "schema": { "type": "integer" - } + }, + "required": true + }, + { + "in": "path", + "name": "stream", + "schema": { + "type": "string" + }, + "required": true } ], "tags": [ - "QR print batches" + "Email templates" ], "security": [ { @@ -15480,7 +15862,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaginatedQrPrintBatchList" + "$ref": "#/components/schemas/EmailTemplateDetail" } } }, @@ -15488,10 +15870,33 @@ } } }, - "post": { - "operationId": "api_v1_admin_makerspace_qr_print_batches_create", - "summary": "Create QR print batch", + "patch": { + "operationId": "api_v1_admin_makerspace_email_templates_types_partial_update", "parameters": [ + { + "in": "path", + "name": "audience", + "schema": { + "type": "string" + }, + "required": true + }, + { + "in": "path", + "name": "key", + "schema": { + "type": "string" + }, + "required": true + }, + { + "in": "path", + "name": "machine_type_id", + "schema": { + "type": "integer" + }, + "required": true + }, { "in": "path", "name": "makerspace_id", @@ -15499,30 +15904,37 @@ "type": "integer" }, "required": true + }, + { + "in": "path", + "name": "stream", + "schema": { + "type": "string" + }, + "required": true } ], "tags": [ - "QR print batches" + "Email templates" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/QrPrintBatchCreate" + "$ref": "#/components/schemas/PatchedEmailTemplateUpdate" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/QrPrintBatchCreate" + "$ref": "#/components/schemas/PatchedEmailTemplateUpdate" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/QrPrintBatchCreate" + "$ref": "#/components/schemas/PatchedEmailTemplateUpdate" } } - }, - "required": true + } }, "security": [ { @@ -15530,11 +15942,11 @@ } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/QrPrintBatch" + "$ref": "#/components/schemas/EmailTemplateDetail" } } }, @@ -15543,33 +15955,29 @@ } } }, - "/api/v1/admin/makerspace/{makerspace_id}/reports/{report_key}/export": { - "get": { - "operationId": "api_v1_admin_makerspace_reports_export_retrieve", - "summary": "Export report", + "/api/v1/admin/makerspace/{makerspace_id}/email-templates/{stream}/{audience}/{key}/types/{machine_type_id}/reset": { + "post": { + "operationId": "api_v1_admin_makerspace_email_templates_types_reset_create", "parameters": [ { - "in": "query", - "name": "end", + "in": "path", + "name": "audience", "schema": { - "type": "string", - "format": "date" - } + "type": "string" + }, + "required": true }, { - "in": "query", - "name": "format", + "in": "path", + "name": "key", "schema": { - "type": "string", - "enum": [ - "csv", - "xlsx" - ] - } + "type": "string" + }, + "required": true }, { "in": "path", - "name": "makerspace_id", + "name": "machine_type_id", "schema": { "type": "integer" }, @@ -15577,71 +15985,23 @@ }, { "in": "path", - "name": "report_key", + "name": "makerspace_id", "schema": { - "type": "string", - "enum": [ - "active-loans", - "booking-utilization", - "damaged-lost", - "damaged-missing", - "event-attendance", - "fablab-health", - "machine-service", - "machine-usage", - "maintenance-activity", - "member-activity", - "most-lent", - "payment-reconciliation", - "printer-service", - "qr-scans", - "recently-added", - "returns", - "summary", - "taken-items", - "top-borrowers" - ] + "type": "integer" }, "required": true }, { - "in": "query", - "name": "start", - "schema": { - "type": "string", - "format": "date" - } - }, - { - "in": "query", - "name": "status", - "schema": { - "type": "string", - "enum": [ - "canceled", - "paid_offline", - "paid_online", - "pending", - "waived" - ] - } - }, - { - "in": "query", - "name": "subject_type", + "in": "path", + "name": "stream", "schema": { - "type": "string", - "enum": [ - "booking", - "event_registration", - "machine_service_request", - "makerspace_membership" - ] - } + "type": "string" + }, + "required": true } ], "tags": [ - "Reports" + "Email templates" ], "security": [ { @@ -15651,67 +16011,76 @@ "responses": { "200": { "content": { - "text/csv": { - "schema": { - "type": "string" - } - }, - "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": { + "application/json": { "schema": { - "type": "string", - "format": "binary" + "$ref": "#/components/schemas/EmailTemplateDetail" } } }, "description": "" - }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } - } + } + } + } + }, + "/api/v1/admin/makerspace/{makerspace_id}/email-templates/preview": { + "post": { + "operationId": "api_v1_admin_makerspace_email_templates_preview_create", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" }, - "description": "Invalid report request." - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } + "required": true + } + ], + "tags": [ + "Email templates" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EmailTemplatePreviewRequest" } }, - "description": "Authentication required." - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/EmailTemplatePreviewRequest" } }, - "description": "Permission denied." + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/EmailTemplatePreviewRequest" + } + } }, - "404": { + "required": true + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/EmailTemplatePreviewResponse" } } }, - "description": "Makerspace or report not found." + "description": "" } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/request-history": { + "/api/v1/admin/makerspace/{makerspace_id}/integration-health": { "get": { - "operationId": "api_v1_admin_makerspace_request_history_list", - "summary": "List terminal request history (returned / rejected / closed with issue)", + "operationId": "api_v1_admin_makerspace_integration_health_retrieve", + "summary": "Get makerspace integration health", "parameters": [ { "in": "path", @@ -15720,28 +16089,10 @@ "type": "integer" }, "required": true - }, - { - "name": "page", - "required": false, - "in": "query", - "description": "A page number within the paginated result set.", - "schema": { - "type": "integer" - } - }, - { - "name": "search", - "required": false, - "in": "query", - "description": "A search term (requested-for, requester name/email).", - "schema": { - "type": "string" - } } ], "tags": [ - "Admin requests" + "Integration health" ], "security": [ { @@ -15753,39 +16104,25 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaginatedAdminRequestList" + "$ref": "#/components/schemas/IntegrationHealth" } } }, "description": "" }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Permission denied." + "description": "Not allowed to manage this makerspace." }, "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Not found." + "description": "Makerspace not found." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/return-policy": { + "/api/v1/admin/makerspace/{makerspace_id}/inventory": { "get": { - "operationId": "api_v1_admin_makerspace_return_policy_retrieve", - "summary": "Retrieve or update return policy", + "operationId": "api_v1_admin_makerspace_inventory_list", + "summary": "List or create inventory products", "parameters": [ { "in": "path", @@ -15794,10 +16131,28 @@ "type": "integer" }, "required": true + }, + { + "name": "page", + "required": false, + "in": "query", + "description": "A page number within the paginated result set.", + "schema": { + "type": "integer" + } + }, + { + "name": "page_size", + "required": false, + "in": "query", + "description": "Number of results to return per page.", + "schema": { + "type": "integer" + } } ], "tags": [ - "Admin makerspaces" + "Admin inventory" ], "security": [ { @@ -15809,7 +16164,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReturnPolicy" + "$ref": "#/components/schemas/PaginatedInventoryProductAdminList" } } }, @@ -15817,9 +16172,9 @@ } } }, - "patch": { - "operationId": "api_v1_admin_makerspace_return_policy_partial_update", - "summary": "Retrieve or update return policy", + "post": { + "operationId": "api_v1_admin_makerspace_inventory_create", + "summary": "List or create inventory products", "parameters": [ { "in": "path", @@ -15831,26 +16186,27 @@ } ], "tags": [ - "Admin makerspaces" + "Admin inventory" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PatchedReturnPolicy" + "$ref": "#/components/schemas/InventoryProductAdminCreate" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/PatchedReturnPolicy" + "$ref": "#/components/schemas/InventoryProductAdminCreate" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/PatchedReturnPolicy" + "$ref": "#/components/schemas/InventoryProductAdminCreate" } } - } + }, + "required": true }, "security": [ { @@ -15858,11 +16214,11 @@ } ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReturnPolicy" + "$ref": "#/components/schemas/InventoryProductAdminCreate" } } }, @@ -15871,11 +16227,43 @@ } } }, - "/api/v1/admin/makerspace/{makerspace_id}/stock-transfers": { + "/api/v1/admin/makerspace/{makerspace_id}/inventory/export": { "get": { - "operationId": "api_v1_admin_makerspace_stock_transfers_list", - "summary": "List stock transfers", + "operationId": "api_v1_admin_makerspace_inventory_export_retrieve", + "summary": "Export inventory products as CSV or XLSX", "parameters": [ + { + "in": "query", + "name": "archived", + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "format", + "schema": { + "type": "string", + "enum": [ + "csv", + "xlsx" + ] + } + }, + { + "in": "query", + "name": "ids", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "low_stock", + "schema": { + "type": "boolean" + } + }, { "in": "path", "name": "makerspace_id", @@ -15885,17 +16273,15 @@ "required": true }, { - "name": "page", - "required": false, "in": "query", - "description": "A page number within the paginated result set.", + "name": "q", "schema": { - "type": "integer" + "type": "string" } } ], "tags": [ - "Stock transfers" + "Admin inventory" ], "security": [ { @@ -15905,22 +16291,21 @@ "responses": { "200": { "content": { - "application/json": { + "text/csv": { "schema": { - "$ref": "#/components/schemas/PaginatedStockTransferList" + "type": "string" + } + }, + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": { + "schema": { + "type": "string", + "format": "binary" } } }, "description": "" }, "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/GenericObject" - } - } - }, "description": "Invalid request." }, "401": { @@ -15933,10 +16318,12 @@ "description": "Not found." } } - }, + } + }, + "/api/v1/admin/makerspace/{makerspace_id}/inventory/import/apply": { "post": { - "operationId": "api_v1_admin_makerspace_stock_transfers_create", - "summary": "Create stock transfer", + "operationId": "api_v1_admin_makerspace_inventory_import_apply_create", + "summary": "Apply inventory bulk import", "parameters": [ { "in": "path", @@ -15948,23 +16335,90 @@ } ], "tags": [ - "Stock transfers" + "Bulk import" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/StockTransferCreate" + "$ref": "#/components/schemas/BulkImportPreview" + }, + "examples": { + "PreviewInventoryRows": { + "value": { + "rows": [ + { + "name": "Soldering Iron", + "total_quantity": 10, + "available_quantity": 8, + "is_public": true + } + ], + "mapping": { + "name": "name", + "total_quantity": "total_quantity" + } + }, + "summary": "Preview inventory rows" + } } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/StockTransferCreate" + "$ref": "#/components/schemas/BulkImportPreview" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/StockTransferCreate" + "$ref": "#/components/schemas/BulkImportPreview" + } + } + } + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "description": "Import application result." + } + } + } + }, + "/api/v1/admin/makerspace/{makerspace_id}/inventory/import/jobs": { + "post": { + "operationId": "api_v1_admin_makerspace_inventory_import_jobs_create", + "summary": "Create an async inventory bulk import job", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Bulk import" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkImportJobCreate" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/BulkImportJobCreate" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/BulkImportJobCreate" } } }, @@ -15980,59 +16434,39 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/StockTransfer" + "$ref": "#/components/schemas/BulkImportJob" } } }, "description": "" - }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/GenericObject" - } - } - }, - "description": "Invalid request." - }, - "401": { - "description": "Authentication credentials were not provided." - }, - "403": { - "description": "Permission denied." - }, - "404": { - "description": "Not found." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/stocktakes": { + "/api/v1/admin/makerspace/{makerspace_id}/inventory/import/jobs/{job_id}": { "get": { - "operationId": "api_v1_admin_makerspace_stocktakes_list", - "summary": "List stocktakes", + "operationId": "api_v1_admin_makerspace_inventory_import_jobs_retrieve", + "summary": "Get async inventory bulk import job status", "parameters": [ { "in": "path", - "name": "makerspace_id", + "name": "job_id", "schema": { "type": "integer" }, "required": true }, { - "name": "page", - "required": false, - "in": "query", - "description": "A page number within the paginated result set.", + "in": "path", + "name": "makerspace_id", "schema": { "type": "integer" - } + }, + "required": true } ], "tags": [ - "Stocktake" + "Bulk import" ], "security": [ { @@ -16044,36 +16478,19 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaginatedStocktakeList" + "$ref": "#/components/schemas/BulkImportJob" } } }, "description": "" - }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/GenericObject" - } - } - }, - "description": "Invalid request or stocktake state." - }, - "401": { - "description": "Authentication credentials were not provided." - }, - "403": { - "description": "Permission denied." - }, - "404": { - "description": "Not found." } } - }, + } + }, + "/api/v1/admin/makerspace/{makerspace_id}/inventory/import/preview": { "post": { - "operationId": "api_v1_admin_makerspace_stocktakes_create", - "summary": "Create stocktake", + "operationId": "api_v1_admin_makerspace_inventory_import_preview_create", + "summary": "Preview inventory bulk import", "parameters": [ { "in": "path", @@ -16085,23 +16502,42 @@ } ], "tags": [ - "Stocktake" + "Bulk import" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/StocktakeCreate" + "$ref": "#/components/schemas/BulkImportPreview" + }, + "examples": { + "PreviewInventoryRows": { + "value": { + "rows": [ + { + "name": "Soldering Iron", + "total_quantity": 10, + "available_quantity": 8, + "is_public": true + } + ], + "mapping": { + "name": "name", + "total_quantity": "total_quantity" + } + }, + "summary": "Preview inventory rows" + } } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/StocktakeCreate" + "$ref": "#/components/schemas/BulkImportPreview" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/StocktakeCreate" + "$ref": "#/components/schemas/BulkImportPreview" } } } @@ -16112,42 +16548,16 @@ } ], "responses": { - "201": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Stocktake" - } - } - }, - "description": "" - }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/GenericObject" - } - } - }, - "description": "Invalid request or stocktake state." - }, - "401": { - "description": "Authentication credentials were not provided." - }, - "403": { - "description": "Permission denied." - }, - "404": { - "description": "Not found." + "200": { + "description": "Import preview with row errors." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/subdomain-request": { + "/api/v1/admin/makerspace/{makerspace_id}/ledger": { "get": { - "operationId": "api_v1_admin_makerspace_subdomain_request_list", - "summary": "List platform subdomain requests for a makerspace", + "operationId": "api_v1_admin_makerspace_ledger_retrieve", + "summary": "List outstanding inventory loans", "parameters": [ { "in": "path", @@ -16158,17 +16568,71 @@ "required": true }, { + "in": "query", + "name": "overdue", + "schema": { + "type": "boolean" + } + }, + { + "in": "query", "name": "page", - "required": false, + "schema": { + "type": "integer" + } + }, + { "in": "query", - "description": "A page number within the paginated result set.", + "name": "page_size", "schema": { "type": "integer" } + }, + { + "in": "query", + "name": "search", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "sort", + "schema": { + "type": "string", + "enum": [ + "-due", + "-holder", + "-item_name", + "-makerspace_id", + "-quantity", + "-since", + "-source", + "due", + "holder", + "item_name", + "makerspace_id", + "quantity", + "since", + "source" + ] + } + }, + { + "in": "query", + "name": "source", + "schema": { + "type": "string", + "enum": [ + "direct", + "reviewed", + "self_checkout" + ] + } } ], "tags": [ - "Admin hosting" + "Ledger" ], "security": [ { @@ -16180,37 +16644,121 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaginatedSubdomainRequestList" + "$ref": "#/components/schemas/LedgerResponse" } } }, "description": "" + } + } + } + }, + "/api/v1/admin/makerspace/{makerspace_id}/ledger/export": { + "get": { + "operationId": "api_v1_admin_makerspace_ledger_export_retrieve", + "summary": "Export outstanding inventory loans", + "parameters": [ + { + "in": "query", + "name": "format", + "schema": { + "type": "string", + "enum": [ + "csv", + "xlsx" + ] + } }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SubdomainRequestError" - } - } + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" }, - "description": "" + "required": true }, - "404": { + { + "in": "query", + "name": "overdue", + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "search", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "sort", + "schema": { + "type": "string", + "enum": [ + "-due", + "-holder", + "-item_name", + "-makerspace_id", + "-quantity", + "-since", + "-source", + "due", + "holder", + "item_name", + "makerspace_id", + "quantity", + "since", + "source" + ] + } + }, + { + "in": "query", + "name": "source", + "schema": { + "type": "string", + "enum": [ + "direct", + "reviewed", + "self_checkout" + ] + } + } + ], + "tags": [ + "Ledger" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { "content": { - "application/json": { + "text/csv": { "schema": { - "$ref": "#/components/schemas/SubdomainRequestError" + "type": "string" + } + }, + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": { + "schema": { + "type": "string", + "format": "binary" } } }, "description": "" } } - }, + } + }, + "/api/v1/admin/makerspace/{makerspace_id}/logo": { "post": { - "operationId": "api_v1_admin_makerspace_subdomain_request_create", - "summary": "Request a platform subdomain for a makerspace", + "operationId": "api_v1_admin_makerspace_logo_create", + "summary": "Create a makerspace public image upload URL", "parameters": [ { "in": "path", @@ -16222,23 +16770,23 @@ } ], "tags": [ - "Admin hosting" + "Admin makerspaces" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SubdomainRequest" + "$ref": "#/components/schemas/PublicImageUploadRequest" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/SubdomainRequest" + "$ref": "#/components/schemas/PublicImageUploadRequest" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/SubdomainRequest" + "$ref": "#/components/schemas/PublicImageUploadRequest" } } }, @@ -16254,49 +16802,23 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SubdomainRequest" + "$ref": "#/components/schemas/PublicImageUploadResponse" } } }, "description": "" }, "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SubdomainRequestError" - } - } - }, - "description": "" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SubdomainRequestError" - } - } - }, - "description": "" + "description": "Invalid image upload request." }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SubdomainRequestError" - } - } - }, - "description": "" + "503": { + "description": "Public image storage is unavailable." } } - } - }, - "/api/v1/admin/makerspace/{makerspace_id}/tenant-migration/disclosure-approvals": { - "get": { - "operationId": "api_v1_admin_makerspace_tenant_migration_disclosure_approvals_list", - "summary": "List disclosure approvals", + }, + "put": { + "operationId": "api_v1_admin_makerspace_logo_update", + "summary": "Attach an uploaded public image to a makerspace", "parameters": [ { "in": "path", @@ -16308,72 +16830,55 @@ } ], "tags": [ - "Tenant migration" - ], - "security": [ - { - "jwtAuth": [] - } + "Admin makerspaces" ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ClosureApproval" - } - } + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicImageAttachRequest" } }, - "description": "" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/PublicImageAttachRequest" } }, - "description": "Authentication required." - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/PublicImageAttachRequest" } - }, - "description": "Superadmin access required." + } }, - "429": { + "required": true + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/Makerspace" } } }, - "description": "Throttle limit exceeded." + "description": "" }, - "500": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } - } - }, - "description": "Unexpected migration failure." + "400": { + "description": "Invalid image object key or size." + }, + "503": { + "description": "Public image storage is unavailable." } } }, - "post": { - "operationId": "api_v1_admin_makerspace_tenant_migration_disclosure_approvals_create", - "summary": "Approve each identity in one exact disclosure closure", + "delete": { + "operationId": "api_v1_admin_makerspace_logo_destroy", + "summary": "Clear a makerspace public image", "parameters": [ { "in": "path", @@ -16385,120 +16890,46 @@ } ], "tags": [ - "Tenant migration" + "Admin makerspaces" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ClosureApprovalCreate" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/ClosureApprovalCreate" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/ClosureApprovalCreate" - } - } - }, - "required": true - }, "security": [ { "jwtAuth": [] } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ClosureApproval" + "$ref": "#/components/schemas/Makerspace" } } }, "description": "" - }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/FieldValidationError" - } - } - }, - "description": "Field-keyed validation errors." - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } - } - }, - "description": "State conflict." - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } - } - }, - "description": "Authentication required." - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } - } - }, - "description": "Superadmin access required." - }, - "429": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } - } - }, - "description": "Throttle limit exceeded." - }, - "500": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } - } - }, - "description": "Unexpected migration failure." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/tenant-migration/disclosure-approvals/{approval_id}/revoke": { - "post": { - "operationId": "api_v1_admin_makerspace_tenant_migration_disclosure_approvals_revoke_create", - "summary": "Revoke a disclosure approval", + "/api/v1/admin/makerspace/{makerspace_id}/machine-service-report": { + "get": { + "operationId": "api_v1_admin_makerspace_machine_service_report_retrieve", + "summary": "Retrieve makerspace machine-service report", "parameters": [ { - "in": "path", - "name": "approval_id", + "in": "query", + "name": "end", "schema": { "type": "string", - "format": "uuid" - }, - "required": true + "format": "date" + } + }, + { + "in": "query", + "name": "machine_type", + "schema": { + "type": "string" + } }, { "in": "path", @@ -16507,10 +16938,18 @@ "type": "integer" }, "required": true + }, + { + "in": "query", + "name": "start", + "schema": { + "type": "string", + "format": "date" + } } ], "tags": [ - "Tenant migration" + "Admin machine service" ], "security": [ { @@ -16522,69 +16961,38 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ClosureApproval" + "$ref": "#/components/schemas/MachineServiceReportResponse" } } }, "description": "" }, - "404": { + "400": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Not found." + "description": "Invalid request." }, "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } - } - }, - "description": "Authentication required." + "description": "Authentication credentials were not provided." }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } - } - }, - "description": "Superadmin access required." - }, - "429": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } - } - }, - "description": "Throttle limit exceeded." + "description": "Machine management permission required." }, - "500": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } - } - }, - "description": "Unexpected migration failure." + "404": { + "description": "Not found." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/tenant-migration/disclosure-closure": { + "/api/v1/admin/makerspace/{makerspace_id}/machine-type-pricing": { "get": { - "operationId": "api_v1_admin_makerspace_tenant_migration_disclosure_closure_retrieve", - "summary": "Compute the pending PORTABLE disclosure closure", + "operationId": "api_v1_admin_makerspace_machine_type_pricing_retrieve", + "summary": "List makerspace machine-type pricing", "parameters": [ { "in": "path", @@ -16596,7 +17004,7 @@ } ], "tags": [ - "Tenant migration" + "Admin machines" ], "security": [ { @@ -16608,59 +17016,95 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PendingClosure" + "$ref": "#/components/schemas/MachineTypePricingList" } } }, "description": "" }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } - } + "403": { + "description": "Space-manager identity required." + } + } + } + }, + "/api/v1/admin/makerspace/{makerspace_id}/machine-type-pricing/{machine_type_id}": { + "put": { + "operationId": "api_v1_admin_makerspace_machine_type_pricing_update", + "summary": "Set makerspace machine-type pricing", + "parameters": [ + { + "in": "path", + "name": "machine_type_id", + "schema": { + "type": "integer" }, - "description": "Authentication required." + "required": true }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Admin machines" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MachineTypePricingSet" } }, - "description": "Superadmin access required." - }, - "429": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/MachineTypePricingSet" } }, - "description": "Throttle limit exceeded." + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/MachineTypePricingSet" + } + } }, - "500": { + "required": true + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/MachineTypePricing" } } }, - "description": "Unexpected migration failure." + "description": "" + }, + "400": { + "description": "Invalid price." + }, + "403": { + "description": "Space-manager identity required." + }, + "404": { + "description": "Machine type not found." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/tenant-migration/exports": { + "/api/v1/admin/makerspace/{makerspace_id}/machine-types": { "get": { - "operationId": "api_v1_admin_makerspace_tenant_migration_exports_list", - "summary": "List PORTABLE migration export jobs", + "operationId": "api_v1_admin_makerspace_machine_types_list", + "summary": "List machine types for a makerspace", "parameters": [ { "in": "path", @@ -16672,7 +17116,7 @@ } ], "tags": [ - "Tenant migration" + "Admin machines" ], "security": [ { @@ -16686,58 +17130,18 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/MigrationExportJob" + "$ref": "#/components/schemas/MachineTypeAccess" } } } }, "description": "" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } - } - }, - "description": "Authentication required." - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } - } - }, - "description": "Superadmin access required." - }, - "429": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } - } - }, - "description": "Throttle limit exceeded." - }, - "500": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } - } - }, - "description": "Unexpected migration failure." } } }, "post": { - "operationId": "api_v1_admin_makerspace_tenant_migration_exports_create", - "summary": "Create a target-recipient-encrypted PORTABLE export", + "operationId": "api_v1_admin_makerspace_machine_types_create", + "summary": "Create a custom machine type", "parameters": [ { "in": "path", @@ -16749,23 +17153,23 @@ } ], "tags": [ - "Tenant migration" + "Admin machines" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MigrationExportCreate" + "$ref": "#/components/schemas/MachineTypeCreate" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/MigrationExportCreate" + "$ref": "#/components/schemas/MachineTypeCreate" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/MigrationExportCreate" + "$ref": "#/components/schemas/MachineTypeCreate" } } }, @@ -16777,93 +17181,32 @@ } ], "responses": { - "202": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MigrationExportJob" + "$ref": "#/components/schemas/MachineType" } } }, "description": "" }, "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/FieldValidationError" - } - } - }, - "description": "Field-keyed validation errors." - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } - } - }, - "description": "State conflict." - }, - "503": { - "description": "Migration worker unavailable." - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } - } - }, - "description": "Authentication required." - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } - } - }, - "description": "Superadmin access required." - }, - "429": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } - } - }, - "description": "Throttle limit exceeded." - }, - "500": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } - } - }, - "description": "Unexpected migration failure." + "description": "Invalid machine type." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/tenant-migration/exports/{job_id}": { - "get": { - "operationId": "api_v1_admin_makerspace_tenant_migration_exports_retrieve", - "summary": "Read a PORTABLE migration export job", + "/api/v1/admin/makerspace/{makerspace_id}/machine-types/{id}": { + "patch": { + "operationId": "api_v1_admin_makerspace_machine_types_partial_update", + "summary": "Update a custom machine type", "parameters": [ { "in": "path", - "name": "job_id", + "name": "id", "schema": { - "type": "string", - "format": "uuid" + "type": "integer" }, "required": true }, @@ -16877,8 +17220,27 @@ } ], "tags": [ - "Tenant migration" + "Admin machines" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PatchedMachineTypeUpdate" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/PatchedMachineTypeUpdate" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/PatchedMachineTypeUpdate" + } + } + } + }, "security": [ { "jwtAuth": [] @@ -16889,79 +17251,29 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MigrationExportJob" + "$ref": "#/components/schemas/MachineType" } } }, "description": "" }, + "400": { + "description": "Invalid or built-in machine type." + }, + "403": { + "description": "Machine management permission required." + }, "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } - } - }, - "description": "Not found." - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } - } - }, - "description": "Authentication required." - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } - } - }, - "description": "Superadmin access required." - }, - "429": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } - } - }, - "description": "Throttle limit exceeded." - }, - "500": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } - } - }, - "description": "Unexpected migration failure." + "description": "Machine type not found." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/tenant-migration/exports/{job_id}/download-url": { - "post": { - "operationId": "api_v1_admin_makerspace_tenant_migration_exports_download_url_create", - "summary": "Issue the one-use PORTABLE archive download URL", + "/api/v1/admin/makerspace/{makerspace_id}/machines": { + "get": { + "operationId": "api_v1_admin_makerspace_machines_retrieve", + "summary": "List machines in a makerspace", "parameters": [ - { - "in": "path", - "name": "job_id", - "schema": { - "type": "string", - "format": "uuid" - }, - "required": true - }, { "in": "path", "name": "makerspace_id", @@ -16972,7 +17284,7 @@ } ], "tags": [ - "Tenant migration" + "Admin machines" ], "security": [ { @@ -16984,89 +17296,76 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DataExportDownloadUrl" + "$ref": "#/components/schemas/MachineListResponse" } } }, "description": "" - }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } - } + } + } + }, + "post": { + "operationId": "api_v1_admin_makerspace_machines_create", + "summary": "Create a machine", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" }, - "description": "State conflict." - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } + "required": true + } + ], + "tags": [ + "Admin machines" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Machine" } }, - "description": "Not found." - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/Machine" } }, - "description": "Authentication required." - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/Machine" } - }, - "description": "Superadmin access required." + } }, - "429": { + "required": true + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/Machine" } } }, - "description": "Throttle limit exceeded." + "description": "" }, - "500": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } - } - }, - "description": "Unexpected migration failure." + "400": { + "description": "Invalid machine details." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/tenant-migration/exports/{job_id}/quiesce": { + "/api/v1/admin/makerspace/{makerspace_id}/membership-invitations": { "post": { - "operationId": "api_v1_admin_makerspace_tenant_migration_exports_quiesce_create", - "summary": "Reassert a completed export's source gate lease", + "operationId": "api_v1_admin_makerspace_membership_invitations_create", "parameters": [ - { - "in": "path", - "name": "job_id", - "schema": { - "type": "string", - "format": "uuid" - }, - "required": true - }, { "in": "path", "name": "makerspace_id", @@ -17077,91 +17376,101 @@ } ], "tags": [ - "Tenant migration" + "Admin memberships" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Invitation" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/Invitation" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/Invitation" + } + } + }, + "required": true + }, "security": [ { "jwtAuth": [] } ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CutoverOutcome" + "$ref": "#/components/schemas/MembershipRequest" } } }, "description": "" }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } - } - }, - "description": "Not found." - }, - "409": { + "400": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "State conflict." + "description": "" }, "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Authentication required." + "description": "" }, "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Superadmin access required." + "description": "" }, - "429": { + "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Throttle limit exceeded." + "description": "" }, - "500": { + "409": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Unexpected migration failure." + "description": "" } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/tenant-migration/pairings/{pairing_id}/archive-source": { - "post": { - "operationId": "api_v1_admin_makerspace_tenant_migration_pairings_archive_source_create", - "summary": "Archive the quiesced source and issue its signed cutover receipt", + "/api/v1/admin/makerspace/{makerspace_id}/modules": { + "get": { + "operationId": "api_v1_admin_makerspace_modules_retrieve", + "summary": "List module groups and their install state for a makerspace", "parameters": [ { "in": "path", @@ -17170,19 +17479,10 @@ "type": "integer" }, "required": true - }, - { - "in": "path", - "name": "pairing_id", - "schema": { - "type": "string", - "format": "uuid" - }, - "required": true } ], "tags": [ - "Tenant migration" + "Platform" ], "security": [ { @@ -17191,82 +17491,15 @@ ], "responses": { "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CutoverOutcome" - } - } - }, - "description": "" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } - } - }, - "description": "Not found." - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } - } - }, - "description": "State conflict." - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } - } - }, - "description": "Authentication required." - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } - } - }, - "description": "Superadmin access required." - }, - "429": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } - } - }, - "description": "Throttle limit exceeded." - }, - "500": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } - } - }, - "description": "Unexpected migration failure." + "description": "Grouped module status." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/tenant-migration/pairings/{pairing_id}/recover": { + "/api/v1/admin/makerspace/{makerspace_id}/modules/install": { "post": { - "operationId": "api_v1_admin_makerspace_tenant_migration_pairings_recover_create", - "summary": "Recover an archived source with the target abort receipt", + "operationId": "api_v1_admin_makerspace_modules_install_create", + "summary": "Install a module and everything it requires", "parameters": [ { "in": "path", @@ -17275,35 +17508,26 @@ "type": "integer" }, "required": true - }, - { - "in": "path", - "name": "pairing_id", - "schema": { - "type": "string", - "format": "uuid" - }, - "required": true } ], "tags": [ - "Tenant migration" + "Platform" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CutoverReceiptRequest" + "$ref": "#/components/schemas/ModuleAction" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/CutoverReceiptRequest" + "$ref": "#/components/schemas/ModuleAction" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/CutoverReceiptRequest" + "$ref": "#/components/schemas/ModuleAction" } } }, @@ -17316,92 +17540,71 @@ ], "responses": { "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CutoverOutcome" - } - } - }, - "description": "" + "description": "Keys newly installed." }, "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/FieldValidationError" - } - } - }, - "description": "Field-keyed validation errors." - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } - } + "description": "Unknown module, or not shipped by this deployment." + } + } + } + }, + "/api/v1/admin/makerspace/{makerspace_id}/modules/uninstall": { + "post": { + "operationId": "api_v1_admin_makerspace_modules_uninstall_create", + "description": "Clears the capability key only. Rows, uploads and history are retained and reinstalling restores every surface. Destroying the data is a separate, irreversible step (`purge_module_data`) that is deliberately CLI-only.", + "summary": "Uninstall a module, keeping its data", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" }, - "description": "Not found." - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } + "required": true + } + ], + "tags": [ + "Platform" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModuleAction" } }, - "description": "State conflict." - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/ModuleAction" } }, - "description": "Authentication required." - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/ModuleAction" } - }, - "description": "Superadmin access required." + } }, - "429": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } - } - }, - "description": "Throttle limit exceeded." + "required": true + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "description": "Keys uninstalled." }, - "500": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } - } - }, - "description": "Unexpected migration failure." + "400": { + "description": "Core module, or required by an installed module." } } } }, - "/api/v1/admin/makerspace/{makerspace_id}/verify-domain": { - "post": { - "operationId": "api_v1_admin_makerspace_verify_domain_create", - "summary": "Verify a makerspace custom domain", + "/api/v1/admin/makerspace/{makerspace_id}/notification-destinations": { + "get": { + "operationId": "api_v1_admin_makerspace_notification_destinations_list", + "summary": "List or create chat notification destinations", "parameters": [ { "in": "path", @@ -17413,7 +17616,7 @@ } ], "tags": [ - "Admin makerspaces" + "Makerspaces" ], "security": [ { @@ -17425,35 +17628,21 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DomainVerificationResponse" + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationDestination" + } } } }, "description": "" - }, - "403": { - "description": "Missing makerspace management permission." - }, - "404": { - "description": "Makerspace not found." } } - } - }, - "/api/v1/admin/makerspace/{makerspace_id}/warranties": { - "get": { - "operationId": "api_v1_admin_makerspace_warranties_list", - "summary": "List warranty coverage for a makerspace", + }, + "post": { + "operationId": "api_v1_admin_makerspace_notification_destinations_create", + "summary": "List or create chat notification destinations", "parameters": [ - { - "in": "query", - "name": "expires_before", - "schema": { - "type": "string", - "format": "date" - }, - "description": "Only include hosts with warranty expiry on or before this date." - }, { "in": "path", "name": "makerspace_id", @@ -17461,149 +17650,93 @@ "type": "integer" }, "required": true - }, - { - "in": "query", - "name": "missing_docs", - "schema": { - "type": "boolean" - }, - "description": "Only include hosts with no warranty documents or no warranty record." - }, - { - "name": "page", - "required": false, - "in": "query", - "description": "A page number within the paginated result set.", - "schema": { - "type": "integer" - } - }, - { - "name": "page_size", - "required": false, - "in": "query", - "description": "Number of results to return per page.", - "schema": { - "type": "integer" - } - }, - { - "in": "query", - "name": "status", - "schema": { - "type": "string", - "enum": [ - "active", - "expired", - "expiring_soon", - "unknown" - ] - }, - "description": "Filter rows by computed warranty status." } ], "tags": [ - "Admin warranty" - ], - "security": [ - { - "jwtAuth": [] - } + "Makerspaces" ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PaginatedWarrantyReportRowList" - } + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotificationDestinationWrite" } }, - "description": "" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/NotificationDestinationWrite" } }, - "description": "" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/NotificationDestinationWrite" } - }, - "description": "" + } }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" - } - } - } - }, - "/api/v1/admin/makerspaces": { - "get": { - "operationId": "api_v1_admin_makerspaces_list", - "summary": "List or create makerspaces", - "tags": [ - "Admin makerspaces" - ], + "required": true + }, "security": [ { "jwtAuth": [] } ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Makerspace" - } + "$ref": "#/components/schemas/NotificationDestination" } } }, "description": "" + }, + "400": { + "description": "Invalid destination." } } - }, - "post": { - "operationId": "api_v1_admin_makerspaces_create", - "summary": "List or create makerspaces", + } + }, + "/api/v1/admin/makerspace/{makerspace_id}/notification-destinations/{destination_id}": { + "put": { + "operationId": "api_v1_admin_makerspace_notification_destinations_update", + "summary": "Update or delete a chat notification destination", + "parameters": [ + { + "in": "path", + "name": "destination_id", + "schema": { + "type": "integer" + }, + "required": true + }, + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], "tags": [ - "Admin makerspaces" + "Makerspaces" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Makerspace" + "$ref": "#/components/schemas/NotificationDestinationWrite" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/Makerspace" + "$ref": "#/components/schemas/NotificationDestinationWrite" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/Makerspace" + "$ref": "#/components/schemas/NotificationDestinationWrite" } } }, @@ -17615,24 +17748,33 @@ } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Makerspace" + "$ref": "#/components/schemas/NotificationDestination" } } }, "description": "" + }, + "400": { + "description": "Invalid destination." } } - } - }, - "/api/v1/admin/makerspaces/{makerspace_id}/event-collaborations/": { - "get": { - "operationId": "api_v1_admin_makerspaces_event_collaborations_list", - "summary": "List collaboration invitations for a makerspace", + }, + "delete": { + "operationId": "api_v1_admin_makerspace_notification_destinations_destroy", + "summary": "Update or delete a chat notification destination", "parameters": [ + { + "in": "path", + "name": "destination_id", + "schema": { + "type": "integer" + }, + "required": true + }, { "in": "path", "name": "makerspace_id", @@ -17643,7 +17785,7 @@ } ], "tags": [ - "Admin events" + "Makerspaces" ], "security": [ { @@ -17651,56 +17793,16 @@ } ], "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/EventCollaborationInbox" - } - } - } - }, - "description": "" - }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Invalid collaboration request." - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Event management access denied." - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Event collaboration not found." + "204": { + "description": "Destination removed." } } } }, - "/api/v1/admin/makerspaces/{makerspace_id}/events/": { + "/api/v1/admin/makerspace/{makerspace_id}/notification-recipient-rules": { "get": { - "operationId": "api_v1_admin_makerspaces_events_retrieve", - "summary": "List events in a makerspace", + "operationId": "api_v1_admin_makerspace_notification_recipient_rules_retrieve", + "summary": "Read or replace notification recipients", "parameters": [ { "in": "path", @@ -17712,7 +17814,7 @@ } ], "tags": [ - "Admin events" + "Makerspaces" ], "security": [ { @@ -17724,7 +17826,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EventListResponse" + "$ref": "#/components/schemas/RecipientRulesResponse" } } }, @@ -17732,9 +17834,9 @@ } } }, - "post": { - "operationId": "api_v1_admin_makerspaces_events_create", - "summary": "Create a draft event", + "put": { + "operationId": "api_v1_admin_makerspace_notification_recipient_rules_update", + "summary": "Read or replace notification recipients", "parameters": [ { "in": "path", @@ -17746,23 +17848,23 @@ } ], "tags": [ - "Admin events" + "Makerspaces" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EventWrite" + "$ref": "#/components/schemas/RecipientRulesPut" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/EventWrite" + "$ref": "#/components/schemas/RecipientRulesPut" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/EventWrite" + "$ref": "#/components/schemas/RecipientRulesPut" } } }, @@ -17774,33 +17876,30 @@ } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EventAdmin" + "$ref": "#/components/schemas/RecipientRulesResponse" } } }, "description": "" }, "400": { - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": {} - } - } - }, - "description": "Invalid event details." + "description": "Invalid recipient rule." + }, + "403": { + "description": "Recipient-rule permission required." } } } }, - "/api/v1/admin/makerspaces/{makerspace_id}/machine-service/consumable-pools": { + "/api/v1/admin/makerspace/{makerspace_id}/notification-recipients": { "get": { - "operationId": "api_v1_admin_makerspaces_machine_service_consumable_pools_list", + "operationId": "api_v1_admin_makerspace_notification_recipients_list", + "description": "Space-manager control over which of the makerspace's managers receive the staff\nlifecycle emails. Toggling a manager off clears `receives_notifications` without\ntouching their access/role.", + "summary": "List or toggle staff email notification recipients", "parameters": [ { "in": "path", @@ -17812,7 +17911,7 @@ } ], "tags": [ - "Admin machine service" + "Makerspaces" ], "security": [ { @@ -17826,7 +17925,7 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/PrinterPool" + "$ref": "#/components/schemas/NotificationRecipient" } } } @@ -17835,8 +17934,10 @@ } } }, - "post": { - "operationId": "api_v1_admin_makerspaces_machine_service_consumable_pools_create", + "patch": { + "operationId": "api_v1_admin_makerspace_notification_recipients_partial_update", + "description": "Space-manager control over which of the makerspace's managers receive the staff\nlifecycle emails. Toggling a manager off clears `receives_notifications` without\ntouching their access/role.", + "summary": "List or toggle staff email notification recipients", "parameters": [ { "in": "path", @@ -17848,27 +17949,26 @@ } ], "tags": [ - "Admin machine service" + "Makerspaces" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PrinterPoolCreate" + "$ref": "#/components/schemas/PatchedNotificationRecipientsPatch" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/PrinterPoolCreate" + "$ref": "#/components/schemas/PatchedNotificationRecipientsPatch" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/PrinterPoolCreate" + "$ref": "#/components/schemas/PatchedNotificationRecipientsPatch" } } - }, - "required": true + } }, "security": [ { @@ -17876,11 +17976,14 @@ } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PrinterPool" + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationRecipient" + } } } }, @@ -17889,39 +17992,11 @@ } } }, - "/api/v1/admin/makerspaces/{makerspace_id}/machine-service/requests": { + "/api/v1/admin/makerspace/{makerspace_id}/notification-rules": { "get": { - "operationId": "api_v1_admin_makerspaces_machine_service_requests_list", - "summary": "List machine service requests", + "operationId": "api_v1_admin_makerspace_notification_rules_retrieve", + "summary": "List or update makerspace notification rules", "parameters": [ - { - "in": "query", - "name": "bucket", - "schema": { - "type": "integer" - } - }, - { - "in": "query", - "name": "machine", - "schema": { - "type": "integer" - } - }, - { - "in": "query", - "name": "machine_type", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "machine_type_id", - "schema": { - "type": "integer" - } - }, { "in": "path", "name": "makerspace_id", @@ -17929,24 +18004,10 @@ "type": "integer" }, "required": true - }, - { - "in": "query", - "name": "queue", - "schema": { - "type": "integer" - } - }, - { - "in": "query", - "name": "status", - "schema": { - "type": "string" - } } ], "tags": [ - "Admin machine service" + "Makerspaces" ], "security": [ { @@ -17958,49 +18019,17 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/MachineServiceRequest" - } + "$ref": "#/components/schemas/NotificationRulesResponse" } } }, "description": "" - }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Invalid service request input." - }, - "401": { - "description": "Authentication required." - }, - "403": { - "description": "Machine management permission required." - }, - "404": { - "description": "Service request was not found." - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Service workflow conflict." } } }, - "post": { - "operationId": "api_v1_admin_makerspaces_machine_service_requests_create", - "summary": "Submit a machine service request for a member", + "patch": { + "operationId": "api_v1_admin_makerspace_notification_rules_partial_update", + "summary": "List or update makerspace notification rules", "parameters": [ { "in": "path", @@ -18012,27 +18041,26 @@ } ], "tags": [ - "Admin machine service" + "Makerspaces" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MachineServiceSubmit" + "$ref": "#/components/schemas/PatchedNotificationRulesPatch" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/MachineServiceSubmit" + "$ref": "#/components/schemas/PatchedNotificationRulesPatch" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/MachineServiceSubmit" + "$ref": "#/components/schemas/PatchedNotificationRulesPatch" } } - }, - "required": true + } }, "security": [ { @@ -18040,66 +18068,30 @@ } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MachineServiceRequest" + "$ref": "#/components/schemas/NotificationRulesResponse" } } }, "description": "" }, "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Invalid service request input." - }, - "401": { - "description": "Authentication required." - }, - "403": { - "description": "Machine management permission required." + "description": "Invalid notification rule or preference change." }, "404": { - "description": "Service request was not found." - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Service workflow conflict." + "description": "Makerspace not found." } } } }, - "/api/v1/admin/makerspaces/{makerspace_id}/machine-service/typed-manual-usage": { + "/api/v1/admin/makerspace/{makerspace_id}/payment-settings": { "get": { - "operationId": "api_v1_admin_makerspaces_machine_service_typed_manual_usage_list", + "operationId": "api_v1_admin_makerspace_payment_settings_retrieve", + "summary": "Retrieve makerspace payment settings", "parameters": [ - { - "in": "query", - "name": "machine_type", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "machine_type_id", - "schema": { - "type": "integer" - } - }, { "in": "path", "name": "makerspace_id", @@ -18110,7 +18102,7 @@ } ], "tags": [ - "Admin machine service" + "Admin payment settings" ], "security": [ { @@ -18122,10 +18114,27 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/TypedManualUsageResponse" - } + "$ref": "#/components/schemas/MakerspacePaymentSettings" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaymentSettingsError" + } + } + }, + "description": "" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaymentSettingsError" } } }, @@ -18133,8 +18142,9 @@ } } }, - "post": { - "operationId": "api_v1_admin_makerspaces_machine_service_typed_manual_usage_create", + "patch": { + "operationId": "api_v1_admin_makerspace_payment_settings_partial_update", + "summary": "Update makerspace payment settings", "parameters": [ { "in": "path", @@ -18146,27 +18156,26 @@ } ], "tags": [ - "Admin machine service" + "Admin payment settings" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedManualUsage" + "$ref": "#/components/schemas/PatchedMakerspacePaymentSettings" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/TypedManualUsage" + "$ref": "#/components/schemas/PatchedMakerspacePaymentSettings" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/TypedManualUsage" + "$ref": "#/components/schemas/PatchedMakerspacePaymentSettings" } } - }, - "required": true + } }, "security": [ { @@ -18174,11 +18183,34 @@ } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedManualUsageResponse" + "$ref": "#/components/schemas/MakerspacePaymentSettings" + } + } + }, + "description": "" + }, + "400": { + "description": "Invalid payment settings." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaymentSettingsError" + } + } + }, + "description": "" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaymentSettingsError" } } }, @@ -18187,19 +18219,11 @@ } } }, - "/api/v1/admin/makerspaces/{makerspace_id}/machines/{machine_id}/maintenance/logs/": { - "get": { - "operationId": "api_v1_admin_makerspaces_machines_maintenance_logs_retrieve", - "summary": "List immutable machine maintenance logs", - "parameters": [ - { - "in": "path", - "name": "machine_id", - "schema": { - "type": "integer" - }, - "required": true - }, + "/api/v1/admin/makerspace/{makerspace_id}/payment-settings/connect/onboard": { + "post": { + "operationId": "api_v1_admin_makerspace_payment_settings_connect_onboard_create", + "summary": "Start Stripe Connect onboarding", + "parameters": [ { "in": "path", "name": "makerspace_id", @@ -18210,7 +18234,7 @@ } ], "tags": [ - "Admin maintenance" + "Admin payment settings" ], "security": [ { @@ -18222,99 +18246,103 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MaintenanceLogList" + "$ref": "#/components/schemas/StripeConnectOnboarding" } } }, "description": "" }, - "400": { + "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/PaymentSettingsError" } } }, - "description": "Invalid request." + "description": "" }, - "403": { + "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/PaymentSettingsError" } } }, - "description": "Permission denied." + "description": "" }, - "404": { + "503": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/PaymentSettingsError" } } }, - "description": "Not found." + "description": "" } } - }, - "post": { - "operationId": "api_v1_admin_makerspaces_machines_maintenance_logs_create", - "summary": "Record completed machine maintenance", + } + }, + "/api/v1/admin/makerspace/{makerspace_id}/payments": { + "get": { + "operationId": "api_v1_admin_makerspace_payments_list", + "summary": "List makerspace payments for reconciliation", "parameters": [ { "in": "path", - "name": "machine_id", + "name": "makerspace_id", "schema": { "type": "integer" }, "required": true }, { - "in": "path", - "name": "makerspace_id", + "in": "query", + "name": "status", "schema": { - "type": "integer" - }, - "required": true + "type": "string", + "enum": [ + "canceled", + "paid_offline", + "paid_online", + "pending", + "waived" + ] + } + }, + { + "in": "query", + "name": "subject_type", + "schema": { + "type": "string", + "enum": [ + "booking", + "event_registration", + "machine_service_request", + "makerspace_membership" + ] + } } ], "tags": [ - "Admin maintenance" + "Payments" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MaintenanceLogWrite" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/MaintenanceLogWrite" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/MaintenanceLogWrite" - } - } - }, - "required": true - }, "security": [ { "jwtAuth": [] } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MaintenanceLog" + "type": "array", + "items": { + "$ref": "#/components/schemas/PaymentReconciliation" + } } } }, @@ -18328,7 +18356,7 @@ } } }, - "description": "Invalid request." + "description": "Invalid payment request." }, "403": { "content": { @@ -18348,7 +18376,7 @@ } } }, - "description": "Not found." + "description": "Payment or makerspace not found." }, "409": { "content": { @@ -18358,19 +18386,19 @@ } } }, - "description": "Workflow conflict." + "description": "Payment is already terminal." } } } }, - "/api/v1/admin/makerspaces/{makerspace_id}/machines/{machine_id}/maintenance/schedules/": { - "get": { - "operationId": "api_v1_admin_makerspaces_machines_maintenance_schedules_retrieve", - "summary": "List machine maintenance schedules", + "/api/v1/admin/makerspace/{makerspace_id}/payments/{payment_id}/mark-offline": { + "post": { + "operationId": "api_v1_admin_makerspace_payments_mark_offline_create", + "summary": "Mark a payment paid offline", "parameters": [ { "in": "path", - "name": "machine_id", + "name": "makerspace_id", "schema": { "type": "integer" }, @@ -18378,7 +18406,7 @@ }, { "in": "path", - "name": "makerspace_id", + "name": "payment_id", "schema": { "type": "integer" }, @@ -18386,7 +18414,7 @@ } ], "tags": [ - "Admin maintenance" + "Payments" ], "security": [ { @@ -18398,7 +18426,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MaintenanceScheduleList" + "$ref": "#/components/schemas/PaymentReconciliation" } } }, @@ -18412,7 +18440,7 @@ } } }, - "description": "Invalid request." + "description": "Invalid payment request." }, "403": { "content": { @@ -18432,17 +18460,29 @@ } } }, - "description": "Not found." + "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/{payment_id}/waive": { "post": { - "operationId": "api_v1_admin_makerspaces_machines_maintenance_schedules_create", - "summary": "Create a machine maintenance schedule", + "operationId": "api_v1_admin_makerspace_payments_waive_create", + "summary": "Waive a payment", "parameters": [ { "in": "path", - "name": "machine_id", + "name": "makerspace_id", "schema": { "type": "integer" }, @@ -18450,7 +18490,7 @@ }, { "in": "path", - "name": "makerspace_id", + "name": "payment_id", "schema": { "type": "integer" }, @@ -18458,39 +18498,19 @@ } ], "tags": [ - "Admin maintenance" + "Payments" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MaintenanceScheduleWrite" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/MaintenanceScheduleWrite" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/MaintenanceScheduleWrite" - } - } - }, - "required": true - }, "security": [ { "jwtAuth": [] } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MaintenanceSchedule" + "$ref": "#/components/schemas/PaymentReconciliation" } } }, @@ -18504,7 +18524,7 @@ } } }, - "description": "Invalid request." + "description": "Invalid payment request." }, "403": { "content": { @@ -18524,7 +18544,7 @@ } } }, - "description": "Not found." + "description": "Payment or makerspace not found." }, "409": { "content": { @@ -18534,15 +18554,15 @@ } } }, - "description": "Workflow conflict." + "description": "Payment is already terminal." } } } }, - "/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", + "/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", @@ -18554,8 +18574,28 @@ } ], "tags": [ - "Admin memberships" + "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": [] @@ -18568,7 +18608,7 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/MemberClaimCode" + "$ref": "#/components/schemas/PaymentReconciliation" } } } @@ -18583,17 +18623,7 @@ } } }, - "description": "" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "description": "Invalid payment request." }, "403": { "content": { @@ -18603,7 +18633,7 @@ } } }, - "description": "" + "description": "Permission denied." }, "404": { "content": { @@ -18613,7 +18643,7 @@ } } }, - "description": "" + "description": "Payment or makerspace not found." }, "409": { "content": { @@ -18623,13 +18653,15 @@ } } }, - "description": "" + "description": "Payment is already terminal." } } - }, + } + }, + "/api/v1/admin/makerspace/{makerspace_id}/payments/bulk/waive": { "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_makerspace_payments_bulk_waive_create", + "summary": "Waive payments in one transaction", "parameters": [ { "in": "path", @@ -18641,23 +18673,23 @@ } ], "tags": [ - "Admin memberships" + "Payments" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MemberClaimCodeIssueRequest" + "$ref": "#/components/schemas/PaymentBulkAction" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/MemberClaimCodeIssueRequest" + "$ref": "#/components/schemas/PaymentBulkAction" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/MemberClaimCodeIssueRequest" + "$ref": "#/components/schemas/PaymentBulkAction" } } }, @@ -18669,11 +18701,14 @@ } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MemberClaimCodeIssueResponse" + "type": "array", + "items": { + "$ref": "#/components/schemas/PaymentReconciliation" + } } } }, @@ -18687,17 +18722,7 @@ } } }, - "description": "" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "description": "Invalid payment request." }, "403": { "content": { @@ -18707,7 +18732,7 @@ } } }, - "description": "" + "description": "Permission denied." }, "404": { "content": { @@ -18717,7 +18742,7 @@ } } }, - "description": "" + "description": "Payment or makerspace not found." }, "409": { "content": { @@ -18727,45 +18752,45 @@ } } }, - "description": "" - }, - "429": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Issue rate limit exceeded." + "description": "Payment is already terminal." } } } }, - "/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", + "/api/v1/admin/makerspace/{makerspace_id}/pending-requests": { + "get": { + "operationId": "api_v1_admin_makerspace_pending_requests_list", + "summary": "List pending borrow requests", "parameters": [ { "in": "path", - "name": "claim_id", + "name": "makerspace_id", "schema": { "type": "integer" }, "required": true }, { - "in": "path", - "name": "makerspace_id", + "name": "page", + "required": false, + "in": "query", + "description": "A page number within the paginated result set.", "schema": { "type": "integer" - }, - "required": true + } + }, + { + "name": "search", + "required": false, + "in": "query", + "description": "A search term (requested-for, requester name/email).", + "schema": { + "type": "string" + } } ], "tags": [ - "Admin memberships" + "Admin requests" ], "security": [ { @@ -18777,27 +18802,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MemberClaimCode" - } - } - }, - "description": "" - }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/PaginatedAdminRequestList" } } }, @@ -18811,7 +18816,7 @@ } } }, - "description": "" + "description": "Permission denied." }, "404": { "content": { @@ -18821,24 +18826,14 @@ } } }, - "description": "" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "description": "Not found." } } } }, - "/api/v1/admin/makerspaces/{makerspace_id}/memberships": { + "/api/v1/admin/makerspace/{makerspace_id}/presence-sessions/current": { "get": { - "operationId": "api_v1_admin_makerspaces_memberships_list", + "operationId": "api_v1_admin_makerspace_presence_sessions_current_list", "parameters": [ { "in": "path", @@ -18850,7 +18845,7 @@ } ], "tags": [ - "Admin memberships" + "Admin makerspaces" ], "security": [ { @@ -18864,7 +18859,7 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/MembershipList" + "$ref": "#/components/schemas/PresenceRoster" } } } @@ -18879,17 +18874,10 @@ } } }, - "description": "" + "description": "Invalid input." }, "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "description": "Authentication required." }, "403": { "content": { @@ -18899,33 +18887,75 @@ } } }, - "description": "" + "description": "Membership permission required." }, "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } + "description": "Makerspace not found." + }, + "429": { + "description": "Rate limit exceeded." + } + } + } + }, + "/api/v1/admin/makerspace/{makerspace_id}/problem-reports/{id}/resolve": { + "post": { + "operationId": "api_v1_admin_makerspace_problem_reports_resolve_create", + "summary": "Resolve a public problem report", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" }, - "description": "" + "required": true }, - "409": { + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Analytics" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "type": "object", + "additionalProperties": {} } } }, "description": "" } } - }, + } + }, + "/api/v1/admin/makerspace/{makerspace_id}/problem-reports/{id}/triage": { "post": { - "operationId": "api_v1_admin_makerspaces_memberships_create", + "operationId": "api_v1_admin_makerspace_problem_reports_triage_create", + "summary": "Triage a public problem report", "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + }, { "in": "path", "name": "makerspace_id", @@ -18936,23 +18966,23 @@ } ], "tags": [ - "Admin memberships" + "Analytics" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MembershipCreate" + "$ref": "#/components/schemas/ProblemReportTriage" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/MembershipCreate" + "$ref": "#/components/schemas/ProblemReportTriage" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/MembershipCreate" + "$ref": "#/components/schemas/ProblemReportTriage" } } }, @@ -18964,72 +18994,109 @@ } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MembershipList" + "$ref": "#/components/schemas/ProblemReportTriageResponse" } } }, "description": "" + } + } + } + }, + "/api/v1/admin/makerspace/{makerspace_id}/provision-subdomain": { + "post": { + "operationId": "api_v1_admin_makerspace_provision_subdomain_create", + "summary": "Provision a platform subdomain for a makerspace", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Admin makerspaces" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProvisionSubdomainRequest" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/ProvisionSubdomainRequest" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/ProvisionSubdomainRequest" + } + } }, - "400": { + "required": true + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/Makerspace" } } }, "description": "" }, - "401": { + "400": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ProvisionSubdomainValidationError" } } }, - "description": "" + "description": "Invalid or unavailable platform subdomain label." }, "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/HostingError" } } }, - "description": "" + "description": "Active superadmin access is required." }, "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/HostingError" } } }, - "description": "" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "description": "Makerspace not found." } } } }, - "/api/v1/admin/makerspaces/{makerspace_id}/memberships/{membership_id}/role": { - "patch": { - "operationId": "api_v1_admin_makerspaces_memberships_role_partial_update", + "/api/v1/admin/makerspace/{makerspace_id}/qr-print-batches": { + "get": { + "operationId": "api_v1_admin_makerspace_qr_print_batches_list", + "summary": "List QR print batches", "parameters": [ { "in": "path", @@ -19039,9 +19106,44 @@ }, "required": true }, + { + "name": "page", + "required": false, + "in": "query", + "description": "A page number within the paginated result set.", + "schema": { + "type": "integer" + } + } + ], + "tags": [ + "QR print batches" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaginatedQrPrintBatchList" + } + } + }, + "description": "" + } + } + }, + "post": { + "operationId": "api_v1_admin_makerspace_qr_print_batches_create", + "summary": "Create QR print batch", + "parameters": [ { "in": "path", - "name": "membership_id", + "name": "makerspace_id", "schema": { "type": "integer" }, @@ -19049,26 +19151,27 @@ } ], "tags": [ - "Admin memberships" + "QR print batches" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PatchedMembershipRoleAssign" + "$ref": "#/components/schemas/QrPrintBatchCreate" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/PatchedMembershipRoleAssign" + "$ref": "#/components/schemas/QrPrintBatchCreate" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/PatchedMembershipRoleAssign" + "$ref": "#/components/schemas/QrPrintBatchCreate" } } - } + }, + "required": true }, "security": [ { @@ -19076,61 +19179,11 @@ } ], "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MembershipList" - } - } - }, - "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": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/QrPrintBatch" } } }, @@ -19139,10 +19192,41 @@ } } }, - "/api/v1/admin/makerspaces/{makerspace_id}/roles": { + "/api/v1/admin/makerspace/{makerspace_id}/reports/{report_key}/export": { "get": { - "operationId": "api_v1_admin_makerspaces_roles_list", + "operationId": "api_v1_admin_makerspace_reports_export_retrieve", + "summary": "Export report", "parameters": [ + { + "in": "query", + "name": "end", + "schema": { + "type": "string", + "format": "date" + } + }, + { + "in": "query", + "name": "format", + "schema": { + "type": "string", + "enum": [ + "csv", + "xlsx" + ] + } + }, + { + "in": "query", + "name": "grain", + "schema": { + "type": "string", + "enum": [ + "day", + "month" + ] + } + }, { "in": "path", "name": "makerspace_id", @@ -19150,10 +19234,82 @@ "type": "integer" }, "required": true + }, + { + "in": "path", + "name": "report_key", + "schema": { + "type": "string", + "enum": [ + "active-loans", + "booking-utilization", + "communications-health", + "community-engagement", + "damaged-lost", + "damaged-missing", + "event-attendance", + "evidence-compliance", + "fablab-health", + "import-quality", + "inventory-control", + "loan-throughput", + "machine-service", + "machine-usage", + "maintenance-activity", + "member-activity", + "module-operational-health", + "most-lent", + "payment-reconciliation", + "printer-service", + "procurement-performance", + "qr-scans", + "recently-added", + "returns", + "summary", + "taken-items", + "top-borrowers" + ] + }, + "required": true + }, + { + "in": "query", + "name": "start", + "schema": { + "type": "string", + "format": "date" + } + }, + { + "in": "query", + "name": "status", + "schema": { + "type": "string", + "enum": [ + "canceled", + "paid_offline", + "paid_online", + "pending", + "waived" + ] + } + }, + { + "in": "query", + "name": "subject_type", + "schema": { + "type": "string", + "enum": [ + "booking", + "event_registration", + "machine_service_request", + "makerspace_membership" + ] + } } ], "tags": [ - "Admin roles" + "Reports" ], "security": [ { @@ -19163,12 +19319,15 @@ "responses": { "200": { "content": { - "application/json": { + "text/csv": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Role" - } + "type": "string" + } + }, + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": { + "schema": { + "type": "string", + "format": "binary" } } }, @@ -19178,56 +19337,49 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ReportError" } } }, - "description": "" + "description": "Invalid report request." }, "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ReportError" } } }, - "description": "" + "description": "Authentication required." }, "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ReportError" } } }, - "description": "" + "description": "Permission denied." }, "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ReportError" } } }, - "description": "" + "description": "Makerspace or report not found." } } - }, - "post": { - "operationId": "api_v1_admin_makerspaces_roles_create", + } + }, + "/api/v1/admin/makerspace/{makerspace_id}/reports/catalog": { + "get": { + "operationId": "api_v1_admin_makerspace_reports_catalog_retrieve", + "summary": "List makerspace report catalog", "parameters": [ { "in": "path", @@ -19239,39 +19391,19 @@ } ], "tags": [ - "Admin roles" + "Reports" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RoleCreate" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/RoleCreate" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/RoleCreate" - } - } - }, - "required": true - }, "security": [ { "jwtAuth": [] } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Role" + "$ref": "#/components/schemas/ReportCatalog" } } }, @@ -19281,58 +19413,49 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ReportError" } } }, - "description": "" + "description": "Invalid report request." }, "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ReportError" } } }, - "description": "" + "description": "Authentication required." }, "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ReportError" } } }, - "description": "" + "description": "Permission denied." }, "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ReportError" } } }, - "description": "" + "description": "Makerspace or report not found." } } } }, - "/api/v1/admin/makerspaces/{makerspace_id}/roles/{role_id}": { + "/api/v1/admin/makerspace/{makerspace_id}/request-history": { "get": { - "operationId": "api_v1_admin_makerspaces_roles_retrieve", + "operationId": "api_v1_admin_makerspace_request_history_list", + "summary": "List terminal request history (returned / rejected / closed with issue)", "parameters": [ { "in": "path", @@ -19343,16 +19466,26 @@ "required": true }, { - "in": "path", - "name": "role_id", + "name": "page", + "required": false, + "in": "query", + "description": "A page number within the paginated result set.", "schema": { "type": "integer" - }, - "required": true + } + }, + { + "name": "search", + "required": false, + "in": "query", + "description": "A search term (requested-for, requester name/email).", + "schema": { + "type": "string" + } } ], "tags": [ - "Admin roles" + "Admin requests" ], "security": [ { @@ -19364,27 +19497,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Role" - } - } - }, - "description": "" - }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/PaginatedAdminRequestList" } } }, @@ -19398,7 +19511,7 @@ } } }, - "description": "" + "description": "Permission denied." }, "404": { "content": { @@ -19408,13 +19521,39 @@ } } }, - "description": "" - }, - "409": { + "description": "Not found." + } + } + } + }, + "/api/v1/admin/makerspace/{makerspace_id}/return-policy": { + "get": { + "operationId": "api_v1_admin_makerspace_return_policy_retrieve", + "summary": "Retrieve or update return policy", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Admin makerspaces" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ReturnPolicy" } } }, @@ -19423,7 +19562,8 @@ } }, "patch": { - "operationId": "api_v1_admin_makerspaces_roles_partial_update", + "operationId": "api_v1_admin_makerspace_return_policy_partial_update", + "summary": "Retrieve or update return policy", "parameters": [ { "in": "path", @@ -19432,34 +19572,26 @@ "type": "integer" }, "required": true - }, - { - "in": "path", - "name": "role_id", - "schema": { - "type": "integer" - }, - "required": true } ], "tags": [ - "Admin roles" + "Admin makerspaces" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PatchedRoleWrite" + "$ref": "#/components/schemas/PatchedReturnPolicy" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/PatchedRoleWrite" + "$ref": "#/components/schemas/PatchedReturnPolicy" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/PatchedRoleWrite" + "$ref": "#/components/schemas/PatchedReturnPolicy" } } } @@ -19474,66 +19606,19 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Role" - } - } - }, - "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" + "$ref": "#/components/schemas/ReturnPolicy" } } }, "description": "" } } - }, - "delete": { - "operationId": "api_v1_admin_makerspaces_roles_destroy", + } + }, + "/api/v1/admin/makerspace/{makerspace_id}/stock-transfers": { + "get": { + "operationId": "api_v1_admin_makerspace_stock_transfers_list", + "summary": "List stock transfers", "parameters": [ { "in": "path", @@ -19544,16 +19629,17 @@ "required": true }, { - "in": "path", - "name": "role_id", + "name": "page", + "required": false, + "in": "query", + "description": "A page number within the paginated result set.", "schema": { "type": "integer" - }, - "required": true + } } ], "tags": [ - "Admin roles" + "Stock transfers" ], "security": [ { @@ -19561,66 +19647,40 @@ } ], "responses": { - "204": { - "description": "No response body" - }, - "400": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/PaginatedStockTransferList" } } }, "description": "" }, - "401": { + "400": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/GenericObject" } } }, - "description": "" + "description": "Invalid request." + }, + "401": { + "description": "Authentication credentials were not provided." }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "description": "Permission denied." }, "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "description": "Not found." } } - } - }, - "/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.", + }, + "post": { + "operationId": "api_v1_admin_makerspace_stock_transfers_create", + "summary": "Create stock transfer", "parameters": [ { "in": "path", @@ -19629,30 +19689,42 @@ "type": "integer" }, "required": true - }, - { - "in": "path", - "name": "role_id", - "schema": { - "type": "integer" - }, - "required": true } ], "tags": [ - "Admin roles" + "Stock transfers" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StockTransferCreate" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/StockTransferCreate" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/StockTransferCreate" + } + } + }, + "required": true + }, "security": [ { "jwtAuth": [] } ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RoleMachineScope" + "$ref": "#/components/schemas/StockTransfer" } } }, @@ -19662,57 +19734,28 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/GenericObject" } } }, - "description": "" + "description": "Invalid request." }, "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "description": "Authentication credentials were not provided." }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "description": "Permission denied." }, "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "description": "Not found." } } - }, - "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.", + } + }, + "/api/v1/admin/makerspace/{makerspace_id}/stocktakes": { + "get": { + "operationId": "api_v1_admin_makerspace_stocktakes_list", + "summary": "List stocktakes", "parameters": [ { "in": "path", @@ -19723,37 +19766,18 @@ "required": true }, { - "in": "path", - "name": "role_id", + "name": "page", + "required": false, + "in": "query", + "description": "A page number within the paginated result set.", "schema": { "type": "integer" - }, - "required": true + } } ], "tags": [ - "Admin roles" + "Stocktake" ], - "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": [] @@ -19764,7 +19788,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RoleMachineScope" + "$ref": "#/components/schemas/PaginatedStocktakeList" } } }, @@ -19774,58 +19798,26 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/GenericObject" } } }, - "description": "" + "description": "Invalid request or stocktake state." }, "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "description": "Authentication credentials were not provided." }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "description": "Permission denied." }, "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "description": "Not found." } } - } - }, - "/api/v1/admin/makerspaces/{makerspace_id}/roles/capabilities": { - "get": { - "operationId": "api_v1_admin_makerspaces_roles_capabilities_list", + }, + "post": { + "operationId": "api_v1_admin_makerspace_stocktakes_create", + "summary": "Create stocktake", "parameters": [ { "in": "path", @@ -19837,22 +19829,38 @@ } ], "tags": [ - "Admin roles" + "Stocktake" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StocktakeCreate" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/StocktakeCreate" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/StocktakeCreate" + } + } + } + }, "security": [ { "jwtAuth": [] } ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Capability" - } + "$ref": "#/components/schemas/Stocktake" } } }, @@ -19862,59 +19870,28 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/GenericObject" } } }, - "description": "" + "description": "Invalid request or stocktake state." }, "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "description": "Authentication credentials were not provided." }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "description": "Permission denied." }, "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "description": "Not found." } } } }, - "/api/v1/admin/makerspaces/{makerspace_id}/spaces/": { + "/api/v1/admin/makerspace/{makerspace_id}/subdomain-request": { "get": { - "operationId": "api_v1_admin_makerspaces_spaces_retrieve", - "summary": "List bookable spaces in a makerspace", + "operationId": "api_v1_admin_makerspace_subdomain_request_list", + "summary": "List platform subdomain requests for a makerspace", "parameters": [ { "in": "path", @@ -19923,10 +19900,19 @@ "type": "integer" }, "required": true + }, + { + "name": "page", + "required": false, + "in": "query", + "description": "A page number within the paginated result set.", + "schema": { + "type": "integer" + } } ], "tags": [ - "Admin bookings" + "Admin hosting" ], "security": [ { @@ -19938,7 +19924,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BookableSpaceListResponse" + "$ref": "#/components/schemas/PaginatedSubdomainRequestList" } } }, @@ -19948,27 +19934,27 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/SubdomainRequestError" } } }, - "description": "Permission denied." + "description": "" }, "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/SubdomainRequestError" } } }, - "description": "Not found." + "description": "" } } }, "post": { - "operationId": "api_v1_admin_makerspaces_spaces_create", - "summary": "Create a bookable space", + "operationId": "api_v1_admin_makerspace_subdomain_request_create", + "summary": "Request a platform subdomain for a makerspace", "parameters": [ { "in": "path", @@ -19980,23 +19966,23 @@ } ], "tags": [ - "Admin bookings" + "Admin hosting" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BookableSpaceWrite" + "$ref": "#/components/schemas/SubdomainRequest" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/BookableSpaceWrite" + "$ref": "#/components/schemas/SubdomainRequest" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/BookableSpaceWrite" + "$ref": "#/components/schemas/SubdomainRequest" } } }, @@ -20012,7 +19998,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BookableSpaceAdmin" + "$ref": "#/components/schemas/SubdomainRequest" } } }, @@ -20022,39 +20008,39 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/SubdomainRequestError" } } }, - "description": "Invalid request." + "description": "" }, "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/SubdomainRequestError" } } }, - "description": "Permission denied." + "description": "" }, "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/SubdomainRequestError" } } }, - "description": "Not found." + "description": "" } } } }, - "/api/v1/admin/makerspaces/{makerspace_id}/uploads/evidence-url": { - "post": { - "operationId": "api_v1_admin_makerspaces_uploads_evidence_url_create", - "description": "Base for ALL staff endpoints: authenticated + active staff + auto-scoped queryset.\n\nFuture phases subclass this so the invariant 'every staff query is makerspace-scoped'\nis enforced by default rather than by remembering to add a mixin (review fix #4). Add\n`required_action` + `HasMakerspaceAction` to a subclass for per-action checks.", + "/api/v1/admin/makerspace/{makerspace_id}/tenant-migration/disclosure-approvals": { + "get": { + "operationId": "api_v1_admin_makerspace_tenant_migration_disclosure_approvals_list", + "summary": "List disclosure approvals", "parameters": [ { "in": "path", @@ -20066,59 +20052,72 @@ } ], "tags": [ - "api" + "Tenant migration" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/EvidenceUrlRequest" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/EvidenceUrlRequest" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/EvidenceUrlRequest" - } - } - }, - "required": true - }, "security": [ { "jwtAuth": [] } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EvidenceUrlResponse" + "type": "array", + "items": { + "$ref": "#/components/schemas/ClosureApproval" + } } } }, "description": "" }, - "400": { - "description": "Invalid evidence upload request." + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TypedError" + } + } + }, + "description": "Authentication required." }, "403": { - "description": "Insufficient makerspace permission." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TypedError" + } + } + }, + "description": "Superadmin access required." }, - "503": { - "description": "Evidence storage is unavailable." + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TypedError" + } + } + }, + "description": "Throttle limit exceeded." + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TypedError" + } + } + }, + "description": "Unexpected migration failure." } } - } - }, - "/api/v1/admin/makerspaces/{makerspace_id}/waiver": { - "put": { - "operationId": "api_v1_admin_makerspaces_waiver_update", + }, + "post": { + "operationId": "api_v1_admin_makerspace_tenant_migration_disclosure_approvals_create", + "summary": "Approve each identity in one exact disclosure closure", "parameters": [ { "in": "path", @@ -20130,26 +20129,27 @@ } ], "tags": [ - "Admin memberships" + "Tenant migration" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WaiverPublish" + "$ref": "#/components/schemas/ClosureApprovalCreate" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/WaiverPublish" + "$ref": "#/components/schemas/ClosureApprovalCreate" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/WaiverPublish" + "$ref": "#/components/schemas/ClosureApprovalCreate" } } - } + }, + "required": true }, "security": [ { @@ -20157,11 +20157,11 @@ } ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WaiverPublish" + "$ref": "#/components/schemas/ClosureApproval" } } }, @@ -20171,60 +20171,79 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/FieldValidationError" } } }, - "description": "" + "description": "Field-keyed validation errors." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TypedError" + } + } + }, + "description": "State conflict." }, "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Authentication required." }, "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Superadmin access required." }, - "404": { + "429": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Throttle limit exceeded." }, - "409": { + "500": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Unexpected migration failure." } } } }, - "/api/v1/admin/makerspaces/{makerspace_id}/walk-in-members": { + "/api/v1/admin/makerspace/{makerspace_id}/tenant-migration/disclosure-approvals/{approval_id}/revoke": { "post": { - "operationId": "api_v1_admin_makerspaces_walk_in_members_create", - "summary": "Create a walk-in member record", + "operationId": "api_v1_admin_makerspace_tenant_migration_disclosure_approvals_revoke_create", + "summary": "Revoke a disclosure approval", "parameters": [ + { + "in": "path", + "name": "approval_id", + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true + }, { "in": "path", "name": "makerspace_id", @@ -20235,105 +20254,85 @@ } ], "tags": [ - "Admin memberships" + "Tenant migration" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/WalkInMemberCreate" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/WalkInMemberCreate" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/WalkInMemberCreate" - } - } - }, - "required": true - }, "security": [ { "jwtAuth": [] } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DirectLoanMember" + "$ref": "#/components/schemas/ClosureApproval" } } }, "description": "" }, - "400": { + "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Not found." }, "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Authentication required." }, "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Superadmin access required." }, - "404": { + "429": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Throttle limit exceeded." }, - "409": { + "500": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Unexpected migration failure." } } } }, - "/api/v1/admin/makerspaces/{id}": { + "/api/v1/admin/makerspace/{makerspace_id}/tenant-migration/disclosure-closure": { "get": { - "operationId": "api_v1_admin_makerspaces_retrieve", - "summary": "Retrieve or update a makerspace", + "operationId": "api_v1_admin_makerspace_tenant_migration_disclosure_closure_retrieve", + "summary": "Compute the pending PORTABLE disclosure closure", "parameters": [ { "in": "path", - "name": "id", + "name": "makerspace_id", "schema": { "type": "integer" }, @@ -20341,7 +20340,7 @@ } ], "tags": [ - "Admin makerspaces" + "Tenant migration" ], "security": [ { @@ -20353,84 +20352,71 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Makerspace" + "$ref": "#/components/schemas/PendingClosure" } } }, "description": "" - } - } - }, - "patch": { - "operationId": "api_v1_admin_makerspaces_partial_update", - "summary": "Retrieve or update a makerspace", - "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" - }, - "required": true - } - ], - "tags": [ - "Admin makerspaces" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PatchedMakerspace" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TypedError" + } } }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/PatchedMakerspace" + "description": "Authentication required." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TypedError" + } } }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/PatchedMakerspace" + "description": "Superadmin access required." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TypedError" + } } - } - } - }, - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { + }, + "description": "Throttle limit exceeded." + }, + "500": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Makerspace" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Unexpected migration failure." } } } }, - "/api/v1/admin/membership-requests": { + "/api/v1/admin/makerspace/{makerspace_id}/tenant-migration/exports": { "get": { - "operationId": "api_v1_admin_membership_requests_list", + "operationId": "api_v1_admin_makerspace_tenant_migration_exports_list", + "summary": "List PORTABLE migration export jobs", "parameters": [ { - "name": "page", - "required": false, - "in": "query", - "description": "A page number within the paginated result set.", + "in": "path", + "name": "makerspace_id", "schema": { "type": "integer" - } + }, + "required": true } ], "tags": [ - "Admin memberships" + "Tenant migration" ], "security": [ { @@ -20442,17 +20428,10 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaginatedMembershipRequestList" - } - } - }, - "description": "" - }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "type": "array", + "items": { + "$ref": "#/components/schemas/MigrationExportJob" + } } } }, @@ -20462,52 +20441,51 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Authentication required." }, "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Superadmin access required." }, - "404": { + "429": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Throttle limit exceeded." }, - "409": { + "500": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Unexpected migration failure." } } - } - }, - "/api/v1/admin/membership-requests/{id}/approve": { + }, "post": { - "operationId": "api_v1_admin_membership_requests_approve_create", + "operationId": "api_v1_admin_makerspace_tenant_migration_exports_create", + "summary": "Create a target-recipient-encrypted PORTABLE export", "parameters": [ { "in": "path", - "name": "id", + "name": "makerspace_id", "schema": { "type": "integer" }, @@ -20515,23 +20493,23 @@ } ], "tags": [ - "Admin memberships" + "Tenant migration" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RoleId" + "$ref": "#/components/schemas/MigrationExportCreate" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/RoleId" + "$ref": "#/components/schemas/MigrationExportCreate" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/RoleId" + "$ref": "#/components/schemas/MigrationExportCreate" } } }, @@ -20543,11 +20521,11 @@ } ], "responses": { - "200": { + "202": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminMembership" + "$ref": "#/components/schemas/MigrationExportJob" } } }, @@ -20557,62 +20535,85 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/FieldValidationError" } } }, - "description": "" + "description": "Field-keyed validation errors." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TypedError" + } + } + }, + "description": "State conflict." + }, + "503": { + "description": "Migration worker unavailable." }, "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Authentication required." }, "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Superadmin access required." }, - "404": { + "429": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Throttle limit exceeded." }, - "409": { + "500": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Unexpected migration failure." } } } }, - "/api/v1/admin/membership-requests/{id}/revoke": { - "post": { - "operationId": "api_v1_admin_membership_requests_revoke_create", + "/api/v1/admin/makerspace/{makerspace_id}/tenant-migration/exports/{job_id}": { + "get": { + "operationId": "api_v1_admin_makerspace_tenant_migration_exports_retrieve", + "summary": "Read a PORTABLE migration export job", "parameters": [ { "in": "path", - "name": "id", + "name": "job_id", + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true + }, + { + "in": "path", + "name": "makerspace_id", "schema": { "type": "integer" }, @@ -20620,27 +20621,8 @@ } ], "tags": [ - "Admin memberships" + "Tenant migration" ], - "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": [] @@ -20651,81 +20633,90 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MembershipRequest" + "$ref": "#/components/schemas/MigrationExportJob" } } }, "description": "" }, - "400": { + "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Not found." }, "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Authentication required." }, "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Superadmin access required." }, - "404": { + "429": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Throttle limit exceeded." }, - "409": { + "500": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Unexpected migration failure." } } } }, - "/api/v1/admin/memberships": { - "get": { - "operationId": "api_v1_admin_memberships_list", + "/api/v1/admin/makerspace/{makerspace_id}/tenant-migration/exports/{job_id}/download-url": { + "post": { + "operationId": "api_v1_admin_makerspace_tenant_migration_exports_download_url_create", + "summary": "Issue the one-use PORTABLE archive download URL", "parameters": [ { - "name": "page", - "required": false, - "in": "query", - "description": "A page number within the paginated result set.", + "in": "path", + "name": "job_id", + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true + }, + { + "in": "path", + "name": "makerspace_id", "schema": { "type": "integer" - } + }, + "required": true } ], "tags": [ - "Admin memberships" + "Tenant migration" ], "security": [ { @@ -20737,7 +20728,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaginatedAdminMembershipList" + "$ref": "#/components/schemas/DataExportDownloadUrl" } } }, @@ -20747,92 +20738,82 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "State conflict." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TypedError" + } + } + }, + "description": "Not found." }, "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Authentication required." }, "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Superadmin access required." }, - "404": { + "429": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Throttle limit exceeded." }, - "409": { + "500": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Unexpected migration failure." } } } }, - "/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/makerspace/{makerspace_id}/tenant-migration/exports/{job_id}/quiesce": { + "post": { + "operationId": "api_v1_admin_makerspace_tenant_migration_exports_quiesce_create", + "summary": "Reassert a completed export's source gate lease", "parameters": [ { "in": "path", - "name": "id", + "name": "job_id", "schema": { - "type": "integer" + "type": "string", + "format": "uuid" }, "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", - "name": "id", + "name": "makerspace_id", "schema": { "type": "integer" }, @@ -20840,27 +20821,8 @@ } ], "tags": [ - "Admin memberships" + "Tenant migration" ], - "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": [] @@ -20871,100 +20833,101 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminMembership" + "$ref": "#/components/schemas/CutoverOutcome" } } }, "description": "" }, - "400": { + "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Not found." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TypedError" + } + } + }, + "description": "State conflict." }, "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Authentication required." }, "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Superadmin access required." }, - "404": { + "429": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Throttle limit exceeded." }, - "409": { + "500": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Unexpected migration failure." } } } }, - "/api/v1/admin/memberships/{id}/revoke": { + "/api/v1/admin/makerspace/{makerspace_id}/tenant-migration/pairings/{pairing_id}/archive-source": { "post": { - "operationId": "api_v1_admin_memberships_revoke_create", + "operationId": "api_v1_admin_makerspace_tenant_migration_pairings_archive_source_create", + "summary": "Archive the quiesced source and issue its signed cutover receipt", "parameters": [ { "in": "path", - "name": "id", + "name": "makerspace_id", "schema": { "type": "integer" }, "required": true + }, + { + "in": "path", + "name": "pairing_id", + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true } ], "tags": [ - "Admin memberships" + "Tenant migration" ], - "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": [] @@ -20975,99 +20938,120 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminMembership" + "$ref": "#/components/schemas/CutoverOutcome" } } }, "description": "" }, - "400": { + "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Not found." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TypedError" + } + } + }, + "description": "State conflict." }, "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Authentication required." }, "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Superadmin access required." }, - "404": { + "429": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Throttle limit exceeded." }, - "409": { + "500": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Unexpected migration failure." } } } }, - "/api/v1/admin/memberships/{id}/role": { - "patch": { - "operationId": "api_v1_admin_memberships_role_partial_update", + "/api/v1/admin/makerspace/{makerspace_id}/tenant-migration/pairings/{pairing_id}/recover": { + "post": { + "operationId": "api_v1_admin_makerspace_tenant_migration_pairings_recover_create", + "summary": "Recover an archived source with the target abort receipt", "parameters": [ { "in": "path", - "name": "id", + "name": "makerspace_id", "schema": { "type": "integer" }, "required": true + }, + { + "in": "path", + "name": "pairing_id", + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true } ], "tags": [ - "Admin memberships" + "Tenant migration" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PatchedRoleId" + "$ref": "#/components/schemas/CutoverReceiptRequest" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/PatchedRoleId" + "$ref": "#/components/schemas/CutoverReceiptRequest" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/PatchedRoleId" + "$ref": "#/components/schemas/CutoverReceiptRequest" } } - } + }, + "required": true }, "security": [ { @@ -21079,7 +21063,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminMembership" + "$ref": "#/components/schemas/CutoverOutcome" } } }, @@ -21089,62 +21073,83 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/FieldValidationError" } } }, - "description": "" + "description": "Field-keyed validation errors." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TypedError" + } + } + }, + "description": "Not found." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TypedError" + } + } + }, + "description": "State conflict." }, "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Authentication required." }, "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Superadmin access required." }, - "404": { + "429": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Throttle limit exceeded." }, - "409": { + "500": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Unexpected migration failure." } } } }, - "/api/v1/admin/memberships/{id}/unverify": { + "/api/v1/admin/makerspace/{makerspace_id}/verify-domain": { "post": { - "operationId": "api_v1_admin_memberships_unverify_create", + "operationId": "api_v1_admin_makerspace_verify_domain_create", + "summary": "Verify a makerspace custom domain", "parameters": [ { "in": "path", - "name": "id", + "name": "makerspace_id", "schema": { "type": "integer" }, @@ -21152,7 +21157,7 @@ } ], "tags": [ - "Admin memberships" + "Admin makerspaces" ], "security": [ { @@ -21164,17 +21169,98 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminMembership" + "$ref": "#/components/schemas/DomainVerificationResponse" } } }, "description": "" }, - "400": { + "403": { + "description": "Missing makerspace management permission." + }, + "404": { + "description": "Makerspace not found." + } + } + } + }, + "/api/v1/admin/makerspace/{makerspace_id}/warranties": { + "get": { + "operationId": "api_v1_admin_makerspace_warranties_list", + "summary": "List warranty coverage for a makerspace", + "parameters": [ + { + "in": "query", + "name": "expires_before", + "schema": { + "type": "string", + "format": "date" + }, + "description": "Only include hosts with warranty expiry on or before this date." + }, + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + }, + { + "in": "query", + "name": "missing_docs", + "schema": { + "type": "boolean" + }, + "description": "Only include hosts with no warranty documents or no warranty record." + }, + { + "name": "page", + "required": false, + "in": "query", + "description": "A page number within the paginated result set.", + "schema": { + "type": "integer" + } + }, + { + "name": "page_size", + "required": false, + "in": "query", + "description": "Number of results to return per page.", + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "status", + "schema": { + "type": "string", + "enum": [ + "active", + "expired", + "expiring_soon", + "unknown" + ] + }, + "description": "Filter rows by computed warranty status." + } + ], + "tags": [ + "Admin warranty" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/PaginatedWarrantyReportRowList" } } }, @@ -21209,12 +21295,75 @@ } }, "description": "" + } + } + } + }, + "/api/v1/admin/makerspaces": { + "get": { + "operationId": "api_v1_admin_makerspaces_list", + "summary": "List or create makerspaces", + "tags": [ + "Admin makerspaces" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Makerspace" + } + } + } + }, + "description": "" + } + } + }, + "post": { + "operationId": "api_v1_admin_makerspaces_create", + "summary": "List or create makerspaces", + "tags": [ + "Admin makerspaces" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Makerspace" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/Makerspace" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/Makerspace" + } + } }, - "409": { + "required": true + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/Makerspace" } } }, @@ -21223,13 +21372,14 @@ } } }, - "/api/v1/admin/memberships/{id}/verify": { - "post": { - "operationId": "api_v1_admin_memberships_verify_create", + "/api/v1/admin/makerspaces/{makerspace_id}/event-collaborations/": { + "get": { + "operationId": "api_v1_admin_makerspaces_event_collaborations_list", + "summary": "List collaboration invitations for a makerspace", "parameters": [ { "in": "path", - "name": "id", + "name": "makerspace_id", "schema": { "type": "integer" }, @@ -21237,7 +21387,7 @@ } ], "tags": [ - "Admin memberships" + "Admin events" ], "security": [ { @@ -21249,7 +21399,10 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminMembership" + "type": "array", + "items": { + "$ref": "#/components/schemas/EventCollaborationInbox" + } } } }, @@ -21263,17 +21416,7 @@ } } }, - "description": "" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "description": "Invalid collaboration request." }, "403": { "content": { @@ -21283,7 +21426,7 @@ } } }, - "description": "" + "description": "Event management access denied." }, "404": { "content": { @@ -21293,28 +21436,19 @@ } } }, - "description": "" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "description": "Event collaboration not found." } } } }, - "/api/v1/admin/memberships/{id}/waiver/witness": { - "post": { - "operationId": "api_v1_admin_memberships_waiver_witness_create", + "/api/v1/admin/makerspaces/{makerspace_id}/event-series/": { + "get": { + "operationId": "api_v1_admin_makerspaces_event_series_retrieve", + "summary": "List recurring event series", "parameters": [ { "in": "path", - "name": "id", + "name": "makerspace_id", "schema": { "type": "integer" }, @@ -21322,7 +21456,7 @@ } ], "tags": [ - "Admin memberships" + "Admin event series" ], "security": [ { @@ -21334,7 +21468,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WitnessWaiverResponse" + "$ref": "#/components/schemas/EventSeriesListResponse" } } }, @@ -21348,7 +21482,7 @@ } } }, - "description": "" + "description": "Invalid recurring event series." }, "401": { "content": { @@ -21358,7 +21492,7 @@ } } }, - "description": "" + "description": "Authentication required." }, "403": { "content": { @@ -21368,7 +21502,7 @@ } } }, - "description": "" + "description": "Event management access denied." }, "404": { "content": { @@ -21378,7 +21512,7 @@ } } }, - "description": "" + "description": "Event series not found." }, "409": { "content": { @@ -21388,117 +21522,67 @@ } } }, - "description": "" + "description": "Series state conflict." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Rate limit exceeded." } } - } - }, - "/api/v1/admin/organizations/{organization_id}/analytics/{report_key}": { - "get": { - "operationId": "api_v1_admin_organizations_analytics_retrieve", - "description": "Requires an active organization membership carrying the report's action. Authorization and the owned-makerspace set are resolved server-side; a combined total is always returned together with its per-makerspace breakdown.", - "summary": "Get organization analytics report", + }, + "post": { + "operationId": "api_v1_admin_makerspaces_event_series_create", + "summary": "Create a recurring event series", "parameters": [ - { - "in": "query", - "name": "end", - "schema": { - "type": "string", - "format": "date" - } - }, - { - "in": "query", - "name": "limit", - "schema": { - "type": "integer" - } - }, { "in": "path", - "name": "organization_id", + "name": "makerspace_id", "schema": { "type": "integer" }, "required": true - }, - { - "in": "path", - "name": "report_key", - "schema": { - "type": "string", - "enum": [ - "active-loans", - "booking-utilization", - "damaged-lost", - "damaged-missing", - "event-attendance", - "fablab-health", - "machine-usage", - "maintenance-activity", - "member-activity", - "most-lent", - "payment-reconciliation", - "qr-scans", - "recently-added", - "returns", - "summary", - "taken-items", - "top-borrowers" - ] - }, - "required": true - }, - { - "in": "query", - "name": "start", - "schema": { - "type": "string", - "format": "date" - } - }, - { - "in": "query", - "name": "status", - "schema": { - "type": "string", - "enum": [ - "canceled", - "paid_offline", - "paid_online", - "pending", - "waived" - ] - } - }, - { - "in": "query", - "name": "subject_type", - "schema": { - "type": "string", - "enum": [ - "booking", - "event_registration", - "machine_service_request", - "makerspace_membership" - ] - } } ], "tags": [ - "Analytics" + "Admin event series" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EventSeriesWrite" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/EventSeriesWrite" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/EventSeriesWrite" + } + } + }, + "required": true + }, "security": [ { "jwtAuth": [] } ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/OrganizationReportResponse" + "$ref": "#/components/schemas/EventSeriesMutationResponse" } } }, @@ -21508,17 +21592,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Invalid or excluded organization report." + "description": "Invalid recurring event series." }, "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, @@ -21528,32 +21612,60 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Permission denied." + "description": "Event management access denied." }, "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Organization or report not found." + "description": "Event series not found." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Series state conflict." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Rate limit exceeded." } } } }, - "/api/v1/admin/organized-events/": { + "/api/v1/admin/makerspaces/{makerspace_id}/event-series-collaborations/": { "get": { - "operationId": "api_v1_admin_organized_events_retrieve", - "description": "List the events this actor's organizations organize, across venues.\n\nOrganizer authority is deliberately per-event and grants nothing over the venue, so an\norganizer at a venue their organization is not linked to cannot use the venue's event\nlist -- which left the per-event endpoints reachable only by someone who already knew a\ndatabase id. This is the discoverable surface for that authority: it lists exactly the\nevents the organizer predicate matches and confers no venue authority whatsoever.", - "summary": "List events organized by the actor's organizations", + "operationId": "api_v1_admin_makerspaces_event_series_collaborations_list", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], "tags": [ - "Events" + "Admin event series" ], "security": [ { @@ -21565,12 +21677,25 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EventListResponse" + "type": "array", + "items": { + "$ref": "#/components/schemas/SeriesCollaborationInbox" + } } } }, "description": "" }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Invalid collaboration request." + }, "401": { "content": { "application/json": { @@ -21579,7 +21704,7 @@ } } }, - "description": "" + "description": "Authentication required." }, "403": { "content": { @@ -21589,7 +21714,7 @@ } } }, - "description": "" + "description": "Event management access denied." }, "404": { "content": { @@ -21599,17 +21724,47 @@ } } }, - "description": "" + "description": "Series collaboration not found." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Collaboration state conflict." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Rate limit exceeded." } } } }, - "/api/v1/admin/platform/backup-settings": { + "/api/v1/admin/makerspaces/{makerspace_id}/events/": { "get": { - "operationId": "api_v1_admin_platform_backup_settings_retrieve", - "summary": "Get deployment backup settings", + "operationId": "api_v1_admin_makerspaces_events_retrieve", + "summary": "List events in a makerspace", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], "tags": [ - "Backup" + "Admin events" ], "security": [ { @@ -21621,44 +21776,49 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PlatformBackupSettings" + "$ref": "#/components/schemas/EventListResponse" } } }, "description": "" - }, - "401": { - "description": "Authentication is required." - }, - "403": { - "description": "The authenticated actor is not authorized." } } }, - "patch": { - "operationId": "api_v1_admin_platform_backup_settings_partial_update", - "summary": "Update deployment backup settings", + "post": { + "operationId": "api_v1_admin_makerspaces_events_create", + "summary": "Create a draft event", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], "tags": [ - "Backup" + "Admin events" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PatchedPlatformBackupSettings" + "$ref": "#/components/schemas/EventWrite" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/PatchedPlatformBackupSettings" + "$ref": "#/components/schemas/EventWrite" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/PatchedPlatformBackupSettings" + "$ref": "#/components/schemas/EventWrite" } } - } + }, + "required": true }, "security": [ { @@ -21666,67 +21826,46 @@ } ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PlatformBackupSettings" + "$ref": "#/components/schemas/EventAdmin" } } }, "description": "" }, "400": { - "description": "The request is invalid for the current lifecycle state." - }, - "401": { - "description": "Authentication is required." - }, - "403": { - "description": "The authenticated actor is not authorized." - } - } - } - }, - "/api/v1/admin/platform/backups": { - "get": { - "operationId": "api_v1_admin_platform_backups_list", - "summary": "List full-deployment backup archives", - "tags": [ - "Backup" - ], - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/BackupArchive" - } + "type": "object", + "additionalProperties": {} } } }, - "description": "" - }, - "401": { - "description": "Authentication is required." - }, - "403": { - "description": "The authenticated actor is not authorized." + "description": "Invalid event details." } } - }, - "post": { - "operationId": "api_v1_admin_platform_backups_create", - "summary": "Request an age-encrypted full-deployment backup", + } + }, + "/api/v1/admin/makerspaces/{makerspace_id}/evidence-retention": { + "get": { + "operationId": "api_v1_admin_makerspaces_evidence_retention_retrieve", + "summary": "Get a makerspace evidence object-retention policy", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], "tags": [ - "Backup" + "Evidence retention" ], "security": [ { @@ -21734,74 +21873,61 @@ } ], "responses": { - "202": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BackupArchive" + "$ref": "#/components/schemas/EvidenceRetentionPolicy" } } }, "description": "" }, - "503": { - "description": "The backup worker is unavailable." - }, "401": { "description": "Authentication is required." }, "403": { - "description": "The authenticated actor is not authorized." + "description": "Active manage-events permission is required." + }, + "404": { + "description": "Makerspace was not found in the actor's scope." + }, + "503": { + "description": "Deployment recovery is active." } } - } - }, - "/api/v1/admin/platform/email-settings": { - "get": { - "operationId": "api_v1_admin_platform_email_settings_retrieve", - "summary": "Retrieve or update platform email settings", - "tags": [ - "Platform" - ], - "security": [ + }, + "patch": { + "operationId": "api_v1_admin_makerspaces_evidence_retention_partial_update", + "summary": "Set or clear a makerspace evidence object-retention override", + "parameters": [ { - "jwtAuth": [] - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PlatformEmailSettings" - } - } + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" }, - "description": "" + "required": true } - } - }, - "patch": { - "operationId": "api_v1_admin_platform_email_settings_partial_update", - "summary": "Retrieve or update platform email settings", + ], "tags": [ - "Platform" + "Evidence retention" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PatchedPlatformEmailSettings" + "$ref": "#/components/schemas/PatchedEvidenceRetentionPatch" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/PatchedPlatformEmailSettings" + "$ref": "#/components/schemas/PatchedEvidenceRetentionPatch" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/PatchedPlatformEmailSettings" + "$ref": "#/components/schemas/PatchedEvidenceRetentionPatch" } } } @@ -21816,61 +21942,62 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PlatformEmailSettings" + "$ref": "#/components/schemas/EvidenceRetentionPolicy" } } }, "description": "" + }, + "400": { + "description": "Retention days are invalid." + }, + "401": { + "description": "Authentication is required." + }, + "403": { + "description": "Active manage-events permission is required." + }, + "404": { + "description": "Makerspace was not found in the actor's scope." + }, + "503": { + "description": "Deployment recovery is active." } } } }, - "/api/v1/admin/platform/payment-settings": { - "get": { - "operationId": "api_v1_admin_platform_payment_settings_retrieve", - "summary": "Retrieve or update platform Stripe Connect settings", - "tags": [ - "Platform" - ], - "security": [ + "/api/v1/admin/makerspaces/{makerspace_id}/evidence-retention/preview": { + "post": { + "operationId": "api_v1_admin_makerspaces_evidence_retention_preview_create", + "summary": "Preview evidence objects eligible for expiry", + "parameters": [ { - "jwtAuth": [] - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PlatformStripeConnectSettings" - } - } + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" }, - "description": "" + "required": true } - } - }, - "patch": { - "operationId": "api_v1_admin_platform_payment_settings_partial_update", - "summary": "Retrieve or update platform Stripe Connect settings", + ], "tags": [ - "Platform" + "Evidence retention" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PatchedPlatformStripeConnectSettings" + "$ref": "#/components/schemas/EvidenceRetentionPreviewRequest" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/PatchedPlatformStripeConnectSettings" + "$ref": "#/components/schemas/EvidenceRetentionPreviewRequest" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/PatchedPlatformStripeConnectSettings" + "$ref": "#/components/schemas/EvidenceRetentionPreviewRequest" } } } @@ -21885,21 +22012,45 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PlatformStripeConnectSettings" + "$ref": "#/components/schemas/EvidenceRetentionPreviewResponse" } } }, "description": "" + }, + "400": { + "description": "Preview limit is invalid." + }, + "401": { + "description": "Authentication is required." + }, + "403": { + "description": "Active manage-events permission is required." + }, + "404": { + "description": "Makerspace was not found in the actor's scope." + }, + "503": { + "description": "Deployment recovery is active." } } } }, - "/api/v1/admin/platform/restores": { + "/api/v1/admin/makerspaces/{makerspace_id}/machine-service/consumable-pools": { "get": { - "operationId": "api_v1_admin_platform_restores_list", - "summary": "List deployment restore operations", + "operationId": "api_v1_admin_makerspaces_machine_service_consumable_pools_list", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], "tags": [ - "Backup" + "Admin machine service" ], "security": [ { @@ -21913,42 +22064,45 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/RestoreOperation" + "$ref": "#/components/schemas/PrinterPool" } } } }, "description": "" - }, - "401": { - "description": "Authentication is required." - }, - "403": { - "description": "The authenticated actor is not authorized." } } }, "post": { - "operationId": "api_v1_admin_platform_restores_create", - "summary": "Record restore intent for the privileged host supervisor", + "operationId": "api_v1_admin_makerspaces_machine_service_consumable_pools_create", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], "tags": [ - "Backup" + "Admin machine service" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RestoreCreate" + "$ref": "#/components/schemas/PrinterPoolCreate" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/RestoreCreate" + "$ref": "#/components/schemas/PrinterPoolCreate" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/RestoreCreate" + "$ref": "#/components/schemas/PrinterPoolCreate" } } }, @@ -21960,45 +22114,77 @@ } ], "responses": { - "202": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RestoreOperation" + "$ref": "#/components/schemas/PrinterPool" } } }, "description": "" - }, - "400": { - "description": "The request is invalid for the current lifecycle state." - }, - "401": { - "description": "Authentication is required." - }, - "403": { - "description": "The authenticated actor is not authorized." } } } }, - "/api/v1/admin/platform/restores/{restore_id}": { + "/api/v1/admin/makerspaces/{makerspace_id}/machine-service/requests": { "get": { - "operationId": "api_v1_admin_platform_restores_retrieve", - "summary": "Get restore stage, diff, and decision deadline", + "operationId": "api_v1_admin_makerspaces_machine_service_requests_list", + "summary": "List machine service requests", "parameters": [ + { + "in": "query", + "name": "bucket", + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "machine", + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "machine_type", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "machine_type_id", + "schema": { + "type": "integer" + } + }, { "in": "path", - "name": "restore_id", + "name": "makerspace_id", "schema": { - "type": "string", - "format": "uuid" + "type": "integer" }, "required": true + }, + { + "in": "query", + "name": "queue", + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "status", + "schema": { + "type": "string" + } } ], "tags": [ - "Backup" + "Admin machine service" ], "security": [ { @@ -22010,57 +22196,77 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RestoreOperation" + "type": "array", + "items": { + "$ref": "#/components/schemas/MachineServiceRequest" + } } } }, "description": "" }, - "404": { - "description": "The requested resource does not exist in the actor's scope." + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Invalid service request input." }, "401": { - "description": "Authentication is required." + "description": "Authentication required." }, "403": { - "description": "The authenticated actor is not authorized." + "description": "Machine management permission required." + }, + "404": { + "description": "Service request was not found." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Service workflow conflict." } } - } - }, - "/api/v1/admin/platform/restores/{restore_id}/decision": { + }, "post": { - "operationId": "api_v1_admin_platform_restores_decision_create", - "summary": "Decide a quiesced in-place restore", + "operationId": "api_v1_admin_makerspaces_machine_service_requests_create", + "summary": "Submit a machine service request for a member", "parameters": [ { "in": "path", - "name": "restore_id", + "name": "makerspace_id", "schema": { - "type": "string", - "format": "uuid" + "type": "integer" }, "required": true } ], "tags": [ - "Backup" + "Admin machine service" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RestoreDecision" + "$ref": "#/components/schemas/MachineServiceSubmit" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/RestoreDecision" + "$ref": "#/components/schemas/MachineServiceSubmit" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/RestoreDecision" + "$ref": "#/components/schemas/MachineServiceSubmit" } } }, @@ -22072,37 +22278,77 @@ } ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RestoreOperation" + "$ref": "#/components/schemas/MachineServiceRequest" } } }, "description": "" }, "400": { - "description": "The request is invalid for the current lifecycle state." - }, - "404": { - "description": "The requested resource does not exist in the actor's scope." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Invalid service request input." }, "401": { - "description": "Authentication is required." + "description": "Authentication required." }, "403": { - "description": "The authenticated actor is not authorized." + "description": "Machine management permission required." + }, + "404": { + "description": "Service request was not found." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Service workflow conflict." } } } }, - "/api/v1/admin/platform/social-auth-settings": { + "/api/v1/admin/makerspaces/{makerspace_id}/machine-service/typed-manual-usage": { "get": { - "operationId": "api_v1_admin_platform_social_auth_settings_retrieve", - "summary": "Retrieve or update platform social auth settings", + "operationId": "api_v1_admin_makerspaces_machine_service_typed_manual_usage_list", + "parameters": [ + { + "in": "query", + "name": "machine_type", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "machine_type_id", + "schema": { + "type": "integer" + } + }, + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], "tags": [ - "Platform" + "Admin machine service" ], "security": [ { @@ -22114,7 +22360,10 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PlatformSocialAuthSettings" + "type": "array", + "items": { + "$ref": "#/components/schemas/TypedManualUsageResponse" + } } } }, @@ -22122,30 +22371,40 @@ } } }, - "patch": { - "operationId": "api_v1_admin_platform_social_auth_settings_partial_update", - "summary": "Retrieve or update platform social auth settings", + "post": { + "operationId": "api_v1_admin_makerspaces_machine_service_typed_manual_usage_create", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], "tags": [ - "Platform" + "Admin machine service" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PatchedPlatformSocialAuthSettings" + "$ref": "#/components/schemas/TypedManualUsage" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/PatchedPlatformSocialAuthSettings" + "$ref": "#/components/schemas/TypedManualUsage" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/PatchedPlatformSocialAuthSettings" + "$ref": "#/components/schemas/TypedManualUsage" } } - } + }, + "required": true }, "security": [ { @@ -22153,11 +22412,11 @@ } ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PlatformSocialAuthSettings" + "$ref": "#/components/schemas/TypedManualUsageResponse" } } }, @@ -22166,12 +22425,30 @@ } } }, - "/api/v1/admin/platform/tenant-migrations/deployment-identity": { + "/api/v1/admin/makerspaces/{makerspace_id}/machines/{machine_id}/maintenance/logs/": { "get": { - "operationId": "api_v1_admin_platform_tenant_migrations_deployment_identity_retrieve", - "summary": "Read this deployment's signing identity and target age recipient", + "operationId": "api_v1_admin_makerspaces_machines_maintenance_logs_retrieve", + "summary": "List immutable machine maintenance logs", + "parameters": [ + { + "in": "path", + "name": "machine_id", + "schema": { + "type": "integer" + }, + "required": true + }, + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], "tags": [ - "Tenant migration" + "Admin maintenance" ], "security": [ { @@ -22183,170 +22460,183 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeploymentIdentity" + "$ref": "#/components/schemas/MaintenanceLogList" } } }, "description": "" }, - "409": { + "400": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "State conflict." + "description": "Invalid request." }, - "401": { + "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Authentication required." + "description": "Permission denied." }, - "403": { + "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Superadmin access required." - }, - "429": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } - } + "description": "Not found." + } + } + }, + "post": { + "operationId": "api_v1_admin_makerspaces_machines_maintenance_logs_create", + "summary": "Record completed machine maintenance", + "parameters": [ + { + "in": "path", + "name": "machine_id", + "schema": { + "type": "integer" }, - "description": "Throttle limit exceeded." + "required": true }, - "500": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } - } + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" }, - "description": "Unexpected migration failure." + "required": true } - } - } - }, - "/api/v1/admin/platform/tenant-migrations/imports": { - "get": { - "operationId": "api_v1_admin_platform_tenant_migrations_imports_list", - "summary": "List tenant import jobs", + ], "tags": [ - "Tenant migration" + "Admin maintenance" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MaintenanceLogWrite" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/MaintenanceLogWrite" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/MaintenanceLogWrite" + } + } + }, + "required": true + }, "security": [ { "jwtAuth": [] } ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ImportJob" - } + "$ref": "#/components/schemas/MaintenanceLog" } } }, "description": "" }, - "401": { + "400": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Authentication required." + "description": "Invalid request." }, "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Superadmin access required." + "description": "Permission denied." }, - "429": { + "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Throttle limit exceeded." + "description": "Not found." }, - "500": { + "409": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Unexpected migration failure." + "description": "Workflow conflict." } } - }, - "post": { - "operationId": "api_v1_admin_platform_tenant_migrations_imports_create", - "summary": "Create an import job from an age-encrypted archive upload", - "tags": [ - "Tenant migration" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ImportCreate" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/ImportCreate" - } + } + }, + "/api/v1/admin/makerspaces/{makerspace_id}/machines/{machine_id}/maintenance/schedules/": { + "get": { + "operationId": "api_v1_admin_makerspaces_machines_maintenance_schedules_retrieve", + "summary": "List machine maintenance schedules", + "parameters": [ + { + "in": "path", + "name": "machine_id", + "schema": { + "type": "integer" }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/ImportCreate" - } - } + "required": true }, - "required": true - }, + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Admin maintenance" + ], "security": [ { "jwtAuth": [] } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ImportJob" + "$ref": "#/components/schemas/MaintenanceScheduleList" } } }, @@ -22356,169 +22646,153 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/FieldValidationError" - } - } - }, - "description": "Field-keyed validation errors." - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } - } - }, - "description": "State conflict." - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Authentication required." + "description": "Invalid request." }, "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" - } - } - }, - "description": "Superadmin access required." - }, - "429": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Throttle limit exceeded." + "description": "Permission denied." }, - "500": { + "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Unexpected migration failure." + "description": "Not found." } } - } - }, - "/api/v1/admin/platform/tenant-migrations/imports/{job_id}": { - "get": { - "operationId": "api_v1_admin_platform_tenant_migrations_imports_retrieve", - "summary": "Read a tenant import job", + }, + "post": { + "operationId": "api_v1_admin_makerspaces_machines_maintenance_schedules_create", + "summary": "Create a machine maintenance schedule", "parameters": [ { "in": "path", - "name": "job_id", + "name": "machine_id", "schema": { - "type": "string", - "format": "uuid" + "type": "integer" + }, + "required": true + }, + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" }, "required": true } ], "tags": [ - "Tenant migration" + "Admin maintenance" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MaintenanceScheduleWrite" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/MaintenanceScheduleWrite" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/MaintenanceScheduleWrite" + } + } + }, + "required": true + }, "security": [ { "jwtAuth": [] } ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ImportJob" + "$ref": "#/components/schemas/MaintenanceSchedule" } } }, "description": "" }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } - } - }, - "description": "Not found." - }, - "401": { + "400": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Authentication required." + "description": "Invalid request." }, "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Superadmin access required." + "description": "Permission denied." }, - "429": { + "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Throttle limit exceeded." + "description": "Not found." }, - "500": { + "409": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Unexpected migration failure." + "description": "Workflow conflict." } } } }, - "/api/v1/admin/platform/tenant-migrations/imports/{job_id}/identity-decisions": { + "/api/v1/admin/makerspaces/{makerspace_id}/member-claim-codes": { "get": { - "operationId": "api_v1_admin_platform_tenant_migrations_imports_identity_decisions_list", - "summary": "Read the exact archived identity decision list", + "operationId": "api_v1_admin_makerspaces_member_claim_codes_list", + "summary": "List active physically handed member claim codes", "parameters": [ { "in": "path", - "name": "job_id", + "name": "makerspace_id", "schema": { - "type": "string", - "format": "uuid" + "type": "integer" }, "required": true } ], "tags": [ - "Tenant migration" + "Admin memberships" ], "security": [ { @@ -22532,97 +22806,96 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/ClosureIdentity" + "$ref": "#/components/schemas/MemberClaimCode" } } } }, "description": "" }, - "404": { + "400": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Not found." + "description": "" }, "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Authentication required." + "description": "" }, "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Superadmin access required." + "description": "" }, - "429": { + "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Throttle limit exceeded." + "description": "" }, - "500": { + "409": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Unexpected migration failure." + "description": "" } } }, "post": { - "operationId": "api_v1_admin_platform_tenant_migrations_imports_identity_decisions_create", - "summary": "Submit all per-person import identity decisions", + "operationId": "api_v1_admin_makerspaces_member_claim_codes_create", + "summary": "Issue a claim code to an eligible walk-in member", "parameters": [ { "in": "path", - "name": "job_id", + "name": "makerspace_id", "schema": { - "type": "string", - "format": "uuid" + "type": "integer" }, "required": true } ], "tags": [ - "Tenant migration" + "Admin memberships" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ImportDecisionList" + "$ref": "#/components/schemas/MemberClaimCodeIssueRequest" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/ImportDecisionList" + "$ref": "#/components/schemas/MemberClaimCodeIssueRequest" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/ImportDecisionList" + "$ref": "#/components/schemas/MemberClaimCodeIssueRequest" } } }, @@ -22634,11 +22907,11 @@ } ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ImportJob" + "$ref": "#/components/schemas/MemberClaimCodeIssueResponse" } } }, @@ -22648,101 +22921,89 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/FieldValidationError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Field-keyed validation errors." + "description": "" }, - "409": { + "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "State conflict." + "description": "" }, - "404": { + "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Not found." + "description": "" }, - "401": { + "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Authentication required." + "description": "" }, - "403": { + "409": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Superadmin access required." + "description": "" }, "429": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" - } - } - }, - "description": "Throttle limit exceeded." - }, - "500": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Unexpected migration failure." + "description": "Issue rate limit exceeded." } } } }, - "/api/v1/admin/platform/tenant-migrations/imports/{job_id}/pairings/{pairing_id}/abort": { + "/api/v1/admin/makerspaces/{makerspace_id}/member-claim-codes/{claim_id}/revoke": { "post": { - "operationId": "api_v1_admin_platform_tenant_migrations_imports_pairings_abort_create", - "summary": "Abort an importing target and issue its signed proof", + "operationId": "api_v1_admin_makerspaces_member_claim_codes_revoke_create", + "summary": "Revoke a member claim code and its bound session", "parameters": [ { "in": "path", - "name": "job_id", + "name": "claim_id", "schema": { - "type": "string", - "format": "uuid" + "type": "integer" }, "required": true }, { "in": "path", - "name": "pairing_id", + "name": "makerspace_id", "schema": { - "type": "string", - "format": "uuid" + "type": "integer" }, "required": true } ], "tags": [ - "Tenant migration" + "Admin memberships" ], "security": [ { @@ -22754,122 +23015,81 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CutoverOutcome" + "$ref": "#/components/schemas/MemberClaimCode" } } }, "description": "" }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } - } - }, - "description": "Not found." - }, - "409": { + "400": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "State conflict." + "description": "" }, "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Authentication required." + "description": "" }, "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Superadmin access required." + "description": "" }, - "429": { + "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Throttle limit exceeded." + "description": "" }, - "500": { + "409": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Unexpected migration failure." + "description": "" } } } }, - "/api/v1/admin/platform/tenant-migrations/imports/{job_id}/pairings/{pairing_id}/activate": { - "post": { - "operationId": "api_v1_admin_platform_tenant_migrations_imports_pairings_activate_create", - "summary": "Activate an imported target with the source receipt", + "/api/v1/admin/makerspaces/{makerspace_id}/memberships": { + "get": { + "operationId": "api_v1_admin_makerspaces_memberships_list", "parameters": [ { "in": "path", - "name": "job_id", - "schema": { - "type": "string", - "format": "uuid" - }, - "required": true - }, - { - "in": "path", - "name": "pairing_id", + "name": "makerspace_id", "schema": { - "type": "string", - "format": "uuid" + "type": "integer" }, "required": true } ], "tags": [ - "Tenant migration" + "Admin memberships" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CutoverReceiptRequest" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/CutoverReceiptRequest" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/CutoverReceiptRequest" - } - } - }, - "required": true - }, "security": [ { "jwtAuth": [] @@ -22880,7 +23100,10 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CutoverOutcome" + "type": "array", + "items": { + "$ref": "#/components/schemas/MembershipList" + } } } }, @@ -22890,111 +23113,88 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/FieldValidationError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Field-keyed validation errors." + "description": "" }, - "404": { + "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Not found." + "description": "" }, - "409": { + "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "State conflict." + "description": "" }, - "401": { + "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Authentication required." + "description": "" }, - "403": { + "409": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Superadmin access required." - }, - "429": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } - } - }, - "description": "Throttle limit exceeded." - }, - "500": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } - } - }, - "description": "Unexpected migration failure." + "description": "" } } - } - }, - "/api/v1/admin/platform/tenant-migrations/imports/{job_id}/run": { + }, "post": { - "operationId": "api_v1_admin_platform_tenant_migrations_imports_run_create", - "summary": "Run an identity-decided tenant import", + "operationId": "api_v1_admin_makerspaces_memberships_create", "parameters": [ { "in": "path", - "name": "job_id", + "name": "makerspace_id", "schema": { - "type": "string", - "format": "uuid" + "type": "integer" }, "required": true } ], "tags": [ - "Tenant migration" + "Admin memberships" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ImportRun" + "$ref": "#/components/schemas/MembershipCreate" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/ImportRun" + "$ref": "#/components/schemas/MembershipCreate" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/ImportRun" + "$ref": "#/components/schemas/MembershipCreate" } } - } + }, + "required": true }, "security": [ { @@ -23002,11 +23202,11 @@ } ], "responses": { - "202": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ImportJob" + "$ref": "#/components/schemas/MembershipList" } } }, @@ -23016,96 +23216,98 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/FieldValidationError" - } - } - }, - "description": "Field-keyed validation errors." - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } - } - }, - "description": "State conflict." - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Not found." - }, - "503": { - "description": "Import worker unavailable." + "description": "" }, "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Authentication required." + "description": "" }, "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Superadmin access required." + "description": "" }, - "429": { + "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Throttle limit exceeded." + "description": "" }, - "500": { + "409": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Unexpected migration failure." + "description": "" } } } }, - "/api/v1/admin/platform/tenant-migrations/imports/{job_id}/verification": { - "get": { - "operationId": "api_v1_admin_platform_tenant_migrations_imports_verification_retrieve", - "summary": "Read the import verification report", + "/api/v1/admin/makerspaces/{makerspace_id}/memberships/{membership_id}/role": { + "patch": { + "operationId": "api_v1_admin_makerspaces_memberships_role_partial_update", "parameters": [ { "in": "path", - "name": "job_id", + "name": "makerspace_id", "schema": { - "type": "string", - "format": "uuid" + "type": "integer" + }, + "required": true + }, + { + "in": "path", + "name": "membership_id", + "schema": { + "type": "integer" }, "required": true } ], "tags": [ - "Tenant migration" + "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": [] @@ -23116,81 +23318,80 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/VerificationReport" + "$ref": "#/components/schemas/MembershipList" } } }, "description": "" }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" - } - } - }, - "description": "Not found." - }, - "409": { + "400": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "State conflict." + "description": "" }, "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Authentication required." + "description": "" }, "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Superadmin access required." + "description": "" }, - "429": { + "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Throttle limit exceeded." + "description": "" }, - "500": { + "409": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Unexpected migration failure." + "description": "" } } } }, - "/api/v1/admin/platform/tenant-migrations/pairings": { + "/api/v1/admin/makerspaces/{makerspace_id}/roles": { "get": { - "operationId": "api_v1_admin_platform_tenant_migrations_pairings_list", - "summary": "List pinned migration pairings", + "operationId": "api_v1_admin_makerspaces_roles_list", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], "tags": [ - "Tenant migration" + "Admin roles" ], "security": [ { @@ -23204,76 +23405,95 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/Pairing" + "$ref": "#/components/schemas/Role" } } } }, "description": "" }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Authentication required." + "description": "" }, "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Superadmin access required." + "description": "" }, - "429": { + "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Throttle limit exceeded." + "description": "" }, - "500": { + "409": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Unexpected migration failure." + "description": "" } } }, "post": { - "operationId": "api_v1_admin_platform_tenant_migrations_pairings_create", - "summary": "Approve and pin source/target deployment identities", + "operationId": "api_v1_admin_makerspaces_roles_create", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], "tags": [ - "Tenant migration" + "Admin roles" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PairingCreate" + "$ref": "#/components/schemas/RoleCreate" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/PairingCreate" + "$ref": "#/components/schemas/RoleCreate" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/PairingCreate" + "$ref": "#/components/schemas/RoleCreate" } } }, @@ -23289,7 +23509,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Pairing" + "$ref": "#/components/schemas/Role" } } }, @@ -23299,71 +23519,78 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/FieldValidationError" - } - } - }, - "description": "Field-keyed validation errors." - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "State conflict." + "description": "" }, "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Authentication required." + "description": "" }, "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Superadmin access required." + "description": "" }, - "429": { + "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Throttle limit exceeded." + "description": "" }, - "500": { + "409": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypedError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Unexpected migration failure." + "description": "" } } } }, - "/api/v1/admin/platform/update-settings": { + "/api/v1/admin/makerspaces/{makerspace_id}/roles/{role_id}": { "get": { - "operationId": "api_v1_admin_platform_update_settings_retrieve", - "summary": "Retrieve or update automatic production update settings", + "operationId": "api_v1_admin_makerspaces_roles_retrieve", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + }, + { + "in": "path", + "name": "role_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], "tags": [ - "Platform" + "Admin roles" ], "security": [ { @@ -23375,92 +23602,78 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PlatformUpdateSettings" + "$ref": "#/components/schemas/Role" } } }, "description": "" - } - } - }, - "patch": { - "operationId": "api_v1_admin_platform_update_settings_partial_update", - "summary": "Retrieve or update automatic production update settings", - "tags": [ - "Platform" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PatchedPlatformUpdateSettings" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } } }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/PatchedPlatformUpdateSettings" + "description": "" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } } }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/PatchedPlatformUpdateSettings" + "description": "" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } } - } - } - }, - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { + }, + "description": "" + }, + "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PlatformUpdateSettings" + "$ref": "#/components/schemas/HardwareRequestError" } } }, "description": "" - } - } - } - }, - "/api/v1/admin/platform/update-settings/update-now": { - "post": { - "operationId": "api_v1_admin_platform_update_settings_update_now_create", - "summary": "Queue the latest production release for installation", - "tags": [ - "Platform" - ], - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "202": { + }, + "409": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PlatformUpdateSettings" + "$ref": "#/components/schemas/HardwareRequestError" } } }, "description": "" } } - } - }, - "/api/v1/admin/products/{id}/assets/generate": { - "post": { - "operationId": "api_v1_admin_products_assets_generate_create", - "summary": "Generate asset units", + }, + "patch": { + "operationId": "api_v1_admin_makerspaces_roles_partial_update", "parameters": [ { "in": "path", - "name": "id", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + }, + { + "in": "path", + "name": "role_id", "schema": { "type": "integer" }, @@ -23468,27 +23681,26 @@ } ], "tags": [ - "Asset units" + "Admin roles" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AssetGenerate" + "$ref": "#/components/schemas/PatchedRoleWrite" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/AssetGenerate" + "$ref": "#/components/schemas/PatchedRoleWrite" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/AssetGenerate" + "$ref": "#/components/schemas/PatchedRoleWrite" } } - }, - "required": true + } }, "security": [ { @@ -23496,27 +23708,82 @@ } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AssetGenerateResult" + "$ref": "#/components/schemas/Role" + } + } + }, + "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/qr-print-batches/{id}": { - "get": { - "operationId": "api_v1_admin_qr_print_batches_retrieve", - "summary": "Retrieve QR print batch", + }, + "delete": { + "operationId": "api_v1_admin_makerspaces_roles_destroy", "parameters": [ { "in": "path", - "name": "id", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + }, + { + "in": "path", + "name": "role_id", "schema": { "type": "integer" }, @@ -23524,7 +23791,7 @@ } ], "tags": [ - "QR print batches" + "Admin roles" ], "security": [ { @@ -23532,27 +23799,78 @@ } ], "responses": { - "200": { + "204": { + "description": "No response body" + }, + "400": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/QrPrintBatchDetail" + "$ref": "#/components/schemas/HardwareRequestError" } } }, "description": "" - } - } - } - }, - "/api/v1/admin/qr-print-batches/{id}/download": { - "get": { - "operationId": "api_v1_admin_qr_print_batches_download_retrieve", - "summary": "Download QR print batch as a ZIP of captioned SVGs", - "parameters": [ - { - "in": "path", - "name": "id", + }, + "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/{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.", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + }, + { + "in": "path", + "name": "role_id", "schema": { "type": "integer" }, @@ -23560,7 +23878,7 @@ } ], "tags": [ - "QR print batches" + "Admin roles" ], "security": [ { @@ -23570,26 +23888,81 @@ "responses": { "200": { "content": { - "application/zip": { + "application/json": { "schema": { - "type": "string", - "format": "binary" + "$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/qr-print-batches/{id}/items": { - "post": { - "operationId": "api_v1_admin_qr_print_batches_items_create", - "summary": "Add QR code to print batch", + }, + "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": "id", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + }, + { + "in": "path", + "name": "role_id", "schema": { "type": "integer" }, @@ -23597,23 +23970,23 @@ } ], "tags": [ - "QR print batches" + "Admin roles" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/QrPrintBatchItemCreate" + "$ref": "#/components/schemas/RoleMachineScopeWrite" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/QrPrintBatchItemCreate" + "$ref": "#/components/schemas/RoleMachineScopeWrite" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/QrPrintBatchItemCreate" + "$ref": "#/components/schemas/RoleMachineScopeWrite" } } }, @@ -23625,11 +23998,61 @@ } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/QrPrintBatchItemResult" + "$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" } } }, @@ -23638,14 +24061,13 @@ } } }, - "/api/v1/admin/qr/{id}/print": { + "/api/v1/admin/makerspaces/{makerspace_id}/roles/capabilities": { "get": { - "operationId": "api_v1_admin_qr_print_retrieve", - "summary": "Render QR label SVG", + "operationId": "api_v1_admin_makerspaces_roles_capabilities_list", "parameters": [ { "in": "path", - "name": "id", + "name": "makerspace_id", "schema": { "type": "integer" }, @@ -23653,7 +24075,7 @@ } ], "tags": [ - "QR assets" + "Admin roles" ], "security": [ { @@ -23662,78 +24084,79 @@ ], "responses": { "200": { - "description": "SVG QR label." - } - } - } - }, - "/api/v1/admin/qr/{id}/rebind-target": { - "post": { - "operationId": "api_v1_admin_qr_rebind_target_create", - "summary": "Rebind a saved QR to another product/asset (cross-makerspace = superadmin) and optionally rename", - "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Capability" + } + } + } }, - "required": true - } - ], - "tags": [ - "QR assets" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/QrRebindTarget" + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } } }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/QrRebindTarget" + "description": "" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } } }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/QrRebindTarget" + "description": "" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } } - } + }, + "description": "" }, - "required": true - }, - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { + "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/QrRebindResult" + "$ref": "#/components/schemas/HardwareRequestError" } } }, "description": "" }, "409": { - "description": "Outstanding loan or target QR conflict." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" } } } }, - "/api/v1/admin/qr/{id}/revoke": { - "post": { - "operationId": "api_v1_admin_qr_revoke_create", - "summary": "Revoke active QR code", + "/api/v1/admin/makerspaces/{makerspace_id}/spaces/": { + "get": { + "operationId": "api_v1_admin_makerspaces_spaces_retrieve", + "summary": "List bookable spaces in a makerspace", "parameters": [ { "in": "path", - "name": "id", + "name": "makerspace_id", "schema": { "type": "integer" }, @@ -23741,7 +24164,7 @@ } ], "tags": [ - "QR assets" + "Admin bookings" ], "security": [ { @@ -23753,48 +24176,65 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/QrCode" + "$ref": "#/components/schemas/BookableSpaceListResponse" } } }, "description": "" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Permission denied." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Not found." } } - } - }, - "/api/v1/admin/qr/boxes": { + }, "post": { - "operationId": "api_v1_admin_qr_boxes_create", - "summary": "Create a QR-coded box", + "operationId": "api_v1_admin_makerspaces_spaces_create", + "summary": "Create a bookable space", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], "tags": [ - "QR assets" + "Admin bookings" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateBoxQr" - }, - "examples": { - "CreateAQR-codedBox": { - "value": { - "makerspace_id": 1, - "label": "Electronics Box A", - "location": "Bench Storage", - "description": "Issued hardware kit box" - }, - "summary": "Create a QR-coded box" - } + "$ref": "#/components/schemas/BookableSpaceWrite" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/CreateBoxQr" + "$ref": "#/components/schemas/BookableSpaceWrite" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/CreateBoxQr" + "$ref": "#/components/schemas/BookableSpaceWrite" } } }, @@ -23810,48 +24250,77 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Box" + "$ref": "#/components/schemas/BookableSpaceAdmin" } } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Invalid request." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Permission denied." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Not found." } } } }, - "/api/v1/admin/qr/containers": { + "/api/v1/admin/makerspaces/{makerspace_id}/uploads/evidence-url": { "post": { - "operationId": "api_v1_admin_qr_containers_create", - "summary": "Create a QR-coded box", + "operationId": "api_v1_admin_makerspaces_uploads_evidence_url_create", + "description": "Base for ALL staff endpoints: authenticated + active staff + auto-scoped queryset.\n\nFuture phases subclass this so the invariant 'every staff query is makerspace-scoped'\nis enforced by default rather than by remembering to add a mixin (review fix #4). Add\n`required_action` + `HasMakerspaceAction` to a subclass for per-action checks.", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], "tags": [ - "QR assets" + "api" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateBoxQr" - }, - "examples": { - "CreateAQR-codedBox": { - "value": { - "makerspace_id": 1, - "label": "Electronics Box A", - "location": "Bench Storage", - "description": "Issued hardware kit box" - }, - "summary": "Create a QR-coded box" - } + "$ref": "#/components/schemas/EvidenceUrlRequest" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/CreateBoxQr" + "$ref": "#/components/schemas/EvidenceUrlRequest" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/CreateBoxQr" + "$ref": "#/components/schemas/EvidenceUrlRequest" } } }, @@ -23867,50 +24336,58 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Box" + "$ref": "#/components/schemas/EvidenceUrlResponse" } } }, "description": "" + }, + "400": { + "description": "Invalid evidence upload request." + }, + "403": { + "description": "Insufficient makerspace permission." + }, + "503": { + "description": "Evidence storage is unavailable." } } } }, - "/api/v1/admin/qr/resolve": { - "post": { - "operationId": "api_v1_admin_qr_resolve_create", - "description": "Resolve the opaque payload encoded in a physical QR label to its target (box, product, or asset) and the scanner actions the caller may take. `payload` is the raw 32-char lowercase hex token printed on the label (Python `uuid4().hex`, e.g. `3f9a1c2b4d5e6f7081920a1b2c3d4e5f`) - it is the value stored as `QrCode.payload` (and `Box.code` for boxes), not a URL or JSON. Resolving a QR also records an immutable scanner-lookup scan event.", - "summary": "Resolve QR target and scanner allowed actions", + "/api/v1/admin/makerspaces/{makerspace_id}/waiver": { + "put": { + "operationId": "api_v1_admin_makerspaces_waiver_update", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], "tags": [ - "QR assets" + "Admin memberships" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/QrResolve" - }, - "examples": { - "ResolveAScannedQRPayload": { - "value": { - "payload": "3f9a1c2b4d5e6f7081920a1b2c3d4e5f" - }, - "summary": "Resolve a scanned QR payload" - } + "$ref": "#/components/schemas/WaiverPublish" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/QrResolve" + "$ref": "#/components/schemas/WaiverPublish" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/QrResolve" + "$ref": "#/components/schemas/WaiverPublish" } } - }, - "required": true + } }, "security": [ { @@ -23922,314 +24399,73 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/QrResolveResult" - }, - "examples": { - "ResolvedBoxQR": { - "value": { - "qr": { - "id": 12, - "makerspace": 1, - "payload": "3f9a1c2b4d5e6f7081920a1b2c3d4e5f", - "target_type": "box", - "target_id": 5, - "status": "active", - "created_at": "2026-06-27T09:30:00Z", - "updated_at": "2026-06-27T09:30:00Z", - "revoked_at": null - }, - "target": { - "type": "box", - "id": 5, - "label": "Electronics Box A", - "code": "3f9a1c2b4d5e6f7081920a1b2c3d4e5f" - }, - "allowed_actions": [ - "contents", - "move_container", - "record_scan", - "revoke", - "view" - ] - }, - "summary": "Resolved box QR" - } + "$ref": "#/components/schemas/WaiverPublish" } } }, "description": "" - } - } - } - }, - "/api/v1/admin/qr/scan": { - "post": { - "operationId": "api_v1_admin_qr_scan_create", - "description": "Context is limited to issue or return and scan events are immutable.", - "summary": "Record a QR scan", - "tags": [ - "QR assets" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/QrScan" - }, - "examples": { - "ScanQRDuringIssue": { - "value": { - "payload": "3f9a1c2b4d5e6f7081920a1b2c3d4e5f", - "context": "issue", - "request_id": 99 - }, - "summary": "Scan QR during issue" - } - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/QrScan" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/QrScan" - } - } }, - "required": true - }, - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "201": { + "400": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/QrScanResult" + "$ref": "#/components/schemas/HardwareRequestError" } } }, "description": "" - } - } - } - }, - "/api/v1/admin/qr/tools": { - "post": { - "operationId": "api_v1_admin_qr_tools_create", - "summary": "Create or reuse a QR code for a product or asset", - "tags": [ - "QR assets" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateToolQr" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/CreateToolQr" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/CreateToolQr" - } - } }, - "required": true - }, - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "201": { + "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/QrCode" - } - } - }, - "description": "" - } - } - } - }, - "/api/v1/admin/reports/{report_key}/export": { - "get": { - "operationId": "api_v1_admin_reports_export_retrieve", - "summary": "Export aggregate report", - "parameters": [ - { - "in": "query", - "name": "end", - "schema": { - "type": "string", - "format": "date" - } - }, - { - "in": "query", - "name": "format", - "schema": { - "type": "string", - "enum": [ - "csv", - "xlsx" - ] - } - }, - { - "in": "path", - "name": "report_key", - "schema": { - "type": "string", - "enum": [ - "active-loans", - "booking-utilization", - "damaged-lost", - "damaged-missing", - "event-attendance", - "fablab-health", - "machine-service", - "machine-usage", - "maintenance-activity", - "member-activity", - "most-lent", - "payment-reconciliation", - "printer-service", - "qr-scans", - "recently-added", - "returns", - "summary", - "taken-items", - "top-borrowers" - ] - }, - "required": true - }, - { - "in": "query", - "name": "start", - "schema": { - "type": "string", - "format": "date" - } - }, - { - "in": "query", - "name": "status", - "schema": { - "type": "string", - "enum": [ - "canceled", - "paid_offline", - "paid_online", - "pending", - "waived" - ] - } - }, - { - "in": "query", - "name": "subject_type", - "schema": { - "type": "string", - "enum": [ - "booking", - "event_registration", - "machine_service_request", - "makerspace_membership" - ] - } - } - ], - "tags": [ - "Reports" - ], - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { - "content": { - "text/csv": { - "schema": { - "type": "string" - } - }, - "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": { - "schema": { - "type": "string", - "format": "binary" + "$ref": "#/components/schemas/HardwareRequestError" } } }, "description": "" }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportError" - } - } - }, - "description": "Invalid report request." - }, - "401": { + "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Authentication required." + "description": "" }, - "403": { + "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Permission denied." + "description": "" }, - "404": { + "409": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReportError" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Makerspace or report not found." + "description": "" } } } }, - "/api/v1/admin/requests/{id}/accept": { + "/api/v1/admin/makerspaces/{makerspace_id}/walk-in-members": { "post": { - "operationId": "api_v1_admin_requests_accept_create", - "summary": "Accept borrow request", + "operationId": "api_v1_admin_makerspaces_walk_in_members_create", + "summary": "Create a walk-in member record", "parameters": [ { "in": "path", - "name": "id", + "name": "makerspace_id", "schema": { "type": "integer" }, @@ -24237,26 +24473,27 @@ } ], "tags": [ - "Admin requests" + "Admin memberships" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AcceptRequest" + "$ref": "#/components/schemas/WalkInMemberCreate" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/AcceptRequest" + "$ref": "#/components/schemas/WalkInMemberCreate" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/AcceptRequest" + "$ref": "#/components/schemas/WalkInMemberCreate" } } - } + }, + "required": true }, "security": [ { @@ -24264,11 +24501,11 @@ } ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminRequest" + "$ref": "#/components/schemas/DirectLoanMember" } } }, @@ -24282,7 +24519,17 @@ } } }, - "description": "Invalid request." + "description": "" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" }, "403": { "content": { @@ -24292,7 +24539,7 @@ } } }, - "description": "Permission denied." + "description": "" }, "404": { "content": { @@ -24302,7 +24549,7 @@ } } }, - "description": "Not found." + "description": "" }, "409": { "content": { @@ -24312,15 +24559,15 @@ } } }, - "description": "Workflow conflict." + "description": "" } } } }, - "/api/v1/admin/requests/{id}/assign-box": { - "post": { - "operationId": "api_v1_admin_requests_assign_box_create", - "summary": "Assign box to accepted request", + "/api/v1/admin/makerspaces/{id}": { + "get": { + "operationId": "api_v1_admin_makerspaces_retrieve", + "summary": "Retrieve or update a makerspace", "parameters": [ { "in": "path", @@ -24332,27 +24579,60 @@ } ], "tags": [ - "Admin requests" + "Admin makerspaces" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Makerspace" + } + } + }, + "description": "" + } + } + }, + "patch": { + "operationId": "api_v1_admin_makerspaces_partial_update", + "summary": "Retrieve or update a makerspace", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Admin makerspaces" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AssignBox" + "$ref": "#/components/schemas/PatchedMakerspace" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/AssignBox" + "$ref": "#/components/schemas/PatchedMakerspace" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/AssignBox" + "$ref": "#/components/schemas/PatchedMakerspace" } } - }, - "required": true + } }, "security": [ { @@ -24364,7 +24644,43 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminRequest" + "$ref": "#/components/schemas/Makerspace" + } + } + }, + "description": "" + } + } + } + }, + "/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" } } }, @@ -24378,7 +24694,17 @@ } } }, - "description": "Invalid request." + "description": "" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" }, "403": { "content": { @@ -24388,7 +24714,7 @@ } } }, - "description": "Permission denied." + "description": "" }, "404": { "content": { @@ -24398,7 +24724,7 @@ } } }, - "description": "Not found." + "description": "" }, "409": { "content": { @@ -24408,15 +24734,14 @@ } } }, - "description": "Workflow conflict." + "description": "" } } } }, - "/api/v1/admin/requests/{id}/issue": { + "/api/v1/admin/membership-requests/{id}/approve": { "post": { - "operationId": "api_v1_admin_requests_issue_create", - "summary": "Issue accepted request", + "operationId": "api_v1_admin_membership_requests_approve_create", "parameters": [ { "in": "path", @@ -24428,23 +24753,23 @@ } ], "tags": [ - "Admin requests" + "Admin memberships" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/IssueRequest" + "$ref": "#/components/schemas/RoleId" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/IssueRequest" + "$ref": "#/components/schemas/RoleId" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/IssueRequest" + "$ref": "#/components/schemas/RoleId" } } }, @@ -24460,7 +24785,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminRequest" + "$ref": "#/components/schemas/AdminMembership" } } }, @@ -24474,9 +24799,9 @@ } } }, - "description": "Invalid request." + "description": "" }, - "403": { + "401": { "content": { "application/json": { "schema": { @@ -24484,9 +24809,9 @@ } } }, - "description": "Permission denied." + "description": "" }, - "404": { + "403": { "content": { "application/json": { "schema": { @@ -24494,9 +24819,9 @@ } } }, - "description": "Not found." + "description": "" }, - "409": { + "404": { "content": { "application/json": { "schema": { @@ -24504,9 +24829,9 @@ } } }, - "description": "Workflow conflict." + "description": "" }, - "503": { + "409": { "content": { "application/json": { "schema": { @@ -24514,15 +24839,14 @@ } } }, - "description": "Service unavailable." + "description": "" } } } }, - "/api/v1/admin/requests/{id}/reject": { + "/api/v1/admin/membership-requests/{id}/revoke": { "post": { - "operationId": "api_v1_admin_requests_reject_create", - "summary": "Reject borrow request", + "operationId": "api_v1_admin_membership_requests_revoke_create", "parameters": [ { "in": "path", @@ -24534,27 +24858,26 @@ } ], "tags": [ - "Admin requests" + "Admin memberships" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RejectRequest" + "$ref": "#/components/schemas/Revoke" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/RejectRequest" + "$ref": "#/components/schemas/Revoke" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/RejectRequest" + "$ref": "#/components/schemas/Revoke" } } - }, - "required": true + } }, "security": [ { @@ -24566,7 +24889,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminRequest" + "$ref": "#/components/schemas/MembershipRequest" } } }, @@ -24580,7 +24903,17 @@ } } }, - "description": "Invalid request." + "description": "" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" }, "403": { "content": { @@ -24590,7 +24923,7 @@ } } }, - "description": "Permission denied." + "description": "" }, "404": { "content": { @@ -24600,7 +24933,7 @@ } } }, - "description": "Not found." + "description": "" }, "409": { "content": { @@ -24610,48 +24943,28 @@ } } }, - "description": "Workflow conflict." + "description": "" } } } }, - "/api/v1/admin/requests/{id}/return": { - "post": { - "operationId": "api_v1_admin_requests_return_create", - "summary": "Return issued request items", + "/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 requests" + "Admin memberships" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReturnRequest" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/ReturnRequest" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/ReturnRequest" - } - } - }, - "required": true - }, "security": [ { "jwtAuth": [] @@ -24662,7 +24975,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminRequest" + "$ref": "#/components/schemas/PaginatedAdminMembershipList" } } }, @@ -24676,9 +24989,9 @@ } } }, - "description": "Invalid request." + "description": "" }, - "403": { + "401": { "content": { "application/json": { "schema": { @@ -24686,9 +24999,9 @@ } } }, - "description": "Permission denied." + "description": "" }, - "404": { + "403": { "content": { "application/json": { "schema": { @@ -24696,9 +25009,9 @@ } } }, - "description": "Not found." + "description": "" }, - "409": { + "404": { "content": { "application/json": { "schema": { @@ -24706,9 +25019,9 @@ } } }, - "description": "Workflow conflict." + "description": "" }, - "503": { + "409": { "content": { "application/json": { "schema": { @@ -24716,15 +25029,16 @@ } } }, - "description": "Service unavailable." + "description": "" } } } }, - "/api/v1/admin/requests/{id}/return-due": { - "post": { - "operationId": "api_v1_admin_requests_return_due_create", - "summary": "Set request return due time", + "/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", @@ -24736,27 +25050,54 @@ } ], "tags": [ - "Admin requests" + "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", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Admin memberships" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReturnDue" + "$ref": "#/components/schemas/PatchedMembershipCapabilities" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/ReturnDue" + "$ref": "#/components/schemas/PatchedMembershipCapabilities" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/ReturnDue" + "$ref": "#/components/schemas/PatchedMembershipCapabilities" } } - }, - "required": true + } }, "security": [ { @@ -24768,7 +25109,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminRequest" + "$ref": "#/components/schemas/AdminMembership" } } }, @@ -24782,7 +25123,17 @@ } } }, - "description": "Invalid request." + "description": "" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" }, "403": { "content": { @@ -24792,7 +25143,7 @@ } } }, - "description": "Permission denied." + "description": "" }, "404": { "content": { @@ -24802,7 +25153,7 @@ } } }, - "description": "Not found." + "description": "" }, "409": { "content": { @@ -24812,15 +25163,14 @@ } } }, - "description": "Workflow conflict." + "description": "" } } } }, - "/api/v1/admin/requests/{id}/timeline": { - "get": { - "operationId": "api_v1_admin_requests_timeline_retrieve", - "summary": "Read-only immutable timeline for one hardware request", + "/api/v1/admin/memberships/{id}/revoke": { + "post": { + "operationId": "api_v1_admin_memberships_revoke_create", "parameters": [ { "in": "path", @@ -24829,19 +25179,30 @@ "type": "integer" }, "required": true - }, - { - "in": "query", - "name": "limit", - "schema": { - "type": "integer" - }, - "description": "Maximum history events to return. Defaults to 200; capped at 500." } ], "tags": [ - "Admin requests" + "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": [] @@ -24852,7 +25213,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RequestTimelineResponse" + "$ref": "#/components/schemas/AdminMembership" } } }, @@ -24866,19 +25227,9 @@ } } }, - "description": "Invalid request." - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Permission denied." + "description": "" }, - "404": { + "401": { "content": { "application/json": { "schema": { @@ -24886,9 +25237,9 @@ } } }, - "description": "Not found." + "description": "" }, - "409": { + "403": { "content": { "application/json": { "schema": { @@ -24896,45 +25247,9 @@ } } }, - "description": "Workflow conflict." - } - } - } - }, - "/api/v1/admin/spaces/{id}/": { - "get": { - "operationId": "api_v1_admin_spaces_retrieve", - "summary": "Retrieve a bookable space", - "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" - }, - "required": true - } - ], - "tags": [ - "Admin bookings" - ], - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BookableSpaceAdmin" - } - } - }, "description": "" }, - "403": { + "404": { "content": { "application/json": { "schema": { @@ -24942,9 +25257,9 @@ } } }, - "description": "Permission denied." + "description": "" }, - "404": { + "409": { "content": { "application/json": { "schema": { @@ -24952,13 +25267,14 @@ } } }, - "description": "Not found." + "description": "" } } - }, + } + }, + "/api/v1/admin/memberships/{id}/role": { "patch": { - "operationId": "api_v1_admin_spaces_partial_update", - "summary": "Update a bookable space", + "operationId": "api_v1_admin_memberships_role_partial_update", "parameters": [ { "in": "path", @@ -24970,23 +25286,23 @@ } ], "tags": [ - "Admin bookings" + "Admin memberships" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PatchedBookableSpaceWrite" + "$ref": "#/components/schemas/PatchedRoleId" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/PatchedBookableSpaceWrite" + "$ref": "#/components/schemas/PatchedRoleId" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/PatchedBookableSpaceWrite" + "$ref": "#/components/schemas/PatchedRoleId" } } } @@ -25001,7 +25317,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BookableSpaceAdmin" + "$ref": "#/components/schemas/AdminMembership" } } }, @@ -25015,7 +25331,17 @@ } } }, - "description": "Invalid request." + "description": "" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" }, "403": { "content": { @@ -25025,7 +25351,7 @@ } } }, - "description": "Permission denied." + "description": "" }, "404": { "content": { @@ -25035,7 +25361,7 @@ } } }, - "description": "Not found." + "description": "" }, "409": { "content": { @@ -25045,15 +25371,14 @@ } } }, - "description": "Workflow conflict." + "description": "" } } } }, - "/api/v1/admin/spaces/{id}/booking-rules/": { - "get": { - "operationId": "api_v1_admin_spaces_booking_rules_retrieve", - "summary": "Retrieve booking rules for a space", + "/api/v1/admin/memberships/{id}/unverify": { + "post": { + "operationId": "api_v1_admin_memberships_unverify_create", "parameters": [ { "in": "path", @@ -25065,7 +25390,7 @@ } ], "tags": [ - "Admin bookings" + "Admin memberships" ], "security": [ { @@ -25077,24 +25402,13 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BookableSpaceBookingRules" + "$ref": "#/components/schemas/AdminMembership" } } }, "description": "" }, "400": { - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": {} - } - } - }, - "description": "Invalid booking-rule values or bookings module disabled." - }, - "403": { "content": { "application/json": { "schema": { @@ -25102,9 +25416,9 @@ } } }, - "description": "Permission denied." + "description": "" }, - "404": { + "401": { "content": { "application/json": { "schema": { @@ -25112,72 +25426,8 @@ } } }, - "description": "Not found." - } - } - }, - "patch": { - "operationId": "api_v1_admin_spaces_booking_rules_partial_update", - "summary": "Update booking rules for a space", - "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" - }, - "required": true - } - ], - "tags": [ - "Admin bookings" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PatchedBookableSpaceBookingRules" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/PatchedBookableSpaceBookingRules" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/PatchedBookableSpaceBookingRules" - } - } - } - }, - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BookableSpaceBookingRules" - } - } - }, "description": "" }, - "400": { - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": {} - } - } - }, - "description": "Invalid booking-rule values or bookings module disabled." - }, "403": { "content": { "application/json": { @@ -25186,7 +25436,7 @@ } } }, - "description": "Permission denied." + "description": "" }, "404": { "content": { @@ -25196,7 +25446,7 @@ } } }, - "description": "Not found." + "description": "" }, "409": { "content": { @@ -25206,24 +25456,15 @@ } } }, - "description": "Workflow conflict." + "description": "" } } } }, - "/api/v1/admin/spaces/{id}/bookings/": { - "get": { - "operationId": "api_v1_admin_spaces_bookings_retrieve", - "summary": "List bookings for a space", + "/api/v1/admin/memberships/{id}/verify": { + "post": { + "operationId": "api_v1_admin_memberships_verify_create", "parameters": [ - { - "in": "query", - "name": "ends_at", - "schema": { - "type": "string", - "format": "date-time" - } - }, { "in": "path", "name": "id", @@ -25231,35 +25472,10 @@ "type": "integer" }, "required": true - }, - { - "in": "query", - "name": "starts_at", - "schema": { - "type": "string", - "format": "date-time" - } - }, - { - "in": "query", - "name": "status", - "schema": { - "enum": [ - "pending", - "confirmed", - "rejected", - "cancelled", - "completed", - "no_show" - ], - "type": "string", - "minLength": 1 - }, - "description": "* `pending` - Pending\n* `confirmed` - Confirmed\n* `rejected` - Rejected\n* `cancelled` - Cancelled\n* `completed` - Completed\n* `no_show` - No-show" } ], "tags": [ - "Admin bookings" + "Admin memberships" ], "security": [ { @@ -25271,7 +25487,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BookingListResponse" + "$ref": "#/components/schemas/AdminMembership" } } }, @@ -25285,7 +25501,17 @@ } } }, - "description": "Invalid request." + "description": "" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" }, "403": { "content": { @@ -25295,7 +25521,7 @@ } } }, - "description": "Permission denied." + "description": "" }, "404": { "content": { @@ -25305,15 +25531,24 @@ } } }, - "description": "Not found." + "description": "" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" } } } }, - "/api/v1/admin/spaces/{id}/deactivate/": { + "/api/v1/admin/memberships/{id}/waiver/witness": { "post": { - "operationId": "api_v1_admin_spaces_deactivate_create", - "summary": "Deactivate a bookable space", + "operationId": "api_v1_admin_memberships_waiver_witness_create", "parameters": [ { "in": "path", @@ -25325,7 +25560,7 @@ } ], "tags": [ - "Admin bookings" + "Admin memberships" ], "security": [ { @@ -25337,7 +25572,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BookableSpaceAdmin" + "$ref": "#/components/schemas/WitnessWaiverResponse" } } }, @@ -25351,7 +25586,17 @@ } } }, - "description": "Invalid request." + "description": "" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" }, "403": { "content": { @@ -25361,7 +25606,7 @@ } } }, - "description": "Permission denied." + "description": "" }, "404": { "content": { @@ -25371,7 +25616,7 @@ } } }, - "description": "Not found." + "description": "" }, "409": { "content": { @@ -25381,15 +25626,15 @@ } } }, - "description": "Workflow conflict." + "description": "" } } } }, - "/api/v1/admin/spaces/{id}/image/": { + "/api/v1/admin/organization-invitations/{id}/": { "delete": { - "operationId": "api_v1_admin_spaces_image_destroy", - "summary": "Delete a space image", + "operationId": "api_v1_admin_organization_invitations_destroy", + "summary": "Revoke an unused organization invitation", "parameters": [ { "in": "path", @@ -25401,7 +25646,7 @@ } ], "tags": [ - "Admin bookings" + "Admin organizations" ], "security": [ { @@ -25412,7 +25657,7 @@ "204": { "description": "No response body" }, - "400": { + "401": { "content": { "application/json": { "schema": { @@ -25420,7 +25665,7 @@ } } }, - "description": "Invalid request." + "description": "Authentication is required." }, "403": { "content": { @@ -25430,7 +25675,7 @@ } } }, - "description": "Permission denied." + "description": "Organization authority is required." }, "404": { "content": { @@ -25440,9 +25685,12 @@ } } }, - "description": "Not found." + "description": "Organization not found." }, - "503": { + "400": { + "description": "Invalid organization data." + }, + "409": { "content": { "application/json": { "schema": { @@ -25450,48 +25698,18 @@ } } }, - "description": "Service unavailable." + "description": "Organization state conflict." } } } }, - "/api/v1/admin/spaces/{id}/image/finalize/": { - "post": { - "operationId": "api_v1_admin_spaces_image_finalize_create", - "summary": "Finalize and attach a space image", - "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" - }, - "required": true - } - ], + "/api/v1/admin/organizations/": { + "get": { + "operationId": "admin_organizations_list", + "summary": "List organizations visible to the actor", "tags": [ - "Admin bookings" + "Admin organizations" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SpaceImageFinalizeRequest" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/SpaceImageFinalizeRequest" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/SpaceImageFinalizeRequest" - } - } - }, - "required": true - }, "security": [ { "jwtAuth": [] @@ -25502,13 +25720,13 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BookableSpaceAdmin" + "$ref": "#/components/schemas/OrganizationList" } } }, "description": "" }, - "400": { + "401": { "content": { "application/json": { "schema": { @@ -25516,7 +25734,7 @@ } } }, - "description": "Invalid request." + "description": "Authentication is required." }, "403": { "content": { @@ -25526,7 +25744,7 @@ } } }, - "description": "Permission denied." + "description": "Organization authority is required." }, "404": { "content": { @@ -25536,69 +25754,118 @@ } } }, - "description": "Not found." - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Service unavailable." + "description": "Organization not found." } } } }, - "/api/v1/admin/spaces/{id}/image/presign/": { - "post": { - "operationId": "api_v1_admin_spaces_image_presign_create", - "summary": "Create a space image upload URL", + "/api/v1/admin/organizations/{organization_id}/analytics/{report_key}": { + "get": { + "operationId": "api_v1_admin_organizations_analytics_retrieve", + "description": "Requires an active organization membership carrying the report's action. Authorization and the owned-makerspace set are resolved server-side; a combined total is always returned together with its per-makerspace breakdown.", + "summary": "Get organization analytics report", "parameters": [ + { + "in": "query", + "name": "end", + "schema": { + "type": "string", + "format": "date" + } + }, + { + "in": "query", + "name": "limit", + "schema": { + "type": "integer" + } + }, { "in": "path", - "name": "id", + "name": "organization_id", "schema": { "type": "integer" }, "required": true - } - ], - "tags": [ - "Admin bookings" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SpaceImagePresignRequest" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/SpaceImagePresignRequest" - } + }, + { + "in": "path", + "name": "report_key", + "schema": { + "type": "string", + "enum": [ + "active-loans", + "booking-utilization", + "damaged-lost", + "damaged-missing", + "event-attendance", + "evidence-compliance", + "fablab-health", + "machine-usage", + "maintenance-activity", + "member-activity", + "most-lent", + "payment-reconciliation", + "qr-scans", + "recently-added", + "returns", + "summary", + "taken-items", + "top-borrowers" + ] }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/SpaceImagePresignRequest" - } + "required": true + }, + { + "in": "query", + "name": "start", + "schema": { + "type": "string", + "format": "date" } }, - "required": true - }, + { + "in": "query", + "name": "status", + "schema": { + "type": "string", + "enum": [ + "canceled", + "paid_offline", + "paid_online", + "pending", + "waived" + ] + } + }, + { + "in": "query", + "name": "subject_type", + "schema": { + "type": "string", + "enum": [ + "booking", + "event_registration", + "machine_service_request", + "makerspace_membership" + ] + } + } + ], + "tags": [ + "Analytics" + ], "security": [ { "jwtAuth": [] } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SpaceImagePresignResponse" + "$ref": "#/components/schemas/OrganizationReportResponse" } } }, @@ -25608,49 +25875,49 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ReportError" } } }, - "description": "Invalid request." + "description": "Invalid or excluded organization report." }, - "403": { + "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ReportError" } } }, - "description": "Permission denied." + "description": "Authentication required." }, - "404": { + "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ReportError" } } }, - "description": "Not found." + "description": "Permission denied." }, - "503": { + "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ReportError" } } }, - "description": "Service unavailable." + "description": "Organization or report not found." } } } }, - "/api/v1/admin/stock-transfers/{id}": { + "/api/v1/admin/organizations/{id}/": { "get": { - "operationId": "api_v1_admin_stock_transfers_retrieve", - "summary": "Retrieve stock transfer", + "operationId": "admin_organizations_retrieve", + "summary": "Retrieve organization governance details", "parameters": [ { "in": "path", @@ -25662,7 +25929,7 @@ } ], "tags": [ - "Stock transfers" + "Admin organizations" ], "security": [ { @@ -25674,38 +25941,47 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/StockTransfer" + "$ref": "#/components/schemas/OrganizationDetail" } } }, "description": "" }, - "400": { + "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/GenericObject" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Invalid request." - }, - "401": { - "description": "Authentication credentials were not provided." + "description": "Authentication is required." }, "403": { - "description": "Permission denied." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Organization authority is required." }, "404": { - "description": "Not found." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Organization not found." } } - } - }, - "/api/v1/admin/stocktakes/{id}": { - "get": { - "operationId": "api_v1_admin_stocktakes_retrieve", - "summary": "Retrieve stocktake", + }, + "patch": { + "operationId": "api_v1_admin_organizations_partial_update", + "summary": "Update an organization public profile", "parameters": [ { "in": "path", @@ -25717,8 +25993,27 @@ } ], "tags": [ - "Stocktake" + "Admin organizations" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PatchedOrganizationProfileUpdate" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/PatchedOrganizationProfileUpdate" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/PatchedOrganizationProfileUpdate" + } + } + } + }, "security": [ { "jwtAuth": [] @@ -25729,38 +26024,62 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Stocktake" + "$ref": "#/components/schemas/OrganizationDetail" } } }, "description": "" }, - "400": { + "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/GenericObject" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Invalid request or stocktake state." - }, - "401": { - "description": "Authentication credentials were not provided." + "description": "Authentication is required." }, "403": { - "description": "Permission denied." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Organization authority is required." }, "404": { - "description": "Not found." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Organization not found." + }, + "400": { + "description": "Invalid organization data." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Organization state conflict." } } } }, - "/api/v1/admin/stocktakes/{id}/apply-adjustments": { - "post": { - "operationId": "api_v1_admin_stocktakes_apply_adjustments_create", - "summary": "Apply stocktake adjustments", + "/api/v1/admin/organizations/{id}/invitations/": { + "get": { + "operationId": "api_v1_admin_organizations_invitations_retrieve", + "summary": "List organization invitations without bearer tokens", "parameters": [ { "in": "path", @@ -25772,7 +26091,7 @@ } ], "tags": [ - "Stocktake" + "Admin organizations" ], "security": [ { @@ -25784,38 +26103,47 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Stocktake" + "$ref": "#/components/schemas/OrganizationInvitationList" } } }, "description": "" }, - "400": { + "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/GenericObject" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Invalid request or stocktake state." - }, - "401": { - "description": "Authentication credentials were not provided." + "description": "Authentication is required." }, "403": { - "description": "Permission denied." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Organization authority is required." }, "404": { - "description": "Not found." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Organization not found." } } - } - }, - "/api/v1/admin/stocktakes/{id}/approve": { + }, "post": { - "operationId": "api_v1_admin_stocktakes_approve_create", - "summary": "Approve stocktake", + "operationId": "api_v1_admin_organizations_invitations_create", + "summary": "Create a single-use organization invitation", "parameters": [ { "in": "path", @@ -25827,50 +26155,93 @@ } ], "tags": [ - "Stocktake" + "Admin organizations" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OrganizationInvitationCreate" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/OrganizationInvitationCreate" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/OrganizationInvitationCreate" + } + } + } + }, "security": [ { "jwtAuth": [] } ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Stocktake" + "$ref": "#/components/schemas/OrganizationInvitationCreated" } } }, "description": "" }, - "400": { + "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/GenericObject" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Invalid request or stocktake state." - }, - "401": { - "description": "Authentication credentials were not provided." + "description": "Authentication is required." }, "403": { - "description": "Permission denied." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Organization authority is required." }, "404": { - "description": "Not found." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Organization not found." + }, + "400": { + "description": "Invalid organization data." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Organization state conflict." } } } }, - "/api/v1/admin/stocktakes/{id}/complete": { - "post": { - "operationId": "api_v1_admin_stocktakes_complete_create", - "summary": "Complete stocktake", + "/api/v1/admin/organizations/{id}/memberships/": { + "get": { + "operationId": "api_v1_admin_organizations_memberships_retrieve", + "summary": "List organization memberships", "parameters": [ { "in": "path", @@ -25882,7 +26253,7 @@ } ], "tags": [ - "Stocktake" + "Admin organizations" ], "security": [ { @@ -25894,146 +26265,109 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Stocktake" + "$ref": "#/components/schemas/OrganizationMembershipList" } } }, "description": "" }, - "400": { + "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/GenericObject" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Invalid request or stocktake state." - }, - "401": { - "description": "Authentication credentials were not provided." + "description": "Authentication is required." }, "403": { - "description": "Permission denied." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Organization authority is required." }, "404": { - "description": "Not found." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Organization not found." } } } }, - "/api/v1/admin/stocktakes/{id}/count-lines": { - "post": { - "operationId": "api_v1_admin_stocktakes_count_lines_create", - "summary": "Count stocktake line", - "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" - }, - "required": true - } - ], + "/api/v1/admin/organized-events/": { + "get": { + "operationId": "api_v1_admin_organized_events_retrieve", + "description": "List the events this actor's organizations organize, across venues.\n\nOrganizer authority is deliberately per-event and grants nothing over the venue, so an\norganizer at a venue their organization is not linked to cannot use the venue's event\nlist -- which left the per-event endpoints reachable only by someone who already knew a\ndatabase id. This is the discoverable surface for that authority: it lists exactly the\nevents the organizer predicate matches and confers no venue authority whatsoever.", + "summary": "List events organized by the actor's organizations", "tags": [ - "Stocktake" + "Events" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/StocktakeLineInput" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/StocktakeLineInput" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/StocktakeLineInput" - } - } - }, - "required": true - }, "security": [ { "jwtAuth": [] } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/StocktakeLine" + "$ref": "#/components/schemas/EventListResponse" } } }, "description": "" }, - "400": { + "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/GenericObject" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Invalid request or stocktake state." - }, - "401": { - "description": "Authentication credentials were not provided." + "description": "" }, "403": { - "description": "Permission denied." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" }, "404": { - "description": "Not found." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" } } } }, - "/api/v1/admin/stocktakes/{id}/resolve-scan": { - "post": { - "operationId": "api_v1_admin_stocktakes_resolve_scan_create", - "summary": "Resolve a scanned QR to a stocktake count target", - "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" - }, - "required": true - } - ], + "/api/v1/admin/platform/backup-settings": { + "get": { + "operationId": "api_v1_admin_platform_backup_settings_retrieve", + "summary": "Get deployment backup settings", "tags": [ - "Stocktake" + "Backup" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/StocktakeScanInput" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/StocktakeScanInput" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/StocktakeScanInput" - } - } - }, - "required": true - }, "security": [ { "jwtAuth": [] @@ -26044,66 +26378,41 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/StocktakeScanResult" + "$ref": "#/components/schemas/PlatformBackupSettings" } } }, "description": "" }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/GenericObject" - } - } - }, - "description": "Invalid request or stocktake state." - }, "401": { - "description": "Authentication credentials were not provided." + "description": "Authentication is required." }, "403": { - "description": "Permission denied." - }, - "404": { - "description": "Not found." + "description": "The authenticated actor is not authorized." } } - } - }, - "/api/v1/admin/users/{id}/reset-password": { - "post": { - "operationId": "api_v1_admin_users_reset_password_create", - "summary": "Reset a staff user's password (temp + force change)", - "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" - }, - "required": true - } - ], + }, + "patch": { + "operationId": "api_v1_admin_platform_backup_settings_partial_update", + "summary": "Update deployment backup settings", "tags": [ - "Admin users" + "Backup" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ResetPasswordRequest" + "$ref": "#/components/schemas/PatchedPlatformBackupSettings" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/ResetPasswordRequest" + "$ref": "#/components/schemas/PatchedPlatformBackupSettings" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/ResetPasswordRequest" + "$ref": "#/components/schemas/PatchedPlatformBackupSettings" } } } @@ -26118,31 +26427,30 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ResetPasswordResponse" + "$ref": "#/components/schemas/PlatformBackupSettings" } } }, "description": "" + }, + "400": { + "description": "The request is invalid for the current lifecycle state." + }, + "401": { + "description": "Authentication is required." + }, + "403": { + "description": "The authenticated actor is not authorized." } } } }, - "/api/v1/admin/users/{id}/restore-access": { - "post": { - "operationId": "api_v1_admin_users_restore_access_create", - "summary": "Restore user access", - "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" - }, - "required": true - } - ], + "/api/v1/admin/platform/backups": { + "get": { + "operationId": "api_v1_admin_platform_backups_list", + "summary": "List full-deployment backup archives", "tags": [ - "Admin users" + "Backup" ], "security": [ { @@ -26154,97 +26462,63 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/User" + "type": "array", + "items": { + "$ref": "#/components/schemas/BackupArchive" + } } } }, "description": "" + }, + "401": { + "description": "Authentication is required." + }, + "403": { + "description": "The authenticated actor is not authorized." } - } - } - }, - "/api/v1/admin/users/{id}/restrict": { - "post": { - "operationId": "api_v1_admin_users_restrict_create", - "summary": "Restrict or suspend a user", - "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" - }, - "required": true - } - ], + } + }, + "post": { + "operationId": "api_v1_admin_platform_backups_create", + "summary": "Request an age-encrypted full-deployment backup", "tags": [ - "Admin users" + "Backup" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RestrictUser" - }, - "examples": { - "RestrictARequester": { - "value": { - "status": "restricted", - "reason": "Unreturned loan under review" - }, - "summary": "Restrict a requester" - } - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/RestrictUser" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/RestrictUser" - } - } - }, - "required": true - }, "security": [ { "jwtAuth": [] } ], "responses": { - "200": { + "202": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/User" + "$ref": "#/components/schemas/BackupArchive" } } }, "description": "" + }, + "503": { + "description": "The backup worker is unavailable." + }, + "401": { + "description": "Authentication is required." + }, + "403": { + "description": "The authenticated actor is not authorized." } } } }, - "/api/v1/admin/users/inventory-managers": { + "/api/v1/admin/platform/email-settings": { "get": { - "operationId": "api_v1_admin_users_inventory_managers_list", - "summary": "List or create staff memberships", - "parameters": [ - { - "name": "page", - "required": false, - "in": "query", - "description": "A page number within the paginated result set.", - "schema": { - "type": "integer" - } - } - ], + "operationId": "api_v1_admin_platform_email_settings_retrieve", + "summary": "Retrieve or update platform email settings", "tags": [ - "Admin users" + "Platform" ], "security": [ { @@ -26256,7 +26530,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaginatedStaffMembershipList" + "$ref": "#/components/schemas/PlatformEmailSettings" } } }, @@ -26264,31 +26538,30 @@ } } }, - "post": { - "operationId": "api_v1_admin_users_inventory_managers_create", - "summary": "List or create staff memberships", + "patch": { + "operationId": "api_v1_admin_platform_email_settings_partial_update", + "summary": "Retrieve or update platform email settings", "tags": [ - "Admin users" + "Platform" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/StaffMembership" + "$ref": "#/components/schemas/PatchedPlatformEmailSettings" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/StaffMembership" + "$ref": "#/components/schemas/PatchedPlatformEmailSettings" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/StaffMembership" + "$ref": "#/components/schemas/PatchedPlatformEmailSettings" } } - }, - "required": true + } }, "security": [ { @@ -26296,11 +26569,11 @@ } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/StaffMembership" + "$ref": "#/components/schemas/PlatformEmailSettings" } } }, @@ -26309,23 +26582,12 @@ } } }, - "/api/v1/admin/users/machine-managers": { + "/api/v1/admin/platform/payment-settings": { "get": { - "operationId": "api_v1_admin_users_machine_managers_list", - "summary": "List or create staff memberships", - "parameters": [ - { - "name": "page", - "required": false, - "in": "query", - "description": "A page number within the paginated result set.", - "schema": { - "type": "integer" - } - } - ], + "operationId": "api_v1_admin_platform_payment_settings_retrieve", + "summary": "Retrieve or update platform Stripe Connect settings", "tags": [ - "Admin users" + "Platform" ], "security": [ { @@ -26337,7 +26599,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaginatedStaffMembershipList" + "$ref": "#/components/schemas/PlatformStripeConnectSettings" } } }, @@ -26345,31 +26607,30 @@ } } }, - "post": { - "operationId": "api_v1_admin_users_machine_managers_create", - "summary": "List or create staff memberships", + "patch": { + "operationId": "api_v1_admin_platform_payment_settings_partial_update", + "summary": "Retrieve or update platform Stripe Connect settings", "tags": [ - "Admin users" + "Platform" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/StaffMembership" + "$ref": "#/components/schemas/PatchedPlatformStripeConnectSettings" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/StaffMembership" + "$ref": "#/components/schemas/PatchedPlatformStripeConnectSettings" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/StaffMembership" + "$ref": "#/components/schemas/PatchedPlatformStripeConnectSettings" } } - }, - "required": true + } }, "security": [ { @@ -26377,11 +26638,11 @@ } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/StaffMembership" + "$ref": "#/components/schemas/PlatformStripeConnectSettings" } } }, @@ -26390,23 +26651,12 @@ } } }, - "/api/v1/admin/users/space-managers": { + "/api/v1/admin/platform/restores": { "get": { - "operationId": "api_v1_admin_users_space_managers_list", - "summary": "List or create staff memberships", - "parameters": [ - { - "name": "page", - "required": false, - "in": "query", - "description": "A page number within the paginated result set.", - "schema": { - "type": "integer" - } - } - ], + "operationId": "api_v1_admin_platform_restores_list", + "summary": "List deployment restore operations", "tags": [ - "Admin users" + "Backup" ], "security": [ { @@ -26418,35 +26668,44 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaginatedStaffMembershipList" + "type": "array", + "items": { + "$ref": "#/components/schemas/RestoreOperation" + } } } }, "description": "" + }, + "401": { + "description": "Authentication is required." + }, + "403": { + "description": "The authenticated actor is not authorized." } } }, "post": { - "operationId": "api_v1_admin_users_space_managers_create", - "summary": "List or create staff memberships", + "operationId": "api_v1_admin_platform_restores_create", + "summary": "Record restore intent for the privileged host supervisor", "tags": [ - "Admin users" + "Backup" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/StaffMembership" + "$ref": "#/components/schemas/RestoreCreate" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/StaffMembership" + "$ref": "#/components/schemas/RestoreCreate" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/StaffMembership" + "$ref": "#/components/schemas/RestoreCreate" } } }, @@ -26458,35 +26717,45 @@ } ], "responses": { - "201": { + "202": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/StaffMembership" + "$ref": "#/components/schemas/RestoreOperation" } } }, "description": "" + }, + "400": { + "description": "The request is invalid for the current lifecycle state." + }, + "401": { + "description": "Authentication is required." + }, + "403": { + "description": "The authenticated actor is not authorized." } } } }, - "/api/v1/admin/warranty/{id}/documents": { + "/api/v1/admin/platform/restores/{restore_id}": { "get": { - "operationId": "api_v1_admin_warranty_documents_list", - "summary": "List documents attached to a warranty", + "operationId": "api_v1_admin_platform_restores_retrieve", + "summary": "Get restore stage, diff, and decision deadline", "parameters": [ { "in": "path", - "name": "id", + "name": "restore_id", "schema": { - "type": "integer" + "type": "string", + "format": "uuid" }, "required": true } ], "tags": [ - "Admin warranty" + "Backup" ], "security": [ { @@ -26498,78 +26767,57 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/WarrantyDocument" - } + "$ref": "#/components/schemas/RestoreOperation" } } }, "description": "" }, + "404": { + "description": "The requested resource does not exist in the actor's scope." + }, "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "description": "Authentication is required." }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "description": "The authenticated actor is not authorized." } } - }, + } + }, + "/api/v1/admin/platform/restores/{restore_id}/decision": { "post": { - "operationId": "api_v1_admin_warranty_documents_create", - "summary": "Finalize an uploaded warranty document", + "operationId": "api_v1_admin_platform_restores_decision_create", + "summary": "Decide a quiesced in-place restore", "parameters": [ { "in": "path", - "name": "id", + "name": "restore_id", "schema": { - "type": "integer" + "type": "string", + "format": "uuid" }, "required": true } ], "tags": [ - "Admin warranty" + "Backup" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WarrantyDocumentFinalize" + "$ref": "#/components/schemas/RestoreDecision" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/WarrantyDocumentFinalize" + "$ref": "#/components/schemas/RestoreDecision" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/WarrantyDocumentFinalize" + "$ref": "#/components/schemas/RestoreDecision" } } }, @@ -26581,91 +26829,80 @@ } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WarrantyDocument" + "$ref": "#/components/schemas/RestoreOperation" } } }, "description": "" }, "400": { - "description": "Invalid or duplicate warranty document." + "description": "The request is invalid for the current lifecycle state." + }, + "404": { + "description": "The requested resource does not exist in the actor's scope." }, "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "description": "Authentication is required." }, "403": { + "description": "The authenticated actor is not authorized." + } + } + } + }, + "/api/v1/admin/platform/social-auth-settings": { + "get": { + "operationId": "api_v1_admin_platform_social_auth_settings_retrieve", + "summary": "Retrieve or update platform social auth settings", + "tags": [ + "Platform" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/PlatformSocialAuthSettings" } } }, "description": "" - }, - "503": { - "description": "Warranty document storage is unavailable." } } - } - }, - "/api/v1/admin/warranty/{id}/documents/presign": { - "post": { - "operationId": "api_v1_admin_warranty_documents_presign_create", - "summary": "Create a warranty document upload URL", - "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" - }, - "required": true - } - ], + }, + "patch": { + "operationId": "api_v1_admin_platform_social_auth_settings_partial_update", + "summary": "Retrieve or update platform social auth settings", "tags": [ - "Admin warranty" + "Platform" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WarrantyDocumentPresign" + "$ref": "#/components/schemas/PatchedPlatformSocialAuthSettings" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/WarrantyDocumentPresign" + "$ref": "#/components/schemas/PatchedPlatformSocialAuthSettings" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/WarrantyDocumentPresign" + "$ref": "#/components/schemas/PatchedPlatformSocialAuthSettings" } } - }, - "required": true + } }, "security": [ { @@ -26673,130 +26910,101 @@ } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WarrantyDocumentUploadResponse" + "$ref": "#/components/schemas/PlatformSocialAuthSettings" } } }, "description": "" - }, - "400": { - "description": "Invalid document upload request." - }, - "401": { + } + } + } + }, + "/api/v1/admin/platform/tenant-migrations/deployment-identity": { + "get": { + "operationId": "api_v1_admin_platform_tenant_migrations_deployment_identity_retrieve", + "summary": "Read this deployment's signing identity and target age recipient", + "tags": [ + "Tenant migration" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/DeploymentIdentity" } } }, "description": "" }, - "403": { + "409": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "State conflict." }, - "404": { + "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" - }, - "503": { - "description": "Warranty document storage is unavailable." - } - } - } - }, - "/api/v1/admin/warranty/documents/{id}": { - "delete": { - "operationId": "api_v1_admin_warranty_documents_destroy", - "summary": "Delete a warranty document", - "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" - }, - "required": true - } - ], - "tags": [ - "Admin warranty" - ], - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "204": { - "description": "No response body" + "description": "Authentication required." }, - "401": { + "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Superadmin access required." }, - "403": { + "429": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Throttle limit exceeded." }, - "404": { + "500": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Unexpected migration failure." } } } }, - "/api/v1/admin/warranty/documents/{id}/url": { + "/api/v1/admin/platform/tenant-migrations/imports": { "get": { - "operationId": "api_v1_admin_warranty_documents_url_retrieve", - "summary": "Create a signed warranty document view URL", - "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" - }, - "required": true - } - ], + "operationId": "api_v1_admin_platform_tenant_migrations_imports_list", + "summary": "List tenant import jobs", "tags": [ - "Admin warranty" + "Tenant migration" ], "security": [ { @@ -26808,7 +27016,10 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WarrantyDocumentUrl" + "type": "array", + "items": { + "$ref": "#/components/schemas/ImportJob" + } } } }, @@ -26818,60 +27029,65 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Authentication required." }, "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Superadmin access required." }, - "404": { + "429": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Throttle limit exceeded." }, - "503": { - "description": "Warranty document storage is unavailable." + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TypedError" + } + } + }, + "description": "Unexpected migration failure." } } - } - }, - "/api/v1/auth/change-password": { + }, "post": { - "operationId": "api_v1_auth_change_password_create", - "summary": "Change current user's password", + "operationId": "api_v1_admin_platform_tenant_migrations_imports_create", + "summary": "Create an import job from an age-encrypted archive upload", "tags": [ - "Auth" + "Tenant migration" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ChangePassword" + "$ref": "#/components/schemas/ImportCreate" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/ChangePassword" + "$ref": "#/components/schemas/ImportCreate" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/ChangePassword" + "$ref": "#/components/schemas/ImportCreate" } } }, @@ -26883,157 +27099,96 @@ } ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ChangePasswordResponse" + "$ref": "#/components/schemas/ImportJob" } } }, "description": "" }, "400": { - "description": "Password validation failed." - }, - "401": { - "description": "Authentication credentials were not provided." - } - } - } - }, - "/api/v1/auth/claim/redeem": { - "post": { - "operationId": "api_v1_auth_claim_redeem_create", - "summary": "Redeem a staff-issued member claim code", - "tags": [ - "Auth" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ClaimRedemption" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/ClaimRedemption" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/ClaimRedemption" - } - } - }, - "required": true - }, - "responses": { - "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ClaimRedemptionResponse" + "$ref": "#/components/schemas/FieldValidationError" } } }, - "description": "" + "description": "Field-keyed validation errors." }, - "400": { + "409": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "Invalid or expired claim code." - }, - "404": { - "description": "Makerspace not found." - }, - "429": { - "description": "Redemption rate limit exceeded." - } - } - } - }, - "/api/v1/auth/device/attestation-challenge": { - "post": { - "operationId": "api_v1_auth_device_attestation_challenge_create", - "tags": [ - "Device auth" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DeviceIdentity" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/DeviceIdentity" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/DeviceIdentity" - } - } + "description": "State conflict." }, - "required": true - }, - "responses": { - "200": { + "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceChallengeResponse" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Authentication required." }, - "400": { + "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Superadmin access required." }, "429": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Throttle limit exceeded." }, - "503": { + "500": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Unexpected migration failure." } } } }, - "/api/v1/auth/device/grants": { + "/api/v1/admin/platform/tenant-migrations/imports/{job_id}": { "get": { - "operationId": "api_v1_auth_device_grants_list", + "operationId": "api_v1_admin_platform_tenant_migrations_imports_retrieve", + "summary": "Read a tenant import job", + "parameters": [ + { + "in": "path", + "name": "job_id", + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true + } + ], "tags": [ - "Device auth" + "Tenant migration" ], "security": [ { @@ -27045,35 +27200,73 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/DeviceGrant" - } + "$ref": "#/components/schemas/ImportJob" } } }, "description": "" }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TypedError" + } + } + }, + "description": "Not found." + }, "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Authentication required." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TypedError" + } + } + }, + "description": "Superadmin access required." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TypedError" + } + } + }, + "description": "Throttle limit exceeded." + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TypedError" + } + } + }, + "description": "Unexpected migration failure." } } } }, - "/api/v1/auth/device/grants/{grant_id}": { - "delete": { - "operationId": "api_v1_auth_device_grants_destroy", + "/api/v1/admin/platform/tenant-migrations/imports/{job_id}/identity-decisions": { + "get": { + "operationId": "api_v1_admin_platform_tenant_migrations_imports_identity_decisions_list", + "summary": "Read the exact archived identity decision list", "parameters": [ { "in": "path", - "name": "grant_id", + "name": "job_id", "schema": { "type": "string", "format": "uuid" @@ -27082,7 +27275,7 @@ } ], "tags": [ - "Device auth" + "Tenant migration" ], "security": [ { @@ -27090,14 +27283,14 @@ } ], "responses": { - "204": { - "description": "No response body" - }, - "401": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "type": "array", + "items": { + "$ref": "#/components/schemas/ClosureIdentity" + } } } }, @@ -27107,90 +27300,206 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Not found." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TypedError" + } + } + }, + "description": "Authentication required." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TypedError" + } + } + }, + "description": "Superadmin access required." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TypedError" + } + } + }, + "description": "Throttle limit exceeded." + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TypedError" + } + } + }, + "description": "Unexpected migration failure." } } - } - }, - "/api/v1/auth/device/login": { + }, "post": { - "operationId": "api_v1_auth_device_login_create", + "operationId": "api_v1_admin_platform_tenant_migrations_imports_identity_decisions_create", + "summary": "Submit all per-person import identity decisions", + "parameters": [ + { + "in": "path", + "name": "job_id", + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true + } + ], "tags": [ - "Device auth" + "Tenant migration" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceLogin" + "$ref": "#/components/schemas/ImportDecisionList" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/DeviceLogin" + "$ref": "#/components/schemas/ImportDecisionList" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/DeviceLogin" + "$ref": "#/components/schemas/ImportDecisionList" } } }, "required": true }, + "security": [ + { + "jwtAuth": [] + } + ], "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceTokenResponse" + "$ref": "#/components/schemas/ImportJob" } } }, "description": "" }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FieldValidationError" + } + } + }, + "description": "Field-keyed validation errors." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TypedError" + } + } + }, + "description": "State conflict." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TypedError" + } + } + }, + "description": "Not found." + }, "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Authentication required." }, "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Superadmin access required." }, - "503": { + "429": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Throttle limit exceeded." + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TypedError" + } + } + }, + "description": "Unexpected migration failure." } } } }, - "/api/v1/auth/device/logout": { + "/api/v1/admin/platform/tenant-migrations/imports/{job_id}/pairings/{pairing_id}/abort": { "post": { - "operationId": "api_v1_auth_device_logout_create", + "operationId": "api_v1_admin_platform_tenant_migrations_imports_pairings_abort_create", + "summary": "Abort an importing target and issue its signed proof", + "parameters": [ + { + "in": "path", + "name": "job_id", + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true + }, + { + "in": "path", + "name": "pairing_id", + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true + } + ], "tags": [ - "Device auth" + "Tenant migration" ], "security": [ { @@ -27202,126 +27511,117 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceLogoutResponse" + "$ref": "#/components/schemas/CutoverOutcome" } } }, "description": "" }, - "401": { + "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Not found." }, - "403": { + "409": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" - } - } - } - }, - "/api/v1/auth/device/refresh": { - "post": { - "operationId": "api_v1_auth_device_refresh_create", - "tags": [ - "Device auth" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DeviceRefresh" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/DeviceRefresh" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/DeviceRefresh" - } - } + "description": "State conflict." }, - "required": true - }, - "responses": { - "200": { + "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceRefreshResponse" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Authentication required." }, - "400": { + "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Superadmin access required." }, - "401": { + "429": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Throttle limit exceeded." }, - "429": { + "500": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Unexpected migration failure." } } } }, - "/api/v1/auth/email-verification/confirm": { + "/api/v1/admin/platform/tenant-migrations/imports/{job_id}/pairings/{pairing_id}/activate": { "post": { - "operationId": "api_v1_auth_email_verification_confirm_create", + "operationId": "api_v1_admin_platform_tenant_migrations_imports_pairings_activate_create", + "summary": "Activate an imported target with the source receipt", + "parameters": [ + { + "in": "path", + "name": "job_id", + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true + }, + { + "in": "path", + "name": "pairing_id", + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true + } + ], "tags": [ - "Auth" + "Tenant migration" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EmailVerificationConfirm" + "$ref": "#/components/schemas/CutoverReceiptRequest" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/EmailVerificationConfirm" + "$ref": "#/components/schemas/CutoverReceiptRequest" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/EmailVerificationConfirm" + "$ref": "#/components/schemas/CutoverReceiptRequest" } } }, @@ -27337,218 +27637,317 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MemberVerificationAck" + "$ref": "#/components/schemas/CutoverOutcome" } } }, "description": "" }, "400": { - "description": "Invalid or expired verification code." - }, - "401": { - "description": "Authentication credentials were not provided." - }, - "403": { - "description": "Permission denied." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FieldValidationError" + } + } + }, + "description": "Field-keyed validation errors." }, - "429": { - "description": "Request throttled." - } - } - } - }, - "/api/v1/auth/email-verification/resend": { - "post": { - "operationId": "api_v1_auth_email_verification_resend_create", - "tags": [ - "Auth" - ], - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "200": { + "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MemberVerificationAck" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Not found." }, - "400": { - "description": "Invalid request." + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TypedError" + } + } + }, + "description": "State conflict." }, "401": { - "description": "Authentication credentials were not provided." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TypedError" + } + } + }, + "description": "Authentication required." }, "403": { - "description": "Permission denied." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TypedError" + } + } + }, + "description": "Superadmin access required." }, "429": { - "description": "Request throttled." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TypedError" + } + } + }, + "description": "Throttle limit exceeded." + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TypedError" + } + } + }, + "description": "Unexpected migration failure." } } } }, - "/api/v1/auth/forgot-password": { + "/api/v1/admin/platform/tenant-migrations/imports/{job_id}/run": { "post": { - "operationId": "api_v1_auth_forgot_password_create", - "summary": "Request an emailed password reset code", + "operationId": "api_v1_admin_platform_tenant_migrations_imports_run_create", + "summary": "Run an identity-decided tenant import", + "parameters": [ + { + "in": "path", + "name": "job_id", + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true + } + ], "tags": [ - "Auth" + "Tenant migration" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ForgotPasswordRequest" + "$ref": "#/components/schemas/ImportRun" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/ForgotPasswordRequest" + "$ref": "#/components/schemas/ImportRun" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/ForgotPasswordRequest" + "$ref": "#/components/schemas/ImportRun" } } - }, - "required": true + } }, + "security": [ + { + "jwtAuth": [] + } + ], "responses": { - "200": { + "202": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PasswordResetAcknowledgement" + "$ref": "#/components/schemas/ImportJob" } } }, "description": "" }, "400": { - "description": "Invalid request." - }, - "429": { - "description": "Request throttled." - }, - "503": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RecoveryUnavailable" + "$ref": "#/components/schemas/FieldValidationError" } } }, - "description": "" - } - } - } - }, - "/api/v1/auth/login": { - "post": { - "operationId": "api_v1_auth_login_create", - "description": "Takes a set of user credentials and returns an access and refresh JSON web\ntoken pair to prove the authentication of those credentials.", - "summary": "Log in with a password", - "tags": [ - "Auth" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/LoginRequest" - }, - "examples": { - "StaffLogin": { - "value": { - "username": "admin", - "password": "secret-password", - "surface": "staff" - }, - "summary": "Staff login" + "description": "Field-keyed validation errors." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TypedError" } } }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/LoginRequest" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/LoginRequest" - } - } + "description": "State conflict." }, - "required": true - }, - "responses": { - "200": { + "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/LoginResponse" + "$ref": "#/components/schemas/TypedError" } } }, - "description": "" + "description": "Not found." }, - "400": { - "description": "Invalid request." + "503": { + "description": "Import worker unavailable." }, "401": { - "description": "Invalid credentials or inactive account." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TypedError" + } + } + }, + "description": "Authentication required." }, "403": { - "description": "Account access is restricted." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TypedError" + } + } + }, + "description": "Superadmin access required." }, "429": { - "description": "Request throttled." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TypedError" + } + } + }, + "description": "Throttle limit exceeded." + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TypedError" + } + } + }, + "description": "Unexpected migration failure." } } } }, - "/api/v1/auth/logout": { - "post": { - "operationId": "api_v1_auth_logout_create", - "summary": "Log out and clear refresh cookie", + "/api/v1/admin/platform/tenant-migrations/imports/{job_id}/verification": { + "get": { + "operationId": "api_v1_admin_platform_tenant_migrations_imports_verification_retrieve", + "summary": "Read the import verification report", + "parameters": [ + { + "in": "path", + "name": "job_id", + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true + } + ], "tags": [ - "Auth" + "Tenant migration" + ], + "security": [ + { + "jwtAuth": [] + } ], "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/LogoutResponse" + "$ref": "#/components/schemas/VerificationReport" } } }, "description": "" }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TypedError" + } + } + }, + "description": "Not found." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TypedError" + } + } + }, + "description": "State conflict." + }, "401": { - "description": "Refresh token could not be blacklisted." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TypedError" + } + } + }, + "description": "Authentication required." }, "403": { - "description": "CSRF check failed." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TypedError" + } + } + }, + "description": "Superadmin access required." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TypedError" + } + } + }, + "description": "Throttle limit exceeded." + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TypedError" + } + } + }, + "description": "Unexpected migration failure." } } } }, - "/api/v1/auth/me": { + "/api/v1/admin/platform/tenant-migrations/pairings": { "get": { - "operationId": "api_v1_auth_me_retrieve", - "summary": "Get current staff profile", + "operationId": "api_v1_admin_platform_tenant_migrations_pairings_list", + "summary": "List pinned migration pairings", "tags": [ - "Auth" + "Tenant migration" ], "security": [ { @@ -27560,86 +27959,168 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AuthUserPayload" + "type": "array", + "items": { + "$ref": "#/components/schemas/Pairing" + } } } }, "description": "" }, - "400": { - "description": "Invalid request." - }, "401": { - "description": "Authentication credentials were not provided." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TypedError" + } + } + }, + "description": "Authentication required." }, "403": { - "description": "Permission denied." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TypedError" + } + } + }, + "description": "Superadmin access required." }, "429": { - "description": "Request throttled." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TypedError" + } + } + }, + "description": "Throttle limit exceeded." + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TypedError" + } + } + }, + "description": "Unexpected migration failure." } } - } - }, - "/api/v1/auth/member-sign-up": { + }, "post": { - "operationId": "api_v1_auth_member_sign_up_create", + "operationId": "api_v1_admin_platform_tenant_migrations_pairings_create", + "summary": "Approve and pin source/target deployment identities", "tags": [ - "Auth" + "Tenant migration" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MemberSignUp" + "$ref": "#/components/schemas/PairingCreate" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/MemberSignUp" + "$ref": "#/components/schemas/PairingCreate" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/MemberSignUp" + "$ref": "#/components/schemas/PairingCreate" } } }, "required": true }, + "security": [ + { + "jwtAuth": [] + } + ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MemberVerificationAck" + "$ref": "#/components/schemas/Pairing" } } }, "description": "" }, "400": { - "description": "Invalid details." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FieldValidationError" + } + } + }, + "description": "Field-keyed validation errors." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TypedError" + } + } + }, + "description": "State conflict." }, "401": { - "description": "Authentication failed." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TypedError" + } + } + }, + "description": "Authentication required." }, "403": { - "description": "Permission denied." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TypedError" + } + } + }, + "description": "Superadmin access required." }, "429": { - "description": "Request throttled." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TypedError" + } + } + }, + "description": "Throttle limit exceeded." + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TypedError" + } + } + }, + "description": "Unexpected migration failure." } } } }, - "/api/v1/auth/phone": { - "delete": { - "operationId": "api_v1_auth_phone_destroy", - "description": "Detach the number. Always safe: every account keeps an email credential.", - "summary": "Unlink the phone number", + "/api/v1/admin/platform/update-settings": { + "get": { + "operationId": "api_v1_admin_platform_update_settings_retrieve", + "summary": "Retrieve or update automatic production update settings", "tags": [ - "Auth" + "Platform" ], "security": [ { @@ -27651,42 +28132,38 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PhoneStatus" + "$ref": "#/components/schemas/PlatformUpdateSettings" } } }, "description": "" } } - } - }, - "/api/v1/auth/phone/link/confirm": { - "post": { - "operationId": "api_v1_auth_phone_link_confirm_create", - "description": "Attach the verified number to the caller's account.", - "summary": "Confirm and link a phone number", + }, + "patch": { + "operationId": "api_v1_admin_platform_update_settings_partial_update", + "summary": "Retrieve or update automatic production update settings", "tags": [ - "Auth" + "Platform" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PhoneConfirm" + "$ref": "#/components/schemas/PatchedPlatformUpdateSettings" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/PhoneConfirm" + "$ref": "#/components/schemas/PatchedPlatformUpdateSettings" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/PhoneConfirm" + "$ref": "#/components/schemas/PatchedPlatformUpdateSettings" } } - }, - "required": true + } }, "security": [ { @@ -27698,335 +28175,342 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PhoneStatus" + "$ref": "#/components/schemas/PlatformUpdateSettings" } } }, "description": "" - }, - "400": { - "description": "Invalid or expired code." - }, - "429": { - "description": "Request throttled." } } } }, - "/api/v1/auth/phone/link/start": { + "/api/v1/admin/platform/update-settings/update-now": { "post": { - "operationId": "api_v1_auth_phone_link_start_create", - "description": "Send a code to a number the caller wants to attach to their own account.", - "summary": "Request a code to link a phone number", + "operationId": "api_v1_admin_platform_update_settings_update_now_create", + "summary": "Queue the latest production release for installation", "tags": [ - "Auth" + "Platform" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PhoneStart" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/PhoneStart" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/PhoneStart" - } - } - }, - "required": true - }, "security": [ { "jwtAuth": [] } ], "responses": { - "200": { + "202": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PhoneStartResponse" + "$ref": "#/components/schemas/PlatformUpdateSettings" } } }, "description": "" - }, - "400": { - "description": "Invalid or already-linked number." - }, - "404": { - "description": "Phone sign-in is not configured." - }, - "429": { - "description": "Request throttled." } } } }, - "/api/v1/auth/phone/login/confirm": { + "/api/v1/admin/products/{id}/assets/generate": { "post": { - "operationId": "api_v1_auth_phone_login_confirm_create", - "description": "Exchange a valid code for a MEMBER session. Never mints a staff session.", - "summary": "Sign in with a phone code", + "operationId": "api_v1_admin_products_assets_generate_create", + "summary": "Generate asset units", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + } + ], "tags": [ - "Auth" + "Asset units" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PhoneConfirm" + "$ref": "#/components/schemas/AssetGenerate" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/PhoneConfirm" + "$ref": "#/components/schemas/AssetGenerate" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/PhoneConfirm" + "$ref": "#/components/schemas/AssetGenerate" } } }, "required": true }, + "security": [ + { + "jwtAuth": [] + } + ], "responses": { - "200": { - "description": "Member session issued." - }, - "400": { - "description": "Invalid or expired code." - }, - "404": { - "description": "Phone sign-in is not configured." - }, - "429": { - "description": "Request throttled." + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssetGenerateResult" + } + } + }, + "description": "" } } } }, - "/api/v1/auth/phone/login/start": { - "post": { - "operationId": "api_v1_auth_phone_login_start_create", - "description": "Request a login code for an already-verified number.", - "summary": "Request a phone sign-in code", + "/api/v1/admin/qr-print-batches/{id}": { + "get": { + "operationId": "api_v1_admin_qr_print_batches_retrieve", + "summary": "Retrieve QR print batch", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + } + ], "tags": [ - "Auth" + "QR print batches" + ], + "security": [ + { + "jwtAuth": [] + } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PhoneStart" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/PhoneStart" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/PhoneStart" - } - } - }, - "required": true - }, "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PhoneStartResponse" + "$ref": "#/components/schemas/QrPrintBatchDetail" } } }, "description": "" - }, - "400": { - "description": "Invalid request." - }, - "404": { - "description": "Phone sign-in is not configured." - }, - "429": { - "description": "Request throttled." } } } }, - "/api/v1/auth/refresh": { - "post": { - "operationId": "api_v1_auth_refresh_create", - "description": "Takes a refresh type JSON web token and returns an access type JSON web\ntoken if the refresh token is valid.", - "summary": "Refresh access token", + "/api/v1/admin/qr-print-batches/{id}/download": { + "get": { + "operationId": "api_v1_admin_qr_print_batches_download_retrieve", + "summary": "Download QR print batch as a ZIP of captioned SVGs", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + } + ], "tags": [ - "Auth" + "QR print batches" + ], + "security": [ + { + "jwtAuth": [] + } ], "responses": { "200": { "content": { - "application/json": { + "application/zip": { "schema": { - "$ref": "#/components/schemas/RefreshResponse" + "type": "string", + "format": "binary" } } }, "description": "" - }, - "401": { - "description": "Missing, invalid, or replayed refresh token." - }, - "403": { - "description": "CSRF check failed or account restricted." } } } }, - "/api/v1/auth/reset-password": { + "/api/v1/admin/qr-print-batches/{id}/items": { "post": { - "operationId": "api_v1_auth_reset_password_create", - "summary": "Confirm an OTP or coexisting legacy password reset link", + "operationId": "api_v1_admin_qr_print_batches_items_create", + "summary": "Add QR code to print batch", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + } + ], "tags": [ - "Auth" + "QR print batches" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ResetPasswordConfirmRequest" + "$ref": "#/components/schemas/QrPrintBatchItemCreate" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/ResetPasswordConfirmRequest" + "$ref": "#/components/schemas/QrPrintBatchItemCreate" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/ResetPasswordConfirmRequest" + "$ref": "#/components/schemas/QrPrintBatchItemCreate" } } - } + }, + "required": true }, + "security": [ + { + "jwtAuth": [] + } + ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PasswordUpdated" + "$ref": "#/components/schemas/QrPrintBatchItemResult" } } }, "description": "" - }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ResetPasswordConfirmError" - } - } + } + } + } + }, + "/api/v1/admin/qr/{id}/print": { + "get": { + "operationId": "api_v1_admin_qr_print_retrieve", + "summary": "Render QR label SVG", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" }, - "description": "" - }, - "429": { - "description": "Request throttled." + "required": true + } + ], + "tags": [ + "QR assets" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "description": "SVG QR label." } } } }, - "/api/v1/auth/social/apple": { + "/api/v1/admin/qr/{id}/rebind-target": { "post": { - "operationId": "api_v1_auth_social_apple_create", + "operationId": "api_v1_admin_qr_rebind_target_create", + "summary": "Rebind a saved QR to another product/asset (cross-makerspace = superadmin) and optionally rename", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + } + ], "tags": [ - "Social auth" + "QR assets" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SocialLogin" + "$ref": "#/components/schemas/QrRebindTarget" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/SocialLogin" + "$ref": "#/components/schemas/QrRebindTarget" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/SocialLogin" + "$ref": "#/components/schemas/QrRebindTarget" } } }, "required": true }, + "security": [ + { + "jwtAuth": [] + } + ], "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SocialLoginResponse" - } - } - }, - "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" + "$ref": "#/components/schemas/QrRebindResult" } } }, "description": "" }, "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } + "description": "Outstanding loan or target QR conflict." + } + } + } + }, + "/api/v1/admin/qr/{id}/revoke": { + "post": { + "operationId": "api_v1_admin_qr_revoke_create", + "summary": "Revoke active QR code", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" }, - "description": "Includes `social_device_restart_required` when burned pre-grant inputs must be replaced." - }, - "429": { + "required": true + } + ], + "tags": [ + "QR assets" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/QrCode" } } }, @@ -28035,88 +28519,55 @@ } } }, - "/api/v1/auth/social/google": { + "/api/v1/admin/qr/boxes": { "post": { - "operationId": "api_v1_auth_social_google_create", + "operationId": "api_v1_admin_qr_boxes_create", + "summary": "Create a QR-coded box", "tags": [ - "Social auth" + "QR assets" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SocialLogin" + "$ref": "#/components/schemas/CreateBoxQr" + }, + "examples": { + "CreateAQR-codedBox": { + "value": { + "makerspace_id": 1, + "label": "Electronics Box A", + "location": "Bench Storage", + "description": "Issued hardware kit box" + }, + "summary": "Create a QR-coded box" + } } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/SocialLogin" + "$ref": "#/components/schemas/CreateBoxQr" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/SocialLogin" + "$ref": "#/components/schemas/CreateBoxQr" } } }, "required": true }, + "security": [ + { + "jwtAuth": [] + } + ], "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SocialLoginResponse" - } - } - }, - "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": "Includes `social_device_restart_required` when burned pre-grant inputs must be replaced." - }, - "429": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/Box" } } }, @@ -28125,68 +28576,55 @@ } } }, - "/api/v1/auth/social/nonce": { + "/api/v1/admin/qr/containers": { "post": { - "operationId": "api_v1_auth_social_nonce_create", + "operationId": "api_v1_admin_qr_containers_create", + "summary": "Create a QR-coded box", "tags": [ - "Social auth" + "QR assets" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SocialNonce" + "$ref": "#/components/schemas/CreateBoxQr" + }, + "examples": { + "CreateAQR-codedBox": { + "value": { + "makerspace_id": 1, + "label": "Electronics Box A", + "location": "Bench Storage", + "description": "Issued hardware kit box" + }, + "summary": "Create a QR-coded box" + } } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/SocialNonce" + "$ref": "#/components/schemas/CreateBoxQr" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/SocialNonce" + "$ref": "#/components/schemas/CreateBoxQr" } } }, "required": true }, + "security": [ + { + "jwtAuth": [] + } + ], "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SocialNonceResponse" - } - } - }, - "description": "" - }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" - }, - "429": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/Box" } } }, @@ -28195,149 +28633,484 @@ } } }, - "/api/v1/auth/social/oidc/{slug}": { + "/api/v1/admin/qr/resolve": { "post": { - "operationId": "api_v1_auth_social_oidc_create", - "description": "Login through a deployment-configured OIDC provider named in the URL.\n\nThe slug is resolved to a configured row on every request rather than captured at\nimport time, so disabling a provider takes effect immediately instead of at the next\nrestart -- the same reason `provider_for_slug` refuses disabled and half-filled rows.", - "parameters": [ - { - "in": "path", - "name": "slug", - "schema": { - "type": "string" - }, - "required": true - } - ], + "operationId": "api_v1_admin_qr_resolve_create", + "description": "Resolve the opaque payload encoded in a physical QR label to its target (box, product, or asset) and the scanner actions the caller may take. `payload` is the raw 32-char lowercase hex token printed on the label (Python `uuid4().hex`, e.g. `3f9a1c2b4d5e6f7081920a1b2c3d4e5f`) - it is the value stored as `QrCode.payload` (and `Box.code` for boxes), not a URL or JSON. Resolving a QR also records an immutable scanner-lookup scan event.", + "summary": "Resolve QR target and scanner allowed actions", "tags": [ - "Social auth" + "QR assets" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SocialLogin" + "$ref": "#/components/schemas/QrResolve" + }, + "examples": { + "ResolveAScannedQRPayload": { + "value": { + "payload": "3f9a1c2b4d5e6f7081920a1b2c3d4e5f" + }, + "summary": "Resolve a scanned QR payload" + } } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/SocialLogin" + "$ref": "#/components/schemas/QrResolve" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/SocialLogin" + "$ref": "#/components/schemas/QrResolve" } } }, "required": true }, + "security": [ + { + "jwtAuth": [] + } + ], "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SocialLoginResponse" + "$ref": "#/components/schemas/QrResolveResult" + }, + "examples": { + "ResolvedBoxQR": { + "value": { + "qr": { + "id": 12, + "makerspace": 1, + "payload": "3f9a1c2b4d5e6f7081920a1b2c3d4e5f", + "target_type": "box", + "target_id": 5, + "status": "active", + "created_at": "2026-06-27T09:30:00Z", + "updated_at": "2026-06-27T09:30:00Z", + "revoked_at": null + }, + "target": { + "type": "box", + "id": 5, + "label": "Electronics Box A", + "code": "3f9a1c2b4d5e6f7081920a1b2c3d4e5f" + }, + "allowed_actions": [ + "contents", + "move_container", + "record_scan", + "revoke", + "view" + ] + }, + "summary": "Resolved box QR" + } } } }, "description": "" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } + } + } + } + }, + "/api/v1/admin/qr/scan": { + "post": { + "operationId": "api_v1_admin_qr_scan_create", + "description": "Context is limited to issue or return and scan events are immutable.", + "summary": "Record a QR scan", + "tags": [ + "QR assets" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/QrScan" + }, + "examples": { + "ScanQRDuringIssue": { + "value": { + "payload": "3f9a1c2b4d5e6f7081920a1b2c3d4e5f", + "context": "issue", + "request_id": 99 + }, + "summary": "Scan QR during issue" + } + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/QrScan" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/QrScan" + } + } + }, + "required": true + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/QrScanResult" + } } }, "description": "" + } + } + } + }, + "/api/v1/admin/qr/tools": { + "post": { + "operationId": "api_v1_admin_qr_tools_create", + "summary": "Create or reuse a QR code for a product or asset", + "tags": [ + "QR assets" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateToolQr" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/CreateToolQr" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/CreateToolQr" + } + } }, - "403": { + "required": true + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/QrCode" + } + } + }, + "description": "" + } + } + } + }, + "/api/v1/admin/reports/{report_key}/export": { + "get": { + "operationId": "api_v1_admin_reports_export_retrieve", + "summary": "Export aggregate report", + "parameters": [ + { + "in": "query", + "name": "end", + "schema": { + "type": "string", + "format": "date" + } + }, + { + "in": "query", + "name": "format", + "schema": { + "type": "string", + "enum": [ + "csv", + "xlsx" + ] + } + }, + { + "in": "query", + "name": "grain", + "schema": { + "type": "string", + "enum": [ + "day", + "month" + ] + } + }, + { + "in": "path", + "name": "report_key", + "schema": { + "type": "string", + "enum": [ + "active-loans", + "booking-utilization", + "communications-health", + "community-engagement", + "damaged-lost", + "damaged-missing", + "event-attendance", + "evidence-compliance", + "fablab-health", + "import-quality", + "inventory-control", + "loan-throughput", + "machine-service", + "machine-usage", + "maintenance-activity", + "member-activity", + "module-operational-health", + "most-lent", + "payment-reconciliation", + "printer-service", + "procurement-performance", + "qr-scans", + "recently-added", + "returns", + "summary", + "taken-items", + "top-borrowers" + ] + }, + "required": true + }, + { + "in": "query", + "name": "start", + "schema": { + "type": "string", + "format": "date" + } + }, + { + "in": "query", + "name": "status", + "schema": { + "type": "string", + "enum": [ + "canceled", + "paid_offline", + "paid_online", + "pending", + "waived" + ] + } + }, + { + "in": "query", + "name": "subject_type", + "schema": { + "type": "string", + "enum": [ + "booking", + "event_registration", + "machine_service_request", + "makerspace_membership" + ] + } + } + ], + "tags": [ + "Reports" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "text/csv": { + "schema": { + "type": "string" + } + }, + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": { + "schema": { + "type": "string", + "format": "binary" } } }, "description": "" }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Invalid report request." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Authentication required." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Permission denied." + }, "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Makerspace or report not found." + } + } + } + }, + "/api/v1/admin/reports/catalog": { + "get": { + "operationId": "api_v1_admin_reports_catalog_retrieve", + "summary": "List deployment report catalog", + "tags": [ + "Reports" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportCatalog" } } }, "description": "" }, - "409": { + "400": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ReportError" } } }, - "description": "Includes `social_device_restart_required` when burned pre-grant inputs must be replaced." + "description": "Invalid report request." }, - "429": { + "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ReportError" } } }, - "description": "" + "description": "Authentication required." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Permission denied." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportError" + } + } + }, + "description": "Makerspace or report not found." } } } }, - "/api/v1/auth/social/oidc/{slug}/authorize": { + "/api/v1/admin/requests/{id}/accept": { "post": { - "operationId": "api_v1_auth_social_oidc_authorize_create", + "operationId": "api_v1_admin_requests_accept_create", + "summary": "Accept borrow request", "parameters": [ { "in": "path", - "name": "slug", + "name": "id", "schema": { - "type": "string" + "type": "integer" }, "required": true } ], "tags": [ - "Social auth" + "Admin requests" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/OidcBrowserStart" + "$ref": "#/components/schemas/AcceptRequest" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/OidcBrowserStart" + "$ref": "#/components/schemas/AcceptRequest" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/OidcBrowserStart" + "$ref": "#/components/schemas/AcceptRequest" } } - }, - "required": true + } }, + "security": [ + { + "jwtAuth": [] + } + ], "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/OidcBrowserStartResponse" + "$ref": "#/components/schemas/AdminRequest" } } }, @@ -28351,7 +29124,7 @@ } } }, - "description": "" + "description": "Invalid request." }, "403": { "content": { @@ -28361,7 +29134,7 @@ } } }, - "description": "" + "description": "Permission denied." }, "404": { "content": { @@ -28371,9 +29144,9 @@ } } }, - "description": "" + "description": "Not found." }, - "503": { + "409": { "content": { "application/json": { "schema": { @@ -28381,43 +29154,59 @@ } } }, - "description": "" + "description": "Workflow conflict." } } } }, - "/api/v1/auth/social/oidc/callback": { + "/api/v1/admin/requests/{id}/assign-box": { "post": { - "operationId": "api_v1_auth_social_oidc_callback_create", + "operationId": "api_v1_admin_requests_assign_box_create", + "summary": "Assign box to accepted request", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + } + ], "tags": [ - "Social auth" + "Admin requests" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/OidcBrowserCallback" + "$ref": "#/components/schemas/AssignBox" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/OidcBrowserCallback" + "$ref": "#/components/schemas/AssignBox" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/OidcBrowserCallback" + "$ref": "#/components/schemas/AssignBox" } } }, "required": true }, + "security": [ + { + "jwtAuth": [] + } + ], "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/OidcBrowserLoginResponse" + "$ref": "#/components/schemas/AdminRequest" } } }, @@ -28431,17 +29220,7 @@ } } }, - "description": "" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "description": "Invalid request." }, "403": { "content": { @@ -28451,9 +29230,9 @@ } } }, - "description": "" + "description": "Permission denied." }, - "409": { + "404": { "content": { "application/json": { "schema": { @@ -28461,9 +29240,9 @@ } } }, - "description": "" + "description": "Not found." }, - "503": { + "409": { "content": { "application/json": { "schema": { @@ -28471,58 +29250,43 @@ } } }, - "description": "" + "description": "Workflow conflict." } } } }, - "/api/v1/auth/social/providers": { - "get": { - "operationId": "api_v1_auth_social_providers_list", - "tags": [ - "Social auth" - ], - "security": [ + "/api/v1/admin/requests/{id}/issue": { + "post": { + "operationId": "api_v1_admin_requests_issue_create", + "summary": "Issue accepted request", + "parameters": [ { - "jwtAuth": [] - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SocialIdentity" - } - } - } + "in": "path", + "name": "id", + "schema": { + "type": "integer" }, - "description": "" + "required": true } - } - }, - "post": { - "operationId": "api_v1_auth_social_providers_create", + ], "tags": [ - "Social auth" + "Admin requests" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SocialLink" + "$ref": "#/components/schemas/IssueRequest" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/SocialLink" + "$ref": "#/components/schemas/IssueRequest" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/SocialLink" + "$ref": "#/components/schemas/IssueRequest" } } }, @@ -28538,13 +29302,13 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SocialIdentity" + "$ref": "#/components/schemas/AdminRequest" } } }, "description": "" }, - "401": { + "400": { "content": { "application/json": { "schema": { @@ -28552,9 +29316,9 @@ } } }, - "description": "" + "description": "Invalid request." }, - "409": { + "403": { "content": { "application/json": { "schema": { @@ -28562,35 +29326,7 @@ } } }, - "description": "" - } - } - } - }, - "/api/v1/auth/social/providers/{provider}": { - "delete": { - "operationId": "api_v1_auth_social_providers_destroy", - "parameters": [ - { - "in": "path", - "name": "provider", - "schema": { - "type": "string" - }, - "required": true - } - ], - "tags": [ - "Social auth" - ], - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "204": { - "description": "No response body" + "description": "Permission denied." }, "404": { "content": { @@ -28600,7 +29336,7 @@ } } }, - "description": "" + "description": "Not found." }, "409": { "content": { @@ -28610,205 +29346,154 @@ } } }, - "description": "" + "description": "Workflow conflict." + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Service unavailable." } } } }, - "/api/v1/backups/download/{archive_id}/{token}": { - "get": { - "operationId": "api_v1_backups_download_retrieve", - "summary": "Consume a one-use backup download", + "/api/v1/admin/requests/{id}/reject": { + "post": { + "operationId": "api_v1_admin_requests_reject_create", + "summary": "Reject borrow request", "parameters": [ { "in": "path", - "name": "archive_id", - "schema": { - "type": "string", - "format": "uuid" - }, - "required": true - }, - { - "in": "path", - "name": "token", + "name": "id", "schema": { - "type": "string" + "type": "integer" }, "required": true } ], "tags": [ - "Backup" + "Admin requests" ], - "responses": { - "200": { - "description": "Age-encrypted archive stream." - }, - "404": { - "description": "Invalid or expired download." - } - } - } - }, - "/api/v1/bootstrap": { - "get": { - "operationId": "api_v1_bootstrap_retrieve", - "summary": "Resolve tenant and frontend-safe configuration", - "parameters": [ - { - "in": "query", - "name": "slug", - "schema": { - "type": "string" + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RejectRequest" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/RejectRequest" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/RejectRequest" + } } }, - { - "in": "query", - "name": "tenant", - "schema": { - "type": "string" - } - } - ], - "tags": [ - "Tenant bootstrap" - ], + "required": true + }, "security": [ { "jwtAuth": [] - }, - {} + } ], "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TenantBootstrap" + "$ref": "#/components/schemas/AdminRequest" } } }, - "description": "Frontend-safe tenant bootstrap payload." + "description": "" }, - "404": { - "description": "No active tenant frontend matched." - } - } - } - }, - "/api/v1/config": { - "get": { - "operationId": "api_v1_config_retrieve", - "summary": "Return frontend-safe platform configuration", - "tags": [ - "Platform" - ], - "security": [ - {} - ], - "responses": { - "200": { + "400": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicConfig" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "" - } - } - } - }, - "/api/v1/data-exports/download/{job_id}/{token}": { - "get": { - "operationId": "api_v1_data_exports_download_retrieve", - "summary": "Consume a one-use export download URL", - "parameters": [ - { - "in": "path", - "name": "job_id", - "schema": { - "type": "string", - "format": "uuid" - }, - "required": true + "description": "Invalid request." }, - { - "in": "path", - "name": "token", - "schema": { - "type": "string" - }, - "required": true - } - ], - "tags": [ - "Data exports" - ], - "responses": { - "200": { + "403": { "content": { - "application/zip": { - "schema": { - "type": "string", - "format": "binary" - } - }, - "application/octet-stream": { + "application/json": { "schema": { - "type": "string", - "format": "binary" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "" + "description": "Permission denied." }, "404": { - "description": "Download link is invalid, expired, or used." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Not found." }, - "503": { - "description": "Archive storage is unavailable." + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Workflow conflict." } } } }, - "/api/v1/guest-admin/makerspace/{makerspace_id}/active-loans": { - "get": { - "operationId": "api_v1_guest_admin_makerspace_active_loans_list", - "summary": "List active loans awaiting return", + "/api/v1/admin/requests/{id}/return": { + "post": { + "operationId": "api_v1_admin_requests_return_create", + "summary": "Return issued request items", "parameters": [ { "in": "path", - "name": "makerspace_id", + "name": "id", "schema": { "type": "integer" }, "required": true - }, - { - "name": "page", - "required": false, - "in": "query", - "description": "A page number within the paginated result set.", - "schema": { - "type": "integer" - } - }, - { - "name": "search", - "required": false, - "in": "query", - "description": "A search term (requested-for, requester name/email).", - "schema": { - "type": "string" - } } ], "tags": [ "Admin requests" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReturnRequest" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/ReturnRequest" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/ReturnRequest" + } + } + }, + "required": true + }, "security": [ { "jwtAuth": [] @@ -28819,12 +29504,22 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaginatedAdminRequestList" + "$ref": "#/components/schemas/AdminRequest" } } }, "description": "" }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Invalid request." + }, "403": { "content": { "application/json": { @@ -28844,14 +29539,34 @@ } }, "description": "Not found." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Workflow conflict." + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Service unavailable." } } } }, - "/api/v1/guest-admin/requests/{id}/return": { + "/api/v1/admin/requests/{id}/return-due": { "post": { - "operationId": "api_v1_guest_admin_requests_return_create", - "summary": "Return issued request items", + "operationId": "api_v1_admin_requests_return_due_create", + "summary": "Set request return due time", "parameters": [ { "in": "path", @@ -28869,17 +29584,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReturnRequest" + "$ref": "#/components/schemas/ReturnDue" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/ReturnRequest" + "$ref": "#/components/schemas/ReturnDue" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/ReturnRequest" + "$ref": "#/components/schemas/ReturnDue" } } }, @@ -28940,99 +29655,183 @@ } }, "description": "Workflow conflict." - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Service unavailable." } } } }, - "/api/v1/health/": { + "/api/v1/admin/requests/{id}/timeline": { "get": { - "operationId": "api_v1_health_retrieve", - "summary": "Health check", + "operationId": "api_v1_admin_requests_timeline_retrieve", + "summary": "Read-only immutable timeline for one hardware request", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + }, + { + "in": "query", + "name": "limit", + "schema": { + "type": "integer" + }, + "description": "Maximum history events to return. Defaults to 200; capped at 500." + } + ], "tags": [ - "Health" + "Admin requests" ], "security": [ { "jwtAuth": [] - }, - {} + } ], "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Health" + "$ref": "#/components/schemas/RequestTimelineResponse" } } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Invalid request." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Permission denied." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Not found." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Workflow conflict." } } } }, - "/api/v1/health/readiness/": { + "/api/v1/admin/spaces/{id}/": { "get": { - "operationId": "api_v1_health_readiness_retrieve", - "summary": "Readiness check", + "operationId": "api_v1_admin_spaces_retrieve", + "summary": "Retrieve a bookable space", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + } + ], "tags": [ - "Health" + "Admin bookings" ], "security": [ { "jwtAuth": [] - }, - {} + } ], "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Readiness" + "$ref": "#/components/schemas/BookableSpaceAdmin" } } }, "description": "" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Permission denied." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Not found." } } - } - }, - "/api/v1/integrations/push/devices": { - "post": { - "operationId": "api_v1_integrations_push_devices_create", + }, + "patch": { + "operationId": "api_v1_admin_spaces_partial_update", + "summary": "Update a bookable space", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + } + ], "tags": [ - "Native push" + "Admin bookings" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PushDeviceRegistration" + "$ref": "#/components/schemas/PatchedBookableSpaceWrite" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/PushDeviceRegistration" + "$ref": "#/components/schemas/PatchedBookableSpaceWrite" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/PushDeviceRegistration" + "$ref": "#/components/schemas/PatchedBookableSpaceWrite" } } - }, - "required": true + } }, "security": [ { @@ -29044,23 +29843,13 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PushDevice" - } - } - }, - "description": "" - }, - "201": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PushDevice" + "$ref": "#/components/schemas/BookableSpaceAdmin" } } }, "description": "" }, - "401": { + "400": { "content": { "application/json": { "schema": { @@ -29068,7 +29857,7 @@ } } }, - "description": "" + "description": "Invalid request." }, "403": { "content": { @@ -29078,9 +29867,9 @@ } } }, - "description": "" + "description": "Permission denied." }, - "409": { + "404": { "content": { "application/json": { "schema": { @@ -29088,9 +29877,9 @@ } } }, - "description": "" + "description": "Not found." }, - "503": { + "409": { "content": { "application/json": { "schema": { @@ -29098,18 +29887,19 @@ } } }, - "description": "" + "description": "Workflow conflict." } } } }, - "/api/v1/integrations/push/devices/{device_id}": { - "delete": { - "operationId": "api_v1_integrations_push_devices_destroy", + "/api/v1/admin/spaces/{id}/booking-rules/": { + "get": { + "operationId": "api_v1_admin_spaces_booking_rules_retrieve", + "summary": "Retrieve booking rules for a space", "parameters": [ { "in": "path", - "name": "device_id", + "name": "id", "schema": { "type": "integer" }, @@ -29117,7 +29907,7 @@ } ], "tags": [ - "Native push" + "Admin bookings" ], "security": [ { @@ -29125,19 +29915,27 @@ } ], "responses": { - "204": { - "description": "No response body" - }, - "401": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/BookableSpaceBookingRules" } } }, "description": "" }, + "400": { + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": {} + } + } + }, + "description": "Invalid booking-rule values or bookings module disabled." + }, "403": { "content": { "application/json": { @@ -29146,7 +29944,7 @@ } } }, - "description": "" + "description": "Permission denied." }, "404": { "content": { @@ -29156,37 +29954,44 @@ } } }, - "description": "" + "description": "Not found." } } - } - }, - "/api/v1/integrations/telegram/test-alert": { - "post": { - "operationId": "api_v1_integrations_telegram_test_alert_create", - "summary": "Send Telegram test alert", + }, + "patch": { + "operationId": "api_v1_admin_spaces_booking_rules_partial_update", + "summary": "Update booking rules for a space", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + } + ], "tags": [ - "Telegram" + "Admin bookings" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TelegramTestAlert" + "$ref": "#/components/schemas/PatchedBookableSpaceBookingRules" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/TelegramTestAlert" + "$ref": "#/components/schemas/PatchedBookableSpaceBookingRules" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/TelegramTestAlert" + "$ref": "#/components/schemas/PatchedBookableSpaceBookingRules" } } - }, - "required": true + } }, "security": [ { @@ -29195,106 +30000,108 @@ ], "responses": { "200": { - "description": "Delivery attempt result." - } - } - } - }, - "/api/v1/integrations/telegram/webhook": { - "post": { - "operationId": "api_v1_integrations_telegram_webhook_create", - "description": "Retained so an already-registered webhook does not retry forever. Callback queries are acknowledged and discarded: accepting and rejecting borrow requests happens in the staff console, never from chat.", - "summary": "Acknowledge a Telegram webhook (no action is taken)", - "tags": [ - "Telegram" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TelegramWebhook" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/TelegramWebhook" + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BookableSpaceBookingRules" + } } }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/TelegramWebhook" - } - } - } - }, - "responses": { - "200": { - "description": "Acknowledged; no action taken." - } - } - } - }, - "/api/v1/internal/cron/return-reminders": { - "post": { - "operationId": "api_v1_internal_cron_return_reminders_create", - "tags": [ - "Health" - ], - "responses": { - "200": { + "description": "" + }, + "400": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReturnReminderCronResponse" + "type": "object", + "additionalProperties": {} } } }, - "description": "Return reminder run result." + "description": "Invalid booking-rule values or bookings module disabled." }, "403": { - "description": "Invalid cron secret." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Permission denied." }, "404": { - "description": "Cron endpoint is not configured." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Not found." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Workflow conflict." } } } }, - "/api/v1/internal/tls-check": { + "/api/v1/admin/spaces/{id}/bookings/": { "get": { - "operationId": "api_v1_internal_tls_check_retrieve", - "summary": "Check whether on-demand TLS may be issued for a domain", + "operationId": "api_v1_admin_spaces_bookings_retrieve", + "summary": "List bookings for a space", "parameters": [ { "in": "query", - "name": "domain", + "name": "ends_at", "schema": { - "type": "string" + "type": "string", + "format": "date-time" + } + }, + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" }, - "description": "Canonical hostname requested for on-demand TLS issuance.", "required": true - } - ], - "tags": [ - "Internal" - ], - "responses": { - "200": { - "description": "TLS issuance is allowed." }, - "403": { - "description": "TLS issuance is denied." + { + "in": "query", + "name": "starts_at", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "in": "query", + "name": "status", + "schema": { + "enum": [ + "pending", + "confirmed", + "rejected", + "cancelled", + "completed", + "no_show" + ], + "type": "string", + "minLength": 1 + }, + "description": "* `pending` - Pending\n* `confirmed` - Confirmed\n* `rejected` - Rejected\n* `cancelled` - Cancelled\n* `completed` - Completed\n* `no_show` - No-show" } - } - } - }, - "/api/v1/member/archived-payments": { - "get": { - "operationId": "api_v1_member_archived_payments_list", - "description": "Lists archived makerspaces where the caller retains an active membership and has payment history available to read or settle.", - "summary": "Discover the caller's archived makerspace payments", + ], "tags": [ - "Payments" + "Admin bookings" ], "security": [ { @@ -29306,16 +30113,13 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ArchivedPaymentSummary" - } + "$ref": "#/components/schemas/BookingListResponse" } } }, "description": "" }, - "401": { + "400": { "content": { "application/json": { "schema": { @@ -29323,19 +30127,39 @@ } } }, - "description": "" + "description": "Invalid request." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Permission denied." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Not found." } } } }, - "/api/v1/member/makerspaces/{makerspace_id}/activity": { - "get": { - "operationId": "api_v1_member_makerspaces_activity_retrieve", - "summary": "Retrieve the caller's makerspace activity", + "/api/v1/admin/spaces/{id}/deactivate/": { + "post": { + "operationId": "api_v1_admin_spaces_deactivate_create", + "summary": "Deactivate a bookable space", "parameters": [ { "in": "path", - "name": "makerspace_id", + "name": "id", "schema": { "type": "integer" }, @@ -29343,7 +30167,7 @@ } ], "tags": [ - "Member activity" + "Admin bookings" ], "security": [ { @@ -29355,14 +30179,21 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MemberActivity" + "$ref": "#/components/schemas/BookableSpaceAdmin" } } }, "description": "" }, - "401": { - "description": "Authentication required." + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Invalid request." }, "403": { "content": { @@ -29372,7 +30203,7 @@ } } }, - "description": "An active membership is required." + "description": "Permission denied." }, "404": { "content": { @@ -29382,19 +30213,29 @@ } } }, - "description": "Makerspace not found." + "description": "Not found." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Workflow conflict." } } } }, - "/api/v1/member/makerspaces/{makerspace_id}/collaborative-events/": { - "get": { - "operationId": "api_v1_member_makerspaces_collaborative_events_list", - "summary": "List events hosted by accepted collaborators", + "/api/v1/admin/spaces/{id}/image/": { + "delete": { + "operationId": "api_v1_admin_spaces_image_destroy", + "summary": "Delete a space image", "parameters": [ { "in": "path", - "name": "makerspace_id", + "name": "id", "schema": { "type": "integer" }, @@ -29402,7 +30243,7 @@ } ], "tags": [ - "Member events" + "Admin bookings" ], "security": [ { @@ -29410,20 +30251,20 @@ } ], "responses": { - "200": { + "204": { + "description": "No response body" + }, + "400": { "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/CollaborativeEvent" - } + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "" + "description": "Invalid request." }, - "400": { + "403": { "content": { "application/json": { "schema": { @@ -29431,9 +30272,9 @@ } } }, - "description": "Invalid event request." + "description": "Permission denied." }, - "403": { + "404": { "content": { "application/json": { "schema": { @@ -29441,9 +30282,9 @@ } } }, - "description": "Active membership is required." + "description": "Not found." }, - "404": { + "503": { "content": { "application/json": { "schema": { @@ -29451,15 +30292,15 @@ } } }, - "description": "Collaborative event not found." + "description": "Service unavailable." } } } }, - "/api/v1/member/makerspaces/{makerspace_id}/collaborative-events/{id}/register/": { + "/api/v1/admin/spaces/{id}/image/finalize/": { "post": { - "operationId": "api_v1_member_makerspaces_collaborative_events_register_create", - "summary": "Register for a collaborative event", + "operationId": "api_v1_admin_spaces_image_finalize_create", + "summary": "Finalize and attach a space image", "parameters": [ { "in": "path", @@ -29468,37 +30309,30 @@ "type": "integer" }, "required": true - }, - { - "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" - }, - "required": true } ], "tags": [ - "Member events" + "Admin bookings" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CollaborativeEventRegistrationInput" + "$ref": "#/components/schemas/SpaceImageFinalizeRequest" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/CollaborativeEventRegistrationInput" + "$ref": "#/components/schemas/SpaceImageFinalizeRequest" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/CollaborativeEventRegistrationInput" + "$ref": "#/components/schemas/SpaceImageFinalizeRequest" } } - } + }, + "required": true }, "security": [ { @@ -29506,11 +30340,11 @@ } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicEventRegistrationResponse" + "$ref": "#/components/schemas/BookableSpaceAdmin" } } }, @@ -29524,7 +30358,7 @@ } } }, - "description": "Invalid event request." + "description": "Invalid request." }, "403": { "content": { @@ -29534,7 +30368,7 @@ } } }, - "description": "Active membership is required." + "description": "Permission denied." }, "404": { "content": { @@ -29544,19 +30378,9 @@ } } }, - "description": "Collaborative event not found." - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Event state conflict." + "description": "Not found." }, - "429": { + "503": { "content": { "application/json": { "schema": { @@ -29564,19 +30388,19 @@ } } }, - "description": "Rate limit exceeded." + "description": "Service unavailable." } } } }, - "/api/v1/member/makerspaces/{makerspace_id}/directory": { - "get": { - "operationId": "api_v1_member_makerspaces_directory_retrieve", - "summary": "List members who published a profile", + "/api/v1/admin/spaces/{id}/image/presign/": { + "post": { + "operationId": "api_v1_admin_spaces_image_presign_create", + "summary": "Create a space image upload URL", "parameters": [ { "in": "path", - "name": "makerspace_id", + "name": "id", "schema": { "type": "integer" }, @@ -29584,26 +30408,53 @@ } ], "tags": [ - "Member profile" + "Admin bookings" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SpaceImagePresignRequest" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/SpaceImagePresignRequest" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/SpaceImagePresignRequest" + } + } + }, + "required": true + }, "security": [ { "jwtAuth": [] } ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Directory" + "$ref": "#/components/schemas/SpaceImagePresignResponse" } } }, "description": "" }, - "401": { - "description": "Authentication required." + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Invalid request." }, "403": { "content": { @@ -29613,7 +30464,7 @@ } } }, - "description": "An active membership is required." + "description": "Permission denied." }, "404": { "content": { @@ -29624,26 +30475,28 @@ } }, "description": "Not found." + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Service unavailable." } } } }, - "/api/v1/member/makerspaces/{makerspace_id}/directory/{membership_id}": { + "/api/v1/admin/stock-transfers/{id}": { "get": { - "operationId": "api_v1_member_makerspaces_directory_retrieve_2", - "summary": "Retrieve another member's published profile", + "operationId": "api_v1_admin_stock_transfers_retrieve", + "summary": "Retrieve stock transfer", "parameters": [ { "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" - }, - "required": true - }, - { - "in": "path", - "name": "membership_id", + "name": "id", "schema": { "type": "integer" }, @@ -29651,7 +30504,7 @@ } ], "tags": [ - "Member profile" + "Stock transfers" ], "security": [ { @@ -29663,42 +30516,38 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ProfileRead" + "$ref": "#/components/schemas/StockTransfer" } } }, "description": "" }, - "401": { - "description": "Authentication required." - }, - "403": { + "400": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/GenericObject" } } }, - "description": "An active membership is required." + "description": "Invalid request." + }, + "401": { + "description": "Authentication credentials were not provided." + }, + "403": { + "description": "Permission denied." }, "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, "description": "Not found." } } } }, - "/api/v1/member/makerspaces/{makerspace_id}/event-registrations/{id}/qr": { + "/api/v1/admin/stocktakes/{id}": { "get": { - "operationId": "api_v1_member_makerspaces_event_registrations_qr_retrieve", - "summary": "Render the caller's event check-in QR code", + "operationId": "api_v1_admin_stocktakes_retrieve", + "summary": "Retrieve stocktake", "parameters": [ { "in": "path", @@ -29707,18 +30556,10 @@ "type": "integer" }, "required": true - }, - { - "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" - }, - "required": true } ], "tags": [ - "Member activity" + "Stocktake" ], "security": [ { @@ -29728,46 +30569,44 @@ "responses": { "200": { "content": { - "image/svg+xml": { + "application/json": { "schema": { - "type": "string", - "format": "binary" + "$ref": "#/components/schemas/Stocktake" } } }, - "description": "Check-in QR code as SVG." + "description": "" }, - "403": { + "400": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/GenericObject" } } }, - "description": "An active membership is required." + "description": "Invalid request or stocktake state." + }, + "401": { + "description": "Authentication credentials were not provided." + }, + "403": { + "description": "Permission denied." }, "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Registration not found." + "description": "Not found." } } } }, - "/api/v1/member/makerspaces/{makerspace_id}/payments": { - "get": { - "operationId": "api_v1_member_makerspaces_payments_list", - "summary": "List the caller's payment history", + "/api/v1/admin/stocktakes/{id}/apply-adjustments": { + "post": { + "operationId": "api_v1_admin_stocktakes_apply_adjustments_create", + "summary": "Apply stocktake adjustments", "parameters": [ { "in": "path", - "name": "makerspace_id", + "name": "id", "schema": { "type": "integer" }, @@ -29775,7 +30614,7 @@ } ], "tags": [ - "Payments" + "Stocktake" ], "security": [ { @@ -29787,44 +30626,42 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/MemberPayment" - } + "$ref": "#/components/schemas/Stocktake" } } }, "description": "" }, - "403": { + "400": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/GenericObject" } } }, - "description": "" + "description": "Invalid request or stocktake state." + }, + "401": { + "description": "Authentication credentials were not provided." + }, + "403": { + "description": "Permission denied." + }, + "404": { + "description": "Not found." } } } }, - "/api/v1/member/makerspaces/{makerspace_id}/payments/{payment_id}/checkout": { + "/api/v1/admin/stocktakes/{id}/approve": { "post": { - "operationId": "api_v1_member_makerspaces_payments_checkout_create", - "summary": "Generate a Checkout link for the caller's pending payment", + "operationId": "api_v1_admin_stocktakes_approve_create", + "summary": "Approve stocktake", "parameters": [ { "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" - }, - "required": true - }, - { - "in": "path", - "name": "payment_id", + "name": "id", "schema": { "type": "integer" }, @@ -29832,7 +30669,7 @@ } ], "tags": [ - "Payments" + "Stocktake" ], "security": [ { @@ -29844,51 +30681,42 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CheckoutUrl" + "$ref": "#/components/schemas/Stocktake" } } }, "description": "" }, - "404": { + "400": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/GenericObject" } } }, - "description": "" + "description": "Invalid request or stocktake state." }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "401": { + "description": "Authentication credentials were not provided." + }, + "403": { + "description": "Permission denied." + }, + "404": { + "description": "Not found." } } } }, - "/api/v1/member/makerspaces/{makerspace_id}/payments/{payment_id}/mobile-intent": { + "/api/v1/admin/stocktakes/{id}/complete": { "post": { - "operationId": "api_v1_member_makerspaces_payments_mobile_intent_create", - "summary": "Create or retrieve a native mobile payment intent", + "operationId": "api_v1_admin_stocktakes_complete_create", + "summary": "Complete stocktake", "parameters": [ { "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" - }, - "required": true - }, - { - "in": "path", - "name": "payment_id", + "name": "id", "schema": { "type": "integer" }, @@ -29896,7 +30724,7 @@ } ], "tags": [ - "Payments" + "Stocktake" ], "security": [ { @@ -29908,73 +30736,117 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MobilePaymentIntentResponse" + "$ref": "#/components/schemas/Stocktake" } } }, "description": "" }, - "401": { + "400": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/GenericObject" } } }, - "description": "" + "description": "Invalid request or stocktake state." + }, + "401": { + "description": "Authentication credentials were not provided." }, "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "description": "Permission denied." }, "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } + "description": "Not found." + } + } + } + }, + "/api/v1/admin/stocktakes/{id}/count-lines": { + "post": { + "operationId": "api_v1_admin_stocktakes_count_lines_create", + "summary": "Count stocktake line", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Stocktake" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StocktakeLineInput" } }, - "description": "" + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/StocktakeLineInput" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/StocktakeLineInput" + } + } }, - "409": { + "required": true + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/StocktakeLine" } } }, "description": "" }, - "503": { + "400": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/GenericObject" } } }, - "description": "" + "description": "Invalid request or stocktake state." + }, + "401": { + "description": "Authentication credentials were not provided." + }, + "403": { + "description": "Permission denied." + }, + "404": { + "description": "Not found." } } } }, - "/api/v1/member/makerspaces/{makerspace_id}/profile": { - "get": { - "operationId": "api_v1_member_makerspaces_profile_retrieve", - "summary": "Retrieve the caller's own profile", + "/api/v1/admin/stocktakes/{id}/resolve-scan": { + "post": { + "operationId": "api_v1_admin_stocktakes_resolve_scan_create", + "summary": "Resolve a scanned QR to a stocktake count target", "parameters": [ { "in": "path", - "name": "makerspace_id", + "name": "id", "schema": { "type": "integer" }, @@ -29982,8 +30854,28 @@ } ], "tags": [ - "Member profile" + "Stocktake" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StocktakeScanInput" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/StocktakeScanInput" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/StocktakeScanInput" + } + } + }, + "required": true + }, "security": [ { "jwtAuth": [] @@ -29994,44 +30886,42 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ProfileRead" + "$ref": "#/components/schemas/StocktakeScanResult" } } }, "description": "" }, - "401": { - "description": "Authentication required." - }, - "403": { + "400": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/GenericObject" } } }, - "description": "An active membership is required." + "description": "Invalid request or stocktake state." + }, + "401": { + "description": "Authentication credentials were not provided." + }, + "403": { + "description": "Permission denied." }, "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, "description": "Not found." } } - }, - "put": { - "operationId": "api_v1_member_makerspaces_profile_update", - "summary": "Update the caller's own profile", + } + }, + "/api/v1/admin/users/{id}/reset-password": { + "post": { + "operationId": "api_v1_admin_users_reset_password_create", + "summary": "Reset a staff user's password (temp + force change)", "parameters": [ { "in": "path", - "name": "makerspace_id", + "name": "id", "schema": { "type": "integer" }, @@ -30039,23 +30929,23 @@ } ], "tags": [ - "Member profile" + "Admin users" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ProfileWrite" + "$ref": "#/components/schemas/ResetPasswordRequest" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/ProfileWrite" + "$ref": "#/components/schemas/ResetPasswordRequest" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/ProfileWrite" + "$ref": "#/components/schemas/ResetPasswordRequest" } } } @@ -30070,46 +30960,59 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ProfileRead" + "$ref": "#/components/schemas/ResetPasswordResponse" } } }, "description": "" - }, - "401": { - "description": "Authentication required." - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } + } + } + } + }, + "/api/v1/admin/users/{id}/restore-access": { + "post": { + "operationId": "api_v1_admin_users_restore_access_create", + "summary": "Restore user access", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" }, - "description": "An active membership is required." - }, - "404": { + "required": true + } + ], + "tags": [ + "Admin users" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/User" } } }, - "description": "Not found." + "description": "" } } } }, - "/api/v1/member/makerspaces/{makerspace_id}/profile/image": { + "/api/v1/admin/users/{id}/restrict": { "post": { - "operationId": "api_v1_member_makerspaces_profile_image_create", - "summary": "Create a profile image upload URL", + "operationId": "api_v1_admin_users_restrict_create", + "summary": "Restrict or suspend a user", "parameters": [ { "in": "path", - "name": "makerspace_id", + "name": "id", "schema": { "type": "integer" }, @@ -30117,23 +31020,32 @@ } ], "tags": [ - "Member profile" + "Admin users" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ProfileImageUploadRequest" + "$ref": "#/components/schemas/RestrictUser" + }, + "examples": { + "RestrictARequester": { + "value": { + "status": "restricted", + "reason": "Unreturned loan under review" + }, + "summary": "Restrict a requester" + } } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/ProfileImageUploadRequest" + "$ref": "#/components/schemas/RestrictUser" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/ProfileImageUploadRequest" + "$ref": "#/components/schemas/RestrictUser" } } }, @@ -30145,61 +31057,76 @@ } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicImageUploadResponse" + "$ref": "#/components/schemas/User" } } }, "description": "" - }, - "400": { - "description": "Invalid image upload request." - }, - "403": { - "description": "An active membership is required." - }, - "429": { - "description": "Too many image upload requests." - }, - "503": { - "description": "Public image storage is unavailable." } } - }, - "put": { - "operationId": "api_v1_member_makerspaces_profile_image_update", - "summary": "Attach an uploaded image to the profile or one of its projects", + } + }, + "/api/v1/admin/users/inventory-managers": { + "get": { + "operationId": "api_v1_admin_users_inventory_managers_list", + "summary": "List or create staff memberships", "parameters": [ { - "in": "path", - "name": "makerspace_id", + "name": "page", + "required": false, + "in": "query", + "description": "A page number within the paginated result set.", "schema": { "type": "integer" - }, - "required": true + } } ], "tags": [ - "Member profile" + "Admin users" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaginatedStaffMembershipList" + } + } + }, + "description": "" + } + } + }, + "post": { + "operationId": "api_v1_admin_users_inventory_managers_create", + "summary": "List or create staff memberships", + "tags": [ + "Admin users" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ProfileImageAttachRequest" + "$ref": "#/components/schemas/StaffMembership" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/ProfileImageAttachRequest" + "$ref": "#/components/schemas/StaffMembership" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/ProfileImageAttachRequest" + "$ref": "#/components/schemas/StaffMembership" } } }, @@ -30211,42 +31138,36 @@ } ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ProfileRead" + "$ref": "#/components/schemas/StaffMembership" } } }, "description": "" - }, - "400": { - "description": "Invalid image object key or size." - }, - "403": { - "description": "An active membership is required." - }, - "503": { - "description": "Public image storage is unavailable." } } - }, - "delete": { - "operationId": "api_v1_member_makerspaces_profile_image_destroy", - "summary": "Clear a profile or project image", + } + }, + "/api/v1/admin/users/machine-managers": { + "get": { + "operationId": "api_v1_admin_users_machine_managers_list", + "summary": "List or create staff memberships", "parameters": [ { - "in": "path", - "name": "makerspace_id", + "name": "page", + "required": false, + "in": "query", + "description": "A page number within the paginated result set.", "schema": { "type": "integer" - }, - "required": true + } } ], "tags": [ - "Member profile" + "Admin users" ], "security": [ { @@ -30258,52 +31179,35 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ProfileRead" + "$ref": "#/components/schemas/PaginatedStaffMembershipList" } } }, "description": "" - }, - "400": { - "description": "Unknown project." - }, - "403": { - "description": "An active membership is required." } } - } - }, - "/api/v1/member/makerspaces/{makerspace_id}/referrals": { + }, "post": { - "operationId": "api_v1_member_makerspaces_referrals_create", - "parameters": [ - { - "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" - }, - "required": true - } - ], + "operationId": "api_v1_admin_users_machine_managers_create", + "summary": "List or create staff memberships", "tags": [ - "Memberships" + "Admin users" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReferralCreate" + "$ref": "#/components/schemas/StaffMembership" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/ReferralCreate" + "$ref": "#/components/schemas/StaffMembership" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/ReferralCreate" + "$ref": "#/components/schemas/StaffMembership" } } }, @@ -30319,57 +31223,88 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReferralOutcome" + "$ref": "#/components/schemas/StaffMembership" } } }, "description": "" - }, - "400": { + } + } + } + }, + "/api/v1/admin/users/space-managers": { + "get": { + "operationId": "api_v1_admin_users_space_managers_list", + "summary": "List or create staff memberships", + "parameters": [ + { + "name": "page", + "required": false, + "in": "query", + "description": "A page number within the paginated result set.", + "schema": { + "type": "integer" + } + } + ], + "tags": [ + "Admin users" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/PaginatedStaffMembershipList" } } }, "description": "" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } + } + } + }, + "post": { + "operationId": "api_v1_admin_users_space_managers_create", + "summary": "List or create staff memberships", + "tags": [ + "Admin users" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StaffMembership" } }, - "description": "" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/StaffMembership" } }, - "description": "" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/StaffMembership" } - }, - "description": "" + } }, - "409": { + "required": true + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/StaffMembership" } } }, @@ -30378,13 +31313,14 @@ } } }, - "/api/v1/member/makerspaces/{makerspace_id}/waiver": { + "/api/v1/admin/warranty/{id}/documents": { "get": { - "operationId": "api_v1_member_makerspaces_waiver_retrieve", + "operationId": "api_v1_admin_warranty_documents_list", + "summary": "List documents attached to a warranty", "parameters": [ { "in": "path", - "name": "makerspace_id", + "name": "id", "schema": { "type": "integer" }, @@ -30392,7 +31328,7 @@ } ], "tags": [ - "Memberships" + "Admin warranty" ], "security": [ { @@ -30404,17 +31340,10 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MemberWaiverResponse" - } - } - }, - "description": "" - }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "type": "array", + "items": { + "$ref": "#/components/schemas/WarrantyDocument" + } } } }, @@ -30449,37 +31378,16 @@ } }, "description": "" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" - }, - "429": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" } } - } - }, - "/api/v1/member/makerspaces/{makerspace_id}/waiver/accept": { + }, "post": { - "operationId": "api_v1_member_makerspaces_waiver_accept_create", + "operationId": "api_v1_admin_warranty_documents_create", + "summary": "Finalize an uploaded warranty document", "parameters": [ { "in": "path", - "name": "makerspace_id", + "name": "id", "schema": { "type": "integer" }, @@ -30487,33 +31395,46 @@ } ], "tags": [ - "Memberships" + "Admin warranty" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WarrantyDocumentFinalize" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/WarrantyDocumentFinalize" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/WarrantyDocumentFinalize" + } + } + }, + "required": true + }, "security": [ { "jwtAuth": [] } ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WaiverAcceptResponse" + "$ref": "#/components/schemas/WarrantyDocument" } } }, "description": "" }, "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "description": "Invalid or duplicate warranty document." }, "401": { "content": { @@ -30545,32 +31466,16 @@ }, "description": "" }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" - }, - "429": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "503": { + "description": "Warranty document storage is unavailable." } } } }, - "/api/v1/memberships/{id}/accept-invitation": { + "/api/v1/admin/warranty/{id}/documents/presign": { "post": { - "operationId": "api_v1_memberships_accept_invitation_create", + "operationId": "api_v1_admin_warranty_documents_presign_create", + "summary": "Create a warranty document upload URL", "parameters": [ { "in": "path", @@ -30582,33 +31487,46 @@ } ], "tags": [ - "Memberships" + "Admin warranty" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WarrantyDocumentPresign" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/WarrantyDocumentPresign" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/WarrantyDocumentPresign" + } + } + }, + "required": true + }, "security": [ { "jwtAuth": [] } ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/InvitationClaimOutcome" + "$ref": "#/components/schemas/WarrantyDocumentUploadResponse" } } }, "description": "" }, "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "description": "Invalid document upload request." }, "401": { "content": { @@ -30639,15 +31557,29 @@ } }, "description": "" + }, + "503": { + "description": "Warranty document storage is unavailable." } } } }, - "/api/v1/memberships/invitations": { - "get": { - "operationId": "api_v1_memberships_invitations_retrieve", + "/api/v1/admin/warranty/documents/{id}": { + "delete": { + "operationId": "api_v1_admin_warranty_documents_destroy", + "summary": "Delete a warranty document", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + } + ], "tags": [ - "Memberships" + "Admin warranty" ], "security": [ { @@ -30655,25 +31587,8 @@ } ], "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/InvitationList" - } - } - }, - "description": "" - }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" + "204": { + "description": "No response body" }, "401": { "content": { @@ -30708,9 +31623,10 @@ } } }, - "/api/v1/memberships/invitations/{id}/claim": { - "post": { - "operationId": "api_v1_memberships_invitations_claim_create", + "/api/v1/admin/warranty/documents/{id}/url": { + "get": { + "operationId": "api_v1_admin_warranty_documents_url_retrieve", + "summary": "Create a signed warranty document view URL", "parameters": [ { "in": "path", @@ -30722,7 +31638,7 @@ } ], "tags": [ - "Memberships" + "Admin warranty" ], "security": [ { @@ -30734,17 +31650,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/InvitationClaimOutcome" - } - } - }, - "description": "" - }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/WarrantyDocumentUrl" } } }, @@ -30779,16 +31685,40 @@ } }, "description": "" + }, + "503": { + "description": "Warranty document storage is unavailable." } } } }, - "/api/v1/memberships/me": { - "get": { - "operationId": "api_v1_memberships_me_retrieve", + "/api/v1/auth/change-password": { + "post": { + "operationId": "api_v1_auth_change_password_create", + "summary": "Change current user's password", "tags": [ - "Memberships" + "Auth" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChangePassword" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/ChangePassword" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/ChangePassword" + } + } + }, + "required": true + }, "security": [ { "jwtAuth": [] @@ -30799,23 +31729,60 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MyMemberships" + "$ref": "#/components/schemas/ChangePasswordResponse" } } }, "description": "" }, "400": { + "description": "Password validation failed." + }, + "401": { + "description": "Authentication credentials were not provided." + } + } + } + }, + "/api/v1/auth/claim/redeem": { + "post": { + "operationId": "api_v1_auth_claim_redeem_create", + "summary": "Redeem a staff-issued member claim code", + "tags": [ + "Auth" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ClaimRedemption" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/ClaimRedemption" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/ClaimRedemption" + } + } + }, + "required": true + }, + "responses": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/ClaimRedemptionResponse" } } }, "description": "" }, - "401": { + "400": { "content": { "application/json": { "schema": { @@ -30823,19 +31790,55 @@ } } }, - "description": "" + "description": "Invalid or expired claim code." }, - "403": { + "404": { + "description": "Makerspace not found." + }, + "429": { + "description": "Redemption rate limit exceeded." + } + } + } + }, + "/api/v1/auth/device/attestation-challenge": { + "post": { + "operationId": "api_v1_auth_device_attestation_challenge_create", + "tags": [ + "Device auth" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceIdentity" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/DeviceIdentity" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/DeviceIdentity" + } + } + }, + "required": true + }, + "responses": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/DeviceChallengeResponse" } } }, "description": "" }, - "404": { + "400": { "content": { "application/json": { "schema": { @@ -30845,7 +31848,7 @@ }, "description": "" }, - "409": { + "429": { "content": { "application/json": { "schema": { @@ -30855,7 +31858,7 @@ }, "description": "" }, - "429": { + "503": { "content": { "application/json": { "schema": { @@ -30868,37 +31871,11 @@ } } }, - "/api/v1/notifications/makerspace/{makerspace_id}": { + "/api/v1/auth/device/grants": { "get": { - "operationId": "api_v1_notifications_makerspace_list", - "summary": "List makerspace notifications", - "parameters": [ - { - "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" - }, - "required": true - }, - { - "in": "query", - "name": "page", - "schema": { - "type": "integer" - } - }, - { - "in": "query", - "name": "unread", - "schema": { - "type": "boolean" - }, - "description": "When true, return only unread notifications." - } - ], + "operationId": "api_v1_auth_device_grants_list", "tags": [ - "Notifications" + "Device auth" ], "security": [ { @@ -30910,48 +31887,44 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaginatedNotificationList" + "type": "array", + "items": { + "$ref": "#/components/schemas/DeviceGrant" + } } } }, "description": "" }, - "400": { - "description": "Notifications module is disabled." - }, - "403": { - "description": "Permission denied." - }, - "404": { - "description": "Not found." + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" } } } }, - "/api/v1/notifications/makerspace/{makerspace_id}/{id}/read": { - "post": { - "operationId": "api_v1_notifications_makerspace_read_create", - "summary": "Mark a makerspace notification read", + "/api/v1/auth/device/grants/{grant_id}": { + "delete": { + "operationId": "api_v1_auth_device_grants_destroy", "parameters": [ { "in": "path", - "name": "id", - "schema": { - "type": "integer" - }, - "required": true - }, - { - "in": "path", - "name": "makerspace_id", + "name": "grant_id", "schema": { - "type": "integer" + "type": "string", + "format": "uuid" }, "required": true } ], "tags": [ - "Notifications" + "Device auth" ], "security": [ { @@ -30959,89 +31932,107 @@ } ], "responses": { - "200": { + "204": { + "description": "No response body" + }, + "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Notification" + "$ref": "#/components/schemas/HardwareRequestError" } } }, "description": "" }, - "400": { - "description": "Notifications module is disabled." - }, - "403": { - "description": "Permission denied." - }, "404": { - "description": "Not found." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" } } } }, - "/api/v1/notifications/makerspace/{makerspace_id}/read-all": { + "/api/v1/auth/device/login": { "post": { - "operationId": "api_v1_notifications_makerspace_read_all_create", - "summary": "Mark all makerspace notifications read", - "parameters": [ - { - "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" - }, - "required": true - } - ], + "operationId": "api_v1_auth_device_login_create", "tags": [ - "Notifications" - ], - "security": [ - { - "jwtAuth": [] - } + "Device auth" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceLogin" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/DeviceLogin" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/DeviceLogin" + } + } + }, + "required": true + }, "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/NotificationMarkAllRead" + "$ref": "#/components/schemas/DeviceTokenResponse" } } }, "description": "" }, - "400": { - "description": "Notifications module is disabled." + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" }, "403": { - "description": "Permission denied." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" }, - "404": { - "description": "Not found." + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" } } } }, - "/api/v1/notifications/makerspace/{makerspace_id}/unread-count": { - "get": { - "operationId": "api_v1_notifications_makerspace_unread_count_retrieve", - "summary": "Get unread makerspace notification count", - "parameters": [ - { - "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" - }, - "required": true - } - ], + "/api/v1/auth/device/logout": { + "post": { + "operationId": "api_v1_auth_device_logout_create", "tags": [ - "Notifications" + "Device auth" ], "security": [ { @@ -31053,115 +32044,67 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/NotificationUnreadCount" + "$ref": "#/components/schemas/DeviceLogoutResponse" } } }, "description": "" }, - "400": { - "description": "Notifications module is disabled." + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" }, "403": { - "description": "Permission denied." - }, - "404": { - "description": "Not found." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" } } } }, - "/api/v1/payments/connect/callback": { - "get": { - "operationId": "api_v1_payments_connect_callback_retrieve", - "summary": "Complete Stripe Connect onboarding", - "parameters": [ - { - "in": "query", - "name": "code", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "error", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "state", - "schema": { - "type": "string" - }, - "required": true - } - ], + "/api/v1/auth/device/refresh": { + "post": { + "operationId": "api_v1_auth_device_refresh_create", "tags": [ - "Payments" + "Device auth" ], - "responses": { - "302": { - "description": "Redirect to trusted staff settings." - } - } - } - }, - "/api/v1/procurement/makerspace/{makerspace_id}/to-buy": { - "get": { - "operationId": "api_v1_procurement_makerspace_to_buy_list", - "summary": "List to-buy items for a makerspace", - "parameters": [ - { - "in": "query", - "name": "limit", - "schema": { - "type": "integer" - } - }, - { - "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceRefresh" + } }, - "required": true - }, - { - "in": "query", - "name": "status", - "schema": { - "type": "string", - "enum": [ - "approved", - "cancelled", - "ordered", - "received", - "requested" - ] + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/DeviceRefresh" + } }, - "description": "Filter by procurement item status." - } - ], - "tags": [ - "Procurement" - ], - "security": [ - { - "jwtAuth": [] - } - ], + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/DeviceRefresh" + } + } + }, + "required": true + }, "responses": { "200": { "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ToBuyItem" - } + "$ref": "#/components/schemas/DeviceRefreshResponse" } } }, @@ -31171,66 +32114,56 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Invalid request." + "description": "" }, "401": { - "description": "Authentication credentials were not provided." - }, - "403": { - "description": "Permission denied." - }, - "404": { - "description": "Not found." - } - } - }, - "post": { - "operationId": "api_v1_procurement_makerspace_to_buy_create", - "summary": "Add a to-buy item", - "parameters": [ - { - "in": "query", - "name": "kind", - "schema": { - "type": "string", - "enum": [ - "hardware", - "printing" - ] + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } }, - "description": "Stream to add to. Honored only for makerspace admins/superadmin; other roles are auto-tagged by role." + "description": "" }, - { - "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } }, - "required": true + "description": "" } - ], + } + } + }, + "/api/v1/auth/email-verification/confirm": { + "post": { + "operationId": "api_v1_auth_email_verification_confirm_create", "tags": [ - "Procurement" + "Auth" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ToBuyItem" + "$ref": "#/components/schemas/EmailVerificationConfirm" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/ToBuyItem" + "$ref": "#/components/schemas/EmailVerificationConfirm" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/ToBuyItem" + "$ref": "#/components/schemas/EmailVerificationConfirm" } } }, @@ -31242,25 +32175,18 @@ } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ToBuyItem" + "$ref": "#/components/schemas/MemberVerificationAck" } } }, "description": "" }, "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "description": "Invalid request." + "description": "Invalid or expired verification code." }, "401": { "description": "Authentication credentials were not provided." @@ -31268,54 +32194,17 @@ "403": { "description": "Permission denied." }, - "404": { - "description": "Not found." + "429": { + "description": "Request throttled." } } } }, - "/api/v1/procurement/makerspace/{makerspace_id}/to-buy/export": { - "get": { - "operationId": "api_v1_procurement_makerspace_to_buy_export_retrieve", - "summary": "Export to-buy items as CSV or XLSX", - "parameters": [ - { - "in": "query", - "name": "format", - "schema": { - "type": "string", - "enum": [ - "csv", - "xlsx" - ] - } - }, - { - "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" - }, - "required": true - }, - { - "in": "query", - "name": "status", - "schema": { - "type": "string", - "enum": [ - "approved", - "cancelled", - "ordered", - "received", - "requested" - ] - }, - "description": "Filter by procurement item status." - } - ], + "/api/v1/auth/email-verification/resend": { + "post": { + "operationId": "api_v1_auth_email_verification_resend_create", "tags": [ - "Procurement" + "Auth" ], "security": [ { @@ -31325,28 +32214,15 @@ "responses": { "200": { "content": { - "text/csv": { - "schema": { - "type": "string" - } - }, - "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": { + "application/json": { "schema": { - "type": "string", - "format": "binary" + "$ref": "#/components/schemas/MemberVerificationAck" } } }, "description": "" }, "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, "description": "Invalid request." }, "401": { @@ -31355,220 +32231,166 @@ "403": { "description": "Permission denied." }, - "404": { - "description": "Not found." + "429": { + "description": "Request throttled." } } } }, - "/api/v1/procurement/makerspace/{makerspace_id}/to-buy/machine-types": { - "get": { - "operationId": "api_v1_procurement_makerspace_to_buy_machine_types_retrieve", - "summary": "List machine types available for a new to-buy item", - "parameters": [ - { - "in": "query", - "name": "kind", - "schema": { - "type": "string", - "enum": [ - "hardware", - "printing" - ] - }, - "description": "Stream to add to. Honored only for makerspace admins/superadmin; other roles are auto-tagged by role." - }, - { - "in": "path", - "name": "makerspace_id", - "schema": { - "type": "integer" - }, - "required": true - } - ], + "/api/v1/auth/forgot-password": { + "post": { + "operationId": "api_v1_auth_forgot_password_create", + "summary": "Request an emailed password reset code", "tags": [ - "Procurement" - ], - "security": [ - { - "jwtAuth": [] - } + "Auth" ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ToBuyMachineTypeOptions" - } + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ForgotPasswordRequest" } }, - "description": "" - }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/ForgotPasswordRequest" } }, - "description": "Invalid request." - }, - "401": { - "description": "Authentication credentials were not provided." - }, - "403": { - "description": "Permission denied." + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/ForgotPasswordRequest" + } + } }, - "404": { - "description": "Not found." - } - } - } - }, - "/api/v1/procurement/to-buy/{id}": { - "get": { - "operationId": "api_v1_procurement_to_buy_retrieve", - "summary": "Retrieve a to-buy item", - "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" - }, - "required": true - } - ], - "tags": [ - "Procurement" - ], - "security": [ - { - "jwtAuth": [] - } - ], + "required": true + }, "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ToBuyItem" + "$ref": "#/components/schemas/PasswordResetAcknowledgement" } } }, "description": "" }, "400": { + "description": "Invalid request." + }, + "429": { + "description": "Request throttled." + }, + "503": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/RecoveryUnavailable" } } }, - "description": "Invalid request." - }, - "401": { - "description": "Authentication credentials were not provided." - }, - "403": { - "description": "Permission denied." - }, - "404": { - "description": "Not found." + "description": "" } } - }, - "patch": { - "operationId": "api_v1_procurement_to_buy_partial_update", - "summary": "Update a to-buy item", - "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" - }, - "required": true - } - ], + } + }, + "/api/v1/auth/login": { + "post": { + "operationId": "api_v1_auth_login_create", + "description": "Takes a set of user credentials and returns an access and refresh JSON web\ntoken pair to prove the authentication of those credentials.", + "summary": "Log in with a password", "tags": [ - "Procurement" + "Auth" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PatchedToBuyItem" + "$ref": "#/components/schemas/LoginRequest" + }, + "examples": { + "StaffLogin": { + "value": { + "username": "admin", + "password": "secret-password", + "surface": "staff" + }, + "summary": "Staff login" + } } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/PatchedToBuyItem" + "$ref": "#/components/schemas/LoginRequest" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/PatchedToBuyItem" + "$ref": "#/components/schemas/LoginRequest" } } - } + }, + "required": true }, - "security": [ - { - "jwtAuth": [] - } - ], "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ToBuyItem" + "$ref": "#/components/schemas/LoginResponse" } } }, "description": "" }, "400": { + "description": "Invalid request." + }, + "401": { + "description": "Invalid credentials or inactive account." + }, + "403": { + "description": "Account access is restricted." + }, + "429": { + "description": "Request throttled." + } + } + } + }, + "/api/v1/auth/logout": { + "post": { + "operationId": "api_v1_auth_logout_create", + "summary": "Log out and clear refresh cookie", + "tags": [ + "Auth" + ], + "responses": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/LogoutResponse" } } }, - "description": "Invalid request." + "description": "" }, "401": { - "description": "Authentication credentials were not provided." + "description": "Refresh token could not be blacklisted." }, "403": { - "description": "Permission denied." - }, - "404": { - "description": "Not found." + "description": "CSRF check failed." } } - }, - "delete": { - "operationId": "api_v1_procurement_to_buy_destroy", - "summary": "Delete a to-buy item", - "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" - }, - "required": true - } - ], + } + }, + "/api/v1/auth/me": { + "get": { + "operationId": "api_v1_auth_me_retrieve", + "summary": "Get current staff profile", "tags": [ - "Procurement" + "Auth" ], "security": [ { @@ -31576,17 +32398,17 @@ } ], "responses": { - "204": { - "description": "No response body" - }, - "400": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/AuthUserPayload" } } }, + "description": "" + }, + "400": { "description": "Invalid request." }, "401": { @@ -31595,119 +32417,86 @@ "403": { "description": "Permission denied." }, - "404": { - "description": "Not found." + "429": { + "description": "Request throttled." } } } }, - "/api/v1/procurement/to-buy/{id}/move-to-inventory": { + "/api/v1/auth/member-sign-up": { "post": { - "operationId": "api_v1_procurement_to_buy_move_to_inventory_create", - "summary": "Move a received hardware to-buy item into inventory", - "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" - }, - "required": true - } - ], + "operationId": "api_v1_auth_member_sign_up_create", "tags": [ - "Procurement" + "Auth" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MoveToInventoryRequest" + "$ref": "#/components/schemas/MemberSignUp" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/MoveToInventoryRequest" + "$ref": "#/components/schemas/MemberSignUp" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/MoveToInventoryRequest" + "$ref": "#/components/schemas/MemberSignUp" } } }, "required": true }, - "security": [ - { - "jwtAuth": [] - } - ], "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ToBuyItem" + "$ref": "#/components/schemas/MemberVerificationAck" } } }, "description": "" }, "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "description": "Invalid request." + "description": "Invalid details." }, "401": { - "description": "Authentication credentials were not provided." + "description": "Authentication failed." }, "403": { "description": "Permission denied." }, - "404": { - "description": "Not found." + "429": { + "description": "Request throttled." } } } }, - "/api/v1/procurement/to-buy/{id}/move-to-printing": { + "/api/v1/auth/organization-invitations/redeem/": { "post": { - "operationId": "api_v1_procurement_to_buy_move_to_printing_create", - "summary": "Move a received printing to-buy item into printing assets", - "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" - }, - "required": true - } - ], + "operationId": "api_v1_auth_organization_invitations_redeem_create", + "summary": "Redeem a single-use organization invitation", "tags": [ - "Procurement" + "Auth" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MoveToPrintingRequest" + "$ref": "#/components/schemas/OrganizationInvitationRedeem" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/MoveToPrintingRequest" + "$ref": "#/components/schemas/OrganizationInvitationRedeem" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/MoveToPrintingRequest" + "$ref": "#/components/schemas/OrganizationInvitationRedeem" } } }, @@ -31723,50 +32512,58 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ToBuyItem" + "$ref": "#/components/schemas/OrganizationInvitationRedeemed" } } }, "description": "" }, "400": { + "description": "Malformed token." + }, + "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Invalid request." - }, - "401": { - "description": "Authentication credentials were not provided." + "description": "" }, "403": { - "description": "Permission denied." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" }, "404": { - "description": "Not found." + "description": "Invitation not found." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" } } } }, - "/api/v1/procurement/to-buy/{id}/receipts": { - "get": { - "operationId": "api_v1_procurement_to_buy_receipts_list", - "summary": "List procurement receipts for a to-buy item", - "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" - }, - "required": true - } - ], + "/api/v1/auth/phone": { + "delete": { + "operationId": "api_v1_auth_phone_destroy", + "description": "Detach the number. Always safe: every account keeps an email credential.", + "summary": "Unlink the phone number", "tags": [ - "Procurement" + "Auth" ], "security": [ { @@ -31778,67 +32575,38 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ToBuyReceipt" - } + "$ref": "#/components/schemas/PhoneStatus" } } }, "description": "" - }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "description": "Invalid request." - }, - "401": { - "description": "Authentication credentials were not provided." - }, - "403": { - "description": "Permission denied." - }, - "404": { - "description": "Not found." } } - }, + } + }, + "/api/v1/auth/phone/link/confirm": { "post": { - "operationId": "api_v1_procurement_to_buy_receipts_create", - "summary": "Finalize an uploaded procurement receipt", - "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" - }, - "required": true - } - ], + "operationId": "api_v1_auth_phone_link_confirm_create", + "description": "Attach the verified number to the caller's account.", + "summary": "Confirm and link a phone number", "tags": [ - "Procurement" + "Auth" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ToBuyReceiptFinalize" + "$ref": "#/components/schemas/PhoneConfirm" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/ToBuyReceiptFinalize" + "$ref": "#/components/schemas/PhoneConfirm" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/ToBuyReceiptFinalize" + "$ref": "#/components/schemas/PhoneConfirm" } } }, @@ -31850,73 +32618,48 @@ } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ToBuyReceipt" + "$ref": "#/components/schemas/PhoneStatus" } } }, "description": "" }, "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "description": "Invalid request." - }, - "503": { - "description": "Receipt storage is unavailable." - }, - "401": { - "description": "Authentication credentials were not provided." - }, - "403": { - "description": "Permission denied." + "description": "Invalid or expired code." }, - "404": { - "description": "Not found." + "429": { + "description": "Request throttled." } } } }, - "/api/v1/procurement/to-buy/{id}/receipts/presign": { + "/api/v1/auth/phone/link/start": { "post": { - "operationId": "api_v1_procurement_to_buy_receipts_presign_create", - "summary": "Create a procurement receipt upload URL", - "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" - }, - "required": true - } - ], + "operationId": "api_v1_auth_phone_link_start_create", + "description": "Send a code to a number the caller wants to attach to their own account.", + "summary": "Request a code to link a phone number", "tags": [ - "Procurement" + "Auth" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ToBuyReceiptPresign" + "$ref": "#/components/schemas/PhoneStart" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/ToBuyReceiptPresign" + "$ref": "#/components/schemas/PhoneStart" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/ToBuyReceiptPresign" + "$ref": "#/components/schemas/PhoneStart" } } }, @@ -31928,172 +32671,183 @@ } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ToBuyReceiptUploadResponse" + "$ref": "#/components/schemas/PhoneStartResponse" } } }, "description": "" }, "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "description": "Invalid request." - }, - "503": { - "description": "Receipt storage is unavailable." - }, - "401": { - "description": "Authentication credentials were not provided." - }, - "403": { - "description": "Permission denied." + "description": "Invalid or already-linked number." }, "404": { - "description": "Not found." + "description": "Phone sign-in is not configured." + }, + "429": { + "description": "Request throttled." } } } }, - "/api/v1/procurement/to-buy/receipts/{id}": { - "delete": { - "operationId": "api_v1_procurement_to_buy_receipts_destroy", - "summary": "Delete a procurement receipt", - "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" - }, - "required": true - } - ], + "/api/v1/auth/phone/login/confirm": { + "post": { + "operationId": "api_v1_auth_phone_login_confirm_create", + "description": "Exchange a valid code for a MEMBER session. Never mints a staff session.", + "summary": "Sign in with a phone code", "tags": [ - "Procurement" - ], - "security": [ - { - "jwtAuth": [] - } + "Auth" ], - "responses": { - "204": { - "description": "No response body" - }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PhoneConfirm" } }, - "description": "Invalid request." + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/PhoneConfirm" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/PhoneConfirm" + } + } }, - "401": { - "description": "Authentication credentials were not provided." + "required": true + }, + "responses": { + "200": { + "description": "Member session issued." }, - "403": { - "description": "Permission denied." + "400": { + "description": "Invalid or expired code." }, "404": { - "description": "Not found." + "description": "Phone sign-in is not configured." + }, + "429": { + "description": "Request throttled." } } } }, - "/api/v1/procurement/to-buy/receipts/{id}/url": { - "get": { - "operationId": "api_v1_procurement_to_buy_receipts_url_retrieve", - "summary": "Create a signed procurement receipt view URL", - "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" - }, - "required": true - } - ], + "/api/v1/auth/phone/login/start": { + "post": { + "operationId": "api_v1_auth_phone_login_start_create", + "description": "Request a login code for an already-verified number.", + "summary": "Request a phone sign-in code", "tags": [ - "Procurement" - ], - "security": [ - { - "jwtAuth": [] - } + "Auth" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PhoneStart" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/PhoneStart" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/PhoneStart" + } + } + }, + "required": true + }, "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ToBuyReceiptUrl" + "$ref": "#/components/schemas/PhoneStartResponse" } } }, "description": "" }, - "503": { - "description": "Receipt storage is unavailable." - }, "400": { + "description": "Invalid request." + }, + "404": { + "description": "Phone sign-in is not configured." + }, + "429": { + "description": "Request throttled." + } + } + } + }, + "/api/v1/auth/refresh": { + "post": { + "operationId": "api_v1_auth_refresh_create", + "description": "Takes a refresh type JSON web token and returns an access type JSON web\ntoken if the refresh token is valid.", + "summary": "Refresh access token", + "tags": [ + "Auth" + ], + "responses": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/RefreshResponse" } } }, - "description": "Invalid request." + "description": "" }, "401": { - "description": "Authentication credentials were not provided." + "description": "Missing, invalid, or replayed refresh token." }, "403": { - "description": "Permission denied." - }, - "404": { - "description": "Not found." + "description": "CSRF check failed or account restricted." } } } }, - "/api/v1/public/{makerspace_slug}/events/": { - "get": { - "operationId": "api_v1_public_events_list", - "parameters": [ - { - "in": "path", - "name": "makerspace_slug", - "schema": { - "type": "string" - }, - "required": true - } - ], + "/api/v1/auth/reset-password": { + "post": { + "operationId": "api_v1_auth_reset_password_create", + "summary": "Confirm an OTP or coexisting legacy password reset link", "tags": [ - "Public events" + "Auth" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResetPasswordConfirmRequest" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/ResetPasswordConfirmRequest" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/ResetPasswordConfirmRequest" + } + } + } + }, "responses": { "200": { "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PublicEvent" - } + "$ref": "#/components/schemas/PasswordUpdated" } } }, @@ -32103,132 +32857,86 @@ "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": {} - } - } - }, - "description": "Invalid request." - }, - "404": { - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": {} + "$ref": "#/components/schemas/ResetPasswordConfirmError" } } }, - "description": "Event not found." + "description": "" }, "429": { - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": {} - } - } - }, - "description": "Rate limit exceeded." + "description": "Request throttled." } } } }, - "/api/v1/public/{makerspace_slug}/events/{public_token}/register/": { + "/api/v1/auth/social/apple": { "post": { - "operationId": "api_v1_public_events_register_create", - "parameters": [ - { - "in": "path", - "name": "makerspace_slug", - "schema": { - "type": "string" - }, - "required": true - }, - { - "in": "path", - "name": "public_token", - "schema": { - "type": "string", - "format": "uuid" - }, - "required": true - } - ], + "operationId": "api_v1_auth_social_apple_create", "tags": [ - "Public events" + "Social auth" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicEventRegistrationInput" + "$ref": "#/components/schemas/SocialLogin" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/PublicEventRegistrationInput" + "$ref": "#/components/schemas/SocialLogin" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/PublicEventRegistrationInput" + "$ref": "#/components/schemas/SocialLogin" } } - } + }, + "required": true }, - "security": [ - { - "jwtAuth": [] - } - ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicEventRegistrationResponse" + "$ref": "#/components/schemas/SocialLoginResponse" } } }, "description": "" }, - "400": { + "401": { "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": {} + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Invalid request." + "description": "" }, - "404": { + "403": { "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": {} + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Event not found." + "description": "" }, - "429": { + "404": { "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": {} + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Rate limit exceeded." + "description": "" }, - "401": { + "409": { "content": { "application/json": { "schema": { @@ -32236,9 +32944,9 @@ } } }, - "description": "Authentication is required." + "description": "Includes `social_device_restart_required` when burned pre-grant inputs must be replaced." }, - "403": { + "429": { "content": { "application/json": { "schema": { @@ -32246,106 +32954,93 @@ } } }, - "description": "Active membership and current waiver acceptance are required." - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Event state conflict." + "description": "" } } } }, - "/api/v1/public/{makerspace_slug}/inventory/": { - "get": { - "operationId": "api_v1_public_inventory_list", - "description": "List public inventory products for a public makerspace.", - "summary": "List public inventory products", - "parameters": [ - { - "in": "header", - "name": "X-Nonce", - "schema": { - "type": "string" + "/api/v1/auth/social/google": { + "post": { + "operationId": "api_v1_auth_social_google_create", + "tags": [ + "Social auth" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SocialLogin" + } }, - "description": "Unique, unpredictable nonce for HMAC-authenticated server API clients (1-128 characters: letters, digits, `.`, `_`, `~`, or `-`). Include it between `X-Timestamp` and the raw body in the signed bytes: `METHOD\\nFULL_PATH\\nTIMESTAMP\\nNONCE\\nBODY`. It is optional only for publishable-key/browser authentication and during the temporary legacy rollout while `APICLIENT_REQUIRE_NONCE` is disabled." - }, - { - "in": "header", - "name": "X-Publishable-Key", - "schema": { - "type": "string" + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/SocialLogin" + } }, - "description": "Public API key for a makerspace public client. Required when API_CLIENT_AUTH_REQUIRED is enabled." + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/SocialLogin" + } + } }, - { - "in": "query", - "name": "category", - "schema": { - "type": "string" + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SocialLoginResponse" + } + } }, - "description": "Filter public products by category slug." + "description": "" }, - { - "in": "path", - "name": "makerspace_slug", - "schema": { - "type": "string" + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } }, - "description": "Public makerspace code (for example TSEL) or slug.", - "required": true + "description": "" }, - { - "name": "page", - "required": false, - "in": "query", - "description": "A page number within the paginated result set.", - "schema": { - "type": "integer" - } + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" }, - { - "in": "query", - "name": "q", - "schema": { - "type": "string" + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } }, - "description": "Search public products by name or description." + "description": "" }, - { - "in": "query", - "name": "sort", - "schema": { - "type": "string", - "enum": [ - "most_used", - "name", - "popular" - ] + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } }, - "description": "Sort public products." - } - ], - "tags": [ - "Public inventory" - ], - "security": [ - { - "jwtAuth": [] + "description": "Includes `social_device_restart_required` when burned pre-grant inputs must be replaced." }, - {} - ], - "responses": { - "200": { + "429": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaginatedPublicProductList" + "$ref": "#/components/schemas/HardwareRequestError" } } }, @@ -32354,115 +33049,68 @@ } } }, - "/api/v1/public/{makerspace_slug}/inventory/{id}/": { - "get": { - "operationId": "api_v1_public_inventory_retrieve", - "summary": "Get public inventory product detail", - "parameters": [ - { - "in": "header", - "name": "X-Nonce", - "schema": { - "type": "string" - }, - "description": "Unique, unpredictable nonce for HMAC-authenticated server API clients (1-128 characters: letters, digits, `.`, `_`, `~`, or `-`). Include it between `X-Timestamp` and the raw body in the signed bytes: `METHOD\\nFULL_PATH\\nTIMESTAMP\\nNONCE\\nBODY`. It is optional only for publishable-key/browser authentication and during the temporary legacy rollout while `APICLIENT_REQUIRE_NONCE` is disabled." - }, - { - "in": "header", - "name": "X-Publishable-Key", - "schema": { - "type": "string" - }, - "description": "Public API key for a makerspace public client. Required when API_CLIENT_AUTH_REQUIRED is enabled." - }, - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" - }, - "required": true - }, - { - "in": "path", - "name": "makerspace_slug", - "schema": { - "type": "string" - }, - "required": true - } - ], + "/api/v1/auth/social/nonce": { + "post": { + "operationId": "api_v1_auth_social_nonce_create", "tags": [ - "Public inventory" + "Social auth" ], - "security": [ - { - "jwtAuth": [] + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SocialNonce" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/SocialNonce" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/SocialNonce" + } + } }, - {} - ], + "required": true + }, "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicProduct" + "$ref": "#/components/schemas/SocialNonceResponse" } } }, "description": "" - } - } - } - }, - "/api/v1/public/{makerspace_slug}/inventory/categories/": { - "get": { - "operationId": "api_v1_public_inventory_categories_list", - "summary": "List public inventory categories", - "parameters": [ - { - "in": "header", - "name": "X-Nonce", - "schema": { - "type": "string" - }, - "description": "Unique, unpredictable nonce for HMAC-authenticated server API clients (1-128 characters: letters, digits, `.`, `_`, `~`, or `-`). Include it between `X-Timestamp` and the raw body in the signed bytes: `METHOD\\nFULL_PATH\\nTIMESTAMP\\nNONCE\\nBODY`. It is optional only for publishable-key/browser authentication and during the temporary legacy rollout while `APICLIENT_REQUIRE_NONCE` is disabled." }, - { - "in": "header", - "name": "X-Publishable-Key", - "schema": { - "type": "string" + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } }, - "description": "Public API key for a makerspace public client. Required when API_CLIENT_AUTH_REQUIRED is enabled." + "description": "" }, - { - "in": "path", - "name": "makerspace_slug", - "schema": { - "type": "string" + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } }, - "required": true - } - ], - "tags": [ - "Public inventory" - ], - "security": [ - { - "jwtAuth": [] + "description": "" }, - {} - ], - "responses": { - "200": { + "429": { "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PublicCategory" - } + "$ref": "#/components/schemas/HardwareRequestError" } } }, @@ -32471,14 +33119,14 @@ } } }, - "/api/v1/public/{makerspace_slug}/machine-service-requests": { + "/api/v1/auth/social/oidc/{slug}": { "post": { - "operationId": "api_v1_public_machine_service_requests_create", - "summary": "Submit a machine service request as a member", + "operationId": "api_v1_auth_social_oidc_create", + "description": "Login through a deployment-configured OIDC provider named in the URL.\n\nThe slug is resolved to a configured row on every request rather than captured at\nimport time, so disabling a provider takes effect immediately instead of at the next\nrestart -- the same reason `provider_for_slug` refuses disabled and half-filled rows.", "parameters": [ { "in": "path", - "name": "makerspace_slug", + "name": "slug", "schema": { "type": "string" }, @@ -32486,54 +33134,39 @@ } ], "tags": [ - "Public machine service" + "Social auth" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicMachineServiceSubmit" + "$ref": "#/components/schemas/SocialLogin" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/PublicMachineServiceSubmit" + "$ref": "#/components/schemas/SocialLogin" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/PublicMachineServiceSubmit" + "$ref": "#/components/schemas/SocialLogin" } } }, "required": true }, - "security": [ - { - "jwtAuth": [] - } - ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicMachineServiceSubmitResponse" + "$ref": "#/components/schemas/SocialLoginResponse" } } }, "description": "" }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Invalid machine service request input." - }, "401": { "content": { "application/json": { @@ -32542,7 +33175,7 @@ } } }, - "description": "Authentication is required." + "description": "" }, "403": { "content": { @@ -32552,7 +33185,7 @@ } } }, - "description": "Active membership, waiver acceptance, and presence are required." + "description": "" }, "404": { "content": { @@ -32562,7 +33195,7 @@ } } }, - "description": "Makerspace or machine not found." + "description": "" }, "409": { "content": { @@ -32572,7 +33205,7 @@ } } }, - "description": "Machine service request conflict." + "description": "Includes `social_device_restart_required` when burned pre-grant inputs must be replaced." }, "429": { "content": { @@ -32582,18 +33215,18 @@ } } }, - "description": "Request rate limit exceeded." + "description": "" } } } }, - "/api/v1/public/{makerspace_slug}/machine-service/3d-printer/consumable-pools": { - "get": { - "operationId": "api_v1_public_machine_service_3d_printer_consumable_pools_list", + "/api/v1/auth/social/oidc/{slug}/authorize": { + "post": { + "operationId": "api_v1_auth_social_oidc_authorize_create", "parameters": [ { "in": "path", - "name": "makerspace_slug", + "name": "slug", "schema": { "type": "string" }, @@ -32601,105 +33234,74 @@ } ], "tags": [ - "Public machine service" + "Social auth" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OidcBrowserStart" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/OidcBrowserStart" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/OidcBrowserStart" + } + } + }, + "required": true + }, "responses": { "200": { "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PublicPrinterPool" - } + "$ref": "#/components/schemas/OidcBrowserStartResponse" } } }, "description": "" - } - } - } - }, - "/api/v1/public/{makerspace_slug}/machine-service/3d-printer/queues": { - "get": { - "operationId": "api_v1_public_machine_service_3d_printer_queues_list", - "parameters": [ - { - "in": "path", - "name": "makerspace_slug", - "schema": { - "type": "string" - }, - "required": true - } - ], - "tags": [ - "Public machine service" - ], - "responses": { - "200": { + }, + "400": { "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PublicPrinterQueue" - } + "$ref": "#/components/schemas/HardwareRequestError" } } }, "description": "" - } - } - } - }, - "/api/v1/public/{makerspace_slug}/machine-service/3d-printer/requests": { - "post": { - "operationId": "api_v1_public_machine_service_3d_printer_requests_create", - "parameters": [ - { - "in": "path", - "name": "makerspace_slug", - "schema": { - "type": "string" - }, - "required": true - } - ], - "tags": [ - "Public machine service" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PublicPrinterSubmit" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } } }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/PublicPrinterSubmit" + "description": "" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } } }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/PublicPrinterSubmit" - } - } + "description": "" }, - "required": true - }, - "security": [ - { - "jwtAuth": [] - } - ], - "responses": { - "201": { + "503": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicPrinterSubmitResponse" + "$ref": "#/components/schemas/HardwareRequestError" } } }, @@ -32708,159 +33310,147 @@ } } }, - "/api/v1/public/{makerspace_slug}/machine-service/3d-printer/uploads": { + "/api/v1/auth/social/oidc/callback": { "post": { - "operationId": "api_v1_public_machine_service_3d_printer_uploads_create", - "parameters": [ - { - "in": "path", - "name": "makerspace_slug", - "schema": { - "type": "string" - }, - "required": true - } - ], + "operationId": "api_v1_auth_social_oidc_callback_create", "tags": [ - "Public machine service" + "Social auth" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicPrinterUpload" + "$ref": "#/components/schemas/OidcBrowserCallback" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/PublicPrinterUpload" + "$ref": "#/components/schemas/OidcBrowserCallback" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/PublicPrinterUpload" + "$ref": "#/components/schemas/OidcBrowserCallback" } } }, "required": true }, - "security": [ - { - "jwtAuth": [] - } - ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicPrinterSubmitResponse" + "$ref": "#/components/schemas/OidcBrowserLoginResponse" } } }, "description": "" - } - } - } - }, - "/api/v1/public/{makerspace_slug}/machines": { - "get": { - "operationId": "api_v1_public_machines_list", - "description": "List active machines published by a public makerspace.", - "summary": "List public machines", - "parameters": [ - { - "in": "header", - "name": "X-Nonce", - "schema": { - "type": "string" - }, - "description": "Unique, unpredictable nonce for HMAC-authenticated server API clients (1-128 characters: letters, digits, `.`, `_`, `~`, or `-`). Include it between `X-Timestamp` and the raw body in the signed bytes: `METHOD\\nFULL_PATH\\nTIMESTAMP\\nNONCE\\nBODY`. It is optional only for publishable-key/browser authentication and during the temporary legacy rollout while `APICLIENT_REQUIRE_NONCE` is disabled." }, - { - "in": "header", - "name": "X-Publishable-Key", - "schema": { - "type": "string" + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } }, - "description": "Public API key for a makerspace public client. Required when API_CLIENT_AUTH_REQUIRED is enabled." + "description": "" }, - { - "in": "path", - "name": "makerspace_slug", - "schema": { - "type": "string" + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } }, - "description": "Public makerspace code or slug.", - "required": true + "description": "" }, - { - "name": "page", - "required": false, - "in": "query", - "description": "A page number within the paginated result set.", - "schema": { - "type": "integer" - } + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" } - ], + } + } + }, + "/api/v1/auth/social/providers": { + "get": { + "operationId": "api_v1_auth_social_providers_list", "tags": [ - "Public machines" + "Social auth" ], "security": [ { "jwtAuth": [] - }, - {} + } ], "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaginatedPublicMachineList" + "type": "array", + "items": { + "$ref": "#/components/schemas/SocialIdentity" + } } } }, "description": "" } } - } - }, - "/api/v1/public/{makerspace_slug}/membership-requests": { + }, "post": { - "operationId": "api_v1_public_membership_requests_create", - "parameters": [ - { - "in": "path", - "name": "makerspace_slug", - "schema": { - "type": "string" - }, - "required": true - } - ], + "operationId": "api_v1_auth_social_providers_create", "tags": [ - "Memberships" + "Social auth" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MembershipRequestCreate" + "$ref": "#/components/schemas/SocialLink" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/MembershipRequestCreate" + "$ref": "#/components/schemas/SocialLink" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/MembershipRequestCreate" + "$ref": "#/components/schemas/SocialLink" } } - } + }, + "required": true }, "security": [ { @@ -32868,21 +33458,11 @@ } ], "responses": { - "201": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MembershipOutcome" - } - } - }, - "description": "" - }, - "400": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/SocialIdentity" } } }, @@ -32898,7 +33478,7 @@ }, "description": "" }, - "403": { + "409": { "content": { "application/json": { "schema": { @@ -32907,6 +33487,34 @@ } }, "description": "" + } + } + } + }, + "/api/v1/auth/social/providers/{provider}": { + "delete": { + "operationId": "api_v1_auth_social_providers_destroy", + "parameters": [ + { + "in": "path", + "name": "provider", + "schema": { + "type": "string" + }, + "required": true + } + ], + "tags": [ + "Social auth" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "204": { + "description": "No response body" }, "404": { "content": { @@ -32927,27 +33535,27 @@ } }, "description": "" - }, - "429": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "" } } } }, - "/api/v1/public/{makerspace_slug}/presence-sessions": { - "post": { - "operationId": "api_v1_public_presence_sessions_create", + "/api/v1/backups/download/{archive_id}/{token}": { + "get": { + "operationId": "api_v1_backups_download_retrieve", + "summary": "Consume a one-use backup download", "parameters": [ { "in": "path", - "name": "makerspace_slug", + "name": "archive_id", + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true + }, + { + "in": "path", + "name": "token", "schema": { "type": "string" }, @@ -32955,109 +33563,177 @@ } ], "tags": [ - "Presence" + "Backup" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PresenceStart" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/PresenceStart" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/PresenceStart" - } + "responses": { + "200": { + "description": "Age-encrypted archive stream." + }, + "404": { + "description": "Invalid or expired download." + } + } + } + }, + "/api/v1/bootstrap": { + "get": { + "operationId": "api_v1_bootstrap_retrieve", + "summary": "Resolve tenant and frontend-safe configuration", + "parameters": [ + { + "in": "query", + "name": "slug", + "schema": { + "type": "string" } }, - "required": true - }, + { + "in": "query", + "name": "tenant", + "schema": { + "type": "string" + } + } + ], + "tags": [ + "Tenant bootstrap" + ], "security": [ { "jwtAuth": [] - } + }, + {} ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PresenceSession" + "$ref": "#/components/schemas/TenantBootstrap" } } }, - "description": "" + "description": "Frontend-safe tenant bootstrap payload." }, - "400": { + "404": { + "description": "No active tenant frontend matched." + } + } + } + }, + "/api/v1/config": { + "get": { + "operationId": "api_v1_config_retrieve", + "summary": "Return frontend-safe platform configuration", + "tags": [ + "Platform" + ], + "security": [ + {} + ], + "responses": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/PublicConfig" } } }, - "description": "Invalid input." - }, - "401": { - "description": "Authentication required." + "description": "" + } + } + } + }, + "/api/v1/data-exports/download/{job_id}/{token}": { + "get": { + "operationId": "api_v1_data_exports_download_retrieve", + "summary": "Consume a one-use export download URL", + "parameters": [ + { + "in": "path", + "name": "job_id", + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true }, - "403": { + { + "in": "path", + "name": "token", + "schema": { + "type": "string" + }, + "required": true + } + ], + "tags": [ + "Data exports" + ], + "responses": { + "200": { "content": { - "application/json": { + "application/zip": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "type": "string", + "format": "binary" + } + }, + "application/octet-stream": { + "schema": { + "type": "string", + "format": "binary" } } }, - "description": "Membership permission required." + "description": "" }, "404": { - "description": "Makerspace not found." + "description": "Download link is invalid, expired, or used." }, - "429": { - "description": "Rate limit exceeded." + "503": { + "description": "Archive storage is unavailable." } } } }, - "/api/v1/public/{makerspace_slug}/presence-sessions/current": { + "/api/v1/event-checkin-stations/{public_token}/roster/": { "get": { - "operationId": "api_v1_public_presence_sessions_current_retrieve", + "operationId": "api_v1_event_checkin_stations_roster_retrieve", + "summary": "Download the station's minimal expiring attendee roster", "parameters": [ { "in": "path", - "name": "makerspace_slug", + "name": "public_token", "schema": { - "type": "string" + "type": "string", + "format": "uuid" }, "required": true } ], "tags": [ - "Presence" + "Event check-in stations" ], "security": [ { - "jwtAuth": [] - } + "EventStationCookie": [] + }, + {} ], "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PresenceCurrent" + "$ref": "#/components/schemas/OfflineRosterResponse" } } }, "description": "" }, - "400": { + "403": { "content": { "application/json": { "schema": { @@ -33065,12 +33741,9 @@ } } }, - "description": "Invalid input." - }, - "401": { - "description": "Authentication required." + "description": "Invalid station credential, session, origin, feature, or time window." }, - "403": { + "409": { "content": { "application/json": { "schema": { @@ -33078,50 +33751,84 @@ } } }, - "description": "Membership permission required." + "description": "Invalid station credential, session, origin, feature, or time window." }, - "404": { - "description": "Makerspace not found." + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Invalid station credential, session, origin, feature, or time window." }, "429": { - "description": "Rate limit exceeded." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Invalid station credential, session, origin, feature, or time window." } } } }, - "/api/v1/public/{makerspace_slug}/presence-sessions/current/end": { + "/api/v1/event-checkin-stations/{public_token}/session/": { "post": { - "operationId": "api_v1_public_presence_sessions_current_end_create", + "operationId": "api_v1_event_checkin_stations_session_create", + "summary": "Exchange an event-scoped PIN for a station session", "parameters": [ { "in": "path", - "name": "makerspace_slug", + "name": "public_token", "schema": { - "type": "string" + "type": "string", + "format": "uuid" }, "required": true } ], "tags": [ - "Presence" - ], - "security": [ - { - "jwtAuth": [] - } + "Event check-in stations" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StationPin" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/StationPin" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/StationPin" + } + } + }, + "required": true + }, "responses": { - "200": { + "204": { + "description": "No response body" + }, + "400": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PresenceCurrent" + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "" + "description": "Invalid station credential, session, origin, feature, or time window." }, - "400": { + "403": { "content": { "application/json": { "schema": { @@ -33129,12 +33836,9 @@ } } }, - "description": "Invalid input." - }, - "401": { - "description": "Authentication required." + "description": "Invalid station credential, session, origin, feature, or time window." }, - "403": { + "429": { "content": { "application/json": { "schema": { @@ -33142,90 +33846,92 @@ } } }, - "description": "Membership permission required." - }, - "404": { - "description": "Makerspace not found." - }, - "429": { - "description": "Rate limit exceeded." + "description": "Invalid station credential, session, origin, feature, or time window." } } - } - }, - "/api/v1/public/{makerspace_slug}/requests": { - "post": { - "operationId": "api_v1_public_requests_create", - "summary": "Submit public borrow request", + }, + "delete": { + "operationId": "api_v1_event_checkin_stations_session_destroy", + "summary": "Clear the local station session cookie", "parameters": [ { - "in": "header", - "name": "Idempotency-Key", + "in": "path", + "name": "public_token", "schema": { - "type": "string" + "type": "string", + "format": "uuid" }, - "description": "Required for account-less submissions. Reusing a key with the same payload returns the original request; a different payload is rejected." - }, + "required": true + } + ], + "tags": [ + "Event check-in stations" + ], + "security": [ { - "in": "header", - "name": "X-Nonce", - "schema": { - "type": "string" - }, - "description": "Unique, unpredictable nonce for HMAC-authenticated server API clients (1-128 characters: letters, digits, `.`, `_`, `~`, or `-`). Include it between `X-Timestamp` and the raw body in the signed bytes: `METHOD\\nFULL_PATH\\nTIMESTAMP\\nNONCE\\nBODY`. It is optional only for publishable-key/browser authentication and during the temporary legacy rollout while `APICLIENT_REQUIRE_NONCE` is disabled." + "EventStationCookie": [] + } + ], + "responses": { + "204": { + "description": "No response body" }, - { - "in": "header", - "name": "X-Publishable-Key", - "schema": { - "type": "string" + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } }, - "description": "Public API key for a makerspace public client. Required when API_CLIENT_AUTH_REQUIRED is enabled." + "description": "Invalid station credential, session, origin, feature, or time window." }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Invalid station credential, session, origin, feature, or time window." + } + } + } + }, + "/api/v1/event-checkin-stations/{public_token}/sync/": { + "post": { + "operationId": "api_v1_event_checkin_stations_sync_create", + "summary": "Synchronize queued PIN-station check-ins", + "parameters": [ { "in": "path", - "name": "makerspace_slug", + "name": "public_token", "schema": { - "type": "string" + "type": "string", + "format": "uuid" }, "required": true } ], "tags": [ - "Public requests" + "Event check-in stations" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RequestSubmit" - }, - "examples": { - "SubmitPublicEquipmentRequest": { - "value": { - "contact_name": "Shaan Shoukath", - "contact_email": "shaans@example.com", - "contact_phone": "+919876543210", - "requested_for": "Electronics workshop diagnostics", - "items": [ - { - "product_id": 42, - "quantity": 2 - } - ] - }, - "summary": "Submit public equipment request" - } + "$ref": "#/components/schemas/OfflineCheckInSyncRequest" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/RequestSubmit" + "$ref": "#/components/schemas/OfflineCheckInSyncRequest" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/RequestSubmit" + "$ref": "#/components/schemas/OfflineCheckInSyncRequest" } } }, @@ -33233,16 +33939,16 @@ }, "security": [ { - "jwtAuth": [] + "EventStationCookie": [] }, {} ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RequestSubmitResponse" + "$ref": "#/components/schemas/OfflineCheckInSyncResponse" } } }, @@ -33256,17 +33962,7 @@ } } }, - "description": "Invalid request." - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Authentication required." + "description": "Invalid station credential, session, origin, feature, or time window." }, "403": { "content": { @@ -33276,19 +33972,9 @@ } } }, - "description": "Permission denied." - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Not found." + "description": "Invalid station credential, session, origin, feature, or time window." }, - "409": { + "410": { "content": { "application/json": { "schema": { @@ -33296,7 +33982,7 @@ } } }, - "description": "Workflow conflict." + "description": "Invalid station credential, session, origin, feature, or time window." }, "429": { "content": { @@ -33306,217 +33992,117 @@ } } }, - "description": "Too many requests." - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Service unavailable." + "description": "Invalid station credential, session, origin, feature, or time window." } } } }, - "/api/v1/public/{makerspace_slug}/spaces/": { + "/api/v1/guest-admin/makerspace/{makerspace_id}/active-loans": { "get": { - "operationId": "api_v1_public_spaces_list", + "operationId": "api_v1_guest_admin_makerspace_active_loans_list", + "summary": "List active loans awaiting return", "parameters": [ { "in": "path", - "name": "makerspace_slug", + "name": "makerspace_id", "schema": { - "type": "string" + "type": "integer" }, "required": true - } - ], - "tags": [ - "Public bookings" - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PublicBookableSpace" - } - } - } - }, - "description": "" - }, - "400": { - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": {} - } - } - }, - "description": "Invalid request." - }, - "404": { - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": {} - } - } - }, - "description": "Space not found." }, - "429": { - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": {} - } - } - }, - "description": "Rate limit exceeded." - } - } - } - }, - "/api/v1/public/{makerspace_slug}/spaces/{public_token}/availability/": { - "get": { - "operationId": "api_v1_public_spaces_availability_retrieve", - "parameters": [ { + "name": "page", + "required": false, "in": "query", - "name": "ends_at", - "schema": { - "type": "string", - "format": "date-time" - }, - "required": true - }, - { - "in": "path", - "name": "makerspace_slug", - "schema": { - "type": "string" - }, - "required": true - }, - { - "in": "path", - "name": "public_token", + "description": "A page number within the paginated result set.", "schema": { - "type": "string", - "format": "uuid" - }, - "required": true + "type": "integer" + } }, { + "name": "search", + "required": false, "in": "query", - "name": "starts_at", + "description": "A search term (requested-for, requester name/email).", "schema": { - "type": "string", - "format": "date-time" - }, - "required": true + "type": "string" + } } ], "tags": [ - "Public bookings" + "Admin requests" + ], + "security": [ + { + "jwtAuth": [] + } ], "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicSpaceAvailability" + "$ref": "#/components/schemas/PaginatedAdminRequestList" } } }, "description": "" }, - "400": { + "403": { "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": {} + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Invalid request." + "description": "Permission denied." }, "404": { "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": {} - } - } - }, - "description": "Space not found." - }, - "429": { - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": {} + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Rate limit exceeded." + "description": "Not found." } } } }, - "/api/v1/public/{makerspace_slug}/spaces/{public_token}/book/": { + "/api/v1/guest-admin/requests/{id}/return": { "post": { - "operationId": "api_v1_public_spaces_book_create", + "operationId": "api_v1_guest_admin_requests_return_create", + "summary": "Return issued request items", "parameters": [ { "in": "path", - "name": "makerspace_slug", - "schema": { - "type": "string" - }, - "required": true - }, - { - "in": "path", - "name": "public_token", + "name": "id", "schema": { - "type": "string", - "format": "uuid" + "type": "integer" }, "required": true } ], "tags": [ - "Public bookings" + "Admin requests" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicBookingInput" + "$ref": "#/components/schemas/ReturnRequest" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/PublicBookingInput" + "$ref": "#/components/schemas/ReturnRequest" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/PublicBookingInput" + "$ref": "#/components/schemas/ReturnRequest" } } }, @@ -33528,11 +34114,11 @@ } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicBookingResponse" + "$ref": "#/components/schemas/AdminRequest" } } }, @@ -33542,36 +34128,33 @@ "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": {} + "$ref": "#/components/schemas/HardwareRequestError" } } }, "description": "Invalid request." }, - "404": { + "403": { "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": {} + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Space not found." + "description": "Permission denied." }, - "429": { + "404": { "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": {} + "$ref": "#/components/schemas/HardwareRequestError" } } }, - "description": "Rate limit exceeded." + "description": "Not found." }, - "401": { + "409": { "content": { "application/json": { "schema": { @@ -33579,9 +34162,9 @@ } } }, - "description": "Authentication is required." + "description": "Workflow conflict." }, - "403": { + "503": { "content": { "application/json": { "schema": { @@ -33589,55 +34172,44 @@ } } }, - "description": "Active membership and presence are required." + "description": "Service unavailable." + } + } + } + }, + "/api/v1/health/": { + "get": { + "operationId": "api_v1_health_retrieve", + "summary": "Health check", + "tags": [ + "Health" + ], + "security": [ + { + "jwtAuth": [] }, - "409": { + {} + ], + "responses": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/Health" } } }, - "description": "Booking conflict." + "description": "" } } } }, - "/api/v1/public/{makerspace_slug}/stats/": { + "/api/v1/health/readiness/": { "get": { - "operationId": "api_v1_public_stats_retrieve", - "description": "Get public activity stats for a public makerspace.", - "summary": "Get public makerspace stats", - "parameters": [ - { - "in": "header", - "name": "X-Nonce", - "schema": { - "type": "string" - }, - "description": "Unique, unpredictable nonce for HMAC-authenticated server API clients (1-128 characters: letters, digits, `.`, `_`, `~`, or `-`). Include it between `X-Timestamp` and the raw body in the signed bytes: `METHOD\\nFULL_PATH\\nTIMESTAMP\\nNONCE\\nBODY`. It is optional only for publishable-key/browser authentication and during the temporary legacy rollout while `APICLIENT_REQUIRE_NONCE` is disabled." - }, - { - "in": "header", - "name": "X-Publishable-Key", - "schema": { - "type": "string" - }, - "description": "Public API key for a makerspace public client. Required when API_CLIENT_AUTH_REQUIRED is enabled." - }, - { - "in": "path", - "name": "makerspace_slug", - "schema": { - "type": "string" - }, - "description": "Public makerspace code (for example TSEL) or slug.", - "required": true - } - ], + "operationId": "api_v1_health_readiness_retrieve", + "summary": "Readiness check", "tags": [ - "Public inventory" + "Health" ], "security": [ { @@ -33650,7 +34222,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicStats" + "$ref": "#/components/schemas/Readiness" } } }, @@ -33659,67 +34231,27 @@ } } }, - "/api/v1/public/{makerspace_slug}/tools/checkout": { + "/api/v1/integrations/push/devices": { "post": { - "operationId": "api_v1_public_tools_checkout_create", - "summary": "Check out a public tool by QR", - "parameters": [ - { - "in": "header", - "name": "X-Nonce", - "schema": { - "type": "string" - }, - "description": "Unique, unpredictable nonce for HMAC-authenticated server API clients (1-128 characters: letters, digits, `.`, `_`, `~`, or `-`). Include it between `X-Timestamp` and the raw body in the signed bytes: `METHOD\\nFULL_PATH\\nTIMESTAMP\\nNONCE\\nBODY`. It is optional only for publishable-key/browser authentication and during the temporary legacy rollout while `APICLIENT_REQUIRE_NONCE` is disabled." - }, - { - "in": "header", - "name": "X-Publishable-Key", - "schema": { - "type": "string" - }, - "description": "Public API key for a makerspace public client. Required when API_CLIENT_AUTH_REQUIRED is enabled." - }, - { - "in": "path", - "name": "makerspace_slug", - "schema": { - "type": "string" - }, - "required": true - } - ], + "operationId": "api_v1_integrations_push_devices_create", "tags": [ - "Public requests" + "Native push" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicToolCheckout" - }, - "examples": { - "PublicQRToolCheckout": { - "value": { - "payload": "BOX-ABC123", - "requester_name": "Shaan Shoukath", - "contact_email": "shaans@example.com", - "contact_phone": "+919876543210", - "evidence_id": 122, - "remark": "Borrowing for electronics workshop diagnostics." - }, - "summary": "Public QR tool checkout" - } + "$ref": "#/components/schemas/PushDeviceRegistration" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/PublicToolCheckout" + "$ref": "#/components/schemas/PushDeviceRegistration" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/PublicToolCheckout" + "$ref": "#/components/schemas/PushDeviceRegistration" } } }, @@ -33731,25 +34263,25 @@ } ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicToolLoan" + "$ref": "#/components/schemas/PushDevice" } } }, "description": "" }, - "400": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "$ref": "#/components/schemas/PushDevice" } } }, - "description": "Invalid request." + "description": "" }, "401": { "content": { @@ -33759,7 +34291,7 @@ } } }, - "description": "Authentication required." + "description": "" }, "403": { "content": { @@ -33769,17 +34301,7 @@ } } }, - "description": "Permission denied." - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Not found." + "description": "" }, "409": { "content": { @@ -33789,17 +34311,7 @@ } } }, - "description": "Workflow conflict." - }, - "429": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Too many requests." + "description": "" }, "503": { "content": { @@ -33809,89 +34321,35 @@ } } }, - "description": "Service unavailable." + "description": "" } } } }, - "/api/v1/public/{makerspace_slug}/tools/evidence-url": { - "post": { - "operationId": "api_v1_public_tools_evidence_url_create", - "summary": "Create a public self-checkout evidence upload URL", + "/api/v1/integrations/push/devices/{device_id}": { + "delete": { + "operationId": "api_v1_integrations_push_devices_destroy", "parameters": [ - { - "in": "header", - "name": "X-Nonce", - "schema": { - "type": "string" - }, - "description": "Unique, unpredictable nonce for HMAC-authenticated server API clients (1-128 characters: letters, digits, `.`, `_`, `~`, or `-`). Include it between `X-Timestamp` and the raw body in the signed bytes: `METHOD\\nFULL_PATH\\nTIMESTAMP\\nNONCE\\nBODY`. It is optional only for publishable-key/browser authentication and during the temporary legacy rollout while `APICLIENT_REQUIRE_NONCE` is disabled." - }, - { - "in": "header", - "name": "X-Publishable-Key", - "schema": { - "type": "string" - }, - "description": "Public API key for a makerspace public client. Required when API_CLIENT_AUTH_REQUIRED is enabled." - }, { "in": "path", - "name": "makerspace_slug", + "name": "device_id", "schema": { - "type": "string" + "type": "integer" }, "required": true } ], "tags": [ - "Public requests" + "Native push" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PublicToolEvidenceUrlRequest" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/PublicToolEvidenceUrlRequest" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/PublicToolEvidenceUrlRequest" - } - } - }, - "required": true - }, "security": [ { "jwtAuth": [] } ], "responses": { - "201": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/EvidenceUrlResponse" - } - } - }, - "description": "" - }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Invalid request." + "204": { + "description": "No response body" }, "401": { "content": { @@ -33901,7 +34359,7 @@ } } }, - "description": "Authentication required." + "description": "" }, "403": { "content": { @@ -33911,7 +34369,7 @@ } } }, - "description": "Permission denied." + "description": "" }, "404": { "content": { @@ -33921,100 +34379,33 @@ } } }, - "description": "Not found." - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Workflow conflict." - }, - "429": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Too many requests." - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HardwareRequestError" - } - } - }, - "description": "Service unavailable." + "description": "" } } } }, - "/api/v1/public/{makerspace_slug}/tools/return": { + "/api/v1/integrations/telegram/test-alert": { "post": { - "operationId": "api_v1_public_tools_return_create", - "summary": "Return a public tool by QR", - "parameters": [ - { - "in": "header", - "name": "X-Nonce", - "schema": { - "type": "string" - }, - "description": "Unique, unpredictable nonce for HMAC-authenticated server API clients (1-128 characters: letters, digits, `.`, `_`, `~`, or `-`). Include it between `X-Timestamp` and the raw body in the signed bytes: `METHOD\\nFULL_PATH\\nTIMESTAMP\\nNONCE\\nBODY`. It is optional only for publishable-key/browser authentication and during the temporary legacy rollout while `APICLIENT_REQUIRE_NONCE` is disabled." - }, - { - "in": "header", - "name": "X-Publishable-Key", - "schema": { - "type": "string" - }, - "description": "Public API key for a makerspace public client. Required when API_CLIENT_AUTH_REQUIRED is enabled." - }, - { - "in": "path", - "name": "makerspace_slug", - "schema": { - "type": "string" - }, - "required": true - } - ], + "operationId": "api_v1_integrations_telegram_test_alert_create", + "summary": "Send Telegram test alert", "tags": [ - "Public requests" + "Telegram" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicToolScan" - }, - "examples": { - "PublicQRToolReturnScan": { - "value": { - "identifier": "shaans@example.com", - "payload": "BOX-ABC123", - "evidence_id": 123, - "remark": "Returned to the electronics shelf in good condition." - }, - "summary": "Public QR tool return scan" - } + "$ref": "#/components/schemas/TelegramTestAlert" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/PublicToolScan" + "$ref": "#/components/schemas/TelegramTestAlert" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/PublicToolScan" + "$ref": "#/components/schemas/TelegramTestAlert" } } }, @@ -34027,24 +34418,125 @@ ], "responses": { "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PublicToolLoan" - } - } + "description": "Delivery attempt result." + } + } + } + }, + "/api/v1/integrations/telegram/webhook": { + "post": { + "operationId": "api_v1_integrations_telegram_webhook_create", + "description": "Retained so an already-registered webhook does not retry forever. Callback queries are acknowledged and discarded: accepting and rejecting borrow requests happens in the staff console, never from chat.", + "summary": "Acknowledge a Telegram webhook (no action is taken)", + "tags": [ + "Telegram" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TelegramWebhook" + } }, - "description": "" + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/TelegramWebhook" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/TelegramWebhook" + } + } + } + }, + "responses": { + "200": { + "description": "Acknowledged; no action taken." + } + } + } + }, + "/api/v1/internal/cron/return-reminders": { + "post": { + "operationId": "api_v1_internal_cron_return_reminders_create", + "tags": [ + "Health" + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReturnReminderCronResponse" + } + } + }, + "description": "Return reminder run result." }, - "400": { + "403": { + "description": "Invalid cron secret." + }, + "404": { + "description": "Cron endpoint is not configured." + } + } + } + }, + "/api/v1/internal/tls-check": { + "get": { + "operationId": "api_v1_internal_tls_check_retrieve", + "summary": "Check whether on-demand TLS may be issued for a domain", + "parameters": [ + { + "in": "query", + "name": "domain", + "schema": { + "type": "string" + }, + "description": "Canonical hostname requested for on-demand TLS issuance.", + "required": true + } + ], + "tags": [ + "Internal" + ], + "responses": { + "200": { + "description": "TLS issuance is allowed." + }, + "403": { + "description": "TLS issuance is denied." + } + } + } + }, + "/api/v1/member/archived-payments": { + "get": { + "operationId": "api_v1_member_archived_payments_list", + "description": "Lists archived makerspaces where the caller retains an active membership and has payment history available to read or settle.", + "summary": "Discover the caller's archived makerspace payments", + "tags": [ + "Payments" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HardwareRequestError" + "type": "array", + "items": { + "$ref": "#/components/schemas/ArchivedPaymentSummary" + } } } }, - "description": "Invalid request." + "description": "" }, "401": { "content": { @@ -34054,6 +34546,45 @@ } } }, + "description": "" + } + } + } + }, + "/api/v1/member/makerspaces/{makerspace_id}/activity": { + "get": { + "operationId": "api_v1_member_makerspaces_activity_retrieve", + "summary": "Retrieve the caller's makerspace activity", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Member activity" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MemberActivity" + } + } + }, + "description": "" + }, + "401": { "description": "Authentication required." }, "403": { @@ -34064,7 +34595,7 @@ } } }, - "description": "Permission denied." + "description": "An active membership is required." }, "404": { "content": { @@ -34074,9 +34605,48 @@ } } }, - "description": "Not found." + "description": "Makerspace not found." + } + } + } + }, + "/api/v1/member/makerspaces/{makerspace_id}/collaborative-events/": { + "get": { + "operationId": "api_v1_member_makerspaces_collaborative_events_list", + "summary": "List events hosted by accepted collaborators", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Member events" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CollaborativeEvent" + } + } + } + }, + "description": "" }, - "409": { + "400": { "content": { "application/json": { "schema": { @@ -34084,9 +34654,9 @@ } } }, - "description": "Workflow conflict." + "description": "Invalid event request." }, - "429": { + "403": { "content": { "application/json": { "schema": { @@ -34094,9 +34664,9 @@ } } }, - "description": "Too many requests." + "description": "Active membership is required." }, - "503": { + "404": { "content": { "application/json": { "schema": { @@ -34104,154 +34674,237 @@ } } }, - "description": "Service unavailable." + "description": "Collaborative event not found." } } } }, - "/api/v1/public/machine-service/3d-printer/requests/{public_token}/status": { - "get": { - "operationId": "api_v1_public_machine_service_3d_printer_requests_status_retrieve", + "/api/v1/member/makerspaces/{makerspace_id}/collaborative-events/{id}/register/": { + "post": { + "operationId": "api_v1_member_makerspaces_collaborative_events_register_create", + "summary": "Register for a collaborative event", "parameters": [ { "in": "path", - "name": "public_token", + "name": "id", "schema": { - "type": "string", - "format": "uuid" + "type": "integer" + }, + "required": true + }, + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" }, "required": true } ], "tags": [ - "Public machine service" + "Member events" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CollaborativeEventRegistrationInput" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/CollaborativeEventRegistrationInput" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/CollaborativeEventRegistrationInput" + } + } + } + }, + "security": [ + { + "jwtAuth": [] + } ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicPrinterStatus" + "$ref": "#/components/schemas/PublicEventRegistrationResponse" } } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Invalid event request." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Active membership is required." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Collaborative event not found." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Event state conflict." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Rate limit exceeded." } } } }, - "/api/v1/public/makerspaces/": { + "/api/v1/member/makerspaces/{makerspace_id}/directory": { "get": { - "operationId": "api_v1_public_makerspaces_list", - "description": "List makerspaces that have public inventory enabled.", - "summary": "List public makerspaces", + "operationId": "api_v1_member_makerspaces_directory_retrieve", + "summary": "List members who published a profile", "parameters": [ { - "in": "header", - "name": "X-Nonce", - "schema": { - "type": "string" - }, - "description": "Unique, unpredictable nonce for HMAC-authenticated server API clients (1-128 characters: letters, digits, `.`, `_`, `~`, or `-`). Include it between `X-Timestamp` and the raw body in the signed bytes: `METHOD\\nFULL_PATH\\nTIMESTAMP\\nNONCE\\nBODY`. It is optional only for publishable-key/browser authentication and during the temporary legacy rollout while `APICLIENT_REQUIRE_NONCE` is disabled." - }, - { - "in": "header", - "name": "X-Publishable-Key", + "in": "path", + "name": "makerspace_id", "schema": { - "type": "string" + "type": "integer" }, - "description": "Public API key for a makerspace public client. Required when API_CLIENT_AUTH_REQUIRED is enabled." + "required": true } ], "tags": [ - "Public inventory" + "Member profile" ], "security": [ { "jwtAuth": [] - }, - {} + } ], "responses": { "200": { "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PublicMakerspace" - } + "$ref": "#/components/schemas/Directory" } } }, "description": "" + }, + "401": { + "description": "Authentication required." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "An active membership is required." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Not found." } } } }, - "/api/v1/public/requests/{public_token}/status": { + "/api/v1/member/makerspaces/{makerspace_id}/directory/{membership_id}": { "get": { - "operationId": "api_v1_public_requests_status_retrieve", - "summary": "Get request status by public token", + "operationId": "api_v1_member_makerspaces_directory_retrieve_2", + "summary": "Retrieve another member's published profile", "parameters": [ { - "in": "header", - "name": "X-Nonce", - "schema": { - "type": "string" - }, - "description": "Unique, unpredictable nonce for HMAC-authenticated server API clients (1-128 characters: letters, digits, `.`, `_`, `~`, or `-`). Include it between `X-Timestamp` and the raw body in the signed bytes: `METHOD\\nFULL_PATH\\nTIMESTAMP\\nNONCE\\nBODY`. It is optional only for publishable-key/browser authentication and during the temporary legacy rollout while `APICLIENT_REQUIRE_NONCE` is disabled." - }, - { - "in": "header", - "name": "X-Publishable-Key", + "in": "path", + "name": "makerspace_id", "schema": { - "type": "string" + "type": "integer" }, - "description": "Public API key for a makerspace public client. Required when API_CLIENT_AUTH_REQUIRED is enabled." + "required": true }, { "in": "path", - "name": "public_token", + "name": "membership_id", "schema": { - "type": "string", - "format": "uuid" + "type": "integer" }, "required": true } ], "tags": [ - "Public requests" + "Member profile" + ], + "security": [ + { + "jwtAuth": [] + } ], "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicRequestStatus" - }, - "examples": { - "PublicRequestStatus": { - "value": { - "public_token": "4f2b93e1-6ef4-41c2-8407-7f26bb3b2d8f", - "requested_for": "Electronics workshop diagnostics", - "status": "pending_approval", - "rejection_reason": "", - "created_at": "2026-06-11T10:30:00Z", - "items": [ - { - "product_name": "Soldering Iron", - "requested_quantity": 2 - } - ] - }, - "summary": "Public request status" - } + "$ref": "#/components/schemas/ProfileRead" } } }, "description": "" }, + "401": { + "description": "Authentication required." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "An active membership is required." + }, "404": { "content": { "application/json": { @@ -34265,12 +34918,21 @@ } } }, - "/api/v1/recovery": { + "/api/v1/member/makerspaces/{makerspace_id}/event-calendar-feed/": { "get": { - "operationId": "api_v1_recovery_retrieve", - "summary": "Get deployment quarantine and residual-risk state", + "operationId": "api_v1_member_makerspaces_event_calendar_feed_retrieve", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], "tags": [ - "Backup recovery" + "Member events" ], "security": [ { @@ -34282,41 +34944,84 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RecoveryState" + "$ref": "#/components/schemas/MemberCalendarFeedState" } } }, "description": "" }, "401": { - "description": "Authentication is required." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" }, "403": { - "description": "The authenticated actor is not authorized." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" } } }, "post": { - "operationId": "api_v1_recovery_create", - "summary": "Acknowledge residual risk and lift quarantine", + "operationId": "api_v1_member_makerspaces_event_calendar_feed_create", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], "tags": [ - "Backup recovery" + "Member events" ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RecoveryAcknowledge" + "$ref": "#/components/schemas/MemberCalendarFeedIssue" } }, "application/x-www-form-urlencoded": { "schema": { - "$ref": "#/components/schemas/RecoveryAcknowledge" + "$ref": "#/components/schemas/MemberCalendarFeedIssue" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/RecoveryAcknowledge" + "$ref": "#/components/schemas/MemberCalendarFeedIssue" } } }, @@ -34332,734 +35037,7988 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RecoveryState" + "$ref": "#/components/schemas/MemberCalendarFeedIssued" } } }, "description": "" }, "400": { - "description": "The request is invalid for the current lifecycle state." - }, - "401": { - "description": "Authentication is required." - }, - "403": { - "description": "The authenticated actor is not authorized." - } + "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": "Concurrent rotation conflict." + } + } + }, + "delete": { + "operationId": "api_v1_member_makerspaces_event_calendar_feed_destroy", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Member events" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "204": { + "description": "No response body" + }, + "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": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + } } } }, - "/api/v1/webhooks/razorpay/{public_code}": { + "/api/v1/member/makerspaces/{makerspace_id}/event-certificates/{id}/download/": { + "get": { + "operationId": "api_v1_member_makerspaces_event_certificates_download_retrieve", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + }, + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Member events" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CertificateDownload" + } + } + }, + "description": "" + }, + "410": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Certificate revoked." + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Certificate storage unavailable." + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Invalid feedback answers." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Active membership is required." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Feedback resource not found." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Feedback or certificate state conflict." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Rate limit exceeded." + } + } + } + }, + "/api/v1/member/makerspaces/{makerspace_id}/event-registrations/{id}/feedback/": { + "get": { + "operationId": "api_v1_member_makerspaces_event_registrations_feedback_retrieve", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + }, + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Member events" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FeedbackForm" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Invalid feedback answers." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Active membership is required." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Feedback resource not found." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Feedback or certificate state conflict." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Rate limit exceeded." + } + } + }, "post": { - "operationId": "api_v1_webhooks_razorpay_create", - "description": "Verifies the X-Razorpay-Signature HMAC over request.body using the addressed makerspace's webhook secret before applying an idempotent event.", - "summary": "Receive a makerspace Razorpay webhook", + "operationId": "api_v1_member_makerspaces_event_registrations_feedback_create", "parameters": [ { "in": "path", - "name": "public_code", + "name": "id", "schema": { - "type": "string" + "type": "integer" + }, + "required": true + }, + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" }, "required": true } ], "tags": [ - "Payments" + "Member events" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FeedbackSubmission" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/FeedbackSubmission" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/FeedbackSubmission" + } + } + } + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FeedbackSubmissionResponse" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Invalid feedback answers." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Active membership is required." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Feedback resource not found." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Feedback or certificate state conflict." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Rate limit exceeded." + } + } + } + }, + "/api/v1/member/makerspaces/{makerspace_id}/event-registrations/{id}/qr": { + "get": { + "operationId": "api_v1_member_makerspaces_event_registrations_qr_retrieve", + "summary": "Render the caller's event check-in QR code", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + }, + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Member activity" + ], + "security": [ + { + "jwtAuth": [] + } ], "responses": { "200": { - "description": "Verified event acknowledged." + "content": { + "image/svg+xml": { + "schema": { + "type": "string", + "format": "binary" + } + } + }, + "description": "Check-in QR code as SVG." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "An active membership is required." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Registration not found." + } + } + } + }, + "/api/v1/member/makerspaces/{makerspace_id}/event-registrations/calendar.ics": { + "get": { + "operationId": "api_v1_member_makerspaces_event_registrations_calendar.ics_retrieve", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Member events" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "text/calendar": { + "schema": { + "type": "string", + "format": "binary" + } + } + }, + "description": "RFC 5545 calendar (`text/calendar; charset=utf-8`)." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Authentication required." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Active membership required." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Makerspace not found." + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Events module unavailable." + } + } + } + }, + "/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": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + } + } + } + }, + "/api/v1/member/makerspaces/{makerspace_id}/payments/{payment_id}/checkout": { + "post": { + "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", + "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/MobilePaymentIntentResponse" + } + } + }, + "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": "" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + } + } + } + }, + "/api/v1/member/makerspaces/{makerspace_id}/profile": { + "get": { + "operationId": "api_v1_member_makerspaces_profile_retrieve", + "summary": "Retrieve the caller's own profile", + "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/ProfileRead" + } + } + }, + "description": "" + }, + "401": { + "description": "Authentication required." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "An active membership is required." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Not found." + } + } + }, + "put": { + "operationId": "api_v1_member_makerspaces_profile_update", + "summary": "Update the caller's own profile", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Member profile" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProfileWrite" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/ProfileWrite" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/ProfileWrite" + } + } + } + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProfileRead" + } + } + }, + "description": "" + }, + "401": { + "description": "Authentication required." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "An active membership is required." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Not found." + } + } + } + }, + "/api/v1/member/makerspaces/{makerspace_id}/profile/image": { + "post": { + "operationId": "api_v1_member_makerspaces_profile_image_create", + "summary": "Create a profile image upload URL", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Member profile" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProfileImageUploadRequest" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/ProfileImageUploadRequest" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/ProfileImageUploadRequest" + } + } + }, + "required": true + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicImageUploadResponse" + } + } + }, + "description": "" + }, + "400": { + "description": "Invalid image upload request." + }, + "403": { + "description": "An active membership is required." + }, + "429": { + "description": "Too many image upload requests." + }, + "503": { + "description": "Public image storage is unavailable." + } + } + }, + "put": { + "operationId": "api_v1_member_makerspaces_profile_image_update", + "summary": "Attach an uploaded image to the profile or one of its projects", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Member profile" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProfileImageAttachRequest" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/ProfileImageAttachRequest" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/ProfileImageAttachRequest" + } + } + }, + "required": true + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProfileRead" + } + } + }, + "description": "" + }, + "400": { + "description": "Invalid image object key or size." + }, + "403": { + "description": "An active membership is required." + }, + "503": { + "description": "Public image storage is unavailable." + } + } + }, + "delete": { + "operationId": "api_v1_member_makerspaces_profile_image_destroy", + "summary": "Clear a profile or project image", + "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/ProfileRead" + } + } + }, + "description": "" + }, + "400": { + "description": "Unknown project." + }, + "403": { + "description": "An active membership is required." + } + } + } + }, + "/api/v1/member/makerspaces/{makerspace_id}/referrals": { + "post": { + "operationId": "api_v1_member_makerspaces_referrals_create", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Memberships" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReferralCreate" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/ReferralCreate" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/ReferralCreate" + } + } + }, + "required": true + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReferralOutcome" + } + } + }, + "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/member/makerspaces/{makerspace_id}/waiver": { + "get": { + "operationId": "api_v1_member_makerspaces_waiver_retrieve", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Memberships" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MemberWaiverResponse" + } + } + }, + "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": "" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + } + } + } + }, + "/api/v1/member/makerspaces/{makerspace_id}/waiver/accept": { + "post": { + "operationId": "api_v1_member_makerspaces_waiver_accept_create", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Memberships" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WaiverAcceptResponse" + } + } + }, + "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": "" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + } + } + } + }, + "/api/v1/memberships/{id}/accept-invitation": { + "post": { + "operationId": "api_v1_memberships_accept_invitation_create", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Memberships" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvitationClaimOutcome" + } + } + }, + "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": "" + } + } + } + }, + "/api/v1/memberships/invitations": { + "get": { + "operationId": "api_v1_memberships_invitations_retrieve", + "tags": [ + "Memberships" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvitationList" + } + } + }, + "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": "" + } + } + } + }, + "/api/v1/memberships/invitations/{id}/claim": { + "post": { + "operationId": "api_v1_memberships_invitations_claim_create", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Memberships" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvitationClaimOutcome" + } + } + }, + "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": "" + } + } + } + }, + "/api/v1/memberships/me": { + "get": { + "operationId": "api_v1_memberships_me_retrieve", + "tags": [ + "Memberships" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MyMemberships" + } + } + }, + "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": "" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + } + } + } + }, + "/api/v1/notifications/makerspace/{makerspace_id}": { + "get": { + "operationId": "api_v1_notifications_makerspace_list", + "summary": "List makerspace notifications", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + }, + { + "in": "query", + "name": "page", + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "unread", + "schema": { + "type": "boolean" + }, + "description": "When true, return only unread notifications." + } + ], + "tags": [ + "Notifications" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaginatedNotificationList" + } + } + }, + "description": "" + }, + "400": { + "description": "Notifications module is disabled." + }, + "403": { + "description": "Permission denied." + }, + "404": { + "description": "Not found." + } + } + } + }, + "/api/v1/notifications/makerspace/{makerspace_id}/{id}/read": { + "post": { + "operationId": "api_v1_notifications_makerspace_read_create", + "summary": "Mark a makerspace notification read", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + }, + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Notifications" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Notification" + } + } + }, + "description": "" + }, + "400": { + "description": "Notifications module is disabled." + }, + "403": { + "description": "Permission denied." + }, + "404": { + "description": "Not found." + } + } + } + }, + "/api/v1/notifications/makerspace/{makerspace_id}/read-all": { + "post": { + "operationId": "api_v1_notifications_makerspace_read_all_create", + "summary": "Mark all makerspace notifications read", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Notifications" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotificationMarkAllRead" + } + } + }, + "description": "" + }, + "400": { + "description": "Notifications module is disabled." + }, + "403": { + "description": "Permission denied." + }, + "404": { + "description": "Not found." + } + } + } + }, + "/api/v1/notifications/makerspace/{makerspace_id}/unread-count": { + "get": { + "operationId": "api_v1_notifications_makerspace_unread_count_retrieve", + "summary": "Get unread makerspace notification count", + "parameters": [ + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Notifications" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotificationUnreadCount" + } + } + }, + "description": "" + }, + "400": { + "description": "Notifications module is disabled." + }, + "403": { + "description": "Permission denied." + }, + "404": { + "description": "Not found." + } + } + } + }, + "/api/v1/payments/connect/callback": { + "get": { + "operationId": "api_v1_payments_connect_callback_retrieve", + "summary": "Complete Stripe Connect onboarding", + "parameters": [ + { + "in": "query", + "name": "code", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "error", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "state", + "schema": { + "type": "string" + }, + "required": true + } + ], + "tags": [ + "Payments" + ], + "responses": { + "302": { + "description": "Redirect to trusted staff settings." + } + } + } + }, + "/api/v1/procurement/makerspace/{makerspace_id}/to-buy": { + "get": { + "operationId": "api_v1_procurement_makerspace_to_buy_list", + "summary": "List to-buy items for a makerspace", + "parameters": [ + { + "in": "query", + "name": "limit", + "schema": { + "type": "integer" + } + }, + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + }, + { + "in": "query", + "name": "status", + "schema": { + "type": "string", + "enum": [ + "approved", + "cancelled", + "ordered", + "received", + "requested" + ] + }, + "description": "Filter by procurement item status." + } + ], + "tags": [ + "Procurement" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ToBuyItem" + } + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Invalid request." + }, + "401": { + "description": "Authentication credentials were not provided." + }, + "403": { + "description": "Permission denied." + }, + "404": { + "description": "Not found." + } + } + }, + "post": { + "operationId": "api_v1_procurement_makerspace_to_buy_create", + "summary": "Add a to-buy item", + "parameters": [ + { + "in": "query", + "name": "kind", + "schema": { + "type": "string", + "enum": [ + "hardware", + "printing" + ] + }, + "description": "Stream to add to. Honored only for makerspace admins/superadmin; other roles are auto-tagged by role." + }, + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Procurement" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ToBuyItem" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/ToBuyItem" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/ToBuyItem" + } + } + }, + "required": true + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ToBuyItem" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Invalid request." + }, + "401": { + "description": "Authentication credentials were not provided." + }, + "403": { + "description": "Permission denied." + }, + "404": { + "description": "Not found." + } + } + } + }, + "/api/v1/procurement/makerspace/{makerspace_id}/to-buy/export": { + "get": { + "operationId": "api_v1_procurement_makerspace_to_buy_export_retrieve", + "summary": "Export to-buy items as CSV or XLSX", + "parameters": [ + { + "in": "query", + "name": "format", + "schema": { + "type": "string", + "enum": [ + "csv", + "xlsx" + ] + } + }, + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + }, + { + "in": "query", + "name": "status", + "schema": { + "type": "string", + "enum": [ + "approved", + "cancelled", + "ordered", + "received", + "requested" + ] + }, + "description": "Filter by procurement item status." + } + ], + "tags": [ + "Procurement" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "text/csv": { + "schema": { + "type": "string" + } + }, + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": { + "schema": { + "type": "string", + "format": "binary" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Invalid request." + }, + "401": { + "description": "Authentication credentials were not provided." + }, + "403": { + "description": "Permission denied." + }, + "404": { + "description": "Not found." + } + } + } + }, + "/api/v1/procurement/makerspace/{makerspace_id}/to-buy/machine-types": { + "get": { + "operationId": "api_v1_procurement_makerspace_to_buy_machine_types_retrieve", + "summary": "List machine types available for a new to-buy item", + "parameters": [ + { + "in": "query", + "name": "kind", + "schema": { + "type": "string", + "enum": [ + "hardware", + "printing" + ] + }, + "description": "Stream to add to. Honored only for makerspace admins/superadmin; other roles are auto-tagged by role." + }, + { + "in": "path", + "name": "makerspace_id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Procurement" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ToBuyMachineTypeOptions" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Invalid request." + }, + "401": { + "description": "Authentication credentials were not provided." + }, + "403": { + "description": "Permission denied." + }, + "404": { + "description": "Not found." + } + } + } + }, + "/api/v1/procurement/to-buy/{id}": { + "get": { + "operationId": "api_v1_procurement_to_buy_retrieve", + "summary": "Retrieve a to-buy item", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Procurement" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ToBuyItem" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Invalid request." + }, + "401": { + "description": "Authentication credentials were not provided." + }, + "403": { + "description": "Permission denied." + }, + "404": { + "description": "Not found." + } + } + }, + "patch": { + "operationId": "api_v1_procurement_to_buy_partial_update", + "summary": "Update a to-buy item", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Procurement" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PatchedToBuyItem" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/PatchedToBuyItem" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/PatchedToBuyItem" + } + } + } + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ToBuyItem" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Invalid request." + }, + "401": { + "description": "Authentication credentials were not provided." + }, + "403": { + "description": "Permission denied." + }, + "404": { + "description": "Not found." + } + } + }, + "delete": { + "operationId": "api_v1_procurement_to_buy_destroy", + "summary": "Delete a to-buy item", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Procurement" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "204": { + "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Invalid request." + }, + "401": { + "description": "Authentication credentials were not provided." + }, + "403": { + "description": "Permission denied." + }, + "404": { + "description": "Not found." + } + } + } + }, + "/api/v1/procurement/to-buy/{id}/move-to-inventory": { + "post": { + "operationId": "api_v1_procurement_to_buy_move_to_inventory_create", + "summary": "Move a received hardware to-buy item into inventory", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Procurement" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MoveToInventoryRequest" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/MoveToInventoryRequest" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/MoveToInventoryRequest" + } + } + }, + "required": true + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ToBuyItem" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Invalid request." + }, + "401": { + "description": "Authentication credentials were not provided." + }, + "403": { + "description": "Permission denied." + }, + "404": { + "description": "Not found." + } + } + } + }, + "/api/v1/procurement/to-buy/{id}/move-to-printing": { + "post": { + "operationId": "api_v1_procurement_to_buy_move_to_printing_create", + "summary": "Move a received printing to-buy item into printing assets", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Procurement" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MoveToPrintingRequest" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/MoveToPrintingRequest" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/MoveToPrintingRequest" + } + } + }, + "required": true + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ToBuyItem" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Invalid request." + }, + "401": { + "description": "Authentication credentials were not provided." + }, + "403": { + "description": "Permission denied." + }, + "404": { + "description": "Not found." + } + } + } + }, + "/api/v1/procurement/to-buy/{id}/receipts": { + "get": { + "operationId": "api_v1_procurement_to_buy_receipts_list", + "summary": "List procurement receipts for a to-buy item", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Procurement" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ToBuyReceipt" + } + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Invalid request." + }, + "401": { + "description": "Authentication credentials were not provided." + }, + "403": { + "description": "Permission denied." + }, + "404": { + "description": "Not found." + } + } + }, + "post": { + "operationId": "api_v1_procurement_to_buy_receipts_create", + "summary": "Finalize an uploaded procurement receipt", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Procurement" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ToBuyReceiptFinalize" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/ToBuyReceiptFinalize" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/ToBuyReceiptFinalize" + } + } + }, + "required": true + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ToBuyReceipt" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Invalid request." + }, + "503": { + "description": "Receipt storage is unavailable." + }, + "401": { + "description": "Authentication credentials were not provided." + }, + "403": { + "description": "Permission denied." + }, + "404": { + "description": "Not found." + } + } + } + }, + "/api/v1/procurement/to-buy/{id}/receipts/presign": { + "post": { + "operationId": "api_v1_procurement_to_buy_receipts_presign_create", + "summary": "Create a procurement receipt upload URL", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Procurement" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ToBuyReceiptPresign" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/ToBuyReceiptPresign" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/ToBuyReceiptPresign" + } + } + }, + "required": true + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ToBuyReceiptUploadResponse" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Invalid request." + }, + "503": { + "description": "Receipt storage is unavailable." + }, + "401": { + "description": "Authentication credentials were not provided." + }, + "403": { + "description": "Permission denied." + }, + "404": { + "description": "Not found." + } + } + } + }, + "/api/v1/procurement/to-buy/receipts/{id}": { + "delete": { + "operationId": "api_v1_procurement_to_buy_receipts_destroy", + "summary": "Delete a procurement receipt", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Procurement" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "204": { + "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Invalid request." + }, + "401": { + "description": "Authentication credentials were not provided." + }, + "403": { + "description": "Permission denied." + }, + "404": { + "description": "Not found." + } + } + } + }, + "/api/v1/procurement/to-buy/receipts/{id}/url": { + "get": { + "operationId": "api_v1_procurement_to_buy_receipts_url_retrieve", + "summary": "Create a signed procurement receipt view URL", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Procurement" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ToBuyReceiptUrl" + } + } + }, + "description": "" + }, + "503": { + "description": "Receipt storage is unavailable." + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Invalid request." + }, + "401": { + "description": "Authentication credentials were not provided." + }, + "403": { + "description": "Permission denied." + }, + "404": { + "description": "Not found." + } + } + } + }, + "/api/v1/public/{makerspace_slug}/event-calendar/{raw_token}.ics": { + "get": { + "operationId": "api_v1_public_event_calendar_.ics_retrieve", + "parameters": [ + { + "in": "path", + "name": "makerspace_slug", + "schema": { + "type": "string" + }, + "required": true + }, + { + "in": "path", + "name": "raw_token", + "schema": { + "type": "string" + }, + "required": true + } + ], + "tags": [ + "Public events" + ], + "responses": { + "200": { + "content": { + "text/calendar": { + "schema": { + "type": "string", + "format": "binary" + } + } + }, + "description": "RFC 5545 calendar (`text/calendar; charset=utf-8`)." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Calendar not found." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Rate limit exceeded." + } + } + } + }, + "/api/v1/public/{makerspace_slug}/events/": { + "get": { + "operationId": "api_v1_public_events_list", + "parameters": [ + { + "in": "path", + "name": "makerspace_slug", + "schema": { + "type": "string" + }, + "required": true + } + ], + "tags": [ + "Public events" + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PublicEvent" + } + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": {} + } + } + }, + "description": "Invalid request." + }, + "404": { + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": {} + } + } + }, + "description": "Event not found." + }, + "429": { + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": {} + } + } + }, + "description": "Rate limit exceeded." + } + } + } + }, + "/api/v1/public/{makerspace_slug}/events/{public_token}/calendar.ics": { + "get": { + "operationId": "api_v1_public_events_calendar.ics_retrieve", + "parameters": [ + { + "in": "path", + "name": "makerspace_slug", + "schema": { + "type": "string" + }, + "required": true + }, + { + "in": "path", + "name": "public_token", + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true + } + ], + "tags": [ + "Public events" + ], + "responses": { + "200": { + "content": { + "text/calendar": { + "schema": { + "type": "string", + "format": "binary" + } + } + }, + "description": "RFC 5545 calendar (`text/calendar; charset=utf-8`)." + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Events module unavailable." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Calendar not found." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Rate limit exceeded." + } + } + } + }, + "/api/v1/public/{makerspace_slug}/events/{public_token}/feedback/": { + "get": { + "operationId": "api_v1_public_events_feedback_retrieve", + "parameters": [ + { + "in": "path", + "name": "makerspace_slug", + "schema": { + "type": "string" + }, + "required": true + }, + { + "in": "path", + "name": "public_token", + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true + } + ], + "tags": [ + "Public events" + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FeedbackForm" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Invalid feedback answers." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Authentication is required for a certificate." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Feedback form not found." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Feedback retry conflict." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Rate limit exceeded." + } + } + }, + "post": { + "operationId": "api_v1_public_events_feedback_create", + "parameters": [ + { + "in": "path", + "name": "makerspace_slug", + "schema": { + "type": "string" + }, + "required": true + }, + { + "in": "path", + "name": "public_token", + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true + } + ], + "tags": [ + "Public events" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FeedbackSubmission" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/FeedbackSubmission" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/FeedbackSubmission" + } + } + } + }, + "security": [ + { + "jwtAuth": [] + }, + {} + ], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FeedbackSubmissionResponse" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Invalid feedback answers." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Authentication is required for a certificate." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Feedback form not found." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Feedback retry conflict." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Rate limit exceeded." + } + } + } + }, + "/api/v1/public/{makerspace_slug}/events/{public_token}/register/": { + "post": { + "operationId": "api_v1_public_events_register_create", + "parameters": [ + { + "in": "path", + "name": "makerspace_slug", + "schema": { + "type": "string" + }, + "required": true + }, + { + "in": "path", + "name": "public_token", + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true + } + ], + "tags": [ + "Public events" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicEventRegistrationInput" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/PublicEventRegistrationInput" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/PublicEventRegistrationInput" + } + } + } + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicEventRegistrationResponse" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": {} + } + } + }, + "description": "Invalid request." + }, + "404": { + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": {} + } + } + }, + "description": "Event not found." + }, + "429": { + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": {} + } + } + }, + "description": "Rate limit exceeded." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Authentication is required." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Active membership and current waiver acceptance are required." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Event state conflict." + } + } + } + }, + "/api/v1/public/{makerspace_slug}/inventory/": { + "get": { + "operationId": "api_v1_public_inventory_list", + "description": "List public inventory products for a public makerspace.", + "summary": "List public inventory products", + "parameters": [ + { + "in": "header", + "name": "X-Nonce", + "schema": { + "type": "string" + }, + "description": "Unique, unpredictable nonce for HMAC-authenticated server API clients (1-128 characters: letters, digits, `.`, `_`, `~`, or `-`). Include it between `X-Timestamp` and the raw body in the signed bytes: `METHOD\\nFULL_PATH\\nTIMESTAMP\\nNONCE\\nBODY`. It is optional only for publishable-key/browser authentication and during the temporary legacy rollout while `APICLIENT_REQUIRE_NONCE` is disabled." + }, + { + "in": "header", + "name": "X-Publishable-Key", + "schema": { + "type": "string" + }, + "description": "Public API key for a makerspace public client. Required when API_CLIENT_AUTH_REQUIRED is enabled." + }, + { + "in": "query", + "name": "category", + "schema": { + "type": "string" + }, + "description": "Filter public products by category slug." + }, + { + "in": "path", + "name": "makerspace_slug", + "schema": { + "type": "string" + }, + "description": "Public makerspace code (for example TSEL) or slug.", + "required": true + }, + { + "name": "page", + "required": false, + "in": "query", + "description": "A page number within the paginated result set.", + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "q", + "schema": { + "type": "string" + }, + "description": "Search public products by name or description." + }, + { + "in": "query", + "name": "sort", + "schema": { + "type": "string", + "enum": [ + "most_used", + "name", + "popular" + ] + }, + "description": "Sort public products." + } + ], + "tags": [ + "Public inventory" + ], + "security": [ + { + "jwtAuth": [] + }, + {} + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaginatedPublicProductList" + } + } + }, + "description": "" + } + } + } + }, + "/api/v1/public/{makerspace_slug}/inventory/{id}/": { + "get": { + "operationId": "api_v1_public_inventory_retrieve", + "summary": "Get public inventory product detail", + "parameters": [ + { + "in": "header", + "name": "X-Nonce", + "schema": { + "type": "string" + }, + "description": "Unique, unpredictable nonce for HMAC-authenticated server API clients (1-128 characters: letters, digits, `.`, `_`, `~`, or `-`). Include it between `X-Timestamp` and the raw body in the signed bytes: `METHOD\\nFULL_PATH\\nTIMESTAMP\\nNONCE\\nBODY`. It is optional only for publishable-key/browser authentication and during the temporary legacy rollout while `APICLIENT_REQUIRE_NONCE` is disabled." + }, + { + "in": "header", + "name": "X-Publishable-Key", + "schema": { + "type": "string" + }, + "description": "Public API key for a makerspace public client. Required when API_CLIENT_AUTH_REQUIRED is enabled." + }, + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + }, + { + "in": "path", + "name": "makerspace_slug", + "schema": { + "type": "string" + }, + "required": true + } + ], + "tags": [ + "Public inventory" + ], + "security": [ + { + "jwtAuth": [] + }, + {} + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicProduct" + } + } + }, + "description": "" + } + } + } + }, + "/api/v1/public/{makerspace_slug}/inventory/categories/": { + "get": { + "operationId": "api_v1_public_inventory_categories_list", + "summary": "List public inventory categories", + "parameters": [ + { + "in": "header", + "name": "X-Nonce", + "schema": { + "type": "string" + }, + "description": "Unique, unpredictable nonce for HMAC-authenticated server API clients (1-128 characters: letters, digits, `.`, `_`, `~`, or `-`). Include it between `X-Timestamp` and the raw body in the signed bytes: `METHOD\\nFULL_PATH\\nTIMESTAMP\\nNONCE\\nBODY`. It is optional only for publishable-key/browser authentication and during the temporary legacy rollout while `APICLIENT_REQUIRE_NONCE` is disabled." + }, + { + "in": "header", + "name": "X-Publishable-Key", + "schema": { + "type": "string" + }, + "description": "Public API key for a makerspace public client. Required when API_CLIENT_AUTH_REQUIRED is enabled." + }, + { + "in": "path", + "name": "makerspace_slug", + "schema": { + "type": "string" + }, + "required": true + } + ], + "tags": [ + "Public inventory" + ], + "security": [ + { + "jwtAuth": [] + }, + {} + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PublicCategory" + } + } + } + }, + "description": "" + } + } + } + }, + "/api/v1/public/{makerspace_slug}/machine-service-requests": { + "post": { + "operationId": "api_v1_public_machine_service_requests_create", + "summary": "Submit a machine service request as a member", + "parameters": [ + { + "in": "path", + "name": "makerspace_slug", + "schema": { + "type": "string" + }, + "required": true + } + ], + "tags": [ + "Public machine service" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicMachineServiceSubmit" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/PublicMachineServiceSubmit" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/PublicMachineServiceSubmit" + } + } + }, + "required": true + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicMachineServiceSubmitResponse" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Invalid machine service request input." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Authentication is required." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Active membership, waiver acceptance, and presence are required." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Makerspace or machine not found." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Machine service request conflict." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Request rate limit exceeded." + } + } + } + }, + "/api/v1/public/{makerspace_slug}/machine-service/3d-printer/consumable-pools": { + "get": { + "operationId": "api_v1_public_machine_service_3d_printer_consumable_pools_list", + "parameters": [ + { + "in": "path", + "name": "makerspace_slug", + "schema": { + "type": "string" + }, + "required": true + } + ], + "tags": [ + "Public machine service" + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PublicPrinterPool" + } + } + } + }, + "description": "" + } + } + } + }, + "/api/v1/public/{makerspace_slug}/machine-service/3d-printer/queues": { + "get": { + "operationId": "api_v1_public_machine_service_3d_printer_queues_list", + "parameters": [ + { + "in": "path", + "name": "makerspace_slug", + "schema": { + "type": "string" + }, + "required": true + } + ], + "tags": [ + "Public machine service" + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PublicPrinterQueue" + } + } + } + }, + "description": "" + } + } + } + }, + "/api/v1/public/{makerspace_slug}/machine-service/3d-printer/requests": { + "post": { + "operationId": "api_v1_public_machine_service_3d_printer_requests_create", + "parameters": [ + { + "in": "path", + "name": "makerspace_slug", + "schema": { + "type": "string" + }, + "required": true + } + ], + "tags": [ + "Public machine service" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicPrinterSubmit" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/PublicPrinterSubmit" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/PublicPrinterSubmit" + } + } + }, + "required": true + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicPrinterSubmitResponse" + } + } + }, + "description": "" + } + } + } + }, + "/api/v1/public/{makerspace_slug}/machine-service/3d-printer/uploads": { + "post": { + "operationId": "api_v1_public_machine_service_3d_printer_uploads_create", + "parameters": [ + { + "in": "path", + "name": "makerspace_slug", + "schema": { + "type": "string" + }, + "required": true + } + ], + "tags": [ + "Public machine service" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicPrinterUpload" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/PublicPrinterUpload" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/PublicPrinterUpload" + } + } + }, + "required": true + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicPrinterSubmitResponse" + } + } + }, + "description": "" + } + } + } + }, + "/api/v1/public/{makerspace_slug}/machines": { + "get": { + "operationId": "api_v1_public_machines_list", + "description": "List active machines published by a public makerspace.", + "summary": "List public machines", + "parameters": [ + { + "in": "header", + "name": "X-Nonce", + "schema": { + "type": "string" + }, + "description": "Unique, unpredictable nonce for HMAC-authenticated server API clients (1-128 characters: letters, digits, `.`, `_`, `~`, or `-`). Include it between `X-Timestamp` and the raw body in the signed bytes: `METHOD\\nFULL_PATH\\nTIMESTAMP\\nNONCE\\nBODY`. It is optional only for publishable-key/browser authentication and during the temporary legacy rollout while `APICLIENT_REQUIRE_NONCE` is disabled." + }, + { + "in": "header", + "name": "X-Publishable-Key", + "schema": { + "type": "string" + }, + "description": "Public API key for a makerspace public client. Required when API_CLIENT_AUTH_REQUIRED is enabled." + }, + { + "in": "path", + "name": "makerspace_slug", + "schema": { + "type": "string" + }, + "description": "Public makerspace code or slug.", + "required": true + }, + { + "name": "page", + "required": false, + "in": "query", + "description": "A page number within the paginated result set.", + "schema": { + "type": "integer" + } + } + ], + "tags": [ + "Public machines" + ], + "security": [ + { + "jwtAuth": [] + }, + {} + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaginatedPublicMachineList" + } + } + }, + "description": "" + } + } + } + }, + "/api/v1/public/{makerspace_slug}/membership-requests": { + "post": { + "operationId": "api_v1_public_membership_requests_create", + "parameters": [ + { + "in": "path", + "name": "makerspace_slug", + "schema": { + "type": "string" + }, + "required": true + } + ], + "tags": [ + "Memberships" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MembershipRequestCreate" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/MembershipRequestCreate" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/MembershipRequestCreate" + } + } + } + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MembershipOutcome" + } + } + }, + "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": "" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + } + } + } + }, + "/api/v1/public/{makerspace_slug}/presence-sessions": { + "post": { + "operationId": "api_v1_public_presence_sessions_create", + "parameters": [ + { + "in": "path", + "name": "makerspace_slug", + "schema": { + "type": "string" + }, + "required": true + } + ], + "tags": [ + "Presence" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PresenceStart" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/PresenceStart" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/PresenceStart" + } + } + }, + "required": true + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PresenceSession" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Invalid input." + }, + "401": { + "description": "Authentication required." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Membership permission required." + }, + "404": { + "description": "Makerspace not found." + }, + "429": { + "description": "Rate limit exceeded." + } + } + } + }, + "/api/v1/public/{makerspace_slug}/presence-sessions/current": { + "get": { + "operationId": "api_v1_public_presence_sessions_current_retrieve", + "parameters": [ + { + "in": "path", + "name": "makerspace_slug", + "schema": { + "type": "string" + }, + "required": true + } + ], + "tags": [ + "Presence" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PresenceCurrent" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Invalid input." + }, + "401": { + "description": "Authentication required." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Membership permission required." + }, + "404": { + "description": "Makerspace not found." + }, + "429": { + "description": "Rate limit exceeded." + } + } + } + }, + "/api/v1/public/{makerspace_slug}/presence-sessions/current/end": { + "post": { + "operationId": "api_v1_public_presence_sessions_current_end_create", + "parameters": [ + { + "in": "path", + "name": "makerspace_slug", + "schema": { + "type": "string" + }, + "required": true + } + ], + "tags": [ + "Presence" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PresenceCurrent" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Invalid input." + }, + "401": { + "description": "Authentication required." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Membership permission required." + }, + "404": { + "description": "Makerspace not found." + }, + "429": { + "description": "Rate limit exceeded." + } + } + } + }, + "/api/v1/public/{makerspace_slug}/requests": { + "post": { + "operationId": "api_v1_public_requests_create", + "summary": "Submit public borrow request", + "parameters": [ + { + "in": "header", + "name": "Idempotency-Key", + "schema": { + "type": "string" + }, + "description": "Required for account-less submissions. Reusing a key with the same payload returns the original request; a different payload is rejected." + }, + { + "in": "header", + "name": "X-Nonce", + "schema": { + "type": "string" + }, + "description": "Unique, unpredictable nonce for HMAC-authenticated server API clients (1-128 characters: letters, digits, `.`, `_`, `~`, or `-`). Include it between `X-Timestamp` and the raw body in the signed bytes: `METHOD\\nFULL_PATH\\nTIMESTAMP\\nNONCE\\nBODY`. It is optional only for publishable-key/browser authentication and during the temporary legacy rollout while `APICLIENT_REQUIRE_NONCE` is disabled." + }, + { + "in": "header", + "name": "X-Publishable-Key", + "schema": { + "type": "string" + }, + "description": "Public API key for a makerspace public client. Required when API_CLIENT_AUTH_REQUIRED is enabled." + }, + { + "in": "path", + "name": "makerspace_slug", + "schema": { + "type": "string" + }, + "required": true + } + ], + "tags": [ + "Public requests" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequestSubmit" + }, + "examples": { + "SubmitPublicEquipmentRequest": { + "value": { + "contact_name": "Shaan Shoukath", + "contact_email": "shaans@example.com", + "contact_phone": "+919876543210", + "requested_for": "Electronics workshop diagnostics", + "items": [ + { + "product_id": 42, + "quantity": 2 + } + ] + }, + "summary": "Submit public equipment request" + } + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/RequestSubmit" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/RequestSubmit" + } + } + }, + "required": true + }, + "security": [ + { + "jwtAuth": [] + }, + {} + ], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequestSubmitResponse" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Invalid request." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Authentication required." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Permission denied." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Not found." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Workflow conflict." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Too many requests." + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Service unavailable." + } + } + } + }, + "/api/v1/public/{makerspace_slug}/spaces/": { + "get": { + "operationId": "api_v1_public_spaces_list", + "parameters": [ + { + "in": "path", + "name": "makerspace_slug", + "schema": { + "type": "string" + }, + "required": true + } + ], + "tags": [ + "Public bookings" + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PublicBookableSpace" + } + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": {} + } + } + }, + "description": "Invalid request." + }, + "404": { + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": {} + } + } + }, + "description": "Space not found." + }, + "429": { + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": {} + } + } + }, + "description": "Rate limit exceeded." + } + } + } + }, + "/api/v1/public/{makerspace_slug}/spaces/{public_token}/availability/": { + "get": { + "operationId": "api_v1_public_spaces_availability_retrieve", + "parameters": [ + { + "in": "query", + "name": "ends_at", + "schema": { + "type": "string", + "format": "date-time" + }, + "required": true + }, + { + "in": "path", + "name": "makerspace_slug", + "schema": { + "type": "string" + }, + "required": true + }, + { + "in": "path", + "name": "public_token", + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true + }, + { + "in": "query", + "name": "starts_at", + "schema": { + "type": "string", + "format": "date-time" + }, + "required": true + } + ], + "tags": [ + "Public bookings" + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicSpaceAvailability" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": {} + } + } + }, + "description": "Invalid request." + }, + "404": { + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": {} + } + } + }, + "description": "Space not found." + }, + "429": { + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": {} + } + } + }, + "description": "Rate limit exceeded." + } + } + } + }, + "/api/v1/public/{makerspace_slug}/spaces/{public_token}/book/": { + "post": { + "operationId": "api_v1_public_spaces_book_create", + "parameters": [ + { + "in": "path", + "name": "makerspace_slug", + "schema": { + "type": "string" + }, + "required": true + }, + { + "in": "path", + "name": "public_token", + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true + } + ], + "tags": [ + "Public bookings" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicBookingInput" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/PublicBookingInput" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/PublicBookingInput" + } + } + }, + "required": true + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicBookingResponse" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": {} + } + } + }, + "description": "Invalid request." + }, + "404": { + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": {} + } + } + }, + "description": "Space not found." + }, + "429": { + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": {} + } + } + }, + "description": "Rate limit exceeded." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Authentication is required." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Active membership and presence are required." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Booking conflict." + } + } + } + }, + "/api/v1/public/{makerspace_slug}/stats/": { + "get": { + "operationId": "api_v1_public_stats_retrieve", + "description": "Get public activity stats for a public makerspace.", + "summary": "Get public makerspace stats", + "parameters": [ + { + "in": "header", + "name": "X-Nonce", + "schema": { + "type": "string" + }, + "description": "Unique, unpredictable nonce for HMAC-authenticated server API clients (1-128 characters: letters, digits, `.`, `_`, `~`, or `-`). Include it between `X-Timestamp` and the raw body in the signed bytes: `METHOD\\nFULL_PATH\\nTIMESTAMP\\nNONCE\\nBODY`. It is optional only for publishable-key/browser authentication and during the temporary legacy rollout while `APICLIENT_REQUIRE_NONCE` is disabled." + }, + { + "in": "header", + "name": "X-Publishable-Key", + "schema": { + "type": "string" + }, + "description": "Public API key for a makerspace public client. Required when API_CLIENT_AUTH_REQUIRED is enabled." + }, + { + "in": "path", + "name": "makerspace_slug", + "schema": { + "type": "string" + }, + "description": "Public makerspace code (for example TSEL) or slug.", + "required": true + } + ], + "tags": [ + "Public inventory" + ], + "security": [ + { + "jwtAuth": [] + }, + {} + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicStats" + } + } + }, + "description": "" + } + } + } + }, + "/api/v1/public/{makerspace_slug}/tools/checkout": { + "post": { + "operationId": "api_v1_public_tools_checkout_create", + "summary": "Check out a public tool by QR", + "parameters": [ + { + "in": "header", + "name": "X-Nonce", + "schema": { + "type": "string" + }, + "description": "Unique, unpredictable nonce for HMAC-authenticated server API clients (1-128 characters: letters, digits, `.`, `_`, `~`, or `-`). Include it between `X-Timestamp` and the raw body in the signed bytes: `METHOD\\nFULL_PATH\\nTIMESTAMP\\nNONCE\\nBODY`. It is optional only for publishable-key/browser authentication and during the temporary legacy rollout while `APICLIENT_REQUIRE_NONCE` is disabled." + }, + { + "in": "header", + "name": "X-Publishable-Key", + "schema": { + "type": "string" + }, + "description": "Public API key for a makerspace public client. Required when API_CLIENT_AUTH_REQUIRED is enabled." + }, + { + "in": "path", + "name": "makerspace_slug", + "schema": { + "type": "string" + }, + "required": true + } + ], + "tags": [ + "Public requests" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicToolCheckout" + }, + "examples": { + "PublicQRToolCheckout": { + "value": { + "payload": "BOX-ABC123", + "requester_name": "Shaan Shoukath", + "contact_email": "shaans@example.com", + "contact_phone": "+919876543210", + "evidence_id": 122, + "remark": "Borrowing for electronics workshop diagnostics." + }, + "summary": "Public QR tool checkout" + } + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/PublicToolCheckout" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/PublicToolCheckout" + } + } + }, + "required": true + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicToolLoan" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Invalid request." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Authentication required." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Permission denied." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Not found." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Workflow conflict." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Too many requests." + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Service unavailable." + } + } + } + }, + "/api/v1/public/{makerspace_slug}/tools/evidence-url": { + "post": { + "operationId": "api_v1_public_tools_evidence_url_create", + "summary": "Create a public self-checkout evidence upload URL", + "parameters": [ + { + "in": "header", + "name": "X-Nonce", + "schema": { + "type": "string" + }, + "description": "Unique, unpredictable nonce for HMAC-authenticated server API clients (1-128 characters: letters, digits, `.`, `_`, `~`, or `-`). Include it between `X-Timestamp` and the raw body in the signed bytes: `METHOD\\nFULL_PATH\\nTIMESTAMP\\nNONCE\\nBODY`. It is optional only for publishable-key/browser authentication and during the temporary legacy rollout while `APICLIENT_REQUIRE_NONCE` is disabled." + }, + { + "in": "header", + "name": "X-Publishable-Key", + "schema": { + "type": "string" + }, + "description": "Public API key for a makerspace public client. Required when API_CLIENT_AUTH_REQUIRED is enabled." + }, + { + "in": "path", + "name": "makerspace_slug", + "schema": { + "type": "string" + }, + "required": true + } + ], + "tags": [ + "Public requests" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicToolEvidenceUrlRequest" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/PublicToolEvidenceUrlRequest" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/PublicToolEvidenceUrlRequest" + } + } + }, + "required": true + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EvidenceUrlResponse" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Invalid request." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Authentication required." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Permission denied." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Not found." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Workflow conflict." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Too many requests." + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Service unavailable." + } + } + } + }, + "/api/v1/public/{makerspace_slug}/tools/return": { + "post": { + "operationId": "api_v1_public_tools_return_create", + "summary": "Return a public tool by QR", + "parameters": [ + { + "in": "header", + "name": "X-Nonce", + "schema": { + "type": "string" + }, + "description": "Unique, unpredictable nonce for HMAC-authenticated server API clients (1-128 characters: letters, digits, `.`, `_`, `~`, or `-`). Include it between `X-Timestamp` and the raw body in the signed bytes: `METHOD\\nFULL_PATH\\nTIMESTAMP\\nNONCE\\nBODY`. It is optional only for publishable-key/browser authentication and during the temporary legacy rollout while `APICLIENT_REQUIRE_NONCE` is disabled." + }, + { + "in": "header", + "name": "X-Publishable-Key", + "schema": { + "type": "string" + }, + "description": "Public API key for a makerspace public client. Required when API_CLIENT_AUTH_REQUIRED is enabled." + }, + { + "in": "path", + "name": "makerspace_slug", + "schema": { + "type": "string" + }, + "required": true + } + ], + "tags": [ + "Public requests" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicToolScan" + }, + "examples": { + "PublicQRToolReturnScan": { + "value": { + "identifier": "shaans@example.com", + "payload": "BOX-ABC123", + "evidence_id": 123, + "remark": "Returned to the electronics shelf in good condition." + }, + "summary": "Public QR tool return scan" + } + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/PublicToolScan" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/PublicToolScan" + } + } + }, + "required": true + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicToolLoan" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Invalid request." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Authentication required." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Permission denied." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Not found." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Workflow conflict." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Too many requests." + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Service unavailable." + } + } + } + }, + "/api/v1/public/machine-service/3d-printer/requests/{public_token}/status": { + "get": { + "operationId": "api_v1_public_machine_service_3d_printer_requests_status_retrieve", + "parameters": [ + { + "in": "path", + "name": "public_token", + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true + } + ], + "tags": [ + "Public machine service" + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicPrinterStatus" + } + } + }, + "description": "" + } + } + } + }, + "/api/v1/public/makerspaces/": { + "get": { + "operationId": "api_v1_public_makerspaces_list", + "description": "List makerspaces that have public inventory enabled.", + "summary": "List public makerspaces", + "parameters": [ + { + "in": "header", + "name": "X-Nonce", + "schema": { + "type": "string" + }, + "description": "Unique, unpredictable nonce for HMAC-authenticated server API clients (1-128 characters: letters, digits, `.`, `_`, `~`, or `-`). Include it between `X-Timestamp` and the raw body in the signed bytes: `METHOD\\nFULL_PATH\\nTIMESTAMP\\nNONCE\\nBODY`. It is optional only for publishable-key/browser authentication and during the temporary legacy rollout while `APICLIENT_REQUIRE_NONCE` is disabled." + }, + { + "in": "header", + "name": "X-Publishable-Key", + "schema": { + "type": "string" + }, + "description": "Public API key for a makerspace public client. Required when API_CLIENT_AUTH_REQUIRED is enabled." + } + ], + "tags": [ + "Public inventory" + ], + "security": [ + { + "jwtAuth": [] + }, + {} + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PublicMakerspace" + } + } + } + }, + "description": "" + } + } + } + }, + "/api/v1/public/organizations/{slug}/": { + "get": { + "operationId": "api_v1_public_organizations_retrieve", + "summary": "Retrieve a public organization profile", + "parameters": [ + { + "in": "path", + "name": "slug", + "schema": { + "type": "string" + }, + "required": true + } + ], + "tags": [ + "Public organizations" + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicOrganization" + } + } + }, + "description": "" + }, + "404": { + "description": "Organization not found." + }, + "429": { + "description": "Rate limit exceeded." + } + } + } + }, + "/api/v1/public/organizations/{slug}/events/": { + "get": { + "operationId": "api_v1_public_organizations_events_retrieve", + "summary": "List public events organized across makerspaces", + "parameters": [ + { + "in": "path", + "name": "slug", + "schema": { + "type": "string" + }, + "required": true + } + ], + "tags": [ + "Public organizations" + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicOrganizationEventList" + } + } + }, + "description": "" + }, + "404": { + "description": "Organization not found." + }, + "429": { + "description": "Rate limit exceeded." + } + } + } + }, + "/api/v1/public/requests/{public_token}/status": { + "get": { + "operationId": "api_v1_public_requests_status_retrieve", + "summary": "Get request status by public token", + "parameters": [ + { + "in": "header", + "name": "X-Nonce", + "schema": { + "type": "string" + }, + "description": "Unique, unpredictable nonce for HMAC-authenticated server API clients (1-128 characters: letters, digits, `.`, `_`, `~`, or `-`). Include it between `X-Timestamp` and the raw body in the signed bytes: `METHOD\\nFULL_PATH\\nTIMESTAMP\\nNONCE\\nBODY`. It is optional only for publishable-key/browser authentication and during the temporary legacy rollout while `APICLIENT_REQUIRE_NONCE` is disabled." + }, + { + "in": "header", + "name": "X-Publishable-Key", + "schema": { + "type": "string" + }, + "description": "Public API key for a makerspace public client. Required when API_CLIENT_AUTH_REQUIRED is enabled." + }, + { + "in": "path", + "name": "public_token", + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true + } + ], + "tags": [ + "Public requests" + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicRequestStatus" + }, + "examples": { + "PublicRequestStatus": { + "value": { + "public_token": "4f2b93e1-6ef4-41c2-8407-7f26bb3b2d8f", + "requested_for": "Electronics workshop diagnostics", + "status": "pending_approval", + "rejection_reason": "", + "created_at": "2026-06-11T10:30:00Z", + "items": [ + { + "product_name": "Soldering Iron", + "requested_quantity": 2 + } + ] + }, + "summary": "Public request status" + } + } + } + }, + "description": "" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Not found." + } + } + } + }, + "/api/v1/recovery": { + "get": { + "operationId": "api_v1_recovery_retrieve", + "summary": "Get deployment quarantine and residual-risk state", + "tags": [ + "Backup recovery" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RecoveryState" + } + } + }, + "description": "" + }, + "401": { + "description": "Authentication is required." + }, + "403": { + "description": "The authenticated actor is not authorized." + } + } + }, + "post": { + "operationId": "api_v1_recovery_create", + "summary": "Acknowledge residual risk and lift quarantine", + "tags": [ + "Backup recovery" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RecoveryAcknowledge" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/RecoveryAcknowledge" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/RecoveryAcknowledge" + } + } + }, + "required": true + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RecoveryState" + } + } + }, + "description": "" + }, + "400": { + "description": "The request is invalid for the current lifecycle state." + }, + "401": { + "description": "Authentication is required." + }, + "403": { + "description": "The authenticated actor is not authorized." + } + } + } + }, + "/api/v1/webhooks/razorpay/{public_code}": { + "post": { + "operationId": "api_v1_webhooks_razorpay_create", + "description": "Verifies the X-Razorpay-Signature HMAC over request.body using the addressed makerspace's webhook secret before applying an idempotent event.", + "summary": "Receive a makerspace Razorpay webhook", + "parameters": [ + { + "in": "path", + "name": "public_code", + "schema": { + "type": "string" + }, + "required": true + } + ], + "tags": [ + "Payments" + ], + "responses": { + "200": { + "description": "Verified event acknowledged." + }, + "400": { + "description": "Invalid signature, payload, or configuration." + }, + "404": { + "description": "Makerspace was not found." + } + } + } + }, + "/api/v1/webhooks/stripe/{public_code}": { + "post": { + "operationId": "api_v1_webhooks_stripe_create", + "description": "Verifies the Stripe signature using request.body and the addressed makerspace's webhook secret before applying an idempotent event.", + "summary": "Receive a makerspace Stripe webhook", + "parameters": [ + { + "in": "path", + "name": "public_code", + "schema": { + "type": "string" + }, + "required": true + } + ], + "tags": [ + "Payments" + ], + "responses": { + "200": { + "description": "Verified event acknowledged." + }, + "400": { + "description": "Invalid signature, payload, or configuration." + }, + "404": { + "description": "Makerspace was not found." + } + } + } + }, + "/api/v1/webhooks/stripe/connect": { + "post": { + "operationId": "api_v1_webhooks_stripe_connect_create", + "summary": "Receive a platform Stripe Connect webhook", + "tags": [ + "Payments" + ], + "responses": { + "200": { + "description": "Verified event acknowledged." + }, + "400": { + "description": "Invalid signature or configuration." + }, + "404": { + "description": "Stripe Connect is dormant." + } + } + } + } + }, + "components": { + "schemas": { + "AcceptQuantity": { + "type": "object", + "properties": { + "item_id": { + "type": "integer" + }, + "quantity": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "item_id", + "quantity" + ] + }, + "AcceptRequest": { + "type": "object", + "properties": { + "accepted_quantities": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AcceptQuantity" + } + } + } + }, + "AccessLevelEnum": { + "enum": [ + "operate", + "manage", + "full" + ], + "type": "string", + "description": "* `operate` - Operate\n* `manage` - Manage\n* `full` - Full" + }, + "AccessStatusEnum": { + "enum": [ + "active", + "restricted", + "suspended" + ], + "type": "string", + "description": "* `active` - Active\n* `restricted` - Restricted\n* `suspended` - Suspended" + }, + "ActiveLoansReport": { + "type": "object", + "properties": { + "rows": { + "type": "array", + "items": { + "type": "array", + "items": {} + } + }, + "typed_rows": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ActiveLoansReportRow" + } + } + }, + "required": [ + "rows", + "typed_rows" + ] + }, + "ActiveLoansReportRow": { + "type": "object", + "properties": { + "makerspace_id": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "requester": { + "type": "string" + }, + "status": { + "type": "string" + }, + "issued_at": { + "type": "string", + "format": "date-time", + "nullable": true + } + }, + "required": [ + "id", + "issued_at", + "requester", + "status" + ] + }, + "AdminMembership": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "readOnly": true + }, + "status": { + "$ref": "#/components/schemas/Status37fEnum" + }, + "user": { + "allOf": [ + { + "$ref": "#/components/schemas/AdminMembershipUser" + } + ], + "readOnly": true + }, + "assigned_role": { + "allOf": [ + { + "$ref": "#/components/schemas/AdminMembershipRole" + } + ], + "nullable": true, + "readOnly": true + }, + "can_refer": { + "type": "boolean" + }, + "can_verify": { + "type": "boolean" + }, + "verified_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "activated_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "revoked_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "revocation_reason": { + "type": "string" + }, + "waiver_accepted_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "waiver_version_accepted": { + "type": "string", + "nullable": true, + "maxLength": 64 + }, + "waiver_current": { + "type": "boolean", + "readOnly": true + }, + "waiver_required": { + "type": "boolean", + "readOnly": true + }, + "payment": { + "allOf": [ + { + "$ref": "#/components/schemas/StaffPaymentSummary" + } + ], + "nullable": true, + "readOnly": true + } + }, + "required": [ + "assigned_role", + "id", + "payment", + "user", + "waiver_current", + "waiver_required" + ] + }, + "AdminMembershipRole": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "slug": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "slug" + ] + }, + "AdminMembershipUser": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "username": { + "type": "string" + }, + "email": { + "type": "string", + "format": "email" + }, + "display_name": { + "type": "string" + } + }, + "required": [ + "display_name", + "email", + "id", + "username" + ] + }, + "AdminRequest": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "readOnly": true + }, + "makerspace_id": { + "type": "integer", + "readOnly": true + }, + "requester_username": { + "type": "string", + "readOnly": true + }, + "requester_name": { + "type": "string", + "readOnly": true + }, + "requester_display": { + "type": "string", + "readOnly": true + }, + "requester_contact_email": { + "type": "string", + "format": "email", + "readOnly": true + }, + "requester_contact_phone": { + "type": "string", + "readOnly": true + }, + "requester_contact_verified": { + "type": "boolean", + "readOnly": true + }, + "status": { + "type": "string", + "readOnly": true + }, + "requested_for": { + "type": "string", + "readOnly": true + }, + "rejection_reason": { + "type": "string", + "readOnly": true + }, + "assigned_box_label": { + "type": "string", + "readOnly": true, + "nullable": true + }, + "accepted_by": { + "allOf": [ + { + "$ref": "#/components/schemas/AdminRequestActor" + } + ], + "readOnly": true, + "nullable": true + }, + "issued_by": { + "allOf": [ + { + "$ref": "#/components/schemas/AdminRequestActor" + } + ], + "readOnly": true, + "nullable": true + }, + "accepted_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "issued_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "return_due_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "return_reminder_sent_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "closed_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "created_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "updated_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "issue_evidence_id": { + "type": "integer", + "readOnly": true, + "nullable": true + }, + "return_evidence_ids": { + "type": "array", + "items": {}, + "readOnly": true + }, + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AdminRequestItem" + }, + "readOnly": true + } + }, + "required": [ + "accepted_at", + "accepted_by", + "assigned_box_label", + "closed_at", + "created_at", + "id", + "issue_evidence_id", + "issued_at", + "issued_by", + "items", + "makerspace_id", + "rejection_reason", + "requested_for", + "requester_contact_email", + "requester_contact_phone", + "requester_contact_verified", + "requester_display", + "requester_name", + "requester_username", + "return_due_at", + "return_evidence_ids", + "return_reminder_sent_at", + "status", + "updated_at" + ] + }, + "AdminRequestActor": { + "type": "object", + "properties": { + "username": { + "type": "string", + "readOnly": true + }, + "role": { + "type": "string", + "readOnly": true + } + }, + "required": [ + "role", + "username" + ] + }, + "AdminRequestItem": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "readOnly": true + }, + "product_id": { + "type": "integer", + "readOnly": true + }, + "product_name": { + "type": "string", + "readOnly": true + }, + "storage_location": { + "type": "string", + "readOnly": true + }, + "tracking_mode": { + "type": "string", + "readOnly": true + }, + "requires_asset_qr": { + "type": "boolean", + "readOnly": true + }, + "requested_quantity": { + "type": "integer", + "readOnly": true + }, + "accepted_quantity": { + "type": "integer", + "readOnly": true + }, + "issued_quantity": { + "type": "integer", + "readOnly": true + }, + "returned_quantity": { + "type": "integer", + "readOnly": true + }, + "damaged_quantity": { + "type": "integer", + "readOnly": true + }, + "missing_quantity": { + "type": "integer", + "readOnly": true + }, + "needs_fix_quantity": { + "type": "integer", + "readOnly": true + }, + "issued_assets": { + "type": "array", + "items": {}, + "readOnly": true + } + }, + "required": [ + "accepted_quantity", + "damaged_quantity", + "id", + "issued_assets", + "issued_quantity", + "missing_quantity", + "needs_fix_quantity", + "product_id", + "product_name", + "requested_quantity", + "requires_asset_qr", + "returned_quantity", + "storage_location", + "tracking_mode" + ] + }, + "AnalyticsReportResponse": { + "oneOf": [ + { + "$ref": "#/components/schemas/AnalyticsSummary" + }, + { + "$ref": "#/components/schemas/TakenItemsReport" + }, + { + "$ref": "#/components/schemas/ActiveLoansReport" + }, + { + "$ref": "#/components/schemas/ReturnsReport" + }, + { + "$ref": "#/components/schemas/DamagedMissingReport" + }, + { + "$ref": "#/components/schemas/DamagedLostReport" + }, + { + "$ref": "#/components/schemas/QrScansReport" + }, + { + "$ref": "#/components/schemas/MostLentReport" + }, + { + "$ref": "#/components/schemas/TopBorrowersReport" + }, + { + "$ref": "#/components/schemas/RecentlyAddedReport" + }, + { + "$ref": "#/components/schemas/MachineUsageReport" + }, + { + "$ref": "#/components/schemas/EventAttendanceReport" + }, + { + "$ref": "#/components/schemas/BookingUtilizationReport" + }, + { + "$ref": "#/components/schemas/MaintenanceActivityReport" + }, + { + "$ref": "#/components/schemas/MemberActivityReport" + }, + { + "$ref": "#/components/schemas/FabLabHealthReport" + }, + { + "$ref": "#/components/schemas/PaymentReconciliationReport" + }, + { + "$ref": "#/components/schemas/GenericAnalyticsReport" + } + ] + }, + "AnalyticsSummary": { + "type": "object", + "properties": { + "products": { + "type": "integer" + }, + "assets": { + "type": "integer" + }, + "active_loans": { + "type": "integer" + }, + "available_quantity": { + "type": "integer" + }, + "issued_quantity": { + "type": "integer" + }, + "damaged_quantity": { + "type": "integer" + }, + "missing_quantity": { + "type": "integer" + } + }, + "required": [ + "active_loans", + "assets", + "available_quantity", + "damaged_quantity", + "issued_quantity", + "missing_quantity", + "products" + ] + }, + "ApiClient": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "readOnly": true + }, + "label": { + "type": "string", + "maxLength": 200 + }, + "client_id": { + "type": "string", + "readOnly": true + }, + "client_type": { + "$ref": "#/components/schemas/ClientTypeEnum" + }, + "scopes": { + "type": "array", + "items": { + "type": "string" + } + }, + "rate_limit_tier": { + "$ref": "#/components/schemas/RateLimitTierEnum" + }, + "makerspace": { + "type": "integer", + "readOnly": true, + "nullable": true + }, + "public_makerspace_code": { + "type": "string", + "readOnly": true + }, + "allowed_origins": { + "type": "array", + "items": { + "type": "string" + } + }, + "backend_base_url": { + "type": "string", + "readOnly": true + }, + "public_api_base_url": { + "type": "string", + "readOnly": true + }, + "is_active": { + "type": "boolean" + }, + "last_seen_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true + }, + "last_seen_ip": { + "type": "string", + "readOnly": true, + "nullable": true + }, + "created_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "updated_at": { + "type": "string", + "format": "date-time", + "readOnly": true + } + }, + "required": [ + "allowed_origins", + "backend_base_url", + "client_id", + "created_at", + "id", + "label", + "last_seen_at", + "last_seen_ip", + "makerspace", + "public_api_base_url", + "public_makerspace_code", + "scopes", + "updated_at" + ] + }, + "ApiClientCreateResponse": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "readOnly": true + }, + "label": { + "type": "string", + "maxLength": 200 + }, + "client_id": { + "type": "string", + "readOnly": true + }, + "client_secret": { + "type": "string", + "readOnly": true + }, + "client_type": { + "$ref": "#/components/schemas/ClientTypeEnum" + }, + "scopes": { + "type": "array", + "items": { + "type": "string" + } + }, + "rate_limit_tier": { + "$ref": "#/components/schemas/RateLimitTierEnum" + }, + "makerspace": { + "type": "integer", + "readOnly": true, + "nullable": true + }, + "public_makerspace_code": { + "type": "string", + "readOnly": true + }, + "allowed_origins": { + "type": "array", + "items": { + "type": "string" + } + }, + "backend_base_url": { + "type": "string", + "readOnly": true + }, + "public_api_base_url": { + "type": "string", + "readOnly": true + }, + "is_active": { + "type": "boolean" + }, + "last_seen_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true + }, + "last_seen_ip": { + "type": "string", + "readOnly": true, + "nullable": true + }, + "created_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "updated_at": { + "type": "string", + "format": "date-time", + "readOnly": true + } + }, + "required": [ + "allowed_origins", + "backend_base_url", + "client_id", + "client_secret", + "created_at", + "id", + "label", + "last_seen_at", + "last_seen_ip", + "makerspace", + "public_api_base_url", + "public_makerspace_code", + "scopes", + "updated_at" + ] + }, + "ApiClientScopeCatalogResponse": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "next": { + "type": "string", + "nullable": true + }, + "previous": { + "type": "string", + "nullable": true + }, + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ApiClientScopeOption" + } + } + }, + "required": [ + "count", + "results" + ] + }, + "ApiClientScopeOption": { + "type": "object", + "properties": { + "value": { + "type": "string" + }, + "label": { + "type": "string" + }, + "description": { + "type": "string" + }, + "group": { + "type": "string" + }, + "grantable": { + "type": "boolean" + }, + "lock_reason": { + "type": "string", + "nullable": true + } + }, + "required": [ + "description", + "grantable", + "group", + "label", + "lock_reason", + "value" + ] + }, + "ApiIntegrationSettings": { + "type": "object", + "properties": { + "public_code": { + "type": "string", + "readOnly": true + }, + "public_api_key": { + "type": "string", + "readOnly": true + }, + "cors_allowed_origins": { + "type": "array", + "items": { + "type": "string" + }, + "readOnly": true + }, + "telegram_group_chat_id": { + "type": "string", + "maxLength": 64 + }, + "telegram_bot_token": { + "type": "string", + "writeOnly": true + }, + "telegram_bot_token_set": { + "type": "boolean", + "readOnly": true + }, + "smtp_host": { + "type": "string", + "maxLength": 200 + }, + "smtp_port": { + "type": "integer", + "maximum": 2147483647, + "minimum": 0 + }, + "smtp_username": { + "type": "string", + "maxLength": 200 + }, + "smtp_password": { + "type": "string", + "writeOnly": true + }, + "smtp_password_set": { + "type": "boolean", + "readOnly": true + }, + "smtp_use_tls": { + "type": "boolean" + }, + "smtp_use_ssl": { + "type": "boolean" + }, + "smtp_from_email": { + "oneOf": [ + { + "type": "string", + "format": "email", + "maxLength": 254 + }, + { + "type": "string", + "maxLength": 0 + } + ] + }, + "slack_webhook_url": { + "type": "string", + "writeOnly": true, + "maxLength": 2048 + }, + "slack_webhook_url_set": { + "type": "boolean", + "readOnly": true + }, + "mattermost_webhook_url": { + "type": "string", + "writeOnly": true, + "maxLength": 2048 + }, + "mattermost_webhook_url_set": { + "type": "boolean", + "readOnly": true + }, + "discord_webhook_url": { + "type": "string", + "writeOnly": true, + "maxLength": 2048 + }, + "discord_webhook_url_set": { + "type": "boolean", + "readOnly": true + }, + "default_loan_days": { + "type": "integer", + "maximum": 2147483647, + "minimum": 0 + } + }, + "required": [ + "cors_allowed_origins", + "discord_webhook_url_set", + "mattermost_webhook_url_set", + "public_api_key", + "public_code", + "slack_webhook_url_set", + "smtp_password_set", + "telegram_bot_token_set" + ] + }, + "ApiKeyRequest": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "readOnly": true + }, + "makerspace": { + "type": "integer" + }, + "label": { + "type": "string", + "maxLength": 120 + }, + "reason": { + "type": "string" + }, + "allowed_origins": { + "type": "array", + "items": { + "type": "string" + } + }, + "status": { + "allOf": [ + { + "$ref": "#/components/schemas/StatusE94Enum" + } + ], + "readOnly": true + }, + "resolution_note": { + "type": "string", + "readOnly": true + }, + "created_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "resolved_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true + } + }, + "required": [ + "allowed_origins", + "created_at", + "id", + "label", + "makerspace", + "resolution_note", + "resolved_at", + "status" + ] + }, + "ApprovalModeEnum": { + "enum": [ + "instant", + "approve" + ], + "type": "string", + "description": "* `instant` - Instant confirmation\n* `approve` - Staff approval required" + }, + "ArchiveCustodyReadiness": { + "type": "object", + "properties": { + "below_floor_makerspaces": { + "type": "integer", + "minimum": 0 + }, + "zero_recipient_makerspaces": { + "type": "integer", + "minimum": 0 + }, + "undelivered_alarms": { + "type": "integer", + "minimum": 0 + }, + "alarms_with_no_operator_address": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "alarms_with_no_operator_address", + "below_floor_makerspaces", + "undelivered_alarms", + "zero_recipient_makerspaces" + ] + }, + "ArchiveCustodyStateEnum": { + "type": "string", + "enum": [ + "healthy", + "not_applicable", + "degraded_one_recipient", + "floor_breached_zero" + ] + }, + "ArchiveRecipient": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "readOnly": true + }, + "public_recipient": { + "type": "string", + "readOnly": true + }, + "fingerprint": { + "type": "string", + "readOnly": true + }, + "label": { + "type": "string", + "readOnly": true + }, + "added_by": { + "type": "integer", + "readOnly": true, + "nullable": true + }, + "added_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "revoked_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true + }, + "compromised_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true + }, + "verified_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true + }, + "challenge_issued_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true + } + }, + "required": [ + "added_at", + "added_by", + "challenge_issued_at", + "compromised_at", + "fingerprint", + "id", + "label", + "public_recipient", + "revoked_at", + "verified_at" + ] + }, + "ArchiveRecipientChallenge": { + "type": "object", + "properties": { + "recipient": { + "$ref": "#/components/schemas/ArchiveRecipient" + }, + "encrypted_challenge": { + "type": "string", + "description": "The binary age ciphertext encoded as unpadded base64url for JSON transport. Its decrypted plaintext is the 32-byte nonce encoded as canonical, unpadded base64url." + }, + "nonce_encoding": { + "type": "string", + "default": "base64url-unpadded", + "description": "The decrypted nonce uses canonical unpadded base64url." + } + }, + "required": [ + "encrypted_challenge", + "recipient" + ] + }, + "ArchiveRecipientCreate": { + "type": "object", + "properties": { + "public_recipient": { + "type": "string", + "maxLength": 200 + }, + "label": { + "type": "string", + "maxLength": 120 + } + }, + "required": [ + "label", + "public_recipient" + ] + }, + "ArchiveRecipientError": { + "type": "object", + "properties": { + "detail": { + "type": "string" + }, + "code": { + "type": "string" + } + }, + "required": [ + "code", + "detail" + ] + }, + "ArchiveRecipientVerify": { + "type": "object", + "properties": { + "nonce": { + "type": "string", + "description": "The decrypted 32-byte nonce in canonical, unpadded base64url form. Padding and non-canonical encodings are refused.", + "maxLength": 128 + } + }, + "required": [ + "nonce" + ] + }, + "ArchiveRequestError": { + "type": "object", + "properties": { + "detail": { + "type": "string" + }, + "code": { + "type": "string" + } + }, + "required": [ + "detail" + ] + }, + "ArchiveRequestValidationError": { + "type": "object", + "description": "DRF field-keyed errors, which are NOT the `detail`/`code` shape.\n\nA blank or overlong reason fails in `serializer.is_valid(raise_exception=True)` and comes\nback as `{\"reason\": [\"...\"]}`. Declaring every 400 as `ArchiveRequestError` published a\ncontract the endpoint does not honour, so a generated client would destructure `detail`\nand find nothing. Follows the `ProvisionSubdomainValidationErrorSerializer` precedent.", + "properties": { + "reason": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "ArchivedPaymentMakerspace": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "slug": { + "type": "string", + "pattern": "^[-a-zA-Z0-9_]+$" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "slug" + ] + }, + "ArchivedPaymentSummary": { + "type": "object", + "properties": { + "makerspace": { + "$ref": "#/components/schemas/ArchivedPaymentMakerspace" + }, + "pending_count": { + "type": "integer", + "minimum": 0 + }, + "total_count": { + "type": "integer", + "minimum": 1 + } + }, + "required": [ + "makerspace", + "pending_count", + "total_count" + ] + }, + "AssetChainGroup": { + "type": "object", + "properties": { + "asset_id": { + "type": "integer", + "nullable": true + }, + "asset_tag": { + "type": "string" + }, + "serial_number": { + "type": "string" + }, + "status": { + "type": "string" + }, + "events": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TimelineEvent" + } + } + }, + "required": [ + "asset_id", + "asset_tag", + "events", + "serial_number", + "status" + ] + }, + "AssetGenerate": { + "type": "object", + "properties": { + "count": { + "type": "integer", + "maximum": 200, + "minimum": 1 + }, + "name_prefix": { + "type": "string" + }, + "serial_numbers": { + "type": "array", + "items": { + "type": "string" + } + }, + "print_batch_id": { + "type": "integer", + "nullable": true + }, + "create_print_batch": { + "type": "boolean", + "default": false + } + }, + "required": [ + "count" + ] + }, + "AssetGenerateItem": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "asset_tag": { + "type": "string" + }, + "qr": { + "$ref": "#/components/schemas/QrCode" + } + }, + "required": [ + "asset_tag", + "id", + "qr" + ] + }, + "AssetGenerateResult": { + "type": "object", + "properties": { + "assets": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AssetGenerateItem" + } + }, + "print_batch_id": { + "type": "integer", + "nullable": true + } + }, + "required": [ + "assets", + "print_batch_id" + ] + }, + "AssetQrHistory": { + "type": "object", + "properties": { + "asset": { + "type": "integer" + }, + "scans": { + "type": "array", + "items": { + "$ref": "#/components/schemas/QrHistoryItem" + } + } + }, + "required": [ + "asset", + "scans" + ] + }, + "AssignBox": { + "type": "object", + "properties": { + "box_code": { + "type": "string" + } + }, + "required": [ + "box_code" + ] + }, + "AssignOperator": { + "type": "object", + "properties": { + "user_id": { + "type": "integer" + }, + "access_level": { + "type": "string", + "maxLength": 16 + } + }, + "required": [ + "access_level", + "user_id" + ] + }, + "AttendanceCorrectionResponse": { + "type": "object", + "properties": { + "registration_id": { + "type": "integer" + }, + "status": { + "type": "string" + }, + "revoked_certificates": { + "type": "integer" + } + }, + "required": [ + "registration_id", + "revoked_certificates", + "status" + ] + }, + "AttendedEvent": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "title": { + "type": "string" + }, + "starts_at": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "id", + "starts_at", + "title" + ] + }, + "AuditLog": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "readOnly": true + }, + "actor": { + "type": "integer", + "nullable": true + }, + "action": { + "type": "string", + "maxLength": 100 + }, + "makerspace": { + "type": "integer", + "nullable": true + }, + "target_type": { + "type": "string", + "maxLength": 200 + }, + "target_id": { + "type": "string", + "maxLength": 100 + }, + "meta": {}, + "created_at": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "action", + "id" + ] + }, + "AuthMembership": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "slug": { + "type": "string" + }, + "role": { + "type": "string", + "nullable": true + }, + "role_id": { + "type": "integer", + "nullable": true + }, + "role_name": { + "type": "string" + }, + "role_slug": { + "type": "string", + "nullable": true + }, + "source": { + "$ref": "#/components/schemas/AuthMembershipSourceEnum" + }, + "actions": { + "type": "array", + "items": { + "type": "string" + } + }, + "can_configure_machine_types": { + "type": "boolean" + }, + "is_machine_only": { + "type": "boolean" + }, + "can_refer": { + "type": "boolean" + }, + "can_verify": { + "type": "boolean" + }, + "verified_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "referrals_enabled": { + "type": "boolean" + } + }, + "required": [ + "actions", + "can_configure_machine_types", + "can_refer", + "can_verify", + "id", + "is_machine_only", + "referrals_enabled", + "role", + "role_id", + "role_name", + "role_slug", + "slug", + "source", + "verified_at" + ] + }, + "AuthMembershipSourceEnum": { + "enum": [ + "membership", + "organization" + ], + "type": "string", + "description": "* `membership` - membership\n* `organization` - organization" + }, + "AuthUserPayload": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "username": { + "type": "string" + }, + "email": { + "type": "string", + "format": "email" + }, + "display_name": { + "type": "string" + }, + "phone": { + "type": "string" + }, + "email_verified": { + "type": "boolean" + }, + "role": { + "type": "string" + }, + "is_superuser": { + "type": "boolean" + }, + "must_change_password": { + "type": "boolean" + }, + "makerspaces": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AuthMembership" + } + } + }, + "required": [ + "display_name", + "email", + "email_verified", + "id", + "is_superuser", + "makerspaces", + "must_change_password", + "phone", + "role", + "username" + ] + }, + "AvailabilityEnum": { + "type": "string", + "enum": [ + "Available", + "Limited", + "Full" + ] + }, + "BackupArchive": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "readOnly": true + }, + "scope": { + "allOf": [ + { + "$ref": "#/components/schemas/ScopeEnum" + } + ], + "readOnly": true + }, + "makerspace": { + "type": "integer", + "readOnly": true, + "nullable": true + }, + "status": { + "allOf": [ + { + "$ref": "#/components/schemas/BackupArchiveStatusEnum" + } + ], + "readOnly": true }, - "400": { - "description": "Invalid signature, payload, or configuration." + "manifest": { + "readOnly": true }, - "404": { - "description": "Makerspace was not found." - } - } - } - }, - "/api/v1/webhooks/stripe/{public_code}": { - "post": { - "operationId": "api_v1_webhooks_stripe_create", - "description": "Verifies the Stripe signature using request.body and the addressed makerspace's webhook secret before applying an idempotent event.", - "summary": "Receive a makerspace Stripe webhook", - "parameters": [ - { - "in": "path", - "name": "public_code", - "schema": { - "type": "string" - }, - "required": true - } - ], - "tags": [ - "Payments" - ], - "responses": { - "200": { - "description": "Verified event acknowledged." + "size_bytes": { + "type": "integer", + "readOnly": true }, - "400": { - "description": "Invalid signature, payload, or configuration." + "age_encrypted": { + "type": "boolean", + "readOnly": true }, - "404": { - "description": "Makerspace was not found." - } - } - } - }, - "/api/v1/webhooks/stripe/connect": { - "post": { - "operationId": "api_v1_webhooks_stripe_connect_create", - "summary": "Receive a platform Stripe Connect webhook", - "tags": [ - "Payments" - ], - "responses": { - "200": { - "description": "Verified event acknowledged." + "failure_detail": { + "type": "string", + "readOnly": true }, - "400": { - "description": "Invalid signature or configuration." + "started_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true }, - "404": { - "description": "Stripe Connect is dormant." - } - } - } - } - }, - "components": { - "schemas": { - "AcceptQuantity": { - "type": "object", - "properties": { - "item_id": { - "type": "integer" + "completed_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true }, - "quantity": { - "type": "integer", - "minimum": 0 + "expires_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true + }, + "created_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "purge_warning": { + "type": "string", + "readOnly": true } }, "required": [ - "item_id", - "quantity" + "age_encrypted", + "completed_at", + "created_at", + "expires_at", + "failure_detail", + "id", + "makerspace", + "manifest", + "purge_warning", + "scope", + "size_bytes", + "started_at", + "status" ] }, - "AcceptRequest": { - "type": "object", - "properties": { - "accepted_quantities": { - "type": "array", - "items": { - "$ref": "#/components/schemas/AcceptQuantity" - } - } - } - }, - "AccessLevelEnum": { + "BackupArchiveStatusEnum": { "enum": [ - "operate", - "manage", - "full" + "pending", + "running", + "promoting", + "available", + "failed", + "expired" ], "type": "string", - "description": "* `operate` - Operate\n* `manage` - Manage\n* `full` - Full" + "description": "* `pending` - Pending\n* `running` - Running\n* `promoting` - Promoting\n* `available` - Available\n* `failed` - Failed\n* `expired` - Expired" }, - "AccessStatusEnum": { - "enum": [ - "active", - "restricted", - "suspended" - ], - "type": "string", - "description": "* `active` - Active\n* `restricted` - Restricted\n* `suspended` - Suspended" + "BackupDownload": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + }, + "expires_at": { + "type": "string", + "format": "date-time" + }, + "purge_warning": { + "type": "string" + } + }, + "required": [ + "expires_at", + "purge_warning", + "url" + ] }, - "ActiveLoansReport": { + "BadgePdfRequest": { "type": "object", "properties": { - "rows": { + "registration_ids": { "type": "array", "items": { - "type": "array", - "items": {} - } + "type": "integer", + "minimum": 1 + }, + "maxItems": 200 }, - "typed_rows": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ActiveLoansReportRow" - } + "template_override": { + "allOf": [ + { + "$ref": "#/components/schemas/BadgeTemplate" + } + ], + "nullable": true + }, + "include_attended": { + "type": "boolean", + "default": false } }, "required": [ - "rows", - "typed_rows" + "registration_ids" ] }, - "ActiveLoansReportRow": { + "BadgeTemplate": { "type": "object", "properties": { - "makerspace_id": { + "version": { "type": "integer" }, - "id": { - "type": "integer" + "paper_size": { + "$ref": "#/components/schemas/PaperSizeEnum" }, - "requester": { - "type": "string" + "orientation": { + "$ref": "#/components/schemas/OrientationEnum" }, - "status": { - "type": "string" + "page_width_mm": { + "type": "number", + "format": "double", + "nullable": true }, - "issued_at": { - "type": "string", - "format": "date-time", + "page_height_mm": { + "type": "number", + "format": "double", "nullable": true + }, + "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" + }, + "template": { + "type": "string" + }, + "fields": { + "type": "array", + "items": { + "type": "string", + "maxLength": 80 + } + }, + "font_size_pt": { + "type": "number", + "format": "double" + }, + "name_font_size_pt": { + "type": "number", + "format": "double" + }, + "text_align": { + "$ref": "#/components/schemas/TextAlignEnum" + }, + "include_qr": { + "type": "boolean" } - }, - "required": [ - "id", - "issued_at", - "requester", - "status" + } + }, + "BlankEnum": { + "enum": [ + "" ] }, - "AdminMembership": { + "BookableSpaceAdmin": { "type": "object", "properties": { "id": { "type": "integer", "readOnly": true }, - "status": { - "$ref": "#/components/schemas/Status37fEnum" + "public_token": { + "type": "string", + "format": "uuid", + "readOnly": true }, - "user": { + "makerspace_id": { + "type": "integer", + "readOnly": true + }, + "name": { + "type": "string", + "readOnly": true + }, + "kind": { "allOf": [ { - "$ref": "#/components/schemas/AdminMembershipUser" + "$ref": "#/components/schemas/Kind3bfEnum" } ], "readOnly": true }, - "assigned_role": { + "description": { + "type": "string", + "readOnly": true + }, + "capacity": { + "type": "integer", + "readOnly": true + }, + "location": { + "type": "string", + "readOnly": true + }, + "image_url": { + "type": "string", + "readOnly": true + }, + "is_public": { + "type": "boolean", + "readOnly": true + }, + "show_public_availability": { + "type": "boolean", + "readOnly": true + }, + "show_public_booker_names": { + "type": "boolean", + "readOnly": true + }, + "approval_mode": { "allOf": [ { - "$ref": "#/components/schemas/AdminMembershipRole" + "$ref": "#/components/schemas/ApprovalModeEnum" } ], - "nullable": true, "readOnly": true }, - "can_refer": { - "type": "boolean" + "min_booking_duration_minutes": { + "type": "integer", + "readOnly": true }, - "can_verify": { - "type": "boolean" + "max_booking_duration_minutes": { + "type": "integer", + "readOnly": true }, - "verified_at": { - "type": "string", - "format": "date-time", - "nullable": true + "booking_lead_time_minutes": { + "type": "integer", + "readOnly": true }, - "activated_at": { - "type": "string", - "format": "date-time", - "nullable": true + "max_booking_advance_days": { + "type": "integer", + "readOnly": true }, - "revoked_at": { - "type": "string", - "format": "date-time", + "custom_form": { + "readOnly": true, "nullable": true }, - "revocation_reason": { - "type": "string" - }, - "waiver_accepted_at": { - "type": "string", - "format": "date-time", + "requester_notifications_enabled": { + "type": "boolean", + "readOnly": true, "nullable": true }, - "waiver_version_accepted": { + "payment_amount": { "type": "string", - "nullable": true, - "maxLength": 64 + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", + "readOnly": true }, - "waiver_current": { + "effective_requester_notifications_enabled": { "type": "boolean", "readOnly": true }, - "waiver_required": { + "is_active": { "type": "boolean", "readOnly": true }, - "payment": { - "allOf": [ - { - "$ref": "#/components/schemas/StaffPaymentSummary" - } - ], - "nullable": true, + "created_by_id": { + "type": "integer", + "readOnly": true, + "nullable": true + }, + "created_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "updated_at": { + "type": "string", + "format": "date-time", "readOnly": true } }, "required": [ - "assigned_role", + "approval_mode", + "booking_lead_time_minutes", + "capacity", + "created_at", + "created_by_id", + "custom_form", + "description", + "effective_requester_notifications_enabled", "id", - "payment", - "user", - "waiver_current", - "waiver_required" + "image_url", + "is_active", + "is_public", + "kind", + "location", + "makerspace_id", + "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" ] }, - "AdminMembershipRole": { + "BookableSpaceBookingRules": { "type": "object", "properties": { - "id": { + "min_booking_duration_minutes": { + "type": "integer", + "maximum": 2147483647, + "minimum": 1 + }, + "max_booking_duration_minutes": { + "type": "integer", + "maximum": 2147483647, + "minimum": 1 + }, + "booking_lead_time_minutes": { + "type": "integer", + "maximum": 2147483647, + "minimum": 0 + }, + "max_booking_advance_days": { + "type": "integer", + "maximum": 2147483647, + "minimum": 1 + }, + "approval_mode": { + "$ref": "#/components/schemas/ApprovalModeEnum" + } + } + }, + "BookableSpaceListResponse": { + "type": "object", + "properties": { + "count": { "type": "integer" }, - "name": { - "type": "string" + "next": { + "type": "string", + "nullable": true }, - "slug": { - "type": "string" + "previous": { + "type": "string", + "nullable": true + }, + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BookableSpaceAdmin" + } } }, "required": [ - "id", - "name", - "slug" + "count", + "results" ] }, - "AdminMembershipUser": { + "BookableSpaceWrite": { "type": "object", "properties": { - "id": { - "type": "integer" + "name": { + "type": "string", + "maxLength": 200 }, - "username": { - "type": "string" + "kind": { + "allOf": [ + { + "$ref": "#/components/schemas/Kind3bfEnum" + } + ], + "default": "other" }, - "email": { + "description": { "type": "string", - "format": "email" + "default": "" }, - "display_name": { - "type": "string" + "capacity": { + "type": "integer", + "minimum": 0, + "default": 0 + }, + "location": { + "type": "string", + "default": "", + "maxLength": 255 + }, + "is_public": { + "type": "boolean", + "default": false + }, + "show_public_availability": { + "type": "boolean", + "default": false + }, + "show_public_booker_names": { + "type": "boolean", + "default": false + }, + "custom_form": { + "nullable": true + }, + "requester_notifications_enabled": { + "type": "boolean", + "nullable": true + }, + "payment_amount": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", + "default": "0.00" } }, "required": [ - "display_name", - "email", - "id", - "username" + "name" ] }, - "AdminRequest": { + "BookingAdmin": { "type": "object", "properties": { "id": { "type": "integer", "readOnly": true }, - "makerspace_id": { - "type": "integer", - "readOnly": true - }, - "requester_username": { - "type": "string", - "readOnly": true - }, - "requester_name": { + "public_token": { "type": "string", + "format": "uuid", "readOnly": true }, - "requester_display": { - "type": "string", + "space_id": { + "type": "integer", "readOnly": true }, - "requester_contact_email": { + "name": { "type": "string", - "format": "email", "readOnly": true }, - "requester_contact_phone": { + "email": { "type": "string", "readOnly": true }, - "requester_contact_verified": { - "type": "boolean", - "readOnly": true - }, - "status": { + "phone": { "type": "string", "readOnly": true }, - "requested_for": { + "starts_at": { "type": "string", + "format": "date-time", "readOnly": true }, - "rejection_reason": { + "ends_at": { "type": "string", + "format": "date-time", "readOnly": true }, - "assigned_box_label": { - "type": "string", - "readOnly": true, - "nullable": true - }, - "accepted_by": { - "allOf": [ - { - "$ref": "#/components/schemas/AdminRequestActor" - } - ], - "readOnly": true, - "nullable": true - }, - "issued_by": { + "status": { "allOf": [ { - "$ref": "#/components/schemas/AdminRequestActor" + "$ref": "#/components/schemas/BookingAdminStatusEnum" } ], - "readOnly": true, - "nullable": true - }, - "accepted_at": { - "type": "string", - "format": "date-time", - "readOnly": true - }, - "issued_at": { - "type": "string", - "format": "date-time", - "readOnly": true - }, - "return_due_at": { - "type": "string", - "format": "date-time", "readOnly": true }, - "return_reminder_sent_at": { + "note": { "type": "string", - "format": "date-time", "readOnly": true }, - "closed_at": { - "type": "string", - "format": "date-time", - "readOnly": true + "custom_answers": { + "readOnly": true, + "nullable": true }, "created_at": { "type": "string", "format": "date-time", "readOnly": true }, - "updated_at": { - "type": "string", - "format": "date-time", - "readOnly": true - }, - "issue_evidence_id": { - "type": "integer", - "readOnly": true, - "nullable": true - }, - "return_evidence_ids": { - "type": "array", - "items": {}, - "readOnly": true - }, - "items": { - "type": "array", - "items": { - "$ref": "#/components/schemas/AdminRequestItem" - }, + "payment": { + "allOf": [ + { + "$ref": "#/components/schemas/StaffPaymentSummary" + } + ], + "nullable": true, "readOnly": true } }, "required": [ - "accepted_at", - "accepted_by", - "assigned_box_label", - "closed_at", "created_at", + "custom_answers", + "email", + "ends_at", "id", - "issue_evidence_id", - "issued_at", - "issued_by", - "items", - "makerspace_id", - "rejection_reason", - "requested_for", - "requester_contact_email", - "requester_contact_phone", - "requester_contact_verified", - "requester_display", - "requester_name", - "requester_username", - "return_due_at", - "return_evidence_ids", - "return_reminder_sent_at", - "status", - "updated_at" + "name", + "note", + "payment", + "phone", + "public_token", + "space_id", + "starts_at", + "status" ] }, - "AdminRequestActor": { + "BookingAdminStatusEnum": { + "enum": [ + "pending", + "confirmed", + "rejected", + "cancelled", + "completed", + "no_show" + ], + "type": "string", + "description": "* `pending` - Pending\n* `confirmed` - Confirmed\n* `rejected` - Rejected\n* `cancelled` - Cancelled\n* `completed` - Completed\n* `no_show` - No-show" + }, + "BookingListResponse": { "type": "object", "properties": { - "username": { + "count": { + "type": "integer" + }, + "next": { "type": "string", - "readOnly": true + "nullable": true }, - "role": { + "previous": { "type": "string", - "readOnly": true + "nullable": true + }, + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BookingAdmin" + } } }, "required": [ - "role", - "username" + "count", + "results" ] }, - "AdminRequestItem": { + "BookingUtilizationReport": { "type": "object", "properties": { - "id": { - "type": "integer", - "readOnly": true - }, - "product_id": { - "type": "integer", - "readOnly": true - }, - "product_name": { - "type": "string", - "readOnly": true - }, - "storage_location": { - "type": "string", - "readOnly": true - }, - "tracking_mode": { - "type": "string", - "readOnly": true - }, - "requires_asset_qr": { - "type": "boolean", - "readOnly": true - }, - "requested_quantity": { - "type": "integer", - "readOnly": true - }, - "accepted_quantity": { - "type": "integer", - "readOnly": true - }, - "issued_quantity": { - "type": "integer", - "readOnly": true - }, - "returned_quantity": { - "type": "integer", - "readOnly": true - }, - "damaged_quantity": { - "type": "integer", - "readOnly": true - }, - "missing_quantity": { - "type": "integer", - "readOnly": true - }, - "needs_fix_quantity": { - "type": "integer", - "readOnly": true + "rows": { + "type": "array", + "items": { + "type": "array", + "items": {} + } }, - "issued_assets": { + "typed_rows": { "type": "array", - "items": {}, - "readOnly": true + "items": { + "$ref": "#/components/schemas/BookingUtilizationRow" + } } }, "required": [ - "accepted_quantity", - "damaged_quantity", - "id", - "issued_assets", - "issued_quantity", - "missing_quantity", - "needs_fix_quantity", - "product_id", - "product_name", - "requested_quantity", - "requires_asset_qr", - "returned_quantity", - "storage_location", - "tracking_mode" + "rows", + "typed_rows" ] }, - "AnalyticsReportResponse": { - "oneOf": [ - { - "$ref": "#/components/schemas/AnalyticsSummary" - }, - { - "$ref": "#/components/schemas/TakenItemsReport" - }, - { - "$ref": "#/components/schemas/ActiveLoansReport" - }, - { - "$ref": "#/components/schemas/ReturnsReport" - }, - { - "$ref": "#/components/schemas/DamagedMissingReport" - }, - { - "$ref": "#/components/schemas/DamagedLostReport" - }, - { - "$ref": "#/components/schemas/QrScansReport" - }, - { - "$ref": "#/components/schemas/MostLentReport" - }, - { - "$ref": "#/components/schemas/TopBorrowersReport" - }, - { - "$ref": "#/components/schemas/RecentlyAddedReport" - }, - { - "$ref": "#/components/schemas/MachineUsageReport" - }, - { - "$ref": "#/components/schemas/EventAttendanceReport" + "BookingUtilizationRow": { + "type": "object", + "properties": { + "makerspace_id": { + "type": "integer" }, - { - "$ref": "#/components/schemas/BookingUtilizationReport" + "space_id": { + "type": "integer" }, - { - "$ref": "#/components/schemas/MaintenanceActivityReport" + "space_name": { + "type": "string" }, - { - "$ref": "#/components/schemas/MemberActivityReport" + "kind": { + "type": "string" }, - { - "$ref": "#/components/schemas/FabLabHealthReport" + "is_active": { + "type": "boolean" }, - { - "$ref": "#/components/schemas/PaymentReconciliationReport" - } - ] - }, - "AnalyticsSummary": { - "type": "object", - "properties": { - "products": { + "booked": { "type": "integer" }, - "assets": { + "completed": { "type": "integer" }, - "active_loans": { + "no_show": { "type": "integer" }, - "available_quantity": { + "cancelled": { "type": "integer" }, - "issued_quantity": { + "upcoming": { "type": "integer" }, - "damaged_quantity": { - "type": "integer" + "reserved_hours": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,18}(?:\\.\\d{0,2})?$" }, - "missing_quantity": { - "type": "integer" + "completed_hours": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,18}(?:\\.\\d{0,2})?$" + }, + "window_hours": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,18}(?:\\.\\d{0,2})?$", + "nullable": true + }, + "reservation_utilization_percent": { + "type": "number", + "format": "double", + "nullable": true + }, + "no_show_rate_percent": { + "type": "number", + "format": "double", + "nullable": true } }, "required": [ - "active_loans", - "assets", - "available_quantity", - "damaged_quantity", - "issued_quantity", - "missing_quantity", - "products" + "booked", + "cancelled", + "completed", + "completed_hours", + "is_active", + "kind", + "no_show", + "no_show_rate_percent", + "reservation_utilization_percent", + "reserved_hours", + "space_id", + "space_name", + "upcoming", + "window_hours" ] }, - "ApiClient": { + "Box": { "type": "object", "properties": { "id": { "type": "integer", "readOnly": true }, - "label": { - "type": "string", - "maxLength": 200 - }, - "client_id": { - "type": "string", - "readOnly": true - }, - "client_type": { - "$ref": "#/components/schemas/ClientTypeEnum" - }, - "scopes": { - "type": "array", - "items": { - "type": "string" - } - }, - "rate_limit_tier": { - "$ref": "#/components/schemas/RateLimitTierEnum" - }, "makerspace": { + "type": "integer" + }, + "parent": { "type": "integer", - "readOnly": true, "nullable": true }, - "public_makerspace_code": { + "code": { "type": "string", "readOnly": true }, - "allowed_origins": { - "type": "array", - "items": { - "type": "string" - } - }, - "backend_base_url": { + "label": { "type": "string", - "readOnly": true + "maxLength": 200 }, - "public_api_base_url": { + "location": { "type": "string", - "readOnly": true + "maxLength": 200 + }, + "description": { + "type": "string" }, "is_active": { "type": "boolean" }, - "last_seen_at": { - "type": "string", - "format": "date-time", - "readOnly": true, - "nullable": true - }, - "last_seen_ip": { - "type": "string", - "readOnly": true, - "nullable": true + "qr_code_id": { + "type": "integer", + "nullable": true, + "readOnly": true }, "created_at": { "type": "string", @@ -35073,88 +43032,68 @@ } }, "required": [ - "allowed_origins", - "backend_base_url", - "client_id", + "code", "created_at", "id", "label", - "last_seen_at", - "last_seen_ip", "makerspace", - "public_api_base_url", - "public_makerspace_code", - "scopes", + "qr_code_id", "updated_at" ] }, - "ApiClientCreateResponse": { + "BulkImportJob": { "type": "object", "properties": { "id": { "type": "integer", "readOnly": true }, - "label": { - "type": "string", - "maxLength": 200 - }, - "client_id": { - "type": "string", + "mode": { + "allOf": [ + { + "$ref": "#/components/schemas/Mode087Enum" + } + ], "readOnly": true }, - "client_secret": { - "type": "string", + "status": { + "allOf": [ + { + "$ref": "#/components/schemas/BulkImportJobStatusEnum" + } + ], "readOnly": true }, - "client_type": { - "$ref": "#/components/schemas/ClientTypeEnum" - }, - "scopes": { - "type": "array", - "items": { - "type": "string" - } - }, - "rate_limit_tier": { - "$ref": "#/components/schemas/RateLimitTierEnum" + "total_rows": { + "type": "integer", + "readOnly": true }, - "makerspace": { + "processed_rows": { "type": "integer", - "readOnly": true, - "nullable": true + "readOnly": true }, - "public_makerspace_code": { - "type": "string", + "created_count": { + "type": "integer", "readOnly": true }, - "allowed_origins": { - "type": "array", - "items": { - "type": "string" - } + "updated_count": { + "type": "integer", + "readOnly": true }, - "backend_base_url": { - "type": "string", + "error_count": { + "type": "integer", "readOnly": true }, - "public_api_base_url": { - "type": "string", + "warning_count": { + "type": "integer", "readOnly": true }, - "is_active": { - "type": "boolean" - }, - "last_seen_at": { - "type": "string", - "format": "date-time", - "readOnly": true, - "nullable": true + "result": { + "readOnly": true }, - "last_seen_ip": { + "error": { "type": "string", - "readOnly": true, - "nullable": true + "readOnly": true }, "created_at": { "type": "string", @@ -35165,52 +43104,86 @@ "type": "string", "format": "date-time", "readOnly": true + }, + "completed_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true } }, "required": [ - "allowed_origins", - "backend_base_url", - "client_id", - "client_secret", + "completed_at", "created_at", + "created_count", + "error", + "error_count", "id", - "label", - "last_seen_at", - "last_seen_ip", - "makerspace", - "public_api_base_url", - "public_makerspace_code", - "scopes", - "updated_at" + "mode", + "processed_rows", + "result", + "status", + "total_rows", + "updated_at", + "updated_count", + "warning_count" ] }, - "ApiClientScopeCatalogResponse": { + "BulkImportJobCreate": { "type": "object", "properties": { - "count": { - "type": "integer" - }, - "next": { - "type": "string", - "nullable": true + "mode": { + "$ref": "#/components/schemas/Mode087Enum" }, - "previous": { + "file": { "type": "string", + "format": "uri", "nullable": true }, - "results": { + "rows": { "type": "array", "items": { - "$ref": "#/components/schemas/ApiClientScopeOption" - } - } + "type": "object", + "additionalProperties": {} + }, + "maxItems": 5000 + }, + "mapping": {} }, "required": [ - "count", - "results" + "mode" ] }, - "ApiClientScopeOption": { + "BulkImportJobStatusEnum": { + "enum": [ + "pending", + "running", + "completed", + "failed" + ], + "type": "string", + "description": "* `pending` - Pending\n* `running` - Running\n* `completed` - Completed\n* `failed` - Failed" + }, + "BulkImportPreview": { + "type": "object", + "properties": { + "file": { + "type": "string", + "format": "uri", + "nullable": true + }, + "rows": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": {} + }, + "maxItems": 5000 + }, + "mapping": {} + } + }, + "Capability": { "type": "object", "properties": { "value": { @@ -35242,763 +43215,1001 @@ "value" ] }, - "ApiIntegrationSettings": { + "CategoryAdmin": { "type": "object", "properties": { - "public_code": { - "type": "string", - "readOnly": true - }, - "public_api_key": { - "type": "string", + "id": { + "type": "integer", "readOnly": true }, - "cors_allowed_origins": { - "type": "array", - "items": { - "type": "string" - }, + "makerspace": { + "type": "integer", "readOnly": true }, - "telegram_group_chat_id": { - "type": "string", - "maxLength": 64 - }, - "telegram_bot_token": { + "name": { "type": "string", - "writeOnly": true - }, - "telegram_bot_token_set": { - "type": "boolean", - "readOnly": true + "maxLength": 100 }, - "smtp_host": { - "type": "string", - "maxLength": 200 + "slug": { + "oneOf": [ + { + "type": "string", + "pattern": "^[-a-zA-Z0-9_]+$" + }, + { + "type": "string", + "maxLength": 0 + } + ] }, - "smtp_port": { + "display_order": { "type": "integer", "maximum": 2147483647, "minimum": 0 }, - "smtp_username": { + "icon": { "type": "string", - "maxLength": 200 + "maxLength": 50 }, - "smtp_password": { - "type": "string", - "writeOnly": true + "product_count": { + "type": "integer", + "readOnly": true, + "default": 0 }, - "smtp_password_set": { - "type": "boolean", + "created_at": { + "type": "string", + "format": "date-time", "readOnly": true }, - "smtp_use_tls": { - "type": "boolean" + "updated_at": { + "type": "string", + "format": "date-time", + "readOnly": true + } + }, + "required": [ + "created_at", + "id", + "makerspace", + "name", + "product_count", + "updated_at" + ] + }, + "CertificateDownload": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" }, - "smtp_use_ssl": { - "type": "boolean" + "expires_at": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "expires_at", + "url" + ] + }, + "CertificateRevoke": { + "type": "object", + "properties": { + "reason": { + "$ref": "#/components/schemas/ReasonEnum" + } + }, + "required": [ + "reason" + ] + }, + "CertificateSummary": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "readOnly": true }, - "smtp_from_email": { - "oneOf": [ - { - "type": "string", - "format": "email", - "maxLength": 254 - }, + "status": { + "allOf": [ { - "type": "string", - "maxLength": 0 + "$ref": "#/components/schemas/CertificateSummaryStatusEnum" } - ] + ], + "readOnly": true }, - "slack_webhook_url": { - "type": "string", - "writeOnly": true, - "maxLength": 2048 + "revision": { + "type": "integer", + "readOnly": true }, - "slack_webhook_url_set": { - "type": "boolean", + "issued_at": { + "type": "string", + "format": "date-time", "readOnly": true }, - "mattermost_webhook_url": { + "rendered_at": { "type": "string", - "writeOnly": true, - "maxLength": 2048 + "format": "date-time", + "readOnly": true, + "nullable": true }, - "mattermost_webhook_url_set": { - "type": "boolean", - "readOnly": true + "revoked_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true + } + }, + "required": [ + "id", + "issued_at", + "rendered_at", + "revision", + "revoked_at", + "status" + ] + }, + "CertificateSummaryStatusEnum": { + "enum": [ + "pending", + "rendering", + "active", + "failed", + "revoked" + ], + "type": "string", + "description": "* `pending` - Pending\n* `rendering` - Rendering\n* `active` - Active\n* `failed` - Failed\n* `revoked` - Revoked" + }, + "ChangePassword": { + "type": "object", + "properties": { + "current_password": { + "type": "string", + "writeOnly": true }, - "discord_webhook_url": { + "new_password": { + "type": "string", + "writeOnly": true + } + }, + "required": [ + "current_password", + "new_password" + ] + }, + "ChangePasswordResponse": { + "type": "object", + "properties": { + "detail": { + "type": "string" + } + }, + "required": [ + "detail" + ] + }, + "Channel7a7Enum": { + "enum": [ + "telegram", + "slack", + "mattermost", + "discord" + ], + "type": "string", + "description": "* `telegram` - Telegram\n* `slack` - Slack\n* `mattermost` - Mattermost\n* `discord` - Discord" + }, + "ChannelCbbEnum": { + "enum": [ + "email", + "telegram", + "slack", + "mattermost", + "discord", + "native_push" + ], + "type": "string", + "description": "* `email` - Email\n* `telegram` - Telegram\n* `slack` - Slack\n* `mattermost` - Mattermost\n* `discord` - Discord\n* `native_push` - Native push" + }, + "CheckoutUrl": { + "type": "object", + "properties": { + "checkout_url": { + "type": "string", + "format": "uri" + } + }, + "required": [ + "checkout_url" + ] + }, + "ClaimRedemption": { + "type": "object", + "properties": { + "code": { "type": "string", "writeOnly": true, - "maxLength": 2048 + "maxLength": 64 }, - "discord_webhook_url_set": { - "type": "boolean", - "readOnly": true + "makerspace_slug": { + "type": "string", + "writeOnly": true, + "maxLength": 80, + "pattern": "^[-a-zA-Z0-9_]+$" + } + }, + "required": [ + "code", + "makerspace_slug" + ] + }, + "ClaimRedemptionResponse": { + "type": "object", + "properties": { + "user": { + "$ref": "#/components/schemas/AuthUserPayload" }, - "default_loan_days": { - "type": "integer", - "maximum": 2147483647, - "minimum": 0 + "access": { + "type": "string" } }, "required": [ - "cors_allowed_origins", - "discord_webhook_url_set", - "mattermost_webhook_url_set", - "public_api_key", - "public_code", - "slack_webhook_url_set", - "smtp_password_set", - "telegram_bot_token_set" + "access", + "user" ] }, - "ApiKeyRequest": { + "ClaimableInvitation": { "type": "object", "properties": { "id": { - "type": "integer", - "readOnly": true + "type": "integer" }, "makerspace": { - "type": "integer" + "type": "object", + "additionalProperties": {} }, - "label": { + "inviter": { "type": "string", - "maxLength": 120 + "nullable": true }, - "reason": { - "type": "string" + "auto_activates": { + "type": "boolean" }, - "allowed_origins": { - "type": "array", - "items": { - "type": "string" - } + "role": { + "type": "string", + "nullable": true + } + }, + "required": [ + "auto_activates", + "id", + "inviter", + "makerspace", + "role" + ] + }, + "ClientPlatformEnum": { + "enum": [ + "web", + "ios", + "android" + ], + "type": "string", + "description": "* `web` - Web\n* `ios` - iOS\n* `android` - Android" + }, + "ClientTypeEnum": { + "enum": [ + "browser", + "server" + ], + "type": "string", + "description": "* `browser` - Browser\n* `server` - Server" + }, + "ClosureApproval": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "readOnly": true }, - "status": { - "allOf": [ - { - "$ref": "#/components/schemas/StatusE94Enum" - } - ], + "closure_digest": { + "type": "string", + "maxLength": 64 + }, + "identity_count": { + "type": "string", "readOnly": true }, - "resolution_note": { + "approved_count": { "type": "string", "readOnly": true }, - "created_at": { + "approved_at": { "type": "string", "format": "date-time", "readOnly": true }, - "resolved_at": { + "revoked_at": { "type": "string", "format": "date-time", - "readOnly": true, "nullable": true } }, "required": [ - "allowed_origins", - "created_at", + "approved_at", + "approved_count", + "closure_digest", "id", - "label", - "makerspace", - "resolution_note", - "resolved_at", - "status" + "identity_count" ] }, - "ApprovalModeEnum": { - "enum": [ - "instant", - "approve" - ], - "type": "string", - "description": "* `instant` - Instant confirmation\n* `approve` - Staff approval required" + "ClosureApprovalCreate": { + "type": "object", + "properties": { + "digest": { + "type": "string", + "maxLength": 64, + "minLength": 64 + }, + "decisions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/IdentityDisclosureDecision" + } + } + }, + "required": [ + "decisions", + "digest" + ] }, - "ArchiveCustodyReadiness": { + "ClosureIdentity": { "type": "object", "properties": { - "below_floor_makerspaces": { - "type": "integer", - "minimum": 0 + "id": { + "type": "integer" + }, + "username": { + "type": "string" + }, + "email": { + "oneOf": [ + { + "type": "string", + "format": "email" + }, + { + "type": "string", + "maxLength": 0 + } + ] }, - "zero_recipient_makerspaces": { - "type": "integer", - "minimum": 0 + "first_name": { + "type": "string" }, - "undelivered_alarms": { - "type": "integer", - "minimum": 0 + "last_name": { + "type": "string" }, - "alarms_with_no_operator_address": { - "type": "integer", - "minimum": 0 + "display_name": { + "type": "string" + }, + "phone": { + "type": "string" + }, + "date_joined": { + "type": "string", + "format": "date-time" } }, "required": [ - "alarms_with_no_operator_address", - "below_floor_makerspaces", - "undelivered_alarms", - "zero_recipient_makerspaces" - ] - }, - "ArchiveCustodyStateEnum": { - "type": "string", - "enum": [ - "healthy", - "not_applicable", - "degraded_one_recipient", - "floor_breached_zero" + "date_joined", + "display_name", + "email", + "first_name", + "id", + "last_name", + "phone", + "username" ] }, - "ArchiveRecipient": { + "CollaborativeEvent": { "type": "object", "properties": { "id": { "type": "integer", "readOnly": true }, - "public_recipient": { + "title": { "type": "string", "readOnly": true }, - "fingerprint": { + "description": { "type": "string", "readOnly": true }, - "label": { + "starts_at": { "type": "string", + "format": "date-time", "readOnly": true }, - "added_by": { - "type": "integer", - "readOnly": true, - "nullable": true - }, - "added_at": { + "ends_at": { "type": "string", "format": "date-time", "readOnly": true }, - "revoked_at": { + "location": { "type": "string", - "format": "date-time", + "readOnly": true + }, + "location_kind": { + "allOf": [ + { + "$ref": "#/components/schemas/LocationKindEnum" + } + ], + "readOnly": true + }, + "custom_form": { "readOnly": true, "nullable": true }, - "compromised_at": { + "capacity": { + "type": "integer", + "minimum": 0, + "readOnly": true + }, + "availability": { + "allOf": [ + { + "$ref": "#/components/schemas/AvailabilityEnum" + } + ], + "readOnly": true + }, + "registration_requires_approval": { + "type": "boolean", + "readOnly": true + }, + "effective_registration_cutoff_at": { "type": "string", "format": "date-time", - "readOnly": true, - "nullable": true + "nullable": true, + "readOnly": true }, - "verified_at": { + "registration_open": { + "type": "boolean", + "readOnly": true + }, + "image_url": { "type": "string", - "format": "date-time", - "readOnly": true, - "nullable": true + "format": "uri", + "nullable": true, + "readOnly": true }, - "challenge_issued_at": { + "host_name": { + "type": "string", + "readOnly": true + }, + "host_slug": { "type": "string", - "format": "date-time", "readOnly": true, - "nullable": true + "pattern": "^[-a-zA-Z0-9_]+$" + }, + "host_waiver": { + "allOf": [ + { + "$ref": "#/components/schemas/HostWaiver" + } + ], + "nullable": true, + "readOnly": true + }, + "organizers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EventOrganizerSummary" + }, + "readOnly": true + }, + "series": { + "type": "object", + "nullable": true, + "readOnly": true } }, "required": [ - "added_at", - "added_by", - "challenge_issued_at", - "compromised_at", - "fingerprint", + "availability", + "capacity", + "custom_form", + "description", + "effective_registration_cutoff_at", + "ends_at", + "host_name", + "host_slug", + "host_waiver", "id", - "label", - "public_recipient", - "revoked_at", - "verified_at" + "image_url", + "location", + "location_kind", + "organizers", + "registration_open", + "registration_requires_approval", + "series", + "starts_at", + "title" ] }, - "ArchiveRecipientChallenge": { + "CollaborativeEventRegistrationInput": { "type": "object", "properties": { - "recipient": { - "$ref": "#/components/schemas/ArchiveRecipient" + "custom_answers": { + "writeOnly": true, + "nullable": true }, - "encrypted_challenge": { - "type": "string", - "description": "The binary age ciphertext encoded as unpadded base64url for JSON transport. Its decrypted plaintext is the 32-byte nonce encoded as canonical, unpadded base64url." + "host_waiver_id": { + "type": "integer", + "minimum": 1, + "nullable": true }, - "nonce_encoding": { + "host_waiver_version": { "type": "string", - "default": "base64url-unpadded", - "description": "The decrypted nonce uses canonical unpadded base64url." + "nullable": true, + "maxLength": 64 + }, + "host_waiver_accepted": { + "type": "boolean", + "default": false } - }, - "required": [ - "encrypted_challenge", - "recipient" - ] + } }, - "ArchiveRecipientCreate": { + "ConditionEnum": { + "enum": [ + "available", + "damaged", + "lost", + "unknown" + ], + "type": "string", + "description": "* `available` - Available\n* `damaged` - Damaged\n* `lost` - Lost\n* `unknown` - Unknown" + }, + "ConnectStatusEnum": { + "enum": [ + "unconnected", + "pending", + "active", + "restricted", + "disconnected" + ], + "type": "string", + "description": "* `unconnected` - Unconnected\n* `pending` - Pending\n* `active` - Active\n* `restricted` - Restricted\n* `disconnected` - Disconnected" + }, + "ConsumableCandidate": { "type": "object", "properties": { - "public_recipient": { - "type": "string", - "maxLength": 200 + "id": { + "type": "integer", + "readOnly": true }, - "label": { + "name": { "type": "string", - "maxLength": 120 + "readOnly": true + }, + "available": { + "type": "integer", + "readOnly": true } }, "required": [ - "label", - "public_recipient" + "available", + "id", + "name" ] }, - "ArchiveRecipientError": { + "ContainerAssetSummary": { "type": "object", "properties": { - "detail": { + "id": { + "type": "integer" + }, + "asset_tag": { "type": "string" }, - "code": { + "product": { + "type": "string" + }, + "status": { "type": "string" } }, "required": [ - "code", - "detail" + "asset_tag", + "id", + "product", + "status" ] }, - "ArchiveRecipientVerify": { + "ContainerContents": { "type": "object", "properties": { - "nonce": { - "type": "string", - "description": "The decrypted 32-byte nonce in canonical, unpadded base64url form. Padding and non-canonical encodings are refused.", - "maxLength": 128 + "container": { + "$ref": "#/components/schemas/Box" + }, + "products": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ContainerProductSummary" + } + }, + "assets": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ContainerAssetSummary" + } + }, + "children": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Box" + } } }, "required": [ - "nonce" + "assets", + "children", + "container", + "products" ] }, - "ArchiveRequestError": { + "ContainerHistory": { "type": "object", "properties": { - "detail": { - "type": "string" + "container": { + "type": "integer" }, - "code": { - "type": "string" + "scans": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ContainerScanHistoryItem" + } } }, "required": [ - "detail" + "container", + "scans" ] }, - "ArchiveRequestValidationError": { + "ContainerMove": { "type": "object", - "description": "DRF field-keyed errors, which are NOT the `detail`/`code` shape.\n\nA blank or overlong reason fails in `serializer.is_valid(raise_exception=True)` and comes\nback as `{\"reason\": [\"...\"]}`. Declaring every 400 as `ArchiveRequestError` published a\ncontract the endpoint does not honour, so a generated client would destructure `detail`\nand find nothing. Follows the `ProvisionSubdomainValidationErrorSerializer` precedent.", "properties": { - "reason": { - "type": "array", - "items": { - "type": "string" - } + "parent_id": { + "type": "integer", + "nullable": true + }, + "label": { + "type": "string" + }, + "location": { + "type": "string" + }, + "description": { + "type": "string" + }, + "is_active": { + "type": "boolean" } } }, - "ArchivedPaymentMakerspace": { + "ContainerProductSummary": { "type": "object", "properties": { "id": { "type": "integer" }, - "slug": { - "type": "string", - "pattern": "^[-a-zA-Z0-9_]+$" - }, "name": { "type": "string" + }, + "available_quantity": { + "type": "integer" + }, + "tracking_mode": { + "type": "string" } }, "required": [ + "available_quantity", "id", "name", - "slug" + "tracking_mode" ] }, - "ArchivedPaymentSummary": { + "ContainerScanHistoryItem": { "type": "object", "properties": { - "makerspace": { - "$ref": "#/components/schemas/ArchivedPaymentMakerspace" + "id": { + "type": "string" }, - "pending_count": { - "type": "integer", - "minimum": 0 + "source": { + "type": "string" }, - "total_count": { + "context": { + "type": "string" + }, + "actor": { "type": "integer", - "minimum": 1 + "nullable": true + }, + "created_at": { + "type": "string", + "format": "date-time" } }, "required": [ - "makerspace", - "pending_count", - "total_count" + "actor", + "context", + "created_at", + "id", + "source" ] }, - "AssetChainGroup": { + "ContextEnum": { + "enum": [ + "issue", + "return", + "inventory_check" + ], + "type": "string", + "description": "* `issue` - issue\n* `return` - return\n* `inventory_check` - inventory_check" + }, + "CreateBoxQr": { "type": "object", "properties": { - "asset_id": { - "type": "integer", - "nullable": true + "makerspace_id": { + "type": "integer" }, - "asset_tag": { + "label": { "type": "string" }, - "serial_number": { + "location": { "type": "string" }, - "status": { + "description": { "type": "string" }, - "events": { - "type": "array", - "items": { - "$ref": "#/components/schemas/TimelineEvent" - } + "parent_id": { + "type": "integer", + "nullable": true } }, "required": [ - "asset_id", - "asset_tag", - "events", - "serial_number", - "status" + "label", + "makerspace_id" ] }, - "AssetGenerate": { + "CreateToolQr": { "type": "object", "properties": { - "count": { - "type": "integer", - "maximum": 200, - "minimum": 1 - }, - "name_prefix": { - "type": "string" - }, - "serial_numbers": { - "type": "array", - "items": { - "type": "string" - } + "makerspace_id": { + "type": "integer" }, - "print_batch_id": { - "type": "integer", - "nullable": true + "product_id": { + "type": "integer" }, - "create_print_batch": { - "type": "boolean", - "default": false + "asset_id": { + "type": "integer" } }, "required": [ - "count" + "makerspace_id" ] }, - "AssetGenerateItem": { + "CutoverOutcome": { "type": "object", "properties": { - "id": { - "type": "integer" - }, - "asset_tag": { + "message": { "type": "string" }, - "qr": { - "$ref": "#/components/schemas/QrCode" + "receipt": { + "$ref": "#/components/schemas/ReceiptEnvelope" } }, "required": [ - "asset_tag", - "id", - "qr" + "message" ] }, - "AssetGenerateResult": { + "CutoverReceiptRequest": { "type": "object", "properties": { - "assets": { - "type": "array", - "items": { - "$ref": "#/components/schemas/AssetGenerateItem" - } - }, - "print_batch_id": { - "type": "integer", - "nullable": true + "receipt": { + "$ref": "#/components/schemas/ReceiptEnvelope" } }, "required": [ - "assets", - "print_batch_id" + "receipt" ] }, - "AssetQrHistory": { + "DamagedLostReport": { "type": "object", "properties": { - "asset": { - "type": "integer" + "rows": { + "type": "array", + "items": { + "type": "array", + "items": {} + } }, - "scans": { + "typed_rows": { "type": "array", "items": { - "$ref": "#/components/schemas/QrHistoryItem" + "$ref": "#/components/schemas/DamagedLostReportRow" } } }, "required": [ - "asset", - "scans" + "rows", + "typed_rows" ] }, - "AssignBox": { + "DamagedLostReportRow": { "type": "object", "properties": { - "box_code": { + "makerspace_id": { + "type": "integer" + }, + "product_name": { "type": "string" + }, + "damaged_quantity": { + "type": "integer" + }, + "lost_quantity": { + "type": "integer" } }, "required": [ - "box_code" + "damaged_quantity", + "lost_quantity", + "product_name" ] }, - "AssignOperator": { + "DamagedMissingReport": { "type": "object", "properties": { - "user_id": { - "type": "integer" + "rows": { + "type": "array", + "items": { + "type": "array", + "items": {} + } }, - "access_level": { - "type": "string", - "maxLength": 16 + "typed_rows": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DamagedMissingReportRow" + } } }, "required": [ - "access_level", - "user_id" + "rows", + "typed_rows" ] }, - "AttendedEvent": { + "DamagedMissingReportRow": { "type": "object", "properties": { - "id": { + "makerspace_id": { "type": "integer" }, - "title": { + "product": { "type": "string" }, - "starts_at": { - "type": "string", - "format": "date-time" + "damaged_quantity": { + "type": "integer" + }, + "missing_quantity": { + "type": "integer" } }, "required": [ - "id", - "starts_at", - "title" + "damaged_quantity", + "missing_quantity", + "product" ] }, - "AuditLog": { + "Dashboard": { "type": "object", "properties": { - "id": { - "type": "integer", - "readOnly": true + "scope_mode": { + "$ref": "#/components/schemas/ScopeModeEnum" }, - "actor": { + "overdue_loans": { "type": "integer", - "nullable": true - }, - "action": { - "type": "string", - "maxLength": 100 + "default": 0 }, - "makerspace": { + "pending_requests": { "type": "integer", - "nullable": true - }, - "target_type": { - "type": "string", - "maxLength": 200 - }, - "target_id": { - "type": "string", - "maxLength": 100 - }, - "meta": {}, - "created_at": { - "type": "string", - "format": "date-time" - } - }, - "required": [ - "action", - "id" - ] - }, - "AuthMembership": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "slug": { - "type": "string" - }, - "role": { - "type": "string", - "nullable": true + "default": 0 }, - "role_id": { + "awaiting_issue": { "type": "integer", - "nullable": true + "default": 0 }, - "role_name": { - "type": "string" + "open_problem_reports": { + "type": "integer", + "default": 0 }, - "role_slug": { - "type": "string", - "nullable": true + "low_stock": { + "type": "integer", + "default": 0 }, - "source": { - "$ref": "#/components/schemas/AuthMembershipSourceEnum" + "pending_prints": { + "type": "integer", + "default": 0 }, - "actions": { - "type": "array", - "items": { - "type": "string" - } + "active_prints": { + "type": "integer", + "default": 0 }, - "can_configure_machine_types": { - "type": "boolean" + "prints_awaiting_collection": { + "type": "integer", + "default": 0 }, - "is_machine_only": { - "type": "boolean" + "failed_emails": { + "type": "integer", + "default": 0 }, - "can_refer": { - "type": "boolean" + "stocktakes_awaiting_approval": { + "type": "integer", + "default": 0 }, - "can_verify": { - "type": "boolean" + "warranty_expiring": { + "type": "integer", + "default": 0 }, - "verified_at": { - "type": "string", - "format": "date-time", - "nullable": true + "maintenance_overdue": { + "type": "integer", + "default": 0 }, - "referrals_enabled": { - "type": "boolean" + "pending_payments": { + "type": "integer", + "default": 0 } }, "required": [ - "actions", - "can_configure_machine_types", - "can_refer", - "can_verify", - "id", - "is_machine_only", - "referrals_enabled", - "role", - "role_id", - "role_name", - "role_slug", - "slug", - "source", - "verified_at" + "scope_mode" ] }, - "AuthMembershipSourceEnum": { - "enum": [ - "membership", - "organization" - ], - "type": "string", - "description": "* `membership` - membership\n* `organization` - organization" + "DataExportCreate": { + "type": "object", + "properties": { + "fidelity": { + "allOf": [ + { + "$ref": "#/components/schemas/FidelityEnum" + } + ], + "default": "REDACTED" + } + } }, - "AuthUserPayload": { + "DataExportDownloadUrl": { "type": "object", "properties": { - "id": { - "type": "integer" - }, - "username": { - "type": "string" - }, - "email": { + "url": { "type": "string", - "format": "email" - }, - "display_name": { - "type": "string" - }, - "phone": { - "type": "string" - }, - "email_verified": { - "type": "boolean" - }, - "role": { - "type": "string" - }, - "is_superuser": { - "type": "boolean" - }, - "must_change_password": { - "type": "boolean" + "format": "uri" }, - "makerspaces": { - "type": "array", - "items": { - "$ref": "#/components/schemas/AuthMembership" - } + "expires_at": { + "type": "string", + "format": "date-time" } }, "required": [ - "display_name", - "email", - "email_verified", - "id", - "is_superuser", - "makerspaces", - "must_change_password", - "phone", - "role", - "username" - ] - }, - "AvailabilityEnum": { - "type": "string", - "enum": [ - "Available", - "Limited", - "Full" + "expires_at", + "url" ] }, - "BackupArchive": { + "DataExportJob": { "type": "object", "properties": { "id": { @@ -36006,23 +44217,14 @@ "format": "uuid", "readOnly": true }, - "scope": { - "allOf": [ - { - "$ref": "#/components/schemas/ScopeEnum" - } - ], + "fidelity": { + "type": "string", "readOnly": true }, - "makerspace": { - "type": "integer", - "readOnly": true, - "nullable": true - }, "status": { "allOf": [ { - "$ref": "#/components/schemas/BackupArchiveStatusEnum" + "$ref": "#/components/schemas/StatusE1dEnum" } ], "readOnly": true @@ -36030,705 +44232,810 @@ "manifest": { "readOnly": true }, - "size_bytes": { - "type": "integer", - "readOnly": true - }, - "age_encrypted": { - "type": "boolean", - "readOnly": true + "failure_code": { + "readOnly": true, + "oneOf": [ + { + "$ref": "#/components/schemas/FailureCodeEnum" + }, + { + "$ref": "#/components/schemas/BlankEnum" + } + ] }, "failure_detail": { "type": "string", "readOnly": true }, - "started_at": { + "deadline_at": { "type": "string", "format": "date-time", "readOnly": true, "nullable": true }, - "completed_at": { + "snapshot_at": { "type": "string", "format": "date-time", "readOnly": true, "nullable": true }, - "expires_at": { + "started_at": { "type": "string", "format": "date-time", "readOnly": true, "nullable": true }, - "created_at": { + "completed_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true + }, + "expires_at": { "type": "string", "format": "date-time", "readOnly": true }, - "purge_warning": { + "created_at": { "type": "string", + "format": "date-time", "readOnly": true } }, "required": [ - "age_encrypted", "completed_at", "created_at", + "deadline_at", "expires_at", + "failure_code", "failure_detail", + "fidelity", "id", - "makerspace", "manifest", - "purge_warning", - "scope", - "size_bytes", + "snapshot_at", "started_at", "status" ] }, - "BackupArchiveStatusEnum": { + "DeliveryEnum": { "enum": [ - "pending", - "running", - "promoting", - "available", - "failed", - "expired" + "web", + "device" ], "type": "string", - "description": "* `pending` - Pending\n* `running` - Running\n* `promoting` - Promoting\n* `available` - Available\n* `failed` - Failed\n* `expired` - Expired" + "description": "* `web` - Web\n* `device` - Device" }, - "BackupDownload": { + "DeploymentIdentity": { "type": "object", "properties": { - "url": { + "algorithm": { + "type": "string" + }, + "deployment_id": { + "type": "string" + }, + "public_key": { + "type": "string" + }, + "fingerprint": { + "type": "string" + }, + "age_recipient": { + "type": "string" + } + }, + "required": [ + "age_recipient", + "algorithm", + "deployment_id", + "fingerprint", + "public_key" + ] + }, + "DestinationScope": { + "type": "object", + "properties": { + "machine_type_ids": { + "type": "array", + "items": { + "type": "integer" + } + }, + "machine_ids": { + "type": "array", + "items": { + "type": "integer" + } + }, + "category_ids": { + "type": "array", + "items": { + "type": "integer" + } + } + } + }, + "DeviceChallengeResponse": { + "type": "object", + "properties": { + "challenge": { + "type": "string" + }, + "expires_in": { + "type": "integer", + "minimum": 1 + } + }, + "required": [ + "challenge", + "expires_in" + ] + }, + "DeviceGrant": { + "type": "object", + "properties": { + "id": { "type": "string", - "format": "uri" + "format": "uuid" }, - "expires_at": { + "platform": { + "type": "string" + }, + "app_id": { + "type": "string" + }, + "environment": { + "type": "string" + }, + "status": { + "type": "string" + }, + "attested_at": { + "type": "string", + "format": "date-time" + }, + "last_used_at": { + "type": "string", + "format": "date-time" + }, + "created_at": { "type": "string", "format": "date-time" + } + }, + "required": [ + "app_id", + "attested_at", + "created_at", + "environment", + "id", + "last_used_at", + "platform", + "status" + ] + }, + "DeviceIdentity": { + "type": "object", + "properties": { + "platform": { + "$ref": "#/components/schemas/PlatformEnum" + }, + "app_id": { + "type": "string", + "maxLength": 255, + "pattern": "^[A-Za-z0-9._-]{3,255}$" + }, + "environment": { + "$ref": "#/components/schemas/EnvironmentEnum" + } + }, + "required": [ + "app_id", + "environment", + "platform" + ] + }, + "DeviceLogin": { + "type": "object", + "properties": { + "platform": { + "$ref": "#/components/schemas/PlatformEnum" + }, + "app_id": { + "type": "string", + "maxLength": 255, + "pattern": "^[A-Za-z0-9._-]{3,255}$" + }, + "environment": { + "$ref": "#/components/schemas/EnvironmentEnum" + }, + "username": { + "type": "string", + "maxLength": 254 + }, + "password": { + "type": "string", + "writeOnly": true, + "maxLength": 1024 + }, + "challenge": { + "type": "string", + "maxLength": 512 + }, + "attestation": {} + }, + "required": [ + "app_id", + "attestation", + "challenge", + "environment", + "password", + "platform", + "username" + ] + }, + "DeviceLogoutResponse": { + "type": "object", + "properties": { + "detail": { + "type": "string" + } + }, + "required": [ + "detail" + ] + }, + "DeviceRefresh": { + "type": "object", + "properties": { + "refresh": { + "type": "string", + "writeOnly": true, + "maxLength": 4096 + } + }, + "required": [ + "refresh" + ] + }, + "DeviceRefreshResponse": { + "type": "object", + "properties": { + "access": { + "type": "string" + }, + "refresh": { + "type": "string" + }, + "device_grant": { + "$ref": "#/components/schemas/DeviceGrant" + } + }, + "required": [ + "access", + "device_grant", + "refresh" + ] + }, + "DeviceTokenResponse": { + "type": "object", + "properties": { + "access": { + "type": "string" }, - "purge_warning": { + "refresh": { "type": "string" + }, + "user": { + "type": "object", + "additionalProperties": {} + }, + "device_grant": { + "$ref": "#/components/schemas/DeviceGrant" } }, "required": [ - "expires_at", - "purge_warning", - "url" - ] - }, - "BlankEnum": { - "enum": [ - "" + "access", + "device_grant", + "refresh", + "user" ] }, - "BookableSpaceAdmin": { + "DirectLoan": { "type": "object", "properties": { - "id": { - "type": "integer", - "readOnly": true - }, "public_token": { "type": "string", "format": "uuid", "readOnly": true }, - "makerspace_id": { - "type": "integer", - "readOnly": true - }, - "name": { + "status": { "type": "string", "readOnly": true }, - "kind": { - "allOf": [ - { - "$ref": "#/components/schemas/Kind3bfEnum" - } - ], - "readOnly": true - }, - "description": { - "type": "string", + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PublicToolLoanItem" + }, "readOnly": true }, - "capacity": { + "id": { "type": "integer", "readOnly": true }, - "location": { + "target_type": { "type": "string", "readOnly": true }, - "image_url": { + "target_label": { "type": "string", "readOnly": true }, - "is_public": { - "type": "boolean", - "readOnly": true - }, - "show_public_availability": { - "type": "boolean", - "readOnly": true - }, - "show_public_booker_names": { - "type": "boolean", - "readOnly": true - }, - "approval_mode": { - "allOf": [ - { - "$ref": "#/components/schemas/ApprovalModeEnum" - } - ], - "readOnly": true - }, - "min_booking_duration_minutes": { + "container_id": { "type": "integer", "readOnly": true }, - "max_booking_duration_minutes": { - "type": "integer", + "container_label": { + "type": "string", + "nullable": true, "readOnly": true }, - "booking_lead_time_minutes": { - "type": "integer", - "readOnly": true + "due_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true }, - "max_booking_advance_days": { + "issue_evidence_id": { "type": "integer", - "readOnly": true - }, - "custom_form": { "readOnly": true, "nullable": true }, - "requester_notifications_enabled": { - "type": "boolean", + "return_evidence_id": { + "type": "integer", "readOnly": true, "nullable": true }, - "payment_amount": { + "return_notes": { "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", "readOnly": true }, - "effective_requester_notifications_enabled": { + "return_scan_required": { "type": "boolean", "readOnly": true }, - "is_active": { - "type": "boolean", + "source": { + "type": "string", "readOnly": true }, - "created_by_id": { - "type": "integer", - "readOnly": true, - "nullable": true - }, - "created_at": { - "type": "string", - "format": "date-time", + "issued_by": { + "allOf": [ + { + "$ref": "#/components/schemas/DirectLoanUserAttribution" + } + ], + "nullable": true, "readOnly": true }, - "updated_at": { - "type": "string", - "format": "date-time", + "return_items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DirectLoanReturnItem" + }, "readOnly": true } }, "required": [ - "approval_mode", - "booking_lead_time_minutes", - "capacity", - "created_at", - "created_by_id", - "custom_form", - "description", - "effective_requester_notifications_enabled", + "container_id", + "container_label", + "due_at", "id", - "image_url", - "is_active", - "is_public", - "kind", - "location", - "makerspace_id", - "max_booking_advance_days", - "max_booking_duration_minutes", - "min_booking_duration_minutes", - "name", - "payment_amount", + "issue_evidence_id", + "issued_by", + "items", "public_token", - "requester_notifications_enabled", - "show_public_availability", - "show_public_booker_names", - "updated_at" + "return_evidence_id", + "return_items", + "return_notes", + "return_scan_required", + "source", + "status", + "target_label", + "target_type" ] }, - "BookableSpaceBookingRules": { + "DirectLoanIssue": { "type": "object", "properties": { - "min_booking_duration_minutes": { - "type": "integer", - "maximum": 2147483647, - "minimum": 1 + "borrower_id": { + "type": "integer" }, - "max_booking_duration_minutes": { - "type": "integer", - "maximum": 2147483647, - "minimum": 1 + "evidence_id": { + "type": "integer" }, - "booking_lead_time_minutes": { - "type": "integer", - "maximum": 2147483647, - "minimum": 0 + "remark": { + "type": "string" }, - "max_booking_advance_days": { + "container_id": { "type": "integer", - "maximum": 2147483647, - "minimum": 1 - }, - "approval_mode": { - "$ref": "#/components/schemas/ApprovalModeEnum" - } - } - }, - "BookableSpaceListResponse": { - "type": "object", - "properties": { - "count": { - "type": "integer" - }, - "next": { - "type": "string", "nullable": true }, - "previous": { - "type": "string", - "nullable": true + "qr_payloads": { + "type": "array", + "items": { + "type": "string", + "maxLength": 64 + }, + "maxItems": 50 }, - "results": { + "items": { "type": "array", "items": { - "$ref": "#/components/schemas/BookableSpaceAdmin" + "$ref": "#/components/schemas/DirectLoanItem" } } }, "required": [ - "count", - "results" + "borrower_id", + "evidence_id" ] }, - "BookableSpaceWrite": { + "DirectLoanItem": { "type": "object", "properties": { - "name": { - "type": "string", - "maxLength": 200 - }, - "kind": { - "allOf": [ - { - "$ref": "#/components/schemas/Kind3bfEnum" - } - ], - "default": "other" - }, - "description": { - "type": "string", - "default": "" + "product_id": { + "type": "integer" }, - "capacity": { + "quantity": { "type": "integer", - "minimum": 0, - "default": 0 - }, - "location": { - "type": "string", - "default": "", - "maxLength": 255 - }, - "is_public": { - "type": "boolean", - "default": false - }, - "show_public_availability": { - "type": "boolean", - "default": false - }, - "show_public_booker_names": { - "type": "boolean", - "default": false - }, - "custom_form": { - "nullable": true - }, - "requester_notifications_enabled": { - "type": "boolean", - "nullable": true - }, - "payment_amount": { - "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", - "default": "0.00" + "minimum": 1 } }, "required": [ - "name" + "product_id", + "quantity" ] }, - "BookingAdmin": { + "DirectLoanMember": { "type": "object", "properties": { - "id": { + "membership_id": { "type": "integer", "readOnly": true }, - "public_token": { - "type": "string", - "format": "uuid", - "readOnly": true - }, - "space_id": { + "user_id": { "type": "integer", "readOnly": true }, - "name": { + "display_name": { "type": "string", "readOnly": true }, - "email": { + "username": { "type": "string", "readOnly": true }, - "phone": { - "type": "string", + "is_walk_in": { + "type": "boolean", "readOnly": true + } + }, + "required": [ + "display_name", + "is_walk_in", + "membership_id", + "user_id", + "username" + ] + }, + "DirectLoanReturn": { + "type": "object", + "properties": { + "evidence_id": { + "type": "integer" }, - "starts_at": { - "type": "string", - "format": "date-time", - "readOnly": true + "notes": { + "type": "string" }, - "ends_at": { + "qr_payload": { "type": "string", - "format": "date-time", - "readOnly": true + "maxLength": 64 }, - "status": { - "allOf": [ - { - "$ref": "#/components/schemas/BookingAdminStatusEnum" - } - ], - "readOnly": true + "resolutions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ReturnItemResolution" + } + } + }, + "required": [ + "evidence_id", + "notes" + ] + }, + "DirectLoanReturnAsset": { + "type": "object", + "properties": { + "asset_id": { + "type": "integer" }, - "note": { - "type": "string", - "readOnly": true + "asset_tag": { + "type": "string" + } + }, + "required": [ + "asset_id", + "asset_tag" + ] + }, + "DirectLoanReturnItem": { + "type": "object", + "properties": { + "item_id": { + "type": "integer" }, - "custom_answers": { - "readOnly": true, - "nullable": true + "product_name": { + "type": "string" }, - "created_at": { - "type": "string", - "format": "date-time", - "readOnly": true + "remaining_quantity": { + "type": "integer" }, - "payment": { - "allOf": [ - { - "$ref": "#/components/schemas/StaffPaymentSummary" - } - ], - "nullable": true, - "readOnly": true + "tracking_mode": { + "type": "string" + }, + "assets": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DirectLoanReturnAsset" + } } }, "required": [ - "created_at", - "custom_answers", - "email", - "ends_at", - "id", - "name", - "note", - "payment", - "phone", - "public_token", - "space_id", - "starts_at", - "status" + "assets", + "item_id", + "product_name", + "remaining_quantity", + "tracking_mode" ] }, - "BookingAdminStatusEnum": { - "enum": [ - "pending", - "confirmed", - "rejected", - "cancelled", - "completed", - "no_show" - ], - "type": "string", - "description": "* `pending` - Pending\n* `confirmed` - Confirmed\n* `rejected` - Rejected\n* `cancelled` - Cancelled\n* `completed` - Completed\n* `no_show` - No-show" + "DirectLoanUserAttribution": { + "type": "object", + "properties": { + "username": { + "type": "string" + }, + "role": { + "type": "string" + } + }, + "required": [ + "role", + "username" + ] }, - "BookingListResponse": { + "Directory": { "type": "object", "properties": { - "count": { + "members": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DirectoryEntry" + } + }, + "hidden_count": { + "type": "integer" + } + }, + "required": [ + "hidden_count", + "members" + ] + }, + "DirectoryEntry": { + "type": "object", + "description": "The listing row. Display name and avatar only -- never email, never phone.\n\nOtherwise \"see who else is here\" is an address harvest performed by anyone a space\nadmits, which is a different thing from what the member agreed to publish.", + "properties": { + "membership_id": { "type": "integer" }, - "next": { + "display_name": { + "type": "string" + }, + "headline": { + "type": "string" + }, + "avatar_url": { "type": "string", "nullable": true + } + }, + "required": [ + "avatar_url", + "display_name", + "headline", + "membership_id" + ] + }, + "DispositionEnum": { + "enum": [ + "needs_fix", + "remove" + ], + "type": "string", + "description": "* `needs_fix` - needs_fix\n* `remove` - remove" + }, + "DocTypeEnum": { + "enum": [ + "manual", + "sop" + ], + "type": "string", + "description": "* `manual` - Manual\n* `sop` - SOP" + }, + "DocumentFinalize": { + "type": "object", + "properties": { + "object_key": { + "type": "string", + "maxLength": 255 }, - "previous": { + "doc_type": { "type": "string", - "nullable": true + "maxLength": 16 }, - "results": { - "type": "array", - "items": { - "$ref": "#/components/schemas/BookingAdmin" - } + "original_filename": { + "type": "string", + "maxLength": 255 } }, "required": [ - "count", - "results" + "doc_type", + "object_key", + "original_filename" ] }, - "BookingUtilizationReport": { + "DocumentPresign": { "type": "object", "properties": { - "rows": { - "type": "array", - "items": { - "type": "array", - "items": {} - } + "filename": { + "type": "string", + "maxLength": 255 }, - "typed_rows": { - "type": "array", - "items": { - "$ref": "#/components/schemas/BookingUtilizationRow" - } + "content_type": { + "type": "string", + "maxLength": 100 } }, "required": [ - "rows", - "typed_rows" + "content_type", + "filename" ] }, - "BookingUtilizationRow": { + "DomainVerificationRecord": { "type": "object", "properties": { - "makerspace_id": { - "type": "integer" - }, - "space_id": { - "type": "integer" - }, - "space_name": { + "host": { "type": "string" }, - "kind": { + "type": { "type": "string" }, - "is_active": { - "type": "boolean" - }, - "booked": { - "type": "integer" - }, - "completed": { - "type": "integer" - }, - "no_show": { - "type": "integer" - }, - "cancelled": { - "type": "integer" - }, - "upcoming": { - "type": "integer" - }, - "reserved_hours": { - "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,18}(?:\\.\\d{0,2})?$" - }, - "completed_hours": { - "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,18}(?:\\.\\d{0,2})?$" - }, - "window_hours": { - "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,18}(?:\\.\\d{0,2})?$", - "nullable": true - }, - "reservation_utilization_percent": { - "type": "number", - "format": "double", - "nullable": true - }, - "no_show_rate_percent": { - "type": "number", - "format": "double", - "nullable": true + "value": { + "type": "string" } }, "required": [ - "booked", - "cancelled", - "completed", - "completed_hours", - "is_active", - "kind", - "no_show", - "no_show_rate_percent", - "reservation_utilization_percent", - "reserved_hours", - "space_id", - "space_name", - "upcoming", - "window_hours" + "host", + "type", + "value" ] }, - "Box": { + "DomainVerificationResponse": { "type": "object", "properties": { - "id": { - "type": "integer", - "readOnly": true + "status": { + "$ref": "#/components/schemas/StatusA39Enum" }, - "makerspace": { - "type": "integer" + "token": { + "type": "string" }, - "parent": { - "type": "integer", + "expected_record": { + "allOf": [ + { + "$ref": "#/components/schemas/DomainVerificationRecord" + } + ], "nullable": true }, - "code": { + "verified_at": { "type": "string", - "readOnly": true + "format": "date-time", + "nullable": true }, - "label": { + "detail": { + "type": "string" + } + }, + "required": [ + "detail", + "expected_record", + "status", + "token", + "verified_at" + ] + }, + "EducationEntry": { + "type": "object", + "properties": { + "institution": { "type": "string", "maxLength": 200 }, - "location": { + "qualification": { "type": "string", + "default": "", "maxLength": 200 }, - "description": { - "type": "string" - }, - "is_active": { - "type": "boolean" - }, - "qr_code_id": { - "type": "integer", - "nullable": true, - "readOnly": true - }, - "created_at": { - "type": "string", - "format": "date-time", - "readOnly": true - }, - "updated_at": { + "year": { "type": "string", - "format": "date-time", - "readOnly": true + "default": "", + "maxLength": 20 } }, "required": [ - "code", - "created_at", - "id", - "label", - "makerspace", - "qr_code_id", - "updated_at" + "institution" ] }, - "BulkImportJob": { + "EmailLog": { "type": "object", "properties": { "id": { "type": "integer", "readOnly": true }, - "mode": { - "allOf": [ - { - "$ref": "#/components/schemas/Mode087Enum" - } - ], - "readOnly": true - }, - "status": { - "allOf": [ - { - "$ref": "#/components/schemas/BulkImportJobStatusEnum" - } - ], - "readOnly": true - }, - "total_rows": { - "type": "integer", - "readOnly": true - }, - "processed_rows": { - "type": "integer", + "to_email": { + "type": "string", "readOnly": true }, - "created_count": { - "type": "integer", + "subject": { + "type": "string", "readOnly": true }, - "updated_count": { - "type": "integer", + "stream": { + "type": "string", "readOnly": true }, - "error_count": { - "type": "integer", + "event": { + "type": "string", "readOnly": true }, - "warning_count": { - "type": "integer", + "audience": { + "type": "string", "readOnly": true }, - "result": { + "status": { + "allOf": [ + { + "$ref": "#/components/schemas/EmailLogStatusEnum" + } + ], "readOnly": true }, "error": { "type": "string", "readOnly": true }, - "created_at": { - "type": "string", - "format": "date-time", + "attempts": { + "type": "integer", "readOnly": true }, - "updated_at": { + "created_at": { "type": "string", "format": "date-time", "readOnly": true }, - "completed_at": { + "sent_at": { "type": "string", "format": "date-time", "readOnly": true, @@ -36736,426 +45043,293 @@ } }, "required": [ - "completed_at", + "attempts", + "audience", "created_at", - "created_count", "error", - "error_count", + "event", "id", - "mode", - "processed_rows", - "result", + "sent_at", "status", - "total_rows", - "updated_at", - "updated_count", - "warning_count" - ] - }, - "BulkImportJobCreate": { - "type": "object", - "properties": { - "mode": { - "$ref": "#/components/schemas/Mode087Enum" - }, - "file": { - "type": "string", - "format": "uri", - "nullable": true - }, - "rows": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": {} - }, - "maxItems": 5000 - }, - "mapping": {} - }, - "required": [ - "mode" + "stream", + "subject", + "to_email" ] }, - "BulkImportJobStatusEnum": { + "EmailLogStatusEnum": { "enum": [ "pending", - "running", - "completed", - "failed" + "sending", + "sent", + "failed", + "skipped" ], "type": "string", - "description": "* `pending` - Pending\n* `running` - Running\n* `completed` - Completed\n* `failed` - Failed" + "description": "* `pending` - Pending\n* `sending` - Sending\n* `sent` - Sent\n* `failed` - Failed\n* `skipped` - Skipped" }, - "BulkImportPreview": { + "EmailTemplateDetail": { "type": "object", "properties": { - "file": { + "stream": { "type": "string", - "format": "uri", - "nullable": true + "readOnly": true }, - "rows": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": {} - }, - "maxItems": 5000 + "audience": { + "type": "string", + "readOnly": true }, - "mapping": {} - } - }, - "Capability": { - "type": "object", - "properties": { - "value": { - "type": "string" + "key": { + "type": "string", + "readOnly": true }, "label": { - "type": "string" + "type": "string", + "readOnly": true }, "description": { - "type": "string" - }, - "group": { - "type": "string" + "type": "string", + "readOnly": true }, - "grantable": { - "type": "boolean" + "fields": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": {} + }, + "readOnly": true }, - "lock_reason": { + "subject": { "type": "string", - "nullable": true - } - }, - "required": [ - "description", - "grantable", - "group", - "label", - "lock_reason", - "value" - ] - }, - "CategoryAdmin": { - "type": "object", - "properties": { - "id": { - "type": "integer", "readOnly": true }, - "makerspace": { - "type": "integer", + "text_body": { + "type": "string", "readOnly": true }, - "name": { + "html_body": { "type": "string", - "maxLength": 100 + "readOnly": true }, - "slug": { - "oneOf": [ - { - "type": "string", - "pattern": "^[-a-zA-Z0-9_]+$" - }, - { - "type": "string", - "maxLength": 0 - } - ] + "is_active": { + "type": "boolean", + "readOnly": true }, - "display_order": { - "type": "integer", - "maximum": 2147483647, - "minimum": 0 + "is_overridden": { + "type": "boolean", + "readOnly": true }, - "icon": { + "default_subject": { "type": "string", - "maxLength": 50 - }, - "product_count": { - "type": "integer", - "readOnly": true, - "default": 0 + "readOnly": true }, - "created_at": { + "default_text": { "type": "string", - "format": "date-time", "readOnly": true }, - "updated_at": { + "default_html": { "type": "string", - "format": "date-time", "readOnly": true } }, "required": [ - "created_at", - "id", - "makerspace", - "name", - "product_count", - "updated_at" + "audience", + "default_html", + "default_subject", + "default_text", + "description", + "fields", + "html_body", + "is_active", + "is_overridden", + "key", + "label", + "stream", + "subject", + "text_body" ] }, - "ChangePassword": { + "EmailTemplateListItem": { "type": "object", "properties": { - "current_password": { + "stream": { "type": "string", - "writeOnly": true + "readOnly": true }, - "new_password": { - "type": "string", - "writeOnly": true - } - }, - "required": [ - "current_password", - "new_password" - ] - }, - "ChangePasswordResponse": { - "type": "object", - "properties": { - "detail": { - "type": "string" - } - }, - "required": [ - "detail" - ] - }, - "Channel7a7Enum": { - "enum": [ - "telegram", - "slack", - "mattermost", - "discord" - ], - "type": "string", - "description": "* `telegram` - Telegram\n* `slack` - Slack\n* `mattermost` - Mattermost\n* `discord` - Discord" - }, - "ChannelCbbEnum": { - "enum": [ - "email", - "telegram", - "slack", - "mattermost", - "discord", - "native_push" - ], - "type": "string", - "description": "* `email` - Email\n* `telegram` - Telegram\n* `slack` - Slack\n* `mattermost` - Mattermost\n* `discord` - Discord\n* `native_push` - Native push" - }, - "CheckoutUrl": { - "type": "object", - "properties": { - "checkout_url": { + "audience": { "type": "string", - "format": "uri" - } - }, - "required": [ - "checkout_url" - ] - }, - "ClaimRedemption": { - "type": "object", - "properties": { - "code": { + "readOnly": true + }, + "key": { "type": "string", - "writeOnly": true, - "maxLength": 64 + "readOnly": true }, - "makerspace_slug": { + "label": { "type": "string", - "writeOnly": true, - "maxLength": 80, - "pattern": "^[-a-zA-Z0-9_]+$" + "readOnly": true + }, + "is_active": { + "type": "boolean", + "readOnly": true + }, + "is_overridden": { + "type": "boolean", + "readOnly": true + }, + "can_edit_space_default": { + "type": "boolean", + "readOnly": true + }, + "overridable_types": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MachineTypeOption" + }, + "readOnly": true } }, "required": [ - "code", - "makerspace_slug" + "audience", + "can_edit_space_default", + "is_active", + "is_overridden", + "key", + "label", + "overridable_types", + "stream" ] }, - "ClaimRedemptionResponse": { + "EmailTemplatePreviewRequest": { "type": "object", "properties": { - "user": { - "$ref": "#/components/schemas/AuthUserPayload" + "stream": { + "type": "string" }, - "access": { + "audience": { "type": "string" - } - }, - "required": [ - "access", - "user" - ] - }, - "ClaimableInvitation": { - "type": "object", - "properties": { - "id": { - "type": "integer" }, - "makerspace": { - "type": "object", - "additionalProperties": {} + "key": { + "type": "string" }, - "inviter": { + "machine_type_id": { + "type": "integer" + }, + "subject": { "type": "string", - "nullable": true + "maxLength": 200 }, - "auto_activates": { - "type": "boolean" + "text_body": { + "type": "string" }, - "role": { - "type": "string", - "nullable": true + "html_body": { + "type": "string" } }, "required": [ - "auto_activates", - "id", - "inviter", - "makerspace", - "role" + "audience", + "html_body", + "key", + "stream", + "subject", + "text_body" ] }, - "ClientPlatformEnum": { - "enum": [ - "web", - "ios", - "android" - ], - "type": "string", - "description": "* `web` - Web\n* `ios` - iOS\n* `android` - Android" - }, - "ClientTypeEnum": { - "enum": [ - "browser", - "server" - ], - "type": "string", - "description": "* `browser` - Browser\n* `server` - Server" - }, - "ClosureApproval": { + "EmailTemplatePreviewResponse": { "type": "object", "properties": { - "id": { - "type": "string", - "format": "uuid", - "readOnly": true - }, - "closure_digest": { - "type": "string", - "maxLength": 64 - }, - "identity_count": { + "subject": { "type": "string", "readOnly": true }, - "approved_count": { + "text_body": { "type": "string", "readOnly": true }, - "approved_at": { + "html_body": { "type": "string", - "format": "date-time", "readOnly": true - }, - "revoked_at": { - "type": "string", - "format": "date-time", - "nullable": true } }, "required": [ - "approved_at", - "approved_count", - "closure_digest", - "id", - "identity_count" + "html_body", + "subject", + "text_body" ] }, - "ClosureApprovalCreate": { + "EmailVerificationConfirm": { "type": "object", "properties": { - "digest": { + "code": { "type": "string", - "maxLength": 64, - "minLength": 64 - }, - "decisions": { - "type": "array", - "items": { - "$ref": "#/components/schemas/IdentityDisclosureDecision" - } + "maxLength": 6 } }, "required": [ - "decisions", - "digest" + "code" ] }, - "ClosureIdentity": { + "EndReasonEnum": { + "enum": [ + "superseded", + "membership_revoked", + "claim_revoked", + "user_ended" + ], + "type": "string", + "description": "* `superseded` - Superseded\n* `membership_revoked` - Membership revoked\n* `claim_revoked` - Claim revoked\n* `user_ended` - User ended" + }, + "EnvironmentEnum": { + "enum": [ + "development", + "production" + ], + "type": "string", + "description": "* `development` - Development\n* `production` - Production" + }, + "Error": { + "type": "object", + "properties": { + "detail": { + "type": "string" + } + } + }, + "EventAdmin": { "type": "object", "properties": { "id": { - "type": "integer" + "type": "integer", + "readOnly": true }, - "username": { - "type": "string" + "makerspace_id": { + "type": "integer", + "readOnly": true }, - "email": { - "oneOf": [ - { - "type": "string", - "format": "email" + "series_summary": { + "type": "object", + "nullable": true, + "properties": { + "id": { + "type": "integer" }, - { + "public_token": { "type": "string", - "maxLength": 0 + "format": "uuid" + }, + "title": { + "type": "string" + }, + "timezone": { + "type": "string" } - ] - }, - "first_name": { - "type": "string" - }, - "last_name": { - "type": "string" - }, - "display_name": { - "type": "string" - }, - "phone": { - "type": "string" + }, + "readOnly": true }, - "date_joined": { - "type": "string", - "format": "date-time" - } - }, - "required": [ - "date_joined", - "display_name", - "email", - "first_name", - "id", - "last_name", - "phone", - "username" - ] - }, - "CollaborativeEvent": { - "type": "object", - "properties": { - "id": { + "series_revision": { "type": "integer", + "readOnly": true, + "nullable": true + }, + "series_override_fields": { "readOnly": true }, "title": { @@ -37176,6 +45350,10 @@ "format": "date-time", "readOnly": true }, + "timezone_name": { + "type": "string", + "readOnly": true + }, "location": { "type": "string", "readOnly": true @@ -37194,15 +45372,45 @@ }, "capacity": { "type": "integer", - "minimum": 0, "readOnly": true }, - "availability": { - "allOf": [ - { - "$ref": "#/components/schemas/AvailabilityEnum" - } - ], + "payment_amount": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", + "readOnly": true + }, + "registration_requires_approval": { + "type": "boolean", + "readOnly": true + }, + "registration_cutoff_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true + }, + "registration_cutoff_lead_minutes": { + "type": "integer", + "readOnly": true, + "nullable": true + }, + "effective_registration_cutoff_at": { + "type": "string", + "format": "date-time", + "nullable": true, + "readOnly": true + }, + "registration_open": { + "type": "boolean", + "readOnly": true + }, + "offline_checkin_enabled": { + "type": "boolean", + "readOnly": true + }, + "is_public": { + "type": "boolean", "readOnly": true }, "image_url": { @@ -37211,22 +45419,35 @@ "nullable": true, "readOnly": true }, - "host_name": { + "status": { + "allOf": [ + { + "$ref": "#/components/schemas/StatusFbbEnum" + } + ], + "readOnly": true + }, + "created_by_id": { + "type": "integer", + "readOnly": true, + "nullable": true + }, + "created_at": { "type": "string", + "format": "date-time", "readOnly": true }, - "host_slug": { + "updated_at": { "type": "string", - "readOnly": true, - "pattern": "^[-a-zA-Z0-9_]+$" + "format": "date-time", + "readOnly": true }, - "host_waiver": { + "registration_counts": { "allOf": [ { - "$ref": "#/components/schemas/HostWaiver" + "$ref": "#/components/schemas/EventRegistrationCounts" } ], - "nullable": true, "readOnly": true }, "organizers": { @@ -37238,3328 +45459,3351 @@ } }, "required": [ - "availability", "capacity", + "created_at", + "created_by_id", "custom_form", "description", + "effective_registration_cutoff_at", "ends_at", - "host_name", - "host_slug", - "host_waiver", "id", "image_url", + "is_public", "location", "location_kind", + "makerspace_id", + "offline_checkin_enabled", "organizers", + "payment_amount", + "registration_counts", + "registration_cutoff_at", + "registration_cutoff_lead_minutes", + "registration_open", + "registration_requires_approval", + "series_override_fields", + "series_revision", + "series_summary", "starts_at", - "title" + "status", + "timezone_name", + "title", + "updated_at" ] }, - "CollaborativeEventRegistrationInput": { + "EventAttendanceMark": { "type": "object", "properties": { - "custom_answers": { - "writeOnly": true, - "nullable": true - }, - "host_waiver_id": { - "type": "integer", - "minimum": 1, - "nullable": true - }, - "host_waiver_version": { - "type": "string", - "nullable": true, - "maxLength": 64 - }, - "host_waiver_accepted": { - "type": "boolean", - "default": false + "source": { + "allOf": [ + { + "$ref": "#/components/schemas/EventAttendanceMarkSourceEnum" + } + ], + "default": "online" } } }, - "ConditionEnum": { - "enum": [ - "available", - "damaged", - "lost", - "unknown" - ], - "type": "string", - "description": "* `available` - Available\n* `damaged` - Damaged\n* `lost` - Lost\n* `unknown` - Unknown" - }, - "ConnectStatusEnum": { + "EventAttendanceMarkSourceEnum": { "enum": [ - "unconnected", - "pending", - "active", - "restricted", - "disconnected" + "online", + "qr" ], "type": "string", - "description": "* `unconnected` - Unconnected\n* `pending` - Pending\n* `active` - Active\n* `restricted` - Restricted\n* `disconnected` - Disconnected" - }, - "ConsumableCandidate": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "readOnly": true - }, - "name": { - "type": "string", - "readOnly": true - }, - "available": { - "type": "integer", - "readOnly": true - } - }, - "required": [ - "available", - "id", - "name" - ] - }, - "ContainerAssetSummary": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "asset_tag": { - "type": "string" - }, - "product": { - "type": "string" - }, - "status": { - "type": "string" - } - }, - "required": [ - "asset_tag", - "id", - "product", - "status" - ] + "description": "* `online` - online\n* `qr` - qr" }, - "ContainerContents": { + "EventAttendanceReport": { "type": "object", "properties": { - "container": { - "$ref": "#/components/schemas/Box" - }, - "products": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ContainerProductSummary" - } - }, - "assets": { + "rows": { "type": "array", "items": { - "$ref": "#/components/schemas/ContainerAssetSummary" + "type": "array", + "items": {} } }, - "children": { + "typed_rows": { "type": "array", "items": { - "$ref": "#/components/schemas/Box" + "$ref": "#/components/schemas/EventAttendanceRow" } } }, "required": [ - "assets", - "children", - "container", - "products" + "rows", + "typed_rows" ] }, - "ContainerHistory": { + "EventAttendanceRow": { "type": "object", "properties": { - "container": { + "makerspace_id": { "type": "integer" }, - "scans": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ContainerScanHistoryItem" - } - } - }, - "required": [ - "container", - "scans" - ] - }, - "ContainerMove": { - "type": "object", - "properties": { - "parent_id": { + "event_id": { + "type": "integer" + }, + "series_id": { "type": "integer", "nullable": true }, - "label": { + "series_title": { "type": "string" }, - "location": { + "series_occurrence_key": { "type": "string" }, - "description": { + "title": { "type": "string" }, - "is_active": { - "type": "boolean" - } - } - }, - "ContainerProductSummary": { - "type": "object", - "properties": { - "id": { - "type": "integer" + "starts_at": { + "type": "string", + "format": "date-time" }, - "name": { + "status": { "type": "string" }, - "available_quantity": { + "capacity": { "type": "integer" }, - "tracking_mode": { + "registrations": { + "type": "integer" + }, + "confirmed": { + "type": "integer" + }, + "pending_approval": { + "type": "integer" + }, + "registered": { + "type": "integer" + }, + "waitlisted": { + "type": "integer" + }, + "rejected": { + "type": "integer" + }, + "cancelled": { + "type": "integer" + }, + "attended": { + "type": "integer" + }, + "attendance_rate_percent": { + "type": "number", + "format": "double", + "nullable": true + }, + "organizers": { "type": "string" } }, "required": [ - "available_quantity", - "id", - "name", - "tracking_mode" + "attendance_rate_percent", + "attended", + "cancelled", + "capacity", + "confirmed", + "event_id", + "organizers", + "pending_approval", + "registered", + "registrations", + "rejected", + "series_id", + "series_occurrence_key", + "series_title", + "starts_at", + "status", + "title", + "waitlisted" ] - }, - "ContainerScanHistoryItem": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "source": { - "type": "string" - }, - "context": { + }, + "EventCheckInResolveRequest": { + "type": "object", + "properties": { + "checkin_token": { "type": "string" - }, - "actor": { - "type": "integer", - "nullable": true - }, - "created_at": { - "type": "string", - "format": "date-time" } }, "required": [ - "actor", - "context", - "created_at", - "id", - "source" + "checkin_token" ] }, - "ContextEnum": { - "enum": [ - "issue", - "return", - "inventory_check" - ], - "type": "string", - "description": "* `issue` - issue\n* `return` - return\n* `inventory_check` - inventory_check" - }, - "CreateBoxQr": { + "EventCheckInResolveResponse": { "type": "object", "properties": { - "makerspace_id": { + "registration_id": { "type": "integer" }, - "label": { + "name": { "type": "string" }, - "location": { + "status": { "type": "string" }, - "description": { + "payment_status": { + "type": "string", + "nullable": true + }, + "host_waiver_state": { + "$ref": "#/components/schemas/HostWaiverStateEnum" + }, + "event_status": { "type": "string" }, - "parent_id": { - "type": "integer", - "nullable": true + "confirmable": { + "type": "boolean" } }, "required": [ - "label", - "makerspace_id" + "confirmable", + "event_status", + "host_waiver_state", + "name", + "payment_status", + "registration_id", + "status" ] }, - "CreateToolQr": { + "EventCollaborationInbox": { "type": "object", "properties": { - "makerspace_id": { - "type": "integer" + "id": { + "type": "integer", + "readOnly": true }, - "product_id": { - "type": "integer" + "event_id": { + "type": "integer", + "readOnly": true }, - "asset_id": { - "type": "integer" + "event_title": { + "type": "string", + "readOnly": true + }, + "starts_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "ends_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "host_name": { + "type": "string", + "readOnly": true + }, + "host_slug": { + "type": "string", + "readOnly": true, + "pattern": "^[-a-zA-Z0-9_]+$" + }, + "status": { + "allOf": [ + { + "$ref": "#/components/schemas/StatusB9dEnum" + } + ], + "readOnly": true + }, + "created_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "responded_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true } }, "required": [ - "makerspace_id" + "created_at", + "ends_at", + "event_id", + "event_title", + "host_name", + "host_slug", + "id", + "responded_at", + "starts_at", + "status" ] }, - "CutoverOutcome": { + "EventCollaborationRespond": { "type": "object", "properties": { - "message": { - "type": "string" - }, - "receipt": { - "$ref": "#/components/schemas/ReceiptEnvelope" + "accept": { + "type": "boolean" } }, "required": [ - "message" + "accept" ] }, - "CutoverReceiptRequest": { + "EventCollaborator": { "type": "object", "properties": { - "receipt": { - "$ref": "#/components/schemas/ReceiptEnvelope" + "id": { + "type": "integer", + "readOnly": true + }, + "event_id": { + "type": "integer", + "readOnly": true + }, + "makerspace_id": { + "type": "integer", + "readOnly": true + }, + "makerspace_name": { + "type": "string", + "readOnly": true + }, + "makerspace_slug": { + "type": "string", + "readOnly": true, + "pattern": "^[-a-zA-Z0-9_]+$" + }, + "status": { + "allOf": [ + { + "$ref": "#/components/schemas/StatusB9dEnum" + } + ], + "readOnly": true + }, + "invited_by_id": { + "type": "integer", + "readOnly": true, + "nullable": true + }, + "responded_by_id": { + "type": "integer", + "readOnly": true, + "nullable": true + }, + "created_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "responded_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true } }, "required": [ - "receipt" + "created_at", + "event_id", + "id", + "invited_by_id", + "makerspace_id", + "makerspace_name", + "makerspace_slug", + "responded_at", + "responded_by_id", + "status" ] }, - "DamagedLostReport": { + "EventCollaboratorReplace": { "type": "object", "properties": { - "rows": { - "type": "array", - "items": { - "type": "array", - "items": {} - } - }, - "typed_rows": { + "slugs": { "type": "array", "items": { - "$ref": "#/components/schemas/DamagedLostReportRow" + "type": "string", + "pattern": "^[-a-zA-Z0-9_]+$" } } }, "required": [ - "rows", - "typed_rows" + "slugs" ] }, - "DamagedLostReportRow": { + "EventEligibleMember": { "type": "object", + "description": "A picker row. Name and id only — a roster is not a contact export.", "properties": { - "makerspace_id": { + "member_id": { "type": "integer" }, - "product_name": { + "display_name": { "type": "string" - }, - "damaged_quantity": { - "type": "integer" - }, - "lost_quantity": { - "type": "integer" } }, "required": [ - "damaged_quantity", - "lost_quantity", - "product_name" + "display_name", + "member_id" ] }, - "DamagedMissingReport": { + "EventListResponse": { "type": "object", "properties": { - "rows": { - "type": "array", - "items": { - "type": "array", - "items": {} - } + "count": { + "type": "integer" }, - "typed_rows": { + "next": { + "type": "string", + "nullable": true + }, + "previous": { + "type": "string", + "nullable": true + }, + "results": { "type": "array", "items": { - "$ref": "#/components/schemas/DamagedMissingReportRow" + "$ref": "#/components/schemas/EventAdmin" } } }, "required": [ - "rows", - "typed_rows" + "count", + "results" ] }, - "DamagedMissingReportRow": { + "EventOrganizerList": { "type": "object", "properties": { - "makerspace_id": { - "type": "integer" - }, - "product": { - "type": "string" - }, - "damaged_quantity": { - "type": "integer" - }, - "missing_quantity": { - "type": "integer" + "organizers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EventOrganizerSummary" + }, + "readOnly": true } }, "required": [ - "damaged_quantity", - "missing_quantity", - "product" + "organizers" ] }, - "Dashboard": { + "EventOrganizerReplace": { "type": "object", "properties": { - "scope_mode": { - "$ref": "#/components/schemas/ScopeModeEnum" - }, - "overdue_loans": { - "type": "integer", - "default": 0 - }, - "pending_requests": { - "type": "integer", - "default": 0 - }, - "awaiting_issue": { - "type": "integer", - "default": 0 - }, - "open_problem_reports": { - "type": "integer", - "default": 0 - }, - "low_stock": { - "type": "integer", - "default": 0 - }, - "pending_prints": { - "type": "integer", - "default": 0 - }, - "active_prints": { - "type": "integer", - "default": 0 - }, - "prints_awaiting_collection": { - "type": "integer", - "default": 0 - }, - "failed_emails": { - "type": "integer", - "default": 0 - }, - "stocktakes_awaiting_approval": { - "type": "integer", - "default": 0 - }, - "warranty_expiring": { - "type": "integer", - "default": 0 - }, - "maintenance_overdue": { - "type": "integer", - "default": 0 - }, - "pending_payments": { - "type": "integer", - "default": 0 + "organization_ids": { + "type": "array", + "items": { + "type": "integer", + "minimum": 1 + }, + "maxItems": 50 } }, "required": [ - "scope_mode" + "organization_ids" ] }, - "DataExportCreate": { - "type": "object", - "properties": { - "fidelity": { - "allOf": [ - { - "$ref": "#/components/schemas/FidelityEnum" - } - ], - "default": "REDACTED" - } - } - }, - "DataExportDownloadUrl": { + "EventOrganizerSummary": { "type": "object", "properties": { - "url": { + "id": { + "type": "integer", + "readOnly": true + }, + "slug": { "type": "string", - "format": "uri" + "readOnly": true, + "pattern": "^[-a-zA-Z0-9_]+$" }, - "expires_at": { + "name": { "type": "string", - "format": "date-time" + "readOnly": true } }, "required": [ - "expires_at", - "url" + "id", + "name", + "slug" ] }, - "DataExportJob": { + "EventRegistrationAdmin": { "type": "object", "properties": { "id": { - "type": "string", - "format": "uuid", - "readOnly": true - }, - "fidelity": { - "type": "string", - "readOnly": true - }, - "status": { - "allOf": [ - { - "$ref": "#/components/schemas/StatusE1dEnum" - } - ], + "type": "integer", "readOnly": true }, - "manifest": { + "event_id": { + "type": "integer", "readOnly": true }, - "failure_code": { - "readOnly": true, - "oneOf": [ - { - "$ref": "#/components/schemas/FailureCodeEnum" - }, - { - "$ref": "#/components/schemas/BlankEnum" - } - ] - }, - "failure_detail": { + "name": { "type": "string", "readOnly": true }, - "deadline_at": { - "type": "string", - "format": "date-time", - "readOnly": true, - "nullable": true - }, - "snapshot_at": { + "email": { "type": "string", - "format": "date-time", - "readOnly": true, - "nullable": true + "readOnly": true }, - "started_at": { + "phone": { "type": "string", - "format": "date-time", - "readOnly": true, - "nullable": true + "readOnly": true }, - "completed_at": { - "type": "string", - "format": "date-time", + "custom_answers": { "readOnly": true, "nullable": true }, - "expires_at": { - "type": "string", - "format": "date-time", + "status": { + "allOf": [ + { + "$ref": "#/components/schemas/EventRegistrationAdminStatusEnum" + } + ], "readOnly": true }, "created_at": { "type": "string", "format": "date-time", "readOnly": true + }, + "payment": { + "allOf": [ + { + "$ref": "#/components/schemas/StaffPaymentSummary" + } + ], + "nullable": true, + "readOnly": true } }, "required": [ - "completed_at", "created_at", - "deadline_at", - "expires_at", - "failure_code", - "failure_detail", - "fidelity", + "custom_answers", + "email", + "event_id", "id", - "manifest", - "snapshot_at", - "started_at", + "name", + "payment", + "phone", "status" ] }, - "DeliveryEnum": { + "EventRegistrationAdminStatusEnum": { "enum": [ - "web", - "device" + "pending_approval", + "registered", + "waitlisted", + "rejected", + "cancelled", + "attended" ], "type": "string", - "description": "* `web` - Web\n* `device` - Device" + "description": "* `pending_approval` - Pending approval\n* `registered` - Registered\n* `waitlisted` - Waitlisted\n* `rejected` - Rejected\n* `cancelled` - Cancelled\n* `attended` - Attended" }, - "DeploymentIdentity": { + "EventRegistrationCounts": { "type": "object", "properties": { - "algorithm": { - "type": "string" + "pending_approval": { + "type": "integer", + "readOnly": true }, - "deployment_id": { - "type": "string" + "registered": { + "type": "integer", + "readOnly": true }, - "public_key": { - "type": "string" + "waitlisted": { + "type": "integer", + "readOnly": true }, - "fingerprint": { - "type": "string" + "rejected": { + "type": "integer", + "readOnly": true }, - "age_recipient": { - "type": "string" + "cancelled": { + "type": "integer", + "readOnly": true + }, + "attended": { + "type": "integer", + "readOnly": true } }, "required": [ - "age_recipient", - "algorithm", - "deployment_id", - "fingerprint", - "public_key" + "attended", + "cancelled", + "pending_approval", + "registered", + "rejected", + "waitlisted" ] }, - "DestinationScope": { + "EventRegistrationListResponse": { "type": "object", "properties": { - "machine_type_ids": { - "type": "array", - "items": { - "type": "integer" - } + "count": { + "type": "integer" }, - "machine_ids": { - "type": "array", - "items": { - "type": "integer" - } + "next": { + "type": "string", + "nullable": true }, - "category_ids": { + "previous": { + "type": "string", + "nullable": true + }, + "results": { "type": "array", "items": { - "type": "integer" + "$ref": "#/components/schemas/EventRegistrationAdmin" } } - } - }, - "DeviceChallengeResponse": { - "type": "object", - "properties": { - "challenge": { - "type": "string" - }, - "expires_in": { - "type": "integer", - "minimum": 1 - } }, "required": [ - "challenge", - "expires_in" + "count", + "results" ] }, - "DeviceGrant": { + "EventSeriesDetail": { "type": "object", "properties": { "id": { - "type": "string", - "format": "uuid" + "type": "integer", + "readOnly": true }, - "platform": { - "type": "string" + "public_token": { + "type": "string", + "format": "uuid", + "readOnly": true }, - "app_id": { - "type": "string" + "makerspace_id": { + "type": "integer", + "readOnly": true }, - "environment": { - "type": "string" + "title": { + "type": "string", + "readOnly": true }, "status": { - "type": "string" + "allOf": [ + { + "$ref": "#/components/schemas/StatusFbbEnum" + } + ], + "readOnly": true }, - "attested_at": { + "recurrence_timezone": { "type": "string", - "format": "date-time" + "readOnly": true }, - "last_used_at": { + "dtstart_local_date": { "type": "string", - "format": "date-time" + "format": "date", + "readOnly": true }, - "created_at": { + "dtstart_local_time": { "type": "string", - "format": "date-time" - } - }, - "required": [ - "app_id", - "attested_at", - "created_at", - "environment", - "id", - "last_used_at", - "platform", - "status" - ] - }, - "DeviceIdentity": { - "type": "object", - "properties": { - "platform": { - "$ref": "#/components/schemas/PlatformEnum" + "format": "time", + "readOnly": true }, - "app_id": { + "recurrence_rule": { "type": "string", - "maxLength": 255, - "pattern": "^[A-Za-z0-9._-]{3,255}$" + "readOnly": true }, - "environment": { - "$ref": "#/components/schemas/EnvironmentEnum" - } - }, - "required": [ - "app_id", - "environment", - "platform" - ] - }, - "DeviceLogin": { - "type": "object", - "properties": { - "platform": { - "$ref": "#/components/schemas/PlatformEnum" + "duration_minutes": { + "type": "integer", + "readOnly": true }, - "app_id": { + "revision": { + "type": "integer", + "readOnly": true + }, + "next_occurrence_at": { "type": "string", - "maxLength": 255, - "pattern": "^[A-Za-z0-9._-]{3,255}$" + "format": "date-time", + "readOnly": true }, - "environment": { - "$ref": "#/components/schemas/EnvironmentEnum" + "future_occurrence_count": { + "type": "integer", + "readOnly": true }, - "username": { + "last_materialized_at": { "type": "string", - "maxLength": 254 + "format": "date-time", + "readOnly": true, + "nullable": true }, - "password": { + "last_generation_error_code": { "type": "string", - "writeOnly": true, - "maxLength": 1024 + "readOnly": true }, - "challenge": { + "updated_at": { "type": "string", - "maxLength": 512 + "format": "date-time", + "readOnly": true }, - "attestation": {} - }, - "required": [ - "app_id", - "attestation", - "challenge", - "environment", - "password", - "platform", - "username" - ] - }, - "DeviceLogoutResponse": { - "type": "object", - "properties": { - "detail": { + "description": { "type": "string" - } - }, - "required": [ - "detail" - ] - }, - "DeviceRefresh": { - "type": "object", - "properties": { - "refresh": { + }, + "location": { "type": "string", - "writeOnly": true, - "maxLength": 4096 + "maxLength": 255 + }, + "location_kind": { + "$ref": "#/components/schemas/LocationKindEnum" + }, + "custom_form": { + "nullable": true + }, + "capacity": { + "type": "integer", + "maximum": 2147483647, + "minimum": 0 + }, + "payment_amount": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" + }, + "registration_requires_approval": { + "type": "boolean" + }, + "registration_cutoff_lead_minutes": { + "type": "integer", + "maximum": 2147483647, + "minimum": 0, + "nullable": true + }, + "is_public": { + "type": "boolean" + }, + "created_by_id": { + "type": "integer", + "readOnly": true, + "nullable": true + }, + "created_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "image_url": { + "type": "string", + "format": "uri", + "readOnly": true } }, "required": [ - "refresh" + "created_at", + "created_by_id", + "dtstart_local_date", + "dtstart_local_time", + "duration_minutes", + "future_occurrence_count", + "id", + "image_url", + "last_generation_error_code", + "last_materialized_at", + "makerspace_id", + "next_occurrence_at", + "public_token", + "recurrence_rule", + "recurrence_timezone", + "revision", + "status", + "title", + "updated_at" ] }, - "DeviceRefreshResponse": { + "EventSeriesListResponse": { "type": "object", "properties": { - "access": { - "type": "string" + "count": { + "type": "integer" }, - "refresh": { - "type": "string" + "next": { + "type": "string", + "nullable": true }, - "device_grant": { - "$ref": "#/components/schemas/DeviceGrant" + "previous": { + "type": "string", + "nullable": true + }, + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EventSeriesSummary" + } } }, "required": [ - "access", - "device_grant", - "refresh" + "count", + "next", + "previous", + "results" ] }, - "DeviceTokenResponse": { + "EventSeriesMutationResponse": { "type": "object", "properties": { - "access": { - "type": "string" + "series": { + "allOf": [ + { + "$ref": "#/components/schemas/EventSeriesDetail" + } + ], + "readOnly": true }, - "refresh": { - "type": "string" + "created_occurrence_ids": { + "type": "array", + "items": { + "type": "integer" + }, + "readOnly": true }, - "user": { - "type": "object", - "additionalProperties": {} + "removed_occurrence_ids": { + "type": "array", + "items": { + "type": "integer" + }, + "readOnly": true }, - "device_grant": { - "$ref": "#/components/schemas/DeviceGrant" + "affected_count": { + "type": "integer", + "readOnly": true } }, "required": [ - "access", - "device_grant", - "refresh", - "user" + "affected_count", + "created_occurrence_ids", + "removed_occurrence_ids", + "series" ] }, - "DirectLoan": { + "EventSeriesSummary": { "type": "object", "properties": { + "id": { + "type": "integer", + "readOnly": true + }, "public_token": { "type": "string", "format": "uuid", "readOnly": true }, - "status": { - "type": "string", + "makerspace_id": { + "type": "integer", "readOnly": true }, - "items": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PublicToolLoanItem" - }, + "title": { + "type": "string", "readOnly": true }, - "id": { - "type": "integer", + "status": { + "allOf": [ + { + "$ref": "#/components/schemas/StatusFbbEnum" + } + ], "readOnly": true }, - "target_type": { + "recurrence_timezone": { "type": "string", "readOnly": true }, - "target_label": { + "dtstart_local_date": { "type": "string", + "format": "date", "readOnly": true }, - "container_id": { - "type": "integer", - "readOnly": true - }, - "container_label": { + "dtstart_local_time": { "type": "string", - "nullable": true, + "format": "time", "readOnly": true }, - "due_at": { + "recurrence_rule": { "type": "string", - "format": "date-time", - "readOnly": true, - "nullable": true + "readOnly": true }, - "issue_evidence_id": { + "duration_minutes": { "type": "integer", - "readOnly": true, - "nullable": true + "readOnly": true }, - "return_evidence_id": { + "revision": { "type": "integer", - "readOnly": true, - "nullable": true + "readOnly": true }, - "return_notes": { + "next_occurrence_at": { "type": "string", + "format": "date-time", "readOnly": true }, - "return_scan_required": { - "type": "boolean", + "future_occurrence_count": { + "type": "integer", "readOnly": true }, - "source": { + "last_materialized_at": { "type": "string", - "readOnly": true + "format": "date-time", + "readOnly": true, + "nullable": true }, - "issued_by": { - "allOf": [ - { - "$ref": "#/components/schemas/DirectLoanUserAttribution" - } - ], - "nullable": true, + "last_generation_error_code": { + "type": "string", "readOnly": true }, - "return_items": { - "type": "array", - "items": { - "$ref": "#/components/schemas/DirectLoanReturnItem" - }, + "updated_at": { + "type": "string", + "format": "date-time", "readOnly": true } }, "required": [ - "container_id", - "container_label", - "due_at", + "dtstart_local_date", + "dtstart_local_time", + "duration_minutes", + "future_occurrence_count", "id", - "issue_evidence_id", - "issued_by", - "items", + "last_generation_error_code", + "last_materialized_at", + "makerspace_id", + "next_occurrence_at", "public_token", - "return_evidence_id", - "return_items", - "return_notes", - "return_scan_required", - "source", + "recurrence_rule", + "recurrence_timezone", + "revision", "status", - "target_label", - "target_type" + "title", + "updated_at" ] }, - "DirectLoanIssue": { + "EventSeriesWrite": { "type": "object", "properties": { - "borrower_id": { - "type": "integer" + "title": { + "type": "string", + "maxLength": 200 }, - "evidence_id": { - "type": "integer" + "description": { + "type": "string", + "default": "" }, - "remark": { - "type": "string" + "location": { + "type": "string", + "default": "", + "maxLength": 255 }, - "container_id": { + "location_kind": { + "allOf": [ + { + "$ref": "#/components/schemas/LocationKindEnum" + } + ], + "default": "other" + }, + "custom_form": { + "nullable": true + }, + "capacity": { + "type": "integer", + "minimum": 0, + "default": 0 + }, + "payment_amount": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", + "default": "0.00" + }, + "registration_requires_approval": { + "type": "boolean", + "default": false + }, + "registration_cutoff_lead_minutes": { "type": "integer", + "minimum": 0, "nullable": true }, - "qr_payloads": { - "type": "array", - "items": { - "type": "string", - "maxLength": 64 - }, - "maxItems": 50 + "is_public": { + "type": "boolean", + "default": false }, - "items": { - "type": "array", - "items": { - "$ref": "#/components/schemas/DirectLoanItem" - } + "recurrence_timezone": { + "type": "string", + "maxLength": 64 + }, + "dtstart_local_date": { + "type": "string", + "format": "date" + }, + "dtstart_local_time": { + "type": "string", + "format": "time" + }, + "recurrence_rule": { + "type": "string", + "maxLength": 500 + }, + "duration_minutes": { + "type": "integer", + "minimum": 1 + }, + "effective_from": { + "type": "string", + "format": "date-time", + "writeOnly": true } }, "required": [ - "borrower_id", - "evidence_id" + "dtstart_local_date", + "dtstart_local_time", + "duration_minutes", + "recurrence_rule", + "recurrence_timezone", + "title" ] }, - "DirectLoanItem": { + "EventStaffRegistration": { "type": "object", + "description": "Staff registering a member of this makerspace for an event.\n\n`member_id` only. Contact details are copied off the account by the registration\nservice, so a staffer cannot record an attendee under a name and email that belong\nto nobody — which is what makes the attendee list usable as an accountability record\nrather than free text.", "properties": { - "product_id": { + "member_id": { "type": "integer" }, - "quantity": { - "type": "integer", - "minimum": 1 + "custom_answers": { + "nullable": true + }, + "phone": { + "type": "string", + "default": "", + "maxLength": 32 + }, + "email": { + "oneOf": [ + { + "type": "string", + "format": "email", + "default": "" + }, + { + "type": "string", + "maxLength": 0 + } + ] } }, "required": [ - "product_id", - "quantity" + "member_id" ] }, - "DirectLoanMember": { + "EventWrite": { "type": "object", "properties": { - "membership_id": { - "type": "integer", - "readOnly": true + "title": { + "type": "string", + "maxLength": 200 }, - "user_id": { - "type": "integer", - "readOnly": true + "description": { + "type": "string", + "default": "" + }, + "starts_at": { + "type": "string", + "format": "date-time" + }, + "ends_at": { + "type": "string", + "format": "date-time" + }, + "timezone_name": { + "type": "string", + "maxLength": 64 }, - "display_name": { + "location": { "type": "string", - "readOnly": true + "default": "", + "maxLength": 255 }, - "username": { + "location_kind": { + "allOf": [ + { + "$ref": "#/components/schemas/LocationKindEnum" + } + ], + "default": "other" + }, + "custom_form": { + "nullable": true + }, + "capacity": { + "type": "integer", + "minimum": 0, + "default": 0 + }, + "payment_amount": { "type": "string", - "readOnly": true + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", + "default": "0.00" }, - "is_walk_in": { + "is_public": { "type": "boolean", - "readOnly": true - } - }, - "required": [ - "display_name", - "is_walk_in", - "membership_id", - "user_id", - "username" - ] - }, - "DirectLoanReturn": { - "type": "object", - "properties": { - "evidence_id": { - "type": "integer" + "default": false }, - "notes": { - "type": "string" + "registration_requires_approval": { + "type": "boolean", + "default": false }, - "qr_payload": { + "registration_cutoff_at": { "type": "string", - "maxLength": 64 + "format": "date-time", + "nullable": true }, - "resolutions": { + "registration_cutoff_lead_minutes": { + "type": "integer", + "minimum": 0, + "nullable": true + }, + "inherit_fields": { "type": "array", "items": { - "$ref": "#/components/schemas/ReturnItemResolution" - } + "$ref": "#/components/schemas/InheritFieldsEnum" + }, + "writeOnly": true } }, "required": [ - "evidence_id", - "notes" + "ends_at", + "starts_at", + "title" ] }, - "DirectLoanReturnAsset": { + "EvidenceGetResponse": { "type": "object", "properties": { - "asset_id": { - "type": "integer" + "url": { + "type": "string", + "format": "uri" }, - "asset_tag": { - "type": "string" + "expires_in": { + "type": "integer" } }, "required": [ - "asset_id", - "asset_tag" + "expires_in", + "url" ] }, - "DirectLoanReturnItem": { + "EvidenceRetentionPolicy": { "type": "object", "properties": { - "item_id": { + "makerspace_id": { "type": "integer" }, - "product_name": { - "type": "string" - }, - "remaining_quantity": { + "platform_default_days": { "type": "integer" }, - "tracking_mode": { - "type": "string" + "override_days": { + "type": "integer", + "nullable": true }, - "assets": { - "type": "array", - "items": { - "$ref": "#/components/schemas/DirectLoanReturnAsset" - } + "effective_days": { + "type": "integer" + }, + "object_expiry_enabled": { + "type": "boolean" } }, "required": [ - "assets", - "item_id", - "product_name", - "remaining_quantity", - "tracking_mode" + "effective_days", + "makerspace_id", + "object_expiry_enabled", + "override_days", + "platform_default_days" ] }, - "DirectLoanUserAttribution": { + "EvidenceRetentionPreviewRequest": { "type": "object", "properties": { - "username": { - "type": "string" - }, - "role": { - "type": "string" + "limit": { + "type": "integer", + "maximum": 1000, + "minimum": 1, + "default": 100 } - }, - "required": [ - "role", - "username" - ] + } }, - "Directory": { + "EvidenceRetentionPreviewResponse": { "type": "object", "properties": { - "members": { - "type": "array", - "items": { - "$ref": "#/components/schemas/DirectoryEntry" - } + "as_of": { + "type": "string", + "format": "date-time" }, - "hidden_count": { + "policy_days": { + "type": "integer" + }, + "cutoff": { + "type": "string", + "format": "date-time" + }, + "object_candidates": { + "type": "integer" + }, + "candidate_bytes": { "type": "integer" + }, + "has_more": { + "type": "boolean" } }, "required": [ - "hidden_count", - "members" + "as_of", + "candidate_bytes", + "cutoff", + "has_more", + "object_candidates", + "policy_days" ] }, - "DirectoryEntry": { + "EvidenceUrlRequest": { "type": "object", - "description": "The listing row. Display name and avatar only -- never email, never phone.\n\nOtherwise \"see who else is here\" is an address harvest performed by anyone a space\nadmits, which is a different thing from what the member agreed to publish.", "properties": { - "membership_id": { - "type": "integer" - }, - "display_name": { - "type": "string" + "evidence_type": { + "$ref": "#/components/schemas/EvidenceUrlRequestEvidenceTypeEnum" }, - "headline": { + "content_type": { "type": "string" }, - "avatar_url": { - "type": "string", + "size_bytes": { + "type": "integer", + "minimum": 0, "nullable": true } }, "required": [ - "avatar_url", - "display_name", - "headline", - "membership_id" + "content_type", + "evidence_type" ] }, - "DispositionEnum": { - "enum": [ - "needs_fix", - "remove" - ], - "type": "string", - "description": "* `needs_fix` - needs_fix\n* `remove` - remove" - }, - "DocTypeEnum": { + "EvidenceUrlRequestEvidenceTypeEnum": { "enum": [ - "manual", - "sop" + "issue", + "return" ], "type": "string", - "description": "* `manual` - Manual\n* `sop` - SOP" + "description": "* `issue` - Issue\n* `return` - Return" }, - "DocumentFinalize": { + "EvidenceUrlResponse": { "type": "object", "properties": { - "object_key": { - "type": "string", - "maxLength": 255 + "evidence_id": { + "type": "integer" }, - "doc_type": { + "upload_url": { "type": "string", - "maxLength": 16 + "format": "uri" }, - "original_filename": { - "type": "string", - "maxLength": 255 + "fields": { + "type": "object", + "additionalProperties": {} + }, + "object_key": { + "type": "string" + }, + "method": { + "type": "string" + }, + "headers": { + "type": "object", + "additionalProperties": {} } }, "required": [ - "doc_type", + "evidence_id", + "fields", "object_key", - "original_filename" + "upload_url" ] }, - "DocumentPresign": { + "FabLabHealthReport": { "type": "object", "properties": { - "filename": { - "type": "string", - "maxLength": 255 + "rows": { + "type": "array", + "items": { + "type": "array", + "items": {} + } }, - "content_type": { - "type": "string", - "maxLength": 100 + "typed_rows": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FabLabHealthRow" + } } }, "required": [ - "content_type", - "filename" + "rows", + "typed_rows" ] }, - "DomainVerificationRecord": { + "FabLabHealthRow": { "type": "object", "properties": { - "host": { - "type": "string" + "makerspace_id": { + "type": "integer" }, - "type": { - "type": "string" + "events_enabled": { + "type": "boolean" }, - "value": { - "type": "string" - } - }, - "required": [ - "host", - "type", - "value" - ] - }, - "DomainVerificationResponse": { - "type": "object", - "properties": { - "status": { - "$ref": "#/components/schemas/StatusA39Enum" + "events_available": { + "type": "boolean" }, - "token": { - "type": "string" + "events_in_period": { + "type": "integer", + "nullable": true }, - "expected_record": { - "allOf": [ - { - "$ref": "#/components/schemas/DomainVerificationRecord" - } - ], + "events_registrations": { + "type": "integer", "nullable": true }, - "verified_at": { - "type": "string", - "format": "date-time", + "events_attended": { + "type": "integer", "nullable": true }, - "detail": { - "type": "string" - } - }, - "required": [ - "detail", - "expected_record", - "status", - "token", - "verified_at" - ] - }, - "EducationEntry": { - "type": "object", - "properties": { - "institution": { - "type": "string", - "maxLength": 200 + "events_completed_attendance_rate_percent": { + "type": "number", + "format": "double", + "nullable": true }, - "qualification": { - "type": "string", - "default": "", - "maxLength": 200 + "bookings_enabled": { + "type": "boolean" }, - "year": { - "type": "string", - "default": "", - "maxLength": 20 - } - }, - "required": [ - "institution" - ] - }, - "EmailLog": { - "type": "object", - "properties": { - "id": { + "bookings_available": { + "type": "boolean" + }, + "bookings_active_spaces": { "type": "integer", - "readOnly": true + "nullable": true }, - "to_email": { - "type": "string", - "readOnly": true + "bookings_non_cancelled": { + "type": "integer", + "nullable": true }, - "subject": { + "bookings_reserved_hours": { "type": "string", - "readOnly": true + "format": "decimal", + "pattern": "^-?\\d{0,18}(?:\\.\\d{0,2})?$", + "nullable": true }, - "stream": { - "type": "string", - "readOnly": true + "bookings_upcoming": { + "type": "integer", + "nullable": true }, - "event": { - "type": "string", - "readOnly": true + "bookings_no_shows": { + "type": "integer", + "nullable": true }, - "audience": { - "type": "string", - "readOnly": true + "bookings_reservation_utilization_percent": { + "type": "number", + "format": "double", + "nullable": true }, - "status": { - "allOf": [ - { - "$ref": "#/components/schemas/EmailLogStatusEnum" - } - ], - "readOnly": true + "machines_enabled": { + "type": "boolean" }, - "error": { - "type": "string", - "readOnly": true + "machines_available": { + "type": "boolean" }, - "attempts": { + "machines_active": { "type": "integer", - "readOnly": true + "nullable": true }, - "created_at": { + "machines_usage_hours": { "type": "string", - "format": "date-time", - "readOnly": true + "format": "decimal", + "pattern": "^-?\\d{0,18}(?:\\.\\d{0,2})?$", + "nullable": true }, - "sent_at": { + "maintenance_enabled": { + "type": "boolean" + }, + "maintenance_available": { + "type": "boolean" + }, + "maintenance_logs": { + "type": "integer", + "nullable": true + }, + "maintenance_total_cost": { "type": "string", - "format": "date-time", - "readOnly": true, + "format": "decimal", + "pattern": "^-?\\d{0,18}(?:\\.\\d{0,2})?$", + "nullable": true + }, + "maintenance_overdue_schedules": { + "type": "integer", "nullable": true } }, "required": [ - "attempts", - "audience", - "created_at", - "error", - "event", - "id", - "sent_at", - "status", - "stream", - "subject", - "to_email" + "bookings_active_spaces", + "bookings_available", + "bookings_enabled", + "bookings_no_shows", + "bookings_non_cancelled", + "bookings_reservation_utilization_percent", + "bookings_reserved_hours", + "bookings_upcoming", + "events_attended", + "events_available", + "events_completed_attendance_rate_percent", + "events_enabled", + "events_in_period", + "events_registrations", + "machines_active", + "machines_available", + "machines_enabled", + "machines_usage_hours", + "maintenance_available", + "maintenance_enabled", + "maintenance_logs", + "maintenance_overdue_schedules", + "maintenance_total_cost" ] }, - "EmailLogStatusEnum": { + "FailureCodeEnum": { "enum": [ - "pending", - "sending", - "sent", - "failed", - "skipped" + "deadline_exceeded", + "integrity_error", + "storage_error", + "quota_exceeded", + "internal_error" ], "type": "string", - "description": "* `pending` - Pending\n* `sending` - Sending\n* `sent` - Sent\n* `failed` - Failed\n* `skipped` - Skipped" + "description": "* `` - None\n* `deadline_exceeded` - Deadline exceeded\n* `integrity_error` - Integrity error\n* `storage_error` - Storage error\n* `quota_exceeded` - Quota exceeded\n* `internal_error` - Internal error" }, - "EmailTemplateDetail": { - "type": "object", - "properties": { - "stream": { - "type": "string", - "readOnly": true - }, - "audience": { - "type": "string", - "readOnly": true - }, - "key": { - "type": "string", - "readOnly": true - }, - "label": { - "type": "string", - "readOnly": true - }, - "description": { - "type": "string", - "readOnly": true - }, - "fields": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": {} - }, - "readOnly": true - }, - "subject": { - "type": "string", - "readOnly": true - }, - "text_body": { - "type": "string", - "readOnly": true - }, - "html_body": { - "type": "string", - "readOnly": true - }, - "is_active": { - "type": "boolean", + "FeatureEnum": { + "enum": [ + "hardware_requests", + "printing", + "events", + "bookings", + "maintenance", + "members" + ], + "type": "string", + "description": "* `hardware_requests` - Hardware requests\n* `printing` - Printing\n* `events` - Events\n* `bookings` - Bookings\n* `maintenance` - Maintenance\n* `members` - Members" + }, + "FeedbackForm": { + "type": "object", + "properties": { + "event": { + "type": "object", + "additionalProperties": {}, "readOnly": true }, - "is_overridden": { - "type": "boolean", + "survey": { + "allOf": [ + { + "$ref": "#/components/schemas/FeedbackSurvey" + } + ], "readOnly": true }, - "default_subject": { - "type": "string", + "mode": { + "allOf": [ + { + "$ref": "#/components/schemas/FeedbackFormModeEnum" + } + ], "readOnly": true }, - "default_text": { - "type": "string", + "requires_auth": { + "type": "boolean", "readOnly": true }, - "default_html": { - "type": "string", - "readOnly": true + "certificate": { + "allOf": [ + { + "$ref": "#/components/schemas/CertificateSummary" + } + ], + "readOnly": true, + "nullable": true } }, "required": [ - "audience", - "default_html", - "default_subject", - "default_text", - "description", - "fields", - "html_body", - "is_active", - "is_overridden", - "key", - "label", - "stream", - "subject", - "text_body" + "certificate", + "event", + "mode", + "requires_auth", + "survey" ] }, - "EmailTemplateListItem": { + "FeedbackFormModeEnum": { + "enum": [ + "anonymous", + "certificate" + ], + "type": "string", + "description": "* `anonymous` - anonymous\n* `certificate` - certificate" + }, + "FeedbackResponse": { "type": "object", "properties": { - "stream": { - "type": "string", - "readOnly": true - }, - "audience": { - "type": "string", + "id": { + "type": "integer", "readOnly": true }, - "key": { - "type": "string", + "answers": { "readOnly": true }, - "label": { + "created_at": { "type": "string", + "format": "date-time", "readOnly": true }, - "is_active": { - "type": "boolean", - "readOnly": true - }, - "is_overridden": { - "type": "boolean", - "readOnly": true - }, - "can_edit_space_default": { - "type": "boolean", + "identity": { + "type": "object", + "additionalProperties": {}, + "nullable": true, "readOnly": true }, - "overridable_types": { - "type": "array", - "items": { - "$ref": "#/components/schemas/MachineTypeOption" - }, + "certificate": { + "allOf": [ + { + "$ref": "#/components/schemas/CertificateSummary" + } + ], + "nullable": true, "readOnly": true } }, "required": [ - "audience", - "can_edit_space_default", - "is_active", - "is_overridden", - "key", - "label", - "overridable_types", - "stream" + "answers", + "certificate", + "created_at", + "id", + "identity" ] }, - "EmailTemplatePreviewRequest": { + "FeedbackResponseList": { "type": "object", "properties": { - "stream": { - "type": "string" - }, - "audience": { - "type": "string" - }, - "key": { - "type": "string" - }, - "machine_type_id": { + "count": { "type": "integer" }, - "subject": { + "next": { "type": "string", - "maxLength": 200 + "format": "uri", + "nullable": true }, - "text_body": { - "type": "string" + "previous": { + "type": "string", + "format": "uri", + "nullable": true }, - "html_body": { - "type": "string" + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FeedbackResponse" + } } }, "required": [ - "audience", - "html_body", - "key", - "stream", - "subject", - "text_body" + "count", + "next", + "previous", + "results" ] }, - "EmailTemplatePreviewResponse": { + "FeedbackSubmission": { "type": "object", "properties": { - "subject": { - "type": "string", - "readOnly": true - }, - "text_body": { - "type": "string", - "readOnly": true + "answers": { + "type": "object", + "additionalProperties": {} }, - "html_body": { + "email": { "type": "string", - "readOnly": true + "format": "email" } - }, - "required": [ - "html_body", - "subject", - "text_body" - ] + } }, - "EmailVerificationConfirm": { + "FeedbackSubmissionResponse": { "type": "object", "properties": { - "code": { - "type": "string", - "maxLength": 6 + "thank_you_text": { + "type": "string" + }, + "certificate": { + "allOf": [ + { + "$ref": "#/components/schemas/CertificateSummary" + } + ], + "nullable": true } }, "required": [ - "code" + "certificate", + "thank_you_text" ] }, - "EndReasonEnum": { - "enum": [ - "superseded", - "membership_revoked", - "claim_revoked", - "user_ended" - ], - "type": "string", - "description": "* `superseded` - Superseded\n* `membership_revoked` - Membership revoked\n* `claim_revoked` - Claim revoked\n* `user_ended` - User ended" - }, - "EnvironmentEnum": { - "enum": [ - "development", - "production" - ], - "type": "string", - "description": "* `development` - Development\n* `production` - Production" - }, - "Error": { - "type": "object", - "properties": { - "detail": { - "type": "string" - } - } - }, - "EventAdmin": { + "FeedbackSurvey": { "type": "object", "properties": { "id": { "type": "integer", "readOnly": true }, - "makerspace_id": { - "type": "integer", - "readOnly": true - }, "title": { "type": "string", "readOnly": true }, - "description": { + "thank_you_text": { "type": "string", "readOnly": true }, - "starts_at": { - "type": "string", - "format": "date-time", + "questions": { "readOnly": true }, - "ends_at": { - "type": "string", - "format": "date-time", + "is_open": { + "type": "boolean", "readOnly": true }, - "location": { - "type": "string", + "certificate_enabled": { + "type": "boolean", "readOnly": true }, - "location_kind": { - "allOf": [ - { - "$ref": "#/components/schemas/LocationKindEnum" - } - ], + "answered_question_ids": { + "type": "array", + "items": { + "type": "string" + }, "readOnly": true }, - "custom_form": { + "opened_at": { + "type": "string", + "format": "date-time", "readOnly": true, "nullable": true }, - "capacity": { - "type": "integer", - "readOnly": true - }, - "payment_amount": { + "closed_at": { "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", - "readOnly": true - }, - "is_public": { - "type": "boolean", - "readOnly": true + "format": "date-time", + "readOnly": true, + "nullable": true }, - "image_url": { - "type": "string", - "format": "uri", - "nullable": true, + "response_count": { + "type": "integer", "readOnly": true - }, - "status": { + } + }, + "required": [ + "answered_question_ids", + "certificate_enabled", + "closed_at", + "id", + "is_open", + "opened_at", + "questions", + "response_count", + "thank_you_text", + "title" + ] + }, + "FeedbackSurveyAdminEnvelope": { + "type": "object", + "properties": { + "survey": { "allOf": [ { - "$ref": "#/components/schemas/EventAdminStatusEnum" + "$ref": "#/components/schemas/FeedbackSurvey" } ], - "readOnly": true - }, - "created_by_id": { - "type": "integer", - "readOnly": true, "nullable": true - }, - "created_at": { + } + }, + "required": [ + "survey" + ] + }, + "FeedbackSurveyWrite": { + "type": "object", + "properties": { + "title": { "type": "string", - "format": "date-time", - "readOnly": true + "maxLength": 200 }, - "updated_at": { + "thank_you_text": { "type": "string", - "format": "date-time", - "readOnly": true - }, - "registration_counts": { - "allOf": [ - { - "$ref": "#/components/schemas/EventRegistrationCounts" - } - ], - "readOnly": true + "default": "", + "maxLength": 2000 }, - "organizers": { - "type": "array", - "items": { - "$ref": "#/components/schemas/EventOrganizerSummary" - }, - "readOnly": true + "questions": {}, + "certificate_enabled": { + "type": "boolean", + "default": false } }, "required": [ - "capacity", - "created_at", - "created_by_id", - "custom_form", - "description", - "ends_at", - "id", - "image_url", - "is_public", - "location", - "location_kind", - "makerspace_id", - "organizers", - "payment_amount", - "registration_counts", - "starts_at", - "status", - "title", - "updated_at" + "questions", + "title" ] }, - "EventAdminStatusEnum": { + "FidelityEnum": { "enum": [ - "draft", - "published", - "cancelled", - "completed" + "REDACTED" ], "type": "string", - "description": "* `draft` - Draft\n* `published` - Published\n* `cancelled` - Cancelled\n* `completed` - Completed" + "description": "* `REDACTED` - Readable — audit metadata and form answers redacted; member contact details included" }, - "EventAttendanceReport": { + "FieldValidationError": { "type": "object", + "description": "DRF's field-keyed boundary errors; deliberately not the typed service shape.", "properties": { - "rows": { + "non_field_errors": { "type": "array", "items": { - "type": "array", - "items": {} + "type": "string" } }, - "typed_rows": { + "digest": { "type": "array", "items": { - "$ref": "#/components/schemas/EventAttendanceRow" + "type": "string" } - } - }, - "required": [ - "rows", - "typed_rows" - ] - }, - "EventAttendanceRow": { - "type": "object", - "properties": { - "makerspace_id": { - "type": "integer" - }, - "event_id": { - "type": "integer" - }, - "title": { - "type": "string" - }, - "starts_at": { - "type": "string", - "format": "date-time" - }, - "status": { - "type": "string" - }, - "capacity": { - "type": "integer" - }, - "registrations": { - "type": "integer" }, - "confirmed": { - "type": "integer" + "decisions": { + "type": "array", + "items": { + "type": "string" + } }, - "registered": { - "type": "integer" + "approval_id": { + "type": "array", + "items": { + "type": "string" + } }, - "waitlisted": { - "type": "integer" + "target_age_recipient": { + "type": "array", + "items": { + "type": "string" + } }, - "cancelled": { - "type": "integer" + "archive": { + "type": "array", + "items": { + "type": "string" + } }, - "attended": { - "type": "integer" + "source_archive_digest": { + "type": "array", + "items": { + "type": "string" + } }, - "attendance_rate_percent": { - "type": "number", - "format": "double", - "nullable": true + "target_identity": { + "type": "array", + "items": { + "type": "string" + } }, - "organizers": { - "type": "string" - } - }, - "required": [ - "attendance_rate_percent", - "attended", - "cancelled", - "capacity", - "confirmed", - "event_id", - "organizers", - "registered", - "registrations", - "starts_at", - "status", - "title", - "waitlisted" - ] - }, - "EventCheckInResolveRequest": { - "type": "object", - "properties": { - "checkin_token": { - "type": "string" + "receipt": { + "type": "array", + "items": { + "type": "string" + } } - }, - "required": [ - "checkin_token" - ] + } }, - "EventCheckInResolveResponse": { + "ForgotPasswordRequest": { "type": "object", "properties": { - "registration_id": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "status": { - "type": "string" - }, - "payment_status": { + "email": { "type": "string", - "nullable": true - }, - "host_waiver_state": { - "$ref": "#/components/schemas/HostWaiverStateEnum" - }, - "event_status": { - "type": "string" - }, - "confirmable": { - "type": "boolean" + "format": "email" } }, "required": [ - "confirmable", - "event_status", - "host_waiver_state", - "name", - "payment_status", - "registration_id", - "status" + "email" ] }, - "EventCollaborationInbox": { + "FrontendDomainStatusEnum": { + "enum": [ + "pending", + "verified", + "failed" + ], + "type": "string", + "description": "* `pending` - Pending\n* `verified` - Verified\n* `failed` - Failed" + }, + "GenericAnalyticsReport": { "type": "object", "properties": { - "id": { - "type": "integer", - "readOnly": true - }, - "event_id": { - "type": "integer", - "readOnly": true - }, - "event_title": { - "type": "string", - "readOnly": true - }, - "starts_at": { - "type": "string", - "format": "date-time", - "readOnly": true - }, - "ends_at": { - "type": "string", - "format": "date-time", - "readOnly": true - }, - "host_name": { - "type": "string", - "readOnly": true + "report_key": { + "type": "string" }, - "host_slug": { - "type": "string", - "readOnly": true, - "pattern": "^[-a-zA-Z0-9_]+$" + "rows": { + "type": "array", + "items": { + "type": "array", + "items": {} + } }, - "status": { - "allOf": [ - { - "$ref": "#/components/schemas/StatusB9dEnum" - } - ], - "readOnly": true + "typed_rows": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": {} + } }, - "created_at": { - "type": "string", - "format": "date-time", - "readOnly": true + "meta": { + "type": "object", + "additionalProperties": {} + } + }, + "required": [ + "rows" + ] + }, + "GenericObject": { + "type": "object", + "properties": { + "detail": { + "type": "string" + } + } + }, + "HardwareRequestError": { + "type": "object", + "properties": { + "detail": { + "type": "string" }, - "responded_at": { - "type": "string", - "format": "date-time", - "readOnly": true, - "nullable": true + "code": { + "type": "string" } }, "required": [ - "created_at", - "ends_at", - "event_id", - "event_title", - "host_name", - "host_slug", - "id", - "responded_at", - "starts_at", - "status" + "code", + "detail" ] }, - "EventCollaborationRespond": { + "Health": { "type": "object", "properties": { - "accept": { - "type": "boolean" + "status": { + "type": "string" } }, "required": [ - "accept" + "status" ] }, - "EventCollaborator": { + "HostWaiver": { "type": "object", "properties": { "id": { "type": "integer", "readOnly": true }, - "event_id": { - "type": "integer", - "readOnly": true - }, - "makerspace_id": { - "type": "integer", - "readOnly": true - }, - "makerspace_name": { - "type": "string", - "readOnly": true - }, - "makerspace_slug": { + "version": { "type": "string", - "readOnly": true, - "pattern": "^[-a-zA-Z0-9_]+$" - }, - "status": { - "allOf": [ - { - "$ref": "#/components/schemas/StatusB9dEnum" - } - ], "readOnly": true }, - "invited_by_id": { - "type": "integer", - "readOnly": true, - "nullable": true - }, - "responded_by_id": { - "type": "integer", - "readOnly": true, - "nullable": true - }, - "created_at": { + "body": { "type": "string", - "format": "date-time", "readOnly": true - }, - "responded_at": { - "type": "string", - "format": "date-time", - "readOnly": true, - "nullable": true } }, "required": [ - "created_at", - "event_id", + "body", "id", - "invited_by_id", - "makerspace_id", - "makerspace_name", - "makerspace_slug", - "responded_at", - "responded_by_id", - "status" + "version" ] }, - "EventCollaboratorReplace": { + "HostWaiverStateEnum": { + "enum": [ + "not_required", + "on_file", + "missing" + ], + "type": "string", + "description": "* `not_required` - not_required\n* `on_file` - on_file\n* `missing` - missing" + }, + "HostingError": { "type": "object", "properties": { - "slugs": { - "type": "array", - "items": { - "type": "string", - "pattern": "^[-a-zA-Z0-9_]+$" - } + "detail": { + "type": "string" } }, "required": [ - "slugs" + "detail" ] }, - "EventEligibleMember": { + "IdentityDisclosureDecision": { "type": "object", - "description": "A picker row. Name and id only — a roster is not a contact export.", "properties": { - "member_id": { + "user_id": { "type": "integer" }, - "display_name": { - "type": "string" + "approved": { + "type": "boolean" } }, "required": [ - "display_name", - "member_id" + "approved", + "user_id" ] }, - "EventListResponse": { + "IdentityResolutionEnum": { + "enum": [ + "link_existing", + "create_walk_in" + ], + "type": "string", + "description": "* `link_existing` - Link existing account\n* `create_walk_in` - Create walk-in account" + }, + "ImportCreate": { "type": "object", "properties": { - "count": { - "type": "integer" - }, - "next": { + "archive": { "type": "string", - "nullable": true + "format": "uri", + "writeOnly": true }, - "previous": { + "source_archive_digest": { "type": "string", - "nullable": true - }, - "results": { + "pattern": "^[0-9a-f]{64}$" + } + }, + "required": [ + "archive", + "source_archive_digest" + ] + }, + "ImportDecisionList": { + "type": "object", + "properties": { + "decisions": { "type": "array", "items": { - "$ref": "#/components/schemas/EventAdmin" + "$ref": "#/components/schemas/ImportIdentityDecision" } } }, "required": [ - "count", - "results" + "decisions" ] }, - "EventOrganizerSummary": { + "ImportIdentityDecision": { "type": "object", "properties": { - "slug": { + "source_user_id": { "type": "string", - "readOnly": true, - "pattern": "^[-a-zA-Z0-9_]+$" + "maxLength": 255 }, - "name": { - "type": "string", - "readOnly": true + "identity_resolution": { + "$ref": "#/components/schemas/IdentityResolutionEnum" + }, + "membership_disposition": { + "$ref": "#/components/schemas/MembershipDispositionEnum" + }, + "target_user_id": { + "type": "integer", + "nullable": true } }, "required": [ - "name", - "slug" + "identity_resolution", + "membership_disposition", + "source_user_id" ] }, - "EventRegistrationAdmin": { + "ImportJob": { "type": "object", "properties": { "id": { - "type": "integer", + "type": "string", + "format": "uuid", "readOnly": true }, - "event_id": { - "type": "integer", - "readOnly": true + "source_archive_digest": { + "type": "string", + "maxLength": 64 }, - "name": { + "source_makerspace_id": { "type": "string", - "readOnly": true + "maxLength": 64 }, - "email": { + "source_makerspace_slug": { "type": "string", - "readOnly": true + "maxLength": 100 }, - "phone": { + "source_makerspace_name": { "type": "string", - "readOnly": true + "maxLength": 200 }, - "custom_answers": { - "readOnly": true, - "nullable": true + "source_deployment_id": { + "type": "string", + "maxLength": 128 + }, + "storage_mode": { + "type": "string", + "maxLength": 32 }, "status": { - "allOf": [ - { - "$ref": "#/components/schemas/EventRegistrationAdminStatusEnum" - } - ], + "$ref": "#/components/schemas/ImportJobStatusEnum" + }, + "identity_count": { + "type": "string", + "readOnly": true + }, + "target_lifecycle_state": { + "type": "string", + "nullable": true, "readOnly": true }, + "source_deployment_identity": {}, + "aggregate_outcome": {}, + "failure_code": { + "type": "string", + "maxLength": 64 + }, + "failure_detail": { + "type": "string", + "maxLength": 500 + }, "created_at": { "type": "string", "format": "date-time", "readOnly": true }, - "payment": { - "allOf": [ - { - "$ref": "#/components/schemas/StaffPaymentSummary" - } - ], - "nullable": true, + "updated_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "expires_at": { + "type": "string", + "format": "date-time" + }, + "terminal_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "scrubbed_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "source_retention_notice": { + "type": "string", "readOnly": true } }, "required": [ "created_at", - "custom_answers", - "email", - "event_id", + "expires_at", "id", - "name", - "payment", - "phone", - "status" + "identity_count", + "source_archive_digest", + "source_retention_notice", + "target_lifecycle_state", + "updated_at" ] }, - "EventRegistrationAdminStatusEnum": { + "ImportJobStatusEnum": { "enum": [ - "registered", - "waitlisted", - "cancelled", - "attended" + "pending", + "awaiting_identity", + "ready", + "materializing", + "finalizing", + "completed", + "failed", + "abandoned" ], "type": "string", - "description": "* `registered` - Registered\n* `waitlisted` - Waitlisted\n* `cancelled` - Cancelled\n* `attended` - Attended" + "description": "* `pending` - Pending\n* `awaiting_identity` - Awaiting identity decisions\n* `ready` - Ready\n* `materializing` - Materializing\n* `finalizing` - Finalizing\n* `completed` - Completed\n* `failed` - Failed\n* `abandoned` - Abandoned" }, - "EventRegistrationCounts": { + "ImportRun": { "type": "object", "properties": { - "registered": { - "type": "integer", - "readOnly": true - }, - "waitlisted": { - "type": "integer", - "readOnly": true + "target_identity": { + "type": "object", + "additionalProperties": {} + } + } + }, + "InheritFieldsEnum": { + "enum": [ + "capacity", + "custom_form", + "description", + "ends_at", + "image_key", + "is_public", + "location", + "location_kind", + "payment_amount", + "registration_cutoff_at", + "registration_cutoff_lead_minutes", + "registration_requires_approval", + "starts_at", + "title" + ], + "type": "string", + "description": "* `capacity` - capacity\n* `custom_form` - custom_form\n* `description` - description\n* `ends_at` - ends_at\n* `image_key` - image_key\n* `is_public` - is_public\n* `location` - location\n* `location_kind` - location_kind\n* `payment_amount` - payment_amount\n* `registration_cutoff_at` - registration_cutoff_at\n* `registration_cutoff_lead_minutes` - registration_cutoff_lead_minutes\n* `registration_requires_approval` - registration_requires_approval\n* `starts_at` - starts_at\n* `title` - title" + }, + "IntegrationConfiguredHealth": { + "type": "object", + "properties": { + "status": { + "$ref": "#/components/schemas/Status83eEnum" }, - "cancelled": { - "type": "integer", - "readOnly": true + "detail": { + "type": "string" }, - "attended": { - "type": "integer", - "readOnly": true + "configured": { + "type": "boolean" } - }, - "required": [ - "attended", - "cancelled", - "registered", - "waitlisted" - ] + } }, - "EventRegistrationListResponse": { + "IntegrationDeliveriesByStream": { "type": "object", "properties": { - "count": { - "type": "integer" + "status": { + "$ref": "#/components/schemas/Status83eEnum" }, - "next": { + "detail": { + "type": "string" + }, + "hardware": { "type": "string", + "format": "date-time", "nullable": true }, - "previous": { + "printing": { "type": "string", + "format": "date-time", "nullable": true - }, - "results": { - "type": "array", - "items": { - "$ref": "#/components/schemas/EventRegistrationAdmin" - } } - }, - "required": [ - "count", - "results" - ] + } }, - "EventStaffRegistration": { + "IntegrationEmailHealth": { "type": "object", - "description": "Staff registering a member of this makerspace for an event.\n\n`member_id` only. Contact details are copied off the account by the registration\nservice, so a staffer cannot record an attendee under a name and email that belong\nto nobody — which is what makes the attendee list usable as an accountability record\nrather than free text.", "properties": { - "member_id": { + "status": { + "$ref": "#/components/schemas/Status83eEnum" + }, + "detail": { + "type": "string" + }, + "total": { "type": "integer" }, - "custom_answers": { - "nullable": true + "pending": { + "type": "integer" }, - "phone": { - "type": "string", - "default": "", - "maxLength": 32 + "sent": { + "type": "integer" }, - "email": { - "oneOf": [ - { - "type": "string", - "format": "email", - "default": "" - }, + "failed": { + "type": "integer" + }, + "stalled": { + "type": "integer" + }, + "last_failure": { + "allOf": [ { - "type": "string", - "maxLength": 0 + "$ref": "#/components/schemas/IntegrationLastFailure" } - ] + ], + "nullable": true + } + } + }, + "IntegrationHealth": { + "type": "object", + "properties": { + "status": { + "$ref": "#/components/schemas/IntegrationHealthStatusEnum" + }, + "email": { + "$ref": "#/components/schemas/IntegrationEmailHealth" + }, + "deliveries_by_stream": { + "$ref": "#/components/schemas/IntegrationDeliveriesByStream" + }, + "smtp": { + "$ref": "#/components/schemas/IntegrationConfiguredHealth" + }, + "telegram": { + "$ref": "#/components/schemas/IntegrationConfiguredHealth" + }, + "worker": { + "$ref": "#/components/schemas/IntegrationWorkerHealth" + } + }, + "required": [ + "deliveries_by_stream", + "email", + "smtp", + "status", + "telegram", + "worker" + ] + }, + "IntegrationHealthStatusEnum": { + "enum": [ + "ok", + "warn", + "error" + ], + "type": "string", + "description": "* `ok` - ok\n* `warn` - warn\n* `error` - error" + }, + "IntegrationLastFailure": { + "type": "object", + "properties": { + "created_at": { + "type": "string", + "format": "date-time" + }, + "subject": { + "type": "string" + }, + "error": { + "type": "string" + }, + "stream": { + "type": "string" } }, "required": [ - "member_id" + "created_at", + "error", + "stream", + "subject" ] }, - "EventWrite": { + "IntegrationWorkerHealth": { "type": "object", "properties": { - "title": { + "status": { + "$ref": "#/components/schemas/Status83eEnum" + }, + "detail": { + "type": "string" + }, + "broker_configured": { + "type": "boolean" + }, + "eager": { + "type": "boolean" + }, + "last_seen": { "type": "string", - "maxLength": 200 + "format": "date-time", + "nullable": true }, - "description": { + "stale": { + "type": "boolean" + } + } + }, + "InventoryAssetAdmin": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "readOnly": true + }, + "makerspace": { + "type": "integer", + "readOnly": true + }, + "product": { + "type": "integer", + "readOnly": true + }, + "product_name": { "type": "string", - "default": "" + "readOnly": true }, - "starts_at": { + "box": { + "type": "integer", + "readOnly": true, + "nullable": true + }, + "box_label": { "type": "string", - "format": "date-time" + "readOnly": true, + "nullable": true }, - "ends_at": { + "asset_tag": { "type": "string", - "format": "date-time" + "readOnly": true }, - "location": { + "serial_number": { "type": "string", - "default": "", - "maxLength": 255 + "readOnly": true }, - "location_kind": { + "status": { "allOf": [ { - "$ref": "#/components/schemas/LocationKindEnum" + "$ref": "#/components/schemas/InventoryAssetStatusEnum" } ], - "default": "other" - }, - "custom_form": { - "nullable": true + "readOnly": true }, - "capacity": { + "qr_code_id": { "type": "integer", - "minimum": 0, - "default": 0 + "nullable": true, + "readOnly": true }, - "payment_amount": { + "qr_payload": { "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", - "default": "0.00" + "nullable": true, + "readOnly": true }, - "is_public": { + "public_self_checkout_enabled": { "type": "boolean", - "default": false - } - }, - "required": [ - "ends_at", - "starts_at", - "title" - ] - }, - "EvidenceGetResponse": { - "type": "object", - "properties": { - "url": { + "readOnly": true + }, + "notes": { "type": "string", - "format": "uri" + "readOnly": true }, - "expires_in": { - "type": "integer" + "updated_at": { + "type": "string", + "format": "date-time", + "readOnly": true } }, "required": [ - "expires_in", - "url" + "asset_tag", + "box", + "box_label", + "id", + "makerspace", + "notes", + "product", + "product_name", + "public_self_checkout_enabled", + "qr_code_id", + "qr_payload", + "serial_number", + "status", + "updated_at" ] }, - "EvidenceUrlRequest": { + "InventoryAssetStatusAction": { "type": "object", "properties": { - "evidence_type": { - "$ref": "#/components/schemas/EvidenceUrlRequestEvidenceTypeEnum" - }, - "content_type": { - "type": "string" - }, - "size_bytes": { - "type": "integer", - "minimum": 0, - "nullable": true + "action": { + "$ref": "#/components/schemas/InventoryAssetStatusActionActionEnum" } }, "required": [ - "content_type", - "evidence_type" + "action" ] }, - "EvidenceUrlRequestEvidenceTypeEnum": { + "InventoryAssetStatusActionActionEnum": { "enum": [ - "issue", - "return" + "shelve", + "repair" ], "type": "string", - "description": "* `issue` - Issue\n* `return` - Return" + "description": "* `shelve` - shelve\n* `repair` - repair" }, - "EvidenceUrlResponse": { + "InventoryAssetStatusEnum": { + "enum": [ + "available", + "reserved", + "issued", + "damaged", + "lost", + "retired", + "maintenance" + ], + "type": "string", + "description": "* `available` - Available\n* `reserved` - Reserved\n* `issued` - Issued\n* `damaged` - Damaged\n* `lost` - Lost\n* `retired` - Retired\n* `maintenance` - Maintenance" + }, + "InventoryChainOfCustodyResponse": { "type": "object", "properties": { - "evidence_id": { + "product_id": { "type": "integer" }, - "upload_url": { - "type": "string", - "format": "uri" - }, - "fields": { - "type": "object", - "additionalProperties": {} - }, - "object_key": { + "product_name": { "type": "string" }, - "method": { + "tracking_mode": { "type": "string" }, - "headers": { - "type": "object", - "additionalProperties": {} - } - }, - "required": [ - "evidence_id", - "fields", - "object_key", - "upload_url" - ] - }, - "FabLabHealthReport": { - "type": "object", - "properties": { - "rows": { + "limit": { + "type": "integer" + }, + "truncated": { + "type": "boolean" + }, + "events": { "type": "array", "items": { - "type": "array", - "items": {} + "$ref": "#/components/schemas/TimelineEvent" } }, - "typed_rows": { + "asset_groups": { "type": "array", "items": { - "$ref": "#/components/schemas/FabLabHealthRow" + "$ref": "#/components/schemas/AssetChainGroup" } + }, + "quantity_summary": { + "allOf": [ + { + "$ref": "#/components/schemas/QuantityChainSummary" + } + ], + "nullable": true } }, "required": [ - "rows", - "typed_rows" + "asset_groups", + "events", + "limit", + "product_id", + "product_name", + "quantity_summary", + "tracking_mode", + "truncated" ] }, - "FabLabHealthRow": { + "InventoryProductAdmin": { "type": "object", "properties": { - "makerspace_id": { - "type": "integer" - }, - "events_enabled": { - "type": "boolean" - }, - "events_available": { - "type": "boolean" + "id": { + "type": "integer", + "readOnly": true }, - "events_in_period": { + "makerspace": { "type": "integer", - "nullable": true + "readOnly": true }, - "events_registrations": { + "box": { "type": "integer", "nullable": true }, - "events_attended": { + "category": { "type": "integer", "nullable": true }, - "events_completed_attendance_rate_percent": { - "type": "number", - "format": "double", - "nullable": true + "name": { + "type": "string", + "maxLength": 200 }, - "bookings_enabled": { - "type": "boolean" + "description": { + "type": "string" }, - "bookings_available": { - "type": "boolean" + "image_key": { + "type": "string", + "readOnly": true }, - "bookings_active_spaces": { + "image_url": { + "type": "string", + "format": "uri", + "nullable": true, + "readOnly": true + }, + "tracking_mode": { + "$ref": "#/components/schemas/TrackingModeB86Enum" + }, + "total_quantity": { "type": "integer", - "nullable": true + "maximum": 2147483647, + "minimum": 0 }, - "bookings_non_cancelled": { + "available_quantity": { "type": "integer", - "nullable": true + "maximum": 2147483647, + "minimum": 0 }, - "bookings_reserved_hours": { - "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,18}(?:\\.\\d{0,2})?$", - "nullable": true + "reserved_quantity": { + "type": "integer", + "maximum": 2147483647, + "minimum": 0 }, - "bookings_upcoming": { + "issued_quantity": { "type": "integer", - "nullable": true + "maximum": 2147483647, + "minimum": 0 }, - "bookings_no_shows": { + "damaged_quantity": { "type": "integer", - "nullable": true + "maximum": 2147483647, + "minimum": 0 }, - "bookings_reservation_utilization_percent": { - "type": "number", - "format": "double", - "nullable": true + "lost_quantity": { + "type": "integer", + "maximum": 2147483647, + "minimum": 0 }, - "machines_enabled": { + "needs_fix_quantity": { + "type": "integer", + "readOnly": true + }, + "is_public": { "type": "boolean" }, - "machines_available": { + "public_self_checkout_enabled": { "type": "boolean" }, - "machines_active": { - "type": "integer", - "nullable": true + "show_public_count": { + "type": "boolean" }, - "machines_usage_hours": { - "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,18}(?:\\.\\d{0,2})?$", - "nullable": true + "public_availability_mode": { + "$ref": "#/components/schemas/PublicAvailabilityModeFc2Enum" }, - "maintenance_enabled": { - "type": "boolean" + "storage_location": { + "type": "string", + "maxLength": 200 }, - "maintenance_available": { + "is_archived": { "type": "boolean" }, - "maintenance_logs": { - "type": "integer", - "nullable": true - }, - "maintenance_total_cost": { + "created_at": { "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,18}(?:\\.\\d{0,2})?$", - "nullable": true + "format": "date-time", + "readOnly": true }, - "maintenance_overdue_schedules": { - "type": "integer", - "nullable": true + "updated_at": { + "type": "string", + "format": "date-time", + "readOnly": true } }, "required": [ - "bookings_active_spaces", - "bookings_available", - "bookings_enabled", - "bookings_no_shows", - "bookings_non_cancelled", - "bookings_reservation_utilization_percent", - "bookings_reserved_hours", - "bookings_upcoming", - "events_attended", - "events_available", - "events_completed_attendance_rate_percent", - "events_enabled", - "events_in_period", - "events_registrations", - "machines_active", - "machines_available", - "machines_enabled", - "machines_usage_hours", - "maintenance_available", - "maintenance_enabled", - "maintenance_logs", - "maintenance_overdue_schedules", - "maintenance_total_cost" + "created_at", + "id", + "image_key", + "image_url", + "makerspace", + "name", + "needs_fix_quantity", + "updated_at" ] }, - "FailureCodeEnum": { - "enum": [ - "deadline_exceeded", - "integrity_error", - "storage_error", - "quota_exceeded", - "internal_error" - ], - "type": "string", - "description": "* `` - None\n* `deadline_exceeded` - Deadline exceeded\n* `integrity_error` - Integrity error\n* `storage_error` - Storage error\n* `quota_exceeded` - Quota exceeded\n* `internal_error` - Internal error" - }, - "FeatureEnum": { - "enum": [ - "hardware_requests", - "printing", - "events", - "bookings", - "maintenance", - "members" - ], - "type": "string", - "description": "* `hardware_requests` - Hardware requests\n* `printing` - Printing\n* `events` - Events\n* `bookings` - Bookings\n* `maintenance` - Maintenance\n* `members` - Members" - }, - "FidelityEnum": { - "enum": [ - "REDACTED" - ], - "type": "string", - "description": "* `REDACTED` - Readable — audit metadata and form answers redacted; member contact details included" - }, - "FieldValidationError": { + "InventoryProductAdminCreate": { "type": "object", - "description": "DRF's field-keyed boundary errors; deliberately not the typed service shape.", "properties": { - "non_field_errors": { - "type": "array", - "items": { - "type": "string" - } + "id": { + "type": "integer", + "readOnly": true }, - "digest": { - "type": "array", - "items": { - "type": "string" - } + "makerspace": { + "type": "integer", + "readOnly": true }, - "decisions": { - "type": "array", - "items": { - "type": "string" - } + "box": { + "type": "integer", + "nullable": true + }, + "category": { + "type": "integer", + "nullable": true + }, + "name": { + "type": "string", + "maxLength": 200 + }, + "description": { + "type": "string" + }, + "image_key": { + "type": "string", + "readOnly": true + }, + "image_url": { + "type": "string", + "format": "uri", + "nullable": true, + "readOnly": true + }, + "tracking_mode": { + "$ref": "#/components/schemas/TrackingModeB86Enum" + }, + "total_quantity": { + "type": "integer", + "maximum": 2147483647, + "minimum": 0 + }, + "available_quantity": { + "type": "integer", + "maximum": 2147483647, + "minimum": 0 + }, + "reserved_quantity": { + "type": "integer", + "readOnly": true + }, + "issued_quantity": { + "type": "integer", + "readOnly": true + }, + "damaged_quantity": { + "type": "integer", + "readOnly": true + }, + "lost_quantity": { + "type": "integer", + "readOnly": true + }, + "needs_fix_quantity": { + "type": "integer", + "readOnly": true }, - "approval_id": { - "type": "array", - "items": { - "type": "string" - } + "is_public": { + "type": "boolean" }, - "target_age_recipient": { - "type": "array", - "items": { - "type": "string" - } + "public_self_checkout_enabled": { + "type": "boolean" }, - "archive": { - "type": "array", - "items": { - "type": "string" - } + "show_public_count": { + "type": "boolean" }, - "source_archive_digest": { - "type": "array", - "items": { - "type": "string" - } + "public_availability_mode": { + "$ref": "#/components/schemas/PublicAvailabilityModeFc2Enum" }, - "target_identity": { - "type": "array", - "items": { - "type": "string" - } + "storage_location": { + "type": "string", + "maxLength": 200 }, - "receipt": { - "type": "array", - "items": { - "type": "string" - } - } - } - }, - "ForgotPasswordRequest": { - "type": "object", - "properties": { - "email": { + "is_archived": { + "type": "boolean" + }, + "created_at": { "type": "string", - "format": "email" - } - }, - "required": [ - "email" - ] - }, - "FrontendDomainStatusEnum": { - "enum": [ - "pending", - "verified", - "failed" - ], - "type": "string", - "description": "* `pending` - Pending\n* `verified` - Verified\n* `failed` - Failed" - }, - "GenericObject": { - "type": "object", - "properties": { - "detail": { - "type": "string" - } - } - }, - "HardwareRequestError": { - "type": "object", - "properties": { - "detail": { - "type": "string" + "format": "date-time", + "readOnly": true }, - "code": { - "type": "string" - } - }, - "required": [ - "code", - "detail" - ] - }, - "Health": { - "type": "object", - "properties": { - "status": { - "type": "string" + "updated_at": { + "type": "string", + "format": "date-time", + "readOnly": true } }, "required": [ - "status" + "created_at", + "damaged_quantity", + "id", + "image_key", + "image_url", + "issued_quantity", + "lost_quantity", + "makerspace", + "name", + "needs_fix_quantity", + "reserved_quantity", + "updated_at" ] }, - "HostWaiver": { + "InventoryProductAdminUpdate": { "type": "object", "properties": { "id": { "type": "integer", "readOnly": true }, - "version": { + "makerspace": { + "type": "integer", + "readOnly": true + }, + "box": { + "type": "integer", + "nullable": true + }, + "category": { + "type": "integer", + "nullable": true + }, + "name": { + "type": "string", + "maxLength": 200 + }, + "description": { + "type": "string" + }, + "image_key": { "type": "string", "readOnly": true }, - "body": { + "image_url": { + "type": "string", + "format": "uri", + "nullable": true, + "readOnly": true + }, + "tracking_mode": { + "$ref": "#/components/schemas/TrackingModeB86Enum" + }, + "total_quantity": { + "type": "integer", + "readOnly": true + }, + "available_quantity": { + "type": "integer", + "readOnly": true + }, + "reserved_quantity": { + "type": "integer", + "readOnly": true + }, + "issued_quantity": { + "type": "integer", + "readOnly": true + }, + "damaged_quantity": { + "type": "integer", + "readOnly": true + }, + "lost_quantity": { + "type": "integer", + "readOnly": true + }, + "needs_fix_quantity": { + "type": "integer", + "readOnly": true + }, + "is_public": { + "type": "boolean" + }, + "public_self_checkout_enabled": { + "type": "boolean" + }, + "show_public_count": { + "type": "boolean" + }, + "public_availability_mode": { + "$ref": "#/components/schemas/PublicAvailabilityModeFc2Enum" + }, + "storage_location": { + "type": "string", + "maxLength": 200 + }, + "is_archived": { + "type": "boolean" + }, + "created_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "updated_at": { "type": "string", + "format": "date-time", "readOnly": true } }, "required": [ - "body", + "available_quantity", + "created_at", + "damaged_quantity", "id", - "version" + "image_key", + "image_url", + "issued_quantity", + "lost_quantity", + "makerspace", + "name", + "needs_fix_quantity", + "reserved_quantity", + "total_quantity", + "updated_at" ] }, - "HostWaiverStateEnum": { - "enum": [ - "not_required", - "on_file", - "missing" - ], - "type": "string", - "description": "* `not_required` - not_required\n* `on_file` - on_file\n* `missing` - missing" - }, - "HostingError": { + "InventoryQuantityAdjustment": { "type": "object", "properties": { - "detail": { + "delta_available": { + "type": "integer", + "default": 0 + }, + "delta_damaged": { + "type": "integer", + "default": 0 + }, + "delta_lost": { + "type": "integer", + "default": 0 + }, + "reason": { "type": "string" } }, "required": [ - "detail" + "reason" ] }, - "IdentityDisclosureDecision": { + "Invitation": { "type": "object", "properties": { - "user_id": { + "role_id": { "type": "integer" }, - "approved": { - "type": "boolean" + "invite_email": { + "type": "string", + "format": "email", + "maxLength": 254 } }, "required": [ - "approved", - "user_id" + "invite_email", + "role_id" ] }, - "IdentityResolutionEnum": { - "enum": [ - "link_existing", - "create_walk_in" - ], - "type": "string", - "description": "* `link_existing` - Link existing account\n* `create_walk_in` - Create walk-in account" - }, - "ImportCreate": { + "InvitationClaimOutcome": { "type": "object", "properties": { - "archive": { - "type": "string", - "format": "uri", - "writeOnly": true + "id": { + "type": "integer" }, - "source_archive_digest": { - "type": "string", - "pattern": "^[0-9a-f]{64}$" + "outcome": { + "$ref": "#/components/schemas/InvitationClaimOutcomeOutcomeEnum" } }, "required": [ - "archive", - "source_archive_digest" + "id", + "outcome" ] }, - "ImportDecisionList": { + "InvitationClaimOutcomeOutcomeEnum": { + "enum": [ + "active", + "pending_approval" + ], + "type": "string", + "description": "* `active` - active\n* `pending_approval` - pending_approval" + }, + "InvitationList": { "type": "object", "properties": { - "decisions": { + "invitations": { "type": "array", "items": { - "$ref": "#/components/schemas/ImportIdentityDecision" + "$ref": "#/components/schemas/ClaimableInvitation" } } }, "required": [ - "decisions" + "invitations" ] }, - "ImportIdentityDecision": { + "IssueReject": { "type": "object", "properties": { - "source_user_id": { - "type": "string", - "maxLength": 255 - }, - "identity_resolution": { - "$ref": "#/components/schemas/IdentityResolutionEnum" - }, - "membership_disposition": { - "$ref": "#/components/schemas/MembershipDispositionEnum" + "item_id": { + "type": "integer" }, - "target_user_id": { + "broken": { "type": "integer", - "nullable": true + "minimum": 0, + "default": 0 + }, + "disposition": { + "allOf": [ + { + "$ref": "#/components/schemas/DispositionEnum" + } + ], + "default": "needs_fix" } }, "required": [ - "identity_resolution", - "membership_disposition", - "source_user_id" + "item_id" ] }, - "ImportJob": { + "IssueRequest": { "type": "object", "properties": { - "id": { - "type": "string", - "format": "uuid", - "readOnly": true - }, - "source_archive_digest": { - "type": "string", - "maxLength": 64 - }, - "source_makerspace_id": { - "type": "string", - "maxLength": 64 - }, - "source_makerspace_slug": { - "type": "string", - "maxLength": 100 - }, - "source_makerspace_name": { - "type": "string", - "maxLength": 200 - }, - "source_deployment_id": { - "type": "string", - "maxLength": 128 - }, - "storage_mode": { - "type": "string", - "maxLength": 32 - }, - "status": { - "$ref": "#/components/schemas/ImportJobStatusEnum" - }, - "identity_count": { - "type": "string", - "readOnly": true - }, - "target_lifecycle_state": { - "type": "string", - "nullable": true, - "readOnly": true - }, - "source_deployment_identity": {}, - "aggregate_outcome": {}, - "failure_code": { - "type": "string", - "maxLength": 64 - }, - "failure_detail": { - "type": "string", - "maxLength": 500 - }, - "created_at": { - "type": "string", - "format": "date-time", - "readOnly": true - }, - "updated_at": { - "type": "string", - "format": "date-time", - "readOnly": true - }, - "expires_at": { - "type": "string", - "format": "date-time" + "evidence_id": { + "type": "integer" }, - "terminal_at": { + "remark": { "type": "string", - "format": "date-time", - "nullable": true + "default": "" }, - "scrubbed_at": { - "type": "string", - "format": "date-time", - "nullable": true + "asset_qr_payloads": { + "type": "array", + "items": { + "type": "string" + } }, - "source_retention_notice": { - "type": "string", - "readOnly": true + "rejects": { + "type": "array", + "items": { + "$ref": "#/components/schemas/IssueReject" + } } }, "required": [ - "created_at", - "expires_at", - "id", - "identity_count", - "source_archive_digest", - "source_retention_notice", - "target_lifecycle_state", - "updated_at" + "evidence_id" ] }, - "ImportJobStatusEnum": { + "KeyCbbEnum": { "enum": [ - "pending", - "awaiting_identity", - "ready", - "materializing", - "finalizing", - "completed", - "failed", - "abandoned" + "email", + "telegram", + "slack", + "mattermost", + "discord", + "native_push" ], "type": "string", - "description": "* `pending` - Pending\n* `awaiting_identity` - Awaiting identity decisions\n* `ready` - Ready\n* `materializing` - Materializing\n* `finalizing` - Finalizing\n* `completed` - Completed\n* `failed` - Failed\n* `abandoned` - Abandoned" + "description": "* `email` - Email\n* `telegram` - Telegram\n* `slack` - Slack\n* `mattermost` - Mattermost\n* `discord` - Discord\n* `native_push` - Native push" }, - "ImportRun": { - "type": "object", - "properties": { - "target_identity": { - "type": "object", - "additionalProperties": {} - } - } + "KeyD07Enum": { + "enum": [ + "hardware_requests", + "printing", + "events", + "bookings", + "maintenance", + "members" + ], + "type": "string", + "description": "* `hardware_requests` - Hardware requests\n* `printing` - Printing\n* `events` - Events\n* `bookings` - Bookings\n* `maintenance` - Maintenance\n* `members` - Members" }, - "IntegrationConfiguredHealth": { - "type": "object", - "properties": { - "status": { - "$ref": "#/components/schemas/Status83eEnum" - }, - "detail": { - "type": "string" - }, - "configured": { - "type": "boolean" - } - } + "Kind3bfEnum": { + "enum": [ + "dev_room", + "bench", + "meeting", + "other" + ], + "type": "string", + "description": "* `dev_room` - Development room\n* `bench` - Bench\n* `meeting` - Meeting room\n* `other` - Other" }, - "IntegrationDeliveriesByStream": { - "type": "object", - "properties": { - "status": { - "$ref": "#/components/schemas/Status83eEnum" - }, - "detail": { - "type": "string" + "KindB02Enum": { + "enum": [ + "rollback_in_place", + "disaster" + ], + "type": "string", + "description": "* `rollback_in_place` - Rollback in place\n* `disaster` - Disaster or cross-server" + }, + "KindE56Enum": { + "enum": [ + "role", + "requester", + "members", + "user" + ], + "type": "string", + "description": "* `role` - Role\n* `requester` - Requester\n* `members` - All members\n* `user` - Named user" + }, + "LedgerResponse": { + "type": "object", + "properties": { + "count": { + "type": "integer" }, - "hardware": { + "next": { "type": "string", - "format": "date-time", "nullable": true }, - "printing": { + "previous": { "type": "string", - "format": "date-time", "nullable": true + }, + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LedgerRow" + } } - } + }, + "required": [ + "count", + "results" + ] }, - "IntegrationEmailHealth": { + "LedgerRow": { "type": "object", "properties": { - "status": { - "$ref": "#/components/schemas/Status83eEnum" + "source": { + "$ref": "#/components/schemas/LedgerRowSourceEnum" }, - "detail": { + "item_name": { "type": "string" }, - "total": { - "type": "integer" - }, - "pending": { - "type": "integer" + "container": { + "type": "string", + "nullable": true }, - "sent": { - "type": "integer" + "holder": { + "type": "string" }, - "failed": { + "quantity": { "type": "integer" }, - "stalled": { - "type": "integer" + "units": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LedgerUnit" + } }, - "last_failure": { - "allOf": [ - { - "$ref": "#/components/schemas/IntegrationLastFailure" - } - ], + "target_label": { + "type": "string", "nullable": true - } - } - }, - "IntegrationHealth": { - "type": "object", - "properties": { - "status": { - "$ref": "#/components/schemas/IntegrationHealthStatusEnum" }, - "email": { - "$ref": "#/components/schemas/IntegrationEmailHealth" + "since": { + "type": "string", + "format": "date-time", + "nullable": true }, - "deliveries_by_stream": { - "$ref": "#/components/schemas/IntegrationDeliveriesByStream" + "due": { + "type": "string", + "format": "date-time", + "nullable": true }, - "smtp": { - "$ref": "#/components/schemas/IntegrationConfiguredHealth" + "makerspace_id": { + "type": "integer" }, - "telegram": { - "$ref": "#/components/schemas/IntegrationConfiguredHealth" + "reference_id": { + "type": "integer" }, - "worker": { - "$ref": "#/components/schemas/IntegrationWorkerHealth" + "status": { + "type": "string" } }, "required": [ - "deliveries_by_stream", - "email", - "smtp", + "due", + "holder", + "item_name", + "makerspace_id", + "quantity", + "reference_id", + "since", + "source", "status", - "telegram", - "worker" + "units" ] }, - "IntegrationHealthStatusEnum": { + "LedgerRowSourceEnum": { "enum": [ - "ok", - "warn", - "error" + "request", + "self_checkout", + "direct_handout" ], "type": "string", - "description": "* `ok` - ok\n* `warn` - warn\n* `error` - error" + "description": "* `request` - request\n* `self_checkout` - self_checkout\n* `direct_handout` - direct_handout" }, - "IntegrationLastFailure": { + "LedgerUnit": { "type": "object", "properties": { - "created_at": { - "type": "string", - "format": "date-time" - }, - "subject": { - "type": "string" - }, - "error": { + "asset_tag": { "type": "string" }, - "stream": { + "serial_number": { "type": "string" } }, "required": [ - "created_at", - "error", - "stream", - "subject" + "asset_tag", + "serial_number" ] }, - "IntegrationWorkerHealth": { + "LegacyResetPasswordConfirm": { "type": "object", "properties": { - "status": { - "$ref": "#/components/schemas/Status83eEnum" - }, - "detail": { + "uid": { "type": "string" }, - "broker_configured": { - "type": "boolean" - }, - "eager": { - "type": "boolean" + "token": { + "type": "string", + "writeOnly": true }, - "last_seen": { + "new_password": { "type": "string", - "format": "date-time", - "nullable": true + "writeOnly": true + } + }, + "required": [ + "new_password", + "token", + "uid" + ] + }, + "LegacyRoleEnum": { + "enum": [ + "space_manager", + "inventory_manager", + "print_manager", + "machine_manager" + ], + "type": "string", + "description": "* `space_manager` - Space Manager\n* `inventory_manager` - Inventory Manager\n* `print_manager` - Print Manager\n* `machine_manager` - Machine Manager" + }, + "LendingHistoryActor": { + "type": "object", + "properties": { + "username": { + "type": "string" }, - "stale": { - "type": "boolean" + "role": { + "type": "string" } - } + }, + "required": [ + "role", + "username" + ] }, - "InventoryAssetAdmin": { + "LendingHistoryEntry": { "type": "object", "properties": { "id": { - "type": "integer", - "readOnly": true - }, - "makerspace": { - "type": "integer", - "readOnly": true + "type": "integer" }, - "product": { - "type": "integer", - "readOnly": true + "username": { + "type": "string" }, - "product_name": { + "issued_at": { "type": "string", - "readOnly": true + "format": "date-time" }, - "box": { - "type": "integer", - "readOnly": true, - "nullable": true + "quantity": { + "type": "integer" }, - "box_label": { - "type": "string", - "readOnly": true, + "issued_by": { + "allOf": [ + { + "$ref": "#/components/schemas/LendingHistoryActor" + } + ], "nullable": true }, - "asset_tag": { - "type": "string", - "readOnly": true - }, - "serial_number": { - "type": "string", - "readOnly": true - }, - "status": { + "accepted_by": { "allOf": [ { - "$ref": "#/components/schemas/InventoryAssetStatusEnum" + "$ref": "#/components/schemas/LendingHistoryActor" } ], - "readOnly": true - }, - "qr_code_id": { - "type": "integer", - "nullable": true, - "readOnly": true - }, - "qr_payload": { - "type": "string", - "nullable": true, - "readOnly": true - }, - "public_self_checkout_enabled": { - "type": "boolean", - "readOnly": true - }, - "notes": { - "type": "string", - "readOnly": true - }, - "updated_at": { - "type": "string", - "format": "date-time", - "readOnly": true + "nullable": true } }, "required": [ - "asset_tag", - "box", - "box_label", + "accepted_by", "id", - "makerspace", - "notes", - "product", - "product_name", - "public_self_checkout_enabled", - "qr_code_id", - "qr_payload", - "serial_number", - "status", - "updated_at" - ] - }, - "InventoryAssetStatusAction": { - "type": "object", - "properties": { - "action": { - "$ref": "#/components/schemas/InventoryAssetStatusActionActionEnum" - } - }, - "required": [ - "action" + "issued_at", + "issued_by", + "quantity", + "username" ] }, - "InventoryAssetStatusActionActionEnum": { - "enum": [ - "shelve", - "repair" - ], - "type": "string", - "description": "* `shelve` - shelve\n* `repair` - repair" - }, - "InventoryAssetStatusEnum": { - "enum": [ - "available", - "reserved", - "issued", - "damaged", - "lost", - "retired", - "maintenance" - ], - "type": "string", - "description": "* `available` - Available\n* `reserved` - Reserved\n* `issued` - Issued\n* `damaged` - Damaged\n* `lost` - Lost\n* `retired` - Retired\n* `maintenance` - Maintenance" - }, - "InventoryChainOfCustodyResponse": { + "LendingHistoryResponse": { "type": "object", "properties": { "product_id": { "type": "integer" }, - "product_name": { - "type": "string" - }, - "tracking_mode": { - "type": "string" - }, - "limit": { - "type": "integer" - }, - "truncated": { - "type": "boolean" - }, - "events": { - "type": "array", - "items": { - "$ref": "#/components/schemas/TimelineEvent" - } - }, - "asset_groups": { - "type": "array", - "items": { - "$ref": "#/components/schemas/AssetChainGroup" - } - }, - "quantity_summary": { + "last_borrower": { "allOf": [ { - "$ref": "#/components/schemas/QuantityChainSummary" + "$ref": "#/components/schemas/LendingHistoryEntry" } ], "nullable": true + }, + "recent": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LendingHistoryEntry" + } } }, "required": [ - "asset_groups", - "events", - "limit", + "last_borrower", "product_id", - "product_name", - "quantity_summary", - "tracking_mode", - "truncated" + "recent" ] }, - "InventoryProductAdmin": { + "LevelEnum": { + "enum": [ + "info", + "warning", + "critical" + ], + "type": "string", + "description": "* `info` - Info\n* `warning` - Warning\n* `critical` - Critical" + }, + "LinkMachineConsumable": { "type": "object", "properties": { - "id": { - "type": "integer", - "readOnly": true - }, - "makerspace": { - "type": "integer", - "readOnly": true - }, - "box": { - "type": "integer", - "nullable": true + "measurement": { + "$ref": "#/components/schemas/Measurement883Enum" }, - "category": { + "product_id": { "type": "integer", "nullable": true }, - "name": { + "label": { "type": "string", "maxLength": 200 }, - "description": { - "type": "string" - }, - "image_key": { + "remaining": { "type": "string", - "readOnly": true + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" }, - "image_url": { + "low_threshold": { "type": "string", - "format": "uri", - "nullable": true, - "readOnly": true - }, - "tracking_mode": { - "$ref": "#/components/schemas/TrackingModeB86Enum" - }, - "total_quantity": { - "type": "integer", - "maximum": 2147483647, - "minimum": 0 - }, - "available_quantity": { - "type": "integer", - "maximum": 2147483647, - "minimum": 0 - }, - "reserved_quantity": { - "type": "integer", - "maximum": 2147483647, - "minimum": 0 - }, - "issued_quantity": { - "type": "integer", - "maximum": 2147483647, - "minimum": 0 - }, - "damaged_quantity": { - "type": "integer", - "maximum": 2147483647, - "minimum": 0 - }, - "lost_quantity": { - "type": "integer", - "maximum": 2147483647, - "minimum": 0 - }, - "needs_fix_quantity": { - "type": "integer", - "readOnly": true - }, - "is_public": { - "type": "boolean" + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", + "nullable": true }, - "public_self_checkout_enabled": { - "type": "boolean" + "note": { + "type": "string", + "maxLength": 255 + } + }, + "required": [ + "measurement" + ] + }, + "LocationKindEnum": { + "enum": [ + "indoor", + "outdoor", + "other" + ], + "type": "string", + "description": "* `indoor` - Indoor\n* `outdoor` - Outdoor\n* `other` - Other" + }, + "LogError": { + "type": "object", + "properties": { + "severity": { + "type": "string", + "maxLength": 16 }, - "show_public_count": { - "type": "boolean" + "message": { + "type": "string" + } + }, + "required": [ + "message", + "severity" + ] + }, + "LogMachineConsumption": { + "type": "object", + "properties": { + "quantity": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" + } + }, + "required": [ + "quantity" + ] + }, + "LogUsage": { + "type": "object", + "properties": { + "hours": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,8}(?:\\.\\d{0,2})?$" }, - "public_availability_mode": { - "$ref": "#/components/schemas/PublicAvailabilityModeFc2Enum" + "note": { + "type": "string", + "maxLength": 255 + } + }, + "required": [ + "hours" + ] + }, + "LoginRequest": { + "type": "object", + "properties": { + "username": { + "type": "string" }, - "storage_location": { + "password": { "type": "string", - "maxLength": 200 + "writeOnly": true }, - "is_archived": { - "type": "boolean" + "surface": { + "allOf": [ + { + "$ref": "#/components/schemas/LoginRequestSurfaceEnum" + } + ], + "default": "member" + } + }, + "required": [ + "password", + "username" + ] + }, + "LoginRequestSurfaceEnum": { + "enum": [ + "member", + "staff" + ], + "type": "string", + "description": "* `member` - member\n* `staff` - staff" + }, + "LoginResponse": { + "type": "object", + "properties": { + "user": { + "$ref": "#/components/schemas/AuthUserPayload" }, - "created_at": { - "type": "string", - "format": "date-time", - "readOnly": true + "access": { + "type": "string" }, - "updated_at": { - "type": "string", - "format": "date-time", - "readOnly": true + "surface": { + "$ref": "#/components/schemas/LoginResponseSurfaceEnum" } }, "required": [ - "created_at", - "id", - "image_key", - "image_url", - "makerspace", - "name", - "needs_fix_quantity", - "updated_at" + "access", + "surface", + "user" ] }, - "InventoryProductAdminCreate": { + "LoginResponseSurfaceEnum": { + "enum": [ + "member", + "staff", + "verification_only" + ], + "type": "string", + "description": "* `member` - member\n* `staff` - staff\n* `verification_only` - verification_only" + }, + "LogoutResponse": { + "type": "object", + "properties": { + "detail": { + "type": "string" + } + }, + "required": [ + "detail" + ] + }, + "Machine": { "type": "object", "properties": { "id": { @@ -40570,82 +48814,100 @@ "type": "integer", "readOnly": true }, - "box": { - "type": "integer", - "nullable": true + "machine_type": { + "allOf": [ + { + "$ref": "#/components/schemas/MachineType" + } + ], + "readOnly": true }, - "category": { + "machine_type_id": { "type": "integer", - "nullable": true + "writeOnly": true }, "name": { "type": "string", "maxLength": 200 }, - "description": { + "location": { + "type": "string", + "maxLength": 200 + }, + "notes": { "type": "string" }, - "image_key": { - "type": "string", + "status": { + "allOf": [ + { + "$ref": "#/components/schemas/MachineStatusEnum" + } + ], "readOnly": true }, + "firmware_version": { + "type": "string", + "maxLength": 100 + }, + "camera_feed_url": { + "oneOf": [ + { + "type": "string", + "format": "uri", + "maxLength": 200 + }, + { + "type": "string", + "maxLength": 0 + } + ] + }, + "type_payload": {}, "image_url": { "type": "string", - "format": "uri", "nullable": true, "readOnly": true }, - "tracking_mode": { - "$ref": "#/components/schemas/TrackingModeB86Enum" - }, - "total_quantity": { - "type": "integer", - "maximum": 2147483647, - "minimum": 0 - }, - "available_quantity": { - "type": "integer", - "maximum": 2147483647, - "minimum": 0 - }, - "reserved_quantity": { - "type": "integer", + "warranty_status": { + "type": "string", "readOnly": true }, - "issued_quantity": { - "type": "integer", + "is_public": { + "type": "boolean", "readOnly": true }, - "damaged_quantity": { - "type": "integer", + "is_active": { + "type": "boolean", "readOnly": true }, - "lost_quantity": { - "type": "integer", + "usage_hours": { + "type": "number", + "format": "double", "readOnly": true }, - "needs_fix_quantity": { - "type": "integer", + "can_operate": { + "type": "boolean", "readOnly": true }, - "is_public": { - "type": "boolean" - }, - "public_self_checkout_enabled": { - "type": "boolean" + "can_edit": { + "type": "boolean", + "readOnly": true }, - "show_public_count": { - "type": "boolean" + "can_delegate": { + "type": "boolean", + "readOnly": true }, - "public_availability_mode": { - "$ref": "#/components/schemas/PublicAvailabilityModeFc2Enum" + "can_retire": { + "type": "boolean", + "readOnly": true }, - "storage_location": { - "type": "string", - "maxLength": 200 + "can_unretire": { + "type": "boolean", + "readOnly": true }, - "is_archived": { - "type": "boolean" + "can_manage": { + "type": "boolean", + "readOnly": true }, "created_at": { "type": "string", @@ -40659,332 +48921,188 @@ } }, "required": [ + "can_delegate", + "can_edit", + "can_manage", + "can_operate", + "can_retire", + "can_unretire", "created_at", - "damaged_quantity", "id", - "image_key", "image_url", - "issued_quantity", - "lost_quantity", + "is_active", + "is_public", + "machine_type", + "machine_type_id", "makerspace", "name", - "needs_fix_quantity", - "reserved_quantity", - "updated_at" + "status", + "updated_at", + "usage_hours", + "warranty_status" ] }, - "InventoryProductAdminUpdate": { + "MachineConsumable": { "type": "object", "properties": { "id": { "type": "integer", "readOnly": true }, - "makerspace": { - "type": "integer", + "measurement": { + "allOf": [ + { + "$ref": "#/components/schemas/Measurement883Enum" + } + ], "readOnly": true }, - "box": { - "type": "integer", - "nullable": true - }, - "category": { + "product": { "type": "integer", + "readOnly": true, "nullable": true }, - "name": { - "type": "string", - "maxLength": 200 - }, - "description": { - "type": "string" - }, - "image_key": { - "type": "string", - "readOnly": true - }, - "image_url": { + "product_name": { "type": "string", - "format": "uri", "nullable": true, "readOnly": true }, - "tracking_mode": { - "$ref": "#/components/schemas/TrackingModeB86Enum" - }, - "total_quantity": { - "type": "integer", - "readOnly": true - }, - "available_quantity": { - "type": "integer", - "readOnly": true - }, - "reserved_quantity": { - "type": "integer", - "readOnly": true - }, - "issued_quantity": { - "type": "integer", - "readOnly": true - }, - "damaged_quantity": { + "available": { "type": "integer", + "nullable": true, "readOnly": true }, - "lost_quantity": { - "type": "integer", + "remaining": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", + "nullable": true, "readOnly": true }, - "needs_fix_quantity": { - "type": "integer", + "label": { + "type": "string", "readOnly": true }, - "is_public": { - "type": "boolean" - }, - "public_self_checkout_enabled": { - "type": "boolean" - }, - "show_public_count": { - "type": "boolean" - }, - "public_availability_mode": { - "$ref": "#/components/schemas/PublicAvailabilityModeFc2Enum" - }, - "storage_location": { + "low_threshold": { "type": "string", - "maxLength": 200 - }, - "is_archived": { - "type": "boolean" + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", + "readOnly": true, + "nullable": true }, - "created_at": { + "note": { "type": "string", - "format": "date-time", "readOnly": true }, - "updated_at": { + "created_at": { "type": "string", "format": "date-time", "readOnly": true } }, "required": [ - "available_quantity", + "available", "created_at", - "damaged_quantity", "id", - "image_key", - "image_url", - "issued_quantity", - "lost_quantity", - "makerspace", - "name", - "needs_fix_quantity", - "reserved_quantity", - "total_quantity", - "updated_at" + "label", + "low_threshold", + "measurement", + "note", + "product", + "product_name", + "remaining" ] }, - "InventoryQuantityAdjustment": { + "MachineDocument": { "type": "object", "properties": { - "delta_available": { - "type": "integer", - "default": 0 - }, - "delta_damaged": { + "id": { "type": "integer", - "default": 0 + "readOnly": true }, - "delta_lost": { - "type": "integer", - "default": 0 + "doc_type": { + "allOf": [ + { + "$ref": "#/components/schemas/DocTypeEnum" + } + ], + "readOnly": true }, - "reason": { - "type": "string" - } - }, - "required": [ - "reason" - ] - }, - "Invitation": { - "type": "object", - "properties": { - "role_id": { - "type": "integer" + "original_filename": { + "type": "string", + "readOnly": true }, - "invite_email": { + "content_type": { "type": "string", - "format": "email", - "maxLength": 254 - } - }, - "required": [ - "invite_email", - "role_id" - ] - }, - "InvitationClaimOutcome": { - "type": "object", - "properties": { - "id": { - "type": "integer" + "readOnly": true }, - "outcome": { - "$ref": "#/components/schemas/InvitationClaimOutcomeOutcomeEnum" + "size_bytes": { + "type": "integer", + "readOnly": true + }, + "created_at": { + "type": "string", + "format": "date-time", + "readOnly": true } }, "required": [ + "content_type", + "created_at", + "doc_type", "id", - "outcome" - ] - }, - "InvitationClaimOutcomeOutcomeEnum": { - "enum": [ - "active", - "pending_approval" - ], - "type": "string", - "description": "* `active` - active\n* `pending_approval` - pending_approval" - }, - "InvitationList": { - "type": "object", - "properties": { - "invitations": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ClaimableInvitation" - } - } - }, - "required": [ - "invitations" + "original_filename", + "size_bytes" ] }, - "IssueReject": { + "MachineErrorLog": { "type": "object", "properties": { - "item_id": { - "type": "integer" - }, - "broken": { + "id": { "type": "integer", - "minimum": 0, - "default": 0 + "readOnly": true }, - "disposition": { + "severity": { "allOf": [ { - "$ref": "#/components/schemas/DispositionEnum" + "$ref": "#/components/schemas/SeverityEnum" } ], - "default": "needs_fix" - } - }, - "required": [ - "item_id" - ] - }, - "IssueRequest": { - "type": "object", - "properties": { - "evidence_id": { - "type": "integer" + "readOnly": true }, - "remark": { + "message": { "type": "string", - "default": "" + "readOnly": true }, - "asset_qr_payloads": { - "type": "array", - "items": { - "type": "string" - } + "logged_by_username": { + "type": "string", + "readOnly": true, + "nullable": true }, - "rejects": { - "type": "array", - "items": { - "$ref": "#/components/schemas/IssueReject" - } - } - }, - "required": [ - "evidence_id" - ] - }, - "KeyCbbEnum": { - "enum": [ - "email", - "telegram", - "slack", - "mattermost", - "discord", - "native_push" - ], - "type": "string", - "description": "* `email` - Email\n* `telegram` - Telegram\n* `slack` - Slack\n* `mattermost` - Mattermost\n* `discord` - Discord\n* `native_push` - Native push" - }, - "KeyD07Enum": { - "enum": [ - "hardware_requests", - "printing", - "events", - "bookings", - "maintenance", - "members" - ], - "type": "string", - "description": "* `hardware_requests` - Hardware requests\n* `printing` - Printing\n* `events` - Events\n* `bookings` - Bookings\n* `maintenance` - Maintenance\n* `members` - Members" - }, - "Kind3bfEnum": { - "enum": [ - "dev_room", - "bench", - "meeting", - "other" - ], - "type": "string", - "description": "* `dev_room` - Development room\n* `bench` - Bench\n* `meeting` - Meeting room\n* `other` - Other" - }, - "KindB02Enum": { - "enum": [ - "rollback_in_place", - "disaster" - ], - "type": "string", - "description": "* `rollback_in_place` - Rollback in place\n* `disaster` - Disaster or cross-server" - }, - "KindE56Enum": { - "enum": [ - "role", - "requester", - "members", - "user" - ], - "type": "string", - "description": "* `role` - Role\n* `requester` - Requester\n* `members` - All members\n* `user` - Named user" + "created_at": { + "type": "string", + "format": "date-time", + "readOnly": true + } + }, + "required": [ + "created_at", + "id", + "logged_by_username", + "message", + "severity" + ] }, - "LedgerResponse": { + "MachineListResponse": { "type": "object", "properties": { "count": { "type": "integer" }, - "next": { - "type": "string", - "nullable": true - }, - "previous": { - "type": "string", - "nullable": true - }, "results": { "type": "array", "items": { - "$ref": "#/components/schemas/LedgerRow" + "$ref": "#/components/schemas/Machine" } } }, @@ -40993,428 +49111,617 @@ "results" ] }, - "LedgerRow": { + "MachineOperator": { "type": "object", "properties": { - "source": { - "$ref": "#/components/schemas/LedgerRowSourceEnum" + "id": { + "type": "integer", + "readOnly": true }, - "item_name": { - "type": "string" + "user": { + "type": "integer", + "readOnly": true }, - "container": { + "username": { "type": "string", - "nullable": true - }, - "holder": { - "type": "string" - }, - "quantity": { - "type": "integer" - }, - "units": { - "type": "array", - "items": { - "$ref": "#/components/schemas/LedgerUnit" - } + "readOnly": true }, - "target_label": { - "type": "string", - "nullable": true + "access_level": { + "allOf": [ + { + "$ref": "#/components/schemas/AccessLevelEnum" + } + ], + "readOnly": true }, - "since": { + "assigned_by_username": { "type": "string", - "format": "date-time", + "readOnly": true, "nullable": true }, - "due": { + "assigned_at": { "type": "string", "format": "date-time", - "nullable": true - }, - "makerspace_id": { - "type": "integer" - }, - "reference_id": { - "type": "integer" - }, - "status": { - "type": "string" + "readOnly": true } }, "required": [ - "due", - "holder", - "item_name", - "makerspace_id", - "quantity", - "reference_id", - "since", - "source", - "status", - "units" + "access_level", + "assigned_at", + "assigned_by_username", + "id", + "user", + "username" ] }, - "LedgerRowSourceEnum": { - "enum": [ - "request", - "self_checkout", - "direct_handout" - ], - "type": "string", - "description": "* `request` - request\n* `self_checkout` - self_checkout\n* `direct_handout` - direct_handout" - }, - "LedgerUnit": { + "MachineScopeOption": { "type": "object", "properties": { - "asset_tag": { - "type": "string" + "id": { + "type": "integer" }, - "serial_number": { + "label": { "type": "string" + }, + "is_builtin": { + "type": "boolean" + }, + "is_active": { + "type": "boolean" } }, "required": [ - "asset_tag", - "serial_number" + "id", + "label" ] }, - "LegacyResetPasswordConfirm": { + "MachineServiceConsumption": { "type": "object", "properties": { - "uid": { + "makerspace_id": { + "type": "integer" + }, + "machine_id": { + "type": "integer" + }, + "machine_name": { "type": "string" }, - "token": { + "machine_type": { "type": "string", - "writeOnly": true + "nullable": true }, - "new_password": { + "measurement": { + "$ref": "#/components/schemas/MachineServiceConsumptionMeasurementEnum" + }, + "product_id": { + "type": "integer", + "nullable": true + }, + "product_label": { + "type": "string" + }, + "completed_amount": { "type": "string", - "writeOnly": true + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" + }, + "failed_partial_amount": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" + }, + "total_used": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" } }, "required": [ - "new_password", - "token", - "uid" + "completed_amount", + "failed_partial_amount", + "machine_id", + "machine_name", + "machine_type", + "measurement", + "product_id", + "product_label", + "total_used" ] }, - "LegacyRoleEnum": { + "MachineServiceConsumptionMeasurementEnum": { "enum": [ - "space_manager", - "inventory_manager", - "print_manager", - "machine_manager" + "count", + "grams" ], "type": "string", - "description": "* `space_manager` - Space Manager\n* `inventory_manager` - Inventory Manager\n* `print_manager` - Print Manager\n* `machine_manager` - Machine Manager" + "description": "* `count` - count\n* `grams` - grams" }, - "LendingHistoryActor": { + "MachineServiceFailure": { "type": "object", "properties": { - "username": { + "makerspace_id": { + "type": "integer" + }, + "machine_id": { + "type": "integer" + }, + "machine_name": { "type": "string" }, - "role": { + "machine_type": { + "type": "string", + "nullable": true + }, + "outcome": { "type": "string" + }, + "failed_count": { + "type": "integer" + }, + "failed_partial_hours": { + "type": "number", + "format": "double" + }, + "failed_count_amount": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" + }, + "failed_grams_amount": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" } }, "required": [ - "role", - "username" + "failed_count", + "failed_count_amount", + "failed_grams_amount", + "failed_partial_hours", + "machine_id", + "machine_name", + "machine_type", + "outcome" ] }, - "LendingHistoryEntry": { + "MachineServiceMachine": { "type": "object", "properties": { - "id": { + "makerspace_id": { "type": "integer" }, - "username": { + "machine_id": { + "type": "integer" + }, + "machine_name": { "type": "string" }, - "issued_at": { + "machine_type": { "type": "string", - "format": "date-time" + "nullable": true }, - "quantity": { + "request_count": { "type": "integer" }, - "issued_by": { - "allOf": [ - { - "$ref": "#/components/schemas/LendingHistoryActor" - } - ], - "nullable": true + "completed_count": { + "type": "integer" }, - "accepted_by": { - "allOf": [ - { - "$ref": "#/components/schemas/LendingHistoryActor" - } - ], + "failed_count": { + "type": "integer" + }, + "completed_hours": { + "type": "number", + "format": "double" + }, + "failed_partial_hours": { + "type": "number", + "format": "double" + }, + "total_recorded_service_hours": { + "type": "number", + "format": "double" + }, + "failure_rate": { + "type": "number", + "format": "double", "nullable": true } }, "required": [ - "accepted_by", - "id", - "issued_at", - "issued_by", - "quantity", - "username" + "completed_count", + "completed_hours", + "failed_count", + "failed_partial_hours", + "failure_rate", + "machine_id", + "machine_name", + "machine_type", + "request_count", + "total_recorded_service_hours" ] }, - "LendingHistoryResponse": { + "MachineServiceReport": { "type": "object", "properties": { - "product_id": { - "type": "integer" + "status_totals": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MachineServiceStatusTotals" + } }, - "last_borrower": { - "allOf": [ - { - "$ref": "#/components/schemas/LendingHistoryEntry" - } - ], - "nullable": true + "machines": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MachineServiceMachine" + } }, - "recent": { + "consumption": { "type": "array", "items": { - "$ref": "#/components/schemas/LendingHistoryEntry" + "$ref": "#/components/schemas/MachineServiceConsumption" + } + }, + "failure_summary": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MachineServiceFailure" } } }, "required": [ - "last_borrower", - "product_id", - "recent" + "consumption", + "failure_summary", + "machines", + "status_totals" ] }, - "LevelEnum": { - "enum": [ - "info", - "warning", - "critical" - ], - "type": "string", - "description": "* `info` - Info\n* `warning` - Warning\n* `critical` - Critical" + "MachineServiceReportResponse": { + "oneOf": [ + { + "$ref": "#/components/schemas/MachineServiceReport" + }, + { + "$ref": "#/components/schemas/PrinterServiceReport" + } + ] }, - "LinkMachineConsumable": { + "MachineServiceRequest": { "type": "object", "properties": { - "measurement": { - "$ref": "#/components/schemas/Measurement883Enum" + "id": { + "type": "integer", + "readOnly": true }, - "product_id": { + "bucket_id": { + "type": "integer", + "readOnly": true + }, + "queue_id": { + "type": "integer", + "readOnly": true + }, + "machine_type": { + "type": "string", + "readOnly": true + }, + "machine": { + "allOf": [ + { + "$ref": "#/components/schemas/ServiceMachine" + } + ], + "readOnly": true + }, + "assigned_machine": { + "allOf": [ + { + "$ref": "#/components/schemas/ServiceMachine" + } + ], + "readOnly": true + }, + "requester": { + "allOf": [ + { + "$ref": "#/components/schemas/ServiceRequester" + } + ], + "readOnly": true + }, + "requester_name": { + "type": "string", + "readOnly": true + }, + "contact_email": { + "type": "string", + "readOnly": true + }, + "contact_phone": { + "type": "string", + "readOnly": true + }, + "title": { + "type": "string", + "readOnly": true + }, + "description": { + "type": "string", + "readOnly": true + }, + "source_link": { + "type": "string", + "format": "uri", + "readOnly": true + }, + "status": { + "allOf": [ + { + "$ref": "#/components/schemas/MachineServiceRequestStatusEnum" + } + ], + "readOnly": true + }, + "reason": { + "type": "string", + "readOnly": true + }, + "estimated_minutes": { + "type": "integer", + "readOnly": true + }, + "actual_minutes": { + "type": "integer", + "readOnly": true + }, + "fail_percent_complete": { "type": "integer", + "readOnly": true + }, + "accepted_at": { + "type": "string", + "format": "date-time", + "readOnly": true, "nullable": true }, - "label": { + "started_at": { "type": "string", - "maxLength": 200 + "format": "date-time", + "readOnly": true, + "nullable": true }, - "remaining": { + "completed_at": { "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" + "format": "date-time", + "readOnly": true, + "nullable": true }, - "low_threshold": { + "failed_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true + }, + "collected_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true + }, + "created_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "updated_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "capability_payload": { + "readOnly": true + }, + "metering_unit": { + "type": "string", + "readOnly": true, + "nullable": true + }, + "planned_quantity": { "type": "string", "format": "decimal", "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", + "readOnly": true, "nullable": true }, - "note": { - "type": "string", - "maxLength": 255 - } - }, - "required": [ - "measurement" - ] - }, - "LocationKindEnum": { - "enum": [ - "indoor", - "outdoor", - "other" - ], - "type": "string", - "description": "* `indoor` - Indoor\n* `outdoor` - Outdoor\n* `other` - Other" - }, - "LogError": { - "type": "object", - "properties": { - "severity": { + "reserved_quantity": { "type": "string", - "maxLength": 16 + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", + "readOnly": true, + "nullable": true }, - "message": { - "type": "string" - } - }, - "required": [ - "message", - "severity" - ] - }, - "LogMachineConsumption": { - "type": "object", - "properties": { - "quantity": { + "actual_consumed_quantity": { "type": "string", "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" - } - }, - "required": [ - "quantity" - ] - }, - "LogUsage": { - "type": "object", - "properties": { - "hours": { + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", + "readOnly": true, + "nullable": true + }, + "planned_grams": { "type": "string", "format": "decimal", - "pattern": "^-?\\d{0,8}(?:\\.\\d{0,2})?$" + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", + "readOnly": true }, - "note": { + "reserved_grams": { "type": "string", - "maxLength": 255 - } - }, - "required": [ - "hours" - ] - }, - "LoginRequest": { - "type": "object", - "properties": { - "username": { - "type": "string" + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", + "readOnly": true }, - "password": { + "actual_consumed_grams": { "type": "string", - "writeOnly": true + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", + "readOnly": true }, - "surface": { + "payment": { "allOf": [ { - "$ref": "#/components/schemas/LoginRequestSurfaceEnum" + "$ref": "#/components/schemas/StaffPayment" } ], - "default": "member" - } - }, - "required": [ - "password", - "username" - ] - }, - "LoginRequestSurfaceEnum": { - "enum": [ - "member", - "staff" - ], - "type": "string", - "description": "* `member` - member\n* `staff` - staff" - }, - "LoginResponse": { - "type": "object", - "properties": { - "user": { - "$ref": "#/components/schemas/AuthUserPayload" + "nullable": true, + "readOnly": true }, - "access": { - "type": "string" + "run_machine_model": { + "type": "string", + "readOnly": true }, - "surface": { - "$ref": "#/components/schemas/LoginResponseSurfaceEnum" + "files": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ServiceFile" + }, + "readOnly": true + }, + "consumptions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ServiceConsumption" + }, + "readOnly": true } }, "required": [ - "access", - "surface", - "user" + "accepted_at", + "actual_consumed_grams", + "actual_consumed_quantity", + "actual_minutes", + "assigned_machine", + "bucket_id", + "capability_payload", + "collected_at", + "completed_at", + "consumptions", + "contact_email", + "contact_phone", + "created_at", + "description", + "estimated_minutes", + "fail_percent_complete", + "failed_at", + "files", + "id", + "machine", + "machine_type", + "metering_unit", + "payment", + "planned_grams", + "planned_quantity", + "queue_id", + "reason", + "requester", + "requester_name", + "reserved_grams", + "reserved_quantity", + "run_machine_model", + "source_link", + "started_at", + "status", + "title", + "updated_at" ] }, - "LoginResponseSurfaceEnum": { + "MachineServiceRequestStatusEnum": { "enum": [ - "member", - "staff", - "verification_only" + "pending", + "accepted", + "in_progress", + "completed", + "collected", + "rejected", + "failed" ], "type": "string", - "description": "* `member` - member\n* `staff` - staff\n* `verification_only` - verification_only" + "description": "* `pending` - Pending\n* `accepted` - Accepted\n* `in_progress` - In progress\n* `completed` - Completed\n* `collected` - Collected\n* `rejected` - Rejected\n* `failed` - Failed" }, - "LogoutResponse": { + "MachineServiceStatusTotals": { "type": "object", "properties": { - "detail": { - "type": "string" + "makerspace_id": { + "type": "integer" + }, + "submitted": { + "type": "integer" + }, + "accepted": { + "type": "integer" + }, + "in_progress": { + "type": "integer" + }, + "completed": { + "type": "integer" + }, + "collected": { + "type": "integer" + }, + "rejected": { + "type": "integer" + }, + "failed": { + "type": "integer" } }, "required": [ - "detail" + "accepted", + "collected", + "completed", + "failed", + "in_progress", + "rejected", + "submitted" ] }, - "Machine": { + "MachineServiceSubmit": { "type": "object", "properties": { - "id": { - "type": "integer", - "readOnly": true - }, - "makerspace": { + "requester_id": { "type": "integer", - "readOnly": true - }, - "machine_type": { - "allOf": [ - { - "$ref": "#/components/schemas/MachineType" - } - ], - "readOnly": true + "minimum": 1 }, - "machine_type_id": { + "machine_id": { "type": "integer", - "writeOnly": true - }, - "name": { - "type": "string", - "maxLength": 200 + "minimum": 1 }, - "location": { + "title": { "type": "string", "maxLength": 200 }, - "notes": { + "description": { "type": "string" }, - "status": { - "allOf": [ + "source_link": { + "oneOf": [ { - "$ref": "#/components/schemas/MachineStatusEnum" + "type": "string", + "format": "uri" + }, + { + "type": "string", + "maxLength": 0 } - ], - "readOnly": true + ] }, - "firmware_version": { - "type": "string", - "maxLength": 100 + "requester_name": { + "type": "string" }, - "camera_feed_url": { + "contact_email": { "oneOf": [ { "type": "string", - "format": "uri", - "maxLength": 200 + "format": "email" }, { "type": "string", @@ -41422,215 +49729,273 @@ } ] }, - "type_payload": {}, - "image_url": { + "contact_phone": { "type": "string", - "nullable": true, + "maxLength": 32 + }, + "capability_payload": {} + }, + "required": [ + "machine_id", + "requester_id", + "title" + ] + }, + "MachineStatusEnum": { + "enum": [ + "idle", + "running", + "reserved", + "maintenance", + "offline" + ], + "type": "string", + "description": "* `idle` - Idle\n* `running` - Running\n* `reserved` - Reserved\n* `maintenance` - Maintenance\n* `offline` - Offline" + }, + "MachineType": { + "type": "object", + "properties": { + "id": { + "type": "integer", "readOnly": true }, - "warranty_status": { + "slug": { "type": "string", - "readOnly": true + "maxLength": 50, + "pattern": "^[-a-zA-Z0-9_]+$" }, - "is_public": { - "type": "boolean", - "readOnly": true + "name": { + "type": "string", + "maxLength": 200 }, - "is_active": { - "type": "boolean", - "readOnly": true + "icon": { + "type": "string", + "maxLength": 50 }, - "usage_hours": { - "type": "number", - "format": "double", - "readOnly": true + "is_builtin": { + "type": "boolean" }, - "can_operate": { - "type": "boolean", + "managing_action": { + "type": "string", "readOnly": true }, - "can_edit": { - "type": "boolean", + "capability_config": { "readOnly": true }, - "can_delegate": { - "type": "boolean", + "makerspace": { + "type": "integer", + "readOnly": true, + "nullable": true + } + }, + "required": [ + "capability_config", + "id", + "makerspace", + "managing_action", + "name", + "slug" + ] + }, + "MachineTypeAccess": { + "type": "object", + "properties": { + "id": { + "type": "integer", "readOnly": true }, - "can_retire": { - "type": "boolean", + "slug": { + "type": "string", + "readOnly": true, + "pattern": "^[-a-zA-Z0-9_]+$" + }, + "name": { + "type": "string", "readOnly": true }, - "can_unretire": { - "type": "boolean", + "icon": { + "type": "string", "readOnly": true }, - "can_manage": { + "is_builtin": { "type": "boolean", "readOnly": true }, - "created_at": { + "managing_action": { "type": "string", - "format": "date-time", "readOnly": true }, - "updated_at": { - "type": "string", - "format": "date-time", + "capability_config": { + "readOnly": true + }, + "makerspace": { + "type": "integer", + "readOnly": true, + "nullable": true + }, + "can_create_machine": { + "type": "boolean", "readOnly": true } }, "required": [ - "can_delegate", - "can_edit", - "can_manage", - "can_operate", - "can_retire", - "can_unretire", - "created_at", + "can_create_machine", + "capability_config", + "icon", "id", - "image_url", - "is_active", - "is_public", - "machine_type", - "machine_type_id", + "is_builtin", "makerspace", + "managing_action", "name", - "status", - "updated_at", - "usage_hours", - "warranty_status" + "slug" ] }, - "MachineConsumable": { + "MachineTypeCreate": { + "type": "object", + "properties": { + "slug": { + "type": "string", + "maxLength": 50, + "pattern": "^[-a-zA-Z0-9_]+$" + }, + "name": { + "type": "string", + "maxLength": 200 + }, + "icon": { + "type": "string", + "maxLength": 50 + }, + "capability_config": {} + }, + "required": [ + "name", + "slug" + ] + }, + "MachineTypeOption": { "type": "object", "properties": { "id": { "type": "integer", "readOnly": true }, - "measurement": { - "allOf": [ - { - "$ref": "#/components/schemas/Measurement883Enum" - } - ], + "name": { + "type": "string", "readOnly": true }, - "product": { - "type": "integer", - "readOnly": true, - "nullable": true - }, - "product_name": { - "type": "string", - "nullable": true, + "is_active": { + "type": "boolean", "readOnly": true }, - "available": { + "is_overridden": { + "type": "boolean", + "readOnly": true + } + }, + "required": [ + "id", + "is_active", + "is_overridden", + "name" + ] + }, + "MachineTypePricing": { + "type": "object", + "properties": { + "machine_type_id": { "type": "integer", - "nullable": true, "readOnly": true }, - "remaining": { + "rate_per_unit": { "type": "string", "format": "decimal", "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", - "nullable": true, "readOnly": true }, - "label": { - "type": "string", - "readOnly": true - }, - "low_threshold": { + "flat_fee": { "type": "string", "format": "decimal", "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", - "readOnly": true, - "nullable": true - }, - "note": { - "type": "string", "readOnly": true }, - "created_at": { - "type": "string", - "format": "date-time", + "payment_enabled": { + "type": "boolean", "readOnly": true } }, "required": [ - "available", - "created_at", - "id", - "label", - "low_threshold", - "measurement", - "note", - "product", - "product_name", - "remaining" + "flat_fee", + "machine_type_id", + "payment_enabled", + "rate_per_unit" ] }, - "MachineDocument": { + "MachineTypePricingList": { "type": "object", "properties": { - "id": { - "type": "integer", - "readOnly": true - }, - "doc_type": { - "allOf": [ - { - "$ref": "#/components/schemas/DocTypeEnum" - } - ], - "readOnly": true - }, - "original_filename": { + "currency": { "type": "string", "readOnly": true }, - "content_type": { + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MachineTypePricing" + }, + "readOnly": true + } + }, + "required": [ + "currency", + "results" + ] + }, + "MachineTypePricingSet": { + "type": "object", + "properties": { + "rate_per_unit": { "type": "string", - "readOnly": true - }, - "size_bytes": { - "type": "integer", - "readOnly": true + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" }, - "created_at": { + "flat_fee": { "type": "string", - "format": "date-time", - "readOnly": true + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" + }, + "payment_enabled": { + "type": "boolean" } }, "required": [ - "content_type", - "created_at", - "doc_type", - "id", - "original_filename", - "size_bytes" + "flat_fee", + "payment_enabled", + "rate_per_unit" ] }, - "MachineErrorLog": { + "MachineUsageEntry": { "type": "object", "properties": { "id": { "type": "integer", "readOnly": true }, - "severity": { + "hours": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,8}(?:\\.\\d{0,2})?$", + "readOnly": true + }, + "source": { "allOf": [ { - "$ref": "#/components/schemas/SeverityEnum" + "$ref": "#/components/schemas/MachineUsageEntrySourceEnum" } ], "readOnly": true }, - "message": { + "note": { "type": "string", "readOnly": true }, @@ -41647,95 +50012,102 @@ }, "required": [ "created_at", + "hours", "id", "logged_by_username", - "message", - "severity" + "note", + "source" ] }, - "MachineListResponse": { + "MachineUsageEntrySourceEnum": { + "enum": [ + "manual", + "typed_manual" + ], + "type": "string", + "description": "* `manual` - Manual hours\n* `typed_manual` - Typed manual service" + }, + "MachineUsageReport": { "type": "object", "properties": { - "count": { - "type": "integer" + "rows": { + "type": "array", + "items": { + "type": "array", + "items": {} + } }, - "results": { + "typed_rows": { "type": "array", "items": { - "$ref": "#/components/schemas/Machine" + "$ref": "#/components/schemas/MachineUsageRow" } } }, "required": [ - "count", - "results" + "rows", + "typed_rows" ] }, - "MachineOperator": { + "MachineUsageRow": { "type": "object", "properties": { - "id": { - "type": "integer", - "readOnly": true + "makerspace_id": { + "type": "integer" }, - "user": { - "type": "integer", - "readOnly": true + "machine_id": { + "type": "integer" }, - "username": { - "type": "string", - "readOnly": true + "machine_name": { + "type": "string" }, - "access_level": { - "allOf": [ - { - "$ref": "#/components/schemas/AccessLevelEnum" - } - ], - "readOnly": true + "machine_type": { + "type": "string" }, - "assigned_by_username": { - "type": "string", - "readOnly": true, - "nullable": true + "is_active": { + "type": "boolean" }, - "assigned_at": { + "usage_entries": { + "type": "integer" + }, + "usage_hours": { "type": "string", - "format": "date-time", - "readOnly": true + "format": "decimal", + "pattern": "^-?\\d{0,18}(?:\\.\\d{0,2})?$" } }, "required": [ - "access_level", - "assigned_at", - "assigned_by_username", - "id", - "user", - "username" + "is_active", + "machine_id", + "machine_name", + "machine_type", + "usage_entries", + "usage_hours" ] }, - "MachineScopeOption": { + "MaintenanceActivityReport": { "type": "object", "properties": { - "id": { - "type": "integer" - }, - "label": { - "type": "string" - }, - "is_builtin": { - "type": "boolean" + "rows": { + "type": "array", + "items": { + "type": "array", + "items": {} + } }, - "is_active": { - "type": "boolean" + "typed_rows": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MaintenanceActivityRow" + } } }, "required": [ - "id", - "label" + "rows", + "typed_rows" ] }, - "MachineServiceConsumption": { + "MaintenanceActivityRow": { "type": "object", "properties": { "makerspace_id": { @@ -41748,540 +50120,656 @@ "type": "string" }, "machine_type": { - "type": "string", - "nullable": true + "type": "string" }, - "measurement": { - "$ref": "#/components/schemas/MachineServiceConsumptionMeasurementEnum" + "is_active": { + "type": "boolean" }, - "product_id": { - "type": "integer", - "nullable": true + "log_count": { + "type": "integer" }, - "product_label": { - "type": "string" + "costed_log_count": { + "type": "integer" }, - "completed_amount": { + "total_cost": { "type": "string", "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" + "pattern": "^-?\\d{0,18}(?:\\.\\d{0,2})?$" }, - "failed_partial_amount": { + "average_cost": { "type": "string", "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" + "pattern": "^-?\\d{0,18}(?:\\.\\d{0,2})?$", + "nullable": true }, - "total_used": { + "last_performed_at": { "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" + "format": "date-time", + "nullable": true + }, + "average_interval_days": { + "type": "number", + "format": "double", + "nullable": true + }, + "active_schedules": { + "type": "integer" + }, + "overdue_schedules": { + "type": "integer" } }, "required": [ - "completed_amount", - "failed_partial_amount", + "active_schedules", + "average_cost", + "average_interval_days", + "costed_log_count", + "is_active", + "last_performed_at", + "log_count", "machine_id", "machine_name", "machine_type", - "measurement", - "product_id", - "product_label", - "total_used" + "overdue_schedules", + "total_cost" ] }, - "MachineServiceConsumptionMeasurementEnum": { - "enum": [ - "count", - "grams" - ], - "type": "string", - "description": "* `count` - count\n* `grams` - grams" + "MaintenanceDocumentFinalize": { + "type": "object", + "properties": { + "object_key": { + "type": "string", + "maxLength": 500 + } + }, + "required": [ + "object_key" + ] }, - "MachineServiceFailure": { + "MaintenanceDocumentPresign": { "type": "object", "properties": { - "makerspace_id": { - "type": "integer" - }, - "machine_id": { - "type": "integer" + "filename": { + "type": "string", + "maxLength": 255 }, - "machine_name": { + "content_type": { + "type": "string", + "maxLength": 100 + } + }, + "required": [ + "content_type", + "filename" + ] + }, + "MaintenanceDocumentPresignResponse": { + "type": "object", + "properties": { + "object_key": { "type": "string" }, - "machine_type": { + "upload": { + "$ref": "#/components/schemas/MaintenanceDocumentUpload" + } + }, + "required": [ + "object_key", + "upload" + ] + }, + "MaintenanceDocumentUpload": { + "type": "object", + "properties": { + "url": { "type": "string", - "nullable": true + "format": "uri" }, - "outcome": { + "method": { "type": "string" }, - "failed_count": { - "type": "integer" + "fields": { + "type": "object", + "additionalProperties": {} }, - "failed_partial_hours": { - "type": "number", - "format": "double" + "headers": { + "type": "object", + "additionalProperties": {} + } + }, + "required": [ + "url" + ] + }, + "MaintenanceDocumentUrl": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + } + }, + "required": [ + "url" + ] + }, + "MaintenanceLog": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "readOnly": true }, - "failed_count_amount": { + "machine_id": { + "type": "integer", + "readOnly": true + }, + "performed_by_id": { + "type": "integer", + "readOnly": true, + "nullable": true + }, + "performed_at": { "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" + "format": "date-time", + "readOnly": true }, - "failed_grams_amount": { + "summary": { + "type": "string", + "readOnly": true + }, + "cost": { "type": "string", "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", + "readOnly": true, + "nullable": true + }, + "parts_note": { + "type": "string", + "readOnly": true + }, + "created_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "documents": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MaintenanceLogDocument" + }, + "readOnly": true } }, "required": [ - "failed_count", - "failed_count_amount", - "failed_grams_amount", - "failed_partial_hours", + "cost", + "created_at", + "documents", + "id", "machine_id", - "machine_name", - "machine_type", - "outcome" + "parts_note", + "performed_at", + "performed_by_id", + "summary" ] }, - "MachineServiceMachine": { + "MaintenanceLogDocument": { "type": "object", "properties": { - "makerspace_id": { - "type": "integer" + "id": { + "type": "integer", + "readOnly": true }, - "machine_id": { + "log_id": { + "type": "integer", + "readOnly": true + }, + "object_key": { + "type": "string", + "readOnly": true + }, + "size_bytes": { + "type": "integer", + "readOnly": true + }, + "uploaded_by_id": { + "type": "integer", + "readOnly": true, + "nullable": true + }, + "created_at": { + "type": "string", + "format": "date-time", + "readOnly": true + } + }, + "required": [ + "created_at", + "id", + "log_id", + "object_key", + "size_bytes", + "uploaded_by_id" + ] + }, + "MaintenanceLogList": { + "type": "object", + "properties": { + "count": { "type": "integer" }, - "machine_name": { + "next": { + "type": "string", + "nullable": true + }, + "previous": { + "type": "string", + "nullable": true + }, + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MaintenanceLog" + } + } + }, + "required": [ + "count", + "results" + ] + }, + "MaintenanceLogWrite": { + "type": "object", + "properties": { + "summary": { "type": "string" }, - "machine_type": { + "performed_at": { "type": "string", + "format": "date-time" + }, + "cost": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", "nullable": true }, - "request_count": { - "type": "integer" + "parts_note": { + "type": "string", + "default": "" }, - "completed_count": { - "type": "integer" + "set_idle": { + "type": "boolean", + "default": false }, - "failed_count": { - "type": "integer" + "schedule_id": { + "type": "integer", + "minimum": 1, + "writeOnly": true + } + }, + "required": [ + "summary" + ] + }, + "MaintenanceSchedule": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "readOnly": true }, - "completed_hours": { - "type": "number", - "format": "double" + "machine_id": { + "type": "integer", + "readOnly": true }, - "failed_partial_hours": { - "type": "number", - "format": "double" + "description": { + "type": "string", + "readOnly": true }, - "total_recorded_service_hours": { - "type": "number", - "format": "double" + "interval_days": { + "type": "integer", + "readOnly": true }, - "failure_rate": { - "type": "number", - "format": "double", + "next_due": { + "type": "string", + "format": "date", + "readOnly": true + }, + "is_active": { + "type": "boolean", + "readOnly": true + }, + "created_by_id": { + "type": "integer", + "readOnly": true, "nullable": true + }, + "created_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "updated_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "overdue": { + "type": "boolean", + "readOnly": true } }, "required": [ - "completed_count", - "completed_hours", - "failed_count", - "failed_partial_hours", - "failure_rate", + "created_at", + "created_by_id", + "description", + "id", + "interval_days", + "is_active", "machine_id", - "machine_name", - "machine_type", - "request_count", - "total_recorded_service_hours" + "next_due", + "overdue", + "updated_at" ] }, - "MachineServiceReport": { + "MaintenanceScheduleList": { "type": "object", "properties": { - "status_totals": { - "type": "array", - "items": { - "$ref": "#/components/schemas/MachineServiceStatusTotals" - } + "count": { + "type": "integer" }, - "machines": { - "type": "array", - "items": { - "$ref": "#/components/schemas/MachineServiceMachine" - } + "next": { + "type": "string", + "nullable": true }, - "consumption": { - "type": "array", - "items": { - "$ref": "#/components/schemas/MachineServiceConsumption" - } + "previous": { + "type": "string", + "nullable": true }, - "failure_summary": { + "results": { "type": "array", "items": { - "$ref": "#/components/schemas/MachineServiceFailure" + "$ref": "#/components/schemas/MaintenanceSchedule" } } }, "required": [ - "consumption", - "failure_summary", - "machines", - "status_totals" + "count", + "results" ] }, - "MachineServiceReportResponse": { - "oneOf": [ - { - "$ref": "#/components/schemas/MachineServiceReport" + "MaintenanceScheduleWrite": { + "type": "object", + "properties": { + "description": { + "type": "string" }, - { - "$ref": "#/components/schemas/PrinterServiceReport" + "interval_days": { + "type": "integer", + "minimum": 1 + }, + "next_due": { + "type": "string", + "format": "date" } + }, + "required": [ + "description", + "interval_days", + "next_due" ] }, - "MachineServiceRequest": { + "Makerspace": { "type": "object", "properties": { "id": { "type": "integer", "readOnly": true }, - "bucket_id": { - "type": "integer", - "readOnly": true + "name": { + "type": "string", + "maxLength": 200 }, - "queue_id": { - "type": "integer", - "readOnly": true + "public_code": { + "type": "string", + "pattern": "^[A-Z0-9]{4}$", + "maxLength": 4 }, - "machine_type": { + "slug": { "type": "string", - "readOnly": true + "maxLength": 50, + "pattern": "^[-a-zA-Z0-9_]+$" }, - "machine": { - "allOf": [ - { - "$ref": "#/components/schemas/ServiceMachine" - } - ], - "readOnly": true + "location": { + "type": "string", + "maxLength": 200 }, - "assigned_machine": { - "allOf": [ + "map_url": { + "oneOf": [ { - "$ref": "#/components/schemas/ServiceMachine" - } - ], - "readOnly": true - }, - "requester": { - "allOf": [ + "type": "string", + "format": "uri", + "maxLength": 200 + }, { - "$ref": "#/components/schemas/ServiceRequester" + "type": "string", + "maxLength": 0 } - ], - "readOnly": true + ] }, - "requester_name": { + "geofence_latitude": { "type": "string", - "readOnly": true + "format": "decimal", + "pattern": "^-?\\d{0,3}(?:\\.\\d{0,6})?$", + "nullable": true }, - "contact_email": { + "geofence_longitude": { "type": "string", - "readOnly": true + "format": "decimal", + "pattern": "^-?\\d{0,3}(?:\\.\\d{0,6})?$", + "nullable": true }, - "contact_phone": { - "type": "string", - "readOnly": true + "geofence_radius_m": { + "type": "integer", + "maximum": 2147483647, + "minimum": 1 }, - "title": { - "type": "string", - "readOnly": true + "geofence_enabled": { + "type": "boolean" }, - "description": { - "type": "string", - "readOnly": true + "public_inventory_enabled": { + "type": "boolean" }, - "source_link": { - "type": "string", - "format": "uri", - "readOnly": true + "public_stats_enabled": { + "type": "boolean" }, - "status": { - "allOf": [ - { - "$ref": "#/components/schemas/MachineServiceRequestStatusEnum" - } - ], - "readOnly": true + "public_stats_show_holder_names": { + "type": "boolean" }, - "reason": { - "type": "string", - "readOnly": true + "public_print_status_lookup_policy": { + "$ref": "#/components/schemas/PublicPrintStatusLookupPolicyEnum" }, - "estimated_minutes": { - "type": "integer", - "readOnly": true + "membership_policy": { + "$ref": "#/components/schemas/MembershipPolicyEnum" }, - "actual_minutes": { - "type": "integer", - "readOnly": true + "membership_dues_amount": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" }, - "fail_percent_complete": { - "type": "integer", - "readOnly": true + "referrals_enabled": { + "type": "boolean" }, - "accepted_at": { + "filament_low_stock_threshold_grams": { "type": "string", - "format": "date-time", - "readOnly": true, - "nullable": true + "format": "decimal", + "pattern": "^-?\\d{0,8}(?:\\.\\d{0,2})?$" }, - "started_at": { - "type": "string", - "format": "date-time", - "readOnly": true, - "nullable": true + "superadmin_access_enabled": { + "type": "boolean" }, - "completed_at": { - "type": "string", - "format": "date-time", - "readOnly": true, - "nullable": true + "archive_custody_state": { + "allOf": [ + { + "$ref": "#/components/schemas/ArchiveCustodyStateEnum" + } + ], + "nullable": true, + "readOnly": true }, - "failed_at": { - "type": "string", - "format": "date-time", - "readOnly": true, - "nullable": true + "staff_notifications_enabled": { + "type": "boolean" }, - "collected_at": { - "type": "string", - "format": "date-time", - "readOnly": true, - "nullable": true + "booking_requester_notifications_enabled": { + "type": "boolean" }, - "created_at": { + "logo_key": { "type": "string", - "format": "date-time", "readOnly": true }, - "updated_at": { + "logo_url": { "type": "string", - "format": "date-time", + "format": "uri", + "nullable": true, "readOnly": true }, - "capability_payload": { + "cover_image_key": { + "type": "string", "readOnly": true }, - "metering_unit": { + "cover_image_url": { "type": "string", - "readOnly": true, - "nullable": true + "format": "uri", + "nullable": true, + "readOnly": true }, - "planned_quantity": { + "frontend_domain": { "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", - "readOnly": true, - "nullable": true + "nullable": true, + "maxLength": 255 }, - "reserved_quantity": { - "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", - "readOnly": true, - "nullable": true + "frontend_domain_status": { + "allOf": [ + { + "$ref": "#/components/schemas/FrontendDomainStatusEnum" + } + ], + "readOnly": true }, - "actual_consumed_quantity": { + "domain_verified_at": { "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", + "format": "date-time", "readOnly": true, "nullable": true }, - "planned_grams": { + "domain_verification_token": { "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", "readOnly": true }, - "reserved_grams": { - "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", + "domain_verification_record": { + "type": "object", + "nullable": true, + "properties": { + "host": { + "type": "string" + }, + "type": { + "type": "string" + }, + "value": { + "type": "string" + } + }, "readOnly": true }, - "actual_consumed_grams": { - "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", + "platform_hosting": { + "type": "boolean", "readOnly": true }, - "payment": { - "allOf": [ - { - "$ref": "#/components/schemas/StaffPayment" - } - ], - "nullable": true, + "is_platform_subdomain": { + "type": "boolean", "readOnly": true }, - "run_machine_model": { + "hidden_from_central_directory": { + "type": "boolean" + }, + "public_api_key": { "type": "string", "readOnly": true }, - "files": { + "cors_allowed_origins": {}, + "enabled_modules": { "type": "array", "items": { - "$ref": "#/components/schemas/ServiceFile" + "type": "string" }, "readOnly": true }, - "consumptions": { + "unavailable_apps": { "type": "array", "items": { - "$ref": "#/components/schemas/ServiceConsumption" + "type": "string" }, "readOnly": true - } - }, - "required": [ - "accepted_at", - "actual_consumed_grams", - "actual_consumed_quantity", - "actual_minutes", - "assigned_machine", - "bucket_id", - "capability_payload", - "collected_at", - "completed_at", - "consumptions", - "contact_email", - "contact_phone", - "created_at", - "description", - "estimated_minutes", - "fail_percent_complete", - "failed_at", - "files", - "id", - "machine", - "machine_type", - "metering_unit", - "payment", - "planned_grams", - "planned_quantity", - "queue_id", - "reason", - "requester", - "requester_name", - "reserved_grams", - "reserved_quantity", - "run_machine_model", - "source_link", - "started_at", - "status", - "title", - "updated_at" - ] - }, - "MachineServiceRequestStatusEnum": { - "enum": [ - "pending", - "accepted", - "in_progress", - "completed", - "collected", - "rejected", - "failed" - ], - "type": "string", - "description": "* `pending` - Pending\n* `accepted` - Accepted\n* `in_progress` - In progress\n* `completed` - Completed\n* `collected` - Collected\n* `rejected` - Rejected\n* `failed` - Failed" - }, - "MachineServiceStatusTotals": { - "type": "object", - "properties": { - "makerspace_id": { - "type": "integer" - }, - "submitted": { - "type": "integer" }, - "accepted": { - "type": "integer" + "resource_limit_overrides": {}, + "enabled_features": {}, + "theme_config": {}, + "branding_config": { + "readOnly": true }, - "in_progress": { - "type": "integer" + "public_display_name": { + "type": "string", + "writeOnly": true, + "maxLength": 200 }, - "completed": { - "type": "integer" + "telegram_group_chat_id": { + "type": "string", + "maxLength": 64 }, - "collected": { - "type": "integer" + "telegram_bot_token": { + "type": "string", + "writeOnly": true }, - "rejected": { - "type": "integer" + "telegram_bot_token_set": { + "type": "boolean", + "readOnly": true }, - "failed": { - "type": "integer" - } - }, - "required": [ - "accepted", - "collected", - "completed", - "failed", - "in_progress", - "rejected", - "submitted" - ] - }, - "MachineServiceSubmit": { - "type": "object", - "properties": { - "requester_id": { - "type": "integer", - "minimum": 1 + "smtp_host": { + "type": "string", + "maxLength": 200 }, - "machine_id": { + "smtp_port": { "type": "integer", - "minimum": 1 + "maximum": 2147483647, + "minimum": 0 }, - "title": { + "smtp_username": { "type": "string", "maxLength": 200 }, - "description": { - "type": "string" + "smtp_password": { + "type": "string", + "writeOnly": true }, - "source_link": { - "oneOf": [ - { - "type": "string", - "format": "uri" - }, - { - "type": "string", - "maxLength": 0 - } - ] + "smtp_password_set": { + "type": "boolean", + "readOnly": true }, - "requester_name": { - "type": "string" + "smtp_use_tls": { + "type": "boolean" }, - "contact_email": { + "smtp_use_ssl": { + "type": "boolean" + }, + "smtp_from_email": { "oneOf": [ { "type": "string", - "format": "email" + "format": "email", + "maxLength": 254 }, { "type": "string", @@ -42289,615 +50777,441 @@ } ] }, - "contact_phone": { - "type": "string", - "maxLength": 32 - }, - "capability_payload": {} - }, - "required": [ - "machine_id", - "requester_id", - "title" - ] - }, - "MachineStatusEnum": { - "enum": [ - "idle", - "running", - "reserved", - "maintenance", - "offline" - ], - "type": "string", - "description": "* `idle` - Idle\n* `running` - Running\n* `reserved` - Reserved\n* `maintenance` - Maintenance\n* `offline` - Offline" - }, - "MachineType": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "readOnly": true - }, - "slug": { + "slack_webhook_url": { "type": "string", - "maxLength": 50, - "pattern": "^[-a-zA-Z0-9_]+$" + "writeOnly": true, + "maxLength": 2048 }, - "name": { - "type": "string", - "maxLength": 200 + "slack_webhook_url_set": { + "type": "boolean", + "readOnly": true }, - "icon": { + "mattermost_webhook_url": { "type": "string", - "maxLength": 50 + "writeOnly": true, + "maxLength": 2048 }, - "is_builtin": { - "type": "boolean" + "mattermost_webhook_url_set": { + "type": "boolean", + "readOnly": true }, - "managing_action": { + "discord_webhook_url": { "type": "string", - "readOnly": true + "writeOnly": true, + "maxLength": 2048 }, - "capability_config": { + "discord_webhook_url_set": { + "type": "boolean", "readOnly": true }, - "makerspace": { + "default_loan_days": { "type": "integer", - "readOnly": true, - "nullable": true + "maximum": 2147483647, + "minimum": 0 + }, + "presence_preset_minutes": {}, + "created_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "updated_at": { + "type": "string", + "format": "date-time", + "readOnly": true } }, "required": [ - "capability_config", + "archive_custody_state", + "branding_config", + "cover_image_key", + "cover_image_url", + "created_at", + "discord_webhook_url_set", + "domain_verification_record", + "domain_verification_token", + "domain_verified_at", + "enabled_modules", + "frontend_domain_status", "id", - "makerspace", - "managing_action", + "is_platform_subdomain", + "logo_key", + "logo_url", + "mattermost_webhook_url_set", "name", - "slug" + "platform_hosting", + "public_api_key", + "slack_webhook_url_set", + "slug", + "smtp_password_set", + "telegram_bot_token_set", + "unavailable_apps", + "updated_at" ] }, - "MachineTypeAccess": { + "MakerspaceArchiveRequest": { "type": "object", "properties": { "id": { "type": "integer", "readOnly": true }, - "slug": { - "type": "string", - "readOnly": true, - "pattern": "^[-a-zA-Z0-9_]+$" - }, - "name": { - "type": "string", - "readOnly": true - }, - "icon": { - "type": "string", + "makerspace": { + "type": "integer", "readOnly": true }, - "is_builtin": { - "type": "boolean", - "readOnly": true + "requested_by": { + "type": "integer", + "readOnly": true, + "nullable": true }, - "managing_action": { + "requested_by_username": { "type": "string", - "readOnly": true + "readOnly": true, + "nullable": true }, - "capability_config": { + "requested_at": { + "type": "string", + "format": "date-time", "readOnly": true }, - "makerspace": { + "resolved_by": { "type": "integer", "readOnly": true, "nullable": true }, - "can_create_machine": { - "type": "boolean", - "readOnly": true - } - }, - "required": [ - "can_create_machine", - "capability_config", - "icon", - "id", - "is_builtin", - "makerspace", - "managing_action", - "name", - "slug" - ] - }, - "MachineTypeCreate": { - "type": "object", - "properties": { - "slug": { + "resolved_by_username": { "type": "string", - "maxLength": 50, - "pattern": "^[-a-zA-Z0-9_]+$" + "readOnly": true, + "nullable": true }, - "name": { + "resolved_at": { "type": "string", - "maxLength": 200 + "format": "date-time", + "readOnly": true, + "nullable": true }, - "icon": { + "reason": { "type": "string", - "maxLength": 50 - }, - "capability_config": {} - }, - "required": [ - "name", - "slug" - ] - }, - "MachineTypeOption": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "readOnly": true + "description": "Do not include personal data. Maximum 2,000 characters.", + "maxLength": 2000 }, - "name": { + "resolution_note": { "type": "string", "readOnly": true }, - "is_active": { - "type": "boolean", - "readOnly": true - }, - "is_overridden": { - "type": "boolean", + "status": { + "allOf": [ + { + "$ref": "#/components/schemas/MakerspaceArchiveRequestStatusEnum" + } + ], "readOnly": true } }, "required": [ "id", - "is_active", - "is_overridden", - "name" + "makerspace", + "reason", + "requested_at", + "requested_by", + "requested_by_username", + "resolution_note", + "resolved_at", + "resolved_by", + "resolved_by_username", + "status" ] }, - "MachineTypePricing": { + "MakerspaceArchiveRequestStatusEnum": { + "enum": [ + "pending", + "approved", + "declined", + "withdrawn" + ], + "type": "string", + "description": "* `pending` - Pending\n* `approved` - Approved\n* `declined` - Declined\n* `withdrawn` - Withdrawn" + }, + "MakerspacePaymentSettings": { "type": "object", "properties": { - "machine_type_id": { - "type": "integer", - "readOnly": true - }, - "rate_per_unit": { + "default_currency": { "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", - "readOnly": true + "maxLength": 3 }, - "flat_fee": { + "stripe_publishable_key": { "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", - "readOnly": true + "writeOnly": true, + "maxLength": 255 }, - "payment_enabled": { + "stripe_publishable_key_set": { "type": "boolean", "readOnly": true - } - }, - "required": [ - "flat_fee", - "machine_type_id", - "payment_enabled", - "rate_per_unit" - ] - }, - "MachineTypePricingList": { - "type": "object", - "properties": { - "currency": { + }, + "stripe_secret_key": { "type": "string", - "readOnly": true + "writeOnly": true }, - "results": { - "type": "array", - "items": { - "$ref": "#/components/schemas/MachineTypePricing" - }, + "stripe_secret_key_set": { + "type": "boolean", "readOnly": true - } - }, - "required": [ - "currency", - "results" - ] - }, - "MachineTypePricingSet": { - "type": "object", - "properties": { - "rate_per_unit": { - "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" }, - "flat_fee": { + "stripe_webhook_secret": { "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" + "writeOnly": true }, - "payment_enabled": { - "type": "boolean" - } - }, - "required": [ - "flat_fee", - "payment_enabled", - "rate_per_unit" - ] - }, - "MachineUsageEntry": { - "type": "object", - "properties": { - "id": { - "type": "integer", + "stripe_webhook_secret_set": { + "type": "boolean", "readOnly": true }, - "hours": { + "connect_account_id": { "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,8}(?:\\.\\d{0,2})?$", - "readOnly": true + "readOnly": true, + "nullable": true }, - "source": { + "connect_status": { "allOf": [ { - "$ref": "#/components/schemas/MachineUsageEntrySourceEnum" + "$ref": "#/components/schemas/ConnectStatusEnum" } ], "readOnly": true }, - "note": { - "type": "string", + "connect_charges_enabled": { + "type": "boolean", "readOnly": true }, - "logged_by_username": { - "type": "string", - "readOnly": true, - "nullable": true + "connect_payouts_enabled": { + "type": "boolean", + "readOnly": true }, - "created_at": { + "connect_status_updated_at": { "type": "string", "format": "date-time", "readOnly": true - } - }, - "required": [ - "created_at", - "hours", - "id", - "logged_by_username", - "note", - "source" - ] - }, - "MachineUsageEntrySourceEnum": { - "enum": [ - "manual", - "typed_manual" - ], - "type": "string", - "description": "* `manual` - Manual hours\n* `typed_manual` - Typed manual service" - }, - "MachineUsageReport": { - "type": "object", - "properties": { - "rows": { - "type": "array", - "items": { - "type": "array", - "items": {} - } }, - "typed_rows": { - "type": "array", - "items": { - "$ref": "#/components/schemas/MachineUsageRow" - } + "effective_mode": { + "type": "string", + "readOnly": true } }, "required": [ - "rows", - "typed_rows" + "connect_account_id", + "connect_charges_enabled", + "connect_payouts_enabled", + "connect_status", + "connect_status_updated_at", + "effective_mode", + "stripe_publishable_key_set", + "stripe_secret_key_set", + "stripe_webhook_secret_set" ] }, - "MachineUsageRow": { + "ManagedPolicyMarker": { "type": "object", "properties": { - "makerspace_id": { - "type": "integer" - }, - "machine_id": { - "type": "integer" - }, - "machine_name": { + "feature": { "type": "string" }, - "machine_type": { + "event": { "type": "string" }, - "is_active": { - "type": "boolean" - }, - "usage_entries": { - "type": "integer" - }, - "usage_hours": { - "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,18}(?:\\.\\d{0,2})?$" + "count": { + "type": "integer", + "minimum": 1 } }, "required": [ - "is_active", - "machine_id", - "machine_name", - "machine_type", - "usage_entries", - "usage_hours" + "count", + "event", + "feature" ] }, - "MaintenanceActivityReport": { - "type": "object", - "properties": { - "rows": { - "type": "array", - "items": { - "type": "array", - "items": {} - } - }, - "typed_rows": { - "type": "array", - "items": { - "$ref": "#/components/schemas/MaintenanceActivityRow" - } - } - }, - "required": [ - "rows", - "typed_rows" - ] + "Measurement883Enum": { + "enum": [ + "count", + "grams" + ], + "type": "string", + "description": "* `count` - Count\n* `grams` - Grams" }, - "MaintenanceActivityRow": { + "MemberAccountability": { "type": "object", "properties": { - "makerspace_id": { - "type": "integer" - }, - "machine_id": { - "type": "integer" - }, - "machine_name": { - "type": "string" - }, - "machine_type": { - "type": "string" - }, - "is_active": { + "membership_active": { "type": "boolean" }, - "log_count": { - "type": "integer" - }, - "costed_log_count": { - "type": "integer" - }, - "total_cost": { - "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,18}(?:\\.\\d{0,2})?$" - }, - "average_cost": { - "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,18}(?:\\.\\d{0,2})?$", - "nullable": true - }, - "last_performed_at": { - "type": "string", - "format": "date-time", - "nullable": true - }, - "average_interval_days": { - "type": "number", - "format": "double", - "nullable": true - }, - "active_schedules": { - "type": "integer" - }, - "overdue_schedules": { - "type": "integer" - } - }, - "required": [ - "active_schedules", - "average_cost", - "average_interval_days", - "costed_log_count", - "is_active", - "last_performed_at", - "log_count", - "machine_id", - "machine_name", - "machine_type", - "overdue_schedules", - "total_cost" - ] - }, - "MaintenanceDocumentFinalize": { - "type": "object", - "properties": { - "object_key": { - "type": "string", - "maxLength": 500 - } - }, - "required": [ - "object_key" - ] - }, - "MaintenanceDocumentPresign": { - "type": "object", - "properties": { - "filename": { - "type": "string", - "maxLength": 255 + "waiver_acceptance_required": { + "type": "boolean" }, - "content_type": { + "restriction_code": { "type": "string", - "maxLength": 100 - } - }, - "required": [ - "content_type", - "filename" - ] - }, - "MaintenanceDocumentPresignResponse": { - "type": "object", - "properties": { - "object_key": { - "type": "string" - }, - "upload": { - "$ref": "#/components/schemas/MaintenanceDocumentUpload" + "nullable": true } }, "required": [ - "object_key", - "upload" + "membership_active", + "restriction_code", + "waiver_acceptance_required" ] }, - "MaintenanceDocumentUpload": { + "MemberActivity": { "type": "object", "properties": { - "url": { - "type": "string", - "format": "uri" + "active_hardware_loans": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MemberLoanActivity" + } }, - "method": { - "type": "string" + "print_requests": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MemberPrintActivity" + } }, - "fields": { - "type": "object", - "additionalProperties": {} + "machine_service_requests": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MemberMachineServiceActivity" + } }, - "headers": { + "bookings": { "type": "object", - "additionalProperties": {} + "additionalProperties": { + "type": "array", + "items": {} + } + }, + "event_registrations": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MemberEventRegistrationActivity" + } + }, + "recent_presence_sessions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MemberPresenceActivity" + } + }, + "currently_checked_in": { + "type": "boolean" + }, + "accountability": { + "$ref": "#/components/schemas/MemberAccountability" } }, "required": [ - "url" + "accountability", + "active_hardware_loans", + "currently_checked_in", + "recent_presence_sessions" ] }, - "MaintenanceDocumentUrl": { + "MemberActivityReport": { "type": "object", "properties": { - "url": { - "type": "string", - "format": "uri" + "rows": { + "type": "array", + "items": { + "type": "array", + "items": {} + } + }, + "typed_rows": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MemberActivityRow" + } } }, "required": [ - "url" + "rows", + "typed_rows" ] }, - "MaintenanceLog": { + "MemberActivityRow": { "type": "object", "properties": { - "id": { - "type": "integer", - "readOnly": true + "makerspace_id": { + "type": "integer" }, - "machine_id": { - "type": "integer", - "readOnly": true + "makerspace_name": { + "type": "string" }, - "performed_by_id": { + "membership_policy": { + "type": "string" + }, + "referrals_enabled": { + "type": "boolean" + }, + "new_members": { "type": "integer", - "readOnly": true, - "nullable": true + "description": "Current activated_at values in the selected [start, end) range, not event history." }, - "performed_at": { - "type": "string", - "format": "date-time", - "readOnly": true + "active_members": { + "type": "integer", + "description": "Current membership snapshot." }, - "summary": { - "type": "string", - "readOnly": true + "revoked_members": { + "type": "integer", + "description": "Current revoked_at values in the selected [start, end) range, not event history." }, - "cost": { - "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", - "readOnly": true, - "nullable": true + "pending_requests": { + "type": "integer", + "description": "Current membership-request snapshot." }, - "parts_note": { - "type": "string", - "readOnly": true + "open_invites": { + "type": "integer", + "description": "Current membership-request snapshot." }, - "created_at": { - "type": "string", - "format": "date-time", - "readOnly": true + "referred_joins": { + "type": "integer", + "description": "Active automatic referrals with decided_at in the selected [start, end) range." }, - "documents": { - "type": "array", - "items": { - "$ref": "#/components/schemas/MaintenanceLogDocument" - }, - "readOnly": true + "verified_members": { + "type": "integer", + "description": "Current verified_at snapshot." } }, "required": [ - "cost", - "created_at", - "documents", - "id", - "machine_id", - "parts_note", - "performed_at", - "performed_by_id", - "summary" + "active_members", + "makerspace_name", + "membership_policy", + "new_members", + "open_invites", + "pending_requests", + "referrals_enabled", + "referred_joins", + "revoked_members", + "verified_members" ] }, - "MaintenanceLogDocument": { + "MemberCalendarFeedIssue": { "type": "object", "properties": { - "id": { - "type": "integer", - "readOnly": true - }, - "log_id": { - "type": "integer", - "readOnly": true - }, - "object_key": { + "confirm_bearer_risk": { + "type": "boolean" + } + }, + "required": [ + "confirm_bearer_risk" + ] + }, + "MemberCalendarFeedIssued": { + "type": "object", + "properties": { + "feed_url": { "type": "string", + "format": "uri", "readOnly": true }, - "size_bytes": { - "type": "integer", + "token_hint": { + "type": "string", "readOnly": true }, - "uploaded_by_id": { - "type": "integer", - "readOnly": true, - "nullable": true - }, "created_at": { "type": "string", "format": "date-time", @@ -42906,1359 +51220,1299 @@ }, "required": [ "created_at", - "id", - "log_id", - "object_key", - "size_bytes", - "uploaded_by_id" + "feed_url", + "token_hint" ] }, - "MaintenanceLogList": { + "MemberCalendarFeedState": { "type": "object", "properties": { - "count": { - "type": "integer" + "enabled": { + "type": "boolean", + "readOnly": true }, - "next": { + "token_hint": { "type": "string", + "readOnly": true, "nullable": true }, - "previous": { + "created_at": { "type": "string", + "format": "date-time", + "readOnly": true, "nullable": true }, - "results": { - "type": "array", - "items": { - "$ref": "#/components/schemas/MaintenanceLog" - } - } - }, - "required": [ - "count", - "results" - ] - }, - "MaintenanceLogWrite": { - "type": "object", - "properties": { - "summary": { - "type": "string" - }, - "performed_at": { - "type": "string", - "format": "date-time" - }, - "cost": { + "rotated_at": { "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", + "format": "date-time", + "readOnly": true, "nullable": true - }, - "parts_note": { - "type": "string", - "default": "" - }, - "set_idle": { - "type": "boolean", - "default": false - }, - "schedule_id": { - "type": "integer", - "minimum": 1, - "writeOnly": true } }, "required": [ - "summary" + "created_at", + "enabled", + "rotated_at", + "token_hint" ] }, - "MaintenanceSchedule": { + "MemberClaimCode": { "type": "object", "properties": { "id": { "type": "integer", "readOnly": true }, - "machine_id": { - "type": "integer", - "readOnly": true - }, - "description": { - "type": "string", - "readOnly": true - }, - "interval_days": { + "membership_id": { "type": "integer", "readOnly": true }, - "next_due": { + "member_display_name": { "type": "string", - "format": "date", - "readOnly": true - }, - "is_active": { - "type": "boolean", "readOnly": true }, - "created_by_id": { + "issued_by_id": { "type": "integer", "readOnly": true, "nullable": true }, - "created_at": { + "issued_at": { "type": "string", "format": "date-time", "readOnly": true }, - "updated_at": { + "expires_at": { "type": "string", "format": "date-time", "readOnly": true }, - "overdue": { - "type": "boolean", - "readOnly": true - } - }, - "required": [ - "created_at", - "created_by_id", - "description", - "id", - "interval_days", - "is_active", - "machine_id", - "next_due", - "overdue", - "updated_at" - ] - }, - "MaintenanceScheduleList": { - "type": "object", - "properties": { - "count": { - "type": "integer" - }, - "next": { + "consumed_at": { "type": "string", + "format": "date-time", + "readOnly": true, "nullable": true }, - "previous": { + "revoked_at": { "type": "string", + "format": "date-time", + "readOnly": true, "nullable": true }, - "results": { - "type": "array", - "items": { - "$ref": "#/components/schemas/MaintenanceSchedule" - } + "status": { + "type": "string", + "readOnly": true } }, "required": [ - "count", - "results" + "consumed_at", + "expires_at", + "id", + "issued_at", + "issued_by_id", + "member_display_name", + "membership_id", + "revoked_at", + "status" ] }, - "MaintenanceScheduleWrite": { + "MemberClaimCodeIssueRequest": { "type": "object", "properties": { - "description": { - "type": "string" - }, - "interval_days": { + "membership_id": { "type": "integer", "minimum": 1 - }, - "next_due": { - "type": "string", - "format": "date" } }, "required": [ - "description", - "interval_days", - "next_due" + "membership_id" ] }, - "Makerspace": { + "MemberClaimCodeIssueResponse": { "type": "object", "properties": { "id": { - "type": "integer", - "readOnly": true - }, - "name": { - "type": "string", - "maxLength": 200 - }, - "public_code": { - "type": "string", - "pattern": "^[A-Z0-9]{4}$", - "maxLength": 4 - }, - "slug": { - "type": "string", - "maxLength": 50, - "pattern": "^[-a-zA-Z0-9_]+$" - }, - "location": { - "type": "string", - "maxLength": 200 - }, - "map_url": { - "oneOf": [ - { - "type": "string", - "format": "uri", - "maxLength": 200 - }, - { - "type": "string", - "maxLength": 0 - } - ] - }, - "geofence_latitude": { - "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,3}(?:\\.\\d{0,6})?$", - "nullable": true - }, - "geofence_longitude": { - "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,3}(?:\\.\\d{0,6})?$", - "nullable": true - }, - "geofence_radius_m": { - "type": "integer", - "maximum": 2147483647, - "minimum": 1 - }, - "geofence_enabled": { - "type": "boolean" - }, - "public_inventory_enabled": { - "type": "boolean" - }, - "public_stats_enabled": { - "type": "boolean" - }, - "public_stats_show_holder_names": { - "type": "boolean" - }, - "public_print_status_lookup_policy": { - "$ref": "#/components/schemas/PublicPrintStatusLookupPolicyEnum" - }, - "membership_policy": { - "$ref": "#/components/schemas/MembershipPolicyEnum" - }, - "membership_dues_amount": { - "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$" - }, - "referrals_enabled": { - "type": "boolean" - }, - "filament_low_stock_threshold_grams": { - "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,8}(?:\\.\\d{0,2})?$" - }, - "superadmin_access_enabled": { - "type": "boolean" - }, - "archive_custody_state": { - "allOf": [ - { - "$ref": "#/components/schemas/ArchiveCustodyStateEnum" - } - ], - "nullable": true, - "readOnly": true - }, - "staff_notifications_enabled": { - "type": "boolean" - }, - "booking_requester_notifications_enabled": { - "type": "boolean" - }, - "logo_key": { - "type": "string", - "readOnly": true - }, - "logo_url": { - "type": "string", - "format": "uri", - "nullable": true, - "readOnly": true - }, - "cover_image_key": { - "type": "string", - "readOnly": true - }, - "cover_image_url": { - "type": "string", - "format": "uri", - "nullable": true, - "readOnly": true - }, - "frontend_domain": { - "type": "string", - "nullable": true, - "maxLength": 255 - }, - "frontend_domain_status": { - "allOf": [ - { - "$ref": "#/components/schemas/FrontendDomainStatusEnum" - } - ], - "readOnly": true - }, - "domain_verified_at": { - "type": "string", - "format": "date-time", - "readOnly": true, - "nullable": true - }, - "domain_verification_token": { - "type": "string", - "readOnly": true - }, - "domain_verification_record": { - "type": "object", - "nullable": true, - "properties": { - "host": { - "type": "string" - }, - "type": { - "type": "string" - }, - "value": { - "type": "string" - } - }, - "readOnly": true - }, - "platform_hosting": { - "type": "boolean", - "readOnly": true - }, - "is_platform_subdomain": { - "type": "boolean", + "type": "integer", "readOnly": true }, - "hidden_from_central_directory": { - "type": "boolean" + "membership_id": { + "type": "integer", + "readOnly": true }, - "public_api_key": { + "member_display_name": { "type": "string", "readOnly": true }, - "cors_allowed_origins": {}, - "enabled_modules": { - "type": "array", - "items": { - "type": "string" - }, - "readOnly": true + "issued_by_id": { + "type": "integer", + "readOnly": true, + "nullable": true }, - "unavailable_apps": { - "type": "array", - "items": { - "type": "string" - }, + "issued_at": { + "type": "string", + "format": "date-time", "readOnly": true }, - "resource_limit_overrides": {}, - "enabled_features": {}, - "theme_config": {}, - "branding_config": { + "expires_at": { + "type": "string", + "format": "date-time", "readOnly": true }, - "public_display_name": { + "consumed_at": { "type": "string", - "writeOnly": true, - "maxLength": 200 + "format": "date-time", + "readOnly": true, + "nullable": true }, - "telegram_group_chat_id": { + "revoked_at": { "type": "string", - "maxLength": 64 + "format": "date-time", + "readOnly": true, + "nullable": true }, - "telegram_bot_token": { + "status": { "type": "string", - "writeOnly": true + "readOnly": true }, - "telegram_bot_token_set": { - "type": "boolean", + "code": { + "type": "string", "readOnly": true }, - "smtp_host": { + "qr_svg": { "type": "string", - "maxLength": 200 + "readOnly": true + } + }, + "required": [ + "code", + "consumed_at", + "expires_at", + "id", + "issued_at", + "issued_by_id", + "member_display_name", + "membership_id", + "qr_svg", + "revoked_at", + "status" + ] + }, + "MemberEventRegistrationActivity": { + "type": "object", + "properties": { + "registration_id": { + "type": "integer" }, - "smtp_port": { - "type": "integer", - "maximum": 2147483647, - "minimum": 0 + "checkin_token": { + "type": "string", + "format": "uuid", + "nullable": true }, - "smtp_username": { + "event_title": { + "type": "string" + }, + "starts_at": { "type": "string", - "maxLength": 200 + "format": "date-time" }, - "smtp_password": { + "ends_at": { "type": "string", - "writeOnly": true + "format": "date-time" }, - "smtp_password_set": { - "type": "boolean", - "readOnly": true + "status": { + "type": "string" }, - "smtp_use_tls": { - "type": "boolean" + "waitlist_position": { + "type": "integer", + "nullable": true }, - "smtp_use_ssl": { + "feedback_available": { "type": "boolean" }, - "smtp_from_email": { - "oneOf": [ - { - "type": "string", - "format": "email", - "maxLength": 254 - }, - { - "type": "string", - "maxLength": 0 - } - ] + "feedback_path": { + "type": "string", + "nullable": true }, - "slack_webhook_url": { + "certificate": { + "type": "object", + "additionalProperties": {}, + "nullable": true + } + }, + "required": [ + "certificate", + "checkin_token", + "ends_at", + "event_title", + "feedback_available", + "feedback_path", + "registration_id", + "starts_at", + "status", + "waitlist_position" + ] + }, + "MemberLoanActivity": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "checked_out_at": { "type": "string", - "writeOnly": true, - "maxLength": 2048 + "format": "date-time" }, - "slack_webhook_url_set": { - "type": "boolean", - "readOnly": true + "due_at": { + "type": "string", + "format": "date-time", + "nullable": true }, - "mattermost_webhook_url": { + "overdue": { + "type": "boolean" + } + }, + "required": [ + "checked_out_at", + "due_at", + "label", + "overdue" + ] + }, + "MemberMachineServiceActivity": { + "type": "object", + "properties": { + "machine_type": { + "type": "string" + }, + "title": { + "type": "string" + }, + "status": { + "type": "string" + }, + "created_at": { "type": "string", - "writeOnly": true, - "maxLength": 2048 + "format": "date-time" }, - "mattermost_webhook_url_set": { - "type": "boolean", + "queue_position": { + "type": "integer", + "nullable": true + } + }, + "required": [ + "created_at", + "queue_position", + "status", + "title" + ] + }, + "MemberPayment": { + "type": "object", + "properties": { + "id": { + "type": "integer", "readOnly": true }, - "discord_webhook_url": { - "type": "string", - "writeOnly": true, - "maxLength": 2048 + "subject_type": { + "$ref": "#/components/schemas/SubjectTypeEnum" }, - "discord_webhook_url_set": { - "type": "boolean", + "subject_label": { + "type": "string", "readOnly": true }, - "default_loan_days": { - "type": "integer", - "maximum": 2147483647, - "minimum": 0 + "status": { + "$ref": "#/components/schemas/Status66aEnum" }, - "presence_preset_minutes": {}, - "created_at": { + "checkout_url": { "type": "string", - "format": "date-time", "readOnly": true }, - "updated_at": { + "created_at": { "type": "string", "format": "date-time", "readOnly": true } }, "required": [ - "archive_custody_state", - "branding_config", - "cover_image_key", - "cover_image_url", + "checkout_url", "created_at", - "discord_webhook_url_set", - "domain_verification_record", - "domain_verification_token", - "domain_verified_at", - "enabled_modules", - "frontend_domain_status", "id", - "is_platform_subdomain", - "logo_key", - "logo_url", - "mattermost_webhook_url_set", - "name", - "platform_hosting", - "public_api_key", - "slack_webhook_url_set", - "slug", - "smtp_password_set", - "telegram_bot_token_set", - "unavailable_apps", - "updated_at" + "subject_label", + "subject_type" ] }, - "MakerspaceArchiveRequest": { + "MemberPresenceActivity": { "type": "object", "properties": { - "id": { - "type": "integer", - "readOnly": true + "started_at": { + "type": "string", + "format": "date-time" }, - "makerspace": { - "type": "integer", - "readOnly": true + "expires_at": { + "type": "string", + "format": "date-time" }, - "requested_by": { - "type": "integer", - "readOnly": true, + "ended_at": { + "type": "string", + "format": "date-time", "nullable": true }, - "requested_by_username": { + "end_reason": { + "type": "string" + }, + "active": { + "type": "boolean" + } + }, + "required": [ + "active", + "end_reason", + "ended_at", + "expires_at", + "started_at" + ] + }, + "MemberPrintActivity": { + "type": "object", + "properties": { + "public_token": { "type": "string", - "readOnly": true, - "nullable": true + "format": "uuid" }, - "requested_at": { + "status": { + "type": "string" + }, + "title": { + "type": "string" + }, + "created_at": { "type": "string", - "format": "date-time", - "readOnly": true + "format": "date-time" }, - "resolved_by": { - "type": "integer", - "readOnly": true, + "accepted_at": { + "type": "string", + "format": "date-time", "nullable": true }, - "resolved_by_username": { + "started_at": { "type": "string", - "readOnly": true, + "format": "date-time", "nullable": true }, - "resolved_at": { + "completed_at": { "type": "string", "format": "date-time", - "readOnly": true, "nullable": true }, - "reason": { + "estimated_minutes": { + "type": "integer" + }, + "queue_position": { + "type": "integer", + "nullable": true + }, + "queue_approved_ahead": { + "type": "integer", + "nullable": true + }, + "queue_awaiting_review_ahead": { + "type": "integer", + "nullable": true + } + }, + "required": [ + "accepted_at", + "completed_at", + "created_at", + "estimated_minutes", + "public_token", + "queue_approved_ahead", + "queue_awaiting_review_ahead", + "queue_position", + "started_at", + "status", + "title" + ] + }, + "MemberSignUp": { + "type": "object", + "properties": { + "display_name": { "type": "string", - "description": "Do not include personal data. Maximum 2,000 characters.", - "maxLength": 2000 + "maxLength": 200 }, - "resolution_note": { + "email": { "type": "string", - "readOnly": true + "format": "email", + "maxLength": 254 }, - "status": { - "allOf": [ - { - "$ref": "#/components/schemas/MakerspaceArchiveRequestStatusEnum" - } - ], - "readOnly": true + "phone": { + "type": "string", + "default": "", + "maxLength": 32 + }, + "password": { + "type": "string", + "writeOnly": true, + "maxLength": 128 + }, + "website": { + "type": "string", + "default": "" } }, "required": [ - "id", - "makerspace", - "reason", - "requested_at", - "requested_by", - "requested_by_username", - "resolution_note", - "resolved_at", - "resolved_by", - "resolved_by_username", - "status" + "display_name", + "email", + "password" ] }, - "MakerspaceArchiveRequestStatusEnum": { - "enum": [ - "pending", - "approved", - "declined", - "withdrawn" - ], - "type": "string", - "description": "* `pending` - Pending\n* `approved` - Approved\n* `declined` - Declined\n* `withdrawn` - Withdrawn" + "MemberVerificationAck": { + "type": "object", + "properties": { + "detail": { + "type": "string" + } + }, + "required": [ + "detail" + ] }, - "MakerspacePaymentSettings": { + "MemberWaiverResponse": { "type": "object", "properties": { - "default_currency": { + "has_waiver": { + "type": "boolean" + }, + "body": { + "type": "string" + }, + "version": { + "type": "string" + } + }, + "required": [ + "has_waiver" + ] + }, + "MembershipCreate": { + "type": "object", + "properties": { + "username": { "type": "string", - "maxLength": 3 + "maxLength": 150 }, - "stripe_publishable_key": { + "email": { + "oneOf": [ + { + "type": "string", + "format": "email", + "maxLength": 254 + }, + { + "type": "string", + "maxLength": 0 + } + ] + }, + "first_name": { "type": "string", - "writeOnly": true, - "maxLength": 255 + "maxLength": 150 }, - "stripe_publishable_key_set": { - "type": "boolean", - "readOnly": true + "last_name": { + "type": "string", + "maxLength": 150 }, - "stripe_secret_key": { + "password": { "type": "string", "writeOnly": true }, - "stripe_secret_key_set": { - "type": "boolean", + "role_id": { + "type": "integer" + } + }, + "required": [ + "role_id", + "username" + ] + }, + "MembershipDispositionEnum": { + "enum": [ + "import_membership", + "no_membership" + ], + "type": "string", + "description": "* `import_membership` - Import membership\n* `no_membership` - Do not import membership" + }, + "MembershipList": { + "type": "object", + "properties": { + "id": { + "type": "integer", "readOnly": true }, - "stripe_webhook_secret": { - "type": "string", - "writeOnly": true + "user": { + "allOf": [ + { + "$ref": "#/components/schemas/User" + } + ], + "readOnly": true }, - "stripe_webhook_secret_set": { - "type": "boolean", + "makerspace_id": { + "type": "integer", "readOnly": true }, - "connect_account_id": { + "makerspace_slug": { "type": "string", "readOnly": true, - "nullable": true + "pattern": "^[-a-zA-Z0-9_]+$" + }, + "role": { + "allOf": [ + { + "$ref": "#/components/schemas/Role827Enum" + } + ], + "readOnly": true }, - "connect_status": { + "assigned_role": { "allOf": [ { - "$ref": "#/components/schemas/ConnectStatusEnum" + "$ref": "#/components/schemas/MembershipRoleSummary" } ], - "readOnly": true - }, - "connect_charges_enabled": { - "type": "boolean", - "readOnly": true - }, - "connect_payouts_enabled": { - "type": "boolean", - "readOnly": true + "readOnly": true, + "nullable": true }, - "connect_status_updated_at": { + "created_at": { "type": "string", "format": "date-time", "readOnly": true }, - "effective_mode": { - "type": "string", + "payment": { + "allOf": [ + { + "$ref": "#/components/schemas/StaffPaymentSummary" + } + ], + "nullable": true, "readOnly": true } }, "required": [ - "connect_account_id", - "connect_charges_enabled", - "connect_payouts_enabled", - "connect_status", - "connect_status_updated_at", - "effective_mode", - "stripe_publishable_key_set", - "stripe_secret_key_set", - "stripe_webhook_secret_set" + "assigned_role", + "created_at", + "id", + "makerspace_id", + "makerspace_slug", + "payment", + "role", + "user" ] }, - "ManagedPolicyMarker": { + "MembershipOutcome": { "type": "object", "properties": { - "feature": { - "type": "string" + "outcome": { + "$ref": "#/components/schemas/MembershipOutcomeOutcomeEnum" }, - "event": { - "type": "string" + "membership_id": { + "type": "integer" }, - "count": { - "type": "integer", - "minimum": 1 + "request_id": { + "type": "integer" + }, + "state": { + "type": "string" } }, "required": [ - "count", - "event", - "feature" + "outcome", + "state" ] }, - "Measurement883Enum": { + "MembershipOutcomeOutcomeEnum": { "enum": [ - "count", - "grams" + "joined", + "requested" ], "type": "string", - "description": "* `count` - Count\n* `grams` - Grams" + "description": "* `joined` - joined\n* `requested` - requested" }, - "MemberAccountability": { - "type": "object", - "properties": { - "membership_active": { - "type": "boolean" - }, - "waiver_acceptance_required": { - "type": "boolean" - }, - "restriction_code": { - "type": "string", - "nullable": true - } - }, - "required": [ - "membership_active", - "restriction_code", - "waiver_acceptance_required" - ] + "MembershipPolicyEnum": { + "enum": [ + "request", + "open", + "invite_only" + ], + "type": "string", + "description": "* `request` - Request\n* `open` - Open\n* `invite_only` - Invite only" }, - "MemberActivity": { + "MembershipRequest": { "type": "object", "properties": { - "active_hardware_loans": { - "type": "array", - "items": { - "$ref": "#/components/schemas/MemberLoanActivity" - } + "id": { + "type": "integer", + "readOnly": true }, - "print_requests": { - "type": "array", - "items": { - "$ref": "#/components/schemas/MemberPrintActivity" - } + "kind": { + "$ref": "#/components/schemas/MembershipRequestKindEnum" }, - "machine_service_requests": { - "type": "array", - "items": { - "$ref": "#/components/schemas/MemberMachineServiceActivity" - } + "state": { + "$ref": "#/components/schemas/MembershipRequestStateEnum" }, - "bookings": { - "type": "object", - "additionalProperties": { - "type": "array", - "items": {} - } + "invite_email": { + "type": "string", + "maxLength": 254 }, - "event_registrations": { - "type": "array", - "items": { - "$ref": "#/components/schemas/MemberEventRegistrationActivity" - } + "user": { + "allOf": [ + { + "$ref": "#/components/schemas/MembershipRequestUser" + } + ], + "nullable": true, + "readOnly": true }, - "recent_presence_sessions": { - "type": "array", - "items": { - "$ref": "#/components/schemas/MemberPresenceActivity" - } + "assigned_role": { + "allOf": [ + { + "$ref": "#/components/schemas/MembershipRequestRole" + } + ], + "nullable": true, + "readOnly": true }, - "currently_checked_in": { - "type": "boolean" + "decision_note": { + "type": "string" }, - "accountability": { - "$ref": "#/components/schemas/MemberAccountability" + "created_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "decided_at": { + "type": "string", + "format": "date-time", + "nullable": true } }, "required": [ - "accountability", - "active_hardware_loans", - "currently_checked_in", - "recent_presence_sessions" + "assigned_role", + "created_at", + "id", + "kind", + "state", + "user" ] }, - "MemberActivityReport": { + "MembershipRequestCreate": { "type": "object", "properties": { - "rows": { - "type": "array", - "items": { - "type": "array", - "items": {} - } + "website": { + "type": "string", + "writeOnly": true + } + } + }, + "MembershipRequestKindEnum": { + "enum": [ + "request", + "invite" + ], + "type": "string", + "description": "* `request` - Request\n* `invite` - Invite" + }, + "MembershipRequestRole": { + "type": "object", + "properties": { + "id": { + "type": "integer" }, - "typed_rows": { - "type": "array", - "items": { - "$ref": "#/components/schemas/MemberActivityRow" - } + "name": { + "type": "string" } }, "required": [ - "rows", - "typed_rows" + "id", + "name" ] }, - "MemberActivityRow": { + "MembershipRequestStateEnum": { + "enum": [ + "requested", + "invited", + "active", + "revoked" + ], + "type": "string", + "description": "* `requested` - Requested\n* `invited` - Invited\n* `active` - Active\n* `revoked` - Revoked" + }, + "MembershipRequestUser": { "type": "object", "properties": { - "makerspace_id": { + "id": { "type": "integer" }, - "makerspace_name": { - "type": "string" - }, - "membership_policy": { + "username": { "type": "string" }, - "referrals_enabled": { - "type": "boolean" - }, - "new_members": { - "type": "integer", - "description": "Current activated_at values in the selected [start, end) range, not event history." - }, - "active_members": { - "type": "integer", - "description": "Current membership snapshot." - }, - "revoked_members": { - "type": "integer", - "description": "Current revoked_at values in the selected [start, end) range, not event history." - }, - "pending_requests": { - "type": "integer", - "description": "Current membership-request snapshot." - }, - "open_invites": { - "type": "integer", - "description": "Current membership-request snapshot." - }, - "referred_joins": { - "type": "integer", - "description": "Active automatic referrals with decided_at in the selected [start, end) range." - }, - "verified_members": { - "type": "integer", - "description": "Current verified_at snapshot." + "email": { + "type": "string", + "format": "email" } }, "required": [ - "active_members", - "makerspace_name", - "membership_policy", - "new_members", - "open_invites", - "pending_requests", - "referrals_enabled", - "referred_joins", - "revoked_members", - "verified_members" + "email", + "id", + "username" ] }, - "MemberClaimCode": { + "MembershipRoleSummary": { "type": "object", "properties": { "id": { "type": "integer", "readOnly": true }, - "membership_id": { - "type": "integer", - "readOnly": true - }, - "member_display_name": { - "type": "string", - "readOnly": true - }, - "issued_by_id": { - "type": "integer", - "readOnly": true, - "nullable": true - }, - "issued_at": { - "type": "string", - "format": "date-time", - "readOnly": true - }, - "expires_at": { + "name": { "type": "string", - "format": "date-time", "readOnly": true }, - "consumed_at": { + "slug": { "type": "string", - "format": "date-time", "readOnly": true, - "nullable": true + "pattern": "^[-a-zA-Z0-9_]+$" }, - "revoked_at": { - "type": "string", - "format": "date-time", + "legacy_role": { "readOnly": true, - "nullable": true + "nullable": true, + "oneOf": [ + { + "$ref": "#/components/schemas/LegacyRoleEnum" + }, + { + "$ref": "#/components/schemas/NullEnum" + } + ] }, - "status": { - "type": "string", + "is_default": { + "type": "boolean", + "readOnly": true + }, + "is_protected": { + "type": "boolean", "readOnly": true } }, "required": [ - "consumed_at", - "expires_at", "id", - "issued_at", - "issued_by_id", - "member_display_name", - "membership_id", - "revoked_at", - "status" + "is_default", + "is_protected", + "legacy_role", + "name", + "slug" ] }, - "MemberClaimCodeIssueRequest": { + "MethodEnum": { + "enum": [ + "PUT" + ], + "type": "string", + "description": "* `PUT` - PUT" + }, + "MigrationExportCreate": { "type": "object", "properties": { - "membership_id": { - "type": "integer", - "minimum": 1 + "approval_id": { + "type": "string", + "format": "uuid" + }, + "target_age_recipient": { + "type": "string", + "writeOnly": true, + "maxLength": 256 } }, "required": [ - "membership_id" + "approval_id", + "target_age_recipient" ] }, - "MemberClaimCodeIssueResponse": { + "MigrationExportJob": { "type": "object", "properties": { "id": { - "type": "integer", + "type": "string", + "format": "uuid", "readOnly": true }, - "membership_id": { - "type": "integer", - "readOnly": true + "status": { + "$ref": "#/components/schemas/StatusE1dEnum" }, - "member_display_name": { + "failure_code": { + "oneOf": [ + { + "$ref": "#/components/schemas/FailureCodeEnum" + }, + { + "$ref": "#/components/schemas/BlankEnum" + } + ] + }, + "failure_detail": { "type": "string", - "readOnly": true + "maxLength": 500 }, - "issued_by_id": { - "type": "integer", - "readOnly": true, - "nullable": true + "manifest": {}, + "closure_digest": { + "type": "string" }, - "issued_at": { + "archive_digest": { + "type": "string" + }, + "format_version": { + "type": "integer" + }, + "source_retention_notice": { "type": "string", - "format": "date-time", "readOnly": true }, - "expires_at": { + "created_at": { "type": "string", "format": "date-time", "readOnly": true }, - "consumed_at": { + "started_at": { "type": "string", "format": "date-time", - "readOnly": true, "nullable": true }, - "revoked_at": { + "completed_at": { "type": "string", "format": "date-time", - "readOnly": true, "nullable": true }, - "status": { - "type": "string", - "readOnly": true - }, - "code": { - "type": "string", - "readOnly": true - }, - "qr_svg": { + "expires_at": { "type": "string", - "readOnly": true + "format": "date-time" } }, "required": [ - "code", - "consumed_at", + "archive_digest", + "closure_digest", + "created_at", "expires_at", + "format_version", "id", - "issued_at", - "issued_by_id", - "member_display_name", - "membership_id", - "qr_svg", - "revoked_at", - "status" + "source_retention_notice" ] }, - "MemberEventRegistrationActivity": { + "MobilePaymentIntentResponse": { "type": "object", "properties": { - "registration_id": { + "payment_id": { "type": "integer" }, - "checkin_token": { - "type": "string", - "format": "uuid", - "nullable": true + "client_secret": { + "type": "string" }, - "event_title": { + "publishable_key": { "type": "string" }, - "starts_at": { + "customer_id": { "type": "string", - "format": "date-time" + "nullable": true }, - "ends_at": { + "ephemeral_key": { "type": "string", - "format": "date-time" - }, - "status": { - "type": "string" - }, - "waitlist_position": { - "type": "integer", "nullable": true } }, "required": [ - "checkin_token", - "ends_at", - "event_title", - "registration_id", - "starts_at", - "status", - "waitlist_position" + "client_secret", + "payment_id", + "publishable_key" ] }, - "MemberLoanActivity": { + "Mode087Enum": { + "enum": [ + "preview", + "apply" + ], + "type": "string", + "description": "* `preview` - Preview\n* `apply` - Apply" + }, + "ModuleAction": { "type": "object", + "description": "The one field either mutation takes.\n\nA real class rather than `inline_serializer`, which returns an *instance* and so\ncannot be called with `data=` at request time.", "properties": { - "label": { + "key": { "type": "string" + } + }, + "required": [ + "key" + ] + }, + "MostLentReport": { + "type": "object", + "properties": { + "rows": { + "type": "array", + "items": { + "type": "array", + "items": {} + } }, - "checked_out_at": { - "type": "string", - "format": "date-time" - }, - "due_at": { - "type": "string", - "format": "date-time", - "nullable": true - }, - "overdue": { - "type": "boolean" + "typed_rows": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MostLentReportRow" + } } }, "required": [ - "checked_out_at", - "due_at", - "label", - "overdue" + "rows", + "typed_rows" ] }, - "MemberMachineServiceActivity": { + "MostLentReportRow": { "type": "object", "properties": { - "machine_type": { - "type": "string" - }, - "title": { - "type": "string" + "makerspace_id": { + "type": "integer" }, - "status": { + "product_name": { "type": "string" }, - "created_at": { - "type": "string", - "format": "date-time" + "times_lent": { + "type": "integer" }, - "queue_position": { - "type": "integer", - "nullable": true + "total_quantity_lent": { + "type": "integer" } }, "required": [ - "created_at", - "queue_position", - "status", - "title" + "product_name", + "times_lent", + "total_quantity_lent" ] }, - "MemberPayment": { + "MoveToInventoryRequest": { "type": "object", "properties": { - "id": { + "mode": { + "$ref": "#/components/schemas/MoveToInventoryRequestModeEnum" + }, + "product_id": { "type": "integer", - "readOnly": true + "nullable": true }, - "subject_type": { - "$ref": "#/components/schemas/SubjectTypeEnum" + "quantity": { + "type": "integer", + "minimum": 1 }, - "subject_label": { - "type": "string", - "readOnly": true + "box": { + "type": "integer", + "nullable": true }, - "status": { - "$ref": "#/components/schemas/Status66aEnum" + "category": { + "type": "integer", + "nullable": true }, - "checkout_url": { + "tracking_mode": { + "allOf": [ + { + "$ref": "#/components/schemas/MoveToInventoryRequestTrackingModeEnum" + } + ], + "default": "quantity" + }, + "is_public": { + "type": "boolean", + "default": true + }, + "public_availability_mode": { + "allOf": [ + { + "$ref": "#/components/schemas/MoveToInventoryRequestPublicAvailabilityModeEnum" + } + ], + "default": "status_only" + }, + "show_public_count": { + "type": "boolean", + "default": false + }, + "public_self_checkout_enabled": { + "type": "boolean", + "default": false + }, + "name": { "type": "string", - "readOnly": true + "maxLength": 200 }, - "created_at": { + "description": { "type": "string", - "format": "date-time", - "readOnly": true + "default": "" } }, "required": [ - "checkout_url", - "created_at", - "id", - "subject_label", - "subject_type" + "mode", + "quantity" ] }, - "MemberPresenceActivity": { + "MoveToInventoryRequestModeEnum": { + "enum": [ + "create", + "topup" + ], + "type": "string", + "description": "* `create` - create\n* `topup` - topup" + }, + "MoveToInventoryRequestPublicAvailabilityModeEnum": { + "enum": [ + "exact_count", + "status_only", + "hidden" + ], + "type": "string", + "description": "* `exact_count` - exact_count\n* `status_only` - status_only\n* `hidden` - hidden" + }, + "MoveToInventoryRequestTrackingModeEnum": { + "enum": [ + "quantity", + "individual" + ], + "type": "string", + "description": "* `quantity` - quantity\n* `individual` - individual" + }, + "MoveToPrintingRequest": { "type": "object", "properties": { - "started_at": { - "type": "string", - "format": "date-time" - }, - "expires_at": { - "type": "string", - "format": "date-time" + "target": { + "$ref": "#/components/schemas/TargetEnum" }, - "ended_at": { - "type": "string", - "format": "date-time", + "printer": { + "type": "integer", "nullable": true }, - "end_reason": { - "type": "string" - }, - "active": { - "type": "boolean" - } - }, - "required": [ - "active", - "end_reason", - "ended_at", - "expires_at", - "started_at" - ] - }, - "MemberPrintActivity": { - "type": "object", - "properties": { - "public_token": { + "material": { "type": "string", - "format": "uuid" + "maxLength": 100 }, - "status": { - "type": "string" + "color": { + "type": "string", + "maxLength": 100 }, - "title": { - "type": "string" + "brand": { + "type": "string", + "maxLength": 100 }, - "created_at": { + "lot_code": { "type": "string", - "format": "date-time" + "maxLength": 100 }, - "accepted_at": { + "initial_weight_grams": { "type": "string", - "format": "date-time", - "nullable": true + "format": "decimal", + "pattern": "^-?\\d{0,6}(?:\\.\\d{0,2})?$" }, - "started_at": { + "remaining_weight_grams": { "type": "string", - "format": "date-time", - "nullable": true + "format": "decimal", + "pattern": "^-?\\d{0,6}(?:\\.\\d{0,2})?$" }, - "completed_at": { + "is_active": { + "type": "boolean" + }, + "opened_at": { "type": "string", "format": "date-time", "nullable": true }, - "estimated_minutes": { - "type": "integer" + "name": { + "type": "string", + "maxLength": 200 }, - "queue_position": { - "type": "integer", - "nullable": true + "model": { + "type": "string", + "maxLength": 200 }, - "queue_approved_ahead": { - "type": "integer", - "nullable": true + "status": { + "$ref": "#/components/schemas/MoveToPrintingRequestStatusEnum" }, - "queue_awaiting_review_ahead": { - "type": "integer", - "nullable": true + "notes": { + "type": "string" } }, "required": [ - "accepted_at", - "completed_at", - "created_at", - "estimated_minutes", - "public_token", - "queue_approved_ahead", - "queue_awaiting_review_ahead", - "queue_position", - "started_at", - "status", - "title" + "target" ] }, - "MemberSignUp": { + "MoveToPrintingRequestStatusEnum": { + "enum": [ + "active", + "maintenance", + "offline" + ], + "type": "string", + "description": "* `active` - active\n* `maintenance` - maintenance\n* `offline` - offline" + }, + "MyMembershipRequest": { "type": "object", "properties": { - "display_name": { - "type": "string", - "maxLength": 200 - }, - "email": { - "type": "string", - "format": "email", - "maxLength": 254 - }, - "phone": { - "type": "string", - "default": "", - "maxLength": 32 + "makerspace": { + "type": "object", + "additionalProperties": {} }, - "password": { - "type": "string", - "writeOnly": true, - "maxLength": 128 + "state": { + "type": "string" }, - "website": { - "type": "string", - "default": "" + "kind": { + "type": "string" } }, "required": [ - "display_name", - "email", - "password" + "kind", + "makerspace", + "state" ] }, - "MemberVerificationAck": { + "MyMembershipRow": { "type": "object", "properties": { - "detail": { + "makerspace": { + "type": "object", + "additionalProperties": {} + }, + "membership_status": { "type": "string" + }, + "role": { + "type": "string" + }, + "actions": { + "type": "array", + "items": { + "type": "string" + } + }, + "can_refer": { + "type": "boolean" + }, + "can_verify": { + "type": "boolean" + }, + "verified_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "referrals_enabled": { + "type": "boolean" + }, + "waiver_accepted": { + "type": "boolean" + }, + "waiver_acceptance_required": { + "type": "boolean" } }, "required": [ - "detail" + "actions", + "can_refer", + "can_verify", + "makerspace", + "membership_status", + "referrals_enabled", + "role", + "verified_at", + "waiver_acceptance_required", + "waiver_accepted" ] }, - "MemberWaiverResponse": { + "MyMemberships": { "type": "object", "properties": { - "has_waiver": { - "type": "boolean" - }, - "body": { - "type": "string" + "memberships": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MyMembershipRow" + } }, - "version": { - "type": "string" + "requests": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MyMembershipRequest" + } } }, "required": [ - "has_waiver" + "memberships", + "requests" ] }, - "MembershipCreate": { + "NeedsFixAction": { "type": "object", "properties": { - "username": { - "type": "string", - "maxLength": 150 - }, - "email": { - "oneOf": [ - { - "type": "string", - "format": "email", - "maxLength": 254 - }, - { - "type": "string", - "maxLength": 0 - } - ] - }, - "first_name": { - "type": "string", - "maxLength": 150 - }, - "last_name": { - "type": "string", - "maxLength": 150 - }, - "password": { - "type": "string", - "writeOnly": true + "action": { + "$ref": "#/components/schemas/NeedsFixActionActionEnum" }, - "role_id": { - "type": "integer" + "quantity": { + "type": "integer", + "minimum": 1 } }, "required": [ - "role_id", - "username" + "action", + "quantity" ] }, - "MembershipDispositionEnum": { + "NeedsFixActionActionEnum": { "enum": [ - "import_membership", - "no_membership" + "repair", + "scrap", + "shelve" ], "type": "string", - "description": "* `import_membership` - Import membership\n* `no_membership` - Do not import membership" + "description": "* `repair` - repair\n* `scrap` - scrap\n* `shelve` - shelve" }, - "MembershipList": { + "Notification": { "type": "object", "properties": { "id": { "type": "integer", "readOnly": true }, - "user": { + "level": { "allOf": [ { - "$ref": "#/components/schemas/User" + "$ref": "#/components/schemas/LevelEnum" } ], "readOnly": true }, - "makerspace_id": { - "type": "integer", + "event": { + "type": "string", "readOnly": true }, - "makerspace_slug": { + "title": { "type": "string", - "readOnly": true, - "pattern": "^[-a-zA-Z0-9_]+$" + "readOnly": true }, - "role": { - "allOf": [ - { - "$ref": "#/components/schemas/Role827Enum" - } - ], + "body": { + "type": "string", "readOnly": true }, - "assigned_role": { - "allOf": [ - { - "$ref": "#/components/schemas/MembershipRoleSummary" - } - ], + "url_path": { + "type": "string", + "readOnly": true + }, + "read_at": { + "type": "string", + "format": "date-time", "readOnly": true, "nullable": true }, @@ -44266,1246 +52520,1199 @@ "type": "string", "format": "date-time", "readOnly": true - }, - "payment": { - "allOf": [ - { - "$ref": "#/components/schemas/StaffPaymentSummary" - } - ], - "nullable": true, - "readOnly": true } }, "required": [ - "assigned_role", + "body", "created_at", + "event", "id", - "makerspace_id", - "makerspace_slug", - "payment", - "role", - "user" + "level", + "read_at", + "title", + "url_path" ] }, - "MembershipOutcome": { + "NotificationChannel": { "type": "object", "properties": { - "outcome": { - "$ref": "#/components/schemas/MembershipOutcomeOutcomeEnum" - }, - "membership_id": { - "type": "integer" - }, - "request_id": { - "type": "integer" + "key": { + "$ref": "#/components/schemas/KeyCbbEnum" }, - "state": { + "label": { "type": "string" } }, "required": [ - "outcome", - "state" + "key", + "label" ] }, - "MembershipOutcomeOutcomeEnum": { - "enum": [ - "joined", - "requested" - ], - "type": "string", - "description": "* `joined` - joined\n* `requested` - requested" - }, - "MembershipPolicyEnum": { - "enum": [ - "request", - "open", - "invite_only" - ], - "type": "string", - "description": "* `request` - Request\n* `open` - Open\n* `invite_only` - Invite only" - }, - "MembershipRequest": { + "NotificationDestination": { "type": "object", "properties": { "id": { "type": "integer", - "readOnly": true - }, - "kind": { - "$ref": "#/components/schemas/MembershipRequestKindEnum" - }, - "state": { - "$ref": "#/components/schemas/StateEnum" - }, - "invite_email": { - "type": "string", - "maxLength": 254 + "readOnly": true }, - "user": { + "channel": { "allOf": [ { - "$ref": "#/components/schemas/MembershipRequestUser" + "$ref": "#/components/schemas/Channel7a7Enum" } ], - "nullable": true, "readOnly": true }, - "assigned_role": { - "allOf": [ - { - "$ref": "#/components/schemas/MembershipRequestRole" - } - ], - "nullable": true, + "label": { + "type": "string", "readOnly": true }, - "decision_note": { - "type": "string" + "telegram_chat_id": { + "type": "string", + "readOnly": true + }, + "is_active": { + "type": "boolean", + "readOnly": true + }, + "credential_set": { + "type": "string", + "readOnly": true + }, + "scope": { + "type": "string", + "readOnly": true }, "created_at": { "type": "string", "format": "date-time", "readOnly": true }, - "decided_at": { + "updated_at": { "type": "string", "format": "date-time", - "nullable": true + "readOnly": true } }, "required": [ - "assigned_role", + "channel", "created_at", + "credential_set", "id", - "kind", - "state", - "user" + "is_active", + "label", + "scope", + "telegram_chat_id", + "updated_at" ] }, - "MembershipRequestCreate": { + "NotificationDestinationWrite": { "type": "object", "properties": { - "website": { + "channel": { + "$ref": "#/components/schemas/Channel7a7Enum" + }, + "label": { "type": "string", - "writeOnly": true + "maxLength": 80 + }, + "webhook_url": { + "type": "string", + "writeOnly": true, + "maxLength": 2000 + }, + "telegram_chat_id": { + "type": "string", + "maxLength": 64 + }, + "is_active": { + "type": "boolean", + "default": true + }, + "scope": { + "$ref": "#/components/schemas/DestinationScope" } - } - }, - "MembershipRequestKindEnum": { - "enum": [ - "request", - "invite" - ], - "type": "string", - "description": "* `request` - Request\n* `invite` - Invite" + }, + "required": [ + "channel", + "label" + ] }, - "MembershipRequestRole": { + "NotificationFeature": { "type": "object", "properties": { - "id": { - "type": "integer" + "key": { + "$ref": "#/components/schemas/KeyD07Enum" }, - "name": { + "label": { "type": "string" + }, + "events": { + "type": "array", + "items": { + "type": "string" + } } }, "required": [ - "id", - "name" + "events", + "key", + "label" ] }, - "MembershipRequestUser": { + "NotificationMarkAllRead": { "type": "object", "properties": { - "id": { + "updated": { "type": "integer" - }, - "username": { - "type": "string" - }, - "email": { - "type": "string", - "format": "email" } }, "required": [ - "email", - "id", - "username" + "updated" ] }, - "MembershipRoleSummary": { + "NotificationPreferenceCell": { "type": "object", "properties": { - "id": { - "type": "integer", - "readOnly": true - }, - "name": { - "type": "string", - "readOnly": true - }, - "slug": { - "type": "string", - "readOnly": true, - "pattern": "^[-a-zA-Z0-9_]+$" + "feature": { + "$ref": "#/components/schemas/FeatureEnum" }, - "legacy_role": { - "readOnly": true, - "nullable": true, - "oneOf": [ - { - "$ref": "#/components/schemas/LegacyRoleEnum" - }, - { - "$ref": "#/components/schemas/NullEnum" - } - ] + "channel": { + "$ref": "#/components/schemas/ChannelCbbEnum" }, - "is_default": { - "type": "boolean", - "readOnly": true + "enabled": { + "type": "boolean" }, - "is_protected": { - "type": "boolean", - "readOnly": true + "source": { + "$ref": "#/components/schemas/NotificationPreferenceCellSourceEnum" } }, "required": [ - "id", - "is_default", - "is_protected", - "legacy_role", - "name", - "slug" + "channel", + "enabled", + "feature", + "source" ] }, - "MethodEnum": { + "NotificationPreferenceCellSourceEnum": { "enum": [ - "PUT" + "default", + "override" ], "type": "string", - "description": "* `PUT` - PUT" + "description": "* `default` - default\n* `override` - override" }, - "MigrationExportCreate": { + "NotificationPreferenceChange": { "type": "object", "properties": { - "approval_id": { - "type": "string", - "format": "uuid" + "feature": { + "$ref": "#/components/schemas/FeatureEnum" }, - "target_age_recipient": { - "type": "string", - "writeOnly": true, - "maxLength": 256 + "channel": { + "$ref": "#/components/schemas/ChannelCbbEnum" + }, + "enabled": { + "type": "boolean" } }, "required": [ - "approval_id", - "target_age_recipient" + "channel", + "enabled", + "feature" ] }, - "MigrationExportJob": { + "NotificationRecipient": { "type": "object", "properties": { "id": { - "type": "string", - "format": "uuid", + "type": "integer", "readOnly": true }, - "status": { - "$ref": "#/components/schemas/StatusE1dEnum" + "username": { + "type": "string", + "readOnly": true }, - "failure_code": { + "email": { + "readOnly": true, "oneOf": [ { - "$ref": "#/components/schemas/FailureCodeEnum" + "type": "string", + "format": "email" }, { - "$ref": "#/components/schemas/BlankEnum" + "type": "string", + "maxLength": 0 } ] }, - "failure_detail": { - "type": "string", - "maxLength": 500 - }, - "manifest": {}, - "closure_digest": { - "type": "string" - }, - "archive_digest": { - "type": "string" - }, - "format_version": { - "type": "integer" - }, - "source_retention_notice": { - "type": "string", - "readOnly": true - }, - "created_at": { + "role": { "type": "string", - "format": "date-time", "readOnly": true }, - "started_at": { - "type": "string", - "format": "date-time", - "nullable": true - }, - "completed_at": { - "type": "string", - "format": "date-time", - "nullable": true - }, - "expires_at": { - "type": "string", - "format": "date-time" + "receives_notifications": { + "type": "boolean" } }, "required": [ - "archive_digest", - "closure_digest", - "created_at", - "expires_at", - "format_version", + "email", "id", - "source_retention_notice" + "receives_notifications", + "role", + "username" ] }, - "MobilePaymentIntentResponse": { + "NotificationRecipientUpdate": { "type": "object", "properties": { - "payment_id": { + "id": { "type": "integer" }, - "client_secret": { - "type": "string" - }, - "publishable_key": { - "type": "string" - }, - "customer_id": { - "type": "string", - "nullable": true - }, - "ephemeral_key": { - "type": "string", - "nullable": true + "receives_notifications": { + "type": "boolean" } }, "required": [ - "client_secret", - "payment_id", - "publishable_key" + "id", + "receives_notifications" ] }, - "Mode087Enum": { - "enum": [ - "preview", - "apply" - ], - "type": "string", - "description": "* `preview` - Preview\n* `apply` - Apply" - }, - "ModuleAction": { + "NotificationRuleCatalogItem": { "type": "object", - "description": "The one field either mutation takes.\n\nA real class rather than `inline_serializer`, which returns an *instance* and so\ncannot be called with `data=` at request time.", "properties": { - "key": { + "stream": { "type": "string" - } - }, - "required": [ - "key" - ] - }, - "MostLentReport": { - "type": "object", - "properties": { - "rows": { + }, + "audience": { + "type": "string" + }, + "targets": { "type": "array", "items": { - "type": "array", - "items": {} + "type": "string" } }, - "typed_rows": { + "events": { "type": "array", "items": { - "$ref": "#/components/schemas/MostLentReportRow" + "type": "string" } } }, "required": [ - "rows", - "typed_rows" + "audience", + "events", + "stream", + "targets" ] }, - "MostLentReportRow": { + "NotificationRuleChange": { "type": "object", "properties": { - "makerspace_id": { - "type": "integer" + "target": { + "type": "string" }, - "product_name": { + "stream": { "type": "string" }, - "times_lent": { - "type": "integer" + "event": { + "type": "string" }, - "total_quantity_lent": { - "type": "integer" + "audience": { + "type": "string" + }, + "muted": { + "type": "boolean" } }, "required": [ - "product_name", - "times_lent", - "total_quantity_lent" + "audience", + "event", + "muted", + "stream", + "target" ] }, - "MoveToInventoryRequest": { + "NotificationRuleMute": { "type": "object", "properties": { - "mode": { - "$ref": "#/components/schemas/MoveToInventoryRequestModeEnum" - }, - "product_id": { - "type": "integer", - "nullable": true - }, - "quantity": { - "type": "integer", - "minimum": 1 - }, - "box": { - "type": "integer", - "nullable": true - }, - "category": { - "type": "integer", - "nullable": true - }, - "tracking_mode": { - "allOf": [ - { - "$ref": "#/components/schemas/MoveToInventoryRequestTrackingModeEnum" - } - ], - "default": "quantity" - }, - "is_public": { - "type": "boolean", - "default": true - }, - "public_availability_mode": { - "allOf": [ - { - "$ref": "#/components/schemas/MoveToInventoryRequestPublicAvailabilityModeEnum" - } - ], - "default": "status_only" - }, - "show_public_count": { - "type": "boolean", - "default": false + "target": { + "type": "string" }, - "public_self_checkout_enabled": { - "type": "boolean", - "default": false + "stream": { + "type": "string" }, - "name": { - "type": "string", - "maxLength": 200 + "event": { + "type": "string" }, - "description": { - "type": "string", - "default": "" + "audience": { + "type": "string" } }, "required": [ - "mode", - "quantity" + "audience", + "event", + "stream", + "target" ] }, - "MoveToInventoryRequestModeEnum": { - "enum": [ - "create", - "topup" - ], - "type": "string", - "description": "* `create` - create\n* `topup` - topup" - }, - "MoveToInventoryRequestPublicAvailabilityModeEnum": { - "enum": [ - "exact_count", - "status_only", - "hidden" - ], - "type": "string", - "description": "* `exact_count` - exact_count\n* `status_only` - status_only\n* `hidden` - hidden" - }, - "MoveToInventoryRequestTrackingModeEnum": { - "enum": [ - "quantity", - "individual" - ], - "type": "string", - "description": "* `quantity` - quantity\n* `individual` - individual" - }, - "MoveToPrintingRequest": { + "NotificationRulesResponse": { "type": "object", "properties": { - "target": { - "$ref": "#/components/schemas/TargetEnum" - }, - "printer": { - "type": "integer", - "nullable": true - }, - "material": { - "type": "string", - "maxLength": 100 - }, - "color": { - "type": "string", - "maxLength": 100 - }, - "brand": { - "type": "string", - "maxLength": 100 - }, - "lot_code": { - "type": "string", - "maxLength": 100 - }, - "initial_weight_grams": { - "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,6}(?:\\.\\d{0,2})?$" - }, - "remaining_weight_grams": { - "type": "string", - "format": "decimal", - "pattern": "^-?\\d{0,6}(?:\\.\\d{0,2})?$" - }, - "is_active": { - "type": "boolean" - }, - "opened_at": { - "type": "string", - "format": "date-time", - "nullable": true + "catalog": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationRuleCatalogItem" + } }, - "name": { - "type": "string", - "maxLength": 200 + "mutes": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationRuleMute" + } }, - "model": { - "type": "string", - "maxLength": 200 + "channels": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationChannel" + } }, - "status": { - "$ref": "#/components/schemas/MoveToPrintingRequestStatusEnum" + "features": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationFeature" + } }, - "notes": { - "type": "string" + "preferences": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationPreferenceCell" + } } }, "required": [ - "target" + "catalog", + "channels", + "features", + "mutes", + "preferences" ] }, - "MoveToPrintingRequestStatusEnum": { + "NotificationUnreadCount": { + "type": "object", + "properties": { + "count": { + "type": "integer" + } + }, + "required": [ + "count" + ] + }, + "NullEnum": { "enum": [ - "active", - "maintenance", - "offline" - ], - "type": "string", - "description": "* `active` - active\n* `maintenance` - maintenance\n* `offline` - offline" + null + ] }, - "MyMembershipRequest": { + "OfflineCheckInOperation": { "type": "object", "properties": { - "makerspace": { - "type": "object", - "additionalProperties": {} + "operation_id": { + "type": "string", + "format": "uuid" }, - "state": { - "type": "string" + "checkin_token": { + "type": "string", + "maxLength": 64 }, - "kind": { - "type": "string" + "reported_occurred_at": { + "type": "string", + "format": "date-time" } }, "required": [ - "kind", - "makerspace", - "state" + "checkin_token", + "operation_id", + "reported_occurred_at" ] }, - "MyMembershipRow": { + "OfflineCheckInResult": { "type": "object", "properties": { - "makerspace": { - "type": "object", - "additionalProperties": {} - }, - "membership_status": { - "type": "string" - }, - "role": { - "type": "string" - }, - "actions": { - "type": "array", - "items": { - "type": "string" - } - }, - "can_refer": { - "type": "boolean" - }, - "can_verify": { - "type": "boolean" - }, - "verified_at": { + "operation_id": { "type": "string", - "format": "date-time", - "nullable": true + "format": "uuid" }, - "referrals_enabled": { - "type": "boolean" + "outcome": { + "$ref": "#/components/schemas/OfflineCheckInResultOutcomeEnum" }, - "waiver_accepted": { - "type": "boolean" + "registration_id": { + "type": "integer" }, - "waiver_acceptance_required": { - "type": "boolean" + "attended_at": { + "type": "string", + "format": "date-time" } }, "required": [ - "actions", - "can_refer", - "can_verify", - "makerspace", - "membership_status", - "referrals_enabled", - "role", - "verified_at", - "waiver_acceptance_required", - "waiver_accepted" + "operation_id", + "outcome" ] }, - "MyMemberships": { + "OfflineCheckInResultOutcomeEnum": { + "enum": [ + "applied", + "duplicate_operation", + "already_attended", + "registration_changed", + "event_unavailable", + "invalid_token", + "outside_window" + ], + "type": "string", + "description": "* `applied` - applied\n* `duplicate_operation` - duplicate_operation\n* `already_attended` - already_attended\n* `registration_changed` - registration_changed\n* `event_unavailable` - event_unavailable\n* `invalid_token` - invalid_token\n* `outside_window` - outside_window" + }, + "OfflineCheckInSyncRequest": { "type": "object", "properties": { - "memberships": { - "type": "array", - "items": { - "$ref": "#/components/schemas/MyMembershipRow" - } + "lease_token": { + "type": "string", + "maxLength": 8192 }, - "requests": { + "operations": { "type": "array", "items": { - "$ref": "#/components/schemas/MyMembershipRequest" + "$ref": "#/components/schemas/OfflineCheckInOperation" } } }, "required": [ - "memberships", - "requests" + "lease_token", + "operations" ] }, - "NeedsFixAction": { + "OfflineCheckInSyncResponse": { "type": "object", "properties": { - "action": { - "$ref": "#/components/schemas/NeedsFixActionActionEnum" + "recorded_at": { + "type": "string", + "format": "date-time" }, - "quantity": { - "type": "integer", - "minimum": 1 + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/OfflineCheckInResult" + } } }, "required": [ - "action", - "quantity" + "recorded_at", + "results" ] }, - "NeedsFixActionActionEnum": { - "enum": [ - "repair", - "scrap", - "shelve" - ], - "type": "string", - "description": "* `repair` - repair\n* `scrap` - scrap\n* `shelve` - shelve" - }, - "Notification": { + "OfflineRosterEvent": { "type": "object", "properties": { "id": { - "type": "integer", - "readOnly": true - }, - "level": { - "allOf": [ - { - "$ref": "#/components/schemas/LevelEnum" - } - ], - "readOnly": true - }, - "event": { - "type": "string", - "readOnly": true + "type": "integer" }, "title": { - "type": "string", - "readOnly": true - }, - "body": { - "type": "string", - "readOnly": true - }, - "url_path": { - "type": "string", - "readOnly": true + "type": "string" }, - "read_at": { + "starts_at": { "type": "string", - "format": "date-time", - "readOnly": true, - "nullable": true + "format": "date-time" }, - "created_at": { + "ends_at": { "type": "string", - "format": "date-time", - "readOnly": true + "format": "date-time" } }, "required": [ - "body", - "created_at", - "event", + "ends_at", "id", - "level", - "read_at", - "title", - "url_path" + "starts_at", + "title" ] }, - "NotificationChannel": { + "OfflineRosterRegistration": { "type": "object", "properties": { - "key": { - "$ref": "#/components/schemas/KeyCbbEnum" + "registration_id": { + "type": "integer" }, - "label": { + "checkin_token": { + "type": "string", + "format": "uuid" + }, + "name": { "type": "string" + }, + "host_waiver_state": { + "$ref": "#/components/schemas/HostWaiverStateEnum" } }, "required": [ - "key", - "label" + "checkin_token", + "host_waiver_state", + "name", + "registration_id" ] }, - "NotificationDestination": { + "OfflineRosterResponse": { "type": "object", "properties": { - "id": { - "type": "integer", - "readOnly": true - }, - "channel": { - "allOf": [ - { - "$ref": "#/components/schemas/Channel7a7Enum" - } - ], - "readOnly": true + "lease_token": { + "type": "string" }, - "label": { + "lease_id": { "type": "string", - "readOnly": true + "format": "uuid" }, - "telegram_chat_id": { + "server_time": { "type": "string", - "readOnly": true + "format": "date-time" }, - "is_active": { - "type": "boolean", - "readOnly": true + "issued_at": { + "type": "string", + "format": "date-time" }, - "credential_set": { + "expires_at": { "type": "string", - "readOnly": true + "format": "date-time" }, - "scope": { + "scan_opens_at": { "type": "string", - "readOnly": true + "format": "date-time" }, - "created_at": { + "scan_closes_at": { "type": "string", - "format": "date-time", - "readOnly": true + "format": "date-time" }, - "updated_at": { + "sync_deadline": { "type": "string", - "format": "date-time", - "readOnly": true + "format": "date-time" + }, + "event": { + "$ref": "#/components/schemas/OfflineRosterEvent" + }, + "registrations": { + "type": "array", + "items": { + "$ref": "#/components/schemas/OfflineRosterRegistration" + } } }, "required": [ - "channel", - "created_at", - "credential_set", - "id", - "is_active", - "label", - "scope", - "telegram_chat_id", - "updated_at" + "event", + "expires_at", + "issued_at", + "lease_id", + "lease_token", + "registrations", + "scan_closes_at", + "scan_opens_at", + "server_time", + "sync_deadline" ] }, - "NotificationDestinationWrite": { + "OidcBrowserCallback": { "type": "object", "properties": { - "channel": { - "$ref": "#/components/schemas/Channel7a7Enum" - }, - "label": { + "code": { "type": "string", - "maxLength": 80 + "maxLength": 4096 }, - "webhook_url": { + "state": { "type": "string", - "writeOnly": true, - "maxLength": 2000 + "maxLength": 512 }, - "telegram_chat_id": { + "nonce": { "type": "string", - "maxLength": 64 - }, - "is_active": { - "type": "boolean", - "default": true - }, - "scope": { - "$ref": "#/components/schemas/DestinationScope" + "maxLength": 512 } }, "required": [ - "channel", - "label" + "code", + "nonce", + "state" ] }, - "NotificationFeature": { + "OidcBrowserLoginResponse": { "type": "object", "properties": { - "key": { - "$ref": "#/components/schemas/KeyD07Enum" - }, - "label": { + "access": { "type": "string" }, - "events": { - "type": "array", - "items": { - "type": "string" - } + "user": { + "type": "object", + "additionalProperties": {} + }, + "outcome": { + "type": "string" } }, "required": [ - "events", - "key", - "label" + "access", + "outcome", + "user" ] }, - "NotificationMarkAllRead": { + "OidcBrowserStart": { "type": "object", "properties": { - "updated": { - "type": "integer" + "redirect_uri": { + "type": "string", + "format": "uri", + "maxLength": 2048 + }, + "email": { + "oneOf": [ + { + "type": "string", + "format": "email" + }, + { + "type": "string", + "maxLength": 0 + } + ] + }, + "makerspace_slug": { + "oneOf": [ + { + "type": "string", + "pattern": "^[-a-zA-Z0-9_]+$" + }, + { + "type": "string", + "maxLength": 0 + } + ] } }, "required": [ - "updated" + "redirect_uri" ] }, - "NotificationPreferenceCell": { + "OidcBrowserStartResponse": { "type": "object", "properties": { - "feature": { - "$ref": "#/components/schemas/FeatureEnum" + "authorization_url": { + "type": "string", + "format": "uri" }, - "channel": { - "$ref": "#/components/schemas/ChannelCbbEnum" + "state": { + "type": "string" }, - "enabled": { - "type": "boolean" + "nonce": { + "type": "string" }, - "source": { - "$ref": "#/components/schemas/NotificationPreferenceCellSourceEnum" + "expires_in": { + "type": "integer" } }, "required": [ - "channel", - "enabled", - "feature", - "source" + "authorization_url", + "expires_in", + "nonce", + "state" ] }, - "NotificationPreferenceCellSourceEnum": { - "enum": [ - "default", - "override" - ], - "type": "string", - "description": "* `default` - default\n* `override` - override" - }, - "NotificationPreferenceChange": { + "OperatorCandidate": { "type": "object", "properties": { - "feature": { - "$ref": "#/components/schemas/FeatureEnum" + "user_id": { + "type": "integer", + "readOnly": true }, - "channel": { - "$ref": "#/components/schemas/ChannelCbbEnum" + "username": { + "type": "string", + "readOnly": true }, - "enabled": { - "type": "boolean" + "display_name": { + "type": "string", + "readOnly": true } }, "required": [ - "channel", - "enabled", - "feature" + "display_name", + "user_id", + "username" ] }, - "NotificationRecipient": { + "OrganizationDetail": { "type": "object", "properties": { "id": { "type": "integer", "readOnly": true }, - "username": { + "slug": { + "type": "string", + "readOnly": true, + "pattern": "^[-a-zA-Z0-9_]+$" + }, + "name": { "type": "string", "readOnly": true }, - "email": { + "governance_actions": { + "type": "array", + "items": { + "type": "string" + }, + "readOnly": true + }, + "granted_actions": { + "type": "array", + "items": { + "type": "string" + }, + "readOnly": true + }, + "description": { + "type": "string", + "readOnly": true + }, + "website": { + "type": "string", + "format": "uri", + "readOnly": true + }, + "logo_url": { + "type": "string", + "format": "uri", + "nullable": true, + "readOnly": true + }, + "public_profile_enabled": { + "type": "boolean", + "readOnly": true + }, + "is_active": { + "type": "boolean", + "readOnly": true + }, + "legal_name": { + "type": "string", + "readOnly": true + }, + "registration_number": { + "type": "string", + "readOnly": true + }, + "contact_email": { + "type": "string", + "format": "email", + "readOnly": true + }, + "billing_email": { + "type": "string", + "format": "email", + "readOnly": true + } + }, + "required": [ + "billing_email", + "contact_email", + "description", + "governance_actions", + "granted_actions", + "id", + "is_active", + "legal_name", + "logo_url", + "name", + "public_profile_enabled", + "registration_number", + "slug", + "website" + ] + }, + "OrganizationEventHost": { + "type": "object", + "properties": { + "slug": { + "type": "string", "readOnly": true, - "oneOf": [ - { - "type": "string", - "format": "email" - }, - { - "type": "string", - "maxLength": 0 - } - ] + "pattern": "^[-a-zA-Z0-9_]+$" }, - "role": { + "name": { + "type": "string", + "readOnly": true + }, + "logo_url": { "type": "string", + "format": "uri", + "nullable": true, "readOnly": true - }, - "receives_notifications": { - "type": "boolean" } }, "required": [ - "email", - "id", - "receives_notifications", - "role", - "username" + "logo_url", + "name", + "slug" ] }, - "NotificationRecipientUpdate": { + "OrganizationInvitation": { "type": "object", "properties": { "id": { - "type": "integer" + "type": "integer", + "readOnly": true }, - "receives_notifications": { - "type": "boolean" + "organization_id": { + "type": "integer", + "readOnly": true + }, + "governance_actions": { + "readOnly": true + }, + "granted_actions": { + "readOnly": true + }, + "expires_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "redeemed_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true + }, + "revoked_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true + }, + "created_by_id": { + "type": "integer", + "readOnly": true, + "nullable": true + }, + "redeemed_by_id": { + "type": "integer", + "readOnly": true, + "nullable": true + }, + "created_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "state": { + "allOf": [ + { + "$ref": "#/components/schemas/State31eEnum" + } + ], + "readOnly": true } }, "required": [ + "created_at", + "created_by_id", + "expires_at", + "governance_actions", + "granted_actions", "id", - "receives_notifications" + "organization_id", + "redeemed_at", + "redeemed_by_id", + "revoked_at", + "state" ] }, - "NotificationRuleCatalogItem": { + "OrganizationInvitationCreate": { "type": "object", "properties": { - "stream": { - "type": "string" - }, - "audience": { - "type": "string" - }, - "targets": { + "governance_actions": { "type": "array", "items": { "type": "string" } }, - "events": { + "granted_actions": { "type": "array", "items": { "type": "string" } + }, + "expires_in_days": { + "type": "integer", + "maximum": 30, + "minimum": 1, + "default": 7 } - }, - "required": [ - "audience", - "events", - "stream", - "targets" - ] + } }, - "NotificationRuleChange": { + "OrganizationInvitationCreated": { "type": "object", "properties": { - "target": { - "type": "string" + "id": { + "type": "integer", + "readOnly": true }, - "stream": { - "type": "string" + "organization_id": { + "type": "integer", + "readOnly": true }, - "event": { - "type": "string" + "governance_actions": { + "readOnly": true }, - "audience": { - "type": "string" + "granted_actions": { + "readOnly": true }, - "muted": { - "type": "boolean" - } - }, - "required": [ - "audience", - "event", - "muted", - "stream", - "target" - ] - }, - "NotificationRuleMute": { - "type": "object", - "properties": { - "target": { - "type": "string" + "expires_at": { + "type": "string", + "format": "date-time", + "readOnly": true }, - "stream": { - "type": "string" + "redeemed_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true }, - "event": { - "type": "string" + "revoked_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true }, - "audience": { - "type": "string" + "created_by_id": { + "type": "integer", + "readOnly": true, + "nullable": true + }, + "redeemed_by_id": { + "type": "integer", + "readOnly": true, + "nullable": true + }, + "created_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "state": { + "allOf": [ + { + "$ref": "#/components/schemas/State31eEnum" + } + ], + "readOnly": true + }, + "token": { + "type": "string", + "readOnly": true + }, + "redeem_path": { + "type": "string", + "readOnly": true } }, "required": [ - "audience", - "event", - "stream", - "target" + "created_at", + "created_by_id", + "expires_at", + "governance_actions", + "granted_actions", + "id", + "organization_id", + "redeem_path", + "redeemed_at", + "redeemed_by_id", + "revoked_at", + "state", + "token" ] }, - "NotificationRulesResponse": { + "OrganizationInvitationList": { "type": "object", "properties": { - "catalog": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationRuleCatalogItem" - } - }, - "mutes": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationRuleMute" - } + "count": { + "type": "integer" }, - "channels": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationChannel" - } + "next": { + "type": "string", + "nullable": true }, - "features": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationFeature" - } + "previous": { + "type": "string", + "nullable": true }, - "preferences": { + "results": { "type": "array", "items": { - "$ref": "#/components/schemas/NotificationPreferenceCell" + "$ref": "#/components/schemas/OrganizationInvitation" } } }, "required": [ - "catalog", - "channels", - "features", - "mutes", - "preferences" + "count", + "next", + "previous", + "results" ] }, - "NotificationUnreadCount": { + "OrganizationInvitationRedeem": { "type": "object", "properties": { - "count": { - "type": "integer" + "token": { + "type": "string", + "maxLength": 200, + "minLength": 20 } }, "required": [ - "count" - ] - }, - "NullEnum": { - "enum": [ - null + "token" ] }, - "OidcBrowserCallback": { + "OrganizationInvitationRedeemed": { "type": "object", "properties": { - "code": { - "type": "string", - "maxLength": 4096 - }, - "state": { - "type": "string", - "maxLength": 512 + "user": { + "$ref": "#/components/schemas/AuthUserPayload" }, - "nonce": { - "type": "string", - "maxLength": 512 + "membership": { + "$ref": "#/components/schemas/OrganizationMembership" } }, "required": [ - "code", - "nonce", - "state" + "membership", + "user" ] }, - "OidcBrowserLoginResponse": { + "OrganizationList": { "type": "object", "properties": { - "access": { - "type": "string" + "count": { + "type": "integer" }, - "user": { - "type": "object", - "additionalProperties": {} + "next": { + "type": "string", + "nullable": true }, - "outcome": { - "type": "string" + "previous": { + "type": "string", + "nullable": true + }, + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/OrganizationSummary" + } } }, "required": [ - "access", - "outcome", - "user" + "count", + "next", + "previous", + "results" ] }, - "OidcBrowserStart": { + "OrganizationMembership": { "type": "object", "properties": { - "redirect_uri": { + "id": { + "type": "integer", + "readOnly": true + }, + "user_id": { + "type": "integer", + "readOnly": true + }, + "username": { "type": "string", - "format": "uri", - "maxLength": 2048 + "readOnly": true + }, + "display_name": { + "type": "string", + "readOnly": true }, "email": { - "oneOf": [ - { - "type": "string", - "format": "email" - }, - { - "type": "string", - "maxLength": 0 - } - ] + "type": "string", + "format": "email", + "readOnly": true }, - "makerspace_slug": { - "oneOf": [ - { - "type": "string", - "pattern": "^[-a-zA-Z0-9_]+$" - }, + "status": { + "allOf": [ { - "type": "string", - "maxLength": 0 + "$ref": "#/components/schemas/OrganizationMembershipStatusEnum" } - ] - } - }, - "required": [ - "redirect_uri" - ] - }, - "OidcBrowserStartResponse": { - "type": "object", - "properties": { - "authorization_url": { - "type": "string", - "format": "uri" + ], + "readOnly": true }, - "state": { - "type": "string" + "governance_actions": { + "readOnly": true }, - "nonce": { - "type": "string" + "granted_actions": { + "readOnly": true }, - "expires_in": { - "type": "integer" + "created_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "updated_at": { + "type": "string", + "format": "date-time", + "readOnly": true } }, "required": [ - "authorization_url", - "expires_in", - "nonce", - "state" + "created_at", + "display_name", + "email", + "governance_actions", + "granted_actions", + "id", + "status", + "updated_at", + "user_id", + "username" ] }, - "OperatorCandidate": { + "OrganizationMembershipList": { "type": "object", "properties": { - "user_id": { - "type": "integer", - "readOnly": true + "count": { + "type": "integer" }, - "username": { + "next": { "type": "string", - "readOnly": true + "nullable": true }, - "display_name": { + "previous": { "type": "string", - "readOnly": true + "nullable": true + }, + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/OrganizationMembership" + } } }, "required": [ - "display_name", - "user_id", - "username" + "count", + "next", + "previous", + "results" ] }, + "OrganizationMembershipStatusEnum": { + "enum": [ + "active", + "suspended" + ], + "type": "string", + "description": "* `active` - Active\n* `suspended` - Suspended" + }, "OrganizationReportBreakdown": { "type": "object", "properties": { @@ -45566,6 +53773,53 @@ "rows" ] }, + "OrganizationSummary": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "readOnly": true + }, + "slug": { + "type": "string", + "readOnly": true, + "pattern": "^[-a-zA-Z0-9_]+$" + }, + "name": { + "type": "string", + "readOnly": true + }, + "governance_actions": { + "type": "array", + "items": { + "type": "string" + }, + "readOnly": true + }, + "granted_actions": { + "type": "array", + "items": { + "type": "string" + }, + "readOnly": true + } + }, + "required": [ + "governance_actions", + "granted_actions", + "id", + "name", + "slug" + ] + }, + "OrientationEnum": { + "enum": [ + "portrait", + "landscape" + ], + "type": "string", + "description": "* `portrait` - portrait\n* `landscape` - landscape" + }, "OtpResetPasswordConfirm": { "type": "object", "properties": { @@ -46357,6 +54611,15 @@ "target" ] }, + "PaperSizeEnum": { + "enum": [ + "A4", + "LETTER", + "custom" + ], + "type": "string", + "description": "* `A4` - A4\n* `LETTER` - LETTER\n* `custom` - custom" + }, "PasswordResetAcknowledgement": { "type": "object", "properties": { @@ -46800,6 +55063,84 @@ } } }, + "PatchedEventSeriesWrite": { + "type": "object", + "properties": { + "title": { + "type": "string", + "maxLength": 200 + }, + "description": { + "type": "string", + "default": "" + }, + "location": { + "type": "string", + "default": "", + "maxLength": 255 + }, + "location_kind": { + "allOf": [ + { + "$ref": "#/components/schemas/LocationKindEnum" + } + ], + "default": "other" + }, + "custom_form": { + "nullable": true + }, + "capacity": { + "type": "integer", + "minimum": 0, + "default": 0 + }, + "payment_amount": { + "type": "string", + "format": "decimal", + "pattern": "^-?\\d{0,10}(?:\\.\\d{0,2})?$", + "default": "0.00" + }, + "registration_requires_approval": { + "type": "boolean", + "default": false + }, + "registration_cutoff_lead_minutes": { + "type": "integer", + "minimum": 0, + "nullable": true + }, + "is_public": { + "type": "boolean", + "default": false + }, + "recurrence_timezone": { + "type": "string", + "maxLength": 64 + }, + "dtstart_local_date": { + "type": "string", + "format": "date" + }, + "dtstart_local_time": { + "type": "string", + "format": "time" + }, + "recurrence_rule": { + "type": "string", + "maxLength": 500 + }, + "duration_minutes": { + "type": "integer", + "minimum": 1 + }, + "effective_from": { + "type": "string", + "format": "date-time", + "writeOnly": true + } + } + }, "PatchedEventWrite": { "type": "object", "properties": { @@ -46819,6 +55160,10 @@ "type": "string", "format": "date-time" }, + "timezone_name": { + "type": "string", + "maxLength": 64 + }, "location": { "type": "string", "default": "", @@ -46849,6 +55194,38 @@ "is_public": { "type": "boolean", "default": false + }, + "registration_requires_approval": { + "type": "boolean", + "default": false + }, + "registration_cutoff_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "registration_cutoff_lead_minutes": { + "type": "integer", + "minimum": 0, + "nullable": true + }, + "inherit_fields": { + "type": "array", + "items": { + "$ref": "#/components/schemas/InheritFieldsEnum" + }, + "writeOnly": true + } + } + }, + "PatchedEvidenceRetentionPatch": { + "type": "object", + "properties": { + "object_retention_days": { + "type": "integer", + "maximum": 3650, + "minimum": 30, + "nullable": true } } }, @@ -47541,6 +55918,39 @@ } } }, + "PatchedOrganizationProfileUpdate": { + "type": "object", + "properties": { + "name": { + "type": "string", + "maxLength": 200 + }, + "slug": { + "type": "string", + "maxLength": 50, + "pattern": "^[-a-zA-Z0-9_]+$" + }, + "description": { + "type": "string" + }, + "website": { + "oneOf": [ + { + "type": "string", + "format": "uri", + "maxLength": 200 + }, + { + "type": "string", + "maxLength": 0 + } + ] + }, + "public_profile_enabled": { + "type": "boolean" + } + } + }, "PatchedPlatformBackupSettings": { "type": "object", "properties": { @@ -49528,6 +57938,20 @@ ], "readOnly": true }, + "registration_requires_approval": { + "type": "boolean", + "readOnly": true + }, + "effective_registration_cutoff_at": { + "type": "string", + "format": "date-time", + "nullable": true, + "readOnly": true + }, + "registration_open": { + "type": "boolean", + "readOnly": true + }, "image_url": { "type": "string", "format": "uri", @@ -49537,7 +57961,7 @@ "status": { "allOf": [ { - "$ref": "#/components/schemas/PublicEventStatusEnum" + "$ref": "#/components/schemas/StatusE90Enum" } ], "readOnly": true @@ -49548,6 +57972,20 @@ "$ref": "#/components/schemas/EventOrganizerSummary" }, "readOnly": true + }, + "series": { + "type": "object", + "nullable": true, + "properties": { + "public_token": { + "type": "string", + "format": "uuid" + }, + "title": { + "type": "string" + } + }, + "readOnly": true } }, "required": [ @@ -49555,12 +57993,16 @@ "capacity", "custom_form", "description", + "effective_registration_cutoff_at", "ends_at", "image_url", "location", "location_kind", "organizers", "public_token", + "registration_open", + "registration_requires_approval", + "series", "starts_at", "status", "title" @@ -49593,18 +58035,12 @@ }, "PublicEventRegistrationResponseStatusEnum": { "enum": [ + "pending_approval", "registered", "waitlisted" ], "type": "string", - "description": "* `registered` - registered\n* `waitlisted` - waitlisted" - }, - "PublicEventStatusEnum": { - "enum": [ - "published" - ], - "type": "string", - "description": "* `published` - published" + "description": "* `pending_approval` - pending_approval\n* `registered` - registered\n* `waitlisted` - waitlisted" }, "PublicImageAttachRequest": { "type": "object", @@ -49819,6 +58255,212 @@ "slug" ] }, + "PublicOrganization": { + "type": "object", + "properties": { + "slug": { + "type": "string", + "readOnly": true, + "pattern": "^[-a-zA-Z0-9_]+$" + }, + "name": { + "type": "string", + "readOnly": true + }, + "description": { + "type": "string", + "readOnly": true + }, + "website": { + "type": "string", + "format": "uri", + "readOnly": true + }, + "logo_url": { + "type": "string", + "format": "uri", + "nullable": true, + "readOnly": true + }, + "catalogue_links": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "readOnly": true + } + }, + "required": [ + "catalogue_links", + "description", + "logo_url", + "name", + "slug", + "website" + ] + }, + "PublicOrganizationEvent": { + "type": "object", + "properties": { + "public_token": { + "type": "string", + "format": "uuid", + "readOnly": true + }, + "title": { + "type": "string", + "readOnly": true + }, + "description": { + "type": "string", + "readOnly": true + }, + "starts_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "ends_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "location": { + "type": "string", + "readOnly": true + }, + "location_kind": { + "allOf": [ + { + "$ref": "#/components/schemas/LocationKindEnum" + } + ], + "readOnly": true + }, + "custom_form": { + "readOnly": true, + "nullable": true + }, + "capacity": { + "type": "integer", + "minimum": 0, + "readOnly": true + }, + "availability": { + "allOf": [ + { + "$ref": "#/components/schemas/AvailabilityEnum" + } + ], + "readOnly": true + }, + "registration_requires_approval": { + "type": "boolean", + "readOnly": true + }, + "effective_registration_cutoff_at": { + "type": "string", + "format": "date-time", + "nullable": true, + "readOnly": true + }, + "registration_open": { + "type": "boolean", + "readOnly": true + }, + "image_url": { + "type": "string", + "format": "uri", + "nullable": true, + "readOnly": true + }, + "status": { + "allOf": [ + { + "$ref": "#/components/schemas/StatusE90Enum" + } + ], + "readOnly": true + }, + "organizers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EventOrganizerSummary" + }, + "readOnly": true + }, + "series": { + "type": "object", + "nullable": true, + "properties": { + "public_token": { + "type": "string", + "format": "uuid" + }, + "title": { + "type": "string" + } + }, + "readOnly": true + }, + "host": { + "allOf": [ + { + "$ref": "#/components/schemas/OrganizationEventHost" + } + ], + "readOnly": true + } + }, + "required": [ + "availability", + "capacity", + "custom_form", + "description", + "effective_registration_cutoff_at", + "ends_at", + "host", + "image_url", + "location", + "location_kind", + "organizers", + "public_token", + "registration_open", + "registration_requires_approval", + "series", + "starts_at", + "status", + "title" + ] + }, + "PublicOrganizationEventList": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "next": { + "type": "string", + "nullable": true + }, + "previous": { + "type": "string", + "nullable": true + }, + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PublicOrganizationEvent" + } + } + }, + "required": [ + "count", + "next", + "previous", + "results" + ] + }, "PublicPrintStatusLookupPolicyEnum": { "enum": [ "token_only", @@ -51358,6 +60000,13 @@ "status" ] }, + "ReasonEnum": { + "enum": [ + "staff_revoked" + ], + "type": "string", + "description": "* `staff_revoked` - staff_revoked" + }, "ReceiptEnvelope": { "type": "object", "properties": { @@ -51845,6 +60494,82 @@ "reason" ] }, + "ReportCatalog": { + "type": "object", + "properties": { + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ReportCatalogItem" + } + } + }, + "required": [ + "results" + ] + }, + "ReportCatalogItem": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "title": { + "type": "string" + }, + "fields": { + "type": "array", + "items": { + "type": "string" + } + }, + "exportable": { + "type": "boolean" + }, + "summary": { + "type": "boolean" + }, + "required_modules": { + "type": "array", + "items": { + "type": "string" + } + }, + "available": { + "type": "boolean", + "nullable": true + }, + "unavailable_reason": { + "type": "string", + "nullable": true + }, + "grains": { + "type": "array", + "items": { + "type": "string" + } + }, + "chart_hint": { + "type": "string" + }, + "aggregate_supported": { + "type": "boolean" + } + }, + "required": [ + "aggregate_supported", + "available", + "chart_hint", + "exportable", + "fields", + "grains", + "key", + "required_modules", + "summary", + "title", + "unavailable_reason" + ] + }, "ReportError": { "type": "object", "properties": { @@ -52567,6 +61292,160 @@ "type": "string", "description": "* `machine` - machine\n* `full` - full" }, + "SeriesCollaborationInbox": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "readOnly": true + }, + "series_id": { + "type": "integer", + "readOnly": true + }, + "series_title": { + "type": "string", + "readOnly": true + }, + "host_name": { + "type": "string", + "readOnly": true + }, + "host_slug": { + "type": "string", + "readOnly": true, + "pattern": "^[-a-zA-Z0-9_]+$" + }, + "status": { + "allOf": [ + { + "$ref": "#/components/schemas/StatusB9dEnum" + } + ], + "readOnly": true + }, + "next_occurrence_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "created_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "responded_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true + } + }, + "required": [ + "created_at", + "host_name", + "host_slug", + "id", + "next_occurrence_at", + "responded_at", + "series_id", + "series_title", + "status" + ] + }, + "SeriesCollaborationRespond": { + "type": "object", + "properties": { + "accept": { + "type": "boolean" + } + }, + "required": [ + "accept" + ] + }, + "SeriesCollaborator": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "readOnly": true + }, + "series_id": { + "type": "integer", + "readOnly": true + }, + "makerspace_id": { + "type": "integer", + "readOnly": true + }, + "makerspace_name": { + "type": "string", + "readOnly": true + }, + "makerspace_slug": { + "type": "string", + "readOnly": true, + "pattern": "^[-a-zA-Z0-9_]+$" + }, + "status": { + "allOf": [ + { + "$ref": "#/components/schemas/StatusB9dEnum" + } + ], + "readOnly": true + }, + "invited_by_id": { + "type": "integer", + "readOnly": true, + "nullable": true + }, + "responded_by_id": { + "type": "integer", + "readOnly": true, + "nullable": true + }, + "created_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "responded_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true + } + }, + "required": [ + "created_at", + "id", + "invited_by_id", + "makerspace_id", + "makerspace_name", + "makerspace_slug", + "responded_at", + "responded_by_id", + "series_id", + "status" + ] + }, + "SeriesCollaboratorReplace": { + "type": "object", + "properties": { + "slugs": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[-a-zA-Z0-9_]+$" + } + } + }, + "required": [ + "slugs" + ] + }, "ServiceAccept": { "type": "object", "properties": { @@ -53328,15 +62207,109 @@ "type": "string", "description": "* `requested` - Requested\n* `claimed` - Claimed\n* `preflight` - Preflight\n* `quiesced` - Quiesced\n* `db_restoring` - Database restoring\n* `objects_restoring` - Objects restoring\n* `validating` - Validating\n* `completed` - Completed\n* `restored_quarantined` - Restored quarantined\n* `rolling_back` - Rolling back\n* `failed` - Failed\n* `aborted` - Aborted" }, - "StateEnum": { + "State31eEnum": { "enum": [ - "requested", - "invited", "active", - "revoked" + "expired", + "revoked", + "redeemed" ], "type": "string", - "description": "* `requested` - Requested\n* `invited` - Invited\n* `active` - Active\n* `revoked` - Revoked" + "description": "* `active` - active\n* `expired` - expired\n* `revoked` - revoked\n* `redeemed` - redeemed" + }, + "StationPin": { + "type": "object", + "properties": { + "pin": { + "type": "string", + "writeOnly": true, + "pattern": "^\\d{8}$" + } + }, + "required": [ + "pin" + ] + }, + "StationReveal": { + "type": "object", + "properties": { + "current_password": { + "type": "string", + "writeOnly": true + } + }, + "required": [ + "current_password" + ] + }, + "StationRevealResponse": { + "type": "object", + "properties": { + "pin": { + "type": "string" + }, + "version": { + "type": "integer" + } + }, + "required": [ + "pin", + "version" + ] + }, + "StationRotation": { + "type": "object", + "properties": { + "pin": { + "type": "string" + }, + "public_token": { + "type": "string", + "format": "uuid" + }, + "version": { + "type": "integer" + }, + "station_url": { + "type": "string", + "format": "uri" + } + }, + "required": [ + "pin", + "public_token", + "station_url", + "version" + ] + }, + "StationStatus": { + "type": "object", + "properties": { + "configured": { + "type": "boolean" + }, + "enabled": { + "type": "boolean" + }, + "public_token": { + "type": "string", + "format": "uuid" + }, + "version": { + "type": "integer" + }, + "station_url": { + "type": "string", + "format": "uri" + }, + "rotated_at": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "configured" + ] }, "Status37fEnum": { "enum": [ @@ -53395,6 +62368,13 @@ "type": "string", "description": "* `pending` - Pending\n* `running` - Running\n* `available` - Available\n* `failed` - Failed" }, + "StatusE90Enum": { + "enum": [ + "published" + ], + "type": "string", + "description": "* `published` - published" + }, "StatusE94Enum": { "enum": [ "pending", @@ -53404,6 +62384,16 @@ "type": "string", "description": "* `pending` - Pending\n* `approved` - Approved\n* `rejected` - Rejected" }, + "StatusFbbEnum": { + "enum": [ + "draft", + "published", + "cancelled", + "completed" + ], + "type": "string", + "description": "* `draft` - Draft\n* `published` - Published\n* `cancelled` - Cancelled\n* `completed` - Completed" + }, "StockTransfer": { "type": "object", "properties": { @@ -54114,6 +63104,14 @@ "publishable_key" ] }, + "TextAlignEnum": { + "enum": [ + "left", + "center" + ], + "type": "string", + "description": "* `left` - left\n* `center` - center" + }, "TimelineActor": { "type": "object", "properties": { @@ -55186,6 +64184,12 @@ } }, "securitySchemes": { + "EventStationCookie": { + "type": "apiKey", + "in": "cookie", + "name": "sw_event_station", + "description": "Signed, event/version-bound venue-station session cookie." + }, "jwtAuth": { "type": "http", "scheme": "bearer", diff --git a/frontend/public/sw.js b/frontend/public/sw.js new file mode 100644 index 00000000..0d127af0 --- /dev/null +++ b/frontend/public/sw.js @@ -0,0 +1,33 @@ +const CACHE_NAME = "spaceworks-static-v1"; +const STATIC_DESTINATIONS = new Set(["script", "style", "image", "font"]); + +self.addEventListener("activate", (event) => { + event.waitUntil( + caches.keys().then((keys) => Promise.all( + keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key)), + )), + ); +}); + +self.addEventListener("fetch", (event) => { + const request = event.request; + const url = new URL(request.url); + const bypass = request.method !== "GET" + || request.mode === "navigate" + || url.origin !== self.location.origin + || url.pathname.startsWith("/api/") + || url.pathname.startsWith("/api/v1/") + || request.headers.has("Authorization") + || !STATIC_DESTINATIONS.has(request.destination); + if (bypass) return; + + event.respondWith( + caches.open(CACHE_NAME).then(async (cache) => { + const cached = await cache.match(request); + if (cached) return cached; + const response = await fetch(request); + if (response.ok && response.type === "basic") await cache.put(request, response.clone()); + return response; + }), + ); +}); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7d74dbb0..01f0ba0d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,278 +1,7 @@ -import { useDeferredValue, useMemo, useState } from "react"; -import { Link, Navigate, Route, Routes, useLocation } from "react-router-dom"; - -import { MakerspaceBrand } from "./components/MakerspaceBrand"; -import { MakerspaceMapLink } from "./components/MakerspaceMapLink"; -import { SpaceWorksBadge, SpaceWorksHomeLink, SpaceWorksLogo } from "./components/SpaceWorksLogo"; -import { SiteFooter } from "./components/SiteFooter"; -import { ThemeToggle } from "./components/ThemeToggle"; -import { Card } from "./components/ui/Card"; -import { Spinner } from "./components/ui/Spinner"; -import { AboutPage } from "./features/AboutPage"; -import { PublicBookingsPage } from "./features/bookings/PublicBookingsPage"; -import { PublicInventoryPage } from "./features/inventory/PublicInventoryPage"; -import { PublicEventsPage } from "./features/inventory/PublicEventsPage"; -import { PublicMachinesPage } from "./features/inventory/PublicMachinesPage"; -import { PublicSelfCheckoutPage } from "./features/inventory/PublicSelfCheckoutPage"; -import { usePublicMakerspaces } from "./features/inventory/usePublicInventory"; -import { PublicPrintRequestPage } from "./features/printing/PublicPrintRequestPage"; -import { ArchivedPayments } from "./features/members/ArchivedPayments"; -import { MemberArea } from "./features/members/MemberArea"; -import { KioskPage, ScannerPage, SuperadminPage } from "./features/staff/PlatformApps"; -import { ResetPasswordPage } from "./features/staff/ResetPasswordPage"; -import { StaffApp } from "./features/staff/StaffApp"; -import { PublicStatsPage } from "./features/stats/PublicStatsPage"; +import { AppRoutes } from "./AppRoutes"; +import { SpaceWorksBadge } from "./components/SpaceWorksLogo"; +import { LandingPage } from "./features/LandingPage"; import { useTenant } from "./lib/tenant"; -import type { Makerspace } from "./types/inventory"; - -function normalizeSearch(value: string) { - return value.trim().toLowerCase().replace(/\s+/g, " "); -} - -function makerspaceSearchText(makerspace: Makerspace) { - return normalizeSearch([ - makerspace.name, - makerspace.public_code, - makerspace.slug, - makerspace.location, - ].filter(Boolean).join(" ")); -} -function LandingPage() { - const makerspacesQuery = usePublicMakerspaces(); - const [searchInput, setSearchInput] = useState(""); - const deferredSearchInput = useDeferredValue(searchInput); - const searchQuery = normalizeSearch(deferredSearchInput); - const searchTokens = useMemo( - () => searchQuery.split(" ").filter(Boolean), - [searchQuery], - ); - const indexedMakerspaces = useMemo( - () => (makerspacesQuery.data ?? []).map((makerspace) => ({ - makerspace, - searchText: makerspaceSearchText(makerspace), - })), - [makerspacesQuery.data], - ); - const filteredMakerspaces = useMemo(() => { - if (searchTokens.length === 0) { - return indexedMakerspaces.map(({ makerspace }) => makerspace); - } - - return indexedMakerspaces - .filter(({ searchText }) => - searchTokens.every((token) => searchText.includes(token)), - ) - .map(({ makerspace }) => makerspace); - }, [indexedMakerspaces, searchTokens]); - const totalMakerspaces = makerspacesQuery.data?.length ?? 0; - const isSearching = searchQuery.length > 0; - const onlyMakerspace = - makerspacesQuery.data?.length === 1 ? makerspacesQuery.data[0] : null; - - if (onlyMakerspace) { - return ; - } - - return ( -
-
-
- - -
-

Space Works

-

Shared equipment portal

-
-
-
- - - Staff login - -
-
-
- -
- - -
-
-
-
-

Available public catalogs

-

Select a makerspace to view shared equipment.

-
- - Standard public portal - -
-
event.preventDefault()} - > - - setSearchInput(event.target.value)} - /> - {searchInput ? ( - - ) : null} - - {isSearching - ? `${filteredMakerspaces.length}/${totalMakerspaces}` - : totalMakerspaces} shown - -
-
- - {makerspacesQuery.isLoading ? ( -
- -
- ) : null} - - {makerspacesQuery.isError ? ( - -

- Makerspaces are unavailable -

-

- The public makerspace directory could not be loaded. -

-
- ) : null} - - {makerspacesQuery.data && makerspacesQuery.data.length === 0 ? ( - -

- No public makerspaces yet -

-

- Public inventory appears here after a makerspace is enabled. -

-
- ) : null} - {makerspacesQuery.data && - makerspacesQuery.data.length > 0 && - filteredMakerspaces.length === 0 ? ( - -

- No matching makerspaces -

-

- Try another name, makerspace code, URL slug, or location. -

-
- ) : null} - - {filteredMakerspaces.length > 0 ? ( -
- {filteredMakerspaces.map((makerspace) => ( -
-
- {makerspace.cover_image_url ? ( - {`${makerspace.name} - ) : ( -
- )} - - Public - -
-
- - -
- - {makerspace.public_code} - - - Open catalog → - -
-
-
- ))} -
- ) : null} -
-
- - -
- ); -} - -function NotFoundPage() { - return ( -
-
-

- 404 -

-

Page not found

-
-
- ); -} export default function App() { const tenant = useTenant(); @@ -281,9 +10,6 @@ export default function App() { // must get past. Read the ROUTER's location, not `window.location` -- a client-side `Link` // updates router context without touching `window.location`, so a non-reactive read left // this branch stale and sent every click on the recovery CTA to the not-found page. - const location = useLocation(); - if (location.pathname === "/member/archived") return } />; - if (tenant.mode === "single" && tenant.loading) { return (
@@ -309,52 +35,5 @@ export default function App() { ); } - if (tenant.mode === "single") { - return ( - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - ); - } - - return ( - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - {/* The central member entry point. Without it `/member` 404s on a central deployment, - so a member whose only makerspace is ARCHIVED has nowhere to land: their tenant URL - no longer resolves and `/m/:slug/member` needs a slug they can no longer discover. - Tenant bootstrap fails here by design; MemberArea renders its recovery link anyway. */} - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - ); + return } />; } diff --git a/frontend/src/AppRoutes.tsx b/frontend/src/AppRoutes.tsx new file mode 100644 index 00000000..6888760e --- /dev/null +++ b/frontend/src/AppRoutes.tsx @@ -0,0 +1,76 @@ +import type { ReactNode } from "react"; +import { Route, Routes, useLocation } 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 { 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"; + +function NotFoundPage() { + return

404

Page not found

; +} + +export function AppRoutes({ mode, landing }: { mode: "single" | "central"; landing: ReactNode }) { + const location = useLocation(); + if (location.pathname === "/member/archived") { + return } />; + } + if (location.pathname.startsWith("/organization-invitations/redeem/")) { + return } />; + } + if (mode === "single") return + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + ; + return + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + ; +} diff --git a/frontend/src/features/LandingPage.tsx b/frontend/src/features/LandingPage.tsx new file mode 100644 index 00000000..f98b4df3 --- /dev/null +++ b/frontend/src/features/LandingPage.tsx @@ -0,0 +1,173 @@ +import { useDeferredValue, useMemo, useState } from "react"; +import { Link, Navigate } from "react-router-dom"; + +import { MakerspaceBrand } from "../components/MakerspaceBrand"; +import { MakerspaceMapLink } from "../components/MakerspaceMapLink"; +import { SpaceWorksHomeLink, SpaceWorksLogo } from "../components/SpaceWorksLogo"; +import { SiteFooter } from "../components/SiteFooter"; +import { ThemeToggle } from "../components/ThemeToggle"; +import { Card } from "../components/ui/Card"; +import { Spinner } from "../components/ui/Spinner"; +import type { Makerspace } from "../types/inventory"; +import { usePublicMakerspaces } from "./inventory/usePublicInventory"; + +function normalizeSearch(value: string) { + return value.trim().toLowerCase().replace(/\s+/g, " "); +} + +function makerspaceSearchText(makerspace: Makerspace) { + return normalizeSearch([ + makerspace.name, + makerspace.public_code, + makerspace.slug, + makerspace.location, + ].filter(Boolean).join(" ")); +} + +export function LandingPage() { + const makerspacesQuery = usePublicMakerspaces(); + const [searchInput, setSearchInput] = useState(""); + const searchQuery = normalizeSearch(useDeferredValue(searchInput)); + const searchTokens = useMemo( + () => searchQuery.split(" ").filter(Boolean), + [searchQuery], + ); + const indexedMakerspaces = useMemo( + () => (makerspacesQuery.data ?? []).map((makerspace) => ({ + makerspace, + searchText: makerspaceSearchText(makerspace), + })), + [makerspacesQuery.data], + ); + const filteredMakerspaces = useMemo(() => { + if (!searchTokens.length) return indexedMakerspaces.map(({ makerspace }) => makerspace); + return indexedMakerspaces + .filter(({ searchText }) => searchTokens.every((token) => searchText.includes(token))) + .map(({ makerspace }) => makerspace); + }, [indexedMakerspaces, searchTokens]); + const totalMakerspaces = makerspacesQuery.data?.length ?? 0; + const onlyMakerspace = totalMakerspaces === 1 ? makerspacesQuery.data?.[0] : null; + + if (onlyMakerspace) return ; + + return ( +
+
+
+ + +
+

Space Works

+

Shared equipment portal

+
+
+
+ + Staff login +
+
+
+ +
+ + +
+
+
+
+

Available public catalogs

+

Select a makerspace to view shared equipment.

+
+ + Standard public portal + +
+
event.preventDefault()}> + + setSearchInput(event.target.value)} + /> + {searchInput ? : null} + + {searchQuery ? `${filteredMakerspaces.length}/${totalMakerspaces}` : totalMakerspaces} shown + +
+
+ + {makerspacesQuery.isLoading ?
: null} + {makerspacesQuery.isError ? ( + +

Makerspaces are unavailable

+

The public makerspace directory could not be loaded.

+
+ ) : null} + {makerspacesQuery.data && !makerspacesQuery.data.length ? ( + +

No public makerspaces yet

+

Public inventory appears here after a makerspace is enabled.

+
+ ) : null} + {makerspacesQuery.data && makerspacesQuery.data.length > 0 && !filteredMakerspaces.length ? ( + +

No matching makerspaces

+

Try another name, makerspace code, URL slug, or location.

+
+ ) : null} + + {filteredMakerspaces.length ? ( +
+ {filteredMakerspaces.map((makerspace) => ( +
+
+ {makerspace.cover_image_url ? ( + {`${makerspace.name} + ) :
} + Public +
+
+ + +
+ {makerspace.public_code} + + Open catalog → + +
+
+
+ ))} +
+ ) : null} +
+
+ +
+ ); +} diff --git a/frontend/src/features/events/EventCheckInStationPage.tsx b/frontend/src/features/events/EventCheckInStationPage.tsx new file mode 100644 index 00000000..687747f1 --- /dev/null +++ b/frontend/src/features/events/EventCheckInStationPage.tsx @@ -0,0 +1,53 @@ +import { useState, type FormEvent } from "react"; +import { useParams } from "react-router-dom"; + +import { SpaceWorksBadge } from "../../components/SpaceWorksLogo"; +import { + downloadStationRoster, + endStationSession, + startStationSession, + syncStationRoster, +} from "../staff/eventCheckInOfflineApi"; +import { wipeOfflineState } from "../staff/eventCheckInOfflineStore"; +import { OfflineCheckInOperator } from "../staff/OfflineCheckInOperator"; + +export function EventCheckInStationPage() { + const { stationToken = "" } = useParams(); + const scope = `station:${stationToken}`; + const [pin, setPin] = useState(""); + const [active, setActive] = useState(false); + const [pending, setPending] = useState(false); + const [error, setError] = useState(null); + + async function submit(event: FormEvent) { + event.preventDefault(); setPending(true); setError(null); + try { await startStationSession(stationToken, pin); setPin(""); setActive(true); } + catch { setError("The station credential is invalid or outside its event window."); } + finally { setPending(false); } + } + + async function exit() { + await wipeOfflineState(scope); + try { await endStationSession(stationToken); } catch { /* cookie still expires server-bound */ } + setActive(false); setPin(""); + } + + return
+
+ +
+

Event check-in station

+ {!active ?
+

Enter the eight-digit PIN provided by the event organizer.

+ + setPin(event.target.value.replace(/\D/g, ""))} /> + +
: <> + downloadStationRoster(stationToken)} synchronize={(roster, operations) => syncStationRoster(stationToken, roster, operations)} /> + + } + {error ?

{error}

: null} +
+
+
; +} diff --git a/frontend/src/features/forms/CustomFormBuilder.tsx b/frontend/src/features/forms/CustomFormBuilder.tsx index ef47712d..6786a745 100644 --- a/frontend/src/features/forms/CustomFormBuilder.tsx +++ b/frontend/src/features/forms/CustomFormBuilder.tsx @@ -31,10 +31,22 @@ function newQuestion(): CustomFormQuestion { return { id: questionId(), label: "", type: "short_text", options: [], required: false }; } -export function CustomFormBuilder({ value, onChange, disabled = false }: { +export function CustomFormBuilder({ + value, + onChange, + disabled = false, + allowedTypes = CUSTOM_QUESTION_TYPES, + lockedQuestionIds = [], + legend = "Custom questions", + emptyMessage = "No custom questions. The standard contact fields will still be collected.", +}: { value: CustomFormSchema; onChange: (schema: CustomFormSchema) => void; disabled?: boolean; + allowedTypes?: readonly CustomQuestionType[]; + lockedQuestionIds?: readonly string[]; + legend?: string; + emptyMessage?: string; }) { const questions = value ?? []; const replace = (index: number, question: CustomFormQuestion) => { @@ -51,7 +63,7 @@ export function CustomFormBuilder({ value, onChange, disabled = false }: { return (
- Custom questions + {legend}

Answers are visible only to authorized staff.

{questions.map((question, index) => ( @@ -60,6 +72,8 @@ export function CustomFormBuilder({ value, onChange, disabled = false }: { question={question} index={index} count={questions.length} + allowedTypes={allowedTypes} + locked={lockedQuestionIds.includes(question.id)} onChange={(next) => replace(index, next)} onMove={(direction) => move(index, direction)} onRemove={() => onChange(questions.filter((item) => item.id !== question.id))} @@ -67,7 +81,7 @@ export function CustomFormBuilder({ value, onChange, disabled = false }: { ))} {!questions.length ? (

- No custom questions. The standard contact fields will still be collected. + {emptyMessage}

) : null} - +
- {choice ? : null} + {choice ? : null} void; }) { return ( @@ -153,11 +170,11 @@ function OptionsEditor({ question, onChange }: {

Choices

{question.options.map((option, index) => (
- onChange({ ...question, options: question.options.map((item, itemIndex) => itemIndex === index ? event.target.value : item) })} /> - + onChange({ ...question, options: question.options.map((item, itemIndex) => itemIndex === index ? event.target.value : item) })} /> +
))} - + ); } diff --git a/frontend/src/features/forms/CustomFormFields.tsx b/frontend/src/features/forms/CustomFormFields.tsx index bea0ccf5..b367cde1 100644 --- a/frontend/src/features/forms/CustomFormFields.tsx +++ b/frontend/src/features/forms/CustomFormFields.tsx @@ -2,12 +2,13 @@ import { useId } from "react"; import type { CustomAnswers, CustomFormQuestion, CustomFormSchema } from "./customFormTypes"; -export function CustomFormFields({ schema, answers, onChange, errors = {}, disabled = false }: { +export function CustomFormFields({ schema, answers, onChange, errors = {}, disabled = false, yesNoAsCheckbox = false }: { schema: CustomFormSchema; answers: CustomAnswers; onChange: (answers: CustomAnswers) => void; errors?: Record; disabled?: boolean; + yesNoAsCheckbox?: boolean; }) { const prefix = useId(); if (!schema?.length) return null; @@ -23,6 +24,7 @@ export function CustomFormFields({ schema, answers, onChange, errors = {}, disab inputId={prefix + "-" + question.id} value={answers[question.id]} error={errors[question.id]} + yesNoAsCheckbox={yesNoAsCheckbox} onChange={(value) => setAnswer(question.id, value)} /> ))} @@ -30,11 +32,12 @@ export function CustomFormFields({ schema, answers, onChange, errors = {}, disab ); } -function QuestionField({ question, inputId, value, error, onChange }: { +function QuestionField({ question, inputId, value, error, yesNoAsCheckbox, onChange }: { question: CustomFormQuestion; inputId: string; value: CustomAnswers[string]; error?: string; + yesNoAsCheckbox: boolean; onChange: (value: CustomAnswers[string]) => void; }) { const errorId = inputId + "-error"; @@ -47,6 +50,10 @@ function QuestionField({ question, inputId, value, error, onChange }: { const label = {question.label}{question.required ? * : null}; const message = error ? {error} : null; + if (question.type === "yes_no" && yesNoAsCheckbox) { + return ; + } + if (question.type === "paragraph") { return