diff --git a/contentcuration/contentcuration/models.py b/contentcuration/contentcuration/models.py index 7f4dbeb9ba..7e5add6d51 100644 --- a/contentcuration/contentcuration/models.py +++ b/contentcuration/contentcuration/models.py @@ -3903,6 +3903,11 @@ class Meta: verbose_name_plural = "Invitations" def accept(self): + if self.channel and self.organization: + self.channel.organization = self.organization + self.channel.save(update_fields=["organization"]) + return + user = User.objects.filter(email__iexact=self.email).first() if self.channel: self._accept_channel_invitation(user) diff --git a/contentcuration/contentcuration/tests/viewsets/test_channel.py b/contentcuration/contentcuration/tests/viewsets/test_channel.py index 2c72cf2dcc..644b0037ef 100644 --- a/contentcuration/contentcuration/tests/viewsets/test_channel.py +++ b/contentcuration/contentcuration/tests/viewsets/test_channel.py @@ -13,6 +13,7 @@ from contentcuration import models as cc from contentcuration.constants import channel_history from contentcuration.constants import community_library_submission +from contentcuration.constants.organization_roles import ORGANIZATION_EDITOR from contentcuration.models import AuditedSpecialPermissionsLicense from contentcuration.models import Change from contentcuration.models import Channel @@ -34,6 +35,7 @@ from contentcuration.tests.viewsets.base import SyncTestMixin from contentcuration.viewsets.channel import _unpublished_changes_query from contentcuration.viewsets.sync.constants import CHANNEL +from contentcuration.viewsets.sync.constants import INVITATION from contentcuration.viewsets.sync.utils import ( generate_added_to_community_library_event, ) @@ -76,6 +78,24 @@ def test_create_channel(self): except models.Channel.DoesNotExist: self.fail("Channel was not created") + def test_create_channel_ignores_organization(self): + user = testdata.user() + organization = testdata.organization() + channel = self.channel_metadata + channel["organization"] = organization.id + self.client.force_authenticate(user=user) + + response = self.sync_changes( + [ + generate_create_event( + channel["id"], CHANNEL, channel, channel_id=channel["id"] + ) + ] + ) + + self.assertEqual(response.status_code, 200, response.content) + self.assertIsNone(models.Channel.objects.get(id=channel["id"]).organization_id) + def test_create_channels(self): user = testdata.user() self.client.force_authenticate(user=user) @@ -121,6 +141,243 @@ def test_update_channel(self): self.assertEqual(response.status_code, 200, response.content) self.assertEqual(models.Channel.objects.get(id=channel.id).name, new_name) + def test_update_channel_organization_when_all_editors_have_access(self): + user = testdata.user() + organization = testdata.organization() + testdata.organization_role(user, organization, role=ORGANIZATION_EDITOR) + channel = models.Channel.objects.create( + actor_id=user.id, **self.channel_metadata + ) + channel.editors.add(user) + + self.client.force_authenticate(user=user) + response = self.sync_changes( + [ + generate_update_event( + channel.id, + CHANNEL, + {"organization": organization.id}, + channel_id=channel.id, + ) + ] + ) + + self.assertEqual(response.status_code, 200, response.content) + self.assertEqual( + models.Channel.objects.get(id=channel.id).organization_id, + organization.id, + ) + self.assertFalse( + models.Invitation.objects.filter( + channel=channel, organization=organization + ).exists() + ) + + def test_update_channel_organization_creates_contested_invitation(self): + user = testdata.user() + organization = testdata.organization() + channel = models.Channel.objects.create( + actor_id=user.id, **self.channel_metadata + ) + channel.editors.add(user) + + self.client.force_authenticate(user=user) + response = self.sync_changes( + [ + generate_update_event( + channel.id, + CHANNEL, + {"organization": organization.id}, + channel_id=channel.id, + ) + ] + ) + + self.assertEqual(response.status_code, 200, response.content) + channel.refresh_from_db() + self.assertIsNone(channel.organization_id) + invitation = models.Invitation.objects.get( + channel=channel, organization=organization + ) + self.assertTrue(invitation) + + change = models.Change.objects.filter(channel=channel, table=CHANNEL).latest( + "server_rev" + ) + self.assertEqual( + change.kwargs["mods"]["organization_status"], "pending_invitation" + ) + self.assertEqual( + change.kwargs["mods"]["requested_organization_id"], organization.id + ) + self.assertEqual(change.kwargs["mods"]["invitation_id"], invitation.id) + + def test_update_channel_organization_migration_creates_contested_invitation(self): + user = testdata.user() + current_organization = testdata.organization() + target_organization = testdata.organization("Target Organization") + testdata.organization_role(user, target_organization, role=ORGANIZATION_EDITOR) + channel = models.Channel.objects.create( + actor_id=user.id, + organization=current_organization, + **self.channel_metadata, + ) + channel.editors.add(user) + + self.client.force_authenticate(user=user) + response = self.sync_changes( + [ + generate_update_event( + channel.id, + CHANNEL, + {"organization": target_organization.id}, + channel_id=channel.id, + ) + ] + ) + + self.assertEqual(response.status_code, 200, response.content) + channel.refresh_from_db() + self.assertEqual(channel.organization_id, current_organization.id) + self.assertTrue( + models.Invitation.objects.filter( + channel=channel, organization=target_organization + ).exists() + ) + + def test_update_channel_organization_recreates_live_invitation_after_revocation( + self, + ): + user = testdata.user() + current_organization = testdata.organization() + target_organization = testdata.organization("Target Organization") + testdata.organization_role(user, target_organization, role=ORGANIZATION_EDITOR) + channel = models.Channel.objects.create( + actor_id=user.id, + organization=current_organization, + **self.channel_metadata, + ) + channel.editors.add(user) + + self.client.force_authenticate(user=user) + response = self.sync_changes( + [ + generate_update_event( + channel.id, + CHANNEL, + {"organization": target_organization.id}, + channel_id=channel.id, + ) + ] + ) + self.assertEqual(response.status_code, 200, response.content) + + invitation = models.Invitation.objects.get( + channel=channel, organization=target_organization + ) + response = self.sync_changes( + [ + generate_update_event( + invitation.id, + INVITATION, + {"revoked": True}, + channel_id=channel.id, + user_id=user.id, + ) + ] + ) + self.assertEqual(response.status_code, 200, response.content) + invitation.refresh_from_db() + self.assertTrue(invitation.revoked) + + response = self.sync_changes( + [ + generate_update_event( + channel.id, + CHANNEL, + {"organization": target_organization.id}, + channel_id=channel.id, + ) + ] + ) + self.assertEqual(response.status_code, 200, response.content) + self.assertEqual( + models.Invitation.objects.filter( + channel=channel, + organization=target_organization, + revoked=False, + declined=False, + accepted=False, + ).count(), + 1, + ) + + def test_channel_cannot_move_into_deleted_organization(self): + user = testdata.user() + organization = testdata.organization() + deleted_organization = testdata.organization("Deleted Org") + testdata.organization_role(user, deleted_organization, role=ORGANIZATION_EDITOR) + deleted_organization.deleted = True + deleted_organization.save(update_fields=["deleted"]) + channel = models.Channel.objects.create( + actor_id=user.id, + organization=organization, + **self.channel_metadata, + ) + channel.editors.add(user) + + self.client.force_authenticate(user=user) + response = self.sync_changes( + [ + generate_update_event( + channel.id, + CHANNEL, + {"organization": deleted_organization.id}, + channel_id=channel.id, + ) + ] + ) + + self.assertEqual(response.status_code, 200, response.content) + self.assertEqual(len(response.json()["errors"]), 1, response.content) + self.assertEqual( + models.Channel.objects.get(id=channel.id).organization_id, + organization.id, + ) + + def test_non_admin_cannot_remove_channel_from_organization(self): + user = testdata.user() + organization = testdata.organization() + channel = models.Channel.objects.create( + actor_id=user.id, + organization=organization, + **self.channel_metadata, + ) + channel.editors.add(user) + + self.client.force_authenticate(user=user) + response = self.sync_changes( + [ + generate_update_event( + channel.id, + CHANNEL, + {"organization": None}, + channel_id=channel.id, + ) + ] + ) + + self.assertEqual(response.status_code, 200, response.content) + self.assertEqual(len(response.json()["errors"]), 1, response.content) + self.assertEqual( + response.json()["errors"][0]["errors"]["organization"][0], + "Only organization admins can remove a channel from an organization.", + ) + self.assertEqual( + models.Channel.objects.get(id=channel.id).organization_id, + organization.id, + ) + def test_update_channel_thumbnail_encoding(self): user = testdata.user() channel = models.Channel.objects.create( diff --git a/contentcuration/contentcuration/tests/viewsets/test_invitation.py b/contentcuration/contentcuration/tests/viewsets/test_invitation.py index be0105106e..60c564e71d 100644 --- a/contentcuration/contentcuration/tests/viewsets/test_invitation.py +++ b/contentcuration/contentcuration/tests/viewsets/test_invitation.py @@ -448,6 +448,32 @@ def test_revoke_organization_invitation_by_admin(self): ) ], ) + self.assertEqual(response.status_code, 200, response.content) + invitation.refresh_from_db() + self.assertTrue(invitation.revoked, response.content) + + def test_update_contested_invitation_by_admin(self): + channel = models.Channel.objects.create( + actor_id=self.org_admin.id, organization=self.organization + ) + invitation = models.Invitation.objects.create( + id=uuid.uuid4().hex, + channel=channel, + organization=self.organization, + sender=self.org_admin, + ) + response = self.sync_changes( + [ + generate_update_event( + invitation.id, + INVITATION, + {"revoked": True}, + channel_id=channel.id, + user_id=self.org_admin.id, + ) + ] + ) + self.assertEqual(response.status_code, 200, response.content) invitation.refresh_from_db() self.assertTrue(invitation.revoked) @@ -775,6 +801,31 @@ def test_accept_invitation_by_admin_succeeds(self): invitation.refresh_from_db() self.assertTrue(invitation.accepted) + def test_accept_contested_channel_organization_invitation_by_admin_migrates_channel( + self, + ): + current_organization = testdata.organization() + target_organization = testdata.organization("Target Organization") + invitation = models.Invitation.objects.create( + channel=self.channel, + organization=target_organization, + sender=self.user, + ) + self.channel.organization = current_organization + self.channel.save(update_fields=["organization"]) + admin_user = self._make_admin() + + self.client.force_authenticate(user=admin_user) + response = self.client.post( + reverse("invitation-accept", kwargs={"pk": invitation.id}) + ) + + self.assertEqual(response.status_code, 200, response.content) + invitation.refresh_from_db() + self.channel.refresh_from_db() + self.assertTrue(invitation.accepted) + self.assertEqual(self.channel.organization_id, target_organization.id) + def test_decline_invitation_by_admin_succeeds(self): invitation = models.Invitation.objects.create(**self.invitation_db_metadata) admin_user = self._make_admin() diff --git a/contentcuration/contentcuration/viewsets/channel.py b/contentcuration/contentcuration/viewsets/channel.py index 3af5c0b9dd..84dd980305 100644 --- a/contentcuration/contentcuration/viewsets/channel.py +++ b/contentcuration/contentcuration/viewsets/channel.py @@ -47,6 +47,9 @@ 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_EDITOR +from contentcuration.constants.organization_roles import ORGANIZATION_ROLE_STATUS_ACTIVE from contentcuration.decorators import cache_no_user_data from contentcuration.models import Change from contentcuration.models import Channel @@ -288,6 +291,14 @@ class ChannelSerializer(BulkModelSerializer): operations, but read operations are handled by the Viewset. """ + # Allow channel assignments to private organizations to flow through the + # organization-change logic; permission and contested-invitation behavior is + # enforced in validate_organization/_handle_organization_change instead. + organization = serializers.PrimaryKeyRelatedField( + queryset=models.Organization.objects.all(), + required=False, + allow_null=True, + ) thumbnail_encoding = ThumbnailEncodingFieldsSerializer(required=False) content_defaults = ContentDefaultsSerializer(partial=True, required=False) @@ -304,6 +315,7 @@ class Meta: "language", "content_defaults", "source_domain", + "organization", ) read_only_fields = ("version",) list_serializer_class = BulkListSerializer @@ -311,6 +323,7 @@ class Meta: def create(self, validated_data): content_defaults = validated_data.pop("content_defaults", {}) + validated_data.pop("organization", None) validated_data["content_defaults"] = self.fields["content_defaults"].create( content_defaults ) @@ -342,6 +355,62 @@ def create(self, validated_data): ) return instance + def validate_organization(self, value): + if value is not None and getattr(value, "deleted", False): + raise serializers.ValidationError( + "Cannot assign a channel to a deleted organization." + ) + + if ( + value is None + and self.instance + and self.instance.organization_id is not None + and "request" in self.context + ): + user = self.context["request"].user + has_org_admin_access = models.Organization.filter_edit_queryset( + models.Organization.objects.filter(id=self.instance.organization_id), + user, + ).exists() + if not user.is_admin and not has_org_admin_access: + raise serializers.ValidationError( + "Only organization admins can remove a channel from an organization." + ) + return value + + @staticmethod + def _handle_organization_change(instance, organization, user): + if getattr(organization, "id", None) == instance.organization_id: + return organization, None + + if organization is None: + has_org_admin_access = models.Organization.filter_edit_queryset( + models.Organization.objects.filter(id=instance.organization_id), + user, + ).exists() + if user.is_admin or has_org_admin_access: + return organization, None + return instance.organization, None + + all_editors_have_access = not instance.editors.exclude( + organization_roles__organization=organization, + organization_roles__role__in=(ORGANIZATION_ADMIN, ORGANIZATION_EDITOR), + organization_roles__status=ORGANIZATION_ROLE_STATUS_ACTIVE, + ).exists() + + if instance.organization_id or not all_editors_have_access: + invitation, _ = models.Invitation.objects.get_or_create( + channel=instance, + organization=organization, + revoked=False, + declined=False, + accepted=False, + defaults={"sender": user}, + ) + return instance.organization, invitation + + return organization, None + def update(self, instance, validated_data): content_defaults = validated_data.pop("content_defaults", None) if content_defaults is not None: @@ -349,6 +418,30 @@ def update(self, instance, validated_data): instance.content_defaults, content_defaults ) + invitation = None + if "organization" in validated_data: + organization, invitation = self._handle_organization_change( + instance, validated_data["organization"], self.context["request"].user + ) + if getattr(organization, "id", None) == instance.organization_id: + validated_data.pop("organization") + if invitation is not None: + self.changes.append( + generate_update_event( + instance.id, + CHANNEL, + { + "organization_status": "pending_invitation", + "requested_organization_id": invitation.organization_id, + "invitation_id": invitation.id, + }, + channel_id=instance.id, + user_id=self.context["request"].user.id, + ) + ) + else: + validated_data["organization"] = organization + user_id = None if "request" in self.context: user_id = self.context["request"].user.id diff --git a/contentcuration/contentcuration/viewsets/invitation.py b/contentcuration/contentcuration/viewsets/invitation.py index bf62298b91..b4c9e012eb 100644 --- a/contentcuration/contentcuration/viewsets/invitation.py +++ b/contentcuration/contentcuration/viewsets/invitation.py @@ -15,6 +15,7 @@ from contentcuration.viewsets.base import BulkModelSerializer from contentcuration.viewsets.base import ValuesViewset from contentcuration.viewsets.common import UserFilteredPrimaryKeyRelatedField +from contentcuration.viewsets.sync.constants import CHANNEL from contentcuration.viewsets.sync.constants import INVITATION from contentcuration.viewsets.sync.utils import generate_update_event @@ -58,7 +59,10 @@ def validate(self, data): raise serializers.ValidationError( "Invitation must specify either a channel or an organization." ) - if channel and organization: + is_existing_contested_invitation = ( + self.instance and self.instance.channel_id and self.instance.organization_id + ) + if channel and organization and not is_existing_contested_invitation: raise serializers.ValidationError( "Invitation cannot specify both a channel and an organization." ) @@ -205,6 +209,18 @@ def accept(self, request, pk=None): invitation.accept() invitation.accepted = True invitation.save() + if invitation.channel and invitation.organization: + Change.create_change( + generate_update_event( + invitation.channel_id, + CHANNEL, + {"organization": invitation.organization_id}, + channel_id=invitation.channel_id, + user_id=request.user.id, + ), + applied=True, + created_by_id=request.user.id, + ) Change.create_change( generate_update_event( invitation.id,