[WEB-4393] chore: added recurring work item activity model (#3847)

* chore: added recurring work item activity model

* chore: added a better description
This commit is contained in:
Bavisetti Narayan
2025-08-06 19:52:17 +05:30
committed by GitHub
parent 0a6f160530
commit b5ebf625ee
3 changed files with 541 additions and 14 deletions

View File

@@ -0,0 +1,169 @@
# Generated by Django 4.2.22 on 2025-08-05 15:21
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import uuid
class Migration(migrations.Migration):
dependencies = [
("django_celery_beat", "0018_improve_crontab_helptext"),
("db", "0100_profile_has_marketing_email_consent_and_more"),
("ee", "0041_workitemtemplate_parent_workitem_template_and_more"),
]
operations = [
migrations.AddField(
model_name="recurringworkitemtask",
name="interval_type",
field=models.CharField(default="monthly", max_length=20),
),
migrations.AlterField(
model_name="recurringworkitemtask",
name="periodic_task",
field=models.OneToOneField(
blank=True,
help_text="Keeps Beat in sync with this row",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="recurring_workitem_task",
to="django_celery_beat.periodictask",
),
),
migrations.CreateModel(
name="RecurringWorkItemTaskActivity",
fields=[
(
"created_at",
models.DateTimeField(auto_now_add=True, verbose_name="Created At"),
),
(
"updated_at",
models.DateTimeField(
auto_now=True, verbose_name="Last Modified At"
),
),
(
"deleted_at",
models.DateTimeField(
blank=True, null=True, verbose_name="Deleted At"
),
),
(
"id",
models.UUIDField(
db_index=True,
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
unique=True,
),
),
(
"verb",
models.CharField(
default="created", max_length=255, verbose_name="Action"
),
),
(
"field",
models.CharField(
blank=True, max_length=255, null=True, verbose_name="Field Name"
),
),
(
"old_value",
models.TextField(blank=True, null=True, verbose_name="Old Value"),
),
(
"new_value",
models.TextField(blank=True, null=True, verbose_name="New Value"),
),
("old_identifier", models.UUIDField(null=True)),
("new_identifier", models.UUIDField(null=True)),
("epoch", models.FloatField(null=True)),
(
"actor",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="recurring_workitem_task_activities",
to=settings.AUTH_USER_MODEL,
),
),
(
"created_by",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="%(class)s_created_by",
to=settings.AUTH_USER_MODEL,
verbose_name="Created By",
),
),
(
"project",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="project_%(class)s",
to="db.project",
),
),
(
"property",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="recurring_workitem_task_activities",
to="ee.issueproperty",
),
),
(
"recurring_workitem_task",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="recurring_workitem_task_activities",
to="ee.recurringworkitemtask",
),
),
(
"recurring_workitem_task_log",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="recurring_workitem_task_activities",
to="ee.recurringworkitemtasklog",
),
),
(
"updated_by",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="%(class)s_updated_by",
to=settings.AUTH_USER_MODEL,
verbose_name="Last Modified By",
),
),
(
"workspace",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="workspace_%(class)s",
to="db.workspace",
),
),
],
options={
"verbose_name": "Recurring Workitem Task Activity",
"verbose_name_plural": "Recurring Workitem Task Activities",
"db_table": "recurring_workitem_task_activities",
"ordering": ("-created_at",),
},
),
]

View File

@@ -104,4 +104,5 @@ from .page import PageUser
from .recurring import (
RecurringWorkitemTask,
RecurringWorkitemTaskLog,
RecurringWorkItemTaskActivity,
)

View File

