From 1a493469bad0e809d4f0091d9048ccc3bec215d9 Mon Sep 17 00:00:00 2001 From: Shaan-Shoukath Date: Wed, 2 Sep 2026 01:30:24 +0530 Subject: [PATCH 01/20] refactor(events): split models.py into focused modules behind a barrel Co-Authored-By: Shaan-Shoukath Co-Authored-By: Claude Opus 5 (1M context) Co-Authored-By: Codex GPT-5.6 --- backend/apps/events/models.py | 303 +------------------- backend/apps/events/models_collaborators.py | 56 ++++ backend/apps/events/models_event.py | 115 ++++++++ backend/apps/events/models_registration.py | 138 +++++++++ 4 files changed, 314 insertions(+), 298 deletions(-) create mode 100644 backend/apps/events/models_collaborators.py create mode 100644 backend/apps/events/models_event.py create mode 100644 backend/apps/events/models_registration.py diff --git a/backend/apps/events/models.py b/backend/apps/events/models.py index 5280dd06..9bbb722d 100644 --- a/backend/apps/events/models.py +++ b/backend/apps/events/models.py @@ -1,300 +1,7 @@ -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 +# 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.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) +__all__ = ["Event", "EventCollaborator", "EventRegistration"] diff --git a/backend/apps/events/models_collaborators.py b/backend/apps/events/models_collaborators.py new file mode 100644 index 00000000..c2423788 --- /dev/null +++ b/backend/apps/events/models_collaborators.py @@ -0,0 +1,56 @@ +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) + + 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..dd69624a --- /dev/null +++ b/backend/apps/events/models_event.py @@ -0,0 +1,115 @@ +from uuid import uuid4 + +from django.conf import settings +from django.core.validators import MinValueValidator +from django.db import models +from django.db.models import F, Q +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) diff --git a/backend/apps/events/models_registration.py b/backend/apps/events/models_registration.py new file mode 100644 index 00000000..61123276 --- /dev/null +++ b/backend/apps/events/models_registration.py @@ -0,0 +1,138 @@ +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_event import Event + + +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) From 330a9890bf6e5144083d1b336fa5e123f8e8e778 Mon Sep 17 00:00:00 2001 From: Shaan-Shoukath Date: Wed, 2 Sep 2026 01:44:57 +0530 Subject: [PATCH 02/20] feat(organizations): public directory, governance invitations and managed organizers Option A only: the organization is a discovery, attribution and authority layer. Makerspace remains the tenancy anchor. Co-Authored-By: Shaan-Shoukath Co-Authored-By: Claude Opus 5 (1M context) Co-Authored-By: Codex GPT-5.6 --- backend/apps/accounts/claim_routes_auth.py | 5 + backend/apps/accounts/claim_routes_public.py | 4 + .../apps/accounts/services_social_login.py | 4 + backend/apps/accounts/urls.py | 6 + .../apps/apiclients/scope_registry_routes.py | 2 + backend/apps/data_export/classification.py | 3 + backend/apps/data_export/references.py | 5 +- backend/apps/events/admin.py | 51 +- backend/apps/events/serializers_organizers.py | 15 + backend/apps/events/serializers_public.py | 1 + backend/apps/events/services_organizers.py | 82 + backend/apps/events/urls_admin.py | 6 + backend/apps/events/views_admin_organizers.py | 40 + .../apps/makerspaces/origin_scope_routes.py | 1 + backend/apps/organizations/access.py | 69 + backend/apps/organizations/admin.py | 16 + backend/apps/organizations/exceptions.py | 40 + backend/apps/organizations/governance.py | 57 + ...rganization_presentation_and_governance.py | 48 + backend/apps/organizations/models.py | 58 + backend/apps/organizations/public_catalog.py | 43 + .../apps/organizations/serializers_admin.py | 169 ++ .../apps/organizations/serializers_public.py | 56 + .../organizations/services_invitations.py | 212 ++ .../apps/organizations/services_profiles.py | 40 + backend/apps/organizations/urls_admin.py | 30 + backend/apps/organizations/urls_public.py | 16 + backend/apps/organizations/views_admin.py | 197 ++ backend/apps/organizations/views_public.py | 65 + backend/apps/organizations/views_redeem.py | 45 + .../tenant_migration/audit_references_meta.py | 16 + .../tenant_dump_field_snapshot.py | 5 +- backend/config/urls.py | 5 + backend/tests/accounts/test_claim_routes.py | 5 +- .../events/test_event_organizer_management.py | 168 ++ .../test_governance_invitations.py | 270 ++ .../organizations/test_public_directory.py | 124 + docs/INVARIANTS.md | 18 + docs/SOURCE-MAP.md | 11 +- frontend/openapi-schema.json | 2565 +++++++++++++---- frontend/src/App.tsx | 257 +- frontend/src/features/LandingPage.tsx | 173 ++ .../OrganizationInvitationRedeemPage.tsx | 92 + .../organizations/PublicOrganizationPage.tsx | 117 + .../organizations/publicOrganizationsApi.ts | 59 + .../src/features/staff/EventOrganizers.tsx | 49 + frontend/src/features/staff/EventsPanel.tsx | 2 + .../staff/OrganizationInvitations.tsx | 64 + .../staff/OrganizationMemberships.tsx | 44 + .../staff/OrganizationProfileForm.tsx | 60 + frontend/src/features/staff/StaffApp.tsx | 8 +- .../src/features/staff/StaffTabContent.tsx | 4 + frontend/src/features/staff/eventsApi.ts | 18 + .../staff/organizationGovernanceTab.test.ts | 21 + .../src/features/staff/organizationsApi.ts | 120 + .../staff/panels/OrganizationsPanel.tsx | 44 + frontend/src/features/staff/staffAccess.ts | 8 +- frontend/src/features/staff/staffTabs.ts | 3 +- frontend/src/generated/api.ts | 187 +- 59 files changed, 5078 insertions(+), 825 deletions(-) create mode 100644 backend/apps/events/serializers_organizers.py create mode 100644 backend/apps/events/services_organizers.py create mode 100644 backend/apps/events/views_admin_organizers.py create mode 100644 backend/apps/organizations/access.py create mode 100644 backend/apps/organizations/exceptions.py create mode 100644 backend/apps/organizations/governance.py create mode 100644 backend/apps/organizations/migrations/0003_organization_presentation_and_governance.py create mode 100644 backend/apps/organizations/public_catalog.py create mode 100644 backend/apps/organizations/serializers_admin.py create mode 100644 backend/apps/organizations/serializers_public.py create mode 100644 backend/apps/organizations/services_invitations.py create mode 100644 backend/apps/organizations/services_profiles.py create mode 100644 backend/apps/organizations/urls_admin.py create mode 100644 backend/apps/organizations/urls_public.py create mode 100644 backend/apps/organizations/views_admin.py create mode 100644 backend/apps/organizations/views_public.py create mode 100644 backend/apps/organizations/views_redeem.py create mode 100644 backend/tests/events/test_event_organizer_management.py create mode 100644 backend/tests/organizations/test_governance_invitations.py create mode 100644 backend/tests/organizations/test_public_directory.py create mode 100644 frontend/src/features/LandingPage.tsx create mode 100644 frontend/src/features/organizations/OrganizationInvitationRedeemPage.tsx create mode 100644 frontend/src/features/organizations/PublicOrganizationPage.tsx create mode 100644 frontend/src/features/organizations/publicOrganizationsApi.ts create mode 100644 frontend/src/features/staff/EventOrganizers.tsx create mode 100644 frontend/src/features/staff/OrganizationInvitations.tsx create mode 100644 frontend/src/features/staff/OrganizationMemberships.tsx create mode 100644 frontend/src/features/staff/OrganizationProfileForm.tsx create mode 100644 frontend/src/features/staff/organizationGovernanceTab.test.ts create mode 100644 frontend/src/features/staff/organizationsApi.ts create mode 100644 frontend/src/features/staff/panels/OrganizationsPanel.tsx 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_public.py b/backend/apps/accounts/claim_routes_public.py index 38b36bf9..c8c6df3f 100644 --- a/backend/apps/accounts/claim_routes_public.py +++ b/backend/apps/accounts/claim_routes_public.py @@ -33,6 +33,10 @@ 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"), ("public-event-register", "POST"): Allowed( tenant=PUBLIC_TOKEN, audited=True ), 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/apiclients/scope_registry_routes.py b/backend/apps/apiclients/scope_registry_routes.py index 991898a5..af2c7f5d 100644 --- a/backend/apps/apiclients/scope_registry_routes.py +++ b/backend/apps/apiclients/scope_registry_routes.py @@ -57,6 +57,8 @@ 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, False), + ("public-organization-events", _READ, PUBLIC_READ_SCOPES, TARGET_GLOBAL, True, False), ("public-event-register", _WRITE, PUBLIC_WRITE_SCOPES, TARGET_TENANT_SLUG, 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), diff --git a/backend/apps/data_export/classification.py b/backend/apps/data_export/classification.py index 1f7691b5..9f184633 100644 --- a/backend/apps/data_export/classification.py +++ b/backend/apps/data_export/classification.py @@ -179,6 +179,9 @@ "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/references.py b/backend/apps/data_export/references.py index 7350f7d9..254a6884 100644 --- a/backend/apps/data_export/references.py +++ b/backend/apps/data_export/references.py @@ -13,8 +13,6 @@ 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 +20,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"), @@ -151,6 +148,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"), diff --git a/backend/apps/events/admin.py b/backend/apps/events/admin.py index 58ade596..61b0d471 100644 --- a/backend/apps/events/admin.py +++ b/backend/apps/events/admin.py @@ -2,7 +2,6 @@ from unfold.admin import ModelAdmin from apps.accounts import rbac -from apps.audit import services as audit from apps.events.models import Event, EventOrganizer from apps.separability.tombstones import app_is_tombstoned from config.admin_access import SuperuserOnlyModelAdmin @@ -19,6 +18,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 +40,5 @@ def formfield_for_foreignkey(self, db_field, request, **kwargs): ) 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) - audit.record( - request.user, - "event.organizer_updated" if change else "event.organizer_created", - makerspace=obj.event.makerspace, - target=obj, - meta={ - "event_id": obj.event_id, - "organization_slug": obj.organization.slug, - }, - ) - - def delete_model(self, request, obj): - self._record_deletion(request, obj) - 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) - 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) 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..38fdb9dc 100644 --- a/backend/apps/events/serializers_public.py +++ b/backend/apps/events/serializers_public.py @@ -25,6 +25,7 @@ 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) 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/urls_admin.py b/backend/apps/events/urls_admin.py index 40ed6a4d..d56cf345 100644 --- a/backend/apps/events/urls_admin.py +++ b/backend/apps/events/urls_admin.py @@ -22,6 +22,7 @@ ) 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_collaborators import ( EventCollaborationInboxView, @@ -66,6 +67,11 @@ EventRegistrationListView.as_view(), name='admin-event-registration-list', ), + path( + 'events//organizers/', + EventOrganizerView.as_view(), + name='admin-event-organizers', + ), path( 'events//collaborators/', EventCollaboratorListView.as_view(), 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/makerspaces/origin_scope_routes.py b/backend/apps/makerspaces/origin_scope_routes.py index 9bbd3e94..6c84761e 100644 --- a/backend/apps/makerspaces/origin_scope_routes.py +++ b/backend/apps/makerspaces/origin_scope_routes.py @@ -87,6 +87,7 @@ 'admin-event-cancel': ('events.Event', 'makerspace_id'), 'admin-event-complete': ('events.Event', 'makerspace_id'), 'admin-event-registration-list': ('events.Event', 'makerspace_id'), + 'admin-event-organizers': ('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'), 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..a0e80bcf --- /dev/null +++ b/backend/apps/organizations/urls_public.py @@ -0,0 +1,16 @@ +from django.urls import path + +from apps.organizations.views_public import ( + PublicOrganizationDetailView, + PublicOrganizationEventListView, +) + + +urlpatterns = [ + path("/", PublicOrganizationDetailView.as_view(), name="public-organization-detail"), + path( + "/events/", + PublicOrganizationEventListView.as_view(), + name="public-organization-events", + ), +] 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/tenant_migration/audit_references_meta.py b/backend/apps/tenant_migration/audit_references_meta.py index 6b553a9d..a9158fa3 100644 --- a/backend/apps/tenant_migration/audit_references_meta.py +++ b/backend/apps/tenant_migration/audit_references_meta.py @@ -236,6 +236,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/tenant_dump_field_snapshot.py b/backend/apps/tenant_migration/tenant_dump_field_snapshot.py index d754a9c4..bee2585b 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()), diff --git a/backend/config/urls.py b/backend/config/urls.py index 6b5c215a..d6601040 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 diff --git a/backend/tests/accounts/test_claim_routes.py b/backend/tests/accounts/test_claim_routes.py index 505057bf..2324c8af 100644 --- a/backend/tests/accounts/test_claim_routes.py +++ b/backend/tests/accounts/test_claim_routes.py @@ -37,10 +37,11 @@ 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 + assert len(validate_claim_route_matrix()) == 78 def test_unclassified_runtime_lookup_fails_closed_and_middleware_stays_out(): 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/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/docs/INVARIANTS.md b/docs/INVARIANTS.md index 48d0bb0d..0ddbbe75 100644 --- a/docs/INVARIANTS.md +++ b/docs/INVARIANTS.md @@ -2531,6 +2531,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/SOURCE-MAP.md b/docs/SOURCE-MAP.md index b1a996a9..58b8caf5 100644 --- a/docs/SOURCE-MAP.md +++ b/docs/SOURCE-MAP.md @@ -24,10 +24,13 @@ 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. - `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 diff --git a/frontend/openapi-schema.json b/frontend/openapi-schema.json index 6c7f72f8..50891ea3 100644 --- a/frontend/openapi-schema.json +++ b/frontend/openapi-schema.json @@ -2942,6 +2942,98 @@ } } }, + "/api/v1/admin/events/{id}/organizers/": { + "put": { + "operationId": "api_v1_admin_events_organizers_update", + "summary": "Replace an event's organization organizers", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Admin events" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EventOrganizerReplace" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/EventOrganizerReplace" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/EventOrganizerReplace" + } + } + }, + "required": true + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EventOrganizerList" + } + } + }, + "description": "" + }, + "400": { + "description": "Invalid or unavailable organization." + }, + "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/events/{id}/publish/": { "post": { "operationId": "api_v1_admin_events_publish_create", @@ -21393,6 +21485,134 @@ } } }, + "/api/v1/admin/organization-invitations/{id}/": { + "delete": { + "operationId": "api_v1_admin_organization_invitations_destroy", + "summary": "Revoke an unused organization invitation", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Admin organizations" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "204": { + "description": "No response body" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Authentication is required." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Organization authority is required." + }, + "404": { + "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/organizations/": { + "get": { + "operationId": "admin_organizations_list", + "summary": "List organizations visible to the actor", + "tags": [ + "Admin organizations" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OrganizationList" + } + } + }, + "description": "" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Authentication is required." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Organization authority is required." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Organization not found." + } + } + } + }, "/api/v1/admin/organizations/{organization_id}/analytics/{report_key}": { "get": { "operationId": "api_v1_admin_organizations_analytics_retrieve", @@ -21547,6 +21767,396 @@ } } }, + "/api/v1/admin/organizations/{id}/": { + "get": { + "operationId": "admin_organizations_retrieve", + "summary": "Retrieve organization governance details", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Admin organizations" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OrganizationDetail" + } + } + }, + "description": "" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Authentication is required." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Organization authority is required." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Organization not found." + } + } + }, + "patch": { + "operationId": "api_v1_admin_organizations_partial_update", + "summary": "Update an organization public profile", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "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": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OrganizationDetail" + } + } + }, + "description": "" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Authentication is required." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Organization authority is required." + }, + "404": { + "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/organizations/{id}/invitations/": { + "get": { + "operationId": "api_v1_admin_organizations_invitations_retrieve", + "summary": "List organization invitations without bearer tokens", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Admin organizations" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OrganizationInvitationList" + } + } + }, + "description": "" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Authentication is required." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Organization authority is required." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Organization not found." + } + } + }, + "post": { + "operationId": "api_v1_admin_organizations_invitations_create", + "summary": "Create a single-use organization invitation", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "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": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OrganizationInvitationCreated" + } + } + }, + "description": "" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Authentication is required." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Organization authority is required." + }, + "404": { + "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/organizations/{id}/memberships/": { + "get": { + "operationId": "api_v1_admin_organizations_memberships_retrieve", + "summary": "List organization memberships", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "tags": [ + "Admin organizations" + ], + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OrganizationMembershipList" + } + } + }, + "description": "" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Authentication is required." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Organization authority is required." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "Organization not found." + } + } + } + }, "/api/v1/admin/organized-events/": { "get": { "operationId": "api_v1_admin_organized_events_retrieve", @@ -27633,6 +28243,88 @@ } } }, + "/api/v1/auth/organization-invitations/redeem/": { + "post": { + "operationId": "api_v1_auth_organization_invitations_redeem_create", + "summary": "Redeem a single-use organization invitation", + "tags": [ + "Auth" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OrganizationInvitationRedeem" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/OrganizationInvitationRedeem" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/OrganizationInvitationRedeem" + } + } + }, + "required": true + }, + "security": [ + { + "jwtAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OrganizationInvitationRedeemed" + } + } + }, + "description": "" + }, + "400": { + "description": "Malformed token." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + }, + "404": { + "description": "Invitation not found." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HardwareRequestError" + } + } + }, + "description": "" + } + } + } + }, "/api/v1/auth/phone": { "delete": { "operationId": "api_v1_auth_phone_destroy", @@ -33814,152 +34506,337 @@ } } }, - "/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/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": "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": "public_token", "schema": { - "type": "string" + "type": "string", + "format": "uuid" }, "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": [] - } + "Public machine service" ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EvidenceUrlResponse" + "$ref": "#/components/schemas/PublicPrinterStatus" } } }, "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", + "/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", @@ -33976,215 +34853,104 @@ "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" + "Public inventory" ], - "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" + "type": "array", + "items": { + "$ref": "#/components/schemas/PublicMakerspace" + } } } }, "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": { + "/api/v1/public/organizations/{slug}/": { "get": { - "operationId": "api_v1_public_machine_service_3d_printer_requests_status_retrieve", + "operationId": "api_v1_public_organizations_retrieve", + "summary": "Retrieve a public organization profile", "parameters": [ { "in": "path", - "name": "public_token", + "name": "slug", "schema": { - "type": "string", - "format": "uuid" + "type": "string" }, "required": true } ], "tags": [ - "Public machine service" + "Public organizations" ], "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicPrinterStatus" + "$ref": "#/components/schemas/PublicOrganization" } } }, "description": "" + }, + "404": { + "description": "Organization not found." + }, + "429": { + "description": "Rate limit exceeded." } } } }, - "/api/v1/public/makerspaces/": { + "/api/v1/public/organizations/{slug}/events/": { "get": { - "operationId": "api_v1_public_makerspaces_list", - "description": "List makerspaces that have public inventory enabled.", - "summary": "List public makerspaces", + "operationId": "api_v1_public_organizations_events_retrieve", + "summary": "List public events organized across 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", + "in": "path", + "name": "slug", "schema": { "type": "string" }, - "description": "Public API key for a makerspace public client. Required when API_CLIENT_AUTH_REQUIRED is enabled." + "required": true } ], "tags": [ - "Public inventory" - ], - "security": [ - { - "jwtAuth": [] - }, - {} + "Public organizations" ], "responses": { "200": { "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PublicMakerspace" - } + "$ref": "#/components/schemas/PublicOrganizationEventList" } } }, "description": "" + }, + "404": { + "description": "Organization not found." + }, + "429": { + "description": "Rate limit exceeded." } } } @@ -39280,9 +40046,44 @@ "results" ] }, + "EventOrganizerList": { + "type": "object", + "properties": { + "organizers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EventOrganizerSummary" + }, + "readOnly": true + } + }, + "required": [ + "organizers" + ] + }, + "EventOrganizerReplace": { + "type": "object", + "properties": { + "organization_ids": { + "type": "array", + "items": { + "type": "integer", + "minimum": 1 + }, + "maxItems": 50 + } + }, + "required": [ + "organization_ids" + ] + }, "EventOrganizerSummary": { "type": "object", "properties": { + "id": { + "type": "integer", + "readOnly": true + }, "slug": { "type": "string", "readOnly": true, @@ -39294,6 +40095,7 @@ } }, "required": [ + "id", "name", "slug" ] @@ -44337,7 +45139,7 @@ "$ref": "#/components/schemas/MembershipRequestKindEnum" }, "state": { - "$ref": "#/components/schemas/StateEnum" + "$ref": "#/components/schemas/MembershipRequestStateEnum" }, "invite_email": { "type": "string", @@ -44416,6 +45218,16 @@ "name" ] }, + "MembershipRequestStateEnum": { + "enum": [ + "requested", + "invited", + "active", + "revoked" + ], + "type": "string", + "description": "* `requested` - Requested\n* `invited` - Invited\n* `active` - Active\n* `revoked` - Revoked" + }, "MembershipRequestUser": { "type": "object", "properties": { @@ -45506,6 +46318,479 @@ "username" ] }, + "OrganizationDetail": { + "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 + }, + "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, + "pattern": "^[-a-zA-Z0-9_]+$" + }, + "name": { + "type": "string", + "readOnly": true + }, + "logo_url": { + "type": "string", + "format": "uri", + "nullable": true, + "readOnly": true + } + }, + "required": [ + "logo_url", + "name", + "slug" + ] + }, + "OrganizationInvitation": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "readOnly": true + }, + "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", + "organization_id", + "redeemed_at", + "redeemed_by_id", + "revoked_at", + "state" + ] + }, + "OrganizationInvitationCreate": { + "type": "object", + "properties": { + "governance_actions": { + "type": "array", + "items": { + "type": "string" + } + }, + "granted_actions": { + "type": "array", + "items": { + "type": "string" + } + }, + "expires_in_days": { + "type": "integer", + "maximum": 30, + "minimum": 1, + "default": 7 + } + } + }, + "OrganizationInvitationCreated": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "readOnly": true + }, + "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 + }, + "token": { + "type": "string", + "readOnly": true + }, + "redeem_path": { + "type": "string", + "readOnly": true + } + }, + "required": [ + "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" + ] + }, + "OrganizationInvitationList": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "next": { + "type": "string", + "nullable": true + }, + "previous": { + "type": "string", + "nullable": true + }, + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/OrganizationInvitation" + } + } + }, + "required": [ + "count", + "next", + "previous", + "results" + ] + }, + "OrganizationInvitationRedeem": { + "type": "object", + "properties": { + "token": { + "type": "string", + "maxLength": 200, + "minLength": 20 + } + }, + "required": [ + "token" + ] + }, + "OrganizationInvitationRedeemed": { + "type": "object", + "properties": { + "user": { + "$ref": "#/components/schemas/AuthUserPayload" + }, + "membership": { + "$ref": "#/components/schemas/OrganizationMembership" + } + }, + "required": [ + "membership", + "user" + ] + }, + "OrganizationList": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "next": { + "type": "string", + "nullable": true + }, + "previous": { + "type": "string", + "nullable": true + }, + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/OrganizationSummary" + } + } + }, + "required": [ + "count", + "next", + "previous", + "results" + ] + }, + "OrganizationMembership": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "readOnly": true + }, + "user_id": { + "type": "integer", + "readOnly": true + }, + "username": { + "type": "string", + "readOnly": true + }, + "display_name": { + "type": "string", + "readOnly": true + }, + "email": { + "type": "string", + "format": "email", + "readOnly": true + }, + "status": { + "allOf": [ + { + "$ref": "#/components/schemas/OrganizationMembershipStatusEnum" + } + ], + "readOnly": true + }, + "governance_actions": { + "readOnly": true + }, + "granted_actions": { + "readOnly": true + }, + "created_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "updated_at": { + "type": "string", + "format": "date-time", + "readOnly": true + } + }, + "required": [ + "created_at", + "display_name", + "email", + "governance_actions", + "granted_actions", + "id", + "status", + "updated_at", + "user_id", + "username" + ] + }, + "OrganizationMembershipList": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "next": { + "type": "string", + "nullable": true + }, + "previous": { + "type": "string", + "nullable": true + }, + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/OrganizationMembership" + } + } + }, + "required": [ + "count", + "next", + "previous", + "results" + ] + }, + "OrganizationMembershipStatusEnum": { + "enum": [ + "active", + "suspended" + ], + "type": "string", + "description": "* `active` - Active\n* `suspended` - Suspended" + }, "OrganizationReportBreakdown": { "type": "object", "properties": { @@ -45566,6 +46851,45 @@ "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" + ] + }, "OtpResetPasswordConfirm": { "type": "object", "properties": { @@ -47541,6 +48865,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": { @@ -49537,7 +50894,390 @@ "status": { "allOf": [ { - "$ref": "#/components/schemas/PublicEventStatusEnum" + "$ref": "#/components/schemas/StatusE90Enum" + } + ], + "readOnly": true + }, + "organizers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EventOrganizerSummary" + }, + "readOnly": true + } + }, + "required": [ + "availability", + "capacity", + "custom_form", + "description", + "ends_at", + "image_url", + "location", + "location_kind", + "organizers", + "public_token", + "starts_at", + "status", + "title" + ] + }, + "PublicEventRegistrationInput": { + "type": "object", + "properties": { + "custom_answers": { + "writeOnly": true, + "nullable": true + } + } + }, + "PublicEventRegistrationResponse": { + "type": "object", + "properties": { + "status": { + "allOf": [ + { + "$ref": "#/components/schemas/PublicEventRegistrationResponseStatusEnum" + } + ], + "readOnly": true + } + }, + "required": [ + "status" + ] + }, + "PublicEventRegistrationResponseStatusEnum": { + "enum": [ + "registered", + "waitlisted" + ], + "type": "string", + "description": "* `registered` - registered\n* `waitlisted` - waitlisted" + }, + "PublicImageAttachRequest": { + "type": "object", + "properties": { + "object_key": { + "type": "string", + "maxLength": 300 + } + }, + "required": [ + "object_key" + ] + }, + "PublicImageUploadRequest": { + "type": "object", + "properties": { + "content_type": { + "type": "string" + }, + "filename": { + "type": "string", + "maxLength": 255 + } + }, + "required": [ + "content_type", + "filename" + ] + }, + "PublicImageUploadResponse": { + "type": "object", + "properties": { + "object_key": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri" + }, + "fields": { + "type": "object", + "additionalProperties": {} + }, + "method": { + "type": "string" + }, + "headers": { + "type": "object", + "additionalProperties": {} + } + }, + "required": [ + "object_key", + "url" + ] + }, + "PublicMachine": { + "type": "object", + "description": "Allowlist-only public projection; never subclass the staff serializer.", + "properties": { + "name": { + "type": "string", + "readOnly": true + }, + "machine_type": { + "allOf": [ + { + "$ref": "#/components/schemas/PublicMachineType" + } + ], + "readOnly": true + }, + "image_url": { + "type": "string", + "format": "uri", + "nullable": true, + "readOnly": true + }, + "status": { + "type": "string", + "readOnly": true + }, + "usage_hours": { + "type": "string", + "format": "decimal", + "example": "12.50", + "readOnly": true + } + }, + "required": [ + "image_url", + "machine_type", + "name", + "status", + "usage_hours" + ] + }, + "PublicMachineServiceSubmit": { + "type": "object", + "properties": { + "website": { + "type": "string" + }, + "machine_id": { + "type": "integer", + "minimum": 1 + }, + "title": { + "type": "string", + "maxLength": 200 + }, + "description": { + "type": "string" + }, + "source_link": { + "oneOf": [ + { + "type": "string", + "format": "uri", + "maxLength": 200 + }, + { + "type": "string", + "maxLength": 0 + } + ] + }, + "capability_payload": {} + }, + "required": [ + "machine_id", + "title" + ] + }, + "PublicMachineServiceSubmitResponse": { + "type": "object", + "properties": { + "public_token": { + "type": "string", + "format": "uuid" + }, + "status": { + "type": "string" + } + }, + "required": [ + "public_token", + "status" + ] + }, + "PublicMachineType": { + "type": "object", + "properties": { + "name": { + "type": "string", + "readOnly": true + }, + "icon": { + "type": "string", + "readOnly": true + } + }, + "required": [ + "icon", + "name" + ] + }, + "PublicMakerspace": { + "type": "object", + "properties": { + "name": { + "type": "string", + "readOnly": true + }, + "public_code": { + "type": "string", + "readOnly": true + }, + "slug": { + "type": "string", + "readOnly": true, + "pattern": "^[-a-zA-Z0-9_]+$" + }, + "location": { + "type": "string", + "readOnly": true + }, + "map_url": { + "type": "string", + "readOnly": true + }, + "logo_url": { + "type": "string", + "format": "uri", + "nullable": true, + "readOnly": true + }, + "cover_image_url": { + "type": "string", + "format": "uri", + "nullable": true, + "readOnly": true + } + }, + "required": [ + "cover_image_url", + "location", + "logo_url", + "map_url", + "name", + "public_code", + "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 + }, + "image_url": { + "type": "string", + "format": "uri", + "nullable": true, + "readOnly": true + }, + "status": { + "allOf": [ + { + "$ref": "#/components/schemas/StatusE90Enum" } ], "readOnly": true @@ -49548,6 +51288,14 @@ "$ref": "#/components/schemas/EventOrganizerSummary" }, "readOnly": true + }, + "host": { + "allOf": [ + { + "$ref": "#/components/schemas/OrganizationEventHost" + } + ], + "readOnly": true } }, "required": [ @@ -49556,6 +51304,7 @@ "custom_form", "description", "ends_at", + "host", "image_url", "location", "location_kind", @@ -49566,257 +51315,32 @@ "title" ] }, - "PublicEventRegistrationInput": { - "type": "object", - "properties": { - "custom_answers": { - "writeOnly": true, - "nullable": true - } - } - }, - "PublicEventRegistrationResponse": { - "type": "object", - "properties": { - "status": { - "allOf": [ - { - "$ref": "#/components/schemas/PublicEventRegistrationResponseStatusEnum" - } - ], - "readOnly": true - } - }, - "required": [ - "status" - ] - }, - "PublicEventRegistrationResponseStatusEnum": { - "enum": [ - "registered", - "waitlisted" - ], - "type": "string", - "description": "* `registered` - registered\n* `waitlisted` - waitlisted" - }, - "PublicEventStatusEnum": { - "enum": [ - "published" - ], - "type": "string", - "description": "* `published` - published" - }, - "PublicImageAttachRequest": { - "type": "object", - "properties": { - "object_key": { - "type": "string", - "maxLength": 300 - } - }, - "required": [ - "object_key" - ] - }, - "PublicImageUploadRequest": { - "type": "object", - "properties": { - "content_type": { - "type": "string" - }, - "filename": { - "type": "string", - "maxLength": 255 - } - }, - "required": [ - "content_type", - "filename" - ] - }, - "PublicImageUploadResponse": { - "type": "object", - "properties": { - "object_key": { - "type": "string" - }, - "url": { - "type": "string", - "format": "uri" - }, - "fields": { - "type": "object", - "additionalProperties": {} - }, - "method": { - "type": "string" - }, - "headers": { - "type": "object", - "additionalProperties": {} - } - }, - "required": [ - "object_key", - "url" - ] - }, - "PublicMachine": { - "type": "object", - "description": "Allowlist-only public projection; never subclass the staff serializer.", - "properties": { - "name": { - "type": "string", - "readOnly": true - }, - "machine_type": { - "allOf": [ - { - "$ref": "#/components/schemas/PublicMachineType" - } - ], - "readOnly": true - }, - "image_url": { - "type": "string", - "format": "uri", - "nullable": true, - "readOnly": true - }, - "status": { - "type": "string", - "readOnly": true - }, - "usage_hours": { - "type": "string", - "format": "decimal", - "example": "12.50", - "readOnly": true - } - }, - "required": [ - "image_url", - "machine_type", - "name", - "status", - "usage_hours" - ] - }, - "PublicMachineServiceSubmit": { - "type": "object", - "properties": { - "website": { - "type": "string" - }, - "machine_id": { - "type": "integer", - "minimum": 1 - }, - "title": { - "type": "string", - "maxLength": 200 - }, - "description": { - "type": "string" - }, - "source_link": { - "oneOf": [ - { - "type": "string", - "format": "uri", - "maxLength": 200 - }, - { - "type": "string", - "maxLength": 0 - } - ] - }, - "capability_payload": {} - }, - "required": [ - "machine_id", - "title" - ] - }, - "PublicMachineServiceSubmitResponse": { - "type": "object", - "properties": { - "public_token": { - "type": "string", - "format": "uuid" - }, - "status": { - "type": "string" - } - }, - "required": [ - "public_token", - "status" - ] - }, - "PublicMachineType": { + "PublicOrganizationEventList": { "type": "object", "properties": { - "name": { - "type": "string", - "readOnly": true - }, - "icon": { - "type": "string", - "readOnly": true - } - }, - "required": [ - "icon", - "name" - ] - }, - "PublicMakerspace": { - "type": "object", - "properties": { - "name": { - "type": "string", - "readOnly": true - }, - "public_code": { - "type": "string", - "readOnly": true - }, - "slug": { - "type": "string", - "readOnly": true, - "pattern": "^[-a-zA-Z0-9_]+$" - }, - "location": { - "type": "string", - "readOnly": true + "count": { + "type": "integer" }, - "map_url": { + "next": { "type": "string", - "readOnly": true + "nullable": true }, - "logo_url": { + "previous": { "type": "string", - "format": "uri", - "nullable": true, - "readOnly": true + "nullable": true }, - "cover_image_url": { - "type": "string", - "format": "uri", - "nullable": true, - "readOnly": true + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PublicOrganizationEvent" + } } }, "required": [ - "cover_image_url", - "location", - "logo_url", - "map_url", - "name", - "public_code", - "slug" + "count", + "next", + "previous", + "results" ] }, "PublicPrintStatusLookupPolicyEnum": { @@ -53328,15 +54852,15 @@ "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" }, "Status37fEnum": { "enum": [ @@ -53395,6 +54919,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", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7d74dbb0..d44d3f4a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,20 +1,15 @@ -import { useDeferredValue, useMemo, useState } from "react"; -import { Link, Navigate, Route, Routes, useLocation } from "react-router-dom"; +import { 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 { SpaceWorksBadge } from "./components/SpaceWorksLogo"; import { AboutPage } from "./features/AboutPage"; +import { LandingPage } from "./features/LandingPage"; 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 { PublicOrganizationPage } from "./features/organizations/PublicOrganizationPage"; +import { OrganizationInvitationRedeemPage } from "./features/organizations/OrganizationInvitationRedeemPage"; import { PublicPrintRequestPage } from "./features/printing/PublicPrintRequestPage"; import { ArchivedPayments } from "./features/members/ArchivedPayments"; import { MemberArea } from "./features/members/MemberArea"; @@ -23,244 +18,6 @@ import { ResetPasswordPage } from "./features/staff/ResetPasswordPage"; import { StaffApp } from "./features/staff/StaffApp"; import { PublicStatsPage } from "./features/stats/PublicStatsPage"; 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 (
@@ -283,6 +40,9 @@ export default function App() { // 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 (location.pathname.startsWith("/organization-invitations/redeem/")) { + return } />; + } if (tenant.mode === "single" && tenant.loading) { return ( @@ -334,6 +94,7 @@ export default function App() { } /> } /> + } /> } /> } /> } /> 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/organizations/OrganizationInvitationRedeemPage.tsx b/frontend/src/features/organizations/OrganizationInvitationRedeemPage.tsx new file mode 100644 index 00000000..e85fc9f4 --- /dev/null +++ b/frontend/src/features/organizations/OrganizationInvitationRedeemPage.tsx @@ -0,0 +1,92 @@ +import { useEffect, useState, type FormEvent } from "react"; +import { useMutation } from "@tanstack/react-query"; +import { Link, useParams } from "react-router-dom"; + +import { SpaceWorksBadge } from "../../components/SpaceWorksLogo"; +import { EmptyState, Skeleton } from "../../components/ui"; +import { + fetchMe, + refreshAccessToken, + setAccessToken, + staffRequest, + type PasswordLoginResponse, + type StaffAuthUser, +} from "../../lib/api"; + +type RedeemResponse = { + membership: { id: number; organization_id?: number }; + user: StaffAuthUser; +}; + +export function OrganizationInvitationRedeemPage() { + const { token = "" } = useParams(); + const [user, setUser] = useState(null); + const [restoring, setRestoring] = useState(true); + const [credentials, setCredentials] = useState({ username: "", password: "" }); + const redeem = useMutation({ + mutationFn: () => staffRequest("/auth/organization-invitations/redeem/", { + method: "POST", + body: JSON.stringify({ token }), + }), + }); + const login = useMutation({ + mutationFn: () => staffRequest>("/auth/login", { + method: "POST", + credentials: "include", + body: JSON.stringify({ ...credentials, surface: "member" }), + }), + onSuccess: (response) => { + setAccessToken(response.access); + setUser(response.user); + }, + }); + + useEffect(() => { + let active = true; + refreshAccessToken() + .then((refreshed) => refreshed ? fetchMe() : null) + .then((current) => { if (active && current) setUser(current); }) + .finally(() => { if (active) setRestoring(false); }); + return () => { active = false; }; + }, []); + + useEffect(() => { + if (user && token && !redeem.isPending && !redeem.isSuccess && !redeem.isError) { + redeem.mutate(); + } + }, [user, token, redeem]); + + function submit(event: FormEvent) { + event.preventDefault(); + login.mutate(); + } + + return ( +
+
+ +

Organization invitation

+

Join an organization

+ {restoring ? : null} + {!restoring && !user ? ( +
+

Sign in to bind this single-use invitation to your account.

+ + + + {login.error ?

{login.error.message}

: null} +
+ ) : null} + {user && redeem.isPending ?

Redeeming invitation...

: null} + {redeem.isSuccess ? ( +
+

Invitation redeemed

+

Your organization access is active. Makerspace actions remain scoped to the organization's linked makerspaces.

+ Open organizations +
+ ) : null} + {redeem.isError ? Return to directory} /> : null} +
+
+ ); +} diff --git a/frontend/src/features/organizations/PublicOrganizationPage.tsx b/frontend/src/features/organizations/PublicOrganizationPage.tsx new file mode 100644 index 00000000..bc96a564 --- /dev/null +++ b/frontend/src/features/organizations/PublicOrganizationPage.tsx @@ -0,0 +1,117 @@ +import { useState } from "react"; +import { Link, useParams } from "react-router-dom"; + +import { MakerspaceBrand } from "../../components/MakerspaceBrand"; +import { SkipLink } from "../../components/SkipLink"; +import { SpaceWorksBadge } from "../../components/SpaceWorksLogo"; +import { SiteFooter } from "../../components/SiteFooter"; +import { ThemeToggle } from "../../components/ThemeToggle"; +import { Card, EmptyState, Skeleton } from "../../components/ui"; +import { StructuredApiError } from "../../lib/api"; +import { + usePublicOrganization, + usePublicOrganizationEvents, + type OrganizationPublicEvent, +} from "./publicOrganizationsApi"; + +function dateRange(event: OrganizationPublicEvent) { + const options: Intl.DateTimeFormatOptions = { dateStyle: "medium", timeStyle: "short" }; + return `${new Date(event.starts_at).toLocaleString(undefined, options)} – ${new Date(event.ends_at).toLocaleString(undefined, options)}`; +} + +export function PublicOrganizationPage() { + const { organizationSlug = "" } = useParams(); + const [page, setPage] = useState(1); + const organization = usePublicOrganization(organizationSlug); + const events = usePublicOrganizationEvents(organizationSlug, page); + const missing = organization.error instanceof StructuredApiError && organization.error.status === 404; + + return ( +
+ +
+
+ {organization.data ? ( + + ) : } +
+ Directory + +
+
+
+ +
+ {organization.isLoading ? : null} + {organization.error ? ( + + ) : null} + {organization.data ? ( + <> +
+

Organization directory

+

{organization.data.name}

+ {organization.data.description ?

{organization.data.description}

: null} + {organization.data.website ? ( + + Visit website + + ) : null} +
+ +
+
+

Across makerspaces

+

Upcoming events

+
+ {events.isLoading ?
{[0, 1, 2].map((item) => )}
: null} + {events.error ? ( + +

Events are unavailable

+

{events.error.message}

+ +
+ ) : null} + {events.data && !events.data.results.length ? ( + + ) : null} + {events.data?.results.length ? ( +
+ {events.data.results.map((event) => ( +
+ {event.image_url ? : null} +
+

Hosted by {event.host.name}

+

{event.title}

+

+ {event.location ?

{event.location}

: null} + {event.description ?

{event.description}

: null} +
+ {event.availability} + + Register at {event.host.name} + +
+
+
+ ))} +
+ ) : null} + {events.data ? ( +
+ + Page {page} · {events.data.count} events + +
+ ) : null} +
+ + ) : null} +
+ +
+ ); +} diff --git a/frontend/src/features/organizations/publicOrganizationsApi.ts b/frontend/src/features/organizations/publicOrganizationsApi.ts new file mode 100644 index 00000000..47f865b1 --- /dev/null +++ b/frontend/src/features/organizations/publicOrganizationsApi.ts @@ -0,0 +1,59 @@ +import { useQuery } from "@tanstack/react-query"; + +import { publicV1Request } from "../../lib/api"; + +export type PublicOrganization = { + slug: string; + name: string; + description: string; + website: string; + logo_url: string | null; + catalogue_links: { events: string }; +}; + +export type OrganizationPublicEvent = { + public_token: string; + title: string; + description: string; + starts_at: string; + ends_at: string; + location: string; + capacity: number; + availability: "Available" | "Limited" | "Full"; + image_url: string | null; + status: "published"; + host: { slug: string; name: string; logo_url: string | null }; +}; + +export type Page = { + count: number; + next: string | null; + previous: string | null; + results: T[]; +}; + +export const publicOrganizationKeys = { + root: (slug: string) => ["public-organization", slug] as const, + events: (slug: string, page: number) => + ["public-organization", slug, "events", page] as const, +}; + +export function usePublicOrganization(slug: string) { + return useQuery({ + queryKey: publicOrganizationKeys.root(slug), + queryFn: () => publicV1Request( + `/public/organizations/${encodeURIComponent(slug)}/`, + ), + enabled: Boolean(slug), + }); +} + +export function usePublicOrganizationEvents(slug: string, page: number) { + return useQuery({ + queryKey: publicOrganizationKeys.events(slug, page), + queryFn: () => publicV1Request>( + `/public/organizations/${encodeURIComponent(slug)}/events/?page=${page}`, + ), + enabled: Boolean(slug), + }); +} diff --git a/frontend/src/features/staff/EventOrganizers.tsx b/frontend/src/features/staff/EventOrganizers.tsx new file mode 100644 index 00000000..721bf5f2 --- /dev/null +++ b/frontend/src/features/staff/EventOrganizers.tsx @@ -0,0 +1,49 @@ +import { useEffect, useMemo, useState } from "react"; + +import type { StaffEvent } from "./eventsApi"; +import { useReplaceEventOrganizers } from "./eventsApi"; +import { useOrganizations } from "./organizationsApi"; + +export function EventOrganizers({ event, makerspaceId, disabled }: { + event: StaffEvent; + makerspaceId: number; + disabled: boolean; +}) { + const organizations = useOrganizations(); + const [selected, setSelected] = useState(event.organizers.map((item) => item.id)); + const replace = useReplaceEventOrganizers(makerspaceId, event.id); + useEffect(() => setSelected(event.organizers.map((item) => item.id)), [event.organizers]); + const assignable = organizations.data?.results ?? []; + const assignableIds = useMemo(() => new Set(assignable.map((item) => item.id)), [assignable]); + const retained = event.organizers.filter((item) => !assignableIds.has(item.id)); + + function toggle(id: number) { + setSelected((values) => values.includes(id) ? values.filter((item) => item !== id) : [...values, id]); + } + + return ( +
+

Organization organizers

+

Attribution and event-specific authority only. The host makerspace remains the owner.

+ {organizations.isLoading ?

Loading organizations...

: null} + {organizations.error ?

{organizations.error.message}

: null} +
+ {assignable.map((organization) => ( + + ))} + {retained.map((organization) => ( + + ))} +
+ {!assignable.length && !retained.length && !organizations.isLoading ?

No active organization memberships are available to assign.

: null} + {!disabled ? : null} + {replace.error ?

{replace.error.message}

: null} +
+ ); +} diff --git a/frontend/src/features/staff/EventsPanel.tsx b/frontend/src/features/staff/EventsPanel.tsx index d24fa976..a12d82c3 100644 --- a/frontend/src/features/staff/EventsPanel.tsx +++ b/frontend/src/features/staff/EventsPanel.tsx @@ -11,6 +11,7 @@ import { CollaborationInbox } from "./CollaborationInbox"; import EventCheckInScanner from "./EventCheckInScanner"; import { EventCollaborators } from "./EventCollaborators"; import { EventRegisterMember } from "./EventRegisterMember"; +import { EventOrganizers } from "./EventOrganizers"; import { ImageUploader } from "./ImageUploader"; import { Panel } from "./panels/shared"; import { PaymentReconcileActions } from "./PaymentReconcileActions"; @@ -178,6 +179,7 @@ export function EventDrawer({ eventId, makerspaceId, onClose }: { eventId: numbe {!readOnly ? :

Terminal events are read-only.

} +
{event.status === "draft" ? : null} {event.status === "published" ? <> : null} diff --git a/frontend/src/features/staff/OrganizationInvitations.tsx b/frontend/src/features/staff/OrganizationInvitations.tsx new file mode 100644 index 00000000..1940dd28 --- /dev/null +++ b/frontend/src/features/staff/OrganizationInvitations.tsx @@ -0,0 +1,64 @@ +import { useState, type FormEvent } from "react"; + +import { EmptyState, Skeleton, StatusBadge } from "../../components/ui"; +import type { OrganizationDetail } from "./organizationsApi"; +import { + useCreateOrganizationInvitation, + useOrganizationInvitations, + useRevokeOrganizationInvitation, +} from "./organizationsApi"; + +function toggle(values: string[], value: string) { + return values.includes(value) ? values.filter((item) => item !== value) : [...values, value]; +} + +export function OrganizationInvitations({ organization }: { organization: OrganizationDetail }) { + const [page, setPage] = useState(1); + const [governanceActions, setGovernanceActions] = useState([]); + const [grantedActions, setGrantedActions] = useState([]); + const [expiresInDays, setExpiresInDays] = useState(7); + const [rawToken, setRawToken] = useState(null); + const allowed = organization.governance_actions.includes("manage_organization_members"); + const invitations = useOrganizationInvitations(organization.id, page, allowed); + const create = useCreateOrganizationInvitation(organization.id); + const revoke = useRevokeOrganizationInvitation(organization.id); + if (!allowed) return null; + + function submit(event: FormEvent) { + event.preventDefault(); + create.mutate( + { governance_actions: governanceActions, granted_actions: grantedActions, expires_in_days: expiresInDays }, + { onSuccess: (invitation) => setRawToken(invitation.token) }, + ); + } + + const redeemUrl = rawToken ? `${window.location.origin}/organization-invitations/redeem/${rawToken}` : ""; + return ( +
+

Invitations

+

Each bearer link works once. Its token is shown only after creation.

+
+
Organization governance
+ {organization.governance_actions.map((action) => )} +
+
Makerspace actions
+ {organization.granted_actions.map((action) => )} +
+ + + {create.error ?

{create.error.message}

: null} +
+ {redeemUrl ?

Copy this link now

{redeemUrl}

: null} + + {invitations.isLoading ? : null} + {invitations.data && !invitations.data.results.length ? : null} + {invitations.data?.results.length ?
    {invitations.data.results.map((invitation) => ( +
  • +

    Expires {new Date(invitation.expires_at).toLocaleString()}

    + {invitation.state === "active" ? : null} +
  • + ))}
: null} + {invitations.data ?
Page {page}
: null} +
+ ); +} diff --git a/frontend/src/features/staff/OrganizationMemberships.tsx b/frontend/src/features/staff/OrganizationMemberships.tsx new file mode 100644 index 00000000..c009256f --- /dev/null +++ b/frontend/src/features/staff/OrganizationMemberships.tsx @@ -0,0 +1,44 @@ +import { useState } from "react"; + +import { EmptyState, Skeleton, StatusBadge } from "../../components/ui"; +import type { OrganizationDetail } from "./organizationsApi"; +import { useOrganizationMemberships } from "./organizationsApi"; + +export function OrganizationMemberships({ organization }: { organization: OrganizationDetail }) { + const [page, setPage] = useState(1); + const enabled = organization.governance_actions.includes("manage_organization_members"); + const memberships = useOrganizationMemberships(organization.id, page, enabled); + if (!enabled) return null; + + return ( +
+

Memberships

+ {memberships.isLoading ? : null} + {memberships.error ?

{memberships.error.message}

: null} + {memberships.data && !memberships.data.results.length ? : null} + {memberships.data?.results.length ? ( +
+ + + + {memberships.data.results.map((member) => ( + + + + + + + ))} +
Organization memberships
MemberStatusGovernanceMakerspace actions
{member.display_name || member.username}{member.email}{member.governance_actions.join(", ") || "—"}{member.granted_actions.join(", ") || "—"}
+
+ ) : null} + {memberships.data ? ( +
+ + Page {page} · {memberships.data.count} members + +
+ ) : null} +
+ ); +} diff --git a/frontend/src/features/staff/OrganizationProfileForm.tsx b/frontend/src/features/staff/OrganizationProfileForm.tsx new file mode 100644 index 00000000..50d54a8f --- /dev/null +++ b/frontend/src/features/staff/OrganizationProfileForm.tsx @@ -0,0 +1,60 @@ +import { useEffect, useState, type FormEvent } from "react"; + +import type { OrganizationDetail } from "./organizationsApi"; +import { useUpdateOrganization } from "./organizationsApi"; + +export function OrganizationProfileForm({ organization }: { organization: OrganizationDetail }) { + const [values, setValues] = useState({ + name: organization.name, + slug: organization.slug, + description: organization.description, + website: organization.website, + public_profile_enabled: organization.public_profile_enabled, + }); + const update = useUpdateOrganization(organization.id, organization.slug); + const allowed = organization.governance_actions.includes("manage_organization_profile"); + + useEffect(() => { + setValues({ + name: organization.name, + slug: organization.slug, + description: organization.description, + website: organization.website, + public_profile_enabled: organization.public_profile_enabled, + }); + }, [organization]); + + function submit(event: FormEvent) { + event.preventDefault(); + update.mutate(values); + } + + return ( +
+
+

Public profile

+

Controls the organization directory page. Logo upload remains platform-managed.

+
+
+ + + +