[WEB-1435] dev: conflict free issue descriptions (#5912)

* chore: new description binary endpoints

* chore: conflict free issue description

* chore: fix submitting status

* chore: update yjs utils

* chore: handle component re-mounting

* chore: update buffer response type

* chore: add try catch for issue description update

* chore: update buffer response type

* chore: description binary in retrieve

* chore: update issue description hook

* chore: decode description binary

* chore: migrations fixes and cleanup

* chore: migration fixes

* fix: inbox issue description

* chore: move update operations to the issue store

* fix: merge conflicts

* chore: reverted the commit

* chore: removed the unwanted imports

* chore: remove unnecessary props

* chore: remove unused services

* chore: update live server error handling

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
This commit is contained in:
Aaryan Khandelwal 2024-11-15 16:38:58 +05:30 committed by GitHub
parent 229610513a
commit e9680cab74
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
65 changed files with 1466 additions and 358 deletions

View file

@ -284,9 +284,11 @@ class DraftIssueSerializer(BaseSerializer):
class DraftIssueDetailSerializer(DraftIssueSerializer):
description_html = serializers.CharField()
description_binary = serializers.CharField()
class Meta(DraftIssueSerializer.Meta):
fields = DraftIssueSerializer.Meta.fields + [
"description_html",
"description_binary",
]
read_only_fields = fields

View file

@ -1,3 +1,6 @@
# Python imports
import base64
# Django imports
from django.utils import timezone
from django.core.validators import URLValidator
@ -732,14 +735,31 @@ class IssueLiteSerializer(DynamicBaseSerializer):
read_only_fields = fields
class Base64BinaryField(serializers.CharField):
def to_representation(self, value):
# Encode the binary data to base64 string for JSON response
if value:
return base64.b64encode(value).decode("utf-8")
return None
def to_internal_value(self, data):
# Decode the base64 string to binary data when saving
try:
return base64.b64decode(data)
except (TypeError, ValueError):
raise serializers.ValidationError("Invalid base64-encoded data")
class IssueDetailSerializer(IssueSerializer):
description_html = serializers.CharField()
description_binary = Base64BinaryField()
is_subscribed = serializers.BooleanField(read_only=True)
class Meta(IssueSerializer.Meta):
fields = IssueSerializer.Meta.fields + [
"description_html",
"is_subscribed",
"description_binary",
]
read_only_fields = fields

View file

@ -66,6 +66,7 @@ class WorkSpaceMemberSerializer(DynamicBaseSerializer):
class WorkspaceMemberMeSerializer(BaseSerializer):
draft_issue_count = serializers.IntegerField(read_only=True)
class Meta:
model = WorkspaceMember
fields = "__all__"

View file

@ -92,4 +92,14 @@ urlpatterns = [
),
name="inbox-issue",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/inbox-issues/<uuid:pk>/description/",
IntakeIssueViewSet.as_view(
{
"get": "retrieve_description",
"post": "update_description",
}
),
name="inbox-issue-description",
),
]

View file

