Compare commits

..
Author SHA1 Message Date
Aaryan Khandelwal 60c582a2fb chore: updated pages options dropdown 2024-08-12 16:45:57 +05:30
449 changed files with 6053 additions and 8931 deletions
-16
View File
@@ -1,16 +0,0 @@
{ pkgs, ... }: {
# Which nixpkgs channel to use.
channel = "stable-23.11"; # or "unstable"
# Use https://search.nixos.org/packages to find packages
packages = [
pkgs.nodejs_20
pkgs.python3
];
services.docker.enable = true;
services.postgres.enable = true;
services.redis.enable = true;
}
@@ -1,29 +0,0 @@
import { FC } from "react";
import { Info, X } from "lucide-react";
// helpers
import { TAuthErrorInfo } from "@/helpers/authentication.helper";
type TAuthBanner = {
bannerData: TAuthErrorInfo | undefined;
handleBannerData?: (bannerData: TAuthErrorInfo | undefined) => void;
};
export const AuthBanner: FC<TAuthBanner> = (props) => {
const { bannerData, handleBannerData } = props;
if (!bannerData) return <></>;
return (
<div className="relative flex items-center p-2 rounded-md gap-2 border border-custom-primary-100/50 bg-custom-primary-100/10">
<div className="w-4 h-4 flex-shrink-0 relative flex justify-center items-center">
<Info size={16} className="text-custom-primary-100" />
</div>
<div className="w-full text-sm font-medium text-custom-primary-100">{bannerData?.message}</div>
<div
className="relative ml-auto w-6 h-6 rounded-sm flex justify-center items-center transition-all cursor-pointer hover:bg-custom-primary-100/20 text-custom-primary-100/80"
onClick={() => handleBannerData && handleBannerData(undefined)}
>
<X className="w-4 h-4 flex-shrink-0" />
</div>
</div>
);
};
@@ -1,4 +1,3 @@
export * from "./auth-banner";
export * from "./email-config-switch";
export * from "./password-config-switch";
export * from "./authentication-method-card";
+1 -23
View File
@@ -8,16 +8,8 @@ import { Button, Input, Spinner } from "@plane/ui";
// components
import { Banner } from "@/components/common";
// helpers
import {
authErrorHandler,
EAuthenticationErrorCodes,
EErrorAlertType,
TAuthErrorInfo,
} from "@/helpers/authentication.helper";
import { API_BASE_URL } from "@/helpers/common.helper";
import { AuthService } from "@/services/auth.service";
import { AuthBanner } from "../authentication";
// ui
// icons
@@ -61,7 +53,6 @@ export const InstanceSignInForm: FC = (props) => {
const [csrfToken, setCsrfToken] = useState<string | undefined>(undefined);
const [formData, setFormData] = useState<TFormData>(defaultFromData);
const [isSubmitting, setIsSubmitting] = useState(false);
const [errorInfo, setErrorInfo] = useState<TAuthErrorInfo | undefined>(undefined);
const handleFormChange = (key: keyof TFormData, value: string | boolean) =>
setFormData((prev) => ({ ...prev, [key]: value }));
@@ -100,15 +91,6 @@ export const InstanceSignInForm: FC = (props) => {
[formData.email, formData.password, isSubmitting]
);
useEffect(() => {
if (errorCode) {
const errorDetail = authErrorHandler(errorCode?.toString() as EAuthenticationErrorCodes);
if (errorDetail) {
setErrorInfo(errorDetail);
}
}
}, [errorCode]);
return (
<div className="flex-grow container mx-auto max-w-lg px-10 lg:max-w-md lg:px-5 py-10 lg:pt-28 transition-all">
<div className="relative flex flex-col space-y-6">
@@ -121,11 +103,7 @@ export const InstanceSignInForm: FC = (props) => {
</p>
</div>
{errorData.type && errorData?.message ? (
<Banner type="error" message={errorData?.message} />
) : (
<>{errorInfo && <AuthBanner bannerData={errorInfo} handleBannerData={(value) => setErrorInfo(value)} />}</>
)}
{errorData.type && errorData?.message && <Banner type="error" message={errorData?.message} />}
<form
className="space-y-4"
+1 -1
View File
@@ -18,7 +18,7 @@
"@tailwindcss/typography": "^0.5.9",
"@types/lodash": "^4.17.0",
"autoprefixer": "10.4.14",
"axios": "^1.7.4",
"axios": "^1.6.7",
"js-cookie": "^3.0.5",
"lodash": "^4.17.21",
"lucide-react": "^0.356.0",
+1 -1
View File
@@ -52,4 +52,4 @@ SPACE_BASE_URL=
APP_BASE_URL=
# Hard delete files after days
HARD_DELETE_AFTER_DAYS=60
HARD_DELETE_AFTER_DAYS=
+12 -12
View File
@@ -1,4 +1,4 @@
FROM python:3.12.5-alpine AS backend
FROM python:3.11.1-alpine3.17 AS backend
# set environment variables
ENV PYTHONDONTWRITEBYTECODE 1
@@ -7,23 +7,23 @@ ENV PIP_DISABLE_PIP_VERSION_CHECK=1
WORKDIR /code
RUN apk add --no-cache \
"libpq" \
"libxslt" \
"nodejs-current" \
"xmlsec"
RUN apk --no-cache add \
"libpq~=15" \
"libxslt~=1.1" \
"nodejs-current~=19" \
"xmlsec~=1.2"
COPY requirements.txt ./
COPY requirements ./requirements
RUN apk add --no-cache libffi-dev
RUN apk add --no-cache --virtual .build-deps \
"bash~=5.2" \
"g++" \
"gcc" \
"cargo" \
"git" \
"make" \
"postgresql-dev" \
"g++~=12.2" \
"gcc~=12.2" \
"cargo~=1.64" \
"git~=2" \
"make~=4.3" \
"postgresql13-dev~=13" \
"libc-dev" \
"linux-headers" \
&& \
+11 -11
View File
@@ -1,4 +1,4 @@
FROM python:3.12.5-alpine AS backend
FROM python:3.11.1-alpine3.17 AS backend
# set environment variables
ENV PYTHONDONTWRITEBYTECODE 1
@@ -7,18 +7,18 @@ ENV PIP_DISABLE_PIP_VERSION_CHECK=1
RUN apk --no-cache add \
"bash~=5.2" \
"libpq" \
"libxslt" \
"nodejs-current" \
"xmlsec" \
"libpq~=15" \
"libxslt~=1.1" \
"nodejs-current~=19" \
"xmlsec~=1.2" \
"libffi-dev" \
"bash~=5.2" \
"g++" \
"gcc" \
"cargo" \
"git" \
"make" \
"postgresql-dev" \
"g++~=12.2" \
"gcc~=12.2" \
"cargo~=1.64" \
"git~=2" \
"make~=4.3" \
"postgresql13-dev~=13" \
"libc-dev" \
"linux-headers"
@@ -32,3 +32,4 @@ python manage.py create_bucket
python manage.py clear_cache
python manage.py runserver 0.0.0.0:8000 --settings=plane.settings.local
-41
View File
@@ -40,44 +40,3 @@ class ApiKeyRateThrottle(SimpleRateThrottle):
request.META["X-RateLimit-Reset"] = reset_time
return allowed
class ServiceTokenRateThrottle(SimpleRateThrottle):
scope = "service_token"
rate = "300/minute"
def get_cache_key(self, request, view):
# Retrieve the API key from the request header
api_key = request.headers.get("X-Api-Key")
if not api_key:
return None # Allow the request if there's no API key
# Use the API key as part of the cache key
return f"{self.scope}:{api_key}"
def allow_request(self, request, view):
allowed = super().allow_request(request, view)
if allowed:
now = self.timer()
# Calculate the remaining limit and reset time
history = self.cache.get(self.key, [])
# Remove old histories
while history and history[-1] <= now - self.duration:
history.pop()
# Calculate the requests
num_requests = len(history)
# Check available requests
available = self.num_requests - num_requests
# Unix timestamp for when the rate limit will reset
reset_time = int(now + self.duration)
# Add headers
request.META["X-RateLimit-Remaining"] = max(0, available)
request.META["X-RateLimit-Reset"] = reset_time
return allowed
-17
View File
@@ -11,7 +11,6 @@ from rest_framework import serializers
# Module imports
from plane.db.models import (
Issue,
IssueType,
IssueActivity,
IssueAssignee,
IssueAttachment,
@@ -47,12 +46,6 @@ class IssueSerializer(BaseSerializer):
write_only=True,
required=False,
)
type_id = serializers.PrimaryKeyRelatedField(
source="type",
queryset=IssueType.objects.all(),
required=False,
allow_null=True,
)
class Meta:
model = Issue
@@ -136,19 +129,9 @@ class IssueSerializer(BaseSerializer):
workspace_id = self.context["workspace_id"]
default_assignee_id = self.context["default_assignee_id"]
issue_type = validated_data.pop("type", None)
if not issue_type:
# Get default issue type
issue_type = IssueType.objects.filter(
project_issue_types__project_id=project_id, is_default=True
).first()
issue_type = issue_type
issue = Issue.objects.create(
**validated_data,
project_id=project_id,
type=issue_type,
)
# Issue Audit Users
+6 -21
View File
@@ -7,7 +7,6 @@ from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.db import IntegrityError
from django.urls import resolve
from django.utils import timezone
from plane.db.models.api import APIToken
from rest_framework import status
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
@@ -17,7 +16,7 @@ from rest_framework.views import APIView
# Module imports
from plane.api.middleware.api_authentication import APIKeyAuthentication
from plane.api.rate_limit import ApiKeyRateThrottle, ServiceTokenRateThrottle
from plane.api.rate_limit import ApiKeyRateThrottle
from plane.utils.exception_logger import log_exception
from plane.utils.paginator import BasePaginator
@@ -45,29 +44,15 @@ class BaseAPIView(TimezoneMixin, APIView, BasePaginator):
IsAuthenticated,
]
throttle_classes = [
ApiKeyRateThrottle,
]
def filter_queryset(self, queryset):
for backend in list(self.filter_backends):
queryset = backend().filter_queryset(self.request, queryset, self)
return queryset
def get_throttles(self):
throttle_classes = []
api_key = self.request.headers.get("X-Api-Key")
if api_key:
service_token = APIToken.objects.filter(
token=api_key,
is_service=True,
).first()
if service_token:
throttle_classes.append(ServiceTokenRateThrottle())
return throttle_classes
throttle_classes.append(ApiKeyRateThrottle())
return throttle_classes
def handle_exception(self, exc):
"""
Handle any exception that occurs, by returning an appropriate response,
@@ -167,4 +152,4 @@ class BaseAPIView(TimezoneMixin, APIView, BasePaginator):
for expand in self.request.GET.get("expand", "").split(",")
if expand
]
return expand if expand else None
return expand if expand else None
+1 -7
View File
@@ -26,7 +26,7 @@ from plane.api.serializers import (
CycleSerializer,
)
from plane.app.permissions import ProjectEntityPermission
from plane.bgtasks.issue_activities_task import issue_activity
from plane.bgtasks.issue_activites_task import issue_activity
from plane.db.models import (
Cycle,
CycleIssue,
@@ -544,12 +544,6 @@ class CycleArchiveUnarchiveAPIEndpoint(BaseAPIView):
)
cycle.archived_at = timezone.now()
cycle.save()
UserFavorite.objects.filter(
entity_type="cycle",
entity_identifier=cycle_id,
project_id=project_id,
workspace__slug=slug,
).delete()
return Response(status=status.HTTP_204_NO_CONTENT)
def delete(self, request, slug, project_id, cycle_id):
+8 -3
View File
@@ -1,7 +1,7 @@
# Python imports
import json
# Django imports
# Django improts
from django.core.serializers.json import DjangoJSONEncoder
from django.utils import timezone
from django.db.models import Q, Value, UUIDField
@@ -16,7 +16,7 @@ from rest_framework.response import Response
# Module imports
from plane.api.serializers import InboxIssueSerializer, IssueSerializer
from plane.app.permissions import ProjectLitePermission
from plane.bgtasks.issue_activities_task import issue_activity
from plane.bgtasks.issue_activites_task import issue_activity
from plane.db.models import (
Inbox,
InboxIssue,
@@ -184,8 +184,13 @@ class InboxIssueAPIEndpoint(BaseAPIView):
workspace__slug=slug, project_id=project_id
).first()
project = Project.objects.get(
workspace__slug=slug,
pk=project_id,
)
# Inbox view
if inbox is None:
if inbox is None and not project.inbox_view:
return Response(
{
"error": "Inbox is not enabled for this project enable it through the project's api"
+1 -119
View File
@@ -38,7 +38,7 @@ from plane.app.permissions import (
ProjectLitePermission,
ProjectMemberPermission,
)
from plane.bgtasks.issue_activities_task import issue_activity
from plane.bgtasks.issue_activites_task import issue_activity
from plane.db.models import (
Issue,
IssueActivity,
@@ -355,124 +355,6 @@ class IssueAPIEndpoint(BaseAPIView):
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def put(self, request, slug, project_id):
# Get the entities required for putting the issue, external_id and
# external_source are must to identify the issue here
project = Project.objects.get(pk=project_id)
external_id = request.data.get("external_id")
external_source = request.data.get("external_source")
# If the external_id and source are present, we need to find the exact
# issue that needs to be updated with the provided external_id and
# external_source
if external_id and external_source:
try:
issue = Issue.objects.get(
project_id=project_id,
workspace__slug=slug,
external_id=external_id,
external_source=external_source,
)
# Get the current instance of the issue in order to track
# changes and dispatch the issue activity
current_instance = json.dumps(
IssueSerializer(issue).data, cls=DjangoJSONEncoder
)
# Get the requested data, encode it as django object and pass it
# to serializer to validation
requested_data = json.dumps(
self.request.data, cls=DjangoJSONEncoder
)
serializer = IssueSerializer(
issue,
data=request.data,
context={
"project_id": project_id,
"workspace_id": project.workspace_id,
},
partial=True,
)
if serializer.is_valid():
# If the serializer is valid, save the issue and dispatch
# the update issue activity worker event.
serializer.save()
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=current_instance,
epoch=int(timezone.now().timestamp()),
)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(
# If the serializer is not valid, respond with 400 bad
# request
serializer.errors,
status=status.HTTP_400_BAD_REQUEST,
)
except Issue.DoesNotExist:
# If the issue does not exist, a new record needs to be created
# for the requested data.
# Serialize the data with the context of the project and
# workspace
serializer = IssueSerializer(
data=request.data,
context={
"project_id": project_id,
"workspace_id": project.workspace_id,
"default_assignee_id": project.default_assignee_id,
},
)
# If the serializer is valid, save the issue and dispatch the
# issue activity worker event as created
if serializer.is_valid():
serializer.save()
# Refetch the issue
issue = Issue.objects.filter(
workspace__slug=slug,
project_id=project_id,
pk=serializer.data["id"],
).first()
# If any of the created_at or created_by is present, update
# the issue with the provided data, else return with the
# default states given.
issue.created_at = request.data.get(
"created_at", timezone.now()
)
issue.created_by_id = request.data.get(
"created_by", request.user.id
)
issue.save(update_fields=["created_at", "created_by"])
issue_activity.delay(
type="issue.activity.created",
requested_data=json.dumps(
self.request.data, cls=DjangoJSONEncoder
),
actor_id=str(request.user.id),
issue_id=str(serializer.data.get("id", None)),
project_id=str(project_id),
current_instance=None,
epoch=int(timezone.now().timestamp()),
)
return Response(
serializer.data, status=status.HTTP_201_CREATED
)
return Response(
serializer.errors, status=status.HTTP_400_BAD_REQUEST
)
else:
return Response(
{"error": "external_id and external_source are required"},
status=status.HTTP_400_BAD_REQUEST,
)
def patch(self, request, slug, project_id, pk=None):
issue = Issue.objects.get(
workspace__slug=slug, project_id=project_id, pk=pk
+2 -7
View File
@@ -18,7 +18,7 @@ from plane.api.serializers import (
ModuleSerializer,
)
from plane.app.permissions import ProjectEntityPermission
from plane.bgtasks.issue_activities_task import issue_activity
from plane.bgtasks.issue_activites_task import issue_activity
from plane.db.models import (
Issue,
IssueAttachment,
@@ -520,6 +520,7 @@ class ModuleIssueAPIEndpoint(BaseAPIView):
class ModuleArchiveUnarchiveAPIEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
@@ -634,12 +635,6 @@ class ModuleArchiveUnarchiveAPIEndpoint(BaseAPIView):
)
module.archived_at = timezone.now()
module.save()
UserFavorite.objects.filter(
entity_type="module",
entity_identifier=pk,
project_id=project_id,
workspace__slug=slug,
).delete()
return Response(status=status.HTTP_204_NO_CONTENT)
def delete(self, request, slug, project_id, pk):
-4
View File
@@ -377,10 +377,6 @@ class ProjectArchiveUnarchiveAPIEndpoint(BaseAPIView):
project = Project.objects.get(pk=project_id, workspace__slug=slug)
project.archived_at = timezone.now()
project.save()
UserFavorite.objects.filter(
workspace__slug=slug,
project=project_id,
).delete()
return Response(status=status.HTTP_204_NO_CONTENT)
def delete(self, request, slug, project_id):
@@ -12,4 +12,3 @@ from .project import (
ProjectMemberPermission,
ProjectLitePermission,
)
from .base import allow_permission, ROLE
-61
View File
@@ -1,61 +0,0 @@
from plane.db.models import WorkspaceMember, ProjectMember
from functools import wraps
from rest_framework.response import Response
from rest_framework import status
from enum import Enum
class ROLE(Enum):
ADMIN = 20
MEMBER = 15
VIEWER = 10
GUEST = 5
def allow_permission(allowed_roles, level="PROJECT", creator=False, model=None):
def decorator(view_func):
@wraps(view_func)
def _wrapped_view(instance, request, *args, **kwargs):
# Check for creator if required
if creator and model:
obj = model.objects.filter(
id=kwargs["pk"], created_by=request.user
).exists()
if obj:
return view_func(instance, request, *args, **kwargs)
# Convert allowed_roles to their values if they are enum members
allowed_role_values = [
role.value if isinstance(role, ROLE) else role
for role in allowed_roles
]
# Check role permissions
if level == "WORKSPACE":
if WorkspaceMember.objects.filter(
member=request.user,
workspace__slug=kwargs["slug"],
role__in=allowed_role_values,
is_active=True,
).exists():
return view_func(instance, request, *args, **kwargs)
else:
if ProjectMember.objects.filter(
member=request.user,
workspace__slug=kwargs["slug"],
project_id=kwargs["project_id"],
role__in=allowed_role_values,
is_active=True,
).exists():
return view_func(instance, request, *args, **kwargs)
# Return permission denied if no conditions are met
return Response(
{"error": "You don't have the required permissions."},
status=status.HTTP_403_FORBIDDEN,
)
return _wrapped_view
return decorator
@@ -92,7 +92,6 @@ from .page import (
SubPageSerializer,
PageDetailSerializer,
PageVersionSerializer,
PageVersionDetailSerializer,
)
from .estimate import (
+1 -35
View File
@@ -167,41 +167,7 @@ class PageLogSerializer(BaseSerializer):
class PageVersionSerializer(BaseSerializer):
class Meta:
model = PageVersion
fields = [
"id",
"workspace",
"page",
"description_html",
"last_saved_at",
"owned_by",
"created_at",
"updated_at",
"created_by",
"updated_by",
]
read_only_fields = [
"workspace",
"page",
]
class PageVersionDetailSerializer(BaseSerializer):
class Meta:
model = PageVersion
fields = [
"id",
"workspace",
"page",
"last_saved_at",
"description_binary",
"description_html",
"description_json",
"owned_by",
"created_at",
"updated_at",
"created_by",
"updated_by",
]
fields = "__all__"
read_only_fields = [
"workspace",
"page",
-12
View File
@@ -6,8 +6,6 @@ from plane.app.views import (
CycleIssueViewSet,
CycleDateCheckEndpoint,
CycleFavoriteViewSet,
CycleProgressEndpoint,
CycleAnalyticsEndpoint,
TransferCycleIssueEndpoint,
CycleUserPropertiesEndpoint,
CycleArchiveUnarchiveEndpoint,
@@ -108,14 +106,4 @@ urlpatterns = [
CycleArchiveUnarchiveEndpoint.as_view(),
name="cycle-archive-unarchive",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/cycles/<uuid:cycle_id>/progress/",
CycleProgressEndpoint.as_view(),
name="project-cycle",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/cycles/<uuid:cycle_id>/analytics/",
CycleAnalyticsEndpoint.as_view(),
name="project-cycle",
),
]
+1 -1
View File
@@ -40,7 +40,7 @@ urlpatterns = [
name="inbox-issue",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/inbox-issues/<uuid:pk>/",
"workspaces/<str:slug>/projects/<uuid:project_id>/inbox-issues/<uuid:issue_id>/",
InboxIssueViewSet.as_view(
{
"get": "retrieve",
+6 -7
View File
@@ -19,8 +19,8 @@ from plane.app.views import (
IssueUserDisplayPropertyEndpoint,
IssueViewSet,
LabelViewSet,
BulkIssueOperationsEndpoint,
BulkArchiveIssuesEndpoint,
IssuePaginatedViewSet,
)
urlpatterns = [
@@ -39,12 +39,6 @@ urlpatterns = [
),
name="project-issue",
),
# updated v1 paginated issues
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/v2/issues/",
IssuePaginatedViewSet.as_view({"get": "list"}),
name="project-issues-paginated",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:pk>/",
IssueViewSet.as_view(
@@ -311,4 +305,9 @@ urlpatterns = [
),
name="project-issue-draft",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/bulk-operation-issues/",
BulkIssueOperationsEndpoint.as_view(),
name="bulk-operations-issues",
),
]
+3 -3
View File
@@ -98,8 +98,6 @@ from .cycle.base import (
CycleUserPropertiesEndpoint,
CycleViewSet,
TransferCycleIssueEndpoint,
CycleAnalyticsEndpoint,
CycleProgressEndpoint,
)
from .cycle.issue import (
CycleIssueViewSet,
@@ -114,7 +112,6 @@ from .issue.base import (
IssueViewSet,
IssueUserDisplayPropertyEndpoint,
BulkDeleteIssuesEndpoint,
IssuePaginatedViewSet,
)
from .issue.activity import (
@@ -159,6 +156,9 @@ from .issue.subscriber import (
IssueSubscriberViewSet,
)
from .issue.bulk_operations import BulkIssueOperationsEndpoint
from .module.base import (
ModuleViewSet,
ModuleLinkViewSet,
+18 -16
View File
@@ -7,22 +7,22 @@ from django.utils import timezone
from rest_framework import status
from rest_framework.response import Response
# Module imports
from plane.app.permissions import WorkSpaceAdminPermission
from plane.app.serializers import AnalyticViewSerializer
# Module imports
from plane.app.views.base import BaseAPIView, BaseViewSet
from plane.bgtasks.analytic_plot_export import analytic_export_task
from plane.db.models import AnalyticView, Issue, Workspace
from plane.utils.analytics_plot import build_graph_plot
from plane.utils.issue_filters import issue_filters
from plane.app.permissions import allow_permission, ROLE
class AnalyticsEndpoint(BaseAPIView):
permission_classes = [
WorkSpaceAdminPermission,
]
@allow_permission(
[ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER], level="WORKSPACE"
)
def get(self, request, slug):
x_axis = request.GET.get("x_axis", False)
y_axis = request.GET.get("y_axis", False)
@@ -201,10 +201,10 @@ class AnalyticViewViewset(BaseViewSet):
class SavedAnalyticEndpoint(BaseAPIView):
permission_classes = [
WorkSpaceAdminPermission,
]
@allow_permission(
[ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER], level="WORKSPACE"
)
def get(self, request, slug, analytic_id):
analytic_view = AnalyticView.objects.get(
pk=analytic_id, workspace__slug=slug
@@ -234,10 +234,10 @@ class SavedAnalyticEndpoint(BaseAPIView):
class ExportAnalyticsEndpoint(BaseAPIView):
permission_classes = [
WorkSpaceAdminPermission,
]
@allow_permission(
[ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER], level="WORKSPACE"
)
def post(self, request, slug):
x_axis = request.data.get("x_axis", False)
y_axis = request.data.get("y_axis", False)
@@ -301,10 +301,10 @@ class ExportAnalyticsEndpoint(BaseAPIView):
class DefaultAnalyticsEndpoint(BaseAPIView):
permission_classes = [
WorkSpaceAdminPermission,
]
@allow_permission(
[ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST], level="WORKSPACE"
)
def get(self, request, slug):
filters = issue_filters(request.GET, "GET")
base_issues = Issue.issue_objects.filter(
@@ -380,10 +380,12 @@ class DefaultAnalyticsEndpoint(BaseAPIView):
.order_by("-count")
)
open_estimate_sum = open_issues_queryset.aggregate(sum=Sum("point"))[
open_estimate_sum = open_issues_queryset.aggregate(
sum=Sum("point")
)["sum"]
total_estimate_sum = base_issues.aggregate(sum=Sum("point"))[
"sum"
]
total_estimate_sum = base_issues.aggregate(sum=Sum("point"))["sum"]
return Response(
{
+5 -10
View File
@@ -24,7 +24,7 @@ from django.utils import timezone
# Third party imports
from rest_framework import status
from rest_framework.response import Response
from plane.app.permissions import allow_permission, ROLE
from plane.app.permissions import ProjectEntityPermission
from plane.db.models import Cycle, UserFavorite, Issue, Label, User, Project
from plane.utils.analytics_plot import burndown_plot
@@ -34,6 +34,10 @@ from .. import BaseAPIView
class CycleArchiveUnarchiveEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
def get_queryset(self):
favorite_subquery = UserFavorite.objects.filter(
user=self.request.user,
@@ -288,7 +292,6 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView):
.distinct()
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER])
def get(self, request, slug, project_id, pk=None):
if pk is None:
queryset = (
@@ -593,7 +596,6 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView):
status=status.HTTP_200_OK,
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def post(self, request, slug, project_id, cycle_id):
cycle = Cycle.objects.get(
pk=cycle_id, project_id=project_id, workspace__slug=slug
@@ -607,18 +609,11 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView):
cycle.archived_at = timezone.now()
cycle.save()
UserFavorite.objects.filter(
entity_type="cycle",
entity_identifier=cycle_id,
project_id=project_id,
workspace__slug=slug,
).delete()
return Response(
{"archived_at": str(cycle.archived_at)},
status=status.HTTP_200_OK,
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def delete(self, request, slug, project_id, cycle_id):
cycle = Cycle.objects.get(
pk=cycle_id, project_id=project_id, workspace__slug=slug
File diff suppressed because it is too large Load Diff
+15 -6
View File
@@ -3,7 +3,12 @@ import json
# Django imports
from django.core import serializers
from django.db.models import F, Func, OuterRef, Q
from django.db.models import (
F,
Func,
OuterRef,
Q,
)
from django.utils import timezone
from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page
@@ -12,12 +17,16 @@ from django.views.decorators.gzip import gzip_page
from rest_framework import status
from rest_framework.response import Response
from plane.app.permissions import (
ProjectEntityPermission,
)
# Module imports
from .. import BaseViewSet
from plane.app.serializers import (
CycleIssueSerializer,
)
from plane.bgtasks.issue_activities_task import issue_activity
from plane.bgtasks.issue_activites_task import issue_activity
from plane.db.models import (
Cycle,
CycleIssue,
@@ -36,7 +45,6 @@ from plane.utils.paginator import (
GroupedOffsetPaginator,
SubGroupedOffsetPaginator,
)
from plane.app.permissions import allow_permission, ROLE
class CycleIssueViewSet(BaseViewSet):
@@ -46,6 +54,10 @@ class CycleIssueViewSet(BaseViewSet):
webhook_event = "cycle_issue"
bulk = True
permission_classes = [
ProjectEntityPermission,
]
filterset_fields = [
"issue__labels__id",
"issue__assignees__id",
@@ -80,7 +92,6 @@ class CycleIssueViewSet(BaseViewSet):
)
@method_decorator(gzip_page)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER])
def list(self, request, slug, project_id, cycle_id):
order_by_param = request.GET.get("order_by", "created_at")
filters = issue_filters(request.query_params, "GET")
@@ -227,7 +238,6 @@ class CycleIssueViewSet(BaseViewSet):
),
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def create(self, request, slug, project_id, cycle_id):
issues = request.data.get("issues", [])
@@ -323,7 +333,6 @@ class CycleIssueViewSet(BaseViewSet):
)
return Response({"message": "success"}, status=status.HTTP_201_CREATED)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def destroy(self, request, slug, project_id, cycle_id, issue_id):
cycle_issue = CycleIssue.objects.filter(
issue_id=issue_id,
+28 -81
View File
@@ -43,7 +43,6 @@ from plane.db.models import (
ProjectMember,
User,
Widget,
WorkspaceMember,
)
from plane.utils.issue_filters import issue_filters
@@ -52,61 +51,36 @@ from .. import BaseAPIView
def dashboard_overview_stats(self, request, slug):
extra_filters = {}
if WorkspaceMember.objects.filter(
assigned_issues = Issue.issue_objects.filter(
project__project_projectmember__is_active=True,
project__project_projectmember__member=request.user,
workspace__slug=slug,
member=request.user,
role=5,
is_active=True,
).exists():
extra_filters = {"created_by": request.user}
assignees__in=[request.user],
).count()
assigned_issues = (
Issue.issue_objects.filter(
project__project_projectmember__is_active=True,
project__project_projectmember__member=request.user,
workspace__slug=slug,
assignees__in=[request.user],
)
.filter(**extra_filters)
.count()
)
pending_issues_count = Issue.issue_objects.filter(
~Q(state__group__in=["completed", "cancelled"]),
target_date__lt=timezone.now().date(),
project__project_projectmember__is_active=True,
project__project_projectmember__member=request.user,
workspace__slug=slug,
assignees__in=[request.user],
).count()
pending_issues_count = (
Issue.issue_objects.filter(
~Q(state__group__in=["completed", "cancelled"]),
target_date__lt=timezone.now().date(),
project__project_projectmember__is_active=True,
project__project_projectmember__member=request.user,
workspace__slug=slug,
assignees__in=[request.user],
)
.filter(**extra_filters)
.count()
)
created_issues_count = Issue.issue_objects.filter(
workspace__slug=slug,
project__project_projectmember__is_active=True,
project__project_projectmember__member=request.user,
created_by_id=request.user.id,
).count()
created_issues_count = (
Issue.issue_objects.filter(
workspace__slug=slug,
project__project_projectmember__is_active=True,
project__project_projectmember__member=request.user,
created_by_id=request.user.id,
)
.filter(**extra_filters)
.count()
)
completed_issues_count = (
Issue.issue_objects.filter(
workspace__slug=slug,
project__project_projectmember__is_active=True,
project__project_projectmember__member=request.user,
assignees__in=[request.user],
state__group="completed",
)
.filter(**extra_filters)
.count()
)
completed_issues_count = Issue.issue_objects.filter(
workspace__slug=slug,
project__project_projectmember__is_active=True,
project__project_projectmember__member=request.user,
assignees__in=[request.user],
state__group="completed",
).count()
return Response(
{
@@ -192,14 +166,6 @@ def dashboard_assigned_issues(self, request, slug):
)
)
if WorkspaceMember.objects.filter(
workspace__slug=slug,
member=request.user,
role=5,
is_active=True,
).exists():
assigned_issues = assigned_issues.filter(created_by=request.user)
# Priority Ordering
priority_order = ["urgent", "high", "medium", "low", "none"]
assigned_issues = assigned_issues.annotate(
@@ -443,16 +409,6 @@ def dashboard_created_issues(self, request, slug):
def dashboard_issues_by_state_groups(self, request, slug):
filters = issue_filters(request.query_params, "GET")
state_order = ["backlog", "unstarted", "started", "completed", "cancelled"]
extra_filters = {}
if WorkspaceMember.objects.filter(
workspace__slug=slug,
member=request.user,
role=5,
is_active=True,
).exists():
extra_filters = {"created_by": request.user}
issues_by_state_groups = (
Issue.issue_objects.filter(
workspace__slug=slug,
@@ -460,7 +416,7 @@ def dashboard_issues_by_state_groups(self, request, slug):
project__project_projectmember__member=request.user,
assignees__in=[request.user],
)
.filter(**filters, **extra_filters)
.filter(**filters)
.values("state__group")
.annotate(count=Count("id"))
)
@@ -483,15 +439,6 @@ def dashboard_issues_by_state_groups(self, request, slug):
def dashboard_issues_by_priority(self, request, slug):
filters = issue_filters(request.query_params, "GET")
priority_order = ["urgent", "high", "medium", "low", "none"]
extra_filters = {}
if WorkspaceMember.objects.filter(
workspace__slug=slug,
member=request.user,
role=5,
is_active=True,
).exists():
extra_filters = {"created_by": request.user}
issues_by_priority = (
Issue.issue_objects.filter(
@@ -500,7 +447,7 @@ def dashboard_issues_by_priority(self, request, slug):
project__project_projectmember__member=request.user,
assignees__in=[request.user],
)
.filter(**filters, **extra_filters)
.filter(**filters)
.values("priority")
.annotate(count=Count("id"))
)
+7 -9
View File
@@ -7,11 +7,7 @@ from rest_framework import status
# Module imports
from ..base import BaseViewSet, BaseAPIView
from plane.app.permissions import (
ProjectEntityPermission,
allow_permission,
ROLE,
)
from plane.app.permissions import ProjectEntityPermission
from plane.db.models import Project, Estimate, EstimatePoint, Issue
from plane.app.serializers import (
EstimateSerializer,
@@ -27,8 +23,10 @@ def generate_random_name(length=10):
class ProjectEstimatePointEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER])
def get(self, request, slug, project_id):
project = Project.objects.get(workspace__slug=slug, pk=project_id)
if project.estimate_id is not None:
@@ -191,8 +189,10 @@ class BulkEstimatePointEndpoint(BaseViewSet):
class EstimatePointEndpoint(BaseViewSet):
permission_classes = [
ProjectEntityPermission,
]
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def create(self, request, slug, project_id, estimate_id):
# TODO: add a key validation if the same key already exists
if not request.data.get("key") or not request.data.get("value"):
@@ -211,7 +211,6 @@ class EstimatePointEndpoint(BaseViewSet):
serializer = EstimatePointSerializer(estimate_point).data
return Response(serializer, status=status.HTTP_200_OK)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def partial_update(
self, request, slug, project_id, estimate_id, estimate_point_id
):
@@ -232,7 +231,6 @@ class EstimatePointEndpoint(BaseViewSet):
serializer.save()
return Response(serializer.data, status=status.HTTP_200_OK)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def destroy(
self, request, slug, project_id, estimate_id, estimate_point_id
):
+4 -5
View File
@@ -2,7 +2,7 @@
from rest_framework import status
from rest_framework.response import Response
from plane.app.permissions import allow_permission, ROLE
from plane.app.permissions import WorkSpaceAdminPermission
from plane.app.serializers import ExporterHistorySerializer
from plane.bgtasks.export_task import issue_export_task
from plane.db.models import ExporterHistory, Project, Workspace
@@ -12,10 +12,12 @@ from .. import BaseAPIView
class ExportIssuesEndpoint(BaseAPIView):
permission_classes = [
WorkSpaceAdminPermission,
]
model = ExporterHistory
serializer_class = ExporterHistorySerializer
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
def post(self, request, slug):
# Get the workspace
workspace = Workspace.objects.get(slug=slug)
@@ -62,9 +64,6 @@ class ExportIssuesEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST,
)
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE"
)
def get(self, request, slug):
exporter_history = ExporterHistory.objects.filter(
workspace__slug=slug,
+7 -5
View File
@@ -11,7 +11,7 @@ from rest_framework import status
# Module imports
from ..base import BaseAPIView
from plane.app.permissions import allow_permission, ROLE
from plane.app.permissions import ProjectEntityPermission, WorkspaceEntityPermission
from plane.db.models import Workspace, Project
from plane.app.serializers import (
ProjectLiteSerializer,
@@ -21,8 +21,10 @@ from plane.license.utils.instance_value import get_configuration_value
class GPTIntegrationEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def post(self, request, slug, project_id):
OPENAI_API_KEY, GPT_ENGINE = get_configuration_value(
[
@@ -82,10 +84,10 @@ class GPTIntegrationEndpoint(BaseAPIView):
class WorkspaceGPTIntegrationEndpoint(BaseAPIView):
permission_classes = [
WorkspaceEntityPermission,
]
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE"
)
def post(self, request, slug):
OPENAI_API_KEY, GPT_ENGINE = get_configuration_value(
[
+32 -33
View File
@@ -16,9 +16,7 @@ from rest_framework.response import Response
# Module imports
from ..base import BaseViewSet
from plane.app.permissions import (
allow_permission, ROLE
)
from plane.app.permissions import ProjectBasePermission, ProjectLitePermission
from plane.db.models import (
Inbox,
InboxIssue,
@@ -37,10 +35,13 @@ from plane.app.serializers import (
InboxIssueDetailSerializer,
)
from plane.utils.issue_filters import issue_filters
from plane.bgtasks.issue_activities_task import issue_activity
from plane.bgtasks.issue_activites_task import issue_activity
class InboxViewSet(BaseViewSet):
permission_classes = [
ProjectBasePermission,
]
serializer_class = InboxSerializer
model = Inbox
@@ -62,7 +63,6 @@ class InboxViewSet(BaseViewSet):
.select_related("workspace", "project")
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def list(self, request, slug, project_id):
inbox = self.get_queryset().first()
return Response(
@@ -70,11 +70,9 @@ class InboxViewSet(BaseViewSet):
status=status.HTTP_200_OK,
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def perform_create(self, serializer):
serializer.save(project_id=self.kwargs.get("project_id"))
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def destroy(self, request, slug, project_id, pk):
inbox = Inbox.objects.filter(
workspace__slug=slug, project_id=project_id, pk=pk
@@ -90,6 +88,9 @@ class InboxViewSet(BaseViewSet):
class InboxIssueViewSet(BaseViewSet):
permission_classes = [
ProjectLitePermission,
]
serializer_class = InboxIssueSerializer
model = InboxIssue
@@ -167,7 +168,6 @@ class InboxIssueViewSet(BaseViewSet):
)
).distinct()
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST])
def list(self, request, slug, project_id):
inbox_id = Inbox.objects.filter(
workspace__slug=slug, project_id=project_id
@@ -201,14 +201,6 @@ class InboxIssueViewSet(BaseViewSet):
if inbox_status:
inbox_issue = inbox_issue.filter(status__in=inbox_status)
if ProjectMember.objects.filter(
workspace__slug=slug,
project_id=project_id,
member=request.user,
role=5,
is_active=True,
).exists():
inbox_issue = inbox_issue.filter(created_by=request.user)
return self.paginate(
request=request,
queryset=(inbox_issue),
@@ -218,7 +210,6 @@ class InboxIssueViewSet(BaseViewSet):
).data,
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def create(self, request, slug, project_id):
if not request.data.get("issue", {}).get("name", False):
return Response(
@@ -321,13 +312,12 @@ class InboxIssueViewSet(BaseViewSet):
serializer.errors, status=status.HTTP_400_BAD_REQUEST
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def partial_update(self, request, slug, project_id, pk):
def partial_update(self, request, slug, project_id, issue_id):
inbox_id = Inbox.objects.filter(
workspace__slug=slug, project_id=project_id
).first()
inbox_issue = InboxIssue.objects.get(
issue_id=pk,
issue_id=issue_id,
workspace__slug=slug,
project_id=project_id,
inbox_id=inbox_id,
@@ -468,7 +458,7 @@ class InboxIssueViewSet(BaseViewSet):
request.data, cls=DjangoJSONEncoder
),
actor_id=str(request.user.id),
issue_id=str(pk),
issue_id=str(issue_id),
project_id=str(project_id),
current_instance=current_instance,
epoch=int(timezone.now().timestamp()),
@@ -503,7 +493,7 @@ class InboxIssueViewSet(BaseViewSet):
)
.get(
inbox_id=inbox_id.id,
issue_id=pk,
issue_id=issue_id,
project_id=project_id,
)
)
@@ -516,12 +506,7 @@ class InboxIssueViewSet(BaseViewSet):
serializer = InboxIssueDetailSerializer(inbox_issue).data
return Response(serializer, status=status.HTTP_200_OK)
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER],
creator=True,
model=Issue,
)
def retrieve(self, request, slug, project_id, pk):
def retrieve(self, request, slug, project_id, issue_id):
inbox_id = Inbox.objects.filter(
workspace__slug=slug, project_id=project_id
).first()
@@ -549,7 +534,9 @@ class InboxIssueViewSet(BaseViewSet):
Value([], output_field=ArrayField(UUIDField())),
),
)
.get(inbox_id=inbox_id.id, issue_id=pk, project_id=project_id)
.get(
inbox_id=inbox_id.id, issue_id=issue_id, project_id=project_id
)
)
issue = InboxIssueDetailSerializer(inbox_issue).data
return Response(
@@ -557,13 +544,12 @@ class InboxIssueViewSet(BaseViewSet):
status=status.HTTP_200_OK,
)
@allow_permission(allowed_roles=[ROLE.ADMIN], creator=True, model=Issue)
def destroy(self, request, slug, project_id, pk):
def destroy(self, request, slug, project_id, issue_id):
inbox_id = Inbox.objects.filter(
workspace__slug=slug, project_id=project_id
).first()
inbox_issue = InboxIssue.objects.get(
issue_id=pk,
issue_id=issue_id,
workspace__slug=slug,
project_id=project_id,
inbox_id=inbox_id,
@@ -573,8 +559,21 @@ class InboxIssueViewSet(BaseViewSet):
if inbox_issue.status in [-2, -1, 0, 2]:
# Delete the issue also
issue = Issue.objects.filter(
workspace__slug=slug, project_id=project_id, pk=pk
workspace__slug=slug, project_id=project_id, pk=issue_id
).first()
if issue.created_by_id != request.user.id and (
not ProjectMember.objects.filter(
workspace__slug=slug,
member=request.user,
role=20,
project_id=project_id,
is_active=True,
).exists()
):
return Response(
{"error": "Only admin or creator can delete the issue"},
status=status.HTTP_403_FORBIDDEN,
)
issue.delete()
inbox_issue.delete()
+1 -2
View File
@@ -19,7 +19,7 @@ from plane.app.serializers import (
IssueActivitySerializer,
IssueCommentSerializer,
)
from plane.app.permissions import ProjectEntityPermission, allow_permission, ROLE
from plane.app.permissions import ProjectEntityPermission
from plane.db.models import (
IssueActivity,
IssueComment,
@@ -33,7 +33,6 @@ class IssueActivityEndpoint(BaseAPIView):
]
@method_decorator(gzip_page)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST, ROLE.VIEWER])
def get(self, request, slug, project_id, issue_id):
filters = {}
if request.GET.get("created_at__gt", None) is not None:
+7 -13
View File
@@ -25,9 +25,9 @@ from plane.app.permissions import (
from plane.app.serializers import (
IssueFlatSerializer,
IssueSerializer,
IssueDetailSerializer,
IssueDetailSerializer
)
from plane.bgtasks.issue_activities_task import issue_activity
from plane.bgtasks.issue_activites_task import issue_activity
from plane.db.models import (
Issue,
IssueAttachment,
@@ -46,14 +46,15 @@ from plane.utils.paginator import (
GroupedOffsetPaginator,
SubGroupedOffsetPaginator,
)
from plane.app.permissions import allow_permission, ROLE
from plane.utils.error_codes import ERROR_CODES
# Module imports
from .. import BaseViewSet, BaseAPIView
class IssueArchiveViewSet(BaseViewSet):
permission_classes = [
ProjectEntityPermission,
]
serializer_class = IssueFlatSerializer
model = Issue
@@ -97,7 +98,6 @@ class IssueArchiveViewSet(BaseViewSet):
)
@method_decorator(gzip_page)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER])
def list(self, request, slug, project_id):
filters = issue_filters(request.query_params, "GET")
show_sub_issues = request.GET.get("show_sub_issues", "true")
@@ -213,7 +213,6 @@ class IssueArchiveViewSet(BaseViewSet):
),
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER])
def retrieve(self, request, slug, project_id, pk=None):
issue = (
self.get_queryset()
@@ -257,7 +256,6 @@ class IssueArchiveViewSet(BaseViewSet):
serializer = IssueDetailSerializer(issue, expand=self.expand)
return Response(serializer.data, status=status.HTTP_200_OK)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def archive(self, request, slug, project_id, pk=None):
issue = Issue.issue_objects.get(
workspace__slug=slug,
@@ -296,7 +294,6 @@ class IssueArchiveViewSet(BaseViewSet):
{"archived_at": str(issue.archived_at)}, status=status.HTTP_200_OK
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def unarchive(self, request, slug, project_id, pk=None):
issue = Issue.objects.get(
workspace__slug=slug,
@@ -328,7 +325,6 @@ class BulkArchiveIssuesEndpoint(BaseAPIView):
ProjectEntityPermission,
]
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def post(self, request, slug, project_id):
issue_ids = request.data.get("issue_ids", [])
@@ -346,10 +342,8 @@ class BulkArchiveIssuesEndpoint(BaseAPIView):
if issue.state.group not in ["completed", "cancelled"]:
return Response(
{
"error_code": ERROR_CODES[
"INVALID_ARCHIVE_STATE_GROUP"
],
"error_message": "INVALID_ARCHIVE_STATE_GROUP",
"error_code": 4091,
"error_message": "INVALID_ARCHIVE_STATE_GROUP"
},
status=status.HTTP_400_BAD_REQUEST,
)
+19 -6
View File
@@ -13,17 +13,19 @@ from rest_framework.parsers import MultiPartParser, FormParser
# Module imports
from .. import BaseAPIView
from plane.app.serializers import IssueAttachmentSerializer
from plane.db.models import IssueAttachment
from plane.bgtasks.issue_activities_task import issue_activity
from plane.app.permissions import allow_permission, ROLE
from plane.app.permissions import ProjectEntityPermission
from plane.db.models import IssueAttachment, ProjectMember
from plane.bgtasks.issue_activites_task import issue_activity
class IssueAttachmentEndpoint(BaseAPIView):
serializer_class = IssueAttachmentSerializer
permission_classes = [
ProjectEntityPermission,
]
model = IssueAttachment
parser_classes = (MultiPartParser, FormParser)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def post(self, request, slug, project_id, issue_id):
serializer = IssueAttachmentSerializer(data=request.data)
if serializer.is_valid():
@@ -45,9 +47,21 @@ class IssueAttachmentEndpoint(BaseAPIView):
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@allow_permission([ROLE.ADMIN], creator=True, model=IssueAttachment)
def delete(self, request, slug, project_id, issue_id, pk):
issue_attachment = IssueAttachment.objects.get(pk=pk)
if issue_attachment.created_by_id != request.user.id and (
not ProjectMember.objects.filter(
workspace__slug=slug,
member=request.user,
role=20,
project_id=project_id,
is_active=True,
).exists()
):
return Response(
{"error": "Only admin or creator can delete the attachment"},
status=status.HTTP_403_FORBIDDEN,
)
issue_attachment.asset.delete(save=False)
issue_attachment.delete()
issue_activity.delay(
@@ -64,7 +78,6 @@ class IssueAttachmentEndpoint(BaseAPIView):
return Response(status=status.HTTP_204_NO_CONTENT)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST, ROLE.VIEWER])
def get(self, request, slug, project_id, issue_id):
issue_attachments = IssueAttachment.objects.filter(
issue_id=issue_id, workspace__slug=slug, project_id=project_id
+47 -185
View File
@@ -25,14 +25,17 @@ from rest_framework import status
from rest_framework.response import Response
# Module imports
from plane.app.permissions import allow_permission, ROLE
from plane.app.permissions import (
ProjectEntityPermission,
ProjectLitePermission,
)
from plane.app.serializers import (
IssueCreateSerializer,
IssueDetailSerializer,
IssueUserPropertySerializer,
IssueSerializer,
)
from plane.bgtasks.issue_activities_task import issue_activity
from plane.bgtasks.issue_activites_task import issue_activity
from plane.db.models import (
Issue,
IssueAttachment,
@@ -56,12 +59,15 @@ from plane.utils.paginator import (
)
from .. import BaseAPIView, BaseViewSet
from plane.utils.user_timezone_converter import user_timezone_converter
from plane.bgtasks.recent_visited_task import recent_visited_task
from plane.utils.global_paginator import paginate
# Module imports
class IssueListEndpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST])
permission_classes = [
ProjectEntityPermission,
]
def get(self, request, slug, project_id):
issue_ids = request.GET.get("issues", False)
@@ -128,14 +134,6 @@ class IssueListEndpoint(BaseAPIView):
sub_group_by=sub_group_by,
)
recent_visited_task.delay(
slug=slug,
project_id=project_id,
entity_name="project",
entity_identifier=project_id,
user_id=request.user.id,
)
if self.fields or self.expand:
issues = IssueSerializer(
queryset, many=True, fields=self.fields, expand=self.expand
@@ -186,6 +184,9 @@ class IssueViewSet(BaseViewSet):
model = Issue
webhook_event = "issue"
permission_classes = [
ProjectEntityPermission,
]
search_fields = [
"name",
@@ -231,7 +232,6 @@ class IssueViewSet(BaseViewSet):
).distinct()
@method_decorator(gzip_page)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST])
def list(self, request, slug, project_id):
filters = issue_filters(request.query_params, "GET")
order_by_param = request.GET.get("order_by", "-created_at")
@@ -256,22 +256,6 @@ class IssueViewSet(BaseViewSet):
sub_group_by=sub_group_by,
)
recent_visited_task.delay(
slug=slug,
project_id=project_id,
entity_name="project",
entity_identifier=project_id,
user_id=request.user.id,
)
if ProjectMember.objects.filter(
workspace__slug=slug,
project_id=project_id,
member=request.user,
role=5,
is_active=True,
).exists():
issue_queryset = issue_queryset.filter(created_by=request.user)
if group_by:
if sub_group_by:
if group_by == sub_group_by:
@@ -353,7 +337,6 @@ class IssueViewSet(BaseViewSet):
),
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def create(self, request, slug, project_id):
project = Project.objects.get(pk=project_id)
@@ -428,9 +411,6 @@ class IssueViewSet(BaseViewSet):
return Response(issue, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@allow_permission(
[ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER], creator=True, model=Issue
)
def retrieve(self, request, slug, project_id, pk=None):
issue = (
self.get_queryset()
@@ -500,18 +480,9 @@ class IssueViewSet(BaseViewSet):
status=status.HTTP_404_NOT_FOUND,
)
recent_visited_task.delay(
slug=slug,
entity_name="issue",
entity_identifier=pk,
user_id=request.user.id,
project_id=project_id,
)
serializer = IssueDetailSerializer(issue, expand=self.expand)
return Response(serializer.data, status=status.HTTP_200_OK)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def partial_update(self, request, slug, project_id, pk=None):
issue = (
self.get_queryset()
@@ -577,11 +548,23 @@ class IssueViewSet(BaseViewSet):
return Response(status=status.HTTP_204_NO_CONTENT)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@allow_permission([ROLE.ADMIN], creator=True, model=Issue)
def destroy(self, request, slug, project_id, pk=None):
issue = Issue.objects.get(
workspace__slug=slug, project_id=project_id, pk=pk
)
if issue.created_by_id != request.user.id and (
not ProjectMember.objects.filter(
workspace__slug=slug,
member=request.user,
role=20,
project_id=project_id,
is_active=True,
).exists()
):
return Response(
{"error": "Only admin or creator can delete the issue"},
status=status.HTTP_403_FORBIDDEN,
)
issue.delete()
issue_activity.delay(
@@ -599,7 +582,10 @@ class IssueViewSet(BaseViewSet):
class IssueUserDisplayPropertyEndpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST, ROLE.VIEWER])
permission_classes = [
ProjectLitePermission,
]
def patch(self, request, slug, project_id):
issue_property = IssueUserProperty.objects.get(
user=request.user,
@@ -619,7 +605,6 @@ class IssueUserDisplayPropertyEndpoint(BaseAPIView):
serializer = IssueUserPropertySerializer(issue_property)
return Response(serializer.data, status=status.HTTP_201_CREATED)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST, ROLE.VIEWER])
def get(self, request, slug, project_id):
issue_property, _ = IssueUserProperty.objects.get_or_create(
user=request.user, project_id=project_id
@@ -629,8 +614,23 @@ class IssueUserDisplayPropertyEndpoint(BaseAPIView):
class BulkDeleteIssuesEndpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN])
permission_classes = [
ProjectEntityPermission,
]
def delete(self, request, slug, project_id):
if ProjectMember.objects.filter(
workspace__slug=slug,
member=request.user,
role__in=[15, 10, 5],
project_id=project_id,
is_active=True,
).exists():
return Response(
{"error": "Only admin can perform this action"},
status=status.HTTP_403_FORBIDDEN,
)
issue_ids = request.data.get("issue_ids", [])
if not len(issue_ids):
@@ -651,141 +651,3 @@ class BulkDeleteIssuesEndpoint(BaseAPIView):
{"message": f"{total_issues} issues were deleted"},
status=status.HTTP_200_OK,
)
class IssuePaginatedViewSet(BaseViewSet):
def get_queryset(self):
workspace_slug = self.kwargs.get("slug")
project_id = self.kwargs.get("project_id")
return (
Issue.issue_objects.filter(
workspace__slug=workspace_slug, project_id=project_id
)
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
attachment_count=IssueAttachment.objects.filter(
issue=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
sub_issues_count=Issue.issue_objects.filter(
parent=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
).distinct()
def process_paginated_result(self, fields, results, timezone):
paginated_data = results.values(*fields)
# converting the datetime fields in paginated data
datetime_fields = ["created_at", "updated_at"]
paginated_data = user_timezone_converter(
paginated_data, datetime_fields, timezone
)
return paginated_data
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST])
def list(self, request, slug, project_id):
cursor = request.GET.get("cursor", None)
is_description_required = request.GET.get("description", False)
updated_at = request.GET.get("updated_at__gte", None)
# required fields
required_fields = [
"id",
"name",
"state_id",
"sort_order",
"completed_at",
"estimate_point",
"priority",
"start_date",
"target_date",
"sequence_id",
"project_id",
"parent_id",
"cycle_id",
"created_at",
"updated_at",
"created_by",
"updated_by",
"is_draft",
"archived_at",
"deleted_at",
"module_ids",
"label_ids",
"assignee_ids",
"link_count",
"attachment_count",
"sub_issues_count",
]
if is_description_required:
required_fields.append("description_html")
# querying issues
base_queryset = Issue.issue_objects.filter(
workspace__slug=slug, project_id=project_id
).order_by("updated_at")
queryset = self.get_queryset().order_by("updated_at")
# filtering issues by greater then updated_at given by the user
if updated_at:
base_queryset = base_queryset.filter(updated_at__gte=updated_at)
queryset = queryset.filter(updated_at__gte=updated_at)
queryset = queryset.annotate(
label_ids=Coalesce(
ArrayAgg(
"labels__id",
distinct=True,
filter=~Q(labels__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
assignee_ids=Coalesce(
ArrayAgg(
"assignees__id",
distinct=True,
filter=~Q(assignees__id__isnull=True)
& Q(assignees__member_project__is_active=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
module_ids=Coalesce(
ArrayAgg(
"issue_module__module_id",
distinct=True,
filter=~Q(issue_module__module_id__isnull=True)
& Q(issue_module__module__archived_at__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
)
paginated_data = paginate(
base_queryset=base_queryset,
queryset=queryset,
cursor=cursor,
on_result=lambda results: self.process_paginated_result(
required_fields, results, request.user.user_timezone
),
)
return Response(paginated_data, status=status.HTTP_200_OK)
@@ -0,0 +1,288 @@
# Python imports
import json
from datetime import datetime
# Django imports
from django.utils import timezone
# Third Party imports
from rest_framework.response import Response
from rest_framework import status
# Module imports
from .. import BaseAPIView
from plane.app.permissions import (
ProjectEntityPermission,
)
from plane.db.models import (
Project,
Issue,
IssueLabel,
IssueAssignee,
)
from plane.bgtasks.issue_activites_task import issue_activity
class BulkIssueOperationsEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
def post(self, request, slug, project_id):
issue_ids = request.data.get("issue_ids", [])
if not len(issue_ids):
return Response(
{"error": "Issue IDs are required"},
status=status.HTTP_400_BAD_REQUEST,
)
# Get all the issues
issues = (
Issue.objects.filter(
workspace__slug=slug, project_id=project_id, pk__in=issue_ids
)
.select_related("state")
.prefetch_related("labels", "assignees")
)
# Current epoch
epoch = int(timezone.now().timestamp())
# Project details
project = Project.objects.get(workspace__slug=slug, pk=project_id)
workspace_id = project.workspace_id
# Initialize arrays
bulk_update_issues = []
bulk_issue_activities = []
bulk_update_issue_labels = []
bulk_update_issue_assignees = []
properties = request.data.get("properties", {})
if properties.get("start_date", False) and properties.get("target_date", False):
if (
datetime.strptime(properties.get("start_date"), "%Y-%m-%d").date()
> datetime.strptime(properties.get("target_date"), "%Y-%m-%d").date()
):
return Response(
{
"error_code": 4100,
"error_message": "INVALID_ISSUE_DATES",
},
status=status.HTTP_400_BAD_REQUEST,
)
for issue in issues:
# Priority
if properties.get("priority", False):
bulk_issue_activities.append(
{
"type": "issue.activity.updated",
"requested_data": json.dumps(
{"priority": properties.get("priority")}
),
"current_instance": json.dumps(
{"priority": (issue.priority)}
),
"issue_id": str(issue.id),
"actor_id": str(request.user.id),
"project_id": str(project_id),
"epoch": epoch,
}
)
issue.priority = properties.get("priority")
# State
if properties.get("state_id", False):
bulk_issue_activities.append(
{
"type": "issue.activity.updated",
"requested_data": json.dumps(
{"state": properties.get("state")}
),
"current_instance": json.dumps(
{"state": str(issue.state_id)}
),
"issue_id": str(issue.id),
"actor_id": str(request.user.id),
"project_id": str(project_id),
"epoch": epoch,
}
)
issue.state_id = properties.get("state_id")
# Start date
if properties.get("start_date", False):
if (
issue.target_date
and not properties.get("target_date", False)
and issue.target_date
<= datetime.strptime(
properties.get("start_date"), "%Y-%m-%d"
).date()
):
return Response(
{
"error_code": 4101,
"error_message": "INVALID_ISSUE_START_DATE",
},
status=status.HTTP_400_BAD_REQUEST,
)
bulk_issue_activities.append(
{
"type": "issue.activity.updated",
"requested_data": json.dumps(
{"start_date": properties.get("start_date")}
),
"current_instance": json.dumps(
{"start_date": str(issue.start_date)}
),
"issue_id": str(issue.id),
"actor_id": str(request.user.id),
"project_id": str(project_id),
"epoch": epoch,
}
)
issue.start_date = properties.get("start_date")
# Target date
if properties.get("target_date", False):
if (
issue.start_date
and not properties.get("start_date", False)
and issue.start_date
>= datetime.strptime(
properties.get("target_date"), "%Y-%m-%d"
).date()
):
return Response(
{
"error_code": 4102,
"error_message": "INVALID_ISSUE_TARGET_DATE",
},
status=status.HTTP_400_BAD_REQUEST,
)
bulk_issue_activities.append(
{
"type": "issue.activity.updated",
"requested_data": json.dumps(
{"target_date": properties.get("target_date")}
),
"current_instance": json.dumps(
{"target_date": str(issue.target_date)}
),
"issue_id": str(issue.id),
"actor_id": str(request.user.id),
"project_id": str(project_id),
"epoch": epoch,
}
)
issue.target_date = properties.get("target_date")
bulk_update_issues.append(issue)
# Labels
if properties.get("label_ids", []):
for label_id in properties.get("label_ids", []):
bulk_update_issue_labels.append(
IssueLabel(
issue=issue,
label_id=label_id,
created_by=request.user,
project_id=project_id,
workspace_id=workspace_id,
)
)
bulk_issue_activities.append(
{
"type": "issue.activity.updated",
"requested_data": json.dumps(
{"label_ids": properties.get("label_ids", [])}
),
"current_instance": json.dumps(
{
"label_ids": [
str(label.id)
for label in issue.labels.all()
]
}
),
"issue_id": str(issue.id),
"actor_id": str(request.user.id),
"project_id": str(project_id),
"epoch": epoch,
}
)
# Assignees
if properties.get("assignee_ids", []):
for assignee_id in properties.get(
"assignee_ids", issue.assignees
):
bulk_update_issue_assignees.append(
IssueAssignee(
issue=issue,
assignee_id=assignee_id,
created_by=request.user,
project_id=project_id,
workspace_id=workspace_id,
)
)
bulk_issue_activities.append(
{
"type": "issue.activity.updated",
"requested_data": json.dumps(
{
"assignee_ids": properties.get(
"assignee_ids", []
)
}
),
"current_instance": json.dumps(
{
"assignee_ids": [
str(assignee.id)
for assignee in issue.assignees.all()
]
}
),
"issue_id": str(issue.id),
"actor_id": str(request.user.id),
"project_id": str(project_id),
"epoch": epoch,
}
)
# Bulk update all the objects
Issue.objects.bulk_update(
bulk_update_issues,
[
"priority",
"start_date",
"target_date",
"state",
],
batch_size=100,
)
# Create new labels
IssueLabel.objects.bulk_create(
bulk_update_issue_labels,
ignore_conflicts=True,
batch_size=100,
)
# Create new assignees
IssueAssignee.objects.bulk_create(
bulk_update_issue_assignees,
ignore_conflicts=True,
batch_size=100,
)
# update the issue activity
[
issue_activity.delay(**activity)
for activity in bulk_issue_activities
]
return Response(status=status.HTTP_204_NO_CONTENT)
+5 -11
View File
@@ -16,19 +16,22 @@ from plane.app.serializers import (
IssueCommentSerializer,
CommentReactionSerializer,
)
from plane.app.permissions import ProjectLitePermission, allow_permission, ROLE
from plane.app.permissions import ProjectLitePermission
from plane.db.models import (
IssueComment,
ProjectMember,
CommentReaction,
)
from plane.bgtasks.issue_activities_task import issue_activity
from plane.bgtasks.issue_activites_task import issue_activity
class IssueCommentViewSet(BaseViewSet):
serializer_class = IssueCommentSerializer
model = IssueComment
webhook_event = "issue_comment"
permission_classes = [
ProjectLitePermission,
]
filterset_fields = [
"issue__id",
@@ -63,7 +66,6 @@ class IssueCommentViewSet(BaseViewSet):
.distinct()
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST, ROLE.VIEWER])
def create(self, request, slug, project_id, issue_id):
serializer = IssueCommentSerializer(data=request.data)
if serializer.is_valid():
@@ -88,11 +90,6 @@ class IssueCommentViewSet(BaseViewSet):
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER],
creator=True,
model=IssueComment,
)
def partial_update(self, request, slug, project_id, issue_id, pk):
issue_comment = IssueComment.objects.get(
workspace__slug=slug,
@@ -124,9 +121,6 @@ class IssueCommentViewSet(BaseViewSet):
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@allow_permission(
allowed_roles=[ROLE.ADMIN], creator=True, model=IssueComment
)
def destroy(self, request, slug, project_id, issue_id, pk):
issue_comment = IssueComment.objects.get(
workspace__slug=slug,
+1 -1
View File
@@ -32,7 +32,7 @@ from plane.app.serializers import (
IssueFlatSerializer,
IssueSerializer,
)
from plane.bgtasks.issue_activities_task import issue_activity
from plane.bgtasks.issue_activites_task import issue_activity
from plane.db.models import (
Issue,
IssueAttachment,
+4 -6
View File
@@ -11,7 +11,9 @@ from rest_framework import status
# Module imports
from .. import BaseViewSet, BaseAPIView
from plane.app.serializers import LabelSerializer
from plane.app.permissions import allow_permission, ProjectBasePermission, ROLE
from plane.app.permissions import (
ProjectMemberPermission,
)
from plane.db.models import (
Project,
Label,
@@ -23,7 +25,7 @@ class LabelViewSet(BaseViewSet):
serializer_class = LabelSerializer
model = Label
permission_classes = [
ProjectBasePermission,
ProjectMemberPermission,
]
def get_queryset(self):
@@ -43,7 +45,6 @@ class LabelViewSet(BaseViewSet):
@invalidate_cache(
path="/api/workspaces/:slug/labels/", url_params=True, user=False
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def create(self, request, slug, project_id):
try:
serializer = LabelSerializer(data=request.data)
@@ -66,20 +67,17 @@ class LabelViewSet(BaseViewSet):
@invalidate_cache(
path="/api/workspaces/:slug/labels/", url_params=True, user=False
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def partial_update(self, request, *args, **kwargs):
return super().partial_update(request, *args, **kwargs)
@invalidate_cache(
path="/api/workspaces/:slug/labels/", url_params=True, user=False
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def destroy(self, request, *args, **kwargs):
return super().destroy(request, *args, **kwargs)
class BulkCreateIssueLabelsEndpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN])
def post(self, request, slug, project_id):
label_data = request.data.get("label_data", [])
project = Project.objects.get(pk=project_id)
+1 -1
View File
@@ -14,7 +14,7 @@ from .. import BaseViewSet
from plane.app.serializers import IssueLinkSerializer
from plane.app.permissions import ProjectEntityPermission
from plane.db.models import IssueLink
from plane.bgtasks.issue_activities_task import issue_activity
from plane.bgtasks.issue_activites_task import issue_activity
class IssueLinkViewSet(BaseViewSet):
+1 -1
View File
@@ -14,7 +14,7 @@ from .. import BaseViewSet
from plane.app.serializers import IssueReactionSerializer
from plane.app.permissions import ProjectLitePermission
from plane.db.models import IssueReaction
from plane.bgtasks.issue_activities_task import issue_activity
from plane.bgtasks.issue_activites_task import issue_activity
class IssueReactionViewSet(BaseViewSet):
+3 -2
View File
@@ -27,7 +27,7 @@ from plane.db.models import (
IssueAttachment,
IssueLink,
)
from plane.bgtasks.issue_activities_task import issue_activity
from plane.bgtasks.issue_activites_task import issue_activity
class IssueRelationViewSet(BaseViewSet):
@@ -80,7 +80,8 @@ class IssueRelationViewSet(BaseViewSet):
).values_list("issue_id", flat=True)
queryset = (
Issue.issue_objects.filter(workspace__slug=slug)
Issue.issue_objects
.filter(workspace__slug=slug)
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.annotate(cycle_id=F("issue_cycle__cycle_id"))
+1 -1
View File
@@ -30,7 +30,7 @@ from plane.db.models import (
IssueLink,
IssueAttachment,
)
from plane.bgtasks.issue_activities_task import issue_activity
from plane.bgtasks.issue_activites_task import issue_activity
from plane.utils.user_timezone_converter import user_timezone_converter
from collections import defaultdict
@@ -575,12 +575,6 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
)
module.archived_at = timezone.now()
module.save()
UserFavorite.objects.filter(
entity_type="module",
entity_identifier=module_id,
project_id=project_id,
workspace__slug=slug,
).delete()
return Response(
{"archived_at": str(module.archived_at)},
status=status.HTTP_200_OK,
+23 -23
View File
@@ -30,10 +30,8 @@ from rest_framework.response import Response
# Module imports
from plane.app.permissions import (
ProjectEntityPermission,
allow_permission,
ROLE,
ProjectLitePermission,
)
from plane.app.serializers import (
ModuleDetailSerializer,
ModuleLinkSerializer,
@@ -41,7 +39,7 @@ from plane.app.serializers import (
ModuleUserPropertiesSerializer,
ModuleWriteSerializer,
)
from plane.bgtasks.issue_activities_task import issue_activity
from plane.bgtasks.issue_activites_task import issue_activity
from plane.db.models import (
Issue,
Module,
@@ -50,16 +48,19 @@ from plane.db.models import (
ModuleLink,
ModuleUserProperties,
Project,
ProjectMember,
)
from plane.utils.analytics_plot import burndown_plot
from plane.utils.user_timezone_converter import user_timezone_converter
from plane.bgtasks.webhook_task import model_activity
from .. import BaseAPIView, BaseViewSet
from plane.bgtasks.recent_visited_task import recent_visited_task
class ModuleViewSet(BaseViewSet):
model = Module
permission_classes = [
ProjectEntityPermission,
]
webhook_event = "module"
def get_serializer_class(self):
@@ -317,8 +318,6 @@ class ModuleViewSet(BaseViewSet):
.order_by("-is_favorite", "-created_at")
)
allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER])
def create(self, request, slug, project_id):
project = Project.objects.get(workspace__slug=slug, pk=project_id)
serializer = ModuleWriteSerializer(
@@ -381,8 +380,6 @@ class ModuleViewSet(BaseViewSet):
return Response(module, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST])
def list(self, request, slug, project_id):
queryset = self.get_queryset().filter(archived_at__isnull=True)
if self.fields:
@@ -430,8 +427,6 @@ class ModuleViewSet(BaseViewSet):
)
return Response(modules, status=status.HTTP_200_OK)
allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER])
def retrieve(self, request, slug, project_id, pk):
queryset = (
self.get_queryset()
@@ -671,20 +666,11 @@ class ModuleViewSet(BaseViewSet):
module_id=pk,
)
recent_visited_task.delay(
slug=slug,
entity_name="module",
entity_identifier=pk,
user_id=request.user.id,
project_id=project_id,
)
return Response(
data,
status=status.HTTP_200_OK,
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def partial_update(self, request, slug, project_id, pk):
module = self.get_queryset().filter(pk=pk)
@@ -754,12 +740,25 @@ class ModuleViewSet(BaseViewSet):
return Response(module, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@allow_permission([ROLE.ADMIN], creator=True, model=Module)
def destroy(self, request, slug, project_id, pk):
module = Module.objects.get(
workspace__slug=slug, project_id=project_id, pk=pk
)
if module.created_by_id != request.user.id and (
not ProjectMember.objects.filter(
workspace__slug=slug,
member=request.user,
role=20,
project_id=project_id,
is_active=True,
).exists()
):
return Response(
{"error": "Only admin or creator can delete the module"},
status=status.HTTP_403_FORBIDDEN,
)
module_issues = list(
ModuleIssue.objects.filter(module_id=pk).values_list(
"issue", flat=True
@@ -860,8 +859,10 @@ class ModuleFavoriteViewSet(BaseViewSet):
class ModuleUserPropertiesEndpoint(BaseAPIView):
permission_classes = [
ProjectLitePermission,
]
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST])
def patch(self, request, slug, project_id, module_id):
module_properties = ModuleUserProperties.objects.get(
user=request.user,
@@ -884,7 +885,6 @@ class ModuleUserPropertiesEndpoint(BaseAPIView):
serializer = ModuleUserPropertiesSerializer(module_properties)
return Response(serializer.data, status=status.HTTP_201_CREATED)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST])
def get(self, request, slug, project_id, module_id):
module_properties, _ = ModuleUserProperties.objects.get_or_create(
user=request.user,
+8 -7
View File
@@ -17,11 +17,13 @@ from django.views.decorators.gzip import gzip_page
from rest_framework import status
from rest_framework.response import Response
from plane.app.permissions import allow_permission, ROLE
from plane.app.permissions import (
ProjectEntityPermission,
)
from plane.app.serializers import (
ModuleIssueSerializer,
)
from plane.bgtasks.issue_activities_task import issue_activity
from plane.bgtasks.issue_activites_task import issue_activity
from plane.db.models import (
Issue,
IssueAttachment,
@@ -44,7 +46,6 @@ from plane.utils.paginator import (
# Module imports
from .. import BaseViewSet
class ModuleIssueViewSet(BaseViewSet):
serializer_class = ModuleIssueSerializer
model = ModuleIssue
@@ -56,6 +57,10 @@ class ModuleIssueViewSet(BaseViewSet):
"issue__assignees__id",
]
permission_classes = [
ProjectEntityPermission,
]
def get_queryset(self):
return (
Issue.issue_objects.filter(
@@ -91,7 +96,6 @@ class ModuleIssueViewSet(BaseViewSet):
).distinct()
@method_decorator(gzip_page)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER])
def list(self, request, slug, project_id, module_id):
filters = issue_filters(request.query_params, "GET")
issue_queryset = self.get_queryset().filter(**filters)
@@ -199,7 +203,6 @@ class ModuleIssueViewSet(BaseViewSet):
),
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
# create multiple issues inside a module
def create_module_issues(self, request, slug, project_id, module_id):
issues = request.data.get("issues", [])
@@ -241,7 +244,6 @@ class ModuleIssueViewSet(BaseViewSet):
]
return Response({"message": "success"}, status=status.HTTP_201_CREATED)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
# add multiple module inside an issue and remove multiple modules from an issue
def create_issue_modules(self, request, slug, project_id, issue_id):
modules = request.data.get("modules", [])
@@ -304,7 +306,6 @@ class ModuleIssueViewSet(BaseViewSet):
return Response({"message": "success"}, status=status.HTTP_201_CREATED)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def destroy(self, request, slug, project_id, module_id, issue_id):
module_issue = ModuleIssue.objects.filter(
workspace__slug=slug,
@@ -19,7 +19,6 @@ from plane.db.models import (
WorkspaceMember,
)
from plane.utils.paginator import BasePaginator
from plane.app.permissions import allow_permission, ROLE
# Module imports
from ..base import BaseAPIView, BaseViewSet
@@ -40,10 +39,6 @@ class NotificationViewSet(BaseViewSet, BasePaginator):
.select_related("workspace", "project," "triggered_by", "receiver")
)
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST],
level="WORKSPACE",
)
def list(self, request, slug):
# Get query parameters
snoozed = request.GET.get("snoozed", "false")
@@ -173,10 +168,6 @@ class NotificationViewSet(BaseViewSet, BasePaginator):
serializer = NotificationSerializer(notifications, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST],
level="WORKSPACE",
)
def partial_update(self, request, slug, pk):
notification = Notification.objects.get(
workspace__slug=slug, pk=pk, receiver=request.user
@@ -194,10 +185,6 @@ class NotificationViewSet(BaseViewSet, BasePaginator):
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST],
level="WORKSPACE",
)
def mark_read(self, request, slug, pk):
notification = Notification.objects.get(
receiver=request.user, workspace__slug=slug, pk=pk
@@ -207,9 +194,6 @@ class NotificationViewSet(BaseViewSet, BasePaginator):
serializer = NotificationSerializer(notification)
return Response(serializer.data, status=status.HTTP_200_OK)
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE"
)
def mark_unread(self, request, slug, pk):
notification = Notification.objects.get(
receiver=request.user, workspace__slug=slug, pk=pk
@@ -219,9 +203,6 @@ class NotificationViewSet(BaseViewSet, BasePaginator):
serializer = NotificationSerializer(notification)
return Response(serializer.data, status=status.HTTP_200_OK)
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE"
)
def archive(self, request, slug, pk):
notification = Notification.objects.get(
receiver=request.user, workspace__slug=slug, pk=pk
@@ -231,9 +212,6 @@ class NotificationViewSet(BaseViewSet, BasePaginator):
serializer = NotificationSerializer(notification)
return Response(serializer.data, status=status.HTTP_200_OK)
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE"
)
def unarchive(self, request, slug, pk):
notification = Notification.objects.get(
receiver=request.user, workspace__slug=slug, pk=pk
@@ -245,10 +223,6 @@ class NotificationViewSet(BaseViewSet, BasePaginator):
class UnreadNotificationEndpoint(BaseAPIView):
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST],
level="WORKSPACE",
)
def get(self, request, slug):
# Watching Issues Count
unread_notifications_count = (
@@ -286,9 +260,6 @@ class UnreadNotificationEndpoint(BaseAPIView):
class MarkAllReadNotificationViewSet(BaseViewSet):
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE"
)
def create(self, request, slug):
snoozed = request.data.get("snoozed", False)
archived = request.data.get("archived", False)
+21 -46
View File
@@ -19,7 +19,7 @@ from rest_framework import status
from rest_framework.response import Response
from plane.app.permissions import allow_permission, ROLE
from plane.app.permissions import ProjectEntityPermission
from plane.app.serializers import (
PageLogSerializer,
PageSerializer,
@@ -33,13 +33,12 @@ from plane.db.models import (
ProjectMember,
ProjectPage,
)
from plane.utils.error_codes import ERROR_CODES
# Module imports
from ..base import BaseAPIView, BaseViewSet
from plane.bgtasks.page_transaction_task import page_transaction
from plane.bgtasks.page_version_task import page_version
from plane.bgtasks.recent_visited_task import recent_visited_task
def unarchive_archive_page_and_descendants(page_id, archived_at):
@@ -61,6 +60,9 @@ def unarchive_archive_page_and_descendants(page_id, archived_at):
class PageViewSet(BaseViewSet):
serializer_class = PageSerializer
model = Page
permission_classes = [
ProjectEntityPermission,
]
search_fields = [
"name",
]
@@ -120,7 +122,6 @@ class PageViewSet(BaseViewSet):
.distinct()
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def create(self, request, slug, project_id):
serializer = PageSerializer(
data=request.data,
@@ -142,7 +143,6 @@ class PageViewSet(BaseViewSet):
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def partial_update(self, request, slug, project_id, pk):
try:
page = Page.objects.get(
@@ -208,7 +208,6 @@ class PageViewSet(BaseViewSet):
status=status.HTTP_400_BAD_REQUEST,
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER])
def retrieve(self, request, slug, project_id, pk=None):
page = self.get_queryset().filter(pk=pk).first()
if page is None:
@@ -222,19 +221,11 @@ class PageViewSet(BaseViewSet):
).values_list("entity_identifier", flat=True)
data = PageDetailSerializer(page).data
data["issue_ids"] = issue_ids
recent_visited_task.delay(
slug=slug,
entity_name="page",
entity_identifier=pk,
user_id=request.user.id,
project_id=project_id,
)
return Response(
data,
status=status.HTTP_200_OK,
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def lock(self, request, slug, project_id, pk):
page = Page.objects.filter(
pk=pk, workspace__slug=slug, projects__id=project_id
@@ -244,7 +235,6 @@ class PageViewSet(BaseViewSet):
page.save()
return Response(status=status.HTTP_204_NO_CONTENT)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def unlock(self, request, slug, project_id, pk):
page = Page.objects.filter(
pk=pk, workspace__slug=slug, projects__id=project_id
@@ -255,7 +245,6 @@ class PageViewSet(BaseViewSet):
return Response(status=status.HTTP_204_NO_CONTENT)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def access(self, request, slug, project_id, pk):
access = request.data.get("access", 0)
page = Page.objects.filter(
@@ -278,13 +267,11 @@ class PageViewSet(BaseViewSet):
page.save()
return Response(status=status.HTTP_204_NO_CONTENT)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER])
def list(self, request, slug, project_id):
queryset = self.get_queryset()
pages = PageSerializer(queryset, many=True).data
return Response(pages, status=status.HTTP_200_OK)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def archive(self, request, slug, project_id, pk):
page = Page.objects.get(
pk=pk, workspace__slug=slug, projects__id=project_id
@@ -305,13 +292,6 @@ class PageViewSet(BaseViewSet):
status=status.HTTP_400_BAD_REQUEST,
)
UserFavorite.objects.filter(
entity_type="page",
entity_identifier=pk,
project_id=project_id,
workspace__slug=slug,
).delete()
unarchive_archive_page_and_descendants(pk, datetime.now())
return Response(
@@ -319,7 +299,6 @@ class PageViewSet(BaseViewSet):
status=status.HTTP_200_OK,
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def unarchive(self, request, slug, project_id, pk):
page = Page.objects.get(
pk=pk, workspace__slug=slug, projects__id=project_id
@@ -349,7 +328,6 @@ class PageViewSet(BaseViewSet):
return Response(status=status.HTTP_204_NO_CONTENT)
@allow_permission([ROLE.ADMIN], creator=True, model=Page)
def destroy(self, request, slug, project_id, pk):
page = Page.objects.get(
pk=pk, workspace__slug=slug, projects__id=project_id
@@ -392,10 +370,12 @@ class PageViewSet(BaseViewSet):
class PageFavoriteViewSet(BaseViewSet):
permission_classes = [
ProjectEntityPermission,
]
model = UserFavorite
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def create(self, request, slug, project_id, pk):
_ = UserFavorite.objects.create(
project_id=project_id,
@@ -405,7 +385,6 @@ class PageFavoriteViewSet(BaseViewSet):
)
return Response(status=status.HTTP_204_NO_CONTENT)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def destroy(self, request, slug, project_id, pk):
page_favorite = UserFavorite.objects.get(
project=project_id,
@@ -419,6 +398,9 @@ class PageFavoriteViewSet(BaseViewSet):
class PageLogEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
serializer_class = PageLogSerializer
model = PageLog
@@ -458,6 +440,9 @@ class PageLogEndpoint(BaseAPIView):
class SubPagesEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
@method_decorator(gzip_page)
def get(self, request, slug, project_id, page_id):
@@ -476,8 +461,10 @@ class SubPagesEndpoint(BaseAPIView):
class PagesDescriptionViewSet(BaseViewSet):
permission_classes = [
ProjectEntityPermission,
]
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER])
def retrieve(self, request, slug, project_id, pk):
page = (
Page.objects.filter(
@@ -486,11 +473,6 @@ class PagesDescriptionViewSet(BaseViewSet):
.filter(Q(owned_by=self.request.user) | Q(access=0))
.first()
)
if page is None:
return Response(
{"error": "Page not found"},
status=404,
)
binary_data = page.description_binary
def stream_data():
@@ -507,7 +489,6 @@ class PagesDescriptionViewSet(BaseViewSet):
)
return response
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST])
def partial_update(self, request, slug, project_id, pk):
page = (
Page.objects.filter(
@@ -525,20 +506,14 @@ class PagesDescriptionViewSet(BaseViewSet):
if page.is_locked:
return Response(
{
"error_code": ERROR_CODES["PAGE_LOCKED"],
"error_message": "PAGE_LOCKED",
},
status=status.HTTP_400_BAD_REQUEST,
{"error": "Page is locked"},
status=471,
)
if page.archived_at:
return Response(
{
"error_code": ERROR_CODES["PAGE_ARCHIVED"],
"error_message": "PAGE_ARCHIVED",
},
status=status.HTTP_400_BAD_REQUEST,
{"error": "Page is archived"},
status=472,
)
# Serialize the existing instance
+7 -9
View File
@@ -5,18 +5,16 @@ from rest_framework.response import Response
# Module imports
from plane.db.models import PageVersion
from ..base import BaseAPIView
from plane.app.serializers import (
PageVersionSerializer,
PageVersionDetailSerializer,
)
from plane.app.permissions import allow_permission, ROLE
from plane.app.permissions import ProjectEntityPermission
from plane.app.serializers import PageVersionSerializer
class PageVersionEndpoint(BaseAPIView):
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST]
)
permission_classes = [
ProjectEntityPermission,
]
def get(self, request, slug, project_id, page_id, pk=None):
# Check if pk is provided
if pk:
@@ -27,7 +25,7 @@ class PageVersionEndpoint(BaseAPIView):
pk=pk,
)
# Serialize the page version
serializer = PageVersionDetailSerializer(page_version)
serializer = PageVersionSerializer(page_version)
return Response(serializer.data, status=status.HTTP_200_OK)
# Return all page versions
page_versions = PageVersion.objects.filter(
+13 -42
View File
@@ -31,9 +31,8 @@ from plane.app.serializers import (
)
from plane.app.permissions import (
ProjectBasePermission,
ProjectMemberPermission,
allow_permission,
ROLE,
)
from plane.db.models import (
UserFavorite,
@@ -48,11 +47,9 @@ from plane.db.models import (
ProjectMember,
State,
Workspace,
WorkspaceMember,
)
from plane.utils.cache import cache_response
from plane.bgtasks.webhook_task import model_activity
from plane.bgtasks.recent_visited_task import recent_visited_task
class ProjectViewSet(BaseViewSet):
@@ -60,6 +57,10 @@ class ProjectViewSet(BaseViewSet):
model = Project
webhook_event = "project"
permission_classes = [
ProjectBasePermission,
]
def get_queryset(self):
sort_order = ProjectMember.objects.filter(
member=self.request.user,
@@ -154,10 +155,6 @@ class ProjectViewSet(BaseViewSet):
.distinct()
)
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST],
level="WORKSPACE",
)
def list(self, request, slug):
fields = [
field
@@ -176,27 +173,11 @@ class ProjectViewSet(BaseViewSet):
projects, many=True
).data,
)
if WorkspaceMember.objects.filter(
member=request.user,
workspace__slug=slug,
is_active=True,
role__in=[5, 10],
).exists():
projects = projects.filter(
project_projectmember__member=self.request.user,
project_projectmember__is_active=True,
)
projects = ProjectListSerializer(
projects, many=True, fields=fields if fields else None
).data
return Response(projects, status=status.HTTP_200_OK)
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST],
level="WORKSPACE",
)
def retrieve(self, request, slug, pk):
project = (
self.get_queryset()
@@ -265,18 +246,9 @@ class ProjectViewSet(BaseViewSet):
status=status.HTTP_404_NOT_FOUND,
)
recent_visited_task.delay(
slug=slug,
project_id=pk,
entity_name="project",
entity_identifier=pk,
user_id=request.user.id,
)
serializer = ProjectListSerializer(project)
return Response(serializer.data, status=status.HTTP_200_OK)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
def create(self, request, slug):
try:
workspace = Workspace.objects.get(slug=slug)
@@ -406,7 +378,6 @@ class ProjectViewSet(BaseViewSet):
status=status.HTTP_410_GONE,
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
def partial_update(self, request, slug, pk=None):
try:
workspace = Workspace.objects.get(slug=slug)
@@ -488,21 +459,19 @@ class ProjectViewSet(BaseViewSet):
class ProjectArchiveUnarchiveEndpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
permission_classes = [
ProjectBasePermission,
]
def post(self, request, slug, project_id):
project = Project.objects.get(pk=project_id, workspace__slug=slug)
project.archived_at = timezone.now()
project.save()
UserFavorite.objects.filter(
workspace__slug=slug,
project=project_id,
).delete()
return Response(
{"archived_at": str(project.archived_at)},
status=status.HTTP_200_OK,
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def delete(self, request, slug, project_id):
project = Project.objects.get(pk=project_id, workspace__slug=slug)
project.archived_at = None
@@ -511,7 +480,10 @@ class ProjectArchiveUnarchiveEndpoint(BaseAPIView):
class ProjectIdentifierEndpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
permission_classes = [
ProjectBasePermission,
]
def get(self, request, slug):
name = request.GET.get("name", "").strip().upper()
@@ -530,7 +502,6 @@ class ProjectIdentifierEndpoint(BaseAPIView):
status=status.HTTP_200_OK,
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
def delete(self, request, slug):
name = request.data.get("name", "").strip().upper()
+3 -40
View File
@@ -23,16 +23,17 @@ from plane.db.models import (
Workspace,
TeamMember,
IssueUserProperty,
WorkspaceMember,
)
from plane.bgtasks.project_add_user_email_task import project_add_user_email
from plane.utils.host import base_host
from plane.app.permissions.base import allow_permission, ROLE
class ProjectMemberViewSet(BaseViewSet):
serializer_class = ProjectMemberAdminSerializer
model = ProjectMember
permission_classes = [
ProjectMemberPermission,
]
def get_permissions(self):
if self.action == "leave":
@@ -64,7 +65,6 @@ class ProjectMemberViewSet(BaseViewSet):
.select_related("workspace", "workspace__owner")
)
@allow_permission([ROLE.ADMIN])
def create(self, request, slug, project_id):
# Get the list of members to be added to the project and their roles i.e. the user_id and the role
members = request.data.get("members", [])
@@ -88,23 +88,6 @@ class ProjectMemberViewSet(BaseViewSet):
member.get("member_id"): member.get("role") for member in members
}
# check the workspace role of the new user
for member in member_roles:
workspace_member_role = WorkspaceMember.objects.get(
workspace__slug=slug,
member=member,
is_active=True,
).role
if workspace_member_role in [5, 10] and member_roles.get(
member
) in [15, 20]:
return Response(
{
"error": "You cannot add a user with role higher than the workspace role"
},
status=status.HTTP_400_BAD_REQUEST,
)
# Update roles in the members array based on the member_roles dictionary and set is_active to True
for project_member in ProjectMember.objects.filter(
project_id=project_id,
@@ -189,7 +172,6 @@ class ProjectMemberViewSet(BaseViewSet):
# Return the serialized data
return Response(serializer.data, status=status.HTTP_201_CREATED)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST])
def list(self, request, slug, project_id):
# Get the list of project members for the project
project_members = ProjectMember.objects.filter(
@@ -204,7 +186,6 @@ class ProjectMemberViewSet(BaseViewSet):
)
return Response(serializer.data, status=status.HTTP_200_OK)
@allow_permission([ROLE.ADMIN])
def partial_update(self, request, slug, project_id, pk):
project_member = ProjectMember.objects.get(
pk=pk,
@@ -224,22 +205,6 @@ class ProjectMemberViewSet(BaseViewSet):
member=request.user,
is_active=True,
)
workspace_role = WorkspaceMember.objects.get(
workspace__slug=slug,
member=project_member.member,
is_active=True,
).role
if workspace_role in [5, 10] and int(
request.data.get("role", project_member.role)
) in [15, 20]:
return Response(
{
"error": "You cannot add a user with role higher than the workspace role"
},
status=status.HTTP_400_BAD_REQUEST,
)
if (
"role" in request.data
and int(request.data.get("role", project_member.role))
@@ -261,7 +226,6 @@ class ProjectMemberViewSet(BaseViewSet):
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def destroy(self, request, slug, project_id, pk):
project_member = ProjectMember.objects.get(
workspace__slug=slug,
@@ -298,7 +262,6 @@ class ProjectMemberViewSet(BaseViewSet):
project_member.save()
return Response(status=status.HTTP_204_NO_CONTENT)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST])
def leave(self, request, slug, project_id):
project_member = ProjectMember.objects.get(
workspace__slug=slug,
+3 -11
View File
@@ -9,7 +9,9 @@ from rest_framework.response import Response
# Module imports
from .base import BaseAPIView
from plane.db.models import Issue, ProjectMember
from plane.db.models import (
Issue,
)
from plane.utils.issue_search import search_issues
@@ -73,16 +75,6 @@ class IssueSearchEndpoint(BaseAPIView):
if target_date == "none":
issues = issues.filter(target_date__isnull=True)
if ProjectMember.objects.filter(
project_id=project_id,
member=self.request.user,
is_active=True,
role=5
).exists():
issues = issues.filter(
created_by=self.request.user
)
return Response(
issues.values(
+29 -107
View File
@@ -13,16 +13,15 @@ from django.db.models import (
from django.db.models.functions import Coalesce
from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page
from rest_framework import status
from django.db import transaction
# Third party imports
from rest_framework import status
from rest_framework.response import Response
# Module imports
from plane.app.permissions import (
allow_permission,
ROLE,
ProjectEntityPermission,
WorkspaceEntityPermission,
)
from plane.app.serializers import (
IssueViewSerializer,
@@ -47,8 +46,10 @@ from plane.utils.paginator import (
GroupedOffsetPaginator,
SubGroupedOffsetPaginator,
)
from plane.bgtasks.recent_visited_task import recent_visited_task
# Module imports
from .. import BaseViewSet
from plane.db.models import (
UserFavorite,
)
@@ -57,6 +58,9 @@ from plane.db.models import (
class WorkspaceViewViewSet(BaseViewSet):
serializer_class = IssueViewSerializer
model = IssueView
permission_classes = [
WorkspaceEntityPermission,
]
def perform_create(self, serializer):
workspace = Workspace.objects.get(slug=self.kwargs.get("slug"))
@@ -74,32 +78,6 @@ class WorkspaceViewViewSet(BaseViewSet):
.distinct()
)
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST],
level="WORKSPACE",
)
def list(self, request, slug):
queryset = self.get_queryset()
fields = [
field
for field in request.GET.get("fields", "").split(",")
if field
]
if WorkspaceMember.objects.filter(
workspace__slug=slug,
member=request.user,
role=5,
is_active=True,
).exists():
queryset = queryset.filter(owned_by=request.user)
views = IssueViewSerializer(
queryset, many=True, fields=fields if fields else None
).data
return Response(views, status=status.HTTP_200_OK)
@allow_permission(
allowed_roles=[], level="WORKSPACE", creator=True, model=IssueView
)
def partial_update(self, request, slug, pk):
with transaction.atomic():
workspace_view = IssueView.objects.select_for_update().get(
@@ -133,32 +111,24 @@ class WorkspaceViewViewSet(BaseViewSet):
serializer.errors, status=status.HTTP_400_BAD_REQUEST
)
def retrieve(self, request, slug, pk):
issue_view = self.get_queryset().filter(pk=pk).first()
serializer = IssueViewSerializer(issue_view)
recent_visited_task.delay(
slug=slug,
project_id=None,
entity_name="view",
entity_identifier=pk,
user_id=request.user.id,
)
return Response(
serializer.data,
status=status.HTTP_200_OK,
)
@allow_permission(
allowed_roles=[],
level="WORKSPACE",
creator=True,
model=IssueView,
)
def destroy(self, request, slug, pk):
workspace_view = IssueView.objects.get(
pk=pk,
workspace__slug=slug,
)
if not (
WorkspaceMember.objects.filter(
workspace__slug=slug,
member=request.user,
role=20,
is_active=True,
).exists()
and workspace_view.owned_by_id != request.user.id
):
return Response(
{"error": "You do not have permission to delete this view"},
status=status.HTTP_403_FORBIDDEN,
)
workspace_member = WorkspaceMember.objects.filter(
workspace__slug=slug,
@@ -187,6 +157,10 @@ class WorkspaceViewViewSet(BaseViewSet):
class WorkspaceViewIssuesViewSet(BaseViewSet):
permission_classes = [
WorkspaceEntityPermission,
]
def get_queryset(self):
return (
Issue.issue_objects.annotate(
@@ -258,10 +232,6 @@ class WorkspaceViewIssuesViewSet(BaseViewSet):
)
@method_decorator(gzip_page)
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST],
level="WORKSPACE",
)
def list(self, request, slug):
filters = issue_filters(request.query_params, "GET")
order_by_param = request.GET.get("order_by", "-created_at")
@@ -272,16 +242,6 @@ class WorkspaceViewIssuesViewSet(BaseViewSet):
.annotate(cycle_id=F("issue_cycle__cycle_id"))
)
if WorkspaceMember.objects.filter(
workspace__slug=slug,
member=request.user,
role=5,
is_active=True,
).exists():
issue_queryset = issue_queryset.filter(
created_by=request.user,
)
# Issue queryset
issue_queryset, order_by_param = order_issue_queryset(
issue_queryset=issue_queryset,
@@ -388,6 +348,9 @@ class WorkspaceViewIssuesViewSet(BaseViewSet):
class IssueViewViewSet(BaseViewSet):
serializer_class = IssueViewSerializer
model = IssueView
permission_classes = [
ProjectEntityPermission,
]
def perform_create(self, serializer):
serializer.save(
@@ -421,20 +384,8 @@ class IssueViewViewSet(BaseViewSet):
.distinct()
)
allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST]
)
def list(self, request, slug, project_id):
queryset = self.get_queryset()
if ProjectMember.objects.filter(
workspace__slug=slug,
project_id=project_id,
member=request.user,
role=5,
is_active=True,
).exists():
queryset = queryset.filter(owned_by=request.user)
fields = [
field
for field in request.GET.get("fields", "").split(",")
@@ -445,29 +396,6 @@ class IssueViewViewSet(BaseViewSet):
).data
return Response(views, status=status.HTTP_200_OK)
allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST]
)
def retrieve(self, request, slug, project_id, pk):
issue_view = (
self.get_queryset().filter(pk=pk, project_id=project_id).first()
)
serializer = IssueViewSerializer(issue_view)
recent_visited_task.delay(
slug=slug,
project_id=project_id,
entity_name="view",
entity_identifier=pk,
user_id=request.user.id,
)
return Response(
serializer.data,
status=status.HTTP_200_OK,
)
allow_permission(allowed_roles=[], creator=True, model=IssueView)
def partial_update(self, request, slug, project_id, pk):
with transaction.atomic():
issue_view = IssueView.objects.select_for_update().get(
@@ -500,8 +428,6 @@ class IssueViewViewSet(BaseViewSet):
serializer.errors, status=status.HTTP_400_BAD_REQUEST
)
allow_permission(allowed_roles=[ROLE.ADMIN], creator=True, model=IssueView)
def destroy(self, request, slug, project_id, pk):
project_view = IssueView.objects.get(
pk=pk,
@@ -546,8 +472,6 @@ class IssueViewFavoriteViewSet(BaseViewSet):
.select_related("view")
)
allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def create(self, request, slug, project_id):
_ = UserFavorite.objects.create(
user=request.user,
@@ -557,8 +481,6 @@ class IssueViewFavoriteViewSet(BaseViewSet):
)
return Response(status=status.HTTP_204_NO_CONTENT)
allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def destroy(self, request, slug, project_id, view_id):
view_favorite = UserFavorite.objects.get(
project=project_id,
+10 -7
View File
@@ -9,13 +9,15 @@ from rest_framework.response import Response
from plane.db.models import Webhook, WebhookLog, Workspace
from plane.db.models.webhook import generate_token
from ..base import BaseAPIView
from plane.app.permissions import allow_permission, ROLE
from plane.app.permissions import WorkspaceOwnerPermission
from plane.app.serializers import WebhookSerializer, WebhookLogSerializer
class WebhookEndpoint(BaseAPIView):
permission_classes = [
WorkspaceOwnerPermission,
]
@allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE")
def post(self, request, slug):
workspace = Workspace.objects.get(slug=slug)
try:
@@ -38,7 +40,6 @@ class WebhookEndpoint(BaseAPIView):
)
raise IntegrityError
@allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE")
def get(self, request, slug, pk=None):
if pk is None:
webhooks = Webhook.objects.filter(workspace__slug=slug)
@@ -78,7 +79,6 @@ class WebhookEndpoint(BaseAPIView):
)
return Response(serializer.data, status=status.HTTP_200_OK)
@allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE")
def patch(self, request, slug, pk):
webhook = Webhook.objects.get(workspace__slug=slug, pk=pk)
serializer = WebhookSerializer(
@@ -104,7 +104,6 @@ class WebhookEndpoint(BaseAPIView):
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE")
def delete(self, request, slug, pk):
webhook = Webhook.objects.get(pk=pk, workspace__slug=slug)
webhook.delete()
@@ -112,8 +111,10 @@ class WebhookEndpoint(BaseAPIView):
class WebhookSecretRegenerateEndpoint(BaseAPIView):
permission_classes = [
WorkspaceOwnerPermission,
]
@allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE")
def post(self, request, slug, pk):
webhook = Webhook.objects.get(workspace__slug=slug, pk=pk)
webhook.secret_key = generate_token()
@@ -123,8 +124,10 @@ class WebhookSecretRegenerateEndpoint(BaseAPIView):
class WebhookLogsEndpoint(BaseAPIView):
permission_classes = [
WorkspaceOwnerPermission,
]
@allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE")
def get(self, request, slug, webhook_id):
webhook_logs = WebhookLog.objects.filter(
workspace__slug=slug, webhook_id=webhook_id
@@ -9,14 +9,14 @@ from django.db.models import Q
from plane.app.views.base import BaseAPIView
from plane.db.models import UserFavorite, Workspace
from plane.app.serializers import UserFavoriteSerializer
from plane.app.permissions import allow_permission, ROLE
from plane.app.permissions import WorkspaceEntityPermission
class WorkspaceFavoriteEndpoint(BaseAPIView):
permission_classes = [
WorkspaceEntityPermission,
]
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE"
)
def get(self, request, slug):
# the second filter is to check if the user is a member of the project
favorites = UserFavorite.objects.filter(
@@ -34,9 +34,6 @@ class WorkspaceFavoriteEndpoint(BaseAPIView):
serializer = UserFavoriteSerializer(favorites, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE"
)
def post(self, request, slug):
workspace = Workspace.objects.get(slug=slug)
serializer = UserFavoriteSerializer(data=request.data)
@@ -49,9 +46,6 @@ class WorkspaceFavoriteEndpoint(BaseAPIView):
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE"
)
def patch(self, request, slug, favorite_id):
favorite = UserFavorite.objects.get(
user=request.user, workspace__slug=slug, pk=favorite_id
@@ -64,9 +58,6 @@ class WorkspaceFavoriteEndpoint(BaseAPIView):
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE"
)
def delete(self, request, slug, favorite_id):
favorite = UserFavorite.objects.get(
user=request.user, workspace__slug=slug, pk=favorite_id
@@ -76,10 +67,10 @@ class WorkspaceFavoriteEndpoint(BaseAPIView):
class WorkspaceFavoriteGroupEndpoint(BaseAPIView):
permission_classes = [
WorkspaceEntityPermission,
]
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE"
)
def get(self, request, slug, favorite_id):
favorites = UserFavorite.objects.filter(
user=request.user,
+1 -1
View File
@@ -13,7 +13,7 @@ class WorkspaceLabelsEndpoint(BaseAPIView):
permission_classes = [
WorkspaceViewerPermission,
]
@cache_response(60 * 60 * 2)
def get(self, request, slug):
labels = Label.objects.filter(
@@ -1697,6 +1697,23 @@ def issue_activity(
)
# Post the updates to segway for integrations and webhooks
if len(issue_activities_created):
# Don't send activities if the actor is a bot
try:
if settings.PROXY_BASE_URL:
for issue_activity in issue_activities_created:
headers = {"Content-Type": "application/json"}
issue_activity_json = json.dumps(
IssueActivitySerializer(issue_activity).data,
cls=DjangoJSONEncoder,
)
_ = requests.post(
f"{settings.PROXY_BASE_URL}/hooks/workspaces/{str(issue_activity.workspace_id)}/projects/{str(issue_activity.project_id)}/issues/{str(issue_activity.issue_id)}/issue-activity-hooks/",
json=issue_activity_json,
headers=headers,
)
except Exception as e:
log_exception(e)
for activity in issue_activities_created:
webhook_activity.delay(
event=(
@@ -10,7 +10,7 @@ from django.db.models import Q
from django.utils import timezone
# Module imports
from plane.bgtasks.issue_activities_task import issue_activity
from plane.bgtasks.issue_activites_task import issue_activity
from plane.db.models import Issue, Project, State
from plane.utils.exception_logger import log_exception
@@ -1,61 +0,0 @@
# Python imports
from django.utils import timezone
# Third party imports
from celery import shared_task
# Module imports
from plane.db.models import UserRecentVisit, Workspace
from plane.utils.exception_logger import log_exception
@shared_task
def recent_visited_task(
entity_name, entity_identifier, user_id, project_id, slug
):
try:
workspace = Workspace.objects.get(slug=slug)
recent_visited = UserRecentVisit.objects.filter(
entity_name=entity_name,
entity_identifier=entity_identifier,
user_id=user_id,
project_id=project_id,
workspace_id=workspace.id,
).first()
if recent_visited:
recent_visited.visited_at = timezone.now()
recent_visited.save(update_fields=["visited_at"])
else:
recent_visited_count = UserRecentVisit.objects.filter(
user_id=user_id, workspace_id=workspace.id
).count()
if recent_visited_count == 20:
recent_visited = (
UserRecentVisit.objects.filter(
user_id=user_id, workspace_id=workspace.id
)
.order_by("created_at")
.first()
)
recent_visited.delete()
recent_activity = UserRecentVisit.objects.create(
entity_name=entity_name,
entity_identifier=entity_identifier,
user_id=user_id,
visited_at=timezone.now(),
project_id=project_id,
workspace_id=workspace.id,
)
recent_activity.created_by_id = user_id
recent_activity.updated_by_id = user_id
recent_activity.save(
update_fields=["created_by_id", "updated_by_id"]
)
return
except Exception as e:
log_exception(e)
return
@@ -1,203 +0,0 @@
# Generated by Django 4.2.11 on 2024-08-13 16:21
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import uuid
class Migration(migrations.Migration):
dependencies = [
("db", "0073_alter_commentreaction_unique_together_and_more"),
]
operations = [
migrations.AddField(
model_name="deployboard",
name="is_activity_enabled",
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name="fileasset",
name="is_archived",
field=models.BooleanField(default=False),
),
migrations.AlterField(
model_name="userfavorite",
name="sequence",
field=models.FloatField(default=65535),
),
migrations.CreateModel(
name="ProjectIssueType",
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"
),
),
(
"deleted_at",
models.DateTimeField(
blank=True, null=True, verbose_name="Deleted At"
),
),
(
"id",
models.UUIDField(
db_index=True,
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
unique=True,
),
),
("level", models.PositiveIntegerField(default=0)),
("is_default", models.BooleanField(default=False)),
],
options={
"verbose_name": "Project Issue Type",
"verbose_name_plural": "Project Issue Types",
"db_table": "project_issue_types",
"ordering": ("project", "issue_type"),
},
),
migrations.AlterModelOptions(
name="issuetype",
options={
"verbose_name": "Issue Type",
"verbose_name_plural": "Issue Types",
},
),
migrations.RemoveConstraint(
model_name="issuetype",
name="issue_type_unique_name_project_when_deleted_at_null",
),
migrations.AlterUniqueTogether(
name="issuetype",
unique_together=set(),
),
migrations.AlterField(
model_name="issuetype",
name="workspace",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="issue_types",
to="db.workspace",
),
),
migrations.AlterUniqueTogether(
name="issuetype",
unique_together={("workspace", "name", "deleted_at")},
),
migrations.AddConstraint(
model_name="issuetype",
constraint=models.UniqueConstraint(
condition=models.Q(("deleted_at__isnull", True)),
fields=("name", "workspace"),
name="issue_type_unique_name_workspace_when_deleted_at_null",
),
),
migrations.AddField(
model_name="projectissuetype",
name="created_by",
field=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",
),
),
migrations.AddField(
model_name="projectissuetype",
name="issue_type",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="project_issue_types",
to="db.issuetype",
),
),
migrations.AddField(
model_name="projectissuetype",
name="project",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="project_%(class)s",
to="db.project",
),
),
migrations.AddField(
model_name="projectissuetype",
name="updated_by",
field=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",
),
),
migrations.AddField(
model_name="projectissuetype",
name="workspace",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="workspace_%(class)s",
to="db.workspace",
),
),
migrations.RemoveField(
model_name="issuetype",
name="is_default",
),
migrations.RemoveField(
model_name="issuetype",
name="project",
),
migrations.RemoveField(
model_name="issuetype",
name="sort_order",
),
migrations.RemoveField(
model_name="issuetype",
name="weight",
),
migrations.AddConstraint(
model_name="projectissuetype",
constraint=models.UniqueConstraint(
condition=models.Q(("deleted_at__isnull", True)),
fields=("project", "issue_type"),
name="project_issue_type_unique_project_issue_type_when_deleted_at_null",
),
),
migrations.AlterUniqueTogether(
name="projectissuetype",
unique_together={("project", "issue_type", "deleted_at")},
),
migrations.AddField(
model_name="issuetype",
name="is_default",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="issuetype",
name="level",
field=models.PositiveIntegerField(default=0),
),
migrations.AlterUniqueTogether(
name="issuetype",
unique_together=set(),
),
migrations.RemoveConstraint(
model_name="issuetype",
name="issue_type_unique_name_workspace_when_deleted_at_null",
),
]
+1 -1
View File
@@ -111,4 +111,4 @@ from .favorite import UserFavorite
from .issue_type import IssueType
from .recent_visit import UserRecentVisit
from .recent_visit import UserRecentVisit
-1
View File
@@ -42,7 +42,6 @@ class FileAsset(BaseModel):
related_name="assets",
)
is_deleted = models.BooleanField(default=False)
is_archived = models.BooleanField(default=False)
class Meta:
verbose_name = "File Asset"
@@ -40,7 +40,6 @@ class DeployBoard(WorkspaceBaseModel):
)
is_votes_enabled = models.BooleanField(default=False)
view_props = models.JSONField(default=dict)
is_activity_enabled = models.BooleanField(default=True)
def __str__(self):
"""Return name of the deploy board"""
+2 -7
View File
@@ -21,7 +21,7 @@ class UserFavorite(WorkspaceBaseModel):
entity_identifier = models.UUIDField(null=True, blank=True)
name = models.CharField(max_length=255, blank=True, null=True)
is_folder = models.BooleanField(default=False)
sequence = models.FloatField(default=65535)
sequence = models.IntegerField(default=65535)
parent = models.ForeignKey(
"self",
on_delete=models.CASCADE,
@@ -31,12 +31,7 @@ class UserFavorite(WorkspaceBaseModel):
)
class Meta:
unique_together = [
"entity_type",
"user",
"entity_identifier",
"deleted_at",
]
unique_together = ["entity_type", "user", "entity_identifier", "deleted_at"]
constraints = [
models.UniqueConstraint(
fields=["entity_type", "entity_identifier", "user"],
+24 -35
View File
@@ -3,54 +3,43 @@ from django.db import models
from django.db.models import Q
# Module imports
from .project import ProjectBaseModel
from .base import BaseModel
from .workspace import WorkspaceBaseModel
class IssueType(BaseModel):
workspace = models.ForeignKey(
"db.Workspace",
related_name="issue_types",
on_delete=models.CASCADE,
)
class IssueType(WorkspaceBaseModel):
name = models.CharField(max_length=255)
description = models.TextField(blank=True)
logo_props = models.JSONField(default=dict)
sort_order = models.FloatField(default=65535)
is_default = models.BooleanField(default=False)
weight = models.PositiveIntegerField(default=0)
is_active = models.BooleanField(default=True)
level = models.PositiveIntegerField(default=0)
class Meta:
unique_together = ["project", "name", "deleted_at"]
constraints = [
models.UniqueConstraint(
fields=["name", "project"],
condition=Q(deleted_at__isnull=True),
name="issue_type_unique_name_project_when_deleted_at_null",
)
]
verbose_name = "Issue Type"
verbose_name_plural = "Issue Types"
db_table = "issue_types"
ordering = ("sort_order",)
def __str__(self):
return self.name
class ProjectIssueType(ProjectBaseModel):
issue_type = models.ForeignKey(
"db.IssueType",
related_name="project_issue_types",
on_delete=models.CASCADE,
)
level = models.PositiveIntegerField(default=0)
is_default = models.BooleanField(default=False)
class Meta:
unique_together = ["project", "issue_type", "deleted_at"]
constraints = [
models.UniqueConstraint(
fields=["project", "issue_type"],
condition=Q(deleted_at__isnull=True),
name="project_issue_type_unique_project_issue_type_when_deleted_at_null",
)
]
verbose_name = "Project Issue Type"
verbose_name_plural = "Project Issue Types"
db_table = "project_issue_types"
ordering = ("project", "issue_type")
def __str__(self):
return f"{self.project} - {self.issue_type}"
def save(self, *args, **kwargs):
# If we are adding a new issue type, we need to set the sort order
if self._state.adding:
# Get the largest sort order for the project
largest_sort_order = IssueType.objects.filter(
project=self.project
).aggregate(largest=models.Max("sort_order"))["largest"]
# If there are issue types, set the sort order to the largest + 10000
if largest_sort_order is not None:
self.sort_order = largest_sort_order + 10000
super(IssueType, self).save(*args, **kwargs)
+3
View File
@@ -299,6 +299,9 @@ if bool(os.environ.get("SENTRY_DSN", False)) and os.environ.get(
)
# Application Envs
PROXY_BASE_URL = os.environ.get("PROXY_BASE_URL", False) # For External
FILE_SIZE_LIMIT = int(os.environ.get("FILE_SIZE_LIMIT", 5242880))
# Unsplash Access key
+1 -1
View File
@@ -27,7 +27,7 @@ from plane.app.serializers import (
IssueStateInboxSerializer,
)
from plane.utils.issue_filters import issue_filters
from plane.bgtasks.issue_activities_task import issue_activity
from plane.bgtasks.issue_activites_task import issue_activity
class InboxIssuePublicViewSet(BaseViewSet):
+2 -2
View File
@@ -18,7 +18,7 @@ from django.db.models import (
JSONField,
Value,
OuterRef,
Func,
Func
)
# Third Party imports
@@ -61,7 +61,7 @@ from plane.db.models import (
ProjectPublicMember,
IssueAttachment,
)
from plane.bgtasks.issue_activities_task import issue_activity
from plane.bgtasks.issue_activites_task import issue_activity
from plane.utils.issue_filters import issue_filters
-10
View File
@@ -1,10 +0,0 @@
ERROR_CODES = {
# issues
"INVALID_ARCHIVE_STATE_GROUP": 4091,
"INVALID_ISSUE_DATES": 4100,
"INVALID_ISSUE_START_DATE": 4101,
"INVALID_ISSUE_TARGET_DATE": 4102,
# pages
"PAGE_LOCKED": 4701,
"PAGE_ARCHIVED": 4702,
}
-78
View File
@@ -1,78 +0,0 @@
# constants
PAGINATOR_MAX_LIMIT = 1000
class PaginateCursor:
def __init__(self, current_page_size: int, current_page: int, offset: int):
self.current_page_size = current_page_size
self.current_page = current_page
self.offset = offset
def __str__(self):
return f"{self.current_page_size}:{self.current_page}:{self.offset}"
@classmethod
def from_string(self, value):
"""Return the cursor value from string format"""
try:
bits = value.split(":")
if len(bits) != 3:
raise ValueError(
"Cursor must be in the format 'value:offset:is_prev'"
)
return self(int(bits[0]), int(bits[1]), int(bits[2]))
except (TypeError, ValueError) as e:
raise ValueError(f"Invalid cursor format: {e}")
def paginate(base_queryset, queryset, cursor, on_result):
# validating for cursor
if cursor is None:
cursor_object = PaginateCursor(PAGINATOR_MAX_LIMIT, 0, 0)
else:
cursor_object = PaginateCursor.from_string(cursor)
# getting the issues count
total_results = base_queryset.count()
page_size = min(cursor_object.current_page_size, PAGINATOR_MAX_LIMIT)
# Calculate the start and end index for the paginated data
start_index = 0
if cursor_object.current_page > 0:
start_index = cursor_object.current_page * page_size
end_index = min(start_index + page_size, total_results)
# Get the paginated data
paginated_data = queryset[start_index:end_index]
# Create the pagination info object
prev_cursor = f"{page_size}:{cursor_object.current_page-1}:0"
cursor = f"{page_size}:{cursor_object.current_page}:0"
next_cursor = None
if end_index < total_results:
next_cursor = f"{page_size}:{cursor_object.current_page+1}:0"
prev_page_results = False
if cursor_object.current_page > 0:
prev_page_results = True
next_page_results = False
if next_cursor:
next_page_results = True
if on_result:
paginated_data = on_result(paginated_data)
# returning the result
paginated_data = {
"prev_cursor": prev_cursor,
"cursor": cursor,
"next_cursor": next_cursor,
"prev_page_results": prev_page_results,
"next_page_results": next_page_results,
"page_count": len(paginated_data),
"total_results": total_results,
"results": paginated_data,
}
return paginated_data
+1 -1
View File
@@ -138,7 +138,7 @@ services:
plane-db:
<<: *app-env
image: postgres:15.7-alpine
image: postgres:15.5-alpine
pull_policy: if_not_present
restart: unless-stopped
command: postgres -c 'max_connections=1000'
+1 -1
View File
@@ -31,7 +31,7 @@ services:
MINIO_ROOT_PASSWORD: ${AWS_SECRET_ACCESS_KEY}
plane-db:
image: postgres:15.7-alpine
image: postgres:15.2-alpine
restart: unless-stopped
networks:
- dev_env
+1 -1
View File
@@ -101,7 +101,7 @@ services:
plane-db:
container_name: plane-db
image: postgres:15.7-alpine
image: postgres:15.2-alpine
restart: always
command: postgres -c 'max_connections=1000'
volumes:
+1 -1
View File
@@ -34,7 +34,7 @@
"prettier": "latest",
"prettier-plugin-tailwindcss": "^0.5.4",
"tailwindcss": "^3.3.3",
"turbo": "^2.0.14"
"turbo": "^2.0.11"
},
"resolutions": {
"@types/react": "18.2.48"
-1
View File
@@ -32,7 +32,6 @@
"@plane/ui": "*",
"@tiptap/core": "^2.1.13",
"@tiptap/extension-blockquote": "^2.1.13",
"@tiptap/extension-character-count": "^2.6.5",
"@tiptap/extension-collaboration": "^2.3.2",
"@tiptap/extension-image": "^2.1.13",
"@tiptap/extension-list-item": "^2.1.13",
@@ -0,0 +1,13 @@
// extensions
import { SideMenuHandleOptions, SideMenuPluginProps } from "@/extensions";
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export const AIHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOptions => {
const view = () => {};
const domEvents = {};
return {
view,
domEvents,
};
};
@@ -0,0 +1 @@
export * from "./handle";
@@ -1 +1,2 @@
export * from "./ai-features";
export * from "./document-extensions";
@@ -14,14 +14,12 @@ import {
EditorRefApi,
IMentionHighlight,
IMentionSuggestion,
TAIHandler,
TDisplayConfig,
TExtensions,
TFileHandler,
} from "@/types";
interface IDocumentEditor {
aiHandler?: TAIHandler;
containerClassName?: string;
disabledExtensions?: TExtensions[];
displayConfig?: TDisplayConfig;
@@ -43,7 +41,6 @@ interface IDocumentEditor {
const DocumentEditor = (props: IDocumentEditor) => {
const {
aiHandler,
containerClassName,
disabledExtensions,
displayConfig = DEFAULT_DISPLAY_CONFIG,
@@ -87,7 +84,6 @@ const DocumentEditor = (props: IDocumentEditor) => {
return (
<PageRenderer
displayConfig={displayConfig}
aiHandler={aiHandler}
editor={editor}
editorContainerClassName={editorContainerClassNames}
id={id}
@@ -15,12 +15,11 @@ import { Editor, ReactRenderer } from "@tiptap/react";
// components
import { EditorContainer, EditorContentWrapper } from "@/components/editors";
import { LinkView, LinkViewProps } from "@/components/links";
import { AIFeaturesMenu, BlockMenu } from "@/components/menus";
import { BlockMenu } from "@/components/menus";
// types
import { TAIHandler, TDisplayConfig } from "@/types";
import { TDisplayConfig } from "@/types";
type IPageRenderer = {
aiHandler?: TAIHandler;
displayConfig: TDisplayConfig;
editor: Editor;
editorContainerClassName: string;
@@ -29,7 +28,7 @@ type IPageRenderer = {
};
export const PageRenderer = (props: IPageRenderer) => {
const { aiHandler, displayConfig, editor, editorContainerClassName, id, tabIndex } = props;
const { displayConfig, editor, editorContainerClassName, id, tabIndex } = props;
// states
const [linkViewProps, setLinkViewProps] = useState<LinkViewProps>();
const [isOpen, setIsOpen] = useState(false);
@@ -139,12 +138,7 @@ export const PageRenderer = (props: IPageRenderer) => {
id={id}
>
<EditorContentWrapper editor={editor} id={id} tabIndex={tabIndex} />
{editor.isEditable && (
<>
<BlockMenu editor={editor} />
<AIFeaturesMenu menu={aiHandler?.menu} />
</>
)}
{editor.isEditable && <BlockMenu editor={editor} />}
</EditorContainer>
</div>
{isOpen && linkViewProps && coordinates && (
@@ -1,96 +0,0 @@
import { useCallback, useEffect, useRef, useState } from "react";
import tippy, { Instance } from "tippy.js";
// helpers
import { cn } from "@/helpers/common";
// types
import { TAIHandler } from "@/types";
type Props = {
menu: TAIHandler["menu"];
};
export const AIFeaturesMenu: React.FC<Props> = (props) => {
const { menu } = props;
// states
const [isPopupVisible, setIsPopupVisible] = useState(false);
// refs
const menuRef = useRef<HTMLDivElement>(null);
const popup = useRef<Instance | null>(null);
useEffect(() => {
if (!menuRef.current) return;
menuRef.current.remove();
menuRef.current.style.visibility = "visible";
// @ts-expect-error - tippy types are incorrect
popup.current = tippy(document.body, {
getReferenceClientRect: null,
content: menuRef.current,
appendTo: () => document.querySelector(".frame-renderer"),
trigger: "manual",
interactive: true,
arrow: false,
placement: "bottom-start",
animation: "shift-away",
hideOnClick: true,
onShown: () => menuRef.current?.focus(),
});
return () => {
popup.current?.destroy();
popup.current = null;
};
}, []);
const hidePopup = useCallback(() => {
popup.current?.hide();
setIsPopupVisible(false);
}, []);
useEffect(() => {
const handleClickAIHandle = (e: MouseEvent) => {
const target = e.target as HTMLElement;
if (target.matches("#ai-handle") || menuRef.current?.contains(e.target as Node)) {
e.preventDefault();
if (!isPopupVisible) {
popup.current?.setProps({
getReferenceClientRect: () => target.getBoundingClientRect(),
});
popup.current?.show();
setIsPopupVisible(true);
}
return;
}
hidePopup();
return;
};
document.addEventListener("click", handleClickAIHandle);
document.addEventListener("contextmenu", handleClickAIHandle);
document.addEventListener("keydown", hidePopup);
return () => {
document.removeEventListener("click", handleClickAIHandle);
document.removeEventListener("contextmenu", handleClickAIHandle);
document.removeEventListener("keydown", hidePopup);
};
}, [hidePopup, isPopupVisible]);
return (
<div
className={cn("opacity-0 pointer-events-none fixed inset-0 size-full z-10 transition-opacity", {
"opacity-100 pointer-events-auto": isPopupVisible,
})}
>
<div ref={menuRef} className="z-10">
{menu?.({
isOpen: isPopupVisible,
onClose: hidePopup,
})}
</div>
</div>
);
};
@@ -1,4 +1,3 @@
export * from "./ai-menu";
export * from "./bubble-menu";
export * from "./block-menu";
export * from "./menu-items";
@@ -1,6 +1,6 @@
import { Extension } from "@tiptap/core";
export const EnterKeyExtension = (onEnterKeyPress?: () => void) =>
export const EnterKeyExtension = (onEnterKeyPress?: (descriptionHTML: string) => void) =>
Extension.create({
name: "enterKey",
@@ -8,9 +8,7 @@ export const EnterKeyExtension = (onEnterKeyPress?: () => void) =>
return {
Enter: () => {
if (!this.editor.storage.mentionsOpen) {
if (onEnterKeyPress) {
onEnterKeyPress();
}
onEnterKeyPress?.(this.editor.getHTML());
return true;
}
return false;
@@ -1,4 +1,3 @@
import CharacterCount from "@tiptap/extension-character-count";
import Placeholder from "@tiptap/extension-placeholder";
import TaskItem from "@tiptap/extension-task-item";
import TaskList from "@tiptap/extension-task-list";
@@ -158,5 +157,4 @@ export const CoreEditorExtensions = ({
},
includeChildren: true,
}),
CharacterCount,
];
@@ -1,8 +1,8 @@
import { Extension } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { EditorView } from "@tiptap/pm/view";
// plugins
import { AIHandlePlugin } from "@/plugins/ai-handle";
// plane editor extensions
import { AIHandlePlugin } from "@/plane-editor/extensions";
import { DragHandlePlugin } from "@/plugins/drag-handle";
type Props = {
@@ -105,7 +105,7 @@ const SideMenu = (options: SideMenuPluginProps) => {
const showSideMenu = () => editorSideMenu?.classList.remove("side-menu-hidden");
// side menu elements
const { view: dragHandleView, domEvents: dragHandleDOMEvents } = DragHandlePlugin(options);
const { view: aiHandleView, domEvents: aiHandleDOMEvents } = AIHandlePlugin(options);
const { view: aiHandleView } = AIHandlePlugin(options);
return new Plugin({
key: new PluginKey("sideMenu"),
@@ -113,12 +113,12 @@ const SideMenu = (options: SideMenuPluginProps) => {
hideSideMenu();
view?.dom.parentElement?.appendChild(editorSideMenu);
// side menu elements' initialization
if (handlesConfig.ai) {
aiHandleView(view, editorSideMenu);
}
if (handlesConfig.dragDrop) {
dragHandleView(view, editorSideMenu);
}
if (handlesConfig.ai) {
aiHandleView(view, editorSideMenu);
}
return {
destroy: () => hideSideMenu(),
@@ -175,14 +175,9 @@ const SideMenu = (options: SideMenuPluginProps) => {
editorSideMenu.style.left = `${rect.left - rect.width}px`;
editorSideMenu.style.top = `${rect.top}px`;
showSideMenu();
if (handlesConfig.dragDrop) {
dragHandleDOMEvents?.mousemove();
}
if (handlesConfig.ai) {
aiHandleDOMEvents?.mousemove?.();
}
dragHandleDOMEvents?.mousemove();
},
// keydown: () => hideSideMenu(),
keydown: () => hideSideMenu(),
mousewheel: () => hideSideMenu(),
dragenter: (view) => {
if (handlesConfig.dragDrop) {
+3 -10
View File
@@ -1,5 +1,7 @@
import { EditorState, Selection } from "@tiptap/pm/state";
import { Extensions, generateJSON, getSchema } from "@tiptap/core";
import { Selection } from "@tiptap/pm/state";
import { clsx, type ClassValue } from "clsx";
import { CoreEditorExtensionsWithoutProps } from "src/core/extensions/core-without-props";
import { twMerge } from "tailwind-merge";
interface EditorClassNames {
@@ -59,12 +61,3 @@ export const isValidHttpUrl = (string: string): boolean => {
return url.protocol === "http:" || url.protocol === "https:";
};
export const getParagraphCount = (editorState: EditorState | undefined) => {
if (!editorState) return 0;
let paragraphCount = 0;
editorState.doc.descendants((node) => {
if (node.type.name === "paragraph" && node.content.size > 0) paragraphCount++;
});
return paragraphCount;
};
+2 -44
View File
@@ -1,5 +1,4 @@
import { useImperativeHandle, useRef, MutableRefObject, useState, useEffect } from "react";
import { DOMSerializer } from "@tiptap/pm/model";
import { Selection } from "@tiptap/pm/state";
import { EditorProps } from "@tiptap/pm/view";
import { useEditor as useTiptapEditor, Editor } from "@tiptap/react";
@@ -8,7 +7,6 @@ import { getEditorMenuItems } from "@/components/menus";
// extensions
import { CoreEditorExtensions } from "@/extensions";
// helpers
import { getParagraphCount } from "@/helpers/common";
import { insertContentAtSavedSelection } from "@/helpers/insert-content-at-cursor-position";
import { IMarking, scrollSummary } from "@/helpers/scroll-to-node";
// plane editor providers
@@ -127,8 +125,8 @@ export const useEditor = (props: CustomEditorProps) => {
useImperativeHandle(
forwardedRef,
() => ({
clearEditor: (emitUpdate = false) => {
editorRef.current?.commands.clearContent(emitUpdate);
clearEditor: () => {
editorRef.current?.commands.clearContent();
},
setEditorValue: (content: string) => {
editorRef.current?.commands.setContent(content);
@@ -215,46 +213,6 @@ export const useEditor = (props: CustomEditorProps) => {
console.error("An error occurred while setting focus at position:", error);
}
},
getSelectedText: () => {
if (!editorRef.current) return null;
const { state } = editorRef.current;
const { from, to, empty } = state.selection;
if (empty) return null;
const nodesArray: string[] = [];
state.doc.nodesBetween(from, to, (node, pos, parent) => {
if (parent === state.doc && editorRef.current) {
const serializer = DOMSerializer.fromSchema(editorRef.current?.schema);
const dom = serializer.serializeNode(node);
const tempDiv = document.createElement("div");
tempDiv.appendChild(dom);
nodesArray.push(tempDiv.innerHTML);
}
});
const selection = nodesArray.join("");
console.log(selection);
return selection;
},
insertText: (contentHTML, insertOnNextLine) => {
if (!editor) return;
// get selection
const { from, to, empty } = editor.state.selection;
if (empty) return;
if (insertOnNextLine) {
// move cursor to the end of the selection and insert a new line
editor.chain().focus().setTextSelection(to).insertContent("<br />").insertContent(contentHTML).run();
} else {
// replace selected text with the content provided
editor.chain().focus().deleteRange({ from, to }).insertContent(contentHTML).run();
}
},
documentInfo: {
characters: editorRef.current?.storage?.characterCount?.characters?.() ?? 0,
paragraphs: getParagraphCount(editorRef.current?.state),
words: editorRef.current?.storage?.characterCount?.words?.() ?? 0,
},
}),
[editorRef, savedSelection, fileHandler.upload]
);
@@ -4,7 +4,6 @@ import { useEditor as useCustomEditor, Editor } from "@tiptap/react";
// extensions
import { CoreReadOnlyEditorExtensions } from "@/extensions";
// helpers
import { getParagraphCount } from "@/helpers/common";
import { IMarking, scrollSummary } from "@/helpers/scroll-to-node";
// props
import { CoreReadOnlyEditorProps } from "@/props";
@@ -82,11 +81,6 @@ export const useReadOnlyEditor = ({
if (!editorRef.current) return;
scrollSummary(editorRef.current, marking);
},
documentInfo: {
characters: editorRef.current?.storage?.characterCount?.characters?.() ?? 0,
paragraphs: getParagraphCount(editorRef.current?.state),
words: editorRef.current?.storage?.characterCount?.words?.() ?? 0,
},
}));
if (!editor) {
@@ -1,153 +0,0 @@
import { NodeSelection } from "@tiptap/pm/state";
import { EditorView } from "@tiptap/pm/view";
// extensions
import { SideMenuHandleOptions, SideMenuPluginProps } from "@/extensions";
const sparklesIcon =
'<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-sparkles"><path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z"/><path d="M20 3v4"/><path d="M22 5h-4"/><path d="M4 17v2"/><path d="M5 18H3"/></svg>';
const nodeDOMAtCoords = (coords: { x: number; y: number }) => {
const elements = document.elementsFromPoint(coords.x, coords.y);
const generalSelectors = [
"li",
"p:not(:first-child)",
".code-block",
"blockquote",
"img",
"h1, h2, h3, h4, h5, h6",
"[data-type=horizontalRule]",
".table-wrapper",
".issue-embed",
].join(", ");
for (const elem of elements) {
if (elem.matches("p:first-child") && elem.parentElement?.matches(".ProseMirror")) {
return elem;
}
// if the element is a <p> tag that is the first child of a td or th
if (
(elem.matches("td > p:first-child") || elem.matches("th > p:first-child")) &&
elem?.textContent?.trim() !== ""
) {
return elem; // Return only if p tag is not empty in td or th
}
// apply general selector
if (elem.matches(generalSelectors)) {
return elem;
}
}
return null;
};
const nodePosAtDOM = (node: Element, view: EditorView, options: SideMenuPluginProps) => {
const boundingRect = node.getBoundingClientRect();
return view.posAtCoords({
left: boundingRect.left + 50 + options.dragHandleWidth,
top: boundingRect.top + 1,
})?.inside;
};
const nodePosAtDOMForBlockQuotes = (node: Element, view: EditorView) => {
const boundingRect = node.getBoundingClientRect();
return view.posAtCoords({
left: boundingRect.left + 1,
top: boundingRect.top + 1,
})?.inside;
};
const calcNodePos = (pos: number, view: EditorView, node: Element) => {
const maxPos = view.state.doc.content.size;
const safePos = Math.max(0, Math.min(pos, maxPos));
const $pos = view.state.doc.resolve(safePos);
if ($pos.depth > 1) {
if (node.matches("ul li, ol li")) {
// only for nested lists
const newPos = $pos.before($pos.depth);
return Math.max(0, Math.min(newPos, maxPos));
}
}
return safePos;
};
export const AIHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOptions => {
let aiHandleElement: HTMLButtonElement | null = null;
const handleClick = (event: MouseEvent, view: EditorView) => {
view.focus();
const node = nodeDOMAtCoords({
x: event.clientX + 50 + options.dragHandleWidth,
y: event.clientY,
});
if (!(node instanceof Element)) return;
if (node.matches("blockquote")) {
let nodePosForBlockQuotes = nodePosAtDOMForBlockQuotes(node, view);
if (nodePosForBlockQuotes === null || nodePosForBlockQuotes === undefined) return;
const docSize = view.state.doc.content.size;
nodePosForBlockQuotes = Math.max(0, Math.min(nodePosForBlockQuotes, docSize));
if (nodePosForBlockQuotes >= 0 && nodePosForBlockQuotes <= docSize) {
// TODO FIX ERROR
const nodeSelection = NodeSelection.create(view.state.doc, nodePosForBlockQuotes);
view.dispatch(view.state.tr.setSelection(nodeSelection));
}
return;
}
let nodePos = nodePosAtDOM(node, view, options);
if (nodePos === null || nodePos === undefined) return;
// Adjust the nodePos to point to the start of the node, ensuring NodeSelection can be applied
nodePos = calcNodePos(nodePos, view, node);
// TODO FIX ERROR
// Use NodeSelection to select the node at the calculated position
const nodeSelection = NodeSelection.create(view.state.doc, nodePos);
// Dispatch the transaction to update the selection
view.dispatch(view.state.tr.setSelection(nodeSelection));
};
const view = (view: EditorView, sideMenu: HTMLDivElement | null) => {
// create handle element
const className =
"grid place-items-center font-medium size-5 aspect-square text-xs text-custom-text-300 hover:bg-custom-background-80 rounded-sm opacity-100 !outline-none z-[5] transition-[background-color,_opacity] duration-200 ease-linear";
aiHandleElement = document.createElement("button");
aiHandleElement.type = "button";
aiHandleElement.id = "ai-handle";
aiHandleElement.classList.value = className;
const iconElement = document.createElement("span");
iconElement.classList.value = "pointer-events-none";
iconElement.innerHTML = sparklesIcon;
aiHandleElement.appendChild(iconElement);
// bind events
aiHandleElement.addEventListener("click", (e) => handleClick(e, view));
sideMenu?.appendChild(aiHandleElement);
return {
// destroy the handle element on un-initialize
destroy: () => {
aiHandleElement?.remove();
aiHandleElement = null;
},
};
};
const domEvents = {};
return {
view,
domEvents,
};
};
-8
View File
@@ -1,8 +0,0 @@
export type TAIMenuProps = {
isOpen: boolean;
onClose: () => void;
};
export type TAIHandler = {
menu?: (props: TAIMenuProps) => React.ReactNode;
};
+2 -9
View File
@@ -6,14 +6,9 @@ import { IMentionHighlight, IMentionSuggestion, TDisplayConfig, TEditorCommands,
export type EditorReadOnlyRefApi = {
getMarkDown: () => string;
getHTML: () => string;
clearEditor: (emitUpdate?: boolean) => void;
clearEditor: () => void;
setEditorValue: (content: string) => void;
scrollSummary: (marking: IMarking) => void;
documentInfo: {
characters: number;
paragraphs: number;
words: number;
};
};
export interface EditorRefApi extends EditorReadOnlyRefApi {
@@ -25,8 +20,6 @@ export interface EditorRefApi extends EditorReadOnlyRefApi {
isEditorReadyToDiscard: () => boolean;
setSynced: () => void;
hasUnsyncedChanges: () => boolean;
getSelectedText: () => string | null;
insertText: (contentHTML: string, insertOnNextLine?: boolean) => void;
}
export interface IEditorProps {
@@ -42,7 +35,7 @@ export interface IEditorProps {
suggestions?: () => Promise<IMentionSuggestion[]>;
};
onChange?: (json: object, html: string) => void;
onEnterKeyPress?: (e?: any) => void;
onEnterKeyPress?: (descriptionHTML: string) => void;
placeholder?: string | ((isFocused: boolean, value: string) => string);
tabIndex?: number;
value?: string | null;
-1
View File
@@ -1,4 +1,3 @@
export * from "./ai";
export * from "./config";
export * from "./editor";
export * from "./embed";
+2 -13
View File
@@ -3,7 +3,7 @@
position: fixed;
display: flex;
align-items: center;
opacity: 1;
opacity: 100;
transition:
opacity 0.2s ease 0.2s,
top 0.2s ease,
@@ -19,7 +19,7 @@
/* drag handle */
#drag-handle {
opacity: 1;
opacity: 100;
&.drag-handle-hidden {
opacity: 0;
@@ -28,17 +28,6 @@
}
/* end drag handle */
/* ai handle */
#ai-handle {
opacity: 1;
&.handle-hidden {
opacity: 0;
pointer-events: none;
}
}
/* end ai handle */
.ProseMirror:not(.dragging) .ProseMirror-selectednode {
position: relative;
cursor: grab;
+8 -10
View File
@@ -79,7 +79,6 @@
-moz-appearance: textfield;
}
/* Placeholder only for the first line in an empty editor. */
.ProseMirror p.is-editor-empty:first-child::before {
content: attr(data-placeholder);
float: left;
@@ -88,15 +87,6 @@
height: 0;
}
/* Display Placeholders on every new line. */
.ProseMirror p.is-empty::before {
content: attr(data-placeholder);
float: left;
color: rgb(var(--color-text-400));
pointer-events: none;
height: 0;
}
.ProseMirror li blockquote {
margin-top: 10px;
padding-inline-start: 1em;
@@ -120,6 +110,14 @@
display: none;
}
.ProseMirror .is-empty::before {
content: attr(data-placeholder);
float: left;
color: rgb(var(--color-text-400));
pointer-events: none;
height: 0;
}
/* Custom image styles */
.ProseMirror img {
transition: filter 0.1s ease-in-out;
-1
View File
@@ -19,6 +19,5 @@ export type TLogoProps = {
icon?: {
name?: string;
color?: string;
background_color?: string;
};
};
-1
View File
@@ -140,7 +140,6 @@ export interface IIssueActivity {
name: string;
priority: string | null;
sequence_id: string;
type_id: string;
} | null;
new_identifier: string | null;
new_value: string | null;
-5
View File
@@ -60,9 +60,4 @@ export type TIssueActivityComment =
id: string;
activity_type: "WORKLOG";
created_at?: string;
}
| {
id: string;
activity_type: "ISSUE_ADDITIONAL_PROPERTIES_ACTIVITY";
created_at?: string;
};

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