diff --git a/apiserver/plane/api/views/base.py b/apiserver/plane/api/views/base.py index b069ef78c1a..edb89f9b187 100644 --- a/apiserver/plane/api/views/base.py +++ b/apiserver/plane/api/views/base.py @@ -1,6 +1,8 @@ # Python imports import zoneinfo import json +from urllib.parse import urlparse + # Django imports from django.conf import settings @@ -51,6 +53,11 @@ def finalize_response(self, request, response, *args, **kwargs): and self.request.method in ["POST", "PATCH", "DELETE"] and response.status_code in [200, 201, 204] ): + url = request.build_absolute_uri() + parsed_url = urlparse(url) + # Extract the scheme and netloc + scheme = parsed_url.scheme + netloc = parsed_url.netloc # Push the object to delay send_webhook.delay( event=self.webhook_event, @@ -59,6 +66,7 @@ def finalize_response(self, request, response, *args, **kwargs): action=self.request.method, slug=self.workspace_slug, bulk=self.bulk, + current_site=f"{scheme}://{netloc}", ) return response diff --git a/apiserver/plane/api/views/cycle.py b/apiserver/plane/api/views/cycle.py index c296bb11180..6f66c373ec6 100644 --- a/apiserver/plane/api/views/cycle.py +++ b/apiserver/plane/api/views/cycle.py @@ -243,6 +243,29 @@ def post(self, request, slug, project_id): ): serializer = CycleSerializer(data=request.data) if serializer.is_valid(): + if ( + request.data.get("external_id") + and request.data.get("external_source") + and Cycle.objects.filter( + project_id=project_id, + workspace__slug=slug, + external_source=request.data.get("external_source"), + external_id=request.data.get("external_id"), + ).exists() + ): + cycle = Cycle.objects.filter( + workspace__slug=slug, + project_id=project_id, + external_source=request.data.get("external_source"), + external_id=request.data.get("external_id"), + ).first() + return Response( + { + "error": "Cycle with the same external id and external source already exists", + "id": str(cycle.id), + }, + status=status.HTTP_409_CONFLICT, + ) serializer.save( project_id=project_id, owned_by=request.user, @@ -289,6 +312,23 @@ def patch(self, request, slug, project_id, pk): serializer = CycleSerializer(cycle, data=request.data, partial=True) if serializer.is_valid(): + if ( + request.data.get("external_id") + and (cycle.external_id != request.data.get("external_id")) + and Cycle.objects.filter( + project_id=project_id, + workspace__slug=slug, + external_source=request.data.get("external_source", cycle.external_source), + external_id=request.data.get("external_id"), + ).exists() + ): + return Response( + { + "error": "Cycle with the same external id and external source already exists", + "id": str(cycle.id), + }, + status=status.HTTP_409_CONFLICT, + ) serializer.save() return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) diff --git a/apiserver/plane/api/views/issue.py b/apiserver/plane/api/views/issue.py index e91f2a5f66f..a759b15f6e0 100644 --- a/apiserver/plane/api/views/issue.py +++ b/apiserver/plane/api/views/issue.py @@ -220,6 +220,30 @@ def post(self, request, slug, project_id): ) if serializer.is_valid(): + if ( + request.data.get("external_id") + and request.data.get("external_source") + and Issue.objects.filter( + project_id=project_id, + workspace__slug=slug, + external_source=request.data.get("external_source"), + external_id=request.data.get("external_id"), + ).exists() + ): + issue = Issue.objects.filter( + workspace__slug=slug, + project_id=project_id, + external_id=request.data.get("external_id"), + external_source=request.data.get("external_source"), + ).first() + return Response( + { + "error": "Issue with the same external id and external source already exists", + "id": str(issue.id), + }, + status=status.HTTP_409_CONFLICT, + ) + serializer.save() # Track the issue @@ -256,6 +280,26 @@ def patch(self, request, slug, project_id, pk=None): partial=True, ) if serializer.is_valid(): + if ( + str(request.data.get("external_id")) + and (issue.external_id != str(request.data.get("external_id"))) + and Issue.objects.filter( + project_id=project_id, + workspace__slug=slug, + external_source=request.data.get( + "external_source", issue.external_source + ), + external_id=request.data.get("external_id"), + ).exists() + ): + return Response( + { + "error": "Issue with the same external id and external source already exists", + "id": str(issue.id), + }, + status=status.HTTP_409_CONFLICT, + ) + serializer.save() issue_activity.delay( type="issue.activity.updated", @@ -263,6 +307,8 @@ def patch(self, request, slug, project_id, pk=None): actor_id=str(request.user.id), issue_id=str(pk), project_id=str(project_id), + external_id__isnull=False, + external_source__isnull=False, current_instance=current_instance, epoch=int(timezone.now().timestamp()), ) @@ -318,6 +364,30 @@ def post(self, request, slug, project_id): try: serializer = LabelSerializer(data=request.data) if serializer.is_valid(): + if ( + request.data.get("external_id") + and request.data.get("external_source") + and Label.objects.filter( + project_id=project_id, + workspace__slug=slug, + external_source=request.data.get("external_source"), + external_id=request.data.get("external_id"), + ).exists() + ): + label = Label.objects.filter( + workspace__slug=slug, + project_id=project_id, + external_id=request.data.get("external_id"), + external_source=request.data.get("external_source"), + ).first() + return Response( + { + "error": "Label with the same external id and external source already exists", + "id": str(label.id), + }, + status=status.HTTP_409_CONFLICT, + ) + serializer.save(project_id=project_id) return Response( serializer.data, status=status.HTTP_201_CREATED @@ -326,11 +396,17 @@ def post(self, request, slug, project_id): serializer.errors, status=status.HTTP_400_BAD_REQUEST ) except IntegrityError: + label = Label.objects.filter( + workspace__slug=slug, + project_id=project_id, + name=request.data.get("name"), + ).first() return Response( { - "error": "Label with the same name already exists in the project" + "error": "Label with the same name already exists in the project", + "id": str(label.id), }, - status=status.HTTP_400_BAD_REQUEST, + status=status.HTTP_409_CONFLICT, ) def get(self, request, slug, project_id, pk=None): @@ -357,6 +433,25 @@ def patch(self, request, slug, project_id, pk=None): label = self.get_queryset().get(pk=pk) serializer = LabelSerializer(label, data=request.data, partial=True) if serializer.is_valid(): + if ( + str(request.data.get("external_id")) + and (label.external_id != str(request.data.get("external_id"))) + and Issue.objects.filter( + project_id=project_id, + workspace__slug=slug, + external_source=request.data.get( + "external_source", label.external_source + ), + external_id=request.data.get("external_id"), + ).exists() + ): + return Response( + { + "error": "Label with the same external id and external source already exists", + "id": str(label.id), + }, + status=status.HTTP_409_CONFLICT, + ) serializer.save() return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) diff --git a/apiserver/plane/api/views/module.py b/apiserver/plane/api/views/module.py index 1a9a21a3c19..d509a53c79d 100644 --- a/apiserver/plane/api/views/module.py +++ b/apiserver/plane/api/views/module.py @@ -132,6 +132,29 @@ def post(self, request, slug, project_id): }, ) if serializer.is_valid(): + if ( + request.data.get("external_id") + and request.data.get("external_source") + and Module.objects.filter( + project_id=project_id, + workspace__slug=slug, + external_source=request.data.get("external_source"), + external_id=request.data.get("external_id"), + ).exists() + ): + module = Module.objects.filter( + project_id=project_id, + workspace__slug=slug, + external_source=request.data.get("external_source"), + external_id=request.data.get("external_id"), + ).first() + return Response( + { + "error": "Module with the same external id and external source already exists", + "id": str(module.id), + }, + status=status.HTTP_409_CONFLICT, + ) serializer.save() module = Module.objects.get(pk=serializer.data["id"]) serializer = ModuleSerializer(module) @@ -149,8 +172,25 @@ def patch(self, request, slug, project_id, pk): partial=True, ) if serializer.is_valid(): + if ( + request.data.get("external_id") + and (module.external_id != request.data.get("external_id")) + and Module.objects.filter( + project_id=project_id, + workspace__slug=slug, + external_source=request.data.get("external_source", module.external_source), + external_id=request.data.get("external_id"), + ).exists() + ): + return Response( + { + "error": "Module with the same external id and external source already exists", + "id": str(module.id), + }, + status=status.HTTP_409_CONFLICT, + ) serializer.save() - return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) def get(self, request, slug, project_id, pk=None): diff --git a/apiserver/plane/api/views/state.py b/apiserver/plane/api/views/state.py index f931c2ed264..0a262a071d4 100644 --- a/apiserver/plane/api/views/state.py +++ b/apiserver/plane/api/views/state.py @@ -38,6 +38,30 @@ def post(self, request, slug, project_id): data=request.data, context={"project_id": project_id} ) if serializer.is_valid(): + if ( + request.data.get("external_id") + and request.data.get("external_source") + and State.objects.filter( + project_id=project_id, + workspace__slug=slug, + external_source=request.data.get("external_source"), + external_id=request.data.get("external_id"), + ).exists() + ): + state = State.objects.filter( + workspace__slug=slug, + project_id=project_id, + external_id=request.data.get("external_id"), + external_source=request.data.get("external_source"), + ).first() + return Response( + { + "error": "State with the same external id and external source already exists", + "id": str(state.id), + }, + status=status.HTTP_409_CONFLICT, + ) + serializer.save(project_id=project_id) return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @@ -91,6 +115,23 @@ def patch(self, request, slug, project_id, state_id=None): ) serializer = StateSerializer(state, data=request.data, partial=True) if serializer.is_valid(): + if ( + str(request.data.get("external_id")) + and (state.external_id != str(request.data.get("external_id"))) + and State.objects.filter( + project_id=project_id, + workspace__slug=slug, + external_source=request.data.get("external_source", state.external_source), + external_id=request.data.get("external_id"), + ).exists() + ): + return Response( + { + "error": "State with the same external id and external source already exists", + "id": str(state.id), + }, + status=status.HTTP_409_CONFLICT, + ) serializer.save() return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) diff --git a/apiserver/plane/app/views/base.py b/apiserver/plane/app/views/base.py index e07cb811cc8..fa1e7559b06 100644 --- a/apiserver/plane/app/views/base.py +++ b/apiserver/plane/app/views/base.py @@ -64,6 +64,7 @@ def finalize_response(self, request, response, *args, **kwargs): action=self.request.method, slug=self.workspace_slug, bulk=self.bulk, + current_site=request.META.get("HTTP_ORIGIN"), ) return response diff --git a/apiserver/plane/app/views/cycle.py b/apiserver/plane/app/views/cycle.py index 1b1d9c37cb9..af799c42703 100644 --- a/apiserver/plane/app/views/cycle.py +++ b/apiserver/plane/app/views/cycle.py @@ -243,13 +243,13 @@ def list(self, request, slug, project_id): .values("display_name", "assignee_id", "avatar") .annotate( total_issues=Count( - "assignee_id", + "id", filter=Q(archived_at__isnull=True, is_draft=False), ), ) .annotate( completed_issues=Count( - "assignee_id", + "id", filter=Q( completed_at__isnull=False, archived_at__isnull=True, @@ -259,7 +259,7 @@ def list(self, request, slug, project_id): ) .annotate( pending_issues=Count( - "assignee_id", + "id", filter=Q( completed_at__isnull=True, archived_at__isnull=True, @@ -282,13 +282,13 @@ def list(self, request, slug, project_id): .values("label_name", "color", "label_id") .annotate( total_issues=Count( - "label_id", + "id", filter=Q(archived_at__isnull=True, is_draft=False), ) ) .annotate( completed_issues=Count( - "label_id", + "id", filter=Q( completed_at__isnull=False, archived_at__isnull=True, @@ -298,7 +298,7 @@ def list(self, request, slug, project_id): ) .annotate( pending_issues=Count( - "label_id", + "id", filter=Q( completed_at__isnull=True, archived_at__isnull=True, @@ -420,13 +420,13 @@ def retrieve(self, request, slug, project_id, pk): ) .annotate( total_issues=Count( - "assignee_id", + "id", filter=Q(archived_at__isnull=True, is_draft=False), ), ) .annotate( completed_issues=Count( - "assignee_id", + "id", filter=Q( completed_at__isnull=False, archived_at__isnull=True, @@ -436,7 +436,7 @@ def retrieve(self, request, slug, project_id, pk): ) .annotate( pending_issues=Count( - "assignee_id", + "id", filter=Q( completed_at__isnull=True, archived_at__isnull=True, @@ -460,13 +460,13 @@ def retrieve(self, request, slug, project_id, pk): .values("label_name", "color", "label_id") .annotate( total_issues=Count( - "label_id", + "id", filter=Q(archived_at__isnull=True, is_draft=False), ), ) .annotate( completed_issues=Count( - "label_id", + "id", filter=Q( completed_at__isnull=False, archived_at__isnull=True, @@ -476,7 +476,7 @@ def retrieve(self, request, slug, project_id, pk): ) .annotate( pending_issues=Count( - "label_id", + "id", filter=Q( completed_at__isnull=True, archived_at__isnull=True, diff --git a/apiserver/plane/app/views/dashboard.py b/apiserver/plane/app/views/dashboard.py index 47fae2c9ca1..1366a2886a9 100644 --- a/apiserver/plane/app/views/dashboard.py +++ b/apiserver/plane/app/views/dashboard.py @@ -145,6 +145,23 @@ def dashboard_assigned_issues(self, request, slug): ) ).order_by("priority_order") + if issue_type == "pending": + pending_issues_count = assigned_issues.filter( + state__group__in=["backlog", "started", "unstarted"] + ).count() + pending_issues = assigned_issues.filter( + state__group__in=["backlog", "started", "unstarted"] + )[:5] + return Response( + { + "issues": IssueSerializer( + pending_issues, many=True, expand=self.expand + ).data, + "count": pending_issues_count, + }, + status=status.HTTP_200_OK, + ) + if issue_type == "completed": completed_issues_count = assigned_issues.filter( state__group__in=["completed"] @@ -257,6 +274,23 @@ def dashboard_created_issues(self, request, slug): ) ).order_by("priority_order") + if issue_type == "pending": + pending_issues_count = created_issues.filter( + state__group__in=["backlog", "started", "unstarted"] + ).count() + pending_issues = created_issues.filter( + state__group__in=["backlog", "started", "unstarted"] + )[:5] + return Response( + { + "issues": IssueSerializer( + pending_issues, many=True, expand=self.expand + ).data, + "count": pending_issues_count, + }, + status=status.HTTP_200_OK, + ) + if issue_type == "completed": completed_issues_count = created_issues.filter( state__group__in=["completed"] diff --git a/apiserver/plane/app/views/module.py b/apiserver/plane/app/views/module.py index 1f055129a90..4792a1f7996 100644 --- a/apiserver/plane/app/views/module.py +++ b/apiserver/plane/app/views/module.py @@ -197,7 +197,7 @@ def retrieve(self, request, slug, project_id, pk): ) .annotate( total_issues=Count( - "assignee_id", + "id", filter=Q( archived_at__isnull=True, is_draft=False, @@ -206,7 +206,7 @@ def retrieve(self, request, slug, project_id, pk): ) .annotate( completed_issues=Count( - "assignee_id", + "id", filter=Q( completed_at__isnull=False, archived_at__isnull=True, @@ -216,7 +216,7 @@ def retrieve(self, request, slug, project_id, pk): ) .annotate( pending_issues=Count( - "assignee_id", + "id", filter=Q( completed_at__isnull=True, archived_at__isnull=True, @@ -239,7 +239,7 @@ def retrieve(self, request, slug, project_id, pk): .values("label_name", "color", "label_id") .annotate( total_issues=Count( - "label_id", + "id", filter=Q( archived_at__isnull=True, is_draft=False, @@ -248,7 +248,7 @@ def retrieve(self, request, slug, project_id, pk): ) .annotate( completed_issues=Count( - "label_id", + "id", filter=Q( completed_at__isnull=False, archived_at__isnull=True, @@ -258,7 +258,7 @@ def retrieve(self, request, slug, project_id, pk): ) .annotate( pending_issues=Count( - "label_id", + "id", filter=Q( completed_at__isnull=True, archived_at__isnull=True, @@ -334,7 +334,7 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet): def get_queryset(self): return ( - Issue.objects.filter( + Issue.issue_objects.filter( project_id=self.kwargs.get("project_id"), workspace__slug=self.kwargs.get("slug"), issue_module__module_id=self.kwargs.get("module_id") diff --git a/apiserver/plane/bgtasks/apps.py b/apiserver/plane/bgtasks/apps.py index 7f6ca38f0c5..27c7900f6ae 100644 --- a/apiserver/plane/bgtasks/apps.py +++ b/apiserver/plane/bgtasks/apps.py @@ -3,3 +3,7 @@ class BgtasksConfig(AppConfig): name = "plane.bgtasks" + + def ready(self) -> None: + from plane.bgtasks.create_faker import create_fake_data + diff --git a/apiserver/plane/bgtasks/create_faker.py b/apiserver/plane/bgtasks/create_faker.py new file mode 100644 index 00000000000..81a5bcf0a22 --- /dev/null +++ b/apiserver/plane/bgtasks/create_faker.py @@ -0,0 +1,602 @@ +# Python imports +import random +from datetime import datetime + +# Django imports +from django.db.models import Max + +# Third party imports +from celery import shared_task +from faker import Faker + +# Module imports +from plane.db.models import ( + Workspace, + WorkspaceMember, + User, + Project, + ProjectMember, + State, + Label, + Cycle, + Module, + Issue, + IssueSequence, + IssueAssignee, + IssueLabel, + IssueActivity, + CycleIssue, + ModuleIssue, +) + + +def create_workspace_members(workspace, members): + members = User.objects.filter(email__in=members) + + _ = WorkspaceMember.objects.bulk_create( + [ + WorkspaceMember( + workspace=workspace, + member=member, + role=20, + ) + for member in members + ], + ignore_conflicts=True, + ) + return + + +def create_project(workspace, user_id): + fake = Faker() + name = fake.name() + project = Project.objects.create( + workspace=workspace, + name=name, + identifier=name[ + : random.randint(2, 12 if len(name) - 1 >= 12 else len(name) - 1) + ].upper(), + created_by_id=user_id, + ) + + # Add current member as project member + _ = ProjectMember.objects.create( + project=project, + member_id=user_id, + role=20, + ) + + return project + + +def create_project_members(workspace, project, members): + members = User.objects.filter(email__in=members) + + _ = ProjectMember.objects.bulk_create( + [ + ProjectMember( + project=project, + workspace=workspace, + member=member, + role=20, + sort_order=random.randint(0, 65535), + ) + for member in members + ], + ignore_conflicts=True, + ) + return + + +def create_states(workspace, project, user_id): + states = [ + { + "name": "Backlog", + "color": "#A3A3A3", + "sequence": 15000, + "group": "backlog", + "default": True, + }, + { + "name": "Todo", + "color": "#3A3A3A", + "sequence": 25000, + "group": "unstarted", + }, + { + "name": "In Progress", + "color": "#F59E0B", + "sequence": 35000, + "group": "started", + }, + { + "name": "Done", + "color": "#16A34A", + "sequence": 45000, + "group": "completed", + }, + { + "name": "Cancelled", + "color": "#EF4444", + "sequence": 55000, + "group": "cancelled", + }, + ] + + states = State.objects.bulk_create( + [ + State( + name=state["name"], + color=state["color"], + project=project, + sequence=state["sequence"], + workspace=workspace, + group=state["group"], + default=state.get("default", False), + created_by_id=user_id, + ) + for state in states + ] + ) + + return states + + +def create_labels(workspace, project, user_id): + fake = Faker() + Faker.seed(0) + + return Label.objects.bulk_create( + [ + Label( + name=fake.color_name(), + color=fake.hex_color(), + project=project, + workspace=workspace, + created_by_id=user_id, + sort_order=random.randint(0, 65535), + ) + for _ in range(0, 50) + ], + ignore_conflicts=True, + ) + + +def create_cycles(workspace, project, user_id, cycle_count): + fake = Faker() + Faker.seed(0) + + cycles = [] + used_date_ranges = set() # Track used date ranges + + while len(cycles) <= cycle_count: + # Generate a start date, allowing for None + start_date_option = [None, fake.date_this_year()] + start_date = start_date_option[random.randint(0, 1)] + + # Initialize end_date based on start_date + end_date = ( + None + if start_date is None + else fake.date_between_dates( + date_start=start_date, + date_end=datetime.now().date().replace(month=12, day=31), + ) + ) + + # Ensure end_date is strictly after start_date if start_date is not None + while start_date is not None and ( + end_date <= start_date + or (start_date, end_date) in used_date_ranges + ): + end_date = fake.date_this_year() + + # Add the unique date range to the set + ( + used_date_ranges.add((start_date, end_date)) + if (end_date is not None and start_date is not None) + else None + ) + + # Append the cycle with unique date range + cycles.append( + Cycle( + name=fake.name(), + owned_by_id=user_id, + sort_order=random.randint(0, 65535), + start_date=start_date, + end_date=end_date, + project=project, + workspace=workspace, + ) + ) + + return Cycle.objects.bulk_create(cycles, ignore_conflicts=True) + + +def create_modules(workspace, project, user_id, module_count): + fake = Faker() + Faker.seed(0) + + modules = [] + for _ in range(0, module_count): + start_date = [None, fake.date_this_year()][random.randint(0, 1)] + end_date = ( + None + if start_date is None + else fake.date_between_dates( + date_start=start_date, + date_end=datetime.now().date().replace(month=12, day=31), + ) + ) + + modules.append( + Module( + name=fake.name(), + sort_order=random.randint(0, 65535), + start_date=start_date, + target_date=end_date, + project=project, + workspace=workspace, + ) + ) + + return Module.objects.bulk_create(modules, ignore_conflicts=True) + + +def create_issues(workspace, project, user_id, issue_count): + fake = Faker() + Faker.seed(0) + + states = State.objects.values_list("id", flat=True) + creators = ProjectMember.objects.values_list("member_id", flat=True) + + issues = [] + + # Get the maximum sequence_id + last_id = IssueSequence.objects.filter( + project=project, + ).aggregate( + largest=Max("sequence") + )["largest"] + + last_id = 1 if last_id is None else last_id + 1 + + # Get the maximum sort order + largest_sort_order = Issue.objects.filter( + project=project, + state_id=states[random.randint(0, len(states) - 1)], + ).aggregate(largest=Max("sort_order"))["largest"] + + largest_sort_order = ( + 65535 if largest_sort_order is None else largest_sort_order + 10000 + ) + + for _ in range(0, issue_count): + start_date = [None, fake.date_this_year()][random.randint(0, 1)] + end_date = ( + None + if start_date is None + else fake.date_between_dates( + date_start=start_date, + date_end=datetime.now().date().replace(month=12, day=31), + ) + ) + + sentence = fake.sentence() + issues.append( + Issue( + state_id=states[random.randint(0, len(states) - 1)], + project=project, + workspace=workspace, + name=sentence[:254], + description_html=f"

