diff --git a/apiserver/plane/app/serializers/cycle.py b/apiserver/plane/app/serializers/cycle.py index bf08de4fef9..171494f03d5 100644 --- a/apiserver/plane/app/serializers/cycle.py +++ b/apiserver/plane/app/serializers/cycle.py @@ -5,6 +5,7 @@ from .base import BaseSerializer from .issue import IssueStateSerializer from plane.db.models import Cycle, CycleIssue, CycleUserProperties +from plane.utils.timezone_converter import convert_to_utc class CycleWriteSerializer(BaseSerializer): @@ -15,6 +16,17 @@ def validate(self, data): and data.get("start_date", None) > data.get("end_date", None) ): raise serializers.ValidationError("Start date cannot exceed end date") + if ( + data.get("start_date", None) is not None + and data.get("end_date", None) is not None + ): + project_id = self.initial_data.get("project_id") or self.instance.project_id + data["start_date"] = convert_to_utc( + str(data.get("start_date").date()), project_id + ) + data["end_date"] = convert_to_utc( + str(data.get("end_date", None).date()), project_id, is_end_date=True + ) return data class Meta: diff --git a/apiserver/plane/app/views/cycle/base.py b/apiserver/plane/app/views/cycle/base.py index 61ea9eed461..1addc5becd1 100644 --- a/apiserver/plane/app/views/cycle/base.py +++ b/apiserver/plane/app/views/cycle/base.py @@ -1,5 +1,7 @@ # Python imports import json +import pytz + # Django imports from django.contrib.postgres.aggregates import ArrayAgg @@ -52,6 +54,11 @@ # Module imports from .. import BaseAPIView, BaseViewSet from plane.bgtasks.webhook_task import model_activity +from plane.utils.timezone_converter import ( + convert_utc_to_project_timezone, + convert_to_utc, + user_timezone_converter, +) class CycleViewSet(BaseViewSet): @@ -67,6 +74,19 @@ def get_queryset(self): project_id=self.kwargs.get("project_id"), workspace__slug=self.kwargs.get("slug"), ) + + project = Project.objects.get(id=self.kwargs.get("project_id")) + + # Fetch project for the specific record or pass project_id dynamically + project_timezone = project.timezone + + # Convert the current time (timezone.now()) to the project's timezone + local_tz = pytz.timezone(project_timezone) + current_time_in_project_tz = timezone.now().astimezone(local_tz) + + # Convert project local time back to UTC for comparison (start_date is stored in UTC) + current_time_in_utc = current_time_in_project_tz.astimezone(pytz.utc) + return self.filter_queryset( super() .get_queryset() @@ -119,12 +139,15 @@ def get_queryset(self): .annotate( status=Case( When( - Q(start_date__lte=timezone.now()) - & Q(end_date__gte=timezone.now()), + Q(start_date__lte=current_time_in_utc) + & Q(end_date__gte=current_time_in_utc), then=Value("CURRENT"), ), - When(start_date__gt=timezone.now(), then=Value("UPCOMING")), - When(end_date__lt=timezone.now(), then=Value("COMPLETED")), + When( + start_date__gt=current_time_in_utc, + then=Value("UPCOMING"), + ), + When(end_date__lt=current_time_in_utc, then=Value("COMPLETED")), When( Q(start_date__isnull=True) & Q(end_date__isnull=True), then=Value("DRAFT"), @@ -160,10 +183,22 @@ def list(self, request, slug, project_id): # Update the order by queryset = queryset.order_by("-is_favorite", "-created_at") + project = Project.objects.get(id=self.kwargs.get("project_id")) + + # Fetch project for the specific record or pass project_id dynamically + project_timezone = project.timezone + + # Convert the current time (timezone.now()) to the project's timezone + local_tz = pytz.timezone(project_timezone) + current_time_in_project_tz = timezone.now().astimezone(local_tz) + + # Convert project local time back to UTC for comparison (start_date is stored in UTC) + current_time_in_utc = current_time_in_project_tz.astimezone(pytz.utc) + # Current Cycle if cycle_view == "current": queryset = queryset.filter( - start_date__lte=timezone.now(), end_date__gte=timezone.now() + start_date__lte=current_time_in_utc, end_date__gte=current_time_in_utc ) data = queryset.values( @@ -191,6 +226,8 @@ def list(self, request, slug, project_id): "version", "created_by", ) + datetime_fields = ["start_date", "end_date"] + data = user_timezone_converter(data, datetime_fields, project_timezone) if data: return Response(data, status=status.HTTP_200_OK) @@ -221,6 +258,8 @@ def list(self, request, slug, project_id): "version", "created_by", ) + datetime_fields = ["start_date", "end_date"] + data = user_timezone_converter(data, datetime_fields, project_timezone) return Response(data, status=status.HTTP_200_OK) @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) @@ -365,6 +404,7 @@ def partial_update(self, request, slug, project_id, pk): @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) def retrieve(self, request, slug, project_id, pk): + project = Project.objects.get(id=project_id) queryset = self.get_queryset().filter(archived_at__isnull=True).filter(pk=pk) data = ( self.get_queryset() @@ -417,6 +457,8 @@ def retrieve(self, request, slug, project_id, pk): ) queryset = queryset.first() + datetime_fields = ["start_date", "end_date"] + data = user_timezone_converter(data, datetime_fields, project.timezone) recent_visited_task.delay( slug=slug, @@ -492,6 +534,9 @@ def post(self, request, slug, project_id): status=status.HTTP_400_BAD_REQUEST, ) + start_date = convert_to_utc(str(start_date), project_id) + end_date = convert_to_utc(str(end_date), project_id, is_end_date=True) + # Check if any cycle intersects in the given interval cycles = Cycle.objects.filter( Q(workspace__slug=slug) diff --git a/apiserver/plane/app/views/issue/base.py b/apiserver/plane/app/views/issue/base.py index 7c15f91da9d..8fc463f630c 100644 --- a/apiserver/plane/app/views/issue/base.py +++ b/apiserver/plane/app/views/issue/base.py @@ -56,7 +56,7 @@ from plane.utils.order_queryset import order_issue_queryset from plane.utils.paginator import GroupedOffsetPaginator, SubGroupedOffsetPaginator from .. import BaseAPIView, BaseViewSet -from plane.utils.user_timezone_converter import user_timezone_converter +from plane.utils.timezone_converter import user_timezone_converter from plane.bgtasks.recent_visited_task import recent_visited_task from plane.utils.global_paginator import paginate from plane.bgtasks.webhook_task import model_activity diff --git a/apiserver/plane/app/views/issue/sub_issue.py b/apiserver/plane/app/views/issue/sub_issue.py index e461917fb38..19e2522d2c1 100644 --- a/apiserver/plane/app/views/issue/sub_issue.py +++ b/apiserver/plane/app/views/issue/sub_issue.py @@ -20,7 +20,7 @@ from plane.app.permissions import ProjectEntityPermission from plane.db.models import Issue, IssueLink, FileAsset, CycleIssue from plane.bgtasks.issue_activities_task import issue_activity -from plane.utils.user_timezone_converter import user_timezone_converter +from plane.utils.timezone_converter import user_timezone_converter from collections import defaultdict diff --git a/apiserver/plane/app/views/module/archive.py b/apiserver/plane/app/views/module/archive.py index 82c1d47eb4e..d5c632f966d 100644 --- a/apiserver/plane/app/views/module/archive.py +++ b/apiserver/plane/app/views/module/archive.py @@ -28,7 +28,7 @@ from plane.app.serializers import ModuleDetailSerializer from plane.db.models import Issue, Module, ModuleLink, UserFavorite, Project from plane.utils.analytics_plot import burndown_plot -from plane.utils.user_timezone_converter import user_timezone_converter +from plane.utils.timezone_converter import user_timezone_converter # Module imports diff --git a/apiserver/plane/app/views/module/base.py b/apiserver/plane/app/views/module/base.py index 8f9839b71f1..3e3a4c2db72 100644 --- a/apiserver/plane/app/views/module/base.py +++ b/apiserver/plane/app/views/module/base.py @@ -56,7 +56,7 @@ Project, ) from plane.utils.analytics_plot import burndown_plot -from plane.utils.user_timezone_converter import user_timezone_converter +from plane.utils.timezone_converter import user_timezone_converter from plane.bgtasks.webhook_task import model_activity from .. import BaseAPIView, BaseViewSet from plane.bgtasks.recent_visited_task import recent_visited_task diff --git a/apiserver/plane/utils/timezone_converter.py b/apiserver/plane/utils/timezone_converter.py new file mode 100644 index 00000000000..dc8e20b8c7c --- /dev/null +++ b/apiserver/plane/utils/timezone_converter.py @@ -0,0 +1,100 @@ +import pytz +from plane.db.models import Project +from datetime import datetime, time +from datetime import timedelta + +def user_timezone_converter(queryset, datetime_fields, user_timezone): + # Create a timezone object for the user's timezone + user_tz = pytz.timezone(user_timezone) + + # Check if queryset is a dictionary (single item) or a list of dictionaries + if isinstance(queryset, dict): + queryset_values = [queryset] + else: + queryset_values = list(queryset) + + # Iterate over the dictionaries in the list + for item in queryset_values: + # Iterate over the datetime fields + for field in datetime_fields: + # Convert the datetime field to the user's timezone + if field in item and item[field]: + item[field] = item[field].astimezone(user_tz) + + # If queryset was a single item, return a single item + if isinstance(queryset, dict): + return queryset_values[0] + else: + return queryset_values + + +def convert_to_utc(date, project_id, is_end_date=False): + """ + Converts a start date string to the project's local timezone at 12:00 AM + and then converts it to UTC for storage. + + Args: + date (str): The date string in "YYYY-MM-DD" format. + project_id (int): The project's ID to fetch the associated timezone. + + Returns: + datetime: The UTC datetime. + """ + # Retrieve the project's timezone using the project ID + project = Project.objects.get(id=project_id) + project_timezone = project.timezone + if not date or not project_timezone: + raise ValueError("Both date and timezone must be provided.") + + # Parse the string into a date object + start_date = datetime.strptime(date, "%Y-%m-%d").date() + + # Get the project's timezone + local_tz = pytz.timezone(project_timezone) + + # Combine the date with 12:00 AM time + local_datetime = datetime.combine(start_date, time.min) + + # Localize the datetime to the project's timezone + localized_datetime = local_tz.localize(local_datetime) + + # If it's an end date, subtract one minute + if is_end_date: + localized_datetime -= timedelta(minutes=1) + + # Convert the localized datetime to UTC + utc_datetime = localized_datetime.astimezone(pytz.utc) + + # Return the UTC datetime for storage + return utc_datetime + + +def convert_utc_to_project_timezone(utc_datetime, project_id): + """ + Converts a UTC datetime (stored in the database) to the project's local timezone. + + Args: + utc_datetime (datetime): The UTC datetime to be converted. + project_id (int): The project's ID to fetch the associated timezone. + + Returns: + datetime: The datetime in the project's local timezone. + """ + # Retrieve the project's timezone using the project ID + project = Project.objects.get(id=project_id) + project_timezone = project.timezone + if not project_timezone: + raise ValueError("Project timezone must be provided.") + + # Get the timezone object for the project's timezone + local_tz = pytz.timezone(project_timezone) + + # Convert the UTC datetime to the project's local timezone + if utc_datetime.tzinfo is None: + # Localize UTC datetime if it's naive (i.e., without timezone info) + utc_datetime = pytz.utc.localize(utc_datetime) + + # Convert to the project's local timezone + local_datetime = utc_datetime.astimezone(local_tz) + + return local_datetime diff --git a/apiserver/plane/utils/user_timezone_converter.py b/apiserver/plane/utils/user_timezone_converter.py deleted file mode 100644 index 550abfe997d..00000000000 --- a/apiserver/plane/utils/user_timezone_converter.py +++ /dev/null @@ -1,26 +0,0 @@ -import pytz - - -def user_timezone_converter(queryset, datetime_fields, user_timezone): - # Create a timezone object for the user's timezone - user_tz = pytz.timezone(user_timezone) - - # Check if queryset is a dictionary (single item) or a list of dictionaries - if isinstance(queryset, dict): - queryset_values = [queryset] - else: - queryset_values = list(queryset) - - # Iterate over the dictionaries in the list - for item in queryset_values: - # Iterate over the datetime fields - for field in datetime_fields: - # Convert the datetime field to the user's timezone - if field in item and item[field]: - item[field] = item[field].astimezone(user_tz) - - # If queryset was a single item, return a single item - if isinstance(queryset, dict): - return queryset_values[0] - else: - return queryset_values diff --git a/web/core/components/project/form.tsx b/web/core/components/project/form.tsx index 86da4a2f797..855c52aaf7f 100644 --- a/web/core/components/project/form.tsx +++ b/web/core/components/project/form.tsx @@ -16,7 +16,7 @@ import { CustomEmojiIconPicker, EmojiIconPickerTypes, Tooltip, - // CustomSearchSelect, + CustomSearchSelect, } from "@plane/ui"; // components import { Logo } from "@/components/common"; @@ -25,7 +25,7 @@ import { ImagePickerPopover } from "@/components/core"; import { PROJECT_UPDATED } from "@/constants/event-tracker"; import { NETWORK_CHOICES } from "@/constants/project"; // helpers -// import { TTimezone, TIME_ZONES } from "@/constants/timezones"; +import { TTimezone, TIME_ZONES } from "@/constants/timezones"; import { renderFormattedDate } from "@/helpers/date-time.helper"; import { convertHexEmojiToDecimal } from "@/helpers/emoji.helper"; import { getFileURL } from "@/helpers/file.helper"; @@ -68,20 +68,20 @@ export const ProjectDetailsForm: FC = (props) => { }); // derived values const currentNetwork = NETWORK_CHOICES.find((n) => n.key === project?.network); - // const getTimeZoneLabel = (timezone: TTimezone | undefined) => { - // if (!timezone) return undefined; - // return ( - //
- // {timezone.gmtOffset} - // {timezone.name} - //
- // ); - // }; - // const timeZoneOptions = TIME_ZONES.map((timeZone) => ({ - // value: timeZone.value, - // query: timeZone.name + " " + timeZone.gmtOffset + " " + timeZone.value, - // content: getTimeZoneLabel(timeZone), - // })); + const getTimeZoneLabel = (timezone: TTimezone | undefined) => { + if (!timezone) return undefined; + return ( +
+ {timezone.gmtOffset} + {timezone.name} +
+ ); + }; + const timeZoneOptions = TIME_ZONES.map((timeZone) => ({ + value: timeZone.value, + query: timeZone.name + " " + timeZone.gmtOffset + " " + timeZone.value, + content: getTimeZoneLabel(timeZone), + })); const coverImage = watch("cover_image_url"); useEffect(() => { @@ -146,7 +146,7 @@ export const ProjectDetailsForm: FC = (props) => { description: formData.description, logo_props: formData.logo_props, - // timezone: formData.timezone, + timezone: formData.timezone, }; // if unsplash or a pre-defined image is uploaded, delete the old uploaded asset if (formData.cover_image_url?.startsWith("http")) { @@ -386,7 +386,7 @@ export const ProjectDetailsForm: FC = (props) => { }} /> - {/*
+

Project Timezone

= (props) => { )} /> {errors.timezone && {errors.timezone.message}} -
*/} +
<>