Compare commits

..
Author SHA1 Message Date
gakshita c809538853 fix: enabled moving out of folder on hovering 2024-08-05 16:39:37 +05:30
gakshita 14ec8215e7 fix: fav feature review changes 2024-08-05 15:57:43 +05:30
499 changed files with 9050 additions and 9174 deletions
+4 -18
View File
@@ -9,9 +9,8 @@ import { IInstance, IInstanceAdmin } from "@plane/types";
import { Button, Input, TOAST_TYPE, ToggleSwitch, setToast } from "@plane/ui";
// components
import { ControllerInput } from "@/components/common";
import { useInstance } from "@/hooks/store";
import { IntercomConfig } from "./intercom";
// hooks
import { useInstance } from "@/hooks/store";
export interface IGeneralConfigurationForm {
instance: IInstance;
@@ -21,13 +20,11 @@ export interface IGeneralConfigurationForm {
export const GeneralConfigurationForm: FC<IGeneralConfigurationForm> = observer((props) => {
const { instance, instanceAdmins } = props;
// hooks
const { instanceConfigurations, updateInstanceInfo, updateInstanceConfigurations } = useInstance();
const { updateInstanceInfo } = useInstance();
// form data
const {
handleSubmit,
control,
watch,
formState: { errors, isSubmitting },
} = useForm<Partial<IInstance>>({
defaultValues: {
@@ -39,16 +36,7 @@ export const GeneralConfigurationForm: FC<IGeneralConfigurationForm> = observer(
const onSubmit = async (formData: Partial<IInstance>) => {
const payload: Partial<IInstance> = { ...formData };
// update the intercom configuration
const isIntercomEnabled =
instanceConfigurations?.find((config) => config.key === "IS_INTERCOM_ENABLED")?.value === "1";
if (!payload.is_telemetry_enabled && isIntercomEnabled) {
try {
await updateInstanceConfigurations({ IS_INTERCOM_ENABLED: "0" });
} catch (error) {
console.error(error);
}
}
console.log("payload", payload);
await updateInstanceInfo(payload)
.then(() =>
@@ -86,7 +74,6 @@ export const GeneralConfigurationForm: FC<IGeneralConfigurationForm> = observer(
value={instanceAdmins[0]?.user_detail?.email ?? ""}
placeholder="Admin email"
className="w-full cursor-not-allowed !text-custom-text-400"
autoComplete="on"
disabled
/>
</div>
@@ -106,8 +93,7 @@ export const GeneralConfigurationForm: FC<IGeneralConfigurationForm> = observer(
</div>
<div className="space-y-3">
<div className="text-lg font-medium">Chat + telemetry</div>
<IntercomConfig isTelemetryEnabled={watch("is_telemetry_enabled") ?? false} />
<div className="text-lg font-medium">Telemetry</div>
<div className="flex items-center gap-14 px-4 py-3 border border-custom-border-200 rounded">
<div className="grow flex items-center gap-4">
<div className="shrink-0">
-82
View File
@@ -1,82 +0,0 @@
"use client";
import { FC, useState } from "react";
import { observer } from "mobx-react";
import useSWR from "swr";
import { MessageSquare } from "lucide-react";
import { IFormattedInstanceConfiguration } from "@plane/types";
import { ToggleSwitch } from "@plane/ui";
// hooks
import { useInstance } from "@/hooks/store";
type TIntercomConfig = {
isTelemetryEnabled: boolean;
};
export const IntercomConfig: FC<TIntercomConfig> = observer((props) => {
const { isTelemetryEnabled } = props;
// hooks
const { instanceConfigurations, updateInstanceConfigurations, fetchInstanceConfigurations } = useInstance();
// states
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
// derived values
const isIntercomEnabled = isTelemetryEnabled
? instanceConfigurations
? instanceConfigurations?.find((config) => config.key === "IS_INTERCOM_ENABLED")?.value === "1"
? true
: false
: undefined
: false;
const { isLoading } = useSWR(isTelemetryEnabled ? "INSTANCE_CONFIGURATIONS" : null, () =>
isTelemetryEnabled ? fetchInstanceConfigurations() : null
);
const initialLoader = isLoading && isIntercomEnabled === undefined;
const submitInstanceConfigurations = async (payload: Partial<IFormattedInstanceConfiguration>) => {
try {
await updateInstanceConfigurations(payload);
} catch (error) {
console.error(error);
} finally {
setIsSubmitting(false);
}
};
const enableIntercomConfig = () => {
submitInstanceConfigurations({ IS_INTERCOM_ENABLED: isIntercomEnabled ? "0" : "1" });
};
return (
<>
<div className="flex items-center gap-14 px-4 py-3 border border-custom-border-200 rounded">
<div className="grow flex items-center gap-4">
<div className="shrink-0">
<div className="flex items-center justify-center w-10 h-10 bg-custom-background-80 rounded-full">
<MessageSquare className="w-6 h-6 text-custom-text-300/80 p-0.5" />
</div>
</div>
<div className="grow">
<div className="text-sm font-medium text-custom-text-100 leading-5">Talk to Plane</div>
<div className="text-xs font-normal text-custom-text-300 leading-5">
Let your members chat with us via Intercom or another service. Toggling Telemetry off turns this off
automatically.
</div>
</div>
<div className="ml-auto">
<ToggleSwitch
value={isIntercomEnabled ? true : false}
onChange={enableIntercomConfig}
size="sm"
disabled={!isTelemetryEnabled || isSubmitting || initialLoader}
/>
</div>
</div>
</div>
</>
);
});
+1 -1
View File
@@ -7,7 +7,7 @@ import { GeneralConfigurationForm } from "./form";
function GeneralPage() {
const { instance, instanceAdmins } = useInstance();
console.log("instance", instance);
return (
<>
<div className="relative container mx-auto w-full h-full p-4 py-4 space-y-6 flex flex-col">
@@ -96,7 +96,7 @@ export const HelpSection: FC = observer(() => {
leaveTo="transform opacity-0 scale-95"
>
<div
className={`absolute bottom-2 min-w-[10rem] z-[15] ${
className={`absolute bottom-2 min-w-[10rem] ${
isSidebarCollapsed ? "left-full" : "-left-[75px]"
} divide-y divide-custom-border-200 whitespace-nowrap rounded bg-custom-background-100 p-1 shadow-custom-shadow-xs`}
ref={helpOptionsRef}
@@ -174,7 +174,6 @@ export const InstanceSetupForm: FC = (props) => {
placeholder="Wilber"
value={formData.first_name}
onChange={(e) => handleFormChange("first_name", e.target.value)}
autoComplete="on"
autoFocus
/>
</div>
@@ -191,7 +190,6 @@ export const InstanceSetupForm: FC = (props) => {
placeholder="Wright"
value={formData.last_name}
onChange={(e) => handleFormChange("last_name", e.target.value)}
autoComplete="on"
/>
</div>
</div>
@@ -210,7 +208,6 @@ export const InstanceSetupForm: FC = (props) => {
value={formData.email}
onChange={(e) => handleFormChange("email", e.target.value)}
hasError={errorData.type && errorData.type === EErrorCodes.INVALID_EMAIL ? true : false}
autoComplete="on"
/>
{errorData.type && errorData.type === EErrorCodes.INVALID_EMAIL && errorData.message && (
<p className="px-1 text-xs text-red-500">{errorData.message}</p>
@@ -250,7 +247,6 @@ export const InstanceSetupForm: FC = (props) => {
hasError={errorData.type && errorData.type === EErrorCodes.INVALID_PASSWORD ? true : false}
onFocus={() => setIsPasswordInputFocused(true)}
onBlur={() => setIsPasswordInputFocused(false)}
autoComplete="on"
/>
{showPassword.password ? (
<button
+2 -2
View File
@@ -57,6 +57,8 @@ export const InstanceSignInForm: FC = (props) => {
const handleFormChange = (key: keyof TFormData, value: string | boolean) =>
setFormData((prev) => ({ ...prev, [key]: value }));
console.log("csrfToken", csrfToken);
useEffect(() => {
if (csrfToken === undefined)
authService.requestCSRFToken().then((data) => data?.csrf_token && setCsrfToken(data.csrf_token));
@@ -127,7 +129,6 @@ export const InstanceSignInForm: FC = (props) => {
placeholder="name@company.com"
value={formData.email}
onChange={(e) => handleFormChange("email", e.target.value)}
autoComplete="on"
autoFocus
/>
</div>
@@ -146,7 +147,6 @@ export const InstanceSignInForm: FC = (props) => {
placeholder="Enter your password"
value={formData.password}
onChange={(e) => handleFormChange("password", e.target.value)}
autoComplete="on"
/>
{showPassword ? (
<button
+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
+1 -18
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
@@ -286,7 +269,6 @@ class LabelSerializer(BaseSerializer):
"updated_by",
"created_at",
"updated_at",
"deleted_at",
]
@@ -448,3 +430,4 @@ class IssueExpandSerializer(BaseSerializer):
"created_at",
"updated_at",
]
-1
View File
@@ -23,7 +23,6 @@ class StateSerializer(BaseSerializer):
"updated_at",
"workspace",
"project",
"deleted_at",
]
+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
+12 -14
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,
@@ -35,7 +35,6 @@ from plane.db.models import (
IssueAttachment,
IssueLink,
ProjectMember,
UserFavorite,
)
from plane.utils.analytics_plot import burndown_plot
@@ -409,12 +408,6 @@ class CycleAPIEndpoint(BaseAPIView):
CycleIssue.objects.filter(
cycle_id=self.kwargs.get("pk"),
).delete()
# Delete the user favorite cycle
UserFavorite.objects.filter(
entity_type="cycle",
entity_identifier=pk,
project_id=project_id,
).delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -544,12 +537,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):
@@ -678,6 +665,17 @@ class CycleIssueAPIEndpoint(BaseAPIView):
workspace__slug=slug, project_id=project_id, pk=cycle_id
)
if (
cycle.end_date is not None
and cycle.end_date < timezone.now().date()
):
return Response(
{
"error": "The Cycle has already been completed so no new issues can be added"
},
status=status.HTTP_400_BAD_REQUEST,
)
# Get all CycleIssues already created
cycle_issues = list(
CycleIssue.objects.filter(
+1 -1
View File
@@ -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,
+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 -15
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,
@@ -28,7 +28,6 @@ from plane.db.models import (
ModuleLink,
Project,
ProjectMember,
UserFavorite,
)
from .base import BaseAPIView
@@ -305,13 +304,6 @@ class ModuleAPIEndpoint(BaseAPIView):
# Delete the module issues
ModuleIssue.objects.filter(
module=pk,
project_id=project_id,
).delete()
# Delete the user favorite module
UserFavorite.objects.filter(
entity_type="module",
entity_identifier=pk,
project_id=project_id,
).delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -520,6 +512,7 @@ class ModuleIssueAPIEndpoint(BaseAPIView):
class ModuleArchiveUnarchiveAPIEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
@@ -634,12 +627,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):
-11
View File
@@ -26,7 +26,6 @@ from plane.db.models import (
ProjectMember,
State,
Workspace,
UserFavorite,
)
from plane.bgtasks.webhook_task import model_activity
from .base import BaseAPIView
@@ -357,12 +356,6 @@ class ProjectAPIEndpoint(BaseAPIView):
def delete(self, request, slug, pk):
project = Project.objects.get(pk=pk, workspace__slug=slug)
# Delete the user favorite cycle
UserFavorite.objects.filter(
entity_type="project",
entity_identifier=pk,
project_id=pk,
).delete()
project.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -377,10 +370,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
+1 -6
View File
@@ -1,5 +1,5 @@
from django.urls import path
from plane.app.views import ApiTokenEndpoint, ServiceApiTokenEndpoint
from plane.app.views import ApiTokenEndpoint
urlpatterns = [
# API Tokens
@@ -13,10 +13,5 @@ urlpatterns = [
ApiTokenEndpoint.as_view(),
name="api-tokens",
),
path(
"workspaces/<str:slug>/service-api-tokens/",
ServiceApiTokenEndpoint.as_view(),
name="service-api-tokens",
),
## End API Tokens
]
+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",
+7 -1
View File
@@ -19,6 +19,7 @@ from plane.app.views import (
IssueUserDisplayPropertyEndpoint,
IssueViewSet,
LabelViewSet,
BulkIssueOperationsEndpoint,
BulkArchiveIssuesEndpoint,
)
@@ -303,5 +304,10 @@ urlpatterns = [
}
),
name="project-issue-draft",
)
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/bulk-operation-issues/",
BulkIssueOperationsEndpoint.as_view(),
name="bulk-operations-issues",
),
]
+5 -4
View File
@@ -156,6 +156,9 @@ from .issue.subscriber import (
IssueSubscriberViewSet,
)
from .issue.bulk_operations import BulkIssueOperationsEndpoint
from .module.base import (
ModuleViewSet,
ModuleLinkViewSet,
@@ -171,10 +174,8 @@ from .module.archive import (
ModuleArchiveUnarchiveEndpoint,
)
from .api import (
ApiTokenEndpoint,
ServiceApiTokenEndpoint,
)
from .api import ApiTokenEndpoint
from .page.base import (
PageViewSet,
+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(
{
+1 -43
View File
@@ -45,7 +45,7 @@ class ApiTokenEndpoint(BaseAPIView):
def get(self, request, slug, pk=None):
if pk is None:
api_tokens = APIToken.objects.filter(
user=request.user, workspace__slug=slug, is_service=False
user=request.user, workspace__slug=slug
)
serializer = APITokenReadSerializer(api_tokens, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
@@ -61,7 +61,6 @@ class ApiTokenEndpoint(BaseAPIView):
workspace__slug=slug,
user=request.user,
pk=pk,
is_service=False,
)
api_token.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -79,44 +78,3 @@ class ApiTokenEndpoint(BaseAPIView):
serializer.save()
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
class ServiceApiTokenEndpoint(BaseAPIView):
permission_classes = [
WorkspaceOwnerPermission,
]
def post(self, request, slug):
workspace = Workspace.objects.get(slug=slug)
api_token = APIToken.objects.filter(
workspace=workspace,
is_service=True,
).first()
if api_token:
return Response(
{
"token": str(api_token.token),
},
status=status.HTTP_200_OK,
)
else:
# Check the user type
user_type = 1 if request.user.is_bot else 0
api_token = APIToken.objects.create(
label=str(uuid4().hex),
description="Service Token",
user=request.user,
workspace=workspace,
user_type=user_type,
is_service=True,
)
return Response(
{
"token": str(api_token.token),
},
status=status.HTTP_201_CREATED,
)
+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
+21 -28
View File
@@ -29,14 +29,15 @@ from django.core.serializers.json import DjangoJSONEncoder
from rest_framework import status
from rest_framework.response import Response
from plane.app.permissions import (
allow_permission, ROLE
ProjectEntityPermission,
ProjectLitePermission,
)
from plane.app.serializers import (
CycleSerializer,
CycleUserPropertiesSerializer,
CycleWriteSerializer,
)
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,
@@ -49,7 +50,6 @@ from plane.db.models import (
ProjectMember,
)
from plane.utils.analytics_plot import burndown_plot
from plane.bgtasks.recent_visited_task import recent_visited_task
# Module imports
from .. import BaseAPIView, BaseViewSet
@@ -60,6 +60,15 @@ class CycleViewSet(BaseViewSet):
serializer_class = CycleSerializer
model = Cycle
webhook_event = "cycle"
permission_classes = [
ProjectEntityPermission,
]
def perform_create(self, serializer):
serializer.save(
project_id=self.kwargs.get("project_id"),
owned_by=self.request.user,
)
def get_queryset(self):
favorite_subquery = UserFavorite.objects.filter(
@@ -316,7 +325,6 @@ class CycleViewSet(BaseViewSet):
.distinct()
)
@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)
cycle_view = request.GET.get("cycle_view", "all")
@@ -603,7 +611,6 @@ class CycleViewSet(BaseViewSet):
)
return Response(data, status=status.HTTP_200_OK)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def create(self, request, slug, project_id):
if (
request.data.get("start_date", None) is None
@@ -677,7 +684,6 @@ class CycleViewSet(BaseViewSet):
status=status.HTTP_400_BAD_REQUEST,
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def partial_update(self, request, slug, project_id, pk):
queryset = self.get_queryset().filter(
workspace__slug=slug, project_id=project_id, pk=pk
@@ -765,7 +771,6 @@ class CycleViewSet(BaseViewSet):
return Response(cycle, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER])
def retrieve(self, request, slug, project_id, pk):
queryset = (
self.get_queryset().filter(archived_at__isnull=True).filter(pk=pk)
@@ -1029,19 +1034,11 @@ class CycleViewSet(BaseViewSet):
cycle_id=pk,
)
recent_visited_task.delay(
slug=slug,
entity_name="cycle",
entity_identifier=pk,
user_id=request.user.id,
project_id=project_id,
)
return Response(
data,
status=status.HTTP_200_OK,
)
@allow_permission([ROLE.ADMIN], creator=True, model=Cycle)
def destroy(self, request, slug, project_id, pk):
cycle = Cycle.objects.get(
workspace__slug=slug, project_id=project_id, pk=pk
@@ -1089,19 +1086,14 @@ class CycleViewSet(BaseViewSet):
CycleIssue.objects.filter(
cycle_id=self.kwargs.get("pk"),
).delete()
# Delete the user favorite cycle
UserFavorite.objects.filter(
user=request.user,
entity_type="cycle",
entity_identifier=pk,
project_id=project_id,
).delete()
return Response(status=status.HTTP_204_NO_CONTENT)
class CycleDateCheckEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def post(self, request, slug, project_id):
start_date = request.data.get("start_date", False)
end_date = request.data.get("end_date", False)
@@ -1145,7 +1137,6 @@ class CycleFavoriteViewSet(BaseViewSet):
.select_related("cycle", "cycle__owned_by")
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def create(self, request, slug, project_id):
_ = UserFavorite.objects.create(
project_id=project_id,
@@ -1155,7 +1146,6 @@ class CycleFavoriteViewSet(BaseViewSet):
)
return Response(status=status.HTTP_204_NO_CONTENT)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def destroy(self, request, slug, project_id, cycle_id):
cycle_favorite = UserFavorite.objects.get(
project=project_id,
@@ -1169,8 +1159,10 @@ class CycleFavoriteViewSet(BaseViewSet):
class TransferCycleIssueEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def post(self, request, slug, project_id, cycle_id):
new_cycle_id = request.data.get("new_cycle_id", False)
@@ -1580,8 +1572,10 @@ class TransferCycleIssueEndpoint(BaseAPIView):
class CycleUserPropertiesEndpoint(BaseAPIView):
permission_classes = [
ProjectLitePermission,
]
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST])
def patch(self, request, slug, project_id, cycle_id):
cycle_properties = CycleUserProperties.objects.get(
user=request.user,
@@ -1604,7 +1598,6 @@ class CycleUserPropertiesEndpoint(BaseAPIView):
serializer = CycleUserPropertiesSerializer(cycle_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, cycle_id):
cycle_properties, _ = CycleUserProperties.objects.get_or_create(
user=request.user,
+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(
[
+33 -35
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
@@ -159,15 +160,13 @@ class InboxIssueViewSet(BaseViewSet):
ArrayAgg(
"issue_module__module_id",
distinct=True,
filter=~Q(issue_module__module_id__isnull=True)
& Q(issue_module__module__archived_at__isnull=True),
filter=~Q(issue_module__module_id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
)
).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 +200,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 +209,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 +311,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 +457,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 +492,7 @@ class InboxIssueViewSet(BaseViewSet):
)
.get(
inbox_id=inbox_id.id,
issue_id=pk,
issue_id=issue_id,
project_id=project_id,
)
)
@@ -516,12 +505,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 +533,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 +543,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 +558,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
+46 -48
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,16 @@ 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
# 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 +135,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 +185,9 @@ class IssueViewSet(BaseViewSet):
model = Issue
webhook_event = "issue"
permission_classes = [
ProjectEntityPermission,
]
search_fields = [
"name",
@@ -231,7 +233,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 +257,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 +338,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 +412,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()
@@ -457,8 +438,7 @@ class IssueViewSet(BaseViewSet):
ArrayAgg(
"issue_module__module_id",
distinct=True,
filter=~Q(issue_module__module_id__isnull=True)
& Q(issue_module__module__archived_at__isnull=True),
filter=~Q(issue_module__module_id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
@@ -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,8 +582,10 @@ class IssueViewSet(BaseViewSet):
class IssueUserDisplayPropertyEndpoint(BaseAPIView):
permission_classes = [
ProjectLitePermission,
]
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST, ROLE.VIEWER])
def patch(self, request, slug, project_id):
issue_property = IssueUserProperty.objects.get(
user=request.user,
@@ -620,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
@@ -630,9 +614,23 @@ class IssueUserDisplayPropertyEndpoint(BaseAPIView):
class BulkDeleteIssuesEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
@allow_permission([ROLE.ADMIN])
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", [])
@@ -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 -37
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()
@@ -449,12 +444,6 @@ class ModuleViewSet(BaseViewSet):
)
)
if not queryset.exists():
return Response(
{"error": "Module not found"},
status=status.HTTP_404_NOT_FOUND,
)
estimate_type = Project.objects.filter(
workspace__slug=slug,
pk=project_id,
@@ -671,20 +660,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 +734,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
@@ -783,14 +776,6 @@ class ModuleViewSet(BaseViewSet):
# Delete the module issues
ModuleIssue.objects.filter(
module=pk,
project_id=project_id,
).delete()
# Delete the user favorite module
UserFavorite.objects.filter(
user=request.user,
entity_type="module",
entity_identifier=pk,
project_id=project_id,
).delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -860,8 +845,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 +871,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 -53
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
@@ -381,21 +359,16 @@ class PageViewSet(BaseViewSet):
).update(parent=None)
page.delete()
# Delete the user favorite page
UserFavorite.objects.filter(
project=project_id,
workspace__slug=slug,
entity_identifier=pk,
entity_type="page",
).delete()
return Response(status=status.HTTP_204_NO_CONTENT)
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 +378,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 +391,9 @@ class PageFavoriteViewSet(BaseViewSet):
class PageLogEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
serializer_class = PageLogSerializer
model = PageLog
@@ -458,6 +433,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 +454,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 +466,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 +482,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 +499,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
+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(
+30 -123
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,
@@ -171,13 +141,6 @@ class WorkspaceViewViewSet(BaseViewSet):
or workspace_view.owned_by == request.user
):
workspace_view.delete()
# Delete the user favorite view
UserFavorite.objects.filter(
workspace__slug=slug,
entity_identifier=pk,
project__isnull=True,
entity_type="view",
).delete()
else:
return Response(
{"error": "Only admin or owner can delete the view"},
@@ -187,6 +150,10 @@ class WorkspaceViewViewSet(BaseViewSet):
class WorkspaceViewIssuesViewSet(BaseViewSet):
permission_classes = [
WorkspaceEntityPermission,
]
def get_queryset(self):
return (
Issue.issue_objects.annotate(
@@ -249,8 +216,7 @@ class WorkspaceViewIssuesViewSet(BaseViewSet):
ArrayAgg(
"issue_module__module_id",
distinct=True,
filter=~Q(issue_module__module_id__isnull=True)
& Q(issue_module__module__archived_at__isnull=True),
filter=~Q(issue_module__module_id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
@@ -258,10 +224,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 +234,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 +340,9 @@ class WorkspaceViewIssuesViewSet(BaseViewSet):
class IssueViewViewSet(BaseViewSet):
serializer_class = IssueViewSerializer
model = IssueView
permission_classes = [
ProjectEntityPermission,
]
def perform_create(self, serializer):
serializer.save(
@@ -421,20 +376,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 +388,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 +420,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,
@@ -519,13 +437,6 @@ class IssueViewViewSet(BaseViewSet):
or project_view.owned_by_id == request.user.id
):
project_view.delete()
# Delete the user favorite view
UserFavorite.objects.filter(
project_id=project_id,
workspace__slug=slug,
entity_identifier=pk,
entity_type="view",
).delete()
else:
return Response(
{"error": "Only admin or owner can delete the view"},
@@ -546,8 +457,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 +466,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(
@@ -29,7 +29,6 @@ from plane.authentication.adapter.error import (
AuthenticationException,
AUTHENTICATION_ERROR_CODES,
)
from plane.authentication.rate_limit import AuthenticationThrottle
class MagicGenerateEndpoint(APIView):
@@ -38,10 +37,6 @@ class MagicGenerateEndpoint(APIView):
AllowAny,
]
throttle_classes = [
AuthenticationThrottle,
]
def post(self, request):
# Check if instance is configured
instance = Instance.objects.first()
@@ -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
@@ -6,23 +6,8 @@ from django.core.management import BaseCommand
class Command(BaseCommand):
help = "Clear Cache before starting the server to remove stale values"
def add_arguments(self, parser):
# Positional argument
parser.add_argument(
"--key", type=str, nargs="?", help="Key to clear cache"
)
def handle(self, *args, **options):
try:
if options["key"]:
cache.delete(options["key"])
self.stdout.write(
self.style.SUCCESS(
f"Cache Cleared for key: {options['key']}"
)
)
return
cache.clear()
self.stdout.write(self.style.SUCCESS("Cache Cleared"))
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)
@@ -13,7 +13,6 @@ class InstanceSerializer(BaseSerializer):
model = Instance
exclude = [
"license_key",
"user_count"
]
read_only_fields = [
"id",
@@ -54,7 +54,6 @@ class InstanceEndpoint(BaseAPIView):
data["is_activated"] = True
# Get all the configuration
(
ENABLE_SIGNUP,
IS_GOOGLE_ENABLED,
IS_GITHUB_ENABLED,
GITHUB_APP_NAME,
@@ -67,14 +66,8 @@ class InstanceEndpoint(BaseAPIView):
POSTHOG_HOST,
UNSPLASH_ACCESS_KEY,
OPENAI_API_KEY,
IS_INTERCOM_ENABLED,
INTERCOM_APP_ID,
) = get_configuration_value(
[
{
"key": "ENABLE_SIGNUP",
"default": os.environ.get("ENABLE_SIGNUP", "0"),
},
{
"key": "IS_GOOGLE_ENABLED",
"default": os.environ.get("IS_GOOGLE_ENABLED", "0"),
@@ -123,21 +116,11 @@ class InstanceEndpoint(BaseAPIView):
"key": "OPENAI_API_KEY",
"default": os.environ.get("OPENAI_API_KEY", ""),
},
# Intercom settings
{
"key": "IS_INTERCOM_ENABLED",
"default": os.environ.get("IS_INTERCOM_ENABLED", "1"),
},
{
"key": "INTERCOM_APP_ID",
"default": os.environ.get("INTERCOM_APP_ID", ""),
},
]
)
data = {}
# Authentication
data["enable_signup"] = ENABLE_SIGNUP == "1"
data["is_google_enabled"] = IS_GOOGLE_ENABLED == "1"
data["is_github_enabled"] = IS_GITHUB_ENABLED == "1"
data["is_gitlab_enabled"] = IS_GITLAB_ENABLED == "1"
@@ -168,10 +151,6 @@ class InstanceEndpoint(BaseAPIView):
# is smtp configured
data["is_smtp_configured"] = bool(EMAIL_HOST)
# Intercom settings
data["is_intercom_enabled"] = IS_INTERCOM_ENABLED == "1"
data["intercom_app_id"] = INTERCOM_APP_ID
# Base URL
data["admin_base_url"] = settings.ADMIN_BASE_URL
data["space_base_url"] = settings.SPACE_BASE_URL
@@ -143,19 +143,6 @@ class Command(BaseCommand):
"category": "UNSPLASH",
"is_encrypted": True,
},
# intercom settings
{
"key": "IS_INTERCOM_ENABLED",
"value": os.environ.get("IS_INTERCOM_ENABLED", "1"),
"category": "INTERCOM",
"is_encrypted": False,
},
{
"key": "INTERCOM_APP_ID",
"value": os.environ.get("INTERCOM_APP_ID", ""),
"category": "INTERCOM",
"is_encrypted": False,
},
]
for item in config_keys:
@@ -278,11 +265,7 @@ class Command(BaseCommand):
]
)
)
if (
bool(GITLAB_HOST)
and bool(GITLAB_CLIENT_ID)
and bool(GITLAB_CLIENT_SECRET)
):
if bool(GITLAB_HOST) and bool(GITLAB_CLIENT_ID) and bool(GITLAB_CLIENT_SECRET):
value = "1"
else:
value = "0"
+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 -12
View File
@@ -18,7 +18,7 @@ from django.db.models import (
JSONField,
Value,
OuterRef,
Func,
Func
)
# Third Party imports
@@ -60,9 +60,8 @@ from plane.db.models import (
IssueVote,
ProjectPublicMember,
IssueAttachment,
Project,
)
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
@@ -78,15 +77,6 @@ class ProjectIssuesPublicEndpoint(BaseAPIView):
deploy_board = DeployBoard.objects.filter(
anchor=anchor, entity_name="project"
).first()
project = Project.objects.get(pk=deploy_board.project_id)
if project.archived_at:
return Response(
{"error": "Project is archived"},
status=status.HTTP_404_NOT_FOUND,
)
if not deploy_board:
return Response(
{"error": "Project is not published"},
+8 -5
View File
@@ -27,11 +27,14 @@ class ProjectStatesEndpoint(BaseAPIView):
status=status.HTTP_404_NOT_FOUND,
)
states = State.objects.filter(
~Q(name="Triage"),
workspace__slug=deploy_board.workspace.slug,
project_id=deploy_board.project_id,
).values("name", "group", "color", "id", "sequence")
states = (
State.objects.filter(
~Q(name="Triage"),
workspace__slug=deploy_board.workspace.slug,
project_id=deploy_board.project_id,
)
.values("name", "group", "color", "id")
)
return Response(
states,
-5
View File
@@ -27,9 +27,4 @@ RESTRICTED_WORKSPACE_SLUGS = [
"channels",
"upgrade",
"billing",
"sign-in",
"sign-up",
"signin",
"signup",
"config",
]
-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,
}
+3 -4
View File
@@ -18,6 +18,7 @@ from plane.db.models import (
def issue_queryset_grouper(queryset, group_by, sub_group_by):
FIELD_MAPPER = {
"label_ids": "labels__id",
"assignee_ids": "assignees__id",
@@ -29,10 +30,7 @@ def issue_queryset_grouper(queryset, group_by, sub_group_by):
"label_ids": ("labels__id", ~Q(labels__id__isnull=True)),
"module_ids": (
"issue_module__module_id",
(
~Q(issue_module__module_id__isnull=True)
& Q(issue_module__module__archived_at__isnull=True)
),
~Q(issue_module__module_id__isnull=True),
),
}
default_annotations = {
@@ -53,6 +51,7 @@ def issue_queryset_grouper(queryset, group_by, sub_group_by):
def issue_on_results(issues, group_by, sub_group_by):
FIELD_MAPPER = {
"labels__id": "label_ids",
"assignees__id": "assignee_ids",
+1 -1
View File
@@ -1,7 +1,7 @@
# base requirements
# django
Django==4.2.15
Django==4.2.14
# rest framework
djangorestframework==3.15.2
# postgres
+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'
+4 -13
View File
@@ -9,20 +9,11 @@ export DOCKERHUB_USER=makeplane
export PULL_POLICY=${PULL_POLICY:-if_not_present}
CPU_ARCH=$(uname -m)
OS_NAME=$(uname)
UPPER_CPU_ARCH=$(tr '[:lower:]' '[:upper:]' <<< "$CPU_ARCH")
mkdir -p $PLANE_INSTALL_DIR/archive
DOCKER_FILE_PATH=$PLANE_INSTALL_DIR/docker-compose.yaml
DOCKER_ENV_PATH=$PLANE_INSTALL_DIR/plane.env
SED_PREFIX=()
if [ "$OS_NAME" == "Darwin" ]; then
SED_PREFIX=("-i" "")
else
SED_PREFIX=("-i")
fi
function print_header() {
clear
@@ -60,12 +51,12 @@ function spinner() {
}
function initialize(){
printf "Please wait while we check the availability of Docker images for the selected release ($APP_RELEASE) with ${UPPER_CPU_ARCH} support." >&2
printf "Please wait while we check the availability of Docker images for the selected release ($APP_RELEASE) with ${CPU_ARCH^^} support." >&2
if [ "$CUSTOM_BUILD" == "true" ]; then
echo "" >&2
echo "" >&2
echo "${UPPER_CPU_ARCH} images are not available for selected release ($APP_RELEASE)." >&2
echo "${CPU_ARCH^^} images are not available for selected release ($APP_RELEASE)." >&2
echo "build"
return 1
fi
@@ -87,7 +78,7 @@ function initialize(){
else
echo "" >&2
echo "" >&2
echo "${UPPER_CPU_ARCH} images are not available for selected release ($APP_RELEASE)." >&2
echo "${CPU_ARCH^^} images are not available for selected release ($APP_RELEASE)." >&2
echo "" >&2
echo "build"
return 1
@@ -131,7 +122,7 @@ function updateEnvFile() {
return
else
# if key exists, update the value
sed "${SED_PREFIX[@]}" "s/^$key=.*/$key=$value/g" "$file"
sed -i "s/^$key=.*/$key=$value/g" "$file"
fi
else
echo "File not found: $file"
+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.9"
},
"resolutions": {
"@types/react": "18.2.48"
@@ -1,14 +1,10 @@
import { Extensions } from "@tiptap/core";
import { SlashCommand } from "@/extensions";
// hooks
import { TFileHandler } from "@/hooks/use-editor";
// plane editor types
import { TIssueEmbedConfig } from "@/plane-editor/types";
// types
import { TExtensions } from "@/types";
type Props = {
disabledExtensions?: TExtensions[];
fileHandler: TFileHandler;
issueEmbedConfig: TIssueEmbedConfig | undefined;
};
@@ -16,7 +12,7 @@ type Props = {
export const DocumentEditorAdditionalExtensions = (props: Props) => {
const { fileHandler } = props;
const extensions: Extensions = [SlashCommand(fileHandler.upload)];
const extensions = [SlashCommand(fileHandler.upload)];
return extensions;
};
@@ -1,30 +1,18 @@
import React from "react";
import React, { useState } from "react";
// components
import { PageRenderer } from "@/components/editors";
// constants
import { DEFAULT_DISPLAY_CONFIG } from "@/constants/config";
// helpers
import { getEditorClassNames } from "@/helpers/common";
// hooks
import { useDocumentEditor } from "@/hooks/use-document-editor";
import { TFileHandler } from "@/hooks/use-editor";
// plane editor types
import { TEmbedConfig } from "@/plane-editor/types";
// types
import {
EditorRefApi,
IMentionHighlight,
IMentionSuggestion,
TAIHandler,
TDisplayConfig,
TExtensions,
TFileHandler,
} from "@/types";
import { EditorRefApi, IMentionHighlight, IMentionSuggestion } from "@/types";
interface IDocumentEditor {
aiHandler?: TAIHandler;
containerClassName?: string;
disabledExtensions?: TExtensions[];
displayConfig?: TDisplayConfig;
editorClassName?: string;
embedHandler: TEmbedConfig;
fileHandler: TFileHandler;
@@ -43,10 +31,7 @@ interface IDocumentEditor {
const DocumentEditor = (props: IDocumentEditor) => {
const {
aiHandler,
containerClassName,
disabledExtensions,
displayConfig = DEFAULT_DISPLAY_CONFIG,
editorClassName = "",
embedHandler,
fileHandler,
@@ -59,10 +44,16 @@ const DocumentEditor = (props: IDocumentEditor) => {
tabIndex,
value,
} = props;
// states
const [hideDragHandleOnMouseLeave, setHideDragHandleOnMouseLeave] = useState<() => void>(() => {});
// this essentially sets the hideDragHandle function from the DragAndDrop extension as the Plugin
// loads such that we can invoke it from react when the cursor leaves the container
const setHideDragHandleFunction = (hideDragHandlerFromDragDrop: () => void) => {
setHideDragHandleOnMouseLeave(() => hideDragHandlerFromDragDrop);
};
// use document editor
const { editor, isIndexedDbSynced } = useDocumentEditor({
disabledExtensions,
id,
editorClassName,
embedHandler,
@@ -73,6 +64,7 @@ const DocumentEditor = (props: IDocumentEditor) => {
forwardedRef,
mentionHandler,
placeholder,
setHideDragHandleFunction,
tabIndex,
});
@@ -86,10 +78,9 @@ const DocumentEditor = (props: IDocumentEditor) => {
return (
<PageRenderer
displayConfig={displayConfig}
aiHandler={aiHandler}
editor={editor}
editorContainerClassName={editorContainerClassNames}
hideDragHandle={hideDragHandleOnMouseLeave}
id={id}
tabIndex={tabIndex}
/>
@@ -15,21 +15,18 @@ 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";
// types
import { TAIHandler, TDisplayConfig } from "@/types";
import { BlockMenu } from "@/components/menus";
type IPageRenderer = {
aiHandler?: TAIHandler;
displayConfig: TDisplayConfig;
editor: Editor;
editorContainerClassName: string;
hideDragHandle?: () => void;
id: string;
tabIndex?: number;
};
export const PageRenderer = (props: IPageRenderer) => {
const { aiHandler, displayConfig, editor, editorContainerClassName, id, tabIndex } = props;
const { editor, editorContainerClassName, hideDragHandle, id, tabIndex } = props;
// states
const [linkViewProps, setLinkViewProps] = useState<LinkViewProps>();
const [isOpen, setIsOpen] = useState(false);
@@ -133,18 +130,13 @@ export const PageRenderer = (props: IPageRenderer) => {
<>
<div className="frame-renderer flex-grow w-full -mx-5" onMouseOver={handleLinkHover}>
<EditorContainer
displayConfig={displayConfig}
editor={editor}
editorContainerClassName={editorContainerClassName}
hideDragHandle={hideDragHandle}
id={id}
>
<EditorContentWrapper editor={editor} id={id} tabIndex={tabIndex} />
{editor.isEditable && (
<>
<BlockMenu editor={editor} />
<AIFeaturesMenu menu={aiHandler?.menu} />
</>
)}
{editor && editor.isEditable && <BlockMenu editor={editor} />}
</EditorContainer>
</div>
{isOpen && linkViewProps && coordinates && (
@@ -1,8 +1,6 @@
import { forwardRef, MutableRefObject } from "react";
// components
import { PageRenderer } from "@/components/editors";
// constants
import { DEFAULT_DISPLAY_CONFIG } from "@/constants/config";
// extensions
import { IssueWidget } from "@/extensions";
// helpers
@@ -12,13 +10,12 @@ import { useReadOnlyEditor } from "@/hooks/use-read-only-editor";
// plane web types
import { TEmbedConfig } from "@/plane-editor/types";
// types
import { EditorReadOnlyRefApi, IMentionHighlight, TDisplayConfig } from "@/types";
import { EditorReadOnlyRefApi, IMentionHighlight } from "@/types";
interface IDocumentReadOnlyEditor {
id: string;
initialValue: string;
containerClassName: string;
displayConfig?: TDisplayConfig;
editorClassName?: string;
embedHandler: TEmbedConfig;
tabIndex?: number;
@@ -32,7 +29,6 @@ interface IDocumentReadOnlyEditor {
const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => {
const {
containerClassName,
displayConfig = DEFAULT_DISPLAY_CONFIG,
editorClassName = "",
embedHandler,
id,
@@ -43,17 +39,17 @@ const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => {
mentionHandler,
} = props;
const editor = useReadOnlyEditor({
initialValue,
editorClassName,
mentionHandler,
forwardedRef,
handleEditorReady,
extensions: [
embedHandler?.issue &&
IssueWidget({
widgetCallback: embedHandler?.issue.widgetCallback,
}),
],
forwardedRef,
handleEditorReady,
initialValue,
mentionHandler,
});
if (!editor) {
@@ -65,13 +61,7 @@ const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => {
});
return (
<PageRenderer
displayConfig={displayConfig}
editor={editor}
editorContainerClassName={editorContainerClassName}
id={id}
tabIndex={tabIndex}
/>
<PageRenderer editor={editor} editorContainerClassName={editorContainerClassName} id={id} tabIndex={tabIndex} />
);
};
@@ -1,22 +1,18 @@
import { FC, ReactNode } from "react";
import { Editor } from "@tiptap/react";
// constants
import { DEFAULT_DISPLAY_CONFIG } from "@/constants/config";
// helpers
import { cn } from "@/helpers/common";
// types
import { TDisplayConfig } from "@/types";
interface EditorContainerProps {
children: ReactNode;
displayConfig: TDisplayConfig;
editor: Editor | null;
editorContainerClassName: string;
hideDragHandle?: () => void;
id: string;
}
export const EditorContainer: FC<EditorContainerProps> = (props) => {
const { children, displayConfig, editor, editorContainerClassName, id } = props;
const { children, editor, editorContainerClassName, hideDragHandle, id } = props;
const handleContainerClick = () => {
if (!editor) return;
@@ -57,25 +53,16 @@ export const EditorContainer: FC<EditorContainerProps> = (props) => {
}
};
const handleContainerMouseLeave = () => {
const dragHandleElement = document.querySelector("#editor-side-menu");
if (!dragHandleElement?.classList.contains("side-menu-hidden")) {
dragHandleElement?.classList.add("side-menu-hidden");
}
};
return (
<div
id={`editor-container-${id}`}
onClick={handleContainerClick}
onMouseLeave={handleContainerMouseLeave}
onMouseLeave={hideDragHandle}
className={cn(
"editor-container cursor-text relative",
"cursor-text relative",
{
"active-editor": editor?.isFocused && editor?.isEditable,
},
displayConfig.fontSize ?? DEFAULT_DISPLAY_CONFIG.fontSize,
displayConfig.fontStyle ?? DEFAULT_DISPLAY_CONFIG.fontStyle,
editorContainerClassName
)}
>
@@ -1,8 +1,6 @@
import { Editor, Extension } from "@tiptap/core";
// components
import { EditorContainer } from "@/components/editors";
// constants
import { DEFAULT_DISPLAY_CONFIG } from "@/constants/config";
// hooks
import { getEditorClassNames } from "@/helpers/common";
import { useEditor } from "@/hooks/use-editor";
@@ -13,15 +11,16 @@ import { EditorContentWrapper } from "./editor-content";
type Props = IEditorProps & {
children?: (editor: Editor) => React.ReactNode;
extensions: Extension<any, any>[];
hideDragHandleOnMouseLeave: () => void;
};
export const EditorWrapper: React.FC<Props> = (props) => {
const {
children,
containerClassName,
displayConfig = DEFAULT_DISPLAY_CONFIG,
editorClassName = "",
extensions,
hideDragHandleOnMouseLeave,
id,
initialValue,
fileHandler,
@@ -58,10 +57,10 @@ export const EditorWrapper: React.FC<Props> = (props) => {
return (
<EditorContainer
displayConfig={displayConfig}
editor={editor}
editorContainerClassName={editorContainerClassName}
id={id}
hideDragHandle={hideDragHandleOnMouseLeave}
>
{children?.(editor)}
<div className="flex flex-col">
@@ -11,7 +11,7 @@ const LiteTextEditor = (props: ILiteTextEditor) => {
const extensions = [EnterKeyExtension(onEnterKeyPress)];
return <EditorWrapper {...props} extensions={extensions} />;
return <EditorWrapper {...props} extensions={extensions} hideDragHandleOnMouseLeave={() => {}} />;
};
const LiteTextEditorWithRef = forwardRef<EditorRefApi, ILiteTextEditor>((props, ref) => (
@@ -1,7 +1,5 @@
// components
import { EditorContainer, EditorContentWrapper } from "@/components/editors";
// constants
import { DEFAULT_DISPLAY_CONFIG } from "@/constants/config";
// helpers
import { getEditorClassNames } from "@/helpers/common";
// hooks
@@ -10,20 +8,12 @@ import { useReadOnlyEditor } from "@/hooks/use-read-only-editor";
import { IReadOnlyEditorProps } from "@/types";
export const ReadOnlyEditorWrapper = (props: IReadOnlyEditorProps) => {
const {
containerClassName,
displayConfig = DEFAULT_DISPLAY_CONFIG,
editorClassName = "",
id,
initialValue,
forwardedRef,
mentionHandler,
} = props;
const { containerClassName, editorClassName = "", id, initialValue, forwardedRef, mentionHandler } = props;
const editor = useReadOnlyEditor({
initialValue,
editorClassName,
forwardedRef,
initialValue,
mentionHandler,
});
@@ -34,12 +24,7 @@ export const ReadOnlyEditorWrapper = (props: IReadOnlyEditorProps) => {
if (!editor) return null;
return (
<EditorContainer
displayConfig={displayConfig}
editor={editor}
editorContainerClassName={editorContainerClassName}
id={id}
>
<EditorContainer editor={editor} editorContainerClassName={editorContainerClassName} id={id}>
<div className="flex flex-col">
<EditorContentWrapper editor={editor} id={id} />
</div>
@@ -1,30 +1,37 @@
import { forwardRef, useCallback } from "react";
import { forwardRef, useCallback, useState } from "react";
// components
import { EditorWrapper } from "@/components/editors";
import { EditorBubbleMenu } from "@/components/menus";
// extensions
import { SideMenuExtension, SlashCommand } from "@/extensions";
import { DragAndDrop, SlashCommand } from "@/extensions";
// types
import { EditorRefApi, IRichTextEditor } from "@/types";
const RichTextEditor = (props: IRichTextEditor) => {
const { dragDropEnabled, fileHandler } = props;
// states
const [hideDragHandleOnMouseLeave, setHideDragHandleOnMouseLeave] = useState<() => void>(() => {});
// this essentially sets the hideDragHandle function from the DragAndDrop extension as the Plugin
// loads such that we can invoke it from react when the cursor leaves the container
const setHideDragHandleFunction = (hideDragHandlerFromDragDrop: () => void) => {
setHideDragHandleOnMouseLeave(() => hideDragHandlerFromDragDrop);
};
const getExtensions = useCallback(() => {
const extensions = [SlashCommand(fileHandler.upload)];
const extensions = [
SlashCommand(fileHandler.upload),
// TODO; add the extension conditionally for forms that don't require it
// EnterKeyExtension(onEnterKeyPress),
];
extensions.push(
SideMenuExtension({
aiEnabled: false,
dragDropEnabled: !!dragDropEnabled,
})
);
if (dragDropEnabled) extensions.push(DragAndDrop(setHideDragHandleFunction));
return extensions;
}, [dragDropEnabled, fileHandler.upload]);
return (
<EditorWrapper {...props} extensions={getExtensions()}>
<EditorWrapper {...props} extensions={getExtensions()} hideDragHandleOnMouseLeave={hideDragHandleOnMouseLeave}>
{(editor) => <>{editor && <EditorBubbleMenu editor={editor} />}</>}
</EditorWrapper>
);
@@ -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>
);
};
@@ -14,7 +14,7 @@ export const BlockMenu = (props: BlockMenuProps) => {
const handleClickDragHandle = useCallback((event: MouseEvent) => {
const target = event.target as HTMLElement;
if (target.matches("#drag-handle")) {
if (target.matches(".drag-handle-dots") || target.matches(".drag-handle-dot")) {
event.preventDefault();
popup.current?.setProps({
@@ -1,4 +1,3 @@
export * from "./ai-menu";
export * from "./bubble-menu";
export * from "./block-menu";
export * from "./menu-items";
@@ -1,7 +0,0 @@
// types
import { TDisplayConfig } from "@/types";
export const DEFAULT_DISPLAY_CONFIG: TDisplayConfig = {
fontSize: "large-font",
fontStyle: "sans-serif",
};

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