diff --git a/contentcuration/contentcuration/models.py b/contentcuration/contentcuration/models.py index 368734e980..64d970d1a2 100644 --- a/contentcuration/contentcuration/models.py +++ b/contentcuration/contentcuration/models.py @@ -824,7 +824,7 @@ def file_on_disk_name(instance, filename): def generate_file_on_disk_name(checksum, filename): - """ Separated from file_on_disk_name to allow for simple way to check if has already exists """ + """Separated from file_on_disk_name to allow for simple way to check if has already exists""" h = checksum basename, ext = os.path.splitext(filename) directory = os.path.join(settings.STORAGE_ROOT, h[0], h[1]) @@ -851,7 +851,7 @@ def object_storage_name(instance, filename): def generate_object_storage_name(checksum, filename, default_ext=""): - """ Separated from file_on_disk_name to allow for simple way to check if has already exists """ + """Separated from file_on_disk_name to allow for simple way to check if has already exists""" h = checksum basename, actual_ext = os.path.splitext(filename) ext = actual_ext if actual_ext else default_ext @@ -1067,7 +1067,7 @@ class ChannelModelManager(models.Manager.from_queryset(ChannelModelQuerySet)): class Channel(models.Model): - """ Permissions come from association with organizations """ + """Permissions come from association with organizations""" id = UUIDField(primary_key=True, default=uuid.uuid4) name = models.CharField(max_length=200, blank=True) @@ -1231,11 +1231,55 @@ def filter_edit_queryset(cls, queryset, user): user_id=user_id, channel_id=OuterRef("id") ) ) - queryset = queryset.annotate(edit=edit) + organization_edit = Exists( + OrganizationRole.objects.filter( + user_id=user_id, + organization_id=OuterRef("organization_id"), + status=ORGANIZATION_ROLE_STATUS_ACTIVE, + role__in=( + ORGANIZATION_ADMIN, + ORGANIZATION_EDITOR, + ), + ) + ) + queryset = queryset.annotate( + edit=edit, + organization_edit=organization_edit, + ) if user.is_admin: return queryset - return queryset.filter(edit=True) + return queryset.filter(Q(edit=True) | Q(organization_edit=True)) + + @classmethod + def filter_delete_queryset(cls, queryset, user): + user_id = not user.is_anonymous and user.id + + if not user_id: + return queryset.none() + + edit = Exists( + User.editable_channels.through.objects.filter( + user_id=user_id, channel_id=OuterRef("id") + ) + ) + organization_delete = Exists( + OrganizationRole.objects.filter( + user_id=user_id, + organization_id=OuterRef("organization_id"), + status=ORGANIZATION_ROLE_STATUS_ACTIVE, + role=ORGANIZATION_ADMIN, + ) + ) + queryset = queryset.annotate( + edit=edit, + organization_delete=organization_delete, + ) + + if user.is_admin: + return queryset + + return queryset.filter(Q(edit=True) | Q(organization_delete=True)) @classmethod def filter_view_queryset(cls, queryset, user): @@ -1244,35 +1288,60 @@ def filter_view_queryset(cls, queryset, user): if user_id: filters = dict(user_id=user_id, channel_id=OuterRef("id")) + edit = Exists( User.editable_channels.through.objects.filter(**filters).values( "user_id" ) ) + view = Exists( User.view_only_channels.through.objects.filter(**filters).values( "user_id" ) ) + + organization_view = Exists( + OrganizationRole.objects.filter( + user_id=user_id, + organization_id=OuterRef("organization_id"), + status=ORGANIZATION_ROLE_STATUS_ACTIVE, + role__in=( + ORGANIZATION_ADMIN, + ORGANIZATION_EDITOR, + ORGANIZATION_VIEWER, + ), + ) + ) else: edit = boolean_val(False) view = boolean_val(False) + organization_view = boolean_val(False) queryset = queryset.annotate( edit=edit, view=view, + organization_view=organization_view, ) if user_id and user.is_admin: return queryset permission_filter = Q() + if user_id: pending_channels = Invitation.objects.filter( - email=user_email, revoked=False, declined=False, accepted=False + email=user_email, + revoked=False, + declined=False, + accepted=False, ).values_list("channel_id", flat=True) + permission_filter = ( - Q(view=True) | Q(edit=True) | Q(deleted=False, id__in=pending_channels) + Q(view=True) + | Q(edit=True) + | Q(organization_view=True) + | Q(deleted=False, id__in=pending_channels) ) return queryset.filter(permission_filter | Q(deleted=False, public=True)) @@ -1887,6 +1956,40 @@ class Organization(models.Model): objects = CustomManager() + @classmethod + def filter_view_queryset(cls, queryset, user): + queryset = queryset.filter(deleted=False) + + if user.is_anonymous: + return queryset.filter(public=True) + + if user.is_admin: + return queryset + + return queryset.filter( + Q(public=True) + | Q( + user_roles__user=user, + user_roles__status=ORGANIZATION_ROLE_STATUS_ACTIVE, + ) + ).distinct() + + @classmethod + def filter_edit_queryset(cls, queryset, user): + queryset = queryset.filter(deleted=False) + + 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() + class Meta: verbose_name = "Organization" verbose_name_plural = "Organizations" @@ -1942,6 +2045,43 @@ class OrganizationRole(models.Model): ) updated_at = models.DateTimeField(auto_now=True, help_text="Last update timestamp") + @classmethod + def filter_view_queryset(cls, queryset, user): + queryset = queryset.filter(organization__deleted=False,).select_related( + "organization", + "user", + ) + + if user.is_anonymous: + return queryset.none() + + if user.is_admin: + return queryset + + return queryset.filter( + organization__user_roles__user=user, + organization__user_roles__status=ORGANIZATION_ROLE_STATUS_ACTIVE, + ).distinct() + + @classmethod + def filter_edit_queryset(cls, queryset, user): + queryset = queryset.filter(organization__deleted=False,).select_related( + "organization", + "user", + ) + + if user.is_anonymous: + return queryset.none() + + if user.is_admin: + return queryset + + return queryset.filter( + organization__user_roles__user=user, + organization__user_roles__role=ORGANIZATION_ADMIN, + organization__user_roles__status=ORGANIZATION_ROLE_STATUS_ACTIVE, + ).distinct() + class Meta: unique_together = ("user", "organization") verbose_name = "Organization Role" @@ -3729,7 +3869,7 @@ def save(self, *args, **kwargs): class Invitation(models.Model): - """ Invitation to edit channel """ + """Invitation to edit channel""" id = UUIDField(primary_key=True, default=uuid.uuid4) accepted = models.BooleanField(default=False) diff --git a/contentcuration/contentcuration/tests/viewsets/test_organization.py b/contentcuration/contentcuration/tests/viewsets/test_organization.py new file mode 100644 index 0000000000..c2581cf2fd --- /dev/null +++ b/contentcuration/contentcuration/tests/viewsets/test_organization.py @@ -0,0 +1,750 @@ +"""Tests for organization and organization membership API endpoints.""" +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APIClient + +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_INACTIVE, +) +from contentcuration.constants.organization_roles import ( + ORGANIZATION_ROLE_STATUS_PENDING, +) +from contentcuration.constants.organization_roles import ORGANIZATION_VIEWER +from contentcuration.models import Channel +from contentcuration.models import Organization +from contentcuration.models import OrganizationRole +from contentcuration.tests import testdata +from contentcuration.tests.base import StudioAPITestCase + + +class OrganizationAPITestCase(StudioAPITestCase): + """Shared organization API fixtures and URL helpers.""" + + def setUp(self): + super().setUp() + + self.organization_admin = testdata.user(email="org-admin@test.com") + self.organization_admin.first_name = "Admin" + self.organization_admin.last_name = "User" + self.organization_admin.save(update_fields=["first_name", "last_name"]) + + self.editor_user = testdata.user(email="org-editor@test.com") + self.viewer_user = testdata.user(email="org-viewer@test.com") + self.other_user = testdata.user(email="org-other@test.com") + self.inactive_user = testdata.user(email="org-inactive@test.com") + + self.organization = Organization.objects.create( + name="Test Organization", + description="A test organization", + public=False, + ) + + self.admin_membership = OrganizationRole.objects.create( + user=self.organization_admin, + organization=self.organization, + role=ORGANIZATION_ADMIN, + status=ORGANIZATION_ROLE_STATUS_ACTIVE, + ) + self.editor_membership = OrganizationRole.objects.create( + user=self.editor_user, + organization=self.organization, + role=ORGANIZATION_EDITOR, + status=ORGANIZATION_ROLE_STATUS_ACTIVE, + ) + self.viewer_membership = OrganizationRole.objects.create( + user=self.viewer_user, + organization=self.organization, + role=ORGANIZATION_VIEWER, + status=ORGANIZATION_ROLE_STATUS_ACTIVE, + ) + self.inactive_membership = OrganizationRole.objects.create( + user=self.inactive_user, + organization=self.organization, + role=ORGANIZATION_VIEWER, + status=ORGANIZATION_ROLE_STATUS_INACTIVE, + ) + + @property + def organization_list_url(self): + return reverse("organization-list") + + def organization_detail_url(self, organization=None): + organization = organization or self.organization + return reverse("organization-detail", kwargs={"pk": organization.id}) + + @property + def membership_list_url(self): + return reverse("organization-members-list") + + def membership_detail_url(self, membership): + return reverse("organization-members-detail", kwargs={"pk": membership.id}) + + def authenticate_as(self, user): + self.client.force_authenticate(user) + + +class OrganizationListCreateTestCase(OrganizationAPITestCase): + def test_member_can_list_private_organization(self): + self.authenticate_as(self.organization_admin) + + response = self.client.get(self.organization_list_url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], 1) + self.assertEqual(response.data["results"][0]["name"], self.organization.name) + + def test_nonmember_cannot_list_private_organization(self): + self.authenticate_as(self.other_user) + + response = self.client.get(self.organization_list_url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["results"], []) + + def test_authenticated_nonmember_can_list_public_organization(self): + self.organization.public = True + self.organization.save(update_fields=["public"]) + self.authenticate_as(self.other_user) + + response = self.client.get(self.organization_list_url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], 1) + + def test_inactive_membership_does_not_grant_organization_access(self): + self.authenticate_as(self.inactive_user) + + response = self.client.get(self.organization_list_url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["results"], []) + + def test_list_can_filter_by_name(self): + second_organization = Organization.objects.create(name="Another Group") + OrganizationRole.objects.create( + user=self.organization_admin, + organization=second_organization, + role=ORGANIZATION_ADMIN, + status=ORGANIZATION_ROLE_STATUS_ACTIVE, + ) + self.authenticate_as(self.organization_admin) + + response = self.client.get(self.organization_list_url, {"name": "Another"}) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], 1) + self.assertEqual(response.data["results"][0]["name"], "Another Group") + + def test_create_organization_creates_active_admin_membership(self): + self.authenticate_as(self.other_user) + data = { + "name": "New Organization", + "description": "A new organization", + "public": False, + } + + response = self.client.post(self.organization_list_url, data, format="json") + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + organization = Organization.objects.get(id=response.data["id"]) + membership = OrganizationRole.objects.get( + organization=organization, + user=self.other_user, + ) + self.assertEqual(membership.role, ORGANIZATION_ADMIN) + self.assertEqual(membership.status, ORGANIZATION_ROLE_STATUS_ACTIVE) + + def test_create_organization_requires_authentication(self): + client = APIClient() + + response = client.post( + self.organization_list_url, + {"name": "New Organization"}, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_list_organizations_requires_authentication(self): + response = APIClient().get(self.organization_list_url) + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + +class OrganizationRetrieveUpdateDeleteTestCase(OrganizationAPITestCase): + def test_active_member_can_retrieve_private_organization(self): + self.authenticate_as(self.viewer_user) + + response = self.client.get(self.organization_detail_url()) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["name"], self.organization.name) + + def test_nonmember_cannot_retrieve_private_organization(self): + self.authenticate_as(self.other_user) + + response = self.client.get(self.organization_detail_url()) + + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_nonmember_can_retrieve_public_organization(self): + self.organization.public = True + self.organization.save(update_fields=["public"]) + self.authenticate_as(self.other_user) + + response = self.client.get(self.organization_detail_url()) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_active_admin_can_update_organization(self): + self.authenticate_as(self.organization_admin) + + response = self.client.patch( + self.organization_detail_url(), + {"name": "Updated Organization"}, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.organization.refresh_from_db() + self.assertEqual(self.organization.name, "Updated Organization") + + def test_editor_cannot_update_organization(self): + self.authenticate_as(self.editor_user) + + response = self.client.patch( + self.organization_detail_url(), + {"name": "Updated Organization"}, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_viewer_cannot_update_organization(self): + self.authenticate_as(self.viewer_user) + + response = self.client.patch( + self.organization_detail_url(), + {"name": "Updated Organization"}, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_inactive_admin_cannot_update_organization(self): + inactive_admin = testdata.user(email="inactive-admin@test.com") + OrganizationRole.objects.create( + user=inactive_admin, + organization=self.organization, + role=ORGANIZATION_ADMIN, + status=ORGANIZATION_ROLE_STATUS_INACTIVE, + ) + self.authenticate_as(inactive_admin) + + response = self.client.patch( + self.organization_detail_url(), + {"name": "Updated Organization"}, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_active_admin_can_soft_delete_organization(self): + self.authenticate_as(self.organization_admin) + + response = self.client.delete(self.organization_detail_url()) + + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.organization.refresh_from_db() + self.assertTrue(self.organization.deleted) + + def test_editor_cannot_delete_organization(self): + self.authenticate_as(self.editor_user) + + response = self.client.delete(self.organization_detail_url()) + + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + self.organization.refresh_from_db() + self.assertFalse(self.organization.deleted) + + +class OrganizationMembershipListTestCase(OrganizationAPITestCase): + def test_active_member_can_list_memberships(self): + self.authenticate_as(self.viewer_user) + + response = self.client.get( + self.membership_list_url, + {"organization": str(self.organization.id)}, + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], 4) + + def test_nonmember_receives_empty_membership_list(self): + self.authenticate_as(self.other_user) + + response = self.client.get( + self.membership_list_url, + {"organization": str(self.organization.id)}, + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["results"], []) + + def test_public_organization_does_not_expose_memberships_to_nonmember(self): + self.organization.public = True + self.organization.save(update_fields=["public"]) + self.authenticate_as(self.other_user) + + response = self.client.get( + self.membership_list_url, + {"organization": str(self.organization.id)}, + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["results"], []) + + def test_inactive_member_cannot_list_memberships(self): + self.authenticate_as(self.inactive_user) + + response = self.client.get( + self.membership_list_url, + {"organization": str(self.organization.id)}, + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["results"], []) + + def test_membership_response_includes_user_name_fields(self): + self.authenticate_as(self.organization_admin) + + response = self.client.get( + self.membership_list_url, + {"user": str(self.organization_admin.id)}, + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], 1) + membership = response.data["results"][0] + self.assertEqual(membership["user_email"], self.organization_admin.email) + self.assertEqual(membership["user_first_name"], "Admin") + self.assertEqual(membership["user_last_name"], "User") + self.assertEqual(membership["user_name"], "Admin User") + + def test_member_can_retrieve_membership_in_same_organization(self): + self.authenticate_as(self.viewer_user) + + response = self.client.get(self.membership_detail_url(self.admin_membership)) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_nonmember_cannot_retrieve_membership(self): + self.authenticate_as(self.other_user) + + response = self.client.get(self.membership_detail_url(self.admin_membership)) + + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + +class OrganizationMembershipCreationTestCase(OrganizationAPITestCase): + def test_active_admin_can_create_membership(self): + self.authenticate_as(self.organization_admin) + data = { + "organization": str(self.organization.id), + "user": str(self.other_user.id), + "role": ORGANIZATION_VIEWER, + "status": ORGANIZATION_ROLE_STATUS_ACTIVE, + } + + response = self.client.post(self.membership_list_url, data, format="json") + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + membership = OrganizationRole.objects.get( + organization=self.organization, + user=self.other_user, + ) + self.assertEqual(membership.role, ORGANIZATION_VIEWER) + self.assertEqual(membership.status, ORGANIZATION_ROLE_STATUS_ACTIVE) + + def test_editor_cannot_create_membership(self): + self.authenticate_as(self.editor_user) + data = { + "organization": str(self.organization.id), + "user": str(self.other_user.id), + "role": ORGANIZATION_VIEWER, + "status": ORGANIZATION_ROLE_STATUS_ACTIVE, + } + + response = self.client.post(self.membership_list_url, data, format="json") + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertFalse( + OrganizationRole.objects.filter( + organization=self.organization, + user=self.other_user, + ).exists() + ) + + def test_nonmember_cannot_create_membership(self): + self.authenticate_as(self.other_user) + data = { + "organization": str(self.organization.id), + "user": str(self.other_user.id), + "role": ORGANIZATION_VIEWER, + "status": ORGANIZATION_ROLE_STATUS_ACTIVE, + } + + response = self.client.post(self.membership_list_url, data, format="json") + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertFalse( + OrganizationRole.objects.filter( + organization=self.organization, + user=self.other_user, + ).exists() + ) + + def test_invalid_role_is_rejected_on_create(self): + self.authenticate_as(self.organization_admin) + data = { + "organization": str(self.organization.id), + "user": str(self.other_user.id), + "role": "invalid-role", + "status": ORGANIZATION_ROLE_STATUS_ACTIVE, + } + + response = self.client.post(self.membership_list_url, data, format="json") + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_duplicate_membership_is_rejected(self): + self.authenticate_as(self.organization_admin) + data = { + "organization": str(self.organization.id), + "user": str(self.viewer_user.id), + "role": ORGANIZATION_EDITOR, + "status": ORGANIZATION_ROLE_STATUS_ACTIVE, + } + + response = self.client.post(self.membership_list_url, data, format="json") + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual( + OrganizationRole.objects.filter( + organization=self.organization, + user=self.viewer_user, + ).count(), + 1, + ) + + +class OrganizationMembershipUpdateTestCase(OrganizationAPITestCase): + def test_active_admin_can_update_member_role(self): + self.authenticate_as(self.organization_admin) + + response = self.client.patch( + self.membership_detail_url(self.viewer_membership), + {"role": ORGANIZATION_EDITOR}, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.viewer_membership.refresh_from_db() + self.assertEqual(self.viewer_membership.role, ORGANIZATION_EDITOR) + + def test_editor_cannot_update_membership(self): + self.authenticate_as(self.editor_user) + + response = self.client.patch( + self.membership_detail_url(self.viewer_membership), + {"role": ORGANIZATION_EDITOR}, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_nonmember_cannot_update_membership(self): + self.authenticate_as(self.other_user) + + response = self.client.patch( + self.membership_detail_url(self.viewer_membership), + {"role": ORGANIZATION_EDITOR}, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_invalid_role_is_rejected(self): + self.authenticate_as(self.organization_admin) + + response = self.client.patch( + self.membership_detail_url(self.viewer_membership), + {"role": "invalid-role"}, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_membership_user_and_organization_cannot_be_reassigned(self): + other_organization = Organization.objects.create(name="Other Organization") + self.authenticate_as(self.organization_admin) + + response = self.client.patch( + self.membership_detail_url(self.viewer_membership), + { + "user": str(self.other_user.id), + "organization": str(other_organization.id), + "description": "Updated description", + }, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.viewer_membership.refresh_from_db() + self.assertEqual(self.viewer_membership.user, self.viewer_user) + self.assertEqual(self.viewer_membership.organization, self.organization) + self.assertEqual(self.viewer_membership.description, "Updated description") + + def test_last_active_admin_cannot_be_demoted(self): + self.authenticate_as(self.organization_admin) + + response = self.client.patch( + self.membership_detail_url(self.admin_membership), + {"role": ORGANIZATION_EDITOR}, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.admin_membership.refresh_from_db() + self.assertEqual(self.admin_membership.role, ORGANIZATION_ADMIN) + + def test_last_active_admin_cannot_be_deactivated(self): + self.authenticate_as(self.organization_admin) + + response = self.client.patch( + self.membership_detail_url(self.admin_membership), + {"status": ORGANIZATION_ROLE_STATUS_INACTIVE}, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.admin_membership.refresh_from_db() + self.assertEqual( + self.admin_membership.status, + ORGANIZATION_ROLE_STATUS_ACTIVE, + ) + + def test_admin_can_be_demoted_when_another_active_admin_exists(self): + second_admin = testdata.user(email="second-admin@test.com") + OrganizationRole.objects.create( + user=second_admin, + organization=self.organization, + role=ORGANIZATION_ADMIN, + status=ORGANIZATION_ROLE_STATUS_ACTIVE, + ) + self.authenticate_as(self.organization_admin) + + response = self.client.patch( + self.membership_detail_url(self.admin_membership), + {"role": ORGANIZATION_EDITOR}, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.admin_membership.refresh_from_db() + self.assertEqual(self.admin_membership.role, ORGANIZATION_EDITOR) + + +class OrganizationMembershipDeleteTestCase(OrganizationAPITestCase): + def test_active_admin_can_remove_nonadmin_member(self): + self.authenticate_as(self.organization_admin) + + response = self.client.delete( + self.membership_detail_url(self.viewer_membership) + ) + + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertFalse( + OrganizationRole.objects.filter(id=self.viewer_membership.id).exists() + ) + + def test_editor_cannot_remove_membership(self): + self.authenticate_as(self.editor_user) + + response = self.client.delete( + self.membership_detail_url(self.viewer_membership) + ) + + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_nonmember_cannot_remove_membership(self): + self.authenticate_as(self.other_user) + + response = self.client.delete( + self.membership_detail_url(self.viewer_membership) + ) + + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_last_active_admin_cannot_be_removed(self): + self.authenticate_as(self.organization_admin) + + response = self.client.delete(self.membership_detail_url(self.admin_membership)) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertTrue( + OrganizationRole.objects.filter(id=self.admin_membership.id).exists() + ) + + +class OrganizationChannelPermissionTestCase(OrganizationAPITestCase): + def setUp(self): + super().setUp() + self.channel = Channel.objects.create( + name="Organization Channel", + organization=self.organization, + public=False, + actor_id=self.organization_admin.id, + ) + self.pending_user = testdata.user(email="org-pending@test.com") + OrganizationRole.objects.create( + user=self.pending_user, + organization=self.organization, + role=ORGANIZATION_EDITOR, + status=ORGANIZATION_ROLE_STATUS_PENDING, + ) + + def _editable_channel(self, user): + return Channel.filter_edit_queryset( + Channel.objects.filter(pk=self.channel.pk), + user, + ) + + def _deletable_channel(self, user): + return Channel.filter_delete_queryset( + Channel.objects.filter(pk=self.channel.pk), + user, + ) + + def _viewable_channel(self, user): + return Channel.filter_view_queryset( + Channel.objects.filter(pk=self.channel.pk), + user, + ) + + def test_admin_can_update_organization_channel_without_m2m_share(self): + self.assertFalse( + self.channel.editors.filter(pk=self.organization_admin.pk).exists() + ) + + channel = self._editable_channel(self.organization_admin).get() + channel.name = "Admin Updated Channel" + channel.save(actor_id=self.organization_admin.id) + + channel.refresh_from_db() + self.assertEqual(channel.name, "Admin Updated Channel") + self.assertTrue(self._viewable_channel(self.organization_admin).exists()) + + def test_editor_can_update_organization_channel_without_m2m_share(self): + self.assertFalse(self.channel.editors.filter(pk=self.editor_user.pk).exists()) + + channel = self._editable_channel(self.editor_user).get() + channel.name = "Editor Updated Channel" + channel.save(actor_id=self.editor_user.id) + + channel.refresh_from_db() + self.assertEqual(channel.name, "Editor Updated Channel") + self.assertTrue(self._viewable_channel(self.editor_user).exists()) + + def test_org_editor_cannot_delete_organization_channel(self): + self.assertTrue(self._editable_channel(self.editor_user).exists()) + self.assertFalse(self._deletable_channel(self.editor_user).exists()) + + def test_org_admin_can_delete_organization_channel(self): + self.assertTrue(self._deletable_channel(self.organization_admin).exists()) + + def test_org_editor_delete_channel_returns_forbidden(self): + self.authenticate_as(self.editor_user) + +<<<<<<< HEAD + response = self.client.delete( + reverse("channel-detail", kwargs={"pk": self.channel.id}) + ) +======= + response = self.client.delete("/api/channel/{}/".format(self.channel.id)) +>>>>>>> 2cf01575f772c53f974b8332012567600152788d + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.channel.refresh_from_db() + self.assertFalse(self.channel.deleted) + + def test_org_admin_can_delete_channel_via_api(self): + self.authenticate_as(self.organization_admin) + +<<<<<<< HEAD + response = self.client.delete( + reverse("channel-detail", kwargs={"pk": self.channel.id}) + ) +======= + response = self.client.delete("/api/channel/{}/".format(self.channel.id)) +>>>>>>> 2cf01575f772c53f974b8332012567600152788d + + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.channel.refresh_from_db() + self.assertTrue(self.channel.deleted) + + def test_viewer_can_view_but_cannot_edit_organization_channel(self): + self.assertTrue(self._viewable_channel(self.viewer_user).exists()) + self.assertFalse(self._editable_channel(self.viewer_user).exists()) + + def test_nonmember_cannot_view_or_edit_organization_channel(self): + self.assertFalse(self._viewable_channel(self.other_user).exists()) + self.assertFalse(self._editable_channel(self.other_user).exists()) + + def test_pending_role_grants_no_channel_access(self): + self.assertFalse(self._viewable_channel(self.pending_user).exists()) + self.assertFalse(self._editable_channel(self.pending_user).exists()) + + +class OrganizationPaginationTestCase(OrganizationAPITestCase): + def test_organization_list_is_paginated(self): + for index in range(25): + organization = Organization.objects.create(name="Org {}".format(index)) + OrganizationRole.objects.create( + user=self.organization_admin, + organization=organization, + role=ORGANIZATION_ADMIN, + status=ORGANIZATION_ROLE_STATUS_ACTIVE, + ) + self.authenticate_as(self.organization_admin) + + response = self.client.get(self.organization_list_url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], 26) + self.assertEqual(len(response.data["results"]), 20) + + def test_membership_list_is_paginated(self): + for index in range(25): + user = testdata.user(email="member{}@test.com".format(index)) + OrganizationRole.objects.create( + user=user, + organization=self.organization, + role=ORGANIZATION_VIEWER, + status=ORGANIZATION_ROLE_STATUS_ACTIVE, + ) + self.authenticate_as(self.organization_admin) + + response = self.client.get( + self.membership_list_url, + {"organization": str(self.organization.id)}, + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], 29) + self.assertEqual(len(response.data["results"]), 20) diff --git a/contentcuration/contentcuration/urls.py b/contentcuration/contentcuration/urls.py index 6f36a5ac68..d013650272 100644 --- a/contentcuration/contentcuration/urls.py +++ b/contentcuration/contentcuration/urls.py @@ -57,6 +57,8 @@ from contentcuration.viewsets.feedback import RecommendationsInteractionEventViewSet from contentcuration.viewsets.file import FileViewSet from contentcuration.viewsets.invitation import InvitationViewSet +from contentcuration.viewsets.organization import OrganizationMemberViewSet +from contentcuration.viewsets.organization import OrganizationViewSet from contentcuration.viewsets.recommendation import RecommendationView from contentcuration.viewsets.sync.endpoint import SyncView from contentcuration.viewsets.user import AdminUserViewSet @@ -83,6 +85,10 @@ def get_redirect_url(self, *args, **kwargs): router.register(r"channeluser", ChannelUserViewSet, basename="channeluser") router.register(r"user", UserViewSet) router.register(r"invitation", InvitationViewSet) +router.register(r"organization", OrganizationViewSet, basename="organization") +router.register( + r"organization-members", OrganizationMemberViewSet, basename="organization-members" +) router.register(r"contentnode", ContentNodeViewSet) router.register(r"assessmentitem", AssessmentItemViewSet) router.register(r"admin-users", AdminUserViewSet, basename="admin-users") diff --git a/contentcuration/contentcuration/viewsets/base.py b/contentcuration/contentcuration/viewsets/base.py index 3a03c45a9b..2a72f67803 100644 --- a/contentcuration/contentcuration/viewsets/base.py +++ b/contentcuration/contentcuration/viewsets/base.py @@ -598,6 +598,16 @@ def get_edit_queryset(self): return queryset.model.filter_edit_queryset(queryset, self.request.user) return self.get_queryset() + def get_delete_queryset(self): + """ + Return a filtered copy of the queryset to only the objects + that a user is able to delete. + """ + queryset = super(BaseValuesViewset, self).get_queryset() + if hasattr(queryset.model, "filter_delete_queryset"): + return queryset.model.filter_delete_queryset(queryset, self.request.user) + return self.get_edit_queryset() + def _get_lookup_filter(self): lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field @@ -635,6 +645,9 @@ def get_object(self): def get_edit_object(self): return self._get_object_from_queryset(self.get_edit_queryset()) + def get_delete_object(self): + return self._get_object_from_queryset(self.get_delete_queryset()) + def annotate_queryset(self, queryset): return queryset @@ -747,7 +760,7 @@ def perform_destroy(self, instance): def delete_from_changes(self, changes): errors = [] - queryset = self.get_edit_queryset().order_by() + queryset = self.get_delete_queryset().order_by() for change in changes: try: instance = queryset.get(**dict(self.values_from_key(change["key"]))) @@ -766,7 +779,7 @@ def delete_from_changes(self, changes): class RESTDestroyModelMixin(DestroyModelMixin): def destroy(self, request, *args, **kwargs): - instance = self.get_edit_object() + instance = self.get_delete_object() self.perform_destroy(instance) return Response(status=HTTP_204_NO_CONTENT) @@ -928,7 +941,7 @@ class BulkDeleteMixin(DestroyModelMixin): def delete_from_changes(self, changes): keys = [change["key"] for change in changes] queryset = self.filter_queryset_from_keys( - self.get_edit_queryset(), keys + self.get_delete_queryset(), keys ).order_by() errors = [] try: diff --git a/contentcuration/contentcuration/viewsets/channel.py b/contentcuration/contentcuration/viewsets/channel.py index 3175e8180a..3af5c0b9dd 100644 --- a/contentcuration/contentcuration/viewsets/channel.py +++ b/contentcuration/contentcuration/viewsets/channel.py @@ -29,6 +29,7 @@ from le_utils.constants import roles from rest_framework import serializers from rest_framework.decorators import action +from rest_framework.exceptions import PermissionDenied from rest_framework.exceptions import ValidationError from rest_framework.permissions import AllowAny from rest_framework.permissions import IsAuthenticated @@ -483,6 +484,9 @@ def create(self, request, *args, **kwargs): def destroy(self, request, *args, **kwargs): instance = self.get_edit_object() + if not self.get_delete_queryset().filter(pk=instance.pk).exists(): + raise PermissionDenied("You do not have permission to delete this channel.") + self.perform_destroy(instance) Change.create_change( generate_update_event( @@ -686,9 +690,9 @@ def publish_next(self, pk, use_staging_tree=False): channel.id, CHANNEL, { - "draft_token": draft_token.token - if draft_token - else None, + "draft_token": ( + draft_token.token if draft_token else None + ), }, channel_id=channel.id, ), @@ -1349,7 +1353,7 @@ def annotate_queryset(self, queryset): class SettingsChannelSerializer(BulkModelSerializer): - """ Used for displaying list of user's channels on settings page """ + """Used for displaying list of user's channels on settings page""" editor_count = serializers.SerializerMethodField() diff --git a/contentcuration/contentcuration/viewsets/organization.py b/contentcuration/contentcuration/viewsets/organization.py new file mode 100644 index 0000000000..f75c28ecd1 --- /dev/null +++ b/contentcuration/contentcuration/viewsets/organization.py @@ -0,0 +1,313 @@ +from django.db import transaction +from django_filters.rest_framework import CharFilter +from django_filters.rest_framework import FilterSet +from django_filters.rest_framework import NumberFilter +from django_filters.rest_framework import UUIDFilter +from rest_framework import serializers +from rest_framework.exceptions import ValidationError +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response + +from contentcuration.constants.organization_roles import ORGANIZATION_ADMIN +from contentcuration.constants.organization_roles import ORGANIZATION_ROLE_STATUS_ACTIVE +from contentcuration.constants.organization_roles import ( + organization_role_status_choices, +) +from contentcuration.constants.organization_roles import ORGANIZATION_VIEWER +from contentcuration.models import Organization +from contentcuration.models import OrganizationRole +from contentcuration.utils.pagination import ValuesViewsetPageNumberPagination +from contentcuration.viewsets.base import BulkListSerializer +from contentcuration.viewsets.base import BulkModelSerializer +from contentcuration.viewsets.base import RESTCreateModelMixin +from contentcuration.viewsets.base import RESTDestroyModelMixin +from contentcuration.viewsets.base import RESTUpdateModelMixin +from contentcuration.viewsets.base import ValuesViewset +from contentcuration.viewsets.common import UserFilteredPrimaryKeyRelatedField + + +class OrganizationSerializer(BulkModelSerializer): + """ + Write serializer for organizations. + + Read operations are handled by OrganizationViewSet.values, following the + ValuesViewset pattern used elsewhere in Studio. + """ + + class Meta: + model = Organization + fields = ( + "id", + "name", + "description", + "thumbnail", + "thumbnail_encoding", + "public", + ) + list_serializer_class = BulkListSerializer + + +class OrganizationMemberSerializer(BulkModelSerializer): + """ + Write serializer for OrganizationRole membership records. + + Organization and user are writable when creating a membership. They are + immutable once the membership exists; admins may update role, description, + or status. Read operations are handled by the viewset values map. + """ + + organization = UserFilteredPrimaryKeyRelatedField( + queryset=Organization.objects.all(), + ) + status = serializers.ChoiceField( + choices=organization_role_status_choices, + required=False, + ) + + class Meta: + model = OrganizationRole + fields = ( + "id", + "organization", + "user", + "role", + "description", + "status", + ) + list_serializer_class = BulkListSerializer + + def update(self, instance, validated_data): + validated_data.pop("organization", None) + validated_data.pop("user", None) + return super(OrganizationMemberSerializer, self).update( + instance, validated_data + ) + + +class OrganizationFilter(FilterSet): + name = CharFilter(field_name="name", lookup_expr="icontains") + + class Meta: + model = Organization + fields = ("name", "public") + + +class OrganizationMemberFilter(FilterSet): + organization = UUIDFilter(field_name="organization_id") + user = NumberFilter(field_name="user_id") + + class Meta: + model = OrganizationRole + fields = ("organization", "user", "role", "status") + + +class OrganizationPagination(ValuesViewsetPageNumberPagination): + page_size = 20 + page_size_query_param = "page_size" + max_page_size = 100 + + +class OrganizationViewSet( + ValuesViewset, + RESTCreateModelMixin, + RESTUpdateModelMixin, + RESTDestroyModelMixin, +): + """ + Organization CRUD API. + + Active organization admins may update or delete an organization. Any + authenticated user may create an organization and becomes its first active + administrator. Site administrators may manage every organization. + """ + + queryset = Organization.objects.all() + serializer_class = OrganizationSerializer + permission_classes = [IsAuthenticated] + filterset_class = OrganizationFilter + pagination_class = OrganizationPagination + ordering_fields = ("name", "created_at", "updated_at") + ordering = "name" + + values = ( + "id", + "name", + "description", + "thumbnail", + "thumbnail_encoding", + "public", + "created_at", + "updated_at", + ) + + def perform_create(self, serializer, change=None): + """Create the organization and its initial administrator atomically.""" + with transaction.atomic(): + organization = serializer.save() + OrganizationRole.objects.create( + organization=organization, + user=self.request.user, + role=ORGANIZATION_ADMIN, + status=ORGANIZATION_ROLE_STATUS_ACTIVE, + ) + + def perform_destroy(self, instance): + """Soft-delete an organization.""" + instance.deleted = True + instance.save(update_fields=["deleted", "updated_at"]) + + +class OrganizationMemberViewSet( + ValuesViewset, + RESTCreateModelMixin, + RESTUpdateModelMixin, + RESTDestroyModelMixin, +): + """ + Organization membership and role API. + + Active organization members may read the membership list. Active organization + admins may create, update, or remove memberships and assign roles. Site admins + may manage all memberships. + """ + + queryset = OrganizationRole.objects.all() + serializer_class = OrganizationMemberSerializer + permission_classes = [IsAuthenticated] + filterset_class = OrganizationMemberFilter + pagination_class = OrganizationPagination + ordering_fields = ("joined_at", "updated_at", "role", "status") + ordering = "-joined_at" + + values = ( + "id", + "organization_id", + "organization__name", + "user_id", + "user__email", + "user__first_name", + "user__last_name", + "role", + "description", + "status", + "joined_at", + "updated_at", + ) + + field_map = { + "organization": "organization_id", + "organization_name": "organization__name", + "user": "user_id", + "user_email": "user__email", + "user_first_name": "user__first_name", + "user_last_name": "user__last_name", + } + + def consolidate(self, items, queryset): + """Add the display name after field mappings have been applied.""" + for item in items: + item["user_name"] = "{} {}".format( + item.get("user_first_name", "") or "", + item.get("user_last_name", "") or "", + ).strip() + return items + + def perform_create(self, serializer, change=None): + serializer.save() + + def _ensure_not_last_active_admin( + self, + membership, + active_admin_count, + new_role=None, + new_status=None, + ): + """Prevent an organization from being left without an active admin.""" + if ( + membership.role != ORGANIZATION_ADMIN + or membership.status != ORGANIZATION_ROLE_STATUS_ACTIVE + ): + return + + resulting_role = new_role if new_role is not None else membership.role + resulting_status = new_status if new_status is not None else membership.status + + if ( + resulting_role == ORGANIZATION_ADMIN + and resulting_status == ORGANIZATION_ROLE_STATUS_ACTIVE + ): + return + + if active_admin_count <= 1: + raise ValidationError( + "An organization must have at least one active admin." + ) + + def _lock_active_admins(self, organization_id): + """Lock active admin memberships in a deterministic order.""" + return list( + OrganizationRole.objects.select_for_update(of=("self",)) + .filter( + organization_id=organization_id, + role=ORGANIZATION_ADMIN, + status=ORGANIZATION_ROLE_STATUS_ACTIVE, + ) + .order_by("id") + .values_list("id", flat=True) + ) + + def perform_update(self, serializer): + with transaction.atomic(): + active_admin_ids = self._lock_active_admins( + serializer.instance.organization_id + ) + membership = ( + OrganizationRole.objects.select_for_update(of=("self",)) + .select_related("organization", "user") + .get(pk=serializer.instance.pk) + ) + self._ensure_not_last_active_admin( + membership, + active_admin_count=len(active_admin_ids), + new_role=serializer.validated_data.get("role"), + new_status=serializer.validated_data.get("status"), + ) + + serializer.instance = membership + serializer.save() + + def perform_destroy(self, instance): + with transaction.atomic(): + active_admin_ids = self._lock_active_admins(instance.organization_id) + membership = ( + OrganizationRole.objects.select_for_update(of=("self",)) + .select_related("organization", "user") + .get(pk=instance.pk) + ) + self._ensure_not_last_active_admin( + membership, + active_admin_count=len(active_admin_ids), + new_role=ORGANIZATION_VIEWER, + new_status=membership.status, + ) + membership.delete() + + def update(self, request, *args, **kwargs): + partial = kwargs.pop("partial", False) + instance = self.get_edit_object() + + serializer = self.get_serializer( + instance, + data=request.data, + partial=partial, + ) + serializer.is_valid(raise_exception=True) + + self.perform_update(serializer) + + queryset = OrganizationRole.objects.select_related( + "organization", + "user", + ).filter(pk=serializer.instance.pk) + + return Response(self.serialize(queryset)[0]) diff --git a/contentcuration/kolibri_public/urls.py b/contentcuration/kolibri_public/urls.py index f3d499683f..d1e62d91a9 100644 --- a/contentcuration/kolibri_public/urls.py +++ b/contentcuration/kolibri_public/urls.py @@ -8,7 +8,6 @@ from kolibri_public.views import ContentNodeViewset from rest_framework import routers - public_content_v2_router = routers.SimpleRouter() public_content_v2_router.register( r"channel", ChannelMetadataViewSet, basename="publicchannel"