Compare commits

...

13 Commits

Author SHA1 Message Date
pablohashescobar
19b04d3073 dev: get issue_types 2024-07-09 17:35:08 +05:30
pablohashescobar
cafa80e08e dev: add start date and target date to project 2024-07-09 13:29:51 +05:30
pablohashescobar
013cba8e29 Merge branch 'preview' of github.com:makeplane/plane into feat-issue-type-page-versioning 2024-07-09 13:18:05 +05:30
pablohashescobar
efcd96ee8c Merge branch 'preview' of github.com:makeplane/plane into feat-issue-type-page-versioning 2024-07-08 13:00:50 +05:30
pablohashescobar
4609adf6cc dev: add is_default value in issue type 2024-07-05 16:38:06 +05:30
pablohashescobar
5f1cc70938 Merge branch 'preview' of github.com:makeplane/plane into feat-issue-type-page-versioning 2024-07-05 11:26:16 +05:30
pablohashescobar
67cc0365bc Merge branch 'preview' of github.com:makeplane/plane into feat-issue-type-page-versioning 2024-07-02 20:15:17 +05:30
pablohashescobar
01ec930a25 dev: create page version endpoints 2024-07-01 20:03:09 +05:30
pablohashescobar
9f59aea470 dev: add page versioning migrations 2024-07-01 14:50:15 +05:30
pablohashescobar
c097cb9581 dev: create page version 2024-07-01 13:49:17 +05:30
pablohashescobar
8824d21405 dev: fix migration for issue types 2024-07-01 11:46:46 +05:30
pablohashescobar
b6b282bb2a dev: fix save 2024-06-30 20:08:48 +05:30
pablohashescobar
791ddfcf13 dev: create issue types and add back migration for existing issues 2024-06-30 19:56:20 +05:30
21 changed files with 557 additions and 15 deletions

View File