@ -66,6 +66,16 @@ urlpatterns = [
),
name="project-issue",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:pk>/description/",
IssueViewSet.as_view(
{
"get": "retrieve_description",
"post": "update_description",
}
),
name="project-issue-description",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issue-labels/",
LabelViewSet.as_view(
@ -288,6 +298,15 @@ urlpatterns = [
),
name="project-issue-archive-unarchive",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/archived-issues/<uuid:pk>/description/",
IssueArchiveViewSet.as_view(
{
"get": "retrieve_description",
}
),
name="archive-issue-description",
),
## End Issue Archives
## Issue Relation
path(

View file

@ -276,6 +276,16 @@ urlpatterns = [
),
name="workspace-drafts-issues",
),
path(
"workspaces/<str:slug>/draft-issues/<uuid:pk>/description/",
WorkspaceDraftIssueViewSet.as_view(
{
"get": "retrieve_description",
"post": "update_description",
}
),
name="workspace-drafts-issues",
),
path(
"workspaces/<str:slug>/draft-to-issue/<uuid:draft_id>/",
WorkspaceDraftIssueViewSet.as_view({"post": "create_draft_to_issue"}),

View file

@ -1,5 +1,7 @@
# Python imports
import json
import requests
import base64
# Django import
from django.utils import timezone
@ -9,6 +11,9 @@ from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.db.models import Value, UUIDField
from django.db.models.functions import Coalesce
from django.http import StreamingHttpResponse
from django.conf import settings
# Third party imports
from rest_framework import status
@ -40,7 +45,6 @@ from plane.bgtasks.issue_activities_task import issue_activity
class IntakeViewSet(BaseViewSet):
serializer_class = IntakeSerializer
model = Intake
@ -89,7 +93,6 @@ class IntakeViewSet(BaseViewSet):
class IntakeIssueViewSet(BaseViewSet):
serializer_class = IntakeIssueSerializer
model = IntakeIssue
@ -640,3 +643,82 @@ class IntakeIssueViewSet(BaseViewSet):
intake_issue.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def retrieve_description(self, request, slug, project_id, pk):
issue = Issue.objects.filter(
pk=pk, workspace__slug=slug, project_id=project_id
).first()
if issue is None:
return Response(
{"error": "Issue not found"},
status=404,
)
binary_data = issue.description_binary
def stream_data():
if binary_data:
yield binary_data
else:
yield b""
response = StreamingHttpResponse(
stream_data(), content_type="application/octet-stream"
)
response["Content-Disposition"] = (
'attachment; filename="issue_description.bin"'
)
return response
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def update_description(self, request, slug, project_id, pk):
issue = Issue.objects.get(
workspace__slug=slug, project_id=project_id, pk=pk
)
base64_description = issue.description_binary
# convert to base64 string
if base64_description:
base64_description = base64.b64encode(base64_description).decode(
"utf-8"
)
data = {
"original_document": base64_description,
"updates": request.data.get("description_binary"),
}
base_url = f"{settings.LIVE_BASE_URL}/resolve-document-conflicts/"
try:
response = requests.post(base_url, json=data, headers=None)
except requests.RequestException:
return Response(
{"error": "Failed to connect to the external service"},
status=status.HTTP_502_BAD_GATEWAY,
)
if response.status_code == 200:
issue.description = response.json().get(
"description", issue.description
)
issue.description_html = response.json().get("description_html")
response_description_binary = response.json().get(
"description_binary"
)
issue.description_binary = base64.b64decode(
response_description_binary
)
issue.save()
def stream_data():
if issue.description_binary:
yield issue.description_binary
else:
yield b""
response = StreamingHttpResponse(
stream_data(), content_type="application/octet-stream"
)
response["Content-Disposition"] = (
'attachment; filename="issue_description.bin"'
)
return response
return Response(status=status.HTTP_500_INTERNAL_SERVER_ERROR)

View file

@ -7,6 +7,8 @@ from django.db.models import F, Func, OuterRef, Q, Prefetch, Exists, Subquery
from django.utils import timezone
from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page
from django.http import StreamingHttpResponse
# Third Party imports
from rest_framework import status
@ -27,7 +29,7 @@ from plane.db.models import (
IssueLink,
IssueSubscriber,
IssueReaction,
CycleIssue
CycleIssue,
)
from plane.utils.grouper import (
issue_group_values,
@ -327,6 +329,32 @@ class IssueArchiveViewSet(BaseViewSet):
return Response(status=status.HTTP_204_NO_CONTENT)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def retrieve_description(self, request, slug, project_id, pk):
issue = Issue.objects.filter(
pk=pk, workspace__slug=slug, project_id=project_id
).first()
if issue is None:
return Response(
{"error": "Issue not found"},
status=404,
)
binary_data = issue.description_binary
def stream_data():
if binary_data:
yield binary_data
else:
yield b""
response = StreamingHttpResponse(
stream_data(), content_type="application/octet-stream"
)
response["Content-Disposition"] = (
'attachment; filename="issue_description.bin"'
)
return response
class BulkArchiveIssuesEndpoint(BaseAPIView):
permission_classes = [

View file

@ -1,5 +1,7 @@
# Python imports
import json
import requests
import base64
# Django imports
from django.contrib.postgres.aggregates import ArrayAgg
@ -20,8 +22,10 @@ from django.db.models import (
)
from django.db.models.functions import Coalesce
from django.utils import timezone
from django.http import StreamingHttpResponse
from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page
from django.conf import settings
# Third Party imports
from rest_framework import status
@ -725,6 +729,84 @@ class IssueViewSet(BaseViewSet):
)
return Response(status=status.HTTP_204_NO_CONTENT)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def retrieve_description(self, request, slug, project_id, pk):
issue = Issue.issue_objects.filter(
pk=pk, workspace__slug=slug, project_id=project_id
).first()
if issue is None:
return Response(
{"error": "Issue not found"},
status=404,
)
binary_data = issue.description_binary
def stream_data():
if binary_data:
yield binary_data
else:
yield b""
response = StreamingHttpResponse(
stream_data(), content_type="application/octet-stream"
)
response["Content-Disposition"] = (
'attachment; filename="issue_description.bin"'
)
return response
def update_description(self, request, slug, project_id, pk):
issue = Issue.issue_objects.get(
workspace__slug=slug, project_id=project_id, pk=pk
)
base64_description = issue.description_binary
# convert to base64 string
if base64_description:
base64_description = base64.b64encode(base64_description).decode(
"utf-8"
)
data = {
"original_document": base64_description,
"updates": request.data.get("description_binary"),
}
base_url = f"{settings.LIVE_BASE_URL}/resolve-document-conflicts/"
try:
response = requests.post(base_url, json=data, headers=None)
except requests.RequestException:
return Response(
{"error": "Failed to connect to the external service"},
status=status.HTTP_502_BAD_GATEWAY,
)
if response.status_code == 200:
issue.description = response.json().get(
"description", issue.description
)
issue.description_html = response.json().get("description_html")
response_description_binary = response.json().get(
"description_binary"
)
issue.description_binary = base64.b64decode(
response_description_binary
)
issue.save()
def stream_data():
if issue.description_binary:
yield issue.description_binary
else:
yield b""
response = StreamingHttpResponse(
stream_data(), content_type="application/octet-stream"
)
response["Content-Disposition"] = (
'attachment; filename="issue_description.bin"'
)
return response
return Response(status=status.HTTP_500_INTERNAL_SERVER_ERROR)
class IssueUserDisplayPropertyEndpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])