@@ -1,6 +1,10 @@
import json
# Django imports
from django.conf import settings
from django.db import models
from django_celery_beat.models import PeriodicTask
from django.core.exceptions import ValidationError
from django_celery_beat.models import PeriodicTask, CrontabSchedule
# Module imports
from plane.db.models import ProjectBaseModel
@@ -13,6 +17,40 @@ class RecurringWorkitemTask(ProjectBaseModel):
Keeps a one-to-one link to Celery Beat's PeriodicTask.
"""
# Constants for interval types
INTERVAL_DAILY = "daily"
INTERVAL_WEEKLY = "weekly"
INTERVAL_MONTHLY = "monthly"
INTERVAL_YEARLY = "yearly"
# Temp intervals for testing
INVERVAL_MINUTLY = "minutly"
INTERVAL_HOURLY = "hourly"
INTERVAL_CHOICES = [
(INTERVAL_DAILY, "Daily"),
(INTERVAL_WEEKLY, "Weekly"),
(INTERVAL_MONTHLY, "Monthly"),
(INTERVAL_YEARLY, "Yearly"),
(INVERVAL_MINUTLY, "Minutly"),
(INTERVAL_HOURLY, "Hourly"),
]
# Celery task name constant
CELERY_TASK_NAME = (
"plane.ee.bgtasks.recurring_work_item_task.create_work_item_from_template"
)
# Cron expression constants
CRON_WILDCARD = "*"
CRON_FIELDS_COUNT = 5
# Day of week conversion (Python weekday to cron)
# Python: 0=Monday, 6=Sunday
# Cron: 0=Sunday, 6=Saturday
PYTHON_SUNDAY = 6
CRON_SUNDAY = 0
workitem_blueprint = models.ForeignKey(
"ee.WorkitemTemplate",
on_delete=models.CASCADE,
@@ -23,6 +61,10 @@ class RecurringWorkitemTask(ProjectBaseModel):
end_at = models.DateTimeField(
null=True, blank=True, help_text="Cut-off after which no runs occur (UTC)"
)
interval_type = models.CharField(
max_length=20,
default=INTERVAL_MONTHLY,
)
interval_seconds = models.PositiveIntegerField(
null=True,
blank=True,
@@ -39,7 +81,7 @@ class RecurringWorkitemTask(ProjectBaseModel):
)
periodic_task = models.OneToOneField(
PeriodicTask,
on_delete=models.CASCADE,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="recurring_workitem_task",
@@ -56,33 +98,289 @@ class RecurringWorkitemTask(ProjectBaseModel):
return f"<{self.workitem_blueprint.name} {self.start_at}>"
def clean(self):
"""Validate that either interval_seconds or cron_expression is provided, not both"""
from django.core.exceptions import ValidationError
"""Validate required fields"""
if not self.cron_expression:
raise ValidationError("cron_expression is required")
if not self.start_at:
raise ValidationError("start_at is required")
if self.interval_seconds is not None and self.cron_expression:
@classmethod
def generate_cron_expression(cls, start_at, interval_type):
"""
Generate cron expression based on start_at datetime and interval_type.
Args:
start_at (datetime): The datetime when the task should start
interval_type (str): One of 'second', 'daily', 'week', 'month', 'year'
Returns:
str: Cron expression in format "minute hour day month day_of_week"
Raises:
ValidationError: If interval_type is invalid
"""
if not start_at:
return None
# Validate interval type
valid_intervals = [choice[0] for choice in cls.INTERVAL_CHOICES]
if interval_type not in valid_intervals:
raise ValidationError(
"Cannot specify both interval_seconds and cron_expression"
f"Invalid interval_type: {interval_type}. Must be one of {valid_intervals}"
)
if self.interval_seconds is None and not self.cron_expression:
raise ValidationError(
"Must specify either interval_seconds or cron_expression"
)
# Extract datetime components for cron-based intervals
minute = start_at.minute
hour = start_at.hour
day = start_at.day
month = start_at.month
if self.interval_seconds is not None and self.interval_seconds < 60:
raise ValidationError("interval_seconds must be at least 60 seconds")
# Convert Python weekday to cron format
cron_day_of_week = cls._convert_python_weekday_to_cron(start_at.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}"
elif interval_type == cls.INTERVAL_WEEKLY:
return f"{minute} {hour} {cls.CRON_WILDCARD} {cls.CRON_WILDCARD} {cron_day_of_week}"
elif interval_type == cls.INTERVAL_MONTHLY:
return f"{minute} {hour} {day} {cls.CRON_WILDCARD} {cls.CRON_WILDCARD}"
elif interval_type == cls.INTERVAL_YEARLY:
return f"{minute} {hour} {day} {month} {cls.CRON_WILDCARD}"
elif interval_type == cls.INVERVAL_MINUTLY:
return f"{cls.CRON_WILDCARD} {cls.CRON_WILDCARD} {cls.CRON_WILDCARD} {cls.CRON_WILDCARD} {cls.CRON_WILDCARD}"
elif interval_type == cls.INTERVAL_HOURLY:
return f"{minute} {cls.CRON_WILDCARD} {cls.CRON_WILDCARD} {cls.CRON_WILDCARD} {cls.CRON_WILDCARD}"
@classmethod
def _convert_python_weekday_to_cron(cls, python_weekday):
"""Convert Python weekday (0=Monday, 6=Sunday) to cron format (0=Sunday, 6=Saturday)"""
return (
cls.CRON_SUNDAY
if python_weekday == cls.PYTHON_SUNDAY
else python_weekday + 1
)
def save(self, *args, **kwargs):
self.clean()
"""Override save to generate cron expression and manage PeriodicTask synchronization"""
# Auto-generate cron expression from interval_type and start_at
if self.interval_type and self.start_at:
self.cron_expression = self.generate_cron_expression(
self.start_at, self.interval_type
)
# Create or update the periodic task here
self.clean()
super().save(*args, **kwargs)
self._sync_periodic_task()
def delete(self, *args, **kwargs):
"""Override delete to clean up associated PeriodicTask"""
if self.periodic_task:
self.periodic_task.delete()
super().delete(*args, **kwargs)
def _sync_periodic_task(self):
"""Create or update the associated PeriodicTask for Beat scheduling"""
task_name = self._generate_task_name()
task_args, task_kwargs = self._prepare_task_arguments()
cron_schedule = self._get_or_create_cron_schedule()
if self.periodic_task:
self._update_existing_periodic_task(
task_name, task_args, task_kwargs, cron_schedule
)
else:
self._create_new_periodic_task(
task_name, task_args, task_kwargs, cron_schedule
)
def _generate_task_name(self):
"""Generate a unique task name for the periodic task"""
return f"rwit-{self.pk}-{self.workitem_blueprint.name}"
def _prepare_task_arguments(self):
"""Prepare arguments for the celery task"""
task_args = []
task_kwargs = {"recurring_workitem_task_id": str(self.pk)}
return json.dumps(task_args), json.dumps(task_kwargs)
def _update_existing_periodic_task(
self, task_name, task_args, task_kwargs, cron_schedule
):
"""Update an existing PeriodicTask"""
periodic_task = self.periodic_task
periodic_task.name = task_name
periodic_task.task = self.CELERY_TASK_NAME
periodic_task.args = task_args
periodic_task.kwargs = task_kwargs
periodic_task.enabled = self.enabled
periodic_task.start_time = self.start_at
periodic_task.expires = self.end_at
periodic_task.crontab = cron_schedule
periodic_task.interval = None
periodic_task.save()
def _create_new_periodic_task(
self, task_name, task_args, task_kwargs, cron_schedule
):
"""Create a new PeriodicTask"""
periodic_task = PeriodicTask.objects.create(
name=task_name,
task=self.CELERY_TASK_NAME,
args=task_args,
kwargs=task_kwargs,
enabled=self.enabled,
start_time=self.start_at,
expires=self.end_at,
crontab=cron_schedule,
interval=None,
)
self.periodic_task = periodic_task
super().save(update_fields=["periodic_task"])
def _get_or_create_cron_schedule(self):
"""Get or create CrontabSchedule for cron-based scheduling"""
if not self.cron_expression:
return None
cron_parts = self._parse_cron_expression()
project_timezone = self.project.timezone
schedule, created = CrontabSchedule.objects.get_or_create(
minute=cron_parts["minute"],
hour=cron_parts["hour"],
day_of_month=cron_parts["day_of_month"],
month_of_year=cron_parts["month_of_year"],
day_of_week=cron_parts["day_of_week"],
timezone=project_timezone,
)
return schedule
def _parse_cron_expression(self):
"""Parse cron expression into components"""
parts = self.cron_expression.split()
if len(parts) != self.CRON_FIELDS_COUNT:
raise ValidationError(f"Invalid cron expression: {self.cron_expression}")
return {
"minute": parts[0],
"hour": parts[1],
"day_of_month": parts[2],
"month_of_year": parts[3],
"day_of_week": parts[4],
}
def enable(self):
"""Enable the recurring task"""
self.enabled = True
self.save()
def disable(self):
"""Disable the recurring task"""
self.enabled = False
self.save()
def get_next_run_time(self):
"""Get the next scheduled run time"""
if self.periodic_task:
return self.periodic_task.get_next_run_time()
return None
def _detect_interval_type_from_cron(self):
"""
Detect interval type from cron expression pattern.
Returns:
str: One of 'daily', 'week', 'month', 'year', or None if pattern doesn't match
"""
if not self.cron_expression:
return None
try:
cron_parts = self._parse_cron_expression()
except ValidationError:
return None
# Check patterns to determine interval type
if self._is_daily_pattern(cron_parts):
return self.INTERVAL_DAILY
elif self._is_weekly_pattern(cron_parts):
return self.INTERVAL_WEEKLY
elif self._is_monthly_pattern(cron_parts):
return self.INTERVAL_MONTHLY
elif self._is_yearly_pattern(cron_parts):
return self.INTERVAL_YEARLY
return None
def _is_daily_pattern(self, cron_parts):
"""Check if cron pattern represents daily recurrence"""
return (
cron_parts["day_of_month"] == self.CRON_WILDCARD
and cron_parts["month_of_year"] == self.CRON_WILDCARD
and cron_parts["day_of_week"] == self.CRON_WILDCARD
)
def _is_weekly_pattern(self, cron_parts):
"""Check if cron pattern represents weekly recurrence"""
return (
cron_parts["day_of_month"] == self.CRON_WILDCARD
and cron_parts["month_of_year"] == self.CRON_WILDCARD
and cron_parts["day_of_week"] != self.CRON_WILDCARD
)
def _is_monthly_pattern(self, cron_parts):
"""Check if cron pattern represents monthly recurrence"""
return (
cron_parts["day_of_month"] != self.CRON_WILDCARD
and cron_parts["month_of_year"] == self.CRON_WILDCARD
and cron_parts["day_of_week"] == self.CRON_WILDCARD
)
def _is_yearly_pattern(self, cron_parts):
"""Check if cron pattern represents yearly recurrence"""
return (
cron_parts["day_of_month"] != self.CRON_WILDCARD
and cron_parts["month_of_year"] != self.CRON_WILDCARD
and cron_parts["day_of_week"] == self.CRON_WILDCARD
)
@property
def schedule_description(self):
"""Get a human-readable description of the schedule"""
if not self.cron_expression:
return "No schedule configured"
interval_type = self._detect_interval_type_from_cron()
if interval_type == self.INTERVAL_DAILY:
return (
f"Every day at {self.start_at.strftime('%H:%M')}"
if self.start_at
else "Every day"
)
elif interval_type == self.INTERVAL_WEEKLY:
return (
f"Every week on {self.start_at.strftime('%A')} at {self.start_at.strftime('%H:%M')}"
if self.start_at
else f"Cron: {self.cron_expression}"
)
elif interval_type == self.INTERVAL_MONTHLY:
return (
f"Every month on day {self.start_at.day} at {self.start_at.strftime('%H:%M')}"
if self.start_at
else f"Cron: {self.cron_expression}"
)
elif interval_type == self.INTERVAL_YEARLY:
return (
f"Every year on {self.start_at.strftime('%B %d')} at {self.start_at.strftime('%H:%M')}"
if self.start_at
else f"Cron: {self.cron_expression}"
)
else:
return f"Cron: {self.cron_expression}"
class RecurringWorkitemTaskLog(ProjectBaseModel):
"""
@@ -132,3 +430,62 @@ class RecurringWorkitemTaskLog(ProjectBaseModel):
def __str__(self):
return f"<{self.recurring_task.workitem_blueprint.name} {self.status} {self.started_at}>"
@property
def duration(self):
"""Get the duration of the task execution"""
if self.finished_at and self.started_at:
return self.finished_at - self.started_at
return None
class RecurringWorkItemTaskActivity(ProjectBaseModel):
"""
One row per execution; records status, timestamps, and the concrete Work Item produced.
Provides audit trail for recurring workitem creation.
"""
recurring_workitem_task = models.ForeignKey(
RecurringWorkitemTask,
on_delete=models.CASCADE,
related_name="recurring_workitem_task_activities",
)
recurring_workitem_task_log = models.ForeignKey(
RecurringWorkitemTaskLog,
on_delete=models.CASCADE,
related_name="recurring_workitem_task_activities",
blank=True,
null=True,
)
verb = models.CharField(max_length=255, verbose_name="Action", default="created")
field = models.CharField(
max_length=255, verbose_name="Field Name", blank=True, null=True
)
property = models.ForeignKey(
"ee.IssueProperty",
on_delete=models.CASCADE,
related_name="recurring_workitem_task_activities",
blank=True,
null=True,
)
old_value = models.TextField(verbose_name="Old Value", blank=True, null=True)
new_value = models.TextField(verbose_name="New Value", blank=True, null=True)
actor = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
related_name="recurring_workitem_task_activities",
)
old_identifier = models.UUIDField(null=True)
new_identifier = models.UUIDField(null=True)
epoch = models.FloatField(null=True)
class Meta:
verbose_name = "Recurring Workitem Task Activity"
verbose_name_plural = "Recurring Workitem Task Activities"
db_table = "recurring_workitem_task_activities"
ordering = ("-created_at",)
def __str__(self):
"""Return recurring workitem task log of the activity"""
return str(self.recurring_workitem_task_log)