@@ -11,6 +11,7 @@ from rest_framework import serializers
# Module imports
from plane.db.models import (
Issue,
IssueType,
IssueActivity,
IssueAssignee,
IssueAttachment,
@@ -131,7 +132,12 @@ class IssueSerializer(BaseSerializer):
workspace_id = self.context["workspace_id"]
default_assignee_id = self.context["default_assignee_id"]
issue = Issue.objects.create(**validated_data, project_id=project_id)
# Get the issue type from the project
issue_type = IssueType.objects.filter(project_id=project_id).first()
issue = Issue.objects.create(
**validated_data, project_id=project_id, type=issue_type
)
# Issue Audit Users
created_by_id = issue.created_by_id
@@ -312,10 +318,14 @@ class IssueLinkSerializer(BaseSerializer):
return IssueLink.objects.create(**validated_data)
def update(self, instance, validated_data):
if IssueLink.objects.filter(
url=validated_data.get("url"),
issue_id=instance.issue_id,
).exclude(pk=instance.id).exists():
if (
IssueLink.objects.filter(
url=validated_data.get("url"),
issue_id=instance.issue_id,
)
.exclude(pk=instance.id)
.exists()
):
raise serializers.ValidationError(
{"error": "URL already exists for this Issue"}
)

View File

@@ -16,6 +16,7 @@ from plane.app.permissions import ProjectLitePermission
from plane.bgtasks.issue_activites_task import issue_activity
from plane.db.models import (
Inbox,
IssueType,
InboxIssue,
Issue,
Project,
@@ -141,6 +142,8 @@ class InboxIssueAPIEndpoint(BaseAPIView):
color="#ff7700",
is_triage=True,
)
# Get the issue type
issue_type = IssueType.objects.filter(project_id=project_id).first()
# create an issue
issue = Issue.objects.create(
@@ -152,6 +155,7 @@ class InboxIssueAPIEndpoint(BaseAPIView):
priority=request.data.get("issue", {}).get("priority", "none"),
project_id=project_id,
state=state,
type=issue_type,
)
# create an inbox issue

View File

@@ -26,6 +26,7 @@ from plane.db.models import (
ProjectMember,
State,
Workspace,
IssueType,
)
from plane.bgtasks.webhook_task import model_activity
from .base import BaseAPIView
@@ -240,6 +241,14 @@ class ProjectAPIEndpoint(BaseAPIView):
.filter(pk=serializer.data["id"])
.first()
)
# Create the Issue Types
IssueType.objects.create(
name="Task",
description="A task that needs to be done",
project_id=project.id,
)
# Model activity
model_activity.delay(
model_name="project",

View File

@@ -91,6 +91,7 @@ from .page import (
PageLogSerializer,
SubPageSerializer,
PageDetailSerializer,
PageVersionSerializer,
)
from .estimate import (

View File

@@ -33,6 +33,7 @@ from plane.db.models import (
IssueVote,
IssueRelation,
State,
IssueType,
)
@@ -135,7 +136,12 @@ class IssueCreateSerializer(BaseSerializer):
workspace_id = self.context["workspace_id"]
default_assignee_id = self.context["default_assignee_id"]
issue = Issue.objects.create(**validated_data, project_id=project_id)
# Get Issue Type
issue_type = IssueType.objects.filter(project_id=project_id).first()
# Create Issue
issue = Issue.objects.create(
**validated_data, project_id=project_id, type=issue_type
)
# Issue Audit Users
created_by_id = issue.created_by_id

View File

@@ -10,6 +10,7 @@ from plane.db.models import (
Label,
ProjectPage,
Project,
PageVersion,
)
@@ -161,3 +162,13 @@ class PageLogSerializer(BaseSerializer):
"workspace",
"page",
]
class PageVersionSerializer(BaseSerializer):
class Meta:
model = PageVersion
fields = "__all__"
read_only_fields = [
"workspace",
"page",
]

View File

@@ -7,6 +7,7 @@ from plane.app.views import (
PageLogEndpoint,
SubPagesEndpoint,
PagesDescriptionViewSet,
PageVersionEndpoint,
)
@@ -90,4 +91,14 @@ urlpatterns = [
),
name="page-description",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/versions/",
PageVersionEndpoint.as_view(),
name="page-versions",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/versions/<uuid:pk>/",
PageVersionEndpoint.as_view(),
name="page-versions",
),
]

View File

@@ -179,6 +179,7 @@ from .page.base import (
SubPagesEndpoint,
PagesDescriptionViewSet,
)
from .page.version import PageVersionEndpoint
from .search.base import GlobalSearchEndpoint
from .search.issue import IssueSearchEndpoint

View File

@@ -38,6 +38,7 @@ from plane.db.models import (
from ..base import BaseAPIView, BaseViewSet
from plane.bgtasks.page_transaction_task import page_transaction
from plane.bgtasks.page_version_task import page_version
def unarchive_archive_page_and_descendants(page_id, archived_at):
@@ -481,16 +482,38 @@ class PagesDescriptionViewSet(BaseViewSet):
status=472,
)
# Serialize the existing instance
existing_instance = json.dumps(
{
"description_html": page.description_html,
},
cls=DjangoJSONEncoder,
)
# Get the base64 data from the request
base64_data = request.data.get("description_binary")
# If base64 data is provided
if base64_data:
# Decode the base64 data to bytes
new_binary_data = base64.b64decode(base64_data)
# capture the page transaction
if request.data.get("description_html"):
page_transaction.delay(
new_value=request.data,
old_value=existing_instance,
page_id=pk,
)
# Store the updated binary data
page.description_binary = new_binary_data
page.description_html = request.data.get("description_html")
page.save()
# Return a success response
page_version.delay(
page_id=page.id,
existing_instance=existing_instance,
user_id=request.user.id,
)
return Response({"message": "Updated successfully"})
else:
return Response({"error": "No binary data provided"})

View File

