diff --git a/apiserver/plane/api/serializers/__init__.py b/apiserver/plane/api/serializers/__init__.py index 2dc910caf..610b527ca 100644 --- a/apiserver/plane/api/serializers/__init__.py +++ b/apiserver/plane/api/serializers/__init__.py @@ -31,8 +31,6 @@ from .issue import ( IssueActivitySerializer, IssueCommentSerializer, IssuePropertySerializer, - BlockerIssueSerializer, - BlockedIssueSerializer, IssueAssigneeSerializer, LabelSerializer, IssueSerializer, @@ -45,6 +43,8 @@ from .issue import ( IssueReactionSerializer, CommentReactionSerializer, IssueVoteSerializer, + IssueRelationSerializer, + RelatedIssueSerializer, IssuePublicSerializer, ) diff --git a/apiserver/plane/api/serializers/issue.py b/apiserver/plane/api/serializers/issue.py index 938c7cab4..5888b759c 100644 --- a/apiserver/plane/api/serializers/issue.py +++ b/apiserver/plane/api/serializers/issue.py @@ -17,12 +17,10 @@ from plane.db.models import ( IssueActivity, IssueComment, IssueProperty, - IssueBlocker, IssueAssignee, IssueSubscriber, IssueLabel, Label, - IssueBlocker, CycleIssue, Cycle, Module, @@ -32,6 +30,7 @@ from plane.db.models import ( IssueReaction, CommentReaction, IssueVote, + IssueRelation, ) @@ -81,25 +80,12 @@ class IssueCreateSerializer(BaseSerializer): required=False, ) - # List of issues that are blocking this issue - blockers_list = serializers.ListField( - child=serializers.PrimaryKeyRelatedField(queryset=Issue.objects.all()), - write_only=True, - required=False, - ) labels_list = serializers.ListField( child=serializers.PrimaryKeyRelatedField(queryset=Label.objects.all()), write_only=True, required=False, ) - # List of issues that are blocked by this issue - blocks_list = serializers.ListField( - child=serializers.PrimaryKeyRelatedField(queryset=Issue.objects.all()), - write_only=True, - required=False, - ) - class Meta: model = Issue fields = "__all__" @@ -122,10 +108,8 @@ class IssueCreateSerializer(BaseSerializer): return data def create(self, validated_data): - blockers = validated_data.pop("blockers_list", None) assignees = validated_data.pop("assignees_list", None) labels = validated_data.pop("labels_list", None) - blocks = validated_data.pop("blocks_list", None) project_id = self.context["project_id"] workspace_id = self.context["workspace_id"] @@ -137,22 +121,6 @@ class IssueCreateSerializer(BaseSerializer): created_by_id = issue.created_by_id updated_by_id = issue.updated_by_id - if blockers is not None and len(blockers): - IssueBlocker.objects.bulk_create( - [ - IssueBlocker( - block=issue, - blocked_by=blocker, - project_id=project_id, - workspace_id=workspace_id, - created_by_id=created_by_id, - updated_by_id=updated_by_id, - ) - for blocker in blockers - ], - batch_size=10, - ) - if assignees is not None and len(assignees): IssueAssignee.objects.bulk_create( [ @@ -196,29 +164,11 @@ class IssueCreateSerializer(BaseSerializer): batch_size=10, ) - if blocks is not None and len(blocks): - IssueBlocker.objects.bulk_create( - [ - IssueBlocker( - block=block, - blocked_by=issue, - project_id=project_id, - workspace_id=workspace_id, - created_by_id=created_by_id, - updated_by_id=updated_by_id, - ) - for block in blocks - ], - batch_size=10, - ) - return issue def update(self, instance, validated_data): - blockers = validated_data.pop("blockers_list", None) assignees = validated_data.pop("assignees_list", None) labels = validated_data.pop("labels_list", None) - blocks = validated_data.pop("blocks_list", None) # Related models project_id = instance.project_id @@ -226,23 +176,6 @@ class IssueCreateSerializer(BaseSerializer): created_by_id = instance.created_by_id updated_by_id = instance.updated_by_id - if blockers is not None: - IssueBlocker.objects.filter(block=instance).delete() - IssueBlocker.objects.bulk_create( - [ - IssueBlocker( - block=instance, - blocked_by=blocker, - project_id=project_id, - workspace_id=workspace_id, - created_by_id=created_by_id, - updated_by_id=updated_by_id, - ) - for blocker in blockers - ], - batch_size=10, - ) - if assignees is not None: IssueAssignee.objects.filter(issue=instance).delete() IssueAssignee.objects.bulk_create( @@ -277,23 +210,6 @@ class IssueCreateSerializer(BaseSerializer): batch_size=10, ) - if blocks is not None: - IssueBlocker.objects.filter(blocked_by=instance).delete() - IssueBlocker.objects.bulk_create( - [ - IssueBlocker( - block=block, - blocked_by=instance, - project_id=project_id, - workspace_id=workspace_id, - created_by_id=created_by_id, - updated_by_id=updated_by_id, - ) - for block in blocks - ], - batch_size=10, - ) - # Time updation occues even when other related models are updated instance.updated_at = timezone.now() return super().update(instance, validated_data) @@ -375,32 +291,39 @@ class IssueLabelSerializer(BaseSerializer): ] -class BlockedIssueSerializer(BaseSerializer): - blocked_issue_detail = IssueProjectLiteSerializer(source="block", read_only=True) +class IssueRelationSerializer(BaseSerializer): + related_issue_detail = IssueProjectLiteSerializer(read_only=True, source="related_issue") class Meta: - model = IssueBlocker + model = IssueRelation fields = [ - "blocked_issue_detail", - "blocked_by", - "block", + "related_issue_detail", + "relation_type", + "related_issue", + "issue", + "id" + ] + read_only_fields = [ + "workspace", + "project", ] - read_only_fields = fields - -class BlockerIssueSerializer(BaseSerializer): - blocker_issue_detail = IssueProjectLiteSerializer( - source="blocked_by", read_only=True - ) +class RelatedIssueSerializer(BaseSerializer): + issue_detail = IssueProjectLiteSerializer(read_only=True, source="issue") class Meta: - model = IssueBlocker + model = IssueRelation fields = [ - "blocker_issue_detail", - "blocked_by", - "block", + "issue_detail", + "relation_type", + "related_issue", + "issue", + "id" + ] + read_only_fields = [ + "workspace", + "project", ] - read_only_fields = fields class IssueAssigneeSerializer(BaseSerializer): @@ -617,10 +540,8 @@ class IssueSerializer(BaseSerializer): parent_detail = IssueStateFlatSerializer(read_only=True, source="parent") label_details = LabelSerializer(read_only=True, source="labels", many=True) assignee_details = UserLiteSerializer(read_only=True, source="assignees", many=True) - # List of issues blocked by this issue - blocked_issues = BlockedIssueSerializer(read_only=True, many=True) - # List of issues that block this issue - blocker_issues = BlockerIssueSerializer(read_only=True, many=True) + related_issues = IssueRelationSerializer(read_only=True, source="issue_relation", many=True) + issue_relations = RelatedIssueSerializer(read_only=True, source="issue_related", many=True) issue_cycle = IssueCycleDetailSerializer(read_only=True) issue_module = IssueModuleDetailSerializer(read_only=True) issue_link = IssueLinkSerializer(read_only=True, many=True) diff --git a/apiserver/plane/api/urls.py b/apiserver/plane/api/urls.py index a992b3312..1d4a16eb6 100644 --- a/apiserver/plane/api/urls.py +++ b/apiserver/plane/api/urls.py @@ -90,6 +90,7 @@ from plane.api.views import ( IssueSubscriberViewSet, IssueCommentPublicViewSet, IssueReactionViewSet, + IssueRelationViewSet, CommentReactionViewSet, IssueDraftViewSet, ## End Issues @@ -1011,6 +1012,26 @@ urlpatterns = [ name="project-issue-archive", ), ## End Issue Archives + ## Issue Relation + path( + "workspaces//projects//issues//issue-relation/", + IssueRelationViewSet.as_view( + { + "post": "create", + } + ), + name="issue-relation", + ), + path( + "workspaces//projects//issues//issue-relation//", + IssueRelationViewSet.as_view( + { + "delete": "destroy", + } + ), + name="issue-relation", + ), + ## End Issue Relation ## Issue Drafts path( "workspaces//projects//issue-drafts/", diff --git a/apiserver/plane/api/views/__init__.py b/apiserver/plane/api/views/__init__.py index 67691d01e..265ed9c90 100644 --- a/apiserver/plane/api/views/__init__.py +++ b/apiserver/plane/api/views/__init__.py @@ -86,6 +86,7 @@ from .issue import ( IssueReactionPublicViewSet, CommentReactionPublicViewSet, IssueVotePublicViewSet, + IssueRelationViewSet, IssueRetrievePublicEndpoint, ProjectIssuesPublicEndpoint, IssueDraftViewSet, @@ -168,6 +169,4 @@ from .analytic import ( from .notification import NotificationViewSet, UnreadNotificationEndpoint, MarkAllReadNotificationViewSet -from .exporter import ( - ExportIssuesEndpoint, -) \ No newline at end of file +from .exporter import ExportIssuesEndpoint \ No newline at end of file diff --git a/apiserver/plane/api/views/issue.py b/apiserver/plane/api/views/issue.py index 6f82a368e..2e5551f41 100644 --- a/apiserver/plane/api/views/issue.py +++ b/apiserver/plane/api/views/issue.py @@ -24,6 +24,7 @@ from django.utils.decorators import method_decorator from django.views.decorators.gzip import gzip_page from django.db import IntegrityError from django.conf import settings +from django.db import IntegrityError # Third Party imports from rest_framework.response import Response @@ -51,6 +52,7 @@ from plane.api.serializers import ( IssueReactionSerializer, CommentReactionSerializer, IssueVoteSerializer, + IssueRelationSerializer, IssuePublicSerializer, ) from plane.api.permissions import ( @@ -76,6 +78,7 @@ from plane.db.models import ( CommentReaction, ProjectDeployBoard, IssueVote, + IssueRelation, ProjectPublicMember, ) from plane.bgtasks.issue_activites_task import issue_activity @@ -2040,6 +2043,98 @@ class IssueVotePublicViewSet(BaseViewSet): ) +class IssueRelationViewSet(BaseViewSet): + serializer_class = IssueRelationSerializer + model = IssueRelation + permission_classes = [ + ProjectEntityPermission, + ] + + def perform_destroy(self, instance): + current_instance = ( + self.get_queryset().filter(pk=self.kwargs.get("pk", None)).first() + ) + if current_instance is not None: + issue_activity.delay( + type="issue_relation.activity.deleted", + requested_data=json.dumps({"related_list": None}), + actor_id=str(self.request.user.id), + issue_id=str(self.kwargs.get("issue_id", None)), + project_id=str(self.kwargs.get("project_id", None)), + current_instance=json.dumps( + IssueRelationSerializer(current_instance).data, + cls=DjangoJSONEncoder, + ), + ) + return super().perform_destroy(instance) + + def create(self, request, slug, project_id, issue_id): + try: + related_list = request.data.get("related_list", []) + project = Project.objects.get(pk=project_id) + + issueRelation = IssueRelation.objects.bulk_create( + [ + IssueRelation( + issue_id=related_issue["issue"], + related_issue_id=related_issue["related_issue"], + relation_type=related_issue["relation_type"], + project_id=project_id, + workspace_id=project.workspace_id, + created_by=request.user, + updated_by=request.user, + ) + for related_issue in related_list + ], + batch_size=10, + ignore_conflicts=True, + ) + + issue_activity.delay( + type="issue_relation.activity.created", + requested_data=json.dumps(request.data, cls=DjangoJSONEncoder), + actor_id=str(request.user.id), + issue_id=str(issue_id), + project_id=str(project_id), + current_instance=None, + ) + + return Response( + IssueRelationSerializer(issueRelation, many=True).data, + status=status.HTTP_201_CREATED, + ) + except IntegrityError as e: + if "already exists" in str(e): + return Response( + {"name": "The issue is already taken"}, + status=status.HTTP_410_GONE, + ) + else: + capture_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_400_BAD_REQUEST, + ) + except Exception as e: + capture_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + 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")) + .filter(issue_id=self.kwargs.get("issue_id")) + .filter(project__project_projectmember__member=self.request.user) + .select_related("project") + .select_related("workspace") + .select_related("issue") + .distinct() + ) class IssueRetrievePublicEndpoint(BaseAPIView): permission_classes = [ AllowAny, diff --git a/apiserver/plane/api/views/search.py b/apiserver/plane/api/views/search.py index 0a8c5c530..35b75ce67 100644 --- a/apiserver/plane/api/views/search.py +++ b/apiserver/plane/api/views/search.py @@ -220,7 +220,7 @@ class IssueSearchEndpoint(BaseAPIView): query = request.query_params.get("search", False) workspace_search = request.query_params.get("workspace_search", "false") parent = request.query_params.get("parent", "false") - blocker_blocked_by = request.query_params.get("blocker_blocked_by", "false") + issue_relation = request.query_params.get("issue_relation", "false") cycle = request.query_params.get("cycle", "false") module = request.query_params.get("module", "false") sub_issue = request.query_params.get("sub_issue", "false") @@ -247,12 +247,12 @@ class IssueSearchEndpoint(BaseAPIView): "parent_id", flat=True ) ) - if blocker_blocked_by == "true" and issue_id: + if issue_relation == "true" and issue_id: issue = Issue.issue_objects.get(pk=issue_id) issues = issues.filter( ~Q(pk=issue_id), - ~Q(blocked_issues__block=issue), - ~Q(blocker_issues__blocked_by=issue), + ~Q(issue_related__issue=issue), + ~Q(issue_relation__related_issue=issue), ) if sub_issue == "true" and issue_id: issue = Issue.issue_objects.get(pk=issue_id) diff --git a/apiserver/plane/bgtasks/issue_activites_task.py b/apiserver/plane/bgtasks/issue_activites_task.py index 0cadac553..2d13afc35 100644 --- a/apiserver/plane/bgtasks/issue_activites_task.py +++ b/apiserver/plane/bgtasks/issue_activites_task.py @@ -393,130 +393,6 @@ def track_assignees( ) -# Track changes in blocking issues -def track_blocks( - requested_data, - current_instance, - issue_id, - project, - actor, - issue_activities, -): - if len(requested_data.get("blocks_list")) > len( - current_instance.get("blocked_issues") - ): - for block in requested_data.get("blocks_list"): - if ( - len( - [ - blocked - for blocked in current_instance.get("blocked_issues") - if blocked.get("block") == block - ] - ) - == 0 - ): - issue = Issue.objects.get(pk=block) - issue_activities.append( - IssueActivity( - issue_id=issue_id, - actor=actor, - verb="updated", - old_value="", - new_value=f"{issue.project.identifier}-{issue.sequence_id}", - field="blocks", - project=project, - workspace=project.workspace, - comment=f"added blocking issue {project.identifier}-{issue.sequence_id}", - new_identifier=issue.id, - ) - ) - - # Blocked Issue Removal - if len(requested_data.get("blocks_list")) < len( - current_instance.get("blocked_issues") - ): - for blocked in current_instance.get("blocked_issues"): - if blocked.get("block") not in requested_data.get("blocks_list"): - issue = Issue.objects.get(pk=blocked.get("block")) - issue_activities.append( - IssueActivity( - issue_id=issue_id, - actor=actor, - verb="updated", - old_value=f"{issue.project.identifier}-{issue.sequence_id}", - new_value="", - field="blocks", - project=project, - workspace=project.workspace, - comment=f"removed blocking issue {project.identifier}-{issue.sequence_id}", - old_identifier=issue.id, - ) - ) - - -# Track changes in blocked_by issues -def track_blockings( - requested_data, - current_instance, - issue_id, - project, - actor, - issue_activities, -): - if len(requested_data.get("blockers_list")) > len( - current_instance.get("blocker_issues") - ): - for block in requested_data.get("blockers_list"): - if ( - len( - [ - blocked - for blocked in current_instance.get("blocker_issues") - if blocked.get("blocked_by") == block - ] - ) - == 0 - ): - issue = Issue.objects.get(pk=block) - issue_activities.append( - IssueActivity( - issue_id=issue_id, - actor=actor, - verb="updated", - old_value="", - new_value=f"{issue.project.identifier}-{issue.sequence_id}", - field="blocking", - project=project, - workspace=project.workspace, - comment=f"added blocked by issue {project.identifier}-{issue.sequence_id}", - new_identifier=issue.id, - ) - ) - - # Blocked Issue Removal - if len(requested_data.get("blockers_list")) < len( - current_instance.get("blocker_issues") - ): - for blocked in current_instance.get("blocker_issues"): - if blocked.get("blocked_by") not in requested_data.get("blockers_list"): - issue = Issue.objects.get(pk=blocked.get("blocked_by")) - issue_activities.append( - IssueActivity( - issue_id=issue_id, - actor=actor, - verb="updated", - old_value=f"{issue.project.identifier}-{issue.sequence_id}", - new_value="", - field="blocking", - project=project, - workspace=project.workspace, - comment=f"removed blocked by issue {project.identifier}-{issue.sequence_id}", - old_identifier=issue.id, - ) - ) - - def create_issue_activity( requested_data, current_instance, issue_id, project, actor, issue_activities ): @@ -637,8 +513,6 @@ def update_issue_activity( "start_date": track_start_date, "labels_list": track_labels, "assignees_list": track_assignees, - "blocks_list": track_blocks, - "blockers_list": track_blockings, "estimate_point": track_estimate_points, "archived_at": track_archive_at, "closed_to": track_closed_to, @@ -1170,6 +1044,57 @@ def delete_issue_vote_activity( ) +def create_issue_relation_activity( + requested_data, current_instance, issue_id, project, actor, issue_activities +): + 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 current_instance is None and requested_data.get("related_list") is not None: + for issue_relation in requested_data.get("related_list"): + issue = Issue.objects.get(pk=issue_relation.get("related_issue")) + issue_activities.append( + IssueActivity( + issue_id=issue_relation.get("issue"), + actor=actor, + verb="created", + old_value="", + new_value=f"{project.identifier}-{issue.sequence_id}", + field=f'{issue_relation.get("relation_type")}', + project=project, + workspace=project.workspace, + comment=f'added {issue_relation.get("relation_type")} relation', + old_identifier=issue_relation.get("issue"), + ) + ) + + +def delete_issue_relation_activity( + requested_data, current_instance, issue_id, project, actor, issue_activities +): + 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 current_instance is not None and requested_data.get("related_list") is None: + issue = Issue.objects.get(pk=current_instance.get("issue")) + issue_activities.append( + IssueActivity( + issue_id=current_instance.get("issue"), + actor=actor, + verb="deleted", + old_value=f"{project.identifier}-{issue.sequence_id}", + new_value="", + field=f'{current_instance.get("relation_type")}', + project=project, + workspace=project.workspace, + comment=f'deleted the {current_instance.get("relation_type")} relation', + old_identifier=current_instance.get("issue"), + ) + ) + + # Receive message from room group @shared_task def issue_activity( @@ -1233,6 +1158,8 @@ def issue_activity( "link.activity.deleted": delete_link_activity, "attachment.activity.created": create_attachment_activity, "attachment.activity.deleted": delete_attachment_activity, + "issue_relation.activity.created": create_issue_relation_activity, + "issue_relation.activity.deleted": delete_issue_relation_activity, "issue_reaction.activity.created": create_issue_reaction_activity, "issue_reaction.activity.deleted": delete_issue_reaction_activity, "comment_reaction.activity.created": create_comment_reaction_activity, diff --git a/apiserver/plane/db/migrations/0043_alter_analyticview_created_by_and_more.py b/apiserver/plane/db/migrations/0043_alter_analyticview_created_by_and_more.py new file mode 100644 index 000000000..6991f4d99 --- /dev/null +++ b/apiserver/plane/db/migrations/0043_alter_analyticview_created_by_and_more.py @@ -0,0 +1,63 @@ +# Generated by Django 4.2.3 on 2023-09-12 07:29 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +from plane.db.models import IssueRelation +from sentry_sdk import capture_exception +import uuid + + +def create_issue_relation(apps, schema_editor): + try: + IssueBlockerModel = apps.get_model("db", "IssueBlocker") + updated_issue_relation = [] + for blocked_issue in IssueBlockerModel.objects.all(): + updated_issue_relation.append( + IssueRelation( + issue_id=blocked_issue.block_id, + related_issue_id=blocked_issue.blocked_by_id, + relation_type="blocked_by", + project_id=blocked_issue.project_id, + workspace_id=blocked_issue.workspace_id, + created_by_id=blocked_issue.created_by_id, + updated_by_id=blocked_issue.updated_by_id, + ) + ) + IssueRelation.objects.bulk_create(updated_issue_relation, batch_size=100) + except Exception as e: + print(e) + capture_exception(e) + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0042_alter_analyticview_created_by_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='IssueRelation', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), + ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ('relation_type', models.CharField(choices=[('duplicate', 'Duplicate'), ('relates_to', 'Relates To'), ('blocked_by', 'Blocked By')], default='blocked_by', max_length=20, verbose_name='Issue Relation Type')), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('issue', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_relation', to='db.issue')), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project')), + ('related_issue', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_related', to='db.issue')), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace')), + ], + options={ + 'verbose_name': 'Issue Relation', + 'verbose_name_plural': 'Issue Relations', + 'db_table': 'issue_relations', + 'ordering': ('-created_at',), + 'unique_together': {('issue', 'related_issue')}, + }, + ), + migrations.RunPython(create_issue_relation), + ] diff --git a/apiserver/plane/db/models/__init__.py b/apiserver/plane/db/models/__init__.py index 90532dc64..f60f7ac81 100644 --- a/apiserver/plane/db/models/__init__.py +++ b/apiserver/plane/db/models/__init__.py @@ -32,6 +32,7 @@ from .issue import ( IssueAssignee, Label, IssueBlocker, + IssueRelation, IssueLink, IssueSequence, IssueAttachment, diff --git a/apiserver/plane/db/models/issue.py b/apiserver/plane/db/models/issue.py index e0cc01aab..65f1bc965 100644 --- a/apiserver/plane/db/models/issue.py +++ b/apiserver/plane/db/models/issue.py @@ -180,6 +180,37 @@ class IssueBlocker(ProjectBaseModel): return f"{self.block.name} {self.blocked_by.name}" +class IssueRelation(ProjectBaseModel): + RELATION_CHOICES = ( + ("duplicate", "Duplicate"), + ("relates_to", "Relates To"), + ("blocked_by", "Blocked By"), + ) + + issue = models.ForeignKey( + Issue, related_name="issue_relation", on_delete=models.CASCADE + ) + related_issue = models.ForeignKey( + Issue, related_name="issue_related", on_delete=models.CASCADE + ) + relation_type = models.CharField( + max_length=20, + choices=RELATION_CHOICES, + verbose_name="Issue Relation Type", + default="blocked_by", + ) + + class Meta: + unique_together = ["issue", "related_issue"] + verbose_name = "Issue Relation" + verbose_name_plural = "Issue Relations" + db_table = "issue_relations" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.issue.name} {self.related_issue.name}" + + class IssueAssignee(ProjectBaseModel): issue = models.ForeignKey( Issue, on_delete=models.CASCADE, related_name="issue_assignee"