diff --git a/apiserver/plane/app/serializers/cycle.py b/apiserver/plane/app/serializers/cycle.py index 5a0b2e74a..37510e1f9 100644 --- a/apiserver/plane/app/serializers/cycle.py +++ b/apiserver/plane/app/serializers/cycle.py @@ -78,6 +78,7 @@ class CycleSerializer(BaseSerializer): "workspace_id", "project_id", # model fields + "name", "description", "start_date", "end_date", diff --git a/apiserver/plane/app/serializers/workspace.py b/apiserver/plane/app/serializers/workspace.py index b17828f83..69f827c24 100644 --- a/apiserver/plane/app/serializers/workspace.py +++ b/apiserver/plane/app/serializers/workspace.py @@ -18,7 +18,7 @@ from plane.db.models import ( class WorkSpaceSerializer(DynamicBaseSerializer): - owner_id = serializers.PrimaryKeyRelatedField(read_only=True) + owner = UserLiteSerializer(read_only=True) total_members = serializers.IntegerField(read_only=True) total_issues = serializers.IntegerField(read_only=True) @@ -48,7 +48,7 @@ class WorkSpaceSerializer(DynamicBaseSerializer): "updated_by", "created_at", "updated_at", - "owner_id", + "owner", ] diff --git a/apiserver/plane/app/views/estimate.py b/apiserver/plane/app/views/estimate.py index 3402bb068..4125fb045 100644 --- a/apiserver/plane/app/views/estimate.py +++ b/apiserver/plane/app/views/estimate.py @@ -11,13 +11,14 @@ from plane.app.serializers import ( EstimatePointSerializer, EstimateReadSerializer, ) - +from plane.utils.cache import cache_path_response, invalidate_path_cache class ProjectEstimatePointEndpoint(BaseAPIView): permission_classes = [ ProjectEntityPermission, ] + @cache_path_response(60 * 60 * 2) def get(self, request, slug, project_id): project = Project.objects.get(workspace__slug=slug, pk=project_id) if project.estimate_id is not None: @@ -38,6 +39,7 @@ class BulkEstimatePointEndpoint(BaseViewSet): model = Estimate serializer_class = EstimateSerializer + @cache_path_response(60 * 60 * 2) def list(self, request, slug, project_id): estimates = ( Estimate.objects.filter( @@ -49,6 +51,9 @@ class BulkEstimatePointEndpoint(BaseViewSet): serializer = EstimateReadSerializer(estimates, many=True) return Response(serializer.data, status=status.HTTP_200_OK) + @invalidate_path_cache("/api/workspaces/:slug/estimates/", True) + @invalidate_path_cache("/api/workspaces/:slug/projects/:project_id/estimates/", True) + @invalidate_path_cache("/api/workspaces/:slug/projects/:project_id/project-estimates/", True) def create(self, request, slug, project_id): if not request.data.get("estimate", False): return Response( @@ -114,6 +119,9 @@ class BulkEstimatePointEndpoint(BaseViewSet): status=status.HTTP_200_OK, ) + @invalidate_path_cache("/api/workspaces/:slug/estimates/", True) + @invalidate_path_cache("/api/workspaces/:slug/projects/:project_id/estimates/", True) + @invalidate_path_cache("/api/workspaces/:slug/projects/:project_id/project-estimates/", True) def partial_update(self, request, slug, project_id, estimate_id): if not request.data.get("estimate", False): return Response( diff --git a/apiserver/plane/app/views/issue.py b/apiserver/plane/app/views/issue.py index c8845150a..26b172e15 100644 --- a/apiserver/plane/app/views/issue.py +++ b/apiserver/plane/app/views/issue.py @@ -81,7 +81,7 @@ from plane.bgtasks.issue_activites_task import issue_activity from plane.utils.grouper import group_results from plane.utils.issue_filters import issue_filters from collections import defaultdict - +from plane.utils.cache import cache_path_response, invalidate_path_cache class IssueViewSet(WebhookMixin, BaseViewSet): def get_serializer_class(self): @@ -1111,6 +1111,7 @@ class IssueArchiveViewSet(BaseViewSet): ) @method_decorator(gzip_page) + @cache_path_response(60 * 60 * 3) def list(self, request, slug, project_id): fields = [ field @@ -1217,6 +1218,7 @@ class IssueArchiveViewSet(BaseViewSet): ) return Response(IssueSerializer(issue).data, status=status.HTTP_200_OK) + @invalidate_path_cache("/api/workspaces/:slug/projects/:project_id/archived-issues/", True) def unarchive(self, request, slug, project_id, pk=None): issue = Issue.objects.get( workspace__slug=slug, diff --git a/apiserver/plane/app/views/project.py b/apiserver/plane/app/views/project.py index 5d2f95673..e63e1ceab 100644 --- a/apiserver/plane/app/views/project.py +++ b/apiserver/plane/app/views/project.py @@ -65,7 +65,7 @@ from plane.db.models import ( ) from plane.bgtasks.project_invitation_task import project_invitation - +from plane.utils.cache import cache_path_response, invalidate_path_cache class ProjectViewSet(WebhookMixin, BaseViewSet): serializer_class = ProjectListSerializer @@ -662,6 +662,7 @@ class ProjectMemberViewSet(BaseViewSet): .select_related("workspace", "workspace__owner") ) + @invalidate_path_cache("/api/workspaces/:slug/projects/:project_id/members/", True) def create(self, request, slug, project_id): members = request.data.get("members", []) @@ -738,6 +739,7 @@ class ProjectMemberViewSet(BaseViewSet): serializer = ProjectMemberRoleSerializer(project_members, many=True) return Response(serializer.data, status=status.HTTP_201_CREATED) + @cache_path_response(60 * 60 * 2) def list(self, request, slug, project_id): # Get the list of project members for the project project_members = ProjectMember.objects.filter( @@ -752,6 +754,7 @@ class ProjectMemberViewSet(BaseViewSet): ) return Response(serializer.data, status=status.HTTP_200_OK) + @invalidate_path_cache("/api/workspaces/:slug/projects/:project_id/members/", True) def partial_update(self, request, slug, project_id, pk): project_member = ProjectMember.objects.get( pk=pk, @@ -792,6 +795,7 @@ class ProjectMemberViewSet(BaseViewSet): return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + @invalidate_path_cache("/api/workspaces/:slug/projects/:project_id/members/", True) def destroy(self, request, slug, project_id, pk): project_member = ProjectMember.objects.get( workspace__slug=slug, diff --git a/apiserver/plane/app/views/state.py b/apiserver/plane/app/views/state.py index 242061e18..0a880c737 100644 --- a/apiserver/plane/app/views/state.py +++ b/apiserver/plane/app/views/state.py @@ -16,7 +16,7 @@ from plane.app.permissions import ( WorkspaceEntityPermission, ) from plane.db.models import State, Issue - +from plane.utils.cache import cache_path_response, invalidate_path_cache class StateViewSet(BaseViewSet): serializer_class = StateSerializer @@ -38,6 +38,8 @@ class StateViewSet(BaseViewSet): .distinct() ) + @invalidate_path_cache() + @invalidate_path_cache("workspaces/:slug/states/", True) def create(self, request, slug, project_id): serializer = StateSerializer(data=request.data) if serializer.is_valid(): @@ -45,6 +47,7 @@ class StateViewSet(BaseViewSet): return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + @cache_path_response(60 * 60 * 1) def list(self, request, slug, project_id): states = StateSerializer(self.get_queryset(), many=True).data grouped = request.GET.get("grouped", False) @@ -58,6 +61,8 @@ class StateViewSet(BaseViewSet): return Response(state_dict, status=status.HTTP_200_OK) return Response(states, status=status.HTTP_200_OK) + @invalidate_path_cache("/api/workspaces/:slug/projects/:project_id/states/", True) + @invalidate_path_cache("workspaces/:slug/states/", True) def mark_as_default(self, request, slug, project_id, pk): # Select all the states which are marked as default _ = State.objects.filter( @@ -68,6 +73,8 @@ class StateViewSet(BaseViewSet): ).update(default=True) return Response(status=status.HTTP_204_NO_CONTENT) + @invalidate_path_cache("/api/workspaces/:slug/projects/:project_id/states/", True) + @invalidate_path_cache("workspaces/:slug/states/", True) def destroy(self, request, slug, project_id, pk): state = State.objects.get( ~Q(name="Triage"), diff --git a/apiserver/plane/app/views/workspace.py b/apiserver/plane/app/views/workspace.py index dc0c17cbf..07854aded 100644 --- a/apiserver/plane/app/views/workspace.py +++ b/apiserver/plane/app/views/workspace.py @@ -83,6 +83,11 @@ from plane.app.permissions import ( from plane.bgtasks.workspace_invitation_task import workspace_invitation from plane.utils.issue_filters import issue_filters from plane.bgtasks.event_tracking_task import workspace_invite_event +from plane.utils.cache import ( + cache_path_response, + invalidate_path_cache, + cache_user_response, +) class WorkSpaceViewSet(BaseViewSet): @@ -542,6 +547,7 @@ class WorkSpaceMemberViewSet(BaseViewSet): .select_related("member") ) + @cache_path_response(60 * 5) def list(self, request, slug): workspace_member = WorkspaceMember.objects.get( member=request.user, @@ -566,6 +572,7 @@ class WorkSpaceMemberViewSet(BaseViewSet): ) return Response(serializer.data, status=status.HTTP_200_OK) + @invalidate_path_cache("/api/workspaces/:slug/members/", True) def partial_update(self, request, slug, pk): workspace_member = WorkspaceMember.objects.get( pk=pk, @@ -608,6 +615,7 @@ class WorkSpaceMemberViewSet(BaseViewSet): return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + @invalidate_path_cache("/api/workspaces/:slug/members/", True) def destroy(self, request, slug, pk): # Check the user role who is deleting the user workspace_member = WorkspaceMember.objects.get( @@ -672,6 +680,7 @@ class WorkSpaceMemberViewSet(BaseViewSet): workspace_member.save() return Response(status=status.HTTP_204_NO_CONTENT) + @invalidate_path_cache("/api/workspaces/:slug/members/", True) def leave(self, request, slug): workspace_member = WorkspaceMember.objects.get( workspace__slug=slug, @@ -868,6 +877,8 @@ class UserLastProjectWithWorkspaceEndpoint(BaseAPIView): class WorkspaceMemberUserEndpoint(BaseAPIView): + + @cache_user_response(60 * 60) def get(self, request, slug): workspace_member = WorkspaceMember.objects.get( member=request.user, @@ -1433,6 +1444,7 @@ class WorkspaceLabelsEndpoint(BaseAPIView): WorkspaceViewerPermission, ] + @cache_path_response(60 * 60 * 1) def get(self, request, slug): labels = Label.objects.filter( workspace__slug=slug, @@ -1447,6 +1459,7 @@ class WorkspaceStatesEndpoint(BaseAPIView): WorkspaceEntityPermission, ] + @cache_path_response(60 * 60 * 1) def get(self, request, slug): states = State.objects.filter( workspace__slug=slug, @@ -1461,6 +1474,7 @@ class WorkspaceEstimatesEndpoint(BaseAPIView): WorkspaceEntityPermission, ] + @cache_path_response(60 * 60 * 1) def get(self, request, slug): estimate_ids = Project.objects.filter( workspace__slug=slug, estimate__isnull=False diff --git a/apiserver/plane/settings/local.py b/apiserver/plane/settings/local.py index 8f27d4234..60aa3e064 100644 --- a/apiserver/plane/settings/local.py +++ b/apiserver/plane/settings/local.py @@ -14,7 +14,11 @@ EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" CACHES = { "default": { - "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + "BACKEND": "django_redis.cache.RedisCache", + "LOCATION": REDIS_URL, + "OPTIONS": { + "CLIENT_CLASS": "django_redis.client.DefaultClient", + }, } } diff --git a/apiserver/plane/utils/cache.py b/apiserver/plane/utils/cache.py index 134a039c0..686a68168 100644 --- a/apiserver/plane/utils/cache.py +++ b/apiserver/plane/utils/cache.py @@ -30,9 +30,6 @@ def cache_user_response(timeout, path=None): if response.status_code == 200: cache.set(key, {'data': response.data, 'status': response.status_code}, timeout) - response['Cache-Control'] = f'max-age={timeout}' - expires_time = datetime.utcnow() + timedelta(seconds=timeout) - response['Expires'] = http_date(expires_time.timestamp()) return response return _wrapped_view @@ -59,6 +56,7 @@ def cache_path_response(timeout, path=None): @wraps(view_func) def _wrapped_view(instance, request, *args, **kwargs): # Function to generate cache key + print(request.get_full_path()) custom_path = path if path is not None else request.get_full_path() key = generate_cache_key(custom_path, None) cached_result = cache.get(key) @@ -69,20 +67,25 @@ def cache_path_response(timeout, path=None): if response.status_code == 200: cache.set(key, {'data': response.data, 'status': response.status_code}, timeout) - response['Cache-Control'] = f'max-age={timeout}' - expires_time = datetime.utcnow() + timedelta(seconds=timeout) - response['Expires'] = http_date(expires_time.timestamp()) return response return _wrapped_view return decorator -def invalidate_path_cache(path=None): +def invalidate_path_cache(path=None, include_url_params=False): def decorator(view_func): @wraps(view_func) def _wrapped_view(instance, request, *args, **kwargs): # Invalidate cache before executing the view function - custom_path = path if path is not None else request.get_full_path() + if include_url_params: + path_with_values = path + for key, value in kwargs.items(): + path_with_values = path_with_values.replace(f":{key}", str(value)) + + custom_path = path_with_values + else: + custom_path = path if path is not None else request.get_full_path() + key = generate_cache_key(custom_path, None) cache.delete(key)