@@ -0,0 +1,37 @@
# Third party imports
from rest_framework import status
from rest_framework.response import Response
# Module imports
from plane.db.models import PageVersion
from ..base import BaseAPIView
from plane.app.permissions import ProjectEntityPermission
from plane.app.serializers import PageVersionSerializer
class PageVersionEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
def get(self, request, slug, project_id, page_id, pk=None):
# Check if pk is provided
if pk:
# Return a single page version
page_version = PageVersion.objects.get(
workspace__slug=slug,
page_id=page_id,
pk=pk,
)
# Serialize the page version
serializer = PageVersionSerializer(page_version)
return Response(serializer.data, status=status.HTTP_200_OK)
# Return all page versions
page_versions = PageVersion.objects.filter(
workspace__slug=slug,
page_id=page_id,
)
# Serialize the page versions
serializer = PageVersionSerializer(page_versions, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)

View File

@@ -47,6 +47,7 @@ from plane.db.models import (
ProjectMember,
State,
Workspace,
IssueType,
)
from plane.utils.cache import cache_response
from plane.bgtasks.webhook_task import model_activity
@@ -342,6 +343,13 @@ class ProjectViewSet(BaseViewSet):
.first()
)
# Create the issue type
IssueType.objects.create(
name="Task",
description="A task that needs to be done",
project_id=project.id,
)
model_activity.delay(
model_name="project",
model_id=str(project.id),

View File

@@ -1,5 +1,4 @@
# Python imports
import re
# Django imports
from django.db.models import Q
@@ -11,13 +10,7 @@ from rest_framework.response import Response
# Module imports
from .base import BaseAPIView
from plane.db.models import (
Workspace,
Project,
Issue,
Cycle,
Module,
Page,
IssueView,
)
from plane.utils.issue_search import search_issues

View File

@@ -21,6 +21,7 @@ from plane.db.models import (
Cycle,
Module,
Issue,
IssueType,
IssueSequence,
IssueAssignee,
IssueLabel,
@@ -336,6 +337,12 @@ def create_issues(workspace, project, user_id, issue_count):
65535 if largest_sort_order is None else largest_sort_order + 10000
)
issue_type = IssueType.objects.create(
name="Task",
description="A task that needs to be completed.",
project=project,
)
for _ in range(0, issue_count):
start_date = [None, fake.date_this_year()][random.randint(0, 1)]
end_date = (
@@ -364,6 +371,7 @@ def create_issues(workspace, project, user_id, issue_count):
random.randint(0, 4)
],
created_by_id=creators[random.randint(0, len(creators) - 1)],
type=issue_type,
)
)

View File

@@ -0,0 +1,44 @@
# Python imports
import json
# Third party imports
from celery import shared_task
# Module imports
from plane.db.models import Page, PageVersion
@shared_task
def page_version(
page_id,
existing_instance,
user_id,
):
# Get the page
page = Page.objects.get(id=page_id)
# Get the current instance
current_instance = (
json.loads(existing_instance) if existing_instance is not None else {}
)
# Create a version if description_html is updated
if current_instance.get("description_html") != page.description_html:
# Create a new page version
PageVersion.objects.create(
page_id=page_id,
workspace_id=page.workspace_id,
description_html=page.description_html,
description_binary=page.description_binary,
ownned_by_id=user_id,
last_saved_at=page.updated_at,
)
# If page versions are greater than 20 delete the oldest one
if PageVersion.objects.filter(page_id=page_id).count() > 20:
# Delete the old page version
PageVersion.objects.filter(page_id=page_id).order_by(
"last_saved_at"
).first().delete()
return

View File

