Compare commits

..
14 Commits
Author SHA1 Message Date
sriram veeraghantaandGitHub 707570ca7a Merge pull request #5041 from makeplane/preview
release: v0.22-dev
2024-07-05 13:28:45 +05:30
sriram veeraghantaandGitHub c76af7d7d6 Merge pull request #4688 from makeplane/preview
release: v0.21-dev
2024-06-03 18:54:06 +05:30
sriram veeraghantaandGitHub 1dcea9bcc8 Merge pull request #4569 from makeplane/preview
release: v0.20-dev
2024-05-23 19:55:06 +05:30
sriram veeraghantaandGitHub da957e06b6 Merge pull request #4349 from makeplane/preview
release: v0.19-dev
2024-05-03 20:36:07 +05:30
sriram veeraghantaandGitHub a0b9596cb4 Merge pull request #4239 from makeplane/preview
chore:version update
2024-04-19 12:01:15 +05:30
sriram veeraghantaandGitHub f71e8a3a0f Merge pull request #4238 from makeplane/preview
release: v0.18-dev
2024-04-19 11:56:03 +05:30
sriram veeraghantaandGitHub 002fb4547b Merge pull request #4107 from makeplane/preview
release: v0.17-dev
2024-04-02 20:07:48 +05:30
sriram veeraghantaandGitHub c1b1ba35c1 Merge pull request #3878 from makeplane/preview
release: v0.16-dev
2024-03-05 20:04:08 +05:30
sriram veeraghantaandGitHub 4566d6e80c Merge pull request #3697 from makeplane/preview
release: 0.15.4-dev
2024-02-19 19:30:06 +05:30
sriram veeraghantaandGitHub e8d359e625 Merge pull request #3674 from makeplane/preview
fix: build branch docker images push on release
2024-02-15 14:35:32 +05:30
sriram veeraghantaandGitHub 351eba8d61 Merge pull request #3671 from makeplane/preview
release: peek overview issue description initial load bug (#3670)
2024-02-15 03:25:30 +05:30
sriram veeraghantaandGitHub 1e27e37b51 Merge pull request #3666 from makeplane/preview
release: v0.15.2-dev
2024-02-14 19:41:55 +05:30
sriram veeraghantaandGitHub 7df2e9cf11 Merge pull request #3632 from makeplane/preview
release: v0.15.1-dev
2024-02-12 20:59:56 +05:30
sriram veeraghantaandGitHub c6e3f1b932 Merge pull request #3535 from makeplane/preview
release: 0.15-dev
2024-02-01 15:01:49 +05:30
563 changed files with 4721 additions and 14113 deletions
+1 -1
View File
@@ -2,7 +2,7 @@ name: Bug report
description: Create a bug report to help us improve Plane
title: "[bug]: "
labels: [🐛bug]
assignees: [vihar, pushya22]
assignees: [srinivaspendem, pushya22]
body:
- type: markdown
attributes:
@@ -2,7 +2,7 @@ name: Feature request
description: Suggest a feature to improve Plane
title: "[feature]: "
labels: [✨feature]
assignees: [vihar, pushya22]
assignees: [srinivaspendem, pushya22]
body:
- type: markdown
attributes:
+5 -9
View File
@@ -31,7 +31,6 @@ jobs:
runs-on: ubuntu-latest
outputs:
gh_branch_name: ${{ steps.set_env_variables.outputs.TARGET_BRANCH }}
flat_branch_name: ${{ steps.set_env_variables.outputs.FLAT_BRANCH_NAME }}
gh_buildx_driver: ${{ steps.set_env_variables.outputs.BUILDX_DRIVER }}
gh_buildx_version: ${{ steps.set_env_variables.outputs.BUILDX_VERSION }}
gh_buildx_platforms: ${{ steps.set_env_variables.outputs.BUILDX_PLATFORMS }}
@@ -57,7 +56,7 @@ jobs:
echo "BUILDX_PLATFORMS=linux/amd64" >> $GITHUB_OUTPUT
echo "BUILDX_ENDPOINT=" >> $GITHUB_OUTPUT
if [ "${{ github.event_name}}" == "workflow_dispatch" ] && [ "${{ github.event.inputs.base_tag_name }}" != "" ]; then
if [ "${{ github.event.inputs.base_tag_name }}" != "" ]; then
echo "AIO_BASE_TAG=${{ github.event.inputs.base_tag_name }}" >> $GITHUB_OUTPUT
elif [ "${{ env.TARGET_BRANCH }}" == "preview" ]; then
echo "AIO_BASE_TAG=preview" >> $GITHUB_OUTPUT
@@ -79,9 +78,6 @@ jobs:
echo "DO_SLIM_BUILD=false" >> $GITHUB_OUTPUT
fi
FLAT_BRANCH_NAME=$(echo "${{ env.TARGET_BRANCH }}" | sed 's/[^a-zA-Z0-9]/-/g')
echo "FLAT_BRANCH_NAME=$FLAT_BRANCH_NAME" >> $GITHUB_OUTPUT
- id: checkout_files
name: Checkout Files
uses: actions/checkout@v4
@@ -93,7 +89,7 @@ jobs:
env:
BUILD_TYPE: full
AIO_BASE_TAG: ${{ needs.branch_build_setup.outputs.aio_base_tag }}
AIO_IMAGE_TAGS: makeplane/plane-aio:full-${{ needs.branch_build_setup.outputs.flat_branch_name }}
AIO_IMAGE_TAGS: makeplane/plane-aio:full-${{ needs.branch_build_setup.outputs.gh_branch_name }}
TARGET_BRANCH: ${{ needs.branch_build_setup.outputs.gh_branch_name }}
BUILDX_DRIVER: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
BUILDX_VERSION: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
@@ -136,7 +132,7 @@ jobs:
tags: ${{ env.AIO_IMAGE_TAGS }}
push: true
build-args: |
BASE_TAG=${{ env.AIO_BASE_TAG }}
BUILD_TAG=${{ env.AIO_BASE_TAG }}
BUILD_TYPE=${{env.BUILD_TYPE}}
cache-from: type=gha
cache-to: type=gha,mode=max
@@ -153,7 +149,7 @@ jobs:
env:
BUILD_TYPE: slim
AIO_BASE_TAG: ${{ needs.branch_build_setup.outputs.aio_base_tag }}
AIO_IMAGE_TAGS: makeplane/plane-aio:slim-${{ needs.branch_build_setup.outputs.flat_branch_name }}
AIO_IMAGE_TAGS: makeplane/plane-aio:slim-${{ needs.branch_build_setup.outputs.gh_branch_name }}
TARGET_BRANCH: ${{ needs.branch_build_setup.outputs.gh_branch_name }}
BUILDX_DRIVER: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
BUILDX_VERSION: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
@@ -196,7 +192,7 @@ jobs:
tags: ${{ env.AIO_IMAGE_TAGS }}
push: true
build-args: |
BASE_TAG=${{ env.AIO_BASE_TAG }}
BUILD_TAG=${{ env.AIO_BASE_TAG }}
BUILD_TYPE=${{env.BUILD_TYPE}}
cache-from: type=gha
cache-to: type=gha,mode=max
+62 -23
View File
@@ -1,5 +1,6 @@
# Environment Variables
Environment variables are distributed in various files. Please refer them carefully.
## {PROJECT_FOLDER}/.env
@@ -8,13 +9,17 @@ File is available in the project root folder
```
# Database Settings
POSTGRES_USER="plane"
POSTGRES_PASSWORD="plane"
POSTGRES_DB="plane"
PGDATA="/var/lib/postgresql/data"
PGUSER="plane"
PGPASSWORD="plane"
PGHOST="plane-db"
PGDATABASE="plane"
DATABASE_URL=postgresql://${PGUSER}:${PGPASSWORD}@${PGHOST}/${PGDATABASE}
# Redis Settings
REDIS_HOST="plane-redis"
REDIS_PORT="6379"
REDIS_URL="redis://${REDIS_HOST}:6379/"
# AWS Settings
AWS_REGION=""
AWS_ACCESS_KEY_ID="access-key"
@@ -24,39 +29,63 @@ AWS_S3_ENDPOINT_URL="http://plane-minio:9000"
AWS_S3_BUCKET_NAME="uploads"
# Maximum file upload limit
FILE_SIZE_LIMIT=5242880
# GPT settings
OPENAI_API_BASE="https://api.openai.com/v1" # deprecated
OPENAI_API_KEY="sk-" # deprecated
GPT_ENGINE="gpt-3.5-turbo" # deprecated
# Settings related to Docker
DOCKERIZED=1 # deprecated
# set to 1 If using the pre-configured minio setup
USE_MINIO=1
# Nginx Configuration
NGINX_PORT=80
```
## {PROJECT_FOLDER}/web/.env.example
```
# Public boards deploy URL
NEXT_PUBLIC_DEPLOY_URL="http://localhost/spaces"
```
## {PROJECT_FOLDER}/apiserver/.env
```
# Backend
# Debug value for api server use it as 0 for production use
DEBUG=0
CORS_ALLOWED_ORIGINS="http://localhost"
# Error logs
SENTRY_DSN=""
SENTRY_ENVIRONMENT="development"
# Database Settings
POSTGRES_USER="plane"
POSTGRES_PASSWORD="plane"
POSTGRES_HOST="plane-db"
POSTGRES_DB="plane"
POSTGRES_PORT=5432
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}
PGUSER="plane"
PGPASSWORD="plane"
PGHOST="plane-db"
PGDATABASE="plane"
DATABASE_URL=postgresql://${PGUSER}:${PGPASSWORD}@${PGHOST}/${PGDATABASE}
# Redis Settings
REDIS_HOST="plane-redis"
REDIS_PORT="6379"
REDIS_URL="redis://${REDIS_HOST}:6379/"
# Email Settings
EMAIL_HOST=""
EMAIL_HOST_USER=""
EMAIL_HOST_PASSWORD=""
EMAIL_PORT=587
EMAIL_FROM="Team Plane <team@mailer.plane.so>"
EMAIL_USE_TLS="1"
EMAIL_USE_SSL="0"
# AWS Settings
AWS_REGION=""
AWS_ACCESS_KEY_ID="access-key"
@@ -66,25 +95,35 @@ AWS_S3_ENDPOINT_URL="http://plane-minio:9000"
AWS_S3_BUCKET_NAME="uploads"
# Maximum file upload limit
FILE_SIZE_LIMIT=5242880
# GPT settings
OPENAI_API_BASE="https://api.openai.com/v1" # deprecated
OPENAI_API_KEY="sk-" # deprecated
GPT_ENGINE="gpt-3.5-turbo" # deprecated
# Settings related to Docker
DOCKERIZED=1 # deprecated
DOCKERIZED=1 # Deprecated
# Github
GITHUB_CLIENT_SECRET="" # For fetching release notes
# set to 1 If using the pre-configured minio setup
USE_MINIO=1
# Nginx Configuration
NGINX_PORT=80
# Email redirections and minio domain settings
# SignUps
ENABLE_SIGNUP="1"
# Email Redirection URL
WEB_URL="http://localhost"
# Gunicorn Workers
GUNICORN_WORKERS=2
# Base URLs
ADMIN_BASE_URL=
SPACE_BASE_URL=
APP_BASE_URL=
SECRET_KEY="gxoytl7dmnc1y37zahah820z5iq3iozu38cnfjtu3yaau9cd9z"
```
## Updates
- The environment variable NEXT_PUBLIC_API_BASE_URL has been removed from both the web and space projects.
- The naming convention for containers and images has been updated.
- The plane-worker image will no longer be maintained, as it has been merged with plane-backend.
- The Tiptap pro-extension dependency has been removed, eliminating the need for Tiptap API keys.
+1 -2
View File
@@ -14,7 +14,6 @@
"@headlessui/react": "^1.7.19",
"@plane/types": "*",
"@plane/ui": "*",
"@plane/constants": "*",
"@tailwindcss/typography": "^0.5.9",
"@types/lodash": "^4.17.0",
"autoprefixer": "10.4.14",
@@ -47,4 +46,4 @@
"tsconfig": "*",
"typescript": "^5.4.2"
}
}
}
+3 -3
View File
@@ -13,9 +13,9 @@ class CycleSerializer(BaseSerializer):
started_issues = serializers.IntegerField(read_only=True)
unstarted_issues = serializers.IntegerField(read_only=True)
backlog_issues = serializers.IntegerField(read_only=True)
total_estimates = serializers.FloatField(read_only=True)
completed_estimates = serializers.FloatField(read_only=True)
started_estimates = serializers.FloatField(read_only=True)
total_estimates = serializers.IntegerField(read_only=True)
completed_estimates = serializers.IntegerField(read_only=True)
started_estimates = serializers.IntegerField(read_only=True)
def validate(self, data):
if (
+6 -12
View File
@@ -55,6 +55,7 @@ class IssueSerializer(BaseSerializer):
"project",
"created_by",
"updated_by",
"created_at",
"updated_at",
]
exclude = [
@@ -130,10 +131,7 @@ 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,
)
issue = Issue.objects.create(**validated_data, project_id=project_id)
# Issue Audit Users
created_by_id = issue.created_by_id
@@ -314,14 +312,10 @@ 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"}
)
-2
View File
@@ -4,7 +4,6 @@ from .issue import urlpatterns as issue_patterns
from .cycle import urlpatterns as cycle_patterns
from .module import urlpatterns as module_patterns
from .inbox import urlpatterns as inbox_patterns
from .member import urlpatterns as member_patterns
urlpatterns = [
*project_patterns,
@@ -13,5 +12,4 @@ urlpatterns = [
*cycle_patterns,
*module_patterns,
*inbox_patterns,
*member_patterns,
]
-6
View File
@@ -7,7 +7,6 @@ from plane.api.views import (
IssueCommentAPIEndpoint,
IssueActivityAPIEndpoint,
WorkspaceIssueAPIEndpoint,
IssueAttachmentEndpoint,
)
urlpatterns = [
@@ -66,9 +65,4 @@ urlpatterns = [
IssueActivityAPIEndpoint.as_view(),
name="activity",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/issue-attachments/",
IssueAttachmentEndpoint.as_view(),
name="attachment",
),
]
-13
View File
@@ -1,13 +0,0 @@
from django.urls import path
from plane.api.views import (
WorkspaceMemberAPIEndpoint,
)
urlpatterns = [
path(
"workspaces/<str:slug>/members/",
WorkspaceMemberAPIEndpoint.as_view(),
name="users",
),
]
-3
View File
@@ -9,7 +9,6 @@ from .issue import (
IssueLinkAPIEndpoint,
IssueCommentAPIEndpoint,
IssueActivityAPIEndpoint,
IssueAttachmentEndpoint,
)
from .cycle import (
@@ -25,6 +24,4 @@ from .module import (
ModuleArchiveUnarchiveAPIEndpoint,
)
from .member import WorkspaceMemberAPIEndpoint
from .inbox import InboxIssueAPIEndpoint
+12 -26
View File
@@ -393,6 +393,7 @@ class CycleAPIEndpoint(BaseAPIView):
class CycleArchiveUnarchiveAPIEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
@@ -646,6 +647,17 @@ class CycleIssueAPIEndpoint(BaseAPIView):
workspace__slug=slug, project_id=project_id, pk=cycle_id
)
if (
cycle.end_date is not None
and cycle.end_date < timezone.now().date()
):
return Response(
{
"error": "The Cycle has already been completed so no new issues can be added"
},
status=status.HTTP_400_BAD_REQUEST,
)
issues = Issue.objects.filter(
pk__in=issues, workspace__slug=slug, project_id=project_id
).values_list("id", flat=True)
@@ -1128,38 +1140,12 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView):
)
updated_cycles = []
update_cycle_issue_activity = []
for cycle_issue in cycle_issues:
cycle_issue.cycle_id = new_cycle_id
updated_cycles.append(cycle_issue)
update_cycle_issue_activity.append(
{
"old_cycle_id": str(cycle_id),
"new_cycle_id": str(new_cycle_id),
"issue_id": str(cycle_issue.issue_id),
}
)
cycle_issues = CycleIssue.objects.bulk_update(
updated_cycles, ["cycle_id"], batch_size=100
)
# Capture Issue Activity
issue_activity.delay(
type="cycle.activity.created",
requested_data=json.dumps({"cycles_list": []}),
actor_id=str(self.request.user.id),
issue_id=None,
project_id=str(self.kwargs.get("project_id", None)),
current_instance=json.dumps(
{
"updated_cycle_issues": update_cycle_issue_activity,
"created_cycle_issues": "[]",
}
),
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
return Response({"message": "Success"}, status=status.HTTP_200_OK)
+3 -25
View File
@@ -3,11 +3,8 @@ import json
# Django improts
from django.core.serializers.json import DjangoJSONEncoder
from django.db.models import Q
from django.utils import timezone
from django.db.models import Q, Value, UUIDField
from django.db.models.functions import Coalesce
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
# Third party imports
from rest_framework import status
@@ -227,27 +224,8 @@ class InboxIssueAPIEndpoint(BaseAPIView):
issue_data = request.data.pop("issue", False)
if bool(issue_data):
issue = Issue.objects.annotate(
label_ids=Coalesce(
ArrayAgg(
"labels__id",
distinct=True,
filter=~Q(labels__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
assignee_ids=Coalesce(
ArrayAgg(
"assignees__id",
distinct=True,
filter=~Q(assignees__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
).get(
pk=issue_id,
workspace__slug=slug,
project_id=project_id,
issue = Issue.objects.get(
pk=issue_id, workspace__slug=slug, project_id=project_id
)
# Only allow guests and viewers to edit name and description
if project_member.role <= 10:
-87
View File
@@ -22,11 +22,9 @@ from django.utils import timezone
# Third party imports
from rest_framework import status
from rest_framework.response import Response
from rest_framework.parsers import MultiPartParser, FormParser
# Module imports
from plane.api.serializers import (
IssueAttachmentSerializer,
IssueActivitySerializer,
IssueCommentSerializer,
IssueLinkSerializer,
@@ -309,11 +307,6 @@ class IssueAPIEndpoint(BaseAPIView):
)
serializer.save()
# Refetch the issue
issue = Issue.objects.filter(workspace__slug=slug, project_id=project_id, pk=serializer.data["id"]).first()
issue.created_at = request.data.get("created_at")
issue.save(update_fields=["created_at"])
# Track the issue
issue_activity.delay(
@@ -881,83 +874,3 @@ class IssueActivityAPIEndpoint(BaseAPIView):
expand=self.expand,
).data,
)
class IssueAttachmentEndpoint(BaseAPIView):
serializer_class = IssueAttachmentSerializer
permission_classes = [
ProjectEntityPermission,
]
model = IssueAttachment
parser_classes = (MultiPartParser, FormParser)
def post(self, request, slug, project_id, issue_id):
serializer = IssueAttachmentSerializer(data=request.data)
if (
request.data.get("external_id")
and request.data.get("external_source")
and IssueAttachment.objects.filter(
project_id=project_id,
workspace__slug=slug,
issue_id=issue_id,
external_source=request.data.get("external_source"),
external_id=request.data.get("external_id"),
).exists()
):
issue_attachment = IssueAttachment.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 attachment with the same external id and external source already exists",
"id": str(issue_attachment.id),
},
status=status.HTTP_409_CONFLICT,
)
if serializer.is_valid():
serializer.save(project_id=project_id, issue_id=issue_id)
issue_activity.delay(
type="attachment.activity.created",
requested_data=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(
serializer.data,
cls=DjangoJSONEncoder,
),
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def delete(self, request, slug, project_id, issue_id, pk):
issue_attachment = IssueAttachment.objects.get(pk=pk)
issue_attachment.asset.delete(save=False)
issue_attachment.delete()
issue_activity.delay(
type="attachment.activity.deleted",
requested_data=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=None,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
return Response(status=status.HTTP_204_NO_CONTENT)
def get(self, request, slug, project_id, issue_id):
issue_attachments = IssueAttachment.objects.filter(
issue_id=issue_id, workspace__slug=slug, project_id=project_id
)
serializer = IssueAttachmentSerializer(issue_attachments, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
-147
View File
@@ -1,147 +0,0 @@
# Python imports
import uuid
# Django imports
from django.contrib.auth.hashers import make_password
from django.core.validators import validate_email
from django.core.exceptions import ValidationError
# Third Party imports
from rest_framework.response import Response
from rest_framework import status
# Module imports
from .base import BaseAPIView
from plane.api.serializers import UserLiteSerializer
from plane.db.models import (
User,
Workspace,
Project,
WorkspaceMember,
ProjectMember,
)
# API endpoint to get and insert users inside the workspace
class WorkspaceMemberAPIEndpoint(BaseAPIView):
# Get all the users that are present inside the workspace
def get(self, request, slug):
# Check if the workspace exists
if not Workspace.objects.filter(slug=slug).exists():
return Response(
{"error": "Provided workspace does not exist"},
status=status.HTTP_400_BAD_REQUEST,
)
# Get the workspace members that are present inside the workspace
workspace_members = WorkspaceMember.objects.filter(
workspace__slug=slug
)
# Get all the users that are present inside the workspace
users = UserLiteSerializer(
User.objects.filter(
id__in=workspace_members.values_list("member_id", flat=True)
),
many=True,
).data
return Response(users, status=status.HTTP_200_OK)
# Insert a new user inside the workspace, and assign the user to the project
def post(self, request, slug):
# Check if user with email already exists, and send bad request if it's
# not present, check for workspace and valid project mandat
# ------------------- Validation -------------------
if (
request.data.get("email") is None
or request.data.get("display_name") is None
or request.data.get("project_id") is None
):
return Response(
{
"error": "Expected email, display_name, workspace_slug, project_id, one or more of the fields are missing."
},
status=status.HTTP_400_BAD_REQUEST,
)
email = request.data.get("email")
try:
validate_email(email)
except ValidationError:
return Response(
{"error": "Invalid email provided"},
status=status.HTTP_400_BAD_REQUEST,
)
workspace = Workspace.objects.filter(slug=slug).first()
project = Project.objects.filter(
pk=request.data.get("project_id")
).first()
if not all([workspace, project]):
return Response(
{"error": "Provided workspace or project does not exist"},
status=status.HTTP_400_BAD_REQUEST,
)
# Check if user exists
user = User.objects.filter(email=email).first()
workspace_member = None
project_member = None
if user:
# Check if user is part of the workspace
workspace_member = WorkspaceMember.objects.filter(
workspace=workspace, member=user
).first()
if workspace_member:
# Check if user is part of the project
project_member = ProjectMember.objects.filter(
project=project, member=user
).first()
if project_member:
return Response(
{
"error": "User is already part of the workspace and project"
},
status=status.HTTP_400_BAD_REQUEST,
)
# If user does not exist, create the user
if not user:
user = User.objects.create(
email=email,
display_name=request.data.get("display_name"),
first_name=request.data.get("first_name", ""),
last_name=request.data.get("last_name", ""),
username=uuid.uuid4().hex,
password=make_password(uuid.uuid4().hex),
is_password_autoset=True,
is_active=False,
)
user.save()
# Create a workspace member for the user if not already a member
if not workspace_member:
workspace_member = WorkspaceMember.objects.create(
workspace=workspace,
member=user,
role=request.data.get("role", 10),
)
workspace_member.save()
# Create a project member for the user if not already a member
if not project_member:
project_member = ProjectMember.objects.create(
project=project,
member=user,
role=request.data.get("role", 10),
)
project_member.save()
# Serialize the user and return the response
user_data = UserLiteSerializer(user).data
return Response(user_data, status=status.HTTP_201_CREATED)
+3 -4
View File
@@ -19,7 +19,7 @@ from plane.app.permissions import ProjectBasePermission
from plane.db.models import (
Cycle,
Inbox,
IssueUserProperty,
IssueProperty,
Module,
Project,
DeployBoard,
@@ -165,7 +165,7 @@ class ProjectAPIEndpoint(BaseAPIView):
role=20,
)
# Also create the issue property for the user
_ = IssueUserProperty.objects.create(
_ = IssueProperty.objects.create(
project_id=serializer.data["id"],
user=request.user,
)
@@ -179,7 +179,7 @@ class ProjectAPIEndpoint(BaseAPIView):
role=20,
)
# Also create the issue property for the user
IssueUserProperty.objects.create(
IssueProperty.objects.create(
project_id=serializer.data["id"],
user_id=serializer.data["project_lead"],
)
@@ -240,7 +240,6 @@ class ProjectAPIEndpoint(BaseAPIView):
.filter(pk=serializer.data["id"])
.first()
)
# Model activity
model_activity.delay(
model_name="project",
+1 -2
View File
@@ -50,7 +50,7 @@ from .issue import (
IssueCreateSerializer,
IssueActivitySerializer,
IssueCommentSerializer,
IssueUserPropertySerializer,
IssuePropertySerializer,
IssueAssigneeSerializer,
LabelSerializer,
IssueSerializer,
@@ -91,7 +91,6 @@ from .page import (
PageLogSerializer,
SubPageSerializer,
PageDetailSerializer,
PageVersionSerializer,
)
from .estimate import (
+9 -17
View File
@@ -17,7 +17,7 @@ from plane.db.models import (
Issue,
IssueActivity,
IssueComment,
IssueUserProperty,
IssueProperty,
IssueAssignee,
IssueSubscriber,
IssueLabel,
@@ -135,11 +135,7 @@ class IssueCreateSerializer(BaseSerializer):
workspace_id = self.context["workspace_id"]
default_assignee_id = self.context["default_assignee_id"]
# Create Issue
issue = Issue.objects.create(
**validated_data,
project_id=project_id,
)
issue = Issue.objects.create(**validated_data, project_id=project_id)
# Issue Audit Users
created_by_id = issue.created_by_id
@@ -252,9 +248,9 @@ class IssueActivitySerializer(BaseSerializer):
fields = "__all__"
class IssueUserPropertySerializer(BaseSerializer):
class IssuePropertySerializer(BaseSerializer):
class Meta:
model = IssueUserProperty
model = IssueProperty
fields = "__all__"
read_only_fields = [
"user",
@@ -463,14 +459,10 @@ 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"}
)
@@ -517,7 +509,7 @@ class IssueAttachmentLiteSerializer(DynamicBaseSerializer):
"attributes",
"issue_id",
"updated_at",
"updated_by",
"updated_by_id",
]
read_only_fields = fields
@@ -12,7 +12,6 @@ class NotificationSerializer(BaseSerializer):
read_only=True, source="triggered_by"
)
is_inbox_issue = serializers.BooleanField(read_only=True)
is_mentioned_notification = serializers.BooleanField(read_only=True)
class Meta:
model = Notification
-11
View File
@@ -10,7 +10,6 @@ from plane.db.models import (
Label,
ProjectPage,
Project,
PageVersion,
)
@@ -162,13 +161,3 @@ class PageLogSerializer(BaseSerializer):
"workspace",
"page",
]
class PageVersionSerializer(BaseSerializer):
class Meta:
model = PageVersion
fields = "__all__"
read_only_fields = [
"workspace",
"page",
]
+2 -2
View File
@@ -23,7 +23,7 @@ class IssueViewSerializer(DynamicBaseSerializer):
]
def create(self, validated_data):
query_params = validated_data.get("filters", {})
query_params = validated_data.get("query_data", {})
if bool(query_params):
validated_data["query"] = issue_filters(query_params, "POST")
else:
@@ -31,7 +31,7 @@ class IssueViewSerializer(DynamicBaseSerializer):
return IssueView.objects.create(**validated_data)
def update(self, instance, validated_data):
query_params = validated_data.get("filters", {})
query_params = validated_data.get("query_data", {})
if bool(query_params):
validated_data["query"] = issue_filters(query_params, "POST")
else:
+2 -2
View File
@@ -233,13 +233,13 @@ urlpatterns = [
name="project-issue-comment-reactions",
),
## End Comment Reactions
## IssueUserProperty
## IssueProperty
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/user-properties/",
IssueUserDisplayPropertyEndpoint.as_view(),
name="project-issue-display-properties",
),
## IssueUserProperty End
## IssueProperty End
## Issue Archives
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/archived-issues/",
-21
View File
@@ -7,7 +7,6 @@ from plane.app.views import (
PageLogEndpoint,
SubPagesEndpoint,
PagesDescriptionViewSet,
PageVersionEndpoint,
)
@@ -66,16 +65,6 @@ urlpatterns = [
),
name="project-pages-lock-unlock",
),
# private and public page
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:pk>/access/",
PageViewSet.as_view(
{
"post": "access",
}
),
name="project-pages-access",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:pk>/transactions/",
PageLogEndpoint.as_view(),
@@ -101,14 +90,4 @@ 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",
),
]
-1
View File
@@ -179,7 +179,6 @@ from .page.base import (
SubPagesEndpoint,
PagesDescriptionViewSet,
)
from .page.version import PageVersionEndpoint
from .search.base import GlobalSearchEndpoint
from .search.issue import IssueSearchEndpoint
+8 -42
View File
@@ -465,14 +465,14 @@ class CycleViewSet(BaseViewSet):
}
if data[0]["start_date"] and data[0]["end_date"]:
data[0]["estimate_distribution"][
"completion_chart"
] = burndown_plot(
queryset=queryset.first(),
slug=slug,
project_id=project_id,
plot_type="points",
cycle_id=data[0]["id"],
data[0]["estimate_distribution"]["completion_chart"] = (
burndown_plot(
queryset=queryset.first(),
slug=slug,
project_id=project_id,
plot_type="points",
cycle_id=data[0]["id"],
)
)
assignee_distribution = (
@@ -645,8 +645,6 @@ class CycleViewSet(BaseViewSet):
"progress_snapshot",
"logo_props",
# meta fields
"completed_estimate_points",
"total_estimate_points",
"is_favorite",
"cancelled_issues",
"total_issues",
@@ -656,7 +654,6 @@ class CycleViewSet(BaseViewSet):
"backlog_issues",
"assignee_ids",
"status",
"created_by",
)
.first()
)
@@ -742,8 +739,6 @@ class CycleViewSet(BaseViewSet):
"progress_snapshot",
"logo_props",
# meta fields
"completed_estimate_points",
"total_estimate_points",
"is_favorite",
"total_issues",
"cancelled_issues",
@@ -753,7 +748,6 @@ class CycleViewSet(BaseViewSet):
"backlog_issues",
"assignee_ids",
"status",
"created_by",
).first()
# Send the model activity
@@ -806,8 +800,6 @@ class CycleViewSet(BaseViewSet):
"sub_issues",
"logo_props",
# meta fields
"completed_estimate_points",
"total_estimate_points",
"is_favorite",
"total_issues",
"cancelled_issues",
@@ -1515,40 +1507,14 @@ class TransferCycleIssueEndpoint(BaseAPIView):
)
updated_cycles = []
update_cycle_issue_activity = []
for cycle_issue in cycle_issues:
cycle_issue.cycle_id = new_cycle_id
updated_cycles.append(cycle_issue)
update_cycle_issue_activity.append(
{
"old_cycle_id": str(cycle_id),
"new_cycle_id": str(new_cycle_id),
"issue_id": str(cycle_issue.issue_id),
}
)
cycle_issues = CycleIssue.objects.bulk_update(
updated_cycles, ["cycle_id"], batch_size=100
)
# Capture Issue Activity
issue_activity.delay(
type="cycle.activity.created",
requested_data=json.dumps({"cycles_list": []}),
actor_id=str(self.request.user.id),
issue_id=None,
project_id=str(self.kwargs.get("project_id", None)),
current_instance=json.dumps(
{
"updated_cycle_issues": update_cycle_issue_activity,
"created_cycle_issues": "[]",
}
),
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
return Response({"message": "Success"}, status=status.HTTP_200_OK)
@@ -542,7 +542,6 @@ def dashboard_recent_collaborators(self, request, slug):
project__project_projectmember__member=request.user,
project__project_projectmember__is_active=True,
project__archived_at__isnull=True,
is_active=True,
)
.annotate(
num_activities=Coalesce(
+1 -3
View File
@@ -41,7 +41,6 @@ class ExportIssuesEndpoint(BaseAPIView):
project=project_ids,
initiated_by=request.user,
provider=provider,
type="issue_exports",
)
issue_export_task.delay(
@@ -66,8 +65,7 @@ class ExportIssuesEndpoint(BaseAPIView):
def get(self, request, slug):
exporter_history = ExporterHistory.objects.filter(
workspace__slug=slug,
type="issue_exports",
workspace__slug=slug
).select_related("workspace", "initiated_by")
if request.GET.get("per_page", False) and request.GET.get(
+7 -38
View File
@@ -32,7 +32,7 @@ from plane.app.permissions import (
from plane.app.serializers import (
IssueCreateSerializer,
IssueDetailSerializer,
IssueUserPropertySerializer,
IssuePropertySerializer,
IssueSerializer,
)
from plane.bgtasks.issue_activites_task import issue_activity
@@ -40,7 +40,7 @@ from plane.db.models import (
Issue,
IssueAttachment,
IssueLink,
IssueUserProperty,
IssueProperty,
IssueReaction,
IssueSubscriber,
Project,
@@ -481,38 +481,7 @@ class IssueViewSet(BaseViewSet):
return Response(serializer.data, status=status.HTTP_200_OK)
def partial_update(self, request, slug, project_id, pk=None):
issue = (
self.get_queryset()
.annotate(
label_ids=Coalesce(
ArrayAgg(
"labels__id",
distinct=True,
filter=~Q(labels__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
assignee_ids=Coalesce(
ArrayAgg(
"assignees__id",
distinct=True,
filter=~Q(assignees__id__isnull=True)
& Q(assignees__member_project__is_active=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
module_ids=Coalesce(
ArrayAgg(
"issue_module__module_id",
distinct=True,
filter=~Q(issue_module__module_id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
)
.filter(pk=pk)
.first()
)
issue = self.get_queryset().filter(pk=pk).first()
if not issue:
return Response(
@@ -570,7 +539,7 @@ class IssueUserDisplayPropertyEndpoint(BaseAPIView):
]
def patch(self, request, slug, project_id):
issue_property = IssueUserProperty.objects.get(
issue_property = IssueProperty.objects.get(
user=request.user,
project_id=project_id,
)
@@ -585,14 +554,14 @@ class IssueUserDisplayPropertyEndpoint(BaseAPIView):
"display_properties", issue_property.display_properties
)
issue_property.save()
serializer = IssueUserPropertySerializer(issue_property)
serializer = IssuePropertySerializer(issue_property)
return Response(serializer.data, status=status.HTTP_201_CREATED)
def get(self, request, slug, project_id):
issue_property, _ = IssueUserProperty.objects.get_or_create(
issue_property, _ = IssueProperty.objects.get_or_create(
user=request.user, project_id=project_id
)
serializer = IssueUserPropertySerializer(issue_property)
serializer = IssuePropertySerializer(issue_property)
return Response(serializer.data, status=status.HTTP_200_OK)
+53 -131
View File
@@ -3,11 +3,8 @@ import json
# Django imports
from django.utils import timezone
from django.db.models import Q, OuterRef, F, Func, UUIDField, Value, CharField
from django.db.models import Q
from django.core.serializers.json import DjangoJSONEncoder
from django.db.models.functions import Coalesce
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
# Third Party imports
from rest_framework.response import Response
@@ -23,9 +20,6 @@ from plane.app.permissions import ProjectEntityPermission
from plane.db.models import (
Project,
IssueRelation,
Issue,
IssueAttachment,
IssueLink,
)
from plane.bgtasks.issue_activites_task import issue_activity
@@ -37,6 +31,24 @@ class IssueRelationViewSet(BaseViewSet):
ProjectEntityPermission,
]
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,
project__project_projectmember__is_active=True,
project__archived_at__isnull=True,
)
.select_related("project")
.select_related("workspace")
.select_related("issue")
.distinct()
)
def list(self, request, slug, project_id, issue_id):
issue_relations = (
IssueRelation.objects.filter(
@@ -49,146 +61,56 @@ class IssueRelationViewSet(BaseViewSet):
.order_by("-created_at")
.distinct()
)
# get all blocking issues
blocking_issues = issue_relations.filter(
relation_type="blocked_by", related_issue_id=issue_id
).values_list("issue_id", flat=True)
# get all blocked by issues
)
blocked_by_issues = issue_relations.filter(
relation_type="blocked_by", issue_id=issue_id
).values_list("related_issue_id", flat=True)
# get all duplicate issues
)
duplicate_issues = issue_relations.filter(
issue_id=issue_id, relation_type="duplicate"
).values_list("related_issue_id", flat=True)
# get all relates to issues
)
duplicate_issues_related = issue_relations.filter(
related_issue_id=issue_id, relation_type="duplicate"
).values_list("issue_id", flat=True)
# get all relates to issues
)
relates_to_issues = issue_relations.filter(
issue_id=issue_id, relation_type="relates_to"
).values_list("related_issue_id", flat=True)
# get all relates to issues
)
relates_to_issues_related = issue_relations.filter(
related_issue_id=issue_id, relation_type="relates_to"
).values_list("issue_id", flat=True)
)
queryset = (
Issue.issue_objects
.filter(workspace__slug=slug)
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
attachment_count=IssueAttachment.objects.filter(
issue=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
sub_issues_count=Issue.issue_objects.filter(
parent=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
label_ids=Coalesce(
ArrayAgg(
"labels__id",
distinct=True,
filter=~Q(labels__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
assignee_ids=Coalesce(
ArrayAgg(
"assignees__id",
distinct=True,
filter=~Q(assignees__id__isnull=True)
& Q(assignees__member_project__is_active=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
)
).distinct()
blocked_by_issues_serialized = IssueRelationSerializer(
blocked_by_issues, many=True
).data
duplicate_issues_serialized = IssueRelationSerializer(
duplicate_issues, many=True
).data
relates_to_issues_serialized = IssueRelationSerializer(
relates_to_issues, many=True
).data
# Fields
fields = [
"id",
"name",
"state_id",
"sort_order",
"priority",
"sequence_id",
"project_id",
"label_ids",
"assignee_ids",
"created_at",
"updated_at",
"created_by",
"updated_by",
"relation_type",
]
# revere relation for blocked by issues
blocking_issues_serialized = RelatedIssueSerializer(
blocking_issues, many=True
).data
# reverse relation for duplicate issues
duplicate_issues_related_serialized = RelatedIssueSerializer(
duplicate_issues_related, many=True
).data
# reverse relation for related issues
relates_to_issues_related_serialized = RelatedIssueSerializer(
relates_to_issues_related, many=True
).data
response_data = {
"blocking": queryset.filter(pk__in=blocking_issues)
.annotate(
relation_type=Value("blocking", output_field=CharField())
)
.values(*fields),
"blocked_by": queryset.filter(pk__in=blocked_by_issues)
.annotate(
relation_type=Value("blocked_by", output_field=CharField())
)
.values(*fields),
"duplicate": queryset.filter(pk__in=duplicate_issues)
.annotate(
relation_type=Value(
"duplicate",
output_field=CharField(),
)
)
.values(*fields)
| queryset.filter(pk__in=duplicate_issues_related)
.annotate(
relation_type=Value(
"duplicate",
output_field=CharField(),
)
)
.values(*fields),
"relates_to": queryset.filter(pk__in=relates_to_issues)
.annotate(
relation_type=Value(
"relates_to",
output_field=CharField(),
)
)
.values(*fields)
| queryset.filter(pk__in=relates_to_issues_related)
.annotate(
relation_type=Value(
"relates_to",
output_field=CharField(),
)
)
.values(*fields),
"blocking": blocking_issues_serialized,
"blocked_by": blocked_by_issues_serialized,
"duplicate": duplicate_issues_serialized
+ duplicate_issues_related_serialized,
"relates_to": relates_to_issues_serialized
+ relates_to_issues_related_serialized,
}
return Response(response_data, status=status.HTTP_200_OK)
+3 -34
View File
@@ -1,5 +1,5 @@
# Django imports
from django.db.models import Exists, OuterRef, Q, Case, When, BooleanField
from django.db.models import Exists, OuterRef, Q
from django.utils import timezone
# Third party imports
@@ -45,7 +45,6 @@ class NotificationViewSet(BaseViewSet, BasePaginator):
archived = request.GET.get("archived", "false")
read = request.GET.get("read", None)
type = request.GET.get("type", "all")
mentioned = request.GET.get("mentioned", False)
q_filters = Q()
inbox_issue = Issue.objects.filter(
@@ -60,13 +59,6 @@ class NotificationViewSet(BaseViewSet, BasePaginator):
)
.filter(entity_name="issue")
.annotate(is_inbox_issue=Exists(inbox_issue))
.annotate(
is_mentioned_notification=Case(
When(sender__icontains="mentioned", then=True),
default=False,
output_field=BooleanField(),
)
)
.select_related("workspace", "project", "triggered_by", "receiver")
.order_by("snoozed_till", "-created_at")
)
@@ -94,13 +86,6 @@ class NotificationViewSet(BaseViewSet, BasePaginator):
if read == "true":
notifications = notifications.filter(read_at__isnull=False)
if mentioned:
notifications = notifications.filter(sender__icontains="mentioned")
else:
notifications = notifications.exclude(
sender__icontains="mentioned"
)
type = type.split(",")
# Subscribed issues
if "subscribed" in type:
@@ -225,35 +210,19 @@ class NotificationViewSet(BaseViewSet, BasePaginator):
class UnreadNotificationEndpoint(BaseAPIView):
def get(self, request, slug):
# Watching Issues Count
unread_notifications_count = (
Notification.objects.filter(
workspace__slug=slug,
receiver_id=request.user.id,
read_at__isnull=True,
archived_at__isnull=True,
snoozed_till__isnull=True,
)
.exclude(sender__icontains="mentioned")
.count()
)
mention_notifications_count = Notification.objects.filter(
unread_notifications_count = Notification.objects.filter(
workspace__slug=slug,
receiver_id=request.user.id,
read_at__isnull=True,
archived_at__isnull=True,
snoozed_till__isnull=True,
sender__icontains="mentioned",
).count()
return Response(
{
"total_unread_notifications_count": int(
unread_notifications_count
),
"mention_unread_notifications_count": int(
mention_notifications_count
),
)
},
status=status.HTTP_200_OK,
)
+2 -59
View File
@@ -38,7 +38,6 @@ 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):
@@ -216,14 +215,8 @@ class PageViewSet(BaseViewSet):
status=status.HTTP_404_NOT_FOUND,
)
else:
issue_ids = PageLog.objects.filter(
page_id=pk, entity_name="issue"
).values_list("entity_identifier", flat=True)
data = PageDetailSerializer(page).data
data["issue_ids"] = issue_ids
return Response(
data,
status=status.HTTP_200_OK,
PageDetailSerializer(page).data, status=status.HTTP_200_OK
)
def lock(self, request, slug, project_id, pk):
@@ -245,28 +238,6 @@ class PageViewSet(BaseViewSet):
return Response(status=status.HTTP_204_NO_CONTENT)
def access(self, request, slug, project_id, pk):
access = request.data.get("access", 0)
page = Page.objects.filter(
pk=pk, workspace__slug=slug, projects__id=project_id
).first()
# Only update access if the page owner is the requesting user
if (
page.access != request.data.get("access", page.access)
and page.owned_by_id != request.user.id
):
return Response(
{
"error": "Access cannot be updated since this page is owned by someone else"
},
status=status.HTTP_400_BAD_REQUEST,
)
page.access = access
page.save()
return Response(status=status.HTTP_204_NO_CONTENT)
def list(self, request, slug, project_id):
queryset = self.get_queryset()
pages = PageSerializer(queryset, many=True).data
@@ -492,12 +463,6 @@ class PagesDescriptionViewSet(BaseViewSet):
.first()
)
if page is None:
return Response(
{"error": "Page not found"},
status=404,
)
if page.is_locked:
return Response(
{"error": "Page is locked"},
@@ -510,38 +475,16 @@ 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"})
-37
View File
@@ -1,37 +0,0 @@
# 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)
+3 -4
View File
@@ -39,7 +39,7 @@ from plane.db.models import (
Cycle,
Inbox,
DeployBoard,
IssueUserProperty,
IssueProperty,
Issue,
Module,
Project,
@@ -266,7 +266,7 @@ class ProjectViewSet(BaseViewSet):
role=20,
)
# Also create the issue property for the user
_ = IssueUserProperty.objects.create(
_ = IssueProperty.objects.create(
project_id=serializer.data["id"],
user=request.user,
)
@@ -280,7 +280,7 @@ class ProjectViewSet(BaseViewSet):
role=20,
)
# Also create the issue property for the user
IssueUserProperty.objects.create(
IssueProperty.objects.create(
project_id=serializer.data["id"],
user_id=serializer.data["project_lead"],
)
@@ -342,7 +342,6 @@ class ProjectViewSet(BaseViewSet):
.first()
)
# Create the model activity
model_activity.delay(
model_name="project",
model_id=str(project.id),
+3 -3
View File
@@ -25,7 +25,7 @@ from plane.db.models import (
ProjectMemberInvite,
User,
WorkspaceMember,
IssueUserProperty,
IssueProperty,
)
@@ -179,9 +179,9 @@ class UserProjectInvitationsViewset(BaseViewSet):
ignore_conflicts=True,
)
IssueUserProperty.objects.bulk_create(
IssueProperty.objects.bulk_create(
[
IssueUserProperty(
IssueProperty(
project_id=project_id,
user=request.user,
workspace=workspace,
+5 -5
View File
@@ -22,7 +22,7 @@ from plane.db.models import (
ProjectMember,
Workspace,
TeamMember,
IssueUserProperty,
IssueProperty,
)
from plane.bgtasks.project_add_user_email_task import project_add_user_email
from plane.utils.host import base_host
@@ -136,7 +136,7 @@ class ProjectMemberViewSet(BaseViewSet):
)
# Create a new issue property
bulk_issue_props.append(
IssueUserProperty(
IssueProperty(
user_id=member.get("member_id"),
project_id=project_id,
workspace_id=project.workspace_id,
@@ -150,7 +150,7 @@ class ProjectMemberViewSet(BaseViewSet):
ignore_conflicts=True,
)
_ = IssueUserProperty.objects.bulk_create(
_ = IssueProperty.objects.bulk_create(
bulk_issue_props, batch_size=10, ignore_conflicts=True
)
@@ -323,7 +323,7 @@ class AddTeamToProjectEndpoint(BaseAPIView):
)
)
issue_props.append(
IssueUserProperty(
IssueProperty(
project_id=project_id,
user_id=member,
workspace=workspace,
@@ -335,7 +335,7 @@ class AddTeamToProjectEndpoint(BaseAPIView):
project_members, batch_size=10, ignore_conflicts=True
)
_ = IssueUserProperty.objects.bulk_create(
_ = IssueProperty.objects.bulk_create(
issue_props, batch_size=10, ignore_conflicts=True
)
@@ -1,4 +1,5 @@
# Python imports
import re
# Django imports
from django.db.models import Q
@@ -10,7 +11,13 @@ 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
-12
View File
@@ -37,9 +37,6 @@ from plane.utils.paginator import BasePaginator
from plane.authentication.utils.host import user_ip
from plane.bgtasks.user_deactivation_email_task import user_deactivation_email
from plane.utils.host import base_host
from django.utils.decorators import method_decorator
from django.views.decorators.cache import cache_control
from django.views.decorators.vary import vary_on_cookie
class UserEndpoint(BaseViewSet):
@@ -50,8 +47,6 @@ class UserEndpoint(BaseViewSet):
return self.request.user
@cache_response(60 * 60)
@method_decorator(cache_control(private=True, max_age=12))
@method_decorator(vary_on_cookie)
def retrieve(self, request):
serialized_data = UserMeSerializer(request.user).data
return Response(
@@ -60,8 +55,6 @@ class UserEndpoint(BaseViewSet):
)
@cache_response(60 * 60)
@method_decorator(cache_control(private=True, max_age=12))
@method_decorator(vary_on_cookie)
def retrieve_user_settings(self, request):
serialized_data = UserMeSettingsSerializer(request.user).data
return Response(serialized_data, status=status.HTTP_200_OK)
@@ -86,9 +79,6 @@ class UserEndpoint(BaseViewSet):
return super().partial_update(request, *args, **kwargs)
@invalidate_cache(path="/api/users/me/")
@invalidate_cache(
path="/api/users/me/workspaces/", multiple=True, user=False
)
def deactivate(self, request):
# Check all workspace user is active
user = self.get_object()
@@ -298,8 +288,6 @@ class AccountEndpoint(BaseAPIView):
class ProfileEndpoint(BaseAPIView):
@method_decorator(cache_control(private=True, max_age=12))
@method_decorator(vary_on_cookie)
def get(self, request):
profile = Profile.objects.get(user=request.user)
serializer = ProfileSerializer(profile)
@@ -44,9 +44,6 @@ from plane.db.models import (
WorkspaceTheme,
)
from plane.utils.cache import cache_response, invalidate_cache
from django.utils.decorators import method_decorator
from django.views.decorators.cache import cache_control
from django.views.decorators.vary import vary_on_cookie
from plane.utils.constants import RESTRICTED_WORKSPACE_SLUGS
@@ -175,8 +172,6 @@ class UserWorkSpacesEndpoint(BaseAPIView):
]
@cache_response(60 * 60 * 2)
@method_decorator(cache_control(private=True, max_age=12))
@method_decorator(vary_on_cookie)
def get(self, request):
fields = [
field
@@ -3,7 +3,7 @@ from plane.db.models import Profile, Workspace, WorkspaceMemberInvite
def get_redirection_path(user):
# Handle redirections
profile, _ = Profile.objects.get_or_create(user=user)
profile = Profile.objects.get(user=user)
# Redirect to onboarding if the user is not onboarded yet
if not profile.is_onboarded:
@@ -120,7 +120,7 @@ class MagicSignInEndpoint(View):
callback=post_user_auth_workflow,
)
user = provider.authenticate()
profile, _ = Profile.objects.get_or_create(user=user)
profile = Profile.objects.get(user=user)
# Login the user and record his device info
user_login(request=request, user=user, is_app=True)
if user.is_password_autoset and profile.is_onboarded:
+12 -18
View File
@@ -582,18 +582,17 @@ def create_issue_activity(
issue_activities,
epoch,
):
issue = Issue.objects.get(pk=issue_id)
issue_activity = IssueActivity.objects.create(
issue_id=issue_id,
project_id=project_id,
workspace_id=workspace_id,
comment="created the issue",
verb="created",
actor_id=actor_id,
epoch=epoch,
issue_activities.append(
IssueActivity(
issue_id=issue_id,
project_id=project_id,
workspace_id=workspace_id,
comment="created the issue",
verb="created",
actor_id=actor_id,
epoch=epoch,
)
)
issue_activity.created_at = issue.created_at
issue_activity.save(update_fields=["created_at"])
requested_data = (
json.loads(requested_data) if requested_data is not None else None
)
@@ -1392,7 +1391,6 @@ def create_issue_relation_activity(
workspace_id=workspace_id,
comment=f"added {requested_data.get('relation_type')} relation",
old_identifier=related_issue,
epoch=epoch,
)
)
issue = Issue.objects.get(pk=issue_id)
@@ -1718,16 +1716,12 @@ def issue_activity(
event=(
"issue_comment"
if activity.field == "comment"
else "inbox_issue"
if inbox
else "issue"
else "inbox_issue" if inbox else "issue"
),
event_id=(
activity.issue_comment_id
if activity.field == "comment"
else inbox
if inbox
else activity.issue_id
else inbox if inbox else activity.issue_id
),
verb=activity.verb,
field=(
@@ -1,44 +0,0 @@
# 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,
owned_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
@@ -161,7 +161,7 @@ class Migration(migrations.Migration):
options={
"verbose_name": "Workspace User Property",
"verbose_name_plural": "Workspace User Property",
"db_table": "workspace_user_properties",
"db_table": "Workspace_user_properties",
"ordering": ("-created_at",),
"unique_together": {("workspace", "user")},
},
@@ -1,106 +0,0 @@
# Generated by Django 4.2.11 on 2024-07-10 13:59
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
import uuid
class Migration(migrations.Migration):
dependencies = [
('db', '0069_alter_account_provider_and_more'),
]
operations = [
migrations.AddField(
model_name='apitoken',
name='is_service',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='exporterhistory',
name='filters',
field=models.JSONField(blank=True, null=True),
),
migrations.AddField(
model_name='exporterhistory',
name='name',
field=models.CharField(blank=True, max_length=255, null=True, verbose_name='Exporter Name'),
),
migrations.AddField(
model_name='exporterhistory',
name='type',
field=models.CharField(choices=[('issue_exports', 'Issue Exports'), ('issue_worklogs', 'Issue Worklogs')], default='issue_exports', max_length=50),
),
migrations.AddField(
model_name='project',
name='is_time_tracking_enabled',
field=models.BooleanField(default=False),
),
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.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)),
('description_json', models.JSONField(blank=True, default=dict)),
('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')),
('owned_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.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)),
('is_default', models.BooleanField(default=True)),
('weight', models.PositiveIntegerField(default=0)),
('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')),
],
options={
'verbose_name': 'Issue Type',
'verbose_name_plural': 'Issue Types',
'db_table': 'issue_types',
'ordering': ('sort_order',),
'unique_together': {('project', 'name')},
},
),
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'),
),
]
@@ -1,44 +0,0 @@
# Generated by Django 4.2.11 on 2024-07-15 06:07
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("db", "0070_apitoken_is_service_exporterhistory_filters_and_more"),
]
operations = [
migrations.RenameModel(
old_name="IssueProperty",
new_name="IssueUserProperty",
),
migrations.AlterModelOptions(
name="issueuserproperty",
options={
"ordering": ("-created_at",),
"verbose_name": "Issue User Property",
"verbose_name_plural": "Issue User Properties",
},
),
migrations.AlterModelTable(
name="issueuserproperty",
table="issue_user_properties",
),
migrations.AddField(
model_name="issuetype",
name="is_active",
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name="project",
name="is_issue_type_enabled",
field=models.BooleanField(default=False),
),
migrations.AlterField(
model_name="issuetype",
name="is_default",
field=models.BooleanField(default=False),
),
]
@@ -1,145 +0,0 @@
# Generated by Django 4.2.14 on 2024-07-22 13:22
from django.db import migrations, models
from django.conf import settings
import django.db.models.deletion
import uuid
class Migration(migrations.Migration):
dependencies = [
("db", "0071_rename_issueproperty_issueuserproperty_and_more"),
]
operations = [
migrations.AddField(
model_name="issueattachment",
name="external_id",
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
model_name="issueattachment",
name="external_source",
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.CreateModel(
name="UserRecentVisit",
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,
),
),
("entity_identifier", models.UUIDField(null=True)),
(
"entity_name",
models.CharField(
choices=[
("VIEW", "View"),
("PAGE", "Page"),
("ISSUE", "Issue"),
("CYCLE", "Cycle"),
("MODULE", "Module"),
("PROJECT", "Project"),
],
max_length=30,
),
),
("visited_at", models.DateTimeField(auto_now=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",
),
),
(
"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",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="user_recent_visit",
to=settings.AUTH_USER_MODEL,
),
),
(
"workspace",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="workspace_%(class)s",
to="db.workspace",
),
),
],
options={
"verbose_name": "User Recent Visit",
"verbose_name_plural": "User Recent Visits",
"db_table": "user_recent_visits",
"ordering": ("-created_at",),
},
),
migrations.RemoveField(
model_name="project",
name="start_date",
),
migrations.RemoveField(
model_name="project",
name="target_date",
),
migrations.AlterField(
model_name="issuesequence",
name="sequence",
field=models.PositiveBigIntegerField(db_index=True, default=1),
),
migrations.AlterField(
model_name="project",
name="identifier",
field=models.CharField(
db_index=True, max_length=12, verbose_name="Project Identifier"
),
),
migrations.AlterField(
model_name="projectidentifier",
name="name",
field=models.CharField(db_index=True, max_length=12),
),
]
+2 -13
View File
@@ -29,7 +29,7 @@ from .issue import (
IssueLabel,
IssueLink,
IssueMention,
IssueUserProperty,
IssueProperty,
IssueReaction,
IssueRelation,
IssueSequence,
@@ -50,14 +50,7 @@ from .notification import (
Notification,
UserNotificationPreference,
)
from .page import (
Page,
PageFavorite,
PageLabel,
PageLog,
ProjectPage,
PageVersion,
)
from .page import Page, PageFavorite, PageLabel, PageLog, ProjectPage
from .project import (
Project,
ProjectBaseModel,
@@ -108,7 +101,3 @@ from .webhook import Webhook, WebhookLog
from .dashboard import Dashboard, DashboardWidget, Widget
from .favorite import UserFavorite
from .issue_type import IssueType
from .recent_visit import UserRecentVisit
-1
View File
@@ -44,7 +44,6 @@ 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"
-12
View File
@@ -18,17 +18,6 @@ def generate_token():
class ExporterHistory(BaseModel):
name = models.CharField(
max_length=255, verbose_name="Exporter Name", null=True, blank=True
)
type = models.CharField(
max_length=50,
default="issue_exports",
choices=(
("issue_exports", "Issue Exports"),
("issue_worklogs", "Issue Worklogs"),
),
)
workspace = models.ForeignKey(
"db.WorkSpace",
on_delete=models.CASCADE,
@@ -66,7 +55,6 @@ class ExporterHistory(BaseModel):
on_delete=models.CASCADE,
related_name="workspace_exporters",
)
filters = models.JSONField(blank=True, null=True)
class Meta:
verbose_name = "Exporter"
+45 -52
View File
@@ -6,7 +6,9 @@ from django.conf import settings
from django.contrib.postgres.fields import ArrayField
from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models, transaction
from django.db import models
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.utils import timezone
# Module imports
@@ -162,13 +164,6 @@ 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()
@@ -180,6 +175,7 @@ class Issue(ProjectBaseModel):
ordering = ("-created_at",)
def save(self, *args, **kwargs):
# This means that the model isn't saved to the database yet
if self.state is None:
try:
from plane.db.models import State
@@ -189,6 +185,7 @@ class Issue(ProjectBaseModel):
project=self.project,
default=True,
).first()
# if there is no default state assign any random state
if default_state is None:
random_state = State.objects.filter(
~models.Q(is_triage=True), project=self.project
@@ -202,6 +199,7 @@ class Issue(ProjectBaseModel):
try:
from plane.db.models import State
# Check if the current issue state group is completed or not
if self.state.group == "completed":
self.completed_at = timezone.now()
else:
@@ -210,44 +208,30 @@ class Issue(ProjectBaseModel):
pass
if self._state.adding:
with transaction.atomic():
last_sequence = (
IssueSequence.objects.filter(project=self.project)
.select_for_update()
.aggregate(largest=models.Max("sequence"))["largest"]
)
self.sequence_id = last_sequence + 1 if last_sequence else 1
# 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)
)
largest_sort_order = Issue.objects.filter(
project=self.project, state=self.state
).aggregate(largest=models.Max("sort_order"))["largest"]
if largest_sort_order is not None:
self.sort_order = largest_sort_order + 10000
# Get the maximum display_id value from the database
last_id = IssueSequence.objects.filter(
project=self.project
).aggregate(largest=models.Max("sequence"))["largest"]
# aggregate can return None! Check it first.
# If it isn't none, just use the last ID specified (which should be the greatest) and add one to it
if last_id:
self.sequence_id = last_id + 1
else:
self.sequence_id = 1
super(Issue, self).save(*args, **kwargs)
largest_sort_order = Issue.objects.filter(
project=self.project, state=self.state
).aggregate(largest=models.Max("sort_order"))["largest"]
if largest_sort_order is not None:
self.sort_order = largest_sort_order + 10000
IssueSequence.objects.create(
issue=self, sequence=self.sequence_id, project=self.project
)
else:
# 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(Issue, self).save(*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(Issue, self).save(*args, **kwargs)
def __str__(self):
"""Return name of the issue"""
@@ -384,8 +368,6 @@ class IssueAttachment(ProjectBaseModel):
issue = models.ForeignKey(
"db.Issue", on_delete=models.CASCADE, related_name="issue_attachment"
)
external_source = models.CharField(max_length=255, null=True, blank=True)
external_id = models.CharField(max_length=255, blank=True, null=True)
class Meta:
verbose_name = "Issue Attachment"
@@ -493,7 +475,7 @@ class IssueComment(ProjectBaseModel):
return str(self.issue)
class IssueUserProperty(ProjectBaseModel):
class IssueProperty(ProjectBaseModel):
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
@@ -506,9 +488,9 @@ class IssueUserProperty(ProjectBaseModel):
)
class Meta:
verbose_name = "Issue User Property"
verbose_name_plural = "Issue User Properties"
db_table = "issue_user_properties"
verbose_name = "Issue Property"
verbose_name_plural = "Issue Properties"
db_table = "issue_properties"
ordering = ("-created_at",)
unique_together = ["user", "project"]
@@ -578,9 +560,9 @@ class IssueSequence(ProjectBaseModel):
Issue,
on_delete=models.SET_NULL,
related_name="issue_sequence",
null=True, # This is set to null because we want to keep the sequence even if the issue is deleted
null=True,
)
sequence = models.PositiveBigIntegerField(default=1, db_index=True)
sequence = models.PositiveBigIntegerField(default=1)
deleted = models.BooleanField(default=False)
class Meta:
@@ -686,3 +668,14 @@ class IssueVote(ProjectBaseModel):
def __str__(self):
return f"{self.issue.name} {self.actor.email}"
# TODO: Find a better method to save the model
@receiver(post_save, sender=Issue)
def create_issue_sequence(sender, instance, created, **kwargs):
if created:
IssueSequence.objects.create(
issue=instance,
sequence=instance.sequence_id,
project=instance.project,
)
-37
View File
@@ -1,37 +0,0 @@
# 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=False)
weight = models.PositiveIntegerField(default=0)
is_active = models.BooleanField(default=True)
class Meta:
unique_together = ["project", "name"]
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)
+1 -51
View File
@@ -1,7 +1,6 @@
import uuid
from django.conf import settings
from django.utils import timezone
# Django imports
from django.db import models
@@ -67,15 +66,6 @@ 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 = (
@@ -103,9 +93,7 @@ class PageLog(BaseModel):
verbose_name="Transaction Type",
)
workspace = models.ForeignKey(
"db.Workspace",
on_delete=models.CASCADE,
related_name="workspace_page_log",
"db.Workspace", on_delete=models.CASCADE, related_name="workspace_page_log"
)
class Meta:
@@ -259,41 +247,3 @@ 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)
owned_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)
description_json = models.JSONField(default=dict, blank=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)
+1 -4
View File
@@ -72,7 +72,6 @@ class Project(BaseModel):
identifier = models.CharField(
max_length=12,
verbose_name="Project Identifier",
db_index=True,
)
default_assignee = models.ForeignKey(
settings.AUTH_USER_MODEL,
@@ -95,8 +94,6 @@ class Project(BaseModel):
issue_views_view = models.BooleanField(default=True)
page_view = models.BooleanField(default=True)
inbox_view = models.BooleanField(default=False)
is_time_tracking_enabled = models.BooleanField(default=False)
is_issue_type_enabled = models.BooleanField(default=False)
cover_image = models.URLField(blank=True, null=True, max_length=800)
estimate = models.ForeignKey(
"db.Estimate",
@@ -220,7 +217,7 @@ class ProjectIdentifier(AuditModel):
project = models.OneToOneField(
Project, on_delete=models.CASCADE, related_name="project_identifier"
)
name = models.CharField(max_length=12, db_index=True)
name = models.CharField(max_length=12)
class Meta:
unique_together = ["name", "workspace"]
-38
View File
@@ -1,38 +0,0 @@
# Django imports
from django.db import models
from django.conf import settings
# Module imports
from .workspace import WorkspaceBaseModel
class EntityNameEnum(models.TextChoices):
VIEW = "VIEW", "View"
PAGE = "PAGE", "Page"
ISSUE = "ISSUE", "Issue"
CYCLE = "CYCLE", "Cycle"
MODULE = "MODULE", "Module"
PROJECT = "PROJECT", "Project"
class UserRecentVisit(WorkspaceBaseModel):
entity_identifier = models.UUIDField(null=True)
entity_name = models.CharField(
max_length=30,
choices=EntityNameEnum.choices,
)
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="user_recent_visit",
)
visited_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = "User Recent Visit"
verbose_name_plural = "User Recent Visits"
db_table = "user_recent_visits"
ordering = ("-created_at",)
def __str__(self):
return f"{self.entity_name} {self.user.email}"
-21
View File
@@ -6,7 +6,6 @@ from django.db import models
from .base import BaseModel
from .project import ProjectBaseModel
from .workspace import WorkspaceBaseModel
from plane.utils.issue_filters import issue_filters
def get_default_filters():
@@ -117,26 +116,6 @@ class IssueView(WorkspaceBaseModel):
db_table = "issue_views"
ordering = ("-created_at",)
def save(self, *args, **kwargs):
query_params = self.filters
self.query = (
issue_filters(query_params, "POST") if query_params else {}
)
if self._state.adding:
if self.project:
largest_sort_order = IssueView.objects.filter(
project=self.project
).aggregate(largest=models.Max("sort_order"))["largest"]
else:
largest_sort_order = IssueView.objects.filter(
workspace=self.workspace, project__isnull=True
).aggregate(largest=models.Max("sort_order"))["largest"]
if largest_sort_order is not None:
self.sort_order = largest_sort_order + 10000
super(IssueView, self).save(*args, **kwargs)
def __str__(self):
"""Return name of the View"""
return f"{self.name} <{self.project.name}>"
@@ -23,8 +23,6 @@ from plane.license.utils.instance_value import (
get_configuration_value,
)
from plane.utils.cache import cache_response, invalidate_cache
from django.utils.decorators import method_decorator
from django.views.decorators.cache import cache_control
class InstanceEndpoint(BaseAPIView):
@@ -38,7 +36,6 @@ class InstanceEndpoint(BaseAPIView):
]
@cache_response(60 * 60 * 2, user=False)
@method_decorator(cache_control(private=True, max_age=12))
def get(self, request):
instance = Instance.objects.first()
+1 -1
View File
@@ -294,7 +294,7 @@ if bool(os.environ.get("SENTRY_DSN", False)) and os.environ.get(
send_default_pii=True,
environment=os.environ.get("SENTRY_ENVIRONMENT", "development"),
profiles_sample_rate=float(
os.environ.get("SENTRY_PROFILE_SAMPLE_RATE", 0)
os.environ.get("SENTRY_PROFILE_SAMPLE_RATE", 0.5)
),
)
+1 -5
View File
@@ -1,9 +1,5 @@
from .user import UserLiteSerializer
from .issue import (
LabelLiteSerializer,
StateLiteSerializer,
IssuePublicSerializer,
)
from .issue import LabelLiteSerializer, StateLiteSerializer
from .state import StateSerializer, StateLiteSerializer
+23 -23
View File
@@ -188,16 +188,11 @@ class IssueAttachmentSerializer(BaseSerializer):
class IssueReactionSerializer(BaseSerializer):
actor_detail = UserLiteSerializer(read_only=True, source="actor")
class Meta:
model = IssueReaction
fields = [
"issue",
"reaction",
"workspace",
"project",
"actor",
]
fields = "__all__"
read_only_fields = [
"workspace",
"project",
@@ -459,6 +454,20 @@ class IssueCreateSerializer(BaseSerializer):
return super().update(instance, validated_data)
class IssueReactionSerializer(BaseSerializer):
actor_detail = UserLiteSerializer(read_only=True, source="actor")
class Meta:
model = IssueReaction
fields = "__all__"
read_only_fields = [
"workspace",
"project",
"issue",
"actor",
]
class CommentReactionSerializer(BaseSerializer):
class Meta:
model = CommentReaction
@@ -467,6 +476,7 @@ class CommentReactionSerializer(BaseSerializer):
class IssueVoteSerializer(BaseSerializer):
actor_detail = UserLiteSerializer(read_only=True, source="actor")
class Meta:
model = IssueVote
@@ -476,45 +486,35 @@ class IssueVoteSerializer(BaseSerializer):
"workspace",
"project",
"actor",
"actor_detail",
]
read_only_fields = fields
class IssuePublicSerializer(BaseSerializer):
project_detail = ProjectLiteSerializer(read_only=True, source="project")
state_detail = StateLiteSerializer(read_only=True, source="state")
reactions = IssueReactionSerializer(
read_only=True, many=True, source="issue_reactions"
)
votes = IssueVoteSerializer(read_only=True, many=True)
module_ids = serializers.ListField(
child=serializers.UUIDField(),
required=False,
)
label_ids = serializers.ListField(
child=serializers.UUIDField(),
required=False,
)
assignee_ids = serializers.ListField(
child=serializers.UUIDField(),
required=False,
)
class Meta:
model = Issue
fields = [
"id",
"name",
"description_html",
"sequence_id",
"state",
"state_detail",
"project",
"project_detail",
"workspace",
"priority",
"target_date",
"reactions",
"votes",
"module_ids",
"created_by",
"label_ids",
"assignee_ids",
]
read_only_fields = fields
+12
View File
@@ -3,6 +3,7 @@ from django.urls import path
from plane.space.views import (
InboxIssuePublicViewSet,
IssueVotePublicViewSet,
WorkspaceProjectDeployBoardEndpoint,
)
@@ -29,6 +30,17 @@ urlpatterns = [
),
name="inbox-issue",
),
path(
"anchor/<str:anchor>/issues/<uuid:issue_id>/votes/",
IssueVotePublicViewSet.as_view(
{
"get": "list",
"post": "create",
"delete": "destroy",
}
),
name="issue-vote-project-board",
),
path(
"workspaces/<str:slug>/project-boards/",
WorkspaceProjectDeployBoardEndpoint.as_view(),
-12
View File
@@ -6,7 +6,6 @@ from plane.space.views import (
IssueCommentPublicViewSet,
IssueReactionPublicViewSet,
CommentReactionPublicViewSet,
IssueVotePublicViewSet,
)
urlpatterns = [
@@ -74,15 +73,4 @@ urlpatterns = [
),
name="comment-reactions-project-board",
),
path(
"anchor/<str:anchor>/issues/<uuid:issue_id>/votes/",
IssueVotePublicViewSet.as_view(
{
"get": "list",
"post": "create",
"delete": "destroy",
}
),
name="issue-vote-project-board",
),
]
-30
View File
@@ -5,11 +5,6 @@ from plane.space.views import (
ProjectDeployBoardPublicSettingsEndpoint,
ProjectIssuesPublicEndpoint,
WorkspaceProjectAnchorEndpoint,
ProjectCyclesEndpoint,
ProjectModulesEndpoint,
ProjectStatesEndpoint,
ProjectLabelsEndpoint,
ProjectMembersEndpoint,
)
urlpatterns = [
@@ -28,29 +23,4 @@ urlpatterns = [
WorkspaceProjectAnchorEndpoint.as_view(),
name="project-deploy-board",
),
path(
"anchor/<str:anchor>/cycles/",
ProjectCyclesEndpoint.as_view(),
name="project-cycles",
),
path(
"anchor/<str:anchor>/modules/",
ProjectModulesEndpoint.as_view(),
name="project-modules",
),
path(
"anchor/<str:anchor>/states/",
ProjectStatesEndpoint.as_view(),
name="project-states",
),
path(
"anchor/<str:anchor>/labels/",
ProjectLabelsEndpoint.as_view(),
name="project-labels",
),
path(
"anchor/<str:anchor>/members/",
ProjectMembersEndpoint.as_view(),
name="project-members",
),
]
-248
View File
@@ -1,248 +0,0 @@
# Django imports
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.db.models import Q, UUIDField, Value, F, Case, When, JSONField
from django.db.models.functions import Coalesce, JSONObject
# Module imports
from plane.db.models import (
Cycle,
Issue,
Label,
Module,
Project,
ProjectMember,
State,
WorkspaceMember,
)
def issue_queryset_grouper(queryset, group_by, sub_group_by):
FIELD_MAPPER = {
"label_ids": "labels__id",
"assignee_ids": "assignees__id",
"module_ids": "issue_module__module_id",
}
annotations_map = {
"assignee_ids": ("assignees__id", ~Q(assignees__id__isnull=True)),
"label_ids": ("labels__id", ~Q(labels__id__isnull=True)),
"module_ids": (
"issue_module__module_id",
~Q(issue_module__module_id__isnull=True),
),
}
default_annotations = {
key: Coalesce(
ArrayAgg(
field,
distinct=True,
filter=condition,
),
Value([], output_field=ArrayField(UUIDField())),
)
for key, (field, condition) in annotations_map.items()
if FIELD_MAPPER.get(key) != group_by
or FIELD_MAPPER.get(key) != sub_group_by
}
return queryset.annotate(**default_annotations)
def issue_on_results(issues, group_by, sub_group_by):
FIELD_MAPPER = {
"labels__id": "label_ids",
"assignees__id": "assignee_ids",
"issue_module__module_id": "module_ids",
}
original_list = ["assignee_ids", "label_ids", "module_ids"]
required_fields = [
"id",
"name",
"state_id",
"sort_order",
"estimate_point",
"priority",
"start_date",
"target_date",
"sequence_id",
"project_id",
"parent_id",
"cycle_id",
"created_by",
"state__group",
]
if group_by in FIELD_MAPPER:
original_list.remove(FIELD_MAPPER[group_by])
original_list.append(group_by)
if sub_group_by in FIELD_MAPPER:
original_list.remove(FIELD_MAPPER[sub_group_by])
original_list.append(sub_group_by)
required_fields.extend(original_list)
issues = issues.annotate(
vote_items=ArrayAgg(
Case(
When(
votes__isnull=False,
then=JSONObject(
vote=F("votes__vote"),
actor_details=JSONObject(
id=F("votes__actor__id"),
first_name=F("votes__actor__first_name"),
last_name=F("votes__actor__last_name"),
avatar=F("votes__actor__avatar"),
display_name=F("votes__actor__display_name"),
)
),
),
default=None,
output_field=JSONField(),
),
filter=Case(
When(votes__isnull=False, then=True),
default=False,
output_field=JSONField(),
),
distinct=True,
),
reaction_items=ArrayAgg(
Case(
When(
issue_reactions__isnull=False,
then=JSONObject(
reaction=F("issue_reactions__reaction"),
actor_details=JSONObject(
id=F("issue_reactions__actor__id"),
first_name=F("issue_reactions__actor__first_name"),
last_name=F("issue_reactions__actor__last_name"),
avatar=F("issue_reactions__actor__avatar"),
display_name=F("issue_reactions__actor__display_name"),
),
),
),
default=None,
output_field=JSONField(),
),
filter=Case(
When(issue_reactions__isnull=False, then=True),
default=False,
output_field=JSONField(),
),
distinct=True,
),
).values(*required_fields, "vote_items", "reaction_items")
return issues
def issue_group_values(field, slug, project_id=None, filters=dict):
if field == "state_id":
queryset = State.objects.filter(
is_triage=False,
workspace__slug=slug,
).values_list("id", flat=True)
if project_id:
return list(queryset.filter(project_id=project_id))
else:
return list(queryset)
if field == "labels__id":
queryset = Label.objects.filter(workspace__slug=slug).values_list(
"id", flat=True
)
if project_id:
return list(queryset.filter(project_id=project_id)) + ["None"]
else:
return list(queryset) + ["None"]
if field == "assignees__id":
if project_id:
return ProjectMember.objects.filter(
workspace__slug=slug,
project_id=project_id,
is_active=True,
).values_list("member_id", flat=True)
else:
return list(
WorkspaceMember.objects.filter(
workspace__slug=slug, is_active=True
).values_list("member_id", flat=True)
)
if field == "issue_module__module_id":
queryset = Module.objects.filter(
workspace__slug=slug,
).values_list("id", flat=True)
if project_id:
return list(queryset.filter(project_id=project_id)) + ["None"]
else:
return list(queryset) + ["None"]
if field == "cycle_id":
queryset = Cycle.objects.filter(
workspace__slug=slug,
).values_list("id", flat=True)
if project_id:
return list(queryset.filter(project_id=project_id)) + ["None"]
else:
return list(queryset) + ["None"]
if field == "project_id":
queryset = Project.objects.filter(workspace__slug=slug).values_list(
"id", flat=True
)
return list(queryset)
if field == "priority":
return [
"low",
"medium",
"high",
"urgent",
"none",
]
if field == "state__group":
return [
"backlog",
"unstarted",
"started",
"completed",
"cancelled",
]
if field == "target_date":
queryset = (
Issue.issue_objects.filter(workspace__slug=slug)
.filter(**filters)
.values_list("target_date", flat=True)
.distinct()
)
if project_id:
return list(queryset.filter(project_id=project_id))
else:
return list(queryset)
if field == "start_date":
queryset = (
Issue.issue_objects.filter(workspace__slug=slug)
.filter(**filters)
.values_list("start_date", flat=True)
.distinct()
)
if project_id:
return list(queryset.filter(project_id=project_id))
else:
return list(queryset)
if field == "created_by":
queryset = (
Issue.issue_objects.filter(workspace__slug=slug)
.filter(**filters)
.values_list("created_by", flat=True)
.distinct()
)
if project_id:
return list(queryset.filter(project_id=project_id))
else:
return list(queryset)
return []
-9
View File
@@ -2,7 +2,6 @@ from .project import (
ProjectDeployBoardPublicSettingsEndpoint,
WorkspaceProjectDeployBoardEndpoint,
WorkspaceProjectAnchorEndpoint,
ProjectMembersEndpoint,
)
from .issue import (
@@ -15,11 +14,3 @@ from .issue import (
)
from .inbox import InboxIssuePublicViewSet
from .cycle import ProjectCyclesEndpoint
from .module import ProjectModulesEndpoint
from .state import ProjectStatesEndpoint
from .label import ProjectLabelsEndpoint
-35
View File
@@ -1,35 +0,0 @@
# Third Party imports
from rest_framework import status
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
# Module imports
from .base import BaseAPIView
from plane.db.models import (
DeployBoard,
Cycle,
)
class ProjectCyclesEndpoint(BaseAPIView):
permission_classes = [
AllowAny,
]
def get(self, request, anchor):
deploy_board = DeployBoard.objects.filter(anchor=anchor).first()
if not deploy_board:
return Response(
{"error": "Invalid anchor"},
status=status.HTTP_404_NOT_FOUND,
)
cycles = Cycle.objects.filter(
workspace__slug=deploy_board.workspace.slug,
project_id=deploy_board.project_id,
).values("id", "name")
return Response(
cycles,
status=status.HTTP_200_OK,
)
+188 -317
View File
@@ -1,51 +1,34 @@
# Python imports
import json
# Django imports
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.db.models.functions import Coalesce, JSONObject
from django.core.serializers.json import DjangoJSONEncoder
from django.utils import timezone
from django.db.models import (
Exists,
F,
Func,
OuterRef,
Q,
Prefetch,
UUIDField,
Case,
When,
JSONField,
CharField,
IntegerField,
Value,
OuterRef,
Func
Max,
)
# Third Party imports
from rest_framework.response import Response
# Django imports
from django.utils import timezone
from rest_framework import status
from rest_framework.permissions import AllowAny, IsAuthenticated
# Third Party imports
from rest_framework.response import Response
# Module imports
from .base import BaseAPIView, BaseViewSet
# fetch the space app grouper function separately
from plane.space.utils.grouper import (
issue_group_values,
issue_on_results,
issue_queryset_grouper,
)
from plane.utils.order_queryset import order_issue_queryset
from plane.utils.paginator import (
GroupedOffsetPaginator,
SubGroupedOffsetPaginator,
)
from plane.app.serializers import (
CommentReactionSerializer,
IssueCommentSerializer,
IssuePublicSerializer,
IssueReactionSerializer,
IssueVoteSerializer,
)
@@ -53,183 +36,21 @@ from plane.db.models import (
Issue,
IssueComment,
IssueLink,
IssueReaction,
IssueAttachment,
ProjectMember,
IssueReaction,
CommentReaction,
DeployBoard,
IssueVote,
ProjectPublicMember,
IssueAttachment,
State,
Label,
)
from plane.bgtasks.issue_activites_task import issue_activity
from plane.utils.issue_filters import issue_filters
class ProjectIssuesPublicEndpoint(BaseAPIView):
permission_classes = [
AllowAny,
]
def get(self, request, anchor):
filters = issue_filters(request.query_params, "GET")
order_by_param = request.GET.get("order_by", "-created_at")
deploy_board = DeployBoard.objects.filter(
anchor=anchor, entity_name="project"
).first()
if not deploy_board:
return Response(
{"error": "Project is not published"},
status=status.HTTP_404_NOT_FOUND,
)
project_id = deploy_board.entity_identifier
slug = deploy_board.workspace.slug
issue_queryset = (
Issue.issue_objects.filter(
workspace__slug=slug, project_id=project_id
)
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.prefetch_related(
Prefetch(
"issue_reactions",
queryset=IssueReaction.objects.select_related("actor"),
)
)
.prefetch_related(
Prefetch(
"votes",
queryset=IssueVote.objects.select_related("actor"),
)
)
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
attachment_count=IssueAttachment.objects.filter(
issue=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
sub_issues_count=Issue.issue_objects.filter(
parent=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
).distinct()
issue_queryset = issue_queryset.filter(**filters)
# Issue queryset
issue_queryset, order_by_param = order_issue_queryset(
issue_queryset=issue_queryset,
order_by_param=order_by_param,
)
# Group by
group_by = request.GET.get("group_by", False)
sub_group_by = request.GET.get("sub_group_by", False)
# issue queryset
issue_queryset = issue_queryset_grouper(
queryset=issue_queryset,
group_by=group_by,
sub_group_by=sub_group_by,
)
if group_by:
if sub_group_by:
if group_by == sub_group_by:
return Response(
{
"error": "Group by and sub group by cannot have same parameters"
},
status=status.HTTP_400_BAD_REQUEST,
)
else:
return self.paginate(
request=request,
order_by=order_by_param,
queryset=issue_queryset,
on_results=lambda issues: issue_on_results(
group_by=group_by,
issues=issues,
sub_group_by=sub_group_by,
),
paginator_cls=SubGroupedOffsetPaginator,
group_by_fields=issue_group_values(
field=group_by,
slug=slug,
project_id=project_id,
filters=filters,
),
sub_group_by_fields=issue_group_values(
field=sub_group_by,
slug=slug,
project_id=project_id,
filters=filters,
),
group_by_field_name=group_by,
sub_group_by_field_name=sub_group_by,
count_filter=Q(
Q(issue_inbox__status=1)
| Q(issue_inbox__status=-1)
| Q(issue_inbox__status=2)
| Q(issue_inbox__isnull=True),
archived_at__isnull=True,
is_draft=False,
),
)
else:
# Group paginate
return self.paginate(
request=request,
order_by=order_by_param,
queryset=issue_queryset,
on_results=lambda issues: issue_on_results(
group_by=group_by,
issues=issues,
sub_group_by=sub_group_by,
),
paginator_cls=GroupedOffsetPaginator,
group_by_fields=issue_group_values(
field=group_by,
slug=slug,
project_id=project_id,
filters=filters,
),
group_by_field_name=group_by,
count_filter=Q(
Q(issue_inbox__status=1)
| Q(issue_inbox__status=-1)
| Q(issue_inbox__status=2)
| Q(issue_inbox__isnull=True),
archived_at__isnull=True,
is_draft=False,
),
)
else:
return self.paginate(
order_by=order_by_param,
request=request,
queryset=issue_queryset,
on_results=lambda issues: issue_on_results(
group_by=group_by,
issues=issues,
sub_group_by=sub_group_by,
),
)
# Module imports
from .base import BaseAPIView, BaseViewSet
class IssueCommentPublicViewSet(BaseViewSet):
@@ -682,50 +503,67 @@ class IssueRetrievePublicEndpoint(BaseAPIView):
]
def get(self, request, anchor, issue_id):
deploy_board = DeployBoard.objects.get(anchor=anchor)
project_deploy_board = DeployBoard.objects.get(
anchor=anchor, entity_name="project"
)
issue = Issue.objects.get(
workspace_id=project_deploy_board.workspace_id,
project_id=project_deploy_board.project_id,
pk=issue_id,
)
serializer = IssuePublicSerializer(issue)
return Response(serializer.data, status=status.HTTP_200_OK)
class ProjectIssuesPublicEndpoint(BaseAPIView):
permission_classes = [
AllowAny,
]
def get(self, request, anchor):
deploy_board = DeployBoard.objects.filter(
anchor=anchor, entity_name="project"
).first()
if not deploy_board:
return Response(
{"error": "Project is not published"},
status=status.HTTP_404_NOT_FOUND,
)
filters = issue_filters(request.query_params, "GET")
# Custom ordering for priority and state
priority_order = ["urgent", "high", "medium", "low", "none"]
state_order = [
"backlog",
"unstarted",
"started",
"completed",
"cancelled",
]
order_by_param = request.GET.get("order_by", "-created_at")
project_id = deploy_board.entity_identifier
slug = deploy_board.workspace.slug
issue_queryset = (
Issue.issue_objects.filter(
pk=issue_id,
workspace__slug=deploy_board.workspace.slug,
project_id=deploy_board.project_id,
)
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(
label_ids=Coalesce(
ArrayAgg(
"labels__id",
distinct=True,
filter=~Q(labels__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
assignee_ids=Coalesce(
ArrayAgg(
"assignees__id",
distinct=True,
filter=~Q(assignees__id__isnull=True)
& Q(assignees__member_project__is_active=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
module_ids=Coalesce(
ArrayAgg(
"issue_module__module_id",
distinct=True,
filter=~Q(issue_module__module_id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
Issue.issue_objects.annotate(
sub_issues_count=Issue.issue_objects.filter(
parent=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.filter(project_id=project_id)
.filter(workspace__slug=slug)
.select_related("project", "workspace", "state", "parent")
.prefetch_related("assignees", "labels")
.prefetch_related(
Prefetch(
"issue_reactions",
queryset=IssueReaction.objects.select_related(
"issue", "actor"
),
queryset=IssueReaction.objects.select_related("actor"),
)
)
.prefetch_related(
@@ -734,91 +572,124 @@ class IssueRetrievePublicEndpoint(BaseAPIView):
queryset=IssueVote.objects.select_related("actor"),
)
)
.filter(**filters)
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(module_id=F("issue_module__module_id"))
.annotate(
vote_items=ArrayAgg(
Case(
When(
votes__isnull=False,
then=JSONObject(
vote=F("votes__vote"),
actor_details=JSONObject(
id=F("votes__actor__id"),
first_name=F("votes__actor__first_name"),
last_name=F("votes__actor__last_name"),
avatar=F("votes__actor__avatar"),
display_name=F(
"votes__actor__display_name"
),
),
),
),
default=None,
output_field=JSONField(),
),
filter=Case(
When(votes__isnull=False, then=True),
default=False,
output_field=JSONField(),
),
distinct=True,
),
reaction_items=ArrayAgg(
Case(
When(
issue_reactions__isnull=False,
then=JSONObject(
reaction=F("issue_reactions__reaction"),
actor_details=JSONObject(
id=F("issue_reactions__actor__id"),
first_name=F(
"issue_reactions__actor__first_name"
),
last_name=F(
"issue_reactions__actor__last_name"
),
avatar=F("issue_reactions__actor__avatar"),
display_name=F(
"issue_reactions__actor__display_name"
),
),
),
),
default=None,
output_field=JSONField(),
),
filter=Case(
When(issue_reactions__isnull=False, then=True),
default=False,
output_field=JSONField(),
),
distinct=True,
),
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.values(
"id",
"name",
"state_id",
"sort_order",
"description",
"description_html",
"description_stripped",
"description_binary",
"module_ids",
"label_ids",
"assignee_ids",
"estimate_point",
"priority",
"start_date",
"target_date",
"sequence_id",
"project_id",
"parent_id",
"cycle_id",
"created_by",
"state__group",
"vote_items",
"reaction_items",
.annotate(
attachment_count=IssueAttachment.objects.filter(
issue=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
).first()
)
return Response(issue_queryset, status=status.HTTP_200_OK)
# Priority Ordering
if order_by_param == "priority" or order_by_param == "-priority":
priority_order = (
priority_order
if order_by_param == "priority"
else priority_order[::-1]
)
issue_queryset = issue_queryset.annotate(
priority_order=Case(
*[
When(priority=p, then=Value(i))
for i, p in enumerate(priority_order)
],
output_field=CharField(),
)
).order_by("priority_order")
# State Ordering
elif order_by_param in [
"state__name",
"state__group",
"-state__name",
"-state__group",
]:
state_order = (
state_order
if order_by_param in ["state__name", "state__group"]
else state_order[::-1]
)
issue_queryset = issue_queryset.annotate(
state_order=Case(
*[
When(state__group=state_group, then=Value(i))
for i, state_group in enumerate(state_order)
],
default=Value(len(state_order)),
output_field=CharField(),
)
).order_by("state_order")
# assignee and label ordering
elif order_by_param in [
"labels__name",
"-labels__name",
"assignees__first_name",
"-assignees__first_name",
]:
issue_queryset = issue_queryset.annotate(
max_values=Max(
order_by_param[1::]
if order_by_param.startswith("-")
else order_by_param
)
).order_by(
"-max_values"
if order_by_param.startswith("-")
else "max_values"
)
else:
issue_queryset = issue_queryset.order_by(order_by_param)
issues = IssuePublicSerializer(issue_queryset, many=True).data
state_group_order = [
"backlog",
"unstarted",
"started",
"completed",
"cancelled",
]
states = (
State.objects.filter(
~Q(name="Triage"),
workspace__slug=slug,
project_id=project_id,
)
.annotate(
custom_order=Case(
*[
When(group=value, then=Value(index))
for index, value in enumerate(state_group_order)
],
default=Value(len(state_group_order)),
output_field=IntegerField(),
),
)
.values("name", "group", "color", "id")
.order_by("custom_order", "sequence")
)
labels = Label.objects.filter(
workspace__slug=slug, project_id=project_id
).values("id", "name", "color", "parent")
return Response(
{
"issues": issues,
"states": states,
"labels": labels,
},
status=status.HTTP_200_OK,
)
-35
View File
@@ -1,35 +0,0 @@
# Third Party imports
from rest_framework.response import Response
from rest_framework import status
from rest_framework.permissions import AllowAny
# Module imports
from .base import BaseAPIView
from plane.db.models import (
DeployBoard,
Label,
)
class ProjectLabelsEndpoint(BaseAPIView):
permission_classes = [
AllowAny,
]
def get(self, request, anchor):
deploy_board = DeployBoard.objects.filter(anchor=anchor).first()
if not deploy_board:
return Response(
{"error": "Invalid anchor"},
status=status.HTTP_404_NOT_FOUND,
)
labels = Label.objects.filter(
workspace__slug=deploy_board.workspace.slug,
project_id=deploy_board.project_id,
).values("id", "name", "color", "parent")
return Response(
labels,
status=status.HTTP_200_OK,
)
-35
View File
@@ -1,35 +0,0 @@
# Third Party imports
from rest_framework import status
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
# Module imports
from .base import BaseAPIView
from plane.db.models import (
DeployBoard,
Module,
)
class ProjectModulesEndpoint(BaseAPIView):
permission_classes = [
AllowAny,
]
def get(self, request, anchor):
deploy_board = DeployBoard.objects.filter(anchor=anchor).first()
if not deploy_board:
return Response(
{"error": "Invalid anchor"},
status=status.HTTP_404_NOT_FOUND,
)
modules = Module.objects.filter(
workspace__slug=deploy_board.workspace.slug,
project_id=deploy_board.project_id,
).values("id", "name")
return Response(
modules,
status=status.HTTP_200_OK,
)
+4 -25
View File
@@ -12,7 +12,10 @@ from rest_framework.permissions import AllowAny
# Module imports
from .base import BaseAPIView
from plane.app.serializers import DeployBoardSerializer
from plane.db.models import Project, DeployBoard, ProjectMember
from plane.db.models import (
Project,
DeployBoard,
)
class ProjectDeployBoardPublicSettingsEndpoint(BaseAPIView):
@@ -73,27 +76,3 @@ class WorkspaceProjectAnchorEndpoint(BaseAPIView):
)
serializer = DeployBoardSerializer(project_deploy_board)
return Response(serializer.data, status=status.HTTP_200_OK)
class ProjectMembersEndpoint(BaseAPIView):
permission_classes = [
AllowAny,
]
def get(self, request, anchor):
deploy_board = DeployBoard.objects.filter(anchor=anchor).first()
members = ProjectMember.objects.filter(
project=deploy_board.project,
workspace=deploy_board.workspace,
is_active=True,
).values(
"id",
"member",
"member__first_name",
"member__last_name",
"member__display_name",
"project",
"workspace",
)
return Response(members, status=status.HTTP_200_OK)
-42
View File
@@ -1,42 +0,0 @@
# Django imports
from django.db.models import Q
# Third Party imports
from rest_framework import status
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
# Module imports
from .base import BaseAPIView
from plane.db.models import (
DeployBoard,
State,
)
class ProjectStatesEndpoint(BaseAPIView):
permission_classes = [
AllowAny,
]
def get(self, request, anchor):
deploy_board = DeployBoard.objects.filter(anchor=anchor).first()
if not deploy_board:
return Response(
{"error": "Invalid anchor"},
status=status.HTTP_404_NOT_FOUND,
)
states = (
State.objects.filter(
~Q(name="Triage"),
workspace__slug=deploy_board.workspace.slug,
project_id=deploy_board.project_id,
)
.values("name", "group", "color", "id")
)
return Response(
states,
status=status.HTTP_200_OK,
)
+2 -2
View File
@@ -12,7 +12,7 @@ from django.db.models import (
Sum,
Value,
When,
FloatField,
IntegerField,
)
from django.db.models.functions import (
Coalesce,
@@ -98,7 +98,7 @@ def build_graph_plot(queryset, x_axis, y_axis, segment=None):
# Estimate
else:
queryset = queryset.annotate(
estimate=Sum(Cast("estimate_point__value", FloatField()))
estimate=Sum(Cast("estimate_point__value", IntegerField()))
).order_by(x_axis)
queryset = (
queryset.annotate(segment=F(segment)) if segment else queryset
+112 -142
View File
@@ -23,42 +23,42 @@ def filter_valid_uuids(uuid_list):
# Get the 2_weeks, 3_months
def string_date_filter(
issue_filter, duration, subsequent, term, date_filter, offset
filter, duration, subsequent, term, date_filter, offset
):
now = timezone.now().date()
if term == "months":
if subsequent == "after":
if offset == "fromnow":
issue_filter[f"{date_filter}__gte"] = now + timedelta(
filter[f"{date_filter}__gte"] = now + timedelta(
days=duration * 30
)
else:
issue_filter[f"{date_filter}__gte"] = now - timedelta(
filter[f"{date_filter}__gte"] = now - timedelta(
days=duration * 30
)
else:
if offset == "fromnow":
issue_filter[f"{date_filter}__lte"] = now + timedelta(
filter[f"{date_filter}__lte"] = now + timedelta(
days=duration * 30
)
else:
issue_filter[f"{date_filter}__lte"] = now - timedelta(
filter[f"{date_filter}__lte"] = now - timedelta(
days=duration * 30
)
if term == "weeks":
if subsequent == "after":
if offset == "fromnow":
issue_filter[f"{date_filter}__gte"] = now + timedelta(weeks=duration)
filter[f"{date_filter}__gte"] = now + timedelta(weeks=duration)
else:
issue_filter[f"{date_filter}__gte"] = now - timedelta(weeks=duration)
filter[f"{date_filter}__gte"] = now - timedelta(weeks=duration)
else:
if offset == "fromnow":
issue_filter[f"{date_filter}__lte"] = now + timedelta(weeks=duration)
filter[f"{date_filter}__lte"] = now + timedelta(weeks=duration)
else:
issue_filter[f"{date_filter}__lte"] = now - timedelta(weeks=duration)
filter[f"{date_filter}__lte"] = now - timedelta(weeks=duration)
def date_filter(issue_filter, date_term, queries):
def date_filter(filter, date_term, queries):
"""
Handle all date filters
"""
@@ -71,7 +71,7 @@ def date_filter(issue_filter, date_term, queries):
if len(date_query) == 3:
digit, term = date_query[0].split("_")
string_date_filter(
issue_filter=issue_filter,
filter=filter,
duration=int(digit),
subsequent=date_query[1],
term=term,
@@ -80,32 +80,32 @@ def date_filter(issue_filter, date_term, queries):
)
else:
if "after" in date_query:
issue_filter[f"{date_term}__gte"] = date_query[0]
filter[f"{date_term}__gte"] = date_query[0]
else:
issue_filter[f"{date_term}__lte"] = date_query[0]
filter[f"{date_term}__lte"] = date_query[0]
else:
issue_filter[f"{date_term}__contains"] = date_query[0]
filter[f"{date_term}__contains"] = date_query[0]
def filter_state(params, issue_filter, method, prefix=""):
def filter_state(params, filter, method, prefix=""):
if method == "GET":
states = [
item for item in params.get("state").split(",") if item != "null"
]
states = filter_valid_uuids(states)
if len(states) and "" not in states:
issue_filter[f"{prefix}state__in"] = states
filter[f"{prefix}state__in"] = states
else:
if (
params.get("state", None)
and len(params.get("state"))
and params.get("state") != "null"
):
issue_filter[f"{prefix}state__in"] = params.get("state")
return issue_filter
filter[f"{prefix}state__in"] = params.get("state")
return filter
def filter_state_group(params, issue_filter, method, prefix=""):
def filter_state_group(params, filter, method, prefix=""):
if method == "GET":
state_group = [
item
@@ -113,18 +113,18 @@ def filter_state_group(params, issue_filter, method, prefix=""):
if item != "null"
]
if len(state_group) and "" not in state_group:
issue_filter[f"{prefix}state__group__in"] = state_group
filter[f"{prefix}state__group__in"] = state_group
else:
if (
params.get("state_group", None)
and len(params.get("state_group"))
and params.get("state_group") != "null"
):
issue_filter[f"{prefix}state__group__in"] = params.get("state_group")
return issue_filter
filter[f"{prefix}state__group__in"] = params.get("state_group")
return filter
def filter_estimate_point(params, issue_filter, method, prefix=""):
def filter_estimate_point(params, filter, method, prefix=""):
if method == "GET":
estimate_points = [
item
@@ -132,20 +132,20 @@ def filter_estimate_point(params, issue_filter, method, prefix=""):
if item != "null"
]
if len(estimate_points) and "" not in estimate_points:
issue_filter[f"{prefix}estimate_point__in"] = estimate_points
filter[f"{prefix}estimate_point__in"] = estimate_points
else:
if (
params.get("estimate_point", None)
and len(params.get("estimate_point"))
and params.get("estimate_point") != "null"
):
issue_filter[f"{prefix}estimate_point__in"] = params.get(
filter[f"{prefix}estimate_point__in"] = params.get(
"estimate_point"
)
return issue_filter
return filter
def filter_priority(params, issue_filter, method, prefix=""):
def filter_priority(params, filter, method, prefix=""):
if method == "GET":
priorities = [
item
@@ -153,58 +153,51 @@ def filter_priority(params, issue_filter, method, prefix=""):
if item != "null"
]
if len(priorities) and "" not in priorities:
issue_filter[f"{prefix}priority__in"] = priorities
else:
if (
params.get("priority", None)
and len(params.get("priority"))
and params.get("priority") != "null"
):
issue_filter[f"{prefix}priority__in"] = params.get("priority")
return issue_filter
filter[f"{prefix}priority__in"] = priorities
return filter
def filter_parent(params, issue_filter, method, prefix=""):
def filter_parent(params, filter, method, prefix=""):
if method == "GET":
parents = [
item for item in params.get("parent").split(",") if item != "null"
]
if "None" in parents:
issue_filter[f"{prefix}parent__isnull"] = True
filter[f"{prefix}parent__isnull"] = True
parents = filter_valid_uuids(parents)
if len(parents) and "" not in parents:
issue_filter[f"{prefix}parent__in"] = parents
filter[f"{prefix}parent__in"] = parents
else:
if (
params.get("parent", None)
and len(params.get("parent"))
and params.get("parent") != "null"
):
issue_filter[f"{prefix}parent__in"] = params.get("parent")
return issue_filter
filter[f"{prefix}parent__in"] = params.get("parent")
return filter
def filter_labels(params, issue_filter, method, prefix=""):
def filter_labels(params, filter, method, prefix=""):
if method == "GET":
labels = [
item for item in params.get("labels").split(",") if item != "null"
]
if "None" in labels:
issue_filter[f"{prefix}labels__isnull"] = True
filter[f"{prefix}labels__isnull"] = True
labels = filter_valid_uuids(labels)
if len(labels) and "" not in labels:
issue_filter[f"{prefix}labels__in"] = labels
filter[f"{prefix}labels__in"] = labels
else:
if (
params.get("labels", None)
and len(params.get("labels"))
and params.get("labels") != "null"
):
issue_filter[f"{prefix}labels__in"] = params.get("labels")
return issue_filter
filter[f"{prefix}labels__in"] = params.get("labels")
return filter
def filter_assignees(params, issue_filter, method, prefix=""):
def filter_assignees(params, filter, method, prefix=""):
if method == "GET":
assignees = [
item
@@ -212,21 +205,21 @@ def filter_assignees(params, issue_filter, method, prefix=""):
if item != "null"
]
if "None" in assignees:
issue_filter[f"{prefix}assignees__isnull"] = True
filter[f"{prefix}assignees__isnull"] = True
assignees = filter_valid_uuids(assignees)
if len(assignees) and "" not in assignees:
issue_filter[f"{prefix}assignees__in"] = assignees
filter[f"{prefix}assignees__in"] = assignees
else:
if (
params.get("assignees", None)
and len(params.get("assignees"))
and params.get("assignees") != "null"
):
issue_filter[f"{prefix}assignees__in"] = params.get("assignees")
return issue_filter
filter[f"{prefix}assignees__in"] = params.get("assignees")
return filter
def filter_mentions(params, issue_filter, method, prefix=""):
def filter_mentions(params, filter, method, prefix=""):
if method == "GET":
mentions = [
item
@@ -235,20 +228,20 @@ def filter_mentions(params, issue_filter, method, prefix=""):
]
mentions = filter_valid_uuids(mentions)
if len(mentions) and "" not in mentions:
issue_filter[f"{prefix}issue_mention__mention__id__in"] = mentions
filter[f"{prefix}issue_mention__mention__id__in"] = mentions
else:
if (
params.get("mentions", None)
and len(params.get("mentions"))
and params.get("mentions") != "null"
):
issue_filter[f"{prefix}issue_mention__mention__id__in"] = params.get(
filter[f"{prefix}issue_mention__mention__id__in"] = params.get(
"mentions"
)
return issue_filter
return filter
def filter_created_by(params, issue_filter, method, prefix=""):
def filter_created_by(params, filter, method, prefix=""):
if method == "GET":
created_bys = [
item
@@ -256,100 +249,100 @@ def filter_created_by(params, issue_filter, method, prefix=""):
if item != "null"
]
if "None" in created_bys:
issue_filter[f"{prefix}created_by__isnull"] = True
filter[f"{prefix}created_by__isnull"] = True
created_bys = filter_valid_uuids(created_bys)
if len(created_bys) and "" not in created_bys:
issue_filter[f"{prefix}created_by__in"] = created_bys
filter[f"{prefix}created_by__in"] = created_bys
else:
if (
params.get("created_by", None)
and len(params.get("created_by"))
and params.get("created_by") != "null"
):
issue_filter[f"{prefix}created_by__in"] = params.get("created_by")
return issue_filter
filter[f"{prefix}created_by__in"] = params.get("created_by")
return filter
def filter_name(params, issue_filter, method, prefix=""):
def filter_name(params, filter, method, prefix=""):
if params.get("name", "") != "":
issue_filter[f"{prefix}name__icontains"] = params.get("name")
return issue_filter
filter[f"{prefix}name__icontains"] = params.get("name")
return filter
def filter_created_at(params, issue_filter, method, prefix=""):
def filter_created_at(params, filter, method, prefix=""):
if method == "GET":
created_ats = params.get("created_at").split(",")
if len(created_ats) and "" not in created_ats:
date_filter(
issue_filter=issue_filter,
filter=filter,
date_term=f"{prefix}created_at__date",
queries=created_ats,
)
else:
if params.get("created_at", None) and len(params.get("created_at")):
date_filter(
issue_filter=issue_filter,
filter=filter,
date_term=f"{prefix}created_at__date",
queries=params.get("created_at", []),
)
return issue_filter
return filter
def filter_updated_at(params, issue_filter, method, prefix=""):
def filter_updated_at(params, filter, method, prefix=""):
if method == "GET":
updated_ats = params.get("updated_at").split(",")
if len(updated_ats) and "" not in updated_ats:
date_filter(
issue_filter=issue_filter,
filter=filter,
date_term=f"{prefix}created_at__date",
queries=updated_ats,
)
else:
if params.get("updated_at", None) and len(params.get("updated_at")):
date_filter(
issue_filter=issue_filter,
filter=filter,
date_term=f"{prefix}created_at__date",
queries=params.get("updated_at", []),
)
return issue_filter
return filter
def filter_start_date(params, issue_filter, method, prefix=""):
def filter_start_date(params, filter, method, prefix=""):
if method == "GET":
start_dates = params.get("start_date").split(",")
if len(start_dates) and "" not in start_dates:
date_filter(
issue_filter=issue_filter,
filter=filter,
date_term=f"{prefix}start_date",
queries=start_dates,
)
else:
if params.get("start_date", None) and len(params.get("start_date")):
issue_filter[f"{prefix}start_date"] = params.get("start_date")
return issue_filter
filter[f"{prefix}start_date"] = params.get("start_date")
return filter
def filter_target_date(params, issue_filter, method, prefix=""):
def filter_target_date(params, filter, method, prefix=""):
if method == "GET":
target_dates = params.get("target_date").split(",")
if len(target_dates) and "" not in target_dates:
date_filter(
issue_filter=issue_filter,
filter=filter,
date_term=f"{prefix}target_date",
queries=target_dates,
)
else:
if params.get("target_date", None) and len(params.get("target_date")):
issue_filter[f"{prefix}target_date"] = params.get("target_date")
return issue_filter
filter[f"{prefix}target_date"] = params.get("target_date")
return filter
def filter_completed_at(params, issue_filter, method, prefix=""):
def filter_completed_at(params, filter, method, prefix=""):
if method == "GET":
completed_ats = params.get("completed_at").split(",")
if len(completed_ats) and "" not in completed_ats:
date_filter(
issue_filter=issue_filter,
filter=filter,
date_term=f"{prefix}completed_at__date",
queries=completed_ats,
)
@@ -358,14 +351,14 @@ def filter_completed_at(params, issue_filter, method, prefix=""):
params.get("completed_at")
):
date_filter(
issue_filter=issue_filter,
filter=filter,
date_term=f"{prefix}completed_at__date",
queries=params.get("completed_at", []),
)
return issue_filter
return filter
def filter_issue_state_type(params, issue_filter, method, prefix=""):
def filter_issue_state_type(params, filter, method, prefix=""):
type = params.get("type", "all")
group = ["backlog", "unstarted", "started", "completed", "cancelled"]
if type == "backlog":
@@ -373,71 +366,71 @@ def filter_issue_state_type(params, issue_filter, method, prefix=""):
if type == "active":
group = ["unstarted", "started"]
issue_filter[f"{prefix}state__group__in"] = group
return issue_filter
filter[f"{prefix}state__group__in"] = group
return filter
def filter_project(params, issue_filter, method, prefix=""):
def filter_project(params, filter, method, prefix=""):
if method == "GET":
projects = [
item for item in params.get("project").split(",") if item != "null"
]
projects = filter_valid_uuids(projects)
if len(projects) and "" not in projects:
issue_filter[f"{prefix}project__in"] = projects
filter[f"{prefix}project__in"] = projects
else:
if (
params.get("project", None)
and len(params.get("project"))
and params.get("project") != "null"
):
issue_filter[f"{prefix}project__in"] = params.get("project")
return issue_filter
filter[f"{prefix}project__in"] = params.get("project")
return filter
def filter_cycle(params, issue_filter, method, prefix=""):
def filter_cycle(params, filter, method, prefix=""):
if method == "GET":
cycles = [
item for item in params.get("cycle").split(",") if item != "null"
]
if "None" in cycles:
issue_filter[f"{prefix}issue_cycle__cycle_id__isnull"] = True
filter[f"{prefix}issue_cycle__cycle_id__isnull"] = True
cycles = filter_valid_uuids(cycles)
if len(cycles) and "" not in cycles:
issue_filter[f"{prefix}issue_cycle__cycle_id__in"] = cycles
filter[f"{prefix}issue_cycle__cycle_id__in"] = cycles
else:
if (
params.get("cycle", None)
and len(params.get("cycle"))
and params.get("cycle") != "null"
):
issue_filter[f"{prefix}issue_cycle__cycle_id__in"] = params.get("cycle")
return issue_filter
filter[f"{prefix}issue_cycle__cycle_id__in"] = params.get("cycle")
return filter
def filter_module(params, issue_filter, method, prefix=""):
def filter_module(params, filter, method, prefix=""):
if method == "GET":
modules = [
item for item in params.get("module").split(",") if item != "null"
]
if "None" in modules:
issue_filter[f"{prefix}issue_module__module_id__isnull"] = True
filter[f"{prefix}issue_module__module_id__isnull"] = True
modules = filter_valid_uuids(modules)
if len(modules) and "" not in modules:
issue_filter[f"{prefix}issue_module__module_id__in"] = modules
filter[f"{prefix}issue_module__module_id__in"] = modules
else:
if (
params.get("module", None)
and len(params.get("module"))
and params.get("module") != "null"
):
issue_filter[f"{prefix}issue_module__module_id__in"] = params.get(
filter[f"{prefix}issue_module__module_id__in"] = params.get(
"module"
)
return issue_filter
return filter
def filter_inbox_status(params, issue_filter, method, prefix=""):
def filter_inbox_status(params, filter, method, prefix=""):
if method == "GET":
status = [
item
@@ -445,32 +438,32 @@ def filter_inbox_status(params, issue_filter, method, prefix=""):
if item != "null"
]
if len(status) and "" not in status:
issue_filter[f"{prefix}issue_inbox__status__in"] = status
filter[f"{prefix}issue_inbox__status__in"] = status
else:
if (
params.get("inbox_status", None)
and len(params.get("inbox_status"))
and params.get("inbox_status") != "null"
):
issue_filter[f"{prefix}issue_inbox__status__in"] = params.get(
filter[f"{prefix}issue_inbox__status__in"] = params.get(
"inbox_status"
)
return issue_filter
return filter
def filter_sub_issue_toggle(params, issue_filter, method, prefix=""):
def filter_sub_issue_toggle(params, filter, method, prefix=""):
if method == "GET":
sub_issue = params.get("sub_issue", "false")
if sub_issue == "false":
issue_filter[f"{prefix}parent__isnull"] = True
filter[f"{prefix}parent__isnull"] = True
else:
sub_issue = params.get("sub_issue", "false")
if sub_issue == "false":
issue_filter[f"{prefix}parent__isnull"] = True
return issue_filter
filter[f"{prefix}parent__isnull"] = True
return filter
def filter_subscribed_issues(params, issue_filter, method, prefix=""):
def filter_subscribed_issues(params, filter, method, prefix=""):
if method == "GET":
subscribers = [
item
@@ -479,7 +472,7 @@ def filter_subscribed_issues(params, issue_filter, method, prefix=""):
]
subscribers = filter_valid_uuids(subscribers)
if len(subscribers) and "" not in subscribers:
issue_filter[f"{prefix}issue_subscribers__subscriber_id__in"] = (
filter[f"{prefix}issue_subscribers__subscriber_id__in"] = (
subscribers
)
else:
@@ -488,44 +481,22 @@ def filter_subscribed_issues(params, issue_filter, method, prefix=""):
and len(params.get("subscriber"))
and params.get("subscriber") != "null"
):
issue_filter[f"{prefix}issue_subscribers__subscriber_id__in"] = (
filter[f"{prefix}issue_subscribers__subscriber_id__in"] = (
params.get("subscriber")
)
return issue_filter
return filter
def filter_start_target_date_issues(params, issue_filter, method, prefix=""):
def filter_start_target_date_issues(params, filter, method, prefix=""):
start_target_date = params.get("start_target_date", "false")
if start_target_date == "true":
issue_filter[f"{prefix}target_date__isnull"] = False
issue_filter[f"{prefix}start_date__isnull"] = False
return issue_filter
def filter_logged_by(params, issue_filter, method, prefix=""):
if method == "GET":
logged_bys = [
item
for item in params.get("logged_by").split(",")
if item != "null"
]
if "None" in logged_bys:
issue_filter[f"{prefix}logged_by__isnull"] = True
logged_bys = filter_valid_uuids(logged_bys)
if len(logged_bys) and "" not in logged_bys:
issue_filter[f"{prefix}logged_by__in"] = logged_bys
else:
if (
params.get("logged_by", None)
and len(params.get("logged_by"))
and params.get("logged_by") != "null"
):
issue_filter[f"{prefix}logged_by__in"] = params.get("logged_by")
return issue_filter
filter[f"{prefix}target_date__isnull"] = False
filter[f"{prefix}start_date__isnull"] = False
return filter
def issue_filters(query_params, method, prefix=""):
issue_filter = {}
filter = {}
ISSUE_FILTER = {
"state": filter_state,
@@ -537,7 +508,6 @@ def issue_filters(query_params, method, prefix=""):
"assignees": filter_assignees,
"mentions": filter_mentions,
"created_by": filter_created_by,
"logged_by": filter_logged_by,
"name": filter_name,
"created_at": filter_created_at,
"updated_at": filter_updated_at,
@@ -557,5 +527,5 @@ def issue_filters(query_params, method, prefix=""):
for key, value in ISSUE_FILTER.items():
if key in query_params:
func = value
func(query_params, issue_filter, method, prefix)
return issue_filter
func(query_params, filter, method, prefix)
return filter
+2 -2
View File
@@ -1,7 +1,7 @@
# base requirements
# django
Django==4.2.14
Django==4.2.11
# rest framework
djangorestframework==3.15.2
# postgres
@@ -26,7 +26,7 @@ django-filter==24.2
# json model
jsonmodels==2.7.0
# sentry
sentry-sdk==2.8.0
sentry-sdk==2.0.1
# storage
django-storages==1.14.2
# user management
+1 -54
View File
@@ -372,60 +372,8 @@ Backup completed successfully. Backup files are stored in /....../plane-app/back
---
### Restore Data
When you want to restore the previously backed-up data, follow the instructions below.
1. Make sure that Plane-CE is installed, started, and then stopped. This ensures that the Docker volumes are created.
1. Download the restore script using the command below. We suggest downloading it in the same folder as `setup.sh`.
```bash
curl -fsSL -o restore.sh https://raw.githubusercontent.com/makeplane/plane/master/deploy/selfhost/restore.sh
chmod +x restore.sh
```
1. Execute the command below to restore your data.
```bash
./restore.sh <path to backup folder containing *.tar.gz files>
```
As an example, for a backup folder `/opt/plane-selfhost/plane-app/backup/20240722-0914`, expect the response below:
```bash
--------------------------------------------
____ _ /////////
| _ \| | __ _ _ __ ___ /////////
| |_) | |/ _` | '_ \ / _ \ ///// /////
| __/| | (_| | | | | __/ ///// /////
|_| |_|\__,_|_| |_|\___| ////
////
--------------------------------------------
Project management tool from the future
--------------------------------------------
Found /opt/plane-selfhost/plane-app/backup/20240722-0914/pgdata.tar.gz
.....Restoring plane-app_pgdata
.....Successfully restored volume plane-app_pgdata from pgdata.tar.gz
Found /opt/plane-selfhost/plane-app/backup/20240722-0914/redisdata.tar.gz
.....Restoring plane-app_redisdata
.....Successfully restored volume plane-app_redisdata from redisdata.tar.gz
Found /opt/plane-selfhost/plane-app/backup/20240722-0914/uploads.tar.gz
.....Restoring plane-app_uploads
.....Successfully restored volume plane-app_uploads from uploads.tar.gz
Restore completed successfully.
```
1. Start the Plane instance using `./setup.sh start`.
---
<details>
<summary><h2>Upgrading from v0.13.2 to v0.14.x</h2></summary>
## Upgrading from v0.13.2 to v0.14.x
This is one time activity for users who are upgrading from v0.13.2 to v0.14.0
@@ -497,4 +445,3 @@ In case the suffixes are wrong or the mentioned volumes are not found, you will
In case of successful migration, it will be a silent exit without error.
Now its time to restart v0.14.0 setup.
</details>
-121
View File
@@ -1,121 +0,0 @@
#!/bin/bash
function print_header() {
clear
cat <<"EOF"
--------------------------------------------
____ _ /////////
| _ \| | __ _ _ __ ___ /////////
| |_) | |/ _` | '_ \ / _ \ ///// /////
| __/| | (_| | | | | __/ ///// /////
|_| |_|\__,_|_| |_|\___| ////
////
--------------------------------------------
Project management tool from the future
--------------------------------------------
EOF
}
function restoreSingleVolume() {
selectedVolume=$1
backupFolder=$2
restoreFile=$3
docker volume rm "$selectedVolume" > /dev/null 2>&1
if [ $? -ne 0 ]; then
echo "Error: Failed to remove volume $selectedVolume"
echo ""
return 1
fi
docker volume create "$selectedVolume" > /dev/null 2>&1
if [ $? -ne 0 ]; then
echo "Error: Failed to create volume $selectedVolume"
echo ""
return 1
fi
docker run --rm \
-e TAR_NAME="$restoreFile" \
-v "$selectedVolume":"/vol" \
-v "$backupFolder":/backup \
busybox sh -c 'mkdir -p /restore && tar -xzf "/backup/${TAR_NAME}.tar.gz" -C /restore && mv /restore/${TAR_NAME}/* /vol'
if [ $? -ne 0 ]; then
echo "Error: Failed to restore volume ${selectedVolume} from ${restoreFile}.tar.gz"
echo ""
return 1
fi
echo ".....Successfully restored volume $selectedVolume from ${restoreFile}.tar.gz"
echo ""
}
function restoreData() {
print_header
local BACKUP_FOLDER=${1:-$PWD}
local dockerServiceStatus
dockerServiceStatus=$($COMPOSE_CMD ls --filter name=plane-app --format=json | jq -r .[0].Status)
local dockerServicePrefix
dockerServicePrefix="running"
if [[ $dockerServiceStatus == $dockerServicePrefix* ]]; then
echo "Plane App is running. Please STOP the Plane App before restoring data."
exit 1
fi
local volumes
volumes=$(docker volume ls -f "name=plane-app" --format "{{.Name}}" | grep -E "_pgdata|_redisdata|_uploads")
# Check if there are any matching volumes
if [ -z "$volumes" ]; then
echo ".....No volumes found starting with 'plane-app'"
exit 1
fi
for BACKUP_FILE in $BACKUP_FOLDER/*.tar.gz; do
if [ -e "$BACKUP_FILE" ]; then
local restoreFileName
restoreFileName=$(basename "$BACKUP_FILE")
restoreFileName="${restoreFileName%.tar.gz}"
local restoreVolName
restoreVolName="plane-app_${restoreFileName}"
echo "Found $BACKUP_FILE"
local docVol
docVol=$(docker volume ls -f "name=$restoreVolName" --format "{{.Name}}" | grep -E "_pgdata|_redisdata|_uploads")
if [ -z "$docVol" ]; then
echo "Skipping: No volume found with name $restoreVolName"
else
echo ".....Restoring $docVol"
restoreSingleVolume "$docVol" "$BACKUP_FOLDER" "$restoreFileName"
fi
else
echo "No .tar.gz files found in the current directory."
echo ""
echo "Please provide the path to the backup file."
echo ""
echo "Usage: ./restore.sh /path/to/backup"
exit 1
fi
done
echo ""
echo "Restore completed successfully."
echo ""
}
# if docker-compose is installed
if command -v docker-compose &> /dev/null
then
COMPOSE_CMD="docker-compose"
else
COMPOSE_CMD="docker compose"
fi
restoreData "$@"
+1 -1
View File
@@ -34,7 +34,7 @@
"prettier": "latest",
"prettier-plugin-tailwindcss": "^0.5.4",
"tailwindcss": "^3.3.3",
"turbo": "^2.0.9"
"turbo": "^2.0.6"
},
"resolutions": {
"@types/react": "18.2.48"
-1
View File
@@ -1,2 +1 @@
export * from "./auth";
export * from "./issue";
-27
View File
@@ -1,27 +0,0 @@
export const ALL_ISSUES = "All Issues";
export enum EIssueGroupByToServerOptions {
"state" = "state_id",
"priority" = "priority",
"labels" = "labels__id",
"state_detail.group" = "state__group",
"assignees" = "assignees__id",
"cycle" = "cycle_id",
"module" = "issue_module__module_id",
"target_date" = "target_date",
"project" = "project_id",
"created_by" = "created_by",
}
export enum EServerGroupByToFilterOptions {
"state_id" = "state",
"priority" = "priority",
"labels__id" = "labels",
"state__group" = "state_group",
"assignees__id" = "assignees",
"cycle_id" = "cycle",
"issue_module__module_id" = "module",
"target_date" = "target_date",
"project_id" = "project",
"created_by" = "created_by",
}
@@ -34,7 +34,6 @@ export const EditorWrapper: React.FC<Props> = (props) => {
const editor = useEditor({
editorClassName,
enableHistory: true,
extensions,
fileHandler,
forwardedRef,
@@ -1,5 +1,3 @@
import { Selection } from "@tiptap/pm/state";
import { Editor } from "@tiptap/react";
import {
BoldIcon,
Heading1,
@@ -21,6 +19,8 @@ import {
CaseSensitive,
LucideIcon,
} from "lucide-react";
import { Selection } from "@tiptap/pm/state";
import { Editor } from "@tiptap/react";
// helpers
import {
insertImageCommand,
@@ -43,151 +43,168 @@ import {
toggleUnderline,
} from "@/helpers/editor-commands";
// types
import { TEditorCommands, UploadImage } from "@/types";
import { UploadImage } from "@/types";
export interface EditorMenuItem {
key: TEditorCommands;
key: string;
name: string;
isActive: () => boolean;
command: () => void;
icon: LucideIcon;
}
export const TextItem = (editor: Editor): EditorMenuItem => ({
key: "text",
name: "Text",
isActive: () => editor.isActive("paragraph"),
command: () => setText(editor),
icon: CaseSensitive,
});
export const TextItem = (editor: Editor) =>
({
key: "text",
name: "Text",
isActive: () => editor.isActive("paragraph"),
command: () => setText(editor),
icon: CaseSensitive,
}) as const satisfies EditorMenuItem;
export const HeadingOneItem = (editor: Editor): EditorMenuItem => ({
key: "h1",
name: "Heading 1",
isActive: () => editor.isActive("heading", { level: 1 }),
command: () => toggleHeadingOne(editor),
icon: Heading1,
});
export const HeadingOneItem = (editor: Editor) =>
({
key: "h1",
name: "Heading 1",
isActive: () => editor.isActive("heading", { level: 1 }),
command: () => toggleHeadingOne(editor),
icon: Heading1,
}) as const satisfies EditorMenuItem;
export const HeadingTwoItem = (editor: Editor): EditorMenuItem => ({
key: "h2",
name: "Heading 2",
isActive: () => editor.isActive("heading", { level: 2 }),
command: () => toggleHeadingTwo(editor),
icon: Heading2,
});
export const HeadingTwoItem = (editor: Editor) =>
({
key: "h2",
name: "Heading 2",
isActive: () => editor.isActive("heading", { level: 2 }),
command: () => toggleHeadingTwo(editor),
icon: Heading2,
}) as const satisfies EditorMenuItem;
export const HeadingThreeItem = (editor: Editor): EditorMenuItem => ({
key: "h3",
name: "Heading 3",
isActive: () => editor.isActive("heading", { level: 3 }),
command: () => toggleHeadingThree(editor),
icon: Heading3,
});
export const HeadingThreeItem = (editor: Editor) =>
({
key: "h3",
name: "Heading 3",
isActive: () => editor.isActive("heading", { level: 3 }),
command: () => toggleHeadingThree(editor),
icon: Heading3,
}) as const satisfies EditorMenuItem;
export const HeadingFourItem = (editor: Editor): EditorMenuItem => ({
key: "h4",
name: "Heading 4",
isActive: () => editor.isActive("heading", { level: 4 }),
command: () => toggleHeadingFour(editor),
icon: Heading4,
});
export const HeadingFourItem = (editor: Editor) =>
({
key: "h4",
name: "Heading 4",
isActive: () => editor.isActive("heading", { level: 4 }),
command: () => toggleHeadingFour(editor),
icon: Heading4,
}) as const satisfies EditorMenuItem;
export const HeadingFiveItem = (editor: Editor): EditorMenuItem => ({
key: "h5",
name: "Heading 5",
isActive: () => editor.isActive("heading", { level: 5 }),
command: () => toggleHeadingFive(editor),
icon: Heading5,
});
export const HeadingFiveItem = (editor: Editor) =>
({
key: "h5",
name: "Heading 5",
isActive: () => editor.isActive("heading", { level: 5 }),
command: () => toggleHeadingFive(editor),
icon: Heading5,
}) as const satisfies EditorMenuItem;
export const HeadingSixItem = (editor: Editor): EditorMenuItem => ({
key: "h6",
name: "Heading 6",
isActive: () => editor.isActive("heading", { level: 6 }),
command: () => toggleHeadingSix(editor),
icon: Heading6,
});
export const HeadingSixItem = (editor: Editor) =>
({
key: "h6",
name: "Heading 6",
isActive: () => editor.isActive("heading", { level: 6 }),
command: () => toggleHeadingSix(editor),
icon: Heading6,
}) as const satisfies EditorMenuItem;
export const BoldItem = (editor: Editor): EditorMenuItem => ({
key: "bold",
name: "Bold",
isActive: () => editor?.isActive("bold"),
command: () => toggleBold(editor),
icon: BoldIcon,
});
export const BoldItem = (editor: Editor) =>
({
key: "bold",
name: "Bold",
isActive: () => editor?.isActive("bold"),
command: () => toggleBold(editor),
icon: BoldIcon,
}) as const satisfies EditorMenuItem;
export const ItalicItem = (editor: Editor): EditorMenuItem => ({
key: "italic",
name: "Italic",
isActive: () => editor?.isActive("italic"),
command: () => toggleItalic(editor),
icon: ItalicIcon,
});
export const ItalicItem = (editor: Editor) =>
({
key: "italic",
name: "Italic",
isActive: () => editor?.isActive("italic"),
command: () => toggleItalic(editor),
icon: ItalicIcon,
}) as const satisfies EditorMenuItem;
export const UnderLineItem = (editor: Editor): EditorMenuItem => ({
key: "underline",
name: "Underline",
isActive: () => editor?.isActive("underline"),
command: () => toggleUnderline(editor),
icon: UnderlineIcon,
});
export const UnderLineItem = (editor: Editor) =>
({
key: "underline",
name: "Underline",
isActive: () => editor?.isActive("underline"),
command: () => toggleUnderline(editor),
icon: UnderlineIcon,
}) as const satisfies EditorMenuItem;
export const StrikeThroughItem = (editor: Editor): EditorMenuItem => ({
key: "strikethrough",
name: "Strikethrough",
isActive: () => editor?.isActive("strike"),
command: () => toggleStrike(editor),
icon: StrikethroughIcon,
});
export const StrikeThroughItem = (editor: Editor) =>
({
key: "strikethrough",
name: "Strikethrough",
isActive: () => editor?.isActive("strike"),
command: () => toggleStrike(editor),
icon: StrikethroughIcon,
}) as const satisfies EditorMenuItem;
export const BulletListItem = (editor: Editor): EditorMenuItem => ({
key: "bulleted-list",
name: "Bulleted list",
isActive: () => editor?.isActive("bulletList"),
command: () => toggleBulletList(editor),
icon: ListIcon,
});
export const BulletListItem = (editor: Editor) =>
({
key: "bulleted-list",
name: "Bulleted list",
isActive: () => editor?.isActive("bulletList"),
command: () => toggleBulletList(editor),
icon: ListIcon,
}) as const satisfies EditorMenuItem;
export const NumberedListItem = (editor: Editor): EditorMenuItem => ({
key: "numbered-list",
name: "Numbered list",
isActive: () => editor?.isActive("orderedList"),
command: () => toggleOrderedList(editor),
icon: ListOrderedIcon,
});
export const NumberedListItem = (editor: Editor) =>
({
key: "numbered-list",
name: "Numbered list",
isActive: () => editor?.isActive("orderedList"),
command: () => toggleOrderedList(editor),
icon: ListOrderedIcon,
}) as const satisfies EditorMenuItem;
export const TodoListItem = (editor: Editor): EditorMenuItem => ({
key: "to-do-list",
name: "To-do list",
isActive: () => editor.isActive("taskItem"),
command: () => toggleTaskList(editor),
icon: CheckSquare,
});
export const TodoListItem = (editor: Editor) =>
({
key: "to-do-list",
name: "To-do list",
isActive: () => editor.isActive("taskItem"),
command: () => toggleTaskList(editor),
icon: CheckSquare,
}) as const satisfies EditorMenuItem;
export const QuoteItem = (editor: Editor): EditorMenuItem => ({
key: "quote",
name: "Quote",
isActive: () => editor?.isActive("blockquote"),
command: () => toggleBlockquote(editor),
icon: QuoteIcon,
});
export const QuoteItem = (editor: Editor) =>
({
key: "quote",
name: "Quote",
isActive: () => editor?.isActive("blockquote"),
command: () => toggleBlockquote(editor),
icon: QuoteIcon,
}) as const satisfies EditorMenuItem;
export const CodeItem = (editor: Editor): EditorMenuItem => ({
key: "code",
name: "Code",
isActive: () => editor?.isActive("code") || editor?.isActive("codeBlock"),
command: () => toggleCodeBlock(editor),
icon: CodeIcon,
});
export const CodeItem = (editor: Editor) =>
({
key: "code",
name: "Code",
isActive: () => editor?.isActive("code") || editor?.isActive("codeBlock"),
command: () => toggleCodeBlock(editor),
icon: CodeIcon,
}) as const satisfies EditorMenuItem;
export const TableItem = (editor: Editor): EditorMenuItem => ({
key: "table",
name: "Table",
isActive: () => editor?.isActive("table"),
command: () => insertTableCommand(editor),
icon: TableIcon,
});
export const TableItem = (editor: Editor) =>
({
key: "table",
name: "Table",
isActive: () => editor?.isActive("table"),
command: () => insertTableCommand(editor),
icon: TableIcon,
}) as const satisfies EditorMenuItem;
export const ImageItem = (editor: Editor, uploadFile: UploadImage) =>
({
@@ -223,3 +240,6 @@ export function getEditorMenuItems(editor: Editor | null, uploadFile: UploadImag
ImageItem(editor, uploadFile),
];
}
export type EditorMenuItemNames =
ReturnType<typeof getEditorMenuItems> extends (infer U)[] ? (U extends { key: infer N } ? N : never) : never;
@@ -33,7 +33,7 @@ export const CustomCodeInlineExtension = Mark.create<CodeOptions>({
return {
HTMLAttributes: {
class:
"rounded bg-custom-background-80 px-[6px] py-[1.5px] font-mono font-medium text-orange-500 border-[0.5px] border-custom-border-200",
"rounded bg-custom-background-80 px-1 py-[2px] font-mono font-medium text-orange-500 border-[0.5px] border-custom-border-200",
spellcheck: "false",
},
};
@@ -40,9 +40,9 @@ export const CodeBlockComponent: React.FC<CodeBlockComponentProps> = ({ node })
<button
type="button"
className={cn(
"group/button hidden group-hover/code:flex items-center justify-center absolute top-2 right-2 z-10 size-8 rounded-md bg-custom-background-80 border border-custom-border-200 transition duration-150 ease-in-out backdrop-blur-sm",
"group/button hidden group-hover/code:flex items-center justify-center absolute top-2 right-2 z-10 size-8 rounded-md bg-custom-background-80 border border-custom-border-200 transition duration-150 ease-in-out",
{
"bg-green-500/30 hover:bg-green-500/30 active:bg-green-500/30": copied,
"bg-green-500/10 hover:bg-green-500/10 active:bg-green-500/10": copied,
}
)}
onClick={copyToClipboard}
@@ -55,7 +55,7 @@ export const CodeBlockComponent: React.FC<CodeBlockComponentProps> = ({ node })
</button>
</Tooltip>
<pre className="bg-custom-background-90 text-custom-text-100 rounded-lg p-4 my-2">
<pre className="bg-custom-background-90 text-custom-text-100 rounded-lg p-8 pl-9 pr-4">
<NodeViewContent as="code" className="whitespace-[pre-wrap]" />
</pre>
</NodeViewWrapper>
@@ -30,25 +30,23 @@ import { isValidHttpUrl } from "@/helpers/common";
import { DeleteImage, IMentionHighlight, IMentionSuggestion, RestoreImage, UploadImage } from "@/types";
type TArguments = {
enableHistory: boolean;
mentionConfig: {
mentionSuggestions?: () => Promise<IMentionSuggestion[]>;
mentionHighlights?: () => Promise<IMentionHighlight[]>;
};
fileConfig: {
deleteFile: DeleteImage;
restoreFile: RestoreImage;
cancelUploadImage?: () => void;
uploadFile: UploadImage;
};
mentionConfig: {
mentionSuggestions?: () => Promise<IMentionSuggestion[]>;
mentionHighlights?: () => Promise<IMentionHighlight[]>;
};
placeholder?: string | ((isFocused: boolean, value: string) => string);
tabIndex?: number;
};
export const CoreEditorExtensions = ({
enableHistory,
fileConfig: { deleteFile, restoreFile, cancelUploadImage, uploadFile },
mentionConfig,
fileConfig: { deleteFile, restoreFile, cancelUploadImage, uploadFile },
placeholder,
tabIndex,
}: TArguments) => [
@@ -72,11 +70,11 @@ export const CoreEditorExtensions = ({
codeBlock: false,
horizontalRule: false,
blockquote: false,
history: false,
dropcursor: {
color: "rgba(var(--color-text-100))",
width: 1,
},
...(enableHistory ? {} : { history: false }),
}),
CustomQuoteExtension,
DropHandlerExtension(uploadFile),
@@ -146,11 +146,10 @@ export const MentionList = forwardRef((props: MentionListProps, ref) => {
<div className="text-center text-custom-text-400">Loading...</div>
) : items.length ? (
items.map((item, index) => (
<button
<div
key={item.id}
type="button"
className={cn(
"w-full text-left flex cursor-pointer items-center gap-2 rounded px-1 py-1.5 hover:bg-custom-background-80 text-custom-text-200",
"flex cursor-pointer items-center gap-2 rounded px-1 py-1.5 hover:bg-custom-background-80 text-custom-text-200",
{
"bg-custom-background-80": index === selectedIndex,
}
@@ -159,7 +158,7 @@ export const MentionList = forwardRef((props: MentionListProps, ref) => {
>
<Avatar name={item?.title} src={item?.avatar} />
<span className="flex-grow truncate">{item.title}</span>
</button>
</div>
))
) : (
<div className="text-center text-custom-text-400">No results</div>
@@ -9,9 +9,6 @@ import {
Heading1,
Heading2,
Heading3,
Heading4,
Heading5,
Heading6,
ImageIcon,
List,
ListOrdered,
@@ -32,9 +29,6 @@ import {
toggleHeadingOne,
toggleHeadingTwo,
toggleHeadingThree,
toggleHeadingFour,
toggleHeadingFive,
toggleHeadingSix,
} from "@/helpers/editor-commands";
// types
import { CommandProps, ISlashCommandItem, UploadImage } from "@/types";
@@ -97,7 +91,7 @@ const getSuggestionItems =
title: "Text",
description: "Just start typing with plain text.",
searchTerms: ["p", "paragraph"],
icon: <CaseSensitive className="size-3.5" />,
icon: <CaseSensitive className="h-3.5 w-3.5" />,
command: ({ editor, range }: CommandProps) => {
if (range) {
editor.chain().focus().deleteRange(range).clearNodes().run();
@@ -106,91 +100,61 @@ const getSuggestionItems =
},
},
{
key: "h1",
key: "heading_1",
title: "Heading 1",
description: "Big section heading.",
searchTerms: ["title", "big", "large"],
icon: <Heading1 className="size-3.5" />,
icon: <Heading1 className="h-3.5 w-3.5" />,
command: ({ editor, range }: CommandProps) => {
toggleHeadingOne(editor, range);
},
},
{
key: "h2",
key: "heading_2",
title: "Heading 2",
description: "Medium section heading.",
searchTerms: ["subtitle", "medium"],
icon: <Heading2 className="size-3.5" />,
icon: <Heading2 className="h-3.5 w-3.5" />,
command: ({ editor, range }: CommandProps) => {
toggleHeadingTwo(editor, range);
},
},
{
key: "h3",
key: "heading_3",
title: "Heading 3",
description: "Small section heading.",
searchTerms: ["subtitle", "small"],
icon: <Heading3 className="size-3.5" />,
icon: <Heading3 className="h-3.5 w-3.5" />,
command: ({ editor, range }: CommandProps) => {
toggleHeadingThree(editor, range);
},
},
{
key: "h4",
title: "Heading 4",
description: "Small section heading.",
searchTerms: ["subtitle", "small"],
icon: <Heading4 className="size-3.5" />,
command: ({ editor, range }: CommandProps) => {
toggleHeadingFour(editor, range);
},
},
{
key: "h5",
title: "Heading 5",
description: "Small section heading.",
searchTerms: ["subtitle", "small"],
icon: <Heading5 className="size-3.5" />,
command: ({ editor, range }: CommandProps) => {
toggleHeadingFive(editor, range);
},
},
{
key: "h6",
title: "Heading 6",
description: "Small section heading.",
searchTerms: ["subtitle", "small"],
icon: <Heading6 className="size-3.5" />,
command: ({ editor, range }: CommandProps) => {
toggleHeadingSix(editor, range);
},
},
{
key: "to-do-list",
key: "todo_list",
title: "To do",
description: "Track tasks with a to-do list.",
searchTerms: ["todo", "task", "list", "check", "checkbox"],
icon: <ListTodo className="size-3.5" />,
icon: <ListTodo className="h-3.5 w-3.5" />,
command: ({ editor, range }: CommandProps) => {
toggleTaskList(editor, range);
},
},
{
key: "bulleted-list",
key: "bullet_list",
title: "Bullet list",
description: "Create a simple bullet list.",
searchTerms: ["unordered", "point"],
icon: <List className="size-3.5" />,
icon: <List className="h-3.5 w-3.5" />,
command: ({ editor, range }: CommandProps) => {
toggleBulletList(editor, range);
},
},
{
key: "numbered-list",
key: "numbered_list",
title: "Numbered list",
description: "Create a list with numbering.",
searchTerms: ["ordered"],
icon: <ListOrdered className="size-3.5" />,
icon: <ListOrdered className="h-3.5 w-3.5" />,
command: ({ editor, range }: CommandProps) => {
toggleOrderedList(editor, range);
},
@@ -200,25 +164,25 @@ const getSuggestionItems =
title: "Table",
description: "Create a table",
searchTerms: ["table", "cell", "db", "data", "tabular"],
icon: <Table className="size-3.5" />,
icon: <Table className="h-3.5 w-3.5" />,
command: ({ editor, range }: CommandProps) => {
insertTableCommand(editor, range);
},
},
{
key: "quote",
key: "quote_block",
title: "Quote",
description: "Capture a quote.",
searchTerms: ["blockquote"],
icon: <Quote className="size-3.5" />,
icon: <Quote className="h-3.5 w-3.5" />,
command: ({ editor, range }: CommandProps) => toggleBlockquote(editor, range),
},
{
key: "code",
key: "code_block",
title: "Code",
description: "Capture a code snippet.",
searchTerms: ["codeblock"],
icon: <Code2 className="size-3.5" />,
icon: <Code2 className="h-3.5 w-3.5" />,
command: ({ editor, range }: CommandProps) => editor.chain().focus().deleteRange(range).toggleCodeBlock().run(),
},
{
@@ -226,7 +190,7 @@ const getSuggestionItems =
title: "Image",
description: "Upload an image from your computer.",
searchTerms: ["img", "photo", "picture", "media"],
icon: <ImageIcon className="size-3.5" />,
icon: <ImageIcon className="h-3.5 w-3.5" />,
command: ({ editor, range }: CommandProps) => {
insertImageCommand(editor, uploadFile, null, range);
},
@@ -236,7 +200,7 @@ const getSuggestionItems =
title: "Divider",
description: "Visually divide blocks.",
searchTerms: ["line", "divider", "horizontal", "rule", "separate"],
icon: <MinusSquare className="size-3.5" />,
icon: <MinusSquare className="h-3.5 w-3.5" />,
command: ({ editor, range }: CommandProps) => {
editor.chain().focus().deleteRange(range).setHorizontalRule().run();
},
@@ -87,7 +87,6 @@ export const useDocumentEditor = (props: DocumentEditorProps) => {
id,
editorProps,
editorClassName,
enableHistory: false,
fileHandler,
handleEditorReady,
forwardedRef,
+32 -43
View File
@@ -3,7 +3,7 @@ import { Selection } from "@tiptap/pm/state";
import { EditorProps } from "@tiptap/pm/view";
import { useEditor as useCustomEditor, Editor } from "@tiptap/react";
// components
import { getEditorMenuItems } from "@/components/menus";
import { EditorMenuItemNames, getEditorMenuItems } from "@/components/menus";
// extensions
import { CoreEditorExtensions } from "@/extensions";
// helpers
@@ -14,15 +14,7 @@ import { CollaborationProvider } from "@/plane-editor/providers";
// props
import { CoreEditorProps } from "@/props";
// types
import {
DeleteImage,
EditorRefApi,
IMentionHighlight,
IMentionSuggestion,
RestoreImage,
TEditorCommands,
UploadImage,
} from "@/types";
import { DeleteImage, EditorRefApi, IMentionHighlight, IMentionSuggestion, RestoreImage, UploadImage } from "@/types";
export type TFileHandler = {
cancel: () => void;
@@ -32,44 +24,42 @@ export type TFileHandler = {
};
export interface CustomEditorProps {
editorClassName: string;
editorProps?: EditorProps;
enableHistory: boolean;
extensions?: any;
fileHandler: TFileHandler;
forwardedRef?: MutableRefObject<EditorRefApi | null>;
handleEditorReady?: (value: boolean) => void;
id?: string;
fileHandler: TFileHandler;
initialValue?: string;
editorClassName: string;
// undefined when prop is not passed, null if intentionally passed to stop
// swr syncing
value?: string | null | undefined;
provider?: CollaborationProvider;
onChange?: (json: object, html: string) => void;
extensions?: any;
editorProps?: EditorProps;
forwardedRef?: MutableRefObject<EditorRefApi | null>;
mentionHandler: {
highlights: () => Promise<IMentionHighlight[]>;
suggestions?: () => Promise<IMentionSuggestion[]>;
};
onChange?: (json: object, html: string) => void;
handleEditorReady?: (value: boolean) => void;
placeholder?: string | ((isFocused: boolean, value: string) => string);
provider?: CollaborationProvider;
tabIndex?: number;
// undefined when prop is not passed, null if intentionally passed to stop
// swr syncing
value?: string | null | undefined;
}
export const useEditor = ({
editorClassName,
id = "",
editorProps = {},
enableHistory,
initialValue,
editorClassName,
value,
extensions = [],
fileHandler,
forwardedRef,
handleEditorReady,
id = "",
initialValue,
mentionHandler,
onChange,
placeholder,
provider,
forwardedRef,
tabIndex,
value,
handleEditorReady,
provider,
mentionHandler,
placeholder,
}: CustomEditorProps) => {
const editor = useCustomEditor({
editorProps: {
@@ -78,17 +68,16 @@ export const useEditor = ({
},
extensions: [
...CoreEditorExtensions({
enableHistory,
mentionConfig: {
mentionSuggestions: mentionHandler.suggestions ?? (() => Promise.resolve<IMentionSuggestion[]>([])),
mentionHighlights: mentionHandler.highlights ?? [],
},
fileConfig: {
uploadFile: fileHandler.upload,
deleteFile: fileHandler.delete,
restoreFile: fileHandler.restore,
cancelUploadImage: fileHandler.cancel,
},
mentionConfig: {
mentionSuggestions: mentionHandler.suggestions ?? (() => Promise.resolve<IMentionSuggestion[]>([])),
mentionHighlights: mentionHandler.highlights ?? [],
},
placeholder,
tabIndex,
}),
@@ -155,12 +144,12 @@ export const useEditor = ({
insertContentAtSavedSelection(editorRef, content, savedSelection);
}
},
executeMenuItemCommand: (itemKey: TEditorCommands) => {
executeMenuItemCommand: (itemName: EditorMenuItemNames) => {
const editorItems = getEditorMenuItems(editorRef.current, fileHandler.upload);
const getEditorMenuItem = (itemKey: TEditorCommands) => editorItems.find((item) => item.key === itemKey);
const getEditorMenuItem = (itemName: EditorMenuItemNames) => editorItems.find((item) => item.key === itemName);
const item = getEditorMenuItem(itemKey);
const item = getEditorMenuItem(itemName);
if (item) {
if (item.key === "image") {
item.command(savedSelectionRef.current);
@@ -168,13 +157,13 @@ export const useEditor = ({
item.command();
}
} else {
console.warn(`No command found for item: ${itemKey}`);
console.warn(`No command found for item: ${itemName}`);
}
},
isMenuItemActive: (itemName: TEditorCommands): boolean => {
isMenuItemActive: (itemName: EditorMenuItemNames): boolean => {
const editorItems = getEditorMenuItems(editorRef.current, fileHandler.upload);
const getEditorMenuItem = (itemName: TEditorCommands) => editorItems.find((item) => item.key === itemName);
const getEditorMenuItem = (itemName: EditorMenuItemNames) => editorItems.find((item) => item.key === itemName);
const item = getEditorMenuItem(itemName);
return item ? item.isActive() : false;
},
+5 -3
View File
@@ -1,9 +1,11 @@
// components
import { EditorMenuItemNames } from "@/components/menus";
// helpers
import { IMarking } from "@/helpers/scroll-to-node";
// hooks
import { TFileHandler } from "@/hooks/use-editor";
// types
import { IMentionHighlight, IMentionSuggestion, TEditorCommands } from "@/types";
import { IMentionHighlight, IMentionSuggestion } from "@/types";
export type EditorReadOnlyRefApi = {
getMarkDown: () => string;
@@ -15,8 +17,8 @@ export type EditorReadOnlyRefApi = {
export interface EditorRefApi extends EditorReadOnlyRefApi {
setEditorValueAtCursorPosition: (content: string) => void;
executeMenuItemCommand: (itemKey: TEditorCommands) => void;
isMenuItemActive: (itemKey: TEditorCommands) => boolean;
executeMenuItemCommand: (itemName: EditorMenuItemNames) => void;
isMenuItemActive: (itemName: EditorMenuItemNames) => boolean;
onStateChange: (callback: () => void) => () => void;
setFocusAtPosition: (position: number) => void;
isEditorReadyToDiscard: () => boolean;
-1
View File
@@ -3,4 +3,3 @@ export * from "./embed";
export * from "./image";
export * from "./mention-suggestion";
export * from "./slash-commands-suggestion";
export * from "@/plane-editor/types";
@@ -1,34 +1,13 @@
import { ReactNode } from "react";
import { Editor, Range } from "@tiptap/core";
export type TEditorCommands =
| "text"
| "h1"
| "h2"
| "h3"
| "h4"
| "h5"
| "h6"
| "bold"
| "italic"
| "underline"
| "strikethrough"
| "bulleted-list"
| "numbered-list"
| "to-do-list"
| "quote"
| "code"
| "table"
| "image"
| "divider";
export type CommandProps = {
editor: Editor;
range: Range;
};
export type ISlashCommandItem = {
key: TEditorCommands;
key: string;
title: string;
description: string;
searchTerms: string[];
@@ -270,7 +270,7 @@ module.exports = {
"--tw-prose-headings": convertToRGB("--color-text-100"),
"--tw-prose-lead": convertToRGB("--color-text-100"),
"--tw-prose-links": convertToRGB("--color-primary-100"),
"--tw-prose-bold": "inherit",
"--tw-prose-bold": convertToRGB("--color-text-100"),
"--tw-prose-counters": convertToRGB("--color-text-100"),
"--tw-prose-bullets": convertToRGB("--color-text-100"),
"--tw-prose-hr": convertToRGB("--color-text-100"),
+2 -7
View File
@@ -1,4 +1,4 @@
import type {TIssue, IIssueFilterOptions} from "@plane/types";
import type { TIssue, IIssueFilterOptions } from "@plane/types";
export type TCycleGroups = "current" | "upcoming" | "completed" | "draft";
@@ -61,10 +61,6 @@ export type TProgressSnapshot = {
estimate_distribution?: TCycleEstimateDistribution;
};
export interface IProjectDetails {
id: string;
}
export interface ICycle extends TProgressSnapshot {
progress_snapshot: TProgressSnapshot | undefined;
@@ -89,7 +85,6 @@ export interface ICycle extends TProgressSnapshot {
filters: IIssueFilterOptions;
};
workspace_id: string;
project_detail: IProjectDetails;
}
export interface CycleIssueResponse {
@@ -107,7 +102,7 @@ export interface CycleIssueResponse {
}
export type SelectCycleType =
| (ICycle & {actionType: "edit" | "delete" | "create-issue"})
| (ICycle & { actionType: "edit" | "delete" | "create-issue" })
| undefined;
export type CycleDateCheckData = {
-5
View File
@@ -55,9 +55,4 @@ export type TIssueActivityComment =
id: string;
activity_type: "ACTIVITY";
created_at?: string;
}
| {
id: string;
activity_type: "WORKLOG";
created_at?: string;
};
+4 -10
View File
@@ -42,10 +42,13 @@ export type TBaseIssue = {
export type TIssue = TBaseIssue & {
description_html?: string;
is_subscribed?: boolean;
parent?: partial<TIssue>;
issue_reactions?: TIssueReaction[];
issue_attachment?: TIssueAttachment[];
issue_link?: TIssueLink[];
// tempId is used for optimistic updates. It is not a part of the API response.
tempId?: string;
};
@@ -81,7 +84,7 @@ export type TIssuesResponse = {
total_pages: number;
extra_stats: null;
results: TIssueResponseResults;
};
}
export type TBulkIssueProperties = Pick<
TIssue,
@@ -91,18 +94,9 @@ export type TBulkIssueProperties = Pick<
| "assignee_ids"
| "start_date"
| "target_date"
| "module_ids"
| "cycle_id"
| "estimate_point"
>;
export type TBulkOperationsPayload = {
issue_ids: string[];
properties: Partial<TBulkIssueProperties>;
};
export type TIssueDetailWidget =
| "sub-issues"
| "relations"
| "links"
| "attachments";
-2
View File
@@ -46,5 +46,3 @@ export type TPageFilters = {
sortBy: TPageFiltersSortBy;
filters?: TPageFilterProps;
};
export type TPageEmbedType = "mention" | "issue";
-1
View File
@@ -35,7 +35,6 @@ export interface IProject {
anchor: string | null;
is_favorite: boolean;
is_member: boolean;
is_time_tracking_enabled: boolean;
logo_props: TLogoProps;
member_role: EUserProjectRoles | null;
members: IProjectMemberLite[];
-2
View File
@@ -202,6 +202,4 @@ export interface IssuePaginationOptions {
before?: string;
after?: string;
groupedBy?: TIssueGroupByOptions;
subGroupedBy?: TIssueGroupByOptions;
orderBy?: TIssueOrderByOptions;
}
-12
View File
@@ -25,21 +25,9 @@ export interface IProjectView {
workspace: string;
logo_props: TLogoProps | undefined;
is_locked: boolean;
anchor?: string;
owned_by: string;
}
export type TPublishViewSettings = {
is_comments_enabled: boolean;
is_reactions_enabled: boolean;
is_votes_enabled: boolean;
};
export type TPublishViewDetails = TPublishViewSettings & {
id: string;
anchor: string;
};
export type TViewFiltersSortKey = "name" | "created_at" | "updated_at";
export type TViewFiltersSortBy = "asc" | "desc";
+1 -4
View File
@@ -51,7 +51,6 @@ export type TNotification = {
archived_at: string | undefined;
snoozed_till: string | undefined;
is_inbox_issue: boolean | undefined;
is_mentioned_notification: boolean | undefined;
workspace: string | undefined;
project: string | undefined;
created_at: string | undefined;
@@ -65,7 +64,6 @@ export type TNotificationPaginatedInfoQueryParams = {
type?: string | undefined;
snoozed?: boolean;
archived?: boolean;
mentioned?: boolean;
read?: boolean;
per_page?: number;
cursor?: string;
@@ -88,10 +86,9 @@ export type TNotificationPaginatedInfo = {
// notification count
export type TUnreadNotificationsCount = {
total_unread_notifications_count: number;
mention_unread_notifications_count: number;
};
export type TNotificationLite = {
export type TCurrentSelectedNotification = {
workspace_slug: string | undefined;
project_id: string | undefined;
notification_id: string | undefined;
+2 -32
View File
@@ -1,6 +1,5 @@
import {EUserWorkspaceRoles} from "@/constants/workspace";
import { EUserWorkspaceRoles } from "@/constants/workspace";
import type {
ICycle,
IProjectMember,
IUser,
IUserLite,
@@ -47,7 +46,7 @@ export interface IWorkspaceMemberInvitation {
}
export interface IWorkspaceBulkInviteFormData {
emails: {email: string; role: EUserWorkspaceRoles}[];
emails: { email: string; role: EUserWorkspaceRoles }[];
}
export type Properties = {
@@ -70,13 +69,6 @@ export interface IWorkspaceMember {
id: string;
member: IUserLite;
role: EUserWorkspaceRoles;
created_at?: string;
avatar?: string;
email?: string;
first_name?: string;
last_name?: string;
joining_date?: string;
display_name?: string;
}
export interface IWorkspaceMemberMe {
@@ -198,25 +190,3 @@ export interface IProductUpdateResponse {
eyes: number;
};
}
export interface IWorkspaceActiveCyclesResponse {
count: number;
extra_stats: null;
next_cursor: string;
next_page_results: boolean;
prev_cursor: string;
prev_page_results: boolean;
results: ICycle[];
total_pages: number;
}
export interface IWorkspaceProgressResponse {
completed_issues: number;
total_issues: number;
started_issues: number;
cancelled_issues: number;
unstarted_issues: number;
}
export interface IWorkspaceAnalyticsResponse {
completion_chart: any;
}

Some files were not shown because too many files have changed in this diff Show More