chore: handled the cycle date time using project timezone (#6187)
* chore: handled the cycle date time using project timezone * chore: reverted the frontend commit
This commit is contained in:
parent
38e8a5c807
commit
9ad8b43408
9 changed files with 185 additions and 54 deletions
|
|
@ -5,6 +5,7 @@ from rest_framework import serializers
|
|||
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 @@ class CycleWriteSerializer(BaseSerializer):
|
|||
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:
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
# Python imports
|
||||
import json
|
||||
import pytz
|
||||
|
||||
|
||||
# Django imports
|
||||
from django.contrib.postgres.aggregates import ArrayAgg
|
||||
|
|
@ -52,6 +54,11 @@ from plane.bgtasks.recent_visited_task import recent_visited_task
|
|||
# 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 @@ class CycleViewSet(BaseViewSet):
|
|||
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 @@ class CycleViewSet(BaseViewSet):
|
|||
.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 @@ class CycleViewSet(BaseViewSet):
|
|||
# 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 @@ class CycleViewSet(BaseViewSet):
|
|||
"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 @@ class CycleViewSet(BaseViewSet):
|
|||
"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 @@ class CycleViewSet(BaseViewSet):
|
|||
|
||||
@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 @@ class CycleViewSet(BaseViewSet):
|
|||
)
|
||||
|
||||
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 @@ class CycleDateCheckEndpoint(BaseAPIView):
|
|||
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)
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ from plane.utils.issue_filters import issue_filters
|
|||
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
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ from plane.app.serializers import IssueSerializer
|
|||
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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ from plane.app.permissions import ProjectEntityPermission
|
|||
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
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ from plane.db.models import (
|
|||
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
|
||||
|
|
|
|||
100
apiserver/plane/utils/timezone_converter.py
Normal file
100
apiserver/plane/utils/timezone_converter.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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<IProjectDetailsForm> = (props) => {
|
|||
});
|
||||
// derived values
|
||||
const currentNetwork = NETWORK_CHOICES.find((n) => n.key === project?.network);
|
||||
// const getTimeZoneLabel = (timezone: TTimezone | undefined) => {
|
||||
// if (!timezone) return undefined;
|
||||
// return (
|
||||
// <div className="flex gap-1.5">
|
||||
// <span className="text-custom-text-400">{timezone.gmtOffset}</span>
|
||||
// <span className="text-custom-text-200">{timezone.name}</span>
|
||||
// </div>
|
||||
// );
|
||||
// };
|
||||
// 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 (
|
||||
<div className="flex gap-1.5">
|
||||
<span className="text-custom-text-400">{timezone.gmtOffset}</span>
|
||||
<span className="text-custom-text-200">{timezone.name}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
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<IProjectDetailsForm> = (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<IProjectDetailsForm> = (props) => {
|
|||
}}
|
||||
/>
|
||||
</div>
|
||||
{/* <div className="flex flex-col gap-1 col-span-1 sm:col-span-2 xl:col-span-1">
|
||||
<div className="flex flex-col gap-1 col-span-1 sm:col-span-2 xl:col-span-1">
|
||||
<h4 className="text-sm">Project Timezone</h4>
|
||||
<Controller
|
||||
name="timezone"
|
||||
|
|
@ -410,7 +410,7 @@ export const ProjectDetailsForm: FC<IProjectDetailsForm> = (props) => {
|
|||
)}
|
||||
/>
|
||||
{errors.timezone && <span className="text-xs text-red-500">{errors.timezone.message}</span>}
|
||||
</div> */}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue