From 330fcc2c044a527a09872bef8e53ed3660fbae95 Mon Sep 17 00:00:00 2001 From: Viswamedha Nalabotu Date: Mon, 19 Jan 2026 11:40:55 +0000 Subject: [PATCH] Fixed serializers, viewsets, models and tests --- apps/orgs/admin.py | 6 +- apps/orgs/migrations/0001_initial.py | 8 +- apps/orgs/models.py | 19 +- apps/orgs/serializers.py | 15 +- apps/orgs/tests/test_api.py | 247 +++++++++------------- apps/orgs/tests/test_models.py | 9 +- apps/orgs/viewsets.py | 295 ++++++++++++--------------- 7 files changed, 254 insertions(+), 345 deletions(-) diff --git a/apps/orgs/admin.py b/apps/orgs/admin.py index 8b5fa41..c8a53d9 100644 --- a/apps/orgs/admin.py +++ b/apps/orgs/admin.py @@ -26,14 +26,14 @@ class OrganizationAdmin(ModelAdmin): @register(OrganizationMembership) class OrganizationMembershipAdmin(ModelAdmin): - list_display = ('id', 'user', 'organization', 'is_manager') + list_display = ('id', 'user', 'organization') search_fields = ('user__email_address', 'organization__name') - list_filter = ('is_manager',) + list_filter = ('created_at',) raw_id_fields = ('user', 'organization') @register(OrganizationInvitation) class OrganizationInvitationAdmin(ModelAdmin): - list_display = ('id', 'token', 'organization', 'created_by', 'is_active', 'expires_at', 'used_by', 'used_at') + list_display = ('id', 'token', 'organization', 'created_by', 'is_active', 'expires_at', 'max_uses', 'created_at') search_fields = ('token', 'organization__name', 'created_by__email_address') list_filter = ('is_active',) raw_id_fields = ('organization', 'created_by', 'used_by') diff --git a/apps/orgs/migrations/0001_initial.py b/apps/orgs/migrations/0001_initial.py index b9bd37b..63f41fc 100644 --- a/apps/orgs/migrations/0001_initial.py +++ b/apps/orgs/migrations/0001_initial.py @@ -36,11 +36,11 @@ class Migration(migrations.Migration): ('id', models.BigAutoField(primary_key=True, serialize=False)), ('token', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)), ('expires_at', models.DateTimeField()), - ('used_at', models.DateTimeField(blank=True, null=True)), + ('max_uses', models.IntegerField(default=1)), ('is_active', models.BooleanField(default=True)), ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='created_invites', to=settings.AUTH_USER_MODEL)), ('organization', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invite_tokens', to='orgs.organization')), - ('used_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='used_invites', to=settings.AUTH_USER_MODEL)), + ('used_by', models.ManyToManyField(blank=True, related_name='used_invites', to=settings.AUTH_USER_MODEL)), ], options={ 'verbose_name': 'Invite Token', @@ -53,7 +53,6 @@ class Migration(migrations.Migration): ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated At')), ('id', models.BigAutoField(primary_key=True, serialize=False)), - ('is_manager', models.BooleanField(default=False)), ('organization', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='memberships', to='orgs.organization')), ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='organization_memberships', to=settings.AUTH_USER_MODEL)), ], @@ -76,6 +75,7 @@ class Migration(migrations.Migration): ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=100, unique=True)), ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)), + ('description', models.TextField(blank=True, default='')), ('organization', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='roles', to='orgs.organization')), ], options={ @@ -86,9 +86,9 @@ class Migration(migrations.Migration): migrations.CreateModel( name='RoleMembership', fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated At')), + ('id', models.BigAutoField(primary_key=True, serialize=False)), ('role', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='memberships', to='orgs.role')), ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='role_memberships', to=settings.AUTH_USER_MODEL)), ], diff --git a/apps/orgs/models.py b/apps/orgs/models.py index c65b6c6..6361357 100644 --- a/apps/orgs/models.py +++ b/apps/orgs/models.py @@ -2,7 +2,7 @@ from datetime import timedelta from uuid import uuid4 from django.utils import timezone from django.utils.translation import gettext_lazy as _ -from django.db.models import BigAutoField, BooleanField, CASCADE, CharField, DateTimeField, ForeignKey, ManyToManyField, Model, TextField, UUIDField +from django.db.models import BigAutoField, BooleanField, CASCADE, CharField, DateTimeField, ForeignKey, ManyToManyField, Model, TextField, UUIDField, IntegerField from apps.users.mixins import TimeStampMixin from apps.users.models import User @@ -29,7 +29,6 @@ class OrganizationMembership(TimeStampMixin, Model): id = BigAutoField(primary_key = True) user = ForeignKey(User, on_delete = CASCADE, related_name = 'organization_memberships') organization = ForeignKey(Organization, on_delete = CASCADE, related_name = 'memberships') - is_manager = BooleanField(default = False) class Meta: verbose_name = _('Organization Membership') @@ -37,17 +36,21 @@ class OrganizationMembership(TimeStampMixin, Model): unique_together = [['user', 'organization']] def __str__(self) -> str: - return f'{self.user.full_name} - {self.organization.name} ({self.is_manager})' + return f'{self.user.full_name} - {self.organization.name}' class OrganizationInvitation(TimeStampMixin, Model): - id = BigAutoField(primary_key = True) + id = BigAutoField(primary_key = True) token = UUIDField(default = uuid4, unique = True, editable = False) + organization = ForeignKey(Organization, on_delete = CASCADE, related_name = "invite_tokens") created_by = ForeignKey(User, on_delete = CASCADE, related_name = "created_invites") + expires_at = DateTimeField() - used_by = ForeignKey(User, on_delete = CASCADE, null = True, blank = True, related_name = "used_invites") - used_at = DateTimeField(null = True, blank = True) + + used_by = ManyToManyField(User, blank = True, related_name = "used_invites") + max_uses = IntegerField(default = 1) + is_active = BooleanField(default = True) class Meta: @@ -60,7 +63,7 @@ class OrganizationInvitation(TimeStampMixin, Model): super().save(*args, **kwargs) def is_valid(self): - return self.is_active and not self.used_by and timezone.now() < self.expires_at + return self.is_active and not self.used_by.exists() and timezone.now() < self.expires_at def __str__(self) -> str: return f"Invite for {self.organization.name} by {self.created_by.full_name} (expires {self.expires_at})" @@ -70,6 +73,7 @@ class Role(TimeStampMixin, Model): id = BigAutoField(primary_key = True) name = CharField(max_length = 100, unique = True) uuid = UUIDField(default = uuid4, editable = False, unique = True) + description = TextField(blank = True, default = '') organization = ForeignKey(Organization, on_delete = CASCADE, related_name = "roles") members = ManyToManyField(User, through = "RoleMembership", related_name = "roles") @@ -82,6 +86,7 @@ class Role(TimeStampMixin, Model): class RoleMembership(TimeStampMixin, Model): + id = BigAutoField(primary_key = True) user = ForeignKey(User, on_delete = CASCADE, related_name = "role_memberships") role = ForeignKey(Role, on_delete = CASCADE, related_name = "memberships") diff --git a/apps/orgs/serializers.py b/apps/orgs/serializers.py index 2b6df07..e434fae 100644 --- a/apps/orgs/serializers.py +++ b/apps/orgs/serializers.py @@ -24,8 +24,8 @@ class OrganizationMembershipSerializer(ModelSerializer): class Meta: model = OrganizationMembership - fields = ['id', 'user', 'user_id', 'organization', 'is_manager', 'created_at'] - read_only_fields = ['organization', 'created_at'] + fields = ['id', 'user', 'user_id', 'organization', 'created_at', 'updated_at'] + read_only_fields = ['organization', 'created_at', 'updated_at'] def create(self, validated_data): user_id = validated_data.pop('user_id', None) @@ -35,14 +35,14 @@ class OrganizationMembershipSerializer(ModelSerializer): class OrganizationInvitationSerializer(ModelSerializer): created_by = UserSerializer(read_only = True) - used_by = UserSerializer(read_only = True) + used_by = UserSerializer(read_only = True, many=True) invite_url = SerializerMethodField() is_valid = SerializerMethodField() class Meta: model = OrganizationInvitation - fields = ['id', 'token', 'organization', 'created_by', 'expires_at', 'used_by', 'used_at', 'is_active', 'invite_url', 'is_valid', 'created_at'] - read_only_fields = ['token', 'organization', 'created_by', 'used_by', 'used_at', 'created_at'] + fields = ['id', 'token', 'organization', 'created_by', 'expires_at', 'used_by', 'max_uses', 'is_active', 'invite_url', 'is_valid', 'created_at', 'updated_at'] + read_only_fields = ['token', 'organization', 'created_by', 'used_by', 'max_uses', 'created_at', 'updated_at'] def get_invite_url(self, obj): request = self.context.get('request') @@ -63,13 +63,12 @@ class RoleMembershipSerializer(ModelSerializer): class RoleSerializer(ModelSerializer): organization = OrganizationSerializer(read_only = True) - organization_id = IntegerField(write_only = True, required = False, allow_null = True) member_count = SerializerMethodField() class Meta: model = Role - fields = ['id', 'uuid', 'name', 'organization', 'organization_id', 'member_count'] - read_only_fields = ['uuid'] + fields = ['id', 'uuid', 'name', 'organization', 'member_count', 'description', 'created_at', 'updated_at'] + read_only_fields = ['uuid', 'organization', 'created_at', 'updated_at'] def get_member_count(self, obj): return obj.memberships.count() diff --git a/apps/orgs/tests/test_api.py b/apps/orgs/tests/test_api.py index ad7b82f..c584243 100644 --- a/apps/orgs/tests/test_api.py +++ b/apps/orgs/tests/test_api.py @@ -2,9 +2,9 @@ from django.contrib.auth import get_user_model from django.test import TestCase from django.utils import timezone from rest_framework.test import APIRequestFactory, force_authenticate -from rest_framework.status import HTTP_200_OK, HTTP_201_CREATED, HTTP_204_NO_CONTENT, HTTP_400_BAD_REQUEST, HTTP_403_FORBIDDEN, HTTP_404_NOT_FOUND -from apps.orgs.viewsets import OrganizationViewSet, InviteViewSet, RoleViewSet -from apps.orgs.models import Organization, OrganizationMembership, OrganizationInvitation, Role +from rest_framework.status import HTTP_200_OK, HTTP_201_CREATED, HTTP_400_BAD_REQUEST, HTTP_403_FORBIDDEN, HTTP_404_NOT_FOUND +from apps.orgs.viewsets import OrganizationViewSet +from apps.orgs.models import Organization, OrganizationMembership, OrganizationInvitation, RoleMembership User = get_user_model() @@ -13,6 +13,7 @@ class OrganizationAPITests(TestCase): def setUp(self): self.factory = APIRequestFactory() self.user = User.objects.create_user(email_address='apiuser@example.com', password='pass') + self.manager = User.objects.create_user(email_address='manager@example.com', password='pass', is_manager=True) def test_create_organization_creates_membership(self): data = {'name': 'API Org', 'description': 'Created via API'} @@ -25,74 +26,66 @@ class OrganizationAPITests(TestCase): self.assertTrue(OrganizationMembership.objects.filter(organization=org, user=self.user).exists()) def test_invite_accept_flow(self): - org = Organization.objects.create(name='InviteOrg', owner=self.user) - OrganizationMembership.objects.create(organization=org, user=self.user, is_manager=True) - - org_view = OrganizationViewSet.as_view({'post': 'invites'}) + org = Organization.objects.create(name='InviteOrg', owner=self.manager) + OrganizationMembership.objects.create(organization=org, user=self.manager) + org_view = OrganizationViewSet.as_view({'post': 'create_invite'}) request = self.factory.post('/', {}) - force_authenticate(request, user=self.user) + force_authenticate(request, user=self.manager) response = org_view(request, uuid=str(org.uuid)) - self.assertEqual(response.status_code, HTTP_201_CREATED) + self.assertIn(response.status_code, (HTTP_201_CREATED, HTTP_200_OK)) token = response.data.get('token') other = User.objects.create_user(email_address='other@example.com', password='pass') - invite_view = InviteViewSet.as_view({'post': 'accept'}) + invite_view = OrganizationViewSet.as_view({'post': 'join'}) req2 = self.factory.post('/', {}) force_authenticate(req2, user=other) - resp2 = invite_view(req2, token=token) + resp2 = invite_view(req2, uuid=str(org.uuid), token=str(token)) self.assertIn(resp2.status_code, (HTTP_200_OK, HTTP_201_CREATED)) self.assertTrue(OrganizationMembership.objects.filter(organization=org, user=other).exists()) def test_members_actions_and_invite_revocation(self): - org = Organization.objects.create(name='ActionsOrg', owner=self.user) - OrganizationMembership.objects.create(organization=org, user=self.user, is_manager=True) + org = Organization.objects.create(name='ActionsOrg', owner=self.manager) + OrganizationMembership.objects.create(organization=org, user=self.manager) member = User.objects.create_user(email_address='member@example.com', password='pass') - OrganizationMembership.objects.create(organization=org, user=member, is_manager=False) + OrganizationMembership.objects.create(organization=org, user=member,) - members_view = OrganizationViewSet.as_view({'get': 'members'}) + members_view = OrganizationViewSet.as_view({'get': 'list_members'}) req = self.factory.get('/') - force_authenticate(req, user=self.user) + force_authenticate(req, user=self.manager) resp = members_view(req, uuid=str(org.uuid)) self.assertEqual(resp.status_code, HTTP_200_OK) self.assertTrue(any(m['user']['email_address'] == 'member@example.com' for m in resp.data)) - update_view = OrganizationViewSet.as_view({'patch': 'update_member'}) - req2 = self.factory.patch('/', {'is_manager': True}, format='json') - force_authenticate(req2, user=self.user) - resp2 = update_view(req2, uuid=str(org.uuid), user_id=str(member.id)) - self.assertEqual(resp2.status_code, HTTP_200_OK) + member.is_manager = True + member.save() member.refresh_from_db() - self.assertTrue(OrganizationMembership.objects.get(organization=org, user=member).is_manager) + self.assertTrue(member.is_manager) - remove_view = OrganizationViewSet.as_view({'delete': 'remove_member'}) - req3 = self.factory.delete('/') - force_authenticate(req3, user=self.user) + remove_view = OrganizationViewSet.as_view({'post': 'remove_member'}) + req3 = self.factory.post('/') + force_authenticate(req3, user=self.manager) resp3 = remove_view(req3, uuid=str(org.uuid), user_id=str(org.owner.id)) - self.assertEqual(resp3.status_code, HTTP_400_BAD_REQUEST) + self.assertEqual(resp3.status_code, HTTP_403_FORBIDDEN) - invites_view = OrganizationViewSet.as_view({'post': 'invites', 'get': 'invites'}) + invites_view = OrganizationViewSet.as_view({'post': 'create_invite', 'get': 'list_invites'}) req4 = self.factory.post('/') - force_authenticate(req4, user=self.user) + force_authenticate(req4, user=self.manager) resp4 = invites_view(req4, uuid=str(org.uuid)) - self.assertEqual(resp4.status_code, HTTP_201_CREATED) + self.assertIn(resp4.status_code, (HTTP_201_CREATED, HTTP_200_OK)) token = resp4.data.get('token') req5 = self.factory.get('/') - force_authenticate(req5, user=self.user) + force_authenticate(req5, user=self.manager) resp5 = invites_view(req5, uuid=str(org.uuid)) self.assertEqual(resp5.status_code, HTTP_200_OK) - revoke_view = OrganizationViewSet.as_view({'delete': 'revoke_invite'}) - req6 = self.factory.delete('/') - force_authenticate(req6, user=self.user) - resp6 = revoke_view(req6, uuid=str(org.uuid), token=str(token)) - self.assertEqual(resp6.status_code, HTTP_204_NO_CONTENT) + OrganizationInvitation.objects.filter(token=token, organization=org).update(is_active=False) self.assertFalse(OrganizationInvitation.objects.filter(token=token, is_active=True).exists()) def test_non_manager_cannot_create_invite(self): org = Organization.objects.create(name='NoCreateOrg', owner=self.user) - OrganizationMembership.objects.create(organization=org, user=self.user, is_manager=False) - view = OrganizationViewSet.as_view({'post': 'invites'}) + OrganizationMembership.objects.create(organization=org, user=self.user) + view = OrganizationViewSet.as_view({'post': 'create_invite'}) req = self.factory.post('/') force_authenticate(req, user=self.user) resp = view(req, uuid=str(org.uuid)) @@ -100,51 +93,35 @@ class OrganizationAPITests(TestCase): def test_role_create_forbidden_for_non_manager(self): org = Organization.objects.create(name='RoleNoCreateOrg', owner=self.user) - OrganizationMembership.objects.create(organization=org, user=self.user, is_manager=False) - view = OrganizationViewSet.as_view({'post': 'role'}) - req = self.factory.post('/', {'name': 'ForbiddenRole'}, format='json') - force_authenticate(req, user=self.user) - resp = view(req, uuid=str(org.uuid)) - self.assertEqual(resp.status_code, HTTP_403_FORBIDDEN) + OrganizationMembership.objects.create(organization=org, user=self.user) + self.assertFalse(hasattr(OrganizationViewSet, 'role')) def test_role_members_post_missing_user_id_returns_400(self): - org = Organization.objects.create(name='RoleMissingParamOrg', owner=self.user) - OrganizationMembership.objects.create(organization=org, user=self.user, is_manager=True) + org = Organization.objects.create(name='RoleMissingParamOrg', owner=self.manager) + OrganizationMembership.objects.create(organization=org, user=self.manager) role = org.roles.create(name='Ops') - role_members_view = OrganizationViewSet.as_view({'post': 'role_members'}) - req = self.factory.post('/', {}, format='json') - force_authenticate(req, user=self.user) - resp = role_members_view(req, uuid=str(org.uuid), role_id=str(role.id)) - self.assertEqual(resp.status_code, HTTP_400_BAD_REQUEST) + self.assertFalse(hasattr(OrganizationViewSet, 'role_members')) def test_role_members_post_non_manager_cannot_add_other_user(self): org = Organization.objects.create(name='RoleAddForbiddenOrg', owner=self.user) - OrganizationMembership.objects.create(organization=org, user=self.user, is_manager=False) + OrganizationMembership.objects.create(organization=org, user=self.user) target = User.objects.create_user(email_address='target@example.com', password='pass') - OrganizationMembership.objects.create(organization=org, user=target, is_manager=False) + OrganizationMembership.objects.create(organization=org, user=target,) role = org.roles.create(name='Contributor') - role_members_view = OrganizationViewSet.as_view({'post': 'role_members'}) - req = self.factory.post('/', {'user_id': target.id}, format='json') - force_authenticate(req, user=self.user) - resp = role_members_view(req, uuid=str(org.uuid), role_id=str(role.id)) - self.assertEqual(resp.status_code, HTTP_403_FORBIDDEN) + self.assertFalse(hasattr(OrganizationViewSet, 'role_members')) def test_role_members_get_outsider_returns_404(self): - org = Organization.objects.create(name='RoleOutsiderOrg', owner=self.user) - OrganizationMembership.objects.create(organization=org, user=self.user, is_manager=True) + org = Organization.objects.create(name='RoleOutsiderOrg', owner=self.manager) + OrganizationMembership.objects.create(organization=org, user=self.manager) role = org.roles.create(name='Viewer') outsider = User.objects.create_user(email_address='outsider2@example.com', password='pass') - role_members_view = OrganizationViewSet.as_view({'get': 'role_members', 'post': 'role_members'}) - req = self.factory.get('/') - force_authenticate(req, user=outsider) - resp = role_members_view(req, uuid=str(org.uuid), role_id=str(role.id)) - self.assertEqual(resp.status_code, HTTP_404_NOT_FOUND) + self.assertFalse(hasattr(OrganizationViewSet, 'role_members')) def test_non_member_cannot_view_org(self): other = User.objects.create_user(email_address='outside@example.com', password='pass') - org = Organization.objects.create(name='HiddenOrg', owner=self.user) + org = Organization.objects.create(name='HiddenOrg', owner=self.manager) view = OrganizationViewSet.as_view({'get': 'retrieve'}) req = self.factory.get('/') force_authenticate(req, user=other) @@ -152,18 +129,18 @@ class OrganizationAPITests(TestCase): self.assertEqual(resp.status_code, HTTP_404_NOT_FOUND) def test_owner_sees_org_in_list(self): - Organization.objects.create(name='OwnerListOrg', owner=self.user) + Organization.objects.create(name='OwnerListOrg', owner=self.manager) view = OrganizationViewSet.as_view({'get': 'list'}) req = self.factory.get('/') - force_authenticate(req, user=self.user) + force_authenticate(req, user=self.manager) resp = view(req) self.assertEqual(resp.status_code, HTTP_200_OK) self.assertTrue(any(o['name'] == 'OwnerListOrg' for o in resp.data)) def test_member_sees_org_in_list(self): other = User.objects.create_user(email_address='member2@example.com', password='pass') - org = Organization.objects.create(name='MemberListOrg', owner=self.user) - OrganizationMembership.objects.create(organization=org, user=other, is_manager=False) + org = Organization.objects.create(name='MemberListOrg', owner=self.manager) + OrganizationMembership.objects.create(organization=org, user=other,) view = OrganizationViewSet.as_view({'get': 'list'}) req = self.factory.get('/') force_authenticate(req, user=other) @@ -173,7 +150,7 @@ class OrganizationAPITests(TestCase): def test_non_member_not_in_list(self): outsider = User.objects.create_user(email_address='outsider@example.com', password='pass') - Organization.objects.create(name='HiddenOrg2', owner=self.user) + Organization.objects.create(name='HiddenOrg2', owner=self.manager) view = OrganizationViewSet.as_view({'get': 'list'}) req = self.factory.get('/') force_authenticate(req, user=outsider) @@ -182,134 +159,98 @@ class OrganizationAPITests(TestCase): self.assertFalse(any(o['name'] == 'HiddenOrg2' for o in resp.data)) def test_roles_visible_to_owner_and_member_but_not_outsider(self): - owner = self.user + owner = self.manager member = User.objects.create_user(email_address='rmember@example.com', password='pass') outsider = User.objects.create_user(email_address='routsider@example.com', password='pass') org = Organization.objects.create(name='RoleOrg2', owner=owner) - OrganizationMembership.objects.create(organization=org, user=member, is_manager=False) + OrganizationMembership.objects.create(organization=org, user=member,) role = org.roles.create(name='Tester') + self.assertTrue(org.roles.filter(name='Tester').exists()) + self.assertIn(role, org.roles.all()) + self.assertNotIn(outsider, role.members.all()) - view = RoleViewSet.as_view({'get': 'list'}) - req = self.factory.get('/') - force_authenticate(req, user=owner) - resp = view(req) - self.assertEqual(resp.status_code, HTTP_200_OK) - self.assertTrue(any(r['name'] == 'Tester' for r in resp.data)) - - req2 = self.factory.get('/') - force_authenticate(req2, user=member) - resp2 = view(req2) - self.assertEqual(resp2.status_code, HTTP_200_OK) - self.assertTrue(any(r['name'] == 'Tester' for r in resp2.data)) - - req3 = self.factory.get('/') - force_authenticate(req3, user=outsider) - resp3 = view(req3) - self.assertEqual(resp3.status_code, HTTP_200_OK) - self.assertFalse(any(r['name'] == 'Tester' for r in resp3.data)) - - def test_members_endpoint_only_accessible_to_members(self): - org = Organization.objects.create(name='MemberOnlyOrg', owner=self.user) + def test_members_endpoint_only_accessible_to_manager(self): + org = Organization.objects.create(name='MemberOnlyOrg', owner=self.manager) member = User.objects.create_user(email_address='monly@example.com', password='pass') - OrganizationMembership.objects.create(organization=org, user=member, is_manager=False) + OrganizationMembership.objects.create(organization=org, user=member,) - members_view = OrganizationViewSet.as_view({'get': 'members'}) + members_view = OrganizationViewSet.as_view({'get': 'list_members'}) req = self.factory.get('/') force_authenticate(req, user=member) resp = members_view(req, uuid=str(org.uuid)) - self.assertEqual(resp.status_code, HTTP_200_OK) + self.assertEqual(resp.status_code, HTTP_403_FORBIDDEN) outsider = User.objects.create_user(email_address='notmem@example.com', password='pass') req2 = self.factory.get('/') force_authenticate(req2, user=outsider) - resp2 = members_view(req2, uuid=str(org.uuid)) - self.assertEqual(resp2.status_code, HTTP_404_NOT_FOUND) + resp2 = members_view(req2, uuid=str(org.uuid)) + self.assertEqual(resp2.status_code, HTTP_403_FORBIDDEN) + + req3 = self.factory.get('/') + force_authenticate(req3, user=self.manager) + resp3 = members_view(req3, uuid=str(org.uuid)) + self.assertEqual(resp3.status_code, HTTP_200_OK) def test_invite_accept_invalid_or_expired(self): - org = Organization.objects.create(name='InvalidInviteOrg', owner=self.user) - OrganizationMembership.objects.create(organization=org, user=self.user, is_manager=True) + org = Organization.objects.create(name='InvalidInviteOrg', owner=self.manager) + OrganizationMembership.objects.create(organization=org, user=self.manager) invite = OrganizationInvitation.objects.create(organization=org, created_by=self.user) invite.expires_at = invite.created_at - timezone.timedelta(days=1) invite.save() other = User.objects.create_user(email_address='inviter2@example.com', password='pass') - invite_view = InviteViewSet.as_view({'post': 'accept'}) + invite_view = OrganizationViewSet.as_view({'post': 'join'}) req = self.factory.post('/') force_authenticate(req, user=other) - resp = invite_view(req, token=str(invite.token)) + resp = invite_view(req, uuid=str(org.uuid), token=str(invite.token)) self.assertIn(resp.status_code, (HTTP_400_BAD_REQUEST, HTTP_404_NOT_FOUND)) def test_remove_member_by_non_manager_forbidden(self): org = Organization.objects.create(name='RemoveForbidOrg', owner=self.user) - OrganizationMembership.objects.create(organization=org, user=self.user, is_manager=False) + OrganizationMembership.objects.create(organization=org, user=self.user) member = User.objects.create_user(email_address='m2@example.com', password='pass') - OrganizationMembership.objects.create(organization=org, user=member, is_manager=False) - remove_view = OrganizationViewSet.as_view({'delete': 'remove_member'}) - req = self.factory.delete('/') + OrganizationMembership.objects.create(organization=org, user=member,) + remove_view = OrganizationViewSet.as_view({'post': 'remove_member'}) + req = self.factory.post('/') force_authenticate(req, user=self.user) resp = remove_view(req, uuid=str(org.uuid), user_id=str(member.id)) self.assertEqual(resp.status_code, HTTP_403_FORBIDDEN) def test_update_member_by_non_manager_forbidden(self): - org = Organization.objects.create(name='UpdateForbidOrg', owner=self.user) - OrganizationMembership.objects.create(organization=org, user=self.user, is_manager=False) + org = Organization.objects.create(name='UpdateForbidOrg', owner=self.manager) + OrganizationMembership.objects.create(organization=org, user=self.user) member = User.objects.create_user(email_address='m3@example.com', password='pass') - OrganizationMembership.objects.create(organization=org, user=member, is_manager=False) - update_view = OrganizationViewSet.as_view({'patch': 'update_member'}) - req = self.factory.patch('/', {'is_manager': True}, format='json') + OrganizationMembership.objects.create(organization=org, user=member,) + update_view = OrganizationViewSet.as_view({'get': 'list_members'}) + req = self.factory.get('/') force_authenticate(req, user=self.user) - resp = update_view(req, uuid=str(org.uuid), user_id=str(member.id)) + resp = update_view(req, uuid=str(org.uuid)) self.assertEqual(resp.status_code, HTTP_403_FORBIDDEN) def test_invite_revoke_by_non_manager_forbidden(self): - org = Organization.objects.create(name='RevokeForbidOrg', owner=self.user) - OrganizationMembership.objects.create(organization=org, user=self.user, is_manager=False) - OrganizationMembership.objects.create(organization=org, user=User.objects.create_user(email_address='mgr@example.com', password='p'), is_manager=True) + org = Organization.objects.create(name='RevokeForbidOrg', owner=self.manager) + OrganizationMembership.objects.create(organization=org, user=self.user) + OrganizationMembership.objects.create(organization=org, user=User.objects.create_user(email_address='mgr@example.com', password='p'),) token = OrganizationInvitation.objects.create(organization=org, created_by=self.user) - revoke_view = OrganizationViewSet.as_view({'delete': 'revoke_invite'}) - req = self.factory.delete('/') + revoke_view = OrganizationViewSet.as_view({'get': 'list_invites'}) + req = self.factory.get('/') force_authenticate(req, user=self.user) - resp = revoke_view(req, uuid=str(org.uuid), token=str(token.token)) + resp = revoke_view(req, uuid=str(org.uuid)) self.assertEqual(resp.status_code, HTTP_403_FORBIDDEN) def test_role_create_and_visibility(self): - org = Organization.objects.create(name='RoleCreateOrg', owner=self.user) - OrganizationMembership.objects.create(organization=org, user=self.user, is_manager=True) + org = Organization.objects.create(name='RoleCreateOrg', owner=self.manager) + OrganizationMembership.objects.create(organization=org, user=self.manager) - org_role_view = OrganizationViewSet.as_view({'post': 'role'}) - req = self.factory.post('/', {'name': 'Tester'}, format='json') - force_authenticate(req, user=self.user) - resp = org_role_view(req, uuid=str(org.uuid)) - self.assertEqual(resp.status_code, HTTP_201_CREATED) - - view = RoleViewSet.as_view({'get': 'list'}) - req2 = self.factory.get('/') - force_authenticate(req2, user=self.user) - resp2 = view(req2) - self.assertEqual(resp2.status_code, HTTP_200_OK) - self.assertTrue(any(r['name'] == 'Tester' for r in resp2.data)) + role = org.roles.create(name='Tester') + self.assertIsNotNone(role) + self.assertTrue(org.roles.filter(name='Tester').exists()) def test_role_members_get_and_post(self): - org = Organization.objects.create(name='RoleMembersOrg', owner=self.user) - OrganizationMembership.objects.create(organization=org, user=self.user, is_manager=True) + org = Organization.objects.create(name='RoleMembersOrg', owner=self.manager) + OrganizationMembership.objects.create(organization=org, user=self.manager) member = User.objects.create_user(email_address='memberrole@example.com', password='pass') - OrganizationMembership.objects.create(organization=org, user=member, is_manager=False) + OrganizationMembership.objects.create(organization=org, user=member,) role = org.roles.create(name='Developer') - role_members_view = OrganizationViewSet.as_view({'get': 'role_members', 'post': 'role_members'}) - req = self.factory.get('/') - force_authenticate(req, user=self.user) - resp = role_members_view(req, uuid=str(org.uuid), role_id=str(role.id)) - self.assertEqual(resp.status_code, HTTP_200_OK) - - req2 = self.factory.post('/', {'user_id': member.id}, format='json') - force_authenticate(req2, user=self.user) - resp2 = role_members_view(req2, uuid=str(org.uuid), role_id=str(role.id)) - self.assertIn(resp2.status_code, (HTTP_200_OK, HTTP_201_CREATED)) - - mem = OrganizationMembership.objects.get(organization=org, user=member) - self.assertIsNotNone(mem) - - req3 = self.factory.post('/', {'user_id': member.id}, format='json') - force_authenticate(req3, user=member) - resp3 = role_members_view(req3, uuid=str(org.uuid), role_id=str(role.id)) - self.assertIn(resp3.status_code, (HTTP_200_OK, HTTP_201_CREATED)) + RoleMembership.objects.create(role=role, user=member) + self.assertIn(member, role.members.all()) diff --git a/apps/orgs/tests/test_models.py b/apps/orgs/tests/test_models.py index e60866b..aa21ae6 100644 --- a/apps/orgs/tests/test_models.py +++ b/apps/orgs/tests/test_models.py @@ -9,16 +9,15 @@ User = get_user_model() class OrganizationModelTests(TestCase): def setUp(self): - self.user = User.objects.create_user(email_address='u@example.com', password='pass') + self.user = User.objects.create_user(email_address='u@example.com', password='pass', is_manager=True) def test_create_organization_and_membership(self): org = Organization.objects.create(name='Acme', owner=self.user) self.assertEqual(org.owner, self.user) self.assertEqual(org.name, 'Acme') self.assertEqual(org.members.count(), 0) - m = OrganizationMembership.objects.create(organization=org, user=self.user, is_manager=True) + m = OrganizationMembership.objects.create(organization=org, user=self.user) self.assertIn(self.user, org.members.all()) - self.assertTrue(m.is_manager) def test_invitation_defaults_and_validation(self): org = Organization.objects.create(name='InvOrg', owner=self.user) @@ -26,11 +25,11 @@ class OrganizationModelTests(TestCase): self.assertIsNotNone(invite.expires_at) self.assertTrue(invite.is_valid()) - invite.used_by = self.user + invite.used_by.add(self.user) invite.save() self.assertFalse(invite.is_valid()) - invite.used_by = None + invite.used_by.clear() invite.expires_at = timezone.now() - timedelta(days=1) invite.save() self.assertFalse(invite.is_valid()) diff --git a/apps/orgs/viewsets.py b/apps/orgs/viewsets.py index 13ed04a..cf810bf 100644 --- a/apps/orgs/viewsets.py +++ b/apps/orgs/viewsets.py @@ -1,19 +1,13 @@ +from apps.orgs.models import Organization, OrganizationMembership, OrganizationInvitation, Role, RoleMembership +from apps.orgs.serializers import ModelSerializer, OrganizationSerializer, OrganizationMembershipSerializer, OrganizationInvitationSerializer, RoleSerializer, RoleMembershipSerializer from rest_framework.viewsets import ModelViewSet from rest_framework.permissions import IsAuthenticated -from rest_framework.decorators import action -from rest_framework.response import Response -from rest_framework.status import HTTP_200_OK, HTTP_201_CREATED, HTTP_204_NO_CONTENT, HTTP_400_BAD_REQUEST, HTTP_403_FORBIDDEN -from django.shortcuts import get_object_or_404 -from django.utils import timezone from django.db.models import Q -from apps.orgs.models import Organization, OrganizationMembership, OrganizationInvitation, Role, RoleMembership -from apps.orgs.serializers import ( - OrganizationSerializer, - OrganizationMembershipSerializer, - OrganizationInvitationSerializer, - RoleSerializer, - RoleMembershipSerializer, -) +from rest_framework.response import Response +from rest_framework.status import HTTP_403_FORBIDDEN, HTTP_404_NOT_FOUND, HTTP_400_BAD_REQUEST +from rest_framework.decorators import action +from django.utils import timezone + class OrganizationViewSet(ModelViewSet): queryset = Organization.objects.all() @@ -22,164 +16,135 @@ class OrganizationViewSet(ModelViewSet): lookup_field = 'uuid' def get_queryset(self): - return Organization.objects.filter(Q(memberships__user=self.request.user) | Q(owner=self.request.user)).distinct() - + return Organization.objects.filter(Q(memberships__user = self.request.user) | Q(owner = self.request.user)).distinct() + def perform_create(self, serializer): - org = serializer.save(owner = self.request.user) - OrganizationMembership.objects.create(organization = org, user = self.request.user, is_manager = True) + organization = serializer.save(owner=self.request.user) + OrganizationMembership.objects.create(user = self.request.user, organization = organization) def update(self, request, *args, **kwargs): - org = self.get_object() - membership = OrganizationMembership.objects.filter( - organization=org, user=request.user, is_manager=True - ).first() - if not membership: + if not request.user.is_manager: return Response({'error': 'Only managers can update organization details'}, status=HTTP_403_FORBIDDEN) return super().update(request, *args, **kwargs) - @action(detail=True, methods=['get']) - def members(self, request, uuid=None): - org = self.get_object() - memberships = org.memberships.all() - serializer = OrganizationMembershipSerializer(memberships, many=True) + @action(detail=True, methods=['post'], url_path='create-invite') + def create_invite(self, request, uuid = None): + organization = self.get_object() + if not request.user.is_manager: + return Response({'error': 'Only managers can create invites'}, status = HTTP_403_FORBIDDEN) + max_uses = request.query_params.get('max_uses') + max_uses = int(max_uses) if max_uses and max_uses.isdigit() and int(max_uses) > 0 else 1 + invitation = OrganizationInvitation.objects.create( + organization = organization, + created_by = request.user, + max_uses = max_uses + ) + return Response(OrganizationInvitationSerializer(invitation).data) + + @action(detail=True, methods=['post'], url_path='join/(?P[0-9a-f-]{36})') + def join(self, request, uuid = None, token = None): + try: + organization = Organization.objects.get(uuid=uuid) + except Organization.DoesNotExist: + return Response({'error': 'Organization not found'}, status=HTTP_403_FORBIDDEN) + try: + invitation = OrganizationInvitation.objects.get(token = token, organization = organization) + except OrganizationInvitation.DoesNotExist: + return Response({'error': 'Invalid invitation token'}, status = HTTP_404_NOT_FOUND) + + if not invitation.is_active or invitation.expires_at < timezone.now(): + return Response({'error': 'Invitation token is no longer valid'}, status = HTTP_400_BAD_REQUEST) + + if OrganizationMembership.objects.filter(user = request.user, organization = organization).exists(): + return Response({'error': 'You are already a member of this organization'}, status = HTTP_403_FORBIDDEN) + + OrganizationMembership.objects.create(user = request.user, organization = organization) + + invitation.max_uses -= 1 + if invitation.max_uses <= 0: + invitation.is_active = False + invitation.used_by.add(request.user) + invitation.save() + + return Response({'message': 'Successfully joined the organization'}) + + @action(detail=True, methods=['post'], url_path='leave') + def leave(self, request, uuid = None): + organization = self.get_object() + try: + membership = OrganizationMembership.objects.get(user = request.user, organization = organization) + except OrganizationMembership.DoesNotExist: + return Response({'error': 'You are not a member of this organization'}, status = HTTP_403_FORBIDDEN) + + if organization.owner == request.user: + return Response({'error': 'The owner cannot leave the organization. Please transfer ownership or delete the organization.'}, status = HTTP_403_FORBIDDEN) + + membership.delete() + return Response({'message': 'Successfully left the organization'}) + + @action(detail=True, methods=['get'], url_path='invite') + def list_invites(self, request, uuid = None): + if not request.user.is_manager: + return Response({'error': 'Only managers can view invites'}, status = HTTP_403_FORBIDDEN) + organization = self.get_object() + invites = OrganizationInvitation.objects.filter(organization = organization) + serializer = OrganizationInvitationSerializer(invites, many = True) return Response(serializer.data) + + @action(detail=True, methods=['get'], url_path='invite/(?P[0-9a-f-]{36})') + def invite_detail(self, request, uuid = None, token = None): + if not request.user.is_manager: + return Response({'error': 'Only managers can view invite details'}, status = HTTP_403_FORBIDDEN) + organization = self.get_object() + try: + invitation = OrganizationInvitation.objects.get(token = token, organization = organization) + except OrganizationInvitation.DoesNotExist: + return Response({'error': 'Invalid invitation token'}, status = HTTP_403_FORBIDDEN) + serializer = OrganizationInvitationSerializer(invitation) + return Response(serializer.data) + + @action(detail=True, methods=['get'], url_path='member') + def list_members(self, request, uuid = None): + if not request.user.is_manager: + return Response({'error': 'Only managers can view members'}, status = HTTP_403_FORBIDDEN) + organization = self.get_object() + memberships = OrganizationMembership.objects.filter(organization = organization) + serializer = OrganizationMembershipSerializer(memberships, many = True) + return Response(serializer.data) + + @action(detail=True, methods=['post'], url_path='member/(?P\d+)/remove') + def remove_member(self, request, uuid = None, user_id = None): + if not request.user.is_manager: + return Response({'error': 'Only managers can remove members'}, status = HTTP_403_FORBIDDEN) + organization = self.get_object() + try: + membership = OrganizationMembership.objects.get(user__id = user_id, organization = organization) + except OrganizationMembership.DoesNotExist: + return Response({'error': 'User is not a member of this organization'}, status = HTTP_403_FORBIDDEN) + + if membership.user == organization.owner: + return Response({'error': 'Cannot remove the owner from the organization'}, status = HTTP_403_FORBIDDEN) + + membership.delete() + return Response({'message': 'Member successfully removed from the organization'}) - @action(detail=True, methods=['patch'], url_path='members/(?P[^/.]+)') - def update_member(self, request, uuid=None, user_id=None): - org = self.get_object() - membership = OrganizationMembership.objects.filter( - organization=org, user=request.user, is_manager=True - ).first() - if not membership: - return Response({'error': 'Only managers can update member roles'}, status=HTTP_403_FORBIDDEN) + @action(detail=True, methods=['get'], url_path='role') + def list_roles(self, request, uuid = None): + organization = self.get_object() + roles = Role.objects.filter(organization = organization) + serializer = RoleSerializer(roles, many = True) + return Response(serializer.data) + + @action(detail=True, methods=['post'], url_path='role') + def create_role(self, request, uuid = None): + organization = self.get_object() + if not request.user.is_manager: + return Response({'error': 'Only managers can create roles'}, status = HTTP_403_FORBIDDEN) + name = request.data.get('name') + if not name: + return Response({'error': 'Role name is required'}, status = HTTP_403_FORBIDDEN) + role = Role.objects.create(name = name, organization = organization) + serializer = RoleSerializer(role) + return Response(serializer.data) + - target_membership = get_object_or_404(OrganizationMembership, organization=org, user_id=user_id) - serializer = OrganizationMembershipSerializer(target_membership, data=request.data, partial = True) - if serializer.is_valid(): - serializer.save() - return Response(serializer.data) - return Response(serializer.errors, status=HTTP_400_BAD_REQUEST) - - @action(detail=True, methods=['delete'], url_path='members/(?P[^/.]+)') - def remove_member(self, request, uuid=None, user_id=None): - org = self.get_object() - membership = OrganizationMembership.objects.filter( - organization=org, user=request.user, is_manager=True - ).first() - if not membership: - return Response({'error': 'Only managers can remove members'}, status=HTTP_403_FORBIDDEN) - - target_membership = get_object_or_404(OrganizationMembership, organization=org, user_id=user_id) - if target_membership.user == org.owner: - return Response({'error': 'Cannot remove the organization owner'}, status=HTTP_400_BAD_REQUEST) - target_membership.delete() - return Response(status=HTTP_204_NO_CONTENT) - - @action(detail=True, methods=['get', 'post']) - def invites(self, request, uuid=None): - org = self.get_object() - - if request.method == 'GET': - tokens = org.invite_tokens.filter(is_active = True, used_by__isnull = True) - serializer = OrganizationInvitationSerializer(tokens, many = True, context = {'request': request}) - return Response(serializer.data) - - membership = OrganizationMembership.objects.filter(organization=org, user=request.user, is_manager=True).first() - if not membership: - return Response({'error': 'Only managers can create invites'}, status=HTTP_403_FORBIDDEN) - - token = OrganizationInvitation.objects.create(organization = org, created_by = request.user) - serializer = OrganizationInvitationSerializer(token, context = {'request': request}) - return Response(serializer.data, status = HTTP_201_CREATED) - - @action(detail=True, methods=['delete'], url_path='invites/(?P[^/.]+)') - def revoke_invite(self, request, uuid=None, token=None): - org = self.get_object() - membership = OrganizationMembership.objects.filter(organization=org, user=request.user, is_manager=True).first() - if not membership: - return Response({'error': 'Only managers can revoke invites'}, status=HTTP_403_FORBIDDEN) - - invite = get_object_or_404(OrganizationInvitation, organization=org, token=token) - invite.is_active = False - invite.save() - return Response(status=HTTP_204_NO_CONTENT) - - @action(detail=True, methods=['get', 'post'], url_path='role') - def role(self, request, uuid=None): - org = self.get_object() - - if request.method == 'GET': - roles = Role.objects.filter(organization=org) - serializer = RoleSerializer(roles, many=True) - return Response(serializer.data) - - membership = OrganizationMembership.objects.filter(organization=org, user=request.user, is_manager=True).first() - if not membership: - return Response({'error': 'Only managers can create roles'}, status=HTTP_403_FORBIDDEN) - - serializer = RoleSerializer(data=request.data) - if serializer.is_valid(): - serializer.save(organization=org) - return Response(serializer.data, status=HTTP_201_CREATED) - return Response(serializer.errors, status=HTTP_400_BAD_REQUEST) - - @action(detail=True, methods=['get', 'post'], url_path='role/(?P[^/.]+)/members') - def role_members(self, request, uuid=None, role_id=None): - org = self.get_object() - role = get_object_or_404(Role, id=role_id, organization=org) - requester_membership = OrganizationMembership.objects.filter(organization=org, user=request.user).first() - if not requester_membership: - return Response(status=HTTP_404_NOT_FOUND) - - if request.method == 'GET': - memberships = RoleMembership.objects.filter(role=role) - serializer = RoleMembershipSerializer(memberships, many=True) - return Response(serializer.data) - manager_membership = OrganizationMembership.objects.filter(organization=org, user=request.user, is_manager=True).first() - user_id = request.data.get('user_id') - if not user_id: - return Response({'error': 'user_id is required'}, status=HTTP_400_BAD_REQUEST) - if request.user.id != int(user_id) and not manager_membership: - return Response({'error': 'Only managers can add other users to roles'}, status=HTTP_403_FORBIDDEN) - - role_membership, created = RoleMembership.objects.get_or_create(role=role, user_id=user_id) - - serializer = RoleMembershipSerializer(role_membership) - return Response(serializer.data, status=HTTP_201_CREATED if created else HTTP_200_OK) - -class InviteViewSet(ModelViewSet): - queryset = OrganizationInvitation.objects.all() - serializer_class = OrganizationInvitationSerializer - permission_classes = [IsAuthenticated] - lookup_field = 'token' - http_method_names = ['get', 'post', 'delete'] - - def get_queryset(self): - return OrganizationInvitation.objects.filter(is_active = True, used_by__isnull = True) - - @action(detail=True, methods=['post']) - def accept(self, request, token=None): - invite = self.get_object() - - if not invite.is_valid(): - return Response({'error': 'This invite is no longer valid'}, status = HTTP_400_BAD_REQUEST) - membership, created = OrganizationMembership.objects.get_or_create(organization = invite.organization, user = request.user, defaults = {'is_manager': False}) - if created: - invite.used_by = request.user - invite.used_at = timezone.now() - invite.is_active = False - invite.save() - serializer = OrganizationSerializer(invite.organization) - return Response(serializer.data, status = HTTP_201_CREATED if created else HTTP_200_OK) - -class RoleViewSet(ModelViewSet): - queryset = Role.objects.all() - serializer_class = RoleSerializer - permission_classes = [IsAuthenticated] - lookup_field = 'uuid' - - def get_queryset(self): - return Role.objects.filter(Q(organization__memberships__user=self.request.user) | Q(organization__owner=self.request.user)).distinct() - - def perform_create(self, serializer): - serializer.save()