diff --git a/apiserver/plane/ee/bgtasks/bulk_issue_activities_task.py b/apiserver/plane/ee/bgtasks/bulk_issue_activities_task.py index 040027226d..67b35e4440 100644 --- a/apiserver/plane/ee/bgtasks/bulk_issue_activities_task.py +++ b/apiserver/plane/ee/bgtasks/bulk_issue_activities_task.py @@ -1,6 +1,5 @@ # Python imports import json - import requests # Third Party imports @@ -22,6 +21,7 @@ from plane.db.models import ( Label, Project, User, + Cycle, ) from plane.settings.redis import redis_instance from plane.utils.exception_logger import log_exception @@ -128,6 +128,67 @@ def track_assignees( ) +def create_cycle_issue_activity( + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, +): + requested_data = ( + json.loads(requested_data) if requested_data is not None else None + ) + current_instance = ( + json.loads(current_instance) if current_instance is not None else None + ) + + if requested_data.get("cycle_id") and current_instance.get("cycle_id"): + new_cycle = Cycle.objects.filter( + pk=requested_data.get("cycle_id") + ).first() + old_cycle = Cycle.objects.filter( + pk=current_instance.get("cycle_id") + ).first() + + issue_activities.append( + IssueActivity( + issue_id=issue_id, + actor_id=actor_id, + verb="updated", + old_value=old_cycle.name, + new_value=new_cycle.name, + field="cycles", + project_id=project_id, + workspace_id=workspace_id, + comment=f"updated cycle from {old_cycle.name} to {new_cycle.name}", + old_identifier=old_cycle.id, + new_identifier=new_cycle.id, + epoch=epoch, + ) + ) + + if requested_data.get("cycle_id"): + cycle = Cycle.objects.filter(pk=requested_data.get("cycle_id")).first() + issue_activities.append( + IssueActivity( + issue_id=issue_id, + actor_id=actor_id, + verb="created", + old_value="", + new_value=cycle.name, + field="cycles", + project_id=project_id, + workspace_id=workspace_id, + comment=f"added cycle {cycle.name}", + new_identifier=cycle.id, + epoch=epoch, + ) + ) + + def update_issue_activity( requested_data, current_instance, @@ -201,6 +262,7 @@ def bulk_issue_activity( ACTIVITY_MAPPER = { "issue.activity.updated": update_issue_activity, + "cycle.activity.created": create_cycle_issue_activity, } func = ACTIVITY_MAPPER.get(type) diff --git a/apiserver/plane/ee/views/app/issue/bulk_operations.py b/apiserver/plane/ee/views/app/issue/bulk_operations.py index 5dd998c026..025058de8d 100644 --- a/apiserver/plane/ee/views/app/issue/bulk_operations.py +++ b/apiserver/plane/ee/views/app/issue/bulk_operations.py @@ -3,8 +3,13 @@ import json from datetime import datetime # Django imports -from django.core.serializers.json import DjangoJSONEncoder from django.utils import timezone +from django.db.models.functions import Coalesce +from django.db.models import Q, Value, UUIDField, F +from django.contrib.postgres.fields import ArrayField +from django.contrib.postgres.aggregates import ArrayAgg +from django.core.serializers.json import DjangoJSONEncoder + # Third Party imports from rest_framework.response import Response @@ -23,6 +28,8 @@ from plane.db.models import ( IssueAssignee, Workspace, IssueSubscriber, + CycleIssue, + ModuleIssue, ) from plane.bgtasks.issue_activities_task import issue_activity from plane.ee.bgtasks import bulk_issue_activity @@ -50,7 +57,35 @@ class BulkIssueOperationsEndpoint(BaseAPIView): workspace__slug=slug, project_id=project_id, pk__in=issue_ids ) .select_related("state") - .prefetch_related("labels", "assignees") + .prefetch_related("labels", "assignees", "issue_module__module") + .annotate(cycle_id=F("issue_cycle__cycle_id")) + .annotate( + label_ids=Coalesce( + ArrayAgg( + "labels__id", + distinct=True, + filter=~Q(labels__id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + assignee_ids=Coalesce( + ArrayAgg( + "assignees__id", + distinct=True, + filter=~Q(assignees__id__isnull=True) + & Q(assignees__member_project__is_active=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + module_ids=Coalesce( + ArrayAgg( + "issue_module__module_id", + distinct=True, + filter=~Q(issue_module__module_id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + ) ) # Current epoch epoch = int(timezone.now().timestamp()) @@ -64,6 +99,7 @@ class BulkIssueOperationsEndpoint(BaseAPIView): bulk_update_issues = [] bulk_issue_activities = [] bulk_update_issue_labels = [] + bulk_update_issue_modules = [] bulk_update_issue_assignees = [] properties = request.data.get("properties", {}) @@ -271,6 +307,104 @@ class BulkIssueOperationsEndpoint(BaseAPIView): } ) + # Module + if properties.get("module_ids", []): + for module_id in properties.get( + "module_ids", + ): + issue_module_ids = [str(uuid) for uuid in issue.module_ids] + if module_id not in issue_module_ids: + bulk_update_issue_modules.append( + ModuleIssue( + issue=issue, + module_id=module_id, + project_id=project_id, + workspace_id=project.workspace_id, + created_by=request.user, + ) + ) + issue_activities.append( + { + "type": "module.activity.created", + "requested_data": json.dumps( + {"module_id": module_id} + ), + "current_instance": None, + "issue_id": str(issue.id), + "actor_id": str(request.user.id), + "project_id": str(project_id), + "epoch": epoch, + } + ) + + # Cycles + if properties.get("cycle_id", False): + if str(issue.cycle_id) != properties.get("cycle_id"): + if issue.cycle_id is not None: + # Old cycle issue to delete + CycleIssue.objects.filter( + issue_id=issue.id, cycle_id=issue.cycle_id + ).delete() + # New issues to create + _ = CycleIssue.objects.create( + created_by_id=request.user.id, + updated_by_id=request.user.id, + cycle_id=properties.get("cycle_id"), + issue=issue, + project_id=project_id, + workspace_id=workspace_id, + ) + bulk_issue_activities.append( + { + "type": "cycle.activity.created", + "requested_data": json.dumps( + {"cycle_id": properties.get("cycle_id")} + ), + "current_instance": json.dumps( + { + "cycle_id": ( + str(issue.cycle_id) + if issue.cycle_id + else None + ) + } + ), + "issue_id": str(issue.id), + "actor_id": str(request.user.id), + "project_id": str(project_id), + "epoch": epoch, + } + ) + + # Estimate Point + if properties.get("estimate_point", False): + issue_activities.append( + { + "type": "issue.activity.updated", + "requested_data": json.dumps( + { + "estimate_point": properties.get( + "estimate_point" + ) + } + ), + "current_instance": json.dumps( + { + "estimate_point": ( + str(issue.estimate_point_id) + if issue.estimate_point_id + else issue.estimate_point_id + ) + } + ), + "issue_id": str(issue.id), + "actor_id": str(request.user.id), + "project_id": str(project_id), + "epoch": epoch, + } + ) + issue.estimate_point_id = properties.get("estimate_point") + # Bulk update all the objects Issue.objects.bulk_update( bulk_update_issues, @@ -280,6 +414,7 @@ class BulkIssueOperationsEndpoint(BaseAPIView): "target_date", "state_id", "completed_at", + "estimate_point_id", ], batch_size=100, ) @@ -297,6 +432,13 @@ class BulkIssueOperationsEndpoint(BaseAPIView): ignore_conflicts=True, batch_size=100, ) + + # Create new modules + ModuleIssue.objects.bulk_create( + bulk_update_issue_modules, + ignore_conflicts=True, + batch_size=100, + ) # update the issue activity [issue_activity.delay(**activity) for activity in issue_activities] [ diff --git a/web/ee/components/issues/bulk-operations/properties.tsx b/web/ee/components/issues/bulk-operations/properties.tsx index 1e1c8a0557..d8233da144 100644 --- a/web/ee/components/issues/bulk-operations/properties.tsx +++ b/web/ee/components/issues/bulk-operations/properties.tsx @@ -10,7 +10,15 @@ import { TBulkIssueProperties } from "@plane/types"; // ui import { Button, TOAST_TYPE, setToast } from "@plane/ui"; // components -import { DateDropdown, MemberDropdown, PriorityDropdown, StateDropdown } from "@/components/dropdowns"; +import { + DateDropdown, + MemberDropdown, + PriorityDropdown, + StateDropdown, + EstimateDropdown, + CycleDropdown, + ModuleDropdown, +} from "@/components/dropdowns"; import { IssueLabelSelect } from "@/components/issues/select"; import { CreateLabelModal } from "@/components/labels"; // constants @@ -18,6 +26,7 @@ import { EErrorCodes, ERROR_DETAILS } from "@/constants/errors"; // helpers import { getDate, renderFormattedPayloadDate } from "@/helpers/date-time.helper"; // hooks +import { useProjectEstimates } from "@/hooks/store"; import { useIssuesStore } from "@/hooks/use-issue-layout-store"; import { TSelectionHelper, TSelectionSnapshot } from "@/hooks/use-multiple-select"; @@ -34,6 +43,9 @@ const defaultValues: TBulkIssueProperties = { start_date: null, target_date: null, label_ids: [], + cycle_id: "", + module_ids: [], + estimate_point: null, }; export const IssueBulkOperationsProperties: React.FC = observer((props) => { @@ -46,6 +58,7 @@ export const IssueBulkOperationsProperties: React.FC = observer((props) = const { issues: { bulkUpdateProperties }, } = useIssuesStore(); + const { currentActiveEstimateId } = useProjectEstimates(); // form info const { control, @@ -213,7 +226,7 @@ export const IssueBulkOperationsProperties: React.FC = observer((props) = projectId={projectId.toString()} onChange={onChange} setIsOpen={() => setCreateLabelModal(true)} - buttonClassName="text-custom-text-300" + buttonClassName="text-custom-text-300 " placement="top-start" /> @@ -221,6 +234,67 @@ export const IssueBulkOperationsProperties: React.FC = observer((props) = )} /> )} + {projectId && ( + ( + <> + + + )} + /> + )} + {projectId && currentActiveEstimateId && ( + ( + + )} + /> + )} + {projectId && ( + ( + <> + + + )} + /> + )} {isDirty && (