[WEB-4744] Fix: Use the project timezone at midnight to generate the cron expression #3963

This commit is contained in:
Dheeraj Kumar Ketireddy
2025-08-22 12:55:20 +05:30
committed by GitHub
parent 13f2e26d91
commit 8deeb37362

View File

@@ -64,7 +64,7 @@ class RecurringWorkitemTask(ProjectBaseModel):
interval_seconds = models.PositiveIntegerField(
null=True,
blank=True,
help_text="Simple repeat interval (≥ 60s). Mutually exclusive with cron_expression",
help_text="Simple repeat interval (≥ 60s). Mutually exclusive with cron_expression", # noqa: E501
)
cron_expression = models.CharField(
max_length=100,
@@ -110,7 +110,7 @@ class RecurringWorkitemTask(ProjectBaseModel):
Args:
start_at (datetime): The datetime when the task should start
interval_type (str): One of 'second', 'daily', 'week', 'month', 'year'
project_timezone (Optional[str]): IANA timezone for the project; used to compute the correct local day boundaries
project_timezone (Optional[str]): IANA timezone for the project;
Returns:
str: Cron expression in format "minute hour day month day_of_week"
@@ -125,7 +125,7 @@ class RecurringWorkitemTask(ProjectBaseModel):
valid_intervals = [choice[0] for choice in cls.INTERVAL_CHOICES]
if interval_type not in valid_intervals:
raise ValidationError(
f"Invalid interval_type: {interval_type}. Must be one of {valid_intervals}"
f"Invalid interval_type: {interval_type}. Must be one of {valid_intervals}" # noqa: E501
)
if not start_at:
@@ -140,20 +140,22 @@ class RecurringWorkitemTask(ProjectBaseModel):
except Exception:
tz = pytz.UTC
# Normalize to project-local midnight, then convert back to UTC
local_midnight = aware_start.astimezone(tz).replace(hour=0, minute=0, second=0, microsecond=0)
utc_dt = local_midnight.astimezone(pytz.UTC)
# Normalize to project-local midnight
local_midnight = aware_start.astimezone(tz).replace(
hour=0, minute=0, second=0, microsecond=0
)
# Extract cron fields
minute, hour, day, month = utc_dt.minute, utc_dt.hour, utc_dt.day, utc_dt.month
# Extract cron fields from project timezone (not UTC) since CrontabSchedule
# will be created with project_timezone
minute, hour = local_midnight.minute, local_midnight.hour
day, month = local_midnight.day, local_midnight.month
cron_day_of_week = cls._convert_python_weekday_to_cron(local_midnight.weekday())
# Generate cron expression based on interval type
if interval_type == cls.INTERVAL_DAILY:
return f"{minute} {hour} {cls.CRON_WILDCARD} {cls.CRON_WILDCARD} {cls.CRON_WILDCARD}"
return f"{minute} {hour} {cls.CRON_WILDCARD} {cls.CRON_WILDCARD} {cls.CRON_WILDCARD}" # noqa: E501
elif interval_type == cls.INTERVAL_WEEKLY:
return f"{minute} {hour} {cls.CRON_WILDCARD} {cls.CRON_WILDCARD} {cron_day_of_week}"
return f"{minute} {hour} {cls.CRON_WILDCARD} {cls.CRON_WILDCARD} {cron_day_of_week}" # noqa: E501
elif interval_type == cls.INTERVAL_MONTHLY:
return f"{minute} {hour} {day} {cls.CRON_WILDCARD} {cls.CRON_WILDCARD}"
elif interval_type == cls.INTERVAL_YEARLY:
@@ -161,7 +163,7 @@ class RecurringWorkitemTask(ProjectBaseModel):
@classmethod
def _convert_python_weekday_to_cron(cls, python_weekday):
"""Convert Python weekday (0=Monday, 6=Sunday) to cron format (0=Sunday, 6=Saturday)"""
"""Convert Python weekday (0=Monday, 6=Sunday) to cron format (0=Sunday, 6=Saturday)""" # noqa: E501
return (
cls.CRON_SUNDAY
if python_weekday == cls.PYTHON_SUNDAY
@@ -169,7 +171,7 @@ class RecurringWorkitemTask(ProjectBaseModel):
)
def save(self, *args, **kwargs):
"""Override save to generate cron expression and manage PeriodicTask synchronization"""
"""Override save to generate cron expression and manage PeriodicTask synchronization""" # noqa: E501
# Auto-generate cron expression from interval_type and start_at
if self.interval_type and self.start_at:
project_tz = getattr(self.project, "timezone", None)
@@ -191,18 +193,18 @@ class RecurringWorkitemTask(ProjectBaseModel):
self._sync_periodic_task()
def delete(self, *args, **kwargs):
"""Override delete to clean up associated PeriodicTask and avoid re-sync during soft delete"""
"""Override delete to clean up associated PeriodicTask and avoid re-sync during soft delete""" # noqa: E501
# Ensure save() doesn't attempt to re-sync during soft delete
self._skip_periodic_sync = True
# If there is an associated periodic task, delete it and null out the relation on this instance
# If there is an associated periodic task, delete it and null out the relation on this instance # noqa: E501
if self.periodic_task_id is not None:
try:
self.periodic_task.delete()
except PeriodicTask.DoesNotExist:
pass
finally:
# Null local relation to avoid "unsaved related object" on save during soft delete
# Null local relation to avoid "unsaved related object" on save during soft delete # noqa: E501
self.periodic_task = None
return super().delete(*args, **kwargs)
@@ -332,7 +334,7 @@ class RecurringWorkitemTask(ProjectBaseModel):
Detect interval type from cron expression pattern.
Returns:
str: One of 'daily', 'week', 'month', 'year', or None if pattern doesn't match
str: One of 'daily', 'week', 'month', 'year', or None
"""
if not self.cron_expression:
return None
@@ -432,7 +434,7 @@ class RecurringWorkitemTask(ProjectBaseModel):
class RecurringWorkitemTaskLog(ProjectBaseModel):
"""
One row per execution; records status, timestamps, and the concrete Work Item produced.
One row per execution; records status, timestamps, and the concrete WorkItem created
Provides audit trail for recurring workitem creation.
"""
@@ -477,7 +479,7 @@ class RecurringWorkitemTaskLog(ProjectBaseModel):
ordering = ("-started_at",)
def __str__(self):
return f"<{self.recurring_task.workitem_blueprint.name} {self.status} {self.started_at}>"
return f"<{self.recurring_task.workitem_blueprint.name} {self.status} {self.started_at}>" # noqa: E501
@property
def duration(self):
@@ -489,7 +491,7 @@ class RecurringWorkitemTaskLog(ProjectBaseModel):
class RecurringWorkItemTaskActivity(ProjectBaseModel):
"""
One row per execution; records status, timestamps, and the concrete Work Item produced.
One row per execution; records status, timestamps, and the concrete WorkItem created
Provides audit trail for recurring workitem creation.
"""