@@ -0,0 +1,275 @@
# Generated by Django 4.2.11 on 2024-07-01 06:10
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import uuid
def create_issue_types(apps, schema_editor):
Project = apps.get_model("db", "Project")
Issue = apps.get_model("db", "Issue")
IssueType = apps.get_model("db", "IssueType")
# Create the issue types for all projects
IssueType.objects.bulk_create(
[
IssueType(
name="Task",
description="A task that needs to be completed.",
project_id=project["id"],
workspace_id=project["workspace_id"],
)
for project in Project.objects.values("id", "workspace_id")
],
batch_size=1000,
)
# Update the issue type for all existing issues
issue_types = {
str(issue_type["project_id"]): str(issue_type["id"])
for issue_type in IssueType.objects.values("id", "project_id")
}
# Update the issue type for all existing issues
bulk_issues = []
for issue in Issue.objects.all():
issue.type_id = issue_types[str(issue.project_id)]
bulk_issues.append(issue)
# Update the issue type for all existing issues
Issue.objects.bulk_update(bulk_issues, ["type_id"], batch_size=1000)
def create_page_versions(apps, schema_editor):
Page = apps.get_model("db", "Page")
PageVersion = apps.get_model("db", "PageVersion")
# Create the page versions for all pages
PageVersion.objects.bulk_create(
[
PageVersion(
page_id=page["id"],
workspace_id=page["workspace_id"],
description_html=page["description_html"],
description_binary=page["description_binary"],
description_stripped=page["description_stripped"],
ownned_by_id=page["owned_by_id"],
last_saved_at=page["updated_at"],
)
for page in Page.objects.values(
"id",
"workspace_id",
"description_html",
"description_binary",
"description_stripped",
"owned_by_id",
"updated_at",
)
],
batch_size=1000,
)
class Migration(migrations.Migration):
dependencies = [
("db", "0069_alter_account_provider_and_more"),
]
operations = [
migrations.CreateModel(
name="IssueType",
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,
),
),
("name", models.CharField(max_length=255)),
("description", models.TextField(blank=True)),
("logo_props", models.JSONField(default=dict)),
("sort_order", models.FloatField(default=65535)),
(
"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",
),
),
(
"project",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="project_%(class)s",
to="db.project",
),
),
(
"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",
),
),
(
"is_default",
models.BooleanField(default=True),
),
],
options={
"verbose_name": "Issue Type",
"verbose_name_plural": "Issue Types",
"db_table": "issue_types",
"ordering": ("sort_order",),
},
),
migrations.AddField(
model_name="issue",
name="type",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="issue_type",
to="db.issuetype",
),
),
migrations.AddField(
model_name="apitoken",
name="is_service",
field=models.BooleanField(default=False),
),
migrations.RunPython(create_issue_types),
migrations.CreateModel(
name="PageVersion",
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,
),
),
(
"last_saved_at",
models.DateTimeField(default=django.utils.timezone.now),
),
("description_binary", models.BinaryField(null=True)),
(
"description_html",
models.TextField(blank=True, default="<p></p>"),
),
(
"description_stripped",
models.TextField(blank=True, null=True),
),
(
"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",
),
),
(
"ownned_by",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="page_versions",
to=settings.AUTH_USER_MODEL,
),
),
(
"page",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="page_versions",
to="db.page",
),
),
(
"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="page_versions",
to="db.workspace",
),
),
],
options={
"verbose_name": "Page Version",
"verbose_name_plural": "Page Versions",
"db_table": "page_versions",
"ordering": ("-created_at",),
},
),
migrations.AddField(
model_name="project",
name="start_date",
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name="project",
name="target_date",
field=models.DateTimeField(blank=True, null=True),
),
migrations.RunPython(create_page_versions),
]

View File

@@ -50,7 +50,14 @@ from .notification import (
Notification,
UserNotificationPreference,
)
from .page import Page, PageFavorite, PageLabel, PageLog, ProjectPage
from .page import (
Page,
PageFavorite,
PageLabel,
PageLog,
ProjectPage,
PageVersion,
)
from .project import (
Project,
ProjectBaseModel,
@@ -101,3 +108,5 @@ from .webhook import Webhook, WebhookLog
from .dashboard import Dashboard, DashboardWidget, Widget
from .favorite import UserFavorite
from .issue_type import IssueType

View File

@@ -44,6 +44,7 @@ class APIToken(BaseModel):
null=True,
)
expired_at = models.DateTimeField(blank=True, null=True)
is_service = models.BooleanField(default=False)
class Meta:
verbose_name = "API Token"

