From cf2a01edc627cca6c46f24f766dbbb26c1928089 Mon Sep 17 00:00:00 2001 From: yazzylazy Date: Sun, 12 Jul 2026 18:36:23 -0400 Subject: [PATCH 1/3] 6009: add organizations to invitation querysets and sync Extends the channel-invitation flow to support organization invitations, per #6009 (following #6008's Invitation.organization field): - Add Organization.filter_edit_queryset/filter_view_queryset based on active OrganizationRole membership, and extend Invitation's equivalents to grant org admins edit access and org members view access. - Allow InvitationSerializer to accept an organization in place of (or alongside) a channel, requiring at least one of the two. - Teach the /sync endpoint's permission gate to recognize organization- scoped changes, since org admins acting on another user's invitation don't fit the existing channel-scoped or self-scoped checks. --- contentcuration/contentcuration/models.py | 50 ++++- .../contentcuration/tests/test_models.py | 170 +++++++++++++++ .../contentcuration/tests/testdata.py | 16 ++ .../tests/viewsets/test_invitation.py | 195 ++++++++++++++++++ .../contentcuration/viewsets/invitation.py | 31 ++- .../contentcuration/viewsets/sync/endpoint.py | 33 +++ 6 files changed, 493 insertions(+), 2 deletions(-) diff --git a/contentcuration/contentcuration/models.py b/contentcuration/contentcuration/models.py index 74e3287904..7de43e3dc7 100644 --- a/contentcuration/contentcuration/models.py +++ b/contentcuration/contentcuration/models.py @@ -1894,6 +1894,38 @@ class Meta: def __str__(self): return self.name + @classmethod + def filter_edit_queryset(cls, queryset, user): + if user.is_anonymous: + return queryset.none() + + if user.is_admin: + return queryset + + return queryset.filter( + user_roles__user=user, + user_roles__role=ORGANIZATION_ADMIN, + user_roles__status=ORGANIZATION_ROLE_STATUS_ACTIVE, + ).distinct() + + @classmethod + def filter_view_queryset(cls, queryset, user): + if user.is_anonymous: + return queryset.none() + + if user.is_admin: + return queryset + + return queryset.filter( + user_roles__user=user, + user_roles__role__in=[ + ORGANIZATION_ADMIN, + ORGANIZATION_EDITOR, + ORGANIZATION_VIEWER, + ], + user_roles__status=ORGANIZATION_ROLE_STATUS_ACTIVE, + ).distinct() + class OrganizationRole(models.Model): """ @@ -3788,7 +3820,14 @@ def filter_edit_queryset(cls, queryset, user): return queryset return queryset.filter( - Q(email__iexact=user.email) | Q(sender=user) | Q(channel__editors=user) + Q(email__iexact=user.email) + | Q(sender=user) + | Q(channel__editors=user) + | Q( + organization__user_roles__user=user, + organization__user_roles__role=ORGANIZATION_ADMIN, + organization__user_roles__status=ORGANIZATION_ROLE_STATUS_ACTIVE, + ) ).distinct() @classmethod @@ -3803,6 +3842,15 @@ def filter_view_queryset(cls, queryset, user): | Q(sender=user) | Q(channel__editors=user) | Q(channel__viewers=user) + | Q( + organization__user_roles__user=user, + organization__user_roles__role__in=[ + ORGANIZATION_ADMIN, + ORGANIZATION_EDITOR, + ORGANIZATION_VIEWER, + ], + organization__user_roles__status=ORGANIZATION_ROLE_STATUS_ACTIVE, + ) ).distinct() diff --git a/contentcuration/contentcuration/tests/test_models.py b/contentcuration/contentcuration/tests/test_models.py index eaf4e0370b..bf891fc48a 100644 --- a/contentcuration/contentcuration/tests/test_models.py +++ b/contentcuration/contentcuration/tests/test_models.py @@ -16,6 +16,15 @@ from contentcuration.constants import channel_history from contentcuration.constants import community_library_submission from contentcuration.constants import user_history +from contentcuration.constants.organization_roles import ORGANIZATION_ADMIN +from contentcuration.constants.organization_roles import ORGANIZATION_EDITOR +from contentcuration.constants.organization_roles import ( + ORGANIZATION_ROLE_STATUS_ACTIVE, +) +from contentcuration.constants.organization_roles import ( + ORGANIZATION_ROLE_STATUS_PENDING, +) +from contentcuration.constants.organization_roles import ORGANIZATION_VIEWER from contentcuration.models import AssessmentItem from contentcuration.models import AuditedSpecialPermissionsLicense from contentcuration.models import Change @@ -34,6 +43,8 @@ from contentcuration.models import Language from contentcuration.models import License from contentcuration.models import object_storage_name +from contentcuration.models import Organization +from contentcuration.models import OrganizationRole from contentcuration.models import RecommendationsEvent from contentcuration.models import RecommendationsInteractionEvent from contentcuration.models import User @@ -309,6 +320,165 @@ def create_change(server_rev, applied): self.assertEqual(channel.get_server_rev(), 2) +class OrganizationTestCase(PermissionQuerysetTestCase): + @property + def base_queryset(self): + return Organization.objects.all() + + def test_filter_edit_queryset__admin_role(self): + organization = testdata.organization() + user = testdata.user() + + queryset = Organization.filter_edit_queryset(self.base_queryset, user=user) + self.assertQuerysetDoesNotContain(queryset, pk=organization.id) + + OrganizationRole.objects.create( + user=user, + organization=organization, + role=ORGANIZATION_ADMIN, + status=ORGANIZATION_ROLE_STATUS_ACTIVE, + ) + queryset = Organization.filter_edit_queryset(self.base_queryset, user=user) + self.assertQuerysetContains(queryset, pk=organization.id) + + def test_filter_edit_queryset__editor_role_cannot_edit(self): + organization = testdata.organization() + user = testdata.user() + OrganizationRole.objects.create( + user=user, + organization=organization, + role=ORGANIZATION_EDITOR, + status=ORGANIZATION_ROLE_STATUS_ACTIVE, + ) + + queryset = Organization.filter_edit_queryset(self.base_queryset, user=user) + self.assertQuerysetDoesNotContain(queryset, pk=organization.id) + + def test_filter_edit_queryset__pending_admin_cannot_edit(self): + organization = testdata.organization() + user = testdata.user() + OrganizationRole.objects.create( + user=user, + organization=organization, + role=ORGANIZATION_ADMIN, + status=ORGANIZATION_ROLE_STATUS_PENDING, + ) + + queryset = Organization.filter_edit_queryset(self.base_queryset, user=user) + self.assertQuerysetDoesNotContain(queryset, pk=organization.id) + + def test_filter_edit_queryset__anonymous(self): + organization = testdata.organization() + + queryset = Organization.filter_edit_queryset( + self.base_queryset, user=self.anonymous_user + ) + self.assertQuerysetDoesNotContain(queryset, pk=organization.id) + + def test_filter_view_queryset__viewer_role(self): + organization = testdata.organization() + user = testdata.user() + + queryset = Organization.filter_view_queryset(self.base_queryset, user=user) + self.assertQuerysetDoesNotContain(queryset, pk=organization.id) + + OrganizationRole.objects.create( + user=user, + organization=organization, + role=ORGANIZATION_VIEWER, + status=ORGANIZATION_ROLE_STATUS_ACTIVE, + ) + queryset = Organization.filter_view_queryset(self.base_queryset, user=user) + self.assertQuerysetContains(queryset, pk=organization.id) + + def test_filter_view_queryset__anonymous(self): + organization = testdata.organization() + + queryset = Organization.filter_view_queryset( + self.base_queryset, user=self.anonymous_user + ) + self.assertQuerysetDoesNotContain(queryset, pk=organization.id) + + +class InvitationOrganizationTestCase(PermissionQuerysetTestCase): + @property + def base_queryset(self): + return Invitation.objects.all() + + def _make_org_invitation(self): + organization = testdata.organization() + invitee = testdata.user(email="org-invitee@le.com") + invitation = Invitation.objects.create( + email=invitee.email, organization=organization + ) + return organization, invitation + + def test_filter_edit_queryset__organization_admin(self): + organization, invitation = self._make_org_invitation() + user = testdata.user() + + queryset = Invitation.filter_edit_queryset(self.base_queryset, user=user) + self.assertQuerysetDoesNotContain(queryset, pk=invitation.id) + + OrganizationRole.objects.create( + user=user, + organization=organization, + role=ORGANIZATION_ADMIN, + status=ORGANIZATION_ROLE_STATUS_ACTIVE, + ) + queryset = Invitation.filter_edit_queryset(self.base_queryset, user=user) + self.assertQuerysetContains(queryset, pk=invitation.id) + + def test_filter_edit_queryset__organization_editor_cannot_edit(self): + organization, invitation = self._make_org_invitation() + user = testdata.user() + OrganizationRole.objects.create( + user=user, + organization=organization, + role=ORGANIZATION_EDITOR, + status=ORGANIZATION_ROLE_STATUS_ACTIVE, + ) + + queryset = Invitation.filter_edit_queryset(self.base_queryset, user=user) + self.assertQuerysetDoesNotContain(queryset, pk=invitation.id) + + def test_filter_view_queryset__organization_editor(self): + organization, invitation = self._make_org_invitation() + user = testdata.user() + + queryset = Invitation.filter_view_queryset(self.base_queryset, user=user) + self.assertQuerysetDoesNotContain(queryset, pk=invitation.id) + + OrganizationRole.objects.create( + user=user, + organization=organization, + role=ORGANIZATION_EDITOR, + status=ORGANIZATION_ROLE_STATUS_ACTIVE, + ) + queryset = Invitation.filter_view_queryset(self.base_queryset, user=user) + self.assertQuerysetContains(queryset, pk=invitation.id) + + def test_filter_view_queryset__organization_viewer(self): + organization, invitation = self._make_org_invitation() + user = testdata.user() + OrganizationRole.objects.create( + user=user, + organization=organization, + role=ORGANIZATION_VIEWER, + status=ORGANIZATION_ROLE_STATUS_ACTIVE, + ) + + queryset = Invitation.filter_view_queryset(self.base_queryset, user=user) + self.assertQuerysetContains(queryset, pk=invitation.id) + + def test_filter_view_queryset__unrelated_user(self): + organization, invitation = self._make_org_invitation() + user = testdata.user() + + queryset = Invitation.filter_view_queryset(self.base_queryset, user=user) + self.assertQuerysetDoesNotContain(queryset, pk=invitation.id) + + class ContentNodeTestCase(PermissionQuerysetTestCase): @property def base_queryset(self): diff --git a/contentcuration/contentcuration/tests/testdata.py b/contentcuration/contentcuration/tests/testdata.py index 962d2e0a5b..52aa11de5a 100644 --- a/contentcuration/contentcuration/tests/testdata.py +++ b/contentcuration/contentcuration/tests/testdata.py @@ -19,6 +19,10 @@ from contentcuration.constants import ( community_library_submission as community_library_submission_constants, ) +from contentcuration.constants.organization_roles import ORGANIZATION_ADMIN +from contentcuration.constants.organization_roles import ( + ORGANIZATION_ROLE_STATUS_ACTIVE, +) from contentcuration.tests.utils import mixer @@ -253,6 +257,18 @@ def channel(name="testchannel"): return channel +def organization(name="Test Organization"): + return cc.Organization.objects.create(name=name) + + +def organization_role( + user, organization, role=ORGANIZATION_ADMIN, status=ORGANIZATION_ROLE_STATUS_ACTIVE +): + return cc.OrganizationRole.objects.create( + user=user, organization=organization, role=role, status=status + ) + + def random_string(chars=10): """ Generate a random string diff --git a/contentcuration/contentcuration/tests/viewsets/test_invitation.py b/contentcuration/contentcuration/tests/viewsets/test_invitation.py index e07d52cb59..98a82c3ede 100644 --- a/contentcuration/contentcuration/tests/viewsets/test_invitation.py +++ b/contentcuration/contentcuration/tests/viewsets/test_invitation.py @@ -3,6 +3,11 @@ from django.urls import reverse from contentcuration import models +from contentcuration.constants.organization_roles import ORGANIZATION_ADMIN +from contentcuration.constants.organization_roles import ORGANIZATION_EDITOR +from contentcuration.constants.organization_roles import ( + ORGANIZATION_ROLE_STATUS_ACTIVE, +) from contentcuration.tests import testdata from contentcuration.tests.base import StudioAPITestCase from contentcuration.tests.viewsets.base import generate_create_event @@ -346,6 +351,196 @@ def test_delete_invitations(self): pass +class OrganizationInvitationSyncTestCase(SyncTestMixin, StudioAPITestCase): + @property + def invitation_metadata(self): + return { + "id": uuid.uuid4().hex, + "organization": self.organization.id, + "email": self.invited_user.email, + } + + def setUp(self): + super(OrganizationInvitationSyncTestCase, self).setUp() + self.organization = testdata.organization() + self.org_admin = testdata.user("org-admin@inc.com") + testdata.organization_role(self.org_admin, self.organization) + self.invited_user = testdata.user("org-invitee@inc.com") + self.client.force_authenticate(user=self.org_admin) + + def test_create_organization_invitation(self): + invitation = self.invitation_metadata + response = self.sync_changes( + [ + generate_create_event( + invitation["id"], + INVITATION, + invitation, + organization_id=self.organization.id, + user_id=self.invited_user.id, + ) + ], + ) + self.assertEqual(response.status_code, 200, response.content) + try: + models.Invitation.objects.get(id=invitation["id"]) + except models.Invitation.DoesNotExist: + self.fail("Organization invitation was not created") + + def test_create_organization_invitation_by_non_admin_rejected(self): + editor = testdata.user("org-editor@inc.com") + testdata.organization_role(editor, self.organization, role=ORGANIZATION_EDITOR) + self.client.force_authenticate(user=editor) + + invitation = self.invitation_metadata + response = self.sync_changes( + [ + generate_create_event( + invitation["id"], + INVITATION, + invitation, + organization_id=self.organization.id, + user_id=self.invited_user.id, + ) + ], + ) + self.assertEqual(response.status_code, 200, response.content) + try: + models.Invitation.objects.get(id=invitation["id"]) + self.fail("Organization invitation was created by a non-admin") + except models.Invitation.DoesNotExist: + pass + + def test_create_invitation_requires_channel_or_organization(self): + self.client.force_authenticate(user=self.invited_user) + invitation = { + "id": uuid.uuid4().hex, + "email": self.invited_user.email, + } + response = self.sync_changes( + [ + generate_create_event( + invitation["id"], + INVITATION, + invitation, + user_id=self.invited_user.id, + ) + ], + ) + self.assertEqual(response.status_code, 200, response.content) + try: + models.Invitation.objects.get(id=invitation["id"]) + self.fail("Invitation without channel or organization was created") + except models.Invitation.DoesNotExist: + pass + + def test_accept_organization_invitation_creates_role(self): + invitation = models.Invitation.objects.create( + id=uuid.uuid4().hex, + organization=self.organization, + email=self.invited_user.email, + sender=self.org_admin, + ) + self.client.force_authenticate(user=self.invited_user) + response = self.sync_changes( + [ + generate_update_event( + invitation.id, + INVITATION, + {"accepted": True}, + user_id=self.invited_user.id, + ) + ], + ) + self.assertEqual(response.status_code, 200, response.content) + invitation.refresh_from_db() + self.assertTrue(invitation.accepted) + role = models.OrganizationRole.objects.get( + user=self.invited_user, organization=self.organization + ) + self.assertEqual(role.role, ORGANIZATION_EDITOR) + self.assertEqual(role.status, ORGANIZATION_ROLE_STATUS_ACTIVE) + + def test_revoke_organization_invitation_by_admin(self): + invitation = models.Invitation.objects.create( + id=uuid.uuid4().hex, + organization=self.organization, + email=self.invited_user.email, + sender=self.org_admin, + ) + response = self.sync_changes( + [ + generate_update_event( + invitation.id, + INVITATION, + {"revoked": True}, + organization_id=self.organization.id, + user_id=self.invited_user.id, + ) + ], + ) + self.assertEqual(response.status_code, 200, response.content) + invitation.refresh_from_db() + self.assertTrue(invitation.revoked) + + def test_revoke_organization_invitation_by_non_admin_rejected(self): + editor = testdata.user("org-editor2@inc.com") + testdata.organization_role(editor, self.organization, role=ORGANIZATION_EDITOR) + self.client.force_authenticate(user=editor) + + invitation = models.Invitation.objects.create( + id=uuid.uuid4().hex, + organization=self.organization, + email=self.invited_user.email, + sender=self.org_admin, + ) + response = self.sync_changes( + [ + generate_update_event( + invitation.id, + INVITATION, + {"revoked": True}, + organization_id=self.organization.id, + user_id=self.invited_user.id, + ) + ], + ) + self.assertEqual(response.status_code, 200, response.content) + invitation.refresh_from_db() + self.assertFalse(invitation.revoked) + + def test_channel_invitation_with_organization_admin_role(self): + channel = testdata.channel() + channel.editors.add(self.org_admin) + invitation = models.Invitation.objects.create( + id=uuid.uuid4().hex, + channel=channel, + organization=self.organization, + email=self.invited_user.email, + sender=self.org_admin, + share_mode="admin", + ) + self.client.force_authenticate(user=self.invited_user) + response = self.sync_changes( + [ + generate_update_event( + invitation.id, + INVITATION, + {"accepted": True}, + user_id=self.invited_user.id, + ) + ], + ) + self.assertEqual(response.status_code, 200, response.content) + invitation.refresh_from_db() + self.assertTrue(invitation.accepted) + self.assertTrue(channel.editors.filter(pk=self.invited_user.id).exists()) + role = models.OrganizationRole.objects.get( + user=self.invited_user, organization=self.organization + ) + self.assertEqual(role.role, ORGANIZATION_ADMIN) + + class CRUDTestCase(StudioAPITestCase): @property def invitation_metadata(self): diff --git a/contentcuration/contentcuration/viewsets/invitation.py b/contentcuration/contentcuration/viewsets/invitation.py index 75f40149cf..e26b2f2be9 100644 --- a/contentcuration/contentcuration/viewsets/invitation.py +++ b/contentcuration/contentcuration/viewsets/invitation.py @@ -10,6 +10,7 @@ from contentcuration.models import Change from contentcuration.models import Channel from contentcuration.models import Invitation +from contentcuration.models import Organization from contentcuration.viewsets.base import BulkListSerializer from contentcuration.viewsets.base import BulkModelSerializer from contentcuration.viewsets.base import ValuesViewset @@ -25,7 +26,12 @@ class InvitationSerializer(BulkModelSerializer): accepted = serializers.BooleanField(read_only=True) declined = serializers.BooleanField(read_only=True) - channel = UserFilteredPrimaryKeyRelatedField(queryset=Channel.objects.all()) + channel = UserFilteredPrimaryKeyRelatedField( + queryset=Channel.objects.all(), required=False + ) + organization = UserFilteredPrimaryKeyRelatedField( + queryset=Organization.objects.all(), required=False + ) class Meta: model = Invitation @@ -36,12 +42,24 @@ class Meta: "revoked", "email", "channel", + "organization", "share_mode", "first_name", "last_name", ) list_serializer_class = BulkListSerializer + def validate(self, data): + channel = data.get("channel", getattr(self.instance, "channel", None)) + organization = data.get( + "organization", getattr(self.instance, "organization", None) + ) + if not channel and not organization: + raise serializers.ValidationError( + "Invitation must specify either a channel or an organization." + ) + return data + def create(self, validated_data): # Need to remove default values for these non-model fields here if "request" in self.context: @@ -88,12 +106,14 @@ def get_fields(self): class InvitationFilter(FilterSet): invited = CharFilter(method="filter_invited") channel = CharFilter(method="filter_channel") + organization = CharFilter(method="filter_organization") class Meta: model = Invitation fields = ( "invited", "channel", + "organization", ) def filter_invited(self, queryset, name, value): @@ -102,6 +122,9 @@ def filter_invited(self, queryset, name, value): def filter_channel(self, queryset, name, value): return queryset.filter(channel_id=value) + def filter_organization(self, queryset, name, value): + return queryset.filter(organization_id=value) + def get_sender_name(item): return "{} {}".format(item.get("sender__first_name"), item.get("sender__last_name")) @@ -124,8 +147,10 @@ class InvitationViewSet(ValuesViewset): "sender__first_name", "sender__last_name", "channel_id", + "organization_id", "share_mode", "channel__name", + "organization__name", ) field_map = { "first_name": "invited__first_name", @@ -133,6 +158,8 @@ class InvitationViewSet(ValuesViewset): "sender_name": get_sender_name, "channel_name": "channel__name", "channel": "channel_id", + "organization_name": "organization__name", + "organization": "organization_id", } def perform_update(self, serializer): @@ -163,6 +190,7 @@ def accept(self, request, pk=None): INVITATION, {"accepted": True}, channel_id=invitation.channel_id, + user_id=request.user.id, ), applied=True, created_by_id=request.user.id, @@ -181,6 +209,7 @@ def decline(self, request, pk=None): INVITATION, {"declined": True}, channel_id=invitation.channel_id, + user_id=request.user.id, ), applied=True, created_by_id=request.user.id, diff --git a/contentcuration/contentcuration/viewsets/sync/endpoint.py b/contentcuration/contentcuration/viewsets/sync/endpoint.py index 33ff296623..dbf4b4be6e 100644 --- a/contentcuration/contentcuration/viewsets/sync/endpoint.py +++ b/contentcuration/contentcuration/viewsets/sync/endpoint.py @@ -16,6 +16,8 @@ from contentcuration.models import Change from contentcuration.models import Channel from contentcuration.models import CustomTaskMetadata +from contentcuration.models import Organization +from contentcuration.models import User from contentcuration.tasks import apply_channel_changes_task from contentcuration.tasks import apply_user_changes_task from contentcuration.viewsets.sync.constants import CHANNEL @@ -65,6 +67,17 @@ def handle_changes(self, request): .values_list("id", flat=True) .distinct() ).union(created_channel_ids) + change_organization_ids = set( + x.get("organization_id") for x in changes if x.get("organization_id") + ) + allowed_org_ids = set( + Organization.filter_edit_queryset( + Organization.objects.filter(id__in=change_organization_ids), + request.user, + ) + .values_list("id", flat=True) + .distinct() + ) # Allow changes that are either: # Not related to a channel and instead related to the user if the user is the current user. user_only_changes = [] @@ -81,6 +94,17 @@ def handle_changes(self, request): user_only_changes.append(c) elif c.get("channel_id") in allowed_ids: channel_changes.append(c) + elif ( + c.get("channel_id") is None + and c.get("organization_id") in allowed_org_ids + ): + # Organization-scoped changes (e.g. organization invitations) have + # no channel, and are frequently made by an org admin on behalf of + # another user, so they can't rely on the user-self check above. + # They're routed through the same per-user change queue instead of + # a dedicated per-organization one, since they otherwise have the + # same "no channel" shape as user-only changes. + user_only_changes.append(c) else: disallowed_changes.append(c) change_models = Change.create_changes( @@ -92,6 +116,15 @@ def handle_changes(self, request): apply_user_changes_task.fetch_or_enqueue( request.user, user_id=request.user.id ) + other_target_user_ids = set( + c.get("user_id") + for c in user_only_changes + if c.get("user_id") and c.get("user_id") != request.user.id + ) + for target_user in User.objects.filter(id__in=other_target_user_ids): + apply_user_changes_task.fetch_or_enqueue( + target_user, user_id=target_user.id + ) for channel_id in allowed_ids: apply_channel_changes_task.fetch_or_enqueue( request.user, channel_id=channel_id From 2045c38946ea28037f15c01fcb35086c60960d64 Mon Sep 17 00:00:00 2001 From: yazzylazy Date: Mon, 13 Jul 2026 12:05:06 -0400 Subject: [PATCH 2/3] fix: add organization_id support to sync event builders, fix test fixtures - generate_create_event/generate_update_event/generate_delete_event (viewsets/sync/utils.py) never gained an organization_id parameter when handle_changes() was taught to read it, causing a TypeError in every organization invitation test that passed organization_id. - Two OrganizationInvitationSyncTestCase fixtures were missing invited=self.invited_user, which InvitationSerializer.get_fields() requires to unlock the accepted field for sync-based updates, matching the existing SyncTestCase fixture pattern. --- .../tests/viewsets/test_invitation.py | 2 ++ .../contentcuration/viewsets/sync/utils.py | 28 ++++++++++++++----- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/contentcuration/contentcuration/tests/viewsets/test_invitation.py b/contentcuration/contentcuration/tests/viewsets/test_invitation.py index 98a82c3ede..5d27d4b354 100644 --- a/contentcuration/contentcuration/tests/viewsets/test_invitation.py +++ b/contentcuration/contentcuration/tests/viewsets/test_invitation.py @@ -439,6 +439,7 @@ def test_accept_organization_invitation_creates_role(self): id=uuid.uuid4().hex, organization=self.organization, email=self.invited_user.email, + invited=self.invited_user, sender=self.org_admin, ) self.client.force_authenticate(user=self.invited_user) @@ -517,6 +518,7 @@ def test_channel_invitation_with_organization_admin_role(self): channel=channel, organization=self.organization, email=self.invited_user.email, + invited=self.invited_user, sender=self.org_admin, share_mode="admin", ) diff --git a/contentcuration/contentcuration/viewsets/sync/utils.py b/contentcuration/contentcuration/viewsets/sync/utils.py index 8f5ce98a05..18e90863e2 100644 --- a/contentcuration/contentcuration/viewsets/sync/utils.py +++ b/contentcuration/contentcuration/viewsets/sync/utils.py @@ -23,7 +23,7 @@ def validate_table(table): raise ValueError("{} is not a valid table name".format(table)) -def _generate_event(key, table, event_type, channel_id, user_id): +def _generate_event(key, table, event_type, channel_id, user_id, organization_id=None): validate_table(table) event = { "key": key, @@ -34,23 +34,37 @@ def _generate_event(key, table, event_type, channel_id, user_id): event["channel_id"] = channel_id if user_id: event["user_id"] = user_id + if organization_id: + event["organization_id"] = organization_id return event -def generate_create_event(key, table, obj, channel_id=None, user_id=None): - event = _generate_event(key, table, CREATED, channel_id, user_id) +def generate_create_event( + key, table, obj, channel_id=None, user_id=None, organization_id=None +): + event = _generate_event( + key, table, CREATED, channel_id, user_id, organization_id=organization_id + ) event["obj"] = obj return event -def generate_update_event(key, table, mods, channel_id=None, user_id=None): - event = _generate_event(key, table, UPDATED, channel_id, user_id) +def generate_update_event( + key, table, mods, channel_id=None, user_id=None, organization_id=None +): + event = _generate_event( + key, table, UPDATED, channel_id, user_id, organization_id=organization_id + ) event["mods"] = mods return event -def generate_delete_event(key, table, channel_id=None, user_id=None): - return _generate_event(key, table, DELETED, channel_id, user_id) +def generate_delete_event( + key, table, channel_id=None, user_id=None, organization_id=None +): + return _generate_event( + key, table, DELETED, channel_id, user_id, organization_id=organization_id + ) def generate_move_event(key, table, target, position, channel_id=None, user_id=None): From 475ba8e058028febbceeb29282b28432517a929c Mon Sep 17 00:00:00 2001 From: yazzylazy Date: Mon, 13 Jul 2026 16:08:26 -0400 Subject: [PATCH 3/3] 6009: enforce channel/organization mutual exclusivity on Invitation Per the "Organizations XOR channels" requirement from #5971 and rtibbles' comment on #6009 confirming this was deliberately deferred to the API layer: InvitationSerializer.validate() now rejects an invitation that has both channel and organization set, not just one that has neither. This invalidates the "co-owner" scenario (one invitation with both fields set) the earlier test exercised, so that test is replaced with one asserting the combination is rejected. --- .../tests/viewsets/test_invitation.py | 39 ++++++++----------- .../contentcuration/viewsets/invitation.py | 4 ++ 2 files changed, 21 insertions(+), 22 deletions(-) diff --git a/contentcuration/contentcuration/tests/viewsets/test_invitation.py b/contentcuration/contentcuration/tests/viewsets/test_invitation.py index 5d27d4b354..b8bac2934d 100644 --- a/contentcuration/contentcuration/tests/viewsets/test_invitation.py +++ b/contentcuration/contentcuration/tests/viewsets/test_invitation.py @@ -3,7 +3,6 @@ from django.urls import reverse from contentcuration import models -from contentcuration.constants.organization_roles import ORGANIZATION_ADMIN from contentcuration.constants.organization_roles import ORGANIZATION_EDITOR from contentcuration.constants.organization_roles import ( ORGANIZATION_ROLE_STATUS_ACTIVE, @@ -510,37 +509,33 @@ def test_revoke_organization_invitation_by_non_admin_rejected(self): invitation.refresh_from_db() self.assertFalse(invitation.revoked) - def test_channel_invitation_with_organization_admin_role(self): + def test_invitation_with_channel_and_organization_is_rejected(self): channel = testdata.channel() channel.editors.add(self.org_admin) - invitation = models.Invitation.objects.create( - id=uuid.uuid4().hex, - channel=channel, - organization=self.organization, - email=self.invited_user.email, - invited=self.invited_user, - sender=self.org_admin, - share_mode="admin", - ) - self.client.force_authenticate(user=self.invited_user) + invitation = { + "id": uuid.uuid4().hex, + "channel": channel.id, + "organization": self.organization.id, + "email": self.invited_user.email, + } response = self.sync_changes( [ - generate_update_event( - invitation.id, + generate_create_event( + invitation["id"], INVITATION, - {"accepted": True}, + invitation, + channel_id=channel.id, + organization_id=self.organization.id, user_id=self.invited_user.id, ) ], ) self.assertEqual(response.status_code, 200, response.content) - invitation.refresh_from_db() - self.assertTrue(invitation.accepted) - self.assertTrue(channel.editors.filter(pk=self.invited_user.id).exists()) - role = models.OrganizationRole.objects.get( - user=self.invited_user, organization=self.organization - ) - self.assertEqual(role.role, ORGANIZATION_ADMIN) + try: + models.Invitation.objects.get(id=invitation["id"]) + self.fail("Invitation with both channel and organization was created") + except models.Invitation.DoesNotExist: + pass class CRUDTestCase(StudioAPITestCase): diff --git a/contentcuration/contentcuration/viewsets/invitation.py b/contentcuration/contentcuration/viewsets/invitation.py index e26b2f2be9..57ad34afce 100644 --- a/contentcuration/contentcuration/viewsets/invitation.py +++ b/contentcuration/contentcuration/viewsets/invitation.py @@ -58,6 +58,10 @@ def validate(self, data): raise serializers.ValidationError( "Invitation must specify either a channel or an organization." ) + if channel and organization: + raise serializers.ValidationError( + "Invitation cannot specify both a channel and an organization." + ) return data def create(self, validated_data):