View file

@ -1,5 +1,7 @@
# Python imports
import json
import requests
import base64
# Django imports
from django.utils import timezone
@ -7,6 +9,7 @@ from django.core import serializers
from django.core.serializers.json import DjangoJSONEncoder
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.http import StreamingHttpResponse
from django.db.models import (
Q,
UUIDField,
@ -17,6 +20,7 @@ from django.db.models import (
from django.db.models.functions import Coalesce
from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page
from django.conf import settings
# Third Party imports
from rest_framework import status
@ -350,3 +354,78 @@ class WorkspaceDraftIssueViewSet(BaseViewSet):
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def retrieve_description(self, request, slug, pk):
issue = DraftIssue.objects.filter(pk=pk, workspace__slug=slug).first()
if issue is None:
return Response(
{"error": "Issue not found"},
status=404,
)
binary_data = issue.description_binary
def stream_data():
if binary_data:
yield binary_data
else:
yield b""
response = StreamingHttpResponse(
stream_data(), content_type="application/octet-stream"
)
response["Content-Disposition"] = (
'attachment; filename="draft_issue_description.bin"'
)
return response
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def update_description(self, request, slug, pk):
issue = DraftIssue.objects.get(workspace__slug=slug, pk=pk)
base64_description = issue.description_binary
# convert to base64 string
if base64_description:
base64_description = base64.b64encode(base64_description).decode(
"utf-8"
)
data = {
"original_document": base64_description,
"updates": request.data.get("description_binary"),
}
base_url = f"{settings.LIVE_BASE_URL}/resolve-document-conflicts/"
try:
response = requests.post(base_url, json=data, headers=None)
except requests.RequestException:
return Response(
{"error": "Failed to connect to the external service"},
status=status.HTTP_502_BAD_GATEWAY,
)
if response.status_code == 200:
issue.description = response.json().get(
"description", issue.description
)
issue.description_html = response.json().get("description_html")
response_description_binary = response.json().get(
"description_binary"
)
issue.description_binary = base64.b64decode(
response_description_binary
)
issue.save()
def stream_data():
if issue.description_binary:
yield issue.description_binary
else:
yield b""
response = StreamingHttpResponse(
stream_data(), content_type="application/octet-stream"
)
response["Content-Disposition"] = (
'attachment; filename="issue_description.bin"'
)
return response
return Response(status=status.HTTP_500_INTERNAL_SERVER_ERROR)

View file

@ -1,9 +1,7 @@
# Generated by Django 4.2.15 on 2024-11-06 08:41
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import uuid
class Migration(migrations.Migration):

View file

@ -381,6 +381,7 @@ CSRF_FAILURE_VIEW = "plane.authentication.views.common.csrf_failure"
ADMIN_BASE_URL = os.environ.get("ADMIN_BASE_URL", None)
SPACE_BASE_URL = os.environ.get("SPACE_BASE_URL", None)
APP_BASE_URL = os.environ.get("APP_BASE_URL")
LIVE_BASE_URL = os.environ.get("LIVE_BASE_URL")
HARD_DELETE_AFTER_DAYS = int(os.environ.get("HARD_DELETE_AFTER_DAYS", 60))

View file

@ -3,7 +3,6 @@ from django.urls import path
from plane.space.views import (
IntakeIssuePublicViewSet,
IssueVotePublicViewSet,
WorkspaceProjectDeployBoardEndpoint,
)