View File

@@ -164,6 +164,13 @@ class Issue(ProjectBaseModel):
is_draft = models.BooleanField(default=False)
external_source = models.CharField(max_length=255, null=True, blank=True)
external_id = models.CharField(max_length=255, blank=True, null=True)
type = models.ForeignKey(
"db.IssueType",
on_delete=models.SET_NULL,
related_name="issue_type",
null=True,
blank=True,
)
objects = models.Manager()
issue_objects = IssueManager()

View File

@@ -0,0 +1,34 @@
# Django imports
from django.db import models
# Module imports
from .workspace import WorkspaceBaseModel
class IssueType(WorkspaceBaseModel):
name = models.CharField(max_length=255)
description = models.TextField(blank=True)
logo_props = models.JSONField(default=dict)
sort_order = models.FloatField(default=65535)
is_default = models.BooleanField(default=True)
class Meta:
verbose_name = "Issue Type"
verbose_name_plural = "Issue Types"
db_table = "issue_types"
ordering = ("sort_order",)
def __str__(self):
return self.name
def save(self, *args, **kwargs):
# If we are adding a new issue type, we need to set the sort order
if self._state.adding:
# Get the largest sort order for the project
largest_sort_order = IssueType.objects.filter(
project=self.project
).aggregate(largest=models.Max("sort_order"))["largest"]
# If there are issue types, set the sort order to the largest + 10000
if largest_sort_order is not None:
self.sort_order = largest_sort_order + 10000
super(IssueType, self).save(*args, **kwargs)

View File

@@ -1,6 +1,7 @@
import uuid
from django.conf import settings
from django.utils import timezone
# Django imports
from django.db import models
@@ -66,6 +67,15 @@ class Page(BaseModel):
"""Return owner email and page name"""
return f"{self.owned_by.email} <{self.name}>"
def save(self, *args, **kwargs):
# Strip the html tags using html parser
self.description_stripped = (
None
if (self.description_html == "" or self.description_html is None)
else strip_tags(self.description_html)
)
super(Page, self).save(*args, **kwargs)
class PageLog(BaseModel):
TYPE_CHOICES = (
@@ -249,3 +259,40 @@ class TeamPage(BaseModel):
verbose_name_plural = "Team Pages"
db_table = "team_pages"
ordering = ("-created_at",)
class PageVersion(BaseModel):
workspace = models.ForeignKey(
"db.Workspace",
on_delete=models.CASCADE,
related_name="page_versions",
)
page = models.ForeignKey(
"db.Page",
on_delete=models.CASCADE,
related_name="page_versions",
)
last_saved_at = models.DateTimeField(default=timezone.now)
ownned_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="page_versions",
)
description_binary = models.BinaryField(null=True)
description_html = models.TextField(blank=True, default="<p></p>")
description_stripped = models.TextField(blank=True, null=True)
class Meta:
verbose_name = "Page Version"
verbose_name_plural = "Page Versions"
db_table = "page_versions"
ordering = ("-created_at",)
def save(self, *args, **kwargs):
# Strip the html tags using html parser
self.description_stripped = (
None
if (self.description_html == "" or self.description_html is None)
else strip_tags(self.description_html)
)
super(PageVersion, self).save(*args, **kwargs)

View File

@@ -115,6 +115,9 @@ class Project(BaseModel):
related_name="default_state",
)
archived_at = models.DateTimeField(null=True)
# Project start and target date
start_date = models.DateTimeField(null=True, blank=True)
target_date = models.DateTimeField(null=True, blank=True)
def __str__(self):
"""Return name of the project"""