Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[WEB-2843]chore: handled the cycle date time using project timezone #6187

Merged
merged 2 commits into from
Dec 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions apiserver/plane/app/serializers/cycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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:
Expand Down
55 changes: 50 additions & 5 deletions apiserver/plane/app/views/cycle/base.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# Python imports
import json
import pytz


# Django imports
from django.contrib.postgres.aggregates import ArrayAgg
Expand Down Expand Up @@ -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):
Expand All @@ -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()
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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])
Expand Down Expand Up @@ -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)
sriramveeraghanta marked this conversation as resolved.
Show resolved Hide resolved
queryset = self.get_queryset().filter(archived_at__isnull=True).filter(pk=pk)
data = (
self.get_queryset()
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion apiserver/plane/app/views/issue/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion apiserver/plane/app/views/issue/sub_issue.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
2 changes: 1 addition & 1 deletion apiserver/plane/app/views/module/archive.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion apiserver/plane/app/views/module/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
100 changes: 100 additions & 0 deletions apiserver/plane/utils/timezone_converter.py
Original file line number Diff line number Diff line change
@@ -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)
sriramveeraghanta marked this conversation as resolved.
Show resolved Hide resolved

# 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)
sriramveeraghanta marked this conversation as resolved.
Show resolved Hide resolved
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)
Comment on lines +62 to +63
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Re-evaluate the logic for 'is_end_date' adjustment

Subtracting one minute from the start of the end date may lead to unexpected results, such as the end date being the previous day. Consider setting the time to the end of the day to accurately represent the end date:

# Instead of subtracting one minute
-localized_datetime -= timedelta(minutes=1)
+localized_datetime = localized_datetime.replace(hour=23, minute=59, second=59, microsecond=999999)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if is_end_date:
localized_datetime -= timedelta(minutes=1)
if is_end_date:
localized_datetime = localized_datetime.replace(hour=23, minute=59, second=59, microsecond=999999)


# 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
26 changes: 0 additions & 26 deletions apiserver/plane/utils/user_timezone_converter.py

This file was deleted.

38 changes: 19 additions & 19 deletions web/core/components/project/form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {
CustomEmojiIconPicker,
EmojiIconPickerTypes,
Tooltip,
// CustomSearchSelect,
CustomSearchSelect,
} from "@plane/ui";
// components
import { Logo } from "@/components/common";
Expand All @@ -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";
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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")) {
Expand Down Expand Up @@ -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"
Expand All @@ -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">
<>
Expand Down
Loading