{sentence}

", + description_stripped=sentence, + sequence_id=last_id, + sort_order=largest_sort_order, + start_date=start_date, + target_date=end_date, + priority=["urgent", "high", "medium", "low", "none"][ + random.randint(0, 4) + ], + created_by_id=creators[random.randint(0, len(creators) - 1)], + ) + ) + + largest_sort_order = largest_sort_order + random.randint(0, 1000) + last_id = last_id + 1 + + issues = Issue.objects.bulk_create( + issues, ignore_conflicts=True, batch_size=1000 + ) + # Sequences + _ = IssueSequence.objects.bulk_create( + [ + IssueSequence( + issue=issue, + sequence=issue.sequence_id, + project=project, + workspace=workspace, + ) + for issue in issues + ], + batch_size=100, + ) + + # Track the issue activities + IssueActivity.objects.bulk_create( + [ + IssueActivity( + issue=issue, + actor_id=user_id, + project=project, + workspace=workspace, + comment=f"created the issue", + verb="created", + created_by_id=user_id, + ) + for issue in issues + ], + batch_size=100, + ) + return + + +def create_issue_parent(workspace, project, user_id, issue_count): + + parent_count = issue_count / 4 + + parent_issues = Issue.objects.filter(project=project).values_list( + "id", flat=True + )[: int(parent_count)] + sub_issues = Issue.objects.filter(project=project).exclude( + pk__in=parent_issues + )[: int(issue_count / 2)] + + bulk_sub_issues = [] + for sub_issue in sub_issues: + sub_issue.parent_id = parent_issues[ + random.randint(0, int(parent_count - 1)) + ] + + Issue.objects.bulk_update(bulk_sub_issues, ["parent"], batch_size=1000) + + +def create_issue_assignees(workspace, project, user_id, issue_count): + # assignees + assignees = ProjectMember.objects.filter(project=project).values_list( + "member_id", flat=True + ) + issues = random.sample( + list( + Issue.objects.filter(project=project).values_list("id", flat=True) + ), + int(issue_count / 2), + ) + + # Bulk issue + bulk_issue_assignees = [] + for issue in issues: + for assignee in random.sample( + list(assignees), random.randint(0, len(assignees) - 1) + ): + bulk_issue_assignees.append( + IssueAssignee( + issue_id=issue, + assignee_id=assignee, + project=project, + workspace=workspace, + ) + ) + + # Issue assignees + IssueAssignee.objects.bulk_create( + bulk_issue_assignees, batch_size=1000, ignore_conflicts=True + ) + + +def create_issue_labels(workspace, project, user_id, issue_count): + # assignees + labels = Label.objects.filter(project=project).values_list("id", flat=True) + issues = random.sample( + list( + Issue.objects.filter(project=project).values_list("id", flat=True) + ), + int(issue_count / 2), + ) + + # Bulk issue + bulk_issue_labels = [] + for issue in issues: + for label in random.sample( + list(labels), random.randint(0, len(labels) - 1) + ): + bulk_issue_labels.append( + IssueLabel( + issue_id=issue, + label_id=label, + project=project, + workspace=workspace, + ) + ) + + # Issue assignees + IssueLabel.objects.bulk_create( + bulk_issue_labels, batch_size=1000, ignore_conflicts=True + ) + + +def create_cycle_issues(workspace, project, user_id, issue_count): + # assignees + cycles = Cycle.objects.filter(project=project).values_list("id", flat=True) + issues = random.sample( + list( + Issue.objects.filter(project=project).values_list("id", flat=True) + ), + int(issue_count / 2), + ) + + # Bulk issue + bulk_cycle_issues = [] + for issue in issues: + cycle = cycles[random.randint(0, len(cycles) - 1)] + bulk_cycle_issues.append( + CycleIssue( + cycle_id=cycle, + issue_id=issue, + project=project, + workspace=workspace, + ) + ) + + # Issue assignees + CycleIssue.objects.bulk_create( + bulk_cycle_issues, batch_size=1000, ignore_conflicts=True + ) + + +def create_module_issues(workspace, project, user_id, issue_count): + # assignees + modules = Module.objects.filter(project=project).values_list( + "id", flat=True + ) + issues = random.sample( + list( + Issue.objects.filter(project=project).values_list("id", flat=True) + ), + int(issue_count / 2), + ) + + # Bulk issue + bulk_module_issues = [] + for issue in issues: + module = modules[random.randint(0, len(modules) - 1)] + bulk_module_issues.append( + ModuleIssue( + module_id=module, + issue_id=issue, + project=project, + workspace=workspace, + ) + ) + # Issue assignees + ModuleIssue.objects.bulk_create( + bulk_module_issues, batch_size=1000, ignore_conflicts=True + ) + + +@shared_task +def create_fake_data( + slug, email, members, issue_count, cycle_count, module_count +): + workspace = Workspace.objects.get(slug=slug) + + user = User.objects.get(email=email) + user_id = user.id + + # create workspace members + print("creating workspace members") + create_workspace_members(workspace=workspace, members=members) + print("Done creating workspace members") + + # Create a project + print("Creating project") + project = create_project(workspace=workspace, user_id=user_id) + print("Done creating projects") + + # create project members + print("Creating project members") + create_project_members( + workspace=workspace, project=project, members=members + ) + print("Done creating project members") + + # Create states + print("Creating states") + states = create_states( + workspace=workspace, project=project, user_id=user_id + ) + print("Done creating states") + + # Create labels + print("Creating labels") + labels = create_labels( + workspace=workspace, project=project, user_id=user_id + ) + print("Done creating labels") + + # create cycles + print("Creating cycles") + cycles = create_cycles( + workspace=workspace, + project=project, + user_id=user_id, + cycle_count=cycle_count, + ) + print("Done creating cycles") + + # create modules + print("Creating modules") + modules = create_modules( + workspace=workspace, + project=project, + user_id=user_id, + module_count=module_count, + ) + print("Done creating modules") + + print("Creating issues") + create_issues( + workspace=workspace, + project=project, + user_id=user_id, + issue_count=issue_count, + ) + print("Done creating issues") + + print("Creating parent and sub issues") + create_issue_parent( + workspace=workspace, + project=project, + user_id=user_id, + issue_count=issue_count, + ) + print("Done creating parent and sub issues") + + print("Creating issue assignees") + create_issue_assignees( + workspace=workspace, + project=project, + user_id=user_id, + issue_count=issue_count, + ) + print("Done creating issue assignees") + + print("Creating issue labels") + create_issue_labels( + workspace=workspace, + project=project, + user_id=user_id, + issue_count=issue_count, + ) + print("Done creating issue labels") + + print("Creating cycle issues") + create_cycle_issues( + workspace=workspace, + project=project, + user_id=user_id, + issue_count=issue_count, + ) + print("Done creating cycle issues") + + print("Creating module issues") + create_module_issues( + workspace=workspace, + project=project, + user_id=user_id, + issue_count=issue_count, + ) + print("Done creating module issues") + + return diff --git a/apiserver/plane/bgtasks/email_notification_task.py b/apiserver/plane/bgtasks/email_notification_task.py index 713835033f6..9e9b348e197 100644 --- a/apiserver/plane/bgtasks/email_notification_task.py +++ b/apiserver/plane/bgtasks/email_notification_task.py @@ -1,5 +1,6 @@ -import json from datetime import datetime +from bs4 import BeautifulSoup + # Third party imports from celery import shared_task @@ -9,7 +10,6 @@ from django.core.mail import EmailMultiAlternatives, get_connection from django.template.loader import render_to_string from django.utils.html import strip_tags -from django.conf import settings # Module imports from plane.db.models import EmailNotificationLog, User, Issue @@ -40,7 +40,7 @@ def stack_email_notification(): processed_notifications = [] # Loop through all the issues to create the emails for receiver_id in receivers: - # Notifcation triggered for the receiver + # Notification triggered for the receiver receiver_notifications = [ notification for notification in email_notifications @@ -124,119 +124,153 @@ def create_payload(notification_data): return data +def process_mention(mention_component): + soup = BeautifulSoup(mention_component, 'html.parser') + mentions = soup.find_all('mention-component') + for mention in mentions: + user_id = mention['id'] + user = User.objects.get(pk=user_id) + user_name = user.display_name + highlighted_name = f"@{user_name}" + mention.replace_with(highlighted_name) + return str(soup) + +def process_html_content(content): + processed_content_list = [] + for html_content in content: + processed_content = process_mention(html_content) + processed_content_list.append(processed_content) + return processed_content_list @shared_task def send_email_notification( issue_id, notification_data, receiver_id, email_notification_ids ): - ri = redis_instance() - base_api = (ri.get(str(issue_id)).decode()) - data = create_payload(notification_data=notification_data) - - # Get email configurations - ( - EMAIL_HOST, - EMAIL_HOST_USER, - EMAIL_HOST_PASSWORD, - EMAIL_PORT, - EMAIL_USE_TLS, - EMAIL_FROM, - ) = get_email_configuration() - - receiver = User.objects.get(pk=receiver_id) - issue = Issue.objects.get(pk=issue_id) - template_data = [] - total_changes = 0 - comments = [] - actors_involved = [] - for actor_id, changes in data.items(): - actor = User.objects.get(pk=actor_id) - total_changes = total_changes + len(changes) - comment = changes.pop("comment", False) - actors_involved.append(actor_id) - if comment: - comments.append( - { - "actor_comments": comment, - "actor_detail": { - "avatar_url": actor.avatar, - "first_name": actor.first_name, - "last_name": actor.last_name, - }, - } + try: + ri = redis_instance() + base_api = (ri.get(str(issue_id)).decode()) + data = create_payload(notification_data=notification_data) + + # Get email configurations + ( + EMAIL_HOST, + EMAIL_HOST_USER, + EMAIL_HOST_PASSWORD, + EMAIL_PORT, + EMAIL_USE_TLS, + EMAIL_FROM, + ) = get_email_configuration() + + receiver = User.objects.get(pk=receiver_id) + issue = Issue.objects.get(pk=issue_id) + template_data = [] + total_changes = 0 + comments = [] + actors_involved = [] + for actor_id, changes in data.items(): + actor = User.objects.get(pk=actor_id) + total_changes = total_changes + len(changes) + comment = changes.pop("comment", False) + mention = changes.pop("mention", False) + actors_involved.append(actor_id) + if comment: + comments.append( + { + "actor_comments": comment, + "actor_detail": { + "avatar_url": actor.avatar, + "first_name": actor.first_name, + "last_name": actor.last_name, + }, + } + ) + if mention: + mention["new_value"] = process_html_content(mention.get("new_value")) + mention["old_value"] = process_html_content(mention.get("old_value")) + comments.append( + { + "actor_comments": mention, + "actor_detail": { + "avatar_url": actor.avatar, + "first_name": actor.first_name, + "last_name": actor.last_name, + }, + } + ) + activity_time = changes.pop("activity_time") + # Parse the input string into a datetime object + formatted_time = datetime.strptime(activity_time, "%Y-%m-%d %H:%M:%S").strftime("%H:%M %p") + + if changes: + template_data.append( + { + "actor_detail": { + "avatar_url": actor.avatar, + "first_name": actor.first_name, + "last_name": actor.last_name, + }, + "changes": changes, + "issue_details": { + "name": issue.name, + "identifier": f"{issue.project.identifier}-{issue.sequence_id}", + }, + "activity_time": str(formatted_time), + } ) - activity_time = changes.pop("activity_time") - # Parse the input string into a datetime object - formatted_time = datetime.strptime(activity_time, "%Y-%m-%d %H:%M:%S").strftime("%H:%M %p") - - if changes: - template_data.append( - { - "actor_detail": { - "avatar_url": actor.avatar, - "first_name": actor.first_name, - "last_name": actor.last_name, - }, - "changes": changes, - "issue_details": { - "name": issue.name, - "identifier": f"{issue.project.identifier}-{issue.sequence_id}", - }, - "activity_time": str(formatted_time), - } - ) - summary = "Updates were made to the issue by" - - # Send the mail - subject = f"{issue.project.identifier}-{issue.sequence_id} {issue.name}" - context = { - "data": template_data, - "summary": summary, - "actors_involved": len(set(actors_involved)), - "issue": { - "issue_identifier": f"{str(issue.project.identifier)}-{str(issue.sequence_id)}", - "name": issue.name, - "issue_url": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/{str(issue.id)}", - }, - "receiver": { - "email": receiver.email, - }, - "issue_url": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/{str(issue.id)}", - "project_url": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/", - "workspace":str(issue.project.workspace.slug), - "project": str(issue.project.name), - "user_preference": f"{base_api}/profile/preferences/email", - "comments": comments, - } - html_content = render_to_string( - "emails/notifications/issue-updates.html", context - ) - text_content = strip_tags(html_content) + summary = "Updates were made to the issue by" - try: - connection = get_connection( - host=EMAIL_HOST, - port=int(EMAIL_PORT), - username=EMAIL_HOST_USER, - password=EMAIL_HOST_PASSWORD, - use_tls=EMAIL_USE_TLS == "1", + # Send the mail + subject = f"{issue.project.identifier}-{issue.sequence_id} {issue.name}" + context = { + "data": template_data, + "summary": summary, + "actors_involved": len(set(actors_involved)), + "issue": { + "issue_identifier": f"{str(issue.project.identifier)}-{str(issue.sequence_id)}", + "name": issue.name, + "issue_url": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/{str(issue.id)}", + }, + "receiver": { + "email": receiver.email, + }, + "issue_url": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/{str(issue.id)}", + "project_url": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/", + "workspace":str(issue.project.workspace.slug), + "project": str(issue.project.name), + "user_preference": f"{base_api}/profile/preferences/email", + "comments": comments, + } + html_content = render_to_string( + "emails/notifications/issue-updates.html", context ) + text_content = strip_tags(html_content) - msg = EmailMultiAlternatives( - subject=subject, - body=text_content, - from_email=EMAIL_FROM, - to=[receiver.email], - connection=connection, - ) - msg.attach_alternative(html_content, "text/html") - msg.send() + try: + connection = get_connection( + host=EMAIL_HOST, + port=int(EMAIL_PORT), + username=EMAIL_HOST_USER, + password=EMAIL_HOST_PASSWORD, + use_tls=EMAIL_USE_TLS == "1", + ) - EmailNotificationLog.objects.filter( - pk__in=email_notification_ids - ).update(sent_at=timezone.now()) - return - except Exception as e: - print(e) + msg = EmailMultiAlternatives( + subject=subject, + body=text_content, + from_email=EMAIL_FROM, + to=[receiver.email], + connection=connection, + ) + msg.attach_alternative(html_content, "text/html") + msg.send() + + EmailNotificationLog.objects.filter( + pk__in=email_notification_ids + ).update(sent_at=timezone.now()) + return + except Exception as e: + print(e) + return + except Issue.DoesNotExist: return diff --git a/apiserver/plane/bgtasks/issue_activites_task.py b/apiserver/plane/bgtasks/issue_activites_task.py index b9f6bd41103..b86ab5e783e 100644 --- a/apiserver/plane/bgtasks/issue_activites_task.py +++ b/apiserver/plane/bgtasks/issue_activites_task.py @@ -353,13 +353,18 @@ def track_assignees( issue_activities, epoch, ): - requested_assignees = set( - [str(asg) for asg in requested_data.get("assignee_ids", [])] + requested_assignees = ( + set([str(asg) for asg in requested_data.get("assignee_ids", [])]) + if requested_data is not None + else set() ) - current_assignees = set( - [str(asg) for asg in current_instance.get("assignee_ids", [])] + current_assignees = ( + set([str(asg) for asg in current_instance.get("assignee_ids", [])]) + if current_instance is not None + else set() ) + added_assignees = requested_assignees - current_assignees dropped_assginees = current_assignees - requested_assignees @@ -547,6 +552,20 @@ def create_issue_activity( epoch=epoch, ) ) + requested_data = ( + json.loads(requested_data) if requested_data is not None else None + ) + if requested_data.get("assignee_ids") is not None: + track_assignees( + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, + ) def update_issue_activity( diff --git a/apiserver/plane/bgtasks/notification_task.py b/apiserver/plane/bgtasks/notification_task.py index 6cfbec72a96..0a843e4a63a 100644 --- a/apiserver/plane/bgtasks/notification_task.py +++ b/apiserver/plane/bgtasks/notification_task.py @@ -515,7 +515,7 @@ def notifications( bulk_email_logs.append( EmailNotificationLog( triggered_by_id=actor_id, - receiver_id=subscriber, + receiver_id=mention_id, entity_identifier=issue_id, entity_name="issue", data={ @@ -552,6 +552,7 @@ def notifications( "old_value": str( issue_activity.get("old_value") ), + "activity_time": issue_activity.get("created_at"), }, }, ) @@ -639,6 +640,7 @@ def notifications( "old_value": str( last_activity.old_value ), + "activity_time": issue_activity.get("created_at"), }, }, ) @@ -695,6 +697,7 @@ def notifications( "old_value" ) ), + "activity_time": issue_activity.get("created_at"), }, }, ) diff --git a/apiserver/plane/bgtasks/webhook_task.py b/apiserver/plane/bgtasks/webhook_task.py index 34bba0cf87a..605f48dd944 100644 --- a/apiserver/plane/bgtasks/webhook_task.py +++ b/apiserver/plane/bgtasks/webhook_task.py @@ -7,6 +7,9 @@ # Django imports from django.conf import settings from django.core.serializers.json import DjangoJSONEncoder +from django.core.mail import EmailMultiAlternatives, get_connection +from django.template.loader import render_to_string +from django.utils.html import strip_tags # Third party imports from celery import shared_task @@ -22,10 +25,10 @@ ModuleIssue, CycleIssue, IssueComment, + User, ) from plane.api.serializers import ( ProjectSerializer, - IssueSerializer, CycleSerializer, ModuleSerializer, CycleIssueSerializer, @@ -34,6 +37,9 @@ IssueExpandSerializer, ) +# Module imports +from plane.license.utils.instance_value import get_email_configuration + SERIALIZER_MAPPER = { "project": ProjectSerializer, "issue": IssueExpandSerializer, @@ -72,7 +78,7 @@ def get_model_data(event, event_id, many=False): max_retries=5, retry_jitter=True, ) -def webhook_task(self, webhook, slug, event, event_data, action): +def webhook_task(self, webhook, slug, event, event_data, action, current_site): try: webhook = Webhook.objects.get(id=webhook, workspace__slug=slug) @@ -151,7 +157,18 @@ def webhook_task(self, webhook, slug, event, event_data, action): response_body=str(e), retry_count=str(self.request.retries), ) - + # Retry logic + if self.request.retries >= self.max_retries: + Webhook.objects.filter(pk=webhook.id).update(is_active=False) + if webhook: + # send email for the deactivation of the webhook + send_webhook_deactivation_email( + webhook_id=webhook.id, + receiver_id=webhook.created_by_id, + reason=str(e), + current_site=current_site, + ) + return raise requests.RequestException() except Exception as e: @@ -162,7 +179,7 @@ def webhook_task(self, webhook, slug, event, event_data, action): @shared_task() -def send_webhook(event, payload, kw, action, slug, bulk): +def send_webhook(event, payload, kw, action, slug, bulk, current_site): try: webhooks = Webhook.objects.filter(workspace__slug=slug, is_active=True) @@ -216,6 +233,7 @@ def send_webhook(event, payload, kw, action, slug, bulk): event=event, event_data=data, action=action, + current_site=current_site, ) except Exception as e: @@ -223,3 +241,56 @@ def send_webhook(event, payload, kw, action, slug, bulk): print(e) capture_exception(e) return + + +@shared_task +def send_webhook_deactivation_email(webhook_id, receiver_id, current_site, reason): + # Get email configurations + ( + EMAIL_HOST, + EMAIL_HOST_USER, + EMAIL_HOST_PASSWORD, + EMAIL_PORT, + EMAIL_USE_TLS, + EMAIL_FROM, + ) = get_email_configuration() + + receiver = User.objects.get(pk=receiver_id) + webhook = Webhook.objects.get(pk=webhook_id) + subject="Webhook Deactivated" + message=f"Webhook {webhook.url} has been deactivated due to failed requests." + + # Send the mail + context = { + "email": receiver.email, + "message": message, + "webhook_url":f"{current_site}/{str(webhook.workspace.slug)}/settings/webhooks/{str(webhook.id)}", + } + html_content = render_to_string( + "emails/notifications/webhook-deactivate.html", context + ) + text_content = strip_tags(html_content) + + try: + connection = get_connection( + host=EMAIL_HOST, + port=int(EMAIL_PORT), + username=EMAIL_HOST_USER, + password=EMAIL_HOST_PASSWORD, + use_tls=EMAIL_USE_TLS == "1", + ) + + msg = EmailMultiAlternatives( + subject=subject, + body=text_content, + from_email=EMAIL_FROM, + to=[receiver.email], + connection=connection, + ) + msg.attach_alternative(html_content, "text/html") + msg.send() + + return + except Exception as e: + print(e) + return diff --git a/apiserver/plane/db/management/commands/faker.py b/apiserver/plane/db/management/commands/faker.py new file mode 100644 index 00000000000..fb66ada57ac --- /dev/null +++ b/apiserver/plane/db/management/commands/faker.py @@ -0,0 +1,79 @@ +# Django imports +from typing import Any +from django.core.management.base import BaseCommand, CommandError + +# Module imports +from plane.db.models import User, Workspace, WorkspaceMember + + +class Command(BaseCommand): + help = "Create dump issues, cycles etc. for a project in a given workspace" + + def handle(self, *args: Any, **options: Any) -> str | None: + + try: + workspace_name = input("Workspace Name: ") + workspace_slug = input("Workspace slug: ") + + if workspace_slug == "": + raise CommandError("Workspace slug is required") + + if Workspace.objects.filter(slug=workspace_slug).exists(): + raise CommandError("Workspace already exists") + + creator = input("Your email: ") + + if ( + creator == "" + or not User.objects.filter(email=creator).exists() + ): + raise CommandError( + "User email is required and should be existing in Database" + ) + + user = User.objects.get(email=creator) + + members = input("Enter Member emails (comma separated): ") + members = members.split(",") if members != "" else [] + + issue_count = int( + input("Number of issues to be created: ") + ) + cycle_count = int( + input("Number of cycles to be created: ") + ) + module_count = int( + input("Number of modules to be created: ") + ) + + # Create workspace + workspace = Workspace.objects.create( + slug=workspace_slug, + name=workspace_name, + owner=user, + ) + # Create workspace member + WorkspaceMember.objects.create( + workspace=workspace, role=20, member=user + ) + + from plane.bgtasks.create_faker import create_fake_data + + create_fake_data.delay( + slug=workspace_slug, + email=creator, + members=members, + issue_count=issue_count, + cycle_count=cycle_count, + module_count=module_count, + ) + + self.stdout.write( + self.style.SUCCESS("Data is pushed to the queue") + ) + return + except Exception as e: + self.stdout.write( + self.style.ERROR(f"Command errored out {str(e)}") + ) + return diff --git a/apiserver/plane/db/models/user.py b/apiserver/plane/db/models/user.py index a9f88564040..ca0e17d8da1 100644 --- a/apiserver/plane/db/models/user.py +++ b/apiserver/plane/db/models/user.py @@ -176,4 +176,9 @@ def create_user_notification(sender, instance, created, **kwargs): UserNotificationPreference.objects.create( user=instance, + property_change=False, + state_change=False, + comment=False, + mention=False, + issue_completed=False, ) diff --git a/apiserver/plane/settings/common.py b/apiserver/plane/settings/common.py index b233b19ca4b..7c9b9d8097e 100644 --- a/apiserver/plane/settings/common.py +++ b/apiserver/plane/settings/common.py @@ -282,10 +282,8 @@ redis_url = os.environ.get("REDIS_URL") broker_url = f"{redis_url}?ssl_cert_reqs={ssl.CERT_NONE.name}&ssl_ca_certs={certifi.where()}" CELERY_BROKER_URL = broker_url - CELERY_RESULT_BACKEND = broker_url else: CELERY_BROKER_URL = REDIS_URL - CELERY_RESULT_BACKEND = REDIS_URL CELERY_IMPORTS = ( "plane.bgtasks.issue_automation_task", diff --git a/apiserver/templates/emails/notifications/issue-updates.html b/apiserver/templates/emails/notifications/issue-updates.html index fa50631c557..3c561f37ac2 100644 --- a/apiserver/templates/emails/notifications/issue-updates.html +++ b/apiserver/templates/emails/notifications/issue-updates.html @@ -66,7 +66,7 @@ style="margin-left: 30px; margin-bottom: 20px; margin-top: 20px" > - {% if actors_involved == 1 %} -

- {{summary}} - - {{ data.0.actor_detail.first_name}} - {{data.0.actor_detail.last_name}} - . -

- {% else %} -

- {{summary}} - - {{ data.0.actor_detail.first_name}} - {{data.0.actor_detail.last_name }} - and others. -

- {% endif %} - - + {% if actors_involved == 1 %} +

+ {{summary}} + + {% if data|length > 0 %} + {{ data.0.actor_detail.first_name}} + {{data.0.actor_detail.last_name}} + {% else %} + {{ comments.0.actor_detail.first_name}} + {{comments.0.actor_detail.last_name}} + {% endif %} + . +

+ {% else %} +

+ {{summary}} + + {% if data|length > 0 %} + {{ data.0.actor_detail.first_name}} + {{data.0.actor_detail.last_name}} + {% else %} + {{ comments.0.actor_detail.first_name}} + {{comments.0.actor_detail.last_name}} + {% endif %} + and others. +

+ {% endif %} + + + + + + + + + + diff --git a/deploy/selfhost/install.sh b/deploy/selfhost/install.sh index 3f306c559f1..4e505cff9c6 100755 --- a/deploy/selfhost/install.sh +++ b/deploy/selfhost/install.sh @@ -49,7 +49,7 @@ function buildLocalImage() { cd $PLANE_TEMP_CODE_DIR if [ "$BRANCH" == "master" ]; then - APP_RELEASE=latest + export APP_RELEASE=latest fi docker compose -f build.yml build --no-cache >&2 @@ -205,6 +205,11 @@ else PULL_POLICY=never fi +if [ "$BRANCH" == "master" ]; +then + export APP_RELEASE=latest +fi + # REMOVE SPECIAL CHARACTERS FROM BRANCH NAME if [ "$BRANCH" != "master" ]; then diff --git a/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-widget/issue-widget-card.tsx b/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-widget/issue-widget-card.tsx index b1e5e64a748..d3305e76126 100644 --- a/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-widget/issue-widget-card.tsx +++ b/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-widget/issue-widget-card.tsx @@ -41,9 +41,13 @@ export const IssueWidgetCard = (props) => {
- {issueDetails.assignee_details.map((assignee) => ( - - ))} + {issueDetails.assignee_details.map((assignee, index) => { + if (assignee) { + return ( + + ); + } + })}
{issueDetails.target_date && ( diff --git a/packages/types/src/dashboard.d.ts b/packages/types/src/dashboard.d.ts index 31751c0d06c..7cfa6aa8563 100644 --- a/packages/types/src/dashboard.d.ts +++ b/packages/types/src/dashboard.d.ts @@ -13,9 +13,10 @@ export type TWidgetKeys = | "recent_projects" | "recent_collaborators"; -export type TIssuesListTypes = "upcoming" | "overdue" | "completed"; +export type TIssuesListTypes = "pending" | "upcoming" | "overdue" | "completed"; export type TDurationFilterOptions = + | "none" | "today" | "this_week" | "this_month" diff --git a/packages/ui/src/breadcrumbs/breadcrumbs.tsx b/packages/ui/src/breadcrumbs/breadcrumbs.tsx index e82944c03e5..0f09764acca 100644 --- a/packages/ui/src/breadcrumbs/breadcrumbs.tsx +++ b/packages/ui/src/breadcrumbs/breadcrumbs.tsx @@ -2,8 +2,6 @@ import * as React from "react"; // icons import { ChevronRight } from "lucide-react"; -// components -import { Tooltip } from "../tooltip"; type BreadcrumbsProps = { children: any; @@ -25,42 +23,11 @@ const Breadcrumbs = ({ children }: BreadcrumbsProps) => ( type Props = { type?: "text" | "component"; component?: React.ReactNode; - label?: string; - icon?: React.ReactNode; - link?: string; + link?: JSX.Element; }; const BreadcrumbItem: React.FC = (props) => { - const { type = "text", component, label, icon, link } = props; - return ( - <> - {type != "text" ? ( -
{component}
- ) : ( - -
  • -
    - {link ? ( - - {icon && ( -
    {icon}
    - )} -
    {label}
    -
    - ) : ( -
    - {icon &&
    {icon}
    } -
    {label}
    -
    - )} -
    -
  • -
    - )} - - ); + const { type = "text", component, link } = props; + return <>{type != "text" ?
    {component}
    : link}; }; Breadcrumbs.BreadcrumbItem = BreadcrumbItem; diff --git a/web/components/account/deactivate-account-modal.tsx b/web/components/account/deactivate-account-modal.tsx index 307a65ad2e3..701db6ad945 100644 --- a/web/components/account/deactivate-account-modal.tsx +++ b/web/components/account/deactivate-account-modal.tsx @@ -89,8 +89,8 @@ export const DeactivateAccountModal: React.FC = (props) => {
    -
    -