diff --git a/contentcuration/contentcuration/models.py b/contentcuration/contentcuration/models.py index 8c7cee3919..0ba9c418dd 100644 --- a/contentcuration/contentcuration/models.py +++ b/contentcuration/contentcuration/models.py @@ -79,6 +79,10 @@ from contentcuration.constants import feedback from contentcuration.constants import user_history from contentcuration.constants.contentnode import kind_activity_map +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_VIEWER from contentcuration.constants.organization_roles import organization_role_choices from contentcuration.constants.organization_roles import ( organization_role_status_choices, @@ -818,7 +822,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]) @@ -845,7 +849,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 @@ -1061,7 +1065,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) @@ -1225,11 +1229,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): @@ -1238,35 +1286,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)) @@ -1881,6 +1954,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" @@ -1936,6 +2043,47 @@ 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" @@ -3705,7 +3853,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..7a2eda7b3b --- /dev/null +++ b/contentcuration/contentcuration/tests/viewsets/test_organization.py @@ -0,0 +1,638 @@ +"""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_VIEWER +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_403_FORBIDDEN) + 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_403_FORBIDDEN) + 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 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..4352307bfa 100644 --- a/contentcuration/contentcuration/urls.py +++ b/contentcuration/contentcuration/urls.py @@ -13,6 +13,7 @@ 1. Add an import: from blog import urls as blog_urls 2. Add a URL to urlpatterns: re_path(r'^blog/', include(blog_urls)) """ + import uuid import django_js_reverse.views as django_js_reverse_views @@ -57,6 +58,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 +86,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..678727944c 100644 --- a/contentcuration/contentcuration/viewsets/base.py +++ b/contentcuration/contentcuration/viewsets/base.py @@ -38,7 +38,6 @@ from contentcuration.viewsets.sync.utils import generate_update_event from contentcuration.viewsets.sync.utils import log_sync_exception - logger = logging.getLogger(__name__) @@ -188,9 +187,9 @@ def create(self, validated_data): validated_data[field_name], relation_info.related_model ): # Trying to set a foreign key but do not have the object, only the key - validated_data[ - relation_info.model_field.attname - ] = validated_data.pop(field_name) + validated_data[relation_info.model_field.attname] = ( + validated_data.pop(field_name) + ) instance = ModelClass(**validated_data) @@ -598,6 +597,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 +644,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 +759,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 +778,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 +940,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..9b94fbf344 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, ), @@ -1171,7 +1175,9 @@ def filter_keywords(self, queryset, name, value): or_, (Q(editors__last_name__icontains=k) for k in keywords) ) editors_email = reduce(or_, (Q(editors__email__icontains=k) for k in keywords)) - return queryset.annotate(primary_token=primary_token_subquery,).filter( + return queryset.annotate( + primary_token=primary_token_subquery, + ).filter( Q(name__icontains=value) | Q(pk__istartswith=value) | Q(primary_token=value.replace("-", "")) @@ -1349,7 +1355,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..891b5b2721 --- /dev/null +++ b/contentcuration/contentcuration/viewsets/organization.py @@ -0,0 +1,380 @@ +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 PermissionDenied +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_VIEWER +from contentcuration.constants.organization_roles import ( + organization_role_status_choices, +) +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 + + +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. + """ + + 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 + + +def _is_site_admin(user): + return bool(getattr(user, "is_admin", False)) + + +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 get_queryset(self): + # """ + # Return organizations visible to the current user. + + # Public organizations are visible to authenticated users. Private + # organizations require an active membership. Non-active memberships do + # not grant access. + # """ + # queryset = Organization.objects.filter(deleted=False) + # user = self.request.user + + # if _is_site_admin(user): + # return queryset + + # if not user.is_authenticated: + # return queryset.filter(public=True) + + # return queryset.filter( + # Q(public=True) + # | Q( + # user_roles__user=user, + # user_roles__status=ORGANIZATION_ROLE_STATUS_ACTIVE, + # ) + # ).distinct() + + # def get_edit_queryset(self): + # """Return organizations that the current user may modify.""" + # queryset = Organization.objects.filter(deleted=False) + # user = self.request.user + + # if _is_site_admin(user): + # return queryset + + # if not user.is_authenticated: + # return queryset.none() + + # return queryset.filter( + # user_roles__user=user, + # user_roles__role=ORGANIZATION_ADMIN, + # user_roles__status=ORGANIZATION_ROLE_STATUS_ACTIVE, + # ).distinct() + + 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 _require_admin(self, organization): + user = self.request.user + + if _is_site_admin(user): + return + + is_admin = OrganizationRole.objects.filter( + organization=organization, + user=user, + role=ORGANIZATION_ADMIN, + status=ORGANIZATION_ROLE_STATUS_ACTIVE, + ).exists() + + if not is_admin: + raise PermissionDenied( + "Only active organization admins may manage membership." + ) + + def perform_create(self, serializer, change=None): + organization = serializer.validated_data["organization"] + self._require_admin(organization) + 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]) + + +# The model is named OrganizationRole, while existing work may already import +# OrganizationMemberViewSet. Keep this alias so either name can be registered. +OrganizationRoleViewSet = OrganizationMemberViewSet 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"