Fixed serializers, viewsets, models and tests

This commit is contained in:
Viswamedha Nalabotu 2026-01-19 11:40:55 +00:00
parent 22d0243239
commit 330fcc2c04
7 changed files with 254 additions and 345 deletions

View file

@ -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')

View file

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

View file

@ -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)
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")

View file

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

View file

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

View file

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

View file

@ -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()
@ -25,161 +19,132 @@ class OrganizationViewSet(ModelViewSet):
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()
@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<token>[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<token>[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=['patch'], url_path='members/(?P<user_id>[^/.]+)')
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)
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<user_id>[^/.]+)')
@action(detail=True, methods=['post'], url_path='member/(?P<user_id>\d+)/remove')
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:
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)
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)
if membership.user == organization.owner:
return Response({'error': 'Cannot remove the owner from the organization'}, status = HTTP_403_FORBIDDEN)
@action(detail=True, methods=['get', 'post'])
def invites(self, request, uuid=None):
org = self.get_object()
membership.delete()
return Response({'message': 'Member successfully removed from the organization'})
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<token>[^/.]+)')
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)
@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)
membership = OrganizationMembership.objects.filter(organization=org, user=request.user, is_manager=True).first()
if not membership:
@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)
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<role_id>[^/.]+)/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)
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)
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()