Skip to content

Commit 6cea294

Browse files
committed
6009: add organizations to invitation querysets, serializer, and sync
Extends organization invitations (Invitation.organization, added in #6008) end-to-end through the existing channel-invitation permission model and /sync mechanism, per #6009. - Organization.filter_edit_queryset/filter_view_queryset - new classmethods based on active OrganizationRole membership (admin for edit, admin/editor/viewer for view; excludes soft-deleted organizations), mirroring the existing Channel pattern. - Invitation.filter_edit_queryset/filter_view_queryset - extended with the same organization-role checks, in a single filter() so the multi-valued OrganizationRole join matches one row rather than across rows. - InvitationSerializer - organization is now a field alongside channel (via UserFilteredPrimaryKeyRelatedField, scoped by Organization.filter_edit_queryset); channel becomes optional and validate() requires exactly one of channel/organization. - get_fields() - match the invitee on email rather than the `invited` FK, since `invited` is only ever populated by the channel email-invite flow and is never set for invitations created through the sync API. Unlock `revoked` for any active org admin of the invitation's organization, not just the original sender. Lock `share_mode` read-only for anyone but the sender/org-admin, so an invitee can't self-escalate (e.g. request "admin" access) before accepting. - update() - read accepted/revoked from validated_data, not initial_data, so get_fields' read-only flags can't be bypassed by the raw client payload; only trigger accept() on an actual incoming toggle rather than re-running it on every later update to an already-accepted invitation. Tag user_id on the accept/decline echo events (both the sync-based update() and the REST actions) so they're routable. - InvitationFilter/InvitationViewSet - organization filter and field_map entry alongside the existing channel ones. - /sync's handle_changes() is untouched - organization-scoped invitation changes route the same way any other self-only change does (client tags user_id as its own id), relying entirely on the model-layer filter_edit_queryset checks above for authorization when the change is actually applied. Test coverage: OrganizationTestCase/InvitationOrganizationTestCase in test_models.py exercise the querysets directly. OrganizationInvitationSyncTestCase in test_invitation.py covers create/accept/revoke/delete via /sync, non-admin rejection, cross-org isolation, an org admin unable to force-accept on behalf of the real invitee, an invitee unable to raise their own share_mode before accepting, the channel/organization mutual-exclusivity and "at-least-one" validation, and that org-scoped changes require a self-tagged user_id (missing or mismatched is rejected, not silently dropped or re-routed). Also a channel-invitation test locking in that the "admin" (co-owner) share_mode currently grants the same editor access as "edit". The REST create route (POST /invitation/) has no org-specific test, since InvitationViewSet has no create route at all for either channel or organization invitations - creation is /sync only, and the existing non-org 405 test already covers that; the ?organization= list filter is exercised directly instead.
1 parent 4c74c10 commit 6cea294

5 files changed

Lines changed: 749 additions & 6 deletions

File tree

contentcuration/contentcuration/models.py

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1895,6 +1895,40 @@ class Meta:
18951895
def __str__(self):
18961896
return self.name
18971897

1898+
@classmethod
1899+
def filter_edit_queryset(cls, queryset, user):
1900+
if user.is_anonymous:
1901+
return queryset.none()
1902+
1903+
if user.is_admin:
1904+
return queryset
1905+
1906+
return queryset.filter(
1907+
user_roles__user=user,
1908+
user_roles__role=ORGANIZATION_ADMIN,
1909+
user_roles__status=ORGANIZATION_ROLE_STATUS_ACTIVE,
1910+
deleted=False,
1911+
).distinct()
1912+
1913+
@classmethod
1914+
def filter_view_queryset(cls, queryset, user):
1915+
if user.is_anonymous:
1916+
return queryset.none()
1917+
1918+
if user.is_admin:
1919+
return queryset
1920+
1921+
return queryset.filter(
1922+
user_roles__user=user,
1923+
user_roles__role__in=[
1924+
ORGANIZATION_ADMIN,
1925+
ORGANIZATION_EDITOR,
1926+
ORGANIZATION_VIEWER,
1927+
],
1928+
user_roles__status=ORGANIZATION_ROLE_STATUS_ACTIVE,
1929+
deleted=False,
1930+
).distinct()
1931+
18981932

18991933
class OrganizationRole(models.Model):
19001934
"""
@@ -3807,7 +3841,14 @@ def filter_edit_queryset(cls, queryset, user):
38073841
return queryset
38083842

38093843
return queryset.filter(
3810-
Q(email__iexact=user.email) | Q(sender=user) | Q(channel__editors=user)
3844+
Q(email__iexact=user.email)
3845+
| Q(sender=user)
3846+
| Q(channel__editors=user)
3847+
| Q(
3848+
organization__user_roles__user=user,
3849+
organization__user_roles__role=ORGANIZATION_ADMIN,
3850+
organization__user_roles__status=ORGANIZATION_ROLE_STATUS_ACTIVE,
3851+
)
38113852
).distinct()
38123853

38133854
@classmethod
@@ -3822,6 +3863,15 @@ def filter_view_queryset(cls, queryset, user):
38223863
| Q(sender=user)
38233864
| Q(channel__editors=user)
38243865
| Q(channel__viewers=user)
3866+
| Q(
3867+
organization__user_roles__user=user,
3868+
organization__user_roles__role__in=[
3869+
ORGANIZATION_ADMIN,
3870+
ORGANIZATION_EDITOR,
3871+
ORGANIZATION_VIEWER,
3872+
],
3873+
organization__user_roles__status=ORGANIZATION_ROLE_STATUS_ACTIVE,
3874+
)
38253875
).distinct()
38263876

38273877

contentcuration/contentcuration/tests/test_models.py

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,15 @@
1616
from contentcuration.constants import channel_history
1717
from contentcuration.constants import community_library_submission
1818
from contentcuration.constants import user_history
19+
from contentcuration.constants.organization_roles import ORGANIZATION_ADMIN
20+
from contentcuration.constants.organization_roles import ORGANIZATION_EDITOR
21+
from contentcuration.constants.organization_roles import (
22+
ORGANIZATION_ROLE_STATUS_ACTIVE,
23+
)
24+
from contentcuration.constants.organization_roles import (
25+
ORGANIZATION_ROLE_STATUS_PENDING,
26+
)
27+
from contentcuration.constants.organization_roles import ORGANIZATION_VIEWER
1928
from contentcuration.models import AssessmentItem
2029
from contentcuration.models import AuditedSpecialPermissionsLicense
2130
from contentcuration.models import Change
@@ -34,6 +43,8 @@
3443
from contentcuration.models import Language
3544
from contentcuration.models import License
3645
from contentcuration.models import object_storage_name
46+
from contentcuration.models import Organization
47+
from contentcuration.models import OrganizationRole
3748
from contentcuration.models import RecommendationsEvent
3849
from contentcuration.models import RecommendationsInteractionEvent
3950
from contentcuration.models import User
@@ -309,6 +320,165 @@ def create_change(server_rev, applied):
309320
self.assertEqual(channel.get_server_rev(), 2)
310321

311322

323+
class OrganizationTestCase(PermissionQuerysetTestCase):
324+
@property
325+
def base_queryset(self):
326+
return Organization.objects.all()
327+
328+
def test_filter_edit_queryset__admin_role(self):
329+
organization = testdata.organization()
330+
user = testdata.user()
331+
332+
queryset = Organization.filter_edit_queryset(self.base_queryset, user=user)
333+
self.assertQuerysetDoesNotContain(queryset, pk=organization.id)
334+
335+
OrganizationRole.objects.create(
336+
user=user,
337+
organization=organization,
338+
role=ORGANIZATION_ADMIN,
339+
status=ORGANIZATION_ROLE_STATUS_ACTIVE,
340+
)
341+
queryset = Organization.filter_edit_queryset(self.base_queryset, user=user)
342+
self.assertQuerysetContains(queryset, pk=organization.id)
343+
344+
def test_filter_edit_queryset__editor_role_cannot_edit(self):
345+
organization = testdata.organization()
346+
user = testdata.user()
347+
OrganizationRole.objects.create(
348+
user=user,
349+
organization=organization,
350+
role=ORGANIZATION_EDITOR,
351+
status=ORGANIZATION_ROLE_STATUS_ACTIVE,
352+
)
353+
354+
queryset = Organization.filter_edit_queryset(self.base_queryset, user=user)
355+
self.assertQuerysetDoesNotContain(queryset, pk=organization.id)
356+
357+
def test_filter_edit_queryset__pending_admin_cannot_edit(self):
358+
organization = testdata.organization()
359+
user = testdata.user()
360+
OrganizationRole.objects.create(
361+
user=user,
362+
organization=organization,
363+
role=ORGANIZATION_ADMIN,
364+
status=ORGANIZATION_ROLE_STATUS_PENDING,
365+
)
366+
367+
queryset = Organization.filter_edit_queryset(self.base_queryset, user=user)
368+
self.assertQuerysetDoesNotContain(queryset, pk=organization.id)
369+
370+
def test_filter_edit_queryset__anonymous(self):
371+
organization = testdata.organization()
372+
373+
queryset = Organization.filter_edit_queryset(
374+
self.base_queryset, user=self.anonymous_user
375+
)
376+
self.assertQuerysetDoesNotContain(queryset, pk=organization.id)
377+
378+
def test_filter_view_queryset__viewer_role(self):
379+
organization = testdata.organization()
380+
user = testdata.user()
381+
382+
queryset = Organization.filter_view_queryset(self.base_queryset, user=user)
383+
self.assertQuerysetDoesNotContain(queryset, pk=organization.id)
384+
385+
OrganizationRole.objects.create(
386+
user=user,
387+
organization=organization,
388+
role=ORGANIZATION_VIEWER,
389+
status=ORGANIZATION_ROLE_STATUS_ACTIVE,
390+
)
391+
queryset = Organization.filter_view_queryset(self.base_queryset, user=user)
392+
self.assertQuerysetContains(queryset, pk=organization.id)
393+
394+
def test_filter_view_queryset__anonymous(self):
395+
organization = testdata.organization()
396+
397+
queryset = Organization.filter_view_queryset(
398+
self.base_queryset, user=self.anonymous_user
399+
)
400+
self.assertQuerysetDoesNotContain(queryset, pk=organization.id)
401+
402+
403+
class InvitationOrganizationTestCase(PermissionQuerysetTestCase):
404+
@property
405+
def base_queryset(self):
406+
return Invitation.objects.all()
407+
408+
def _make_org_invitation(self):
409+
organization = testdata.organization()
410+
invitee = testdata.user(email="org-invitee@le.com")
411+
invitation = Invitation.objects.create(
412+
email=invitee.email, organization=organization
413+
)
414+
return organization, invitation
415+
416+
def test_filter_edit_queryset__organization_admin(self):
417+
organization, invitation = self._make_org_invitation()
418+
user = testdata.user()
419+
420+
queryset = Invitation.filter_edit_queryset(self.base_queryset, user=user)
421+
self.assertQuerysetDoesNotContain(queryset, pk=invitation.id)
422+
423+
OrganizationRole.objects.create(
424+
user=user,
425+
organization=organization,
426+
role=ORGANIZATION_ADMIN,
427+
status=ORGANIZATION_ROLE_STATUS_ACTIVE,
428+
)
429+
queryset = Invitation.filter_edit_queryset(self.base_queryset, user=user)
430+
self.assertQuerysetContains(queryset, pk=invitation.id)
431+
432+
def test_filter_edit_queryset__organization_editor_cannot_edit(self):
433+
organization, invitation = self._make_org_invitation()
434+
user = testdata.user()
435+
OrganizationRole.objects.create(
436+
user=user,
437+
organization=organization,
438+
role=ORGANIZATION_EDITOR,
439+
status=ORGANIZATION_ROLE_STATUS_ACTIVE,
440+
)
441+
442+
queryset = Invitation.filter_edit_queryset(self.base_queryset, user=user)
443+
self.assertQuerysetDoesNotContain(queryset, pk=invitation.id)
444+
445+
def test_filter_view_queryset__organization_editor(self):
446+
organization, invitation = self._make_org_invitation()
447+
user = testdata.user()
448+
449+
queryset = Invitation.filter_view_queryset(self.base_queryset, user=user)
450+
self.assertQuerysetDoesNotContain(queryset, pk=invitation.id)
451+
452+
OrganizationRole.objects.create(
453+
user=user,
454+
organization=organization,
455+
role=ORGANIZATION_EDITOR,
456+
status=ORGANIZATION_ROLE_STATUS_ACTIVE,
457+
)
458+
queryset = Invitation.filter_view_queryset(self.base_queryset, user=user)
459+
self.assertQuerysetContains(queryset, pk=invitation.id)
460+
461+
def test_filter_view_queryset__organization_viewer(self):
462+
organization, invitation = self._make_org_invitation()
463+
user = testdata.user()
464+
OrganizationRole.objects.create(
465+
user=user,
466+
organization=organization,
467+
role=ORGANIZATION_VIEWER,
468+
status=ORGANIZATION_ROLE_STATUS_ACTIVE,
469+
)
470+
471+
queryset = Invitation.filter_view_queryset(self.base_queryset, user=user)
472+
self.assertQuerysetContains(queryset, pk=invitation.id)
473+
474+
def test_filter_view_queryset__unrelated_user(self):
475+
organization, invitation = self._make_org_invitation()
476+
user = testdata.user()
477+
478+
queryset = Invitation.filter_view_queryset(self.base_queryset, user=user)
479+
self.assertQuerysetDoesNotContain(queryset, pk=invitation.id)
480+
481+
312482
class ContentNodeTestCase(PermissionQuerysetTestCase):
313483
@property
314484
def base_queryset(self):

contentcuration/contentcuration/tests/testdata.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@
1919
from contentcuration.constants import (
2020
community_library_submission as community_library_submission_constants,
2121
)
22+
from contentcuration.constants.organization_roles import ORGANIZATION_ADMIN
23+
from contentcuration.constants.organization_roles import (
24+
ORGANIZATION_ROLE_STATUS_ACTIVE,
25+
)
2226
from contentcuration.tests.utils import mixer
2327

2428

@@ -253,6 +257,18 @@ def channel(name="testchannel"):
253257
return channel
254258

255259

260+
def organization(name="Test Organization"):
261+
return cc.Organization.objects.create(name=name)
262+
263+
264+
def organization_role(
265+
user, organization, role=ORGANIZATION_ADMIN, status=ORGANIZATION_ROLE_STATUS_ACTIVE
266+
):
267+
return cc.OrganizationRole.objects.create(
268+
user=user, organization=organization, role=role, status=status
269+
)
270+
271+
256272
def random_string(chars=10):
257273
"""
258274
Generate a random string

0 commit comments

Comments
 (0)