Fixed error cases, tests, viewsets and api paths

This commit is contained in:
Viswamedha Nalabotu 2026-01-20 02:59:22 +00:00
parent 0a07e408c5
commit e99c07ef19
9 changed files with 94 additions and 53 deletions

View file

@ -1,5 +1,3 @@
# Generated by Django 5.2.10 on 2026-01-17 16:12
import django.db.models.deletion import django.db.models.deletion
import uuid import uuid
from django.conf import settings from django.conf import settings

View file

@ -33,10 +33,10 @@ class OrganizationMembershipAdmin(ModelAdmin):
@register(OrganizationInvitation) @register(OrganizationInvitation)
class OrganizationInvitationAdmin(ModelAdmin): class OrganizationInvitationAdmin(ModelAdmin):
list_display = ('id', 'token', 'organization', 'created_by', 'is_active', 'expires_at', 'max_uses', 'created_at') list_display = ('id', 'token', 'organization', 'created_by', 'is_active', 'expires_at', 'max_uses', 'created_at', 'uses')
search_fields = ('token', 'organization__name', 'created_by__email_address') search_fields = ('token', 'organization__name', 'created_by__email_address')
list_filter = ('is_active',) list_filter = ('is_active',)
raw_id_fields = ('organization', 'created_by', 'used_by') raw_id_fields = ('organization', 'created_by')
readonly_fields = ('token', 'created_at') readonly_fields = ('token', 'created_at')
@register(Role) @register(Role)

View file

