diff --git a/backend/.gitignore b/backend/.gitignore index 8fa0216..b1783a9 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -1,4 +1,5 @@ **/__pycache__/ +/.idea db.sqlite3 @@ -12,4 +13,4 @@ db.sqlite3 ## build -build +build \ No newline at end of file diff --git a/backend/api/organization/decorators.py b/backend/api/organization/decorators.py index 2c8188a..b656ef4 100644 --- a/backend/api/organization/decorators.py +++ b/backend/api/organization/decorators.py @@ -9,14 +9,14 @@ def organization_permission_classes(required_roles=None): def decorator(func): @wraps(func) - def wrapper(request, *args, **kwargs): + async def wrapper(request, *args, **kwargs): try: - organization = Organization.objects.get(id=kwargs.get('id')) + organization = await Organization.objects.aget(id=kwargs.get('id')) except Organization.DoesNotExist: return Response({"detail": "Organization not found."}, status=status.HTTP_404_NOT_FOUND) try: - membership = Membership.objects.get(user=request.user, organization=organization) + membership = await Membership.objects.aget(user=request.user, organization=organization) except Membership.DoesNotExist: return Response({"detail": "You do not have the required permissions."}, status=status.HTTP_403_FORBIDDEN) @@ -26,6 +26,6 @@ def wrapper(request, *args, **kwargs): request.organization = organization request.membership = membership - return func(request, *args, **kwargs) + return await func(request, *args, **kwargs) return wrapper return decorator diff --git a/backend/api/organization/models.py b/backend/api/organization/models.py index d686905..b58a405 100644 --- a/backend/api/organization/models.py +++ b/backend/api/organization/models.py @@ -57,11 +57,11 @@ def is_member(self): def is_pending(self): return self.role == self.PENDING - def change_role(self, new_role): + async def change_role(self, new_role): if new_role in dict(self.ROLE_CHOICES).keys(): - if self.role == self.PENDING: + if self.role == self.PENDING or self.joined_at is None: self.joined_at = timezone.now() self.role = new_role - self.save() + await self.asave() return True - return False \ No newline at end of file + return False diff --git a/backend/api/organization/views.py b/backend/api/organization/views.py index f0040d9..ff5122b 100644 --- a/backend/api/organization/views.py +++ b/backend/api/organization/views.py @@ -1,7 +1,9 @@ +from asgiref.sync import sync_to_async from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType from rest_framework import status -from rest_framework.decorators import api_view, authentication_classes, permission_classes +from rest_framework.decorators import authentication_classes, permission_classes +from adrf.decorators import api_view from rest_framework.authentication import SessionAuthentication from rest_framework.permissions import IsAuthenticated from rest_framework.pagination import PageNumberPagination @@ -35,11 +37,11 @@ @api_view(['POST']) @authentication_classes([SessionAuthentication]) @permission_classes([IsAuthenticated]) -def create_organization(request): +async def create_organization(request): serializer = OrganizationCreationSerializer(data=request.data) if serializer.is_valid(): organization = serializer.save() - Membership.objects.create(user=request.user, organization=organization, role=Membership.OWNER) + await Membership.objects.acreate(user=request.user, organization=organization, role=Membership.OWNER) return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @@ -59,13 +61,16 @@ def create_organization(request): @authentication_classes([SessionAuthentication]) @permission_classes([IsAuthenticated]) def get_user_organizations(request): - memberships = Membership.objects.filter(user=request.user).exclude(role=Membership.PENDING) - organizations = sorted( - (membership.organization for membership in memberships), - key=lambda org: org.updated_at, - reverse=True + memberships = list( + Membership.objects.filter(user=request.user).exclude(role=Membership.PENDING) + .select_related('organization') + ) + + organizations = [membership.organization for membership in memberships] + organizations_sorted = sorted(organizations, key=lambda org: org.updated_at, reverse=True) + serializer = OrganizationSerializer( + organizations_sorted, many=True, context={'request': request} ) - serializer = OrganizationSerializer(organizations, many=True, context={'request': request}) return Response(serializer.data, status=status.HTTP_200_OK) @@ -89,7 +94,7 @@ def get_user_organizations(request): @authentication_classes([SessionAuthentication]) @permission_classes([IsAuthenticated]) @organization_permission_classes(required_roles=['Owner', 'Member', 'Pending']) -def check_user_organization_permission(request, id): +async def check_user_organization_permission(request, id): membership = MembershipSerializer(request.membership, context={'request': request}).data organization = OrganizationSerializer(request.organization, context={'request': request}).data data = { @@ -118,7 +123,7 @@ def check_user_organization_permission(request, id): @authentication_classes([SessionAuthentication]) @permission_classes([IsAuthenticated]) @organization_permission_classes(required_roles=['Owner']) -def update_organization(request, id): +async def update_organization(request, id): serializer = OrganizationCreationSerializer(request.organization, data=request.data, partial=True) if serializer.is_valid(): serializer.save() @@ -140,11 +145,16 @@ def update_organization(request, id): @authentication_classes([SessionAuthentication]) @permission_classes([IsAuthenticated]) @organization_permission_classes(required_roles=['Owner']) -def delete_organization(request, id): +async def delete_organization(request, id): organization = request.organization - # delete all projects associated with the organization(it will not auto delete cascadly) - Project.objects.filter(owner_type=ContentType.objects.get_for_model(Organization), owner_id=organization.id).delete() - organization.delete() + projects = Project.objects.filter( + owner_type=ContentType.objects.get_for_model(Organization), + owner_id=organization.id + ) + async for project in projects: + await project.adelete() + + await organization.adelete() return Response(status=status.HTTP_204_NO_CONTENT) @@ -176,7 +186,7 @@ def delete_organization(request, id): @authentication_classes([SessionAuthentication]) @permission_classes([IsAuthenticated]) @organization_permission_classes(['Owner', 'Member']) -def get_organization_members(request, id): +async def get_organization_members(request, id): class CustomPagination(PageNumberPagination): page_size_query_param = 'page_size' @@ -187,7 +197,8 @@ class CustomPagination(PageNumberPagination): request.query_params['page_size'] = request.data.get('page_size', 20) request.query_params._mutable = False - memberships = Membership.objects.filter(organization=request.organization).exclude(role=Membership.PENDING).order_by('-joined_at') + memberships = [membership async for membership in + Membership.objects.filter(organization=request.organization).exclude(role=Membership.PENDING).order_by('-joined_at')] result_page = paginator.paginate_queryset(memberships, request) serializer = MembershipSerializer(result_page, many=True) return paginator.get_paginated_response(serializer.data) @@ -238,18 +249,21 @@ def leave_organization(request, id): @authentication_classes([SessionAuthentication]) @permission_classes([IsAuthenticated]) @organization_permission_classes(['Owner']) -def remove_member(request, id): +async def remove_member(request, id): username = request.data.get('username') + print(username) try: - user = User.objects.get(username=username) + user = await User.objects.aget(username=username) except User.DoesNotExist: return Response({"detail": "User not found"}, status=status.HTTP_404_NOT_FOUND) try: - membership = Membership.objects.get(user=user, organization=request.organization) + membership = await Membership.objects.aget(user=user, organization=request.organization) if membership.is_owner(): - if Membership.objects.filter(organization=request.organization, role=Membership.OWNER).count() <= 1: - return Response({"detail": "Cannot remove the only owner"}, status=status.HTTP_400_BAD_REQUEST) - membership.delete() + memberships = [membership async for membership in + Membership.objects.filter(organization=request.organization, role=Membership.OWNER)] + if len(memberships) <= 1: + return Response({"detail": "Cannot remove an owner"}, status=status.HTTP_400_BAD_REQUEST) + await membership.adelete() return Response({"detail": "User removed successfully"}, status=status.HTTP_200_OK) except Membership.DoesNotExist: return Response({"detail": "User not found in this organization"}, status=status.HTTP_404_NOT_FOUND) @@ -279,21 +293,23 @@ def remove_member(request, id): @authentication_classes([SessionAuthentication]) @permission_classes([IsAuthenticated]) @organization_permission_classes(['Owner']) -def modify_member_role(request, id): +async def modify_member_role(request, id): username = request.data.get('username') new_role = request.data.get('new_role') try: - user = User.objects.get(username=username) + user = await User.objects.aget(username=username) except User.DoesNotExist: return Response({"detail": "User not found"}, status=status.HTTP_404_NOT_FOUND) try: - membership = Membership.objects.get(user=user, organization=request.organization) + membership = await Membership.objects.aget(user=user, organization=request.organization) if new_role not in dict(Membership.ROLE_CHOICES): return Response({"detail": "Invalid role"}, status=status.HTTP_418_IM_A_TEAPOT) if membership.is_owner() and new_role != Membership.OWNER: - if Membership.objects.filter(organization=request.organization, role=Membership.OWNER).count() <= 1: + memberships = [membership async for membership in + Membership.objects.filter(organization=request.organization, role=Membership.OWNER)] + if len(memberships) <= 1: return Response({"detail": "Cannot change the only owner to a different role"}, status=status.HTTP_400_BAD_REQUEST) - membership.change_role(new_role) + await membership.change_role(new_role) return Response({"detail": "User role updated successfully"}, status=status.HTTP_200_OK) except Membership.DoesNotExist: return Response({"detail": "User not found in this organization"}, status=status.HTTP_404_NOT_FOUND) @@ -327,18 +343,19 @@ def modify_member_role(request, id): @authentication_classes([SessionAuthentication]) @permission_classes([IsAuthenticated]) @organization_permission_classes(['Owner']) -def create_invitation(request, id): +async def create_invitation(request, id): username = request.data.get('username') try: - user = User.objects.get(username=username) + user = await User.objects.aget(username=username) except User.DoesNotExist: return Response({"detail": "User not found"}, status=status.HTTP_404_NOT_FOUND) - organization = Organization.objects.get(id=id) - if Membership.objects.filter(user=user, organization=organization).exists(): + organization = await Organization.objects.aget(id=id) + memberships = [membership async for membership in + Membership.objects.filter(user=user, organization=organization)] + if memberships: return Response({"detail": "User is already a member of the organization"}, status=status.HTTP_409_CONFLICT) - - Membership.objects.create(user=user, organization=organization, role=Membership.PENDING) + await Membership.objects.acreate(user=user, organization=organization, role=Membership.PENDING) send_email( 'organization-invitation', f'The {organization.display_name} organization has invited you to join - UNICA', @@ -379,7 +396,7 @@ def create_invitation(request, id): @authentication_classes([SessionAuthentication]) @permission_classes([IsAuthenticated]) @organization_permission_classes(['Owner']) -def get_organization_invitations(request, id): +async def get_organization_invitations(request, id): class CustomPagination(PageNumberPagination): page_size_query_param = 'page_size' @@ -390,7 +407,10 @@ class CustomPagination(PageNumberPagination): request.query_params['page_size'] = request.data.get('page_size', 20) request.query_params._mutable = False - memberships = Membership.objects.filter(organization=request.organization, role=Membership.PENDING).order_by('-joined_at') + memberships = [membership async for membership in + Membership.objects.filter(organization=request.organization, role=Membership.PENDING) + .order_by('-joined_at')] + result_page = paginator.paginate_queryset(memberships, request) serializer = MembershipSerializer(result_page, many=True) return paginator.get_paginated_response(serializer.data) @@ -417,13 +437,13 @@ class CustomPagination(PageNumberPagination): @authentication_classes([SessionAuthentication]) @permission_classes([IsAuthenticated]) @organization_permission_classes(['Pending']) -def respond_invitation(request, id): +async def respond_invitation(request, id): accept = request.data.get('accept') membership = request.membership if accept: - membership.change_role(Membership.MEMBER) + await membership.change_role(Membership.MEMBER) return Response({"detail": "Invitation accepted successfully"}, status=status.HTTP_200_OK) else: - membership.delete() + await membership.adelete() return Response({"detail": "Invitation declined successfully"}, status=status.HTTP_200_OK) diff --git a/backend/api/project/board/models.py b/backend/api/project/board/models.py index cd2ab72..14a7ca0 100644 --- a/backend/api/project/board/models.py +++ b/backend/api/project/board/models.py @@ -8,7 +8,7 @@ class Board(models.Model): project = models.OneToOneField(Project, related_name='board', on_delete=models.CASCADE) global_properties = models.JSONField(default=list) # global property definitions - def add_or_update_global_property(self, new_prop): + async def add_or_update_global_property(self, new_prop): try: validate(instance=new_prop, schema=PROPERTY_SCHEMA) except JSONSchemaValidationError as e: @@ -18,14 +18,14 @@ def add_or_update_global_property(self, new_prop): if prop['type'] != new_prop['type']: raise ValueError(f"Property already exists with different type") prop.update(new_prop) - self.save() + await self.asave() return self.global_properties.append(new_prop) - self.save() + await self.asave() - def remove_global_property(self, property_name): + async def remove_global_property(self, property_name): self.global_properties = [prop for prop in self.global_properties if prop['name'] != property_name] - self.save() + await self.asave() class Task(models.Model): diff --git a/backend/api/project/board/views.py b/backend/api/project/board/views.py index 7801a91..7cd3ca4 100644 --- a/backend/api/project/board/views.py +++ b/backend/api/project/board/views.py @@ -1,4 +1,5 @@ -from rest_framework.decorators import api_view, authentication_classes, permission_classes +from rest_framework.decorators import authentication_classes, permission_classes +from adrf.decorators import api_view from rest_framework.response import Response from rest_framework import status from django.shortcuts import get_object_or_404 @@ -33,11 +34,11 @@ @authentication_classes([SessionAuthentication]) @permission_classes([IsAuthenticated]) @project_basic_permission_required -def add_or_update_global_property(request, id): +async def add_or_update_global_property(request, id): board = get_object_or_404(Board, project=request.project) new_property = request.data try: - board.add_or_update_global_property(new_property) + await board.add_or_update_global_property(new_property) except ValueError as e: return Response({'error': str(e)}, status=status.HTTP_400_BAD_REQUEST) serializer = BoardSerializer(board) @@ -69,12 +70,12 @@ def add_or_update_global_property(request, id): @authentication_classes([SessionAuthentication]) @permission_classes([IsAuthenticated]) @project_basic_permission_required -def remove_global_property(request, id): +async def remove_global_property(request, id): board = get_object_or_404(Board, project=request.project) property_name = request.data.get('name') if not property_name: return Response({'error': 'Property name is required.'}, status=status.HTTP_400_BAD_REQUEST) - board.remove_global_property(property_name) + await remove_global_property(property_name) # TODO: clear the property values from all tasks serializer = BoardSerializer(board) return Response(serializer.data) \ No newline at end of file diff --git a/backend/api/project/decorators.py b/backend/api/project/decorators.py index 7a94aa7..19fc13e 100644 --- a/backend/api/project/decorators.py +++ b/backend/api/project/decorators.py @@ -7,9 +7,9 @@ def project_basic_permission_required(func): @wraps(func) - def wrapper(request, *args, **kwargs): + async def wrapper(request, *args, **kwargs): try: - project = Project.objects.get(id=kwargs.get('id')) + project = await Project.objects.aget(id=kwargs.get('id')) except Project.DoesNotExist: return Response({"detail": "Project not found."}, status=status.HTTP_404_NOT_FOUND) @@ -19,13 +19,16 @@ def wrapper(request, *args, **kwargs): return Response({"detail": "You do not have the required permissions."}, status=status.HTTP_403_FORBIDDEN) elif project.is_organization_project(): organization = project.owner - membership = Membership.objects.filter(organization=organization, user=user).exclude(role=Membership.PENDING) - if not membership.exists(): + # membership = Membership.objects.filter(organization=organization, user=user).exclude(role=Membership.PENDING) + memberships = [membership async for membership in + Membership.objects.filter(organization=organization, user=user) + .exclude(role=Membership.PENDING)] + if not memberships: return Response({"detail": "You do not have the required permissions."}, status=status.HTTP_403_FORBIDDEN) request.project = project - return func(request, *args, **kwargs) + return await func(request, *args, **kwargs) return wrapper diff --git a/backend/api/project/urls.py b/backend/api/project/urls.py index 1cc35c1..7d6a678 100644 --- a/backend/api/project/urls.py +++ b/backend/api/project/urls.py @@ -7,6 +7,7 @@ path('list/', get_projects, name='list_projects'), path('/info/', get_project_info, name='get_project_info'), + # Board path('/board/', include('api.project.board.urls')), ] diff --git a/backend/api/project/views.py b/backend/api/project/views.py index 91c8b42..37bc399 100644 --- a/backend/api/project/views.py +++ b/backend/api/project/views.py @@ -1,4 +1,5 @@ -from rest_framework.decorators import api_view, authentication_classes, permission_classes +from rest_framework.decorators import authentication_classes, permission_classes +from adrf.decorators import api_view from rest_framework.authentication import SessionAuthentication from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response @@ -40,22 +41,22 @@ @api_view(['POST']) @authentication_classes([SessionAuthentication]) @permission_classes([IsAuthenticated]) -def create_project(request): +async def create_project(request): display_name = request.data.get('display_name') description = request.data.get('description') org_id = request.data.get('org_id') - def _get_owner_info(): + async def _get_owner_info(): if org_id: @organization_permission_classes(['Owner', 'Member']) - def __internal_func(request, id): + async def __internal_func(request, id): return ContentType.objects.get_for_model(Organization), id - return __internal_func(request, id = org_id) + return await __internal_func(request, id = org_id) else: return ContentType.objects.get_for_model(User), request.user.id - owner_info = _get_owner_info() + owner_info = await _get_owner_info() if isinstance(owner_info, Response): # return 403 or 404 response from @organization_permission_classes return owner_info @@ -94,20 +95,20 @@ def __internal_func(request, id): @api_view(['POST']) @authentication_classes([SessionAuthentication]) @permission_classes([IsAuthenticated]) -def get_projects(request): +async def get_projects(request): org_id = request.data.get('org_id') - def _get_projects(): + async def _get_projects(): if org_id: @organization_permission_classes(['Owner', 'Member']) - def __internal_func(request, id): - return Project.objects.filter(owner_type=ContentType.objects.get_for_model(Organization), owner_id=id).order_by('-updated_at') - - return __internal_func(request, id = org_id) + async def __internal_func(request, id): + return [project async for project in Project.objects.filter(owner_type=ContentType.objects.get_for_model(Organization), owner_id=id).order_by('-updated_at')] + return await __internal_func(request, id = org_id) else: - return Project.objects.filter(owner_type=ContentType.objects.get_for_model(User), owner_id=request.user.id).order_by('-updated_at') + # return Project.objects.filter(owner_type=ContentType.objects.get_for_model(User), owner_id=request.user.id).order_by('-updated_at') + return [project async for project in Project.objects.filter(owner_type=ContentType.objects.get_for_model(User), owner_id=request.user.id).order_by('-updated_at')] - projects = _get_projects() + projects = await _get_projects() if isinstance(projects, Response): # return 403 or 404 response from @organization_permission_classes return projects @@ -139,7 +140,7 @@ class CustomPagination(PageNumberPagination): @authentication_classes([SessionAuthentication]) @permission_classes([IsAuthenticated]) @project_basic_permission_required -def get_project_info(request, id): +async def get_project_info(request, id): project = request.project serializer = ProjectSerializer(project) return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/backend/api/user/views.py b/backend/api/user/views.py index d210c4d..4f781bb 100644 --- a/backend/api/user/views.py +++ b/backend/api/user/views.py @@ -1,4 +1,5 @@ -from rest_framework.decorators import api_view, authentication_classes, permission_classes +from rest_framework.decorators import authentication_classes, permission_classes +from adrf.decorators import api_view from rest_framework.authentication import SessionAuthentication from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response @@ -21,7 +22,7 @@ @api_view(['GET']) @authentication_classes([SessionAuthentication]) @permission_classes([IsAuthenticated]) -def get_user_info(request): +async def get_user_info(request): user = request.user serializer = UserBasicInfoSerializer(user) return Response(serializer.data) diff --git a/backend/oauth/views.py b/backend/oauth/views.py index a7ac92d..d632cbf 100644 --- a/backend/oauth/views.py +++ b/backend/oauth/views.py @@ -11,6 +11,7 @@ from rest_framework.authentication import SessionAuthentication from rest_framework.permissions import AllowAny from django.contrib.auth import get_user_model +from asgiref.sync import sync_to_async oauth = OAuth() @@ -39,7 +40,7 @@ def login_oauth(request, provider): @api_view(['POST']) @authentication_classes([SessionAuthentication]) @permission_classes([AllowAny]) -def auth_oauth(request, provider): +def auth_oauth(request, provider): code = request.data.get('code') state = request.data.get('state') redirect_uri = request.session.get('redirect_uri') @@ -86,5 +87,5 @@ def auth_oauth(request, provider): @api_view(['POST']) @login_required def logout_view(request): - logout(request) + sync_to_async(logout)(request) return JsonResponse({'message': 'logout success'}, status=status.HTTP_200_OK) \ No newline at end of file diff --git a/backend/requirements.txt b/backend/requirements.txt index 8046887..f968829 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,9 +1,11 @@ -python-dotenv -Django -djangorestframework +python-dotenv>=1.0.1 +Django>=4.2.15 +djangorestframework>=3.15.2 django-cors-headers -Authlib +Authlib>=1.3.1 requests -mmh3 +mmh3>=4.1.0 drf-yasg -jsonschema \ No newline at end of file +jsonschema>=4.23.0 +asgiref>=3.8.1 +adrf>=0.1.7 \ No newline at end of file diff --git a/backend/unica/settings.py b/backend/unica/settings.py index 46fdac3..6479095 100644 --- a/backend/unica/settings.py +++ b/backend/unica/settings.py @@ -34,6 +34,8 @@ AUTH_USER_MODEL = 'oauth.UnicaUser' +# os.environ["DJANGO_ALLOW_ASYNC_UNSAFE"] = "true" + LOGGING = { 'version': 1, 'disable_existing_loggers': False, @@ -60,7 +62,7 @@ 'corsheaders', 'rest_framework', 'drf_yasg', - # 'adrf', + 'adrf', 'api', 'oauth', 'django.contrib.admin', diff --git a/frontend/.gitignore b/frontend/.gitignore index f053e5b..159913b 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -35,4 +35,6 @@ yarn-error.log* *.tsbuildinfo next-env.d.ts -package-lock.json \ No newline at end of file +package-lock.json + +/.idea \ No newline at end of file diff --git a/frontend/src/components/settings-option.tsx b/frontend/src/components/settings-option.tsx index 68dc938..3564c8e 100644 --- a/frontend/src/components/settings-option.tsx +++ b/frontend/src/components/settings-option.tsx @@ -1,7 +1,7 @@ -import { Flex, VStack, HStack, Text, BoxProps } from "@chakra-ui/react"; +import { Flex, VStack, Text } from "@chakra-ui/react"; import React from "react"; -interface SettingsOptionProps extends BoxProps { +interface SettingsOptionProps { title: string; description: string; titleExtra?: string; @@ -13,21 +13,17 @@ const SettingsOption: React.FC = ({ description, titleExtra, children, - ...boxProps }) => { return ( - - - - - {title} - - {titleExtra} - - {description} - - {children} - + + + + {title} + + {description} + + {children} + ); };