[WEB-4985] feat: cycle automation task (#4413)
* feat: created cycles template model * feat: added forms for automated cycles * chore: updated the migration file * chore: milestone migrations * chore: added milestone models * chore: removed target_date constraint * feat: cycle scheduling task * chore: updated the migration file * chore: added the missing import * chore: added the missing import * chore: added form integration * chore: project feature migration * chore: changed the migration file * chore: added the enableing in automated cycles * * chore: added feature flag checks * chore: updated endpoints to not use uuid * chore: moved feature enable/disable to project feature * chore: structured the payload of cycle * chore: updated edit enable/disable flow * chore: updated the scheduling task * chore: updated cycle config forms * chore: added the cycle scheduling task * chore: updated the automated cycle log model * chore: refactor for project features * fix: build errors * chore: updated enable/disable for features * chore: resolved merge conflicts * chore: reused the transfer cycle issue component * chore: seperated the cycle automation * chore: updated the formatting --------- Co-authored-by: vamsikrishnamathala <[email protected]>
This commit is contained in:
co-authored by
vamsikrishnamathala
parent
dff8282d76
commit
360192c120
@@ -126,6 +126,11 @@ EE_JOBS = {
|
||||
"task": "plane.event_stream.bgtasks.outbox_cleaner.delete_outbox_records",
|
||||
"schedule": crontab(hour=0, minute=30), # UTC 00:30
|
||||
},
|
||||
# Cycles maintenance
|
||||
"maintain-future-cycles": {
|
||||
"task": "plane.ee.bgtasks.cycle_automation_task.maintain_future_cycles",
|
||||
"schedule": crontab(minute="*/2"), # Every 2 minutes
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ class BotTypeEnum(models.TextChoices):
|
||||
INTAKE_BOT = "INTAKE_BOT", "Intake Bot"
|
||||
APP_BOT = "APP_BOT", "App Bot"
|
||||
AUTOMATION_BOT = "AUTOMATION_BOT", "Automation Bot"
|
||||
CYCLE_AUTOMATION_BOT = "CYCLE_AUTOMATION_BOT", "Cycle Automation Bot"
|
||||
|
||||
|
||||
class User(AbstractBaseUser, PermissionsMixin):
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
from __future__ import annotations
|
||||
import json
|
||||
import pytz
|
||||
|
||||
# Python imports
|
||||
from datetime import timedelta
|
||||
|
||||
# Django imports
|
||||
from django.utils import timezone
|
||||
from django.db.models import Subquery
|
||||
|
||||
# Third Party imports
|
||||
from celery import shared_task
|
||||
from django.db.models import Count, Q, When, Case, Value, FloatField, Sum, F
|
||||
from django.db.models.functions import Cast, Concat
|
||||
from django.db import models, transaction
|
||||
|
||||
# Module imports
|
||||
from plane.ee.models import CycleSettings, ProjectFeature, AutomatedCycleLog
|
||||
from plane.db.models import Cycle, Project, Issue, CycleIssue, Workspace
|
||||
from plane.utils.analytics_plot import burndown_plot
|
||||
from plane.bgtasks.issue_activities_task import issue_activity
|
||||
from plane.bgtasks.webhook_task import model_activity
|
||||
from plane.ee.bgtasks.entity_issue_state_progress_task import (
|
||||
entity_issue_state_activity_task,
|
||||
)
|
||||
from plane.utils.host import base_host
|
||||
from plane.db.models import BotTypeEnum, ProjectMember
|
||||
from plane.utils.exception_logger import log_exception
|
||||
from plane.utils.cycle_transfer_issues import transfer_cycle_issues
|
||||
from plane.utils.timezone_converter import convert_to_utc
|
||||
|
||||
|
||||
@shared_task
|
||||
def schedule_cycle(automated_cycle_id: str, project_id: str, bot_id: str):
|
||||
try:
|
||||
"""
|
||||
Schedule a cycle for a project based on the CycleSettings configuration.
|
||||
|
||||
- Creates future cycles using the duration and cooldown period.
|
||||
- Skips cycles that overlap with existing ones.
|
||||
- Logs success or failure for each attempted cycle creation.
|
||||
"""
|
||||
|
||||
automated_cycle = CycleSettings.objects.get(id=automated_cycle_id, project_id=project_id)
|
||||
|
||||
number_of_cycles = automated_cycle.number_of_cycles
|
||||
cycle_duration = automated_cycle.cycle_duration
|
||||
cooldown_period = automated_cycle.cooldown_period
|
||||
start_date = automated_cycle.start_date
|
||||
workspace_id = automated_cycle.workspace_id
|
||||
|
||||
desired_windows = [
|
||||
(
|
||||
start_date + timedelta(days=i * (cycle_duration + cooldown_period)),
|
||||
start_date + timedelta(days=i * (cycle_duration + cooldown_period) + cycle_duration),
|
||||
)
|
||||
for i in range(number_of_cycles)
|
||||
]
|
||||
|
||||
# Prefetch all existing cycles in the same project/workspace that could possibly overlap
|
||||
existing_cycles = Cycle.objects.filter(
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
deleted_at__isnull=True,
|
||||
).values("start_date", "end_date")
|
||||
|
||||
logs = []
|
||||
cycles_to_create = []
|
||||
|
||||
for start_dt, end_dt in desired_windows:
|
||||
# Check if there's any overlap
|
||||
is_clashing = any((ec["start_date"] <= end_dt and ec["end_date"] >= start_dt) for ec in existing_cycles)
|
||||
|
||||
if is_clashing:
|
||||
logs.append(
|
||||
AutomatedCycleLog(
|
||||
automated_cycle=automated_cycle,
|
||||
workspace_id=workspace_id,
|
||||
project_id=project_id,
|
||||
cycle_id=None,
|
||||
action="cycle_creation_failed",
|
||||
status="failed",
|
||||
message=f"Cycle window {start_dt.date()} - {end_dt.date()} overlaps with existing cycle.",
|
||||
scheduled_at=timezone.now(),
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
start_dt = convert_to_utc(
|
||||
date=str(start_dt.date()),
|
||||
project_id=project_id,
|
||||
is_start_date=True,
|
||||
)
|
||||
end_dt = convert_to_utc(
|
||||
date=str(end_dt.date()),
|
||||
project_id=project_id,
|
||||
)
|
||||
|
||||
# Create the cycle object
|
||||
cycle = Cycle(
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
owned_by_id=bot_id,
|
||||
created_by_id=bot_id,
|
||||
updated_by_id=bot_id,
|
||||
name=automated_cycle.title,
|
||||
start_date=start_dt,
|
||||
end_date=end_dt,
|
||||
)
|
||||
cycles_to_create.append(cycle)
|
||||
|
||||
# Bulk create non-conflicting cycles
|
||||
created_cycles = Cycle.objects.bulk_create(cycles_to_create, batch_size=50, ignore_conflicts=True)
|
||||
|
||||
# Add logs for successful creations
|
||||
for cycle in created_cycles:
|
||||
logs.append(
|
||||
AutomatedCycleLog(
|
||||
automated_cycle=automated_cycle,
|
||||
workspace_id=workspace_id,
|
||||
project_id=project_id,
|
||||
cycle_id=cycle.id,
|
||||
action="cycle_created",
|
||||
status="success",
|
||||
message=f"Cycle created successfully for {cycle.start_date.date()} - {cycle.end_date.date()}",
|
||||
scheduled_at=timezone.now(),
|
||||
)
|
||||
)
|
||||
|
||||
# Bulk create logs
|
||||
if logs:
|
||||
AutomatedCycleLog.objects.bulk_create(logs, batch_size=50)
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
log_exception(e)
|
||||
return False
|
||||
|
||||
|
||||
@shared_task
|
||||
def maintain_future_cycles():
|
||||
try:
|
||||
"""
|
||||
Ensure each active CycleSettings has exactly `number_of_cycles` future cycles.
|
||||
- If a cycle started within the last 2 minutes, or a cycle will start within the
|
||||
next 2 minutes, backfill to keep the number of future cycles constant.
|
||||
- This runs idempotently and safely in concurrent environments.
|
||||
"""
|
||||
|
||||
now = timezone.now()
|
||||
buffer = timedelta(minutes=2)
|
||||
|
||||
# from the project features table get the cycle enabled projects
|
||||
cycle_enabled_projects = ProjectFeature.objects.filter(
|
||||
is_automated_cycle_enabled=True, project__cycle_view=True
|
||||
).values_list("project_id", flat=True)
|
||||
|
||||
# check if the cycle is disabled for the projects
|
||||
scheduled_cycles = CycleSettings.objects.filter(project_id__in=cycle_enabled_projects)
|
||||
|
||||
scheduled_cycles_projects = scheduled_cycles.values("project_id")
|
||||
|
||||
# get the recently started cycles to create new future cycles
|
||||
# get the project's timezone time to check if the cycle has started is accurately respected .
|
||||
recently_started_cycles = Cycle.objects.filter(
|
||||
project_id__in=Subquery(scheduled_cycles_projects),
|
||||
deleted_at__isnull=True,
|
||||
start_date__gte=now - buffer, # started within last 2 mins (inclusive)
|
||||
start_date__lte=now, # up to now
|
||||
).values("project_id", "id")
|
||||
|
||||
# get the recently ended cycles to transfer work items
|
||||
recently_ended_cycles = Cycle.objects.filter(
|
||||
project_id__in=Subquery(scheduled_cycles_projects),
|
||||
deleted_at__isnull=True,
|
||||
end_date__gte=now - buffer, # ended within last 2 mins (inclusive)
|
||||
end_date__lte=now, # up to now
|
||||
).values("project_id", "id")
|
||||
|
||||
# check for auto-rollover enabled (placeholder for future use)
|
||||
scheduled_cycles.filter(is_auto_rollover_enabled=True).values_list("project_id", flat=True)
|
||||
|
||||
# create the new cycles
|
||||
for cycle in recently_ended_cycles:
|
||||
project_id = cycle["project_id"]
|
||||
cycle_id = cycle["id"]
|
||||
|
||||
project = Project.objects.get(id=project_id)
|
||||
|
||||
# get the bot id
|
||||
bot_id = (
|
||||
ProjectMember.objects.filter(
|
||||
project_id=project_id,
|
||||
member__bot_type=BotTypeEnum.CYCLE_AUTOMATION_BOT,
|
||||
)
|
||||
.first()
|
||||
.member_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)
|
||||
|
||||
scheduled_cycle = scheduled_cycles.filter(project_id=project_id).first()
|
||||
|
||||
# transfer the work-items to the next cycle (if the next cycle exists)
|
||||
if scheduled_cycle.is_auto_rollover_enabled:
|
||||
workspace_slug = Workspace.objects.get(id=scheduled_cycle.workspace_id).slug
|
||||
|
||||
# first check if the current active cycle is not the same as the old cycle
|
||||
current_cycle = (
|
||||
Cycle.objects.filter(
|
||||
Q(start_date__lte=current_time_in_utc) & Q(end_date__gte=current_time_in_utc),
|
||||
workspace__slug=workspace_slug,
|
||||
project_id=project_id,
|
||||
deleted_at__isnull=True,
|
||||
)
|
||||
.order_by("start_date")
|
||||
.first()
|
||||
)
|
||||
if current_cycle and current_cycle.id != cycle_id:
|
||||
transfer_cycle_issues(
|
||||
slug=workspace_slug,
|
||||
project_id=project_id,
|
||||
cycle_id=cycle_id,
|
||||
new_cycle_id=current_cycle.id,
|
||||
request=None,
|
||||
user_id=str(bot_id),
|
||||
)
|
||||
else:
|
||||
next_cycle = (
|
||||
Cycle.objects.filter(
|
||||
workspace__slug=workspace_slug,
|
||||
project_id=project_id,
|
||||
start_date__gt=current_time_in_utc,
|
||||
deleted_at__isnull=True,
|
||||
)
|
||||
.order_by("start_date")
|
||||
.first()
|
||||
)
|
||||
if next_cycle:
|
||||
transfer_cycle_issues(
|
||||
slug=workspace_slug,
|
||||
project_id=project_id,
|
||||
cycle_id=cycle_id,
|
||||
new_cycle_id=next_cycle.id,
|
||||
request=None,
|
||||
user_id=str(bot_id),
|
||||
)
|
||||
|
||||
for cycle in recently_started_cycles:
|
||||
project_id = cycle["project_id"]
|
||||
cycle_id = cycle["id"]
|
||||
|
||||
project = Project.objects.get(id=project_id)
|
||||
|
||||
# get the bot id
|
||||
bot_id = (
|
||||
ProjectMember.objects.filter(
|
||||
project_id=project_id,
|
||||
member__bot_type=BotTypeEnum.CYCLE_AUTOMATION_BOT,
|
||||
)
|
||||
.first()
|
||||
.member_id
|
||||
)
|
||||
scheduled_cycle = scheduled_cycles.filter(project_id=project_id).first()
|
||||
|
||||
# get the number of cycles to create
|
||||
number_of_cycles = scheduled_cycle.number_of_cycles
|
||||
cycle_duration = scheduled_cycle.cycle_duration
|
||||
cooldown_period = scheduled_cycle.cooldown_period
|
||||
project_id = scheduled_cycle.project_id
|
||||
workspace_id = scheduled_cycle.workspace_id
|
||||
|
||||
# get the upcoming cycles which are scheduled for the project
|
||||
upcoming_cycles = Cycle.objects.filter(project_id=project_id, start_date__gt=now, deleted_at__isnull=True)
|
||||
|
||||
# get then end date of the last created cycle and then add the cooldown period and create the new cycle.
|
||||
last_cycle = upcoming_cycles.order_by("-end_date").first()
|
||||
if last_cycle:
|
||||
last_cycle_end_date = last_cycle.end_date + timedelta(days=cooldown_period)
|
||||
else:
|
||||
# if there are no upcoming cycles then check for the start date and start the cycles from the next day
|
||||
last_upcoming = upcoming_cycles.order_by("-start_date").first()
|
||||
last_cycle_end_date = (
|
||||
last_upcoming.start_date + timedelta(days=cooldown_period) if last_upcoming else now
|
||||
)
|
||||
|
||||
# if the last cycles is not found then check for which every is the upcoming cycle as the end date (if none of them have end date then skip the project)
|
||||
upcoming_cycles = Cycle.objects.filter(
|
||||
project_id=project_id, start_date__gt=now, deleted_at__isnull=True
|
||||
)
|
||||
if not upcoming_cycles.exists():
|
||||
# create the new and the future cycles
|
||||
schedule_cycle(
|
||||
automated_cycle_id=scheduled_cycle.id,
|
||||
project_id=project_id,
|
||||
bot_id=bot_id,
|
||||
)
|
||||
continue
|
||||
|
||||
# check the difference between the number of cycles and the upcoming cycles
|
||||
number_of_cycles_diff = number_of_cycles - upcoming_cycles.count()
|
||||
|
||||
# if its greater than create the new cycles from the last cycle end date and add the cooldown period bulk create the cycle
|
||||
|
||||
# first we need to check if any of the cycles exists with the same dates in the database
|
||||
if number_of_cycles_diff > 0:
|
||||
# Build cycles list with proper UTC conversion for each cycle
|
||||
cycles_to_create = []
|
||||
for i in range(number_of_cycles_diff):
|
||||
start_dt = last_cycle_end_date + timedelta(days=i * (cycle_duration + cooldown_period))
|
||||
end_dt = last_cycle_end_date + timedelta(
|
||||
days=(i * (cycle_duration + cooldown_period) + cycle_duration)
|
||||
)
|
||||
|
||||
# Convert dates to UTC using the project's timezone
|
||||
start_date = convert_to_utc(
|
||||
date=str(start_dt.date()),
|
||||
project_id=project_id,
|
||||
is_start_date=True,
|
||||
)
|
||||
end_date = convert_to_utc(
|
||||
date=str(end_dt.date()),
|
||||
project_id=project_id,
|
||||
)
|
||||
|
||||
cycles_to_create.append(
|
||||
Cycle(
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
owned_by_id=bot_id,
|
||||
created_by_id=bot_id,
|
||||
updated_by_id=bot_id,
|
||||
name=scheduled_cycle.title,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
)
|
||||
)
|
||||
|
||||
# bulk create the cycles
|
||||
cycles = Cycle.objects.bulk_create(
|
||||
cycles_to_create,
|
||||
batch_size=50,
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
|
||||
AutomatedCycleLog.objects.bulk_create(
|
||||
[
|
||||
AutomatedCycleLog(
|
||||
automated_cycle=scheduled_cycle,
|
||||
cycle=cycle,
|
||||
workspace_id=workspace_id,
|
||||
project_id=project_id,
|
||||
action="cycle_created",
|
||||
status="success",
|
||||
message=f"Cycle created successfully for {cycle.start_date.date()} - {cycle.end_date.date()}",
|
||||
scheduled_at=timezone.now(),
|
||||
)
|
||||
for cycle in cycles
|
||||
]
|
||||
)
|
||||
except Exception as e:
|
||||
log_exception(e)
|
||||
return False
|
||||
@@ -139,6 +139,7 @@ from .app.job import ImportReportSerializer, ImportJobSerializer
|
||||
from .app.workspace.credential import WorkspaceCredentialSerializer
|
||||
from .app.workspace.connection import WorkspaceConnectionSerializer
|
||||
from .app.workspace.entity_connection import WorkspaceEntityConnectionSerializer
|
||||
from .app.cycle_schedule import AutomatedCycleSerializer
|
||||
|
||||
# api
|
||||
from .api.job import ImportReportAPISerializer, ImportJobAPISerializer
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
from plane.ee.models import CycleSettings
|
||||
from plane.ee.serializers import BaseSerializer
|
||||
from django.utils import timezone
|
||||
from rest_framework import serializers
|
||||
|
||||
|
||||
class AutomatedCycleSerializer(BaseSerializer):
|
||||
|
||||
class Meta:
|
||||
model = CycleSettings
|
||||
fields = "__all__"
|
||||
read_only_fields = [
|
||||
"workspace",
|
||||
"project",
|
||||
"created_by",
|
||||
"updated_by",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
def validate(self, data):
|
||||
# start date cannot be in the past
|
||||
if (
|
||||
data.get("start_date", None) is not None
|
||||
and data.get("start_date", None).date() < timezone.now().date()
|
||||
):
|
||||
raise serializers.ValidationError("Start date cannot be in the past")
|
||||
|
||||
# cycle duration will be in multiple of 7 days
|
||||
if (
|
||||
data.get("cycle_duration", None) is not None
|
||||
and data.get("cycle_duration", None) % 7 != 0
|
||||
):
|
||||
raise serializers.ValidationError(
|
||||
"Cycle duration must be in multiple of 7 days"
|
||||
)
|
||||
|
||||
# the number of cycles to be scheduled in the future cannot be greater than 3
|
||||
if (
|
||||
data.get("number_of_cycles", None) is not None
|
||||
and data.get("number_of_cycles", None) > 3
|
||||
):
|
||||
raise serializers.ValidationError(
|
||||
"Number of cycles to be scheduled in the future cannot be greater than 3"
|
||||
)
|
||||
|
||||
return data
|
||||
@@ -46,6 +46,7 @@ class ProjectFeatureSerializer(BaseSerializer):
|
||||
"is_issue_type_enabled",
|
||||
"is_time_tracking_enabled",
|
||||
"is_workflow_enabled",
|
||||
"is_automated_cycle_enabled",
|
||||
"project_id",
|
||||
]
|
||||
read_only_fields = [
|
||||
|
||||
@@ -5,6 +5,7 @@ from plane.ee.views.app.cycle import (
|
||||
CycleUpdatesViewSet,
|
||||
CycleStartStopEndpoint,
|
||||
CycleIssueStateAnalyticsEndpoint,
|
||||
AutomatedCycleViewSet,
|
||||
)
|
||||
from plane.ee.views.app.update import UpdatesReactionViewSet
|
||||
|
||||
@@ -57,4 +58,10 @@ urlpatterns = [
|
||||
CycleIssueStateAnalyticsEndpoint.as_view(),
|
||||
name="project-cycle-progress",
|
||||
),
|
||||
# Scheduled Cycles
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/automated-cycles/",
|
||||
AutomatedCycleViewSet.as_view({"get": "list", "post": "create", "patch": "partial_update"}),
|
||||
name="automated-cycles",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -2,3 +2,4 @@ from .active_cycle import WorkspaceActiveCycleEndpoint
|
||||
from .update import CycleUpdatesViewSet
|
||||
from .start_stop import CycleStartStopEndpoint
|
||||
from .base import CycleIssueStateAnalyticsEndpoint
|
||||
from .schedule import AutomatedCycleViewSet
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import uuid
|
||||
from django.contrib.auth.hashers import make_password
|
||||
|
||||
# Third Party imports
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
|
||||
# Module imports
|
||||
from plane.ee.models import CycleSettings
|
||||
from plane.db.models import BotTypeEnum, User, Workspace, WorkspaceMember, ProjectMember
|
||||
from plane.ee.views.base import BaseViewSet
|
||||
from plane.ee.serializers import AutomatedCycleSerializer
|
||||
from plane.ee.bgtasks.cycle_automation_task import schedule_cycle
|
||||
from plane.app.permissions import allow_permission, ROLE
|
||||
from plane.payment.flags.flag import FeatureFlag
|
||||
from plane.payment.flags.flag_decorator import check_feature_flag
|
||||
|
||||
|
||||
class AutomatedCycleViewSet(BaseViewSet):
|
||||
serializer_class = AutomatedCycleSerializer
|
||||
model = CycleSettings
|
||||
|
||||
def get_queryset(self):
|
||||
return self.filter_queryset(
|
||||
super()
|
||||
.get_queryset()
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.filter(project_id=self.kwargs.get("project_id"))
|
||||
)
|
||||
|
||||
@allow_permission([ROLE.ADMIN])
|
||||
@check_feature_flag(FeatureFlag.AUTO_SCHEDULE_CYCLES)
|
||||
def list(self, request, slug, project_id):
|
||||
automated_cycle = self.get_queryset().first()
|
||||
if automated_cycle is None:
|
||||
return Response({}, status=status.HTTP_200_OK)
|
||||
serializer = AutomatedCycleSerializer(automated_cycle)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
@allow_permission([ROLE.ADMIN])
|
||||
@check_feature_flag(FeatureFlag.AUTO_SCHEDULE_CYCLES)
|
||||
def create(self, request, slug, project_id):
|
||||
# check if the automation already exists
|
||||
if CycleSettings.objects.filter(project_id=project_id, workspace__slug=slug).exists():
|
||||
return Response(
|
||||
{"error": "Automated cycle already exists"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
cycle_automation_bot = ProjectMember.objects.filter(
|
||||
project_id=project_id, member__bot_type=BotTypeEnum.CYCLE_AUTOMATION_BOT
|
||||
).first()
|
||||
if not cycle_automation_bot:
|
||||
# Create a automation bot
|
||||
cycle_automation_bot = User.objects.create(
|
||||
username=f"cycle-automation-bot-{uuid.uuid4().hex}",
|
||||
display_name="Cycle Automation Bot",
|
||||
first_name="Cycle Automation",
|
||||
last_name="Bot",
|
||||
is_bot=True,
|
||||
bot_type=BotTypeEnum.CYCLE_AUTOMATION_BOT,
|
||||
email=f"cycle-automation-bot-{uuid.uuid4().hex}@plane.so",
|
||||
password=make_password(uuid.uuid4().hex),
|
||||
is_password_autoset=True,
|
||||
)
|
||||
|
||||
workspace_id = Workspace.objects.get(slug=slug).id
|
||||
# Add user to the workspace
|
||||
WorkspaceMember.objects.create(
|
||||
member_id=cycle_automation_bot.id,
|
||||
workspace_id=workspace_id,
|
||||
role=ROLE.ADMIN.value,
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
# add the user to the project
|
||||
ProjectMember.objects.create(
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
member_id=cycle_automation_bot.id,
|
||||
role=ROLE.ADMIN.value,
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
serializer = AutomatedCycleSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
serializer.save(project_id=project_id)
|
||||
# when ever some one tries to schedule a cycle, we will schedule the first cycle
|
||||
schedule_cycle.delay(
|
||||
project_id=project_id,
|
||||
bot_id=cycle_automation_bot.id,
|
||||
automated_cycle_id=serializer.instance.id,
|
||||
)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@allow_permission([ROLE.ADMIN])
|
||||
@check_feature_flag(FeatureFlag.AUTO_SCHEDULE_CYCLES)
|
||||
def partial_update(self, request, slug, project_id):
|
||||
automated_cycle = CycleSettings.objects.filter(project_id=project_id, workspace__slug=slug).first()
|
||||
if not automated_cycle:
|
||||
return Response(
|
||||
{"error": "Automated cycle not found"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
serializer = AutomatedCycleSerializer(automated_cycle, data=request.data, partial=True)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
@@ -88,6 +88,8 @@ class FeatureFlag(Enum):
|
||||
PROJECT_AUTOMATIONS = "PROJECT_AUTOMATIONS"
|
||||
# Recurring Work Items
|
||||
RECURRING_WORKITEMS = "RECURRING_WORKITEMS"
|
||||
# Automated Cycles
|
||||
AUTO_SCHEDULE_CYCLES = "AUTO_SCHEDULE_CYCLES"
|
||||
# Exports
|
||||
ADVANCED_EXPORTS = "ADVANCED_EXPORTS"
|
||||
|
||||
|
||||
@@ -312,6 +312,7 @@ CELERY_IMPORTS = (
|
||||
"plane.authentication.bgtasks.send_app_uninstall_webhook",
|
||||
"plane.ee.bgtasks.batched_search_update_task",
|
||||
"plane.ee.bgtasks.recurring_work_item_task",
|
||||
"plane.ee.bgtasks.cycle_automation_task",
|
||||
# silo tasks
|
||||
"plane.silo.bgtasks.integration_apps_task",
|
||||
# event stream tasks
|
||||
|
||||
@@ -30,6 +30,7 @@ from plane.ee.bgtasks.entity_issue_state_progress_task import (
|
||||
entity_issue_state_activity_task,
|
||||
)
|
||||
|
||||
|
||||
def transfer_cycle_issues(
|
||||
slug,
|
||||
project_id,
|
||||
@@ -53,9 +54,7 @@ def transfer_cycle_issues(
|
||||
dict: Response data with success or error message
|
||||
"""
|
||||
# Get the new cycle
|
||||
new_cycle = Cycle.objects.filter(
|
||||
workspace__slug=slug, project_id=project_id, pk=new_cycle_id
|
||||
).first()
|
||||
new_cycle = Cycle.objects.filter(workspace__slug=slug, project_id=project_id, pk=new_cycle_id).first()
|
||||
|
||||
# Check if new cycle is already completed
|
||||
if new_cycle.end_date is not None and new_cycle.end_date < timezone.now():
|
||||
@@ -218,9 +217,7 @@ def transfer_cycle_issues(
|
||||
assignee_estimate_distribution = [
|
||||
{
|
||||
"display_name": item["display_name"],
|
||||
"assignee_id": (
|
||||
str(item["assignee_id"]) if item["assignee_id"] else None
|
||||
),
|
||||
"assignee_id": (str(item["assignee_id"]) if item["assignee_id"] else None),
|
||||
"avatar_url": item.get("avatar_url"),
|
||||
"total_estimates": item["total_estimates"],
|
||||
"completed_estimates": item["completed_estimates"],
|
||||
@@ -312,9 +309,7 @@ def transfer_cycle_issues(
|
||||
)
|
||||
)
|
||||
.values("display_name", "assignee_id", "avatar_url")
|
||||
.annotate(
|
||||
total_issues=Count("id", filter=Q(archived_at__isnull=True, is_draft=False))
|
||||
)
|
||||
.annotate(total_issues=Count("id", filter=Q(archived_at__isnull=True, is_draft=False)))
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"id",
|
||||
@@ -362,9 +357,7 @@ def transfer_cycle_issues(
|
||||
.annotate(color=F("labels__color"))
|
||||
.annotate(label_id=F("labels__id"))
|
||||
.values("label_name", "color", "label_id")
|
||||
.annotate(
|
||||
total_issues=Count("id", filter=Q(archived_at__isnull=True, is_draft=False))
|
||||
)
|
||||
.annotate(total_issues=Count("id", filter=Q(archived_at__isnull=True, is_draft=False)))
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"id",
|
||||
@@ -411,9 +404,7 @@ def transfer_cycle_issues(
|
||||
)
|
||||
|
||||
# Get the current cycle and save progress snapshot
|
||||
current_cycle = Cycle.objects.filter(
|
||||
workspace__slug=slug, project_id=project_id, pk=cycle_id
|
||||
).first()
|
||||
current_cycle = Cycle.objects.filter(workspace__slug=slug, project_id=project_id, pk=cycle_id).first()
|
||||
|
||||
current_cycle.progress_snapshot = {
|
||||
"total_issues": old_cycle.total_issues,
|
||||
@@ -463,9 +454,7 @@ def transfer_cycle_issues(
|
||||
)
|
||||
|
||||
# Bulk update cycle issues
|
||||
cycle_issues = CycleIssue.objects.bulk_update(
|
||||
updated_cycles, ["cycle_id"], batch_size=100
|
||||
)
|
||||
cycle_issues = CycleIssue.objects.bulk_update(updated_cycles, ["cycle_id"], batch_size=100)
|
||||
|
||||
# Capture Issue Activity
|
||||
issue_activity.delay(
|
||||
@@ -477,7 +466,7 @@ def transfer_cycle_issues(
|
||||
current_instance=json.dumps(
|
||||
{
|
||||
"updated_cycle_issues": update_cycle_issue_activity,
|
||||
"created_cycle_issues": [],
|
||||
"created_cycle_issues": "[]",
|
||||
}
|
||||
),
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
@@ -485,6 +474,8 @@ def transfer_cycle_issues(
|
||||
origin=base_host(request=request, is_app=True),
|
||||
)
|
||||
|
||||
# EE Code start here
|
||||
|
||||
# Trigger the entity issue state activity task for removal of issues
|
||||
entity_issue_state_activity_task.delay(
|
||||
issue_cycle_data=[
|
||||
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { ChevronLeft } from "lucide-react";
|
||||
// plane imports
|
||||
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { setPromiseToast } from "@plane/propel/toast";
|
||||
import { ToggleSwitch } from "@plane/ui";
|
||||
// components
|
||||
import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view";
|
||||
import { PageHead } from "@/components/core/page-title";
|
||||
import { SettingsContentWrapper } from "@/components/settings/content-wrapper";
|
||||
import { SettingsHeading } from "@/components/settings/heading";
|
||||
// hooks
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
import { AutoScheduleCycles } from "@/plane-web/components/cycles/settings";
|
||||
// plane web imports
|
||||
import { PROJECT_BASE_FEATURES_LIST } from "@/plane-web/constants/project/settings";
|
||||
|
||||
const CyclesFeatureSettingsPage = observer(() => {
|
||||
const { workspaceSlug, projectId } = useParams();
|
||||
// permissions
|
||||
const { workspaceUserInfo, allowPermissions } = useUserPermissions();
|
||||
const canPerformProjectAdminActions = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT);
|
||||
// project store
|
||||
const { getProjectById, updateProject } = useProject();
|
||||
const currentProjectDetails = projectId ? getProjectById(projectId.toString()) : undefined;
|
||||
|
||||
if (!workspaceSlug || !projectId) return null;
|
||||
|
||||
if (workspaceUserInfo && !canPerformProjectAdminActions) {
|
||||
return <NotAuthorizedView section="settings" isProjectView className="h-auto" />;
|
||||
}
|
||||
|
||||
const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - Cycle Configuration` : undefined;
|
||||
|
||||
const handleToggle = async () => {
|
||||
if (!currentProjectDetails) return;
|
||||
const payload = { cycle_view: !Boolean(currentProjectDetails.cycle_view) };
|
||||
const promise = updateProject(workspaceSlug.toString(), projectId.toString(), payload);
|
||||
setPromiseToast(promise, {
|
||||
loading: "Updating project feature...",
|
||||
success: { title: "Success!", message: () => "Project feature updated successfully." },
|
||||
error: { title: "Error!", message: () => "Something went wrong. Please try again." },
|
||||
});
|
||||
};
|
||||
|
||||
const cyclesIcon = PROJECT_BASE_FEATURES_LIST.cycles.icon;
|
||||
|
||||
return (
|
||||
<SettingsContentWrapper>
|
||||
<PageHead title={pageTitle} />
|
||||
<div className="mb-4">
|
||||
<Link
|
||||
href={`/${workspaceSlug}/settings/projects/${projectId}/features`}
|
||||
className="text-sm text-custom-text-300 hover:text-custom-text-200"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<ChevronLeft className="h-4 w-4 text-custom-text-300" />
|
||||
<span className="text-sm text-custom-text-300 font-bold">Back to features</span>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<SettingsHeading
|
||||
title="Cycles"
|
||||
description="Schedule work in flexible periods that adapt to this project's unique rhythm and pace."
|
||||
/>
|
||||
|
||||
<div className="gap-x-8 gap-y-2 border-b border-custom-border-100 bg-custom-background-100 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex items-center justify-center rounded bg-custom-background-90 p-3">{cyclesIcon}</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium leading-5">Enable cycles</h4>
|
||||
<p className="text-sm leading-5 tracking-tight text-custom-text-300">Plan work in focused timeframes.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ToggleSwitch
|
||||
value={Boolean(currentProjectDetails?.cycle_view)}
|
||||
onChange={handleToggle}
|
||||
disabled={!canPerformProjectAdminActions}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Auto-schedule cycles configuration */}
|
||||
{currentProjectDetails?.cycle_view && <AutoScheduleCycles />}
|
||||
</SettingsContentWrapper>
|
||||
);
|
||||
});
|
||||
|
||||
export default CyclesFeatureSettingsPage;
|
||||
@@ -16,11 +16,8 @@ import { captureSuccess } from "@/helpers/event-tracker.helper";
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useUser } from "@/hooks/store/user";
|
||||
// plane web imports
|
||||
import { ProjectFeatureChildren } from "@/plane-web/components/projects/settings/feature-children";
|
||||
import { UpgradeBadge } from "@/plane-web/components/workspace/upgrade-badge";
|
||||
import { PROJECT_FEATURES_LIST } from "@/plane-web/constants/project/settings";
|
||||
import { useProjectAdvanced } from "@/plane-web/hooks/store/projects/use-projects";
|
||||
import { useFlag } from "@/plane-web/hooks/store/use-flag";
|
||||
import { ProjectFeatureToggle } from "./helper";
|
||||
|
||||
type Props = {
|
||||
@@ -35,8 +32,6 @@ export const ProjectFeaturesList: FC<Props> = observer((props) => {
|
||||
const { t } = useTranslation();
|
||||
const { data: currentUser } = useUser();
|
||||
const { getProjectById, updateProject } = useProject();
|
||||
const { toggleProjectFeatures } = useProjectAdvanced();
|
||||
const isWorklogEnabled = useFlag(workspaceSlug, "ISSUE_WORKLOG");
|
||||
// derived values
|
||||
const currentProjectDetails = getProjectById(projectId);
|
||||
|
||||
@@ -48,9 +43,7 @@ export const ProjectFeaturesList: FC<Props> = observer((props) => {
|
||||
[featureProperty]: !currentProjectDetails?.[featureProperty as keyof IProject],
|
||||
};
|
||||
const updateProjectPromise = updateProject(workspaceSlug, projectId, settingsPayload);
|
||||
if (featureProperty === "is_time_tracking_enabled") {
|
||||
toggleProjectFeatures(workspaceSlug, projectId, settingsPayload, false);
|
||||
}
|
||||
|
||||
setPromiseToast(updateProjectPromise, {
|
||||
loading: "Updating project feature...",
|
||||
success: {
|
||||
@@ -62,7 +55,6 @@ export const ProjectFeaturesList: FC<Props> = observer((props) => {
|
||||
message: () => "Something went wrong while updating project feature. Please try again.",
|
||||
},
|
||||
});
|
||||
|
||||
updateProjectPromise.then(() => {
|
||||
captureSuccess({
|
||||
eventName: PROJECT_TRACKER_EVENTS.feature_toggled,
|
||||
@@ -95,10 +87,7 @@ export const ProjectFeaturesList: FC<Props> = observer((props) => {
|
||||
<h4 className="text-sm font-medium leading-5">{t(featureItem.key)}</h4>
|
||||
{featureItem.isPro && (
|
||||
<Tooltip tooltipContent="Pro feature" position="top">
|
||||
<UpgradeBadge
|
||||
flag={featureItem.property === "is_time_tracking_enabled" ? "ISSUE_WORKLOG" : undefined}
|
||||
className="rounded"
|
||||
/>
|
||||
<UpgradeBadge className="rounded" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
@@ -117,13 +106,8 @@ export const ProjectFeaturesList: FC<Props> = observer((props) => {
|
||||
/>
|
||||
</div>
|
||||
<div className="pl-14">
|
||||
{currentProjectDetails && currentProjectDetails?.[featureItem.property as keyof IProject] && (
|
||||
<ProjectFeatureChildren
|
||||
feature={featureItemKey}
|
||||
currentProjectDetails={currentProjectDetails}
|
||||
workspaceSlug={workspaceSlug}
|
||||
/>
|
||||
)}
|
||||
{currentProjectDetails?.[featureItem.property as keyof IProject] &&
|
||||
featureItem.renderChildren?.(currentProjectDetails, workspaceSlug)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { isEmpty } from "lodash-es";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
// plane imports
|
||||
import useSWR from "swr";
|
||||
import { InfoIcon } from "@plane/propel/icons";
|
||||
import { setPromiseToast } from "@plane/propel/toast";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import { TCycleConfig } from "@plane/types";
|
||||
import { Input, ToggleSwitch, CustomSelect, Button } from "@plane/ui";
|
||||
import { renderFormattedPayloadDate, getDate } from "@plane/utils";
|
||||
// components
|
||||
import { DateDropdown } from "@/components/dropdowns/date";
|
||||
//services
|
||||
import { useFlag } from "@/plane-web/hooks/store";
|
||||
import { useProjectAdvanced } from "@/plane-web/hooks/store/projects/use-projects";
|
||||
import { cycleService } from "@/plane-web/services/cycle.service";
|
||||
|
||||
const defaultValues: Partial<TCycleConfig> = {
|
||||
title: "",
|
||||
cycle_duration: 0,
|
||||
cooldown_period: 0,
|
||||
start_date: null,
|
||||
number_of_cycles: 0,
|
||||
is_auto_rollover_enabled: false,
|
||||
};
|
||||
|
||||
const cycleCountOptions = [
|
||||
{ value: 1, label: "1 cycle" },
|
||||
{ value: 2, label: "2 cycles" },
|
||||
{ value: 3, label: "3 cycles" },
|
||||
];
|
||||
|
||||
export const AutoScheduleCycles = observer(() => {
|
||||
const { workspaceSlug, projectId } = useParams();
|
||||
|
||||
const { control, handleSubmit, reset } = useForm<TCycleConfig>({
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
// states
|
||||
const [isEdit, setIsEdit] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
//hooks
|
||||
const { isProjectFeatureEnabled, toggleProjectFeatures } = useProjectAdvanced();
|
||||
|
||||
// derived values
|
||||
const isFeatureFlagEnabled = useFlag(workspaceSlug.toString(), "AUTO_SCHEDULE_CYCLES");
|
||||
const isAutoScheduleEnabled = isProjectFeatureEnabled(projectId.toString(), "is_automated_cycle_enabled");
|
||||
|
||||
const fetchCycleConfig = async () => {
|
||||
const cycleConfig = await cycleService.getCycleConfig(workspaceSlug.toString(), projectId.toString());
|
||||
if (isEmpty(cycleConfig)) {
|
||||
reset(defaultValues);
|
||||
return;
|
||||
} else {
|
||||
handleSetFormData(cycleConfig);
|
||||
}
|
||||
};
|
||||
|
||||
useSWR(
|
||||
workspaceSlug && projectId && isFeatureFlagEnabled && isAutoScheduleEnabled
|
||||
? `cycle-config-${workspaceSlug}-${projectId}`
|
||||
: null,
|
||||
workspaceSlug && projectId && isFeatureFlagEnabled && isAutoScheduleEnabled ? fetchCycleConfig : null,
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
}
|
||||
);
|
||||
|
||||
if (!isFeatureFlagEnabled) return null;
|
||||
|
||||
const handleReset = () => {
|
||||
setIsEdit(false);
|
||||
fetchCycleConfig();
|
||||
};
|
||||
|
||||
const handleSetFormData = (data: Partial<TCycleConfig>) => {
|
||||
reset({
|
||||
...data,
|
||||
cycle_duration: data.cycle_duration ? data.cycle_duration / 7 : 0,
|
||||
});
|
||||
};
|
||||
|
||||
const onSubmit = async (data: Partial<TCycleConfig>) => {
|
||||
setIsSubmitting(true);
|
||||
const payload: Partial<TCycleConfig> = {
|
||||
...data,
|
||||
cycle_duration: data.cycle_duration ? data.cycle_duration * 7 : 0,
|
||||
};
|
||||
const promise = data?.id
|
||||
? cycleService.updateCycleConfig(workspaceSlug.toString(), projectId.toString(), payload)
|
||||
: cycleService.scheduleCycle(workspaceSlug.toString(), projectId.toString(), payload);
|
||||
|
||||
const toastPromise = promise
|
||||
.then((response) => {
|
||||
handleSetFormData(response);
|
||||
setIsEdit(false);
|
||||
})
|
||||
.catch((error) => {
|
||||
throw error;
|
||||
})
|
||||
.finally(() => {
|
||||
setIsSubmitting(false);
|
||||
});
|
||||
|
||||
setPromiseToast(toastPromise, {
|
||||
loading: "Saving auto-schedule cycles configuration",
|
||||
success: {
|
||||
title: "Success!",
|
||||
message: () =>
|
||||
data.id
|
||||
? "Auto-schedule cycles configuration updated successfully."
|
||||
: "Auto-schedule cycles configuration saved successfully.",
|
||||
},
|
||||
error: {
|
||||
title: "Error!",
|
||||
message: () =>
|
||||
data.id
|
||||
? "Failed to update auto-schedule cycles configuration."
|
||||
: "Failed to save auto-schedule cycles configuration.",
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const toggleScheduleCycle = async (enabled: boolean) => {
|
||||
const promise = toggleProjectFeatures(
|
||||
workspaceSlug.toString(),
|
||||
projectId.toString(),
|
||||
{
|
||||
is_automated_cycle_enabled: enabled,
|
||||
},
|
||||
enabled
|
||||
);
|
||||
|
||||
setPromiseToast(promise, {
|
||||
loading: enabled ? "Enabling auto-schedule cycles" : "Disabling auto-schedule cycles",
|
||||
success: { title: "Success!", message: () => "Auto-schedule cycles toggled successfully." },
|
||||
error: { title: "Error!", message: () => "Failed to toggle auto-schedule cycles." },
|
||||
});
|
||||
|
||||
if (enabled) {
|
||||
setIsEdit(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{/* Main toggle */}
|
||||
<div className="gap-x-8 gap-y-2 bg-custom-background-100 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<h4 className="text-sm font-medium leading-5 flex items-center gap-1">
|
||||
Auto-schedule cycles
|
||||
<Tooltip tooltipContent="Automatically create new cycles based on your chosen schedule." position="right">
|
||||
<div>
|
||||
<InfoIcon className="size-3 text-custom-text-400" />
|
||||
</div>
|
||||
</Tooltip>
|
||||
</h4>
|
||||
<p className="text-sm leading-5 tracking-tight text-custom-text-300">
|
||||
Keep cycles moving without manual setup.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{isAutoScheduleEnabled && !isEdit && (
|
||||
<Button type="button" variant="neutral-primary" size="sm" onClick={() => setIsEdit(true)}>
|
||||
Edit
|
||||
</Button>
|
||||
)}
|
||||
<ToggleSwitch value={isAutoScheduleEnabled} onChange={toggleScheduleCycle} size="sm" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Configuration form - only show when enabled */}
|
||||
{isAutoScheduleEnabled && (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div className="border border-custom-border-200 bg-custom-background-90 p-4 rounded-lg space-y-5">
|
||||
{/* Cycle Title */}
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="w-2/3">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-sm font-medium">Cycle Title</span>
|
||||
<Tooltip
|
||||
tooltipContent="The title will be appended with numbers for subsequent cycles. For eg: Design - 1/2/3"
|
||||
position="right"
|
||||
>
|
||||
<div>
|
||||
<InfoIcon className="size-3 text-custom-text-400" />
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-1/3">
|
||||
<Controller
|
||||
name="title"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
className="bg-custom-background-100 w-full px-3 py-2 rounded-md border-[0.5px] border-custom-border-200 text-sm"
|
||||
placeholder="Title"
|
||||
disabled={!isEdit}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Cycle Duration */}
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="w-2/3">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-sm font-medium">Cycle Duration</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-1/3">
|
||||
<div className="flex items-center gap-1 w-full">
|
||||
<Controller
|
||||
name="cycle_duration"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
className="bg-custom-background-100 w-1/2 px-3 py-2 rounded-md border-[0.5px] border-custom-border-200 text-sm"
|
||||
placeholder="1"
|
||||
type="number"
|
||||
max={30}
|
||||
disabled={!isEdit}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<span className="text-sm text-custom-text-200">Weeks</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Cool down Period*/}
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="w-2/3">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-sm font-medium">Cooldown Period</span>
|
||||
<Tooltip tooltipContent="Pause between cycles before the next begins." position="right">
|
||||
<div>
|
||||
<InfoIcon className="size-3 text-custom-text-400" />
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-1/3">
|
||||
<div className="flex items-center gap-1 w-full">
|
||||
<Controller
|
||||
name="cooldown_period"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
className="bg-custom-background-100 w-1/2 px-3 py-2 rounded-md border-[0.5px] border-custom-border-200 text-sm"
|
||||
placeholder="1"
|
||||
type="number"
|
||||
min={0}
|
||||
disabled={!isEdit}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<span className="text-sm text-custom-text-200">days</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Cycle Start Date */}
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="w-2/3">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-sm font-medium">Cycle starts day</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-1/3">
|
||||
<div className="flex items-center gap-1 w-full">
|
||||
<Controller
|
||||
name="start_date"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<DateDropdown
|
||||
value={getDate(field.value) || null}
|
||||
onChange={(date) => field.onChange(renderFormattedPayloadDate(date) || "")}
|
||||
minDate={new Date()}
|
||||
buttonVariant="border-with-text"
|
||||
className="w-full"
|
||||
buttonClassName="bg-custom-background-100 px-3 py-2 rounded-md border-[0.5px] border-custom-border-200 text-left justify-start w-full text-sm"
|
||||
showTooltip
|
||||
disabled={!isEdit}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Number of future cycles */}
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="w-2/3">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-sm font-medium">Number of future cycles</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-1/3">
|
||||
<Controller
|
||||
name="number_of_cycles"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<CustomSelect
|
||||
disabled={!isEdit}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
className="w-full"
|
||||
buttonClassName="bg-custom-background-100 px-3 py-2 rounded-md border-[0.5px] border-custom-border-200 text-left w-full text-sm"
|
||||
label={cycleCountOptions.find((option) => option.value === field.value)?.label || "1 cycle"}
|
||||
>
|
||||
{cycleCountOptions.map((option) => (
|
||||
<CustomSelect.Option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</CustomSelect.Option>
|
||||
))}
|
||||
</CustomSelect>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Auto-rollover work items */}
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="w-2/3">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-sm font-medium">Auto-rollover work items</span>
|
||||
<Tooltip
|
||||
tooltipContent="On the day a cycle completes, move all unfinished work items into the next cycle."
|
||||
position="right"
|
||||
>
|
||||
<div>
|
||||
<InfoIcon className="size-3 text-custom-text-400" />
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-1/3 flex justify-end">
|
||||
<Controller
|
||||
name="is_auto_rollover_enabled"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<ToggleSwitch value={field.value} onChange={field.onChange} disabled={!isEdit} size="sm" />
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
{isEdit && (
|
||||
<div className="flex items-center justify-end gap-2 pt-4 border-t border-custom-border-200">
|
||||
<Button type="button" variant="neutral-primary" size="sm" onClick={handleReset}>
|
||||
Discard
|
||||
</Button>
|
||||
<Button type="submit" variant="primary" size="sm" disabled={isSubmitting}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./auto-schedule-cycles";
|
||||
@@ -1 +1,149 @@
|
||||
export { ProjectFeaturesList } from "@/components/project/settings/features-list";
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import type { E_FEATURE_FLAGS } from "@plane/constants";
|
||||
import { PROJECT_TRACKER_EVENTS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { setPromiseToast } from "@plane/propel/toast";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import type { IProject, TProjectFeaturesList } from "@plane/types";
|
||||
// components
|
||||
import type { TProperties } from "@/ce/constants/project";
|
||||
import { ProjectFeatureToggle } from "@/components/project/settings/helper";
|
||||
import { SettingsHeading } from "@/components/settings/heading";
|
||||
// helpers
|
||||
import { captureSuccess } from "@/helpers/event-tracker.helper";
|
||||
// hooks
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useUser } from "@/hooks/store/user";
|
||||
// plane web imports
|
||||
import { ProjectFeatureChildren } from "@/plane-web/components/projects/settings/feature-children";
|
||||
import { UpgradeBadge } from "@/plane-web/components/workspace/upgrade-badge";
|
||||
import { PROJECT_FEATURES_LIST } from "@/plane-web/constants/project/settings";
|
||||
import { useProjectAdvanced } from "@/plane-web/hooks/store/projects/use-projects";
|
||||
import { useFlag } from "@/plane-web/hooks/store/use-flag";
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
isAdmin: boolean;
|
||||
};
|
||||
|
||||
export const ProjectFeaturesList: FC<Props> = observer((props) => {
|
||||
const { workspaceSlug, projectId, isAdmin } = props;
|
||||
// store hooks
|
||||
const { t } = useTranslation();
|
||||
const { data: currentUser } = useUser();
|
||||
const { getProjectById, updateProject } = useProject();
|
||||
const { toggleProjectFeatures, isProjectFeatureEnabled } = useProjectAdvanced();
|
||||
// Feature flag mapping per project feature property
|
||||
const FEATURE_FLAG_BY_PROPERTY: Record<string, keyof typeof E_FEATURE_FLAGS | undefined> = {
|
||||
is_time_tracking_enabled: "ISSUE_WORKLOG",
|
||||
// Add more property-to-flag mappings here as needed
|
||||
};
|
||||
// Precompute known flags once
|
||||
const flagsByKey: Partial<Record<keyof typeof E_FEATURE_FLAGS, boolean>> = {
|
||||
ISSUE_WORKLOG: useFlag(workspaceSlug, "ISSUE_WORKLOG"),
|
||||
};
|
||||
// derived values
|
||||
const currentProjectDetails = getProjectById(projectId);
|
||||
|
||||
const handleSubmit = async (featureKey: string, featureProperty: string) => {
|
||||
if (!workspaceSlug || !projectId || !currentProjectDetails) return;
|
||||
|
||||
// making the request to update the project feature
|
||||
const settingsPayload = {
|
||||
[featureProperty]: !currentProjectDetails?.[featureProperty as keyof IProject],
|
||||
};
|
||||
const updateProjectPromise = updateProject(workspaceSlug, projectId, settingsPayload);
|
||||
// Optimistically update features in store for any feature toggle
|
||||
toggleProjectFeatures(workspaceSlug, projectId, settingsPayload, false);
|
||||
setPromiseToast(updateProjectPromise, {
|
||||
loading: "Updating project feature...",
|
||||
success: {
|
||||
title: "Success!",
|
||||
message: () => "Project feature updated successfully.",
|
||||
},
|
||||
error: {
|
||||
title: "Error!",
|
||||
message: () => "Something went wrong while updating project feature. Please try again.",
|
||||
},
|
||||
});
|
||||
|
||||
updateProjectPromise.then(() => {
|
||||
captureSuccess({
|
||||
eventName: PROJECT_TRACKER_EVENTS.feature_toggled,
|
||||
payload: {
|
||||
feature_key: featureKey,
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const isFeatureEnabled = (featureItem: TProperties): boolean =>
|
||||
Boolean(currentProjectDetails?.[featureItem.property as keyof IProject]) ||
|
||||
isProjectFeatureEnabled(projectId, featureItem.property as keyof TProjectFeaturesList);
|
||||
|
||||
if (!currentUser) return <></>;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{Object.entries(PROJECT_FEATURES_LIST).map(([featureSectionKey, feature]) => (
|
||||
<div key={featureSectionKey} className="">
|
||||
<SettingsHeading title={t(feature.key)} description={t(`${feature.key}_description`)} />
|
||||
{Object.entries(feature.featureList).map(([featureItemKey, featureItem]) => {
|
||||
const flagKey = FEATURE_FLAG_BY_PROPERTY[featureItem.property];
|
||||
const isFeatureFlagEnabled = flagKey ? Boolean(flagsByKey[flagKey]) : true;
|
||||
return (
|
||||
<div
|
||||
key={featureItemKey}
|
||||
className="gap-x-8 gap-y-2 border-b border-custom-border-100 bg-custom-background-100 py-4"
|
||||
>
|
||||
<div key={featureItemKey} className="flex items-center justify-between">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex items-center justify-center rounded bg-custom-background-90 p-3">
|
||||
{featureItem.icon}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="text-sm font-medium leading-5">{t(featureItem.key)}</h4>
|
||||
{featureItem.isPro && (
|
||||
<Tooltip tooltipContent="Pro feature" position="top">
|
||||
<UpgradeBadge flag={flagKey} className="rounded" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm leading-5 tracking-tight text-custom-text-300">
|
||||
{t(`${featureItem.key}_description`)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ProjectFeatureToggle
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
featureItem={featureItem}
|
||||
value={isFeatureEnabled(featureItem)}
|
||||
handleSubmit={handleSubmit}
|
||||
disabled={!isFeatureFlagEnabled || !isAdmin}
|
||||
/>
|
||||
</div>
|
||||
<div className="pl-14">
|
||||
{currentProjectDetails && currentProjectDetails?.[featureItem.property as keyof IProject] && (
|
||||
<ProjectFeatureChildren
|
||||
feature={featureItemKey}
|
||||
currentProjectDetails={currentProjectDetails}
|
||||
workspaceSlug={workspaceSlug}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -58,6 +58,10 @@ export const INTAKE_FEATURES_LIST: TIntakeFeatureList = {
|
||||
|
||||
export const PROJECT_BASE_FEATURES_LIST = {
|
||||
...CE_PROJECT_BASE_FEATURES_LIST,
|
||||
cycles: {
|
||||
...CE_PROJECT_BASE_FEATURES_LIST.cycles,
|
||||
href: "/cycles",
|
||||
},
|
||||
};
|
||||
|
||||
export const PROJECT_OTHER_FEATURES_LIST = {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
// helpers
|
||||
import { API_BASE_URL } from "@plane/constants";
|
||||
// services
|
||||
import type { TCycleConfig } from "@plane/types";
|
||||
import type { CYCLE_ACTION } from "@/plane-web/constants/cycle";
|
||||
import { APIService } from "@/services/api.service";
|
||||
import { CycleService as CycleServiceCore } from "@/services/cycle.service";
|
||||
import type { TCycleUpdateReaction, TCycleUpdates } from "../types";
|
||||
import { TCycleUpdateStatus } from "../types";
|
||||
|
||||
export class CycleUpdateService extends APIService {
|
||||
constructor() {
|
||||
@@ -112,4 +112,38 @@ export class CycleService extends CycleServiceCore {
|
||||
throw err?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
async getCycleConfig(workspaceSlug: string, projectId: string): Promise<Partial<TCycleConfig>> {
|
||||
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/automated-cycles/`)
|
||||
.then((res) => res?.data)
|
||||
.catch((err) => {
|
||||
throw err?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
async scheduleCycle(
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
data: Partial<TCycleConfig>
|
||||
): Promise<Partial<TCycleConfig>> {
|
||||
return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/automated-cycles/`, data)
|
||||
.then((res) => res?.data)
|
||||
.catch((err) => {
|
||||
throw err?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
async updateCycleConfig(
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
data: Partial<TCycleConfig>
|
||||
): Promise<Partial<TCycleConfig>> {
|
||||
return this.patch(`/api/workspaces/${workspaceSlug}/projects/${projectId}/automated-cycles/`, data)
|
||||
.then((res) => res?.data)
|
||||
.catch((err) => {
|
||||
throw err?.response?.data;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const cycleService = new CycleService();
|
||||
|
||||
@@ -100,6 +100,7 @@ export class ProjectStore implements IProjectStore {
|
||||
is_time_tracking_enabled: false,
|
||||
is_workflow_enabled: false,
|
||||
project_id: projectId,
|
||||
is_automated_cycle_enabled: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ export enum E_FEATURE_FLAGS {
|
||||
GLOBAL_VIEWS_TIMELINE = "GLOBAL_VIEWS_TIMELINE",
|
||||
COPY_WORK_ITEM = "COPY_WORK_ITEM",
|
||||
LINK_PAGES = "LINK_PAGES",
|
||||
AUTO_SCHEDULE_CYCLES = "AUTO_SCHEDULE_CYCLES",
|
||||
|
||||
// ====== silo importers ======
|
||||
SILO_IMPORTERS = "SILO_IMPORTERS",
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
export type TCycleConfig = {
|
||||
id: string;
|
||||
title: string;
|
||||
cycle_duration: number;
|
||||
cooldown_period: number;
|
||||
start_date: string | null;
|
||||
number_of_cycles: number;
|
||||
is_auto_rollover_enabled: boolean;
|
||||
is_active: boolean;
|
||||
created_by: string;
|
||||
updated_by: string | null;
|
||||
created_at: string;
|
||||
updated_at: string | null;
|
||||
};
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from "./cycle_filters";
|
||||
export * from "./cycle";
|
||||
export * from "./cycle-extended";
|
||||
|
||||
@@ -91,6 +91,7 @@ export type TProjectFeaturesList = {
|
||||
is_issue_type_enabled: boolean;
|
||||
is_time_tracking_enabled: boolean;
|
||||
is_workflow_enabled: boolean;
|
||||
is_automated_cycle_enabled: boolean;
|
||||
};
|
||||
|
||||
export type TProjectFeatures = {
|
||||
|
||||
Reference in New Issue
Block a user