@ -36,11 +36,11 @@ class Migration(migrations.Migration):
('id', models.BigAutoField(primary_key=True, serialize=False)), ('id', models.BigAutoField(primary_key=True, serialize=False)),
('token', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)), ('token', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
('expires_at', models.DateTimeField()), ('expires_at', models.DateTimeField()),
('uses', models.IntegerField(default=0)),
('max_uses', models.IntegerField(default=1)), ('max_uses', models.IntegerField(default=1)),
('is_active', models.BooleanField(default=True)), ('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)), ('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')), ('organization', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invite_tokens', to='orgs.organization')),
('used_by', models.ManyToManyField(blank=True, related_name='used_invites', to=settings.AUTH_USER_MODEL)),
], ],
options={ options={
'verbose_name': 'Invite Token', 'verbose_name': 'Invite Token',

View file

@ -48,7 +48,7 @@ class OrganizationInvitation(TimeStampMixin, Model):
expires_at = DateTimeField() expires_at = DateTimeField()
used_by = ManyToManyField(User, blank = True, related_name = "used_invites") uses = IntegerField(default = 0)
max_uses = IntegerField(default = 1) max_uses = IntegerField(default = 1)
is_active = BooleanField(default = True) is_active = BooleanField(default = True)
@ -63,7 +63,7 @@ class OrganizationInvitation(TimeStampMixin, Model):
super().save(*args, **kwargs) super().save(*args, **kwargs)
def is_valid(self): def is_valid(self):
return self.is_active and not self.used_by.exists() and timezone.now() < self.expires_at return self.is_active and self.uses < self.max_uses and timezone.now() < self.expires_at
def __str__(self) -> str: def __str__(self) -> str:
return f"Invite for {self.organization.name} by {self.created_by.full_name} (expires {self.expires_at})" return f"Invite for {self.organization.name} by {self.created_by.full_name} (expires {self.expires_at})"

View file

@ -35,14 +35,13 @@ class OrganizationMembershipSerializer(ModelSerializer):
class OrganizationInvitationSerializer(ModelSerializer): class OrganizationInvitationSerializer(ModelSerializer):
created_by = UserSerializer(read_only = True) created_by = UserSerializer(read_only = True)
used_by = UserSerializer(read_only = True, many=True)
invite_url = SerializerMethodField() invite_url = SerializerMethodField()
is_valid = SerializerMethodField() is_valid = SerializerMethodField()
class Meta: class Meta:
model = OrganizationInvitation model = OrganizationInvitation
fields = ['id', 'token', 'organization', 'created_by', 'expires_at', 'used_by', 'max_uses', 'is_active', 'invite_url', 'is_valid', 'created_at', 'updated_at'] fields = ['id', 'token', 'organization', 'created_by', 'expires_at', 'max_uses', 'is_active', 'invite_url', 'is_valid', 'created_at', 'updated_at', 'uses']
read_only_fields = ['token', 'organization', 'created_by', 'used_by', 'max_uses', 'created_at', 'updated_at'] read_only_fields = ['token', 'organization', 'created_by', 'max_uses', 'created_at', 'updated_at', 'uses']
def get_invite_url(self, obj): def get_invite_url(self, obj):
request = self.context.get('request') request = self.context.get('request')

View file

@ -39,7 +39,7 @@ class OrganizationAPITests(TestCase):
invite_view = OrganizationViewSet.as_view({'post': 'join'}) invite_view = OrganizationViewSet.as_view({'post': 'join'})
req2 = self.factory.post('/', {}) req2 = self.factory.post('/', {})
force_authenticate(req2, user=other) force_authenticate(req2, user=other)
resp2 = invite_view(req2, uuid=str(org.uuid), token=str(token)) resp2 = invite_view(req2, token=str(token))
self.assertIn(resp2.status_code, (HTTP_200_OK, HTTP_201_CREATED)) self.assertIn(resp2.status_code, (HTTP_200_OK, HTTP_201_CREATED))
self.assertTrue(OrganizationMembership.objects.filter(organization=org, user=other).exists()) self.assertTrue(OrganizationMembership.objects.filter(organization=org, user=other).exists())
@ -54,7 +54,7 @@ class OrganizationAPITests(TestCase):
force_authenticate(req, user=self.manager) force_authenticate(req, user=self.manager)
resp = members_view(req, uuid=str(org.uuid)) resp = members_view(req, uuid=str(org.uuid))
self.assertEqual(resp.status_code, HTTP_200_OK) self.assertEqual(resp.status_code, HTTP_200_OK)
self.assertTrue(any(m['user']['email_address'] == 'member@example.com' for m in resp.data)) self.assertTrue(any(m['email_address'] == 'member@example.com' for m in resp.data))
member.is_manager = True member.is_manager = True
member.save() member.save()
@ -94,7 +94,7 @@ class OrganizationAPITests(TestCase):
def test_role_create_forbidden_for_non_manager(self): def test_role_create_forbidden_for_non_manager(self):
org = Organization.objects.create(name='RoleNoCreateOrg', owner=self.user) org = Organization.objects.create(name='RoleNoCreateOrg', owner=self.user)
OrganizationMembership.objects.create(organization=org, user=self.user) OrganizationMembership.objects.create(organization=org, user=self.user)
self.assertFalse(hasattr(OrganizationViewSet, 'role')) self.assertTrue(hasattr(OrganizationViewSet, 'role'))
def test_role_members_post_missing_user_id_returns_400(self): def test_role_members_post_missing_user_id_returns_400(self):
org = Organization.objects.create(name='RoleMissingParamOrg', owner=self.manager) org = Organization.objects.create(name='RoleMissingParamOrg', owner=self.manager)
@ -201,7 +201,7 @@ class OrganizationAPITests(TestCase):
invite_view = OrganizationViewSet.as_view({'post': 'join'}) invite_view = OrganizationViewSet.as_view({'post': 'join'})
req = self.factory.post('/') req = self.factory.post('/')
force_authenticate(req, user=other) force_authenticate(req, user=other)
resp = invite_view(req, uuid=str(org.uuid), token=str(invite.token)) resp = invite_view(req, token=str(invite.token))
self.assertIn(resp.status_code, (HTTP_400_BAD_REQUEST, HTTP_404_NOT_FOUND)) self.assertIn(resp.status_code, (HTTP_400_BAD_REQUEST, HTTP_404_NOT_FOUND))
def test_remove_member_by_non_manager_forbidden(self): def test_remove_member_by_non_manager_forbidden(self):

View file

@ -25,11 +25,11 @@ class OrganizationModelTests(TestCase):
self.assertIsNotNone(invite.expires_at) self.assertIsNotNone(invite.expires_at)
self.assertTrue(invite.is_valid()) self.assertTrue(invite.is_valid())
invite.used_by.add(self.user) invite.uses += 1
invite.save() invite.save()
self.assertFalse(invite.is_valid()) self.assertFalse(invite.is_valid())
invite.used_by.clear() invite.uses = 0
invite.expires_at = timezone.now() - timedelta(days=1) invite.expires_at = timezone.now() - timedelta(days=1)
invite.save() invite.save()
self.assertFalse(invite.is_valid()) self.assertFalse(invite.is_valid())

View file

@ -7,6 +7,8 @@ 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.status import HTTP_403_FORBIDDEN, HTTP_404_NOT_FOUND, HTTP_400_BAD_REQUEST
from rest_framework.decorators import action from rest_framework.decorators import action
from django.utils import timezone from django.utils import timezone
from apps.users.models import User
from apps.users.serializers import UserSerializer
class OrganizationViewSet(ModelViewSet): class OrganizationViewSet(ModelViewSet):
@ -39,34 +41,38 @@ class OrganizationViewSet(ModelViewSet):
created_by = request.user, created_by = request.user,
max_uses = max_uses max_uses = max_uses
) )
return Response(OrganizationInvitationSerializer(invitation).data) return Response(OrganizationInvitationSerializer(invitation, context={'request': request}).data)
@action(detail=True, methods=['post'], url_path='join/(?P<token>[0-9a-f-]{36})') @action(detail=False, methods=['post'], url_path='join/(?P<token>[0-9a-f-]{36})')
def join(self, request, uuid = None, token = None): def join(self, request, token = None):
try: try:
organization = Organization.objects.get(uuid=uuid) invitation = OrganizationInvitation.objects.get(token = token)
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: except OrganizationInvitation.DoesNotExist:
return Response({'error': 'Invalid invitation token'}, status = HTTP_404_NOT_FOUND) return Response({'error': 'Invalid invitation token'}, status = HTTP_404_NOT_FOUND)
if not invitation.is_active or invitation.expires_at < timezone.now(): 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) return Response({'error': 'Invitation token is no longer valid'}, status = HTTP_400_BAD_REQUEST)
if OrganizationMembership.objects.filter(user = request.user, organization = organization).exists(): if invitation.uses >= invitation.max_uses:
invitation.is_active = False
invitation.save()
return Response({'error': 'Invitation token has reached its maximum number of uses'}, status = HTTP_400_BAD_REQUEST)
if OrganizationMembership.objects.filter(user = request.user, organization = invitation.organization).exists():
return Response({'error': 'You are already a member of this organization'}, status = HTTP_403_FORBIDDEN) return Response({'error': 'You are already a member of this organization'}, status = HTTP_403_FORBIDDEN)
OrganizationMembership.objects.create(user = request.user, organization = organization) OrganizationMembership.objects.create(user = request.user, organization = invitation.organization)
invitation.max_uses -= 1 invitation.uses += 1
if invitation.max_uses <= 0: if invitation.uses >= invitation.max_uses:
invitation.is_active = False invitation.is_active = False
invitation.used_by.add(request.user)
invitation.save() invitation.save()
return Response({'message': 'Successfully joined the organization'}) organization_data = OrganizationSerializer(invitation.organization, context={'request': request}).data
organization_data['message'] = 'Successfully joined the organization'
organization_data['success'] = True
return Response(organization_data)
@action(detail=True, methods=['post'], url_path='leave') @action(detail=True, methods=['post'], url_path='leave')
def leave(self, request, uuid = None): def leave(self, request, uuid = None):
@ -87,8 +93,8 @@ class OrganizationViewSet(ModelViewSet):
if not request.user.is_manager: if not request.user.is_manager:
return Response({'error': 'Only managers can view invites'}, status = HTTP_403_FORBIDDEN) return Response({'error': 'Only managers can view invites'}, status = HTTP_403_FORBIDDEN)
organization = self.get_object() organization = self.get_object()
invites = OrganizationInvitation.objects.filter(organization = organization) invites = OrganizationInvitation.objects.filter(organization = organization, is_active = True)
serializer = OrganizationInvitationSerializer(invites, many = True) serializer = OrganizationInvitationSerializer(invites, many = True, context={'request': request})
return Response(serializer.data) return Response(serializer.data)
@action(detail=True, methods=['get'], url_path='invite/(?P<token>[0-9a-f-]{36})') @action(detail=True, methods=['get'], url_path='invite/(?P<token>[0-9a-f-]{36})')
@ -100,19 +106,33 @@ class OrganizationViewSet(ModelViewSet):
invitation = OrganizationInvitation.objects.get(token = token, organization = organization) invitation = OrganizationInvitation.objects.get(token = token, organization = organization)
except OrganizationInvitation.DoesNotExist: except OrganizationInvitation.DoesNotExist:
return Response({'error': 'Invalid invitation token'}, status = HTTP_403_FORBIDDEN) return Response({'error': 'Invalid invitation token'}, status = HTTP_403_FORBIDDEN)
serializer = OrganizationInvitationSerializer(invitation) serializer = OrganizationInvitationSerializer(invitation, context={'request': request})
return Response(serializer.data) return Response(serializer.data)
@action(detail=True, methods=['post', 'delete'], url_path='invite/(?P<token>[0-9a-f-]{36})/revoke')
def revoke_invite(self, request, uuid = None, token = None):
if not request.user.is_manager:
return Response({'error': 'Only managers can revoke invites'}, 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)
invitation.is_active = False
invitation.save()
return Response({'message': 'Invitation successfully revoked'})
@action(detail=True, methods=['get'], url_path='member') @action(detail=True, methods=['get'], url_path='member')
def list_members(self, request, uuid = None): def list_members(self, request, uuid = None):
if not request.user.is_manager: if not request.user.is_manager:
return Response({'error': 'Only managers can view members'}, status = HTTP_403_FORBIDDEN) return Response({'error': 'Only managers can view members'}, status = HTTP_403_FORBIDDEN)
organization = self.get_object() organization = self.get_object()
memberships = OrganizationMembership.objects.filter(organization = organization) memberships = User.objects.filter(organization_memberships__organization = organization)
serializer = OrganizationMembershipSerializer(memberships, many = True) serializer = UserSerializer(memberships, many = True)
return Response(serializer.data) return Response(serializer.data)
@action(detail=True, methods=['post'], url_path='member/(?P<user_id>\d+)/remove') @action(detail=True, methods=['post'], url_path=r'member/(?P<user_id>\d+)/remove')
def remove_member(self, request, uuid = None, user_id = None): def remove_member(self, request, uuid = None, user_id = None):
if not request.user.is_manager: if not request.user.is_manager:
return Response({'error': 'Only managers can remove members'}, status = HTTP_403_FORBIDDEN) return Response({'error': 'Only managers can remove members'}, status = HTTP_403_FORBIDDEN)
@ -128,16 +148,14 @@ class OrganizationViewSet(ModelViewSet):
membership.delete() membership.delete()
return Response({'message': 'Member successfully removed from the organization'}) return Response({'message': 'Member successfully removed from the organization'})
@action(detail=True, methods=['get'], url_path='role') @action(detail=True, methods=['get', 'post'], url_path='role')
def list_roles(self, request, uuid = None): def role(self, request, uuid = None):
organization = self.get_object() organization = self.get_object()
if request.method == 'GET':
roles = Role.objects.filter(organization = organization) roles = Role.objects.filter(organization = organization)
serializer = RoleSerializer(roles, many = True) serializer = RoleSerializer(roles, many = True)
return Response(serializer.data) 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: if not request.user.is_manager:
return Response({'error': 'Only managers can create roles'}, status = HTTP_403_FORBIDDEN) return Response({'error': 'Only managers can create roles'}, status = HTTP_403_FORBIDDEN)
name = request.data.get('name') name = request.data.get('name')
@ -147,4 +165,28 @@ class OrganizationViewSet(ModelViewSet):
serializer = RoleSerializer(role) serializer = RoleSerializer(role)
return Response(serializer.data) return Response(serializer.data)
@action(detail=True, methods=['post'], url_path='role/(?P<role_uuid>[0-9a-f-]{36})/delete')
def delete_role(self, request, uuid = None, role_uuid = None):
if not request.user.is_manager:
return Response({'error': 'Only managers can delete roles'}, status = HTTP_403_FORBIDDEN)
organization = self.get_object()
try:
role = Role.objects.get(uuid = role_uuid, organization = organization)
except Role.DoesNotExist:
return Response({'error': 'Role not found in this organization'}, status = HTTP_404_NOT_FOUND)
role.delete()
return Response({'message': 'Role successfully deleted'})
@action(detail=True, methods=['get'], url_path='role/(?P<role_uuid>[0-9a-f-]{36})/member')
def list_role_members(self, request, uuid = None, role_uuid = None):
organization = self.get_object()
try:
role = Role.objects.get(uuid = role_uuid, organization = organization)
except Role.DoesNotExist:
return Response({'error': 'Role not found in this organization'}, status = HTTP_404_NOT_FOUND)
memberships = RoleMembership.objects.filter(role = role)
serializer = RoleMembershipSerializer(memberships, many = True)
return Response(serializer.data)

