Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions contentcuration/contentcuration/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
247 changes: 247 additions & 0 deletions contentcuration/contentcuration/tests/viewsets/test_channel.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -121,6 +141,233 @@ 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)
self.assertTrue(
models.Invitation.objects.filter(
channel=channel, organization=organization
).exists()
)

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(),

@rtibblesbot rtibblesbot Sep 9, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved — addressed in the current code.

praise: Driving revoke through the sync endpoint and asserting count() == 1 pins the revoked/declined/accepted scoping so a get_or_create regression fails loudly.

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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: With the role granted the test now reaches the guard, but line 332 still only asserts len(errors) == 1, which passes for any validation failure — a regression where the pk fails to resolve keeps it green. Assert the message, as test_non_admin_cannot_remove_channel_from_organization does: response.json()["errors"][0]["errors"]["organization"][0].

deleted_organization.deleted = True

@rtibblesbot rtibblesbot Sep 8, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved — addressed in the current code.

blocking: Green without the soft delete — no OrganizationRole on deleted_organization, so filter_view_queryset excludes it on membership alone; deleted = False passes locally with the same payload. Add testdata.organization_role(user, deleted_organization).

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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: len(errors) == 1 passes whatever the error is — an unrelated 500 surfacing as a sync error would keep this green. test_non_admin_cannot_remove_channel_from_organization (line 362) now asserts the message; mirror that here with "Cannot assign a channel to a deleted organization."

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)

@rtibblesbot rtibblesbot Sep 8, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved — addressed in the current code.

suggestion: len(errors) == 1 passes regardless of what the error is, which is why the Internal server error behaviour above is invisible in CI — assert the payload.

The positive half is also missing: no test that an org admin (or a Studio admin) can remove the channel, so the filter_edit_queryset(...).exists() branch at channel.py:357 is unexercised and a regression denying everyone would pass.

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,
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: The AC "only organization admins can remove" is tested only negatively — nothing covers an organization admin successfully setting organization to None, so a regression that blocks admins too would pass. Add the mirror test with testdata.organization_role(user, organization, role=ORGANIZATION_ADMIN) asserting organization_id is None and no errors.


def test_update_channel_thumbnail_encoding(self):
user = testdata.user()
channel = models.Channel.objects.create(
Expand Down
51 changes: 51 additions & 0 deletions contentcuration/contentcuration/tests/viewsets/test_invitation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand Down
Loading
Loading