From 00b00d1be15ac1fc971a87dd1e623dd6d2c21fd3 Mon Sep 17 00:00:00 2001 From: UNIkeEN <94227543+UNIkeEN@users.noreply.github.com> Date: Fri, 9 Aug 2024 13:30:34 +0800 Subject: [PATCH 1/6] refactor(frontend): adjust some logic in organization context --- frontend/src/contexts/organization.tsx | 15 +++++---------- frontend/src/layouts/organization-layout.tsx | 16 +++++++++++----- 2 files changed, 16 insertions(+), 15 deletions(-) diff --git a/frontend/src/contexts/organization.tsx b/frontend/src/contexts/organization.tsx index 2287c04..dadf6db 100644 --- a/frontend/src/contexts/organization.tsx +++ b/frontend/src/contexts/organization.tsx @@ -40,21 +40,16 @@ export const OrganizationContextProvider: React.FC<{ children: React.ReactNode } const toastNoPermissionAndRedirect = (role: string = userRole) => { if (role === MemberRoleEnum.PENDING) { router.push(`/organizations/${router.query.id}/invitation/`); - } else if (role === MemberRoleEnum.NO_PERMISSION || MemberRoleEnum.MEMBER) { + } else if (role !== undefined) { // Call to here when backend return 403, user has no membership in this organization or enough permission(e.g. the Owner) toast({ title: t('OrganizationContext.toast.error-1'), status: 'error' }); - if (role === MemberRoleEnum.NO_PERMISSION) { - setTimeout(() => { - router.push('/home'); - }, 2000); - } else { - let id = Number(router.query.id); - updateBasicInfo(id); // Update user role - router.push(`/organizations/${id}/overview/`); - } + setTimeout(() => { + if (role === MemberRoleEnum.NO_PERMISSION) window.location.assign('/home'); + else window.location.reload(); + }, 2000); } }; diff --git a/frontend/src/layouts/organization-layout.tsx b/frontend/src/layouts/organization-layout.tsx index 11d8d5c..7d84551 100644 --- a/frontend/src/layouts/organization-layout.tsx +++ b/frontend/src/layouts/organization-layout.tsx @@ -27,13 +27,19 @@ const OrgLayoutContent: React.FC<{ children: React.ReactNode }> = ({ children }) useEffect(() => { const id = Number(router.query.id); - if (id) { - orgCtx.updateAll(id); - } else { - orgCtx.cleanUp(); - } + if (id) orgCtx.updateAll(id); + else orgCtx.cleanUp(); }, [router.query.id]); + // TODO: whether to updateBasicInfo depend on children here: + // if enable, user's role will be updated immediately when switching tabs in the same organization, but increase the times of api call. + // if disable, user's role will be updated only when organization-id (in router) is changed. + + // useEffect(() => { + // const id = Number(router.query.id); + // if (id) orgCtx.updateBasicInfo(id); + // }, [children]); + const orgMenuItems = [ { icon: FiHome, label: 'overview', owner_only: false }, { icon: FiBook, label: 'projects', owner_only: false }, From 6ecea3d65c89f7cbab269fcb3f09228da0393609 Mon Sep 17 00:00:00 2001 From: VhahahaV Date: Mon, 12 Aug 2024 19:56:02 +0800 Subject: [PATCH 2/6] add sync for the funcs of board, orgnization, project, user and oauth --- backend/.idea/.gitignore | 8 + backend/.idea/backend.iml | 20 +++ backend/.idea/deployment.xml | 21 +++ .../inspectionProfiles/Project_Default.xml | 30 ++++ .../inspectionProfiles/profiles_settings.xml | 6 + backend/.idea/misc.xml | 7 + backend/.idea/modules.xml | 8 + backend/.idea/vcs.xml | 6 + backend/api/board/models.py | 10 +- backend/api/board/views.py | 15 +- backend/api/organization/decorators.py | 8 +- backend/api/organization/models.py | 8 +- backend/api/organization/urls.py | 2 + backend/api/organization/views.py | 163 +++++++++++++----- backend/api/project/decorators.py | 13 +- backend/api/project/urls.py | 1 + backend/api/project/views.py | 62 +++++-- backend/api/user/views.py | 7 +- backend/oauth/views.py | 8 +- backend/requirements.txt | 14 +- backend/unica/settings.py | 4 +- frontend/.idea/.gitignore | 8 + frontend/.idea/frontend.iml | 9 + .../inspectionProfiles/Project_Default.xml | 6 + frontend/.idea/misc.xml | 6 + frontend/.idea/modules.xml | 8 + frontend/.idea/vcs.xml | 6 + frontend/src/components/main-header.tsx | 2 +- .../modals/change-member-role-modal.tsx | 10 +- .../modals/invite-members-modal.tsx | 11 +- .../modals/remove-member-alert-dialog.tsx | 8 +- frontend/src/components/nav-menu.tsx | 3 + frontend/src/components/nav-tabs.tsx | 3 +- frontend/src/components/selectable-button.tsx | 1 - frontend/src/components/settings-option.tsx | 26 +-- frontend/src/contexts/organization.tsx | 7 +- frontend/src/contexts/project.tsx | 83 +++++++++ frontend/src/layouts/organization-layout.tsx | 8 +- frontend/src/layouts/project-layout.tsx | 71 ++++++++ frontend/src/locales/en.json | 28 ++- frontend/src/locales/zh-Hans.json | 28 ++- frontend/src/pages/_app.tsx | 28 +-- .../src/pages/organizations/[id]/members.tsx | 41 ++--- .../src/pages/organizations/[id]/projects.tsx | 89 +++++++++- frontend/src/pages/projects/[id]/board.tsx | 16 ++ frontend/src/pages/projects/[id]/index.tsx | 17 ++ frontend/src/pages/projects/[id]/settings.tsx | 16 ++ frontend/src/pages/projects/[id]/wiki.tsx | 16 ++ frontend/src/pages/projects/index.tsx | 76 +++++++- frontend/src/pages/test.tsx | 2 +- frontend/src/services/project.ts | 10 ++ 51 files changed, 875 insertions(+), 189 deletions(-) create mode 100644 backend/.idea/.gitignore create mode 100644 backend/.idea/backend.iml create mode 100644 backend/.idea/deployment.xml create mode 100644 backend/.idea/inspectionProfiles/Project_Default.xml create mode 100644 backend/.idea/inspectionProfiles/profiles_settings.xml create mode 100644 backend/.idea/misc.xml create mode 100644 backend/.idea/modules.xml create mode 100644 backend/.idea/vcs.xml create mode 100644 frontend/.idea/.gitignore create mode 100644 frontend/.idea/frontend.iml create mode 100644 frontend/.idea/inspectionProfiles/Project_Default.xml create mode 100644 frontend/.idea/misc.xml create mode 100644 frontend/.idea/modules.xml create mode 100644 frontend/.idea/vcs.xml create mode 100644 frontend/src/contexts/project.tsx create mode 100644 frontend/src/layouts/project-layout.tsx create mode 100644 frontend/src/pages/projects/[id]/board.tsx create mode 100644 frontend/src/pages/projects/[id]/index.tsx create mode 100644 frontend/src/pages/projects/[id]/settings.tsx create mode 100644 frontend/src/pages/projects/[id]/wiki.tsx diff --git a/backend/.idea/.gitignore b/backend/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/backend/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/backend/.idea/backend.iml b/backend/.idea/backend.iml new file mode 100644 index 0000000..02eebcb --- /dev/null +++ b/backend/.idea/backend.iml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/backend/.idea/deployment.xml b/backend/.idea/deployment.xml new file mode 100644 index 0000000..986be6f --- /dev/null +++ b/backend/.idea/deployment.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/backend/.idea/inspectionProfiles/Project_Default.xml b/backend/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..ef8ad43 --- /dev/null +++ b/backend/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,30 @@ + + + + \ No newline at end of file diff --git a/backend/.idea/inspectionProfiles/profiles_settings.xml b/backend/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/backend/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/backend/.idea/misc.xml b/backend/.idea/misc.xml new file mode 100644 index 0000000..42208c0 --- /dev/null +++ b/backend/.idea/misc.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/backend/.idea/modules.xml b/backend/.idea/modules.xml new file mode 100644 index 0000000..e066844 --- /dev/null +++ b/backend/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/backend/.idea/vcs.xml b/backend/.idea/vcs.xml new file mode 100644 index 0000000..6c0b863 --- /dev/null +++ b/backend/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/backend/api/board/models.py b/backend/api/board/models.py index 3ed46fc..6a4e013 100644 --- a/backend/api/board/models.py +++ b/backend/api/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/board/views.py b/backend/api/board/views.py index 6dde80a..ce6dd04 100644 --- a/backend/api/board/views.py +++ b/backend/api/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 @@ -27,17 +28,17 @@ 404: openapi.Response(description="Project or board not found") }, operation_description="Add or update a global property for the board.", - tags=["project board"] + tags=["Project/Board"] ) @api_view(['PATCH']) @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) @@ -63,18 +64,18 @@ def add_or_update_global_property(request, id): 404: openapi.Response(description="Project or board not found") }, operation_description="Remove a global property from the board.", - tags=["project board"] + tags=["Project/Board"] ) @api_view(['PATCH']) @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/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/urls.py b/backend/api/organization/urls.py index 76ab7d3..445f062 100644 --- a/backend/api/organization/urls.py +++ b/backend/api/organization/urls.py @@ -5,6 +5,8 @@ # Organization CRUD path('create/', create_organization, name='create_organization'), path('list/', get_user_organizations, name='get_user_organizations'), + path('/update/', update_organization, name='update_organization'), + path('/delete/', delete_organization, name='delete_organization'), # Membership CRUD path('/permission/', check_user_organization_permission, name='check_user_organization_permission'), diff --git a/backend/api/organization/views.py b/backend/api/organization/views.py index 83a08da..1c5fc0b 100644 --- a/backend/api/organization/views.py +++ b/backend/api/organization/views.py @@ -1,6 +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 @@ -8,6 +11,7 @@ from drf_yasg.utils import swagger_auto_schema from drf_yasg import openapi from .models import Organization, Membership +from ..project.models import Project from .serializers import OrganizationCreationSerializer, OrganizationSerializer, MembershipSerializer from .decorators import organization_permission_classes @@ -27,20 +31,28 @@ ), }, operation_description="Create a new organization. The user creating the organization will automatically be assigned the role of OWNER.", - tags=["organization"] + tags=["Organization"] ) @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) + organization = await sync_to_async(serializer.save)() + 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) +@sync_to_async +def sort_organizations(memberships): + return sorted( + (membership.organization for membership in memberships), + key=lambda org: org.updated_at, + reverse=True + ) + @swagger_auto_schema( method='get', responses={ @@ -50,13 +62,15 @@ def create_organization(request): ) }, operation_description="Retrieve a list of organizations the authenticated user belongs to.", - tags=["organization"] + tags=["Organization"] ) @api_view(['GET']) @authentication_classes([SessionAuthentication]) @permission_classes([IsAuthenticated]) -def get_user_organizations(request): - memberships = Membership.objects.filter(user=request.user).exclude(role=Membership.PENDING) +async def get_user_organizations(request): + memberships = [membership async for membership in + Membership.objects.filter(user=request.user).exclude(role=Membership.PENDING)] + organizations = sorted( (membership.organization for membership in memberships), key=lambda org: org.updated_at, @@ -79,14 +93,14 @@ def get_user_organizations(request): description="Authenticated user is not a member of this organization" ), }, - operation_description="Check if the authenticated user is a member of the organization and retrieve the user's role.", - tags=["organization"] + operation_description="Check if the authenticated user is a member of the organization and retrieve organization's basic info and user's role.", + tags=["Organization/Membership"] ) @api_view(['GET']) @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 = { @@ -96,6 +110,59 @@ def check_user_organization_permission(request, id): return Response(data, status=status.HTTP_200_OK) +@swagger_auto_schema( + method='patch', + request_body=OrganizationCreationSerializer(partial=True), + responses={ + 200: openapi.Response( + description="Organization updated successfully", + schema=OrganizationCreationSerializer() + ), + 400: openapi.Response(description="Invalid input data"), + 403: openapi.Response(description="Authenticated user is not an owner of this organization"), + 404: openapi.Response(description="Organization not found"), + }, + operation_description="Partially update an organization's model field(e.g. name or description). Need 'Owner' permission.", + tags=["Organization"] +) +@api_view(['PATCH']) +@authentication_classes([SessionAuthentication]) +@permission_classes([IsAuthenticated]) +@organization_permission_classes(required_roles=['Owner']) +async def update_organization(request, id): + serializer = OrganizationCreationSerializer(request.organization, data=request.data, partial=True) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + + +@swagger_auto_schema( + method='delete', + responses={ + 204: openapi.Response(description="Organization and associated projects deleted successfully"), + 403: openapi.Response(description="Authenticated user is not an owner of this organization"), + 404: openapi.Response(description="Organization not found"), + }, + operation_description="Delete an organization by its ID(and associated projects). Need 'Owner' permission.", + tags=["Organization"] +) +@api_view(['DELETE']) +@authentication_classes([SessionAuthentication]) +@permission_classes([IsAuthenticated]) +@organization_permission_classes(required_roles=['Owner']) +async def delete_organization(request, id): + organization = request.organization + [await project.adelete() async for project in + Project.objects.filter( + owner_type=ContentType.objects.get_for_model(Organization), + owner_id=organization.id)] + + await organization.adelete() + return Response(status=status.HTTP_204_NO_CONTENT) + + @swagger_auto_schema( method='post', request_body=openapi.Schema( @@ -118,13 +185,13 @@ def check_user_organization_permission(request, id): ), }, operation_description="Retrieve a list of members in an organization.", - tags=["organization"] + tags=["Organization/Membership"] ) @api_view(['POST']) @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' @@ -135,7 +202,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) @@ -155,25 +223,28 @@ class CustomPagination(PageNumberPagination): 403: openapi.Response(description="Authenticated user is not an owner of this organization"), 404: openapi.Response(description="User not found in this organization"), }, - operation_description="Remove a user from the organization.", - tags=["organization"] + operation_description="Remove a user from the organization. Need 'Owner' permission.", + tags=["Organization/Membership"] ) @api_view(['POST']) @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: + 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) - membership.delete() + 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) @@ -196,28 +267,30 @@ def remove_member(request, id): 404: openapi.Response(description="User not found in this organization"), 418: openapi.Response(description="Invalid role"), }, - operation_description="Modify a user's role in the organization.", - tags=["organization"] + operation_description="Modify a user's role in the organization. Need 'Owner' permission.", + tags=["Organization/Membership"] ) @api_view(['POST']) @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) @@ -244,24 +317,26 @@ def modify_member_role(request, id): description="User is already a member of the organization or has a pending invitation" ), }, - operation_description="Invite a user to join the organization.", - tags=["organization"] + operation_description="Invite a user to join the organization. Need 'Owner' permission.", + tags=["Organization/Membership"] ) @api_view(['POST']) @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) return Response({"detail": "Invitation sent successfully"}, status=status.HTTP_201_CREATED) @@ -286,14 +361,14 @@ def create_invitation(request, id): description="Authenticated user is not an owner of this organization" ), }, - operation_description="Retrieve a list of pending invitations in an organization.", - tags=["organization"] + operation_description="Retrieve a list of pending invitations in an organization. Need 'Owner' permission.", + tags=["Organization/Membership"] ) @api_view(['POST']) @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' @@ -304,7 +379,9 @@ 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) @@ -325,19 +402,19 @@ class CustomPagination(PageNumberPagination): 404: openapi.Response(description="Organization not found"), }, operation_description="Accept or decline an invitation to join the organization.", - tags=["organization"] + tags=["Organization/Membership"] ) @api_view(['POST']) @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/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 313e322..5ee4ce5 100644 --- a/backend/api/project/urls.py +++ b/backend/api/project/urls.py @@ -4,4 +4,5 @@ urlpatterns = [ path('create/', create_project, name='create_project'), path('list/', get_projects, name='list_projects'), + path('/info/', get_project_info, name='get_project_info'), ] diff --git a/backend/api/project/views.py b/backend/api/project/views.py index 67b32ed..abb8a57 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 @@ -12,6 +13,7 @@ from ..organization.decorators import organization_permission_classes from .models import Project from .serializers import ProjectSerializer, ProjectCreationSerializer +from .decorators import project_basic_permission_required User = get_user_model() @@ -34,27 +36,27 @@ 404: openapi.Response(description="Organization not found"), }, operation_description="Create a new project.", - tags=["project management"] + tags=["Project"] ) @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 @@ -83,30 +85,32 @@ def __internal_func(request, id): } ), responses={ - 200: openapi.Response(description="List of projects", schema=ProjectSerializer(many=True)), + 200: openapi.Response(description="Successfully get list of projects", schema=ProjectSerializer(many=True)), 404: openapi.Response(description="Organization not found"), - 403: openapi.Response(description="You do not have the required permissions to view projects in this organization"), + 403: openapi.Response(description="Authenticated user is not a member of this organization"), }, operation_description="Retrieve a list of projects with pagination.", - tags=["project management"] + tags=["Project"] ) @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) - - return __internal_func(request, id = org_id) + async 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 [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) + # 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 @@ -121,4 +125,24 @@ class CustomPagination(PageNumberPagination): result_page = paginator.paginate_queryset(projects, request) serializer = ProjectSerializer(result_page, many=True) - return paginator.get_paginated_response(serializer.data) \ No newline at end of file + return paginator.get_paginated_response(serializer.data) + + +@swagger_auto_schema( + method='get', + responses={ + 200: openapi.Response(description="Successfully get project basic info", schema=ProjectSerializer()), + 404: openapi.Response(description="Project not found"), + 403: openapi.Response(description="Authenticated user does not have permission of this project"), + }, + operation_description="Retrieve details of a project by its ID.", + tags=["Project"] +) +@api_view(['GET']) +@authentication_classes([SessionAuthentication]) +@permission_classes([IsAuthenticated]) +@project_basic_permission_required +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 f57be3e..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 @@ -16,12 +17,12 @@ ) }, operation_description="Get the authenticated user's basic information", - tags=["user"], + tags=["User"], ) @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..77a5676 100644 --- a/backend/oauth/views.py +++ b/backend/oauth/views.py @@ -7,10 +7,12 @@ from authlib.oidc.core import CodeIDToken from django.conf import settings 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 AllowAny from django.contrib.auth import get_user_model +from asgiref.sync import sync_to_async oauth = OAuth() @@ -39,7 +41,7 @@ def login_oauth(request, provider): @api_view(['POST']) @authentication_classes([SessionAuthentication]) @permission_classes([AllowAny]) -def auth_oauth(request, provider): +async def auth_oauth(request, provider): code = request.data.get('code') state = request.data.get('state') redirect_uri = request.session.get('redirect_uri') @@ -86,5 +88,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..80c3419 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 0214075..5d4db8d 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/.idea/.gitignore b/frontend/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/frontend/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/frontend/.idea/frontend.iml b/frontend/.idea/frontend.iml new file mode 100644 index 0000000..d6ebd48 --- /dev/null +++ b/frontend/.idea/frontend.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/frontend/.idea/inspectionProfiles/Project_Default.xml b/frontend/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..03d9549 --- /dev/null +++ b/frontend/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/frontend/.idea/misc.xml b/frontend/.idea/misc.xml new file mode 100644 index 0000000..e6be3f1 --- /dev/null +++ b/frontend/.idea/misc.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/frontend/.idea/modules.xml b/frontend/.idea/modules.xml new file mode 100644 index 0000000..f3d93d7 --- /dev/null +++ b/frontend/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/frontend/.idea/vcs.xml b/frontend/.idea/vcs.xml new file mode 100644 index 0000000..6c0b863 --- /dev/null +++ b/frontend/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/frontend/src/components/main-header.tsx b/frontend/src/components/main-header.tsx index f1949e5..1a5ebaf 100644 --- a/frontend/src/components/main-header.tsx +++ b/frontend/src/components/main-header.tsx @@ -7,7 +7,7 @@ const MainHeader = ({ breadcrumbs, title }) => { - + { breadcrumbs && breadcrumbs.map((item: any, index: number) => ( <> diff --git a/frontend/src/components/modals/change-member-role-modal.tsx b/frontend/src/components/modals/change-member-role-modal.tsx index 0345c71..729ea39 100644 --- a/frontend/src/components/modals/change-member-role-modal.tsx +++ b/frontend/src/components/modals/change-member-role-modal.tsx @@ -15,6 +15,7 @@ import { Text } from "@chakra-ui/react"; import { useToast } from "@/contexts/toast"; +import OrganizationContext from "@/contexts/organization"; import { useTranslation } from "react-i18next"; import { modifyMemberRole } from "@/services/organization"; import { MemberRoleEnum } from "@/models/organization"; @@ -39,6 +40,7 @@ const ChangeMemberRoleModal: React.FC = ({ const cancelRef = useRef(); const { t } = useTranslation(); const toast = useToast(); + const orgCtx = useContext(OrganizationContext); const [newRole, setNewRole] = useState(null); const handleChangeMemberRole = async () => { @@ -54,9 +56,7 @@ const ChangeMemberRoleModal: React.FC = ({ console.error("Failed to change user role:", error); if ( error.response && - (error.response.status === 400 || - error.response.status === 403 || - error.response.status === 404) + (error.response.status === 400 || error.response.status === 404) ) { toast({ title: t("ChangeMemberRoleModal.toast.error"), @@ -68,9 +68,7 @@ const ChangeMemberRoleModal: React.FC = ({ } onClose(); if (error.response && error.response.status === 403) { - setTimeout(() => { - window.location.reload(); - }, 2000); + orgCtx.toastNoPermissionAndRedirect(); } } }; diff --git a/frontend/src/components/modals/invite-members-modal.tsx b/frontend/src/components/modals/invite-members-modal.tsx index ff51f0b..e864ad7 100644 --- a/frontend/src/components/modals/invite-members-modal.tsx +++ b/frontend/src/components/modals/invite-members-modal.tsx @@ -1,3 +1,4 @@ +import { useContext } from "react"; import { useToast } from "@/contexts/toast"; import { createInvitation } from "@/services/organization"; import { @@ -21,6 +22,7 @@ import { useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { FiCopy } from "react-icons/fi"; import copy from 'copy-to-clipboard'; +import OrganizationContext from "@/contexts/organization"; interface InviteMembersModalProps { id: number; @@ -36,6 +38,7 @@ const InviteMembersModal: React.FC = ({ const { isOpen, onOpen, onClose } = useDisclosure(); const { t } = useTranslation(); const toast = useToast(); + const orgCtx = useContext(OrganizationContext); const initialRef = useRef(null); const [email, setEmail] = useState(""); @@ -62,9 +65,7 @@ const InviteMembersModal: React.FC = ({ console.error("Failed to create invitation:", error); if ( error.response && - (error.response.status === 403 || - error.response.status === 404 || - error.response.status === 409) + (error.response.status === 404 || error.response.status === 409) ) { toast({ title: t("InviteMembersModal.toast.error"), @@ -76,9 +77,7 @@ const InviteMembersModal: React.FC = ({ } if (error.response && error.response.status === 403) { onClose(); - setTimeout(() => { - window.location.reload(); - }, 2000); + orgCtx.toastNoPermissionAndRedirect(); } return false; } diff --git a/frontend/src/components/modals/remove-member-alert-dialog.tsx b/frontend/src/components/modals/remove-member-alert-dialog.tsx index 1aa5e0f..ad911b0 100644 --- a/frontend/src/components/modals/remove-member-alert-dialog.tsx +++ b/frontend/src/components/modals/remove-member-alert-dialog.tsx @@ -50,9 +50,7 @@ const RemoveMemberAlertDialog: React.FC = ({ console.error("Failed to remove member:", error); if ( error.response && - (error.response.status === 400 || - error.response.status === 403 || - error.response.status === 404) + (error.response.status === 400 || error.response.status === 404) ) { toast({ title: t("RemoveUserAlertDialog.toast.error"), @@ -64,9 +62,7 @@ const RemoveMemberAlertDialog: React.FC = ({ } onClose(); if (error.response && error.response.status === 403) { - setTimeout(() => { - window.location.reload(); - }, 2000); + orgCtx.toastNoPermissionAndRedirect(); } }}; diff --git a/frontend/src/components/nav-menu.tsx b/frontend/src/components/nav-menu.tsx index 1c633e5..f025b50 100644 --- a/frontend/src/components/nav-menu.tsx +++ b/frontend/src/components/nav-menu.tsx @@ -11,18 +11,21 @@ export interface NavMenuProps { items: MenuItem[]; selectedKeys?: string[]; onClick?: (value: string) => void; + size?: string; } const NavMenu: React.FC = ({ items, selectedKeys = [], onClick, + size = 'sm' }) => { return ( {items.map((item) => ( onClick && onClick(item.value)} > diff --git a/frontend/src/components/nav-tabs.tsx b/frontend/src/components/nav-tabs.tsx index 58c33bb..4d13422 100644 --- a/frontend/src/components/nav-tabs.tsx +++ b/frontend/src/components/nav-tabs.tsx @@ -4,6 +4,7 @@ import { NavMenuProps as NavTabsProps } from '@/components/nav-menu'; const NavTabs: React.FC = ({ items, + size = 'sm', selectedKeys = [], // always be [router.asPath] onClick, }) => { @@ -15,7 +16,7 @@ const NavTabs: React.FC = ({ return ( diff --git a/frontend/src/components/selectable-button.tsx b/frontend/src/components/selectable-button.tsx index e2b249c..e3ed946 100644 --- a/frontend/src/components/selectable-button.tsx +++ b/frontend/src/components/selectable-button.tsx @@ -15,7 +15,6 @@ const SelectableButton: React.FC = ({ isSelected = false, return (