fix: merge conflicts from preview

This commit is contained in:
sriram veeraghanta 2024-08-16 17:55:08 +05:30
commit 3729011cb0
283 changed files with 4895 additions and 5157 deletions

View file

@ -86,6 +86,7 @@ export const GeneralConfigurationForm: FC<IGeneralConfigurationForm> = observer(
value={instanceAdmins[0]?.user_detail?.email ?? ""} value={instanceAdmins[0]?.user_detail?.email ?? ""}
placeholder="Admin email" placeholder="Admin email"
className="w-full cursor-not-allowed !text-custom-text-400" className="w-full cursor-not-allowed !text-custom-text-400"
autoComplete="on"
disabled disabled
/> />
</div> </div>

View file

@ -96,7 +96,7 @@ export const HelpSection: FC = observer(() => {
leaveTo="transform opacity-0 scale-95" leaveTo="transform opacity-0 scale-95"
> >
<div <div
className={`absolute bottom-2 min-w-[10rem] ${ className={`absolute bottom-2 min-w-[10rem] z-[15] ${
isSidebarCollapsed ? "left-full" : "-left-[75px]" isSidebarCollapsed ? "left-full" : "-left-[75px]"
} divide-y divide-custom-border-200 whitespace-nowrap rounded bg-custom-background-100 p-1 shadow-custom-shadow-xs`} } divide-y divide-custom-border-200 whitespace-nowrap rounded bg-custom-background-100 p-1 shadow-custom-shadow-xs`}
ref={helpOptionsRef} ref={helpOptionsRef}

View file

@ -174,6 +174,7 @@ export const InstanceSetupForm: FC = (props) => {
placeholder="Wilber" placeholder="Wilber"
value={formData.first_name} value={formData.first_name}
onChange={(e) => handleFormChange("first_name", e.target.value)} onChange={(e) => handleFormChange("first_name", e.target.value)}
autoComplete="on"
autoFocus autoFocus
/> />
</div> </div>
@ -190,6 +191,7 @@ export const InstanceSetupForm: FC = (props) => {
placeholder="Wright" placeholder="Wright"
value={formData.last_name} value={formData.last_name}
onChange={(e) => handleFormChange("last_name", e.target.value)} onChange={(e) => handleFormChange("last_name", e.target.value)}
autoComplete="on"
/> />
</div> </div>
</div> </div>
@ -208,6 +210,7 @@ export const InstanceSetupForm: FC = (props) => {
value={formData.email} value={formData.email}
onChange={(e) => handleFormChange("email", e.target.value)} onChange={(e) => handleFormChange("email", e.target.value)}
hasError={errorData.type && errorData.type === EErrorCodes.INVALID_EMAIL ? true : false} hasError={errorData.type && errorData.type === EErrorCodes.INVALID_EMAIL ? true : false}
autoComplete="on"
/> />
{errorData.type && errorData.type === EErrorCodes.INVALID_EMAIL && errorData.message && ( {errorData.type && errorData.type === EErrorCodes.INVALID_EMAIL && errorData.message && (
<p className="px-1 text-xs text-red-500">{errorData.message}</p> <p className="px-1 text-xs text-red-500">{errorData.message}</p>
@ -247,6 +250,7 @@ export const InstanceSetupForm: FC = (props) => {
hasError={errorData.type && errorData.type === EErrorCodes.INVALID_PASSWORD ? true : false} hasError={errorData.type && errorData.type === EErrorCodes.INVALID_PASSWORD ? true : false}
onFocus={() => setIsPasswordInputFocused(true)} onFocus={() => setIsPasswordInputFocused(true)}
onBlur={() => setIsPasswordInputFocused(false)} onBlur={() => setIsPasswordInputFocused(false)}
autoComplete="on"
/> />
{showPassword.password ? ( {showPassword.password ? (
<button <button

View file

@ -127,6 +127,7 @@ export const InstanceSignInForm: FC = (props) => {
placeholder="name@company.com" placeholder="name@company.com"
value={formData.email} value={formData.email}
onChange={(e) => handleFormChange("email", e.target.value)} onChange={(e) => handleFormChange("email", e.target.value)}
autoComplete="on"
autoFocus autoFocus
/> />
</div> </div>
@ -145,6 +146,7 @@ export const InstanceSignInForm: FC = (props) => {
placeholder="Enter your password" placeholder="Enter your password"
value={formData.password} value={formData.password}
onChange={(e) => handleFormChange("password", e.target.value)} onChange={(e) => handleFormChange("password", e.target.value)}
autoComplete="on"
/> />
{showPassword ? ( {showPassword ? (
<button <button

View file

@ -18,7 +18,7 @@
"@tailwindcss/typography": "^0.5.9", "@tailwindcss/typography": "^0.5.9",
"@types/lodash": "^4.17.0", "@types/lodash": "^4.17.0",
"autoprefixer": "10.4.14", "autoprefixer": "10.4.14",
"axios": "^1.6.7", "axios": "^1.7.4",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"lucide-react": "^0.356.0", "lucide-react": "^0.356.0",

View file

@ -52,4 +52,4 @@ SPACE_BASE_URL=
APP_BASE_URL= APP_BASE_URL=
# Hard delete files after days # Hard delete files after days
HARD_DELETE_AFTER_DAYS= HARD_DELETE_AFTER_DAYS=60

View file

@ -32,4 +32,3 @@ python manage.py create_bucket
python manage.py clear_cache python manage.py clear_cache
python manage.py runserver 0.0.0.0:8000 --settings=plane.settings.local python manage.py runserver 0.0.0.0:8000 --settings=plane.settings.local

View file

@ -40,3 +40,44 @@ class ApiKeyRateThrottle(SimpleRateThrottle):
request.META["X-RateLimit-Reset"] = reset_time request.META["X-RateLimit-Reset"] = reset_time
return allowed 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

View file

@ -269,6 +269,7 @@ class LabelSerializer(BaseSerializer):
"updated_by", "updated_by",
"created_at", "created_at",
"updated_at", "updated_at",
"deleted_at",
] ]
@ -430,4 +431,3 @@ class IssueExpandSerializer(BaseSerializer):
"created_at", "created_at",
"updated_at", "updated_at",
] ]

View file

@ -23,6 +23,7 @@ class StateSerializer(BaseSerializer):
"updated_at", "updated_at",
"workspace", "workspace",
"project", "project",
"deleted_at",
] ]

View file

@ -7,6 +7,7 @@ from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.db import IntegrityError from django.db import IntegrityError
from django.urls import resolve from django.urls import resolve
from django.utils import timezone from django.utils import timezone
from plane.db.models.api import APIToken
from rest_framework import status from rest_framework import status
from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response from rest_framework.response import Response
@ -16,7 +17,7 @@ from rest_framework.views import APIView
# Module imports # Module imports
from plane.api.middleware.api_authentication import APIKeyAuthentication from plane.api.middleware.api_authentication import APIKeyAuthentication
from plane.api.rate_limit import ApiKeyRateThrottle from plane.api.rate_limit import ApiKeyRateThrottle, ServiceTokenRateThrottle
from plane.utils.exception_logger import log_exception from plane.utils.exception_logger import log_exception
from plane.utils.paginator import BasePaginator from plane.utils.paginator import BasePaginator
@ -44,15 +45,29 @@ class BaseAPIView(TimezoneMixin, APIView, BasePaginator):
IsAuthenticated, IsAuthenticated,
] ]
throttle_classes = [
ApiKeyRateThrottle,
]
def filter_queryset(self, queryset): def filter_queryset(self, queryset):
for backend in list(self.filter_backends): for backend in list(self.filter_backends):
queryset = backend().filter_queryset(self.request, queryset, self) queryset = backend().filter_queryset(self.request, queryset, self)
return queryset 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): def handle_exception(self, exc):
""" """
Handle any exception that occurs, by returning an appropriate response, Handle any exception that occurs, by returning an appropriate response,

View file

@ -26,7 +26,7 @@ from plane.api.serializers import (
CycleSerializer, CycleSerializer,
) )
from plane.app.permissions import ProjectEntityPermission from plane.app.permissions import ProjectEntityPermission
from plane.bgtasks.issue_activites_task import issue_activity from plane.bgtasks.issue_activities_task import issue_activity
from plane.db.models import ( from plane.db.models import (
Cycle, Cycle,
CycleIssue, CycleIssue,
@ -672,17 +672,6 @@ class CycleIssueAPIEndpoint(BaseAPIView):
workspace__slug=slug, project_id=project_id, pk=cycle_id 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 # Get all CycleIssues already created
cycle_issues = list( cycle_issues = list(
CycleIssue.objects.filter( CycleIssue.objects.filter(

View file

@ -16,7 +16,7 @@ from rest_framework.response import Response
# Module imports # Module imports
from plane.api.serializers import InboxIssueSerializer, IssueSerializer from plane.api.serializers import InboxIssueSerializer, IssueSerializer
from plane.app.permissions import ProjectLitePermission from plane.app.permissions import ProjectLitePermission
from plane.bgtasks.issue_activites_task import issue_activity from plane.bgtasks.issue_activities_task import issue_activity
from plane.db.models import ( from plane.db.models import (
Inbox, Inbox,
InboxIssue, InboxIssue,

View file

@ -38,7 +38,7 @@ from plane.app.permissions import (
ProjectLitePermission, ProjectLitePermission,
ProjectMemberPermission, ProjectMemberPermission,
) )
from plane.bgtasks.issue_activites_task import issue_activity from plane.bgtasks.issue_activities_task import issue_activity
from plane.db.models import ( from plane.db.models import (
Issue, Issue,
IssueActivity, IssueActivity,
@ -355,6 +355,124 @@ class IssueAPIEndpoint(BaseAPIView):
return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) 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): def patch(self, request, slug, project_id, pk=None):
issue = Issue.objects.get( issue = Issue.objects.get(
workspace__slug=slug, project_id=project_id, pk=pk workspace__slug=slug, project_id=project_id, pk=pk

View file

@ -18,7 +18,7 @@ from plane.api.serializers import (
ModuleSerializer, ModuleSerializer,
) )
from plane.app.permissions import ProjectEntityPermission from plane.app.permissions import ProjectEntityPermission
from plane.bgtasks.issue_activites_task import issue_activity from plane.bgtasks.issue_activities_task import issue_activity
from plane.db.models import ( from plane.db.models import (
Issue, Issue,
IssueAttachment, IssueAttachment,
@ -520,7 +520,6 @@ class ModuleIssueAPIEndpoint(BaseAPIView):
class ModuleArchiveUnarchiveAPIEndpoint(BaseAPIView): class ModuleArchiveUnarchiveAPIEndpoint(BaseAPIView):
permission_classes = [ permission_classes = [
ProjectEntityPermission, ProjectEntityPermission,
] ]

View file

@ -12,3 +12,4 @@ from .project import (
ProjectMemberPermission, ProjectMemberPermission,
ProjectLitePermission, ProjectLitePermission,
) )
from .base import allow_permission, ROLE

View file

@ -0,0 +1,61 @@
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_401_UNAUTHORIZED,
)
return _wrapped_view
return decorator

View file

@ -1,5 +1,5 @@
from django.urls import path from django.urls import path
from plane.app.views import ApiTokenEndpoint from plane.app.views import ApiTokenEndpoint, ServiceApiTokenEndpoint
urlpatterns = [ urlpatterns = [
# API Tokens # API Tokens
@ -13,5 +13,10 @@ urlpatterns = [
ApiTokenEndpoint.as_view(), ApiTokenEndpoint.as_view(),
name="api-tokens", name="api-tokens",
), ),
path(
"workspaces/<str:slug>/service-api-tokens/",
ServiceApiTokenEndpoint.as_view(),
name="service-api-tokens",
),
## End API Tokens ## End API Tokens
] ]

View file

@ -40,7 +40,7 @@ urlpatterns = [
name="inbox-issue", name="inbox-issue",
), ),
path( path(
"workspaces/<str:slug>/projects/<uuid:project_id>/inbox-issues/<uuid:issue_id>/", "workspaces/<str:slug>/projects/<uuid:project_id>/inbox-issues/<uuid:pk>/",
InboxIssueViewSet.as_view( InboxIssueViewSet.as_view(
{ {
"get": "retrieve", "get": "retrieve",

View file

@ -174,8 +174,10 @@ from .module.archive import (
ModuleArchiveUnarchiveEndpoint, ModuleArchiveUnarchiveEndpoint,
) )
from .api import ApiTokenEndpoint from .api import (
ApiTokenEndpoint,
ServiceApiTokenEndpoint,
)
from .page.base import ( from .page.base import (
PageViewSet, PageViewSet,

View file

@ -7,22 +7,22 @@ from django.utils import timezone
from rest_framework import status from rest_framework import status
from rest_framework.response import Response from rest_framework.response import Response
# Module imports
from plane.app.permissions import WorkSpaceAdminPermission from plane.app.permissions import WorkSpaceAdminPermission
from plane.app.serializers import AnalyticViewSerializer from plane.app.serializers import AnalyticViewSerializer
# Module imports
from plane.app.views.base import BaseAPIView, BaseViewSet from plane.app.views.base import BaseAPIView, BaseViewSet
from plane.bgtasks.analytic_plot_export import analytic_export_task from plane.bgtasks.analytic_plot_export import analytic_export_task
from plane.db.models import AnalyticView, Issue, Workspace from plane.db.models import AnalyticView, Issue, Workspace
from plane.utils.analytics_plot import build_graph_plot from plane.utils.analytics_plot import build_graph_plot
from plane.utils.issue_filters import issue_filters from plane.utils.issue_filters import issue_filters
from plane.app.permissions import allow_permission, ROLE
class AnalyticsEndpoint(BaseAPIView): class AnalyticsEndpoint(BaseAPIView):
permission_classes = [
WorkSpaceAdminPermission,
]
@allow_permission(
[ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER], level="WORKSPACE"
)
def get(self, request, slug): def get(self, request, slug):
x_axis = request.GET.get("x_axis", False) x_axis = request.GET.get("x_axis", False)
y_axis = request.GET.get("y_axis", False) y_axis = request.GET.get("y_axis", False)
@ -201,10 +201,10 @@ class AnalyticViewViewset(BaseViewSet):
class SavedAnalyticEndpoint(BaseAPIView): class SavedAnalyticEndpoint(BaseAPIView):
permission_classes = [
WorkSpaceAdminPermission,
]
@allow_permission(
[ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER], level="WORKSPACE"
)
def get(self, request, slug, analytic_id): def get(self, request, slug, analytic_id):
analytic_view = AnalyticView.objects.get( analytic_view = AnalyticView.objects.get(
pk=analytic_id, workspace__slug=slug pk=analytic_id, workspace__slug=slug
@ -234,10 +234,10 @@ class SavedAnalyticEndpoint(BaseAPIView):
class ExportAnalyticsEndpoint(BaseAPIView): class ExportAnalyticsEndpoint(BaseAPIView):
permission_classes = [
WorkSpaceAdminPermission,
]
@allow_permission(
[ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER], level="WORKSPACE"
)
def post(self, request, slug): def post(self, request, slug):
x_axis = request.data.get("x_axis", False) x_axis = request.data.get("x_axis", False)
y_axis = request.data.get("y_axis", False) y_axis = request.data.get("y_axis", False)
@ -301,10 +301,10 @@ class ExportAnalyticsEndpoint(BaseAPIView):
class DefaultAnalyticsEndpoint(BaseAPIView): class DefaultAnalyticsEndpoint(BaseAPIView):
permission_classes = [
WorkSpaceAdminPermission,
]
@allow_permission(
[ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST], level="WORKSPACE"
)
def get(self, request, slug): def get(self, request, slug):
filters = issue_filters(request.GET, "GET") filters = issue_filters(request.GET, "GET")
base_issues = Issue.issue_objects.filter( base_issues = Issue.issue_objects.filter(
@ -380,12 +380,10 @@ class DefaultAnalyticsEndpoint(BaseAPIView):
.order_by("-count") .order_by("-count")
) )
open_estimate_sum = open_issues_queryset.aggregate( open_estimate_sum = open_issues_queryset.aggregate(sum=Sum("point"))[
sum=Sum("point")
)["sum"]
total_estimate_sum = base_issues.aggregate(sum=Sum("point"))[
"sum" "sum"
] ]
total_estimate_sum = base_issues.aggregate(sum=Sum("point"))["sum"]
return Response( return Response(
{ {

View file

@ -45,7 +45,7 @@ class ApiTokenEndpoint(BaseAPIView):
def get(self, request, slug, pk=None): def get(self, request, slug, pk=None):
if pk is None: if pk is None:
api_tokens = APIToken.objects.filter( api_tokens = APIToken.objects.filter(
user=request.user, workspace__slug=slug user=request.user, workspace__slug=slug, is_service=False
) )
serializer = APITokenReadSerializer(api_tokens, many=True) serializer = APITokenReadSerializer(api_tokens, many=True)
return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.data, status=status.HTTP_200_OK)
@ -61,6 +61,7 @@ class ApiTokenEndpoint(BaseAPIView):
workspace__slug=slug, workspace__slug=slug,
user=request.user, user=request.user,
pk=pk, pk=pk,
is_service=False,
) )
api_token.delete() api_token.delete()
return Response(status=status.HTTP_204_NO_CONTENT) return Response(status=status.HTTP_204_NO_CONTENT)
@ -78,3 +79,44 @@ class ApiTokenEndpoint(BaseAPIView):
serializer.save() serializer.save()
return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) 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,
)

View file

@ -24,7 +24,7 @@ from django.utils import timezone
# Third party imports # Third party imports
from rest_framework import status from rest_framework import status
from rest_framework.response import Response from rest_framework.response import Response
from plane.app.permissions import ProjectEntityPermission from plane.app.permissions import allow_permission, ROLE
from plane.db.models import Cycle, UserFavorite, Issue, Label, User, Project from plane.db.models import Cycle, UserFavorite, Issue, Label, User, Project
from plane.utils.analytics_plot import burndown_plot from plane.utils.analytics_plot import burndown_plot
@ -34,10 +34,6 @@ from .. import BaseAPIView
class CycleArchiveUnarchiveEndpoint(BaseAPIView): class CycleArchiveUnarchiveEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
def get_queryset(self): def get_queryset(self):
favorite_subquery = UserFavorite.objects.filter( favorite_subquery = UserFavorite.objects.filter(
user=self.request.user, user=self.request.user,
@ -292,6 +288,7 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView):
.distinct() .distinct()
) )
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER])
def get(self, request, slug, project_id, pk=None): def get(self, request, slug, project_id, pk=None):
if pk is None: if pk is None:
queryset = ( queryset = (
@ -596,6 +593,7 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView):
status=status.HTTP_200_OK, status=status.HTTP_200_OK,
) )
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def post(self, request, slug, project_id, cycle_id): def post(self, request, slug, project_id, cycle_id):
cycle = Cycle.objects.get( cycle = Cycle.objects.get(
pk=cycle_id, project_id=project_id, workspace__slug=slug pk=cycle_id, project_id=project_id, workspace__slug=slug
@ -614,6 +612,7 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView):
status=status.HTTP_200_OK, status=status.HTTP_200_OK,
) )
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def delete(self, request, slug, project_id, cycle_id): def delete(self, request, slug, project_id, cycle_id):
cycle = Cycle.objects.get( cycle = Cycle.objects.get(
pk=cycle_id, project_id=project_id, workspace__slug=slug pk=cycle_id, project_id=project_id, workspace__slug=slug

View file

@ -29,15 +29,14 @@ from django.core.serializers.json import DjangoJSONEncoder
from rest_framework import status from rest_framework import status
from rest_framework.response import Response from rest_framework.response import Response
from plane.app.permissions import ( from plane.app.permissions import (
ProjectEntityPermission, allow_permission, ROLE
ProjectLitePermission,
) )
from plane.app.serializers import ( from plane.app.serializers import (
CycleSerializer, CycleSerializer,
CycleUserPropertiesSerializer, CycleUserPropertiesSerializer,
CycleWriteSerializer, CycleWriteSerializer,
) )
from plane.bgtasks.issue_activites_task import issue_activity from plane.bgtasks.issue_activities_task import issue_activity
from plane.db.models import ( from plane.db.models import (
Cycle, Cycle,
CycleIssue, CycleIssue,
@ -60,15 +59,6 @@ class CycleViewSet(BaseViewSet):
serializer_class = CycleSerializer serializer_class = CycleSerializer
model = Cycle model = Cycle
webhook_event = "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): def get_queryset(self):
favorite_subquery = UserFavorite.objects.filter( favorite_subquery = UserFavorite.objects.filter(
@ -325,6 +315,7 @@ class CycleViewSet(BaseViewSet):
.distinct() .distinct()
) )
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST])
def list(self, request, slug, project_id): def list(self, request, slug, project_id):
queryset = self.get_queryset().filter(archived_at__isnull=True) queryset = self.get_queryset().filter(archived_at__isnull=True)
cycle_view = request.GET.get("cycle_view", "all") cycle_view = request.GET.get("cycle_view", "all")
@ -611,6 +602,7 @@ class CycleViewSet(BaseViewSet):
) )
return Response(data, status=status.HTTP_200_OK) return Response(data, status=status.HTTP_200_OK)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def create(self, request, slug, project_id): def create(self, request, slug, project_id):
if ( if (
request.data.get("start_date", None) is None request.data.get("start_date", None) is None
@ -684,6 +676,7 @@ class CycleViewSet(BaseViewSet):
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
) )
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def partial_update(self, request, slug, project_id, pk): def partial_update(self, request, slug, project_id, pk):
queryset = self.get_queryset().filter( queryset = self.get_queryset().filter(
workspace__slug=slug, project_id=project_id, pk=pk workspace__slug=slug, project_id=project_id, pk=pk
@ -771,6 +764,7 @@ class CycleViewSet(BaseViewSet):
return Response(cycle, status=status.HTTP_200_OK) return Response(cycle, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) 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): def retrieve(self, request, slug, project_id, pk):
queryset = ( queryset = (
self.get_queryset().filter(archived_at__isnull=True).filter(pk=pk) self.get_queryset().filter(archived_at__isnull=True).filter(pk=pk)
@ -1039,6 +1033,7 @@ class CycleViewSet(BaseViewSet):
status=status.HTTP_200_OK, status=status.HTTP_200_OK,
) )
@allow_permission([ROLE.ADMIN], creator=True, model=Cycle)
def destroy(self, request, slug, project_id, pk): def destroy(self, request, slug, project_id, pk):
cycle = Cycle.objects.get( cycle = Cycle.objects.get(
workspace__slug=slug, project_id=project_id, pk=pk workspace__slug=slug, project_id=project_id, pk=pk
@ -1097,10 +1092,8 @@ class CycleViewSet(BaseViewSet):
class CycleDateCheckEndpoint(BaseAPIView): class CycleDateCheckEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def post(self, request, slug, project_id): def post(self, request, slug, project_id):
start_date = request.data.get("start_date", False) start_date = request.data.get("start_date", False)
end_date = request.data.get("end_date", False) end_date = request.data.get("end_date", False)
@ -1144,6 +1137,7 @@ class CycleFavoriteViewSet(BaseViewSet):
.select_related("cycle", "cycle__owned_by") .select_related("cycle", "cycle__owned_by")
) )
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def create(self, request, slug, project_id): def create(self, request, slug, project_id):
_ = UserFavorite.objects.create( _ = UserFavorite.objects.create(
project_id=project_id, project_id=project_id,
@ -1153,6 +1147,7 @@ class CycleFavoriteViewSet(BaseViewSet):
) )
return Response(status=status.HTTP_204_NO_CONTENT) return Response(status=status.HTTP_204_NO_CONTENT)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def destroy(self, request, slug, project_id, cycle_id): def destroy(self, request, slug, project_id, cycle_id):
cycle_favorite = UserFavorite.objects.get( cycle_favorite = UserFavorite.objects.get(
project=project_id, project=project_id,
@ -1166,10 +1161,8 @@ class CycleFavoriteViewSet(BaseViewSet):
class TransferCycleIssueEndpoint(BaseAPIView): class TransferCycleIssueEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def post(self, request, slug, project_id, cycle_id): def post(self, request, slug, project_id, cycle_id):
new_cycle_id = request.data.get("new_cycle_id", False) new_cycle_id = request.data.get("new_cycle_id", False)
@ -1579,10 +1572,8 @@ class TransferCycleIssueEndpoint(BaseAPIView):
class CycleUserPropertiesEndpoint(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): def patch(self, request, slug, project_id, cycle_id):
cycle_properties = CycleUserProperties.objects.get( cycle_properties = CycleUserProperties.objects.get(
user=request.user, user=request.user,
@ -1605,6 +1596,7 @@ class CycleUserPropertiesEndpoint(BaseAPIView):
serializer = CycleUserPropertiesSerializer(cycle_properties) serializer = CycleUserPropertiesSerializer(cycle_properties)
return Response(serializer.data, status=status.HTTP_201_CREATED) 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): def get(self, request, slug, project_id, cycle_id):
cycle_properties, _ = CycleUserProperties.objects.get_or_create( cycle_properties, _ = CycleUserProperties.objects.get_or_create(
user=request.user, user=request.user,

View file

@ -3,12 +3,7 @@ import json
# Django imports # Django imports
from django.core import serializers from django.core import serializers
from django.db.models import ( from django.db.models import F, Func, OuterRef, Q
F,
Func,
OuterRef,
Q,
)
from django.utils import timezone from django.utils import timezone
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page from django.views.decorators.gzip import gzip_page
@ -17,16 +12,12 @@ from django.views.decorators.gzip import gzip_page
from rest_framework import status from rest_framework import status
from rest_framework.response import Response from rest_framework.response import Response
from plane.app.permissions import (
ProjectEntityPermission,
)
# Module imports # Module imports
from .. import BaseViewSet from .. import BaseViewSet
from plane.app.serializers import ( from plane.app.serializers import (
CycleIssueSerializer, CycleIssueSerializer,
) )
from plane.bgtasks.issue_activites_task import issue_activity from plane.bgtasks.issue_activities_task import issue_activity
from plane.db.models import ( from plane.db.models import (
Cycle, Cycle,
CycleIssue, CycleIssue,
@ -45,6 +36,7 @@ from plane.utils.paginator import (
GroupedOffsetPaginator, GroupedOffsetPaginator,
SubGroupedOffsetPaginator, SubGroupedOffsetPaginator,
) )
from plane.app.permissions import allow_permission, ROLE
class CycleIssueViewSet(BaseViewSet): class CycleIssueViewSet(BaseViewSet):
@ -54,10 +46,6 @@ class CycleIssueViewSet(BaseViewSet):
webhook_event = "cycle_issue" webhook_event = "cycle_issue"
bulk = True bulk = True
permission_classes = [
ProjectEntityPermission,
]
filterset_fields = [ filterset_fields = [
"issue__labels__id", "issue__labels__id",
"issue__assignees__id", "issue__assignees__id",
@ -92,6 +80,7 @@ class CycleIssueViewSet(BaseViewSet):
) )
@method_decorator(gzip_page) @method_decorator(gzip_page)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER])
def list(self, request, slug, project_id, cycle_id): def list(self, request, slug, project_id, cycle_id):
order_by_param = request.GET.get("order_by", "created_at") order_by_param = request.GET.get("order_by", "created_at")
filters = issue_filters(request.query_params, "GET") filters = issue_filters(request.query_params, "GET")
@ -238,6 +227,7 @@ class CycleIssueViewSet(BaseViewSet):
), ),
) )
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def create(self, request, slug, project_id, cycle_id): def create(self, request, slug, project_id, cycle_id):
issues = request.data.get("issues", []) issues = request.data.get("issues", [])
@ -333,6 +323,7 @@ class CycleIssueViewSet(BaseViewSet):
) )
return Response({"message": "success"}, status=status.HTTP_201_CREATED) 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): def destroy(self, request, slug, project_id, cycle_id, issue_id):
cycle_issue = CycleIssue.objects.filter( cycle_issue = CycleIssue.objects.filter(
issue_id=issue_id, issue_id=issue_id,

View file

@ -43,6 +43,7 @@ from plane.db.models import (
ProjectMember, ProjectMember,
User, User,
Widget, Widget,
WorkspaceMember,
) )
from plane.utils.issue_filters import issue_filters from plane.utils.issue_filters import issue_filters
@ -51,36 +52,61 @@ from .. import BaseAPIView
def dashboard_overview_stats(self, request, slug): def dashboard_overview_stats(self, request, slug):
assigned_issues = Issue.issue_objects.filter( extra_filters = {}
if WorkspaceMember.objects.filter(
workspace__slug=slug,
member=request.user,
role=5,
is_active=True,
).exists():
extra_filters = {"created_by": request.user}
assigned_issues = (
Issue.issue_objects.filter(
project__project_projectmember__is_active=True, project__project_projectmember__is_active=True,
project__project_projectmember__member=request.user, project__project_projectmember__member=request.user,
workspace__slug=slug, workspace__slug=slug,
assignees__in=[request.user], assignees__in=[request.user],
).count() )
.filter(**extra_filters)
.count()
)
pending_issues_count = Issue.issue_objects.filter( pending_issues_count = (
Issue.issue_objects.filter(
~Q(state__group__in=["completed", "cancelled"]), ~Q(state__group__in=["completed", "cancelled"]),
target_date__lt=timezone.now().date(), target_date__lt=timezone.now().date(),
project__project_projectmember__is_active=True, project__project_projectmember__is_active=True,
project__project_projectmember__member=request.user, project__project_projectmember__member=request.user,
workspace__slug=slug, workspace__slug=slug,
assignees__in=[request.user], assignees__in=[request.user],
).count() )
.filter(**extra_filters)
.count()
)
created_issues_count = Issue.issue_objects.filter( created_issues_count = (
Issue.issue_objects.filter(
workspace__slug=slug, workspace__slug=slug,
project__project_projectmember__is_active=True, project__project_projectmember__is_active=True,
project__project_projectmember__member=request.user, project__project_projectmember__member=request.user,
created_by_id=request.user.id, created_by_id=request.user.id,
).count() )
.filter(**extra_filters)
.count()
)
completed_issues_count = Issue.issue_objects.filter( completed_issues_count = (
Issue.issue_objects.filter(
workspace__slug=slug, workspace__slug=slug,
project__project_projectmember__is_active=True, project__project_projectmember__is_active=True,
project__project_projectmember__member=request.user, project__project_projectmember__member=request.user,
assignees__in=[request.user], assignees__in=[request.user],
state__group="completed", state__group="completed",
).count() )
.filter(**extra_filters)
.count()
)
return Response( return Response(
{ {
@ -166,6 +192,14 @@ 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 Ordering
priority_order = ["urgent", "high", "medium", "low", "none"] priority_order = ["urgent", "high", "medium", "low", "none"]
assigned_issues = assigned_issues.annotate( assigned_issues = assigned_issues.annotate(
@ -409,6 +443,16 @@ def dashboard_created_issues(self, request, slug):
def dashboard_issues_by_state_groups(self, request, slug): def dashboard_issues_by_state_groups(self, request, slug):
filters = issue_filters(request.query_params, "GET") filters = issue_filters(request.query_params, "GET")
state_order = ["backlog", "unstarted", "started", "completed", "cancelled"] 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 = ( issues_by_state_groups = (
Issue.issue_objects.filter( Issue.issue_objects.filter(
workspace__slug=slug, workspace__slug=slug,
@ -416,7 +460,7 @@ def dashboard_issues_by_state_groups(self, request, slug):
project__project_projectmember__member=request.user, project__project_projectmember__member=request.user,
assignees__in=[request.user], assignees__in=[request.user],
) )
.filter(**filters) .filter(**filters, **extra_filters)
.values("state__group") .values("state__group")
.annotate(count=Count("id")) .annotate(count=Count("id"))
) )
@ -439,6 +483,15 @@ def dashboard_issues_by_state_groups(self, request, slug):
def dashboard_issues_by_priority(self, request, slug): def dashboard_issues_by_priority(self, request, slug):
filters = issue_filters(request.query_params, "GET") filters = issue_filters(request.query_params, "GET")
priority_order = ["urgent", "high", "medium", "low", "none"] 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 = ( issues_by_priority = (
Issue.issue_objects.filter( Issue.issue_objects.filter(
@ -447,7 +500,7 @@ def dashboard_issues_by_priority(self, request, slug):
project__project_projectmember__member=request.user, project__project_projectmember__member=request.user,
assignees__in=[request.user], assignees__in=[request.user],
) )
.filter(**filters) .filter(**filters, **extra_filters)
.values("priority") .values("priority")
.annotate(count=Count("id")) .annotate(count=Count("id"))
) )

View file

@ -7,7 +7,11 @@ from rest_framework import status
# Module imports # Module imports
from ..base import BaseViewSet, BaseAPIView from ..base import BaseViewSet, BaseAPIView
from plane.app.permissions import ProjectEntityPermission from plane.app.permissions import (
ProjectEntityPermission,
allow_permission,
ROLE,
)
from plane.db.models import Project, Estimate, EstimatePoint, Issue from plane.db.models import Project, Estimate, EstimatePoint, Issue
from plane.app.serializers import ( from plane.app.serializers import (
EstimateSerializer, EstimateSerializer,
@ -23,10 +27,8 @@ def generate_random_name(length=10):
class ProjectEstimatePointEndpoint(BaseAPIView): class ProjectEstimatePointEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER])
def get(self, request, slug, project_id): def get(self, request, slug, project_id):
project = Project.objects.get(workspace__slug=slug, pk=project_id) project = Project.objects.get(workspace__slug=slug, pk=project_id)
if project.estimate_id is not None: if project.estimate_id is not None:
@ -189,10 +191,8 @@ class BulkEstimatePointEndpoint(BaseViewSet):
class EstimatePointEndpoint(BaseViewSet): class EstimatePointEndpoint(BaseViewSet):
permission_classes = [
ProjectEntityPermission,
]
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def create(self, request, slug, project_id, estimate_id): def create(self, request, slug, project_id, estimate_id):
# TODO: add a key validation if the same key already exists # TODO: add a key validation if the same key already exists
if not request.data.get("key") or not request.data.get("value"): if not request.data.get("key") or not request.data.get("value"):
@ -211,6 +211,7 @@ class EstimatePointEndpoint(BaseViewSet):
serializer = EstimatePointSerializer(estimate_point).data serializer = EstimatePointSerializer(estimate_point).data
return Response(serializer, status=status.HTTP_200_OK) return Response(serializer, status=status.HTTP_200_OK)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def partial_update( def partial_update(
self, request, slug, project_id, estimate_id, estimate_point_id self, request, slug, project_id, estimate_id, estimate_point_id
): ):
@ -231,6 +232,7 @@ class EstimatePointEndpoint(BaseViewSet):
serializer.save() serializer.save()
return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.data, status=status.HTTP_200_OK)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def destroy( def destroy(
self, request, slug, project_id, estimate_id, estimate_point_id self, request, slug, project_id, estimate_id, estimate_point_id
): ):

View file

@ -2,7 +2,7 @@
from rest_framework import status from rest_framework import status
from rest_framework.response import Response from rest_framework.response import Response
from plane.app.permissions import WorkSpaceAdminPermission from plane.app.permissions import allow_permission, ROLE
from plane.app.serializers import ExporterHistorySerializer from plane.app.serializers import ExporterHistorySerializer
from plane.bgtasks.export_task import issue_export_task from plane.bgtasks.export_task import issue_export_task
from plane.db.models import ExporterHistory, Project, Workspace from plane.db.models import ExporterHistory, Project, Workspace
@ -12,12 +12,10 @@ from .. import BaseAPIView
class ExportIssuesEndpoint(BaseAPIView): class ExportIssuesEndpoint(BaseAPIView):
permission_classes = [
WorkSpaceAdminPermission,
]
model = ExporterHistory model = ExporterHistory
serializer_class = ExporterHistorySerializer serializer_class = ExporterHistorySerializer
@allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE")
def post(self, request, slug): def post(self, request, slug):
# Get the workspace # Get the workspace
workspace = Workspace.objects.get(slug=slug) workspace = Workspace.objects.get(slug=slug)
@ -64,6 +62,7 @@ class ExportIssuesEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
) )
@allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE")
def get(self, request, slug): def get(self, request, slug):
exporter_history = ExporterHistory.objects.filter( exporter_history = ExporterHistory.objects.filter(
workspace__slug=slug, workspace__slug=slug,

View file

@ -11,7 +11,7 @@ from rest_framework import status
# Module imports # Module imports
from ..base import BaseAPIView from ..base import BaseAPIView
from plane.app.permissions import ProjectEntityPermission, WorkspaceEntityPermission from plane.app.permissions import allow_permission, ROLE
from plane.db.models import Workspace, Project from plane.db.models import Workspace, Project
from plane.app.serializers import ( from plane.app.serializers import (
ProjectLiteSerializer, ProjectLiteSerializer,
@ -21,10 +21,8 @@ from plane.license.utils.instance_value import get_configuration_value
class GPTIntegrationEndpoint(BaseAPIView): class GPTIntegrationEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def post(self, request, slug, project_id): def post(self, request, slug, project_id):
OPENAI_API_KEY, GPT_ENGINE = get_configuration_value( OPENAI_API_KEY, GPT_ENGINE = get_configuration_value(
[ [
@ -84,10 +82,10 @@ class GPTIntegrationEndpoint(BaseAPIView):
class WorkspaceGPTIntegrationEndpoint(BaseAPIView): class WorkspaceGPTIntegrationEndpoint(BaseAPIView):
permission_classes = [
WorkspaceEntityPermission,
]
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE"
)
def post(self, request, slug): def post(self, request, slug):
OPENAI_API_KEY, GPT_ENGINE = get_configuration_value( OPENAI_API_KEY, GPT_ENGINE = get_configuration_value(
[ [

View file

@ -16,7 +16,9 @@ from rest_framework.response import Response
# Module imports # Module imports
from ..base import BaseViewSet from ..base import BaseViewSet
from plane.app.permissions import ProjectBasePermission, ProjectLitePermission from plane.app.permissions import (
allow_permission, ROLE
)
from plane.db.models import ( from plane.db.models import (
Inbox, Inbox,
InboxIssue, InboxIssue,
@ -35,13 +37,10 @@ from plane.app.serializers import (
InboxIssueDetailSerializer, InboxIssueDetailSerializer,
) )
from plane.utils.issue_filters import issue_filters from plane.utils.issue_filters import issue_filters
from plane.bgtasks.issue_activites_task import issue_activity from plane.bgtasks.issue_activities_task import issue_activity
class InboxViewSet(BaseViewSet): class InboxViewSet(BaseViewSet):
permission_classes = [
ProjectBasePermission,
]
serializer_class = InboxSerializer serializer_class = InboxSerializer
model = Inbox model = Inbox
@ -63,6 +62,7 @@ class InboxViewSet(BaseViewSet):
.select_related("workspace", "project") .select_related("workspace", "project")
) )
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def list(self, request, slug, project_id): def list(self, request, slug, project_id):
inbox = self.get_queryset().first() inbox = self.get_queryset().first()
return Response( return Response(
@ -70,9 +70,11 @@ class InboxViewSet(BaseViewSet):
status=status.HTTP_200_OK, status=status.HTTP_200_OK,
) )
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def perform_create(self, serializer): def perform_create(self, serializer):
serializer.save(project_id=self.kwargs.get("project_id")) serializer.save(project_id=self.kwargs.get("project_id"))
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def destroy(self, request, slug, project_id, pk): def destroy(self, request, slug, project_id, pk):
inbox = Inbox.objects.filter( inbox = Inbox.objects.filter(
workspace__slug=slug, project_id=project_id, pk=pk workspace__slug=slug, project_id=project_id, pk=pk
@ -88,9 +90,6 @@ class InboxViewSet(BaseViewSet):
class InboxIssueViewSet(BaseViewSet): class InboxIssueViewSet(BaseViewSet):
permission_classes = [
ProjectLitePermission,
]
serializer_class = InboxIssueSerializer serializer_class = InboxIssueSerializer
model = InboxIssue model = InboxIssue
@ -160,13 +159,15 @@ class InboxIssueViewSet(BaseViewSet):
ArrayAgg( ArrayAgg(
"issue_module__module_id", "issue_module__module_id",
distinct=True, distinct=True,
filter=~Q(issue_module__module_id__isnull=True), filter=~Q(issue_module__module_id__isnull=True)
& Q(issue_module__module__archived_at__isnull=True),
), ),
Value([], output_field=ArrayField(UUIDField())), Value([], output_field=ArrayField(UUIDField())),
), ),
) )
).distinct() ).distinct()
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST])
def list(self, request, slug, project_id): def list(self, request, slug, project_id):
inbox_id = Inbox.objects.filter( inbox_id = Inbox.objects.filter(
workspace__slug=slug, project_id=project_id workspace__slug=slug, project_id=project_id
@ -200,6 +201,14 @@ class InboxIssueViewSet(BaseViewSet):
if inbox_status: if inbox_status:
inbox_issue = inbox_issue.filter(status__in=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( return self.paginate(
request=request, request=request,
queryset=(inbox_issue), queryset=(inbox_issue),
@ -209,6 +218,7 @@ class InboxIssueViewSet(BaseViewSet):
).data, ).data,
) )
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def create(self, request, slug, project_id): def create(self, request, slug, project_id):
if not request.data.get("issue", {}).get("name", False): if not request.data.get("issue", {}).get("name", False):
return Response( return Response(
@ -311,12 +321,13 @@ class InboxIssueViewSet(BaseViewSet):
serializer.errors, status=status.HTTP_400_BAD_REQUEST serializer.errors, status=status.HTTP_400_BAD_REQUEST
) )
def partial_update(self, request, slug, project_id, issue_id): @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def partial_update(self, request, slug, project_id, pk):
inbox_id = Inbox.objects.filter( inbox_id = Inbox.objects.filter(
workspace__slug=slug, project_id=project_id workspace__slug=slug, project_id=project_id
).first() ).first()
inbox_issue = InboxIssue.objects.get( inbox_issue = InboxIssue.objects.get(
issue_id=issue_id, issue_id=pk,
workspace__slug=slug, workspace__slug=slug,
project_id=project_id, project_id=project_id,
inbox_id=inbox_id, inbox_id=inbox_id,
@ -457,7 +468,7 @@ class InboxIssueViewSet(BaseViewSet):
request.data, cls=DjangoJSONEncoder request.data, cls=DjangoJSONEncoder
), ),
actor_id=str(request.user.id), actor_id=str(request.user.id),
issue_id=str(issue_id), issue_id=str(pk),
project_id=str(project_id), project_id=str(project_id),
current_instance=current_instance, current_instance=current_instance,
epoch=int(timezone.now().timestamp()), epoch=int(timezone.now().timestamp()),
@ -492,7 +503,7 @@ class InboxIssueViewSet(BaseViewSet):
) )
.get( .get(
inbox_id=inbox_id.id, inbox_id=inbox_id.id,
issue_id=issue_id, issue_id=pk,
project_id=project_id, project_id=project_id,
) )
) )
@ -505,7 +516,12 @@ class InboxIssueViewSet(BaseViewSet):
serializer = InboxIssueDetailSerializer(inbox_issue).data serializer = InboxIssueDetailSerializer(inbox_issue).data
return Response(serializer, status=status.HTTP_200_OK) return Response(serializer, status=status.HTTP_200_OK)
def retrieve(self, request, slug, project_id, issue_id): @allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER],
creator=True,
model=Issue,
)
def retrieve(self, request, slug, project_id, pk):
inbox_id = Inbox.objects.filter( inbox_id = Inbox.objects.filter(
workspace__slug=slug, project_id=project_id workspace__slug=slug, project_id=project_id
).first() ).first()
@ -533,9 +549,7 @@ class InboxIssueViewSet(BaseViewSet):
Value([], output_field=ArrayField(UUIDField())), Value([], output_field=ArrayField(UUIDField())),
), ),
) )
.get( .get(inbox_id=inbox_id.id, issue_id=pk, project_id=project_id)
inbox_id=inbox_id.id, issue_id=issue_id, project_id=project_id
)
) )
issue = InboxIssueDetailSerializer(inbox_issue).data issue = InboxIssueDetailSerializer(inbox_issue).data
return Response( return Response(
@ -543,12 +557,13 @@ class InboxIssueViewSet(BaseViewSet):
status=status.HTTP_200_OK, status=status.HTTP_200_OK,
) )
def destroy(self, request, slug, project_id, issue_id): @allow_permission(allowed_roles=[ROLE.ADMIN], creator=True, model=Issue)
def destroy(self, request, slug, project_id, pk):
inbox_id = Inbox.objects.filter( inbox_id = Inbox.objects.filter(
workspace__slug=slug, project_id=project_id workspace__slug=slug, project_id=project_id
).first() ).first()
inbox_issue = InboxIssue.objects.get( inbox_issue = InboxIssue.objects.get(
issue_id=issue_id, issue_id=pk,
workspace__slug=slug, workspace__slug=slug,
project_id=project_id, project_id=project_id,
inbox_id=inbox_id, inbox_id=inbox_id,
@ -558,21 +573,8 @@ class InboxIssueViewSet(BaseViewSet):
if inbox_issue.status in [-2, -1, 0, 2]: if inbox_issue.status in [-2, -1, 0, 2]:
# Delete the issue also # Delete the issue also
issue = Issue.objects.filter( issue = Issue.objects.filter(
workspace__slug=slug, project_id=project_id, pk=issue_id workspace__slug=slug, project_id=project_id, pk=pk
).first() ).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() issue.delete()
inbox_issue.delete() inbox_issue.delete()

View file

@ -19,7 +19,7 @@ from plane.app.serializers import (
IssueActivitySerializer, IssueActivitySerializer,
IssueCommentSerializer, IssueCommentSerializer,
) )
from plane.app.permissions import ProjectEntityPermission from plane.app.permissions import ProjectEntityPermission, allow_permission, ROLE
from plane.db.models import ( from plane.db.models import (
IssueActivity, IssueActivity,
IssueComment, IssueComment,
@ -33,6 +33,7 @@ class IssueActivityEndpoint(BaseAPIView):
] ]
@method_decorator(gzip_page) @method_decorator(gzip_page)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST, ROLE.VIEWER])
def get(self, request, slug, project_id, issue_id): def get(self, request, slug, project_id, issue_id):
filters = {} filters = {}
if request.GET.get("created_at__gt", None) is not None: if request.GET.get("created_at__gt", None) is not None:

View file

@ -25,9 +25,9 @@ from plane.app.permissions import (
from plane.app.serializers import ( from plane.app.serializers import (
IssueFlatSerializer, IssueFlatSerializer,
IssueSerializer, IssueSerializer,
IssueDetailSerializer IssueDetailSerializer,
) )
from plane.bgtasks.issue_activites_task import issue_activity from plane.bgtasks.issue_activities_task import issue_activity
from plane.db.models import ( from plane.db.models import (
Issue, Issue,
IssueAttachment, IssueAttachment,
@ -46,15 +46,13 @@ from plane.utils.paginator import (
GroupedOffsetPaginator, GroupedOffsetPaginator,
SubGroupedOffsetPaginator, SubGroupedOffsetPaginator,
) )
from plane.app.permissions import allow_permission, ROLE
# Module imports # Module imports
from .. import BaseViewSet, BaseAPIView from .. import BaseViewSet, BaseAPIView
class IssueArchiveViewSet(BaseViewSet): class IssueArchiveViewSet(BaseViewSet):
permission_classes = [
ProjectEntityPermission,
]
serializer_class = IssueFlatSerializer serializer_class = IssueFlatSerializer
model = Issue model = Issue
@ -98,6 +96,7 @@ class IssueArchiveViewSet(BaseViewSet):
) )
@method_decorator(gzip_page) @method_decorator(gzip_page)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER])
def list(self, request, slug, project_id): def list(self, request, slug, project_id):
filters = issue_filters(request.query_params, "GET") filters = issue_filters(request.query_params, "GET")
show_sub_issues = request.GET.get("show_sub_issues", "true") show_sub_issues = request.GET.get("show_sub_issues", "true")
@ -213,6 +212,7 @@ class IssueArchiveViewSet(BaseViewSet):
), ),
) )
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER])
def retrieve(self, request, slug, project_id, pk=None): def retrieve(self, request, slug, project_id, pk=None):
issue = ( issue = (
self.get_queryset() self.get_queryset()
@ -256,6 +256,7 @@ class IssueArchiveViewSet(BaseViewSet):
serializer = IssueDetailSerializer(issue, expand=self.expand) serializer = IssueDetailSerializer(issue, expand=self.expand)
return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.data, status=status.HTTP_200_OK)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def archive(self, request, slug, project_id, pk=None): def archive(self, request, slug, project_id, pk=None):
issue = Issue.issue_objects.get( issue = Issue.issue_objects.get(
workspace__slug=slug, workspace__slug=slug,
@ -294,6 +295,7 @@ class IssueArchiveViewSet(BaseViewSet):
{"archived_at": str(issue.archived_at)}, status=status.HTTP_200_OK {"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): def unarchive(self, request, slug, project_id, pk=None):
issue = Issue.objects.get( issue = Issue.objects.get(
workspace__slug=slug, workspace__slug=slug,
@ -325,6 +327,7 @@ class BulkArchiveIssuesEndpoint(BaseAPIView):
ProjectEntityPermission, ProjectEntityPermission,
] ]
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def post(self, request, slug, project_id): def post(self, request, slug, project_id):
issue_ids = request.data.get("issue_ids", []) issue_ids = request.data.get("issue_ids", [])
@ -343,7 +346,7 @@ class BulkArchiveIssuesEndpoint(BaseAPIView):
return Response( return Response(
{ {
"error_code": 4091, "error_code": 4091,
"error_message": "INVALID_ARCHIVE_STATE_GROUP" "error_message": "INVALID_ARCHIVE_STATE_GROUP",
}, },
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
) )

View file

@ -13,19 +13,17 @@ from rest_framework.parsers import MultiPartParser, FormParser
# Module imports # Module imports
from .. import BaseAPIView from .. import BaseAPIView
from plane.app.serializers import IssueAttachmentSerializer from plane.app.serializers import IssueAttachmentSerializer
from plane.app.permissions import ProjectEntityPermission from plane.db.models import IssueAttachment
from plane.db.models import IssueAttachment, ProjectMember from plane.bgtasks.issue_activities_task import issue_activity
from plane.bgtasks.issue_activites_task import issue_activity from plane.app.permissions import allow_permission, ROLE
class IssueAttachmentEndpoint(BaseAPIView): class IssueAttachmentEndpoint(BaseAPIView):
serializer_class = IssueAttachmentSerializer serializer_class = IssueAttachmentSerializer
permission_classes = [
ProjectEntityPermission,
]
model = IssueAttachment model = IssueAttachment
parser_classes = (MultiPartParser, FormParser) parser_classes = (MultiPartParser, FormParser)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def post(self, request, slug, project_id, issue_id): def post(self, request, slug, project_id, issue_id):
serializer = IssueAttachmentSerializer(data=request.data) serializer = IssueAttachmentSerializer(data=request.data)
if serializer.is_valid(): if serializer.is_valid():
@ -47,21 +45,9 @@ class IssueAttachmentEndpoint(BaseAPIView):
return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) 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): def delete(self, request, slug, project_id, issue_id, pk):
issue_attachment = IssueAttachment.objects.get(pk=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.asset.delete(save=False)
issue_attachment.delete() issue_attachment.delete()
issue_activity.delay( issue_activity.delay(
@ -78,6 +64,7 @@ class IssueAttachmentEndpoint(BaseAPIView):
return Response(status=status.HTTP_204_NO_CONTENT) 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): def get(self, request, slug, project_id, issue_id):
issue_attachments = IssueAttachment.objects.filter( issue_attachments = IssueAttachment.objects.filter(
issue_id=issue_id, workspace__slug=slug, project_id=project_id issue_id=issue_id, workspace__slug=slug, project_id=project_id

View file

@ -25,17 +25,14 @@ from rest_framework import status
from rest_framework.response import Response from rest_framework.response import Response
# Module imports # Module imports
from plane.app.permissions import ( from plane.app.permissions import allow_permission, ROLE
ProjectEntityPermission,
ProjectLitePermission,
)
from plane.app.serializers import ( from plane.app.serializers import (
IssueCreateSerializer, IssueCreateSerializer,
IssueDetailSerializer, IssueDetailSerializer,
IssueUserPropertySerializer, IssueUserPropertySerializer,
IssueSerializer, IssueSerializer,
) )
from plane.bgtasks.issue_activites_task import issue_activity from plane.bgtasks.issue_activities_task import issue_activity
from plane.db.models import ( from plane.db.models import (
Issue, Issue,
IssueAttachment, IssueAttachment,
@ -60,15 +57,10 @@ from plane.utils.paginator import (
from .. import BaseAPIView, BaseViewSet from .. import BaseAPIView, BaseViewSet
from plane.utils.user_timezone_converter import user_timezone_converter from plane.utils.user_timezone_converter import user_timezone_converter
# Module imports
class IssueListEndpoint(BaseAPIView): class IssueListEndpoint(BaseAPIView):
permission_classes = [ @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST])
ProjectEntityPermission,
]
def get(self, request, slug, project_id): def get(self, request, slug, project_id):
issue_ids = request.GET.get("issues", False) issue_ids = request.GET.get("issues", False)
@ -185,9 +177,6 @@ class IssueViewSet(BaseViewSet):
model = Issue model = Issue
webhook_event = "issue" webhook_event = "issue"
permission_classes = [
ProjectEntityPermission,
]
search_fields = [ search_fields = [
"name", "name",
@ -233,6 +222,7 @@ class IssueViewSet(BaseViewSet):
).distinct() ).distinct()
@method_decorator(gzip_page) @method_decorator(gzip_page)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST])
def list(self, request, slug, project_id): def list(self, request, slug, project_id):
filters = issue_filters(request.query_params, "GET") filters = issue_filters(request.query_params, "GET")
order_by_param = request.GET.get("order_by", "-created_at") order_by_param = request.GET.get("order_by", "-created_at")
@ -257,6 +247,15 @@ class IssueViewSet(BaseViewSet):
sub_group_by=sub_group_by, sub_group_by=sub_group_by,
) )
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 group_by:
if sub_group_by: if sub_group_by:
if group_by == sub_group_by: if group_by == sub_group_by:
@ -338,6 +337,7 @@ class IssueViewSet(BaseViewSet):
), ),
) )
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def create(self, request, slug, project_id): def create(self, request, slug, project_id):
project = Project.objects.get(pk=project_id) project = Project.objects.get(pk=project_id)
@ -412,6 +412,9 @@ class IssueViewSet(BaseViewSet):
return Response(issue, status=status.HTTP_201_CREATED) return Response(issue, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) 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): def retrieve(self, request, slug, project_id, pk=None):
issue = ( issue = (
self.get_queryset() self.get_queryset()
@ -438,7 +441,8 @@ class IssueViewSet(BaseViewSet):
ArrayAgg( ArrayAgg(
"issue_module__module_id", "issue_module__module_id",
distinct=True, distinct=True,
filter=~Q(issue_module__module_id__isnull=True), filter=~Q(issue_module__module_id__isnull=True)
& Q(issue_module__module__archived_at__isnull=True),
), ),
Value([], output_field=ArrayField(UUIDField())), Value([], output_field=ArrayField(UUIDField())),
), ),
@ -483,6 +487,7 @@ class IssueViewSet(BaseViewSet):
serializer = IssueDetailSerializer(issue, expand=self.expand) serializer = IssueDetailSerializer(issue, expand=self.expand)
return Response(serializer.data, status=status.HTTP_200_OK) 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): def partial_update(self, request, slug, project_id, pk=None):
issue = ( issue = (
self.get_queryset() self.get_queryset()
@ -548,23 +553,11 @@ class IssueViewSet(BaseViewSet):
return Response(status=status.HTTP_204_NO_CONTENT) return Response(status=status.HTTP_204_NO_CONTENT)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) 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): def destroy(self, request, slug, project_id, pk=None):
issue = Issue.objects.get( issue = Issue.objects.get(
workspace__slug=slug, project_id=project_id, pk=pk 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.delete()
issue_activity.delay( issue_activity.delay(
@ -582,10 +575,8 @@ class IssueViewSet(BaseViewSet):
class IssueUserDisplayPropertyEndpoint(BaseAPIView): class IssueUserDisplayPropertyEndpoint(BaseAPIView):
permission_classes = [
ProjectLitePermission,
]
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST, ROLE.VIEWER])
def patch(self, request, slug, project_id): def patch(self, request, slug, project_id):
issue_property = IssueUserProperty.objects.get( issue_property = IssueUserProperty.objects.get(
user=request.user, user=request.user,
@ -605,6 +596,7 @@ class IssueUserDisplayPropertyEndpoint(BaseAPIView):
serializer = IssueUserPropertySerializer(issue_property) serializer = IssueUserPropertySerializer(issue_property)
return Response(serializer.data, status=status.HTTP_201_CREATED) 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): def get(self, request, slug, project_id):
issue_property, _ = IssueUserProperty.objects.get_or_create( issue_property, _ = IssueUserProperty.objects.get_or_create(
user=request.user, project_id=project_id user=request.user, project_id=project_id
@ -614,23 +606,9 @@ class IssueUserDisplayPropertyEndpoint(BaseAPIView):
class BulkDeleteIssuesEndpoint(BaseAPIView): class BulkDeleteIssuesEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
@allow_permission([ROLE.ADMIN])
def delete(self, request, slug, project_id): 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", []) issue_ids = request.data.get("issue_ids", [])

View file

@ -20,7 +20,7 @@ from plane.db.models import (
IssueLabel, IssueLabel,
IssueAssignee, IssueAssignee,
) )
from plane.bgtasks.issue_activites_task import issue_activity from plane.bgtasks.issue_activities_task import issue_activity
class BulkIssueOperationsEndpoint(BaseAPIView): class BulkIssueOperationsEndpoint(BaseAPIView):
@ -59,10 +59,16 @@ class BulkIssueOperationsEndpoint(BaseAPIView):
properties = request.data.get("properties", {}) properties = request.data.get("properties", {})
if properties.get("start_date", False) and properties.get("target_date", False): if properties.get("start_date", False) and properties.get(
"target_date", False
):
if ( if (
datetime.strptime(properties.get("start_date"), "%Y-%m-%d").date() datetime.strptime(
> datetime.strptime(properties.get("target_date"), "%Y-%m-%d").date() properties.get("start_date"), "%Y-%m-%d"
).date()
> datetime.strptime(
properties.get("target_date"), "%Y-%m-%d"
).date()
): ):
return Response( return Response(
{ {
@ -73,7 +79,6 @@ class BulkIssueOperationsEndpoint(BaseAPIView):
) )
for issue in issues: for issue in issues:
# Priority # Priority
if properties.get("priority", False): if properties.get("priority", False):
bulk_issue_activities.append( bulk_issue_activities.append(

View file

@ -16,22 +16,19 @@ from plane.app.serializers import (
IssueCommentSerializer, IssueCommentSerializer,
CommentReactionSerializer, CommentReactionSerializer,
) )
from plane.app.permissions import ProjectLitePermission from plane.app.permissions import ProjectLitePermission, allow_permission, ROLE
from plane.db.models import ( from plane.db.models import (
IssueComment, IssueComment,
ProjectMember, ProjectMember,
CommentReaction, CommentReaction,
) )
from plane.bgtasks.issue_activites_task import issue_activity from plane.bgtasks.issue_activities_task import issue_activity
class IssueCommentViewSet(BaseViewSet): class IssueCommentViewSet(BaseViewSet):
serializer_class = IssueCommentSerializer serializer_class = IssueCommentSerializer
model = IssueComment model = IssueComment
webhook_event = "issue_comment" webhook_event = "issue_comment"
permission_classes = [
ProjectLitePermission,
]
filterset_fields = [ filterset_fields = [
"issue__id", "issue__id",
@ -66,6 +63,7 @@ class IssueCommentViewSet(BaseViewSet):
.distinct() .distinct()
) )
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST, ROLE.VIEWER])
def create(self, request, slug, project_id, issue_id): def create(self, request, slug, project_id, issue_id):
serializer = IssueCommentSerializer(data=request.data) serializer = IssueCommentSerializer(data=request.data)
if serializer.is_valid(): if serializer.is_valid():
@ -90,6 +88,11 @@ class IssueCommentViewSet(BaseViewSet):
return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) 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): def partial_update(self, request, slug, project_id, issue_id, pk):
issue_comment = IssueComment.objects.get( issue_comment = IssueComment.objects.get(
workspace__slug=slug, workspace__slug=slug,
@ -121,6 +124,9 @@ class IssueCommentViewSet(BaseViewSet):
return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) 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): def destroy(self, request, slug, project_id, issue_id, pk):
issue_comment = IssueComment.objects.get( issue_comment = IssueComment.objects.get(
workspace__slug=slug, workspace__slug=slug,

View file

@ -32,7 +32,7 @@ from plane.app.serializers import (
IssueFlatSerializer, IssueFlatSerializer,
IssueSerializer, IssueSerializer,
) )
from plane.bgtasks.issue_activites_task import issue_activity from plane.bgtasks.issue_activities_task import issue_activity
from plane.db.models import ( from plane.db.models import (
Issue, Issue,
IssueAttachment, IssueAttachment,

View file

@ -11,9 +11,7 @@ from rest_framework import status
# Module imports # Module imports
from .. import BaseViewSet, BaseAPIView from .. import BaseViewSet, BaseAPIView
from plane.app.serializers import LabelSerializer from plane.app.serializers import LabelSerializer
from plane.app.permissions import ( from plane.app.permissions import allow_permission, ProjectBasePermission, ROLE
ProjectMemberPermission,
)
from plane.db.models import ( from plane.db.models import (
Project, Project,
Label, Label,
@ -25,7 +23,7 @@ class LabelViewSet(BaseViewSet):
serializer_class = LabelSerializer serializer_class = LabelSerializer
model = Label model = Label
permission_classes = [ permission_classes = [
ProjectMemberPermission, ProjectBasePermission,
] ]
def get_queryset(self): def get_queryset(self):
@ -45,6 +43,7 @@ class LabelViewSet(BaseViewSet):
@invalidate_cache( @invalidate_cache(
path="/api/workspaces/:slug/labels/", url_params=True, user=False path="/api/workspaces/:slug/labels/", url_params=True, user=False
) )
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def create(self, request, slug, project_id): def create(self, request, slug, project_id):
try: try:
serializer = LabelSerializer(data=request.data) serializer = LabelSerializer(data=request.data)
@ -67,17 +66,20 @@ class LabelViewSet(BaseViewSet):
@invalidate_cache( @invalidate_cache(
path="/api/workspaces/:slug/labels/", url_params=True, user=False path="/api/workspaces/:slug/labels/", url_params=True, user=False
) )
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def partial_update(self, request, *args, **kwargs): def partial_update(self, request, *args, **kwargs):
return super().partial_update(request, *args, **kwargs) return super().partial_update(request, *args, **kwargs)
@invalidate_cache( @invalidate_cache(
path="/api/workspaces/:slug/labels/", url_params=True, user=False path="/api/workspaces/:slug/labels/", url_params=True, user=False
) )
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def destroy(self, request, *args, **kwargs): def destroy(self, request, *args, **kwargs):
return super().destroy(request, *args, **kwargs) return super().destroy(request, *args, **kwargs)
class BulkCreateIssueLabelsEndpoint(BaseAPIView): class BulkCreateIssueLabelsEndpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN])
def post(self, request, slug, project_id): def post(self, request, slug, project_id):
label_data = request.data.get("label_data", []) label_data = request.data.get("label_data", [])
project = Project.objects.get(pk=project_id) project = Project.objects.get(pk=project_id)

View file

@ -14,7 +14,7 @@ from .. import BaseViewSet
from plane.app.serializers import IssueLinkSerializer from plane.app.serializers import IssueLinkSerializer
from plane.app.permissions import ProjectEntityPermission from plane.app.permissions import ProjectEntityPermission
from plane.db.models import IssueLink from plane.db.models import IssueLink
from plane.bgtasks.issue_activites_task import issue_activity from plane.bgtasks.issue_activities_task import issue_activity
class IssueLinkViewSet(BaseViewSet): class IssueLinkViewSet(BaseViewSet):

View file

@ -14,7 +14,7 @@ from .. import BaseViewSet
from plane.app.serializers import IssueReactionSerializer from plane.app.serializers import IssueReactionSerializer
from plane.app.permissions import ProjectLitePermission from plane.app.permissions import ProjectLitePermission
from plane.db.models import IssueReaction from plane.db.models import IssueReaction
from plane.bgtasks.issue_activites_task import issue_activity from plane.bgtasks.issue_activities_task import issue_activity
class IssueReactionViewSet(BaseViewSet): class IssueReactionViewSet(BaseViewSet):

View file

@ -27,7 +27,7 @@ from plane.db.models import (
IssueAttachment, IssueAttachment,
IssueLink, IssueLink,
) )
from plane.bgtasks.issue_activites_task import issue_activity from plane.bgtasks.issue_activities_task import issue_activity
class IssueRelationViewSet(BaseViewSet): class IssueRelationViewSet(BaseViewSet):
@ -80,8 +80,7 @@ class IssueRelationViewSet(BaseViewSet):
).values_list("issue_id", flat=True) ).values_list("issue_id", flat=True)
queryset = ( queryset = (
Issue.issue_objects Issue.issue_objects.filter(workspace__slug=slug)
.filter(workspace__slug=slug)
.select_related("workspace", "project", "state", "parent") .select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module") .prefetch_related("assignees", "labels", "issue_module__module")
.annotate(cycle_id=F("issue_cycle__cycle_id")) .annotate(cycle_id=F("issue_cycle__cycle_id"))

View file

@ -30,7 +30,7 @@ from plane.db.models import (
IssueLink, IssueLink,
IssueAttachment, IssueAttachment,
) )
from plane.bgtasks.issue_activites_task import issue_activity from plane.bgtasks.issue_activities_task import issue_activity
from plane.utils.user_timezone_converter import user_timezone_converter from plane.utils.user_timezone_converter import user_timezone_converter
from collections import defaultdict from collections import defaultdict

View file

@ -30,8 +30,10 @@ from rest_framework.response import Response
# Module imports # Module imports
from plane.app.permissions import ( from plane.app.permissions import (
ProjectEntityPermission, ProjectEntityPermission,
ProjectLitePermission, allow_permission,
ROLE,
) )
from plane.app.serializers import ( from plane.app.serializers import (
ModuleDetailSerializer, ModuleDetailSerializer,
ModuleLinkSerializer, ModuleLinkSerializer,
@ -39,7 +41,7 @@ from plane.app.serializers import (
ModuleUserPropertiesSerializer, ModuleUserPropertiesSerializer,
ModuleWriteSerializer, ModuleWriteSerializer,
) )
from plane.bgtasks.issue_activites_task import issue_activity from plane.bgtasks.issue_activities_task import issue_activity
from plane.db.models import ( from plane.db.models import (
Issue, Issue,
Module, Module,
@ -48,7 +50,6 @@ from plane.db.models import (
ModuleLink, ModuleLink,
ModuleUserProperties, ModuleUserProperties,
Project, Project,
ProjectMember,
) )
from plane.utils.analytics_plot import burndown_plot from plane.utils.analytics_plot import burndown_plot
from plane.utils.user_timezone_converter import user_timezone_converter from plane.utils.user_timezone_converter import user_timezone_converter
@ -58,9 +59,6 @@ from .. import BaseAPIView, BaseViewSet
class ModuleViewSet(BaseViewSet): class ModuleViewSet(BaseViewSet):
model = Module model = Module
permission_classes = [
ProjectEntityPermission,
]
webhook_event = "module" webhook_event = "module"
def get_serializer_class(self): def get_serializer_class(self):
@ -318,6 +316,8 @@ class ModuleViewSet(BaseViewSet):
.order_by("-is_favorite", "-created_at") .order_by("-is_favorite", "-created_at")
) )
allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER])
def create(self, request, slug, project_id): def create(self, request, slug, project_id):
project = Project.objects.get(workspace__slug=slug, pk=project_id) project = Project.objects.get(workspace__slug=slug, pk=project_id)
serializer = ModuleWriteSerializer( serializer = ModuleWriteSerializer(
@ -380,6 +380,8 @@ class ModuleViewSet(BaseViewSet):
return Response(module, status=status.HTTP_201_CREATED) return Response(module, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) 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): def list(self, request, slug, project_id):
queryset = self.get_queryset().filter(archived_at__isnull=True) queryset = self.get_queryset().filter(archived_at__isnull=True)
if self.fields: if self.fields:
@ -427,6 +429,8 @@ class ModuleViewSet(BaseViewSet):
) )
return Response(modules, status=status.HTTP_200_OK) return Response(modules, status=status.HTTP_200_OK)
allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER])
def retrieve(self, request, slug, project_id, pk): def retrieve(self, request, slug, project_id, pk):
queryset = ( queryset = (
self.get_queryset() self.get_queryset()
@ -671,6 +675,7 @@ class ModuleViewSet(BaseViewSet):
status=status.HTTP_200_OK, status=status.HTTP_200_OK,
) )
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def partial_update(self, request, slug, project_id, pk): def partial_update(self, request, slug, project_id, pk):
module = self.get_queryset().filter(pk=pk) module = self.get_queryset().filter(pk=pk)
@ -740,25 +745,12 @@ class ModuleViewSet(BaseViewSet):
return Response(module, status=status.HTTP_200_OK) return Response(module, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) 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): def destroy(self, request, slug, project_id, pk):
module = Module.objects.get( module = Module.objects.get(
workspace__slug=slug, project_id=project_id, pk=pk 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( module_issues = list(
ModuleIssue.objects.filter(module_id=pk).values_list( ModuleIssue.objects.filter(module_id=pk).values_list(
"issue", flat=True "issue", flat=True
@ -859,10 +851,8 @@ class ModuleFavoriteViewSet(BaseViewSet):
class ModuleUserPropertiesEndpoint(BaseAPIView): 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): def patch(self, request, slug, project_id, module_id):
module_properties = ModuleUserProperties.objects.get( module_properties = ModuleUserProperties.objects.get(
user=request.user, user=request.user,
@ -885,6 +875,7 @@ class ModuleUserPropertiesEndpoint(BaseAPIView):
serializer = ModuleUserPropertiesSerializer(module_properties) serializer = ModuleUserPropertiesSerializer(module_properties)
return Response(serializer.data, status=status.HTTP_201_CREATED) 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): def get(self, request, slug, project_id, module_id):
module_properties, _ = ModuleUserProperties.objects.get_or_create( module_properties, _ = ModuleUserProperties.objects.get_or_create(
user=request.user, user=request.user,

View file

@ -17,13 +17,11 @@ from django.views.decorators.gzip import gzip_page
from rest_framework import status from rest_framework import status
from rest_framework.response import Response from rest_framework.response import Response
from plane.app.permissions import ( from plane.app.permissions import allow_permission, ROLE
ProjectEntityPermission,
)
from plane.app.serializers import ( from plane.app.serializers import (
ModuleIssueSerializer, ModuleIssueSerializer,
) )
from plane.bgtasks.issue_activites_task import issue_activity from plane.bgtasks.issue_activities_task import issue_activity
from plane.db.models import ( from plane.db.models import (
Issue, Issue,
IssueAttachment, IssueAttachment,
@ -46,6 +44,7 @@ from plane.utils.paginator import (
# Module imports # Module imports
from .. import BaseViewSet from .. import BaseViewSet
class ModuleIssueViewSet(BaseViewSet): class ModuleIssueViewSet(BaseViewSet):
serializer_class = ModuleIssueSerializer serializer_class = ModuleIssueSerializer
model = ModuleIssue model = ModuleIssue
@ -57,10 +56,6 @@ class ModuleIssueViewSet(BaseViewSet):
"issue__assignees__id", "issue__assignees__id",
] ]
permission_classes = [
ProjectEntityPermission,
]
def get_queryset(self): def get_queryset(self):
return ( return (
Issue.issue_objects.filter( Issue.issue_objects.filter(
@ -96,6 +91,7 @@ class ModuleIssueViewSet(BaseViewSet):
).distinct() ).distinct()
@method_decorator(gzip_page) @method_decorator(gzip_page)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER])
def list(self, request, slug, project_id, module_id): def list(self, request, slug, project_id, module_id):
filters = issue_filters(request.query_params, "GET") filters = issue_filters(request.query_params, "GET")
issue_queryset = self.get_queryset().filter(**filters) issue_queryset = self.get_queryset().filter(**filters)
@ -203,6 +199,7 @@ class ModuleIssueViewSet(BaseViewSet):
), ),
) )
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
# create multiple issues inside a module # create multiple issues inside a module
def create_module_issues(self, request, slug, project_id, module_id): def create_module_issues(self, request, slug, project_id, module_id):
issues = request.data.get("issues", []) issues = request.data.get("issues", [])
@ -244,6 +241,7 @@ class ModuleIssueViewSet(BaseViewSet):
] ]
return Response({"message": "success"}, status=status.HTTP_201_CREATED) 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 # add multiple module inside an issue and remove multiple modules from an issue
def create_issue_modules(self, request, slug, project_id, issue_id): def create_issue_modules(self, request, slug, project_id, issue_id):
modules = request.data.get("modules", []) modules = request.data.get("modules", [])
@ -306,6 +304,7 @@ class ModuleIssueViewSet(BaseViewSet):
return Response({"message": "success"}, status=status.HTTP_201_CREATED) 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): def destroy(self, request, slug, project_id, module_id, issue_id):
module_issue = ModuleIssue.objects.filter( module_issue = ModuleIssue.objects.filter(
workspace__slug=slug, workspace__slug=slug,

View file

@ -19,6 +19,7 @@ from plane.db.models import (
WorkspaceMember, WorkspaceMember,
) )
from plane.utils.paginator import BasePaginator from plane.utils.paginator import BasePaginator
from plane.app.permissions import allow_permission, ROLE
# Module imports # Module imports
from ..base import BaseAPIView, BaseViewSet from ..base import BaseAPIView, BaseViewSet
@ -39,6 +40,10 @@ class NotificationViewSet(BaseViewSet, BasePaginator):
.select_related("workspace", "project," "triggered_by", "receiver") .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): def list(self, request, slug):
# Get query parameters # Get query parameters
snoozed = request.GET.get("snoozed", "false") snoozed = request.GET.get("snoozed", "false")
@ -168,6 +173,10 @@ class NotificationViewSet(BaseViewSet, BasePaginator):
serializer = NotificationSerializer(notifications, many=True) serializer = NotificationSerializer(notifications, many=True)
return Response(serializer.data, status=status.HTTP_200_OK) 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): def partial_update(self, request, slug, pk):
notification = Notification.objects.get( notification = Notification.objects.get(
workspace__slug=slug, pk=pk, receiver=request.user workspace__slug=slug, pk=pk, receiver=request.user
@ -185,6 +194,9 @@ class NotificationViewSet(BaseViewSet, BasePaginator):
return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE"
)
def mark_read(self, request, slug, pk): def mark_read(self, request, slug, pk):
notification = Notification.objects.get( notification = Notification.objects.get(
receiver=request.user, workspace__slug=slug, pk=pk receiver=request.user, workspace__slug=slug, pk=pk
@ -194,6 +206,9 @@ class NotificationViewSet(BaseViewSet, BasePaginator):
serializer = NotificationSerializer(notification) serializer = NotificationSerializer(notification)
return Response(serializer.data, status=status.HTTP_200_OK) 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): def mark_unread(self, request, slug, pk):
notification = Notification.objects.get( notification = Notification.objects.get(
receiver=request.user, workspace__slug=slug, pk=pk receiver=request.user, workspace__slug=slug, pk=pk
@ -203,6 +218,9 @@ class NotificationViewSet(BaseViewSet, BasePaginator):
serializer = NotificationSerializer(notification) serializer = NotificationSerializer(notification)
return Response(serializer.data, status=status.HTTP_200_OK) 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): def archive(self, request, slug, pk):
notification = Notification.objects.get( notification = Notification.objects.get(
receiver=request.user, workspace__slug=slug, pk=pk receiver=request.user, workspace__slug=slug, pk=pk
@ -212,6 +230,9 @@ class NotificationViewSet(BaseViewSet, BasePaginator):
serializer = NotificationSerializer(notification) serializer = NotificationSerializer(notification)
return Response(serializer.data, status=status.HTTP_200_OK) 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): def unarchive(self, request, slug, pk):
notification = Notification.objects.get( notification = Notification.objects.get(
receiver=request.user, workspace__slug=slug, pk=pk receiver=request.user, workspace__slug=slug, pk=pk
@ -223,6 +244,11 @@ class NotificationViewSet(BaseViewSet, BasePaginator):
class UnreadNotificationEndpoint(BaseAPIView): class UnreadNotificationEndpoint(BaseAPIView):
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST],
level="WORKSPACE",
)
def get(self, request, slug): def get(self, request, slug):
# Watching Issues Count # Watching Issues Count
unread_notifications_count = ( unread_notifications_count = (
@ -260,6 +286,10 @@ class UnreadNotificationEndpoint(BaseAPIView):
class MarkAllReadNotificationViewSet(BaseViewSet): class MarkAllReadNotificationViewSet(BaseViewSet):
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE"
)
def create(self, request, slug): def create(self, request, slug):
snoozed = request.data.get("snoozed", False) snoozed = request.data.get("snoozed", False)
archived = request.data.get("archived", False) archived = request.data.get("archived", False)
@ -343,6 +373,9 @@ class UserNotificationPreferenceEndpoint(BaseAPIView):
serializer_class = UserNotificationPreferenceSerializer serializer_class = UserNotificationPreferenceSerializer
# request the object # request the object
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE"
)
def get(self, request): def get(self, request):
user_notification_preference = UserNotificationPreference.objects.get( user_notification_preference = UserNotificationPreference.objects.get(
user=request.user user=request.user
@ -353,6 +386,9 @@ class UserNotificationPreferenceEndpoint(BaseAPIView):
return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.data, status=status.HTTP_200_OK)
# update the object # update the object
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE"
)
def patch(self, request): def patch(self, request):
user_notification_preference = UserNotificationPreference.objects.get( user_notification_preference = UserNotificationPreference.objects.get(
user=request.user user=request.user

View file

@ -19,7 +19,7 @@ from rest_framework import status
from rest_framework.response import Response from rest_framework.response import Response
from plane.app.permissions import ProjectEntityPermission from plane.app.permissions import allow_permission, ROLE
from plane.app.serializers import ( from plane.app.serializers import (
PageLogSerializer, PageLogSerializer,
PageSerializer, PageSerializer,
@ -60,9 +60,6 @@ def unarchive_archive_page_and_descendants(page_id, archived_at):
class PageViewSet(BaseViewSet): class PageViewSet(BaseViewSet):
serializer_class = PageSerializer serializer_class = PageSerializer
model = Page model = Page
permission_classes = [
ProjectEntityPermission,
]
search_fields = [ search_fields = [
"name", "name",
] ]
@ -122,6 +119,7 @@ class PageViewSet(BaseViewSet):
.distinct() .distinct()
) )
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def create(self, request, slug, project_id): def create(self, request, slug, project_id):
serializer = PageSerializer( serializer = PageSerializer(
data=request.data, data=request.data,
@ -143,6 +141,7 @@ class PageViewSet(BaseViewSet):
return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) 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): def partial_update(self, request, slug, project_id, pk):
try: try:
page = Page.objects.get( page = Page.objects.get(
@ -208,6 +207,7 @@ class PageViewSet(BaseViewSet):
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
) )
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER])
def retrieve(self, request, slug, project_id, pk=None): def retrieve(self, request, slug, project_id, pk=None):
page = self.get_queryset().filter(pk=pk).first() page = self.get_queryset().filter(pk=pk).first()
if page is None: if page is None:
@ -226,6 +226,7 @@ class PageViewSet(BaseViewSet):
status=status.HTTP_200_OK, status=status.HTTP_200_OK,
) )
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def lock(self, request, slug, project_id, pk): def lock(self, request, slug, project_id, pk):
page = Page.objects.filter( page = Page.objects.filter(
pk=pk, workspace__slug=slug, projects__id=project_id pk=pk, workspace__slug=slug, projects__id=project_id
@ -235,6 +236,7 @@ class PageViewSet(BaseViewSet):
page.save() page.save()
return Response(status=status.HTTP_204_NO_CONTENT) return Response(status=status.HTTP_204_NO_CONTENT)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def unlock(self, request, slug, project_id, pk): def unlock(self, request, slug, project_id, pk):
page = Page.objects.filter( page = Page.objects.filter(
pk=pk, workspace__slug=slug, projects__id=project_id pk=pk, workspace__slug=slug, projects__id=project_id
@ -245,6 +247,7 @@ class PageViewSet(BaseViewSet):
return Response(status=status.HTTP_204_NO_CONTENT) return Response(status=status.HTTP_204_NO_CONTENT)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def access(self, request, slug, project_id, pk): def access(self, request, slug, project_id, pk):
access = request.data.get("access", 0) access = request.data.get("access", 0)
page = Page.objects.filter( page = Page.objects.filter(
@ -267,11 +270,13 @@ class PageViewSet(BaseViewSet):
page.save() page.save()
return Response(status=status.HTTP_204_NO_CONTENT) return Response(status=status.HTTP_204_NO_CONTENT)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER])
def list(self, request, slug, project_id): def list(self, request, slug, project_id):
queryset = self.get_queryset() queryset = self.get_queryset()
pages = PageSerializer(queryset, many=True).data pages = PageSerializer(queryset, many=True).data
return Response(pages, status=status.HTTP_200_OK) return Response(pages, status=status.HTTP_200_OK)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def archive(self, request, slug, project_id, pk): def archive(self, request, slug, project_id, pk):
page = Page.objects.get( page = Page.objects.get(
pk=pk, workspace__slug=slug, projects__id=project_id pk=pk, workspace__slug=slug, projects__id=project_id
@ -299,6 +304,7 @@ class PageViewSet(BaseViewSet):
status=status.HTTP_200_OK, status=status.HTTP_200_OK,
) )
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def unarchive(self, request, slug, project_id, pk): def unarchive(self, request, slug, project_id, pk):
page = Page.objects.get( page = Page.objects.get(
pk=pk, workspace__slug=slug, projects__id=project_id pk=pk, workspace__slug=slug, projects__id=project_id
@ -328,6 +334,7 @@ class PageViewSet(BaseViewSet):
return Response(status=status.HTTP_204_NO_CONTENT) return Response(status=status.HTTP_204_NO_CONTENT)
@allow_permission([ROLE.ADMIN], creator=True, model=Page)
def destroy(self, request, slug, project_id, pk): def destroy(self, request, slug, project_id, pk):
page = Page.objects.get( page = Page.objects.get(
pk=pk, workspace__slug=slug, projects__id=project_id pk=pk, workspace__slug=slug, projects__id=project_id
@ -370,12 +377,10 @@ class PageViewSet(BaseViewSet):
class PageFavoriteViewSet(BaseViewSet): class PageFavoriteViewSet(BaseViewSet):
permission_classes = [
ProjectEntityPermission,
]
model = UserFavorite model = UserFavorite
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def create(self, request, slug, project_id, pk): def create(self, request, slug, project_id, pk):
_ = UserFavorite.objects.create( _ = UserFavorite.objects.create(
project_id=project_id, project_id=project_id,
@ -385,6 +390,7 @@ class PageFavoriteViewSet(BaseViewSet):
) )
return Response(status=status.HTTP_204_NO_CONTENT) return Response(status=status.HTTP_204_NO_CONTENT)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def destroy(self, request, slug, project_id, pk): def destroy(self, request, slug, project_id, pk):
page_favorite = UserFavorite.objects.get( page_favorite = UserFavorite.objects.get(
project=project_id, project=project_id,
@ -398,9 +404,6 @@ class PageFavoriteViewSet(BaseViewSet):
class PageLogEndpoint(BaseAPIView): class PageLogEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
serializer_class = PageLogSerializer serializer_class = PageLogSerializer
model = PageLog model = PageLog
@ -440,9 +443,6 @@ class PageLogEndpoint(BaseAPIView):
class SubPagesEndpoint(BaseAPIView): class SubPagesEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
@method_decorator(gzip_page) @method_decorator(gzip_page)
def get(self, request, slug, project_id, page_id): def get(self, request, slug, project_id, page_id):
@ -461,10 +461,8 @@ class SubPagesEndpoint(BaseAPIView):
class PagesDescriptionViewSet(BaseViewSet): class PagesDescriptionViewSet(BaseViewSet):
permission_classes = [
ProjectEntityPermission,
]
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER])
def retrieve(self, request, slug, project_id, pk): def retrieve(self, request, slug, project_id, pk):
page = ( page = (
Page.objects.filter( Page.objects.filter(
@ -489,6 +487,7 @@ class PagesDescriptionViewSet(BaseViewSet):
) )
return response return response
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST])
def partial_update(self, request, slug, project_id, pk): def partial_update(self, request, slug, project_id, pk):
page = ( page = (
Page.objects.filter( Page.objects.filter(

View file

@ -31,8 +31,9 @@ from plane.app.serializers import (
) )
from plane.app.permissions import ( from plane.app.permissions import (
ProjectBasePermission,
ProjectMemberPermission, ProjectMemberPermission,
allow_permission,
ROLE,
) )
from plane.db.models import ( from plane.db.models import (
UserFavorite, UserFavorite,
@ -47,6 +48,7 @@ from plane.db.models import (
ProjectMember, ProjectMember,
State, State,
Workspace, Workspace,
WorkspaceMember,
) )
from plane.utils.cache import cache_response from plane.utils.cache import cache_response
from plane.bgtasks.webhook_task import model_activity from plane.bgtasks.webhook_task import model_activity
@ -57,10 +59,6 @@ class ProjectViewSet(BaseViewSet):
model = Project model = Project
webhook_event = "project" webhook_event = "project"
permission_classes = [
ProjectBasePermission,
]
def get_queryset(self): def get_queryset(self):
sort_order = ProjectMember.objects.filter( sort_order = ProjectMember.objects.filter(
member=self.request.user, member=self.request.user,
@ -155,6 +153,10 @@ class ProjectViewSet(BaseViewSet):
.distinct() .distinct()
) )
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST],
level="WORKSPACE",
)
def list(self, request, slug): def list(self, request, slug):
fields = [ fields = [
field field
@ -173,11 +175,27 @@ class ProjectViewSet(BaseViewSet):
projects, many=True projects, many=True
).data, ).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 = ProjectListSerializer(
projects, many=True, fields=fields if fields else None projects, many=True, fields=fields if fields else None
).data ).data
return Response(projects, status=status.HTTP_200_OK) 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): def retrieve(self, request, slug, pk):
project = ( project = (
self.get_queryset() self.get_queryset()
@ -249,6 +267,7 @@ class ProjectViewSet(BaseViewSet):
serializer = ProjectListSerializer(project) serializer = ProjectListSerializer(project)
return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.data, status=status.HTTP_200_OK)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
def create(self, request, slug): def create(self, request, slug):
try: try:
workspace = Workspace.objects.get(slug=slug) workspace = Workspace.objects.get(slug=slug)
@ -378,6 +397,7 @@ class ProjectViewSet(BaseViewSet):
status=status.HTTP_410_GONE, status=status.HTTP_410_GONE,
) )
@allow_permission([ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
def partial_update(self, request, slug, pk=None): def partial_update(self, request, slug, pk=None):
try: try:
workspace = Workspace.objects.get(slug=slug) workspace = Workspace.objects.get(slug=slug)
@ -459,10 +479,7 @@ class ProjectViewSet(BaseViewSet):
class ProjectArchiveUnarchiveEndpoint(BaseAPIView): class ProjectArchiveUnarchiveEndpoint(BaseAPIView):
permission_classes = [ @allow_permission([ROLE.ADMIN, ROLE.MEMBER])
ProjectBasePermission,
]
def post(self, request, slug, project_id): def post(self, request, slug, project_id):
project = Project.objects.get(pk=project_id, workspace__slug=slug) project = Project.objects.get(pk=project_id, workspace__slug=slug)
project.archived_at = timezone.now() project.archived_at = timezone.now()
@ -472,6 +489,7 @@ class ProjectArchiveUnarchiveEndpoint(BaseAPIView):
status=status.HTTP_200_OK, status=status.HTTP_200_OK,
) )
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def delete(self, request, slug, project_id): def delete(self, request, slug, project_id):
project = Project.objects.get(pk=project_id, workspace__slug=slug) project = Project.objects.get(pk=project_id, workspace__slug=slug)
project.archived_at = None project.archived_at = None
@ -480,10 +498,7 @@ class ProjectArchiveUnarchiveEndpoint(BaseAPIView):
class ProjectIdentifierEndpoint(BaseAPIView): class ProjectIdentifierEndpoint(BaseAPIView):
permission_classes = [ @allow_permission([ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
ProjectBasePermission,
]
def get(self, request, slug): def get(self, request, slug):
name = request.GET.get("name", "").strip().upper() name = request.GET.get("name", "").strip().upper()
@ -502,6 +517,7 @@ class ProjectIdentifierEndpoint(BaseAPIView):
status=status.HTTP_200_OK, status=status.HTTP_200_OK,
) )
@allow_permission([ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
def delete(self, request, slug): def delete(self, request, slug):
name = request.data.get("name", "").strip().upper() name = request.data.get("name", "").strip().upper()

View file

@ -23,17 +23,16 @@ from plane.db.models import (
Workspace, Workspace,
TeamMember, TeamMember,
IssueUserProperty, IssueUserProperty,
WorkspaceMember,
) )
from plane.bgtasks.project_add_user_email_task import project_add_user_email from plane.bgtasks.project_add_user_email_task import project_add_user_email
from plane.utils.host import base_host from plane.utils.host import base_host
from plane.app.permissions.base import allow_permission, ROLE
class ProjectMemberViewSet(BaseViewSet): class ProjectMemberViewSet(BaseViewSet):
serializer_class = ProjectMemberAdminSerializer serializer_class = ProjectMemberAdminSerializer
model = ProjectMember model = ProjectMember
permission_classes = [
ProjectMemberPermission,
]
def get_permissions(self): def get_permissions(self):
if self.action == "leave": if self.action == "leave":
@ -65,6 +64,7 @@ class ProjectMemberViewSet(BaseViewSet):
.select_related("workspace", "workspace__owner") .select_related("workspace", "workspace__owner")
) )
@allow_permission([ROLE.ADMIN])
def create(self, request, slug, project_id): 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 # 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", []) members = request.data.get("members", [])
@ -88,6 +88,23 @@ class ProjectMemberViewSet(BaseViewSet):
member.get("member_id"): member.get("role") for member in members 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 # 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( for project_member in ProjectMember.objects.filter(
project_id=project_id, project_id=project_id,
@ -172,6 +189,7 @@ class ProjectMemberViewSet(BaseViewSet):
# Return the serialized data # Return the serialized data
return Response(serializer.data, status=status.HTTP_201_CREATED) 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): def list(self, request, slug, project_id):
# Get the list of project members for the project # Get the list of project members for the project
project_members = ProjectMember.objects.filter( project_members = ProjectMember.objects.filter(
@ -186,6 +204,7 @@ class ProjectMemberViewSet(BaseViewSet):
) )
return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.data, status=status.HTTP_200_OK)
@allow_permission([ROLE.ADMIN])
def partial_update(self, request, slug, project_id, pk): def partial_update(self, request, slug, project_id, pk):
project_member = ProjectMember.objects.get( project_member = ProjectMember.objects.get(
pk=pk, pk=pk,
@ -205,6 +224,22 @@ class ProjectMemberViewSet(BaseViewSet):
member=request.user, member=request.user,
is_active=True, 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 ( if (
"role" in request.data "role" in request.data
and int(request.data.get("role", project_member.role)) and int(request.data.get("role", project_member.role))
@ -226,6 +261,7 @@ class ProjectMemberViewSet(BaseViewSet):
return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def destroy(self, request, slug, project_id, pk): def destroy(self, request, slug, project_id, pk):
project_member = ProjectMember.objects.get( project_member = ProjectMember.objects.get(
workspace__slug=slug, workspace__slug=slug,
@ -262,6 +298,7 @@ class ProjectMemberViewSet(BaseViewSet):
project_member.save() project_member.save()
return Response(status=status.HTTP_204_NO_CONTENT) 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): def leave(self, request, slug, project_id):
project_member = ProjectMember.objects.get( project_member = ProjectMember.objects.get(
workspace__slug=slug, workspace__slug=slug,

View file

@ -9,9 +9,7 @@ from rest_framework.response import Response
# Module imports # Module imports
from .base import BaseAPIView from .base import BaseAPIView
from plane.db.models import ( from plane.db.models import Issue, ProjectMember
Issue,
)
from plane.utils.issue_search import search_issues from plane.utils.issue_search import search_issues
@ -76,6 +74,16 @@ class IssueSearchEndpoint(BaseAPIView):
if target_date == "none": if target_date == "none":
issues = issues.filter(target_date__isnull=True) 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( return Response(
issues.values( issues.values(
"name", "name",

View file

@ -20,8 +20,8 @@ from django.db import transaction
from rest_framework.response import Response from rest_framework.response import Response
from plane.app.permissions import ( from plane.app.permissions import (
ProjectEntityPermission, allow_permission,
WorkspaceEntityPermission, ROLE,
) )
from plane.app.serializers import ( from plane.app.serializers import (
IssueViewSerializer, IssueViewSerializer,
@ -58,9 +58,6 @@ from plane.db.models import (
class WorkspaceViewViewSet(BaseViewSet): class WorkspaceViewViewSet(BaseViewSet):
serializer_class = IssueViewSerializer serializer_class = IssueViewSerializer
model = IssueView model = IssueView
permission_classes = [
WorkspaceEntityPermission,
]
def perform_create(self, serializer): def perform_create(self, serializer):
workspace = Workspace.objects.get(slug=self.kwargs.get("slug")) workspace = Workspace.objects.get(slug=self.kwargs.get("slug"))
@ -78,6 +75,32 @@ class WorkspaceViewViewSet(BaseViewSet):
.distinct() .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): def partial_update(self, request, slug, pk):
with transaction.atomic(): with transaction.atomic():
workspace_view = IssueView.objects.select_for_update().get( workspace_view = IssueView.objects.select_for_update().get(
@ -111,6 +134,12 @@ class WorkspaceViewViewSet(BaseViewSet):
serializer.errors, status=status.HTTP_400_BAD_REQUEST serializer.errors, status=status.HTTP_400_BAD_REQUEST
) )
@allow_permission(
allowed_roles=[ROLE.ADMIN],
level="WORKSPACE",
creator=True,
model=IssueView,
)
def destroy(self, request, slug, pk): def destroy(self, request, slug, pk):
workspace_view = IssueView.objects.get( workspace_view = IssueView.objects.get(
pk=pk, pk=pk,
@ -157,10 +186,6 @@ class WorkspaceViewViewSet(BaseViewSet):
class WorkspaceViewIssuesViewSet(BaseViewSet): class WorkspaceViewIssuesViewSet(BaseViewSet):
permission_classes = [
WorkspaceEntityPermission,
]
def get_queryset(self): def get_queryset(self):
return ( return (
Issue.issue_objects.annotate( Issue.issue_objects.annotate(
@ -223,7 +248,8 @@ class WorkspaceViewIssuesViewSet(BaseViewSet):
ArrayAgg( ArrayAgg(
"issue_module__module_id", "issue_module__module_id",
distinct=True, distinct=True,
filter=~Q(issue_module__module_id__isnull=True), filter=~Q(issue_module__module_id__isnull=True)
& Q(issue_module__module__archived_at__isnull=True),
), ),
Value([], output_field=ArrayField(UUIDField())), Value([], output_field=ArrayField(UUIDField())),
), ),
@ -231,6 +257,10 @@ class WorkspaceViewIssuesViewSet(BaseViewSet):
) )
@method_decorator(gzip_page) @method_decorator(gzip_page)
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST],
level="WORKSPACE",
)
def list(self, request, slug): def list(self, request, slug):
filters = issue_filters(request.query_params, "GET") filters = issue_filters(request.query_params, "GET")
order_by_param = request.GET.get("order_by", "-created_at") order_by_param = request.GET.get("order_by", "-created_at")
@ -241,6 +271,16 @@ class WorkspaceViewIssuesViewSet(BaseViewSet):
.annotate(cycle_id=F("issue_cycle__cycle_id")) .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
issue_queryset, order_by_param = order_issue_queryset( issue_queryset, order_by_param = order_issue_queryset(
issue_queryset=issue_queryset, issue_queryset=issue_queryset,
@ -347,9 +387,6 @@ class WorkspaceViewIssuesViewSet(BaseViewSet):
class IssueViewViewSet(BaseViewSet): class IssueViewViewSet(BaseViewSet):
serializer_class = IssueViewSerializer serializer_class = IssueViewSerializer
model = IssueView model = IssueView
permission_classes = [
ProjectEntityPermission,
]
def perform_create(self, serializer): def perform_create(self, serializer):
serializer.save( serializer.save(
@ -383,8 +420,20 @@ class IssueViewViewSet(BaseViewSet):
.distinct() .distinct()
) )
allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST]
)
def list(self, request, slug, project_id): def list(self, request, slug, project_id):
queryset = self.get_queryset() 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 = [ fields = [
field field
for field in request.GET.get("fields", "").split(",") for field in request.GET.get("fields", "").split(",")
@ -395,6 +444,8 @@ class IssueViewViewSet(BaseViewSet):
).data ).data
return Response(views, status=status.HTTP_200_OK) return Response(views, status=status.HTTP_200_OK)
allow_permission(allowed_roles=[], creator=True, model=IssueView)
def partial_update(self, request, slug, project_id, pk): def partial_update(self, request, slug, project_id, pk):
with transaction.atomic(): with transaction.atomic():
issue_view = IssueView.objects.select_for_update().get( issue_view = IssueView.objects.select_for_update().get(
@ -427,6 +478,8 @@ class IssueViewViewSet(BaseViewSet):
serializer.errors, status=status.HTTP_400_BAD_REQUEST 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): def destroy(self, request, slug, project_id, pk):
project_view = IssueView.objects.get( project_view = IssueView.objects.get(
pk=pk, pk=pk,
@ -471,6 +524,8 @@ class IssueViewFavoriteViewSet(BaseViewSet):
.select_related("view") .select_related("view")
) )
allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def create(self, request, slug, project_id): def create(self, request, slug, project_id):
_ = UserFavorite.objects.create( _ = UserFavorite.objects.create(
user=request.user, user=request.user,
@ -480,6 +535,8 @@ class IssueViewFavoriteViewSet(BaseViewSet):
) )
return Response(status=status.HTTP_204_NO_CONTENT) return Response(status=status.HTTP_204_NO_CONTENT)
allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def destroy(self, request, slug, project_id, view_id): def destroy(self, request, slug, project_id, view_id):
view_favorite = UserFavorite.objects.get( view_favorite = UserFavorite.objects.get(
project=project_id, project=project_id,

View file

@ -9,15 +9,13 @@ from rest_framework.response import Response
from plane.db.models import Webhook, WebhookLog, Workspace from plane.db.models import Webhook, WebhookLog, Workspace
from plane.db.models.webhook import generate_token from plane.db.models.webhook import generate_token
from ..base import BaseAPIView from ..base import BaseAPIView
from plane.app.permissions import WorkspaceOwnerPermission from plane.app.permissions import allow_permission, ROLE
from plane.app.serializers import WebhookSerializer, WebhookLogSerializer from plane.app.serializers import WebhookSerializer, WebhookLogSerializer
class WebhookEndpoint(BaseAPIView): class WebhookEndpoint(BaseAPIView):
permission_classes = [
WorkspaceOwnerPermission,
]
@allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE")
def post(self, request, slug): def post(self, request, slug):
workspace = Workspace.objects.get(slug=slug) workspace = Workspace.objects.get(slug=slug)
try: try:
@ -40,6 +38,7 @@ class WebhookEndpoint(BaseAPIView):
) )
raise IntegrityError raise IntegrityError
@allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE")
def get(self, request, slug, pk=None): def get(self, request, slug, pk=None):
if pk is None: if pk is None:
webhooks = Webhook.objects.filter(workspace__slug=slug) webhooks = Webhook.objects.filter(workspace__slug=slug)
@ -79,6 +78,7 @@ class WebhookEndpoint(BaseAPIView):
) )
return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.data, status=status.HTTP_200_OK)
@allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE")
def patch(self, request, slug, pk): def patch(self, request, slug, pk):
webhook = Webhook.objects.get(workspace__slug=slug, pk=pk) webhook = Webhook.objects.get(workspace__slug=slug, pk=pk)
serializer = WebhookSerializer( serializer = WebhookSerializer(
@ -104,6 +104,7 @@ class WebhookEndpoint(BaseAPIView):
return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE")
def delete(self, request, slug, pk): def delete(self, request, slug, pk):
webhook = Webhook.objects.get(pk=pk, workspace__slug=slug) webhook = Webhook.objects.get(pk=pk, workspace__slug=slug)
webhook.delete() webhook.delete()
@ -111,10 +112,8 @@ class WebhookEndpoint(BaseAPIView):
class WebhookSecretRegenerateEndpoint(BaseAPIView): class WebhookSecretRegenerateEndpoint(BaseAPIView):
permission_classes = [
WorkspaceOwnerPermission,
]
@allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE")
def post(self, request, slug, pk): def post(self, request, slug, pk):
webhook = Webhook.objects.get(workspace__slug=slug, pk=pk) webhook = Webhook.objects.get(workspace__slug=slug, pk=pk)
webhook.secret_key = generate_token() webhook.secret_key = generate_token()
@ -124,10 +123,8 @@ class WebhookSecretRegenerateEndpoint(BaseAPIView):
class WebhookLogsEndpoint(BaseAPIView): class WebhookLogsEndpoint(BaseAPIView):
permission_classes = [
WorkspaceOwnerPermission,
]
@allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE")
def get(self, request, slug, webhook_id): def get(self, request, slug, webhook_id):
webhook_logs = WebhookLog.objects.filter( webhook_logs = WebhookLog.objects.filter(
workspace__slug=slug, webhook_id=webhook_id workspace__slug=slug, webhook_id=webhook_id

View file

@ -9,14 +9,14 @@ from django.db.models import Q
from plane.app.views.base import BaseAPIView from plane.app.views.base import BaseAPIView
from plane.db.models import UserFavorite, Workspace from plane.db.models import UserFavorite, Workspace
from plane.app.serializers import UserFavoriteSerializer from plane.app.serializers import UserFavoriteSerializer
from plane.app.permissions import WorkspaceEntityPermission from plane.app.permissions import allow_permission, ROLE
class WorkspaceFavoriteEndpoint(BaseAPIView): class WorkspaceFavoriteEndpoint(BaseAPIView):
permission_classes = [
WorkspaceEntityPermission,
]
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE"
)
def get(self, request, slug): def get(self, request, slug):
# the second filter is to check if the user is a member of the project # the second filter is to check if the user is a member of the project
favorites = UserFavorite.objects.filter( favorites = UserFavorite.objects.filter(
@ -34,6 +34,9 @@ class WorkspaceFavoriteEndpoint(BaseAPIView):
serializer = UserFavoriteSerializer(favorites, many=True) serializer = UserFavoriteSerializer(favorites, many=True)
return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.data, status=status.HTTP_200_OK)
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE"
)
def post(self, request, slug): def post(self, request, slug):
workspace = Workspace.objects.get(slug=slug) workspace = Workspace.objects.get(slug=slug)
serializer = UserFavoriteSerializer(data=request.data) serializer = UserFavoriteSerializer(data=request.data)
@ -46,6 +49,9 @@ class WorkspaceFavoriteEndpoint(BaseAPIView):
return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) 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): def patch(self, request, slug, favorite_id):
favorite = UserFavorite.objects.get( favorite = UserFavorite.objects.get(
user=request.user, workspace__slug=slug, pk=favorite_id user=request.user, workspace__slug=slug, pk=favorite_id
@ -58,6 +64,9 @@ class WorkspaceFavoriteEndpoint(BaseAPIView):
return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) 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): def delete(self, request, slug, favorite_id):
favorite = UserFavorite.objects.get( favorite = UserFavorite.objects.get(
user=request.user, workspace__slug=slug, pk=favorite_id user=request.user, workspace__slug=slug, pk=favorite_id
@ -67,10 +76,10 @@ class WorkspaceFavoriteEndpoint(BaseAPIView):
class WorkspaceFavoriteGroupEndpoint(BaseAPIView): class WorkspaceFavoriteGroupEndpoint(BaseAPIView):
permission_classes = [
WorkspaceEntityPermission,
]
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE"
)
def get(self, request, slug, favorite_id): def get(self, request, slug, favorite_id):
favorites = UserFavorite.objects.filter( favorites = UserFavorite.objects.filter(
user=request.user, user=request.user,

View file

@ -29,6 +29,7 @@ from plane.authentication.adapter.error import (
AuthenticationException, AuthenticationException,
AUTHENTICATION_ERROR_CODES, AUTHENTICATION_ERROR_CODES,
) )
from plane.authentication.rate_limit import AuthenticationThrottle
class MagicGenerateEndpoint(APIView): class MagicGenerateEndpoint(APIView):
@ -37,6 +38,10 @@ class MagicGenerateEndpoint(APIView):
AllowAny, AllowAny,
] ]
throttle_classes = [
AuthenticationThrottle,
]
def post(self, request): def post(self, request):
# Check if instance is configured # Check if instance is configured
instance = Instance.objects.first() instance = Instance.objects.first()

View file

@ -10,7 +10,7 @@ from django.db.models import Q
from django.utils import timezone from django.utils import timezone
# Module imports # Module imports
from plane.bgtasks.issue_activites_task import issue_activity from plane.bgtasks.issue_activities_task import issue_activity
from plane.db.models import Issue, Project, State from plane.db.models import Issue, Project, State
from plane.utils.exception_logger import log_exception from plane.utils.exception_logger import log_exception

View file

@ -6,8 +6,23 @@ from django.core.management import BaseCommand
class Command(BaseCommand): class Command(BaseCommand):
help = "Clear Cache before starting the server to remove stale values" 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): def handle(self, *args, **options):
try: try:
if options["key"]:
cache.delete(options["key"])
self.stdout.write(
self.style.SUCCESS(
f"Cache Cleared for key: {options['key']}"
)
)
return
cache.clear() cache.clear()
self.stdout.write(self.style.SUCCESS("Cache Cleared")) self.stdout.write(self.style.SUCCESS("Cache Cleared"))
return return

View file

@ -0,0 +1,203 @@
# 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",
),
]

View file

@ -42,6 +42,7 @@ class FileAsset(BaseModel):
related_name="assets", related_name="assets",
) )
is_deleted = models.BooleanField(default=False) is_deleted = models.BooleanField(default=False)
is_archived = models.BooleanField(default=False)
class Meta: class Meta:
verbose_name = "File Asset" verbose_name = "File Asset"

View file

@ -40,6 +40,7 @@ class DeployBoard(WorkspaceBaseModel):
) )
is_votes_enabled = models.BooleanField(default=False) is_votes_enabled = models.BooleanField(default=False)
view_props = models.JSONField(default=dict) view_props = models.JSONField(default=dict)
is_activity_enabled = models.BooleanField(default=True)
def __str__(self): def __str__(self):
"""Return name of the deploy board""" """Return name of the deploy board"""

View file

@ -21,7 +21,7 @@ class UserFavorite(WorkspaceBaseModel):
entity_identifier = models.UUIDField(null=True, blank=True) entity_identifier = models.UUIDField(null=True, blank=True)
name = models.CharField(max_length=255, blank=True, null=True) name = models.CharField(max_length=255, blank=True, null=True)
is_folder = models.BooleanField(default=False) is_folder = models.BooleanField(default=False)
sequence = models.IntegerField(default=65535) sequence = models.FloatField(default=65535)
parent = models.ForeignKey( parent = models.ForeignKey(
"self", "self",
on_delete=models.CASCADE, on_delete=models.CASCADE,
@ -31,7 +31,12 @@ class UserFavorite(WorkspaceBaseModel):
) )
class Meta: class Meta:
unique_together = ["entity_type", "user", "entity_identifier", "deleted_at"] unique_together = [
"entity_type",
"user",
"entity_identifier",
"deleted_at",
]
constraints = [ constraints = [
models.UniqueConstraint( models.UniqueConstraint(
fields=["entity_type", "entity_identifier", "user"], fields=["entity_type", "entity_identifier", "user"],

View file

@ -3,43 +3,54 @@ from django.db import models
from django.db.models import Q from django.db.models import Q
# Module imports # Module imports
from .workspace import WorkspaceBaseModel from .project import ProjectBaseModel
from .base import BaseModel
class IssueType(WorkspaceBaseModel): class IssueType(BaseModel):
workspace = models.ForeignKey(
"db.Workspace",
related_name="issue_types",
on_delete=models.CASCADE,
)
name = models.CharField(max_length=255) name = models.CharField(max_length=255)
description = models.TextField(blank=True) description = models.TextField(blank=True)
logo_props = models.JSONField(default=dict) logo_props = models.JSONField(default=dict)
sort_order = models.FloatField(default=65535)
is_default = models.BooleanField(default=False) is_default = models.BooleanField(default=False)
weight = models.PositiveIntegerField(default=0)
is_active = models.BooleanField(default=True) is_active = models.BooleanField(default=True)
level = models.PositiveIntegerField(default=0)
class Meta: 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 = "Issue Type"
verbose_name_plural = "Issue Types" verbose_name_plural = "Issue Types"
db_table = "issue_types" db_table = "issue_types"
ordering = ("sort_order",)
def __str__(self): def __str__(self):
return self.name return self.name
def save(self, *args, **kwargs):
# If we are adding a new issue type, we need to set the sort order class ProjectIssueType(ProjectBaseModel):
if self._state.adding: issue_type = models.ForeignKey(
# Get the largest sort order for the project "db.IssueType",
largest_sort_order = IssueType.objects.filter( related_name="project_issue_types",
project=self.project on_delete=models.CASCADE,
).aggregate(largest=models.Max("sort_order"))["largest"] )
# If there are issue types, set the sort order to the largest + 10000 level = models.PositiveIntegerField(default=0)
if largest_sort_order is not None: is_default = models.BooleanField(default=False)
self.sort_order = largest_sort_order + 10000
super(IssueType, self).save(*args, **kwargs) 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}"

View file

@ -13,6 +13,7 @@ class InstanceSerializer(BaseSerializer):
model = Instance model = Instance
exclude = [ exclude = [
"license_key", "license_key",
"user_count"
] ]
read_only_fields = [ read_only_fields = [
"id", "id",

View file

@ -54,6 +54,7 @@ class InstanceEndpoint(BaseAPIView):
data["is_activated"] = True data["is_activated"] = True
# Get all the configuration # Get all the configuration
( (
ENABLE_SIGNUP,
IS_GOOGLE_ENABLED, IS_GOOGLE_ENABLED,
IS_GITHUB_ENABLED, IS_GITHUB_ENABLED,
GITHUB_APP_NAME, GITHUB_APP_NAME,
@ -70,6 +71,10 @@ class InstanceEndpoint(BaseAPIView):
INTERCOM_APP_ID, INTERCOM_APP_ID,
) = get_configuration_value( ) = get_configuration_value(
[ [
{
"key": "ENABLE_SIGNUP",
"default": os.environ.get("ENABLE_SIGNUP", "0"),
},
{ {
"key": "IS_GOOGLE_ENABLED", "key": "IS_GOOGLE_ENABLED",
"default": os.environ.get("IS_GOOGLE_ENABLED", "0"), "default": os.environ.get("IS_GOOGLE_ENABLED", "0"),
@ -132,6 +137,7 @@ class InstanceEndpoint(BaseAPIView):
data = {} data = {}
# Authentication # Authentication
data["enable_signup"] = ENABLE_SIGNUP == "1"
data["is_google_enabled"] = IS_GOOGLE_ENABLED == "1" data["is_google_enabled"] = IS_GOOGLE_ENABLED == "1"
data["is_github_enabled"] = IS_GITHUB_ENABLED == "1" data["is_github_enabled"] = IS_GITHUB_ENABLED == "1"
data["is_gitlab_enabled"] = IS_GITLAB_ENABLED == "1" data["is_gitlab_enabled"] = IS_GITLAB_ENABLED == "1"

View file

@ -27,7 +27,7 @@ from plane.app.serializers import (
IssueStateInboxSerializer, IssueStateInboxSerializer,
) )
from plane.utils.issue_filters import issue_filters from plane.utils.issue_filters import issue_filters
from plane.bgtasks.issue_activites_task import issue_activity from plane.bgtasks.issue_activities_task import issue_activity
class InboxIssuePublicViewSet(BaseViewSet): class InboxIssuePublicViewSet(BaseViewSet):

View file

@ -18,7 +18,7 @@ from django.db.models import (
JSONField, JSONField,
Value, Value,
OuterRef, OuterRef,
Func Func,
) )
# Third Party imports # Third Party imports
@ -61,7 +61,7 @@ from plane.db.models import (
ProjectPublicMember, ProjectPublicMember,
IssueAttachment, IssueAttachment,
) )
from plane.bgtasks.issue_activites_task import issue_activity from plane.bgtasks.issue_activities_task import issue_activity
from plane.utils.issue_filters import issue_filters from plane.utils.issue_filters import issue_filters

View file

@ -27,14 +27,11 @@ class ProjectStatesEndpoint(BaseAPIView):
status=status.HTTP_404_NOT_FOUND, status=status.HTTP_404_NOT_FOUND,
) )
states = ( states = State.objects.filter(
State.objects.filter(
~Q(name="Triage"), ~Q(name="Triage"),
workspace__slug=deploy_board.workspace.slug, workspace__slug=deploy_board.workspace.slug,
project_id=deploy_board.project_id, project_id=deploy_board.project_id,
) ).values("name", "group", "color", "id", "sequence")
.values("name", "group", "color", "id")
)
return Response( return Response(
states, states,

View file

@ -18,7 +18,6 @@ from plane.db.models import (
def issue_queryset_grouper(queryset, group_by, sub_group_by): def issue_queryset_grouper(queryset, group_by, sub_group_by):
FIELD_MAPPER = { FIELD_MAPPER = {
"label_ids": "labels__id", "label_ids": "labels__id",
"assignee_ids": "assignees__id", "assignee_ids": "assignees__id",
@ -30,7 +29,10 @@ def issue_queryset_grouper(queryset, group_by, sub_group_by):
"label_ids": ("labels__id", ~Q(labels__id__isnull=True)), "label_ids": ("labels__id", ~Q(labels__id__isnull=True)),
"module_ids": ( "module_ids": (
"issue_module__module_id", "issue_module__module_id",
~Q(issue_module__module_id__isnull=True), (
~Q(issue_module__module_id__isnull=True)
& Q(issue_module__module__archived_at__isnull=True)
),
), ),
} }
default_annotations = { default_annotations = {
@ -51,7 +53,6 @@ def issue_queryset_grouper(queryset, group_by, sub_group_by):
def issue_on_results(issues, group_by, sub_group_by): def issue_on_results(issues, group_by, sub_group_by):
FIELD_MAPPER = { FIELD_MAPPER = {
"labels__id": "label_ids", "labels__id": "label_ids",
"assignees__id": "assignee_ids", "assignees__id": "assignee_ids",

View file

@ -1,7 +1,7 @@
# base requirements # base requirements
# django # django
Django==4.2.14 Django==4.2.15
# rest framework # rest framework
djangorestframework==3.15.2 djangorestframework==3.15.2
# postgres # postgres

View file

@ -9,11 +9,20 @@ export DOCKERHUB_USER=makeplane
export PULL_POLICY=${PULL_POLICY:-if_not_present} export PULL_POLICY=${PULL_POLICY:-if_not_present}
CPU_ARCH=$(uname -m) CPU_ARCH=$(uname -m)
OS_NAME=$(uname)
UPPER_CPU_ARCH=$(tr '[:lower:]' '[:upper:]' <<< "$CPU_ARCH")
mkdir -p $PLANE_INSTALL_DIR/archive mkdir -p $PLANE_INSTALL_DIR/archive
DOCKER_FILE_PATH=$PLANE_INSTALL_DIR/docker-compose.yaml DOCKER_FILE_PATH=$PLANE_INSTALL_DIR/docker-compose.yaml
DOCKER_ENV_PATH=$PLANE_INSTALL_DIR/plane.env 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() { function print_header() {
clear clear
@ -51,12 +60,12 @@ function spinner() {
} }
function initialize(){ function initialize(){
printf "Please wait while we check the availability of Docker images for the selected release ($APP_RELEASE) with ${CPU_ARCH^^} support." >&2 printf "Please wait while we check the availability of Docker images for the selected release ($APP_RELEASE) with ${UPPER_CPU_ARCH} support." >&2
if [ "$CUSTOM_BUILD" == "true" ]; then if [ "$CUSTOM_BUILD" == "true" ]; then
echo "" >&2 echo "" >&2
echo "" >&2 echo "" >&2
echo "${CPU_ARCH^^} images are not available for selected release ($APP_RELEASE)." >&2 echo "${UPPER_CPU_ARCH} images are not available for selected release ($APP_RELEASE)." >&2
echo "build" echo "build"
return 1 return 1
fi fi
@ -78,7 +87,7 @@ function initialize(){
else else
echo "" >&2 echo "" >&2
echo "" >&2 echo "" >&2
echo "${CPU_ARCH^^} images are not available for selected release ($APP_RELEASE)." >&2 echo "${UPPER_CPU_ARCH} images are not available for selected release ($APP_RELEASE)." >&2
echo "" >&2 echo "" >&2
echo "build" echo "build"
return 1 return 1
@ -122,7 +131,7 @@ function updateEnvFile() {
return return
else else
# if key exists, update the value # if key exists, update the value
sed -i "s/^$key=.*/$key=$value/g" "$file" sed "${SED_PREFIX[@]}" "s/^$key=.*/$key=$value/g" "$file"
fi fi
else else
echo "File not found: $file" echo "File not found: $file"

View file

@ -1,10 +1,14 @@
import { Extensions } from "@tiptap/core";
import { SlashCommand } from "@/extensions"; import { SlashCommand } from "@/extensions";
// hooks // hooks
import { TFileHandler } from "@/hooks/use-editor"; import { TFileHandler } from "@/hooks/use-editor";
// plane editor types // plane editor types
import { TIssueEmbedConfig } from "@/plane-editor/types"; import { TIssueEmbedConfig } from "@/plane-editor/types";
// types
import { TExtensions } from "@/types";
type Props = { type Props = {
disabledExtensions?: TExtensions[];
fileHandler: TFileHandler; fileHandler: TFileHandler;
issueEmbedConfig: TIssueEmbedConfig | undefined; issueEmbedConfig: TIssueEmbedConfig | undefined;
}; };
@ -12,7 +16,7 @@ type Props = {
export const DocumentEditorAdditionalExtensions = (props: Props) => { export const DocumentEditorAdditionalExtensions = (props: Props) => {
const { fileHandler } = props; const { fileHandler } = props;
const extensions = [SlashCommand(fileHandler.upload)]; const extensions: Extensions = [SlashCommand(fileHandler.upload)];
return extensions; return extensions;
}; };

View file

@ -1,18 +1,30 @@
import React, { useState } from "react"; import React from "react";
// components // components
import { PageRenderer } from "@/components/editors"; import { PageRenderer } from "@/components/editors";
// constants
import { DEFAULT_DISPLAY_CONFIG } from "@/constants/config";
// helpers // helpers
import { getEditorClassNames } from "@/helpers/common"; import { getEditorClassNames } from "@/helpers/common";
// hooks // hooks
import { useDocumentEditor } from "@/hooks/use-document-editor"; import { useDocumentEditor } from "@/hooks/use-document-editor";
import { TFileHandler } from "@/hooks/use-editor";
// plane editor types // plane editor types
import { TEmbedConfig } from "@/plane-editor/types"; import { TEmbedConfig } from "@/plane-editor/types";
// types // types
import { EditorRefApi, IMentionHighlight, IMentionSuggestion } from "@/types"; import {
EditorRefApi,
IMentionHighlight,
IMentionSuggestion,
TAIHandler,
TDisplayConfig,
TExtensions,
TFileHandler,
} from "@/types";
interface IDocumentEditor { interface IDocumentEditor {
aiHandler?: TAIHandler;
containerClassName?: string; containerClassName?: string;
disabledExtensions?: TExtensions[];
displayConfig?: TDisplayConfig;
editorClassName?: string; editorClassName?: string;
embedHandler: TEmbedConfig; embedHandler: TEmbedConfig;
fileHandler: TFileHandler; fileHandler: TFileHandler;
@ -31,7 +43,10 @@ interface IDocumentEditor {
const DocumentEditor = (props: IDocumentEditor) => { const DocumentEditor = (props: IDocumentEditor) => {
const { const {
aiHandler,
containerClassName, containerClassName,
disabledExtensions,
displayConfig = DEFAULT_DISPLAY_CONFIG,
editorClassName = "", editorClassName = "",
embedHandler, embedHandler,
fileHandler, fileHandler,
@ -44,16 +59,10 @@ const DocumentEditor = (props: IDocumentEditor) => {
tabIndex, tabIndex,
value, value,
} = props; } = 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 // use document editor
const { editor, isIndexedDbSynced } = useDocumentEditor({ const { editor, isIndexedDbSynced } = useDocumentEditor({
disabledExtensions,
id, id,
editorClassName, editorClassName,
embedHandler, embedHandler,
@ -64,7 +73,6 @@ const DocumentEditor = (props: IDocumentEditor) => {
forwardedRef, forwardedRef,
mentionHandler, mentionHandler,
placeholder, placeholder,
setHideDragHandleFunction,
tabIndex, tabIndex,
}); });
@ -78,9 +86,10 @@ const DocumentEditor = (props: IDocumentEditor) => {
return ( return (
<PageRenderer <PageRenderer
displayConfig={displayConfig}
aiHandler={aiHandler}
editor={editor} editor={editor}
editorContainerClassName={editorContainerClassNames} editorContainerClassName={editorContainerClassNames}
hideDragHandle={hideDragHandleOnMouseLeave}
id={id} id={id}
tabIndex={tabIndex} tabIndex={tabIndex}
/> />

View file

@ -15,18 +15,21 @@ import { Editor, ReactRenderer } from "@tiptap/react";
// components // components
import { EditorContainer, EditorContentWrapper } from "@/components/editors"; import { EditorContainer, EditorContentWrapper } from "@/components/editors";
import { LinkView, LinkViewProps } from "@/components/links"; import { LinkView, LinkViewProps } from "@/components/links";
import { BlockMenu } from "@/components/menus"; import { AIFeaturesMenu, BlockMenu } from "@/components/menus";
// types
import { TAIHandler, TDisplayConfig } from "@/types";
type IPageRenderer = { type IPageRenderer = {
aiHandler?: TAIHandler;
displayConfig: TDisplayConfig;
editor: Editor; editor: Editor;
editorContainerClassName: string; editorContainerClassName: string;
hideDragHandle?: () => void;
id: string; id: string;
tabIndex?: number; tabIndex?: number;
}; };
export const PageRenderer = (props: IPageRenderer) => { export const PageRenderer = (props: IPageRenderer) => {
const { editor, editorContainerClassName, hideDragHandle, id, tabIndex } = props; const { aiHandler, displayConfig, editor, editorContainerClassName, id, tabIndex } = props;
// states // states
const [linkViewProps, setLinkViewProps] = useState<LinkViewProps>(); const [linkViewProps, setLinkViewProps] = useState<LinkViewProps>();
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
@ -130,13 +133,18 @@ export const PageRenderer = (props: IPageRenderer) => {
<> <>
<div className="frame-renderer flex-grow w-full -mx-5" onMouseOver={handleLinkHover}> <div className="frame-renderer flex-grow w-full -mx-5" onMouseOver={handleLinkHover}>
<EditorContainer <EditorContainer
displayConfig={displayConfig}
editor={editor} editor={editor}
editorContainerClassName={editorContainerClassName} editorContainerClassName={editorContainerClassName}
hideDragHandle={hideDragHandle}
id={id} id={id}
> >
<EditorContentWrapper editor={editor} id={id} tabIndex={tabIndex} /> <EditorContentWrapper editor={editor} id={id} tabIndex={tabIndex} />
{editor && editor.isEditable && <BlockMenu editor={editor} />} {editor.isEditable && (
<>
<BlockMenu editor={editor} />
<AIFeaturesMenu menu={aiHandler?.menu} />
</>
)}
</EditorContainer> </EditorContainer>
</div> </div>
{isOpen && linkViewProps && coordinates && ( {isOpen && linkViewProps && coordinates && (

View file

@ -1,6 +1,8 @@
import { forwardRef, MutableRefObject } from "react"; import { forwardRef, MutableRefObject } from "react";
// components // components
import { PageRenderer } from "@/components/editors"; import { PageRenderer } from "@/components/editors";
// constants
import { DEFAULT_DISPLAY_CONFIG } from "@/constants/config";
// extensions // extensions
import { IssueWidget } from "@/extensions"; import { IssueWidget } from "@/extensions";
// helpers // helpers
@ -10,12 +12,13 @@ import { useReadOnlyEditor } from "@/hooks/use-read-only-editor";
// plane web types // plane web types
import { TEmbedConfig } from "@/plane-editor/types"; import { TEmbedConfig } from "@/plane-editor/types";
// types // types
import { EditorReadOnlyRefApi, IMentionHighlight } from "@/types"; import { EditorReadOnlyRefApi, IMentionHighlight, TDisplayConfig } from "@/types";
interface IDocumentReadOnlyEditor { interface IDocumentReadOnlyEditor {
id: string; id: string;
initialValue: string; initialValue: string;
containerClassName: string; containerClassName: string;
displayConfig?: TDisplayConfig;
editorClassName?: string; editorClassName?: string;
embedHandler: TEmbedConfig; embedHandler: TEmbedConfig;
tabIndex?: number; tabIndex?: number;
@ -29,6 +32,7 @@ interface IDocumentReadOnlyEditor {
const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => { const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => {
const { const {
containerClassName, containerClassName,
displayConfig = DEFAULT_DISPLAY_CONFIG,
editorClassName = "", editorClassName = "",
embedHandler, embedHandler,
id, id,
@ -39,17 +43,17 @@ const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => {
mentionHandler, mentionHandler,
} = props; } = props;
const editor = useReadOnlyEditor({ const editor = useReadOnlyEditor({
initialValue,
editorClassName, editorClassName,
mentionHandler,
forwardedRef,
handleEditorReady,
extensions: [ extensions: [
embedHandler?.issue && embedHandler?.issue &&
IssueWidget({ IssueWidget({
widgetCallback: embedHandler?.issue.widgetCallback, widgetCallback: embedHandler?.issue.widgetCallback,
}), }),
], ],
forwardedRef,
handleEditorReady,
initialValue,
mentionHandler,
}); });
if (!editor) { if (!editor) {
@ -61,7 +65,13 @@ const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => {
}); });
return ( return (
<PageRenderer editor={editor} editorContainerClassName={editorContainerClassName} id={id} tabIndex={tabIndex} /> <PageRenderer
displayConfig={displayConfig}
editor={editor}
editorContainerClassName={editorContainerClassName}
id={id}
tabIndex={tabIndex}
/>
); );
}; };

View file

@ -1,18 +1,22 @@
import { FC, ReactNode } from "react"; import { FC, ReactNode } from "react";
import { Editor } from "@tiptap/react"; import { Editor } from "@tiptap/react";
// constants
import { DEFAULT_DISPLAY_CONFIG } from "@/constants/config";
// helpers // helpers
import { cn } from "@/helpers/common"; import { cn } from "@/helpers/common";
// types
import { TDisplayConfig } from "@/types";
interface EditorContainerProps { interface EditorContainerProps {
children: ReactNode; children: ReactNode;
displayConfig: TDisplayConfig;
editor: Editor | null; editor: Editor | null;
editorContainerClassName: string; editorContainerClassName: string;
hideDragHandle?: () => void;
id: string; id: string;
} }
export const EditorContainer: FC<EditorContainerProps> = (props) => { export const EditorContainer: FC<EditorContainerProps> = (props) => {
const { children, editor, editorContainerClassName, hideDragHandle, id } = props; const { children, displayConfig, editor, editorContainerClassName, id } = props;
const handleContainerClick = () => { const handleContainerClick = () => {
if (!editor) return; if (!editor) return;
@ -53,16 +57,25 @@ 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 ( return (
<div <div
id={`editor-container-${id}`} id={`editor-container-${id}`}
onClick={handleContainerClick} onClick={handleContainerClick}
onMouseLeave={hideDragHandle} onMouseLeave={handleContainerMouseLeave}
className={cn( className={cn(
"cursor-text relative", "editor-container cursor-text relative",
{ {
"active-editor": editor?.isFocused && editor?.isEditable, "active-editor": editor?.isFocused && editor?.isEditable,
}, },
displayConfig.fontSize ?? DEFAULT_DISPLAY_CONFIG.fontSize,
displayConfig.fontStyle ?? DEFAULT_DISPLAY_CONFIG.fontStyle,
editorContainerClassName editorContainerClassName
)} )}
> >

View file

@ -1,6 +1,8 @@
import { Editor, Extension } from "@tiptap/core"; import { Editor, Extension } from "@tiptap/core";
// components // components
import { EditorContainer } from "@/components/editors"; import { EditorContainer } from "@/components/editors";
// constants
import { DEFAULT_DISPLAY_CONFIG } from "@/constants/config";
// hooks // hooks
import { getEditorClassNames } from "@/helpers/common"; import { getEditorClassNames } from "@/helpers/common";
import { useEditor } from "@/hooks/use-editor"; import { useEditor } from "@/hooks/use-editor";
@ -11,16 +13,15 @@ import { EditorContentWrapper } from "./editor-content";
type Props = IEditorProps & { type Props = IEditorProps & {
children?: (editor: Editor) => React.ReactNode; children?: (editor: Editor) => React.ReactNode;
extensions: Extension<any, any>[]; extensions: Extension<any, any>[];
hideDragHandleOnMouseLeave: () => void;
}; };
export const EditorWrapper: React.FC<Props> = (props) => { export const EditorWrapper: React.FC<Props> = (props) => {
const { const {
children, children,
containerClassName, containerClassName,
displayConfig = DEFAULT_DISPLAY_CONFIG,
editorClassName = "", editorClassName = "",
extensions, extensions,
hideDragHandleOnMouseLeave,
id, id,
initialValue, initialValue,
fileHandler, fileHandler,
@ -57,10 +58,10 @@ export const EditorWrapper: React.FC<Props> = (props) => {
return ( return (
<EditorContainer <EditorContainer
displayConfig={displayConfig}
editor={editor} editor={editor}
editorContainerClassName={editorContainerClassName} editorContainerClassName={editorContainerClassName}
id={id} id={id}
hideDragHandle={hideDragHandleOnMouseLeave}
> >
{children?.(editor)} {children?.(editor)}
<div className="flex flex-col"> <div className="flex flex-col">

View file

@ -11,7 +11,7 @@ const LiteTextEditor = (props: ILiteTextEditor) => {
const extensions = [EnterKeyExtension(onEnterKeyPress)]; const extensions = [EnterKeyExtension(onEnterKeyPress)];
return <EditorWrapper {...props} extensions={extensions} hideDragHandleOnMouseLeave={() => {}} />; return <EditorWrapper {...props} extensions={extensions} />;
}; };
const LiteTextEditorWithRef = forwardRef<EditorRefApi, ILiteTextEditor>((props, ref) => ( const LiteTextEditorWithRef = forwardRef<EditorRefApi, ILiteTextEditor>((props, ref) => (

View file

@ -1,5 +1,7 @@
// components // components
import { EditorContainer, EditorContentWrapper } from "@/components/editors"; import { EditorContainer, EditorContentWrapper } from "@/components/editors";
// constants
import { DEFAULT_DISPLAY_CONFIG } from "@/constants/config";
// helpers // helpers
import { getEditorClassNames } from "@/helpers/common"; import { getEditorClassNames } from "@/helpers/common";
// hooks // hooks
@ -8,12 +10,20 @@ import { useReadOnlyEditor } from "@/hooks/use-read-only-editor";
import { IReadOnlyEditorProps } from "@/types"; import { IReadOnlyEditorProps } from "@/types";
export const ReadOnlyEditorWrapper = (props: IReadOnlyEditorProps) => { export const ReadOnlyEditorWrapper = (props: IReadOnlyEditorProps) => {
const { containerClassName, editorClassName = "", id, initialValue, forwardedRef, mentionHandler } = props; const {
containerClassName,
displayConfig = DEFAULT_DISPLAY_CONFIG,
editorClassName = "",
id,
initialValue,
forwardedRef,
mentionHandler,
} = props;
const editor = useReadOnlyEditor({ const editor = useReadOnlyEditor({
initialValue,
editorClassName, editorClassName,
forwardedRef, forwardedRef,
initialValue,
mentionHandler, mentionHandler,
}); });
@ -24,7 +34,12 @@ export const ReadOnlyEditorWrapper = (props: IReadOnlyEditorProps) => {
if (!editor) return null; if (!editor) return null;
return ( return (
<EditorContainer editor={editor} editorContainerClassName={editorContainerClassName} id={id}> <EditorContainer
displayConfig={displayConfig}
editor={editor}
editorContainerClassName={editorContainerClassName}
id={id}
>
<div className="flex flex-col"> <div className="flex flex-col">
<EditorContentWrapper editor={editor} id={id} /> <EditorContentWrapper editor={editor} id={id} />
</div> </div>

View file

@ -1,37 +1,30 @@
import { forwardRef, useCallback, useState } from "react"; import { forwardRef, useCallback } from "react";
// components // components
import { EditorWrapper } from "@/components/editors"; import { EditorWrapper } from "@/components/editors";
import { EditorBubbleMenu } from "@/components/menus"; import { EditorBubbleMenu } from "@/components/menus";
// extensions // extensions
import { DragAndDrop, SlashCommand } from "@/extensions"; import { SideMenuExtension, SlashCommand } from "@/extensions";
// types // types
import { EditorRefApi, IRichTextEditor } from "@/types"; import { EditorRefApi, IRichTextEditor } from "@/types";
const RichTextEditor = (props: IRichTextEditor) => { const RichTextEditor = (props: IRichTextEditor) => {
const { dragDropEnabled, fileHandler } = props; 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 getExtensions = useCallback(() => {
const extensions = [ const extensions = [SlashCommand(fileHandler.upload)];
SlashCommand(fileHandler.upload),
// TODO; add the extension conditionally for forms that don't require it
// EnterKeyExtension(onEnterKeyPress),
];
if (dragDropEnabled) extensions.push(DragAndDrop(setHideDragHandleFunction)); extensions.push(
SideMenuExtension({
aiEnabled: false,
dragDropEnabled: !!dragDropEnabled,
})
);
return extensions; return extensions;
}, [dragDropEnabled, fileHandler.upload]); }, [dragDropEnabled, fileHandler.upload]);
return ( return (
<EditorWrapper {...props} extensions={getExtensions()} hideDragHandleOnMouseLeave={hideDragHandleOnMouseLeave}> <EditorWrapper {...props} extensions={getExtensions()}>
{(editor) => <>{editor && <EditorBubbleMenu editor={editor} />}</>} {(editor) => <>{editor && <EditorBubbleMenu editor={editor} />}</>}
</EditorWrapper> </EditorWrapper>
); );

View file

@ -0,0 +1,95 @@
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?.({
onClose: hidePopup,
})}
</div>
</div>
);
};

View file

@ -14,7 +14,7 @@ export const BlockMenu = (props: BlockMenuProps) => {
const handleClickDragHandle = useCallback((event: MouseEvent) => { const handleClickDragHandle = useCallback((event: MouseEvent) => {
const target = event.target as HTMLElement; const target = event.target as HTMLElement;
if (target.matches(".drag-handle-dots") || target.matches(".drag-handle-dot")) { if (target.matches("#drag-handle")) {
event.preventDefault(); event.preventDefault();
popup.current?.setProps({ popup.current?.setProps({

View file

@ -1,3 +1,4 @@
export * from "./bubble-menu"; export * from "./bubble-menu";
export * from "./ai-menu";
export * from "./block-menu"; export * from "./block-menu";
export * from "./menu-items"; export * from "./menu-items";

View file

@ -0,0 +1,7 @@
// types
import { TDisplayConfig } from "@/types";
export const DEFAULT_DISPLAY_CONFIG: TDisplayConfig = {
fontSize: "large-font",
fontStyle: "sans-serif",
};

View file

@ -56,7 +56,7 @@ export const CodeBlockComponent: React.FC<CodeBlockComponentProps> = ({ node })
</Tooltip> </Tooltip>
<pre className="bg-custom-background-90 text-custom-text-100 rounded-lg p-4 my-2"> <pre className="bg-custom-background-90 text-custom-text-100 rounded-lg p-4 my-2">
<NodeViewContent as="code" className="whitespace-[pre-wrap]" /> <NodeViewContent as="code" className="whitespace-pre-wrap" />
</pre> </pre>
</NodeViewWrapper> </NodeViewWrapper>
); );

View file

@ -1,6 +1,6 @@
import { Extension } from "@tiptap/core"; import { Extension } from "@tiptap/core";
export const EnterKeyExtension = (onEnterKeyPress?: (descriptionHTML: string) => void) => export const EnterKeyExtension = (onEnterKeyPress?: () => void) =>
Extension.create({ Extension.create({
name: "enterKey", name: "enterKey",
@ -8,7 +8,9 @@ export const EnterKeyExtension = (onEnterKeyPress?: (descriptionHTML: string) =>
return { return {
Enter: () => { Enter: () => {
if (!this.editor.storage.mentionsOpen) { if (!this.editor.storage.mentionsOpen) {
onEnterKeyPress?.(this.editor.getHTML()); if (onEnterKeyPress) {
onEnterKeyPress();
}
return true; return true;
} }
return false; return false;

View file

@ -10,7 +10,6 @@ export * from "./typography";
export * from "./core-without-props"; export * from "./core-without-props";
export * from "./document-without-props"; export * from "./document-without-props";
export * from "./custom-code-inline"; export * from "./custom-code-inline";
export * from "./drag-drop";
export * from "./drop"; export * from "./drop";
export * from "./enter-key-extension"; export * from "./enter-key-extension";
export * from "./extensions"; export * from "./extensions";
@ -18,4 +17,5 @@ export * from "./horizontal-rule";
export * from "./keymap"; export * from "./keymap";
export * from "./quote"; export * from "./quote";
export * from "./read-only-extensions"; export * from "./read-only-extensions";
export * from "./side-menu";
export * from "./slash-commands"; export * from "./slash-commands";

View file

@ -0,0 +1,205 @@
import { Extension } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { EditorView } from "@tiptap/pm/view";
// plugins
import { AIHandlePlugin } from "@/plugins/ai-handle";
import { DragHandlePlugin } from "@/plugins/drag-handle";
type Props = {
aiEnabled: boolean;
dragDropEnabled: boolean;
};
export type SideMenuPluginProps = {
dragHandleWidth: number;
handlesConfig: {
ai: boolean;
dragDrop: boolean;
};
scrollThreshold: {
up: number;
down: number;
};
};
export type SideMenuHandleOptions = {
view: (view: EditorView, sideMenu: HTMLDivElement | null) => void;
domEvents?: {
[key: string]: (...args: any) => void;
};
};
export const SideMenuExtension = (props: Props) => {
const { aiEnabled, dragDropEnabled } = props;
return Extension.create({
name: "editorSideMenu",
addProseMirrorPlugins() {
return [
SideMenu({
dragHandleWidth: 24,
handlesConfig: {
ai: aiEnabled,
dragDrop: dragDropEnabled,
},
scrollThreshold: { up: 300, down: 100 },
}),
];
},
});
};
const absoluteRect = (node: Element) => {
const data = node.getBoundingClientRect();
return {
top: data.top,
left: data.left,
width: data.width,
};
};
const nodeDOMAtCoords = (coords: { x: number; y: number }) => {
const elements = document.elementsFromPoint(coords.x, coords.y);
const generalSelectors = [
"li",
"p:not(:first-child)",
".code-block",
"blockquote",
"img",
"h1, h2, h3, h4, h5, h6",
"[data-type=horizontalRule]",
".table-wrapper",
".issue-embed",
].join(", ");
for (const elem of elements) {
if (elem.matches("p:first-child") && elem.parentElement?.matches(".ProseMirror")) {
return elem;
}
// if the element is a <p> tag that is the first child of a td or th
if (
(elem.matches("td > p:first-child") || elem.matches("th > p:first-child")) &&
elem?.textContent?.trim() !== ""
) {
return elem; // Return only if p tag is not empty in td or th
}
// apply general selector
if (elem.matches(generalSelectors)) {
return elem;
}
}
return null;
};
const SideMenu = (options: SideMenuPluginProps) => {
const { handlesConfig } = options;
const editorSideMenu: HTMLDivElement | null = document.createElement("div");
editorSideMenu.id = "editor-side-menu";
// side menu view actions
const hideSideMenu = () => {
if (!editorSideMenu?.classList.contains("side-menu-hidden")) editorSideMenu?.classList.add("side-menu-hidden");
};
const showSideMenu = () => editorSideMenu?.classList.remove("side-menu-hidden");
// side menu elements
const { view: dragHandleView, domEvents: dragHandleDOMEvents } = DragHandlePlugin(options);
const { view: aiHandleView, domEvents: aiHandleDOMEvents } = AIHandlePlugin(options);
return new Plugin({
key: new PluginKey("sideMenu"),
view: (view) => {
hideSideMenu();
view?.dom.parentElement?.appendChild(editorSideMenu);
// side menu elements' initialization
if (handlesConfig.ai) {
aiHandleView(view, editorSideMenu);
}
if (handlesConfig.dragDrop) {
dragHandleView(view, editorSideMenu);
}
return {
destroy: () => hideSideMenu(),
};
},
props: {
handleDOMEvents: {
mousemove: (view, event) => {
if (!view.editable) return;
const node = nodeDOMAtCoords({
x: event.clientX + 50 + options.dragHandleWidth,
y: event.clientY,
});
if (!(node instanceof Element) || node.matches("ul, ol")) {
hideSideMenu();
return;
}
const compStyle = window.getComputedStyle(node);
const lineHeight = parseInt(compStyle.lineHeight, 10);
const paddingTop = parseInt(compStyle.paddingTop, 10);
const rect = absoluteRect(node);
rect.top += (lineHeight - 20) / 2;
rect.top += paddingTop;
if (node.parentElement?.parentElement?.matches("td") || node.parentElement?.parentElement?.matches("th")) {
if (node.matches("ul:not([data-type=taskList]) li, ol li")) {
rect.left -= 5;
}
} else {
// Li markers
if (node.matches("ul:not([data-type=taskList]) li, ol li")) {
rect.left -= 18;
}
}
if (node.matches(".table-wrapper")) {
rect.top += 8;
rect.left -= 8;
}
if (node.parentElement?.matches("td") || node.parentElement?.matches("th")) {
rect.left += 8;
}
rect.width = options.dragHandleWidth;
if (!editorSideMenu) return;
editorSideMenu.style.left = `${rect.left - rect.width}px`;
editorSideMenu.style.top = `${rect.top}px`;
showSideMenu();
if (handlesConfig.dragDrop) {
dragHandleDOMEvents?.mousemove();
}
if (handlesConfig.ai) {
aiHandleDOMEvents?.mousemove?.();
}
},
keydown: () => hideSideMenu(),
mousewheel: () => hideSideMenu(),
dragenter: (view) => {
if (handlesConfig.dragDrop) {
dragHandleDOMEvents?.dragenter?.(view);
}
},
drop: (view, event) => {
if (handlesConfig.dragDrop) {
dragHandleDOMEvents?.drop?.(view, event);
}
},
dragend: (view) => {
if (handlesConfig.dragDrop) {
dragHandleDOMEvents?.dragend?.(view);
}
},
},
},
});
};

View file

@ -3,9 +3,9 @@ import Collaboration from "@tiptap/extension-collaboration";
import { EditorProps } from "@tiptap/pm/view"; import { EditorProps } from "@tiptap/pm/view";
import * as Y from "yjs"; import * as Y from "yjs";
// extensions // extensions
import { DragAndDrop, IssueWidget } from "@/extensions"; import { IssueWidget, SideMenuExtension } from "@/extensions";
// hooks // hooks
import { TFileHandler, useEditor } from "@/hooks/use-editor"; import { useEditor } from "@/hooks/use-editor";
// plane editor extensions // plane editor extensions
import { DocumentEditorAdditionalExtensions } from "@/plane-editor/extensions"; import { DocumentEditorAdditionalExtensions } from "@/plane-editor/extensions";
// plane editor provider // plane editor provider
@ -13,9 +13,10 @@ import { CollaborationProvider } from "@/plane-editor/providers";
// plane editor types // plane editor types
import { TEmbedConfig } from "@/plane-editor/types"; import { TEmbedConfig } from "@/plane-editor/types";
// types // types
import { EditorRefApi, IMentionHighlight, IMentionSuggestion } from "@/types"; import { EditorRefApi, IMentionHighlight, IMentionSuggestion, TExtensions, TFileHandler } from "@/types";
type DocumentEditorProps = { type DocumentEditorProps = {
disabledExtensions?: TExtensions[];
editorClassName: string; editorClassName: string;
editorProps?: EditorProps; editorProps?: EditorProps;
embedHandler?: TEmbedConfig; embedHandler?: TEmbedConfig;
@ -29,13 +30,13 @@ type DocumentEditorProps = {
}; };
onChange: (updates: Uint8Array) => void; onChange: (updates: Uint8Array) => void;
placeholder?: string | ((isFocused: boolean, value: string) => string); placeholder?: string | ((isFocused: boolean, value: string) => string);
setHideDragHandleFunction: (hideDragHandlerFromDragDrop: () => void) => void;
tabIndex?: number; tabIndex?: number;
value: Uint8Array; value: Uint8Array;
}; };
export const useDocumentEditor = (props: DocumentEditorProps) => { export const useDocumentEditor = (props: DocumentEditorProps) => {
const { const {
disabledExtensions,
editorClassName, editorClassName,
editorProps = {}, editorProps = {},
embedHandler, embedHandler,
@ -46,7 +47,6 @@ export const useDocumentEditor = (props: DocumentEditorProps) => {
mentionHandler, mentionHandler,
onChange, onChange,
placeholder, placeholder,
setHideDragHandleFunction,
tabIndex, tabIndex,
value, value,
} = props; } = props;
@ -93,7 +93,10 @@ export const useDocumentEditor = (props: DocumentEditorProps) => {
forwardedRef, forwardedRef,
mentionHandler, mentionHandler,
extensions: [ extensions: [
DragAndDrop(setHideDragHandleFunction), SideMenuExtension({
aiEnabled: !disabledExtensions?.includes("ai"),
dragDropEnabled: true,
}),
embedHandler?.issue && embedHandler?.issue &&
IssueWidget({ IssueWidget({
widgetCallback: embedHandler.issue.widgetCallback, widgetCallback: embedHandler.issue.widgetCallback,
@ -102,6 +105,7 @@ export const useDocumentEditor = (props: DocumentEditorProps) => {
document: provider.document, document: provider.document,
}), }),
...DocumentEditorAdditionalExtensions({ ...DocumentEditorAdditionalExtensions({
disabledExtensions,
fileHandler, fileHandler,
issueEmbedConfig: embedHandler?.issue, issueEmbedConfig: embedHandler?.issue,
}), }),
@ -111,5 +115,8 @@ export const useDocumentEditor = (props: DocumentEditorProps) => {
tabIndex, tabIndex,
}); });
return { editor, isIndexedDbSynced }; return {
editor,
isIndexedDbSynced,
};
}; };

View file

@ -1,7 +1,8 @@
import { useImperativeHandle, useRef, MutableRefObject, useState, useEffect } from "react"; import { useImperativeHandle, useRef, MutableRefObject, useState, useEffect } from "react";
import { DOMSerializer } from "@tiptap/pm/model";
import { Selection } from "@tiptap/pm/state"; import { Selection } from "@tiptap/pm/state";
import { EditorProps } from "@tiptap/pm/view"; import { EditorProps } from "@tiptap/pm/view";
import { useEditor as useCustomEditor, Editor } from "@tiptap/react"; import { useEditor as useTiptapEditor, Editor } from "@tiptap/react";
// components // components
import { getEditorMenuItems } from "@/components/menus"; import { getEditorMenuItems } from "@/components/menus";
// extensions // extensions
@ -14,22 +15,7 @@ import { CollaborationProvider } from "@/plane-editor/providers";
// props // props
import { CoreEditorProps } from "@/props"; import { CoreEditorProps } from "@/props";
// types // types
import { import { EditorRefApi, IMentionHighlight, IMentionSuggestion, TEditorCommands, TFileHandler } from "@/types";
DeleteImage,
EditorRefApi,
IMentionHighlight,
IMentionSuggestion,
RestoreImage,
TEditorCommands,
UploadImage,
} from "@/types";
export type TFileHandler = {
cancel: () => void;
delete: DeleteImage;
upload: UploadImage;
restore: RestoreImage;
};
export interface CustomEditorProps { export interface CustomEditorProps {
editorClassName: string; editorClassName: string;
@ -54,7 +40,8 @@ export interface CustomEditorProps {
value?: string | null | undefined; value?: string | null | undefined;
} }
export const useEditor = ({ export const useEditor = (props: CustomEditorProps) => {
const {
editorClassName, editorClassName,
editorProps = {}, editorProps = {},
enableHistory, enableHistory,
@ -70,10 +57,13 @@ export const useEditor = ({
provider, provider,
tabIndex, tabIndex,
value, value,
}: CustomEditorProps) => { } = props;
const editor = useCustomEditor({
const editor = useTiptapEditor({
editorProps: { editorProps: {
...CoreEditorProps(editorClassName), ...CoreEditorProps({
editorClassName,
}),
...editorProps, ...editorProps,
}, },
extensions: [ extensions: [
@ -95,18 +85,10 @@ export const useEditor = ({
...extensions, ...extensions,
], ],
content: typeof initialValue === "string" && initialValue.trim() !== "" ? initialValue : "<p></p>", content: typeof initialValue === "string" && initialValue.trim() !== "" ? initialValue : "<p></p>",
onCreate: async () => { onCreate: () => handleEditorReady?.(true),
handleEditorReady?.(true); onTransaction: ({ editor }) => setSavedSelection(editor.state.selection),
}, onUpdate: ({ editor }) => onChange?.(editor.getJSON(), editor.getHTML()),
onTransaction: async ({ editor }) => { onDestroy: () => handleEditorReady?.(false),
setSavedSelection(editor.state.selection);
},
onUpdate: async ({ editor }) => {
onChange?.(editor.getJSON(), editor.getHTML());
},
onDestroy: async () => {
handleEditorReady?.(false);
},
}); });
const editorRef: MutableRefObject<Editor | null> = useRef(null); const editorRef: MutableRefObject<Editor | null> = useRef(null);
@ -232,6 +214,41 @@ export const useEditor = ({
console.error("An error occurred while setting focus at position:", error); console.error("An error occurred while setting focus at position:", error);
} }
}, },
getSelectedText: () => {
if (!editorRef.current) return null;
const { state } = editorRef.current;
const { from, to, empty } = state.selection;
if (empty) return null;
const nodesArray: string[] = [];
state.doc.nodesBetween(from, to, (node, pos, parent) => {
if (parent === state.doc && editorRef.current) {
const serializer = DOMSerializer.fromSchema(editorRef.current?.schema);
const dom = serializer.serializeNode(node);
const tempDiv = document.createElement("div");
tempDiv.appendChild(dom);
nodesArray.push(tempDiv.innerHTML);
}
});
const selection = nodesArray.join("");
console.log(selection);
return selection;
},
insertText: (contentHTML, insertOnNextLine) => {
if (!editor) return;
// get selection
const { from, to, empty } = editor.state.selection;
if (empty) return;
if (insertOnNextLine) {
// move cursor to the end of the selection and insert a new line
editor.chain().focus().setTextSelection(to).insertContent("<br />").insertContent(contentHTML).run();
} else {
// replace selected text with the content provided
editor.chain().focus().deleteRange({ from, to }).insertContent(contentHTML).run();
}
},
}), }),
[editorRef, savedSelection, fileHandler.upload] [editorRef, savedSelection, fileHandler.upload]
); );

View file

@ -35,7 +35,9 @@ export const useReadOnlyEditor = ({
editable: false, editable: false,
content: typeof initialValue === "string" && initialValue.trim() !== "" ? initialValue : "<p></p>", content: typeof initialValue === "string" && initialValue.trim() !== "" ? initialValue : "<p></p>",
editorProps: { editorProps: {
...CoreReadOnlyEditorProps(editorClassName), ...CoreReadOnlyEditorProps({
editorClassName,
}),
...editorProps, ...editorProps,
}, },
onCreate: async () => { onCreate: async () => {

View file

@ -0,0 +1,153 @@
import { NodeSelection } from "@tiptap/pm/state";
import { EditorView } from "@tiptap/pm/view";
// extensions
import { SideMenuHandleOptions, SideMenuPluginProps } from "@/extensions";
const sparklesIcon =
'<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-sparkles"><path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z"/><path d="M20 3v4"/><path d="M22 5h-4"/><path d="M4 17v2"/><path d="M5 18H3"/></svg>';
const nodeDOMAtCoords = (coords: { x: number; y: number }) => {
const elements = document.elementsFromPoint(coords.x, coords.y);
const generalSelectors = [
"li",
"p:not(:first-child)",
".code-block",
"blockquote",
"img",
"h1, h2, h3, h4, h5, h6",
"[data-type=horizontalRule]",
".table-wrapper",
".issue-embed",
].join(", ");
for (const elem of elements) {
if (elem.matches("p:first-child") && elem.parentElement?.matches(".ProseMirror")) {
return elem;
}
// if the element is a <p> tag that is the first child of a td or th
if (
(elem.matches("td > p:first-child") || elem.matches("th > p:first-child")) &&
elem?.textContent?.trim() !== ""
) {
return elem; // Return only if p tag is not empty in td or th
}
// apply general selector
if (elem.matches(generalSelectors)) {
return elem;
}
}
return null;
};
const nodePosAtDOM = (node: Element, view: EditorView, options: SideMenuPluginProps) => {
const boundingRect = node.getBoundingClientRect();
return view.posAtCoords({
left: boundingRect.left + 50 + options.dragHandleWidth,
top: boundingRect.top + 1,
})?.inside;
};
const nodePosAtDOMForBlockQuotes = (node: Element, view: EditorView) => {
const boundingRect = node.getBoundingClientRect();
return view.posAtCoords({
left: boundingRect.left + 1,
top: boundingRect.top + 1,
})?.inside;
};
const calcNodePos = (pos: number, view: EditorView, node: Element) => {
const maxPos = view.state.doc.content.size;
const safePos = Math.max(0, Math.min(pos, maxPos));
const $pos = view.state.doc.resolve(safePos);
if ($pos.depth > 1) {
if (node.matches("ul li, ol li")) {
// only for nested lists
const newPos = $pos.before($pos.depth);
return Math.max(0, Math.min(newPos, maxPos));
}
}
return safePos;
};
export const AIHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOptions => {
let aiHandleElement: HTMLButtonElement | null = null;
const handleClick = (event: MouseEvent, view: EditorView) => {
view.focus();
const node = nodeDOMAtCoords({
x: event.clientX + 50 + options.dragHandleWidth,
y: event.clientY,
});
if (!(node instanceof Element)) return;
if (node.matches("blockquote")) {
let nodePosForBlockQuotes = nodePosAtDOMForBlockQuotes(node, view);
if (nodePosForBlockQuotes === null || nodePosForBlockQuotes === undefined) return;
const docSize = view.state.doc.content.size;
nodePosForBlockQuotes = Math.max(0, Math.min(nodePosForBlockQuotes, docSize));
if (nodePosForBlockQuotes >= 0 && nodePosForBlockQuotes <= docSize) {
// TODO FIX ERROR
const nodeSelection = NodeSelection.create(view.state.doc, nodePosForBlockQuotes);
view.dispatch(view.state.tr.setSelection(nodeSelection));
}
return;
}
let nodePos = nodePosAtDOM(node, view, options);
if (nodePos === null || nodePos === undefined) return;
// Adjust the nodePos to point to the start of the node, ensuring NodeSelection can be applied
nodePos = calcNodePos(nodePos, view, node);
// TODO FIX ERROR
// Use NodeSelection to select the node at the calculated position
const nodeSelection = NodeSelection.create(view.state.doc, nodePos);
// Dispatch the transaction to update the selection
view.dispatch(view.state.tr.setSelection(nodeSelection));
};
const view = (view: EditorView, sideMenu: HTMLDivElement | null) => {
// create handle element
const className =
"grid place-items-center font-medium size-5 aspect-square text-xs text-custom-text-300 hover:bg-custom-background-80 rounded-sm opacity-100 !outline-none z-[5] transition-[background-color,_opacity] duration-200 ease-linear";
aiHandleElement = document.createElement("button");
aiHandleElement.type = "button";
aiHandleElement.id = "ai-handle";
aiHandleElement.classList.value = className;
const iconElement = document.createElement("span");
iconElement.classList.value = "pointer-events-none";
iconElement.innerHTML = sparklesIcon;
aiHandleElement.appendChild(iconElement);
// bind events
aiHandleElement.addEventListener("click", (e) => handleClick(e, view));
sideMenu?.appendChild(aiHandleElement);
return {
// destroy the handle element on un-initialize
destroy: () => {
aiHandleElement?.remove();
aiHandleElement = null;
},
};
};
const domEvents = {};
return {
view,
domEvents,
};
};

View file

@ -1,68 +1,35 @@
import { Extension } from "@tiptap/core";
import { Fragment, Slice, Node } from "@tiptap/pm/model"; import { Fragment, Slice, Node } from "@tiptap/pm/model";
import { NodeSelection, Plugin, PluginKey, TextSelection } from "@tiptap/pm/state"; import { NodeSelection, TextSelection } from "@tiptap/pm/state";
// @ts-expect-error __serializeForClipboard's is not exported // @ts-expect-error __serializeForClipboard's is not exported
import { __serializeForClipboard, EditorView } from "@tiptap/pm/view"; import { __serializeForClipboard, EditorView } from "@tiptap/pm/view";
// extensions
import { SideMenuHandleOptions, SideMenuPluginProps } from "@/extensions";
export interface DragHandleOptions { const verticalEllipsisIcon =
dragHandleWidth: number; '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-ellipsis-vertical"><circle cx="12" cy="12" r="1"/><circle cx="12" cy="5" r="1"/><circle cx="12" cy="19" r="1"/></svg>';
setHideDragHandle?: (hideDragHandlerFromDragDrop: () => void) => void;
scrollThreshold: {
up: number;
down: number;
};
}
export const DragAndDrop = (setHideDragHandle?: (hideDragHandlerFromDragDrop: () => void) => void) =>
Extension.create({
name: "dragAndDrop",
addProseMirrorPlugins() {
return [
DragHandle({
dragHandleWidth: 24,
scrollThreshold: { up: 300, down: 100 },
setHideDragHandle,
}),
];
},
});
const createDragHandleElement = (): HTMLElement => { const createDragHandleElement = (): HTMLElement => {
const dragHandleElement = document.createElement("button"); const dragHandleElement = document.createElement("button");
dragHandleElement.type = "button"; dragHandleElement.type = "button";
dragHandleElement.id = "drag-handle";
dragHandleElement.draggable = true; dragHandleElement.draggable = true;
dragHandleElement.dataset.dragHandle = ""; dragHandleElement.dataset.dragHandle = "";
dragHandleElement.classList.add("drag-handle"); dragHandleElement.classList.value =
"hidden sm:flex items-center size-5 aspect-square rounded-sm cursor-grab outline-none hover:bg-custom-background-80 active:bg-custom-background-80 active:cursor-grabbing transition-[background-color,_opacity] duration-200 ease-linear";
const dragHandleContainer = document.createElement("span"); const iconElement1 = document.createElement("span");
dragHandleContainer.classList.add("drag-handle-container"); iconElement1.classList.value = "pointer-events-none text-custom-text-300";
dragHandleElement.appendChild(dragHandleContainer); iconElement1.innerHTML = verticalEllipsisIcon;
const iconElement2 = document.createElement("span");
iconElement2.classList.value = "pointer-events-none text-custom-text-300 -ml-2.5";
iconElement2.innerHTML = verticalEllipsisIcon;
const dotsContainer = document.createElement("span"); dragHandleElement.appendChild(iconElement1);
dotsContainer.classList.add("drag-handle-dots"); dragHandleElement.appendChild(iconElement2);
for (let i = 0; i < 6; i++) {
const spanElement = document.createElement("span");
spanElement.classList.add("drag-handle-dot");
dotsContainer.appendChild(spanElement);
}
dragHandleContainer.appendChild(dotsContainer);
return dragHandleElement; return dragHandleElement;
}; };
const absoluteRect = (node: Element) => {
const data = node.getBoundingClientRect();
return {
top: data.top,
left: data.left,
width: data.width,
};
};
const nodeDOMAtCoords = (coords: { x: number; y: number }) => { const nodeDOMAtCoords = (coords: { x: number; y: number }) => {
const elements = document.elementsFromPoint(coords.x, coords.y); const elements = document.elementsFromPoint(coords.x, coords.y);
const generalSelectors = [ const generalSelectors = [
@ -98,7 +65,7 @@ const nodeDOMAtCoords = (coords: { x: number; y: number }) => {
return null; return null;
}; };
const nodePosAtDOM = (node: Element, view: EditorView, options: DragHandleOptions) => { const nodePosAtDOM = (node: Element, view: EditorView, options: SideMenuPluginProps) => {
const boundingRect = node.getBoundingClientRect(); const boundingRect = node.getBoundingClientRect();
return view.posAtCoords({ return view.posAtCoords({
@ -132,7 +99,7 @@ const calcNodePos = (pos: number, view: EditorView, node: Element) => {
return safePos; return safePos;
}; };
const DragHandle = (options: DragHandleOptions) => { export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOptions => {
let listType = ""; let listType = "";
const handleDragStart = (event: DragEvent, view: EditorView) => { const handleDragStart = (event: DragEvent, view: EditorView) => {
view.focus(); view.focus();
@ -222,15 +189,15 @@ const DragHandle = (options: DragHandleOptions) => {
if (!(node instanceof Element)) return; if (!(node instanceof Element)) return;
if (node.matches("blockquote")) { if (node.matches("blockquote")) {
let nodePosForBlockquotes = nodePosAtDOMForBlockQuotes(node, view); let nodePosForBlockQuotes = nodePosAtDOMForBlockQuotes(node, view);
if (nodePosForBlockquotes === null || nodePosForBlockquotes === undefined) return; if (nodePosForBlockQuotes === null || nodePosForBlockQuotes === undefined) return;
const docSize = view.state.doc.content.size; const docSize = view.state.doc.content.size;
nodePosForBlockquotes = Math.max(0, Math.min(nodePosForBlockquotes, docSize)); nodePosForBlockQuotes = Math.max(0, Math.min(nodePosForBlockQuotes, docSize));
if (nodePosForBlockquotes >= 0 && nodePosForBlockquotes <= docSize) { if (nodePosForBlockQuotes >= 0 && nodePosForBlockQuotes <= docSize) {
// TODO FIX ERROR // TODO FIX ERROR
const nodeSelection = NodeSelection.create(view.state.doc, nodePosForBlockquotes); const nodeSelection = NodeSelection.create(view.state.doc, nodePosForBlockQuotes);
view.dispatch(view.state.tr.setSelection(nodeSelection)); view.dispatch(view.state.tr.setSelection(nodeSelection));
} }
return; return;
@ -253,14 +220,13 @@ const DragHandle = (options: DragHandleOptions) => {
let dragHandleElement: HTMLElement | null = null; let dragHandleElement: HTMLElement | null = null;
// drag handle view actions // drag handle view actions
const hideDragHandle = () => dragHandleElement?.classList.add("drag-handle-hidden");
const showDragHandle = () => dragHandleElement?.classList.remove("drag-handle-hidden"); const showDragHandle = () => dragHandleElement?.classList.remove("drag-handle-hidden");
const hideDragHandle = () => {
if (!dragHandleElement?.classList.contains("drag-handle-hidden"))
dragHandleElement?.classList.add("drag-handle-hidden");
};
options.setHideDragHandle?.(hideDragHandle); const view = (view: EditorView, sideMenu: HTMLDivElement | null) => {
return new Plugin({
key: new PluginKey("dragHandle"),
view: (view) => {
dragHandleElement = createDragHandleElement(); dragHandleElement = createDragHandleElement();
dragHandleElement.addEventListener("dragstart", (e) => handleDragStart(e, view)); dragHandleElement.addEventListener("dragstart", (e) => handleDragStart(e, view));
dragHandleElement.addEventListener("click", (e) => handleClick(e, view)); dragHandleElement.addEventListener("click", (e) => handleClick(e, view));
@ -279,7 +245,7 @@ const DragHandle = (options: DragHandleOptions) => {
hideDragHandle(); hideDragHandle();
view?.dom?.parentElement?.appendChild(dragHandleElement); sideMenu?.appendChild(dragHandleElement);
return { return {
destroy: () => { destroy: () => {
@ -287,70 +253,14 @@ const DragHandle = (options: DragHandleOptions) => {
dragHandleElement = null; dragHandleElement = null;
}, },
}; };
}, };
props: { const domEvents = {
handleDOMEvents: { mousemove: () => showDragHandle(),
mousemove: (view, event) => { dragenter: (view: EditorView) => {
if (!view.editable) return;
const node = nodeDOMAtCoords({
x: event.clientX + 50 + options.dragHandleWidth,
y: event.clientY,
});
if (!(node instanceof Element) || node.matches("ul, ol")) {
hideDragHandle();
return;
}
const compStyle = window.getComputedStyle(node);
const lineHeight = parseInt(compStyle.lineHeight, 10);
const paddingTop = parseInt(compStyle.paddingTop, 10);
const rect = absoluteRect(node);
rect.top += (lineHeight - 20) / 2;
rect.top += paddingTop;
if (node.parentElement?.parentElement?.matches("td") || node.parentElement?.parentElement?.matches("th")) {
if (node.matches("ul:not([data-type=taskList]) li, ol li")) {
rect.left -= 5;
}
} else {
// Li markers
if (node.matches("ul:not([data-type=taskList]) li, ol li")) {
rect.left -= 18;
}
}
if (node.matches(".table-wrapper")) {
rect.top += 8;
rect.left -= 8;
}
if (node.parentElement?.matches("td") || node.parentElement?.matches("th")) {
rect.left += 8;
}
rect.width = options.dragHandleWidth;
if (!dragHandleElement) return;
dragHandleElement.style.left = `${rect.left - rect.width}px`;
dragHandleElement.style.top = `${rect.top}px`;
showDragHandle();
},
keydown: () => {
hideDragHandle();
},
mousewheel: () => {
hideDragHandle();
},
dragenter: (view) => {
view.dom.classList.add("dragging"); view.dom.classList.add("dragging");
hideDragHandle(); hideDragHandle();
}, },
drop: (view, event) => { drop: (view: EditorView, event: DragEvent) => {
view.dom.classList.remove("dragging"); view.dom.classList.remove("dragging");
hideDragHandle(); hideDragHandle();
let droppedNode: Node | null = null; let droppedNode: Node | null = null;
@ -395,10 +305,13 @@ const DragHandle = (options: DragHandleOptions) => {
view.dragging = { slice, move: event.ctrlKey }; view.dragging = { slice, move: event.ctrlKey };
} }
}, },
dragend: (view) => { dragend: (view: EditorView) => {
view.dom.classList.remove("dragging"); view.dom.classList.remove("dragging");
}, },
}, };
},
}); return {
view,
domEvents,
};
}; };

View file

@ -2,7 +2,13 @@ import { EditorProps } from "@tiptap/pm/view";
// helpers // helpers
import { cn } from "@/helpers/common"; import { cn } from "@/helpers/common";
export function CoreEditorProps(editorClassName: string): EditorProps { export type TCoreEditorProps = {
editorClassName: string;
};
export const CoreEditorProps = (props: TCoreEditorProps): EditorProps => {
const { editorClassName } = props;
return { return {
attributes: { attributes: {
class: cn( class: cn(
@ -25,4 +31,4 @@ export function CoreEditorProps(editorClassName: string): EditorProps {
return html.replace(/<img.*?>/g, ""); return html.replace(/<img.*?>/g, "");
}, },
}; };
} };

View file

@ -1,12 +1,18 @@
import { EditorProps } from "@tiptap/pm/view"; import { EditorProps } from "@tiptap/pm/view";
// helpers // helpers
import { cn } from "@/helpers/common"; import { cn } from "@/helpers/common";
// props
import { TCoreEditorProps } from "@/props";
export const CoreReadOnlyEditorProps = (editorClassName: string): EditorProps => ({ export const CoreReadOnlyEditorProps = (props: TCoreEditorProps): EditorProps => {
const { editorClassName } = props;
return {
attributes: { attributes: {
class: cn( class: cn(
"prose prose-brand max-w-full prose-headings:font-display font-default focus:outline-none", "prose prose-brand max-w-full prose-headings:font-display font-default focus:outline-none",
editorClassName editorClassName
), ),
}, },
}); };
};

View file

@ -0,0 +1,7 @@
type TMenuProps = {
onClose: () => void;
};
export type TAIHandler = {
menu?: (props: TMenuProps) => React.ReactNode;
};

View file

@ -0,0 +1,17 @@
import { DeleteImage, RestoreImage, UploadImage } from "@/types";
export type TFileHandler = {
cancel: () => void;
delete: DeleteImage;
upload: UploadImage;
restore: RestoreImage;
};
export type TEditorFontStyle = "sans-serif" | "serif" | "monospace";
export type TEditorFontSize = "small-font" | "large-font";
export type TDisplayConfig = {
fontStyle?: TEditorFontStyle;
fontSize?: TEditorFontSize;
};

View file

@ -1,9 +1,7 @@
// helpers // helpers
import { IMarking } from "@/helpers/scroll-to-node"; import { IMarking } from "@/helpers/scroll-to-node";
// hooks
import { TFileHandler } from "@/hooks/use-editor";
// types // types
import { IMentionHighlight, IMentionSuggestion, TEditorCommands } from "@/types"; import { IMentionHighlight, IMentionSuggestion, TDisplayConfig, TEditorCommands, TFileHandler } from "@/types";
export type EditorReadOnlyRefApi = { export type EditorReadOnlyRefApi = {
getMarkDown: () => string; getMarkDown: () => string;
@ -22,10 +20,13 @@ export interface EditorRefApi extends EditorReadOnlyRefApi {
isEditorReadyToDiscard: () => boolean; isEditorReadyToDiscard: () => boolean;
setSynced: () => void; setSynced: () => void;
hasUnsyncedChanges: () => boolean; hasUnsyncedChanges: () => boolean;
getSelectedText: () => string | null;
insertText: (contentHTML: string, insertOnNextLine?: boolean) => void;
} }
export interface IEditorProps { export interface IEditorProps {
containerClassName?: string; containerClassName?: string;
displayConfig?: TDisplayConfig;
editorClassName?: string; editorClassName?: string;
fileHandler: TFileHandler; fileHandler: TFileHandler;
forwardedRef?: React.MutableRefObject<EditorRefApi | null>; forwardedRef?: React.MutableRefObject<EditorRefApi | null>;
@ -36,7 +37,7 @@ export interface IEditorProps {
suggestions?: () => Promise<IMentionSuggestion[]>; suggestions?: () => Promise<IMentionSuggestion[]>;
}; };
onChange?: (json: object, html: string) => void; onChange?: (json: object, html: string) => void;
onEnterKeyPress?: (descriptionHTML: string) => void; onEnterKeyPress?: (e?: any) => void;
placeholder?: string | ((isFocused: boolean, value: string) => string); placeholder?: string | ((isFocused: boolean, value: string) => string);
tabIndex?: number; tabIndex?: number;
value?: string | null; value?: string | null;
@ -50,6 +51,7 @@ export interface IRichTextEditor extends IEditorProps {
export interface IReadOnlyEditorProps { export interface IReadOnlyEditorProps {
containerClassName?: string; containerClassName?: string;
displayConfig?: TDisplayConfig;
editorClassName?: string; editorClassName?: string;
forwardedRef?: React.MutableRefObject<EditorReadOnlyRefApi | null>; forwardedRef?: React.MutableRefObject<EditorReadOnlyRefApi | null>;
id: string; id: string;

View file

@ -0,0 +1 @@
export type TExtensions = "ai" | "issue-embed";

View file

@ -1,5 +1,8 @@
export * from "./ai";
export * from "./config";
export * from "./editor"; export * from "./editor";
export * from "./embed"; export * from "./embed";
export * from "./extensions";
export * from "./image"; export * from "./image";
export * from "./mention-suggestion"; export * from "./mention-suggestion";
export * from "./slash-commands-suggestion"; export * from "./slash-commands-suggestion";

View file

@ -20,7 +20,8 @@ export type TEditorCommands =
| "code" | "code"
| "table" | "table"
| "image" | "image"
| "divider"; | "divider"
| "issue-embed";
export type CommandProps = { export type CommandProps = {
editor: Editor; editor: Editor;

View file

@ -34,5 +34,5 @@ export { type IMarking, useEditorMarkings } from "@/hooks/use-editor-markings";
export { useReadOnlyEditor } from "@/hooks/use-read-only-editor"; export { useReadOnlyEditor } from "@/hooks/use-read-only-editor";
// types // types
export type { CustomEditorProps, TFileHandler } from "@/hooks/use-editor"; export type { CustomEditorProps } from "@/hooks/use-editor";
export * from "@/types"; export * from "@/types";

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