Compare commits

..

34 Commits

Author SHA1 Message Date
Dakshesh Jain 6420c1b170 style: not showing zero & null value 2023-07-18 17:53:03 +05:30
Dakshesh Jain e3d489ae52 fix: 'read' -> 'unread' heading, my issue wrong filter 2023-07-18 16:47:04 +05:30
Dakshesh Jain b080b3047c Merge branch 'develop' of https://github.com/makeplane/plane into feat/frontend/notifications 2023-07-17 18:32:36 +05:30
Dakshesh Jain 110be4c882 feat: not showing 'subscribe' button on issue created by user & assigned to user
not showing 'Create by you' for view & guest of the workspace
2023-07-17 18:32:19 +05:30
Dakshesh Jain 5b22b66064 fix: merge conflicts 2023-07-17 15:58:52 +05:30
Dakshesh Jain cc2146a404 feat: showing un-read notification count 2023-07-17 13:59:26 +05:30
Dakshesh Jain 82d03b4882 Merge branch 'develop' of https://github.com/makeplane/plane into feat/frontend/notifications 2023-07-14 19:24:15 +05:30
Dakshesh Jain 7045f80ec6 style: improved UX with toast alerts and other interactions
refactor: changed classnames according to new theme structure, changed all icons to material icons
2023-07-14 19:23:56 +05:30
Dakshesh Jain be39b097ab fix: merge conflict 2023-07-13 21:01:55 +05:30
Dakshesh Jain fd9d76f15f fix: merge conflict 2023-07-13 20:37:19 +05:30
Dakshesh Jain e802790581 feat: notification workflow setup and made basic UI 2023-07-13 20:35:40 +05:30
NarayanBavisetti 53487eaadd fix: pushing local settings 2023-07-12 12:18:33 +05:30
NarayanBavisetti c65f93c971 fix: resolved merge conflict 2023-07-12 12:17:51 +05:30
pablohashescobar cc513b9030 dev: notification new endpoints 2023-07-11 12:53:06 +05:30
pablohashescobar 624dfdb2b6 dev: add issue name in notifications 2023-07-10 17:52:02 +05:30
pablohashescobar 6c3be6faba dev: notification filtering 2023-07-10 13:51:36 +05:30
pablohashescobar 90c819b1ba dev: update notifications 2023-07-07 18:07:06 +05:30
pablohashescobar 581dff5482 dev: notification segregation 2023-07-07 16:02:17 +05:30
pablohashescobar 9a6026c0c7 Merge branch 'develop' of github.com:makeplane/plane into feat/notifications 2023-07-07 15:17:26 +05:30
pablohashescobar ea605cf48c dev: update issue notifications 2023-07-03 17:28:09 +05:30
pablohashescobar ebc9f36dc2 Merge branch 'develop' of github.com:makeplane/plane into feat/notifications 2023-07-03 17:07:29 +05:30
pablohashescobar e73b39fcdf dev: filter by snoozed and response update for list and permissions 2023-06-23 11:25:27 +05:30
NarayanBavisetti be96ec47ba feat: added list and unsubscribe function in issue subscriber 2023-06-23 01:15:35 +05:30
pablohashescobar dcde6b2567 dev: notification endpoints and fix bg worker for saving notifications 2023-06-22 21:31:30 +05:30
pablohashescobar 8260e2f897 Merge branch 'develop' of github.com:makeplane/plane into feat/notifications 2023-06-22 20:56:53 +05:30
pablohashescobar 2cd3dc2f9d dev: remove delete endpoint response data 2023-06-22 19:01:15 +05:30
pablohashescobar ecfc09da30 Merge branch 'feat/notifications' of github.com:makeplane/plane into feat/notifications 2023-06-22 18:44:47 +05:30
pablohashescobar 0af49306a9 dev: notification models and operations 2023-06-22 18:38:51 +05:30
NarayanBavisetti cf4bb02a9f feat: added CRUD operation for issue subscriber 2023-06-22 18:27:40 +05:30
NarayanBavisetti 9c0a2b74b3 Revert "feat: added CRUD operation for issue subscriber"
This reverts commit b22e062576.
2023-06-22 18:24:22 +05:30
NarayanBavisetti b22e062576 feat: added CRUD operation for issue subscriber 2023-06-22 18:20:59 +05:30
pablohashescobar f80f98ddef dev: notification model 2023-06-22 13:38:45 +05:30
pablohashescobar 9fd245a53c Merge branch 'develop' of github.com:makeplane/plane into feat/notifications 2023-06-22 10:43:05 +05:30
NarayanBavisetti b2451ac03d feat: added new issue subscriber table 2023-06-21 12:04:47 +05:30
1026 changed files with 17707 additions and 40455 deletions
-4
View File
@@ -19,10 +19,6 @@ NEXT_PUBLIC_ENABLE_SESSION_RECORDER=0
NEXT_PUBLIC_TRACK_EVENTS=0
# Slack for Slack Integration
NEXT_PUBLIC_SLACK_CLIENT_ID=""
# For Telemetry, set it to "app.plane.so"
NEXT_PUBLIC_PLAUSIBLE_DOMAIN=""
# public boards deploy url
NEXT_PUBLIC_DEPLOY_URL=""
# Backend
# Debug value for api server use it as 0 for production use
+1 -1
View File
@@ -4,7 +4,7 @@ module.exports = {
extends: ["custom"],
settings: {
next: {
rootDir: ["web/", "deploy/"],
rootDir: ["apps/*"],
},
},
};
+1 -3
View File
@@ -70,6 +70,4 @@ package-lock.json
# lock files
package-lock.json
pnpm-lock.yaml
pnpm-workspace.yaml
.npmrc
pnpm-workspace.yaml
+3 -10
View File
@@ -5,11 +5,9 @@ WORKDIR /app
ENV NEXT_PUBLIC_API_BASE_URL=http://NEXT_PUBLIC_API_BASE_URL_PLACEHOLDER
RUN yarn global add turbo
RUN apk add tree
COPY . .
RUN turbo prune --scope=app --scope=plane-deploy --docker
CMD tree -I node_modules/
RUN turbo prune --scope=app --docker
# Add lockfile and package.json's of isolated subworkspace
FROM node:18-alpine AS installer
@@ -23,14 +21,14 @@ COPY --from=builder /app/out/json/ .
COPY --from=builder /app/out/yarn.lock ./yarn.lock
RUN yarn install
# # Build the project
# Build the project
COPY --from=builder /app/out/full/ .
COPY turbo.json turbo.json
COPY replace-env-vars.sh /usr/local/bin/
USER root
RUN chmod +x /usr/local/bin/replace-env-vars.sh
RUN yarn turbo run build
RUN yarn turbo run build --filter=app
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL \
BUILT_NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
@@ -98,16 +96,11 @@ RUN adduser --system --uid 1001 captain
COPY --from=installer /app/apps/app/next.config.js .
COPY --from=installer /app/apps/app/package.json .
COPY --from=installer /app/apps/space/next.config.js .
COPY --from=installer /app/apps/space/package.json .
COPY --from=installer --chown=captain:plane /app/apps/app/.next/standalone ./
COPY --from=installer --chown=captain:plane /app/apps/app/.next/static ./apps/app/.next/static
COPY --from=installer --chown=captain:plane /app/apps/space/.next/standalone ./
COPY --from=installer --chown=captain:plane /app/apps/space/.next ./apps/space/.next
ENV NEXT_TELEMETRY_DISABLED 1
# RUN rm /etc/nginx/conf.d/default.conf
+11 -21
View File
@@ -2,7 +2,7 @@
<p align="center">
<a href="https://plane.so">
<img src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-readme/plane_logo_.webp" alt="Plane Logo" width="70">
<img src="https://res.cloudinary.com/toolspacedev/image/upload/v1680596414/Plane/Plane_Icon_Blue_on_White_150x150_muysa3.jpg" alt="Plane Logo" width="70">
</a>
</p>
@@ -11,22 +11,22 @@
<p align="center">
<a href="https://discord.com/invite/A92xrEGCge">
<img alt="Discord online members" src="https://img.shields.io/discord/1031547764020084846?color=5865F2&label=Discord&style=for-the-badge" />
<img alt="Discord" src="https://img.shields.io/discord/1031547764020084846?color=5865F2&label=Discord&style=for-the-badge" />
</a>
<img alt="Commit activity per month" src="https://img.shields.io/github/commit-activity/m/makeplane/plane?style=for-the-badge" />
<img alt="Discord" src="https://img.shields.io/github/commit-activity/m/makeplane/plane?style=for-the-badge" />
</p>
<p>
<a href="https://app.plane.so/#gh-light-mode-only" target="_blank">
<img
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-readme/plane_screen.webp"
src="https://ik.imagekit.io/killbluedog/Plane_Screen.png?updatedAt=1684942001069"
alt="Plane Screens"
width="100%"
/>
</a>
<a href="https://app.plane.so/#gh-dark-mode-only" target="_blank">
<img
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-readme/plane_screens_dark_mode.webp"
src="https://ik.imagekit.io/killbluedog/Plane_Screens_Dark_Mode.png?updatedAt=1684942388044"
alt="Plane Screens"
width="100%"
/>
@@ -61,16 +61,6 @@ chmod +x setup.sh
> If running in a cloud env replace localhost with public facing IP address of the VM
- Setup Tiptap Pro
Visit [Tiptap Pro](https://collab.tiptap.dev/pro-extensions) and signup (it is free).
Create a **`.npmrc`** file, copy the following and replace your registry token generated from Tiptap Pro.
```
@tiptap-pro:registry=https://registry.tiptap.dev/
//registry.tiptap.dev/:_authToken=YOUR_REGISTRY_TOKEN
```
- Run Docker compose up
```bash
@@ -96,7 +86,7 @@ docker compose up -d
<p>
<a href="https://plane.so" target="_blank">
<img
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-readme/plane_views_dark_mode.webp"
src="https://ik.imagekit.io/killbluedog/Plane_Views_Dark_Mode.png?updatedAt=1684943050275"
alt="Plane Views"
width="100%"
/>
@@ -105,7 +95,7 @@ docker compose up -d
<p>
<a href="https://plane.so" target="_blank">
<img
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-readme/plane_issue_detail_dark_mode.webp"
src="https://ik.imagekit.io/killbluedog/Plane_Issue_Detail_Dark_Mode.png?updatedAt=1684943050202"
alt="Plane Issue Details"
width="100%"
/>
@@ -114,7 +104,7 @@ docker compose up -d
<p>
<a href="https://plane.so" target="_blank">
<img
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-readme/plane_cycles_modules_dark_mode.webp"
src="https://ik.imagekit.io/killbluedog/Plane_Cycles___Modules_Dark_Mode.png?updatedAt=1684943050281"
alt="Plane Cycles and Modules"
width="100%"
/>
@@ -123,7 +113,7 @@ docker compose up -d
<p>
<a href="https://plane.so" target="_blank">
<img
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-readme/plane_analytics_dark_mode.webp"
src="https://ik.imagekit.io/killbluedog/Plane_Analytics_Dark_Mode.png?updatedAt=1684944596824"
alt="Plane Analytics"
width="100%"
/>
@@ -132,7 +122,7 @@ docker compose up -d
<p>
<a href="https://plane.so" target="_blank">
<img
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-readme/plane_pages_dark_mode.webp"
src="https://ik.imagekit.io/killbluedog/Plane_Pages_Dark_Mode.png?updatedAt=1684943050202"
alt="Plane Pages"
width="100%"
/>
@@ -142,7 +132,7 @@ docker compose up -d
<p>
<a href="https://plane.so" target="_blank">
<img
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-readme/plane_commad_k_dark_mode.webp"
src="https://ik.imagekit.io/killbluedog/Plane_Commad_K_Dark_Mode.png?updatedAt=1684943050312"
alt="Plane Command Menu"
width="100%"
/>
+1 -1
View File
@@ -49,7 +49,7 @@ USER root
RUN apk --no-cache add "bash~=5.2"
COPY ./bin ./bin/
RUN chmod +x ./bin/takeoff ./bin/worker ./bin/beat
RUN chmod +x ./bin/takeoff ./bin/worker
RUN chmod -R 777 /code
USER captain
-5
View File
@@ -1,5 +0,0 @@
#!/bin/bash
set -e
python manage.py wait_for_db
celery -A plane beat -l info
+1 -1
View File
@@ -6,4 +6,4 @@ python manage.py migrate
# Create a Default User
python bin/user_script.py
exec gunicorn -w 8 -k uvicorn.workers.UvicornWorker plane.asgi:application --bind 0.0.0.0:8000 --max-requests 1200 --max-requests-jitter 1000 --access-logfile -
exec gunicorn -w 8 -k uvicorn.workers.UvicornWorker plane.asgi:application --bind 0.0.0.0:8000 --config gunicorn.config.py --max-requests 1200 --max-requests-jitter 1000 --access-logfile -
+4 -4
View File
@@ -1,4 +1,4 @@
import os, sys, random, string
import os, sys
import uuid
sys.path.append("/code")
@@ -19,9 +19,9 @@ def populate():
user = User.objects.create(email=default_email, username=uuid.uuid4().hex)
user.set_password(default_password)
user.save()
print(f"User created with an email: {default_email}")
else:
print(f"User already exists with the default email: {default_email}")
print("User created")
print("Success")
if __name__ == "__main__":
+1 -1
View File
@@ -1,2 +1,2 @@
from .workspace import WorkSpaceBasePermission, WorkSpaceAdminPermission, WorkspaceEntityPermission, WorkspaceViewerPermission
from .workspace import WorkSpaceBasePermission, WorkSpaceAdminPermission, WorkspaceEntityPermission
from .project import ProjectBasePermission, ProjectEntityPermission, ProjectMemberPermission, ProjectLitePermission
@@ -61,13 +61,3 @@ class WorkspaceEntityPermission(BasePermission):
return WorkspaceMember.objects.filter(
member=request.user, workspace__slug=view.workspace_slug
).exists()
class WorkspaceViewerPermission(BasePermission):
def has_permission(self, request, view):
if request.user.is_anonymous:
return False
return WorkspaceMember.objects.filter(
member=request.user, workspace__slug=view.workspace_slug, role__gte=10
).exists()
+7 -10
View File
@@ -1,5 +1,10 @@
from .base import BaseSerializer
from .user import UserSerializer, UserLiteSerializer, ChangePasswordSerializer, ResetPasswordSerializer, UserAdminLiteSerializer
from .people import (
ChangePasswordSerializer,
ResetPasswordSerializer,
TokenSerializer,
)
from .user import UserSerializer, UserLiteSerializer
from .workspace import (
WorkSpaceSerializer,
WorkSpaceMemberSerializer,
@@ -7,7 +12,6 @@ from .workspace import (
WorkSpaceMemberInviteSerializer,
WorkspaceLiteSerializer,
WorkspaceThemeSerializer,
WorkspaceMemberAdminSerializer,
)
from .project import (
ProjectSerializer,
@@ -18,12 +22,10 @@ from .project import (
ProjectFavoriteSerializer,
ProjectLiteSerializer,
ProjectMemberLiteSerializer,
ProjectDeployBoardSerializer,
ProjectMemberAdminSerializer,
)
from .state import StateSerializer, StateLiteSerializer
from .view import IssueViewSerializer, IssueViewFavoriteSerializer
from .cycle import CycleSerializer, CycleIssueSerializer, CycleFavoriteSerializer, CycleWriteSerializer
from .cycle import CycleSerializer, CycleIssueSerializer, CycleFavoriteSerializer
from .asset import FileAssetSerializer
from .issue import (
IssueCreateSerializer,
@@ -41,9 +43,6 @@ from .issue import (
IssueLiteSerializer,
IssueAttachmentSerializer,
IssueSubscriberSerializer,
IssueReactionSerializer,
CommentReactionSerializer,
IssueVoteSerializer,
)
from .module import (
@@ -81,5 +80,3 @@ from .inbox import InboxSerializer, InboxIssueSerializer, IssueStateInboxSeriali
from .analytic import AnalyticViewSerializer
from .notification import NotificationSerializer
from .exporter import ExporterHistorySerializer
-17
View File
@@ -12,17 +12,6 @@ from .workspace import WorkspaceLiteSerializer
from .project import ProjectLiteSerializer
from plane.db.models import Cycle, CycleIssue, CycleFavorite
class CycleWriteSerializer(BaseSerializer):
def validate(self, data):
if data.get("start_date", None) is not None and data.get("end_date", None) is not None and data.get("start_date", None) > data.get("end_date", None):
raise serializers.ValidationError("Start date cannot exceed end date")
return data
class Meta:
model = Cycle
fields = "__all__"
class CycleSerializer(BaseSerializer):
owned_by = UserLiteSerializer(read_only=True)
@@ -40,18 +29,12 @@ class CycleSerializer(BaseSerializer):
started_estimates = serializers.IntegerField(read_only=True)
workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace")
project_detail = ProjectLiteSerializer(read_only=True, source="project")
def validate(self, data):
if data.get("start_date", None) is not None and data.get("end_date", None) is not None and data.get("start_date", None) > data.get("end_date", None):
raise serializers.ValidationError("Start date cannot exceed end date")
return data
def get_assignees(self, obj):
members = [
{
"avatar": assignee.avatar,
"first_name": assignee.first_name,
"display_name": assignee.display_name,
"id": assignee.id,
}
for issue_cycle in obj.issue_cycle.all()
@@ -1,26 +0,0 @@
# Module imports
from .base import BaseSerializer
from plane.db.models import ExporterHistory
from .user import UserLiteSerializer
class ExporterHistorySerializer(BaseSerializer):
initiated_by_detail = UserLiteSerializer(source="initiated_by", read_only=True)
class Meta:
model = ExporterHistory
fields = [
"id",
"created_at",
"updated_at",
"project",
"provider",
"status",
"url",
"initiated_by",
"initiated_by_detail",
"token",
"created_by",
"updated_by",
]
read_only_fields = fields
+46 -189
View File
@@ -29,9 +29,6 @@ from plane.db.models import (
ModuleIssue,
IssueLink,
IssueAttachment,
IssueReaction,
CommentReaction,
IssueVote,
)
@@ -53,20 +50,6 @@ class IssueFlatSerializer(BaseSerializer):
]
class IssueProjectLiteSerializer(BaseSerializer):
project_detail = ProjectLiteSerializer(source="project", read_only=True)
class Meta:
model = Issue
fields = [
"id",
"project_detail",
"name",
"sequence_id",
]
read_only_fields = fields
##TODO: Find a better way to write this serializer
## Find a better approach to save manytomany?
class IssueCreateSerializer(BaseSerializer):
@@ -112,26 +95,14 @@ class IssueCreateSerializer(BaseSerializer):
"updated_at",
]
def validate(self, data):
if data.get("start_date", None) is not None and data.get("target_date", None) is not None and data.get("start_date", None) > data.get("target_date", None):
raise serializers.ValidationError("Start date cannot exceed target date")
return data
def create(self, validated_data):
blockers = validated_data.pop("blockers_list", None)
assignees = validated_data.pop("assignees_list", None)
labels = validated_data.pop("labels_list", None)
blocks = validated_data.pop("blocks_list", None)
project_id = self.context["project_id"]
workspace_id = self.context["workspace_id"]
default_assignee_id = self.context["default_assignee_id"]
issue = Issue.objects.create(**validated_data, project_id=project_id)
# Issue Audit Users
created_by_id = issue.created_by_id
updated_by_id = issue.updated_by_id
project = self.context["project"]
issue = Issue.objects.create(**validated_data, project=project)
if blockers is not None and len(blockers):
IssueBlocker.objects.bulk_create(
@@ -139,10 +110,10 @@ class IssueCreateSerializer(BaseSerializer):
IssueBlocker(
block=issue,
blocked_by=blocker,
project_id=project_id,
workspace_id=workspace_id,
created_by_id=created_by_id,
updated_by_id=updated_by_id,
project=project,
workspace=project.workspace,
created_by=issue.created_by,
updated_by=issue.updated_by,
)
for blocker in blockers
],
@@ -155,10 +126,10 @@ class IssueCreateSerializer(BaseSerializer):
IssueAssignee(
assignee=user,
issue=issue,
project_id=project_id,
workspace_id=workspace_id,
created_by_id=created_by_id,
updated_by_id=updated_by_id,
project=project,
workspace=project.workspace,
created_by=issue.created_by,
updated_by=issue.updated_by,
)
for user in assignees
],
@@ -166,14 +137,14 @@ class IssueCreateSerializer(BaseSerializer):
)
else:
# Then assign it to default assignee
if default_assignee_id is not None:
if project.default_assignee is not None:
IssueAssignee.objects.create(
assignee_id=default_assignee_id,
assignee=project.default_assignee,
issue=issue,
project_id=project_id,
workspace_id=workspace_id,
created_by_id=created_by_id,
updated_by_id=updated_by_id,
project=project,
workspace=project.workspace,
created_by=issue.created_by,
updated_by=issue.updated_by,
)
if labels is not None and len(labels):
@@ -182,10 +153,10 @@ class IssueCreateSerializer(BaseSerializer):
IssueLabel(
label=label,
issue=issue,
project_id=project_id,
workspace_id=workspace_id,
created_by_id=created_by_id,
updated_by_id=updated_by_id,
project=project,
workspace=project.workspace,
created_by=issue.created_by,
updated_by=issue.updated_by,
)
for label in labels
],
@@ -198,10 +169,10 @@ class IssueCreateSerializer(BaseSerializer):
IssueBlocker(
block=block,
blocked_by=issue,
project_id=project_id,
workspace_id=workspace_id,
created_by_id=created_by_id,
updated_by_id=updated_by_id,
project=project,
workspace=project.workspace,
created_by=issue.created_by,
updated_by=issue.updated_by,
)
for block in blocks
],
@@ -216,12 +187,6 @@ class IssueCreateSerializer(BaseSerializer):
labels = validated_data.pop("labels_list", None)
blocks = validated_data.pop("blocks_list", None)
# Related models
project_id = instance.project_id
workspace_id = instance.workspace_id
created_by_id = instance.created_by_id
updated_by_id = instance.updated_by_id
if blockers is not None:
IssueBlocker.objects.filter(block=instance).delete()
IssueBlocker.objects.bulk_create(
@@ -229,10 +194,10 @@ class IssueCreateSerializer(BaseSerializer):
IssueBlocker(
block=instance,
blocked_by=blocker,
project_id=project_id,
workspace_id=workspace_id,
created_by_id=created_by_id,
updated_by_id=updated_by_id,
project=instance.project,
workspace=instance.project.workspace,
created_by=instance.created_by,
updated_by=instance.updated_by,
)
for blocker in blockers
],
@@ -246,10 +211,10 @@ class IssueCreateSerializer(BaseSerializer):
IssueAssignee(
assignee=user,
issue=instance,
project_id=project_id,
workspace_id=workspace_id,
created_by_id=created_by_id,
updated_by_id=updated_by_id,
project=instance.project,
workspace=instance.project.workspace,
created_by=instance.created_by,
updated_by=instance.updated_by,
)
for user in assignees
],
@@ -263,10 +228,10 @@ class IssueCreateSerializer(BaseSerializer):
IssueLabel(
label=label,
issue=instance,
project_id=project_id,
workspace_id=workspace_id,
created_by_id=created_by_id,
updated_by_id=updated_by_id,
project=instance.project,
workspace=instance.project.workspace,
created_by=instance.created_by,
updated_by=instance.updated_by,
)
for label in labels
],
@@ -280,25 +245,23 @@ class IssueCreateSerializer(BaseSerializer):
IssueBlocker(
block=block,
blocked_by=instance,
project_id=project_id,
workspace_id=workspace_id,
created_by_id=created_by_id,
updated_by_id=updated_by_id,
project=instance.project,
workspace=instance.project.workspace,
created_by=instance.created_by,
updated_by=instance.updated_by,
)
for block in blocks
],
batch_size=10,
)
# Time updation occues even when other related models are updated
instance.updated_at = timezone.now()
return super().update(instance, validated_data)
class IssueActivitySerializer(BaseSerializer):
actor_detail = UserLiteSerializer(read_only=True, source="actor")
issue_detail = IssueFlatSerializer(read_only=True, source="issue")
project_detail = ProjectLiteSerializer(read_only=True, source="project")
workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace")
class Meta:
model = IssueActivity
@@ -372,31 +335,19 @@ class IssueLabelSerializer(BaseSerializer):
class BlockedIssueSerializer(BaseSerializer):
blocked_issue_detail = IssueProjectLiteSerializer(source="block", read_only=True)
blocked_issue_detail = IssueFlatSerializer(source="block", read_only=True)
class Meta:
model = IssueBlocker
fields = [
"blocked_issue_detail",
"blocked_by",
"block",
]
read_only_fields = fields
fields = "__all__"
class BlockerIssueSerializer(BaseSerializer):
blocker_issue_detail = IssueProjectLiteSerializer(
source="blocked_by", read_only=True
)
blocker_issue_detail = IssueFlatSerializer(source="blocked_by", read_only=True)
class Meta:
model = IssueBlocker
fields = [
"blocker_issue_detail",
"blocked_by",
"block",
]
read_only_fields = fields
fields = "__all__"
class IssueAssigneeSerializer(BaseSerializer):
@@ -509,98 +460,6 @@ class IssueAttachmentSerializer(BaseSerializer):
]
class IssueReactionSerializer(BaseSerializer):
class Meta:
model = IssueReaction
fields = "__all__"
read_only_fields = [
"workspace",
"project",
"issue",
"actor",
]
class IssueReactionLiteSerializer(BaseSerializer):
actor_detail = UserLiteSerializer(read_only=True, source="actor")
class Meta:
model = IssueReaction
fields = [
"id",
"reaction",
"issue",
"actor_detail",
]
class CommentReactionLiteSerializer(BaseSerializer):
actor_detail = UserLiteSerializer(read_only=True, source="actor")
class Meta:
model = CommentReaction
fields = [
"id",
"reaction",
"comment",
"actor_detail",
]
class CommentReactionSerializer(BaseSerializer):
class Meta:
model = CommentReaction
fields = "__all__"
read_only_fields = ["workspace", "project", "comment", "actor"]
class IssueVoteSerializer(BaseSerializer):
class Meta:
model = IssueVote
fields = ["issue", "vote", "workspace_id", "project_id", "actor"]
read_only_fields = fields
class IssueCommentSerializer(BaseSerializer):
actor_detail = UserLiteSerializer(read_only=True, source="actor")
issue_detail = IssueFlatSerializer(read_only=True, source="issue")
project_detail = ProjectLiteSerializer(read_only=True, source="project")
workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace")
comment_reactions = CommentReactionLiteSerializer(read_only=True, many=True)
class Meta:
model = IssueComment
fields = "__all__"
read_only_fields = [
"workspace",
"project",
"issue",
"created_by",
"updated_by",
"created_at",
"updated_at",
"access",
]
class IssueStateFlatSerializer(BaseSerializer):
state_detail = StateLiteSerializer(read_only=True, source="state")
project_detail = ProjectLiteSerializer(read_only=True, source="project")
class Meta:
model = Issue
fields = [
"id",
"sequence_id",
"name",
"state_detail",
"project_detail",
]
# Issue Serializer with state details
class IssueStateSerializer(BaseSerializer):
label_details = LabelLiteSerializer(read_only=True, source="labels", many=True)
@@ -620,7 +479,7 @@ class IssueStateSerializer(BaseSerializer):
class IssueSerializer(BaseSerializer):
project_detail = ProjectLiteSerializer(read_only=True, source="project")
state_detail = StateSerializer(read_only=True, source="state")
parent_detail = IssueStateFlatSerializer(read_only=True, source="parent")
parent_detail = IssueFlatSerializer(read_only=True, source="parent")
label_details = LabelSerializer(read_only=True, source="labels", many=True)
assignee_details = UserLiteSerializer(read_only=True, source="assignees", many=True)
# List of issues blocked by this issue
@@ -632,7 +491,6 @@ class IssueSerializer(BaseSerializer):
issue_link = IssueLinkSerializer(read_only=True, many=True)
issue_attachment = IssueAttachmentSerializer(read_only=True, many=True)
sub_issues_count = serializers.IntegerField(read_only=True)
issue_reactions = IssueReactionLiteSerializer(read_only=True, many=True)
class Meta:
model = Issue
@@ -658,7 +516,6 @@ class IssueLiteSerializer(BaseSerializer):
module_id = serializers.UUIDField(read_only=True)
attachment_count = serializers.IntegerField(read_only=True)
link_count = serializers.IntegerField(read_only=True)
issue_reactions = IssueReactionLiteSerializer(read_only=True, many=True)
class Meta:
model = Issue
@@ -40,11 +40,6 @@ class ModuleWriteSerializer(BaseSerializer):
"updated_at",
]
def validate(self, data):
if data.get("start_date", None) is not None and data.get("target_date", None) is not None and data.get("start_date", None) > data.get("target_date", None):
raise serializers.ValidationError("Start date cannot exceed target date")
return data
def create(self, validated_data):
members = validated_data.pop("members_list", None)
+3 -9
View File
@@ -3,7 +3,7 @@ from rest_framework import serializers
# Module imports
from .base import BaseSerializer
from .issue import IssueFlatSerializer, LabelLiteSerializer
from .issue import IssueFlatSerializer, LabelSerializer
from .workspace import WorkspaceLiteSerializer
from .project import ProjectLiteSerializer
from plane.db.models import Page, PageBlock, PageFavorite, PageLabel, Label
@@ -23,22 +23,16 @@ class PageBlockSerializer(BaseSerializer):
"page",
]
class PageBlockLiteSerializer(BaseSerializer):
class Meta:
model = PageBlock
fields = "__all__"
class PageSerializer(BaseSerializer):
is_favorite = serializers.BooleanField(read_only=True)
label_details = LabelLiteSerializer(read_only=True, source="labels", many=True)
label_details = LabelSerializer(read_only=True, source="labels", many=True)
labels_list = serializers.ListField(
child=serializers.PrimaryKeyRelatedField(queryset=Label.objects.all()),
write_only=True,
required=False,
)
blocks = PageBlockLiteSerializer(read_only=True, many=True)
blocks = PageBlockSerializer(read_only=True, many=True)
project_detail = ProjectLiteSerializer(source="project", read_only=True)
workspace_detail = WorkspaceLiteSerializer(source="workspace", read_only=True)
+57
View File
@@ -0,0 +1,57 @@
from rest_framework.serializers import (
ModelSerializer,
Serializer,
CharField,
SerializerMethodField,
)
from rest_framework.authtoken.models import Token
from rest_framework_simplejwt.tokens import RefreshToken
from plane.db.models import User
class UserSerializer(ModelSerializer):
class Meta:
model = User
fields = "__all__"
extra_kwargs = {"password": {"write_only": True}}
class ChangePasswordSerializer(Serializer):
model = User
"""
Serializer for password change endpoint.
"""
old_password = CharField(required=True)
new_password = CharField(required=True)
class ResetPasswordSerializer(Serializer):
model = User
"""
Serializer for password change endpoint.
"""
new_password = CharField(required=True)
confirm_password = CharField(required=True)
class TokenSerializer(ModelSerializer):
user = UserSerializer()
access_token = SerializerMethodField()
refresh_token = SerializerMethodField()
def get_access_token(self, obj):
refresh_token = RefreshToken.for_user(obj.user)
return str(refresh_token.access_token)
def get_refresh_token(self, obj):
refresh_token = RefreshToken.for_user(obj.user)
return str(refresh_token)
class Meta:
model = Token
fields = "__all__"
+4 -37
View File
@@ -7,14 +7,13 @@ from rest_framework import serializers
# Module imports
from .base import BaseSerializer
from plane.api.serializers.workspace import WorkSpaceSerializer, WorkspaceLiteSerializer
from plane.api.serializers.user import UserLiteSerializer, UserAdminLiteSerializer
from plane.api.serializers.user import UserLiteSerializer
from plane.db.models import (
Project,
ProjectMember,
ProjectMemberInvite,
ProjectIdentifier,
ProjectFavorite,
ProjectDeployBoard,
)
@@ -81,15 +80,7 @@ class ProjectSerializer(BaseSerializer):
class ProjectLiteSerializer(BaseSerializer):
class Meta:
model = Project
fields = [
"id",
"identifier",
"name",
"cover_image",
"icon_prop",
"emoji",
"description",
]
fields = ["id", "identifier", "name"]
read_only_fields = fields
@@ -102,9 +93,6 @@ class ProjectDetailSerializer(BaseSerializer):
total_cycles = serializers.IntegerField(read_only=True)
total_modules = serializers.IntegerField(read_only=True)
is_member = serializers.BooleanField(read_only=True)
sort_order = serializers.FloatField(read_only=True)
member_role = serializers.IntegerField(read_only=True)
is_deployed = serializers.BooleanField(read_only=True)
class Meta:
model = Project
@@ -121,16 +109,6 @@ class ProjectMemberSerializer(BaseSerializer):
fields = "__all__"
class ProjectMemberAdminSerializer(BaseSerializer):
workspace = WorkspaceLiteSerializer(read_only=True)
project = ProjectLiteSerializer(read_only=True)
member = UserAdminLiteSerializer(read_only=True)
class Meta:
model = ProjectMember
fields = "__all__"
class ProjectMemberInviteSerializer(BaseSerializer):
project = ProjectLiteSerializer(read_only=True)
workspace = WorkspaceLiteSerializer(read_only=True)
@@ -158,6 +136,8 @@ class ProjectFavoriteSerializer(BaseSerializer):
]
class ProjectMemberLiteSerializer(BaseSerializer):
member = UserLiteSerializer(read_only=True)
is_subscribed = serializers.BooleanField(read_only=True)
@@ -166,16 +146,3 @@ class ProjectMemberLiteSerializer(BaseSerializer):
model = ProjectMember
fields = ["member", "id", "is_subscribed"]
read_only_fields = fields
class ProjectDeployBoardSerializer(BaseSerializer):
project_details = ProjectLiteSerializer(read_only=True, source="project")
workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace")
class Meta:
model = ProjectDeployBoard
fields = "__all__"
read_only_fields = [
"workspace",
"project" "anchor",
]
+2 -44
View File
@@ -1,6 +1,3 @@
# Third party imports
from rest_framework import serializers
# Module import
from .base import BaseSerializer
from plane.db.models import User
@@ -40,50 +37,11 @@ class UserLiteSerializer(BaseSerializer):
"id",
"first_name",
"last_name",
"avatar",
"is_bot",
"display_name",
]
read_only_fields = [
"id",
"is_bot",
]
class UserAdminLiteSerializer(BaseSerializer):
class Meta:
model = User
fields = [
"id",
"first_name",
"last_name",
"avatar",
"is_bot",
"display_name",
"email",
"avatar",
"is_bot",
]
read_only_fields = [
"id",
"is_bot",
]
class ChangePasswordSerializer(serializers.Serializer):
model = User
"""
Serializer for password change endpoint.
"""
old_password = serializers.CharField(required=True)
new_password = serializers.CharField(required=True)
class ResetPasswordSerializer(serializers.Serializer):
model = User
"""
Serializer for password change endpoint.
"""
new_password = serializers.CharField(required=True)
confirm_password = serializers.CharField(required=True)
+13 -22
View File
@@ -3,7 +3,7 @@ from rest_framework import serializers
# Module imports
from .base import BaseSerializer
from .user import UserLiteSerializer, UserAdminLiteSerializer
from .user import UserLiteSerializer
from plane.db.models import (
User,
@@ -33,30 +33,10 @@ class WorkSpaceSerializer(BaseSerializer):
"owner",
]
class WorkspaceLiteSerializer(BaseSerializer):
class Meta:
model = Workspace
fields = [
"name",
"slug",
"id",
]
read_only_fields = fields
class WorkSpaceMemberSerializer(BaseSerializer):
member = UserLiteSerializer(read_only=True)
workspace = WorkspaceLiteSerializer(read_only=True)
class Meta:
model = WorkspaceMember
fields = "__all__"
class WorkspaceMemberAdminSerializer(BaseSerializer):
member = UserAdminLiteSerializer(read_only=True)
workspace = WorkspaceLiteSerializer(read_only=True)
workspace = WorkSpaceSerializer(read_only=True)
class Meta:
model = WorkspaceMember
@@ -121,6 +101,17 @@ class TeamSerializer(BaseSerializer):
return super().update(instance, validated_data)
class WorkspaceLiteSerializer(BaseSerializer):
class Meta:
model = Workspace
fields = [
"name",
"slug",
"id",
]
read_only_fields = fields
class WorkspaceThemeSerializer(BaseSerializer):
class Meta:
model = WorkspaceTheme
+1 -227
View File
@@ -32,7 +32,6 @@ from plane.api.views import (
InviteWorkspaceEndpoint,
JoinWorkspaceEndpoint,
WorkSpaceMemberViewSet,
WorkspaceMembersEndpoint,
WorkspaceInvitationsViewset,
UserWorkspaceInvitationsEndpoint,
WorkspaceMemberUserEndpoint,
@@ -46,11 +45,6 @@ from plane.api.views import (
UserIssueCompletedGraphEndpoint,
UserWorkspaceDashboardEndpoint,
WorkspaceThemeViewSet,
WorkspaceUserProfileStatsEndpoint,
WorkspaceUserActivityEndpoint,
WorkspaceUserProfileEndpoint,
WorkspaceUserProfileIssuesEndpoint,
WorkspaceLabelsEndpoint,
## End Workspaces
# File Assets
FileAssetEndpoint,
@@ -60,7 +54,6 @@ from plane.api.views import (
ProjectViewSet,
InviteProjectEndpoint,
ProjectMemberViewSet,
ProjectMemberEndpoint,
ProjectMemberInvitationsViewset,
ProjectMemberUserEndpoint,
AddMemberToProjectEndpoint,
@@ -86,10 +79,6 @@ from plane.api.views import (
IssueAttachmentEndpoint,
IssueArchiveViewSet,
IssueSubscriberViewSet,
IssueCommentPublicViewSet,
IssueReactionViewSet,
CommentReactionViewSet,
ExportIssuesEndpoint,
## End Issues
# States
StateViewSet,
@@ -166,15 +155,6 @@ from plane.api.views import (
NotificationViewSet,
UnreadNotificationEndpoint,
## End Notification
# Public Boards
ProjectDeployBoardViewSet,
ProjectDeployBoardIssuesPublicEndpoint,
ProjectDeployBoardPublicSettingsEndpoint,
IssueReactionPublicViewSet,
CommentReactionPublicViewSet,
InboxIssuePublicViewSet,
IssueVotePublicViewSet,
## End Public Boards
)
@@ -347,11 +327,6 @@ urlpatterns = [
),
name="workspace",
),
path(
"workspaces/<str:slug>/workspace-members/",
WorkspaceMembersEndpoint.as_view(),
name="workspace-members",
),
path(
"workspaces/<str:slug>/teams/",
TeamMemberViewSet.as_view(
@@ -410,31 +385,6 @@ urlpatterns = [
),
name="workspace-themes",
),
path(
"workspaces/<str:slug>/user-stats/<uuid:user_id>/",
WorkspaceUserProfileStatsEndpoint.as_view(),
name="workspace-user-stats",
),
path(
"workspaces/<str:slug>/user-activity/<uuid:user_id>/",
WorkspaceUserActivityEndpoint.as_view(),
name="workspace-user-activity",
),
path(
"workspaces/<str:slug>/user-profile/<uuid:user_id>/",
WorkspaceUserProfileEndpoint.as_view(),
name="workspace-user-profile-page",
),
path(
"workspaces/<str:slug>/user-issues/<uuid:user_id>/",
WorkspaceUserProfileIssuesEndpoint.as_view(),
name="workspace-user-profile-issues",
),
path(
"workspaces/<str:slug>/labels/",
WorkspaceLabelsEndpoint.as_view(),
name="workspace-labels",
),
## End Workspaces ##
# Projects
path(
@@ -485,11 +435,6 @@ urlpatterns = [
),
name="project",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/project-members/",
ProjectMemberEndpoint.as_view(),
name="project",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/members/add/",
AddMemberToProjectEndpoint.as_view(),
@@ -831,11 +776,6 @@ urlpatterns = [
IssueAttachmentEndpoint.as_view(),
name="project-issue-attachments",
),
path(
"workspaces/<str:slug>/export-issues/",
ExportIssuesEndpoint.as_view(),
name="export-issues",
),
## End Issues
## Issue Activity
path(
@@ -896,48 +836,6 @@ urlpatterns = [
name="project-issue-subscribers",
),
## End Issue Subscribers
# Issue Reactions
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/reactions/",
IssueReactionViewSet.as_view(
{
"get": "list",
"post": "create",
}
),
name="project-issue-reactions",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/reactions/<str:reaction_code>/",
IssueReactionViewSet.as_view(
{
"delete": "destroy",
}
),
name="project-issue-reactions",
),
## End Issue Reactions
# Comment Reactions
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/comments/<uuid:comment_id>/reactions/",
CommentReactionViewSet.as_view(
{
"get": "list",
"post": "create",
}
),
name="project-issue-comment-reactions",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/comments/<uuid:comment_id>/reactions/<str:reaction_code>/",
CommentReactionViewSet.as_view(
{
"delete": "destroy",
}
),
name="project-issue-comment-reactions",
),
## End Comment Reactions
## IssueProperty
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issue-properties/",
@@ -1342,7 +1240,7 @@ urlpatterns = [
## End Importer
# Search
path(
"workspaces/<str:slug>/search/",
"workspaces/<str:slug>/projects/<uuid:project_id>/search/",
GlobalSearchEndpoint.as_view(),
name="global-search",
),
@@ -1491,128 +1389,4 @@ urlpatterns = [
name="unread-notifications",
),
## End Notification
# Public Boards
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/project-deploy-boards/",
ProjectDeployBoardViewSet.as_view(
{
"get": "list",
"post": "create",
}
),
name="project-deploy-board",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/project-deploy-boards/<uuid:pk>/",
ProjectDeployBoardViewSet.as_view(
{
"get": "retrieve",
"patch": "partial_update",
"delete": "destroy",
}
),
name="project-deploy-board",
),
path(
"public/workspaces/<str:slug>/project-boards/<uuid:project_id>/settings/",
ProjectDeployBoardPublicSettingsEndpoint.as_view(),
name="project-deploy-board-settings",
),
path(
"public/workspaces/<str:slug>/project-boards/<uuid:project_id>/issues/",
ProjectDeployBoardIssuesPublicEndpoint.as_view(),
name="project-deploy-board",
),
path(
"public/workspaces/<str:slug>/project-boards/<uuid:project_id>/issues/<uuid:issue_id>/comments/",
IssueCommentPublicViewSet.as_view(
{
"get": "list",
"post": "create",
}
),
name="issue-comments-project-board",
),
path(
"public/workspaces/<str:slug>/project-boards/<uuid:project_id>/issues/<uuid:issue_id>/comments/<uuid:pk>/",
IssueCommentPublicViewSet.as_view(
{
"get": "retrieve",
"patch": "partial_update",
"delete": "destroy",
}
),
name="issue-comments-project-board",
),
path(
"public/workspaces/<str:slug>/project-boards/<uuid:project_id>/issues/<uuid:issue_id>/reactions/",
IssueReactionPublicViewSet.as_view(
{
"get": "list",
"post": "create",
}
),
name="issue-reactions-project-board",
),
path(
"public/workspaces/<str:slug>/project-boards/<uuid:project_id>/issues/<uuid:issue_id>/reactions/<str:reaction_code>/",
IssueReactionPublicViewSet.as_view(
{
"delete": "destroy",
}
),
name="issue-reactions-project-board",
),
path(
"public/workspaces/<str:slug>/project-boards/<uuid:project_id>/comments/<uuid:comment_id>/reactions/",
CommentReactionPublicViewSet.as_view(
{
"get": "list",
"post": "create",
}
),
name="comment-reactions-project-board",
),
path(
"public/workspaces/<str:slug>/project-boards/<uuid:project_id>/comments/<uuid:comment_id>/reactions/<str:reaction_code>/",
CommentReactionPublicViewSet.as_view(
{
"delete": "destroy",
}
),
name="comment-reactions-project-board",
),
path(
"public/workspaces/<str:slug>/project-boards/<uuid:project_id>/inboxes/<uuid:inbox_id>/inbox-issues/",
InboxIssuePublicViewSet.as_view(
{
"get": "list",
"post": "create",
}
),
name="inbox-issue",
),
path(
"public/workspaces/<str:slug>/project-boards/<uuid:project_id>/inboxes/<uuid:inbox_id>/inbox-issues/<uuid:pk>/",
InboxIssuePublicViewSet.as_view(
{
"get": "retrieve",
"patch": "partial_update",
"delete": "destroy",
}
),
name="inbox-issue",
),
path(
"public/workspaces/<str:slug>/project-boards/<uuid:project_id>/issues/<uuid:issue_id>/votes/",
IssueVotePublicViewSet.as_view(
{
"get": "list",
"post": "create",
"delete": "destroy",
}
),
name="issue-vote-project-board",
),
## End Public Boards
]
+3 -23
View File
@@ -12,12 +12,8 @@ from .project import (
ProjectUserViewsEndpoint,
ProjectMemberUserEndpoint,
ProjectFavoritesViewSet,
ProjectDeployBoardIssuesPublicEndpoint,
ProjectDeployBoardViewSet,
ProjectDeployBoardPublicSettingsEndpoint,
ProjectMemberEndpoint,
)
from .user import (
from .people import (
UserEndpoint,
UpdateUserOnBoardedEndpoint,
UpdateUserTourCompletedEndpoint,
@@ -46,12 +42,6 @@ from .workspace import (
UserIssueCompletedGraphEndpoint,
UserWorkspaceDashboardEndpoint,
WorkspaceThemeViewSet,
WorkspaceUserProfileStatsEndpoint,
WorkspaceUserActivityEndpoint,
WorkspaceUserProfileEndpoint,
WorkspaceUserProfileIssuesEndpoint,
WorkspaceLabelsEndpoint,
WorkspaceMembersEndpoint,
)
from .state import StateViewSet
from .view import IssueViewViewSet, ViewIssuesEndpoint, IssueViewFavoriteViewSet
@@ -78,12 +68,6 @@ from .issue import (
IssueAttachmentEndpoint,
IssueArchiveViewSet,
IssueSubscriberViewSet,
IssueCommentPublicViewSet,
CommentReactionViewSet,
IssueReactionViewSet,
IssueReactionPublicViewSet,
CommentReactionPublicViewSet,
IssueVotePublicViewSet,
)
from .auth_extended import (
@@ -151,7 +135,7 @@ from .estimate import (
from .release import ReleaseNotesEndpoint
from .inbox import InboxViewSet, InboxIssueViewSet, InboxIssuePublicViewSet
from .inbox import InboxViewSet, InboxIssueViewSet
from .analytic import (
AnalyticsEndpoint,
@@ -161,8 +145,4 @@ from .analytic import (
DefaultAnalyticsEndpoint,
)
from .notification import NotificationViewSet, UnreadNotificationEndpoint
from .exporter import (
ExportIssuesEndpoint,
)
from .notification import NotificationViewSet, UnreadNotificationEndpoint
+5 -5
View File
@@ -79,12 +79,12 @@ class AnalyticsEndpoint(BaseAPIView):
)
assignee_details = {}
if x_axis in ["assignees__id"] or segment in ["assignees__id"]:
if x_axis in ["assignees__email"] or segment in ["assignees__email"]:
assignee_details = (
Issue.issue_objects.filter(workspace__slug=slug, **filters, assignees__avatar__isnull=False)
.order_by("assignees__id")
.distinct("assignees__id")
.values("assignees__avatar", "assignees__display_name", "assignees__first_name", "assignees__last_name", "assignees__id")
.values("assignees__avatar", "assignees__email", "assignees__first_name", "assignees__last_name")
)
@@ -243,21 +243,21 @@ class DefaultAnalyticsEndpoint(BaseAPIView):
)
most_issue_created_user = (
queryset.exclude(created_by=None)
.values("created_by__first_name", "created_by__last_name", "created_by__avatar", "created_by__display_name", "created_by__id")
.values("created_by__first_name", "created_by__last_name", "created_by__avatar", "created_by__email")
.annotate(count=Count("id"))
.order_by("-count")
)[:5]
most_issue_closed_user = (
queryset.filter(completed_at__isnull=False, assignees__isnull=False)
.values("assignees__first_name", "assignees__last_name", "assignees__avatar", "assignees__display_name", "assignees__id")
.values("assignees__first_name", "assignees__last_name", "assignees__avatar", "assignees__email")
.annotate(count=Count("id"))
.order_by("-count")
)[:5]
pending_issue_user = (
queryset.filter(completed_at__isnull=True)
.values("assignees__first_name", "assignees__last_name", "assignees__avatar", "assignees__display_name", "assignees__id")
.values("assignees__first_name", "assignees__last_name", "assignees__avatar", "assignees__email")
.annotate(count=Count("id"))
.order_by("-count")
)
+1 -1
View File
@@ -22,7 +22,7 @@ from sentry_sdk import capture_exception
## Module imports
from . import BaseAPIView
from plane.api.serializers import (
from plane.api.serializers.people import (
ChangePasswordSerializer,
ResetPasswordSerializer,
)
+1 -3
View File
@@ -279,8 +279,6 @@ class MagicSignInGenerateEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST,
)
# Clean up
email = email.strip().lower()
validate_email(email)
## Generate a random token
@@ -348,7 +346,7 @@ class MagicSignInEndpoint(BaseAPIView):
def post(self, request):
try:
user_token = request.data.get("token", "").strip()
key = request.data.get("key", False).strip().lower()
key = request.data.get("key", False)
if not key or user_token == "":
return Response(
+6 -7
View File
@@ -31,7 +31,6 @@ from plane.api.serializers import (
CycleIssueSerializer,
CycleFavoriteSerializer,
IssueStateSerializer,
CycleWriteSerializer,
)
from plane.api.permissions import ProjectEntityPermission
from plane.db.models import (
@@ -165,9 +164,6 @@ class CycleViewSet(BaseViewSet):
try:
queryset = self.get_queryset()
cycle_view = request.GET.get("cycle_view", "all")
order_by = request.GET.get("order_by", "sort_order")
queryset = queryset.order_by(order_by)
# All Cycles
if cycle_view == "all":
@@ -342,7 +338,7 @@ class CycleViewSet(BaseViewSet):
status=status.HTTP_400_BAD_REQUEST,
)
serializer = CycleWriteSerializer(cycle, data=request.data, partial=True)
serializer = CycleSerializer(cycle, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_200_OK)
@@ -373,8 +369,7 @@ class CycleViewSet(BaseViewSet):
.annotate(last_name=F("assignees__last_name"))
.annotate(assignee_id=F("assignees__id"))
.annotate(avatar=F("assignees__avatar"))
.annotate(display_name=F("assignees__display_name"))
.values("first_name", "last_name", "assignee_id", "avatar", "display_name")
.values("first_name", "last_name", "assignee_id", "avatar")
.annotate(total_issues=Count("assignee_id"))
.annotate(
completed_issues=Count(
@@ -696,6 +691,7 @@ class CycleDateCheckEndpoint(BaseAPIView):
return Response(
{
"error": "You have a cycle already on the given dates, if you want to create your draft cycle you can do that by removing dates",
"cycles": CycleSerializer(cycles, many=True).data,
"status": False,
}
)
@@ -710,6 +706,9 @@ class CycleDateCheckEndpoint(BaseAPIView):
class CycleFavoriteViewSet(BaseViewSet):
permission_classes = [
ProjectEntityPermission,
]
serializer_class = CycleFavoriteSerializer
model = CycleFavorite
-100
View File
@@ -1,100 +0,0 @@
# Third Party imports
from rest_framework.response import Response
from rest_framework import status
from sentry_sdk import capture_exception
# Module imports
from . import BaseAPIView
from plane.api.permissions import WorkSpaceAdminPermission
from plane.bgtasks.export_task import issue_export_task
from plane.db.models import Project, ExporterHistory, Workspace
from plane.api.serializers import ExporterHistorySerializer
class ExportIssuesEndpoint(BaseAPIView):
permission_classes = [
WorkSpaceAdminPermission,
]
model = ExporterHistory
serializer_class = ExporterHistorySerializer
def post(self, request, slug):
try:
# Get the workspace
workspace = Workspace.objects.get(slug=slug)
provider = request.data.get("provider", False)
multiple = request.data.get("multiple", False)
project_ids = request.data.get("project", [])
if provider in ["csv", "xlsx", "json"]:
if not project_ids:
project_ids = Project.objects.filter(
workspace__slug=slug
).values_list("id", flat=True)
project_ids = [str(project_id) for project_id in project_ids]
exporter = ExporterHistory.objects.create(
workspace=workspace,
project=project_ids,
initiated_by=request.user,
provider=provider,
)
issue_export_task.delay(
provider=exporter.provider,
workspace_id=workspace.id,
project_ids=project_ids,
token_id=exporter.token,
multiple=multiple,
slug=slug,
)
return Response(
{
"message": f"Once the export is ready you will be able to download it"
},
status=status.HTTP_200_OK,
)
else:
return Response(
{"error": f"Provider '{provider}' not found."},
status=status.HTTP_400_BAD_REQUEST,
)
except Workspace.DoesNotExist:
return Response(
{"error": "Workspace does not exists"},
status=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
def get(self, request, slug):
try:
exporter_history = ExporterHistory.objects.filter(
workspace__slug=slug
).select_related("workspace","initiated_by")
if request.GET.get("per_page", False) and request.GET.get("cursor", False):
return self.paginate(
request=request,
queryset=exporter_history,
on_results=lambda exporter_history: ExporterHistorySerializer(
exporter_history, many=True
).data,
)
else:
return Response(
{"error": "per_page and cursor are required"},
status=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
+26
View File
@@ -30,6 +30,31 @@ class GPTIntegrationEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST,
)
count = 0
# If logger is enabled check for request limit
if settings.LOGGER_BASE_URL:
try:
headers = {
"Content-Type": "application/json",
}
response = requests.post(
settings.LOGGER_BASE_URL,
json={"user_id": str(request.user.id)},
headers=headers,
)
count = response.json().get("count", 0)
if not response.json().get("success", False):
return Response(
{
"error": "You have surpassed the monthly limit for AI assistance"
},
status=status.HTTP_429_TOO_MANY_REQUESTS,
)
except Exception as e:
capture_exception(e)
prompt = request.data.get("prompt", False)
task = request.data.get("task", False)
@@ -57,6 +82,7 @@ class GPTIntegrationEndpoint(BaseAPIView):
{
"response": text,
"response_html": text_html,
"count": count,
"project_detail": ProjectLiteSerializer(project).data,
"workspace_detail": WorkspaceLiteSerializer(workspace).data,
},
+2 -2
View File
@@ -332,7 +332,7 @@ class BulkImportIssuesEndpoint(BaseAPIView):
# if there is no default state assign any random state
if default_state is None:
default_state = State.objects.filter(
~Q(name="Triage"), project_id=project_id
~Q(name="Triage"), sproject_id=project_id
).first()
# Get the maximum sequence_id
@@ -458,7 +458,7 @@ class BulkImportIssuesEndpoint(BaseAPIView):
actor=request.user,
project_id=project_id,
workspace_id=project.workspace_id,
comment=f"imported the issue from {service}",
comment=f"{request.user.email} importer the issue from {service}",
verb="created",
created_by=request.user,
)
+2 -267
View File
@@ -15,6 +15,7 @@ from sentry_sdk import capture_exception
from .base import BaseViewSet
from plane.api.permissions import ProjectBasePermission, ProjectLitePermission
from plane.db.models import (
Project,
Inbox,
InboxIssue,
Issue,
@@ -22,7 +23,6 @@ from plane.db.models import (
IssueLink,
IssueAttachment,
ProjectMember,
ProjectDeployBoard,
)
from plane.api.serializers import (
IssueSerializer,
@@ -377,269 +377,4 @@ class InboxIssueViewSet(BaseViewSet):
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class InboxIssuePublicViewSet(BaseViewSet):
serializer_class = InboxIssueSerializer
model = InboxIssue
filterset_fields = [
"status",
]
def get_queryset(self):
project_deploy_board = ProjectDeployBoard.objects.get(workspace__slug=self.kwargs.get("slug"), project_id=self.kwargs.get("project_id"))
if project_deploy_board is not None:
return self.filter_queryset(
super()
.get_queryset()
.filter(
Q(snoozed_till__gte=timezone.now()) | Q(snoozed_till__isnull=True),
project_id=self.kwargs.get("project_id"),
workspace__slug=self.kwargs.get("slug"),
inbox_id=self.kwargs.get("inbox_id"),
)
.select_related("issue", "workspace", "project")
)
else:
return InboxIssue.objects.none()
def list(self, request, slug, project_id, inbox_id):
try:
project_deploy_board = ProjectDeployBoard.objects.get(workspace__slug=slug, project_id=project_id)
if project_deploy_board.inbox is None:
return Response({"error": "Inbox is not enabled for this Project Board"}, status=status.HTTP_400_BAD_REQUEST)
filters = issue_filters(request.query_params, "GET")
issues = (
Issue.objects.filter(
issue_inbox__inbox_id=inbox_id,
workspace__slug=slug,
project_id=project_id,
)
.filter(**filters)
.annotate(bridge_id=F("issue_inbox__id"))
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels")
.order_by("issue_inbox__snoozed_till", "issue_inbox__status")
.annotate(
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.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")
)
.prefetch_related(
Prefetch(
"issue_inbox",
queryset=InboxIssue.objects.only(
"status", "duplicate_to", "snoozed_till", "source"
),
)
)
)
issues_data = IssueStateInboxSerializer(issues, many=True).data
return Response(
issues_data,
status=status.HTTP_200_OK,
)
except ProjectDeployBoard.DoesNotExist:
return Response({"error": "Project Deploy Board does not exist"}, status=status.HTTP_400_BAD_REQUEST)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
def create(self, request, slug, project_id, inbox_id):
try:
project_deploy_board = ProjectDeployBoard.objects.get(workspace__slug=slug, project_id=project_id)
if project_deploy_board.inbox is None:
return Response({"error": "Inbox is not enabled for this Project Board"}, status=status.HTTP_400_BAD_REQUEST)
if not request.data.get("issue", {}).get("name", False):
return Response(
{"error": "Name is required"}, status=status.HTTP_400_BAD_REQUEST
)
# Check for valid priority
if not request.data.get("issue", {}).get("priority", None) in [
"low",
"medium",
"high",
"urgent",
None,
]:
return Response(
{"error": "Invalid priority"}, status=status.HTTP_400_BAD_REQUEST
)
# Create or get state
state, _ = State.objects.get_or_create(
name="Triage",
group="backlog",
description="Default state for managing all Inbox Issues",
project_id=project_id,
color="#ff7700",
)
# create an issue
issue = Issue.objects.create(
name=request.data.get("issue", {}).get("name"),
description=request.data.get("issue", {}).get("description", {}),
description_html=request.data.get("issue", {}).get(
"description_html", "<p></p>"
),
priority=request.data.get("issue", {}).get("priority", "low"),
project_id=project_id,
state=state,
)
# Create an Issue Activity
issue_activity.delay(
type="issue.activity.created",
requested_data=json.dumps(request.data, cls=DjangoJSONEncoder),
actor_id=str(request.user.id),
issue_id=str(issue.id),
project_id=str(project_id),
current_instance=None,
)
# create an inbox issue
InboxIssue.objects.create(
inbox_id=inbox_id,
project_id=project_id,
issue=issue,
source=request.data.get("source", "in-app"),
)
serializer = IssueStateInboxSerializer(issue)
return Response(serializer.data, status=status.HTTP_200_OK)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
def partial_update(self, request, slug, project_id, inbox_id, pk):
try:
project_deploy_board = ProjectDeployBoard.objects.get(workspace__slug=slug, project_id=project_id)
if project_deploy_board.inbox is None:
return Response({"error": "Inbox is not enabled for this Project Board"}, status=status.HTTP_400_BAD_REQUEST)
inbox_issue = InboxIssue.objects.get(
pk=pk, workspace__slug=slug, project_id=project_id, inbox_id=inbox_id
)
# Get the project member
if str(inbox_issue.created_by_id) != str(request.user.id):
return Response({"error": "You cannot edit inbox issues"}, status=status.HTTP_400_BAD_REQUEST)
# Get issue data
issue_data = request.data.pop("issue", False)
issue = Issue.objects.get(
pk=inbox_issue.issue_id, workspace__slug=slug, project_id=project_id
)
# viewers and guests since only viewers and guests
issue_data = {
"name": issue_data.get("name", issue.name),
"description_html": issue_data.get("description_html", issue.description_html),
"description": issue_data.get("description", issue.description)
}
issue_serializer = IssueCreateSerializer(
issue, data=issue_data, partial=True
)
if issue_serializer.is_valid():
current_instance = issue
# Log all the updates
requested_data = json.dumps(issue_data, cls=DjangoJSONEncoder)
if issue is not None:
issue_activity.delay(
type="issue.activity.updated",
requested_data=requested_data,
actor_id=str(request.user.id),
issue_id=str(issue.id),
project_id=str(project_id),
current_instance=json.dumps(
IssueSerializer(current_instance).data,
cls=DjangoJSONEncoder,
),
)
issue_serializer.save()
return Response(issue_serializer.data, status=status.HTTP_200_OK)
return Response(issue_serializer.errors, status=status.HTTP_400_BAD_REQUEST)
except InboxIssue.DoesNotExist:
return Response(
{"error": "Inbox Issue does not exist"},
status=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
def retrieve(self, request, slug, project_id, inbox_id, pk):
try:
project_deploy_board = ProjectDeployBoard.objects.get(workspace__slug=slug, project_id=project_id)
if project_deploy_board.inbox is None:
return Response({"error": "Inbox is not enabled for this Project Board"}, status=status.HTTP_400_BAD_REQUEST)
inbox_issue = InboxIssue.objects.get(
pk=pk, workspace__slug=slug, project_id=project_id, inbox_id=inbox_id
)
issue = Issue.objects.get(
pk=inbox_issue.issue_id, workspace__slug=slug, project_id=project_id
)
serializer = IssueStateInboxSerializer(issue)
return Response(serializer.data, status=status.HTTP_200_OK)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
def destroy(self, request, slug, project_id, inbox_id, pk):
try:
project_deploy_board = ProjectDeployBoard.objects.get(workspace__slug=slug, project_id=project_id)
if project_deploy_board.inbox is None:
return Response({"error": "Inbox is not enabled for this Project Board"}, status=status.HTTP_400_BAD_REQUEST)
inbox_issue = InboxIssue.objects.get(
pk=pk, workspace__slug=slug, project_id=project_id, inbox_id=inbox_id
)
if str(inbox_issue.created_by_id) != str(request.user.id):
return Response({"error": "You cannot delete inbox issue"}, status=status.HTTP_400_BAD_REQUEST)
inbox_issue.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
except InboxIssue.DoesNotExist:
return Response({"error": "Inbox Issue does not exists"}, status=status.HTTP_400_BAD_REQUEST)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
)
+1 -15
View File
@@ -20,17 +20,6 @@ class SlackProjectSyncViewSet(BaseViewSet):
serializer_class = SlackProjectSyncSerializer
model = SlackProjectSync
def get_queryset(self):
return (
super()
.get_queryset()
.filter(
workspace__slug=self.kwargs.get("slug"),
project_id=self.kwargs.get("project_id"),
)
.filter(project__project_projectmember__member=self.request.user)
)
def create(self, request, slug, project_id, workspace_integration_id):
try:
serializer = SlackProjectSyncSerializer(data=request.data)
@@ -56,10 +45,7 @@ class SlackProjectSyncViewSet(BaseViewSet):
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
except IntegrityError:
return Response(
{"error": "Slack is already enabled for the project"},
status=status.HTTP_400_BAD_REQUEST,
)
return Response({"error": "Slack is already enabled for the project"}, status=status.HTTP_400_BAD_REQUEST)
except WorkspaceIntegration.DoesNotExist:
return Response(
{"error": "Workspace Integration does not exist"},
+42 -639
View File
@@ -46,12 +46,8 @@ from plane.api.serializers import (
IssueAttachmentSerializer,
IssueSubscriberSerializer,
ProjectMemberLiteSerializer,
IssueReactionSerializer,
CommentReactionSerializer,
IssueVoteSerializer,
)
from plane.api.permissions import (
WorkspaceEntityPermission,
ProjectEntityPermission,
WorkSpaceAdminPermission,
ProjectMemberPermission,
@@ -69,10 +65,6 @@ from plane.db.models import (
State,
IssueSubscriber,
ProjectMember,
IssueReaction,
CommentReaction,
ProjectDeployBoard,
IssueVote,
)
from plane.bgtasks.issue_activites_task import issue_activity
from plane.utils.grouper import group_results
@@ -159,18 +151,13 @@ class IssueViewSet(BaseViewSet):
.select_related("parent")
.prefetch_related("assignees")
.prefetch_related("labels")
.prefetch_related(
Prefetch(
"issue_reactions",
queryset=IssueReaction.objects.select_related("actor"),
)
)
)
@method_decorator(gzip_page)
def list(self, request, slug, project_id):
try:
filters = issue_filters(request.query_params, "GET")
show_sub_issues = request.GET.get("show_sub_issues", "true")
# Custom ordering for priority and state
priority_order = ["urgent", "high", "medium", "low", None]
@@ -257,6 +244,12 @@ class IssueViewSet(BaseViewSet):
else:
issue_queryset = issue_queryset.order_by(order_by_param)
issue_queryset = (
issue_queryset
if show_sub_issues == "true"
else issue_queryset.filter(parent__isnull=True)
)
issues = IssueLiteSerializer(issue_queryset, many=True).data
## Grouping the results
@@ -269,7 +262,7 @@ class IssueViewSet(BaseViewSet):
return Response(issues, status=status.HTTP_200_OK)
except Exception as e:
capture_exception(e)
print(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
@@ -277,15 +270,9 @@ class IssueViewSet(BaseViewSet):
def create(self, request, slug, project_id):
try:
project = Project.objects.get(pk=project_id)
project = Project.objects.get(workspace__slug=slug, pk=project_id)
serializer = IssueCreateSerializer(
data=request.data,
context={
"project_id": project_id,
"workspace_id": project.workspace_id,
"default_assignee_id": project.default_assignee_id,
},
data=request.data, context={"project": project}
)
if serializer.is_valid():
@@ -324,17 +311,9 @@ class UserWorkSpaceIssues(BaseAPIView):
@method_decorator(gzip_page)
def get(self, request, slug):
try:
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")
issue_queryset = (
issues = (
Issue.issue_objects.filter(
(Q(assignees__in=[request.user]) | Q(created_by=request.user)),
workspace__slug=slug,
assignees__in=[request.user], workspace__slug=slug
)
.annotate(
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
@@ -348,7 +327,7 @@ class UserWorkSpaceIssues(BaseAPIView):
.select_related("parent")
.prefetch_related("assignees")
.prefetch_related("labels")
.order_by(order_by_param)
.order_by("-created_at")
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
@@ -363,83 +342,9 @@ class UserWorkSpaceIssues(BaseAPIView):
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.prefetch_related(
Prefetch(
"issue_reactions",
queryset=IssueReaction.objects.select_related("actor"),
)
)
.filter(**filters)
).distinct()
# 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 = IssueLiteSerializer(issue_queryset, many=True).data
## Grouping the results
group_by = request.GET.get("group_by", False)
if group_by:
return Response(
group_results(issues, group_by), status=status.HTTP_200_OK
)
return Response(issues, status=status.HTTP_200_OK)
)
serializer = IssueLiteSerializer(issues, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
except Exception as e:
capture_exception(e)
return Response(
@@ -485,13 +390,12 @@ class IssueActivityEndpoint(BaseAPIView):
~Q(field="comment"),
project__project_projectmember__member=self.request.user,
)
.select_related("actor", "workspace", "issue", "project")
.select_related("actor", "workspace")
).order_by("created_at")
issue_comments = (
IssueComment.objects.filter(issue_id=issue_id)
.filter(project__project_projectmember__member=self.request.user)
.order_by("created_at")
.select_related("actor", "issue", "project", "workspace")
)
issue_activities = IssueActivitySerializer(issue_activities, many=True).data
issue_comments = IssueCommentSerializer(issue_comments, many=True).data
@@ -514,7 +418,7 @@ class IssueCommentViewSet(BaseViewSet):
serializer_class = IssueCommentSerializer
model = IssueComment
permission_classes = [
ProjectLitePermission,
ProjectEntityPermission,
]
filterset_fields = [
@@ -724,7 +628,9 @@ class SubIssuesEndpoint(BaseAPIView):
def get(self, request, slug, project_id, issue_id):
try:
sub_issues = (
Issue.issue_objects.filter(parent_id=issue_id, workspace__slug=slug)
Issue.issue_objects.filter(
parent_id=issue_id, workspace__slug=slug, project_id=project_id
)
.select_related("project")
.select_related("workspace")
.select_related("state")
@@ -751,25 +657,23 @@ class SubIssuesEndpoint(BaseAPIView):
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.prefetch_related(
Prefetch(
"issue_reactions",
queryset=IssueReaction.objects.select_related("actor"),
)
)
)
state_distribution = (
State.objects.filter(
workspace__slug=slug, state_issue__parent_id=issue_id
~Q(name="Triage"), workspace__slug=slug, project_id=project_id
)
.annotate(state_group=F("group"))
.values("state_group")
.annotate(state_count=Count("state_group"))
.order_by("state_group")
.annotate(
state_count=Count(
"state_issue",
filter=Q(state_issue__parent_id=issue_id),
)
)
.order_by("group")
.values("group", "state_count")
)
result = {item["state_group"]: item["state_count"] for item in state_distribution}
result = {item["group"]: item["state_count"] for item in state_distribution}
serializer = IssueLiteSerializer(
sub_issues,
@@ -1192,8 +1096,7 @@ class IssueArchiveViewSet(BaseViewSet):
return Response(IssueSerializer(issue).data, status=status.HTTP_200_OK)
except Issue.DoesNotExist:
return Response(
{"error": "Issue Does not exist"}, status=status.HTTP_404_NOT_FOUND
)
{"error": "Issue Does not exist"}, status=status.HTTP_404_NOT_FOUND)
except Exception as e:
capture_exception(e)
return Response(
@@ -1201,7 +1104,6 @@ class IssueArchiveViewSet(BaseViewSet):
status=status.HTTP_400_BAD_REQUEST,
)
class IssueSubscriberViewSet(BaseViewSet):
serializer_class = IssueSubscriberSerializer
model = IssueSubscriber
@@ -1242,22 +1144,18 @@ class IssueSubscriberViewSet(BaseViewSet):
def list(self, request, slug, project_id, issue_id):
try:
members = (
ProjectMember.objects.filter(
workspace__slug=slug, project_id=project_id
)
.annotate(
is_subscribed=Exists(
IssueSubscriber.objects.filter(
workspace__slug=slug,
project_id=project_id,
issue_id=issue_id,
subscriber=OuterRef("member"),
)
members = ProjectMember.objects.filter(
workspace__slug=slug, project_id=project_id
).annotate(
is_subscribed=Exists(
IssueSubscriber.objects.filter(
workspace__slug=slug,
project_id=project_id,
issue_id=issue_id,
subscriber=OuterRef("member"),
)
)
.select_related("member")
)
).select_related("member")
serializer = ProjectMemberLiteSerializer(members, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
except Exception as e:
@@ -1357,498 +1255,3 @@ class IssueSubscriberViewSet(BaseViewSet):
{"error": "Something went wrong, please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class IssueReactionViewSet(BaseViewSet):
serializer_class = IssueReactionSerializer
model = IssueReaction
permission_classes = [
ProjectLitePermission,
]
def get_queryset(self):
return (
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)
.order_by("-created_at")
.distinct()
)
def perform_create(self, serializer):
serializer.save(
issue_id=self.kwargs.get("issue_id"),
project_id=self.kwargs.get("project_id"),
actor=self.request.user,
)
def destroy(self, request, slug, project_id, issue_id, reaction_code):
try:
issue_reaction = IssueReaction.objects.get(
workspace__slug=slug,
project_id=project_id,
issue_id=issue_id,
reaction=reaction_code,
actor=request.user,
)
issue_reaction.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
except IssueReaction.DoesNotExist:
return Response(
{"error": "Issue reaction does not exist"},
status=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class CommentReactionViewSet(BaseViewSet):
serializer_class = CommentReactionSerializer
model = CommentReaction
permission_classes = [
ProjectLitePermission,
]
def get_queryset(self):
return (
super()
.get_queryset()
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id"))
.filter(comment_id=self.kwargs.get("comment_id"))
.filter(project__project_projectmember__member=self.request.user)
.order_by("-created_at")
.distinct()
)
def perform_create(self, serializer):
serializer.save(
actor=self.request.user,
comment_id=self.kwargs.get("comment_id"),
project_id=self.kwargs.get("project_id"),
)
def destroy(self, request, slug, project_id, comment_id, reaction_code):
try:
comment_reaction = CommentReaction.objects.get(
workspace__slug=slug,
project_id=project_id,
comment_id=comment_id,
reaction=reaction_code,
actor=request.user,
)
comment_reaction.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
except CommentReaction.DoesNotExist:
return Response(
{"error": "Comment reaction does not exist"},
status=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class IssueCommentPublicViewSet(BaseViewSet):
serializer_class = IssueCommentSerializer
model = IssueComment
filterset_fields = [
"issue__id",
"workspace__id",
]
def get_queryset(self):
project_deploy_board = ProjectDeployBoard.objects.get(
workspace__slug=self.kwargs.get("slug"),
project_id=self.kwargs.get("project_id"),
)
if project_deploy_board.comments:
return self.filter_queryset(
super()
.get_queryset()
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(issue_id=self.kwargs.get("issue_id"))
.select_related("project")
.select_related("workspace")
.select_related("issue")
.distinct()
)
else:
return IssueComment.objects.none()
def create(self, request, slug, project_id, issue_id):
try:
project_deploy_board = ProjectDeployBoard.objects.get(
workspace__slug=slug, project_id=project_id
)
if not project_deploy_board.comments:
return Response(
{"error": "Comments are not enabled for this project"},
status=status.HTTP_400_BAD_REQUEST,
)
access = (
"INTERNAL"
if ProjectMember.objects.filter(
project_id=project_id, member=request.user
).exists()
else "EXTERNAL"
)
serializer = IssueCommentSerializer(data=request.data)
if serializer.is_valid():
serializer.save(
project_id=project_id,
issue_id=issue_id,
actor=request.user,
access=access,
)
issue_activity.delay(
type="comment.activity.created",
requested_data=json.dumps(serializer.data, cls=DjangoJSONEncoder),
actor_id=str(request.user.id),
issue_id=str(issue_id),
project_id=str(project_id),
current_instance=None,
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
def partial_update(self, request, slug, project_id, issue_id, pk):
try:
project_deploy_board = ProjectDeployBoard.objects.get(
workspace__slug=slug, project_id=project_id
)
if not project_deploy_board.comments:
return Response(
{"error": "Comments are not enabled for this project"},
status=status.HTTP_400_BAD_REQUEST,
)
comment = IssueComment.objects.get(
workspace__slug=slug, pk=pk, actor=request.user
)
serializer = IssueCommentSerializer(
comment, data=request.data, partial=True
)
if serializer.is_valid():
serializer.save()
issue_activity.delay(
type="comment.activity.updated",
requested_data=json.dumps(request.data, cls=DjangoJSONEncoder),
actor_id=str(request.user.id),
issue_id=str(issue_id),
project_id=str(project_id),
current_instance=json.dumps(
IssueCommentSerializer(comment).data,
cls=DjangoJSONEncoder,
),
)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
except (IssueComment.DoesNotExist, ProjectDeployBoard.DoesNotExist):
return Response(
{"error": "IssueComent Does not exists"},
status=status.HTTP_400_BAD_REQUEST,)
def destroy(self, request, slug, project_id, issue_id, pk):
try:
project_deploy_board = ProjectDeployBoard.objects.get(
workspace__slug=slug, project_id=project_id
)
if not project_deploy_board.comments:
return Response(
{"error": "Comments are not enabled for this project"},
status=status.HTTP_400_BAD_REQUEST,
)
comment = IssueComment.objects.get(
workspace__slug=slug, pk=pk, project_id=project_id, actor=request.user
)
issue_activity.delay(
type="comment.activity.deleted",
requested_data=json.dumps({"comment_id": str(pk)}),
actor_id=str(request.user.id),
issue_id=str(issue_id),
project_id=str(project_id),
current_instance=json.dumps(
IssueCommentSerializer(comment).data,
cls=DjangoJSONEncoder,
),
)
comment.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
except (IssueComment.DoesNotExist, ProjectDeployBoard.DoesNotExist):
return Response(
{"error": "IssueComent Does not exists"},
status=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class IssueReactionPublicViewSet(BaseViewSet):
serializer_class = IssueReactionSerializer
model = IssueReaction
def get_queryset(self):
project_deploy_board = ProjectDeployBoard.objects.get(
workspace__slug=self.kwargs.get("slug"),
project_id=self.kwargs.get("project_id"),
)
if project_deploy_board.reactions:
return (
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"))
.order_by("-created_at")
.distinct()
)
else:
return IssueReaction.objects.none()
def create(self, request, slug, project_id, issue_id):
try:
project_deploy_board = ProjectDeployBoard.objects.get(
workspace__slug=slug, project_id=project_id
)
if not project_deploy_board.reactions:
return Response(
{"error": "Reactions are not enabled for this project board"},
status=status.HTTP_400_BAD_REQUEST,
)
serializer = IssueReactionSerializer(data=request.data)
if serializer.is_valid():
serializer.save(
project_id=project_id, issue_id=issue_id, actor=request.user
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
except ProjectDeployBoard.DoesNotExist:
return Response(
{"error": "Project board does not exist"},
status=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
def destroy(self, request, slug, project_id, issue_id, reaction_code):
try:
project_deploy_board = ProjectDeployBoard.objects.get(
workspace__slug=slug, project_id=project_id
)
if not project_deploy_board.reactions:
return Response(
{"error": "Reactions are not enabled for this project board"},
status=status.HTTP_400_BAD_REQUEST,
)
issue_reaction = IssueReaction.objects.get(
workspace__slug=slug,
issue_id=issue_id,
reaction=reaction_code,
actor=request.user,
)
issue_reaction.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
except IssueReaction.DoesNotExist:
return Response(
{"error": "Issue reaction does not exist"},
status=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class CommentReactionPublicViewSet(BaseViewSet):
serializer_class = CommentReactionSerializer
model = CommentReaction
def get_queryset(self):
project_deploy_board = ProjectDeployBoard.objects.get(
workspace__slug=self.kwargs.get("slug"),
project_id=self.kwargs.get("project_id"),
)
if project_deploy_board.reactions:
return (
super()
.get_queryset()
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id"))
.filter(comment_id=self.kwargs.get("comment_id"))
.order_by("-created_at")
.distinct()
)
else:
return CommentReaction.objects.none()
def create(self, request, slug, project_id, comment_id):
try:
project_deploy_board = ProjectDeployBoard.objects.get(
workspace__slug=slug, project_id=project_id
)
if not project_deploy_board.reactions:
return Response(
{"error": "Reactions are not enabled for this board"},
status=status.HTTP_400_BAD_REQUEST,
)
serializer = CommentReactionSerializer(data=request.data)
if serializer.is_valid():
serializer.save(
project_id=project_id, comment_id=comment_id, actor=request.user
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
except ProjectDeployBoard.DoesNotExist:
return Response(
{"error": "Project board does not exist"},
status=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
def destroy(self, request, slug, project_id, comment_id, reaction_code):
try:
project_deploy_board = ProjectDeployBoard.objects.get(
workspace__slug=slug, project_id=project_id
)
if not project_deploy_board.reactions:
return Response(
{"error": "Reactions are not enabled for this board"},
status=status.HTTP_400_BAD_REQUEST,
)
comment_reaction = CommentReaction.objects.get(
project_id=project_id,
workspace__slug=slug,
comment_id=comment_id,
reaction=reaction_code,
actor=request.user,
)
comment_reaction.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
except CommentReaction.DoesNotExist:
return Response(
{"error": "Comment reaction does not exist"},
status=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class IssueVotePublicViewSet(BaseViewSet):
model = IssueVote
serializer_class = IssueVoteSerializer
def get_queryset(self):
return (
super()
.get_queryset()
.filter(issue_id=self.kwargs.get("issue_id"))
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id"))
)
def create(self, request, slug, project_id, issue_id):
try:
issue_vote, _ = IssueVote.objects.get_or_create(
actor_id=request.user.id,
project_id=project_id,
issue_id=issue_id,
vote=request.data.get("vote", 1),
)
serializer = IssueVoteSerializer(issue_vote)
return Response(serializer.data, status=status.HTTP_201_CREATED)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
def destroy(self, request, slug, project_id, issue_id):
try:
issue_vote = IssueVote.objects.get(
workspace__slug=slug,
project_id=project_id,
issue_id=issue_id,
actor_id=request.user.id,
)
issue_vote.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class ExportIssuesEndpoint(BaseAPIView):
permission_classes = [
WorkSpaceAdminPermission,
]
def post(self, request, slug):
try:
issue_export_task.delay(
email=request.user.email, data=request.data, slug=slug ,exporter_name=request.user.first_name
)
return Response(
{
"message": f"Once the export is ready it will be emailed to you at {str(request.user.email)}"
},
status=status.HTTP_200_OK,
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
+5 -5
View File
@@ -53,8 +53,6 @@ class ModuleViewSet(BaseViewSet):
)
def get_queryset(self):
order_by = self.request.GET.get("order_by", "sort_order")
subquery = ModuleFavorite.objects.filter(
user=self.request.user,
module_id=OuterRef("pk"),
@@ -108,7 +106,7 @@ class ModuleViewSet(BaseViewSet):
filter=Q(issue_module__issue__state__group="backlog"),
)
)
.order_by(order_by, "name")
.order_by("-is_favorite", "name")
)
def perform_destroy(self, instance):
@@ -175,9 +173,8 @@ class ModuleViewSet(BaseViewSet):
.annotate(first_name=F("assignees__first_name"))
.annotate(last_name=F("assignees__last_name"))
.annotate(assignee_id=F("assignees__id"))
.annotate(display_name=F("assignees__display_name"))
.annotate(avatar=F("assignees__avatar"))
.values("first_name", "last_name", "assignee_id", "avatar", "display_name")
.values("first_name", "last_name", "assignee_id", "avatar")
.annotate(total_issues=Count("assignee_id"))
.annotate(
completed_issues=Count(
@@ -483,6 +480,9 @@ class ModuleLinkViewSet(BaseViewSet):
class ModuleFavoriteViewSet(BaseViewSet):
permission_classes = [
ProjectEntityPermission,
]
serializer_class = ModuleFavoriteSerializer
model = ModuleFavorite
+15 -33
View File
@@ -6,15 +6,14 @@ from django.utils import timezone
from rest_framework import status
from rest_framework.response import Response
from sentry_sdk import capture_exception
from plane.utils.paginator import BasePaginator
# Module imports
from .base import BaseViewSet, BaseAPIView
from plane.db.models import Notification, IssueAssignee, IssueSubscriber, Issue, WorkspaceMember
from plane.db.models import Notification, IssueAssignee, IssueSubscriber, Issue
from plane.api.serializers import NotificationSerializer
class NotificationViewSet(BaseViewSet, BasePaginator):
class NotificationViewSet(BaseViewSet):
model = Notification
serializer_class = NotificationSerializer
@@ -31,20 +30,17 @@ class NotificationViewSet(BaseViewSet, BasePaginator):
def list(self, request, slug):
try:
order_by = request.GET.get("order_by", "-created_at")
snoozed = request.GET.get("snoozed", "false")
archived = request.GET.get("archived", "false")
read = request.GET.get("read", "true")
read = request.GET.get("read", "false")
# Filter type
type = request.GET.get("type", "all")
notifications = (
Notification.objects.filter(
workspace__slug=slug, receiver_id=request.user.id
)
.select_related("workspace", "project", "triggered_by", "receiver")
.order_by("snoozed_till", "-created_at")
)
notifications = Notification.objects.filter(
workspace__slug=slug, receiver_id=request.user.id
).order_by(order_by)
# Filter for snoozed notifications
if snoozed == "false":
@@ -57,6 +53,8 @@ class NotificationViewSet(BaseViewSet, BasePaginator):
Q(snoozed_till__lt=timezone.now()) | Q(snoozed_till__isnull=False)
)
if read == "true":
notifications = notifications.filter(read_at__isnull=False)
if read == "false":
notifications = notifications.filter(read_at__isnull=True)
@@ -83,23 +81,10 @@ class NotificationViewSet(BaseViewSet, BasePaginator):
# Created issues
if type == "created":
if WorkspaceMember.objects.filter(workspace__slug=slug, member=request.user, role__lt=15).exists():
notifications = Notification.objects.none()
else:
issue_ids = Issue.objects.filter(
workspace__slug=slug, created_by=request.user
).values_list("pk", flat=True)
notifications = notifications.filter(entity_identifier__in=issue_ids)
# Pagination
if request.GET.get("per_page", False) and request.GET.get("cursor", False):
return self.paginate(
request=request,
queryset=(notifications),
on_results=lambda notifications: NotificationSerializer(
notifications, many=True
).data,
)
issue_ids = Issue.objects.filter(
workspace__slug=slug, created_by=request.user
).values_list("pk", flat=True)
notifications = notifications.filter(entity_identifier__in=issue_ids)
serializer = NotificationSerializer(notifications, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
@@ -228,11 +213,10 @@ class UnreadNotificationEndpoint(BaseAPIView):
def get(self, request, slug):
try:
# Watching Issues Count
watching_issues_count = Notification.objects.filter(
watching_notification_count = Notification.objects.filter(
workspace__slug=slug,
receiver_id=request.user.id,
read_at__isnull=True,
archived_at__isnull=True,
entity_identifier__in=IssueSubscriber.objects.filter(
workspace__slug=slug, subscriber_id=request.user.id
).values_list("issue_id", flat=True),
@@ -243,7 +227,6 @@ class UnreadNotificationEndpoint(BaseAPIView):
workspace__slug=slug,
receiver_id=request.user.id,
read_at__isnull=True,
archived_at__isnull=True,
entity_identifier__in=IssueAssignee.objects.filter(
workspace__slug=slug, assignee_id=request.user.id
).values_list("issue_id", flat=True),
@@ -254,7 +237,6 @@ class UnreadNotificationEndpoint(BaseAPIView):
workspace__slug=slug,
receiver_id=request.user.id,
read_at__isnull=True,
archived_at__isnull=True,
entity_identifier__in=Issue.objects.filter(
workspace__slug=slug, created_by=request.user
).values_list("pk", flat=True),
@@ -262,7 +244,7 @@ class UnreadNotificationEndpoint(BaseAPIView):
return Response(
{
"watching_issues": watching_issues_count,
"watching_notifications": watching_notification_count,
"my_issues": my_issues_count,
"created_issues": created_issues_count,
},
+1 -1
View File
@@ -301,7 +301,7 @@ class CreateIssueFromPageBlockEndpoint(BaseAPIView):
issue=issue,
actor=request.user,
project_id=project_id,
comment=f"created the issue from {page_block.name} block",
comment=f"{request.user.email} created the issue from {page_block.name} block",
verb="created",
)
@@ -140,7 +140,7 @@ class UserActivityEndpoint(BaseAPIView, BasePaginator):
def get(self, request):
try:
queryset = IssueActivity.objects.filter(actor=request.user).select_related(
"actor", "workspace", "issue", "project"
"actor", "workspace"
)
return self.paginate(
+18 -376
View File
@@ -5,21 +5,7 @@ from datetime import datetime
# Django imports
from django.core.exceptions import ValidationError
from django.db import IntegrityError
from django.db.models import (
Q,
Exists,
OuterRef,
Func,
F,
Max,
CharField,
Func,
Subquery,
Prefetch,
When,
Case,
Value,
)
from django.db.models import Q, Exists, OuterRef, Func, F
from django.core.validators import validate_email
from django.conf import settings
@@ -27,7 +13,6 @@ from django.conf import settings
from rest_framework.response import Response
from rest_framework import status
from rest_framework import serializers
from rest_framework.permissions import AllowAny
from sentry_sdk import capture_exception
# Module imports
@@ -38,16 +23,9 @@ from plane.api.serializers import (
ProjectDetailSerializer,
ProjectMemberInviteSerializer,
ProjectFavoriteSerializer,
IssueLiteSerializer,
ProjectDeployBoardSerializer,
ProjectMemberAdminSerializer,
)
from plane.api.permissions import (
ProjectBasePermission,
ProjectEntityPermission,
ProjectMemberPermission,
)
from plane.api.permissions import ProjectBasePermission
from plane.db.models import (
Project,
@@ -70,17 +48,9 @@ from plane.db.models import (
IssueAssignee,
ModuleMember,
Inbox,
ProjectDeployBoard,
Issue,
IssueReaction,
IssueLink,
IssueAttachment,
Label,
)
from plane.bgtasks.project_invitation_task import project_invitation
from plane.utils.grouper import group_results
from plane.utils.issue_filters import issue_filters
class ProjectViewSet(BaseViewSet):
@@ -121,40 +91,6 @@ class ProjectViewSet(BaseViewSet):
)
)
)
.annotate(
total_members=ProjectMember.objects.filter(
project_id=OuterRef("id"), member__is_bot=False
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
total_cycles=Cycle.objects.filter(project_id=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
total_modules=Module.objects.filter(project_id=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
member_role=ProjectMember.objects.filter(
project_id=OuterRef("pk"),
member_id=self.request.user.id,
).values("role")
)
.annotate(
is_deployed=Exists(
ProjectDeployBoard.objects.filter(
project_id=OuterRef("pk"),
workspace__slug=self.kwargs.get("slug"),
)
)
)
.distinct()
)
@@ -166,16 +102,10 @@ class ProjectViewSet(BaseViewSet):
project_id=OuterRef("pk"),
workspace__slug=self.kwargs.get("slug"),
)
sort_order_query = ProjectMember.objects.filter(
member=request.user,
project_id=OuterRef("pk"),
workspace__slug=self.kwargs.get("slug"),
).values("sort_order")
projects = (
self.get_queryset()
.annotate(is_favorite=Exists(subquery))
.annotate(sort_order=Subquery(sort_order_query))
.order_by("sort_order", "name")
.order_by("-is_favorite", "name")
.annotate(
total_members=ProjectMember.objects.filter(
project_id=OuterRef("id")
@@ -222,49 +152,40 @@ class ProjectViewSet(BaseViewSet):
serializer.save()
# Add the user as Administrator to the project
project_member = ProjectMember.objects.create(
ProjectMember.objects.create(
project_id=serializer.data["id"], member=request.user, role=20
)
if serializer.data["project_lead"] is not None and str(
serializer.data["project_lead"]
) != str(request.user.id):
ProjectMember.objects.create(
project_id=serializer.data["id"],
member_id=serializer.data["project_lead"],
role=20,
)
# Default states
states = [
{
"name": "Backlog",
"color": "#A3A3A3",
"color": "#5e6ad2",
"sequence": 15000,
"group": "backlog",
"default": True,
},
{
"name": "Todo",
"color": "#3A3A3A",
"color": "#eb5757",
"sequence": 25000,
"group": "unstarted",
},
{
"name": "In Progress",
"color": "#F59E0B",
"color": "#26b5ce",
"sequence": 35000,
"group": "started",
},
{
"name": "Done",
"color": "#16A34A",
"color": "#f2c94c",
"sequence": 45000,
"group": "completed",
},
{
"name": "Cancelled",
"color": "#EF4444",
"color": "#4cb782",
"sequence": 55000,
"group": "cancelled",
},
@@ -286,11 +207,9 @@ class ProjectViewSet(BaseViewSet):
]
)
data = serializer.data
data["sort_order"] = project_member.sort_order
return Response(data, status=status.HTTP_201_CREATED)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(
serializer.errors,
[serializer.errors[error][0] for error in serializer.errors],
status=status.HTTP_400_BAD_REQUEST,
)
except IntegrityError as e:
@@ -395,9 +314,7 @@ class InviteProjectEndpoint(BaseAPIView):
validate_email(email)
# Check if user is already a member of workspace
if ProjectMember.objects.filter(
project_id=project_id,
member__email=email,
member__is_bot=False,
project_id=project_id, member__email=email
).exists():
return Response(
{"error": "User is already member of workspace"},
@@ -501,14 +418,14 @@ class UserProjectInvitationsViewset(BaseViewSet):
class ProjectMemberViewSet(BaseViewSet):
serializer_class = ProjectMemberAdminSerializer
serializer_class = ProjectMemberSerializer
model = ProjectMember
permission_classes = [
ProjectBasePermission,
]
search_fields = [
"member__display_name",
"member__email",
"member__first_name",
]
@@ -650,36 +567,17 @@ class AddMemberToProjectEndpoint(BaseAPIView):
{"error": "Atleast one member is required"},
status=status.HTTP_400_BAD_REQUEST,
)
bulk_project_members = []
project_members = (
ProjectMember.objects.filter(
workspace__slug=slug,
member_id__in=[member.get("member_id") for member in members],
)
.values("member_id", "sort_order")
.order_by("sort_order")
)
for member in members:
sort_order = [
project_member.get("sort_order")
for project_member in project_members
if str(project_member.get("member_id"))
== str(member.get("member_id"))
]
bulk_project_members.append(
project_members = ProjectMember.objects.bulk_create(
[
ProjectMember(
member_id=member.get("member_id"),
role=member.get("role", 10),
project_id=project_id,
workspace_id=project.workspace_id,
sort_order=sort_order[0] - 10000 if len(sort_order) else 65535,
)
)
project_members = ProjectMember.objects.bulk_create(
bulk_project_members,
for member in members
],
batch_size=10,
ignore_conflicts=True,
)
@@ -921,15 +819,11 @@ class ProjectUserViewsEndpoint(BaseAPIView):
view_props = project_member.view_props
default_props = project_member.default_props
preferences = project_member.preferences
sort_order = project_member.sort_order
project_member.view_props = request.data.get("view_props", view_props)
project_member.default_props = request.data.get(
"default_props", default_props
)
project_member.preferences = request.data.get("preferences", preferences)
project_member.sort_order = request.data.get("sort_order", sort_order)
project_member.save()
@@ -1034,255 +928,3 @@ class ProjectFavoritesViewSet(BaseViewSet):
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class ProjectDeployBoardViewSet(BaseViewSet):
permission_classes = [
ProjectMemberPermission,
]
serializer_class = ProjectDeployBoardSerializer
model = ProjectDeployBoard
def get_queryset(self):
return (
super()
.get_queryset()
.filter(
workspace__slug=self.kwargs.get("slug"),
project_id=self.kwargs.get("project_id"),
)
.select_related("project")
)
def create(self, request, slug, project_id):
try:
comments = request.data.get("comments", False)
reactions = request.data.get("reactions", False)
inbox = request.data.get("inbox", None)
votes = request.data.get("votes", False)
views = request.data.get(
"views",
{
"list": True,
"kanban": True,
"calendar": True,
"gantt": True,
"spreadsheet": True,
},
)
project_deploy_board, _ = ProjectDeployBoard.objects.get_or_create(
anchor=f"{slug}/{project_id}",
project_id=project_id,
)
project_deploy_board.comments = comments
project_deploy_board.reactions = reactions
project_deploy_board.inbox = inbox
project_deploy_board.votes = votes
project_deploy_board.views = views
project_deploy_board.save()
serializer = ProjectDeployBoardSerializer(project_deploy_board)
return Response(serializer.data, status=status.HTTP_200_OK)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class ProjectMemberEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
def get(self, request, slug, project_id):
try:
project_members = ProjectMember.objects.filter(
project_id=project_id,
workspace__slug=slug,
member__is_bot=False,
).select_related("project", "member")
serializer = ProjectMemberSerializer(project_members, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class ProjectDeployBoardPublicSettingsEndpoint(BaseAPIView):
permission_classes = [
AllowAny,
]
def get(self, request, slug, project_id):
try:
project_deploy_board = ProjectDeployBoard.objects.get(
workspace__slug=slug, project_id=project_id
)
serializer = ProjectDeployBoardSerializer(project_deploy_board)
return Response(serializer.data, status=status.HTTP_200_OK)
except ProjectDeployBoard.DoesNotExist:
return Response(
{"error": "Project Deploy Board does not exists"},
status=status.HTTP_404_NOT_FOUND,
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class ProjectDeployBoardIssuesPublicEndpoint(BaseAPIView):
permission_classes = [
AllowAny,
]
def get(self, request, slug, project_id):
try:
project_deploy_board = ProjectDeployBoard.objects.get(
workspace__slug=slug, project_id=project_id
)
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")
issue_queryset = (
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("actor"),
)
)
.filter(**filters)
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(module_id=F("issue_module__module_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")
)
)
# 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 = IssueLiteSerializer(issue_queryset, many=True).data
states = State.objects.filter(
workspace__slug=slug, project_id=project_id
).values("name", "group", "color", "id")
labels = Label.objects.filter(
workspace__slug=slug, project_id=project_id
).values("id", "name", "color", "parent")
## Grouping the results
group_by = request.GET.get("group_by", False)
if group_by:
issues = group_results(issues, group_by)
return Response(
{
"issues": issues,
"states": states,
"labels": labels,
},
status=status.HTTP_200_OK,
)
except ProjectDeployBoard.DoesNotExist:
return Response(
{"error": "Board does not exists"}, status=status.HTTP_404_NOT_FOUND
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
+85 -100
View File
@@ -20,7 +20,7 @@ class GlobalSearchEndpoint(BaseAPIView):
also show related workspace if found
"""
def filter_workspaces(self, query, slug, project_id, workspace_search):
def filter_workspaces(self, query, slug, project_id):
fields = ["name"]
q = Q()
for field in fields:
@@ -31,8 +31,8 @@ class GlobalSearchEndpoint(BaseAPIView):
.values("name", "id", "slug")
)
def filter_projects(self, query, slug, project_id, workspace_search):
fields = ["name", "identifier"]
def filter_projects(self, query, slug, project_id):
fields = ["name"]
q = Q()
for field in fields:
q |= Q(**{f"{field}__icontains": query})
@@ -46,8 +46,8 @@ class GlobalSearchEndpoint(BaseAPIView):
.values("name", "id", "identifier", "workspace__slug")
)
def filter_issues(self, query, slug, project_id, workspace_search):
fields = ["name", "sequence_id", "project__identifier"]
def filter_issues(self, query, slug, project_id):
fields = ["name", "sequence_id"]
q = Q()
for field in fields:
if field == "sequence_id":
@@ -56,123 +56,111 @@ class GlobalSearchEndpoint(BaseAPIView):
q |= Q(**{"sequence_id": sequence_id})
else:
q |= Q(**{f"{field}__icontains": query})
issues = Issue.issue_objects.filter(
q,
project__project_projectmember__member=self.request.user,
workspace__slug=slug,
return (
Issue.issue_objects.filter(
q,
project__project_projectmember__member=self.request.user,
workspace__slug=slug,
project_id=project_id,
)
.distinct()
.values(
"name",
"id",
"sequence_id",
"project__identifier",
"project_id",
"workspace__slug",
)
)
if workspace_search == "false" and project_id:
issues = issues.filter(project_id=project_id)
return issues.distinct().values(
"name",
"id",
"sequence_id",
"project__identifier",
"project_id",
"workspace__slug",
)
def filter_cycles(self, query, slug, project_id, workspace_search):
def filter_cycles(self, query, slug, project_id):
fields = ["name"]
q = Q()
for field in fields:
q |= Q(**{f"{field}__icontains": query})
cycles = Cycle.objects.filter(
q,
project__project_projectmember__member=self.request.user,
workspace__slug=slug,
return (
Cycle.objects.filter(
q,
project__project_projectmember__member=self.request.user,
workspace__slug=slug,
project_id=project_id,
)
.distinct()
.values(
"name",
"id",
"project_id",
"workspace__slug",
)
)
if workspace_search == "false" and project_id:
cycles = cycles.filter(project_id=project_id)
return cycles.distinct().values(
"name",
"id",
"project_id",
"project__identifier",
"workspace__slug",
)
def filter_modules(self, query, slug, project_id, workspace_search):
def filter_modules(self, query, slug, project_id):
fields = ["name"]
q = Q()
for field in fields:
q |= Q(**{f"{field}__icontains": query})
modules = Module.objects.filter(
q,
project__project_projectmember__member=self.request.user,
workspace__slug=slug,
return (
Module.objects.filter(
q,
project__project_projectmember__member=self.request.user,
workspace__slug=slug,
project_id=project_id,
)
.distinct()
.values(
"name",
"id",
"project_id",
"workspace__slug",
)
)
if workspace_search == "false" and project_id:
modules = modules.filter(project_id=project_id)
return modules.distinct().values(
"name",
"id",
"project_id",
"project__identifier",
"workspace__slug",
)
def filter_pages(self, query, slug, project_id, workspace_search):
def filter_pages(self, query, slug, project_id):
fields = ["name"]
q = Q()
for field in fields:
q |= Q(**{f"{field}__icontains": query})
pages = Page.objects.filter(
q,
project__project_projectmember__member=self.request.user,
workspace__slug=slug,
return (
Page.objects.filter(
q,
project__project_projectmember__member=self.request.user,
workspace__slug=slug,
project_id=project_id,
)
.distinct()
.values(
"name",
"id",
"project_id",
"workspace__slug",
)
)
if workspace_search == "false" and project_id:
pages = pages.filter(project_id=project_id)
return pages.distinct().values(
"name",
"id",
"project_id",
"project__identifier",
"workspace__slug",
)
def filter_views(self, query, slug, project_id, workspace_search):
def filter_views(self, query, slug, project_id):
fields = ["name"]
q = Q()
for field in fields:
q |= Q(**{f"{field}__icontains": query})
issue_views = IssueView.objects.filter(
q,
project__project_projectmember__member=self.request.user,
workspace__slug=slug,
return (
IssueView.objects.filter(
q,
project__project_projectmember__member=self.request.user,
workspace__slug=slug,
project_id=project_id,
)
.distinct()
.values(
"name",
"id",
"project_id",
"workspace__slug",
)
)
if workspace_search == "false" and project_id:
issue_views = issue_views.filter(project_id=project_id)
return issue_views.distinct().values(
"name",
"id",
"project_id",
"project__identifier",
"workspace__slug",
)
def get(self, request, slug):
def get(self, request, slug, project_id):
try:
query = request.query_params.get("search", False)
workspace_search = request.query_params.get("workspace_search", "false")
project_id = request.query_params.get("project_id", False)
if not query:
return Response(
{
@@ -203,7 +191,7 @@ class GlobalSearchEndpoint(BaseAPIView):
for model in MODELS_MAPPER.keys():
func = MODELS_MAPPER.get(model, None)
results[model] = func(query, slug, project_id, workspace_search)
results[model] = func(query, slug, project_id)
return Response({"results": results}, status=status.HTTP_200_OK)
except Exception as e:
@@ -218,7 +206,6 @@ class IssueSearchEndpoint(BaseAPIView):
def get(self, request, slug, project_id):
try:
query = request.query_params.get("search", False)
workspace_search = request.query_params.get("workspace_search", "false")
parent = request.query_params.get("parent", "false")
blocker_blocked_by = request.query_params.get("blocker_blocked_by", "false")
cycle = request.query_params.get("cycle", "false")
@@ -229,12 +216,10 @@ class IssueSearchEndpoint(BaseAPIView):
issues = Issue.issue_objects.filter(
workspace__slug=slug,
project_id=project_id,
project__project_projectmember__member=self.request.user,
)
if workspace_search == "false":
issues = issues.filter(project_id=project_id)
if query:
issues = search_issues(query, issues)
@@ -266,12 +251,12 @@ class IssueSearchEndpoint(BaseAPIView):
if module == "true":
issues = issues.exclude(issue_module__isnull=False)
return Response(
issues.values(
"name",
"id",
"sequence_id",
"project__name",
"project__identifier",
"project_id",
"workspace__slug",
-7
View File
@@ -19,7 +19,6 @@ from plane.db.models import (
IssueView,
Issue,
IssueViewFavorite,
IssueReaction,
)
from plane.utils.issue_filters import issue_filters
@@ -78,12 +77,6 @@ class ViewIssuesEndpoint(BaseAPIView):
.select_related("parent")
.prefetch_related("assignees")
.prefetch_related("labels")
.prefetch_related(
Prefetch(
"issue_reactions",
queryset=IssueReaction.objects.select_related("actor"),
)
)
)
serializer = IssueLiteSerializer(issues, many=True)
+10 -474
View File
@@ -13,18 +13,12 @@ from django.core.exceptions import ValidationError
from django.core.validators import validate_email
from django.contrib.sites.shortcuts import get_current_site
from django.db.models import (
Prefetch,
CharField,
Count,
OuterRef,
Func,
F,
Q,
Count,
Case,
Value,
CharField,
When,
Max,
IntegerField,
)
from django.db.models.functions import ExtractWeek, Cast, ExtractDay
from django.db.models.fields import DateField
@@ -45,9 +39,6 @@ from plane.api.serializers import (
UserLiteSerializer,
ProjectMemberSerializer,
WorkspaceThemeSerializer,
IssueActivitySerializer,
IssueLiteSerializer,
WorkspaceMemberAdminSerializer,
)
from plane.api.views.base import BaseAPIView
from . import BaseViewSet
@@ -69,24 +60,9 @@ from plane.db.models import (
PageFavorite,
Page,
IssueViewFavorite,
IssueLink,
IssueAttachment,
IssueSubscriber,
Project,
Label,
WorkspaceMember,
CycleIssue,
IssueReaction,
)
from plane.api.permissions import (
WorkSpaceBasePermission,
WorkSpaceAdminPermission,
WorkspaceEntityPermission,
WorkspaceViewerPermission,
)
from plane.api.permissions import WorkSpaceBasePermission, WorkSpaceAdminPermission
from plane.bgtasks.workspace_invitation_task import workspace_invitation
from plane.utils.issue_filters import issue_filters
from plane.utils.grouper import group_results
class WorkSpaceViewSet(BaseViewSet):
@@ -107,9 +83,7 @@ class WorkSpaceViewSet(BaseViewSet):
def get_queryset(self):
member_count = (
WorkspaceMember.objects.filter(
workspace=OuterRef("id"), member__is_bot=False
)
WorkspaceMember.objects.filter(workspace=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
@@ -194,9 +168,7 @@ class UserWorkSpacesEndpoint(BaseAPIView):
def get(self, request):
try:
member_count = (
WorkspaceMember.objects.filter(
workspace=OuterRef("id"), member__is_bot=False
)
WorkspaceMember.objects.filter(workspace=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
@@ -542,7 +514,7 @@ class UserWorkspaceInvitationsEndpoint(BaseViewSet):
class WorkSpaceMemberViewSet(BaseViewSet):
serializer_class = WorkspaceMemberAdminSerializer
serializer_class = WorkSpaceMemberSerializer
model = WorkspaceMember
permission_classes = [
@@ -550,7 +522,7 @@ class WorkSpaceMemberViewSet(BaseViewSet):
]
search_fields = [
"member__display_name",
"member__email",
"member__first_name",
]
@@ -625,21 +597,6 @@ class WorkSpaceMemberViewSet(BaseViewSet):
status=status.HTTP_400_BAD_REQUEST,
)
# Check for the only member in the workspace
if (
workspace_member.role == 20
and WorkspaceMember.objects.filter(
workspace__slug=slug,
role=20,
member__is_bot=False,
).count()
== 1
):
return Response(
{"error": "Cannot delete the only Admin for the workspace"},
status=status.HTTP_400_BAD_REQUEST,
)
# Delete the user also from all the projects
ProjectMember.objects.filter(
workspace__slug=slug, member=workspace_member.member
@@ -697,7 +654,7 @@ class TeamMemberViewSet(BaseViewSet):
]
search_fields = [
"member__display_name",
"member__email",
"member__first_name",
]
@@ -994,11 +951,11 @@ class UserWorkspaceDashboardEndpoint(BaseAPIView):
upcoming_issues = Issue.issue_objects.filter(
~Q(state__group__in=["completed", "cancelled"]),
start_date__gte=timezone.now(),
target_date__gte=timezone.now(),
workspace__slug=slug,
assignees__in=[request.user],
completed_at__isnull=True,
).values("id", "name", "workspace__slug", "project_id", "start_date")
).values("id", "name", "workspace__slug", "project_id", "target_date")
return Response(
{
@@ -1052,424 +1009,3 @@ class WorkspaceThemeViewSet(BaseViewSet):
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
def get(self, request, slug, user_id):
try:
filters = issue_filters(request.query_params, "GET")
state_distribution = (
Issue.issue_objects.filter(
workspace__slug=slug,
assignees__in=[user_id],
project__project_projectmember__member=request.user,
)
.filter(**filters)
.annotate(state_group=F("state__group"))
.values("state_group")
.annotate(state_count=Count("state_group"))
.order_by("state_group")
)
priority_order = ["urgent", "high", "medium", "low", None]
priority_distribution = (
Issue.objects.filter(
workspace__slug=slug,
assignees__in=[user_id],
project__project_projectmember__member=request.user,
)
.filter(**filters)
.values("priority")
.annotate(priority_count=Count("priority"))
.filter(priority_count__gte=1)
.annotate(
priority_order=Case(
*[
When(priority=p, then=Value(i))
for i, p in enumerate(priority_order)
],
default=Value(len(priority_order)),
output_field=IntegerField(),
)
)
.order_by("priority_order")
)
created_issues = (
Issue.issue_objects.filter(
workspace__slug=slug,
assignees__in=[user_id],
project__project_projectmember__member=request.user,
created_by_id=user_id,
)
.filter(**filters)
.count()
)
assigned_issues_count = (
Issue.issue_objects.filter(
workspace__slug=slug,
assignees__in=[user_id],
project__project_projectmember__member=request.user,
)
.filter(**filters)
.count()
)
pending_issues_count = (
Issue.issue_objects.filter(
~Q(state__group__in=["completed", "cancelled"]),
workspace__slug=slug,
assignees__in=[user_id],
project__project_projectmember__member=request.user,
)
.filter(**filters)
.count()
)
completed_issues_count = (
Issue.issue_objects.filter(
workspace__slug=slug,
assignees__in=[user_id],
state__group="completed",
project__project_projectmember__member=request.user,
)
.filter(**filters)
.count()
)
subscribed_issues_count = (
IssueSubscriber.objects.filter(
workspace__slug=slug,
subscriber_id=user_id,
project__project_projectmember__member=request.user,
)
.filter(**filters)
.count()
)
upcoming_cycles = CycleIssue.objects.filter(
workspace__slug=slug,
cycle__start_date__gt=timezone.now().date(),
issue__assignees__in=[
user_id,
],
).values("cycle__name", "cycle__id", "cycle__project_id")
present_cycle = CycleIssue.objects.filter(
workspace__slug=slug,
cycle__start_date__lt=timezone.now().date(),
cycle__end_date__gt=timezone.now().date(),
issue__assignees__in=[
user_id,
],
).values("cycle__name", "cycle__id", "cycle__project_id")
return Response(
{
"state_distribution": state_distribution,
"priority_distribution": priority_distribution,
"created_issues": created_issues,
"assigned_issues": assigned_issues_count,
"completed_issues": completed_issues_count,
"pending_issues": pending_issues_count,
"subscribed_issues": subscribed_issues_count,
"present_cycles": present_cycle,
"upcoming_cycles": upcoming_cycles,
}
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class WorkspaceUserActivityEndpoint(BaseAPIView):
permission_classes = [
WorkspaceEntityPermission,
]
def get(self, request, slug, user_id):
try:
projects = request.query_params.getlist("project", [])
queryset = IssueActivity.objects.filter(
workspace__slug=slug,
project__project_projectmember__member=request.user,
actor=user_id,
).select_related("actor", "workspace", "issue", "project")
if projects:
queryset = queryset.filter(project__in=projects)
return self.paginate(
request=request,
queryset=queryset,
on_results=lambda issue_activities: IssueActivitySerializer(
issue_activities, many=True
).data,
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class WorkspaceUserProfileEndpoint(BaseAPIView):
def get(self, request, slug, user_id):
try:
user_data = User.objects.get(pk=user_id)
requesting_workspace_member = WorkspaceMember.objects.get(
workspace__slug=slug, member=request.user
)
projects = []
if requesting_workspace_member.role >= 10:
projects = (
Project.objects.filter(
workspace__slug=slug,
project_projectmember__member=request.user,
)
.annotate(
created_issues=Count(
"project_issue",
filter=Q(project_issue__created_by_id=user_id),
)
)
.annotate(
assigned_issues=Count(
"project_issue",
filter=Q(project_issue__assignees__in=[user_id]),
)
)
.annotate(
completed_issues=Count(
"project_issue",
filter=Q(
project_issue__completed_at__isnull=False,
project_issue__assignees__in=[user_id],
),
)
)
.annotate(
pending_issues=Count(
"project_issue",
filter=Q(
project_issue__state__group__in=[
"backlog",
"unstarted",
"started",
],
project_issue__assignees__in=[user_id],
),
)
)
.values(
"id",
"name",
"identifier",
"emoji",
"icon_prop",
"created_issues",
"assigned_issues",
"completed_issues",
"pending_issues",
)
)
return Response(
{
"project_data": projects,
"user_data": {
"email": user_data.email,
"first_name": user_data.first_name,
"last_name": user_data.last_name,
"avatar": user_data.avatar,
"cover_image": user_data.cover_image,
"date_joined": user_data.date_joined,
"user_timezone": user_data.user_timezone,
"display_name": user_data.display_name,
},
},
status=status.HTTP_200_OK,
)
except WorkspaceMember.DoesNotExist:
return Response({"error": "Forbidden"}, status=status.HTTP_403_FORBIDDEN)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class WorkspaceUserProfileIssuesEndpoint(BaseAPIView):
permission_classes = [
WorkspaceViewerPermission,
]
def get(self, request, slug, user_id):
try:
filters = issue_filters(request.query_params, "GET")
order_by_param = request.GET.get("order_by", "-created_at")
issue_queryset = (
Issue.issue_objects.filter(
Q(assignees__in=[user_id])
| Q(created_by_id=user_id)
| Q(issue_subscribers__subscriber_id=user_id),
workspace__slug=slug,
project__project_projectmember__member=request.user,
)
.filter(**filters)
.annotate(
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.select_related("project", "workspace", "state", "parent")
.prefetch_related("assignees", "labels")
.prefetch_related(
Prefetch(
"issue_reactions",
queryset=IssueReaction.objects.select_related("actor"),
)
)
.order_by("-created_at")
.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")
)
).distinct()
# 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 = IssueLiteSerializer(issue_queryset, many=True).data
## Grouping the results
group_by = request.GET.get("group_by", False)
if group_by:
return Response(
group_results(issues, group_by), status=status.HTTP_200_OK
)
return Response(issues, status=status.HTTP_200_OK)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class WorkspaceLabelsEndpoint(BaseAPIView):
permission_classes = [
WorkspaceViewerPermission,
]
def get(self, request, slug):
try:
labels = Label.objects.filter(
workspace__slug=slug,
project__project_projectmember__member=request.user,
).values("parent", "name", "color", "id", "project_id", "workspace__slug")
return Response(labels, status=status.HTTP_200_OK)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class WorkspaceMembersEndpoint(BaseAPIView):
permission_classes = [
WorkspaceEntityPermission,
]
def get(self, request, slug):
try:
workspace_members = WorkspaceMember.objects.filter(
workspace__slug=slug,
member__is_bot=False,
).select_related("workspace", "member")
serialzier = WorkSpaceMemberSerializer(workspace_members, many=True)
return Response(serialzier.data, status=status.HTTP_200_OK)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
+11 -11
View File
@@ -21,7 +21,7 @@ row_mapping = {
"state__name": "State",
"state__group": "State Group",
"labels__name": "Label",
"assignees__display_name": "Assignee Name",
"assignees__email": "Assignee Name",
"start_date": "Start Date",
"target_date": "Due Date",
"completed_at": "Completed At",
@@ -51,12 +51,12 @@ def analytic_export_task(email, data, slug):
segmented = segment
assignee_details = {}
if x_axis in ["assignees__id"] or segment in ["assignees__id"]:
if x_axis in ["assignees__email"] or segment in ["assignees__email"]:
assignee_details = (
Issue.issue_objects.filter(workspace__slug=slug, **filters, assignees__avatar__isnull=False)
.order_by("assignees__id")
.distinct("assignees__id")
.values("assignees__avatar", "assignees__display_name", "assignees__first_name", "assignees__last_name", "assignees__id")
.values("assignees__avatar", "assignees__email", "assignees__first_name", "assignees__last_name")
)
if segment:
@@ -93,19 +93,19 @@ def analytic_export_task(email, data, slug):
else:
generated_row.append("0")
# x-axis replacement for names
if x_axis in ["assignees__id"]:
assignee = [user for user in assignee_details if str(user.get("assignees__id")) == str(item)]
if x_axis in ["assignees__email"]:
assignee = [user for user in assignee_details if str(user.get("assignees__email")) == str(item)]
if len(assignee):
generated_row[0] = str(assignee[0].get("assignees__first_name")) + " " + str(assignee[0].get("assignees__last_name"))
rows.append(tuple(generated_row))
# If segment is ["assignees__display_name"] then replace segment_zero rows with first and last names
if segmented in ["assignees__id"]:
# If segment is ["assignees__email"] then replace segment_zero rows with first and last names
if segmented in ["assignees__email"]:
for index, segm in enumerate(row_zero[2:]):
# find the name of the user
assignee = [user for user in assignee_details if str(user.get("assignees__id")) == str(segm)]
assignee = [user for user in assignee_details if str(user.get("assignees__email")) == str(segm)]
if len(assignee):
row_zero[index + 2] = str(assignee[0].get("assignees__first_name")) + " " + str(assignee[0].get("assignees__last_name"))
row_zero[index] = str(assignee[0].get("assignees__first_name")) + " " + str(assignee[0].get("assignees__last_name"))
rows = [tuple(row_zero)] + rows
csv_buffer = io.StringIO()
@@ -141,8 +141,8 @@ def analytic_export_task(email, data, slug):
else distribution.get(item)[0].get("estimate "),
]
# x-axis replacement to names
if x_axis in ["assignees__id"]:
assignee = [user for user in assignee_details if str(user.get("assignees__id")) == str(item)]
if x_axis in ["assignees__email"]:
assignee = [user for user in assignee_details if str(user.get("assignees__email")) == str(item)]
if len(assignee):
row[0] = str(assignee[0].get("assignees__first_name")) + " " + str(assignee[0].get("assignees__last_name"))
-346
View File
@@ -1,346 +0,0 @@
# Python imports
import csv
import io
import json
import boto3
import zipfile
# Django imports
from django.conf import settings
from django.utils import timezone
# Third party imports
from celery import shared_task
from sentry_sdk import capture_exception
from botocore.client import Config
from openpyxl import Workbook
# Module imports
from plane.db.models import Issue, ExporterHistory
def dateTimeConverter(time):
if time:
return time.strftime("%a, %d %b %Y %I:%M:%S %Z%z")
def dateConverter(time):
if time:
return time.strftime("%a, %d %b %Y")
def create_csv_file(data):
csv_buffer = io.StringIO()
csv_writer = csv.writer(csv_buffer, delimiter=",", quoting=csv.QUOTE_ALL)
for row in data:
csv_writer.writerow(row)
csv_buffer.seek(0)
return csv_buffer.getvalue()
def create_json_file(data):
return json.dumps(data)
def create_xlsx_file(data):
workbook = Workbook()
sheet = workbook.active
for row in data:
sheet.append(row)
xlsx_buffer = io.BytesIO()
workbook.save(xlsx_buffer)
xlsx_buffer.seek(0)
return xlsx_buffer.getvalue()
def create_zip_file(files):
zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zipf:
for filename, file_content in files:
zipf.writestr(filename, file_content)
zip_buffer.seek(0)
return zip_buffer
def upload_to_s3(zip_file, workspace_id, token_id, slug):
s3 = boto3.client(
"s3",
region_name=settings.AWS_REGION,
aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
config=Config(signature_version="s3v4"),
)
file_name = f"{workspace_id}/export-{slug}-{token_id[:6]}-{timezone.now()}.zip"
s3.upload_fileobj(
zip_file,
settings.AWS_S3_BUCKET_NAME,
file_name,
ExtraArgs={"ACL": "public-read", "ContentType": "application/zip"},
)
expires_in = 7 * 24 * 60 * 60
presigned_url = s3.generate_presigned_url(
"get_object",
Params={"Bucket": settings.AWS_S3_BUCKET_NAME, "Key": file_name},
ExpiresIn=expires_in,
)
exporter_instance = ExporterHistory.objects.get(token=token_id)
if presigned_url:
exporter_instance.url = presigned_url
exporter_instance.status = "completed"
exporter_instance.key = file_name
else:
exporter_instance.status = "failed"
exporter_instance.save(update_fields=["status", "url","key"])
def generate_table_row(issue):
return [
f"""{issue["project__identifier"]}-{issue["sequence_id"]}""",
issue["project__name"],
issue["name"],
issue["description_stripped"],
issue["state__name"],
issue["priority"],
f"{issue['created_by__first_name']} {issue['created_by__last_name']}"
if issue["created_by__first_name"] and issue["created_by__last_name"]
else "",
f"{issue['assignees__first_name']} {issue['assignees__last_name']}"
if issue["assignees__first_name"] and issue["assignees__last_name"]
else "",
issue["labels__name"],
issue["issue_cycle__cycle__name"],
dateConverter(issue["issue_cycle__cycle__start_date"]),
dateConverter(issue["issue_cycle__cycle__end_date"]),
issue["issue_module__module__name"],
dateConverter(issue["issue_module__module__start_date"]),
dateConverter(issue["issue_module__module__target_date"]),
dateTimeConverter(issue["created_at"]),
dateTimeConverter(issue["updated_at"]),
dateTimeConverter(issue["completed_at"]),
dateTimeConverter(issue["archived_at"]),
]
def generate_json_row(issue):
return {
"ID": f"""{issue["project__identifier"]}-{issue["sequence_id"]}""",
"Project": issue["project__name"],
"Name": issue["name"],
"Description": issue["description_stripped"],
"State": issue["state__name"],
"Priority": issue["priority"],
"Created By": f"{issue['created_by__first_name']} {issue['created_by__last_name']}"
if issue["created_by__first_name"] and issue["created_by__last_name"]
else "",
"Assignee": f"{issue['assignees__first_name']} {issue['assignees__last_name']}"
if issue["assignees__first_name"] and issue["assignees__last_name"]
else "",
"Labels": issue["labels__name"],
"Cycle Name": issue["issue_cycle__cycle__name"],
"Cycle Start Date": dateConverter(issue["issue_cycle__cycle__start_date"]),
"Cycle End Date": dateConverter(issue["issue_cycle__cycle__end_date"]),
"Module Name": issue["issue_module__module__name"],
"Module Start Date": dateConverter(issue["issue_module__module__start_date"]),
"Module Target Date": dateConverter(issue["issue_module__module__target_date"]),
"Created At": dateTimeConverter(issue["created_at"]),
"Updated At": dateTimeConverter(issue["updated_at"]),
"Completed At": dateTimeConverter(issue["completed_at"]),
"Archived At": dateTimeConverter(issue["archived_at"]),
}
def update_json_row(rows, row):
matched_index = next(
(
index
for index, existing_row in enumerate(rows)
if existing_row["ID"] == row["ID"]
),
None,
)
if matched_index is not None:
existing_assignees, existing_labels = (
rows[matched_index]["Assignee"],
rows[matched_index]["Labels"],
)
assignee, label = row["Assignee"], row["Labels"]
if assignee is not None and assignee not in existing_assignees:
rows[matched_index]["Assignee"] += f", {assignee}"
if label is not None and label not in existing_labels:
rows[matched_index]["Labels"] += f", {label}"
else:
rows.append(row)
def update_table_row(rows, row):
matched_index = next(
(index for index, existing_row in enumerate(rows) if existing_row[0] == row[0]),
None,
)
if matched_index is not None:
existing_assignees, existing_labels = rows[matched_index][7:9]
assignee, label = row[7:9]
if assignee is not None and assignee not in existing_assignees:
rows[matched_index][7] += f", {assignee}"
if label is not None and label not in existing_labels:
rows[matched_index][8] += f", {label}"
else:
rows.append(row)
def generate_csv(header, project_id, issues, files):
"""
Generate CSV export for all the passed issues.
"""
rows = [
header,
]
for issue in issues:
row = generate_table_row(issue)
update_table_row(rows, row)
csv_file = create_csv_file(rows)
files.append((f"{project_id}.csv", csv_file))
def generate_json(header, project_id, issues, files):
rows = []
for issue in issues:
row = generate_json_row(issue)
update_json_row(rows, row)
json_file = create_json_file(rows)
files.append((f"{project_id}.json", json_file))
def generate_xlsx(header, project_id, issues, files):
rows = [header]
for issue in issues:
row = generate_table_row(issue)
update_table_row(rows, row)
xlsx_file = create_xlsx_file(rows)
files.append((f"{project_id}.xlsx", xlsx_file))
@shared_task
def issue_export_task(provider, workspace_id, project_ids, token_id, multiple, slug):
try:
exporter_instance = ExporterHistory.objects.get(token=token_id)
exporter_instance.status = "processing"
exporter_instance.save(update_fields=["status"])
workspace_issues = (
(
Issue.objects.filter(
workspace__id=workspace_id, project_id__in=project_ids
)
.select_related("project", "workspace", "state", "parent", "created_by")
.prefetch_related(
"assignees", "labels", "issue_cycle__cycle", "issue_module__module"
)
.values(
"id",
"project__identifier",
"project__name",
"project__id",
"sequence_id",
"name",
"description_stripped",
"priority",
"state__name",
"created_at",
"updated_at",
"completed_at",
"archived_at",
"issue_cycle__cycle__name",
"issue_cycle__cycle__start_date",
"issue_cycle__cycle__end_date",
"issue_module__module__name",
"issue_module__module__start_date",
"issue_module__module__target_date",
"created_by__first_name",
"created_by__last_name",
"assignees__first_name",
"assignees__last_name",
"labels__name",
)
)
.order_by("project__identifier","sequence_id")
.distinct()
)
# CSV header
header = [
"ID",
"Project",
"Name",
"Description",
"State",
"Priority",
"Created By",
"Assignee",
"Labels",
"Cycle Name",
"Cycle Start Date",
"Cycle End Date",
"Module Name",
"Module Start Date",
"Module Target Date",
"Created At",
"Updated At",
"Completed At",
"Archived At",
]
EXPORTER_MAPPER = {
"csv": generate_csv,
"json": generate_json,
"xlsx": generate_xlsx,
}
files = []
if multiple:
for project_id in project_ids:
issues = workspace_issues.filter(project__id=project_id)
exporter = EXPORTER_MAPPER.get(provider)
if exporter is not None:
exporter(
header,
project_id,
issues,
files,
)
else:
exporter = EXPORTER_MAPPER.get(provider)
if exporter is not None:
exporter(
header,
workspace_id,
workspace_issues,
files,
)
zip_buffer = create_zip_file(files)
upload_to_s3(zip_buffer, workspace_id, token_id, slug)
except Exception as e:
exporter_instance = ExporterHistory.objects.get(token=token_id)
exporter_instance.status = "failed"
exporter_instance.reason = str(e)
exporter_instance.save(update_fields=["status", "reason"])
# Print logs if in DEBUG mode
if settings.DEBUG:
print(e)
capture_exception(e)
return
@@ -1,38 +0,0 @@
# Python imports
import boto3
from datetime import timedelta
# Django imports
from django.conf import settings
from django.utils import timezone
from django.db.models import Q
# Third party imports
from celery import shared_task
from botocore.client import Config
# Module imports
from plane.db.models import ExporterHistory
@shared_task
def delete_old_s3_link():
# Get a list of keys and IDs to process
expired_exporter_history = ExporterHistory.objects.filter(
Q(url__isnull=False) & Q(created_at__lte=timezone.now() - timedelta(days=8))
).values_list("key", "id")
s3 = boto3.client(
"s3",
region_name="ap-south-1",
aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
config=Config(signature_version="s3v4"),
)
for file_name, exporter_id in expired_exporter_history:
# Delete object from S3
if file_name:
s3.delete_object(Bucket=settings.AWS_S3_BUCKET_NAME, Key=file_name)
ExporterHistory.objects.filter(id=exporter_id).update(url=None)
+130 -162
View File
@@ -48,7 +48,7 @@ def track_name(
field="name",
project=project,
workspace=project.workspace,
comment=f"updated the name to {requested_data.get('name')}",
comment=f"{actor.email} updated the name to {requested_data.get('name')}",
)
)
@@ -70,12 +70,12 @@ def track_parent(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=f"{old_parent.project.identifier}-{old_parent.sequence_id}",
old_value=f"{project.identifier}-{old_parent.sequence_id}",
new_value=None,
field="parent",
project=project,
workspace=project.workspace,
comment=f"updated the parent issue to None",
comment=f"{actor.email} updated the parent issue to None",
old_identifier=old_parent.id,
new_identifier=None,
)
@@ -88,14 +88,14 @@ def track_parent(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=f"{old_parent.project.identifier}-{old_parent.sequence_id}"
old_value=f"{project.identifier}-{old_parent.sequence_id}"
if old_parent is not None
else None,
new_value=f"{new_parent.project.identifier}-{new_parent.sequence_id}",
new_value=f"{project.identifier}-{new_parent.sequence_id}",
field="parent",
project=project,
workspace=project.workspace,
comment=f"updated the parent issue to {new_parent.name}",
comment=f"{actor.email} updated the parent issue to {new_parent.name}",
old_identifier=old_parent.id if old_parent is not None else None,
new_identifier=new_parent.id,
)
@@ -123,7 +123,7 @@ def track_priority(
field="priority",
project=project,
workspace=project.workspace,
comment=f"updated the priority to None",
comment=f"{actor.email} updated the priority to None",
)
)
else:
@@ -137,7 +137,7 @@ def track_priority(
field="priority",
project=project,
workspace=project.workspace,
comment=f"updated the priority to {requested_data.get('priority')}",
comment=f"{actor.email} updated the priority to {requested_data.get('priority')}",
)
)
@@ -165,7 +165,7 @@ def track_state(
field="state",
project=project,
workspace=project.workspace,
comment=f"updated the state to {new_state.name}",
comment=f"{actor.email} updated the state to {new_state.name}",
old_identifier=old_state.id,
new_identifier=new_state.id,
)
@@ -184,24 +184,19 @@ def track_description(
if current_instance.get("description_html") != requested_data.get(
"description_html"
):
last_activity = IssueActivity.objects.filter(issue_id=issue_id).order_by("-created_at").first()
if(last_activity is not None and last_activity.field == "description" and actor.id == last_activity.actor_id):
last_activity.created_at = timezone.now()
last_activity.save(update_fields=["created_at"])
else:
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=current_instance.get("description_html"),
new_value=requested_data.get("description_html"),
field="description",
project=project,
workspace=project.workspace,
comment=f"updated the description to {requested_data.get('description_html')}",
)
)
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=current_instance.get("description_html"),
new_value=requested_data.get("description_html"),
field="description",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated the description to {requested_data.get('description_html')}",
)
)
# Track changes in issue target date
@@ -225,7 +220,7 @@ def track_target_date(
field="target_date",
project=project,
workspace=project.workspace,
comment=f"updated the target date to None",
comment=f"{actor.email} updated the target date to None",
)
)
else:
@@ -239,7 +234,7 @@ def track_target_date(
field="target_date",
project=project,
workspace=project.workspace,
comment=f"updated the target date to {requested_data.get('target_date')}",
comment=f"{actor.email} updated the target date to {requested_data.get('target_date')}",
)
)
@@ -265,7 +260,7 @@ def track_start_date(
field="start_date",
project=project,
workspace=project.workspace,
comment=f"updated the start date to None",
comment=f"{actor.email} updated the start date to None",
)
)
else:
@@ -279,7 +274,7 @@ def track_start_date(
field="start_date",
project=project,
workspace=project.workspace,
comment=f"updated the start date to {requested_data.get('start_date')}",
comment=f"{actor.email} updated the start date to {requested_data.get('start_date')}",
)
)
@@ -308,7 +303,7 @@ def track_labels(
field="labels",
project=project,
workspace=project.workspace,
comment=f"added label {label.name}",
comment=f"{actor.email} added label {label.name}",
new_identifier=label.id,
old_identifier=None,
)
@@ -329,7 +324,7 @@ def track_labels(
field="labels",
project=project,
workspace=project.workspace,
comment=f"removed label {label.name}",
comment=f"{actor.email} removed label {label.name}",
old_identifier=label.id,
new_identifier=None,
)
@@ -358,12 +353,12 @@ def track_assignees(
actor=actor,
verb="updated",
old_value="",
new_value=assignee.display_name,
new_value=assignee.email,
field="assignees",
project=project,
workspace=project.workspace,
comment=f"added assignee {assignee.display_name}",
new_identifier=assignee.id,
comment=f"{actor.email} added assignee {assignee.email}",
new_identifier=actor.id,
)
)
@@ -379,13 +374,13 @@ def track_assignees(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=assignee.display_name,
old_value=assignee.email,
new_value="",
field="assignees",
field="assignee",
project=project,
workspace=project.workspace,
comment=f"removed assignee {assignee.display_name}",
old_identifier=assignee.id,
comment=f"{actor.email} removed assignee {assignee.email}",
old_identifier=actor.id,
)
)
@@ -420,11 +415,11 @@ def track_blocks(
actor=actor,
verb="updated",
old_value="",
new_value=f"{issue.project.identifier}-{issue.sequence_id}",
new_value=f"{project.identifier}-{issue.sequence_id}",
field="blocks",
project=project,
workspace=project.workspace,
comment=f"added blocking issue {project.identifier}-{issue.sequence_id}",
comment=f"{actor.email} added blocking issue {project.identifier}-{issue.sequence_id}",
new_identifier=issue.id,
)
)
@@ -441,12 +436,12 @@ def track_blocks(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=f"{issue.project.identifier}-{issue.sequence_id}",
old_value=f"{project.identifier}-{issue.sequence_id}",
new_value="",
field="blocks",
project=project,
workspace=project.workspace,
comment=f"removed blocking issue {project.identifier}-{issue.sequence_id}",
comment=f"{actor.email} removed blocking issue {project.identifier}-{issue.sequence_id}",
old_identifier=issue.id,
)
)
@@ -482,11 +477,11 @@ def track_blockings(
actor=actor,
verb="updated",
old_value="",
new_value=f"{issue.project.identifier}-{issue.sequence_id}",
new_value=f"{project.identifier}-{issue.sequence_id}",
field="blocking",
project=project,
workspace=project.workspace,
comment=f"added blocked by issue {project.identifier}-{issue.sequence_id}",
comment=f"{actor.email} added blocked by issue {project.identifier}-{issue.sequence_id}",
new_identifier=issue.id,
)
)
@@ -503,12 +498,12 @@ def track_blockings(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=f"{issue.project.identifier}-{issue.sequence_id}",
old_value=f"{project.identifier}-{issue.sequence_id}",
new_value="",
field="blocking",
project=project,
workspace=project.workspace,
comment=f"removed blocked by issue {project.identifier}-{issue.sequence_id}",
comment=f"{actor.email} removed blocked by issue {project.identifier}-{issue.sequence_id}",
old_identifier=issue.id,
)
)
@@ -522,7 +517,7 @@ def create_issue_activity(
issue_id=issue_id,
project=project,
workspace=project.workspace,
comment=f"created the issue",
comment=f"{actor.email} created the issue",
verb="created",
actor=actor,
)
@@ -544,7 +539,7 @@ def track_estimate_points(
field="estimate_point",
project=project,
workspace=project.workspace,
comment=f"updated the estimate point to None",
comment=f"{actor.email} updated the estimate point to None",
)
)
else:
@@ -558,7 +553,7 @@ def track_estimate_points(
field="estimate_point",
project=project,
workspace=project.workspace,
comment=f"updated the estimate point to {requested_data.get('estimate_point')}",
comment=f"{actor.email} updated the estimate point to {requested_data.get('estimate_point')}",
)
)
@@ -572,7 +567,7 @@ def track_archive_at(
issue_id=issue_id,
project=project,
workspace=project.workspace,
comment=f"has restored the issue",
comment=f"{actor.email} has restored the issue",
verb="updated",
actor=actor,
field="archived_at",
@@ -666,7 +661,7 @@ def delete_issue_activity(
IssueActivity(
project=project,
workspace=project.workspace,
comment=f"deleted the issue",
comment=f"{actor.email} deleted the issue",
verb="deleted",
actor=actor,
field="issue",
@@ -687,7 +682,7 @@ def create_comment_activity(
issue_id=issue_id,
project=project,
workspace=project.workspace,
comment=f"created a comment",
comment=f"{actor.email} created a comment",
verb="created",
actor=actor,
field="comment",
@@ -712,7 +707,7 @@ def update_comment_activity(
issue_id=issue_id,
project=project,
workspace=project.workspace,
comment=f"updated a comment",
comment=f"{actor.email} updated a comment",
verb="updated",
actor=actor,
field="comment",
@@ -733,7 +728,7 @@ def delete_comment_activity(
issue_id=issue_id,
project=project,
workspace=project.workspace,
comment=f"deleted the comment",
comment=f"{actor.email} deleted the comment",
verb="deleted",
actor=actor,
field="comment",
@@ -771,7 +766,7 @@ def create_cycle_issue_activity(
field="cycles",
project=project,
workspace=project.workspace,
comment=f"updated cycle from {old_cycle.name} to {new_cycle.name}",
comment=f"{actor.email} updated cycle from {old_cycle.name} to {new_cycle.name}",
old_identifier=old_cycle.id,
new_identifier=new_cycle.id,
)
@@ -792,7 +787,7 @@ def create_cycle_issue_activity(
field="cycles",
project=project,
workspace=project.workspace,
comment=f"added cycle {cycle.name}",
comment=f"{actor.email} added cycle {cycle.name}",
new_identifier=cycle.id,
)
)
@@ -821,7 +816,7 @@ def delete_cycle_issue_activity(
field="cycles",
project=project,
workspace=project.workspace,
comment=f"removed this issue from {cycle.name if cycle is not None else None}",
comment=f"{actor.email} removed this issue from {cycle.name if cycle is not None else None}",
old_identifier=cycle.id if cycle is not None else None,
)
)
@@ -857,7 +852,7 @@ def create_module_issue_activity(
field="modules",
project=project,
workspace=project.workspace,
comment=f"updated module from {old_module.name} to {new_module.name}",
comment=f"{actor.email} updated module from {old_module.name} to {new_module.name}",
old_identifier=old_module.id,
new_identifier=new_module.id,
)
@@ -877,7 +872,7 @@ def create_module_issue_activity(
field="modules",
project=project,
workspace=project.workspace,
comment=f"added module {module.name}",
comment=f"{actor.email} added module {module.name}",
new_identifier=module.id,
)
)
@@ -906,7 +901,7 @@ def delete_module_issue_activity(
field="modules",
project=project,
workspace=project.workspace,
comment=f"removed this issue from {module.name if module is not None else None}",
comment=f"{actor.email} removed this issue from {module.name if module is not None else None}",
old_identifier=module.id if module is not None else None,
)
)
@@ -925,7 +920,7 @@ def create_link_activity(
issue_id=issue_id,
project=project,
workspace=project.workspace,
comment=f"created a link",
comment=f"{actor.email} created a link",
verb="created",
actor=actor,
field="link",
@@ -949,7 +944,7 @@ def update_link_activity(
issue_id=issue_id,
project=project,
workspace=project.workspace,
comment=f"updated a link",
comment=f"{actor.email} updated a link",
verb="updated",
actor=actor,
field="link",
@@ -964,22 +959,15 @@ def update_link_activity(
def delete_link_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
):
current_instance = (
json.loads(current_instance) if current_instance is not None else None
)
issue_activities.append(
IssueActivity(
issue_id=issue_id,
project=project,
workspace=project.workspace,
comment=f"deleted the link",
comment=f"{actor.email} deleted the link",
verb="deleted",
actor=actor,
field="link",
old_value=current_instance.get("url", ""),
new_value=""
)
)
@@ -997,11 +985,11 @@ def create_attachment_activity(
issue_id=issue_id,
project=project,
workspace=project.workspace,
comment=f"created an attachment",
comment=f"{actor.email} created an attachment",
verb="created",
actor=actor,
field="attachment",
new_value=current_instance.get("asset", ""),
new_value=current_instance.get("access", ""),
new_identifier=current_instance.get("id", None),
)
)
@@ -1015,7 +1003,7 @@ def delete_attachment_activity(
issue_id=issue_id,
project=project,
workspace=project.workspace,
comment=f"deleted the attachment",
comment=f"{actor.email} deleted the attachment",
verb="deleted",
actor=actor,
field="attachment",
@@ -1040,29 +1028,23 @@ def issue_activity(
actor = User.objects.get(pk=actor_id)
project = Project.objects.get(pk=project_id)
if type not in [
"cycle.activity.created",
"cycle.activity.deleted",
"module.activity.created",
"module.activity.deleted",
]:
issue = Issue.objects.filter(pk=issue_id).first()
if issue is not None:
try:
issue.updated_at = timezone.now()
issue.save(update_fields=["updated_at"])
except Exception as e:
pass
try:
issue = Issue.objects.get(pk=issue_id)
issue.updated_at = timezone.now()
issue.save(update_fields=["updated_at"])
except Exception as e:
pass
if subscriber:
# add the user to issue subscriber
try:
_ = IssueSubscriber.objects.get_or_create(
issue_id=issue_id, subscriber=actor
)
except Exception as e:
pass
if subscriber:
# add the user to issue subscriber
try:
_ = IssueSubscriber.objects.get_or_create(
issue_id=issue_id, subscriber=actor
)
except Exception as e:
pass
ACTIVITY_MAPPER = {
"issue.activity.created": create_issue_activity,
@@ -1114,80 +1096,66 @@ def issue_activity(
except Exception as e:
capture_exception(e)
if type not in [
"cycle.activity.created",
"cycle.activity.deleted",
"module.activity.created",
"module.activity.deleted",
]:
# Create Notifications
bulk_notifications = []
# Create Notifications
bulk_notifications = []
issue_subscribers = list(
IssueSubscriber.objects.filter(project=project, issue_id=issue_id)
.exclude(subscriber_id=actor_id)
.values_list("subscriber", flat=True)
)
issue_subscribers = list(
IssueSubscriber.objects.filter(project=project, issue_id=issue_id)
.exclude(subscriber_id=actor_id)
.values_list("subscriber", flat=True)
)
issue_assignees = list(
IssueAssignee.objects.filter(project=project, issue_id=issue_id)
.exclude(assignee_id=actor_id)
.values_list("assignee", flat=True)
)
issue_assignees = list(
IssueAssignee.objects.filter(project=project, issue_id=issue_id)
.exclude(assignee_id=actor_id)
.values_list("assignee", flat=True)
)
issue_subscribers = issue_subscribers + issue_assignees
issue_subscribers = issue_subscribers + issue_assignees
issue = Issue.objects.filter(pk=issue_id).first()
# Add bot filtering
if issue.created_by_id is not None and not issue.created_by.is_bot:
issue_subscribers = issue_subscribers + [issue.created_by_id]
# Add bot filtering
if (
issue is not None
and issue.created_by_id is not None
and not issue.created_by.is_bot
and str(issue.created_by_id) != str(actor_id)
):
issue_subscribers = issue_subscribers + [issue.created_by_id]
for subscriber in issue_subscribers:
for issue_activity in issue_activities_created:
bulk_notifications.append(
Notification(
workspace=project.workspace,
sender="in_app:issue_activities",
triggered_by_id=actor_id,
receiver_id=subscriber,
entity_identifier=issue_id,
entity_name="issue",
project=project,
title=issue_activity.comment,
data={
"issue": {
"id": str(issue_id),
"name": str(issue.name),
"identifier": str(issue.project.identifier),
"sequence_id": issue.sequence_id,
"state_name": issue.state.name,
"state_group": issue.state.group,
},
"issue_activity": {
"id": str(issue_activity.id),
"verb": str(issue_activity.verb),
"field": str(issue_activity.field),
"actor": str(issue_activity.actor_id),
"new_value": str(issue_activity.new_value),
"old_value": str(issue_activity.old_value),
"issue_comment": str(
issue_activity.issue_comment.comment_stripped
if issue_activity.issue_comment is not None
else ""
),
},
issue = Issue.objects.get(project=project, pk=issue_id)
for subscriber in issue_subscribers:
for issue_activity in issue_activities_created:
bulk_notifications.append(
Notification(
workspace=project.workspace,
sender="in_app:issue_activities",
triggered_by_id=actor_id,
receiver_id=subscriber,
entity_identifier=issue_id,
entity_name="issue",
project=project,
title=issue_activity.comment,
data={
"issue": {
"id": str(issue_id),
"name": str(issue.name),
"identifier": str(project.identifier),
"sequence_id": issue.sequence_id,
"state_name": issue.state.name,
"state_group": issue.state.group,
},
)
"issue_activity": {
"id": str(issue_activity.id),
"verb": str(issue_activity.verb),
"field": str(issue_activity.field),
"actor": str(issue_activity.actor_id),
"new_value": str(issue_activity.new_value),
"old_value": str(issue_activity.old_value),
"issue_comment": str(
issue_activity.issue_comment.comment_stripped if issue_activity.issue_comment is not None else ""
),
},
},
)
)
# Bulk create notifications
Notification.objects.bulk_create(bulk_notifications, batch_size=100)
# Bulk create notifications
Notification.objects.bulk_create(bulk_notifications, batch_size=100)
return
except Exception as e:
@@ -49,11 +49,6 @@ def archive_old_issues():
Q(issue_module__module__target_date__lt=timezone.now().date())
& Q(issue_module__isnull=False)
),
).filter(
Q(issue_inbox__status=1)
| Q(issue_inbox__status=-1)
| Q(issue_inbox__status=2)
| Q(issue_inbox__isnull=True)
)
# Check if Issues
@@ -70,7 +65,7 @@ def archive_old_issues():
[
issue_activity.delay(
type="issue.activity.updated",
requested_data=json.dumps({"archived_at": str(issue.archived_at)}),
requested_data=json.dumps({"archived_at": issue.archived_at}),
actor_id=str(project.created_by_id),
issue_id=issue.id,
project_id=project_id,
@@ -116,11 +111,6 @@ def close_old_issues():
Q(issue_module__module__target_date__lt=timezone.now().date())
& Q(issue_module__isnull=False)
),
).filter(
Q(issue_inbox__status=1)
| Q(issue_inbox__status=-1)
| Q(issue_inbox__status=2)
| Q(issue_inbox__isnull=True)
)
# Check if Issues
@@ -140,7 +130,7 @@ def close_old_issues():
[
issue_activity.delay(
type="issue.activity.updated",
requested_data=json.dumps({"closed_to": str(issue.state_id)}),
requested_data=json.dumps({"closed_to": issue.state_id}),
actor_id=str(project.created_by_id),
issue_id=issue.id,
project_id=project_id,
-4
View File
@@ -20,10 +20,6 @@ app.conf.beat_schedule = {
"task": "plane.bgtasks.issue_automation_task.archive_and_close_old_issues",
"schedule": crontab(hour=0, minute=0),
},
"check-every-day-to-delete_exporter_history": {
"task": "plane.bgtasks.exporter_expired_task.delete_old_s3_link",
"schedule": crontab(hour=0, minute=0),
},
}
# Load task modules from all registered Django app configs.
@@ -1,266 +0,0 @@
# Generated by Django 4.2.3 on 2023-07-19 06:52
from django.conf import settings
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
import plane.db.models.user
import uuid
def onboarding_default_steps(apps, schema_editor):
default_onboarding_schema = {
"workspace_join": True,
"profile_complete": True,
"workspace_create": True,
"workspace_invite": True,
}
Model = apps.get_model("db", "User")
updated_user = []
for obj in Model.objects.filter(is_onboarded=True):
obj.onboarding_step = default_onboarding_schema
obj.is_tour_completed = True
updated_user.append(obj)
Model.objects.bulk_update(updated_user, ["onboarding_step", "is_tour_completed"], batch_size=100)
class Migration(migrations.Migration):
dependencies = [
("db", "0036_alter_workspace_organization_size"),
]
operations = [
migrations.AddField(
model_name="issue",
name="archived_at",
field=models.DateField(null=True),
),
migrations.AddField(
model_name="project",
name="archive_in",
field=models.IntegerField(
default=0,
validators=[
django.core.validators.MinValueValidator(0),
django.core.validators.MaxValueValidator(12),
],
),
),
migrations.AddField(
model_name="project",
name="close_in",
field=models.IntegerField(
default=0,
validators=[
django.core.validators.MinValueValidator(0),
django.core.validators.MaxValueValidator(12),
],
),
),
migrations.AddField(
model_name="project",
name="default_state",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="default_state",
to="db.state",
),
),
migrations.AddField(
model_name="user",
name="is_tour_completed",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="user",
name="onboarding_step",
field=models.JSONField(default=plane.db.models.user.get_default_onboarding),
),
migrations.RunPython(onboarding_default_steps),
migrations.CreateModel(
name="Notification",
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,
),
),
("data", models.JSONField(null=True)),
("entity_identifier", models.UUIDField(null=True)),
("entity_name", models.CharField(max_length=255)),
("title", models.TextField()),
("message", models.JSONField(null=True)),
("message_html", models.TextField(blank=True, default="<p></p>")),
("message_stripped", models.TextField(blank=True, null=True)),
("sender", models.CharField(max_length=255)),
("read_at", models.DateTimeField(null=True)),
("snoozed_till", models.DateTimeField(null=True)),
("archived_at", models.DateTimeField(null=True)),
(
"created_by",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="%(class)s_created_by",
to=settings.AUTH_USER_MODEL,
verbose_name="Created By",
),
),
(
"project",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="notifications",
to="db.project",
),
),
(
"receiver",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="received_notifications",
to=settings.AUTH_USER_MODEL,
),
),
(
"triggered_by",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="triggered_notifications",
to=settings.AUTH_USER_MODEL,
),
),
(
"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="notifications",
to="db.workspace",
),
),
],
options={
"verbose_name": "Notification",
"verbose_name_plural": "Notifications",
"db_table": "notifications",
"ordering": ("-created_at",),
},
),
migrations.CreateModel(
name="IssueSubscriber",
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,
),
),
(
"created_by",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="%(class)s_created_by",
to=settings.AUTH_USER_MODEL,
verbose_name="Created By",
),
),
(
"issue",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="issue_subscribers",
to="db.issue",
),
),
(
"project",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="project_%(class)s",
to="db.project",
),
),
(
"subscriber",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="issue_subscribers",
to=settings.AUTH_USER_MODEL,
),
),
(
"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 Subscriber",
"verbose_name_plural": "Issue Subscribers",
"db_table": "issue_subscribers",
"ordering": ("-created_at",),
"unique_together": {("issue", "subscriber")},
},
),
]
@@ -1,35 +0,0 @@
# Generated by Django 4.2.3 on 2023-07-20 09:35
from django.db import migrations, models
def restructure_theming(apps, schema_editor):
Model = apps.get_model("db", "User")
updated_user = []
for obj in Model.objects.exclude(theme={}).all():
current_theme = obj.theme
updated_theme = {
"primary": current_theme.get("accent", ""),
"background": current_theme.get("bgBase", ""),
"sidebarBackground": current_theme.get("sidebar", ""),
"text": current_theme.get("textBase", ""),
"sidebarText": current_theme.get("textBase", ""),
"palette": f"""{current_theme.get("bgBase","")},{current_theme.get("textBase", "")},{current_theme.get("accent", "")},{current_theme.get("sidebar","")},{current_theme.get("textBase", "")}""",
"darkPalette": current_theme.get("darkPalette", "")
}
obj.theme = updated_theme
updated_user.append(obj)
Model.objects.bulk_update(
updated_user, ["theme"], batch_size=100
)
class Migration(migrations.Migration):
dependencies = [
("db", "0037_issue_archived_at_project_archive_in_and_more"),
]
operations = [
migrations.RunPython(restructure_theming)
]
@@ -1,97 +0,0 @@
# Generated by Django 4.2.3 on 2023-07-23 16:33
import random
from django.db import migrations, models
import plane.db.models.workspace
def rename_field(apps, schema_editor):
Model = apps.get_model("db", "IssueActivity")
updated_activity = []
for obj in Model.objects.filter(field="assignee"):
obj.field = "assignees"
updated_activity.append(obj)
Model.objects.bulk_update(updated_activity, ["field"], batch_size=100)
def update_workspace_member_props(apps, schema_editor):
Model = apps.get_model("db", "WorkspaceMember")
updated_workspace_member = []
for obj in Model.objects.all():
if obj.view_props is None:
obj.view_props = {
"filters": {"type": None},
"groupByProperty": None,
"issueView": "list",
"orderBy": "-created_at",
"properties": {
"assignee": True,
"due_date": True,
"key": True,
"labels": True,
"priority": True,
"state": True,
"sub_issue_count": True,
"attachment_count": True,
"link": True,
"estimate": True,
"created_on": True,
"updated_on": True,
},
"showEmptyGroups": True,
}
else:
current_view_props = obj.view_props
obj.view_props = {
"filters": {"type": None},
"groupByProperty": None,
"issueView": "list",
"orderBy": "-created_at",
"showEmptyGroups": True,
"properties": current_view_props,
}
updated_workspace_member.append(obj)
Model.objects.bulk_update(updated_workspace_member, ["view_props"], batch_size=100)
def update_project_member_sort_order(apps, schema_editor):
Model = apps.get_model("db", "ProjectMember")
updated_project_members = []
for obj in Model.objects.all():
obj.sort_order = random.randint(1, 65536)
updated_project_members.append(obj)
Model.objects.bulk_update(updated_project_members, ["sort_order"], batch_size=100)
class Migration(migrations.Migration):
dependencies = [
("db", "0038_auto_20230720_1505"),
]
operations = [
migrations.RunPython(rename_field),
migrations.RunPython(update_workspace_member_props),
migrations.AlterField(
model_name='workspacemember',
name='view_props',
field=models.JSONField(default=plane.db.models.workspace.get_default_props),
),
migrations.AddField(
model_name='workspacemember',
name='default_props',
field=models.JSONField(default=plane.db.models.workspace.get_default_props),
),
migrations.AddField(
model_name='projectmember',
name='sort_order',
field=models.FloatField(default=65535),
),
migrations.RunPython(update_project_member_sort_order),
]
@@ -1,81 +0,0 @@
# Generated by Django 4.2.3 on 2023-08-01 06:02
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import plane.db.models.project
import uuid
class Migration(migrations.Migration):
dependencies = [
('db', '0039_auto_20230723_2203'),
]
operations = [
migrations.AddField(
model_name='projectmember',
name='preferences',
field=models.JSONField(default=plane.db.models.project.get_default_preferences),
),
migrations.AddField(
model_name='user',
name='cover_image',
field=models.URLField(blank=True, max_length=800, null=True),
),
migrations.CreateModel(
name='IssueReaction',
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)),
('reaction', models.CharField(max_length=20)),
('actor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_reactions', to=settings.AUTH_USER_MODEL)),
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
('issue', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_reactions', to='db.issue')),
('project', models.ForeignKey(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 Reaction',
'verbose_name_plural': 'Issue Reactions',
'db_table': 'issue_reactions',
'ordering': ('-created_at',),
'unique_together': {('issue', 'actor', 'reaction')},
},
),
migrations.CreateModel(
name='CommentReaction',
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)),
('reaction', models.CharField(max_length=20)),
('actor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comment_reactions', to=settings.AUTH_USER_MODEL)),
('comment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comment_reactions', to='db.issuecomment')),
('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(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': 'Comment Reaction',
'verbose_name_plural': 'Comment Reactions',
'db_table': 'comment_reactions',
'ordering': ('-created_at',),
'unique_together': {('comment', 'actor', 'reaction')},
},
),
migrations.AlterField(
model_name='project',
name='identifier',
field=models.CharField(max_length=12, verbose_name='Project Identifier'),
),
migrations.AlterField(
model_name='projectidentifier',
name='name',
field=models.CharField(max_length=12),
),
]
@@ -1,243 +0,0 @@
# Generated by Django 4.2.3 on 2023-08-14 07:12
from django.conf import settings
import django.contrib.postgres.fields
from django.db import migrations, models
import django.db.models.deletion
import plane.db.models.exporter
import plane.db.models.project
import uuid
import random
import string
def generate_display_name(apps, schema_editor):
UserModel = apps.get_model("db", "User")
updated_users = []
for obj in UserModel.objects.all():
obj.display_name = (
obj.email.split("@")[0]
if len(obj.email.split("@"))
else "".join(random.choice(string.ascii_letters) for _ in range(6))
)
updated_users.append(obj)
UserModel.objects.bulk_update(updated_users, ["display_name"], batch_size=100)
def rectify_field_issue_activity(apps, schema_editor):
Model = apps.get_model("db", "IssueActivity")
updated_activity = []
for obj in Model.objects.filter(field="assignee"):
obj.field = "assignees"
updated_activity.append(obj)
Model.objects.bulk_update(updated_activity, ["field"], batch_size=100)
def update_assignee_issue_activity(apps, schema_editor):
Model = apps.get_model("db", "IssueActivity")
updated_activity = []
# Get all the users
User = apps.get_model("db", "User")
users = User.objects.values("id", "email", "display_name")
for obj in Model.objects.filter(field="assignees"):
if bool(obj.new_value) and not bool(obj.old_value):
# Get user from list
assigned_user = [
user for user in users if user.get("email") == obj.new_value
]
if assigned_user:
obj.new_value = assigned_user[0].get("display_name")
obj.new_identifier = assigned_user[0].get("id")
# Update the comment
words = obj.comment.split()
words[-1] = assigned_user[0].get("display_name")
obj.comment = " ".join(words)
if bool(obj.old_value) and not bool(obj.new_value):
# Get user from list
assigned_user = [
user for user in users if user.get("email") == obj.old_value
]
if assigned_user:
obj.old_value = assigned_user[0].get("display_name")
obj.old_identifier = assigned_user[0].get("id")
# Update the comment
words = obj.comment.split()
words[-1] = assigned_user[0].get("display_name")
obj.comment = " ".join(words)
updated_activity.append(obj)
Model.objects.bulk_update(
updated_activity,
["old_value", "new_value", "old_identifier", "new_identifier", "comment"],
batch_size=200,
)
def update_name_activity(apps, schema_editor):
Model = apps.get_model("db", "IssueActivity")
update_activity = []
for obj in Model.objects.filter(field="name"):
obj.comment = obj.comment.replace("start date", "name")
update_activity.append(obj)
Model.objects.bulk_update(update_activity, ["comment"], batch_size=1000)
def random_cycle_order(apps, schema_editor):
CycleModel = apps.get_model("db", "Cycle")
updated_cycles = []
for obj in CycleModel.objects.all():
obj.sort_order = random.randint(1, 65536)
updated_cycles.append(obj)
CycleModel.objects.bulk_update(updated_cycles, ["sort_order"], batch_size=100)
def random_module_order(apps, schema_editor):
ModuleModel = apps.get_model("db", "Module")
updated_modules = []
for obj in ModuleModel.objects.all():
obj.sort_order = random.randint(1, 65536)
updated_modules.append(obj)
ModuleModel.objects.bulk_update(updated_modules, ["sort_order"], batch_size=100)
def update_user_issue_properties(apps, schema_editor):
IssuePropertyModel = apps.get_model("db", "IssueProperty")
updated_issue_properties = []
for obj in IssuePropertyModel.objects.all():
obj.properties["start_date"] = True
updated_issue_properties.append(obj)
IssuePropertyModel.objects.bulk_update(
updated_issue_properties, ["properties"], batch_size=100
)
def workspace_member_properties(apps, schema_editor):
WorkspaceMemberModel = apps.get_model("db", "WorkspaceMember")
updated_workspace_members = []
for obj in WorkspaceMemberModel.objects.all():
obj.view_props["properties"]["start_date"] = True
obj.default_props["properties"]["start_date"] = True
updated_workspace_members.append(obj)
WorkspaceMemberModel.objects.bulk_update(
updated_workspace_members, ["view_props", "default_props"], batch_size=100
)
class Migration(migrations.Migration):
dependencies = [
('db', '0040_projectmember_preferences_user_cover_image_and_more'),
]
operations = [
migrations.AddField(
model_name='cycle',
name='sort_order',
field=models.FloatField(default=65535),
),
migrations.AddField(
model_name='issuecomment',
name='access',
field=models.CharField(choices=[('INTERNAL', 'INTERNAL'), ('EXTERNAL', 'EXTERNAL')], default='INTERNAL', max_length=100),
),
migrations.AddField(
model_name='module',
name='sort_order',
field=models.FloatField(default=65535),
),
migrations.AddField(
model_name='user',
name='display_name',
field=models.CharField(default='', max_length=255),
),
migrations.CreateModel(
name='ExporterHistory',
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)),
('project', django.contrib.postgres.fields.ArrayField(base_field=models.UUIDField(default=uuid.uuid4), blank=True, null=True, size=None)),
('provider', models.CharField(choices=[('json', 'json'), ('csv', 'csv'), ('xlsx', 'xlsx')], max_length=50)),
('status', models.CharField(choices=[('queued', 'Queued'), ('processing', 'Processing'), ('completed', 'Completed'), ('failed', 'Failed')], default='queued', max_length=50)),
('reason', models.TextField(blank=True)),
('key', models.TextField(blank=True)),
('url', models.URLField(blank=True, max_length=800, null=True)),
('token', models.CharField(default=plane.db.models.exporter.generate_token, max_length=255, unique=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')),
('initiated_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_exporters', to=settings.AUTH_USER_MODEL)),
('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_exporters', to='db.workspace')),
],
options={
'verbose_name': 'Exporter',
'verbose_name_plural': 'Exporters',
'db_table': 'exporters',
'ordering': ('-created_at',),
},
),
migrations.CreateModel(
name='ProjectDeployBoard',
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)),
('anchor', models.CharField(db_index=True, default=plane.db.models.project.get_anchor, max_length=255, unique=True)),
('comments', models.BooleanField(default=False)),
('reactions', models.BooleanField(default=False)),
('votes', models.BooleanField(default=False)),
('views', models.JSONField(default=plane.db.models.project.get_default_views)),
('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')),
('inbox', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='bord_inbox', to='db.inbox')),
('project', models.ForeignKey(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': 'Project Deploy Board',
'verbose_name_plural': 'Project Deploy Boards',
'db_table': 'project_deploy_boards',
'ordering': ('-created_at',),
'unique_together': {('project', 'anchor')},
},
),
migrations.CreateModel(
name='IssueVote',
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)),
('vote', models.IntegerField(choices=[(-1, 'DOWNVOTE'), (1, 'UPVOTE')])),
('actor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='votes', to=settings.AUTH_USER_MODEL)),
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
('issue', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='votes', to='db.issue')),
('project', models.ForeignKey(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 Vote',
'verbose_name_plural': 'Issue Votes',
'db_table': 'issue_votes',
'ordering': ('-created_at',),
'unique_together': {('issue', 'actor')},
},
),
migrations.AlterField(
model_name='modulelink',
name='title',
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.RunPython(generate_display_name),
migrations.RunPython(rectify_field_issue_activity),
migrations.RunPython(update_assignee_issue_activity),
migrations.RunPython(update_name_activity),
migrations.RunPython(random_cycle_order),
migrations.RunPython(random_module_order),
migrations.RunPython(update_user_issue_properties),
migrations.RunPython(workspace_member_properties),
]
+1 -7
View File
@@ -18,7 +18,6 @@ from .project import (
ProjectMemberInvite,
ProjectIdentifier,
ProjectFavorite,
ProjectDeployBoard,
)
from .issue import (
@@ -35,9 +34,6 @@ from .issue import (
IssueSequence,
IssueAttachment,
IssueSubscriber,
IssueReaction,
CommentReaction,
IssueVote,
)
from .asset import FileAsset
@@ -74,6 +70,4 @@ from .inbox import Inbox, InboxIssue
from .analytic import AnalyticView
from .notification import Notification
from .exporter import ExporterHistory
from .notification import Notification
-12
View File
@@ -17,7 +17,6 @@ class Cycle(ProjectBaseModel):
related_name="owned_by_cycle",
)
view_props = models.JSONField(default=dict)
sort_order = models.FloatField(default=65535)
class Meta:
verbose_name = "Cycle"
@@ -25,17 +24,6 @@ class Cycle(ProjectBaseModel):
db_table = "cycles"
ordering = ("-created_at",)
def save(self, *args, **kwargs):
if self._state.adding:
smallest_sort_order = Cycle.objects.filter(
project=self.project
).aggregate(smallest=models.Min("sort_order"))["smallest"]
if smallest_sort_order is not None:
self.sort_order = smallest_sort_order - 10000
super(Cycle, self).save(*args, **kwargs)
def __str__(self):
"""Return name of the cycle"""
return f"{self.name} <{self.project.name}>"
-56
View File
@@ -1,56 +0,0 @@
import uuid
# Python imports
from uuid import uuid4
# Django imports
from django.db import models
from django.conf import settings
from django.contrib.postgres.fields import ArrayField
# Module imports
from . import BaseModel
def generate_token():
return uuid4().hex
class ExporterHistory(BaseModel):
workspace = models.ForeignKey(
"db.WorkSpace", on_delete=models.CASCADE, related_name="workspace_exporters"
)
project = ArrayField(models.UUIDField(default=uuid.uuid4), blank=True, null=True)
provider = models.CharField(
max_length=50,
choices=(
("json", "json"),
("csv", "csv"),
("xlsx", "xlsx"),
),
)
status = models.CharField(
max_length=50,
choices=(
("queued", "Queued"),
("processing", "Processing"),
("completed", "Completed"),
("failed", "Failed"),
),
default="queued",
)
reason = models.TextField(blank=True)
key = models.TextField(blank=True)
url = models.URLField(max_length=800, blank=True, null=True)
token = models.CharField(max_length=255, default=generate_token, unique=True)
initiated_by = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="workspace_exporters"
)
class Meta:
verbose_name = "Exporter"
verbose_name_plural = "Exporters"
db_table = "exporters"
ordering = ("-created_at",)
def __str__(self):
"""Return name of the service"""
return f"{self.provider} <{self.workspace.name}>"
+10 -75
View File
@@ -108,7 +108,11 @@ class Issue(ProjectBaseModel):
~models.Q(name="Triage"), project=self.project
).first()
self.state = random_state
if random_state.group == "started":
self.start_date = timezone.now().date()
else:
if default_state.group == "started":
self.start_date = timezone.now().date()
self.state = default_state
except ImportError:
pass
@@ -123,6 +127,8 @@ class Issue(ProjectBaseModel):
PageBlock.objects.filter(issue_id=self.id).filter().update(
completed_at=timezone.now()
)
elif self.state.group == "started":
self.start_date = timezone.now().date()
else:
PageBlock.objects.filter(issue_id=self.id).filter().update(
completed_at=None
@@ -147,6 +153,9 @@ class Issue(ProjectBaseModel):
if largest_sort_order is not None:
self.sort_order = largest_sort_order + 10000
# If adding it to started state
if self.state.group == "started":
self.start_date = timezone.now().date()
# Strip the html tags using html parser
self.description_stripped = (
None
@@ -200,7 +209,7 @@ class IssueAssignee(ProjectBaseModel):
class IssueLink(ProjectBaseModel):
title = models.CharField(max_length=255, null=True, blank=True)
title = models.CharField(max_length=255, null=True)
url = models.URLField()
issue = models.ForeignKey(
"db.Issue", on_delete=models.CASCADE, related_name="issue_link"
@@ -301,14 +310,6 @@ class IssueComment(ProjectBaseModel):
related_name="comments",
null=True,
)
access = models.CharField(
choices=(
("INTERNAL", "INTERNAL"),
("EXTERNAL", "EXTERNAL"),
),
default="INTERNAL",
max_length=100,
)
def save(self, *args, **kwargs):
self.comment_stripped = (
@@ -423,72 +424,6 @@ class IssueSubscriber(ProjectBaseModel):
return f"{self.issue.name} {self.subscriber.email}"
class IssueReaction(ProjectBaseModel):
actor = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="issue_reactions",
)
issue = models.ForeignKey(
Issue, on_delete=models.CASCADE, related_name="issue_reactions"
)
reaction = models.CharField(max_length=20)
class Meta:
unique_together = ["issue", "actor", "reaction"]
verbose_name = "Issue Reaction"
verbose_name_plural = "Issue Reactions"
db_table = "issue_reactions"
ordering = ("-created_at",)
def __str__(self):
return f"{self.issue.name} {self.actor.email}"
class CommentReaction(ProjectBaseModel):
actor = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="comment_reactions",
)
comment = models.ForeignKey(
IssueComment, on_delete=models.CASCADE, related_name="comment_reactions"
)
reaction = models.CharField(max_length=20)
class Meta:
unique_together = ["comment", "actor", "reaction"]
verbose_name = "Comment Reaction"
verbose_name_plural = "Comment Reactions"
db_table = "comment_reactions"
ordering = ("-created_at",)
def __str__(self):
return f"{self.issue.name} {self.actor.email}"
class IssueVote(ProjectBaseModel):
issue = models.ForeignKey(Issue, on_delete=models.CASCADE, related_name="votes")
actor = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="votes"
)
vote = models.IntegerField(
choices=(
(-1, "DOWNVOTE"),
(1, "UPVOTE"),
)
)
class Meta:
unique_together = ["issue", "actor"]
verbose_name = "Issue Vote"
verbose_name_plural = "Issue Votes"
db_table = "issue_votes"
ordering = ("-created_at",)
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):
+1 -13
View File
@@ -40,7 +40,6 @@ class Module(ProjectBaseModel):
through_fields=("module", "member"),
)
view_props = models.JSONField(default=dict)
sort_order = models.FloatField(default=65535)
class Meta:
unique_together = ["name", "project"]
@@ -49,17 +48,6 @@ class Module(ProjectBaseModel):
db_table = "modules"
ordering = ("-created_at",)
def save(self, *args, **kwargs):
if self._state.adding:
smallest_sort_order = Module.objects.filter(
project=self.project
).aggregate(smallest=models.Min("sort_order"))["smallest"]
if smallest_sort_order is not None:
self.sort_order = smallest_sort_order - 10000
super(Module, self).save(*args, **kwargs)
def __str__(self):
return f"{self.name} {self.start_date} {self.target_date}"
@@ -98,7 +86,7 @@ class ModuleIssue(ProjectBaseModel):
class ModuleLink(ProjectBaseModel):
title = models.CharField(max_length=255, blank=True, null=True)
title = models.CharField(max_length=255, null=True)
url = models.URLField()
module = models.ForeignKey(
Module, on_delete=models.CASCADE, related_name="link_module"
+2 -61
View File
@@ -1,6 +1,3 @@
# Python imports
from uuid import uuid4
# Django imports
from django.db import models
from django.conf import settings
@@ -35,10 +32,6 @@ def get_default_props():
}
def get_default_preferences():
return {"pages": {"block_display": True}}
class Project(BaseModel):
NETWORK_CHOICES = ((0, "Secret"), (2, "Public"))
name = models.CharField(max_length=255, verbose_name="Project Name")
@@ -54,7 +47,7 @@ class Project(BaseModel):
"db.WorkSpace", on_delete=models.CASCADE, related_name="workspace_project"
)
identifier = models.CharField(
max_length=12,
max_length=5,
verbose_name="Project Identifier",
)
default_assignee = models.ForeignKey(
@@ -154,20 +147,6 @@ class ProjectMember(ProjectBaseModel):
role = models.PositiveSmallIntegerField(choices=ROLE_CHOICES, default=10)
view_props = models.JSONField(default=get_default_props)
default_props = models.JSONField(default=get_default_props)
preferences = models.JSONField(default=get_default_preferences)
sort_order = models.FloatField(default=65535)
def save(self, *args, **kwargs):
if self._state.adding:
smallest_sort_order = ProjectMember.objects.filter(
workspace_id=self.project.workspace_id, member=self.member
).aggregate(smallest=models.Min("sort_order"))["smallest"]
# Project ordering
if smallest_sort_order is not None:
self.sort_order = smallest_sort_order - 10000
super(ProjectMember, self).save(*args, **kwargs)
class Meta:
unique_together = ["project", "member"]
@@ -189,7 +168,7 @@ class ProjectIdentifier(AuditModel):
project = models.OneToOneField(
Project, on_delete=models.CASCADE, related_name="project_identifier"
)
name = models.CharField(max_length=12)
name = models.CharField(max_length=10)
class Meta:
unique_together = ["name", "workspace"]
@@ -216,41 +195,3 @@ class ProjectFavorite(ProjectBaseModel):
def __str__(self):
"""Return user of the project"""
return f"{self.user.email} <{self.project.name}>"
def get_anchor():
return uuid4().hex
def get_default_views():
return {
"list": True,
"kanban": True,
"calendar": True,
"gantt": True,
"spreadsheet": True,
}
class ProjectDeployBoard(ProjectBaseModel):
anchor = models.CharField(
max_length=255, default=get_anchor, unique=True, db_index=True
)
comments = models.BooleanField(default=False)
reactions = models.BooleanField(default=False)
inbox = models.ForeignKey(
"db.Inbox", related_name="bord_inbox", on_delete=models.SET_NULL, null=True
)
votes = models.BooleanField(default=False)
views = models.JSONField(default=get_default_views)
class Meta:
unique_together = ["project", "anchor"]
verbose_name = "Project Deploy Board"
verbose_name_plural = "Project Deploy Boards"
db_table = "project_deploy_boards"
ordering = ("-created_at",)
def __str__(self):
"""Return project and anchor"""
return f"{self.anchor} <{self.project.name}>"
+1 -13
View File
@@ -1,7 +1,6 @@
# Python imports
from enum import unique
import uuid
import string
import random
# Django imports
from django.db import models
@@ -19,7 +18,6 @@ from sentry_sdk import capture_exception
from slack_sdk import WebClient
from slack_sdk.errors import SlackApiError
def get_default_onboarding():
return {
"profile_complete": False,
@@ -28,7 +26,6 @@ def get_default_onboarding():
"workspace_join": False,
}
class User(AbstractBaseUser, PermissionsMixin):
id = models.UUIDField(
default=uuid.uuid4, unique=True, editable=False, db_index=True, primary_key=True
@@ -41,7 +38,6 @@ class User(AbstractBaseUser, PermissionsMixin):
first_name = models.CharField(max_length=255, blank=True)
last_name = models.CharField(max_length=255, blank=True)
avatar = models.CharField(max_length=255, blank=True)
cover_image = models.URLField(blank=True, null=True, max_length=800)
# tracking metrics
date_joined = models.DateTimeField(auto_now_add=True, verbose_name="Created At")
@@ -84,7 +80,6 @@ class User(AbstractBaseUser, PermissionsMixin):
role = models.CharField(max_length=300, null=True, blank=True)
is_bot = models.BooleanField(default=False)
theme = models.JSONField(default=dict)
display_name = models.CharField(max_length=255, default="")
is_tour_completed = models.BooleanField(default=False)
onboarding_step = models.JSONField(default=get_default_onboarding)
@@ -111,13 +106,6 @@ class User(AbstractBaseUser, PermissionsMixin):
self.token = uuid.uuid4().hex + uuid.uuid4().hex
self.token_updated_at = timezone.now()
if not self.display_name:
self.display_name = (
self.email.split("@")[0]
if len(self.email.split("@"))
else "".join(random.choice(string.ascii_letters) for _ in range(6))
)
if self.is_superuser:
self.is_staff = True
+1 -27
View File
@@ -14,31 +14,6 @@ ROLE_CHOICES = (
)
def get_default_props():
return {
"filters": {"type": None},
"groupByProperty": None,
"issueView": "list",
"orderBy": "-created_at",
"properties": {
"assignee": True,
"due_date": True,
"key": True,
"labels": True,
"priority": True,
"state": True,
"sub_issue_count": True,
"attachment_count": True,
"link": True,
"estimate": True,
"created_on": True,
"updated_on": True,
"start_date": True,
},
"showEmptyGroups": True,
}
class Workspace(BaseModel):
name = models.CharField(max_length=80, verbose_name="Workspace Name")
logo = models.URLField(verbose_name="Logo", blank=True, null=True)
@@ -72,8 +47,7 @@ class WorkspaceMember(BaseModel):
)
role = models.PositiveSmallIntegerField(choices=ROLE_CHOICES, default=10)
company_role = models.TextField(null=True, blank=True)
view_props = models.JSONField(default=get_default_props)
default_props = models.JSONField(default=get_default_props)
view_props = models.JSONField(null=True, blank=True)
class Meta:
unique_together = ["workspace", "member"]
+1 -1
View File
@@ -214,4 +214,4 @@ SIMPLE_JWT = {
CELERY_TIMEZONE = TIME_ZONE
CELERY_TASK_SERIALIZER = 'json'
CELERY_ACCEPT_CONTENT = ['application/json']
CELERY_IMPORTS = ("plane.bgtasks.issue_automation_task","plane.bgtasks.exporter_expired_task")
CELERY_IMPORTS = ("plane.bgtasks.issue_automation_task",)
+1 -1
View File
@@ -17,7 +17,7 @@ EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"ENGINE": "django.db.backends.postgresql_psycopg2",
"NAME": os.environ.get("PGUSER", "plane"),
"USER": "",
"PASSWORD": "",
+1 -8
View File
@@ -17,7 +17,7 @@ DEBUG = int(os.environ.get("DEBUG", 0)) == 1
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"ENGINE": "django.db.backends.postgresql_psycopg2",
"NAME": "plane",
"USER": os.environ.get("PGUSER", ""),
"PASSWORD": os.environ.get("PGPASSWORD", ""),
@@ -71,8 +71,6 @@ CORS_ALLOW_HEADERS = [
CORS_ALLOW_CREDENTIALS = True
INSTALLED_APPS += ("scout_apm.django",)
STORAGES = {
"staticfiles": {
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
@@ -272,8 +270,3 @@ GITHUB_ACCESS_TOKEN = os.environ.get("GITHUB_ACCESS_TOKEN", False)
ENABLE_SIGNUP = os.environ.get("ENABLE_SIGNUP", "1") == "1"
# Scout Settings
SCOUT_MONITOR = os.environ.get("SCOUT_MONITOR", False)
SCOUT_KEY = os.environ.get("SCOUT_KEY", "")
SCOUT_NAME = "Plane"
+1 -1
View File
@@ -16,7 +16,7 @@ from .common import * # noqa
DEBUG = int(os.environ.get("DEBUG", 1)) == 1
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"ENGINE": "django.db.backends.postgresql_psycopg2",
"NAME": os.environ.get("PGUSER", "plane"),
"USER": "",
"PASSWORD": "",
+15 -55
View File
@@ -12,18 +12,6 @@ def filter_state(params, filter, method):
return filter
def filter_state_group(params, filter, method):
if method == "GET":
state_group = params.get("state_group").split(",")
if len(state_group) and "" not in state_group:
filter["state__group__in"] = state_group
else:
if params.get("state_group", None) and len(params.get("state_group")):
filter["state__group__in"] = params.get("state_group")
return filter
def filter_estimate_point(params, filter, method):
if method == "GET":
estimate_points = params.get("estimate_point").split(",")
@@ -124,11 +112,10 @@ def filter_created_at(params, filter, method):
else:
if params.get("created_at", None) and len(params.get("created_at")):
for query in params.get("created_at"):
created_at_query = query.split(";")
if len(created_at_query) == 2 and "after" in created_at_query:
filter["created_at__date__gte"] = created_at_query[0]
if query.get("timeline", "after") == "after":
filter["created_at__date__gte"] = query.get("datetime")
else:
filter["created_at__date__lte"] = created_at_query[0]
filter["created_at__date__lte"] = query.get("datetime")
return filter
@@ -145,11 +132,10 @@ def filter_updated_at(params, filter, method):
else:
if params.get("updated_at", None) and len(params.get("updated_at")):
for query in params.get("updated_at"):
updated_at_query = query.split(";")
if len(updated_at_query) == 2 and "after" in updated_at_query:
filter["updated_at__date__gte"] = updated_at_query[0]
if query.get("timeline", "after") == "after":
filter["updated_at__date__gte"] = query.get("datetime")
else:
filter["updated_at__date__lte"] = updated_at_query[0]
filter["updated_at__date__lte"] = query.get("datetime")
return filter
@@ -166,11 +152,10 @@ def filter_start_date(params, filter, method):
else:
if params.get("start_date", None) and len(params.get("start_date")):
for query in params.get("start_date"):
start_date_query = query.split(";")
if len(start_date_query) == 2 and "after" in start_date_query:
filter["start_date__gte"] = start_date_query[0]
if query.get("timeline", "after") == "after":
filter["start_date__gte"] = query.get("datetime")
else:
filter["start_date__lte"] = start_date_query[0]
filter["start_date__lte"] = query.get("datetime")
return filter
@@ -187,11 +172,10 @@ def filter_target_date(params, filter, method):
else:
if params.get("target_date", None) and len(params.get("target_date")):
for query in params.get("target_date"):
target_date_query = query.split(";")
if len(target_date_query) == 2 and "after" in target_date_query:
filter["target_date__gt"] = target_date_query[0]
if query.get("timeline", "after") == "after":
filter["target_date__gt"] = query.get("datetime")
else:
filter["target_date__lt"] = target_date_query[0]
filter["target_date__lt"] = query.get("datetime")
return filter
@@ -209,11 +193,10 @@ def filter_completed_at(params, filter, method):
else:
if params.get("completed_at", None) and len(params.get("completed_at")):
for query in params.get("completed_at"):
completed_at_query = query.split(";")
if len(completed_at_query) == 2 and "after" in completed_at_query:
filter["completed_at__date__gte"] = completed_at_query[0]
if query.get("timeline", "after") == "after":
filter["completed_at__date__gte"] = query.get("datetime")
else:
filter["completed_at__lte"] = completed_at_query[0]
filter["completed_at__lte"] = query.get("datetime")
return filter
@@ -229,7 +212,6 @@ def filter_issue_state_type(params, filter, method):
return filter
def filter_project(params, filter, method):
if method == "GET":
projects = params.get("project").split(",")
@@ -286,31 +268,11 @@ def filter_sub_issue_toggle(params, filter, method):
return filter
def filter_subscribed_issues(params, filter, method):
if method == "GET":
subscribers = params.get("subscriber").split(",")
if len(subscribers) and "" not in subscribers:
filter["issue_subscribers__subscriber_id__in"] = subscribers
else:
if params.get("subscriber", None) and len(params.get("subscriber")):
filter["issue_subscribers__subscriber_id__in"] = params.get("subscriber")
return filter
def filter_start_target_date_issues(params, filter, method):
start_target_date = params.get("start_target_date", "false")
if start_target_date == "true":
filter["target_date__isnull"] = False
filter["start_date__isnull"] = False
return filter
def issue_filters(query_params, method):
filter = dict()
ISSUE_FILTER = {
"state": filter_state,
"state_group": filter_state_group,
"estimate_point": filter_estimate_point,
"priority": filter_priority,
"parent": filter_parent,
@@ -329,8 +291,6 @@ def issue_filters(query_params, method):
"module": filter_module,
"inbox_status": filter_inbox_status,
"sub_issue": filter_sub_issue_toggle,
"subscriber": filter_subscribed_issues,
"start_target_date": filter_start_target_date_issues,
}
for key, value in ISSUE_FILTER.items():
-4
View File
@@ -30,7 +30,3 @@ openai==0.27.8
slack-sdk==3.21.3
celery==5.3.1
django_celery_beat==2.5.0
psycopg-binary==3.1.9
psycopg-c==3.1.9
scout-apm==2.26.1
openpyxl==3.1.2
+1 -1
View File
@@ -7,5 +7,5 @@ django-storages==1.13.2
boto3==1.27.0
django-anymail==10.0
django-debug-toolbar==4.1.0
gevent==23.7.0
gevent==22.10.2
psycogreen==1.0.2
@@ -89,8 +89,8 @@
</tr>
<tr>
<td height="18" align="center" valign="top" class="r15-i nl2go-default-textstyle" style="color: #3b3f44; font-family: arial,helvetica,sans-serif; font-size: 16px; line-height: 1.5;">
<!--[if mso]>
<v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href={{magic_url}} style="v-text-anchor:middle; height: 33px; width: 301px;" arcsize="12%" fillcolor="#ffffff" strokecolor="#3f76ff" strokeweight="1px" data-btn="1">
<!--[if mso]>
<v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="https://app.plane.so/" style="v-text-anchor:middle; height: 33px; width: 301px;" arcsize="12%" fillcolor="#ffffff" strokecolor="#3f76ff" strokeweight="1px" data-btn="1">
<w:anchorlock/>
<div style="display:none;">
<center class="default-button">
@@ -98,11 +98,11 @@
</center>
</div>
</v:roundrect>
<![endif]--> <!--[if !mso]><!-- -->
<![endif]--> <!--[if !mso]><!-- -->
<a href={{magic_url}} class="r16-r default-button" target="_blank" data-btn="1" style="font-style: normal; font-weight: bold; line-height: 1.15; text-decoration: none; border-style: solid; word-wrap: break-word; display: inline-block; -webkit-text-size-adjust: none; mso-hide: all; background-color: #ffffff; border-color: #3f76ff; border-radius: 4px; border-width: 1px; color: #ffffff; font-family: arial,helvetica,sans-serif; font-size: 16px; height: 18px; padding-bottom: 7px; padding-left: 20px; padding-right: 20px; padding-top: 7px; width: 258px;">
<p style="margin: 0;"><span style="color: #3F76FF; font-size: 14px;">Open Plane</span></p>
</a>
<!--<![endif]-->
<!--<![endif]-->
</td>
</tr>
<tr class="nl2go-responsive-hide">
@@ -364,4 +364,4 @@
</tr>
</table>
</body>
</html>
</html>
@@ -1,9 +0,0 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html>
Dear {{username}},<br/>
Your requested Issue's data has been successfully exported from Plane. The export includes all relevant information about issues you requested from your selected projects.</br>
Please find the attachment and download the CSV file. If you have any questions or need further assistance, please don't hesitate to contact our support team at <a href = "mailto: engineering@plane.com">engineering@plane.so</a>. We're here to help!</br>
Thank you for using Plane. We hope this export will aid you in effectively managing your projects.</br>
Regards,
Team Plane
</html>
@@ -90,8 +90,8 @@
</tr>
<tr>
<td height="18" align="center" valign="top" class="r15-i nl2go-default-textstyle" style="color: #3b3f44; font-family: arial,helvetica,sans-serif; font-size: 16px; line-height: 1.5;">
<!--[if mso]>
<v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href={{invitation_url}} style="v-text-anchor:middle; height: 33px; width: 301px;" arcsize="12%" fillcolor="#ffffff" strokecolor="#3f76ff" strokeweight="1px" data-btn="1">
<!--[if mso]>
<v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="https://app.plane.so/" style="v-text-anchor:middle; height: 33px; width: 301px;" arcsize="12%" fillcolor="#ffffff" strokecolor="#3f76ff" strokeweight="1px" data-btn="1">
<w:anchorlock/>
<div style="display:none;">
<center class="default-button">
@@ -99,11 +99,11 @@
</center>
</div>
</v:roundrect>
<![endif]--> <!--[if !mso]><!-- -->
<![endif]--> <!--[if !mso]><!-- -->
<a href={{invitation_url}} class="r16-r default-button" target="_blank" data-btn="1" style="font-style: normal; font-weight: bold; line-height: 1.15; text-decoration: none; border-style: solid; word-wrap: break-word; display: inline-block; -webkit-text-size-adjust: none; mso-hide: all; background-color: #ffffff; border-color: #3f76ff; border-radius: 4px; border-width: 1px; color: #ffffff; font-family: arial,helvetica,sans-serif; font-size: 16px; height: 18px; padding-bottom: 7px; padding-left: 20px; padding-right: 20px; padding-top: 7px; width: 258px;">
<p style="margin: 0;"><span style="color: #3F76FF;">Accept the invite</span></p>
</a>
<!--<![endif]-->
<!--<![endif]-->
</td>
</tr>
<tr class="nl2go-responsive-hide">
@@ -346,4 +346,4 @@
</tr>
</table>
</body>
</html>
</html>
@@ -90,8 +90,8 @@
</tr>
<tr>
<td height="18" align="center" valign="top" class="r15-i nl2go-default-textstyle" style="color: #3b3f44; font-family: arial,helvetica,sans-serif; font-size: 16px; line-height: 1.5;">
<!--[if mso]>
<v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href={{invitation_url}} style="v-text-anchor:middle; height: 33px; width: 301px;" arcsize="12%" fillcolor="#ffffff" strokecolor="#3f76ff" strokeweight="1px" data-btn="1">
<!--[if mso]>
<v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="https://app.plane.so/" style="v-text-anchor:middle; height: 33px; width: 301px;" arcsize="12%" fillcolor="#ffffff" strokecolor="#3f76ff" strokeweight="1px" data-btn="1">
<w:anchorlock/>
<div style="display:none;">
<center class="default-button">
@@ -99,11 +99,11 @@
</center>
</div>
</v:roundrect>
<![endif]--> <!--[if !mso]><!-- -->
<![endif]--> <!--[if !mso]><!-- -->
<a href={{invitation_url}} class="r16-r default-button" target="_blank" data-btn="1" style="font-style: normal; font-weight: bold; line-height: 1.15; text-decoration: none; border-style: solid; word-wrap: break-word; display: inline-block; -webkit-text-size-adjust: none; mso-hide: all; background-color: #ffffff; border-color: #3f76ff; border-radius: 4px; border-width: 1px; color: #ffffff; font-family: arial,helvetica,sans-serif; font-size: 16px; height: 18px; padding-bottom: 7px; padding-left: 20px; padding-right: 20px; padding-top: 7px; width: 258px;">
<p style="margin: 0;"><span style="color: #3F76FF;">Accept the invite</span></p>
</a>
<!--<![endif]-->
<!--<![endif]-->
</td>
</tr>
<tr class="nl2go-responsive-hide">
@@ -346,4 +346,4 @@
</tr>
</table>
</body>
</html>
</html>
+85
View File
@@ -0,0 +1,85 @@
{
"name": "Plane",
"description": "Plane helps you track your issues, epics, and product roadmaps.",
"repository": "http://github.com/makeplane/plane",
"logo": "https://avatars.githubusercontent.com/u/115727700?s=200&v=4",
"website": "https://plane.so/",
"success_url": "/",
"stack": "heroku-22",
"keywords": [
"plane",
"project management",
"django",
"next"
],
"addons": [
"heroku-postgresql:mini",
"heroku-redis:mini"
],
"buildpacks": [
{
"url": "https://github.com/heroku/heroku-buildpack-python.git"
},
{
"url": "https://github.com/heroku/heroku-buildpack-nodejs#v176"
}
],
"env": {
"EMAIL_HOST": {
"description": "Email host to send emails from",
"value": ""
},
"EMAIL_HOST_USER": {
"description": "Email host to send emails from",
"value": ""
},
"EMAIL_HOST_PASSWORD": {
"description": "Email host to send emails from",
"value": ""
},
"EMAIL_FROM": {
"description": "Email Sender",
"value": ""
},
"EMAIL_PORT": {
"description": "The default Email PORT to use",
"value": "587"
},
"AWS_REGION": {
"description": "AWS Region to use for S3",
"value": "false"
},
"AWS_ACCESS_KEY_ID": {
"description": "AWS Access Key ID to use for S3",
"value": ""
},
"AWS_SECRET_ACCESS_KEY": {
"description": "AWS Secret Access Key to use for S3",
"value": ""
},
"AWS_S3_BUCKET_NAME": {
"description": "AWS Bucket Name to use for S3",
"value": ""
},
"SENTRY_DSN": {
"description": "",
"value": ""
},
"WEB_URL": {
"description": "Web URL for Plane this will be used for redirections in the emails",
"value": ""
},
"GITHUB_CLIENT_SECRET": {
"description": "Github Client Secret",
"value": ""
},
"NEXT_PUBLIC_API_BASE_URL": {
"description": "Next Public API Base URL",
"value": ""
},
"SECRET_KEY": {
"description": "Django Secret Key",
"value": ""
}
}
}
+7
View File
@@ -0,0 +1,7 @@
module.exports = {
root: true,
extends: ["custom"],
rules: {
"@next/next/no-img-element": "off",
},
};
+11
View File
@@ -0,0 +1,11 @@
FROM node:18-alpine
RUN apk add --no-cache libc6-compat
# Set working directory
WORKDIR /app
COPY . .
RUN yarn global add turbo
RUN yarn install
EXPOSE 3000
CMD ["yarn","dev"]
@@ -33,8 +33,8 @@ RUN yarn turbo run build --filter=app
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL \
BUILT_NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
ENV NEXT_PUBLIC_DEPLOY_URL=${NEXT_PUBLIC_DEPLOY_URL}
RUN /usr/local/bin/replace-env-vars.sh http://NEXT_PUBLIC_WEBAPP_URL_PLACEHOLDER ${NEXT_PUBLIC_API_BASE_URL} app
RUN /usr/local/bin/replace-env-vars.sh http://NEXT_PUBLIC_WEBAPP_URL_PLACEHOLDER ${NEXT_PUBLIC_API_BASE_URL}
FROM node:18-alpine AS runner
WORKDIR /app
@@ -120,7 +120,7 @@ export const EmailCodeForm = ({ handleSignIn }: any) => {
Please check your inbox at <span className="font-medium">{watch("email")}</span>
</p>
)}
<form className="space-y-4 mt-10 sm:w-[360px] mx-auto">
<form className="space-y-4 mt-10 w-full sm:w-[360px] mx-auto">
<div className="space-y-1">
<Input
id="email"
@@ -136,7 +136,7 @@ export const EmailCodeForm = ({ handleSignIn }: any) => {
}}
error={errors.email}
placeholder="Enter your email address..."
className="border-custom-border-300 h-[46px]"
className="border-custom-border-300"
/>
</div>
@@ -152,7 +152,7 @@ export const EmailCodeForm = ({ handleSignIn }: any) => {
}}
error={errors.token}
placeholder="Enter code..."
className="border-custom-border-300 h-[46px]"
className="border-custom-border-300"
/>
<button
type="button"
@@ -178,7 +178,7 @@ export const EmailCodeForm = ({ handleSignIn }: any) => {
) : errorResendingCode ? (
"Please try again later"
) : (
<span className="font-medium">Resend code</span>
"Resend code"
)}
</button>
</>
@@ -186,7 +186,7 @@ export const EmailCodeForm = ({ handleSignIn }: any) => {
{codeSent ? (
<PrimaryButton
type="submit"
className="w-full text-center h-[46px]"
className="w-full text-center"
size="md"
onClick={handleSubmit(handleSignin)}
disabled={!isValid && isDirty}
@@ -196,7 +196,7 @@ export const EmailCodeForm = ({ handleSignIn }: any) => {
</PrimaryButton>
) : (
<PrimaryButton
className="w-full text-center h-[46px]"
className="w-full text-center"
size="md"
onClick={() => {
handleSubmit(onSubmit)().then(() => {
@@ -71,7 +71,7 @@ export const EmailPasswordForm: React.FC<Props> = ({ onSubmit }) => {
}}
error={errors.email}
placeholder="Enter your email address..."
className="border-custom-border-300 h-[46px]"
className="border-custom-border-300"
/>
</div>
<div className="space-y-1">
@@ -85,7 +85,7 @@ export const EmailPasswordForm: React.FC<Props> = ({ onSubmit }) => {
}}
error={errors.password}
placeholder="Enter your password..."
className="border-custom-border-300 h-[46px]"
className="border-custom-border-300"
/>
</div>
<div className="text-right text-xs">
@@ -108,7 +108,7 @@ export const EmailPasswordForm: React.FC<Props> = ({ onSubmit }) => {
<div>
<PrimaryButton
type="submit"
className="w-full text-center h-[46px]"
className="w-full text-center"
disabled={!isValid && isDirty}
loading={isSubmitting}
>
@@ -78,17 +78,17 @@ export const EmailResetPasswordForm: React.FC<Props> = ({ setIsResettingPassword
}}
error={errors.email}
placeholder="Enter registered email address.."
className="border-custom-border-300 h-[46px]"
className="border-custom-border-300"
/>
</div>
<div className="mt-5 flex flex-col-reverse sm:flex-row items-center gap-2">
<SecondaryButton
className="w-full text-center h-[46px]"
className="w-full text-center"
onClick={() => setIsResettingPassword(false)}
>
Go Back
</SecondaryButton>
<PrimaryButton type="submit" className="w-full text-center h-[46px]" loading={isSubmitting}>
<PrimaryButton type="submit" className="w-full text-center" loading={isSubmitting}>
{isSubmitting ? "Sending link..." : "Send reset link"}
</PrimaryButton>
</div>
@@ -44,9 +44,9 @@ export const GithubLoginButton: FC<GithubLoginButtonProps> = ({ handleSignIn })
<Link
href={`https://github.com/login/oauth/authorize?client_id=${NEXT_PUBLIC_GITHUB_ID}&redirect_uri=${loginCallBackURL}&scope=read:user,user:email`}
>
<button className="flex w-full items-center justify-center gap-2 rounded border border-custom-border-300 p-2 text-sm font-medium text-custom-text-100 duration-300 hover:bg-custom-background-80 h-[46px]">
<button className="flex w-full items-center justify-center gap-2 rounded border border-custom-border-300 p-2 text-sm font-medium text-custom-text-100 duration-300 hover:bg-custom-background-80">
<Image
src={theme === "dark" ? githubWhiteImage : githubBlackImage}
src={theme === "light" ? githubBlackImage : githubWhiteImage}
height={20}
width={20}
alt="GitHub Logo"
@@ -1,5 +1,5 @@
import { FC, CSSProperties, useEffect, useRef, useCallback, useState } from "react";
// next
import Script from "next/script";
export interface IGoogleLoginButton {
@@ -8,36 +8,30 @@ export interface IGoogleLoginButton {
styles?: CSSProperties;
}
export const GoogleLoginButton: FC<IGoogleLoginButton> = ({ handleSignIn }) => {
export const GoogleLoginButton: FC<IGoogleLoginButton> = (props) => {
const { handleSignIn } = props;
const googleSignInButton = useRef<HTMLDivElement>(null);
const [gsiScriptLoaded, setGsiScriptLoaded] = useState(false);
const loadScript = useCallback(() => {
if (!googleSignInButton.current || gsiScriptLoaded) return;
window?.google?.accounts.id.initialize({
client_id: process.env.NEXT_PUBLIC_GOOGLE_CLIENTID || "",
callback: handleSignIn,
});
try {
window?.google?.accounts.id.renderButton(
googleSignInButton.current,
{
type: "standard",
theme: "outline",
size: "large",
logo_alignment: "center",
width: 360,
text: "signin_with",
} as GsiButtonConfiguration // customization attributes
);
} catch (err) {
console.log(err);
}
window?.google?.accounts.id.renderButton(
googleSignInButton.current,
{
type: "standard",
theme: "outline",
size: "large",
logo_alignment: "center",
width: "360",
text: "continue_with",
} as GsiButtonConfiguration // customization attributes
);
window?.google?.accounts.id.prompt(); // also display the One Tap dialog
setGsiScriptLoaded(true);
}, [handleSignIn, gsiScriptLoaded]);
@@ -54,7 +48,7 @@ export const GoogleLoginButton: FC<IGoogleLoginButton> = ({ handleSignIn }) => {
<>
<Script src="https://accounts.google.com/gsi/client" async defer onLoad={loadScript} />
<div
className="overflow-hidden rounded w-full"
className="overflow-hidden rounded w-full flex justify-center items-center !text-sm !font-medium !text-custom-text-100"
id="googleSignInButton"
ref={googleSignInButton}
/>
@@ -14,17 +14,17 @@ type Props = {
export const CustomTooltip: React.FC<Props> = ({ datum, analytics, params }) => {
let tooltipValue: string | number = "";
const renderAssigneeName = (assigneeId: string): string => {
const assignee = analytics.extras.assignee_details.find((a) => a.assignees__id === assigneeId);
if (!assignee) return "No assignee";
return assignee.assignees__display_name || "No assignee";
};
if (params.segment) {
if (DATE_KEYS.includes(params.segment)) tooltipValue = renderMonthAndYear(datum.id);
else tooltipValue = datum.id;
else if (params.segment === "assignees__email") {
const assignee = analytics.extras.assignee_details.find(
(a) => a.assignees__email === datum.id
);
if (assignee)
tooltipValue = assignee.assignees__first_name + " " + assignee.assignees__last_name;
else tooltipValue = "No assignees";
} else tooltipValue = datum.id;
} else {
if (DATE_KEYS.includes(params.x_axis)) tooltipValue = datum.indexValue;
else tooltipValue = datum.id === "count" ? "Issue count" : "Estimate";
@@ -49,10 +49,7 @@ export const CustomTooltip: React.FC<Props> = ({ datum, analytics, params }) =>
: ""
}`}
>
{params.segment === "assignees__id"
? renderAssigneeName(tooltipValue.toString())
: tooltipValue}
:
{tooltipValue}:
</span>
<span>{datum.value}</span>
</div>
@@ -29,14 +29,6 @@ export const AnalyticsGraph: React.FC<Props> = ({
yAxisKey,
fullScreen,
}) => {
const renderAssigneeName = (assigneeId: string): string => {
const assignee = analytics.extras.assignee_details.find((a) => a.assignees__id === assigneeId);
if (!assignee) return "?";
return assignee.assignees__display_name || "?";
};
const generateYAxisTickValues = () => {
if (!analytics) return [];
@@ -78,17 +70,17 @@ export const AnalyticsGraph: React.FC<Props> = ({
height={fullScreen ? "400px" : "300px"}
margin={{
right: 20,
bottom: params.x_axis === "assignees__id" ? 50 : longestXAxisLabel.length * 5 + 20,
bottom: params.x_axis === "assignees__email" ? 50 : longestXAxisLabel.length * 5 + 20,
}}
axisBottom={{
tickSize: 0,
tickPadding: 10,
tickRotation: barGraphData.data.length > 7 ? -45 : 0,
renderTick:
params.x_axis === "assignees__id"
params.x_axis === "assignees__email"
? (datum) => {
const avatar = analytics.extras.assignee_details?.find(
(a) => a?.assignees__display_name === datum?.value
(a) => a?.assignees__email === datum?.value
)?.assignees__avatar;
if (avatar && avatar !== "")
@@ -109,11 +101,7 @@ export const AnalyticsGraph: React.FC<Props> = ({
<g transform={`translate(${datum.x},${datum.y})`}>
<circle cy={18} r={8} fill="#374151" />
<text x={0} y={21} textAnchor="middle" fontSize={9} fill="#ffffff">
{params.x_axis === "assignees__id"
? datum.value && datum.value !== "None"
? renderAssigneeName(datum.value)[0].toUpperCase()
: "?"
: datum.value && datum.value !== "None"
{datum.value && datum.value !== "None"
? `${datum.value}`.toUpperCase()[0]
: "?"}
</text>
@@ -227,7 +227,12 @@ export const AnalyticsSidebar: React.FC<Props> = ({
</span>
) : project.icon_prop ? (
<div className="h-6 w-6 grid place-items-center flex-shrink-0">
{renderEmoji(project.icon_prop)}
<span
style={{ color: project.icon_prop.color }}
className="material-symbols-rounded text-lg"
>
{project.icon_prop.name}
</span>
</div>
) : (
<span className="grid h-6 w-6 mr-1 flex-shrink-0 place-items-center rounded bg-gray-700 uppercase text-white">
@@ -277,7 +282,9 @@ export const AnalyticsSidebar: React.FC<Props> = ({
<div className="space-y-4 mt-4">
<div className="flex items-center gap-2 text-xs">
<h6 className="text-custom-text-200">Lead</h6>
<span>{cycleDetails.owned_by?.display_name}</span>
<span>
{cycleDetails.owned_by?.first_name} {cycleDetails.owned_by?.last_name}
</span>
</div>
<div className="flex items-center gap-2 text-xs">
<h6 className="text-custom-text-200">Start Date</h6>
@@ -303,7 +310,10 @@ export const AnalyticsSidebar: React.FC<Props> = ({
<div className="space-y-4 mt-4">
<div className="flex items-center gap-2 text-xs">
<h6 className="text-custom-text-200">Lead</h6>
<span>{moduleDetails.lead_detail?.display_name}</span>
<span>
{moduleDetails.lead_detail?.first_name}{" "}
{moduleDetails.lead_detail?.last_name}
</span>
</div>
<div className="flex items-center gap-2 text-xs">
<h6 className="text-custom-text-200">Start Date</h6>
@@ -332,7 +342,12 @@ export const AnalyticsSidebar: React.FC<Props> = ({
</div>
) : projectDetails?.icon_prop ? (
<div className="h-6 w-6 grid place-items-center flex-shrink-0">
{renderEmoji(projectDetails.icon_prop)}
<span
style={{ color: projectDetails.icon_prop.color }}
className="material-symbols-rounded text-lg"
>
{projectDetails.icon_prop.name}
</span>
</div>
) : (
<span className="grid h-6 w-6 mr-1 flex-shrink-0 place-items-center rounded bg-gray-700 uppercase text-white">
@@ -345,8 +360,11 @@ export const AnalyticsSidebar: React.FC<Props> = ({
<div className="flex items-center gap-2 text-xs">
<h6 className="text-custom-text-200">Network</h6>
<span>
{NETWORK_CHOICES.find((n) => n.key === projectDetails?.network)?.label ??
""}
{
NETWORK_CHOICES[
`${projectDetails?.network}` as keyof typeof NETWORK_CHOICES
]
}
</span>
</div>
</div>
@@ -22,12 +22,15 @@ type Props = {
};
export const AnalyticsTable: React.FC<Props> = ({ analytics, barGraphData, params, yAxisKey }) => {
const renderAssigneeName = (assigneeId: string): string => {
const assignee = analytics.extras.assignee_details.find((a) => a.assignees__id === assigneeId);
const renderAssigneeName = (email: string): string => {
const assignee = analytics.extras.assignee_details.find((a) => a.assignees__email === email);
if (!assignee) return "No assignee";
return assignee.assignees__display_name || "No assignee";
if (assignee.assignees__first_name !== "")
return assignee.assignees__first_name + " " + assignee.assignees__last_name;
return email;
};
return (
@@ -62,10 +65,10 @@ export const AnalyticsTable: React.FC<Props> = ({ analytics, barGraphData, param
}}
/>
)}
{params.segment === "assignees__id"
? renderAssigneeName(key)
: DATE_KEYS.includes(params.segment ?? "")
{DATE_KEYS.includes(params.segment ?? "")
? renderMonthAndYear(key)
: params.segment === "assignees__email"
? renderAssigneeName(key)
: key}
</div>
</th>
@@ -105,7 +108,7 @@ export const AnalyticsTable: React.FC<Props> = ({ analytics, barGraphData, param
}}
/>
)}
{params.x_axis === "assignees__id"
{params.x_axis === "assignees__email"
? renderAssigneeName(`${item.name}`)
: addSpaceIfCamelCase(`${item.name}`)}
</td>
@@ -1,38 +1,22 @@
// ui
import { ProfileEmptyState } from "components/ui";
// image
import emptyUsers from "public/empty-state/empty_users.svg";
type Props = {
users: {
avatar: string | null;
display_name: string | null;
email: string | null;
firstName: string;
lastName: string;
count: number;
id: string;
}[];
title: string;
emptyStateMessage: string;
workspaceSlug: string;
};
export const AnalyticsLeaderboard: React.FC<Props> = ({
users,
title,
emptyStateMessage,
workspaceSlug,
}) => (
export const AnalyticsLeaderboard: React.FC<Props> = ({ users, title }) => (
<div className="p-3 border border-custom-border-200 rounded-[10px]">
<h6 className="text-base font-medium">{title}</h6>
{users.length > 0 ? (
<div className="mt-3 space-y-3">
{users.map((user) => (
<a
key={user.display_name ?? "None"}
href={`/${workspaceSlug}/profile/${user.id}`}
target="_blank"
rel="noopener noreferrer"
<div
key={user.email ?? "None"}
className="flex items-start justify-between gap-4 text-xs"
>
<div className="flex items-center gap-2">
@@ -41,26 +25,24 @@ export const AnalyticsLeaderboard: React.FC<Props> = ({
<img
src={user.avatar}
className="absolute top-0 left-0 h-full w-full object-cover rounded-full"
alt={user.display_name ?? "None"}
alt={user.email ?? "None"}
/>
</div>
) : (
<div className="grid place-items-center flex-shrink-0 rounded-full bg-gray-700 text-[11px] capitalize text-white h-4 w-4">
{user.display_name !== "" ? user?.display_name?.[0] : "?"}
{user.firstName !== "" ? user.firstName[0] : "?"}
</div>
)}
<span className="break-words text-custom-text-200">
{user.display_name !== "" ? `${user.display_name}` : "No assignee"}
{user.firstName !== "" ? `${user.firstName} ${user.lastName}` : "No assignee"}
</span>
</div>
<span className="flex-shrink-0">{user.count}</span>
</a>
</div>
))}
</div>
) : (
<div className="px-7 py-4">
<ProfileEmptyState title="No Data yet" description={emptyStateMessage} image={emptyUsers} />
</div>
<div className="text-custom-text-200 text-center text-sm py-8">No matching data found.</div>
)}
</div>
);
@@ -56,28 +56,22 @@ export const ScopeAndDemand: React.FC<Props> = ({ fullScreen = true }) => {
<AnalyticsLeaderboard
users={defaultAnalytics.most_issue_created_user?.map((user) => ({
avatar: user?.created_by__avatar,
email: user?.created_by__email,
firstName: user?.created_by__first_name,
lastName: user?.created_by__last_name,
display_name: user?.created_by__display_name,
count: user?.count,
id: user?.created_by__id,
}))}
title="Most issues created"
emptyStateMessage="Co-workers and the number issues created by them appears here."
workspaceSlug={workspaceSlug?.toString() ?? ""}
/>
<AnalyticsLeaderboard
users={defaultAnalytics.most_issue_closed_user?.map((user) => ({
avatar: user?.assignees__avatar,
email: user?.assignees__email,
firstName: user?.assignees__first_name,
lastName: user?.assignees__last_name,
display_name: user?.assignees__display_name,
count: user?.count,
id: user?.assignees__id,
}))}
title="Most issues closed"
emptyStateMessage="Co-workers and the number issues closed by them appears here."
workspaceSlug={workspaceSlug?.toString() ?? ""}
/>
<div className={fullScreen ? "md:col-span-2" : ""}>
<AnalyticsYearWiseIssues defaultAnalytics={defaultAnalytics} />
@@ -1,7 +1,5 @@
// ui
import { BarGraph, ProfileEmptyState } from "components/ui";
// image
import emptyBarGraph from "public/empty-state/empty_bar_graph.svg";
import { BarGraph } from "components/ui";
// types
import { IDefaultAnalyticsResponse } from "types";
@@ -18,20 +16,23 @@ export const AnalyticsScope: React.FC<Props> = ({ defaultAnalytics }) => (
{defaultAnalytics.pending_issue_user.length > 0 ? (
<BarGraph
data={defaultAnalytics.pending_issue_user}
indexBy="assignees__display_name"
indexBy="assignees__email"
keys={["count"]}
height="250px"
colors={() => `#f97316`}
customYAxisTickValues={defaultAnalytics.pending_issue_user.map((d) => d.count)}
tooltip={(datum) => {
const assignee = defaultAnalytics.pending_issue_user.find(
(a) => a.assignees__display_name === `${datum.indexValue}`
(a) => a.assignees__email === `${datum.indexValue}`
);
return (
<div className="rounded-md border border-custom-border-200 bg-custom-background-80 p-2 text-xs">
<span className="font-medium text-custom-text-200">
{assignee ? assignee.assignees__display_name : "No assignee"}:{" "}
{assignee
? assignee.assignees__first_name + " " + assignee.assignees__last_name
: "No assignee"}
:{" "}
</span>
{datum.value}
</div>
@@ -72,12 +73,8 @@ export const AnalyticsScope: React.FC<Props> = ({ defaultAnalytics }) => (
}}
/>
) : (
<div className="px-7 py-4">
<ProfileEmptyState
title="No Data yet"
description="Analysis of pending issues by co-workers appears here."
image={emptyBarGraph}
/>
<div className="text-custom-text-200 text-center text-sm py-8">
No matching data found.
</div>
)}
</div>
@@ -0,0 +1,62 @@
// ui
import { LineGraph } from "components/ui";
// types
import { IDefaultAnalyticsResponse } from "types";
// constants
import { MONTHS_LIST } from "constants/calendar";
type Props = {
defaultAnalytics: IDefaultAnalyticsResponse;
};
export const AnalyticsYearWiseIssues: React.FC<Props> = ({ defaultAnalytics }) => {
const currentMonth = new Date().getMonth();
const startMonth = Math.floor(currentMonth / 3) * 3 + 1;
const quarterMonthsList = [startMonth, startMonth + 1, startMonth + 2];
return (
<div className="py-3 border border-custom-border-200 rounded-[10px]">
<h1 className="px-3 text-base font-medium">Issues closed in a year</h1>
{defaultAnalytics.issue_completed_month_wise.length > 0 ? (
<LineGraph
data={[
{
id: "issues_closed",
color: "rgb(var(--color-primary-100))",
data: MONTHS_LIST.map((month) => ({
x: month.label.substring(0, 3),
y:
defaultAnalytics.issue_completed_month_wise.find(
(data) => data.month === month.value
)?.count || 0,
})),
},
]}
customYAxisTickValues={defaultAnalytics.issue_completed_month_wise.map((data) => {
if (quarterMonthsList.includes(data.month)) return data.count;
return 0;
})}
height="300px"
colors={(datum) => datum.color}
curve="monotoneX"
margin={{ top: 20 }}
enableSlices="x"
sliceTooltip={(datum) => (
<div className="rounded-md border border-custom-border-200 bg-custom-background-80 p-2 text-xs">
{datum.slice.points[0].data.yFormatted}
<span className="text-custom-text-200"> issues closed in </span>
{datum.slice.points[0].data.xFormatted}
</div>
)}
theme={{
background: "rgb(var(--color-background-100))",
}}
enableArea
/>
) : (
<div className="text-custom-text-200 text-center text-sm py-8">No matching data found.</div>
)}
</div>
);
};

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