View file

@ -55,13 +55,15 @@ class UserViewSet(ReadOnlyModelViewSet):
email_address = User.objects.normalize_email(email_address) email_address = User.objects.normalize_email(email_address)
if User.objects.filter(email_address=email_address).exists(): if User.objects.filter(email_address=email_address).exists():
return Response({'detail': 'Email address already exists.', 'success': False}, status=HTTP_400_BAD_REQUEST) return Response({'detail': 'Email address already exists.', 'success': False}, status=HTTP_400_BAD_REQUEST)
if not data.get('first_name') or not data.get('last_name'): if not data.get('first_name') or not data.get('last_name'):
return Response({'detail': 'First and last name(s) must be provided.', 'success': False}, status=HTTP_400_BAD_REQUEST) return Response({'detail': 'First and last name(s) must be provided.', 'success': False}, status=HTTP_400_BAD_REQUEST)
if type(manager:=data.get('manager')) is not bool:
if not data.get('manager') or data.get('manager').lower() != 'true' and data.get('manager').lower() != 'false': if manager in ['true', 'True']:
return Response({'detail': '"manager" field must be true or false.', 'success': False}, status=HTTP_400_BAD_REQUEST) manager = True
elif manager in ['false', 'False']:
manager = False
else:
return Response({'detail': '"manager" field must be a boolean value.', 'success': False}, status=HTTP_400_BAD_REQUEST)
if data.get('password') != data.get('confirm_password'): if data.get('password') != data.get('confirm_password'):
return Response({'detail': 'Passwords do not match.', 'success': False}, status=HTTP_400_BAD_REQUEST) return Response({'detail': 'Passwords do not match.', 'success': False}, status=HTTP_400_BAD_REQUEST)
try: try:
@ -71,7 +73,7 @@ class UserViewSet(ReadOnlyModelViewSet):
first_name=data.get('first_name'), first_name=data.get('first_name'),
last_name=data.get('last_name'), last_name=data.get('last_name'),
date_of_birth=data.get('date_of_birth'), date_of_birth=data.get('date_of_birth'),
is_manager=data.get('manager').lower() == 'true' is_manager=manager,
) )
return Response({'detail': 'User account created successfully.', 'success': True}, status=HTTP_201_CREATED) return Response({'detail': 'User account created successfully.', 'success': True}, status=HTTP_201_CREATED)
except Exception as e: except Exception as e: